Files
pdfcraft/crates/edit/src/tests.rs
T
2d24de1c16 M7: edit text: Cyrillic/Greek fallback letters are spaced proportionally (#528)
Replacement text that the line's own (subset) font can't show and WinAnsi can't either goes
to the Type 3 fallback. For serif sources that was always Shippori Mincho, which has no
Cyrillic or Greek, so e.g. retyping a Bulgarian word in a Garamond line failed with
"Japanese fallback font has no glyph for U+0421". BIZ UDMincho and BIZ UDPGothic have the
letters. document_font_for_text keeps the style's face when it shows every character and
otherwise picks the closest Japanese face that does (Mincho first for serif, then weight).

Those faces draw Cyrillic and Greek full-width (1 em, JIS X 0208) with no palt metrics, so
fallback glyphs in those blocks are respaced to their ink width plus a 0.05 em side bearing.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 13:06:33 -04:00

1197 lines
62 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::sync::Arc;
use pdfcraft_cos::{Document, SaveOptions, write_incremental};
use super::*;
/// Three pages: an upright page with content that leaves the graphics state changed (an
/// unbalanced `cm`), a page rotated 90° and one with inherited, shared resources.
fn fixture() -> Document {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 600 800] /Resources 6 0 R >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 7 0 R >>".into(),
"<< /Type /Page /Parent 2 0 R /Rotate 90 /Contents [7 0 R] >>".into(),
"<< /Type /Page /Parent 2 0 R >>".into(),
"<< /Font << /F1 8 0 R >> >>".into(),
"<< /Length 37 >>\nstream\n2 0 0 2 0 0 cm BT /F1 9 Tf (Hi) Tj ET\nendstream".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>".into(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
fn reopen(doc: &Document) -> Document {
let bytes = write_incremental(doc, &SaveOptions::default()).unwrap();
hayro_syntax::Pdf::new(bytes.clone()).expect("parses");
Document::open(Arc::new(bytes)).unwrap()
}
/// The decoded content streams of a page, in order.
fn streams(doc: &Document, page: usize) -> Vec<String> {
let p = &pdfcraft_model::pages(doc)[page];
let c = p.dict.get(b"Contents").cloned();
let list = match c.map(|c| doc.resolve(&c)).as_deref() {
Some(Object::Array(a)) => a.clone(),
Some(_) => vec![p.dict.get(b"Contents").cloned().unwrap()],
None => vec![],
};
list.iter().map(|o| String::from_utf8_lossy(&stream_bytes(doc, o).unwrap()).into_owned()).collect()
}
fn cx() -> Context {
Context { date: (2026, 10, 1) }
}
#[test]
fn tokens_expand() {
let c = cx();
assert_eq!(expand("Page <<1>> of <<n>>", 3, 10, 0, &c), "Page 3 of 10");
assert_eq!(expand("<<1 of n>> · <<Page 1>> · <<1/n>>", 2, 5, 0, &c), "2 of 5 · Page 2 · 2/5");
assert_eq!(expand("<<m/d/yyyy>> <<yyyy-mm-dd>> <<mmmm d, yyyy>>", 1, 1, 0, &c), "10/1/2026 2026-10-01 October 1, 2026");
assert_eq!(expand("<<Bates Number#6#100#ABC#-X>>", 1, 1, 104, &c), "ABC000104-X");
assert_eq!(expand("keep <<unknown>> and <<unclosed", 1, 1, 0, &c), "keep <<unknown>> and <<unclosed");
assert_eq!(bates_start("x <<Bates Number#6#100#A#B>>"), Some(100));
}
#[test]
fn header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content() {
let mut doc = fixture();
let hf = HeaderFooter {
text: ["Left".into(), String::new(), "<<Page 1 of n>>".into(), String::new(), "Confidential (draft)".into(), String::new()],
..HeaderFooter::default()
};
add_header_footer(&mut doc, &[0, 1, 2], &hf, false, &cx()).unwrap();
let doc = reopen(&doc);
let s0 = streams(&doc, 0);
// q-wrapper, original, Q-wrapper, header/footer.
assert_eq!(s0.len(), 4, "{s0:?}");
assert_eq!((s0[0].as_str(), s0[2].as_str()), ("q %PdfCraft\n", "Q %PdfCraft\n"));
let mark = &s0[3];
assert!(mark.contains("/PCMark /HeaderFooter") && mark.contains("(Page 1 of 3) Tj") && mark.contains("(Confidential \\(draft\\)) Tj"), "{mark}");
assert!(mark.contains("1 0 0 1 0 0 cm"), "upright page: identity");
// The rotated page maps display space onto its rotated crop box.
let s1 = streams(&doc, 1);
assert!(s1.last().unwrap().contains("0 1 -1 0 600 0 cm") && s1.last().unwrap().contains("(Page 2 of 3)"), "{s1:?}");
// A page without content gets just the mark; its inherited resources are copied, not changed.
assert_eq!(streams(&doc, 2).len(), 1);
let p2 = &pdfcraft_model::pages(&doc)[2];
let res = doc.resolve(p2.dict.get(b"Resources").unwrap());
let fonts = doc.resolve(res.as_dict().unwrap().get(b"Font").unwrap());
assert!(fonts.as_dict().unwrap().contains(b"PCHelv") && fonts.as_dict().unwrap().contains(b"F1"));
let shared = doc.get(pdfcraft_cos::ObjRef::new(6, 0));
let shared_fonts = doc.resolve(shared.as_dict().unwrap().get(b"Font").unwrap());
assert!(!shared_fonts.as_dict().unwrap().contains(b"PCHelv"), "the shared dictionary is untouched");
assert_eq!(marks_present(&doc), [MarkKind::HeaderFooter]);
}
#[test]
fn replace_and_remove_restore_the_original_content() {
let mut doc = fixture();
let original = streams(&doc, 0);
let mut hf = HeaderFooter::default();
hf.text[1] = "First".into();
add_header_footer(&mut doc, &[0], &hf, false, &cx()).unwrap();
hf.text[1] = "Second".into();
add_header_footer(&mut doc, &[0], &hf, true, &cx()).unwrap();
let s = streams(&doc, 0);
assert_eq!(s.iter().filter(|x| x.contains("/PCMark")).count(), 1, "replaced, not added");
assert!(s.last().unwrap().contains("(Second)"));
add_watermark(&mut doc, &[0], &Watermark { text: "DRAFT".into(), ..Watermark::default() }, false).unwrap();
add_background(&mut doc, &[0], &Background { color: [1.0, 1.0, 0.9], opacity: 1.0, ..Background::default() }, false).unwrap();
assert_eq!(marks_present(&doc).len(), 3);
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1);
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::Watermark).unwrap(), 1);
let s = streams(&doc, 0);
assert!(s[0].contains("/PCMark /Background"), "the background stays behind: {s:?}");
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(), 1);
assert_eq!(streams(&reopen(&doc), 0), original, "back to exactly the original content");
}
#[test]
fn watermarks_rotate_fade_and_can_go_behind() {
let mut doc = fixture();
let wm = Watermark { text: "CONFIDENTIAL\nDo not copy".into(), opacity: 0.3, rotation: 45.0, behind: true, ..Watermark::default() };
add_watermark(&mut doc, &[0], &wm, false).unwrap();
let s = streams(&doc, 0);
assert!(s[0].contains("/PCMark /Watermark"), "behind: first");
assert!(s[0].contains("/PCGS30 gs") && s[0].contains("0.707 0.707 -0.707 0.707 300 400 cm"), "{}", s[0]);
assert!(s[0].contains("(CONFIDENTIAL) Tj") && s[0].contains("(Do not copy) Tj"));
assert_eq!(s.len(), 2, "no wrapper needed for content behind");
}
#[test]
fn invalid_requests_change_nothing() {
let mut doc = fixture();
assert!(matches!(add_header_footer(&mut doc, &[0], &HeaderFooter::default(), false, &cx()), Err(EditError::Invalid(_))));
assert!(matches!(add_watermark(&mut doc, &[0], &Watermark::default(), false), Err(EditError::Invalid(_))));
assert_eq!(
add_background(&mut doc, &[9], &Background { color: [1.0; 3], opacity: 1.0, ..Background::default() }, false),
Err(EditError::NoSuchPage(9))
);
assert!(!doc.is_modified());
}
#[test]
fn flattening_draws_appearances_into_the_page_and_removes_the_comments() {
use 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");
}
#[test]
fn added_text_and_images_are_page_content_that_stays_editable() {
let mut doc = fixture();
let text = AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: "Approved by Ada\nSecond line".into(), size: 14.0, ..AddedText::default() };
assert_eq!(add_content(&mut doc, 0, &Content::Text(text.clone())).unwrap(), 0);
// An 1×1 gray image, placed on the rotated page.
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("XObject"));
d.set(b"Subtype".to_vec(), Object::name("Image"));
d.set(b"Width".to_vec(), Object::Int(1));
d.set(b"Height".to_vec(), Object::Int(1));
d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
let img = doc.add(Object::Stream(Stream::from_raw(d, vec![128])));
add_content(&mut doc, 1, &Content::Image(AddedImage::new([10.0, 10.0, 110.0, 60.0], img))).unwrap();
let doc2 = reopen(&doc);
let all = list_added(&doc2);
assert_eq!(all.len(), 2);
let Content::Text(t) = &all[0].content else { panic!() };
assert_eq!((t.text.as_str(), t.size), ("Approved by Ada\nSecond line", 14.0));
assert_eq!(t.rect, [72.0, 700.0 - 2.0 * 14.0 * 1.2, 300.0, 700.0], "the box height follows the two lines");
let page0 = streams(&doc2, 0).join("\n");
assert!(page0.contains("(Approved by Ada) Tj") && page0.contains("(Second line) Tj") && page0.contains("/PCFHelvetica 14 Tf"), "{page0}");
// The rotated page draws in display space: the view matrix comes first.
let page1 = streams(&doc2, 1).join("\n");
assert!(page1.contains("q 0 1 -1 0 600 0 cm") && page1.contains(&format!("/PCImg{} Do", img.num)), "{page1}");
// Edit: move, restyle, retype; then delete.
let mut doc = doc2;
let moved = AddedText {
rect: [100.0, 500.0, 300.0, 520.0],
text: "Approved".into(),
bold: true,
family: Family::Times,
align: Align::Right,
color: [1.0, 0.0, 0.0],
..text
};
update_content(&mut doc, 0, 0, &Content::Text(moved)).unwrap();
let page0 = streams(&doc, 0).join("\n");
assert!(page0.contains("/PCFTimesBold 14 Tf 1 0 0 rg") && !page0.contains("Second line"), "{page0}");
assert!(
update_content(&mut doc, 1, 0, &Content::Text(AddedText { text: "x".into(), rect: [0.0, 0.0, 50.0, 10.0], ..AddedText::default() })).is_err(),
"kinds don't change"
);
delete_content(&mut doc, 0, 0).unwrap();
assert_eq!(list_added(&doc).len(), 1);
assert!(!streams(&doc, 0).join("").contains("Approved"));
assert!(add_content(&mut doc, 0, &Content::Text(AddedText { rect: [0.0, 0.0, 100.0, 10.0], ..AddedText::default() })).is_err(), "empty text");
// Page marks ignore added items.
assert!(marks_present(&doc).is_empty());
}
#[test]
fn added_arabic_text_is_shaped_in_display_order_and_stays_searchable() {
let mut doc = fixture();
let text =
AddedText { rect: [72.0, 600.0, 400.0, 700.0], text: "مرحبا World".into(), size: 14.0, align: Align::Right, ..AddedText::default() };
if pdfcraft_fonts::document_arabic_font().is_none() {
eprintln!("skipping the Arabic drawing checks: built without a craft-fonts Arab face (set CRAFT_FONTS_DIR)");
let err = add_content(&mut doc, 0, &Content::Text(text)).unwrap_err();
assert!(matches!(&err, EditError::Invalid(m) if m.contains("CRAFT_FONTS_DIR")), "{err:?}");
assert!(!doc.is_modified(), "nothing is written when the font is missing");
return;
}
add_content(&mut doc, 0, &Content::Text(text.clone())).unwrap();
// A second Arabic item on the same page keeps its own fonts.
add_content(&mut doc, 0, &Content::Text(AddedText { text: "شكرا".into(), rect: [72.0, 500.0, 400.0, 520.0], ..text.clone() })).unwrap();
let doc = reopen(&doc);
let added = list_added(&doc);
let Content::Text(t) = &added[0].content else { panic!() };
assert_eq!(t.text, "مرحبا World", "the logical text is kept for editing");
let s = streams(&doc, 0);
let item = s.iter().find(|s| s.contains("(World) Tj")).unwrap();
// A right-to-left paragraph: "World" is drawn left of the Arabic word.
let x_before = |s: &str, at: usize| -> f64 {
let tm = s[..at].rfind("1 0 0 1 ").unwrap() + 8;
s[tm..].split(' ').next().unwrap().parse().unwrap()
};
let latin_x = x_before(item, item.find("(World) Tj").unwrap());
let arabic_at = item.find("/PCAr").unwrap();
let arabic_x = x_before(item, item[arabic_at..].find(" Tj").unwrap() + arabic_at);
assert!(latin_x < arabic_x, "{item}");
// The Arabic glyphs come from Type 3 fonts whose ToUnicode maps give back each letter.
let p = &pdfcraft_model::pages(&doc)[0];
let res = doc.resolve(p.dict.get(b"Resources").unwrap());
let fonts = doc.resolve(res.as_dict().unwrap().get(b"Font").unwrap()).as_dict().unwrap().clone();
let arabic: Vec<_> = fonts.iter().filter(|(k, _)| k.starts_with(b"PCAr")).map(|(_, v)| doc.resolve(v).as_dict().unwrap().clone()).collect();
assert_eq!(arabic.len(), 2, "one font per item");
let mut mapped = String::new();
for f in &arabic {
assert_eq!(f.name(b"Subtype"), Some(&b"Type3"[..]));
mapped += &String::from_utf8_lossy(&stream_bytes(&doc, f.get(b"ToUnicode").unwrap()).unwrap());
}
for letter in ["0645", "0631", "062D", "0628", "0627", "0634", "0643"] {
assert!(mapped.contains(&format!("<{letter}>")), "{letter} in {mapped}");
}
}
/// The `PCAr` font names in page `page`'s resources.
fn arabic_font_names(doc: &Document, page: usize) -> Vec<String> {
let p = &pdfcraft_model::pages(doc)[page];
let Some(res) = p.dict.get(b"Resources").map(|r| doc.resolve(r)) else { return vec![] };
let Some(fonts) = res.as_dict().and_then(|r| r.get(b"Font")).map(|f| doc.resolve(f)) else { return vec![] };
fonts.as_dict().unwrap().iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).filter(|k| k.starts_with("PCAr")).collect()
}
#[test]
fn arabic_items_keep_their_own_fonts_through_saves_updates_and_deletes() {
let mut doc = fixture();
let text = AddedText { rect: [72.0, 600.0, 400.0, 700.0], text: "مرحبا".into(), size: 14.0, ..AddedText::default() };
if pdfcraft_fonts::document_arabic_font().is_none() {
// An item that already holds Arabic (stored by an older version, drawn as "?") can still
// be moved and retyped without the face.
add_content(&mut doc, 0, &Content::Text(AddedText { text: "x".into(), ..text.clone() })).unwrap();
update_content(&mut doc, 0, 0, &Content::Text(text)).unwrap();
assert!(streams(&doc, 0).join("").contains("(?????) Tj"));
return;
}
// A full save renumbers objects but keeps resource names, so a font named after its object
// number may meet a name already on the page: here, every name it could take.
let mut taken = Dict::new();
for num in 1..2_000 {
taken.set(format!("PCAr{num}").into_bytes(), Object::name("Taken"));
}
let mut res = Dict::new();
res.set(b"Font".to_vec(), Object::Dict(taken));
let page = pdfcraft_model::pages(&doc)[0].obj;
doc.update_dict(page, |d| d.set(b"Resources".to_vec(), Object::Dict(res))).unwrap();
add_content(&mut doc, 0, &Content::Text(text.clone())).unwrap();
add_content(&mut doc, 0, &Content::Text(AddedText { text: "شكرا".into(), rect: [72.0, 500.0, 400.0, 520.0], ..text.clone() })).unwrap();
let names = arabic_font_names(&doc, 0);
assert_eq!(names.len(), 1_999 + 2, "nothing replaced");
let p = &pdfcraft_model::pages(&doc)[0];
let fonts = doc.resolve(p.dict.get(b"Resources").unwrap()).as_dict().unwrap().get(b"Font").cloned().unwrap();
let fonts = doc.resolve(&fonts).as_dict().unwrap().clone();
assert_eq!(fonts.iter().filter(|(_, v)| v.as_name() == Some(&b"Taken"[..])).count(), 1_999);
let first = own(&doc, 0);
assert!(first.len() == 1 && first[0].contains('_'), "a free name, not a taken one: {first:?}");
// Updating an item swaps its fonts; deleting it removes them.
update_content(&mut doc, 0, 0, &Content::Text(AddedText { text: "أهلا".into(), ..text })).unwrap();
let names = arabic_font_names(&doc, 0);
assert!(names.len() == 2_001 && !names.contains(&first[0]), "the replaced item's font is gone");
delete_content(&mut doc, 0, 0).unwrap();
delete_content(&mut doc, 0, 0).unwrap();
assert_eq!(arabic_font_names(&doc, 0).len(), 1_999, "only the page's own names are left");
reopen(&doc);
}
/// The `PCAr` fonts the stream of added item `index` on page 0 shows text with.
fn own(doc: &Document, index: usize) -> Vec<String> {
let s = String::from_utf8_lossy(&stream_bytes(doc, &Object::Ref(list_added(doc)[index].obj)).unwrap()).into_owned();
let mut names: Vec<String> =
s.split_whitespace().filter_map(|w| w.strip_prefix('/')).filter(|w| w.starts_with("PCAr")).map(String::from).collect();
names.dedup();
names
}
#[test]
fn odd_arabic_text_never_panics() {
let mut doc = fixture();
let long = "بسم الله ".repeat(2_000);
for s in
["\u{202E}ب\u{064B}\u{064B} (]", "\u{064B}", "ا\u{200F}\u{2067}b\u{2069}", "ا\n\nب\tc", "ا\u{2029}ب\rc\u{85}د\n", "ﷺ ١٢٣ 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>());
}
// A character the face lacks is an error that names it, not a box or a crash.
if pdfcraft_fonts::document_arabic_font().is_some() && !pdfcraft_fonts::arabic_has('\u{FDFD}') {
let r = add_content(
&mut doc,
0,
&Content::Text(AddedText { rect: [0.0, 800.0, 300.0, 780.0], text: "ب \u{FDFD}".into(), ..AddedText::default() }),
);
assert!(matches!(&r, Err(EditError::Invalid(m)) if m.contains('\u{FDFD}')), "{r:?}");
}
}
/// One page with Helvetica (WinAnsi) and a subset font that has only the glyphs it uses.
fn text_page(content: &str) -> Document {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>".into(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
// Subset: glyphs for a (97) and b (98) only.
"<< /Type /Font /Subtype /TrueType /BaseFont /ABCDEF+Arial /FirstChar 97 /LastChar 99 /Widths [500 520 0] /Encoding /WinAnsiEncoding >>"
.into(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
fn descriptor_text_page() -> Document {
let content = "BT /F1 12 Tf 72 700 Td (ab) Tj ET";
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
"<< /Type /Font /Subtype /TrueType /BaseFont /ABCDEF+NeutralFace /FirstChar 97 /LastChar 98 /Widths [500 500] /FontDescriptor 6 0 R /Encoding /WinAnsiEncoding >>".into(),
"<< /Type /FontDescriptor /FontName /NeutralFace /Flags 262208 /ItalicAngle -12 /FontWeight 700 /Ascent 900 /Descent -250 >>".into(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
/// The Japanese fallback face comes from craft-fonts, an optional build input.
fn without_craft_fonts(test: &str) -> bool {
if pdfcraft_fonts::document_japanese_font().is_some() {
return false;
}
eprintln!("skipping {test}: built without craft-fonts (set CRAFT_FONTS_DIR to run it)");
true
}
#[test]
fn japanese_line_uses_unicode_type3_fallback() {
if without_craft_fonts("japanese_line_uses_unicode_type3_fallback") {
return;
}
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
let replacement = "25362738こんにちは、お元気ですか? 2.3444";
let result = text::replace_line(&mut doc, 0, 0, replacement).unwrap();
assert_eq!(result.substituted.as_deref(), Some("BIZ UDPGothic Type3"));
let bytes = page_content_bytes(&doc, 0);
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("/PCJp"), "{content}");
let reopened = reopen(&doc);
assert_eq!(text::text_lines(&reopened, 0).unwrap()[0].text, replacement);
}
fn styled_text_page(base: &str) -> Document {
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Original) Tj ET");
let r = pdfcraft_cos::ObjRef::new(5, 0);
let mut font = doc.get(r).as_dict().unwrap().clone();
font.set(b"BaseFont".to_vec(), pdfcraft_cos::Object::name(base));
doc.set(r, pdfcraft_cos::Object::Dict(font));
doc
}
fn fallback_paths(doc: &Document) -> Vec<Vec<u8>> {
let p = pdfcraft_model::pages(doc).swap_remove(0);
let res = doc.resolve(p.dict.get(b"Resources").unwrap());
let fonts = doc.resolve(res.as_dict().unwrap().get(b"Font").unwrap());
let font = doc.resolve(fonts.as_dict().unwrap().get(b"PCJp").unwrap());
let procs = doc.resolve(font.as_dict().unwrap().get(b"CharProcs").unwrap());
procs
.as_dict()
.unwrap()
.iter()
.map(|(_, object)| match &*doc.resolve(object) {
pdfcraft_cos::Object::Stream(s) => s.decoded().unwrap(),
other => panic!("expected a glyph stream, got {other:?}"),
})
.collect()
}
#[test]
fn japanese_line_fallback_uses_real_source_family_and_weight() {
if without_craft_fonts("japanese_line_fallback_uses_real_source_family_and_weight") {
return;
}
let mut sans = styled_text_page("Helvetica");
let mut bold = descriptor_text_page();
let mut serif = styled_text_page("ABCDEF+ShipporiMincho-Regular");
let replacement = "見本商会 御中";
text::replace_line(&mut sans, 0, 0, replacement).unwrap();
text::replace_line(&mut bold, 0, 0, replacement).unwrap();
text::replace_line(&mut serif, 0, 0, replacement).unwrap();
assert_ne!(fallback_paths(&sans), fallback_paths(&serif), "sans source must not use Mincho outlines");
if pdfcraft_fonts::CRAFT_FONTS.iter().any(|f| f.family == "BIZ UDPGothic" && f.style == "Bold") {
assert_ne!(fallback_paths(&sans), fallback_paths(&bold), "bold must change the actual glyph paths");
}
for doc in [&sans, &bold, &serif] {
assert_eq!(text::text_lines(&reopen(doc), 0).unwrap()[0].text, replacement);
}
}
#[test]
fn japanese_paragraph_fallback_honors_requested_family_and_weight() {
if without_craft_fonts("japanese_paragraph_fallback_honors_requested_family_and_weight") {
return;
}
let replacement = "見本商会 御中";
let mut regular = styled_text_page("Helvetica");
let mut bold = styled_text_page("Helvetica");
let mut serif = styled_text_page("Helvetica");
text::replace_block(&mut regular, 0, 0, replacement).unwrap();
text::rewrite_block(
&mut bold,
0,
0,
Some(replacement),
&text::BlockStyle { family: Some((added::Family::Helvetica, true, false)), ..Default::default() },
)
.unwrap();
text::rewrite_block(
&mut serif,
0,
0,
Some(replacement),
&text::BlockStyle { family: Some((added::Family::Times, false, false)), ..Default::default() },
)
.unwrap();
assert_ne!(fallback_paths(&regular), fallback_paths(&serif), "requested serif must change actual outlines");
if pdfcraft_fonts::CRAFT_FONTS.iter().any(|f| f.family == "BIZ UDPGothic" && f.style == "Bold") {
assert_ne!(fallback_paths(&regular), fallback_paths(&bold), "requested bold must change actual outlines");
let mut source_bold = styled_text_page("Helvetica-Bold");
text::replace_block(&mut source_bold, 0, 0, replacement).unwrap();
assert_eq!(fallback_paths(&bold), fallback_paths(&source_bold));
}
for doc in [&regular, &bold, &serif] {
assert_eq!(text::text_blocks(&reopen(doc), 0).unwrap()[0].text, replacement);
}
}
#[test]
fn japanese_fallback_reports_the_face_after_save_and_reopen() {
if without_craft_fonts("japanese_fallback_reports_the_face_after_save_and_reopen") {
return;
}
// F2 is (a subset of) Arial: a regular sans face, so the fallback is the regular Gothic.
let face = pdfcraft_fonts::document_japanese_font_for_style(false, false).unwrap();
let expected = format!("{}-{}", face.family, face.style).replace(' ', "");
for paragraph in [false, true] {
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
let replacement = "見本商会 御中";
if paragraph {
text::replace_block(&mut doc, 0, 0, replacement).unwrap();
} else {
text::replace_line(&mut doc, 0, 0, replacement).unwrap();
}
for d in [&doc, &reopen(&doc)] {
let lines = text::text_lines(d, 0).unwrap();
assert_eq!(lines[0].text, replacement);
assert_eq!(lines[0].base_font, expected, "report the real fallback face, not an empty BaseFont");
assert_eq!(text::text_blocks(d, 0).unwrap()[0].base_font, expected);
}
}
}
#[test]
fn cyrillic_in_a_serif_line_falls_back_to_a_face_that_has_it() {
if without_craft_fonts("cyrillic_in_a_serif_line_falls_back_to_a_face_that_has_it") {
return;
}
let replacement = "Привет, world";
let has_all = |f: &pdfcraft_fonts::CraftFont| replacement.chars().all(|ch| pdfcraft_fonts::japanese_glyph_from(f, ch).is_ok());
if !pdfcraft_fonts::CRAFT_FONTS.iter().any(|f| f.covers("Jpan") && has_all(f)) {
eprintln!("skipping: no Japanese fallback face has Cyrillic");
return;
}
for paragraph in [false, true] {
let mut doc = styled_text_page("Times-Roman");
if paragraph {
text::replace_block(&mut doc, 0, 0, replacement).unwrap();
} else {
text::replace_line(&mut doc, 0, 0, replacement).unwrap();
}
assert_eq!(text::text_lines(&reopen(&doc), 0).unwrap()[0].text, replacement);
}
}
#[test]
fn japanese_paragraph_uses_unicode_type3_fallback() {
if without_craft_fonts("japanese_paragraph_uses_unicode_type3_fallback") {
return;
}
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
let replacement = "こんにちは、お元気ですか? 2.3444 日本語の文章";
text::replace_block(&mut doc, 0, 0, replacement).unwrap();
let reopened = reopen(&doc);
assert_eq!(text::text_blocks(&reopened, 0).unwrap()[0].text, replacement);
}
/// Every Type 3 fallback glyph starts with a well-formed `d1`: six operands, the second 0, and a
/// box that encloses every point of the glyph. Acrobat shows a bullet for a glyph whose `d1`
/// has the wrong operand count.
#[test]
fn type3_fallback_glyphs_declare_a_well_formed_d1() {
if without_craft_fonts("type3_fallback_glyphs_declare_a_well_formed_d1") {
return;
}
// Cyrillic is respaced (shifted by its side bearing), and its box must move with it.
let mut glyphs = Vec::new();
for replacement in ["見本商会 御中", "София 2027"] {
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
text::replace_line(&mut doc, 0, 0, replacement).unwrap();
glyphs.extend(fallback_paths(&reopen(&doc)));
}
assert!(!glyphs.is_empty());
for glyph in glyphs {
let text = String::from_utf8(glyph).unwrap();
let mut lines = text.lines();
let d1: Vec<&str> = lines.next().unwrap().split_whitespace().collect();
assert_eq!(d1.len(), 7, "six operands and d1: {d1:?}");
assert_eq!(d1[6], "d1");
let n: Vec<f64> = d1[..6].iter().map(|v| v.parse().unwrap()).collect();
assert_eq!(n[1], 0.0, "wy");
assert!(n[2] <= n[4] && n[3] <= n[5], "box: {n:?}");
for line in lines.filter(|l| l.ends_with(" m") || l.ends_with(" l")) {
let p: Vec<f64> = line.split_whitespace().take(2).map(|v| v.parse().unwrap()).collect();
assert!(p[0] >= n[2] && p[0] <= n[4] && p[1] >= n[3] && p[1] <= n[5], "{line} outside {n:?}");
}
}
}
/// Shippori Mincho, the serif fallback, has no Cyrillic: Bulgarian replacement text in a serif
/// line takes a fallback face that has every letter instead of failing on the first one.
#[test]
fn cyrillic_replacement_in_a_serif_line_uses_a_face_with_the_letters() {
if without_craft_fonts("cyrillic_replacement_in_a_serif_line_uses_a_face_with_the_letters") {
return;
}
let replacement = "София 2027";
for paragraph in [false, true] {
let mut doc = styled_text_page("ABCDEF+Garamond");
if paragraph {
text::replace_block(&mut doc, 0, 0, replacement).unwrap();
} else {
text::replace_line(&mut doc, 0, 0, replacement).unwrap();
}
let lines = text::text_lines(&reopen(&doc), 0).unwrap();
assert_eq!(lines[0].text, replacement);
// Letters are spaced by their shape, not one em each (the faces' full-width Cyrillic).
let widths = fallback_widths(&doc);
assert!(widths.iter().all(|w| *w > 300.0 && *w < 900.0), "{widths:?}");
assert!(widths.windows(2).any(|w| w[0] != w[1]), "proportional: {widths:?}");
}
}
/// The `/Widths` of the page's Type 3 fallback font, for its Cyrillic letters.
fn fallback_widths(doc: &Document) -> Vec<f64> {
let p = pdfcraft_model::pages(doc).swap_remove(0);
let res = doc.resolve(p.dict.get(b"Resources").unwrap());
let fonts = doc.resolve(res.as_dict().unwrap().get(b"Font").unwrap());
let font = doc.resolve(fonts.as_dict().unwrap().get(b"PCJp").unwrap());
let font = font.as_dict().unwrap();
let widths = doc.resolve(font.get(b"Widths").unwrap()).as_array().unwrap().iter().filter_map(|w| w.as_f64()).collect::<Vec<_>>();
// "София 2027": the first five codes are the letters.
widths[..5].to_vec()
}
#[test]
fn japanese_paragraph_keeps_ideographic_spaces() {
if without_craft_fonts("japanese_paragraph_keeps_ideographic_spaces") {
return;
}
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Original paragraph) Tj ET");
let replacement = " 見本商会  御中 ";
let style = text::BlockStyle { width: Some(400.0), ..Default::default() };
text::rewrite_block(&mut doc, 0, 0, Some(replacement), &style).unwrap();
let reopened = reopen(&doc);
let lines = text::text_lines(&reopened, 0).unwrap();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text, replacement);
assert_eq!(text::text_blocks(&reopened, 0).unwrap()[0].text, replacement);
// Restyling the saved paragraph without new text must also preserve its spacing.
let mut doc = reopened;
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { size: Some(14.0), ..style }).unwrap();
assert_eq!(text::text_lines(&reopen(&doc), 0).unwrap()[0].text, replacement);
}
/// Without craft-fonts there is no Japanese face: editing in Japanese is a clear error that
/// leaves the page untouched, never a panic; Latin edits work as before.
#[test]
fn japanese_edit_without_craft_fonts_is_a_clear_error() {
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
let before = page_content_bytes(&doc, 0);
let line = text::replace_line(&mut doc, 0, 0, "日本語の文字");
let block = text::replace_block(&mut doc, 0, 0, "日本語の文字");
if pdfcraft_fonts::document_japanese_font().is_some() {
eprintln!("built with craft-fonts: the Japanese edits succeed (checked by the tests above)");
assert!(line.is_ok() && block.is_ok());
return;
}
for result in [line.map(|_| ()), block.map(|_| ())] {
let Err(EditError::Invalid(msg)) = result else { panic!("expected a clear error, got {result:?}") };
assert!(msg.contains("Japanese fallback font") && msg.contains("CRAFT_FONTS_DIR"), "{msg}");
}
assert_eq!(page_content_bytes(&doc, 0), before);
let latin = text::replace_line(&mut doc, 0, 0, "Hello").unwrap();
assert_eq!(text::text_lines(&reopen(&doc), 0).unwrap()[0].text, "Hello", "{latin:?}");
}
#[test]
fn font_descriptor_style_is_exposed_even_with_a_neutral_name() {
let doc = descriptor_text_page();
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 1);
assert!(lines[0].bold && lines[0].italic, "{:?}", lines[0]);
let blocks = text::text_blocks(&doc, 0).unwrap();
assert!(blocks[0].bold && blocks[0].italic, "{:?}", blocks[0]);
}
#[test]
fn mixed_font_runs_are_not_merged_into_one_source_style() {
let doc = text_page("BT /F1 12 Tf 72 700 Td (Regular) Tj /F2 12 Tf 150 700 Td (Subset) Tj ET");
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 2);
assert_ne!(lines[0].font, lines[1].font);
}
#[test]
fn missing_glyphs_preserve_source_style_in_fallback() {
let mut doc = descriptor_text_page();
let result = text::replace_line(&mut doc, 0, 0, "Styled €").unwrap();
assert_eq!(result.substituted.as_deref(), Some("Helvetica-BoldOblique"));
let bytes = page_content_bytes(&doc, 0);
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("/PCEdHelveticaBoldOblique 12 Tf"), "{content}");
}
#[test]
fn text_lines_are_found_and_replaced_in_place() {
let mut doc =
text_page("BT /F1 12 Tf 72 700 Td (Hello) Tj 40 0 Td [(wor) -20 (ld)] TJ 0 -20 Td (Second line) Tj ET BT /F2 10 Tf 72 600 Td (ab) Tj ET");
let lines = text::text_lines(&doc, 0).unwrap();
let texts: Vec<&str> = lines.iter().map(|l| l.text.as_str()).collect();
assert_eq!(texts, ["Hello world", "Second line", "ab"]);
assert!((lines[0].rect[0] - 72.0).abs() < 0.01 && lines[0].rect[1] < 700.0 && lines[0].rect[3] > 700.0, "{:?}", lines[0].rect);
assert!((lines[0].size - 12.0).abs() < 1e-9 && lines[0].base_font == "Helvetica");
let second = lines[1].rect;
let r = text::replace_line(&mut doc, 0, 0, "Goodbye, café").unwrap();
assert_eq!(r.substituted, None);
let doc = reopen(&doc);
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines[0].text, "Goodbye, café");
assert_eq!(lines[1].text, "Second line");
assert_eq!(lines[1].rect, second);
}
/// A page whose content is one stream in three pieces, split between tokens: a marked-content
/// dictionary closes at the start of the middle piece, and the `TJ` array that ends it is shown
/// by the operator that starts the last one.
fn split_streams_page() -> Document {
let piece = |s: &str| format!("<< /Length {} >>\nstream\n{s}\nendstream", s.len());
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents [4 0 R 5 0 R 6 0 R] /Resources << /Font << /F1 7 0 R >> >> >>".into(),
piece("/P << /MCID 0"),
piece(">> BDC BT /F1 12 Tf 72 700 Td (Target) Tj 0 -20 Td [(After) -20 (wards)]"),
piece("TJ ET EMC"),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
/// The page's pieces joined back into one stream: every operator must still have its operands.
fn assert_split_tokens_kept(doc: &Document, new_text: &str) {
let joined = streams(doc, 0).join("\n");
assert!(joined.contains(new_text) && !joined.contains("Target"), "{joined}");
assert!(joined.contains("[(After) -20 (wards)]"), "the text after the line is gone: {joined}");
for op in pdfcraft_content::parse(joined.as_bytes()).ops {
let want = match op.op.as_slice() {
b"BDC" => 2,
b"TJ" => 1,
_ => continue,
};
assert_eq!(op.operands.len(), want, "{} lost its operands: {joined}", String::from_utf8_lossy(&op.op));
}
}
#[test]
fn editing_text_keeps_the_tokens_a_stream_shares_with_its_neighbours() {
let mut doc = split_streams_page();
assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Target");
text::replace_line(&mut doc, 0, 0, "Edited").unwrap();
assert_split_tokens_kept(&reopen(&doc), "Edited");
// A paragraph rewrite rebuilds the same piece.
let mut doc = split_streams_page();
text::replace_block(&mut doc, 0, 0, "Rewrapped").unwrap();
assert_split_tokens_kept(&reopen(&doc), "Rewrapped");
}
#[test]
fn win_ansi_high_glyphs_use_helvetica_not_the_fallback_font() {
// š ž Š Ž Œ Ÿ ƒ † ‰ (WinAnsi 0x80–0x9F) are standard-font glyphs (#347): no Type3 fallback.
let text = "Šumava žaba œuvre Ÿ ƒ † ‰ €";
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
let r = text::replace_line(&mut doc, 0, 0, text).unwrap();
assert_eq!(r.substituted.as_deref(), Some("Helvetica"));
let lines = text::text_lines(&reopen(&doc), 0).unwrap();
assert_eq!((lines[0].text.as_str(), lines[0].base_font.as_str()), (text, "Helvetica"));
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
let r = text::replace_block(&mut doc, 0, 0, text).unwrap();
assert!(r.substituted.as_deref().is_none_or(|s| !s.contains("Type3")), "{:?}", r.substituted);
let lines = text::text_lines(&reopen(&doc), 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>().join(" "), text);
}
#[test]
fn missing_glyphs_substitute_helvetica_and_impossible_text_is_refused() {
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
// "c" has no glyph in the subset: Helvetica takes over for this line.
let r = text::replace_line(&mut doc, 0, 0, "abc").unwrap();
assert_eq!(r.substituted.as_deref(), Some("Helvetica"));
let doc2 = reopen(&doc);
let lines = text::text_lines(&doc2, 0).unwrap();
assert_eq!((lines[0].text.as_str(), lines[0].base_font.as_str()), ("abc", "Helvetica"));
// Neither the standard font nor the craft-fonts Japanese faces have this emoji.
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
assert!(text::replace_line(&mut doc, 0, 0, "\u{1f4a9}").is_err());
assert!(text::replace_line(&mut doc, 0, 5, "x").is_err(), "no such line");
}
#[test]
fn paragraphs_are_found_and_rewrapped() {
let mut doc = text_page(
"BT 0 0 1 rg /F1 10 Tf 12 TL 72 700 Td (The quick brown fox) Tj T* (jumps over the) Tj T* (lazy dog.) Tj ET \
BT /F1 10 Tf 72 600 Td (Next paragraph) Tj ET",
);
let blocks = text::text_blocks(&doc, 0).unwrap();
let texts: Vec<&str> = blocks.iter().map(|b| b.text.as_str()).collect();
assert_eq!(texts, ["The quick brown fox jumps over the lazy dog.", "Next paragraph"]);
assert_eq!(blocks[0].lines, [0, 1, 2]);
let width = blocks[0].rect[2] - blocks[0].rect[0];
let next = blocks[1].rect;
let long = "PdfCraft rewraps a paragraph to its own width when its text changes, keeping the font, size, colour and line spacing.";
assert_eq!(text::replace_block(&mut doc, 0, 0, long).unwrap().substituted, None);
let doc = reopen(&doc);
let lines = text::text_lines(&doc, 0).unwrap();
let blocks = text::text_blocks(&doc, 0).unwrap();
assert_eq!(blocks[0].text, long);
assert!(blocks[0].lines.len() > 3, "more lines: {}", blocks[0].lines.len());
for i in &blocks[0].lines {
assert!(lines[*i].rect[2] - lines[*i].rect[0] <= width + 1.0, "line {} fits", lines[*i].text);
}
// Same left edge, same spacing (12 pt).
assert!((lines[0].rect[0] - 72.0).abs() < 0.01);
let spacing = lines[blocks[0].lines[0]].origin_baseline() - lines[blocks[0].lines[1]].origin_baseline();
assert!((spacing - 12.0).abs() < 0.01, "{spacing}");
// The next paragraph is untouched, and the new text is blue like the old.
assert_eq!(blocks[1].text, "Next paragraph");
assert_eq!(blocks[1].rect, next);
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert!(content.contains("0 0 1 rg"), "{content}");
// Shorter text: fewer lines.
let mut doc = doc;
text::replace_block(&mut doc, 0, 0, "Short.").unwrap();
let blocks = text::text_blocks(&doc, 0).unwrap();
assert_eq!((blocks[0].text.as_str(), blocks[0].lines.len()), ("Short.", 1));
}
fn page_content_bytes(doc: &Document, page: usize) -> Vec<u8> {
let p = pdfcraft_model::pages(doc).swap_remove(page);
let c = p.dict.get(b"Contents").unwrap();
match &*doc.resolve(c) {
pdfcraft_cos::Object::Stream(s) => s.decoded().unwrap(),
pdfcraft_cos::Object::Array(a) => a
.iter()
.flat_map(|x| match &*doc.resolve(x) {
pdfcraft_cos::Object::Stream(s) => s.decoded().unwrap(),
_ => Vec::new(),
})
.collect(),
_ => Vec::new(),
}
}
/// A page drawing image XObject /Im0 at 100,100 size 200 × 100 (pixels 4 × 2).
fn image_page() -> Document {
let content = "q 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET";
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> >> >>".into(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
"<< /Type /XObject /Subtype /Image /Width 4 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 8 >>\nstream\n\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\nendstream".into(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
fn close(a: [f64; 4], b: [f64; 4]) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6)
}
#[test]
fn page_images_move_turn_replace_and_delete() {
let mut doc = image_page();
let imgs = images::page_images(&doc, 0).unwrap();
assert_eq!(imgs.len(), 1);
assert!(close(imgs[0].rect, [100.0, 100.0, 300.0, 200.0]), "{:?}", imgs[0].rect);
assert_eq!((imgs[0].width, imgs[0].height, imgs[0].name.as_str()), (4, 2, "Im0"));
// Move and resize.
let t = images::rect_to_rect(imgs[0].rect, [50.0, 400.0, 150.0, 450.0]);
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(t)).unwrap();
let doc = reopen(&doc);
let r = images::page_images(&doc, 0).unwrap()[0].rect;
assert!(close(r, [50.0, 400.0, 150.0, 450.0]), "{r:?}");
// A quarter turn about the centre: 100 × 50 becomes 50 × 100 around (100, 425).
let mut doc = doc;
let t = images::turn_about_centre(r, 1, false, false);
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(t)).unwrap();
let r = images::page_images(&doc, 0).unwrap()[0].rect;
assert!(close(r, [75.0, 375.0, 125.0, 475.0]), "{r:?}");
// The text after it is untouched.
assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Caption");
// Replace with another image object, in the same place.
let mut d = pdfcraft_cos::Dict::new();
for (k, v) in [
(&b"Type"[..], pdfcraft_cos::Object::name("XObject")),
(b"Subtype", pdfcraft_cos::Object::name("Image")),
(b"Width", pdfcraft_cos::Object::Int(1)),
(b"Height", pdfcraft_cos::Object::Int(1)),
] {
d.set(k.to_vec(), v);
}
let other = doc.add(pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::from_raw(d, vec![0])));
images::change_image(&mut doc, 0, 0, &images::ImageChange::Replace(other)).unwrap();
let imgs = images::page_images(&doc, 0).unwrap();
assert_eq!((imgs[0].object, imgs[0].width), (Some(other), 1));
assert!(close(imgs[0].rect, r));
// Delete.
images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).unwrap();
assert!(images::page_images(&doc, 0).unwrap().is_empty());
assert!(images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).is_err());
}
#[test]
fn paragraphs_take_new_formatting() {
let mut doc = text_page("BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj ET");
let before = text::text_blocks(&doc, 0).unwrap()[0].clone();
let style = text::BlockStyle {
family: Some((added::Family::Times, true, false)),
size: Some(12.0),
color: Some([1.0, 0.0, 0.0]),
align: Some(added::Align::Center),
..Default::default()
};
text::rewrite_block(&mut doc, 0, 0, None, &style).unwrap();
let doc = reopen(&doc);
let blocks = text::text_blocks(&doc, 0).unwrap();
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(blocks[0].text, before.text, "same words");
assert_eq!((blocks[0].base_font.as_str(), blocks[0].size), ("Times-Bold", 12.0));
// Centred in the paragraph's width.
let mid = (before.rect[0] + before.rect[2]) / 2.0;
for i in &blocks[0].lines {
let r = lines[*i].rect;
assert!(((r[0] + r[2]) / 2.0 - mid).abs() < 2.0, "line {:?} centred on {mid}", lines[*i].text);
}
assert!(String::from_utf8_lossy(&page_content_bytes(&doc, 0)).contains("1 0 0 rg"));
// Right alignment keeps lines flush with the right edge.
let mut doc = text_page("BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj ET");
let right = text::text_blocks(&doc, 0).unwrap()[0].rect[2];
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { align: Some(added::Align::Right), ..Default::default() }).unwrap();
let lines = text::text_lines(&doc, 0).unwrap();
for l in &lines {
assert!((l.rect[2] - right).abs() < 1.0, "{:?} ends at {} not {right}", l.text, l.rect[2]);
}
}
#[test]
fn a_new_paragraph_colour_does_not_spill_into_the_text_after_it() {
let mut doc = text_page("BT /F1 10 Tf 72 700 Td (First paragraph) Tj 0 -40 Td (Second paragraph) Tj ET");
let style = text::BlockStyle { color: Some([1.0, 0.0, 0.0]), ..Default::default() };
text::rewrite_block(&mut doc, 0, 0, None, &style).unwrap();
let doc = reopen(&doc);
// The fill colour in force where the second paragraph is shown (q/Q nest it).
let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops;
let (mut fill, mut stack) = (String::from("0 g"), Vec::new());
for op in &ops {
match op.op.as_slice() {
b"q" => stack.push(fill.clone()),
b"Q" => fill = stack.pop().unwrap_or_default(),
b"g" | b"rg" | b"k" => fill = String::from_utf8_lossy(&pdfcraft_content::serialize_ops(std::slice::from_ref(op))).trim().to_string(),
b"Tj" if op.operands.first().and_then(|o| o.as_string()).is_some_and(|s| s.to_text() == "Second paragraph") => break,
_ => {}
}
}
assert_eq!(fill, "0 g", "{ops:?}");
assert_eq!(text::text_blocks(&doc, 0).unwrap()[1].text, "Second paragraph");
}
#[test]
fn a_rewritten_paragraph_keeps_its_place_in_a_shared_text_object() {
// Three paragraphs in one BT … ET: rewriting the second keeps the order (paragraph numbers
// and reading order) and every paragraph's position.
let src = "BT /F1 10 Tf 72 700 Td (First paragraph) Tj 0 -40 Td (Second paragraph) Tj 0 -40 Td (Third paragraph) Tj ET";
let mut doc = text_page(src);
let before = text::text_blocks(&doc, 0).unwrap();
text::replace_block(&mut doc, 0, 1, "Edited second").unwrap();
let doc = reopen(&doc);
let after = text::text_blocks(&doc, 0).unwrap();
let texts: Vec<&str> = after.iter().map(|b| b.text.as_str()).collect();
assert_eq!(texts, ["First paragraph", "Edited second", "Third paragraph"]);
assert_eq!(after[0].rect, before[0].rect);
assert_eq!(after[2].rect, before[2].rect);
assert!((after[1].rect[0] - before[1].rect[0]).abs() < 0.01 && (after[1].rect[1] - before[1].rect[1]).abs() < 0.01, "{:?}", after[1].rect);
}
#[test]
fn recolouring_a_paragraph_in_a_shared_text_object_keeps_order_and_nesting() {
// Splitting the text object (to keep the order) and q … Q (to keep the colour in) together:
// q/Q stay outside text objects, and the paragraph after it keeps the original colour.
let src = "BT /F1 10 Tf 72 700 Td (First paragraph) Tj 0 -40 Td (Second paragraph) Tj 0 -40 Td (Third paragraph) Tj ET";
let mut doc = text_page(src);
let style = text::BlockStyle { color: Some([1.0, 0.0, 0.0]), ..Default::default() };
text::rewrite_block(&mut doc, 0, 1, None, &style).unwrap();
let doc = reopen(&doc);
let texts: Vec<String> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
assert_eq!(texts, ["First paragraph", "Second paragraph", "Third paragraph"]);
let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops;
let (mut in_text, mut depth, mut red) = (false, 0usize, Vec::new());
for op in &ops {
match op.op.as_slice() {
b"BT" => in_text = true,
b"ET" => in_text = false,
b"q" | b"Q" => {
assert!(!in_text, "q/Q inside a text object: {ops:?}");
depth = if op.is("q") { depth + 1 } else { depth.saturating_sub(1) };
}
b"rg" => red.push(depth),
b"Tj" if op.operands.first().and_then(|o| o.as_string()).is_some_and(|s| s.to_text() == "Third paragraph") => {
assert!(red.iter().all(|d| *d > depth), "red still in force for the third paragraph: {ops:?}");
}
_ => {}
}
}
assert!(!red.is_empty(), "{ops:?}");
}
#[test]
fn justify_underline_and_spacing() {
let src = "BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj T* (end) Tj ET";
// Justified: every line but the last reaches the right edge.
let mut doc = text_page(src);
let right = text::text_blocks(&doc, 0).unwrap()[0].rect[2];
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { align: Some(added::Align::Justify), ..Default::default() }).unwrap();
let lines = text::text_lines(&doc, 0).unwrap();
for l in &lines[..lines.len() - 1] {
assert!((l.rect[2] - right).abs() < 1.0, "{:?} ends at {} not {right}", l.text, l.rect[2]);
}
// Double line spacing, underlined.
let mut doc = text_page(src);
let style = text::BlockStyle { line_spacing: Some(2.0), underline: Some(true), ..Default::default() };
text::rewrite_block(&mut doc, 0, 0, None, &style).unwrap();
let lines = text::text_lines(&doc, 0).unwrap();
assert!((lines[0].origin_baseline() - lines[1].origin_baseline() - 20.0).abs() < 0.01);
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert!(content.contains(" l\nS\n") || content.contains(" l S"), "{content}");
// Character spacing widens, horizontal scale narrows.
let mut doc = text_page("BT /F1 10 Tf 100 700 Td (Wide text) Tj ET");
let w0 = text::text_lines(&doc, 0).unwrap()[0].rect;
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { char_spacing: Some(2.0), ..Default::default() }).unwrap();
let w1 = text::text_lines(&doc, 0).unwrap()[0].rect;
assert!((w1[2] - w1[0]) - (w0[2] - w0[0]) > 15.0, "{w0:?} → {w1:?}");
let mut doc = text_page("BT /F1 10 Tf 100 700 Td (Wide text) Tj ET");
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { scale: Some(50.0), ..Default::default() }).unwrap();
let w2 = text::text_lines(&doc, 0).unwrap()[0].rect;
assert!(((w2[2] - w2[0]) - (w0[2] - w0[0]) / 2.0).abs() < 1.0, "{w0:?} → {w2:?}");
}
#[test]
fn a_line_drawn_twice_is_replaced_everywhere() {
// Fake bold: a line drawn twice at the same spot (each copy its own text object). The two
// copies group apart, but editing one replaces both — a surviving copy would show the old
// text under the new.
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello) Tj ET BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2, "the copies group apart");
text::replace_block(&mut doc, 0, 0, "Hello world").unwrap();
let doc = reopen(&doc);
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["Hello world"], "one line, replaced");
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert_eq!(content.matches("(Hello").count(), 1, "the second copy's operators are gone: {content}");
// Copies in one text object (the matrix re-issued at the same spot) go too.
let mut doc = text_page("BT /F1 12 Tf 1 0 0 1 72 700 Tm (Hi) Tj 1 0 0 1 72.3 700 Tm (Hi) Tj ET");
text::replace_line(&mut doc, 0, 0, "Hey").unwrap();
let doc = reopen(&doc);
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["Hey"], "one line, replaced");
// A neighbouring line is not coincident and stays (40 pt spacing groups apart at 12 pt).
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello) Tj 0 -40 Td (world) Tj ET");
text::replace_block(&mut doc, 0, 0, "Hello there").unwrap();
let doc = reopen(&doc);
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["Hello there", "world"]);
}
#[test]
fn paragraphs_move_and_rewrap_to_a_new_width() {
let para = "BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj ET";
let close = |a: f64, b: f64| (a - b).abs() < 0.5;
// Moved 50 right and 200 down, same words and wrapping.
let mut doc = text_page(para);
let before = text::text_blocks(&doc, 0).unwrap()[0].clone();
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { offset: Some([50.0, -200.0]), ..Default::default() }).unwrap();
let doc = reopen(&doc);
let after = text::text_blocks(&doc, 0).unwrap()[0].clone();
assert_eq!((after.text.as_str(), after.lines.len()), (before.text.as_str(), before.lines.len()));
assert!(close(after.rect[0], before.rect[0] + 50.0) && close(after.rect[1], before.rect[1] - 200.0), "{:?} → {:?}", before.rect, after.rect);
// Drawn at half scale: the move is still in page space.
let mut doc = text_page(&format!("q 0.5 0 0 0.5 0 0 cm {} Q", para.replace("100 700 Td", "200 1400 Td")));
let before = text::text_blocks(&doc, 0).unwrap()[0].clone();
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { offset: Some([30.0, 40.0]), ..Default::default() }).unwrap();
let after = text::text_blocks(&reopen(&doc), 0).unwrap()[0].clone();
assert!(close(after.rect[0], before.rect[0] + 30.0) && close(after.rect[1], before.rect[1] + 40.0), "{:?} → {:?}", before.rect, after.rect);
// A narrower box rewraps into more lines that fit it; a wider one into fewer.
let mut doc = text_page(para);
let left = text::text_blocks(&doc, 0).unwrap()[0].rect[0];
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { width: Some(80.0), ..Default::default() }).unwrap();
let narrow = text::text_blocks(&reopen(&doc), 0).unwrap()[0].clone();
assert!(narrow.lines.len() > 2, "{narrow:?}");
assert!(narrow.rect[2] <= left + 80.5, "{narrow:?}");
let mut doc = text_page(para);
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { width: Some(400.0), ..Default::default() }).unwrap();
assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap()[0].lines.len(), 1);
// Untrusted numbers (the automation tools pass them through) are refused, not drawn.
let mut doc = text_page(para);
for style in [
text::BlockStyle { offset: Some([f64::NAN, 0.0]), ..Default::default() },
text::BlockStyle { offset: Some([0.0, f64::INFINITY]), ..Default::default() },
text::BlockStyle { width: Some(f64::NAN), ..Default::default() },
] {
assert!(text::rewrite_block(&mut doc, 0, 0, None, &style).is_err(), "{style:?}");
}
// A zero width is clamped to one character's width rather than looping or vanishing.
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { width: Some(0.0), ..Default::default() }).unwrap();
assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap().iter().map(|b| b.text.split_whitespace().count()).sum::<usize>(), 12);
}
/// A page split into several content streams, as AutoCAD writes them: the first scales the
/// page (`0.12 0 0 0.12 0 0 cm`, no `q`), the text comes in a later one.
fn split_page(first: &str, second: &str) -> Document {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents [4 0 R 5 0 R] /Resources << /Font << /F1 6 0 R >> >> >>".into(),
format!("<< /Length {} >>\nstream\n{first}\nendstream", first.len()),
format!("<< /Length {} >>\nstream\n{second}\nendstream", second.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
#[test]
fn the_graphics_state_carries_over_between_content_streams() {
let close = |a: f64, b: f64| (a - b).abs() < 0.5;
// Text at (1000, 2000) in a space scaled by 0.12: on the page at (120, 240), 12 pt.
let mut doc = split_page("0.12 0 0 0.12 0 0 cm", "BT /F1 100 Tf 1000 2000 Td (Site plan) Tj ET");
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 1, "{lines:?}");
let l = &lines[0];
assert!(close(l.rect[0], 120.0) && l.rect[1] > 230.0 && l.rect[3] < 260.0, "{:?}", l.rect);
assert!(close(l.size, 12.0), "{}", l.size);
// Editing keeps the paragraph where it was, at its size; moving it moves it in page space.
text::rewrite_block(&mut doc, 0, 0, Some("Site plan, rev. B"), &text::BlockStyle { offset: Some([10.0, -20.0]), ..Default::default() }).unwrap();
let after = text::text_blocks(&reopen(&doc), 0).unwrap();
assert_eq!(after[0].text, "Site plan, rev. B");
assert!(close(after[0].rect[0], 130.0) && close(after[0].rect[1], l.rect[1] - 20.0), "{:?}", after[0].rect);
assert!(close(after[0].size, 12.0), "{}", after[0].size);
// A `q` left open in one stream and closed in the next still restores the state.
let doc = split_page("q 0.5 0 0 0.5 0 0 cm", "Q BT /F1 10 Tf 100 100 Td (Footpath) Tj ET");
let l = &text::text_lines(&doc, 0).unwrap()[0];
assert!(close(l.rect[0], 100.0) && close(l.size, 10.0), "{l:?}");
}