Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ version. A separate release pull request bumps the manifests with
version and date. Earlier releases are described in their
[GitHub releases](https://github.com/firecrawl/pdf-inspector/releases).

## [Unreleased]

### Fixed

- 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 that guess, and a code it maps to a control character
reads as U+FFFD.

## [1.25.2] - 2026-09-28

Changes since 1.25.1.
Expand Down
66 changes: 65 additions & 1 deletion src/extractor/fonts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2657,7 +2657,22 @@ pub(crate) fn extract_text_from_operand(
}
}
}
// 6. Printable single-byte fallback — a guess at a
// 6. 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`): what it says of a code nothing
// above reads is no guess, and a code it maps to
// no text is not guessed at below either.
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,
_ => {}
}
Comment on lines +2666 to +2673

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Sparse mappings lose to font encodings

With a declared base encoding, entry.remapped never reads codes that entry.primary misses; the base returns first. A sparse ToUnicode mapping from 0x21 to c still reads ! under WinAnsi, and unreadable /Differences names drop mapped codes.

Learn more

A sparse ToUnicode CMap is moved into remapped when a font-program reading takes the primary role in cmap_entry. The new lookup occurs after differences_reading and the base-encoding return. Thus an explicit /BaseEncoding supplies a character even for a code the sparse CMap maps differently; an unreadable Differences name exits before the CMap can read the code at all. Control destinations in the sparse map likewise cannot be marked when the base encoding has already returned.

Example: A simple font with a sparse <21> <0063> ToUnicode entry, an embedded program that reads only code 0x22, and /BaseEncoding /WinAnsiEncoding reads byte 0x21 as ! rather than c. With an unresolved /Differences name at 0x21 and another readable name keeping the encoding active, it omits c altogether.

Recommended fix: After the primary program reading, consult the demoted ToUnicode map before returning a base encoding or discarding an unreadable Differences code. Preserve the existing priority for a Differences name that actually identifies a readable glyph, and apply control-destination handling consistently.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

}
// 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 {
Expand Down Expand Up @@ -6024,6 +6039,55 @@ 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"
);
}

/// 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
Expand Down