M2: a CMap's bfchar mappings read codes at their codespace length (view.render-cid-fonts)

A CMap that writes its CID mappings as `bfchar` was looked up without
the code's byte length, so the first byte of a two-byte code matched a
two-byte entry of the same value and drew as its own glyph: `<fe96>`
drew as "þ–" (PDFBOX-1282, Characters_Arial.pdf page 3). The bfchar
fallback of `lookup_cid_code` now applies only when the codespace
ranges give the code the length being tried (ISO 32000-2 §9.7.6.2),
byte by byte; a CMap without codespace ranges keeps the old fallback.
An unmapped code still advances one byte. Vendored hayro-cmap patch 4.

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 17:03:56 +08:00
co-authored by Claude Opus 5.5
parent f42fda7342
commit 37e5708ed2
4 changed files with 143 additions and 7 deletions
+34
View File
@@ -5503,6 +5503,40 @@ fn a_font_whose_post_version_has_swapped_halves_is_still_used() {
assert_eq!(alternate([0, 0, 0, 2]), alternate([0, 2, 0, 0]), "and also with swapped halves");
}
/// An embedded CMap whose codes are all two bytes long (codespace `<0000> <FFFF>`) and whose
/// mappings are written as `bfchar` reads `<2041>` as the one code 0x2041 (ISO 32000-2
/// §9.7.6.2: the codespace ranges decide how many bytes form a code). It drew as two
/// glyphs: the lone first byte 0x20 matched the two-byte entry `<0020>` (PDFBOX-1282,
/// `Characters_Arial.pdf` page 3, codes 0x20AC to 0xFFFC). Vendored hayro-cmap patch 4.
#[test]
fn a_bfchar_cmap_reads_codes_of_its_codespace_length() {
let font = include_bytes!("../../../../assets/fonts/JetBrainsMono-Regular.ttf");
let render = |encoding: &str, text: &str| -> Vec<u8> {
let cmap = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap
/CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> def
/CMapName /Test-H def /CMapType 1 def
1 begincodespacerange <0000> <FFFF> endcodespacerange
3 beginbfchar <0020> <0005> <0041> <0006> <2041> <0024> endbfchar
endcmap CMapName currentdict /CMap defineresource pop end end";
let resources = format!(
"<< /Font << /F1 << /Type /Font /Subtype /Type0 /BaseFont /JetBrainsMono-Regular /Encoding {encoding} /DescendantFonts [<< /Type /Font /Subtype /CIDFontType2 /BaseFont /JetBrainsMono-Regular /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /DW 600 /CIDToGIDMap /Identity /FontDescriptor << /Type /FontDescriptor /FontName /JetBrainsMono-Regular /Flags 4 /FontBBox [-1000 -300 1000 1000] /ItalicAngle 0 /Ascent 1020 /Descent -300 /CapHeight 730 /StemV 80 /FontFile2 5 0 R >> >>] >> >> >>"
);
let content = format!("BT /F1 40 Tf 10 20 Td {text} Tj ET");
let font_dict = format!("<< /Length {} >>", font.len());
let cmap_dict = format!(
"<< /Type /CMap /CMapName /Test-H /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /Length {} >>",
cmap.len()
);
let page = render_first_page(image_cache_pdf(&content, &resources, &[(&font_dict, font), (&cmap_dict, cmap)]));
page.rgba.to_vec()
};
let blank = render("/Identity-H", "<>");
let reference = render("/Identity-H", "<0024>");
assert_ne!(reference, blank, "the reference glyph draws");
assert_eq!(render("6 0 R", "<2041>"), reference, "<2041> is one code, CID 0x24");
assert_ne!(render("/Identity-H", "<00050006>"), reference, "the two-glyph misreading looks different");
}
/// A page whose content expands far beyond its compressed stream stops at the expansion budget,
/// which is 64 times the compressed size and at least `MAX_DECODED_STREAM`, and the page says so.
#[test]
+2 -2
View File
@@ -794,8 +794,8 @@ milestone = "M2"
status = "partial"
commands = []
tools = []
evidence = ["crates/render/src/raster/tests.rs::huge_cid_width_ranges_terminate", "crates/render/src/raster/tests.rs::non_embedded_japanese_cid_fonts_draw_with_a_craft_fonts_face", "crates/render/src/raster/tests.rs::mincho_substitute_covers_half_width_katakana", "crates/render/src/raster/tests.rs::non_embedded_chinese_and_korean_cid_fonts_draw_with_an_installed_face", "crates/engine/src/tests.rs::installed_cjk_fonts_draw_in_the_viewer_but_not_in_an_export", "crates/render/src/text/tests.rs::identity_to_unicode_names_give_text", "crates/fonts/src/pdf/tests.rs::identity_to_unicode_names_read_codes_as_unicode", "crates/render/src/raster/tests.rs::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen"]
notes = "CID-to-Unicode tables are M2.4; a non-embedded Adobe-Japan1 font is drawn in a craft-fonts Japanese face (Mincho or Gothic by name, else by the descendant font descriptor's Serif flag; bold Gothic for a Bold name or a `/FontWeight` of 600 or more; BIZ UDMincho for Mincho, which covers half-width katakana) when built with craft-fonts; the desktop viewer can fall back to an installed CJK face for GB1, CNS1, Korea1 and Japan1, including GBK-EUC-H TrueType CID fonts without ToUnicode. Windows preserves installed SimSun, SimHei, KaiTi and FangSong families, including subset-prefixed Founder aliases. Combine thumbnails and accessibility figure previews use the same on-screen fallback. System faces are display-only, disabled by PDFCRAFT_SYSTEM_FONTS=0, and not used by export, printing, OCR or automation; machines without a candidate and web builds remain unsupported. A `/ToUnicode` given as the name `/Identity-H` or `/Identity-V` (WSCAD) reads codes as Unicode for selection, search and editing, without control characters. A non-embedded Identity font whose `/ToUnicode` gives a soft hyphen (U+00AD, as Windows exporters write Arial's hyphen) draws the standard substitute's hyphen (#5888). Any other character the substitute lacks still falls back to the CID taken as one of the substitute's glyph indexes, an unrelated glyph (#3529, #5726)"
evidence = ["crates/render/src/raster/tests.rs::huge_cid_width_ranges_terminate", "crates/render/src/raster/tests.rs::non_embedded_japanese_cid_fonts_draw_with_a_craft_fonts_face", "crates/render/src/raster/tests.rs::mincho_substitute_covers_half_width_katakana", "crates/render/src/raster/tests.rs::non_embedded_chinese_and_korean_cid_fonts_draw_with_an_installed_face", "crates/engine/src/tests.rs::installed_cjk_fonts_draw_in_the_viewer_but_not_in_an_export", "crates/render/src/text/tests.rs::identity_to_unicode_names_give_text", "crates/fonts/src/pdf/tests.rs::identity_to_unicode_names_read_codes_as_unicode", "crates/render/src/raster/tests.rs::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen", "crates/render/src/raster/tests.rs::a_bfchar_cmap_reads_codes_of_its_codespace_length"]
notes = "CID-to-Unicode tables are M2.4; a non-embedded Adobe-Japan1 font is drawn in a craft-fonts Japanese face (Mincho or Gothic by name, else by the descendant font descriptor's Serif flag; bold Gothic for a Bold name or a `/FontWeight` of 600 or more; BIZ UDMincho for Mincho, which covers half-width katakana) when built with craft-fonts; the desktop viewer can fall back to an installed CJK face for GB1, CNS1, Korea1 and Japan1, including GBK-EUC-H TrueType CID fonts without ToUnicode. Windows preserves installed SimSun, SimHei, KaiTi and FangSong families, including subset-prefixed Founder aliases. Combine thumbnails and accessibility figure previews use the same on-screen fallback. System faces are display-only, disabled by PDFCRAFT_SYSTEM_FONTS=0, and not used by export, printing, OCR or automation; machines without a candidate and web builds remain unsupported. A `/ToUnicode` given as the name `/Identity-H` or `/Identity-V` (WSCAD) reads codes as Unicode for selection, search and editing, without control characters. A non-embedded Identity font whose `/ToUnicode` gives a soft hyphen (U+00AD, as Windows exporters write Arial's hyphen) draws the standard substitute's hyphen (#5888). Any other character the substitute lacks still falls back to the CID taken as one of the substitute's glyph indexes, an unrelated glyph (#3529, #5726). An embedded CMap that writes its CID mappings as `bfchar` reads each code at the length its codespace ranges give it (ISO 32000-2 §9.7.6.2), so a two-byte code is one glyph, not two (hayro-cmap patch 4)"
[[feature]]
id = "view.render-image-codecs"
+1 -1
View File
File diff suppressed because one or more lines are too long
+106 -4
View File
@@ -359,8 +359,9 @@ const MAX_NESTING_DEPTH: u32 = 16;
#[derive(Debug, Clone)]
pub struct CMap {
metadata: Metadata,
// Note that we don't actually use this, because Acrobat _seems_ to ignore
// it, too.
// Note that the CID lookup doesn't check this, because Acrobat _seems_ to
// ignore it, too. PdfCraft patch (4): the `bfchar` fallback of
// `lookup_cid_code` does check it (see there).
_codespace_ranges: Vec<CodespaceRange>,
cid_ranges: PartitionedRanges,
notdef_ranges: PartitionedRanges,
@@ -449,7 +450,16 @@ impl CMap {
// See pdfjs_bug920426, it uses bf chars for encoding the characters
// of a text-showing operator, so try that as well. We don't want to
// recurse though, only check the bf chars of this level.
if let Some(BfString::Char(lookup)) = self.lookup_bf_string_inner(code, false) {
//
// PdfCraft patch (4): bf entries don't record how many bytes their code
// has, so the fallback only applies when the codespace ranges give
// the code `byte_len` bytes (ISO 32000-2 §9.7.6.2). Otherwise the
// first byte of `<fe96>` matched a `<00fe>` entry and a two-byte code
// drew as two glyphs. A CMap with no codespace ranges keeps the old
// fallback.
if self.codespace_allows(code, byte_len, 0)
&& let Some(BfString::Char(lookup)) = self.lookup_bf_string_inner(code, false)
{
return Some(lookup as u32);
}
@@ -459,6 +469,32 @@ impl CMap {
.and_then(|b| b.lookup_cid_code(code, byte_len))
}
/// PdfCraft patch (4): whether a code of `byte_len` bytes lies in a codespace range of that
/// length (each byte within the range's bounds for its position, ISO
/// 32000-2 §9.7.6.2). A CMap without codespace ranges defers to its base,
/// and one with none anywhere allows every code.
fn codespace_allows(&self, code: u32, byte_len: u8, depth: u32) -> bool {
if self._codespace_ranges.is_empty() {
return match &self.base {
Some(base) if depth < MAX_NESTING_DEPTH => {
base.codespace_allows(code, byte_len, depth.saturating_add(1))
}
_ => true,
};
}
self._codespace_ranges.iter().any(|range| {
range.number_bytes == byte_len
&& (0..u32::from(byte_len)).all(|i| {
// `byte_len` is at most 4 here (the range's own length),
// so `shift` is at most 24.
let shift = (u32::from(byte_len) - 1 - i) * 8;
let byte = |v: u32| v.checked_shr(shift).map_or(0, |b| b & 0xFF);
byte(range.low) <= byte(code) && byte(code) <= byte(range.high)
})
})
}
/// Look up a bf string in the cmap. This is usually
/// used for mapping character codes to Unicode codepoints in a
/// `ToUnicode` cmap.
@@ -594,7 +630,6 @@ impl HasRange for BfRange {
/// A codespace range defining valid character code byte sequences.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub(crate) struct CodespaceRange {
pub(crate) number_bytes: u8,
pub(crate) low: u32,
@@ -1128,6 +1163,73 @@ endbfchar
);
}
/// Parse `body` after the metadata of [`PREAMBLE`] but without its
/// codespace ranges.
fn parse_without_codespace(body: &[u8]) -> CMap {
let mut data = Vec::new();
data.extend_from_slice(
br#"/CIDSystemInfo 3 dict dup begin
/Registry (Adobe) def
/Ordering (Identity) def
/Supplement 0 def
end def
/CMapName /Test def
"#,
);
data.extend_from_slice(body);
CMap::parse(&data, |_| None).unwrap()
}
/// PdfCraft patch (4) test: a `bfchar` entry used as a CID mapping matches only
/// codes of the length the codespace ranges give it (ISO 32000-2 §9.7.6.2).
#[test]
fn bfchar_cid_fallback_takes_the_codespace_length() {
let cmap = parse_without_codespace(
br#"
1 begincodespacerange
<0000> <FFFF>
endcodespacerange
2 beginbfchar
<0020> <0020>
<2041> <0042>
endbfchar
"#,
);
// The first byte of <2041> alone is not a code of this CMap.
assert_eq!(cmap.lookup_cid_code(0x20, 1), None);
assert_eq!(cmap.lookup_cid_code(0x2041, 2), Some(0x42));
assert_eq!(cmap.lookup_cid_code(0x0020, 2), Some(0x20));
// ToUnicode-style lookups are unchanged.
assert_eq!(cmap.lookup_bf_string(0x20), Some(BfString::Char(' ')));
}
/// PdfCraft patch (4) test: mixed codespaces give each code its own length,
/// byte by byte, and a CMap without codespace ranges keeps the fallback.
#[test]
fn bfchar_cid_fallback_checks_each_byte_of_the_codespace() {
let cmap = parse_without_codespace(
br#"
2 begincodespacerange
<00> <80>
<8140> <9FFC>
endcodespacerange
3 beginbfchar
<41> <0041>
<8141> <0100>
<8220> <0101>
endbfchar
"#,
);
assert_eq!(cmap.lookup_cid_code(0x41, 1), Some(0x41));
assert_eq!(cmap.lookup_cid_code(0x8141, 2), Some(0x100));
// 0x8220 lies numerically between 0x8140 and 0x9FFC, but its second
// byte 0x20 is below 0x40.
assert_eq!(cmap.lookup_cid_code(0x8220, 2), None);
let no_codespace = parse_without_codespace(b"1 beginbfchar <0020> <0020> endbfchar\n");
assert_eq!(no_codespace.lookup_cid_code(0x20, 1), Some(0x20));
}
#[test]
fn stream_cut_off_inside_a_block_keeps_the_entries_before_it() {
let cmap = parse_with_preamble(b"1 beginbfchar <41> <0058>\n<42> <00");