Decode PDF text strings in metadata and attachment names - #8
Merged
Conversation
Route /Info Title/Author/Subject/Keywords/Creator/Producer and filespec /UF,/F through one shared decoder, so UTF-16BE-with-BOM values surface as correct UTF-8 instead of hex digits in the CLI and GUI. The decoder guarantees valid UTF-8 on every branch: an undecodable value falls back to a recoverable raw rendering rather than being dropped or mangled into U+FFFD by json.Marshal. Binary fields (/Params /CheckSum, signature /Contents, trailer /ID) and every raw string renderer including tree.go scalarDisplay stay untouched, pinned by falsifiable guards. Plain-text output stays ASCII-only via a conditional QuoteToASCII escape; all-ASCII values render byte-identically to before. # Conflicts: # testdata/correctness/README.md
There was a problem hiding this comment.
Pull request overview
This PR fixes mojibake for human-readable PDF text strings by routing selected metadata and embedded-filespec display fields through a shared PDF text-string decoder, while keeping binary-carrying fields and raw renderers unchanged. It also makes the CLI plain-text surfaces ASCII-safe without changing existing all-ASCII output.
Changes:
- Introduces
internal/pdfcore/textstring.gowithdecodeTextStringOKandtextStringOrRaw, and wires decoding into/Infotext keys and filespec/UFand/F. - Escapes non-ASCII/non-printable values on CLI plain-text surfaces via
asciiSafe, including embedded-file table cells. - Adds unit, integration, and presenter tests plus a documented correctness fixture to pin decode semantics, boundaries, and fallback behavior.
Reviewed changes
Copilot reviewed 13 out of 14 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/14-4-shared-text-string-decoder/text_string_decoder_test.go | New black-box CLI integration tests pin decoded JSON and ASCII-only plain output behavior. |
| tests/14-4-shared-text-string-decoder/helpers_test.go | Harness utilities to build/run the CLI and parse JSON outputs against the fixture. |
| tests/14-4-shared-text-string-decoder/go.mod | New standalone test module for the acceptance suite. |
| testdata/correctness/README.md | Documents the new text-string-encoding.pdf fixture and its byte-level intent. |
| internal/pdfcore/validate.go | Keeps XMP validation using the shared decoder after moving it into a helper file. |
| internal/pdfcore/textstring.go | New shared PDF text-string decode helper plus safe display fallback (textStringOrRaw). |
| internal/pdfcore/textstring_test.go | Extensive unit coverage for decoder semantics, call-site wiring, and binary boundaries. |
| internal/pdfcore/metadata.go | Decodes the six /Info text keys via textStringOrRaw while leaving date keys raw. |
| internal/pdfcore/embedded.go | Decodes filespec /UF and /F display names while keeping /Params fields raw. |
| cmd/cli/output.go | Adds asciiSafe to conditionally escape non-ASCII/non-printable bytes on plain output. |
| cmd/cli/output_test.go | Adds tests pinning asciiSafe behavior and XMP-vs-Info plain output split. |
| cmd/cli/cmd_metadata.go | Applies asciiSafe to Info block values on the plain dump metadata surface. |
| cmd/cli/cmd_embedded.go | Escapes embedded table cells and adds a --json hint when name matching may differ. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…ral space The stringValue godoc called /Params /ModDate an ASCII date. ISO 32000-1 7.9.4 defines a date as a text string, so it may legally be UTF-16BE - leaving it raw is a scope decision, not a safety requirement. Split the comment so the corrupt-if-decoded fields read differently from the not-wired-yet ones. anyNameDiffersFromDisplay used TrimSpace where it meant a plain space; every other byte TrimSpace strips is already caught by the asciiSafe clause.
anovik
reviewed
Aug 7, 2026
Remove historical narrative, review-process references and traceability IDs from the comments and assertion messages added by this change.
Rename tests/14-4-shared-text-string-decoder to tests/shared-text-string-decoder, matching the unnumbered sibling suites, and remove the remaining traceability references from the fixture README.
Contributor
Author
|
@anovik Done — went through every comment this branch adds.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description and links
PDF text strings are commonly UTF-16BE with a leading BOM, or PDFDocEncoded. We were returning several of them as raw bytes, so a non-ASCII document title or attachment name came out as mojibake in both the CLI and the GUI. A German or CJK
/Info /Titleshowed up asFEFF0052006500630068...indump metadata --json, which is indistinguishable from a document that genuinely has that string as its title.This routes the human-text fields through one shared decoder so a string field in the machine contract is actually the string.
Technical changes
decodeTextStringout ofvalidate.gointo a newinternal/pdfcore/textstring.go, and addeddecodeTextStringOKwhich returns an explicit success bit alongside the value.checkXMPMetadatastill calls the same symbol with no behavior change.textStringOrRawwrapper for display fields. It falls back to the raw rendering when a decode fails, so an undecodable field degrades to visible mojibake instead of disappearing from the output.collectInfoFieldsfor the six/Infotext keys (Title, Author, Subject, Keywords, Creator, Producer) and intoembeddedFileFromFilespecfor the filespec/UFand/Fdisplay names.asciiSafetocmd/cli/output.goand applied it to the plain-text Info values and the embedded-file table's Name, Relationship and MIME cells. It only escapes when a value carries a byte outside0x20-0x7e, so existing all-ASCII output is byte-identical.dump embedded --namewith no match now prints a hint pointing at--jsonwhen the document has names the table can't show verbatim.Considerations
The fallback encoding is Latin-1, not CP1252, despite pdfcpu's function being named
CP1252ToUTF8— it'sutf8.EncodeRune(rune(s[i]))per byte, an identity map with a misleading name.StringLiteral("\x80")decodes to U+0080, not the CP1252 euro sign. I went back and forth on this one and only settled it by running the decoder. Accepting pdfcpu's behavior as-is rather than hand-rolling an encoding table; the unit table pins what it actually does so nobody "fixes" it later.Two things deliberately left alone:
tree.goscalarDisplayrenders every string in the object tree and has no key context, so it can't tell a text/Titlefrom a binary/ID. Routing it through a text decoder would corrupt binary displays. Decoding there needs key-aware plumbing and is a separate change./Params /CheckSum, signature/Contentsand/Cert, the trailer/ID.The decoder now guarantees valid UTF-8 on both branches.
json.Marshalsilently substitutes one U+FFFD per invalid byte, which destroys the value on the surface that's supposed to be machine-readable — worse than the hex rendering it replaced, because hex is at least recoverable. A decode that succeeds but yields invalid UTF-8 counts as a failure, and a raw fallback that isn't valid UTF-8 gets hex-encoded. Worth noting the second case predates this change; I fixed it because leaving one branch of a new guard unvalidated didn't make sense.Also filed follow-ups for a few adjacent things I found and didn't fix here: PDF date strings are legally text strings so a
<FEFF...>/ModDatestill renders as hex;tableWriterhas no cell cap so an escaped CJK name pushes later columns off screen; andcmd_font.go's roster table has the same unescaped-cell issue this fixes fordump embedded.How was this tested?
go vet ./...clean,golangci-lint runreports 0 issuesgo test -race ./...— 7/7 packagestests/*/— 49/49npx tsc --noEmitclean,npm test833/833 in 59 files,eslint . --max-warnings 0cleaninternal/pdfcore/textstring_test.go, 9 CLI integration tests intests/shared-text-string-decoder/against the built binary, 4 presenter tests incmd/cli/output_test.gotestdata/correctness/text-string-encoding.pdfwith a UTF-16BE/Info /Titleand a non-ASCII filespec/UF, documented byte-by-byte in the READMEdump metadataanddump embeddedagainst the fixture, both with and without--json, plusdump object --refanddump treeto confirm the raw renderers are unchangedI mutation-tested the production surface rather than trusting the tests by inspection — 32 mechanical mutations across the seven touched files, 30 caught. The two survivors led to real gaps: the
/Params /CheckSumand/ModDateguards couldn't fail if you wired those fields through the decoder, because their fixture values are printable ASCII or hex-fall-back to the same digits. Same story for the/Fdecode branch, which every existing fixture skipped by carrying a/UF. Those are pinned now, and I verified each new guard fails when you revert the behavior it guards.What could go wrong?
Two user-visible CLI changes that could break a script:
dump embedded --namenow matches the decoded name. A caller passing the old raw hex text won't match anymore.strconv.QuoteToASCIIform. All-ASCII values are untouched, and plain text is documented as non-contractual, but anything parsing it should be on--jsonanyway.The GUI views render the decoded values through the same structs, but there's no IPC or binding change so nothing here exercises them — worth opening a PDF with a non-ASCII title in the app before release.
dump metadata's XMP packet is still passed through verbatim and is UTF-8 XML by spec, so that command's plain output isn't strictly ASCII-only on a document that has an XMP packet. Escaping it would corrupt it as XML. That's documented in the presenter and the assertion is scoped to the Info block rather than claiming something the code doesn't do.Screenshots/videos (if appropriate)
n/a — CLI and data-layer change.
Checklist
devbranch, notmaster