Files
pdfcraft/crates/engine/src/tests.rs
T

7346 lines
406 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::sync::Arc;
use super::*;
/// A minimal `n`-page document; page `i` shows "Page i+1".
pub(crate) fn fixture(n: usize) -> Vec<u8> {
let mut objs: Vec<Vec<u8>> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()];
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect();
objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" ")).into_bytes());
objs.push(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec());
for i in 0..n {
objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i).into_bytes());
let body = format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1);
objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes());
}
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
fn session_with(n: usize) -> (Session, DocId) {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("fixture.pdf", None, Arc::new(fixture(n)), None).expect("opens");
(s, id)
}
#[test]
fn prepared_open_does_not_mutate_the_session_until_commit() {
fn assert_send<T: Send>() {}
assert_send::<crate::PreparedDocument>();
let mut session = Session::new();
let prepared = session.prepare_open("prepared.pdf", None, Arc::new(fixture(3)), None).expect("prepares");
assert!(session.get(DocId(1)).is_none());
let id = session.commit_open(prepared);
assert_eq!(id, DocId(1));
assert_eq!(session.get(id).expect("committed document").info.pages.len(), 3);
}
#[test]
fn custom_image_stamp_keeps_displayed_orientation_on_rotated_pages() {
let colours = [[240, 20, 20, 255], [20, 180, 20, 255], [20, 20, 240, 255], [230, 180, 20, 255]];
let image = image::RgbaImage::from_fn(80, 40, |x, y| image::Rgba(colours[usize::from(y >= 20) * 2 + usize::from(x >= 40)]));
let mut png = std::io::Cursor::new(Vec::new());
image.write_to(&mut png, image::ImageFormat::Png).unwrap();
let bytes = Arc::new(png.into_inner());
for degrees in [0, 90, 180, 270] {
let (mut session, id) = session_with(1);
session.apply(id, Edit::RotatePages { pages: vec![0], degrees }).unwrap();
let at = session.get(id).unwrap().info.pages[0].view_to_user(100.0, 100.0).map(f64::from);
session
.apply(
id,
Edit::AddCustomStamp {
page: 0,
rect: [at[0], at[1], at[0], at[1]],
name: "Original quadrants".into(),
file: MarkFile { name: "quadrants.png".into(), bytes: bytes.clone(), page: 0 },
author: "Test".into(),
},
)
.unwrap();
let check = |session: &Session, points: [(u32, u32); 4]| {
let doc = session.get(id).unwrap();
let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone());
let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
assert!(rendered.error.is_none(), "{:?}", rendered.error);
for ((x, y), colour) in points.into_iter().zip(colours) {
let offset = ((y * rendered.width + x) * 4) as usize;
assert_eq!(&rendered.rgba[offset..offset + 4], &colour, "page rotation {degrees}; displayed point ({x}, {y})");
}
};
let points = [(80, 90), (120, 90), (80, 110), (120, 110)];
check(&session, points);
let info = session.get(id).unwrap().info.pages[0].clone();
let rect = session.get(id).unwrap().info.annotations[0].rect;
let a = info.user_to_view(rect[0], rect[1]);
let b = info.user_to_view(rect[2], rect[3]);
let bounds = [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])];
for (actual, expected) in bounds.into_iter().zip([60.0, 80.0, 140.0, 120.0]) {
assert!((actual - expected).abs() < 0.0001, "displayed bounds {bounds:?}");
}
session.apply(id, Edit::StyleAnnotation { page: 0, index: 0, color: None, opacity: Some(1.0), width: None, endings: None }).unwrap();
check(&session, points);
let resized = info.view_rect_to_user([40.0, 70.0, 160.0, 130.0]).map(f64::from);
session.apply(id, Edit::ResizeAnnotation { page: 0, index: 0, rect: resized }).unwrap();
let resized_points = [(70, 85), (130, 85), (70, 115), (130, 115)];
check(&session, resized_points);
session.undo(id).unwrap();
check(&session, points);
session.redo(id).unwrap();
check(&session, resized_points);
let from = info.view_to_user(100.0, 100.0);
let to = info.view_to_user(110.0, 115.0);
session.apply(id, Edit::MoveAnnotation { page: 0, index: 0, dx: f64::from(to[0] - from[0]), dy: f64::from(to[1] - from[1]) }).unwrap();
check(&session, [(80, 100), (140, 100), (80, 130), (140, 130)]);
session.undo(id).unwrap();
check(&session, resized_points);
session.redo(id).unwrap();
check(&session, [(80, 100), (140, 100), (80, 130), (140, 130)]);
}
}
/// A blank page turned `degrees`, with one annotation placed from displayed coordinates.
fn rotated_blank(degrees: i64) -> (Session, DocId) {
let mut session = Session::new().with_clock(|| 1_700_000_000);
let blank = session.create_blank(200.0, 300.0, 1).unwrap();
let id = session.open_new("drawing.pdf", blank).unwrap();
session.apply(id, Edit::RotatePages { pages: vec![0], degrees }).unwrap();
(session, id)
}
/// The user-space box of a `w` by `h` point rectangle centred at a displayed point.
fn displayed_rect(info: &pdfcraft_render::PageInfo, vx: f64, vy: f64, w: f64, h: f64) -> [f64; 4] {
let corners = [(vx - w / 2.0, vy - h / 2.0), (vx + w / 2.0, vy + h / 2.0)];
let mapped: Vec<[f32; 2]> = corners.iter().map(|(x, y)| info.view_to_user(*x as f32, *y as f32)).collect();
[
f64::from(mapped[0][0].min(mapped[1][0])),
f64::from(mapped[0][1].min(mapped[1][1])),
f64::from(mapped[0][0].max(mapped[1][0])),
f64::from(mapped[0][1].max(mapped[1][1])),
]
}
fn render_page(session: &Session, id: DocId) -> pdfcraft_render::RenderedPage {
let doc = session.get(id).unwrap();
let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone());
let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 2.0, ..Default::default() });
assert!(rendered.error.is_none(), "{:?}", rendered.error);
rendered
}
fn stamp_pixel(rendered: &pdfcraft_render::RenderedPage, x: u32, y: u32) -> [u8; 4] {
let offset = ((y * rendered.width + x) * 4) as usize;
rendered.rgba[offset..offset + 4].try_into().unwrap()
}
/// #1398: a Sign Here tag points left as displayed, on every page rotation, including after save.
#[test]
fn rubber_stamps_point_left_as_displayed_on_rotated_pages() {
let (w, h) = StampKind::SignHere.size();
for degrees in [0, 90, 180, 270] {
let (mut session, id) = rotated_blank(degrees);
let info = session.get(id).unwrap().info.pages[0].clone();
let rect = displayed_rect(&info, 100.0, 40.0, w, h);
let shape = Shape::Stamp { rect, stamp: StampKind::SignHere, by: None };
session
.apply(
id,
Edit::AddAnnotation(NewAnnotation {
page: 0,
style: Style::default_for(&shape),
shape,
contents: String::new(),
author: "Test".into(),
}),
)
.unwrap();
let check = |session: &Session, id: DocId| {
let rendered = render_page(session, id);
let doc = session.get(id).unwrap();
let info = doc.info.pages[0].clone();
let stamp = doc.info.annotations[0].rect;
let a = info.user_to_view(stamp[0], stamp[1]);
let b = info.user_to_view(stamp[2], stamp[3]);
let (left, top) = (a[0].min(b[0]), a[1].min(b[1]));
let (right, bottom) = (a[0].max(b[0]), a[1].max(b[1]));
let scale = 2.0;
let at = |x: f32, y: f32| stamp_pixel(&rendered, (x * scale) as u32, (y * scale) as u32);
// The tag's point is the left edge. The flat end fills the right edge.
let tip = at(left + 4.0, (top + bottom) / 2.0);
let outside = at(left + 4.0, top + 4.0);
let end = at(right - 4.0, top + 4.0);
assert!(tip[0] > 180 && tip[2] < 80, "rotation {degrees}: tip {tip:?} should be the orange point");
assert!(outside.iter().all(|c| *c > 240), "rotation {degrees}: above the point {outside:?} should be paper");
assert!(end[0] > 180 && end[2] < 80, "rotation {degrees}: flat end {end:?} should be orange");
};
check(&session, id);
let saved = session.save_bytes(id).unwrap();
let mut reopened = Session::new().with_clock(|| 1_700_000_000);
let again = reopened.open("drawing.pdf", None, saved, None).unwrap();
check(&reopened, again);
}
}
/// #2392: Fill & Sign marks are drawn as displayed on every page rotation, including after save:
/// a checkmark's long stroke rises to the right, and a line runs across.
#[test]
fn fill_sign_marks_are_upright_as_displayed_on_rotated_pages() {
for degrees in [0, 90, 180, 270] {
let (mut session, id) = rotated_blank(degrees);
let info = session.get(id).unwrap().info.pages[0].clone();
// Displayed boxes: a 40 pt checkmark centred at (50, 50), an 80 × 8 pt line at (130, 50).
for (mark, vx, w, h) in [(FillMark::Check, 50.0, 40.0, 40.0), (FillMark::Line, 130.0, 80.0, 8.0)] {
let shape = Shape::Mark { rect: displayed_rect(&info, vx, 50.0, w, h), mark };
session
.apply(
id,
Edit::AddAnnotation(NewAnnotation {
page: 0,
style: Style::default_for(&shape),
shape,
contents: String::new(),
author: "Test".into(),
}),
)
.unwrap();
}
let check = |session: &Session, id: DocId| {
let rendered = render_page(session, id);
// Dark pixels in a displayed box, rendered at scale 2.
let ink = |x0: u32, y0: u32, x1: u32, y1: u32| -> Vec<(u32, u32)> {
(y0 * 2..y1 * 2)
.flat_map(|y| (x0 * 2..x1 * 2).map(move |x| (x, y)))
.filter(|&(x, y)| stamp_pixel(&rendered, x, y)[..3].iter().all(|c| *c < 128))
.collect()
};
let tick = ink(30, 30, 70, 70);
assert!(!tick.is_empty(), "rotation {degrees}: no checkmark drawn");
let top = tick.iter().min_by_key(|p| p.1).unwrap();
let bottom = tick.iter().max_by_key(|p| p.1).unwrap();
// The long stroke ends at the top right; the short one meets it low, left of centre.
assert!(top.0 > bottom.0 + 20, "rotation {degrees}: highest ink {top:?} should be right of the lowest {bottom:?}");
assert!(bottom.0 < 100 && top.0 > 110, "rotation {degrees}: checkmark {bottom:?} .. {top:?} is not upright");
let line = ink(85, 40, 175, 60);
assert!(!line.is_empty(), "rotation {degrees}: no line drawn");
let (x0, x1) = (line.iter().map(|p| p.0).min().unwrap(), line.iter().map(|p| p.0).max().unwrap());
let (y0, y1) = (line.iter().map(|p| p.1).min().unwrap(), line.iter().map(|p| p.1).max().unwrap());
assert!((x1 - x0) > 10 * (y1 - y0 + 1), "rotation {degrees}: line {x0}..{x1} x {y0}..{y1} should run across");
};
check(&session, id);
let saved = session.save_bytes(id).unwrap();
let mut reopened = Session::new().with_clock(|| 1_700_000_000);
let again = reopened.open("drawing.pdf", None, saved, None).unwrap();
check(&reopened, again);
}
}
/// Fill & Sign text and text boxes read across as displayed on turned pages (scanned forms are
/// often stored sideways with `/Rotate`), including after save.
#[test]
fn typed_text_reads_across_as_displayed_on_rotated_pages() {
for degrees in [0, 90, 180, 270] {
let (mut session, id) = rotated_blank(degrees);
let info = session.get(id).unwrap().info.pages[0].clone();
// Displayed top-left of the text at (40, 60): a wide first line, then one letter below
// its start.
let at = info.view_to_user(40.0, 60.0);
let at = [f64::from(at[0]), f64::from(at[1])];
let rect = text_box_at(degrees, at, 120.0, 2.0 * 12.0 * 1.2 + 6.0).unwrap();
let shape = Shape::Typewriter { rect, font_size: 12.0 };
let style = Style::default_for(&shape);
session
.apply(
id,
Edit::AddAnnotation(NewAnnotation {
page: 0,
style,
shape,
contents: "MMMMMMMM
I"
.into(),
author: "Test".into(),
}),
)
.unwrap();
let check = |session: &Session, id: DocId| {
let rendered = render_page(session, id);
let dark = |x: u32, y: u32| stamp_pixel(&rendered, x, y)[..3].iter().all(|c| *c < 128);
let ink: Vec<(u32, u32)> =
(0..rendered.height).flat_map(|y| (0..rendered.width).map(move |x| (x, y))).filter(|&(x, y)| dark(x, y)).collect();
assert!(!ink.is_empty(), "rotation {degrees}: no text drawn");
let (x0, x1) = (ink.iter().map(|p| p.0).min().unwrap(), ink.iter().map(|p| p.0).max().unwrap());
let (y0, y1) = (ink.iter().map(|p| p.1).min().unwrap(), ink.iter().map(|p| p.1).max().unwrap());
// Rendered at scale 2: the text starts at the clicked point and runs to the right.
assert!((x1 - x0) > 2 * (y1 - y0), "rotation {degrees}: ink {x0}..{x1} x {y0}..{y1} should be wide, not tall");
assert!(x0.abs_diff(80) < 16 && y0.abs_diff(120) < 16, "rotation {degrees}: text starts at ({x0}, {y0}), not near (80, 120)");
// The lone "I" is on the second line under the start of the first, not upside down
// (above the end).
let mid = (y0 + y1) / 2;
let lower: Vec<u32> = ink.iter().filter(|p| p.1 > mid + 2).map(|p| p.0).collect();
assert!(!lower.is_empty() && lower.iter().all(|x| *x < x0 + (x1 - x0) / 4), "rotation {degrees}: second line is not under the start");
};
check(&session, id);
let saved = session.save_bytes(id).unwrap();
let mut reopened = Session::new().with_clock(|| 1_700_000_000);
let again = reopened.open("drawing.pdf", None, saved, None).unwrap();
check(&reopened, again);
}
}
/// A one-page PDF whose left half is red and whose right half is white, as displayed.
fn half_red_page() -> Vec<u8> {
let content = "1 0 0 rg 0 0 40 40 re f";
let objs = [
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 80 40] >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /MediaBox [0 0 80 40] >>".to_vec(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, obj) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(obj);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for offset in offsets {
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
fn quadrant_png() -> Arc<Vec<u8>> {
let colours = [[240, 20, 20, 255], [20, 180, 20, 255], [20, 20, 240, 255], [230, 180, 20, 255]];
let image = image::RgbaImage::from_fn(80, 40, |x, y| image::Rgba(colours[usize::from(y >= 20) * 2 + usize::from(x >= 40)]));
let mut png = std::io::Cursor::new(Vec::new());
image.write_to(&mut png, image::ImageFormat::Png).unwrap();
Arc::new(png.into_inner())
}
fn assert_upright_quadrants(session: &Session, id: DocId, degrees: i64) {
let doc = session.get(id).unwrap();
let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone());
let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
assert!(rendered.error.is_none(), "{:?}", rendered.error);
let colours = [[240, 20, 20, 255], [20, 180, 20, 255], [20, 20, 240, 255], [230, 180, 20, 255]];
for ((x, y), colour) in [(80, 90), (120, 90), (80, 110), (120, 110)].into_iter().zip(colours) {
assert_eq!(stamp_pixel(&rendered, x, y), colour, "rotation {degrees}; displayed point ({x}, {y})");
}
}
/// #1398: an explicit rectangle keeps the caller's box and turns the picture upright.
#[test]
fn explicit_image_stamps_keep_displayed_orientation_on_rotated_pages() {
let bytes = quadrant_png();
for degrees in [0, 90, 180, 270] {
let (mut session, id) = rotated_blank(degrees);
let info = session.get(id).unwrap().info.pages[0].clone();
let rect = displayed_rect(&info, 100.0, 100.0, 80.0, 40.0);
session
.apply(
id,
Edit::AddCustomStamp {
page: 0,
rect,
name: "Original quadrants".into(),
file: MarkFile { name: "quadrants.png".into(), bytes: bytes.clone(), page: 0 },
author: "Test".into(),
},
)
.unwrap();
assert_upright_quadrants(&session, id, degrees);
session.apply(id, Edit::StyleAnnotation { page: 0, index: 0, color: None, opacity: Some(1.0), width: None, endings: None }).unwrap();
assert_upright_quadrants(&session, id, degrees);
}
}
/// #1398: a PDF page used as a stamp stays upright, at its natural size and in an explicit box.
#[test]
fn pdf_page_stamps_keep_displayed_orientation_on_rotated_pages() {
let bytes = Arc::new(half_red_page());
for (natural, degrees) in [true, false].into_iter().flat_map(|natural| [0, 90, 180, 270].map(|degrees| (natural, degrees))) {
let (mut session, id) = rotated_blank(degrees);
let info = session.get(id).unwrap().info.pages[0].clone();
let at = info.view_to_user(100.0, 100.0).map(f64::from);
let rect = if natural { [at[0], at[1], at[0], at[1]] } else { displayed_rect(&info, 100.0, 100.0, 80.0, 40.0) };
session
.apply(
id,
Edit::AddCustomStamp {
page: 0,
rect,
name: "Half red".into(),
file: MarkFile { name: "half.pdf".into(), bytes: bytes.clone(), page: 0 },
author: "Test".into(),
},
)
.unwrap();
let doc = session.get(id).unwrap();
let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone());
let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
assert!(rendered.error.is_none(), "{:?}", rendered.error);
let left = stamp_pixel(&rendered, 80, 100);
let right = stamp_pixel(&rendered, 120, 100);
assert!(left[0] > 200 && left[1] < 40, "rotation {degrees}, natural {natural}: left {left:?} should be red");
assert!(right.iter().all(|c| *c > 240), "rotation {degrees}, natural {natural}: right {right:?} should be paper");
}
}
fn page_texts(s: &Session, id: DocId) -> Vec<String> {
let doc = s.get(id).unwrap();
let config = pdfcraft_render::RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() };
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config);
(0..doc.info.pages.len())
.map(|p| {
let r = r.render(RenderRequestFor::text(p));
r.text.map(|t| t.plain_text().trim().to_string()).unwrap_or_default()
})
.collect()
}
/// Shorthand for building text-extraction requests.
struct RenderRequestFor;
impl RenderRequestFor {
fn text(page: usize) -> pdfcraft_render::RenderRequest {
pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }
}
}
#[test]
fn edits_update_view_data_and_mark_dirty() {
let (mut s, id) = session_with(3);
assert!(s.get(id).unwrap().editable());
assert!(!s.get(id).unwrap().dirty);
s.apply(id, Edit::DeletePages { pages: vec![1] }).unwrap();
let doc = s.get(id).unwrap();
assert!(doc.dirty);
assert_eq!(doc.info.pages.len(), 2, "inspection refreshed");
assert_eq!(doc.can_undo(), Some("Delete page"));
assert_eq!(page_texts(&s, id), ["Page 1", "Page 3"], "renderer refreshed");
}
#[test]
fn metadata_edit_keeps_the_renderer_without_changing_page_content() {
let (mut s, id) = session_with(1);
let request = pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Pixels, scale: 1.0, ..Default::default() };
let render = |session: &Session| {
let doc = session.get(id).unwrap();
doc.renderer.set_queue(vec![request]);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
loop {
if let Some(page) = doc.renderer.try_recv() {
assert!(page.error.is_none(), "{:?}", page.error);
return (doc.renderer.stats(), page.rgba);
}
assert!(std::time::Instant::now() < deadline, "page render did not finish");
std::thread::yield_now();
}
};
let (before, before_pixels) = render(&s);
assert_eq!(before.page_interpretations, 1);
s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Report".into() }).unwrap();
assert_eq!(s.get(id).unwrap().info_value("Title").as_deref(), Some("Report"));
assert_eq!(s.get(id).unwrap().renderer.stats().page_interpretations, 1, "a metadata edit keeps the renderer and its page interpretation");
let (after, after_pixels) = render(&s);
assert_eq!(after.page_interpretations, 2, "the same renderer serves the second request, so its count continues");
assert_eq!(before_pixels, after_pixels, "document properties do not change page pixels");
}
#[test]
fn metadata_edits_and_history_reuse_the_render_pool() {
let (mut s, id) = session_with(1);
let pixels = shown(&s, id, 0).rgba;
let doc = s.get(id).unwrap();
let stats = doc.renderer.stats();
let display = doc.display.clone();
assert_eq!(stats.page_interpretations, 1);
s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Report".into() }).unwrap();
let doc = s.get(id).unwrap();
assert_eq!(doc.info.title.as_deref(), Some("Report"));
assert_eq!(doc.renderer.stats(), stats);
assert!(Arc::ptr_eq(&doc.display, &display));
assert_eq!(pixels.as_ref().len(), 200 * 300 * 4);
s.undo(id).unwrap();
let doc = s.get(id).unwrap();
assert_eq!(doc.info.title, None);
assert_eq!(doc.renderer.stats(), stats);
assert!(Arc::ptr_eq(&doc.display, &display));
s.redo(id).unwrap();
let doc = s.get(id).unwrap();
assert_eq!(doc.info.title.as_deref(), Some("Report"));
assert_eq!(doc.renderer.stats(), stats);
assert!(Arc::ptr_eq(&doc.display, &display));
}
#[test]
fn field_and_comment_edits_still_rebuild_the_render_pool() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("form.pdf", None, Arc::new(scripted_form()), None).unwrap();
let _ = shown(&s, id, 0);
assert_eq!(s.get(id).unwrap().renderer.stats().page_interpretations, 1);
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("4".into()) }).unwrap();
assert_eq!(s.get(id).unwrap().renderer.stats().page_interpretations, 0);
let _ = shown(&s, id, 0);
assert_eq!(s.get(id).unwrap().renderer.stats().page_interpretations, 1);
let shape = Shape::Rectangle { rect: [20.0, 20.0, 60.0, 60.0] };
s.apply(
id,
Edit::AddAnnotation(NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: String::new(), author: "Test".into() }),
)
.unwrap();
assert_eq!(s.get(id).unwrap().renderer.stats().page_interpretations, 0);
}
#[test]
fn undo_and_redo_restore_exact_states() {
let (mut s, id) = session_with(3);
let original = s.get(id).unwrap().bytes.clone();
s.apply(id, Edit::MovePages { pages: vec![2], to: 0 }).unwrap();
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 90);
assert_eq!(s.undo(id).unwrap().as_str(), "Rotate page");
assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 0);
assert_eq!(s.undo(id).unwrap().as_str(), "Move page");
assert_eq!(s.get(id).unwrap().bytes, original, "undoing everything returns the original bytes");
assert_eq!(s.undo(id), Err(EditError::NothingToUndo));
assert_eq!(s.redo(id).unwrap().as_str(), "Move page");
assert_eq!(page_texts(&s, id), ["Page 3", "Page 1", "Page 2"]);
// A new edit clears the redo stack.
s.apply(id, Edit::InsertBlankPage { at: 0, width: 612.0, height: 792.0 }).unwrap();
assert_eq!(s.redo(id), Err(EditError::NothingToRedo));
assert_eq!(s.get(id).unwrap().info.pages.len(), 4);
}
#[test]
fn failed_edit_changes_nothing() {
let (mut s, id) = session_with(2);
let before = s.get(id).unwrap().bytes.clone();
let err = s.apply(id, Edit::DeletePages { pages: vec![0, 1] }).unwrap_err();
assert_eq!(err, EditError::Organize(pdfcraft_organize::OrganizeError::WouldRemoveAllPages));
let doc = s.get(id).unwrap();
assert!(!doc.dirty);
assert_eq!(doc.can_undo(), None);
assert_eq!(doc.bytes, before);
}
#[test]
fn save_is_incremental_stamps_mod_date_and_rebases() {
let (mut s, id) = session_with(2);
let original = s.get(id).unwrap().bytes.clone();
s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Quarterly report".into() }).unwrap();
let saved = s.save_bytes(id).unwrap();
assert_eq!(&saved[..original.len()], &original[..], "incremental save keeps the original bytes");
let text = String::from_utf8_lossy(&saved[original.len()..]);
assert!(text.contains("/ModDate (D:20231114221320Z)"), "mod date stamped: {text}");
s.mark_saved(id, saved.clone(), Some("/tmp/out/report.pdf".into())).unwrap();
let doc = s.get(id).unwrap();
assert!(!doc.dirty);
assert_eq!(doc.name, "report.pdf");
assert_eq!(doc.info_value("Title").as_deref(), Some("Quarterly report"));
assert_eq!(doc.info.title.as_deref(), Some("Quarterly report"));
// Saving again without edits writes the same bytes; a later edit appends just one revision.
assert_eq!(s.save_bytes(id).unwrap(), saved);
s.apply(id, Edit::RotatePages { pages: vec![1], degrees: -90 }).unwrap();
let second = s.save_bytes(id).unwrap();
assert_eq!(&second[..saved.len()], &saved[..]);
let reopened = pdfcraft_cos::Document::open(second).unwrap();
assert_eq!(reopened.revisions().len(), 3);
}
/// #1937: a file whose cross-reference data had to be rebuilt saved as its original bytes when
/// nothing was edited, so the damage Document Properties reported as repaired was still in the
/// file. Save writes the rebuilt file in full; reopened, it needs no repair.
#[test]
fn a_repaired_file_is_rewritten_by_save_even_without_edits() {
let good = fixture(2);
// Every offset in the table is off by the inserted line: the chain reads but leads nowhere.
let mut shifted = b"%PDF-1.7\n%shifted\n".to_vec();
shifted.extend_from_slice(&good[b"%PDF-1.7\n".len()..]);
// No cross-reference table at all.
let mut lost = good.clone();
lost.truncate(good.windows(9).rposition(|w| w == b"startxref").unwrap());
lost.extend_from_slice(b"startxref\n999999\n%%EOF\n");
for (name, damaged) in [("shifted", shifted), ("lost", lost)] {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("broken.pdf", Some("/tmp/broken.pdf".into()), Arc::new(damaged.clone()), None).unwrap();
let doc = s.get(id).unwrap();
assert!(doc.repair_log().iter().any(|l| l.contains("rebuilt cross-reference index")), "{name}: {:?}", doc.repair_log());
assert_eq!(doc.info.pages.len(), 2, "{name}");
assert!(!doc.dirty, "{name}: a repair is not an edit");
let saved = s.save_bytes(id).unwrap();
assert_ne!(&saved[..], &damaged[..], "{name}: the damaged bytes are not what Save writes");
let reopened = pdfcraft_cos::Document::open(saved.clone()).unwrap();
assert!(reopened.repair_log().is_empty(), "{name}: {:?}", reopened.repair_log());
assert_eq!(reopened.revisions().len(), 1, "{name}");
s.mark_saved(id, saved.clone(), None).unwrap();
let doc = s.get(id).unwrap();
assert!(doc.repair_log().is_empty(), "{name}: {:?}", doc.repair_log());
assert_eq!(doc.info.pages.len(), 2, "{name}");
// Sound now: saving again without edits writes the same bytes.
assert_eq!(s.save_bytes(id).unwrap(), saved, "{name}");
}
}
/// A damaged file that is signed keeps its bytes on a Save without edits: rewriting it in full
/// to put the repair on disk would invalidate its signatures without a word.
#[test]
fn a_signed_repaired_file_keeps_its_bytes_when_saved_without_edits() {
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
let digital_id = sign::pkcs12::open(&p12, "test").unwrap();
// After the test certificate's start date (2026-10-02).
let mut s = Session::new().with_clock(|| 1_800_000_000);
let id = s.open("fixture.pdf", None, Arc::new(fixture(1)), None).unwrap();
let opts = SignOptions { page: 0, rect: Some([20.0, 20.0, 180.0, 60.0]), ..SignOptions::default() };
let signed = s.sign(id, &digital_id, opts).unwrap();
// A trailing `startxref` that points nowhere: the signed revision's bytes are untouched, but
// the file's cross-reference data has to be rebuilt.
let mut damaged = signed.to_vec();
damaged.extend_from_slice(b"\nstartxref\n999999\n%%EOF\n");
let mut t = Session::new().with_clock(|| 1_800_000_000);
let tid = t.open("damaged.pdf", Some("/tmp/damaged.pdf".into()), Arc::new(damaged.clone()), None).unwrap();
let doc = t.get(tid).unwrap();
assert!(doc.repair_log().iter().any(|l| l.contains("rebuilt cross-reference index")), "{:?}", doc.repair_log());
assert!(doc.is_signed());
assert_eq!(&t.save_bytes(tid).unwrap()[..], &damaged[..], "a signed file is not rewritten without edits");
}
#[test]
fn undo_still_works_after_save() {
let (mut s, id) = session_with(2);
s.apply(id, Edit::DeletePages { pages: vec![0] }).unwrap();
let saved = s.save_bytes(id).unwrap();
s.mark_saved(id, saved, None).unwrap();
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
s.undo(id).unwrap();
let doc = s.get(id).unwrap();
assert!(!doc.dirty, "undo back to the saved file is clean");
assert_eq!(doc.info.pages.len(), 1);
assert_eq!(doc.info.pages[0].rotation, 0);
s.undo(id).unwrap();
let doc = s.get(id).unwrap();
assert!(doc.dirty, "undoing past a save makes the document dirty again");
assert_eq!(doc.info.pages.len(), 2);
}
#[test]
fn undo_to_the_opened_file_is_not_dirty() {
let mut session = Session::new().with_clock(|| 1_700_000_000);
let opened = Arc::new(fixture(2));
let id = session.open("existing.pdf", None, opened.clone(), None).unwrap();
assert!(!session.get(id).unwrap().dirty);
assert!(session.autosave_snapshots().is_empty());
session.apply(id, Edit::DeletePages { pages: vec![0] }).unwrap();
assert_eq!(session.get(id).unwrap().info.pages.len(), 1);
session.undo(id).unwrap();
let document = session.get(id).unwrap();
assert!(!document.dirty, "the opened bytes are the saved version");
assert_eq!(document.bytes, opened);
assert_eq!(document.info.pages.len(), 2);
assert!(session.autosave_snapshots().is_empty(), "nothing unsaved to recover");
session.redo(id).unwrap();
assert!(session.get(id).unwrap().dirty);
session.undo(id).unwrap();
assert!(!session.get(id).unwrap().dirty);
}
#[test]
fn save_after_undo_retains_the_latest_saved_revision() {
let (mut session, document) = session_with(3);
session.apply(document, Edit::DeletePages { pages: vec![1] }).unwrap();
let deleted = session.save_bytes(document).unwrap();
session.mark_saved(document, deleted, None).unwrap();
session.apply(document, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
let rotated = session.save_bytes(document).unwrap();
session.mark_saved(document, rotated.clone(), None).unwrap();
let rotated_revisions = pdfcraft_cos::Document::open(rotated.clone()).unwrap().revisions().len();
session.undo(document).unwrap();
session.apply(document, Edit::SetInfo { key: "Title".into(), value: "Report".into() }).unwrap();
let saved = session.save_bytes(document).unwrap();
assert!(saved.starts_with(rotated.as_slice()), "the saved rotation stays in the file");
let reopened = pdfcraft_cos::Document::open(saved.clone()).unwrap();
assert!(reopened.revisions().len() > rotated_revisions, "the title is a newer revision, not a rewrite of the earlier one");
assert_eq!(pdfcraft_organize::info(&reopened, "Title").as_deref(), Some("Report"));
session.mark_saved(document, saved, None).unwrap();
let doc = session.get(document).unwrap();
assert_eq!(page_texts(&session, document), ["Page 1", "Page 3"]);
assert_eq!(doc.info.pages[0].rotation, 0, "the undone rotation is the current page");
assert_eq!(doc.info_value("Title").as_deref(), Some("Report"));
}
#[test]
fn revert_preserves_the_saved_pages_after_undo() {
let (mut s, id) = session_with(2);
s.apply(id, Edit::DeletePages { pages: vec![0] }).unwrap();
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
let saved_bytes = s.save_bytes(id).unwrap();
s.mark_saved(id, saved_bytes, None).unwrap();
s.undo(id).unwrap();
assert!(s.get(id).unwrap().dirty);
assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 0);
s.revert(id).unwrap();
let d = s.get(id).unwrap();
assert!(!d.dirty && d.can_undo().is_none() && d.can_redo().is_none());
assert_eq!(page_texts(&s, id), ["Page 2"], "the last saved pages, not the file as opened");
assert_eq!(d.info.pages[0].rotation, 90);
}
#[test]
fn full_save_is_readable_and_smaller_after_deletes() {
let (mut s, id) = session_with(5);
s.apply(id, Edit::DeletePages { pages: vec![0, 1, 2] }).unwrap();
let full = s.save_full_bytes(id).unwrap();
assert!(full.len() < s.save_bytes(id).unwrap().len());
let mut s2 = Session::new();
let id2 = s2.open("full.pdf", None, full, None).unwrap();
assert_eq!(page_texts(&s2, id2), ["Page 4", "Page 5"]);
}
#[test]
fn layer_choices_survive_edits() {
// No layers in the fixture: just make sure refresh keeps working with an empty list and
// that unknown layers are rejected.
let (mut s, id) = session_with(1);
assert!(!s.set_layer_visible(id, 0, false));
s.apply(id, Edit::InsertBlankPage { at: 1, width: 100.0, height: 100.0 }).unwrap();
assert_eq!(s.get(id).unwrap().info.pages.len(), 2);
}
#[test]
fn set_layer_actions_toggle_in_order_and_keep_radio_groups() {
use pdfcraft_render::LayerOp::{Off, On, Toggle};
let layer = |n: u32, visible: bool| Layer { id: (n, 0), name: format!("L{n}"), visible, initial: visible };
let run = |changes: &[(LayerOp, u32)], preserve_rb: bool| {
// L1 and L2 are a radio-button group; L3 is on its own.
let mut layers = vec![layer(1, true), layer(2, false), layer(3, true)];
let changes: Vec<_> = changes.iter().map(|&(op, n)| (op, (n, 0))).collect();
let changed = apply_layer_state(&mut layers, &[vec![(1, 0), (2, 0)]], &changes, preserve_rb);
(changed, layers.iter().map(|l| l.visible).collect::<Vec<_>>())
};
// Turning a layer on turns off the rest of its group, unless the action says not to.
assert_eq!(run(&[(On, 2)], true), (true, vec![false, true, true]));
assert_eq!(run(&[(On, 2)], false), (true, vec![true, true, true]));
// A toggle that turns a layer on does the same.
assert_eq!(run(&[(Toggle, 2)], true), (true, vec![false, true, true]));
// Turning a layer off leaves its group alone.
assert_eq!(run(&[(Off, 1)], true), (true, vec![false, false, true]));
// Each toggle sees the state the changes before it left: twice is no change.
assert_eq!(run(&[(Toggle, 3), (Toggle, 3)], true), (false, vec![true, false, true]));
// Later changes win, and groups that aren't layers are skipped.
assert_eq!(run(&[(On, 1), (On, 2), (Off, 99)], true), (true, vec![false, true, true]));
// What counts is the end state: on and back off again is no change.
assert_eq!(run(&[(On, 2), (On, 1), (Off, 99)], true), (false, vec![true, false, true]));
}
#[test]
fn set_layer_actions_change_what_renders() {
// A red left half on layer Red (on) and a green right half on layer Green (off); the two
// are a radio-button group.
let content = "/OC /R BDC 1 0 0 rg 0 0 50 100 re f EMC /OC /G BDC 0 1 0 rg 50 0 50 100 re f EMC";
let objs = [
"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R] /D << /OFF [6 0 R] /RBGroups [[5 0 R 6 0 R]] >> >> >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /Properties << /R 5 0 R /G 6 0 R >> >> >>".into(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
"<< /Type /OCG /Name (Red) >>".into(),
"<< /Type /OCG /Name (Green) >>".into(),
];
let mut pdf = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
let mut s = Session::new();
let id = s.open("layers.pdf", None, Arc::new(pdf), None).unwrap();
// The colours at the middle of the left and right halves, as the document draws now.
let halves = |s: &Session| {
let doc = s.get(id).unwrap();
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone());
let p = r.render(pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Pixels, scale: 1.0, ..Default::default() });
let at = |x: usize| {
let i = (50 * p.width as usize + x) * 4;
p.rgba[i..i + 3].to_vec()
};
(at(25), at(75))
};
let (red, green, white) = (vec![255, 0, 0], vec![0, 255, 0], vec![255, 255, 255]);
assert_eq!(halves(&s), (red.clone(), white.clone()));
// Showing Green hides Red, its radio-button partner.
assert!(s.set_layer_state(id, &[(LayerOp::On, (6, 0))], true));
let visible: Vec<_> = s.get(id).unwrap().info.layers.iter().map(|l| (l.name.as_str(), l.visible)).collect();
assert_eq!(visible, [("Red", false), ("Green", true)]);
assert_eq!(halves(&s), (white.clone(), green));
// Running it again changes nothing.
assert!(!s.set_layer_state(id, &[(LayerOp::On, (6, 0))], true));
// Turning Green off brings nothing back on.
assert!(s.set_layer_state(id, &[(LayerOp::Off, (6, 0))], true));
assert_eq!(halves(&s), (white.clone(), white));
assert!(!s.set_layer_state(DocId(999), &[(LayerOp::On, (5, 0))], true));
}
#[test]
fn reset_layers_returns_to_the_files_visibility() {
// Red (left half, on) and Green (right half, off) are a radio-button group.
let content = "/OC /R BDC 1 0 0 rg 0 0 50 100 re f EMC /OC /G BDC 0 1 0 rg 50 0 50 100 re f EMC";
let pdf = pdfcraft_testkit::Pdf::new()
.obj("<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R] /D << /OFF [6 0 R] /RBGroups [[5 0 R 6 0 R]] >> >> >>")
.obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>")
.obj("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /Properties << /R 5 0 R /G 6 0 R >> >> >>")
.stream("", content)
.obj("<< /Type /OCG /Name (Red) >>")
.obj("<< /Type /OCG /Name (Green) >>")
.build();
// A page drawing on layer Blue, which that file has off.
let other = pdfcraft_testkit::Pdf::new()
.obj("<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R] /D << /OFF [5 0 R] >> >> >>")
.obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>")
.obj("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /Properties << /B 5 0 R >> >> >>")
.stream("", "/OC /B BDC 0 0 1 rg 0 0 10 10 re f EMC")
.obj("<< /Type /OCG /Name (Blue) >>")
.build();
let mut s = Session::new();
let id = s.open("layers.pdf", None, Arc::new(pdf), None).unwrap();
let shown = |s: &Session| s.get(id).unwrap().info.layers.iter().map(|l| (l.name.clone(), l.visible)).collect::<Vec<_>>();
let left = |s: &Session| {
let doc = s.get(id).unwrap();
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone());
let p = r.render(pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Pixels, scale: 1.0, ..Default::default() });
let i = (50 * p.width as usize + 25) * 4;
p.rgba[i..i + 3].to_vec()
};
assert!(s.set_layer_state(id, &[(LayerOp::On, (6, 0))], true));
let edit = Edit::InsertPagesFrom {
name: "b.pdf".into(),
bytes: Arc::new(other),
pages: None,
at: 1,
field_conflicts: pdfcraft_organize::FieldConflicts::Rename,
};
s.apply(id, edit).unwrap();
let blue = s.get(id).unwrap().info.layers.iter().position(|l| l.name == "Blue").unwrap();
assert!(s.set_layer_visible(id, blue, true));
assert_eq!(shown(&s), [("Red".into(), false), ("Green".into(), true), ("Blue".into(), true)]);
assert_eq!(left(&s), [255, 255, 255]);
// Every layer, the inserted one too, goes back to the file's default configuration.
assert!(s.reset_layers(id));
assert_eq!(shown(&s), [("Red".into(), true), ("Green".into(), false), ("Blue".into(), false)]);
assert_eq!(left(&s), [255, 0, 0]);
assert!(!s.reset_layers(id), "already as the file opens");
// What the eye sees is not an edit: Undo still undoes the insert.
assert_eq!(s.get(id).unwrap().can_undo(), Some("Insert pages from b.pdf"));
assert!(!s.reset_layers(DocId(999)));
}
#[test]
fn edits_to_unknown_documents_are_rejected() {
let (mut s, _) = session_with(1);
assert_eq!(s.apply(DocId(999), Edit::DeletePages { pages: vec![0] }), Err(EditError::NoDocument));
assert_eq!(s.undo(DocId(999)), Err(EditError::NoDocument));
assert!(s.save_bytes(DocId(999)).is_err());
}
#[test]
fn batch_is_one_undo_step_and_all_or_nothing() {
let (mut s, id) = session_with(3);
let batch = Edit::Batch {
label: "Change properties".into(),
edits: vec![Edit::SetInfo { key: "Title".into(), value: "T".into() }, Edit::SetInfo { key: "Author".into(), value: "A".into() }],
};
s.apply(id, batch).unwrap();
assert_eq!(s.get(id).unwrap().info_value("Author").as_deref(), Some("A"));
assert_eq!(s.undo(id).unwrap().as_str(), "Change properties");
assert_eq!(s.get(id).unwrap().info_value("Title"), None);
assert_eq!(s.get(id).unwrap().can_undo(), None, "one step");
// A failing member rolls back the whole batch.
let bad = Edit::Batch { label: "x".into(), edits: vec![Edit::RotatePages { pages: vec![0], degrees: 90 }, Edit::DeletePages { pages: vec![9] }] };
assert!(s.apply(id, bad).is_err());
assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 0);
}
#[test]
fn insert_blank_between_pages_keeps_both_neighbours() {
let (mut s, id) = session_with(2);
s.apply(id, Edit::InsertBlankPage { at: 1, width: 200.0, height: 300.0 }).unwrap();
assert_eq!(page_texts(&s, id), ["Page 1", "", "Page 2"]);
}
#[test]
fn combine_extract_split_and_insert_from_file() {
let (mut s, id) = session_with(3);
let other = Arc::new(fixture(2));
// Combine: the first file then the second.
let combined = s.combine(&[("a.pdf".into(), Arc::new(fixture(3))), ("b.pdf".into(), other.clone())]).unwrap();
let cid = s.open_new("Combined.pdf", combined).unwrap();
assert_eq!(page_texts(&s, cid), ["Page 1", "Page 2", "Page 3", "Page 1", "Page 2"]);
assert!(s.get(cid).unwrap().dirty, "a new document starts unsaved");
assert_eq!(s.get(cid).unwrap().info.outline.len(), 2, "one bookmark per file");
// Extract pages 3 and 1 into a new document.
let ex = s.extract(id, &[2, 0]).unwrap();
let eid = s.open_new("Extract.pdf", ex).unwrap();
assert_eq!(page_texts(&s, eid), ["Page 3", "Page 1"]);
// Split every 2 pages.
let parts = s.split(id, &pdfcraft_organize::SplitBy::PageCount(2)).unwrap();
assert_eq!(parts.iter().map(|(a, b, _)| (*a, *b)).collect::<Vec<_>>(), [(1, 2), (3, 3)]);
// Insert pages from a file, undoably, into the open document.
s.apply(
id,
Edit::InsertPagesFrom {
name: "b.pdf".into(),
bytes: other,
pages: Some(vec![1]),
at: 1,
field_conflicts: pdfcraft_organize::FieldConflicts::Rename,
},
)
.unwrap();
assert_eq!(page_texts(&s, id), ["Page 1", "Page 2", "Page 2", "Page 3"]);
assert_eq!(s.undo(id).unwrap().as_str(), "Insert pages from b.pdf");
assert_eq!(page_texts(&s, id).len(), 3);
// Garbage sources fail cleanly.
let bad = s.apply(
id,
Edit::InsertPagesFrom {
name: "junk.pdf".into(),
bytes: Arc::new(b"nope".to_vec()),
pages: None,
at: 0,
field_conflicts: pdfcraft_organize::FieldConflicts::Rename,
},
);
assert!(matches!(bad, Err(EditError::Source(_))));
}
/// A one-page PDF whose form holds one text field named `name` (a widget that is its own field).
fn form_with_field(name: &str) -> Vec<u8> {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".into(), // 3
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /V (x) /Rect [60 60 200 80] /P 3 0 R >>"), // 4
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
bytes
}
/// Inserting pages of a file whose form names a field as the open document's: the incoming
/// Duplicating a page copies the document's own field: the copy keeps its name (Duplicate is
/// not Combine), and nothing about renaming lands in the repair log.
#[test]
fn duplicating_a_page_with_a_field_keeps_the_field_name() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("a.pdf", None, Arc::new(form_with_field("Text1")), None).unwrap();
s.apply(id, Edit::DuplicatePages { pages: vec![0], at: None }).unwrap();
let doc = s.get(id).unwrap();
assert_eq!(doc.info.pages.len(), 2);
assert!(doc.form.iter().all(|f| f.name == "Text1"), "{:?}", doc.form.iter().map(|f| &f.name).collect::<Vec<_>>());
assert!(!doc.repair_log().iter().any(|l| l.contains("Text1-1")), "{:?}", doc.repair_log());
}
/// field is renamed to keep the two separate, the repair log says so (warning that scripts
/// were not rewritten), the undo step's label counts the renames, and undo puts the names back.
#[test]
fn insert_from_file_renames_colliding_fields_and_notes_it() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("a.pdf", None, Arc::new(form_with_field("Text1")), None).unwrap();
s.apply(
id,
Edit::InsertPagesFrom {
name: "b.pdf".into(),
bytes: Arc::new(form_with_field("Text1")),
pages: None,
at: 1,
field_conflicts: pdfcraft_organize::FieldConflicts::Rename,
},
)
.unwrap();
let doc = s.get(id).unwrap();
let names: Vec<&str> = doc.form.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["Text1", "Text1-1"]);
assert!(
doc.repair_log().iter().any(|l| l.contains("Text1 → Text1-1") && l.contains("were not rewritten")),
"the note names the rename and the scripts caveat: {:?}",
doc.repair_log()
);
let label = s.undo(id).unwrap();
assert_eq!(label.as_str(), "Insert pages from b.pdf — 1 field renamed");
let doc = s.get(id).unwrap();
let names: Vec<&str> = doc.form.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["Text1"], "undo restores the single original field");
}
/// A combine reports the fields it renamed — the tool result tells callers what their scripts
/// must call the field now — and honours a caller's choice to merge instead.
#[test]
fn combine_reports_renamed_fields_and_can_merge() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let sources: Vec<CombineSource> =
vec![("a.pdf".into(), Arc::new(form_with_field("Text1")), None), ("b.pdf".into(), Arc::new(form_with_field("Text1")), None)];
let out = s.combine_unlocked_reporting(&sources, &[], pdfcraft_organize::FieldConflicts::Rename).unwrap();
assert_eq!(out.renamed_fields, [("Text1".to_string(), "Text1-1".to_string())]);
let cid = s.open_new("Combined.pdf", out.bytes).unwrap();
let doc = s.get(cid).unwrap();
let names: Vec<&str> = doc.form.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["Text1", "Text1-1"]);
let merged = s.combine_unlocked_reporting(&sources, &[], pdfcraft_organize::FieldConflicts::Merge).unwrap();
assert!(merged.renamed_fields.is_empty(), "merge renames nothing");
}
/// The Combine tab's pre-flight over byte sources: what combining them, in order, would rename
/// in each. A file that cannot be opened previews as nothing (its row already shows the problem).
#[test]
fn combine_field_conflicts_previews_over_bytes() {
let s = Session::new().with_clock(|| 1_700_000_000);
let sources: Vec<CombineSource> = vec![
("a.pdf".into(), Arc::new(form_with_field("Text1")), None),
("b.pdf".into(), Arc::new(form_with_field("Text1")), None),
("junk.pdf".into(), Arc::new(b"nope".to_vec()), None),
];
let preview = s.combine_field_conflicts(&sources, &[]);
assert_eq!(preview, vec![vec![], vec![("Text1".to_string(), "Text1-1".to_string())], vec![]]);
}
/// A minimal baseline JPEG (headers only; the data is embedded as is).
fn jpeg_bytes() -> Vec<u8> {
let mut v = vec![0xFF, 0xD8];
v.extend_from_slice(&[0xFF, 0xE0, 0, 16, b'J', b'F', b'I', b'F', 0, 1, 1, 1, 0x01, 0x2C, 0x01, 0x2C, 0, 0]);
v.extend_from_slice(&[0xFF, 0xC0, 0, 17, 8, 0, 2, 0, 3, 3, 1, 0x11, 0, 2, 0x11, 1, 3, 0x11, 1]);
v.extend_from_slice(&[0xFF, 0xD9]);
v
}
#[test]
fn mixed_files_convert_and_combine_in_order() {
let mut s = Session::new();
let pdf = Arc::new(fixture(2));
let (kind, same) = s.convert_to_pdf("a.pdf", &pdf).unwrap();
assert_eq!(kind, SourceKind::Pdf);
assert!(Arc::ptr_eq(&same, &pdf), "a PDF is passed through untouched");
let (kind, image) = s.convert_to_pdf("scan.jpg", &Arc::new(jpeg_bytes())).unwrap();
assert_eq!(kind, SourceKind::Image);
let (kind, text) = s.convert_to_pdf("notes.txt", &Arc::new(b"hello".to_vec())).unwrap();
assert_eq!(kind, SourceKind::Text);
let combined = s.combine_ranges(&[("notes".into(), text, None), ("a".into(), pdf, Some("2".into())), ("scan".into(), image, None)]).unwrap();
let id = s.open_new("Combined.pdf", combined).unwrap();
assert_eq!(page_texts(&s, id), ["hello", "Page 2", ""]);
assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["notes→1", "a→2", "scan→3"]);
}
#[test]
fn utf16_and_code_page_text_files_convert_to_their_text() {
let mut s = Session::new();
// Notepad's "Unicode": UTF-16 little endian with a byte order mark.
let mut utf16 = vec![0xFF, 0xFE];
utf16.extend("Grüße aus Köln".encode_utf16().flat_map(u16::to_le_bytes));
let (_, pdf) = s.convert_to_pdf("notes.txt", &Arc::new(utf16)).unwrap();
let id = s.open_new("notes.pdf", pdf).unwrap();
assert_eq!(page_texts(&s, id), ["Grüße aus Köln"]);
// A UTF-8 byte order mark is not text; Windows-1252 bytes are not replacement characters.
let (_, pdf) = s.convert_to_pdf("menu.txt", &Arc::new(b"\xEF\xBB\xBFCaf\xC3\xA9".to_vec())).unwrap();
let id = s.open_new("menu.pdf", pdf).unwrap();
assert_eq!(page_texts(&s, id), ["Café"]);
let (_, pdf) = s.convert_to_pdf("old.txt", &Arc::new(b"Cr\xE8me br\xFBl\xE9e".to_vec())).unwrap();
let id = s.open_new("old.pdf", pdf).unwrap();
assert_eq!(page_texts(&s, id), ["Crème brûlée"]);
}
#[test]
fn a_file_split_around_another_combines_in_order_with_one_bookmark() {
let mut s = Session::new();
let (a, b) = (Arc::new(fixture(3)), Arc::new(fixture(2)));
// a's pages 1–2, then b, then a's page 3: one file (group 7) split around another.
let sources: Vec<CombineSource> = vec![("a".into(), a.clone(), Some("1-2".into())), ("b".into(), b, None), ("a".into(), a, Some("3".into()))];
let combined = s.combine_grouped(&sources, &[7, 9, 7], &[]).unwrap();
let id = s.open_new("Combined.pdf", combined).unwrap();
assert_eq!(page_texts(&s, id), ["Page 1", "Page 2", "Page 1", "Page 2", "Page 3"]);
assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["a→1", "b→3"], "one bookmark for a, at its first page");
// Without groups each source is a file of its own, as combine_ranges.
let (a, b) = (Arc::new(fixture(3)), Arc::new(fixture(2)));
let sources: Vec<CombineSource> = vec![("a".into(), a.clone(), Some("1".into())), ("b".into(), b, None), ("a".into(), a, Some("3".into()))];
let combined = s.combine_grouped(&sources, &[], &[]).unwrap();
let id = s.open_new("Combined 2.pdf", combined).unwrap();
assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["a→1", "b→2", "a→4"]);
// A bad range is refused with the file's name.
let bad: Vec<CombineSource> = vec![("a".into(), Arc::new(fixture(1)), Some("9".into()))];
assert!(matches!(s.combine_grouped(&bad, &[1], &[]), Err(EditError::Print(e)) if e.starts_with("a:")));
}
#[test]
fn create_multiple_reads_each_source_only_after_the_previous_one_is_released() {
let mut s = Session::new();
let mut earlier: Vec<std::sync::Weak<Vec<u8>>> = Vec::new();
let texts = ["Alpha", "Beta", "Gamma"];
let sources = texts.iter().map(|text| {
// The combine has finished with every earlier source, so their bytes are gone by now.
assert!(earlier.iter().all(|w| w.upgrade().is_none()), "a source is released before the next is read");
let (_, pdf) = s.convert_to_pdf(&format!("{text}.txt"), &Arc::new(text.as_bytes().to_vec())).unwrap();
earlier.push(Arc::downgrade(&pdf));
Ok::<CombineSource, EditError>((text.to_string(), pdf, None))
});
let combined = s.combine_each(sources).unwrap().unwrap();
assert!(earlier.iter().all(|w| w.upgrade().is_none()));
let id = s.open_new("Combined.pdf", combined).unwrap();
assert_eq!(page_texts(&s, id), ["Alpha", "Beta", "Gamma"]);
assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Alpha→1", "Beta→2", "Gamma→3"]);
}
#[test]
fn create_multiple_gives_the_bytes_of_combine_ranges_and_refuses_a_bad_source() {
// The two combines are compared byte for byte, and each stamps the clock: pin it.
let s = Session::new().with_clock(|| 1_700_000_000);
let (a, b) = (Arc::new(fixture(3)), Arc::new(fixture(2)));
let sources: Vec<CombineSource> =
vec![("a".into(), a.clone(), Some("1-2".into())), ("b".into(), b, None), ("a".into(), a.clone(), Some("3".into()))];
let by_ranges = s.combine_ranges(&sources).unwrap();
let streamed = s.combine_each(sources.into_iter().map(Ok::<_, EditError>)).unwrap().unwrap();
assert_eq!(*streamed, *by_ranges);
// A bad range, or a file that isn't a PDF, stops the combine with an error naming that source.
let bad_range: Vec<Result<CombineSource, EditError>> = vec![Ok(("a".into(), a.clone(), None)), Ok(("c".into(), a.clone(), Some("9".into())))];
assert!(matches!(s.combine_each(bad_range), Err(EditError::Print(e)) if e.starts_with("c:")));
let junk: Vec<Result<CombineSource, EditError>> = vec![Ok(("junk".into(), Arc::new(b"not a pdf".to_vec()), None))];
assert!(matches!(s.combine_each(junk), Err(EditError::Source(m)) if m.starts_with("junk:")));
// Nothing to combine is `None`, not an empty document.
assert!(matches!(s.combine_each(Vec::<Result<CombineSource, EditError>>::new()), Ok(None)));
}
#[test]
fn a_page_with_a_form_field_shown_twice_keeps_one_field() {
// Regression (found by review): the repeated page copied the whole field again, so the
// result had two fields named "shared", filled in separately.
use pdfcraft_cos::{Dict, Document, Object, PdfString};
let mut doc = Document::open(Arc::new(fixture(2))).unwrap();
let pages: Vec<_> = pdfcraft_organize::pages(&doc).unwrap().into_iter().map(|p| p.obj).collect();
let field = doc.add(Object::Null);
let widgets: Vec<_> = pages
.iter()
.map(|p| {
let mut w = Dict::new();
w.set(b"Type".to_vec(), Object::name("Annot"));
w.set(b"Subtype".to_vec(), Object::name("Widget"));
w.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(100), Object::Int(20)]));
w.set(b"Parent".to_vec(), Object::Ref(field));
w.set(b"P".to_vec(), Object::Ref(*p));
doc.add(w)
})
.collect();
let mut f = Dict::new();
f.set(b"FT".to_vec(), Object::name("Tx"));
f.set(b"T".to_vec(), Object::String(PdfString::text("shared")));
f.set(b"V".to_vec(), Object::String(PdfString::text("Ada")));
f.set(b"Kids".to_vec(), Object::Array(widgets.iter().copied().map(Object::Ref).collect()));
doc.set(field, Object::Dict(f));
for (p, w) in pages.iter().zip(&widgets) {
doc.update_dict(*p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(*w)]))).unwrap();
}
let mut form = Dict::new();
form.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(field)]));
doc.update_dict(doc.root().unwrap(), |d| d.set(b"AcroForm".to_vec(), Object::Dict(form))).unwrap();
let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap());
// Pages 1, 2, then page 1 again: one file split around nothing but itself.
let s = Session::new();
let sources: Vec<CombineSource> = vec![("form".into(), bytes.clone(), Some("1-2".into())), ("form".into(), bytes, Some("1".into()))];
let out = s.combine_grouped(&sources, &[1, 1], &[]).unwrap();
let mut out = Document::open(out).unwrap();
pdfcraft_forms::set_value(&mut out, "shared", &pdfcraft_forms::FieldValue::Text("Grace".into())).unwrap();
let fields = pdfcraft_forms::fields(&out);
assert!(
!fields.is_empty() && fields.iter().all(|f| f.name == "shared" && f.value == ["Grace"]),
"one field, one value: {:?}",
fields.iter().map(|f| (&f.name, &f.value)).collect::<Vec<_>>()
);
let catalog = out.get(out.root().unwrap()).as_dict().cloned().unwrap();
let form = out.resolve(catalog.get(b"AcroForm").unwrap()).as_dict().cloned().unwrap();
let listed = form.get(b"Fields").and_then(|f| f.as_array()).cloned().unwrap();
assert_eq!(listed.len(), 1, "the field isn't copied again for the repeated page");
// Its widgets: one on each of the three pages, each belonging to its own page.
let out_pages: Vec<_> = pdfcraft_organize::pages(&out).unwrap().into_iter().map(|p| p.obj).collect();
let kids = out.resolve(&listed[0]).as_dict().and_then(|f| f.get(b"Kids").and_then(|k| k.as_array()).cloned()).unwrap();
assert_eq!(kids.len(), 3);
let mut on: Vec<_> = kids.iter().map(|k| out.resolve(k).as_dict().and_then(|w| w.reference(b"P")).unwrap()).collect();
on.sort();
let mut expected = out_pages.clone();
expected.sort();
assert_eq!(on, expected, "a widget per page, on its own page");
}
#[test]
fn files_that_cannot_be_converted_are_refused_clearly() {
let s = Session::new();
let err = s.convert_to_pdf("report.docx", &Arc::new(b"PK\x03\x04".to_vec())).unwrap_err();
assert_eq!(err, EditError::Source("report.docx: this file type can't be converted; use a PDF, an image or a .txt file".into()));
assert!(matches!(s.convert_to_pdf("empty", &Arc::new(Vec::new())), Err(EditError::Source(_))));
// Damaged inputs of a known type fail with an error, not a panic.
assert!(s.convert_to_pdf("bad.png", &Arc::new(b"\x89PNG\r\n\x1a\nnope".to_vec())).is_err());
assert!(s.convert_to_pdf("bad.jpg", &Arc::new(vec![0xFF, 0xD8, 0xFF])).is_err());
assert!(s.convert_to_pdf("bad.pdf", &Arc::new(b"%PDF-1.7 nope".to_vec())).is_err());
let err = s.convert_to_pdf("locked.pdf", &protected("pw", "o", -1)).unwrap_err();
assert_eq!(err, EditError::Source("locked.pdf: it is password-protected".into()));
}
/// Combine files (#569): images and text become PDFs; PDFs pass through unread, even protected
/// ones (Combine asks for their password) and ones named .pdf without a header.
#[test]
fn files_for_combine_are_made_into_pdfs_and_pdfs_pass_through() {
let s = Session::new();
let locked = protected("pw", "o", -1);
assert!(Arc::ptr_eq(&s.as_pdf("locked.pdf", &locked).unwrap(), &locked));
let headless = Arc::new(b"1 0 obj << >> endobj".to_vec());
assert!(Arc::ptr_eq(&s.as_pdf("old.PDF", &headless).unwrap(), &headless));
assert!(s.as_pdf("scan.jpg", &Arc::new(jpeg_bytes())).unwrap().starts_with(b"%PDF-"));
assert!(s.as_pdf("notes.txt", &Arc::new(b"hello".to_vec())).unwrap().starts_with(b"%PDF-"));
let err = s.as_pdf("report.docx", &Arc::new(b"PK\x03\x04".to_vec())).unwrap_err();
assert!(matches!(err, EditError::Source(m) if m.starts_with("report.docx: this file type can't be converted")));
}
fn protected(user: &str, owner: &str, permissions: i32) -> Arc<Vec<u8>> {
let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(2))).unwrap();
doc.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm: pdfcraft_cos::Algorithm::Aes256,
user_password: user,
owner_password: owner,
permissions,
encrypt_metadata: true,
seed: [7; 32],
})
.unwrap();
Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap())
}
#[test]
fn encrypted_documents_open_edit_and_save_encrypted() {
let bytes = protected("pw", "owner", -1);
let mut s = Session::new();
assert!(s.open("x.pdf", None, bytes.clone(), None).is_err(), "needs a password");
let id = s.open("x.pdf", None, bytes.clone(), Some("pw")).unwrap();
assert!(s.get(id).unwrap().editable(), "{:?}", s.get(id).unwrap().read_only_reason);
assert_eq!(s.get(id).unwrap().security_summary().unwrap().method, "AES, 256-bit");
assert!(s.get(id).unwrap().info.encrypted, "reported as encrypted");
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
let saved = s.save_bytes(id).unwrap();
assert_eq!(&saved[..bytes.len()], &bytes[..], "incremental");
assert!(pdfcraft_cos::Document::open(saved.clone()).is_err(), "still protected after saving");
let mut s2 = Session::new();
let id2 = s2.open("x.pdf", None, saved, Some("pw")).unwrap();
assert_eq!(s2.get(id2).unwrap().info.pages[0].rotation, 90);
}
#[test]
fn restricted_documents_refuse_changes_unless_opened_by_the_owner() {
let bytes = protected("", "owner", 0b0100); // print only, no password to open
let mut s = Session::new();
let id = s.open("r.pdf", None, bytes.clone(), None).unwrap();
let d = s.get(id).unwrap();
assert!(!d.allows_assembly() && !d.allows_modification());
let summary = d.security_summary().unwrap();
assert!(!summary.owner && summary.permissions.print() && !summary.permissions.copy());
assert_eq!(s.apply(id, Edit::DeletePages { pages: vec![0] }), Err(EditError::NotPermitted("page changes")));
assert_eq!(s.apply(id, Edit::SetInfo { key: "Title".into(), value: "x".into() }), Err(EditError::NotPermitted("changes to the document")));
assert!(matches!(s.extract(id, &[0]), Err(EditError::NotPermitted(_))));
assert!(!s.get(id).unwrap().dirty);
// The owner password lifts the restrictions.
let oid = s.open("r.pdf", None, bytes, Some("owner")).unwrap();
assert!(s.get(oid).unwrap().allows_assembly());
s.apply(oid, Edit::DeletePages { pages: vec![0] }).unwrap();
}
/// Table 22, bit 5: "Copy or otherwise extract text and graphics" (#568). Snapshots, exports and
/// Save Image As are refused, other permissions are untouched, and the owner password lifts it.
#[test]
fn copy_restricted_documents_refuse_extraction_unless_opened_by_the_owner() {
let bytes = protected("", "owner", -1 ^ 0b1_0000); // everything but copying, no password to open
let mut s = Session::new();
let id = s.open("r.pdf", None, bytes.clone(), None).unwrap();
let d = s.get(id).unwrap();
assert!(!d.allows_copying());
assert!(d.allows_printing() && d.allows_modification() && d.allows_assembly(), "only copying is withheld");
assert_eq!(d.check_copying(), Err(EditError::NotPermitted("copying text and images")));
assert!(d.page_image_file(0, 0).unwrap_err().contains("don't allow copying"));
let extracting = ["edit.snapshot", "export.image", "export.text", "export.docx", "export.pptx", "export.html", "export.rtf", "export.all_images"];
let enabled = |s: &Session, id, c| commands::is_enabled(commands::command(c).unwrap(), s, Some(id));
for c in extracting {
assert!(!enabled(&s, id, c), "{c} is disabled");
}
assert!(enabled(&s, id, "edit.find") && enabled(&s, id, "a11y.check"), "searching and accessibility don't copy");
assert!(s.get(id).unwrap().autotag_preview(0).is_ok(), "like a11y.check, the auto-tag preview is allowed");
let oid = s.open("r.pdf", None, bytes, Some("owner")).unwrap();
assert!(s.get(oid).unwrap().allows_copying());
assert!(extracting.iter().all(|c| enabled(&s, oid, c)));
// Comparing reads both documents' text out, into the result, the report and the comments.
let refused = || Err(EditError::NotPermitted("copying text and images"));
assert_eq!((s.compare(id, oid).map(|_| ()), s.compare(oid, id).map(|_| ())), (refused(), refused()));
assert_eq!(s.compare_report(oid, id).map(|_| ()), refused());
assert!(s.compare(oid, oid).is_ok());
assert_eq!(s.mark_differences(id, oid).map(|_| ()), refused());
// Unencrypted documents allow everything.
let (s, plain) = session_with(1);
assert!(s.get(plain).unwrap().check_copying().is_ok() && extracting.iter().all(|c| enabled(&s, plain, c)));
}
/// Table 22, bit 3 (#2669): a document that forbids printing disables Print up front instead
/// of refusing once the dialog is confirmed. Low-resolution printing (bit 3 on, bit 12 off) is
/// still printing, and the owner password lifts the restriction.
#[test]
fn print_restricted_documents_disable_print_unless_opened_by_the_owner() {
let enabled = |s: &Session, id, c| commands::is_enabled(commands::command(c).unwrap(), s, Some(id));
let bytes = protected("", "owner", -1 ^ 0b100);
let mut s = Session::new();
let id = s.open("r.pdf", None, bytes.clone(), None).unwrap();
assert!(!s.get(id).unwrap().allows_printing());
assert!(!enabled(&s, id, "print.dialog"));
assert!(enabled(&s, id, "export.text"), "only printing is withheld");
let oid = s.open("r.pdf", None, bytes, Some("owner")).unwrap();
assert!(enabled(&s, oid, "print.dialog"), "the owner may print");
let low = s.open("low.pdf", None, protected("", "owner", -1 ^ (1 << 11)), None).unwrap();
assert!(enabled(&s, low, "print.dialog"), "low-resolution printing is printing");
}
/// Table 22, bit 12 clear: printing is limited to a low-level representation. Whatever the
/// settings ask for, the print-ready PDF carries each page as a picture of at most 150 dpi, with
/// no text or vector artwork left; the owner password lifts the limit.
#[test]
fn low_resolution_printing_prints_pictures_of_at_most_150_dpi() {
let bytes = protected("", "owner", -1 ^ (1 << 11));
let mut s = Session::new();
let low = s.open("low.pdf", None, bytes.clone(), None).unwrap();
let width = f64::from(s.get(low).unwrap().info.pages[0].width);
let actual = print::Settings { pages: vec![0], layout: print::Layout::Size(print::SizeMode::Actual), ..Default::default() };
for raster in [None, Some(print::Raster { dpi: 600.0, grayscale: false })] {
let printed = s.print_pdf(low, &print::Settings { raster, ..actual.clone() }).unwrap();
let mut s2 = Session::new();
let id2 = s2.open("print.pdf", None, Arc::new(printed.clone()), None).unwrap();
assert!(page_texts(&s2, id2).iter().all(|t| t.trim().is_empty()), "{raster:?}: no text is left");
let doc = pdfcraft_cos::Document::open(Arc::new(printed)).unwrap();
let widths: Vec<i64> = doc
.scan_objects()
.filter_map(|(_, o)| match &*o {
pdfcraft_cos::Object::Stream(st) if st.dict.name(b"Subtype") == Some(b"Image") => st.dict.int(b"Width"),
_ => None,
})
.collect();
assert!(!widths.is_empty() && widths.iter().all(|&w| w as f64 <= width * 150.0 / 72.0 + 1.0), "{raster:?}: {widths:?} px for {width} pt");
}
let owner = s.open("low.pdf", None, bytes, Some("owner")).unwrap();
let printed = s.print_pdf(owner, &actual).unwrap();
let mut s2 = Session::new();
let id2 = s2.open("print.pdf", None, Arc::new(printed), None).unwrap();
assert!(page_texts(&s2, id2).iter().any(|t| !t.trim().is_empty()), "the owner prints the pages themselves");
}
/// A 100 × 400 pt page with one band per kind of thing that prints, top to bottom: page content
/// (blue), a filled text field (green), a stamp (red) and a square comment (yellow).
fn print_content_pdf() -> Vec<u8> {
let content = "0 0 1 rg 0 300 100 100 re f";
let ap = |colour: &str| {
let ops = format!("{colour} rg 0 0 100 100 re f");
format!("<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Length {} >> stream\n{ops}\nendstream", ops.len())
};
format!(
"%PDF-1.7\n\
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 400] /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R] >> endobj\n\
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
5 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (Name) /V (Filled) /F 4 /P 3 0 R /Rect [0 200 100 300] /AP << /N 8 0 R >> >> endobj\n\
6 0 obj << /Type /Annot /Subtype /Stamp /F 4 /P 3 0 R /Rect [0 100 100 200] /AP << /N 9 0 R >> >> endobj\n\
7 0 obj << /Type /Annot /Subtype /Square /F 4 /P 3 0 R /Rect [0 0 100 100] /AP << /N 10 0 R >> >> endobj\n\
8 0 obj {} endobj\n\
9 0 obj {} endobj\n\
10 0 obj {} endobj\n\
trailer << /Root 1 0 R >>\n%%EOF",
content.len(),
ap("0 1 0"),
ap("1 0 0"),
ap("1 1 0"),
)
.into_bytes()
}
/// Low-resolution printing prints what the chosen content prints when the pages themselves are
/// printed: form fields only leaves out the page content and every comment, the document alone
/// leaves out the comments, and the document with stamps keeps only the stamps.
#[test]
fn low_resolution_printing_prints_only_the_chosen_content() {
let mut cos = pdfcraft_cos::Document::open(Arc::new(print_content_pdf())).unwrap();
cos.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm: pdfcraft_cos::Algorithm::Aes256,
user_password: "",
owner_password: "owner",
permissions: -1 ^ (1 << 11),
encrypt_metadata: true,
seed: [7; 32],
})
.unwrap();
let bytes = Arc::new(pdfcraft_cos::write_full(&cos, &Default::default()).unwrap());
let mut s = Session::new();
let low = s.open("low.pdf", None, bytes.clone(), None).unwrap();
let owner = s.open("low.pdf", None, bytes, Some("owner")).unwrap();
// Each band's centre, in pixels from the top of the sheet at 72 dpi, and its colour.
let bands = [("content", 50, [0, 0, 255]), ("field", 150, [0, 255, 0]), ("stamp", 250, [255, 0, 0]), ("comment", 350, [255, 255, 0])];
let all: &[&str] = &["content", "field", "stamp", "comment"];
for (content, shown) in [
(print::Content::DocumentAndMarkups, all),
(print::Content::Document, &["content", "field"][..]),
(print::Content::DocumentAndStamps, &["content", "field", "stamp"][..]),
(print::Content::FormFieldsOnly, &["field"][..]),
] {
let settings = print::Settings {
pages: vec![0],
paper: (100.0, 400.0),
layout: print::Layout::Size(print::SizeMode::Actual),
content,
..Default::default()
};
for (id, how) in [(owner, "pages"), (low, "pictures")] {
let (w, h, rgba) = printed_pixels(s.print_pdf(id, &settings).unwrap());
assert_eq!((w, h), (100, 400), "{content:?} as {how}");
for (band, y, colour) in bands {
let px = rgba.get((y * w as usize + 50) * 4..).and_then(|p| p.get(..3)).unwrap();
let near = |c: [u8; 3]| px.iter().zip(c).all(|(&a, b)| a.abs_diff(b) < 40);
let expected = shown.contains(&band);
assert!(if expected { near(colour) } else { near([255; 3]) }, "{content:?} as {how}: {band} drawn {expected} expected, pixel {px:?}");
}
}
}
}
#[test]
fn the_permissions_password_unlocks_an_open_document() {
// Only a permissions password (#995, #1077): opens without asking. Changes are allowed,
// security changes aren't.
let bytes = protected("", "owner", 0b1100);
let mut s = Session::new();
let id = s.open("r.pdf", None, bytes, None).unwrap();
s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Before".into() }).unwrap();
assert!(!s.get(id).unwrap().allows_security_change());
assert!(matches!(s.apply(id, Edit::RemoveProtection), Err(EditError::NotPermitted(_))));
for wrong in ["", "nope"] {
assert_eq!(s.unlock(id, wrong), Err(EditError::WrongPassword), "{wrong:?}");
}
assert!(!s.get(id).unwrap().allows_security_change());
s.unlock(id, "owner").unwrap();
let d = s.get(id).unwrap();
assert!(d.allows_security_change() && d.security_summary().unwrap().owner);
assert!(d.dirty, "unlocking leaves the unsaved edit alone");
assert_eq!(d.can_undo(), Some("Change Title"), "and adds no undo step");
// Undo and redo don't bring the restrictions back.
s.undo(id).unwrap();
assert!(s.get(id).unwrap().allows_security_change(), "after undo");
s.redo(id).unwrap();
assert!(s.get(id).unwrap().allows_security_change(), "after redo");
// Neither does saving.
let saved = s.save_bytes(id).unwrap();
s.mark_saved(id, saved, None).unwrap();
assert!(s.get(id).unwrap().allows_security_change(), "after saving");
s.apply(id, Edit::RemoveProtection).unwrap();
let plain = s.save_bytes(id).unwrap();
assert!(pdfcraft_cos::Document::open(plain).unwrap().security().is_none(), "security removed");
}
#[test]
fn remove_security_from_a_file_with_its_encrypt_dictionary_in_the_trailer() {
// MuPDF writes /Encrypt inline in the trailer rather than as an object (#2661).
let mut doc = pdfcraft_cos::Document::open_with_password(protected("user", "owner", -1), Some("owner")).unwrap();
let r = doc.trailer().reference(b"Encrypt").unwrap();
let dict = doc.get(r).as_dict().cloned().unwrap();
doc.trailer_mut().set(b"Encrypt".to_vec(), pdfcraft_cos::Object::Dict(dict));
let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap());
let mut s = Session::new();
let id = s.open("m.pdf", None, bytes, Some("owner")).unwrap();
s.apply(id, Edit::RemoveProtection).unwrap();
assert!(!s.get(id).unwrap().info.encrypted);
let plain = s.save_bytes(id).unwrap();
assert!(pdfcraft_cos::Document::open(plain).unwrap().security().is_none(), "security removed");
}
#[test]
fn unlocking_needs_the_permissions_password_not_the_open_password() {
let mut s = Session::new();
let id = s.open("r.pdf", None, protected("open", "perms", 0b0100), Some("open")).unwrap();
assert_eq!(s.unlock(id, "open"), Err(EditError::WrongPassword));
s.unlock(id, "perms").unwrap();
assert!(s.get(id).unwrap().allows_security_change());
// Nothing to unlock: opened by the owner already, or not encrypted.
s.unlock(id, "anything").unwrap();
let plain = s.open("p.pdf", None, Arc::new(fixture(1)), None).unwrap();
s.unlock(plain, "anything").unwrap();
}
#[test]
fn combining_protected_files_is_refused_clearly() {
let s = Session::new();
let err = s.combine(&[("a.pdf".into(), protected("pw", "o", -1)), ("b.pdf".into(), Arc::new(fixture(1)))]).unwrap_err();
assert_eq!(err, EditError::Source("a.pdf: it is password-protected".into()));
let err = s.combine(&[("c.pdf".into(), protected("", "o", 0b0100))]).unwrap_err();
assert!(matches!(err, EditError::Source(m) if m.contains("don't allow copying pages")));
}
#[test]
fn owner_password_of_older_revisions_opens_the_viewer_too() {
for alg in [pdfcraft_cos::Algorithm::Rc4_128, pdfcraft_cos::Algorithm::Aes128] {
let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap();
doc.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm: alg,
user_password: "u",
owner_password: "o",
permissions: 0,
encrypt_metadata: true,
seed: [1; 32],
})
.unwrap();
let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap());
let mut s = Session::new();
let id = s.open("x.pdf", None, bytes, Some("o")).unwrap_or_else(|e| panic!("{alg:?}: {e}"));
let d = s.get(id).unwrap();
assert!(d.security_summary().unwrap().owner && d.allows_modification(), "{alg:?}");
assert_eq!(page_texts(&s, id), ["Page 1"], "{alg:?}: the renderer reads it");
}
}
/// Render paths outside a session (the CLI's `info`, `text`, `render`) get the password the
/// renderer takes: the recovered user password for an R2–R4 owner password, else as given.
#[test]
fn render_password_for_recovers_the_user_password_of_older_revisions() {
for alg in [pdfcraft_cos::Algorithm::Rc4_40, pdfcraft_cos::Algorithm::Rc4_128, pdfcraft_cos::Algorithm::Aes128, pdfcraft_cos::Algorithm::Aes256] {
let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap();
doc.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm: alg,
user_password: "u",
owner_password: "o",
permissions: 0,
encrypt_metadata: true,
seed: [1; 32],
})
.unwrap();
let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap());
let owner = crate::render_password_for(bytes.clone(), Some("o"));
let expected = if alg == pdfcraft_cos::Algorithm::Aes256 { "o" } else { "u" };
assert_eq!(owner.as_deref(), Some(expected), "{alg:?}");
assert!(pdfcraft_render::inspect(bytes.clone(), owner.as_deref()).is_ok(), "{alg:?}: the renderer opens it");
assert_eq!(crate::render_password_for(bytes.clone(), Some("u")).as_deref(), Some("u"), "{alg:?}");
assert_eq!(crate::render_password_for(bytes.clone(), Some("wrong")).as_deref(), Some("wrong"), "{alg:?}");
assert_eq!(crate::render_password_for(bytes, None), None, "{alg:?}");
}
}
#[test]
fn autosave_snapshots_only_changed_documents() {
let (mut s, id) = session_with(2);
assert!(!s.has_pending_autosave());
assert!(s.autosave_snapshots().is_empty(), "clean documents are not snapshotted");
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
assert!(s.has_pending_autosave());
assert!(s.has_pending_autosave(), "checking a deadline must not consume a snapshot");
let snaps = s.autosave_snapshots();
assert!(!s.has_pending_autosave());
assert_eq!(snaps.len(), 1);
assert_eq!(snaps[0].bytes, s.get(id).unwrap().bytes, "the working file");
assert!(s.autosave_snapshots().is_empty(), "nothing new since the last snapshot");
s.apply(id, Edit::DeletePages { pages: vec![1] }).unwrap();
assert_eq!(s.autosave_snapshots().len(), 1);
s.undo(id).unwrap();
assert!(s.get(id).unwrap().dirty, "the rotation is still unsaved");
assert_eq!(s.autosave_snapshots().len(), 1, "undo that leaves unsaved work is a change");
s.undo(id).unwrap();
assert!(!s.get(id).unwrap().dirty, "undo back to the opened file is saved");
assert!(s.autosave_snapshots().is_empty(), "a clean document needs no recovery");
let saved = s.save_bytes(id).unwrap();
s.mark_saved(id, saved, None).unwrap();
assert!(s.autosave_snapshots().is_empty(), "saved documents need no recovery");
}
#[test]
fn autosave_snapshots_a_new_unsaved_document_once() {
let (mut s, clean) = session_with(1);
let id = s.open_new("Untitled.pdf", Arc::new(fixture(1))).unwrap();
let snaps = s.autosave_snapshots();
assert_eq!(snaps.len(), 1, "the new document is unsaved work");
assert_eq!(snaps[0].doc, id);
assert!(snaps.iter().all(|x| x.doc != clean), "the clean document stays out");
assert!(s.autosave_snapshots().is_empty(), "nothing new since the last snapshot");
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
assert_eq!(s.autosave_snapshots().len(), 1, "a later edit is snapshotted again");
}
#[test]
fn recovered_documents_reopen_unsaved_at_their_original_path() {
let (mut s, id) = session_with(2);
s.apply(id, Edit::DeletePages { pages: vec![1] }).unwrap();
let snap = s.autosave_snapshots().remove(0);
// A new session after a crash.
let mut s2 = Session::new();
let rid = s2.open(snap.name.clone(), None, snap.bytes.clone(), None).unwrap();
s2.mark_recovered(rid, Some("/docs/report.pdf".into()));
let d = s2.get(rid).unwrap();
assert!(d.dirty);
assert_eq!(d.path.as_deref(), Some("/docs/report.pdf"));
assert_eq!(d.info.pages.len(), 1, "the edit survived");
assert!(s2.autosave_snapshots().is_empty(), "already in the recovery store");
}
fn outline_titles(items: &[pdfcraft_render::OutlineItem]) -> Vec<String> {
items
.iter()
.map(|o| {
if o.children.is_empty() {
format!("{}→{}", o.title, o.page.map_or(0, |p| p + 1))
} else {
format!("{}→{}[{}]", o.title, o.page.map_or(0, |p| p + 1), outline_titles(&o.children).join(","))
}
})
.collect()
}
#[test]
fn bookmark_edits_show_in_the_viewer_undo_and_save() {
let (mut s, id) = session_with(3);
let titles = |s: &Session| outline_titles(&s.get(id).unwrap().info.outline);
s.apply(id, Edit::AddBookmark { parent: vec![], index: 0, title: "Start".into(), page: 0, left: None, top: None }).unwrap();
s.apply(id, Edit::AddBookmark { parent: vec![], index: 1, title: "End".into(), page: 2, left: None, top: None }).unwrap();
s.apply(id, Edit::AddBookmark { parent: vec![1], index: 0, title: "Détail".into(), page: 1, left: None, top: None }).unwrap();
// The inspector (an independent parser) sees the same tree and destinations.
assert_eq!(titles(&s), ["Start→1", "End→3[Détail→2]"]);
s.apply(id, Edit::MoveBookmark { from: vec![1, 0], to_parent: vec![], index: 0 }).unwrap();
s.apply(id, Edit::RenameBookmark { path: vec![2], title: "Finish".into() }).unwrap();
s.apply(id, Edit::SetBookmarkPage { path: vec![1], page: 1 }).unwrap();
assert_eq!(titles(&s), ["Détail→2", "Start→2", "Finish→3"]);
assert_eq!(s.get(id).unwrap().can_undo(), Some("Set bookmark destination"));
s.undo(id).unwrap();
assert_eq!(titles(&s), ["Détail→2", "Start→1", "Finish→3"]);
s.apply(id, Edit::DeleteBookmark { path: vec![0] }).unwrap();
assert!(matches!(s.apply(id, Edit::DeleteBookmark { path: vec![7] }), Err(EditError::Bookmark(_))));
let saved = s.save_bytes(id).unwrap();
let mut again = Session::new();
let id2 = again.open("again.pdf", None, saved, None).unwrap();
assert_eq!(outline_titles(&again.get(id2).unwrap().info.outline), ["Start→1", "Finish→3"]);
}
/// Two pages tagged with an H1 on the first and an H2 on the second.
fn tagged_headings_fixture() -> Vec<u8> {
let objs = [
"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 7 0 R >>",
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 200] /Resources << /Font << /F1 6 0 R >> >> >>",
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>",
"<< /Type /Page /Parent 2 0 R >>",
"<< /Length 52 >>\nstream\n/H1 << /MCID 0 >> BDC BT /F1 12 Tf (Intro) Tj ET EMC\nendstream",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
"<< /Type /StructTreeRoot /K [8 0 R 9 0 R] >>",
"<< /S /H1 /Pg 3 0 R /K 0 >>",
"<< /S /H2 /Pg 4 0 R /ActualText (Details) >>",
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
#[test]
fn bookmarks_from_structure_nest_the_headings_and_undo() {
let mut s = Session::new();
let id = s.open("tagged.pdf", None, Arc::new(tagged_headings_fixture()), None).unwrap();
s.apply(id, Edit::BookmarksFromStructure).unwrap();
assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Untitled→0[Intro→1[Details→2]]"]);
assert_eq!(s.get(id).unwrap().can_undo(), Some("New bookmarks from structure"));
s.undo(id).unwrap();
assert!(s.get(id).unwrap().info.outline.is_empty());
// Untagged: a clear error and nothing changes.
let (mut s, id) = session_with(2);
let err = s.apply(id, Edit::BookmarksFromStructure).unwrap_err();
assert_eq!(err.to_string(), "this document has no tagged headings to make bookmarks from");
assert!(s.get(id).unwrap().info.outline.is_empty());
}
#[test]
fn number_pages_shows_in_the_viewer_and_undoes() {
let (mut s, id) = session_with(5);
let labels = |s: &Session| s.get(id).unwrap().info.pages.iter().map(|p| p.label.clone()).collect::<Vec<_>>();
use pdfcraft_organize::LabelStyle;
s.apply(id, Edit::NumberPages { from: 0, to: 1, style: LabelStyle::LowerRoman, prefix: String::new(), first: 1 }).unwrap();
s.apply(id, Edit::NumberPages { from: 2, to: 4, style: LabelStyle::Decimal, prefix: "§".into(), first: 10 }).unwrap();
// The inspector (lopdf-based, independent) formats them the same way.
assert_eq!(labels(&s), ["i", "ii", "§10", "§11", "§12"]);
s.undo(id).unwrap();
assert_eq!(labels(&s), ["i", "ii", "3", "4", "5"]);
assert!(s.apply(id, Edit::NumberPages { from: 3, to: 9, style: LabelStyle::Decimal, prefix: String::new(), first: 1 }).is_err());
}
/// RGBA of the pixel at PDF point (x, y) on `page`, rendered at 1 px/pt (page height 300).
fn pixel(s: &Session, id: DocId, page: usize, x: u32, y: u32) -> [u8; 4] {
let doc = s.get(id).unwrap();
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone());
let out = r.render(pdfcraft_render::RenderRequest { page, scale: 1.0, ..Default::default() });
assert!(out.error.is_none(), "{:?}", out.error);
let i = (((300 - y) * out.width + x) * 4) as usize;
out.rgba[i..i + 4].try_into().unwrap()
}
fn rect_comment(page: usize, rect: [f64; 4]) -> Edit {
Edit::AddAnnotation(NewAnnotation {
page,
shape: Shape::Rectangle { rect },
style: Style { color: [1.0, 0.0, 0.0], opacity: 1.0, width: 2.0, fill: Some([1.0, 0.0, 0.0]) },
contents: "Look here".into(),
author: "Reviewer".into(),
})
}
#[test]
fn comments_are_added_drawn_threaded_and_undone() {
let (mut s, id) = session_with(2);
assert_eq!(pixel(&s, id, 1, 100, 100), [255, 255, 255, 255]);
s.apply(id, rect_comment(1, [80.0, 80.0, 120.0, 120.0])).unwrap();
assert_eq!(s.get(id).unwrap().can_undo(), Some("Add rectangle"));
let px = pixel(&s, id, 1, 100, 100);
assert!(px[0] > 200 && px[1] < 40 && px[2] < 40, "the rectangle is drawn: {px:?}");
let a = &s.get(id).unwrap().info.annotations;
assert_eq!(a.len(), 1);
assert_eq!((a[0].page, a[0].index, a[0].author.as_deref(), a[0].contents.as_deref()), (1, 0, Some("Reviewer"), Some("Look here")));
assert_eq!(a[0].modified.as_deref().map(|m| m.contains("2023")), Some(true), "dated by the session clock");
let nm = a[0].name.clone().expect("has an /NM");
assert_eq!(nm.len(), 36);
s.apply(id, Edit::ReplyToAnnotation { page: 1, index: 0, text: "Done".into(), author: "Ada".into() }).unwrap();
s.apply(id, Edit::SetAnnotationStatus { page: 1, index: 0, state: ReviewState::Completed, author: "Ada".into() }).unwrap();
let a = &s.get(id).unwrap().info.annotations;
assert_eq!(a.len(), 3);
assert!(a.iter().filter(|r| r.in_reply_to.as_deref() == Some(nm.as_str())).count() == 2);
assert!(a.iter().any(|r| r.state.as_deref() == Some("Completed")));
assert_ne!(a[1].name, a[2].name, "every comment gets its own id");
s.apply(id, Edit::MoveAnnotation { page: 1, index: 0, dx: 50.0, dy: 0.0 }).unwrap();
assert_eq!(pixel(&s, id, 1, 100, 100), [255, 255, 255, 255]);
let px = pixel(&s, id, 1, 150, 100);
assert!(px[0] > 200 && px[1] < 40, "moved: {px:?}");
s.apply(id, Edit::DeleteAnnotation { page: 1, index: 0 }).unwrap();
assert!(s.get(id).unwrap().info.annotations.is_empty(), "replies go with their parent");
for _ in 0..4 {
s.undo(id).unwrap();
}
assert_eq!(s.get(id).unwrap().info.annotations.len(), 1);
let px = pixel(&s, id, 1, 100, 100);
assert!(px[0] > 200 && px[1] < 40, "back where it was: {px:?}");
}
#[test]
fn highlight_multiplies_over_text() {
let (mut s, id) = session_with(1);
// "Page 1" is drawn at 20,150 in 24 pt Helvetica.
let quad = [18.0, 172.0, 100.0, 172.0, 18.0, 145.0, 100.0, 145.0];
s.apply(
id,
Edit::AddAnnotation(NewAnnotation {
page: 0,
shape: Shape::TextMarkup { kind: Markup::Highlight, quads: vec![quad] },
style: Style { color: [1.0, 1.0, 0.0], ..Style::default() },
contents: String::new(),
author: String::new(),
}),
)
.unwrap();
let a = &s.get(id).unwrap().info.annotations[0];
assert_eq!(a.subtype, "Highlight");
assert_eq!(a.quads, vec![quad.map(|v| v as f32)]);
// Background inside the quad turns yellow; text stays dark (multiply).
let bg = pixel(&s, id, 0, 19, 170);
assert!(bg[0] > 240 && bg[1] > 240 && bg[2] < 30, "{bg:?}");
}
#[test]
fn comment_permission_is_enforced() {
let mut cos = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap();
// Owner "own", empty user password, everything allowed except commenting (bit 6).
let params = pdfcraft_cos::NewEncryption {
algorithm: pdfcraft_cos::Algorithm::Aes256,
user_password: "",
owner_password: "own",
permissions: !(1 << 5),
encrypt_metadata: true,
seed: [7; 32],
};
cos.set_encryption(&params).unwrap();
let bytes = pdfcraft_cos::write_full(&cos, &pdfcraft_cos::SaveOptions::default()).unwrap();
let mut s = Session::new();
let id = s.open("locked.pdf", None, Arc::new(bytes), None).unwrap();
let err = s.apply(id, rect_comment(0, [10.0, 10.0, 50.0, 50.0])).unwrap_err();
assert_eq!(err, EditError::NotPermitted("comments"));
}
#[test]
fn saving_a_password_protected_document_rebases_on_it() {
let bytes = protected("pw", "owner", -1);
let mut s = Session::new();
let id = s.open("x.pdf", None, bytes, Some("pw")).unwrap();
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
let saved = s.save_bytes(id).unwrap();
s.mark_saved(id, saved.clone(), None).expect("rebases on the encrypted file");
assert!(!s.get(id).unwrap().dirty);
s.apply(id, Edit::RotatePages { pages: vec![1], degrees: 90 }).unwrap();
let again = s.save_bytes(id).unwrap();
assert_eq!(&again[..saved.len()], &saved[..], "the next save appends");
}
fn protection(open: Option<&str>, perms: Option<&str>) -> Protection {
Protection { open_password: open.map(Into::into), permissions_password: perms.map(Into::into), ..Protection::default() }
}
#[test]
fn protect_with_an_open_password_then_save_reopen_and_undo() {
let (mut s, id) = session_with(2);
s.apply(id, Edit::Protect(protection(Some("secret"), None))).unwrap();
let d = s.get(id).unwrap();
assert!(d.info.encrypted, "the working file is encrypted: {:?}", d.info.warnings);
assert_eq!(d.security_summary().unwrap().method, "AES, 256-bit");
assert_eq!(page_texts(&s, id), ["Page 1", "Page 2"], "still viewable in this session");
let saved = s.save_bytes(id).unwrap();
assert!(pdfcraft_cos::Document::open(saved.clone()).is_err(), "needs the password");
s.mark_saved(id, saved.clone(), None).unwrap();
// Further edits keep working and saving stays encrypted.
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
let again = s.save_bytes(id).unwrap();
let mut s2 = Session::new();
assert!(s2.open("p.pdf", None, again.clone(), None).is_err());
let id2 = s2.open("p.pdf", None, again, Some("secret")).unwrap();
assert_eq!(s2.get(id2).unwrap().info.pages[0].rotation, 90);
// Nothing is restricted without a permissions password: the opener may remove security.
assert!(s2.get(id2).unwrap().allows_security_change());
s2.apply(id2, Edit::RemoveProtection).unwrap();
let plain = s2.save_bytes(id2).unwrap();
assert!(pdfcraft_cos::Document::open(plain).is_ok());
}
#[test]
fn permissions_password_restricts_others_but_not_this_session() {
let (mut s, id) = session_with(1);
let p = Protection { printing: Printing::Low, changes: Changes::CommentFillSign, copy: false, ..protection(None, Some("boss")) };
s.apply(id, Edit::Protect(p.clone())).unwrap();
assert!(s.get(id).unwrap().allows_modification(), "the author keeps full rights");
let saved = s.save_bytes(id).unwrap();
s.mark_saved(id, saved.clone(), None).unwrap();
assert!(s.get(id).unwrap().allows_modification(), "…also after saving (re-opened as owner)");
// Someone else opens it without a password: restricted as chosen.
let mut s2 = Session::new();
let id2 = s2.open("r.pdf", None, saved.clone(), None).unwrap();
let perm = s2.get(id2).unwrap().permissions().unwrap();
assert!(perm.print() && !perm.print_high_quality() && perm.annotate() && perm.fill_forms() && !perm.copy() && !perm.modify());
assert!(perm.extract_for_accessibility());
assert!(!s2.get(id2).unwrap().allows_security_change());
assert_eq!(s2.apply(id2, Edit::RemoveProtection), Err(EditError::NotPermitted("changing security")));
assert!(s2.get(id2).unwrap().allows_annotation(), "commenting was allowed");
// The owner password lifts everything.
let id3 = s2.open("r.pdf", None, saved, Some("boss")).unwrap();
assert!(s2.get(id3).unwrap().allows_security_change());
}
#[test]
fn combine_source_check_says_why_a_file_cannot_be_combined() {
let (mut s, id) = session_with(1);
let plain = s.get(id).unwrap().bytes.clone();
assert_eq!(crate::combine_source_check(&plain, None), Ok(()));
// Page assembly withheld (Changes::None) without a password to open it.
let p = Protection { changes: Changes::None, ..protection(None, Some("boss")) };
s.apply(id, Edit::Protect(p)).unwrap();
let locked = s.save_bytes(id).unwrap();
assert_eq!(crate::combine_source_check(&locked, None), Err(crate::SourceProblem::NotPermitted));
let (mut s, id) = session_with(1);
s.apply(id, Edit::Protect(protection(Some("pw"), Some("owner")))).unwrap();
let secret = s.save_bytes(id).unwrap();
assert_eq!(crate::combine_source_check(&secret, None), Err(crate::SourceProblem::Password));
assert_eq!(crate::combine_source_check(&secret, Some("nope")), Err(crate::SourceProblem::WrongPassword));
// Its open password reads it, but its permissions withhold assembling pages: the owner's
// password allows it.
assert_eq!(crate::combine_source_check(&secret, Some("pw")), Err(crate::SourceProblem::NotPermitted));
assert_eq!(crate::combine_source_check(&secret, Some("owner")), Ok(()));
// The permissions password lifts the restriction on copying pages.
assert_eq!(crate::combine_source_check(&locked, Some("boss")), Ok(()));
// Combining with the passwords: the result opens without any.
let s = Session::new();
let sources = vec![("secret".to_string(), secret, None), ("locked".to_string(), locked, None)];
assert!(s.combine_ranges(&sources).is_err(), "no passwords, no combining");
let out = s.combine_unlocked(&sources, &[Some("owner"), Some("boss")]).unwrap();
let combined = pdfcraft_cos::Document::open(out).unwrap();
assert!(combined.permissions().is_none(), "the combined file is not encrypted");
assert_eq!(pdfcraft_organize::page_count(&combined).unwrap(), 2);
assert!(matches!(crate::combine_source_check(&Arc::new(b"not a pdf".to_vec()), None), Err(crate::SourceProblem::Unreadable(_))));
}
#[test]
fn protection_is_validated_undoable_and_never_logged() {
let (mut s, id) = session_with(1);
assert!(matches!(s.apply(id, Edit::Protect(protection(None, None))), Err(EditError::Protection(_))));
assert!(matches!(s.apply(id, Edit::Protect(protection(Some("same"), Some("same")))), Err(EditError::Protection(_))));
let rc4 = Protection { algorithm: pdfcraft_cos::Algorithm::Rc4_128, ..protection(Some("pässword"), None) };
assert!(matches!(s.apply(id, Edit::Protect(rc4)), Err(EditError::Protection(_))));
assert!(!format!("{:?}", Edit::Protect(protection(Some("hunter2"), Some("x")))).contains("hunter2"));
s.apply(id, Edit::Protect(protection(Some("pw"), Some("owner")))).unwrap();
assert!(s.get(id).unwrap().info.encrypted);
s.undo(id).unwrap();
assert!(!s.get(id).unwrap().info.encrypted, "undo removes the pending protection");
assert!(s.get(id).unwrap().security_summary().is_none());
s.redo(id).unwrap();
assert!(s.get(id).unwrap().info.encrypted);
assert_eq!(page_texts(&s, id), ["Page 1"]);
}
/// Protect a fresh document with `p` as the app does: the edit's result and, when it is
/// accepted, how the saved file authenticates with no password, the open password and the
/// permissions password (`None`: it doesn't open).
fn protect_and_reopen(p: &Protection) -> (Result<(), EditError>, Option<[Option<pdfcraft_cos::Auth>; 3]>) {
let (mut s, id) = session_with(1);
let result = s.apply(id, Edit::Protect(p.clone()));
let reopened = result.is_ok().then(|| {
let saved = s.save_bytes(id).unwrap();
let auth = |pw: Option<&str>| pdfcraft_cos::Document::open_with_password(saved.clone(), pw).ok()?.security().map(|h| h.auth());
[auth(None), auth(p.open_password.as_deref()), auth(p.permissions_password.as_deref())]
});
(result, reopened)
}
#[test]
fn passwords_that_encryption_would_make_empty_or_equal_are_refused() {
use pdfcraft_cos::Algorithm::{Aes128, Rc4_128};
use pdfcraft_cos::Auth::{Owner, User};
let empty = "passwords can't be empty or made only of characters that passwords ignore, such as soft hyphens";
let same = "the open password and the permissions password must be different (PDF readers treat these two as the same password)";
// AES-256 keys on the SASLprep form of a password (soft hyphens dropped, then NFKC), RC4 and
// AES-128 on its first 32 bytes. Written as typed, these would make a file that opens
// without a password, or whose open password lifts the restrictions.
let (a31, a32) = ("a".repeat(31), "a".repeat(32));
let (long1, long2) = (format!("{a32}1"), format!("{a32}2"));
// The 32-byte padding starts with "(": 31 characters and the same followed by "(" are one key.
let padded = format!("{a31}(");
let cases = [
("soft hyphen open password", protection(Some("\u{AD}"), None), empty),
("soft hyphen permissions password", protection(None, Some("\u{AD}")), empty),
("soft hyphen permissions password beside an open one", protection(Some("0"), Some("\u{AD}")), empty),
("fullwidth A, then A", protection(Some("\u{FF21}"), Some("A")), same),
("A, then fullwidth A", protection(Some("A"), Some("\u{FF21}")), same),
("fi ligature, then fi", protection(Some("\u{FB01}"), Some("fi")), same),
("fi, then fi ligature", protection(Some("fi"), Some("\u{FB01}")), same),
("RC4-128, same first 32 bytes", Protection { algorithm: Rc4_128, ..protection(Some(&long1), Some(&long2)) }, same),
("AES-128, same first 32 bytes", Protection { algorithm: Aes128, ..protection(Some(&long1), Some(&long2)) }, same),
("RC4-128, same once padded", Protection { algorithm: Rc4_128, ..protection(Some(&a31), Some(&padded)) }, same),
("AES-128, same once padded", Protection { algorithm: Aes128, ..protection(Some(&a31), Some(&padded)) }, same),
];
let got: Vec<_> = cases.iter().map(|(case, p, _)| (*case, protect_and_reopen(p))).collect();
let want: Vec<_> = cases.iter().map(|(case, _, message)| (*case, (Err(EditError::Protection(message.to_string())), None))).collect();
assert_eq!(got, want, "each is refused, so no file is written");
// Passwords that are empty or equal as typed keep their messages.
let (mut s, id) = session_with(1);
assert_eq!(s.apply(id, Edit::Protect(protection(Some(""), None))), Err(EditError::Protection("passwords can't be empty".into())));
assert_eq!(
s.apply(id, Edit::Protect(protection(Some("same"), Some("same")))),
Err(EditError::Protection("the open password and the permissions password must be different".into()))
);
// Passwords that stay different are fine: only the permissions password gives owner rights.
let distinct = [
protection(Some("A"), Some("\u{FF21}\u{FF22}")),
Protection { algorithm: Rc4_128, ..protection(Some(&format!("1{a32}")), Some(&format!("2{a32}"))) },
Protection { algorithm: Aes128, ..protection(Some(&format!("1{a32}")), Some(&format!("2{a32}"))) },
];
for p in &distinct {
assert_eq!(protect_and_reopen(p), (Ok(()), Some([None, Some(User), Some(Owner)])), "{:?}", p.algorithm);
}
}
/// A one-page form: a text field and a check box (with appearance states).
fn form_fixture() -> Vec<u8> {
let objs = [
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >> >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>",
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [20 250 180 270] /P 3 0 R /MK << /BC [0 0 0] >> >>",
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (ok) /V /Off /AS /Off /Rect [20 200 35 215] /P 3 0 R /AP << /N << /Yes 7 0 R /Off 7 0 R >> >> >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
"<< /Length 0 >>\nstream\n\nendstream",
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
/// A value its field's font can't draw is kept, and the edit that drew it reports it in
/// `edit_notices` (the interface shows it, tools return it): not a later unrelated edit, not a
/// failed one, and not a value that can be drawn. The form (an inline AcroForm here) asks other
/// viewers to redraw it.
#[test]
fn a_value_its_font_cannot_draw_is_reported_by_the_edit_that_drew_it() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("form.pdf", None, Arc::new(form_fixture()), None).unwrap();
let fill = |s: &mut Session, name: &str, v: FieldValue| s.apply(id, Edit::SetFieldValue { name: name.into(), value: v });
fill(&mut s, "name", FieldValue::Text("张三".into())).unwrap();
let d = s.get(id).unwrap();
assert_eq!(d.form[0].value, ["张三"]);
let n = &d.edit_notices;
assert_eq!(n.len(), 1, "{n:?}");
assert_eq!((n[0].field.as_deref(), n[0].page, n[0].ch, n[0].redrawn_elsewhere), (Some("name"), 0, '张', true));
assert!(n[0].to_string().contains("\"张\" (U+5F20)") && n[0].to_string().contains("other PDF viewers redraw"), "{}", n[0]);
fill(&mut s, "ok", FieldValue::Check(true)).unwrap();
assert!(s.get(id).unwrap().edit_notices.is_empty(), "an unrelated edit reports nothing");
fill(&mut s, "name", FieldValue::Text("李四".into())).unwrap();
assert_eq!(s.get(id).unwrap().edit_notices.len(), 1, "a new value is reported again");
assert!(fill(&mut s, "missing", FieldValue::Text("x".into())).is_err());
assert!(s.get(id).unwrap().edit_notices.is_empty(), "no stale notice after a failed edit");
fill(&mut s, "name", FieldValue::Text("Ada".into())).unwrap();
assert!(s.get(id).unwrap().edit_notices.is_empty());
let saved = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap();
assert!(pdfcraft_forms::need_appearances(&saved));
}
#[test]
fn filling_a_form_shows_saves_and_undoes() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("form.pdf", None, Arc::new(form_fixture()), None).unwrap();
assert_eq!(s.get(id).unwrap().form.len(), 2);
s.apply(id, Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("Ada Lovelace".into()) }).unwrap();
s.apply(id, Edit::SetFieldValue { name: "ok".into(), value: FieldValue::Check(true) }).unwrap();
let d = s.get(id).unwrap();
assert_eq!(d.can_undo(), Some("Fill in ok"));
assert_eq!(d.form[0].value, ["Ada Lovelace"]);
assert_eq!(d.form[1].value, ["Yes"]);
assert_eq!(page_texts(&s, id), ["Ada Lovelace"], "the new appearance shows the text");
let err = s.apply(id, Edit::SetFieldValue { name: "missing".into(), value: FieldValue::Text("x".into()) }).unwrap_err();
assert_eq!(err.to_string(), "there is no field named \"missing\"");
s.apply(id, Edit::ResetForm { names: None }).unwrap();
assert!(s.get(id).unwrap().form.iter().all(|f| f.value.is_empty()));
s.undo(id).unwrap();
assert_eq!(s.get(id).unwrap().form[0].value, ["Ada Lovelace"]);
let saved = s.save_bytes(id).unwrap();
let mut s2 = Session::new();
let id2 = s2.open("f.pdf", None, saved, None).unwrap();
assert_eq!(s2.get(id2).unwrap().form[1].value, ["Yes"]);
}
#[test]
fn comment_edits_refresh_the_list_exactly_as_a_full_inspection_would() {
let (mut s, id) = session_with(3);
let add = |page: usize, shape: Shape, contents: &str| {
Edit::AddAnnotation(NewAnnotation {
page,
shape,
style: Style::default_for(&Shape::Rectangle { rect: [0.0; 4] }),
contents: contents.into(),
author: "Ada".into(),
})
};
s.apply(id, add(0, Shape::Rectangle { rect: [10.0, 10.0, 60.0, 60.0] }, "box")).unwrap();
s.apply(id, add(1, Shape::TextMarkup { kind: Markup::Highlight, quads: vec![[20.0, 170.0, 90.0, 170.0, 20.0, 150.0, 90.0, 150.0]] }, ""))
.unwrap();
s.apply(id, add(1, Shape::TextBox { rect: [10.0, 200.0, 150.0, 240.0], font_size: 11.0 }, "Text box")).unwrap();
s.apply(id, add(2, Shape::Note { at: [100.0, 280.0], icon: NoteIcon::Comment }, "Sticky")).unwrap();
s.apply(id, Edit::ReplyToAnnotation { page: 2, index: 0, text: "Reply".into(), author: "Bob".into() }).unwrap();
s.apply(id, Edit::SetAnnotationStatus { page: 2, index: 0, state: ReviewState::Accepted, author: "Bob".into() }).unwrap();
s.apply(id, Edit::MarkAnnotation { page: 2, index: 0, marked: true, author: "Bob".into() }).unwrap();
s.apply(id, Edit::LockAnnotation { page: 1, index: 1, locked: true }).unwrap();
s.apply(id, Edit::MoveAnnotation { page: 0, index: 0, dx: 5.0, dy: 5.0 }).unwrap();
s.apply(id, Edit::DeleteAnnotation { page: 1, index: 0 }).unwrap();
s.undo(id).unwrap();
let d = s.get(id).unwrap();
let full = pdfcraft_render::inspect(d.bytes.clone(), None).unwrap();
assert_eq!(format!("{:?}", d.info.annotations), format!("{:?}", full.annotations));
assert_eq!(d.info.file_size, full.file_size);
assert_eq!(d.info.annotations.len(), 7);
assert!(d.info.annotations.iter().any(|a| a.locked));
}
#[test]
fn form_edits_refresh_field_values_without_a_full_inspection() {
let mut s = Session::new();
let id = s.open("form.pdf", None, Arc::new(form_fixture()), None).unwrap();
s.apply(id, Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("Ada".into()) }).unwrap();
s.apply(id, Edit::SetFieldValue { name: "ok".into(), value: FieldValue::Check(true) }).unwrap();
let d = s.get(id).unwrap();
let full = pdfcraft_render::inspect(d.bytes.clone(), None).unwrap();
let values = |fs: &[pdfcraft_render::Field]| fs.iter().map(|f| (f.name.clone(), f.value.clone())).collect::<Vec<_>>();
assert_eq!(values(&d.info.fields), values(&full.fields));
assert_eq!(values(&d.info.fields), [("name".to_string(), Some("Ada".to_string())), ("ok".to_string(), Some("Yes".to_string()))]);
}
#[test]
fn form_refresh_keeps_first_value_when_field_names_repeat() {
// Two widgets share one name; the refresh reads the first.
let objs = [
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >> >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>",
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>",
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V (first) /Rect [20 250 180 270] /P 3 0 R >>",
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V (second) /Rect [20 220 180 240] /P 3 0 R >>",
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (other) /Rect [20 190 180 210] /P 3 0 R >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
let mut s = Session::new();
let id = s.open("dup.pdf", None, Arc::new(bytes), None).unwrap();
s.apply(id, Edit::SetFieldValue { name: "other".into(), value: FieldValue::Text("x".into()) }).unwrap();
let dup = s.get(id).unwrap().info.fields.iter().find(|f| f.name == "dup").unwrap().value.clone();
assert_eq!(dup.as_deref(), Some("first"));
}
#[test]
fn multiselect_choice_indices_stay_sorted_through_engine_save_and_reopen() {
let (mut s, id) = session_with(1);
s.apply(
id,
Edit::AddField {
page: 0,
rect: [20.0, 180.0, 180.0, 240.0],
kind: NewField::List { options: vec!["Cheese".into(), "Ham".into(), "Olives".into()], multi: true },
name: Some("toppings".into()),
},
)
.unwrap();
let chosen = vec!["Olives".to_string(), "Ham".to_string()];
s.apply(id, Edit::SetFieldValue { name: "toppings".into(), value: FieldValue::Choice(chosen.clone()) }).unwrap();
assert_eq!(s.get(id).unwrap().form[0].value, chosen, "the live engine keeps selection order");
let saved = s.save_bytes(id).unwrap();
let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap();
let all = pdfcraft_forms::fields(&cos);
let list = all.iter().find(|field| field.name == "toppings").unwrap();
let object = cos.get(list.obj);
let indices = object.as_dict().unwrap().get(b"I").unwrap().as_array().unwrap();
assert_eq!(indices.iter().map(|index| index.as_int().unwrap()).collect::<Vec<_>>(), [1, 2]);
assert_eq!(list.value, chosen, "sorted /I does not reorder /V");
let reopened = s.open("reopened.pdf", None, saved, None).unwrap();
assert_eq!(s.get(reopened).unwrap().form[0].value, chosen, "reopening keeps selection order");
}
#[test]
fn preparing_a_form_adds_renames_and_deletes_fields() {
let (mut s, id) = session_with(1);
assert!(s.get(id).unwrap().form.is_empty());
let add = |kind: NewField, y: f64| Edit::AddField { page: 0, rect: [20.0, y, 180.0, y + 22.0], kind, name: None };
s.apply(id, add(NewField::Text { multiline: false }, 250.0)).unwrap();
s.apply(id, add(NewField::CheckBox, 200.0)).unwrap();
s.apply(id, add(NewField::Text { multiline: false }, 150.0)).unwrap();
let names = |s: &Session| s.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect::<Vec<_>>();
assert_eq!(names(&s), ["Text1", "Check Box1", "Text2"]);
assert_eq!(s.get(id).unwrap().can_undo(), Some("Add field"));
let props = FieldProps { name: Some("email".into()), tooltip: Some("Your e-mail".into()), required: Some(true), ..Default::default() };
s.apply(id, Edit::SetFieldProps { name: "Text2".into(), props: Box::new(props) }).unwrap();
s.apply(id, Edit::DeleteField { name: "Check Box1".into() }).unwrap();
assert_eq!(names(&s), ["Text1", "email"]);
s.apply(id, Edit::SetFieldValue { name: "email".into(), value: FieldValue::Text("ada@example.org".into()) }).unwrap();
let saved = s.save_bytes(id).unwrap();
let mut s2 = Session::new();
let id2 = s2.open("f.pdf", None, saved, None).unwrap();
let d = s2.get(id2).unwrap();
assert_eq!(d.form.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), ["Text1", "email"]);
assert_eq!(d.form[1].value, ["ada@example.org"]);
assert!(page_texts(&s2, id2).iter().any(|t| t.contains("ada@example.org")));
s.undo(id).unwrap();
s.undo(id).unwrap();
assert_eq!(names(&s), ["Text1", "Check Box1", "email"]);
}
/// Setting the calculation order is one undoable edit that recalculates: values that ran too
/// early come out right at once, a script calculation included, and undo brings back the old
/// order and values together.
#[test]
fn setting_the_calculation_order_recalculates_and_undoes() {
use form_scripts::{CalcOp, Calculate};
let (mut s, id) = session_with(1);
for (i, name) in ["qty", "sub", "total", "tax"].into_iter().enumerate() {
let y = 250.0 - 40.0 * i as f64;
let kind = NewField::Text { multiline: false };
s.apply(id, Edit::AddField { page: 0, rect: [20.0, y, 180.0, y + 22.0], kind, name: Some(name.into()) }).unwrap();
}
let calculate = |name: &str, c: Calculate| Edit::SetFieldProps {
name: name.into(),
props: Box::new(FieldProps { calculate: Some(c), ..Default::default() }),
};
// Defined backwards: total, which reads sub, calculates first.
s.apply(id, calculate("total", Calculate::Simple { op: CalcOp::Sum, fields: vec!["sub".into()] })).unwrap();
s.apply(id, calculate("sub", Calculate::Notation("qty * 2".into()))).unwrap();
let script = Some("event.value = this.getField(\"total\").value / 10;".to_string());
s.apply(id, Edit::SetFieldScript { name: "tax".into(), event: "calculate".into(), script }).unwrap();
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("5".into()) }).unwrap();
let values = |s: &Session| {
let form = &s.get(id).unwrap().form;
["sub", "total", "tax"].map(|n| form.iter().find(|f| f.name == n).and_then(|f| f.value.first().cloned()).unwrap_or_default())
};
assert_eq!(values(&s), ["10", "0", "0"], "total and tax ran before sub");
let order = s.get(id).unwrap().calculation_order();
assert_eq!(order.fields.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), ["total", "sub", "tax"]);
assert_eq!(order.fields[0].issues, [CalculationIssue::RunsTooEarly { field: "sub".into() }]);
assert_eq!(order.fixed(), ["sub", "total", "tax"]);
s.apply(id, Edit::SetCalculationOrder { names: order.fixed() }).unwrap();
assert_eq!(values(&s), ["10", "10", "1"]);
assert_eq!(s.get(id).unwrap().can_undo(), Some("Set calculation order"));
assert!(s.get(id).unwrap().calculation_order().fields.iter().all(|f| f.issues.is_empty()));
s.undo(id).unwrap();
assert_eq!(values(&s), ["10", "0", "0"]);
assert_eq!(s.get(id).unwrap().calculation_order().fixed(), ["sub", "total", "tax"], "the old order is back");
assert!(matches!(s.apply(id, Edit::SetCalculationOrder { names: vec!["qty".into()] }), Err(EditError::Form(_))));
}
/// One 612×300 page with the content stream `body`. Object 4 is Helvetica; `resources` is what
/// the page's /Resources holds.
fn one_line_page(resources: &str, body: &str) -> Vec<u8> {
one_line_page_with(resources, "", body)
}
/// [`one_line_page`] with `stream_entries` added to the content stream's dictionary.
fn one_line_page_with(resources: &str, stream_entries: &str, body: &str) -> Vec<u8> {
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 300] >>".to_string(),
format!("<< /Type /Page /Parent 2 0 R /Resources << {resources} >> /Contents 5 0 R >>"),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(),
format!("<< /Length {} {stream_entries} >>\nstream\n{body}\nendstream", body.len()),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
/// Put a redaction mark over `needle` where page 1 shows it, as Find text and redact does.
fn mark_shown_text(s: &mut Session, id: DocId, needle: &str) {
let doc = s.get(id).unwrap();
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), pdfcraft_render::RenderConfig::default());
let text = r.render(RenderRequestFor::text(0)).text.expect("the page has text");
let range = text.find(needle).pop().expect("the page shows the text");
let info = &doc.info.pages[0];
let quads = text.line_rects(range).into_iter().map(|v| pdfcraft_annot::rect_quad(info.view_rect_to_user(v).map(f64::from))).collect();
let shape = Shape::Redact { quads, overlay: String::new(), look: Default::default() };
let mark =
Edit::AddAnnotation(NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: String::new(), author: "Ada".into() });
s.apply(id, mark).unwrap();
}
/// Redaction removes text by the positions its own interpreter works out, and the renderer has
/// the last word: where the two disagree, a page that still shows a glyph mostly under a mark
/// fails the edit. The check is run here on a page nothing was removed from, as if the
/// interpreter had placed the word elsewhere. Only the overlay streams an apply added are left
/// out: a content stream of the input labelled like one (`/PCMark /Redaction`) is checked too.
#[test]
fn the_renderer_check_refuses_text_still_shown_under_a_mark() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let page = one_line_page_with("/Font << /F1 4 0 R >>", "/PCMark /Redaction", "BT /F1 16 Tf 20 150 Td (Public words then SECRET42) Tj ET");
let id = s.open("shown.pdf", None, Arc::new(page), None).unwrap();
mark_shown_text(&mut s, id, "SECRET42");
let doc = s.get(id).unwrap();
let mut cos = doc.editor.as_ref().unwrap().cos.clone();
let marks = pdfcraft_redact::marks(&cos);
let refused = |cos: &pdfcraft_cos::Document| {
let shown = shown_under_redactions(cos, &marks, &[], &doc.info.pages, None);
matches!(shown, Err(EditError::Redact(pdfcraft_redact::RedactError::Residue(n))) if n > 0)
};
assert!(refused(&cos));
// The page's contents as an array, as they are once an apply has added its overlay.
let page = pdfcraft_model::pages(&cos).swap_remove(0);
let contents = page.dict.get(b"Contents").cloned().unwrap();
cos.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), pdfcraft_cos::Object::Array(vec![contents]))).unwrap();
assert!(refused(&cos), "the labelled input stream was not checked");
}
/// Inline image data `7FEI 1 0 0 1 200 0 cm` holds an `EI` the renderer ends the image at, so it
/// runs the `cm` and shows SECRET 200 pt to the right. Redaction reads the image the same way, so
/// a mark drawn over the word as shown takes it out of the file.
#[test]
fn text_after_an_inline_image_is_redacted_where_the_page_shows_it() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let page = one_line_page(
"/Font << /F1 4 0 R >>",
"BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 7FEI 1 0 0 1 200 0 cm EI BT /F1 10 Tf 10 100 Td (SECRET) Tj ET",
);
let id = s.open("inline.pdf", None, Arc::new(page), None).unwrap();
mark_shown_text(&mut s, id, "SECRET");
s.apply(id, Edit::ApplyRedactions { pages: None }).unwrap();
assert!(!page_texts(&s, id).concat().contains("SECRET"));
let saved = s.save_bytes(id).unwrap();
let held = |d: &[u8]| d.windows(6).any(|w| w == b"SECRET");
assert!(!held(&saved), "the saved file holds the redacted word");
let reopened = pdfcraft_cos::Document::open(saved).unwrap();
assert!(
!reopened.scan_objects().any(|(_, o)| matches!(&*o, pdfcraft_cos::Object::Stream(st) if st.decoded().is_ok_and(|d| held(&d)))),
"a saved stream holds the redacted word"
);
}
/// A word whose font and size an ExtGState /Font entry (`gs`) sets is placed where the renderer
/// draws it, so a mark drawn over it as displayed takes it out of the file.
#[test]
fn a_word_in_a_font_set_by_an_extgstate_is_redacted() {
let line = "Public words then SECRET42 and more";
let mut s = Session::new().with_clock(|| 1_700_000_000);
let by_gs = one_line_page("/ExtGState << /GS1 << /Type /ExtGState /Font [4 0 R 16] >> >>", &format!("BT /GS1 gs 20 150 Td ({line}) Tj ET"));
let id = s.open("gs.pdf", None, Arc::new(by_gs), None).unwrap();
mark_shown_text(&mut s, id, "SECRET42");
s.apply(id, Edit::ApplyRedactions { pages: None }).unwrap();
let shown = page_texts(&s, id).swap_remove(0);
assert!(shown.starts_with("Public words then") && shown.ends_with("and more") && !shown.contains("SECRET42"), "{shown}");
let saved = s.save_bytes(id).unwrap();
let held = |d: &[u8]| d.windows(8).any(|w| w == b"SECRET42");
assert!(!held(&saved), "the saved file holds the redacted word");
let reopened = pdfcraft_cos::Document::open(saved).unwrap();
assert!(
!reopened.scan_objects().any(|(_, o)| matches!(&*o, pdfcraft_cos::Object::Stream(st) if st.decoded().is_ok_and(|d| held(&d)))),
"a saved stream holds the redacted word"
);
}
#[test]
fn redaction_marks_apply_for_good_and_undo() {
let (mut s, id) = session_with(2);
// "Page 1" at 24 pt from x 20: the "1" starts near x 82.7 (Helvetica widths).
let shape =
Shape::Redact { quads: vec![pdfcraft_annot::rect_quad([80.0, 140.0, 100.0, 180.0])], overlay: String::new(), look: Default::default() };
let mark =
Edit::AddAnnotation(NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: String::new(), author: "Ada".into() });
s.apply(id, mark).unwrap();
assert_eq!(s.get(id).unwrap().can_undo(), Some("Add redaction mark"));
assert_eq!(s.get(id).unwrap().redaction_marks(), 1);
assert_eq!(page_texts(&s, id), ["Page 1", "Page 2"], "marking alone changes nothing");
s.apply(id, Edit::ApplyRedactions { pages: None }).unwrap();
let d = s.get(id).unwrap();
assert_eq!((d.can_undo(), d.redaction_marks()), (Some("Apply redactions"), 0));
// An independent extractor (the renderer's) no longer finds the "1".
assert_eq!(page_texts(&s, id), ["Page", "Page 2"]);
let saved = s.save_bytes(id).unwrap();
assert!(!saved.windows(8).any(|w| w == b"(Page 1)"), "a full rewrite: the old revision is gone");
assert!(saved.windows(8).any(|w| w == b"(Page 2)"));
let mut s2 = Session::new();
let id2 = s2.open("r.pdf", None, saved, None).unwrap();
assert_eq!(page_texts(&s2, id2), ["Page", "Page 2"]);
s.undo(id).unwrap();
assert_eq!(page_texts(&s, id), ["Page 1", "Page 2"]);
assert_eq!(s.apply(id, Edit::ClearRedactions).map(|_| s.get(id).unwrap().redaction_marks()), Ok(0));
assert!(matches!(s.apply(id, Edit::ApplyRedactions { pages: None }), Err(EditError::Redact(_))));
}
#[test]
fn oversized_redaction_overlay_preserves_the_document_and_undo() {
let (mut s, id) = session_with(1);
let shape = Shape::Redact {
quads: vec![pdfcraft_annot::rect_quad([0.0, 0.0, 16000.0, 1600.0])],
overlay: "X".into(),
look: pdfcraft_annot::OverlayLook { size: 2.0, repeat: true, ..Default::default() },
};
s.apply(
id,
Edit::AddAnnotation(NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: String::new(), author: "Tester".into() }),
)
.unwrap();
let before = s.get(id).unwrap().bytes.clone();
assert!(matches!(s.apply(id, Edit::ApplyRedactions { pages: None }), Err(EditError::Redact(pdfcraft_redact::RedactError::OverlayLimit))));
let doc = s.get(id).unwrap();
assert_eq!(doc.bytes, before);
assert_eq!(doc.redaction_marks(), 1);
assert_eq!(doc.can_undo(), Some("Add redaction mark"));
assert_eq!(page_texts(&s, id), ["Page 1"]);
s.undo(id).unwrap();
assert_eq!(s.get(id).unwrap().redaction_marks(), 0);
assert_eq!(page_texts(&s, id), ["Page 1"]);
}
/// #3046: a layer hidden in the Layers panel stays off on paper, as it is on screen; with the
/// layers untouched, the document's own configuration prints.
#[test]
fn printing_keeps_hidden_layers_hidden() {
let content = b"/OC /A BDC 1 0 0 rg 10 10 80 80 re f EMC /OC /B BDC 0 1 0 rg 110 10 80 80 re f EMC";
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R] /D << /Order [5 0 R 6 0 R] >> >> >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R /Resources << /Properties << /A 5 0 R /B 6 0 R >> >> >>".to_vec(),
[format!("<< /Length {} >>\nstream\n", content.len()).as_bytes(), content, b"\nendstream"].concat(),
b"<< /Type /OCG /Name (Layer A) >>".to_vec(),
b"<< /Type /OCG /Name (Layer B) >>".to_vec(),
];
let mut pdf = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
pdf.extend_from_slice(o);
pdf.extend_from_slice(b"\nendobj\n");
}
let xref = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
// Red and green pixels on the first printed sheet.
let printed = |s: &Session, id: DocId| {
let bytes = s.print_pdf(id, &print::Settings { pages: vec![0], ..Default::default() }).unwrap();
let mut out = Session::new();
let printed = out.open("print.pdf", None, Arc::new(bytes), None).unwrap();
let page = render_page(&out, printed);
let px = page.rgba.as_chunks::<4>().0;
let red = px.iter().filter(|p| p[0] > 200 && p[1] < 60 && p[2] < 60).count();
let green = px.iter().filter(|p| p[1] > 200 && p[0] < 60 && p[2] < 60).count();
(red, green)
};
let mut s = Session::new();
let id = s.open("layers.pdf", None, Arc::new(pdf), None).unwrap();
let (red, green) = printed(&s, id);
assert!(red > 100 && green > 100, "untouched layers both print: {red} red, {green} green");
let a = s.get(id).unwrap().info.layers.iter().position(|l| l.name == "Layer A").unwrap();
assert!(s.set_layer_visible(id, a, false));
let (red, green) = printed(&s, id);
assert_eq!(red, 0, "Layer A was hidden, so it doesn't print");
assert!(green > 100, "Layer B still prints: {green} green");
assert!(s.set_layer_visible(id, a, true));
let (red, _) = printed(&s, id);
assert!(red > 100, "shown again, Layer A prints again: {red} red");
}
#[test]
fn printing_lays_out_sheets_that_render() {
let (s, id) = session_with(5);
let settings = print::Settings {
pages: print::select_pages(5, Some("2-5"), &[], print::Subset::All, false).unwrap(),
layout: print::Layout::multiple(2),
..Default::default()
};
let bytes = s.print_pdf(id, &settings).unwrap();
let mut s2 = Session::new();
let id2 = s2.open("print.pdf", None, Arc::new(bytes), None).unwrap();
assert_eq!(s2.get(id2).unwrap().info.pages.len(), 2, "four pages, two per sheet");
let texts = page_texts(&s2, id2);
assert!(texts[0].contains("Page 2") && texts[0].contains("Page 3") && texts[1].contains("Page 5"), "{texts:?}");
// Printing honours the permissions.
let p =
Protection { open_password: Some("pw".into()), permissions_password: Some("owner".into()), printing: Printing::None, ..Default::default() };
let (mut s3, id3) = session_with(1);
s3.apply(id3, Edit::Protect(p)).unwrap();
let saved = s3.save_bytes(id3).unwrap();
let mut s4 = Session::new();
let id4 = s4.open("locked.pdf", None, saved, Some("pw")).unwrap();
assert_eq!(s4.print_pdf(id4, &print::Settings { pages: vec![0], ..Default::default() }), Err(EditError::NotPermitted("printing")));
}
/// A one-page document whose red square is in a layer; `off` switches the layer off by default.
fn red_layer_pdf(off: bool) -> Vec<u8> {
let content = "/OC /L1 BDC 1 0 0 rg 0 0 100 100 re f EMC";
let off = if off { "/OFF [5 0 R] " } else { "" };
format!(
"%PDF-1.7\n\
1 0 obj << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R] /D << {off}/Order [5 0 R] >> >> >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /Properties << /L1 5 0 R >> >> >> endobj\n\
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
5 0 obj << /Type /OCG /Name (Red) >> endobj\n\
trailer << /Root 1 0 R >>\n%%EOF",
content.len()
)
.into_bytes()
}
/// Does the first page of `pdf` show any red when drawn?
fn first_page_has_red(pdf: Vec<u8>) -> bool {
let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(pdf), pdfcraft_render::RenderConfig::default());
let page = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 0.5, ..Default::default() });
page.rgba.as_chunks::<4>().0.iter().any(|p| p[0] > 200 && p[1] < 60 && p[2] < 60)
}
#[test]
fn a_layer_that_is_off_does_not_print_and_one_that_is_on_does() {
for (off, prints) in [(true, false), (false, true)] {
let source = red_layer_pdf(off);
assert_eq!(first_page_has_red(source.clone()), prints, "on screen (off: {off})");
let mut s = Session::new();
let id = s.open("layers.pdf", None, Arc::new(source), None).unwrap();
let sheet = s.print_pdf(id, &print::Settings { pages: vec![0], ..Default::default() }).unwrap();
assert_eq!(first_page_has_red(sheet), prints, "printed (layer off by default: {off})");
}
}
#[test]
fn crop_and_duplicate_pages_show_in_the_viewer() {
let (mut s, id) = session_with(2);
s.apply(id, Edit::SetPageBox { pages: vec![0], which: PageBox::Crop, spec: BoxSpec::Margins([10.0, 20.0, 30.0, 40.0]) }).unwrap();
let d = s.get(id).unwrap();
assert_eq!(d.can_undo(), Some("Crop page"));
assert_eq!(d.info.pages[0].crop, [10.0, 20.0, 170.0, 260.0]);
assert_eq!((d.info.pages[0].width, d.info.pages[0].height), (160.0, 240.0), "the viewer shows the cropped size");
assert_eq!(d.page_boxes()[1][1], [0.0, 0.0, 200.0, 300.0]);
s.apply(id, Edit::DuplicatePages { pages: vec![1], at: None }).unwrap();
assert_eq!(page_texts(&s, id), ["Page 1", "Page 2", "Page 2"]);
assert_eq!(s.get(id).unwrap().can_undo(), Some("Duplicate page"));
}
#[test]
fn headers_footers_watermarks_and_backgrounds_show_update_and_remove() {
let (mut s, id) = session_with(2);
let mut hf = HeaderFooter::default();
hf.text[4] = "Page <<1>> of <<n>> · <<yyyy-mm-dd>>".into();
s.apply(id, Edit::AddHeaderFooter { pages: vec![0, 1], settings: hf.clone(), replace: false }).unwrap();
// The session clock is 2023-11-14.
assert_eq!(page_texts(&s, id)[1], "Page 2\nPage 2 of 2 · 2023-11-14");
assert_eq!(s.get(id).unwrap().marks(), [MarkKind::HeaderFooter]);
hf.text[4] = "Draft".into();
s.apply(id, Edit::AddHeaderFooter { pages: vec![0, 1], settings: hf, replace: true }).unwrap();
assert_eq!(page_texts(&s, id)[0], "Page 1\nDraft");
s.apply(
id,
Edit::AddWatermark { pages: vec![0], settings: Watermark { text: "SECRET".into(), ..Watermark::default() }, file: None, replace: false },
)
.unwrap();
s.apply(
id,
Edit::AddBackground {
pages: vec![1],
settings: Background { color: [1.0, 0.9, 0.9], opacity: 1.0, ..Background::default() },
replace: false,
file: None,
},
)
.unwrap();
assert_eq!(s.get(id).unwrap().marks().len(), 3);
assert!(page_texts(&s, id)[0].contains("SECRET"));
s.apply(id, Edit::RemoveMarks { kind: MarkKind::HeaderFooter }).unwrap();
s.apply(id, Edit::RemoveMarks { kind: MarkKind::Watermark }).unwrap();
s.apply(id, Edit::RemoveMarks { kind: MarkKind::Background }).unwrap();
assert_eq!(page_texts(&s, id), ["Page 1", "Page 2"]);
assert!(s.get(id).unwrap().marks().is_empty());
assert!(matches!(s.apply(id, Edit::RemoveMarks { kind: MarkKind::Watermark }), Err(EditError::Edit(_))));
assert_eq!(s.get(id).unwrap().can_undo(), Some("Remove background"));
}
#[test]
fn marker_indexes_match_after_open_and_save_when_requested() {
let (mut source, source_id) = session_with(1);
let mut header = HeaderFooter::default();
header.text[0] = "Header".into();
source.apply(source_id, Edit::AddHeaderFooter { pages: vec![0], settings: header, replace: false }).unwrap();
source
.apply(
source_id,
Edit::AddWatermark { pages: vec![0], settings: Watermark { text: "Draft".into(), ..Watermark::default() }, file: None, replace: false },
)
.unwrap();
source
.apply(
source_id,
Edit::AddBackground {
pages: vec![0],
settings: Background { color: [0.9, 0.9, 1.0], opacity: 1.0, ..Background::default() },
replace: false,
file: None,
},
)
.unwrap();
source
.apply(
source_id,
Edit::AddText { page: 0, text: AddedText { rect: [20.0, 250.0, 180.0, 280.0], text: "Added note".into(), ..Default::default() } },
)
.unwrap();
let bytes = source.save_bytes(source_id).unwrap();
let reference = pdfcraft_cos::Document::open(bytes.clone()).unwrap();
let expected_marks = pdfcraft_edit::marks_present(&reference);
let expected_added = pdfcraft_edit::list_added(&reference);
source.mark_saved(source_id, bytes.clone(), None).unwrap();
let refreshed = source.get(source_id).unwrap();
assert_eq!(refreshed.marks(), expected_marks);
assert_eq!(refreshed.added(), expected_added);
let mut reopened = Session::new();
let reopened_id = reopened.open("marked.pdf", None, bytes, None).unwrap();
let opened = reopened.get(reopened_id).unwrap();
assert_eq!(opened.marks(), expected_marks);
assert_eq!(opened.added(), expected_added);
assert_eq!(opened.marks().len(), 3);
assert!(opened.marks().contains(&MarkKind::HeaderFooter));
assert!(opened.marks().contains(&MarkKind::Watermark));
assert!(opened.marks().contains(&MarkKind::Background));
assert_eq!(opened.added().len(), 1);
}
#[test]
fn secondary_indexes_load_once_and_follow_edit_scopes() {
reset_secondary_scan_counts();
let (mut session, id) = session_with(1);
assert_eq!(secondary_scan_counts(), (0, 0), "open does not scan markers or links");
{
let doc = session.get(id).unwrap();
assert!(doc.marks().is_empty());
assert!(doc.added().is_empty());
assert!(doc.added().is_empty());
assert_eq!(secondary_scan_counts(), (1, 0), "marks and added content share one scan");
assert!(doc.links().is_empty());
assert!(doc.links().is_empty());
assert_eq!(secondary_scan_counts(), (1, 1), "repeated reads reuse the link list");
}
session.apply(id, Edit::SetInfo { key: "Title".into(), value: "Changed".into() }).unwrap();
assert!(session.get(id).unwrap().links().is_empty());
assert!(session.get(id).unwrap().marks().is_empty());
assert_eq!(secondary_scan_counts(), (1, 1), "metadata does not invalidate either index");
session.apply(id, rect_comment(0, [20.0, 20.0, 60.0, 60.0])).unwrap();
assert!(session.get(id).unwrap().links().is_empty());
assert_eq!(secondary_scan_counts(), (1, 2), "comment edits invalidate the annotation-position link index");
session
.apply(
id,
Edit::AddLink {
page: 0,
rect: [10.0, 10.0, 80.0, 30.0],
action: LinkAction::Uri("https://example.org".into()),
style: LinkStyle::default(),
},
)
.unwrap();
assert_eq!(secondary_scan_counts(), (1, 2), "a link edit invalidates only the link list");
assert_eq!(session.get(id).unwrap().links()[0].index, 1, "link indexes include earlier annotations");
assert_eq!(secondary_scan_counts(), (1, 3));
session.undo(id).unwrap();
assert_eq!(session.get(id).unwrap().links().len(), 0);
assert_eq!(secondary_scan_counts(), (1, 4), "undo rebuilds the link list once");
session.redo(id).unwrap();
assert_eq!(session.get(id).unwrap().links()[0].index, 1);
assert_eq!(secondary_scan_counts(), (1, 5), "redo rebuilds the link list once");
session.apply(id, Edit::DeleteAnnotation { page: 0, index: 0 }).unwrap();
assert_eq!(session.get(id).unwrap().links()[0].index, 0, "deleting an earlier annotation shifts link indexes");
assert_eq!(secondary_scan_counts(), (1, 6), "comment deletion rebuilds annotation-position indexes");
session
.apply(id, Edit::AddText { page: 0, text: AddedText { rect: [20.0, 250.0, 100.0, 280.0], text: "Added".into(), ..Default::default() } })
.unwrap();
assert_eq!(secondary_scan_counts(), (1, 6), "content edits defer rebuilding until a marker getter is used");
assert_eq!(session.get(id).unwrap().added().len(), 1);
assert_eq!(session.get(id).unwrap().links().len(), 1);
assert_eq!(secondary_scan_counts(), (2, 6), "content edits preserve the link cache");
let mut header = HeaderFooter::default();
header.text[0] = "Header".into();
session.apply(id, Edit::AddHeaderFooter { pages: vec![0], settings: header, replace: false }).unwrap();
assert_eq!(session.get(id).unwrap().marks(), [MarkKind::HeaderFooter]);
assert_eq!(secondary_scan_counts(), (3, 6));
session.undo(id).unwrap();
assert!(session.get(id).unwrap().marks().is_empty());
assert_eq!(secondary_scan_counts(), (4, 6), "undo invalidates marker state");
session.undo(id).unwrap();
assert!(session.get(id).unwrap().added().is_empty());
assert_eq!(secondary_scan_counts(), (5, 6), "undo of added content invalidates marker state");
session.redo(id).unwrap();
assert_eq!(session.get(id).unwrap().added().len(), 1);
assert_eq!(secondary_scan_counts(), (6, 6), "redo of added content rebuilds once");
session.redo(id).unwrap();
assert_eq!(session.get(id).unwrap().marks(), [MarkKind::HeaderFooter]);
assert_eq!(secondary_scan_counts(), (7, 6), "redo of marks rebuilds once");
}
#[test]
fn export_images_and_text_follow_the_working_file() {
let (mut s, id) = session_with(2);
s.apply(id, Edit::RotatePages { pages: vec![1], degrees: 90 }).unwrap();
let doc = s.get(id).unwrap();
let mut ex = export::Exporter::new(doc);
let png = ex.png(1, 144.0).unwrap();
assert_eq!(&png[1..4], b"PNG");
// 200×300 pt rotated → 300×200 pt at 2 px/pt.
let w = u32::from_be_bytes(png[16..20].try_into().unwrap());
let h = u32::from_be_bytes(png[20..24].try_into().unwrap());
assert_eq!((w, h), (600, 400));
assert_eq!(ex.text_of(&[0, 1]).unwrap(), "Page 1\n\u{c}Page 2\n");
assert!(ex.png(5, 72.0).is_err());
}
#[test]
fn create_and_reduce() {
let mut s = Session::new();
let blank = s.create_blank(612.0, 792.0, 2).unwrap();
let id = s.open_new("Untitled.pdf", blank).unwrap();
assert_eq!(s.get(id).unwrap().info.pages.len(), 2);
let text = s.create_from_text("notes", "hello\nworld").unwrap();
let t = s.open_new("notes.pdf", text).unwrap();
assert_eq!(page_texts(&s, t), ["hello\nworld"]);
// A file with the same page imported twice shares nothing until reduced.
let (mut s2, id2) = session_with(1);
let src = Arc::new(fixture(1));
s2.apply(
id2,
Edit::InsertPagesFrom {
name: "x".into(),
bytes: src.clone(),
pages: None,
at: 1,
field_conflicts: pdfcraft_organize::FieldConflicts::Rename,
},
)
.unwrap();
let full = s2.save_full_bytes(id2).unwrap();
let (reduced, _) = s2.reduced_bytes(id2).unwrap();
assert!(reduced.len() <= full.len(), "{} > {}", reduced.len(), full.len());
let mut s3 = Session::new();
let r = s3.open("r.pdf", None, reduced, None).unwrap();
assert_eq!(page_texts(&s3, r), ["Page 1", "Page 1"]);
}
#[test]
fn an_optimize_job_reports_its_stages_and_can_be_cancelled() {
use crate::optimizer::OptimizeStage;
let (s, id) = session_with(2);
let settings = optimize::Settings::default();
let mut stages = Vec::new();
let (bytes, _) = s
.optimize_job(id, &settings, &[])
.unwrap()
.run(|st| {
stages.push(st);
true
})
.unwrap();
assert!(bytes.starts_with(b"%PDF-"));
assert_eq!(stages.first(), Some(&OptimizeStage::Discarding));
assert_eq!(&stages[stages.len() - 3..], [OptimizeStage::CleaningUp, OptimizeStage::Merging, OptimizeStage::Writing]);
let fractions: Vec<f32> = stages.iter().map(|st| st.fraction()).collect();
assert!(fractions.windows(2).all(|w| w[0] <= w[1]), "the bar never goes back: {fractions:?}");
assert!(fractions.iter().all(|f| (0.0..=1.0).contains(f)));
assert_eq!(
OptimizeStage::Images { done: 0, total: 0 }.fraction(),
OptimizeStage::Images { done: 5, total: 5 }.fraction(),
"no images: no division by zero"
);
// Cancelled at the merge: nothing is written, the open document is untouched.
let before = s.get(id).unwrap().bytes.clone();
let r = s.optimize_job(id, &settings, &[]).unwrap().run(|st| st != OptimizeStage::Merging);
assert!(matches!(r, Err(EditError::Cancelled)), "{r:?}");
assert_eq!(s.get(id).unwrap().bytes, before);
}
#[test]
fn flattening_keeps_the_look_and_drops_the_objects() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("form.pdf", None, Arc::new(form_fixture()), None).unwrap();
s.apply(id, Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("Ada".into()) }).unwrap();
s.apply(id, rect_comment(0, [80.0, 80.0, 120.0, 120.0])).unwrap();
let red_before = pixel(&s, id, 0, 100, 100);
s.apply(id, Edit::Flatten { comments: true, fields: true }).unwrap();
let d = s.get(id).unwrap();
assert!(d.info.annotations.is_empty() && d.form.is_empty(), "no comments or fields remain");
assert_eq!(pixel(&s, id, 0, 100, 100), red_before, "the rectangle is now page content");
assert_eq!(page_texts(&s, id), ["Ada"], "the field's text is now page content");
s.undo(id).unwrap();
assert_eq!(s.get(id).unwrap().form.len(), 2);
}
fn fill_item(shape: Shape, contents: &str) -> Edit {
let style = Style::default_for(&shape);
Edit::AddAnnotation(NewAnnotation { page: 0, shape, style, contents: contents.into(), author: "Ada".into() })
}
fn comment_text(s: &Session, id: DocId) -> Vec<String> {
let mut t: Vec<String> = s.get(id).unwrap().info.annotations.iter().map(|a| a.contents.clone().unwrap_or_default()).collect();
t.sort();
t
}
/// A dragged stamp's layers: the page without it (its text and other comments kept) and the
/// stamp alone on a transparent page. Preparing them changes nothing in the document.
#[test]
fn stamp_preview_layers_split_the_stamp_from_its_page() {
let (mut s, id) = session_with(1);
s.apply(id, fill_item(Shape::Mark { rect: [30.0, 60.0, 130.0, 66.0], mark: FillMark::Line }, "")).unwrap();
s.apply(id, rect_comment(0, [140.0, 40.0, 190.0, 90.0])).unwrap();
let doc = s.get(id).unwrap();
let (generation, dirty) = (doc.edit_generation(), doc.dirty);
let preview = doc.stamp_preview(0, 0).unwrap().expect("the line is a stamp");
let (stamp, background) = (preview.render_stamp(1.0).unwrap(), preview.render_background(1.0).unwrap());
let px = |p: &pdfcraft_render::RenderedPage, x: u32, y: u32| -> [u8; 4] {
p.rgba[(((300 - y) * p.width + x) * 4) as usize..][..4].try_into().unwrap()
};
let line = |x: u32, y: u32| (30..=130).contains(&x) && (59..=67).contains(&y);
let (mut drawn, mut stray) = (0, Vec::new());
for y in 1..300 {
for x in 0..200 {
match (line(x, y), px(&stamp, x, y)) {
(true, [.., 255]) => drawn += 1,
(false, v) if v != [0, 0, 0, 0] => stray.push((x, y)),
_ => {}
}
}
}
assert!(drawn > 100, "the line is drawn opaque: {drawn} pixels");
assert!(stray.is_empty(), "only the line is drawn on its layer: {:?}", &stray[..stray.len().min(8)]);
assert!((30..=130).all(|x| px(&background, x, 63) == [255, 255, 255, 255]), "the page beneath has no line");
assert!((138..=142).any(|x| px(&background, x, 65) != [255, 255, 255, 255]), "other comments stay");
assert!((20..120).any(|x| (150..170).any(|y| px(&background, x, y)[0] < 128)), "the page text stays");
assert!(doc.stamp_preview(0, 1).unwrap().is_none(), "a rectangle isn't a stamp");
assert!(doc.stamp_preview(0, 9).unwrap().is_none());
let doc = s.get(id).unwrap();
assert_eq!((doc.edit_generation(), doc.dirty, doc.can_undo()), (generation, dirty, Some("Add rectangle")), "the document is unchanged");
// Another app's comments without appearances: beneath a dragged stamp they are drawn as on
// screen, and such a stamp has nothing to lay over the page.
let bare = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >> endobj
4 0 obj << /Type /Annot /Subtype /Square /Rect [140 40 190 90] /C [1 0 0] >> endobj
5 0 obj << /Type /Annot /Subtype /Stamp /Rect [20 20 80 40] /Name /Draft >> endobj
trailer << /Root 1 0 R >>
%%EOF";
let mut s = Session::new();
let id = s.open("bare.pdf", None, Arc::new(bare.to_vec()), None).unwrap();
s.apply(id, fill_item(Shape::Mark { rect: [30.0, 60.0, 130.0, 66.0], mark: FillMark::Line }, "")).unwrap();
let doc = s.get(id).unwrap();
let background = doc.stamp_preview(0, 2).unwrap().expect("the line is a stamp").render_background(1.0).unwrap();
assert!((138..=142).any(|x| px(&background, x, 65)[..3] == [255, 0, 0]), "the square without an appearance is drawn beneath");
assert!(doc.stamp_preview(0, 1).unwrap().is_none(), "a stamp without an appearance");
}
#[test]
fn flattening_fill_and_sign_keeps_other_comments_fields_and_undoes() {
let (mut s, id) = session_with(1);
s.apply(id, fill_item(Shape::Typewriter { rect: [20.0, 200.0, 120.0, 220.0], font_size: 10.0 }, "Hello")).unwrap();
s.apply(id, fill_item(Shape::Mark { rect: [140.0, 200.0, 160.0, 220.0], mark: FillMark::Check }, "")).unwrap();
s.apply(id, fill_item(Shape::Signature { strokes: vec![vec![[10.0, 40.0], [40.0, 70.0], [70.0, 40.0]]] }, "")).unwrap();
s.apply(id, fill_item(Shape::TypedSignature { rect: [90.0, 40.0, 150.0, 70.0], contours: vec![vec![[0.1, 0.2], [0.5, 0.9], [0.9, 0.2]]] }, ""))
.unwrap();
let mut png = Vec::new();
{
let mut enc = png::Encoder::new(&mut png, 8, 8);
enc.set_color(png::ColorType::Rgba);
let pixel = [40u8, 60, 80, 200];
enc.write_header().unwrap().write_image_data(&pixel.repeat(64)).unwrap();
}
let image = SignatureImage::from_bytes(&png).unwrap();
let info = s.get(id).unwrap().info.pages[0].clone();
s.apply(id, image.edit(0, &info, [20.0, 120.0], false, "Ada").unwrap()).unwrap();
s.apply(id, fill_item(Shape::TextMarkup { kind: Markup::Highlight, quads: vec![[20.0, 280.0, 80.0, 280.0, 20.0, 270.0, 80.0, 270.0]] }, "keep"))
.unwrap();
s.apply(id, fill_item(Shape::TextBox { rect: [20.0, 150.0, 120.0, 180.0], font_size: 12.0 }, "Note")).unwrap();
s.apply(id, Edit::AddField { page: 0, rect: [20.0, 230.0, 120.0, 250.0], kind: NewField::Text { multiline: false }, name: Some("email".into()) })
.unwrap();
s.apply(id, Edit::FlattenFillSign).unwrap();
assert_eq!(s.get(id).unwrap().can_undo(), Some("Flatten Fill & Sign"));
assert_eq!(comment_text(&s, id), ["Note", "keep"]);
assert_eq!(s.get(id).unwrap().form.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), ["email"]);
s.undo(id).unwrap();
assert!(comment_text(&s, id).contains(&"Hello".to_string()), "undo restores the typewriter");
s.apply(id, Edit::FlattenFillSign).unwrap();
let saved = s.save_bytes(id).unwrap();
s.mark_saved(id, saved.clone(), None).unwrap();
let mut again = Session::new();
let id2 = again.open("saved.pdf", None, saved, None).unwrap();
assert_eq!(comment_text(&again, id2), ["Note", "keep"]);
assert_eq!(again.get(id2).unwrap().form.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), ["email"]);
}
#[test]
fn flattening_fill_and_sign_does_nothing_when_there_are_none() {
let (mut s, id) = session_with(1);
s.apply(id, fill_item(Shape::TextMarkup { kind: Markup::Highlight, quads: vec![[20.0, 200.0, 80.0, 200.0, 20.0, 190.0, 80.0, 190.0]] }, "keep"))
.unwrap();
let saved = s.save_bytes(id).unwrap();
s.mark_saved(id, saved, None).unwrap();
assert!(!s.get(id).unwrap().dirty);
s.apply(id, Edit::FlattenFillSign).unwrap();
assert!(!s.get(id).unwrap().dirty, "nothing was baked in, so the document stays saved");
assert_eq!(s.get(id).unwrap().can_undo(), Some("Add highlight"), "no undo step for a flatten that changed nothing");
assert_eq!(comment_text(&s, id), ["keep"]);
}
#[test]
fn flattening_fill_and_sign_is_refused_without_permission_to_change() {
let (mut s, id) = session_with(1);
s.apply(id, fill_item(Shape::Typewriter { rect: [20.0, 200.0, 120.0, 220.0], font_size: 10.0 }, "Hello")).unwrap();
let p = Protection { printing: Printing::Low, changes: Changes::CommentFillSign, copy: false, ..protection(None, Some("boss")) };
s.apply(id, Edit::Protect(p)).unwrap();
let saved = s.save_bytes(id).unwrap();
let mut other = Session::new();
let id2 = other.open("locked.pdf", None, saved, None).unwrap();
let before = other.get(id2).unwrap().bytes.clone();
assert_eq!(other.apply(id2, Edit::FlattenFillSign), Err(EditError::NotPermitted("changes to the document")));
assert_eq!(other.get(id2).unwrap().bytes, before);
assert!(!other.get(id2).unwrap().dirty);
assert!(comment_text(&other, id2).contains(&"Hello".to_string()));
}
#[test]
fn flattening_fill_and_sign_on_a_signed_file_stays_incremental() {
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
let digital_id = sign::pkcs12::open(&p12, "test").unwrap();
let mut s = Session::new().with_clock(|| 1_800_000_000);
let id = s.open("fixture.pdf", None, Arc::new(fixture(1)), None).unwrap();
let opts = SignOptions { page: 0, rect: Some([20.0, 20.0, 160.0, 50.0]), ..SignOptions::default() };
let signed = s.sign(id, &digital_id, opts).unwrap();
s.mark_signed(id, signed.clone(), None).unwrap();
s.apply(id, fill_item(Shape::Typewriter { rect: [20.0, 200.0, 120.0, 220.0], font_size: 10.0 }, "Hello")).unwrap();
s.apply(id, Edit::FlattenFillSign).unwrap();
let saved = s.save_bytes(id).unwrap();
assert!(saved.starts_with(signed.as_ref()), "the signature's bytes stay an incremental update");
assert!(!comment_text(&s, id).contains(&"Hello".to_string()));
}
#[test]
fn flattening_fill_and_sign_reopens_an_encrypted_file() {
let (mut s, id) = session_with(1);
s.apply(id, fill_item(Shape::Typewriter { rect: [20.0, 200.0, 120.0, 220.0], font_size: 10.0 }, "Hello")).unwrap();
s.apply(id, Edit::Protect(protection(Some("secret"), None))).unwrap();
s.apply(id, Edit::FlattenFillSign).unwrap();
let saved = s.save_bytes(id).unwrap();
let mut again = Session::new();
assert!(again.open("p.pdf", None, saved.clone(), None).is_err());
let id2 = again.open("p.pdf", None, saved, Some("secret")).unwrap();
assert!(!comment_text(&again, id2).contains(&"Hello".to_string()));
}
#[test]
fn pages_export_as_png_jpeg_and_tiff() {
use crate::export::{Exporter, ImageFormat};
let (s, id) = session_with(1);
let mut ex = Exporter::new(s.get(id).unwrap());
let png = ex.image(0, 72.0, ImageFormat::Png).unwrap();
assert!(png.starts_with(b"\x89PNG"));
let jpg = ex.image(0, 72.0, ImageFormat::Jpeg { quality: 85 }).unwrap();
let decoded = image::load_from_memory_with_format(&jpg, image::ImageFormat::Jpeg).unwrap();
assert_eq!((decoded.width(), decoded.height()), (200, 300), "the page at 72 dpi");
let corner = decoded.to_rgb8().get_pixel(2, 2).0;
assert!(corner.iter().all(|c| *c > 240), "white paper, not black: {corner:?}");
let tif = ex.image(0, 144.0, ImageFormat::Tiff).unwrap();
let decoded = image::load_from_memory_with_format(&tif, image::ImageFormat::Tiff).unwrap();
assert_eq!((decoded.width(), decoded.height()), (400, 600));
let mut tags = tiff::decoder::Decoder::new(std::io::Cursor::new(&tif)).unwrap();
let compression = tags.get_tag_u32(tiff::tags::Tag::Compression).unwrap();
let predictor = tags.get_tag_u32(tiff::tags::Tag::Predictor).unwrap();
assert_eq!((compression, predictor), (5, 1), "LZW, without a predictor");
}
/// A TIFF's resolution unit (2 = inch) and its horizontal and vertical resolution.
fn tiff_resolution(bytes: &[u8]) -> (u32, f64, f64) {
use tiff::{decoder::ifd::Value, tags::Tag};
let mut d = tiff::decoder::Decoder::new(std::io::Cursor::new(bytes)).unwrap();
let mut res = |tag| match d.get_tag(tag).unwrap() {
Value::Rational(n, d) => f64::from(n) / f64::from(d),
v => panic!("{tag:?} is {v:?}"),
};
let (x, y) = (res(Tag::XResolution), res(Tag::YResolution));
(d.get_tag_u32(Tag::ResolutionUnit).unwrap(), x, y)
}
/// An exported TIFF or JPEG says the resolution it was drawn at, so other programs show and print it
/// at the page's size. A TIFF said 1 × 1 with no unit, and a JPEG said nothing.
#[test]
fn exported_pages_record_the_resolution_they_were_drawn_at() {
use crate::export::{Exporter, ImageFormat};
let (s, id) = session_with(1);
let mut ex = Exporter::new(s.get(id).unwrap());
assert_eq!(tiff_resolution(&ex.image(0, 144.0, ImageFormat::Tiff).unwrap()), (2, 144.0, 144.0));
let jpg = ex.image(0, 144.0, ImageFormat::Jpeg { quality: 85 }).unwrap();
assert_eq!(&jpg[6..11], b"JFIF\0");
assert_eq!(jpg[13..18], [1, 0, 144, 0, 144], "JFIF density in dots per inch");
// pHYs counts pixels per metre: 72 dpi would read back as 72.009, and a page made from the PNG
// (doc_create from images) would come out 0.01% small.
let png = ex.image(0, 72.0, ImageFormat::Png).unwrap();
assert!(png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap().info().pixel_dims.is_none(), "a PNG states no resolution");
// A page too large for the renderer at the asked resolution says the one it was drawn at.
let mut s = Session::new().with_clock(|| 1_700_000_000);
let bytes = s.create_blank(14_400.0, 792.0, 1).unwrap();
let id = s.open("wide.pdf", None, bytes, None).unwrap();
let mut ex = Exporter::new(s.get(id).unwrap());
let used = ex.dpi_used(0, 600.0);
let (unit, x, y) = tiff_resolution(&ex.image(0, 600.0, ImageFormat::Tiff).unwrap());
assert!(unit == 2 && (x - used).abs() < 0.01 && (y - used).abs() < 0.01, "{x} × {y}, drawn at {used}");
}
#[test]
fn added_text_and_images_render_and_stay_editable() {
let (mut s, id) = session_with(1);
let text = AddedText { rect: [20.0, 250.0, 180.0, 280.0], text: "Added note".into(), size: 12.0, ..AddedText::default() };
s.apply(id, Edit::AddText { page: 0, text: text.clone() }).unwrap();
assert_eq!(s.get(id).unwrap().can_undo(), Some("Add text"));
assert!(page_texts(&s, id)[0].contains("Added note"), "the renderer's extractor sees page content");
// A PNG, centred at its natural size.
let mut png = Vec::new();
{
let mut enc = png::Encoder::new(&mut png, 20, 10);
enc.set_color(png::ColorType::Rgb);
let mut w = enc.write_header().unwrap();
w.write_image_data(&[0u8; 600]).unwrap();
}
s.apply(id, Edit::AddImage { page: 0, rect: None, name: "dot.png".into(), bytes: Arc::new(png) }).unwrap();
let d = s.get(id).unwrap();
assert_eq!(d.added().len(), 2);
assert_eq!(d.added()[1].content.rect(), [90.0, 145.0, 110.0, 155.0], "20×10 px at 72 dpi, centred on 200×300");
let moved = d.added()[0].content.with_rect([30.0, 100.0, 190.0, 130.0]);
s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: moved }).unwrap();
assert_eq!(s.get(id).unwrap().added()[0].content.rect()[3], 130.0);
s.apply(id, Edit::DeleteContent { page: 0, index: 1 }).unwrap();
assert_eq!(s.get(id).unwrap().added().len(), 1);
s.undo(id).unwrap();
assert_eq!(s.get(id).unwrap().added().len(), 2);
}
#[test]
fn added_images_rotate_flip_and_crop_as_drawn() {
// A 20×10 image: left half red, right half blue.
let mut px = Vec::new();
for _y in 0..10 {
for x in 0..20 {
px.extend_from_slice(if x < 10 { &[255, 0, 0] } else { &[0, 0, 255] });
}
}
let mut png = Vec::new();
{
let mut enc = png::Encoder::new(&mut png, 20, 10);
enc.set_color(png::ColorType::Rgb);
enc.write_header().unwrap().write_image_data(&px).unwrap();
}
let (mut s, id) = session_with(1);
// A 100×100 box at (50, 100) on the 200×300 page.
s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "rb.png".into(), bytes: Arc::new(png) }).unwrap();
let colour_at = |s: &Session, x: u32, y_from_top: u32| -> [u8; 3] {
let doc = s.get(id).unwrap();
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default());
let out = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
let i = ((y_from_top * out.width + x) * 4) as usize;
[out.rgba[i], out.rgba[i + 1], out.rgba[i + 2]]
};
let red = |c: [u8; 3]| c[0] > 200 && c[2] < 60;
let blue = |c: [u8; 3]| c[2] > 200 && c[0] < 60;
// Display y 100–200 is rows 100–200 from the top on a 300 pt page.
assert!(red(colour_at(&s, 70, 150)) && blue(colour_at(&s, 130, 150)), "as placed: red left, blue right");
let item = |s: &Session| s.get(id).unwrap().added()[0].content.clone();
let AddedContent::Image(mut img) = item(&s) else { panic!() };
img.rotation = 1;
s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: AddedContent::Image(img.clone()) }).unwrap();
assert!(red(colour_at(&s, 100, 180)) && blue(colour_at(&s, 100, 120)), "a quarter turn left: red at the bottom");
img.rotation = 0;
img.flip_h = true;
s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: AddedContent::Image(img.clone()) }).unwrap();
assert!(blue(colour_at(&s, 70, 150)) && red(colour_at(&s, 130, 150)), "flipped");
img.flip_h = false;
img.crop = [0.0, 0.0, 0.5, 0.0];
s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: AddedContent::Image(img) }).unwrap();
assert!(red(colour_at(&s, 130, 150)), "the right half cropped away: red fills the box");
}
#[test]
fn reload_reads_the_new_file_into_the_same_document() {
let (mut s, id) = session_with(2);
let other = s.open("other.pdf", None, Arc::new(fixture(1)), None).unwrap();
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
let before = s.get(id).unwrap().edit_generation();
s.reload(id, Arc::new(fixture(3))).unwrap();
let d = s.get(id).unwrap();
assert_eq!((d.id, d.name.as_str()), (id, "fixture.pdf"), "same document, same name");
assert_eq!(page_texts(&s, id), ["Page 1", "Page 2", "Page 3"], "the new version");
assert!(!d.dirty && d.can_undo().is_none(), "nothing unsaved, nothing to undo");
assert!(d.edit_generation() > before, "caches keyed by the generation see a change");
assert_eq!(s.docs().iter().map(|d| d.id).collect::<Vec<_>>(), [id, other], "no new tab, same order");
// A file that isn't a PDF (yet) leaves the document as it was.
assert!(s.reload(id, Arc::new(b"%PDF-1.7 half written".to_vec())).is_err());
assert_eq!(page_texts(&s, id).len(), 3);
assert_eq!(s.docs().len(), 2);
}
/// #3443: reloading the file (it changed on disk) keeps how the reader shows it. Reading colours
/// reached the new renderer, not just the setting; hidden comments stay hidden. Each setting is
/// checked alone, so neither one rides on the other being changed.
#[test]
fn reload_keeps_reading_colours_and_hidden_comments() {
let dark_share = |s: &Session, id: DocId| {
let doc = s.get(id).unwrap();
doc.renderer.set_queue(vec![pdfcraft_render::RenderRequest {
page: 0,
kind: pdfcraft_render::RequestKind::Pixels,
scale: 1.0,
..Default::default()
}]);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
let page = loop {
if let Some(page) = doc.renderer.try_recv() {
break page;
}
assert!(std::time::Instant::now() < deadline, "page render did not finish");
std::thread::yield_now();
};
let (px, _) = page.rgba.as_chunks::<4>();
px.iter().filter(|p| p[0] < 64 && p[1] < 64 && p[2] < 64).count() as f64 / px.len() as f64
};
let (mut s, id) = session_with(1);
assert!(dark_share(&s, id) < 0.1, "a white page as the file draws it");
assert!(s.set_reading_colors(id, ReadingColors::WHITE_ON_BLACK));
assert!(dark_share(&s, id) > 0.9, "white on black");
s.reload(id, Arc::new(fixture(2))).unwrap();
assert_eq!(s.reading_colors(id), Some(ReadingColors::WHITE_ON_BLACK));
assert!(dark_share(&s, id) > 0.9, "the reloaded page is drawn white on black too");
assert!(!s.get(id).unwrap().comments_hidden());
let (mut s, id) = session_with(1);
assert!(s.set_hide_comments(id, true));
s.reload(id, Arc::new(fixture(2))).unwrap();
assert!(s.get(id).unwrap().comments_hidden(), "hidden comments stay hidden");
assert_eq!(s.reading_colors(id), Some(ReadingColors::default()));
assert!(dark_share(&s, id) < 0.1);
}
#[test]
fn revert_goes_back_to_the_saved_version() {
let (mut s, id) = session_with(2);
s.apply(id, Edit::DeletePages { pages: vec![0] }).unwrap();
let saved = s.save_bytes(id).unwrap();
s.mark_saved(id, saved, None).unwrap();
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
s.apply(id, Edit::InsertBlankPage { at: 0, width: 200.0, height: 300.0 }).unwrap();
assert!(s.get(id).unwrap().dirty);
s.revert(id).unwrap();
let d = s.get(id).unwrap();
assert!(!d.dirty && d.can_undo().is_none() && d.can_redo().is_none());
assert_eq!(page_texts(&s, id), ["Page 2"], "the saved state, not the opened one");
assert_eq!(d.info.pages[0].rotation, 0);
}
#[test]
fn split_by_size_and_bookmarks_and_page_filters() {
let (mut s, id) = session_with(6);
// Every page alone is a few hundred bytes: a limit of about two pages gives three parts.
let one = s.split(id, &pdfcraft_organize::SplitBy::PageCount(1)).unwrap()[0].2.len();
let parts = s.split_by_size(id, one * 2 + one / 2).unwrap();
assert!(parts.len() >= 2 && parts.len() <= 6, "{}", parts.len());
assert_eq!(parts.last().unwrap().1, 6, "every page is in a part");
assert_eq!(s.split_by_size(id, 1).unwrap().len(), 6, "pages larger than the limit stand alone");
// Top-level bookmarks name the parts.
s.apply(id, Edit::AddBookmark { parent: vec![], index: 0, title: "Intro".into(), page: 0, left: None, top: None }).unwrap();
s.apply(id, Edit::AddBookmark { parent: vec![], index: 1, title: "Results".into(), page: 3, left: None, top: None }).unwrap();
assert_eq!(s.bookmark_splits(id), [(0, "Intro".to_string()), (3, "Results".to_string())]);
// Filters: page numbers 2, 4, 6 are even.
let info = &s.get(id).unwrap().info;
let all: Vec<usize> = (0..6).collect();
assert_eq!(filter_pages(info, &all, PageParity::Even, PageOrientation::Both), [1, 3, 5]);
assert_eq!(filter_pages(info, &all, PageParity::Odd, PageOrientation::Landscape), Vec::<usize>::new());
assert_eq!(filter_pages(info, &all, PageParity::Odd, PageOrientation::Portrait), [0, 2, 4]);
}
#[test]
fn links_from_urls_and_link_edits() {
// One page whose text contains a web address.
let mut objs: Vec<Vec<u8>> =
vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 300 200] >>".to_vec()];
objs.push(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec());
objs.push(b"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_vec());
let body = b"BT /F1 12 Tf 20 100 Td (Visit www.example.org today) Tj ET";
objs.push([format!("<< /Length {} >>\nstream\n", body.len()).into_bytes(), body.to_vec(), b"\nendstream".to_vec()].concat());
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
let mut s = Session::new();
let id = s.open("u.pdf", None, Arc::new(out), None).unwrap();
let found = s.find_urls(id);
assert_eq!(found.len(), 1);
assert_eq!(found[0].2, "http://www.example.org");
s.apply(id, Edit::AddLinks { links: found, style: LinkStyle::default() }).unwrap();
let d = s.get(id).unwrap();
assert_eq!(d.links().len(), 1);
assert_eq!(d.info.links.len(), 1, "the viewer follows it");
assert!(s.find_urls(id).is_empty(), "already linked");
// Remove all links and undo.
s.apply(id, Edit::RemoveLinks { pages: None }).unwrap();
assert!(s.get(id).unwrap().links().is_empty());
assert_eq!(s.get(id).unwrap().can_undo(), Some("Remove all links"));
s.undo(id).unwrap();
assert_eq!(s.get(id).unwrap().links().len(), 1);
// Link Properties and delete.
let l = s.get(id).unwrap().links()[0].clone();
s.apply(id, Edit::SetLink { page: 0, index: l.index, rect: None, action: Some(LinkAction::Page(0)), style: None }).unwrap();
assert_eq!(s.get(id).unwrap().links()[0].action, LinkAction::Page(0));
s.apply(id, Edit::DeleteLink { page: 0, index: l.index }).unwrap();
assert!(s.get(id).unwrap().links().is_empty());
assert!(s.apply(id, Edit::RemoveLinks { pages: None }).is_err(), "nothing left to remove");
}
#[test]
fn appearance_only_edit_preserves_gotor_action_semantics() {
let objects = [
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".to_vec(),
b"<< /Type /Annot /Subtype /Link /Rect [10 10 80 30] /A << /S /GoToR /F (target.pdf) /D [2 /FitH 180] /NewWindow true >> >>".to_vec(),
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, object) in objects.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
bytes.extend_from_slice(object);
bytes.extend_from_slice(b"\nendobj\n");
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
for offset in offsets {
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
let mut session = Session::new();
let id = session.open("source.pdf", Some("/docs/source.pdf".into()), Arc::new(bytes), None).unwrap();
let before = session.get(id).unwrap().info.links[0].target.clone();
let mut style = session.get(id).unwrap().links()[0].style;
style.visible = true;
style.width = 2.0;
session.apply(id, Edit::SetLink { page: 0, index: 0, rect: None, action: None, style: Some(style) }).unwrap();
let after = &session.get(id).unwrap().info.links[0].target;
assert_eq!(*after, before, "the GoToR file, destination and /NewWindow hint must remain unchanged");
assert_eq!(session.get(id).unwrap().links()[0].style, style, "the appearance edit is applied");
}
#[test]
fn comments_lock_take_checkmarks_hide_and_summarize() {
let (mut s, id) = session_with(2);
s.apply(id, rect_comment(1, [80.0, 80.0, 120.0, 120.0])).unwrap();
s.apply(id, Edit::ReplyToAnnotation { page: 1, index: 0, text: "Agreed".into(), author: "Ada".into() }).unwrap();
s.apply(id, Edit::LockAnnotation { page: 1, index: 0, locked: true }).unwrap();
assert_eq!(s.get(id).unwrap().can_undo(), Some("Lock comment"));
assert!(s.get(id).unwrap().info.annotations[0].locked);
assert!(s.apply(id, Edit::MoveAnnotation { page: 1, index: 0, dx: 5.0, dy: 0.0 }).is_err(), "locked comments stay put");
assert!(s.apply(id, Edit::DeleteAnnotation { page: 1, index: 0 }).is_err(), "and can't be deleted");
s.apply(id, Edit::SetAnnotationContents { page: 1, index: 0, text: "Still editable".into() }).unwrap();
s.apply(id, Edit::LockAnnotation { page: 1, index: 0, locked: false }).unwrap();
s.apply(id, Edit::MoveAnnotation { page: 1, index: 0, dx: 5.0, dy: 0.0 }).unwrap();
s.apply(id, Edit::MarkAnnotation { page: 1, index: 0, marked: true, author: "Ada".into() }).unwrap();
let a = &s.get(id).unwrap().info.annotations;
let mark = a.iter().find(|r| r.is_mark()).expect("a checkmark reply");
assert_eq!(mark.state.as_deref(), Some("Marked"));
// Hiding comments drops the rectangle but keeps the page.
let px = pixel(&s, id, 1, 105, 100);
assert!(px[0] > 200 && px[1] < 40, "drawn: {px:?}");
assert!(s.set_hide_comments(id, true));
assert!(!s.set_hide_comments(id, true));
assert_eq!(pixel(&s, id, 1, 105, 100), [255, 255, 255, 255]);
assert!(s.set_hide_comments(id, false));
assert!(pixel(&s, id, 1, 105, 100)[1] < 40);
let text = comment_summary("doc.pdf", &s.get(id).unwrap().info.annotations, SummarySort::Page);
assert!(text.starts_with("Summary of Comments on doc.pdf"));
assert!(text.contains("Page: 2\nNumber: 1 Author: Reviewer Subject: Rectangle"), "{text}");
assert!(text.contains("Still editable") && text.contains(" Agreed"), "{text}");
assert!(!text.contains("Marked"), "checkmarks aren't listed: {text}");
let pdf = s.summarize_comments(id, SummarySort::Author).unwrap();
let n = s.open_new("summary.pdf", pdf).unwrap();
assert!(!s.get(n).unwrap().info.pages.is_empty());
assert_eq!(comment_summary("x", &[], SummarySort::Page), "Summary of Comments on x\n\nThis document has no comments.\n");
}
/// #820: the generated summary includes replies to replies, indented one level deeper.
#[test]
fn comment_summary_includes_replies_to_replies() {
let (mut s, id) = session_with(1);
s.apply(id, rect_comment(0, [40.0, 40.0, 90.0, 90.0])).unwrap();
s.apply(id, Edit::ReplyToAnnotation { page: 0, index: 0, text: "FIRST_REPLY".into(), author: "A".into() }).unwrap();
s.apply(id, Edit::ReplyToAnnotation { page: 0, index: 1, text: "NESTED_REPLY".into(), author: "B".into() }).unwrap();
let text = comment_summary("doc.pdf", &s.get(id).unwrap().info.annotations, SummarySort::Page);
assert!(text.contains("FIRST_REPLY"), "{text}");
assert!(text.contains("NESTED_REPLY"), "replies to replies are summarized: {text}");
assert!(text.contains(" NESTED_REPLY"), "the nested reply is indented under its parent: {text}");
}
#[test]
fn reading_colors_set_and_invalidated() {
let (mut s, id) = session_with(1);
// Every open document holds a value; the default is Off.
assert_eq!(s.reading_colors(id), Some(ReadingColors::default()));
assert!(s.set_reading_colors(id, ReadingColors::WHITE_ON_BLACK));
assert_eq!(s.reading_colors(id), Some(ReadingColors::WHITE_ON_BLACK));
assert!(!s.set_reading_colors(id, ReadingColors::WHITE_ON_BLACK), "unchanged");
assert!(s.set_reading_colors(id, ReadingColors::default()), "back to Off");
assert_eq!(s.reading_colors(id), Some(ReadingColors::default()));
// No such document.
assert_eq!(s.reading_colors(DocId(u64::MAX)), None);
assert!(!s.set_reading_colors(DocId(u64::MAX), ReadingColors::WHITE_ON_BLACK));
}
#[test]
fn signing_saving_trusting_and_commenting_afterwards() {
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
let digital_id = sign::pkcs12::open(&p12, "test").unwrap();
// After the test certificate's start date (2026-10-02).
let mut s = Session::new().with_clock(|| 1_800_000_000);
let id = s.open("fixture.pdf", None, Arc::new(fixture(2)), None).unwrap();
assert!(s.get(id).unwrap().signatures.is_empty());
let opts = SignOptions { page: 1, rect: Some([20.0, 20.0, 180.0, 60.0]), reason: Some("Approved".into()), ..SignOptions::default() };
let signed = s.sign(id, &digital_id, opts).unwrap();
s.mark_signed(id, signed, Some("/tmp/signed.pdf".into())).unwrap();
let doc = s.get(id).unwrap();
assert_eq!(doc.name, "signed.pdf");
assert!(doc.is_signed() && !doc.dirty && doc.can_undo().is_none(), "signing can't be undone");
let sig = &doc.signatures[0];
assert_eq!((sig.status, sig.page, sig.date.as_deref()), (SignatureStatus::Unknown, Some(1), Some("D:20270115080000Z")));
assert_eq!(s.save_full_bytes(id).unwrap_err(), EditError::Signed);
// Trusting the signer makes it valid; a later comment is permitted.
s.set_trusted_certificates(vec![digital_id.certificate.clone()]);
assert_eq!(s.get(id).unwrap().signatures[0].status, SignatureStatus::Valid);
s.apply(id, rect_comment(0, [80.0, 80.0, 120.0, 120.0])).unwrap();
let sig = &s.get(id).unwrap().signatures[0];
let comments = sign::Modification::Allowed(vec![sign::kind::COMMENTS]);
assert_eq!((sig.status, sig.modification.clone()), (SignatureStatus::Valid, comments));
// Saving keeps the signature (incremental).
let saved = s.save_bytes(id).unwrap();
s.mark_saved(id, saved, None).unwrap();
assert_eq!(s.get(id).unwrap().signatures[0].status, SignatureStatus::Valid);
// Edits that would need a full rewrite are refused, and change nothing.
let before = s.get(id).unwrap().can_undo().map(str::to_owned);
assert!(matches!(s.apply(id, Edit::Sanitize), Err(EditError::SignedRewrite(_))));
assert!(!s.get(id).unwrap().dirty && s.get(id).unwrap().can_undo().map(str::to_owned) == before);
}
#[test]
fn validated_signatures_without_layers_keep_their_appearance() {
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
let digital_id = sign::pkcs12::open(&p12, "test").unwrap();
// After the test certificate's start date (2026-10-02).
let mut s = Session::new().with_clock(|| 1_800_000_000);
let id = s.open("fixture.pdf", None, Arc::new(fixture(1)), None).unwrap();
let opts = SignOptions { page: 0, rect: Some([20.0, 20.0, 180.0, 60.0]), ..SignOptions::default() };
let signed = s.sign(id, &digital_id, opts).unwrap();
s.mark_signed(id, signed, None).unwrap();
s.set_trusted_certificates(vec![digital_id.certificate.clone()]);
let doc = s.get(id).unwrap();
let sig = &doc.signatures[0];
assert_eq!(sig.status, SignatureStatus::Valid);
// Our own flat appearance has no validation layers: nothing is substituted, and
// the display copy carries no generated caption.
let display = doc.display.clone();
assert!(!display.windows(b"Signature valid".len()).any(|w| w == b"Signature valid"));
// With nothing to draw, the display copy aliases the file exactly as before.
assert!(Arc::ptr_eq(&doc.display, &doc.bytes));
}
#[test]
fn signed_pages_render_trust_save_and_reopen() {
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
let digital_id = sign::pkcs12::open(&p12, "test").unwrap();
// After the test certificate's start date (2026-10-02).
let mut s = Session::new().with_clock(|| 1_800_000_000);
let id = s.open("fixture.pdf", None, Arc::new(fixture(1)), None).unwrap();
let opts = SignOptions { page: 0, rect: Some([20.0, 20.0, 180.0, 60.0]), ..SignOptions::default() };
let signed = s.sign(id, &digital_id, opts).unwrap();
// The signed bytes open elsewhere too: validity is in the file, not the session.
let mut t = Session::new().with_clock(|| 1_800_000_000);
let tid = t.open("signed.pdf", None, signed.clone(), None).unwrap();
assert!(t.get(tid).unwrap().signatures.iter().any(|x| x.signed && x.status == SignatureStatus::Unknown));
s.mark_signed(id, signed, None).unwrap();
// The signed page renders, from the file and from the display copy alike.
for bytes in [s.get(id).unwrap().bytes.clone(), s.get(id).unwrap().display.clone()] {
let mut renderer = pdfcraft_render::PageRenderer::new(bytes, s.get(id).unwrap().config.clone());
let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
assert!(rendered.error.is_none(), "{:?}", rendered.error);
assert!(rendered.width > 0 && rendered.height > 0);
}
// Trusting the signer makes it valid; saving keeps it valid across sessions.
s.set_trusted_certificates(vec![digital_id.certificate.clone()]);
assert_eq!(s.get(id).unwrap().signatures[0].status, SignatureStatus::Valid);
let saved = s.save_bytes(id).unwrap();
let mut u = Session::new().with_clock(|| 1_800_000_000);
u.set_trusted_certificates(vec![digital_id.certificate.clone()]);
let uid = u.open("saved.pdf", None, saved.clone(), None).unwrap();
assert_eq!(u.get(uid).unwrap().signatures[0].status, SignatureStatus::Valid);
}
#[test]
fn signed_documents_round_trip_behind_a_user_password() {
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
let digital_id = sign::pkcs12::open(&p12, "test").unwrap();
// After the test certificate's start date (2026-10-02).
let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap();
doc.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm: pdfcraft_cos::Algorithm::Aes128,
user_password: "user",
owner_password: "owner",
permissions: -1,
encrypt_metadata: true,
seed: [7; 32],
})
.unwrap();
let encrypted = Arc::new(pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap());
// Wrong passwords refuse; the right one signs, validates, saves and reopens.
let mut e = Session::new().with_clock(|| 1_800_000_000);
assert!(e.open("enc.pdf", None, encrypted.clone(), Some("wrong")).is_err(), "wrong passwords refuse");
let eid = e.open("enc.pdf", None, encrypted, Some("user")).unwrap();
let signed = e.sign(eid, &digital_id, SignOptions { page: 0, rect: Some([20.0, 20.0, 180.0, 60.0]), ..SignOptions::default() }).unwrap();
e.mark_signed(eid, signed, None).unwrap();
e.set_trusted_certificates(vec![digital_id.certificate.clone()]);
assert_eq!(e.get(eid).unwrap().signatures[0].status, SignatureStatus::Valid);
let saved = e.save_bytes(eid).unwrap();
let mut f = Session::new().with_clock(|| 1_800_000_000);
f.set_trusted_certificates(vec![digital_id.certificate.clone()]);
assert!(f.open("enc-saved.pdf", None, saved.clone(), Some("wrong")).is_err());
let fid = f.open("enc-saved.pdf", None, saved.clone(), Some("user")).unwrap();
assert_eq!(f.get(fid).unwrap().signatures[0].status, SignatureStatus::Valid);
}
/// ByteRange placeholder values: fixed width, patched after writing.
const LAYERED_BR_MARK: [i64; 3] = [1_111_111_111, 2_222_222_222, 3_333_333_333];
/// A one-page file whose signature field carries a layered appearance on the
/// page (background, words, vector glyph, border, unverified caption): object
/// numbers 1 catalog, 2 pages, 3 page, 4 field, 5 AcroForm, 6 /N, 7 frame,
/// 8 background, 9 words, 10 glyph, 11 caption, 12 border, 13 Helvetica,
/// 14 page content. The field is unsigned: `sign_prepared` adds the value.
fn layered_signable_fixture() -> Vec<u8> {
fn stream(bbox: &str, resources: &str, ops: &str) -> String {
format!("<< /Type /XObject /Subtype /Form /BBox {bbox} /Length {} /Resources {resources} >>\nstream\n{ops}\nendstream", ops.len())
}
let helv = "<< /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >>";
let objs = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 5 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 14 0 R /Resources << /Font << /F1 13 0 R >> >> /Annots [4 0 R] >>"
.to_string(),
"<< /FT /Sig /T (Signature1) /Subtype /Widget /Rect [10 10 110 60] /P 3 0 R /AP << /N 6 0 R >> >>".to_string(),
"<< /Fields [4 0 R] /SigFlags 3 >>".to_string(),
stream("[0 0 50 30]", "<< /XObject << /frame 7 0 R >> >>", "q /frame Do Q"),
stream(
"[0 0 50 30]",
"<< /XObject << /bg 8 0 R /words 9 0 R /glyph 10 0 R /bd 12 0 R /cap 11 0 R >> >>",
"q /bg Do /words Do /glyph Do /bd Do /cap Do Q",
),
stream("[0 0 100 100]", "<< >>", "0 g 0 0 100 100 re f"),
stream("[0 0 100 100]", helv, "BT /F1 10 Tf 5 50 Td (Signed by Test) Tj ET"),
stream("[0 0 50 30]", "<< >>", "0.8 0.1 0.1 RG 3 w 5 5 m 20 5 l 12 22 l h S"),
stream("[0 21 50 30]", helv, "BT /F1 9 Tf 2 22 Td (Signature Not Verified) Tj ET"),
stream("[0 0 100 100]", "<< >>", "0 g 0 0 100 100 re f"),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
"<< /Length 44 >>\nstream\nBT /F1 14 Tf 20 150 Td (Contract text) Tj ET\nendstream".to_string(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (k, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", k + 1).as_bytes());
}
let start = out.len();
out.extend_from_slice(format!("xref\n0 {}\n", objs.len() + 1).as_bytes());
out.extend_from_slice(b"0000000000 65535 f \n");
for o in &offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{start}\n%%EOF", objs.len() + 1).as_bytes());
out
}
/// Whether `haystack` holds `needle`.
fn bytes_has(haystack: &[u8], needle: &[u8]) -> bool {
!needle.is_empty() && haystack.windows(needle.len()).any(|w| w == needle)
}
/// Sign `doc`, whose field already carries its layered appearance, without
/// touching that appearance: reserve `/ByteRange` and `/Contents`, hash the
/// ranges and embed a detached CMS over them. The signed bytes keep the
/// prepared appearance, so they validate exactly like a product signer's file.
fn sign_prepared(doc: &mut pdfcraft_cos::Document, id: &sign::pkcs12::DigitalId, date: &str) -> Vec<u8> {
use pdfcraft_cos::{Dict, Object, PdfString};
let reserve = 8192 + id.certificate.raw.len() + id.chain.iter().map(|c| c.raw.len()).sum::<usize>();
let mut v = Dict::new();
v.set(b"Type".to_vec(), Object::name("Sig"));
v.set(b"Filter".to_vec(), Object::name("Adobe.PPKLite"));
v.set(b"SubFilter".to_vec(), Object::name("ETSI.CAdES.detached"));
v.set(b"ByteRange".to_vec(), Object::Array([0].iter().chain(LAYERED_BR_MARK.iter()).map(|n| Object::Int(*n)).collect()));
v.set(b"Contents".to_vec(), Object::String(PdfString { bytes: vec![0; reserve], hex: true }));
v.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec()));
let sig = doc.add(Object::Dict(v));
// The field by name, never by number: a full save (encryption, repairs) is
// free to renumber objects, and a hardcoded reference would stamp the value
// onto whatever moves into that slot.
let root = doc.root().expect("catalog");
let af = doc.get(root).as_dict().expect("catalog dict").get(b"AcroForm").cloned().expect("AcroForm");
let fields = doc.resolve(&af).as_dict().expect("AcroForm dict").get(b"Fields").cloned().expect("Fields");
let field = doc.resolve(&fields).as_array().expect("Fields array").iter().filter_map(|o| o.as_ref()).next().expect("signature field");
doc.update_dict(field, |d| d.set(b"V".to_vec(), Object::Ref(sig))).unwrap();
let save = pdfcraft_cos::SaveOptions { object_streams: false, ..pdfcraft_cos::SaveOptions::default() };
let mut out = pdfcraft_cos::write_incremental(doc, &save).unwrap();
let mark = format!("0 {} {} {}", LAYERED_BR_MARK[0], LAYERED_BR_MARK[1], LAYERED_BR_MARK[2]);
let br = out.windows(mark.len()).position(|w| w == mark.as_bytes()).expect("ByteRange placeholder");
let start = out[..br].windows(3).rposition(|w| w == b"obj").expect("signature object");
let end = br + out[br..].windows(6).position(|w| w == b"endobj").expect("signature end");
let mut zeros = vec![b'<'];
zeros.extend(std::iter::repeat_n(b'0', reserve * 2));
zeros.push(b'>');
let region = &out[start..end];
let c = region.windows(zeros.len()).position(|w| w == zeros.as_slice()).expect("Contents placeholder");
let (cstart, cend) = (start + c, start + c + zeros.len());
let mut br_text = format!("0 {cstart} {cend} {}", out.len() - cend).into_bytes();
assert!(br_text.len() <= mark.len(), "ByteRange patch fits its placeholder");
br_text.resize(mark.len(), b' ');
out[br..br + mark.len()].copy_from_slice(&br_text);
let alg = id.key.preferred_digest();
let digest = alg.digest(&[&out[..cstart], &out[cend..]]);
let cms = sign::cms::sign_detached(&id.key, &id.certificate, &id.chain, alg, &digest).unwrap();
assert!(cms.len() <= reserve, "signature fits its placeholder");
let hex: Vec<u8> = cms.iter().flat_map(|b| format!("{b:02X}").into_bytes()).collect();
out[cstart + 1..cstart + 1 + hex.len()].copy_from_slice(&hex);
out
}
/// Replace one synthetic appearance stream while retaining its resource dictionary.
fn signature_layer_ops(doc: &mut pdfcraft_cos::Document, number: u32, ops: &[u8]) {
let r = pdfcraft_cos::ObjRef::new(number, 0);
let dict = doc.resolve(&pdfcraft_cos::Object::Ref(r)).as_dict().unwrap().clone();
doc.set(r, pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::from_raw(dict, ops.to_vec())));
}
fn printed_pixels(bytes: Vec<u8>) -> (u32, u32, Vec<u8>) {
let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), Default::default());
let r = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
assert!(r.error.is_none(), "{:?}", r.error);
(r.width, r.height, r.rgba.to_vec())
}
/// Validation belongs to viewer chrome, not to document artwork or export sources.
fn assert_signature_artwork_survives_trust(mut cos: pdfcraft_cos::Document, expected_text: &[&str]) {
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
let identity = sign::pkcs12::open(&p12, "test").unwrap();
let anchor = identity.chain.first().cloned().unwrap_or_else(|| identity.certificate.clone());
let signed = Arc::new(sign_prepared(&mut cos, &identity, "D:20261002120000+01'00'"));
let mut session = Session::new().with_clock(|| 1_800_000_000);
let id = session.open("artwork.pdf", None, signed.clone(), None).unwrap();
let original_display = session.get(id).unwrap().display.clone();
let mut exporter = export::Exporter::new(session.get(id).unwrap());
let original_text = exporter.text_of(&[0]).unwrap();
let original_png = exporter.png(0, 72.0).unwrap();
for text in expected_text {
assert!(original_text.contains(text), "fixture text {text:?} must be exported: {original_text:?}");
}
let mut settings = print::Settings { pages: vec![0], paper: (200.0, 200.0), ..Default::default() };
let original_vector_print = printed_pixels(session.print_pdf(id, &settings).unwrap());
settings.raster = Some(print::Raster { dpi: 72.0, grayscale: false });
let original_raster_print = printed_pixels(session.print_pdf(id, &settings).unwrap());
session.set_trusted_certificates(vec![anchor.clone()]);
let doc = session.get(id).unwrap();
assert_eq!(doc.signatures[0].status, SignatureStatus::Valid);
assert!(doc.display == original_display, "trust must preserve all original appearance bytes");
let mut exporter = export::Exporter::new(doc);
assert_eq!(exporter.text_of(&[0]).unwrap(), original_text, "text exports retain signer details and baked caption");
assert_eq!(exporter.png(0, 72.0).unwrap(), original_png, "image exports exclude the viewer overlay");
assert_eq!(printed_pixels(session.print_pdf(id, &settings).unwrap()), original_raster_print, "Print as Image retains document artwork");
settings.raster = None;
assert_eq!(printed_pixels(session.print_pdf(id, &settings).unwrap()), original_vector_print);
assert_eq!(session.save_bytes(id).unwrap(), signed, "unchanged saves retain the signed file exactly");
session.apply(id, Edit::SetInfo { key: "Title".into(), value: "Preserved artwork".into() }).unwrap();
let saved = session.save_bytes(id).unwrap();
assert!(saved.starts_with(&signed), "a real save appends without replacing signed appearance streams");
let mut reopened = Session::new().with_clock(|| 1_800_000_000);
reopened.set_trusted_certificates(vec![anchor]);
let id = reopened.open("saved.pdf", None, saved, None).unwrap();
let doc = reopened.get(id).unwrap();
assert_eq!(doc.signatures[0].status, SignatureStatus::Valid);
let mut exporter = export::Exporter::new(doc);
assert_eq!(exporter.text_of(&[0]).unwrap(), original_text);
assert_eq!(exporter.png(0, 72.0).unwrap(), original_png);
}
#[test]
fn chamfered_signature_borders_survive_trust_exports_printing_and_save() {
for raster_glyph in [false, true] {
let mut doc = pdfcraft_cos::Document::open(Arc::new(layered_signable_fixture())).unwrap();
signature_layer_ops(&mut doc, 12, b"0 G 1 w 10 1 m 90 1 l 99 10 l 99 90 l 90 99 l 10 99 l 1 90 l 1 10 l h S");
signature_layer_ops(&mut doc, 10, b"0 G 2 w 0 0 50 30 re S");
if raster_glyph {
let mut image = pdfcraft_cos::Dict::new();
image.set(b"Subtype", pdfcraft_cos::Object::name("Image"));
image.set(b"Width", pdfcraft_cos::Object::Int(8));
image.set(b"Height", pdfcraft_cos::Object::Int(8));
image.set(b"ColorSpace", pdfcraft_cos::Object::name("DeviceRGB"));
image.set(b"BitsPerComponent", pdfcraft_cos::Object::Int(8));
let image = doc.add(pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::from_raw(image, vec![0; 8 * 8 * 3])));
let mut xobjects = pdfcraft_cos::Dict::new();
xobjects.set(b"im", pdfcraft_cos::Object::Ref(image));
xobjects.set(b"unused", pdfcraft_cos::Object::Ref(pdfcraft_cos::ObjRef::new(8, 0)));
let mut resources = pdfcraft_cos::Dict::new();
resources.set(b"XObject", pdfcraft_cos::Object::Dict(xobjects));
doc.update_dict(pdfcraft_cos::ObjRef::new(10, 0), |d| d.set(b"Resources", pdfcraft_cos::Object::Dict(resources))).unwrap();
signature_layer_ops(&mut doc, 10, b"q 20 0 0 20 5 5 cm /im Do Q");
}
assert_signature_artwork_survives_trust(doc, &["Not Verified"]);
}
}
#[test]
fn mixed_signature_caption_details_survive_trust_exports_printing_and_save() {
let mut doc = pdfcraft_cos::Document::open(Arc::new(layered_signable_fixture())).unwrap();
signature_layer_ops(&mut doc, 11, b"BT /F1 5 Tf 2 27 Td (Alice Signer) Tj 0 -3 Td (alice@example.org) Tj 0 -3 Td (Signature Not Verified) Tj ET");
assert_signature_artwork_survives_trust(doc, &["Alice Signer", "alice@example.org", "Not Verified"]);
}
/// A real layered signature through `Session`: validity, original artwork,
/// rendering, saving and reopening. `password` opens the signed bytes.
fn check_layered_round_trip(signed: Arc<Vec<u8>>, password: Option<&str>, anchor: &sign::x509::Certificate) {
let mut s = Session::new().with_clock(|| 1_800_000_000);
let vid = s.open("layered.pdf", None, signed.clone(), password).unwrap();
let doc = s.get(vid).unwrap();
let sig = &doc.signatures[0];
assert!(sig.signed && sig.field == "Signature1" && sig.page == Some(0) && sig.visible);
assert_eq!(sig.status, SignatureStatus::Unknown, "untrusted at first");
// Trust changes the verdict while preserving every appearance byte. The page
// overlay is UI chrome and is not part of the document's display/export source.
s.set_trusted_certificates(vec![anchor.clone()]);
let doc = s.get(vid).unwrap();
assert_eq!(doc.signatures[0].status, SignatureStatus::Valid);
assert!(Arc::ptr_eq(&doc.display, &doc.bytes), "trust does not build a signature display copy");
assert!(bytes_has(&doc.display, b"Not Verified"));
assert!(!bytes_has(&doc.display, b"Signature valid"));
let mut renderer = pdfcraft_render::PageRenderer::new(doc.display.clone(), doc.config.clone());
let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
assert!(rendered.error.is_none(), "{:?}", rendered.error);
// Saving keeps the original appearance and the signature: the validated
// state never reaches the file.
let saved = s.save_bytes(vid).unwrap();
assert!(bytes_has(&saved, b"Not Verified"), "saved bytes keep the baked appearance");
assert!(!bytes_has(&saved, b"Signature valid"));
let mut t = Session::new().with_clock(|| 1_800_000_000);
t.set_trusted_certificates(vec![anchor.clone()]);
let tid = t.open("layered-saved.pdf", None, saved.clone(), password).unwrap();
let rsig = &t.get(tid).unwrap().signatures[0];
assert_eq!(rsig.status, SignatureStatus::Valid);
}
/// A trusted layered signature whose original appearance remains document artwork.
fn trusted_layered_session() -> (Session, DocId) {
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
let id = sign::pkcs12::open(&p12, "test").unwrap();
let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone());
let mut doc = pdfcraft_cos::Document::open(Arc::new(layered_signable_fixture())).unwrap();
let signed = Arc::new(sign_prepared(&mut doc, &id, "D:20261002120000+01'00'"));
let mut s = Session::new().with_clock(|| 1_800_000_000);
let vid = s.open("layered.pdf", None, signed, None).unwrap();
s.set_trusted_certificates(vec![anchor]);
assert_eq!(s.get(vid).unwrap().signatures[0].status, SignatureStatus::Valid);
(s, vid)
}
/// Metadata edits retain the original caption; the UI can still show its verdict badge.
#[test]
fn a_metadata_edit_keeps_the_original_signature_appearance() {
let (mut s, vid) = trusted_layered_session();
s.apply(vid, Edit::SetInfo { key: "Title".into(), value: "Contract".into() }).unwrap();
let doc = s.get(vid).unwrap();
assert_eq!(doc.signatures[0].status, SignatureStatus::Valid);
assert!(bytes_has(&doc.display, b"Not Verified"));
assert!(!bytes_has(&doc.display, b"Signature valid"));
}
/// An unchanged document never reaches the writer, so the edit is what makes this the
/// place the display copy would leak if saving ever read it.
#[test]
fn saving_after_an_edit_keeps_the_baked_appearance() {
let (mut s, vid) = trusted_layered_session();
s.apply(vid, Edit::SetInfo { key: "Title".into(), value: "Contract".into() }).unwrap();
let saved = s.save_bytes(vid).unwrap();
assert!(bytes_has(&saved, b"Not Verified"), "the writer keeps the baked appearance: {saved:?}");
assert!(!bytes_has(&saved, b"Signature valid"), "the validated state never reaches the file");
}
#[test]
fn real_layered_signatures_keep_their_original_appearance() {
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
let id = sign::pkcs12::open(&p12, "test").unwrap();
let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone());
let date = "D:20261002120000+01'00'";
let mut doc = pdfcraft_cos::Document::open(Arc::new(layered_signable_fixture())).unwrap();
let signed = Arc::new(sign_prepared(&mut doc, &id, date));
check_layered_round_trip(signed, None, &anchor);
}
#[test]
fn real_layered_signatures_round_trip_behind_a_user_password() {
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
let id = sign::pkcs12::open(&p12, "test").unwrap();
let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone());
let date = "D:20261002120000+01'00'";
let mut doc = pdfcraft_cos::Document::open(Arc::new(layered_signable_fixture())).unwrap();
doc.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm: pdfcraft_cos::Algorithm::Aes128,
user_password: "user",
owner_password: "owner",
permissions: -1,
encrypt_metadata: true,
seed: [7; 32],
})
.unwrap();
let encrypted = Arc::new(pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap());
let mut doc = pdfcraft_cos::Document::open_with_password(encrypted, Some("user")).unwrap();
let signed = Arc::new(sign_prepared(&mut doc, &id, date));
let mut probe = Session::new().with_clock(|| 1_800_000_000);
assert!(probe.open("enc.pdf", None, signed.clone(), Some("wrong")).is_err(), "wrong passwords refuse");
// Encrypted appearance bytes remain unchanged through trust and save/reopen.
let mut s = Session::new().with_clock(|| 1_800_000_000);
let vid = s.open("enc.pdf", None, signed.clone(), Some("user")).unwrap();
assert_eq!(s.get(vid).unwrap().signatures[0].status, SignatureStatus::Unknown);
s.set_trusted_certificates(vec![anchor.clone()]);
let doc = s.get(vid).unwrap();
assert_eq!(doc.signatures[0].status, SignatureStatus::Valid);
assert!(Arc::ptr_eq(&doc.display, &doc.bytes), "encrypted signature artwork remains original");
let saved = s.save_bytes(vid).unwrap();
let mut t = Session::new().with_clock(|| 1_800_000_000);
t.set_trusted_certificates(vec![anchor.clone()]);
assert!(t.open("enc-saved.pdf", None, saved.clone(), Some("wrong")).is_err());
let tid = t.open("enc-saved.pdf", None, saved.clone(), Some("user")).unwrap();
assert_eq!(t.get(tid).unwrap().signatures[0].status, SignatureStatus::Valid);
}
#[test]
#[ignore = "timing probe"]
fn probe_edit_latency_on_a_large_signed_document() {
// ~120 MB: one page whose content stream is large.
let big: Vec<u8> = (0..120_000_000u32).map(|i| b"0123456789 "[(i % 11) as usize]).collect();
let mut pdf = b"%PDF-1.7\n".to_vec();
let mut offs = vec![];
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R >>".to_vec(),
[format!("<< /Length {} >>\nstream\n", big.len() + 3).into_bytes(), b"% ".to_vec(), big, b"\nendstream".to_vec()].concat(),
];
for (i, o) in objs.iter().enumerate() {
offs.push(pdf.len());
pdf.extend(format!("{} 0 obj\n", i + 1).bytes());
pdf.extend(o);
pdf.extend(b"\nendobj\n");
}
let x = pdf.len();
pdf.extend(b"xref\n0 5\n0000000000 65535 f \n");
for o in offs {
pdf.extend(format!("{o:010} 00000 n \n").bytes());
}
pdf.extend(format!("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n").bytes());
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
let digital_id = sign::pkcs12::open(&p12, "test").unwrap();
let mut s = Session::new().with_clock(|| 1_800_000_000);
let t = std::time::Instant::now();
let id = s.open("big.pdf", None, Arc::new(pdf), None).unwrap();
eprintln!("open: {:?}", t.elapsed());
let t = std::time::Instant::now();
s.apply(id, rect_comment(0, [5.0, 10.0, 50.0, 50.0])).unwrap();
eprintln!("comment edit before signing: {:?}", t.elapsed());
s.undo(id).unwrap();
let t = std::time::Instant::now();
let signed = s.sign(id, &digital_id, SignOptions { rect: None, ..SignOptions::default() }).unwrap();
eprintln!("sign: {:?}", t.elapsed());
let t = std::time::Instant::now();
s.mark_signed(id, signed, None).unwrap();
eprintln!("mark_signed (reopen + validate): {:?}", t.elapsed());
for k in 0..3 {
let t = std::time::Instant::now();
s.apply(id, rect_comment(0, [10.0 + k as f64, 10.0, 50.0, 50.0])).unwrap();
eprintln!("comment edit {k}: {:?}", t.elapsed());
}
}
/// A tiny uncompressed BMP of one colour.
fn bmp(w: u32, h: u32, bgr: [u8; 3]) -> Vec<u8> {
let row = (w * 3).div_ceil(4) * 4;
let size = 54 + row * h;
let mut b = b"BM".to_vec();
b.extend(size.to_le_bytes());
b.extend([0u8; 4]);
b.extend(54u32.to_le_bytes());
b.extend(40u32.to_le_bytes());
b.extend((w as i32).to_le_bytes());
b.extend((h as i32).to_le_bytes());
b.extend(1u16.to_le_bytes());
b.extend(24u16.to_le_bytes());
b.extend([0u8; 24]);
for _ in 0..h {
for _ in 0..w {
b.extend(bgr);
}
b.extend(vec![0u8; (row - w * 3) as usize]);
}
b
}
#[test]
fn backgrounds_and_watermarks_from_files() {
let (mut s, id) = session_with(1);
// A source PDF whose second page is solid blue.
let blue = {
let mut src = Session::new();
let sid = src.open("src.pdf", None, Arc::new(fixture(2)), None).unwrap();
src.apply(
sid,
Edit::AddBackground {
pages: vec![1],
settings: Background { color: [0.0, 0.0, 1.0], ..Background::default() },
replace: false,
file: None,
},
)
.unwrap();
src.save_bytes(sid).unwrap()
};
let file = MarkFile { name: "src.pdf".into(), bytes: blue, page: 1 };
s.apply(id, Edit::AddBackground { pages: vec![0], settings: Background::default(), replace: false, file: Some(file) }).unwrap();
let corner = pixel(&s, id, 0, 3, 3);
assert!(corner[2] > 200 && corner[0] < 40, "the blue page fills the background: {corner:?}");
// The page's own text still draws on top (at "Page 1", 20,150).
assert_eq!(s.get(id).unwrap().can_undo(), Some("Add background"));
// A red image watermark at half the page, on top, unrotated.
let red = MarkFile { name: "stamp.bmp".into(), bytes: Arc::new(bmp(4, 4, [0, 0, 255])), page: 0 };
let wm = Watermark { rotation: 0.0, opacity: 1.0, scale: 0.5, ..Watermark::default() };
s.apply(id, Edit::AddWatermark { pages: vec![0], settings: wm, replace: false, file: Some(red) }).unwrap();
let centre = pixel(&s, id, 0, 100, 150);
assert!(centre[0] > 200 && centre[2] < 40, "the red picture sits in the middle: {centre:?}");
let edge = pixel(&s, id, 0, 10, 150);
assert!(edge[2] > 200, "outside it the background shows: {edge:?}");
// A bad file is refused clearly.
let junk = MarkFile { name: "x.png".into(), bytes: Arc::new(b"not an image".to_vec()), page: 0 };
assert!(s.apply(id, Edit::AddBackground { pages: vec![0], settings: Background::default(), replace: true, file: Some(junk) }).is_err());
}
/// Scan & OCR ▸ Recognize text on a page that is only a picture of text (needs the models:
/// `cargo xtask models`; skipped without them).
#[test]
fn recognize_text_reads_the_euro_sign() {
if !ocr::available() {
eprintln!("skipped: OCR models not installed");
return;
}
let mut s = Session::new().with_clock(|| 1_700_000_000);
let text = s.create_from_text("t", "Total amount due: €250 by Friday").unwrap();
let id = s.open("text.pdf", None, text, None).unwrap();
let png = export::Exporter::new(s.get(id).unwrap()).png(0, 150.0).unwrap();
let scan = s.create_from_images(&[("scan.png".into(), png)]).unwrap();
let id = s.open("scan.pdf", None, scan, None).unwrap();
s.recognize_text(id, &[], ocr::OcrSettings::default()).unwrap();
let text = page_texts(&s, id)[0].clone();
// The model was trained with € where the ocrs crate's alphabet has a second E.
assert!(text.contains("€250"), "{text}");
}
const SYNTHETIC_SCAN_DPI: f64 = 300.0;
/// A scan of the OCR sentence at the source page's physical size, without a text layer.
fn synthetic_ocr_scan(s: &mut Session) -> (DocId, [f32; 2]) {
let text = s.create_from_text("t", "The quick brown fox jumps over the lazy dog.").unwrap();
let id = s.open("text.pdf", None, text, None).unwrap();
let original = &s.get(id).unwrap().info.pages[0];
let size = [original.width, original.height];
// A typical 300 DPI scan keeps the small leading glyph in "quick" legible to OCR.
let png = export::Exporter::new(s.get(id).unwrap()).png(0, SYNTHETIC_SCAN_DPI).unwrap();
// Exported PNGs have no resolution metadata. The 72 DPI import default would enlarge
// the page and change the PDF-point coordinates the placement assertions exercise.
let scan = s.create_from_images_with_resolution(&[("scan.png".into(), png)], ImageResolution::Dpi(SYNTHETIC_SCAN_DPI)).unwrap();
(s.open("scan.pdf", None, scan, None).unwrap(), size)
}
#[test]
fn synthetic_ocr_scan_keeps_the_original_page_size_without_models() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let (id, expected) = synthetic_ocr_scan(&mut s);
let page = &s.get(id).unwrap().info.pages[0];
// Rounding to whole raster pixels may change a page edge by at most one pixel.
let tolerance = 72.0 / SYNTHETIC_SCAN_DPI;
for (actual, expected) in [page.width, page.height].into_iter().zip(expected) {
assert!(f64::from((actual - expected).abs()) <= tolerance, "page edge {actual} pt, expected {expected} pt");
}
assert_eq!(page_texts(&s, id), [""], "the scan has no text until OCR runs");
}
#[test]
fn recognize_text_makes_a_scanned_page_searchable() {
if !ocr::available() {
eprintln!("skipped: OCR models not installed");
return;
}
let mut s = Session::new().with_clock(|| 1_700_000_000);
let (id, _) = synthetic_ocr_scan(&mut s);
assert_eq!(page_texts(&s, id), [""], "a picture has no text");
let found = s.recognize_text(id, &[], ocr::OcrSettings::default()).unwrap();
assert_eq!(found.len(), 1);
let text = page_texts(&s, id)[0].to_lowercase();
for w in ["quick", "brown", "fox", "lazy"] {
assert!(text.contains(w), "{text}");
}
assert_eq!(s.get(id).unwrap().can_undo(), Some("Recognize text"));
// The text sits over the words: "quick" is left of "lazy" and on the same line.
let words = &found[0].words;
let q = words.iter().find(|w| w.text.to_lowercase().contains("quick")).unwrap();
let l = words.iter().find(|w| w.text.to_lowercase().contains("lazy")).unwrap();
let top = |w: &ocr::PlacedWord| w.origin[1] + w.up[1];
// OCR boxes can differ by a few pixels at 300 DPI and still sit on the same line.
let line_tolerance = q.up[1].abs().max(l.up[1].abs()) / 4.0;
assert!(q.origin[0] < l.origin[0] && (top(q) - top(l)).abs() < line_tolerance, "{q:?} {l:?}");
// A second pass skips the page: it has text now.
let again = s.recognize_text(id, &[], ocr::OcrSettings::default()).unwrap();
assert!(again[0].skipped.is_some());
}
/// #1028: a page with its own text and a picture of text. By default it is skipped, as before;
/// with `skip_text_pages: false` the picture is read and the page's own text is not added again.
#[test]
fn recognize_text_reads_a_picture_on_a_page_that_has_text() {
if !ocr::available() {
eprintln!("skipped: OCR models not installed");
return;
}
let mut s = Session::new().with_clock(|| 1_700_000_000);
let text = s.create_from_text("t", "The quick brown fox jumps over the lazy dog.").unwrap();
let id = s.open("text.pdf", None, text, None).unwrap();
let png = export::Exporter::new(s.get(id).unwrap()).png(0, 150.0).unwrap();
let scan = s.create_from_images(&[("scan.png".into(), png)]).unwrap();
let id = s.open("scan.pdf", None, scan, None).unwrap();
// Real text in the empty lower part of the picture.
let (w, h) = {
let p = &s.get(id).unwrap().info.pages[0];
(p.width as f64, p.height as f64)
};
let note = AddedText { rect: [w * 0.1, h * 0.75, w * 0.9, h * 0.85], text: "Invoice reference 4711".into(), size: 18.0, ..Default::default() };
s.apply(id, Edit::AddText { page: 0, text: note }).unwrap();
let count = |s: &Session, word: &str| page_texts(s, id)[0].to_lowercase().matches(word).count();
assert_eq!((count(&s, "invoice"), count(&s, "brown")), (1, 0));
let skipped = s.recognize_text(id, &[], ocr::OcrSettings::default()).unwrap();
assert!(skipped[0].skipped.is_some(), "pages with text are still skipped by default");
assert_eq!(s.get(id).unwrap().can_undo(), Some("Add text"), "nothing was added");
let hybrid = ocr::OcrSettings { skip_text_pages: false, ..Default::default() };
let found = s.recognize_text(id, &[], hybrid.clone()).unwrap();
assert_eq!(found[0].skipped, None);
assert!(found[0].already_on_page >= 2, "the page's own words were recognised and left out: {:?}", found[0]);
let added = found[0].text().to_lowercase();
assert!(added.contains("brown") && added.contains("lazy") && !added.contains("invoice"), "{added}");
assert_eq!((count(&s, "invoice"), count(&s, "brown")), (1, 1), "{:?}", page_texts(&s, id));
assert_eq!(s.get(id).unwrap().can_undo(), Some("Recognize text"));
// Reading the page again adds nothing: every word is there now.
let again = s.recognize_text(id, &[], hybrid).unwrap();
assert!(again[0].words.is_empty(), "{:?}", again[0]);
assert_eq!((count(&s, "invoice"), count(&s, "brown")), (1, 1));
}
/// A form whose scripts are custom JavaScript: total = price × qty (calculate, through a
/// document-level function), shown with a custom format; qty is validated; a button script.
fn scripted_form() -> Vec<u8> {
let text = |name: &str, y: u32, aa: &str| {
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [20 {y} 180 {}] /P 3 0 R /F 4 {aa} >>", y + 20)
};
let objs = [
"<< /Type /Catalog /Pages 2 0 R /Names << /JavaScript << /Names [(helpers) 10 0 R] >> >> /AcroForm << /Fields [4 0 R 5 0 R 6 0 R 7 0 R] /CO [6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 8 0 R >> >> >> >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_string(),
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 7 0 R] >>".to_string(),
text("price", 250, "/V (2.5)"),
text("qty", 220, "/AA << /V << /S /JavaScript /JS (if (event.value > 100) { app.alert('At most 100'); event.rc = false; }) >> >>"),
text("total", 190, "/AA << /C << /S /JavaScript /JS (event.value = times\\(getField('price').value, getField('qty').value\\);) >> /F << /S /JavaScript /JS (event.value = 'USD ' + util.printf('%.2f', event.value);) >> >>"),
"<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (go) /Rect [20 150 80 170] /P 3 0 R /F 4 >>".to_string(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(),
"<< >>".to_string(),
"<< /S /JavaScript /JS (function times\\(a, b\\) { console.println('times'); return a * b; }) >>".to_string(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
#[test]
fn form_javascript_validates_calculates_and_formats() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("order.pdf", None, Arc::new(scripted_form()), None).unwrap();
let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).unwrap().value.clone();
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("4".into()) }).unwrap();
assert_eq!(value(&s, "total"), ["10"], "calculated by the script");
assert!(page_texts(&s, id)[0].contains("USD 10.00"), "{:?}", page_texts(&s, id));
let out = s.take_js_output(id);
assert!(out.console.contains(&"times".to_string()), "{out:?}");
let err = s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("500".into()) }).unwrap_err();
assert_eq!(err.to_string(), "At most 100");
assert_eq!(value(&s, "qty"), ["4"]);
// A button script changes fields in one undoable step, and asks for a page.
let o =
s.run_javascript(id, "getField('price').value = 3; getField('total').readonly = true; app.alert('Done'); this.print();", Some("go")).unwrap();
assert_eq!(o.alerts, ["Done"]);
assert_eq!(o.requests, [js::Request::Print]);
assert_eq!(value(&s, "total"), ["12"], "recalculated after the change");
assert!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().read_only());
assert_eq!(s.get(id).unwrap().can_undo(), Some("Run JavaScript"));
// JavaScript off: scripts don't run.
s.set_javascript(false);
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("500".into()) }).unwrap();
assert_eq!(value(&s, "total"), ["12"]);
assert!(s.run_javascript(id, "1", None).is_err());
}
/// XFA forms check `xfa_installed` and `xfa_version` in a document script that runs before
/// each of their scripts, and alert when either is missing.
#[test]
fn scripts_in_an_xfa_form_see_the_xfa_plugin_installed() {
let check = "typeof xfa_installed + ' ' + typeof xfa_version + ' ' + xfa_version";
let mut s = Session::new().with_clock(|| 1_700_000_000);
let xfa = s.open("form.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template(3))), None).unwrap();
let o = s.run_javascript(xfa, check, None).unwrap();
assert_eq!((o.error, o.result.as_deref()), (None, Some("boolean number 3.5")));
let plain = s.open("order.pdf", None, Arc::new(scripted_form()), None).unwrap();
let o = s.run_javascript(plain, check, None).unwrap();
assert_eq!((o.error, o.result.as_deref()), (None, Some("undefined number 3.5")));
}
#[test]
fn edit_history_does_not_replay_field_script_side_effects() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("order.pdf", None, Arc::new(scripted_form()), None).unwrap();
let value = |s: &Session, name: &str| s.get(id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone();
let initial_qty = value(&s, "qty");
let initial_total = value(&s, "total");
assert!(s.take_js_output(id).is_empty());
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("4".into()) }).unwrap();
assert_eq!(value(&s, "qty"), ["4"]);
assert_eq!(value(&s, "total"), ["10"]);
let output = s.take_js_output(id);
assert!(output.console.contains(&"times".to_string()), "{output:?}");
assert_eq!(output.console.iter().filter(|line| *line == "times").count(), 1, "{output:?}");
s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Report".into() }).unwrap();
assert!(s.take_js_output(id).is_empty(), "a metadata edit does not rerun field scripts");
s.undo(id).unwrap();
assert_eq!(s.get(id).unwrap().info.title, None);
assert_eq!(value(&s, "qty"), ["4"]);
assert!(s.take_js_output(id).is_empty(), "undoing metadata does not rerun field scripts");
s.redo(id).unwrap();
assert_eq!(s.get(id).unwrap().info.title.as_deref(), Some("Report"));
assert_eq!(value(&s, "qty"), ["4"]);
assert!(s.take_js_output(id).is_empty(), "redoing metadata does not rerun field scripts");
s.undo(id).unwrap();
s.undo(id).unwrap();
assert_eq!(value(&s, "qty"), initial_qty);
assert_eq!(value(&s, "total"), initial_total);
assert!(s.take_js_output(id).is_empty(), "undoing the scripted edit does not replay its side effect");
s.redo(id).unwrap();
assert_eq!(value(&s, "qty"), ["4"]);
assert_eq!(value(&s, "total"), ["10"]);
assert!(s.take_js_output(id).is_empty(), "redoing the scripted edit restores its snapshot without replay");
}
#[test]
fn detecting_fields_on_a_printed_form() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let text = s.create_from_text("t", "Name: ______________________\n\nEmail address: ____________________\n\nPlain text without blanks.").unwrap();
let id = s.open("paper.pdf", None, text, None).unwrap();
let found = s.detect_fields(id, &[]);
let names: Vec<&str> = found.iter().map(|(_, c)| c.name.as_str()).collect();
assert_eq!(names, ["Name", "Email address"], "{found:?}");
let added = s.auto_detect_fields(id, &[]).unwrap();
assert_eq!(added, ["Name", "Email address"]);
let d = s.get(id).unwrap();
assert_eq!(d.form.len(), 2);
assert_eq!(d.can_undo(), Some("Detect form fields"));
// The field sits on the blank: right of the label, at its height.
let f = &d.form[0];
assert!(f.widgets[0].rect[0] > 72.0 + 20.0, "{:?}", f.widgets[0].rect);
assert!(s.detect_fields(id, &[]).is_empty(), "nothing left to detect");
}
/// #294, #2831: a detected field sits on a line or box the page already draws, so it gets no border
/// and no fill that would cover the printed form. A field drawn by hand keeps the defaults.
#[test]
fn detected_fields_have_no_border_or_fill() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("paper.pdf", None, Arc::new(paper_form()), None).unwrap();
let added = s.auto_detect_fields(id, &[]).unwrap();
let d = s.get(id).unwrap();
let kinds: Vec<pdfcraft_forms::FieldKind> = d.form.iter().map(|f| f.kind).collect();
assert!(kinds.contains(&pdfcraft_forms::FieldKind::Text) && kinds.contains(&pdfcraft_forms::FieldKind::CheckBox), "{kinds:?}");
for name in &added {
let look = d.field_look(name).unwrap();
assert_eq!((look.border, look.fill), (None, None), "{name}");
}
assert_eq!(d.can_undo(), Some("Detect form fields"), "still one step");
s.apply(id, Edit::AddField { page: 0, rect: [200.0, 10.0, 300.0, 30.0], kind: NewField::Text { multiline: false }, name: Some("drawn".into()) })
.unwrap();
let look = s.get(id).unwrap().field_look("drawn").unwrap();
assert!(look.border.is_some() && look.fill.is_some(), "{look:?}");
}
/// A printed form, 420 × 300 pt: a title, a label with a rule to write on, a box with a small
/// label in its corner, an empty box with its label above, and two check boxes.
fn paper_form() -> Vec<u8> {
let content = "BT /F1 14 Tf 30 270 Td (Gewerbe-Ummeldung) Tj ET \
BT /F1 10 Tf 30 230 Td (Name:) Tj ET 0.75 w 80 228 m 380 228 l S \
1 w 30 150 350 50 re S BT /F1 7 Tf 34 191 Td (Anschrift) Tj ET \
BT /F1 8 Tf 30 124 Td (Telefon) Tj ET 30 90 350 30 re S \
30 50 10 10 re S BT /F1 9 Tf 46 51 Td (Haupt) Tj ET 150 50 10 10 re S BT /F1 9 Tf 166 51 Td (Zweig) Tj ET";
let objs = [
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 420 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes(),
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, obj) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(obj);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for offset in offsets {
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
/// How the paper form was scanned.
#[derive(Clone, Copy, Debug)]
enum Scan {
Straight,
/// Stored sideways and turned upright by `/Rotate 90`.
Sideways,
/// Speckled paper, uneven light and lines that lose some pixels.
Noisy,
/// Laid on the scanner a degree askew.
Skewed,
}
/// The paper form as an image-only PDF at 150 dpi (the page keeps its 420 × 300 pt).
fn scanned_form(scan: Scan) -> Arc<Vec<u8>> {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("paper.pdf", None, Arc::new(paper_form()), None).unwrap();
let png = export::Exporter::new(s.get(id).unwrap()).png(0, 150.0).unwrap();
let mut img = image::load_from_memory(&png).unwrap().to_rgba8();
match scan {
Scan::Straight => {}
Scan::Sideways => img = image::imageops::rotate270(&img),
Scan::Skewed => {
// Turned a degree about the centre (nearest pixel; the corners stay paper).
let (w, h) = (img.width() as f64, img.height() as f64);
let (sin, cos) = 1f64.to_radians().sin_cos();
let src = img.clone();
for (x, y, p) in img.enumerate_pixels_mut() {
let (dx, dy) = (x as f64 - w / 2.0, y as f64 - h / 2.0);
let (sx, sy) = (cos * dx + sin * dy + w / 2.0, -sin * dx + cos * dy + h / 2.0);
*p = if sx >= 0.0 && sy >= 0.0 && sx < w && sy < h { *src.get_pixel(sx as u32, sy as u32) } else { image::Rgba([255; 4]) };
}
}
Scan::Noisy => {
let (w, mut seed) = (img.width(), 7u32);
for (x, _, p) in img.enumerate_pixels_mut() {
seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12345);
let noise = ((seed >> 16) % 48) as u8;
let shade = (x * 50 / w) as u8;
for c in &mut p.0[..3] {
*c = if *c < 128 && noise < 4 { 230 } else { c.saturating_sub(shade).saturating_sub(noise) };
}
}
}
}
let mut bytes = std::io::Cursor::new(Vec::new());
img.write_to(&mut bytes, image::ImageFormat::Png).unwrap();
let pdf = s.create_from_images_with_resolution(&[("scan.png".into(), bytes.into_inner())], ImageResolution::Dpi(150.0)).unwrap();
if !matches!(scan, Scan::Sideways) {
return pdf;
}
let id = s.open("scan.pdf", None, pdf, None).unwrap();
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
s.save_bytes(id).unwrap()
}
/// Fields on a scanned paper form are found from its lines and boxes, wherever the page turns
/// them, and named from the labels when text recognition is installed.
#[test]
fn detecting_fields_on_a_scanned_form() {
use pdfcraft_forms::detect::Kind::{CheckBox, Text};
for scan in [Scan::Straight, Scan::Sideways, Scan::Noisy, Scan::Skewed] {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("scan.pdf", None, scanned_form(scan), None).unwrap();
assert_eq!(page_texts(&s, id), [""], "{scan:?}: a picture has no text");
let info = s.get(id).unwrap().info.pages[0].clone();
assert_eq!((info.width.round(), info.height.round()), (420.0, 300.0), "{scan:?}");
// Where a field shows on the page, in points from the bottom-left as displayed.
let shown = |r: [f64; 4]| {
let a = info.user_to_view(r[0] as f32, r[1] as f32);
let b = info.user_to_view(r[2] as f32, r[3] as f32);
[a[0].min(b[0]), 300.0 - a[1].max(b[1]), a[0].max(b[0]), 300.0 - a[1].min(b[1])].map(f64::from)
};
let found = s.detect_fields(id, &[]);
let got: Vec<(pdfcraft_forms::detect::Kind, [f64; 4])> = found.iter().map(|(_, c)| (c.kind.clone(), shown(c.rect))).collect();
// In reading order: the rule after "Name:", the box below "Anschrift", the "Telefon"
// box, two check boxes.
let want = [
(Text, [80.0, 229.0, 380.0, 245.0]),
(Text, [31.0, 151.0, 379.0, 189.0]),
(Text, [31.0, 91.0, 379.0, 119.0]),
(CheckBox, [30.0, 50.0, 40.0, 60.0]),
(CheckBox, [150.0, 50.0, 160.0, 60.0]),
];
assert_eq!(got.len(), want.len(), "{scan:?}: {got:?}");
for ((kind, r), (want_kind, w)) in got.iter().zip(&want) {
assert_eq!(kind, want_kind, "{scan:?}: {got:?}");
// The labelled box's top is wherever the label ends; everything else is close. A
// degree askew, a 350 pt line climbs 6 pt, and the field stays inside the box.
let tol = if matches!(scan, Scan::Skewed) { [6.5; 4] } else { [2.5, 2.5, 2.5, if w[3] == 189.0 { 6.0 } else { 2.5 }] };
assert!(r.iter().zip(w).zip(tol).all(|((a, b), t)| (a - b).abs() <= t), "{scan:?}: {r:?}, want {w:?}");
}
let names: Vec<&str> = found.iter().map(|(_, c)| c.name.as_str()).collect();
if ocr::available() {
// (Askew, recognition may read "Haupt" and "Zweig" as one phrase.)
assert!(names.contains(&"Name") && names.contains(&"Telefon") && names.iter().any(|n| n.starts_with("Haupt")), "{scan:?}: {names:?}");
} else {
// Without text recognition the fields get the default names.
assert!(names.iter().all(|n| n.starts_with("Text") || n.starts_with("Check Box")), "{scan:?}: {names:?}");
}
// Added as one step, where they were found; on the turned page each field turns with
// it, so typing reads upright.
let added = s.auto_detect_fields(id, &[]).unwrap();
assert_eq!(added.len(), want.len());
let d = s.get(id).unwrap();
assert_eq!(d.can_undo(), Some("Detect form fields"));
let turn = if matches!(scan, Scan::Sideways) { 90 } else { 0 };
for (_, c) in &found {
let f = d.form.iter().find(|f| f.name == c.name).unwrap();
assert_eq!(f.widgets[0].rotation, turn, "{scan:?}: {}", f.name);
let r = f.widgets[0].rect;
assert!(r.iter().zip(c.rect).all(|(a, b)| (a - b).abs() < 0.01), "{scan:?}: {r:?} moved from {:?}", c.rect);
}
assert!(s.detect_fields(id, &[]).is_empty(), "{scan:?}: nothing left to detect");
}
}
#[test]
fn detecting_comb_boxes_adds_one_comb_field_per_row() {
// The rows from #1887: five 18 x 22 cells and ten 16 x 20 cells, each a stroked `re`.
let mut body = String::from("BT /F1 10 Tf 40 205 Td (Postcode A) Tj ET BT /F1 10 Tf 40 105 Td (Postcode B) Tj ET\n");
for i in 0..5 {
body.push_str(&format!("{} 200 18 22 re S\n", 120 + i * 18));
}
for i in 0..10 {
body.push_str(&format!("{} 100 16 20 re S\n", 120 + i * 16));
}
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("comb.pdf", None, Arc::new(one_line_page("/Font << /F1 4 0 R >>", &body)), None).unwrap();
let found: Vec<(String, pdfcraft_forms::detect::Kind)> = s.detect_fields(id, &[]).into_iter().map(|(_, c)| (c.name, c.kind)).collect();
assert_eq!(
found,
[
("Postcode A".to_string(), pdfcraft_forms::detect::Kind::Comb { cells: 5 }),
("Postcode B".to_string(), pdfcraft_forms::detect::Kind::Comb { cells: 10 })
]
);
s.auto_detect_fields(id, &[]).unwrap();
let d = s.get(id).unwrap();
let combs: Vec<(&str, FormFieldKind, bool, Option<usize>)> =
d.form.iter().map(|f| (f.name.as_str(), f.kind, f.has(pdfcraft_forms::flags::COMB), f.max_len)).collect();
assert_eq!(combs, [("Postcode A", FormFieldKind::Text, true, Some(5)), ("Postcode B", FormFieldKind::Text, true, Some(10))]);
assert_eq!(d.can_undo(), Some("Detect form fields"));
}
#[test]
fn comparing_two_versions_of_a_document() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let v1 = s.create_from_text("t", "The contract starts on Monday.\n\nPayment is due within thirty days.\n\nSigned by both parties.").unwrap();
let v2 = s
.create_from_text("t", "The contract starts on Tuesday.\n\nPayment is due within thirty days of invoice.\n\nSigned by both parties.")
.unwrap();
let old = s.open("v1.pdf", None, v1, None).unwrap();
let new = s.open("v2.pdf", None, v2, None).unwrap();
let c = s.compare(old, new).unwrap();
let got: Vec<(compare::Kind, &str, &str)> = c.changes.iter().map(|x| (x.kind, x.old.text.as_str(), x.new.text.as_str())).collect();
assert_eq!(got, [(compare::Kind::Replaced, "Monday.", "Tuesday."), (compare::Kind::Replaced, "days.", "days of invoice.")], "{got:?}");
assert!(s.compare(old, old).unwrap().identical());
let report = s.compare_report(old, new).unwrap();
let rid = s.open("report.pdf", None, report, None).unwrap();
let text = page_texts(&s, rid).join(" ");
assert!(text.contains("2 changes: 2 replaced") && text.contains("Tuesday."), "{text}");
// Visually, only the changed words' places differ (and the page is otherwise the same).
let vis = s.compare_visual(old, new, 72.0).unwrap();
assert!(!vis.is_empty() && vis.iter().all(|(p, r)| *p == 0 && r[3] > 600.0), "{vis:?}");
assert!(s.compare_visual(old, old, 72.0).unwrap().is_empty());
assert_eq!(s.mark_differences(old, new).unwrap(), 2);
let d = s.get(new).unwrap();
assert_eq!(d.can_undo(), Some("Mark differences"));
let marks: Vec<_> = d.info.annotations.iter().filter(|a| a.author.as_deref() == Some("Compare")).collect();
assert_eq!(marks.len(), 2);
assert!(marks[0].contents.as_deref().unwrap().starts_with("Replaced: \"Monday.\""));
}
#[test]
fn actions_run_their_steps_on_files() {
let s = Session::new();
let src = s.create_from_text("t", "Quarterly numbers").unwrap();
let built = actions::builtin();
let numbers = built.iter().find(|a| a.name == "Add Page Numbers").unwrap();
let r = actions::run_on(numbers, "q.pdf", src.clone(), |_, _| {}).unwrap();
let mut s2 = Session::new();
let id = s2.open("out.pdf", None, r.bytes, None).unwrap();
assert!(page_texts(&s2, id)[0].contains("Page 1 of 1"), "{:?}", page_texts(&s2, id));
assert_eq!(r.log, ["Add footer"]);
let custom = actions::Action {
name: "Mine".into(),
description: String::new(),
steps: vec![
actions::Step::from_id("set_title", "Q3").unwrap(),
actions::Step::AddWatermark("DRAFT".into()),
actions::Step::ReduceFileSize,
actions::Step::FlattenComments,
],
builtin: false,
};
let mut steps = Vec::new();
let r = actions::run_on(&custom, "q.pdf", src, |i, n| steps.push((i, n))).unwrap();
assert_eq!(steps, [(0, 4), (1, 4), (2, 4), (3, 4)]);
assert_eq!(r.log.len(), 4, "{:?}", r.log);
let id = s2.open("out2.pdf", None, r.bytes, None).unwrap();
let d = s2.get(id).unwrap();
assert_eq!(d.info.title.as_deref(), Some("Q3"));
assert!(page_texts(&s2, id)[0].contains("DRAFT"));
assert!(actions::run_on(&custom, "bad.pdf", Arc::new(b"nope".to_vec()), |_, _| {}).is_err());
}
/// A removal step that fails (here: a signed file can't be rewritten without its hidden data)
/// fails the action, so the file is not passed on as cleaned.
#[test]
fn actions_fail_when_hidden_information_could_not_be_removed() {
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
let digital_id = sign::pkcs12::open(&p12, "test").unwrap();
let mut s = Session::new().with_clock(|| 1_800_000_000);
let id = s.open("fixture.pdf", None, Arc::new(fixture(2)), None).unwrap();
s.apply(id, Edit::SetInfo { key: "Author".into(), value: "Private Name".into() }).unwrap();
let saved = s.save_bytes(id).unwrap();
s.mark_saved(id, saved, None).unwrap();
let signed = s.sign(id, &digital_id, SignOptions { page: 1, ..SignOptions::default() }).unwrap();
let built = actions::builtin();
let sanitize = built.iter().find(|a| a.name == "Sanitize Documents").unwrap();
let e = actions::run_on(sanitize, "signed.pdf", signed, |_, _| {}).map(|r| r.log).unwrap_err();
assert!(e.starts_with("Sanitize document:"), "{e}");
}
#[test]
fn exporting_office_files_keeps_images() {
let mut s = Session::new();
let text = s.create_from_text("t", "Picture page").unwrap();
let id = s.open("t.pdf", None, text, None).unwrap();
let png = export::Exporter::new(s.get(id).unwrap()).png(0, 20.0).unwrap();
let pic = s.create_from_images(&[("p.png".into(), png)]).unwrap();
let pid = s.open("p.pdf", None, pic, None).unwrap();
let d = s.get(pid).unwrap();
assert_eq!(d.export_pages()[0].images.len(), 1);
let docx = d.export_office(compare::OfficeFormat::Docx);
assert!(docx.windows(16).any(|w| w == b"word/media/image"));
let pptx = d.export_office(compare::OfficeFormat::Pptx);
assert!(pptx.windows(15).any(|w| w == b"ppt/media/image"));
if let Ok(dir) = std::env::var("PDFCRAFT_EXPORT_DIR") {
std::fs::write(format!("{dir}/pic.docx"), &docx).unwrap();
}
assert!(String::from_utf8(d.export_office(compare::OfficeFormat::Html)).unwrap().contains("data:image/png;base64,"));
}
/// #314: a page whose content is drawn through a form XObject exports its text (the file from
/// the report: no xref table, wrong stream lengths).
#[test]
fn exporting_office_files_reads_form_xobjects() {
let pdf = b"%PDF-1.4
1 0 obj <</Type/Catalog/Pages 2 0 R>> endobj
2 0 obj <</Type/Pages/Kids[3 0 R]/Count 1>> endobj
3 0 obj <</Type/Page/Parent 2 0 R/MediaBox[0 0 595 842]/Resources<</XObject<</Fm1 7 0 R>>>>/Contents 5 0 R>> endobj
4 0 obj <</Type/Font/Subtype/Type1/BaseFont/Helvetica/Encoding/WinAnsiEncoding>> endobj
5 0 obj <</Length 22>> stream
q 1 0 0 1 0 0 cm /Fm1 Do Q
endstream endobj
7 0 obj <</Type/XObject/Subtype/Form/BBox[0 0 595 842]/Resources<</Font<</F1 4 0 R>>>>/Length 60>> stream
BT /F1 18 Tf 72 760 Td (Hello from a test invoice) Tj ET
endstream endobj
trailer <</Root 1 0 R>>
%%EOF
";
let mut s = Session::new();
let id = s.open("form.pdf", None, Arc::new(pdf.to_vec()), None).unwrap();
let d = s.get(id).unwrap();
let blocks: Vec<String> = d.export_pages()[0].blocks.iter().map(|b| b.text.clone()).collect();
assert_eq!(blocks, ["Hello from a test invoice"]);
assert!(String::from_utf8(d.export_office(compare::OfficeFormat::Html)).unwrap().contains("Hello from a test invoice"));
// Edit text still offers only what the page's own streams show.
assert!(d.text_blocks(0).is_empty());
}
#[test]
fn exporting_office_files_keeps_text_colour() {
// #526: a dark green heading came out black in Word.
let content = "BT /F1 24 Tf 0.05 0.23 0.18 rg 72 700 Td (Annual Report) Tj ET \
BT /F1 11 Tf 0 g 72 650 Td (Black body text that is long enough to be the body size.) Tj ET";
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>".into(),
];
let mut pdf = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
let mut s = Session::new();
let id = s.open("c.pdf", None, Arc::new(pdf), None).unwrap();
let d = s.get(id).unwrap();
let html = String::from_utf8(d.export_office(compare::OfficeFormat::Html)).unwrap();
assert!(html.contains("style=\"color:#0D3B2E\">Annual Report"), "{html}");
let pptx = d.export_office(compare::OfficeFormat::Pptx);
assert!(pptx.starts_with(b"PK"));
assert!(pptx.windows(b"ppt/slides/slide1.xml".len()).any(|w| w == b"ppt/slides/slide1.xml"));
let blocks = &d.export_pages()[0].blocks;
assert!(blocks.iter().any(|b| b.text == "Annual Report" && b.color == [0.05, 0.23, 0.18]), "{blocks:?}");
}
#[test]
fn office_export_positions_follow_the_displayed_crop_rotation_and_units() {
use pdfcraft_cos::{Document as CosDocument, ObjRef, Object, SaveOptions, write_full};
let mut pdf = CosDocument::open(Arc::new(fixture(1))).unwrap();
let mut page = pdf.get(ObjRef::new(4, 0)).as_dict().unwrap().clone();
page.set("CropBox", Object::Array([10, 20, 190, 280].map(Object::Int).to_vec()));
page.set("Rotate", Object::Int(90));
page.set("UserUnit", Object::Int(2));
pdf.set(ObjRef::new(4, 0), page);
let mut session = Session::new();
let id = session.open("turned.pdf", None, Arc::new(write_full(&pdf, &SaveOptions::default()).unwrap()), None).unwrap();
let doc = session.get(id).unwrap();
let pages = doc.export_pages();
let b = &pages[0].blocks[0];
assert!((b.rotation + 90.0).abs() < 0.01, "clockwise page rotation: {b:?}");
assert!((b.rect[0] - 248.0).abs() < 0.01, "crop and UserUnit applied: {b:?}");
assert_eq!(b.size, 48.0);
assert_eq!((pages[0].width, pages[0].height), (520.0, 360.0));
}
#[test]
fn guard_turns_a_panic_into_an_error() {
assert_eq!(guard(|| 7), Ok(7));
assert_eq!(guard(|| -> u8 { panic!("boom") }), Err("boom".to_string()));
let n = 3;
assert_eq!(guard(|| -> u8 { panic!("page {n} is bad") }), Err("page 3 is bad".to_string()));
}
#[test]
fn measurement_edits_are_transactional_and_honour_annotation_permissions() {
let mut session = Session::default();
let id = session.open("plan.pdf", None, Arc::new(fixture(1)), None).unwrap();
let m = measure::NewMeasurement {
page: 0,
kind: measure::Kind::Distance,
points: vec![[10.0, 20.0], [40.0, 60.0]],
scale: measure::Scale::new(0.1, "m", 2).unwrap(),
style: Style::default(),
label: "Wall".into(),
author: "Tester".into(),
};
session.apply(id, Edit::AddMeasurement(m.clone())).unwrap();
let before = session.get(id).unwrap().bytes.clone();
assert_eq!(session.get(id).unwrap().measurements().unwrap().measurements[0].reading.label, "5.00 m");
let mut invalid = m.clone();
invalid.points[1][0] = f64::INFINITY;
assert!(session.apply(id, Edit::AddMeasurement(invalid)).is_err());
assert_eq!(session.get(id).unwrap().bytes, before);
session.undo(id).unwrap();
assert!(session.get(id).unwrap().measurements().unwrap().measurements.is_empty());
session.redo(id).unwrap();
assert_eq!(session.get(id).unwrap().bytes, before);
let p = pdfcraft_cos::Permissions { bits: 0, owner: false };
assert_eq!(check_permission(&Edit::AddMeasurement(m), &p), Err(EditError::NotPermitted("comments")));
assert_eq!(
check_permission(
&Edit::SetMeasurementScale { page: 0, bbox: [0.0, 0.0, 100.0, 100.0], name: "Detail".into(), scale: measure::Scale::default() },
&p
),
Err(EditError::NotPermitted("comments"))
);
}
#[test]
fn dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally() {
let original = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template(3)));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("form.pdf", None, original.clone(), None).expect("opens");
let doc = s.get(id).unwrap();
let x = doc.xfa.as_ref().expect("laid out from the template");
assert_eq!((x.pages, x.fields), (2, 14), "{x:?}");
assert!(x.warnings.is_empty(), "{:?}", x.warnings);
assert_eq!(doc.info.pages.len(), 2, "the placeholder page is gone");
assert_eq!(doc.info.xfa, Some(pdfcraft_render::Xfa::Dynamic), "still an XFA form for Adobe's viewers");
assert!(!doc.dirty, "laying out is not an edit");
assert!(doc.bytes.starts_with(&original[..]), "the layout is an appended revision; the original bytes stay");
let names: Vec<&str> = doc.form.iter().map(|f| f.name.as_str()).collect();
assert!(names.contains(&"familyName") && names.contains(&"answer") && names.contains(&"what2"), "{names:?}");
let family = doc.form.iter().find(|f| f.name == "familyName").unwrap();
assert_eq!((family.tooltip.as_deref(), family.max_len), (Some("Your family name"), Some(30)));
let answer = doc.form.iter().find(|f| f.name == "answer").unwrap();
assert_eq!(answer.widgets.iter().filter_map(|w| w.on_state.clone()).collect::<Vec<_>>(), ["Y", "N"]);
assert!(page_texts(&s, id)[0].contains("Sample Form"), "{:?}", page_texts(&s, id));
// Fill it in and save: one more incremental revision on top.
s.apply(id, Edit::SetFieldValue { name: "familyName".into(), value: FieldValue::Text("Singh".into()) }).unwrap();
s.apply(id, Edit::SetFieldValue { name: "answer".into(), value: FieldValue::Radio(Some("N".into())) }).unwrap();
assert!(s.get(id).unwrap().dirty);
let saved = s.save_bytes(id).unwrap();
assert!(saved.starts_with(&original[..]));
// The datasets packet carries the values too, for Adobe's viewers.
let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap();
let data = pdfcraft_xfa::parse_datasets(&pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp).expect("a datasets packet");
assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].head[0].familyName[0]")), Some("Singh"));
assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].head[0].answer[0]")), Some("N"));
let id2 = s.open("saved.pdf", None, saved, None).unwrap();
let d2 = s.get(id2).unwrap();
assert_eq!(d2.info.pages.len(), 2, "a saved form is not laid out a second time");
assert_eq!(d2.xfa.as_ref().map(|x| (x.pages, x.fields)), Some((2, 14)), "the layout is remembered");
assert_eq!(d2.form.iter().find(|f| f.name == "familyName").unwrap().value, vec!["Singh".to_string()]);
assert_eq!(d2.form.iter().find(|f| f.name == "answer").unwrap().value, vec!["N".to_string()]);
}
#[test]
fn a_signed_xfa_signature_box_shows_the_picture_the_file_holds() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("signed.pdf", None, Arc::new(pdfcraft_xfa::fixtures::signed_shell()), None).expect("opens");
let doc = s.get(id).unwrap();
assert_eq!(doc.xfa.as_ref().map(|x| (x.pages, x.fields)), Some((1, 2)));
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone());
let out = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
assert!(out.error.is_none(), "{:?}", out.error);
let at = |x: u32, y: u32| -> [u8; 4] {
let i = ((y * out.width + x) * 4) as usize;
out.rgba[i..i + 4].try_into().unwrap()
};
// The signed box (3in by 1in from half an inch in) is filled with the signature's blue
// picture; the unsigned one below it is not.
assert_eq!(at(36 + 108, 36 + 36), [0, 0, 255, 255]);
assert_ne!(at(36 + 108, 36 + 72 + 36), [0, 0, 255, 255]);
}
#[test]
fn an_xfa_form_without_a_template_opens_with_its_placeholder_and_a_warning() {
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell("<xdp:xdp xmlns:xdp=\"http://ns.adobe.com/xdp/\"></xdp:xdp>"));
let mut s = Session::new();
let id = s.open("form.pdf", None, bytes, None).expect("opens");
let doc = s.get(id).unwrap();
assert!(doc.xfa.is_none());
assert_eq!(doc.info.pages.len(), 1);
assert!(doc.info.warnings.iter().any(|w| w.contains("could not be laid out") && w.contains("no template")), "{:?}", doc.info.warnings);
}
#[test]
fn dynamic_xfa_forms_open_with_the_values_and_rows_of_their_data() {
let data = "<form><head><familyName>Kaur</familyName><born>2001-02-03</born><answer>Y</answer></head><table><row><what0>a</what0></row><row><what0>b</what0></row><row><what0>c</what0></row></table></form>";
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template_with_data(1, data)));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("form.pdf", None, bytes, None).expect("opens");
let doc = s.get(id).unwrap();
let value = |n: &str| doc.form.iter().find(|f| f.name == n).unwrap_or_else(|| panic!("no field {n}")).value.clone();
assert_eq!(value("familyName"), vec!["Kaur".to_string()]);
assert_eq!(value("born"), vec!["2001-02-03".to_string()]);
assert_eq!(value("answer"), vec!["Y".to_string()]);
assert_eq!(value("what0_3"), vec!["c".to_string()], "three rows from the data: {:?}", doc.form.iter().map(|f| &f.name).collect::<Vec<_>>());
assert!(!doc.dirty);
}
/// #3180: an XFA form with no fields whose configuration says `dynamicRender` is `forbidden` is a
/// static form: its pages are the form. Opening it used to lay the template out into new pages
/// and write them as a revision, so even a save with no change replaced the pages.
#[test]
fn an_xfa_form_without_fields_that_forbids_dynamic_rendering_keeps_its_pages() {
// The fixture's own config packet gains `acrobat/acrobat7/dynamicRender`.
let template = pdfcraft_xfa::fixtures::template(1);
let forbidden = "<acrobat><acrobat7><dynamicRender>forbidden</dynamicRender></acrobat7></acrobat><present>";
let with_config = template.replacen("<present>", forbidden, 1);
assert_ne!(with_config, template, "the fixture has a config packet");
// The fixture shell without /NeedsRendering (blanked in place, so the offsets stay right).
let shell = |xdp: &str| {
let bytes = pdfcraft_xfa::fixtures::shell(xdp);
let at = bytes.windows(20).position(|w| w == b"/NeedsRendering true").expect("the fixture sets it");
let mut bytes = bytes;
bytes.splice(at..at + 20, *b" ");
Arc::new(bytes)
};
let mut s = Session::new().with_clock(|| 1_700_000_000);
let original = shell(&with_config);
let id = s.open("static.pdf", None, original.clone(), None).expect("opens");
let doc = s.get(id).unwrap();
assert_eq!(doc.info.xfa, Some(pdfcraft_render::Xfa::Static));
assert!(doc.xfa.is_none(), "nothing was laid out");
assert_eq!(doc.bytes[..], original[..], "no revision was appended on open");
assert_eq!(doc.info.pages.len(), 1);
// The same form without the config is still taken to be dynamic: laid out from the template
// into an appended revision.
let plain = shell(&template);
let id = s.open("dynamic.pdf", None, plain.clone(), None).expect("opens");
let doc = s.get(id).unwrap();
assert!(doc.xfa.is_some(), "laid out from the template");
assert!(doc.bytes.len() > plain.len() && doc.bytes.starts_with(&plain[..]), "the layout is a revision");
}
/// A static XFA form's fields take their values from the datasets on opening, and edits are
/// written back there.
#[test]
fn static_xfa_forms_take_their_values_from_the_datasets_and_write_them_back() {
let original = Arc::new(pdfcraft_xfa::fixtures::static_shell("<form1><page1><name>Ada</name><agree>1</agree></page1></form1>"));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("static.pdf", None, original.clone(), None).expect("opens");
let doc = s.get(id).unwrap();
assert_eq!(doc.info.xfa, Some(pdfcraft_render::Xfa::Static));
assert!(doc.xfa.is_none(), "nothing was laid out");
assert_eq!(doc.form.iter().find(|f| f.name == "form1[0].page1[0].name[0]").unwrap().value, vec!["Ada".to_string()]);
assert_eq!(doc.form.iter().find(|f| f.name == "form1[0].page1[0].agree[0]").unwrap().value, vec!["1".to_string()]);
assert!(!doc.dirty && doc.bytes.starts_with(&original[..]), "the values are an appended revision");
// Rewriting the fields from the XFA data is recorded, not silent.
let note = doc.info.warnings.iter().find(|w| w.contains("taken from this form's XFA data")).expect("a warning");
assert!(note.starts_with("2 form field values") && note.contains("form1[0].page1[0].name[0]"), "{note}");
s.apply(id, Edit::SetFieldValue { name: "form1[0].page1[0].name[0]".into(), value: FieldValue::Text("Grace".into()) }).unwrap();
s.apply(id, Edit::SetFieldValue { name: "form1[0].page1[0].agree[0]".into(), value: FieldValue::Check(false) }).unwrap();
let saved = s.save_bytes(id).unwrap();
let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap();
let data = pdfcraft_xfa::parse_datasets(&pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp).unwrap();
assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form1[0].page1[0].name[0]")), Some("Grace"));
assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form1[0].page1[0].agree[0]")), Some(""), "unchecked, with no off value");
// Reopening agrees with itself: nothing to apply, nothing dirty.
let id2 = s.open("again.pdf", None, saved, None).unwrap();
let d2 = s.get(id2).unwrap();
assert_eq!(d2.form.iter().find(|f| f.name == "form1[0].page1[0].name[0]").unwrap().value, vec!["Grace".to_string()]);
assert!(!d2.dirty);
assert!(!d2.info.warnings.iter().any(|w| w.contains("taken from this form's XFA data")), "nothing was rewritten");
}
/// A date goes into the XFA data in the field's data picture (`<bind><picture>`), as other
/// viewers write and read it, and comes back from there when the form is opened again. It used
/// to be written as an ISO date whatever the picture said.
#[test]
fn xfa_dates_are_written_and_read_back_in_their_data_picture() {
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::bound_date_template("")));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("form.pdf", None, bytes, None).expect("opens");
s.apply(id, Edit::SetFieldValue { name: "due".into(), value: FieldValue::Text("10/18/2026".into()) }).unwrap();
s.apply(id, Edit::SetFieldValue { name: "when".into(), value: FieldValue::Text("10/18/2026".into()) }).unwrap();
let saved = s.save_bytes(id).unwrap();
let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap();
let data = pdfcraft_xfa::parse_datasets(&pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp).unwrap();
assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].page[0].due[0]")), Some("20261018"), "in the field's data picture");
assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].page[0].when[0]")), Some("2026-10-18"), "ISO without one");
let id2 = s.open("again.pdf", None, saved, None).unwrap();
let d2 = s.get(id2).unwrap();
let value = |n: &str| d2.form.iter().find(|f| f.name == n).unwrap_or_else(|| panic!("no field {n}")).value.clone();
assert_eq!(value("due"), vec!["10/18/2026".to_string()]);
assert_eq!(value("when"), vec!["10/18/2026".to_string()]);
}
/// Same-named date fields are each written in their own data picture, and a data picture
/// without a day keeps the ISO date.
#[test]
fn each_xfa_date_field_is_written_in_its_own_data_picture_and_a_partial_one_keeps_iso() {
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::sibling_date_template("")));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("form.pdf", None, bytes, None).expect("opens");
// Two siblings named `due` (laid out as `due` and `due_2`) and a field bound `YYYYMM`.
for name in ["due", "due_2", "month"] {
s.apply(id, Edit::SetFieldValue { name: name.into(), value: FieldValue::Text("10/18/2026".into()) }).unwrap();
}
let saved = s.save_bytes(id).unwrap();
let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap();
let data = pdfcraft_xfa::parse_datasets(&pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp).unwrap();
let at = |som: &str| data.text_at(&pdfcraft_xfa::som_to_path(som)).map(str::to_string);
assert_eq!(at("form[0].page[0].due[0]").as_deref(), Some("20261018"), "the first sibling's data picture");
assert_eq!(at("form[0].page[0].due[1]").as_deref(), Some("18102026"), "the second sibling's own");
// A data picture without a day would drop the one the field shows: the data keeps ISO.
assert_eq!(at("form[0].page[0].month[0]").as_deref(), Some("2026-10-18"));
let id2 = s.open("again.pdf", None, saved, None).unwrap();
let d2 = s.get(id2).unwrap();
for name in ["due", "due_2", "month"] {
let f = d2.form.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("no field {name}"));
assert_eq!(f.value, vec!["10/18/2026".to_string()], "{name} reads back");
}
}
/// Data no field binds to (other namespaces, history) survives an edit and a save.
#[test]
fn xfa_data_no_field_binds_to_survives_an_edit_and_save() {
// Regression: editing a field replaced the whole data element with the fields' values.
let data = concat!(
"<form1><page1><name>Ada</name><agree>1</agree><extra xmlns:my=\"urn:my\"><my:note id=\"7\">keep</my:note></extra></page1>",
"<history><entry><when>2020</when></entry></history></form1><my:other xmlns:my=\"urn:my\"><my:x>unbound</my:x></my:other>"
);
let original = Arc::new(pdfcraft_xfa::fixtures::static_shell(data));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("static.pdf", None, original, None).expect("opens");
s.apply(id, Edit::SetFieldValue { name: "form1[0].page1[0].name[0]".into(), value: FieldValue::Text("Grace".into()) }).unwrap();
let saved = s.save_bytes(id).unwrap();
let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap();
let xdp = pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp;
for kept in [
"<extra xmlns:my=\"urn:my\"><my:note id=\"7\">keep</my:note></extra>",
"<history><entry><when>2020</when></entry></history>",
"<my:other xmlns:my=\"urn:my\"><my:x>unbound</my:x></my:other>",
] {
assert!(xdp.contains(kept), "lost {kept:?}: {xdp}");
}
let data = pdfcraft_xfa::parse_datasets(&xdp).unwrap();
assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form1[0].page1[0].name[0]")), Some("Grace"));
assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form1[0].page1[0].agree[0]")), Some("1"));
// A value that can't be written is reported on the document.
let original = Arc::new(pdfcraft_xfa::fixtures::static_shell("<form1><page1><name><b>rich</b></name></page1></form1>"));
let id = s.open("rich.pdf", None, original, None).expect("opens");
s.apply(id, Edit::SetFieldValue { name: "form1[0].page1[0].name[0]".into(), value: FieldValue::Text("plain".into()) }).unwrap();
let doc = s.get(id).unwrap();
assert!(doc.info.warnings.iter().any(|w| w.contains("structured content")), "{:?}", doc.info.warnings);
assert!(doc.xfa_warnings.iter().any(|w| w.contains("structured content")));
}
#[test]
fn xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save() {
let data = "<form><page1><country>Japan</country><langs>English\nSpanish</langs></page1></form>";
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(data)));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("fields.pdf", None, bytes, None).expect("opens");
let field = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).cloned().unwrap_or_else(|| panic!("no field {n}"));
use pdfcraft_forms::FieldKind as K;
// The forms layer sees a combo box with its options and the data's saved value, a
// multi-select list with both picks, an editable combo, a password field and a signature.
let country = field(&s, "country");
assert_eq!((country.kind, country.value.clone()), (K::Combo, vec!["JP".to_string()]));
assert_eq!(country.options, vec![("CA".into(), "Canada".into()), ("FR".into(), "France".into()), ("JP".into(), "Japan".into())]);
let langs = field(&s, "langs");
assert_eq!(langs.kind, K::List);
assert!(langs.has(pdfcraft_forms::flags::MULTI_SELECT));
assert_eq!(langs.value, vec!["English".to_string(), "Spanish".to_string()]);
assert!(field(&s, "other").has(pdfcraft_forms::flags::EDIT));
assert!(field(&s, "pin").has(pdfcraft_forms::flags::PASSWORD));
assert_eq!(field(&s, "sign").kind, K::Signature);
// Scripts read a choice list's saved value.
assert_eq!(field(&s, "summary").value, vec!["Country: JP".to_string()], "{:?}", s.take_js_output(id));
// Choosing another item recalculates; the list takes several values; a typed value is kept
// in the editable list and refused in the fixed one.
s.apply(id, Edit::SetFieldValue { name: "country".into(), value: FieldValue::Choice(vec!["CA".into()]) }).unwrap();
assert_eq!(field(&s, "summary").value, vec!["Country: CA".to_string()]);
s.apply(id, Edit::SetFieldValue { name: "langs".into(), value: FieldValue::Choice(vec!["French".into(), "Spanish".into()]) }).unwrap();
s.apply(id, Edit::SetFieldValue { name: "other".into(), value: FieldValue::Text("typed".into()) }).unwrap();
assert!(s.apply(id, Edit::SetFieldValue { name: "country".into(), value: FieldValue::Choice(vec!["Mars".into()]) }).is_err());
// Saved: the datasets hold the saved values, several on separate lines; reopened, the
// fields and the calculation are what they were.
let saved = s.save_bytes(id).unwrap();
let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap();
let d = pdfcraft_xfa::data_of(&cos).unwrap();
assert_eq!(d.text_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].country[0]")), Some("CA"));
assert_eq!(d.text_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].langs[0]")), Some("French\nSpanish"));
assert_eq!(d.text_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].other[0]")), Some("typed"));
let id2 = s.open("again.pdf", None, saved.clone(), None).unwrap();
let d2 = s.get(id2).unwrap();
let f2 = |n: &str| d2.form.iter().find(|f| f.name == n).unwrap().value.clone();
assert_eq!(f2("country"), vec!["CA".to_string()]);
assert_eq!(f2("langs"), vec!["French".to_string(), "Spanish".to_string()]);
assert_eq!(f2("summary"), vec!["Country: CA".to_string()]);
// The PNG of the image field and the GIF of the draw are pictures on the page.
assert_eq!(s.get(id).unwrap().page_images(0).len(), 2);
// Filled in by another viewer since: saved values, several on separate lines, and shown
// text (an older viewer's), are taken on reopen; shown text that means the current value
// is not a change.
let mut cos = pdfcraft_cos::Document::open(saved).unwrap();
pdfcraft_xfa::write_data_value(&mut cos, &pdfcraft_xfa::som_to_path("form[0].page1[0].langs[0]"), "English\nSpanish").unwrap();
pdfcraft_xfa::write_data_value(&mut cos, &pdfcraft_xfa::som_to_path("form[0].page1[0].country[0]"), "Canada").unwrap();
let edited = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).unwrap());
let id3 = s.open("elsewhere.pdf", None, edited, None).unwrap();
let d3 = s.get(id3).unwrap();
let f3 = |n: &str| d3.form.iter().find(|f| f.name == n).unwrap().value.clone();
assert_eq!(f3("langs"), vec!["English".to_string(), "Spanish".to_string()]);
assert_eq!(f3("country"), vec!["CA".to_string()]);
let taken: Vec<&String> = d3.info.warnings.iter().filter(|w| w.contains("another viewer")).collect();
assert_eq!(taken.len(), 1, "{:?}", d3.info.warnings);
assert!(taken[0].contains("langs") && !taken[0].contains("country"), "{}", taken[0]);
}
/// A form's data exports, imports and takes pictures through the session, and what it holds
/// comes back the same.
#[test]
fn xfa_data_exports_imports_and_pictures_round_trip_through_the_session() {
use pdfcraft_xfa::som_to_path;
let data = "<form><page1><country>JP</country><langs>English\nSpanish</langs></page1></form>";
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(data)));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("fields.pdf", None, bytes, None).expect("opens");
let field = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).cloned().unwrap_or_else(|| panic!("no field {n}"));
// Export: XML is the datasets document, XDP wraps it; the fields' current values are in it.
s.apply(id, Edit::SetFieldValue { name: "country".into(), value: FieldValue::Choice(vec!["CA".into()]) }).unwrap();
let xml = String::from_utf8(s.export_data(id, DataFormat::Xml, false, true).unwrap()).unwrap();
assert!(xml.starts_with("<?xml") && xml.contains("<form") && xml.contains("<country>CA</country>") && !xml.contains("<xfa:datasets"), "{xml}");
let xdp = String::from_utf8(s.export_data(id, DataFormat::Xdp, false, true).unwrap()).unwrap();
assert!(xdp.contains("<xdp:xdp") && xdp.contains("<country>CA</country>") && xdp.contains("href=\"fields.pdf\""), "{xdp}");
// Import: the data file's values replace the form's; the calculation runs; undo brings
// the old values back.
let file = "<form><page1><country>FR</country><langs>French</langs><pin>1234</pin></page1></form>";
s.apply(id, Edit::ImportData { name: "data.xml".into(), bytes: Arc::new(file.as_bytes().to_vec()) }).unwrap();
assert_eq!(field(&s, "country").value, vec!["FR".to_string()]);
assert_eq!(field(&s, "langs").value, vec!["French".to_string()]);
assert_eq!(field(&s, "pin").value, vec!["1234".to_string()]);
assert_eq!(field(&s, "summary").value, vec!["Country: FR".to_string()], "{:?}", s.take_js_output(id));
s.undo(id).unwrap();
assert_eq!(field(&s, "country").value, vec!["CA".to_string()]);
s.redo(id).unwrap();
assert_eq!(field(&s, "country").value, vec!["FR".to_string()]);
// A file in another encoding, and one with a long comment before its root, import too.
let latin1: Vec<u8> =
"<?xml version=\"1.0\" encoding=\"ISO-8859-1\"?><form><page1><other>Société</other></page1></form>".chars().map(|c| c as u32 as u8).collect();
s.apply(id, Edit::ImportData { name: "l1.xml".into(), bytes: Arc::new(latin1) }).unwrap();
assert_eq!(field(&s, "other").value, vec!["Société".to_string()]);
let commented = format!("<!-- {} --><form><page1><other>after</other></page1></form>", "x".repeat(5000));
s.apply(id, Edit::ImportData { name: "c.xml".into(), bytes: Arc::new(commented.into_bytes()) }).unwrap();
assert_eq!(field(&s, "other").value, vec!["after".to_string()]);
let utf16: Vec<u8> = "<form><page1><other>wide</other></page1></form>".encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
s.apply(id, Edit::ImportData { name: "w.xml".into(), bytes: Arc::new([&[0xFF, 0xFE][..], &utf16].concat()) }).unwrap();
assert_eq!(field(&s, "other").value, vec!["wide".to_string()]);
let shift_jis = b"<?xml version=\"1.0\" encoding=\"Shift_JIS\"?><form/>".to_vec();
assert!(s.apply(id, Edit::ImportData { name: "sj.xml".into(), bytes: Arc::new(shift_jis) }).is_err());
// A data file for another form is refused.
assert!(s.apply(id, Edit::ImportData { name: "o.xml".into(), bytes: Arc::new(b"<other><x>1</x></other>".to_vec()) }).is_err());
assert_eq!(field(&s, "other").value, vec!["wide".to_string()]);
// XFDF still goes the usual way on an XFA form.
let xfdf =
"<?xml version=\"1.0\"?><xfdf xmlns=\"http://ns.adobe.com/xfdf/\"><fields><field name=\"pin\"><value>9999</value></field></fields></xfdf>";
s.apply(id, Edit::ImportData { name: "d.xfdf".into(), bytes: Arc::new(xfdf.as_bytes().to_vec()) }).unwrap();
assert_eq!(field(&s, "pin").value, vec!["9999".to_string()]);
assert_eq!(field(&s, "country").value, vec!["FR".to_string()]);
// A picture chosen for the image field goes into the data and onto the page.
let png = pdfcraft_xfa::fixtures::tiny_png();
assert_eq!(field(&s, "photo").button, Some(pdfcraft_forms::af::ButtonAction::ImportIcon), "clicking it picks a picture");
s.apply(id, Edit::SetFieldImage { name: "photo".into(), image: Arc::new(pdfcraft_xfa::fixtures::TINY_GIF.to_vec()) }).unwrap();
let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap();
let d = pdfcraft_xfa::data_of(&cos).unwrap();
assert!(d.text_at(&som_to_path("form[0].page1[0].photo[0]")).unwrap().starts_with("R0lGODlh"));
assert!(pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp.contains("xfa:contentType=\"image/gif\""));
assert_eq!(s.get(id).unwrap().page_images(0).len(), 2, "the field's picture (now the GIF) and the logo");
// Only pictures the form can show; only image fields.
for (data, declared) in [(pdfcraft_xfa::fixtures::tiny_bmp(false), "image/bmp"), (pdfcraft_xfa::fixtures::tiny_tiff(false), "image/tiff")] {
s.apply(id, Edit::SetFieldImage { name: "photo".into(), image: Arc::new(data) }).unwrap();
let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap();
assert!(pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp.contains(&format!("xfa:contentType=\"{declared}\"")));
}
assert!(s.apply(id, Edit::SetFieldImage { name: "photo".into(), image: Arc::new(b"BM\0\0\0\0".to_vec()) }).is_err());
assert!(s.apply(id, Edit::SetFieldImage { name: "country".into(), image: Arc::new(pdfcraft_xfa::fixtures::TINY_GIF.to_vec()) }).is_err());
// One datasets stream per edit, however many steps it took.
let before = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap().object_numbers().len();
s.apply(id, Edit::SetFieldImage { name: "photo".into(), image: Arc::new(pdfcraft_xfa::fixtures::TINY_GIF.to_vec()) }).unwrap();
s.apply(id, Edit::SetFieldValue { name: "pin".into(), value: FieldValue::Text("1".into()) }).unwrap();
let after = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap();
let datasets = after
.object_numbers()
.into_iter()
.filter(|n| matches!(&*after.get(pdfcraft_cos::ObjRef { num: *n, generation: 0 }), pdfcraft_cos::Object::Stream(st) if st.decoded().is_ok_and(|b| b.starts_with(b"<xfa:datasets"))))
.count();
assert!(datasets <= 3, "{datasets} datasets streams (one per edit at most), {before} objects before");
s.apply(id, Edit::SetFieldImage { name: "photo".into(), image: Arc::new(png) }).unwrap();
let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap();
assert!(pdfcraft_xfa::data_of(&cos).unwrap().text_at(&som_to_path("form[0].page1[0].photo[0]")).unwrap().starts_with("iVBOR"));
// Reopened: the picture and the imported values are what they were.
let id2 = s.open("again.pdf", None, s.save_bytes(id).unwrap(), None).unwrap();
let d2 = s.get(id2).unwrap();
assert_eq!(d2.form.iter().find(|f| f.name == "country").unwrap().value, vec!["FR".to_string()]);
assert_eq!(d2.page_images(0).len(), 2);
}
/// Importing data into a static form replaces every value, emptying the fields the file
/// doesn't name, and pictures chosen for it reach its data.
#[test]
fn xfa_data_import_on_a_static_form_replaces_every_value_and_pictures_reach_its_data() {
let original = Arc::new(pdfcraft_xfa::fixtures::static_shell("<form1><page1><name>Ada</name><agree>1</agree></page1></form1>"));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("static.pdf", None, original, None).expect("opens");
let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name.ends_with(n)).map(|f| f.value.clone()).unwrap_or_default();
assert_eq!(value(&s, "name[0]"), vec!["Ada".to_string()]);
assert_eq!(value(&s, "agree[0]"), vec!["1".to_string()]);
// The file names only `name`: `agree` is emptied too, since the data was replaced.
s.apply(id, Edit::ImportData { name: "d.xml".into(), bytes: Arc::new(b"<form1><page1><name>Bob</name></page1></form1>".to_vec()) }).unwrap();
assert_eq!(value(&s, "name[0]"), vec!["Bob".to_string()]);
assert!(value(&s, "agree[0]").is_empty(), "{:?}", value(&s, "agree[0]"));
let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap();
let d = pdfcraft_xfa::data_of(&cos).unwrap();
assert_eq!(d.text_at(&pdfcraft_xfa::som_to_path("form1[0].page1[0].name[0]")), Some("Bob"));
assert!(d.text_at(&pdfcraft_xfa::som_to_path("form1[0].page1[0].agree[0]")).is_none_or(str::is_empty));
let xml = String::from_utf8(s.export_data(id, DataFormat::Xml, false, true).unwrap()).unwrap();
assert!(xml.contains("<name>Bob</name>") && !xml.contains("<agree>1</agree>"), "{xml}");
}
/// Importing data into a dynamic form adds and removes the rows of its repeating subform to
/// match, and the calculations over them run again.
#[test]
fn xfa_data_import_adds_and_removes_rows_of_a_dynamic_form() {
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("rows.pdf", None, bytes, None).expect("opens");
let names = |s: &Session| s.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect::<Vec<_>>();
let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).map(|f| f.value.clone()).unwrap_or_default();
assert_eq!(names(&s).iter().filter(|n| n.starts_with("amount")).count(), 1);
let file = "<form><page1><qty>3</qty><table><row><item>a</item><amount>10</amount></row><row><item>b</item><amount>32</amount></row><row><item>c</item><amount>0.5</amount></row></table></page1></form>";
s.apply(id, Edit::ImportData { name: "rows.xml".into(), bytes: Arc::new(file.as_bytes().to_vec()) }).unwrap();
assert_eq!(names(&s).iter().filter(|n| n.starts_with("amount")).count(), 3, "{:?}", names(&s));
assert_eq!(value(&s, "total"), vec!["15".to_string()], "qty 3 × price 5, calculated after the import: {:?}", s.take_js_output(id));
assert_eq!(value(&s, "grand"), vec!["42.5".to_string()]);
// More rows than the layout shows: the document says so.
let rows: String = (0..60).map(|i| format!("<row><item>r{i}</item><amount>1</amount></row>")).collect();
let file = format!("<form><page1><qty>1</qty><table>{rows}</table></page1></form>");
s.apply(id, Edit::ImportData { name: "many.xml".into(), bytes: Arc::new(file.into_bytes()) }).unwrap();
let out = s.take_js_output(id);
assert!(out.errors.iter().chain(out.console.iter()).any(|l| l.contains("60 rows")), "{out:?}");
// A file with one row takes the others away.
let file = "<form><page1><qty>1</qty><table><row><item>only</item><amount>7</amount></row></table></page1></form>";
s.apply(id, Edit::ImportData { name: "one.xml".into(), bytes: Arc::new(file.as_bytes().to_vec()) }).unwrap();
assert_eq!(names(&s).iter().filter(|n| n.starts_with("amount")).count(), 1, "{:?}", names(&s));
assert_eq!(value(&s, "grand"), vec!["7".to_string()]);
// Not an XFA data file on an XFA form: refused with the usual message, nothing changed.
assert!(s.apply(id, Edit::ImportData { name: "junk.xml".into(), bytes: Arc::new(b"<a><b></a>".to_vec()) }).is_err());
assert_eq!(value(&s, "grand"), vec!["7".to_string()]);
// The automation tool's view: export then import through a file round-trips the rows.
let out = s.export_data(id, DataFormat::Xdp, false, true).unwrap();
assert!(String::from_utf8_lossy(&out).contains("<item>only</item>"));
}
/// Hundreds of calculations, in JavaScript and FormCalc, run on opening in one engine within
/// the opening's budget, and a change recalculates them within the event's.
#[test]
fn hundreds_of_xfa_calculations_run_in_one_engine_within_the_open_budget() {
let (sections, rows) = (10, 20);
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::many_calculations_template(sections, rows)));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let started = std::time::Instant::now();
let id = s.open("many.pdf", None, bytes, None).expect("opens");
let elapsed = started.elapsed();
let out = s.take_js_output(id);
assert!(!out.errors.iter().any(|e| e.contains("were skipped") || e.contains("stopped")), "{:?}", out.errors);
let form = &s.get(id).unwrap().form;
let totals: Vec<&pdfcraft_forms::Field> = form.iter().filter(|f| f.name.starts_with("total")).collect();
assert_eq!(totals.len(), sections * rows);
assert!(totals.iter().all(|f| !f.value.is_empty()), "every row's total was calculated");
let sums: Vec<&pdfcraft_forms::Field> = form.iter().filter(|f| f.name.starts_with("sum")).collect();
assert_eq!(sums.len(), sections);
// 2 × (1 + 2 + … + rows)
let expect = (rows * (rows + 1)).to_string();
assert!(sums.iter().all(|f| f.value == vec![expect.clone()]), "{:?}", sums.iter().map(|f| f.value.clone()).collect::<Vec<_>>());
eprintln!("{} calculate scripts on open in {elapsed:?}", sections * rows + sections);
// A change recalculates them all again, well within the event budget.
let started = std::time::Instant::now();
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("100".into()) }).unwrap();
let out = s.take_js_output(id);
assert!(!out.errors.iter().any(|e| e.contains("were skipped")), "{:?}", out.errors);
assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value, vec!["200".to_string()]);
assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "sum").unwrap().value, vec![(rows * (rows + 1) - 2 + 200).to_string()]);
eprintln!("recalculated after a change in {:?}", started.elapsed());
}
/// The scripts of one event share the form as the engine holds it: a failed script's
/// half-done writes reach nobody, and a script reads the saved value a choice list was given
/// by its shown text.
#[test]
fn xfa_scripts_of_one_event_see_the_form_as_the_engine_holds_it() {
// In document order: `a` holds 1; `b`'s calculate writes `a` then fails; `c`'s calculate
// reads `a`. A failed script keeps what it changed before the error, and the next script
// sees it. And `d` sets a choice list by its shown text: the next script reads the saved
// value the engine kept.
let tpl = pdfcraft_xfa::fixtures::fields_template("<form><page1><country>JP</country></page1></form>").replace(
r#"<field name="summary" w="3in" h="0.3in" access="readOnly"><ui><textEdit/></ui>"#,
r#"<field name="a" w="1in" h="0.3in"><ui><numericEdit/></ui><value><integer>1</integer></value></field>
<field name="b" w="1in" h="0.3in"><ui><numericEdit/></ui><calculate><script contentType="application/x-javascript">a.rawValue = 99; throw new Error("boom");</script></calculate></field>
<field name="c" w="1in" h="0.3in"><ui><numericEdit/></ui><calculate><script contentType="application/x-javascript">a.rawValue + 1</script></calculate></field>
<field name="d" w="1in" h="0.3in"><ui><textEdit/></ui><calculate><script contentType="application/x-javascript">country.rawValue = "Canada"; "set"</script></calculate></field>
<field name="e" w="1in" h="0.3in"><ui><textEdit/></ui><calculate><script>country</script></calculate></field>
<field name="summary" w="3in" h="0.3in" access="readOnly"><ui><textEdit/></ui>"#,
);
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("iso.pdf", None, bytes, None).expect("opens");
let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).map(|f| f.value.clone()).unwrap_or_default();
let out = s.take_js_output(id);
assert!(out.errors.iter().any(|e| e.contains("boom")), "{out:?}");
assert_eq!(value(&s, "a"), vec!["99".to_string()], "the failed script's write before the error stands");
assert_eq!(value(&s, "c"), vec!["100".to_string()]);
assert_eq!(value(&s, "country"), vec!["CA".to_string()], "shown text mapped to the saved value");
assert_eq!(value(&s, "e"), vec!["CA".to_string()], "the next script reads the saved value");
assert_eq!(value(&s, "summary"), vec!["Country: CA".to_string()]);
}
/// A row of dates worked out the way leave forms do it: the calculation checks that the raw value
/// is a whole ISO date, then adds days to the date as `formattedValue` shows it (`YYYYMMDD`).
#[test]
fn xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it() {
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::date_row_template()));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("row.pdf", None, bytes, None).expect("opens");
let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).map(|f| f.value.clone()).unwrap_or_default();
s.apply(id, Edit::SetFieldValue { name: "from".into(), value: FieldValue::Text("20261020".into()) }).unwrap();
s.apply(id, Edit::SetFieldValue { name: "days".into(), value: FieldValue::Text("3".into()) }).unwrap();
let out = s.take_js_output(id);
assert_eq!(value(&s, "to"), ["20261022"], "{:?}", out.errors);
}
#[test]
fn filling_growable_xfa_text_reflows_and_undo_restores_the_layout() {
let text: String = (0..150).map(|i| format!("Row{i:03} {}sample\n", "sample ".repeat(8))).collect();
assert_eq!(text.len(), 10_500);
for scripted in [false, true] {
let template = pdfcraft_xfa::fixtures::growable_text_template(scripted);
let mut s = Session::new();
let id = s.open("growing.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&template)), None).unwrap();
assert_eq!(s.get(id).unwrap().info.pages.len(), 2);
s.apply(id, Edit::SetFieldValue { name: "ACTIVIDADES".into(), value: FieldValue::Text(text.clone()) }).unwrap();
assert_eq!(s.get(id).unwrap().info.pages.len(), 3, "scripted={scripted}");
let data = pdfcraft_xfa::data_of(&s.get(id).unwrap().editor.as_ref().unwrap().cos).unwrap();
assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].Page3[0].ACTIVIDADES[0]")), Some(text.as_str()));
s.undo(id).unwrap();
assert_eq!(s.get(id).unwrap().info.pages.len(), 2);
assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "ACTIVIDADES").unwrap().value, vec!["Short"]);
s.redo(id).unwrap();
assert_eq!(s.get(id).unwrap().info.pages.len(), 3);
}
}
#[test]
fn xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets() {
for fixed in [false, true] {
let size = if fixed { r#"h="30pt""# } else { r#"minH="20pt""# };
let template = pdfcraft_xfa::fixtures::growable_text_template(false).replace(r#"minH="207.017mm""#, size);
let mut s = Session::new();
let id = s.open("geometry.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&template)), None).unwrap();
let field = |s: &Session| s.get(id).unwrap().form.iter().find(|f| f.name == "ACTIVIDADES").unwrap().clone();
let before = field(&s);
s.apply(id, Edit::SetFieldValue { name: "ACTIVIDADES".into(), value: FieldValue::Text("Line\n".repeat(10)) }).unwrap();
let after = field(&s);
assert_eq!(s.get(id).unwrap().info.pages.len(), 2);
if fixed {
assert_eq!(before.obj, after.obj, "fixed-height fields must not trigger layout");
assert_eq!(before.widgets[0].rect, after.widgets[0].rect);
} else {
assert_ne!(before.obj, after.obj);
assert!(after.widgets[0].rect[3] - after.widgets[0].rect[1] > before.widgets[0].rect[3] - before.widgets[0].rect[1]);
assert_eq!(
s.get(id).unwrap().info.fields.iter().find(|f| f.name == "ACTIVIDADES").unwrap().value.as_deref(),
Some("Line\n".repeat(10).trim_end())
);
}
s.undo(id).unwrap();
assert_eq!(field(&s).widgets[0].rect, before.widgets[0].rect);
s.redo(id).unwrap();
assert_eq!(field(&s).widgets[0].rect, after.widgets[0].rect);
}
}
#[test]
fn xfa_text_that_cannot_sync_to_structured_data_is_not_replaced_by_a_relayout() {
let template = pdfcraft_xfa::fixtures::growable_text_template(false)
.replace("<ACTIVIDADES>Short</ACTIVIDADES>", "<ACTIVIDADES><nested>Short</nested></ACTIVIDADES>");
let mut s = Session::new();
let id = s.open("structured.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&template)), None).unwrap();
let text = "Keep this accepted value\n".repeat(150);
s.apply(id, Edit::SetFieldValue { name: "ACTIVIDADES".into(), value: FieldValue::Text(text.clone()) }).unwrap();
let doc = s.get(id).unwrap();
assert_eq!(doc.info.pages.len(), 2);
assert_eq!(doc.form.iter().find(|f| f.name == "ACTIVIDADES").unwrap().value, vec![text]);
assert!(doc.xfa_warnings.iter().any(|w| w.contains("structured content")));
}
#[test]
fn xfa_structured_data_whitespace_edits_survive_without_stale_relayout() {
let template = pdfcraft_xfa::fixtures::growable_text_template(false)
.replace("<ACTIVIDADES>Short</ACTIVIDADES>", "<ACTIVIDADES><nested>Short</nested></ACTIVIDADES>");
for text in ["Short\n", "\nShort", " Short ", "\tShort\t"] {
let mut s = Session::new();
let id = s.open("structured.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&template)), None).unwrap();
let field = |s: &Session| s.get(id).unwrap().form.iter().find(|f| f.name == "ACTIVIDADES").unwrap().clone();
let before = field(&s);
s.apply(id, Edit::SetFieldValue { name: "ACTIVIDADES".into(), value: FieldValue::Text(text.into()) }).unwrap();
assert_eq!(field(&s).value, vec![text], "the accepted whitespace must not be replaced from stale datasets");
assert_eq!(field(&s).obj, before.obj, "a skipped data write must not rebuild the widgets");
let doc = s.get(id).unwrap();
assert!(doc.dirty);
assert!(doc.xfa_warnings.iter().any(|w| w.contains("structured content")), "{:?}", doc.xfa_warnings);
let cos = pdfcraft_cos::Document::open(doc.bytes.clone()).unwrap();
let xdp = pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp;
assert!(xdp.contains("<ACTIVIDADES><nested>Short</nested></ACTIVIDADES>"));
s.undo(id).unwrap();
assert_eq!(field(&s).value, before.value);
s.redo(id).unwrap();
assert_eq!(field(&s).value, vec![text]);
}
}
/// A script that fails partway keeps what it did before the error, as JavaScript does: a form
/// whose check box opens the next boxes and then trips over an object the form no longer has
/// still opens them (DA Form 31's leave types).
#[test]
fn an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error() {
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::partly_failing_script_template()));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("form.pdf", None, bytes, None).expect("opens");
let field = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).cloned();
assert!(field(&s, "leave").is_some_and(|f| f.flags & 1 != 0), "protected until `kind` is checked");
assert!(field(&s, "details").is_none(), "invisible: not laid out");
s.take_js_output(id);
s.apply(id, Edit::SetFieldValue { name: "kind".into(), value: FieldValue::Check(true) }).unwrap();
let out = s.take_js_output(id);
assert!(out.errors.iter().any(|e| e.contains("kind") && e.contains("change")), "the error is still reported: {out:?}");
assert!(field(&s, "leave").is_some_and(|f| f.flags & 1 == 0), "opened before the error");
assert!(field(&s, "details").is_some(), "shown before the error");
s.apply(id, Edit::SetFieldValue { name: "leave".into(), value: FieldValue::Check(true) }).expect("can be checked now");
}
/// A scripted form end to end: initialize and calculate on open, recalculation after a change,
/// a failing validation's message, buttons that add and remove rows and show messages, a check
/// box that shows a subform (undoable), FormCalc, and a save and reopen that keep it all.
#[test]
fn repeated_xfa_print_buttons_run_their_click_script() {
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::repeated_print_button_template()));
let mut s = Session::new();
let id = s.open("print.pdf", None, bytes, None).unwrap();
assert_eq!(s.get(id).unwrap().info.pages.len(), 3);
for name in ["PrintButton1", "PrintButton1_2", "PrintButton1_3"] {
let out = s.run_javascript(id, "throw new Error('AcroForm fallback ran')", Some(name)).unwrap();
assert_eq!(out.error, None, "{name}: {out:?}");
assert_eq!(out.requests, vec![pdfcraft_js::Request::Print], "{name}: {out:?}");
}
}
#[test]
fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("scripted.pdf", None, bytes, None).expect("opens");
let add_row = s.get(id).unwrap().form.iter().find(|f| f.name == "addRow").expect("the XFA button is in the form snapshot");
assert!(matches!(add_row.button.as_ref(), Some(pdfcraft_forms::af::ButtonAction::Script(_))));
let value = |s: &Session, n: &str| {
s.get(id).unwrap().form.iter().find(|f| f.name == n).map(|f| f.value.clone()).unwrap_or_else(|| panic!("no field {n}"))
};
let names = |s: &Session| s.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect::<Vec<_>>();
// On open: the initialize script set qty, the calculations ran, nothing is dirty.
assert_eq!(value(&s, "qty"), vec!["2".to_string()]);
assert_eq!(value(&s, "total"), vec!["10".to_string()], "{:?}", s.take_js_output(id));
assert_eq!(value(&s, "grand"), vec!["0".to_string()]);
assert!(!s.get(id).unwrap().xfa_scripts_off());
assert!(!s.get(id).unwrap().dirty);
assert!(!names(&s).iter().any(|n| n == "note"), "details is hidden");
// A change recalculates.
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("3".into()) }).unwrap();
assert_eq!(value(&s, "total"), vec!["15".to_string()], "{:?}", s.take_js_output(id));
// A failing validate script shows its message; the value stays, as in Acrobat.
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("500".into()) }).unwrap();
let out = s.take_js_output(id);
assert!(out.alerts.iter().any(|a| a == "Quantity must be 100 or less"), "{out:?}");
assert_eq!(value(&s, "total"), vec!["2500".to_string()]);
// A button adds a row: new fields, same page, one undo step.
let before = names(&s).len();
let o = s.run_javascript(id, "", Some("addRow")).unwrap();
assert_eq!(o.error, None);
let after = names(&s);
assert_eq!(after.len(), before + 2, "{after:?}");
assert!(after.iter().any(|n| n == "amount_2"));
assert_eq!(s.get(id).unwrap().can_undo(), Some("Run form script"));
s.apply(id, Edit::SetFieldValue { name: "amount".into(), value: FieldValue::Text("10".into()) }).unwrap();
s.apply(id, Edit::SetFieldValue { name: "amount_2".into(), value: FieldValue::Text("32".into()) }).unwrap();
assert_eq!(value(&s, "grand"), vec!["42".to_string()], "{:?}", s.take_js_output(id));
// Message boxes reach the viewer.
let o = s.run_javascript(id, "", Some("hello")).unwrap();
assert_eq!(o.alerts, vec!["Hello 500".to_string()]);
// The check box shows the hidden subform; undo hides it again.
s.apply(id, Edit::SetFieldValue { name: "more".into(), value: FieldValue::Check(true) }).unwrap();
assert!(names(&s).iter().any(|n| n == "note"), "{:?} {:?}", names(&s), s.take_js_output(id));
s.undo(id).unwrap();
assert!(!names(&s).iter().any(|n| n == "note"));
s.redo(id).unwrap();
assert!(names(&s).iter().any(|n| n == "note"));
// Removing the row takes its amount out of the sum.
s.run_javascript(id, "", Some("removeRow")).unwrap();
assert!(!names(&s).iter().any(|n| n == "amount_2"));
assert_eq!(value(&s, "grand"), vec!["10".to_string()]);
// FormCalc scripts run too.
let o = s.run_javascript(id, "", Some("legacy")).unwrap();
assert_eq!(o.error, None);
assert_eq!(o.alerts, vec!["FormCalc".to_string()]);
// Saved and reopened: the visible subform, the values and the rows are what they were.
let saved = s.save_bytes(id).unwrap();
let id2 = s.open("again.pdf", None, saved, None).unwrap();
let d2 = s.get(id2).unwrap();
let n2: Vec<&str> = d2.form.iter().map(|f| f.name.as_str()).collect();
assert!(n2.contains(&"note") && !n2.contains(&"amount_2"), "{n2:?}");
assert_eq!(d2.form.iter().find(|f| f.name == "grand").unwrap().value, vec!["10".to_string()]);
assert_eq!(d2.form.iter().find(|f| f.name == "qty").unwrap().value, vec!["500".to_string()]);
assert!(!d2.dirty);
}
/// A repeating table inside an area of a positioned page (as in real Designer forms): every
/// row is laid out, the values Adobe wrote (under the area's parent: areas are
/// not data scopes) are read, a FormCalc total over all rows computes, and what is filled here
/// is written back where Adobe's viewers read it, with no element for the area.
#[test]
fn xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them() {
let data = "<form><page><table><row><reason>Regular</reason><days>4</days></row><row><reason/><days/></row><row><reason/><days/></row></table><carried>5</carried></page></form>";
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::leave_template(data)));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("leave.pdf", None, bytes, None).expect("opens");
let value = |s: &Session, n: &str| {
s.get(id).unwrap().form.iter().find(|f| f.name == n).unwrap_or_else(|| panic!("no field {n}")).value.first().cloned().unwrap_or_default()
};
let names = |s: &Session| s.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect::<Vec<_>>();
// Three rows (occur min="3"), not one.
assert_eq!(names(&s).iter().filter(|n| n.starts_with("reason")).count(), 3, "{:?}", names(&s));
// Adobe's data, bound past the area.
assert_eq!((value(&s, "reason"), value(&s, "days"), value(&s, "carried")), ("Regular".into(), "4".into(), "5".into()));
// The total walks rows 0 to 2: 5 + 30 - 4.
assert_eq!(value(&s, "rest"), "31", "{:?}", s.take_js_output(id));
// A second regular row recalculates it.
s.apply(id, Edit::SetFieldValue { name: "reason_2".into(), value: FieldValue::Text("Regular".into()) }).unwrap();
s.apply(id, Edit::SetFieldValue { name: "days_2".into(), value: FieldValue::Text("2".into()) }).unwrap();
assert_eq!(value(&s, "rest"), "29", "{:?}", s.take_js_output(id));
assert!(s.take_js_output(id).errors.is_empty());
// Written where Adobe reads it: no element for the area, the rows under the table.
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
let d = pdfcraft_xfa::data_of(&cos).unwrap();
let path = |som: &str| pdfcraft_xfa::som_to_path(som);
assert_eq!(d.count(&path("form[0].page[0]"), "box"), 0, "an element for the area was written");
assert_eq!(d.count(&path("form[0].page[0].table[0]"), "row"), 3);
assert_eq!(d.text_at(&path("form[0].page[0].table[0].row[1].days[0]")), Some("2"));
assert_eq!(d.text_at(&path("form[0].page[0].rest[0]")), Some("29"));
// Reopened, the values come back from that data.
let mut s2 = Session::new().with_clock(|| 1_700_000_000);
let id2 = s2.open("again.pdf", None, s.get(id).unwrap().bytes.clone(), None).expect("reopens");
let v2 = |n: &str| s2.get(id2).unwrap().form.iter().find(|f| f.name == n).unwrap().value.first().cloned().unwrap_or_default();
assert_eq!((v2("days_2"), v2("rest")), ("2".into(), "29".into()));
}
#[test]
fn a_value_a_script_sets_reaches_every_field_bound_to_the_same_data_node() {
// `carried` inside the area and a second `carried` outside it bind to one data node (areas
// are not data scopes). In document order: `setter` writes the inner one, `echo` reads the
// outer one in the same event, and the saved data holds the one value.
let tpl = pdfcraft_xfa::fixtures::leave_template("<form><page><carried>5</carried></page></form>")
.replace(
r#" <field name="rest" y="2.5in" w="2in" h="0.3in" access="readOnly">"#,
r#" <field name="setter" y="3in" w="2in" h="0.3in"><ui><numericEdit/></ui><calculate><script>carried = 9
9</script></calculate></field>
<field name="rest" y="2.5in" w="2in" h="0.3in" access="readOnly">"#,
)
.replace(
" </subform>\n </subform>\n</subform>",
r#" </subform>
<field name="carried" y="8.2in" w="2in" h="0.3in"><ui><numericEdit/></ui></field>
<field name="echo" y="8.5in" w="2in" h="0.3in"><ui><numericEdit/></ui><calculate><script>carried + 0</script></calculate></field>
</subform>
</subform>"#,
);
assert!(tpl.contains("name=\"echo\"") && tpl.contains("name=\"setter\""), "the fixture was reshaped");
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("shared.pdf", None, bytes, None).expect("opens");
let out = s.take_js_output(id);
assert!(out.errors.is_empty(), "{out:?}");
let values = |s: &Session, id: DocId, n: &str| {
s.get(id).unwrap().form.iter().filter(|f| f.name.starts_with(n)).map(|f| f.value.join("")).collect::<Vec<_>>()
};
assert_eq!(values(&s, id, "carried"), vec!["9".to_string(), "9".to_string()], "both fields show the node's value");
assert_eq!(values(&s, id, "echo"), vec!["9".to_string()], "read in the same event the setter ran in");
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
let d = pdfcraft_xfa::data_of(&cos).unwrap();
assert_eq!(d.text_at(&pdfcraft_xfa::som_to_path("form[0].page[0].carried[0]")), Some("9"));
assert_eq!(d.count(&pdfcraft_xfa::som_to_path("form[0].page[0]"), "carried"), 1, "one data node");
// The inner field hidden (no widget): the outer widget still takes the node's value, and
// an unrelated edit does not write the old value back over it.
let hidden = Arc::new(pdfcraft_xfa::fixtures::shell(
&tpl.replace(r#"<field name="carried" y="1.5in""#, r#"<field name="carried" presence="hidden" y="1.5in""#),
));
let id = s.open("hidden.pdf", None, hidden, None).expect("opens");
assert_eq!(values(&s, id, "carried"), vec!["9".to_string()]);
assert_eq!(values(&s, id, "echo"), vec!["9".to_string()]);
s.apply(id, Edit::SetFieldValue { name: "allowance".into(), value: FieldValue::Text("31".into()) }).unwrap();
assert_eq!(values(&s, id, "carried"), vec!["9".to_string()]);
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
assert_eq!(pdfcraft_xfa::data_of(&cos).unwrap().text_at(&pdfcraft_xfa::som_to_path("form[0].page[0].carried[0]")), Some("9"));
// A check box bound to the node a text field sets: checked by its own on state.
let checkbox = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl.replace(
r#"<field name="carried" y="1.5in" w="2in" h="0.3in"><ui><numericEdit/></ui></field>"#,
r#"<field name="carried" y="1.5in" w="2in" h="0.3in"><ui><checkButton/></ui><items><integer>9</integer><integer>0</integer></items></field>"#,
)));
let id = s.open("check.pdf", None, checkbox, None).expect("opens");
let carried: Vec<(pdfcraft_forms::FieldKind, Vec<String>)> =
s.get(id).unwrap().form.iter().filter(|f| f.name.starts_with("carried")).map(|f| (f.kind, f.value.clone())).collect();
assert_eq!(
carried,
vec![(pdfcraft_forms::FieldKind::CheckBox, vec!["9".to_string()]), (pdfcraft_forms::FieldKind::Text, vec!["9".to_string()])],
"{carried:?}"
);
}
#[test]
fn flattening_an_xfa_form_bakes_its_pages_and_takes_the_xfa_out() {
let data = "<form><page1><country>JP</country><langs>English\nSpanish</langs></page1></form>";
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(data)));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("fields.pdf", None, bytes, None).expect("opens");
assert_eq!(s.get(id).unwrap().info.xfa, Some(pdfcraft_render::Xfa::Dynamic));
s.apply(id, Edit::SetFieldValue { name: "other".into(), value: FieldValue::Text("typed here".into()) }).unwrap();
s.apply(id, Edit::Flatten { comments: false, fields: true }).unwrap();
let d = s.get(id).unwrap();
assert!(d.form.is_empty(), "no fields remain");
assert_eq!(d.info.xfa, None, "no longer an XFA form: Adobe's viewers show the baked pages");
assert!(d.xfa.is_none());
let texts = page_texts(&s, id).join("\n");
for want in ["Japan", "typed here", "Country: JP", "12345"] {
assert!(texts.contains(want), "{want} is page content now: {texts}");
}
// Saved and reopened: a plain PDF, the same page content, nothing of the XFA left.
let saved = s.save_bytes(id).unwrap();
let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap();
assert!(pdfcraft_xfa::read_packets(&cos).unwrap().is_none());
let id2 = s.open("flat.pdf", None, saved, None).unwrap();
let d2 = s.get(id2).unwrap();
assert_eq!((d2.info.xfa, d2.form.len()), (None, 0));
assert!(page_texts(&s, id2).join("\n").contains("typed here"));
// Undone, the form is a laid-out XFA form again.
s.undo(id).unwrap();
let d = s.get(id).unwrap();
assert_eq!(d.info.xfa, Some(pdfcraft_render::Xfa::Dynamic));
assert_eq!(d.form.iter().find(|f| f.name == "other").unwrap().value, vec!["typed here".to_string()]);
// Flattening comments alone leaves the XFA form as it is.
s.apply(id, Edit::Flatten { comments: true, fields: false }).unwrap();
assert_eq!(s.get(id).unwrap().info.xfa, Some(pdfcraft_render::Xfa::Dynamic));
}
#[test]
fn a_dynamic_xfa_form_that_was_never_laid_out_keeps_its_xfa_and_the_user_is_told_what_went() {
// A template that can't be read: the form keeps its placeholder page and its XFA.
let broken = "<xdp:xdp xmlns:xdp=\"http://ns.adobe.com/xdp/\"><template xmlns=\"http://www.xfa.org/schema/xfa-template/3.3/\"><subform name=\"form\"</template></xdp:xdp>";
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("broken.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(broken)), None).expect("opens");
assert_eq!(s.get(id).unwrap().info.xfa, Some(pdfcraft_render::Xfa::Dynamic));
assert!(s.get(id).unwrap().xfa.is_none(), "not laid out");
assert!(s.apply(id, Edit::XfaToStatic).is_err(), "nothing to keep");
s.apply(id, Edit::Flatten { comments: false, fields: true }).unwrap();
let d = s.get(id).unwrap();
assert_eq!(d.info.xfa, Some(pdfcraft_render::Xfa::Dynamic), "the XFA stays: Adobe can still render the form");
assert!(d.info.warnings.iter().any(|w| w.contains("The XFA was kept")), "{:?}", d.info.warnings);
// A laid-out form with data no field holds: converting says what went with the XFA, and
// the note stays on the document, while the XFA's own warnings go; undo brings those back.
let data = "<form><page1><country>JP</country><nobody>holds this</nobody></page1></form>";
let id = s.open("fields.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(data))), None).unwrap();
let before: Vec<String> = s.get(id).unwrap().info.warnings.clone();
assert!(before.iter().any(|w| w.contains("scripts changed it on opening")), "an XFA-only warning: {before:?}");
s.apply(id, Edit::XfaToStatic).unwrap();
let w = s.get(id).unwrap().info.warnings.clone();
assert!(w.iter().any(|n| n.starts_with("XFA removed: The XFA was taken out") && n.contains("template, datasets")), "{w:?}");
assert!(w.iter().any(|n| n.contains("1 value(s) the form's XFA data held that no field showed")), "{w:?}");
assert!(w.iter().any(|n| n.contains("1 image field(s) do nothing now")), "{w:?}");
assert!(!w.iter().any(|n| n.contains("scripts changed it on opening")), "the XFA's own warning is gone: {w:?}");
let js = s.take_js_output(id);
assert!(js.errors.is_empty(), "{js:?}");
s.undo(id).unwrap();
let w = s.get(id).unwrap().info.warnings.clone();
assert!(!w.iter().any(|n| n.starts_with("XFA removed")), "{w:?}");
assert_eq!(s.get(id).unwrap().info.xfa, Some(pdfcraft_render::Xfa::Dynamic));
}
#[test]
fn an_xfa_form_converts_to_a_static_form_that_keeps_its_fields() {
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("scripted.pdf", None, bytes, None).expect("opens");
let value = |s: &Session, id: DocId, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).map(|f| f.value.clone()).unwrap_or_default();
assert_eq!(value(&s, id, "total"), vec!["10".to_string()], "the scripts ran on open");
let fields_before = s.get(id).unwrap().form.len();
s.apply(id, Edit::XfaToStatic).unwrap();
let d = s.get(id).unwrap();
assert_eq!(d.info.xfa, None);
assert_eq!(d.form.len(), fields_before, "the fields stay");
assert_eq!(value(&s, id, "qty"), vec!["2".to_string()]);
assert_eq!(value(&s, id, "total"), vec!["10".to_string()]);
assert_eq!(s.get(id).unwrap().can_undo(), Some("Convert to a static form"));
// The form is an ordinary one now: a change no longer runs the XFA calculation.
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("7".into()) }).unwrap();
assert_eq!(value(&s, id, "total"), vec!["10".to_string()]);
assert!(s.take_js_output(id).is_empty());
// Saved: no XFA, no private keys on the fields, the pages as laid out.
let saved = s.save_bytes(id).unwrap();
let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap();
assert!(pdfcraft_xfa::read_packets(&cos).unwrap().is_none());
assert!(pdfcraft_xfa::fields_by_som(&cos).is_empty(), "no /PCSom left");
let id2 = s.open("static.pdf", None, saved, None).unwrap();
let d2 = s.get(id2).unwrap();
assert_eq!(d2.info.xfa, None);
assert_eq!(value(&s, id2, "qty"), vec!["7".to_string()]);
assert_eq!(d2.info.pages.len(), 1);
// Not an XFA form: refused.
assert!(s.apply(id2, Edit::XfaToStatic).is_err());
// Undone, the XFA form is back and its scripts run again.
s.undo(id).unwrap();
s.undo(id).unwrap();
assert_eq!(s.get(id).unwrap().info.xfa, Some(pdfcraft_render::Xfa::Dynamic));
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("3".into()) }).unwrap();
assert_eq!(value(&s, id, "total"), vec!["15".to_string()]);
}
#[test]
fn dynamic_xfa_forms_run_formcalc_scripts() {
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::formcalc_template()));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("formcalc.pdf", None, bytes, None).expect("opens");
let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).unwrap().value.clone();
let names = |s: &Session| s.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect::<Vec<_>>();
// Initialize set qty; the calculates ran from it, including the English words.
assert_eq!(value(&s, "qty"), vec!["2".to_string()], "{:?}", s.take_js_output(id));
assert_eq!(value(&s, "total"), vec!["10".to_string()]);
assert_eq!(value(&s, "words"), vec!["Ten".to_string()]);
assert!(s.take_js_output(id).is_empty());
// Editing recalculates and validates.
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("7".into()) }).unwrap();
assert_eq!(value(&s, "total"), vec!["35".to_string()]);
assert_eq!(value(&s, "words"), vec!["Thirty-Five".to_string()]);
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("500".into()) }).unwrap();
let out = s.take_js_output(id);
assert!(out.alerts.iter().any(|a| a == "Quantity must be 100 or less"), "{out:?}");
assert_eq!(value(&s, "qty"), vec!["500".to_string()]);
// Buttons: a row is added, summed and removed; the message box reaches the viewer.
let before = names(&s).len();
let o = s.run_javascript(id, "", Some("addRow")).unwrap();
assert_eq!(o.error, None, "{o:?}");
assert_eq!(names(&s).len(), before + 2, "{:?}", names(&s));
s.apply(id, Edit::SetFieldValue { name: "amount".into(), value: FieldValue::Text("10".into()) }).unwrap();
s.apply(id, Edit::SetFieldValue { name: "amount_2".into(), value: FieldValue::Text("32".into()) }).unwrap();
assert_eq!(value(&s, "grand"), vec!["42".to_string()], "{:?}", s.take_js_output(id));
let o = s.run_javascript(id, "", Some("hello")).unwrap();
assert_eq!(o.alerts, vec!["Hello 500".to_string()]);
s.apply(id, Edit::SetFieldValue { name: "more".into(), value: FieldValue::Check(true) }).unwrap();
assert!(names(&s).iter().any(|n| n == "note"), "{:?} {:?}", names(&s), s.take_js_output(id));
s.run_javascript(id, "", Some("removeRow")).unwrap();
assert!(!names(&s).iter().any(|n| n == "amount_2"));
assert_eq!(value(&s, "grand"), vec!["10".to_string()]);
// Saved and reopened, the form is what it was.
let saved = s.save_bytes(id).unwrap();
let id2 = s.open("again.pdf", None, saved, None).unwrap();
let d2 = s.get(id2).unwrap();
assert_eq!(d2.form.iter().find(|f| f.name == "words").unwrap().value, vec!["Two Thousand Five Hundred".to_string()]);
assert!(d2.form.iter().any(|f| f.name == "note"));
}
#[test]
fn hostile_formcalc_scripts_cannot_hang_or_bloat_the_document() {
let tpl = pdfcraft_xfa::fixtures::formcalc_template()
.replace(
"<calculate><script>qty * price</script></calculate>",
"<calculate><script>table._row.addInstance(1)
table._row.count</script></calculate>",
)
.replace("<script>table._row.addInstance(1)</script>", "<script>while (1) do table._row.addInstance(1) endwhile</script>")
.replace(
"<script>WordNum(total)</script>",
"<script>func f(n) do f(n + 1) endfunc
f(1)</script>",
);
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let started = std::time::Instant::now();
let id = s.open("hostile.pdf", None, bytes, None).expect("opens");
let rows = |s: &Session| s.get(id).unwrap().form.iter().filter(|f| f.name.starts_with("amount")).count();
assert!(rows(&s) <= 50, "{} rows", rows(&s));
let out = s.take_js_output(id);
assert!(out.errors.iter().any(|l| l.contains("nested")), "{out:?}");
let o = s.run_javascript(id, "", Some("addRow")).unwrap();
assert!(o.error.is_some() || rows(&s) <= 50, "{o:?}");
assert!(started.elapsed() < std::time::Duration::from_secs(60), "{:?}", started.elapsed());
}
#[test]
fn a_formcalc_calculate_result_is_cut_before_it_is_saved() {
let tpl = pdfcraft_xfa::fixtures::formcalc_template()
.replace("<script>WordNum(total)</script>", "<script>Replace(Space(100000), \" \", \"x\")</script>");
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("long.pdf", None, bytes, None).expect("opens");
let words = |s: &Session, id| s.get(id).unwrap().form.iter().find(|f| f.name == "words").unwrap().value.concat();
assert_eq!(words(&s, id).chars().count(), 65_536);
let out = s.take_js_output(id);
assert!(out.errors.iter().any(|e| e.contains("cut")), "{out:?}");
let saved = s.save_bytes(id).unwrap();
let id2 = s.open("again.pdf", None, saved, None).unwrap();
assert!(words(&s, id2).chars().count() <= 65_536);
}
#[test]
fn xfa_scripts_stay_off_with_javascript_off() {
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));
let mut s = Session::new().with_clock(|| 1_700_000_000);
s.set_javascript(false);
let id = s.open("scripted.pdf", None, bytes, None).expect("opens");
let qty = s.get(id).unwrap().form.iter().find(|f| f.name == "qty").unwrap().value.clone();
assert!(qty.is_empty(), "no initialize script ran");
assert!(s.run_javascript(id, "", Some("addRow")).is_err());
assert!(!s.get(id).unwrap().xfa_scripts_off(), "the preference, not a script that ran away");
}
#[test]
fn hostile_xfa_scripts_cannot_hang_or_bloat_the_document() {
// A calculate script that adds a row every time it runs, a click script that loops on
// addInstance, and one that nests setInstances: each ends quickly with a bounded form.
let tpl = pdfcraft_xfa::fixtures::scripted_template()
.replace(
r#"<calculate><script contentType="application/x-javascript">qty.rawValue * price.rawValue</script></calculate>"#,
r#"<calculate><script contentType="application/x-javascript">table._row.addInstance(1); table._row.count</script></calculate>"#,
)
.replace(
r#"table._row.addInstance(1);</script>"#,
r#"for (var i = 0; i < 100000; i++) { table._row.addInstance(1); table._row.setInstances(0); }</script>"#,
);
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let started = std::time::Instant::now();
let id = s.open("hostile.pdf", None, bytes, None).expect("opens");
let rows = |s: &Session| s.get(id).unwrap().form.iter().filter(|f| f.name.starts_with("amount")).count();
assert!(rows(&s) <= 50, "{} rows", rows(&s));
let o = s.run_javascript(id, "", Some("addRow")).unwrap();
assert!(o.error.is_some() || rows(&s) <= 50, "{o:?}");
assert!(started.elapsed() < std::time::Duration::from_secs(60), "{:?}", started.elapsed());
}
#[test]
fn xfa_scripts_can_remove_rows_twice_and_reset_fields_by_name() {
let tpl = pdfcraft_xfa::fixtures::scripted_template()
.replace(r#"if (table._row.count > 1) { table._row.removeInstance(table._row.count - 1); }"#, r#"table._row.setInstances(1);"#)
.replace(r#"xfa.host.messageBox("Hello " + qty.rawValue);"#, r#"xfa.host.resetData("qty, table");"#);
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("rows.pdf", None, bytes, None).expect("opens");
let rows = |s: &Session| s.get(id).unwrap().form.iter().filter(|f| f.name.starts_with("amount")).count();
s.run_javascript(id, "", Some("addRow")).unwrap();
s.run_javascript(id, "", Some("addRow")).unwrap();
assert_eq!(rows(&s), 3);
s.apply(id, Edit::SetFieldValue { name: "amount".into(), value: FieldValue::Text("7".into()) }).unwrap();
// setInstances(1) removes two rows in one script: both go, nothing comes back.
s.run_javascript(id, "", Some("removeRow")).unwrap();
assert_eq!(rows(&s), 1, "{:?}", s.get(id).unwrap().form.iter().map(|f| &f.name).collect::<Vec<_>>());
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
let data = pdfcraft_xfa::data_of(&cos).unwrap();
assert_eq!(data.count(&pdfcraft_xfa::som_to_path("form[0].page1[0].table[0]"), "row"), 1);
// resetData with names clears those fields (the row's amount too) and nothing else.
s.apply(id, Edit::SetFieldValue { name: "price".into(), value: FieldValue::Text("9".into()) }).unwrap();
let o = s.run_javascript(id, "", Some("hello")).unwrap();
let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).unwrap().value.clone();
assert!(value(&s, "qty").is_empty(), "{:?} {o:?}", value(&s, "qty"));
assert!(value(&s, "amount").is_empty());
assert_eq!(value(&s, "price"), vec!["9".to_string()]);
}
/// Streams in `bytes` (every revision) that hold an XFA datasets packet.
fn datasets_streams(bytes: &Arc<Vec<u8>>) -> usize {
let cos = pdfcraft_cos::Document::open(bytes.clone()).unwrap();
cos.object_numbers()
.into_iter()
.filter(|&n| {
cos.try_get(n).ok().is_some_and(|o| match &*o {
pdfcraft_cos::Object::Stream(s) => s.decoded_within(1 << 24).is_ok_and(|b| b.windows(9).any(|w| w == b"datasets>")),
_ => false,
})
})
.count()
}
#[test]
fn hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted() {
// On open: a calculate setting its own value 50 000 times, an initialize script showing a
// thousand messages and asking to print, open a link and save.
let tpl = pdfcraft_xfa::fixtures::scripted_template()
.replace(
r#"<calculate><script contentType="application/x-javascript">qty.rawValue * price.rawValue</script></calculate>"#,
r#"<calculate><script contentType="application/x-javascript">for (var i = 0; i &lt; 50000; i++) this.rawValue = i;</script></calculate>"#,
)
.replace(
r#"if (qty.rawValue === null) qty.rawValue = 2;"#,
r#"for (var i = 0; i &lt; 1000; i++) xfa.host.messageBox("m" + i); xfa.host.print(); xfa.host.gotoURL("https://example.com/"); app.execMenuItem("SaveAs"); if (qty.rawValue === null) qty.rawValue = 2;"#,
);
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let started = std::time::Instant::now();
let id = s.open("hostile.pdf", None, bytes, None).expect("opens");
assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed());
let doc = s.get(id).unwrap();
assert_eq!(doc.form.iter().find(|f| f.name == "total").unwrap().value, vec!["49999".to_string()]);
// One datasets stream for the scripts' revision, not one per value set.
assert!(datasets_streams(&doc.bytes) <= 3, "{} datasets streams", datasets_streams(&doc.bytes));
// What opening changed is noted with the form's other XFA notes.
assert!(doc.xfa_warnings.iter().any(|w| w.contains("scripts changed it on opening")), "{:?}", doc.xfa_warnings);
let out = s.take_js_output(id);
assert_eq!(out.alerts.len(), 100, "message boxes are capped");
assert!(out.errors.iter().any(|e| e.contains("more than 100 messages")), "{:?}", out.errors);
assert!(out.requests.is_empty(), "only a click may print, save or open links: {:?}", out.requests);
assert!(out.console.iter().any(|c| c.contains("only print when a button is clicked")), "{:?}", out.console);
}
#[test]
fn runaway_xfa_calculations_at_open_end_quickly_with_a_report() {
// Nested loops in one frame stop at the loop limit; loops in nested calls (that limit is per
// call) are abandoned at the time limit and turn the form's scripts off.
let tpl = pdfcraft_xfa::fixtures::scripted_template()
.replace(
r#"<calculate><script contentType="application/x-javascript">qty.rawValue * price.rawValue</script></calculate>"#,
r#"<calculate><script contentType="application/x-javascript">for (var i = 0; i &lt; 1e9; i++) { for (var j = 0; j &lt; 1e9; j++) {} }</script></calculate>"#,
)
.replace(
r#"var t = 0; var rows"#,
r#"function f() { for (var j = 0; j &lt; 99999; j++) {} } for (var k = 0; k &lt; 5000; k++) f(); var t = 0; var rows"#,
);
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let started = std::time::Instant::now();
let id = s.open("runaway.pdf", None, bytes, None).expect("opens");
assert!(started.elapsed() < std::time::Duration::from_secs(4), "{:?}", started.elapsed());
let out = s.take_js_output(id);
assert!(out.errors.iter().any(|e| e.contains("total") && e.contains("maximum number of iteration")), "{:?}", out.errors);
assert!(out.errors.iter().any(|e| e.contains("abandoned")), "{:?}", out.errors);
assert!(out.errors.iter().any(|e| e.contains("scripts are off")), "{:?}", out.errors);
assert!(s.get(id).unwrap().xfa_scripts_off());
// The scripts stay off: a change runs nothing more.
let started = std::time::Instant::now();
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("3".into()) }).unwrap();
assert!(started.elapsed() < std::time::Duration::from_secs(2));
assert!(s.take_js_output(id).errors.is_empty());
assert!(s.get(id).unwrap().xfa_scripts_off());
}
/// The page view's raster of `page` at 1 px/pt (the document's own render pool).
fn shown(s: &Session, id: DocId, page: usize) -> pdfcraft_render::RenderedPage {
let doc = s.get(id).unwrap();
doc.renderer.set_queue(vec![pdfcraft_render::RenderRequest { page, scale: 1.0, ..Default::default() }]);
let start = std::time::Instant::now();
loop {
if let Some(p) = doc.renderer.try_recv() {
assert!(p.error.is_none(), "{:?}", p.error);
return p;
}
assert!(start.elapsed() < std::time::Duration::from_secs(20), "no render");
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
/// #260 (page 7): comments without an appearance stream, here a FreeText box (`/C` background,
/// blue `/DA` text) and an Ink stroke, weren't drawn. The page view now draws them from
/// appearances made for display only; the working file and what Save writes stay as they were.
#[test]
fn comments_without_appearances_are_drawn_but_not_saved() {
let objs: Vec<&[u8]> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>",
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
b"<< /Type /Annot /Subtype /FreeText /Rect [20 200 180 260] /Contents (Box) /DA (/Helv 10 Tf 0 0 1 rg) /Border [0 0 1] /C [.85 .47 .02] >>",
b"<< /Type /Annot /Subtype /Ink /Rect [20 20 180 120] /InkList [[30 30 100 110 170 30]] /Border [0 0 4] /C [0 .6 0] >>",
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
bytes.extend_from_slice(o);
bytes.extend_from_slice(b"\nendobj\n");
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
let original = Arc::new(bytes);
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("comments.pdf", None, original.clone(), None).expect("opens");
let check = |s: &Session| {
let p = shown(s, id, 0);
let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec();
// y down: the FreeText box spans rows 40..100, the Ink apex is at (100, 190).
assert_eq!(px(100, 90), vec![217, 120, 5, 255], "the FreeText background");
// On the apex row: Ink is drawn as a curve through its points (#374), level at the apex.
assert_eq!(px(100, 190), vec![0, 153, 0, 255], "the Ink stroke");
};
check(&s);
let doc = s.get(id).unwrap();
assert!(Arc::ptr_eq(&doc.bytes, &original), "the working file is the file as opened");
assert!(!Arc::ptr_eq(&doc.display, &doc.bytes) && Arc::ptr_eq(&doc.export_source().bytes, &doc.display), "page images show it too");
// After an edit (a scoped refresh) they are still drawn, and Save writes no appearances.
s.apply(id, rect_comment(0, [150.0, 270.0, 190.0, 290.0])).unwrap();
check(&s);
let saved = s.save_bytes(id).unwrap();
let reopened = pdfcraft_cos::Document::open(saved).unwrap();
for r in [pdfcraft_cos::ObjRef::new(4, 0), pdfcraft_cos::ObjRef::new(5, 0)] {
assert!(reopened.get(r).as_dict().is_some_and(|d| !d.contains(b"AP")), "{r:?} is saved without /AP");
}
}
/// A page with a FreeText comment that has no `/AP` stream: the smallest case behind #2371.
fn free_text_without_appearance() -> Arc<Vec<u8>> {
let objs: Vec<&[u8]> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>",
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
b"<< /Type /Annot /Subtype /FreeText /Rect [20 200 180 260] /Contents (Box) /DA (/Helv 10 Tf 0 0 1 rg) /Border [0 0 1] /C [.85 .47 .02] >>",
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
bytes.extend_from_slice(o);
bytes.extend_from_slice(b"\nendobj\n");
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
Arc::new(bytes)
}
/// #2371: a comment without an appearance stream is displayed by the app, so the CLI's page image
/// must show it too. The helper's copy is for rendering only; documents that need no copy, and
/// files it can't display, keep exactly the original source bytes.
#[test]
fn cli_display_adds_missing_appearances_without_changing_the_source() {
let original = free_text_without_appearance();
let display = display_bytes_for(original.clone(), None);
assert_ne!(display.as_slice(), original.as_slice(), "the display copy draws the missing comment appearance");
let doc = pdfcraft_cos::Document::open(display).unwrap();
let object = doc.get(pdfcraft_cos::ObjRef::new(4, 0));
let annot = object.as_dict().unwrap();
assert!(annot.contains(b"AP"), "the display copy has an appearance stream");
let plain = Arc::new(fixture(1));
assert!(Arc::ptr_eq(&display_bytes_for(plain.clone(), None), &plain), "a complete document needs no copy");
let damaged = Arc::new(b"not a PDF".to_vec());
assert!(Arc::ptr_eq(&display_bytes_for(damaged.clone(), None), &damaged), "an unreadable file falls back to its source");
}
/// Titles a browser writes for a blank page or a pop-up name no document; real titles stay.
#[test]
fn placeholder_titles_are_recognised() {
for t in ["about:blank", "About:Blank", " about:srcdoc ", "blob:https://bank.example/3f2a", "data:text/html,x", "Untitled", "untitled"] {
assert!(is_placeholder_title(t), "{t:?}");
}
for t in ["Quarterly report", "Untitled report", "About: our company", "Statement", "blank"] {
assert!(!is_placeholder_title(t), "{t:?}");
}
}
/// A form whose fields are only page widgets (the `/Fields` list is empty): they are adopted, and
/// the leniency is recorded in the repair log rather than applied silently.
#[test]
fn adopting_page_only_fields_is_noted_as_a_repair() {
let objs: Vec<&str> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", // 1
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>", // 2
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R] >>", // 3
"<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >>", // 4
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (alpha) /Rect [10 200 90 220] /P 3 0 R >>", // 5
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (beta) /Rect [10 150 90 170] /P 3 0 R >>", // 6
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", // 7
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("orphan.pdf", None, Arc::new(bytes), None).expect("opens");
let doc = s.get(id).unwrap();
let names: Vec<&str> = doc.form.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["alpha", "beta"]);
assert_eq!(doc.repair_log(), vec!["the form's /Fields list omits 2 fields; they were read from the page annotations".to_string()]);
}
/// A page too large for the renderer at the asked resolution exports at the most it allows (and
/// says at what), as File ▸ Export does; a source that asks for strictness refuses it instead.
#[test]
fn oversized_exports_clamp_and_report_their_dpi_unless_strict() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
// 14,400 pt (200 in) wide: 600 dpi would be 120,000 px.
let bytes = s.create_blank(14_400.0, 792.0, 1).unwrap();
let id = s.open("wide.pdf", None, bytes, None).unwrap();
let src = s.get(id).unwrap().export_source();
let mut ex = export::Exporter::from_source(src.clone());
let used = ex.dpi_used(0, 600.0);
assert!(used < 600.0 && used > 0.0, "{used}");
let png = ex.png(0, 600.0).expect("clamped, not refused");
assert!(png.starts_with(b"\x89PNG"));
assert!((ex.dpi_used(0, 18.0) - 18.0).abs() < 0.01, "a small enough request (3,600 px) is drawn as asked");
let mut strict = src;
strict.config.reject_oversize = true;
let err = export::Exporter::from_source(strict).png(0, 600.0).unwrap_err();
assert!(err.contains("exceeds renderer limits"), "{err}");
}
#[test]
fn mixed_object_moves_are_one_undo_step_and_survive_save_reopen() {
let (mut s, id) = session_with(1);
let text = AddedText { text: "Added label".into(), rect: [20.0, 240.0, 140.0, 265.0], ..Default::default() };
s.apply(id, Edit::AddText { page: 0, text }).unwrap();
let image = image::RgbImage::from_pixel(12, 8, image::Rgb([240, 20, 20]));
let mut png = std::io::Cursor::new(Vec::new());
image.write_to(&mut png, image::ImageFormat::Png).unwrap();
s.apply(id, Edit::AddImage { page: 0, rect: Some([40.0, 40.0, 80.0, 70.0]), name: "red.png".into(), bytes: Arc::new(png.into_inner()) }).unwrap();
let saved = s.save_bytes(id).unwrap();
s.mark_saved(id, saved.clone(), None).unwrap();
let before = s.get(id).unwrap().editable_objects(0).unwrap();
assert_eq!(before.len(), 3, "one native paragraph, one added label and one added image");
let generation = s.get(id).unwrap().edit_generation();
let targets = before.iter().map(|o| o.target).collect();
s.apply(id, Edit::MoveObjects { page: 0, objects: targets, offset: [24.0, -24.0] }).unwrap();
let doc = s.get(id).unwrap();
assert!(doc.dirty && doc.edit_generation() > generation);
assert_eq!(doc.can_undo(), Some("Move objects"));
let after = doc.editable_objects(0).unwrap();
for (a, b) in before.iter().zip(&after) {
assert_eq!(a.text, b.text);
for (i, (x, y)) in a.rect.iter().zip(b.rect).enumerate() {
assert!((y - x - if i % 2 == 0 { 24.0 } else { -24.0 }).abs() < 0.001);
}
}
let moved = s.save_bytes(id).unwrap();
assert!(moved.starts_with(saved.as_ref()), "an incremental update preserves the original revision");
let mut again = Session::new();
let other = again.open("moved.pdf", None, moved.clone(), None).unwrap();
assert_eq!(again.get(other).unwrap().editable_objects(0).unwrap(), after);
assert_eq!(s.undo(id).unwrap().as_str(), "Move objects");
assert_eq!(s.get(id).unwrap().editable_objects(0).unwrap(), before);
assert_eq!(s.get(id).unwrap().can_undo(), Some("Add image"), "one undo restores all three objects");
s.redo(id).unwrap();
assert_eq!(s.get(id).unwrap().editable_objects(0).unwrap(), after);
let mut renderer = pdfcraft_render::PageRenderer::new(moved, Default::default());
let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
let pixel = |x: usize, y: usize| &rendered.rgba[(y * rendered.width as usize + x) * 4..][..3];
assert!(pixel(84, 269)[0] > 200 && pixel(84, 269)[1] < 50, "the added image renders at its new position");
assert!(pixel(45, 240).iter().all(|v| *v > 240), "the old image position is clear");
}
#[test]
fn failed_group_moves_preserve_generation_dirty_state_and_redo_history() {
let (mut s, id) = session_with(1);
let objects: Vec<_> = s.get(id).unwrap().editable_objects(0).unwrap().iter().map(|o| o.target).collect();
s.apply(id, Edit::MoveObjects { page: 0, objects: objects.clone(), offset: [10.0, 0.0] }).unwrap();
s.undo(id).unwrap();
let before = s.save_bytes(id).unwrap();
let generation = s.get(id).unwrap().edit_generation();
let dirty = s.get(id).unwrap().dirty;
let mut bad = objects;
bad.push(ObjectTarget { kind: ObjectKind::Image, index: 999 });
assert!(s.apply(id, Edit::MoveObjects { page: 0, objects: bad, offset: [10.0, 0.0] }).is_err());
let doc = s.get(id).unwrap();
assert_eq!(doc.edit_generation(), generation);
assert_eq!(doc.dirty, dirty);
assert!(doc.can_undo().is_none());
assert_eq!(doc.can_redo(), Some("Move object"));
assert_eq!(s.save_bytes(id).unwrap(), before);
s.redo(id).unwrap();
assert!(s.get(id).unwrap().dirty);
}
#[test]
fn group_move_respects_modify_permission_and_display_coordinate_rotation() {
for rotation in [0, 90, 180, 270] {
let (mut s, id) = session_with(1);
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: rotation }).unwrap();
let doc = s.get(id).unwrap();
let delta = doc.object_move_offset(0, [17.0, 23.0]).unwrap();
let info = &doc.info.pages[0];
let a = info.user_to_view(30.0, 70.0);
let b = info.user_to_view(30.0 + delta[0] as f32, 70.0 + delta[1] as f32);
assert!((b[0] - a[0] - 17.0).abs() < 0.001 && (b[1] - a[1] - 23.0).abs() < 0.001);
}
let (mut s, id) = session_with(1);
let mut p = protection(None, Some("owner"));
p.changes = Changes::None;
s.apply(id, Edit::Protect(p)).unwrap();
let bytes = s.save_bytes(id).unwrap();
let mut reader = Session::new();
let id = reader.open("restricted.pdf", None, bytes, None).unwrap();
let objects = reader.get(id).unwrap().editable_objects(0).unwrap().iter().map(|o| o.target).collect();
let generation = reader.get(id).unwrap().edit_generation();
assert!(reader.apply(id, Edit::MoveObjects { page: 0, objects, offset: [10.0, 0.0] }).is_err());
let doc = reader.get(id).unwrap();
assert!(!doc.dirty && doc.can_undo().is_none() && doc.edit_generation() == generation);
}
#[test]
fn group_moves_preserve_rotated_added_content_raster_geometry() {
let (mut s, id) = session_with(1);
s.apply(id, Edit::AddText { page: 0, text: AddedText { text: "Added label".into(), rect: [20.0, 240.0, 140.0, 265.0], ..Default::default() } })
.unwrap();
let image = image::RgbImage::from_fn(20, 10, |x, _| image::Rgb(if x < 10 { [240, 20, 20] } else { [20, 20, 240] }));
let mut png = std::io::Cursor::new(Vec::new());
image.write_to(&mut png, image::ImageFormat::Png).unwrap();
s.apply(id, Edit::AddImage { page: 0, rect: Some([40.0, 40.0, 80.0, 70.0]), name: "quadrants.png".into(), bytes: Arc::new(png.into_inner()) })
.unwrap();
let AddedContent::Image(mut image) = s.get(id).unwrap().added()[1].content.clone() else { panic!() };
image.flip_h = true;
image.crop = [0.1, 0.0, 0.2, 0.0];
s.apply(id, Edit::UpdateContent { page: 0, index: 1, content: AddedContent::Image(image) }).unwrap();
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
let render = |bytes| {
let mut r = pdfcraft_render::PageRenderer::new(bytes, Default::default());
r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() })
};
let before = render(s.get(id).unwrap().bytes.clone());
let doc = s.get(id).unwrap();
let offset = doc.object_move_offset(0, [24.0, 36.0]).unwrap();
let objects = doc.editable_objects(0).unwrap().iter().map(|o| o.target).collect();
s.apply(id, Edit::MoveObjects { page: 0, objects, offset }).unwrap();
let after = render(s.get(id).unwrap().bytes.clone());
assert_eq!((before.width, before.height), (after.width, after.height));
let mut painted = 0;
for y in 0..before.height as usize {
for x in 0..before.width as usize {
let pos = (y * before.width as usize + x) * 4;
let expected = if x >= 24 && y >= 36 {
let old = ((y - 36) * before.width as usize + x - 24) * 4;
&before.rgba[old..old + 4]
} else {
&[255, 255, 255, 255]
};
// Raster coverage can round by one intensity level when equivalent placements
// reach the renderer through different text operators. Geometry and image colours
// must still agree across every pixel of the translated page.
assert!(after.rgba[pos..pos + 4].iter().zip(expected).all(|(a, b)| a.abs_diff(*b) <= 2), "pixel {x},{y}");
painted += usize::from(expected[..3].iter().any(|c| *c < 240));
}
}
assert!(painted > 500, "the comparison contains real rendered text and image pixels");
}
#[test]
fn legacy_passwords_with_turkish_characters_open() {
// RC4-128 (R3): user "ılık€" in PDFDocEncoding (ı = 0x9A, € = 0xA0), owner "Çağlar"
// (PDFDocEncoding has no ğ: written without it, as pdfcraft-crypt writes R2–R4 passwords).
let mut cos = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap();
let params = pdfcraft_cos::NewEncryption {
algorithm: pdfcraft_cos::Algorithm::Rc4_128,
user_password: "ılık€",
owner_password: "Çağlar",
permissions: -1,
encrypt_metadata: true,
seed: [7; 32],
};
cos.set_encryption(&params).unwrap();
let bytes = Arc::new(pdfcraft_cos::write_full(&cos, &pdfcraft_cos::SaveOptions::default()).unwrap());
let mut s = Session::new();
// The renderer used to try only the UTF-8 bytes, and the owner fallback handed it the
// recovered user password as Latin-1: both came back as a wrong password.
for pw in ["ılık€", "Çağlar"] {
let id = s.open("x.pdf", None, bytes.clone(), Some(pw)).unwrap_or_else(|e| panic!("{pw}: {e:?}"));
assert_eq!(page_texts(&s, id), ["Page 1"], "{pw}");
assert!(s.get(id).unwrap().info.encrypted, "{pw}");
}
assert!(matches!(s.open("x.pdf", None, bytes, Some("ilik€")), Err(OpenError::WrongPassword)));
}
#[test]
fn recovered_user_passwords_reach_the_renderer_byte_for_byte() {
assert_eq!(crate::renderer_password(b"pw"), "pw");
// UTF-8 bytes (qpdf writes "şifre" so; its \xC5\x9F has no PDFDocEncoding reading).
assert_eq!(crate::renderer_password("şifre".as_bytes()), "şifre");
// PDFDocEncoding bytes: ı = 0x9A, € = 0xA0, Ç = 0xC7.
assert_eq!(crate::renderer_password(&[0x9A, b'l', 0x9A, b'k', 0xA0]), "ılık€");
assert_eq!(crate::renderer_password(&[0xC7, b'o', b'k']), "Çok");
}
/// AES-256 passwords as typed, each with its SASLprep form, which ISO 32000-2 keys the file on:
/// a decomposed é (NFKC composes it), a no-break space (mapped to a space) and fullwidth letters
/// (what a Japanese IME types in full-width mode; NFKC narrows them). Precomposed "café" is the
/// control: SASLprep leaves it as it is.
const SASLPREP_CHANGES: [(&str, &str); 4] =
[("cafe\u{301}", "caf\u{E9}"), ("a\u{A0}b", "a b"), ("\u{FF41}\u{FF42}", "ab"), ("caf\u{E9}", "caf\u{E9}")];
#[test]
fn aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed() {
for (typed, prepared) in SASLPREP_CHANGES {
let (mut s, id) = session_with(1);
// The renderer used to take the password as typed, so the protected file didn't reopen
// and the edit was rolled back: "the password is incorrect".
s.apply(id, Edit::Protect(protection(Some(typed), None))).unwrap_or_else(|e| panic!("{typed:?}: {e:?}"));
assert!(s.get(id).unwrap().info.encrypted, "{typed:?}");
assert_eq!(page_texts(&s, id), ["Page 1"], "{typed:?}");
let saved = s.save_bytes(id).unwrap();
for pw in [typed, prepared] {
let mut again = Session::new();
let reopened = again.open("x.pdf", None, saved.clone(), Some(pw)).unwrap_or_else(|e| panic!("{typed:?} as {pw:?}: {e:?}"));
assert_eq!(page_texts(&again, reopened), ["Page 1"], "{typed:?} as {pw:?}");
}
assert!(matches!(Session::new().open("x.pdf", None, saved, Some("cafe")), Err(OpenError::WrongPassword)), "{typed:?}");
}
}
#[test]
fn aes256_files_whose_passwords_saslprep_changes_open_as_typed() {
// A fullwidth O: SASLprep makes the owner password "Owner".
let (owner, owner_prepared) = ("\u{FF2F}wner", "Owner");
for (typed, prepared) in SASLPREP_CHANGES {
// pdfcraft-cos keys the file on the SASLprep forms, as ISO 32000-2 asks.
let mut cos = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap();
let params = pdfcraft_cos::NewEncryption {
algorithm: pdfcraft_cos::Algorithm::Aes256,
user_password: typed,
owner_password: owner,
permissions: -1,
encrypt_metadata: true,
seed: [7; 32],
};
cos.set_encryption(&params).unwrap();
let bytes = Arc::new(pdfcraft_cos::write_full(&cos, &pdfcraft_cos::SaveOptions::default()).unwrap());
let mut s = Session::new();
// pdfcraft-cos accepts the password as typed (it applies SASLprep); the renderer used to
// refuse it, so the session called it a wrong password.
for pw in [typed, prepared, owner, owner_prepared] {
let id = s.open("x.pdf", None, bytes.clone(), Some(pw)).unwrap_or_else(|e| panic!("{typed:?} as {pw:?}: {e:?}"));
assert_eq!(page_texts(&s, id), ["Page 1"], "{typed:?} as {pw:?}");
assert!(s.get(id).unwrap().info.encrypted, "{typed:?} as {pw:?}");
}
assert!(matches!(s.open("x.pdf", None, bytes, Some("cafe")), Err(OpenError::WrongPassword)), "{typed:?}");
}
}
/// A synthetic phone photo, 400 × 300 px: a white sheet, tilted, on a grey table, with a black
/// mark in the sheet's top-left corner. Returns the PNG and the sheet's corners in pixels.
fn camera_photo() -> (Vec<u8>, [[f64; 2]; 4]) {
let sheet = [[60.0, 40.0], [330.0, 20.0], [350.0, 270.0], [40.0, 250.0]];
let inside = |x: f64, y: f64| {
(0..4).all(|i| {
let (a, b) = (sheet[i], sheet[(i + 1) % 4]);
(b[0] - a[0]) * (y - a[1]) - (b[1] - a[1]) * (x - a[0]) >= 0.0
})
};
let img = image::RgbImage::from_fn(400, 300, |x, y| {
let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5);
if (75.0..95.0).contains(&fx) && (55.0..75.0).contains(&fy) {
image::Rgb([0, 0, 0])
} else if inside(fx, fy) {
image::Rgb([255, 255, 255])
} else {
image::Rgb([110, 110, 110])
}
});
let mut png = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
(png, sheet)
}
#[test]
fn enhance_camera_page_straightens_the_sheet() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let (png, sheet) = camera_photo();
// 72 dpi: one point per pixel, so the page is 400 × 300 pt and y flips to user space.
let bytes = s.create_from_images_with_resolution(&[("photo.png".into(), png)], ImageResolution::Dpi(72.0)).unwrap();
let id = s.open("photo.pdf", None, bytes, None).unwrap();
let corners = sheet.map(|[x, y]| [x, 300.0 - y]);
// Corners in any order.
s.enhance_camera_page(id, 0, [corners[2], corners[0], corners[3], corners[1]]).unwrap();
let doc = s.get(id).unwrap();
assert_eq!(doc.info.pages.len(), 1);
assert_eq!(doc.can_undo(), Some("Enhance camera image"));
// The longer of each pair of opposite sides: 310 × 250 pt at the photo's own resolution.
let p = &doc.info.pages[0];
assert!((p.width - 310.0).abs() <= 1.0 && (p.height - 250.0).abs() <= 1.0, "{} × {}", p.width, p.height);
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone());
let shot = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
assert!(shot.error.is_none(), "{:?}", shot.error);
let at = |x: u32, y: u32| {
let i = ((y * shot.width + x) * 4) as usize;
shot.rgba[i..i + 3].to_vec()
};
// No table left in the corners: the sheet fills the page, and its mark is top left.
for (x, y) in [(4, 4), (shot.width - 5, 4), (shot.width - 5, shot.height - 5), (4, shot.height - 5), (shot.width / 2, shot.height / 2)] {
assert!(at(x, y).iter().all(|&v| v > 235), "({x}, {y}) = {:?}", at(x, y));
}
assert!(at(30, 30).iter().all(|&v| v < 40), "the mark: {:?}", at(30, 30));
s.undo(id).unwrap();
let p = &s.get(id).unwrap().info.pages[0];
assert_eq!((p.width, p.height), (400.0, 300.0));
}
#[test]
fn detect_camera_corners_finds_the_sheet_in_user_space() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let (png, sheet) = camera_photo();
let bytes = s.create_from_images_with_resolution(&[("photo.png".into(), png)], ImageResolution::Dpi(72.0)).unwrap();
let id = s.open("photo.pdf", None, bytes, None).unwrap();
let found = s.detect_camera_corners(id, 0).expect("the sheet's edges");
// One point per pixel, y flipped: the sheet's corners in reading order.
for (f, p) in found.iter().zip(sheet.iter()) {
let want = [p[0], 300.0 - p[1]];
assert!((f[0] - want[0]).hypot(f[1] - want[1]) < 4.0, "{found:?} vs {sheet:?}");
}
// A page with no photo on it: nothing to find.
let id = s.open("doc.pdf", None, Arc::new(fixture(1)), None).unwrap();
assert_eq!(s.detect_camera_corners(id, 0), None);
assert_eq!(s.detect_camera_corners(id, 7), None);
}
#[test]
fn enhance_camera_page_at_a_level_whitens_a_shadowed_sheet() {
// A sheet lit unevenly: full light at the left, a third of it at the right.
let img = image::RgbImage::from_fn(400, 300, |x, y| {
let light = 1.0 - 0.66 * x as f32 / 400.0;
let ink = if (y / 10) % 3 == 1 && (40..360).contains(&x) { 0.12 } else { 0.96 };
let v = (ink * light * 255.0) as u8;
image::Rgb([v, v, (v as f32 * 0.85) as u8])
});
let mut png = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let whole = [[0.0, 0.0], [400.0, 0.0], [400.0, 300.0], [0.0, 300.0]];
let paper_at_right = |level: u8| {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let bytes = s.create_from_images_with_resolution(&[("photo.png".into(), png.clone())], ImageResolution::Dpi(72.0)).unwrap();
let id = s.open("photo.pdf", None, bytes, None).unwrap();
s.enhance_camera_page_at(id, 0, whole, camera_clean::Look::Colour, level).unwrap();
assert_eq!(s.get(id).unwrap().can_undo(), Some("Enhance camera image"), "one step at level {level}");
let doc = s.get(id).unwrap();
let shot = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()).render(pdfcraft_render::RenderRequest {
page: 0,
scale: 1.0,
..Default::default()
});
// A paper pixel near the right edge, between text rows.
let i = ((5 * shot.width + shot.width - 12) * 4) as usize;
shot.rgba[i..i + 3].to_vec()
};
let (before, after) = (paper_at_right(0), paper_at_right(80));
assert!(before.iter().all(|&v| v < 140), "the shadow: {before:?}");
assert!(after.iter().all(|&v| v > 235), "white paper: {after:?}");
}
#[test]
fn enhance_camera_page_refuses_bad_corners_and_pages() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("doc.pdf", None, Arc::new(fixture(1)), None).unwrap();
let line = [[0.0, 0.0], [50.0, 50.0], [100.0, 100.0], [150.0, 150.0]];
let e = s.enhance_camera_page(id, 0, line).unwrap_err().to_string();
assert!(e.contains("corners"), "{e}");
let ok = [[0.0, 0.0], [200.0, 0.0], [200.0, 300.0], [0.0, 300.0]];
assert!(s.enhance_camera_page(id, 5, ok).unwrap_err().to_string().contains("no page 6"));
assert!(matches!(s.enhance_camera_page(DocId(999), 0, ok), Err(EditError::NoDocument)));
assert_eq!(s.get(id).unwrap().can_undo(), None, "nothing changed");
}
/// A content-only edit must not disturb the inspection results: pages, annotations, fields,
/// links and marks are unchanged, only the working bytes, the added-content list and the
/// display copy move.
#[test]
fn content_edit_keeps_pages_annotations_fields_and_links() {
let (mut session, id) = session_with(2);
let before = session.get(id).unwrap();
let pages = format!("{:?}", before.info.pages);
let annotations = format!("{:?}", before.info.annotations);
let links = format!("{:?}", before.links);
let form = before.form.clone();
let marks = before.marks().to_vec();
let bytes = before.bytes.clone();
let text = AddedText {
rect: [20.0, 20.0, 200.0, 60.0],
text: "hello".into(),
family: FontFamily::Helvetica,
bold: false,
italic: false,
size: 12.0,
color: [0.0, 0.0, 0.0],
align: TextAlign::Left,
};
session.apply(id, Edit::AddText { page: 1, text }).unwrap();
let after = session.get(id).unwrap();
assert_eq!(format!("{:?}", after.info.pages), pages, "page geometry is untouched");
assert_eq!(format!("{:?}", after.info.annotations), annotations);
assert_eq!(format!("{:?}", after.links), links);
assert_eq!(after.form, form, "no field was touched");
assert_eq!(after.marks(), marks, "no mark was touched");
assert!(after.added().iter().any(|a| a.page == 1), "the added text is listed");
assert_ne!(after.bytes, bytes, "the working file carries the edit");
assert_ne!(after.display, bytes, "the display copy carries the edit too");
// The display bytes still open and render the edited page.
let mut renderer = pdfcraft_render::PageRenderer::new(after.display.clone(), after.config.clone());
let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 1, scale: 1.0, ..Default::default() });
assert!(rendered.error.is_none(), "{:?}", rendered.error);
// Undo goes through the same narrow refresh without the edit at hand.
session.undo(id).unwrap();
let undone = session.get(id).unwrap();
assert!(undone.added().is_empty(), "undo drops the added text");
assert_eq!(format!("{:?}", undone.info.pages), pages);
}
/// `refresh_content` re-reads only the derived lists the edit can change (undo/redo, with no
/// edit at hand, re-read both).
#[test]
fn content_refresh_lists_match_the_storages_an_edit_touches() {
let target = |kind| ObjectTarget { kind, index: 0 };
let added = [
Edit::AddText {
page: 0,
text: AddedText {
rect: [0.0, 0.0, 10.0, 10.0],
text: String::new(),
family: FontFamily::Helvetica,
bold: false,
italic: false,
size: 12.0,
color: [0.0, 0.0, 0.0],
align: TextAlign::Left,
},
},
Edit::MoveObjects { page: 0, objects: vec![target(crate::ObjectKind::Added)], offset: [1.0, 0.0] },
];
for edit in &added {
assert_eq!(Session::content_refresh_lists(Some(edit)), (true, false), "{}", edit.label());
}
let plain = [
Edit::MoveObjects { page: 0, objects: vec![target(crate::ObjectKind::Text)], offset: [1.0, 0.0] },
Edit::EditTextBlock { page: 0, block: 0, text: String::new(), style: pdfcraft_edit::BlockStyle::default() },
];
for edit in &plain {
assert_eq!(Session::content_refresh_lists(Some(edit)), (false, false), "{}", edit.label());
}
let marks = [Edit::AddWatermark { pages: vec![0], settings: Watermark::default(), replace: false, file: None }];
for edit in &marks {
assert_eq!(Session::content_refresh_lists(Some(edit)), (false, true), "{}", edit.label());
}
assert_eq!(Session::content_refresh_lists(None), (true, true), "undo/redo re-read both");
}
/// A content edit can add a font (Add text in Times); the narrow refresh leaves the font list
/// as it was, and `refresh_fonts` reads it again before Properties ▸ Fonts or `doc_info` show it.
#[test]
fn fonts_added_by_a_content_edit_show_after_refresh_fonts() {
let (mut session, id) = session_with(1);
let has_times = |s: &Session| s.get(id).unwrap().info.fonts.iter().any(|f| f.name.contains("Times"));
assert!(!has_times(&session));
let text = AddedText { text: "serif".into(), rect: [20.0, 20.0, 200.0, 60.0], family: FontFamily::Times, ..Default::default() };
session.apply(id, Edit::AddText { page: 0, text }).unwrap();
assert!(!has_times(&session), "the narrow refresh doesn't re-inspect");
session.refresh_fonts(id);
assert!(has_times(&session), "{:?}", session.get(id).unwrap().info.fonts);
// Nothing changed since: a second call is a no-op.
let fonts = format!("{:?}", session.get(id).unwrap().info.fonts);
session.refresh_fonts(id);
assert_eq!(format!("{:?}", session.get(id).unwrap().info.fonts), fonts);
}
/// Gap 13: every edit writes a new working file (the original plus one revision). Before the
/// next one is written, the current one is let go of by the working bytes, the display bytes and
/// the renderer: on a 1 GiB file, keeping it is a whole extra gigabyte at the peak of each edit.
#[test]
fn the_working_file_is_let_go_of_before_the_next_is_written() {
let (mut s, id) = session_with(2);
s.apply(id, Edit::RotatePages { pages: vec![1], degrees: 90 }).unwrap();
let doc = s.doc_mut(id).unwrap();
let (working, original) = (Arc::downgrade(&doc.bytes), doc.editor.as_ref().unwrap().cos.bytes().clone());
assert!(!Arc::ptr_eq(&doc.bytes, &original), "an edited working file is a buffer of its own");
Session::release_working(doc);
assert!(Arc::ptr_eq(&doc.bytes, &original) && Arc::ptr_eq(&doc.display, &original));
// The old renderer's workers let go of the file when they exit, just after.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while working.upgrade().is_some() {
assert!(std::time::Instant::now() < deadline, "the working file is still held");
std::thread::sleep(std::time::Duration::from_millis(5));
}
// The refresh that follows brings everything back.
Session::refresh(doc).unwrap();
assert_eq!(s.get(id).unwrap().info.pages[1].rotation, 90);
let _ = shown(&s, id, 1);
}
/// Every refresh path (full, comments, content, undo, redo) lets go of the previous working file
/// and still shows the current state.
#[test]
fn edits_let_go_of_the_previous_working_file() {
let (mut s, id) = session_with(2);
let shape = Shape::Rectangle { rect: [20.0, 20.0, 60.0, 60.0] };
let comment = || {
Edit::AddAnnotation(NewAnnotation {
page: 0,
style: Style::default_for(&shape),
shape: shape.clone(),
contents: String::new(),
author: "Test".into(),
})
};
let watermark =
|| Edit::AddWatermark { pages: vec![0], settings: Watermark { text: "Draft".into(), ..Watermark::default() }, file: None, replace: false };
s.apply(id, Edit::RotatePages { pages: vec![1], degrees: 90 }).unwrap();
// A full refresh, a comment refresh, a content refresh, then undo and redo.
type Step<'a> = Box<dyn Fn(&mut Session) + 'a>;
let steps: [(&str, Step<'_>); 5] = [
("rotate", Box::new(|s: &mut Session| s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap())),
("comment", Box::new(move |s: &mut Session| s.apply(id, comment()).unwrap())),
("watermark", Box::new(move |s: &mut Session| s.apply(id, watermark()).unwrap())),
("undo", Box::new(|s: &mut Session| drop(s.undo(id).unwrap()))),
("redo", Box::new(|s: &mut Session| drop(s.redo(id).unwrap()))),
];
for (name, step) in steps {
let (bytes, display) = {
let doc = s.get(id).unwrap();
(Arc::downgrade(&doc.bytes), Arc::downgrade(&doc.display))
};
step(&mut s);
// The old renderer's workers let go of the file when they exit, just after.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while bytes.upgrade().is_some() || display.upgrade().is_some() {
assert!(std::time::Instant::now() < deadline, "{name}: the previous working file is still held");
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
// What is shown is still the current state.
let doc = s.get(id).unwrap();
assert_eq!(doc.info.annotations.len(), 1);
let _ = shown(&s, id, 0);
}
#[test]
fn copy_image_finds_the_image_under_a_point_and_decodes_it_at_full_size() {
let mut s = Session::new();
let mut px = image::RgbaImage::new(3, 2);
px.put_pixel(0, 0, image::Rgba([255, 0, 0, 255]));
px.put_pixel(2, 1, image::Rgba([0, 0, 255, 255]));
let mut png = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(px.clone()).write_to(&mut png, image::ImageFormat::Png).unwrap();
let pic = s.create_from_images(&[("p.png".into(), png.into_inner())]).unwrap();
let id = s.open("p.pdf", None, pic, None).unwrap();
let d = s.get(id).unwrap();
let r = d.page_images(0)[0].rect;
assert_eq!(d.page_image_at(0, [(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0]), Some(0));
assert_eq!(d.page_image_at(0, [r[2] + 10.0, r[3] + 10.0]), None);
assert_eq!(d.page_image_at(7, [0.0, 0.0]), None);
let (w, h, rgba) = d.page_image_rgba(0, 0).unwrap();
assert_eq!((w, h), (3, 2));
assert_eq!(rgba, px.into_raw());
assert!(d.page_image_rgba(0, 1).is_err());
}
/// Copy Image: a JPEG drawn through grouped Form artwork (as Word, InDesign and scanners write
/// them), with a caption over it and an empty full-page Form on top.
#[test]
fn copy_image_finds_a_picture_inside_form_artwork_under_text() {
let jpeg = jpeg_bytes();
let content = b"q 100 0 0 100 50 50 cm /Fm1 Do Q BT /F1 12 Tf 60 100 Td (Caption over the picture) Tj ET /Fm2 Do".to_vec();
let form = b"/Im1 Do".to_vec();
let stream = |dict: &str, data: &[u8]| {
let mut o = format!("<< {dict} /Length {} >>\nstream\n", data.len()).into_bytes();
o.extend_from_slice(data);
o.extend_from_slice(b"\nendstream");
o
};
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(),
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> /XObject << /Fm1 6 0 R /Fm2 9 0 R >> >> >>".to_vec(),
stream("", &content),
stream("/Type /XObject /Subtype /Form /BBox [0 0 1 1] /Resources << /XObject << /Im1 7 0 R /Tiny 8 0 R >> >>", &form),
stream("/Type /XObject /Subtype /Image /Width 3 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode", &jpeg),
stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8", &[255, 0, 0]),
stream("/Type /XObject /Subtype /Form /BBox [0 0 200 300]", b""),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
let mut s = Session::new();
let id = s.open("form.pdf", None, Arc::new(out), None).unwrap();
let d = s.get(id).unwrap();
assert_eq!(d.page_images(0).len(), 2, "{:?}", d.page_images(0));
// Under the caption, inside the picture: the Form holding it, not the empty Form on top.
assert_eq!(d.page_image_at(0, [100.0, 100.0]), Some(0));
assert_eq!(d.page_image_at(0, [10.0, 10.0]), None, "the empty full-page Form holds no picture");
// The largest picture in the group, as the JPEG stored in the PDF.
assert_eq!(d.page_image_copy_file(0, 0).unwrap(), ("jpg", jpeg));
assert!(d.page_image_copy_file(0, 1).is_err());
}
/// `fixture(2)` with the catalog pointing at a page tree object the file does not have (#1656).
fn fixture_without_page_tree() -> Vec<u8> {
let bytes = fixture(2);
let catalog = b"<< /Type /Catalog /Pages 2 0 R >>";
let at = bytes.windows(catalog.len()).position(|w| w == catalog).unwrap();
let mut out = bytes.clone();
out[at..at + catalog.len()].copy_from_slice(b"<< /Type /Catalog /Pages 9 0 R >>");
out
}
#[test]
fn a_recovered_page_tree_opens_unmodified_and_edits_the_pages_shown() {
let mut s = Session::new();
let id = s.open("damaged.pdf", None, Arc::new(fixture_without_page_tree()), None).unwrap();
let doc = s.get(id).unwrap();
assert_eq!(doc.info.pages.len(), 2);
assert!(!doc.editor.as_ref().unwrap().cos.is_modified(), "the repair is a revision of the opened file, not an unsaved edit");
assert!(doc.repair_log().iter().any(|l| l.contains("page tree is missing")), "{:?}", doc.repair_log());
s.apply(id, Edit::RotatePages { pages: vec![1], degrees: 90 }).unwrap();
assert_eq!(s.get(id).unwrap().info.pages.iter().map(|p| p.rotation).collect::<Vec<_>>(), [0, 90]);
}
#[test]
fn a_signed_document_rebuilt_from_object_headers_refuses_edits() {
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
let digital_id = sign::pkcs12::open(&p12, "test").unwrap();
let mut s = Session::new().with_clock(|| 1_800_000_000);
let id = s.open("fixture.pdf", None, Arc::new(fixture(2)), None).unwrap();
let opts = SignOptions { page: 1, rect: Some([20.0, 20.0, 180.0, 60.0]), ..SignOptions::default() };
let signed = s.sign(id, &digital_id, opts).unwrap();
s.mark_signed(id, signed, None).unwrap();
// A broken startxref: the file opens only by rebuilding from object headers, and any save
// of it is a full rewrite.
let mut bytes = s.get(id).unwrap().bytes.as_ref().clone();
let sx = bytes.windows(10).rposition(|w| w == b"startxref\n").unwrap() + 10;
let end = sx + bytes[sx..].iter().position(|b| !b.is_ascii_digit()).unwrap();
bytes.splice(sx..end, b"1".iter().copied());
let id = s.open("damaged.pdf", None, Arc::new(bytes), None).unwrap();
assert!(s.get(id).unwrap().is_signed());
assert!(s.get(id).unwrap().editor.as_ref().unwrap().cos.revisions().is_empty(), "the fixture must be rebuilt");
assert!(matches!(s.apply(id, rect_comment(0, [80.0, 80.0, 120.0, 120.0])), Err(EditError::SignedRewrite(_))));
assert!(!s.get(id).unwrap().dirty);
}
/// #1324: marking a figure decorative rewrites that figure only. The header, added text and an
/// unknown entry on the original stream stay attached to the page. This lives in the engine tests
/// because edit and a11y are the same layer and cannot depend on each other.
#[test]
fn marking_a_figure_decorative_keeps_other_page_streams() {
let figure = "/Figure << /MCID 0 >> BDC 10 10 50 50 re f EMC";
let header = "BT (HeaderSample) Tj ET";
let added = "BT (AddedSample) Tj ET";
let objs = [
"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 7 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents [4 0 R 5 0 R 6 0 R] /StructParents 0 >>".into(),
format!("<< /VendorKey 7 /Length {} >>\nstream\n{figure}\nendstream", figure.len()),
format!("<< /PCMark /HeaderFooter /Length {} >>\nstream\n{header}\nendstream", header.len()),
format!(
"<< /PCMark /Added /PCAdded << /Kind /Text /Rect [70 70 150 100] /Text (AddedSample) /Size 12 >> /Length {} >>\nstream\n{added}\nendstream",
added.len()
),
"<< /Type /StructTreeRoot /K 8 0 R /ParentTree 10 0 R >>".into(),
"<< /Type /StructElem /S /Document /P 7 0 R /K [9 0 R] >>".into(),
"<< /Type /StructElem /S /Figure /P 8 0 R /Pg 3 0 R /K 0 >>".into(),
"<< /Nums [0 [9 0 R]] >>".into(),
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, body) in objs.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for offset in offsets {
bytes.extend(format!("{offset:010} 00000 n \n").as_bytes());
}
bytes.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
let mut doc = pdfcraft_cos::Document::open(Arc::new(bytes)).unwrap();
assert_eq!(pdfcraft_edit::scan_markers(&doc).marks, vec![MarkKind::HeaderFooter]);
let before = pdfcraft_edit::scan_markers(&doc);
assert_eq!(before.added.len(), 1);
a11y::mark_decorative(&mut doc, pdfcraft_cos::ObjRef::new(9, 0)).unwrap();
assert!(a11y::figures(&doc).is_empty());
let page = &pdfcraft_model::pages(&doc)[0];
let contents = page.dict.get(b"Contents").and_then(|c| c.as_array()).unwrap();
assert_eq!(contents.len(), 3, "the other streams stay in the page's content list");
let figure_ref = contents[0].as_ref().unwrap();
assert_eq!(contents[1].as_ref(), Some(pdfcraft_cos::ObjRef::new(5, 0)));
assert_eq!(contents[2].as_ref(), Some(pdfcraft_cos::ObjRef::new(6, 0)));
let stream = |doc: &pdfcraft_cos::Document, n: u32| match &*doc.get(pdfcraft_cos::ObjRef::new(n, 0)) {
pdfcraft_cos::Object::Stream(s) => s.clone(),
other => panic!("object {n} is {other:?}"),
};
let figure = stream(&doc, figure_ref.num);
assert_eq!(figure.dict.get(b"VendorKey").and_then(pdfcraft_cos::Object::as_int), Some(7));
let drawn = String::from_utf8(figure.decoded().unwrap()).unwrap();
assert!(drawn.contains("/Artifact BMC") && !drawn.contains("/MCID 0"), "{drawn}");
let header = stream(&doc, 5);
assert_eq!(header.dict.name(b"PCMark"), Some(&b"HeaderFooter"[..]));
assert!(String::from_utf8(header.decoded().unwrap()).unwrap().contains("HeaderSample"));
assert!(stream(&doc, 6).dict.get(b"PCAdded").is_some());
let after = pdfcraft_edit::scan_markers(&doc);
assert_eq!(after.marks, vec![MarkKind::HeaderFooter]);
assert_eq!(after.added.len(), 1);
assert_eq!(after.added[0].content, before.added[0].content);
assert_eq!(pdfcraft_edit::remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1);
let left = pdfcraft_model::pages(&doc)[0].dict.get(b"Contents").and_then(|c| c.as_array()).cloned().unwrap();
let text: String = left
.iter()
.filter_map(|o| match &*doc.resolve(o) {
pdfcraft_cos::Object::Stream(s) => String::from_utf8(s.decoded().unwrap()).ok(),
_ => None,
})
.collect();
assert!(!text.contains("HeaderSample"));
assert_eq!(pdfcraft_edit::scan_markers(&doc).added[0].content, before.added[0].content);
assert_eq!(stream(&doc, figure_ref.num).dict.get(b"VendorKey").and_then(pdfcraft_cos::Object::as_int), Some(7));
}
/// Print as image (Acrobat's Advanced Print Setup): the print-ready PDF carries each page as a
/// picture, the size of the page, with no text left to search; in grayscale the pictures are
/// grey. A page used on several sheets is rendered once.
#[test]
fn printing_as_image_sends_pictures_instead_of_pages() {
let (s, id) = session_with(3);
let page_size = (s.get(id).unwrap().info.pages[1].width as f64, s.get(id).unwrap().info.pages[1].height as f64);
let settings = print::Settings {
pages: vec![1, 2, 1],
layout: print::Layout::Size(print::SizeMode::Actual),
raster: Some(print::Raster { dpi: 100.0, grayscale: false }),
..Default::default()
};
let bytes = s.print_pdf(id, &settings).unwrap();
let mut s2 = Session::new();
let id2 = s2.open("print.pdf", None, Arc::new(bytes.clone()), None).unwrap();
assert_eq!(s2.get(id2).unwrap().info.pages.len(), 3, "three sheets, one page each");
assert!(page_texts(&s2, id2).iter().all(|t| t.trim().is_empty()), "pictures carry no text");
let doc = pdfcraft_cos::Document::open(Arc::new(bytes)).unwrap();
let images: Vec<pdfcraft_cos::Dict> = doc
.scan_objects()
.filter_map(|(_, o)| match &*o {
pdfcraft_cos::Object::Stream(st) if st.dict.name(b"Subtype") == Some(b"Image") => Some(st.dict.clone()),
_ => None,
})
.collect();
assert_eq!(images.len(), 2, "two distinct pages, each rendered once: {images:?}");
// 100 dpi: the picture's pixels match the page size.
let w = images.iter().map(|d| d.int(b"Width").unwrap_or(0)).max().unwrap();
assert!((w as f64 - page_size.0 * 100.0 / 72.0).abs() <= 1.0, "{w} px for {} pt", page_size.0);
assert!(images.iter().all(|d| d.name(b"ColorSpace") == Some(b"DeviceRGB")), "{images:?}");
// Grayscale renders the pictures grey (the printer is not merely asked for monochrome).
let grey = s.print_pdf(id, &print::Settings { raster: Some(print::Raster { dpi: 72.0, grayscale: true }), ..settings.clone() }).unwrap();
let mut s3 = Session::new();
let id3 = s3.open("grey.pdf", None, Arc::new(grey), None).unwrap();
let mut ex = crate::export::Exporter::new(s3.get(id3).unwrap());
let png = ex.png(0, 36.0).unwrap();
let img = image::load_from_memory(&png).unwrap().to_rgba8();
assert!(img.pixels().all(|p| p[0] == p[1] && p[1] == p[2]), "every pixel grey");
}
/// A one-page PDF: a JPEG logo (DCTDecode) at the top and a big text title below it.
fn jpeg_cover_pdf(logo_y: i32) -> Vec<u8> {
let mut jpeg = Vec::new();
let img = image::RgbImage::from_fn(40, 20, |x, y| image::Rgb([(x * 6) as u8, (y * 12) as u8, 90]));
image::codecs::jpeg::JpegEncoder::new(&mut jpeg).encode_image(&img).unwrap();
let content = format!(
"q 200 0 0 100 72 {logo_y} cm /Im1 Do Q BT /F1 30 Tf 72 580 Td (Annual Report) Tj ET \
BT /F1 11 Tf 72 500 Td (Body text that is long enough to count as the body size of this page.) Tj ET"
);
let mut pdf = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
let mut add = |pdf: &mut Vec<u8>, n: usize, body: &[u8]| {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{n} 0 obj\n").as_bytes());
pdf.extend_from_slice(body);
pdf.extend_from_slice(b"\nendobj\n");
};
add(&mut pdf, 1, b"<< /Type /Catalog /Pages 2 0 R >>");
add(&mut pdf, 2, b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
add(
&mut pdf,
3,
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im1 6 0 R >> >> >>",
);
add(&mut pdf, 4, format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).as_bytes());
add(&mut pdf, 5, b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>");
let mut obj = format!("<< /Type /XObject /Subtype /Image /Width 40 /Height 20 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length {} >>\nstream\n", jpeg.len()).into_bytes();
obj.extend_from_slice(&jpeg);
obj.extend_from_slice(b"\nendstream");
add(&mut pdf, 6, &obj);
let xref = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1).as_bytes());
for o in &offsets {
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", offsets.len() + 1).as_bytes());
pdf
}
/// #1936: a JPEG logo on a cover page reaches the Word file as the unchanged JPEG, both where it
/// sits clear of the title (inline) and where it overlaps it (a positioned page).
#[test]
fn exporting_a_jpeg_logo_to_word_keeps_the_image() {
for (logo_y, overlaps) in [(650, false), (560, true)] {
let pdf = jpeg_cover_pdf(logo_y);
let mut s = Session::new();
let id = s.open("c.pdf", None, Arc::new(pdf), None).unwrap();
let d = s.get(id).unwrap();
let pages = d.export_pages();
assert_eq!((pages[0].images.len(), pages[0].images[0].ext), (1, "jpg"), "overlap {overlaps}");
let jpeg = pages[0].images[0].bytes.clone();
assert!(jpeg.starts_with(&[0xFF, 0xD8]));
let docx = d.export_office(compare::OfficeFormat::Docx);
assert!(docx.windows(21).any(|w| w == b"word/media/image1.jpg"), "overlap {overlaps}");
assert!(docx.windows(jpeg.len()).any(|w| w == &jpeg[..]), "the JPEG is stored unchanged (overlap {overlaps})");
}
}
/// Cropping an existing page image (Acrobat's crop handles on a picture) shows only the chosen
/// box, the pixels staying where they are: `visible_rect` says what shows, the full placement is
/// unchanged, and a later move carries the crop along.
#[test]
fn cropping_an_existing_image_clips_it_and_moves_with_it() {
let mut s = Session::new();
let mut px = image::RgbaImage::new(40, 40);
for (x, y, p) in px.enumerate_pixels_mut() {
*p = if x < 20 {
image::Rgba([255, 0, 0, 255])
} else if y < 20 {
image::Rgba([0, 255, 0, 255])
} else {
image::Rgba([0, 0, 255, 255])
};
}
let mut png = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(px).write_to(&mut png, image::ImageFormat::Png).unwrap();
let pic = s.create_from_images(&[("p.png".into(), png.into_inner())]).unwrap();
let id = s.open("p.pdf", None, pic, None).unwrap();
let full = s.get(id).unwrap().page_images(0)[0].rect;
let (w, h) = (full[2] - full[0], full[3] - full[1]);
// Keep the right half, upper part.
let keep = [full[0] + w / 2.0, full[1] + h / 2.0, full[2], full[3]];
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop(keep) }).unwrap();
let d = s.get(id).unwrap();
assert_eq!(d.can_undo(), Some("Crop image"));
let im = &d.page_images(0)[0];
assert_eq!(im.rect, full, "the placement is not scaled");
let v = im.visible_rect.expect("clipped");
assert!(v.iter().zip(keep.iter()).all(|(a, b)| (a - b).abs() < 0.01), "{v:?} vs {keep:?}");
// What shows: the kept quarter is green, the cropped-away quarters are paper.
let (pw, ph, rgba) = {
let mut ex = crate::export::Exporter::new(d);
let png = ex.png(0, 72.0).unwrap();
let img = image::load_from_memory(&png).unwrap().to_rgba8();
(img.width(), img.height(), img)
};
let at = |fx: f64, fy: f64| rgba.get_pixel((pw as f64 * fx) as u32, (ph as f64 * fy) as u32).0;
let page_h = d.info.pages[0].height as f64;
let page_w = d.info.pages[0].width as f64;
// Fractions of the page from the top-left: the kept box is the top-right quarter of the image.
let fx = |x: f64| x / page_w;
let fy = |y: f64| 1.0 - y / page_h;
assert_eq!(at(fx(full[0] + 0.75 * w), fy(full[1] + 0.75 * h))[1], 255, "kept green");
assert_eq!(at(fx(full[0] + 0.25 * w), fy(full[1] + 0.25 * h)), [255, 255, 255, 255], "cropped-away red is gone");
// A move takes the crop along (down and left, so the kept part stays on the page).
let to = [full[0] - 10.0, full[1] - 10.0, full[2] - 10.0, full[3] - 10.0];
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move(to) }).unwrap();
let im = &s.get(id).unwrap().page_images(0)[0];
let v = im.visible_rect.expect("still clipped");
let moved_keep = [keep[0] - 10.0, keep[1] - 10.0, keep[2] - 10.0, keep[3] - 10.0];
assert!(v.iter().zip(moved_keep.iter()).all(|(a, b)| (a - b).abs() < 0.01), "{v:?} vs {moved_keep:?}");
// Cropping again replaces the earlier crop, so the picture can grow back (Remove crop).
let full_now = s.get(id).unwrap().page_images(0)[0].rect;
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop(full_now) }).unwrap();
let im = &s.get(id).unwrap().page_images(0)[0];
let v = im.visible_rect.expect("visible");
let page = [0.0, 0.0, page_w, page_h];
let shown = [full_now[0].max(page[0]), full_now[1].max(page[1]), full_now[2].min(page[2]), full_now[3].min(page[3])];
assert!(v.iter().zip(shown.iter()).all(|(a, b)| (a - b).abs() < 0.01), "{v:?} vs {shown:?}");
// No area, or a box off the image, is refused.
assert!(s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop([10.0, 10.0, 10.0, 50.0]) }).is_err());
}
/// A 60 x 20 JPEG at 144 x 72 dpi, tagged with EXIF Orientation 6 (shown turned 90 degrees clockwise).
fn phone_jpeg() -> Vec<u8> {
use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit};
let mut jpeg = Vec::new();
let mut encoder = JpegEncoder::new(&mut jpeg);
encoder.set_pixel_density(PixelDensity { density: (144, 72), unit: PixelDensityUnit::Inches });
encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap();
let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec();
exif.extend_from_slice(&[6, 0, 0, 0, 0, 0, 0, 0]);
let mut out = jpeg[..2].to_vec();
out.extend_from_slice(&[0xff, 0xe1]);
out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes());
out.extend_from_slice(&exif);
out.extend_from_slice(&jpeg[2..]);
out
}
#[test]
fn a_phone_photo_used_as_a_custom_stamp_is_embedded_upright() {
let mut session = Session::new();
let blank = session.create_blank(300.0, 400.0, 1).unwrap();
let id = session.open_new("form.pdf", blank).unwrap();
// A click places the stamp at its natural size: 30 x 20 pt as stored, 20 x 30 pt upright.
// Named "Signature" so `signature_image` finds its picture.
let file = MarkFile { name: "photo.jpg".into(), bytes: Arc::new(phone_jpeg()), page: 0 };
let stamp = Edit::AddCustomStamp { page: 0, rect: [100.0, 100.0, 100.0, 100.0], name: "Signature".into(), file, author: "Ada".into() };
session.apply(id, stamp).unwrap();
let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos;
let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap();
let obj = cos.get(picture);
let pdfcraft_cos::Object::Stream(s) = &*obj else { panic!() };
assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(20), Some(60)));
let rect = pdfcraft_annot::summaries(cos)[0].rect;
assert!(((rect[2] - rect[0]) - 20.0).abs() < 0.01 && ((rect[3] - rect[1]) - 30.0).abs() < 0.01, "{rect:?}");
}
/// Opening a paragraph and applying its edit read the page's text blocks; after an edit they are
/// still read once per document state, and undo and redo never get another state's blocks.
#[test]
fn text_blocks_stay_cached_after_an_edit_and_follow_undo_and_redo() {
let (mut session, id) = session_with(1);
let blocks = |s: &Session| s.get(id).unwrap().editor.as_ref().unwrap().text_blocks(0).unwrap();
let before = blocks(&session);
assert_eq!(before[0].editor_text, "Page 1");
let edit = Edit::EditTextBlock { page: 0, block: 0, text: "Edited".into(), style: pdfcraft_edit::BlockStyle::default() };
session.apply(id, edit).unwrap();
let edited = blocks(&session);
assert_eq!(edited[0].editor_text, "Edited");
assert!(Arc::ptr_eq(&edited, &blocks(&session)), "the edited page is grouped once");
session.undo(id).unwrap();
assert_eq!(blocks(&session)[0].editor_text, "Page 1");
session.redo(id).unwrap();
let redone = blocks(&session);
assert_eq!(redone[0].editor_text, "Edited");
assert!(Arc::ptr_eq(&redone, &blocks(&session)));
}
/// A new document, or one restored from recovery, has never been written where it belongs: undo
/// back to the bytes it was opened from must not make it clean, or closing it would discard the
/// whole document without asking. Saving makes it clean; Revert of a saved one too.
#[test]
fn undo_never_makes_an_unsaved_document_clean() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let new = s.open_new("Combined.pdf", Arc::new(fixture(2))).unwrap();
s.apply(new, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
s.undo(new).unwrap();
assert!(s.get(new).unwrap().dirty, "a new document is unsaved until it is saved");
assert_eq!(s.autosave_snapshots().len(), 1, "and still needs recovery");
s.revert(new).unwrap();
assert!(s.get(new).unwrap().dirty, "Revert goes back to the created bytes, which aren't saved anywhere");
let recovered = s.open("Report.pdf", None, Arc::new(fixture(2)), None).unwrap();
s.mark_recovered(recovered, Some("Report.pdf".into()));
s.apply(recovered, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
s.undo(recovered).unwrap();
assert!(s.get(recovered).unwrap().dirty, "the recovered work was never saved");
let saved = s.save_bytes(new).unwrap();
s.mark_saved(new, saved, Some("Combined.pdf".into())).unwrap();
assert!(!s.get(new).unwrap().dirty);
s.apply(new, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
s.undo(new).unwrap();
assert!(!s.get(new).unwrap().dirty, "once saved, undo back to the saved state is clean");
}
#[test]
fn undo_and_redo_roll_back_on_a_refresh_panic_or_error() {
let (mut s, id) = session_with(1);
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
let (bytes_90, gen_90) = {
let d = s.get(id).unwrap();
(d.bytes.clone(), d.generation)
};
// 1. A panic during undo's refresh catches the unwind and rolls back cos, undo/redo, info, and working bytes.
panic_on_refresh(1);
let err = s.undo(id).unwrap_err();
assert_eq!(err, EditError::Reopen("injected refresh panic".into()));
{
let d = s.get(id).unwrap();
let ed = d.editor.as_ref().unwrap();
assert_eq!(d.info.pages[0].rotation, 90);
assert_eq!(d.bytes, bytes_90);
assert_eq!(d.generation, gen_90);
assert!(d.dirty);
assert!(ed.cos.is_modified());
assert_eq!(ed.undo.len(), 1);
assert!(ed.redo.is_empty());
}
let _ = shown(&s, id, 0);
// 2. If the rollback refresh also panics, `show_released` restores the renderer so the page still draws.
panic_on_refresh(2);
assert_eq!(s.undo(id), Err(EditError::Reopen("injected refresh panic".into())));
assert_eq!(s.get(id).unwrap().editor.as_ref().unwrap().undo.len(), 1);
let _ = shown(&s, id, 0);
// Undo now succeeds cleanly.
assert_eq!(s.undo(id).unwrap().as_str(), "Rotate page");
let (bytes_0, gen_0) = {
let d = s.get(id).unwrap();
assert_eq!(d.info.pages[0].rotation, 0);
assert!(!d.dirty);
(d.bytes.clone(), d.generation)
};
// 3. A panic during redo's refresh catches the unwind and rolls back to the undone state.
panic_on_refresh(1);
let err = s.redo(id).unwrap_err();
assert_eq!(err, EditError::Reopen("injected refresh panic".into()));
{
let d = s.get(id).unwrap();
let ed = d.editor.as_ref().unwrap();
assert_eq!(d.info.pages[0].rotation, 0);
assert_eq!(d.bytes, bytes_0);
assert_eq!(d.generation, gen_0);
assert!(!d.dirty);
assert!(!ed.cos.is_modified());
assert!(ed.undo.is_empty());
assert_eq!(ed.redo.len(), 1);
}
let _ = shown(&s, id, 0);
// 4. If redo's rollback refresh also panics, `show_released` restores the renderer.
panic_on_refresh(2);
assert_eq!(s.redo(id), Err(EditError::Reopen("injected refresh panic".into())));
assert_eq!(s.get(id).unwrap().editor.as_ref().unwrap().redo.len(), 1);
let _ = shown(&s, id, 0);
// Redo now succeeds cleanly.
assert_eq!(s.redo(id).unwrap().as_str(), "Rotate page");
assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 90);
// 5. Metadata scope: a panic after `doc.info.title` was written rolls back `info.title` and keeps `edit_notices`.
s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Draft".into() }).unwrap();
let notice = TextNotice { field: None, page: 0, ch: '☺', redrawn_elsewhere: false };
s.doc_mut(id).unwrap().edit_notices.push(notice.clone());
panic_on_refresh(1);
assert_eq!(s.undo(id), Err(EditError::Reopen("injected refresh panic".into())));
assert_eq!(s.get(id).unwrap().info.title.as_deref(), Some("Draft"));
assert_eq!(s.get(id).unwrap().edit_notices, [notice]);
assert_eq!(s.undo(id).unwrap().as_str(), "Change Title");
assert_eq!(s.get(id).unwrap().info.title, None);
assert!(s.get(id).unwrap().edit_notices.is_empty());
panic_on_refresh(1);
assert_eq!(s.redo(id), Err(EditError::Reopen("injected refresh panic".into())));
assert_eq!(s.get(id).unwrap().info.title, None);
assert_eq!(s.redo(id).unwrap().as_str(), "Change Title");
assert_eq!(s.get(id).unwrap().info.title.as_deref(), Some("Draft"));
// 6. Protection keys: undo/redo rollback restores `editor.keys` and `doc.password` before re-inspecting.
let protect = Protection { open_password: Some("u".into()), permissions_password: Some("o".into()), ..Protection::default() };
s.apply(id, Edit::Protect(protect)).unwrap();
assert!(s.get(id).unwrap().info.encrypted);
panic_on_refresh(1);
assert_eq!(s.undo(id), Err(EditError::Reopen("injected refresh panic".into())));
assert!(s.get(id).unwrap().info.encrypted);
assert_eq!(s.get(id).unwrap().password.as_deref(), Some("u"));
assert_eq!(s.undo(id).unwrap().as_str(), "Protect with password");
assert!(!s.get(id).unwrap().info.encrypted);
assert_eq!(s.get(id).unwrap().password, None);
panic_on_refresh(1);
assert_eq!(s.redo(id), Err(EditError::Reopen("injected refresh panic".into())));
assert!(!s.get(id).unwrap().info.encrypted);
assert_eq!(s.get(id).unwrap().password, None);
assert_eq!(s.redo(id).unwrap().as_str(), "Protect with password");
assert!(s.get(id).unwrap().info.encrypted);
assert_eq!(s.get(id).unwrap().password.as_deref(), Some("u"));
}
#[test]
fn a_bookmark_added_at_a_point_goes_there_through_undo_and_save() {
use pdfcraft_render::DestView;
let (mut s, id) = session_with(3);
let view = |s: &Session, i: usize| s.get(id).unwrap().info.outline.get(i).map(|o| (o.page, o.view));
let add = |s: &mut Session, title: &str, page: usize, left: Option<f64>, top: Option<f64>| {
s.apply(id, Edit::AddBookmark { parent: vec![], index: usize::MAX, title: title.into(), page, left, top })
};
add(&mut s, "Plain", 0, None, None).unwrap();
add(&mut s, "Section", 1, None, Some(500.5)).unwrap();
add(&mut s, "Table", 2, Some(72.0), Some(300.0)).unwrap();
// The page top (no position) is what it always was; the others carry their place.
assert_eq!(view(&s, 0), Some((Some(0), DestView::Xyz { left: None, top: None, zoom: None })));
assert_eq!(view(&s, 1), Some((Some(1), DestView::Xyz { left: None, top: Some(500.5), zoom: None })));
assert_eq!(view(&s, 2), Some((Some(2), DestView::Xyz { left: Some(72.0), top: Some(300.0), zoom: None })));
// A position that is not a number fails the edit, which then changes nothing.
for (left, top) in [(Some(f64::NAN), None), (None, Some(f64::INFINITY))] {
assert!(matches!(add(&mut s, "Bad", 0, left, top), Err(EditError::Bookmark(_))), "{left:?} {top:?}");
}
assert_eq!(s.get(id).unwrap().info.outline.len(), 3);
assert_eq!(s.get(id).unwrap().can_undo(), Some("Add bookmark"));
s.undo(id).unwrap();
assert_eq!(s.get(id).unwrap().info.outline.len(), 2);
s.redo(id).unwrap();
let saved = s.save_bytes(id).unwrap();
let mut again = Session::new();
let id2 = again.open("again.pdf", None, saved, None).unwrap();
let outline = &again.get(id2).unwrap().info.outline;
assert_eq!(outline[2].view, DestView::Xyz { left: Some(72.0), top: Some(300.0), zoom: None });
assert_eq!(outline[1].view, DestView::Xyz { left: None, top: Some(500.5), zoom: None });
}
/// #3470: a filled form whose writer left the appearances to the viewer (`/NeedAppearances`):
/// field A has an empty appearance, B none. The page as displayed (and printed) shows both
/// values, and a comment without an appearance beside them still gets its own; the file itself
/// is not changed, so saving writes it as it was.
#[test]
fn fields_left_to_the_viewer_are_displayed_from_their_values() {
let pdf = |need: bool| {
let flag = if need { "/NeedAppearances true " } else { "" };
format!(
"%PDF-1.7\n\
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 5 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 10 0 R] >> endobj\n\
10 0 obj << /Type /Annot /Subtype /Square /Rect [360 20 390 50] /C [1 0 0] /BS << /W 4 >> >> endobj\n\
5 0 obj << /Fields [6 0 R 7 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 8 0 R >> >> >> endobj\n\
6 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (a) /V (MMMMMMMM) /DA (/Helv 12 Tf 0 g) /Rect [50 120 350 140] /F 4 /P 3 0 R /AP << /N 9 0 R >> >> endobj\n\
7 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (b) /V (MMMMMMMM) /DA (/Helv 12 Tf 0 g) /Rect [50 60 350 80] /F 4 /P 3 0 R >> endobj\n\
8 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\
9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 20] /Length 4 >> stream\nq\nQ\nendstream endobj\n\
trailer << /Root 1 0 R >>\n%%EOF"
)
.into_bytes()
};
let red = |s: &Session, id: DocId| {
let doc = s.get(id).unwrap();
let mut r = pdfcraft_render::PageRenderer::new(doc.display.clone(), doc.config.clone());
let page = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
page.rgba.as_chunks::<4>().0.iter().filter(|p| p[0] > 200 && p[1] < 80 && p[2] < 80).count()
};
// Dark pixels inside a field's box (user-space rectangle, page drawn at scale 1).
let ink = |s: &Session, id: DocId, y0: u32, y1: u32| {
let doc = s.get(id).unwrap();
let mut r = pdfcraft_render::PageRenderer::new(doc.display.clone(), doc.config.clone());
let page = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
let (px, _) = page.rgba.as_chunks::<4>();
(200 - y1..200 - y0)
.flat_map(|y| (55..345).map(move |x| (x, y)))
.filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128))
.count()
};
let mut s = Session::new();
let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap();
let (a, b) = (ink(&s, id, 122, 138), ink(&s, id, 62, 78));
assert!(a > 100 && b > 100, "both values are shown: a {a}, b {b}");
assert!(red(&s, id) > 100, "the comment is drawn too");
// The file is saved as it was: B still has no appearance, A its empty one.
let saved = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap();
let fields = pdfcraft_forms::fields(&saved);
let widget = |name: &str| saved.get(fields.iter().find(|f| f.name == name).unwrap().widgets[0].obj);
assert!(widget("b").as_dict().is_some_and(|d| !d.contains(b"AP")), "B has no appearance");
let n = widget("a").as_dict().and_then(|d| d.get(b"AP").and_then(|ap| saved.resolve(ap).as_dict().and_then(|ap| ap.reference(b"N")))).unwrap();
let pdfcraft_cos::Object::Stream(n) = &*saved.get(n) else { panic!("A's appearance is a stream") };
assert_eq!(n.decoded().unwrap().trim_ascii_end(), b"q\nQ", "A keeps its empty appearance");
// Typing in one field redraws that field only, not the whole form, on each keystroke.
let builds = |s: &Session| s.get(id).unwrap().editor.as_ref().unwrap().field_appearances.builds();
let before = builds(&s);
s.apply(id, Edit::SetFieldValue { name: "a".into(), value: pdfcraft_forms::FieldValue::Text("Typed".into()) }).unwrap();
assert_eq!(builds(&s), before + 1);
// Without the flag, the empty appearance is the file's own, and only B is drawn.
let id = s.open("form.pdf", None, Arc::new(pdf(false)), None).unwrap();
let (a, b) = (ink(&s, id, 122, 138), ink(&s, id, 62, 78));
assert!(a == 0 && b > 100, "a {a}, b {b}");
}
/// Reproducible workload with 3,129 fields and 190 ordered custom calculations.
/// No external PDF assets are needed; only in-memory contributor-original objects.
fn synthetic_acroform(field_count: usize, calculation_count: usize) -> Vec<u8> {
use pdfcraft_cos::{Dict, Object, PdfString};
let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(6))).unwrap();
let mut refs = Vec::new();
let mut order = Vec::new();
for i in 0..field_count {
let name = match i {
0 => "ranks".to_string(),
1 => "trained".to_string(),
2 => "bonus".to_string(),
_ => format!("field{i}"),
};
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("Annot"));
d.set(b"Subtype".to_vec(), Object::name("Widget"));
d.set(b"FT".to_vec(), Object::name(if i == 1 { "Btn" } else { "Tx" }));
d.set(b"T".to_vec(), PdfString::text(&name));
d.set(b"V".to_vec(), if i == 1 { Object::name("Off") } else { Object::String(PdfString::text("0")) });
d.set(b"Rect".to_vec(), Object::Array([20, 20, 100, 40].map(Object::Int).to_vec()));
d.set(b"P".to_vec(), Object::Ref(pdfcraft_cos::ObjRef::new(4, 0)));
if (2..calculation_count + 2).contains(&i) {
let js = if i == 2 {
"var r = Number(getField('ranks').value); event.value = r + (r > 0 && getField('trained').isBoxChecked(0) ? 3 : 0);".to_string()
} else {
format!("event.value = Number(getField('{}').value) + 1;", if i == 3 { "bonus".to_string() } else { format!("field{}", i - 1) })
};
let mut a = Dict::new();
a.set(b"S".to_vec(), Object::name("JavaScript"));
a.set(b"JS".to_vec(), PdfString::text(&js));
let mut aa = Dict::new();
aa.set(b"C".to_vec(), Object::Dict(a));
d.set(b"AA".to_vec(), Object::Dict(aa));
}
let r = doc.add(Object::Dict(d));
refs.push(Object::Ref(r));
if (2..calculation_count + 2).contains(&i) {
order.push(Object::Ref(r));
}
}
doc.update_dict(pdfcraft_cos::ObjRef::new(4, 0), |d| d.set(b"Annots".to_vec(), Object::Array(refs.clone()))).unwrap();
let mut af = Dict::new();
af.set(b"Fields".to_vec(), Object::Array(refs));
af.set(b"CO".to_vec(), Object::Array(order));
doc.update_dict(doc.root().unwrap(), |d| d.set(b"AcroForm".to_vec(), Object::Dict(af))).unwrap();
pdfcraft_cos::write_incremental(&doc, &Default::default()).unwrap()
}
#[test]
#[ignore = "manual performance measurement: 3,129 fields, 190 scripts"]
fn acroform_interaction_benchmark() {
let bytes = Arc::new(synthetic_acroform(3129, 190));
// Optional local export for comparing CLI build profiles. Never overwrite an input.
if let Some(path) = std::env::var_os("PDFCRAFT_BENCH_EXPORT") {
use std::io::Write;
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(path).unwrap();
file.write_all(&bytes).unwrap();
}
let mut s = Session::new();
let id = s.open("synthetic-large-form.pdf", None, bytes, None).unwrap();
let value = |s: &Session, name: &str| s.get(id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone();
for (name, v) in [
("ranks", FieldValue::Text("1".into())),
("trained", FieldValue::Check(true)),
("ranks", FieldValue::Text("2".into())),
("trained", FieldValue::Check(false)),
("ranks", FieldValue::Text("0".into())),
] {
let started = std::time::Instant::now();
s.apply(id, Edit::SetFieldValue { name: name.into(), value: v }).unwrap();
eprintln!("BENCH {name}: {:.3} ms", started.elapsed().as_secs_f64() * 1000.0);
assert!(s.take_js_output(id).errors.is_empty());
}
assert_eq!(value(&s, "bonus"), ["0"]);
assert_eq!(value(&s, "field191"), ["189"]);
assert_eq!(value(&s, "field3128"), ["0"]);
}
#[test]
fn checkbox_and_rank_changes_recalculate_ordered_dependents() {
let mut s = Session::new();
let id = s.open("skills.pdf", None, Arc::new(synthetic_acroform(8, 4)), None).unwrap();
for (name, input, bonus) in [
("trained", FieldValue::Check(true), "0"),
("ranks", FieldValue::Text("1".into()), "4"),
("trained", FieldValue::Check(false), "1"),
("ranks", FieldValue::Text("2".into()), "2"),
("trained", FieldValue::Check(true), "5"),
("ranks", FieldValue::Text("0".into()), "0"),
] {
s.apply(id, Edit::SetFieldValue { name: name.into(), value: input }).unwrap();
let fields = &s.get(id).unwrap().form;
let value = |name: &str| fields.iter().find(|f| f.name == name).unwrap().value.clone();
assert_eq!(value("bonus"), [bonus]);
assert_eq!(value("field5"), [(bonus.parse::<u32>().unwrap() + 3).to_string()]);
assert_eq!(value("field7"), ["0"]);
assert!(s.take_js_output(id).errors.is_empty());
}
s.undo(id).unwrap();
assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "bonus").unwrap().value, ["5"]);
let saved = s.save_bytes(id).unwrap();
let reopened = s.open("saved.pdf", None, saved, None).unwrap();
assert_eq!(s.get(reopened).unwrap().form.iter().find(|f| f.name == "bonus").unwrap().value, ["5"]);
let appearance = |s: &Session| {
let doc = s.get(id).unwrap();
let widget = doc.form.iter().find(|f| f.name == "bonus").unwrap().widgets.first().unwrap().obj;
doc.editor.as_ref().unwrap().cos.get(widget).as_dict().unwrap().get(b"AP").cloned()
};
let before = appearance(&s);
assert!(before.is_some());
s.apply(id, Edit::SetFieldValue { name: "field7".into(), value: FieldValue::Text("9".into()) }).unwrap();
assert_eq!(appearance(&s), before, "unchanged calculated values keep their appearance streams");
}
#[test]
fn dynamic_calculation_reads_and_side_effects_reach_later_fields() {
use pdfcraft_cos::{Dict, Object, PdfString};
let mut cos = pdfcraft_cos::Document::open(Arc::new(synthetic_acroform(8, 4))).unwrap();
let fields = pdfcraft_forms::fields(&cos);
let bonus = fields.iter().find(|f| f.name == "bonus").unwrap();
let selector = fields.iter().find(|f| f.name == "field7").unwrap();
cos.update_dict(selector.obj, |d| d.set(b"V".to_vec(), PdfString::text("ranks"))).unwrap();
let mut a = Dict::new();
a.set(b"S".to_vec(), Object::name("JavaScript"));
a.set(b"JS".to_vec(), PdfString::text(
"var key = getField('field7').value; var v = Number(getField(key).value); getField('field6').value = v * 2; getField('field6').readonly = true; event.value = v + 1;"
));
let mut aa = Dict::new();
aa.set(b"C".to_vec(), Object::Dict(a));
cos.update_dict(bonus.obj, |d| d.set(b"AA".to_vec(), Object::Dict(aa))).unwrap();
let bytes = pdfcraft_cos::write_incremental(&cos, &Default::default()).unwrap();
let mut s = Session::new();
let id = s.open("dynamic.pdf", None, Arc::new(bytes), None).unwrap();
s.apply(id, Edit::SetFieldValue { name: "ranks".into(), value: FieldValue::Text("4".into()) }).unwrap();
let fields = &s.get(id).unwrap().form;
let field = |name: &str| fields.iter().find(|f| f.name == name).unwrap();
assert_eq!(field("bonus").value, ["5"]);
assert_eq!(field("field5").value, ["8"], "later calculations see the ordered result");
assert_eq!(field("field6").value, ["8"], "side-effect value applied");
assert!(field("field6").read_only(), "side-effect property applied");
assert_eq!(field("field7").value, ["ranks"]);
assert!(s.take_js_output(id).errors.is_empty());
}
#[test]
fn scripted_display_changes_survive_warm_cache_and_new_edit() {
use pdfcraft_forms::{FieldEvent, Scripts};
let mut cos = pdfcraft_cos::Document::open(Arc::new(synthetic_acroform(2, 0))).unwrap();
for (display, flags) in [(1, 6), (3, 36), (2, 0), (0, 4)] {
let fields = pdfcraft_forms::fields(&cos);
let target = fields.iter().find(|f| f.name == "ranks").unwrap();
let other = fields.iter().find(|f| f.name == "trained").unwrap();
let old_display = crate::js::field_state(&cos, other).display;
let mut runner = crate::js::JsRunner::new(&cos, "synthetic.pdf");
// Populate the display cache before a script writes the same field.
let warm = runner.run(FieldEvent::Calculate, "event.value = getField('trained').display;", target, "", &fields);
assert_eq!(warm.value, old_display.to_string());
let result = runner.run(
FieldEvent::Calculate,
&format!("getField('trained').display = {display}; event.value = getField('trained').display;"),
target,
"",
&fields,
);
assert_eq!(result.value, display.to_string());
assert_eq!(result.changes.len(), 1);
assert_eq!(result.changes.first().unwrap().display, Some(display));
pdfcraft_forms::apply_script_changes(&mut cos, &result.changes, &mut pdfcraft_forms::NoScripts).unwrap();
assert_eq!(cos.get(other.widgets.first().unwrap().obj).as_dict().unwrap().int(b"F").unwrap() & 38, flags);
// Within an edit the original implementation reads an immutable pre-edit COS snapshot.
let same_edit = runner.run(FieldEvent::Calculate, "event.value = getField('trained').display;", target, "", &fields);
assert_eq!(same_edit.value, old_display.to_string());
// The next edit must rebuild its cache from the newly written annotation flags.
let mut next = crate::js::JsRunner::new(&cos, "synthetic.pdf");
let read = next.run(FieldEvent::Calculate, "event.value = getField('trained').display;", target, "", &pdfcraft_forms::fields(&cos));
assert_eq!(read.value, display.to_string());
assert!(read.changes.is_empty());
assert!(runner.output.errors.is_empty());
assert!(next.output.errors.is_empty());
}
}
/// #2992: a page coloured with Edit a PDF ▸ Background exports to Word with that page colour,
/// behind its text.
#[test]
fn exporting_to_word_keeps_the_page_background() {
let mut s = Session::new();
let text = s.create_from_text("t", "Blue text on teal").unwrap();
let id = s.open("t.pdf", None, text, None).unwrap();
assert_eq!(s.get(id).unwrap().export_pages()[0].background, None, "no background yet");
s.apply(
id,
Edit::AddBackground {
pages: vec![0],
settings: Background { color: [0.0, 0.5, 0.5], opacity: 1.0, ..Background::default() },
replace: false,
file: None,
},
)
.unwrap();
let d = s.get(id).unwrap();
let page = &d.export_pages()[0];
assert!(page.background.is_some_and(|c| c.iter().zip([0.0, 0.5, 0.5]).all(|(a, b)| (a - b).abs() < 1e-6)), "{:?}", page.background);
assert!(page.blocks.iter().any(|b| b.text.contains("Blue text")), "the text is still there");
let docx = d.export_office(compare::OfficeFormat::Docx);
assert!(docx.windows(b"word/settings.xml".len()).any(|w| w == b"word/settings.xml"), "Word is told to show the page colour");
}
#[test]
fn exporting_office_keeps_dotted_leader_entries_as_paragraphs() {
let mut content = String::new();
for (title, page, y) in [("Art.1. Scope", "3", 200), ("Art.2. Eligibility", "7", 180), ("Art.3. Application", "12", 160)] {
content.push_str(&format!(
"BT /F1 12 Tf 72 {y} Td ({title}) Tj ET \
BT /F1 12 Tf 250 {y} Td (....................) Tj ET \
BT /F1 12 Tf 500 {y} Td ({page}) Tj ET\n"
));
}
let pdf = one_line_page("/Font << /F1 4 0 R >>", &content);
let mut session = Session::new();
let id = session.open("contents.pdf", None, Arc::new(pdf), None).unwrap();
let doc = session.get(id).unwrap();
let html = String::from_utf8(doc.export_office(compare::OfficeFormat::Html)).unwrap();
assert!(!html.contains("<table>"), "contents entries are paragraphs: {html}");
let entries = ["Art.1. Scope .................... 3", "Art.2. Eligibility .................... 7", "Art.3. Application .................... 12"];
let positions: Vec<_> = entries.iter().map(|entry| html.find(&format!("<p>{entry}</p>")).expect("one paragraph per entry")).collect();
assert!(positions.windows(2).all(|pair| pair[0] < pair[1]), "reading order: {html}");
}
/// A page whose forms go past the reading limit and whose picture goes past the image budget is
/// listed once among the truncated pages.
#[test]
fn a_page_truncated_for_forms_and_pictures_is_listed_once() {
let depth = compare::MAX_FORM_DEPTH + 1;
let mut objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".into(),
String::new(),
"<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 4 >>\nstream\n\0\x20\x40\x7f\nendstream".into(),
"<< /Length 34 >>\nstream\nq 10 0 0 10 0 0 cm /Im Do Q /D1 Do\nendstream".into(),
];
// Forms D1..D{depth}, objects 6 onwards, each drawing the next.
let xobjects: String = (1..=depth).map(|i| format!("/D{i} {} 0 R ", 5 + i)).collect();
let resources = format!("<< /XObject << /Im 4 0 R {xobjects}>> >>");
for i in 1..=depth {
let body = if i == depth { "q Q".to_string() } else { format!("/D{} Do", i + 1) };
objs.push(format!(
"<< /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Resources {resources} /Length {} >>\nstream\n{body}\nendstream",
body.len()
));
}
objs[2] = format!("<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources {resources} >>");
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
let mut s = Session::new();
let id = s.open("both.pdf", None, Arc::new(bytes), None).unwrap();
let doc = s.get(id).unwrap();
assert_eq!(doc.export_pages_checked(false, 0).1, [0], "the forms alone truncate the page");
let (exported, _) = doc.export_pages_checked(true, 1 << 20);
assert_eq!(exported[0].images.len(), 1, "the picture exports within a sufficient budget");
let (pages, truncated) = doc.export_pages_checked(true, 0);
assert!(pages[0].images.is_empty(), "the picture is past the budget");
assert_eq!(truncated, [0]);
}