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) } fn page_texts(s: &Session, id: DocId) -> Vec { let doc = s.get(id).unwrap(); let config = printcraft_render::RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }; let mut r = printcraft_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) -> printcraft_render::RenderRequest { printcraft_render::RenderRequest { page, kind: printcraft_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 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(), "Rotate page"); assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 0); assert_eq!(s.undo(id).unwrap(), "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(), "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(printcraft_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 = printcraft_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.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 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 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(), "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, &printcraft_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 }).unwrap(); assert_eq!(page_texts(&s, id), ["Page 1", "Page 2", "Page 2", "Page 3"]); assert_eq!(s.undo(id).unwrap(), "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 }); assert!(matches!(bad, Err(EditError::Source(_)))); } fn protected(user: &str, owner: &str, permissions: i32) -> Arc> { let mut doc = printcraft_cos::Document::open(Arc::new(fixture(2))).unwrap(); doc.set_encryption(&printcraft_cos::NewEncryption { algorithm: printcraft_cos::Algorithm::Aes256, user_password: user, owner_password: owner, permissions, encrypt_metadata: true, seed: [7; 32], }) .unwrap(); Arc::new(printcraft_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!(printcraft_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(); } #[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 [printcraft_cos::Algorithm::Rc4_128, printcraft_cos::Algorithm::Aes128] { let mut doc = printcraft_cos::Document::open(Arc::new(fixture(1))).unwrap(); doc.set_encryption(&printcraft_cos::NewEncryption { algorithm: alg, user_password: "u", owner_password: "o", permissions: 0, encrypt_metadata: true, seed: [1; 32], }) .unwrap(); let bytes = Arc::new(printcraft_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.undo(id).unwrap(); assert_eq!(s.autosave_snapshots().len(), 1, "undo is a change too"); 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 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: &[printcraft_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"]); } #[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 printcraft_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 = printcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()); let out = r.render(printcraft_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 = printcraft_cos::Document::open(Arc::new(fixture(1))).unwrap(); // Owner "own", empty user password, everything allowed except commenting (bit 6). let params = printcraft_cos::NewEncryption { algorithm: printcraft_cos::Algorithm::Aes256, user_password: "", owner_password: "own", permissions: !(1 << 5), encrypt_metadata: true, seed: [7; 32], }; cos.set_encryption(¶ms).unwrap(); let bytes = printcraft_cos::write_full(&cos, &printcraft_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!(printcraft_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!(printcraft_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 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: printcraft_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"]); } /// 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 } #[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 = printcraft_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 = printcraft_render::inspect(d.bytes.clone(), None).unwrap(); let values = |fs: &[printcraft_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 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 }).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"]); } #[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![printcraft_annot::rect_quad([80.0, 140.0, 100.0, 180.0])], overlay: String::new() }; 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 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"))); } #[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() }, replace: false }) .unwrap(); s.apply(id, Edit::AddBackground { pages: vec![1], settings: Background { color: [1.0, 0.9, 0.9], opacity: 1.0 }, replace: false }).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 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 }).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 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); } #[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 = printcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default()); let out = r.render(printcraft_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 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, &printcraft_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"); // 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 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"); } #[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]; assert_eq!((sig.status, sig.modification.clone()), (SignatureStatus::Valid, sign::Modification::Allowed(vec!["comments".into()]))); // 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); }