decodeUTF16String: off-by-one boundary check misclassifies U+E000 as an incomplete surrogate pair
Bug description
decodeUTF16String in pkg/pdfcpu/types/utf16.go has an off-by-one boundary
check that misclassifies the single, valid BMP code point U+E000 (the first
character of the Unicode Private Use Area) as the start of an incomplete
surrogate pair, causing a false "corrupt UTF16BE" error and rejecting the
entire document — even for read-only operations like pdfcpu info.
Environment
- pdfcpu version: v0.14.0 (confirmed still present on
masteras of 2026-09) - OS: macOS (issue is not OS-specific — it's a pure string-decoding bug)
Root cause
if val <= 0xD7FF || val > 0xE000 && val <= 0xFFFF {
// treated as a safe single code unit
}
// otherwise treated as the high surrogate of a surrogate pairUnicode surrogates occupy 0xD800–0xDFFF (inclusive). 0xE000 itself is
not a surrogate — it's the first ordinary BMP character after the
surrogate range. The condition uses val > 0xE000 (strictly greater than),
so the single value 0xE000 falls through to the surrogate-pair branch. If
there happen to be fewer than 2 remaining bytes at that point (e.g. it's the
last/only code unit in the string), this line fires:
if i+2 >= len(b) {
return "", fmt.Errorf("corrupt UTF16BE byte length on unicode point 1: %v", b)
}even though the string was perfectly well-formed.
Fix: change val > 0xE000 to val >= 0xE000 (or equivalently,
val < 0xD800 || val > 0xDFFF, which more directly expresses "not a
surrogate").
Impact
Any PDF whose StructTreeRoot (or any other structure validated during
prepareContext, e.g. Outlines, AcroForm, etc. — anywhere a
UTF16BE string is decoded) contains a text string encoding exactly U+E000
as a lone code unit will fail every pdfcpu command, including read-only
ones like info, because prepareContext unconditionally walks and
validates every optional field present in the document catalog. --mode strict/--mode relaxed make no difference since this is a hard decode
error, not a validation-policy check.
We hit this in production on a real, legitimately-generated tagged PDF
(an insurance policy rider document) whose accessibility /Alt text
happened to contain U+E000.
Minimal reproduction
Save the following as min-repro.pdf (it's plain ASCII text, no binary
needed) and run pdfcpu info min-repro.pdf:
``` %PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R /MarkInfo << /Marked true >> >> endobj 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << >> /StructParents 0 >> endobj 4 0 obj << /Length 5 >> stream BT ET endstream endobj 5 0 obj << /Type /StructTreeRoot /K [6 0 R] /ParentTree 7 0 R /ParentTreeNextKey 1 >> endobj 6 0 obj << /Type /StructElem /S /Span /P 5 0 R /Pg 3 0 R /Alt >> endobj 7 0 obj << /Nums [0 [6 0 R]] >> endobj xref 0 8 0000000000 65535 f 0000000009 00000 n 0000000109 00000 n 0000000166 00000 n 0000000287 00000 n 0000000341 00000 n 0000000434 00000 n 0000000517 00000 n trailer << /Size 8 /Root 1 0 R >> startxref 556 %%EOF ```
Expected output: normal info output (1 page, tagged).
Actual output:
info: prepare PDF context: validation error (obj#:3): document catalog: catalog StructTreeRoot: structure tree root K[0] obj#6: corrupt UTF16BE byte length on unicode point 1: [224 0]Note the object number in the error will vary depending on file layout —
the important part is corrupt UTF16BE byte length on unicode point 1.
Suggested fix
- if val <= 0xD7FF || val > 0xE000 && val <= 0xFFFF {
+ if val <= 0xD7FF || val >= 0xE000 && val <= 0xFFFF {Verified locally: this single-character change resolves U+E000 while
leaving surrogate-pair handling for the real 0xD800–0xDFFF range
unaffected (tested U+E001, plain ASCII, and a genuinely incomplete
surrogate U+DFFF as controls — all behave correctly before and after).
Source: pdfcpu/pdfcpu