M10: glyph names in a symbolic TrueType font's post table read as text only in the glyph list's uni and u forms (#3962)

The glyph list specification (ISO 32000-2 §9.10.2) writes the hex digits
of its `uniXXXX` and `uXXXX`-`uXXXXXX` forms in uppercase, `uni` taking
groups of exactly four and `u` four to six, outside the surrogate range.
`glyph_name_text` (hayro-interpret patch 79) took lowercase digits too, so
a glyph named `uni1eae` read as U+1EAE; it now names nothing and the code
reads as WinAnsi, as for any glyph without a name. The editor's reading of
the same `post` names in pdfcraft-fonts applies the same rule, so both
agree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
This commit is contained in:
mtlcl410
2026-11-07 18:11:55 +08:00
co-authored by Claude Opus 5.5
parent 9dfd49cc94
commit 63a6f8ace6
5 changed files with 91 additions and 18 deletions
+15 -1
View File
@@ -1282,11 +1282,25 @@ fn symbolic_glyph_texts(doc: &Document, font: &Dict) -> Vec<(u32, String)> {
let u = crate::encodings::WIN_ANSI.get(usize::try_from(code).ok()?).copied().filter(|u| *u != 0)?;
char::from_u32(u).map(String::from)
};
Some((code, post_name(&post, &custom, glyph).and_then(glyph_text).or_else(winansi)?))
let text = post_name(&post, &custom, glyph).filter(|n| uppercase_hex_forms(n)).and_then(glyph_text);
Some((code, text.or_else(winansi)?))
})
.collect()
}
/// Whether the `uni` and `u` components of a glyph name spell their hex digits in uppercase, as
/// the glyph list specification's forms do (ISO 32000-2 §9.10.2): `uni1eae` names nothing, so
/// its glyph's code reads as WinAnsi, as the renderer reads it (vendored hayro-interpret patch
/// (79)). A component that is a list name (`a`, `ecircumflex`) is read as such whatever its case.
fn uppercase_hex_forms(name: &str) -> bool {
let stem = name.split('.').next().unwrap_or_default();
stem.split('_').all(|part| {
// Outside the list, a component `glyph_text` reads is `u` or `uni` and hex digits, so a
// lowercase `a` to `f` in it is a lowercase digit.
crate::encodings::NAMES.binary_search_by(|(n, _)| (*n).cmp(part)).is_ok() || !part.bytes().any(|b| matches!(b, b'a'..=b'f'))
})
}
/// The names a version 2.0 `post` table spells out, in order, read once. `Post::glyph_name` walks
/// this data from its start for each glyph it is asked about, which over a font program of up to
/// 16 MiB, asked about 256 codes, is work an untrusted file can make large. Reading stops at the
+34 -2
View File
@@ -256,8 +256,22 @@ fn a_symbolic_truetype_font_without_an_encoding_reads_its_glyph_names() {
.collect();
words.iter().flat_map(|w| w.to_be_bytes()).collect::<Vec<u8>>()
};
// Symbol (3,0): 0xF020 → k, 0xF0C0 → é, 0xF0C1 → a.1, 0xF0C2 → x, 0xF0C4 → the unnamed glyph.
let symbol = format4(&[(0xF020, gid('k')), (0xF0C0, gid('é')), (0xF0C1, variant), (0xF0C2, gid('x')), (0xF0C3, gid('w')), (0xF0C4, unnamed)]);
// A glyph whose name the table spells `uni1eae`, lowercase, below: the glyph list
// specification's hex digits are uppercase (ISO 32000-2 §9.10.2), so it names nothing.
let lowercase = (1..face.maxp().unwrap().num_glyphs())
.find(|&g| post.glyph_name(skrifa::raw::types::GlyphId16::new(g)) == Some("uni1EAE"))
.expect("Inter has uni1EAE");
// Symbol (3,0): 0xF020 → k, 0xF0C0 → é, 0xF0C1 → a.1, 0xF0C2 → x, 0xF0C4 → the unnamed glyph,
// 0xF0C5 → the one spelt in lowercase.
let symbol = format4(&[
(0xF020, gid('k')),
(0xF0C0, gid('é')),
(0xF0C1, variant),
(0xF0C2, gid('x')),
(0xF0C3, gid('w')),
(0xF0C4, unnamed),
(0xF0C5, lowercase),
]);
// Unicode (3,1), after it: 0xC2 → z. The renderer reads the (3,0) record, offsets and
// all, before this one, so code 0xC2 is `x`.
let unicode = format4(&[(0xC2, gid('z'))]);
@@ -288,6 +302,11 @@ fn a_symbolic_truetype_font_without_an_encoding_reads_its_glyph_names() {
subset[entry + 8..entry + 12].copy_from_slice(&offset.to_be_bytes());
subset[entry + 12..entry + 16].copy_from_slice(&(cmap.len() as u32).to_be_bytes());
subset.extend_from_slice(&cmap);
// The `post` table's own spelling of that name, a Pascal string, lowercased in place.
let spelt = b"\x07uni1EAE";
assert_eq!(subset.windows(spelt.len()).filter(|w| w == spelt).count(), 1);
let spot = subset.windows(spelt.len()).position(|w| w == spelt).unwrap();
subset[spot..spot + spelt.len()].copy_from_slice(b"\x07uni1eae");
let codes: Vec<u8> = TEXT.bytes().map(|b| b - 29).collect();
let metrics = |flags: i64, encoding: Option<Object>| {
let mut doc = Document::new_empty();
@@ -320,6 +339,7 @@ fn a_symbolic_truetype_font_without_an_encoding_reads_its_glyph_names() {
assert_eq!(Metrics::from_dict(&doc, &f).decode(&codes), TEXT, "a swapped post version");
assert_eq!(symbolic.decode(&[0x20, 0xC0, 0xC1, 0xC2, 0xC3]), "kéaxw");
assert_eq!(symbolic.decode(&[0xC4]), "Ä", "a glyph name with no text reads as WinAnsi, not Standard's `tilde`");
assert_eq!(symbolic.decode(&[0xC5]), "Å", "`uni1eae` names nothing, so the code reads as WinAnsi, not U+1EAE");
assert_eq!(symbolic.encode("Solid").as_deref(), Some(&codes[..5]), "typing sets the same glyphs");
// A named encoding, or the Nonsymbolic flag, says what the codes are: they read through it.
assert_eq!(metrics(4, Some(Object::name("WinAnsiEncoding"))).decode(&codes), "6ROLGVWDWH");
@@ -346,6 +366,18 @@ fn a_symbolic_truetype_font_without_an_encoding_reads_its_glyph_names() {
assert_eq!(Metrics::from_dict(&doc, &f).decode(&codes), "");
}
/// The glyph list specification's `uni` and `u` forms spell their hex digits in uppercase
/// (ISO 32000-2 §9.10.2); list names keep their own case.
#[test]
fn glyph_names_with_lowercase_hex_digits_name_nothing() {
for name in ["uni1EAE", "u1F600", "a", "ecircumflex", "f_i", "a.ss01", "uni0041_uni0042"] {
assert!(uppercase_hex_forms(name), "{name}");
}
for name in ["uni1eae", "u1f600", "uni20ac.alt", "f_uni20ac", "uni0041_uni00e9"] {
assert!(!uppercase_hex_forms(name), "{name}");
}
}
fn metrics_with_flags(flags: Option<Object>) -> Metrics {
let mut doc = Document::new_empty();
let mut descriptor = Dict::new();
+16 -4
View File
@@ -2109,6 +2109,10 @@ fn a_symbolic_truetype_font_without_an_encoding_reads_its_glyph_names() {
const UNI: u16 = 0x3E;
const UNNAMED: u16 = 0x3F;
const LIGATURE: u16 = 0x40;
// 0x41 selects a glyph whose name is spelt `uni01cd`, lowercase: the glyph list
// specification's hex digits are uppercase (ISO 32000-2 §9.10.2), so the name stands for no
// text and the code reads as WinAnsi (`A`), not as U+01CD.
const LOWERCASE: u16 = 0x41;
let font = include_bytes!("../../../../assets/fonts/JetBrainsMono-Regular.ttf");
let face = skrifa::FontRef::new(font).expect("the test font parses");
let charmap = face.charmap();
@@ -2131,6 +2135,9 @@ fn a_symbolic_truetype_font_without_an_encoding_reads_its_glyph_names() {
map.push((0xF000 + UNI, uni));
map.push((0xF000 + UNNAMED, named("EMquad")));
map.push((0xF000 + LIGATURE, named("less_equal.liga")));
let lowercase = named("uni01CD");
assert_ne!(lowercase, uni, "the lowercased name is not the `uniXXXX` glyph read above");
map.push((0xF000 + LOWERCASE, lowercase));
map.sort_unstable();
map.dedup();
// A format 4 subtable: one segment per code, then the closing 0xFFFF segment.
@@ -2159,10 +2166,15 @@ fn a_symbolic_truetype_font_without_an_encoding_reads_its_glyph_names() {
let entry = (0..tables).map(|i| 12 + 16 * i).find(|&e| &font[e..e + 4] == b"cmap").expect("the test font has a cmap");
program[entry + 8..entry + 12].copy_from_slice(&(at as u32).to_be_bytes());
program[entry + 12..entry + 16].copy_from_slice(&(cmap.len() as u32).to_be_bytes());
// The `post` table's own spelling of the name, a Pascal string, lowercased in place.
let spelt = b"\x07uni01CD";
let spot = program.windows(spelt.len()).position(|w| w == spelt).expect("the post table spells the name");
assert_eq!(program.windows(spelt.len()).filter(|w| w == spelt).count(), 1);
program[spot..spot + spelt.len()].copy_from_slice(b"\x07uni01cd");
let text_of = |encoding: &str| -> String {
let codes: String = TEXT.chars().map(|c| format!("{:02X}", c as u8 - 29)).collect::<String>()
+ &format!("{SUFFIXED:02X}{UNI:02X}{UNNAMED:02X}{LIGATURE:02X}");
+ &format!("{SUFFIXED:02X}{UNI:02X}{UNNAMED:02X}{LIGATURE:02X}{LOWERCASE:02X}");
let content = format!("BT /F1 14 Tf 10 40 Td <{codes}> Tj ET");
let objects: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
@@ -2191,11 +2203,11 @@ fn a_symbolic_truetype_font_without_an_encoding_reads_its_glyph_names() {
let page = r.filtered_text(0, crate::text::TextFilter::default()).expect("the page has text");
page.glyphs.iter().map(|g| g.text.as_str()).collect()
};
assert_eq!(text_of(""), format!("Solid stateW{uni_char}?<="));
assert_eq!(text_of(""), format!("Solid stateW{uni_char}?<=A"));
// An encoding says what the codes are, so they read through it.
assert_eq!(text_of("/Encoding /WinAnsiEncoding "), "6ROLGVWDWH=>?@");
assert_eq!(text_of("/Encoding /WinAnsiEncoding "), "6ROLGVWDWH=>?@A");
// `/Differences` still names its code: 0x36 reads `A`, not the glyph's `S`.
assert_eq!(text_of("/Encoding << /Differences [54 /A] >> "), format!("Aolid stateW{uni_char}?<="));
assert_eq!(text_of("/Encoding << /Differences [54 /A] >> "), format!("Aolid stateW{uni_char}?<=A"));
}
/// Issue #102: an A4 page (595.28×841.89 pt) rendered 595×841 at 72 dpi, dropping the last
+1 -1
View File
File diff suppressed because one or more lines are too long
+25 -10
View File
@@ -202,7 +202,9 @@ impl TrueTypeFont {
// PdfCraft patch (#3169): a name outside the glyph list reads by the glyph list
// specification's full mapping (`f_i`, `hyphen.case`).
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))
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),
@@ -230,8 +232,8 @@ struct EmbeddedKind {
cached_mappings: RefCell<HashMap<u8, GlyphId>>,
/// PostScript name from the PDF.
postscript_name: Option<String>,
/// PdfCraft patch: (see vendor/README.md hayro-interpret (53)) the text each glyph's `post`
/// table name stands for, for the names that stand for any (`glyph_name_text`).
/// PdfCraft patch (79): the text each glyph's `post` table name stands for, for the names
/// that stand for any (`glyph_name_text`).
text_names: HashMap<GlyphId, String>,
}
@@ -462,15 +464,17 @@ pub(crate) enum Width {
}
/// PdfCraft patch (79): the text a glyph name stands for, by the rules of the Adobe Glyph List
/// specification, as pdfcraft-fonts' `glyph_text` reads them for the editor: drop any suffix
/// from the first `.` (`a.ss01` is `a`), then each `_`-separated component is a list name,
/// `uni` and groups of four hex digits, or `u` and four to six. `None` when any component is
/// none of these. Each component gives at least one character, so the text is never empty.
/// specification (ISO 32000-2 §9.10.2): drop any suffix from the first `.` (`a.ss01` is `a`),
/// then each `_`-separated component is a list name, `uni` and groups of exactly four
/// uppercase hex digits, or `u` and four to six. A value in the surrogate range or past
/// U+10FFFF is no character. `None` when any component is none of these (`uni20ac`, `uni65`,
/// `u65`). Each component gives at least one character, so the text is never empty.
fn glyph_name_text(name: &str) -> Option<String> {
// An empty stem (`.notdef`) is no list name and no number, so it gives `None` below.
let stem = name.split('.').next()?;
// `from_str_radix` alone would also take a sign, as in `uni+041`.
let hex = |h: &str| !h.is_empty() && h.bytes().all(|b| b.is_ascii_hexdigit());
// `from_str_radix` alone would also take a sign (`uni+041`) and lowercase digits, which the
// specification's forms don't have.
let hex = |h: &str| !h.is_empty() && h.bytes().all(|b| matches!(b, b'0'..=b'9' | b'A'..=b'F'));
let mut out = String::new();
for part in stem.split('_') {
if let Some(text) = glyph_names::get(part) {
@@ -587,7 +591,7 @@ pub(crate) fn read_encoding(dict: &Dict<'_>) -> (Encoding, HashMap<u8, String>)
mod tests {
use super::glyph_name_text;
/// PdfCraft patch (79): glyph names read as pdfcraft-fonts' `glyph_text` reads them.
/// PdfCraft patch (79): glyph names read by the glyph list specification's rules.
#[test]
fn glyph_names_read_by_the_adobe_glyph_list_rules() {
for (name, expected) in [
@@ -616,6 +620,17 @@ mod tests {
"u0000041",
"u004G",
"a_EMquad",
// PdfCraft patch (79): the glyph list specification's hex digits are uppercase only.
"uni20ac",
"uni004100e9",
"u1f600",
"u20ac",
"a_uni20ac",
"uni65",
"u65",
"uni+065",
"uDFFF",
"u110000",
] {
assert_eq!(glyph_name_text(name), None, "{name}");
}