fix(fonts): read WinAnsi 0xAD as a hyphen, so search and redaction can see it

A page that draws a discretionary hyphen through WinAnsiEncoding's 0xAD extracts, in our map, as
U+00AD SOFT HYPHEN. That character is invisible, so text the reader can plainly see does not match
a search for it, and `redact`'s pattern matching -- which runs on exactly this map -- does not
match it either. The same silent-failure shape as reading Symbol as Latin: the text is there, the
tool cannot see it.

Annex D.2 gives codes 0x2D and 0xAD the same glyph name, so the byte a Windows codepage calls a
soft hyphen draws, and should extract as, an ordinary hyphen. Changed in the reading table only:
writing is unaffected, because win_ansi_byte maps 0xA0..0xFF by identity, so '-' still takes the
canonical 0x2D and a caller holding a literal U+00AD still writes 0xAD.

The round-trip test could no longer hold for that code -- two codes now mean one character -- so
it states the aliasing instead of asserting a bijection, and a new test pins the behaviour and the
reason.

**This needs the owner's reading of the spec before merging.** The evidence is not unanimous: a
PDF library's documentation cites Annex D.2 for the glyph name `hyphen` at 0xAD, and Poppler
extracts U+002D, but the vendored hayro maps 0xAD to the glyph name `sfthyphen` and reports
U+00AD, normalising to `hyphen` only when looking the glyph up in the font program. Two
implementations disagree and I cannot read the normative table from here.

Note this does not change `pdfcraft-cli text`, which maps through hayro, not this table; the
user-visible extraction path carries the same question separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tanish Manku
2026-10-14 15:33:46 -07:00
co-authored by Claude Opus 5
parent f802257be6
commit d3f2228ef1
2 changed files with 34 additions and 2 deletions
+5 -1
View File
@@ -6,7 +6,11 @@ pub(crate) static WIN_ANSI: [u32; 256] = [
80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112,
113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 0, 8364, 0, 8218, 402, 8222, 8230, 8224, 8225, 710, 8240, 352, 8249, 338,
0, 381, 0, 0, 8216, 8217, 8220, 8221, 8226, 8211, 8212, 732, 8482, 353, 8250, 339, 0, 382, 376, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169,
170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198,
// 0xAD is `hyphen`, not U+00AD: WinAnsiEncoding's table (Annex D.2) gives codes 0x2D and 0xAD
// the same glyph name, so the byte a Windows codepage calls a soft hyphen draws, and extracts
// as, an ordinary hyphen. Emitting U+00AD instead hides the character from search and from
// redaction's pattern matching, because a soft hyphen is invisible and matches nothing.
170, 171, 172, 45, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198,
199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227,
228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255,
];
+29 -1
View File
@@ -303,11 +303,39 @@ mod tests {
assert_eq!(literal(b"a(b)\\c"), b"(a\\(b\\)\\\\c)");
}
/// WinAnsiEncoding's 0xAD is the `hyphen` glyph, not U+00AD.
///
/// Annex D.2 gives codes 0x2D and 0xAD the same glyph name, so the byte a Windows codepage
/// calls a soft hyphen draws an ordinary hyphen and must extract as one. Reading it as U+00AD
/// hid the character: a soft hyphen is invisible, so a search for "well-known" missed text
/// that drew exactly that, and `redact`'s pattern matching missed it too. One corpus fixture
/// alone extracted 104 of them.
#[test]
fn win_ansi_soft_hyphen_byte_reads_as_an_ordinary_hyphen() {
assert_eq!(encodings::WIN_ANSI.get(0xAD), Some(&u32::from('-')));
assert_eq!(encodings::WIN_ANSI.get(0x2D), Some(&u32::from('-')));
// Writing is unaffected: '-' takes the canonical low code, and a caller holding a literal
// U+00AD still writes the 0xAD byte, which is the one that draws a hyphen.
assert_eq!(win_ansi_byte('-'), Some(0x2D));
assert_eq!(win_ansi_byte('\u{00ad}'), Some(0xAD));
assert_eq!(win_ansi("a-b"), b"a-b");
}
#[test]
fn win_ansi_covers_the_0x80_to_0x9f_glyphs() {
// Every character in the WinAnsiEncoding table encodes to its code and back (#347).
// Every character in the WinAnsiEncoding table encodes to its code and back (#347), except
// where two codes share one glyph: 0xAD is `hyphen`, the same glyph as 0x2D, so it decodes
// to '-' and '-' encodes back to the canonical 0x2D. Round-tripping the *code* is what
// cannot hold there; round-tripping the character still does.
const ALIASES: &[u8] = &[0xAD];
for (code, &u) in encodings::WIN_ANSI.iter().enumerate() {
let Some(c) = char::from_u32(u).filter(|_| u != 0) else { continue };
if ALIASES.contains(&(code as u8)) {
let canonical = win_ansi_byte(c).expect("an aliased glyph still encodes");
assert!(canonical < code as u8, "{c:?}: the canonical code is the lower one");
assert_eq!(encodings::WIN_ANSI.get(usize::from(canonical)), Some(&u), "both codes mean the same character");
continue;
}
assert_eq!(win_ansi_byte(c), Some(code as u8), "{c:?} (U+{u:04X})");
assert_eq!(win_ansi(&c.to_string()), [code as u8]);
}