Skip to content

fix(extractor): a simple font's sparse ToUnicode CMap reads the codes its program does not - #621

Open
tlq5l wants to merge 4 commits into
firecrawl:mainfrom
tlq5l:fix/sparse-tounicode-simple-font
Open

tlq5l wants to merge 4 commits into
firecrawl:mainfrom
tlq5l:fix/sparse-tounicode-simple-font

Conversation

@tlq5l

@tlq5l tlq5l commented Oct 6, 2026 •

Copy link
Copy Markdown

Fixes #620.

Summary

A ToUnicode CMap of fewer than ten entries yields the primary role to the
embedded program's reading and is kept as the alternative (cmap_entry).
The byte-level reading of a simple font never looks at that alternative.
So a code the CMap maps, but whose glyph the program cannot name, falls
through to the printable-byte guess and reads as its byte value.

macOS Quartz writes such fonts: the letters of a heading go into a small
TrueType subset with codes from 0x21 and a short ToUnicode CMap. The
reproduction in #620 (Times New Roman Bold):

text
source BIÊN BẢN
before BIÊ! BẢ!
after BIÊN BẢN

pdftotext reads the same file right.

Fix

In the byte-level reading of a simple font, the demoted sparse CMap is
now read right after a /Differences name that maps (which still wins),
and before a /Differences name that cannot be mapped and before the
base encoding. A ToUnicode CMap says what a code is, so it beats the
encoding table for the codes it maps; the font program's own reading
still comes first. It is limited to simple fonts because their only
alternative is the demoted sparse CMap; the subset remap is made for
Identity-H/V composite fonts alone.

A code the sparse CMap maps to a control character is marked like any
other CMap's control destination: if nothing else reads it, it shows
U+FFFD instead of a guess from its byte value (second commit, from
cubic's review).

Tests

  • Unit: a_simple_fonts_sparse_cmap_reads_the_codes_the_program_does_not
    (synthetic font, no fixture file). Reads !o$$ before, coee after;
    a code both read still takes the program's reading.
  • Unit: a_simple_fonts_sparse_cmap_marks_its_control_destination.
    A code the sparse CMap maps to U+0003 reads # before the second
    commit, U+FFFD after.
  • Unit: a_simple_fonts_sparse_cmap_beats_a_base_encoding_and_unreadable_differences.
    <21> reads c under WinAnsi and under an unreadable /Differences
    name (Devin's case).
  • cargo fmt --all -- --check, cargo clippy -- -D warnings,
    cargo clippy --features ocr -- -D warnings, cargo test,
    scripts/version.py --check, script tests.
  • pdf2md output of all 44 PDFs in tests/fixtures/ was byte-identical
    before and after when last measured, before the third unit test above
    was added; not re-measured since.
  • The PDF from A simple font's short ToUnicode CMap is ignored for codes the embedded program does not name: BIÊN BẢN reads as BIÊ! BẢ! #620 and two more Quartz-made PDFs with the fault now read
    right; four from the same generator that read right before are unchanged.

Not verified


Summary by cubic

Fixes sparse ToUnicode CMap entries being ignored for simple fonts, so codes the embedded program can't name no longer misread as their byte values.

  • Reads the sparse CMap before the base encoding and unreadable /Differences names, after the program's reading and mapped Differences.
  • The program's own reading still takes precedence where both map the same code.
  • A code the CMap maps to a control character now reads as U+FFFD instead of its byte value.

Written for commit dacbc57. Summary will update on new commits.

View guided diff


Devin Review

cubic-dev-ai[bot]

This comment was marked as resolved.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

Devin Review

Comment thread src/extractor/fonts.rs Outdated
Comment on lines +2666 to +2673
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,
_ => {}
}

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

You were right: a declared base encoding, and an unreadable /Differences name, were read before the sparse ToUnicode entry, so <21> -> c still read ! under WinAnsi. Fixed in e5cb9fd. The sparse entry is now read after a readable /Differences name and before the base encoding and unreadable names. The new test a_simple_fonts_sparse_cmap_beats_a_base_encoding_and_unreadable_differences covers both cases and failed before the change.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

0 issues found across 2 files (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Shadow auto-approve: would auto-approve. Fixes a simple font's sparse ToUnicode CMap being ignored by reading it before the printable-byte guess, with tests pinning the corrected codes and control-character handling. The change is focused and clearly corrects misread text.

Turn on auto-fix | Re-trigger cubic

tlq5l added 2 commits October 19, 2026 23:26
… its program does not

A ToUnicode CMap of fewer than ten entries yields the primary role to the
embedded program's own reading and is kept as the alternative. The
byte-level reading of a simple font never looked at that alternative, so
a code the CMap maps but the program does not read (its glyph has no
usable name) fell through to the printable-byte guess: macOS Quartz
writes such fonts for the few accented capitals of a heading, and
`CÔNG TY` read as `CÔ!G TY`.

The alternative is now read before that guess, after the program's
reading, the Differences and the base encoding, so only text that was a
guess changes. A simple font has no other alternative: the subset remap
is made for Identity-H/V composite fonts alone.
…reads as U+FFFD

The sparse ToUnicode CMap of a simple font was read for text alone. A
code it maps to a control character, which nothing repaired because the
program does not name the glyph, fell through to the printable-byte
guess and read as its byte value.

The code is now marked as any other CMap's control destination is, so
it reads as U+FFFD.
@tlq5l

tlq5l commented Oct 19, 2026

Copy link
Copy Markdown
Author

Rebased onto main. #618 conflicted only in the CHANGELOG, so both entries are kept. On current main the two new tests still fail ("!o$$" instead of "coee"), so #620 is still open there. With this PR, cargo fmt --check, cargo clippy -- -D warnings and the full cargo test pass.

@tlq5l
tlq5l force-pushed the fix/sparse-tounicode-simple-font branch from ebf28a2 to 567b863 Compare October 19, 2026 16:56
…its base encoding and unreadable Differences

Agent: claude

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All reported issues were addressed across 2 files (changes from recent commits).

Shadow auto-approve: would not auto-approve. Auto-approval skipped because cubic reviewed only this push, not the earlier force-push. Comment @cubic review to review the whole pull request.
Tip: Review your code locally with the cubic CLI to iterate faster.

View guided diff | Re-trigger cubic

Comment thread src/extractor/fonts.rs Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

0 issues found across 1 file (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Shadow auto-approve: would not auto-approve. Auto-approval skipped because cubic reviewed only this push, not the earlier force-push. Comment @cubic review to review the whole pull request.
Tip: Get a Slack DM when cubic finishes reviewing your PRs. Connect Slack

View guided diff | Re-trigger cubic

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A simple font's short ToUnicode CMap is ignored for codes the embedded program does not name: BIÊN BẢN reads as BIÊ! BẢ!

1 participant