From af156caa1fdf970aa593c04d69654f07bd1f7b4a Mon Sep 17 00:00:00 2001 From: sebastien Date: Wed, 9 Sep 2026 00:32:02 +0200 Subject: [PATCH] Accept the EOL marker some producers count in a stream /Length PDF 32000-1 7.3.8.1 puts an end-of-line marker after stream data and excludes it from /Length. Producers that count it anyway leave one stray byte after an otherwise complete zlib stream, and DecompressionStream rejects that as trailing junk. The payload it had already produced was discarded and the whole document failed to render. Measured on two unrelated real-world PDFs: a complete zlib stream of 7760 bytes followed by a single 0x0d, and one of 1835 bytes followed by a single 0x0a. Both had decoded fully -- 8321 and 4075 bytes -- before the trailing byte turned the result into an error. The marker is now accepted, but only after re-decoding the stream without it proves the same output length. That keeps the guarantee the strict decoder was there for: a truncated stream cannot be rescued this way, since removing a trailing byte never completes it. Nothing else is trimmed. The existing case for a trailing NUL still fails, as do spaces, tabs, and more than one marker: 7.3.8.1 specifies an EOL marker, not arbitrary padding, and the new cases pin that boundary. Refs #2 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/test-native-filter-semantics.mjs | 23 ++++++++++ src/pdf/nativeFilters.ts | 56 +++++++++++++++++++++++- 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/scripts/test-native-filter-semantics.mjs b/scripts/test-native-filter-semantics.mjs index ad453e6..579427c 100644 --- a/scripts/test-native-filter-semantics.mjs +++ b/scripts/test-native-filter-semantics.mjs @@ -135,6 +135,29 @@ async function testFlate() { hasPdfError("invalid-object", /empty/) ); + // PDF 32000-1 7.3.8.1 puts an EOL marker after the stream data and excludes + // it from /Length. Producers that count it anyway leave a stray CR, LF, or + // CRLF after an otherwise complete zlib stream. The marker is not data, and + // the payload it follows decodes to exactly the same bytes. + for (const marker of [[0x0a], [0x0d], [0x0d, 0x0a]]) { + assert.deepEqual( + await decodeOne(concat(zlib, Uint8Array.from(marker)), "FlateDecode"), + decoded, + `zlib stream followed by a ${marker.length}-byte EOL marker` + ); + } + + // Only that marker is specified to sit there. Other trailing bytes, other + // whitespace, and more than one marker stay errors. + for (const junk of [[0x20], [0x09], [0x0a, 0x0a], [0x0a, 0x0d], [0x0d, 0x0a, 0x0a]]) { + await assert.rejects( + decodeOne(concat(zlib, Uint8Array.from(junk)), "FlateDecode"), + hasPdfError("invalid-object", /FlateDecode/), + `zlib stream followed by ${JSON.stringify(junk)}` + ); + } + + // 0x7820 has a valid FCHECK and advertises FDICT. PDF Flate streams cannot // supply the external dictionary, so this is a deterministic typed failure. await assert.rejects( diff --git a/src/pdf/nativeFilters.ts b/src/pdf/nativeFilters.ts index 543a11f..fa56abc 100644 --- a/src/pdf/nativeFilters.ts +++ b/src/pdf/nativeFilters.ts @@ -252,6 +252,46 @@ async function* decodeFlateChunks( } } +/** + * True when `inputs` end with a single EOL marker whose removal yields a + * stream that decodes to exactly `producedLength` bytes. Nothing else is + * trimmed: NUL, spaces, tabs and repeated markers stay decode failures. + */ +async function decodesIdenticallyWithoutEolMarker( + inputs: readonly Uint8Array[], + format: "deflate" | "deflate-raw", + limit: number, + signal: AbortSignal | undefined, + producedLength: number +): Promise { + const trimmed = withoutTrailingEolMarker(inputs); + if (!trimmed) return false; + const state: PlatformInflateState = { length: 0 }; + try { + for await (const chunk of inflatePlatformChunks(trimmed, format, limit, signal, state, false)) { + void chunk; + } + } catch { + return false; + } + return state.length === producedLength; +} + +/** The trailing CR, LF, or CRLF removed, or null when there is no marker. */ +function withoutTrailingEolMarker( + inputs: readonly Uint8Array[] +): readonly Uint8Array[] | null { + let lastIndex = inputs.length - 1; + while (lastIndex >= 0 && inputs[lastIndex].length === 0) lastIndex -= 1; + if (lastIndex < 0) return null; + const last = inputs[lastIndex]; + let end = last.length; + if (last[end - 1] === 0x0a) end -= 1; + if (end > 0 && last[end - 1] === 0x0d) end -= 1; + if (end === last.length || end === 0) return null; + return [...inputs.slice(0, lastIndex), last.subarray(0, end)]; +} + function classifyFlateWrapper(input: Uint8Array): "zlib" | "raw" { if (input.length === 0) throw new PdfError("invalid-object", "FlateDecode stream is empty."); const cmf = input[0]; @@ -303,7 +343,8 @@ async function* inflatePlatformChunks( format: "deflate" | "deflate-raw", limit: number, signal: AbortSignal | undefined, - state: PlatformInflateState + state: PlatformInflateState, + recoverEolMarker = true ): AsyncIterable { if (typeof DecompressionStream !== "function") { throw new PdfError("unsupported-filter", "FlateDecode requires DecompressionStream support."); @@ -348,6 +389,19 @@ async function* inflatePlatformChunks( await reader.cancel().catch(() => undefined); if (cause instanceof PdfError) throw cause; if (signal?.aborted) throwIfAborted(signal); + // PDF 32000-1 7.3.8.1 places an EOL marker after the stream data and keeps + // it out of /Length. Producers that count it leave a stray CR, LF, or CRLF + // that platform decoders reject as trailing junk, discarding a payload that + // was already complete. Accept that marker, but only after re-decoding + // without it proves the same byte count: a truncated stream cannot be + // rescued this way, and any other trailing byte remains an error. + if ( + recoverEolMarker && + await decodesIdenticallyWithoutEolMarker(inputs, format, limit, signal, state.length) + ) { + completed = true; + return; + } throw new PdfError("invalid-object", "Malformed FlateDecode stream.", { cause }); } finally { if (!completed) {