M10: glyph names with lowercase hex digits map to nothing (#3170)

Review on #3239: the Adobe Glyph List Specification's `uni` and `u` forms
take uppercase hexadecimal digits only (section 2), and its examples map
`uni20ac` to an empty string. glyph_name_to_text accepted lowercase too.

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-19 12:22:00 +08:00
co-authored by Claude Opus 5.5
parent ecb990c8ce
commit 49fa11942f
2 changed files with 29 additions and 2 deletions
+24
View File
@@ -1926,6 +1926,30 @@ mod tests {
assert_eq!(texts, ["fi", "fl", "-", "\u{2019}", "a", "\u{fb01}", "fi", "="]);
}
/// 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
+5 -2
View File
@@ -677,6 +677,9 @@ pub(crate) fn glyph_name_to_unicode(name: &str) -> Option<char> {
/// 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('_') {
@@ -684,7 +687,7 @@ pub(crate) fn glyph_name_to_text(name: &str) -> Option<BfString> {
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()))
.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.
@@ -695,7 +698,7 @@ pub(crate) fn glyph_name_to_text(name: &str) -> Option<BfString> {
text.extend(chars);
} else if let Some(c) = part
.strip_prefix('u')
.filter(|d| (4..=6).contains(&d.len()) && d.bytes().all(|b| b.is_ascii_hexdigit()))
.filter(|d| (4..=6).contains(&d.len()) && upper_hex(d))
.and_then(hex)
{
text.push(c);