Skip to content

feat(pdf-codec): leave document-level surfaces out of a read - #1693

Merged
Mearman merged 11 commits into
mainfrom
feat/read-pdf-skip-document-surfaces
Oct 17, 2026
Merged

Mearman merged 11 commits into
mainfrom
feat/read-pdf-skip-document-surfaces

Conversation

@Mearman

@Mearman Mearman commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

readPdf takes a surfaces option: one boolean per document-level surface (outline, destinations, form, layers, structure, residue), each read unless set to false. It is independent of pages, so it works on a full read too. A skipped surface's reader is never entered, its field is left off the LayoutDocument, and the result lists it in skippedSurfaces, so a caller can tell "not read" from "none" the same way pageCount already covers attachments.

Page items only lose what depended on the skipped surface. Without destinations, internal links are dropped (URI links stay) and outline entries carry no destination. Without layers, no item carries layer. Without structure, no item carries a structure owner. form and residue don't touch page items, apart from residue taking the page-boxes row with it. outline doesn't remove anything from page items, but it does rename things: the reader names each direct destination array with the first unused destN in the order it meets them, and the outline is read before any page, so skipping it renumbers the names minted for page links (and drops the entries only the outline minted). Each link still resolves to the same target. I left the minting order alone, since changing it would change full-read output. The rules are in the ReadPdfOptions.surfaces doc comment and the pdf-codec README. writePdf refuses a layout carrying skippedSurfaces with a new PdfSkippedSurfacesError (checked after PdfPartialDocumentError, so a page-selected read that also skipped surfaces still reports the selection).

documents.js threads surfaces through the pdfTo* conversions, convertDocument (first hop with a pdf source only, the same as pages) and readNativeDocumentTree/readContentDocument. readDocumentMetadata now reads a pdf with every surface skipped as well as pages: [], and returns the same metadata. openPdf takes neither. Two of these change reconstruction, and the README says so: with structure skipped a tagged PDF's headings and tables are inferred from geometry, and with layers skipped hidden-layer text is kept as if it were visible.

The tests use a fixture where every surface carries a trap, a reference to an object the xref doesn't hold, so a parse of that surface reports a diagnostic naming that object. A skipped surface's trap never fires, and the reader spies confirm the reader isn't called. For each surface, the skipped read is compared to the full read with exactly the stated losses taken out, which covers every page item field by field. For the outline the comparison resolves link destinations to their targets instead of comparing minted names, and a separate test asserts the renumbering itself on an outline entry with a direct destination array.

Local observation, not a benchmark: peak RSS growth (process.resourceUsage().maxRSS after the read minus before it, file already in memory, one node 24.21.0 process per run, three runs each, on a heavily loaded shared Mac).

A 25 MB, 638 page tagged PDF:

  • whole read: 833 to 1021 MiB
  • pages: []: 206 to 210 MiB
  • pages: [0, 1, 2, -1]: 212 MiB
  • pages: [0, 1, 2, -1], structure skipped only (two runs): 50 MiB
  • pages: [0, 1, 2, -1], every surface skipped: 44 to 45 MiB
  • pages: [], every surface skipped: 41 MiB

A 25 MB, 205 page file with a much smaller structure tree, for comparison: 35 to 38 MiB for pages: [], 43 to 44 MiB for pages: [0, 1, 2, -1], 34 to 37 MiB with every surface skipped as well, and 25 to 31 MiB for pages: [] with every surface skipped.

So on the tagged file almost all of a page-selected read's cost was the structure tree. What's left with every surface skipped is 41 to 45 MiB on the tagged file and 25 to 37 MiB on the other, the lower end in each case being pages: []. Measured the same way, opening the document (openPdfDocument: reading the cross-reference data by following the /Prev chain of tables or streams, which is bounded and stops at a repeated offset, then the trailer, the /Encrypt dictionary when there is one, and the catalog; on that normal path an object stream is decoded only when the catalog or /Encrypt lives in one, while a broken cross-reference falls back to a recovery scan that parses every object in the file and decodes every object stream. Opening either test file raised no pdf/xref-recovered diagnostic, so neither took the recovery scan) accounts for about 33 MiB of that on the tagged file and about 7 MiB on the other. Walking the page tree adds a further 1 to 4 MiB and 12 to 14 MiB respectively.

Refs #1692

ReadPdfOptions.surfaces takes one boolean per document-level surface
(outline, destinations, form, layers, structure, residue), each read
unless set to false, on a full read or a page-selected one. A skipped
surface's reader is never entered, its field is absent from the result,
and the result names it in skippedSurfaces, so "not read" can be told
apart from "none".

