use std::sync::Arc; use super::*; /// A minimal `n`-page document; page `i` shows "Page i+1". pub(crate) fn fixture(n: usize) -> Vec { let mut objs: Vec> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()]; let kids: Vec = (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() {} assert_send::(); 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); } } /// 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 = 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 { 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> { 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 { 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); } #[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 }; 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::>()) }; // 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 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::>(), [(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 { let objs: Vec = 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] }).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::>()); 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 = 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 = 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 { 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 = 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 = 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 = 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>> = 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::((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 = 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> = 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> = 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::>::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 = 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::>() ); 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> { 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))); // 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))); } #[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 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"); } } #[test] fn autosave_snapshots_only_changed_documents() { let (mut s, id) = session_with(2); assert!(s.autosave_snapshots().is_empty(), "clean documents are not snapshotted"); s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); let snaps = s.autosave_snapshots(); 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 { 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 }).unwrap(); s.apply(id, Edit::AddBookmark { parent: vec![], index: 1, title: "End".into(), page: 2 }).unwrap(); s.apply(id, Edit::AddBookmark { parent: vec![1], index: 0, title: "Détail".into(), page: 1 }).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 { 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::>(); 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(¶ms).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; 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 { 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::>(); 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::>(), [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::>(); 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::>(), ["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"]); } /// 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 { 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 {} >>\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. Here the font and size /// come from an ExtGState /Font entry (`gs`), which the renderer honours and the interpreter does /// not model, so it places no glyph under the mark the user drew over the word as displayed: it /// used to report success and leave the word in the file. The renderer is asked now, and the edit /// is refused. #[test] fn redaction_is_refused_when_the_page_still_shows_text_under_a_mark() { 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"); let refused = s.apply(id, Edit::ApplyRedactions { pages: None }); assert!(matches!(refused, Err(EditError::Redact(pdfcraft_redact::RedactError::Residue(n))) if n > 0), "{refused:?}"); // Nothing was changed: the mark is still pending and the page still shows the line. assert_eq!(s.get(id).unwrap().redaction_marks(), 1); assert_eq!(page_texts(&s, id), [line]); // The same line with its font set by `Tf`: the mark is applied and the word is gone. let by_tf = one_line_page("/Font << /F1 4 0 R >>", &format!("BT /F1 16 Tf 20 150 Td ({line}) Tj ET")); let id = s.open("tf.pdf", None, Arc::new(by_tf), 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}"); } #[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"]); } #[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 { 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) -> 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] }).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 <> · <>".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 = 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 { let mut t: Vec = s.get(id).unwrap().info.annotations.iter().map(|a| a.contents.clone().unwrap_or_default()).collect(); t.sort(); t } #[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::>(), ["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::>(), ["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)); } #[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::>(), [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); } #[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 }).unwrap(); s.apply(id, Edit::AddBookmark { parent: vec![], index: 1, title: "Results".into(), page: 3 }).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 = (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::::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![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 { 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 { use pdfcraft_cos::{Dict, Object, PdfString}; let reserve = 8192 + id.certificate.raw.len() + id.chain.iter().map(|c| c.raw.len()).sum::(); 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 = 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) -> (u32, u32, Vec) { 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>, 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 = (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![ 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 { 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}"); } #[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 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(); // 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, 300.0).unwrap(); let scan = s.create_from_images(&[("scan.png".into(), png)]).unwrap(); let id = s.open("scan.pdf", None, scan, None).unwrap(); 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 { 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()); } #[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"); } /// 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 { 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> { 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 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()); } #[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 <> endobj 2 0 obj <> endobj 3 0 obj <>>>/Contents 5 0 R>> endobj 4 0 obj <> endobj 5 0 obj <> stream q 1 0 0 1 0 0 cm /Fm1 Do Q endstream endobj 7 0 obj <>>>/Length 60>> stream BT /F1 18 Tf 72 760 Td (Hello from a test invoice) Tj ET endstream endobj trailer <> %%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 = 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::>(), ["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("")); 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 = "
Kaur2001-02-03Yabc
"; 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::>()); 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() { let config = r#"forbidden"#; let template = pdfcraft_xfa::fixtures::template(1); let with_config = template.replacen("