From 0b731453d377d462cfd55b2f9b01c523b256ba02 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:46:39 +0800 Subject: [PATCH] M3: extract text from glyph names outside the Adobe Glyph List (#3169, #3170) A simple font without a usable /ToUnicode reads its text from glyph names, and a name missing from the glyph list dropped the glyph from the extracted text while the page still drew. An 821-page book set in Type1C fonts named C39, C40, ... extracted 4 KB of front matter and nothing else. - glyph_name_to_text applies the Adobe Glyph List Specification's full mapping: drop from the first '.', split on '_', map each component through the list or as uniXXXX / uXXXX. f_i, f_l and hyphen.case now read as fi, fl and '-'. Type 1, CFF, standard and embedded TrueType simple fonts use it. - A Type 1 / CFF / standard font name that only numbers the glyph (one or two letters then digits, ZapfDingbats' aN excepted) reads as the code's StandardEncoding character, so C174 is the fi ligature and C208 an em dash. The book now extracts 1.9 MB (pdftotext: 1.85 MB), with fi and em dashes where pdftotext writes Latin-1 mojibake. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q --- crates/render/src/text.rs | 22 +++++++++ vendor/README.md | 8 ++++ vendor/hayro-interpret/src/font/mod.rs | 47 +++++++++++++++++++ .../hayro-interpret/src/font/standard_font.rs | 2 +- vendor/hayro-interpret/src/font/true_type.rs | 11 +++-- vendor/hayro-interpret/src/font/type1.rs | 31 ++++++++++-- 6 files changed, 110 insertions(+), 11 deletions(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 5054859961..995fceb36c 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -1902,6 +1902,28 @@ mod tests { assert_eq!(texts(2), ["a", "\u{ad}", "b", "\u{a0}", "c"], "a named glyph keeps its glyph list value"); } + /// #3169, #3170: glyph names outside the Adobe Glyph List still read as text. Ligature + /// (`f_i`) and suffixed (`hyphen.case`) names go by the glyph list specification's full + /// mapping; a name that only numbers the glyph (`c251` small caps, `C39`, `C174`) reads as + /// the code in StandardEncoding, where 39 is a right quote and 174 the fi ligature. A + /// ZapfDingbats name (`a12`) and a name with no meaning left (`foo`) still read as nothing. + #[test] + fn glyph_names_outside_the_glyph_list_still_read_as_text() { + use lopdf::{Object, dictionary}; + let name = |n: &str| Object::Name(n.as_bytes().to_vec()); + let differences = Object::Dictionary(dictionary! { + "Type" => "Encoding", + "Differences" => vec![ + Object::Integer(30), name("f_i"), name("f_l"), Object::Integer(2), name("hyphen.case"), + Object::Integer(39), name("C39"), Object::Integer(97), name("c251"), Object::Integer(174), name("C174"), + Object::Integer(88), name("a12"), name("foo"), name("uni00660069"), + ], + }); + let pdf = encoded_pages(&[(differences, &b"BT /F1 20 Tf 1 0 0 1 20 150 Tm (\x1e\x1f\x02'a\xaeXYZ) Tj ET"[..])]); + let texts = extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap().glyphs.iter().map(|g| g.text.clone()).collect::>(); + assert_eq!(texts, ["fi", "fl", "-", "\u{2019}", "a", "\u{fb01}", "fi"]); + } + #[test] fn narrow_ligatures_keep_every_letter() { // Split evenly, this "ffi" put its two "f"s so close that the second was dropped as a diff --git a/vendor/README.md b/vendor/README.md index 2d42b2628e..78e54ac1ba 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -44,6 +44,14 @@ Covered by `raster::tests::tiff_predictor_packed_rgb_rows_keep_their_byte_bounda horizontal differencing and an 8-bit control) and `raster::tests::tiff_predictor_packed_rgb_image_renders_every_row`. +Glyph names outside the Adobe Glyph List (#3169, #3170): hayro-interpret's `glyph_name_to_text` +maps a name by the glyph list specification's full mapping (drop from the first `.`, split on `_`, +each part through the list, `uniXXXX…` or `uXXXX`), so `f_i`, `f_l` and `hyphen.case` read as +text. A simple Type 1 or CFF font whose name only numbers the glyph (`C39`, `c251`; one or two +letters then digits, ZapfDingbats' `aN` excepted) reads as the code's StandardEncoding character, +so a book set in such fonts extracts instead of returning nothing. Embedded TrueType fonts take +the full mapping only. Covered by `text::tests::glyph_names_outside_the_glyph_list_still_read_as_text`. + Text extraction: hayro-interpret exposes `OutlineGlyph::is_vertical` from a Type0 font’s CMap writing mode. PdfCraft follows negative glyph-space y for WMode 1 so upright vertical columns can be searched (#126). Lab Bradford follow-up to #1237: Lab adapts XYZ to D65 with white-point ratios diff --git a/vendor/hayro-interpret/src/font/mod.rs b/vendor/hayro-interpret/src/font/mod.rs index 7c8c8d086d..9a45e03995 100644 --- a/vendor/hayro-interpret/src/font/mod.rs +++ b/vendor/hayro-interpret/src/font/mod.rs @@ -669,6 +669,53 @@ pub(crate) fn glyph_name_to_unicode(name: &str) -> Option { }) } +/// PdfCraft patch: the text of a glyph name `glyph_name_to_unicode` doesn't know, by the +/// Adobe Glyph List Specification's full mapping: drop everything from the first `.` +/// (`hyphen.case`, `a.sc`), split the rest on `_` into components (`f_i`, `f_f_l`), and map each +/// one through the glyph list, as `uniXXXX[XXXX…]` (BMP values, four hex digits each) or as +/// `uXXXX` to `uXXXXXX`. A component it can't map adds nothing; `None` when nothing is left. +pub(crate) fn glyph_name_to_text(name: &str) -> Option { + let hex = |h: &str| u32::from_str_radix(h, 16).ok().and_then(char::from_u32); + let base = name.split('.').next().unwrap_or_default(); + let mut text = String::new(); + for part in base.split('_') { + if let Some(s) = glyph_names::get(part) { + text.push_str(s); + } else if let Some(digits) = part + .strip_prefix("uni") + .filter(|d| !d.is_empty() && d.len() % 4 == 0 && d.bytes().all(|b| b.is_ascii_hexdigit())) + { + // All ASCII, so every fourth byte is a char boundary. + for at in (0..digits.len()).step_by(4) { + text.extend(digits.get(at..at + 4).and_then(hex)); + } + } else if let Some(c) = part + .strip_prefix('u') + .filter(|d| (4..=6).contains(&d.len()) && d.bytes().all(|b| b.is_ascii_hexdigit())) + .and_then(hex) + { + text.push(c); + } + } + let mut chars = text.chars(); + match (chars.next(), chars.next()) { + (None, _) => None, + (Some(c), None) => Some(BfString::Char(c)), + _ => Some(BfString::String(text)), + } +} + +/// PdfCraft patch: whether a glyph name only numbers the glyph, one or two letters and then +/// digits (`C39`, `c251`, `g12`), so it says nothing about the character. ZapfDingbats' own +/// names (`a1` to `a191`) are left out: they name dingbats, not the code's letter. +pub(crate) fn numbered_glyph_name(name: &str) -> bool { + let letters = name.bytes().take_while(u8::is_ascii_alphabetic).count(); + (1..=2).contains(&letters) + && name.len() > letters + && name.bytes().skip(letters).all(|b| b.is_ascii_digit()) + && !(letters == 1 && name.starts_with('a')) +} + pub(crate) fn unicode_from_name(name: &str) -> Option { let convert = |input: &str| u32::from_str_radix(input, 16).ok().and_then(char::from_u32); diff --git a/vendor/hayro-interpret/src/font/standard_font.rs b/vendor/hayro-interpret/src/font/standard_font.rs index 139454a0b2..1f386a2cec 100644 --- a/vendor/hayro-interpret/src/font/standard_font.rs +++ b/vendor/hayro-interpret/src/font/standard_font.rs @@ -397,7 +397,7 @@ impl StandardKind { }) } - fn code_to_ps_name(&self, code: u8) -> Option<&str> { + pub(crate) fn code_to_ps_name(&self, code: u8) -> Option<&str> { let bf = self.base_font; self.encodings diff --git a/vendor/hayro-interpret/src/font/true_type.rs b/vendor/hayro-interpret/src/font/true_type.rs index b362669859..c650439961 100644 --- a/vendor/hayro-interpret/src/font/true_type.rs +++ b/vendor/hayro-interpret/src/font/true_type.rs @@ -2,7 +2,7 @@ use crate::font::blob::{CffFontBlob, OpenTypeFontBlob}; use crate::font::generated::{glyph_names, mac_os_roman, mac_roman, standard, win_ansi}; use crate::font::standard_font::StandardKind; use crate::font::{ - Encoding, FallbackFontQuery, FontFlags, glyph_name_to_unicode, read_to_unicode, + Encoding, FallbackFontQuery, FontFlags, glyph_name_to_text, glyph_name_to_unicode, read_to_unicode, strip_subset_prefix, unicode_from_name, }; use crate::util::OptionLog; @@ -190,10 +190,11 @@ impl TrueTypeFont { } match &self.kind { - Kind::Embedded(e) => e - .code_to_unicode_name(code as u8) - .and_then(glyph_name_to_unicode) - .map(BfString::Char), + // PdfCraft patch (#3169): a name outside the glyph list reads by the glyph list + // specification's full mapping (`f_i`, `hyphen.case`). + Kind::Embedded(e) => e.code_to_unicode_name(code as u8).and_then(|name| { + glyph_name_to_unicode(name).map(BfString::Char).or_else(|| glyph_name_to_text(name)) + }), Kind::Standard(s) => s.char_code_to_unicode(code as u8).map(BfString::Char), } diff --git a/vendor/hayro-interpret/src/font/type1.rs b/vendor/hayro-interpret/src/font/type1.rs index 02f76bfb80..16c023a88a 100644 --- a/vendor/hayro-interpret/src/font/type1.rs +++ b/vendor/hayro-interpret/src/font/type1.rs @@ -2,7 +2,8 @@ use crate::font::blob::{CffFontBlob, Type1FontBlob}; use crate::font::standard_font::{StandardFont, StandardKind, select_standard_font}; use crate::font::true_type::{Width, read_encoding, read_widths}; use crate::font::{ - Encoding, FallbackFontQuery, glyph_name_to_unicode, normalized_glyph_name, read_to_unicode, + Encoding, FallbackFontQuery, glyph_name_to_text, glyph_name_to_unicode, normalized_glyph_name, + numbered_glyph_name, read_to_unicode, }; use crate::{CMapResolverFn, CacheKey, FontResolverFn}; use hayro_cmap::{BfString, CMap}; @@ -117,11 +118,31 @@ impl Type1Font { } let code = char_code as u8; - match &self.1 { - Kind::Standard(s) => s.char_code_to_unicode(code).map(BfString::Char), - Kind::Cff(c) => c.char_code_to_unicode(code).map(BfString::Char), - Kind::Type1(t) => t.char_code_to_unicode(code).map(BfString::Char), + let found = match &self.1 { + Kind::Standard(s) => s.char_code_to_unicode(code), + Kind::Cff(c) => c.char_code_to_unicode(code), + Kind::Type1(t) => t.char_code_to_unicode(code), + }; + if let Some(c) = found { + return Some(BfString::Char(c)); } + // PdfCraft patch (#3169, #3170): a name outside the glyph list still reads by the + // glyph list specification's full mapping (`f_i`, `hyphen.case`). A name that only + // numbers the glyph (`C39`, `c251`) reads as the code's character in StandardEncoding, + // the encoding a Type 1 program is written against: a book set in such fonts extracted + // as nothing at all, though every page draws. + let name = match &self.1 { + Kind::Standard(s) => s.code_to_ps_name(code), + Kind::Cff(c) => c.code_to_ps_name(code), + Kind::Type1(t) => t.code_to_ps_name(code), + }?; + glyph_name_to_text(name).or_else(|| { + numbered_glyph_name(name) + .then(|| Encoding::Standard.map_code(code)) + .flatten() + .and_then(glyph_name_to_unicode) + .map(BfString::Char) + }) } }