#1479·pdfcpu

decodeUTF16String: off-by-one boundary check misclassifies U+E000 as an incomplete surrogate pair

Author: JhenanCreated Sep 11, 2026Updated Sep 14, 2026
Labelsinvestigate

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 master as of 2026-09)
  • OS: macOS (issue is not OS-specific — it's a pure string-decoding bug)

Root cause

go
if val <= 0xD7FF || val > 0xE000 && val <= 0xFFFF {
    // treated as a safe single code unit
}
// otherwise treated as the high surrogate of a surrogate pair

Unicode surrogates occupy 0xD8000xDFFF (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:

go
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

diff
- 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 0xD8000xDFFF range unaffected (tested U+E001, plain ASCII, and a genuinely incomplete surrogate U+DFFF as controls — all behave correctly before and after).