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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q
This commit is contained in:
mtlcl410
2026-10-18 19:46:39 +08:00
co-authored by Claude Opus 5.5
parent 50f261d70c
commit 0b731453d3
6 changed files with 110 additions and 11 deletions
+22
View File
@@ -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::<Vec<_>>();
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
+8
View File
@@ -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
+47
View File
@@ -669,6 +669,53 @@ pub(crate) fn glyph_name_to_unicode(name: &str) -> Option<char> {
})
}
/// 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<BfString> {
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<char> {
let convert = |input: &str| u32::from_str_radix(input, 16).ok().and_then(char::from_u32);
+1 -1
View File
@@ -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
+6 -5
View File
@@ -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),
}
+26 -5
View File
@@ -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)
})
}
}