Files
pdfcraft/crates/edit/src/tests.rs
T
Ridwan 9b5624afc1 fix: Issue #4017. Flattening removes empty fields it cannot draw (#4033)
* M6: flattening removes empty fields it cannot draw (#4017)

Flattening kept every widget without an appearance so that its value
was not lost with nothing drawn in its place (#1794). A field without a
value has nothing to lose, yet it stayed in /AcroForm /Fields and on the
page, so a flattened file could still be filled in.

A widget without an appearance now stays only when its field has a
value: its /V, or the nearest one inherited through /Parent. A missing
value, an empty string or an empty array is no value. Comments and
valued widgets are unchanged.

Fixes #4017

* M6: flattening keeps a widget whose value is past the parent limit (#4017)

has_value walks at most 64 levels up /Parent. When it ran out it said
"no value", so a widget whose /V sat on the 64th ancestor was removed.
Running out now counts as a value: one that wasn't checked must not be
removed.
2026-10-23 10:42:08 -04:00

6641 lines
360 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_full, 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 an_image_turns_by_any_angle_about_its_centre_and_saves_the_matrix() {
// 100,100..300,200 is 200 x 100 around (200, 150).
let mut doc = image_page();
let r0 = images::page_images(&doc, 0).unwrap()[0].rect;
let t = images::turn_by_degrees(r0, 30.0).unwrap();
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;
let (c, s) = (30f64.to_radians().cos(), 30f64.to_radians().sin());
let (w, h) = (200.0 * c + 100.0 * s, 200.0 * s + 100.0 * c);
assert!(close(r, [200.0 - w / 2.0, 150.0 - h / 2.0, 200.0 + w / 2.0, 150.0 + h / 2.0]), "{r:?}");
// 90 degrees is the quarter turn; whole turns and non-finite angles are harmless.
let q = images::turn_about_centre(r0, 1, false, false);
let d = images::turn_by_degrees(r0, 90.0).unwrap();
assert!(q.iter().zip(&d).all(|(a, b)| (a - b).abs() < 1e-9), "{q:?} {d:?}");
let full = images::turn_by_degrees(r0, 720.0).unwrap();
assert!(full.iter().zip([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]).all(|(a, b)| (a - b).abs() < 1e-9));
assert!(images::turn_by_degrees(r0, f64::NAN).is_none() && images::turn_by_degrees(r0, f64::INFINITY).is_none());
}
#[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("<<yyyy/mm/dd>> <<yyyy/m/d>>", 1, 1, 0, &c), "2026/10/01 2026/10/1");
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:?}");
// The close starts on a new line, so a comment the original ends in can't swallow it.
assert_eq!((s0[0].as_str(), s0[2].as_str()), ("q %PdfCraft\n", "\nQ %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_and_footer_are_drawn_in_the_chosen_standard_face() {
// #3754: Times or Courier instead of Helvetica. The content names the face, the page's fonts
// gain it, and right-aligned text is placed by that face's widths (not Helvetica's).
let text = "Wide words";
for (font, resource, base) in [(added::Family::Times, "PCTimes", "Times-Roman"), (added::Family::Courier, "PCCour", "Courier")] {
let mut doc = fixture();
let hf = HeaderFooter {
text: [String::new(), String::new(), text.into(), String::new(), String::new(), String::new()],
font,
..HeaderFooter::default()
};
add_header_footer(&mut doc, &[0], &hf, false, &cx()).unwrap();
let doc = reopen(&doc);
let mark = streams(&doc, 0).last().cloned().unwrap();
assert!(mark.contains(&format!("/{resource} 8 Tf")), "{base}: {mark}");
let x = 600.0 - 72.0 - font.width(text, 8.0, false, false);
assert!((x - (600.0 - 72.0 - pdfcraft_fonts::helvetica_width(text, 8.0))).abs() > 1.0, "{base}: the faces differ in width");
assert!(mark.contains(&format!("1 0 0 1 {} ", n(x))), "{base}: right-aligned by its own widths: {mark}");
let page = &pdfcraft_model::pages(&doc)[0];
let res = doc.resolve(page.dict.get(b"Resources").unwrap());
let fonts = doc.resolve(res.as_dict().unwrap().get(b"Font").unwrap()).as_dict().cloned().unwrap();
let face = doc.resolve(fonts.get(resource.as_bytes()).unwrap()).as_dict().cloned().unwrap();
assert_eq!(face.name(b"BaseFont"), Some(base.as_bytes()), "{base}");
}
// The default is still Helvetica, and only Helvetica is added.
let mut doc = fixture();
let hf =
HeaderFooter { text: [text.into(), String::new(), String::new(), String::new(), String::new(), String::new()], ..HeaderFooter::default() };
add_header_footer(&mut doc, &[0], &hf, false, &cx()).unwrap();
let mark = streams(&reopen(&doc), 0).last().cloned().unwrap();
assert!(mark.contains("/PCHelv 8 Tf") && !mark.contains("/PCTimes") && !mark.contains("/PCCour"), "{mark}");
}
#[test]
fn a_header_never_takes_over_a_font_name_the_page_already_draws_with() {
// From review of #3756: a page that already draws a different font as /PCTimes keeps it; the
// header's Times goes under a free name, and an update reuses that name, not a third one.
let mut doc = fixture();
let page = pdfcraft_model::pages(&doc)[0].obj;
let mut fonts = Dict::new();
let mut theirs = Dict::new();
theirs.set(b"Type".to_vec(), Object::name("Font"));
theirs.set(b"Subtype".to_vec(), Object::name("Type1"));
theirs.set(b"BaseFont".to_vec(), Object::name("Helvetica-Bold"));
fonts.set(b"PCTimes".to_vec(), Object::Dict(theirs));
let mut res = Dict::new();
res.set(b"Font".to_vec(), Object::Dict(fonts));
doc.update_dict(page, |d| d.set(b"Resources".to_vec(), Object::Dict(res))).unwrap();
let hf = HeaderFooter {
text: ["Serif".into(), String::new(), String::new(), String::new(), String::new(), String::new()],
font: added::Family::Times,
..HeaderFooter::default()
};
let base_of = |doc: &Document, name: &[u8]| {
let fonts = page_fonts(doc, &pdfcraft_model::pages(doc)[0]);
fonts.get(name).map(|f| doc.resolve(f)).and_then(|f| f.as_dict().and_then(|d| d.name(b"BaseFont")).map(<[u8]>::to_vec))
};
add_header_footer(&mut doc, &[0], &hf, false, &cx()).unwrap();
let doc = reopen(&doc);
assert_eq!(base_of(&doc, b"PCTimes").as_deref(), Some(&b"Helvetica-Bold"[..]), "the page's own font is untouched");
assert_eq!(base_of(&doc, b"PCTimes_1").as_deref(), Some(&b"Times-Roman"[..]));
let mark = streams(&doc, 0).last().cloned().unwrap();
assert!(mark.contains("/PCTimes_1 8 Tf"), "{mark}");
// Updating the header finds its own name again.
let mut doc = doc;
add_header_footer(&mut doc, &[0], &hf, true, &cx()).unwrap();
let doc = reopen(&doc);
assert!(streams(&doc, 0).last().unwrap().contains("/PCTimes_1 8 Tf"));
assert_eq!(base_of(&doc, b"PCTimes_2"), None, "no new name on update");
assert_eq!(base_of(&doc, b"PCTimes").as_deref(), Some(&b"Helvetica-Bold"[..]));
// The same face with more to it (here a /ToUnicode map) is the page's own too: kept whole.
let mut doc = fixture();
let page = pdfcraft_model::pages(&doc)[0].obj;
let map = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), b"/CIDInit /ProcSet findresource begin")));
let mut theirs = Dict::new();
for (k, v) in [("Type", "Font"), ("Subtype", "Type1"), ("BaseFont", "Times-Roman"), ("Encoding", "WinAnsiEncoding")] {
theirs.set(k.as_bytes().to_vec(), Object::name(v));
}
theirs.set(b"ToUnicode".to_vec(), Object::Ref(map));
let mut fonts = Dict::new();
fonts.set(b"PCTimes".to_vec(), Object::Dict(theirs));
let mut res = Dict::new();
res.set(b"Font".to_vec(), Object::Dict(fonts));
doc.update_dict(page, |d| d.set(b"Resources".to_vec(), Object::Dict(res))).unwrap();
add_header_footer(&mut doc, &[0], &hf, false, &cx()).unwrap();
let doc = reopen(&doc);
let fonts = page_fonts(&doc, &pdfcraft_model::pages(&doc)[0]);
let kept = doc.resolve(fonts.get(b"PCTimes").unwrap()).as_dict().cloned().unwrap();
assert!(kept.contains(b"ToUnicode"), "the page's map is kept");
assert!(streams(&doc, 0).last().unwrap().contains("/PCTimes_1 8 Tf"));
}
#[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 header_bates_start_that_would_overflow_is_refused_without_drawing() {
// A Bates start of u64::MAX has no room for a second page. The run must be refused up front
// instead of overflowing (a panic in debug builds, a wrap to 0 in release builds).
let mut doc = fixture();
let hf = HeaderFooter {
text: [String::new(), String::new(), "<<Bates Number#6#18446744073709551615#A#B>>".into(), String::new(), String::new(), String::new()],
..HeaderFooter::default()
};
let err = add_header_footer(&mut doc, &[0, 1], &hf, false, &cx()).unwrap_err();
assert!(err.to_string().contains("Bates"), "{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");
}
/// The six numbers of the first `… cm` in `content`.
fn first_cm(content: &str) -> [f64; 6] {
let line = content.lines().find(|l| l.ends_with(" cm")).unwrap_or_else(|| panic!("no cm in {content:?}"));
let v: Vec<f64> = line.split_whitespace().take(6).map(|x| x.parse().unwrap()).collect();
<[f64; 6]>::try_from(v).unwrap()
}
fn map_point(m: [f64; 6], x: f64, y: f64) -> (f64, f64) {
(m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5])
}
fn near(a: (f64, f64), b: (f64, f64)) -> bool {
(a.0 - b.0).abs() < 0.01 && (a.1 - b.1).abs() < 0.01
}
fn shrinking(header: &str, footer: &str) -> HeaderFooter {
let mut hf = HeaderFooter { shrink: true, ..HeaderFooter::default() };
hf.text[1] = header.into();
hf.text[5] = footer.into();
hf
}
#[test]
fn shrink_scales_the_original_content_into_the_band_between_header_and_footer() {
let mut doc = fixture();
// 8 pt text, margins 36 top and bottom: the header takes 36 + 8 (+ 4 clearance) at the top,
// the footer 36 + 6.4 (its capitals above the baseline, + 4) at the bottom.
add_header_footer(&mut doc, &[0], &shrinking("ACME", "<<Bates Number#6#1##>>"), false, &cx()).unwrap();
let doc = reopen(&doc);
let s = streams(&doc, 0);
// q-wrapper, shrink, original, Q-wrapper, header/footer.
assert_eq!(s.len(), 5, "{s:?}");
assert_eq!((s[0].as_str(), s[3].as_str()), ("q %PdfCraft\n", "\nQ %PdfCraft\n"));
assert!(s[2].contains("(Hi) Tj"), "the original content follows the shrink: {s:?}");
let m = first_cm(&s[1]);
let k = (800.0 - 48.0 - 46.4) / 800.0;
assert!((m[0] - k).abs() < 1e-3 && (m[3] - k).abs() < 1e-3 && m[1] == 0.0 && m[2] == 0.0, "uniform scale {k}: {m:?}");
// The page's corners land on the band, centred across.
assert!(near(map_point(m, 0.0, 0.0), ((600.0 - 600.0 * k) / 2.0, 46.4)), "{m:?}");
assert!(near(map_point(m, 600.0, 800.0), ((600.0 + 600.0 * k) / 2.0, 800.0 - 48.0)), "{m:?}");
assert!(s[4].contains("(000001) Tj"), "{}", s[4]);
}
#[test]
fn shrink_follows_the_display_space_of_a_rotated_page() {
let mut doc = fixture();
// Page 2 is rotated 90°: shown 800 wide and 600 high. A footer alone keeps the top.
add_header_footer(&mut doc, &[1], &shrinking("", "<<1>>"), false, &cx()).unwrap();
let s = streams(&doc, 1);
let m = first_cm(&s[1]);
let page = &pdfcraft_model::pages(&doc)[1];
let v = page.view_matrix(&doc);
let k = (600.0 - 46.4) / 600.0;
// Display (x, y) is user v·(x, y): the shrunk content's display corners.
let to_user = |x: f64, y: f64| map_point(v, x, y);
assert!(near(map_point(m, to_user(0.0, 0.0).0, to_user(0.0, 0.0).1), to_user((800.0 - 800.0 * k) / 2.0, 46.4)), "{m:?}");
assert!(near(map_point(m, to_user(800.0, 600.0).0, to_user(800.0, 600.0).1), to_user((800.0 + 800.0 * k) / 2.0, 600.0)), "{m:?}");
}
#[test]
fn shrink_is_undone_by_remove_and_not_doubled_by_update() {
let mut doc = fixture();
let original = streams(&doc, 0);
add_background(&mut doc, &[0], &Background::default(), false).unwrap();
add_header_footer(&mut doc, &[0], &shrinking("First", ""), false, &cx()).unwrap();
add_header_footer(&mut doc, &[0], &shrinking("Second", ""), true, &cx()).unwrap();
let s = streams(&doc, 0);
assert_eq!(s.iter().filter(|x| x.contains(" cm") && !x.contains("/PCMark")).count(), 2, "one shrink besides the original's own cm: {s:?}");
assert!(s[0].contains("/PCMark /Background"), "the background stays behind, full size: {s:?}");
// Update without shrinking: back to full size.
let mut plain = shrinking("Third", "");
plain.shrink = false;
add_header_footer(&mut doc, &[0], &plain, true, &cx()).unwrap();
assert_eq!(streams(&doc, 0).len(), 5, "background, wrapper, original, wrapper, header: {:?}", streams(&doc, 0));
add_header_footer(&mut doc, &[0], &shrinking("Fourth", ""), true, &cx()).unwrap();
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 2, "the header and its shrink");
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 shrink_with_no_room_left_is_refused_without_drawing() {
let mut doc = fixture();
let mut hf = shrinking("Top", "Bottom");
hf.margins = [400.0, 400.0, 72.0, 72.0];
let err = add_header_footer(&mut doc, &[0, 1], &hf, false, &cx()).unwrap_err();
assert!(err.to_string().contains("no room"), "{err}");
assert!(!doc.is_modified());
// A page without content has nothing to shrink: it just gets the header.
let mut doc = fixture();
add_header_footer(&mut doc, &[2], &shrinking("Top", ""), false, &cx()).unwrap();
assert_eq!(streams(&doc, 2).len(), 1);
}
#[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 a_second_background_is_drawn_over_the_first() {
// #3591: the new background went in front of the old one in /Contents, so the old colour
// was painted over it and stayed.
let mut doc = fixture();
add_background(&mut doc, &[0], &Background { color: [0.0, 0.0, 1.0], opacity: 1.0, ..Background::default() }, false).unwrap();
add_background(&mut doc, &[0], &Background { color: [0.0, 1.0, 0.0], opacity: 1.0, ..Background::default() }, false).unwrap();
let s = streams(&doc, 0);
assert_eq!(s.len(), 3, "{s:?}");
assert!(s[0].contains("0 0 1 rg") && s[1].contains("0 1 0 rg"), "the newer background is painted last: {s:?}");
assert!(s[2].contains("(Hi) Tj"), "both stay behind the content: {s:?}");
}
#[test]
fn backgrounds_stay_below_watermarks_behind_the_content() {
// Behind the content, backgrounds come first, in the order added, and then watermarks, so a
// watermark added after a background shows and a later background doesn't cover it.
let mut doc = fixture();
add_background(&mut doc, &[0], &Background { color: [0.0, 0.0, 1.0], opacity: 1.0, ..Background::default() }, false).unwrap();
add_watermark(&mut doc, &[0], &Watermark { text: "DRAFT".into(), behind: true, ..Watermark::default() }, false).unwrap();
add_background(&mut doc, &[0], &Background { color: [0.0, 1.0, 0.0], opacity: 1.0, ..Background::default() }, false).unwrap();
let s = streams(&doc, 0);
assert_eq!(s.len(), 4, "{s:?}");
assert!(s[0].contains("0 0 1 rg") && s[1].contains("0 1 0 rg"), "backgrounds first, oldest first: {s:?}");
assert!(s[2].contains("/PCMark /Watermark") && s[3].contains("(Hi) Tj"), "then the watermark, then the content: {s:?}");
}
#[test]
fn a_watermark_behind_goes_under_one_on_top_of_a_page_without_content() {
// With no content of its own, the page's watermark on top is its first stream, which looks
// like a behind-mark; a watermark added behind later must still be painted under it.
let mut doc = fixture();
add_watermark(&mut doc, &[2], &Watermark { text: "TOP".into(), ..Watermark::default() }, false).unwrap();
add_watermark(&mut doc, &[2], &Watermark { text: "BEHIND".into(), behind: true, ..Watermark::default() }, false).unwrap();
let s = streams(&doc, 2);
assert_eq!(s.len(), 2, "{s:?}");
assert!(s[0].contains("(BEHIND) Tj") && s[1].contains("(TOP) Tj"), "the watermark behind is painted first: {s:?}");
}
#[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_eq!(EditError::NoSuchPage(9).to_string(), "page 10 does not exist", "the message counts pages from 1 (#1550)");
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");
}
/// #1317: a cloudy square this builder cannot draw stays on the page. A solid square, a hidden
/// cloudy square and a cloudy square that already has an appearance keep their previous results.
#[test]
fn flattening_keeps_a_cloudy_square_it_cannot_draw() {
let page = stream("", " ");
let form = "<< /Type /XObject /Subtype /Form /BBox [100 600 200 650] /Length 28 >>\nstream\n1 0 0 RG 100 600 100 50 re S\nendstream";
let case = |extra: &str, drawn: usize, left: bool| {
let annot = format!("<< /Type /Annot /Subtype /Square /Rect [100 600 200 650] /C [1 0 0] /Contents (Synthetic comment) {extra} >>");
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 600 800] /Resources << >> /Contents 5 0 R /Annots [4 0 R] >>",
&annot,
&page,
form,
]);
let before = streams(&doc, 0);
assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), drawn);
let doc = reopen(&doc);
let p = &pdfcraft_model::pages(&doc)[0];
assert_eq!(p.dict.contains(b"Annots"), left);
if drawn == 0 {
assert_eq!(streams(&doc, 0), before, "nothing was drawn in place of the comment");
}
};
case("/BE << /S /C /I 2 >>", 0, true);
case("/BE << /S /S >>", 1, false);
case("/BE << /S /C /I 2 >> /F 2", 0, true);
case("/BE << /S /C /I 2 >> /AP << /N 6 0 R >>", 1, false);
}
/// #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}");
}
/// #3175: a note without an appearance is drawn as a fixed-size icon at the upper-left corner of
/// its `/Rect`. A rectangle with no width (LaTeX `pdfcomment`) used to scale the icon to nothing
/// while flattening still counted it as drawn and deleted the note.
#[test]
fn flattening_a_note_with_a_zero_width_rect_draws_a_fixed_size_icon() {
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 6 0 R] >>",
&page,
"<< /Type /Annot /Subtype /Text /Rect [50 60 50 72] /Contents (Zero width) /Name /Comment /F 4 >>",
"<< /Type /Annot /Subtype /Text /Rect [10 20 150 120] /Contents (Large) /F 4 >>",
]);
assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 2, "both icons are drawn");
let doc = reopen(&doc);
let flat = streams(&doc, 0).pop().unwrap();
assert!(flat.contains("q 1 0 0 1 50 52 cm /PCFl0 Do Q"), "{flat}");
assert!(flat.contains("q 1 0 0 1 10 100 cm /PCFl1 Do Q"), "{flat}");
}
/// #1470: a form field whose widget is on no page (left behind by page extraction or combining)
/// has nothing to draw, but flattening every form field still takes it out of `/AcroForm /Fields`,
/// or other software keeps treating the flattened file as a form. A hidden widget on a page is
/// left alone, as before.
#[test]
fn flattening_fields_drops_the_ones_on_no_page() {
let page = stream("", " ");
let mut doc = build(&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 6 0 R 7 0 R 8 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 7 0 R] >>",
&page,
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (on_page) /Rect [10 10 150 40] /F 4 /AP << /N 10 0 R >> >>",
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (orphan) /Rect [10 50 150 80] /F 4 /AP << /N 10 0 R >> >>",
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (hidden) /Rect [10 90 150 120] /F 2 /AP << /N 10 0 R >> >>",
"<< /FT /Tx /T (parent) /Kids [9 0 R] >>",
"<< /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [10 130 150 160] /F 4 /AP << /N 10 0 R >> >>",
"<< /Type /XObject /Subtype /Form /BBox [0 0 140 30] /Length 0 >>\nstream\n\nendstream",
]);
assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1, "only the field on the page is drawn");
let doc = reopen(&doc);
let root = doc.root().unwrap();
let af = doc.resolve(doc.get(root).as_dict().unwrap().get(b"AcroForm").unwrap());
let fields = doc.resolve(af.as_dict().unwrap().get(b"Fields").unwrap());
let left: Vec<pdfcraft_cos::ObjRef> = fields.as_array().unwrap().iter().filter_map(|f| f.as_ref()).collect();
assert_eq!(left, [pdfcraft_cos::ObjRef::new(7, 0)], "only the hidden field on the page stays");
}
/// #1794: a form field whose widget has no usable appearance (none stored, or none for its
/// selected state) has nothing to draw. Flattening used to remove it anyway, losing its value
/// with no ink in its place. It stays, in the page and in the form, like a comment that can't be
/// drawn; a field with an appearance is still baked in and removed.
#[test]
fn flattening_keeps_a_valued_widget_it_cannot_draw() {
let page = stream("", " ");
let mut doc = build(&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 6 0 R 7 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 6 0 R 7 0 R] >>",
&page,
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (Ada) /Rect [10 10 150 40] /F 4 >>",
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Yes /AS /Yes /Rect [10 50 30 70] /F 4 /AP << /N << /Off 8 0 R >> >> >>",
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (drawn) /V (Bob) /Rect [10 90 150 120] /F 4 /AP << /N 8 0 R >> >>",
"<< /Type /XObject /Subtype /Form /BBox [0 0 140 30] /Length 0 >>\nstream\n\nendstream",
]);
assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1, "only the field with an appearance is drawn");
let doc = reopen(&doc);
let refs = |o: &Object| -> Vec<u32> { doc.resolve(o).as_array().unwrap().iter().filter_map(|f| f.as_ref()).map(|r| r.num).collect() };
let p = &pdfcraft_model::pages(&doc)[0];
assert_eq!(refs(p.dict.get(b"Annots").unwrap()), [5, 6], "the undrawable widgets stay on the page");
let root = doc.root().unwrap();
let af = doc.resolve(doc.get(root).as_dict().unwrap().get(b"AcroForm").unwrap());
assert_eq!(refs(af.as_dict().unwrap().get(b"Fields").unwrap()), [5, 6], "and in the form");
let value = |n: u32| doc.get(pdfcraft_cos::ObjRef::new(n, 0)).as_dict().unwrap().get(b"V").cloned();
assert_eq!(value(5), Some(Object::String(pdfcraft_cos::PdfString::literal(b"Ada".to_vec()))));
assert_eq!(value(6), Some(Object::name("Yes")));
}
/// #4017: a widget with no appearance whose field has no value (none, or an empty string) has
/// nothing to lose, so flattening removes it instead of leaving a fillable field behind. One whose
/// value it inherits from its parent field still stays (#1794).
#[test]
fn flattening_removes_an_empty_widget_it_cannot_draw() {
let page = stream("", " ");
let mut doc = build(&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 6 0 R 7 0 R 8 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 6 0 R 7 0 R 9 0 R] >>",
&page,
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (no_value) /Rect [10 10 150 40] /F 4 >>",
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (empty) /V () /Rect [10 50 150 80] /F 4 >>",
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (drawn) /Rect [10 90 150 120] /F 4 /AP << /N 10 0 R >> >>",
"<< /FT /Tx /T (parent) /V (Ada) /Kids [9 0 R] >>",
"<< /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [10 130 150 160] /F 4 >>",
"<< /Type /XObject /Subtype /Form /BBox [0 0 140 30] /Length 0 >>\nstream\n\nendstream",
]);
assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1, "only the field with an appearance is drawn");
let doc = reopen(&doc);
let refs = |o: &Object| -> Vec<u32> { doc.resolve(o).as_array().unwrap().iter().filter_map(|f| f.as_ref()).map(|r| r.num).collect() };
let p = &pdfcraft_model::pages(&doc)[0];
assert_eq!(refs(p.dict.get(b"Annots").unwrap()), [9], "only the widget with an inherited value stays on the page");
let root = doc.root().unwrap();
let af = doc.resolve(doc.get(root).as_dict().unwrap().get(b"AcroForm").unwrap());
assert_eq!(refs(af.as_dict().unwrap().get(b"Fields").unwrap()), [8], "and its field in the form");
}
/// #4017: when the walk up the parent fields reaches its limit before finding a value, the widget
/// stays: an unverified value must not be removed. Here `/V` sits on the 64th ancestor.
#[test]
fn flattening_keeps_a_widget_whose_value_is_past_the_parent_limit() {
let page = stream("", " ");
let mut objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [69 0 R] >> >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R] >>".into(),
page,
"<< /Type /Annot /Subtype /Widget /Parent 6 0 R /Rect [10 10 150 40] /F 4 >>".into(),
];
objs.extend((6..69).map(|n| format!("<< /Parent {} 0 R /Kids [{} 0 R] >>", n + 1, n - 1)));
objs.push("<< /FT /Tx /T (deep) /V (Ada) /Kids [68 0 R] >>".into());
let mut doc = build(&objs.iter().map(String::as_str).collect::<Vec<_>>());
flatten(&mut doc, &[0], false, true).unwrap();
let doc = reopen(&doc);
let annots = pdfcraft_model::pages(&doc)[0].dict.get(b"Annots").map(|a| doc.resolve(a).as_array().map_or(0, Vec::len));
assert_eq!(annots, Some(1), "the widget stays");
}
/// #1794: a reply this builder cannot draw stays on the page even when the comment it replies to
/// is flattened; the comment's pop-up still goes with it.
#[test]
fn flattening_keeps_an_undrawn_reply_to_a_flattened_comment() {
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 600 800] /Resources << >> /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R] >>",
&page,
"<< /Type /Annot /Subtype /Square /Rect [100 600 200 650] /C [1 0 0] /Contents (Synthetic comment) /AP << /N 8 0 R >> /Popup 7 0 R >>",
"<< /Type /Annot /Subtype /Square /Rect [100 500 200 550] /C [1 0 0] /Contents (Synthetic reply) /BE << /S /C /I 2 >> /IRT 5 0 R >>",
"<< /Type /Annot /Subtype /Popup /Rect [300 600 400 700] /Parent 5 0 R >>",
"<< /Type /XObject /Subtype /Form /BBox [100 600 200 650] /Length 28 >>\nstream\n1 0 0 RG 100 600 100 50 re S\nendstream",
]);
assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1, "the comment is drawn; the cloudy reply cannot be");
let doc = reopen(&doc);
let p = &pdfcraft_model::pages(&doc)[0];
let left: Vec<u32> = doc.resolve(p.dict.get(b"Annots").unwrap()).as_array().unwrap().iter().filter_map(|f| f.as_ref()).map(|r| r.num).collect();
assert_eq!(left, [6], "the undrawn reply stays; the comment and its pop-up are gone");
}
/// Flattening never makes a `?` permanent: it refuses, changing nothing, a text box without an
/// appearance whose text Helvetica can't draw (another program's), and a comment or form field
/// whose appearance PdfCraft drew with `?` (marked `PCUndrawn`). A comment another program drew
/// with a font that has those characters (an appearance, no mark) flattens as before.
#[test]
fn flattening_refuses_what_would_bake_in_question_marks() {
let page = stream("", " ");
let refused = |annot: &str, comments: bool, fields: bool, what: &str| {
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,
annot,
"<< /Type /XObject /Subtype /Form /BBox [0 0 140 30] /Length 0 >>\nstream\n\nendstream",
]);
let before = write_incremental(&doc, &SaveOptions::default()).unwrap();
let err = flatten(&mut doc, &[0], comments, fields).unwrap_err().to_string();
assert!(err.contains(what) && err.contains("page 1") && err.contains("\"欢\" (U+6B22)") && err.contains("nothing was flattened"), "{err}");
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before, "a refused flatten changes nothing");
assert!(pdfcraft_model::pages(&doc)[0].dict.contains(b"Annots"), "the annotation is still there");
};
refused(
"<< /Type /Annot /Subtype /FreeText /Rect [10 10 150 40] /DA (0 g /Helv 12 Tf) /Contents <FEFF6B228FCE> /F 4 >>",
true,
false,
"a comment",
);
refused(
"<< /Type /Annot /Subtype /FreeText /Rect [10 10 150 40] /DA (0 g /Helv 12 Tf) /Contents <FEFF6B228FCE> /F 4 /AP << /N 6 0 R >> /PCUndrawn <FEFF6B22> >>",
true,
false,
"a comment",
);
refused(
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V <FEFF6B228FCE> /Rect [10 10 150 40] /F 4 /AP << /N 6 0 R >> /PCUndrawn <FEFF6B22> >>",
false,
true,
"a form field",
);
// Drawn by another program, with a font that has 欢: no mark, so it flattens.
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 /FreeText /Rect [10 10 150 40] /DA (0 g /Helv 12 Tf) /Contents <FEFF6B228FCE> /F 4 /AP << /N 6 0 R >> >>",
"<< /Type /XObject /Subtype /Form /BBox [0 0 140 30] /Length 0 >>\nstream\n\nendstream",
]);
assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1);
}
/// Display space is in points, so on a `/UserUnit 2` page an item's box maps to half as many
/// user-space units. Items record the `/UserUnit` they were written for; one without it was
/// written when display space was in user-space units, and reads back scaled to points.
#[test]
fn added_items_follow_the_page_user_unit() {
let mut doc = fixture();
let page = pdfcraft_model::pages(&doc)[2].obj;
doc.update_dict(page, |d| d.set(b"UserUnit".to_vec(), Object::Int(2))).unwrap();
let text = AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: "Scaled".into(), size: 14.0, ..AddedText::default() };
add_content(&mut doc, 2, &Content::Text(text.clone())).unwrap();
add_content(&mut doc, 0, &Content::Text(text.clone())).unwrap();
let doc = reopen(&doc);
let all = list_added(&doc);
let on = |page: usize| all.iter().find(|a| a.page == page).unwrap();
let Content::Text(t) = &on(2).content else { panic!() };
assert_eq!((t.rect[0], t.rect[2], t.rect[3], t.size), (72.0, 300.0, 700.0, 14.0), "read back as written");
assert!(streams(&doc, 2).iter().any(|s| s.starts_with("q 0.5 0 0 0.5 0 0 cm")), "display points to user space");
let recorded = |doc: &Document, a: &Added| {
doc.get(a.obj).as_dict().and_then(|d| d.get(b"PCAdded").cloned()).and_then(|p| p.as_dict().and_then(|p| p.get(b"UserUnit").cloned()))
};
assert!(recorded(&doc, on(0)).is_none(), "a page without /UserUnit records nothing");
assert_eq!(recorded(&doc, on(2)).and_then(|u| u.as_f64()), Some(2.0));
// The same item as an earlier version wrote it: no record, box and size in user-space units.
let mut legacy = doc.clone();
let Object::Stream(mut s) = (*legacy.get(on(2).obj)).clone() else { panic!() };
let mut params = s.dict.get(b"PCAdded").and_then(|p| p.as_dict().cloned()).unwrap();
params.remove(b"UserUnit");
s.dict.set(b"PCAdded".to_vec(), Object::Dict(params));
legacy.set(on(2).obj, Object::Stream(s));
let Content::Text(old) = &list_added(&legacy).into_iter().find(|a| a.page == 2).unwrap().content else { panic!() };
assert_eq!((old.rect[0], old.rect[2], old.rect[3], old.size), (144.0, 600.0, 1400.0, 28.0), "scaled to points");
}
fn tagged_dictionary(tag: &str, added: Option<Object>) -> Dict {
let mut dict = Dict::new();
dict.set(b"PCMark".to_vec(), Object::name(tag));
if let Some(added) = added {
dict.set(b"PCAdded".to_vec(), added);
}
dict
}
fn tagged_stream(doc: &mut Document, tag: &str, added: Option<Object>) -> Object {
Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged_dictionary(tag, added), Vec::new()))))
}
fn added_text_params() -> Dict {
let mut params = Dict::new();
params.set(b"Rect".to_vec(), Object::Array([10, 20, 110, 40].into_iter().map(Object::Int).collect()));
params.set(b"Kind".to_vec(), Object::name("Text"));
params.set(b"Text".to_vec(), Object::String(pdfcraft_cos::PdfString::text("Added text")));
params.set(b"Size".to_vec(), Object::Int(12));
params
}
fn added_image_params(image: pdfcraft_cos::ObjRef) -> Dict {
let mut params = Dict::new();
params.set(b"Rect".to_vec(), Object::Array([10, 20, 110, 70].into_iter().map(Object::Int).collect()));
params.set(b"Kind".to_vec(), Object::name("Image"));
params.set(b"Image".to_vec(), Object::Ref(image));
params
}
fn set_page_contents(doc: &mut Document, contents: Object, user_unit: Option<Object>) {
let page = pdfcraft_model::pages(doc)[0].obj;
doc.update_dict(page, |dict| {
dict.set(b"Contents".to_vec(), contents);
if let Some(user_unit) = user_unit {
dict.set(b"UserUnit".to_vec(), user_unit);
}
})
.unwrap();
}
fn assert_marker_scan_matches_legacy(doc: &Document, label: &str, marks: &[MarkKind], added: usize) -> MarkerScan {
let expected_marks = marks_present(doc);
let expected_added = list_added(doc);
let scan = scan_markers(doc);
assert_eq!(scan.marks, expected_marks, "{label}: marks differ from the independent scan");
assert_eq!(scan.added, expected_added, "{label}: added content differs from the independent scan");
assert_eq!(scan.marks, marks, "{label}: fixture's mark order or deduplication is wrong");
assert_eq!(scan.added.len(), added, "{label}: fixture's added-item count is wrong");
scan
}
#[test]
fn combined_marker_scan_matches_the_independent_scans() {
let empty = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", "<< /Type /Page /Parent 2 0 R >>"]);
assert_marker_scan_matches_legacy(&empty, "empty page", &[], 0);
assert_marker_scan_matches_legacy(&fixture(), "ordinary streams and a page without contents", &[], 0);
for (tag, kind) in [("HeaderFooter", MarkKind::HeaderFooter), ("Watermark", MarkKind::Watermark), ("Background", MarkKind::Background)] {
let mut doc = fixture();
let marker = tagged_stream(&mut doc, tag, None);
set_page_contents(&mut doc, marker, None);
assert_marker_scan_matches_legacy(&doc, tag, &[kind], 0);
}
let mut all_marks = fixture();
let background = tagged_stream(&mut all_marks, "Background", None);
let watermark = tagged_stream(&mut all_marks, "Watermark", None);
let header = tagged_stream(&mut all_marks, "HeaderFooter", None);
set_page_contents(
&mut all_marks,
Object::Array(vec![background.clone(), watermark.clone(), header.clone(), watermark, header, background]),
None,
);
assert_marker_scan_matches_legacy(&all_marks, "all mark kinds repeated", &[MarkKind::Background, MarkKind::Watermark, MarkKind::HeaderFooter], 0);
let mut single_reference = fixture();
let marker = tagged_stream(&mut single_reference, "Watermark", None);
set_page_contents(&mut single_reference, marker, None);
assert_marker_scan_matches_legacy(&single_reference, "contents as one reference", &[MarkKind::Watermark], 0);
let mut text_only = fixture();
let text = tagged_stream(&mut text_only, "Added", Some(Object::Dict(added_text_params())));
set_page_contents(&mut text_only, text, None);
let text_scan = assert_marker_scan_matches_legacy(&text_only, "one added text item", &[], 1);
assert!(matches!(&text_scan.added[0].content, Content::Text(text) if text.text == "Added text"));
let mut indirect_params = fixture();
let params = indirect_params.add(Object::Dict(added_text_params()));
let text = tagged_stream(&mut indirect_params, "Added", Some(Object::Ref(params)));
set_page_contents(&mut indirect_params, text, None);
let indirect_scan = assert_marker_scan_matches_legacy(&indirect_params, "indirect PCAdded dictionary", &[], 1);
assert!(matches!(&indirect_scan.added[0].content, Content::Text(text) if text.text == "Added text"));
let mut image_only = fixture();
let image = image_only.add(Object::Stream(Stream::from_raw(Dict::new(), vec![0])));
let image_item = tagged_stream(&mut image_only, "Added", Some(Object::Dict(added_image_params(image))));
set_page_contents(&mut image_only, image_item, None);
let image_scan = assert_marker_scan_matches_legacy(&image_only, "one added image item", &[], 1);
assert!(matches!(&image_scan.added[0].content, Content::Image(item) if item.image == image));
let mut interleaved = fixture();
let text = tagged_stream(&mut interleaved, "Added", Some(Object::Dict(added_text_params())));
let watermark = tagged_stream(&mut interleaved, "Watermark", None);
let image = interleaved.add(Object::Stream(Stream::from_raw(Dict::new(), vec![0])));
let image_item = tagged_stream(&mut interleaved, "Added", Some(Object::Dict(added_image_params(image))));
let header = tagged_stream(&mut interleaved, "HeaderFooter", None);
set_page_contents(&mut interleaved, Object::Array(vec![text, watermark, image_item, header]), None);
let interleaved_scan =
assert_marker_scan_matches_legacy(&interleaved, "marks and added content interleaved", &[MarkKind::Watermark, MarkKind::HeaderFooter], 2);
assert!(matches!(&interleaved_scan.added[0].content, Content::Text(_)));
assert!(matches!(&interleaved_scan.added[1].content, Content::Image(_)));
let mut array_contents = fixture();
let background = tagged_stream(&mut array_contents, "Background", None);
let array_ref = array_contents.add(Object::Array(vec![background]));
set_page_contents(&mut array_contents, Object::Ref(array_ref), None);
assert_marker_scan_matches_legacy(&array_contents, "contents as an indirect array", &[MarkKind::Background], 0);
let mut malformed = fixture();
let indirect_mark = malformed.add(Object::Dict(tagged_dictionary("HeaderFooter", None)));
let missing_added = tagged_stream(&mut malformed, "Added", None);
let invalid_added = tagged_stream(&mut malformed, "Added", Some(Object::String(pdfcraft_cos::PdfString::text("bad params"))));
let unknown = tagged_stream(&mut malformed, "FutureKind", None);
let direct_watermark = Object::Dict(tagged_dictionary("Watermark", None));
let direct_added = Object::Dict(tagged_dictionary("Added", Some(Object::Dict(added_text_params()))));
set_page_contents(
&mut malformed,
Object::Array(vec![
Object::Null,
Object::Int(7),
Object::name("not-a-stream"),
direct_watermark,
Object::Ref(indirect_mark),
missing_added,
invalid_added,
unknown,
direct_added,
]),
None,
);
assert_marker_scan_matches_legacy(
&malformed,
"malformed, non-stream, unknown and direct entries",
&[MarkKind::Watermark, MarkKind::HeaderFooter],
0,
);
let mut shared = fixture();
let added = tagged_stream(&mut shared, "Added", Some(Object::Dict(added_text_params())));
let mark = tagged_stream(&mut shared, "Background", None);
set_page_contents(&mut shared, Object::Array(vec![added.clone(), mark.clone(), added.clone(), mark]), None);
let shared_scan = assert_marker_scan_matches_legacy(&shared, "repeated shared stream references", &[MarkKind::Background], 2);
assert_eq!(shared_scan.added[0].obj, shared_scan.added[1].obj);
let mut user_unit = fixture();
let added = tagged_stream(&mut user_unit, "Added", Some(Object::Dict(added_text_params())));
set_page_contents(&mut user_unit, added, Some(Object::Int(2)));
let unit_scan = assert_marker_scan_matches_legacy(&user_unit, "non-1.0 UserUnit", &[], 1);
let Content::Text(text) = &unit_scan.added[0].content else { panic!() };
assert_eq!(text.rect, [20.0, 40.0, 220.0, 80.0]);
assert_eq!(text.size, 24.0);
}
#[test]
fn added_text_and_images_are_page_content_that_stays_editable() {
let mut doc = fixture();
let text = AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: "Approved by Ada\nSecond line".into(), size: 14.0, ..AddedText::default() };
assert_eq!(add_content(&mut doc, 0, &Content::Text(text.clone())).unwrap(), 0);
// An 1×1 gray image, placed on the rotated page.
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("XObject"));
d.set(b"Subtype".to_vec(), Object::name("Image"));
d.set(b"Width".to_vec(), Object::Int(1));
d.set(b"Height".to_vec(), Object::Int(1));
d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
let img = doc.add(Object::Stream(Stream::from_raw(d, vec![128])));
add_content(&mut doc, 1, &Content::Image(AddedImage::new([10.0, 10.0, 110.0, 60.0], img))).unwrap();
let doc2 = reopen(&doc);
let all = list_added(&doc2);
assert_eq!(all.len(), 2);
let Content::Text(t) = &all[0].content else { panic!() };
assert_eq!((t.text.as_str(), t.size), ("Approved by Ada\nSecond line", 14.0));
assert_eq!(t.rect, [72.0, 700.0 - 2.0 * 14.0 * 1.2, 300.0, 700.0], "the box height follows the two lines");
let page0 = streams(&doc2, 0).join("\n");
assert!(page0.contains("(Approved by Ada) Tj") && page0.contains("(Second line) Tj") && page0.contains("/PCFHelvetica 14 Tf"), "{page0}");
// The rotated page draws in display space: the view matrix comes first.
let page1 = streams(&doc2, 1).join("\n");
assert!(page1.contains("q 0 1 -1 0 600 0 cm") && page1.contains(&format!("/PCImg{} Do", img.num)), "{page1}");
// Edit: move, restyle, retype; then delete.
let mut doc = doc2;
let moved = AddedText {
rect: [100.0, 500.0, 300.0, 520.0],
text: "Approved".into(),
bold: true,
family: Family::Times,
align: Align::Right,
color: [1.0, 0.0, 0.0],
..text
};
update_content(&mut doc, 0, 0, &Content::Text(moved)).unwrap();
let page0 = streams(&doc, 0).join("\n");
assert!(page0.contains("/PCFTimesBold 14 Tf 1 0 0 rg") && !page0.contains("Second line"), "{page0}");
assert!(
update_content(&mut doc, 1, 0, &Content::Text(AddedText { text: "x".into(), rect: [0.0, 0.0, 50.0, 10.0], ..AddedText::default() })).is_err(),
"kinds don't change"
);
delete_content(&mut doc, 0, 0).unwrap();
assert_eq!(list_added(&doc).len(), 1);
assert!(!streams(&doc, 0).join("").contains("Approved"));
assert!(add_content(&mut doc, 0, &Content::Text(AddedText { rect: [0.0, 0.0, 100.0, 10.0], ..AddedText::default() })).is_err(), "empty text");
// Page marks ignore added items.
assert!(marks_present(&doc).is_empty());
}
#[test]
fn added_arabic_text_is_shaped_in_display_order_and_stays_searchable() {
let mut doc = fixture();
let text =
AddedText { rect: [72.0, 600.0, 400.0, 700.0], text: "مرحبا World".into(), size: 14.0, align: Align::Right, ..AddedText::default() };
if pdfcraft_fonts::document_arabic_font().is_none() {
eprintln!("skipping the Arabic drawing checks: built without a craft-fonts Arab face (set CRAFT_FONTS_DIR)");
let err = add_content(&mut doc, 0, &Content::Text(text)).unwrap_err();
assert!(matches!(&err, EditError::Invalid(m) if m.contains("CRAFT_FONTS_DIR")), "{err:?}");
assert!(!doc.is_modified(), "nothing is written when the font is missing");
return;
}
add_content(&mut doc, 0, &Content::Text(text.clone())).unwrap();
// A second Arabic item on the same page keeps its own fonts.
add_content(&mut doc, 0, &Content::Text(AddedText { text: "شكرا".into(), rect: [72.0, 500.0, 400.0, 520.0], ..text.clone() })).unwrap();
let doc = reopen(&doc);
let added = list_added(&doc);
let Content::Text(t) = &added[0].content else { panic!() };
assert_eq!(t.text, "مرحبا World", "the logical text is kept for editing");
let s = streams(&doc, 0);
let item = s.iter().find(|s| s.contains("(World) Tj")).unwrap();
// A right-to-left paragraph: "World" is drawn left of the Arabic word.
let x_before = |s: &str, at: usize| -> f64 {
let tm = s[..at].rfind("1 0 0 1 ").unwrap() + 8;
s[tm..].split(' ').next().unwrap().parse().unwrap()
};
let latin_x = x_before(item, item.find("(World) Tj").unwrap());
let arabic_at = item.find("/PCAr").unwrap();
let arabic_x = x_before(item, item[arabic_at..].find(" Tj").unwrap() + arabic_at);
assert!(latin_x < arabic_x, "{item}");
// The Arabic glyphs come from Type 3 fonts whose ToUnicode maps give back each letter.
let p = &pdfcraft_model::pages(&doc)[0];
let res = doc.resolve(p.dict.get(b"Resources").unwrap());
let fonts = doc.resolve(res.as_dict().unwrap().get(b"Font").unwrap()).as_dict().unwrap().clone();
let arabic: Vec<_> = fonts.iter().filter(|(k, _)| k.starts_with(b"PCAr")).map(|(_, v)| doc.resolve(v).as_dict().unwrap().clone()).collect();
assert_eq!(arabic.len(), 2, "one font per item");
let mut mapped = String::new();
for f in &arabic {
assert_eq!(f.name(b"Subtype"), Some(&b"Type3"[..]));
mapped += &String::from_utf8_lossy(&stream_bytes(&doc, f.get(b"ToUnicode").unwrap()).unwrap());
}
for letter in ["0645", "0631", "062D", "0628", "0627", "0634", "0643"] {
assert!(mapped.contains(&format!("<{letter}>")), "{letter} in {mapped}");
}
}
#[test]
fn added_hebrew_text_is_drawn_right_to_left_and_stays_searchable() {
let mut doc = fixture();
let text = AddedText {
rect: [72.0, 600.0, 400.0, 700.0],
text: "שלום (עולם) 2026 World".into(),
size: 14.0,
align: Align::Right,
..AddedText::default()
};
if pdfcraft_fonts::document_hebrew_font(false).is_none() {
eprintln!("skipping the Hebrew drawing checks: built without a craft-fonts Hebr face (set CRAFT_FONTS_DIR)");
assert_eq!(first_undrawable(&text), Some('ש'));
let err = add_content(&mut doc, 0, &Content::Text(text)).unwrap_err();
assert!(matches!(&err, EditError::Invalid(m) if m.contains("U+05E9")), "{err:?}");
assert!(!doc.is_modified(), "nothing is written when the font is missing");
return;
}
assert_eq!(first_undrawable(&text), None);
add_content(&mut doc, 0, &Content::Text(text.clone())).unwrap();
// Points stay on their letters; a Hebrew line in an Arabic item and Arabic in a Hebrew one.
for other in ["שָׁלוֹם", "שלום مرحبا"] {
let other = AddedText { text: other.into(), rect: [72.0, 500.0, 400.0, 520.0], ..text.clone() };
if other.text.chars().any(|c| ('\u{0600}'..='\u{06FF}').contains(&c)) && pdfcraft_fonts::document_arabic_font().is_none() {
continue;
}
add_content(&mut doc, 0, &Content::Text(other)).unwrap();
}
let doc = reopen(&doc);
let added = list_added(&doc);
let Content::Text(t) = &added[0].content else { panic!() };
assert_eq!(t.text, "שלום (עולם) 2026 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", typed last, is drawn left of the Hebrew.
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 hebrew_at = item.find("/PCAr").unwrap();
let hebrew_x = x_before(item, item[hebrew_at..].find(" Tj").unwrap() + hebrew_at);
assert!(latin_x < hebrew_x, "{item}");
// Copy and search get the typing order back.
let logical: String = t.text.encode_utf16().map(|u| format!("{u:04X}")).collect();
assert!(item.contains(&format!("/ActualText <FEFF{logical}>")), "{item}");
// The Hebrew glyphs come from a Type 3 font named after the face, whose ToUnicode map gives
// 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 shaped: Vec<_> = fonts.iter().filter(|(k, _)| k.starts_with(b"PCAr")).map(|(_, v)| doc.resolve(v).as_dict().unwrap().clone()).collect();
let mut mapped = String::new();
for f in &shaped {
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 ["05E9", "05DC", "05D5", "05DD", "05E2"] {
assert!(mapped.contains(&format!("<{letter}>")), "{letter} in {mapped}");
}
let font_name = |f: &Dict| doc.resolve(f.get(b"FontDescriptor").unwrap()).as_dict().unwrap().name(b"FontName").map(<[u8]>::to_vec);
assert!(shaped.iter().any(|f| font_name(f).as_deref() == Some(&b"NotoSansHebrew"[..])));
// A pointed letter is one glyph with its points, mapped back whole.
assert!(mapped.contains("<05E905"), "{mapped}");
}
#[test]
fn odd_hebrew_text_never_panics() {
let mut doc = fixture();
let long = "שלום עולם ".repeat(2_000);
for s in ["\u{202E}ש\u{05B8}\u{05B8} (]", "\u{05B8}", "א\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_hebrew_font(false).is_some(), "{:?}: {r:?}", s.chars().take(12).collect::<String>());
}
}
/// 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 subsets of Arial: `/F2` and `/F3` are two subsets of the
/// same font, as Word writes a font split across several resources, and `/F4` repeats Helvetica.
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 /F3 7 0 R /F4 8 0 R /F5 9 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(),
// Another subset of the same Arial, with a subset tag of its own.
"<< /Type /Font /Subtype /TrueType /BaseFont /GHIJKL+Arial /FirstChar 97 /LastChar 99 /Widths [500 520 0] /Encoding /WinAnsiEncoding >>"
.into(),
// Helvetica again, in a second resource.
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
// No `/BaseFont`: `Metrics::from_dict` leaves its base-font name empty.
"<< /Type /Font /Subtype /Type1 /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 tracked_text_keeps_words_in_paragraphs_and_objects() {
let expected = "TRACKED SAMPLE TEXT";
for spacing in [1.5, 2.0001, 3.0] {
for scale in [50.0, 100.0, 150.0] {
for positioned in [false, true] {
let mut show = String::new();
if !positioned {
show.push('[');
}
for (i, c) in expected.chars().enumerate() {
if positioned {
show.push_str(&format!("({c}) Tj "));
if i + 1 < expected.len() {
show.push_str(&format!("[{}] TJ ", -spacing * 100.0));
}
} else {
show.push_str(&format!("({c}) "));
if i + 1 < expected.len() {
show.push_str(&format!("{} ", -spacing * 100.0));
}
}
}
if !positioned {
show.push_str("] TJ");
}
let doc = text_page(&format!("BT /F1 10 Tf {scale} Tz 72 700 Td {show} ET"));
assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, expected, "{spacing}, {scale}, {positioned}");
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, expected);
assert_eq!(crate::objects::editable_objects(&doc, 0).unwrap()[0].text.as_deref(), Some(expected));
}
}
let doc = text_page(&format!("BT /F1 10 Tf {spacing} Tc 72 700 Td ({expected}) Tj ET"));
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, expected);
}
}
#[test]
fn single_glyph_words_and_cells_are_not_tracking() {
for (content, expected) in [
("BT /F1 10 Tf 72 700 Td [(a) -1000 (b) -1000 (c) -1000 (d)] TJ ET", "a b c d"),
("BT /F1 10 Tf 72 700 Td [(1) -2500 (2) -2500 (3) -2500 (4) -2500 (5)] TJ ET", "1 2 3 4 5"),
("BT /F1 10 Tf 72 700 Td [(A) -300 (B) -1200 (C) -1200 (D) -1200 (E) -300 (F) -1200 (G)] TJ ET", "A B C D E F G"),
] {
assert_eq!(text::text_blocks(&text_page(content), 0).unwrap()[0].text, expected, "{content}");
}
let mut s = String::from("BT /F1 10 Tf ");
for (i, c) in "12345".chars().enumerate() {
s.push_str(&format!("1 0 0 1 {} 700 Tm ({c}) Tj ", 72 + i * 12));
}
s.push_str("ET");
assert_eq!(text::text_blocks(&text_page(&s), 0).unwrap()[0].text, "1 2 3 4 5");
}
#[test]
fn tracked_text_preserves_larger_word_gaps() {
for show in [
"[(T) -300 (E) -300 (S) -300 (T) -600 (W) -300 (O) -300 (R) -300 (D)] TJ",
"(T) Tj [-300] TJ (E) Tj [-300] TJ (S) Tj [-300] TJ (T) Tj [-600] TJ (W) Tj [-300] TJ (O) Tj [-300] TJ (R) Tj [-300] TJ (D) Tj",
"[(TEST) -300 (WORD)] TJ",
"(TEST) Tj [-300] TJ (W) Tj [-300] TJ (O) Tj [-300] TJ (R) Tj [-300] TJ (D) Tj",
] {
let doc = text_page(&format!("BT /F1 10 Tf 72 700 Td {show} ET"));
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, "TEST WORD", "{show}");
}
}
#[test]
fn tracked_text_positions_do_not_count_character_spacing_twice() {
let expected = "TRACKED SAMPLE TEXT";
for char_spacing in [0.0, 2.0] {
for transform in ["1 0 0 1 0 0", "0 1 -1 0 800 0"] {
let mut content = format!("q {transform} cm BT /F1 10 Tf {char_spacing} Tc ");
let mut x = 72.0;
for c in expected.chars() {
content.push_str(&format!("1 0 0 1 {x:.4} 700 Tm ({c}) Tj "));
x += pdfcraft_fonts::helvetica_width(&c.to_string(), 10.0) + 2.0001;
}
content.push_str("ET Q");
let doc = text_page(&content);
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, expected);
assert_eq!(crate::objects::editable_objects(&doc, 0).unwrap()[0].text.as_deref(), Some(expected));
}
}
}
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);
}
}
/// #2593: the craft-fonts faces are Japanese, and part of the Cyrillic (Uzbek қ ғ ҳ, Kazakh ә ө ү
/// ұ һ) is in none of them. The bundled Inter face has it, so the replacement is written instead of
/// being refused, and a build without craft-fonts can do it too.
#[test]
fn uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face() {
for replacement in ["The quick brown fox jumps over the lazy dog қизил", "әже", "ўзбекча ғўза"] {
for paragraph in [false, true] {
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
let edit =
if paragraph { text::replace_block(&mut doc, 0, 0, replacement) } else { text::replace_line(&mut doc, 0, 0, replacement) }.unwrap();
assert_eq!(edit.substituted.as_deref(), Some("Inter Type3"), "{replacement}: {edit:?}");
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert!(content.contains("/PCLat"), "{replacement}: {content}");
let lines = text::text_lines(&reopen(&doc), 0).unwrap();
assert_eq!(lines[0].text, replacement);
assert_eq!(lines[0].base_font, "Inter-Regular", "the generated font reports the face it drew");
}
}
}
/// The Japanese faces keep first refusal for the Cyrillic they do carry, so Russian text keeps the
/// face it was drawn with before the bundled Latin face was added.
#[test]
fn cyrillic_the_japanese_faces_carry_keeps_them() {
if without_craft_fonts("cyrillic_the_japanese_faces_carry_keeps_them") {
return;
}
let face = pdfcraft_fonts::document_japanese_font_for_style(false, false).unwrap();
if pdfcraft_fonts::japanese_glyph_from(face, 'ф').is_err() {
eprintln!("skipping: the Japanese fallback face has no Cyrillic");
return;
}
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
let edit = text::replace_line(&mut doc, 0, 0, "Привет, world").unwrap();
let expected = format!("{} Type3", face.family);
assert_eq!(edit.substituted.as_deref(), Some(expected.as_str()));
}
#[test]
fn japanese_paragraph_uses_unicode_type3_fallback() {
if without_craft_fonts("japanese_paragraph_uses_unicode_type3_fallback") {
return;
}
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
let replacement = "こんにちは、お元気ですか? 2.3444 日本語の文章";
text::replace_block(&mut doc, 0, 0, replacement).unwrap();
let reopened = reopen(&doc);
assert_eq!(text::text_blocks(&reopened, 0).unwrap()[0].text, replacement);
}
/// The first `n` Japanese characters, in code point order, that every craft-fonts face can draw, so
/// whichever face an edit picks has them all. Without craft-fonts there are no faces, every character
/// passes, and the text is still long.
fn covered_japanese_chars(n: usize) -> String {
let faces = pdfcraft_fonts::document_japanese_fonts_for_style(false, false);
(0x4E00..0xA000)
.filter_map(char::from_u32)
.filter(|ch| faces.iter().all(|face| pdfcraft_fonts::japanese_glyph_from(face, *ch).is_ok()))
.take(n)
.collect()
}
#[test]
fn fallback_fonts_nothing_shows_any_more_leave_with_a_rewrite() {
// `PCJp1` is selected by the page, `PCJp2` only by a form that uses the page's resources, `PCJp`
// by nothing; `PCJpOther` is a Type 3 font of the file's own that only shares the prefix.
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Some text) Tj ET BT /PCJp1 12 Tf ET q /Fm0 Do Q");
let generated = |doc: &mut Document, name: &str| {
doc.add(Object::Dict(Dict::from_iter([
(b"Type".to_vec(), Object::name("Font")),
(b"Subtype".to_vec(), Object::name("Type3")),
(b"Name".to_vec(), Object::name(name)),
])))
};
let (unused, page_shown, form_shown, own) =
(generated(&mut doc, "PCJapanese"), generated(&mut doc, "PCJapanese"), generated(&mut doc, "PCJapanese"), generated(&mut doc, "Other"));
let form = doc.add(Object::Stream(Stream::from_raw(
Dict::from_iter([(b"Subtype".to_vec(), Object::name("Form")), (b"BBox".to_vec(), Object::Array(vec![Object::Int(0); 4]))]),
b"BT /PCJp2 12 Tf ET".to_vec(),
)));
let p = pdfcraft_model::pages(&doc)[0].obj;
let mut fonts = Dict::from_iter([(b"F1".to_vec(), Object::Ref(pdfcraft_cos::ObjRef::new(5, 0)))]);
for (name, font) in [("PCJp", unused), ("PCJp1", page_shown), ("PCJp2", form_shown), ("PCJpOther", own)] {
fonts.set(name.as_bytes().to_vec(), Object::Ref(font));
}
let resources = Dict::from_iter([
(b"Font".to_vec(), Object::Dict(fonts)),
(b"XObject".to_vec(), Object::Dict(Dict::from_iter([(b"Fm0".to_vec(), Object::Ref(form))]))),
]);
doc.update_dict(p, |d| d.set(b"Resources".to_vec(), Object::Dict(resources.clone()))).unwrap();
let names = |doc: &Document| {
let page = pdfcraft_model::pages(doc).remove(0);
let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone();
let mut names: Vec<String> =
doc.resolve(res.get(b"Font").unwrap()).as_dict().unwrap().iter().map(|(n, _)| String::from_utf8_lossy(n).into_owned()).collect();
names.sort();
names
};
// A rewrite that doesn't reach the fallback fonts still drops the one nothing shows.
text::rewrite_block(&mut doc, 0, 0, Some("Other text"), &text::BlockStyle::default()).unwrap();
assert_eq!(names(&reopen(&doc)), ["F1", "PCJp1", "PCJp2", "PCJpOther"]);
// A form using the page's resources whose content can't be read might show any of them: they
// all stay.
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Some text) Tj ET q /Fm0 Do Q");
let unused = generated(&mut doc, "PCJapanese");
let broken = doc.add(Object::Stream(Stream::from_raw(
Dict::from_iter([
(b"Subtype".to_vec(), Object::name("Form")),
(b"BBox".to_vec(), Object::Array(vec![Object::Int(0); 4])),
(b"Filter".to_vec(), Object::name("FlateDecode")),
]),
b"not deflate data".to_vec(),
)));
let p = pdfcraft_model::pages(&doc)[0].obj;
let fonts = Dict::from_iter([(b"F1".to_vec(), Object::Ref(pdfcraft_cos::ObjRef::new(5, 0))), (b"PCJp".to_vec(), Object::Ref(unused))]);
let resources = Dict::from_iter([
(b"Font".to_vec(), Object::Dict(fonts)),
(b"XObject".to_vec(), Object::Dict(Dict::from_iter([(b"Fm0".to_vec(), Object::Ref(broken))]))),
]);
doc.update_dict(p, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap();
text::rewrite_block(&mut doc, 0, 0, Some("Other text"), &text::BlockStyle::default()).unwrap();
assert_eq!(names(&doc), ["F1", "PCJp"]);
}
#[test]
fn rewriting_japanese_twice_keeps_only_the_fallback_fonts_it_shows() {
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
if text::rewrite_block(&mut doc, 0, 0, Some("日本語の文章"), &text::BlockStyle::default()).is_err() {
assert!(pdfcraft_fonts::document_japanese_font().is_none());
eprintln!("built without a craft-fonts Japanese face: the Type 3 path is checked with CRAFT_FONTS_DIR");
return;
}
let first: Vec<_> = fallback_fonts(&doc).into_iter().map(|(_, f)| f).collect();
text::rewrite_block(&mut doc, 0, 0, Some("別の文章です"), &text::BlockStyle::default()).unwrap();
let doc = reopen(&doc);
let after = fallback_fonts(&doc);
assert!(!after.is_empty());
assert!(after.iter().all(|(_, f)| !first.contains(f)), "the first rewrite's fonts are gone");
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert!(after.iter().all(|(name, _)| content.contains(&format!("/{} ", String::from_utf8_lossy(name)))), "{content}");
}
/// The page's Type 3 fallback fonts (`PCJp`, `PCJp1`, ...) by resource name.
fn fallback_fonts(doc: &Document) -> Vec<(Vec<u8>, Dict)> {
let p = pdfcraft_model::pages(doc).swap_remove(0);
let res = doc.resolve(p.dict.get(b"Resources").unwrap());
let fonts = doc.resolve(res.as_dict().unwrap().get(b"Font").unwrap());
fonts
.as_dict()
.unwrap()
.iter()
.filter(|(name, _)| name.starts_with(b"PCJp"))
.map(|(name, font)| (name.clone(), doc.resolve(font).as_dict().unwrap().clone()))
.collect()
}
/// More unique fallback glyphs than one Type 3 font holds (240) are split across several generated
/// fonts, each within that limit, and the text still reads back as one line.
#[test]
fn japanese_replacement_over_240_unique_glyphs_is_split_across_fallback_fonts() {
let replacement = covered_japanese_chars(300);
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
if without_craft_fonts("japanese_replacement_over_240_unique_glyphs_is_split_across_fallback_fonts") {
// Without craft-fonts the long text gets the clear error, not the old limit message.
let err = text::replace_line(&mut doc, 0, 0, &replacement).unwrap_err().to_string();
assert!(err.contains("CRAFT_FONTS_DIR"), "{err}");
return;
}
text::replace_line(&mut doc, 0, 0, &replacement).unwrap();
let reopened = reopen(&doc);
let lines = text::text_lines(&reopened, 0).unwrap();
assert_eq!(lines.len(), 1, "one line: {:?}", lines.iter().map(|l| &l.text).collect::<Vec<_>>());
assert_eq!(lines[0].text, replacement);
let fonts = fallback_fonts(&reopened);
assert_eq!(fonts.len(), 2, "300 glyphs take a 240-glyph font and a 60-glyph font");
let mut glyphs = 0;
for (name, font) in &fonts {
let procs = reopened.resolve(font.get(b"CharProcs").unwrap());
let count = procs.as_dict().unwrap().len();
let last = font.get(b"LastChar").and_then(|o| o.as_int()).unwrap_or(i64::MAX);
assert!(count <= 240 && last <= 240, "{name:?}: {count} glyphs, LastChar {last}");
glyphs += count;
}
assert_eq!(glyphs, 300);
}
/// A replacement whose last glyph no face draws (in a later font) changes nothing: neither the
/// document nor a snapshot taken before the edit, which is what undo restores.
#[test]
fn japanese_replacement_missing_a_glyph_after_240_changes_nothing() {
if without_craft_fonts("japanese_replacement_missing_a_glyph_after_240_changes_nothing") {
return;
}
// The first CJK character that no face draws: the 242nd unique character, in a second font.
let faces = pdfcraft_fonts::document_japanese_fonts_for_style(false, false);
let missing = (0x4E00u32..0x9FFF)
.filter_map(char::from_u32)
.find(|ch| faces.iter().all(|face| pdfcraft_fonts::japanese_glyph_from(face, *ch).is_err()))
.expect("some CJK character is outside every face");
let replacement = format!("{}{missing}", covered_japanese_chars(241));
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
let snapshot = doc.clone();
let err = text::replace_line(&mut doc, 0, 0, &replacement).unwrap_err().to_string();
assert!(err.contains(&format!("U+{:04X}", missing as u32)), "{err}");
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), saved);
assert_eq!(write_incremental(&snapshot, &SaveOptions::default()).unwrap(), saved);
}
/// Runs that change font move nothing: with two glyphs per font the text reads back the same, in the
/// same box as with one font, and the font is switched only where the text moves to another font.
#[test]
fn japanese_fallback_split_across_fonts_keeps_order_and_positions() {
if without_craft_fonts("japanese_fallback_split_across_fonts_keeps_order_and_positions") {
return;
}
// 見 and 商 are in the first font, 御 in the second: runs [見商], [御], [見], three switches.
let replacement = "見商御見";
let mut whole = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
let mut split = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
text::replace_line_with_limit(&mut whole, 0, 0, replacement, 240).unwrap();
text::replace_line_with_limit(&mut split, 0, 0, replacement, 2).unwrap();
let (whole, split) = (reopen(&whole), reopen(&split));
assert_eq!(fallback_fonts(&whole).len(), 1);
assert_eq!(fallback_fonts(&split).len(), 2);
let (one, many) = (text::text_lines(&whole, 0).unwrap(), text::text_lines(&split, 0).unwrap());
assert_eq!(many.len(), 1, "one line: {:?}", many.iter().map(|l| &l.text).collect::<Vec<_>>());
assert_eq!(many[0].text, replacement);
for (a, b) in one[0].rect.iter().zip(many[0].rect.iter()) {
assert!((a - b).abs() < 1e-6, "box {:?} vs {:?}", one[0].rect, many[0].rect);
}
// One `Tf` per run plus the page's own switch to F2 and the switch back after the text.
let content = String::from_utf8_lossy(&page_content_bytes(&split, 0)).into_owned();
assert_eq!(content.matches(" Tf").count(), 5, "{content}");
let content = String::from_utf8_lossy(&page_content_bytes(&whole, 0)).into_owned();
assert_eq!(content.matches(" Tf").count(), 3, "{content}");
}
/// Only a change of font gets a `Tf`: alternating characters with one glyph per font switch at every
/// character, and a run of one font's characters switches nowhere.
#[test]
fn japanese_fallback_switches_font_only_between_runs() {
if without_craft_fonts("japanese_fallback_switches_font_only_between_runs") {
return;
}
let alternating = "見商".repeat(500);
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
text::replace_line_with_limit(&mut doc, 0, 0, &alternating, 1).unwrap();
let reopened = reopen(&doc);
assert_eq!(text::text_lines(&reopened, 0).unwrap()[0].text, alternating);
let content = String::from_utf8_lossy(&page_content_bytes(&reopened, 0)).into_owned();
assert_eq!(content.matches(" Tf").count(), 1000 + 2, "1000 runs, the F2 switch before and after");
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
text::replace_line_with_limit(&mut doc, 0, 0, "見見見", 1).unwrap();
let content = String::from_utf8_lossy(&page_content_bytes(&reopen(&doc), 0)).into_owned();
assert_eq!(content.matches(" Tf").count(), 1 + 1 + 1, "{content}");
}
/// A paragraph set in several fallback fonts reads back as one block with the same text, and it
/// wraps across lines.
#[test]
fn japanese_paragraph_split_across_fonts_reads_back_as_one_block() {
if without_craft_fonts("japanese_paragraph_split_across_fonts_reads_back_as_one_block") {
return;
}
// Spaces are glyphs too, so two glyphs per font needs several fonts; the narrow box wraps the text.
let replacement = "見 商 御 見 商";
let style = text::BlockStyle { width: Some(40.0), ..Default::default() };
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
text::rewrite_block_with_limit(&mut doc, 0, 0, Some(replacement), &style, 2).unwrap();
let reopened = reopen(&doc);
assert!(fallback_fonts(&reopened).len() >= 2);
let blocks = text::text_blocks(&reopened, 0).unwrap();
assert_eq!(blocks.len(), 1, "one paragraph: {:?}", blocks.iter().map(|b| &b.text).collect::<Vec<_>>());
assert_eq!(blocks[0].text, replacement);
assert!(blocks[0].lines.len() > 1, "wrapped to {} line(s)", blocks[0].lines.len());
}
/// A paragraph whose move or width is refused (not finite, or drawn in a space it cannot be moved in)
/// leaves the document as it was: the fallback fonts are written only once the edit is accepted.
#[test]
fn japanese_paragraph_refused_before_its_fallback_fonts_are_written() {
if without_craft_fonts("japanese_paragraph_refused_before_its_fallback_fonts_are_written") {
return;
}
let plain = "BT /F2 12 Tf 72 700 Td (ab) Tj ET";
let cases = [
(plain, text::BlockStyle { offset: Some([f64::NAN, 0.0]), ..Default::default() }, "can't be moved that far"),
(plain, text::BlockStyle { width: Some(f64::INFINITY), ..Default::default() }, "width must be a number"),
(
"0 0 0 0 0 0 cm BT /F2 12 Tf 72 700 Td (ab) Tj ET",
text::BlockStyle { offset: Some([5.0, 0.0]), ..Default::default() },
"can't be moved in",
),
];
for (content, style, message) in cases {
let mut doc = text_page(content);
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
let err = text::rewrite_block(&mut doc, 0, 0, Some("見本商会 御中"), &style).unwrap_err().to_string();
assert!(err.contains(message), "{err}");
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), saved, "{message}: the document changed");
}
}
/// Every Type 3 fallback glyph starts with a well-formed `d1`: six operands, the second 0, and a
/// box that encloses every point of the glyph. Acrobat shows a bullet for a glyph whose `d1`
/// has the wrong operand count.
#[test]
fn type3_fallback_glyphs_declare_a_well_formed_d1() {
if without_craft_fonts("type3_fallback_glyphs_declare_a_well_formed_d1") {
return;
}
// Cyrillic is respaced (shifted by its side bearing), and its box must move with it.
let mut glyphs = Vec::new();
for replacement in ["見本商会 御中", "София 2027"] {
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
text::replace_line(&mut doc, 0, 0, replacement).unwrap();
glyphs.extend(fallback_paths(&reopen(&doc)));
}
assert!(!glyphs.is_empty());
for glyph in glyphs {
let text = String::from_utf8(glyph).unwrap();
let mut lines = text.lines();
let d1: Vec<&str> = lines.next().unwrap().split_whitespace().collect();
assert_eq!(d1.len(), 7, "six operands and d1: {d1:?}");
assert_eq!(d1[6], "d1");
let n: Vec<f64> = d1[..6].iter().map(|v| v.parse().unwrap()).collect();
assert_eq!(n[1], 0.0, "wy");
assert!(n[2] <= n[4] && n[3] <= n[5], "box: {n:?}");
for line in lines.filter(|l| l.ends_with(" m") || l.ends_with(" l")) {
let p: Vec<f64> = line.split_whitespace().take(2).map(|v| v.parse().unwrap()).collect();
assert!(p[0] >= n[2] && p[0] <= n[4] && p[1] >= n[3] && p[1] <= n[5], "{line} outside {n:?}");
}
}
}
/// Shippori Mincho, the serif fallback, has no Cyrillic: Bulgarian replacement text in a serif
/// line takes a fallback face that has every letter instead of failing on the first one.
#[test]
fn cyrillic_replacement_in_a_serif_line_uses_a_face_with_the_letters() {
if without_craft_fonts("cyrillic_replacement_in_a_serif_line_uses_a_face_with_the_letters") {
return;
}
let replacement = "София 2027";
for paragraph in [false, true] {
let mut doc = styled_text_page("ABCDEF+Garamond");
if paragraph {
text::replace_block(&mut doc, 0, 0, replacement).unwrap();
} else {
text::replace_line(&mut doc, 0, 0, replacement).unwrap();
}
let lines = text::text_lines(&reopen(&doc), 0).unwrap();
assert_eq!(lines[0].text, replacement);
// Letters are spaced by their shape, not one em each (the faces' full-width Cyrillic).
let widths = fallback_widths(&doc);
assert!(widths.iter().all(|w| *w > 300.0 && *w < 900.0), "{widths:?}");
assert!(widths.windows(2).any(|w| w[0] != w[1]), "proportional: {widths:?}");
}
}
/// The `/Widths` of the page's Type 3 fallback font, for its Cyrillic letters.
fn fallback_widths(doc: &Document) -> Vec<f64> {
let p = pdfcraft_model::pages(doc).swap_remove(0);
let res = doc.resolve(p.dict.get(b"Resources").unwrap());
let fonts = doc.resolve(res.as_dict().unwrap().get(b"Font").unwrap());
let font = doc.resolve(fonts.as_dict().unwrap().get(b"PCJp").unwrap());
let font = font.as_dict().unwrap();
let widths = doc.resolve(font.get(b"Widths").unwrap()).as_array().unwrap().iter().filter_map(|w| w.as_f64()).collect::<Vec<_>>();
// "София 2027": the first five codes are the letters.
widths[..5].to_vec()
}
#[test]
fn japanese_paragraph_keeps_ideographic_spaces() {
if without_craft_fonts("japanese_paragraph_keeps_ideographic_spaces") {
return;
}
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Original paragraph) Tj ET");
let replacement = " 見本商会  御中 ";
let style = text::BlockStyle { width: Some(400.0), ..Default::default() };
text::rewrite_block(&mut doc, 0, 0, Some(replacement), &style).unwrap();
let reopened = reopen(&doc);
let lines = text::text_lines(&reopened, 0).unwrap();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text, replacement);
assert_eq!(text::text_blocks(&reopened, 0).unwrap()[0].text, replacement);
// Restyling the saved paragraph without new text must also preserve its spacing.
let mut doc = reopened;
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { size: Some(14.0), ..style }).unwrap();
assert_eq!(text::text_lines(&reopen(&doc), 0).unwrap()[0].text, replacement);
}
/// Without craft-fonts there is no Japanese face: editing in Japanese is a clear error that
/// leaves the page untouched, never a panic; Latin edits work as before.
#[test]
fn japanese_edit_without_craft_fonts_is_a_clear_error() {
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
let before = page_content_bytes(&doc, 0);
let line = text::replace_line(&mut doc, 0, 0, "日本語の文字");
let block = text::replace_block(&mut doc, 0, 0, "日本語の文字");
if pdfcraft_fonts::document_japanese_font().is_some() {
eprintln!("built with craft-fonts: the Japanese edits succeed (checked by the tests above)");
assert!(line.is_ok() && block.is_ok());
return;
}
for result in [line.map(|_| ()), block.map(|_| ())] {
let Err(EditError::Invalid(msg)) = result else { panic!("expected a clear error, got {result:?}") };
assert!(msg.contains("Japanese fallback font") && msg.contains("CRAFT_FONTS_DIR"), "{msg}");
}
assert_eq!(page_content_bytes(&doc, 0), before);
let latin = text::replace_line(&mut doc, 0, 0, "Hello").unwrap();
assert_eq!(text::text_lines(&reopen(&doc), 0).unwrap()[0].text, "Hello", "{latin:?}");
}
#[test]
fn font_descriptor_style_is_exposed_even_with_a_neutral_name() {
let doc = descriptor_text_page();
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 1);
assert!(lines[0].bold && lines[0].italic, "{:?}", lines[0]);
let blocks = text::text_blocks(&doc, 0).unwrap();
assert!(blocks[0].bold && blocks[0].italic, "{:?}", blocks[0]);
}
#[test]
fn mixed_font_runs_are_not_merged_into_one_source_style() {
let doc = text_page("BT /F1 12 Tf 72 700 Td (Regular) Tj /F2 12 Tf 150 700 Td (Subset) Tj ET");
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 2);
assert_ne!(lines[0].font, lines[1].font);
}
#[test]
fn missing_glyphs_preserve_source_style_in_fallback() {
let mut doc = descriptor_text_page();
let result = text::replace_line(&mut doc, 0, 0, "Styled €").unwrap();
assert_eq!(result.substituted.as_deref(), Some("Helvetica-BoldOblique"));
let bytes = page_content_bytes(&doc, 0);
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("/PCEdHelveticaBoldOblique 12 Tf"), "{content}");
}
#[test]
fn a_subset_without_a_space_glyph_keeps_its_font_when_editing_words() {
for paragraph in [false, true] {
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj /F1 12 Tf ( ) Tj /F2 12 Tf (ba) Tj ET");
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, "ab ba");
let edit = if paragraph { text::replace_block(&mut doc, 0, 0, "ba ab ba") } else { text::replace_line(&mut doc, 0, 0, "ba ab ba") };
assert_eq!(edit.unwrap().substituted, None);
let lines = text::text_lines(&reopen(&doc), 0).unwrap();
assert_eq!(lines[0].text, "ba ab ba");
assert_eq!(lines[0].font, "F2");
assert!((lines[0].rect[2] - lines[0].rect[0]) > 3.0 * 12.0);
}
}
/// A Chrome (Skia) style subset: Type0/Identity-H over a TrueType program, CID = glyph id.
/// `/W` lists most used glyphs, writes 0 for some it dropped, and leaves `a` and `y` to `/DW`;
/// `z` has a ToUnicode entry but no glyph in the program (its CID is past the last glyph).
fn skia_subset_page() -> Document {
// Inter's glyph ids: P 375, o 790, l 736, a 507, n 773, d 597, G 141, e 614, r 852, m 766, y 998, space 1777.
let mut doc = text_page(
"BT /F3 12 Tf 72 700 Td <0177031602E001FB03050255> Tj ET \
BT /F3 12 Tf 72 650 Td <008D0266035402FE01FB030503E606F101FB0305025506F102FE031603540266> Tj ET",
);
let program = doc.add(Object::Stream(Stream::flate(Dict::new(), include_bytes!("../../../assets/fonts/Inter-Regular.ttf"))));
let unicode = doc.add(Object::Stream(Stream::flate(
Dict::new(),
b"1 begincodespacerange <0000> <FFFF> endcodespacerange 13 beginbfchar <0177> <0050> <0316> <006F> <02E0> <006C> \
<01FB> <0061> <0305> <006E> <0255> <0064> <008D> <0047> <0266> <0065> <0354> <0072> <02FE> <006D> <03E6> <0079> \
<06F1> <0020> <1000> <007A> endbfchar",
)));
let widths: Vec<Object> = [(0x8D, 746), (0x177, 639), (0x255, 612), (0x266, 583), (0x2E0, 242), (0x2FE, 876), (0x305, 591), (0x316, 600)]
.into_iter()
.chain([(0x354, 376), (0x355, 0), (0x356, 0), (0x6F1, 281)])
.flat_map(|(cid, width)| [Object::Int(cid), Object::Array(vec![Object::Int(width)])])
.collect();
let descriptor = Dict::from_iter([
(b"Type".to_vec(), Object::name("FontDescriptor")),
(b"FontName".to_vec(), Object::name("BAAAAA+SampleSans")),
(b"Flags".to_vec(), Object::Int(32)),
(b"FontFile2".to_vec(), Object::Ref(program)),
]);
let cid_font = Dict::from_iter([
(b"Type".to_vec(), Object::name("Font")),
(b"Subtype".to_vec(), Object::name("CIDFontType2")),
(b"BaseFont".to_vec(), Object::name("BAAAAA+SampleSans")),
(b"CIDToGIDMap".to_vec(), Object::name("Identity")),
(b"DW".to_vec(), Object::Int(562)),
(b"W".to_vec(), Object::Array(widths)),
(b"FontDescriptor".to_vec(), Object::Dict(descriptor)),
]);
let font = Dict::from_iter([
(b"Type".to_vec(), Object::name("Font")),
(b"Subtype".to_vec(), Object::name("Type0")),
(b"BaseFont".to_vec(), Object::name("BAAAAA+SampleSans")),
(b"Encoding".to_vec(), Object::name("Identity-H")),
(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(cid_font)])),
(b"ToUnicode".to_vec(), Object::Ref(unicode)),
]);
let p = pdfcraft_model::pages(&doc)[0].obj;
let fonts = Dict::from_iter([(b"F3".to_vec(), Object::Dict(font))]);
doc.update_dict(p, |d| d.set(b"Resources".to_vec(), Object::Dict(Dict::from_iter([(b"Font".to_vec(), Object::Dict(fonts))])))).unwrap();
doc
}
/// #2616: a subset whose ToUnicode has no space (as LaTeX and Sphinx output often has, spacing
/// words with TJ kerning) makes finding the space width read every font on the page. That ran
/// for every character of a multi-row draft, so each keystroke in a long paragraph took half a
/// second. It runs once per font now, however long the paragraph.
#[test]
fn a_long_draft_in_a_font_without_a_space_works_out_the_space_width_once() {
// Two rows of one paragraph (14 pt apart) in the Chrome-style subset of `skia_subset_page`,
// whose ToUnicode here has no entry for the space glyph.
let mut doc =
text_page("BT /F3 12 Tf 72 700 Td <0177031602E001FB03050255> Tj ET BT /F3 12 Tf 72 686 Td <008D0266035402FE01FB030503E6> Tj ET");
let program = doc.add(Object::Stream(Stream::flate(Dict::new(), include_bytes!("../../../assets/fonts/Inter-Regular.ttf"))));
let unicode = doc.add(Object::Stream(Stream::flate(
Dict::new(),
b"1 begincodespacerange <0000> <FFFF> endcodespacerange 12 beginbfchar <0177> <0050> <0316> <006F> <02E0> <006C> <01FB> <0061> <0305> <006E> <0255> <0064> <008D> <0047> <0266> <0065> <0354> <0072> <02FE> <006D> <03E6> <0079> <1000> <007A> endbfchar",
)));
let widths: Vec<Object> =
[(0x8D, 746), (0x177, 639), (0x1FB, 551), (0x255, 612), (0x266, 583), (0x2E0, 242), (0x2FE, 876), (0x305, 591), (0x316, 600)]
.into_iter()
.chain([(0x354, 376), (0x3E6, 515)])
.flat_map(|(cid, width)| [Object::Int(cid), Object::Array(vec![Object::Int(width)])])
.collect();
let descriptor = Dict::from_iter([
(b"Type".to_vec(), Object::name("FontDescriptor")),
(b"FontName".to_vec(), Object::name("BAAAAA+SampleSans")),
(b"Flags".to_vec(), Object::Int(32)),
(b"FontFile2".to_vec(), Object::Ref(program)),
]);
let cid_font = Dict::from_iter([
(b"Type".to_vec(), Object::name("Font")),
(b"Subtype".to_vec(), Object::name("CIDFontType2")),
(b"BaseFont".to_vec(), Object::name("BAAAAA+SampleSans")),
(b"CIDToGIDMap".to_vec(), Object::name("Identity")),
(b"DW".to_vec(), Object::Int(562)),
(b"W".to_vec(), Object::Array(widths)),
(b"FontDescriptor".to_vec(), Object::Dict(descriptor)),
]);
let font = Dict::from_iter([
(b"Type".to_vec(), Object::name("Font")),
(b"Subtype".to_vec(), Object::name("Type0")),
(b"BaseFont".to_vec(), Object::name("BAAAAA+SampleSans")),
(b"Encoding".to_vec(), Object::name("Identity-H")),
(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(cid_font)])),
(b"ToUnicode".to_vec(), Object::Ref(unicode)),
]);
let p = pdfcraft_model::pages(&doc)[0].obj;
let fonts = Dict::from_iter([(b"F3".to_vec(), Object::Dict(font))]);
doc.update_dict(p, |d| d.set(b"Resources".to_vec(), Object::Dict(Dict::from_iter([(b"Font".to_vec(), Object::Dict(fonts))])))).unwrap();
let blocks = text::text_blocks(&doc, 0).unwrap();
assert_eq!(blocks.len(), 1, "one paragraph: {:?}", blocks.iter().map(|b| &b.text).collect::<Vec<_>>());
let draft = "Poland and Germany
"
.repeat(20);
text::SOURCE_SPACE_CALLS.with(|c| c.set(0));
let layout = text::text_edit_layout(&doc, 0, 0, draft.trim_end(), &text::BlockStyle::default()).unwrap();
let calls = text::SOURCE_SPACE_CALLS.with(std::cell::Cell::get);
assert!(layout.draft_rect.is_some_and(|r| r[2] > r[0]), "laid out: {:?}", layout.draft_rect);
assert!(calls <= 8, "the space width was worked out {calls} times for {} characters", draft.len());
}
#[test]
fn a_subset_keeps_its_font_for_glyphs_left_to_the_default_width() {
let doc = skia_subset_page();
let blocks = text::text_blocks(&doc, 0).unwrap();
assert_eq!(blocks.iter().map(|b| b.text.as_str()).collect::<Vec<_>>(), ["Poland", "Germany and more"]);
// Deleting words only, and replacing a word with one drawn elsewhere on the page (`y` and `a`
// have no /W entry): every glyph is in the subset, so the font stays.
for (block, text) in [(1, "Germany"), (0, "Germany")] {
let mut doc = doc.clone();
assert_eq!(text::replace_block(&mut doc, 0, block, text).unwrap().substituted, None, "{text}");
let lines = text::text_lines(&reopen(&doc), 0).unwrap();
assert_eq!(lines.iter().find(|l| l.text == text).unwrap().font, "F3");
}
// `z` maps to a CID the program has no glyph for: that still needs another font.
let mut doc = doc.clone();
assert!(text::replace_block(&mut doc, 0, 0, "Graz").unwrap().substituted.is_some());
}
#[test]
fn spaces_in_another_subset_do_not_split_a_line() {
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello) Tj /F2 12 Tf ( ) Tj /F1 12 Tf (world) Tj ET");
let blocks = text::text_blocks(&doc, 0).unwrap();
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].text, "Hello world");
text::replace_block(&mut doc, 0, 0, "Goodbye").unwrap();
assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap()[0].text, "Goodbye");
}
#[test]
fn editor_background_removes_glyphs_but_preserves_following_text_advance() {
for show in ["(Hello) Tj", "[(He) -20 (llo)] TJ", "(Hello) '", "2 1 (Hello) \""] {
let mut doc = text_page(&format!("BT /F1 12 Tf 14 TL 72 700 Td {show} [-3000] TJ /F2 12 Tf (ab) Tj ET"));
let before = text::text_lines(&doc, 0).unwrap();
assert_eq!(before.len(), 2);
text::clear_block(&mut doc, 0, 0).unwrap();
let after = text::text_lines(&reopen(&doc), 0).unwrap();
assert_eq!(after.len(), 1, "{show}");
assert_eq!(after[0].text, "ab");
for (a, b) in after[0].rect.iter().zip(before[1].rect) {
assert!((a - b).abs() < 1e-6, "unselected text moved: {show}");
}
}
}
#[test]
fn editor_background_refuses_clipping_text_without_mutation() {
let mut doc = text_page("BT /F1 12 Tf 7 Tr 72 700 Td (Clip) Tj ET");
let before = page_content_bytes(&doc, 0);
assert!(text::clear_block(&mut doc, 0, 0).is_err());
assert_eq!(page_content_bytes(&doc, 0), before);
}
#[test]
fn explicit_colour_spaces_expose_the_text_colour() {
let doc = text_page("/DeviceRGB cs 0 0.23 0.45 sc BT /F1 12 Tf 72 700 Td (Blue) Tj ET");
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].color, [0.0, 0.23, 0.45]);
}
#[test]
fn words_in_separate_text_objects_form_one_editable_paragraph() {
let mut doc = text_page(
"BT /F1 12 Tf 1 0 0 1 72 700 Tm (Hello) Tj ET \
BT /F1 12 Tf 1 0 0 1 103 700 Tm (world) Tj ET \
BT /F1 12 Tf 1 0 0 1 72 685 Tm (Second) Tj ET \
BT /F1 12 Tf 1 0 0 1 117 685 Tm (line) Tj ET \
BT /F1 12 Tf 1 0 0 1 300 700 Tm (Other column) Tj ET",
);
let blocks = text::text_blocks(&doc, 0).unwrap();
assert_eq!(blocks.len(), 2);
assert_eq!(blocks[0].text, "Hello world Second line");
assert_eq!(blocks[0].lines.len(), 2);
let other = blocks[1].rect;
text::replace_block(&mut doc, 0, 0, "Replacement").unwrap();
let blocks = text::text_blocks(&reopen(&doc), 0).unwrap();
assert_eq!(blocks.iter().map(|b| b.text.as_str()).collect::<Vec<_>>(), ["Replacement", "Other column"]);
assert_eq!(blocks[1].rect, other);
}
#[test]
fn independent_text_objects_with_different_transforms_stay_separate() {
let doc = text_page(
"BT /F1 12 Tf 1 0 0 1 72 700 Tm (Hello) Tj ET \
BT /F1 12 Tf 0 1 -1 0 103 700 Tm (Vertical) Tj ET",
);
assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 2);
}
/// A negative font size or `Tz` runs the glyphs backwards along the text matrix: each word's
/// `ink` box still spans all its glyphs, not only the first.
#[test]
fn word_boxes_span_whole_words_when_glyphs_run_backwards() {
// "someone@example.com" in Helvetica 11 is 121.814 pt wide.
for content in [
"BT /F1 -11 Tf -1 0 0 1 300 742 Tm (Mail the team: someone@example.com) Tj ET",
"BT /F1 11 Tf -100 Tz 300 742 Td (Mail the team: someone@example.com) Tj ET",
] {
let lines = text::text_lines(&text_page(content), 0).unwrap();
assert_eq!(lines.len(), 1, "{content}");
let ink = &lines[0].ink;
assert_eq!(ink.len(), 4, "{content}: {ink:?}");
let width = |r: &[f64; 4]| r[2] - r[0];
assert!(ink.iter().all(|r| width(r) > 5.0), "{content}: {ink:?}");
let address = ink.iter().map(width).fold(0.0, f64::max);
assert!((address - 121.814).abs() < 0.01, "{content}: {ink:?}");
let (x0, x1) = ink.iter().fold((f64::MAX, f64::MIN), |(a, b), r| (a.min(r[0]), b.max(r[2])));
assert!(x0 >= lines[0].rect[0] - 0.01 && x1 <= lines[0].rect[2] + 0.01, "{content}: {ink:?} {:?}", lines[0].rect);
}
}
/// PDF writers split one font into several subsets, each a font resource of its own: a word drawn
/// through two of them is still one visual line (#3661).
#[test]
fn one_line_continues_across_two_resources_of_the_same_font() {
for (content, text) in [
// The issue's case: two resources for the same Helvetica.
("BT /F1 12 Tf 72 700 Td (Hel) Tj /F4 12 Tf (lo) Tj ET", "Hello"),
// Word's case: two tagged subsets of one font, "abcab" through /F2, /F3, /F2.
("BT /F2 12 Tf 72 700 Td (ab) Tj /F3 12 Tf (ca) Tj /F2 12 Tf (b) Tj ET", "abcab"),
] {
let doc = text_page(content);
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 1, "{content}: {:?}", lines.iter().map(|l| &l.text).collect::<Vec<_>>());
assert_eq!(lines[0].text, text, "{content}");
let blocks = text::text_blocks(&doc, 0).unwrap();
assert_eq!(blocks.len(), 1, "{content}");
assert_eq!(blocks[0].text, text, "{content}");
}
}
/// Two genuinely different fonts still start a new line: the subset tag is all that may differ.
#[test]
fn different_fonts_start_a_new_line() {
let doc = text_page("BT /F1 12 Tf 72 700 Td (Hel) Tj /F2 12 Tf (lo) Tj ET");
assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 2);
}
/// A font without `/BaseFont` has no name to compare. Its lines still group by resource, as they
/// did before subset joining: an empty name must not split one paragraph into two blocks.
#[test]
fn lines_in_a_font_without_a_base_font_stay_one_block() {
let doc = text_page("BT /F5 12 Tf 72 700 Td (First) Tj 0 -15 Td (second) Tj ET");
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 2);
assert!(lines.iter().all(|l| l.base_font.is_empty()), "{:?}", lines.iter().map(|l| &l.base_font).collect::<Vec<_>>());
let blocks = text::text_blocks(&doc, 0).unwrap();
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].text, "First second");
let reading = text::reading_blocks(&doc, 0).unwrap();
assert_eq!(reading.len(), 1, "{:?}", reading.iter().map(|b| &b.text).collect::<Vec<_>>());
assert_eq!(reading[0].text, "First second");
}
/// Two subsets of one font can carry different encodings: here the second names `-` and `l`
/// through `/Differences` (codes 33 and 34), so the same letters have different codes. The visual
/// line still joins and reads in coding order (#3661).
#[test]
fn a_reencoded_subset_of_the_same_font_stays_one_line() {
let content = "BT /F1 16 Tf 72 700 Td (Two) Tj /F2 16 Tf (!\") Tj /F1 16 Tf (ine heading) Tj ET";
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 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>",
&stream("", content),
"<< /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Helvetica-Bold >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /GHIJKL+Helvetica-Bold /Encoding << /Type /Encoding /BaseEncoding /WinAnsiEncoding /Differences [33 /hyphen /l] >> >>",
]);
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 1, "{:?}", lines.iter().map(|l| &l.text).collect::<Vec<_>>());
assert_eq!(lines[0].text, "Two-line heading");
let reading = text::reading_blocks(&doc, 0).unwrap();
assert_eq!(reading.len(), 1);
assert_eq!(reading[0].text, "Two-line heading");
// Editing the one line replaces every run, including the re-encoded subset's.
text::replace_line(&mut doc, 0, 0, "Two-line heading done").unwrap();
let reopened = reopen(&doc);
let lines = text::text_lines(&reopened, 0).unwrap();
assert_eq!(lines.len(), 1, "{:?}", lines.iter().map(|l| &l.text).collect::<Vec<_>>());
assert_eq!(lines[0].text, "Two-line heading done");
}
/// `ABCDE+` is five letters, not the six-letter subset tag of §9.6.4, so it stays part of the
/// name: `ABCDE+Helvetica-Bold` and `Helvetica-Bold` are different fonts and their runs stay apart.
#[test]
fn a_five_letter_prefix_is_not_a_subset_tag() {
let content = "BT /F1 16 Tf 72 700 Td (Two) Tj /F2 16 Tf (-l) Tj /F1 16 Tf (ine heading) 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] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>",
&stream("", content),
"<< /Type /Font /Subtype /Type1 /BaseFont /ABCDE+Helvetica-Bold >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>",
]);
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 3, "{:?}", lines.iter().map(|l| &l.text).collect::<Vec<_>>());
}
#[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));
}
/// A list whose bullet is its own text object at the margin and whose text sits at a hanging
/// indent: the wrapped first item is one paragraph, and editing it keeps the items after it
/// (#2564, the edit that deleted its neighbours).
#[test]
fn a_wrapped_list_item_keeps_its_continuation_and_the_items_after_it() {
let mut doc = text_page(
"BT /F1 12 Tf 1 0 0 1 72 700 Tm (\\225) Tj ET \
BT /F1 12 Tf 14.4 TL 1 0 0 1 86 700 Tm (First item of the list that wraps) Tj T* (onto a second row.) Tj ET \
BT /F1 12 Tf 1 0 0 1 72 671.2 Tm (\\225) Tj ET \
BT /F1 12 Tf 14.4 TL 1 0 0 1 86 671.2 Tm (Second item, one row.) Tj ET \
BT /F1 12 Tf 1 0 0 1 72 656.8 Tm (\\225) Tj ET \
BT /F1 12 Tf 14.4 TL 1 0 0 1 86 656.8 Tm (Third item, one row.) Tj ET",
);
let items = ["• First item of the list that wraps onto a second row.", "• Second item, one row.", "• Third item, one row."];
let blocks = text::text_blocks(&doc, 0).unwrap();
let texts: Vec<&str> = blocks.iter().map(|b| b.text.as_str()).collect();
assert_eq!(texts, items, "one paragraph per item, the continuation with its first row");
// Editing the paragraph the wrapped row belongs to must not take the items after it with it.
let blocks = text::text_blocks(&doc, 0).unwrap();
let wrapped = blocks.iter().position(|b| b.text.contains("onto a second row.")).unwrap();
text::replace_block(&mut doc, 0, wrapped, "• First item of the list that wraps onto a second row, edited.").unwrap();
let blocks = text::text_blocks(&reopen(&doc), 0).unwrap();
let texts: Vec<&str> = blocks.iter().map(|b| b.text.as_str()).collect();
assert_eq!(
texts,
["• First item of the list that wraps onto a second row, edited.", "• Second item, one row.", "• Third item, one row."],
"the items after the edited one are still on the page"
);
}
fn page_content_bytes(doc: &Document, page: usize) -> Vec<u8> {
let p = pdfcraft_model::pages(doc).swap_remove(page);
let c = p.dict.get(b"Contents").unwrap();
match &*doc.resolve(c) {
pdfcraft_cos::Object::Stream(s) => s.decoded().unwrap(),
pdfcraft_cos::Object::Array(a) => a
.iter()
.flat_map(|x| match &*doc.resolve(x) {
pdfcraft_cos::Object::Stream(s) => s.decoded().unwrap(),
_ => Vec::new(),
})
.collect(),
_ => Vec::new(),
}
}
/// A page drawing image XObject /Im0 at 100,100 size 200 × 100 (pixels 4 × 2).
fn image_page() -> Document {
image_page_with("q 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET")
}
fn image_page_with(content: &str) -> Document {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> >> >>".into(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
"<< /Type /XObject /Subtype /Image /Width 4 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 8 >>\nstream\n\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\nendstream".into(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
/// A path segment operator before any `m` (or `re`) has no subpath to extend: the image scan used
/// to call `BezPath`'s `line_to`/`curve_to`/`close_path` on an empty path, which panics by contract,
/// taking `object_list`, `page_images` and the Office export's image walk down with it (#3581).
/// The segment is now skipped, as the renderer, MuPDF and pdftotext do, and the page's images are
/// still listed.
#[test]
fn image_scan_skips_a_path_segment_before_any_m() {
for bad in ["1 1 l S", "1 1 2 2 3 3 c S", "1 1 2 2 4 4 y S", "1 1 2 2 3 3 v S", "h S"] {
let content = format!("q {bad} Q 100 100 200 100 cm /Im0 Do");
let doc = image_page_with(&content);
assert_eq!(images::page_images(&doc, 0).unwrap().len(), 1, "{bad}: the image is still listed");
}
// A path started with m (or re) still extends and closes as before.
let doc = image_page_with("q 0 0 m 1 1 l h S Q 100 100 200 100 cm /Im0 Do");
assert_eq!(images::page_images(&doc, 0).unwrap().len(), 1);
let doc = image_page_with("q 10 10 20 20 re W n Q 100 100 200 100 cm /Im0 Do");
assert_eq!(images::page_images(&doc, 0).unwrap().len(), 1);
}
/// The box of the first `re` on the page in user space, after the `cm`s before it.
fn first_rect_in_user_space(doc: &Document) -> [f64; 4] {
let mut ctm = pdfcraft_content::Matrix::IDENTITY;
for op in pdfcraft_content::parse(&page_content_bytes(doc, 0)).ops {
if op.is("cm") {
ctm = pdfcraft_content::Matrix(op.nums::<6>().unwrap()).then(&ctm);
} else if op.is("re") {
let [x, y, w, h] = op.nums::<4>().unwrap();
return ctm.bbox([x, y, x + w, y + h]);
}
}
panic!("no clip rectangle");
}
#[test]
fn moving_an_image_moves_its_own_clip_too() {
let content = "q 100 100 200 100 re W* n 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET";
let target = [50.0, 400.0, 350.0, 600.0];
let mut doc = image_page_with(content);
let before = images::page_images(&doc, 0).unwrap()[0].rect;
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(images::rect_to_rect(before, target))).unwrap();
let mut doc = reopen(&doc);
assert!(close(images::page_images(&doc, 0).unwrap()[0].rect, target));
assert!(close(first_rect_in_user_space(&doc), target), "{:?}", first_rect_in_user_space(&doc));
// A second edit still finds the clip, now behind the `cm` the first one put before it.
let moved = [80.0, 420.0, 380.0, 620.0];
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(images::rect_to_rect(target, moved))).unwrap();
assert!(close(first_rect_in_user_space(&doc), moved));
images::translate_images(&mut doc, 0, &[0], [10.0, 0.0]).unwrap();
assert!(close(images::page_images(&doc, 0).unwrap()[0].rect, [90.0, 420.0, 390.0, 620.0]));
assert!(close(first_rect_in_user_space(&doc), [90.0, 420.0, 390.0, 620.0]));
// Moving it with the group move helper takes the clip along as well.
let mut doc = image_page_with(content);
images::translate_images(&mut doc, 0, &[0], [30.0, -20.0]).unwrap();
assert!(close(images::page_images(&doc, 0).unwrap()[0].rect, [130.0, 80.0, 330.0, 180.0]));
assert!(close(first_rect_in_user_space(&doc), [130.0, 80.0, 330.0, 180.0]));
// A clip shared with other drawing is left where it is.
let shared = "q 100 100 200 100 re W n q 200 0 0 100 100 100 cm /Im0 Do Q 0 0 10 10 re f Q";
let mut doc = image_page_with(shared);
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(images::rect_to_rect(before, target))).unwrap();
assert!(close(first_rect_in_user_space(&doc), [100.0, 100.0, 300.0, 200.0]));
}
fn close(a: [f64; 4], b: [f64; 4]) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6)
}
/// The deepest `q` nesting on the page.
fn q_depth(doc: &Document) -> usize {
let mut depth = 0_usize;
let mut deepest = 0;
for op in pdfcraft_content::parse(&page_content_bytes(doc, 0)).ops {
if op.is("q") {
depth += 1;
deepest = deepest.max(depth);
} else if op.is("Q") {
depth = depth.saturating_sub(1);
}
}
deepest
}
#[test]
fn repeated_image_edits_change_one_transform_instead_of_nesting() {
let variants = [
// Its own placement, a private crop, and a bare `Do` that the first edit wraps.
"q 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET",
"q 100 100 200 100 re W* n 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET",
"200 0 0 100 100 100 cm /Im0 Do",
];
for content in variants {
let mut doc = image_page_with(content);
let resize = |doc: &mut Document, n: usize| -> usize {
let mut depth = 0;
for i in 0..n {
let r = images::page_images(doc, 0).unwrap()[0].visible_rect.unwrap();
// Grow by 3% about the lower-left corner and move right by 2, staying on the page.
let target = [r[0] + 2.0, r[1], r[0] + 2.0 + (r[2] - r[0]) * 1.03, r[1] + (r[3] - r[1]) * 1.03];
images::change_image(doc, 0, 0, &images::ImageChange::Transform(images::rect_to_rect(r, target))).unwrap();
let after = images::page_images(doc, 0).unwrap()[0].visible_rect.unwrap();
assert!(after.iter().zip(target).all(|(a, b)| (a - b).abs() < 1e-6), "{content}: {after:?} vs {target:?}");
if i == 0 {
depth = q_depth(doc);
}
}
depth
};
let first = resize(&mut doc, 20);
assert_eq!(q_depth(&doc), first, "{content}: twenty resizes nest no deeper than one");
let mut doc = reopen(&doc);
let before = images::page_images(&doc, 0).unwrap()[0].rect;
for _ in 0..20 {
images::translate_images(&mut doc, 0, &[0], [1.0, -2.0]).unwrap();
}
let after = images::page_images(&doc, 0).unwrap()[0].rect;
assert!(close(after, [before[0] + 20.0, before[1] - 40.0, before[2] + 20.0, before[3] - 40.0]), "{content}: {after:?}");
assert_eq!(q_depth(&doc), first, "{content}: group moves change the same transform");
let caption: Vec<_> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
assert_eq!(caption.is_empty(), !content.contains("Caption"), "{content}: the text after the image is untouched");
}
}
#[test]
fn repeated_group_moves_of_a_cropped_image_change_one_clip_transform() {
// The crop is smaller than the image, so a clip left behind would shrink `visible_rect`.
let mut doc = image_page_with("q 150 120 100 50 re W n 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET");
let operators = |doc: &Document| pdfcraft_content::parse(&page_content_bytes(doc, 0)).ops.len();
let start = images::page_images(&doc, 0).unwrap()[0].clone();
let mut first = 0;
for i in 0..20 {
images::translate_images(&mut doc, 0, &[0], [1.0, -2.0]).unwrap();
if i == 0 {
first = operators(&doc);
}
}
let moved = |r: [f64; 4]| [r[0] + 20.0, r[1] - 40.0, r[2] + 20.0, r[3] - 40.0];
let now = images::page_images(&doc, 0).unwrap()[0].clone();
assert!(close(now.rect, moved(start.rect)), "{:?}", now.rect);
assert!(close(now.visible_rect.unwrap(), moved(start.visible_rect.unwrap())), "the crop moves with the image: {:?}", now.visible_rect);
assert_eq!(operators(&doc), first, "twenty group moves add no more operators than one");
assert_eq!(q_depth(&doc), 1);
let doc = reopen(&doc);
assert!(close(images::page_images(&doc, 0).unwrap()[0].visible_rect.unwrap(), moved(start.visible_rect.unwrap())));
let caption: Vec<_> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
assert_eq!(caption, ["Caption"]);
}
#[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_one_row_title_keeps_its_centre_when_it_grows_and_when_it_wraps() {
// A centred one-row title (its centre is at x ≈ 306 on the 600 pt page) above a body
// left-aligned at x = 72. Growing it must not drag it to the right.
let src = "BT /F1 18 Tf 1 0 0 1 252.477 700 Tm (Centered title) Tj ET\n\
BT /F1 12 Tf 1 0 0 1 72 640 Tm (Body text below the heading starts here and runs) Tj ET\n\
BT /F1 12 Tf 1 0 0 1 72 625.6 Tm (on for a second row of ordinary words.) Tj ET";
let mut doc = text_page(src);
let before = text::text_blocks(&doc, 0).unwrap()[0].clone();
let centre = (before.rect[0] + before.rect[2]) / 2.0;
text::rewrite_block(&mut doc, 0, 0, Some("Centered title with a few more words"), &text::BlockStyle::default()).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, "Centered title with a few more words");
let r = lines[blocks[0].lines[0]].rect;
assert!(((r[0] + r[2]) / 2.0 - centre).abs() < 1.0, "the grown title stays centred on {centre}, its centre is {}", (r[0] + r[2]) / 2.0);
// Long enough to have to wrap: every row stays centred on the same centre too.
let mut doc = text_page(src);
text::rewrite_block(
&mut doc,
0,
0,
Some("Centered title with many more words added so that the single row has to become much longer"),
&text::BlockStyle::default(),
)
.unwrap();
let doc = reopen(&doc);
let blocks = text::text_blocks(&doc, 0).unwrap();
let lines = text::text_lines(&doc, 0).unwrap();
assert!(blocks[0].lines.len() > 1, "the long title wraps into rows: {:?}", blocks[0].text);
for i in &blocks[0].lines {
let r = lines[*i].rect;
assert!(((r[0] + r[2]) / 2.0 - centre).abs() < 2.0, "row {:?} stays centred on {centre}", lines[*i].text);
}
}
#[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 replacing_a_line_keeps_a_distinct_nearby_line() {
// 12 pt lines 6 pt apart: the glyph boxes overlap by more than half the smaller box,
// but the baselines are different lines (#1316).
let src = "BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj 0 -6 Td (Beta) Tj ET";
let listed = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().map(|l| l.text).collect::<Vec<_>>();
for paragraph in [false, true] {
let mut doc = text_page(src);
assert_eq!(listed(&doc), ["Alpha", "Beta"]);
assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2);
if paragraph {
text::rewrite_block(&mut doc, 0, 0, Some("ALPHA"), &text::BlockStyle::default()).unwrap();
} else {
text::replace_line(&mut doc, 0, 0, "ALPHA").unwrap();
}
assert_eq!(listed(&doc), ["ALPHA", "Beta"]);
assert!(String::from_utf8_lossy(&page_content_bytes(&doc, 0)).contains("(Beta)"));
for bytes in [write_incremental(&doc, &SaveOptions::default()).unwrap(), write_full(&doc, &SaveOptions::default()).unwrap()] {
let back = Document::open(Arc::new(bytes)).unwrap();
assert!(back.repair_log().is_empty());
assert_eq!(listed(&back), ["ALPHA", "Beta"]);
}
}
// The same word on the next line is still a different line.
let mut same = text_page("BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj 0 -6 Td (Alpha) Tj ET");
text::replace_line(&mut same, 0, 0, "ALPHA").unwrap();
assert_eq!(listed(&same), ["ALPHA", "Alpha"]);
// Ordinary leading, and a paragraph far enough to group apart, keep the neighbour.
let mut spaced = text_page("BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj 0 -14.4 Td (Beta) Tj ET");
text::replace_line(&mut spaced, 0, 0, "ALPHA").unwrap();
assert_eq!(listed(&spaced), ["ALPHA", "Beta"]);
let mut apart = text_page("BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj 0 -40 Td (Beta) Tj ET");
text::rewrite_block(&mut apart, 0, 0, Some("ALPHA"), &text::BlockStyle::default()).unwrap();
assert_eq!(listed(&apart), ["ALPHA", "Beta"]);
// A second copy at the same position is still one replacement.
for paragraph in [false, true] {
let mut duplicate = text_page("BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj ET BT /F1 12 Tf 1 0 0 1 72 700 Tm (Alpha) Tj ET");
if paragraph {
text::rewrite_block(&mut duplicate, 0, 0, Some("ALPHA"), &text::BlockStyle::default()).unwrap();
} else {
text::replace_line(&mut duplicate, 0, 0, "ALPHA").unwrap();
}
assert_eq!(listed(&duplicate), ["ALPHA"]);
}
}
#[test]
fn editing_text_keeps_an_overlapping_watermark_and_covered_paragraphs() {
let listed = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().map(|l| l.text).collect::<Vec<_>>();
// #1756: a rotated watermark's bounding box covers the middle of the page, and a body line
// whose baseline happens to sit near the watermark's starting y is still unrelated text.
for rotation in [35.0, 45.0, 0.0] {
let mut base = text_page("BT /F1 14 Tf 60 700 Td (Heading) Tj ET");
add_watermark(
&mut base,
&[0],
&Watermark { text: "TEST WATERMARK".into(), font_size: 48.0, rotation, opacity: 0.25, ..Watermark::default() },
false,
)
.unwrap();
let wm_y = text::text_lines(&base, 0).unwrap().into_iter().find(|l| l.text == "TEST WATERMARK").unwrap().origin_baseline();
let mut doc = text_page(&format!("BT /F1 14 Tf 1 0 0 1 60 {wm_y:.2} Tm (Body paragraph one with several words here) Tj ET"));
add_watermark(
&mut doc,
&[0],
&Watermark { text: "TEST WATERMARK".into(), font_size: 48.0, rotation, opacity: 0.25, ..Watermark::default() },
false,
)
.unwrap();
let line_idx = text::text_lines(&doc, 0).unwrap().iter().position(|l| l.text.starts_with("Body")).unwrap();
let mut line_edited = doc.clone();
text::replace_line(&mut line_edited, 0, line_idx, "Edited body line").unwrap();
assert_eq!(listed(&reopen(&line_edited)), ["Edited body line", "TEST WATERMARK"], "rotation {rotation}");
let block_idx = text::text_blocks(&doc, 0).unwrap().iter().position(|b| b.text.starts_with("Body")).unwrap();
let mut preview = doc.clone();
text::clear_block(&mut preview, 0, block_idx).unwrap();
assert_eq!(listed(&preview), ["TEST WATERMARK"], "clear_block keeps the watermark at rotation {rotation}");
text::rewrite_block(&mut doc, 0, block_idx, Some("Edited body paragraph"), &text::BlockStyle::default()).unwrap();
assert_eq!(listed(&reopen(&doc)), ["Edited body paragraph", "TEST WATERMARK"], "rotation {rotation}");
}
// #1368: dragging one paragraph onto another paragraph's baseline and moving it on again
// leaves the covered paragraph intact.
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (First paragraph) Tj ET BT /F1 12 Tf 72 600 Td (Second paragraph) Tj ET");
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { offset: Some([0.0, -100.0]), ..Default::default() }).unwrap();
let second = text::text_blocks(&doc, 0).unwrap().iter().position(|b| b.text == "Second paragraph");
assert!(second.is_some(), "the covered paragraph stays on the page: {:?}", listed(&doc));
let first = text::text_blocks(&doc, 0).unwrap().iter().position(|b| b.text == "First paragraph").unwrap();
text::rewrite_block(&mut doc, 0, first, None, &text::BlockStyle { offset: Some([0.0, -100.0]), ..Default::default() }).unwrap();
assert_eq!(listed(&reopen(&doc)), ["First paragraph", "Second paragraph"]);
// Even when a large horizontal watermark shows the same word as a body line at the same y,
// or a rotated 12 pt line crosses a horizontal 12 pt line of the same word at the same origin,
// editing one keeps the other.
let mut same_word = text_page("BT /F1 12 Tf 1 0 0 1 200 400 Tm (DRAFT) Tj ET BT /F1 48 Tf 1 0 0 1 150 400 Tm (DRAFT) Tj ET");
text::replace_line(&mut same_word, 0, 0, "FINAL").unwrap();
let after = text::text_lines(&reopen(&same_word), 0).unwrap();
assert_eq!(after.iter().map(|l| (&l.text[..], l.size as i32)).collect::<Vec<_>>(), [("FINAL", 12), ("DRAFT", 48)]);
let mut crossed = text_page("BT /F1 12 Tf 1 0 0 1 100 500 Tm (Crossed) Tj ET BT /F1 12 Tf 0.707 0.707 -0.707 0.707 100 500 Tm (Crossed) Tj ET");
text::replace_line(&mut crossed, 0, 0, "Horizontal").unwrap();
assert_eq!(listed(&reopen(&crossed)), ["Horizontal", "Crossed"]);
// An invisible OCR text layer (`3 Tr`) on the same baseline and size as a visible line is
// still removed when the visible line is edited, even if OCR misread a letter.
let mut ocr = text_page("BT /F1 12 Tf 0 Tr 1 0 0 1 72 700 Tm (Hello world) Tj ET BT /F1 12 Tf 3 Tr 1 0 0 1 72 700 Tm (Rello world) Tj ET");
text::replace_line(&mut ocr, 0, 0, "Goodbye world").unwrap();
assert_eq!(listed(&reopen(&ocr)), ["Goodbye world"]);
}
#[test]
fn paragraphs_move_and_rewrap_to_a_new_width() {
let para = "BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj ET";
let close = |a: f64, b: f64| (a - b).abs() < 0.5;
// Moved 50 right and 200 down, same words and wrapping.
let mut doc = text_page(para);
let before = text::text_blocks(&doc, 0).unwrap()[0].clone();
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { offset: Some([50.0, -200.0]), ..Default::default() }).unwrap();
let doc = reopen(&doc);
let after = text::text_blocks(&doc, 0).unwrap()[0].clone();
assert_eq!((after.text.as_str(), after.lines.len()), (before.text.as_str(), before.lines.len()));
assert!(close(after.rect[0], before.rect[0] + 50.0) && close(after.rect[1], before.rect[1] - 200.0), "{:?} → {:?}", before.rect, after.rect);
// Drawn at half scale: the move is still in page space.
let mut doc = text_page(&format!("q 0.5 0 0 0.5 0 0 cm {} Q", para.replace("100 700 Td", "200 1400 Td")));
let before = text::text_blocks(&doc, 0).unwrap()[0].clone();
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { offset: Some([30.0, 40.0]), ..Default::default() }).unwrap();
let after = text::text_blocks(&reopen(&doc), 0).unwrap()[0].clone();
assert!(close(after.rect[0], before.rect[0] + 30.0) && close(after.rect[1], before.rect[1] + 40.0), "{:?} → {:?}", before.rect, after.rect);
// A narrower box rewraps into more lines that fit it; a wider one into fewer.
let mut doc = text_page(para);
let left = text::text_blocks(&doc, 0).unwrap()[0].rect[0];
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { width: Some(80.0), ..Default::default() }).unwrap();
let narrow = text::text_blocks(&reopen(&doc), 0).unwrap()[0].clone();
assert!(narrow.lines.len() > 2, "{narrow:?}");
assert!(narrow.rect[2] <= left + 80.5, "{narrow:?}");
let mut doc = text_page(para);
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { width: Some(400.0), ..Default::default() }).unwrap();
assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap()[0].lines.len(), 1);
// Untrusted numbers (the automation tools pass them through) are refused, not drawn.
let mut doc = text_page(para);
for style in [
text::BlockStyle { offset: Some([f64::NAN, 0.0]), ..Default::default() },
text::BlockStyle { offset: Some([0.0, f64::INFINITY]), ..Default::default() },
text::BlockStyle { width: Some(f64::NAN), ..Default::default() },
] {
assert!(text::rewrite_block(&mut doc, 0, 0, None, &style).is_err(), "{style:?}");
}
// A zero width is clamped to one character's width rather than looping or vanishing.
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { width: Some(0.0), ..Default::default() }).unwrap();
assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap().iter().map(|b| b.text.split_whitespace().count()).sum::<usize>(), 12);
}
/// A page split into several content streams, as AutoCAD writes them: the first scales the
/// page (`0.12 0 0 0.12 0 0 cm`, no `q`), the text comes in a later one.
fn split_page(first: &str, second: &str) -> Document {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents [4 0 R 5 0 R] /Resources << /Font << /F1 6 0 R >> >> >>".into(),
format!("<< /Length {} >>\nstream\n{first}\nendstream", first.len()),
format!("<< /Length {} >>\nstream\n{second}\nendstream", second.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
#[test]
fn the_graphics_state_carries_over_between_content_streams() {
let close = |a: f64, b: f64| (a - b).abs() < 0.5;
// Text at (1000, 2000) in a space scaled by 0.12: on the page at (120, 240), 12 pt.
let mut doc = split_page("0.12 0 0 0.12 0 0 cm", "BT /F1 100 Tf 1000 2000 Td (Site plan) Tj ET");
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 1, "{lines:?}");
let l = &lines[0];
assert!(close(l.rect[0], 120.0) && l.rect[1] > 230.0 && l.rect[3] < 260.0, "{:?}", l.rect);
assert!(close(l.size, 12.0), "{}", l.size);
// Editing keeps the paragraph where it was, at its size; moving it moves it in page space.
text::rewrite_block(&mut doc, 0, 0, Some("Site plan, rev. B"), &text::BlockStyle { offset: Some([10.0, -20.0]), ..Default::default() }).unwrap();
let after = text::text_blocks(&reopen(&doc), 0).unwrap();
assert_eq!(after[0].text, "Site plan, rev. B");
assert!(close(after[0].rect[0], 130.0) && close(after[0].rect[1], l.rect[1] - 20.0), "{:?}", after[0].rect);
assert!(close(after[0].size, 12.0), "{}", after[0].size);
// A `q` left open in one stream and closed in the next still restores the state.
let doc = split_page("q 0.5 0 0 0.5 0 0 cm", "Q BT /F1 10 Tf 100 100 Td (Footpath) Tj ET");
let l = &text::text_lines(&doc, 0).unwrap()[0];
assert!(close(l.rect[0], 100.0) && close(l.size, 10.0), "{l:?}");
}
/// One page whose font draws a few Hebrew letters (ToUnicode): A ב, B ד, C י, D ו, E ק, F ש,
/// G ל, H ם. Content streams draw Hebrew in visual order, so "בדיוק" is `(EDCBA)`.
fn hebrew_page(content: &str) -> Document {
let cmap = "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapType 2 def \
1 begincodespacerange <00> <FF> endcodespacerange 9 beginbfchar \
<20> <0020> <41> <05D1> <42> <05D3> <43> <05D9> <44> <05D5> <45> <05E7> <46> <05E9> <47> <05DC> <48> <05DD> \
endbfchar endcmap CMapName currentdict /CMap defineresource pop end end";
// Widths for codes 32..=72: a space, then the eight letters at 65..=72.
let widths: Vec<&str> = (32..=72)
.map(|c| {
if c == 32 {
"250"
} else if c >= 65 {
"500"
} else {
"0"
}
})
.collect();
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
format!(
"<< /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+FakeHebrew /FirstChar 32 /LastChar 72 /Widths [{}] /ToUnicode 6 0 R >>",
widths.join(" ")
),
format!("<< /Length {} >>\nstream\n{cmap}\nendstream", cmap.len()),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
/// The report: Edit text showed "בדיוק" (exactly) reversed, because the line was read in the
/// order the page draws it.
#[test]
fn hebrew_lines_read_in_typing_order() {
let doc = hebrew_page("BT /F1 12 Tf 300 700 Td (EDCBA) Tj ET BT /F1 12 Tf 300 600 Td (EDCBA HDGF) Tj ET");
let lines: Vec<String> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
assert_eq!(lines, ["בדיוק", "שלום בדיוק"]);
let blocks: Vec<String> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
assert_eq!(blocks, ["בדיוק", "שלום בדיוק"]);
}
/// Text the page's own font can show is written back in drawing order, right-aligned to where
/// the line ended, and reads back as typed.
#[test]
fn hebrew_edits_are_drawn_right_to_left_and_keep_their_right_edge() {
let mut doc = hebrew_page("BT /F1 12 Tf 300 700 Td (EDCBA) Tj ET");
let before = text::text_lines(&doc, 0).unwrap()[0].rect;
// One line: the bytes are the visual order of the typed text.
assert_eq!(text::replace_line(&mut doc, 0, 0, "שלום בדיוק").unwrap().substituted, None);
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert!(content.contains("(EDCBA HDGF)"), "{content}");
assert_eq!(text::text_lines(&reopen(&doc), 0).unwrap()[0].text, "שלום בדיוק");
// A paragraph: shorter text stays right-aligned at the old right edge.
let mut doc = hebrew_page("BT /F1 12 Tf 300 700 Td (EDCBA) Tj ET");
assert_eq!(text::replace_block(&mut doc, 0, 0, "דוב").unwrap().substituted, None);
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert!(content.contains("(ADB)"), "{content}");
let doc = reopen(&doc);
let line = text::text_lines(&doc, 0).unwrap().swap_remove(0);
assert_eq!(line.text, "דוב");
assert!((line.rect[2] - before[2]).abs() < 0.5, "right edge {} → {}", before[2], line.rect[2]);
assert!(line.rect[0] > before[0] + 1.0, "shorter text starts further right: {:?} vs {before:?}", line.rect);
// Longer text grows to the left, not off the right edge.
let mut doc = hebrew_page("BT /F1 12 Tf 300 700 Td (EDCBA) Tj ET");
text::replace_block(&mut doc, 0, 0, "שלום בדיוק שלום").unwrap();
let doc = reopen(&doc);
let line = text::text_lines(&doc, 0).unwrap().swap_remove(0);
assert_eq!(line.text, "שלום בדיוק שלום");
assert!((line.rect[2] - before[2]).abs() < 0.5, "right edge {} → {}", before[2], line.rect[2]);
assert!(line.rect[0] < before[0] - 1.0, "longer text grows left: {:?} vs {before:?}", line.rect);
// An alignment asked for still wins.
let mut doc = hebrew_page("BT /F1 12 Tf 300 700 Td (EDCBA) Tj ET");
let left = text::BlockStyle { align: Some(Align::Left), ..Default::default() };
text::rewrite_block(&mut doc, 0, 0, Some("דוב"), &left).unwrap();
let line = text::text_lines(&reopen(&doc), 0).unwrap().swap_remove(0);
assert!((line.rect[0] - before[0]).abs() < 0.5, "left-aligned: {:?} vs {before:?}", line.rect);
}
/// A generated Type 3 font numbers its glyphs from 1, so code 32 is a character, not the space:
/// justified rows are stretched after each space, never with word spacing.
#[test]
fn justified_text_in_the_type3_fallback_stretches_its_spaces() {
let src = "BT /F1 12 Tf 14 TL 100 700 Td (Some words to fill a row of text) Tj T* (and another one below it) Tj T* (end) Tj ET";
let mut doc = text_page(src);
let rect = text::text_blocks(&doc, 0).unwrap()[0].rect;
// More than 32 different characters, so one of them gets code 32.
let typed = "日本語の 文章を 書きます 東京 大阪 京都 名古屋 札幌 福岡 神戸 横浜 仙台 広島 千葉 新潟 静岡 熊本 岡山 金沢";
let style = text::BlockStyle { align: Some(added::Align::Justify), ..Default::default() };
let rewritten = text::rewrite_block(&mut doc, 0, 0, Some(typed), &style);
if pdfcraft_fonts::document_japanese_font().is_none() {
assert!(rewritten.is_err());
eprintln!("built without a craft-fonts Japanese face: the Type 3 path is checked with CRAFT_FONTS_DIR");
return;
}
rewritten.unwrap();
let lines = text::text_lines(&doc, 0).unwrap();
assert!(lines.len() > 2, "{lines:?}");
for l in &lines[..lines.len() - 1] {
assert!((l.rect[2] - rect[2]).abs() < 1.0, "{:?} spans {:?}, not {rect:?}", l.text, l.rect);
}
let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops;
assert!(ops.iter().any(|o| o.is("TJ")), "{ops:?}");
assert!(ops.iter().filter(|o| o.is("Tw")).all(|o| o.operands.first().and_then(|n| n.as_f64()) == Some(0.0)), "{ops:?}");
}
/// Letters the page's font lacks are drawn with the Hebrew face from craft-fonts; a build
/// without it says so and changes nothing. Hebrew points are refused, not misdrawn.
#[test]
fn hebrew_letters_the_page_font_lacks_use_the_hebrew_face() {
let mut doc = hebrew_page("BT /F1 12 Tf 300 700 Td (EDCBA) Tj ET");
let before = page_content_bytes(&doc, 0);
let pointed = text::replace_line(&mut doc, 0, 0, "שָׁלוֹם עוֹלָם");
let Err(EditError::Invalid(msg)) = pointed else { panic!("expected a clear error, got {pointed:?}") };
assert!(msg.contains("niqqud"), "{msg}");
assert_eq!(page_content_bytes(&doc, 0), before);
// ע and ה aren't in the page's font.
let typed = "שלום עולם, עמוד 12 (PDF)";
let line = text::replace_line(&mut doc, 0, 0, typed);
if pdfcraft_fonts::document_hebrew_font(false).is_none() {
let Err(EditError::Invalid(msg)) = line else { panic!("expected a clear error, got {line:?}") };
assert!(msg.contains("Hebrew fallback font") && msg.contains("CRAFT_FONTS_DIR"), "{msg}");
assert_eq!(page_content_bytes(&doc, 0), before);
eprintln!("built without a craft-fonts Hebrew face: the Type 3 path is checked with CRAFT_FONTS_DIR");
return;
}
assert_eq!(line.unwrap().substituted.as_deref(), Some("Noto Sans Hebrew Type3"));
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert!(content.contains("/PCHe"), "{content}");
let reopened = reopen(&doc);
assert_eq!(text::text_lines(&reopened, 0).unwrap()[0].text, typed);
// Every glyph has an outline, so nothing is drawn as a box.
let p = pdfcraft_model::pages(&reopened).swap_remove(0);
let res = reopened.resolve(p.dict.get(b"Resources").unwrap());
let fonts = reopened.resolve(res.as_dict().unwrap().get(b"Font").unwrap());
let font = reopened.resolve(fonts.as_dict().unwrap().get(b"PCHe").unwrap());
let procs = reopened.resolve(font.as_dict().unwrap().get(b"CharProcs").unwrap());
let procs = procs.as_dict().unwrap();
assert_eq!(procs.iter().count(), typed.chars().collect::<std::collections::HashSet<_>>().len());
for (_, glyph) in procs.iter() {
let pdfcraft_cos::Object::Stream(s) = &*reopened.resolve(glyph) else { panic!("glyph stream") };
let path = String::from_utf8(s.decoded().unwrap()).unwrap();
assert!(path.starts_with(|c: char| c.is_ascii_digit()) && path.contains(" d1"), "{path}");
}
// A whole paragraph too, right-aligned.
let mut doc = hebrew_page("BT /F1 12 Tf 300 700 Td (EDCBA) Tj ET");
assert_eq!(text::replace_block(&mut doc, 0, 0, "עברית מלאה").unwrap().substituted.as_deref(), Some("Noto Sans Hebrew Type3"));
assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap()[0].text, "עברית מלאה");
}
/// #2013: the dialog asks `first_undrawable` before offering OK, so it must refuse exactly what
/// applying refuses: a Cyrillic footer was previewed (in the interface font) and then failed.
#[test]
fn header_and_watermark_drawability_matches_what_applying_refuses() {
let footer = |t: &str| HeaderFooter {
text: [String::new(), String::new(), String::new(), String::new(), String::new(), t.into()],
..HeaderFooter::default()
};
for (text, refused) in [
("Страница <<1>> из 1 / Page <<1>> of 1", Some('С')),
("TEST-001 <<Page 1 of n>>", None),
("Café — 5€ ™ <<Bates Number#6#1#ACME-#>>", None),
("Left\r\nRight", None),
("A\rB", Some('\r')),
("line one\nΕλληνικά", Some('Ε')),
] {
let hf = footer(text);
assert_eq!(hf.first_undrawable(), refused, "{text:?}");
let mut doc = fixture();
let applied = add_header_footer(&mut doc, &[0], &hf, false, &cx());
match refused {
Some(c) => assert_eq!(applied.unwrap_err().to_string(), undrawable_message(c), "{text:?}"),
None => applied.unwrap(),
}
let wm = Watermark { text: text.into(), ..Watermark::default() };
assert_eq!(wm.first_undrawable(), refused, "{text:?}");
let applied = add_watermark(&mut fixture(), &[0], &wm, false);
assert_eq!(applied.err().map(|e| e.to_string()), refused.map(undrawable_message), "{text:?}");
}
}
/// #125: page-content tools refuse text the standard fonts can't draw instead of writing `?`,
/// and leave the document untouched.
#[test]
fn page_text_the_standard_fonts_cant_draw_is_refused() {
let mut doc = fixture();
let before: Vec<Vec<String>> = (0..3).map(|p| streams(&reopen(&doc), p)).collect();
let text = AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: "日本語のテキスト".into(), ..AddedText::default() };
let err = add_content(&mut doc, 0, &Content::Text(text)).unwrap_err().to_string();
assert!(err.contains("\"日\" (U+65E5)"), "{err}");
let hf = HeaderFooter {
text: ["Ελληνικά".into(), String::new(), String::new(), String::new(), String::new(), String::new()],
..HeaderFooter::default()
};
assert!(add_header_footer(&mut doc, &[0, 1], &hf, true, &cx()).unwrap_err().to_string().contains("U+0395"));
let wm = Watermark { text: "机密".into(), ..Watermark::default() };
assert!(add_watermark(&mut doc, &[0], &wm, true).unwrap_err().to_string().contains("U+673A"));
let after: Vec<Vec<String>> = (0..3).map(|p| streams(&reopen(&doc), p)).collect();
assert_eq!(before, after, "a refused edit changes nothing");
// Western European text, including what only WinAnsi (not Latin-1) has, still works.
let text = AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: "Café — 5€ ™".into(), ..AddedText::default() };
add_content(&mut doc, 0, &Content::Text(text.clone())).unwrap();
// Retyping existing text is refused the same way, and the whole document is left as it was.
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
let retyped = AddedText { text: "Café 中".into(), ..text.clone() };
assert!(update_content(&mut doc, 0, 0, &Content::Text(retyped)).unwrap_err().to_string().contains("U+4E2D"));
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), saved, "a refused update changes nothing");
// A carriage return is checked where it would be drawn: added text splits on LF only, so
// CRLF would leave a `?`; headers and watermarks split CRLF into lines, but not a lone CR.
let crlf = AddedText { text: "A\r\nB".into(), ..text };
assert!(add_content(&mut doc, 0, &Content::Text(crlf)).unwrap_err().to_string().contains("U+000D"));
let hf = |t: &str| HeaderFooter {
text: [t.into(), String::new(), String::new(), String::new(), String::new(), String::new()],
..HeaderFooter::default()
};
add_header_footer(&mut doc, &[0], &hf("Left\r\n<<Bates Number#6#1#ACME-#>>"), true, &cx()).unwrap();
assert!(add_header_footer(&mut doc, &[0], &hf("A\rB"), true, &cx()).unwrap_err().to_string().contains("U+000D"));
// Arabic is shaped with the craft-fonts Arabic face (#403): drawable when that face is built
// in, refused (never written as `?`) when it isn't. Text left to the standard font still counts.
let arabic = |text: &str| AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: text.into(), ..AddedText::default() };
// U+061C is in the Arabic block but is a direction mark that's dropped, not shaped: it never
// makes text drawable, nor (with the face) undrawable.
assert!(add_content(&mut doc, 0, &Content::Text(arabic("\u{061C}中"))).is_err());
if pdfcraft_fonts::arabic_has('م') {
add_content(&mut doc, 0, &Content::Text(arabic("مرحبا Hello"))).unwrap();
add_content(&mut doc, 0, &Content::Text(arabic("\u{061C}Hello"))).unwrap();
for text in ["مرحبا 中", "\u{061C}中"] {
let err = add_content(&mut doc, 0, &Content::Text(arabic(text))).unwrap_err().to_string();
assert!(err.contains("U+4E2D"), "{text:?}: {err}");
}
} else {
// #403's own refusal says what's missing; the Add-text editor still keeps the draft.
let err = add_content(&mut doc, 0, &Content::Text(arabic("مرحبا Hello"))).unwrap_err().to_string();
assert!(err.contains("CRAFT_FONTS_DIR"), "{err}");
assert_eq!(first_undrawable(&arabic("مرحبا Hello")), Some('م'));
// An existing item may still take Arabic (#403 keeps it movable), but nothing else that
// the standard font would write as `?`.
let n = list_added(&doc).iter().filter(|a| a.page == 0).count();
add_content(&mut doc, 0, &Content::Text(arabic("x"))).unwrap();
let err = update_content(&mut doc, 0, n, &Content::Text(arabic("ب中"))).unwrap_err().to_string();
assert!(err.contains("U+4E2D"), "{err}");
update_content(&mut doc, 0, n, &Content::Text(arabic("ب"))).unwrap();
}
}
fn xobject(doc: &Document, page: usize, name: &[u8]) -> Option<pdfcraft_cos::ObjRef> {
let p = &pdfcraft_model::pages(doc)[page];
let res = doc.resolve(p.dict.get(b"Resources")?);
let xo = doc.resolve(res.as_dict()?.get(b"XObject")?);
xo.as_dict()?.get(name).and_then(Object::as_ref)
}
fn annot_at(doc: &Document, page: usize, index: usize) -> pdfcraft_cos::ObjRef {
let p = &pdfcraft_model::pages(doc)[page];
let list = doc.resolve(p.dict.get(b"Annots").unwrap());
list.as_array().unwrap()[index].as_ref().unwrap()
}
#[test]
fn fill_sign_flatten_bakes_marks_keeps_other_comments_and_does_not_reuse_pcfl0() {
use pdfcraft_annot::{FillMark, Markup, Meta, NewAnnotation, Shape, Style, add_annotation, summaries};
let mut doc = fixture();
assert_eq!(flatten_fill_sign(&mut doc, &[0]).unwrap(), 0);
assert!(!doc.is_modified(), "nothing to flatten leaves the file alone");
let meta = Meta { date: None, id: "x".into() };
let add = |doc: &mut Document, shape: Shape, contents: &str| {
let style = Style::default_for(&shape);
add_annotation(doc, &NewAnnotation { page: 0, shape, style, contents: contents.into(), author: "a".into() }, &meta).unwrap()
};
add(&mut doc, Shape::Rectangle { rect: [10.0, 10.0, 80.0, 40.0] }, "box");
assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1);
let kept = xobject(&doc, 0, b"PCFl0").expect("the first flatten's XObject");
add(&mut doc, Shape::Typewriter { rect: [20.0, 500.0, 140.0, 520.0], font_size: 10.0 }, "Hello");
doc.update_dict(annot_at(&doc, 0, 0), |d| {
d.remove(b"PCFillSign");
})
.unwrap();
add(&mut doc, Shape::Mark { rect: [100.0, 100.0, 120.0, 120.0], mark: FillMark::Check }, "");
add(&mut doc, Shape::Signature { strokes: vec![vec![[10.0, 50.0], [40.0, 80.0], [70.0, 50.0]]] }, "");
doc.update_dict(annot_at(&doc, 0, 2), |d| d.set(b"Subj".to_vec(), pdfcraft_cos::PdfString::text("Pencil"))).unwrap();
add(&mut doc, Shape::TypedSignature { rect: [20.0, 40.0, 90.0, 70.0], contours: vec![vec![[0.1, 0.2], [0.5, 0.9], [0.9, 0.2]]] }, "");
add(&mut doc, Shape::TextMarkup { kind: Markup::Highlight, quads: vec![[20.0, 400.0, 80.0, 400.0, 20.0, 390.0, 80.0, 390.0]] }, "keep");
add(&mut doc, Shape::TextBox { rect: [20.0, 300.0, 140.0, 340.0], font_size: 12.0 }, "Note");
add(&mut doc, Shape::Ink { strokes: vec![vec![[200.0, 200.0], [220.0, 220.0], [240.0, 200.0]]] }, "pencil");
let old = add(&mut doc, Shape::Signature { strokes: vec![vec![[300.0, 50.0], [330.0, 80.0], [360.0, 50.0]]] }, "");
doc.update_dict(annot_at(&doc, 0, old), |d| {
d.remove(b"PCFillSign");
})
.unwrap();
let hidden = add(&mut doc, Shape::Typewriter { rect: [20.0, 600.0, 140.0, 620.0], font_size: 10.0 }, "secret");
doc.update_dict(annot_at(&doc, 0, hidden), |d| d.set(b"F".to_vec(), Object::Int(2))).unwrap();
let n = flatten_fill_sign(&mut doc, &[0]).unwrap();
assert_eq!(n, 5, "text, check, both signatures and the typed signature; the hidden one stays");
assert_eq!(xobject(&doc, 0, b"PCFl0"), Some(kept), "an existing PCFl0 is not replaced");
let newest = streams(&doc, 0).last().unwrap().clone();
assert!(newest.contains("/PCFl1 Do") && !newest.contains("/PCFl0 Do"), "{newest}");
let doc = reopen(&doc);
let mut left: Vec<String> = summaries(&doc).iter().map(|s| s.contents.clone().unwrap_or_default()).collect();
left.sort();
assert_eq!(left, ["Note", "keep", "pencil", "secret"]);
assert!(summaries(&doc).iter().any(|s| s.intent.as_deref() == Some("FreeTextTypeWriter")), "the hidden typewriter stays");
assert_eq!(summaries(&doc).iter().filter(|s| s.intent.as_deref() == Some("FreeTextTypeWriter")).count(), 1);
}
/// A one-page document from its objects (object 1 is the catalog).
fn build(objs: &[&str]) -> Document {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
fn stream(dict: &str, data: &str) -> String {
format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len())
}
/// #314: text and images drawn by (nested) form XObjects are read for export, placed through
/// every form matrix and set in each form's own fonts; editing still sees only the page's text.
#[test]
fn reading_follows_form_xobjects() {
let page = stream("", "q 1 0 0 1 10 0 cm /Fm1 Do Q BT /F1 12 Tf 72 100 Td (Page text) Tj ET");
let fm1 = stream(
"/Type /XObject /Subtype /Form /BBox [0 0 600 800] /Matrix [1 0 0 1 0 50] /Resources << /Font << /F1 7 0 R >> /XObject << /Fm2 8 0 R >> >>",
"BT /F1 18 Tf 72 700 Td (Hello from a test invoice) Tj ET /Fm2 Do",
);
let fm2 = stream(
"/Type /XObject /Subtype /Form /BBox [0 0 300 400] /Matrix [2 0 0 2 0 0] /Resources << /Font << /F2 9 0 R >> /XObject << /Im1 10 0 R >> >>",
"BT /F2 10 Tf 50 100 Td (Nested) Tj ET q 20 0 0 10 5 5 cm /Im1 Do Q",
);
let image = stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", "A");
let doc = build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << /Font << /F1 5 0 R >> /XObject << /Fm1 6 0 R >> >> /Contents 4 0 R >>",
&page,
"<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>",
&fm1,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
&fm2,
"<< /Type /Font /Subtype /Type1 /BaseFont /Times-Bold >>",
&image,
]);
let close = |a: f64, b: f64| (a - b).abs() < 0.01;
let blocks = text::reading_blocks(&doc, 0).unwrap();
let texts: Vec<&str> = blocks.iter().map(|b| b.text.as_str()).collect();
assert_eq!(texts, ["Hello from a test invoice", "Nested", "Page text"]);
let (hello, nested, own) = (&blocks[0], &blocks[1], &blocks[2]);
// Hello: (72, 700) moved by the form's (0, 50) and the page's (10, 0).
assert_eq!(hello.base_font, "Helvetica", "the form's own /F1, not the page's");
assert!(close(hello.rect[0], 82.0) && close(hello.size, 18.0), "{hello:?}");
// Nested: (50, 100) doubled by Fm2, then Fm1 and the page: (110, 250), 20 pt.
assert_eq!(nested.base_font, "Times-Bold");
assert!(nested.bold);
assert!(close(nested.rect[0], 110.0) && close(nested.size, 20.0), "{nested:?}");
assert_eq!(own.base_font, "Courier");
let images = images::reading_images(&doc, 0).unwrap();
assert_eq!(images.len(), 1, "{images:?}");
let r = images[0].rect;
assert!(close(r[0], 20.0) && close(r[1], 60.0) && close(r[2], 60.0) && close(r[3], 80.0), "{r:?}");
assert_eq!((images[0].width, images[0].height), (1, 1));
// Editing works on the page's own streams only, unchanged.
let editable: Vec<String> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
assert_eq!(editable, ["Page text"]);
// The page draws one figure of its own (Fm1, edited as a whole); the image inside Fm2 is not
// a page image.
let own = images::page_images(&doc, 0).unwrap();
assert_eq!(own.len(), 1, "{own:?}");
assert!(own[0].is_form && own[0].object == Some(pdfcraft_cos::ObjRef::new(6, 0)), "{own:?}");
}
/// A chain of forms, each drawing the next twenty times, twelve deep: within the depth cap but
/// 20^11 visits without a budget. Reading stops at the page's visit budget instead of hanging.
#[test]
fn form_xobjects_that_fan_out_stop_at_the_visit_budget() {
let font = "/Font << /F1 5 0 R >>";
let levels = 12;
let mut objs = vec![
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << /Font << /F1 5 0 R >> /XObject << /N 6 0 R >> >> /Contents 4 0 R >>"
.to_string(),
stream("", "/N Do"),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
];
for level in 0..levels {
let next = 7 + level;
objs.push(if level + 1 == levels {
stream(&format!("/Subtype /Form /BBox [0 0 9 9] /Resources << {font} >>"), "BT /F1 10 Tf 10 10 Td (leaf) Tj ET")
} else {
stream(&format!("/Subtype /Form /BBox [0 0 9 9] /Resources << {font} /XObject << /N {next} 0 R >> >>"), &"/N Do ".repeat(20))
});
}
let refs: Vec<&str> = objs.iter().map(String::as_str).collect();
let doc = build(&refs);
let start = std::time::Instant::now();
let _ = text::reading_blocks(&doc, 0).unwrap();
assert!(images::reading_images(&doc, 0).unwrap().is_empty());
assert!(start.elapsed() < std::time::Duration::from_secs(30), "took {:?}", start.elapsed());
}
/// #3120: a one-page document whose page draws "Secret", then `content`. `forms` are (name,
/// content) form XObjects, which the page and every form can draw, as they can a 1×1 image `/Im`.
fn page_with_forms(content: &str, forms: &[(String, String)]) -> Document {
let xobjects: String = forms.iter().enumerate().map(|(i, (name, _))| format!("/{name} {} 0 R ", 7 + i)).collect();
let resources = format!("<< /Font << /F1 5 0 R >> /XObject << /Im 6 0 R {xobjects}>> >>");
let mut objs = vec![
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources {resources} /Contents 4 0 R >>"),
stream("", &format!("BT /F1 24 Tf 72 700 Td (Secret) Tj ET\n{content}")),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", "A"),
];
objs.extend(forms.iter().map(|(_, body)| stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 612 792] /Resources {resources}"), body)));
build(&objs.iter().map(String::as_str).collect::<Vec<_>>())
}
/// #3120: export reads text from forms as deep and as often as the renderer draws them, and says
/// when a limit left some out: a text form drawn 5 000 times (past the old budget of 4 096) or
/// forms nested 13 deep (past the old 12) lost the form drawn after them, silently.
#[test]
fn reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops() {
// Covers "Secret" with a black box and writes "TAIL": drawn last, so a reader that stops
// early misses it.
const TAIL: &str = "0 g 70 692 90 30 re f BT /F1 24 Tf 72 600 Td (TAIL) Tj ET";
let tail = |doc: &Document| {
let read = text::reading_blocks_checked(doc, 0).unwrap();
(read.blocks.iter().any(|b| b.text.contains("TAIL")), read.truncated)
};
let form = |name: &str, body: &str| (name.to_string(), body.to_string());
let rows =
|n: usize| page_with_forms(&format!("{}/C Do", "/R Do ".repeat(n)), &[form("R", "BT /F1 6 Tf 300 100 Td (row) Tj ET"), form("C", TAIL)]);
assert_eq!(tail(&rows(5_000)), (true, false));
// The innermost form also draws an image: an image at the depth limit is read, and leaves
// nothing out.
let chain = |depth: usize| {
let mut forms: Vec<_> = (1..depth).map(|i| form(&format!("D{i}"), &format!("/D{} Do", i + 1))).collect();
forms.push(form(&format!("D{depth}"), &format!("{TAIL} q 20 0 0 10 5 5 cm /Im Do Q")));
page_with_forms("/D1 Do", &forms)
};
assert_eq!(tail(&chain(13)), (true, false));
// The renderer's limit, written out so the test does not move with the constant.
assert_eq!(tail(&chain(50)), (true, false));
assert_eq!(tail(&chain(51)), (false, true));
let deepest = chain(text::MAX_FORM_DEPTH);
assert_eq!(tail(&deepest), (true, false));
assert_eq!(images::reading_images(&deepest, 0).unwrap().len(), 1);
let deep = chain(text::MAX_FORM_DEPTH + 1);
assert_eq!(tail(&deep), (false, true));
assert!(images::reading_images(&deep, 0).unwrap().is_empty());
// A wrapper form A draws a dot form B `n` times, then C. A, the dots and C use the page's
// budget up exactly; one dot more leaves C unread.
let fanout =
|n: usize| page_with_forms("/A Do", &[form("A", &format!("{}/C Do", "/B Do ".repeat(n))), form("B", "0 g 10 10 1 1 re f"), form("C", TAIL)]);
assert_eq!(tail(&fanout(text::MAX_FORM_VISITS - 2)), (true, false));
let many = fanout(text::MAX_FORM_VISITS - 1);
assert_eq!(tail(&many), (false, true));
assert!(images::reading_images(&many, 0).unwrap().is_empty());
// Without the flag, reading is unchanged.
let blocks: Vec<String> = text::reading_blocks(&many, 0).unwrap().into_iter().map(|b| b.text).collect();
assert_eq!(blocks, ["Secret"]);
}
/// #3120: with the renderer's limits, forms that each draw the next a hundred times, 12 levels
/// deep (100^11 forms) or past the depth cap, still stop at the page's budget quickly, and say
/// that text was left out.
#[test]
fn forms_that_fan_out_a_hundred_ways_stop_quickly_and_say_so() {
for levels in [12, text::MAX_FORM_DEPTH + 1] {
let mut forms: Vec<(String, String)> = (1..levels).map(|i| (format!("N{i}"), format!("/N{} Do ", i + 1).repeat(100))).collect();
forms.push((format!("N{levels}"), "BT /F1 10 Tf 10 10 Td (leaf) Tj ET".to_string()));
let doc = page_with_forms("/N1 Do", &forms);
let start = std::time::Instant::now();
assert!(text::reading_blocks_checked(&doc, 0).unwrap().truncated, "{levels} levels");
assert!(images::reading_images(&doc, 0).unwrap().is_empty());
assert!(start.elapsed() < std::time::Duration::from_secs(30), "{levels} levels 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 added_thai_text_wraps_clusters_and_survives_reopen() {
let mut doc = fixture();
let t = AddedText {
rect: [20.0, 100.0, 250.0, 300.0], text: "สวัสดี น้ำ ผู้ใช้ ปู่ ๑๒๓ Hello 123".into(), size: 16.0, ..AddedText::default()
};
if pdfcraft_fonts::document_thai_font().is_none() {
assert_eq!(first_undrawable(&t), Some('ส'));
let before = list_added(&doc);
let err = add_content(&mut doc, 0, &Content::Text(t)).unwrap_err().to_string();
assert!(err.contains("Thai") && err.contains("CRAFT_FONTS_DIR"), "{err}");
assert_eq!(list_added(&doc), before);
return;
}
assert_eq!(first_undrawable(&t), None);
add_content(&mut doc, 0, &Content::Text(t.clone())).unwrap();
let reopened = reopen(&doc);
assert_eq!(list_added(&reopened)[0].content, Content::Text(AddedText { rect: super::added::text_rect(&t), ..t.clone() }));
let narrow = AddedText { rect: [0.0, 0.0, 16.0, 200.0], text: "กิ่กิ่กิ่".into(), ..t.clone() };
assert_eq!(super::added::lines(&narrow), ["กิ่", "กิ่", "กิ่"]);
update_content(&mut doc, 0, 0, &Content::Text(AddedText { text: "แก้ไข น้ำ".into(), ..t.clone() })).unwrap();
assert!(!own(&doc, 0).is_empty());
let bad = Content::Text(AddedText { text: "ไทย 日".into(), ..t });
let before = list_added(&doc);
assert!(update_content(&mut doc, 0, 0, &bad).is_err());
assert_eq!(list_added(&doc), before);
delete_content(&mut doc, 0, 0).unwrap();
assert!(list_added(&doc).is_empty());
assert!(arabic_font_names(&doc, 0).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 image_before = images::page_images(&doc, 0).unwrap()[0].rect;
let offset = [0.00123456789, -0.00987654321];
move_objects(&mut doc, 0, &targets, offset).unwrap();
let reopened = reopen(&doc);
let image_after = images::page_images(&reopened, 0).unwrap()[0].clone();
assert!(
close(image_after.rect, [image_before[0] + offset[0], image_before[1] + offset[1], image_before[2] + offset[0], image_before[3] + offset[1]]),
"full image placement preserves sub-point movement"
);
let after = editable_objects(&reopened, 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] };
if old.target.kind == crate::ObjectKind::Image {
// The full placement moves; the visible right/top edges stay at the page crop.
assert_eq!(Some(new.rect), image_after.visible_rect);
} else {
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 justified_text_in_a_composite_font_fills_each_row_with_adjustments_after_its_spaces() {
// Two-byte codes, so word spacing (single-byte code 32 only) can't stretch the spaces.
let hex = |s: &str| s.bytes().map(|b| format!("00{b:02x}")).collect::<String>();
let src = format!(
"BT /F1 10 Tf 12 TL 100 700 Td <{}> Tj T* <{}> Tj T* <{}> Tj ET",
hex("One two three four five six seven"),
hex("eight nine ten eleven twelve"),
hex("and then the end of it")
);
let mut doc = text_page(&src);
let unicode = doc.add(Object::Stream(Stream::flate(
Dict::new(),
b"1 begincodespacerange <0000> <ffff> endcodespacerange 1 beginbfrange <0020> <007e> <0020> endbfrange",
)));
let 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")),
(b"ToUnicode".to_vec(), Object::Ref(unicode)),
(
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(500)),
]))]),
),
]);
let p = pdfcraft_model::pages(&doc)[0].obj;
let resources = Dict::from_iter([(b"Font".to_vec(), Object::Dict(Dict::from_iter([(b"F1".to_vec(), Object::Dict(font))])))]);
doc.update_dict(p, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap();
let before = text::text_blocks(&doc, 0).unwrap()[0].clone();
let right = before.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();
assert!(lines.len() > 2, "{lines:?}");
for l in &lines[..lines.len() - 1] {
assert!((l.rect[2] - right).abs() < 1.0, "{:?} ends at {} not {right}", l.text, l.rect[2]);
}
// The source font shows the text, with its spaces, and the text reads as before, rewrapped.
let flat = |s: &str| s.replace('\n', " ");
assert_eq!(flat(&text::text_blocks(&doc, 0).unwrap()[0].editor_text), flat(&before.editor_text));
let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops;
assert!(ops.iter().all(|o| !o.is("Tf") || o.operands.first().and_then(|n| n.as_name()) == Some(b"F1".as_slice())), "{ops:?}");
assert!(ops.iter().any(|o| o.is("TJ")), "{ops:?}");
assert!(ops.iter().filter(|o| o.is("Tw")).all(|o| o.operands.first().and_then(|n| n.as_f64()) == Some(0.0)), "{ops:?}");
}
#[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");
}
}
#[test]
fn arabic_actual_text_keeps_typing_order_when_opened_for_editing() {
for logical in ["مرحبا بالعالم", "سَلَام 123 (PDF)", "English مرحبا ١٢٣", "PDFمرحبا", "مرحبا", "שלום עולם", "Latin text"]
{
let hex: String = logical.encode_utf16().map(|u| format!("{u:04X}")).collect();
for drawing in ["(abcd) Tj (efgh) Tj", "(X) Tj"] {
let doc = text_page(&format!("/Span <</ActualText <FEFF{hex}>>> BDC BT /F1 12 Tf 72 700 Td {drawing} ET EMC"));
for doc in [&doc, &reopen(&doc)] {
let lines = text::text_lines(doc, 0).unwrap();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text, logical, "ActualText is already in typing order");
assert_eq!(text::text_blocks(doc, 0).unwrap()[0].editor_text, logical);
}
}
}
}
#[test]
fn latin_ligature_actual_text_stays_on_its_line() {
// Word and Chrome wrap ligatures in ActualText spans mid-word ("fi" drawn, "fi" meant).
let hex: String = "fi".encode_utf16().map(|u| format!("{u:04X}")).collect();
let doc = text_page(&format!("BT /F1 12 Tf 72 700 Td (de) Tj /Span <</ActualText <FEFF{hex}>>> BDC (X) Tj EMC (ned well) Tj ET"));
for doc in [&doc, &reopen(&doc)] {
let lines = text::text_lines(doc, 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["defined well"]);
assert_eq!(text::text_blocks(doc, 0).unwrap()[0].editor_text, "defined well");
}
}
/// #3110: `/ActualText` may come from a named property list (`/Span /MC0 BDC`) looked up in the
/// page's or the form's `/Properties`, as well as from an inline one.
#[test]
fn actual_text_from_named_property_list() {
let properties = "/Properties << /MC0 << /ActualText (Hello World) >> /MC1 << /Lang (en) >> >>";
let page_with = |content: &str| {
build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
&format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> {properties} >> /Contents 4 0 R >>"),
&stream("", content),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
])
};
for (content, expected) in [
("/Span /MC0 BDC BT /F1 24 Tf 72 700 Td (Glyphs) Tj ET EMC", "Hello World"),
("/Span <</ActualText (Hello World)>> BDC BT /F1 24 Tf 72 700 Td (Glyphs) Tj ET EMC", "Hello World"),
("/Span /MC1 BDC BT /F1 24 Tf 72 700 Td (Glyphs) Tj ET EMC", "Glyphs"),
("/Span /Missing BDC BT /F1 24 Tf 72 700 Td (Glyphs) Tj ET EMC", "Glyphs"),
] {
let doc = page_with(content);
for doc in [&doc, &reopen(&doc)] {
let lines = text::text_lines(doc, 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), [expected], "{content}");
assert_eq!(text::text_blocks(doc, 0).unwrap()[0].editor_text, expected, "{content}");
let reading: Vec<String> = text::reading_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect();
assert_eq!(reading, [expected], "{content}");
}
}
// A form's named list is looked up in the form's own /Properties, not the page's.
let fm = stream(
"/Type /XObject /Subtype /Form /BBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> /Properties << /MC0 << /ActualText (Form text) >> >> >>",
"/Span /MC0 BDC BT /F1 24 Tf 72 600 Td (Glyphs) Tj ET EMC",
);
let doc = build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
&format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> /XObject << /Fm1 6 0 R >> {properties} >> /Contents 4 0 R >>"
),
&stream("", "/Fm1 Do"),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
&fm,
]);
let reading: Vec<String> = text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
assert_eq!(reading, ["Form text"]);
}
#[test]
fn actual_text_and_visual_rtl_glyphs_keep_their_own_reading_order() {
let hex: String = "שלום".encode_utf16().map(|u| format!("{u:04X}")).collect();
let doc = hebrew_page(&format!("BT /F1 12 Tf 72 700 Td (EDCBA) Tj /Span <</ActualText <FEFF{hex}>>> BDC (FGHD) Tj EMC (EDCBA) Tj ET"));
for doc in [&doc, &reopen(&doc)] {
let lines = text::text_lines(doc, 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["בדיוק", "שלום", "בדיוק"]);
}
}
/// Synthetic codes A س, B م and X a lam-alef glyph, drawn from left to right.
fn arabic_ligature_page(content: &str, composite: bool) -> Document {
let prefix = if composite { "00" } else { "" };
let cmap = format!(
"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapType 2 def \
1 begincodespacerange <{prefix}00> <{prefix}FF> endcodespacerange 4 beginbfchar \
<{prefix}20> <0020> <{prefix}41> <0633> <{prefix}42> <0645> <{prefix}58> <FEFB> \
endbfchar endcmap CMapName currentdict /CMap defineresource pop end end"
);
let widths: Vec<_> = (32..=88).map(|c| if c == 32 { "250" } else { "500" }).collect();
let font = if composite {
"<< /Type /Font /Subtype /Type0 /BaseFont /FakeArabic /Encoding /Identity-H /DescendantFonts [7 0 R] /ToUnicode 6 0 R >>".into()
} else {
format!("<< /Type /Font /Subtype /Type1 /BaseFont /FakeArabic /FirstChar 32 /LastChar 88 /Widths [{}] /ToUnicode 6 0 R >>", widths.join(" "))
};
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] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
&stream("", content),
&font,
&stream("", &cmap),
"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /FakeArabic /CIDSystemInfo << /Registry (Synthetic) /Ordering (Identity) /Supplement 0 >> /DW 500 /W [32 [250]] >>",
])
}
#[test]
fn arabic_ligature_actual_text_stays_on_its_line() {
for (ligature, composite) in [("لا", false), ("لأ", false), ("لإ", false), ("لآ", false), ("لَا", false), ("لا", true)] {
let hex: String = ligature.encode_utf16().map(|u| format!("{u:04X}")).collect();
let (left, glyph, right) = if composite { ("<0042>", "<0058>", "<0041>") } else { ("(B)", "(X)", "(A)") };
let doc = arabic_ligature_page(
&format!("BT /F1 12 Tf 72 700 Td {left} Tj /Span <</ActualText <FEFF{hex}>>> BDC [{glyph}] TJ EMC {right} Tj ET"),
composite,
);
let expected = format!("س{ligature}م");
for doc in [&doc, &reopen(&doc)] {
let lines = text::text_lines(doc, 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), [expected.as_str()]);
assert_eq!(text::text_blocks(doc, 0).unwrap()[0].editor_text, expected);
}
}
}
#[test]
fn multi_glyph_rtl_actual_text_keeps_its_own_reading_order() {
// Two encoded glyphs are a logical span, even if their replacement looks like a ligature.
for drawing in ["(XX) Tj", "(X) Tj (X) Tj", "[(X) (X)] TJ"] {
let doc =
arabic_ligature_page(&format!("BT /F1 12 Tf 72 700 Td (B) Tj /Span <</ActualText <FEFF06440627>>> BDC {drawing} EMC (A) Tj ET"), false);
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["م", "لا", "س"]);
}
}
#[test]
fn replacing_word_with_actual_text_ligature_removes_all_its_source_operators() {
let mut doc = arabic_ligature_page(
"BT /F1 12 Tf 72 700 Td (B) Tj /Span <</ActualText <FEFF06440627>>> BDC /Span BMC (X) Tj EMC EMC (A) Tj ET \
BT /F1 12 Tf 72 600 Td (AB) Tj ET",
false,
);
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, "سلام");
text::replace_block(&mut doc, 0, 0, "Replacement").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<_>>(), ["Replacement", "مس"]);
let bytes = page_content_bytes(&doc, 0);
let content = String::from_utf8_lossy(&bytes);
assert!(!content.contains("ActualText") && !content.contains("(X)") && !content.contains("(B)") && !content.contains("(A)"), "{content}");
}
#[test]
fn replacing_arabic_actual_text_removes_the_whole_original_drawing() {
let logical = "مرحبا بالعالم";
let hex: String = logical.encode_utf16().map(|u| format!("{u:04X}")).collect();
let mut doc = text_page(&format!(
"/Span <</ActualText <FEFF{hex}>>> BDC BT /F1 12 Tf 72 700 Td (abcd) Tj (efgh) Tj ET EMC \
BT /F1 12 Tf 72 600 Td (Keep this line) Tj ET"
));
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, logical);
text::replace_block(&mut doc, 0, 0, "Replacement").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<_>>(), ["Replacement", "Keep this line"]);
let bytes = page_content_bytes(&doc, 0);
let content = String::from_utf8_lossy(&bytes);
assert!(!content.contains("ActualText") && !content.contains("(abcd)") && !content.contains("(efgh)"));
}
fn mixed_region_page() -> Document {
let mut doc = text_page(
"BT /F2 1 Tf 18 0 0 18 123 700 Tm (TITLE) Tj ET \
BT /F2 1 Tf 12 0 0 12 117.6 676 Tm (two lines) Tj ET \
BT /F2 1 Tf 12 0 0 12 135.6 652 Tm (last) Tj ET \
BT 0 0.2 0.5 rg /F2 10 Tf 72 600 Td (About) Tj ET \
BT 0 g /F1 10 Tf 72 582 Td (Plain ) Tj /F2 10 Tf 0 0.2 0.5 rg (bold) Tj /F1 10 Tf 0 g ( text.) Tj ET \
BT 0.2 0.4 0.7 rg /F3 8 Tf 72 566 Td (Italic footer) Tj ET \
BT 0 g /F1 10 Tf 400 600 Td (Other column) Tj ET",
);
for (obj, base) in [(5, "Courier"), (6, "Courier-Bold")] {
let mut f = Dict::new();
f.set(b"Type".to_vec(), Object::name("Font"));
f.set(b"Subtype".to_vec(), Object::name("Type1"));
f.set(b"BaseFont".to_vec(), Object::name(base));
f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
doc.set(pdfcraft_cos::ObjRef::new(obj, 0), Object::Dict(f));
}
let mut f = Dict::new();
f.set(b"Type".to_vec(), Object::name("Font"));
f.set(b"Subtype".to_vec(), Object::name("Type1"));
f.set(b"BaseFont".to_vec(), Object::name("Courier-Oblique"));
f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
let font = doc.add(Object::Dict(f));
let page = pdfcraft_model::pages(&doc)[0].clone();
let mut resources = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone();
let mut fonts = doc.resolve(resources.get(b"Font").unwrap()).as_dict().unwrap().clone();
fonts.set(b"F3".to_vec(), Object::Ref(font));
resources.set(b"Font".to_vec(), Object::Dict(fonts));
doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap();
doc
}
#[test]
fn mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns() {
let doc = mixed_region_page();
let blocks = text::text_blocks(&doc, 0).unwrap();
assert_eq!(blocks.len(), 3);
assert_eq!(blocks[0].editor_text, "TITLE\ntwo lines\nlast");
assert_eq!(blocks[0].rows.len(), 3);
assert_eq!(blocks[1].editor_text, "About\nPlain bold text.\nItalic footer");
assert_eq!(blocks[1].rows.len(), 3);
assert_eq!(blocks[1].lines.len(), 5);
assert_eq!(blocks[2].text, "Other column");
}
#[test]
fn a_single_row_with_mixed_styles_grows_before_wrapping() {
let mut doc = text_page("BT /F1 10 Tf 72 582 Td (Plain ) Tj /F2 10 Tf (bold) Tj /F1 10 Tf ( text.) Tj ET");
doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |f| {
*f = Dict::new();
f.set(b"Type".to_vec(), Object::name("Font"));
f.set(b"Subtype".to_vec(), Object::name("Type1"));
f.set(b"BaseFont".to_vec(), Object::name("Helvetica-Bold"));
})
.unwrap();
let blocks = text::text_blocks(&doc, 0).unwrap();
let index = blocks.iter().position(|b| b.editor_text == "Plain bold text.").unwrap();
let source = &blocks[index];
assert_eq!(source.rows.len(), 1);
assert!(source.lines.len() > 1);
let draft = "Plain bold text. More words on the same line";
text::replace_block(&mut doc, 0, index, draft).unwrap();
let edited = text::text_blocks(&reopen(&doc), 0).unwrap();
let block = edited.iter().find(|b| b.text == draft).unwrap();
assert_eq!(block.rows.len(), 1, "a style boundary must not pin a single row to its original width");
assert!((block.rect[0] - source.rect[0]).abs() < 0.1);
assert!(block.rect[2] > source.rect[2] + 20.0);
}
#[test]
fn explicit_editor_rows_survive_single_run_edit_and_reopen() {
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Original heading) Tj ET");
let text = "First row\nSecond row\nLast row";
let layout = text::text_edit_layout(&doc, 0, 0, text, &text::BlockStyle::default()).unwrap();
text::replace_block(&mut doc, 0, 0, text).unwrap();
let blocks = text::text_blocks(&reopen(&doc), 0).unwrap();
assert_eq!(blocks[0].editor_text, text);
assert_eq!(blocks[0].rows.len(), 3);
assert_eq!(Some(blocks[0].rect), layout.draft_rect);
}
#[test]
fn rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen() {
let original = mixed_region_page();
let before = text::text_lines(&original, 0).unwrap();
let block = text::text_blocks(&original, 0).unwrap()[1].clone();
let mut doc = original.clone();
text::replace_block(&mut doc, 0, 1, &block.editor_text.replace("Plain", "Other")).unwrap();
let reopened = reopen(&doc);
let blocks = text::text_blocks(&reopened, 0).unwrap();
assert_eq!(blocks.len(), 3);
assert_eq!(blocks[1].editor_text, "About\nOther bold text.\nItalic footer");
let lines = text::text_lines(&reopened, 0).unwrap();
for word in ["About", "bold", "Italic footer"] {
let source = before.iter().find(|l| l.text == word).unwrap();
let after = lines.iter().find(|l| l.text == word).unwrap();
assert_eq!(
(after.base_font.as_str(), after.size, after.color, after.bold, after.italic),
(source.base_font.as_str(), source.size, source.color, source.bold, source.italic)
);
assert!((after.origin_baseline() - source.origin_baseline()).abs() < 0.001);
}
for i in [0, 2] {
assert_eq!(blocks[i].editor_text, text::text_blocks(&original, 0).unwrap()[i].editor_text);
assert_eq!(blocks[i].rect, text::text_blocks(&original, 0).unwrap()[i].rect);
}
let mut removed = original.clone();
text::replace_block(&mut removed, 0, 1, "").unwrap();
let after = text::text_blocks(&reopen(&removed), 0).unwrap();
assert_eq!(after.len(), 2);
assert_eq!(after[0].editor_text, "TITLE\ntwo lines\nlast");
assert_eq!(after[1].text, "Other column");
let mut preview = original.clone();
text::clear_block(&mut preview, 0, 1).unwrap();
assert_eq!(text::text_blocks(&reopen(&preview), 0).unwrap().len(), 2);
assert_eq!(text::text_blocks(&original, 0).unwrap().len(), 3);
}
/// Two edits applied together (heading and footer edited before leaving the editor, #2198): the
/// runs between them are unchanged and keep their own styles instead of taking the heading's.
#[test]
fn unchanged_runs_between_two_edits_keep_their_styles() {
let original = mixed_region_page();
let look =
|doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().map(|l| (l.text.trim().to_string(), l.base_font, l.color)).collect::<Vec<_>>();
let before = look(&original);
let block = text::text_blocks(&original, 0).unwrap()[1].clone();
let mut doc = original.clone();
let draft = block.editor_text.replace("About", "About us").replace("footer", "footnote");
text::replace_block(&mut doc, 0, 1, &draft).unwrap();
let after = look(&reopen(&doc));
let style = |lines: &[(String, String, [f64; 3])], word: &str| lines.iter().find(|l| l.0 == word).map(|l| (l.1.clone(), l.2));
for word in ["Plain", "bold", "text."] {
assert_eq!(style(&after, word), style(&before, word), "{word:?} keeps its own style; lines after the edit: {after:?}");
}
assert_eq!(style(&after, "About us"), style(&before, "About"));
assert_eq!(style(&after, "Italic footnote"), style(&before, "Italic footer"));
}
#[test]
fn disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically() {
let mut doc = mixed_region_page();
let block = text::text_blocks(&doc, 0).unwrap()[1].clone();
let a = block.editor_text.find("Plain").unwrap();
let b = block.editor_text.find("bold").unwrap();
let style = BlockStyle {
ranges: vec![
RangeStyle {
range: a..a + 5,
family: None,
resource: None,
size: Some(15.0),
color: Some([0.8, 0.1, 0.2]),
underline: Some(true),
char_spacing: None,
scale: None,
},
RangeStyle {
range: b..b + 4,
family: Some((Family::Courier, false, true)),
resource: None,
size: None,
color: None,
underline: None,
char_spacing: Some(0.2),
scale: Some(90.0),
},
],
width: Some(300.0),
..Default::default()
};
text::rewrite_block(&mut doc, 0, 1, Some(&block.editor_text), &style).unwrap();
doc = reopen(&doc);
let lines = text::text_lines(&doc, 0).unwrap();
let plain = lines.iter().find(|l| l.text == "Plain").unwrap();
assert_eq!((plain.size, plain.color), (15.0, [0.8, 0.1, 0.2]));
let bold = lines.iter().find(|l| l.text == "bold").unwrap();
assert_eq!(bold.base_font, "Courier-Oblique");
let original = mixed_region_page();
for word in ["About", "Italic footer"] {
let prior = text::text_lines(&original, 0).unwrap().into_iter().find(|l| l.text == word).unwrap();
let after = lines.iter().find(|l| l.text == word).unwrap();
assert_eq!((after.size, after.color, &after.font, after.baseline()), (prior.size, prior.color, &prior.font, prior.baseline()));
}
let before = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap();
let invalid = BlockStyle {
ranges: vec![RangeStyle {
range: 1..2,
family: None,
resource: None,
size: Some(12.0),
color: None,
underline: None,
char_spacing: None,
scale: None,
}],
..Default::default()
};
assert!(text::rewrite_block(&mut doc, 0, 1, Some("é bad"), &invalid).is_err());
assert_eq!(pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(), before);
}
#[test]
fn centered_mixed_size_title_keeps_three_rows_when_changed() {
let mut doc = mixed_region_page();
let title = text::text_blocks(&doc, 0).unwrap()[0].clone();
text::replace_block(&mut doc, 0, 0, &title.editor_text.replace("last", "tail")).unwrap();
let reopened = reopen(&doc);
let blocks = text::text_blocks(&reopened, 0).unwrap();
assert_eq!(blocks[0].editor_text, "TITLE\ntwo lines\ntail");
assert_eq!(blocks[0].rows.len(), 3);
let lines = text::text_lines(&reopened, 0).unwrap();
for row in &blocks[0].rows {
let line = &lines[row[0]];
assert!(((line.rect[0] + line.rect[2]) / 2.0 - 150.0).abs() < 0.01);
}
}
#[test]
fn image_selection_bounds_respect_clips_and_restore_graphics_state() {
let mut doc = image_page();
let page = pdfcraft_model::pages(&doc)[0].clone();
let content = b"q 1 0 0 1 10 20 cm 20 30 100 80 re W* n q 1200 0 0 1400 -500 -600 cm /Im0 Do Q Q q 200 0 0 100 100 100 cm /Im0 Do Q q 0 0 10 10 re W n 100 0 0 100 200 200 cm /Im0 Do Q";
let stream = doc.add(Object::Stream(Stream::flate(Dict::new(), content)));
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Ref(stream))).unwrap();
let before = images::page_images(&doc, 0).unwrap();
assert_eq!(before.len(), 3, "fully clipped placements retain source indexes");
assert_eq!(before[0].visible_rect, Some([30.0, 50.0, 130.0, 130.0]));
assert_eq!(before[1].visible_rect, Some(before[1].rect), "Q restores the prior clip");
assert_eq!(before[2].visible_rect, None);
let objects = crate::editable_objects(&doc, 0).unwrap();
assert_eq!(objects.len(), 2, "hidden images cannot intercept selection");
assert_eq!(objects[0].rect, before[0].visible_rect.unwrap());
let offset = [12.0, 14.0];
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(pdfcraft_content::Matrix::translate(offset[0], offset[1]).0)).unwrap();
let reopened = reopen(&doc);
let after = images::page_images(&reopened, 0).unwrap();
assert_eq!(after[0].visible_rect, Some([42.0, 64.0, 142.0, 144.0]), "an exclusive crop moves with the image even across nested graphics states");
for (a, b) in after[0].rect.iter().zip(before[0].rect.iter()).enumerate() {
assert!((b.0 - b.1 - offset[a % 2]).abs() < 0.001);
}
assert_eq!(after[1].rect, before[1].rect, "neighbor placement unchanged");
assert_eq!(after[1].visible_rect, before[1].visible_rect);
assert_eq!(after[1].matrix, before[1].matrix);
assert_eq!(after[1].object, before[1].object);
}
#[test]
fn image_clip_path_and_graphics_state_continue_across_content_streams() {
let mut doc = image_page();
let page = pdfcraft_model::pages(&doc)[0].clone();
let pieces: [&[u8]; 3] = [b"q 20 30 40 50 re W", b"n 1000 0 0 1000 -300 -400 cm /Im0 Do", b"Q q 50 0 0 50 100 200 cm /Im0 Do Q"];
let refs: Vec<_> = pieces.into_iter().map(|b| Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), b))))).collect();
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(refs))).unwrap();
let images = images::page_images(&doc, 0).unwrap();
assert_eq!(images[0].visible_rect, Some([20.0, 30.0, 60.0, 80.0]));
assert_eq!(images[1].visible_rect, Some([100.0, 200.0, 150.0, 250.0]));
images::change_image(
&mut doc,
0,
0,
&images::ImageChange::Transform(images::rect_to_rect(images[0].visible_rect.unwrap(), [20.0, 30.0, 80.0, 105.0])),
)
.unwrap();
let after = images::page_images(&reopen(&doc), 0).unwrap();
assert!(close(after[0].visible_rect.unwrap(), [20.0, 30.0, 80.0, 105.0]), "an exclusive crop scales with its image across streams");
assert_eq!(after[1].matrix, images[1].matrix, "graphics state restored across stream boundaries");
}
#[test]
fn cropped_image_resizing_moves_private_clips_and_keeps_shared_ones() {
let mut doc = image_page();
let page = pdfcraft_model::pages(&doc)[0].clone();
let stream = doc.add(Object::Stream(Stream::flate(
Dict::new(),
b"q 30 170 130 80 re W* n 800 0 0 900 -300 -400 cm /Im0 Do Q BT /F1 12 Tf 30 130 Td (Caption) Tj ET",
)));
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Ref(stream))).unwrap();
for target in [[30.0, 170.0, 190.0, 270.0], [30.0, 170.0, 110.0, 220.0], [70.0, 210.0, 150.0, 260.0]] {
let before = images::page_images(&doc, 0).unwrap()[0].clone();
let transform = images::rect_to_rect(before.visible_rect.unwrap(), target);
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(transform)).unwrap();
doc = reopen(&doc);
let after = images::page_images(&doc, 0).unwrap()[0].clone();
// The moved clip's `cm` is written with the content stream's number precision.
let near = |a: [f64; 4], b: [f64; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.05);
assert!(near(after.visible_rect.unwrap(), target), "{:?} vs {target:?}", after.visible_rect);
assert!(near(after.rect, pdfcraft_content::Matrix(transform).bbox(before.rect)), "full pixels use the same crop transform");
assert_eq!(after.object, before.object, "pixels are never resampled or replaced");
assert_eq!(text::text_lines(&doc, 0).unwrap()[0].baseline(), [30.0, 130.0]);
}
let page = pdfcraft_model::pages(&doc)[0].clone();
let stream = doc.add(Object::Stream(Stream::flate(
Dict::new(),
b"q 30 170 130 80 re W n q 800 0 0 900 -300 -400 cm /Im0 Do Q BT /F1 12 Tf 40 200 Td (Shared caption) Tj ET Q",
)));
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Ref(stream))).unwrap();
let before = images::page_images(&doc, 0).unwrap();
let caption = text::text_lines(&doc, 0).unwrap();
let transform = [1.2, 0.0, 0.0, 1.2, 0.0, 0.0];
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(transform)).unwrap();
doc = reopen(&doc);
let after = images::page_images(&doc, 0).unwrap();
assert!(close(after[0].visible_rect.unwrap(), before[0].visible_rect.unwrap()), "a shared clip stays where it is");
assert_eq!(after[0].object, before[0].object);
assert_eq!(
text::text_lines(&doc, 0).unwrap().iter().map(|l| (&l.text, l.rect, l.baseline())).collect::<Vec<_>>(),
caption.iter().map(|l| (&l.text, l.rect, l.baseline())).collect::<Vec<_>>()
);
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform([0.5, 0.0, 0.0, 0.5, 0.0, 0.0])).unwrap();
assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 1);
}
#[test]
fn private_image_crop_with_intervening_state_resizes_across_streams() {
let variants: Vec<Vec<&[u8]>> = vec![
vec![b"q 1 0 0 1 10 20 cm 20 150 130 80 re W* n 0.5 g q 800 0 0 900 -300 -400 cm /Im0 Do Q Q BT /F1 12 Tf 30 130 Td (Caption) Tj ET"],
vec![b"q 1 0 0 1 10 20 cm 20 150 130 80 re W*", b"n 0.5 g q 800 0 0 900 -300 -400 cm /Im0 Do", b"Q Q BT /F1 12 Tf 30 130 Td (Caption) Tj ET"],
];
for pieces in variants {
let mut doc = image_page();
let page = pdfcraft_model::pages(&doc)[0].obj;
let refs = pieces.iter().map(|b| Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), b))))).collect();
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Array(refs))).unwrap();
let caption = text::text_lines(&doc, 0).unwrap();
for target in [[30.0, 170.0, 190.0, 270.0], [30.0, 170.0, 110.0, 220.0], [70.0, 210.0, 150.0, 260.0]] {
let before = images::page_images(&doc, 0).unwrap()[0].clone();
let transform = images::rect_to_rect(before.visible_rect.unwrap(), target);
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(transform)).unwrap();
doc = reopen(&doc);
let after = images::page_images(&doc, 0).unwrap()[0].clone();
assert!(close(after.visible_rect.unwrap(), target), "the private crop follows the image without a literal operator pattern");
assert!(close(after.rect, pdfcraft_content::Matrix(transform).bbox(before.rect)));
assert_eq!(after.object, before.object);
assert_eq!(
text::text_lines(&doc, 0).unwrap().iter().map(|l| (&l.text, l.rect, l.baseline())).collect::<Vec<_>>(),
caption.iter().map(|l| (&l.text, l.rect, l.baseline())).collect::<Vec<_>>()
);
}
}
}
/// [`image_page`] with `pieces` as its `/Contents` array and a Form /Fm0 drawing 72,500–172,600.
fn split_image_page(pieces: &[&[u8]]) -> Document {
let mut doc = image_page();
let page = pdfcraft_model::pages(&doc)[0].obj;
let mut form = Dict::new();
form.set(b"Type".to_vec(), Object::name("XObject"));
form.set(b"Subtype".to_vec(), Object::name("Form"));
form.set(b"BBox".to_vec(), Object::Array([72, 500, 172, 600].map(Object::Int).to_vec()));
let form = doc.add(Object::Stream(Stream::flate(form, b"0.5 g 72 500 100 100 re f")));
let image = doc.get(pdfcraft_model::pages(&doc)[0].obj);
let mut res = image.as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| r.as_dict().cloned()).unwrap();
let mut xo = res.get(b"XObject").and_then(|x| x.as_dict().cloned()).unwrap();
xo.set(b"Fm0".to_vec(), Object::Ref(form));
res.set(b"XObject".to_vec(), Object::Dict(xo));
let refs = pieces.iter().map(|b| Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), b))))).collect();
doc.update_dict(page, |d| {
d.set(b"Contents".to_vec(), Object::Array(refs));
d.set(b"Resources".to_vec(), Object::Dict(res));
})
.unwrap();
doc
}
#[test]
fn an_image_whose_operator_starts_the_next_stream_can_be_selected_moved_and_deleted() {
// #3113: an operator's operands end one piece and its keyword starts the next.
let square = [72.0, 500.0, 172.0, 600.0];
let cases: [(&[&[u8]], bool); 4] = [
(&[b"q 100 0 0 100 72 500 cm /Im0", b"Do Q"], false),
(&[b"q 100 0 0 100 72 500", b"cm /Im0 Do Q"], false),
(&[b"q /Fm0", b"Do Q"], true),
(&[b"q 100 0 0 100 72 500 cm /Im0 Do Q"], false),
];
for (pieces, is_form) in cases {
let doc = split_image_page(pieces);
let label: Vec<_> = pieces.iter().map(|p| String::from_utf8_lossy(p)).collect();
let images = images::page_images(&doc, 0).unwrap();
assert_eq!(images.len(), 1, "{label:?}");
assert!(close(images[0].rect, square), "{label:?}: {:?}", images[0].rect);
assert_eq!(images[0].is_form, is_form);
assert!(images[0].contains([122.0, 550.0]), "{label:?}");
if !is_form {
let read = images::reading_images(&doc, 0).unwrap();
assert!(read.len() == 1 && close(read[0].rect, square), "{label:?}: {read:?}");
}
let mut deleted = doc.clone();
images::change_image(&mut deleted, 0, 0, &images::ImageChange::Delete).unwrap();
let deleted = reopen(&deleted);
assert!(images::page_images(&deleted, 0).unwrap().is_empty(), "{label:?}");
let (joined, ops) = joined_ops(&deleted);
assert!(!ops.iter().any(|o| o.is("Do")), "{joined}");
let target = [300.0, 500.0, 400.0, 600.0];
let mut moved = doc.clone();
images::change_image(&mut moved, 0, 0, &images::ImageChange::Transform(images::rect_to_rect(square, target))).unwrap();
let moved = reopen(&moved);
let after = images::page_images(&moved, 0).unwrap();
assert!(after.len() == 1 && close(after[0].rect, target), "{label:?}: {after:?} {:?}", joined_ops(&moved).0);
let mut shifted = doc.clone();
images::translate_images(&mut shifted, 0, &[0], [228.0, 0.0]).unwrap();
let shifted = reopen(&shifted);
let after = images::page_images(&shifted, 0).unwrap();
assert!(after.len() == 1 && close(after[0].rect, target), "{label:?}: {after:?} {:?}", joined_ops(&shifted).0);
}
}
#[test]
fn curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points() {
let mut doc = image_page();
let page = pdfcraft_model::pages(&doc)[0].obj;
let bytes =
b"q 0 0 m 0 200 100 200 100 0 c h W n 300 0 0 300 0 0 cm /Im0 Do Q q 100 100 100 100 re 125 125 50 50 re W* n 400 0 0 400 0 0 cm /Im0 Do Q";
let content = doc.add(Object::Stream(Stream::flate(Dict::new(), bytes)));
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(content))).unwrap();
let images = images::page_images(&doc, 0).unwrap();
assert!(close(images[0].visible_rect.unwrap(), [0.0, 0.0, 100.0, 150.0]), "cubic extrema, not controls");
assert!(images[0].contains([50.0, 100.0]));
assert!(!images[0].contains([2.0, 140.0]), "empty corners of curve bounds are not artwork");
assert!(images[1].contains([110.0, 110.0]));
assert!(!images[1].contains([150.0, 150.0]), "even-odd hole");
images::change_image(&mut doc, 0, 1, &images::ImageChange::Transform([1.2, 0.0, 0.0, 1.2, 10.0, 15.0])).unwrap();
let after = images::page_images(&reopen(&doc), 0).unwrap();
assert!(!after[1].contains([190.0, 195.0]), "the transformed even-odd hole remains empty");
assert!(after[1].contains([142.0, 147.0]), "the transformed filled corner remains selectable");
assert_eq!(after[0].visible_rect, images[0].visible_rect, "the neighbouring curved clip stays put");
}
#[test]
fn marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite() {
let mut doc =
text_page("/P << /MCID 0 >> BDC BT /F1 12 Tf 40 500 Td (One) Tj ET EMC /P << /MCID 1 >> BDC BT /F1 12 Tf 40 482 Td (Two) Tj ET EMC");
assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2, "distinct explicit paragraphs win over nearby geometry");
let page = pdfcraft_model::pages(&doc)[0].obj;
let parts: [&[u8]; 2] =
[b"/P << /MCID 2 >> BDC BT /F1 12 Tf 40 500 Td (First row) Tj ET", b"q 1 0 0 1 10 12 cm BT /F1 12 Tf 30 470 Td (Second row) Tj ET Q EMC"];
let refs = parts.iter().map(|p| Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), p))))).collect();
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Array(refs))).unwrap();
let block = text::text_blocks(&doc, 0).unwrap();
assert_eq!(block.len(), 1, "one marked paragraph across streams and coordinate spaces");
assert_eq!(block[0].editor_text, "First row\nSecond row");
let original = text::text_lines(&doc, 0).unwrap();
text::rewrite_block(&mut doc, 0, 0, Some("First row\nChanged row"), &text::BlockStyle { width: Some(200.0), ..Default::default() }).unwrap();
let after = text::text_lines(&reopen(&doc), 0).unwrap();
for (a, b) in after.iter().zip(original) {
assert!(close(a.rect, b.rect) || a.baseline() == b.baseline());
}
assert_eq!(after[1].text, "Changed row");
}
#[test]
fn logical_indic_with_nuktas_not_reordered_on_standard_font() {
// Already logical Hindi text with nuktas (बढ़िया) must not be corrupted by pre-base reordering
// when the font does not require visual order.
let hex_actual = "<FEFF092C0922093C093F092F093E>";
let doc = text_page(&format!("/Span << /ActualText {hex_actual} >> BDC BT /F1 12 Tf 40 500 Td (ab) Tj ET EMC"));
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text, "\u{092C}\u{0922}\u{093C}\u{093F}\u{092F}\u{093E}");
assert!(!lines[0].visual_order);
}
/// One page in a font named `base_font` whose ToUnicode maps A ब, B ि, C ढ़, D य, E ा, F क, G त.
/// A legacy visual-order font draws "बढ़िया" as `(ABCDE)` (ब ि ढ़ या) and "किताब" as `(BFGEA)`.
fn devanagari_page(base_font: &str, content: &str) -> Document {
let cmap = "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapType 2 def \
1 begincodespacerange <00> <FF> endcodespacerange 8 beginbfchar \
<20> <0020> <41> <092C> <42> <093F> <43> <0922093C> <44> <092F> <45> <093E> <46> <0915> <47> <0924> \
endbfchar endcmap CMapName currentdict /CMap defineresource pop end end";
let widths: Vec<&str> = (32..=71)
.map(|c| {
if c == 32 {
"250"
} else if c >= 65 {
"500"
} else {
"0"
}
})
.collect();
let font = format!(
"<< /Type /Font /Subtype /Type1 /BaseFont /{base_font} /FirstChar 32 /LastChar 71 /Widths [{}] /ToUnicode 6 0 R >>",
widths.join(" ")
);
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] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
&stream("", content),
&font,
&stream("", cmap),
])
}
/// A legacy visual-order font (Kruti Dev) whose ToUnicode gives the glyphs in drawing order: the
/// pre-base vowel sign is read back after its consonant, for editing and for reading.
#[test]
fn legacy_indic_font_flags_visual_order_and_reorders_pre_base_vowels() {
let doc = devanagari_page("KrutiDev010", "BT /F1 12 Tf 40 500 Td (ABCDE) Tj ET");
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 1);
assert!(lines[0].visual_order);
assert_eq!(lines[0].text, "\u{092C}\u{0922}\u{093C}\u{093F}\u{092F}\u{093E}");
let reading: Vec<String> = text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
assert_eq!(reading, ["\u{092C}\u{0922}\u{093C}\u{093F}\u{092F}\u{093E}"]);
}
/// Typed (logical) text is written back in the font's visual order and reads back as typed.
#[test]
fn legacy_indic_font_edits_are_written_in_visual_order() {
let mut doc = devanagari_page("ABCDEF+KrutiDev010", "BT /F1 12 Tf 40 500 Td (ABCDE) Tj ET");
let kitab = "\u{0915}\u{093F}\u{0924}\u{093E}\u{092C}";
assert_eq!(text::replace_line(&mut doc, 0, 0, kitab).unwrap().substituted, None);
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert!(content.contains("(BFGEA)"), "{content}");
assert_eq!(text::text_lines(&reopen(&doc), 0).unwrap()[0].text, kitab);
}
/// `/ActualText` is already in logical order, even in a visual-order font: "बढ़िया" must not
/// become "बढ़यिा".
#[test]
fn logical_actual_text_in_a_legacy_indic_font_is_not_reordered() {
let doc = devanagari_page("KrutiDev010", "/Span << /ActualText <FEFF092C0922093C093F092F093E> >> BDC BT /F1 12 Tf 40 500 Td (ABCDE) Tj ET EMC");
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text, "\u{092C}\u{0922}\u{093C}\u{093F}\u{092F}\u{093E}");
let reading: Vec<String> = text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
assert_eq!(reading, ["\u{092C}\u{0922}\u{093C}\u{093F}\u{092F}\u{093E}"]);
}
/// A modern subset font with a logical ToUnicode and no GSUB in the PDF (subsets and bare CFF
/// often drop it) is not visual order: its text is read as it is.
#[test]
fn subset_indic_font_without_legacy_name_keeps_logical_text() {
let doc = devanagari_page("ABCDEF+NotoSansDevanagari-Regular", "BT /F1 12 Tf 40 500 Td (ACBDE) Tj ET");
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 1);
assert!(!lines[0].visual_order);
assert_eq!(lines[0].text, "\u{092C}\u{0922}\u{093C}\u{093F}\u{092F}\u{093E}");
}
/// Lines of a paragraph drawn turned on the page (a landscape page drawn a quarter turn round)
/// are separate rows, and adding a word keeps them apart (#1443).
#[test]
fn turned_paragraph_keeps_its_line_breaks_when_a_word_is_added() {
for cm in ["0 1 -1 0 600 0", "0 -1 1 0 0 800", "-1 0 0 -1 600 800"] {
let original =
text_page(&format!("q {cm} cm BT /F1 12 Tf 72 500 Td (First line here) Tj 0 -14 Td (Second line) Tj 0 -14 Td (Third) Tj ET Q"));
let block = text::text_blocks(&original, 0).unwrap().remove(0);
assert_eq!(block.editor_text, "First line here\nSecond line\nThird", "{cm}");
let mut doc = original.clone();
text::rewrite_block(&mut doc, 0, 0, Some("First line here\nSecond line\nThird added"), &text::BlockStyle::default()).unwrap();
let reopened = reopen(&doc);
assert_eq!(text::text_blocks(&reopened, 0).unwrap()[0].editor_text, "First line here\nSecond line\nThird added", "{cm}");
let (before, after) = (text::text_lines(&original, 0).unwrap(), text::text_lines(&reopened, 0).unwrap());
assert_eq!(after.len(), 3, "{cm}");
for (a, b) in after.iter().zip(&before).take(2) {
assert!(close(a.rect, b.rect), "{cm}: untouched rows stay put: {:?} {:?}", a.rect, b.rect);
}
// The last row grows along its own baseline from where it started.
let (grown, old) = (after[2].rect, before[2].rect);
assert!((0..2).all(|i| grown[i] <= old[i] + 0.01) && (2..4).all(|i| grown[i] >= old[i] - 0.01), "{cm}: {grown:?} {old:?}");
assert!((after[2].origin_baseline() - before[2].origin_baseline()).abs() < 0.01, "{cm}");
}
// A turned single line grows rather than wrapping across the page's other axis.
let mut doc = text_page("BT /F1 12 Tf 0 1 -1 0 300 300 Tm (Only line) Tj ET");
text::rewrite_block(&mut doc, 0, 0, Some("Only line with several more words added"), &text::BlockStyle::default()).unwrap();
let blocks = text::text_blocks(&reopen(&doc), 0).unwrap();
assert_eq!(blocks[0].editor_text, "Only line with several more words added");
// Formatting part of turned text isn't laid out yet: refused, not drawn along the page's x axis.
let mut doc = text_page("BT /F1 12 Tf 0 1 -1 0 300 300 Tm (Only line) Tj ET");
let range = text::RangeStyle {
range: 0..4,
family: None,
resource: None,
size: Some(20.0),
color: None,
underline: None,
char_spacing: None,
scale: None,
};
let style = text::BlockStyle { ranges: vec![range], ..Default::default() };
assert!(text::rewrite_block(&mut doc, 0, 0, Some("Only line"), &style).is_err());
}
/// A paragraph drawn at a slant rewraps at the width its rows have along their baseline, as the
/// same paragraph upright does. Its upright bounding box turned into the baseline's frame is wider
/// by the rows' height times |sin 2θ|, which let a word that doesn't fit stay on its row.
#[test]
fn a_paragraph_at_a_slant_rewraps_at_the_width_it_has_upright() {
let rows = ["Rotated paragraph, first row of text", "that continues on a second row and", "ends on the third row here."];
let draft = "Rotated paragraph, first row of text that continues on a second row and now carries a few extra words \
before it ends on the third row here.";
let rewrapped = |degrees: f64| {
let (sin, cos) = degrees.to_radians().sin_cos();
let shown = rows.iter().map(|r| format!("({r}) Tj")).collect::<Vec<_>>().join(" T* ");
let mut doc = text_page(&format!("BT /F1 12 Tf 14.4 TL {cos:.6} {sin:.6} {:.6} {cos:.6} 300 400 Tm {shown} ET", -sin));
let block = text::text_blocks(&doc, 0).unwrap().remove(0);
assert_eq!(block.editor_text, rows.join("\n"), "{degrees}°");
text::rewrite_block(&mut doc, 0, 0, Some(draft), &text::BlockStyle::default()).unwrap();
text::text_lines(&reopen(&doc), 0).unwrap().into_iter().map(|l| l.text).collect::<Vec<_>>()
};
let upright = rewrapped(0.0);
// The widest source row is 189.44 pt; "now carries a few extra words before" is 196.07 pt.
assert_eq!(upright[2], "now carries a few extra words", "{upright:?}");
for degrees in [30.0, 45.0, 60.0, 90.0, 135.0, 210.0, 330.0] {
assert_eq!(rewrapped(degrees), upright, "{degrees}°");
}
}
/// A paragraph in a Chrome-style subset font: `/W` leaves out the glyphs of the `/DW` width.
/// Adding a word keeps every row as it was and shows the word in the page's own font (#1443).
#[test]
fn default_width_subset_paragraph_keeps_its_rows_when_a_word_is_added() {
let hex = |s: &str| s.chars().map(|c| format!("00{:02X}", u32::from(c))).collect::<String>();
let rows = ["This is the first line", "the second", "and third"];
let content = rows
.iter()
.enumerate()
.map(|(i, row)| format!("BT /F3 12 Tf 1 0 0 1 72 {} Tm <{}> Tj ET", 700 - 14 * i, hex(row)))
.collect::<Vec<_>>()
.join(" ");
let mut doc = text_page(&content);
let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> <FFFF> endcodespacerange \
1 beginbfrange <0020> <007E> <0020> endbfrange end";
let to_unicode = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec())));
let mut cid = Dict::new();
cid.set(b"Type".to_vec(), Object::name("Font"));
cid.set(b"Subtype".to_vec(), Object::name("CIDFontType2"));
cid.set(b"BaseFont".to_vec(), Object::name("AAAAAA+LiberationSans"));
cid.set(b"CIDToGIDMap".to_vec(), Object::name("Identity"));
cid.set(b"DW".to_vec(), Object::Int(500));
cid.set(
b"W".to_vec(),
Object::Array(vec![Object::Int(32), Object::Array(vec![Object::Int(278)]), Object::Int(84), Object::Array(vec![Object::Int(611)])]),
);
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type0"));
font.set(b"BaseFont".to_vec(), Object::name("AAAAAA+LiberationSans"));
font.set(b"Encoding".to_vec(), Object::name("Identity-H"));
font.set(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(cid)]));
font.set(b"ToUnicode".to_vec(), Object::Ref(to_unicode));
let font = doc.add(Object::Dict(font));
let page = pdfcraft_model::pages(&doc)[0].clone();
let mut resources = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone();
let mut fonts = doc.resolve(resources.get(b"Font").unwrap()).as_dict().unwrap().clone();
fonts.set(b"F3".to_vec(), Object::Ref(font));
resources.set(b"Font".to_vec(), Object::Dict(fonts));
doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap();
let block = text::text_blocks(&doc, 0).unwrap().remove(0);
assert_eq!(block.editor_text, rows.join("\n"));
let before = text::text_lines(&doc, 0).unwrap();
let edit = text::rewrite_block(&mut doc, 0, 0, Some(&format!("{} added", block.editor_text)), &text::BlockStyle::default()).unwrap();
assert_eq!(edit.substituted, None, "the subset shows every letter of the new word");
let reopened = reopen(&doc);
assert_eq!(text::text_blocks(&reopened, 0).unwrap()[0].editor_text, "This is the first line\nthe second\nand third added");
let after = text::text_lines(&reopened, 0).unwrap();
for (a, b) in after.iter().zip(&before).take(2) {
assert_eq!(a.text, b.text);
assert!(close(a.rect, b.rect), "untouched rows keep their place and width: {:?} {:?}", a.rect, b.rect);
}
}
/// #2578: a single turned row grows along its baseline only up to the page's 36 pt margin and wraps
/// onto further rows from there, instead of running off the page.
#[test]
fn turned_single_row_wraps_at_the_page_margin() {
let draft = "A single turned row that keeps growing with many more words added until it is far longer than the page is tall, so it has to stop or wrap somewhere";
// Reading upwards (90° CCW) from y = 600 on the 600 × 800 test page: top margin is at y = 764.
let before = text_page("BT /F1 12 Tf 0 1 -1 0 300 600 Tm (A single turned row) Tj ET");
let layout = text::text_edit_layout(&before, 0, 0, draft, &text::BlockStyle::default()).unwrap();
let mut doc = before;
text::rewrite_block(&mut doc, 0, 0, Some(draft), &text::BlockStyle::default()).unwrap();
let reopened = reopen(&doc);
let lines = text::text_lines(&reopened, 0).unwrap();
assert!(lines.len() > 1, "expected the turned row to wrap at the top margin: {lines:?}");
for line in &lines {
assert!((line.rect[1] - 600.0).abs() < 0.5, "row starts at y = 600: {:?}", line.rect);
assert!(line.rect[3] <= 800.0 - 36.0 + 0.5, "row stays within the top margin: {:?}", line.rect);
}
let blocks = text::text_blocks(&reopened, 0).unwrap();
assert_eq!(blocks.len(), 1, "wrapped turned rows stay one paragraph: {blocks:?}");
assert_eq!(blocks[0].text, draft);
assert_eq!(layout.draft_rect, Some(blocks[0].rect));
// Reading downwards (90° CW) from y = 200: bottom margin is at y = 36.
let mut doc = text_page("BT /F1 12 Tf 0 -1 1 0 300 200 Tm (A single turned row) Tj ET");
text::rewrite_block(&mut doc, 0, 0, Some(draft), &text::BlockStyle::default()).unwrap();
let lines = text::text_lines(&reopen(&doc), 0).unwrap();
assert!(lines.len() > 1, "expected 90° CW row to wrap at the bottom margin: {lines:?}");
for line in &lines {
assert!((line.rect[3] - 200.0).abs() < 0.5, "row starts at y = 200: {:?}", line.rect);
assert!(line.rect[1] >= 36.0 - 0.5, "row stays within the bottom margin: {:?}", line.rect);
}
// Reading upside down (180°) from x = 200: left margin is at x = 36.
let mut doc = text_page("BT /F1 12 Tf -1 0 0 -1 200 600 Tm (A single turned row) Tj ET");
text::rewrite_block(&mut doc, 0, 0, Some(draft), &text::BlockStyle::default()).unwrap();
let lines = text::text_lines(&reopen(&doc), 0).unwrap();
assert!(lines.len() > 1, "expected 180° row to wrap at the left margin: {lines:?}");
for line in &lines {
assert!((line.rect[2] - 200.0).abs() < 0.5, "row starts at x = 200: {:?}", line.rect);
assert!(line.rect[0] >= 36.0 - 0.5, "row stays within the left margin: {:?}", line.rect);
}
// Slanted (45°) from (400, 600): baseline stops where it first reaches a 36 pt margin.
let (sin, cos) = 45.0_f64.to_radians().sin_cos();
let mut doc = text_page(&format!("BT /F1 12 Tf {cos:.6} {sin:.6} {:.6} {cos:.6} 400 600 Tm (A single turned row) Tj ET", -sin));
text::rewrite_block(&mut doc, 0, 0, Some(draft), &text::BlockStyle::default()).unwrap();
let lines = text::text_lines(&reopen(&doc), 0).unwrap();
assert!(lines.len() > 1, "expected 45° slanted row to wrap at the margin: {lines:?}");
for line in &lines {
assert!(line.rect[2] <= 600.0 && line.rect[3] <= 800.0, "slanted row stays on the page: {:?}", line.rect);
}
// Moving a turned single row with `offset` reduces the room left before the margin.
let mut doc = text_page("BT /F1 12 Tf 0 1 -1 0 300 500 Tm (A single turned row) Tj ET");
let moved = text::BlockStyle { offset: Some([0.0, 150.0]), ..Default::default() };
text::rewrite_block(&mut doc, 0, 0, Some(draft), &moved).unwrap();
let lines = text::text_lines(&reopen(&doc), 0).unwrap();
assert!(lines.len() > 1, "{lines:?}");
for line in &lines {
assert!((line.rect[1] - 650.0).abs() < 0.5, "moved row starts at y = 650: {:?}", line.rect);
assert!(line.rect[3] <= 800.0 - 36.0 + 0.5, "moved row stops at the top margin: {:?}", line.rect);
}
// A turned row that already starts inside the 36 pt margin keeps its own width instead of collapsing.
let mut doc = text_page("BT /F1 12 Tf 0 1 -1 0 300 770 Tm (Edge label) Tj ET");
text::rewrite_block(&mut doc, 0, 0, Some("Edge label updated"), &text::BlockStyle::default()).unwrap();
let lines = text::text_lines(&reopen(&doc), 0).unwrap();
assert!(lines.len() >= 2, "{lines:?}");
}
/// An added item is written out as a specific standard-14 face (`/Times-Italic`, `/Helvetica-Bold`
/// …), so it has to be *measured* with that same face. Measuring the upright face while writing
/// the italic one mis-sizes every box: Times-Italic `A` is 611 against Times-Roman's 722.
#[test]
fn an_items_measured_face_is_the_face_it_is_written_as() {
for family in [Family::Helvetica, Family::Times, Family::Courier] {
for bold in [false, true] {
for italic in [false, true] {
let written = family.base_font(bold, italic);
let measured = family.std14(bold, italic);
assert_eq!(written, measured.base_font(), "{family:?} bold={bold} italic={italic}");
// ... and the public measure reaches that face, rather than an upright stand-in.
assert_eq!(family.width("Anna", 12.0, bold, italic), measured.text_width("Anna", 12.0), "{family:?} bold={bold} italic={italic}");
}
}
}
// The faces really do differ, so the assertions above are not all comparing the same number.
let times = |italic| Family::Times.width("Anna", 12.0, false, italic);
assert_ne!(times(false), times(true), "Times-Italic is not Times-Roman");
// Courier is monospaced in every style: equal widths here are correct, not a collapsed face.
let courier = |italic| Family::Courier.width("Anna", 12.0, false, italic);
assert_eq!(courier(false), courier(true));
}
#[test]
fn page_marks_and_added_content_preserve_fractional_user_unit_matrices() {
for rotation in [0, 90] {
let mut doc = fixture();
let page = pdfcraft_model::pages(&doc)[2].obj;
doc.update_dict(page, |d| {
let bounds = Object::Array([0.0, 0.0, 0.1, 0.1].into_iter().map(Object::Real).collect());
d.set(b"MediaBox".to_vec(), bounds.clone());
d.set(b"CropBox".to_vec(), bounds);
d.set(b"UserUnit".to_vec(), Object::Int(4096));
d.set(b"Rotate".to_vec(), Object::Int(rotation));
})
.unwrap();
let mut hf = HeaderFooter::default();
hf.text[0] = "Matrix header".into();
add_header_footer(&mut doc, &[2], &hf, false, &cx()).unwrap();
let text = AddedText { rect: [72.0, 100.0, 200.0, 144.0], text: "Matrix text".into(), size: 12.0, ..AddedText::default() };
add_content(&mut doc, 2, &Content::Text(text)).unwrap();
let saved = reopen(&doc);
let contents = streams(&saved, 2);
for marker in ["/PCMark /HeaderFooter", "(Matrix text) Tj"] {
let matching: Vec<_> = contents.iter().filter(|s| s.contains(marker)).collect();
assert_eq!(matching.len(), 1, "{contents:?}");
let stream = matching[0];
let parsed = pdfcraft_content::parse(stream.as_bytes());
assert_eq!(parsed.skipped, 0);
let cm = parsed.ops.iter().find(|op| op.is("cm")).unwrap();
assert_eq!(cm.operands.len(), 6);
assert!(!stream[cm.span.clone()].contains('e') && !stream[cm.span.clone()].contains('E'), "PDF numbers cannot use exponents");
let matrix = cm.nums::<6>().unwrap();
let expected = if rotation == 0 {
[0.000244140625, 0.0, 0.0, 0.000244140625, 0.0, 0.0]
} else {
[0.0, 0.000244140625, -0.000244140625, 0.0, 0.1, 0.0]
};
assert_eq!(matrix, expected, "rotation={rotation}, stream={stream}");
// A fixed physical display point must remain at its intended raw page position.
let raw = [matrix[0] * 72.0 + matrix[2] * 144.0 + matrix[4], matrix[1] * 72.0 + matrix[3] * 144.0 + matrix[5]];
let expected_raw = if rotation == 0 { [0.017578125, 0.03515625] } else { [0.06484375, 0.017578125] };
for (actual, expected) in raw.into_iter().zip(expected_raw) {
assert!((actual - expected).abs() < 1e-12);
}
}
}
}
#[test]
fn rows_the_edit_does_not_reach_keep_their_source_operators() {
// Word gaps in TJ, as justified text from a layout program has; the text state alone
// can't reproduce them, so a rewritten row would lose them.
let rows = [
"[(justified) -400 ( text) -400 ( uses) -400 ( wide) -400 ( gaps)] TJ",
"[(between) -400 ( every) -400 ( single) -400 ( word) -400 ( here)] TJ",
"(last line.) Tj",
];
let content = format!("BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm {} T* {} T* {} ET", rows[0], rows[1], rows[2]);
let row_texts = |doc: &Document| -> Vec<(String, [f64; 2])> {
text::text_lines(doc, 0).unwrap().into_iter().map(|l| (l.text.clone(), l.baseline())).collect()
};
let source = row_texts(&text_page(&content));
assert_eq!(source.len(), 3);
for (row, from, to) in [(2, "last line.", "last line!"), (0, "justified", "justify"), (1, "single", "lone")] {
let mut doc = text_page(&content);
let block = text::text_blocks(&doc, 0).unwrap().into_iter().position(|b| b.text.contains("justified")).unwrap();
let draft = text::text_blocks(&doc, 0).unwrap()[block].editor_text.replacen(from, to, 1);
text::rewrite_block(&mut doc, 0, block, Some(&draft), &text::BlockStyle::default()).unwrap();
let doc = reopen(&doc);
let bytes = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
let after = row_texts(&doc);
assert_eq!(after.len(), 3, "{to}: {after:?}");
for (i, ((text, at), (old, was))) in after.iter().zip(&source).enumerate() {
assert!((at[0] - was[0]).abs() < 0.01 && (at[1] - was[1]).abs() < 0.01, "{to}: row {i} stays at {was:?}, not {at:?}");
if i == row {
assert_eq!(*text, old.replacen(from, to, 1), "{to}: the edited row");
assert!(!bytes.contains(rows[i]), "{to}: the edited row is rewritten");
} else {
assert_eq!(text, old, "{to}: row {i}");
assert_eq!(bytes.matches(rows[i]).count(), 1, "{to}: row {i} keeps its own operator, kerning included:\n{bytes}");
}
}
}
// Colouring a selection in the last row leaves the rows above it as they were.
let mut doc = text_page(&content);
let block = text::text_blocks(&doc, 0).unwrap().into_iter().position(|b| b.text.contains("justified")).unwrap();
let draft = text::text_blocks(&doc, 0).unwrap()[block].editor_text.clone();
let at = draft.find("line").unwrap();
let range = text::RangeStyle {
range: at..at + 4,
family: None,
resource: None,
size: None,
color: Some([1.0, 0.0, 0.0]),
underline: None,
char_spacing: None,
scale: None,
};
text::rewrite_block(&mut doc, 0, block, Some(&draft), &text::BlockStyle { ranges: vec![range], ..Default::default() }).unwrap();
let bytes = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert!(rows[..2].iter().all(|row| bytes.matches(row).count() == 1) && !bytes.contains(rows[2]), "{bytes}");
assert!(bytes.contains("1 0 0 rg"), "{bytes}");
// The coloured word is its own run now, so only the rows above compare whole.
assert_eq!(row_texts(&reopen(&doc))[..2], source[..2]);
}
#[test]
fn kept_rows_stay_in_place_when_rows_wrap_or_move_with_quote_operators() {
let lines_of = |doc: &Document| -> Vec<(String, [f64; 2])> {
text::text_lines(doc, 0).unwrap().into_iter().map(|l| (l.text.clone(), l.baseline())).collect()
};
let edit = |content: &str, from: &str, to: &str| {
let mut doc = text_page(content);
let block = text::text_blocks(&doc, 0).unwrap().into_iter().position(|b| b.editor_text.contains(from)).unwrap();
let draft = text::text_blocks(&doc, 0).unwrap()[block].editor_text.replacen(from, to, 1);
text::rewrite_block(&mut doc, 0, block, Some(&draft), &text::BlockStyle::default()).unwrap();
reopen(&doc)
};
// `'` moves to the next line as it shows: a row after a rewritten `'` row can't stay as it is.
for (quoted, from, to) in [
("BT /F1 12 Tf 14 TL 72 700 Td (first row here) Tj (second row here) ' (third row here) ' ET", "second", "2nd"),
("BT /F1 12 Tf 14 TL 72 700 Td (first row here) Tj (second row here) ' (third row here) ' ET", "third", "3rd"),
// Here the first row's own `'` makes the step down that the rows after it build on.
("BT /F1 12 Tf 14 TL 72 714 Td (first row here) ' (second row here) ' (third row here) ' ET", "first", "1st"),
] {
let source = lines_of(&text_page(quoted));
let after = lines_of(&edit(quoted, from, to));
assert_eq!(after.len(), 3, "{to}: {after:?}");
for ((text, at), (old, was)) in after.iter().zip(&source) {
assert_eq!(*text, old.replacen(from, to, 1));
assert!((at[0] - was[0]).abs() < 0.01 && (at[1] - was[1]).abs() < 0.01, "{to}: {text} stays at {was:?}, not {at:?}");
}
}
// A row that grows past the paragraph's width wraps and pushes the rows below it down;
// those are rewritten in their new places, never left behind as well.
let rows = "BT /F1 12 Tf 14.4 TL 72 700 Td (one two three four) Tj T* (five six seven eight) Tj T* (nine ten eleven twelve) Tj ET";
let doc = edit(rows, "two", "two and a great many more words");
let text: String = lines_of(&doc).into_iter().map(|(t, _)| t + " ").collect();
for word in ["one", "five", "nine", "twelve"] {
assert_eq!(text.matches(word).count(), 1, "{word} appears once: {text}");
}
assert!(lines_of(&doc).len() > 3, "the edited row wrapped: {text}");
}
/// A page whose `/F1` is Inter embedded as a simple WinAnsi TrueType font, under `base_font`.
fn embedded_program_page(content: &str, base_font: &str) -> Document {
let mut doc = text_page(content);
let program = doc.add(Object::Stream(Stream::flate(Dict::new(), include_bytes!("../../../assets/fonts/Inter-Regular.ttf"))));
let mut descriptor = Dict::new();
descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor"));
descriptor.set(b"FontName".to_vec(), Object::name(base_font));
descriptor.set(b"FontFile2".to_vec(), Object::Ref(program));
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("TrueType"));
font.set(b"BaseFont".to_vec(), Object::name(base_font));
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
font.set(b"FirstChar".to_vec(), Object::Int(32));
font.set(b"LastChar".to_vec(), Object::Int(126));
font.set(b"Widths".to_vec(), Object::Array((32..=126).map(|_| Object::Int(560)).collect()));
font.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor));
doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(font));
reopen(&doc)
}
/// The font programs and subtypes of the page's font resources.
fn page_font_programs(doc: &Document) -> Vec<(Vec<u8>, Option<Vec<u8>>)> {
let page = pdfcraft_model::pages(doc).remove(0);
let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let fonts = resources.get(b"Font").and_then(|f| doc.resolve(f).as_dict().cloned()).unwrap_or_default();
fonts
.iter()
.filter_map(|(_, f)| doc.resolve(f).as_dict().cloned())
.map(|f| (f.name(b"Subtype").unwrap_or_default().to_vec(), pdfcraft_fonts::pdf::embedded_sfnt(doc, &f)))
.collect()
}
#[test]
fn a_new_character_comes_from_the_source_fonts_own_embedded_program() {
let inter = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
for (content, from, to) in [
// One row (the single-style path) and a row of a two-row paragraph (the row editor's path).
("BT /F1 12 Tf 72 700 Td (Stable text) Tj ET", "text", "tőxt"),
("BT /F1 12 Tf 14 TL 72 700 Td (First row) Tj T* (Second row) Tj ET", "Second", "Sőcond"),
] {
let mut doc = embedded_program_page(content, "Inter-Regular");
let block = text::text_blocks(&doc, 0).unwrap()[0].clone();
let draft = block.editor_text.replacen(from, to, 1);
let edit = text::rewrite_block(&mut doc, 0, 0, Some(&draft), &text::BlockStyle::default()).unwrap();
assert_eq!(edit.substituted, None, "{to}: no other family stands in");
let doc = reopen(&doc);
// The new character is its own run in the other resource, read back into the same word.
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, draft, "{to}");
let fonts = page_font_programs(&doc);
assert!(fonts.iter().all(|(subtype, _)| subtype != b"Type3"), "{to}: no fallback glyphs");
assert!(
fonts.iter().any(|(subtype, program)| subtype == b"Type0" && program.as_deref() == Some(inter.as_slice())),
"{to}: the source program draws it"
);
}
}
#[test]
fn a_shaped_run_is_one_text_object_with_each_glyph_where_shaping_put_it() {
let inter = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Stable text) Tj ET");
let resource = text::register_text_font(&mut doc, 0, inter).unwrap();
// Marks shaped beside their letters (q́, x̣) and one stacked above another (ñ̄).
let typed = "q\u{301}x\u{323} n\u{303}\u{304} Cafe\u{301}";
let style = text::BlockStyle { width: Some(400.0), resource: Some(resource), ..Default::default() };
text::rewrite_block(&mut doc, 0, 0, Some(typed), &style).unwrap();
assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap()[0].editor_text, typed);
let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops;
let start = ops.iter().position(|o| o.is("BDC")).unwrap();
let end = start + ops[start..].iter().position(|o| o.is("EMC")).unwrap();
let run = &ops[start..end];
let count = |name: &str| run.iter().filter(|o| o.is(name)).count();
assert_eq!((count("BT"), count("Tm"), count("Tj")), (1, 1, 0), "{run:?}");
// Each glyph's origin, read back with the text-space displacements of ISO 32000-1 §9.4.4.
let font = run.iter().find(|o| o.is("Tf")).unwrap();
let (resource, size) = (font.operands[0].as_name().unwrap().to_vec(), font.operands[1].as_f64().unwrap());
let page = pdfcraft_model::pages(&doc).remove(0);
let resources = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone();
let fonts = doc.resolve(resources.get(b"Font").unwrap()).as_dict().unwrap().clone();
let metrics = pdfcraft_fonts::pdf::Metrics::from_dict(&doc, doc.resolve(fonts.get(&resource).unwrap()).as_dict().unwrap());
let (mut x, mut rise, mut drawn) = (0.0, 0.0, Vec::new());
for op in run {
match op.op.as_slice() {
b"Ts" => rise = op.operands[0].as_f64().unwrap(),
b"Tc" | b"Tw" => assert_eq!(op.operands[0].as_f64(), Some(0.0), "{run:?}"),
b"Tz" => assert_eq!(op.operands[0].as_f64(), Some(100.0), "{run:?}"),
b"TJ" => {
for item in op.operands[0].as_array().unwrap() {
match item.as_string() {
Some(s) => {
for code in s.bytes.chunks(2) {
drawn.push((x, rise));
x += metrics.width(u32::from(u16::from_be_bytes([code[0], code[1]]))) * size;
}
}
None => x -= item.as_f64().unwrap() / 1000.0 * size,
}
}
}
_ => {}
}
}
let shaped = pdfcraft_fonts::source_shaping::shape(inter, typed).unwrap();
assert_eq!(drawn.len(), shaped.glyphs.len());
assert!(shaped.glyphs.iter().any(|g| g.position[1] != 0.0), "a mark is raised");
for (glyph, (x, y)) in shaped.glyphs.iter().zip(&drawn) {
assert!((glyph.position[0] * size - x).abs() < 1e-3 && (glyph.position[1] * size - y).abs() < 1e-3, "{glyph:?} at {x}, {y}");
}
assert_eq!(rise, 0.0, "the rise is reset after the run");
}
#[test]
fn a_subset_program_is_not_trusted_for_new_characters_and_the_error_names_the_character() {
let mut doc = embedded_program_page("BT /F1 12 Tf 72 700 Td (Stable text) Tj ET", "ABCDEF+Inter-Regular");
// The subset's own program is not trusted for a character: a fallback face draws it through a
// generated font instead (the subset may map a code whose outline was dropped). The Japanese
// sans face leads the fallbacks when built with craft-fonts; the bundled Inter face otherwise.
let edit = text::rewrite_block(&mut doc, 0, 0, Some("Stable tőxt"), &text::BlockStyle::default()).unwrap();
let face = pdfcraft_fonts::document_japanese_font_for_style(false, false).map_or("Inter", |f| f.family);
assert_eq!(edit.substituted, Some(format!("{face} Type3")));
assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap()[0].editor_text, "Stable tőxt");
// A character no fallback face has is refused, named, and left as it was.
let mut doc = embedded_program_page("BT /F1 12 Tf 72 700 Td (Stable text) Tj ET", "ABCDEF+Inter-Regular");
let before = page_content_bytes(&doc, 0);
let result = text::rewrite_block(&mut doc, 0, 0, Some("Stable กtext"), &text::BlockStyle::default());
let Err(EditError::Invalid(msg)) = result else { panic!("expected a clear error, got {result:?}") };
assert!(msg.contains("ก (U+0E01)") && msg.contains("Load font") && !msg.contains("Japanese"), "{msg}");
assert_eq!(page_content_bytes(&doc, 0), before);
}
#[test]
fn draft_layout_matches_the_paragraph_an_applied_edit_draws() {
let content = "BT /F1 12 Tf 14 TL 72 700 Td (A first paragraph of words) Tj T* (with a second row) Tj ET \
BT /F1 12 Tf 72 600 Td (Another paragraph below) Tj ET BT /F1 12 Tf 320 700 Td (Side note) Tj ET";
let doc = text_page(content);
let block = text::text_blocks(&doc, 0).unwrap().into_iter().position(|b| b.text.starts_with("A first")).unwrap();
let source = text::text_blocks(&doc, 0).unwrap()[block].clone();
for draft in [
source.editor_text.replacen("words", "many more words than before", 1),
source.editor_text.replacen(" of words", "", 1),
format!("{}\nand a third row", source.editor_text),
] {
let layout = text::text_edit_layout(&doc, 0, block, &draft, &text::BlockStyle::default()).unwrap();
// Apply the same edit and measure every run that isn't one of the page's other runs.
let mut applied = doc.clone();
text::rewrite_block(&mut applied, 0, block, Some(&draft), &text::BlockStyle::default()).unwrap();
let others: Vec<_> =
text::text_lines(&doc, 0).unwrap().into_iter().enumerate().filter(|(i, _)| !source.lines.contains(i)).map(|(_, l)| l.rect).collect();
let drawn = text::text_lines(&applied, 0)
.unwrap()
.into_iter()
.map(|l| l.rect)
.filter(|r| !others.contains(r))
.reduce(|a, b| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]);
assert_eq!(layout.draft_rect, drawn, "{draft:?}");
assert_eq!(layout.source_rect, source.rect);
}
}
#[test]
fn japanese_paragraphs_wrap_between_characters_without_closing_marks_at_a_row_start() {
if pdfcraft_fonts::document_japanese_font().is_none() {
eprintln!("skipping the Japanese wrapping checks: built without craft-fonts (set CRAFT_FONTS_DIR)");
return;
}
let text = "日本語の文章は単語の間に空白がないので、どの文字の間でも行を折り返せます。ただし「句読点」や小さい「っ」は行頭に来ません。";
// One paragraph (the single-style path), and a heading row before it (the row editor's path).
for draft in [text.to_string(), format!("見出し\n{text}")] {
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Placeholder text) Tj ET");
text::rewrite_block(&mut doc, 0, 0, Some(&draft), &text::BlockStyle { width: Some(120.0), ..Default::default() }).unwrap();
let block = text::text_blocks(&reopen(&doc), 0).unwrap().remove(0);
let rows: Vec<&str> = block.editor_text.split('\n').collect();
assert!(rows.len() > 4, "{rows:?}");
assert_eq!(rows.concat(), draft.replace('\n', ""), "no space is added between the pieces");
assert!(block.rect[2] - block.rect[0] <= 120.5, "{:?} {rows:?}", block.rect);
for row in &rows {
assert!(!row.starts_with(['、', '。', '」', 'っ']) && !row.ends_with('「'), "{row:?} in {rows:?}");
}
}
}
#[test]
fn rewritten_paragraph_is_drawn_after_backgrounds_between_its_rows() {
// Each row has its own background box drawn immediately before its BT..ET, as cards and
// highlighted headings exported from LibreOffice or browsers do (#2377).
let card = "0 0.45 0.25 rg 60 696 240 16 re f BT /F1 10 Tf 1 1 1 rg 64 700 Td (SEMINARIO ONLINE) Tj ET \
0 0.45 0.25 rg 60 668 240 24 re f BT /F1 16 Tf 1 1 1 rg 64 674 Td (Dalla teoria alla pratica:) Tj ET \
0 0.45 0.25 rg 60 632 240 32 re f BT /F1 24 Tf 1 1 1 rg 64 640 Td (gestire con metodo) Tj ET \
0 0.45 0.25 rg 60 596 240 32 re f BT /F1 24 Tf 1 1 1 rg 64 604 Td (un progetto complesso) Tj ET";
let shown_at = |ops: &[pdfcraft_content::Op], needle: &str| {
ops.iter()
.position(|op| {
matches!(op.op.as_slice(), b"Tj" | b"TJ")
&& op.operands.iter().any(|o| matches!(o, Object::String(s) if String::from_utf8_lossy(&s.bytes).contains(needle)))
})
.unwrap()
};
// 1. Editing the title rows emits all rewritten rows after all four background fills.
let mut doc = text_page(card);
assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 1);
let edited = "SEMINARIO ONLINE\nDalla teoria alla pratica:\ngestire con criterio\nun progetto nuovo";
text::rewrite_block(&mut doc, 0, 0, Some(edited), &text::BlockStyle::default()).unwrap();
let doc = reopen(&doc);
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, edited);
let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops;
let fills: Vec<usize> = ops.iter().enumerate().filter_map(|(i, op)| op.is("f").then_some(i)).collect();
assert_eq!(fills.len(), 4);
assert!(
shown_at(&ops, "criterio") > fills[3] && shown_at(&ops, "nuovo") > fills[3],
"rewritten title rows must be drawn after their row background fills (fills at {fills:?}): {ops:?}"
);
// 2. Partial edit with a kept suffix row: rewritten rows 0..=2 are drawn after fills[0..=2]
// and still before kept row 3, preserving reading order.
let mut doc = text_page(card);
let partial = "WORKSHOP ONLINE\nDalla teoria alla pratica:\ngestire con criterio\nun progetto complesso";
text::rewrite_block(&mut doc, 0, 0, Some(partial), &text::BlockStyle::default()).unwrap();
let doc = reopen(&doc);
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, partial);
let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops;
let fills: Vec<usize> = ops.iter().enumerate().filter_map(|(i, op)| op.is("f").then_some(i)).collect();
assert!(
shown_at(&ops, "WORKSHOP") > fills[2]
&& shown_at(&ops, "pratica:") > fills[2]
&& shown_at(&ops, "criterio") > fills[2]
&& shown_at(&ops, "criterio") < shown_at(&ops, "complesso")
&& shown_at(&ops, "complesso") > fills[3],
"rewritten prefix rows must follow their fills and precede the kept final row"
);
// 3. Editing row 2 so that it wraps pushes row 3 down; all rewritten rows must follow fills[3].
let mut doc = text_page(card);
let wrapped_edit = "SEMINARIO ONLINE\nDalla teoria alla pratica:\ngestire con criterio e metodo approfondito\nun progetto complesso";
text::rewrite_block(&mut doc, 0, 0, Some(wrapped_edit), &text::BlockStyle::default()).unwrap();
let doc = reopen(&doc);
let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops;
let last_fill = ops.iter().rposition(|op| op.is("f")).unwrap();
assert!(
shown_at(&ops, "approfondito") > last_fill && shown_at(&ops, "complesso") > last_fill,
"wrapped rows and pushed-down suffix row must follow all row background fills"
);
// 4. Single-style paragraph path across a CTM change between rows, with movement, underline,
// and a trailing unrelated line sharing the second row's BT..ET.
let cross_ctm = "0.9 0.9 0.2 rg 60 696 240 18 re f BT /F1 12 Tf 0 0 0 rg 64 700 Td (First highlighted row) Tj ET \
q 1 0 0 1 10 12 cm 0.9 0.9 0.2 rg 50 668 240 18 re f \
BT /F1 12 Tf 0 0 0 rg 54 672 Td (Second highlighted row) Tj 246 -40 Td (Side footer) Tj ET Q";
let mut doc = text_page(cross_ctm);
let para = text::text_blocks(&doc, 0).unwrap().into_iter().position(|b| b.text.starts_with("First highlighted")).unwrap();
assert_eq!(text::text_blocks(&doc, 0).unwrap()[para].rows.len(), 2);
text::rewrite_block(
&mut doc,
0,
para,
Some("Updated first highlighted row and updated second highlighted row"),
&text::BlockStyle { offset: Some([6.0, -4.0]), underline: Some(true), ..Default::default() },
)
.unwrap();
let doc = reopen(&doc);
let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops;
let last_fill = ops.iter().rposition(|op| op.is("f")).unwrap();
assert!(
shown_at(&ops, "Updated first") > last_fill && shown_at(&ops, "Side footer") > shown_at(&ops, "Updated first"),
"cross-CTM single-style rewrite must follow both background fills and precede the shared BT's trailing line"
);
let lines = text::text_lines(&doc, 0).unwrap();
let updated_first = lines.iter().find(|l| l.text.starts_with("Updated first")).unwrap();
let side_footer = lines.iter().find(|l| l.text == "Side footer").unwrap();
assert!(
(updated_first.baseline()[0] - 70.0).abs() < 0.01 && (updated_first.baseline()[1] - 696.0).abs() < 0.01,
"offset baseline in user space across CTM: {:?}",
updated_first.baseline()
);
assert!(
(side_footer.baseline()[0] - 310.0).abs() < 0.01 && (side_footer.baseline()[1] - 644.0).abs() < 0.01,
"trailing line in shared BT keeps its tlm across CTM: {:?}",
side_footer.baseline()
);
}
/// Small graphics-state oracle for synthetic streams: every tracked state change must
/// have been restored when a probe XObject would draw. No external assets are involved.
fn probe_states(doc: &Document) -> Vec<std::collections::BTreeMap<Vec<u8>, Vec<Object>>> {
let bytes = streams(doc, 0);
let joined = pdfcraft_content::Pieces::join(&bytes);
let mut state = std::collections::BTreeMap::new();
let mut stack = Vec::new();
let mut probes = Vec::new();
for op in joined.parse().ops {
match op.op.as_slice() {
b"q" => stack.push(state.clone()),
b"Q" => state = stack.pop().expect("balanced graphics state"),
b"cm" | b"W" | b"W*" | b"Tz" | b"Tc" | b"Tw" | b"Tr" | b"cs" => {
state.insert(op.op, op.operands);
}
b"Do" if op.name(0) == Some(b"PCProbe") => probes.push(state.clone()),
_ => {}
}
}
probes
}
fn isolation_fixture(parts: &[(&str, &str)]) -> Document {
let mut doc = fixture();
let page = page_list(&doc).remove(0);
let list = parts
.iter()
.map(|(tag, bytes)| {
Object::Ref(doc.add(Object::Stream(Stream::from_raw(if tag.is_empty() { Dict::new() } else { tagged(tag) }, bytes.as_bytes().to_vec()))))
})
.collect();
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list))).unwrap();
doc
}
#[test]
fn new_marks_isolate_state_appended_after_old_wrappers() {
for dirt in ["0.5 0 0 0.5 0 0 cm", "0 0 200 200 re W n", "50 Tz", "5 Tc 20 Tw", "3 Tr", "/Pattern cs"] {
for tag in ["", "OCR", "Wrap"] {
let mut doc = isolation_fixture(&[
("Wrap", "q %PdfCraft\n"),
("", "2 0 0 2 0 0 cm"),
("Wrap", "Q %PdfCraft\n"),
("Added", "q /PCProbe Do Q"),
(tag, dirt),
]);
let before = probe_states(&doc);
stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap();
let after = probe_states(&reopen(&doc));
assert_eq!(&after[..before.len()], &before, "existing content must not change");
assert!(after.last().unwrap().is_empty(), "tag={tag}, dirt={dirt}: {after:?}");
}
}
}
#[test]
fn new_marks_isolate_dirty_tagged_only_pages() {
let mut doc = isolation_fixture(&[("OCR", "50 Tz 5 Tc 20 Tw")]);
stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap();
assert!(probe_states(&doc).last().unwrap().is_empty());
}
#[test]
fn new_marks_do_not_accumulate_wrappers_for_self_contained_marks() {
let mut doc = isolation_fixture(&[("", "2 0 0 2 0 0 cm")]);
for _ in 0..10 {
stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap();
}
assert_eq!(streams(&doc, 0).len(), 13);
assert!(probe_states(&doc).iter().all(|s| s.is_empty()));
}
#[test]
fn new_marks_close_wrapper_after_an_unterminated_comment() {
let mut doc = isolation_fixture(&[("", "50 Tz % no final newline")]);
stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap();
// Check literal concatenation too: a separator must exist in the saved stream itself,
// not just in the inspection parser, or the comment can swallow the closing Q.
let bytes = streams(&doc, 0).concat();
let parsed = pdfcraft_content::parse(bytes.as_bytes());
assert_eq!(parsed.ops.iter().filter(|op| op.is("Q")).count(), 2);
assert!(probe_states(&doc).last().unwrap().is_empty());
}
#[test]
fn new_marks_wrap_unbalanced_graphics_state_whole_without_changing_it() {
// Content that can't be isolated precisely is wrapped whole, as before #3084, instead of
// refusing: redaction stamps its boxes after removing the text, so it can't back out.
for (data, probe_clean) in [("q 50 Tz", true), ("50 Tz q", true), ("q q Q", true), ("Q 50 Tz", false), ("q Q Q 50 Tz", false)] {
let mut doc = isolation_fixture(&[("Wrap", data)]);
let page = page_list(&doc).remove(0);
place_tagged(&mut doc, &page, "Probe", b"q /PCProbe Do Q".to_vec(), false).unwrap();
let after = streams(&doc, 0);
assert_eq!(after.len(), 4, "{data}: {after:?}");
assert_eq!((after[0].as_str(), after[1].as_str()), ("q %PdfCraft\n", data), "{data}");
// A `q` the content leaves open is closed after it, so the mark starts clean. A stray
// `Q` pops the wrapper itself: nothing placed after it can undo that.
let probe = probe_states_lenient(&doc);
assert_eq!(probe.last().unwrap().is_empty(), probe_clean, "{data}: {after:?}");
}
}
/// [`probe_states`] for unbalanced content: a `Q` with nothing saved leaves the state as is,
/// as renderers do.
fn probe_states_lenient(doc: &Document) -> Vec<std::collections::BTreeMap<Vec<u8>, Vec<Object>>> {
let joined = pdfcraft_content::Pieces::join(&streams(doc, 0));
let mut state = std::collections::BTreeMap::new();
let mut stack = Vec::new();
let mut probes = Vec::new();
for op in joined.parse().ops {
match op.op.as_slice() {
b"q" => stack.push(state.clone()),
b"Q" => state = stack.pop().unwrap_or(state),
b"cm" | b"W" | b"W*" | b"Tz" | b"Tc" | b"Tw" | b"Tr" | b"cs" => {
state.insert(op.op, op.operands);
}
b"Do" if op.name(0) == Some(b"PCProbe") => probes.push(state.clone()),
_ => {}
}
}
probes
}
#[test]
fn new_marks_preserve_cross_stream_operands_and_q_pairs() {
let mut doc = isolation_fixture(&[("", "q 2 0 0 2"), ("", "0 0 cm Q 50"), ("", "Tz")]);
stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap();
assert!(probe_states(&doc).last().unwrap().is_empty());
}
#[test]
fn removing_marks_keeps_wrappers_needed_by_later_external_content() {
let mut doc = isolation_fixture(&[
("Wrap", "q %PdfCraft\n"),
("", "2 0 0 2 0 0 cm"),
("Wrap", "Q %PdfCraft\n"),
("HeaderFooter", "q /PCProbe Do Q"),
("", "/PCProbe Do"),
]);
remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap();
assert_eq!(probe_states(&doc).len(), 1);
assert!(probe_states(&doc)[0].is_empty(), "the external content still needs the original reset");
}
#[test]
fn new_marks_wrap_unfinished_text_paths_and_operands_whole() {
for data in ["BT", "ET", "BT BT ET", "0 0 10 10 re", "q 0 0 m Q", "50"] {
let mut doc = isolation_fixture(&[("Background", "q Q"), ("", data)]);
let page = page_list(&doc).remove(0);
place_tagged(&mut doc, &page, "Probe", b"q /PCProbe Do Q".to_vec(), false).unwrap();
// The behind-mark stays outside the wrapper and the content itself is untouched.
assert_eq!(streams(&doc, 0), ["q Q", "q %PdfCraft\n", data, "\nQ %PdfCraft\n", "q /PCProbe Do Q"], "{data}");
}
}
#[test]
fn new_marks_do_not_reveal_cross_stream_comments() {
let mut doc = isolation_fixture(&[("", "q 50 Tz % comment continues"), ("", "Q 20 Tc\nQ 5 Tw")]);
stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap();
assert!(probe_states(&doc).last().unwrap().is_empty());
let joined = pdfcraft_content::Pieces::join(&streams(&doc, 0));
assert!(!joined.parse().ops.iter().any(|op| op.is("Tc")), "commented content must remain inert");
}
#[test]
fn new_marks_follow_clean_streams_ending_in_comments() {
let mut doc = isolation_fixture(&[("Added", "q 50 Tz Q % no newline")]);
stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap();
assert_eq!(probe_states(&doc).len(), 1, "the new mark must not be swallowed by a comment");
assert!(probe_states(&doc)[0].is_empty());
}
#[test]
fn removing_marks_does_not_strip_arbitrary_wrap_tagged_content() {
let mut doc = isolation_fixture(&[("Wrap", "q /PCProbe Do Q"), ("HeaderFooter", "q Q")]);
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1);
assert_eq!(probe_states(&doc).len(), 1);
}
#[test]
fn removing_marks_strips_nested_trailing_wrapper_pairs() {
let mut doc = isolation_fixture(&[
("Wrap", "q %PdfCraft\n"),
("Wrap", "q %PdfCraft\n"),
("", "2 0 0 2 0 0 cm"),
("Wrap", "Q %PdfCraft\n"),
("Wrap", "Q %PdfCraft\n"),
("HeaderFooter", "q Q"),
]);
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1);
assert_eq!(streams(&doc, 0), ["2 0 0 2 0 0 cm"]);
}
#[test]
fn updating_marks_on_unreadable_content_does_not_nest_wrappers() {
for data in ["q 50 Tz", "Q 50 Tz", "BT"] {
let mut doc = isolation_fixture(&[("", data)]);
let page = page_list(&doc).remove(0);
for _ in 0..3 {
place_tagged(&mut doc, &page, MarkKind::HeaderFooter.tag(), b"q /PCProbe Do Q".to_vec(), false).unwrap();
assert_eq!(streams(&doc, 0).len(), 4, "{data}: {:?}", streams(&doc, 0));
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1);
assert_eq!(streams(&doc, 0), [data], "{data}: the original list comes back");
}
}
}
#[test]
fn removing_marks_strips_wrappers_that_closed_open_state() {
let mut doc = isolation_fixture(&[("", "q 2 0 0 2 0 0 cm")]);
stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap();
assert!(probe_states(&doc).last().unwrap().is_empty());
// Now balanced, the page reads cleanly: the close (with its extra `Q`) still pairs.
let page = page_list(&doc).remove(0);
place_tagged(&mut doc, &page, MarkKind::Watermark.tag(), b"q /PCProbe Do Q\n".to_vec(), false).unwrap();
assert_eq!(streams(&doc, 0).len(), 5, "no second wrapper: {:?}", streams(&doc, 0));
let mut doc = isolation_fixture(&[("", "q 2 0 0 2 0 0 cm")]);
let page = page_list(&doc).remove(0);
place_tagged(&mut doc, &page, MarkKind::Watermark.tag(), b"q /PCProbe Do Q\n".to_vec(), false).unwrap();
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::Watermark).unwrap(), 1);
assert_eq!(streams(&doc, 0), ["q 2 0 0 2 0 0 cm"]);
}
/// #3087: flattening comments leaves alone what isn't a comment: links, and the non-markup
/// multimedia and print-production annotations (3D, Screen, Movie, RichMedia, PrinterMark,
/// TrapNet, Watermark), which it used to bake into the page and delete with their data.
/// Flattening fields only leaves them alone too; real comments are still flattened.
#[test]
fn flattening_comments_keeps_annotations_that_are_not_comments() {
let kept = ["Widget", "Link", "3D", "Screen", "Movie", "RichMedia", "PrinterMark", "TrapNet", "Watermark"];
let flattened = ["Text", "Square", "FileAttachment"];
let all: Vec<&str> = flattened.iter().chain(kept.iter()).copied().collect();
let form = "<< /Type /XObject /Subtype /Form /BBox [0 0 40 20] /Length 26 >>\nstream\n0 0 1 rg 0 0 40 20 re f\nendstream";
let annots: Vec<String> = all
.iter()
.enumerate()
.map(|(i, s)| {
format!("<< /Type /Annot /Subtype /{s} /Rect [{x} 700 {r} 720] /AP << /N 4 0 R >> /Contents ({s}) >>", x = 20 + 50 * i, r = 60 + 50 * i)
})
.collect();
let refs: Vec<String> = (0..all.len()).map(|i| format!("{} 0 R", 6 + i)).collect();
let page = format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 5 0 R /Annots [{}] >>", refs.join(" "));
let content = stream("", " ");
let mut objs: Vec<&str> = vec!["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", &page, form, &content];
objs.extend(annots.iter().map(String::as_str));
let left = |doc: &Document| -> Vec<String> {
let p = &pdfcraft_model::pages(doc)[0];
let Some(a) = p.dict.get(b"Annots").map(|a| doc.resolve(a)) else { return Vec::new() };
a.as_array()
.unwrap()
.iter()
.filter_map(|o| doc.resolve(o).as_dict().and_then(|d| d.name(b"Subtype")).map(|s| String::from_utf8_lossy(s).into_owned()))
.collect()
};
let mut doc = build(&objs);
assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), flattened.len());
assert_eq!(left(&reopen(&doc)), kept, "only the comments were flattened");
let mut doc = build(&objs);
assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1, "the widget");
let without_widget: Vec<&str> = all.iter().copied().filter(|s| *s != "Widget").collect();
assert_eq!(left(&reopen(&doc)), without_widget, "flattening fields touches no annotation that isn't a widget");
}
/// A footnote marker (`Ts` text rise, a smaller `Tf` size) does not split the paragraph around it
/// into separate editable zones (#3252): on `main` this read as three blocks — the text up to the
/// marker, the marker alone, and the text after it — because the row-joining baseline tolerance
/// was scaled by the smaller of the two runs' sizes, far tighter than a typical superscript's rise.
#[test]
fn a_superscript_footnote_marker_stays_in_its_paragraphs_row() {
let doc = text_page(
"BT /F1 12 Tf 72 700 Td (and A.) Tj 0 -14 Td (Edward Siecienski) Tj 4 Ts /F1 7.2 Tf (3) Tj 0 Ts /F1 12 Tf (. More words continue the paragraph right after the marker.) Tj ET",
);
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 4, "the marker is its own line, raised and smaller: {lines:?}");
let blocks = text::text_blocks(&doc, 0).unwrap();
assert_eq!(blocks.len(), 1, "one paragraph, not split at the marker: {blocks:?}");
assert_eq!(blocks[0].rows.len(), 2, "two visual rows, the second carrying the marker");
assert_eq!(blocks[0].text, "and A. Edward Siecienski3. More words continue the paragraph right after the marker.", "{:?}", blocks[0]);
}
/// A page whose text is a clipping mask (`mode` Tr) with a fill painted through it.
fn clipping_text_page(mode: i64) -> Document {
text_page(&format!("q\nBT {mode} Tr /F1 48 Tf 40 60 Td (MASK) Tj ET\n1 0 0 rg 0 0 300 150 re f\nQ\n"))
}
/// Rewriting text that is a clipping path destroyed the artwork it masked: the glyphs went, the
/// mask with them, and the fill behind flooded its own rectangle, in a file that still opened
/// without a warning (#3429). Photoshop exports draw every text object this way. The rewrite is
/// refused instead, as `clear_block` already refuses a preview.
#[test]
fn rewriting_clipping_path_text_is_refused_for_every_clipping_mode() {
for mode in [4, 5, 6, 7] {
let before = text::text_lines(&clipping_text_page(mode), 0).unwrap();
assert_eq!(before.len(), 1, "mode {mode}: the mask is one line");
let mut doc = clipping_text_page(mode);
let line = text::replace_line(&mut doc, 0, 0, "EDITED");
assert!(matches!(&line, Err(EditError::Invalid(m)) if m.contains("clipping path")), "mode {mode}: {line:?}");
let mut doc = clipping_text_page(mode);
let block = text::rewrite_block(&mut doc, 0, 0, Some("EDITED"), &BlockStyle::default());
assert!(matches!(&block, Err(EditError::Invalid(m)) if m.contains("clipping path")), "mode {mode}: {block:?}");
// Refused means untouched: the mask is still there to shape what is painted through it.
let after = text::text_lines(&doc, 0).unwrap();
assert_eq!(after.len(), 1, "mode {mode}");
assert_eq!(after[0].text, "MASK", "mode {mode}");
}
}
/// The modes that paint ink are still editable: only 4 to 7 add to the clip path.
#[test]
fn painting_modes_are_still_rewritten() {
for mode in [0, 1, 2] {
let mut doc = clipping_text_page(mode);
assert!(text::replace_line(&mut doc, 0, 0, "EDITED").is_ok(), "mode {mode}");
assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "EDITED", "mode {mode}");
}
}
/// A line's runs need not share a rendering mode: nothing in the join requires it, so a line that
/// starts as ink can continue as a mask. The refusal reads the whole line, not the mode its first
/// run set -- otherwise the rewrite passed the guard and took the mask away with the ink.
#[test]
fn a_line_that_starts_as_ink_and_continues_as_a_mask_is_refused() {
let content = "q\nBT /F1 48 Tf 40 60 Td 0 Tr (TITLE) Tj 7 Tr (MASK) Tj ET\n1 0 0 rg 0 0 300 150 re f\nQ\n";
let before = text::text_lines(&text_page(content), 0).unwrap();
assert_eq!(before.len(), 1, "the two runs are one line: {:?}", before.iter().map(|l| &l.text).collect::<Vec<_>>());
assert_eq!(before[0].text, "TITLEMASK", "and the mask is the second half of it");
let mut doc = text_page(content);
let line = text::replace_line(&mut doc, 0, 0, "EDITED");
assert!(matches!(&line, Err(EditError::Invalid(m)) if m.contains("clipping path")), "{line:?}");
let block = text::rewrite_block(&mut doc, 0, 0, Some("EDITED"), &BlockStyle::default());
assert!(matches!(&block, Err(EditError::Invalid(m)) if m.contains("clipping path")), "{block:?}");
assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "TITLEMASK", "refused means untouched");
}
/// Another drawing of the line on the same baseline goes with it, so that the replacement is all
/// that shows -- and that copy can be the mask while the line being replaced is ink. Dropping it
/// destroys the artwork as surely as rewriting the mask itself, so this is refused too.
#[test]
fn a_clipping_copy_on_the_same_baseline_is_refused() {
let content = "q\nBT 0 Tr /F1 48 Tf 40 60 Td (MASK) Tj ET\nBT 7 Tr /F1 48 Tf 40 60 Td (MASK) Tj ET\n1 0 0 rg 0 0 300 150 re f\nQ\n";
let before = text::text_lines(&text_page(content), 0).unwrap();
assert_eq!(before.len(), 2, "two drawings of the same line");
let mut doc = text_page(content);
let line = text::replace_line(&mut doc, 0, 0, "EDITED");
assert!(matches!(&line, Err(EditError::Invalid(m)) if m.contains("clipping path")), "{line:?}");
let block = text::rewrite_block(&mut doc, 0, 0, Some("EDITED"), &BlockStyle::default());
assert!(matches!(&block, Err(EditError::Invalid(m)) if m.contains("clipping path")), "{block:?}");
assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 2, "refused means untouched");
}
/// #2512: a session's drafts are laid out from the document as it was opened, never from each
/// other, and its commit writes what a one-shot rewrite writes, whatever was laid out before.
#[test]
fn a_text_session_commits_what_a_rewrite_writes_whatever_it_laid_out_before() {
let page = "BT /F1 12 Tf 14 TL 72 700 Td (The first row of a paragraph) Tj T* (and its second row here.) Tj ET";
let bold = BlockStyle { bold: Some(true), ..Default::default() };
let drafts: [(&str, &BlockStyle); 4] = [
("The first row of a paragraph\nand its second row here, with more words to wrap it onto a third row", &BlockStyle::default()),
("Short", &bold),
("The first row\nsecond", &BlockStyle::default()),
("The first row of a paragraph\nand its second row here.", &bold),
];
let doc = text_page(page);
let session = text::TextSession::open(&doc, 0, 0).unwrap();
assert_eq!(session.source().editor_text, "The first row of a paragraph\nand its second row here.");
for (text, style) in drafts {
let fresh = text::text_edit_layout(&text_page(page), 0, 0, text, style).unwrap();
assert_eq!(session.layout(text, style).unwrap(), fresh, "{text:?} is laid out from the opened page");
}
let (text, style) = drafts[0];
let mut by_session = doc.clone();
session.commit(&mut by_session, Some(text), style).unwrap();
let mut by_rewrite = text_page(page);
text::rewrite_block(&mut by_rewrite, 0, 0, Some(text), style).unwrap();
assert_eq!(
pdfcraft_cos::write_full(&by_session, &SaveOptions::default()).unwrap(),
pdfcraft_cos::write_full(&by_rewrite, &SaveOptions::default()).unwrap(),
"the commit writes what a rewrite writes"
);
// A document changed since the session opened isn't written to.
let before = pdfcraft_cos::write_full(&by_session, &SaveOptions::default()).unwrap();
let refused = session.commit(&mut by_session, Some("Again"), &BlockStyle::default());
assert!(matches!(&refused, Err(EditError::Invalid(m)) if m.contains("changed while it was being edited")), "{refused:?}");
assert_eq!(pdfcraft_cos::write_full(&by_session, &SaveOptions::default()).unwrap(), before);
assert!(session.is_current(&doc) && !session.is_current(&by_session));
}
/// A one-page document whose resources have `/GS0` (30 % opacity) and `/L1`, a layer that is OFF.
fn row_state_page(content: &str) -> Document {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R] /D << /OFF [5 0 R] /Order [5 0 R] >> >> >>".into(),
"<< /Type /Pages /Kids [6 0 R] /Count 1 >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
"<< /Type /ExtGState /ca 0.3 /CA 0.3 >>".into(),
"<< /Type /OCG /Name (Hidden layer) >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 7 0 R /Resources << /Font << /F1 3 0 R >> /ExtGState << /GS0 4 0 R /GS1 << /Type /ExtGState /ca 0.3 /CA 0.3 /BM /Normal >> /GS2 << /Type /ExtGState /ca 1 /CA 1 >> >> /Properties << /L1 5 0 R >> >> >>".into(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
/// Each shown string on page 0 with the state it is drawn in: inside a `/OC` marked-content
/// sequence, after `/GS0 gs`, and its horizontal scale (percent).
fn shown_states(doc: &Document) -> Vec<(String, bool, bool, f64)> {
let joined = streams(doc, 0).join("\n");
let (mut oc, mut marks) = (0usize, Vec::new());
let (mut gs, mut tz, mut stack) = (false, 100.0, Vec::new());
let mut out = Vec::new();
for op in pdfcraft_content::parse(joined.as_bytes()).ops {
match op.op.as_slice() {
b"BDC" | b"BMC" => {
let layer = op.name(0) == Some(b"OC".as_slice());
oc += usize::from(layer);
marks.push(layer);
}
b"EMC" => oc -= usize::from(marks.pop().unwrap()),
b"q" => stack.push((gs, tz)),
b"Q" => (gs, tz) = stack.pop().unwrap(),
b"gs" => gs |= op.name(0) == Some(b"GS0".as_slice()),
b"Tz" => tz = op.num(0).unwrap(),
b"Tj" | b"'" => {
let Some(Object::String(s)) = op.operands.last() else { continue };
if !s.bytes.is_empty() {
out.push((String::from_utf8_lossy(&s.bytes).into_owned(), oc > 0, gs, tz));
}
}
_ => {}
}
}
out
}
/// #3115: a row drawn in a hidden layer, at 30 % opacity or at 80 % horizontal scale keeps that
/// state when another row of the same paragraph is edited; the edited row doesn't take it on.
#[test]
fn editing_a_row_keeps_the_layer_opacity_and_scale_of_the_rows_around_it() {
let row1 = "BT /F1 12 Tf 72 700 Td (Hello world, this is the first line) Tj ET\n";
let row2 = "BT /F1 12 Tf 72 686 Td (and the second) Tj ET";
let pages = [
(format!("{row1}/OC /L1 BDC {row2} EMC"), (true, false, 100.0)),
(format!("{row1}q /GS0 gs {row2} Q"), (false, true, 100.0)),
("BT /F1 12 Tf 72 700 Td (Hello world, this is the first line) Tj 80 Tz 0 -14 Td (and the second) Tj ET".to_string(), (false, false, 80.0)),
(format!("{row1}{row2}"), (false, false, 100.0)),
];
for (n, (content, (oc, gs, tz))) in pages.iter().enumerate() {
let mut doc = row_state_page(content);
let blocks = text::text_blocks(&doc, 0).unwrap();
let block = blocks.iter().position(|b| b.text.starts_with("Hello")).unwrap();
let text = blocks[block].text.replacen("Hello", "Jello", 1);
text::replace_block(&mut doc, 0, block, &text).unwrap();
let shown = shown_states(&reopen(&doc));
let find = |needle: &str| shown.iter().filter(|s| s.0.contains(needle)).cloned().collect::<Vec<_>>();
let first = find("Jello");
assert_eq!(first.len(), 1, "page {}: {shown:?}", n + 1);
assert_eq!((first[0].1, first[0].2, first[0].3), (false, false, 100.0), "page {}: the edited row keeps its own state: {shown:?}", n + 1);
let second = find("and the second");
assert_eq!(second.len(), 1, "page {}: {shown:?}", n + 1);
assert_eq!((second[0].1, second[0].2, second[0].3), (*oc, *gs, *tz), "page {}: the other row keeps its state: {shown:?}", n + 1);
}
// The control: rows in the same state are still one paragraph.
let blocks = text::text_blocks(&row_state_page(&pages[3].0), 0).unwrap();
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].text, "Hello world, this is the first line and the second");
}
/// What keeps rows apart is the graphics state they are drawn in, not the `gs` operators
/// before them: a repeated `gs` without q/Q, or another name with the same settings, is still one
/// paragraph, and a `gs` that changes the opacity or puts it back is not.
#[test]
fn rows_under_repeated_or_equal_gs_states_stay_one_paragraph() {
let row1 = "BT /F1 12 Tf 72 700 Td (Hello world, this is the first line) Tj ET\n";
let row2 = "BT /F1 12 Tf 72 686 Td (and the second) Tj ET";
let paragraphs = |content: String| text::text_blocks(&row_state_page(&content), 0).unwrap().len();
assert_eq!(paragraphs(format!("/GS0 gs {row1}/GS0 gs {row2}")), 1, "the same name twice");
assert_eq!(paragraphs(format!("/GS0 gs {row1}/GS0 gs /GS0 gs {row2}")), 1, "the same name three times");
assert_eq!(paragraphs(format!("/GS0 gs {row1}/GS1 gs {row2}")), 1, "two names, the same settings");
assert_eq!(paragraphs(format!("/GS2 gs {row1}{row2}")), 1, "settings that are the defaults");
assert_eq!(paragraphs(format!("{row1}/GS0 gs {row2}")), 2, "another opacity");
assert_eq!(paragraphs(format!("/GS0 gs {row1}/GS2 gs {row2}")), 2, "the opacity put back");
}
/// A page of three vertical (`Identity-V`) columns at 20 pt: `ABCDEF` at x = 500, the same
/// text 30 pt to its left, and `ABCD` far away. Synthetic font: no font program is needed.
fn vertical_columns_page() -> Document {
vertical_page(
"BT /V1 20 Tf 500 720 Td <004100420043004400450046> Tj ET\nBT /V1 20 Tf 470 720 Td <004100420043004400450046> Tj ET\nBT /V1 20 Tf 300 720 Td <0041004200430044> Tj ET",
)
}
/// A page drawing `content` with the vertical font `/V1` (as in [`vertical_columns_page`]) and
/// Helvetica `/F1`.
fn vertical_page(content: &str) -> Document {
let mut doc = text_page(content);
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-V")),
]);
let unicode = doc.add(Object::Stream(Stream::flate(
Dict::new(),
b"1 begincodespacerange <0000> <ffff> endcodespacerange 6 beginbfchar <0041> <0041> <0042> <0042> <0043> <0043> <0044> <0044> <0045> <0045> <0046> <0046> 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(1000)),
]))]),
);
let p = pdfcraft_model::pages(&doc)[0].obj;
let helvetica = Dict::from_iter([
(b"Type".to_vec(), Object::name("Font")),
(b"Subtype".to_vec(), Object::name("Type1")),
(b"BaseFont".to_vec(), Object::name("Helvetica")),
(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")),
]);
let fonts = Dict::from_iter([(b"V1".to_vec(), Object::Dict(font)), (b"F1".to_vec(), Object::Dict(helvetica))]);
let resources = Dict::from_iter([(b"Font".to_vec(), Object::Dict(fonts))]);
doc.update_dict(p, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap();
doc
}
#[test]
fn vertical_text_lines_are_columns_and_editing_one_keeps_its_neighbour() {
let doc = vertical_columns_page();
let lines = text::text_lines(&doc, 0).unwrap();
let first = lines.iter().find(|l| l.baseline()[0] == 500.0).unwrap();
let r = first.rect;
// Six glyphs, each 20 pt wide and advancing 20 pt down, centred on x = 500.
assert!((r[0] - 490.0).abs() < 0.5 && (r[2] - 510.0).abs() < 0.5, "{r:?}");
assert!((r[1] - 600.0).abs() < 0.5 && (r[3] - 720.0).abs() < 0.5, "{r:?}");
for edit in [0, 1] {
let mut doc = vertical_columns_page();
let lines = text::text_lines(&doc, 0).unwrap();
if edit == 0 {
let line = lines.iter().position(|l| l.baseline()[0] == 500.0).unwrap();
text::replace_line(&mut doc, 0, line, "ABCDEA").unwrap();
} else {
let blocks = text::text_blocks(&doc, 0).unwrap();
let block = blocks.iter().position(|b| b.lines.iter().any(|&i| lines[i].baseline()[0] == 500.0)).unwrap();
assert_eq!(blocks[block].lines.len(), 1, "{blocks:?}");
text::replace_block(&mut doc, 0, block, "ABCDEA").unwrap();
}
let after = text::text_lines(&doc, 0).unwrap();
let texts: Vec<(f64, &str)> = after.iter().map(|l| (l.baseline()[0], l.text.as_str())).collect();
assert!(texts.contains(&(500.0, "ABCDEA")), "{texts:?}");
assert!(texts.contains(&(470.0, "ABCDEF")), "{texts:?}");
assert!(texts.contains(&(300.0, "ABCD")), "{texts:?}");
}
}
/// Gives `/V1` an embedded CMap that uses itself: which way its glyphs advance can't be known.
fn make_cmap_cyclic(doc: &mut Document) {
let cmap = doc.add(Object::Null);
doc.set(
cmap,
Object::Stream(Stream::flate(
Dict::from_iter([(b"UseCMap".to_vec(), Object::Ref(cmap))]),
b"1 begincodespacerange <0000> <ffff> endcodespacerange",
)),
);
let p = pdfcraft_model::pages(doc)[0].obj;
doc.update_dict(p, |d| {
if let Some(Object::Dict(res)) = d.get_mut(b"Resources")
&& let Some(Object::Dict(fonts)) = res.get_mut(b"Font")
&& let Some(Object::Dict(font)) = fonts.get_mut(b"V1")
{
font.set(b"Encoding".to_vec(), Object::Ref(cmap));
}
})
.unwrap();
}
#[test]
fn text_whose_cmap_writing_mode_cannot_be_read_is_not_edited() {
let mut doc = vertical_columns_page();
make_cmap_cyclic(&mut doc);
let error = text::replace_line(&mut doc, 0, 0, "ABCDEA").unwrap_err();
assert!(error.to_string().contains("cyclic"), "{error}");
}
#[test]
fn a_cyclic_cmap_falls_back_to_horizontal_and_only_refuses_its_own_lines() {
let mut doc = vertical_page("BT /V1 20 Tf 100 700 Td <00410042> Tj ET\nBT /F1 12 Tf 100 600 Td (Plain) Tj ET");
make_cmap_cyclic(&mut doc);
// The page is still listed: the broken font's text as horizontal, the rest untouched.
let lines = text::text_lines(&doc, 0).unwrap();
let texts: Vec<&str> = lines.iter().map(|l| l.text.as_str()).collect();
assert_eq!(texts, ["AB", "Plain"], "{texts:?}");
assert!(lines[0].rect[2] - lines[0].rect[0] > 30.0 && lines[0].rect[3] - lines[0].rect[1] < 30.0, "{:?}", lines[0].rect);
assert_eq!(text::reading_blocks(&doc, 0).unwrap().len(), 2);
assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2);
// Only the line in the broken font is refused.
let error = text::replace_line(&mut doc.clone(), 0, 0, "BA").unwrap_err();
assert!(error.to_string().contains("cyclic"), "{error}");
let block = text::text_blocks(&doc, 0).unwrap().iter().position(|b| b.text == "AB").unwrap();
let error = text::replace_block(&mut doc.clone(), 0, block, "BA").unwrap_err();
assert!(error.to_string().contains("cyclic"), "{error}");
text::replace_line(&mut doc, 0, 1, "Changed").unwrap();
let texts: Vec<String> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
assert_eq!(texts, ["AB", "Changed"]);
}
#[test]
fn vertical_text_drawn_one_glyph_per_tj_is_one_line() {
// One text object, a `Tj` per glyph; and a column with a text object per glyph.
let doc = vertical_page(
"BT /V1 20 Tf 500 720 Td <0041> Tj <0042> Tj <0043> Tj ET\n\
BT /V1 20 Tf 1 0 0 1 470 720 Tm <0041> Tj ET\nBT /V1 20 Tf 1 0 0 1 470 700 Tm <0042> Tj ET\nBT /V1 20 Tf 1 0 0 1 470 680 Tm <0043> Tj ET",
);
let lines = text::text_lines(&doc, 0).unwrap();
let columns: Vec<(f64, &str)> = lines.iter().map(|l| (l.baseline()[0], l.text.as_str())).collect();
assert_eq!(columns, [(500.0, "ABC"), (470.0, "ABC")], "{columns:?}");
for line in &lines {
let r = line.rect;
assert!((r[1] - 660.0).abs() < 0.5 && (r[3] - 720.0).abs() < 0.5, "{r:?}");
}
assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2);
}
/// Two genuinely stacked rows of ordinary body text must not join into one row just because the
/// first is narrow enough to put the second's start within the #3252 tolerance band: here the
/// second row starts 1.4pt to the left of where the first ends (so the two overlap a little,
/// as compressed text or a tight column can) and 5pt below its baseline — inside the widened 50%
/// tolerance but outside the original 20% one. They stay two rows (and two paragraphs).
#[test]
fn stacked_rows_that_merely_overlap_do_not_join_into_one_row() {
let doc = text_page("BT /F1 12 Tf 72 700 Td (A compressed line of body text here) Tj 190 -5 Td (Second stacked line of body text) Tj ET");
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 2);
let gap = lines[1].rect[0] - lines[0].rect[2];
assert!((-2.4..0.0).contains(&gap), "the rows overlap a little, within the old tolerance band: {gap}");
let baseline_shift = lines[0].rect[3] - lines[1].rect[3];
assert!((2.4..6.0).contains(&baseline_shift), "between the old and new vertical tolerance: {baseline_shift}");
let blocks = text::text_blocks(&doc, 0).unwrap();
assert_eq!(blocks.len(), 2, "two separate rows stay two paragraphs, not one: {blocks:?}");
}
/// Two equal-size rows with no horizontal overlap must not join into one row either (#3252):
/// the wider tolerance is for a run distinctly smaller or larger than its neighbour (a
/// super/subscript), not for two ordinary body-text rows that happen to sit close enough
/// vertically — here a 5pt baseline shift, inside the widened 50% tolerance but outside the
/// original 20% one, with the second row starting just past where the first ends.
#[test]
fn equal_size_rows_need_the_tighter_tolerance_even_without_overlap() {
let doc = text_page("BT /F1 12 Tf 72 700 Td (Short line one) Tj 75 -5 Td (Second line two here) Tj ET");
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].size, lines[1].size, "same size: no super/subscript here");
let gap = lines[1].rect[0] - lines[0].rect[2];
assert!(gap >= 0.0, "no horizontal overlap: {gap}");
let baseline_shift = lines[0].rect[3] - lines[1].rect[3];
assert!((2.4..6.0).contains(&baseline_shift), "between the old and new vertical tolerance: {baseline_shift}");
let blocks = text::text_blocks(&doc, 0).unwrap();
assert_eq!(blocks.len(), 2, "two separate rows stay two paragraphs, not one: {blocks:?}");
}
/// A `TJ` with a surplus array operand (`[(FIRSTARR)] [(LASTARR)] TJ`) is malformed, but the
/// operator still takes the one immediately before it, as the renderer, MuPDF and pdftotext do
/// (#3569) -- not the first, which this reader took before, showing a word the page never draws.
#[test]
fn a_tj_with_a_surplus_array_reads_the_last_one() {
let doc = text_page("BT /F1 24 Tf 20 100 Td [(FIRSTARR)] [(LASTARR)] TJ ET");
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text, "LASTARR", "the operand immediately before TJ, as the page draws: {lines:?}");
}
/// A producer wrote `0.0001 Tc` on one word of a line (Word through macOS Quartz). That spacing
/// can't be seen, so the word stays on its line; spacing that can be seen still keeps runs apart.
#[test]
fn a_word_with_invisible_extra_spacing_stays_on_its_line() {
let page = |spacing: &str| {
text_page(&format!(
"BT /F1 12 Tf 1 0 0 1 72 700 Tm (for company ) Tj ET \
BT /F1 12 Tf 1 0 0 1 141.36 700 Tm (A) Tj ET \
BT {spacing} Tc /F1 12 Tf 1 0 0 1 149.364 700 Tm (EROSOL) Tj ET"
))
};
let blocks = text::text_blocks(&page("0.0001"), 0).unwrap();
assert_eq!(blocks.iter().map(|b| b.text.as_str()).collect::<Vec<_>>(), ["for company AEROSOL"]);
let mut doc = page("0.0001");
text::rewrite_block(&mut doc, 0, 0, Some("for company AEROSOL LLC"), &BlockStyle::default()).unwrap();
let lines = text::text_lines(&reopen(&doc), 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["for company AEROSOL LLC"]);
assert!((lines[0].rect[0] - 72.0).abs() < 0.01, "{:?}", lines[0].rect);
let blocks = text::text_blocks(&page("0.5"), 0).unwrap();
assert_eq!(blocks.iter().map(|b| b.text.as_str()).collect::<Vec<_>>(), ["for company A", "EROSOL"]);
}
/// #3573: past the 256-level marked-content cap every `EMC` must still close its own sequence,
/// so `/ActualText` nested that deep is neither dropped nor applied to too little text.
#[test]
fn actual_text_pairs_past_the_marked_content_cap() {
let span = "/Span <</ActualText (REPLACED)>> BDC BT /F1 24 Tf 72 700 Td (abc) Tj ET EMC";
let outer = "/Span <</ActualText (OUTER)>> BDC BT /F1 24 Tf 72 700 Td /Y BMC (abc) Tj EMC (def) Tj ET EMC";
for (content, expected) in [
(span.to_string(), "REPLACED"),
(format!("{}{span}{}", "/X BMC ".repeat(256), " EMC".repeat(256)), "REPLACED"),
(format!("{}{span}{}", "/X BMC ".repeat(1000), " EMC".repeat(1000)), "REPLACED"),
(outer.to_string(), "OUTER"),
(format!("{}{outer}{}", "/X BMC ".repeat(255), " EMC".repeat(255)), "OUTER"),
] {
let doc = text_page(&content);
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), [expected]);
let reading: Vec<String> = text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
assert_eq!(reading, [expected]);
}
}
/// #2992: the colour a page is painted with before anything else is its background, for exports
/// that keep a page colour. Edit a PDF ▸ Background's fill is found under the text it goes
/// behind; a page that paints anything else first, a fill that doesn't cover the page, a
/// translucent or clipped one, or a colour that isn't plain gray, RGB or CMYK has none.
#[test]
fn a_page_painted_first_with_a_full_page_fill_has_that_background() {
let mut doc = fixture();
assert_eq!(page_background(&doc, 0).unwrap(), None, "text first");
add_background(&mut doc, &[0, 1], &Background { color: [0.0, 0.5, 0.5], opacity: 1.0, ..Background::default() }, false).unwrap();
let doc = reopen(&doc);
let teal = page_background(&doc, 0).unwrap().expect("Background's fill");
assert!(teal.iter().zip([0.0, 0.5, 0.5]).all(|(a, b)| (a - b).abs() < 1e-6), "{teal:?}");
assert!(page_background(&doc, 1).unwrap().is_some(), "on a turned page too");
assert_eq!(page_background(&doc, 2).unwrap(), None, "a page with no content");
assert!(page_background(&doc, 9).is_err());
let page = |content: &str, resources: &str| {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /CropBox [0 0 500 700] /Contents 4 0 R /Resources << {resources} >> >>"),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(b"xref\n0 5\n0000000000 65535 f \n");
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n").as_bytes());
page_background(&Document::open(Arc::new(out)).unwrap(), 0).unwrap()
};
let gs = "/ExtGState << /Half << /ca 0.5 >> /Mul << /BM /Multiply >> /Full << /ca 1 /SMask /None >> >>";
assert_eq!(page("1 g 0 0 600 800 re f", ""), Some([1.0; 3]), "a producer's white page fill");
assert_eq!(page("0 0 1 0 k 0 0 500 700 re f", ""), Some([1.0, 1.0, 0.0]), "CMYK, covering the crop box");
assert_eq!(page("/DeviceRGB cs 1 0 0 sc 0 0 500 700 re f", ""), Some([1.0, 0.0, 0.0]), "cs and sc");
assert_eq!(page("/Full gs q 2 0 0 2 0 0 cm 0 1 0 rg 0 0 300 400 re f Q", gs), Some([0.0, 1.0, 0.0]), "under a cm");
assert_eq!(page("1 0 0 rg 0 0 300 800 re f", ""), None, "half the page");
assert_eq!(page("1 0 0 rg 0 0 300 800 re 300 0 300 800 re f", ""), None, "two halves are not one covering rectangle");
assert_eq!(page("1 0 0 rg 0 0 300 800 re 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "one of the rectangles covers it");
// #2994 review: a second rectangle that cuts a hole: any one under even-odd, one wound the
// other way under nonzero.
assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f*", ""), None, "even-odd hole");
assert_eq!(page("1 0 0 rg 0 0 600 800 re 300 100 -200 200 re f", ""), None, "nonzero, reversed inner rectangle");
assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f", ""), Some([1.0, 0.0, 0.0]), "nonzero, same winding: no hole");
assert_eq!(page("1 0 0 rg q -1 0 0 1 600 0 cm 0 0 600 800 re Q 100 100 200 200 re f", ""), None, "reversed by a mirroring cm");
assert_eq!(page("/Half gs 1 0 0 rg 0 0 600 800 re f", gs), None, "translucent");
assert_eq!(page("/Mul gs 1 0 0 rg 0 0 600 800 re f", gs), None, "blended");
assert_eq!(page("0 0 100 100 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "clipped to a corner");
assert_eq!(page("0 0 600 800 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "a page-sized clip");
assert_eq!(page("/Pattern cs /P1 scn 0 0 600 800 re f", ""), None, "a pattern");
assert_eq!(page("BT ET 0 0 600 800 re S 1 0 0 rg 0 0 600 800 re f", ""), None, "a stroke first");
assert_eq!(page("0 0 m 600 0 l 600 800 l 0 800 l h f", ""), None, "a path that isn't a rectangle");
assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 m 200 100 l 200 200 l h f*", ""), None, "a page fill with a hole cut in it");
// #2994 review: a rectangle turned 45° about the crop box's centre covers it only if the crop
// box's corners are inside it; its bounding box covering the crop box isn't enough. This one
// reaches 500 from the centre along each axis (its box covers the 500 × 700 crop box) but
// 354 along the diagonals, short of the corners. One reaching 707 covers them.
let turned = "0.70710678 0.70710678 -0.70710678 0.70710678 250 350 cm";
assert_eq!(page(&format!("1 0 0 rg {turned} -353.553 -353.553 707.107 707.107 re f"), ""), None, "a turned fill short of the corners");
assert_eq!(page(&format!("1 0 0 rg {turned} -500 -500 1000 1000 re f"), ""), Some([1.0, 0.0, 0.0]), "a turned fill over them");
// The same for a clip.
assert_eq!(page(&format!("{turned} -353.553 -353.553 707.107 707.107 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""), None, "a turned clip");
assert_eq!(
page(&format!("{turned} -500 -500 1000 1000 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""),
Some([1.0, 0.0, 0.0]),
"a turned clip over the corners"
);
// Clips add up: two that each cover the page leave it covered, one that doesn't among them
// doesn't.
assert_eq!(page("0 0 600 800 re W n 0 0 550 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "two covering clips");
assert_eq!(page("0 0 600 800 re W n 0 0 300 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "the second clip doesn't cover");
// Past 64 clipping rectangles the clip counts as unknown.
let clips = "0 0 600 800 re W n ".repeat(65);
assert_eq!(page(&format!("{clips}1 0 0 rg 0 0 600 800 re f"), ""), None, "too many clips to follow");
// A rectangle drawn the other way round (negative width) covers the page all the same.
assert_eq!(page("1 0 0 rg 600 0 -600 800 re f", ""), Some([1.0, 0.0, 0.0]), "drawn clockwise");
// The crop box counts as covered within half a percent of its size (2.5 × 3.5 here), the
// edge of that included.
assert_eq!(page("1 0 0 rg 2 3 496 694 re f", ""), Some([1.0, 0.0, 0.0]), "within the tolerance");
assert_eq!(page("1 0 0 rg 2.5 3.5 495 693 re f", ""), Some([1.0, 0.0, 0.0]), "on the tolerance's edge");
assert_eq!(page("1 0 0 rg 3 4 494 692 re f", ""), None, "past the tolerance");
}
#[test]
fn reading_keeps_dotted_leader_entries_whole_and_in_order() {
let mut content = String::new();
for (title, page, y) in [("Art.1. Scope", "3", 700), ("Art.2. Eligibility", "7", 680), ("Art.3. Application", "12", 660)] {
content.push_str(&format!(
"BT /F1 12 Tf 72 {y} Td ({title}) Tj ET \
BT /F1 12 Tf 250 {y} Td (....................) Tj ET \
BT /F1 12 Tf 500 {y} Td ({page}) Tj ET\n"
));
}
content.push_str("BT /F1 12 Tf 72 640 Td (Appendix) Tj ET BT /F1 12 Tf 250 640 Td (\\205\\205\\205) Tj ET BT /F1 12 Tf 500 640 Td (iv) Tj ET\n");
content.push_str("BT /F1 12 Tf 72 600 Td (Ordinary body text.) Tj ET BT /F1 12 Tf 72 582 Td (Still the same paragraph.) Tj ET");
let doc = text_page(&content);
let blocks: Vec<_> = text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
assert_eq!(
blocks,
[
"Art.1. Scope .................... 3",
"Art.2. Eligibility .................... 7",
"Art.3. Application .................... 12",
"Appendix ……… iv",
"Ordinary body text. Still the same paragraph.",
]
);
}
/// Text set larger than the widest page PDF allows (14 400 pt) panicked when its paragraph was given
/// a width: the width is clamped between the font size and 14 400 pt, and `f64::clamp` panics when
/// its minimum is the larger. The width is now capped at 14 400 pt whatever the font size: from the
/// file (`Tf`, or a small `Tf` scaled up by `Tm`) or asked for (the automation tools pass it through).
#[test]
fn width_on_a_paragraph_set_larger_than_a_page_is_capped_not_a_panic() {
let wide = text::BlockStyle { width: Some(200.0), ..Default::default() };
let huge = text::BlockStyle { size: Some(20_000.0), width: Some(200.0), ..Default::default() };
for (content, style) in [
("BT /F2 20000 Tf 72 700 Td (ab) Tj ET", &wide),
("BT /F2 1 Tf 20000 0 0 20000 72 700 Tm (ab) Tj ET", &wide),
("BT /F2 12 Tf 72 700 Td (ab) Tj ET", &huge),
] {
let mut doc = text_page(content);
text::rewrite_block(&mut doc, 0, 0, None, style).unwrap();
}
}
#[test]
fn added_text_is_wrapped_once_per_write() {
let mut doc = fixture();
let lines: Vec<String> = (0..40).map(|i| format!("line {i}")).collect();
let text = AddedText { rect: [72.0, 100.0, 300.0, 700.0], text: lines.join("\n"), size: 10.0, align: Align::Justify, ..AddedText::default() };
added::WRAP_CALLS.with(|c| c.set(0));
add_content(&mut doc, 0, &Content::Text(text)).unwrap();
let calls = added::WRAP_CALLS.with(std::cell::Cell::get);
assert!(calls <= 4, "the text was wrapped {calls} times for 40 lines");
}
/// A field tree whose every node lists its child twice is pruned in work linear in its nodes, not
/// in its paths, and a node listing itself is visited once.
#[test]
fn flattening_prunes_a_field_tree_with_repeated_kids_once_per_node() {
let levels = 16;
let page = stream("", " ");
// 5: the flattened widget; 6…: the chain; then the hidden widget at its end, on the page.
let leaf = 6 + levels;
let fields = format!("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 6 0 R {} 0 R] >> >>", leaf + 2);
let annots = format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R {leaf} 0 R] >>");
let mut objs: Vec<String> = vec![
fields,
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
annots,
page,
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (shown) /Rect [10 10 150 40] /F 4 /AP << /N {} 0 R >> >>", leaf + 1),
];
for i in 0..levels {
let next = 7 + i;
objs.push(format!("<< /T (n{i}) /Kids [{next} 0 R {next} 0 R] >>"));
}
objs.push("<< /Type /Annot /Subtype /Widget /FT /Tx /T (hidden) /Rect [10 90 150 120] /F 2 >>".into());
objs.push("<< /Type /XObject /Subtype /Form /BBox [0 0 140 30] /Length 0 >>\nstream\n\nendstream".into());
objs.push(format!("<< /T (loop) /Kids [{} 0 R] >>", leaf + 2));
let objs: Vec<&str> = objs.iter().map(String::as_str).collect();
let mut doc = build(&objs);
flatten::KEEP_CALLS.with(|c| c.set(0));
assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1);
let calls = flatten::KEEP_CALLS.with(std::cell::Cell::get);
assert!(calls <= 2 * (levels + 4), "{calls} field-tree visits");
let root = doc.root().unwrap();
let af = doc.resolve(doc.get(root).as_dict().unwrap().get(b"AcroForm").unwrap());
let left = doc.resolve(af.as_dict().unwrap().get(b"Fields").unwrap()).as_array().unwrap().len();
assert_eq!(left, 2, "the chain to the hidden widget and the self-listing node stay");
}
/// A field reached first at the end of a long chain has its kids cut off by the depth limit, so
/// they survive there. A shorter path to the same field still looks at its kids: an off-page widget
/// under it is taken out when every field is flattened.
#[test]
fn a_field_reached_again_by_a_shorter_path_is_pruned_there() {
let chain = 64;
let page = stream("", " ");
// 7…: the chain; then the shared field, its off-page widget, and the short path to it.
let shared = 7 + chain;
let fields = format!("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 7 0 R {} 0 R] >> >>", shared + 2);
let mut objs: Vec<String> = vec![
fields,
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R] >>".into(),
page,
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (shown) /Rect [10 10 150 40] /F 4 /AP << /N 6 0 R >> >>".into(),
"<< /Type /XObject /Subtype /Form /BBox [0 0 140 30] /Length 0 >>\nstream\n\nendstream".into(),
];
for i in 0..chain {
objs.push(format!("<< /T (c{i}) /Kids [{} 0 R] >>", 8 + i));
}
objs.push(format!("<< /T (shared) /Kids [{} 0 R] >>", shared + 1));
objs.push("<< /Type /Annot /Subtype /Widget /FT /Tx /T (orphan) /Rect [10 50 150 80] /F 4 >>".into());
objs.push(format!("<< /T (short) /Kids [{shared} 0 R] >>"));
let objs: Vec<&str> = objs.iter().map(String::as_str).collect();
let mut doc = build(&objs);
assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1);
let shared_ref = pdfcraft_cos::ObjRef::new(u32::try_from(shared).unwrap(), 0);
let kids = doc.resolve(doc.get(shared_ref).as_dict().unwrap().get(b"Kids").unwrap()).as_array().unwrap().len();
assert_eq!(kids, 0, "the off-page widget is pruned along the short path");
}
/// A ToUnicode destination is repeated for every glyph that shows its code: a page whose text
/// would run past the extraction budget is refused, not built in memory.
#[test]
fn page_text_past_the_budget_is_refused() {
let cmap = stream("", &format!("1 beginbfchar <41> <{}> endbfchar", "0041".repeat(256)));
let page = |shown: &str| {
let content = stream("", &format!("BT /F1 12 Tf 10 10 Td ({shown}) Tj ET"));
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 << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
&content,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /ToUnicode 6 0 R >>",
&cmap,
])
};
let doc = page(&"A".repeat(100_000));
assert!(matches!(text_lines(&doc, 0), Err(EditError::Invalid(_))), "25 MB of text from a 100 kB page");
let doc = page("AA");
assert_eq!(text_lines(&doc, 0).unwrap()[0].text.len(), 512, "a page within the budget still reads");
}
/// A one-page document from the page's /Contents and /Resources, built in code.
fn page_with(build: impl FnOnce(&mut Document) -> (Object, Dict)) -> Document {
let mut doc = Document::new_empty();
let pages = doc.add(Object::Null);
let (contents, resources) = build(&mut doc);
let page: Dict = [
(b"Type".to_vec(), Object::Name(b"Page".to_vec())),
(b"Parent".to_vec(), Object::Ref(pages)),
(b"MediaBox".to_vec(), Object::Array([0, 0, 200, 300].map(Object::from).to_vec())),
(b"Contents".to_vec(), contents),
(b"Resources".to_vec(), Object::Dict(resources)),
]
.into_iter()
.collect();
let page = doc.add(page);
let tree: Dict = [
(b"Type".to_vec(), Object::Name(b"Pages".to_vec())),
(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])),
(b"Count".to_vec(), Object::Int(1)),
]
.into_iter()
.collect();
doc.set(pages, Object::Dict(tree));
let catalog: Dict = [(b"Type".to_vec(), Object::Name(b"Catalog".to_vec())), (b"Pages".to_vec(), Object::Ref(pages))].into_iter().collect();
let catalog = doc.add(catalog);
doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog));
doc
}
#[test]
fn repeated_content_references_share_one_decode_budget() {
// One 1 MiB stream (a kilobyte on disk) named 300 times by the page's /Contents.
let doc = page_with(|doc| {
let spaces = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]));
(Object::Array(vec![Object::Ref(spaces); 300]), Dict::new())
});
match text_lines(&doc, 0) {
Ok(lines) => panic!("read {} lines from more content than a page may decode", lines.len()),
Err(e) => assert!(matches!(e, EditError::Invalid(_)), "{e}"),
}
}
#[test]
fn reading_images_stops_at_the_page_record_limit() {
// 20 draws of a form drawing a form 20 times that draws an image 20 times: 8,000 images
// per level-two form, 160,000 in all, from a few hundred bytes.
let doc = page_with(|doc| {
let image: Dict = [
(b"Subtype".to_vec(), Object::Name(b"Image".to_vec())),
(b"Width".to_vec(), Object::Int(1)),
(b"Height".to_vec(), Object::Int(1)),
(b"ColorSpace".to_vec(), Object::Name(b"DeviceGray".to_vec())),
(b"BitsPerComponent".to_vec(), Object::Int(8)),
]
.into_iter()
.collect();
let mut inner = doc.add(Stream::from_raw(image, vec![0]));
let draws = "/X Do ".repeat(20);
let resources = |inner: pdfcraft_cos::ObjRef| -> Dict {
let xobjects: Dict = [(b"X".to_vec(), Object::Ref(inner))].into_iter().collect();
[(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect()
};
for _ in 0..3 {
let mut d = resources(inner);
d = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec())), (b"Resources".to_vec(), Object::Dict(d))].into_iter().collect();
inner = doc.add(Stream::from_raw(d, draws.clone().into_bytes()));
}
let page = doc.add(Stream::from_raw(Dict::new(), draws.into_bytes()));
(Object::Ref(page), resources(inner))
});
if let Ok(images) = crate::reading_images(&doc, 0) {
assert!(images.len() <= crate::images::MAX_PAGE_IMAGES, "{} images", images.len());
}
}
#[test]
fn reading_spends_one_decode_budget_on_content_and_forms() {
// 130 MiB of content and a 130 MiB form (a kilobyte each on disk): each fits a page's budget,
// together they don't, so reading leaves the form out and says so.
let doc = page_with(|doc| {
let mut body = b"%".to_vec();
body.resize(130 << 20, b'x');
let form: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect();
let form = doc.add(Stream::flate(form, &body));
let mut content = b"/X Do\n%".to_vec();
content.resize(130 << 20, b'x');
let content = doc.add(Stream::flate(Dict::new(), &content));
let xobjects: Dict = [(b"X".to_vec(), Object::Ref(form))].into_iter().collect();
(Object::Ref(content), [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect())
});
assert!(crate::text::reading_blocks_checked(&doc, 0).unwrap().truncated);
}
/// A page that can't take flattened content on top (marked content nested past the depth budget)
/// refuses the whole flatten: the pages before it keep their comments and content.
#[test]
fn a_flatten_refused_on_a_later_page_changes_nothing() {
let deep = stream("", &"/Artifact BMC\n".repeat(10_001));
let mut doc = build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R] >>",
&stream("", " "),
"<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] /F 4 /AP << /N 9 0 R >> >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 7 0 R /Annots [8 0 R] >>",
&deep,
"<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] /F 4 /AP << /N 9 0 R >> >>",
"<< /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 0 >>\nstream\n\nendstream",
]);
let first = pdfcraft_cos::ObjRef::new(3, 0);
let before = (*doc.get(first)).clone();
assert!(matches!(flatten(&mut doc, &[0, 1], true, false), Err(EditError::Invalid(_))));
assert_eq!((*doc.get(first)).clone(), before, "the first page keeps its comment and content");
}