Repository navigation
Conversation
e8ef98b to
e6b61eb
Compare
Carries upstream PR ledongthuc#61 (ToUnicode-first font decoding) so browser-printed PDFs stop extracting as glyph-index mojibake. Only go.mod and README differ from that PR; the fix commits are AJ Roetker's, unmodified. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ledongthuc
left a comment
There was a problem hiding this comment.
Thanks for this PR — the direction is correct and both fixes are spec-compliant (ToUnicode precedence per §9.10.2, and BaseEncoding + Differences per §9.6.6). I verified locally: it builds, go vet is clean, the existing tests pass, and it merges cleanly.
I'd like a few changes before merging:
-
Identity-Hfallback —Identity-His a 2-byte CMap for Type0/CID fonts, so decoding byte-by-byte throughpdfDocEncodingproduces garbage when ToUnicode is absent. This predates the PR, but since this line is being touched, can we leave it asnopEncoderfor this case (or explicitly note it's out of scope)? -
Unknown named encodings (
defaultcase) — this changed fromnopEncodertopdfDocEncoding, which silently alters behavior forSymbol,ZapfDingbats,StandardEncoding,Identity-V, etc. None of those equal PDFDocEncoding;Symbol/ZapfDingbatsespecially will decode to wrong (but plausible-looking) Latin text. Recommend keepingnopEncoderfor genuinely unknown names, or adding proper tables. -
MacExpertEncodingfallback — silently mapping it topdfDocEncodingis inaccurate; please at least log viaDebugOnor add a real table. -
Default
BaseEncoding= PDFDocEncoding — per spec the default should be the font's built-in encoding (usually StandardEncoding). The comment acknowledges the simplification, but consider adding aStandardEncodingtable since the 0x80–0x9F range differs from PDFDocEncoding. -
/.notdefin Differences —nameToRunehas no.notdef, so a/.notdefentry silently keeps the BaseEncoding value instead of mapping tonoRune. -
readCmapcan panic —readCmaphaspanic("missing beginbfchar")/panic("missing beginbfrange"). Now that ToUnicode is parsed for every font, this expands the panic surface;Content()has norecover(), so a malformed ToUnicode CMap could crash. Recommend converting those to the existingok = false/return nilpattern. -
Tests — the PR reports a big improvement but has no regression tests. Could you add a unit test for
newDictEncoder(BaseEncoding + Differences, incl. a gap and an out-of-range code) and a ToUnicode-precedence test (e.g., a synthetic PDF likeps_test.go)? -
Minor: the branch is ~20 commits behind master; it merges cleanly, but a rebase would be nice.
None of these are hard blockers except arguably (2) and (6). Happy to iterate.
e6b61eb to
5a79951
Compare
|
@ajroetker Nice work recently, The ToUnicode priority change and the BaseEncoding + Differences change are correct. The follow-up fixes for the review feedback are also good. Before we merge, please do the following:
When these items are done, we can review the PR again. |
The PDF specification states that ToUnicode CMap is the authoritative source for character-to-Unicode mapping. Previously, the library only checked ToUnicode for fonts with "Identity-H" encoding or null encoding, causing incorrect text extraction for many PDFs. This change: - Checks ToUnicode CMap first before falling back to Encoding - Falls back to pdfDocEncoding instead of nopEncoder for better compatibility with unknown encodings - Removes the now-redundant charmapEncoding() method This fixes text extraction issues where characters were being incorrectly decoded (e.g., '0' appearing as 'M') due to ToUnicode being ignored when an Encoding entry was present.
The PDF spec (section 9.6.6) requires that when an Encoding dictionary is present, the BaseEncoding (e.g., WinAnsiEncoding, MacRomanEncoding) should be applied first, then the Differences array overlays specific character code mappings on top. Previously, dictEncoder only looked at the Differences array and matched character codes one by one, which was both slow and incorrect for fonts that rely on BaseEncoding for most characters. This fix: - Builds a complete 256-entry lookup table at initialization time - Copies the BaseEncoding table first (defaulting to PDFDocEncoding) - Applies Differences array entries on top - Uses O(1) lookup instead of O(n) scanning during decoding Fixes font encoding corruption in PDFs where fonts use custom Encoding dictionaries with BaseEncoding + Differences (common in legal documents).
- Identity-H without a usable ToUnicode returns nopEncoder again: it is a 2-byte CMap for Type0/CID fonts, so byte-wise table decoding would produce garbage (proper CID decoding remains out of scope) - Unknown named encodings (Symbol, ZapfDingbats, Identity-V, ...) return nopEncoder instead of guessing PDFDocEncoding - MacExpertEncoding fallback is now logged via DebugOn - Add a StandardEncoding table (PDF 32000-1:2008 Table D.2) and use it as the default BaseEncoding in dictEncoder, since built-in encodings are usually StandardEncoding and its 0x27/0x60/0x80-0x9F ranges differ from PDFDocEncoding - Map /.notdef in Differences to noRune instead of keeping the base value - Convert readCmap's beginbfchar/beginbfrange panics to the existing ok = false pattern so a malformed ToUnicode CMap can't crash Content() - Add unit tests for newDictEncoder (BaseEncoding + Differences overlay, gaps, out-of-range codes, /.notdef, StandardEncoding default) and synthetic-PDF tests for ToUnicode precedence and malformed-ToUnicode fallback; update TestDictEncoder to the new constructor
- Reject codespace ranges wider than 4 bytes: m.space is a fixed [4] array, so a 5-byte range like <0000000000> <FFFFFFFFFF> previously caused an index-out-of-range panic — reachable from any untrusted PDF now that ToUnicode is parsed for every font - Detect truncated CMaps: reset the section counter after endbfchar/ endbfrange (previously only endcodespacerange reset it, so a stray end operator after a completed section escaped the missing-begin check) and treat a stream ending inside an open section as a parse failure so extraction falls back to /Encoding - Treat a CMap with no bfchar/bfrange mappings as a parse failure for the same reason: falling back to /Encoding beats decoding every code to the replacement character - Keep advancing the Differences code counter past out-of-range codes so a bad start code doesn't drop later entries that land in range - Add regression tests for all three cases
- Cap section entry counts against the operand stack size: Pop on an exhausted stack returns zero values, so an unchecked count like '9223372036854775807 beginbfchar' looped allocating unbounded entries from a tiny untrusted stream - Track which begin section is open so an end operator of the wrong kind (beginbfchar ... endbfrange) is rejected instead of matched - Require at least one codespace range: mappings without one can never match a code, so everything decoded to the replacement character instead of falling back to /Encoding - utf16Decode: drop a trailing odd byte instead of panicking on s[i+1] - cmap.Decode: an empty bfrange destination string decodes to noRune instead of panicking on b[len(b)-1] when scaling - Add regression tests for all five cases
- Recover from Interpret panics (unmatched end, begin on a non-dict, currentdict with no dictionary, ...) at the readCmap boundary so a malformed ToUnicode stream falls back to /Encoding instead of crashing Content() or failing the whole page's GetPlainText - Validate section entry counts against operands pushed since the section began, not the whole stack, so an entry missing an operand can't pass by borrowing unrelated values like begincmap's dict - Decode destinations that produce no UTF-16 units (a malformed single byte) to noRune instead of silently deleting the source character; genuinely empty destinations still map to nothing - Rename a local that shadowed the package-level name type - Add regression tests for the three behavioral fixes
…oding PDF 32000-1:2008 9.6.6.1 makes StandardEncoding the implicit base only for nonsymbolic fonts; symbolic fonts use their built-in encoding. We don't parse font programs, so symbolic fonts (FontDescriptor Symbolic flag, or the standard Symbol/ZapfDingbats fonts) keep the previous raw-byte behavior for codes outside Differences instead of mapping 0x80-0x9F and other StandardEncoding gaps to U+FFFD.
testdata/font_encoding.pdf is a small, uncompressed PDF reproducing both bugs: a subset font whose text is only recoverable via ToUnicode (master decodes the case number with WinAnsi as garbage), and an Encoding dictionary whose codes outside Differences must come from the WinAnsi BaseEncoding (master returns C1 control characters for 0x92 and 0x96). The test fails on master and passes on this branch. The generator is committed alongside it.
01784f4 to
f857104
Compare
…nts without ToUnicode
It isn't one of the BaseEncoding values Table 114 allows, but some producers write it. Previously it fell into the absent-BaseEncoding branch, so symbolic fonts got the raw-byte base instead.
Adds a fontPDF helper (simpleFontPDF now wraps it) so the font entries and shown string can vary.
|
Another real-world case that this PR fixes: PDFs printed from Chrome ("Save as PDF") with Korean text embed the Hangul glyphs as a Type 3 font whose Preferring |
Summary
This PR improves how font encodings are handled, which fixes text extraction for PDFs with complex font setups. It has two main fixes:
/ToUnicodefirst, and fall back to/Encodingonly if it is missing or does not parse.BaseEncodingand then overlay theDifferencesarray.The expanded CMap parsing is also hardened, because every font now goes through it (see "Robustness" below).
Problem
Characters were decoded incorrectly in some PDFs (for example, scanned court filings):
/Encoding /WinAnsiEncodingas well as a/ToUnicodeCMap. We ignored the CMap, so0came out asM, and so on.Differenceswas applied. Every other code was passed through as its raw byte, not looked up inBaseEncoding. So WinAnsi characters such as’(0x92) and–(0x96) came out as C1 control characters.Solution
The code samples below match the code in this PR, trimmed for readability (
DebugOnlogging and some comments are left out).Fix 1: ToUnicode priority
The PDF spec (32000-1:2008, section 9.10.2) makes ToUnicode the primary way to map character codes to Unicode.
getEncodernow checks it first and falls back to/Encodingonly when there is no ToUnicode stream or whenreadCmaprejects it:Because ToUnicode is now checked up front, master's
charmapEncodinghelper is no longer needed and has been removed. TheUniGB-UCS2-Hcase from #56 is kept.Fix 2: BaseEncoding + Differences
The PDF spec (section 9.6.6) says an Encoding dictionary starts from
BaseEncodingand hasDifferencesapplied on top.dictEncodernow builds a 256-entry table once and looks up each byte in O(1). Before, it scanned theDifferencesarray for every byte:A full
standardEncodingtable (Table D.2) is added totext.go.Behavior change: fonts with Differences but no BaseEncoding
This change is intended. Previously, any code not listed in
Differenceswas returned as its raw byte. Now the implicit base depends on the font type, as in spec 9.6.6.1:quoteright/quoteleft). Codes that StandardEncoding leaves undefined, including 0x80–0x9F, now decode to U+FFFD. Before, they came out as C1 control characters, which were never correct text either.SymbolandZapfDingbatsfonts count as symbolic. The spec says these fonts use their built-in encoding, which we can't read without parsing the font program, so we keep the old behavior and they do not start returning U+FFFD for 0x80–0x9F.Differencesis still applied to symbolic fonts, as before. Some readings of the spec say a symbolic font's/Encodingshould be ignored entirely, but honoringDifferencesmatches master and what common readers such as Acrobat do.Tests:
TestSymbolicFontDifferencesWithoutBaseEncodingandTestFontIsSymbolicinencoding_test.go.Edge cases this doesn't special-case, because we don't parse embedded font programs (true before this PR as well):
isSymbolic()). Codes inDifferencesdecode by glyph name, and the rest use StandardEncoding. Codes outsideDifferencesnormally have no glyph in a Type3 font anyway./Encoding(for example, symbolic fonts that rely on their built-in (3,0) cmap) take theNullpath and decode as PDFDocEncoding, the same as on master. Symbolic detection doesn't come into play there.Behavior change: Identity-H without a usable ToUnicode
On master, an
Identity-Hfont with no ToUnicode decoded each byte of the 2-byte CIDs as PDFDocEncoding. A malformed ToUnicode could panic and fail the whole page's text extraction (for example,endbfcharwithoutbeginbfchar, or a codespace range wider than 4 bytes), or come out as U+FFFD (for example, a CMap truncated inside a section). Both cases now pass the raw bytes through (nopEncoder). Neither is a real CID decode, but this way two-byte codes aren't split into unrelated characters. Proper CID decoding is out of scope. In practice Identity-H fonts almost always include a ToUnicode CMap, and that path works as before."Raw bytes" here means the decoder's output:
GetPlainTextre-encodes text as UTF-8 (unchanged from master), so passed-through bytes that aren't valid UTF-8 appear there as U+FFFD.Tests for these fallbacks:
TestEncoderFallbacksWithoutToUnicodeandTestIdentityHMalformedToUnicodePassesThroughinencoding_test.go.Robustness
Every font with a
/ToUnicodestream now goes throughreadCmap, so its handling of malformed input was hardened, with a regression test for each case:readCmapreturns nil rather than panicking for: malformedbfchar/bfrangesections, codespace ranges wider than 4 bytes, truncated CMaps, sections that don't match (beginbfchar … endbfrange), counts larger than the operand stack (DoS), entries that are missing operands, CMaps with no codespace range or no mappings, and panics from the PostScript interpreter (recovered at thereadCmapboundary). In each case extraction falls back to/Encoding.bfrangedestinations produce U+FFFD rather than panicking.Differencesparser ignores codes outside 0–255 without losing track of later entries, and maps/.notdefto U+FFFD.Testing
go build ./...,go vet ./...andgo test ./...all pass on this branch, rebased onto current master.Regression PDF:
testdata/font_encoding.pdfis 1.9 KB and uncompressed so it can be read by eye.TestFontEncodingRegressionPDFreads it. It has one page with two fonts, built to match the documents where I found the bug:/Encoding /WinAnsiEncoding+/ToUnicode!"#$%&'&()*+),-.//)012Case 1:15-cv-07433-LAP/BaseEncoding /WinAnsiEncoding+/Differences [1 /zero … /nine]Defendants\^R reply \^V filed 03/21/16Defendants’ reply – filed 03/21/16The test fails on master and passes on this branch. To regenerate the PDF, run
go run testdata/font_encoding_gen.go testdata/font_encoding.pdf.About the "104 pages → 2 pages" figure in an earlier version of this description: it was measured on a private set of court filings that can't be shared, so it can't be reproduced from this repository. The regression PDF above recreates the two font setups behind that damage, and it is the reproducible evidence for this fix.