Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions packages/documents.js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<!-- page N -->` on its own line at the start of the page (`pageNumberMarker`), alongside the `<!-- page break -->` 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).
Expand Down
43 changes: 35 additions & 8 deletions packages/documents.js/src/convert/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -196,7 +197,8 @@ export const FORMAT_NODES: Readonly<Record<ContentFormat, FormatNode>> = {
signal: options?.signal,
images: options?.images,
}),
build: (content) => buildMarkdownText(content),
build: (content, options) =>
buildMarkdownText(content, { pageNumbers: options?.pageNumbers }),
encode: (text) => encodeMarkdownText(text),
hasSourcePackage: false,
},
Expand Down Expand Up @@ -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 (`<!-- page 7 -->` at the start of the page, beside the usual `<!-- page break -->` 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 -----------------------------------------
Expand Down Expand Up @@ -391,7 +404,7 @@ interface TextFormatNode {
) => ContentDocument;
readonly build: (
content: ContentDocument,
options?: UnifiedConversionOptions,
options?: UnifiedConversionOptions & TextBuildContext,
) => string;
readonly encode: (text: string) => Uint8Array<ArrayBuffer>;
readonly hasSourcePackage: false;
Expand Down Expand Up @@ -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<ArrayBuffer>,
Expand All @@ -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<ArrayBuffer>;
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);
Expand Down
Loading
Loading