Skip to content

Fix parseBuffer with offset buffers - #437

Merged
modesty merged 1 commit into
modesty:masterfrom
locomote:fix_buffer_with_offset
Nov 6, 2026
Merged

modesty merged 1 commit into
modesty:masterfrom
locomote:fix_buffer_with_offset

Conversation

@dxg

@dxg dxg commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

parseBuffer failed on buffers that are views into a larger ArrayBuffer at a non-zero byteOffset, because PDF.js's makeSubStream reads from bytes.buffer with absolute positions and the old guard rebuilt the buffer as another view instead of copying it.
Node 24.18 raised Buffer.poolSize to 64 KiB, so any Buffer.from/subarray result under 32 KiB now lands at an arbitrary pool offset and parsing fails with "Invalid XRef stream header".

Whilst I encountered the bug after a Node 24.15 -> 24.21 upgrade, tests don't depend on using the latest Node version.

Given that #424 hasn't moved forward in the last 3 months, I've created a new PR.

@modesty modesty left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The actual bug is in the engine and is still there base/core/stream.js: Stream.makeSubStream builds sub-streams from this.bytes.buffer, which loses the view's byte offset. This is the root cause.

Changing it to new Stream(this.bytes, start, length, dict) passes all 12 new tests on Node 22 and 24 without any copying.

The approach in this PR copies the whole PDF on every parse of a buffer it doesn't own, doubling peak memory for large inputs. And it copies again each time a cached buffer is re-parsed, and copies small stream inputs twice; This would leave the same offset bug in place for other engine code that builds a Stream from a view (for example fonts.js:5437, fonts.js:5448). Fix the root cause would avoid the memory issue and other offset places would benefit too.

Currently, the new test fails 8 of 12 cases on master and passes all 12 on the PR branch, on both Node 22.23.3 and Node 24.21.0.

Comment thread pdfparser.js Outdated
return bytes;
}
return Buffer.from(new Uint8Array(bytes).buffer);
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this would break typed arrays other than Uint8Array : #toOwnedBuffer uses new
Uint8Array(bytes), which converts value by value instead of copying the raw bytes. So parseBuffer(new Uint16Array(pdfBytes.buffer)) parses on master but fails on this branch with "Invalid XRef stream".
The fix is to always build a Uint8Array view over the same bytes and, when a copy is needed, use
buffer.slice(byteOffset, byteOffset + byteLength).

@dxg
dxg force-pushed the fix_buffer_with_offset branch from 5a9af2c to 054a4dd Compare October 12, 2026 09:05
@dxg

dxg commented Oct 12, 2026

Copy link
Copy Markdown
Contributor Author

@modesty feedback addressed.

@modesty modesty left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's very close, but has a regression, see inline comments for details.

Also, the following items are cheap to fix:

  1. test/_test_parseBuffer_offset.cjs:77: The tests depend on running in order. Running one test alone fails with a misleading SyntaxError. Move the baseline parse into a before() hook.
  2. test/_test_parseBuffer_offset.cjs:8: is its header on inputs "must be copied" valid?
  3. src/types/pdfparser.d.ts:41 : Widen pdfBuffer: Buffer to Uint8Array to match what the PR now supports.

Comment thread pdfparser.js Outdated
}

this.#startParsingPDF(pdfBufferParse);
this.#startParsingPDF(pdfBuffer);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

regression: typed arrays other than Uint8Array stopped working, it fails with Invalid XRef stream when passing a Uint16Array (or Int32Array, Float32Array, etc.) over PDF bytes, while on master it parses ok.

It results from the removing the guard: it also turned non-byte typed arrays into a byte view. Without it, each element gets cut down to 8 bits, which corrupts the data.

Consider a safer bet like:

const bytes = pdfBuffer instanceof Uint8Array
          ? pdfBuffer
          : new Uint8Array(pdfBuffer.buffer, pdfBuffer.byteOffset, pdfBuffer.byteLength);
  this.#startParsingPDF(bytes);

Also consider to dd a Uint16Array case to test/_test_parseBuffer_offset.cjs so it can't break again.

@dxg
dxg force-pushed the fix_buffer_with_offset branch from 054a4dd to d16d668 Compare November 6, 2026 10:16
@dxg

dxg commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, I've applied fixes for all of the above.

@modesty
modesty merged commit f3e9bbd into modesty:master Nov 6, 2026
2 checks passed
modesty added a commit that referenced this pull request Nov 6, 2026
- parseBuffer: accept any non-empty ArrayBufferView, including DataView,
    which the old check rejected. Inputs that are not views still throw.
  - loadPDF/parseBuffer: use  so verbosity 0 (ERRORS)
    is honored instead of falling back to WARNINGS.
  - Stream: check  explicitly, so a sub-stream at
    offset 0 with length 0 is empty instead of the whole buffer.
  - Field tab indexes (TI): number them per document, from 0, in page
    order. The counter was shared by the whole process, so TI depended on
    how many documents had been parsed before, including concurrently.
  - types: parseBuffer(pdfBuffer: ArrayBufferView) in .d.ts and .d.cts,
    matching the typed-array support the implementation already had.
  - JSDoc: parseBuffer and #startParsingPDF describe the accepted inputs
    and the throw.

  Tests:
  - _test_parseBuffer_offset: add a DataView case; force Buffer.from to
    use the shared pool at a non-zero offset (before, the test passed even
    with the bug on Node 22's 8 KiB pool); compare full output now that TI
    is deterministic; the helper uses once() and always calls destroy();
    add unit tests for Stream sub-streams against base/core/stream.js.
  - _test_errors: inputs that are not views and an empty DataView throw;
    verbosity 0 hides engine warnings for both parseBuffer and loadPDF.
  - _test_testMultipleDataPDF: TI starts at 0 for repeated, same-parser
    and concurrent parses.

  Docs: README API reference documents parseBuffer inputs, the
  verbosity levels and how TI is numbered; unit suite counts updated.

  Behavior change: when several PDFs are parsed in one process (e.g. the
  CLI on a directory), TI now starts at 0 for each file instead of
  continuing from the previous one. Corpus check: across 1064 test:forms
  output files, the only differences from master are TI values.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants