Merge PR #3239: M3: extract text from glyph names outside the reference-app Glyph List (#3169, #3170)

This commit is contained in:
aiongg
2026-10-22 01:45:33 +00:00
6 changed files with 135 additions and 11 deletions
+58
View File
@@ -2086,6 +2086,64 @@ 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`, `C39.sc`)
/// reads as nothing, not as a guess from the code (review on #3239: with `/C1` at 233,
/// StandardEncoding's 233 would read `Ø` for a glyph drawn as `é`), and so do a ZapfDingbats
/// name (`a12`) and a name with no meaning left (`foo`).
#[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"),
Object::Integer(61), name("C39.sc"), Object::Integer(233), name("C1"),
],
});
// One glyph per line: the test font has none of these glyphs, so they have no width, and
// on one line a later `fi` would sit on the first and be dropped as its fake-bold copy.
let mut content = b"BT /F1 20 Tf 20 290 Td".to_vec();
for code in *b"\x1e\x1f\x02'a\xaeXYZ=\xe9" {
content.extend_from_slice(b" (");
content.push(code);
content.extend_from_slice(b") Tj 0 -25 Td");
}
content.extend_from_slice(b" ET");
let pdf = encoded_pages(&[(differences, &content[..])]);
let text = extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap().plain_text();
let lines: Vec<&str> = text.lines().map(str::trim).filter(|l| !l.is_empty()).collect();
assert_eq!(lines, ["fi", "fl", "-", "fi"], "{text:?}");
}
/// Review on #3239: the glyph list specification's `uni` and `u` forms take uppercase hex
/// digits only, and map a name with lowercase ones to nothing ("uni20ac" in its examples).
/// Names upstream's own lookup can't read (several groups, a suffix) show it: lowercase reads
/// as nothing, uppercase as its characters. A bare `uni20ac` or `u1040c` is still read by
/// upstream's lenient `unicode_from_name`, outside this mapping.
#[test]
fn glyph_names_with_lowercase_hex_read_as_nothing() {
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(65), name("uni20ac0308"), name("uni20AC0308"), name("u1040c.alt"), name("u1040C.alt"), name("f_uni20ac"), name("f_uni20AC")],
});
let pdf = encoded_pages(&[(
differences,
&b"BT /F1 20 Tf 20 180 Td (A) Tj 0 -25 Td (B) Tj 0 -25 Td (C) Tj 0 -25 Td (D) Tj 0 -25 Td (E) Tj 0 -25 Td (F) Tj ET"[..],
)]);
// One glyph per line, so each name's text stays its own (and a name read as nothing
// leaves no line).
let text = extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap().plain_text();
let lines: Vec<&str> = text.lines().map(str::trim).filter(|l| !l.is_empty()).collect();
assert_eq!(lines, ["\u{20ac}\u{308}", "\u{1040c}", "f", "f\u{20ac}"], "{text:?}");
}
#[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
+7
View File
@@ -43,6 +43,13 @@ 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`; a `uni` part with any group that isn't a
character maps to nothing), so `f_i`, `f_l` and `hyphen.case` read as text. A name that only
numbers the glyph (`C39`, `c251`) still reads as nothing: the code's StandardEncoding character
would be a guess, wrong wherever the font's /Encoding puts the glyph elsewhere (review on #3239). 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
+43
View File
@@ -669,6 +669,49 @@ 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, or a `uni` component with any group that
/// isn't a character, 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);
// The specification's digits are uppercase only (`uni20ac` maps to nothing, review on
// #3239).
let upper_hex = |d: &str| d.bytes().all(|b| matches!(b, b'0'..=b'9' | b'A'..=b'F'));
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 && upper_hex(d))
{
// All ASCII, so every fourth byte is a char boundary. A group that isn't a
// character (a surrogate) makes the whole component map to nothing.
let chars: Option<String> = (0..digits.len())
.step_by(4)
.map(|at| digits.get(at..at + 4).and_then(hex))
.collect();
text.extend(chars);
} else if let Some(c) = part
.strip_prefix('u')
.filter(|d| (4..=6).contains(&d.len()) && upper_hex(d))
.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)),
}
}
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),
}
+20 -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,
read_to_unicode,
};
use crate::{CMapResolverFn, CacheKey, FontResolverFn};
use hayro_cmap::{BfString, CMap};
@@ -117,11 +118,25 @@ 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`) says nothing about the character and reads as
// nothing: the code's StandardEncoding character would be a guess, wrong wherever the
// font's /Encoding puts the glyph elsewhere (review on #3239).
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)
}
}