mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-25 02:29:31 -06:00
1077 lines
55 KiB
Rust
1077 lines
55 KiB
Rust
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(¶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<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);
|
||
}
|