Skip to content

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

Closed
Mearman wants to merge 2 commits into
mainfrom
feat/read-pdf-page-selection
Closed

Mearman wants to merge 2 commits into
mainfrom
feat/read-pdf-page-selection

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, a negative index counts from the end, duplicates collapse, and the result comes back in document order whatever order the selection was written 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 (out of range, or not an integer) throws PdfPageSelectionError, a PdfParseError with code pdf/page-out-of-range or pdf/page-index-not-integer that carries pageIndex and pageCount, before any page is read. An empty selection is valid and reads no page, which makes it the cheapest way to get the page count and metadata.

Unselected pages are skipped before readPage, which is the only path that fetches /Contents, decodes and interprets it, and resolves the fonts and images a page uses. Objects are resolved lazily, so none of that work happens for pages that were not asked for. The page tree is still walked in full, and the document-level readers still cover the whole file. That is cheap: they read the catalog plus page and annotation dictionaries, never content streams (attachments look at every page's /Annots for /FileAttachment, and the structure tree maps every page's /StructParents). Document-level references (destinations, outline, form widgets, structure, page-boxes residue) keep naming source indices. sourcePageIndex(page, position) resolves either shape.

In documents.js the pages option is threaded through PdfToDocumentOptions, UnifiedConversionOptions (used only by the fromPdf hop) and ReadNativeDocumentTreeOptions. Reconstructions previously used each page's position in layout.pages as its page index. They now use the source index for frames, comment anchor keys (pdf-annot-{page}-{n}) and sheet names, which keeps them in agreement with the form widgets and destinations that already used source indices. Without this, a form widget on a selected page would have been matched against the wrong page. A page-selected conversion's DocumentTree leaves out the pages envelope, because that array is indexed by frame page index and a selection can't fill it.

Page-selected Markdown opens each page with <!-- page N -->, where N is the original one-based page number. The existing <!-- page break --> between pages is unchanged, so a consumer that already splits on it keeps working. A caller can read the label to see which pages it got. That matters because the conversion writes no break between pages that start different sections (different page sizes). buildMarkdownText and renderContentDocumentToMarkdown take the same labels as pageNumbers for callers that compose readPdf, reconstructWordprocessing and the writer themselves. A count mismatch throws MarkdownPageNumberCountError, and reading the Markdown back drops the labels.

For the memory claim, read-page-selection.test.ts wraps interpretContentStream with a pass-through spy. A selection of two pages out of four interprets exactly two content streams, and an empty selection interprets none. The fixture's unselected trap page has a content stream with an unknown filter: a full read reports pdf/unsupported-filter, while a selection that leaves that page out reports no diagnostics. An image drawn only by an unselected page never reaches images. Selected pages are compared page for page against the same pages of a full read, with index added. Further tests cover negative indices, duplicates, the empty selection, out-of-range and non-integer indices, a zero-page file, and destinations keeping their source page. On the documents.js side, tests check the Markdown labels (including a page that starts its own section), source-index frames from all four reconstructors, comment keys that still pair with their definitions, the omitted tree envelope, and that the labels are dropped on read-back. I hand-mutated each piece of new logic (reading every page anyway, ignoring negative indices, dropping index, skipping the range check, using position instead of source index in each reconstructor and in the comment keys, skipping section-start labels, ignoring continuous, keeping labels on read, zero-based labels, keeping the envelope), and in every case at least one test failed.

Known limit: writePdf places pages by position, so writing a page-selected LayoutDocument back out doesn't remap the source-index references. I've documented this. It's a reading of part of a file, not a document to re-emit.

Refs #1688

readPdf takes a pages option of zero-based page-tree indices, negative
indices counting from the end. Only the selected pages' content streams
are fetched, decoded and interpreted, so the fonts and images only
unselected pages use are never resolved: reading a cover page or the
last page of a large file no longer costs the memory of laying out
every page.

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.

Refs #1688
The pdfTo* conversions, convertDocument with a pdf source and
readNativeDocumentTree/readContentDocument take a pages option, passed
to readPdf, so only the selected pages are parsed, laid out and
reconstructed.

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, and reading markdown back
drops them.

Refs #1688
@Mearman

Mearman commented Oct 17, 2026

Copy link
Copy Markdown
Member Author

Superseded by #1690, which rebuilds this change with one commit per package and folds in the review fixes.

@Mearman Mearman closed this Oct 17, 2026
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.

1 participant