pdfcraft/crates/edit/src/tests.rs

2051 lines
108 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");
}
#[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);
}
/// 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 {
let content = "q 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) 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 >> /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()
}
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:?}");
}
/// #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");
}
}