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

1077 lines
55 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::sync::Arc;
use super::*;
/// A minimal `n`-page document; page `i` shows "Page i+1".
pub(crate) fn fixture(n: usize) -> Vec<u8> {
let mut objs: Vec<Vec<u8>> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()];
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect();
objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" ")).into_bytes());
objs.push(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec());
for i in 0..n {
objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i).into_bytes());
let body = format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1);
objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes());
}
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
fn session_with(n: usize) -> (Session, DocId) {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("fixture.pdf", None, Arc::new(fixture(n)), None).expect("opens");
(s, id)
}
fn page_texts(s: &Session, id: DocId) -> Vec<String> {
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::<Vec<_>>(), [(1, 2), (3, 3)]);
// Insert pages from a file, undoably, into the open document.
s.apply(id, Edit::InsertPagesFrom { name: "b.pdf".into(), bytes: other, pages: Some(vec![1]), at: 1 }).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<Vec<u8>> {
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<String> {
items
.iter()
.map(|o| {
if o.children.is_empty() {
format!("{}→{}", o.title, o.page.map_or(0, |p| p + 1))
} else {
format!("{}→{}[{}]", o.title, o.page.map_or(0, |p| p + 1), outline_titles(&o.children).join(","))
}
})
.collect()
}
#[test]
fn bookmark_edits_show_in_the_viewer_undo_and_save() {
let (mut s, id) = session_with(3);
let titles = |s: &Session| outline_titles(&s.get(id).unwrap().info.outline);
s.apply(id, Edit::AddBookmark { parent: vec![], index: 0, title: "Start".into(), page: 0 }).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::<Vec<_>>();
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(&params).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<u8> {
let objs = [
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >> >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>",
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [20 250 180 270] /P 3 0 R /MK << /BC [0 0 0] >> >>",
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (ok) /V /Off /AS /Off /Rect [20 200 35 215] /P 3 0 R /AP << /N << /Yes 7 0 R /Off 7 0 R >> >> >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
"<< /Length 0 >>\nstream\n\nendstream",
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
#[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::<Vec<_>>();
assert_eq!(values(&d.info.fields), values(&full.fields));
assert_eq!(values(&d.info.fields), [("name".to_string(), Some("Ada".to_string())), ("ok".to_string(), Some("Yes".to_string()))]);
}
#[test]
fn preparing_a_form_adds_renames_and_deletes_fields() {
let (mut s, id) = session_with(1);
assert!(s.get(id).unwrap().form.is_empty());
let add = |kind: NewField, y: f64| Edit::AddField { page: 0, rect: [20.0, y, 180.0, y + 22.0], kind, name: None };
s.apply(id, add(NewField::Text { multiline: false }, 250.0)).unwrap();
s.apply(id, add(NewField::CheckBox, 200.0)).unwrap();
s.apply(id, add(NewField::Text { multiline: false }, 150.0)).unwrap();
let names = |s: &Session| s.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect::<Vec<_>>();
assert_eq!(names(&s), ["Text1", "Check Box1", "Text2"]);
assert_eq!(s.get(id).unwrap().can_undo(), Some("Add field"));
let props = FieldProps { name: Some("email".into()), tooltip: Some("Your e-mail".into()), required: Some(true), ..Default::default() };
s.apply(id, Edit::SetFieldProps { name: "Text2".into(), props }).unwrap();
s.apply(id, Edit::DeleteField { name: "Check Box1".into() }).unwrap();
assert_eq!(names(&s), ["Text1", "email"]);
s.apply(id, Edit::SetFieldValue { name: "email".into(), value: FieldValue::Text("ada@example.org".into()) }).unwrap();
let saved = s.save_bytes(id).unwrap();
let mut s2 = Session::new();
let id2 = s2.open("f.pdf", None, saved, None).unwrap();
let d = s2.get(id2).unwrap();
assert_eq!(d.form.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), ["Text1", "email"]);
assert_eq!(d.form[1].value, ["ada@example.org"]);
assert!(page_texts(&s2, id2).iter().any(|t| t.contains("ada@example.org")));
s.undo(id).unwrap();
s.undo(id).unwrap();
assert_eq!(names(&s), ["Text1", "Check Box1", "email"]);
}
#[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 <<n>> · <<yyyy-mm-dd>>".into();
s.apply(id, Edit::AddHeaderFooter { pages: vec![0, 1], settings: hf.clone(), replace: false }).unwrap();
// The session clock is 2023-11-14.
assert_eq!(page_texts(&s, id)[1], "Page 2\nPage 2 of 2 · 2023-11-14");
assert_eq!(s.get(id).unwrap().marks, [MarkKind::HeaderFooter]);
hf.text[4] = "Draft".into();
s.apply(id, Edit::AddHeaderFooter { pages: vec![0, 1], settings: hf, replace: true }).unwrap();
assert_eq!(page_texts(&s, id)[0], "Page 1\nDraft");
s.apply(id, Edit::AddWatermark { pages: vec![0], settings: Watermark { text: "SECRET".into(), ..Watermark::default() }, 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<usize> = (0..6).collect();
assert_eq!(filter_pages(info, &all, PageParity::Even, PageOrientation::Both), [1, 3, 5]);
assert_eq!(filter_pages(info, &all, PageParity::Odd, PageOrientation::Landscape), Vec::<usize>::new());
assert_eq!(filter_pages(info, &all, PageParity::Odd, PageOrientation::Portrait), [0, 2, 4]);
}
#[test]
fn links_from_urls_and_link_edits() {
// One page whose text contains a web address.
let mut objs: Vec<Vec<u8>> =
vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 300 200] >>".to_vec()];
objs.push(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec());
objs.push(b"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_vec());
let body = b"BT /F1 12 Tf 20 100 Td (Visit www.example.org today) Tj ET";
objs.push([format!("<< /Length {} >>\nstream\n", body.len()).into_bytes(), body.to_vec(), b"\nendstream".to_vec()].concat());
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
let mut s = Session::new();
let id = s.open("u.pdf", None, Arc::new(out), None).unwrap();
let found = s.find_urls(id);
assert_eq!(found.len(), 1);
assert_eq!(found[0].2, "http://www.example.org");
s.apply(id, Edit::AddLinks { links: found, style: LinkStyle::default() }).unwrap();
let d = s.get(id).unwrap();
assert_eq!(d.links.len(), 1);
assert_eq!(d.info.links.len(), 1, "the viewer follows it");
assert!(s.find_urls(id).is_empty(), "already linked");
// 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);
}