Skip to content

feat: read and convert a selection of a PDF's pages - #1690

Merged
Mearman merged 9 commits into
mainfrom
feat/read-pdf-page-selection-v2
Oct 17, 2026
Merged

Mearman merged 9 commits into
mainfrom
feat/read-pdf-page-selection-v2

Conversation

@Mearman

@Mearman Mearman commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

readPdf(bytes, { pages: [0, 1, 2, -1] }) now reads only the listed pages. Indices are zero-based into the page tree and a negative index counts from the end. Duplicates collapse, and the result comes back in document order regardless of the order you asked in. Each returned page carries its source position as index, and the document carries the page tree's length as pageCount. A full read sets neither, so its output is unchanged. An index that names no page throws PdfPageSelectionError before any page is read: out of range gives pdf/page-out-of-range, and anything that isn't an integer (NaN and the infinities included) gives pdf/page-index-not-integer. -0 reads as page 0. An empty selection is valid and reads no page, so it's the cheap way to get the page count and metadata.

A page-selected read skips three things. An unselected page's content stream is never fetched, decoded or interpreted. The fonts and images that only unselected pages use are never resolved. No embedded file is decoded, so attachments is absent from a page-selected result. An attachment is document-level payload, and its size has nothing to do with which pages you asked for. An absent attachments therefore means "not read" whenever pageCount is set, and the READMEs tell callers to check pageCount before concluding a document has no attachments.

Some things are still read whole, either because page references resolve against them or because they're document-level and hold no page content stream. These are the page tree, the outline, named destinations, layers, the AcroForm, the structure element tree and the /ParentTree number tree (both of which scale with the document), metadata and residue.

Two surfaces come from page content and so cover selected pages only. One is structure ownership, together with its parent-tree diagnostics. The other is the page-boxes residue. Direct destination arrays on links are minted as destN only when their page is interpreted. So an unselected page's direct link destinations are missing, and the minted names depend on which pages were read.

I haven't measured heap. The claim here is structural: what is and isn't read.

writePdf now refuses any document carrying pageCount and throws PdfPartialDocumentError. It places pages by position, while a selection's references name source pages, so writing one would point links at the wrong pages.

In documents.js, pages is threaded through PdfToDocumentOptions, UnifiedConversionOptions and ReadNativeDocumentTreeOptions. The plan runner passes it only to the first hop of a plan whose source is pdf. A docx to ods route renders an intermediate pdf internally, and that pdf is never page-selected. readDocumentMetadata reads a pdf with an empty selection, and openPdf (which now takes Omit<ReadPdfOptions, "pages">) always reads every page.

Reconstructions used each page's position in layout.pages as its page index. They now use the source index for frames, comment anchor keys and sheet names. That keeps them consistent with the form widgets and destinations, which already use source indices. A page-selected conversion's DocumentTree leaves out the pages envelope, because that array is indexed by frame page index. Such a tree can't be rebuilt to pdf, and buildDocumentBytes(tree, "pdf") now names the page-selected case in its error rather than claiming every pdf-to-format dump carries pages. Reconstruction only sees the selected pages, so a cover page read on its own can lose its title heading: the body size is taken from the selection. This is documented on the option and in the README; the algorithm is unchanged.

Page-selected Markdown opens each page with <!-- page N -->, where N is the original page number. It sits beside the existing <!-- page break -->. Callers who compose readPdf, reconstructWordprocessing and the writer themselves get the same labels through pageNumbers. The labels are output-only, and a markdown read keeps them as ordinary comments.

The tests spy on interpretContentStream and on decodeStream, and the fixture streams carry a marker name. With that setup, a selection interprets one content stream per selected page. It decodes neither the unselected page's content stream nor the font program only that page uses, and on any selection, empty included, it decodes no embedded file. A full read still decodes and returns the attachment. An image used only by an unselected page never reaches images. A broken /StructParents on an unselected page produces no diagnostic, while a full read still produces one. A selected non-first page of a tagged document keeps its structure ownership, equal to the same page in a full read. The page-boxes residue holds only the selected pages' rows.

The selected pages equal the same pages of a full read, plus index. Further tests cover negative indices, duplicates, empty selections, out-of-range and non-integer indices, and writePdf refusing a selection. On the documents.js side there are tests for:

  • buildDocumentBytes(tree, "pdf") on a page-selected tree throwing the page-selected message
  • docx to ods with pages, which converts the whole document and doesn't throw for an index past its rendering
  • onDocument still firing once on a multi-hop route
  • readDocumentMetadata asking for no page
  • openPdf reading every page
  • the Markdown labels, including a page that starts its own section
  • a hand-written <!-- page 12 --> surviving a markdown read
  • source-index frames from all four reconstructors

