Files
craft-libs/crates/pdf/tests/upstream_text.rs
T
aiongg 429258979d hayro-interpret, hayro: port PdfCraft's font and text fixes (ledger P7, 49, 50)
From PdfCraft 0b670caf4:

- P7 (PdfCraft #3109, #3116): text in mode 7 (clip only) installs its
  clip and reaches the device as GlyphDrawMode::Clip, which the hayro
  renderer does not paint, so extraction that leaves out invisible OCR
  text keeps a heading drawn through its clip. Test:
  clipping_only_text_reaches_the_device_apart_from_invisible_text in
  crates/pdf/tests/upstream_text.rs.
- (49) (PdfCraft #2949 and 09668ce88): a TrueType post table version
  1.0, 2.0 or 3.0 written with its 16-bit halves swapped is accepted,
  and a swapped 2.0 table's glyph names are read. Unit test on a
  synthetic sfnt in font::blob::tests.
- (50) (PdfCraft #3958): the CFF glyph-name map is built once on first
  use and shared between clones instead of scanning the charset per
  code. PdfCraft's unit test comes with it.
2026-10-24 06:55:26 +00:00

151 lines
7.9 KiB
Rust

//! Text contracts adapted from PdfCraft a2ec27b4775f53c40cad3456936d9406611e3a8d (MIT OR Apache-2.0):
//! Annex D.2 alias codes, glyph names outside the Adobe Glyph List, and `/ToUnicode` identity names;
//! and from PdfCraft 0b670caf4a090e9d83a7f41a84b4cb2144a2d33e, clipping-only text.
#![cfg(feature = "interpret")]
#![allow(clippy::unwrap_used, clippy::panic)]
use craft_pdf::interpret::{
BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, TransformExt,
font::Glyph,
};
use hayro_cmap::BfString;
use kurbo::{Affine, BezPath, Rect};
/// The text of each glyph drawn, `None` where the font gives none, and how it was drawn.
#[derive(Default)]
struct TextDevice {
glyphs: Vec<Option<String>>,
modes: Vec<char>,
}
impl Device<'_> for TextDevice {
fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
fn set_blend_mode(&mut self, _: BlendMode) {}
fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'_>, _: &PathDrawMode) {}
fn push_clip_path(&mut self, _: &ClipPath) {}
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
fn draw_glyph(&mut self, glyph: &Glyph<'_>, _: Affine, _: Affine, _: &Paint<'_>, mode: &GlyphDrawMode) {
self.modes.push(match mode {
GlyphDrawMode::Fill => 'F',
GlyphDrawMode::Stroke(_) => 'S',
GlyphDrawMode::Clip => 'C',
GlyphDrawMode::Invisible => 'I',
});
self.glyphs.push(glyph.as_unicode().map(|text| match text {
BfString::Char(c) => c.to_string(),
BfString::String(s) => s,
}));
}
fn draw_image(&mut self, _: Image<'_, '_>, _: Affine) {}
fn pop_clip_path(&mut self) {}
fn pop_transparency_group(&mut self) {}
}
/// The glyphs a one-page PDF draws with its font `/F1`, object 5; `extra` holds further objects.
fn drawn_glyphs(font: &str, extra: &str, content: &[u8]) -> TextDevice {
let mut pdf = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj\n\
4 0 obj << /Length {} >> stream\n",
content.len()
)
.into_bytes();
pdf.extend_from_slice(content);
pdf.extend_from_slice(format!("\nendstream endobj\n5 0 obj {font} endobj\n{extra}trailer << /Root 1 0 R >>\n%%EOF\n").as_bytes());
let pdf = craft_pdf::open_slice(&pdf, None).unwrap();
let page = &pdf.pages()[0];
let cache = InterpreterCache::new();
let mut context =
Context::new(page.initial_transform(true).to_kurbo(), Rect::new(0.0, 0.0, 300.0, 300.0), &cache, page.xref(), InterpreterSettings::default());
let mut device = TextDevice::default();
craft_pdf::interpret_page(page, &mut context, &mut device).unwrap();
device
}
fn glyph_texts(font: &str, extra: &str, content: &[u8]) -> Vec<Option<String>> {
drawn_glyphs(font, extra, content).glyphs
}
fn helvetica_texts(encoding: &str, content: &[u8]) -> Vec<String> {
let font = format!("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding {encoding} >>");
glyph_texts(&font, "", content).into_iter().map(|text| text.unwrap_or_default()).collect()
}
/// PdfCraft ledger hayro-interpret (30): WinAnsiEncoding gives 0xAD and 0xA0 the glyph names
/// `hyphen` and `space`, and MacRomanEncoding gives 0xCA `space` (Annex D.2), so they read as the
/// ordinary hyphen and space rather than U+00AD and U+00A0. A `/Differences` entry naming
/// `sfthyphen` or `nbspace` keeps its glyph list value.
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space() {
assert_eq!(
helvetica_texts("/WinAnsiEncoding", b"BT /F1 20 Tf 20 150 Td (a\xadb\xa0c) Tj ET"),
["a", "-", "b", " ", "c"],
"WinAnsi 0xAD and 0xA0"
);
assert_eq!(helvetica_texts("/MacRomanEncoding", b"BT /F1 20 Tf 20 150 Td (a\xcab) Tj ET"), ["a", " ", "b"], "MacRoman 0xCA");
assert_eq!(
helvetica_texts(
"<< /Type /Encoding /BaseEncoding /WinAnsiEncoding /Differences [88 /sfthyphen 89 /nbspace] >>",
b"BT /F1 20 Tf 20 150 Td (aXbYc) Tj ET"
),
["a", "\u{ad}", "b", "\u{a0}", "c"],
"a named glyph keeps its glyph list value"
);
}
/// PdfCraft ledger hayro-interpret P6 (#3169, #3170): ligature (`f_i`) and suffixed
/// (`hyphen.case`) names read by the glyph list specification's full mapping. A name that only
/// numbers the glyph (`C39`, `c251`), a ZapfDingbats name (`a12`) and a name with no meaning
/// left (`foo`) read as nothing.
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn glyph_names_outside_the_glyph_list_still_read_as_text() {
let encoding = "<< /Type /Encoding /Differences [30 /f_i /f_l 2 /hyphen.case 39 /C39 97 /c251 174 /C174 \
88 /a12 /foo /uni00660069 61 /C39.sc 233 /C1] >>";
let texts = helvetica_texts(encoding, b"BT /F1 20 Tf 20 150 Td (\x1e\x1f\x02'a\xaeXYZ=\xe9) Tj ET");
assert_eq!(texts, ["fi", "fl", "-", "", "", "", "", "", "fi", "", ""]);
}
/// PdfCraft ledger hayro-interpret P6: the `uni` and `u` forms take uppercase hex digits only.
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn glyph_names_with_lowercase_hex_read_as_nothing() {
let encoding = "<< /Type /Encoding /Differences [65 /uni20ac0308 /uni20AC0308 /u1040c.alt /u1040C.alt /f_uni20ac /f_uni20AC] >>";
let texts = helvetica_texts(encoding, b"BT /F1 20 Tf 20 150 Td (ABCDEF) Tj ET");
assert_eq!(texts, ["", "\u{20ac}\u{308}", "", "\u{1040c}", "f", "f\u{20ac}"]);
}
/// PdfCraft ledger hayro-interpret (44): `/ToUnicode /Identity-H` (a name, not a CMap stream)
/// reads two-byte codes as their Unicode values. Controls (a DEL and a C1 control) are not text.
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn identity_to_unicode_names_give_text() {
for name in ["Identity-H", "Identity-V"] {
let font = format!(
"<< /Type /Font /Subtype /Type0 /BaseFont /RobotoMono-Regular /Encoding /Identity-H /DescendantFonts [6 0 R] /ToUnicode /{name} >>"
);
let cid = "6 0 obj << /Type /Font /Subtype /CIDFontType2 /BaseFont /RobotoMono-Regular \
/CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /DW 600 /CIDToGIDMap /Identity >> endobj\n";
let texts = glyph_texts(&font, cid, b"BT /F1 12 Tf 20 200 Td <005A00FC0072006900630068002020AC007F0085> Tj ET");
let texts: Vec<String> = texts.into_iter().map(|text| text.unwrap_or_default()).collect();
assert_eq!(texts, ["Z", "\u{fc}", "r", "i", "c", "h", " ", "\u{20ac}", "", ""], "{name}");
}
}
/// PdfCraft #3109 / #3116: text in mode 7 (clip only) reaches the device as
/// `GlyphDrawMode::Clip`, apart from mode 3 (invisible OCR text), so extraction that leaves out
/// OCR text keeps a heading drawn through its clip.
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn clipping_only_text_reaches_the_device_apart_from_invisible_text() {
let font = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>";
for (mode, expected) in [(0, 'F'), (3, 'I'), (7, 'C')] {
let content = format!("q BT /F1 24 Tf {mode} Tr 30 200 Td (Hi) Tj ET 0 0 1 rg 0 0 300 300 re f Q");
let drawn = drawn_glyphs(font, "", content.as_bytes());
assert_eq!(drawn.glyphs, [Some("H".into()), Some("i".into())], "{mode} Tr");
assert_eq!(drawn.modes, [expected; 2], "{mode} Tr");
}
}