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

229 lines
12 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 printcraft_cos::{Document, SaveOptions, write_incremental};
use super::*;
/// Three pages: an upright page with content that leaves the graphics state changed (an
/// unbalanced `cm`), a page rotated 90° and one with inherited, shared resources.
fn fixture() -> Document {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 600 800] /Resources 6 0 R >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 7 0 R >>".into(),
"<< /Type /Page /Parent 2 0 R /Rotate 90 /Contents [7 0 R] >>".into(),
"<< /Type /Page /Parent 2 0 R >>".into(),
"<< /Font << /F1 8 0 R >> >>".into(),
"<< /Length 37 >>\nstream\n2 0 0 2 0 0 cm BT /F1 9 Tf (Hi) Tj ET\nendstream".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>".into(),
];
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{o}\nendobj\n", i + 1).as_bytes());
}
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());
Document::open(Arc::new(out)).unwrap()
}
fn reopen(doc: &Document) -> Document {
let bytes = write_incremental(doc, &SaveOptions::default()).unwrap();
hayro_syntax::Pdf::new(bytes.clone()).expect("parses");
Document::open(Arc::new(bytes)).unwrap()
}
/// The decoded content streams of a page, in order.
fn streams(doc: &Document, page: usize) -> Vec<String> {
let p = &printcraft_model::pages(doc)[page];
let c = p.dict.get(b"Contents").cloned();
let list = match c.map(|c| doc.resolve(&c)).as_deref() {
Some(Object::Array(a)) => a.clone(),
Some(_) => vec![p.dict.get(b"Contents").cloned().unwrap()],
None => vec![],
};
list.iter().map(|o| String::from_utf8_lossy(&stream_bytes(doc, o).unwrap()).into_owned()).collect()
}
fn cx() -> Context {
Context { date: (2026, 10, 1) }
}
#[test]
fn tokens_expand() {
let c = cx();
assert_eq!(expand("Page <<1>> of <<n>>", 3, 10, 0, &c), "Page 3 of 10");
assert_eq!(expand("<<1 of n>> · <<Page 1>> · <<1/n>>", 2, 5, 0, &c), "2 of 5 · Page 2 · 2/5");
assert_eq!(expand("<<m/d/yyyy>> <<yyyy-mm-dd>> <<mmmm d, yyyy>>", 1, 1, 0, &c), "10/1/2026 2026-10-01 October 1, 2026");
assert_eq!(expand("<<Bates Number#6#100#ABC#-X>>", 1, 1, 104, &c), "ABC000104-X");
assert_eq!(expand("keep <<unknown>> and <<unclosed", 1, 1, 0, &c), "keep <<unknown>> and <<unclosed");
assert_eq!(bates_start("x <<Bates Number#6#100#A#B>>"), Some(100));
}
#[test]
fn header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content() {
let mut doc = fixture();
let hf = HeaderFooter {
text: ["Left".into(), String::new(), "<<Page 1 of n>>".into(), String::new(), "Confidential (draft)".into(), String::new()],
..HeaderFooter::default()
};
add_header_footer(&mut doc, &[0, 1, 2], &hf, false, &cx()).unwrap();
let doc = reopen(&doc);
let s0 = streams(&doc, 0);
// q-wrapper, original, Q-wrapper, header/footer.
assert_eq!(s0.len(), 4, "{s0:?}");
assert_eq!((s0[0].as_str(), s0[2].as_str()), ("q %PrintCraft\n", "Q %PrintCraft\n"));
let mark = &s0[3];
assert!(mark.contains("/PCMark /HeaderFooter") && mark.contains("(Page 1 of 3) Tj") && mark.contains("(Confidential \\(draft\\)) Tj"), "{mark}");
assert!(mark.contains("1 0 0 1 0 0 cm"), "upright page: identity");
// The rotated page maps display space onto its rotated crop box.
let s1 = streams(&doc, 1);
assert!(s1.last().unwrap().contains("0 1 -1 0 600 0 cm") && s1.last().unwrap().contains("(Page 2 of 3)"), "{s1:?}");
// A page without content gets just the mark; its inherited resources are copied, not changed.
assert_eq!(streams(&doc, 2).len(), 1);
let p2 = &printcraft_model::pages(&doc)[2];
let res = doc.resolve(p2.dict.get(b"Resources").unwrap());
let fonts = doc.resolve(res.as_dict().unwrap().get(b"Font").unwrap());
assert!(fonts.as_dict().unwrap().contains(b"PCHelv") && fonts.as_dict().unwrap().contains(b"F1"));
let shared = doc.get(printcraft_cos::ObjRef::new(6, 0));
let shared_fonts = doc.resolve(shared.as_dict().unwrap().get(b"Font").unwrap());
assert!(!shared_fonts.as_dict().unwrap().contains(b"PCHelv"), "the shared dictionary is untouched");
assert_eq!(marks_present(&doc), [MarkKind::HeaderFooter]);
}
#[test]
fn replace_and_remove_restore_the_original_content() {
let mut doc = fixture();
let original = streams(&doc, 0);
let mut hf = HeaderFooter::default();
hf.text[1] = "First".into();
add_header_footer(&mut doc, &[0], &hf, false, &cx()).unwrap();
hf.text[1] = "Second".into();
add_header_footer(&mut doc, &[0], &hf, true, &cx()).unwrap();
let s = streams(&doc, 0);
assert_eq!(s.iter().filter(|x| x.contains("/PCMark")).count(), 1, "replaced, not added");
assert!(s.last().unwrap().contains("(Second)"));
add_watermark(&mut doc, &[0], &Watermark { text: "DRAFT".into(), ..Watermark::default() }, false).unwrap();
add_background(&mut doc, &[0], &Background { color: [1.0, 1.0, 0.9], opacity: 1.0, ..Background::default() }, false).unwrap();
assert_eq!(marks_present(&doc).len(), 3);
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1);
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::Watermark).unwrap(), 1);
let s = streams(&doc, 0);
assert!(s[0].contains("/PCMark /Background"), "the background stays behind: {s:?}");
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(), 1);
assert_eq!(streams(&reopen(&doc), 0), original, "back to exactly the original content");
}
#[test]
fn watermarks_rotate_fade_and_can_go_behind() {
let mut doc = fixture();
let wm = Watermark { text: "CONFIDENTIAL\nDo not copy".into(), opacity: 0.3, rotation: 45.0, behind: true, ..Watermark::default() };
add_watermark(&mut doc, &[0], &wm, false).unwrap();
let s = streams(&doc, 0);
assert!(s[0].contains("/PCMark /Watermark"), "behind: first");
assert!(s[0].contains("/PCGS30 gs") && s[0].contains("0.707 0.707 -0.707 0.707 300 400 cm"), "{}", s[0]);
assert!(s[0].contains("(CONFIDENTIAL) Tj") && s[0].contains("(Do not copy) Tj"));
assert_eq!(s.len(), 2, "no wrapper needed for content behind");
}
#[test]
fn invalid_requests_change_nothing() {
let mut doc = fixture();
assert!(matches!(add_header_footer(&mut doc, &[0], &HeaderFooter::default(), false, &cx()), Err(EditError::Invalid(_))));
assert!(matches!(add_watermark(&mut doc, &[0], &Watermark::default(), false), Err(EditError::Invalid(_))));
assert_eq!(
add_background(&mut doc, &[9], &Background { color: [1.0; 3], opacity: 1.0, ..Background::default() }, false),
Err(EditError::NoSuchPage(9))
);
assert!(!doc.is_modified());
}
#[test]
fn flattening_draws_appearances_into_the_page_and_removes_the_comments() {
use printcraft_annot::{Meta, NewAnnotation, NoteIcon, Shape, Style, add_annotation, add_reply};
let mut doc = fixture();
let meta = Meta { date: None, id: "x".into() };
let add = |doc: &mut Document, shape: Shape| {
let style = Style::default_for(&shape);
add_annotation(doc, &NewAnnotation { page: 0, shape, style, contents: "c".into(), author: "a".into() }, &meta).unwrap()
};
add(&mut doc, Shape::Rectangle { rect: [10.0, 10.0, 110.0, 60.0] });
let note = add(&mut doc, Shape::Note { at: [200.0, 700.0], icon: NoteIcon::Comment });
add_reply(&mut doc, 0, note, "reply", "b", &meta).unwrap();
let before = streams(&doc, 0);
let n = flatten(&mut doc, &[0], true, false).unwrap();
assert_eq!(n, 2, "the rectangle and the note icon are drawn; the reply has nothing to draw");
let doc = reopen(&doc);
let p = &printcraft_model::pages(&doc)[0];
assert!(!p.dict.contains(b"Annots"), "comments, pop-up and reply are gone");
let s = streams(&doc, 0);
assert_eq!(s.len(), before.len() + 3, "wrapped original + flattened content: {s:?}");
let flat = s.last().unwrap();
assert!(flat.contains("/PCFl0 Do") && flat.contains("/PCFl1 Do"), "{flat}");
// The rectangle's appearance is drawn at its rectangle (bbox = rect, identity mapping).
assert!(flat.contains("q 1 0 0 1 0 0 cm /PCFl0 Do Q"), "{flat}");
// The note's 20×20 icon box is mapped onto its rect at (200, 680).
assert!(flat.contains("1 0 0 1 200 680 cm /PCFl1 Do"), "{flat}");
let res = doc.resolve(p.dict.get(b"Resources").unwrap());
let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap());
assert!(xo.as_dict().unwrap().contains(b"PCFl1"));
assert!(marks_present(&doc).is_empty(), "flattened content is not a removable mark");
}
#[test]
fn added_text_and_images_are_page_content_that_stays_editable() {
let mut doc = fixture();
let text = AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: "Approved by Ada\nSecond line".into(), size: 14.0, ..AddedText::default() };
assert_eq!(add_content(&mut doc, 0, &Content::Text(text.clone())).unwrap(), 0);
// An 1×1 gray image, placed on the rotated page.
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("XObject"));
d.set(b"Subtype".to_vec(), Object::name("Image"));
d.set(b"Width".to_vec(), Object::Int(1));
d.set(b"Height".to_vec(), Object::Int(1));
d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
let img = doc.add(Object::Stream(Stream::from_raw(d, vec![128])));
add_content(&mut doc, 1, &Content::Image(AddedImage::new([10.0, 10.0, 110.0, 60.0], img))).unwrap();
let doc2 = reopen(&doc);
let all = list_added(&doc2);
assert_eq!(all.len(), 2);
let Content::Text(t) = &all[0].content else { panic!() };
assert_eq!((t.text.as_str(), t.size), ("Approved by Ada\nSecond line", 14.0));
assert_eq!(t.rect, [72.0, 700.0 - 2.0 * 14.0 * 1.2, 300.0, 700.0], "the box height follows the two lines");
let page0 = streams(&doc2, 0).join("\n");
assert!(page0.contains("(Approved by Ada) Tj") && page0.contains("(Second line) Tj") && page0.contains("/PCFHelvetica 14 Tf"), "{page0}");
// The rotated page draws in display space: the view matrix comes first.
let page1 = streams(&doc2, 1).join("\n");
assert!(page1.contains("q 0 1 -1 0 600 0 cm") && page1.contains(&format!("/PCImg{} Do", img.num)), "{page1}");
// Edit: move, restyle, retype; then delete.
let mut doc = doc2;
let moved = AddedText {
rect: [100.0, 500.0, 300.0, 520.0],
text: "Approved".into(),
bold: true,
family: Family::Times,
align: Align::Right,
color: [1.0, 0.0, 0.0],
..text
};
update_content(&mut doc, 0, 0, &Content::Text(moved)).unwrap();
let page0 = streams(&doc, 0).join("\n");
assert!(page0.contains("/PCFTimesBold 14 Tf 1 0 0 rg") && !page0.contains("Second line"), "{page0}");
assert!(
update_content(&mut doc, 1, 0, &Content::Text(AddedText { text: "x".into(), rect: [0.0, 0.0, 50.0, 10.0], ..AddedText::default() })).is_err(),
"kinds don't change"
);
delete_content(&mut doc, 0, 0).unwrap();
assert_eq!(list_added(&doc).len(), 1);
assert!(!streams(&doc, 0).join("").contains("Approved"));
assert!(add_content(&mut doc, 0, &Content::Text(AddedText { rect: [0.0, 0.0, 100.0, 10.0], ..AddedText::default() })).is_err(), "empty text");
// Page marks ignore added items.
assert!(marks_present(&doc).is_empty());
}