Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/document-operations/src/operations/pdf-inspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const PdfInspectInputSchema = z.object({
),
});

// Both shapes carry pageCount (a LayoutDocument does on a page-selected read), so a caller telling them apart discriminates on formatVersion, which every LayoutDocument has and no summary does; pdf_inspect itself never page-selects.
interface PdfInspectSummary {
readonly pageCount: number;
readonly pages: readonly {
Expand Down
24 changes: 21 additions & 3 deletions packages/documents.js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,25 @@ One subpath is a declared entry point in its own right: **`documents.js/read`**
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.
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 read-only dispatch: `src/codecs/read.ts`'s `CONTENT_READERS` for a content format, and pdf-codec's `readPdf` directly for pdf), 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

The PDF-sourced entry points that produce content take 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. Two entry points never take it: `readDocumentMetadata` always reads with an empty selection, since metadata never depends on page content, so identifying a document reads no page at all; and `openPdf` always reads every page, since an editor holds a document to write back. `convertDocument` applies `pages` only when the source itself is pdf: a conversion from another format ignores it, including one whose route renders an intermediate pdf internally (docx to ods), because that pdf is not the caller's document.

```ts
import { pdfToMarkdown } from "documents.js/read";

const coverAndLastPage = pdfToMarkdown(pdfBytes, { pages: [0, 1, 2, -1] });
```

What a selection skips: the unselected pages' content streams are never fetched, decoded or interpreted, the fonts and images only those pages use are never resolved, and the source's embedded files are never decoded, so the result carries no attachments; a caller holding the `LayoutDocument` checks `pageCount` (set only on a page-selected read) before concluding the document has none (see pdf-codec's README for exactly what is and is not read). That is what lets a caller identify a document from its first pages, or read a code from its last page, inside a memory-capped runtime such as a Cloudflare Worker isolate, without paying for every page's content.

The output holds only the selected pages, and reconstruction sees only them, so a page can come out differently than in a full conversion. Heading detection measures text against the body size, taken as the most common text size across the pages read: a cover page read on its own, dominated by large title text, takes the title's size as its body, so the title becomes a paragraph rather than a heading. Reading the cover together with a few ordinary pages restores the document's real body size.

Markdown output labels each selected page 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, so a caller can tell which pages it got without re-deriving the selection. The label is output-only: reading markdown back keeps it as an ordinary HTML-comment paragraph, exactly as any other comment in a markdown source is kept. 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, and a non-wordprocessing document flattened by `renderContentDocumentToMarkdown` counts as a single page.

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. Such a tree cannot be rebuilt to pdf either: `buildDocumentBytes(tree, "pdf")` needs that envelope and throws without it. A later stage's own page-addressing option, such as `page` for an svg target, counts within the selection. A page-selected `LayoutDocument` cannot be written back: `writePdf` refuses one with `PdfPartialDocumentError`. `PdfPageSelectionError`, `PdfPartialDocumentError` and `sourcePageIndex` are exported from the root barrel; the `documents.js/read` entry re-exports nothing (this package allows re-exports only in its root barrel), so a caller of that entry recognises a bad selection by the thrown error's `code` (`pdf/page-out-of-range` or `pdf/page-index-not-integer`), and resolves a page's source index as `page.index ?? position`.

### Reading and building xlsx content directly

Expand Down Expand Up @@ -821,8 +839,8 @@ The package is layered from generic primitives outward to the two conversion dir
- **`src/firebird/`** — Tier 3: gbak logical-backup reader. `reader.ts` (attribute framing + RLE decompression + XDR decoding), `schema.ts`/`data.ts` (table/row walking). No ratified spec — built against Firebird's own engine source.
- **`src/odb/`** — decoder-selection and pivot-mapping: `read.ts` routes to the right tier, `spreadsheet.ts`/`csv.ts` map to output formats. `odb/sql/` is the bounded SQL engine, `odb/formula/` is the rpt formula engine, `odb/report/` is the renderer, `odb/values.ts` is shared comparison/aggregation semantics.
- **`src/convert/`** — the composition layer: `convert.ts` (the named conversion functions and their option types), `composition.ts` (the pathfinder, the primitive registry, the bridge/fromPdf executors, and `convertDocumentFromPdf` — the read half of the engine), `composition-to-pdf.ts` (`executeToPdf`, the layout-engine registry, and the full `convertDocument` binding — the write half, split out so a read-only consumer never statically imports a renderer), `from-pdf.ts` (the `pdfTo*` family, `readDocumentMetadata`, `readNativeDocumentTree`, `readContentDocument`, and the `documents.js/read` entry), `codec.ts` (`z.codec()` pairs), `port.ts`/`local.ts` (the `DocumentConverter` port), `variant-bridges.ts` (cross-variant semantic transforms), and `from-package.ts` (`buildDocumentBytes`, which flattens once at the boundary). The tree ⇄ flat transform those construction sites call — `assembleTree`, `decompose`, `flattenTree`, `factorStyles` — is not implemented here: it lives in `document-schema.js`, which owns both encodings. `bijection.test.ts` is this package's own gate on it, re-running the three laws over the real corpus every reader, editor, and conversion here produces.
- **`src/codecs/`** — `DOCUMENT_FORMAT_CODECS`: every format's read/build capability as data, so `readDocumentMetadata`/`setDocumentMetadata`/`buildDocumentBytes` dispatch through one registry.
- **`src/metadata/`** — cross-format metadata read/write via `DOCUMENT_FORMAT_CODECS`.
- **`src/codecs/`** — `DOCUMENT_FORMAT_CODECS`: every format's read/build capability as data, so `setDocumentMetadata`/`buildDocumentBytes` dispatch through one registry; its read halves live in `read.ts`, whose `CONTENT_READERS` `readDocumentMetadata` uses for content formats (it reads pdf through pdf-codec's `readPdf` directly, with no page selected).
- **`src/metadata/`**: cross-format metadata write via `DOCUMENT_FORMAT_CODECS`; the read side is `readDocumentMetadata` in `src/convert/from-pdf.ts`, which reads a content format through `CONTENT_READERS` and a pdf through `readPdf` with an empty page selection.
- **`src/package-codec.ts`** — `decodeDocumentPackage`/`encodeDocumentPackage`/`decodeOdbPackage`.

Dependency direction is downward and checkable. Twelve external dependencies each own a distinct concern: `ooxml.js` (docx/pptx/xlsx), `odf.js` (odt/ods/odp/odg), `document-schema.js` (shared schemas + port contracts), `pdf-codec` (PDF codec + text-layout/font primitives), `byte-codec` (byte/image utilities), `markdown-codec` (markdown), `rtf-codec` (rtf), `wpd-codec` (wpd, read-only), `doc-codec` (doc), `xls-codec` (xls), `ppt-codec` (ppt, through this package's own `src/ppt/` envelope adapter), `archive-codec` (the `[MS-CFB]` compound-file detection all three legacy binary codecs' own bytes schemas build on). No `PdfObject`/`PdfDict`/`PdfStream` type appears anywhere in this package.
Expand Down
6 changes: 3 additions & 3 deletions packages/documents.js/src/codecs/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,23 +29,23 @@ import { throwIfAborted } from "../ports/abort";

// The read-only half of DOCUMENT_FORMAT_CODECS (src/codecs/registry.ts), split from it by direction for the identical reason the composition engine split (src/convert/composition.ts versus composition-to-pdf.ts): the registry's write halves legitimately import writePdf from the pdf-codec ROOT barrel, whose own font-registry/math-font imports put every vendored font asset one import away — so any module that values the registry cannot sit on the read-only documents.js/read entry (src/convert/from-pdf.ts), whose graph src/read-graph.test.ts holds free of exactly those modules. Everything here is the same per-format read wiring the registry wraps, in a module a read-only consumer may safely reach: readPdf arrives from 'pdf-codec/read' (the read pipeline's own owning module) rather than the root barrel, and no buildXPackage/encode/write import appears.
//
// readDocumentMetadata (defined on the read entry itself, src/convert/from-pdf.ts) dispatches through THIS module rather than the registry for that reason (#744), which is what lets metadata reads join the read entry without dragging the write path back into its graph.
// readDocumentMetadata (defined on the read entry itself, src/convert/from-pdf.ts) never goes through the registry for that reason (#744): a content format dispatches through this module's CONTENT_READERS, and pdf reads pdf-codec/read's readPdf directly with an empty page selection (readDocumentLayout below serves the registry, not metadata). That is what lets metadata reads join the read entry without dragging the write path back into its graph.

// The one option every read here genuinely needs to know about — an AbortSignal — plus an optional MarkdownImageResolver consulted only by the markdown read (every other format ignores it). Identical to the shape the registry declares (moved here when the read halves were, so both modules state one options type rather than two that could drift); matches the parameterisation every registry consumer already threads through this exact call path, so a caller can forward its own signal straight through without a cast at either end.
export interface DocumentCodecOptions {
readonly signal?: AbortSignal;
readonly images?: MarkdownImageResolver;
}

// Every format whose bytes decode into a ContentDocument — all sixteen DocumentFormat members except pdf, the one layout format (its read produces a LayoutDocument through readDocumentLayout below instead, mirroring the registry's content/layout entry split).
// Every format whose bytes decode into a ContentDocument — every DocumentFormat member except pdf, the one layout format (its read produces a LayoutDocument through readDocumentLayout below instead, mirroring the registry's content/layout entry split).
export type ReadContentFormat = Exclude<DocumentFormat, "pdf">;

export type ContentReader = (
bytes: Uint8Array,
options?: DocumentCodecOptions,
) => ContentDocument;

// The fifteen per-format read closures, moved verbatim from the registry: each wraps the identical decode/read pair every ergonomic conversion in this package already uses for that format, with each format's own cancellation policy preserved exactly (docx/pptx/odt/odp/ods/odg/odf/xlsx have no loop of their own to hook a signal into, so their read checks it once via throwIfAborted before decoding; markdown does have one, so its read forwards the signal straight into the reader instead of checking it separately; csv/svg decoders are fatal one-pass functions with no loop, needing no separate check; rtf's own readRtfContent takes bytes directly and checks the signal itself internally, once, before tokenizing — matching markdown's own single-check-inside-the-reader shape, so rtf's closure forwards the signal straight through rather than checking it twice; doc-codec's readDocContent, xls-codec's readXlsContent, and this package's own src/ppt/read.ts wrapper over ppt-codec's readPptContent take no options at all — none of the three legacy binary codecs has a loop or a diagnostic sink of its own yet — so each of their closures checks the signal once via throwIfAborted before decoding, the identical no-loop-format shape docx/pptx/odt/odp/ods/odg/odf/xlsx already get). The registry composes its content entries from these, so there is exactly one place the "which reader for which format" dispatch lives.
// The per-format read closures, moved verbatim from the registry: each wraps the identical decode/read pair every ergonomic conversion in this package already uses for that format, with each format's own cancellation policy preserved exactly (docx/pptx/odt/odp/ods/odg/odf/xlsx have no loop of their own to hook a signal into, so their read checks it once via throwIfAborted before decoding; markdown does have one, so its read forwards the signal straight into the reader instead of checking it separately; csv/svg decoders are fatal one-pass functions with no loop, needing no separate check; rtf's own readRtfContent takes bytes directly and checks the signal itself internally, once, before tokenizing — matching markdown's own single-check-inside-the-reader shape, so rtf's closure forwards the signal straight through rather than checking it twice; doc-codec's readDocContent, xls-codec's readXlsContent, and this package's own src/ppt/read.ts wrapper over ppt-codec's readPptContent take no options at all — none of the three legacy binary codecs has a loop or a diagnostic sink of its own yet — so each of their closures checks the signal once via throwIfAborted before decoding, the identical no-loop-format shape docx/pptx/odt/odp/ods/odg/odf/xlsx already get). The registry composes its content entries from these, so there is exactly one place the "which reader for which format" dispatch lives.
export const CONTENT_READERS: Readonly<
Record<ReadContentFormat, ContentReader>
> = {
Expand Down
2 changes: 1 addition & 1 deletion packages/documents.js/src/codecs/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export interface LayoutEntryCodec {

// Every DocumentFormat's own capability, expressed as data rather than as three independent switch statements re-deriving the same "given a format, which read/build function do I call" dispatch. A format's `content` entry wraps the identical readXContent/buildXPackage pair every ergonomic conversion in this package already uses for it (via decodeDocumentPackage/encodeDocumentPackage for the raw-package half); a format's `layout` entry wraps a LayoutDocument codec (pdf only, so far).
//
// Direction split (#744): every `read` closure lives in src/codecs/read.ts, imported here as CONTENT_READERS/readDocumentLayout, so this module keeps only the write halves — and the pdf-codec ROOT barrel import (writePdf) those write halves genuinely need. That barrel's own font-registry/math-font imports put every vendored font asset one import away, which is exactly what the read-only documents.js/read entry (src/convert/from-pdf.ts) must never reach, so a read-only consumer (readDocumentMetadata, defined on the read entry) dispatches through src/codecs/read.ts directly rather than through this both-directions registry — the identical split the composition engine already made (src/convert/composition.ts versus composition-to-pdf.ts), and the one src/read-graph.test.ts holds by walking the read entry's graph.
// Direction split (#744): every `read` closure lives in src/codecs/read.ts, imported here as CONTENT_READERS/readDocumentLayout, so this module keeps only the write halves — and the pdf-codec ROOT barrel import (writePdf) those write halves genuinely need. That barrel's own font-registry/math-font imports put every vendored font asset one import away, which is exactly what the read-only documents.js/read entry (src/convert/from-pdf.ts) must never reach, so a read-only consumer (readDocumentMetadata, defined on the read entry) never goes through this both-directions registry: a content format dispatches through src/codecs/read.ts's CONTENT_READERS, and pdf reads pdf-codec/read's readPdf directly — the identical split the composition engine already made (src/convert/composition.ts versus composition-to-pdf.ts), and the one src/read-graph.test.ts holds by walking the read entry's graph.
export interface DocumentFormatCodecs {
readonly content?: ContentCodec<DocumentCodecOptions>;
readonly layout?: LayoutEntryCodec;
Expand Down
Loading
Loading