diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eb3eb92..ea04dc2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,15 @@ version and date. Earlier releases are described in their advances reads as a space. Word justifies lines with Arial's en and em spaces, which came out as U+FFFD between every word (`Proposer�Legal�Name`) and sent the pages to OCR as garbled text. +- A simple font's ToUnicode CMap of fewer than ten entries keeps its say + for the codes the embedded program does not read. Such a CMap yields to + the program's own reading, and its entries were then dropped, so a code + whose glyph the program does not name read as its byte value: a + heading set by macOS Quartz in a small subset of Times New Roman Bold + read `CÔNG TY` as `CÔ!G TY` and `phẩm` as `ph'm`. The CMap's entry is + now read before the base encoding, an unreadable `/Differences` name + and that guess, and a code it maps to a control character + reads as U+FFFD. ## [1.25.2] - 2026-09-28 diff --git a/src/extractor/fonts.rs b/src/extractor/fonts.rs index 101e14af..3ebbe7b7 100644 --- a/src/extractor/fonts.rs +++ b/src/extractor/fonts.rs @@ -2622,17 +2622,35 @@ pub(crate) fn extract_text_from_operand( return Some(fb); } } - // 4. Differences mapped it? Use Differences result. A - // code the Differences name but could not map is + // 4. Differences mapped it? Use Differences result. + if let Some(NamedReading::Text(text)) = by_name { + return Some(text); + } + // 5. A simple font's sparse ToUnicode CMap that + // yielded the primary role to the program's reading + // (the one alternative such a font has, see + // `cmap_entry`): it says what the code is, so it + // is read before a code the Differences name but + // could not map, and before the base encoding; + // a code it maps to no text is not guessed at below. + if is_simple_font { + match entry.remapped.as_ref().map(|c| c.lookup_code(code)) { + Some(CodeMapping::Text(text)) if !text.contains('\u{FFFD}') => { + return Some(text); + } + Some(CodeMapping::ControlDestination) => control_destination = true, + _ => {} + } + } + // A code the Differences name but could not map is // that glyph and no other: neither the base encoding - // nor the fallback below has a say, and it reads as - // nothing. - match by_name { - Some(NamedReading::Text(text)) => return Some(text), - Some(NamedReading::Unread) => return None, - None => {} + // nor the printable guess below has a say, and it + // reads as nothing unless the sparse CMap, read + // above, names it. + if let Some(NamedReading::Unread) = by_name { + return None; } - // 5. The font's base encoding, for printable bytes + // 6. The font's base encoding, for printable bytes // (the predefined tables spell out the control // codes too, and those are dropped like they are // by the fallback below) @@ -2657,7 +2675,7 @@ pub(crate) fn extract_text_from_operand( } } } - // 6. Printable single-byte fallback — a guess at a + // 7. Printable single-byte fallback — a guess at a // code the CMap says nothing about, not at one it // maps to no text if b >= 0x20 && !control_destination { @@ -6024,6 +6042,93 @@ mod tests { ); } + /// A simple font's sparse ToUnicode CMap, once the program's own reading + /// has taken the primary role, still reads the codes that reading has + /// no entry for: the program names the glyph of `o` alone, and `c` and + /// `e` read as the CMap has them, not as the bytes `!` and `$`. Where + /// both read a code, the program's reading stays first. + #[test] + fn a_simple_fonts_sparse_cmap_reads_the_codes_the_program_does_not() { + const SPARSE_BFCHAR: &str = "<21> <0063>\n<22> <006F>\n<24> <0065>"; + let mut names = vec![None; 0x25]; + names[0x22] = Some("o"); + assert_eq!( + decode_simple_font_string( + SPARSE_BFCHAR, + Some(sfnt_with_glyph_names(&names)), + None, + &[0x21, 0x22, 0x24, 0x24] + ), + "coee" + ); + names[0x21] = Some("x"); + assert_eq!( + decode_simple_font_string( + SPARSE_BFCHAR, + Some(sfnt_with_glyph_names(&names)), + None, + &[0x21, 0x22, 0x24, 0x24] + ), + "xoee" + ); + } + + /// A control destination left in that sparse CMap — the program does + /// not name the glyph, so nothing repaired it — marks the code like any + /// other CMap's would: it is not guessed to be `#` from its byte value. + #[test] + fn a_simple_fonts_sparse_cmap_marks_its_control_destination() { + let mut names = vec![None; 0x25]; + names[0x22] = Some("o"); + assert_eq!( + decode_simple_font_string( + "<21> <0063>\n<22> <006F>\n<23> <0003>\n<24> <0065>", + Some(sfnt_with_glyph_names(&names)), + None, + &[0x21, 0x22, 0x23, 0x24, 0x24] + ), + "co\u{FFFD}ee" + ); + } + + /// The sparse CMap says what a code is, so it is read before a declared + /// base encoding and before a `/Differences` name that reads as nothing, + /// for the codes it maps: `<21>` is `c` under `/WinAnsiEncoding` (which + /// has `!` there) and under a Differences name no table can read. A + /// Differences name that does read keeps the code. + #[test] + fn a_simple_fonts_sparse_cmap_beats_a_base_encoding_and_unreadable_differences() { + const SPARSE_BFCHAR: &str = "<21> <0063>\n<22> <006F>"; + let mut names = vec![None; 0x25]; + names[0x22] = Some("o"); + let read = |encoding: Object| { + decode_simple_font_string( + SPARSE_BFCHAR, + Some(sfnt_with_glyph_names(&names)), + Some(encoding), + &[0x21, 0x22], + ) + }; + assert_eq!(read(Object::Name(b"WinAnsiEncoding".to_vec())), "co"); + let base_only = Object::Dictionary(lopdf::dictionary! { + "Type" => "Encoding", + "BaseEncoding" => "WinAnsiEncoding" + }); + assert_eq!(read(base_only), "co"); + let differences = |name: &[u8]| { + Object::Dictionary(lopdf::dictionary! { + "Type" => "Encoding", + "BaseEncoding" => "WinAnsiEncoding", + "Differences" => Object::Array(vec![ + Object::Integer(0x21), Object::Name(name.to_vec()), + ]) + }) + }; + assert_eq!(read(differences(b"glyph9999")), "co"); + // A name that reads stays the code's reading. + assert_eq!(read(differences(b"x")), "xo"); + } + /// Codes 0x21..=0x2C are glyph indices; the ligature at 0x23 is mapped /// to its own index, `<0003>`, in place of a character. The CMap lists /// eight more codes than the strings below show, as a subset's CMap