For every new piece of logic I broke it on purpose (reading every page, skipping no attachments, structure for every page, dropping the write guard, passing pages to every hop, forwarding pages from openPdf, full-reading for metadata, and the rest) and confirmed a test fails.

This supersedes #1689, rebuilt as commits scoped to one package each so releases attribute them correctly. The document-operations change is comment and test only: a page-selected LayoutDocument also carries pageCount, so a caller telling pdf_inspect's two result shapes apart should test formatVersion, which only the layout has, rather than "pageCount" in result.

Refs #1688

…Version

A LayoutDocument from a page-selected read carries pageCount, so the
pageCount property no longer narrows pdf_inspect's result to its summary
shape. formatVersion is on every LayoutDocument and on no summary.
readPdf takes a pages option of zero-based page-tree indices, negative
indices counting from the end. An unselected page's content stream is
never fetched, decoded or interpreted, so the fonts and images only
unselected pages use are never resolved, and a page-selected read
decodes no embedded file, leaving attachments out of its result.
Structure ownership and its parent-tree diagnostics cover selected
pages only; the structure element tree stays document-level.

The selected pages come back in document order, each carrying its source
position as index, with the page tree's length as pageCount; a full read
sets neither. Document-level page references keep naming source indices,
and sourcePageIndex resolves either shape. An empty selection reads no
page. An entry naming no page throws PdfPageSelectionError before any
page is read.

writePdf refuses a document carrying pageCount with
PdfPartialDocumentError, since it places pages by position and the
selection's references name source pages.

Refs #1688
The pdfTo* conversions, convertDocument with a pdf source and
readNativeDocumentTree/readContentDocument take a pages option, passed
to readPdf. The plan runner hands it only to the first hop of a plan
whose source is pdf, so a route that renders an intermediate pdf (docx
to ods) never selects pages of a document the caller did not supply.
readDocumentMetadata reads a pdf with an empty selection, and openPdf
always reads every page.

Every reconstruction now stamps frames, comment anchor keys and sheet
names with the page's source index (sourcePageIndex) rather than its
position, so they agree with the destinations and form widgets the
layout already names by source index. A page-selected conversion's
DocumentTree carries no pages envelope, since that array is indexed by
frame page index and a selection cannot fill it.

Markdown output of a page-selected conversion opens each page with its
original one-based page number, written as <!-- page N --> beside the
existing page-break marker. buildMarkdownText and
renderContentDocumentToMarkdown take the same labels as pageNumbers for
callers composing the stages themselves. The label is output-only:
reading markdown keeps it as ordinary content.

Refs #1688
The /ParentTree number tree joins the structure element tree in the list
of what a selected read still reads whole, and the README tells a caller
to check pageCount before taking an absent attachments list to mean the
document has none. The LAYOUT_FORMAT_VERSION comment records why index
and pageCount do not bump the version.

Tests cover a selected non-first page keeping its structure ownership
and the page-boxes residue holding only the selected pages' rows.

Refs #1688
…ome pdf

buildDocumentBytes(tree, "pdf") said every pdf-to-format dump carries
pages, which a page-selected conversion's dump does not. The message
and the README now say so, and readDocumentMetadata's comment states
what an empty selection skips rather than implying a catalog-only read.

Refs #1688
readDocumentMetadata dispatches a content format through
CONTENT_READERS and reads pdf with readPdf directly, not through
readDocumentLayout or the registry. The codec module comments, the
README and the abort-signal test name now say so.

Refs #1688
… stale format counts

The metadata directory holds only the write side. readDocumentMetadata lives with the pdf
conversions and never goes through the codec registry. Comments no longer state how many
content formats there are.
@Mearman
Mearman marked this pull request as ready for review October 17, 2026 03:05
@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-17T03:05:13.703856Z 1ed8f86 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.

@Mearman
Mearman added this pull request to the merge queue Oct 17, 2026
Merged via the queue into main with commit 699fe71 Oct 17, 2026
36 checks passed
@Mearman
Mearman deleted the feat/read-pdf-page-selection-v2 branch October 17, 2026 03:13
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 14.5.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