mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-28 03:58:45 -06:00
229 lines
12 KiB
Rust
229 lines
12 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, ..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());
|
||
}
|