pdfcraft/crates/edit/src/tests.rs

2577 lines
136 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 pdfcraft_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 = &pdfcraft_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 %PdfCraft\n", "Q %PdfCraft\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 = &pdfcraft_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(pdfcraft_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 header_start_number_that_would_wrap_is_refused_without_drawing() {
// u32::MAX is a valid start_number on its own, but there is no room for a second page
// number after it. The old code wrapped the second page to zero; now the whole run is
// refused and the document keeps no header at all.
let mut doc = fixture();
let hf = HeaderFooter {
text: [String::new(), String::new(), "<<1>>".into(), String::new(), String::new(), String::new()],
start_number: u32::MAX,
..HeaderFooter::default()
};
let err = add_header_footer(&mut doc, &[0, 1], &hf, false, &cx()).unwrap_err();
assert!(err.to_string().contains("leaves no room"), "{err}");
assert!(marks_present(&doc).is_empty(), "nothing is drawn when the run is refused");
// A single page still fits the start number itself.
add_header_footer(&mut doc, &[0], &hf, false, &cx()).unwrap();
assert_eq!(marks_present(&doc).len(), 1);
}
#[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 watermark_offset_moves_the_centre_right_and_up() {
let mut doc = fixture();
let wm = Watermark { text: "DRAFT".into(), rotation: 0.0, offset: [-100.0, 50.0], ..Watermark::default() };
add_watermark(&mut doc, &[0], &wm, false).unwrap();
let s = streams(&doc, 0);
let mark = s.iter().find(|c| c.contains("/PCMark /Watermark")).expect("the watermark stream");
assert!(mark.contains("1 0 0 1 200 450 cm"), "{mark}");
}
#[test]
fn a_watermark_offset_that_is_not_finite_is_refused() {
for offset in [[f64::NAN, 0.0], [0.0, f64::INFINITY], [f64::NEG_INFINITY, 0.0]] {
let mut doc = fixture();
let wm = Watermark { text: "DRAFT".into(), offset, ..Watermark::default() };
assert!(matches!(add_watermark(&mut doc, &[0], &wm, false), Err(EditError::Invalid(_))), "{offset:?}");
assert!(!doc.is_modified());
}
}
#[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 pdfcraft_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 = &pdfcraft_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");
}
/// #805: a supported note with no stored appearance is still drawn by viewers. Flattening must
/// generate that appearance and bake it into the page, not delete a comment that shows an icon.
#[test]
fn flattening_keeps_a_note_whose_appearance_is_missing() {
let page = stream("", " ");
let mut doc = build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R] >>",
&page,
"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (Synthetic note) /Name /Comment /Open false /F 0 >>",
]);
assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1, "the note icon is drawn");
let doc = reopen(&doc);
let p = &pdfcraft_model::pages(&doc)[0];
assert!(!p.dict.contains(b"Annots"), "the note is gone once its icon is baked in");
let flat = streams(&doc, 0).pop().unwrap();
assert!(flat.contains("q 1 0 0 1 10 10 cm /PCFl0 Do Q"), "{flat}");
}
/// Display space is in points, so on a `/UserUnit 2` page an item's box maps to half as many
/// user-space units. Items record the `/UserUnit` they were written for; one without it was
/// written when display space was in user-space units, and reads back scaled to points.
#[test]
fn added_items_follow_the_page_user_unit() {
let mut doc = fixture();
let page = pdfcraft_model::pages(&doc)[2].obj;
doc.update_dict(page, |d| d.set(b"UserUnit".to_vec(), Object::Int(2))).unwrap();
let text = AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: "Scaled".into(), size: 14.0, ..AddedText::default() };
add_content(&mut doc, 2, &Content::Text(text.clone())).unwrap();
add_content(&mut doc, 0, &Content::Text(text.clone())).unwrap();
let doc = reopen(&doc);
let all = list_added(&doc);
let on = |page: usize| all.iter().find(|a| a.page == page).unwrap();
let Content::Text(t) = &on(2).content else { panic!() };
assert_eq!((t.rect[0], t.rect[2], t.rect[3], t.size), (72.0, 300.0, 700.0, 14.0), "read back as written");
assert!(streams(&doc, 2).iter().any(|s| s.starts_with("q 0.5 0 0 0.5 0 0 cm")), "display points to user space");
let recorded = |doc: &Document, a: &Added| {
doc.get(a.obj).as_dict().and_then(|d| d.get(b"PCAdded").cloned()).and_then(|p| p.as_dict().and_then(|p| p.get(b"UserUnit").cloned()))
};
assert!(recorded(&doc, on(0)).is_none(), "a page without /UserUnit records nothing");
assert_eq!(recorded(&doc, on(2)).and_then(|u| u.as_f64()), Some(2.0));
// The same item as an earlier version wrote it: no record, box and size in user-space units.
let mut legacy = doc.clone();
let Object::Stream(mut s) = (*legacy.get(on(2).obj)).clone() else { panic!() };
let mut params = s.dict.get(b"PCAdded").and_then(|p| p.as_dict().cloned()).unwrap();
params.remove(b"UserUnit");
s.dict.set(b"PCAdded".to_vec(), Object::Dict(params));
legacy.set(on(2).obj, Object::Stream(s));
let Content::Text(old) = &list_added(&legacy).into_iter().find(|a| a.page == 2).unwrap().content else { panic!() };
assert_eq!((old.rect[0], old.rect[2], old.rect[3], old.size), (144.0, 600.0, 1400.0, 28.0), "scaled to points");
}
fn tagged_dictionary(tag: &str, added: Option<Object>) -> Dict {
let mut dict = Dict::new();
dict.set(b"PCMark".to_vec(), Object::name(tag));
if let Some(added) = added {
dict.set(b"PCAdded".to_vec(), added);
}
dict
}
fn tagged_stream(doc: &mut Document, tag: &str, added: Option<Object>) -> Object {
Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged_dictionary(tag, added), Vec::new()))))
}
fn added_text_params() -> Dict {
let mut params = Dict::new();
params.set(b"Rect".to_vec(), Object::Array([10, 20, 110, 40].into_iter().map(Object::Int).collect()));
params.set(b"Kind".to_vec(), Object::name("Text"));
params.set(b"Text".to_vec(), Object::String(pdfcraft_cos::PdfString::text("Added text")));
params.set(b"Size".to_vec(), Object::Int(12));
params
}
fn added_image_params(image: pdfcraft_cos::ObjRef) -> Dict {
let mut params = Dict::new();
params.set(b"Rect".to_vec(), Object::Array([10, 20, 110, 70].into_iter().map(Object::Int).collect()));
params.set(b"Kind".to_vec(), Object::name("Image"));
params.set(b"Image".to_vec(), Object::Ref(image));
params
}
fn set_page_contents(doc: &mut Document, contents: Object, user_unit: Option<Object>) {
let page = pdfcraft_model::pages(doc)[0].obj;
doc.update_dict(page, |dict| {
dict.set(b"Contents".to_vec(), contents);
if let Some(user_unit) = user_unit {
dict.set(b"UserUnit".to_vec(), user_unit);
}
})
.unwrap();
}
fn assert_marker_scan_matches_legacy(doc: &Document, label: &str, marks: &[MarkKind], added: usize) -> MarkerScan {
let expected_marks = marks_present(doc);
let expected_added = list_added(doc);
let scan = scan_markers(doc);
assert_eq!(scan.marks, expected_marks, "{label}: marks differ from the independent scan");
assert_eq!(scan.added, expected_added, "{label}: added content differs from the independent scan");
assert_eq!(scan.marks, marks, "{label}: fixture's mark order or deduplication is wrong");
assert_eq!(scan.added.len(), added, "{label}: fixture's added-item count is wrong");
scan
}
#[test]
fn combined_marker_scan_matches_the_independent_scans() {
let empty = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", "<< /Type /Page /Parent 2 0 R >>"]);
assert_marker_scan_matches_legacy(&empty, "empty page", &[], 0);
assert_marker_scan_matches_legacy(&fixture(), "ordinary streams and a page without contents", &[], 0);
for (tag, kind) in [("HeaderFooter", MarkKind::HeaderFooter), ("Watermark", MarkKind::Watermark), ("Background", MarkKind::Background)] {
let mut doc = fixture();
let marker = tagged_stream(&mut doc, tag, None);
set_page_contents(&mut doc, marker, None);
assert_marker_scan_matches_legacy(&doc, tag, &[kind], 0);
}
let mut all_marks = fixture();
let background = tagged_stream(&mut all_marks, "Background", None);
let watermark = tagged_stream(&mut all_marks, "Watermark", None);
let header = tagged_stream(&mut all_marks, "HeaderFooter", None);
set_page_contents(
&mut all_marks,
Object::Array(vec![background.clone(), watermark.clone(), header.clone(), watermark, header, background]),
None,
);
assert_marker_scan_matches_legacy(&all_marks, "all mark kinds repeated", &[MarkKind::Background, MarkKind::Watermark, MarkKind::HeaderFooter], 0);
let mut single_reference = fixture();
let marker = tagged_stream(&mut single_reference, "Watermark", None);
set_page_contents(&mut single_reference, marker, None);
assert_marker_scan_matches_legacy(&single_reference, "contents as one reference", &[MarkKind::Watermark], 0);
let mut text_only = fixture();
let text = tagged_stream(&mut text_only, "Added", Some(Object::Dict(added_text_params())));
set_page_contents(&mut text_only, text, None);
let text_scan = assert_marker_scan_matches_legacy(&text_only, "one added text item", &[], 1);
assert!(matches!(&text_scan.added[0].content, Content::Text(text) if text.text == "Added text"));
let mut indirect_params = fixture();
let params = indirect_params.add(Object::Dict(added_text_params()));
let text = tagged_stream(&mut indirect_params, "Added", Some(Object::Ref(params)));
set_page_contents(&mut indirect_params, text, None);
let indirect_scan = assert_marker_scan_matches_legacy(&indirect_params, "indirect PCAdded dictionary", &[], 1);
assert!(matches!(&indirect_scan.added[0].content, Content::Text(text) if text.text == "Added text"));
let mut image_only = fixture();
let image = image_only.add(Object::Stream(Stream::from_raw(Dict::new(), vec![0])));
let image_item = tagged_stream(&mut image_only, "Added", Some(Object::Dict(added_image_params(image))));
set_page_contents(&mut image_only, image_item, None);
let image_scan = assert_marker_scan_matches_legacy(&image_only, "one added image item", &[], 1);
assert!(matches!(&image_scan.added[0].content, Content::Image(item) if item.image == image));
let mut interleaved = fixture();
let text = tagged_stream(&mut interleaved, "Added", Some(Object::Dict(added_text_params())));
let watermark = tagged_stream(&mut interleaved, "Watermark", None);
let image = interleaved.add(Object::Stream(Stream::from_raw(Dict::new(), vec![0])));
let image_item = tagged_stream(&mut interleaved, "Added", Some(Object::Dict(added_image_params(image))));
let header = tagged_stream(&mut interleaved, "HeaderFooter", None);
set_page_contents(&mut interleaved, Object::Array(vec![text, watermark, image_item, header]), None);
let interleaved_scan =
assert_marker_scan_matches_legacy(&interleaved, "marks and added content interleaved", &[MarkKind::Watermark, MarkKind::HeaderFooter], 2);
assert!(matches!(&interleaved_scan.added[0].content, Content::Text(_)));
assert!(matches!(&interleaved_scan.added[1].content, Content::Image(_)));
let mut array_contents = fixture();
let background = tagged_stream(&mut array_contents, "Background", None);
let array_ref = array_contents.add(Object::Array(vec![background]));
set_page_contents(&mut array_contents, Object::Ref(array_ref), None);
assert_marker_scan_matches_legacy(&array_contents, "contents as an indirect array", &[MarkKind::Background], 0);
let mut malformed = fixture();
let indirect_mark = malformed.add(Object::Dict(tagged_dictionary("HeaderFooter", None)));
let missing_added = tagged_stream(&mut malformed, "Added", None);
let invalid_added = tagged_stream(&mut malformed, "Added", Some(Object::String(pdfcraft_cos::PdfString::text("bad params"))));
let unknown = tagged_stream(&mut malformed, "FutureKind", None);
let direct_watermark = Object::Dict(tagged_dictionary("Watermark", None));
let direct_added = Object::Dict(tagged_dictionary("Added", Some(Object::Dict(added_text_params()))));
set_page_contents(
&mut malformed,
Object::Array(vec![
Object::Null,
Object::Int(7),
Object::name("not-a-stream"),
direct_watermark,
Object::Ref(indirect_mark),
missing_added,
invalid_added,
unknown,
direct_added,
]),
None,
);
assert_marker_scan_matches_legacy(
&malformed,
"malformed, non-stream, unknown and direct entries",
&[MarkKind::Watermark, MarkKind::HeaderFooter],
0,
);
let mut shared = fixture();
let added = tagged_stream(&mut shared, "Added", Some(Object::Dict(added_text_params())));
let mark = tagged_stream(&mut shared, "Background", None);
set_page_contents(&mut shared, Object::Array(vec![added.clone(), mark.clone(), added.clone(), mark]), None);
let shared_scan = assert_marker_scan_matches_legacy(&shared, "repeated shared stream references", &[MarkKind::Background], 2);
assert_eq!(shared_scan.added[0].obj, shared_scan.added[1].obj);
let mut user_unit = fixture();
let added = tagged_stream(&mut user_unit, "Added", Some(Object::Dict(added_text_params())));
set_page_contents(&mut user_unit, added, Some(Object::Int(2)));
let unit_scan = assert_marker_scan_matches_legacy(&user_unit, "non-1.0 UserUnit", &[], 1);
let Content::Text(text) = &unit_scan.added[0].content else { panic!() };
assert_eq!(text.rect, [20.0, 40.0, 220.0, 80.0]);
assert_eq!(text.size, 24.0);
}
#[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());
}
#[test]
fn added_arabic_text_is_shaped_in_display_order_and_stays_searchable() {
let mut doc = fixture();
let text =
AddedText { rect: [72.0, 600.0, 400.0, 700.0], text: "مرحبا World".into(), size: 14.0, align: Align::Right, ..AddedText::default() };
if pdfcraft_fonts::document_arabic_font().is_none() {
eprintln!("skipping the Arabic drawing checks: built without a craft-fonts Arab face (set CRAFT_FONTS_DIR)");
let err = add_content(&mut doc, 0, &Content::Text(text)).unwrap_err();
assert!(matches!(&err, EditError::Invalid(m) if m.contains("CRAFT_FONTS_DIR")), "{err:?}");
assert!(!doc.is_modified(), "nothing is written when the font is missing");
return;
}
add_content(&mut doc, 0, &Content::Text(text.clone())).unwrap();
// A second Arabic item on the same page keeps its own fonts.
add_content(&mut doc, 0, &Content::Text(AddedText { text: "شكرا".into(), rect: [72.0, 500.0, 400.0, 520.0], ..text.clone() })).unwrap();
let doc = reopen(&doc);
let added = list_added(&doc);
let Content::Text(t) = &added[0].content else { panic!() };
assert_eq!(t.text, "مرحبا World", "the logical text is kept for editing");
let s = streams(&doc, 0);
let item = s.iter().find(|s| s.contains("(World) Tj")).unwrap();
// A right-to-left paragraph: "World" is drawn left of the Arabic word.
let x_before = |s: &str, at: usize| -> f64 {
let tm = s[..at].rfind("1 0 0 1 ").unwrap() + 8;
s[tm..].split(' ').next().unwrap().parse().unwrap()
};
let latin_x = x_before(item, item.find("(World) Tj").unwrap());
let arabic_at = item.find("/PCAr").unwrap();
let arabic_x = x_before(item, item[arabic_at..].find(" Tj").unwrap() + arabic_at);
assert!(latin_x < arabic_x, "{item}");
// The Arabic glyphs come from Type 3 fonts whose ToUnicode maps give back each letter.
let p = &pdfcraft_model::pages(&doc)[0];
let res = doc.resolve(p.dict.get(b"Resources").unwrap());
let fonts = doc.resolve(res.as_dict().unwrap().get(b"Font").unwrap()).as_dict().unwrap().clone();
let arabic: Vec<_> = fonts.iter().filter(|(k, _)| k.starts_with(b"PCAr")).map(|(_, v)| doc.resolve(v).as_dict().unwrap().clone()).collect();
assert_eq!(arabic.len(), 2, "one font per item");
let mut mapped = String::new();
for f in &arabic {
assert_eq!(f.name(b"Subtype"), Some(&b"Type3"[..]));
mapped += &String::from_utf8_lossy(&stream_bytes(&doc, f.get(b"ToUnicode").unwrap()).unwrap());
}
for letter in ["0645", "0631", "062D", "0628", "0627", "0634", "0643"] {
assert!(mapped.contains(&format!("<{letter}>")), "{letter} in {mapped}");
}
}
/// The `PCAr` font names in page `page`'s resources.
fn arabic_font_names(doc: &Document, page: usize) -> Vec<String> {
let p = &pdfcraft_model::pages(doc)[page];
let Some(res) = p.dict.get(b"Resources").map(|r| doc.resolve(r)) else { return vec![] };
let Some(fonts) = res.as_dict().and_then(|r| r.get(b"Font")).map(|f| doc.resolve(f)) else { return vec![] };
fonts.as_dict().unwrap().iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).filter(|k| k.starts_with("PCAr")).collect()
}
#[test]
fn arabic_items_keep_their_own_fonts_through_saves_updates_and_deletes() {
let mut doc = fixture();
let text = AddedText { rect: [72.0, 600.0, 400.0, 700.0], text: "مرحبا".into(), size: 14.0, ..AddedText::default() };
if pdfcraft_fonts::document_arabic_font().is_none() {
// An item that already holds Arabic (stored by an older version, drawn as "?") can still
// be moved and retyped without the face.
add_content(&mut doc, 0, &Content::Text(AddedText { text: "x".into(), ..text.clone() })).unwrap();
update_content(&mut doc, 0, 0, &Content::Text(text)).unwrap();
assert!(streams(&doc, 0).join("").contains("(?????) Tj"));
return;
}
// A full save renumbers objects but keeps resource names, so a font named after its object
// number may meet a name already on the page: here, every name it could take.
let mut taken = Dict::new();
for num in 1..2_000 {
taken.set(format!("PCAr{num}").into_bytes(), Object::name("Taken"));
}
let mut res = Dict::new();
res.set(b"Font".to_vec(), Object::Dict(taken));
let page = pdfcraft_model::pages(&doc)[0].obj;
doc.update_dict(page, |d| d.set(b"Resources".to_vec(), Object::Dict(res))).unwrap();
add_content(&mut doc, 0, &Content::Text(text.clone())).unwrap();
add_content(&mut doc, 0, &Content::Text(AddedText { text: "شكرا".into(), rect: [72.0, 500.0, 400.0, 520.0], ..text.clone() })).unwrap();
let names = arabic_font_names(&doc, 0);
assert_eq!(names.len(), 1_999 + 2, "nothing replaced");
let p = &pdfcraft_model::pages(&doc)[0];
let fonts = doc.resolve(p.dict.get(b"Resources").unwrap()).as_dict().unwrap().get(b"Font").cloned().unwrap();
let fonts = doc.resolve(&fonts).as_dict().unwrap().clone();
assert_eq!(fonts.iter().filter(|(_, v)| v.as_name() == Some(&b"Taken"[..])).count(), 1_999);
let first = own(&doc, 0);
assert!(first.len() == 1 && first[0].contains('_'), "a free name, not a taken one: {first:?}");
// Updating an item swaps its fonts; deleting it removes them.
update_content(&mut doc, 0, 0, &Content::Text(AddedText { text: "أهلا".into(), ..text })).unwrap();
let names = arabic_font_names(&doc, 0);
assert!(names.len() == 2_001 && !names.contains(&first[0]), "the replaced item's font is gone");
delete_content(&mut doc, 0, 0).unwrap();
delete_content(&mut doc, 0, 0).unwrap();
assert_eq!(arabic_font_names(&doc, 0).len(), 1_999, "only the page's own names are left");
reopen(&doc);
}
/// The `PCAr` fonts the stream of added item `index` on page 0 shows text with.
fn own(doc: &Document, index: usize) -> Vec<String> {
let s = String::from_utf8_lossy(&stream_bytes(doc, &Object::Ref(list_added(doc)[index].obj)).unwrap()).into_owned();
let mut names: Vec<String> =
s.split_whitespace().filter_map(|w| w.strip_prefix('/')).filter(|w| w.starts_with("PCAr")).map(String::from).collect();
names.dedup();
names
}
#[test]
fn odd_arabic_text_never_panics() {
let mut doc = fixture();
let long = "بسم الله ".repeat(2_000);
for s in ["\u{202E}ب\u{064B}\u{064B} (]", "\u{064B}", "ا\u{200F}\u{2067}b\u{2069}", "ا\n\nب\tc", "ﷺ ١٢٣ 456", long.as_str()] {
let r = add_content(&mut doc, 0, &Content::Text(AddedText { rect: [0.0, 800.0, 300.0, 780.0], text: s.into(), ..AddedText::default() }));
assert_eq!(r.is_ok(), pdfcraft_fonts::document_arabic_font().is_some(), "{:?}: {r:?}", s.chars().take(12).collect::<String>());
}
// U+2029 and U+0085 (paragraph and line separators) have no WinAnsi code, so the standard font
// would draw them as `?`: refused, by name, rather than written (#125). Laying it out still
// mustn't panic.
let r = add_content(
&mut doc,
0,
&Content::Text(AddedText { rect: [0.0, 800.0, 300.0, 780.0], text: "ا\u{2029}ب\rc\u{85}د\n".into(), ..AddedText::default() }),
);
assert!(r.is_err(), "{r:?}");
if pdfcraft_fonts::document_arabic_font().is_some() {
assert!(matches!(&r, Err(EditError::Invalid(m)) if m.contains("U+2029") || m.contains("U+0085")), "{r:?}");
}
// A character the face lacks is an error that names it, not a box or a crash.
if pdfcraft_fonts::document_arabic_font().is_some() && !pdfcraft_fonts::arabic_has('\u{FDFD}') {
let r = add_content(
&mut doc,
0,
&Content::Text(AddedText { rect: [0.0, 800.0, 300.0, 780.0], text: "ب \u{FDFD}".into(), ..AddedText::default() }),
);
assert!(matches!(&r, Err(EditError::Invalid(m)) if m.contains('\u{FDFD}')), "{r:?}");
}
}
/// One page with Helvetica (WinAnsi) and a subset font that has only the glyphs it uses.
fn text_page(content: &str) -> Document {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>".into(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
// Subset: glyphs for a (97) and b (98) only.
"<< /Type /Font /Subtype /TrueType /BaseFont /ABCDEF+Arial /FirstChar 97 /LastChar 99 /Widths [500 520 0] /Encoding /WinAnsiEncoding >>"
.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 descriptor_text_page() -> Document {
let content = "BT /F1 12 Tf 72 700 Td (ab) Tj ET";
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
"<< /Type /Font /Subtype /TrueType /BaseFont /ABCDEF+NeutralFace /FirstChar 97 /LastChar 98 /Widths [500 500] /FontDescriptor 6 0 R /Encoding /WinAnsiEncoding >>".into(),
"<< /Type /FontDescriptor /FontName /NeutralFace /Flags 262208 /ItalicAngle -12 /FontWeight 700 /Ascent 900 /Descent -250 >>".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()
}
/// The Japanese fallback face comes from craft-fonts, an optional build input.
fn without_craft_fonts(test: &str) -> bool {
if pdfcraft_fonts::document_japanese_font().is_some() {
return false;
}
eprintln!("skipping {test}: built without craft-fonts (set CRAFT_FONTS_DIR to run it)");
true
}
#[test]
fn japanese_line_uses_unicode_type3_fallback() {
if without_craft_fonts("japanese_line_uses_unicode_type3_fallback") {
return;
}
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
let replacement = "25362738こんにちは、お元気ですか? 2.3444";
let result = text::replace_line(&mut doc, 0, 0, replacement).unwrap();
assert_eq!(result.substituted.as_deref(), Some("BIZ UDPGothic Type3"));
let bytes = page_content_bytes(&doc, 0);
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("/PCJp"), "{content}");
let reopened = reopen(&doc);
assert_eq!(text::text_lines(&reopened, 0).unwrap()[0].text, replacement);
}
fn styled_text_page(base: &str) -> Document {
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Original) Tj ET");
let r = pdfcraft_cos::ObjRef::new(5, 0);
let mut font = doc.get(r).as_dict().unwrap().clone();
font.set(b"BaseFont".to_vec(), pdfcraft_cos::Object::name(base));
doc.set(r, pdfcraft_cos::Object::Dict(font));
doc
}
fn fallback_paths(doc: &Document) -> Vec<Vec<u8>> {
let p = pdfcraft_model::pages(doc).swap_remove(0);
let res = doc.resolve(p.dict.get(b"Resources").unwrap());
let fonts = doc.resolve(res.as_dict().unwrap().get(b"Font").unwrap());
let font = doc.resolve(fonts.as_dict().unwrap().get(b"PCJp").unwrap());
let procs = doc.resolve(font.as_dict().unwrap().get(b"CharProcs").unwrap());
procs
.as_dict()
.unwrap()
.iter()
.map(|(_, object)| match &*doc.resolve(object) {
pdfcraft_cos::Object::Stream(s) => s.decoded().unwrap(),
other => panic!("expected a glyph stream, got {other:?}"),
})
.collect()
}
#[test]
fn japanese_line_fallback_uses_real_source_family_and_weight() {
if without_craft_fonts("japanese_line_fallback_uses_real_source_family_and_weight") {
return;
}
let mut sans = styled_text_page("Helvetica");
let mut bold = descriptor_text_page();
let mut serif = styled_text_page("ABCDEF+ShipporiMincho-Regular");
let replacement = "見本商会 御中";
text::replace_line(&mut sans, 0, 0, replacement).unwrap();
text::replace_line(&mut bold, 0, 0, replacement).unwrap();
text::replace_line(&mut serif, 0, 0, replacement).unwrap();
assert_ne!(fallback_paths(&sans), fallback_paths(&serif), "sans source must not use Mincho outlines");
if pdfcraft_fonts::CRAFT_FONTS.iter().any(|f| f.family == "BIZ UDPGothic" && f.style == "Bold") {
assert_ne!(fallback_paths(&sans), fallback_paths(&bold), "bold must change the actual glyph paths");
}
for doc in [&sans, &bold, &serif] {
assert_eq!(text::text_lines(&reopen(doc), 0).unwrap()[0].text, replacement);
}
}
#[test]
fn japanese_paragraph_fallback_honors_requested_family_and_weight() {
if without_craft_fonts("japanese_paragraph_fallback_honors_requested_family_and_weight") {
return;
}
let replacement = "見本商会 御中";
let mut regular = styled_text_page("Helvetica");
let mut bold = styled_text_page("Helvetica");
let mut serif = styled_text_page("Helvetica");
text::replace_block(&mut regular, 0, 0, replacement).unwrap();
text::rewrite_block(
&mut bold,
0,
0,
Some(replacement),
&text::BlockStyle { family: Some((added::Family::Helvetica, true, false)), ..Default::default() },
)
.unwrap();
text::rewrite_block(
&mut serif,
0,
0,
Some(replacement),
&text::BlockStyle { family: Some((added::Family::Times, false, false)), ..Default::default() },
)
.unwrap();
assert_ne!(fallback_paths(&regular), fallback_paths(&serif), "requested serif must change actual outlines");
if pdfcraft_fonts::CRAFT_FONTS.iter().any(|f| f.family == "BIZ UDPGothic" && f.style == "Bold") {
assert_ne!(fallback_paths(&regular), fallback_paths(&bold), "requested bold must change actual outlines");
let mut source_bold = styled_text_page("Helvetica-Bold");
text::replace_block(&mut source_bold, 0, 0, replacement).unwrap();
assert_eq!(fallback_paths(&bold), fallback_paths(&source_bold));
}
for doc in [&regular, &bold, &serif] {
assert_eq!(text::text_blocks(&reopen(doc), 0).unwrap()[0].text, replacement);
}
}
#[test]
fn japanese_fallback_reports_the_face_after_save_and_reopen() {
if without_craft_fonts("japanese_fallback_reports_the_face_after_save_and_reopen") {
return;
}
// F2 is (a subset of) Arial: a regular sans face, so the fallback is the regular Gothic.
let face = pdfcraft_fonts::document_japanese_font_for_style(false, false).unwrap();
let expected = format!("{}-{}", face.family, face.style).replace(' ', "");
for paragraph in [false, true] {
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
let replacement = "見本商会 御中";
if paragraph {
text::replace_block(&mut doc, 0, 0, replacement).unwrap();
} else {
text::replace_line(&mut doc, 0, 0, replacement).unwrap();
}
for d in [&doc, &reopen(&doc)] {
let lines = text::text_lines(d, 0).unwrap();
assert_eq!(lines[0].text, replacement);
assert_eq!(lines[0].base_font, expected, "report the real fallback face, not an empty BaseFont");
assert_eq!(text::text_blocks(d, 0).unwrap()[0].base_font, expected);
}
}
}
#[test]
fn cyrillic_in_a_serif_line_falls_back_to_a_face_that_has_it() {
if without_craft_fonts("cyrillic_in_a_serif_line_falls_back_to_a_face_that_has_it") {
return;
}
let replacement = "Привет, world";
let has_all = |f: &pdfcraft_fonts::CraftFont| replacement.chars().all(|ch| pdfcraft_fonts::japanese_glyph_from(f, ch).is_ok());
if !pdfcraft_fonts::CRAFT_FONTS.iter().any(|f| f.covers("Jpan") && has_all(f)) {
eprintln!("skipping: no Japanese fallback face has Cyrillic");
return;
}
for paragraph in [false, true] {
let mut doc = styled_text_page("Times-Roman");
if paragraph {
text::replace_block(&mut doc, 0, 0, replacement).unwrap();
} else {
text::replace_line(&mut doc, 0, 0, replacement).unwrap();
}
assert_eq!(text::text_lines(&reopen(&doc), 0).unwrap()[0].text, replacement);
}
}
#[test]
fn japanese_paragraph_uses_unicode_type3_fallback() {
if without_craft_fonts("japanese_paragraph_uses_unicode_type3_fallback") {
return;
}
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
let replacement = "こんにちは、お元気ですか? 2.3444 日本語の文章";
text::replace_block(&mut doc, 0, 0, replacement).unwrap();
let reopened = reopen(&doc);
assert_eq!(text::text_blocks(&reopened, 0).unwrap()[0].text, replacement);
}
/// The first `n` Japanese characters, in code point order, that every craft-fonts face can draw, so
/// whichever face an edit picks has them all. Without craft-fonts there are no faces, every character
/// passes, and the text is still long.
fn covered_japanese_chars(n: usize) -> String {
let faces = pdfcraft_fonts::document_japanese_fonts_for_style(false, false);
(0x4E00..0xA000)
.filter_map(char::from_u32)
.filter(|ch| faces.iter().all(|face| pdfcraft_fonts::japanese_glyph_from(face, *ch).is_ok()))
.take(n)
.collect()
}
/// The page's Type 3 fallback fonts (`PCJp`, `PCJp1`, ...) by resource name.
fn fallback_fonts(doc: &Document) -> Vec<(Vec<u8>, Dict)> {
let p = pdfcraft_model::pages(doc).swap_remove(0);
let res = doc.resolve(p.dict.get(b"Resources").unwrap());
let fonts = doc.resolve(res.as_dict().unwrap().get(b"Font").unwrap());
fonts
.as_dict()
.unwrap()
.iter()
.filter(|(name, _)| name.starts_with(b"PCJp"))
.map(|(name, font)| (name.clone(), doc.resolve(font).as_dict().unwrap().clone()))
.collect()
}
/// More unique fallback glyphs than one Type 3 font holds (240) are split across several generated
/// fonts, each within that limit, and the text still reads back as one line.
#[test]
fn japanese_replacement_over_240_unique_glyphs_is_split_across_fallback_fonts() {
let replacement = covered_japanese_chars(300);
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
if without_craft_fonts("japanese_replacement_over_240_unique_glyphs_is_split_across_fallback_fonts") {
// Without craft-fonts the long text gets the clear error, not the old limit message.
let err = text::replace_line(&mut doc, 0, 0, &replacement).unwrap_err().to_string();
assert!(err.contains("CRAFT_FONTS_DIR"), "{err}");
return;
}
text::replace_line(&mut doc, 0, 0, &replacement).unwrap();
let reopened = reopen(&doc);
let lines = text::text_lines(&reopened, 0).unwrap();
assert_eq!(lines.len(), 1, "one line: {:?}", lines.iter().map(|l| &l.text).collect::<Vec<_>>());
assert_eq!(lines[0].text, replacement);
let fonts = fallback_fonts(&reopened);
assert_eq!(fonts.len(), 2, "300 glyphs take a 240-glyph font and a 60-glyph font");
let mut glyphs = 0;
for (name, font) in &fonts {
let procs = reopened.resolve(font.get(b"CharProcs").unwrap());
let count = procs.as_dict().unwrap().len();
let last = font.get(b"LastChar").and_then(|o| o.as_int()).unwrap_or(i64::MAX);
assert!(count <= 240 && last <= 240, "{name:?}: {count} glyphs, LastChar {last}");
glyphs += count;
}
assert_eq!(glyphs, 300);
}
/// A replacement whose last glyph no face draws (in a later font) changes nothing: neither the
/// document nor a snapshot taken before the edit, which is what undo restores.
#[test]
fn japanese_replacement_missing_a_glyph_after_240_changes_nothing() {
if without_craft_fonts("japanese_replacement_missing_a_glyph_after_240_changes_nothing") {
return;
}
// The first CJK character that no face draws: the 242nd unique character, in a second font.
let faces = pdfcraft_fonts::document_japanese_fonts_for_style(false, false);
let missing = (0x4E00u32..0x9FFF)
.filter_map(char::from_u32)
.find(|ch| faces.iter().all(|face| pdfcraft_fonts::japanese_glyph_from(face, *ch).is_err()))
.expect("some CJK character is outside every face");
let replacement = format!("{}{missing}", covered_japanese_chars(241));
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
let snapshot = doc.clone();
let err = text::replace_line(&mut doc, 0, 0, &replacement).unwrap_err().to_string();
assert!(err.contains(&format!("U+{:04X}", missing as u32)), "{err}");
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), saved);
assert_eq!(write_incremental(&snapshot, &SaveOptions::default()).unwrap(), saved);
}
/// Runs that change font move nothing: with two glyphs per font the text reads back the same, in the
/// same box as with one font, and the font is switched only where the text moves to another font.
#[test]
fn japanese_fallback_split_across_fonts_keeps_order_and_positions() {
if without_craft_fonts("japanese_fallback_split_across_fonts_keeps_order_and_positions") {
return;
}
// 見 and 商 are in the first font, 御 in the second: runs [見商], [御], [見], three switches.
let replacement = "見商御見";
let mut whole = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
let mut split = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
text::replace_line_with_limit(&mut whole, 0, 0, replacement, 240).unwrap();
text::replace_line_with_limit(&mut split, 0, 0, replacement, 2).unwrap();
let (whole, split) = (reopen(&whole), reopen(&split));
assert_eq!(fallback_fonts(&whole).len(), 1);
assert_eq!(fallback_fonts(&split).len(), 2);
let (one, many) = (text::text_lines(&whole, 0).unwrap(), text::text_lines(&split, 0).unwrap());
assert_eq!(many.len(), 1, "one line: {:?}", many.iter().map(|l| &l.text).collect::<Vec<_>>());
assert_eq!(many[0].text, replacement);
for (a, b) in one[0].rect.iter().zip(many[0].rect.iter()) {
assert!((a - b).abs() < 1e-6, "box {:?} vs {:?}", one[0].rect, many[0].rect);
}
// One `Tf` per run plus the page's own switch to F2 and the switch back after the text.
let content = String::from_utf8_lossy(&page_content_bytes(&split, 0)).into_owned();
assert_eq!(content.matches(" Tf").count(), 5, "{content}");
let content = String::from_utf8_lossy(&page_content_bytes(&whole, 0)).into_owned();
assert_eq!(content.matches(" Tf").count(), 3, "{content}");
}
/// Only a change of font gets a `Tf`: alternating characters with one glyph per font switch at every
/// character, and a run of one font's characters switches nowhere.
#[test]
fn japanese_fallback_switches_font_only_between_runs() {
if without_craft_fonts("japanese_fallback_switches_font_only_between_runs") {
return;
}
let alternating = "見商".repeat(500);
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
text::replace_line_with_limit(&mut doc, 0, 0, &alternating, 1).unwrap();
let reopened = reopen(&doc);
assert_eq!(text::text_lines(&reopened, 0).unwrap()[0].text, alternating);
let content = String::from_utf8_lossy(&page_content_bytes(&reopened, 0)).into_owned();
assert_eq!(content.matches(" Tf").count(), 1000 + 2, "1000 runs, the F2 switch before and after");
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
text::replace_line_with_limit(&mut doc, 0, 0, "見見見", 1).unwrap();
let content = String::from_utf8_lossy(&page_content_bytes(&reopen(&doc), 0)).into_owned();
assert_eq!(content.matches(" Tf").count(), 1 + 1 + 1, "{content}");
}
/// A paragraph set in several fallback fonts reads back as one block with the same text, and it
/// wraps across lines.
#[test]
fn japanese_paragraph_split_across_fonts_reads_back_as_one_block() {
if without_craft_fonts("japanese_paragraph_split_across_fonts_reads_back_as_one_block") {
return;
}
// Spaces are glyphs too, so two glyphs per font needs several fonts; the narrow box wraps the text.
let replacement = "見 商 御 見 商";
let style = text::BlockStyle { width: Some(40.0), ..Default::default() };
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
text::rewrite_block_with_limit(&mut doc, 0, 0, Some(replacement), &style, 2).unwrap();
let reopened = reopen(&doc);
assert!(fallback_fonts(&reopened).len() >= 2);
let blocks = text::text_blocks(&reopened, 0).unwrap();
assert_eq!(blocks.len(), 1, "one paragraph: {:?}", blocks.iter().map(|b| &b.text).collect::<Vec<_>>());
assert_eq!(blocks[0].text, replacement);
assert!(blocks[0].lines.len() > 1, "wrapped to {} line(s)", blocks[0].lines.len());
}
/// A paragraph whose move or width is refused (not finite, or drawn in a space it cannot be moved in)
/// leaves the document as it was: the fallback fonts are written only once the edit is accepted.
#[test]
fn japanese_paragraph_refused_before_its_fallback_fonts_are_written() {
if without_craft_fonts("japanese_paragraph_refused_before_its_fallback_fonts_are_written") {
return;
}
let plain = "BT /F2 12 Tf 72 700 Td (ab) Tj ET";
let cases = [
(plain, text::BlockStyle { offset: Some([f64::NAN, 0.0]), ..Default::default() }, "can't be moved that far"),
(plain, text::BlockStyle { width: Some(f64::INFINITY), ..Default::default() }, "width must be a number"),
(
"0 0 0 0 0 0 cm BT /F2 12 Tf 72 700 Td (ab) Tj ET",
text::BlockStyle { offset: Some([5.0, 0.0]), ..Default::default() },
"can't be moved in",
),
];
for (content, style, message) in cases {
let mut doc = text_page(content);
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
let err = text::rewrite_block(&mut doc, 0, 0, Some("見本商会 御中"), &style).unwrap_err().to_string();
assert!(err.contains(message), "{err}");
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), saved, "{message}: the document changed");
}
}
/// Every Type 3 fallback glyph starts with a well-formed `d1`: six operands, the second 0, and a
/// box that encloses every point of the glyph. Acrobat shows a bullet for a glyph whose `d1`
/// has the wrong operand count.
#[test]
fn type3_fallback_glyphs_declare_a_well_formed_d1() {
if without_craft_fonts("type3_fallback_glyphs_declare_a_well_formed_d1") {
return;
}
// Cyrillic is respaced (shifted by its side bearing), and its box must move with it.
let mut glyphs = Vec::new();
for replacement in ["見本商会 御中", "София 2027"] {
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
text::replace_line(&mut doc, 0, 0, replacement).unwrap();
glyphs.extend(fallback_paths(&reopen(&doc)));
}
assert!(!glyphs.is_empty());
for glyph in glyphs {
let text = String::from_utf8(glyph).unwrap();
let mut lines = text.lines();
let d1: Vec<&str> = lines.next().unwrap().split_whitespace().collect();
assert_eq!(d1.len(), 7, "six operands and d1: {d1:?}");
assert_eq!(d1[6], "d1");
let n: Vec<f64> = d1[..6].iter().map(|v| v.parse().unwrap()).collect();
assert_eq!(n[1], 0.0, "wy");
assert!(n[2] <= n[4] && n[3] <= n[5], "box: {n:?}");
for line in lines.filter(|l| l.ends_with(" m") || l.ends_with(" l")) {
let p: Vec<f64> = line.split_whitespace().take(2).map(|v| v.parse().unwrap()).collect();
assert!(p[0] >= n[2] && p[0] <= n[4] && p[1] >= n[3] && p[1] <= n[5], "{line} outside {n:?}");
}
}
}
/// Shippori Mincho, the serif fallback, has no Cyrillic: Bulgarian replacement text in a serif
/// line takes a fallback face that has every letter instead of failing on the first one.
#[test]
fn cyrillic_replacement_in_a_serif_line_uses_a_face_with_the_letters() {
if without_craft_fonts("cyrillic_replacement_in_a_serif_line_uses_a_face_with_the_letters") {
return;
}
let replacement = "София 2027";
for paragraph in [false, true] {
let mut doc = styled_text_page("ABCDEF+Garamond");
if paragraph {
text::replace_block(&mut doc, 0, 0, replacement).unwrap();
} else {
text::replace_line(&mut doc, 0, 0, replacement).unwrap();
}
let lines = text::text_lines(&reopen(&doc), 0).unwrap();
assert_eq!(lines[0].text, replacement);
// Letters are spaced by their shape, not one em each (the faces' full-width Cyrillic).
let widths = fallback_widths(&doc);
assert!(widths.iter().all(|w| *w > 300.0 && *w < 900.0), "{widths:?}");
assert!(widths.windows(2).any(|w| w[0] != w[1]), "proportional: {widths:?}");
}
}
/// The `/Widths` of the page's Type 3 fallback font, for its Cyrillic letters.
fn fallback_widths(doc: &Document) -> Vec<f64> {
let p = pdfcraft_model::pages(doc).swap_remove(0);
let res = doc.resolve(p.dict.get(b"Resources").unwrap());
let fonts = doc.resolve(res.as_dict().unwrap().get(b"Font").unwrap());
let font = doc.resolve(fonts.as_dict().unwrap().get(b"PCJp").unwrap());
let font = font.as_dict().unwrap();
let widths = doc.resolve(font.get(b"Widths").unwrap()).as_array().unwrap().iter().filter_map(|w| w.as_f64()).collect::<Vec<_>>();
// "София 2027": the first five codes are the letters.
widths[..5].to_vec()
}
#[test]
fn japanese_paragraph_keeps_ideographic_spaces() {
if without_craft_fonts("japanese_paragraph_keeps_ideographic_spaces") {
return;
}
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Original paragraph) Tj ET");
let replacement = " 見本商会  御中 ";
let style = text::BlockStyle { width: Some(400.0), ..Default::default() };
text::rewrite_block(&mut doc, 0, 0, Some(replacement), &style).unwrap();
let reopened = reopen(&doc);
let lines = text::text_lines(&reopened, 0).unwrap();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text, replacement);
assert_eq!(text::text_blocks(&reopened, 0).unwrap()[0].text, replacement);
// Restyling the saved paragraph without new text must also preserve its spacing.
let mut doc = reopened;
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { size: Some(14.0), ..style }).unwrap();
assert_eq!(text::text_lines(&reopen(&doc), 0).unwrap()[0].text, replacement);
}
/// Without craft-fonts there is no Japanese face: editing in Japanese is a clear error that
/// leaves the page untouched, never a panic; Latin edits work as before.
#[test]
fn japanese_edit_without_craft_fonts_is_a_clear_error() {
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
let before = page_content_bytes(&doc, 0);
let line = text::replace_line(&mut doc, 0, 0, "日本語の文字");
let block = text::replace_block(&mut doc, 0, 0, "日本語の文字");
if pdfcraft_fonts::document_japanese_font().is_some() {
eprintln!("built with craft-fonts: the Japanese edits succeed (checked by the tests above)");
assert!(line.is_ok() && block.is_ok());
return;
}
for result in [line.map(|_| ()), block.map(|_| ())] {
let Err(EditError::Invalid(msg)) = result else { panic!("expected a clear error, got {result:?}") };
assert!(msg.contains("Japanese fallback font") && msg.contains("CRAFT_FONTS_DIR"), "{msg}");
}
assert_eq!(page_content_bytes(&doc, 0), before);
let latin = text::replace_line(&mut doc, 0, 0, "Hello").unwrap();
assert_eq!(text::text_lines(&reopen(&doc), 0).unwrap()[0].text, "Hello", "{latin:?}");
}
#[test]
fn font_descriptor_style_is_exposed_even_with_a_neutral_name() {
let doc = descriptor_text_page();
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 1);
assert!(lines[0].bold && lines[0].italic, "{:?}", lines[0]);
let blocks = text::text_blocks(&doc, 0).unwrap();
assert!(blocks[0].bold && blocks[0].italic, "{:?}", blocks[0]);
}
#[test]
fn mixed_font_runs_are_not_merged_into_one_source_style() {
let doc = text_page("BT /F1 12 Tf 72 700 Td (Regular) Tj /F2 12 Tf 150 700 Td (Subset) Tj ET");
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 2);
assert_ne!(lines[0].font, lines[1].font);
}
#[test]
fn missing_glyphs_preserve_source_style_in_fallback() {
let mut doc = descriptor_text_page();
let result = text::replace_line(&mut doc, 0, 0, "Styled €").unwrap();
assert_eq!(result.substituted.as_deref(), Some("Helvetica-BoldOblique"));
let bytes = page_content_bytes(&doc, 0);
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("/PCEdHelveticaBoldOblique 12 Tf"), "{content}");
}
#[test]
fn text_lines_are_found_and_replaced_in_place() {
let mut doc =
text_page("BT /F1 12 Tf 72 700 Td (Hello) Tj 40 0 Td [(wor) -20 (ld)] TJ 0 -20 Td (Second line) Tj ET BT /F2 10 Tf 72 600 Td (ab) Tj ET");
let lines = text::text_lines(&doc, 0).unwrap();
let texts: Vec<&str> = lines.iter().map(|l| l.text.as_str()).collect();
assert_eq!(texts, ["Hello world", "Second line", "ab"]);
assert!((lines[0].rect[0] - 72.0).abs() < 0.01 && lines[0].rect[1] < 700.0 && lines[0].rect[3] > 700.0, "{:?}", lines[0].rect);
assert!((lines[0].size - 12.0).abs() < 1e-9 && lines[0].base_font == "Helvetica");
let second = lines[1].rect;
let r = text::replace_line(&mut doc, 0, 0, "Goodbye, café").unwrap();
assert_eq!(r.substituted, None);
let doc = reopen(&doc);
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines[0].text, "Goodbye, café");
assert_eq!(lines[1].text, "Second line");
assert_eq!(lines[1].rect, second);
}
/// A page whose content is one stream in three pieces, split between tokens: a marked-content
/// dictionary closes at the start of the middle piece, and the `TJ` array that ends it is shown
/// by the operator that starts the last one.
fn split_streams_page() -> Document {
let piece = |s: &str| format!("<< /Length {} >>\nstream\n{s}\nendstream", s.len());
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents [4 0 R 5 0 R 6 0 R] /Resources << /Font << /F1 7 0 R >> >> >>".into(),
piece("/P << /MCID 0"),
piece(">> BDC BT /F1 12 Tf 72 700 Td (Target) Tj 0 -20 Td [(After) -20 (wards)]"),
piece("TJ ET EMC"),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".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()
}
/// The page's pieces joined back into one stream: every operator must still have its operands.
fn assert_split_tokens_kept(doc: &Document, new_text: &str) {
let joined = streams(doc, 0).join("\n");
assert!(joined.contains(new_text) && !joined.contains("Target"), "{joined}");
assert!(joined.contains("[(After) -20 (wards)]"), "the text after the line is gone: {joined}");
for op in pdfcraft_content::parse(joined.as_bytes()).ops {
let want = match op.op.as_slice() {
b"BDC" => 2,
b"TJ" => 1,
_ => continue,
};
assert_eq!(op.operands.len(), want, "{} lost its operands: {joined}", String::from_utf8_lossy(&op.op));
}
}
#[test]
fn editing_text_keeps_the_tokens_a_stream_shares_with_its_neighbours() {
let mut doc = split_streams_page();
assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Target");
text::replace_line(&mut doc, 0, 0, "Edited").unwrap();
assert_split_tokens_kept(&reopen(&doc), "Edited");
// A paragraph rewrite: "Target" and the line after it (split across pieces) are one
// paragraph, rewritten whole without stray tokens.
let mut doc = split_streams_page();
assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 1);
text::replace_block(&mut doc, 0, 0, "Rewrapped").unwrap();
let (joined, ops) = joined_ops(&reopen(&doc));
assert!(joined.contains("Rewrapped") && !joined.contains("Target") && !joined.contains("wards"), "{joined}");
assert!(ops.iter().find(|o| o.is("BDC")).is_some_and(|o| o.operands.len() == 2), "{joined}");
}
/// The operators of the page's pieces joined back into one stream, each checked for operands.
fn joined_ops(doc: &Document) -> (String, Vec<pdfcraft_content::Op>) {
let joined = streams(doc, 0).join("\n");
let ops = pdfcraft_content::parse(joined.as_bytes());
assert_eq!(ops.skipped, 0, "stray tokens: {joined}");
(joined, ops.ops)
}
#[test]
fn a_line_whose_operator_starts_the_next_stream_can_be_edited() {
// #155: `[(After) -20 (wards)]` ends one piece and its `TJ` starts the next.
let doc = split_streams_page();
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["Target", "Afterwards"]);
assert!(lines[1].rect[1] < lines[0].rect[1] && lines[1].rect[2] > lines[1].rect[0] + 40.0, "{:?}", lines[1].rect);
// Retyping it replaces the operator whole, in both pieces it spans.
let mut doc = split_streams_page();
text::replace_line(&mut doc, 0, 1, "Later").unwrap();
let doc = reopen(&doc);
let (joined, ops) = joined_ops(&doc);
assert!(!joined.contains("After") && !joined.contains("wards") && joined.contains("Target"), "{joined}");
assert!(ops.iter().filter(|o| o.is("TJ")).count() == 0, "{joined}");
assert!(ops.iter().find(|o| o.is("BDC")).is_some_and(|o| o.operands.len() == 2), "{joined}");
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["Target", "Later"]);
// The pieces stay pieces: the first is untouched, the marked content still closes.
let pieces = streams(&doc, 0);
assert_eq!(pieces.len(), 3);
assert_eq!(pieces[0].trim(), "/P << /MCID 0");
assert!(pieces[2].contains("EMC") && !pieces[2].contains("TJ"), "{pieces:?}");
// As a paragraph with the line before it: both lines are rewritten.
let mut doc = split_streams_page();
let blocks = text::text_blocks(&doc, 0).unwrap();
let last = blocks.iter().position(|b| b.text.contains("Afterwards")).unwrap();
text::replace_block(&mut doc, 0, last, "Rewrapped").unwrap();
let doc = reopen(&doc);
let (joined, ops) = joined_ops(&doc);
assert!(!joined.contains("wards") && joined.contains("Rewrapped"), "{joined}");
assert!(ops.iter().find(|o| o.is("BDC")).is_some_and(|o| o.operands.len() == 2), "{joined}");
assert!(text::text_lines(&doc, 0).unwrap().iter().any(|l| l.text == "Rewrapped"));
}
#[test]
fn win_ansi_high_glyphs_use_helvetica_not_the_fallback_font() {
// š ž Š Ž Œ Ÿ ƒ † ‰ (WinAnsi 0x80–0x9F) are standard-font glyphs (#347): no Type3 fallback.
let text = "Šumava žaba œuvre Ÿ ƒ † ‰ €";
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
let r = text::replace_line(&mut doc, 0, 0, text).unwrap();
assert_eq!(r.substituted.as_deref(), Some("Helvetica"));
let lines = text::text_lines(&reopen(&doc), 0).unwrap();
assert_eq!((lines[0].text.as_str(), lines[0].base_font.as_str()), (text, "Helvetica"));
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
let r = text::replace_block(&mut doc, 0, 0, text).unwrap();
assert!(r.substituted.as_deref().is_none_or(|s| !s.contains("Type3")), "{:?}", r.substituted);
let lines = text::text_lines(&reopen(&doc), 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>().join(" "), text);
}
#[test]
fn missing_glyphs_substitute_helvetica_and_impossible_text_is_refused() {
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
// "c" has no glyph in the subset: Helvetica takes over for this line.
let r = text::replace_line(&mut doc, 0, 0, "abc").unwrap();
assert_eq!(r.substituted.as_deref(), Some("Helvetica"));
let doc2 = reopen(&doc);
let lines = text::text_lines(&doc2, 0).unwrap();
assert_eq!((lines[0].text.as_str(), lines[0].base_font.as_str()), ("abc", "Helvetica"));
// Neither the standard font nor the craft-fonts Japanese faces have this emoji.
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
assert!(text::replace_line(&mut doc, 0, 0, "\u{1f4a9}").is_err());
assert!(text::replace_line(&mut doc, 0, 5, "x").is_err(), "no such line");
}
#[test]
fn paragraphs_are_found_and_rewrapped() {
let mut doc = text_page(
"BT 0 0 1 rg /F1 10 Tf 12 TL 72 700 Td (The quick brown fox) Tj T* (jumps over the) Tj T* (lazy dog.) Tj ET \
BT /F1 10 Tf 72 600 Td (Next paragraph) Tj ET",
);
let blocks = text::text_blocks(&doc, 0).unwrap();
let texts: Vec<&str> = blocks.iter().map(|b| b.text.as_str()).collect();
assert_eq!(texts, ["The quick brown fox jumps over the lazy dog.", "Next paragraph"]);
assert_eq!(blocks[0].lines, [0, 1, 2]);
let width = blocks[0].rect[2] - blocks[0].rect[0];
let next = blocks[1].rect;
let long = "PdfCraft rewraps a paragraph to its own width when its text changes, keeping the font, size, colour and line spacing.";
assert_eq!(text::replace_block(&mut doc, 0, 0, long).unwrap().substituted, None);
let doc = reopen(&doc);
let lines = text::text_lines(&doc, 0).unwrap();
let blocks = text::text_blocks(&doc, 0).unwrap();
assert_eq!(blocks[0].text, long);
assert!(blocks[0].lines.len() > 3, "more lines: {}", blocks[0].lines.len());
for i in &blocks[0].lines {
assert!(lines[*i].rect[2] - lines[*i].rect[0] <= width + 1.0, "line {} fits", lines[*i].text);
}
// Same left edge, same spacing (12 pt).
assert!((lines[0].rect[0] - 72.0).abs() < 0.01);
let spacing = lines[blocks[0].lines[0]].origin_baseline() - lines[blocks[0].lines[1]].origin_baseline();
assert!((spacing - 12.0).abs() < 0.01, "{spacing}");
// The next paragraph is untouched, and the new text is blue like the old.
assert_eq!(blocks[1].text, "Next paragraph");
assert_eq!(blocks[1].rect, next);
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert!(content.contains("0 0 1 rg"), "{content}");
// Shorter text: fewer lines.
let mut doc = doc;
text::replace_block(&mut doc, 0, 0, "Short.").unwrap();
let blocks = text::text_blocks(&doc, 0).unwrap();
assert_eq!((blocks[0].text.as_str(), blocks[0].lines.len()), ("Short.", 1));
}
fn page_content_bytes(doc: &Document, page: usize) -> Vec<u8> {
let p = pdfcraft_model::pages(doc).swap_remove(page);
let c = p.dict.get(b"Contents").unwrap();
match &*doc.resolve(c) {
pdfcraft_cos::Object::Stream(s) => s.decoded().unwrap(),
pdfcraft_cos::Object::Array(a) => a
.iter()
.flat_map(|x| match &*doc.resolve(x) {
pdfcraft_cos::Object::Stream(s) => s.decoded().unwrap(),
_ => Vec::new(),
})
.collect(),
_ => Vec::new(),
}
}
/// A page drawing image XObject /Im0 at 100,100 size 200 × 100 (pixels 4 × 2).
fn image_page() -> Document {
image_page_with("q 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET")
}
fn image_page_with(content: &str) -> Document {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> >> >>".into(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
"<< /Type /XObject /Subtype /Image /Width 4 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 8 >>\nstream\n\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\nendstream".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()
}
/// The box of the first `re` on the page in user space, after the `cm`s before it.
fn first_rect_in_user_space(doc: &Document) -> [f64; 4] {
let mut ctm = pdfcraft_content::Matrix::IDENTITY;
for op in pdfcraft_content::parse(&page_content_bytes(doc, 0)).ops {
if op.is("cm") {
ctm = pdfcraft_content::Matrix(op.nums::<6>().unwrap()).then(&ctm);
} else if op.is("re") {
let [x, y, w, h] = op.nums::<4>().unwrap();
return ctm.bbox([x, y, x + w, y + h]);
}
}
panic!("no clip rectangle");
}
#[test]
fn moving_an_image_moves_its_own_clip_too() {
let content = "q 100 100 200 100 re W* n 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET";
let target = [50.0, 400.0, 350.0, 600.0];
let mut doc = image_page_with(content);
let before = images::page_images(&doc, 0).unwrap()[0].rect;
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(images::rect_to_rect(before, target))).unwrap();
let mut doc = reopen(&doc);
assert!(close(images::page_images(&doc, 0).unwrap()[0].rect, target));
assert!(close(first_rect_in_user_space(&doc), target), "{:?}", first_rect_in_user_space(&doc));
// A second edit still finds the clip, now behind the `cm` the first one put before it.
let moved = [80.0, 420.0, 380.0, 620.0];
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(images::rect_to_rect(target, moved))).unwrap();
assert!(close(first_rect_in_user_space(&doc), moved));
images::translate_images(&mut doc, 0, &[0], [10.0, 0.0]).unwrap();
assert!(close(images::page_images(&doc, 0).unwrap()[0].rect, [90.0, 420.0, 390.0, 620.0]));
assert!(close(first_rect_in_user_space(&doc), [90.0, 420.0, 390.0, 620.0]));
// Moving it with the group move helper takes the clip along as well.
let mut doc = image_page_with(content);
images::translate_images(&mut doc, 0, &[0], [30.0, -20.0]).unwrap();
assert!(close(images::page_images(&doc, 0).unwrap()[0].rect, [130.0, 80.0, 330.0, 180.0]));
assert!(close(first_rect_in_user_space(&doc), [130.0, 80.0, 330.0, 180.0]));
// A clip shared with other drawing is left where it is.
let shared = "q 100 100 200 100 re W n q 200 0 0 100 100 100 cm /Im0 Do Q 0 0 10 10 re f Q";
let mut doc = image_page_with(shared);
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(images::rect_to_rect(before, target))).unwrap();
assert!(close(first_rect_in_user_space(&doc), [100.0, 100.0, 300.0, 200.0]));
}
fn close(a: [f64; 4], b: [f64; 4]) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6)
}
#[test]
fn page_images_move_turn_replace_and_delete() {
let mut doc = image_page();
let imgs = images::page_images(&doc, 0).unwrap();
assert_eq!(imgs.len(), 1);
assert!(close(imgs[0].rect, [100.0, 100.0, 300.0, 200.0]), "{:?}", imgs[0].rect);
assert_eq!((imgs[0].width, imgs[0].height, imgs[0].name.as_str()), (4, 2, "Im0"));
// Move and resize.
let t = images::rect_to_rect(imgs[0].rect, [50.0, 400.0, 150.0, 450.0]);
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(t)).unwrap();
let doc = reopen(&doc);
let r = images::page_images(&doc, 0).unwrap()[0].rect;
assert!(close(r, [50.0, 400.0, 150.0, 450.0]), "{r:?}");
// A quarter turn about the centre: 100 × 50 becomes 50 × 100 around (100, 425).
let mut doc = doc;
let t = images::turn_about_centre(r, 1, false, false);
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(t)).unwrap();
let r = images::page_images(&doc, 0).unwrap()[0].rect;
assert!(close(r, [75.0, 375.0, 125.0, 475.0]), "{r:?}");
// The text after it is untouched.
assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Caption");
// Replace with another image object, in the same place.
let mut d = pdfcraft_cos::Dict::new();
for (k, v) in [
(&b"Type"[..], pdfcraft_cos::Object::name("XObject")),
(b"Subtype", pdfcraft_cos::Object::name("Image")),
(b"Width", pdfcraft_cos::Object::Int(1)),
(b"Height", pdfcraft_cos::Object::Int(1)),
] {
d.set(k.to_vec(), v);
}
let other = doc.add(pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::from_raw(d, vec![0])));
images::change_image(&mut doc, 0, 0, &images::ImageChange::Replace(other)).unwrap();
let imgs = images::page_images(&doc, 0).unwrap();
assert_eq!((imgs[0].object, imgs[0].width), (Some(other), 1));
assert!(close(imgs[0].rect, r));
// Delete.
images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).unwrap();
assert!(images::page_images(&doc, 0).unwrap().is_empty());
assert!(images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).is_err());
}
#[test]
fn form_artwork_edits_only_the_selected_placement() {
let mut doc = fixture(); // three pages share resources; pages 0 and 1 also share contents
let mut form = Dict::new();
form.set(b"Subtype".to_vec(), Object::name("Form"));
form.set(b"BBox".to_vec(), Object::Array([10, 20, 110, 70].map(Object::Int).to_vec()));
form.set(b"Matrix".to_vec(), Object::Array([2, 0, 0, 3, -20, -60].map(Object::Int).to_vec()));
let artwork = doc.add(Object::Stream(Stream::flate(form, b"1 0 0 rg 10 20 100 50 re f")));
doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| {
let mut xo = Dict::new();
xo.set(b"Figure".to_vec(), Object::Ref(artwork));
d.set(b"XObject".to_vec(), Object::Dict(xo));
})
.unwrap();
let content = b"q 1 0 0 1 40 80 cm /Figure Do Q q 1 0 0 1 300 400 cm /Figure Do Q BT /F1 9 Tf (Caption) Tj ET";
doc.set(pdfcraft_cos::ObjRef::new(7, 0), Object::Stream(Stream::flate(Dict::new(), content)));
let mut doc = reopen(&doc);
let original = doc.get(artwork);
let imgs = images::page_images(&doc, 0).unwrap();
assert_eq!(imgs.len(), 2);
assert!(imgs[0].is_form);
assert_eq!((imgs[0].width, imgs[0].height), (0, 0));
assert!(close(imgs[0].rect, [40.0, 80.0, 240.0, 230.0]));
let target = [60.0, 100.0, 160.0, 175.0];
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(images::rect_to_rect(imgs[0].rect, target))).unwrap();
let mut doc = reopen(&doc);
let moved = images::page_images(&doc, 0).unwrap();
assert!(close(moved[0].rect, target));
assert_eq!(moved[1].rect, imgs[1].rect);
assert_eq!(images::page_images(&doc, 1).unwrap()[0].rect, imgs[0].rect);
assert_eq!(*doc.get(artwork), *original, "the shared Form and its unknown data are untouched");
assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Caption");
let t = images::turn_about_centre(target, 1, false, false);
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(t)).unwrap();
assert!(close(images::page_images(&doc, 0).unwrap()[0].rect, [72.5, 87.5, 147.5, 187.5]));
assert!(images::change_image(&mut doc, 0, 0, &images::ImageChange::Replace(artwork)).is_err());
images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).unwrap();
let doc = reopen(&doc);
assert_eq!(images::page_images(&doc, 0).unwrap().len(), 1);
assert_eq!(images::page_images(&doc, 1).unwrap().len(), 2);
// Invalid bounds are skipped without interpreting the Form stream.
let mut doc = doc;
let mut broken = (*doc.get(artwork)).clone();
if let Object::Stream(s) = &mut broken {
s.dict.set(b"BBox".to_vec(), Object::Array(vec![Object::Int(0)]));
}
doc.set(artwork, broken);
assert!(images::page_images(&doc, 0).unwrap().is_empty());
}
#[test]
fn paragraphs_take_new_formatting() {
let mut doc = text_page("BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj ET");
let before = text::text_blocks(&doc, 0).unwrap()[0].clone();
let style = text::BlockStyle {
family: Some((added::Family::Times, true, false)),
size: Some(12.0),
color: Some([1.0, 0.0, 0.0]),
align: Some(added::Align::Center),
..Default::default()
};
text::rewrite_block(&mut doc, 0, 0, None, &style).unwrap();
let doc = reopen(&doc);
let blocks = text::text_blocks(&doc, 0).unwrap();
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(blocks[0].text, before.text, "same words");
assert_eq!((blocks[0].base_font.as_str(), blocks[0].size), ("Times-Bold", 12.0));
// Centred in the paragraph's width.
let mid = (before.rect[0] + before.rect[2]) / 2.0;
for i in &blocks[0].lines {
let r = lines[*i].rect;
assert!(((r[0] + r[2]) / 2.0 - mid).abs() < 2.0, "line {:?} centred on {mid}", lines[*i].text);
}
assert!(String::from_utf8_lossy(&page_content_bytes(&doc, 0)).contains("1 0 0 rg"));
// Right alignment keeps lines flush with the right edge.
let mut doc = text_page("BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj ET");
let right = text::text_blocks(&doc, 0).unwrap()[0].rect[2];
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { align: Some(added::Align::Right), ..Default::default() }).unwrap();
let lines = text::text_lines(&doc, 0).unwrap();
for l in &lines {
assert!((l.rect[2] - right).abs() < 1.0, "{:?} ends at {} not {right}", l.text, l.rect[2]);
}
}
#[test]
fn a_new_paragraph_colour_does_not_spill_into_the_text_after_it() {
let mut doc = text_page("BT /F1 10 Tf 72 700 Td (First paragraph) Tj 0 -40 Td (Second paragraph) Tj ET");
let style = text::BlockStyle { color: Some([1.0, 0.0, 0.0]), ..Default::default() };
text::rewrite_block(&mut doc, 0, 0, None, &style).unwrap();
let doc = reopen(&doc);
// The fill colour in force where the second paragraph is shown (q/Q nest it).
let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops;
let (mut fill, mut stack) = (String::from("0 g"), Vec::new());
for op in &ops {
match op.op.as_slice() {
b"q" => stack.push(fill.clone()),
b"Q" => fill = stack.pop().unwrap_or_default(),
b"g" | b"rg" | b"k" => fill = String::from_utf8_lossy(&pdfcraft_content::serialize_ops(std::slice::from_ref(op))).trim().to_string(),
b"Tj" if op.operands.first().and_then(|o| o.as_string()).is_some_and(|s| s.to_text() == "Second paragraph") => break,
_ => {}
}
}
assert_eq!(fill, "0 g", "{ops:?}");
assert_eq!(text::text_blocks(&doc, 0).unwrap()[1].text, "Second paragraph");
}
#[test]
fn a_rewritten_paragraph_keeps_its_place_in_a_shared_text_object() {
// Three paragraphs in one BT … ET: rewriting the second keeps the order (paragraph numbers
// and reading order) and every paragraph's position.
let src = "BT /F1 10 Tf 72 700 Td (First paragraph) Tj 0 -40 Td (Second paragraph) Tj 0 -40 Td (Third paragraph) Tj ET";
let mut doc = text_page(src);
let before = text::text_blocks(&doc, 0).unwrap();
text::replace_block(&mut doc, 0, 1, "Edited second").unwrap();
let doc = reopen(&doc);
let after = text::text_blocks(&doc, 0).unwrap();
let texts: Vec<&str> = after.iter().map(|b| b.text.as_str()).collect();
assert_eq!(texts, ["First paragraph", "Edited second", "Third paragraph"]);
assert_eq!(after[0].rect, before[0].rect);
assert_eq!(after[2].rect, before[2].rect);
assert!((after[1].rect[0] - before[1].rect[0]).abs() < 0.01 && (after[1].rect[1] - before[1].rect[1]).abs() < 0.01, "{:?}", after[1].rect);
}
#[test]
fn recolouring_a_paragraph_in_a_shared_text_object_keeps_order_and_nesting() {
// Splitting the text object (to keep the order) and q … Q (to keep the colour in) together:
// q/Q stay outside text objects, and the paragraph after it keeps the original colour.
let src = "BT /F1 10 Tf 72 700 Td (First paragraph) Tj 0 -40 Td (Second paragraph) Tj 0 -40 Td (Third paragraph) Tj ET";
let mut doc = text_page(src);
let style = text::BlockStyle { color: Some([1.0, 0.0, 0.0]), ..Default::default() };
text::rewrite_block(&mut doc, 0, 1, None, &style).unwrap();
let doc = reopen(&doc);
let texts: Vec<String> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
assert_eq!(texts, ["First paragraph", "Second paragraph", "Third paragraph"]);
let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops;
let (mut in_text, mut depth, mut red) = (false, 0usize, Vec::new());
for op in &ops {
match op.op.as_slice() {
b"BT" => in_text = true,
b"ET" => in_text = false,
b"q" | b"Q" => {
assert!(!in_text, "q/Q inside a text object: {ops:?}");
depth = if op.is("q") { depth + 1 } else { depth.saturating_sub(1) };
}
b"rg" => red.push(depth),
b"Tj" if op.operands.first().and_then(|o| o.as_string()).is_some_and(|s| s.to_text() == "Third paragraph") => {
assert!(red.iter().all(|d| *d > depth), "red still in force for the third paragraph: {ops:?}");
}
_ => {}
}
}
assert!(!red.is_empty(), "{ops:?}");
}
#[test]
fn justify_underline_and_spacing() {
let src = "BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj T* (end) Tj ET";
// Justified: every line but the last reaches the right edge.
let mut doc = text_page(src);
let right = text::text_blocks(&doc, 0).unwrap()[0].rect[2];
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { align: Some(added::Align::Justify), ..Default::default() }).unwrap();
let lines = text::text_lines(&doc, 0).unwrap();
for l in &lines[..lines.len() - 1] {
assert!((l.rect[2] - right).abs() < 1.0, "{:?} ends at {} not {right}", l.text, l.rect[2]);
}
// Double line spacing, underlined.
let mut doc = text_page(src);
let style = text::BlockStyle { line_spacing: Some(2.0), underline: Some(true), ..Default::default() };
text::rewrite_block(&mut doc, 0, 0, None, &style).unwrap();
let lines = text::text_lines(&doc, 0).unwrap();
assert!((lines[0].origin_baseline() - lines[1].origin_baseline() - 20.0).abs() < 0.01);
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert!(content.contains(" l\nS\n") || content.contains(" l S"), "{content}");
// Character spacing widens, horizontal scale narrows.
let mut doc = text_page("BT /F1 10 Tf 100 700 Td (Wide text) Tj ET");
let w0 = text::text_lines(&doc, 0).unwrap()[0].rect;
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { char_spacing: Some(2.0), ..Default::default() }).unwrap();
let w1 = text::text_lines(&doc, 0).unwrap()[0].rect;
assert!((w1[2] - w1[0]) - (w0[2] - w0[0]) > 15.0, "{w0:?} → {w1:?}");
let mut doc = text_page("BT /F1 10 Tf 100 700 Td (Wide text) Tj ET");
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { scale: Some(50.0), ..Default::default() }).unwrap();
let w2 = text::text_lines(&doc, 0).unwrap()[0].rect;
assert!(((w2[2] - w2[0]) - (w0[2] - w0[0]) / 2.0).abs() < 1.0, "{w0:?} → {w2:?}");
}
#[test]
fn a_line_drawn_twice_is_replaced_everywhere() {
// Fake bold: a line drawn twice at the same spot (each copy its own text object). The two
// copies group apart, but editing one replaces both — a surviving copy would show the old
// text under the new.
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello) Tj ET BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2, "the copies group apart");
text::replace_block(&mut doc, 0, 0, "Hello world").unwrap();
let doc = reopen(&doc);
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["Hello world"], "one line, replaced");
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert_eq!(content.matches("(Hello").count(), 1, "the second copy's operators are gone: {content}");
// Copies in one text object (the matrix re-issued at the same spot) go too.
let mut doc = text_page("BT /F1 12 Tf 1 0 0 1 72 700 Tm (Hi) Tj 1 0 0 1 72.3 700 Tm (Hi) Tj ET");
text::replace_line(&mut doc, 0, 0, "Hey").unwrap();
let doc = reopen(&doc);
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["Hey"], "one line, replaced");
// A neighbouring line is not coincident and stays (40 pt spacing groups apart at 12 pt).
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello) Tj 0 -40 Td (world) Tj ET");
text::replace_block(&mut doc, 0, 0, "Hello there").unwrap();
let doc = reopen(&doc);
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["Hello there", "world"]);
}
#[test]
fn paragraphs_move_and_rewrap_to_a_new_width() {
let para = "BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj ET";
let close = |a: f64, b: f64| (a - b).abs() < 0.5;
// Moved 50 right and 200 down, same words and wrapping.
let mut doc = text_page(para);
let before = text::text_blocks(&doc, 0).unwrap()[0].clone();
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { offset: Some([50.0, -200.0]), ..Default::default() }).unwrap();
let doc = reopen(&doc);
let after = text::text_blocks(&doc, 0).unwrap()[0].clone();
assert_eq!((after.text.as_str(), after.lines.len()), (before.text.as_str(), before.lines.len()));
assert!(close(after.rect[0], before.rect[0] + 50.0) && close(after.rect[1], before.rect[1] - 200.0), "{:?} → {:?}", before.rect, after.rect);
// Drawn at half scale: the move is still in page space.
let mut doc = text_page(&format!("q 0.5 0 0 0.5 0 0 cm {} Q", para.replace("100 700 Td", "200 1400 Td")));
let before = text::text_blocks(&doc, 0).unwrap()[0].clone();
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { offset: Some([30.0, 40.0]), ..Default::default() }).unwrap();
let after = text::text_blocks(&reopen(&doc), 0).unwrap()[0].clone();
assert!(close(after.rect[0], before.rect[0] + 30.0) && close(after.rect[1], before.rect[1] + 40.0), "{:?} → {:?}", before.rect, after.rect);
// A narrower box rewraps into more lines that fit it; a wider one into fewer.
let mut doc = text_page(para);
let left = text::text_blocks(&doc, 0).unwrap()[0].rect[0];
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { width: Some(80.0), ..Default::default() }).unwrap();
let narrow = text::text_blocks(&reopen(&doc), 0).unwrap()[0].clone();
assert!(narrow.lines.len() > 2, "{narrow:?}");
assert!(narrow.rect[2] <= left + 80.5, "{narrow:?}");
let mut doc = text_page(para);
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { width: Some(400.0), ..Default::default() }).unwrap();
assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap()[0].lines.len(), 1);
// Untrusted numbers (the automation tools pass them through) are refused, not drawn.
let mut doc = text_page(para);
for style in [
text::BlockStyle { offset: Some([f64::NAN, 0.0]), ..Default::default() },
text::BlockStyle { offset: Some([0.0, f64::INFINITY]), ..Default::default() },
text::BlockStyle { width: Some(f64::NAN), ..Default::default() },
] {
assert!(text::rewrite_block(&mut doc, 0, 0, None, &style).is_err(), "{style:?}");
}
// A zero width is clamped to one character's width rather than looping or vanishing.
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { width: Some(0.0), ..Default::default() }).unwrap();
assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap().iter().map(|b| b.text.split_whitespace().count()).sum::<usize>(), 12);
}
/// A page split into several content streams, as AutoCAD writes them: the first scales the
/// page (`0.12 0 0 0.12 0 0 cm`, no `q`), the text comes in a later one.
fn split_page(first: &str, second: &str) -> Document {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents [4 0 R 5 0 R] /Resources << /Font << /F1 6 0 R >> >> >>".into(),
format!("<< /Length {} >>\nstream\n{first}\nendstream", first.len()),
format!("<< /Length {} >>\nstream\n{second}\nendstream", second.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".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()
}
#[test]
fn the_graphics_state_carries_over_between_content_streams() {
let close = |a: f64, b: f64| (a - b).abs() < 0.5;
// Text at (1000, 2000) in a space scaled by 0.12: on the page at (120, 240), 12 pt.
let mut doc = split_page("0.12 0 0 0.12 0 0 cm", "BT /F1 100 Tf 1000 2000 Td (Site plan) Tj ET");
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 1, "{lines:?}");
let l = &lines[0];
assert!(close(l.rect[0], 120.0) && l.rect[1] > 230.0 && l.rect[3] < 260.0, "{:?}", l.rect);
assert!(close(l.size, 12.0), "{}", l.size);
// Editing keeps the paragraph where it was, at its size; moving it moves it in page space.
text::rewrite_block(&mut doc, 0, 0, Some("Site plan, rev. B"), &text::BlockStyle { offset: Some([10.0, -20.0]), ..Default::default() }).unwrap();
let after = text::text_blocks(&reopen(&doc), 0).unwrap();
assert_eq!(after[0].text, "Site plan, rev. B");
assert!(close(after[0].rect[0], 130.0) && close(after[0].rect[1], l.rect[1] - 20.0), "{:?}", after[0].rect);
assert!(close(after[0].size, 12.0), "{}", after[0].size);
// A `q` left open in one stream and closed in the next still restores the state.
let doc = split_page("q 0.5 0 0 0.5 0 0 cm", "Q BT /F1 10 Tf 100 100 Td (Footpath) Tj ET");
let l = &text::text_lines(&doc, 0).unwrap()[0];
assert!(close(l.rect[0], 100.0) && close(l.size, 10.0), "{l:?}");
}
/// One page whose font draws a few Hebrew letters (ToUnicode): A ב, B ד, C י, D ו, E ק, F ש,
/// G ל, H ם. Content streams draw Hebrew in visual order, so "בדיוק" is `(EDCBA)`.
fn hebrew_page(content: &str) -> Document {
let cmap = "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapType 2 def \
1 begincodespacerange <00> <FF> endcodespacerange 9 beginbfchar \
<20> <0020> <41> <05D1> <42> <05D3> <43> <05D9> <44> <05D5> <45> <05E7> <46> <05E9> <47> <05DC> <48> <05DD> \
endbfchar endcmap CMapName currentdict /CMap defineresource pop end end";
// Widths for codes 32..=72: a space, then the eight letters at 65..=72.
let widths: Vec<&str> = (32..=72)
.map(|c| {
if c == 32 {
"250"
} else if c >= 65 {
"500"
} else {
"0"
}
})
.collect();
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
format!(
"<< /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+FakeHebrew /FirstChar 32 /LastChar 72 /Widths [{}] /ToUnicode 6 0 R >>",
widths.join(" ")
),
format!("<< /Length {} >>\nstream\n{cmap}\nendstream", cmap.len()),
];
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()
}
/// The report: Edit text showed "בדיוק" (exactly) reversed, because the line was read in the
/// order the page draws it.
#[test]
fn hebrew_lines_read_in_typing_order() {
let doc = hebrew_page("BT /F1 12 Tf 300 700 Td (EDCBA) Tj ET BT /F1 12 Tf 300 600 Td (EDCBA HDGF) Tj ET");
let lines: Vec<String> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
assert_eq!(lines, ["בדיוק", "שלום בדיוק"]);
let blocks: Vec<String> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
assert_eq!(blocks, ["בדיוק", "שלום בדיוק"]);
}
/// Text the page's own font can show is written back in drawing order, right-aligned to where
/// the line ended, and reads back as typed.
#[test]
fn hebrew_edits_are_drawn_right_to_left_and_keep_their_right_edge() {
let mut doc = hebrew_page("BT /F1 12 Tf 300 700 Td (EDCBA) Tj ET");
let before = text::text_lines(&doc, 0).unwrap()[0].rect;
// One line: the bytes are the visual order of the typed text.
assert_eq!(text::replace_line(&mut doc, 0, 0, "שלום בדיוק").unwrap().substituted, None);
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert!(content.contains("(EDCBA HDGF)"), "{content}");
assert_eq!(text::text_lines(&reopen(&doc), 0).unwrap()[0].text, "שלום בדיוק");
// A paragraph: shorter text stays right-aligned at the old right edge.
let mut doc = hebrew_page("BT /F1 12 Tf 300 700 Td (EDCBA) Tj ET");
assert_eq!(text::replace_block(&mut doc, 0, 0, "דוב").unwrap().substituted, None);
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert!(content.contains("(ADB)"), "{content}");
let doc = reopen(&doc);
let line = text::text_lines(&doc, 0).unwrap().swap_remove(0);
assert_eq!(line.text, "דוב");
assert!((line.rect[2] - before[2]).abs() < 0.5, "right edge {} → {}", before[2], line.rect[2]);
assert!(line.rect[0] > before[0] + 1.0, "shorter text starts further right: {:?} vs {before:?}", line.rect);
// Longer text grows to the left, not off the right edge.
let mut doc = hebrew_page("BT /F1 12 Tf 300 700 Td (EDCBA) Tj ET");
text::replace_block(&mut doc, 0, 0, "שלום בדיוק שלום").unwrap();
let doc = reopen(&doc);
let line = text::text_lines(&doc, 0).unwrap().swap_remove(0);
assert_eq!(line.text, "שלום בדיוק שלום");
assert!((line.rect[2] - before[2]).abs() < 0.5, "right edge {} → {}", before[2], line.rect[2]);
assert!(line.rect[0] < before[0] - 1.0, "longer text grows left: {:?} vs {before:?}", line.rect);
// An alignment asked for still wins.
let mut doc = hebrew_page("BT /F1 12 Tf 300 700 Td (EDCBA) Tj ET");
let left = text::BlockStyle { align: Some(Align::Left), ..Default::default() };
text::rewrite_block(&mut doc, 0, 0, Some("דוב"), &left).unwrap();
let line = text::text_lines(&reopen(&doc), 0).unwrap().swap_remove(0);
assert!((line.rect[0] - before[0]).abs() < 0.5, "left-aligned: {:?} vs {before:?}", line.rect);
}
/// Letters the page's font lacks are drawn with the Hebrew face from craft-fonts; a build
/// without it says so and changes nothing. Hebrew points are refused, not misdrawn.
#[test]
fn hebrew_letters_the_page_font_lacks_use_the_hebrew_face() {
let mut doc = hebrew_page("BT /F1 12 Tf 300 700 Td (EDCBA) Tj ET");
let before = page_content_bytes(&doc, 0);
let pointed = text::replace_line(&mut doc, 0, 0, "שָׁלוֹם עוֹלָם");
let Err(EditError::Invalid(msg)) = pointed else { panic!("expected a clear error, got {pointed:?}") };
assert!(msg.contains("niqqud"), "{msg}");
assert_eq!(page_content_bytes(&doc, 0), before);
// ע and ה aren't in the page's font.
let typed = "שלום עולם, עמוד 12 (PDF)";
let line = text::replace_line(&mut doc, 0, 0, typed);
if pdfcraft_fonts::document_hebrew_font(false).is_none() {
let Err(EditError::Invalid(msg)) = line else { panic!("expected a clear error, got {line:?}") };
assert!(msg.contains("Hebrew fallback font") && msg.contains("CRAFT_FONTS_DIR"), "{msg}");
assert_eq!(page_content_bytes(&doc, 0), before);
eprintln!("built without a craft-fonts Hebrew face: the Type 3 path is checked with CRAFT_FONTS_DIR");
return;
}
assert_eq!(line.unwrap().substituted.as_deref(), Some("Noto Sans Hebrew Type3"));
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert!(content.contains("/PCHe"), "{content}");
let reopened = reopen(&doc);
assert_eq!(text::text_lines(&reopened, 0).unwrap()[0].text, typed);
// Every glyph has an outline, so nothing is drawn as a box.
let p = pdfcraft_model::pages(&reopened).swap_remove(0);
let res = reopened.resolve(p.dict.get(b"Resources").unwrap());
let fonts = reopened.resolve(res.as_dict().unwrap().get(b"Font").unwrap());
let font = reopened.resolve(fonts.as_dict().unwrap().get(b"PCHe").unwrap());
let procs = reopened.resolve(font.as_dict().unwrap().get(b"CharProcs").unwrap());
let procs = procs.as_dict().unwrap();
assert_eq!(procs.iter().count(), typed.chars().collect::<std::collections::HashSet<_>>().len());
for (_, glyph) in procs.iter() {
let pdfcraft_cos::Object::Stream(s) = &*reopened.resolve(glyph) else { panic!("glyph stream") };
let path = String::from_utf8(s.decoded().unwrap()).unwrap();
assert!(path.starts_with(|c: char| c.is_ascii_digit()) && path.contains(" d1"), "{path}");
}
// A whole paragraph too, right-aligned.
let mut doc = hebrew_page("BT /F1 12 Tf 300 700 Td (EDCBA) Tj ET");
assert_eq!(text::replace_block(&mut doc, 0, 0, "עברית מלאה").unwrap().substituted.as_deref(), Some("Noto Sans Hebrew Type3"));
assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap()[0].text, "עברית מלאה");
}
/// #125: page-content tools refuse text the standard fonts can't draw instead of writing `?`,
/// and leave the document untouched.
#[test]
fn page_text_the_standard_fonts_cant_draw_is_refused() {
let mut doc = fixture();
let before: Vec<Vec<String>> = (0..3).map(|p| streams(&reopen(&doc), p)).collect();
let text = AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: "日本語のテキスト".into(), ..AddedText::default() };
let err = add_content(&mut doc, 0, &Content::Text(text)).unwrap_err().to_string();
assert!(err.contains("\"日\" (U+65E5)"), "{err}");
let hf = HeaderFooter {
text: ["Ελληνικά".into(), String::new(), String::new(), String::new(), String::new(), String::new()],
..HeaderFooter::default()
};
assert!(add_header_footer(&mut doc, &[0, 1], &hf, true, &cx()).unwrap_err().to_string().contains("U+0395"));
let wm = Watermark { text: "机密".into(), ..Watermark::default() };
assert!(add_watermark(&mut doc, &[0], &wm, true).unwrap_err().to_string().contains("U+673A"));
let after: Vec<Vec<String>> = (0..3).map(|p| streams(&reopen(&doc), p)).collect();
assert_eq!(before, after, "a refused edit changes nothing");
// Western European text, including what only WinAnsi (not Latin-1) has, still works.
let text = AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: "Café — 5€ ™".into(), ..AddedText::default() };
add_content(&mut doc, 0, &Content::Text(text.clone())).unwrap();
// Retyping existing text is refused the same way, and the whole document is left as it was.
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
let retyped = AddedText { text: "Café 中".into(), ..text.clone() };
assert!(update_content(&mut doc, 0, 0, &Content::Text(retyped)).unwrap_err().to_string().contains("U+4E2D"));
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), saved, "a refused update changes nothing");
// A carriage return is checked where it would be drawn: added text splits on LF only, so
// CRLF would leave a `?`; headers and watermarks split CRLF into lines, but not a lone CR.
let crlf = AddedText { text: "A\r\nB".into(), ..text };
assert!(add_content(&mut doc, 0, &Content::Text(crlf)).unwrap_err().to_string().contains("U+000D"));
let hf = |t: &str| HeaderFooter {
text: [t.into(), String::new(), String::new(), String::new(), String::new(), String::new()],
..HeaderFooter::default()
};
add_header_footer(&mut doc, &[0], &hf("Left\r\n<<Bates Number#6#1#ACME-#>>"), true, &cx()).unwrap();
assert!(add_header_footer(&mut doc, &[0], &hf("A\rB"), true, &cx()).unwrap_err().to_string().contains("U+000D"));
// Arabic is shaped with the craft-fonts Arabic face (#403): drawable when that face is built
// in, refused (never written as `?`) when it isn't. Text left to the standard font still counts.
let arabic = |text: &str| AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: text.into(), ..AddedText::default() };
// U+061C is in the Arabic block but is a direction mark that's dropped, not shaped: it never
// makes text drawable, nor (with the face) undrawable.
assert!(add_content(&mut doc, 0, &Content::Text(arabic("\u{061C}中"))).is_err());
if pdfcraft_fonts::arabic_has('م') {
add_content(&mut doc, 0, &Content::Text(arabic("مرحبا Hello"))).unwrap();
add_content(&mut doc, 0, &Content::Text(arabic("\u{061C}Hello"))).unwrap();
for text in ["مرحبا 中", "\u{061C}中"] {
let err = add_content(&mut doc, 0, &Content::Text(arabic(text))).unwrap_err().to_string();
assert!(err.contains("U+4E2D"), "{text:?}: {err}");
}
} else {
// #403's own refusal says what's missing; the Add-text editor still keeps the draft.
let err = add_content(&mut doc, 0, &Content::Text(arabic("مرحبا Hello"))).unwrap_err().to_string();
assert!(err.contains("CRAFT_FONTS_DIR"), "{err}");
assert_eq!(first_undrawable(&arabic("مرحبا Hello")), Some('م'));
// An existing item may still take Arabic (#403 keeps it movable), but nothing else that
// the standard font would write as `?`.
let n = list_added(&doc).iter().filter(|a| a.page == 0).count();
add_content(&mut doc, 0, &Content::Text(arabic("x"))).unwrap();
let err = update_content(&mut doc, 0, n, &Content::Text(arabic("ب中"))).unwrap_err().to_string();
assert!(err.contains("U+4E2D"), "{err}");
update_content(&mut doc, 0, n, &Content::Text(arabic("ب"))).unwrap();
}
}
fn xobject(doc: &Document, page: usize, name: &[u8]) -> Option<pdfcraft_cos::ObjRef> {
let p = &pdfcraft_model::pages(doc)[page];
let res = doc.resolve(p.dict.get(b"Resources")?);
let xo = doc.resolve(res.as_dict()?.get(b"XObject")?);
xo.as_dict()?.get(name).and_then(Object::as_ref)
}
fn annot_at(doc: &Document, page: usize, index: usize) -> pdfcraft_cos::ObjRef {
let p = &pdfcraft_model::pages(doc)[page];
let list = doc.resolve(p.dict.get(b"Annots").unwrap());
list.as_array().unwrap()[index].as_ref().unwrap()
}
#[test]
fn fill_sign_flatten_bakes_marks_keeps_other_comments_and_does_not_reuse_pcfl0() {
use pdfcraft_annot::{FillMark, Markup, Meta, NewAnnotation, Shape, Style, add_annotation, summaries};
let mut doc = fixture();
assert_eq!(flatten_fill_sign(&mut doc, &[0]).unwrap(), 0);
assert!(!doc.is_modified(), "nothing to flatten leaves the file alone");
let meta = Meta { date: None, id: "x".into() };
let add = |doc: &mut Document, shape: Shape, contents: &str| {
let style = Style::default_for(&shape);
add_annotation(doc, &NewAnnotation { page: 0, shape, style, contents: contents.into(), author: "a".into() }, &meta).unwrap()
};
add(&mut doc, Shape::Rectangle { rect: [10.0, 10.0, 80.0, 40.0] }, "box");
assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1);
let kept = xobject(&doc, 0, b"PCFl0").expect("the first flatten's XObject");
add(&mut doc, Shape::Typewriter { rect: [20.0, 500.0, 140.0, 520.0], font_size: 10.0 }, "Hello");
doc.update_dict(annot_at(&doc, 0, 0), |d| {
d.remove(b"PCFillSign");
})
.unwrap();
add(&mut doc, Shape::Mark { rect: [100.0, 100.0, 120.0, 120.0], mark: FillMark::Check }, "");
add(&mut doc, Shape::Signature { strokes: vec![vec![[10.0, 50.0], [40.0, 80.0], [70.0, 50.0]]] }, "");
doc.update_dict(annot_at(&doc, 0, 2), |d| d.set(b"Subj".to_vec(), pdfcraft_cos::PdfString::text("Pencil"))).unwrap();
add(&mut doc, Shape::TypedSignature { rect: [20.0, 40.0, 90.0, 70.0], contours: vec![vec![[0.1, 0.2], [0.5, 0.9], [0.9, 0.2]]] }, "");
add(&mut doc, Shape::TextMarkup { kind: Markup::Highlight, quads: vec![[20.0, 400.0, 80.0, 400.0, 20.0, 390.0, 80.0, 390.0]] }, "keep");
add(&mut doc, Shape::TextBox { rect: [20.0, 300.0, 140.0, 340.0], font_size: 12.0 }, "Note");
add(&mut doc, Shape::Ink { strokes: vec![vec![[200.0, 200.0], [220.0, 220.0], [240.0, 200.0]]] }, "pencil");
let old = add(&mut doc, Shape::Signature { strokes: vec![vec![[300.0, 50.0], [330.0, 80.0], [360.0, 50.0]]] }, "");
doc.update_dict(annot_at(&doc, 0, old), |d| {
d.remove(b"PCFillSign");
})
.unwrap();
let hidden = add(&mut doc, Shape::Typewriter { rect: [20.0, 600.0, 140.0, 620.0], font_size: 10.0 }, "secret");
doc.update_dict(annot_at(&doc, 0, hidden), |d| d.set(b"F".to_vec(), Object::Int(2))).unwrap();
let n = flatten_fill_sign(&mut doc, &[0]).unwrap();
assert_eq!(n, 5, "text, check, both signatures and the typed signature; the hidden one stays");
assert_eq!(xobject(&doc, 0, b"PCFl0"), Some(kept), "an existing PCFl0 is not replaced");
let newest = streams(&doc, 0).last().unwrap().clone();
assert!(newest.contains("/PCFl1 Do") && !newest.contains("/PCFl0 Do"), "{newest}");
let doc = reopen(&doc);
let mut left: Vec<String> = summaries(&doc).iter().map(|s| s.contents.clone().unwrap_or_default()).collect();
left.sort();
assert_eq!(left, ["Note", "keep", "pencil", "secret"]);
assert!(summaries(&doc).iter().any(|s| s.intent.as_deref() == Some("FreeTextTypeWriter")), "the hidden typewriter stays");
assert_eq!(summaries(&doc).iter().filter(|s| s.intent.as_deref() == Some("FreeTextTypeWriter")).count(), 1);
}
/// A one-page document from its objects (object 1 is the catalog).
fn build(objs: &[&str]) -> Document {
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 stream(dict: &str, data: &str) -> String {
format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len())
}
/// #314: text and images drawn by (nested) form XObjects are read for export, placed through
/// every form matrix and set in each form's own fonts; editing still sees only the page's text.
#[test]
fn reading_follows_form_xobjects() {
let page = stream("", "q 1 0 0 1 10 0 cm /Fm1 Do Q BT /F1 12 Tf 72 100 Td (Page text) Tj ET");
let fm1 = stream(
"/Type /XObject /Subtype /Form /BBox [0 0 600 800] /Matrix [1 0 0 1 0 50] /Resources << /Font << /F1 7 0 R >> /XObject << /Fm2 8 0 R >> >>",
"BT /F1 18 Tf 72 700 Td (Hello from a test invoice) Tj ET /Fm2 Do",
);
let fm2 = stream(
"/Type /XObject /Subtype /Form /BBox [0 0 300 400] /Matrix [2 0 0 2 0 0] /Resources << /Font << /F2 9 0 R >> /XObject << /Im1 10 0 R >> >>",
"BT /F2 10 Tf 50 100 Td (Nested) Tj ET q 20 0 0 10 5 5 cm /Im1 Do Q",
);
let image = stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", "A");
let doc = build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << /Font << /F1 5 0 R >> /XObject << /Fm1 6 0 R >> >> /Contents 4 0 R >>",
&page,
"<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>",
&fm1,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
&fm2,
"<< /Type /Font /Subtype /Type1 /BaseFont /Times-Bold >>",
&image,
]);
let close = |a: f64, b: f64| (a - b).abs() < 0.01;
let blocks = text::reading_blocks(&doc, 0).unwrap();
let texts: Vec<&str> = blocks.iter().map(|b| b.text.as_str()).collect();
assert_eq!(texts, ["Hello from a test invoice", "Nested", "Page text"]);
let (hello, nested, own) = (&blocks[0], &blocks[1], &blocks[2]);
// Hello: (72, 700) moved by the form's (0, 50) and the page's (10, 0).
assert_eq!(hello.base_font, "Helvetica", "the form's own /F1, not the page's");
assert!(close(hello.rect[0], 82.0) && close(hello.size, 18.0), "{hello:?}");
// Nested: (50, 100) doubled by Fm2, then Fm1 and the page: (110, 250), 20 pt.
assert_eq!(nested.base_font, "Times-Bold");
assert!(nested.bold);
assert!(close(nested.rect[0], 110.0) && close(nested.size, 20.0), "{nested:?}");
assert_eq!(own.base_font, "Courier");
let images = images::reading_images(&doc, 0).unwrap();
assert_eq!(images.len(), 1, "{images:?}");
let r = images[0].rect;
assert!(close(r[0], 20.0) && close(r[1], 60.0) && close(r[2], 60.0) && close(r[3], 80.0), "{r:?}");
assert_eq!((images[0].width, images[0].height), (1, 1));
// Editing works on the page's own streams only, unchanged.
let editable: Vec<String> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
assert_eq!(editable, ["Page text"]);
// The page draws one figure of its own (Fm1, edited as a whole); the image inside Fm2 is not
// a page image.
let own = images::page_images(&doc, 0).unwrap();
assert_eq!(own.len(), 1, "{own:?}");
assert!(own[0].is_form && own[0].object == Some(pdfcraft_cos::ObjRef::new(6, 0)), "{own:?}");
}
/// A chain of forms, each drawing the next twenty times, twelve deep: within the depth cap but
/// 20^11 visits without a budget. Reading stops at the page's visit budget instead of hanging.
#[test]
fn form_xobjects_that_fan_out_stop_at_the_visit_budget() {
let font = "/Font << /F1 5 0 R >>";
let levels = 12;
let mut objs = vec![
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << /Font << /F1 5 0 R >> /XObject << /N 6 0 R >> >> /Contents 4 0 R >>"
.to_string(),
stream("", "/N Do"),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
];
for level in 0..levels {
let next = 7 + level;
objs.push(if level + 1 == levels {
stream(&format!("/Subtype /Form /BBox [0 0 9 9] /Resources << {font} >>"), "BT /F1 10 Tf 10 10 Td (leaf) Tj ET")
} else {
stream(&format!("/Subtype /Form /BBox [0 0 9 9] /Resources << {font} /XObject << /N {next} 0 R >> >>"), &"/N Do ".repeat(20))
});
}
let refs: Vec<&str> = objs.iter().map(String::as_str).collect();
let doc = build(&refs);
let start = std::time::Instant::now();
let _ = text::reading_blocks(&doc, 0).unwrap();
assert!(images::reading_images(&doc, 0).unwrap().is_empty());
assert!(start.elapsed() < std::time::Duration::from_secs(30), "took {:?}", start.elapsed());
}
/// Forms that draw themselves or each other, have no resources, a malformed matrix, or are
/// missing are read once (or skipped) without panicking or looping.
#[test]
fn hostile_form_xobjects_are_read_once() {
let page = stream("", "/Loop Do /A Do /NoRes Do /Bad Do /Missing Do /F1 Do");
let font = "/Font << /F1 5 0 R >>";
let lp = stream(
&format!("/Subtype /Form /BBox [0 0 9 9] /Resources << {font} /XObject << /Loop 6 0 R >> >>"),
"/Loop Do BT /F1 10 Tf 10 10 Td (once) Tj ET",
);
let a = stream(
&format!("/Subtype /Form /BBox [0 0 9 9] /Resources << {font} /XObject << /B 8 0 R >> >>"),
"/B Do BT /F1 10 Tf 10 100 Td (from a) Tj ET",
);
let b = stream(
&format!("/Subtype /Form /BBox [0 0 9 9] /Resources << {font} /XObject << /A 7 0 R >> >>"),
"/A Do BT /F1 10 Tf 10 200 Td (from b) Tj ET",
);
let nores = stream("/Subtype /Form /BBox [0 0 9 9]", "BT /F1 10 Tf 10 300 Td (inherited) Tj ET");
let bad = stream(&format!("/Subtype /Form /BBox [0 0 9 9] /Matrix [1 0 0] /Resources << {font} >>"), "BT /F1 10 Tf 10 400 Td (bad matrix) Tj ET");
let doc = build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << /Font << /F1 5 0 R >> /XObject << /Loop 6 0 R /A 7 0 R /NoRes 9 0 R /Bad 10 0 R >> >> /Contents 4 0 R >>",
&page,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
&lp,
&a,
&b,
&nores,
&bad,
]);
let texts: Vec<String> = text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
assert_eq!(texts, ["once", "from b", "from a", "inherited", "bad matrix"]);
assert!(images::reading_images(&doc, 0).unwrap().is_empty());
}
#[test]
fn group_moves_preserve_glyph_bytes_and_unselected_text_state() {
for content in [
"BT /F1 12 Tf 1.3 Tc 2 Tw 80 Tz 14 TL 1 0.2 -0.1 1 50 600 Tm (A\\101) Tj 1 0 0 rg (Neighbour) Tj T* (Next) Tj ET",
"BT /F1 12 Tf 14 TL 50 600 Td (Before) Tj 0 0 1 rg (Quoted) ' 1 0 0 rg (After) Tj ET",
"BT /F1 12 Tf 14 TL 50 600 Td (Before) Tj 0 0 1 rg 2 1 (Quoted) \" 1 0 0 rg [(After) -120 (wards)] TJ T* (Last) Tj ET",
"q 1.2 0.3 -0.1 0.9 20 30 cm BT /F1 12 Tf 50 600 Td [(A) -120 (B)] TJ 1 0 0 rg (After) Tj ET Q %keep-this-comment\n",
"BT /F1 12.3456789 Tf 81.23456789 Tz 0.123456789 Ts 14 TL 50 600 Td (Before) Tj 0 0 1 rg 2.123456789 1.987654321 (Quoted) \" 1 0 0 rg (After) Tj ET",
] {
let mut doc = text_page(content);
let before = text::text_lines(&doc, 0).unwrap();
let blocks = text::text_blocks(&doc, 0).unwrap();
let index = blocks.iter().position(|b| b.text.contains("Quoted")).unwrap_or(0);
let selected: Vec<_> = blocks[index].lines.iter().map(|i| before[*i].text.clone()).collect();
let glyph_bytes = |doc: &Document| {
pdfcraft_content::parse(&page_content_bytes(doc, 0))
.ops
.iter()
.flat_map(|op| {
op.operands
.iter()
.flat_map(|o| match o {
Object::String(s) => vec![s.bytes.clone()],
Object::Array(a) => a.iter().filter_map(|v| v.as_string().map(|s| s.bytes.clone())).collect(),
_ => Vec::new(),
})
.collect::<Vec<_>>()
})
.collect::<Vec<_>>()
};
let original_bytes = glyph_bytes(&doc);
let target = ObjectTarget { kind: ObjectKind::Text, index };
move_objects(&mut doc, 0, &[target], [35.0, -27.0]).unwrap();
let doc = reopen(&doc);
assert_eq!(glyph_bytes(&doc), original_bytes, "original codes and kerning strings survive");
let after = text::text_lines(&doc, 0).unwrap();
assert_eq!(after.len(), before.len(), "{content}");
for old in before {
let new = after.iter().find(|l| l.text == old.text).unwrap();
let expected = if selected.contains(&old.text) {
[old.rect[0] + 35.0, old.rect[1] - 27.0, old.rect[2] + 35.0, old.rect[3] - 27.0]
} else {
old.rect
};
assert!(new.rect.iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-6), "{}: {:?} != {:?}", old.text, new.rect, expected);
assert_eq!((new.font.clone(), new.color), (old.font, old.color));
}
if content.contains("%keep-this-comment") {
assert!(String::from_utf8_lossy(&page_content_bytes(&doc, 0)).contains("%keep-this-comment"));
}
}
}
#[test]
fn group_moves_preserve_small_offsets_under_large_transforms() {
let mut doc = image_page();
let page = pdfcraft_model::pages(&doc)[0].obj;
let content = doc.add(Object::Stream(Stream::flate(
Dict::new(),
b"q 100000 0 0 100000 100 100 cm /Im0 Do Q q 10000 0 0 10000 0 0 cm BT /F1 0.00123456789 Tf 0.02 0.06 Td (Small) Tj 1 0 0 rg (Neighbour) Tj ET Q",
)));
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(content))).unwrap();
let before = editable_objects(&doc, 0).unwrap();
let targets = before.iter().filter(|o| o.text.as_deref() != Some("Neighbour")).map(|o| o.target).collect::<Vec<_>>();
assert_eq!(targets.len(), 2);
let offset = [0.00123456789, -0.00987654321];
move_objects(&mut doc, 0, &targets, offset).unwrap();
let after = editable_objects(&reopen(&doc), 0).unwrap();
for old in before {
let new = after.iter().find(|o| o.target == old.target).unwrap();
let [dx, dy] = if targets.contains(&old.target) { offset } else { [0.0, 0.0] };
assert!(close(new.rect, [old.rect[0] + dx, old.rect[1] + dy, old.rect[2] + dx, old.rect[3] + dy]), "{old:?}: {new:?}");
}
}
#[test]
fn mixed_object_inventory_does_not_select_added_content_twice() {
let mut doc = image_page();
add_content(&mut doc, 0, &Content::Text(AddedText { text: "Added label".into(), rect: [50.0, 350.0, 200.0, 380.0], ..AddedText::default() }))
.unwrap();
let before = editable_objects(&doc, 0).unwrap();
assert_eq!(before.len(), 3, "one native paragraph, one native image, one added text");
let targets = before.iter().map(|o| o.target).collect::<Vec<_>>();
let image = images::page_images(&doc, 0).unwrap()[0].object.unwrap();
let original_image = doc.get(image).clone();
move_objects(&mut doc, 0, &targets, [20.0, -15.0]).unwrap();
let doc = reopen(&doc);
let after = editable_objects(&doc, 0).unwrap();
assert_eq!(after.len(), before.len());
for old in before {
let new = after.iter().find(|o| o.target == old.target).unwrap();
assert!(close(new.rect, [old.rect[0] + 20.0, old.rect[1] - 15.0, old.rect[2] + 20.0, old.rect[3] - 15.0]));
assert_eq!(new.text, old.text);
}
assert_eq!(doc.get(image).clone(), original_image, "pixel data is shared, not recompressed");
}
#[test]
fn group_move_refusals_leave_the_object_graph_untouched() {
let mut doc = image_page();
let target = editable_objects(&doc, 0).unwrap()[0].target;
let original = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap();
for (targets, offset) in [
(vec![], [1.0, 2.0]),
(vec![target, target], [1.0, 2.0]),
(vec![target, ObjectTarget { kind: ObjectKind::Image, index: usize::MAX }], [1.0, 2.0]),
(vec![target], [f64::NAN, 2.0]),
(vec![target], [f64::INFINITY, 2.0]),
(vec![target], [0.0, 0.0]),
(vec![target], [1e300, 2.0]),
(vec![target; MAX_MOVE_OBJECTS + 1], [1.0, 2.0]),
] {
assert!(move_objects(&mut doc, 0, &targets, offset).is_err());
assert_eq!(pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(), original);
}
let mut clipped = text_page("BT /F1 12 Tf 50 600 Td (Ordinary) Tj ET BT /F1 12 Tf 4 Tr 50 400 Td (Clipping) Tj ET");
let targets = editable_objects(&clipped, 0).unwrap().iter().map(|o| o.target).collect::<Vec<_>>();
let original = pdfcraft_cos::write_full(&clipped, &SaveOptions::default()).unwrap();
assert!(move_objects(&mut clipped, 0, &targets, [10.0, 5.0]).unwrap_err().to_string().contains("clipping"));
assert_eq!(pdfcraft_cos::write_full(&clipped, &SaveOptions::default()).unwrap(), original);
}
#[test]
fn added_and_native_objects_move_together_on_cropped_rotated_user_unit_pages() {
for rotation in [0, 90, 180, 270] {
let mut doc = image_page();
let page = pdfcraft_model::pages(&doc)[0].obj;
doc.update_dict(page, |d| {
d.set(b"CropBox".to_vec(), Object::Array([10, 20, 500, 750].map(Object::Int).to_vec()));
d.set(b"Rotate".to_vec(), Object::Int(rotation));
d.set(b"UserUnit".to_vec(), Object::Int(2));
})
.unwrap();
add_content(&mut doc, 0, &Content::Text(AddedText { text: "Added".into(), rect: [60.0, 100.0, 180.0, 125.0], ..AddedText::default() }))
.unwrap();
let before = editable_objects(&doc, 0).unwrap();
move_objects(&mut doc, 0, &before.iter().map(|o| o.target).collect::<Vec<_>>(), [12.0, -8.0]).unwrap();
let after = editable_objects(&reopen(&doc), 0).unwrap();
for (a, b) in before.iter().zip(after) {
assert!(
close(b.rect, [a.rect[0] + 12.0, a.rect[1] - 8.0, a.rect[2] + 12.0, a.rect[3] - 8.0]),
"rotation {rotation}: {:?} -> {:?}",
a.rect,
b.rect
);
}
}
}
#[test]
fn group_moves_keep_split_stream_tokens_and_shared_form_resources() {
let mut doc = split_streams_page();
let before = text::text_lines(&doc, 0).unwrap();
let objects: Vec<_> = editable_objects(&doc, 0).unwrap().iter().map(|o| o.target).collect();
move_objects(&mut doc, 0, &objects, [30.0, -20.0]).unwrap();
let doc = reopen(&doc);
let after = text::text_lines(&doc, 0).unwrap();
assert_eq!(before.len(), after.len());
for (a, b) in before.iter().zip(&after) {
assert_eq!(a.text, b.text);
assert!(close(b.rect, [a.rect[0] + 30.0, a.rect[1] - 20.0, a.rect[2] + 30.0, a.rect[3] - 20.0]));
}
let joined = page_content_bytes(&doc, 0);
let ops = pdfcraft_content::parse(&joined).ops;
assert_eq!(ops.iter().find(|o| o.is("BDC")).unwrap().operands.len(), 2);
assert!(ops.iter().any(|o| o.is("EMC")));
// Two pages share a content stream and a Form; only one placement on one page moves.
let mut doc = fixture();
let mut form = Dict::new();
form.set(b"Subtype".to_vec(), Object::name("Form"));
form.set(b"BBox".to_vec(), Object::Array([0, 0, 60, 30].map(Object::Int).to_vec()));
form.set(b"PrivateData".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"retain this".to_vec())));
let artwork = doc.add(Object::Stream(Stream::flate(form, b"1 0 0 rg 0 0 60 30 re f")));
doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| {
let mut xo = Dict::new();
xo.set(b"Figure".to_vec(), Object::Ref(artwork));
d.set(b"XObject".to_vec(), Object::Dict(xo));
})
.unwrap();
doc.set(
pdfcraft_cos::ObjRef::new(7, 0),
Object::Stream(Stream::flate(
Dict::new(),
b"q 1 0 0 1 40 80 cm /Figure Do Q q 1 0 0 1 300 400 cm /Figure Do Q BT /F1 9 Tf 40 160 Td (Caption) Tj ET",
)),
);
let original_form = doc.get(artwork);
let source_page = page_content_bytes(&doc, 1);
let images = images::page_images(&doc, 0).unwrap();
move_objects(
&mut doc,
0,
&[ObjectTarget { kind: ObjectKind::Image, index: 0 }, ObjectTarget { kind: ObjectKind::Text, index: 0 }],
[15.0, -10.0],
)
.unwrap();
let after = images::page_images(&doc, 0).unwrap();
assert!(close(after[0].rect, [55.0, 70.0, 115.0, 100.0]));
assert_eq!(after[1].rect, images[1].rect);
assert_eq!(*doc.get(artwork), *original_form);
assert_eq!(page_content_bytes(&doc, 1), source_page);
}
#[test]
fn group_moves_preserve_encoded_glyphs_and_refuse_vertical_cmaps() {
let mut doc = text_page("BT /F1 12 Tf 40 400 Td <004100420043> Tj ET");
let mut font = Dict::from_iter([
(b"Type".to_vec(), Object::name("Font")),
(b"Subtype".to_vec(), Object::name("Type0")),
(b"BaseFont".to_vec(), Object::name("SyntheticCID")),
(b"Encoding".to_vec(), Object::name("Identity-H")),
]);
let unicode = doc.add(Object::Stream(Stream::flate(
Dict::new(),
b"1 begincodespacerange <0000> <ffff> endcodespacerange 3 beginbfchar <0041> <0041> <0042> <0042> <0043> <0043> endbfchar",
)));
font.set(b"ToUnicode".to_vec(), Object::Ref(unicode));
font.set(
b"DescendantFonts".to_vec(),
Object::Array(vec![Object::Dict(Dict::from_iter([(b"Subtype".to_vec(), Object::name("CIDFontType2")), (b"DW".to_vec(), Object::Int(600))]))]),
);
// No external font asset: this synthetic font is enough to exercise raw CID bytes.
let p = pdfcraft_model::pages(&doc)[0].obj;
let mut resources = Dict::new();
let mut fonts = Dict::new();
fonts.set(b"F1".to_vec(), Object::Dict(font.clone()));
resources.set(b"Font".to_vec(), Object::Dict(fonts));
doc.update_dict(p, |d| d.set(b"Resources".to_vec(), Object::Dict(resources.clone()))).unwrap();
let original = text::text_lines(&doc, 0).unwrap();
assert!(!original.is_empty());
move_objects(&mut doc, 0, &[ObjectTarget { kind: ObjectKind::Text, index: 0 }], [25.0, 15.0]).unwrap();
let encoded =
|doc: &Document| pdfcraft_content::parse(&page_content_bytes(doc, 0)).ops.into_iter().find(|o| o.is("Tj")).unwrap().operands[0].clone();
assert_eq!(encoded(&doc).as_string().unwrap().bytes, [0, 65, 0, 66, 0, 67]);
let after = text::text_lines(&doc, 0).unwrap();
assert_eq!(original[0].text, after[0].text);
for encoding in [
Object::name("Identity-V"),
Object::Stream(Stream::flate(Dict::new(), b"/WMode 1 def")),
Object::Stream(Stream::flate(Dict::from_iter([(b"WMode".to_vec(), Object::Int(1))]), b"")),
] {
let mut f = font.clone();
f.set(b"Encoding".to_vec(), encoding);
let mut fonts = Dict::new();
fonts.set(b"F1".to_vec(), Object::Dict(f));
resources.set(b"Font".to_vec(), Object::Dict(fonts));
doc.update_dict(p, |d| d.set(b"Resources".to_vec(), Object::Dict(resources.clone()))).unwrap();
let before = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap();
assert!(move_objects(&mut doc, 0, &[ObjectTarget { kind: ObjectKind::Text, index: 0 }], [5.0, 5.0]).is_err());
assert_eq!(pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(), before);
}
}
#[test]
fn moving_added_content_after_page_rotation_preserves_streams_and_resources() {
let mut doc = text_page("BT /F1 12 Tf 40 400 Td (Native) Tj ET");
added::add_content(
&mut doc,
0,
&Content::Text(added::AddedText { text: "Added label".into(), rect: [50.0, 300.0, 200.0, 330.0], ..Default::default() }),
)
.unwrap();
let p = pdfcraft_model::pages(&doc)[0].obj;
let resources = pdfcraft_model::pages(&doc)[0].dict.get(b"Resources").cloned();
let item = added::list_added(&doc)[0].clone();
let source = stream_bytes(&doc, &Object::Ref(item.obj)).unwrap();
let content_after_cm = |bytes: &[u8]| {
let ops = pdfcraft_content::parse(bytes).ops;
bytes[ops[1].span.end..].to_vec()
};
for rotation in [90, 180, 270, 0] {
doc.update_dict(p, |d| d.set(b"Rotate".to_vec(), Object::Int(rotation))).unwrap();
let before_lines = text::text_lines(&doc, 0).unwrap();
let before_objects = editable_objects(&doc, 0).unwrap();
let a = before_objects.iter().find(|o| o.target.kind == ObjectKind::Added).unwrap();
// The logical added box must contain the actual source glyph geometry after rotation.
let label = before_lines.iter().find(|l| l.text == "Added label").unwrap();
assert!(
label.rect[0] >= a.rect[0] - 0.01
&& label.rect[2] <= a.rect[2] + 0.01
&& label.rect[1] >= a.rect[1] - 0.01
&& label.rect[3] <= a.rect[3] + 0.01
);
move_objects(&mut doc, 0, &[a.target], [13.0, -17.0]).unwrap();
let after_lines = text::text_lines(&doc, 0).unwrap();
for (old, new) in before_lines.iter().zip(&after_lines) {
let delta = if old.text == "Added label" { [13.0, -17.0] } else { [0.0, 0.0] };
assert_eq!(old.text, new.text);
assert!(close(new.rect, [old.rect[0] + delta[0], old.rect[1] + delta[1], old.rect[2] + delta[0], old.rect[3] + delta[1]]));
}
let current = added::list_added(&doc)[0].clone();
let bytes = stream_bytes(&doc, &Object::Ref(current.obj)).unwrap();
assert_eq!(content_after_cm(&bytes), content_after_cm(&source), "all drawing bytes after the placement, including glyphs, remain identical");
assert_eq!(pdfcraft_model::pages(&doc)[0].dict.get(b"Resources").cloned(), resources);
doc = reopen(&doc);
}
}
#[test]
fn group_moves_of_shared_added_streams_change_only_the_selected_page() {
let mut doc = fixture();
added::add_content(
&mut doc,
0,
&Content::Text(added::AddedText { text: "Shared added label".into(), rect: [40.0, 400.0, 240.0, 420.0], ..Default::default() }),
)
.unwrap();
let pages = pdfcraft_model::pages(&doc);
let contents = pages[0].dict.get(b"Contents").cloned().unwrap();
doc.update_dict(pages[1].obj, |d| d.set(b"Contents".to_vec(), contents)).unwrap();
let doc = reopen(&doc);
let mut doc = doc;
let other = page_content_bytes(&doc, 1);
let before = editable_objects(&doc, 0).unwrap();
let target = before.iter().find(|o| o.target.kind == ObjectKind::Added).unwrap().target;
move_objects(&mut doc, 0, &[target], [15.0, -20.0]).unwrap();
assert_eq!(page_content_bytes(&doc, 1), other, "a shared stream is copied on write");
assert_ne!(added::list_added(&doc).iter().find(|a| a.page == 0).unwrap().obj, added::list_added(&doc).iter().find(|a| a.page == 1).unwrap().obj);
}
#[test]
fn group_moves_handle_added_content_under_inherited_graphics_state() {
let mut doc = text_page("2 0 0 2 10 20 cm BT /F1 12 Tf 40 250 Td (Native) Tj ET");
added::add_content(
&mut doc,
0,
&Content::Text(added::AddedText { text: "Added label".into(), rect: [50.0, 100.0, 200.0, 130.0], ..Default::default() }),
)
.unwrap();
let before = text::text_lines(&doc, 0).unwrap();
let target = editable_objects(&doc, 0).unwrap().iter().find(|o| o.target.kind == ObjectKind::Added).unwrap().target;
move_objects(&mut doc, 0, &[target], [30.0, -20.0]).unwrap();
let after = text::text_lines(&doc, 0).unwrap();
for (old, new) in before.iter().zip(&after) {
let delta = if old.text == "Added label" { [30.0, -20.0] } else { [0.0, 0.0] };
assert!(close(new.rect, [old.rect[0] + delta[0], old.rect[1] + delta[1], old.rect[2] + delta[0], old.rect[3] + delta[1]]));
}
}
#[test]
fn group_selection_refuses_incomplete_content_and_resource_budget_overruns() {
for data in [
format!("{} BT /F1 12 Tf 40 400 Td (Target) Tj ET", "q ".repeat(1025)),
format!("{} BT /F1 12 Tf 40 400 Td (Target) Tj ET", "n ".repeat(100001)),
] {
let mut doc = text_page(&data);
let before = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap();
assert!(editable_objects(&doc, 0).is_err());
assert!(move_objects(&mut doc, 0, &[ObjectTarget { kind: ObjectKind::Text, index: 0 }], [10.0, 10.0]).is_err());
assert_eq!(pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(), before);
}
let mut doc = text_page("BT /F1 12 Tf 40 400 Td (Target) Tj ET");
let page = pdfcraft_model::pages(&doc)[0].obj;
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(pdfcraft_cos::ObjRef::new(999999, 0)))).unwrap();
assert!(editable_objects(&doc, 0).is_err(), "a missing stream cannot produce a partial inventory");
let mut filter = Dict::new();
filter.set(b"Filter".to_vec(), Object::name("NotAFilter"));
let broken = doc.add(Object::Stream(Stream::from_raw(filter, vec![1, 2, 3])));
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(broken))).unwrap();
assert!(editable_objects(&doc, 0).is_err(), "an undecodable stream is refused");
}
#[test]
fn group_move_budget_refusals_keep_the_original_page_selectable() {
let text = text_page(&format!("{} BT /F1 12 Tf 40 400 Td (Target) Tj ET", "n ".repeat(99_995)));
assert_eq!(pdfcraft_content::parse(&page_content_bytes(&text, 0)).ops.len(), 100_000);
let mut image = image_page();
let page = pdfcraft_model::pages(&image)[0].obj;
let bytes = format!("{} /Im0 Do {}", "q ".repeat(1024), "Q ".repeat(1024));
let content = image.add(Object::Stream(Stream::flate(Dict::new(), bytes.as_bytes())));
image.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(content))).unwrap();
for mut doc in [text, image] {
let objects = editable_objects(&doc, 0).unwrap();
assert_eq!(objects.len(), 1);
let before = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap();
let error = move_objects(&mut doc, 0, &[objects[0].target], [10.0, 5.0]).unwrap_err();
assert!(error.to_string().contains("too many content operations") || error.to_string().contains("nested too deeply"), "{error}");
assert_eq!(pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(), before);
assert_eq!(editable_objects(&doc, 0).unwrap(), objects, "refusal retains a usable inventory");
}
}