Page items lose only what depended on a skipped surface: internal links
without destinations, layer names without layers, structure owners
without the structure tree. writePdf refuses a layout carrying
skippedSurfaces with PdfSkippedSurfacesError.

Refs #1692
…nversions

The pdfTo* conversions, convertDocument with a pdf source and
readNativeDocumentTree/readContentDocument take a surfaces option,
handed to the same first hop pages goes to, so an intermediate pdf a
route renders is always read whole. readDocumentMetadata now reads a
pdf with every document-level surface skipped as well as no page, and
returns the same metadata a full read does. openPdf takes neither
option. ReadPdfSurfaces, LayoutDocumentSurface and
PdfSkippedSurfacesError are exported from the root barrel.

Refs #1692
…ations

The reader names each direct destination array with the first unused
destN in the order it meets them, and the outline is read before any
page, so leaving the outline out renumbers the names minted for page
links and drops the entries only the outline minted. The surfaces doc
comment and README said the outline was page-item neutral; they now
state the renumbering, and the surfaces fixture carries a direct-array
outline destination so the per-surface comparison and an explicit
renumbering test cover it. Opening the document is named among what an
every-surface-skipped read still does.

Refs #1692
… change

The surfaces section said a tree read with surfaces skipped rebuilds to
pdf without them, but the rebuild never re-emits the outline,
destinations, form, layers, structure or residue in any case. It now
names the two differences that do reach a rebuild (hidden-layer text
drawn visibly, geometry-inferred blocks), lists the structure
definitions and form contentControl constructs among what a skip
leaves out, and states that skipping the outline renumbers the minted
destination names internal link anchors carry.

Refs #1692
…sidue skipped

The surfaces fixture's XMP packet was empty, so nothing showed that a
metadata read with every surface skipped still fills fields /Info does
not carry from the XMP packet. A pdf whose title and author live only
in XMP now reads the same metadata through readDocumentMetadata as
through a full read.

Refs #1692
The destination registry comment said a direct array shared by a link
and a bookmark mints one destination. intern mints a fresh entry for
every direct array with no identity or value check, so one indirect
array both reference mints two entries naming the same target. The
comment now says what the code does; behaviour is unchanged.

Refs #1692
…ne document

The outline case's link-target normalisation took a document and a
separate source for its pages and destinations, so the expected side's
own pages were silently replaced by the full read's. It now normalises
one document, and the expected side is the full read normalised with
its outline removed.

Refs #1692
The surfaces section said a rebuild draws the PDF from the tree's
metadata, pages and images alone, but it also emits the positioned text,
vectors, formulas and URI links and uses the tree's embedded fonts. It
now says so, states the skippedSurfaces refusal before the rebuild
sentences and that a rebuild is never refused, and notes that a skipped
outline drops the destN entries only it minted from the tree's
destinations table.

Refs #1692
…xactly

The helper comment said linked destinations are keyed by what they
resolve to; they are an ordered list of resolved targets compared in
order. The comment now says so and why the order matches in both reads.

Refs #1692
…a rebuild draws

The outline is not a table of its own: its items are outline-N entries in
the destinations table, so skipping the outline removes those entries
rather than a table. The rebuild's content list now includes table-cell
rectangles and border lines and sheet-cell rectangles, and says a URI
link is re-emitted only when it is attached to a recovered run.

Refs #1692
The surfaces doc comments spoke of outline or bookmark tables, but the
outline lives as outline-N entries in the tree's destinations table. They
now list what each skip removes, matching the README. The README adds
the border lines a sheet cell rebuilds with and says a read outline's
outline-N entries stay, without a destination, when only destinations
is skipped.

Refs #1692
@Mearman
Mearman marked this pull request as ready for review October 17, 2026 06:58
@Mearman
Mearman added this pull request to the merge queue Oct 17, 2026
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
🔒 Security Review ⚠️ Failed 2026-10-17T06:58:43.293744Z 886481b Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

This security review couldn’t start because the paying account has exhausted its Codex allowance and has insufficient available credits. The payer can check their balance and add credits in the Codex usage dashboard, then try again. If you do not manage the paying account, contact this repository's admins.

Merged via the queue into main with commit 5194c56 Oct 17, 2026
36 checks passed
@Mearman
Mearman deleted the feat/read-pdf-skip-document-surfaces branch October 17, 2026 07:07
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 14.6.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant