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

142 lines
7.0 KiB
Rust

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 }, 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 }, false), Err(EditError::NoSuchPage(9)));
assert!(!doc.is_modified());
}