diff --git a/packages/document-operations/src/operations/pdf-inspect.test.ts b/packages/document-operations/src/operations/pdf-inspect.test.ts index c4885a7d4..b0b231f6f 100644 --- a/packages/document-operations/src/operations/pdf-inspect.test.ts +++ b/packages/document-operations/src/operations/pdf-inspect.test.ts @@ -52,7 +52,8 @@ describe("pdfInspectOperation", () => { source: { bytesBase64, format: "pdf" }, }); - if (!("pageCount" in result)) { + // formatVersion, not pageCount, tells the two shapes apart: a LayoutDocument carries pageCount too when it comes from a page-selected read, while formatVersion is on every LayoutDocument and on no summary. + if ("formatVersion" in result) { throw new Error("expected a summary, not a full LayoutDocument"); } expect(result.pageCount).toBe(1); diff --git a/packages/documents.js/README.md b/packages/documents.js/README.md index 79c5e0847..2c64888ae 100644 --- a/packages/documents.js/README.md +++ b/packages/documents.js/README.md @@ -385,6 +385,18 @@ import { pdfToMarkdown } from "documents.js/read"; It carries the ten `pdfTo*` conversions, `PdfToDocumentOptions`, `readDocumentMetadata` (with `ReadDocumentMetadataOptions`), `readNativeDocumentTree` (with `ReadNativeDocumentTreeOptions`), and `readContentDocument` (with `ReadContentDocumentOptions`) — identical functions to the root barrel's (the same forwarders, run through the composition engine's read half, and the same dispatch through the read-only codec half `src/codecs/read.ts`), never a forked behaviour; `convertDocument` and every X-to-PDF direction stay on the root barrel. `src/read-graph.test.ts` walks the entry's static import graph, follows `pdf-codec` specifiers through that package's real `exports` map into its source, and fails the build if the write path or any font asset becomes reachable. +### Converting a selection of a PDF's pages + +Every PDF-sourced entry point takes a `pages` option: the `pdfTo*` conversions (`PdfToDocumentOptions.pages`), `convertDocument` with a pdf source (`UnifiedConversionOptions.pages`), and `readNativeDocumentTree`/`readContentDocument` (`ReadNativeDocumentTreeOptions.pages`). It is pdf-codec's `ReadPdfOptions.pages` threaded through: zero-based page-tree indices, a negative index counted from the end (`-1` is the last page), duplicates collapsed, the result in document order, and `PdfPageSelectionError` for an index naming no page. Only the selected pages are parsed, laid out and reconstructed, so converting a large file's first pages (to identify it) or its last page (for a printed code) costs roughly the memory of those pages rather than of the file, which is what lets that work run inside a memory-capped runtime such as a Cloudflare Worker isolate. + +```ts +import { pdfToMarkdown } from "documents.js/read"; + +const coverAndLastPage = pdfToMarkdown(pdfBytes, { pages: [0, 1, 2, -1] }); +``` + +The output holds only the selected pages. Markdown output labels each one with its original one-based page number, written as `` on its own line at the start of the page (`pageNumberMarker`), alongside the `` marker between pages that share a section; a caller can therefore tell which pages it got without re-deriving the selection, and reading the markdown back drops the labels rather than turning them into content. A full conversion writes no labels, so its output is unchanged. A caller composing the stages itself (`readPdf` with `pages`, `reconstructWordprocessing`, then `buildMarkdownText` or `renderContentDocumentToMarkdown`) gets the same labels by passing `pageNumbers: layout.pages.map((page, position) => sourcePageIndex(page, position) + 1)`; `buildMarkdownText` throws `MarkdownPageNumberCountError` when the count does not match the document's pages. Every reconstructed node's frames name the page's index in the source PDF (the same index every destination and form widget names), which is why a page-selected conversion's reported `DocumentTree` carries no `pages` envelope: that array is indexed by frame page index, and a selection cannot fill it. A later stage's own page-addressing option, such as `page` for an svg target, counts within the selection. + ### Reading and building xlsx content directly Every other content format has its own standalone `readXContent`-shaped entry point (`readDocxContent`, `readPptxContent`, `readOdtContent`, `readOdpContent`, `readOdsContent`, `readOdgContent`) — xlsx is no longer the exception. `readXlsxContent`/`buildXlsxPackage` are this package's names for `ooxml.js`'s own spreadsheet `ContentDocument` read/build pair — the same one the `ods⇄xlsx` bridge and every xlsx metadata-rebuild path already use internally — re-exported here directly rather than wrapped, since `readXlsxContent` already produces the right shape on its own. (Since `ooxml.js` 4.0.0 the upstream flat builder is named `buildXlsxPackageFromContent` — the bare `buildXlsxPackage` name moved to that package's tree-form `DocumentTree` builder — so this package re-exports the flat builder under its own long-standing `buildXlsxPackage` name and the `ContentDocument`-in/`Package`-out contract is unchanged.) csv's `readCsvContent`/`buildCsvText` are the same kind of directly-exported stage pair, one level further in: they operate on RFC 4180 text rather than a decoded package (see `src/csv/` under Architecture). svg's `readSvgContent`/`buildSvgText` are the drawing-variant counterpart of csv's pair, operating on SVG text rather than a decoded package (see `src/svg/` under Architecture). diff --git a/packages/documents.js/src/convert/composition.ts b/packages/documents.js/src/convert/composition.ts index 9da298bdc..1ed5839b6 100644 --- a/packages/documents.js/src/convert/composition.ts +++ b/packages/documents.js/src/convert/composition.ts @@ -22,6 +22,7 @@ import { decodePackage as decodeOdfPackage, encodePackage as encodeOdfPackage, } from "odf.js"; +import { sourcePageIndex } from "pdf-codec/layout"; import { readPdf } from "pdf-codec/read"; import type { LayoutDocument, @@ -196,7 +197,8 @@ export const FORMAT_NODES: Readonly> = { signal: options?.signal, images: options?.images, }), - build: (content) => buildMarkdownText(content), + build: (content, options) => + buildMarkdownText(content, { pageNumbers: options?.pageNumbers }), encode: (text) => encodeMarkdownText(text), hasSourcePackage: false, }, @@ -316,6 +318,17 @@ export interface UnifiedConversionOptions { readonly page?: number; readonly onSvgDiagnostic?: SvgDiagnosticSink; readonly clock?: ClockPort; + /** + * The pages of a pdf source to convert, as pdf-codec's `ReadPdfOptions.pages` reads them: zero-based page-tree indices, a negative index counted from the end, duplicates collapsed, the result in document order, and an index naming no page throwing `PdfPageSelectionError`. Consulted only by the fromPdf hop, so it has no effect on a conversion whose source is not pdf. Only the selected pages are parsed and laid out, which is what lets a caller convert a large file's cover or last page within a small memory budget. + * + * The output holds only those pages. A markdown target labels each with its original one-based page number (`` at the start of the page, beside the usual `` between pages), so the text says which pages it holds. A page-addressing option of a later stage, such as `page` for an svg target, counts within the selection. The DocumentTree `onDocument` reports carries no `pages` envelope for a page-selected conversion: its frames name source page indices, which an envelope holding only the selected pages could not be indexed by. + */ + readonly pages?: readonly number[]; +} + +// What the fromPdf executor knows about a conversion's pages that a text build needs and the caller's options do not carry, passed to a text node's build alongside those options: the original one-based page number of each page the read returned, set only for a page-selected read (see UnifiedConversionOptions.pages), where a page's position in the output no longer says which page it is. Internal to the executors, never caller-supplied. +interface TextBuildContext { + readonly pageNumbers?: readonly number[]; } // --- Registry: declarative per-format primitive wiring ----------------------------------------- @@ -391,7 +404,7 @@ interface TextFormatNode { ) => ContentDocument; readonly build: ( content: ContentDocument, - options?: UnifiedConversionOptions, + options?: UnifiedConversionOptions & TextBuildContext, ) => string; readonly encode: (text: string) => Uint8Array; readonly hasSourcePackage: false; @@ -621,6 +634,13 @@ export function executeBridge( } // readPdf -> reconstruct(target variant) -> build(target) -> encode(target), reproducing the exact sequence and option-threading of convert.ts's pdfTo* functions (pdfToDocx/pdfToOdt/pdfToOdp/pdfToOds/pdfToOdg/pdfToMarkdown). sink reaches readPdf; signal reaches both readPdf and the reconstructor. onCellTypeInference reaches the reconstructor (reconstructSpreadsheet's audit channel) AND csv's read — the two places a cell re-typing decision can happen, threaded on the one options object every hop receives; the ergonomic pdfTo* functions do not declare it, so a caller wanting that audit channel on a pdf -> spreadsheet route passes it to convertDocument directly (the first-class entry point every named function forwards to). The package build half is called with no options, matching pdfTo*'s own `buildXPackage(content)` calls (no clock, no onMathDiagnostic threaded on this direction); the text build half receives options because csv's build consumes { delimiter, sheet }. +// The original one-based page number of each page a read returned, in order: what a page-selected markdown output labels its pages with. +function layoutPageNumbers(layout: LayoutDocument): number[] { + return layout.pages.map( + (page, position) => sourcePageIndex(page, position) + 1, + ); +} + export function executeFromPdf( target: ContentFormat, bytes: Uint8Array, @@ -630,21 +650,28 @@ export function executeFromPdf( const layout = readPdf(bytes, { signal: options?.signal, sink: options?.sink, + pages: options?.pages, }); const content = RECONSTRUCTORS[node.variant](layout, { signal: options?.signal, onCellTypeInference: options?.onCellTypeInference, }); - // The pages half derives from the read LayoutDocument's own pages — every rendered page's size, indexed to match the frames the reconstructor attached to the content it built. - const pages = layout.pages.map((page) => ({ - widthPt: page.widthPt, - heightPt: page.heightPt, - })); + const selected = options?.pages !== undefined; + // The pages half derives from the read LayoutDocument's own pages: every rendered page's size, indexed to match the frames the reconstructor attached to the content it built. A page-selected read has no such array to offer, since its frames name source page indices and its pages are only the selection, so the envelope is left off rather than indexed wrongly. + const pages = selected + ? undefined + : layout.pages.map((page) => ({ + widthPt: page.widthPt, + heightPt: page.heightPt, + })); // Build + encode the target first, then report the package (the ownership rule every construction site follows), with assembleTree decomposing the reconstructed content + page sizes into the tree-form DocumentTree. stampPdfPackageTables then lands the layout's document-level surfaces (destinations, outline, attachments, layers, residue, comment bodies) on the tree — the tables the flat ContentDocument has no root for. let out: Uint8Array; if (isTextFormatNode(node)) { - const text = node.build(content, options); + const text = node.build(content, { + ...options, + pageNumbers: selected ? layoutPageNumbers(layout) : undefined, + }); out = node.encode(text); } else { const pkg = node.build(content); diff --git a/packages/documents.js/src/convert/from-pdf-pages.test.ts b/packages/documents.js/src/convert/from-pdf-pages.test.ts new file mode 100644 index 000000000..127e28181 --- /dev/null +++ b/packages/documents.js/src/convert/from-pdf-pages.test.ts @@ -0,0 +1,356 @@ +import { + assembleTree, + type ContentBlock, + type ContentDocument, + DEFAULT_LAYOUT_FONT, + type DocumentTree, +} from "document-schema.js"; +import { + LAYOUT_FORMAT_VERSION, + type LayoutDocument, + PdfPageSelectionError, + readPdf, + sourcePageIndex, +} from "pdf-codec"; +import { describe, expect, it } from "vitest"; +import { createPdf } from "../edit/pdf/editor"; +import { + reconstructSpreadsheet, + reconstructWordprocessing, +} from "../layout/reconstruct"; +import { + reconstructDrawing, + reconstructPresentation, +} from "../layout/reconstruct-presentations-prod"; +import { stampPdfPackageTables } from "./pdf-package-tables"; +import { readMarkdownContent } from "../markdown/read"; +import { decodeMarkdownText } from "../markdown/text"; +import { + buildMarkdownText, + MarkdownPageNumberCountError, + PAGE_BREAK_MARKER, + pageNumberMarker, +} from "../markdown/write"; +import { renderContentDocumentToMarkdown } from "../markdown/render"; +import { + pdfToMarkdown, + pdfToSvg, + readContentDocument, + readNativeDocumentTree, +} from "./from-pdf"; + +const BLACK = { r: 0, g: 0, b: 0 }; +const LETTER = { widthPt: 612, heightPt: 792 }; +// A page size differing from LETTER, so the page carrying it starts a section of its own in the wordprocessing reconstruction: a page selection spanning it must still label every page, not only those separated by page breaks. +const WIDE = { widthPt: 792, heightPt: 612 }; +const PAGE_SIZES = [LETTER, LETTER, WIDE, LETTER, LETTER] as const; +const LAST_PAGE_INDEX = PAGE_SIZES.length - 1; +const TEXT_SIZE_PT = 12; +const TEXT_X_PT = 72; +const TEXT_Y_PT = 500; + +// "Body of page N" on page N (one-based), so the text says which page it came from. +function bodyOf(pageNumber: number): string { + return `Body of page ${String(pageNumber)}`; +} + +function fivePagePdf(): Uint8Array { + const [first, ...rest] = PAGE_SIZES; + const editor = createPdf(first); + for (const size of rest) { + editor.appendPage(size); + } + editor.pages().forEach((page, index) => { + page.appendText({ + xPt: TEXT_X_PT, + yPt: TEXT_Y_PT, + text: bodyOf(index + 1), + font: DEFAULT_LAYOUT_FONT, + sizePt: TEXT_SIZE_PT, + color: BLACK, + }); + }); + return editor.toBytes(); +} + +function markdownOf(pages?: readonly number[]): string { + return decodeMarkdownText(pdfToMarkdown(fivePagePdf(), { pages })); +} + +function frameIndicesOf(value: unknown): number[] { + const found: number[] = []; + const walk = (node: unknown): void => { + if (Array.isArray(node)) { + node.forEach(walk); + return; + } + if (typeof node !== "object" || node === null) { + return; + } + if ("frames" in node && Array.isArray(node.frames)) { + const frames: readonly unknown[] = node.frames; + for (const frame of frames) { + if ( + typeof frame === "object" && + frame !== null && + "pageIndex" in frame && + typeof frame.pageIndex === "number" + ) { + found.push(frame.pageIndex); + } + } + } + Object.values(node).forEach(walk); + }; + walk(value); + return [...new Set(found)].sort((a, b) => a - b); +} + +describe("pdfToMarkdown with a page selection", () => { + it("writes only the selected pages, each opened by its original one-based page number", () => { + const text = markdownOf([0, -1]); + expect(text).toContain(bodyOf(1)); + expect(text).toContain(bodyOf(PAGE_SIZES.length)); + for (const pageNumber of [2, 3, 4]) { + expect(text).not.toContain(bodyOf(pageNumber)); + } + const labels = text.match(//gu); + expect(labels).toEqual([ + pageNumberMarker(1), + pageNumberMarker(PAGE_SIZES.length), + ]); + expect(text.indexOf(pageNumberMarker(1))).toBeLessThan( + text.indexOf(bodyOf(1)), + ); + expect(text.indexOf(pageNumberMarker(PAGE_SIZES.length))).toBeLessThan( + text.indexOf(bodyOf(PAGE_SIZES.length)), + ); + expect(text.indexOf(bodyOf(1))).toBeLessThan( + text.indexOf(pageNumberMarker(PAGE_SIZES.length)), + ); + }); + + it("keeps the page-break marker between two selected pages that share a section", () => { + const text = markdownOf([0, -1]); + expect(text.split(PAGE_BREAK_MARKER)).toHaveLength(2); + }); + + it("labels a page that starts a section of its own as well as pages after a page break", () => { + const text = markdownOf([1, 2, 3]); + expect(text.match(//gu)).toEqual([ + pageNumberMarker(2), + pageNumberMarker(3), + pageNumberMarker(4), + ]); + }); + + it("labels nothing on a full conversion, whose output is unchanged by the option's existence", () => { + const text = markdownOf(); + expect(text).not.toMatch(//u); + for (const [index] of PAGE_SIZES.entries()) { + expect(text).toContain(bodyOf(index + 1)); + } + }); + + it("reports a DocumentTree with no pages envelope, its frames naming source page indices", () => { + let reported: DocumentTree | undefined; + pdfToMarkdown(fivePagePdf(), { + pages: [-1], + onDocument: (tree) => { + reported = tree; + }, + }); + expect(reported?.pages).toBeUndefined(); + expect(frameIndicesOf(reported)).toEqual([LAST_PAGE_INDEX]); + }); + + it("throws PdfPageSelectionError for an index naming no page", () => { + expect(() => markdownOf([PAGE_SIZES.length])).toThrow( + PdfPageSelectionError, + ); + }); +}); + +describe("readNativeDocumentTree and readContentDocument with a page selection", () => { + it("stamps every frame with the page's index in the source PDF and leaves the pages envelope off", () => { + const tree = readNativeDocumentTree("pdf", fivePagePdf(), { + pages: [1, LAST_PAGE_INDEX], + }); + expect(tree.pages).toBeUndefined(); + expect(frameIndicesOf(tree)).toEqual([1, LAST_PAGE_INDEX]); + expect(readNativeDocumentTree("pdf", fivePagePdf()).pages).toHaveLength( + PAGE_SIZES.length, + ); + }); + + it("reconstructs only the selected pages' content", () => { + const content = readContentDocument("pdf", fivePagePdf(), { + pages: [-1], + }); + const text = JSON.stringify(content); + expect(text).toContain(bodyOf(PAGE_SIZES.length)); + expect(text).not.toContain(bodyOf(1)); + }); +}); + +describe("pdfToSvg with a page selection", () => { + // The svg writer renders vectors only, so the page is identified by its size: the selected page is the one WIDE page. + const WIDE_PAGE_INDEX = PAGE_SIZES.indexOf(WIDE); + + it("needs no page option once the selection holds a single page, and writes that page", () => { + expect(() => pdfToSvg(fivePagePdf())).toThrow(); + const svg = new TextDecoder().decode( + pdfToSvg(fivePagePdf(), { pages: [WIDE_PAGE_INDEX] }), + ); + expect(svg).toContain( + `width="${String(WIDE.widthPt)}pt" height="${String(WIDE.heightPt)}pt"`, + ); + }); +}); + +describe("buildMarkdownText pageNumbers", () => { + it("is what a caller composing readPdf, reconstructWordprocessing and the writer passes to get the same labels", () => { + const layout = readPdf(fivePagePdf(), { pages: [0, -1] }); + const composed = renderContentDocumentToMarkdown( + reconstructWordprocessing(layout), + { + pageNumbers: layout.pages.map( + (page, position) => sourcePageIndex(page, position) + 1, + ), + }, + ); + expect(composed.trim()).toBe(markdownOf([0, -1]).trim()); + }); + + it("refuses a page-number count that differs from the document's page count", () => { + const content = reconstructWordprocessing( + readPdf(fivePagePdf(), { pages: [0, -1] }), + ); + expect(() => buildMarkdownText(content, { pageNumbers: [1] })).toThrow( + MarkdownPageNumberCountError, + ); + expect(() => + buildMarkdownText(content, { pageNumbers: [1, 2, 3] }), + ).toThrow(MarkdownPageNumberCountError); + }); + + it("is dropped again when the markdown is read back, leaving the page break it sits beside", () => { + const read = readMarkdownContent(markdownOf([0, -1])); + if (read.kind !== "wordprocessing") { + throw new Error("expected a wordprocessing document"); + } + const blocks = read.sections.flatMap((section) => section.blocks); + expect(JSON.stringify(blocks)).not.toMatch(//u); + expect(blocks.filter((block) => block.kind === "pageBreak")).toHaveLength( + 1, + ); + }); +}); + +describe("reconstruction of a page-selected layout", () => { + // One page, the last of a five-page source: index 4 at position 0, so any reconstruction reading the position instead of the source index reports page 0. + + const selectedLayout: LayoutDocument = { + formatVersion: LAYOUT_FORMAT_VERSION, + metadata: {}, + pageCount: PAGE_SIZES.length, + pages: [ + { + ...LETTER, + index: LAST_PAGE_INDEX, + items: [ + { + kind: "text", + text: bodyOf(LAST_PAGE_INDEX + 1), + xPt: TEXT_X_PT, + yPt: TEXT_Y_PT, + font: DEFAULT_LAYOUT_FONT, + sizePt: TEXT_SIZE_PT, + color: BLACK, + }, + ], + annotations: [ + { + subtype: "Text", + xPt: TEXT_X_PT, + yPt: TEXT_Y_PT, + widthPt: TEXT_SIZE_PT, + heightPt: TEXT_SIZE_PT, + contents: "A note", + }, + ], + }, + ], + images: {}, + }; + + it.each<{ + readonly kind: string; + readonly reconstruct: (layout: LayoutDocument) => ContentDocument; + }>([ + { kind: "wordprocessing", reconstruct: reconstructWordprocessing }, + { kind: "presentation", reconstruct: reconstructPresentation }, + { kind: "drawing", reconstruct: reconstructDrawing }, + { kind: "spreadsheet", reconstruct: reconstructSpreadsheet }, + ])( + "the $kind reconstruction stamps frames with the source page index", + ({ reconstruct }) => { + expect(frameIndicesOf(reconstruct(selectedLayout))).toEqual([ + LAST_PAGE_INDEX, + ]); + }, + ); + + it("names a reconstructed sheet after the source page", () => { + const content = reconstructSpreadsheet(selectedLayout); + if (content.kind !== "spreadsheet") { + throw new Error("expected a spreadsheet"); + } + expect(content.sheets.map((sheet) => sheet.name)).toEqual([ + `Sheet${String(LAST_PAGE_INDEX + 1)}`, + ]); + }); + + it("keys a comment's anchor and its definition by the source page index, so the two still meet", () => { + const key = `pdf-annot-${String(LAST_PAGE_INDEX)}-0`; + const content = reconstructWordprocessing(selectedLayout); + expect(JSON.stringify(content)).toContain(`"name":"${key}"`); + const tree = assembleTree(content); + stampPdfPackageTables(tree, selectedLayout); + expect(Object.keys(tree.definitions ?? {})).toContain(key); + }); +}); + +describe("buildMarkdownText pageNumbers page-start rule", () => { + it("does not start a page at a section that continues its predecessor's page", () => { + const section = { + pageSize: LETTER, + margins: { topPt: 0, rightPt: 0, bottomPt: 0, leftPt: 0 }, + }; + const paragraph = (text: string): ContentBlock => ({ + kind: "paragraph", + runs: [{ text }], + }); + const content: ContentDocument = { + kind: "wordprocessing", + metadata: {}, + sections: [ + { ...section, blocks: [paragraph("first")] }, + { + ...section, + breakType: "continuous", + blocks: [paragraph("same page")], + }, + { ...section, blocks: [paragraph("second")] }, + ], + }; + const text = buildMarkdownText(content, { pageNumbers: [7, 9] }); + expect(text.match(//gu)).toEqual([ + pageNumberMarker(7), + pageNumberMarker(9), + ]); + expect(text.indexOf("same page")).toBeLessThan( + text.indexOf(pageNumberMarker(9)), + ); + }); +}); diff --git a/packages/documents.js/src/convert/from-pdf.ts b/packages/documents.js/src/convert/from-pdf.ts index 01a830786..77f0446f0 100644 --- a/packages/documents.js/src/convert/from-pdf.ts +++ b/packages/documents.js/src/convert/from-pdf.ts @@ -42,6 +42,10 @@ export interface ReadNativeDocumentTreeOptions { readonly images?: MarkdownImageResolver; // Consulted only for a pdf source — every other format's reader has nothing to report through it. readonly sink?: PdfDiagnosticSink; + /** + * The pages of a pdf source to read, exactly as UnifiedConversionOptions.pages describes them (zero-based, negative from the end, document order, `PdfPageSelectionError` for an index naming no page). Consulted only for a pdf source, since no other format's reader has pages to select. Only the selected pages are parsed and reconstructed, and every frame in the result names the page's index in the source PDF. A DocumentTree read this way carries no `pages` envelope, because that array is indexed by those source indices and a selection cannot fill it. + */ + readonly pages?: readonly number[]; } // readContentDocument's own options are the identical shape — one options type for both, so a caller forwarding {signal, images, sink} through either entry point never needs to know which fields the OTHER one ignores. @@ -57,6 +61,7 @@ function readNativeContent( const layout = readPdf(bytes, { signal: options?.signal, sink: options?.sink, + pages: options?.pages, }); const content = reconstructWordprocessing(layout, { signal: options?.signal, @@ -78,10 +83,14 @@ export function readNativeDocumentTree( ): DocumentTree { const { content, layout } = readNativeContent(format, bytes, options); if (layout !== undefined) { - const pages = layout.pages.map((page) => ({ - widthPt: page.widthPt, - heightPt: page.heightPt, - })); + // A page-selected read's frames name source page indices, which an array of only the selected pages' sizes could not be indexed by, so its tree carries no pages envelope (see ReadNativeDocumentTreeOptions.pages). + const pages = + options?.pages === undefined + ? layout.pages.map((page) => ({ + widthPt: page.widthPt, + heightPt: page.heightPt, + })) + : undefined; const reported = assembleTree(content, pages); stampPdfPackageTables(reported, layout); return reported; @@ -101,6 +110,10 @@ export function readContentDocument( export interface PdfToDocumentOptions { readonly signal?: AbortSignal; readonly sink?: PdfDiagnosticSink; + /** + * The pages to convert, exactly as UnifiedConversionOptions.pages describes them: zero-based page-tree indices, a negative index counted from the end, duplicates collapsed, document order, and `PdfPageSelectionError` for an index naming no page. Only those pages are parsed and laid out, so converting a large file's cover or last page costs roughly the memory of those pages. pdfToMarkdown labels each page it writes with its original one-based page number (``), so the text says which pages it holds. + */ + readonly pages?: readonly number[]; // Called exactly once, synchronously, with the DocumentTree (the readPdf-produced layout, and the reconstructed content built from it) this conversion built internally, before the function returns its bytes — see convert.ts's DocumentToPdfOptions.onDocument for the mirrored X-to-PDF side of this same side channel. readonly onDocument?: (pkg: DocumentTree) => void; } diff --git a/packages/documents.js/src/convert/pdf-package-tables.ts b/packages/documents.js/src/convert/pdf-package-tables.ts index 1468f7569..f546ee424 100644 --- a/packages/documents.js/src/convert/pdf-package-tables.ts +++ b/packages/documents.js/src/convert/pdf-package-tables.ts @@ -5,6 +5,7 @@ import type { LayoutOutlineItem, LayoutStructureElement, } from "pdf-codec"; +import { sourcePageIndex } from "pdf-codec/layout"; // The package-table half of PDF-side construct surfacing (#721): stamping the LayoutDocument's document-level surfaces onto the tree the fromPdf executor assembles. The flat ContentDocument a reconstructor returns has no root for these tables (they are tree-only by design), so this runs immediately after assembleTree — the one place both the layout (the facts) and the package (the home) are in hand. Entry vocabularies are this package's own tenants inside the generic definitions-table facility: a destinations entry names a page and a view, an outline entry names its title/destination and parent, an attachment carries decoded bytes, a layer its visibility, and (#760) a structure entry states one tagged-PDF element's type and attributes, parent stated as a reference. @@ -100,7 +101,8 @@ export function stampPdfPackageTables( // Comment bodies: the same deterministic pdf-annot-{page}-{index} keys reconstruct.ts's anchor constructs reference, so the marker and its definition can never drift apart — both derive from one walk of the same annotations array. const definitions: Record = {}; - layout.pages.forEach((page, pageIndex) => { + layout.pages.forEach((page, position) => { + const pageIndex = sourcePageIndex(page, position); (page.annotations ?? []).forEach( (annotation: LayoutAnnotation, annotIndex) => { definitions[`pdf-annot-${String(pageIndex)}-${String(annotIndex)}`] = { diff --git a/packages/documents.js/src/index.ts b/packages/documents.js/src/index.ts index 4fe3ed783..55b230fdd 100644 --- a/packages/documents.js/src/index.ts +++ b/packages/documents.js/src/index.ts @@ -162,7 +162,11 @@ export type { LayoutSubpath, LayoutText, } from "pdf-codec"; -export { LAYOUT_FORMAT_VERSION, LayoutDocumentSchema } from "pdf-codec"; +export { + LAYOUT_FORMAT_VERSION, + LayoutDocumentSchema, + sourcePageIndex, +} from "pdf-codec"; export type { LayoutMetadata } from "document-schema.js"; // --- document-schema.js's own tree-form DocumentTree (structure, layout, and content fused in one tree since 4.0.0) and its self-describing-JSON helpers. contentDocumentWithSchema / documentSchemaKindOf / documentFromJson's ContentDocument branch / ContentDocumentJson all operate on the identical ContentDocument type exported above. The package's own layoutDocumentWithSchema/LayoutDocumentJson exports retired with the demotion: a LayoutDocument has no schema-stamped JSON envelope any more, it is a pdf-codec value. --- @@ -398,6 +402,7 @@ export type { export { NOOP_DIAGNOSTIC_SINK, PdfEncryptedError, + PdfPageSelectionError, PdfParseError, } from "pdf-codec"; export type { WinAnsiSubstitution } from "pdf-codec"; @@ -555,7 +560,13 @@ export { // markdown <-> ContentDocument — readMarkdownContent/buildMarkdownText are thin adapters over markdown-codec's own readMarkdownContent/writeMarkdownContent (see src/markdown/read.ts's own module comment), never re-exported here directly for the same reason ooxml.js's flat docx/pptx readers aren't (see this section's own top-of-file note): markdown-codec's own readMarkdownContent/writeMarkdownContent operate on document-schema.js's ContentDocument shape directly, a nominally different type from this package's own local ContentDocument above, so exposing both would invite a caller to reach for the wrong one. decodeMarkdownText/encodeMarkdownText are the byte<->text boundary markdown-codec itself has no opinion on (it operates on strings, not bytes), decodeMarkdownText working the character encoding out from the bytes rather than assuming UTF-8 and encodeMarkdownText always writing UTF-8 — exported for a caller composing readMarkdownContent/buildMarkdownText directly, matching every other independently-exported pipeline stage in this section. Construct markers pass straight through to markdown-codec's own writer, which resolves them as balanced brackets and renders each construct it has a markdown spelling for (a footnote, a blockquote division, a titled image) and renders the rest transparently. export { decodeMarkdownText, encodeMarkdownText } from "./markdown/text"; export { readMarkdownContent } from "./markdown/read"; -export { buildMarkdownText } from "./markdown/write"; +export type { BuildMarkdownTextOptions } from "./markdown/write"; +// pageNumbers labels each page of the written markdown with pageNumberMarker; the pdf conversions supply it for a page-selected read so the text says which original pages it holds, and a caller composing readPdf with a page selection, reconstructWordprocessing and this writer (or renderContentDocumentToMarkdown) supplies it the same way, from sourcePageIndex(page, position) + 1 per page. +export { + buildMarkdownText, + MarkdownPageNumberCountError, + pageNumberMarker, +} from "./markdown/write"; // csv <-> ContentDocument — the same independently-usable pipeline stages the other adapter families above expose: decodeCsvText/encodeCsvText are the byte<->text boundary, with decodeCsvText working the character encoding out from the bytes (byte-codec's decodeText) rather than assuming UTF-8, so Excel's own windows-1252 export and a UTF-16 save both read, and encodeCsvText always writing UTF-8, readCsvContent parses RFC 4180 text into a one-sheet spreadsheet ContentDocument (first record as the header row, data cells heuristically re-typed by inferCellValue — the identical heuristic pdfToOds applies, exported standalone further above), buildCsvText writes a sheet back out emitting each cell's displayText. TSV is a delimiter option on these same stages ({ delimiter: '\t' }), not a separate format. The errors are exported beside them so a caller composing the stages directly can branch on them by class: CsvUndecodableTextError (bytes that are not text in any supported character encoding), CsvParseError (RFC 4180 violations — an unterminated quoted field, a multi-character delimiter), CsvUnsupportedDocumentKindError (a non-spreadsheet ContentDocument), and the multi-sheet selection pair CsvSheetNotSpecifiedError/CsvSheetNotFoundError — the identical contract odbToCsv's own table selection follows. export { decodeCsvText, encodeCsvText } from "./csv/text"; export { CsvUndecodableTextError } from "./csv/text"; diff --git a/packages/documents.js/src/layout/reconstruct-presentations-prod.ts b/packages/documents.js/src/layout/reconstruct-presentations-prod.ts index 4a2d8c1fa..6edf020b3 100644 --- a/packages/documents.js/src/layout/reconstruct-presentations-prod.ts +++ b/packages/documents.js/src/layout/reconstruct-presentations-prod.ts @@ -28,6 +28,7 @@ import type { LayoutRect, LayoutSubpath, } from "pdf-codec"; +import { sourcePageIndex } from "pdf-codec/layout"; import { flipY } from "../model/geometry"; import { buildDrawingBlock } from "../model/embedded-drawing"; import { stampFrame } from "./shared"; @@ -64,9 +65,14 @@ export function reconstructPresentation( ): ContentDocument { const signal = options?.signal; const structure = indexStructure(doc); - const slides = doc.pages.map((page, pageIndex) => { + const slides = doc.pages.map((page, position) => { throwIfAborted(signal); - return reconstructSlide(page, pageIndex, doc.images, structure); + return reconstructSlide( + page, + sourcePageIndex(page, position), + doc.images, + structure, + ); }); return { kind: "presentation", metadata: doc.metadata, slides }; } @@ -320,9 +326,13 @@ export function reconstructDrawing( options?: ReconstructOptions, ): ContentDocument { const signal = options?.signal; - const pages: ContentDrawPage[] = doc.pages.map((page, pageIndex) => { + const pages: ContentDrawPage[] = doc.pages.map((page, position) => { throwIfAborted(signal); - return reconstructDrawPage(page, pageIndex, doc.images); + return reconstructDrawPage( + page, + sourcePageIndex(page, position), + doc.images, + ); }); return { kind: "drawing", metadata: doc.metadata, pages }; } diff --git a/packages/documents.js/src/layout/reconstruct.ts b/packages/documents.js/src/layout/reconstruct.ts index 6e26a0c0d..e9fc81e03 100644 --- a/packages/documents.js/src/layout/reconstruct.ts +++ b/packages/documents.js/src/layout/reconstruct.ts @@ -35,6 +35,7 @@ import type { LayoutText, LayoutDocument, } from "pdf-codec"; +import { sourcePageIndex } from "pdf-codec/layout"; import type { Margins } from "document-schema.js"; import { throwIfAborted } from "../ports/abort"; import { stampFrame } from "./shared"; @@ -76,21 +77,21 @@ export function reconstructWordprocessing( const structure = indexStructure(doc); const sections: ContentSection[] = []; let currentGroup: LayoutPage[] = []; - let groupStartPageIndex = 0; + let groupStartPosition = 0; for (const page of doc.pages) { throwIfAborted(signal); if (currentGroup.length > 0 && !samePageSize(currentGroup[0]!, page)) { sections.push( buildSection( currentGroup, - groupStartPageIndex, + groupStartPosition, doc.images, headingLevels, doc.form, structure, ), ); - groupStartPageIndex += currentGroup.length; + groupStartPosition += currentGroup.length; currentGroup = []; } currentGroup.push(page); @@ -99,7 +100,7 @@ export function reconstructWordprocessing( sections.push( buildSection( currentGroup, - groupStartPageIndex, + groupStartPosition, doc.images, headingLevels, doc.form, @@ -323,10 +324,10 @@ function samePageSize(a: LayoutPage, b: LayoutPage): boolean { } // Margins have no PDF equivalent to recover — there is no principled way to distinguish "intentional margin" from "wherever the content happened to start" from geometry alone, so this deliberately reports zero rather than fabricating a plausible-looking value (ZERO_MARGINS, defined above). -// startPageIndex is this section's own first page's absolute index in the whole LayoutDocument — a frame's pageIndex names a page of the SOURCE document, not a page within one section, so every block this section builds stamps absolute indices derived from it. +// startPosition is this section's own first page's position in the whole LayoutDocument's pages. A frame's pageIndex names a page of the SOURCE document, not a page within one section, so every block this section builds stamps the source index sourcePageIndex resolves from the page and its position: the position itself on a full read, the page's own index on a page-selected one, where it is also the index every form widget and destination already names. function buildSection( pages: readonly LayoutPage[], - startPageIndex: number, + startPosition: number, images: Record, headingLevels: ReadonlyMap, form: readonly LayoutFormField[] | undefined, @@ -342,7 +343,7 @@ function buildSection( blocks.push( ...reconstructPageBlocks( page, - startPageIndex + i, + sourcePageIndex(page, startPosition + i), images, headingLevels, form, @@ -731,9 +732,13 @@ export function reconstructSpreadsheet( options?: ReconstructOptions, ): ContentDocument { const signal = options?.signal; - const sheets: ContentSheet[] = doc.pages.map((page, index) => { + const sheets: ContentSheet[] = doc.pages.map((page, position) => { throwIfAborted(signal); - return reconstructSheet(page, index, options?.onCellTypeInference); + return reconstructSheet( + page, + sourcePageIndex(page, position), + options?.onCellTypeInference, + ); }); return { kind: "spreadsheet", metadata: doc.metadata, sheets }; } diff --git a/packages/documents.js/src/markdown/read.ts b/packages/documents.js/src/markdown/read.ts index cbbf1f9b9..a6e07a896 100644 --- a/packages/documents.js/src/markdown/read.ts +++ b/packages/documents.js/src/markdown/read.ts @@ -6,7 +6,7 @@ import { import type { ContentBlock, ContentDocument } from "document-schema.js"; import { lowerMarkdownMath } from "./math"; import type { MarkdownMathLoweringOptions } from "./math"; -import { PAGE_BREAK_MARKER } from "./write"; +import { PAGE_BREAK_MARKER, PAGE_NUMBER_MARKER_PATTERN } from "./write"; // markdown text -> ContentDocument (the wordprocessing variant). A thin adapter over markdown-codec's own readMarkdownContent, mirroring src/ooxml/docx/read.ts's readDocxContent / src/odf/ods/read.ts's readOdsContent: markdown-codec's own readMarkdownContent produces a document-schema.js ContentDocument (kind/formatVersion/metadata/sections) directly. This used to be two nominally distinct ContentDocument types (markdown-codec independently pinned document-schema.js at ^1.5.3, a pre-2.0.0 release, one major behind the 2.0.0+ this package depends on directly), which forced a re-parse through this package's own ContentDocumentSchema to sidestep the recursively-nested mismatch. That version skew is gone: markdown-codec now depends on document-schema.js@^2.2.4, the same range this package depends on directly, and pnpm resolves both to the single installed copy (`pnpm why document-schema.js` shows exactly one) — so the reader's return value is now genuinely, nominally the same ContentDocument type this function returns, and the plain pass-through this comment used to anticipate is what's below. markdown-codec 4.0.0 renamed this flat reader to readMarkdownContent and gave the bare readMarkdown name to its tree-form DocumentTree counterpart — the import is aliased here only because this module's own export is itself named readMarkdownContent. // @@ -29,7 +29,7 @@ export function readMarkdownContent( return lowerMarkdownMath(promotePageBreakMarkers(document), math); } -// The read-side inverse of src/markdown/write.ts's PAGE_BREAK_MARKER: markdown-codec lowers an `` HTML comment to an HTMLPreformatted paragraph carrying that literal text, and this pass promotes exactly that paragraph — the whole paragraph's text equal to the marker, nothing less — back to the pageBreak block it spelled, so a marker in markdown means a page boundary in the ContentDocument (markdownToPdf renders one; buildDocxPackage/buildOdtPackage write one). The match is deliberately exact rather than substring-based: a longer comment merely CONTAINING the marker is a genuine comment, not this package's page-break directive, and stays a paragraph. Runs before lowerMarkdownMath because a marker paragraph carries no math to lower either way. The walk is top-level only by structure, not oversight: GFM table cells hold inline content, so markdown-codec lowers a cell's `` to plain literal text runs — never the HTMLPreformatted paragraph shape this pass matches — and a marker inside a table cell can therefore never be promoted; it reads back as its literal text, the one-way annotation src/markdown/write.ts's own recursion emits it as. +// The read-side inverse of src/markdown/write.ts's PAGE_BREAK_MARKER: markdown-codec lowers an `` HTML comment to an HTMLPreformatted paragraph carrying that literal text, and this pass promotes exactly that paragraph — the whole paragraph's text equal to the marker, nothing less — back to the pageBreak block it spelled, so a marker in markdown means a page boundary in the ContentDocument (the page labels a page-selected conversion writes beside it are dropped on the same walk, see isPageNumberMarker) (markdownToPdf renders one; buildDocxPackage/buildOdtPackage write one). The match is deliberately exact rather than substring-based: a longer comment merely CONTAINING the marker is a genuine comment, not this package's page-break directive, and stays a paragraph. Runs before lowerMarkdownMath because a marker paragraph carries no math to lower either way. The walk is top-level only by structure, not oversight: GFM table cells hold inline content, so markdown-codec lowers a cell's `` to plain literal text runs — never the HTMLPreformatted paragraph shape this pass matches — and a marker inside a table cell can therefore never be promoted; it reads back as its literal text, the one-way annotation src/markdown/write.ts's own recursion emits it as. type WordprocessingContent = Extract< ContentDocument, { kind: "wordprocessing" } @@ -42,11 +42,22 @@ function promotePageBreakMarkers( ...document, sections: document.sections.map((section) => ({ ...section, - blocks: section.blocks.map(promoteBlock), + blocks: section.blocks + .filter((block) => !isPageNumberMarker(block)) + .map(promoteBlock), })), }; } +// A page label src/markdown/write.ts's pageNumberMarker wrote, in the same lowered shape the page-break marker arrives in (a single HTMLPreformatted paragraph whose whole text is the marker). Dropped rather than kept as a paragraph: it annotates which page the text came from, a fact about the markdown's source rather than content of the document, and kept it would render as visible literal text in any format markdown converts to. +function isPageNumberMarker(block: ContentBlock): boolean { + return ( + block.kind === "paragraph" && + block.styleId === HTML_PREFORMATTED_STYLE_ID && + PAGE_NUMBER_MARKER_PATTERN.test(block.runs.map((run) => run.text).join("")) + ); +} + // Exported (not merely internal) so the two conditions that gate a promotion — the block's own styleId, and the exact (not merely substring, not merely per-run) text match — are directly testable: a real markdown-codec-lowered marker paragraph is always a single run, so a hand-built multi-run block is the only way to exercise the join("") boundary, and a same-text-wrong-style paragraph is not a shape markdown-codec's own HTML-block lowering can produce for anything OTHER than this exact marker's own preformatted styling. export function promoteBlock(block: ContentBlock): ContentBlock { if ( diff --git a/packages/documents.js/src/markdown/render.ts b/packages/documents.js/src/markdown/render.ts index d1152e560..bc84eac5f 100644 --- a/packages/documents.js/src/markdown/render.ts +++ b/packages/documents.js/src/markdown/render.ts @@ -13,9 +13,9 @@ import type { LayoutMetadata, } from "document-schema.js"; import { PAGE_SIZE_A4 } from "document-schema.js"; -import type { WriteMarkdownOptions } from "markdown-codec"; import { headingStyleId } from "markdown-codec"; import { formulaPlaceholderText } from "../model/formula"; +import type { BuildMarkdownTextOptions } from "./write"; import { buildMarkdownText } from "./write"; // documents.js has plenty of ways to get INTO a ContentDocument from any of its eight content formats, and one way out to real bytes per format via buildXPackage — but no way to get plain Markdown text out of an arbitrary ContentDocument, regardless of which of the five kinds it happens to be. buildMarkdownText (src/markdown/write.ts) already covers the one kind markdown-codec itself understands ('wordprocessing'); this module is what makes every OTHER kind reachable too, by flattening its own shape-specific structure (slides, sheets, drawing pages, a bare formula) into the same ContentBlock vocabulary markdown-codec's writeMarkdownContent already knows how to render, then delegating to buildMarkdownText. @@ -56,7 +56,7 @@ const NOOP_RENDER_DIAGNOSTIC_SINK: MarkdownRenderDiagnosticSink = () => { // Deliberately empty — the default when a caller supplies no onDiagnostic, mirroring markdown-codec's own NOOP_MARKDOWN_DIAGNOSTIC_SINK. }; -export interface RenderMarkdownOptions extends WriteMarkdownOptions { +export interface RenderMarkdownOptions extends BuildMarkdownTextOptions { // Called once per degrade decision this module makes while flattening a non-wordprocessing ContentDocument. Never called for a 'wordprocessing' document — there is nothing to degrade on that path, since it goes straight to writeMarkdownContent. readonly onDiagnostic?: MarkdownRenderDiagnosticSink; } diff --git a/packages/documents.js/src/markdown/write.ts b/packages/documents.js/src/markdown/write.ts index 9841246f4..741c6b643 100644 --- a/packages/documents.js/src/markdown/write.ts +++ b/packages/documents.js/src/markdown/write.ts @@ -24,6 +24,87 @@ const MATH_INLINE_SOURCE = "markdown:math-inline"; // The read-side inverse lives in src/markdown/read.ts and turns this exact literal back into a pageBreak block, so the marker MEANS a page break rather than decorating one: markdownToPdf re-renders a real page boundary, and a pdfToMarkdown -> markdownToPdf round trip regenerates markers from real boundaries instead of accumulating the previous round's markers as visible literal text. export const PAGE_BREAK_MARKER = ""; +/** + * The marker opening a page that `BuildMarkdownTextOptions.pageNumbers` labels: an HTML comment naming the page's number, `` for page 7, on a line of its own. A one-way annotation for readers of the text rather than a directive: unlike {@link PAGE_BREAK_MARKER} it states which page follows, not that a page ends, so reading markdown back drops it (src/markdown/read.ts) instead of turning it into content. + * @param pageNumber - the number the page is labelled with, as the caller supplied it + * @returns the marker text + */ +export function pageNumberMarker(pageNumber: number): string { + return ``; +} + +// The exact shape pageNumberMarker writes, for the read side to recognise and drop: the number is whatever the caller labelled the page with, so any run of digits matches. +export const PAGE_NUMBER_MARKER_PATTERN = /^$/u; + +/** + * `BuildMarkdownTextOptions.pageNumbers` did not supply exactly one number per page of the document. Thrown rather than labelling a prefix or padding, since a label on the wrong page is worse than none. + */ +export class MarkdownPageNumberCountError extends Error { + readonly pageCount: number; + readonly pageNumberCount: number; + + constructor(pageCount: number, pageNumberCount: number) { + super( + `pageNumbers supplies ${String(pageNumberCount)} page numbers for a document of ${String(pageCount)} pages; it must supply exactly one per page`, + ); + this.name = "MarkdownPageNumberCountError"; + this.pageCount = pageCount; + this.pageNumberCount = pageNumberCount; + } +} + +export interface BuildMarkdownTextOptions extends WriteMarkdownOptions { + /** + * One number per page of the document, in page order, written as {@link pageNumberMarker} at the start of each page so a reader of the text can tell which page each part came from. A page starts at the beginning of the document, after every top-level page break, and at the start of every section after the first that does not continue its predecessor's page (`breakType` other than `continuous`). Omitted, no page is labelled. The pdf conversions supply it for a page-selected read (`pages`), with each page's original one-based page number, since there the position of a part no longer says which page it is. + * @throws MarkdownPageNumberCountError when the count differs from the document's page count + */ + readonly pageNumbers?: readonly number[]; +} + +function pageNumberParagraph(pageNumber: number): ContentBlock { + return { + kind: "paragraph", + runs: [{ text: pageNumberMarker(pageNumber) }], + styleId: HTML_PREFORMATTED_STYLE_ID, + }; +} + +type WordprocessingSection = Extract< + ContentDocument, + { kind: "wordprocessing" } +>["sections"][number]; + +// Inserts pageNumberMarker at the start of every page, by the page-start rule BuildMarkdownTextOptions.pageNumbers states. Runs on the content before markdownBlock lowers page breaks, so a page break is still a pageBreak block here. +function labelPages( + sections: readonly WordprocessingSection[], + pageNumbers: readonly number[], +): WordprocessingSection[] { + let pageCount = 0; + // A page beyond the supplied numbers is counted but left unlabelled; the count check below then refuses the whole call, so no partially labelled text is ever returned. + const label = (): ContentBlock[] => { + const pageNumber = pageNumbers[pageCount]; + pageCount++; + return pageNumber === undefined ? [] : [pageNumberParagraph(pageNumber)]; + }; + const labelled = sections.map((section, sectionIndex) => { + const blocks: ContentBlock[] = []; + if (sectionIndex === 0 || section.breakType !== "continuous") { + blocks.push(...label()); + } + for (const block of section.blocks) { + blocks.push(block); + if (block.kind === "pageBreak") { + blocks.push(...label()); + } + } + return { ...section, blocks }; + }); + if (pageCount !== pageNumbers.length) { + throw new MarkdownPageNumberCountError(pageCount, pageNumbers.length); + } + return labelled; +} + function formulaParagraph( formula: NonNullable>, ): ContentBlock { @@ -89,12 +170,17 @@ function markdownTableCell(cell: ContentTableCell): ContentTableCell { // writeMarkdownContent throws MarkdownUnsupportedDocumentKindError on its own for a non-'wordprocessing' input — this adapter's own guard mirrors that exact check, now for a genuinely different reason than it used to: TypeScript needs `document` narrowed to the wordprocessing variant before this function can map over its own `sections` field at all (a presentation/spreadsheet/drawing/formula-kind ContentDocument has no `sections` field), not to work around any version-skew mismatch. export function buildMarkdownText( document: ContentDocument, - options?: WriteMarkdownOptions, + options?: BuildMarkdownTextOptions, ): string { if (document.kind !== "wordprocessing") { throw new MarkdownUnsupportedDocumentKindError(document.kind); } - const sections = document.sections.map((section) => ({ + const pageNumbers = options?.pageNumbers; + const labelled = + pageNumbers === undefined + ? document.sections + : labelPages(document.sections, pageNumbers); + const sections = labelled.map((section) => ({ ...section, blocks: section.blocks.map(markdownBlock), })); diff --git a/packages/pdf-codec/README.md b/packages/pdf-codec/README.md index 28997c048..2779d9c37 100644 --- a/packages/pdf-codec/README.md +++ b/packages/pdf-codec/README.md @@ -93,6 +93,8 @@ An encrypted PDF that opens without a password decrypts transparently — no ext Both accept an optional `signal` (`AbortSignal`); `readPdf` additionally takes a `sink` (`PdfDiagnosticSink`, called once per recoverable parse diagnostic — see the three-tier failure policy under [Conventions](#conventions)), and `writePdf` an `onSubstitution` callback (called once per character not representable in a standard-14 font — see [Fidelity](#fidelity)). +**Reading a subset of pages.** `readPdf(bytes, { pages: [0, 1, 2, -1] })` reads only the listed pages: zero-based indices into the page tree, a negative index counted from the end (`-1` is the last page). Only the selected pages' content streams are fetched, decoded and interpreted, and only the fonts and images those pages use are resolved, so reading a large file's cover or last page costs roughly the memory of those pages rather than of the whole file (the case this exists for: identifying a document inside a memory-capped runtime such as a 128 MB Cloudflare Worker isolate). The page tree is still walked in full and the document-level surfaces (metadata, outline, destinations, layers, form, structure tree, attachments, residue) still read from the whole document, since none of them touches page content. Duplicates collapse and the result's `pages` holds the selected pages in ascending document order, each carrying its source position as `index` (read it through `sourcePageIndex(page, position)`, which falls back to the position on a full read), with the page tree's length as the document's `pageCount`; every document-level page reference keeps naming source indices, so a destination or form widget pointing at a selected page resolves through `index`, and one pointing at an unselected page names a page the result does not hold. A full read sets neither field, because there `pages.length` and each page's position already state them. An empty selection is valid and reads no page, which makes `{ pages: [] }` the cheapest way to learn a file's page count and metadata. An entry naming no page (outside `-pageCount` to `pageCount - 1`, or not an integer) throws `PdfPageSelectionError` (a `PdfParseError` with code `pdf/page-out-of-range` or `pdf/page-index-not-integer`, carrying `pageIndex` and `pageCount`) before any page is read. A page-selected `LayoutDocument` is a reading of part of a file, not a complete document: `writePdf` places pages by position, so writing one back does not remap the references that name source indices. + **Page boundaries: the crop box is the visible region** (ISO 32000-1 14.11.2). A page's reported `widthPt`/`heightPt` and coordinate frame come from its effective `/CropBox` — the rectangle a viewer displays and prints, inherited through the page tree like `/MediaBox` and defaulting to it — not from the media box: content an author placed outside the crop box (printer's marks, bleed, off-page slugs) is not visible and does not extract. Content wholly outside the crop box is dropped; content straddling the boundary keeps its original unclipped geometry (the item layer records what the file states — a viewer's clipping is a rendering fact, not source data); link annotations are anchored constructs rather than painted content and are never filtered, the same line the optional-content filter draws. The declared boundary rectangles a distinct crop box hides — plus `/BleedBox`/`/TrimBox`/`/ArtBox`, print-production facts with no field in the layout model — are quarantined verbatim as the `page-boxes` package-level residue row rather than silently dropped. A degenerate `/CropBox` (zero width or height) falls back to the media box with a `pdf/invalid-crop-box` warning. **Cancellation granularity and cost, for CPU-metered runtimes.** Both pipelines are synchronous end to end — there is no `await` point for cancellation to hook into implicitly — so the `signal` is checked explicitly, once per page-loop iteration (and once before `readPdf`'s document-open phase begins). A signal aborted mid-parse therefore takes effect at the next page boundary, not instantly: `readPdf`'s document-open phase (cross-reference resolution, object parsing) and a single page's content-stream interpretation are the two spans that cannot be interrupted, and a document consisting of one enormous page is effectively uninterruptible however many pages it claims. Cost is roughly linear in decompressed content length, so budget for the worst single page, not the page count. On Cloudflare Workers this is the honest shape of the trade: the parse holds the isolate for its whole duration with no opportunity to yield or report progress, and an `AbortSignal` shared with whatever can abort concurrently (a binding, another context) makes a deadline enforceable at page granularity — but it cannot convert a synchronous parse into a resumable one. An async page-at-a-time API is a deliberate non-goal of this package's current surface. @@ -275,7 +277,7 @@ Dependency direction is strictly downward and checkable: `layout` imports only ` - **`PdfObject` has no Zod schema at all**, deliberately: it never crosses a public boundary or round-trips through JSON, and is constructed exclusively by this package's own parser. It narrows natively on its own `kind` discriminant. - **The `MathBox`/`MathFontMetrics` family is structurally typed on purpose, not validated by Zod** — the mechanism that lets a caller (`documents.js`) hand this package a real value produced by a completely independent module, with zero cast, zero wrapper, and no shared class or branded type. - **No type assertions anywhere.** Every third-party or loosely-typed value is narrowed through a type guard or a Zod parse at the boundary. -- **A three-tier PDF-read failure policy**: throw a typed `PdfParseError`/`PdfEncryptedError`/`PdfPasswordRequiredError` for a file that cannot be meaningfully processed at all; recover with a `PdfDiagnostic` (`severity: 'warning'`) for something malformed but salvageable; degrade with a diagnostic for an individual unsupported feature while the rest of the document still reads. +- **A three-tier PDF-read failure policy**: throw a typed `PdfParseError`/`PdfEncryptedError`/`PdfPasswordRequiredError` for a file that cannot be meaningfully processed at all (or `PdfPageSelectionError` for a page selection naming no page of it); recover with a `PdfDiagnostic` (`severity: 'warning'`) for something malformed but salvageable; degrade with a diagnostic for an individual unsupported feature while the rest of the document still reads. - **Conventional commits**, enforced via commitlint + husky. ## Gotchas and quirks diff --git a/packages/pdf-codec/src/diagnostics.ts b/packages/pdf-codec/src/diagnostics.ts index ca6fc0403..a2cee8d0c 100644 --- a/packages/pdf-codec/src/diagnostics.ts +++ b/packages/pdf-codec/src/diagnostics.ts @@ -28,6 +28,29 @@ export class PdfParseError extends Error { } } +/** + * A `ReadPdfOptions.pages` entry that names no page of the document: an integer outside `[-pageCount, pageCount - 1]` (code `pdf/page-out-of-range`) or a value that is not an integer at all (code `pdf/page-index-not-integer`). Thrown rather than skipped, because a caller asking for a page it believes exists has a wrong belief about the file, and a result quietly missing that page would let the wrong belief stand. + * + * `pageIndex` is the entry exactly as the caller wrote it (negative indices included); `pageCount` is the number of pages the document's page tree holds, so the caller can correct the selection without a second read. + */ +export class PdfPageSelectionError extends PdfParseError { + readonly pageIndex: number; + readonly pageCount: number; + + constructor(pageIndex: number, pageCount: number) { + const isInteger = Number.isInteger(pageIndex); + super( + isInteger ? "pdf/page-out-of-range" : "pdf/page-index-not-integer", + isInteger + ? `page index ${pageIndex} names no page of this ${pageCount}-page document; a selection takes zero-based indices from 0 to pageCount - 1, or negative indices from -pageCount to -1 counted from the end` + : `page index ${pageIndex} is not an integer; a selection takes zero-based integer page indices`, + ); + this.name = "PdfPageSelectionError"; + this.pageIndex = pageIndex; + this.pageCount = pageCount; + } +} + // /Encrypt present, and encrypted in a way this codec cannot read at all: a non-standard security handler (/Filter other than /Standard, e.g. a public-key one), an unpublished or unrecognised /V, or a crypt filter naming a /CFM this codec does not implement. Distinct from PdfPasswordRequiredError below, which means the encryption itself IS supported and the file simply needs a password we do not have. export class PdfEncryptedError extends PdfParseError { constructor(message = "this PDF is encrypted and unsupported") { diff --git a/packages/pdf-codec/src/index.ts b/packages/pdf-codec/src/index.ts index 6f3de043d..cb03dbcd9 100644 --- a/packages/pdf-codec/src/index.ts +++ b/packages/pdf-codec/src/index.ts @@ -20,6 +20,7 @@ export type { export { NOOP_DIAGNOSTIC_SINK, PdfEncryptedError, + PdfPageSelectionError, PdfParseError, PdfPasswordRequiredError, } from "./diagnostics"; diff --git a/packages/pdf-codec/src/layout.ts b/packages/pdf-codec/src/layout.ts index 976b88d3b..7b579500a 100644 --- a/packages/pdf-codec/src/layout.ts +++ b/packages/pdf-codec/src/layout.ts @@ -204,9 +204,21 @@ export const LayoutPageSchema = z.object({ annotations: z.array(LayoutAnnotationSchema).optional(), // pptx speaker notes for the slide this page came from, if any — carried as a private, non-visible entry on the PDF page's own dictionary, never painted into the page content. PDF has no native concept of hidden presenter notes, so this is a round-trip mechanism specific to a writer/reader pair that both honour it, not a real PDF feature — a PDF produced by anything else will never have it, and a PDF consumer that doesn't specifically know this convention will never see it either. notes: z.string().optional(), + // The page's zero-based position in the source PDF's page tree, set by readPdf on every page of a page-selected read (ReadPdfOptions.pages), where the page's position in `pages` no longer says which page it is. Absent on a full read and on a page any other producer builds: there the position in `pages` IS the index, and stating it twice would be a second copy to keep in agreement. Every document-level reference to a page (a destination's or form widget's pageIndex, a page-boxes residue row, a structure association) names this source index, never the position. Read it through sourcePageIndex rather than branching on its presence. + index: z.number().int().nonnegative().optional(), }); export type LayoutPage = z.infer; +/** + * The zero-based index in the source PDF's page tree of the page at `position` in a `LayoutDocument`'s `pages`: the page's own `index` when a page-selected read set one, otherwise `position` itself, which is the source index whenever `pages` holds every page in document order. The one place that rule is spelled, so every consumer resolving a page against a document-level reference (a destination, a form widget, a frame) agrees on it. + * @param page - the page as it appears in `pages` + * @param position - the page's position in `pages` + * @returns the page's index in the source document's page tree + */ +export function sourcePageIndex(page: LayoutPage, position: number): number { + return page.index ?? position; +} + // An entry in the top-level image registry: bytes live here once, keyed by imageId, so a repeated logo across many pages/slides embeds (or extracts) exactly once. Bytes are the original file bytes for the given format — PNG bytes are re-encoded from decoded pixels where needed; JPEG bytes are the original encoded stream, verbatim, in both directions. export const LayoutImageAssetSchema = z.object({ format: z.enum(["png", "jpeg"]), @@ -373,6 +385,8 @@ export const LayoutDocumentSchema = z.object({ formatVersion: z.literal(LAYOUT_FORMAT_VERSION), metadata: LayoutMetadataSchema, pages: z.array(LayoutPageSchema), + // The number of pages in the source PDF's page tree, set by readPdf on a page-selected read (ReadPdfOptions.pages), where `pages` holds only the selection and so no longer says how long the document is: a caller that asked for the last page, or wants to know which pages it has not read, needs the total from the same read rather than from a second pass over the file. Absent on a full read, where it would restate pages.length. A field rather than a separate page-count function because the page tree is walked by readPdf anyway (destinations and the selection both resolve against it), so reporting its length costs nothing, while a separate call would parse the cross-reference table and walk the tree a second time. + pageCount: z.number().int().nonnegative().optional(), images: z.record(z.string(), LayoutImageAssetSchema), destinations: z.array(LayoutDestinationSchema).optional(), outline: z.array(LayoutOutlineItemSchema).optional(), diff --git a/packages/pdf-codec/src/read-page-selection.test.ts b/packages/pdf-codec/src/read-page-selection.test.ts new file mode 100644 index 000000000..d9b775833 --- /dev/null +++ b/packages/pdf-codec/src/read-page-selection.test.ts @@ -0,0 +1,209 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type * as Interpret from "./interpret"; +import type { PdfDiagnostic } from "./diagnostics"; +import { PdfPageSelectionError, PdfParseError } from "./diagnostics"; +import { interpretContentStream } from "./interpret"; +import { sourcePageIndex } from "./layout"; +import { readPdf } from "./read"; +import { + PAGE_SELECTION_IMAGE_PAGE_INDEX, + PAGE_SELECTION_TRAP_PAGE_INDEX, + navigationClusterPdf, + pagelessPdf, + pageSelectionPdf, +} from "./test-support/pdf"; + +// The interpreter entry readPage calls, wrapped so a test can count how many content streams a read interpreted. The real implementation still runs: this observes, it does not stub. +vi.mock("./interpret", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + interpretContentStream: vi.fn(actual.interpretContentStream), + }; +}); + +const PAGE_COUNT = 4; +const LAST_PAGE_INDEX = PAGE_COUNT - 1; + +function pageTexts(doc: ReturnType): string[] { + return doc.pages.map((page) => + page.items + .flatMap((item) => (item.kind === "text" ? [item.text] : [])) + .join(""), + ); +} + +function collectingSink(): { + readonly diagnostics: PdfDiagnostic[]; + readonly sink: (diagnostic: PdfDiagnostic) => void; +} { + const diagnostics: PdfDiagnostic[] = []; + return { + diagnostics, + sink: (diagnostic) => { + diagnostics.push(diagnostic); + }, + }; +} + +beforeEach(() => { + vi.mocked(interpretContentStream).mockClear(); +}); + +describe("readPdf: page selection never touches an unselected page's content", () => { + it("interprets one content stream per selected page, and none for the rest", () => { + readPdf(pageSelectionPdf()); + expect(interpretContentStream).toHaveBeenCalledTimes(PAGE_COUNT); + + vi.mocked(interpretContentStream).mockClear(); + readPdf(pageSelectionPdf(), { pages: [0, -1] }); + expect(interpretContentStream).toHaveBeenCalledTimes(2); + }); + + it("never decodes an unselected page's content stream: the trap page's undecodable filter is reported only when that page is read", () => { + const full = collectingSink(); + readPdf(pageSelectionPdf(), { sink: full.sink }); + expect(full.diagnostics.map((d) => d.code)).toContain( + "pdf/unsupported-filter", + ); + + const selected = collectingSink(); + readPdf(pageSelectionPdf(), { + sink: selected.sink, + pages: [...Array(PAGE_COUNT).keys()].filter( + (index) => index !== PAGE_SELECTION_TRAP_PAGE_INDEX, + ), + }); + expect(selected.diagnostics).toEqual([]); + }); + + it("never resolves an image only an unselected page draws", () => { + expect(Object.keys(readPdf(pageSelectionPdf()).images)).toHaveLength(1); + expect(readPdf(pageSelectionPdf(), { pages: [0, -1] }).images).toEqual({}); + expect( + Object.keys( + readPdf(pageSelectionPdf(), { + pages: [PAGE_SELECTION_IMAGE_PAGE_INDEX], + }).images, + ), + ).toHaveLength(1); + }); +}); + +describe("readPdf: page selection result", () => { + it("returns exactly the selected pages, page-for-page equal to the same pages of a full read, each carrying its source index", () => { + const full = readPdf(pageSelectionPdf()); + const selection = [0, PAGE_SELECTION_IMAGE_PAGE_INDEX, LAST_PAGE_INDEX]; + const selected = readPdf(pageSelectionPdf(), { pages: selection }); + expect(selected.pages).toEqual( + selection.map((index) => ({ ...full.pages[index], index })), + ); + expect(selected.images).toEqual(full.images); + }); + + it("reports the page tree's length as pageCount on a selected read, and leaves both pageCount and index off a full read", () => { + const full = readPdf(pageSelectionPdf()); + expect(full.pageCount).toBeUndefined(); + expect(full.pages.every((page) => page.index === undefined)).toBe(true); + expect(readPdf(pageSelectionPdf(), { pages: [0] }).pageCount).toBe( + PAGE_COUNT, + ); + }); + + it("counts a negative index from the end", () => { + const doc = readPdf(pageSelectionPdf(), { pages: [-1, -PAGE_COUNT] }); + expect(doc.pages.map((page) => page.index)).toEqual([0, LAST_PAGE_INDEX]); + expect(pageTexts(doc)).toEqual(["Page 1", `Page ${PAGE_COUNT}`]); + }); + + it("collapses duplicates, including a negative and a positive index naming the same page, and orders the result by document order", () => { + const doc = readPdf(pageSelectionPdf(), { + pages: [LAST_PAGE_INDEX, 0, -1, 0], + }); + expect(doc.pages.map((page) => page.index)).toEqual([0, LAST_PAGE_INDEX]); + }); + + it("reads no page for an empty selection, while still reporting pageCount and the document-level surfaces", () => { + const doc = readPdf(navigationClusterPdf(), { pages: [] }); + expect(interpretContentStream).not.toHaveBeenCalled(); + const full = readPdf(navigationClusterPdf()); + expect(doc.pages).toEqual([]); + expect(doc.pageCount).toBe(full.pages.length); + expect(doc.outline).toEqual(full.outline); + }); + + it("keeps document-level page references on source indices", () => { + const full = readPdf(navigationClusterPdf()); + const secondPageOnly = readPdf(navigationClusterPdf(), { pages: [1] }); + expect(secondPageOnly.outline).toEqual(full.outline); + const named = (doc: ReturnType, name: string) => + doc.destinations?.find((destination) => destination.name === name); + expect(named(secondPageOnly, "second")).toEqual(named(full, "second")); + expect(named(secondPageOnly, "second")?.pageIndex).toBe(1); + expect(secondPageOnly.pages[0]?.index).toBe(1); + }); + + it("reads every page when pages is omitted", () => { + expect(pageTexts(readPdf(pageSelectionPdf()))).toEqual([ + "Page 1", + "Page 2", + "Page 3", + "Page 4", + ]); + }); +}); + +describe("readPdf: an invalid page selection throws", () => { + it.each([PAGE_COUNT, -PAGE_COUNT - 1])( + "refuses index %i, naming the index and the page count", + (requested) => { + const read = () => readPdf(pageSelectionPdf(), { pages: [0, requested] }); + expect(read).toThrow(PdfPageSelectionError); + expect(read).toThrow(PdfParseError); + try { + read(); + } catch (error) { + expect(error).toMatchObject({ + code: "pdf/page-out-of-range", + pageIndex: requested, + pageCount: PAGE_COUNT, + }); + expect(String(error)).toContain(String(requested)); + expect(String(error)).toContain(String(PAGE_COUNT)); + } + }, + ); + + it("refuses a non-integer index", () => { + expect(() => readPdf(pageSelectionPdf(), { pages: [0.5] })).toThrow( + expect.objectContaining({ + code: "pdf/page-index-not-integer", + pageIndex: 0.5, + }), + ); + }); + + it("refuses any index on a document with no pages", () => { + expect(() => readPdf(pagelessPdf(), { pages: [0] })).toThrow( + expect.objectContaining({ code: "pdf/page-out-of-range", pageCount: 0 }), + ); + expect(readPdf(pagelessPdf(), { pages: [] }).pageCount).toBe(0); + }); + + it("throws before interpreting any page", () => { + expect(() => + readPdf(pageSelectionPdf(), { pages: [0, PAGE_COUNT] }), + ).toThrow(PdfPageSelectionError); + expect(interpretContentStream).not.toHaveBeenCalled(); + }); +}); + +describe("sourcePageIndex", () => { + it("prefers a page's own index over its position, and falls back to the position", () => { + const page = { widthPt: 1, heightPt: 1, items: [] }; + expect(sourcePageIndex({ ...page, index: LAST_PAGE_INDEX }, 0)).toBe( + LAST_PAGE_INDEX, + ); + expect(sourcePageIndex(page, 2)).toBe(2); + }); +}); diff --git a/packages/pdf-codec/src/read.ts b/packages/pdf-codec/src/read.ts index 5ef85dbd0..8892186f3 100644 --- a/packages/pdf-codec/src/read.ts +++ b/packages/pdf-codec/src/read.ts @@ -21,7 +21,11 @@ import { LAYOUT_FORMAT_VERSION } from "./layout"; import { openPdfDocument } from "./document"; import type { PdfDocument } from "./document"; import type { PdfDiagnosticSink } from "./diagnostics"; -import { NOOP_DIAGNOSTIC_SINK, PdfParseError } from "./diagnostics"; +import { + NOOP_DIAGNOSTIC_SINK, + PdfPageSelectionError, + PdfParseError, +} from "./diagnostics"; import { decodeStream } from "./filters"; import { throwIfAborted } from "./util/abort"; import type { FontResolverService } from "./font-read"; @@ -57,6 +61,30 @@ import { decodePdfString, parsePdfDate } from "./pdf-text"; export interface ReadPdfOptions { readonly sink?: PdfDiagnosticSink; readonly signal?: AbortSignal; + /** + * The pages to read, as zero-based indices into the document's page tree; a negative index counts from the end (`-1` is the last page). Omitted, every page is read. + * + * Only the selected pages' content streams are fetched, decoded and interpreted, so the fonts and images only unselected pages use are never resolved either: reading the cover or the last page of a large file costs roughly the memory of those pages, not of the file. The page tree itself is still walked in full (a negative index and every document-level page reference resolve against it), and the document-level readers still run over the whole document, since none of them touches page content: the outline, destinations, optional-content layers, the AcroForm, the structure tree, metadata and package residue, and embedded files (whose `/FileAttachment` annotations are read from every page's annotation dictionaries, never its content). + * + * Duplicates collapse, and the result's `pages` holds the selected pages in ascending document order whatever order they were asked for in, each carrying its source position as `index`, with the document's total as `pageCount`; every document-level reference keeps naming source indices. An empty array is a valid selection: it reads no page and returns the document-level surfaces with `pageCount`, the cheapest way to learn a file's length and metadata. An entry naming no page throws `PdfPageSelectionError` rather than being skipped. + */ + readonly pages?: readonly number[]; +} + +// Resolves a page selection against the page tree's length: negative indices counted from the end, duplicates collapsed, and the result as a set, since readPdf walks the tree in document order and asks it per page. Throws on the first entry that names no page, before any page is read. +function resolvePageSelection( + selection: readonly number[], + pageCount: number, +): ReadonlySet { + const resolved = new Set(); + for (const requested of selection) { + const index = requested < 0 ? pageCount + requested : requested; + if (!Number.isInteger(requested) || index < 0 || index >= pageCount) { + throw new PdfPageSelectionError(requested, pageCount); + } + resolved.add(index); + } + return resolved; } const PDF_HEADER_BYTES = new TextEncoder().encode("%PDF-"); @@ -103,6 +131,10 @@ export function readPdf( // The page tree is walked first (materialising the page-index map a destination's page reference resolves against), then the navigation surfaces: page interpretation interns direct destination arrays into the registry as it meets them, so links on page N can name a destination minted by nothing but themselves. const pageDicts = doc.pages(); + const selection = + options?.pages === undefined + ? undefined + : resolvePageSelection(options.pages, pageDicts.length); const destinationRegistry = createDestinationRegistry( doc.catalog, doc, @@ -122,7 +154,11 @@ export function readPdf( const source = readDocumentResidue(doc, sink); const pageBoxRows: PdfObject[] = []; - const pages = pageDicts.map((pageDict, index) => { + // An unselected page is skipped before readPage, the only path that fetches a page's /Contents, interprets it, and resolves the fonts and images it names: those costs are paid for selected pages alone. Each page keeps its source position as the page index readPage stamps (diagnostics, the structure-ownership lookup, the page-boxes residue row), and a selected page also carries it as `index`, since its position in `pages` no longer says which page it is. + const pages = pageDicts.flatMap((pageDict, index) => { + if (selection !== undefined && !selection.has(index)) { + return []; + } throwIfAborted(signal); const read = readPage( index, @@ -139,7 +175,7 @@ export function readPdf( if (read.boxRow !== undefined) { pageBoxRows.push(read.boxRow); } - return read.page; + return [selection === undefined ? read.page : { ...read.page, index }]; }); // The per-page boundary declarations a distinct crop box or a print-production box left behind, quarantined with the other package-level residue rows. @@ -154,6 +190,7 @@ export function readPdf( formatVersion: LAYOUT_FORMAT_VERSION, metadata: readMetadata(doc.trailer, doc, doc.catalog, sink), pages, + ...(selection !== undefined ? { pageCount: pageDicts.length } : {}), images, ...(destinationRegistry.entries.length > 0 ? { destinations: [...destinationRegistry.entries] } diff --git a/packages/pdf-codec/src/test-support/pdf.ts b/packages/pdf-codec/src/test-support/pdf.ts index ffb10ea26..5aa8d9627 100644 --- a/packages/pdf-codec/src/test-support/pdf.ts +++ b/packages/pdf-codec/src/test-support/pdf.ts @@ -547,3 +547,51 @@ export function navigationClusterPdf(): Uint8Array { b.classicXrefAndTrailer(LINK_TO_GOTO_ACTION_OBJ, "/Root 1 0 R"); return b.bytes(); } + +// A four-page document built to prove a page-selected read never touches an unselected page's content. Page 1 (index 1) is the trap: its content stream declares a filter no reader implements, so decoding it reports pdf/unsupported-filter (the bytes then pass through undecoded, which here are plain content), and a read that skips the page can never report it. Page 2 (index 2) is the only page drawing the image XObject, so the image registry is non-empty exactly when that page's content was interpreted. Every page shows its own one-based page number as text ("Page 1" to "Page 4"), so a test can tell from the text alone which pages it got. +export const PAGE_SELECTION_TRAP_PAGE_INDEX = 1; +export const PAGE_SELECTION_IMAGE_PAGE_INDEX = 2; +export function pageSelectionPdf(): Uint8Array { + const b = new FixtureBuilder().header(PDF_1_4); + const PAGE_OBJS = [3, 4, 5, 6] as const; + const SHARED_FONT_OBJ = 7; + const CONTENT_OBJS = [8, 9, 10, 11] as const; + const IMAGE_OBJ = 12; + b.object(1, "<< /Type /Catalog /Pages 2 0 R >>"); + b.object( + 2, + `<< /Type /Pages /Kids [${PAGE_OBJS.map((num) => `${num} 0 R`).join(" ")}] /Count ${PAGE_OBJS.length} /MediaBox [0 0 200 100] >>`, + ); + PAGE_OBJS.forEach((pageObj, index) => { + const xObjects = + index === PAGE_SELECTION_IMAGE_PAGE_INDEX + ? ` /XObject << /Im1 ${IMAGE_OBJ} 0 R >>` + : ""; + b.object( + pageObj, + `<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 ${SHARED_FONT_OBJ} 0 R >>${xObjects} >> /Contents ${CONTENT_OBJS[index]} 0 R >>`, + ); + }); + b.object(SHARED_FONT_OBJ, HELVETICA_FONT_DICT); + CONTENT_OBJS.forEach((contentObj, index) => { + const text = `BT /F1 12 Tf 10 50 Td (Page ${index + 1}) Tj ET`; + if (index === PAGE_SELECTION_TRAP_PAGE_INDEX) { + b.stream(contentObj, "<< /Filter /NoSuchDecode >>", enc(text)); + } else if (index === PAGE_SELECTION_IMAGE_PAGE_INDEX) { + b.stream( + contentObj, + EMPTY_DICT, + enc(`${text} q 10 0 0 10 150 10 cm /Im1 Do Q`), + ); + } else { + b.stream(contentObj, EMPTY_DICT, enc(text)); + } + }); + b.stream( + IMAGE_OBJ, + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 >>", + new Uint8Array([0x80]), + ); + b.classicXrefAndTrailer(IMAGE_OBJ, "/Root 1 0 R"); + return b.bytes(); +}