diff --git a/README.md b/README.md index 3de33aa..ffca7d2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/pdf-codec) [![npm](https://img.shields.io/badge/npm-CB3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/pdf-codec) [![Release](https://img.shields.io/github/v/release/ExaDev/pdf-codec)](https://github.com/ExaDev/pdf-codec/releases/latest) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/pdf-codec/ci.yml?branch=main)](https://github.com/ExaDev/pdf-codec/actions) -> A hand-written, dependency-minimal PDF codec: parses arbitrary real-world PDFs into a structured, positioned-content document and generates new PDFs from one, built on [`document-schema.js`](https://github.com/ExaDev/document-schema.js)'s `LayoutDocument` pivot and [Zod 4](https://zod.dev) codecs. +> A hand-written, dependency-minimal PDF codec: parses arbitrary real-world PDFs into a structured, positioned-content document and generates new PDFs from one, built on its own codec-owned `LayoutDocument` item model and [Zod 4](https://zod.dev) codecs. `pdf-codec` is the PDF-reading-and-writing half of [`documents.js`](https://github.com/ExaDev/documents.js), extracted into its own package: every layer of the PDF format — the object model, the cross-reference table, the content-stream operators, standard-font metrics, the parser's cross-reference/object-stream resolution and content-stream interpreter — is hand-written against the ISO 32000-1 specification, with no external PDF library (`pdf-lib`, `pdfjs-dist`, `mupdf`, or any other) as a dependency. The one exception is [`fflate`](https://github.com/101arrowz/fflate) for raw DEFLATE/zlib compression. The OpenType/CFF font parsing this package's own writer uses to embed a real math font (`sfnt.ts`/`math-*.ts`) is hand-written too, as are the cryptographic primitives its reader needs to open an encrypted PDF (`crypto/` — MD5, SHA-2, RC4, AES), because `node:crypto` would end this package's platform neutrality and WebCrypto offers neither MD5 nor RC4 nor a synchronous API. The one bundled binary asset is the vendored STIX Two Math font itself (OFL-1.1, see [Fidelity](#fidelity) and `assets/fonts/NOTICE.md`). @@ -79,6 +79,8 @@ const layout = readPdf(pdfBytes); // -> LayoutDocument: pages of positioned text const bytes = writePdf(layout); ``` +`LayoutDocument` and its whole item family — every item/page/image-asset type and schema, plus `LAYOUT_FORMAT_VERSION` — are this package's own exports, ported from `document-schema.js` (which dropped them) so a codec's native model lives in the codec, the same family pattern as `ooxml.js`'s `Package`/`XmlElement` and `markdown-codec`'s AST. `readPdf`/`writePdf` keep their signatures; callers see the same names from a new home. `documents.js` re-exports the family onward from its own barrel — those re-exports now source from `pdf-codec` rather than `document-schema.js`, same names, new source. + An encrypted PDF that opens without a password decrypts transparently — no extra option, no password parameter; one that genuinely needs a user password throws `PdfPasswordRequiredError`. See [Gotchas](#gotchas-and-quirks) for exactly which encryption is supported. 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)). @@ -127,7 +129,7 @@ A stretched construction is drawn through `MathBox`'s own `MathAssembledGlyphs` `MathFontMetrics.stretch` is the layout-facing form: it resolves a construction at a target size and additionally **measures** it — `inkAscentPt`/`inkDescentPt` are the whole construction's real ink extent about its drawing origin, taken from actual glyph outlines. -Building a layout engine on top of this codec (this is what `documents.js`'s own `src/layout/` does): `TextMeasurer`/`createStandardFontMeasurer` answer "how wide does this text render, and where does this line break" against standard-14 metrics; `resolveStandardFont`/`STANDARD_METRICS` map an arbitrary family/weight/style onto one of the 14 standard PDF faces. The text-wrapping primitive (`wrapRunsToWidth`) and shape-rotation geometry (`rotatePointAboutCenter`) live in documents.js (they had no internal pdf-codec caller); the port types they consume (`TextMeasurer`, `StyledRun`, `WrappedLine`, `ProvidedFont`, the `MathBox`/`MathFontMetrics` family) live in `document-schema.js`, the neutral shared-schema package. +Building a layout engine on top of this codec (this is what `documents.js`'s own `src/layout/` does): `TextMeasurer`/`createStandardFontMeasurer` answer "how wide does this text render, and where does this line break" against standard-14 metrics; `resolveStandardFont`/`STANDARD_METRICS` map an arbitrary family/weight/style onto one of the 14 standard PDF faces. The text-wrapping primitive (`wrapRunsToWidth`) and shape-rotation geometry (`rotatePointAboutCenter`) live in documents.js (they had no internal pdf-codec caller); the port types they consume (`TextMeasurer`, `StyledRun`, `WrappedLine`, `ProvidedFont`, the `MathBox`/`MathFontMetrics` family) live in `document-schema.js`, the neutral shared-schema package, while the `LayoutDocument` they build is this package's own type. Embedding real fonts instead of substituting standard-14 faces, via a `FontRegistry` (see `src/font-registry.ts` for the source-document → caller-supplied → vendored-substitute → standard-14 resolution order). Pass the same registry to both the measurer and `writePdf` so what was measured and what gets drawn come from one font: @@ -185,11 +187,12 @@ This works via package.json's `"./*"` wildcard export, resolving any `pdf-codec/ The package is layered from generic primitives outward to the codec itself: -- **`src/math-types.ts`** and **`src/formula.ts`** — a local, structurally-compatible mirror of `documents.js`'s own MathML layout types (`MathColor`/`MathGlyphRun`/`MathRule`/`MathStroke`/`MathLayoutItem`/`MathBox`/`MathGlyphMetrics`/`MathFontMetrics`) and `PositionedFormula`. Deliberately not imported from `documents.js` — that would be a circular dependency once `documents.js` depends on this package. Because every one of these types is plain data (only `MathFontMetrics` carries a method), a real `MathBox` value `documents.js` produces passes into `writePdf({ formulas })` with zero cast, zero wrapper, and zero transformation. +- **`src/layout.ts`** — this package's own native document model: the `LayoutDocument` item family — Zod schemas and inferred types for text/image/rect/line/ellipse/path/link items, pages (with the hidden speaker-notes channel), the image-asset registry, and `LAYOUT_FORMAT_VERSION`. Ported verbatim from `document-schema.js`, which carried it until its 4.0.0 dropped it — a codec's native model lives in the codec, like `ooxml.js`'s `Package`/`XmlElement` and `markdown-codec`'s AST; only PDF's native model was ever a public shared-schema export, an accident of this package predating the content pivot. The item layer remains the honest boundary between what the format says (positions) and what we think it means (structure): when reconstruction misjudges a wrapped paragraph, the items stay inspectable as the PDF's actual testimony. The shared leaf shapes the family composes from (`Color`, `ContentStrokeStyleSchema`, `LayoutFont`, `LayoutMetadata`) stay in `document-schema.js` and are imported, keeping one definition of each across content and layout. +- **The math port types** — `MathColor`/`MathGlyphRun`/`MathRule`/`MathStroke`/`MathLayoutItem`/`MathBox`/`MathGlyphMetrics`/`MathFontMetrics` and `PositionedFormula`, sourced from `document-schema.js`'s math layout port (one shared definition across the family, not a local mirror). Deliberately not imported from `documents.js` — that would be a circular dependency once `documents.js` depends on this package. Because every one of these types is plain data (only `MathFontMetrics` carries a method), a real `MathBox` value `documents.js` produces passes into `writePdf({ formulas })` with zero cast, zero wrapper, and zero transformation. - **`src/bytes/`** and **`src/image/`** — generic byte and image-container primitives with zero PDF-specific knowledge: a chunked byte writer, backtracking byte reader, CRC32, a hand-written PNG decoder/encoder, JPEG marker scanning for dimensions only (compressed bytes pass through unchanged), a hand-written CCITT Group 3/Group 4 fax decoder (ITU-T T.4/T.6), a hand-written JBIG2 decoder (ITU-T T.88 — `jbig2-arith.ts` MQ decoder, `jbig2-bitmap.ts`, `jbig2-generic.ts`, `jbig2-text.ts`, `jbig2.ts`), and a hand-written JPEG 2000 decoder (ISO/IEC 15444-1 — `jp2-boxes.ts`, `jpeg2000-codestream.ts`, `jpeg2000-tagtree.ts`, `jpeg2000-t2.ts`, `jpeg2000-t1.ts`, `jpeg2000-dwt.ts`, `jpeg2000.ts`). `src/filters.ts` owns all PDF knowledge for CCITT/JBIG2 (resolving parameters, `/JBIG2Globals`, inverting polarity); `src/images-read.ts` owns the JPEG 2000 PDF integration. `src/bytes/flate.ts` is the only file that imports `fflate`. - **`src/util/`** — two small independently-duplicated copies of family-shared logic: `base64.ts` (verbatim copy of `odf.js`'s own, replacing a former `ooxml.js` dependency for this one helper) and `abort.ts` (`throwIfAborted`, called at every page loop boundary — there is no `await` point in this synchronous pipeline for cancellation to hook into implicitly). - **`src/crypto/`** — MD5, SHA-256/384/512, RC4, and AES-CBC, hand-written with zero local imports. Not a preference: ISO 32000-1's key-derivation algorithms name MD5 and RC4 directly, neither offered by any portable platform crypto API, and `crypto.subtle` is asynchronous where this codec's read path is synchronous end to end. Reaching for `node:crypto` would break the browser bundle. Each module cites its specification (RFC 1321, FIPS 180-4, FIPS 197) and is tested against published conformance vectors. -- **The codec itself, importing only `math-types`/`formula`/`bytes`/`image`/`crypto` (no OOXML or ODF knowledge):** +- **The codec itself, importing only `layout`/`bytes`/`image`/`crypto`/`util` plus `document-schema.js`'s port types (no OOXML or ODF knowledge):** - **Write**: `objects.ts` (the `PdfObject` discriminated union), `afm-widths.ts`/`encoding.ts`/`winansi.ts`/`fonts.ts` (standard-14 metrics, WinAnsi encoding, family resolution), `font-registry.ts` (resolution port plus `resolveFaceWithRegistry`, the one step both measurer and writer resolve through so they can never disagree about which face a `LayoutFont` means), `font-face.ts` (`readFontFace`, reading a standalone font file's family/bold/italic triple off its `name`/`OS/2`/`head` tables), `measure.ts`/`text-layout.ts` (greedy line-wrapping against either standard-14 AFM widths plus per-family correction or a resolved face's own real `hmtx` advances — never both), `content-write.ts` (`LayoutItem[]` → content-stream operators, with text branching on standard-14 vs embedded face encoding, pair-kerning split into `TJ` arrays, and stroke `style` becoming real dash/line-cap state), `write.ts` (the full object graph, cross-reference table, trailer, and embedded font groups). - **sfnt font tables**: `sfnt.ts` (bounds-checked table-directory reader), `cmap-table.ts` (Unicode → glyph ID, formats 4/12/6), `hmtx-table.ts` (per-glyph advance widths), `font-tables.ts` (`head`/`maxp`/`OS/2`/`post`/`name`), `glyf.ts` (`loca` offset index, per-glyph headers, composite component records, `glyphInkBounds`), `math-table.ts` (OpenType `MATH` constants/glyph-info/variants subtables). `ot-layout-common.ts` (Coverage/ClassDef, stored as sorted glyph ranges searched by bisection). `gpos-table.ts` reads `GPOS` for exactly one thing: pair-advance kerning through the `kern` feature, both PairPos formats and LookupType 9 Extension indirection — mark attachment, cursive joining, and contextual positioning have no consumer here. Every parser degrades to `undefined` on a missing/truncated table rather than throwing. - **sfnt subsetting**: `sfnt-subset.ts` — a TrueType-outline glyph subsetter (Unicode code points → glyph IDs via `cmap`, transitive closure over `glyf` composite components, rebuilt sfnt carrying only used outlines). **Glyph IDs are preserved, never renumbered**, keeping composite component references valid and making CID == GID trivially true. Output rebuilds `head`/`hhea`/`maxp`/`loca`/`glyf`/`hmtx`, copies hinting programs verbatim, stubs `post`, omits `cmap`/`name`/`OS/2`/`GSUB`/`GPOS`/`kern` (none read through a `CIDFontType2` program per ISO 32000-1 9.9). Applies to `glyf`-flavoured fonts only; CFF returns `undefined`. @@ -201,11 +204,11 @@ The package is layered from generic primitives outward to the codec itself: - `codec.ts` — `pdfCodec`, a `z.codec()` pair over `readPdf`/`writePdf`, plus a standalone local copy of the `%PDF-` header check. - **`src/test-support/`** — hand-built PDF fixtures (`pdf.ts`) built by literal byte/string concatenation and deliberately importing NOTHING from this package's own writer (a fixture built by `writePdf` would let a writer bug hide from the corresponding reader test). `encrypted-pdfs.ts` applies the same principle: real PDFs encrypted by [qpdf](https://qpdf.sourceforge.io/), embedded as base64, so a bug in key derivation cannot cancel out between write and read halves. `fonts.ts` holds the real vendored Carlito and Caladea faces as raw sfnt bytes, and asserts values read out of the `.ttf` files by a standalone script with a bare `DataView`, not by this package's own parsers — external cross-checks rather than a parser's output compared against itself. -Dependency direction is strictly downward and checkable: `math-types`/`formula`/`bytes`/`crypto`/`util` import nothing local (`bytes/flate.ts` imports `fflate`); `image` imports `bytes` only; the codec itself imports `math-types`+`formula`+`bytes`+`image`+`crypto`+`util` only. Nothing anywhere under `src/` imports a `node:` builtin, which is what lets `tsdown`'s `platform: 'neutral'` build run unchanged in a browser bundle. No `PdfObject`/`PdfDict`/`PdfStream` type appears outside the codec's own read/write modules. +Dependency direction is strictly downward and checkable: `layout` imports only `zod` and `document-schema.js`'s shared leaf schemas; `bytes`/`crypto`/`util` import nothing local (`bytes/flate.ts` imports `fflate`); `image` imports `bytes` only; the codec itself imports `layout`+`bytes`+`image`+`crypto`+`util` plus `document-schema.js`'s port and leaf types, and nothing else from outside. Nothing anywhere under `src/` imports a `node:` builtin, which is what lets `tsdown`'s `platform: 'neutral'` build run unchanged in a browser bundle. No `PdfObject`/`PdfDict`/`PdfStream` type appears outside the codec's own read/write modules. ## Conventions -- **Zod-first schema/type/guard**: `PdfBytesSchema`/`LayoutDocumentSchema` (the latter imported from `document-schema.js`) are the only two schemas this package validates against; every other model type (`PdfObject`, `MathBox` and friends) is plain TypeScript, never Zod-validated. +- **Zod-first schema/type/guard**: `PdfBytesSchema` (in `codec.ts`) and `LayoutDocumentSchema` (in `src/layout.ts`, this package's own native model) are the only two schemas this package validates against; every other model type (`PdfObject`, `MathBox` and friends) is plain TypeScript, never Zod-validated. - **`z.codec()` for the one schema-to-schema round trip**: `pdfCodec` (PDF bytes ⇄ `LayoutDocument`), wrapping the already-independently-tested `readPdf`/`writePdf` pair and adding automatic two-way schema validation. Deliberately the no-options form — `readPdf`/`writePdf` remain the primary entry points wherever a caller needs an `AbortSignal`, a `PdfDiagnosticSink`, or an `onSubstitution` callback. - **`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. @@ -293,7 +296,7 @@ There is a single `main` branch and no open pull request workflow established so ## References - [documents.js](https://github.com/ExaDev/documents.js) — the package this codec was extracted from, and its principal downstream consumer: docx/pptx/odt/odp/ods/odg ⇄ PDF conversion, and MathML formula rendering (its own `src/mathml/` typesetting engine feeds a real `MathBox` into this package's `writePdf({ formulas })` with zero cast). -- [document-schema.js](https://github.com/ExaDev/document-schema.js) — the sibling package that owns `LayoutDocument` itself (the PDF-side pivot this codec reads into and writes from), and the canonical `ContentDocument` pivot the wider family shares. +- [document-schema.js](https://github.com/ExaDev/document-schema.js) — the sibling package that owns the canonical `ContentDocument`/`DocumentPackage` pivots the wider family shares, plus the shared leaf shapes and port types this package imports (`Color`, `LayoutFont`, `LayoutMetadata`, `TextMeasurer`, the math family). The `LayoutDocument` item family itself lived there until moving into this package. - [qpdf](https://qpdf.sourceforge.io/) — the independent implementation that produces this package's encrypted-PDF test fixtures. A build-time and test-time tool only, never a dependency of the package itself. - The specifications `src/crypto/` implements, each cited in the module that implements it and checked against published conformance vectors: [RFC 1321](https://www.rfc-editor.org/rfc/rfc1321) (MD5), [FIPS 180-4](https://csrc.nist.gov/pubs/fips/180-4/upd1/final) (SHA-256/384/512), [FIPS 197](https://csrc.nist.gov/pubs/fips/197/final) (AES), and [NIST SP 800-38A](https://csrc.nist.gov/pubs/sp/800/38/a/final) (CBC mode). The standard security handler is ISO 32000-1 7.6, extended for revisions 5 and 6 by ISO 32000-2 7.6.4.3. - [STIX Two Math](https://github.com/stipub/stixfonts) — the embedded math font, vendored at `assets/fonts/STIXTwoMath-Regular.otf` and embedded into `dist/` as a base64 string (`src/assets/stix-two-math-font.ts`, generated by `scripts/generate-math-font-asset.mjs`). Copyright 2001-2021 The STIX Fonts Project Authors, licensed [OFL-1.1](assets/fonts/OFL.txt) — see `assets/fonts/NOTICE.md` for the exact source commit and version. diff --git a/package.json b/package.json index a22a640..0e132f9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "pdf-codec", "version": "2.2.36", - "description": "Hand-written, dependency-minimal PDF codec: parses arbitrary real-world PDFs and generates new ones, built on document-schema.js's LayoutDocument pivot and Zod 4 codecs.", + "description": "Hand-written, dependency-minimal PDF codec: parses arbitrary real-world PDFs and generates new ones, built on its own codec-owned LayoutDocument item model and Zod 4 codecs.", "type": "module", "repository": { "type": "git", @@ -81,7 +81,7 @@ "packageManager": "pnpm@11.6.0", "dependencies": { "byte-codec": "^1.1.8", - "document-schema.js": "^3.3.0", + "document-schema.js": "^4.0.0", "fflate": "^0.8.3", "zod": "^4.4.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7bfe999..f5501eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: ^1.1.8 version: 1.1.8 document-schema.js: - specifier: ^3.3.0 - version: 3.3.0 + specifier: ^4.0.0 + version: 4.0.0 fflate: specifier: ^0.8.3 version: 0.8.3 @@ -1603,8 +1603,8 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} - document-schema.js@3.3.0: - resolution: {integrity: sha512-Jcg+sLEKF6T2w6WdCD1S0DcKFt1Kpk/rO1AZlE7fi0pqN4Ys8tN3qeY97DPc78QRy+EkbCW5hKLwM0EyV+Ojqw==} + document-schema.js@4.0.0: + resolution: {integrity: sha512-aCBeWHDubmchJBXPx7ZesLmGgOCAPOEI3n/PzunPLBEUq/w+hGpl/XLczGyyPkURu7nKR+dP6M1p7u6k4YxDsw==} engines: {node: '>=20'} dot-prop@5.3.0: @@ -4514,7 +4514,7 @@ snapshots: dependencies: path-type: 4.0.0 - document-schema.js@3.3.0: + document-schema.js@4.0.0: dependencies: zod: 4.4.3 diff --git a/src/codec.test.ts b/src/codec.test.ts index 158acb3..fa9a433 100644 --- a/src/codec.test.ts +++ b/src/codec.test.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; import { describe, expect, it } from 'vitest'; -import { LayoutDocumentSchema } from 'document-schema.js'; +import { LayoutDocumentSchema } from './layout'; import { minimalClassicXrefPdf } from './test-support/pdf'; import { pdfCodec } from './codec'; import { readPdf } from './read'; diff --git a/src/codec.ts b/src/codec.ts index e70a9dd..bc785d0 100644 --- a/src/codec.ts +++ b/src/codec.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; -import { LayoutDocumentSchema } from 'document-schema.js'; import { readPdf } from './read'; import { writePdf } from './write'; +import { LayoutDocumentSchema } from './layout'; // '%PDF-' -- the PDF header (ISO 32000-1 section 7.5.2). Per the spec it may be preceded by arbitrary bytes (some producers prepend a comment or BOM), so this checks for the signature within the first kilobyte rather than requiring it at offset 0. A standalone, independently-duplicated copy of documents.js's own src/model/bytes.ts PdfBytesSchema logic -- that file is co-located there alongside unrelated docx/pptx/odt schemas which must stay in documents.js, so this package owns its own narrow ~20-line copy of just the PDF-specific check rather than importing the whole thing. const PDF_HEADER = [0x25, 0x50, 0x44, 0x46, 0x2d]; diff --git a/src/content-write.test.ts b/src/content-write.test.ts index 651ec98..77b1389 100644 --- a/src/content-write.test.ts +++ b/src/content-write.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { LayoutEllipse, LayoutImage, LayoutLine, LayoutLink, LayoutRect, LayoutText, TextMeasurer } from 'document-schema.js'; +import type { TextMeasurer } from 'document-schema.js'; +import type { LayoutEllipse, LayoutImage, LayoutLine, LayoutLink, LayoutRect, LayoutText } from './layout'; import type { ContentWriteContext } from './content-write'; import { writeContentStream } from './content-write'; import type { EmbeddedFace } from './embedded-font'; diff --git a/src/content-write.ts b/src/content-write.ts index 6d7c3ce..e8be253 100644 --- a/src/content-write.ts +++ b/src/content-write.ts @@ -1,6 +1,5 @@ -import type { LayoutEllipse, LayoutImage, LayoutItem, LayoutLine, LayoutPath, LayoutRect, LayoutSubpath, LayoutText } from 'document-schema.js'; -import type { Color as LayoutColor } from 'document-schema.js'; -import type { LayoutFont, TextMeasurer, UnderlineMetrics } from 'document-schema.js'; +import type { Color as LayoutColor, LayoutFont, TextMeasurer, UnderlineMetrics } from 'document-schema.js'; +import type { LayoutEllipse, LayoutImage, LayoutItem, LayoutLine, LayoutPath, LayoutRect, LayoutSubpath, LayoutText } from './layout'; import type { StandardFontName } from './afm-widths'; import { ByteWriter } from './bytes/writer'; import type { EmbeddedFace, EmbeddedFaceSubstitution, EmbeddedShow } from './embedded-font'; @@ -172,7 +171,7 @@ function formatPoint(x: number, y: number): string { return `${formatNumber(x)} ${formatNumber(y)}`; } -// A LayoutLine's/LayoutPath's own stroke style (document-schema.js 2.1's `style` field). 'solid' and an absent field are the same thing: the PDF graphics state's own defaults, with nothing emitted for either. +// A LayoutLine's/LayoutPath's own stroke style (the `style` field on the item schemas in src/layout.ts). 'solid' and an absent field are the same thing: the PDF graphics state's own defaults, with nothing emitted for either. type StrokeStyle = NonNullable; // Dash-pattern lengths (ISO 32000-1 8.4.3.6, the 'd' operator) are expressed as multiples of the stroke's OWN width rather than as fixed point lengths, so a hairline rule and a thick one both read as recognisably dashed: a fixed [3 3] pattern under a 6pt stroke paints overlapping blocks that read as solid, and under a 0.25pt one paints dashes twelve times longer than they are thick. @@ -385,7 +384,7 @@ function writeEllipse(writer: ByteWriter, item: LayoutEllipse): void { writer.writeAscii(`${paint}\n`); } -// One subpath: m (moveto the subpath's own starting point), then l/c per segment, then h if the subpath is closed. No quadratic-to-cubic elevation and no SVG elliptical-arc endpoint-to-center parameterization exist anywhere in this module, deliberately: LayoutPathSegment's own discriminated union (document-schema.js's layout.ts) only ever has 'line'/'cubic' variants, because the sole real-world producer of a LayoutPath -- odf.js's own svg:d/draw:points parser (typed/shared/path.ts), verified against genuine LibreOffice output -- never emits a quadratic or an arc segment in the first place: ODF's own svg:d grammar recognises S/s, Q/q, T/t, A/a as command letters (so the token stream stays in sync) but that parser explicitly produces no segment for any of them, real LibreOffice output for rectangles/ellipses/freeform curves/basic custom-shape presets never exercises them, and ContentPathSegmentSchema itself only models 'line'/'cubic' regardless. There is nothing here to elevate or parameterize, and building that conversion code with no caller would be unused code kept "just in case". +// One subpath: m (moveto the subpath's own starting point), then l/c per segment, then h if the subpath is closed. No quadratic-to-cubic elevation and no SVG elliptical-arc endpoint-to-center parameterization exist anywhere in this module, deliberately: LayoutPathSegment's own discriminated union (src/layout.ts) only ever has 'line'/'cubic' variants, because the sole real-world producer of a LayoutPath -- odf.js's own svg:d/draw:points parser (typed/shared/path.ts), verified against genuine LibreOffice output -- never emits a quadratic or an arc segment in the first place: ODF's own svg:d grammar recognises S/s, Q/q, T/t, A/a as command letters (so the token stream stays in sync) but that parser explicitly produces no segment for any of them, real LibreOffice output for rectangles/ellipses/freeform curves/basic custom-shape presets never exercises them, and ContentPathSegmentSchema itself only models 'line'/'cubic' regardless. There is nothing here to elevate or parameterize, and building that conversion code with no caller would be unused code kept "just in case". function writeSubpath(writer: ByteWriter, subpath: LayoutSubpath): void { writer.writeAscii(`${formatPoint(subpath.startXPt, subpath.startYPt)} m\n`); for (const segment of subpath.segments) { diff --git a/src/index.ts b/src/index.ts index 895c818..7d8f809 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -// pdf-codec's public surface: a curated barrel export, no subpath exports, matching document-schema.js/odf.js/ooxml.js's own precedent. What's exported here is every symbol a real external consumer needs -- headline read/write/codec entry points, the formula/math port documents.js's own MathML layout engine passes real values through, the text-layout and font-resolution helpers every layout engine built on this codec needs, and the full bytes/image surface (this package owns src/bytes/ and src/image/ outright; nothing duplicates them upstream). Internal plumbing (objects.ts, serialize.ts, lexer.ts, parse.ts, xref.ts, document.ts, interpret.ts, content-read.ts, content-write.ts, filters.ts, predictors.ts, images-read.ts, cmap.ts, font-read.ts, font-style.ts, and the cmap-table/hmtx-table/font-tables/glyf/sfnt/sfnt-subset/cff/cff-probe/cff-bounds/tounicode/ot-layout-common/gpos-table/math-font-write/math-content-write/embedded-font-write font-parsing, font-subsetting, and font-embedding internals) stays unexported -- math-table.ts is a partial exception, exporting its MathVariants types alone (see below), and glyph-bounds.ts another, exporting the GlyphInkBounds shape those outline readers report through -- nothing outside this package's own src/ consumes it today. embedded-font.ts is the one partial exception: its EmbeddedFace is the type ResolvedFace's own 'embedded' variant carries, and its EmbeddedFaceSubstitution is what WritePdfOptions.onMissingGlyph reports, so both must be nameable by an external caller even though nothing else in that module is exported. +// pdf-codec's public surface: a curated barrel export, no subpath exports, matching document-schema.js/odf.js/ooxml.js's own precedent. What's exported here is every symbol a real external consumer needs -- headline read/write/codec entry points, the Layout item family this package owns outright as its native document model (src/layout.ts, exported wholesale below), the formula/math port documents.js's own MathML layout engine passes real values through, the text-layout and font-resolution helpers every layout engine built on this codec needs, and the full bytes/image surface (this package owns src/bytes/ and src/image/ outright; nothing duplicates them upstream). Internal plumbing (objects.ts, serialize.ts, lexer.ts, parse.ts, xref.ts, document.ts, interpret.ts, content-read.ts, content-write.ts, filters.ts, predictors.ts, images-read.ts, cmap.ts, font-read.ts, font-style.ts, and the cmap-table/hmtx-table/font-tables/glyf/sfnt/sfnt-subset/cff/cff-probe/cff-bounds/tounicode/ot-layout-common/gpos-table/math-font-write/math-content-write/embedded-font-write font-parsing, font-subsetting, and font-embedding internals) stays unexported -- math-table.ts is a partial exception, exporting its MathVariants types alone (see below), and glyph-bounds.ts another, exporting the GlyphInkBounds shape those outline readers report through -- nothing outside this package's own src/ consumes it today. embedded-font.ts is the one partial exception: its EmbeddedFace is the type ResolvedFace's own 'embedded' variant carries, and its EmbeddedFaceSubstitution is what WritePdfOptions.onMissingGlyph reports, so both must be nameable by an external caller even though nothing else in that module is exported. // Headline: read/write/diagnostics/codec. export type { ReadPdfOptions } from './read'; @@ -10,7 +10,10 @@ export { NOOP_DIAGNOSTIC_SINK, PdfEncryptedError, PdfParseError, PdfPasswordRequ export type { WinAnsiSubstitution } from './winansi'; export { PdfBytesSchema, pdfCodec } from './codec'; -// Formula/math: the structural port documents.js's own MathML layout engine (layoutFormula, staying in documents.js) produces real values against -- see src/math-types.ts for the full rationale. +// The Layout item family: LayoutDocument and every item/page/image-asset schema, inferred type, and LAYOUT_FORMAT_VERSION -- pdf-codec's own native document model, ported from document-schema.js (which dropped it at its 4.0.0) per the family pattern where a codec's native model lives in the codec, like ooxml.js's Package/XmlElement. Exported wholesale because every symbol in src/layout.ts is public family surface: readPdf/writePdf's own signatures speak these types, and documents.js re-exports the family onward from its own barrel. Callers that imported the family from document-schema.js pre-4.0.0 import the same names from pdf-codec now. +export * from './layout'; + +// Formula/math: the structural port documents.js's own MathML layout engine (layoutFormula, staying in documents.js) produces real values against -- the family lives in document-schema.js's math layout port, one shared definition across the family rather than a local mirror (importing it from documents.js itself would be circular once documents.js depends on this package). export type { MathAssembledGlyphs, MathBox, MathColor, MathFontMetrics, MathGlyphMetrics, MathGlyphPlacement, MathGlyphRun, MathLayoutItem, MathRule, MathStretchAxis, MathStretchGlyph, MathStretchResult, MathStroke, PositionedFormula } from 'document-schema.js'; export type { LoadedMathFont, MathFont, MathFontDescriptorMetrics } from './math-font'; export { loadMathFont } from './math-font'; diff --git a/src/layout.test.ts b/src/layout.test.ts new file mode 100644 index 0000000..1ada09c --- /dev/null +++ b/src/layout.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it } from 'vitest'; +import { COLOR_BLACK, DEFAULT_LAYOUT_FONT } from 'document-schema.js'; +import { + LAYOUT_FORMAT_VERSION, + type LayoutDocument, + LayoutDocumentSchema, + type LayoutItem, + LayoutItemSchema, +} from './layout'; + +const text: LayoutItem = { + kind: 'text', + text: 'Hello, layout.', + xPt: 72, + yPt: 720, + font: DEFAULT_LAYOUT_FONT, + sizePt: 12, + color: COLOR_BLACK, + widthPt: 90.5, + rotationDeg: 0, + underline: true, +}; + +const imageItem: LayoutItem = { + kind: 'image', + imageId: 'logo', + xPt: 10, + yPt: 700, + widthPt: 50, + heightPt: 25, + rotationDeg: 5, +}; + +const rect: LayoutItem = { + kind: 'rect', + xPt: 0, + yPt: 0, + widthPt: 200, + heightPt: 100, + fill: { r: 0.9, g: 0.9, b: 0.9 }, + stroke: { color: COLOR_BLACK, widthPt: 1.5 }, +}; + +const line: LayoutItem = { + kind: 'line', + x1Pt: 0, + y1Pt: 0, + x2Pt: 100, + y2Pt: 100, + color: COLOR_BLACK, + widthPt: 2, +}; + +const ellipse: LayoutItem = { + kind: 'ellipse', + xPt: 20, + yPt: 20, + widthPt: 40, + heightPt: 40, + fill: { r: 0.1, g: 0.2, b: 0.3 }, +}; + +const path: LayoutItem = { + kind: 'path', + subpaths: [ + { + startXPt: 0, + startYPt: 0, + closed: true, + segments: [ + { kind: 'line', xPt: 10, yPt: 0 }, + { kind: 'cubic', c1xPt: 15, c1yPt: 5, c2xPt: 15, c2yPt: 15, xPt: 10, yPt: 20 }, + { kind: 'line', xPt: 0, yPt: 20 }, + ], + }, + ], + fill: { r: 0.4, g: 0.5, b: 0.6 }, + fillRule: 'evenodd', + stroke: { color: COLOR_BLACK, widthPt: 1 }, +}; + +const link: LayoutItem = { + kind: 'link', + uri: 'https://example.com/', + xPt: 5, + yPt: 5, + widthPt: 60, + heightPt: 15, +}; + +describe('LayoutItemSchema', () => { + it('accepts every item kind and preserves every field through a JSON round trip', () => { + for (const item of [text, imageItem, rect, line, ellipse, path, link]) { + const parsed = LayoutItemSchema.parse(item); + const roundTripped: unknown = JSON.parse(JSON.stringify(parsed)); + expect(LayoutItemSchema.parse(roundTripped)).toEqual(item); + } + }); + + it('rejects an unknown kind', () => { + expect(LayoutItemSchema.safeParse({ kind: 'circle', xPt: 0, yPt: 0 }).success).toBe(false); + }); +}); + +describe('LayoutPathSchema', () => { + it('accepts a minimal open subpath with no fill, stroke, or fillRule', () => { + const minimal: LayoutItem = { + kind: 'path', + subpaths: [{ startXPt: 0, startYPt: 0, closed: false, segments: [{ kind: 'line', xPt: 10, yPt: 10 }] }], + }; + expect(LayoutItemSchema.parse(minimal)).toEqual(minimal); + }); + + it('accepts a path with multiple subpaths, matching an evenodd hole punched through a fill', () => { + const withHole: LayoutItem = { + kind: 'path', + subpaths: [ + { startXPt: 0, startYPt: 0, closed: true, segments: [{ kind: 'line', xPt: 20, yPt: 0 }, { kind: 'line', xPt: 20, yPt: 20 }, { kind: 'line', xPt: 0, yPt: 20 }] }, + { startXPt: 5, startYPt: 5, closed: true, segments: [{ kind: 'line', xPt: 15, yPt: 5 }, { kind: 'line', xPt: 15, yPt: 15 }, { kind: 'line', xPt: 5, yPt: 15 }] }, + ], + fill: COLOR_BLACK, + fillRule: 'evenodd', + }; + expect(LayoutItemSchema.parse(withHole)).toEqual(withHole); + }); + + it('rejects a segment kind other than line/cubic', () => { + const invalid = { kind: 'path', subpaths: [{ startXPt: 0, startYPt: 0, closed: false, segments: [{ kind: 'quadratic', xPt: 1, yPt: 1 }] }] }; + expect(LayoutItemSchema.safeParse(invalid).success).toBe(false); + }); +}); + +describe('LayoutItemSchema sourcePath', () => { + it('survives a JSON round trip when set on every item kind', () => { + const itemsWithSourcePath: LayoutItem[] = [ + { ...text, sourcePath: 'sections[0].blocks[0].runs[0]' }, + { ...imageItem, sourcePath: 'sections[0].blocks[1]' }, + { ...rect, sourcePath: 'slides[0].shapes[0]' }, + { ...line, sourcePath: 'slides[0].shapes[1]' }, + { ...ellipse, sourcePath: 'slides[0].shapes[2]' }, + { ...path, sourcePath: 'pages[0].vectors[0]' }, + { ...link, sourcePath: 'sections[0].blocks[0].runs[1]' }, + ]; + for (const item of itemsWithSourcePath) { + const parsed = LayoutItemSchema.parse(item); + const roundTripped: unknown = JSON.parse(JSON.stringify(parsed)); + expect(LayoutItemSchema.parse(roundTripped)).toEqual(item); + } + }); + + it('parses correctly when sourcePath is omitted, matching every other optional field', () => { + for (const item of [text, imageItem, rect, line, ellipse, path, link]) { + const parsed = LayoutItemSchema.parse(item); + expect(parsed.sourcePath).toBeUndefined(); + } + }); +}); + +function layoutDocument(): LayoutDocument { + return { + formatVersion: LAYOUT_FORMAT_VERSION, + metadata: { + title: 'Layout round trip', + author: 'pdf-codec', + subject: 'testing', + keywords: ['layout', 'pdf'], + creator: 'pdf-codec tests', + producer: 'pdf-codec tests', // producer is normally PDF-only; exercised here as a plain optional field + createdIso: '2026-07-30T00:00:00.000Z', + modifiedIso: '2026-07-30T01:00:00.000Z', + }, + pages: [ + { + widthPt: 612, + heightPt: 792, + items: [text, imageItem, rect, line, ellipse, path, link], + notes: 'Speaker notes carried as a hidden annotation.', + }, + { + widthPt: 612, + heightPt: 792, + items: [text], + // deliberately no `notes` field, exercising the page-without-notes case + }, + ], + images: { + logo: { format: 'png', base64: 'AA==', widthPx: 32, heightPx: 32 }, + photo: { format: 'jpeg', base64: '/9k=', widthPx: 1024, heightPx: 768 }, + }, + }; +} + +describe('LayoutDocumentSchema round trips', () => { + it('deep-equals the original document after a JSON round trip, covering a page with notes and a page without', () => { + const original = layoutDocument(); + const parsed = LayoutDocumentSchema.parse(original); + const roundTripped: unknown = JSON.parse(JSON.stringify(parsed)); + expect(LayoutDocumentSchema.parse(roundTripped)).toEqual(original); + }); + + it('accepts a minimal document with an empty page and empty image registry', () => { + const doc: LayoutDocument = { + formatVersion: LAYOUT_FORMAT_VERSION, + metadata: {}, + pages: [{ widthPt: 612, heightPt: 792, items: [] }], + images: {}, + }; + expect(LayoutDocumentSchema.parse(doc)).toEqual(doc); + }); + + it('rejects a mismatched formatVersion', () => { + expect( + LayoutDocumentSchema.safeParse({ formatVersion: 2, metadata: {}, pages: [], images: {} }).success, + ).toBe(false); + }); +}); diff --git a/src/layout.ts b/src/layout.ts new file mode 100644 index 0000000..0821a3c --- /dev/null +++ b/src/layout.ts @@ -0,0 +1,159 @@ +import { z } from 'zod'; +import { ColorSchema, ContentStrokeStyleSchema, LayoutFontSchema, LayoutMetadataSchema } from 'document-schema.js'; + +// pdf-codec's own native document model: LayoutDocument, the positioned item layer readPdf assembles from a PDF's bytes and writePdf draws into new ones. Ported verbatim from document-schema.js's own src/layout.ts (its home from the content pivot until that package's 4.0.0 promoted DocumentPackage and dropped it) because a codec's native model belongs in the codec -- the same family pattern as ooxml.js's Package/XmlElement and markdown-codec's AST; only PDF's native model was ever a public shared-schema export, an accident of this package predating the content pivot. The item layer remains the honest boundary between what the format says (positions) and what we think it means (structure): when documents.js's reconstruction misjudges a wrapped paragraph, these items stay inspectable as the PDF's actual testimony. Reconstruction heuristics are semantic policy and stay in documents.js, not here. The shared leaf shapes the family composes from (Color, ContentStrokeStyleSchema, LayoutFont, LayoutMetadata) stay in document-schema.js and are imported above, so content and layout keep one definition of each. + +// Bumped whenever LayoutDocumentSchema's shape changes incompatibly, so a value serialized by one version of a consumer can be recognised (and rejected, rather than silently misread) by another. Still 1: moving the schemas between packages changed where the family lives, not what it accepts. +export const LAYOUT_FORMAT_VERSION = 1; + +// sourcePath is assigned by each format's reader at read time and copied onto emitted LayoutItems by the layout engine; this module only defines the field, it doesn't generate values. Known limitation: sourcePath values are stable within one read+layout pass over a single document, not across edits -- inserting content earlier in a document shifts every later path. This is not a stable identity scheme for incremental re-layout; it exists for tagged/accessible-PDF-style traceability and debugging, not edit-tracking. + +// A single painted or annotated element on a page. Coordinates are always PDF user space: origin bottom-left, y increasing upward, unit = point. Every field carries an explicit Pt suffix so a caller can never accidentally mix this with ContentShape.frame's OOXML (top-left, y-down) space. +export const LayoutTextSchema = z.object({ + kind: z.literal('text'), + text: z.string(), + xPt: z.number(), + yPt: z.number(), // baseline + font: LayoutFontSchema, + sizePt: z.number().positive(), + color: ColorSchema, + widthPt: z.number().nonnegative().optional(), // measured (write path) or reported (read path) + rotationDeg: z.number().optional(), + underline: z.boolean().optional(), + sourcePath: z.string().optional(), // deterministic, document-order-derived path copied from the ContentDocument item this was laid out from +}); +export type LayoutText = z.infer; + +export const LayoutImageSchema = z.object({ + kind: z.literal('image'), + imageId: z.string(), // key into LayoutDocument.images + xPt: z.number(), // bottom-left corner + yPt: z.number(), + widthPt: z.number().positive(), + heightPt: z.number().positive(), + rotationDeg: z.number().optional(), + sourcePath: z.string().optional(), // deterministic, document-order-derived path copied from the ContentDocument item this was laid out from +}); +export type LayoutImage = z.infer; + +export const LayoutRectSchema = z.object({ + kind: z.literal('rect'), + xPt: z.number(), + yPt: z.number(), + widthPt: z.number().nonnegative(), + heightPt: z.number().nonnegative(), + fill: ColorSchema.optional(), + stroke: z.object({ color: ColorSchema, widthPt: z.number().positive() }).optional(), + sourcePath: z.string().optional(), // deterministic, document-order-derived path copied from the ContentDocument item this was laid out from +}); +export type LayoutRect = z.infer; + +export const LayoutLineSchema = z.object({ + kind: z.literal('line'), + x1Pt: z.number(), + y1Pt: z.number(), + x2Pt: z.number(), + y2Pt: z.number(), + color: ColorSchema, + widthPt: z.number().positive(), + style: ContentStrokeStyleSchema.optional(), // stroke dash pattern hint; absent means 'solid', matching ContentStrokeSchema's own documented default + sourcePath: z.string().optional(), // deterministic, document-order-derived path copied from the ContentDocument item this was laid out from +}); +export type LayoutLine = z.infer; + +export const LayoutEllipseSchema = z.object({ + kind: z.literal('ellipse'), + xPt: z.number(), // bottom-left corner of the bounding box + yPt: z.number(), + widthPt: z.number().positive(), + heightPt: z.number().positive(), + fill: ColorSchema.optional(), + stroke: z.object({ color: ColorSchema, widthPt: z.number().positive() }).optional(), + sourcePath: z.string().optional(), // deterministic, document-order-derived path copied from the ContentDocument item this was laid out from +}); +export type LayoutEllipse = z.infer; + +// A path segment in page-absolute PDF user space (see LayoutPathSchema below), not the subpath's own local coordinate space -- unlike ContentVector's 'path' variant (document-schema.js's content.ts), which is still in the source shape's local, viewBox-relative space and needs a frame to place it. By the time a LayoutPath exists, the layout engine has already resolved every point through flipY and shape placement, matching how LayoutLine's x1Pt/y1Pt/x2Pt/y2Pt are already page-absolute rather than carrying a separate frame. +export const LayoutPathSegmentSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('line'), xPt: z.number(), yPt: z.number() }), + z.object({ + kind: z.literal('cubic'), + c1xPt: z.number(), + c1yPt: z.number(), + c2xPt: z.number(), + c2yPt: z.number(), + xPt: z.number(), + yPt: z.number(), + }), +]); +export type LayoutPathSegment = z.infer; + +// One contiguous subpath: an initial moveto point, then a sequence of line/cubic segments, closed or open -- the PDF content-stream model directly (m, then l/c per segment, then an optional h). +export const LayoutSubpathSchema = z.object({ + startXPt: z.number(), + startYPt: z.number(), + segments: z.array(LayoutPathSegmentSchema), + closed: z.boolean(), +}); +export type LayoutSubpath = z.infer; + +// A general vector path: one or more subpaths sharing one fill/stroke, painted with the given fill rule (PDF's f vs f* / B vs B*) -- the LayoutRect/LayoutEllipse fill/stroke shape convention, reused verbatim, plus fillRule since a path (unlike a rect or ellipse) can be self-intersecting or contain nested/overlapping subpaths where nonzero vs evenodd actually changes what paints. +export const LayoutPathSchema = z.object({ + kind: z.literal('path'), + subpaths: z.array(LayoutSubpathSchema), + fill: ColorSchema.optional(), + fillRule: z.enum(['nonzero', 'evenodd']).optional(), + stroke: z.object({ color: ColorSchema, widthPt: z.number().positive() }).optional(), + style: ContentStrokeStyleSchema.optional(), // stroke dash pattern hint; absent means 'solid', matching ContentStrokeSchema's own documented default + sourcePath: z.string().optional(), // deterministic, document-order-derived path copied from the ContentDocument item this was laid out from +}); +export type LayoutPath = z.infer; + +// A URI annotation rectangle -- not painted content, but a clickable region. +export const LayoutLinkSchema = z.object({ + kind: z.literal('link'), + uri: z.string(), + xPt: z.number(), + yPt: z.number(), + widthPt: z.number().nonnegative(), + heightPt: z.number().nonnegative(), + sourcePath: z.string().optional(), // deterministic, document-order-derived path copied from the ContentDocument item this was laid out from +}); +export type LayoutLink = z.infer; + +export const LayoutItemSchema = z.discriminatedUnion('kind', [ + LayoutTextSchema, + LayoutImageSchema, + LayoutRectSchema, + LayoutLineSchema, + LayoutEllipseSchema, + LayoutPathSchema, + LayoutLinkSchema, +]); +export type LayoutItem = z.infer; + +export const LayoutPageSchema = z.object({ + widthPt: z.number().positive(), + heightPt: z.number().positive(), + items: z.array(LayoutItemSchema), // paints in array order, like a PDF content stream + // 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(), +}); +export type LayoutPage = z.infer; + +// 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']), + base64: z.string(), + widthPx: z.number().int().positive(), + heightPx: z.number().int().positive(), +}); +export type LayoutImageAsset = z.infer; + +export const LayoutDocumentSchema = z.object({ + formatVersion: z.literal(LAYOUT_FORMAT_VERSION), + metadata: LayoutMetadataSchema, + pages: z.array(LayoutPageSchema), + images: z.record(z.string(), LayoutImageAssetSchema), +}); +export type LayoutDocument = z.infer; diff --git a/src/math-content-write.ts b/src/math-content-write.ts index 5ac3d6f..42adc8f 100644 --- a/src/math-content-write.ts +++ b/src/math-content-write.ts @@ -142,7 +142,7 @@ export function writeFormulaContentStream(formulas: readonly PositionedFormula[] // Every glyph actually drawn across `formulas` -- the exact, minimal set write.ts's own math font allocation needs for its /W widths array and ToUnicode CMap, even though the embedded CFF program itself carries the font's full, unmodified glyph repertoire (see math-font.ts's own module comment on why). Keyed by glyph ID (= CID, see math-font.ts). // -// The value is the Unicode code point that glyph stands for, or `undefined` for a glyph that HAS no code point in this font's cmap: a stretchy construction's own variant and assembly pieces are addressed by glyph ID precisely because most of them are unencoded (see math-types.ts's MathAssembledGlyphs). Such a glyph still needs its /W width, so it belongs in this map, but it can contribute no ToUnicode entry -- buildMathFontObjects drops it from the CMap, and the /ActualText span writeAssembledGlyphs emits around the construction is what keeps the text recoverable instead. For an ordinary glyph run the mapping is the first code point seen: the cmap this font's glyphId() reads is a proper injective Unicode->glyph mapping for every code point this package's own mathvariant mapping ever produces, so a glyph ID mapping to more than one distinct code point across a whole document is not expected to occur. +// The value is the Unicode code point that glyph stands for, or `undefined` for a glyph that HAS no code point in this font's cmap: a stretchy construction's own variant and assembly pieces are addressed by glyph ID precisely because most of them are unencoded (see the MathAssembledGlyphs item type in document-schema.js's math layout port). Such a glyph still needs its /W width, so it belongs in this map, but it can contribute no ToUnicode entry -- buildMathFontObjects drops it from the CMap, and the /ActualText span writeAssembledGlyphs emits around the construction is what keeps the text recoverable instead. For an ordinary glyph run the mapping is the first code point seen: the cmap this font's glyphId() reads is a proper injective Unicode->glyph mapping for every code point this package's own mathvariant mapping ever produces, so a glyph ID mapping to more than one distinct code point across a whole document is not expected to occur. export function collectUsedGlyphs(formulas: readonly PositionedFormula[], font: MathFont): ReadonlyMap { const used = new Map(); for (const positioned of formulas) { diff --git a/src/math-font.ts b/src/math-font.ts index 2c6a395..03f04c3 100644 --- a/src/math-font.ts +++ b/src/math-font.ts @@ -49,7 +49,7 @@ function toPt(designUnits: number, unitsPerEm: number, sizePt: number): number { return (designUnits / unitsPerEm) * sizePt; } -// The whole construction's own real ink extent about its drawing origin, and its horizontal advance, both measured from the actual glyphs it is built from rather than assumed from the nominal advances the assembly model works in. Along the stretch axis each placement's own offset shifts its ink; across it every placement sits at the same position, so only the glyphs' own bounds matter. Returns undefined when NO placement's outline could be measured at all -- there is then nothing to position the construction by, and math-types.ts's own stretch() contract says the caller falls back to the unstretched base glyph rather than being handed a guessed extent. +// The whole construction's own real ink extent about its drawing origin, and its horizontal advance, both measured from the actual glyphs it is built from rather than assumed from the nominal advances the assembly model works in. Along the stretch axis each placement's own offset shifts its ink; across it every placement sits at the same position, so only the glyphs' own bounds matter. Returns undefined when NO placement's outline could be measured at all -- there is then nothing to position the construction by, and MathFontMetrics.stretch()'s own contract (document-schema.js's math layout port) says the caller falls back to the unstretched base glyph rather than being handed a guessed extent. function measureConstruction(placements: readonly MathStretchPlacement[], axis: MathStretchAxis, inkBounds: CffGlyphBounds | undefined, hmtx: HmtxTable): { inkAscent: number; inkDescent: number; advanceWidth: number } | undefined { let inkAscent = Number.NEGATIVE_INFINITY; let inkDescent = Number.NEGATIVE_INFINITY; diff --git a/src/read.ts b/src/read.ts index 9796bba..1ac6270 100644 --- a/src/read.ts +++ b/src/read.ts @@ -1,9 +1,9 @@ import { bytesToBase64 } from './util/base64'; import { crc32 } from './bytes/crc32'; import { concatBytes } from './bytes/writer'; -import type { LayoutDocument, LayoutEllipse, LayoutImageAsset, LayoutItem, LayoutLine, LayoutLink, LayoutMetadata, LayoutPage, LayoutPath, LayoutPathSegment, LayoutRect, LayoutSubpath, LayoutText } from 'document-schema.js'; -import { LAYOUT_FORMAT_VERSION } from 'document-schema.js'; -import type { Color as LayoutColor, LayoutFont } from 'document-schema.js'; +import type { Color as LayoutColor, LayoutFont, LayoutMetadata } from 'document-schema.js'; +import type { LayoutDocument, LayoutEllipse, LayoutImageAsset, LayoutItem, LayoutLine, LayoutLink, LayoutPage, LayoutPath, LayoutPathSegment, LayoutRect, LayoutSubpath, LayoutText } from './layout'; +import { LAYOUT_FORMAT_VERSION } from './layout'; import { openPdfDocument } from './document'; import type { PdfDiagnosticSink } from './diagnostics'; import { NOOP_DIAGNOSTIC_SINK, PdfParseError } from './diagnostics'; diff --git a/src/roundtrip.test.ts b/src/roundtrip.test.ts index bf5cf2d..45a56a5 100644 --- a/src/roundtrip.test.ts +++ b/src/roundtrip.test.ts @@ -1,7 +1,7 @@ import { bytesToBase64 } from './util/base64'; import { describe, expect, it } from 'vitest'; -import type { LayoutDocument, LayoutEllipse, LayoutImageAsset, LayoutItem, LayoutLine, LayoutPage, LayoutPath, LayoutRect } from 'document-schema.js'; -import { LAYOUT_FORMAT_VERSION, LayoutDocumentSchema } from 'document-schema.js'; +import type { LayoutDocument, LayoutEllipse, LayoutImageAsset, LayoutItem, LayoutLine, LayoutPage, LayoutPath, LayoutRect } from './layout'; +import { LAYOUT_FORMAT_VERSION, LayoutDocumentSchema } from './layout'; import { encodePng } from './image/png-encode'; import { readPdf } from './read'; import { writePdf } from './write'; diff --git a/src/write-embedded-font.test.ts b/src/write-embedded-font.test.ts index 91f2bc0..5594eb0 100644 --- a/src/write-embedded-font.test.ts +++ b/src/write-embedded-font.test.ts @@ -1,7 +1,8 @@ import { createHash } from 'node:crypto'; import { describe, expect, it } from 'vitest'; -import type { LayoutDocument, LayoutFont, LayoutImageAsset } from 'document-schema.js'; -import { LAYOUT_FORMAT_VERSION } from 'document-schema.js'; +import type { LayoutFont } from 'document-schema.js'; +import type { LayoutDocument, LayoutImageAsset } from './layout'; +import { LAYOUT_FORMAT_VERSION } from './layout'; import type { EmbeddedFace, EmbeddedFaceSubstitution } from './embedded-font'; import { encodeForShowEmbedded, loadEmbeddedFace } from './embedded-font'; import { createFontRegistry } from './font-registry'; diff --git a/src/write-path.test.ts b/src/write-path.test.ts index b1fe485..31a83eb 100644 --- a/src/write-path.test.ts +++ b/src/write-path.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { LayoutPath, TextMeasurer } from 'document-schema.js'; +import type { TextMeasurer } from 'document-schema.js'; +import type { LayoutPath } from './layout'; import type { ContentWriteContext } from './content-write'; import { writeContentStream } from './content-write'; diff --git a/src/write.test.ts b/src/write.test.ts index a5b833d..6fe4fd9 100644 --- a/src/write.test.ts +++ b/src/write.test.ts @@ -1,7 +1,7 @@ import { bytesToBase64 } from './util/base64'; import { describe, expect, it } from 'vitest'; -import type { LayoutDocument, LayoutImageAsset, LayoutItem, LayoutPage } from 'document-schema.js'; -import { LAYOUT_FORMAT_VERSION } from 'document-schema.js'; +import type { LayoutDocument, LayoutImageAsset, LayoutItem, LayoutPage } from './layout'; +import { LAYOUT_FORMAT_VERSION } from './layout'; import { encodePng } from './image/png-encode'; import { writePdf } from './write'; diff --git a/src/write.ts b/src/write.ts index 7db5bc7..9c9f214 100644 --- a/src/write.ts +++ b/src/write.ts @@ -3,7 +3,8 @@ import { deflate } from './bytes/flate'; import { ByteWriter, concatBytes } from './bytes/writer'; import { readJpegInfo } from './image/jpeg-info'; import { decodePng } from './image/png-decode'; -import type { LayoutDocument, LayoutFont, LayoutImageAsset, LayoutLink, PositionedFormula } from 'document-schema.js'; +import type { LayoutFont, PositionedFormula } from 'document-schema.js'; +import type { LayoutDocument, LayoutImageAsset, LayoutLink } from './layout'; import type { FontMetrics, StandardFontName } from './afm-widths'; import { STANDARD_METRICS, widthOfCode } from './afm-widths'; import type { ContentWriteContext } from './content-write'; @@ -25,7 +26,7 @@ import { throwIfAborted } from './util/abort'; import { writeObject } from './serialize'; import type { WinAnsiSubstitution } from './winansi'; -// A formula's own glyph runs are shown through an embedded CID composite font via Identity-H 2-byte CIDs (see math-content-write.ts's own module comment) -- a fundamentally different content-stream shape from an ordinary LayoutText item's single-byte WinAnsi string, and one document-schema.js's own LayoutItem union has no member for (LayoutFont only ever names one of the 14 standard PDF faces -- see src/model/style.ts's own comment -- with no room for "this run uses an embedded, non-standard font resource" at all). A formula therefore cannot travel through LayoutDocument.pages[].items the way every other kind of content this writer draws does; WritePdfOptions.formulas is this module's own, local side channel for it instead, positioned entirely outside document-schema.js's own schema. +// A formula's own glyph runs are shown through an embedded CID composite font via Identity-H 2-byte CIDs (see math-content-write.ts's own module comment) -- a fundamentally different content-stream shape from an ordinary LayoutText item's single-byte WinAnsi string, and one this package's own LayoutItem union (src/layout.ts) has no member for (LayoutFont only ever names one of the 14 standard PDF faces -- see document-schema.js's style.ts comment -- with no room for "this run uses an embedded, non-standard font resource" at all). A formula therefore cannot travel through LayoutDocument.pages[].items the way every other kind of content this writer draws does; WritePdfOptions.formulas is this module's own, local side channel for it instead, positioned entirely outside the LayoutDocument schema itself. const MATH_FONT_RESOURCE_NAME = 'MF'; // The /Resources/Font key prefix for an embedded text face, deliberately distinct from both the standard-14 faces' own 'F' prefix and the math font's 'MF': all three share one /Font dict, so a collision would silently make one font's resource name resolve to another's object.