Initial Commit
This commit is contained in:
133
packages/devtools/docs/pdf/README.md
Normal file
133
packages/devtools/docs/pdf/README.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# @suaveplan/pdf - Server-tier PDF 1.4 generation toolkit (Chunks A–D): typed PDF error registry, Zod boundary schemas, OpenTelemetry counters + histograms, unit conversion, hex/RGB colour utilities, page-size + margin types, hand-rolled PDF 1.4 binary writer (with FlateDecode + AbortSignal + injectable Clock), chainable content-stream encoder, document elements (text / table / image / divider) with Zod boundaries and AbortSignal propagation, style cascade with default theme, block-flow layout engine with header/footer slots, fluent DocumentBuilder, and orchestrating renderDocument entry point round-trippable via pdf-parse.
|
||||
|
||||
Server-tier PDF 1.4 generation toolkit. **Chunk A foundation** — pure
|
||||
utilities + cross-cutting wiring (typed error registry, Zod boundary
|
||||
schemas, OpenTelemetry counters + histograms, unit + colour utilities,
|
||||
page-size + margin types). The low-level writer, element primitives,
|
||||
layout engine, document builder, renderer, templates, and React preview
|
||||
component land in subsequent chunks (B–E). See
|
||||
[`enterprise-pdf/tasks.md`](../../../../docs/design/enterprise-pdf/tasks.md)
|
||||
for the chunk roadmap.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [colors](./src/colors/colors.md) — Colour utilities for `@suaveplan/pdf`. The PDF specification stores colours as three floats in the `0`-`1` range (consu…
|
||||
- [colors](./src/colors/colors.md) — Colour utilities for `@suaveplan/pdf`. The PDF specification stores colours as three floats in the `0`-`1` range (consu…
|
||||
- [content-stream](./src/content-stream/content-stream.md) — `ContentStreamEncoder` is the chainable builder for PDF 1.4 content streams used by every page emitted by `@suaveplan/p…
|
||||
- [content-stream](./src/content-stream/content-stream.md) — `ContentStreamEncoder` is the chainable builder for PDF 1.4 content streams used by every page emitted by `@suaveplan/p…
|
||||
- [divider-element](./src/divider-element/divider-element.md) — `DividerElement` renders a horizontal or vertical rule into a PDF content stream. It is the lightweight separator primi…
|
||||
- [divider-element](./src/divider-element/divider-element.md) — `DividerElement` renders a horizontal or vertical rule into a PDF content stream. It is the lightweight separator primi…
|
||||
- [document-builder](./src/document-builder/document-builder.md) — The `DocumentBuilder` provides a chainable, deterministic API for assembling a PDF document tree without touching the l…
|
||||
- [document-builder](./src/document-builder/document-builder.md) — The `DocumentBuilder` provides a chainable, deterministic API for assembling a PDF document tree without touching the l…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Declares the stable `PDF.*` error-code registry and the `PdfError` class used by every public function in `@suaveplan/p…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Declares the stable `PDF.*` error-code registry and the `PdfError` class used by every public function in `@suaveplan/p…
|
||||
- [image-element](./src/image-element/image-element.md) — `ImageElement` is the bitmap primitive of `@suaveplan/pdf`. It accepts raw JPEG or PNG bytes, decodes them at construct…
|
||||
- [image-element](./src/image-element/image-element.md) — `ImageElement` is the bitmap primitive of `@suaveplan/pdf`. It accepts raw JPEG or PNG bytes, decodes them at construct…
|
||||
- [image-registry](./src/image-registry/image-registry.md) — The `ImageRegistry` allocates and reuses PDF Image XObjects across the lifetime of a single `renderDocument` call. Each…
|
||||
- [image-registry](./src/image-registry/image-registry.md) — The `ImageRegistry` allocates and reuses PDF Image XObjects across the lifetime of a single `renderDocument` call. Each…
|
||||
- [layout-engine](./src/layout-engine/layout-engine.md) — The `LayoutEngine` owns the block-flow geometry the renderer walks to position each element on the page. It computes ef…
|
||||
- [layout-engine](./src/layout-engine/layout-engine.md) — The `LayoutEngine` owns the block-flow geometry the renderer walks to position each element on the page. It computes ef…
|
||||
- [pdf-writer](./src/pdf-writer/pdf-writer.md) — `PdfWriter` is the low-level binary writer that emits valid PDF 1.4 byte streams. It manages indirect-object allocation…
|
||||
- [pdf-writer](./src/pdf-writer/pdf-writer.md) — `PdfWriter` is the low-level binary writer that emits valid PDF 1.4 byte streams. It manages indirect-object allocation…
|
||||
- [render-pipeline](./src/render-pipeline/render-pipeline.md) — Internal pipeline helpers that the top-level `renderDocument` orchestrator delegates to. Splitting placement and slot-r…
|
||||
- [render-pipeline](./src/render-pipeline/render-pipeline.md) — Internal pipeline helpers that the top-level `renderDocument` orchestrator delegates to. Splitting placement and slot-r…
|
||||
- [renderer](./src/renderer/renderer.md) — `renderDocument` is the top-level orchestrator that turns a `Document` AST (produced by `DocumentBuilder`) into a real…
|
||||
- [renderer](./src/renderer/renderer.md) — `renderDocument` is the top-level orchestrator that turns a `Document` AST (produced by `DocumentBuilder`) into a real…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod boundary schemas for the public surface of `@suaveplan/pdf`. Every value crossing the package boundary from untrust…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod boundary schemas for the public surface of `@suaveplan/pdf`. Every value crossing the package boundary from untrust…
|
||||
- [style-resolver](./src/style-resolver/style-resolver.md) — The `StyleResolver` resolves a fully-populated `PdfStyle` from the package defaults, an active theme, an optional base…
|
||||
- [style-resolver](./src/style-resolver/style-resolver.md) — The `StyleResolver` resolves a fully-populated `PdfStyle` from the package defaults, an active theme, an optional base…
|
||||
- [table-element](./src/table-element/table-element.md) — `TableElement` is the tabular-data primitive of `@suaveplan/pdf`. It accepts a typed column definition + row array and…
|
||||
- [table-element](./src/table-element/table-element.md) — `TableElement` is the tabular-data primitive of `@suaveplan/pdf`. It accepts a typed column definition + row array and…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — OpenTelemetry surface for `@suaveplan/pdf`. PDF generation is a load-bearing primitive in any system that emits invoice…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — OpenTelemetry surface for `@suaveplan/pdf`. PDF generation is a load-bearing primitive in any system that emits invoice…
|
||||
- [invoice-template](./src/templates/invoice-template/invoice-template.md) — `createInvoiceTemplate(data, options?)` converts a typed `InvoiceData` payload into a renderer-ready `Document` for pro…
|
||||
- [invoice-template](./src/templates/invoice-template/invoice-template.md) — `createInvoiceTemplate(data, options?)` converts a typed `InvoiceData` payload into a renderer-ready `Document` for pro…
|
||||
- [receipt-template](./src/templates/receipt-template/receipt-template.md) — `createReceiptTemplate(data, options?)` converts a typed `ReceiptData` payload into a renderer-ready `Document` for com…
|
||||
- [receipt-template](./src/templates/receipt-template/receipt-template.md) — `createReceiptTemplate(data, options?)` converts a typed `ReceiptData` payload into a renderer-ready `Document` for com…
|
||||
- [report-template](./src/templates/report-template/report-template.md) — `createReportTemplate(data, options?)` converts a typed `ReportData` payload into a renderer-ready `Document` for multi…
|
||||
- [report-template](./src/templates/report-template/report-template.md) — `createReportTemplate(data, options?)` converts a typed `ReportData` payload into a renderer-ready `Document` for multi…
|
||||
- [text-element](./src/text-element/text-element.md) — `TextElement` is the flowed-text primitive of `@suaveplan/pdf`. It accepts a content string plus typography options (fo…
|
||||
- [text-element](./src/text-element/text-element.md) — `TextElement` is the flowed-text primitive of `@suaveplan/pdf`. It accepts a content string plus typography options (fo…
|
||||
- [types](./src/types/page-size.md) — Defines the page-size + page-config primitives used across every other module in `@suaveplan/pdf`. PDF measurements are…
|
||||
- [page-size](./src/types/page-size.md) — Defines the page-size + page-config primitives used across every other module in `@suaveplan/pdf`. PDF measurements are…
|
||||
- [units](./src/units/units.md) — Unit-conversion utilities for `@suaveplan/pdf`. PDF's native measurement is the **point** (`1pt = 1/72 inch`); every co…
|
||||
- [units](./src/units/units.md) — Unit-conversion utilities for `@suaveplan/pdf`. PDF's native measurement is the **point** (`1pt = 1/72 inch`); every co…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/pdf
|
||||
```
|
||||
|
||||
## Surface (Chunk A)
|
||||
|
||||
| Subpath | Symbols |
|
||||
| --- | --- |
|
||||
| `@suaveplan/pdf/error-codes` | `PDF_CODES`, `PdfError`, `asPdfError`, `PdfErrorCode` |
|
||||
| `@suaveplan/pdf/schemas` | `UnitSchema`, `HexColorSchema`, `RgbColorSchema`, `MarginsSchema`, `PageConfigSchema`, `DocumentMetadataSchema`, `parseOrThrow`, `parseHexColor` |
|
||||
| `@suaveplan/pdf/telemetry` | `createPdfObservability`, six `record*` helpers, `PdfObservability` |
|
||||
| `@suaveplan/pdf/units` | `Unit`, `toPoints`, `fromPoints`, `convert`, `parseUnit`, `formatUnit` |
|
||||
| `@suaveplan/pdf/colors` | `RgbColor`, `PdfColor`, `hexToRgb`, `rgbToPdfColor`, `hexToPdfColor`, `rgbToHex`, `lighten`, `darken`, `COLORS`, `isTransparent` |
|
||||
| `@suaveplan/pdf/types` | `PageSize`, `PAGE_DIMENSIONS`, `Margins`, `DEFAULT_MARGINS`, `DocumentMetadata`, `PageConfig`, `DEFAULT_PAGE_CONFIG`, `DocumentDefinition`, `PageDefinition`, `ElementNode` |
|
||||
|
||||
The package barrel `@suaveplan/pdf` re-exports every symbol above.
|
||||
|
||||
## Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
convert,
|
||||
hexToPdfColor,
|
||||
parseOrThrow,
|
||||
PageConfigSchema,
|
||||
PAGE_DIMENSIONS,
|
||||
PageSize,
|
||||
PDF_CODES,
|
||||
} from "@suaveplan/pdf";
|
||||
|
||||
const a4 = PAGE_DIMENSIONS[PageSize.A4];
|
||||
const inchInMm = convert(1, "in", "mm"); // ≈ 25.4
|
||||
const accent = hexToPdfColor("#3366cc"); // PDF-native 0-1 triple
|
||||
|
||||
const page = parseOrThrow(
|
||||
PageConfigSchema,
|
||||
requestBody.page,
|
||||
PDF_CODES.INVALID_PAGE_CONFIG,
|
||||
"bad page config"
|
||||
);
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `@suaveplan/error` — typed-error base.
|
||||
- `@suaveplan/opentelemetry` — counter / histogram helpers.
|
||||
- `@suaveplan/types` — shared `Clock` etc. for later chunks.
|
||||
- `@opentelemetry/api` — meter / tracer global API.
|
||||
- `zod` — boundary validation.
|
||||
- `tslib` — runtime helpers.
|
||||
|
||||
No third-party PDF dependency. Chunk B will keep the hand-rolled PDF 1.4
|
||||
writer the upstream package ships with — the spec is small enough that
|
||||
shipping a custom 200 LOC implementation is preferable to pulling in
|
||||
`pdfkit` (`~600 KB` unpacked, CommonJS-only, includes unrelated PNG and
|
||||
font subsystems).
|
||||
|
||||
## Roadmap
|
||||
|
||||
- **Chunk B** — `pdf-writer/`, `content-stream/` (low-level binary writer + content stream encoder).
|
||||
- **Chunk C** — `text-element/`, `table-element/`, `image-element/`, `divider-element/`.
|
||||
- **Chunk D** — `layout-engine/`, `style-resolver/`, `document-builder/`, `renderer/`.
|
||||
- **Chunk E** — `invoice-template/`, `receipt-template/`, `report-template/`, `pdf-preview/` (React).
|
||||
|
||||
## Quality gate
|
||||
|
||||
```bash
|
||||
bunx turbo lint typecheck test build --filter=@suaveplan/pdf
|
||||
bunx turbo test:coverage --filter=@suaveplan/pdf
|
||||
```
|
||||
|
||||
100 / 100 / 100 / 100 Istanbul coverage on every Chunk A module.
|
||||
|
||||
188
packages/devtools/docs/pdf/package.json
Normal file
188
packages/devtools/docs/pdf/package.json
Normal file
@@ -0,0 +1,188 @@
|
||||
{
|
||||
"name": "@suaveplan/pdf",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Server-tier PDF 1.4 generation toolkit (Chunks A–D): typed PDF error registry, Zod boundary schemas, OpenTelemetry counters + histograms, unit conversion, hex/RGB colour utilities, page-size + margin types, hand-rolled PDF 1.4 binary writer (with FlateDecode + AbortSignal + injectable Clock), chainable content-stream encoder, document elements (text / table / image / divider) with Zod boundaries and AbortSignal propagation, style cascade with default theme, block-flow layout engine with header/footer slots, fluent DocumentBuilder, and orchestrating renderDocument entry point round-trippable via pdf-parse.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
},
|
||||
"./schemas": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/schemas.d.ts",
|
||||
"default": "./dist/schemas/schemas.js"
|
||||
}
|
||||
},
|
||||
"./telemetry": {
|
||||
"import": {
|
||||
"types": "./dist/telemetry/telemetry.d.ts",
|
||||
"default": "./dist/telemetry/telemetry.js"
|
||||
}
|
||||
},
|
||||
"./units": {
|
||||
"import": {
|
||||
"types": "./dist/units/units.d.ts",
|
||||
"default": "./dist/units/units.js"
|
||||
}
|
||||
},
|
||||
"./colors": {
|
||||
"import": {
|
||||
"types": "./dist/colors/colors.d.ts",
|
||||
"default": "./dist/colors/colors.js"
|
||||
}
|
||||
},
|
||||
"./pdf-writer": {
|
||||
"import": {
|
||||
"types": "./dist/pdf-writer/pdf-writer.d.ts",
|
||||
"default": "./dist/pdf-writer/pdf-writer.js"
|
||||
}
|
||||
},
|
||||
"./content-stream": {
|
||||
"import": {
|
||||
"types": "./dist/content-stream/content-stream.d.ts",
|
||||
"default": "./dist/content-stream/content-stream.js"
|
||||
}
|
||||
},
|
||||
"./text-element": {
|
||||
"import": {
|
||||
"types": "./dist/text-element/text-element.d.ts",
|
||||
"default": "./dist/text-element/text-element.js"
|
||||
}
|
||||
},
|
||||
"./table-element": {
|
||||
"import": {
|
||||
"types": "./dist/table-element/table-element.d.ts",
|
||||
"default": "./dist/table-element/table-element.js"
|
||||
}
|
||||
},
|
||||
"./image-element": {
|
||||
"import": {
|
||||
"types": "./dist/image-element/image-element.d.ts",
|
||||
"default": "./dist/image-element/image-element.js"
|
||||
}
|
||||
},
|
||||
"./divider-element": {
|
||||
"import": {
|
||||
"types": "./dist/divider-element/divider-element.d.ts",
|
||||
"default": "./dist/divider-element/divider-element.js"
|
||||
}
|
||||
},
|
||||
"./style-resolver": {
|
||||
"import": {
|
||||
"types": "./dist/style-resolver/style-resolver.d.ts",
|
||||
"default": "./dist/style-resolver/style-resolver.js"
|
||||
}
|
||||
},
|
||||
"./layout-engine": {
|
||||
"import": {
|
||||
"types": "./dist/layout-engine/layout-engine.d.ts",
|
||||
"default": "./dist/layout-engine/layout-engine.js"
|
||||
}
|
||||
},
|
||||
"./document-builder": {
|
||||
"import": {
|
||||
"types": "./dist/document-builder/document-builder.d.ts",
|
||||
"default": "./dist/document-builder/document-builder.js"
|
||||
}
|
||||
},
|
||||
"./renderer": {
|
||||
"import": {
|
||||
"types": "./dist/renderer/renderer.d.ts",
|
||||
"default": "./dist/renderer/renderer.js"
|
||||
}
|
||||
},
|
||||
"./render-pipeline": {
|
||||
"import": {
|
||||
"types": "./dist/render-pipeline/render-pipeline.d.ts",
|
||||
"default": "./dist/render-pipeline/render-pipeline.js"
|
||||
}
|
||||
},
|
||||
"./image-registry": {
|
||||
"import": {
|
||||
"types": "./dist/image-registry/image-registry.d.ts",
|
||||
"default": "./dist/image-registry/image-registry.js"
|
||||
}
|
||||
},
|
||||
"./types": {
|
||||
"import": {
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"default": "./dist/types/index.js"
|
||||
}
|
||||
},
|
||||
"./templates/receipt-template": {
|
||||
"import": {
|
||||
"types": "./dist/templates/receipt-template/receipt-template.d.ts",
|
||||
"default": "./dist/templates/receipt-template/receipt-template.js"
|
||||
}
|
||||
},
|
||||
"./templates/report-template": {
|
||||
"import": {
|
||||
"types": "./dist/templates/report-template/report-template.d.ts",
|
||||
"default": "./dist/templates/report-template/report-template.js"
|
||||
}
|
||||
},
|
||||
"./templates/invoice-template": {
|
||||
"import": {
|
||||
"types": "./dist/templates/invoice-template/invoice-template.d.ts",
|
||||
"default": "./dist/templates/invoice-template/invoice-template.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/opentelemetry": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"pngjs": "^6.0.0",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
93
packages/devtools/docs/pdf/src/colors/colors.md
Normal file
93
packages/devtools/docs/pdf/src/colors/colors.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# `colors`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Colour utilities for `@suaveplan/pdf`. The PDF specification stores
|
||||
colours as three floats in the `0`-`1` range (consumed by the `rg` and
|
||||
`RG` operators in a content stream), whereas humans and design systems
|
||||
use `0`-`255` RGB triples or `#rrggbb` hex strings. This module bridges
|
||||
the three representations, exposes the named palette consumed by the
|
||||
default theme, and provides theme-friendly `lighten` / `darken`
|
||||
helpers — all with the same typed `PdfError(PDF_CODES.INVALID_HEX_COLOR)`
|
||||
failure mode on bad input.
|
||||
|
||||
## Features
|
||||
|
||||
- `hexToRgb` / `rgbToHex` — bidirectional conversion between `#rrggbb`
|
||||
and `{ r, g, b }`. Accepts 3-char shorthand (`#f00` → `{255, 0, 0}`).
|
||||
- `rgbToPdfColor` / `hexToPdfColor` — convert to the `0`-`1` triples the
|
||||
PDF writer's content-stream operators expect.
|
||||
- `lighten` / `darken` — adjust a hex colour by a percentage with
|
||||
clamp-safe semantics (`lighten("#000", 100) === "#ffffff"`).
|
||||
- `COLORS` — named palette (`black`, `white`, `red`, `green`, `blue`,
|
||||
`gray`, `lightGray`, `darkGray`, `transparent`).
|
||||
- `isTransparent` — recognises the `"transparent"` and `"none"` keywords.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { hexToPdfColor, hexToRgb, rgbToHex } from "@suaveplan/pdf/colors";
|
||||
|
||||
const rgb = hexToRgb("#ffaa00"); // { r: 255, g: 170, b: 0 }
|
||||
const hex = rgbToHex(rgb); // "#ffaa00"
|
||||
const pdf = hexToPdfColor("#ffaa00"); // { r: 1, g: 0.667, b: 0 }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Build a tinted theme by composing `lighten` and `darken`:
|
||||
|
||||
```ts
|
||||
import { COLORS, darken, isTransparent, lighten } from "@suaveplan/pdf/colors";
|
||||
|
||||
const accent = "#3366cc";
|
||||
const theme = {
|
||||
accent,
|
||||
accentHover: lighten(accent, 10),
|
||||
accentActive: darken(accent, 15),
|
||||
surface: COLORS.white,
|
||||
border: COLORS.lightGray,
|
||||
};
|
||||
|
||||
if (!isTransparent(theme.surface)) {
|
||||
// …emit a background fill
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface RgbColor { r, g, b: number }` — values in `0`-`255`.
|
||||
- `interface PdfColor { r, g, b: number }` — values in `0`-`1`.
|
||||
- `hexToRgb(hex: string): RgbColor` — accepts `#RGB`, `#RRGGBB`, `RGB`,
|
||||
`RRGGBB`; throws `PdfError(PDF_CODES.INVALID_HEX_COLOR)` on bad input.
|
||||
- `rgbToPdfColor(rgb: RgbColor): PdfColor` — clamps to `0`-`1`.
|
||||
- `hexToPdfColor(hex: string): PdfColor` — composition of the above.
|
||||
- `rgbToHex(rgb: RgbColor): string` — emits `#rrggbb` (lowercase, padded).
|
||||
- `lighten(hex: string, amount: number): string` — `amount` in `0`-`100`,
|
||||
clamped.
|
||||
- `darken(hex: string, amount: number): string` — `amount` in `0`-`100`,
|
||||
clamped.
|
||||
- `COLORS` — frozen palette object.
|
||||
- `isTransparent(color: string): boolean`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The hex regex `^[0-9a-fA-F]{6}$` is applied after the 3-char expansion,
|
||||
so `"#f00"` → `"ff0000"` is validated as a 6-char form.
|
||||
- `rgbToHex` rounds with `Math.round`; `127.5` rounds to `128` per
|
||||
IEEE banker's rounding only on `.5` exact, and `127.4 → 127`
|
||||
(`0x7f`), `127.6 → 128` (`0x80`).
|
||||
- `lighten` linearly interpolates each channel toward white
|
||||
(`c + (255 - c) * f`); `darken` linearly interpolates toward black
|
||||
(`c * (1 - f)`). Out-of-range `amount` is clamped to `[0, 100]` so
|
||||
callers can pass user input directly.
|
||||
- The named palette is `as const` so consumers get string-literal types
|
||||
when destructuring (e.g. `COLORS.black`'s type is `"#000000"`, not
|
||||
`string`).
|
||||
- `"transparent"` and `"none"` are the only no-paint keywords; any other
|
||||
string — empty or otherwise — is treated as a hex colour that must
|
||||
parse, matching CSS-like semantics without supporting `rgba()`.
|
||||
175
packages/devtools/docs/pdf/src/colors/colors.test.ts
Normal file
175
packages/devtools/docs/pdf/src/colors/colors.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PDF_CODES, PdfError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
COLORS,
|
||||
darken,
|
||||
hexToPdfColor,
|
||||
hexToRgb,
|
||||
isTransparent,
|
||||
lighten,
|
||||
rgbToHex,
|
||||
rgbToPdfColor,
|
||||
} from "./colors.js";
|
||||
|
||||
describe("hexToRgb", () => {
|
||||
it("parses 6-char hex with hash", { timeout: 5000 }, () => {
|
||||
expect(hexToRgb("#ff0000")).toEqual({ r: 255, g: 0, b: 0 });
|
||||
});
|
||||
|
||||
it("parses 6-char hex without hash", { timeout: 5000 }, () => {
|
||||
expect(hexToRgb("00ff00")).toEqual({ r: 0, g: 255, b: 0 });
|
||||
});
|
||||
|
||||
it("expands 3-char form", { timeout: 5000 }, () => {
|
||||
expect(hexToRgb("#f00")).toEqual({ r: 255, g: 0, b: 0 });
|
||||
expect(hexToRgb("0f0")).toEqual({ r: 0, g: 255, b: 0 });
|
||||
});
|
||||
|
||||
it("accepts uppercase", { timeout: 5000 }, () => {
|
||||
expect(hexToRgb("#ABCDEF")).toEqual({ r: 171, g: 205, b: 239 });
|
||||
});
|
||||
|
||||
it("rejects invalid hex string", { timeout: 5000 }, () => {
|
||||
try {
|
||||
hexToRgb("#zz");
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(PdfError);
|
||||
expect((err as PdfError).code).toBe(PDF_CODES.INVALID_HEX_COLOR);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects wrong-length hex", { timeout: 5000 }, () => {
|
||||
expect(() => hexToRgb("#12345")).toThrow(PdfError);
|
||||
expect(() => hexToRgb("#1234567")).toThrow(PdfError);
|
||||
});
|
||||
|
||||
it("rejects non-hex characters in 6-char form", { timeout: 5000 }, () => {
|
||||
expect(() => hexToRgb("#gggggg")).toThrow(PdfError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rgbToPdfColor", () => {
|
||||
it("normalises to 0-1 range", { timeout: 5000 }, () => {
|
||||
expect(rgbToPdfColor({ r: 255, g: 0, b: 0 })).toEqual({
|
||||
r: 1,
|
||||
g: 0,
|
||||
b: 0,
|
||||
});
|
||||
expect(rgbToPdfColor({ r: 128, g: 128, b: 128 })).toEqual({
|
||||
r: 128 / 255,
|
||||
g: 128 / 255,
|
||||
b: 128 / 255,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps below 0 and above 255", { timeout: 5000 }, () => {
|
||||
expect(rgbToPdfColor({ r: -10, g: 0, b: 0 })).toEqual({
|
||||
r: 0,
|
||||
g: 0,
|
||||
b: 0,
|
||||
});
|
||||
expect(rgbToPdfColor({ r: 999, g: 999, b: 999 })).toEqual({
|
||||
r: 1,
|
||||
g: 1,
|
||||
b: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("hexToPdfColor", () => {
|
||||
it("chains hexToRgb + rgbToPdfColor", { timeout: 5000 }, () => {
|
||||
expect(hexToPdfColor("#ffffff")).toEqual({ r: 1, g: 1, b: 1 });
|
||||
expect(hexToPdfColor("#000000")).toEqual({ r: 0, g: 0, b: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("rgbToHex", () => {
|
||||
it("formats 6-char hex with hash", { timeout: 5000 }, () => {
|
||||
expect(rgbToHex({ r: 255, g: 0, b: 0 })).toBe("#ff0000");
|
||||
expect(rgbToHex({ r: 0, g: 255, b: 0 })).toBe("#00ff00");
|
||||
});
|
||||
|
||||
it("pads single-digit components", { timeout: 5000 }, () => {
|
||||
expect(rgbToHex({ r: 1, g: 2, b: 3 })).toBe("#010203");
|
||||
});
|
||||
|
||||
it("rounds non-integer components", { timeout: 5000 }, () => {
|
||||
expect(rgbToHex({ r: 127.4, g: 127.5, b: 127.6 })).toBe("#7f8080");
|
||||
});
|
||||
|
||||
it("clamps out-of-range components", { timeout: 5000 }, () => {
|
||||
expect(rgbToHex({ r: -10, g: 999, b: 128 })).toBe("#00ff80");
|
||||
});
|
||||
});
|
||||
|
||||
describe("lighten", () => {
|
||||
it("clamps at white when amount is 100", { timeout: 5000 }, () => {
|
||||
expect(lighten("#000000", 100)).toBe("#ffffff");
|
||||
});
|
||||
|
||||
it("returns input when amount is 0", { timeout: 5000 }, () => {
|
||||
expect(lighten("#445566", 0)).toBe("#445566");
|
||||
});
|
||||
|
||||
it("clamps negative amount to 0", { timeout: 5000 }, () => {
|
||||
expect(lighten("#445566", -5)).toBe("#445566");
|
||||
});
|
||||
|
||||
it("clamps over-100 amount to 100", { timeout: 5000 }, () => {
|
||||
expect(lighten("#000000", 250)).toBe("#ffffff");
|
||||
});
|
||||
|
||||
it("lightens mid-grey by 50%", { timeout: 5000 }, () => {
|
||||
expect(lighten("#808080", 50)).toBe("#c0c0c0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("darken", () => {
|
||||
it("clamps at black when amount is 100", { timeout: 5000 }, () => {
|
||||
expect(darken("#ffffff", 100)).toBe("#000000");
|
||||
});
|
||||
|
||||
it("returns input when amount is 0", { timeout: 5000 }, () => {
|
||||
expect(darken("#445566", 0)).toBe("#445566");
|
||||
});
|
||||
|
||||
it("clamps negative amount to 0", { timeout: 5000 }, () => {
|
||||
expect(darken("#445566", -5)).toBe("#445566");
|
||||
});
|
||||
|
||||
it("clamps over-100 amount to 100", { timeout: 5000 }, () => {
|
||||
expect(darken("#ffffff", 250)).toBe("#000000");
|
||||
});
|
||||
|
||||
it("darkens mid-grey by 50%", { timeout: 5000 }, () => {
|
||||
expect(darken("#808080", 50)).toBe("#404040");
|
||||
});
|
||||
});
|
||||
|
||||
describe("COLORS", () => {
|
||||
it("contains the named palette", { timeout: 5000 }, () => {
|
||||
expect(COLORS.black).toBe("#000000");
|
||||
expect(COLORS.white).toBe("#ffffff");
|
||||
expect(COLORS.red).toBe("#ff0000");
|
||||
expect(COLORS.green).toBe("#00ff00");
|
||||
expect(COLORS.blue).toBe("#0000ff");
|
||||
expect(COLORS.gray).toBe("#808080");
|
||||
expect(COLORS.lightGray).toBe("#d3d3d3");
|
||||
expect(COLORS.darkGray).toBe("#404040");
|
||||
expect(COLORS.transparent).toBe("transparent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTransparent", () => {
|
||||
it("returns true for transparent and none", { timeout: 5000 }, () => {
|
||||
expect(isTransparent("transparent")).toBe(true);
|
||||
expect(isTransparent("none")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for any other colour", { timeout: 5000 }, () => {
|
||||
expect(isTransparent("#000000")).toBe(false);
|
||||
expect(isTransparent("")).toBe(false);
|
||||
expect(isTransparent("rgba(0,0,0,0)")).toBe(false);
|
||||
});
|
||||
});
|
||||
107
packages/devtools/docs/pdf/src/colors/colors.ts
Normal file
107
packages/devtools/docs/pdf/src/colors/colors.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { PDF_CODES, PdfError } from "../error-codes/error-codes.js";
|
||||
|
||||
/** RGB colour representation with values `0`-`255`. */
|
||||
export interface RgbColor {
|
||||
readonly r: number;
|
||||
readonly g: number;
|
||||
readonly b: number;
|
||||
}
|
||||
|
||||
/** PDF colour values normalised to the `0`-`1` range used by PDF operators. */
|
||||
export interface PdfColor {
|
||||
readonly r: number;
|
||||
readonly g: number;
|
||||
readonly b: number;
|
||||
}
|
||||
|
||||
const HEX_RE = /^[0-9a-fA-F]{6}$/;
|
||||
|
||||
/**
|
||||
* Parse a hex colour string to RGB values. Accepts `#RGB`, `#RRGGBB`,
|
||||
* `RGB`, and `RRGGBB` (leading `#` is optional and stripped).
|
||||
*/
|
||||
export function hexToRgb(hex: string): RgbColor {
|
||||
let cleaned = hex.replace(/^#/, "");
|
||||
|
||||
if (cleaned.length === 3) {
|
||||
cleaned = cleaned
|
||||
.split("")
|
||||
.map((c) => c + c)
|
||||
.join("");
|
||||
}
|
||||
|
||||
if (cleaned.length !== 6 || !HEX_RE.test(cleaned)) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.INVALID_HEX_COLOR,
|
||||
`invalid hex colour: "${hex}"`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
r: Number.parseInt(cleaned.slice(0, 2), 16),
|
||||
g: Number.parseInt(cleaned.slice(2, 4), 16),
|
||||
b: Number.parseInt(cleaned.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert RGB values (`0`-`255`) to PDF colour values (`0`-`1`). */
|
||||
export function rgbToPdfColor(rgb: RgbColor): PdfColor {
|
||||
return {
|
||||
r: Math.max(0, Math.min(1, rgb.r / 255)),
|
||||
g: Math.max(0, Math.min(1, rgb.g / 255)),
|
||||
b: Math.max(0, Math.min(1, rgb.b / 255)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse a hex colour directly to PDF colour values (`0`-`1`). */
|
||||
export function hexToPdfColor(hex: string): PdfColor {
|
||||
return rgbToPdfColor(hexToRgb(hex));
|
||||
}
|
||||
|
||||
/** Convert RGB values to a hex string of the form `"#rrggbb"`. */
|
||||
export function rgbToHex(rgb: RgbColor): string {
|
||||
const r = Math.max(0, Math.min(255, Math.round(rgb.r)));
|
||||
const g = Math.max(0, Math.min(255, Math.round(rgb.g)));
|
||||
const b = Math.max(0, Math.min(255, Math.round(rgb.b)));
|
||||
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** Lighten a hex colour by a percentage (`0`-`100`). */
|
||||
export function lighten(hex: string, amount: number): string {
|
||||
const rgb = hexToRgb(hex);
|
||||
const factor = Math.min(100, Math.max(0, amount)) / 100;
|
||||
return rgbToHex({
|
||||
r: rgb.r + (255 - rgb.r) * factor,
|
||||
g: rgb.g + (255 - rgb.g) * factor,
|
||||
b: rgb.b + (255 - rgb.b) * factor,
|
||||
});
|
||||
}
|
||||
|
||||
/** Darken a hex colour by a percentage (`0`-`100`). */
|
||||
export function darken(hex: string, amount: number): string {
|
||||
const rgb = hexToRgb(hex);
|
||||
const factor = 1 - Math.min(100, Math.max(0, amount)) / 100;
|
||||
return rgbToHex({
|
||||
r: rgb.r * factor,
|
||||
g: rgb.g * factor,
|
||||
b: rgb.b * factor,
|
||||
});
|
||||
}
|
||||
|
||||
/** Named colour constants used by the default theme. */
|
||||
export const COLORS = {
|
||||
black: "#000000",
|
||||
white: "#ffffff",
|
||||
red: "#ff0000",
|
||||
green: "#00ff00",
|
||||
blue: "#0000ff",
|
||||
gray: "#808080",
|
||||
lightGray: "#d3d3d3",
|
||||
darkGray: "#404040",
|
||||
transparent: "transparent",
|
||||
} as const;
|
||||
|
||||
/** Check whether a value is the transparent keyword (`"transparent"` or `"none"`). */
|
||||
export function isTransparent(color: string): boolean {
|
||||
return color === "transparent" || color === "none";
|
||||
}
|
||||
11
packages/devtools/docs/pdf/src/colors/index.ts
Normal file
11
packages/devtools/docs/pdf/src/colors/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type { PdfColor, RgbColor } from "./colors.js";
|
||||
export {
|
||||
COLORS,
|
||||
darken,
|
||||
hexToPdfColor,
|
||||
hexToRgb,
|
||||
isTransparent,
|
||||
lighten,
|
||||
rgbToHex,
|
||||
rgbToPdfColor,
|
||||
} from "./colors.js";
|
||||
109
packages/devtools/docs/pdf/src/content-stream/content-stream.md
Normal file
109
packages/devtools/docs/pdf/src/content-stream/content-stream.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# content-stream
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ContentStreamEncoder` is the chainable builder for PDF 1.4 content
|
||||
streams used by every page emitted by `@suaveplan/pdf`. It surfaces the
|
||||
narrow operator subset the package's higher layers (`text-element`,
|
||||
`image-element`, `table-element`, `divider-element`, and the
|
||||
`renderer`) actually need — graphics state, path construction, fill +
|
||||
stroke, colour, text mode, and XObject painting — and refuses to emit
|
||||
out-of-order operators so structural mistakes surface as
|
||||
`PdfError(PDF_CODES.RENDER_FAILED)` rather than silently producing PDFs
|
||||
that render blank or crash readers.
|
||||
|
||||
The encoder formats numbers via the same `formatPdfNumber` helper
|
||||
exposed by the writer (PDF 1.4 §7.3.3 — integers without decimals,
|
||||
fractions to four decimals, `-0` collapsed to `0`) and escapes literal
|
||||
strings per §7.3.4, so the produced operator sequence is always safe to
|
||||
embed inside an `addStream` call. The encoder is strict by default;
|
||||
consumers needing to splice prerecorded streams can opt-in to
|
||||
`{ strict: false }` and use `appendRaw` to inject pre-encoded operators.
|
||||
|
||||
## Features
|
||||
|
||||
- Graphics state: `q`, `Q`, `cm`, `w`.
|
||||
- Path construction + painting: `m`, `l`, `re`, `h`, `S`, `s`, `f`, `B`.
|
||||
- Colour: `rg`, `RG` validated against the `[0, 1]` channel range.
|
||||
- Text: `BT`, `ET`, `Tf`, `Tm`, `Td`, `TL`, `T*`, `Tj` with literal-string
|
||||
escaping and text-mode invariants.
|
||||
- XObjects: `Do` for image painting via the page's `/XObject` resource
|
||||
dictionary.
|
||||
- Strict-mode invariants — `showText`/`Tf`/`Td`/`T*`/`TL`/`Tm` outside
|
||||
text mode, unmatched `Q`, unmatched `BT`, and `encode()` while still
|
||||
inside `BT…ET` all raise `PdfError(PDF_CODES.RENDER_FAILED)`.
|
||||
- `appendRaw` escape hatch for testing or hand-crafted operator
|
||||
sequences when strict mode is disabled.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ContentStreamEncoder } from "@suaveplan/pdf/content-stream";
|
||||
|
||||
const stream = new ContentStreamEncoder()
|
||||
.beginText()
|
||||
.setFont("F1", 12)
|
||||
.moveTextCursor(72, 720)
|
||||
.showText("Hello PDF")
|
||||
.endText()
|
||||
.encode();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { ContentStreamEncoder } from "@suaveplan/pdf/content-stream";
|
||||
import { hexToPdfColor } from "@suaveplan/pdf/colors";
|
||||
|
||||
const stream = new ContentStreamEncoder()
|
||||
.saveGraphicsState()
|
||||
.setFillColor(hexToPdfColor("#1e90ff"))
|
||||
.rectangle(50, 50, 200, 100)
|
||||
.fill()
|
||||
.restoreGraphicsState()
|
||||
.beginText()
|
||||
.setFont("F1", 18)
|
||||
.moveTextCursor(60, 120)
|
||||
.showText("Boxed heading")
|
||||
.endText()
|
||||
.drawImage("Im0")
|
||||
.encode();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new ContentStreamEncoder(options?)` — `options.strict` defaults to
|
||||
`true`.
|
||||
- Graphics state: `saveGraphicsState()`, `restoreGraphicsState()`,
|
||||
`concatMatrix(a, b, c, d, e, f)`, `setLineWidth(w)`.
|
||||
- Path: `moveTo(x, y)`, `lineTo(x, y)`, `rectangle(x, y, w, h)`,
|
||||
`closePath()`, `stroke()`, `closeAndStroke()`, `fill()`,
|
||||
`fillAndStroke()`.
|
||||
- Colour: `setFillColor({ r, g, b })`, `setStrokeColor({ r, g, b })` —
|
||||
each channel must be in `[0, 1]`.
|
||||
- Text: `beginText()`, `endText()`, `setFont(name, size)`,
|
||||
`setTextMatrix(a, b, c, d, e, f)`, `moveTextCursor(tx, ty)`,
|
||||
`setLeading(leading)`, `nextLine()`, `showText(text)`.
|
||||
- XObjects: `drawImage(resourceName)`.
|
||||
- Output: `appendRaw(op)`, `encode(): string`, `length: number`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The encoder buffers operators in a `string[]` so `encode()` is a simple
|
||||
`join("\n")`. Text mode is tracked via an `inText` flag toggled by
|
||||
`beginText`/`endText`. `graphicsDepth` counts `q`/`Q` pairs so
|
||||
`encode()` can reject unmatched saves; this catches the most common
|
||||
mistake in element renderers (forgetting to restore after a clipped
|
||||
draw).
|
||||
|
||||
`setFillColor` and `setStrokeColor` validate channel ranges inline
|
||||
(rather than delegating to a Zod schema) so the typed error chain
|
||||
carries the right `INVALID_RGB` code without an extra import cycle
|
||||
between the colour module and the content-stream module. The
|
||||
`appendRaw` escape hatch deliberately does not validate input; it
|
||||
exists only for tests and for stitching together prerecorded streams
|
||||
where the caller already owns escaping.
|
||||
@@ -0,0 +1,232 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PDF_CODES, PdfError } from "../error-codes/error-codes.js";
|
||||
import { ContentStreamEncoder } from "./content-stream.js";
|
||||
|
||||
describe("ContentStreamEncoder — graphics state", () => {
|
||||
it("save / restore graphics state balance", { timeout: 5000 }, () => {
|
||||
const out = new ContentStreamEncoder()
|
||||
.saveGraphicsState()
|
||||
.restoreGraphicsState()
|
||||
.encode();
|
||||
expect(out).toBe("q\nQ");
|
||||
});
|
||||
|
||||
it("restoreGraphicsState without matching q raises", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
new ContentStreamEncoder().restoreGraphicsState()
|
||||
).toThrowError(PdfError);
|
||||
});
|
||||
|
||||
it("encode rejects unmatched save", { timeout: 5000 }, () => {
|
||||
const enc = new ContentStreamEncoder().saveGraphicsState();
|
||||
expect(() => enc.encode()).toThrowError(PdfError);
|
||||
});
|
||||
|
||||
it("concatMatrix and setLineWidth emit numeric ops", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = new ContentStreamEncoder()
|
||||
.concatMatrix(1, 0, 0, 1, 100, 200)
|
||||
.setLineWidth(2.5)
|
||||
.encode();
|
||||
expect(out).toContain("1 0 0 1 100 200 cm");
|
||||
expect(out).toContain("2.5000 w");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContentStreamEncoder — paths", () => {
|
||||
it("rectangle + fill emits expected ops", { timeout: 5000 }, () => {
|
||||
const out = new ContentStreamEncoder()
|
||||
.setFillColor({ r: 1, g: 0, b: 0 })
|
||||
.rectangle(10, 10, 100, 50)
|
||||
.fill()
|
||||
.encode();
|
||||
expect(out).toContain("1 0 0 rg");
|
||||
expect(out).toContain("10 10 100 50 re");
|
||||
expect(out).toContain("f");
|
||||
});
|
||||
|
||||
it("moveTo / lineTo / closePath / stroke chain", { timeout: 5000 }, () => {
|
||||
const out = new ContentStreamEncoder()
|
||||
.moveTo(0, 0)
|
||||
.lineTo(10, 0)
|
||||
.closePath()
|
||||
.stroke()
|
||||
.encode();
|
||||
expect(out).toContain("0 0 m");
|
||||
expect(out).toContain("10 0 l");
|
||||
expect(out).toContain("h");
|
||||
expect(out).toContain("S");
|
||||
});
|
||||
|
||||
it("closeAndStroke and fillAndStroke emit the right ops", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = new ContentStreamEncoder()
|
||||
.rectangle(0, 0, 5, 5)
|
||||
.closeAndStroke()
|
||||
.rectangle(0, 0, 5, 5)
|
||||
.fillAndStroke()
|
||||
.encode();
|
||||
expect(out).toContain("s");
|
||||
expect(out).toContain("B");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContentStreamEncoder — colour", () => {
|
||||
it("setStrokeColor emits RG", { timeout: 5000 }, () => {
|
||||
const out = new ContentStreamEncoder()
|
||||
.setStrokeColor({ r: 0, g: 0.5, b: 1 })
|
||||
.encode();
|
||||
expect(out).toContain("0 0.5000 1 RG");
|
||||
});
|
||||
|
||||
it("out-of-range channel raises INVALID_RGB", { timeout: 5000 }, () => {
|
||||
try {
|
||||
new ContentStreamEncoder().setFillColor({
|
||||
r: 1.5,
|
||||
g: 0,
|
||||
b: 0,
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(PdfError);
|
||||
expect((err as PdfError).code).toBe(PDF_CODES.INVALID_RGB);
|
||||
}
|
||||
});
|
||||
|
||||
it("NaN channel raises INVALID_RGB on stroke", { timeout: 5000 }, () => {
|
||||
try {
|
||||
new ContentStreamEncoder().setStrokeColor({
|
||||
r: Number.NaN,
|
||||
g: 0,
|
||||
b: 0,
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(PdfError);
|
||||
expect((err as PdfError).code).toBe(PDF_CODES.INVALID_RGB);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContentStreamEncoder — text", () => {
|
||||
it("text-showing operators round-trip", { timeout: 5000 }, () => {
|
||||
const out = new ContentStreamEncoder()
|
||||
.beginText()
|
||||
.setFont("F1", 12)
|
||||
.moveTextCursor(100, 700)
|
||||
.showText("Hello")
|
||||
.endText()
|
||||
.encode();
|
||||
expect(out).toContain("BT");
|
||||
expect(out).toContain("/F1 12 Tf");
|
||||
expect(out).toContain("100 700 Td");
|
||||
expect(out).toContain("(Hello) Tj");
|
||||
expect(out).toContain("ET");
|
||||
});
|
||||
|
||||
it("showText outside text mode raises", { timeout: 5000 }, () => {
|
||||
try {
|
||||
new ContentStreamEncoder().showText("oops");
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(PdfError);
|
||||
expect((err as PdfError).code).toBe(PDF_CODES.RENDER_FAILED);
|
||||
}
|
||||
});
|
||||
|
||||
it("beginText twice without endText raises", { timeout: 5000 }, () => {
|
||||
const enc = new ContentStreamEncoder().beginText();
|
||||
expect(() => enc.beginText()).toThrowError(PdfError);
|
||||
});
|
||||
|
||||
it("endText outside text mode raises", { timeout: 5000 }, () => {
|
||||
expect(() => new ContentStreamEncoder().endText()).toThrowError(
|
||||
PdfError
|
||||
);
|
||||
});
|
||||
|
||||
it("encode with unclosed text object raises", { timeout: 5000 }, () => {
|
||||
const enc = new ContentStreamEncoder().beginText().setFont("F1", 12);
|
||||
expect(() => enc.encode()).toThrowError(PdfError);
|
||||
});
|
||||
|
||||
it("setTextMatrix / setLeading / nextLine emit the right operators", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = new ContentStreamEncoder()
|
||||
.beginText()
|
||||
.setFont("/F2", 10)
|
||||
.setTextMatrix(1, 0, 0, 1, 50, 50)
|
||||
.setLeading(14)
|
||||
.showText("line1")
|
||||
.nextLine()
|
||||
.showText("line2")
|
||||
.endText()
|
||||
.encode();
|
||||
expect(out).toContain("1 0 0 1 50 50 Tm");
|
||||
expect(out).toContain("14 TL");
|
||||
expect(out).toContain("T*");
|
||||
expect(out).toContain("(line1) Tj");
|
||||
expect(out).toContain("(line2) Tj");
|
||||
expect(out).toContain("/F2 10 Tf");
|
||||
});
|
||||
|
||||
it("escapes parens + backslashes in showText", { timeout: 5000 }, () => {
|
||||
const out = new ContentStreamEncoder()
|
||||
.beginText()
|
||||
.setFont("F1", 12)
|
||||
.showText("(hi\\there)")
|
||||
.endText()
|
||||
.encode();
|
||||
expect(out).toContain("(\\(hi\\\\there\\)) Tj");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContentStreamEncoder — XObjects", () => {
|
||||
it("drawImage emits /name Do", { timeout: 5000 }, () => {
|
||||
const out = new ContentStreamEncoder().drawImage("Im0").encode();
|
||||
expect(out).toContain("/Im0 Do");
|
||||
});
|
||||
|
||||
it("drawImage inside text object raises", { timeout: 5000 }, () => {
|
||||
const enc = new ContentStreamEncoder().beginText();
|
||||
try {
|
||||
enc.drawImage("Im0");
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(PdfError);
|
||||
expect((err as PdfError).code).toBe(PDF_CODES.RENDER_FAILED);
|
||||
}
|
||||
});
|
||||
|
||||
it("drawImage accepts an already-prefixed name", { timeout: 5000 }, () => {
|
||||
const out = new ContentStreamEncoder().drawImage("/Im1").encode();
|
||||
expect(out).toContain("/Im1 Do");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContentStreamEncoder — non-strict mode", () => {
|
||||
it("non-strict allows out-of-order ops", { timeout: 5000 }, () => {
|
||||
const enc = new ContentStreamEncoder({ strict: false });
|
||||
// Sequence below would throw in strict mode.
|
||||
enc.restoreGraphicsState();
|
||||
enc.showText("free-form");
|
||||
enc.endText();
|
||||
const out = enc.encode();
|
||||
expect(out).toContain("Q");
|
||||
expect(out).toContain("(free-form) Tj");
|
||||
expect(out).toContain("ET");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContentStreamEncoder — appendRaw + length", () => {
|
||||
it("appendRaw injects a custom operator", { timeout: 5000 }, () => {
|
||||
const enc = new ContentStreamEncoder().appendRaw("0 0 0 rg");
|
||||
expect(enc.length).toBe(1);
|
||||
expect(enc.encode()).toBe("0 0 0 rg");
|
||||
});
|
||||
});
|
||||
302
packages/devtools/docs/pdf/src/content-stream/content-stream.ts
Normal file
302
packages/devtools/docs/pdf/src/content-stream/content-stream.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import type { PdfColor } from "../colors/colors.js";
|
||||
import { PDF_CODES, PdfError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
escapePdfLiteralString,
|
||||
formatPdfNumber,
|
||||
} from "../pdf-writer/pdf-writer.js";
|
||||
import type { ContentStreamOptions } from "./content-stream.types.js";
|
||||
|
||||
/**
|
||||
* Encoder for PDF 1.4 content streams. Each method appends a single
|
||||
* operator (or operand prefix) and returns `this` so calls can chain.
|
||||
* `encode()` returns the joined, newline-separated operator sequence
|
||||
* suitable for `PdfWriter.addStream`.
|
||||
*/
|
||||
export class ContentStreamEncoder {
|
||||
private readonly ops: string[] = [];
|
||||
private inText = false;
|
||||
private graphicsDepth = 0;
|
||||
private readonly strict: boolean;
|
||||
|
||||
constructor(options?: ContentStreamOptions) {
|
||||
this.strict = options?.strict ?? true;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Graphics state operators.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** `q` — save the current graphics state. */
|
||||
saveGraphicsState(): this {
|
||||
this.ops.push("q");
|
||||
this.graphicsDepth++;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** `Q` — restore the previously-saved graphics state. */
|
||||
restoreGraphicsState(): this {
|
||||
if (this.strict && this.graphicsDepth === 0) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.RENDER_FAILED,
|
||||
"restoreGraphicsState without matching saveGraphicsState"
|
||||
);
|
||||
}
|
||||
this.graphicsDepth = Math.max(0, this.graphicsDepth - 1);
|
||||
this.ops.push("Q");
|
||||
return this;
|
||||
}
|
||||
|
||||
/** `a b c d e f cm` — concatenate the supplied affine matrix. */
|
||||
concatMatrix(
|
||||
a: number,
|
||||
b: number,
|
||||
c: number,
|
||||
d: number,
|
||||
e: number,
|
||||
f: number
|
||||
): this {
|
||||
this.ops.push(
|
||||
`${formatPdfNumber(a)} ${formatPdfNumber(b)} ${formatPdfNumber(c)} ${formatPdfNumber(d)} ${formatPdfNumber(e)} ${formatPdfNumber(f)} cm`
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** `w w` — set the stroke line width. */
|
||||
setLineWidth(width: number): this {
|
||||
this.ops.push(`${formatPdfNumber(width)} w`);
|
||||
return this;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Path construction + painting.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** `x y m` — begin a new subpath at `(x, y)`. */
|
||||
moveTo(x: number, y: number): this {
|
||||
this.ops.push(`${formatPdfNumber(x)} ${formatPdfNumber(y)} m`);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** `x y l` — append a straight line to `(x, y)`. */
|
||||
lineTo(x: number, y: number): this {
|
||||
this.ops.push(`${formatPdfNumber(x)} ${formatPdfNumber(y)} l`);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** `x y w h re` — append a rectangle as a complete subpath. */
|
||||
rectangle(x: number, y: number, w: number, h: number): this {
|
||||
this.ops.push(
|
||||
`${formatPdfNumber(x)} ${formatPdfNumber(y)} ${formatPdfNumber(w)} ${formatPdfNumber(h)} re`
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** `h` — close the current subpath. */
|
||||
closePath(): this {
|
||||
this.ops.push("h");
|
||||
return this;
|
||||
}
|
||||
|
||||
/** `S` — stroke the current path. */
|
||||
stroke(): this {
|
||||
this.ops.push("S");
|
||||
return this;
|
||||
}
|
||||
|
||||
/** `s` — close and stroke the current path. */
|
||||
closeAndStroke(): this {
|
||||
this.ops.push("s");
|
||||
return this;
|
||||
}
|
||||
|
||||
/** `f` — fill the current path using the non-zero winding rule. */
|
||||
fill(): this {
|
||||
this.ops.push("f");
|
||||
return this;
|
||||
}
|
||||
|
||||
/** `B` — fill and stroke the current path. */
|
||||
fillAndStroke(): this {
|
||||
this.ops.push("B");
|
||||
return this;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Colour.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** `r g b rg` — set the fill colour from a {@link PdfColor}. */
|
||||
setFillColor(color: PdfColor): this {
|
||||
assertChannel(color.r, "r");
|
||||
assertChannel(color.g, "g");
|
||||
assertChannel(color.b, "b");
|
||||
this.ops.push(
|
||||
`${formatPdfNumber(color.r)} ${formatPdfNumber(color.g)} ${formatPdfNumber(color.b)} rg`
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** `r g b RG` — set the stroke colour from a {@link PdfColor}. */
|
||||
setStrokeColor(color: PdfColor): this {
|
||||
assertChannel(color.r, "r");
|
||||
assertChannel(color.g, "g");
|
||||
assertChannel(color.b, "b");
|
||||
this.ops.push(
|
||||
`${formatPdfNumber(color.r)} ${formatPdfNumber(color.g)} ${formatPdfNumber(color.b)} RG`
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Text.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** `BT` — begin a text object. */
|
||||
beginText(): this {
|
||||
if (this.strict && this.inText) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.RENDER_FAILED,
|
||||
"beginText called while already inside a text object"
|
||||
);
|
||||
}
|
||||
this.inText = true;
|
||||
this.ops.push("BT");
|
||||
return this;
|
||||
}
|
||||
|
||||
/** `ET` — end the current text object. */
|
||||
endText(): this {
|
||||
if (this.strict && !this.inText) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.RENDER_FAILED,
|
||||
"endText called outside a text object"
|
||||
);
|
||||
}
|
||||
this.inText = false;
|
||||
this.ops.push("ET");
|
||||
return this;
|
||||
}
|
||||
|
||||
/** `/font size Tf` — set the active font + size. */
|
||||
setFont(resourceName: string, size: number): this {
|
||||
this.requireTextMode("setFont");
|
||||
const name = resourceName.startsWith("/")
|
||||
? resourceName
|
||||
: `/${resourceName}`;
|
||||
this.ops.push(`${name} ${formatPdfNumber(size)} Tf`);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** `a b c d e f Tm` — set the text matrix. */
|
||||
setTextMatrix(
|
||||
a: number,
|
||||
b: number,
|
||||
c: number,
|
||||
d: number,
|
||||
e: number,
|
||||
f: number
|
||||
): this {
|
||||
this.requireTextMode("setTextMatrix");
|
||||
this.ops.push(
|
||||
`${formatPdfNumber(a)} ${formatPdfNumber(b)} ${formatPdfNumber(c)} ${formatPdfNumber(d)} ${formatPdfNumber(e)} ${formatPdfNumber(f)} Tm`
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** `tx ty Td` — move the text cursor by `(tx, ty)`. */
|
||||
moveTextCursor(tx: number, ty: number): this {
|
||||
this.requireTextMode("moveTextCursor");
|
||||
this.ops.push(`${formatPdfNumber(tx)} ${formatPdfNumber(ty)} Td`);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** `leading TL` — set the inter-line leading. */
|
||||
setLeading(leading: number): this {
|
||||
this.requireTextMode("setLeading");
|
||||
this.ops.push(`${formatPdfNumber(leading)} TL`);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** `T*` — advance to the next line using the current leading. */
|
||||
nextLine(): this {
|
||||
this.requireTextMode("nextLine");
|
||||
this.ops.push("T*");
|
||||
return this;
|
||||
}
|
||||
|
||||
/** `(text) Tj` — show a literal text string. */
|
||||
showText(text: string): this {
|
||||
this.requireTextMode("showText");
|
||||
this.ops.push(`(${escapePdfLiteralString(text)}) Tj`);
|
||||
return this;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// External objects (images).
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** `/name Do` — paint an XObject (typically a page-resource image). */
|
||||
drawImage(resourceName: string): this {
|
||||
if (this.strict && this.inText) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.RENDER_FAILED,
|
||||
"drawImage cannot be called inside a text object"
|
||||
);
|
||||
}
|
||||
const name = resourceName.startsWith("/")
|
||||
? resourceName
|
||||
: `/${resourceName}`;
|
||||
this.ops.push(`${name} Do`);
|
||||
return this;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Direct-injection escape hatch (consumers can append raw ops).
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** Append a raw, pre-encoded operator line. Caller-owned escaping. */
|
||||
appendRaw(op: string): this {
|
||||
this.ops.push(op);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Return the joined newline-separated operator sequence. */
|
||||
encode(): string {
|
||||
if (this.strict && this.inText) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.RENDER_FAILED,
|
||||
"encode called while still inside a text object"
|
||||
);
|
||||
}
|
||||
if (this.strict && this.graphicsDepth !== 0) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.RENDER_FAILED,
|
||||
"encode called with unmatched saveGraphicsState calls"
|
||||
);
|
||||
}
|
||||
return this.ops.join("\n");
|
||||
}
|
||||
|
||||
/** Number of operators currently buffered. Useful for tests. */
|
||||
get length(): number {
|
||||
return this.ops.length;
|
||||
}
|
||||
|
||||
private requireTextMode(op: string): void {
|
||||
if (this.strict && !this.inText) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.RENDER_FAILED,
|
||||
`${op} called outside a text object`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertChannel(value: number, channel: string): void {
|
||||
if (!Number.isFinite(value) || value < 0 || value > 1) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.INVALID_RGB,
|
||||
`colour channel ${channel} out of range [0,1]: ${String(value)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { PdfColor } from "../colors/colors.js";
|
||||
|
||||
/**
|
||||
* Public configuration accepted by {@link ContentStreamEncoder}.
|
||||
*
|
||||
* Reserved for future extensions (compression hints, operator-set
|
||||
* filters). Today the encoder accepts a strict-mode toggle that surfaces
|
||||
* structural mistakes (e.g. `showText` outside `beginText`/`endText`) as
|
||||
* typed errors rather than silently emitting invalid PDF.
|
||||
*/
|
||||
export interface ContentStreamOptions {
|
||||
/** Default `true` — raise `PDF.RENDER_FAILED` on out-of-order ops. */
|
||||
readonly strict?: boolean;
|
||||
}
|
||||
|
||||
/** Convenience type alias re-used from the colour module. */
|
||||
export type ContentStreamColor = PdfColor;
|
||||
5
packages/devtools/docs/pdf/src/content-stream/index.ts
Normal file
5
packages/devtools/docs/pdf/src/content-stream/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { ContentStreamEncoder } from "./content-stream.js";
|
||||
export type {
|
||||
ContentStreamColor,
|
||||
ContentStreamOptions,
|
||||
} from "./content-stream.types.js";
|
||||
@@ -0,0 +1,88 @@
|
||||
# divider-element
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`DividerElement` renders a horizontal or vertical rule into a PDF content
|
||||
stream. It is the lightweight separator primitive used by templates
|
||||
(invoice, receipt, report) and the layout engine to break content into
|
||||
visual sections without resorting to background colours or raw graphics
|
||||
operators. Stroke colour, thickness, dash pattern, and surrounding
|
||||
spacing are all configurable; defaults give an unobtrusive 1pt
|
||||
`#cccccc` line.
|
||||
|
||||
The element validates its options through Zod, so passing a negative
|
||||
thickness, an unknown orientation, or a non-string colour raises a typed
|
||||
`PDF.INVALID_ELEMENT` error rather than silently rendering garbage.
|
||||
|
||||
## Features
|
||||
|
||||
- Horizontal and vertical orientation.
|
||||
- Configurable thickness, stroke colour, dash pattern.
|
||||
- Optional `length` override (defaults to the available width / height).
|
||||
- `spacingAbove` / `spacingBelow` for vertical flow accounting in the
|
||||
layout engine.
|
||||
- `measure(maxWidth, maxHeight?)` returns `{ width, height }` for layout.
|
||||
- `render(context)` emits a `q…Q`-clean content-stream snippet.
|
||||
- `AbortSignal` propagation — aborted contexts raise `PDF.ABORTED`.
|
||||
- Skips the stroke-colour operator entirely for `transparent` / `none`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { DividerElement, PdfWriter } from "@suaveplan/pdf";
|
||||
|
||||
const writer = new PdfWriter();
|
||||
const divider = new DividerElement();
|
||||
const out = divider.render({ x: 72, y: 720, maxWidth: 460 });
|
||||
const streamId = writer.addStream(out.stream);
|
||||
writer.addPage({ width: 612, height: 792, contentStreamId: streamId });
|
||||
const bytes = writer.generate();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { DividerElement, computeDividerSpacing } from "@suaveplan/pdf";
|
||||
|
||||
const divider = new DividerElement({
|
||||
orientation: "horizontal",
|
||||
thickness: 2,
|
||||
color: "#0f3460",
|
||||
dashPattern: [4, 2],
|
||||
spacingAbove: 12,
|
||||
spacingBelow: 12,
|
||||
length: 200,
|
||||
});
|
||||
const flow = computeDividerSpacing(2, 12, 12);
|
||||
// Lays out divider centred in a 460pt column with 12pt above + below.
|
||||
const x = 72 + (460 - 200) / 2;
|
||||
const result = divider.render({ x, y: cursorY - flow.offsetY, maxWidth: 460 });
|
||||
cursorY -= flow.totalHeight;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class DividerElement(options)` — validates `DividerElementOptions`
|
||||
and exposes `measure(maxWidth, maxHeight?)` + `render(context)`.
|
||||
- `DividerElementOptionsSchema` — Zod schema used at construction.
|
||||
- `DIVIDER_DEFAULTS` — fallback values (`thickness: 1`,
|
||||
`color: "#cccccc"`, `orientation: "horizontal"`).
|
||||
- `computeDividerSpacing(thickness, spacingAbove?, spacingBelow?)` —
|
||||
flow-accounting helper used by the layout engine and templates.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Rendering uses `ContentStreamEncoder.moveTo` + `lineTo` + `stroke` rather
|
||||
than the rectangle operator so dash patterns render correctly across the
|
||||
full path. Dash patterns go through `appendRaw` because the
|
||||
`ContentStreamEncoder` chunk-B surface intentionally does not expose
|
||||
every PDF operator — adding `setDashPattern` is a Chunk-D concern. The
|
||||
horizontal divider offsets `y` by `spacingAbove` so calls that pass the
|
||||
top of the flow region get a visually balanced rule; the vertical
|
||||
divider grows downward from `y`. `AbortSignal` is checked at the top of
|
||||
`render()` so the element participates in cooperative cancellation
|
||||
alongside `PdfWriter.generate`.
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PdfError } from "../error-codes/error-codes.js";
|
||||
import { computeDividerSpacing, DividerElement } from "./divider-element.js";
|
||||
|
||||
describe("DividerElement", () => {
|
||||
it("validates options at construction", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
() =>
|
||||
new DividerElement({
|
||||
thickness: -1 as unknown as number,
|
||||
})
|
||||
).toThrow(PdfError);
|
||||
});
|
||||
|
||||
it("measures horizontal default to available width", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const d = new DividerElement();
|
||||
const m = d.measure(400);
|
||||
expect(m.width).toBe(400);
|
||||
expect(m.height).toBe(1);
|
||||
});
|
||||
|
||||
it("measures horizontal with explicit length + spacing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const d = new DividerElement({
|
||||
length: 100,
|
||||
spacingAbove: 4,
|
||||
spacingBelow: 6,
|
||||
thickness: 2,
|
||||
});
|
||||
const m = d.measure(500);
|
||||
expect(m.width).toBe(100);
|
||||
expect(m.height).toBe(12);
|
||||
});
|
||||
|
||||
it("measures vertical with explicit length", { timeout: 5000 }, () => {
|
||||
const d = new DividerElement({ orientation: "vertical", length: 50 });
|
||||
expect(d.measure(500).height).toBe(50);
|
||||
});
|
||||
|
||||
it("measures vertical falling back to maxHeight then 100", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const d = new DividerElement({ orientation: "vertical" });
|
||||
expect(d.measure(500, 75).height).toBe(75);
|
||||
expect(d.measure(500).height).toBe(100);
|
||||
});
|
||||
|
||||
it("renders a horizontal stroke", { timeout: 5000 }, () => {
|
||||
const d = new DividerElement({ color: "#ff0000", thickness: 2 });
|
||||
const out = d.render({ x: 10, y: 100, maxWidth: 200 });
|
||||
expect(out.stream).toContain("1 0 0 RG");
|
||||
expect(out.stream).toContain("2 w");
|
||||
expect(out.stream).toContain("10 100 m");
|
||||
expect(out.stream).toContain("210 100 l");
|
||||
expect(out.stream).toContain("S");
|
||||
});
|
||||
|
||||
it("renders a vertical stroke", { timeout: 5000 }, () => {
|
||||
const d = new DividerElement({
|
||||
orientation: "vertical",
|
||||
length: 80,
|
||||
});
|
||||
const out = d.render({ x: 50, y: 600, maxWidth: 100 });
|
||||
expect(out.stream).toContain("50 600 m");
|
||||
expect(out.stream).toContain("50 520 l");
|
||||
});
|
||||
|
||||
it("applies dash pattern", { timeout: 5000 }, () => {
|
||||
const d = new DividerElement({ dashPattern: [3, 2] });
|
||||
const out = d.render({ x: 0, y: 100, maxWidth: 50 });
|
||||
expect(out.stream).toContain("[3 2] 0 d");
|
||||
});
|
||||
|
||||
it("skips stroke colour when transparent", { timeout: 5000 }, () => {
|
||||
const d = new DividerElement({ color: "transparent" });
|
||||
const out = d.render({ x: 0, y: 100, maxWidth: 50 });
|
||||
expect(out.stream).not.toMatch(/RG/);
|
||||
});
|
||||
|
||||
it("respects spacingAbove for horizontal placement", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const d = new DividerElement({ spacingAbove: 10 });
|
||||
const out = d.render({ x: 0, y: 100, maxWidth: 50 });
|
||||
expect(out.stream).toContain("0 90 m");
|
||||
});
|
||||
|
||||
it("falls back to maxHeight for vertical render", { timeout: 5000 }, () => {
|
||||
const d = new DividerElement({ orientation: "vertical" });
|
||||
const out = d.render({ x: 0, y: 500, maxWidth: 50, maxHeight: 40 });
|
||||
expect(out.stream).toContain("0 460 l");
|
||||
});
|
||||
|
||||
it("vertical render with neither length nor maxHeight defaults to 100", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const d = new DividerElement({ orientation: "vertical" });
|
||||
const out = d.render({ x: 0, y: 500, maxWidth: 50 });
|
||||
expect(out.stream).toContain("0 400 l");
|
||||
});
|
||||
|
||||
it("aborts on aborted signal", { timeout: 5000 }, () => {
|
||||
const d = new DividerElement();
|
||||
const c = new AbortController();
|
||||
c.abort();
|
||||
expect(() =>
|
||||
d.render({ x: 0, y: 100, maxWidth: 50, signal: c.signal })
|
||||
).toThrow(PdfError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeDividerSpacing", () => {
|
||||
it("totals spacings around thickness", { timeout: 5000 }, () => {
|
||||
expect(computeDividerSpacing(2, 4, 6)).toEqual({
|
||||
totalHeight: 12,
|
||||
offsetY: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults spacing to 8/8", { timeout: 5000 }, () => {
|
||||
expect(computeDividerSpacing(1)).toEqual({
|
||||
totalHeight: 17,
|
||||
offsetY: 8,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import z from "zod";
|
||||
import { hexToPdfColor, isTransparent } from "../colors/colors.js";
|
||||
import { ContentStreamEncoder } from "../content-stream/content-stream.js";
|
||||
import { PDF_CODES, PdfError } from "../error-codes/error-codes.js";
|
||||
import { formatPdfNumber } from "../pdf-writer/pdf-writer.js";
|
||||
import { parseOrThrow } from "../schemas/schemas.js";
|
||||
import type {
|
||||
ElementMeasurement,
|
||||
ElementRenderContext,
|
||||
ElementRenderResult,
|
||||
} from "../text-element/text-element.types.js";
|
||||
import type { DividerElementOptions } from "./divider-element.types.js";
|
||||
|
||||
/** Zod schema for {@link DividerElementOptions}. */
|
||||
export const DividerElementOptionsSchema = z.object({
|
||||
thickness: z.number().positive().optional(),
|
||||
color: z.string().optional(),
|
||||
orientation: z.enum(["horizontal", "vertical"]).optional(),
|
||||
dashPattern: z.array(z.number().nonnegative()).optional(),
|
||||
length: z.number().positive().optional(),
|
||||
spacingAbove: z.number().nonnegative().optional(),
|
||||
spacingBelow: z.number().nonnegative().optional(),
|
||||
});
|
||||
|
||||
/** Resolved defaults applied when an option is omitted. */
|
||||
export const DIVIDER_DEFAULTS = {
|
||||
thickness: 1,
|
||||
color: "#cccccc",
|
||||
orientation: "horizontal" as const,
|
||||
spacingAbove: 0,
|
||||
spacingBelow: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Horizontal / vertical rule used to separate document sections. Renders
|
||||
* a single stroked line with optional dash pattern, configurable
|
||||
* thickness + colour, and spacing above / below for layout balance.
|
||||
*/
|
||||
export class DividerElement {
|
||||
readonly options: Readonly<DividerElementOptions>;
|
||||
|
||||
constructor(options: DividerElementOptions = {}) {
|
||||
this.options = parseOrThrow(
|
||||
DividerElementOptionsSchema,
|
||||
options,
|
||||
PDF_CODES.INVALID_ELEMENT,
|
||||
"invalid DividerElement options"
|
||||
);
|
||||
}
|
||||
|
||||
/** Vertical space consumed by the divider when used in a flow layout. */
|
||||
measure(maxWidth: number, maxHeight?: number): ElementMeasurement {
|
||||
const thickness = this.options.thickness ?? DIVIDER_DEFAULTS.thickness;
|
||||
const above =
|
||||
this.options.spacingAbove ?? DIVIDER_DEFAULTS.spacingAbove;
|
||||
const below =
|
||||
this.options.spacingBelow ?? DIVIDER_DEFAULTS.spacingBelow;
|
||||
const orientation =
|
||||
this.options.orientation ?? DIVIDER_DEFAULTS.orientation;
|
||||
if (orientation === "horizontal") {
|
||||
const length = this.options.length ?? maxWidth;
|
||||
return { width: length, height: thickness + above + below };
|
||||
}
|
||||
const length = this.options.length ?? maxHeight ?? 100;
|
||||
return { width: thickness, height: length };
|
||||
}
|
||||
|
||||
/** Render the divider into a content-stream snippet. */
|
||||
render(context: ElementRenderContext): ElementRenderResult {
|
||||
if (context.signal?.aborted) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.ABORTED,
|
||||
"DividerElement.render aborted"
|
||||
);
|
||||
}
|
||||
const thickness = this.options.thickness ?? DIVIDER_DEFAULTS.thickness;
|
||||
const color = this.options.color ?? DIVIDER_DEFAULTS.color;
|
||||
const orientation =
|
||||
this.options.orientation ?? DIVIDER_DEFAULTS.orientation;
|
||||
const dash = this.options.dashPattern ?? [];
|
||||
const above =
|
||||
this.options.spacingAbove ?? DIVIDER_DEFAULTS.spacingAbove;
|
||||
const below =
|
||||
this.options.spacingBelow ?? DIVIDER_DEFAULTS.spacingBelow;
|
||||
|
||||
const encoder = new ContentStreamEncoder();
|
||||
encoder.saveGraphicsState();
|
||||
if (!isTransparent(color)) {
|
||||
encoder.setStrokeColor(hexToPdfColor(color));
|
||||
}
|
||||
encoder.setLineWidth(thickness);
|
||||
if (dash.length > 0) {
|
||||
encoder.appendRaw(
|
||||
`[${dash.map((v) => formatPdfNumber(v)).join(" ")}] 0 d`
|
||||
);
|
||||
}
|
||||
|
||||
if (orientation === "horizontal") {
|
||||
const length = this.options.length ?? context.maxWidth;
|
||||
const lineY = context.y - above;
|
||||
encoder.moveTo(context.x, lineY);
|
||||
encoder.lineTo(context.x + length, lineY);
|
||||
encoder.stroke();
|
||||
encoder.restoreGraphicsState();
|
||||
return {
|
||||
stream: encoder.encode(),
|
||||
width: length,
|
||||
height: thickness + above + below,
|
||||
};
|
||||
}
|
||||
|
||||
const length = this.options.length ?? context.maxHeight ?? 100;
|
||||
encoder.moveTo(context.x, context.y);
|
||||
encoder.lineTo(context.x, context.y - length);
|
||||
encoder.stroke();
|
||||
encoder.restoreGraphicsState();
|
||||
return {
|
||||
stream: encoder.encode(),
|
||||
width: thickness,
|
||||
height: length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience: compute spacing meta for a horizontal divider. */
|
||||
export function computeDividerSpacing(
|
||||
thickness: number,
|
||||
spacingAbove = 8,
|
||||
spacingBelow = 8
|
||||
): { readonly totalHeight: number; readonly offsetY: number } {
|
||||
return {
|
||||
totalHeight: thickness + spacingAbove + spacingBelow,
|
||||
offsetY: spacingAbove,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/** Orientation of a divider line. */
|
||||
export type DividerOrientation = "horizontal" | "vertical";
|
||||
|
||||
/** Options accepted by {@link DividerElement}. */
|
||||
export interface DividerElementOptions {
|
||||
readonly thickness?: number | undefined;
|
||||
readonly color?: string | undefined;
|
||||
readonly orientation?: DividerOrientation | undefined;
|
||||
readonly dashPattern?: ReadonlyArray<number> | undefined;
|
||||
readonly length?: number | undefined;
|
||||
readonly spacingAbove?: number | undefined;
|
||||
readonly spacingBelow?: number | undefined;
|
||||
}
|
||||
10
packages/devtools/docs/pdf/src/divider-element/index.ts
Normal file
10
packages/devtools/docs/pdf/src/divider-element/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
computeDividerSpacing,
|
||||
DIVIDER_DEFAULTS,
|
||||
DividerElement,
|
||||
DividerElementOptionsSchema,
|
||||
} from "./divider-element.js";
|
||||
export type {
|
||||
DividerElementOptions,
|
||||
DividerOrientation,
|
||||
} from "./divider-element.types.js";
|
||||
@@ -0,0 +1,120 @@
|
||||
# document-builder
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `DocumentBuilder` provides a chainable, deterministic API for
|
||||
assembling a PDF document tree without touching the lower-level
|
||||
writer / layout machinery. It eagerly constructs the underlying
|
||||
element instances (running each element's Zod boundary validation at
|
||||
the call site) and stores them in a `Document` AST that the renderer
|
||||
walks node-by-node. Headers, footers, metadata, per-page configuration
|
||||
overrides, and arbitrary typed user data are all first-class.
|
||||
|
||||
## Features
|
||||
|
||||
- Fluent metadata setters: `title`, `author`, `subject`, `keywords`,
|
||||
`creator`, `creationDate`.
|
||||
- Element shortcuts: `addText`, `addTable`, `addImage`, `addDivider`,
|
||||
each accepting an optional per-node `Partial<PdfStyle>` override.
|
||||
- `newPage(config?)` flushes the current logical page and optionally
|
||||
declares a per-page `PageConfig` override (e.g. swap orientation
|
||||
for a wide table page).
|
||||
- `setHeader(nodes, height)` / `setFooter(nodes, height)` register
|
||||
repeating page furniture; the renderer wires the height into the
|
||||
layout engine's slot reservation.
|
||||
- `withData(data)` carries a typed payload through to the resulting
|
||||
`Document<TData>` for downstream consumers (e.g. invoice line items
|
||||
driving telemetry tags).
|
||||
- `build()` stamps a default `creationDate` and `creator` and refuses
|
||||
to emit an empty document via `PDF.INVALID_DOCUMENT`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createDocument, PageSize } from "@suaveplan/pdf";
|
||||
|
||||
const doc = createDocument()
|
||||
.title("Quarterly Report")
|
||||
.addText("Executive Summary", { fontSize: 18, fontWeight: "bold" })
|
||||
.addText("Revenue increased 14% year-over-year.")
|
||||
.build();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createDocument, DEFAULT_PAGE_CONFIG, PageSize } from "@suaveplan/pdf";
|
||||
|
||||
interface InvoiceData {
|
||||
invoiceNumber: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
const doc = createDocument<InvoiceData>()
|
||||
.title("Invoice INV-2026-00128")
|
||||
.author("Suaveplan Billing")
|
||||
.keywords(["invoice", "billing"])
|
||||
.pageConfig({ ...DEFAULT_PAGE_CONFIG, size: PageSize.LETTER })
|
||||
.withData({ invoiceNumber: "INV-2026-00128", total: 12480.55 })
|
||||
.setHeader(
|
||||
[
|
||||
{
|
||||
type: "text",
|
||||
element: new TextElement({
|
||||
content: "Suaveplan Billing",
|
||||
fontWeight: "bold",
|
||||
}),
|
||||
},
|
||||
],
|
||||
48
|
||||
)
|
||||
.addText("Bill to: Acme Corp")
|
||||
.addDivider({ thickness: 1, spacingAbove: 8, spacingBelow: 8 })
|
||||
.addTable(
|
||||
[
|
||||
{ key: "sku", header: "SKU" },
|
||||
{ key: "qty", header: "Qty", align: "right" },
|
||||
{ key: "price", header: "Price", align: "right" },
|
||||
],
|
||||
[
|
||||
{ sku: "A-1", qty: 2, price: "$120.00" },
|
||||
{ sku: "B-2", qty: 1, price: "$240.55" },
|
||||
],
|
||||
{ border: true, repeatHeader: true }
|
||||
)
|
||||
.newPage()
|
||||
.addText("Page 2: payment terms…")
|
||||
.build();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class DocumentBuilder<TData>` —
|
||||
`title` / `author` / `subject` / `keywords` / `creator` /
|
||||
`creationDate` / `pageConfig` / `withData` /
|
||||
`addText(content, options?, style?)` /
|
||||
`addTable(columns, rows, options?, style?)` /
|
||||
`addImage(options, style?)` / `addDivider(options?, style?)` /
|
||||
`newPage(config?)` / `setHeader(nodes, height)` /
|
||||
`setFooter(nodes, height)` / `build()`.
|
||||
- `createDocument<TData>()` → `DocumentBuilder<TData>`.
|
||||
- Types: `Document<TData>`, `DocumentNode`, `TextNode`, `TableNode`,
|
||||
`ImageNode`, `DividerNode`, `BuiltPageDefinition`,
|
||||
`DocumentNodeType`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The builder stores nodes in a mutable per-page buffer; `newPage` /
|
||||
`build` flush this buffer into the page list via the private
|
||||
`flushPage` helper which also drops the per-page config override. The
|
||||
element constructors invoked by `addXxx` perform the Zod boundary
|
||||
validation eagerly so malformed input fails at the call site rather
|
||||
than inside the renderer. `setHeader` / `setFooter` clone their input
|
||||
arrays to guarantee post-call mutation by the caller does not bleed
|
||||
into the built document. `build()` defensively copies pages, header,
|
||||
and footer arrays so the returned `Document` is structurally
|
||||
isolated from any subsequent mutation of the builder.
|
||||
@@ -0,0 +1,207 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PNG } from "pngjs";
|
||||
import { PdfError } from "../error-codes/error-codes.js";
|
||||
import { TextElement } from "../text-element/text-element.js";
|
||||
import { PageSize } from "../types/page-size.js";
|
||||
import { createDocument, DocumentBuilder } from "./document-builder.js";
|
||||
|
||||
const makePng = (width = 2, height = 2): Uint8Array => {
|
||||
const png = new PNG({ width, height });
|
||||
for (let i = 0; i < png.data.length; i += 4) {
|
||||
png.data[i] = 255;
|
||||
png.data[i + 1] = 0;
|
||||
png.data[i + 2] = 0;
|
||||
png.data[i + 3] = 255;
|
||||
}
|
||||
return new Uint8Array(PNG.sync.write(png));
|
||||
};
|
||||
const MINIMAL_PNG = makePng();
|
||||
|
||||
describe("DocumentBuilder — fluent API", () => {
|
||||
it("createDocument returns a builder", { timeout: 5000 }, () => {
|
||||
expect(createDocument()).toBeInstanceOf(DocumentBuilder);
|
||||
});
|
||||
|
||||
it("stamps default creator and creationDate on build", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = createDocument().addText("hello").build();
|
||||
expect(doc.metadata.creator).toBe("@suaveplan/pdf");
|
||||
expect(doc.metadata.creationDate).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("respects explicit metadata", { timeout: 5000 }, () => {
|
||||
const date = new Date("2026-01-01T00:00:00Z");
|
||||
const doc = createDocument()
|
||||
.title("T")
|
||||
.author("A")
|
||||
.subject("S")
|
||||
.keywords(["k1", "k2"])
|
||||
.creator("Custom")
|
||||
.creationDate(date)
|
||||
.addText("x")
|
||||
.build();
|
||||
expect(doc.metadata.title).toBe("T");
|
||||
expect(doc.metadata.author).toBe("A");
|
||||
expect(doc.metadata.subject).toBe("S");
|
||||
expect(doc.metadata.keywords).toEqual(["k1", "k2"]);
|
||||
expect(doc.metadata.creator).toBe("Custom");
|
||||
expect(doc.metadata.creationDate).toBe(date);
|
||||
});
|
||||
|
||||
it("pageConfig merges margins", { timeout: 5000 }, () => {
|
||||
const doc = createDocument()
|
||||
.pageConfig({
|
||||
size: PageSize.LETTER,
|
||||
margins: { top: 10, right: 10, bottom: 10, left: 10 },
|
||||
})
|
||||
.addText("x")
|
||||
.build();
|
||||
expect(doc.defaultPage.size).toBe(PageSize.LETTER);
|
||||
expect(doc.defaultPage.margins.top).toBe(10);
|
||||
});
|
||||
|
||||
it("addText / addDivider / addTable / addImage produce nodes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = createDocument()
|
||||
.addText("hello", { fontSize: 12 }, { color: "#ff0000" })
|
||||
.addDivider({ thickness: 1 }, { color: "#000000" })
|
||||
.addTable(
|
||||
[{ key: "a", header: "A" }],
|
||||
[{ a: 1 }],
|
||||
{ border: true },
|
||||
{ fontSize: 10 }
|
||||
)
|
||||
.addImage(
|
||||
{ src: MINIMAL_PNG, width: 50, height: 50 },
|
||||
{ opacity: 0.9 }
|
||||
)
|
||||
.build();
|
||||
const page = doc.pages[0];
|
||||
expect(page).toBeDefined();
|
||||
expect(page?.nodes.length).toBe(4);
|
||||
expect(page?.nodes[0]?.type).toBe("text");
|
||||
expect(page?.nodes[1]?.type).toBe("divider");
|
||||
expect(page?.nodes[2]?.type).toBe("table");
|
||||
expect(page?.nodes[3]?.type).toBe("image");
|
||||
});
|
||||
|
||||
it("addDivider with no options still produces a node", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = createDocument().addText("x").addDivider().build();
|
||||
expect(doc.pages[0]?.nodes[1]?.type).toBe("divider");
|
||||
});
|
||||
|
||||
it("newPage flushes accumulated nodes", { timeout: 5000 }, () => {
|
||||
const doc = createDocument()
|
||||
.addText("p1")
|
||||
.newPage()
|
||||
.addText("p2")
|
||||
.build();
|
||||
expect(doc.pages.length).toBe(2);
|
||||
});
|
||||
|
||||
it("newPage with config persists a per-page config", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = createDocument()
|
||||
.addText("p1")
|
||||
.newPage({ orientation: "landscape" })
|
||||
.addText("p2")
|
||||
.build();
|
||||
expect(doc.pages[1]?.config?.orientation).toBe("landscape");
|
||||
});
|
||||
|
||||
it("withData carries typed data", { timeout: 5000 }, () => {
|
||||
interface Data {
|
||||
id: string;
|
||||
}
|
||||
const doc = createDocument<Data>()
|
||||
.withData({ id: "abc" })
|
||||
.addText("x")
|
||||
.build();
|
||||
expect(doc.data?.id).toBe("abc");
|
||||
});
|
||||
|
||||
it("setHeader registers header nodes with height", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const headerNode = {
|
||||
type: "text" as const,
|
||||
element: new TextElement({ content: "header" }),
|
||||
};
|
||||
const doc = createDocument()
|
||||
.setHeader([headerNode], 30)
|
||||
.addText("body")
|
||||
.build();
|
||||
expect(doc.header?.length).toBe(1);
|
||||
expect(doc.headerHeight).toBe(30);
|
||||
});
|
||||
|
||||
it("setFooter registers footer nodes with height", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const footerNode = {
|
||||
type: "text" as const,
|
||||
element: new TextElement({ content: "footer" }),
|
||||
};
|
||||
const doc = createDocument()
|
||||
.setFooter([footerNode], 20)
|
||||
.addText("body")
|
||||
.build();
|
||||
expect(doc.footer?.length).toBe(1);
|
||||
expect(doc.footerHeight).toBe(20);
|
||||
});
|
||||
|
||||
it("setHeader rejects negative height", { timeout: 5000 }, () => {
|
||||
expect(() => createDocument().setHeader([], -1)).toThrow(PdfError);
|
||||
});
|
||||
|
||||
it("setHeader rejects non-finite height", { timeout: 5000 }, () => {
|
||||
expect(() => createDocument().setHeader([], Number.NaN)).toThrow(
|
||||
PdfError
|
||||
);
|
||||
});
|
||||
|
||||
it("setFooter rejects negative height", { timeout: 5000 }, () => {
|
||||
expect(() => createDocument().setFooter([], -5)).toThrow(PdfError);
|
||||
});
|
||||
|
||||
it("setFooter rejects non-finite height", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
createDocument().setFooter([], Number.POSITIVE_INFINITY)
|
||||
).toThrow(PdfError);
|
||||
});
|
||||
|
||||
it("build with no pages throws INVALID_DOCUMENT", { timeout: 5000 }, () => {
|
||||
expect(() => createDocument().build()).toThrow(PdfError);
|
||||
});
|
||||
|
||||
it("build defensively copies pages array", { timeout: 5000 }, () => {
|
||||
const builder = createDocument().addText("x");
|
||||
const doc = builder.build();
|
||||
builder.addText("y").build();
|
||||
expect(doc.pages[0]?.nodes.length).toBe(1);
|
||||
});
|
||||
|
||||
it("newPage with no pending content + no config is a no-op", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = createDocument().addText("x").newPage().build();
|
||||
expect(doc.pages.length).toBe(1);
|
||||
});
|
||||
|
||||
it("newPage with config but no content still creates a page", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = createDocument()
|
||||
.addText("a")
|
||||
.newPage({ orientation: "landscape" })
|
||||
.build();
|
||||
expect(doc.pages.length).toBe(2);
|
||||
expect(doc.pages[1]?.config?.orientation).toBe("landscape");
|
||||
expect(doc.pages[1]?.nodes.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import { DividerElement } from "../divider-element/divider-element.js";
|
||||
import type { DividerElementOptions } from "../divider-element/divider-element.types.js";
|
||||
import { PDF_CODES, PdfError } from "../error-codes/error-codes.js";
|
||||
import { ImageElement } from "../image-element/image-element.js";
|
||||
import type { ImageElementOptions } from "../image-element/image-element.types.js";
|
||||
import type { PdfStyle } from "../style-resolver/style-resolver.types.js";
|
||||
import { TableElement } from "../table-element/table-element.js";
|
||||
import type {
|
||||
TableColumn,
|
||||
TableElementOptions,
|
||||
} from "../table-element/table-element.types.js";
|
||||
import { TextElement } from "../text-element/text-element.js";
|
||||
import type { TextElementOptions } from "../text-element/text-element.types.js";
|
||||
import {
|
||||
DEFAULT_PAGE_CONFIG,
|
||||
type DocumentMetadata,
|
||||
type PageConfig,
|
||||
} from "../types/page-size.js";
|
||||
import type {
|
||||
BuiltPageDefinition,
|
||||
Document,
|
||||
DocumentNode,
|
||||
} from "./document-builder.types.js";
|
||||
|
||||
const DEFAULT_CREATOR = "@suaveplan/pdf";
|
||||
|
||||
/**
|
||||
* Fluent builder for assembling a {@link Document}. Every `addXxx` call
|
||||
* constructs the underlying element (running its Zod boundary
|
||||
* validation eagerly) and appends a node to the current page. `newPage`
|
||||
* flushes the accumulated nodes; `build` finalises the document and
|
||||
* stamps a deterministic `creationDate` if none was supplied.
|
||||
*
|
||||
* @typeParam TData - Optional user-supplied data carried alongside the
|
||||
* document (e.g. invoice line items powering a renderer post-process).
|
||||
*/
|
||||
export class DocumentBuilder<TData = unknown> {
|
||||
private metadata: DocumentMetadata = {};
|
||||
private defaultPage: PageConfig = DEFAULT_PAGE_CONFIG;
|
||||
private readonly pages: BuiltPageDefinition[] = [];
|
||||
private currentNodes: DocumentNode[] = [];
|
||||
private currentConfig?: Partial<PageConfig>;
|
||||
private headerNodes?: DocumentNode[];
|
||||
private footerNodes?: DocumentNode[];
|
||||
private headerHeight?: number;
|
||||
private footerHeight?: number;
|
||||
private data?: TData;
|
||||
|
||||
/** Set the document title. */
|
||||
title(title: string): this {
|
||||
this.metadata = { ...this.metadata, title };
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Set the document author. */
|
||||
author(author: string): this {
|
||||
this.metadata = { ...this.metadata, author };
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Set the document subject. */
|
||||
subject(subject: string): this {
|
||||
this.metadata = { ...this.metadata, subject };
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Set the document keywords. */
|
||||
keywords(keywords: ReadonlyArray<string>): this {
|
||||
this.metadata = { ...this.metadata, keywords: [...keywords] };
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Set the document creator (defaults to `@suaveplan/pdf`). */
|
||||
creator(creator: string): this {
|
||||
this.metadata = { ...this.metadata, creator };
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Set the document creation date. */
|
||||
creationDate(date: Date): this {
|
||||
this.metadata = { ...this.metadata, creationDate: date };
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Override the default per-page configuration. */
|
||||
pageConfig(config: Partial<PageConfig>): this {
|
||||
this.defaultPage = {
|
||||
...this.defaultPage,
|
||||
...config,
|
||||
margins: { ...this.defaultPage.margins, ...config.margins },
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Attach typed data to the document for downstream consumers. */
|
||||
withData(data: TData): this {
|
||||
this.data = data;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Append a text node to the current page. */
|
||||
addText(
|
||||
content: string,
|
||||
options?: Partial<Omit<TextElementOptions, "content">>,
|
||||
style?: Partial<PdfStyle>
|
||||
): this {
|
||||
const element = new TextElement({ content, ...options });
|
||||
const node: DocumentNode = style
|
||||
? { type: "text", element, style }
|
||||
: { type: "text", element };
|
||||
this.currentNodes.push(node);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Append a table node to the current page. */
|
||||
addTable<TRow>(
|
||||
columns: ReadonlyArray<TableColumn<TRow>>,
|
||||
rows: ReadonlyArray<TRow>,
|
||||
options?: Partial<Omit<TableElementOptions<TRow>, "columns" | "rows">>,
|
||||
style?: Partial<PdfStyle>
|
||||
): this {
|
||||
const element = new TableElement<TRow>({ columns, rows, ...options });
|
||||
const node: DocumentNode = style
|
||||
? {
|
||||
type: "table",
|
||||
element: element as TableElement<unknown>,
|
||||
style,
|
||||
}
|
||||
: { type: "table", element: element as TableElement<unknown> };
|
||||
this.currentNodes.push(node);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Append an image node to the current page. */
|
||||
addImage(options: ImageElementOptions, style?: Partial<PdfStyle>): this {
|
||||
const element = new ImageElement(options);
|
||||
const node: DocumentNode = style
|
||||
? { type: "image", element, style }
|
||||
: { type: "image", element };
|
||||
this.currentNodes.push(node);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Append a divider node to the current page. */
|
||||
addDivider(
|
||||
options?: DividerElementOptions,
|
||||
style?: Partial<PdfStyle>
|
||||
): this {
|
||||
const element = new DividerElement(options ?? {});
|
||||
const node: DocumentNode = style
|
||||
? { type: "divider", element, style }
|
||||
: { type: "divider", element };
|
||||
this.currentNodes.push(node);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush the in-progress page and start a new one with the supplied
|
||||
* config. Calling this with no pending content still bumps the page
|
||||
* count when `config` is provided so callers can pre-declare a
|
||||
* landscape page mid-document.
|
||||
*/
|
||||
newPage(config?: Partial<PageConfig>): this {
|
||||
this.flushPage();
|
||||
if (config) {
|
||||
this.currentConfig = { ...config };
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Register the per-page header — replaces any prior header. */
|
||||
setHeader(nodes: ReadonlyArray<DocumentNode>, height: number): this {
|
||||
if (!Number.isFinite(height) || height < 0) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.INVALID_PAGE_CONFIG,
|
||||
`header height must be a non-negative finite number: ${String(height)}`
|
||||
);
|
||||
}
|
||||
this.headerNodes = [...nodes];
|
||||
this.headerHeight = height;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Register the per-page footer — replaces any prior footer. */
|
||||
setFooter(nodes: ReadonlyArray<DocumentNode>, height: number): this {
|
||||
if (!Number.isFinite(height) || height < 0) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.INVALID_PAGE_CONFIG,
|
||||
`footer height must be a non-negative finite number: ${String(height)}`
|
||||
);
|
||||
}
|
||||
this.footerNodes = [...nodes];
|
||||
this.footerHeight = height;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Finalise the document and return its renderer-ready AST. */
|
||||
build(): Document<TData> {
|
||||
this.flushPage();
|
||||
if (this.pages.length === 0) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.INVALID_DOCUMENT,
|
||||
"DocumentBuilder.build called with no pages"
|
||||
);
|
||||
}
|
||||
const doc: Document<TData> = {
|
||||
metadata: {
|
||||
...this.metadata,
|
||||
creationDate: this.metadata.creationDate ?? new Date(),
|
||||
creator: this.metadata.creator ?? DEFAULT_CREATOR,
|
||||
},
|
||||
defaultPage: this.defaultPage,
|
||||
pages: this.pages.map((p) => ({ ...p, nodes: [...p.nodes] })),
|
||||
header: this.headerNodes ? [...this.headerNodes] : undefined,
|
||||
footer: this.footerNodes ? [...this.footerNodes] : undefined,
|
||||
headerHeight: this.headerHeight,
|
||||
footerHeight: this.footerHeight,
|
||||
data: this.data,
|
||||
};
|
||||
return doc;
|
||||
}
|
||||
|
||||
private flushPage(): void {
|
||||
if (this.currentNodes.length === 0 && !this.currentConfig) return;
|
||||
const page: BuiltPageDefinition = this.currentConfig
|
||||
? {
|
||||
config: this.currentConfig,
|
||||
nodes: [...this.currentNodes],
|
||||
}
|
||||
: { nodes: [...this.currentNodes] };
|
||||
this.pages.push(page);
|
||||
this.currentNodes = [];
|
||||
delete this.currentConfig;
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience factory mirroring the main-branch `createDocument()` helper. */
|
||||
export function createDocument<TData = unknown>(): DocumentBuilder<TData> {
|
||||
return new DocumentBuilder<TData>();
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Public types for the {@link DocumentBuilder} fluent API.
|
||||
*
|
||||
* The builder produces a {@link Document} AST consumed by the renderer.
|
||||
* Each node carries the constructed element instance directly so the
|
||||
* renderer can invoke `.measure()` / `.render()` without re-validating
|
||||
* props through Zod a second time.
|
||||
*/
|
||||
|
||||
import type { DividerElement } from "../divider-element/divider-element.js";
|
||||
import type { DividerElementOptions } from "../divider-element/divider-element.types.js";
|
||||
import type { ImageElement } from "../image-element/image-element.js";
|
||||
import type { ImageElementOptions } from "../image-element/image-element.types.js";
|
||||
import type { PdfStyle } from "../style-resolver/style-resolver.types.js";
|
||||
import type { TableElement } from "../table-element/table-element.js";
|
||||
import type {
|
||||
TableColumn,
|
||||
TableElementOptions,
|
||||
} from "../table-element/table-element.types.js";
|
||||
import type { TextElement } from "../text-element/text-element.js";
|
||||
import type { TextElementOptions } from "../text-element/text-element.types.js";
|
||||
import type { DocumentMetadata, PageConfig } from "../types/page-size.js";
|
||||
|
||||
/** Discriminator for {@link DocumentNode} unions. */
|
||||
export type DocumentNodeType = "text" | "table" | "image" | "divider";
|
||||
|
||||
/** A single text node in the document tree. */
|
||||
export interface TextNode {
|
||||
readonly type: "text";
|
||||
readonly element: TextElement;
|
||||
readonly style?: Partial<PdfStyle> | undefined;
|
||||
}
|
||||
|
||||
/** A single table node in the document tree. */
|
||||
export interface TableNode<TRow = unknown> {
|
||||
readonly type: "table";
|
||||
readonly element: TableElement<TRow>;
|
||||
readonly style?: Partial<PdfStyle> | undefined;
|
||||
}
|
||||
|
||||
/** A single image node in the document tree. */
|
||||
export interface ImageNode {
|
||||
readonly type: "image";
|
||||
readonly element: ImageElement;
|
||||
readonly style?: Partial<PdfStyle> | undefined;
|
||||
}
|
||||
|
||||
/** A single divider node in the document tree. */
|
||||
export interface DividerNode {
|
||||
readonly type: "divider";
|
||||
readonly element: DividerElement;
|
||||
readonly style?: Partial<PdfStyle> | undefined;
|
||||
}
|
||||
|
||||
/** Discriminated union of document-tree element nodes. */
|
||||
export type DocumentNode = TextNode | TableNode | ImageNode | DividerNode;
|
||||
|
||||
/** Page-level definition produced by the builder. */
|
||||
export interface BuiltPageDefinition {
|
||||
readonly config?: Partial<PageConfig> | undefined;
|
||||
readonly nodes: ReadonlyArray<DocumentNode>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderer-ready document AST. `header` + `footer` are optional element
|
||||
* groups rendered into the layout engine's reserved header / footer
|
||||
* slots on every page.
|
||||
*/
|
||||
export interface Document<TData = unknown> {
|
||||
readonly metadata: DocumentMetadata;
|
||||
readonly defaultPage: PageConfig;
|
||||
readonly pages: ReadonlyArray<BuiltPageDefinition>;
|
||||
readonly header?: ReadonlyArray<DocumentNode> | undefined;
|
||||
readonly footer?: ReadonlyArray<DocumentNode> | undefined;
|
||||
readonly headerHeight?: number | undefined;
|
||||
readonly footerHeight?: number | undefined;
|
||||
readonly data?: TData | undefined;
|
||||
}
|
||||
|
||||
/** Re-exports for the fluent API. */
|
||||
export type {
|
||||
DividerElementOptions,
|
||||
DocumentMetadata,
|
||||
ImageElementOptions,
|
||||
PageConfig,
|
||||
TableColumn,
|
||||
TableElementOptions,
|
||||
TextElementOptions,
|
||||
};
|
||||
11
packages/devtools/docs/pdf/src/document-builder/index.ts
Normal file
11
packages/devtools/docs/pdf/src/document-builder/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { createDocument, DocumentBuilder } from "./document-builder.js";
|
||||
export type {
|
||||
BuiltPageDefinition,
|
||||
DividerNode,
|
||||
Document,
|
||||
DocumentNode,
|
||||
DocumentNodeType,
|
||||
ImageNode,
|
||||
TableNode,
|
||||
TextNode,
|
||||
} from "./document-builder.types.js";
|
||||
106
packages/devtools/docs/pdf/src/error-codes/error-codes.md
Normal file
106
packages/devtools/docs/pdf/src/error-codes/error-codes.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# `error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares the stable `PDF.*` error-code registry and the `PdfError` class
|
||||
used by every public function in `@suaveplan/pdf`. Every failure path
|
||||
the package can take — from a malformed hex colour to a writer that has
|
||||
been disposed mid-render — surfaces one of these codes so callers can
|
||||
classify failures programmatically with `instanceof PdfError` plus a
|
||||
discriminated switch on `err.code`, instead of brittle message-string
|
||||
parsing.
|
||||
|
||||
## Features
|
||||
|
||||
- `PDF_CODES` constant built via `defineCodes("PDF", […])` so every
|
||||
code is namespaced (`"PDF.INVALID_UNIT"`, `"PDF.RENDER_FAILED"`, …).
|
||||
- Thirteen stable codes covering every Chunk A–E failure path. The full
|
||||
union is declared up front so subsequent chunks never widen
|
||||
`PdfErrorCode` and break consumers.
|
||||
- `PdfError` class extending `SuaveplanError<PdfErrorCode>` with the
|
||||
standard `code`, `cause`, `context`, and `timestamp` surface.
|
||||
- `asPdfError(code, cause, fallback)` lifts unknown thrown values into a
|
||||
`PdfError`, preserving the original `cause` and adopting its message
|
||||
when present.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { PDF_CODES, PdfError } from "@suaveplan/pdf/error-codes";
|
||||
|
||||
if (!isValidHex(input)) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.INVALID_HEX_COLOR,
|
||||
`bad hex colour: ${input}`
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wrap third-party throws at the package boundary so the typed surface is
|
||||
preserved without losing the original cause:
|
||||
|
||||
```ts
|
||||
import { asPdfError, PDF_CODES, PdfError } from "@suaveplan/pdf/error-codes";
|
||||
|
||||
try {
|
||||
await externalRenderHook(doc);
|
||||
} catch (cause) {
|
||||
throw asPdfError(PDF_CODES.RENDER_FAILED, cause, "render hook failed");
|
||||
}
|
||||
|
||||
// Downstream discriminates on err.code:
|
||||
try {
|
||||
await render(doc);
|
||||
} catch (err) {
|
||||
if (err instanceof PdfError) {
|
||||
switch (err.code) {
|
||||
case PDF_CODES.LAYOUT_OVERFLOW:
|
||||
// …split into multiple pages
|
||||
break;
|
||||
case PDF_CODES.IMAGE_DECODE_FAILED:
|
||||
// …fall back to a placeholder asset
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PDF_CODES` — frozen object whose keys are the bare names
|
||||
(`INVALID_UNIT`) and whose values are the namespaced forms
|
||||
(`"PDF.INVALID_UNIT"`). Members: `INVALID_UNIT`, `INVALID_HEX_COLOR`,
|
||||
`INVALID_RGB`, `INVALID_PAGE_CONFIG`, `INVALID_DOCUMENT`,
|
||||
`INVALID_ELEMENT`, `INVALID_TEMPLATE_INPUT`, `RENDER_FAILED`,
|
||||
`LAYOUT_OVERFLOW`, `IMAGE_DECODE_FAILED`, `FONT_UNKNOWN`,
|
||||
`WRITER_DISPOSED`, `ABORTED`.
|
||||
- `type PdfErrorCode` — union of `PDF_CODES[keyof typeof PDF_CODES]`.
|
||||
- `class PdfError extends SuaveplanError<PdfErrorCode>` — sets
|
||||
`name === "PdfError"` and forwards `code`, `message`, and `options`
|
||||
to the base.
|
||||
- `asPdfError(code, cause, fallback): PdfError` — lifts arbitrary
|
||||
thrown values into a `PdfError`, attaching the original (or a wrapped
|
||||
`Error(String(cause))`) as `cause`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The code registry is declared with `as const` so the union is exact
|
||||
rather than `string`.
|
||||
- `defineCodes` is imported directly from `@suaveplan/error`; this
|
||||
package never re-exports it (rule 4: no convenience re-exports of
|
||||
external deps).
|
||||
- `asPdfError` always returns a `PdfError` whose `cause` is an `Error`
|
||||
instance, even when the original thrown value was a string or a
|
||||
plain object — preserves the invariant that downstream tracing and
|
||||
serialization can rely on `.message` and `.stack`.
|
||||
- The registry is intentionally over-sized for Chunk A (only the unit /
|
||||
colour / Zod-boundary codes are reachable here); the unreachable
|
||||
codes are exercised in their own modules in Chunks B–E. The test
|
||||
suite asserts every code is registered so adding a new code without
|
||||
exercising it is impossible.
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { asPdfError, PDF_CODES, PdfError } from "./error-codes.js";
|
||||
|
||||
describe("PDF_CODES", () => {
|
||||
it("every PDF code is registered", { timeout: 5000 }, () => {
|
||||
const codes = Object.keys(PDF_CODES).sort();
|
||||
expect(codes).toEqual(
|
||||
[
|
||||
"ABORTED",
|
||||
"FONT_UNKNOWN",
|
||||
"IMAGE_DECODE_FAILED",
|
||||
"INVALID_DOCUMENT",
|
||||
"INVALID_ELEMENT",
|
||||
"INVALID_HEX_COLOR",
|
||||
"INVALID_PAGE_CONFIG",
|
||||
"INVALID_RGB",
|
||||
"INVALID_TEMPLATE_INPUT",
|
||||
"INVALID_UNIT",
|
||||
"LAYOUT_OVERFLOW",
|
||||
"RENDER_FAILED",
|
||||
"WRITER_DISPOSED",
|
||||
].sort()
|
||||
);
|
||||
expect(PDF_CODES.INVALID_UNIT).toBe("PDF.INVALID_UNIT");
|
||||
expect(PDF_CODES.INVALID_HEX_COLOR).toBe("PDF.INVALID_HEX_COLOR");
|
||||
expect(PDF_CODES.INVALID_RGB).toBe("PDF.INVALID_RGB");
|
||||
expect(PDF_CODES.INVALID_PAGE_CONFIG).toBe("PDF.INVALID_PAGE_CONFIG");
|
||||
expect(PDF_CODES.INVALID_DOCUMENT).toBe("PDF.INVALID_DOCUMENT");
|
||||
expect(PDF_CODES.INVALID_ELEMENT).toBe("PDF.INVALID_ELEMENT");
|
||||
expect(PDF_CODES.INVALID_TEMPLATE_INPUT).toBe(
|
||||
"PDF.INVALID_TEMPLATE_INPUT"
|
||||
);
|
||||
expect(PDF_CODES.RENDER_FAILED).toBe("PDF.RENDER_FAILED");
|
||||
expect(PDF_CODES.LAYOUT_OVERFLOW).toBe("PDF.LAYOUT_OVERFLOW");
|
||||
expect(PDF_CODES.IMAGE_DECODE_FAILED).toBe("PDF.IMAGE_DECODE_FAILED");
|
||||
expect(PDF_CODES.FONT_UNKNOWN).toBe("PDF.FONT_UNKNOWN");
|
||||
expect(PDF_CODES.WRITER_DISPOSED).toBe("PDF.WRITER_DISPOSED");
|
||||
expect(PDF_CODES.ABORTED).toBe("PDF.ABORTED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PdfError", () => {
|
||||
it("preserves code, message, name and cause", { timeout: 5000 }, () => {
|
||||
const native = new Error("disk read failure");
|
||||
const err = new PdfError(PDF_CODES.RENDER_FAILED, "wrapped", {
|
||||
cause: native,
|
||||
});
|
||||
expect(err).toBeInstanceOf(PdfError);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.code).toBe(PDF_CODES.RENDER_FAILED);
|
||||
expect(err.message).toBe("wrapped");
|
||||
expect(err.name).toBe("PdfError");
|
||||
expect(err.cause).toBe(native);
|
||||
});
|
||||
});
|
||||
|
||||
describe("asPdfError", () => {
|
||||
it("asPdfError preserves cause", { timeout: 5000 }, () => {
|
||||
const native = new Error("boom");
|
||||
const err = asPdfError(
|
||||
PDF_CODES.RENDER_FAILED,
|
||||
native,
|
||||
"fallback message"
|
||||
);
|
||||
expect(err).toBeInstanceOf(PdfError);
|
||||
expect(err.code).toBe(PDF_CODES.RENDER_FAILED);
|
||||
expect(err.message).toBe("boom");
|
||||
expect(err.cause).toBe(native);
|
||||
});
|
||||
|
||||
it("uses fallback message when cause has none", { timeout: 5000 }, () => {
|
||||
const err = asPdfError(
|
||||
PDF_CODES.RENDER_FAILED,
|
||||
new Error(""),
|
||||
"fallback message"
|
||||
);
|
||||
expect(err.message).toBe("fallback message");
|
||||
});
|
||||
|
||||
it("uses fallback when cause is not an Error", { timeout: 5000 }, () => {
|
||||
const err = asPdfError(
|
||||
PDF_CODES.RENDER_FAILED,
|
||||
"string failure",
|
||||
"fallback message"
|
||||
);
|
||||
expect(err.message).toBe("fallback message");
|
||||
expect(err.cause).toBeInstanceOf(Error);
|
||||
expect((err.cause as Error).message).toBe("string failure");
|
||||
});
|
||||
});
|
||||
65
packages/devtools/docs/pdf/src/error-codes/error-codes.ts
Normal file
65
packages/devtools/docs/pdf/src/error-codes/error-codes.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/pdf`. Every error path the
|
||||
* package can take surfaces one of these codes so callers can classify
|
||||
* failures programmatically without parsing message strings.
|
||||
*
|
||||
* The registry intentionally declares codes for the full Chunk A–E
|
||||
* surface up front so subsequent chunks do not have to expand the union
|
||||
* mid-flight (which would break consumers pinning to `PdfErrorCode`).
|
||||
*/
|
||||
export const PDF_CODES = defineCodes("PDF", [
|
||||
"INVALID_UNIT",
|
||||
"INVALID_HEX_COLOR",
|
||||
"INVALID_RGB",
|
||||
"INVALID_PAGE_CONFIG",
|
||||
"INVALID_DOCUMENT",
|
||||
"INVALID_ELEMENT",
|
||||
"INVALID_TEMPLATE_INPUT",
|
||||
"RENDER_FAILED",
|
||||
"LAYOUT_OVERFLOW",
|
||||
"IMAGE_DECODE_FAILED",
|
||||
"FONT_UNKNOWN",
|
||||
"WRITER_DISPOSED",
|
||||
"ABORTED",
|
||||
] as const);
|
||||
|
||||
/** Union of every error code emitted by `@suaveplan/pdf`. */
|
||||
export type PdfErrorCode = (typeof PDF_CODES)[keyof typeof PDF_CODES];
|
||||
|
||||
/**
|
||||
* Typed error for `@suaveplan/pdf`. Extends `SuaveplanError` so consumers
|
||||
* get the standard `code`, `cause`, `context`, and `timestamp` surface;
|
||||
* raise via `new PdfError(PDF_CODES.INVALID_HEX_COLOR, "bad input")`.
|
||||
*/
|
||||
export class PdfError extends SuaveplanError<PdfErrorCode> {
|
||||
constructor(
|
||||
code: PdfErrorCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.name = "PdfError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lift an unknown thrown value into a {@link PdfError}, preserving the
|
||||
* original `cause` and adopting its message when present. Used at every
|
||||
* `catch` site to maintain the typed error contract.
|
||||
*/
|
||||
export function asPdfError(
|
||||
code: PdfErrorCode,
|
||||
cause: unknown,
|
||||
fallbackMessage: string
|
||||
): PdfError {
|
||||
const message =
|
||||
cause instanceof Error && cause.message
|
||||
? cause.message
|
||||
: fallbackMessage;
|
||||
const causeError =
|
||||
cause instanceof Error ? cause : new Error(String(cause));
|
||||
return new PdfError(code, message, { cause: causeError });
|
||||
}
|
||||
2
packages/devtools/docs/pdf/src/error-codes/index.ts
Normal file
2
packages/devtools/docs/pdf/src/error-codes/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { PdfErrorCode } from "./error-codes.js";
|
||||
export { asPdfError, PDF_CODES, PdfError } from "./error-codes.js";
|
||||
108
packages/devtools/docs/pdf/src/image-element/image-element.md
Normal file
108
packages/devtools/docs/pdf/src/image-element/image-element.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# image-element
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ImageElement` is the bitmap primitive of `@suaveplan/pdf`. It accepts
|
||||
raw JPEG or PNG bytes, decodes them at construction time, and emits a
|
||||
content-stream snippet that paints the registered XObject at the chosen
|
||||
position + size. JPEGs pass through unchanged because PDF supports
|
||||
`DCTDecode` natively; PNGs are decoded via `pngjs` to RGBA pixels, then
|
||||
the RGB plane is embedded as a `FlateDecode` stream and the alpha plane
|
||||
(when present) is published as an `SMask` for translucent compositing.
|
||||
|
||||
The element is the first dependency on a third-party decoder (`pngjs`)
|
||||
and gates Chunk D's renderer, which is responsible for wiring the
|
||||
decoded data into XObject indirect objects in the page-resources
|
||||
dictionary.
|
||||
|
||||
## Features
|
||||
|
||||
- JPEG header parsing (SOF0..SOF15 walk) extracts width / height without
|
||||
decoding pixels.
|
||||
- PNG decoding via `pngjs` synchronous reader yields RGBA8888 buffers.
|
||||
- Alpha plane is separated into a dedicated `SMask` payload only when
|
||||
any pixel is non-opaque.
|
||||
- `detectImageFormat(bytes)` — magic-byte sniffer (PNG signature + JPEG
|
||||
SOI/APP0).
|
||||
- `decodeImage(bytes)` — produces a `DecodedImage` ready for the writer
|
||||
to embed.
|
||||
- `scaleToFit` — proportional scaling that never upscales.
|
||||
- `validateImageProps` — typed errors on non-positive dimensions.
|
||||
- `AbortSignal` propagation on `render()`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ImageElement } from "@suaveplan/pdf";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const bytes = readFileSync("./logo.png");
|
||||
const image = new ImageElement({
|
||||
src: new Uint8Array(bytes),
|
||||
width: 120,
|
||||
height: 60,
|
||||
});
|
||||
const out = image.render({
|
||||
x: 72,
|
||||
y: 770,
|
||||
maxWidth: 460,
|
||||
fontResourceName: "Im0",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
decodeImage,
|
||||
detectImageFormat,
|
||||
ImageElement,
|
||||
scaleToFit,
|
||||
} from "@suaveplan/pdf";
|
||||
|
||||
const format = detectImageFormat(bytes);
|
||||
if (!format) throw new Error("unknown format");
|
||||
const decoded = decodeImage(bytes);
|
||||
const fitted = scaleToFit(decoded.width, decoded.height, 460, 600);
|
||||
const image = new ImageElement({
|
||||
src: bytes,
|
||||
width: fitted.width,
|
||||
height: fitted.height,
|
||||
format,
|
||||
alt: "Company logo",
|
||||
});
|
||||
// Pass `image.decoded` to the renderer (Chunk D) which will register an
|
||||
// XObject indirect object on the writer and populate the page-resources
|
||||
// /XObject dictionary with its resource name.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class ImageElement(options)` — validates `ImageElementOptions`,
|
||||
decodes the image at construction time, exposes `measure()` +
|
||||
`render(context)` + `decoded`.
|
||||
- `ImageElementOptionsSchema` — Zod schema used at construction.
|
||||
- `decodeImage(bytes)` — returns a `DecodedImage`.
|
||||
- `detectImageFormat(bytes)` — magic-byte sniffer.
|
||||
- `scaleToFit(w, h, maxW, maxH)` — aspect-preserving fit.
|
||||
- `validateImageProps(props)` — throws `PDF.INVALID_ELEMENT` on bad
|
||||
dimensions.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`pngjs.PNG.sync.read` allocates a single RGBA buffer; the decoder
|
||||
splits it into three-byte RGB pixels + a separate alpha byte plane so
|
||||
the writer can publish them as a primary `FlateDecode`-compressed
|
||||
stream and an optional `SMask`. The alpha plane is only retained when
|
||||
at least one pixel is non-opaque — fully opaque PNGs decode to a
|
||||
zero-allocation single stream. JPEGs are passed through verbatim
|
||||
because PDF readers handle `DCTDecode` natively; the SOF-walk only
|
||||
reads the header bytes and never decodes the DCT coefficients. The
|
||||
render method writes the `cm` transform such that the image's
|
||||
bottom-left corner lands at `(x, y - height)`, matching the
|
||||
PDF coordinate origin (bottom-left of the page). `AbortSignal` is
|
||||
checked at the top of `render()`.
|
||||
@@ -0,0 +1,319 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PNG } from "pngjs";
|
||||
import { PDF_CODES, PdfError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
decodeImage,
|
||||
detectImageFormat,
|
||||
ImageElement,
|
||||
scaleToFit,
|
||||
validateImageProps,
|
||||
} from "./image-element.js";
|
||||
|
||||
const makePng = (
|
||||
width: number,
|
||||
height: number,
|
||||
fillR = 200,
|
||||
fillG = 100,
|
||||
fillB = 50,
|
||||
alpha = 255
|
||||
): Uint8Array => {
|
||||
const png = new PNG({ width, height });
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const idx = (width * y + x) << 2;
|
||||
png.data[idx] = fillR;
|
||||
png.data[idx + 1] = fillG;
|
||||
png.data[idx + 2] = fillB;
|
||||
png.data[idx + 3] = alpha;
|
||||
}
|
||||
}
|
||||
return new Uint8Array(PNG.sync.write(png));
|
||||
};
|
||||
|
||||
// Minimal JPEG containing only SOI + APP0 + SOF0 + SOS + EOI for header tests.
|
||||
const makeJpegStub = (width: number, height: number): Uint8Array => {
|
||||
const sof = [
|
||||
0xff,
|
||||
0xc0,
|
||||
0x00,
|
||||
0x11,
|
||||
0x08,
|
||||
(height >> 8) & 0xff,
|
||||
height & 0xff,
|
||||
(width >> 8) & 0xff,
|
||||
width & 0xff,
|
||||
0x03,
|
||||
0x01,
|
||||
0x22,
|
||||
0x00,
|
||||
0x02,
|
||||
0x11,
|
||||
0x01,
|
||||
0x03,
|
||||
0x11,
|
||||
0x01,
|
||||
];
|
||||
return new Uint8Array([
|
||||
0xff,
|
||||
0xd8, // SOI
|
||||
0xff,
|
||||
0xe0,
|
||||
0x00,
|
||||
0x10,
|
||||
0x4a,
|
||||
0x46,
|
||||
0x49,
|
||||
0x46,
|
||||
0x00,
|
||||
0x01,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
...sof,
|
||||
0xff,
|
||||
0xd9, // EOI
|
||||
]);
|
||||
};
|
||||
|
||||
describe("detectImageFormat", () => {
|
||||
it("detects PNG signature", { timeout: 5000 }, () => {
|
||||
expect(detectImageFormat(makePng(1, 1))).toBe("png");
|
||||
});
|
||||
|
||||
it("detects JPEG signature", { timeout: 5000 }, () => {
|
||||
expect(detectImageFormat(makeJpegStub(2, 2))).toBe("jpeg");
|
||||
});
|
||||
|
||||
it("returns undefined for unknown bytes", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
detectImageFormat(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]))
|
||||
).toBe(undefined);
|
||||
});
|
||||
|
||||
it("returns undefined for too-short bytes", { timeout: 5000 }, () => {
|
||||
expect(detectImageFormat(new Uint8Array([1, 2]))).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scaleToFit", () => {
|
||||
it("scales down proportionally", { timeout: 5000 }, () => {
|
||||
const out = scaleToFit(200, 100, 100, 200);
|
||||
expect(out).toEqual({ width: 100, height: 50 });
|
||||
});
|
||||
|
||||
it("never upscales", { timeout: 5000 }, () => {
|
||||
expect(scaleToFit(50, 50, 500, 500)).toEqual({ width: 50, height: 50 });
|
||||
});
|
||||
|
||||
it("returns zero for non-positive originals", { timeout: 5000 }, () => {
|
||||
expect(scaleToFit(0, 100, 100, 100)).toEqual({ width: 0, height: 0 });
|
||||
expect(scaleToFit(100, 0, 100, 100)).toEqual({ width: 0, height: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateImageProps", () => {
|
||||
it("requires positive width", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
validateImageProps({
|
||||
src: new Uint8Array(),
|
||||
width: 0,
|
||||
height: 1,
|
||||
})
|
||||
).toThrow(PdfError);
|
||||
});
|
||||
|
||||
it("requires positive height", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
validateImageProps({
|
||||
src: new Uint8Array(),
|
||||
width: 1,
|
||||
height: 0,
|
||||
})
|
||||
).toThrow(PdfError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeImage", () => {
|
||||
it("decodes PNG to RGB pixels", { timeout: 5000 }, () => {
|
||||
const png = makePng(2, 2, 10, 20, 30);
|
||||
const decoded = decodeImage(png);
|
||||
expect(decoded.format).toBe("png");
|
||||
expect(decoded.width).toBe(2);
|
||||
expect(decoded.height).toBe(2);
|
||||
expect(decoded.bitsPerComponent).toBe(8);
|
||||
expect(decoded.data.length).toBe(2 * 2 * 3);
|
||||
expect(decoded.alpha).toBe(undefined);
|
||||
expect(decoded.data[0]).toBe(10);
|
||||
expect(decoded.data[1]).toBe(20);
|
||||
expect(decoded.data[2]).toBe(30);
|
||||
});
|
||||
|
||||
it("captures alpha plane for translucent PNGs", { timeout: 5000 }, () => {
|
||||
const png = makePng(1, 1, 10, 20, 30, 128);
|
||||
const decoded = decodeImage(png);
|
||||
expect(decoded.alpha).toBeInstanceOf(Uint8Array);
|
||||
expect(decoded.alpha?.[0]).toBe(128);
|
||||
});
|
||||
|
||||
it("passes JPEGs through unchanged", { timeout: 5000 }, () => {
|
||||
const jpeg = makeJpegStub(4, 5);
|
||||
const decoded = decodeImage(jpeg);
|
||||
expect(decoded.format).toBe("jpeg");
|
||||
expect(decoded.width).toBe(4);
|
||||
expect(decoded.height).toBe(5);
|
||||
expect(decoded.data).toBe(jpeg);
|
||||
});
|
||||
|
||||
it("rejects unknown format", { timeout: 5000 }, () => {
|
||||
expect(() => decodeImage(new Uint8Array(16))).toThrow(PdfError);
|
||||
});
|
||||
|
||||
it("wraps PNG-decode errors in PDF.IMAGE_DECODE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const broken = new Uint8Array([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0,
|
||||
]);
|
||||
try {
|
||||
decodeImage(broken);
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(PdfError);
|
||||
expect((err as PdfError).code).toBe(PDF_CODES.IMAGE_DECODE_FAILED);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects truncated JPEG (no marker after FF)", { timeout: 5000 }, () => {
|
||||
// Detect-able JPEG header (>=8 bytes, starts with FF D8 FF), but
|
||||
// the walk hits an FF at the last byte → "truncated JPEG".
|
||||
const truncated = new Uint8Array([
|
||||
0xff, 0xd8, 0xff, 0xe0, 0x00, 0x04, 0x00, 0x00, 0xff,
|
||||
]);
|
||||
expect(() => decodeImage(truncated)).toThrow(PdfError);
|
||||
});
|
||||
|
||||
it("rejects JPEG with invalid marker mid-walk", { timeout: 5000 }, () => {
|
||||
// Pass format detection (FF D8 FF) but place a non-FF byte where
|
||||
// the walk expects a marker → "invalid JPEG marker".
|
||||
const badMarker = new Uint8Array([
|
||||
0xff, 0xd8, 0xff, 0xe0, 0x00, 0x04, 0x00, 0x00, 0x00,
|
||||
]);
|
||||
expect(() => decodeImage(badMarker)).toThrow(PdfError);
|
||||
});
|
||||
|
||||
it("rejects JPEG missing SOF", { timeout: 5000 }, () => {
|
||||
// SOI + APP0 with segLen filling the buffer exactly — walk exits
|
||||
// without finding any SOF marker.
|
||||
const noSof = new Uint8Array([
|
||||
0xff, 0xd8, 0xff, 0xe0, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00,
|
||||
]);
|
||||
expect(() => decodeImage(noSof)).toThrow(PdfError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ImageElement", () => {
|
||||
it("validates options at construction", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
() =>
|
||||
new ImageElement({
|
||||
src: makePng(1, 1),
|
||||
width: -1,
|
||||
height: 1,
|
||||
})
|
||||
).toThrow(PdfError);
|
||||
});
|
||||
|
||||
it("rejects mismatched declared format", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
() =>
|
||||
new ImageElement({
|
||||
src: makePng(1, 1),
|
||||
width: 10,
|
||||
height: 10,
|
||||
format: "jpeg",
|
||||
})
|
||||
).toThrow(PdfError);
|
||||
});
|
||||
|
||||
it("measure returns logical size", { timeout: 5000 }, () => {
|
||||
const el = new ImageElement({
|
||||
src: makePng(2, 2),
|
||||
width: 100,
|
||||
height: 50,
|
||||
});
|
||||
expect(el.measure()).toEqual({ width: 100, height: 50 });
|
||||
});
|
||||
|
||||
it("render emits the cm + Do operators", { timeout: 5000 }, () => {
|
||||
const el = new ImageElement({
|
||||
src: makePng(2, 2),
|
||||
width: 100,
|
||||
height: 50,
|
||||
});
|
||||
const out = el.render({
|
||||
x: 72,
|
||||
y: 700,
|
||||
maxWidth: 460,
|
||||
fontResourceName: "Im0",
|
||||
});
|
||||
expect(out.stream).toContain("100 0 0 50 72 650 cm");
|
||||
expect(out.stream).toContain("/Im0 Do");
|
||||
expect(out.height).toBe(50);
|
||||
});
|
||||
|
||||
it("render requires a resource name", { timeout: 5000 }, () => {
|
||||
const el = new ImageElement({
|
||||
src: makePng(2, 2),
|
||||
width: 10,
|
||||
height: 10,
|
||||
});
|
||||
expect(() => el.render({ x: 0, y: 100, maxWidth: 200 })).toThrow(
|
||||
PdfError
|
||||
);
|
||||
});
|
||||
|
||||
it("aborts on aborted signal", { timeout: 5000 }, () => {
|
||||
const el = new ImageElement({
|
||||
src: makePng(2, 2),
|
||||
width: 10,
|
||||
height: 10,
|
||||
});
|
||||
const c = new AbortController();
|
||||
c.abort();
|
||||
expect(() =>
|
||||
el.render({
|
||||
x: 0,
|
||||
y: 100,
|
||||
maxWidth: 200,
|
||||
fontResourceName: "Im0",
|
||||
signal: c.signal,
|
||||
})
|
||||
).toThrow(PdfError);
|
||||
});
|
||||
|
||||
it("ImageElement holds decoded data", { timeout: 5000 }, () => {
|
||||
const el = new ImageElement({
|
||||
src: makePng(2, 2),
|
||||
width: 10,
|
||||
height: 10,
|
||||
});
|
||||
expect(el.decoded.format).toBe("png");
|
||||
expect(el.decoded.width).toBe(2);
|
||||
});
|
||||
|
||||
it("rejects invalid src type via schema", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
() =>
|
||||
new ImageElement({
|
||||
src: "not bytes" as unknown as Uint8Array,
|
||||
width: 10,
|
||||
height: 10,
|
||||
})
|
||||
).toThrow(PdfError);
|
||||
});
|
||||
});
|
||||
270
packages/devtools/docs/pdf/src/image-element/image-element.ts
Normal file
270
packages/devtools/docs/pdf/src/image-element/image-element.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { PNG } from "pngjs";
|
||||
import z from "zod";
|
||||
import { ContentStreamEncoder } from "../content-stream/content-stream.js";
|
||||
import { asPdfError, PDF_CODES, PdfError } from "../error-codes/error-codes.js";
|
||||
import { parseOrThrow } from "../schemas/schemas.js";
|
||||
import type {
|
||||
ElementMeasurement,
|
||||
ElementRenderContext,
|
||||
ElementRenderResult,
|
||||
} from "../text-element/text-element.types.js";
|
||||
import type {
|
||||
DecodedImage,
|
||||
ImageElementOptions,
|
||||
PdfImageFormat,
|
||||
} from "./image-element.types.js";
|
||||
|
||||
/** Zod schema for {@link ImageElementOptions}. */
|
||||
export const ImageElementOptionsSchema = z.object({
|
||||
src: z.instanceof(Uint8Array),
|
||||
width: z.number().positive(),
|
||||
height: z.number().positive(),
|
||||
format: z.enum(["jpeg", "png"]).optional(),
|
||||
alt: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Detect a {@link PdfImageFormat} from the leading bytes of `data`. Only
|
||||
* supports the two PDF-native image formats (JPEG via DCTDecode, PNG via
|
||||
* pngjs decode + FlateDecode re-emission).
|
||||
*/
|
||||
export function detectImageFormat(
|
||||
data: Uint8Array
|
||||
): PdfImageFormat | undefined {
|
||||
if (data.length < 8) return undefined;
|
||||
if (data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) {
|
||||
return "jpeg";
|
||||
}
|
||||
if (
|
||||
data[0] === 0x89 &&
|
||||
data[1] === 0x50 &&
|
||||
data[2] === 0x4e &&
|
||||
data[3] === 0x47 &&
|
||||
data[4] === 0x0d &&
|
||||
data[5] === 0x0a &&
|
||||
data[6] === 0x1a &&
|
||||
data[7] === 0x0a
|
||||
) {
|
||||
return "png";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute width × height while preserving aspect ratio, never upscaling.
|
||||
*/
|
||||
export function scaleToFit(
|
||||
originalWidth: number,
|
||||
originalHeight: number,
|
||||
maxWidth: number,
|
||||
maxHeight: number
|
||||
): { width: number; height: number } {
|
||||
if (originalWidth <= 0 || originalHeight <= 0) {
|
||||
return { width: 0, height: 0 };
|
||||
}
|
||||
const scale = Math.min(
|
||||
maxWidth / originalWidth,
|
||||
maxHeight / originalHeight,
|
||||
1
|
||||
);
|
||||
return {
|
||||
width: originalWidth * scale,
|
||||
height: originalHeight * scale,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode raw image bytes into a {@link DecodedImage} the writer can embed.
|
||||
*
|
||||
* - PNGs go through `pngjs` which yields RGBA8888 pixels; we split out
|
||||
* the alpha channel into a separate SMask plane and keep RGB as the
|
||||
* primary stream.
|
||||
* - JPEGs pass through unchanged — PDF supports `DCTDecode` natively.
|
||||
*/
|
||||
export function decodeImage(data: Uint8Array): DecodedImage {
|
||||
const format = detectImageFormat(data);
|
||||
if (!format) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.IMAGE_DECODE_FAILED,
|
||||
"unrecognised image format"
|
||||
);
|
||||
}
|
||||
if (format === "jpeg") {
|
||||
const { width, height } = readJpegDimensions(data);
|
||||
return {
|
||||
format: "jpeg",
|
||||
width,
|
||||
height,
|
||||
bitsPerComponent: 8,
|
||||
colorSpace: "DeviceRGB",
|
||||
data,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const png = PNG.sync.read(Buffer.from(data));
|
||||
const width = png.width;
|
||||
const height = png.height;
|
||||
const rgba = png.data;
|
||||
const pixels = width * height;
|
||||
const rgb = new Uint8Array(pixels * 3);
|
||||
const alpha = new Uint8Array(pixels);
|
||||
let hasTransparency = false;
|
||||
for (let i = 0; i < pixels; i++) {
|
||||
rgb[i * 3] = rgba[i * 4] as number;
|
||||
rgb[i * 3 + 1] = rgba[i * 4 + 1] as number;
|
||||
rgb[i * 3 + 2] = rgba[i * 4 + 2] as number;
|
||||
const a = rgba[i * 4 + 3] as number;
|
||||
alpha[i] = a;
|
||||
if (a !== 255) hasTransparency = true;
|
||||
}
|
||||
return {
|
||||
format: "png",
|
||||
width,
|
||||
height,
|
||||
bitsPerComponent: 8,
|
||||
colorSpace: "DeviceRGB",
|
||||
data: rgb,
|
||||
alpha: hasTransparency ? alpha : undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
throw asPdfError(
|
||||
PDF_CODES.IMAGE_DECODE_FAILED,
|
||||
err,
|
||||
"PNG decode failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate image element props — raises typed errors on bad input. */
|
||||
export function validateImageProps(props: ImageElementOptions): void {
|
||||
if (props.width <= 0) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.INVALID_ELEMENT,
|
||||
"Image width must be positive"
|
||||
);
|
||||
}
|
||||
if (props.height <= 0) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.INVALID_ELEMENT,
|
||||
"Image height must be positive"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* High-level image element. Decodes JPEG / PNG at construction time and
|
||||
* emits a content-stream snippet that paints the registered XObject at
|
||||
* the requested position + size.
|
||||
*/
|
||||
export class ImageElement {
|
||||
readonly options: Readonly<ImageElementOptions>;
|
||||
readonly decoded: DecodedImage;
|
||||
|
||||
constructor(options: ImageElementOptions) {
|
||||
this.options = parseOrThrow(
|
||||
ImageElementOptionsSchema,
|
||||
options,
|
||||
PDF_CODES.INVALID_ELEMENT,
|
||||
"invalid ImageElement options"
|
||||
);
|
||||
validateImageProps(this.options);
|
||||
this.decoded = decodeImage(this.options.src);
|
||||
if (
|
||||
this.options.format &&
|
||||
this.options.format !== this.decoded.format
|
||||
) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.IMAGE_DECODE_FAILED,
|
||||
`declared format ${this.options.format} does not match detected ${this.decoded.format}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Logical size of the element on the page. */
|
||||
measure(): ElementMeasurement {
|
||||
return { width: this.options.width, height: this.options.height };
|
||||
}
|
||||
|
||||
/** Render the image XObject reference into a content stream. */
|
||||
render(context: ElementRenderContext): ElementRenderResult {
|
||||
if (context.signal?.aborted) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.ABORTED,
|
||||
"ImageElement.render aborted"
|
||||
);
|
||||
}
|
||||
const resourceName = context.fontResourceName;
|
||||
// For images we reuse fontResourceName slot as the XObject name
|
||||
// since render contexts are shared. The renderer (Chunk D) passes
|
||||
// the actual XObject resource name through this channel.
|
||||
if (!resourceName) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.RENDER_FAILED,
|
||||
"ImageElement.render requires xobject resourceName"
|
||||
);
|
||||
}
|
||||
const encoder = new ContentStreamEncoder();
|
||||
encoder.saveGraphicsState();
|
||||
encoder.concatMatrix(
|
||||
this.options.width,
|
||||
0,
|
||||
0,
|
||||
this.options.height,
|
||||
context.x,
|
||||
context.y - this.options.height
|
||||
);
|
||||
encoder.drawImage(resourceName);
|
||||
encoder.restoreGraphicsState();
|
||||
return {
|
||||
stream: encoder.encode(),
|
||||
width: this.options.width,
|
||||
height: this.options.height,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read width + height out of a JPEG SOF marker without decoding pixels.
|
||||
* Required because PDF embeds JPEG bytes verbatim via `DCTDecode`.
|
||||
*/
|
||||
function readJpegDimensions(data: Uint8Array): {
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
} {
|
||||
let offset = 2; // skip SOI
|
||||
while (offset < data.length) {
|
||||
if (data[offset] !== 0xff) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.IMAGE_DECODE_FAILED,
|
||||
"invalid JPEG marker"
|
||||
);
|
||||
}
|
||||
const marker = data[offset + 1];
|
||||
if (marker === undefined) {
|
||||
throw new PdfError(PDF_CODES.IMAGE_DECODE_FAILED, "truncated JPEG");
|
||||
}
|
||||
// SOF0..SOF15 except DHT(0xC4), DAC(0xCC), DNL(0xDC):
|
||||
if (
|
||||
marker >= 0xc0 &&
|
||||
marker <= 0xcf &&
|
||||
marker !== 0xc4 &&
|
||||
marker !== 0xc8 &&
|
||||
marker !== 0xcc
|
||||
) {
|
||||
const height =
|
||||
((data[offset + 5] as number) << 8) |
|
||||
(data[offset + 6] as number);
|
||||
const width =
|
||||
((data[offset + 7] as number) << 8) |
|
||||
(data[offset + 8] as number);
|
||||
return { width, height };
|
||||
}
|
||||
const segLen =
|
||||
((data[offset + 2] as number) << 8) | (data[offset + 3] as number);
|
||||
offset += 2 + segLen;
|
||||
}
|
||||
throw new PdfError(
|
||||
PDF_CODES.IMAGE_DECODE_FAILED,
|
||||
"JPEG SOF marker not found"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Supported PDF image formats. */
|
||||
export type PdfImageFormat = "jpeg" | "png";
|
||||
|
||||
/** Decoded image ready to be embedded as an XObject. */
|
||||
export interface DecodedImage {
|
||||
readonly format: PdfImageFormat;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly bitsPerComponent: number;
|
||||
readonly colorSpace: "DeviceRGB" | "DeviceGray";
|
||||
/** Pixel data — RGB(A)/Gray bytes for PNG, raw bytes for JPEG. */
|
||||
readonly data: Uint8Array;
|
||||
/** Optional separate alpha (SMask) plane for PNGs with transparency. */
|
||||
readonly alpha?: Uint8Array | undefined;
|
||||
}
|
||||
|
||||
/** Options accepted by {@link ImageElement}. */
|
||||
export interface ImageElementOptions {
|
||||
readonly src: Uint8Array;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly format?: PdfImageFormat | undefined;
|
||||
readonly alt?: string | undefined;
|
||||
}
|
||||
13
packages/devtools/docs/pdf/src/image-element/index.ts
Normal file
13
packages/devtools/docs/pdf/src/image-element/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
decodeImage,
|
||||
detectImageFormat,
|
||||
ImageElement,
|
||||
ImageElementOptionsSchema,
|
||||
scaleToFit,
|
||||
validateImageProps,
|
||||
} from "./image-element.js";
|
||||
export type {
|
||||
DecodedImage,
|
||||
ImageElementOptions,
|
||||
PdfImageFormat,
|
||||
} from "./image-element.types.js";
|
||||
@@ -0,0 +1,81 @@
|
||||
# image-registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `ImageRegistry` allocates and reuses PDF Image XObjects across the
|
||||
lifetime of a single `renderDocument` call. Each unique `ImageElement`
|
||||
instance maps to exactly one PDF indirect object, regardless of how
|
||||
many pages reference it. Per-page resource maps share the underlying
|
||||
object by id so a multi-page document with a repeating logo never
|
||||
allocates a copy per page. The module also owns the lower-level
|
||||
`registerImageXObject` helper that builds the canonical `/XObject
|
||||
/Image` dictionary, choosing `/DCTDecode` for JPEGs and `/FlateDecode`
|
||||
for decoded PNG RGB planes.
|
||||
|
||||
## Features
|
||||
|
||||
- One PDF object per `ImageElement` (identity-keyed, not src-hash).
|
||||
- Per-page `pageXObjects` map populated as a side-effect of `ensure`.
|
||||
- JPEG passthrough via `/DCTDecode` (no recompression).
|
||||
- PNG RGB plane re-emission via `/FlateDecode`.
|
||||
- `DeviceRGB` / `DeviceGray` colour-space selection follows the
|
||||
decoder output.
|
||||
- `BitsPerComponent` carried straight from the decoded image.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ImageRegistry } from "@suaveplan/pdf/image-registry";
|
||||
import { ImageElement, PdfWriter } from "@suaveplan/pdf";
|
||||
|
||||
const writer = new PdfWriter();
|
||||
const registry = new ImageRegistry(writer);
|
||||
const pageMap = new Map<string, number>();
|
||||
const image = new ImageElement({ src: pngBytes, width: 100, height: 50 });
|
||||
const resourceName = registry.ensure(image, pageMap);
|
||||
// resourceName === "Im<id>"; pageMap now contains the entry.
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { ImageRegistry, registerImageXObject } from "@suaveplan/pdf/image-registry";
|
||||
import { ImageElement, PdfWriter } from "@suaveplan/pdf";
|
||||
|
||||
const writer = new PdfWriter();
|
||||
const registry = new ImageRegistry(writer);
|
||||
const logo = new ImageElement({ src: pngBytes, width: 80, height: 24 });
|
||||
// Share `logo` across three pages — only one XObject is allocated.
|
||||
for (const pageMap of pageMaps) {
|
||||
registry.ensure(logo, pageMap);
|
||||
}
|
||||
// Build a JPEG XObject directly (e.g. from a pre-decoded blob).
|
||||
const jpegId = registerImageXObject(writer, decodedJpeg);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class ImageRegistry` — constructed with a `PdfWriter`. Exposes
|
||||
`ensure(element, pageXObjects)` which returns the `Im<id>` resource
|
||||
name and side-effects the supplied per-page map.
|
||||
- `registerImageXObject(writer, decoded)` → `number` — low-level
|
||||
helper used by `ImageRegistry.ensure`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry keys by `ImageElement` instance identity rather than
|
||||
content hash. This is intentional: callers that share the same
|
||||
element across pages benefit from the cache automatically, while
|
||||
callers that construct two `ImageElement`s with identical bytes pay
|
||||
for two PDF objects — the safe assumption when the bytes themselves
|
||||
might differ after `decodeImage` runs (e.g. PNG alpha extraction).
|
||||
`registerImageXObject` packs the dictionary entries in a fixed order
|
||||
(`/Type`, `/Subtype`, `/Width`, `/Height`, `/ColorSpace`,
|
||||
`/BitsPerComponent`, optional `/Filter`) so equal inputs produce
|
||||
byte-identical objects, preserving the writer's xref-byte-offset
|
||||
invariant. JPEG bytes are passed through unchanged via `"None"`
|
||||
because the JPEG bitstream itself is what `/DCTDecode` consumes.
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PNG } from "pngjs";
|
||||
import { ImageElement } from "../image-element/image-element.js";
|
||||
import { PdfWriter } from "../pdf-writer/pdf-writer.js";
|
||||
import { ImageRegistry, registerImageXObject } from "./image-registry.js";
|
||||
|
||||
const makePng = (): Uint8Array => {
|
||||
const png = new PNG({ width: 2, height: 2 });
|
||||
for (let i = 0; i < png.data.length; i++) png.data[i] = i & 0xff;
|
||||
return new Uint8Array(PNG.sync.write(png));
|
||||
};
|
||||
|
||||
// Minimal JPEG SOI + SOF0 + EOI for size = 4×4.
|
||||
const JPEG_BYTES = new Uint8Array([
|
||||
0xff, 0xd8, 0xff, 0xc0, 0x00, 0x11, 0x08, 0x00, 0x04, 0x00, 0x04, 0x03,
|
||||
0x01, 0x22, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xff, 0xd9,
|
||||
]);
|
||||
|
||||
describe("ImageRegistry", () => {
|
||||
it("ensures a fresh registration on first call", { timeout: 5000 }, () => {
|
||||
const writer = new PdfWriter();
|
||||
const registry = new ImageRegistry(writer);
|
||||
const png = makePng();
|
||||
const element = new ImageElement({ src: png, width: 10, height: 10 });
|
||||
const pageMap = new Map<string, number>();
|
||||
const name = registry.ensure(element, pageMap);
|
||||
expect(name).toMatch(/^Im\d+$/);
|
||||
expect(pageMap.size).toBe(1);
|
||||
});
|
||||
|
||||
it("reuses the same object on subsequent calls", { timeout: 5000 }, () => {
|
||||
const writer = new PdfWriter();
|
||||
const registry = new ImageRegistry(writer);
|
||||
const png = makePng();
|
||||
const element = new ImageElement({ src: png, width: 10, height: 10 });
|
||||
const pageMap1 = new Map<string, number>();
|
||||
const pageMap2 = new Map<string, number>();
|
||||
const name1 = registry.ensure(element, pageMap1);
|
||||
const name2 = registry.ensure(element, pageMap2);
|
||||
expect(name1).toBe(name2);
|
||||
});
|
||||
|
||||
it("allocates separate objects for distinct elements", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const writer = new PdfWriter();
|
||||
const registry = new ImageRegistry(writer);
|
||||
const png = makePng();
|
||||
const a = new ImageElement({ src: png, width: 10, height: 10 });
|
||||
const b = new ImageElement({ src: png, width: 10, height: 10 });
|
||||
const m = new Map<string, number>();
|
||||
const nameA = registry.ensure(a, m);
|
||||
const nameB = registry.ensure(b, m);
|
||||
expect(nameA).not.toBe(nameB);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerImageXObject", () => {
|
||||
it("registers a JPEG XObject via DCTDecode", { timeout: 5000 }, () => {
|
||||
const writer = new PdfWriter();
|
||||
const png = new ImageElement({
|
||||
src: JPEG_BYTES,
|
||||
width: 4,
|
||||
height: 4,
|
||||
});
|
||||
const id = registerImageXObject(writer, png.decoded);
|
||||
const bytes = writer.generate();
|
||||
const decoded = new TextDecoder().decode(bytes);
|
||||
expect(decoded).toContain("/DCTDecode");
|
||||
expect(id).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("registers a PNG XObject via FlateDecode", { timeout: 5000 }, () => {
|
||||
const writer = new PdfWriter();
|
||||
const png = new ImageElement({
|
||||
src: makePng(),
|
||||
width: 2,
|
||||
height: 2,
|
||||
});
|
||||
const id = registerImageXObject(writer, png.decoded);
|
||||
const bytes = writer.generate();
|
||||
const decoded = new TextDecoder().decode(bytes);
|
||||
expect(decoded).toContain("/FlateDecode");
|
||||
expect(id).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ImageElement } from "../image-element/image-element.js";
|
||||
import type { DecodedImage } from "../image-element/image-element.types.js";
|
||||
import type { PdfWriter } from "../pdf-writer/pdf-writer.js";
|
||||
import type { PdfValue } from "../pdf-writer/pdf-writer.types.js";
|
||||
|
||||
/**
|
||||
* Bookkeeping for image XObjects allocated during a single `renderDocument`
|
||||
* call. Each {@link ImageElement} instance maps to exactly one registered
|
||||
* XObject; per-page resource maps reference the shared object by id +
|
||||
* `Im<id>` name. Sharing across pages keeps multi-page documents that
|
||||
* repeat a logo from allocating a copy per page.
|
||||
*/
|
||||
export class ImageRegistry {
|
||||
private readonly byElement = new Map<
|
||||
ImageElement,
|
||||
{ name: string; id: number }
|
||||
>();
|
||||
|
||||
constructor(private readonly writer: PdfWriter) {}
|
||||
|
||||
/**
|
||||
* Look up or allocate the XObject for `element` and add it to the
|
||||
* supplied `pageXObjects` resource map. Returns the `Im<id>` resource
|
||||
* name used inside content streams.
|
||||
*/
|
||||
ensure(element: ImageElement, pageXObjects: Map<string, number>): string {
|
||||
const cached = this.byElement.get(element);
|
||||
if (cached) {
|
||||
pageXObjects.set(cached.name, cached.id);
|
||||
return cached.name;
|
||||
}
|
||||
const id = registerImageXObject(this.writer, element.decoded);
|
||||
const name = `Im${id}`;
|
||||
this.byElement.set(element, { name, id });
|
||||
pageXObjects.set(name, id);
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate a PDF Image XObject for `decoded`. JPEG bytes are embedded
|
||||
* verbatim under `/DCTDecode`; PNG bytes (post-decode RGB plane) are
|
||||
* Flate-compressed.
|
||||
*/
|
||||
export function registerImageXObject(
|
||||
writer: PdfWriter,
|
||||
decoded: DecodedImage
|
||||
): number {
|
||||
const extras: Array<readonly [string, PdfValue]> = [
|
||||
["/Type", { kind: "name", value: "/XObject" }],
|
||||
["/Subtype", { kind: "name", value: "/Image" }],
|
||||
["/Width", { kind: "number", value: decoded.width }],
|
||||
["/Height", { kind: "number", value: decoded.height }],
|
||||
["/ColorSpace", { kind: "name", value: `/${decoded.colorSpace}` }],
|
||||
[
|
||||
"/BitsPerComponent",
|
||||
{ kind: "number", value: decoded.bitsPerComponent },
|
||||
],
|
||||
];
|
||||
if (decoded.format === "jpeg") {
|
||||
extras.push(["/Filter", { kind: "name", value: "/DCTDecode" }]);
|
||||
return writer.addStream(decoded.data, "None", extras);
|
||||
}
|
||||
return writer.addStream(decoded.data, "FlateDecode", extras);
|
||||
}
|
||||
1
packages/devtools/docs/pdf/src/image-registry/index.ts
Normal file
1
packages/devtools/docs/pdf/src/image-registry/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ImageRegistry, registerImageXObject } from "./image-registry.js";
|
||||
266
packages/devtools/docs/pdf/src/index.ts
Normal file
266
packages/devtools/docs/pdf/src/index.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* `@suaveplan/pdf` — server-tier PDF 1.4 generation toolkit.
|
||||
*
|
||||
* Chunk A surface: typed error registry, Zod boundary schemas,
|
||||
* OpenTelemetry counters + histograms, unit + colour utilities, page-size
|
||||
* + margin types.
|
||||
*
|
||||
* Chunk B surface: PDF 1.4 binary writer (`PdfWriter`, object encoder,
|
||||
* font registry, FlateDecode streams, AbortSignal-aware `generate`) and
|
||||
* the chainable `ContentStreamEncoder` for text + graphics + path +
|
||||
* image operators.
|
||||
*
|
||||
* Chunk C surface: document-element primitives — `TextElement`,
|
||||
* `TableElement`, `ImageElement`, `DividerElement` — each with Zod
|
||||
* boundary validation, `measure()` / `render()` separation, and
|
||||
* AbortSignal propagation.
|
||||
*
|
||||
* Chunk D surface: `StyleResolver` cascade with default theme,
|
||||
* `LayoutEngine` (block flow, page-break detection, header/footer
|
||||
* slots), `DocumentBuilder` fluent API (`createDocument` factory,
|
||||
* `addText` / `addTable` / `addImage` / `addDivider` / `newPage` /
|
||||
* `setHeader` / `setFooter` / `build`), and the orchestrating
|
||||
* `renderDocument(doc, opts)` entry point returning a real PDF 1.4
|
||||
* `Uint8Array` round-trippable via `pdf-parse`.
|
||||
*
|
||||
* Chunk E surface: three pre-built templates — `createReceiptTemplate`,
|
||||
* `createReportTemplate`, `createInvoiceTemplate` — each consuming a
|
||||
* typed payload (Zod-validated) and producing a `Document<TData>`
|
||||
* ready for `renderDocument`. The follow-up React preview component
|
||||
* is tracked in the `enterprise-react-pdf-preview` proposal.
|
||||
*/
|
||||
|
||||
export type { PdfColor, RgbColor } from "./colors/colors.js";
|
||||
export {
|
||||
COLORS,
|
||||
darken,
|
||||
hexToPdfColor,
|
||||
hexToRgb,
|
||||
isTransparent,
|
||||
lighten,
|
||||
rgbToHex,
|
||||
rgbToPdfColor,
|
||||
} from "./colors/colors.js";
|
||||
export { ContentStreamEncoder } from "./content-stream/content-stream.js";
|
||||
export type {
|
||||
ContentStreamColor,
|
||||
ContentStreamOptions,
|
||||
} from "./content-stream/content-stream.types.js";
|
||||
export {
|
||||
computeDividerSpacing,
|
||||
DIVIDER_DEFAULTS,
|
||||
DividerElement,
|
||||
DividerElementOptionsSchema,
|
||||
} from "./divider-element/divider-element.js";
|
||||
export type {
|
||||
DividerElementOptions,
|
||||
DividerOrientation,
|
||||
} from "./divider-element/divider-element.types.js";
|
||||
export {
|
||||
createDocument,
|
||||
DocumentBuilder,
|
||||
} from "./document-builder/document-builder.js";
|
||||
export type {
|
||||
BuiltPageDefinition,
|
||||
DividerNode,
|
||||
Document,
|
||||
DocumentNode,
|
||||
DocumentNodeType,
|
||||
ImageNode,
|
||||
TableNode,
|
||||
TextNode,
|
||||
} from "./document-builder/document-builder.types.js";
|
||||
export type { PdfErrorCode } from "./error-codes/error-codes.js";
|
||||
export {
|
||||
asPdfError,
|
||||
PDF_CODES,
|
||||
PdfError,
|
||||
} from "./error-codes/error-codes.js";
|
||||
export {
|
||||
decodeImage,
|
||||
detectImageFormat,
|
||||
ImageElement,
|
||||
ImageElementOptionsSchema,
|
||||
scaleToFit,
|
||||
validateImageProps,
|
||||
} from "./image-element/image-element.js";
|
||||
export type {
|
||||
DecodedImage,
|
||||
ImageElementOptions,
|
||||
PdfImageFormat,
|
||||
} from "./image-element/image-element.types.js";
|
||||
export {
|
||||
ImageRegistry,
|
||||
registerImageXObject,
|
||||
} from "./image-registry/image-registry.js";
|
||||
export { LayoutEngine } from "./layout-engine/layout-engine.js";
|
||||
export type {
|
||||
AdvanceResult,
|
||||
LayoutBox,
|
||||
LayoutCursor,
|
||||
LayoutEngineOptions,
|
||||
PageSlotMetrics,
|
||||
} from "./layout-engine/layout-engine.types.js";
|
||||
export {
|
||||
encodePdfValue,
|
||||
escapePdfLiteralString,
|
||||
formatPdfDate,
|
||||
formatPdfNumber,
|
||||
PdfWriter,
|
||||
STANDARD_FONTS,
|
||||
} from "./pdf-writer/pdf-writer.js";
|
||||
export type {
|
||||
PdfObjectRef,
|
||||
PdfPageInput,
|
||||
PdfStreamFilter,
|
||||
PdfValue,
|
||||
PdfWriterMetadata,
|
||||
PdfWriterOptions,
|
||||
} from "./pdf-writer/pdf-writer.types.js";
|
||||
export type {
|
||||
PipelineState,
|
||||
PlacementResult,
|
||||
} from "./render-pipeline/render-pipeline.js";
|
||||
export {
|
||||
pickFontResource,
|
||||
placeNode,
|
||||
renderSlot,
|
||||
renderTableSlice,
|
||||
} from "./render-pipeline/render-pipeline.js";
|
||||
export { renderDocument } from "./renderer/renderer.js";
|
||||
export type {
|
||||
RenderOptions,
|
||||
RenderResult,
|
||||
} from "./renderer/renderer.types.js";
|
||||
export {
|
||||
DocumentMetadataSchema,
|
||||
HexColorSchema,
|
||||
MarginsSchema,
|
||||
PageConfigSchema,
|
||||
parseHexColor,
|
||||
parseOrThrow,
|
||||
RgbColorSchema,
|
||||
UnitSchema,
|
||||
} from "./schemas/schemas.js";
|
||||
export {
|
||||
DEFAULT_STYLE,
|
||||
DEFAULT_THEME,
|
||||
mergeStyles,
|
||||
mergeTheme,
|
||||
normalizeSpacing,
|
||||
StyleResolver,
|
||||
} from "./style-resolver/style-resolver.js";
|
||||
export type {
|
||||
BorderStyle,
|
||||
PdfStyle,
|
||||
PdfTheme,
|
||||
ResolvedSpacing,
|
||||
SpacingValue,
|
||||
ThemeColors,
|
||||
ThemeSpacing,
|
||||
ThemeTypography,
|
||||
} from "./style-resolver/style-resolver.types.js";
|
||||
export {
|
||||
computeColumnWidths,
|
||||
TableElement,
|
||||
TableElementOptionsSchema,
|
||||
} from "./table-element/table-element.js";
|
||||
export type {
|
||||
TableColumn,
|
||||
TableElementOptions,
|
||||
TablePageRender,
|
||||
} from "./table-element/table-element.types.js";
|
||||
export {
|
||||
createPdfObservability,
|
||||
recordDocumentRendered,
|
||||
recordDocumentSize,
|
||||
recordPagesEmitted,
|
||||
recordRenderDuration,
|
||||
recordRenderError,
|
||||
recordValidationFailure,
|
||||
} from "./telemetry/telemetry.js";
|
||||
export type {
|
||||
PdfCounters,
|
||||
PdfHistograms,
|
||||
PdfObservability,
|
||||
PdfObservabilityOptions,
|
||||
} from "./telemetry/telemetry.types.js";
|
||||
export type { InvoiceTotals } from "./templates/invoice-template/invoice-template.js";
|
||||
export {
|
||||
computeInvoiceTotals,
|
||||
createInvoiceTemplate,
|
||||
formatInvoiceAmount,
|
||||
InvoiceDataSchema,
|
||||
} from "./templates/invoice-template/invoice-template.js";
|
||||
export type {
|
||||
InvoiceAddress,
|
||||
InvoiceData,
|
||||
InvoiceDocument,
|
||||
InvoiceLineItem,
|
||||
InvoiceTemplateOptions,
|
||||
} from "./templates/invoice-template/invoice-template.types.js";
|
||||
export type { ReceiptTotals } from "./templates/receipt-template/receipt-template.js";
|
||||
export {
|
||||
computeReceiptTotals,
|
||||
createReceiptTemplate,
|
||||
formatReceiptAmount,
|
||||
ReceiptDataSchema,
|
||||
} from "./templates/receipt-template/receipt-template.js";
|
||||
export type {
|
||||
ReceiptData,
|
||||
ReceiptDocument,
|
||||
ReceiptLineItem,
|
||||
ReceiptMerchant,
|
||||
ReceiptTemplateOptions,
|
||||
} from "./templates/receipt-template/receipt-template.types.js";
|
||||
export {
|
||||
createReportTemplate,
|
||||
ReportDataSchema,
|
||||
} from "./templates/report-template/report-template.js";
|
||||
export type {
|
||||
ReportColumn,
|
||||
ReportData,
|
||||
ReportDocument,
|
||||
ReportSection,
|
||||
ReportTemplateOptions,
|
||||
} from "./templates/report-template/report-template.types.js";
|
||||
export {
|
||||
measureLineWidth,
|
||||
resolveStandardFont,
|
||||
TextElement,
|
||||
TextElementOptionsSchema,
|
||||
wrapText,
|
||||
} from "./text-element/text-element.js";
|
||||
export type {
|
||||
ElementMeasurement,
|
||||
ElementRenderContext,
|
||||
ElementRenderResult,
|
||||
PdfFontFamily,
|
||||
PdfFontStyle,
|
||||
PdfFontWeight,
|
||||
PdfTextAlign,
|
||||
TextElementOptions,
|
||||
} from "./text-element/text-element.types.js";
|
||||
export type {
|
||||
DocumentDefinition,
|
||||
DocumentMetadata,
|
||||
ElementNode,
|
||||
Margins,
|
||||
PageConfig,
|
||||
PageDefinition,
|
||||
PageOrientation,
|
||||
} from "./types/page-size.js";
|
||||
export {
|
||||
DEFAULT_MARGINS,
|
||||
DEFAULT_PAGE_CONFIG,
|
||||
PAGE_DIMENSIONS,
|
||||
PageSize,
|
||||
} from "./types/page-size.js";
|
||||
export type { Unit } from "./units/units.js";
|
||||
export {
|
||||
convert,
|
||||
formatUnit,
|
||||
fromPoints,
|
||||
parseUnit,
|
||||
toPoints,
|
||||
} from "./units/units.js";
|
||||
8
packages/devtools/docs/pdf/src/layout-engine/index.ts
Normal file
8
packages/devtools/docs/pdf/src/layout-engine/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { LayoutEngine } from "./layout-engine.js";
|
||||
export type {
|
||||
AdvanceResult,
|
||||
LayoutBox,
|
||||
LayoutCursor,
|
||||
LayoutEngineOptions,
|
||||
PageSlotMetrics,
|
||||
} from "./layout-engine.types.js";
|
||||
@@ -0,0 +1,97 @@
|
||||
# layout-engine
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `LayoutEngine` owns the block-flow geometry the renderer walks to
|
||||
position each element on the page. It computes effective page
|
||||
dimensions (accounting for orientation), the inner content area
|
||||
(margins minus header and footer slot heights), and the per-page
|
||||
cursor state. It also detects when an element does not fit so the
|
||||
renderer can flush the current page and start a fresh one. The engine
|
||||
never emits PDF operators directly — that remains the renderer's
|
||||
responsibility — keeping layout decisions deterministic and side-
|
||||
effect-free.
|
||||
|
||||
## Features
|
||||
|
||||
- Cursor model with `x`, `y`, `availableWidth`, `availableHeight`,
|
||||
`pageIndex` tracking the in-flight flow position.
|
||||
- `getPageDimensions` / `getContentArea` swap dimensions for landscape
|
||||
pages and respect the configured `headerHeight` + `footerHeight`.
|
||||
- `advance` returns an `AdvanceResult` (`fit`, `remaining`) so the
|
||||
renderer can branch without re-querying the cursor.
|
||||
- `nextPage` resets the cursor to the top of a fresh page and bumps
|
||||
`pageIndex`.
|
||||
- `getHeaderArea` / `getFooterArea` expose the reserved slot
|
||||
rectangles for the renderer to draw repeating page furniture.
|
||||
- `computeTextHeight` mirrors `TextElement`'s 0.5em-per-glyph
|
||||
approximation so layout and measurement remain consistent.
|
||||
- AbortSignal-aware: every flow operation throws `PDF.ABORTED` if the
|
||||
supplied signal trips.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { LayoutEngine, DEFAULT_PAGE_CONFIG } from "@suaveplan/pdf";
|
||||
|
||||
const layout = new LayoutEngine({ defaultPage: DEFAULT_PAGE_CONFIG });
|
||||
const cursor = layout.createCursor();
|
||||
const fit = layout.advance(cursor, 100);
|
||||
if (!fit.fit) {
|
||||
layout.nextPage(cursor);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { LayoutEngine, DEFAULT_PAGE_CONFIG, PageSize } from "@suaveplan/pdf";
|
||||
|
||||
const controller = new AbortController();
|
||||
const layout = new LayoutEngine({
|
||||
defaultPage: { ...DEFAULT_PAGE_CONFIG, size: PageSize.LETTER },
|
||||
headerHeight: 48,
|
||||
footerHeight: 36,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const header = layout.getHeaderArea();
|
||||
const footer = layout.getFooterArea();
|
||||
const cursor = layout.createCursor();
|
||||
// Reserved bands keep flow content away from header / footer:
|
||||
// cursor.y starts at (pageHeight - margin.top - headerHeight)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class LayoutEngine<TConfig extends PageConfig>` —
|
||||
`getSlotMetrics()`, `getPageDimensions(config?)`,
|
||||
`getContentArea(config?)`, `getHeaderArea(config?)`,
|
||||
`getFooterArea(config?)`, `createCursor(config?)`,
|
||||
`advance(cursor, height, config?)`, `nextPage(cursor, config?)`,
|
||||
`computeTextHeight(text, style, maxWidth)`,
|
||||
`applyMargin(box, margin)`.
|
||||
- Constructor options: `defaultPage`, `headerHeight`, `footerHeight`,
|
||||
`signal`.
|
||||
- Types: `LayoutBox`, `LayoutCursor`, `LayoutEngineOptions`,
|
||||
`PageSlotMetrics`, `AdvanceResult`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The cursor stores `y` in PDF user-space coordinates (origin
|
||||
bottom-left). `createCursor` therefore initialises `y` to
|
||||
`pageHeight - margins.top - headerHeight` so the top of the content
|
||||
area maps to the largest `y` value. `advance` decreases `y` and
|
||||
`availableHeight` in lock-step; the fit check compares the post-
|
||||
advance `y` against the bottom edge of the content area (computed as
|
||||
`pageHeight - top - innerHeight`). The header / footer rectangles are
|
||||
reported in the same coordinate system to keep renderer arithmetic
|
||||
uniform. `applyMargin` reuses `normalizeSpacing` from the style
|
||||
resolver so `SpacingValue` inputs (scalar or per-side) both work.
|
||||
Validation of negative margins or oversized header/footer values is
|
||||
performed eagerly in the constructor and on `getContentArea` so
|
||||
malformed inputs surface as typed `PDF.LAYOUT_OVERFLOW` /
|
||||
`PDF.INVALID_PAGE_CONFIG` rather than producing nonsense coordinates.
|
||||
@@ -0,0 +1,229 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PdfError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
DEFAULT_PAGE_CONFIG,
|
||||
PAGE_DIMENSIONS,
|
||||
PageSize,
|
||||
} from "../types/page-size.js";
|
||||
import { LayoutEngine } from "./layout-engine.js";
|
||||
|
||||
describe("LayoutEngine — geometry", () => {
|
||||
it("returns portrait dimensions by default", { timeout: 5000 }, () => {
|
||||
const engine = new LayoutEngine();
|
||||
const dims = engine.getPageDimensions();
|
||||
expect(dims).toEqual(PAGE_DIMENSIONS[PageSize.A4]);
|
||||
});
|
||||
|
||||
it("swaps width/height for landscape", { timeout: 5000 }, () => {
|
||||
const engine = new LayoutEngine();
|
||||
const dims = engine.getPageDimensions({ orientation: "landscape" });
|
||||
expect(dims.width).toBe(PAGE_DIMENSIONS[PageSize.A4].height);
|
||||
expect(dims.height).toBe(PAGE_DIMENSIONS[PageSize.A4].width);
|
||||
});
|
||||
|
||||
it("getContentArea respects margins", { timeout: 5000 }, () => {
|
||||
const engine = new LayoutEngine();
|
||||
const area = engine.getContentArea();
|
||||
expect(area.x).toBe(DEFAULT_PAGE_CONFIG.margins.left);
|
||||
expect(area.y).toBe(DEFAULT_PAGE_CONFIG.margins.top);
|
||||
});
|
||||
|
||||
it("getContentArea reserves header + footer slot heights", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const engine = new LayoutEngine({
|
||||
headerHeight: 40,
|
||||
footerHeight: 20,
|
||||
});
|
||||
const area = engine.getContentArea();
|
||||
const dims = engine.getPageDimensions();
|
||||
const expectedHeight =
|
||||
dims.height -
|
||||
DEFAULT_PAGE_CONFIG.margins.top -
|
||||
DEFAULT_PAGE_CONFIG.margins.bottom -
|
||||
40 -
|
||||
20;
|
||||
expect(area.height).toBe(expectedHeight);
|
||||
expect(area.y).toBe(DEFAULT_PAGE_CONFIG.margins.top + 40);
|
||||
});
|
||||
|
||||
it("getHeaderArea returns the header band rectangle", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const engine = new LayoutEngine({ headerHeight: 48 });
|
||||
const header = engine.getHeaderArea();
|
||||
expect(header.height).toBe(48);
|
||||
});
|
||||
|
||||
it("getFooterArea returns the footer band rectangle", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const engine = new LayoutEngine({ footerHeight: 36 });
|
||||
const footer = engine.getFooterArea();
|
||||
expect(footer.height).toBe(36);
|
||||
});
|
||||
|
||||
it("getSlotMetrics returns the configured slots", { timeout: 5000 }, () => {
|
||||
const engine = new LayoutEngine({
|
||||
headerHeight: 10,
|
||||
footerHeight: 20,
|
||||
});
|
||||
expect(engine.getSlotMetrics()).toEqual({
|
||||
headerHeight: 10,
|
||||
footerHeight: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects negative header height in constructor", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => new LayoutEngine({ headerHeight: -1 })).toThrow(PdfError);
|
||||
});
|
||||
|
||||
it("rejects negative footer height in constructor", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => new LayoutEngine({ footerHeight: -10 })).toThrow(PdfError);
|
||||
});
|
||||
|
||||
it("raises LAYOUT_OVERFLOW when slots exceed page height", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const engine = new LayoutEngine({
|
||||
headerHeight: 5000,
|
||||
footerHeight: 5000,
|
||||
});
|
||||
expect(() => engine.getContentArea()).toThrow(PdfError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LayoutEngine — cursor flow", () => {
|
||||
it("createCursor anchors at top of content area", { timeout: 5000 }, () => {
|
||||
const engine = new LayoutEngine();
|
||||
const cursor = engine.createCursor();
|
||||
const dims = engine.getPageDimensions();
|
||||
expect(cursor.y).toBe(dims.height - DEFAULT_PAGE_CONFIG.margins.top);
|
||||
expect(cursor.pageIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("advance succeeds when content fits", { timeout: 5000 }, () => {
|
||||
const engine = new LayoutEngine();
|
||||
const cursor = engine.createCursor();
|
||||
const result = engine.advance(cursor, 100);
|
||||
expect(result.fit).toBe(true);
|
||||
expect(result.remaining).toBe(cursor.availableHeight);
|
||||
});
|
||||
|
||||
it("advance returns fit=false when overflow", { timeout: 5000 }, () => {
|
||||
const engine = new LayoutEngine();
|
||||
const cursor = engine.createCursor();
|
||||
const huge = cursor.availableHeight + 100;
|
||||
const result = engine.advance(cursor, huge);
|
||||
expect(result.fit).toBe(false);
|
||||
});
|
||||
|
||||
it("advance rejects non-finite heights", { timeout: 5000 }, () => {
|
||||
const engine = new LayoutEngine();
|
||||
const cursor = engine.createCursor();
|
||||
expect(() => engine.advance(cursor, Number.NaN)).toThrow(PdfError);
|
||||
});
|
||||
|
||||
it("advance rejects negative heights", { timeout: 5000 }, () => {
|
||||
const engine = new LayoutEngine();
|
||||
const cursor = engine.createCursor();
|
||||
expect(() => engine.advance(cursor, -5)).toThrow(PdfError);
|
||||
});
|
||||
|
||||
it("nextPage resets cursor and bumps pageIndex", { timeout: 5000 }, () => {
|
||||
const engine = new LayoutEngine();
|
||||
const cursor = engine.createCursor();
|
||||
engine.advance(cursor, 200);
|
||||
engine.nextPage(cursor);
|
||||
expect(cursor.pageIndex).toBe(1);
|
||||
const dims = engine.getPageDimensions();
|
||||
expect(cursor.y).toBe(dims.height - DEFAULT_PAGE_CONFIG.margins.top);
|
||||
});
|
||||
|
||||
it("computeTextHeight handles empty strings", { timeout: 5000 }, () => {
|
||||
const engine = new LayoutEngine();
|
||||
const h = engine.computeTextHeight("", { fontSize: 12 }, 400);
|
||||
expect(h).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("computeTextHeight wraps long strings", { timeout: 5000 }, () => {
|
||||
const engine = new LayoutEngine();
|
||||
const short = engine.computeTextHeight("abc", { fontSize: 12 }, 400);
|
||||
const long = engine.computeTextHeight(
|
||||
"x".repeat(1000),
|
||||
{ fontSize: 12 },
|
||||
400
|
||||
);
|
||||
expect(long).toBeGreaterThan(short);
|
||||
});
|
||||
|
||||
it("computeTextHeight handles paragraph breaks", { timeout: 5000 }, () => {
|
||||
const engine = new LayoutEngine();
|
||||
const h = engine.computeTextHeight("line1\n\nline3", {}, 400);
|
||||
expect(h).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("computeTextHeight rejects zero maxWidth", { timeout: 5000 }, () => {
|
||||
const engine = new LayoutEngine();
|
||||
expect(() => engine.computeTextHeight("abc", {}, 0)).toThrow(PdfError);
|
||||
});
|
||||
|
||||
it("applyMargin shrinks the box correctly", { timeout: 5000 }, () => {
|
||||
const engine = new LayoutEngine();
|
||||
const inner = engine.applyMargin(
|
||||
{ x: 0, y: 100, width: 200, height: 100 },
|
||||
{ top: 10, right: 5, bottom: 10, left: 5 }
|
||||
);
|
||||
expect(inner).toEqual({ x: 5, y: 90, width: 190, height: 80 });
|
||||
});
|
||||
|
||||
it("applyMargin accepts a scalar spacing", { timeout: 5000 }, () => {
|
||||
const engine = new LayoutEngine();
|
||||
const inner = engine.applyMargin(
|
||||
{ x: 0, y: 100, width: 100, height: 100 },
|
||||
10
|
||||
);
|
||||
expect(inner).toEqual({ x: 10, y: 90, width: 80, height: 80 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("LayoutEngine — abort", () => {
|
||||
it("aborts createCursor when signal trips", { timeout: 5000 }, () => {
|
||||
const controller = new AbortController();
|
||||
const engine = new LayoutEngine({ signal: controller.signal });
|
||||
controller.abort();
|
||||
expect(() => engine.createCursor()).toThrow(PdfError);
|
||||
});
|
||||
|
||||
it("aborts advance when signal trips", { timeout: 5000 }, () => {
|
||||
const controller = new AbortController();
|
||||
const engine = new LayoutEngine({ signal: controller.signal });
|
||||
const cursor = engine.createCursor();
|
||||
controller.abort();
|
||||
expect(() => engine.advance(cursor, 50)).toThrow(PdfError);
|
||||
});
|
||||
|
||||
it("aborts nextPage when signal trips", { timeout: 5000 }, () => {
|
||||
const controller = new AbortController();
|
||||
const engine = new LayoutEngine({ signal: controller.signal });
|
||||
const cursor = engine.createCursor();
|
||||
controller.abort();
|
||||
expect(() => engine.nextPage(cursor)).toThrow(PdfError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LayoutEngine — config merging", () => {
|
||||
it("merges per-call config margins onto default config", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const engine = new LayoutEngine();
|
||||
const area = engine.getContentArea({
|
||||
margins: { top: 10, right: 10, bottom: 10, left: 10 },
|
||||
});
|
||||
expect(area.x).toBe(10);
|
||||
});
|
||||
});
|
||||
232
packages/devtools/docs/pdf/src/layout-engine/layout-engine.ts
Normal file
232
packages/devtools/docs/pdf/src/layout-engine/layout-engine.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { PDF_CODES, PdfError } from "../error-codes/error-codes.js";
|
||||
import { normalizeSpacing } from "../style-resolver/style-resolver.js";
|
||||
import type { PdfStyle } from "../style-resolver/style-resolver.types.js";
|
||||
import {
|
||||
DEFAULT_PAGE_CONFIG,
|
||||
PAGE_DIMENSIONS,
|
||||
type PageConfig,
|
||||
} from "../types/page-size.js";
|
||||
import type {
|
||||
AdvanceResult,
|
||||
LayoutBox,
|
||||
LayoutCursor,
|
||||
LayoutEngineOptions,
|
||||
PageSlotMetrics,
|
||||
} from "./layout-engine.types.js";
|
||||
|
||||
const DEFAULT_TEXT_FONT_SIZE = 12;
|
||||
const DEFAULT_TEXT_LINE_HEIGHT = 1.2;
|
||||
const AVG_CHAR_WIDTH_RATIO = 0.5;
|
||||
|
||||
/**
|
||||
* Block-flow layout engine for PDF documents.
|
||||
*
|
||||
* Owns the per-page cursor: where the next element lands, how much
|
||||
* vertical space is left, whether an element fits, and how to begin a
|
||||
* fresh page. Header / footer slots are reserved up-front so element
|
||||
* flow never collides with them. All public methods honour the
|
||||
* constructor-supplied `AbortSignal`.
|
||||
*/
|
||||
export class LayoutEngine<TConfig extends PageConfig = PageConfig> {
|
||||
private readonly defaultConfig: TConfig;
|
||||
private readonly headerHeight: number;
|
||||
private readonly footerHeight: number;
|
||||
private readonly signal: AbortSignal | undefined;
|
||||
|
||||
constructor(options?: LayoutEngineOptions) {
|
||||
this.defaultConfig = (options?.defaultPage ??
|
||||
DEFAULT_PAGE_CONFIG) as TConfig;
|
||||
this.headerHeight = options?.headerHeight ?? 0;
|
||||
this.footerHeight = options?.footerHeight ?? 0;
|
||||
this.signal = options?.signal;
|
||||
if (this.headerHeight < 0 || this.footerHeight < 0) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.INVALID_PAGE_CONFIG,
|
||||
"header / footer heights must be non-negative"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Header / footer slot heights. */
|
||||
getSlotMetrics(): PageSlotMetrics {
|
||||
return {
|
||||
headerHeight: this.headerHeight,
|
||||
footerHeight: this.footerHeight,
|
||||
};
|
||||
}
|
||||
|
||||
/** Effective page width × height accounting for orientation. */
|
||||
getPageDimensions(config?: Partial<PageConfig>): {
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
} {
|
||||
const merged = this.mergeConfig(config);
|
||||
const dims = PAGE_DIMENSIONS[merged.size];
|
||||
if (merged.orientation === "landscape") {
|
||||
return { width: dims.height, height: dims.width };
|
||||
}
|
||||
return { width: dims.width, height: dims.height };
|
||||
}
|
||||
|
||||
/**
|
||||
* Content box for `config` — page rect minus margins minus the
|
||||
* configured header / footer slot heights.
|
||||
*/
|
||||
getContentArea(config?: Partial<PageConfig>): LayoutBox {
|
||||
const merged = this.mergeConfig(config);
|
||||
const { width, height } = this.getPageDimensions(merged);
|
||||
const m = merged.margins;
|
||||
const innerWidth = width - m.left - m.right;
|
||||
const innerHeight =
|
||||
height - m.top - m.bottom - this.headerHeight - this.footerHeight;
|
||||
if (innerWidth <= 0 || innerHeight <= 0) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.LAYOUT_OVERFLOW,
|
||||
"page margins + header/footer exceed page dimensions"
|
||||
);
|
||||
}
|
||||
return {
|
||||
x: m.left,
|
||||
y: m.top + this.headerHeight,
|
||||
width: innerWidth,
|
||||
height: innerHeight,
|
||||
};
|
||||
}
|
||||
|
||||
/** Rectangle reserved for the header band (empty when `headerHeight === 0`). */
|
||||
getHeaderArea(config?: Partial<PageConfig>): LayoutBox {
|
||||
const merged = this.mergeConfig(config);
|
||||
const { width } = this.getPageDimensions(merged);
|
||||
const m = merged.margins;
|
||||
return {
|
||||
x: m.left,
|
||||
y: m.top,
|
||||
width: width - m.left - m.right,
|
||||
height: this.headerHeight,
|
||||
};
|
||||
}
|
||||
|
||||
/** Rectangle reserved for the footer band. */
|
||||
getFooterArea(config?: Partial<PageConfig>): LayoutBox {
|
||||
const merged = this.mergeConfig(config);
|
||||
const { width, height } = this.getPageDimensions(merged);
|
||||
const m = merged.margins;
|
||||
return {
|
||||
x: m.left,
|
||||
y: height - m.bottom - this.footerHeight,
|
||||
width: width - m.left - m.right,
|
||||
height: this.footerHeight,
|
||||
};
|
||||
}
|
||||
|
||||
/** Fresh cursor anchored at the top of the content area. */
|
||||
createCursor(config?: Partial<PageConfig>): LayoutCursor {
|
||||
this.assertNotAborted();
|
||||
const area = this.getContentArea(config);
|
||||
const { height } = this.getPageDimensions(config);
|
||||
return {
|
||||
x: area.x,
|
||||
y: height - area.y,
|
||||
availableWidth: area.width,
|
||||
availableHeight: area.height,
|
||||
pageIndex: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to advance the cursor by `height`. Returns whether the
|
||||
* element fit and how much vertical space is left.
|
||||
*/
|
||||
advance(
|
||||
cursor: LayoutCursor,
|
||||
height: number,
|
||||
config?: Partial<PageConfig>
|
||||
): AdvanceResult {
|
||||
this.assertNotAborted();
|
||||
if (!Number.isFinite(height) || height < 0) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.LAYOUT_OVERFLOW,
|
||||
`non-finite or negative advance height: ${String(height)}`
|
||||
);
|
||||
}
|
||||
const area = this.getContentArea(config);
|
||||
const floorY =
|
||||
this.getPageDimensions(config).height - area.y - area.height;
|
||||
if (cursor.y - height < floorY) {
|
||||
return { fit: false, remaining: cursor.availableHeight };
|
||||
}
|
||||
cursor.y -= height;
|
||||
cursor.availableHeight -= height;
|
||||
return { fit: true, remaining: cursor.availableHeight };
|
||||
}
|
||||
|
||||
/** Reset cursor to the top of a fresh page and bump `pageIndex`. */
|
||||
nextPage(cursor: LayoutCursor, config?: Partial<PageConfig>): void {
|
||||
this.assertNotAborted();
|
||||
const area = this.getContentArea(config);
|
||||
const { height } = this.getPageDimensions(config);
|
||||
cursor.x = area.x;
|
||||
cursor.y = height - area.y;
|
||||
cursor.availableWidth = area.width;
|
||||
cursor.availableHeight = area.height;
|
||||
cursor.pageIndex += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Approximate the wrapped height of a text block at the supplied
|
||||
* font size + line height. Mirrors the deterministic
|
||||
* 0.5em-per-glyph approximation used by `TextElement` so the engine
|
||||
* stays consistent with `measure()` output.
|
||||
*/
|
||||
computeTextHeight(
|
||||
text: string,
|
||||
style: Partial<PdfStyle>,
|
||||
maxWidth: number
|
||||
): number {
|
||||
if (maxWidth <= 0) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.LAYOUT_OVERFLOW,
|
||||
"computeTextHeight requires positive maxWidth"
|
||||
);
|
||||
}
|
||||
const fontSize = style.fontSize ?? DEFAULT_TEXT_FONT_SIZE;
|
||||
const lineHeight =
|
||||
(style.lineHeight ?? DEFAULT_TEXT_LINE_HEIGHT) * fontSize;
|
||||
const avgCharWidth = fontSize * AVG_CHAR_WIDTH_RATIO;
|
||||
const charsPerLine = Math.max(1, Math.floor(maxWidth / avgCharWidth));
|
||||
if (text.length === 0) return lineHeight;
|
||||
const paragraphs = text.split("\n");
|
||||
let lines = 0;
|
||||
for (const para of paragraphs) {
|
||||
lines +=
|
||||
para.length === 0 ? 1 : Math.ceil(para.length / charsPerLine);
|
||||
}
|
||||
return lines * lineHeight;
|
||||
}
|
||||
|
||||
/** Inset a layout box by the supplied margin spec. */
|
||||
applyMargin(box: LayoutBox, margin: PdfStyle["margin"]): LayoutBox {
|
||||
const m = normalizeSpacing(margin);
|
||||
return {
|
||||
x: box.x + m.left,
|
||||
y: box.y - m.top,
|
||||
width: box.width - m.left - m.right,
|
||||
height: box.height - m.top - m.bottom,
|
||||
};
|
||||
}
|
||||
|
||||
private mergeConfig(config?: Partial<PageConfig>): PageConfig {
|
||||
if (!config) return this.defaultConfig;
|
||||
return {
|
||||
...this.defaultConfig,
|
||||
...config,
|
||||
margins: { ...this.defaultConfig.margins, ...config.margins },
|
||||
};
|
||||
}
|
||||
|
||||
private assertNotAborted(): void {
|
||||
if (this.signal?.aborted) {
|
||||
throw new PdfError(PDF_CODES.ABORTED, "LayoutEngine aborted");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Public types for the `@suaveplan/pdf` layout engine.
|
||||
*
|
||||
* The engine drives a deterministic top-down cursor (PDF's coordinate
|
||||
* system is bottom-up, so the cursor stores the current `y` in PDF
|
||||
* coordinates and decrements as it advances). It exposes only data —
|
||||
* the renderer owns the loop that walks page elements and asks the
|
||||
* engine to advance / page-break / restart.
|
||||
*/
|
||||
|
||||
import type { PageConfig } from "../types/page-size.js";
|
||||
|
||||
/** A computed bounding box for an element. PDF coordinate system. */
|
||||
export interface LayoutBox {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}
|
||||
|
||||
/** Mutable cursor state tracking the in-flight layout position. */
|
||||
export interface LayoutCursor {
|
||||
x: number;
|
||||
y: number;
|
||||
availableWidth: number;
|
||||
availableHeight: number;
|
||||
pageIndex: number;
|
||||
}
|
||||
|
||||
/** Header / footer slot heights reserved on every page. */
|
||||
export interface PageSlotMetrics {
|
||||
readonly headerHeight: number;
|
||||
readonly footerHeight: number;
|
||||
}
|
||||
|
||||
/** Options accepted by {@link LayoutEngine} construction. */
|
||||
export interface LayoutEngineOptions {
|
||||
/** Default {@link PageConfig} used when none is supplied per-page. */
|
||||
readonly defaultPage?: PageConfig | undefined;
|
||||
/** Header slot height in PDF points reserved at the top of each page. */
|
||||
readonly headerHeight?: number | undefined;
|
||||
/** Footer slot height in PDF points reserved at the bottom of each page. */
|
||||
readonly footerHeight?: number | undefined;
|
||||
/** Optional AbortSignal — every flow operation honours it. */
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
}
|
||||
|
||||
/** Outcome of a single advance request. */
|
||||
export interface AdvanceResult {
|
||||
/** True if the element fit on the current page. */
|
||||
readonly fit: boolean;
|
||||
/** Remaining space after the advance (zero when `fit === false`). */
|
||||
readonly remaining: number;
|
||||
}
|
||||
16
packages/devtools/docs/pdf/src/pdf-writer/index.ts
Normal file
16
packages/devtools/docs/pdf/src/pdf-writer/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export {
|
||||
encodePdfValue,
|
||||
escapePdfLiteralString,
|
||||
formatPdfDate,
|
||||
formatPdfNumber,
|
||||
PdfWriter,
|
||||
STANDARD_FONTS,
|
||||
} from "./pdf-writer.js";
|
||||
export type {
|
||||
PdfObjectRef,
|
||||
PdfPageInput,
|
||||
PdfStreamFilter,
|
||||
PdfValue,
|
||||
PdfWriterMetadata,
|
||||
PdfWriterOptions,
|
||||
} from "./pdf-writer.types.js";
|
||||
143
packages/devtools/docs/pdf/src/pdf-writer/pdf-writer.md
Normal file
143
packages/devtools/docs/pdf/src/pdf-writer/pdf-writer.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# pdf-writer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`PdfWriter` is the low-level binary writer that emits valid PDF 1.4 byte
|
||||
streams. It manages indirect-object allocation, the font registry for
|
||||
the 14 standard fonts, stream-object assembly (raw + FlateDecode), the
|
||||
cross-reference table, the trailer dictionary, the `/Info` block, and
|
||||
the canonical header + binary-marker + `%%EOF` framing.
|
||||
|
||||
This module sits at the bottom of the package. Every higher-level layer
|
||||
(`content-stream` operator sequences, element renderers, the document
|
||||
builder, and the templates) eventually flows through it; the only direct
|
||||
external API is `addObject` / `addStream` / `addPage` / `generate`.
|
||||
Instances are single-use: after `generate()` returns or `dispose()` is
|
||||
called, further writes raise `PdfError(PDF_CODES.WRITER_DISPOSED)`.
|
||||
|
||||
## Features
|
||||
|
||||
- PDF 1.4 framing: header, binary-marker comment, xref, trailer
|
||||
(`/Size`, `/Root`, `/Info`), `startxref`, `%%EOF`.
|
||||
- Eight PDF object types via the `PdfValue` discriminated union: `null`,
|
||||
`bool`, `number`, literal string, hex string, name, array, dictionary,
|
||||
and indirect reference.
|
||||
- Compression hook — `addStream(data, "FlateDecode")` deflates the
|
||||
payload via `node:zlib` and tags the dictionary with
|
||||
`/Filter /FlateDecode`.
|
||||
- The 14 standard fonts registered via `registerFont(name)`; idempotent
|
||||
on identical names; rejects unknown fonts with
|
||||
`PdfError(PDF_CODES.FONT_UNKNOWN)`.
|
||||
- Injectable `Clock` (from `@suaveplan/types`) drives `/CreationDate`
|
||||
for deterministic test output.
|
||||
- `AbortSignal`-aware `generate()` — abort mid-write throws
|
||||
`PdfError(PDF_CODES.ABORTED)` and returns no partial bytes.
|
||||
- `dispose()` lifecycle guard — all subsequent writes raise
|
||||
`PdfError(PDF_CODES.WRITER_DISPOSED)`.
|
||||
- Byte-accurate xref offsets — each entry points at the literal
|
||||
`<id> 0 obj` opener in the emitted stream.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { PdfWriter } from "@suaveplan/pdf/pdf-writer";
|
||||
|
||||
const writer = new PdfWriter();
|
||||
const fontResource = writer.registerFont("Helvetica");
|
||||
const stream = writer.addStream(`BT ${fontResource} 12 Tf (Hi) Tj ET`);
|
||||
writer.addPage({ width: 612, height: 792, contentStreamId: stream });
|
||||
const bytes = writer.generate({ Title: "Hello" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { PdfWriter } from "@suaveplan/pdf/pdf-writer";
|
||||
import { ContentStreamEncoder } from "@suaveplan/pdf/content-stream";
|
||||
|
||||
const controller = new AbortController();
|
||||
const writer = new PdfWriter({
|
||||
clock: () => Date.UTC(2026, 0, 1),
|
||||
signal: controller.signal,
|
||||
producer: "@my-org/reporting/1.0",
|
||||
});
|
||||
|
||||
const fontResource = writer.registerFont("Times-Bold");
|
||||
const imageId = writer.addStream(jpegBytes, "None", [
|
||||
["/Type", { kind: "name", value: "/XObject" }],
|
||||
["/Subtype", { kind: "name", value: "/Image" }],
|
||||
]);
|
||||
|
||||
const stream = writer.addStream(
|
||||
new ContentStreamEncoder()
|
||||
.beginText()
|
||||
.setFont(fontResource, 24)
|
||||
.moveTextCursor(72, 720)
|
||||
.showText("Annual Report")
|
||||
.endText()
|
||||
.drawImage("Im0")
|
||||
.encode(),
|
||||
"FlateDecode",
|
||||
);
|
||||
|
||||
writer.addPage({
|
||||
width: 612,
|
||||
height: 792,
|
||||
contentStreamId: stream,
|
||||
xobjectResources: new Map([["Im0", imageId]]),
|
||||
});
|
||||
|
||||
const bytes = writer.generate({
|
||||
Title: "Annual Report",
|
||||
Author: "Suaveplan",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new PdfWriter(options?)` — `options.clock` defaults to `Date.now`;
|
||||
`options.signal` is an optional `AbortSignal`; `options.producer`
|
||||
defaults to `@suaveplan/pdf`.
|
||||
- `allocateId(): number` — reserve the next indirect-object id.
|
||||
- `addObject(value: PdfValue, id?: number): number` — write any PDF
|
||||
value as an indirect object.
|
||||
- `addStream(data, filter?, extraDictEntries?)` — emit a stream
|
||||
object; `filter` defaults to `"None"` and may be `"FlateDecode"`.
|
||||
- `registerFont(name): string` — register one of the 14 standard fonts;
|
||||
returns the resource name (`F1`, `F2`, …).
|
||||
- `addPage(input): number` — append a page object referencing a content
|
||||
stream + resource dictionaries.
|
||||
- `generate(metadata?): Uint8Array` — assemble the complete document
|
||||
and return the bytes.
|
||||
- `dispose()` — mark the writer as unusable.
|
||||
- Helpers: `encodePdfValue(value)`, `escapePdfLiteralString(s)`,
|
||||
`formatPdfNumber(n)`, `formatPdfDate(epochMs)`, `STANDARD_FONTS`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The writer keeps each indirect object as a `{ id, body }` record (and
|
||||
attaches `streamBytes` + `filter` for stream objects) inside a
|
||||
`Map<number, …>`. Serialisation iterates the map in ascending id order,
|
||||
records each object's byte offset, then assembles the xref table from
|
||||
the recorded offsets. The xref table always contains every id from
|
||||
`0` to `max(id)`; ids that were allocated but never written are
|
||||
emitted with offset `0` (which still satisfies the structural
|
||||
constraint imposed by readers that index the table by id, as the
|
||||
catalog and pages dictionary fill those slots before `generate`).
|
||||
|
||||
`AbortSignal` checks bracket the serialisation pass on either side of
|
||||
the per-object loop. The signal is also checked before the
|
||||
`/Pages`/`/Info`/`/Catalog` writes so an early abort cannot leak a
|
||||
partial document. `generate()` flips the `disposed` flag on the way out
|
||||
so a second call surfaces immediately as `WRITER_DISPOSED` — there is
|
||||
no internal reset path because PDFs are write-once artifacts.
|
||||
|
||||
The `Producer` field of `/Info` is always emitted (PDF 1.4 readers
|
||||
treat it as informational but pdf-parse and other audit tools surface
|
||||
it directly), so test fixtures that compare against `/Info` payloads
|
||||
need to either accept the default `@suaveplan/pdf` value or override it
|
||||
via the constructor option.
|
||||
533
packages/devtools/docs/pdf/src/pdf-writer/pdf-writer.test.ts
Normal file
533
packages/devtools/docs/pdf/src/pdf-writer/pdf-writer.test.ts
Normal file
@@ -0,0 +1,533 @@
|
||||
import { inflateSync } from "node:zlib";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PDFParse } from "pdf-parse";
|
||||
import { ContentStreamEncoder } from "../content-stream/content-stream.js";
|
||||
import { PDF_CODES, PdfError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
encodePdfValue,
|
||||
escapePdfLiteralString,
|
||||
formatPdfDate,
|
||||
formatPdfNumber,
|
||||
PdfWriter,
|
||||
STANDARD_FONTS,
|
||||
} from "./pdf-writer.js";
|
||||
|
||||
describe("formatPdfNumber", () => {
|
||||
it("formats integers without decimals", { timeout: 5000 }, () => {
|
||||
expect(formatPdfNumber(0)).toBe("0");
|
||||
expect(formatPdfNumber(42)).toBe("42");
|
||||
expect(formatPdfNumber(-7)).toBe("-7");
|
||||
});
|
||||
|
||||
it("formats -0 as 0", { timeout: 5000 }, () => {
|
||||
expect(formatPdfNumber(-0)).toBe("0");
|
||||
});
|
||||
|
||||
it("formats fractions to 4 decimals", { timeout: 5000 }, () => {
|
||||
expect(formatPdfNumber(1.5)).toBe("1.5000");
|
||||
expect(formatPdfNumber(595.28)).toBe("595.2800");
|
||||
});
|
||||
|
||||
it("rejects non-finite values", { timeout: 5000 }, () => {
|
||||
expect(() => formatPdfNumber(Number.NaN)).toThrowError(PdfError);
|
||||
expect(() => formatPdfNumber(Number.POSITIVE_INFINITY)).toThrowError(
|
||||
PdfError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("escapePdfLiteralString", () => {
|
||||
it("returns plain strings unchanged", { timeout: 5000 }, () => {
|
||||
expect(escapePdfLiteralString("hi")).toBe("hi");
|
||||
});
|
||||
|
||||
it("escapes backslashes and parens", { timeout: 5000 }, () => {
|
||||
expect(escapePdfLiteralString("(a\\b)")).toBe("\\(a\\\\b\\)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("encodePdfValue", () => {
|
||||
it("encodes null + booleans", { timeout: 5000 }, () => {
|
||||
expect(encodePdfValue({ kind: "null" })).toBe("null");
|
||||
expect(encodePdfValue({ kind: "bool", value: true })).toBe("true");
|
||||
expect(encodePdfValue({ kind: "bool", value: false })).toBe("false");
|
||||
});
|
||||
|
||||
it("encodePdfValue escapes literal strings", { timeout: 5000 }, () => {
|
||||
const result = encodePdfValue({
|
||||
kind: "string",
|
||||
value: "(Hello\\World)",
|
||||
});
|
||||
expect(result).toBe("(\\(Hello\\\\World\\))");
|
||||
});
|
||||
|
||||
it("encodes hex strings uppercase", { timeout: 5000 }, () => {
|
||||
const out = encodePdfValue({
|
||||
kind: "hexString",
|
||||
value: new Uint8Array([0xde, 0xad, 0xbe, 0xef]),
|
||||
});
|
||||
expect(out).toBe("<DEADBEEF>");
|
||||
});
|
||||
|
||||
it("encodes names with leading slash", { timeout: 5000 }, () => {
|
||||
expect(encodePdfValue({ kind: "name", value: "Font" })).toBe("/Font");
|
||||
expect(encodePdfValue({ kind: "name", value: "/Font" })).toBe("/Font");
|
||||
});
|
||||
|
||||
it("encodes arrays + nested values", { timeout: 5000 }, () => {
|
||||
const out = encodePdfValue({
|
||||
kind: "array",
|
||||
items: [
|
||||
{ kind: "number", value: 1 },
|
||||
{ kind: "number", value: 2.5 },
|
||||
{ kind: "bool", value: true },
|
||||
],
|
||||
});
|
||||
expect(out).toBe("[1 2.5000 true]");
|
||||
});
|
||||
|
||||
it("encodes indirect references", { timeout: 5000 }, () => {
|
||||
expect(encodePdfValue({ kind: "ref", id: 7, generation: 0 })).toBe(
|
||||
"7 0 R"
|
||||
);
|
||||
});
|
||||
|
||||
it("encodePdfValue is deterministic for dictionaries", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const value = {
|
||||
kind: "dict" as const,
|
||||
entries: [
|
||||
["/A", { kind: "number" as const, value: 1 }],
|
||||
["/B", { kind: "string" as const, value: "two" }],
|
||||
] satisfies ReadonlyArray<readonly [string, unknown]>,
|
||||
};
|
||||
const first = encodePdfValue(value);
|
||||
const second = encodePdfValue(value);
|
||||
expect(first).toBe(second);
|
||||
expect(first.startsWith("<<")).toBe(true);
|
||||
expect(first.endsWith(">>")).toBe(true);
|
||||
});
|
||||
|
||||
it("encodePdfValue accepts dict entries with bare names", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = encodePdfValue({
|
||||
kind: "dict",
|
||||
entries: [["Bare", { kind: "number", value: 1 }]],
|
||||
});
|
||||
expect(out).toContain("/Bare 1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatPdfDate", () => {
|
||||
it("formats epoch ms as PDF date string", { timeout: 5000 }, () => {
|
||||
// 2026-05-25T12:34:56Z
|
||||
const epoch = Date.UTC(2026, 4, 25, 12, 34, 56);
|
||||
expect(formatPdfDate(epoch)).toBe("D:20260525123456+0000");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PdfWriter — framing", () => {
|
||||
it("emits %PDF-1.4 header and %%EOF trailer", { timeout: 5000 }, () => {
|
||||
const writer = new PdfWriter();
|
||||
const csId = writer.addStream("BT ET");
|
||||
writer.addPage({ width: 612, height: 792, contentStreamId: csId });
|
||||
const bytes = writer.generate();
|
||||
const latin1 = new TextDecoder("latin1").decode(bytes);
|
||||
expect(latin1.startsWith("%PDF-1.4\n")).toBe(true);
|
||||
expect(latin1).toContain("\x25\xc0\xc1\xc2\xc3");
|
||||
expect(latin1.trimEnd().endsWith("%%EOF")).toBe(true);
|
||||
});
|
||||
|
||||
it("emits trailer + Size + Root + Info", { timeout: 5000 }, () => {
|
||||
const writer = new PdfWriter();
|
||||
const csId = writer.addStream("");
|
||||
writer.addPage({ width: 612, height: 792, contentStreamId: csId });
|
||||
const text = new TextDecoder().decode(writer.generate());
|
||||
expect(text).toContain("trailer");
|
||||
expect(text).toMatch(/\/Size \d+/);
|
||||
expect(text).toMatch(/\/Root \d+ 0 R/);
|
||||
expect(text).toMatch(/\/Info \d+ 0 R/);
|
||||
expect(text).toContain("startxref");
|
||||
});
|
||||
|
||||
it("xref offsets match object byte positions", { timeout: 5000 }, () => {
|
||||
const writer = new PdfWriter();
|
||||
const csId = writer.addStream("BT ET");
|
||||
writer.addPage({ width: 612, height: 792, contentStreamId: csId });
|
||||
const bytes = writer.generate();
|
||||
const text = new TextDecoder().decode(bytes);
|
||||
const xrefIdx = text.indexOf("xref\n");
|
||||
const xrefBlock = text.slice(xrefIdx);
|
||||
const lines = xrefBlock.split("\n").slice(2); // skip "xref" + "0 N"
|
||||
// Iterate non-free entries; each declares an offset.
|
||||
for (let id = 1; ; id++) {
|
||||
const line = lines.shift();
|
||||
if (!line || !/^\d{10} \d{5} n/.test(line)) break;
|
||||
const offset = Number.parseInt(line.slice(0, 10), 10);
|
||||
const slice = text.slice(offset, offset + 32);
|
||||
expect(slice.startsWith(`${id} 0 obj`)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("PdfWriter — fonts", () => {
|
||||
it("registers standard fonts and returns Fn names", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const w = new PdfWriter();
|
||||
const n1 = w.registerFont("Helvetica");
|
||||
const n2 = w.registerFont("Helvetica-Bold");
|
||||
expect(n1).toMatch(/^F\d+$/);
|
||||
expect(n2).toMatch(/^F\d+$/);
|
||||
expect(n1).not.toBe(n2);
|
||||
});
|
||||
|
||||
it("idempotent registration", { timeout: 5000 }, () => {
|
||||
const w = new PdfWriter();
|
||||
const a = w.registerFont("Times-Roman");
|
||||
const b = w.registerFont("Times-Roman");
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("rejects unknown fonts with FONT_UNKNOWN", { timeout: 5000 }, () => {
|
||||
const w = new PdfWriter();
|
||||
try {
|
||||
w.registerFont("Comic Sans");
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(PdfError);
|
||||
expect((err as PdfError).code).toBe(PDF_CODES.FONT_UNKNOWN);
|
||||
}
|
||||
});
|
||||
|
||||
it("STANDARD_FONTS contains the 14 fonts", { timeout: 5000 }, () => {
|
||||
expect(STANDARD_FONTS.size).toBe(14);
|
||||
});
|
||||
|
||||
it("emits /Type /Font Type1 entries", { timeout: 5000 }, () => {
|
||||
const w = new PdfWriter();
|
||||
w.registerFont("Helvetica");
|
||||
const csId = w.addStream("");
|
||||
w.addPage({ width: 100, height: 100, contentStreamId: csId });
|
||||
const text = new TextDecoder().decode(w.generate());
|
||||
expect(text).toContain("/Type /Font");
|
||||
expect(text).toContain("/BaseFont /Helvetica");
|
||||
expect(text).toContain("/Subtype /Type1");
|
||||
expect(text).toContain("/Encoding /WinAnsiEncoding");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PdfWriter — streams", () => {
|
||||
it("FlateDecode streams round-trip via node:zlib", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const w = new PdfWriter();
|
||||
const payload = "BT /F1 12 Tf (Hello) Tj ET";
|
||||
w.addStream(payload, "FlateDecode");
|
||||
const csId2 = w.addStream("BT ET");
|
||||
w.addPage({ width: 100, height: 100, contentStreamId: csId2 });
|
||||
const bytes = w.generate();
|
||||
// Locate the first compressed stream's payload bytes.
|
||||
const text = new TextDecoder("latin1").decode(bytes);
|
||||
const filterIdx = text.indexOf("/Filter /FlateDecode");
|
||||
expect(filterIdx).toBeGreaterThan(-1);
|
||||
const streamStart =
|
||||
text.indexOf("stream\n", filterIdx) + "stream\n".length;
|
||||
const streamEnd = text.indexOf("\nendstream", streamStart);
|
||||
const compressedBytes = bytes.subarray(streamStart, streamEnd);
|
||||
const inflated = inflateSync(compressedBytes);
|
||||
expect(new TextDecoder().decode(inflated)).toBe(payload);
|
||||
});
|
||||
|
||||
it("raw streams embed exact bytes + /Length", { timeout: 5000 }, () => {
|
||||
const w = new PdfWriter();
|
||||
const payload = "BT /F1 12 Tf (raw) Tj ET";
|
||||
w.addStream(payload);
|
||||
const csId2 = w.addStream("BT ET");
|
||||
w.addPage({ width: 100, height: 100, contentStreamId: csId2 });
|
||||
const text = new TextDecoder().decode(w.generate());
|
||||
expect(text).toContain(payload);
|
||||
expect(text).toContain(`/Length ${payload.length}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PdfWriter — addObject id paths", () => {
|
||||
it("addObject without explicit id allocates a fresh id", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const w = new PdfWriter();
|
||||
const id = w.addObject({ kind: "number", value: 42 });
|
||||
expect(typeof id).toBe("number");
|
||||
expect(id).toBeGreaterThan(0);
|
||||
const cs = w.addStream("");
|
||||
w.addPage({ width: 10, height: 10, contentStreamId: cs });
|
||||
const text = new TextDecoder().decode(w.generate());
|
||||
expect(text).toContain(`${id} 0 obj\n42`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PdfWriter — stream input forms", () => {
|
||||
it("addStream accepts a Uint8Array payload", { timeout: 5000 }, () => {
|
||||
const w = new PdfWriter();
|
||||
const bytes = new Uint8Array([0x42, 0x54, 0x20, 0x45, 0x54]); // "BT ET"
|
||||
w.addStream(bytes);
|
||||
const csId2 = w.addStream("BT ET");
|
||||
w.addPage({ width: 100, height: 100, contentStreamId: csId2 });
|
||||
const text = new TextDecoder().decode(w.generate());
|
||||
expect(text).toContain("BT ET");
|
||||
expect(text).toContain("/Length 5");
|
||||
});
|
||||
|
||||
it("allocateId without addObject leaves a 0-offset xref entry", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const w = new PdfWriter();
|
||||
// Reserve an id without ever writing the object body.
|
||||
w.allocateId();
|
||||
const cs = w.addStream("");
|
||||
w.addPage({ width: 100, height: 100, contentStreamId: cs });
|
||||
const text = new TextDecoder().decode(w.generate());
|
||||
// The xref table must still cover that id (offset 0000000000).
|
||||
const xrefBody = text.slice(text.indexOf("xref\n"));
|
||||
// Count "0000000000 00000 n " lines (placeholder offsets for
|
||||
// allocated-but-never-written ids).
|
||||
const placeholders =
|
||||
xrefBody.match(/0000000000 00000 n /g)?.length ?? 0;
|
||||
expect(placeholders).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PdfWriter — pages", () => {
|
||||
it("MediaBox carries float page dimensions", { timeout: 5000 }, () => {
|
||||
const w = new PdfWriter();
|
||||
const csId = w.addStream("");
|
||||
w.addPage({ width: 595.28, height: 841.89, contentStreamId: csId });
|
||||
const text = new TextDecoder().decode(w.generate());
|
||||
expect(text).toContain("/MediaBox [0 0 595.2800 841.8900]");
|
||||
});
|
||||
|
||||
it("multi-page /Count is correct", { timeout: 5000 }, () => {
|
||||
const w = new PdfWriter();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const cs = w.addStream("");
|
||||
w.addPage({ width: 612, height: 792, contentStreamId: cs });
|
||||
}
|
||||
const text = new TextDecoder().decode(w.generate());
|
||||
expect(text).toContain("/Count 3");
|
||||
});
|
||||
|
||||
it("XObject resources are emitted on the page resource dict", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const w = new PdfWriter();
|
||||
const imageId = w.addStream("dummy-image-data");
|
||||
const csId = w.addStream(
|
||||
new ContentStreamEncoder().drawImage("Im0").encode()
|
||||
);
|
||||
w.addPage({
|
||||
width: 612,
|
||||
height: 792,
|
||||
contentStreamId: csId,
|
||||
xobjectResources: new Map([["Im0", imageId]]),
|
||||
});
|
||||
const text = new TextDecoder().decode(w.generate());
|
||||
expect(text).toContain("/XObject");
|
||||
expect(text).toContain(`/Im0 ${imageId} 0 R`);
|
||||
});
|
||||
|
||||
it("custom font-resource map is honoured", { timeout: 5000 }, () => {
|
||||
const w = new PdfWriter();
|
||||
const helvId = w.registerFont("Helvetica").slice(1);
|
||||
const csId = w.addStream("BT ET");
|
||||
w.addPage({
|
||||
width: 612,
|
||||
height: 792,
|
||||
contentStreamId: csId,
|
||||
fontResources: new Map([
|
||||
[`F${helvId}`, Number.parseInt(helvId, 10)],
|
||||
]),
|
||||
});
|
||||
const text = new TextDecoder().decode(w.generate());
|
||||
expect(text).toContain(`/F${helvId} ${helvId} 0 R`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PdfWriter — metadata", () => {
|
||||
it("metadata applied to /Info", { timeout: 5000 }, () => {
|
||||
const w = new PdfWriter();
|
||||
const csId = w.addStream("BT ET");
|
||||
w.addPage({ width: 612, height: 792, contentStreamId: csId });
|
||||
const text = new TextDecoder().decode(
|
||||
w.generate({
|
||||
Title: "Round Trip",
|
||||
Author: "Suaveplan",
|
||||
Subject: "Tests",
|
||||
Creator: "agent",
|
||||
Keywords: "a;b",
|
||||
})
|
||||
);
|
||||
expect(text).toContain("/Title (Round Trip)");
|
||||
expect(text).toContain("/Author (Suaveplan)");
|
||||
expect(text).toContain("/Subject (Tests)");
|
||||
expect(text).toContain("/Creator (agent)");
|
||||
expect(text).toContain("/Keywords (a;b)");
|
||||
expect(text).toContain("/Producer (@suaveplan/pdf)");
|
||||
expect(text).toMatch(/\/CreationDate \(D:\d{14}\+0000\)/);
|
||||
});
|
||||
|
||||
it("custom Producer is honoured", { timeout: 5000 }, () => {
|
||||
const w = new PdfWriter({ producer: "custom-producer/1.0" });
|
||||
const csId = w.addStream("BT ET");
|
||||
w.addPage({ width: 612, height: 792, contentStreamId: csId });
|
||||
const text = new TextDecoder().decode(w.generate());
|
||||
expect(text).toContain("/Producer (custom-producer/1.0)");
|
||||
});
|
||||
|
||||
it("injectable Clock controls /CreationDate", { timeout: 5000 }, () => {
|
||||
const fixed = Date.UTC(2026, 0, 1, 0, 0, 0);
|
||||
const w = new PdfWriter({ clock: () => fixed });
|
||||
const csId = w.addStream("BT ET");
|
||||
w.addPage({ width: 612, height: 792, contentStreamId: csId });
|
||||
const text = new TextDecoder().decode(w.generate());
|
||||
expect(text).toContain("/CreationDate (D:20260101000000+0000)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PdfWriter — lifecycle", () => {
|
||||
it("dispose blocks further generate", { timeout: 5000 }, () => {
|
||||
const w = new PdfWriter();
|
||||
w.dispose();
|
||||
try {
|
||||
w.generate();
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(PdfError);
|
||||
expect((err as PdfError).code).toBe(PDF_CODES.WRITER_DISPOSED);
|
||||
}
|
||||
});
|
||||
|
||||
it("generate auto-disposes — second call raises", { timeout: 5000 }, () => {
|
||||
const w = new PdfWriter();
|
||||
const csId = w.addStream("BT ET");
|
||||
w.addPage({ width: 100, height: 100, contentStreamId: csId });
|
||||
w.generate();
|
||||
expect(() => w.generate()).toThrowError(PdfError);
|
||||
});
|
||||
|
||||
it("allocateId after dispose raises", { timeout: 5000 }, () => {
|
||||
const w = new PdfWriter();
|
||||
w.dispose();
|
||||
expect(() => w.allocateId()).toThrowError(PdfError);
|
||||
});
|
||||
|
||||
it("addObject after dispose raises", { timeout: 5000 }, () => {
|
||||
const w = new PdfWriter();
|
||||
w.dispose();
|
||||
expect(() => w.addObject({ kind: "number", value: 1 })).toThrowError(
|
||||
PdfError
|
||||
);
|
||||
});
|
||||
|
||||
it("addStream after dispose raises", { timeout: 5000 }, () => {
|
||||
const w = new PdfWriter();
|
||||
w.dispose();
|
||||
expect(() => w.addStream("x")).toThrowError(PdfError);
|
||||
});
|
||||
|
||||
it("addPage after dispose raises", { timeout: 5000 }, () => {
|
||||
const w = new PdfWriter();
|
||||
w.dispose();
|
||||
expect(() =>
|
||||
w.addPage({ width: 1, height: 1, contentStreamId: 1 })
|
||||
).toThrowError(PdfError);
|
||||
});
|
||||
|
||||
it("registerFont after dispose raises", { timeout: 5000 }, () => {
|
||||
const w = new PdfWriter();
|
||||
w.dispose();
|
||||
expect(() => w.registerFont("Helvetica")).toThrowError(PdfError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PdfWriter — abort", () => {
|
||||
it("AbortSignal aborts generate", { timeout: 5000 }, () => {
|
||||
const controller = new AbortController();
|
||||
const w = new PdfWriter({ signal: controller.signal });
|
||||
const csId = w.addStream("BT ET");
|
||||
w.addPage({ width: 100, height: 100, contentStreamId: csId });
|
||||
controller.abort();
|
||||
try {
|
||||
w.generate();
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(PdfError);
|
||||
expect((err as PdfError).code).toBe(PDF_CODES.ABORTED);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("PdfWriter — pdf-parse round-trip", () => {
|
||||
it("pdf-parse round-trips text and metadata", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const w = new PdfWriter({
|
||||
clock: () => Date.UTC(2026, 4, 25, 0, 0, 0),
|
||||
});
|
||||
const fontResource = w.registerFont("Helvetica");
|
||||
const stream = new ContentStreamEncoder()
|
||||
.beginText()
|
||||
.setFont(fontResource, 18)
|
||||
.moveTextCursor(72, 720)
|
||||
.showText("Hello PDF")
|
||||
.endText()
|
||||
.encode();
|
||||
const csId = w.addStream(stream);
|
||||
w.addPage({ width: 612, height: 792, contentStreamId: csId });
|
||||
const bytes = w.generate({
|
||||
Title: "Round Trip",
|
||||
Author: "Suaveplan",
|
||||
});
|
||||
|
||||
const parser = new PDFParse({ data: bytes });
|
||||
try {
|
||||
const info = await parser.getInfo();
|
||||
const text = await parser.getText();
|
||||
expect(info.total).toBe(1);
|
||||
const infoObj = (info.info ?? {}) as Record<string, string>;
|
||||
expect(infoObj.Title).toBe("Round Trip");
|
||||
expect(infoObj.Author).toBe("Suaveplan");
|
||||
expect(text.text).toContain("Hello PDF");
|
||||
} finally {
|
||||
await parser.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it("pdf-parse handles FlateDecode content streams", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const w = new PdfWriter({
|
||||
clock: () => Date.UTC(2026, 0, 1, 0, 0, 0),
|
||||
});
|
||||
const fontResource = w.registerFont("Times-Roman");
|
||||
const stream = new ContentStreamEncoder()
|
||||
.beginText()
|
||||
.setFont(fontResource, 14)
|
||||
.moveTextCursor(72, 720)
|
||||
.showText("Compressed body")
|
||||
.endText()
|
||||
.encode();
|
||||
const csId = w.addStream(stream, "FlateDecode");
|
||||
w.addPage({ width: 612, height: 792, contentStreamId: csId });
|
||||
const bytes = w.generate({ Title: "Z" });
|
||||
|
||||
const parser = new PDFParse({ data: bytes });
|
||||
try {
|
||||
const text = await parser.getText();
|
||||
expect(text.text).toContain("Compressed body");
|
||||
} finally {
|
||||
await parser.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
504
packages/devtools/docs/pdf/src/pdf-writer/pdf-writer.ts
Normal file
504
packages/devtools/docs/pdf/src/pdf-writer/pdf-writer.ts
Normal file
@@ -0,0 +1,504 @@
|
||||
import { deflateSync } from "node:zlib";
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import { PDF_CODES, PdfError } from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
PdfPageInput,
|
||||
PdfStreamFilter,
|
||||
PdfValue,
|
||||
PdfWriterMetadata,
|
||||
PdfWriterOptions,
|
||||
} from "./pdf-writer.types.js";
|
||||
|
||||
/** Maximum number of decimal places used to format PDF numbers. */
|
||||
const MAX_DECIMALS = 4;
|
||||
|
||||
/**
|
||||
* Format a PDF number. Integers (including `-0`) emit without a decimal
|
||||
* part; non-integers are rendered to four decimal places with trailing
|
||||
* zero retention (PDF 1.4 §7.3.3 — at least one digit on each side of
|
||||
* the optional dot).
|
||||
*/
|
||||
export function formatPdfNumber(n: number): string {
|
||||
if (!Number.isFinite(n)) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.RENDER_FAILED,
|
||||
`non-finite PDF number: ${String(n)}`
|
||||
);
|
||||
}
|
||||
if (Object.is(n, -0)) return "0";
|
||||
if (Number.isInteger(n)) return n.toString();
|
||||
return n.toFixed(MAX_DECIMALS);
|
||||
}
|
||||
|
||||
/** Escape `\`, `(`, `)` per PDF 1.4 §7.3.4 literal-string rules. */
|
||||
export function escapePdfLiteralString(input: string): string {
|
||||
return input
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/\(/g, "\\(")
|
||||
.replace(/\)/g, "\\)");
|
||||
}
|
||||
|
||||
/** Encode a {@link Uint8Array} as a PDF hex string (`<DEADBEEF>`). */
|
||||
function encodeHexString(bytes: Uint8Array): string {
|
||||
let out = "<";
|
||||
for (const byte of bytes) {
|
||||
out += byte.toString(16).padStart(2, "0").toUpperCase();
|
||||
}
|
||||
return `${out}>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialise any {@link PdfValue} to its PDF 1.4 textual encoding.
|
||||
*
|
||||
* Deterministic — equal inputs produce byte-identical output, which the
|
||||
* xref-table byte-offset invariant relies on.
|
||||
*/
|
||||
export function encodePdfValue(value: PdfValue): string {
|
||||
switch (value.kind) {
|
||||
case "null":
|
||||
return "null";
|
||||
case "bool":
|
||||
return value.value ? "true" : "false";
|
||||
case "number":
|
||||
return formatPdfNumber(value.value);
|
||||
case "string":
|
||||
return `(${escapePdfLiteralString(value.value)})`;
|
||||
case "hexString":
|
||||
return encodeHexString(value.value);
|
||||
case "name":
|
||||
return value.value.startsWith("/")
|
||||
? value.value
|
||||
: `/${value.value}`;
|
||||
case "array":
|
||||
return `[${value.items.map(encodePdfValue).join(" ")}]`;
|
||||
case "dict": {
|
||||
const parts: string[] = ["<<"];
|
||||
for (const [name, child] of value.entries) {
|
||||
const nameStr = name.startsWith("/") ? name : `/${name}`;
|
||||
parts.push(`${nameStr} ${encodePdfValue(child)}`);
|
||||
}
|
||||
parts.push(">>");
|
||||
return parts.join("\n");
|
||||
}
|
||||
case "ref":
|
||||
return `${value.id} ${value.generation} R`;
|
||||
}
|
||||
}
|
||||
|
||||
interface PendingObject {
|
||||
readonly id: number;
|
||||
readonly body: string;
|
||||
readonly trailingBytes?: Uint8Array;
|
||||
}
|
||||
|
||||
interface PendingStreamObject extends PendingObject {
|
||||
readonly streamBytes: Uint8Array;
|
||||
readonly filter: PdfStreamFilter;
|
||||
}
|
||||
|
||||
function isStreamObject(obj: PendingObject): obj is PendingStreamObject {
|
||||
return (obj as PendingStreamObject).streamBytes !== undefined;
|
||||
}
|
||||
|
||||
/** Default clock — reads wall-clock milliseconds via `Date.now()`. */
|
||||
const DEFAULT_CLOCK: Clock = () => Date.now();
|
||||
|
||||
/** Format a millisecond epoch as a PDF date string `D:YYYYMMDDHHmmSS+0000`. */
|
||||
export function formatPdfDate(epochMs: number): string {
|
||||
const d = new Date(epochMs);
|
||||
const pad = (n: number, w = 2): string => n.toString().padStart(w, "0");
|
||||
return `D:${d.getUTCFullYear().toString().padStart(4, "0")}${pad(
|
||||
d.getUTCMonth() + 1
|
||||
)}${pad(d.getUTCDate())}${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(
|
||||
d.getUTCSeconds()
|
||||
)}+0000`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-level PDF 1.4 binary writer.
|
||||
*
|
||||
* Manages indirect-object allocation, font-resource registration for the
|
||||
* 14 standard fonts, content streams (raw + FlateDecode), the cross-
|
||||
* reference table, the trailer dictionary, the `/Info` block, and the
|
||||
* canonical header / binary-marker / `%%EOF` framing.
|
||||
*
|
||||
* Instances are single-use: after `generate()` returns or `dispose()` is
|
||||
* called, further writes raise `PDF.WRITER_DISPOSED`.
|
||||
*/
|
||||
export class PdfWriter {
|
||||
private nextId = 1;
|
||||
private readonly objects = new Map<number, PendingObject>();
|
||||
private readonly pageIds: number[] = [];
|
||||
private readonly fontResources = new Map<string, number>();
|
||||
private readonly catalogId: number;
|
||||
private readonly pagesId: number;
|
||||
private disposed = false;
|
||||
private readonly clock: Clock;
|
||||
private readonly signal: AbortSignal | undefined;
|
||||
private readonly producer: string;
|
||||
|
||||
constructor(options?: PdfWriterOptions) {
|
||||
this.clock = options?.clock ?? DEFAULT_CLOCK;
|
||||
this.signal = options?.signal;
|
||||
this.producer = options?.producer ?? "@suaveplan/pdf";
|
||||
this.catalogId = this.allocateId();
|
||||
this.pagesId = this.allocateId();
|
||||
}
|
||||
|
||||
/** Reserve the next free object id. */
|
||||
allocateId(): number {
|
||||
this.assertWritable();
|
||||
return this.nextId++;
|
||||
}
|
||||
|
||||
/** Add an arbitrary PDF value as an indirect object. Returns its id. */
|
||||
addObject(value: PdfValue, id?: number): number {
|
||||
this.assertWritable();
|
||||
const finalId = id ?? this.allocateId();
|
||||
this.objects.set(finalId, {
|
||||
id: finalId,
|
||||
body: encodePdfValue(value),
|
||||
});
|
||||
return finalId;
|
||||
}
|
||||
|
||||
/** Add a stream object (optionally FlateDecode-compressed). */
|
||||
addStream(
|
||||
data: Uint8Array | string,
|
||||
filter: PdfStreamFilter = "None",
|
||||
extraEntries: ReadonlyArray<readonly [string, PdfValue]> = []
|
||||
): number {
|
||||
this.assertWritable();
|
||||
const id = this.allocateId();
|
||||
const rawBytes =
|
||||
typeof data === "string" ? new TextEncoder().encode(data) : data;
|
||||
const streamBytes =
|
||||
filter === "FlateDecode" ? deflateSync(rawBytes) : rawBytes;
|
||||
const dictEntries: Array<readonly [string, PdfValue]> = [
|
||||
["/Length", { kind: "number", value: streamBytes.length }],
|
||||
...extraEntries,
|
||||
];
|
||||
if (filter === "FlateDecode") {
|
||||
dictEntries.splice(1, 0, [
|
||||
"/Filter",
|
||||
{ kind: "name", value: "/FlateDecode" },
|
||||
]);
|
||||
}
|
||||
const body = encodePdfValue({ kind: "dict", entries: dictEntries });
|
||||
this.objects.set(id, {
|
||||
id,
|
||||
body,
|
||||
streamBytes,
|
||||
filter,
|
||||
} as PendingStreamObject);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register one of the 14 standard PDF fonts. Returns the resource
|
||||
* name (`F1`, `F2`, …) used inside content streams. Idempotent: a
|
||||
* second call with the same `name` returns the same resource name
|
||||
* without allocating another object.
|
||||
*/
|
||||
registerFont(name: string): string {
|
||||
this.assertWritable();
|
||||
if (!STANDARD_FONTS.has(name)) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.FONT_UNKNOWN,
|
||||
`unknown standard font: ${name}`
|
||||
);
|
||||
}
|
||||
const existing = this.fontResources.get(name);
|
||||
if (existing !== undefined) {
|
||||
return `F${existing}`;
|
||||
}
|
||||
const id = this.allocateId();
|
||||
this.fontResources.set(name, id);
|
||||
this.addObject(
|
||||
{
|
||||
kind: "dict",
|
||||
entries: [
|
||||
["/Type", { kind: "name", value: "/Font" }],
|
||||
["/Subtype", { kind: "name", value: "/Type1" }],
|
||||
["/BaseFont", { kind: "name", value: `/${name}` }],
|
||||
["/Encoding", { kind: "name", value: "/WinAnsiEncoding" }],
|
||||
],
|
||||
},
|
||||
id
|
||||
);
|
||||
return `F${id}`;
|
||||
}
|
||||
|
||||
/** Append a page object referencing a previously-added content stream. */
|
||||
addPage(page: PdfPageInput): number {
|
||||
this.assertWritable();
|
||||
const pageId = this.allocateId();
|
||||
const fontDict: Array<readonly [string, PdfValue]> = [];
|
||||
const fontMap = page.fontResources ?? this.defaultFontMap();
|
||||
for (const [resourceName, objectId] of fontMap) {
|
||||
fontDict.push([
|
||||
`/${resourceName}`,
|
||||
{ kind: "ref", id: objectId, generation: 0 },
|
||||
]);
|
||||
}
|
||||
const resourceEntries: Array<readonly [string, PdfValue]> = [
|
||||
["/Font", { kind: "dict", entries: fontDict }],
|
||||
];
|
||||
if (page.xobjectResources && page.xobjectResources.size > 0) {
|
||||
const xobjEntries: Array<readonly [string, PdfValue]> = [];
|
||||
for (const [name, id] of page.xobjectResources) {
|
||||
xobjEntries.push([
|
||||
`/${name}`,
|
||||
{ kind: "ref", id, generation: 0 },
|
||||
]);
|
||||
}
|
||||
resourceEntries.push([
|
||||
"/XObject",
|
||||
{ kind: "dict", entries: xobjEntries },
|
||||
]);
|
||||
}
|
||||
this.addObject(
|
||||
{
|
||||
kind: "dict",
|
||||
entries: [
|
||||
["/Type", { kind: "name", value: "/Page" }],
|
||||
[
|
||||
"/Parent",
|
||||
{ kind: "ref", id: this.pagesId, generation: 0 },
|
||||
],
|
||||
[
|
||||
"/MediaBox",
|
||||
{
|
||||
kind: "array",
|
||||
items: [
|
||||
{ kind: "number", value: 0 },
|
||||
{ kind: "number", value: 0 },
|
||||
{ kind: "number", value: page.width },
|
||||
{ kind: "number", value: page.height },
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
"/Contents",
|
||||
{
|
||||
kind: "ref",
|
||||
id: page.contentStreamId,
|
||||
generation: 0,
|
||||
},
|
||||
],
|
||||
["/Resources", { kind: "dict", entries: resourceEntries }],
|
||||
],
|
||||
},
|
||||
pageId
|
||||
);
|
||||
this.pageIds.push(pageId);
|
||||
return pageId;
|
||||
}
|
||||
|
||||
/** Default font-resource map built from all currently-registered fonts. */
|
||||
private defaultFontMap(): Map<string, number> {
|
||||
const map = new Map<string, number>();
|
||||
for (const [, id] of this.fontResources) {
|
||||
map.set(`F${id}`, id);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Generate the complete PDF document. */
|
||||
generate(metadata?: PdfWriterMetadata): Uint8Array {
|
||||
this.assertWritable();
|
||||
this.assertNotAborted();
|
||||
// Finalize the /Pages object now that all page ids are known.
|
||||
this.addObject(
|
||||
{
|
||||
kind: "dict",
|
||||
entries: [
|
||||
["/Type", { kind: "name", value: "/Pages" }],
|
||||
[
|
||||
"/Kids",
|
||||
{
|
||||
kind: "array",
|
||||
items: this.pageIds.map((id) => ({
|
||||
kind: "ref" as const,
|
||||
id,
|
||||
generation: 0,
|
||||
})),
|
||||
},
|
||||
],
|
||||
["/Count", { kind: "number", value: this.pageIds.length }],
|
||||
],
|
||||
},
|
||||
this.pagesId
|
||||
);
|
||||
|
||||
// /Info dictionary (always emitted so Producer is recorded).
|
||||
const infoId = this.writeInfoObject(metadata);
|
||||
|
||||
// Catalog last so it can reference both /Pages and /Info.
|
||||
const catalogEntries: Array<readonly [string, PdfValue]> = [
|
||||
["/Type", { kind: "name", value: "/Catalog" }],
|
||||
["/Pages", { kind: "ref", id: this.pagesId, generation: 0 }],
|
||||
];
|
||||
this.addObject(
|
||||
{ kind: "dict", entries: catalogEntries },
|
||||
this.catalogId
|
||||
);
|
||||
|
||||
this.assertNotAborted();
|
||||
const result = this.serialise(infoId);
|
||||
this.disposed = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Mark the writer as disposed so no further writes succeed. */
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
}
|
||||
|
||||
private writeInfoObject(metadata?: PdfWriterMetadata): number {
|
||||
const entries: Array<readonly [string, PdfValue]> = [];
|
||||
if (metadata?.Title)
|
||||
entries.push(["/Title", { kind: "string", value: metadata.Title }]);
|
||||
if (metadata?.Author)
|
||||
entries.push([
|
||||
"/Author",
|
||||
{ kind: "string", value: metadata.Author },
|
||||
]);
|
||||
if (metadata?.Subject)
|
||||
entries.push([
|
||||
"/Subject",
|
||||
{ kind: "string", value: metadata.Subject },
|
||||
]);
|
||||
if (metadata?.Creator)
|
||||
entries.push([
|
||||
"/Creator",
|
||||
{ kind: "string", value: metadata.Creator },
|
||||
]);
|
||||
if (metadata?.Keywords)
|
||||
entries.push([
|
||||
"/Keywords",
|
||||
{ kind: "string", value: metadata.Keywords },
|
||||
]);
|
||||
entries.push([
|
||||
"/Producer",
|
||||
{
|
||||
kind: "string",
|
||||
value: metadata?.Producer ?? this.producer,
|
||||
},
|
||||
]);
|
||||
entries.push([
|
||||
"/CreationDate",
|
||||
{ kind: "string", value: formatPdfDate(this.clock()) },
|
||||
]);
|
||||
const infoId = this.allocateId();
|
||||
this.addObject({ kind: "dict", entries }, infoId);
|
||||
return infoId;
|
||||
}
|
||||
|
||||
private serialise(infoId: number): Uint8Array {
|
||||
const encoder = new TextEncoder();
|
||||
const chunks: Uint8Array[] = [];
|
||||
const offsets = new Map<number, number>();
|
||||
let position = 0;
|
||||
|
||||
const push = (bytes: Uint8Array): void => {
|
||||
chunks.push(bytes);
|
||||
position += bytes.length;
|
||||
};
|
||||
|
||||
// Header + binary-marker comment.
|
||||
push(encoder.encode("%PDF-1.4\n"));
|
||||
push(new Uint8Array([0x25, 0xc0, 0xc1, 0xc2, 0xc3, 0x0a]));
|
||||
|
||||
const sortedIds = [...this.objects.keys()].sort((a, b) => a - b);
|
||||
for (const id of sortedIds) {
|
||||
this.assertNotAborted();
|
||||
const obj = this.objects.get(id) as PendingObject;
|
||||
offsets.set(id, position);
|
||||
push(encoder.encode(`${id} 0 obj\n${obj.body}\n`));
|
||||
if (isStreamObject(obj)) {
|
||||
push(encoder.encode("stream\n"));
|
||||
push(obj.streamBytes);
|
||||
push(encoder.encode("\nendstream\n"));
|
||||
}
|
||||
push(encoder.encode("endobj\n"));
|
||||
}
|
||||
|
||||
const xrefOffset = position;
|
||||
// generate() always allocates at least the catalog + pages ids, so
|
||||
// sortedIds is never empty by construction; cast away the union.
|
||||
const xrefSize = (sortedIds[sortedIds.length - 1] as number) + 1;
|
||||
const xrefLines: string[] = [
|
||||
`xref\n0 ${xrefSize}\n`,
|
||||
"0000000000 65535 f \n",
|
||||
];
|
||||
for (let i = 1; i < xrefSize; i++) {
|
||||
// Allocated ids that were never written (rare — only via
|
||||
// explicit allocateId without addObject) emit offset 0.
|
||||
const off = offsets.get(i) ?? 0;
|
||||
xrefLines.push(`${off.toString().padStart(10, "0")} 00000 n \n`);
|
||||
}
|
||||
push(encoder.encode(xrefLines.join("")));
|
||||
|
||||
const trailer = encodePdfValue({
|
||||
kind: "dict",
|
||||
entries: [
|
||||
["/Size", { kind: "number", value: xrefSize }],
|
||||
["/Root", { kind: "ref", id: this.catalogId, generation: 0 }],
|
||||
["/Info", { kind: "ref", id: infoId, generation: 0 }],
|
||||
],
|
||||
});
|
||||
push(
|
||||
encoder.encode(
|
||||
`trailer\n${trailer}\nstartxref\n${xrefOffset}\n%%EOF\n`
|
||||
)
|
||||
);
|
||||
|
||||
return concat(chunks);
|
||||
}
|
||||
|
||||
private assertWritable(): void {
|
||||
if (this.disposed) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.WRITER_DISPOSED,
|
||||
"PdfWriter has been disposed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private assertNotAborted(): void {
|
||||
if (this.signal?.aborted) {
|
||||
throw new PdfError(PDF_CODES.ABORTED, "PDF generation aborted");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The 14 standard PDF fonts (PDF 1.4 §9.6.2.2 — always available). */
|
||||
export const STANDARD_FONTS: ReadonlySet<string> = new Set([
|
||||
"Helvetica",
|
||||
"Helvetica-Bold",
|
||||
"Helvetica-Oblique",
|
||||
"Helvetica-BoldOblique",
|
||||
"Times-Roman",
|
||||
"Times-Bold",
|
||||
"Times-Italic",
|
||||
"Times-BoldItalic",
|
||||
"Courier",
|
||||
"Courier-Bold",
|
||||
"Courier-Oblique",
|
||||
"Courier-BoldOblique",
|
||||
"Symbol",
|
||||
"ZapfDingbats",
|
||||
]);
|
||||
|
||||
function concat(chunks: ReadonlyArray<Uint8Array>): Uint8Array {
|
||||
let total = 0;
|
||||
for (const c of chunks) total += c.length;
|
||||
const out = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const c of chunks) {
|
||||
out.set(c, offset);
|
||||
offset += c.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* Discriminated union of the eight PDF 1.4 object types the writer encodes.
|
||||
* Each variant carries the minimal data needed to round-trip through
|
||||
* {@link encodePdfValue}. The encoder is type-driven (one branch per
|
||||
* `kind`) so missing variants surface as TypeScript errors rather than
|
||||
* runtime failures.
|
||||
*/
|
||||
export type PdfValue =
|
||||
| { readonly kind: "null" }
|
||||
| { readonly kind: "bool"; readonly value: boolean }
|
||||
| { readonly kind: "number"; readonly value: number }
|
||||
| { readonly kind: "string"; readonly value: string }
|
||||
| { readonly kind: "hexString"; readonly value: Uint8Array }
|
||||
| { readonly kind: "name"; readonly value: string }
|
||||
| { readonly kind: "array"; readonly items: ReadonlyArray<PdfValue> }
|
||||
| {
|
||||
readonly kind: "dict";
|
||||
readonly entries: ReadonlyArray<readonly [string, PdfValue]>;
|
||||
}
|
||||
| {
|
||||
readonly kind: "ref";
|
||||
readonly id: number;
|
||||
readonly generation: number;
|
||||
};
|
||||
|
||||
/** A PDF indirect object reference (`<id> <generation> R`). */
|
||||
export interface PdfObjectRef {
|
||||
readonly id: number;
|
||||
readonly generation: number;
|
||||
}
|
||||
|
||||
/** Supported PDF stream filters. */
|
||||
export type PdfStreamFilter = "None" | "FlateDecode";
|
||||
|
||||
/** Options for the {@link PdfWriter} constructor. */
|
||||
export interface PdfWriterOptions {
|
||||
/** Wall-clock source used for `/CreationDate`. Defaults to `Date.now`. */
|
||||
readonly clock?: Clock;
|
||||
/** Abort signal — when triggered, `generate()` throws `PDF.ABORTED`. */
|
||||
readonly signal?: AbortSignal;
|
||||
/** Producer string for `/Producer` in `/Info`. Defaults to `@suaveplan/pdf`. */
|
||||
readonly producer?: string;
|
||||
}
|
||||
|
||||
/** Description of a single page's resources, supplied at `addPage` time. */
|
||||
export interface PdfPageInput {
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly contentStreamId: number;
|
||||
/** Map of font resource name (`F1`) → indirect object id. */
|
||||
readonly fontResources?: ReadonlyMap<string, number>;
|
||||
/** Map of XObject resource name (`Im0`) → indirect object id. */
|
||||
readonly xobjectResources?: ReadonlyMap<string, number>;
|
||||
}
|
||||
|
||||
/** Document-level metadata applied to the `/Info` dictionary. */
|
||||
export interface PdfWriterMetadata {
|
||||
readonly Title?: string;
|
||||
readonly Author?: string;
|
||||
readonly Subject?: string;
|
||||
readonly Creator?: string;
|
||||
readonly Producer?: string;
|
||||
readonly Keywords?: string;
|
||||
}
|
||||
7
packages/devtools/docs/pdf/src/render-pipeline/index.ts
Normal file
7
packages/devtools/docs/pdf/src/render-pipeline/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type { PipelineState, PlacementResult } from "./render-pipeline.js";
|
||||
export {
|
||||
pickFontResource,
|
||||
placeNode,
|
||||
renderSlot,
|
||||
renderTableSlice,
|
||||
} from "./render-pipeline.js";
|
||||
@@ -0,0 +1,90 @@
|
||||
# render-pipeline
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Internal pipeline helpers that the top-level `renderDocument`
|
||||
orchestrator delegates to. Splitting placement and slot-rendering out
|
||||
of the orchestrator keeps each file under the 300-line hard limit
|
||||
and lets callers test the per-node placement protocol in isolation
|
||||
from the page-emit loop. The pipeline is stateless across calls — every
|
||||
operation receives a `PipelineState` bundle (writer, layout engine,
|
||||
style resolver, image registry, abort signal, page counter) and
|
||||
mutates only the writer and the page counter.
|
||||
|
||||
## Features
|
||||
|
||||
- `placeNode` dispatches on the discriminated `DocumentNode` union
|
||||
and returns either a content-stream snippet or the `"page-break"`
|
||||
sentinel that signals the orchestrator to flush + restart.
|
||||
- Tables emit a `continuation` descriptor (`{ table, rowStart }`)
|
||||
when they partially fit, so the orchestrator can resume on the
|
||||
next page without re-measuring the full table.
|
||||
- `renderTableSlice` swallows `PDF.LAYOUT_OVERFLOW` from
|
||||
`TableElement.renderPage` and surfaces it as a `rowEnd === rowStart`
|
||||
no-progress result.
|
||||
- `renderSlot` flows header / footer nodes top-to-bottom inside the
|
||||
reserved slot rectangle.
|
||||
- `pickFontResource` re-uses the writer's idempotent font registry so
|
||||
one `F<id>` survives the whole render.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { placeNode, renderSlot, type PipelineState } from "@suaveplan/pdf";
|
||||
|
||||
const state: PipelineState = { writer, layout, styles, images, signal: undefined, pageCount: 0 };
|
||||
const result = placeNode(state, node, cursor, undefined, new Map());
|
||||
if (result === "page-break") {
|
||||
// orchestrator: flush and retry
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { placeNode, renderTableSlice, renderSlot } from "@suaveplan/pdf";
|
||||
|
||||
// Slice rendering protocol — orchestrator advances rowStart between calls.
|
||||
let rowStart = 0;
|
||||
while (rowStart < tableElement.options.rows.length) {
|
||||
const slice = renderTableSlice(state, tableElement, rowStart, cursor, config);
|
||||
if (slice.rowEnd === rowStart) {
|
||||
// No progress — caller must flush + create a fresh cursor.
|
||||
break;
|
||||
}
|
||||
rowStart = slice.rowEnd;
|
||||
// ... write slice.stream into the per-page buffer ...
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `placeNode(state, node, cursor, config?, pageXObjects)` →
|
||||
`PlacementResult`.
|
||||
- `renderTableSlice(state, table, rowStart, cursor, config?)` →
|
||||
`{ stream, rowEnd }`.
|
||||
- `renderSlot(state, nodes?, height?, slot, config?, pageXObjects)` →
|
||||
`string[]`.
|
||||
- `pickFontResource(state, style)` → `"F<id>"`.
|
||||
- Types: `PipelineState`, `PlacementResult`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`placeNode` always measures first, then conditionally advances the
|
||||
cursor. Text and divider elements pass the pre-advance `cursor.y` as
|
||||
the render `y` (PDF's bottom-up coordinate system means the top of
|
||||
the element lives at the larger `y`, and the element's `.render()`
|
||||
contract places baselines at `y - i·lineHeight`). Images measure
|
||||
once and ignore `maxWidth` because their bounds are fixed at
|
||||
construction. Tables short-circuit through `TableElement.render`
|
||||
when the whole element fits and only fall back to the slice
|
||||
protocol when pagination is required. The slot renderer flows
|
||||
nodes downward inside the reserved rectangle, which means a footer
|
||||
slot with two stacked text lines places the first line at the slot
|
||||
top and the second below — matching the visual expectation that
|
||||
flow content reads top-to-bottom regardless of PDF's bottom-up
|
||||
geometry.
|
||||
@@ -0,0 +1,383 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PNG } from "pngjs";
|
||||
import { DividerElement } from "../divider-element/divider-element.js";
|
||||
import { PDF_CODES, PdfError } from "../error-codes/error-codes.js";
|
||||
import { ImageElement } from "../image-element/image-element.js";
|
||||
import { ImageRegistry } from "../image-registry/image-registry.js";
|
||||
import { LayoutEngine } from "../layout-engine/layout-engine.js";
|
||||
import { PdfWriter } from "../pdf-writer/pdf-writer.js";
|
||||
import {
|
||||
DEFAULT_STYLE,
|
||||
StyleResolver,
|
||||
} from "../style-resolver/style-resolver.js";
|
||||
import { TableElement } from "../table-element/table-element.js";
|
||||
import { TextElement } from "../text-element/text-element.js";
|
||||
import {
|
||||
type PipelineState,
|
||||
pickFontResource,
|
||||
placeNode,
|
||||
renderSlot,
|
||||
renderTableSlice,
|
||||
} from "./render-pipeline.js";
|
||||
|
||||
const makePng = (): Uint8Array => {
|
||||
const png = new PNG({ width: 2, height: 2 });
|
||||
return new Uint8Array(PNG.sync.write(png));
|
||||
};
|
||||
|
||||
function freshState(): PipelineState {
|
||||
const writer = new PdfWriter();
|
||||
const layout = new LayoutEngine();
|
||||
const styles = new StyleResolver();
|
||||
const images = new ImageRegistry(writer);
|
||||
return {
|
||||
writer,
|
||||
layout,
|
||||
styles,
|
||||
images,
|
||||
signal: undefined,
|
||||
pageCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe("placeNode", () => {
|
||||
it("places a text node and advances the cursor", { timeout: 5000 }, () => {
|
||||
const state = freshState();
|
||||
const cursor = state.layout.createCursor();
|
||||
const node = {
|
||||
type: "text" as const,
|
||||
element: new TextElement({ content: "hello" }),
|
||||
};
|
||||
const startY = cursor.y;
|
||||
const result = placeNode(state, node, cursor, undefined, new Map());
|
||||
expect(result).not.toBe("page-break");
|
||||
expect(cursor.y).toBeLessThan(startY);
|
||||
});
|
||||
|
||||
it("places a divider node", { timeout: 5000 }, () => {
|
||||
const state = freshState();
|
||||
const cursor = state.layout.createCursor();
|
||||
const node = {
|
||||
type: "divider" as const,
|
||||
element: new DividerElement({ thickness: 2 }),
|
||||
};
|
||||
const result = placeNode(state, node, cursor, undefined, new Map());
|
||||
expect(result).not.toBe("page-break");
|
||||
});
|
||||
|
||||
it("places an image node and registers an XObject", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const state = freshState();
|
||||
const cursor = state.layout.createCursor();
|
||||
const node = {
|
||||
type: "image" as const,
|
||||
element: new ImageElement({
|
||||
src: makePng(),
|
||||
width: 10,
|
||||
height: 10,
|
||||
}),
|
||||
};
|
||||
const pageMap = new Map<string, number>();
|
||||
const result = placeNode(state, node, cursor, undefined, pageMap);
|
||||
expect(result).not.toBe("page-break");
|
||||
expect(pageMap.size).toBe(1);
|
||||
});
|
||||
|
||||
it("returns page-break when text doesn't fit", { timeout: 5000 }, () => {
|
||||
const state = freshState();
|
||||
const cursor = state.layout.createCursor();
|
||||
cursor.availableHeight = 5;
|
||||
const node = {
|
||||
type: "text" as const,
|
||||
element: new TextElement({ content: "x", fontSize: 100 }),
|
||||
};
|
||||
const result = placeNode(state, node, cursor, undefined, new Map());
|
||||
expect(result).toBe("page-break");
|
||||
});
|
||||
|
||||
it("returns page-break when divider doesn't fit", { timeout: 5000 }, () => {
|
||||
const state = freshState();
|
||||
const cursor = state.layout.createCursor();
|
||||
cursor.availableHeight = 0.5;
|
||||
const node = {
|
||||
type: "divider" as const,
|
||||
element: new DividerElement({ thickness: 10 }),
|
||||
};
|
||||
expect(placeNode(state, node, cursor, undefined, new Map())).toBe(
|
||||
"page-break"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns page-break when image doesn't fit", { timeout: 5000 }, () => {
|
||||
const state = freshState();
|
||||
const cursor = state.layout.createCursor();
|
||||
cursor.availableHeight = 5;
|
||||
const node = {
|
||||
type: "image" as const,
|
||||
element: new ImageElement({
|
||||
src: makePng(),
|
||||
width: 200,
|
||||
height: 200,
|
||||
}),
|
||||
};
|
||||
expect(placeNode(state, node, cursor, undefined, new Map())).toBe(
|
||||
"page-break"
|
||||
);
|
||||
});
|
||||
|
||||
it("places a small table fully when it fits", { timeout: 5000 }, () => {
|
||||
const state = freshState();
|
||||
const cursor = state.layout.createCursor();
|
||||
const node = {
|
||||
type: "table" as const,
|
||||
element: new TableElement({
|
||||
columns: [{ key: "a", header: "A" }],
|
||||
rows: [{ a: "x" }, { a: "y" }],
|
||||
}),
|
||||
};
|
||||
const result = placeNode(state, node, cursor, undefined, new Map());
|
||||
expect(result).not.toBe("page-break");
|
||||
if (result !== "page-break") {
|
||||
expect(result.continuation).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a continuation for a large table", { timeout: 5000 }, () => {
|
||||
const state = freshState();
|
||||
const cursor = state.layout.createCursor();
|
||||
const rows = Array.from({ length: 200 }, (_, i) => ({ a: `r${i}` }));
|
||||
const node = {
|
||||
type: "table" as const,
|
||||
element: new TableElement({
|
||||
columns: [{ key: "a", header: "A" }],
|
||||
rows,
|
||||
repeatHeader: true,
|
||||
}),
|
||||
};
|
||||
const result = placeNode(state, node, cursor, undefined, new Map());
|
||||
expect(result).not.toBe("page-break");
|
||||
if (result !== "page-break") {
|
||||
expect(result.continuation).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("page-breaks when the table cannot even fit its header", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const state = freshState();
|
||||
const cursor = state.layout.createCursor();
|
||||
cursor.availableHeight = 5;
|
||||
const node = {
|
||||
type: "table" as const,
|
||||
element: new TableElement({
|
||||
columns: [{ key: "a", header: "A" }],
|
||||
rows: [{ a: "x" }],
|
||||
rowHeight: 100,
|
||||
}),
|
||||
};
|
||||
const result = placeNode(state, node, cursor, undefined, new Map());
|
||||
expect(result).toBe("page-break");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderTableSlice", () => {
|
||||
it("returns rowEnd === rowStart when LAYOUT_OVERFLOW thrown by header", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const state = freshState();
|
||||
const cursor = state.layout.createCursor();
|
||||
cursor.availableHeight = 1;
|
||||
const table = new TableElement({
|
||||
columns: [{ key: "a", header: "A" }],
|
||||
rows: [{ a: "x" }],
|
||||
rowHeight: 50,
|
||||
});
|
||||
const result = renderTableSlice(state, table, 0, cursor, undefined);
|
||||
expect(result.rowEnd).toBe(0);
|
||||
expect(result.stream).toBe("");
|
||||
});
|
||||
|
||||
it("rethrows non-LAYOUT_OVERFLOW errors", { timeout: 5000 }, () => {
|
||||
const state = freshState();
|
||||
const cursor = state.layout.createCursor();
|
||||
// Force an ABORTED error by aborting the signal mid-call.
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const stateWithSignal: PipelineState = {
|
||||
...state,
|
||||
signal: controller.signal,
|
||||
};
|
||||
const table = new TableElement({
|
||||
columns: [{ key: "a", header: "A" }],
|
||||
rows: [{ a: "x" }],
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
renderTableSlice(stateWithSignal, table, 0, cursor, undefined);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(PdfError);
|
||||
expect((caught as PdfError).code).toBe(PDF_CODES.ABORTED);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderSlot", () => {
|
||||
it("returns empty array when nodes is undefined", { timeout: 5000 }, () => {
|
||||
const state = freshState();
|
||||
expect(
|
||||
renderSlot(state, undefined, 40, "header", undefined, new Map())
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when nodes is empty", { timeout: 5000 }, () => {
|
||||
const state = freshState();
|
||||
expect(
|
||||
renderSlot(state, [], 40, "header", undefined, new Map())
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when height is missing", { timeout: 5000 }, () => {
|
||||
const state = freshState();
|
||||
const nodes = [
|
||||
{
|
||||
type: "text" as const,
|
||||
element: new TextElement({ content: "x" }),
|
||||
},
|
||||
];
|
||||
expect(
|
||||
renderSlot(state, nodes, undefined, "header", undefined, new Map())
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("renders header text", { timeout: 5000 }, () => {
|
||||
const writer = new PdfWriter();
|
||||
const state: PipelineState = {
|
||||
writer,
|
||||
layout: new LayoutEngine({ headerHeight: 40 }),
|
||||
styles: new StyleResolver(),
|
||||
images: new ImageRegistry(writer),
|
||||
signal: undefined,
|
||||
pageCount: 0,
|
||||
};
|
||||
const nodes = [
|
||||
{
|
||||
type: "text" as const,
|
||||
element: new TextElement({ content: "HEADER" }),
|
||||
},
|
||||
];
|
||||
const streams = renderSlot(
|
||||
state,
|
||||
nodes,
|
||||
40,
|
||||
"header",
|
||||
undefined,
|
||||
new Map()
|
||||
);
|
||||
expect(streams.length).toBe(1);
|
||||
});
|
||||
|
||||
it("renders footer divider", { timeout: 5000 }, () => {
|
||||
const writer = new PdfWriter();
|
||||
const state: PipelineState = {
|
||||
writer,
|
||||
layout: new LayoutEngine({ footerHeight: 40 }),
|
||||
styles: new StyleResolver(),
|
||||
images: new ImageRegistry(writer),
|
||||
signal: undefined,
|
||||
pageCount: 0,
|
||||
};
|
||||
const nodes = [
|
||||
{
|
||||
type: "divider" as const,
|
||||
element: new DividerElement({ thickness: 1 }),
|
||||
},
|
||||
];
|
||||
const streams = renderSlot(
|
||||
state,
|
||||
nodes,
|
||||
40,
|
||||
"footer",
|
||||
undefined,
|
||||
new Map()
|
||||
);
|
||||
expect(streams.length).toBe(1);
|
||||
});
|
||||
|
||||
it("renders header image and registers XObject", { timeout: 5000 }, () => {
|
||||
const writer = new PdfWriter();
|
||||
const state: PipelineState = {
|
||||
writer,
|
||||
layout: new LayoutEngine({ headerHeight: 40 }),
|
||||
styles: new StyleResolver(),
|
||||
images: new ImageRegistry(writer),
|
||||
signal: undefined,
|
||||
pageCount: 0,
|
||||
};
|
||||
const node = {
|
||||
type: "image" as const,
|
||||
element: new ImageElement({
|
||||
src: makePng(),
|
||||
width: 20,
|
||||
height: 20,
|
||||
}),
|
||||
};
|
||||
const pageMap = new Map<string, number>();
|
||||
const streams = renderSlot(
|
||||
state,
|
||||
[node],
|
||||
40,
|
||||
"header",
|
||||
undefined,
|
||||
pageMap
|
||||
);
|
||||
expect(streams.length).toBe(1);
|
||||
expect(pageMap.size).toBe(1);
|
||||
});
|
||||
|
||||
it("renders header table", { timeout: 5000 }, () => {
|
||||
const writer = new PdfWriter();
|
||||
const state: PipelineState = {
|
||||
writer,
|
||||
layout: new LayoutEngine({ headerHeight: 40 }),
|
||||
styles: new StyleResolver(),
|
||||
images: new ImageRegistry(writer),
|
||||
signal: undefined,
|
||||
pageCount: 0,
|
||||
};
|
||||
const node = {
|
||||
type: "table" as const,
|
||||
element: new TableElement({
|
||||
columns: [{ key: "a", header: "A" }],
|
||||
rows: [{ a: "x" }],
|
||||
}),
|
||||
};
|
||||
const streams = renderSlot(
|
||||
state,
|
||||
[node],
|
||||
40,
|
||||
"header",
|
||||
undefined,
|
||||
new Map()
|
||||
);
|
||||
expect(streams.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickFontResource", () => {
|
||||
it("returns an F<id> resource", { timeout: 5000 }, () => {
|
||||
const state = freshState();
|
||||
const resource = pickFontResource(state, DEFAULT_STYLE);
|
||||
expect(resource).toMatch(/^F\d+$/);
|
||||
});
|
||||
|
||||
it("returns the same resource for the same style", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const state = freshState();
|
||||
const r1 = pickFontResource(state, DEFAULT_STYLE);
|
||||
const r2 = pickFontResource(state, DEFAULT_STYLE);
|
||||
expect(r1).toBe(r2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
import type { DocumentNode } from "../document-builder/document-builder.types.js";
|
||||
import { PDF_CODES, PdfError } from "../error-codes/error-codes.js";
|
||||
import type { ImageRegistry } from "../image-registry/image-registry.js";
|
||||
import type { LayoutEngine } from "../layout-engine/layout-engine.js";
|
||||
import type { LayoutCursor } from "../layout-engine/layout-engine.types.js";
|
||||
import type { PdfWriter } from "../pdf-writer/pdf-writer.js";
|
||||
import type { StyleResolver } from "../style-resolver/style-resolver.js";
|
||||
import type { PdfStyle } from "../style-resolver/style-resolver.types.js";
|
||||
import type { TableElement } from "../table-element/table-element.js";
|
||||
import { resolveStandardFont } from "../text-element/text-element.js";
|
||||
import type { PageConfig } from "../types/page-size.js";
|
||||
|
||||
/**
|
||||
* Shared pipeline state passed around between the placement, slot
|
||||
* rendering, and page-emit helpers. The pipeline never mutates the
|
||||
* document AST — it only writes pages through `state.writer`.
|
||||
*/
|
||||
export interface PipelineState {
|
||||
readonly writer: PdfWriter;
|
||||
readonly layout: LayoutEngine;
|
||||
readonly styles: StyleResolver;
|
||||
readonly images: ImageRegistry;
|
||||
readonly signal: AbortSignal | undefined;
|
||||
pageCount: number;
|
||||
}
|
||||
|
||||
/** Outcome of `placeNode`: a rendered stream or a request to page-break. */
|
||||
export type PlacementResult =
|
||||
| {
|
||||
readonly stream: string;
|
||||
readonly continuation?: {
|
||||
readonly table: TableElement<unknown>;
|
||||
readonly rowStart: number;
|
||||
};
|
||||
}
|
||||
| "page-break";
|
||||
|
||||
/**
|
||||
* Try to place `node` at the current cursor. Returns the rendered
|
||||
* stream snippet or the sentinel `"page-break"` when the node does
|
||||
* not fit in the remaining vertical space. Tables emit a continuation
|
||||
* descriptor when they partially fit.
|
||||
*/
|
||||
export function placeNode(
|
||||
state: PipelineState,
|
||||
node: DocumentNode,
|
||||
cursor: LayoutCursor,
|
||||
config: Partial<PageConfig> | undefined,
|
||||
pageXObjects: Map<string, number>
|
||||
): PlacementResult {
|
||||
const style = state.styles.resolve(node.style);
|
||||
if (node.type === "text") {
|
||||
return placeText(state, node.element, cursor, config, style);
|
||||
}
|
||||
if (node.type === "divider") {
|
||||
return placeDivider(state, node.element, cursor, config);
|
||||
}
|
||||
if (node.type === "image") {
|
||||
return placeImage(state, node.element, cursor, config, pageXObjects);
|
||||
}
|
||||
return placeTable(state, node.element, cursor, config, style);
|
||||
}
|
||||
|
||||
function placeText(
|
||||
state: PipelineState,
|
||||
element: import("../text-element/text-element.js").TextElement,
|
||||
cursor: LayoutCursor,
|
||||
config: Partial<PageConfig> | undefined,
|
||||
style: PdfStyle
|
||||
): PlacementResult {
|
||||
const measurement = element.measure(cursor.availableWidth);
|
||||
if (measurement.height > cursor.availableHeight) return "page-break";
|
||||
const renderY = cursor.y;
|
||||
state.layout.advance(cursor, measurement.height, config);
|
||||
const out = element.render({
|
||||
x: cursor.x,
|
||||
y: renderY,
|
||||
maxWidth: cursor.availableWidth,
|
||||
fontResourceName: pickFontResource(state, style),
|
||||
signal: state.signal,
|
||||
});
|
||||
return { stream: out.stream };
|
||||
}
|
||||
|
||||
function placeDivider(
|
||||
state: PipelineState,
|
||||
element: import("../divider-element/divider-element.js").DividerElement,
|
||||
cursor: LayoutCursor,
|
||||
config: Partial<PageConfig> | undefined
|
||||
): PlacementResult {
|
||||
const measurement = element.measure(cursor.availableWidth);
|
||||
if (measurement.height > cursor.availableHeight) return "page-break";
|
||||
const renderY = cursor.y;
|
||||
state.layout.advance(cursor, measurement.height, config);
|
||||
const out = element.render({
|
||||
x: cursor.x,
|
||||
y: renderY,
|
||||
maxWidth: cursor.availableWidth,
|
||||
signal: state.signal,
|
||||
});
|
||||
return { stream: out.stream };
|
||||
}
|
||||
|
||||
function placeImage(
|
||||
state: PipelineState,
|
||||
element: import("../image-element/image-element.js").ImageElement,
|
||||
cursor: LayoutCursor,
|
||||
config: Partial<PageConfig> | undefined,
|
||||
pageXObjects: Map<string, number>
|
||||
): PlacementResult {
|
||||
const measurement = element.measure();
|
||||
if (measurement.height > cursor.availableHeight) return "page-break";
|
||||
const xobjectName = state.images.ensure(element, pageXObjects);
|
||||
const renderY = cursor.y;
|
||||
state.layout.advance(cursor, measurement.height, config);
|
||||
const out = element.render({
|
||||
x: cursor.x,
|
||||
y: renderY,
|
||||
maxWidth: cursor.availableWidth,
|
||||
fontResourceName: xobjectName,
|
||||
signal: state.signal,
|
||||
});
|
||||
return { stream: out.stream };
|
||||
}
|
||||
|
||||
function placeTable(
|
||||
state: PipelineState,
|
||||
table: TableElement<unknown>,
|
||||
cursor: LayoutCursor,
|
||||
config: Partial<PageConfig> | undefined,
|
||||
style: PdfStyle
|
||||
): PlacementResult {
|
||||
const fontResource = pickFontResource(state, style);
|
||||
const fullMeasure = table.measure(cursor.availableWidth);
|
||||
if (fullMeasure.height <= cursor.availableHeight) {
|
||||
const out = table.render({
|
||||
x: cursor.x,
|
||||
y: cursor.y,
|
||||
maxWidth: cursor.availableWidth,
|
||||
fontResourceName: fontResource,
|
||||
signal: state.signal,
|
||||
});
|
||||
state.layout.advance(cursor, out.height, config);
|
||||
return { stream: out.stream };
|
||||
}
|
||||
const sliced = renderTableSlice(state, table, 0, cursor, config);
|
||||
if (sliced.rowEnd === 0) {
|
||||
return "page-break";
|
||||
}
|
||||
// `fullMeasure.height > cursor.availableHeight` implies the slice could
|
||||
// not have covered every row at this point, so the slice MUST be partial.
|
||||
return {
|
||||
stream: sliced.stream,
|
||||
continuation: { table, rowStart: sliced.rowEnd },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a partial slice of a paginated table starting at `rowStart`.
|
||||
* Catches `PDF.LAYOUT_OVERFLOW` from `TableElement.renderPage` and
|
||||
* surfaces it as a `rowEnd === rowStart` no-progress signal so the
|
||||
* caller can request a fresh page.
|
||||
*/
|
||||
export function renderTableSlice(
|
||||
state: PipelineState,
|
||||
table: TableElement<unknown>,
|
||||
rowStart: number,
|
||||
cursor: LayoutCursor,
|
||||
config: Partial<PageConfig> | undefined
|
||||
): { readonly stream: string; readonly rowEnd: number } {
|
||||
const fontResource = pickFontResource(state, state.styles.resolve());
|
||||
try {
|
||||
const page = table.renderPage(
|
||||
{
|
||||
x: cursor.x,
|
||||
y: cursor.y,
|
||||
maxWidth: cursor.availableWidth,
|
||||
maxHeight: cursor.availableHeight,
|
||||
fontResourceName: fontResource,
|
||||
signal: state.signal,
|
||||
},
|
||||
rowStart
|
||||
);
|
||||
state.layout.advance(cursor, page.height, config);
|
||||
return { stream: page.stream, rowEnd: page.rowEnd };
|
||||
} catch (err) {
|
||||
if (err instanceof PdfError && err.code === PDF_CODES.LAYOUT_OVERFLOW) {
|
||||
return { stream: "", rowEnd: rowStart };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the supplied header / footer nodes into the layout engine's
|
||||
* reserved slot rectangle. The slot does not paginate — nodes flow
|
||||
* top-to-bottom inside the band and any overflow is the caller's
|
||||
* responsibility to declare via a larger `headerHeight` / `footerHeight`.
|
||||
*/
|
||||
export function renderSlot(
|
||||
state: PipelineState,
|
||||
nodes: ReadonlyArray<DocumentNode> | undefined,
|
||||
height: number | undefined,
|
||||
slot: "header" | "footer",
|
||||
config: Partial<PageConfig> | undefined,
|
||||
pageXObjects: Map<string, number>
|
||||
): string[] {
|
||||
if (!nodes || nodes.length === 0 || !height) return [];
|
||||
const area =
|
||||
slot === "header"
|
||||
? state.layout.getHeaderArea(config)
|
||||
: state.layout.getFooterArea(config);
|
||||
const out: string[] = [];
|
||||
let y = area.y + area.height;
|
||||
for (const node of nodes) {
|
||||
const style = state.styles.resolve(node.style);
|
||||
const measurement = measureNode(node, area.width);
|
||||
const stream = renderSlotNode(
|
||||
state,
|
||||
node,
|
||||
area.x,
|
||||
y,
|
||||
area.width,
|
||||
style,
|
||||
pageXObjects
|
||||
);
|
||||
out.push(stream);
|
||||
y -= measurement.height;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderSlotNode(
|
||||
state: PipelineState,
|
||||
node: DocumentNode,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
style: PdfStyle,
|
||||
pageXObjects: Map<string, number>
|
||||
): string {
|
||||
if (node.type === "image") {
|
||||
const xobjectName = state.images.ensure(node.element, pageXObjects);
|
||||
return node.element.render({
|
||||
x,
|
||||
y,
|
||||
maxWidth: width,
|
||||
fontResourceName: xobjectName,
|
||||
signal: state.signal,
|
||||
}).stream;
|
||||
}
|
||||
if (node.type === "table") {
|
||||
return node.element.render({
|
||||
x,
|
||||
y,
|
||||
maxWidth: width,
|
||||
fontResourceName: pickFontResource(state, style),
|
||||
signal: state.signal,
|
||||
}).stream;
|
||||
}
|
||||
if (node.type === "text") {
|
||||
return node.element.render({
|
||||
x,
|
||||
y,
|
||||
maxWidth: width,
|
||||
fontResourceName: pickFontResource(state, style),
|
||||
signal: state.signal,
|
||||
}).stream;
|
||||
}
|
||||
return node.element.render({
|
||||
x,
|
||||
y,
|
||||
maxWidth: width,
|
||||
signal: state.signal,
|
||||
}).stream;
|
||||
}
|
||||
|
||||
function measureNode(
|
||||
node: DocumentNode,
|
||||
width: number
|
||||
): { readonly width: number; readonly height: number } {
|
||||
if (node.type === "image") return node.element.measure();
|
||||
return node.element.measure(width);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a font resource name for the supplied style. Idempotent:
|
||||
* re-registering the same font returns the same `F<id>` resource.
|
||||
*/
|
||||
export function pickFontResource(
|
||||
state: PipelineState,
|
||||
style: PdfStyle
|
||||
): string {
|
||||
const fontName = resolveStandardFont(
|
||||
style.fontFamily,
|
||||
style.fontWeight,
|
||||
style.fontStyle
|
||||
);
|
||||
return state.writer.registerFont(fontName);
|
||||
}
|
||||
2
packages/devtools/docs/pdf/src/renderer/index.ts
Normal file
2
packages/devtools/docs/pdf/src/renderer/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { renderDocument } from "./renderer.js";
|
||||
export type { RenderOptions, RenderResult } from "./renderer.types.js";
|
||||
120
packages/devtools/docs/pdf/src/renderer/renderer.md
Normal file
120
packages/devtools/docs/pdf/src/renderer/renderer.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# renderer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`renderDocument` is the top-level orchestrator that turns a
|
||||
`Document` AST (produced by `DocumentBuilder`) into a real PDF 1.4
|
||||
binary. It wires together the chunk B `PdfWriter`, the chunk D
|
||||
`LayoutEngine` and `StyleResolver`, and every chunk C element's
|
||||
`measure()` + `render()` pair into a deterministic page-by-page flow
|
||||
loop. Image XObjects are registered lazily, font resources are
|
||||
re-used across pages, tables split across pages via
|
||||
`TableElement.renderPage`, and headers / footers are stamped on every
|
||||
emitted physical page. All output is verifiable end-to-end via
|
||||
`pdf-parse`.
|
||||
|
||||
## Features
|
||||
|
||||
- Single-call entry point: `renderDocument(doc, options?)` returns
|
||||
`{ bytes, pageCount, durationMs }`.
|
||||
- Automatic page break: when an element's measured height exceeds
|
||||
the cursor's remaining vertical space, the renderer flushes the
|
||||
current page and retries on a fresh one.
|
||||
- Table pagination via `TableElement.renderPage(context, rowStart)`
|
||||
with optional header repeat.
|
||||
- Image XObject registration is per-document with per-page
|
||||
inclusion: a shared image referenced on three pages allocates one
|
||||
XObject and three `Im<id>` entries in the page `/Resources` dict.
|
||||
- Header / footer slots reserve vertical space up-front so flow
|
||||
content never collides with repeating page furniture.
|
||||
- AbortSignal honoured between every node placement and every page
|
||||
flush.
|
||||
- Six OpenTelemetry instruments recorded per call: documents-
|
||||
rendered counter (tagged success), pages-emitted counter, render-
|
||||
errors counter (tagged `PDF.*` code), validation-failures counter,
|
||||
render-duration histogram, document-size histogram.
|
||||
- Injectable `Clock` for deterministic `/CreationDate`, injectable
|
||||
`producer` string for the `/Info` dictionary, injectable
|
||||
`observability` bundle or factory options.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createDocument, renderDocument } from "@suaveplan/pdf";
|
||||
|
||||
const doc = createDocument()
|
||||
.title("Hello")
|
||||
.addText("Hello, world.")
|
||||
.build();
|
||||
const { bytes, pageCount } = renderDocument(doc);
|
||||
// bytes is a real PDF 1.4 Uint8Array; pageCount === 1.
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
createDocument,
|
||||
DEFAULT_THEME,
|
||||
renderDocument,
|
||||
} from "@suaveplan/pdf";
|
||||
|
||||
const controller = new AbortController();
|
||||
const clock = { now: () => 1700000000000 };
|
||||
|
||||
const doc = createDocument()
|
||||
.title("Annual Report 2026")
|
||||
.author("Suaveplan Reporting")
|
||||
.addText("Section 1: Overview", { fontWeight: "bold", fontSize: 18 })
|
||||
.addDivider({ spacingAbove: 4, spacingBelow: 12 })
|
||||
.addTable(
|
||||
[
|
||||
{ key: "metric", header: "Metric" },
|
||||
{ key: "value", header: "Value", align: "right" },
|
||||
],
|
||||
Array.from({ length: 80 }, (_, i) => ({
|
||||
metric: `Row ${i + 1}`,
|
||||
value: `${i * 17}`,
|
||||
})),
|
||||
{ border: true, repeatHeader: true }
|
||||
)
|
||||
.build();
|
||||
|
||||
const result = renderDocument(doc, {
|
||||
theme: { ...DEFAULT_THEME, colors: { ...DEFAULT_THEME.colors, text: "#1f2937" } },
|
||||
signal: controller.signal,
|
||||
clock,
|
||||
producer: "Suaveplan Reporting/1.0",
|
||||
});
|
||||
// result.pageCount > 1 because the table paginates with header repeat.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `renderDocument<TData>(doc, options?)` → `RenderResult`.
|
||||
- Options: `theme?`, `signal?`, `clock?`, `producer?`,
|
||||
`observability?`, `observabilityOptions?`.
|
||||
- Result: `{ bytes: Uint8Array, pageCount: number, durationMs: number }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The renderer maintains a `RenderState` carrying the active writer,
|
||||
layout engine, style resolver, image registry, and abort signal.
|
||||
Each logical `Document.page` enters `renderLogicalPage`, which sets
|
||||
up a `cursor`, calls `beginPage()` to seed the per-page stream
|
||||
buffer with header + footer fragments, then walks the node list.
|
||||
Text / divider / image nodes measure first, advance the cursor, and
|
||||
render at the pre-advance `y` (PDF's bottom-up coordinates mean the
|
||||
top of an element lives at the larger `y`). Tables that fit in the
|
||||
remaining height render in one pass; tables that overflow call
|
||||
`TableElement.renderPage(context, rowStart)` to emit as many rows as
|
||||
fit, then carry the remaining `rowStart` into the next page via a
|
||||
`tableContinuation` slot — this is the same pagination protocol
|
||||
chunk C declared. Image XObjects are registered once per
|
||||
`ImageElement` instance and reused across pages. Telemetry is
|
||||
recorded in both the success and failure branches of the top-level
|
||||
try/catch so duration is never lost.
|
||||
466
packages/devtools/docs/pdf/src/renderer/renderer.test.ts
Normal file
466
packages/devtools/docs/pdf/src/renderer/renderer.test.ts
Normal file
@@ -0,0 +1,466 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PDFParse } from "pdf-parse";
|
||||
import { PNG } from "pngjs";
|
||||
import { DividerElement } from "../divider-element/divider-element.js";
|
||||
import { createDocument } from "../document-builder/document-builder.js";
|
||||
import { PDF_CODES, PdfError } from "../error-codes/error-codes.js";
|
||||
import { ImageElement } from "../image-element/image-element.js";
|
||||
import { DEFAULT_THEME } from "../style-resolver/style-resolver.js";
|
||||
import { TableElement } from "../table-element/table-element.js";
|
||||
import { TextElement } from "../text-element/text-element.js";
|
||||
import { PageSize } from "../types/page-size.js";
|
||||
import { renderDocument } from "./renderer.js";
|
||||
|
||||
const makePng = (width = 4, height = 4): Uint8Array => {
|
||||
const png = new PNG({ width, height });
|
||||
for (let i = 0; i < png.data.length; i += 4) {
|
||||
png.data[i] = 0;
|
||||
png.data[i + 1] = 128;
|
||||
png.data[i + 2] = 255;
|
||||
png.data[i + 3] = 255;
|
||||
}
|
||||
return new Uint8Array(PNG.sync.write(png));
|
||||
};
|
||||
const MINIMAL_PNG = makePng();
|
||||
|
||||
describe("renderDocument — single-page round-trip", () => {
|
||||
it("emits valid PDF bytes for a hello-world document", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const doc = createDocument()
|
||||
.title("Hello")
|
||||
.author("Test")
|
||||
.subject("Subj")
|
||||
.keywords(["a", "b"])
|
||||
.addText("Hello, world.")
|
||||
.build();
|
||||
const result = renderDocument(doc);
|
||||
expect(result.bytes).toBeInstanceOf(Uint8Array);
|
||||
expect(result.pageCount).toBe(1);
|
||||
const parser = new PDFParse({ data: result.bytes });
|
||||
try {
|
||||
const info = await parser.getInfo();
|
||||
const text = await parser.getText();
|
||||
expect(info.total).toBe(1);
|
||||
expect(text.text).toContain("Hello, world.");
|
||||
} finally {
|
||||
await parser.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it("emits multiple physical pages via newPage", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const doc = createDocument()
|
||||
.addText("page-one-content")
|
||||
.newPage()
|
||||
.addText("page-two-content")
|
||||
.build();
|
||||
const result = renderDocument(doc);
|
||||
expect(result.pageCount).toBe(2);
|
||||
const parser = new PDFParse({ data: result.bytes });
|
||||
try {
|
||||
const text = await parser.getText();
|
||||
expect(text.text).toContain("page-one-content");
|
||||
expect(text.text).toContain("page-two-content");
|
||||
} finally {
|
||||
await parser.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it("includes durationMs in the result", { timeout: 5000 }, () => {
|
||||
const doc = createDocument().addText("x").build();
|
||||
const clock = makeMonotonicClock([0, 10]);
|
||||
const result = renderDocument(doc, { clock });
|
||||
expect(result.durationMs).toBe(10);
|
||||
});
|
||||
|
||||
it("propagates custom producer string", { timeout: 5000 }, () => {
|
||||
const doc = createDocument().addText("x").build();
|
||||
const result = renderDocument(doc, { producer: "SuaveSuite/1.0" });
|
||||
const decoded = new TextDecoder().decode(result.bytes);
|
||||
expect(decoded).toContain("SuaveSuite/1.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderDocument — page break", () => {
|
||||
it("breaks a tall list of paragraphs across pages", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const builder = createDocument().title("LongDoc");
|
||||
for (let i = 0; i < 50; i++) {
|
||||
builder.addText(`paragraph-line-${i}`, { fontSize: 18 });
|
||||
}
|
||||
const doc = builder.build();
|
||||
const result = renderDocument(doc);
|
||||
expect(result.pageCount).toBeGreaterThan(1);
|
||||
const parser = new PDFParse({ data: result.bytes });
|
||||
try {
|
||||
const text = await parser.getText();
|
||||
expect(text.text).toContain("paragraph-line-0");
|
||||
expect(text.text).toContain("paragraph-line-49");
|
||||
} finally {
|
||||
await parser.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it("paginates an oversize table with header repeat", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const rows = Array.from({ length: 200 }, (_, i) => ({
|
||||
id: i,
|
||||
name: `row-${i}`,
|
||||
}));
|
||||
const doc = createDocument()
|
||||
.addTable(
|
||||
[
|
||||
{ key: "id", header: "ID" },
|
||||
{ key: "name", header: "Name" },
|
||||
],
|
||||
rows,
|
||||
{ repeatHeader: true, border: true }
|
||||
)
|
||||
.build();
|
||||
const result = renderDocument(doc);
|
||||
expect(result.pageCount).toBeGreaterThan(1);
|
||||
const parser = new PDFParse({ data: result.bytes });
|
||||
try {
|
||||
const text = await parser.getText();
|
||||
expect(text.text).toContain("row-0");
|
||||
expect(text.text).toContain("row-199");
|
||||
} finally {
|
||||
await parser.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders a non-paginated table when it fits in one page", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
const doc = createDocument()
|
||||
.addTable(
|
||||
[{ key: "name", header: "Name" }],
|
||||
[{ name: "Widget" }, { name: "Gizmo" }],
|
||||
{ border: true }
|
||||
)
|
||||
.build();
|
||||
const result = renderDocument(doc);
|
||||
expect(result.pageCount).toBe(1);
|
||||
});
|
||||
|
||||
it("page-breaks when a divider plus margins overflow", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = createDocument()
|
||||
.addText("top")
|
||||
.addDivider({ thickness: 400, spacingAbove: 0, spacingBelow: 0 })
|
||||
.addDivider({ thickness: 400, spacingAbove: 0, spacingBelow: 0 })
|
||||
.build();
|
||||
const result = renderDocument(doc);
|
||||
expect(result.pageCount).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("throws LAYOUT_OVERFLOW when an element cannot fit a fresh page", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = createDocument()
|
||||
.addDivider({ thickness: 5000, spacingAbove: 0, spacingBelow: 0 })
|
||||
.build();
|
||||
let caught: unknown;
|
||||
try {
|
||||
renderDocument(doc);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(PdfError);
|
||||
expect((caught as PdfError).code).toBe(PDF_CODES.LAYOUT_OVERFLOW);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderDocument — images", () => {
|
||||
it("embeds an image XObject and round-trips", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const doc = createDocument()
|
||||
.addImage({ src: MINIMAL_PNG, width: 50, height: 50 })
|
||||
.addText("After image")
|
||||
.build();
|
||||
const result = renderDocument(doc);
|
||||
const decoded = new TextDecoder().decode(result.bytes);
|
||||
expect(decoded).toContain("/Subtype /Image");
|
||||
expect(decoded).toContain("/Filter /FlateDecode");
|
||||
const parser = new PDFParse({ data: result.bytes });
|
||||
try {
|
||||
const text = await parser.getText();
|
||||
expect(text.text).toContain("After image");
|
||||
} finally {
|
||||
await parser.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses the same image across pages with one XObject", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
const image = new ImageElement({
|
||||
src: MINIMAL_PNG,
|
||||
width: 30,
|
||||
height: 30,
|
||||
});
|
||||
const doc = createDocument()
|
||||
.addText("p1")
|
||||
.newPage()
|
||||
.addText("p2")
|
||||
.build();
|
||||
// Manually inject the same image reference into both pages.
|
||||
const written = [
|
||||
...doc.pages.map((p) => ({
|
||||
...p,
|
||||
nodes: [...p.nodes, { type: "image" as const, element: image }],
|
||||
})),
|
||||
];
|
||||
const doc2 = { ...doc, pages: written };
|
||||
const result = renderDocument(doc2);
|
||||
const decoded = new TextDecoder().decode(result.bytes);
|
||||
const matches = decoded.match(/\/Subtype \/Image/g) ?? [];
|
||||
expect(matches.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderDocument — headers and footers", () => {
|
||||
it("renders header text on every physical page", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const builder = createDocument().setHeader(
|
||||
[
|
||||
{
|
||||
type: "text",
|
||||
element: new TextElement({
|
||||
content: "REPEATING-HEADER",
|
||||
fontWeight: "bold",
|
||||
}),
|
||||
},
|
||||
],
|
||||
40
|
||||
);
|
||||
for (let i = 0; i < 50; i++) {
|
||||
builder.addText(`body-${i}`, { fontSize: 16 });
|
||||
}
|
||||
const doc = builder.build();
|
||||
const result = renderDocument(doc);
|
||||
expect(result.pageCount).toBeGreaterThan(1);
|
||||
const parser = new PDFParse({ data: result.bytes });
|
||||
try {
|
||||
const text = await parser.getText();
|
||||
const occurrences = (text.text.match(/REPEATING-HEADER/g) ?? [])
|
||||
.length;
|
||||
expect(occurrences).toBeGreaterThanOrEqual(result.pageCount);
|
||||
} finally {
|
||||
await parser.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders footer divider on every physical page", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
const doc = createDocument()
|
||||
.setFooter(
|
||||
[
|
||||
{
|
||||
type: "divider",
|
||||
element: new DividerElement({ thickness: 1 }),
|
||||
},
|
||||
],
|
||||
24
|
||||
)
|
||||
.addText("single page")
|
||||
.build();
|
||||
const result = renderDocument(doc);
|
||||
expect(result.pageCount).toBe(1);
|
||||
});
|
||||
|
||||
it("supports image header rendering", { timeout: 30000 }, () => {
|
||||
const img = new ImageElement({
|
||||
src: MINIMAL_PNG,
|
||||
width: 20,
|
||||
height: 20,
|
||||
});
|
||||
const doc = createDocument()
|
||||
.setHeader([{ type: "image", element: img }], 40)
|
||||
.addText("body")
|
||||
.build();
|
||||
const result = renderDocument(doc);
|
||||
const decoded = new TextDecoder().decode(result.bytes);
|
||||
expect(decoded).toContain("/Subtype /Image");
|
||||
});
|
||||
|
||||
it("supports table footer rendering", { timeout: 30000 }, () => {
|
||||
const tbl = new TableElement({
|
||||
columns: [{ key: "a", header: "A" }],
|
||||
rows: [{ a: "x" }],
|
||||
});
|
||||
const doc = createDocument()
|
||||
.setFooter([{ type: "table", element: tbl }], 40)
|
||||
.addText("body")
|
||||
.build();
|
||||
const result = renderDocument(doc);
|
||||
expect(result.pageCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderDocument — options", () => {
|
||||
it("applies a custom theme", { timeout: 5000 }, () => {
|
||||
const doc = createDocument().addText("themed").build();
|
||||
const result = renderDocument(doc, {
|
||||
theme: {
|
||||
...DEFAULT_THEME,
|
||||
colors: { ...DEFAULT_THEME.colors, text: "#0066cc" },
|
||||
},
|
||||
});
|
||||
expect(result.pageCount).toBe(1);
|
||||
});
|
||||
|
||||
it("applies per-page landscape orientation", { timeout: 5000 }, () => {
|
||||
const doc = createDocument()
|
||||
.addText("p1")
|
||||
.newPage({ orientation: "landscape", size: PageSize.A4 })
|
||||
.addText("p2")
|
||||
.build();
|
||||
const result = renderDocument(doc);
|
||||
const decoded = new TextDecoder().decode(result.bytes);
|
||||
// Landscape A4 MediaBox swaps dimensions
|
||||
expect(decoded).toContain("841.89");
|
||||
});
|
||||
|
||||
it("uses injected observability bundle", { timeout: 5000 }, () => {
|
||||
let documentsCount = 0;
|
||||
const observability = {
|
||||
meter: {} as never,
|
||||
tracer: {} as never,
|
||||
counters: {
|
||||
documentsRendered: {
|
||||
add: (n: number) => {
|
||||
documentsCount += n;
|
||||
},
|
||||
},
|
||||
pagesEmitted: { add: () => undefined },
|
||||
renderErrors: { add: () => undefined },
|
||||
validationFailures: { add: () => undefined },
|
||||
},
|
||||
histograms: {
|
||||
renderDurationMs: { record: () => undefined },
|
||||
documentSizeBytes: { record: () => undefined },
|
||||
},
|
||||
};
|
||||
const doc = createDocument().addText("x").build();
|
||||
renderDocument(doc, {
|
||||
observability: observability as never,
|
||||
});
|
||||
expect(documentsCount).toBe(1);
|
||||
});
|
||||
|
||||
it("accepts a non-aborted AbortSignal", { timeout: 5000 }, () => {
|
||||
const controller = new AbortController();
|
||||
const doc = createDocument().addText("x").build();
|
||||
const result = renderDocument(doc, { signal: controller.signal });
|
||||
expect(result.pageCount).toBe(1);
|
||||
});
|
||||
|
||||
it("forwards observabilityOptions when no bundle is supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = createDocument().addText("x").build();
|
||||
const result = renderDocument(doc, {
|
||||
observabilityOptions: {},
|
||||
});
|
||||
expect(result.pageCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderDocument — errors and abort", () => {
|
||||
it("omits Creator when metadata.creator is absent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = createDocument().addText("x").build();
|
||||
const manual = {
|
||||
...doc,
|
||||
metadata: {
|
||||
title: "T",
|
||||
author: "A",
|
||||
subject: "S",
|
||||
keywords: [],
|
||||
creationDate: new Date(0),
|
||||
},
|
||||
};
|
||||
const result = renderDocument(manual);
|
||||
const decoded = new TextDecoder().decode(result.bytes);
|
||||
expect(decoded).not.toContain("/Creator (@suaveplan/pdf)");
|
||||
});
|
||||
|
||||
it("throws INVALID_DOCUMENT for an empty document", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const empty = {
|
||||
metadata: {},
|
||||
defaultPage: {
|
||||
size: PageSize.A4,
|
||||
orientation: "portrait" as const,
|
||||
margins: { top: 72, right: 72, bottom: 72, left: 72 },
|
||||
},
|
||||
pages: [],
|
||||
};
|
||||
let caught: unknown;
|
||||
try {
|
||||
renderDocument(empty);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(PdfError);
|
||||
expect((caught as PdfError).code).toBe(PDF_CODES.INVALID_DOCUMENT);
|
||||
});
|
||||
|
||||
it("aborts when signal trips before render", { timeout: 5000 }, () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const doc = createDocument().addText("x").build();
|
||||
let caught: unknown;
|
||||
try {
|
||||
renderDocument(doc, { signal: controller.signal });
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(PdfError);
|
||||
expect((caught as PdfError).code).toBe(PDF_CODES.ABORTED);
|
||||
});
|
||||
|
||||
it("wraps unknown thrown values in PdfError via renderer", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Construct a document with a synthetic node type to force the
|
||||
// renderer's catch path.
|
||||
const doc = createDocument().addText("x").build();
|
||||
// Inject an invalid node type via cast.
|
||||
const broken = {
|
||||
...doc,
|
||||
pages: [
|
||||
{
|
||||
nodes: [{ type: "ghost" as never, element: {} as never }],
|
||||
},
|
||||
],
|
||||
};
|
||||
let caught: unknown;
|
||||
try {
|
||||
renderDocument(broken);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(PdfError);
|
||||
});
|
||||
});
|
||||
|
||||
function makeMonotonicClock(values: number[]): () => number {
|
||||
let i = 0;
|
||||
return () => {
|
||||
const v = values[i] ?? values[values.length - 1] ?? 0;
|
||||
i++;
|
||||
return v;
|
||||
};
|
||||
}
|
||||
343
packages/devtools/docs/pdf/src/renderer/renderer.ts
Normal file
343
packages/devtools/docs/pdf/src/renderer/renderer.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import type {
|
||||
BuiltPageDefinition,
|
||||
Document,
|
||||
DocumentNode,
|
||||
} from "../document-builder/document-builder.types.js";
|
||||
import { asPdfError, PDF_CODES, PdfError } from "../error-codes/error-codes.js";
|
||||
import { ImageRegistry } from "../image-registry/image-registry.js";
|
||||
import { LayoutEngine } from "../layout-engine/layout-engine.js";
|
||||
import type { LayoutCursor } from "../layout-engine/layout-engine.types.js";
|
||||
import { PdfWriter } from "../pdf-writer/pdf-writer.js";
|
||||
import {
|
||||
type PipelineState,
|
||||
placeNode,
|
||||
renderSlot,
|
||||
renderTableSlice,
|
||||
} from "../render-pipeline/render-pipeline.js";
|
||||
import { StyleResolver } from "../style-resolver/style-resolver.js";
|
||||
import type { TableElement } from "../table-element/table-element.js";
|
||||
import {
|
||||
createPdfObservability,
|
||||
recordDocumentRendered,
|
||||
recordDocumentSize,
|
||||
recordPagesEmitted,
|
||||
recordRenderDuration,
|
||||
recordRenderError,
|
||||
} from "../telemetry/telemetry.js";
|
||||
import type { PageConfig } from "../types/page-size.js";
|
||||
import type { RenderOptions, RenderResult } from "./renderer.types.js";
|
||||
|
||||
const DEFAULT_CLOCK: Clock = () => Date.now();
|
||||
|
||||
/**
|
||||
* Render a {@link Document} AST to PDF 1.4 bytes.
|
||||
*
|
||||
* Walks every page, asking the layout engine to advance the cursor for
|
||||
* each node's measured height. Tables split across pages via
|
||||
* `TableElement.renderPage`. Images are registered as PDF XObjects
|
||||
* lazily and shared across logical pages inside the same document.
|
||||
*
|
||||
* Honours `AbortSignal` between every node and between every page
|
||||
* flush. Emits OTel counters + histograms for documents rendered,
|
||||
* pages emitted, render errors (tagged by `PDF.*` code), render
|
||||
* duration, and document size.
|
||||
*/
|
||||
export function renderDocument<TData = unknown>(
|
||||
doc: Document<TData>,
|
||||
options?: RenderOptions
|
||||
): RenderResult {
|
||||
const clock = options?.clock ?? DEFAULT_CLOCK;
|
||||
const observability =
|
||||
options?.observability ??
|
||||
createPdfObservability(options?.observabilityOptions);
|
||||
const startMs = clock();
|
||||
|
||||
try {
|
||||
const result = runRender(doc, options, clock);
|
||||
const durationMs = clock() - startMs;
|
||||
recordDocumentRendered(observability, { success: true });
|
||||
recordPagesEmitted(observability, result.pageCount);
|
||||
recordDocumentSize(observability, result.bytes.length);
|
||||
recordRenderDuration(observability, durationMs);
|
||||
return { ...result, durationMs };
|
||||
} catch (err) {
|
||||
const pdfErr =
|
||||
err instanceof PdfError
|
||||
? err
|
||||
: asPdfError(
|
||||
PDF_CODES.RENDER_FAILED,
|
||||
err,
|
||||
"renderDocument failed"
|
||||
);
|
||||
recordDocumentRendered(observability, { success: false });
|
||||
recordRenderError(observability, { code: pdfErr.code });
|
||||
recordRenderDuration(observability, clock() - startMs);
|
||||
throw pdfErr;
|
||||
}
|
||||
}
|
||||
|
||||
function runRender<TData>(
|
||||
doc: Document<TData>,
|
||||
options: RenderOptions | undefined,
|
||||
clock: Clock
|
||||
): { readonly bytes: Uint8Array; readonly pageCount: number } {
|
||||
if (doc.pages.length === 0) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.INVALID_DOCUMENT,
|
||||
"renderDocument requires at least one page"
|
||||
);
|
||||
}
|
||||
const signal = options?.signal;
|
||||
assertNotAborted(signal);
|
||||
|
||||
const writerOptions: {
|
||||
clock: Clock;
|
||||
signal?: AbortSignal;
|
||||
producer?: string;
|
||||
} = { clock };
|
||||
if (signal) writerOptions.signal = signal;
|
||||
if (options?.producer) writerOptions.producer = options.producer;
|
||||
const writer = new PdfWriter(writerOptions);
|
||||
|
||||
const layoutOptions: {
|
||||
defaultPage: PageConfig;
|
||||
headerHeight: number;
|
||||
footerHeight: number;
|
||||
signal?: AbortSignal;
|
||||
} = {
|
||||
defaultPage: doc.defaultPage,
|
||||
headerHeight: doc.headerHeight ?? 0,
|
||||
footerHeight: doc.footerHeight ?? 0,
|
||||
};
|
||||
if (signal) layoutOptions.signal = signal;
|
||||
const layout = new LayoutEngine(layoutOptions);
|
||||
|
||||
const styles = new StyleResolver(options?.theme);
|
||||
const images = new ImageRegistry(writer);
|
||||
|
||||
const state: PipelineState = {
|
||||
writer,
|
||||
layout,
|
||||
styles,
|
||||
images,
|
||||
signal,
|
||||
pageCount: 0,
|
||||
};
|
||||
|
||||
for (const page of doc.pages) {
|
||||
renderLogicalPage(state, doc, page);
|
||||
}
|
||||
|
||||
const bytes = writer.generate(buildMetadata(doc));
|
||||
return { bytes, pageCount: state.pageCount };
|
||||
}
|
||||
|
||||
interface PageContext {
|
||||
cursor: LayoutCursor;
|
||||
streams: string[];
|
||||
pageXObjects: Map<string, number>;
|
||||
}
|
||||
|
||||
function renderLogicalPage(
|
||||
state: PipelineState,
|
||||
doc: Document<unknown>,
|
||||
page: BuiltPageDefinition
|
||||
): void {
|
||||
const config = page.config;
|
||||
const ctx: PageContext = {
|
||||
cursor: state.layout.createCursor(config),
|
||||
streams: [],
|
||||
pageXObjects: new Map(),
|
||||
};
|
||||
primeSlots(state, doc, config, ctx);
|
||||
|
||||
let i = 0;
|
||||
let tableCont: { table: TableElement<unknown>; rowStart: number } | null =
|
||||
null;
|
||||
let pageBrokeJustNow = false;
|
||||
|
||||
while (i < page.nodes.length || tableCont) {
|
||||
assertNotAborted(state.signal);
|
||||
|
||||
if (tableCont) {
|
||||
const result = advanceTableContinuation(
|
||||
state,
|
||||
tableCont,
|
||||
ctx.cursor,
|
||||
config,
|
||||
ctx.streams
|
||||
);
|
||||
pageBrokeJustNow = false;
|
||||
if (result.done) {
|
||||
tableCont = null;
|
||||
i++;
|
||||
} else {
|
||||
tableCont = {
|
||||
table: tableCont.table,
|
||||
rowStart: result.nextRowStart,
|
||||
};
|
||||
flushAndReset(state, doc, ctx, config);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const node = page.nodes[i] as DocumentNode;
|
||||
const placed = placeNode(
|
||||
state,
|
||||
node,
|
||||
ctx.cursor,
|
||||
config,
|
||||
ctx.pageXObjects
|
||||
);
|
||||
if (placed === "page-break") {
|
||||
if (pageBrokeJustNow) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.LAYOUT_OVERFLOW,
|
||||
`node of type "${node.type}" does not fit on a fresh page`
|
||||
);
|
||||
}
|
||||
flushAndReset(state, doc, ctx, config);
|
||||
pageBrokeJustNow = true;
|
||||
continue;
|
||||
}
|
||||
pageBrokeJustNow = false;
|
||||
ctx.streams.push(placed.stream);
|
||||
if (placed.continuation) {
|
||||
tableCont = {
|
||||
table: placed.continuation.table,
|
||||
rowStart: placed.continuation.rowStart,
|
||||
};
|
||||
flushAndReset(state, doc, ctx, config);
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
emitPhysicalPage(state, ctx, config);
|
||||
}
|
||||
|
||||
type ContinuationResult =
|
||||
| { readonly done: true }
|
||||
| { readonly done: false; readonly nextRowStart: number };
|
||||
|
||||
function advanceTableContinuation(
|
||||
state: PipelineState,
|
||||
tableCont: { table: TableElement<unknown>; rowStart: number },
|
||||
cursor: LayoutCursor,
|
||||
config: Partial<PageConfig> | undefined,
|
||||
streams: string[]
|
||||
): ContinuationResult {
|
||||
const cont = renderTableSlice(
|
||||
state,
|
||||
tableCont.table,
|
||||
tableCont.rowStart,
|
||||
cursor,
|
||||
config
|
||||
);
|
||||
// Invariant: a continuation only enters this branch on a freshly-reset
|
||||
// cursor, where the table's first slice succeeded earlier — so the
|
||||
// header must fit and renderPage always advances rowEnd.
|
||||
streams.push(cont.stream);
|
||||
if (cont.rowEnd >= tableCont.table.options.rows.length) {
|
||||
return { done: true };
|
||||
}
|
||||
return { done: false, nextRowStart: cont.rowEnd };
|
||||
}
|
||||
|
||||
function primeSlots(
|
||||
state: PipelineState,
|
||||
doc: Document<unknown>,
|
||||
config: Partial<PageConfig> | undefined,
|
||||
ctx: PageContext
|
||||
): void {
|
||||
ctx.streams.push(
|
||||
...renderSlot(
|
||||
state,
|
||||
doc.header,
|
||||
doc.headerHeight,
|
||||
"header",
|
||||
config,
|
||||
ctx.pageXObjects
|
||||
)
|
||||
);
|
||||
ctx.streams.push(
|
||||
...renderSlot(
|
||||
state,
|
||||
doc.footer,
|
||||
doc.footerHeight,
|
||||
"footer",
|
||||
config,
|
||||
ctx.pageXObjects
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function flushAndReset(
|
||||
state: PipelineState,
|
||||
doc: Document<unknown>,
|
||||
ctx: PageContext,
|
||||
config: Partial<PageConfig> | undefined
|
||||
): void {
|
||||
emitPhysicalPage(state, ctx, config);
|
||||
ctx.cursor = state.layout.createCursor(config);
|
||||
ctx.streams = [];
|
||||
ctx.pageXObjects = new Map();
|
||||
primeSlots(state, doc, config, ctx);
|
||||
}
|
||||
|
||||
function emitPhysicalPage(
|
||||
state: PipelineState,
|
||||
ctx: PageContext,
|
||||
config: Partial<PageConfig> | undefined
|
||||
): void {
|
||||
assertNotAborted(state.signal);
|
||||
const dims = state.layout.getPageDimensions(config);
|
||||
const body = ctx.streams.join("\n");
|
||||
const contentId = state.writer.addStream(body, "FlateDecode");
|
||||
const pageInput: {
|
||||
width: number;
|
||||
height: number;
|
||||
contentStreamId: number;
|
||||
xobjectResources?: ReadonlyMap<string, number>;
|
||||
} = {
|
||||
width: dims.width,
|
||||
height: dims.height,
|
||||
contentStreamId: contentId,
|
||||
};
|
||||
if (ctx.pageXObjects.size > 0) {
|
||||
pageInput.xobjectResources = new Map(ctx.pageXObjects);
|
||||
}
|
||||
state.writer.addPage(pageInput);
|
||||
state.pageCount += 1;
|
||||
}
|
||||
|
||||
function buildMetadata(doc: Document<unknown>): {
|
||||
Title?: string;
|
||||
Author?: string;
|
||||
Subject?: string;
|
||||
Creator?: string;
|
||||
Keywords?: string;
|
||||
} {
|
||||
const out: {
|
||||
Title?: string;
|
||||
Author?: string;
|
||||
Subject?: string;
|
||||
Creator?: string;
|
||||
Keywords?: string;
|
||||
} = {};
|
||||
if (doc.metadata.title) out.Title = doc.metadata.title;
|
||||
if (doc.metadata.author) out.Author = doc.metadata.author;
|
||||
if (doc.metadata.subject) out.Subject = doc.metadata.subject;
|
||||
if (doc.metadata.creator) out.Creator = doc.metadata.creator;
|
||||
if (doc.metadata.keywords && doc.metadata.keywords.length > 0) {
|
||||
out.Keywords = doc.metadata.keywords.join(", ");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function assertNotAborted(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) {
|
||||
throw new PdfError(PDF_CODES.ABORTED, "renderDocument aborted");
|
||||
}
|
||||
}
|
||||
36
packages/devtools/docs/pdf/src/renderer/renderer.types.ts
Normal file
36
packages/devtools/docs/pdf/src/renderer/renderer.types.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Public types for the top-level {@link renderDocument} entry point.
|
||||
*/
|
||||
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import type { PdfTheme } from "../style-resolver/style-resolver.types.js";
|
||||
import type {
|
||||
PdfObservability,
|
||||
PdfObservabilityOptions,
|
||||
} from "../telemetry/telemetry.types.js";
|
||||
|
||||
/** Options accepted by {@link renderDocument}. */
|
||||
export interface RenderOptions {
|
||||
/** Theme override applied to the renderer's {@link StyleResolver}. */
|
||||
readonly theme?: PdfTheme | undefined;
|
||||
/** Abort signal — every layout / writer call honours it. */
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
/** Override the wall-clock used for `/CreationDate`. */
|
||||
readonly clock?: Clock | undefined;
|
||||
/** Override the `/Producer` info string. */
|
||||
readonly producer?: string | undefined;
|
||||
/** Observability bundle (or factory options). */
|
||||
readonly observability?: PdfObservability | undefined;
|
||||
/** Factory options forwarded to `createPdfObservability` when no bundle is supplied. */
|
||||
readonly observabilityOptions?: PdfObservabilityOptions | undefined;
|
||||
}
|
||||
|
||||
/** Result returned by {@link renderDocument}. */
|
||||
export interface RenderResult {
|
||||
/** The PDF binary. */
|
||||
readonly bytes: Uint8Array;
|
||||
/** Number of pages emitted. */
|
||||
readonly pageCount: number;
|
||||
/** Wall-clock duration of the render call, in ms. */
|
||||
readonly durationMs: number;
|
||||
}
|
||||
10
packages/devtools/docs/pdf/src/schemas/index.ts
Normal file
10
packages/devtools/docs/pdf/src/schemas/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
DocumentMetadataSchema,
|
||||
HexColorSchema,
|
||||
MarginsSchema,
|
||||
PageConfigSchema,
|
||||
parseHexColor,
|
||||
parseOrThrow,
|
||||
RgbColorSchema,
|
||||
UnitSchema,
|
||||
} from "./schemas.js";
|
||||
104
packages/devtools/docs/pdf/src/schemas/schemas.md
Normal file
104
packages/devtools/docs/pdf/src/schemas/schemas.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# `schemas`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod boundary schemas for the public surface of `@suaveplan/pdf`. Every
|
||||
value crossing the package boundary from untrusted input (HTTP request
|
||||
body, config file, template constructor) is parsed through one of the
|
||||
schemas declared here before reaching the renderer. Schema-validation
|
||||
failures are lifted into typed `PdfError` instances via the
|
||||
`parseOrThrow` helper, so consumers can rely on `instanceof PdfError`
|
||||
without losing the field-level diagnostic detail that Zod produces.
|
||||
|
||||
## Features
|
||||
|
||||
- `UnitSchema` — five-valued enum (`pt`, `mm`, `cm`, `in`, `px`).
|
||||
- `HexColorSchema` — accepts `#RGB`, `#RRGGBB`, and bare 3 / 6-char
|
||||
hex strings.
|
||||
- `RgbColorSchema` — `{ r, g, b }` with each channel in `0`-`255`.
|
||||
- `MarginsSchema` — `{ top, right, bottom, left }` non-negative points.
|
||||
- `PageConfigSchema` — `{ size, orientation, margins }` covering every
|
||||
supported page-size preset.
|
||||
- `DocumentMetadataSchema` — all-optional fields matching the PDF
|
||||
`/Info` dictionary surface.
|
||||
- `parseOrThrow(schema, value, code, message)` — universal helper that
|
||||
converts Zod parse failures into `PdfError` with the supplied code
|
||||
and exposes the Zod issue paths on `err.context.path`.
|
||||
- `parseHexColor(value)` — convenience that wraps `HexColorSchema` with
|
||||
`PDF_CODES.INVALID_HEX_COLOR`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
HexColorSchema,
|
||||
PageConfigSchema,
|
||||
parseHexColor,
|
||||
} from "@suaveplan/pdf/schemas";
|
||||
|
||||
const hex = parseHexColor(req.body.brandColor); // throws PdfError on bad input
|
||||
const page = PageConfigSchema.parse(req.body.page);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
`parseOrThrow` lets you reuse any Zod schema (not just those declared
|
||||
here) with the package's typed error contract:
|
||||
|
||||
```ts
|
||||
import z from "zod";
|
||||
import { PDF_CODES } from "@suaveplan/pdf/error-codes";
|
||||
import { parseOrThrow } from "@suaveplan/pdf/schemas";
|
||||
|
||||
const InvoiceInputSchema = z.object({
|
||||
number: z.string().min(1),
|
||||
total: z.number().nonnegative(),
|
||||
});
|
||||
|
||||
const input = parseOrThrow(
|
||||
InvoiceInputSchema,
|
||||
requestBody,
|
||||
PDF_CODES.INVALID_TEMPLATE_INPUT,
|
||||
"invalid invoice input"
|
||||
);
|
||||
```
|
||||
|
||||
The thrown `PdfError`'s `context.path` is an array of field paths from
|
||||
the Zod issue list — perfect for surfacing field-level validation
|
||||
diagnostics to upstream HTTP responses without leaking implementation
|
||||
details.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `UnitSchema: z.ZodEnum<["pt", "mm", "cm", "in", "px"]>`.
|
||||
- `HexColorSchema: z.ZodString` — regex
|
||||
`^#?(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`.
|
||||
- `RgbColorSchema: z.ZodObject<{ r, g, b }>`.
|
||||
- `MarginsSchema: z.ZodObject<{ top, right, bottom, left }>`.
|
||||
- `PageConfigSchema: z.ZodObject<{ size, orientation, margins }>`.
|
||||
- `DocumentMetadataSchema: z.ZodObject<{ title?, author?, subject?, keywords?, creator?, creationDate? }>`.
|
||||
- `parseOrThrow<T extends z.ZodTypeAny>(schema: T, value: unknown, code: PdfErrorCode, message: string): z.infer<T>`.
|
||||
- `parseHexColor(value: unknown): string` — convenience wrapper.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Schemas declare only the runtime shape; the corresponding compile-time
|
||||
types live in `src/types/page-size.ts` for `PageConfig` /
|
||||
`DocumentMetadata` / `Margins` and in `src/colors/colors.ts` for
|
||||
`RgbColor`. This split mirrors the docs / sandbox / contacts pattern
|
||||
where the canonical type is defined once and the schema is a runtime
|
||||
echo.
|
||||
- `parseOrThrow` reads `result.error.issues` directly (Zod 3+) rather
|
||||
than calling `.format()`, because we need the raw path arrays to
|
||||
populate `err.context.path` for downstream HTTP error responses.
|
||||
- The `cause` attached to the thrown `PdfError` is a fresh `Error`
|
||||
whose `.message` is the `;`-joined Zod issue summary; the original
|
||||
Zod `ZodError` is discarded to keep the cause chain serialisable via
|
||||
the standard `SuaveplanError` wire format.
|
||||
- `HexColorSchema` deliberately rejects `4` and `5`-char forms — only
|
||||
`3` and `6` are valid in CSS / PDF contexts.
|
||||
- `import z from "zod"` is the only Bun-safe form per repo CLAUDE.md.
|
||||
166
packages/devtools/docs/pdf/src/schemas/schemas.test.ts
Normal file
166
packages/devtools/docs/pdf/src/schemas/schemas.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PDF_CODES, PdfError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
DocumentMetadataSchema,
|
||||
HexColorSchema,
|
||||
MarginsSchema,
|
||||
PageConfigSchema,
|
||||
parseHexColor,
|
||||
parseOrThrow,
|
||||
RgbColorSchema,
|
||||
UnitSchema,
|
||||
} from "./schemas.js";
|
||||
|
||||
describe("UnitSchema", () => {
|
||||
it("accepts every supported unit", { timeout: 5000 }, () => {
|
||||
for (const u of ["pt", "mm", "cm", "in", "px"] as const) {
|
||||
expect(UnitSchema.parse(u)).toBe(u);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown unit", { timeout: 5000 }, () => {
|
||||
expect(UnitSchema.safeParse("fathom").success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HexColorSchema", () => {
|
||||
it("accepts canonical 6-char form", { timeout: 5000 }, () => {
|
||||
expect(HexColorSchema.parse("#ffaa00")).toBe("#ffaa00");
|
||||
expect(HexColorSchema.parse("ffaa00")).toBe("ffaa00");
|
||||
});
|
||||
|
||||
it("accepts 3-char shorthand", { timeout: 5000 }, () => {
|
||||
expect(HexColorSchema.parse("#fa0")).toBe("#fa0");
|
||||
});
|
||||
|
||||
it("rejects garbage", { timeout: 5000 }, () => {
|
||||
expect(HexColorSchema.safeParse("#zz").success).toBe(false);
|
||||
expect(HexColorSchema.safeParse("nope").success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RgbColorSchema", () => {
|
||||
it("accepts in-range RGB triple", { timeout: 5000 }, () => {
|
||||
expect(RgbColorSchema.parse({ r: 0, g: 128, b: 255 })).toEqual({
|
||||
r: 0,
|
||||
g: 128,
|
||||
b: 255,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects out-of-range channel", { timeout: 5000 }, () => {
|
||||
expect(RgbColorSchema.safeParse({ r: 300, g: 0, b: 0 }).success).toBe(
|
||||
false
|
||||
);
|
||||
expect(RgbColorSchema.safeParse({ r: -1, g: 0, b: 0 }).success).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MarginsSchema", () => {
|
||||
it("accepts non-negative margins", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
MarginsSchema.parse({ top: 0, right: 1, bottom: 2, left: 3 })
|
||||
).toEqual({ top: 0, right: 1, bottom: 2, left: 3 });
|
||||
});
|
||||
|
||||
it("rejects negative margin", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
MarginsSchema.safeParse({
|
||||
top: -1,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PageConfigSchema", () => {
|
||||
it("accepts a fully-populated config", { timeout: 5000 }, () => {
|
||||
const cfg = {
|
||||
size: "A4" as const,
|
||||
orientation: "portrait" as const,
|
||||
margins: { top: 72, right: 72, bottom: 72, left: 72 },
|
||||
};
|
||||
expect(PageConfigSchema.parse(cfg)).toEqual(cfg);
|
||||
});
|
||||
|
||||
it("rejects unknown page size", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
PageConfigSchema.safeParse({
|
||||
size: "TABLOID",
|
||||
orientation: "portrait",
|
||||
margins: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DocumentMetadataSchema", () => {
|
||||
it("accepts empty metadata", { timeout: 5000 }, () => {
|
||||
expect(DocumentMetadataSchema.parse({})).toEqual({});
|
||||
});
|
||||
|
||||
it("accepts fully-populated metadata", { timeout: 5000 }, () => {
|
||||
const meta = {
|
||||
title: "Invoice",
|
||||
author: "Alice",
|
||||
subject: "Q1",
|
||||
keywords: ["invoice", "q1"],
|
||||
creator: "@suaveplan/pdf",
|
||||
creationDate: new Date("2026-01-01T00:00:00Z"),
|
||||
};
|
||||
expect(DocumentMetadataSchema.parse(meta)).toEqual(meta);
|
||||
});
|
||||
|
||||
it("rejects non-string author", { timeout: 5000 }, () => {
|
||||
expect(DocumentMetadataSchema.safeParse({ author: 42 }).success).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseOrThrow", () => {
|
||||
it("returns parsed value on success", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
parseOrThrow(UnitSchema, "mm", PDF_CODES.INVALID_UNIT, "bad")
|
||||
).toBe("mm");
|
||||
});
|
||||
|
||||
it("lifts Zod errors into PdfError", { timeout: 5000 }, () => {
|
||||
try {
|
||||
parseOrThrow(
|
||||
RgbColorSchema,
|
||||
{ r: 300, g: 0, b: 0 },
|
||||
PDF_CODES.INVALID_RGB,
|
||||
"rgb out of range"
|
||||
);
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(PdfError);
|
||||
const pdfErr = err as PdfError;
|
||||
expect(pdfErr.code).toBe(PDF_CODES.INVALID_RGB);
|
||||
expect(pdfErr.cause).toBeInstanceOf(Error);
|
||||
const ctx = pdfErr.context as { path?: unknown[][] } | undefined;
|
||||
expect(ctx?.path?.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseHexColor", () => {
|
||||
it("returns the parsed string on success", { timeout: 5000 }, () => {
|
||||
expect(parseHexColor("#ffaa00")).toBe("#ffaa00");
|
||||
});
|
||||
|
||||
it("raises INVALID_HEX_COLOR on failure", { timeout: 5000 }, () => {
|
||||
try {
|
||||
parseHexColor("#zz");
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(PdfError);
|
||||
expect((err as PdfError).code).toBe(PDF_CODES.INVALID_HEX_COLOR);
|
||||
}
|
||||
});
|
||||
});
|
||||
83
packages/devtools/docs/pdf/src/schemas/schemas.ts
Normal file
83
packages/devtools/docs/pdf/src/schemas/schemas.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import z from "zod";
|
||||
import {
|
||||
PDF_CODES,
|
||||
PdfError,
|
||||
type PdfErrorCode,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/** Zod schema for a unit identifier. */
|
||||
export const UnitSchema = z.enum(["pt", "mm", "cm", "in", "px"]);
|
||||
|
||||
/** Zod schema for a hex colour with optional leading `#`. */
|
||||
export const HexColorSchema = z
|
||||
.string()
|
||||
.regex(/^#?(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/);
|
||||
|
||||
/** Zod schema for an RGB triple with each channel in 0-255. */
|
||||
export const RgbColorSchema = z.object({
|
||||
r: z.number().min(0).max(255),
|
||||
g: z.number().min(0).max(255),
|
||||
b: z.number().min(0).max(255),
|
||||
});
|
||||
|
||||
/** Zod schema for page margins (points, non-negative). */
|
||||
export const MarginsSchema = z.object({
|
||||
top: z.number().min(0),
|
||||
right: z.number().min(0),
|
||||
bottom: z.number().min(0),
|
||||
left: z.number().min(0),
|
||||
});
|
||||
|
||||
/** Zod schema for a page configuration. */
|
||||
export const PageConfigSchema = z.object({
|
||||
size: z.enum(["A4", "A3", "A5", "LETTER", "LEGAL"]),
|
||||
orientation: z.enum(["portrait", "landscape"]),
|
||||
margins: MarginsSchema,
|
||||
});
|
||||
|
||||
/** Zod schema for document-level PDF metadata (PDF `/Info` dictionary). */
|
||||
export const DocumentMetadataSchema = z.object({
|
||||
title: z.string().optional(),
|
||||
author: z.string().optional(),
|
||||
subject: z.string().optional(),
|
||||
keywords: z.array(z.string()).optional(),
|
||||
creator: z.string().optional(),
|
||||
creationDate: z.date().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Parse a value with a Zod schema, converting any failure into a
|
||||
* {@link PdfError} with the supplied `code` and attaching the Zod issues
|
||||
* as `cause`. The issue paths are placed under `context.path` for
|
||||
* callers that want to surface field-level diagnostics.
|
||||
*/
|
||||
export function parseOrThrow<T extends z.ZodTypeAny>(
|
||||
schema: T,
|
||||
value: unknown,
|
||||
code: PdfErrorCode,
|
||||
message: string
|
||||
): z.infer<T> {
|
||||
const result = schema.safeParse(value);
|
||||
if (result.success) return result.data as z.infer<T>;
|
||||
const issues = result.error.issues;
|
||||
const cause = new Error(
|
||||
issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")
|
||||
);
|
||||
throw new PdfError(code, message, {
|
||||
cause,
|
||||
context: { path: issues.map((i) => i.path) },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: parse a `HexColorSchema` value and raise
|
||||
* `PDF_CODES.INVALID_HEX_COLOR` on failure.
|
||||
*/
|
||||
export function parseHexColor(value: unknown): string {
|
||||
return parseOrThrow(
|
||||
HexColorSchema,
|
||||
value,
|
||||
PDF_CODES.INVALID_HEX_COLOR,
|
||||
"invalid hex colour"
|
||||
);
|
||||
}
|
||||
18
packages/devtools/docs/pdf/src/style-resolver/index.ts
Normal file
18
packages/devtools/docs/pdf/src/style-resolver/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export {
|
||||
DEFAULT_STYLE,
|
||||
DEFAULT_THEME,
|
||||
mergeStyles,
|
||||
mergeTheme,
|
||||
normalizeSpacing,
|
||||
StyleResolver,
|
||||
} from "./style-resolver.js";
|
||||
export type {
|
||||
BorderStyle,
|
||||
PdfStyle,
|
||||
PdfTheme,
|
||||
ResolvedSpacing,
|
||||
SpacingValue,
|
||||
ThemeColors,
|
||||
ThemeSpacing,
|
||||
ThemeTypography,
|
||||
} from "./style-resolver.types.js";
|
||||
@@ -0,0 +1,86 @@
|
||||
# style-resolver
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `StyleResolver` resolves a fully-populated `PdfStyle` from the
|
||||
package defaults, an active theme, an optional base layer, and any
|
||||
caller-supplied overrides. It is the single source of truth for the
|
||||
style cascade applied to every rendered element. A second concern of
|
||||
this module is normalising `SpacingValue` inputs (scalar or per-side
|
||||
object) into the four-sided `ResolvedSpacing` shape the layout engine
|
||||
expects, so downstream consumers never branch on the input shape.
|
||||
|
||||
## Features
|
||||
|
||||
- Deterministic four-layer cascade: `DEFAULT_STYLE` → theme defaults
|
||||
→ optional base → caller overrides.
|
||||
- `mergeStyles` skips `undefined` values so narrow patches do not zero
|
||||
out preceding layers.
|
||||
- `mergeTheme` performs slot-by-slot deep merging (colours,
|
||||
typography, spacing) including the `headingSizes` sub-object.
|
||||
- `withTheme` returns a child resolver — the parent is not mutated.
|
||||
- `normalizeSpacing` raises `PDF.INVALID_ELEMENT` on non-finite input.
|
||||
- `DEFAULT_THEME` matches the historical main-branch palette so
|
||||
templates render identically across both packages.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { StyleResolver } from "@suaveplan/pdf";
|
||||
|
||||
const styles = new StyleResolver();
|
||||
const heading = styles.resolve({ fontSize: 24, fontWeight: "bold" });
|
||||
// heading.color === "#000000" (DEFAULT_THEME palette)
|
||||
// heading.fontFamily === "Helvetica"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { StyleResolver, DEFAULT_THEME, normalizeSpacing } from "@suaveplan/pdf";
|
||||
|
||||
const branded = new StyleResolver({
|
||||
...DEFAULT_THEME,
|
||||
colors: { ...DEFAULT_THEME.colors, text: "#222244" },
|
||||
});
|
||||
const localised = branded.withTheme({
|
||||
typography: { ...DEFAULT_THEME.typography, baseFontSize: 11 },
|
||||
});
|
||||
const cellStyle = localised.resolveWith(
|
||||
{ padding: { top: 4, right: 8, bottom: 4, left: 8 } },
|
||||
{ color: "#ffffff", backgroundColor: "#0f3460" }
|
||||
);
|
||||
const padding = normalizeSpacing(cellStyle.padding);
|
||||
// padding = { top: 4, right: 8, bottom: 4, left: 8 }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class StyleResolver<TTheme extends PdfTheme>` — `getTheme()`,
|
||||
`resolve(overrides?)`, `resolveWith(base, overrides?)`,
|
||||
`withTheme(overrides)`.
|
||||
- `mergeStyles(...layers)` → `PdfStyle`.
|
||||
- `mergeTheme(base, overrides)` → `PdfTheme`.
|
||||
- `normalizeSpacing(value)` → `{ top, right, bottom, left }`.
|
||||
- Constants: `DEFAULT_STYLE`, `DEFAULT_THEME`.
|
||||
- Types: `PdfStyle`, `PdfTheme`, `BorderStyle`, `SpacingValue`,
|
||||
`ResolvedSpacing`, `ThemeColors`, `ThemeTypography`, `ThemeSpacing`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The cascade is implemented as a single left-to-right reduction over
|
||||
the supplied layers. Each layer is iterated with `Object.entries` and
|
||||
any `undefined` value is skipped — empirically this is the
|
||||
least-surprising behaviour for fluent callers that emit partial
|
||||
patches. `mergeTheme` is deliberately written as a literal-spread
|
||||
expression so a missing override slot falls back to the base verbatim
|
||||
without triggering a deep copy. `normalizeSpacing` validates every
|
||||
component with `Number.isFinite` so callers that pipe user input
|
||||
through the resolver receive a typed `PdfError` rather than producing
|
||||
`NaN`-poisoned layout coordinates downstream. The default theme
|
||||
palette and typography mirror the main-branch package byte-for-byte
|
||||
to keep template output stable across the migration boundary.
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PdfError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
DEFAULT_STYLE,
|
||||
DEFAULT_THEME,
|
||||
mergeStyles,
|
||||
mergeTheme,
|
||||
normalizeSpacing,
|
||||
StyleResolver,
|
||||
} from "./style-resolver.js";
|
||||
|
||||
describe("normalizeSpacing", () => {
|
||||
it("expands a scalar to four sides", { timeout: 5000 }, () => {
|
||||
expect(normalizeSpacing(8)).toEqual({
|
||||
top: 8,
|
||||
right: 8,
|
||||
bottom: 8,
|
||||
left: 8,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes through an object", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
normalizeSpacing({ top: 1, right: 2, bottom: 3, left: 4 })
|
||||
).toEqual({ top: 1, right: 2, bottom: 3, left: 4 });
|
||||
});
|
||||
|
||||
it("rejects non-finite scalars", { timeout: 5000 }, () => {
|
||||
expect(() => normalizeSpacing(Number.NaN)).toThrow(PdfError);
|
||||
});
|
||||
|
||||
it("rejects non-finite sides", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
normalizeSpacing({
|
||||
top: 1,
|
||||
right: Number.POSITIVE_INFINITY,
|
||||
bottom: 3,
|
||||
left: 4,
|
||||
})
|
||||
).toThrow(PdfError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeStyles", () => {
|
||||
it("returns DEFAULT_STYLE when no layers given", { timeout: 5000 }, () => {
|
||||
expect(mergeStyles()).toEqual(DEFAULT_STYLE);
|
||||
});
|
||||
|
||||
it("skips undefined values inside layers", { timeout: 5000 }, () => {
|
||||
const merged = mergeStyles(
|
||||
{ fontSize: 14 },
|
||||
{ fontSize: undefined, color: "#ff0000" }
|
||||
);
|
||||
expect(merged.fontSize).toBe(14);
|
||||
expect(merged.color).toBe("#ff0000");
|
||||
});
|
||||
|
||||
it("skips undefined layer entirely", { timeout: 5000 }, () => {
|
||||
const merged = mergeStyles(undefined, { fontSize: 22 }, undefined);
|
||||
expect(merged.fontSize).toBe(22);
|
||||
});
|
||||
|
||||
it("later layers override earlier layers", { timeout: 5000 }, () => {
|
||||
const merged = mergeStyles(
|
||||
{ fontSize: 10 },
|
||||
{ fontSize: 20 },
|
||||
{ fontSize: 30 }
|
||||
);
|
||||
expect(merged.fontSize).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeTheme", () => {
|
||||
it("returns the base theme when overrides is undefined", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(mergeTheme(DEFAULT_THEME, undefined)).toBe(DEFAULT_THEME);
|
||||
});
|
||||
|
||||
it("merges colour palette slot-by-slot", { timeout: 5000 }, () => {
|
||||
const merged = mergeTheme(DEFAULT_THEME, {
|
||||
colors: { ...DEFAULT_THEME.colors, primary: "#abcdef" },
|
||||
});
|
||||
expect(merged.colors.primary).toBe("#abcdef");
|
||||
expect(merged.colors.text).toBe(DEFAULT_THEME.colors.text);
|
||||
});
|
||||
|
||||
it("merges typography including headingSizes deeply", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const merged = mergeTheme(DEFAULT_THEME, {
|
||||
typography: {
|
||||
...DEFAULT_THEME.typography,
|
||||
headingSizes: {
|
||||
...DEFAULT_THEME.typography.headingSizes,
|
||||
h1: 36,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(merged.typography.headingSizes.h1).toBe(36);
|
||||
expect(merged.typography.headingSizes.h2).toBe(
|
||||
DEFAULT_THEME.typography.headingSizes.h2
|
||||
);
|
||||
});
|
||||
|
||||
it("overrides theme name when supplied", { timeout: 5000 }, () => {
|
||||
expect(mergeTheme(DEFAULT_THEME, { name: "branded" }).name).toBe(
|
||||
"branded"
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves base name when override omits it", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
mergeTheme(DEFAULT_THEME, { spacing: { ...DEFAULT_THEME.spacing } })
|
||||
.name
|
||||
).toBe(DEFAULT_THEME.name);
|
||||
});
|
||||
});
|
||||
|
||||
describe("StyleResolver", () => {
|
||||
it("applies cascade DEFAULT_STYLE → theme → overrides", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const resolver = new StyleResolver();
|
||||
const out = resolver.resolve({ fontSize: 20 });
|
||||
expect(out.fontSize).toBe(20);
|
||||
expect(out.fontFamily).toBe(DEFAULT_THEME.typography.fontFamily);
|
||||
expect(out.color).toBe(DEFAULT_THEME.colors.text);
|
||||
});
|
||||
|
||||
it("resolveWith inserts a base layer between theme and overrides", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const resolver = new StyleResolver();
|
||||
const out = resolver.resolveWith(
|
||||
{ fontSize: 18 },
|
||||
{ color: "#112233" }
|
||||
);
|
||||
expect(out.fontSize).toBe(18);
|
||||
expect(out.color).toBe("#112233");
|
||||
});
|
||||
|
||||
it("withTheme returns a child resolver with the merged palette", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parent = new StyleResolver();
|
||||
const child = parent.withTheme({
|
||||
colors: { ...DEFAULT_THEME.colors, text: "#444444" },
|
||||
});
|
||||
expect(child.getTheme().colors.text).toBe("#444444");
|
||||
expect(parent.getTheme().colors.text).toBe(DEFAULT_THEME.colors.text);
|
||||
});
|
||||
|
||||
it("getTheme returns the active theme", { timeout: 5000 }, () => {
|
||||
const resolver = new StyleResolver();
|
||||
expect(resolver.getTheme()).toEqual(DEFAULT_THEME);
|
||||
});
|
||||
|
||||
it("uses the supplied custom theme when provided", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const custom = {
|
||||
...DEFAULT_THEME,
|
||||
typography: { ...DEFAULT_THEME.typography, baseFontSize: 16 },
|
||||
};
|
||||
const resolver = new StyleResolver(custom);
|
||||
const out = resolver.resolve();
|
||||
expect(out.fontSize).toBe(16);
|
||||
});
|
||||
});
|
||||
199
packages/devtools/docs/pdf/src/style-resolver/style-resolver.ts
Normal file
199
packages/devtools/docs/pdf/src/style-resolver/style-resolver.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { PDF_CODES, PdfError } from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
PdfStyle,
|
||||
PdfTheme,
|
||||
ResolvedSpacing,
|
||||
SpacingValue,
|
||||
} from "./style-resolver.types.js";
|
||||
|
||||
/**
|
||||
* Default {@link PdfStyle} applied as the bottom layer of the cascade.
|
||||
*
|
||||
* Mirrors the historical main-branch defaults so existing templates and
|
||||
* downstream consumers keep their visual output. All values are
|
||||
* deterministic — equal inputs yield equal output regardless of host
|
||||
* locale or time zone.
|
||||
*/
|
||||
export const DEFAULT_STYLE: PdfStyle = {
|
||||
fontSize: 12,
|
||||
fontFamily: "Helvetica",
|
||||
fontWeight: "normal",
|
||||
fontStyle: "normal",
|
||||
color: "#000000",
|
||||
backgroundColor: "transparent",
|
||||
lineHeight: 1.2,
|
||||
textAlign: "left",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
border: { width: 0, color: "#000000", style: "none" },
|
||||
width: "auto",
|
||||
height: "auto",
|
||||
opacity: 1,
|
||||
};
|
||||
|
||||
/** Default theme — applied second in the cascade after {@link DEFAULT_STYLE}. */
|
||||
export const DEFAULT_THEME: PdfTheme = {
|
||||
name: "default",
|
||||
colors: {
|
||||
primary: "#1a1a2e",
|
||||
secondary: "#16213e",
|
||||
text: "#000000",
|
||||
textLight: "#666666",
|
||||
background: "#ffffff",
|
||||
border: "#e0e0e0",
|
||||
accent: "#0f3460",
|
||||
error: "#e74c3c",
|
||||
success: "#27ae60",
|
||||
},
|
||||
typography: {
|
||||
fontFamily: "Helvetica",
|
||||
headingFontFamily: "Helvetica",
|
||||
baseFontSize: 10,
|
||||
headingSizes: { h1: 24, h2: 18, h3: 14 },
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalise a {@link SpacingValue} into a 4-sided {@link ResolvedSpacing}.
|
||||
* A scalar applies uniformly; an object passes through after a finite
|
||||
* check. Non-finite inputs raise `PDF.INVALID_ELEMENT` so the layout
|
||||
* engine never sees `NaN` arithmetic.
|
||||
*/
|
||||
export function normalizeSpacing(value: SpacingValue): ResolvedSpacing {
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.INVALID_ELEMENT,
|
||||
`non-finite spacing value: ${String(value)}`
|
||||
);
|
||||
}
|
||||
return { top: value, right: value, bottom: value, left: value };
|
||||
}
|
||||
const sides = [value.top, value.right, value.bottom, value.left];
|
||||
for (const side of sides) {
|
||||
if (!Number.isFinite(side)) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.INVALID_ELEMENT,
|
||||
`non-finite spacing value: ${String(side)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
top: value.top,
|
||||
right: value.right,
|
||||
bottom: value.bottom,
|
||||
left: value.left,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge multiple partial styles into a complete {@link PdfStyle}. Layers
|
||||
* are applied left-to-right so later arguments override earlier ones;
|
||||
* `undefined` fields are skipped so consumers can pass narrow patches
|
||||
* without zeroing out earlier layers.
|
||||
*/
|
||||
export function mergeStyles(
|
||||
...layers: ReadonlyArray<Partial<PdfStyle> | undefined>
|
||||
): PdfStyle {
|
||||
const result: PdfStyle = { ...DEFAULT_STYLE };
|
||||
const writable = result as unknown as Record<string, unknown>;
|
||||
for (const layer of layers) {
|
||||
if (!layer) continue;
|
||||
for (const [key, value] of Object.entries(layer)) {
|
||||
if (value !== undefined) {
|
||||
writable[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-merge a partial theme on top of a base theme, slot-by-slot. Keeps
|
||||
* the base name unless the override supplies one; merges colour /
|
||||
* typography / spacing objects field-by-field so callers can patch a
|
||||
* single colour without rebuilding the whole palette.
|
||||
*/
|
||||
export function mergeTheme(
|
||||
base: PdfTheme,
|
||||
overrides: Partial<PdfTheme> | undefined
|
||||
): PdfTheme {
|
||||
if (!overrides) return base;
|
||||
return {
|
||||
name: overrides.name ?? base.name,
|
||||
colors: { ...base.colors, ...overrides.colors },
|
||||
typography: {
|
||||
...base.typography,
|
||||
...overrides.typography,
|
||||
headingSizes: {
|
||||
...base.typography.headingSizes,
|
||||
...overrides.typography?.headingSizes,
|
||||
},
|
||||
},
|
||||
spacing: { ...base.spacing, ...overrides.spacing },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cascade engine that resolves a final {@link PdfStyle} from the
|
||||
* package defaults, a theme, an optional base style, and per-element
|
||||
* overrides. The same resolver instance is reused across an entire
|
||||
* render pass so theme allocation cost is amortised.
|
||||
*/
|
||||
export class StyleResolver<TTheme extends PdfTheme = PdfTheme> {
|
||||
private readonly theme: TTheme;
|
||||
private readonly themeStyle: Partial<PdfStyle>;
|
||||
|
||||
constructor(theme?: TTheme) {
|
||||
this.theme = (theme ?? (DEFAULT_THEME as PdfTheme)) as TTheme;
|
||||
this.themeStyle = themeToStyle(this.theme);
|
||||
}
|
||||
|
||||
/** Returns the active theme. */
|
||||
getTheme(): TTheme {
|
||||
return this.theme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `overrides` against the cascade
|
||||
* (`DEFAULT_STYLE → theme → overrides`).
|
||||
*/
|
||||
resolve(overrides?: Partial<PdfStyle>): PdfStyle {
|
||||
return mergeStyles(DEFAULT_STYLE, this.themeStyle, overrides);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve with an explicit base layer between the theme and the
|
||||
* overrides. Used by the renderer to pass per-element default
|
||||
* styles (e.g. heading sizes for h1/h2/h3 text elements) without
|
||||
* forcing the caller to repeat them at every call site.
|
||||
*/
|
||||
resolveWith(
|
||||
base: Partial<PdfStyle>,
|
||||
overrides?: Partial<PdfStyle>
|
||||
): PdfStyle {
|
||||
return mergeStyles(DEFAULT_STYLE, this.themeStyle, base, overrides);
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce a child resolver with the supplied theme overrides applied
|
||||
* on top of the current theme. Used by templates that need a local
|
||||
* palette tweak without mutating the parent resolver.
|
||||
*/
|
||||
withTheme(overrides: Partial<PdfTheme>): StyleResolver<TTheme> {
|
||||
const merged = mergeTheme(this.theme, overrides) as TTheme;
|
||||
return new StyleResolver<TTheme>(merged);
|
||||
}
|
||||
}
|
||||
|
||||
function themeToStyle(theme: PdfTheme): Partial<PdfStyle> {
|
||||
return {
|
||||
fontSize: theme.typography.baseFontSize,
|
||||
fontFamily: theme.typography.fontFamily,
|
||||
lineHeight: theme.typography.lineHeight,
|
||||
color: theme.colors.text,
|
||||
backgroundColor: theme.colors.background,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Public types for the style cascade used by `@suaveplan/pdf`.
|
||||
*
|
||||
* The cascade flows: `DEFAULT_STYLE` → theme defaults → element-supplied
|
||||
* base → element-supplied overrides. Themes drive document-wide colour
|
||||
* palettes, typography, and spacing scales; the resolved `PdfStyle`
|
||||
* carries everything the layout engine + renderer need to position and
|
||||
* draw a single element.
|
||||
*/
|
||||
|
||||
import type {
|
||||
PdfFontFamily,
|
||||
PdfFontStyle,
|
||||
PdfFontWeight,
|
||||
} from "../text-element/text-element.types.js";
|
||||
|
||||
/** A spacing value that is either uniform or per-side (top/right/bottom/left). */
|
||||
export type SpacingValue =
|
||||
| number
|
||||
| {
|
||||
readonly top: number;
|
||||
readonly right: number;
|
||||
readonly bottom: number;
|
||||
readonly left: number;
|
||||
};
|
||||
|
||||
/** Border declaration carried in a {@link PdfStyle}. */
|
||||
export interface BorderStyle {
|
||||
readonly width: number;
|
||||
readonly color: string;
|
||||
readonly style: "solid" | "dashed" | "none";
|
||||
}
|
||||
|
||||
/** Resolved style applied to a rendered element. */
|
||||
export interface PdfStyle {
|
||||
readonly fontSize: number;
|
||||
readonly fontFamily: PdfFontFamily;
|
||||
readonly fontWeight: PdfFontWeight;
|
||||
readonly fontStyle: PdfFontStyle;
|
||||
readonly color: string;
|
||||
readonly backgroundColor: string;
|
||||
readonly lineHeight: number;
|
||||
readonly textAlign: "left" | "center" | "right" | "justify";
|
||||
readonly padding: SpacingValue;
|
||||
readonly margin: SpacingValue;
|
||||
readonly border: BorderStyle;
|
||||
readonly width: number | "auto";
|
||||
readonly height: number | "auto";
|
||||
readonly opacity: number;
|
||||
}
|
||||
|
||||
/** Theme colour palette. */
|
||||
export interface ThemeColors {
|
||||
readonly primary: string;
|
||||
readonly secondary: string;
|
||||
readonly text: string;
|
||||
readonly textLight: string;
|
||||
readonly background: string;
|
||||
readonly border: string;
|
||||
readonly accent: string;
|
||||
readonly error: string;
|
||||
readonly success: string;
|
||||
}
|
||||
|
||||
/** Theme typography settings. */
|
||||
export interface ThemeTypography {
|
||||
readonly fontFamily: PdfFontFamily;
|
||||
readonly headingFontFamily: PdfFontFamily;
|
||||
readonly baseFontSize: number;
|
||||
readonly headingSizes: {
|
||||
readonly h1: number;
|
||||
readonly h2: number;
|
||||
readonly h3: number;
|
||||
};
|
||||
readonly lineHeight: number;
|
||||
}
|
||||
|
||||
/** Theme spacing scale. */
|
||||
export interface ThemeSpacing {
|
||||
readonly xs: number;
|
||||
readonly sm: number;
|
||||
readonly md: number;
|
||||
readonly lg: number;
|
||||
readonly xl: number;
|
||||
}
|
||||
|
||||
/** Full theme definition that can be plugged into a {@link StyleResolver}. */
|
||||
export interface PdfTheme {
|
||||
readonly name: string;
|
||||
readonly colors: ThemeColors;
|
||||
readonly typography: ThemeTypography;
|
||||
readonly spacing: ThemeSpacing;
|
||||
}
|
||||
|
||||
/** Normalised spacing — every side is explicit. */
|
||||
export interface ResolvedSpacing {
|
||||
readonly top: number;
|
||||
readonly right: number;
|
||||
readonly bottom: number;
|
||||
readonly left: number;
|
||||
}
|
||||
10
packages/devtools/docs/pdf/src/table-element/index.ts
Normal file
10
packages/devtools/docs/pdf/src/table-element/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
computeColumnWidths,
|
||||
TableElement,
|
||||
TableElementOptionsSchema,
|
||||
} from "./table-element.js";
|
||||
export type {
|
||||
TableColumn,
|
||||
TableElementOptions,
|
||||
TablePageRender,
|
||||
} from "./table-element.types.js";
|
||||
124
packages/devtools/docs/pdf/src/table-element/table-element.md
Normal file
124
packages/devtools/docs/pdf/src/table-element/table-element.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# table-element
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`TableElement` is the tabular-data primitive of `@suaveplan/pdf`. It
|
||||
accepts a typed column definition + row array and renders a content
|
||||
stream containing a header row, alternating row backgrounds, per-cell
|
||||
alignment, optional borders, and cross-page pagination with header
|
||||
repeat. The element is parameterised on the row type `TRow` so callers
|
||||
get full TypeScript inference on the `formatter(value, row)` hook,
|
||||
keeping the template layer (invoice, receipt, report) strongly typed.
|
||||
|
||||
The element validates options through Zod and consumes Chunk B's
|
||||
`ContentStreamEncoder` for graphics-state-clean output. Pagination is
|
||||
exposed via `renderPage(context, rowStart)` so the layout engine in
|
||||
Chunk D can iterate page boundaries without re-measuring the entire
|
||||
table.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-column `width`, `align`, `header`, optional `formatter(value, row)`.
|
||||
- Auto-distribution of remaining width across columns without `width`.
|
||||
- Header repeat across paginated pages (toggleable).
|
||||
- Alternating row backgrounds.
|
||||
- Cell text wrapping using the text-element wrap helper (single line
|
||||
shown by default; multi-line cells are a Chunk-D enhancement).
|
||||
- Optional outer + inter-column border strokes.
|
||||
- `LAYOUT_OVERFLOW` typed error when the available height cannot even
|
||||
hold the header.
|
||||
- `AbortSignal` propagation on `render` / `renderPage`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { PdfWriter, TableElement } from "@suaveplan/pdf";
|
||||
|
||||
const writer = new PdfWriter();
|
||||
const fontName = writer.registerFont("Helvetica");
|
||||
const table = new TableElement({
|
||||
columns: [
|
||||
{ key: "name", header: "Name", width: 200 },
|
||||
{ key: "qty", header: "Qty", width: 60, align: "right" },
|
||||
{ key: "total", header: "Total", align: "right" },
|
||||
],
|
||||
rows: [
|
||||
{ name: "Widget", qty: 2, total: 10 },
|
||||
{ name: "Gizmo", qty: 1, total: 5 },
|
||||
],
|
||||
});
|
||||
const out = table.render({
|
||||
x: 72,
|
||||
y: 770,
|
||||
maxWidth: 460,
|
||||
fontResourceName: fontName,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { TableElement } from "@suaveplan/pdf";
|
||||
|
||||
interface Row { sku: string; price: number; qty: number; }
|
||||
const table = new TableElement<Row>({
|
||||
columns: [
|
||||
{ key: "sku", header: "SKU", width: 120 },
|
||||
{
|
||||
key: "price",
|
||||
header: "Price",
|
||||
width: 80,
|
||||
align: "right",
|
||||
formatter: (v) => `$${(Number(v) ?? 0).toFixed(2)}`,
|
||||
},
|
||||
{ key: "qty", header: "Qty", align: "center" },
|
||||
],
|
||||
rows: largeRowSet,
|
||||
headerBackground: "#0f3460",
|
||||
headerColor: "#ffffff",
|
||||
alternateRowBackground: "#f7f7f7",
|
||||
border: true,
|
||||
borderColor: "#cccccc",
|
||||
cellPadding: 6,
|
||||
fontSize: 9,
|
||||
});
|
||||
|
||||
// Paginate with header repeat across multiple pages.
|
||||
let cursor = 0;
|
||||
while (cursor < largeRowSet.length) {
|
||||
const page = table.renderPage(
|
||||
{ x: 72, y: 770, maxWidth: 460, maxHeight: 680, fontResourceName: "F1" },
|
||||
cursor
|
||||
);
|
||||
if (page.rowEnd === cursor) break; // nothing fitted — guard
|
||||
cursor = page.rowEnd;
|
||||
// Append `page.stream` to a new page in the writer.
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class TableElement<TRow>(options)` — validates `TableElementOptions`
|
||||
and exposes `measure(maxWidth)`, `render(context)`, and
|
||||
`renderPage(context, rowStart)`.
|
||||
- `TableElementOptionsSchema` — Zod schema used at construction.
|
||||
- `computeColumnWidths(columns, maxWidth)` — width distribution helper.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Header repeat is controlled by `repeatHeader` (default `true`) and
|
||||
applies to every `renderPage` call where `rowStart > 0` — set `false`
|
||||
when emitting continuation pages that already have their own header.
|
||||
Cell text is wrapped with `wrapText` from `text-element` but currently
|
||||
displays only the first line; multi-line cells require row-height
|
||||
expansion which lands in Chunk D's layout engine alongside row
|
||||
splitting. The border stroke draws an outer rectangle plus inter-column
|
||||
verticals via raw `S` operators — no horizontal row dividers by design
|
||||
(callers wanting full grids should set `border: true` and then layer a
|
||||
`DividerElement` per row in the layout engine). `AbortSignal` is
|
||||
checked at the top of `renderPage` so cancelled renders bail out
|
||||
before any pixel work begins.
|
||||
@@ -0,0 +1,360 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PDFParse } from "pdf-parse";
|
||||
import { PDF_CODES, PdfError } from "../error-codes/error-codes.js";
|
||||
import { PdfWriter } from "../pdf-writer/pdf-writer.js";
|
||||
import { computeColumnWidths, TableElement } from "./table-element.js";
|
||||
|
||||
interface Row {
|
||||
readonly name: string;
|
||||
readonly qty: number;
|
||||
readonly total: number;
|
||||
}
|
||||
|
||||
const baseColumns = [
|
||||
{ key: "name", header: "Name", width: 200 },
|
||||
{ key: "qty", header: "Qty", width: 60, align: "right" as const },
|
||||
{
|
||||
key: "total",
|
||||
header: "Total",
|
||||
align: "right" as const,
|
||||
formatter: (v: unknown): string => `$${Number(v).toFixed(2)}`,
|
||||
},
|
||||
];
|
||||
|
||||
describe("computeColumnWidths", () => {
|
||||
it("distributes remaining width across auto columns", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const widths = computeColumnWidths(
|
||||
[
|
||||
{ key: "a", header: "A", width: 100 },
|
||||
{ key: "b", header: "B" },
|
||||
],
|
||||
300
|
||||
);
|
||||
expect(widths).toEqual([100, 200]);
|
||||
});
|
||||
|
||||
it("returns explicit widths when no auto columns", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const widths = computeColumnWidths(
|
||||
[
|
||||
{ key: "a", header: "A", width: 100 },
|
||||
{ key: "b", header: "B", width: 150 },
|
||||
],
|
||||
300
|
||||
);
|
||||
expect(widths).toEqual([100, 150]);
|
||||
});
|
||||
|
||||
it("clamps remaining at 0 when explicit widths exceed maxWidth", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const widths = computeColumnWidths(
|
||||
[
|
||||
{ key: "a", header: "A", width: 200 },
|
||||
{ key: "b", header: "B" },
|
||||
],
|
||||
100
|
||||
);
|
||||
expect(widths).toEqual([200, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TableElement", () => {
|
||||
it("validates options at construction", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
() =>
|
||||
new TableElement({
|
||||
columns: [],
|
||||
rows: [],
|
||||
} as unknown as ConstructorParameters<typeof TableElement>[0])
|
||||
).toThrow(PdfError);
|
||||
});
|
||||
|
||||
it("measures header + rows", { timeout: 5000 }, () => {
|
||||
const t = new TableElement<Row>({
|
||||
columns: baseColumns,
|
||||
rows: [
|
||||
{ name: "A", qty: 1, total: 5 },
|
||||
{ name: "B", qty: 2, total: 10 },
|
||||
],
|
||||
});
|
||||
const m = t.measure(400);
|
||||
expect(m.lineCount).toBe(3);
|
||||
expect(m.height).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("hides header when showHeader=false", { timeout: 5000 }, () => {
|
||||
const t = new TableElement<Row>({
|
||||
columns: baseColumns,
|
||||
rows: [{ name: "A", qty: 1, total: 5 }],
|
||||
showHeader: false,
|
||||
});
|
||||
expect(t.measure(400).lineCount).toBe(1);
|
||||
});
|
||||
|
||||
it("renders header + rows with backgrounds", { timeout: 5000 }, () => {
|
||||
const t = new TableElement<Row>({
|
||||
columns: baseColumns,
|
||||
rows: [
|
||||
{ name: "A", qty: 1, total: 5 },
|
||||
{ name: "B", qty: 2, total: 10 },
|
||||
],
|
||||
alternateRowBackground: "#eeeeee",
|
||||
});
|
||||
const out = t.render({
|
||||
x: 50,
|
||||
y: 700,
|
||||
maxWidth: 400,
|
||||
fontResourceName: "F1",
|
||||
});
|
||||
expect(out.stream).toContain("(Name) Tj");
|
||||
expect(out.stream).toContain("(A) Tj");
|
||||
expect(out.stream).toContain("($5.00) Tj");
|
||||
expect(out.stream).toContain("re");
|
||||
expect(out.stream).toContain("f");
|
||||
});
|
||||
|
||||
it("paginates: stops at maxHeight, reports overflow", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TableElement<Row>({
|
||||
columns: baseColumns,
|
||||
rows: Array.from({ length: 50 }, (_, i) => ({
|
||||
name: `R${i}`,
|
||||
qty: i,
|
||||
total: i,
|
||||
})),
|
||||
});
|
||||
const out = t.render({
|
||||
x: 50,
|
||||
y: 700,
|
||||
maxWidth: 400,
|
||||
maxHeight: 100,
|
||||
fontResourceName: "F1",
|
||||
});
|
||||
expect(out.overflowed).toBe(true);
|
||||
expect(out.height).toBeLessThanOrEqual(100);
|
||||
});
|
||||
|
||||
it("renders consecutive pages with header repeat", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TableElement<Row>({
|
||||
columns: baseColumns,
|
||||
rows: Array.from({ length: 6 }, (_, i) => ({
|
||||
name: `R${i}`,
|
||||
qty: i,
|
||||
total: i,
|
||||
})),
|
||||
});
|
||||
const first = t.renderPage(
|
||||
{
|
||||
x: 0,
|
||||
y: 800,
|
||||
maxWidth: 400,
|
||||
maxHeight: 60,
|
||||
fontResourceName: "F1",
|
||||
},
|
||||
0
|
||||
);
|
||||
const second = t.renderPage(
|
||||
{
|
||||
x: 0,
|
||||
y: 800,
|
||||
maxWidth: 400,
|
||||
maxHeight: 60,
|
||||
fontResourceName: "F1",
|
||||
},
|
||||
first.rowEnd
|
||||
);
|
||||
expect(first.rowEnd).toBeGreaterThan(first.rowStart);
|
||||
expect(second.rowStart).toBe(first.rowEnd);
|
||||
expect(second.stream).toContain("(Name) Tj"); // header repeats
|
||||
});
|
||||
|
||||
it("disables header repeat when repeatHeader=false", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TableElement<Row>({
|
||||
columns: baseColumns,
|
||||
rows: Array.from({ length: 4 }, (_, i) => ({
|
||||
name: `R${i}`,
|
||||
qty: i,
|
||||
total: i,
|
||||
})),
|
||||
repeatHeader: false,
|
||||
});
|
||||
const second = t.renderPage(
|
||||
{
|
||||
x: 0,
|
||||
y: 800,
|
||||
maxWidth: 400,
|
||||
maxHeight: 200,
|
||||
fontResourceName: "F1",
|
||||
},
|
||||
2
|
||||
);
|
||||
expect(second.stream).not.toContain("(Name) Tj");
|
||||
});
|
||||
|
||||
it("raises LAYOUT_OVERFLOW when header itself does not fit", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TableElement<Row>({
|
||||
columns: baseColumns,
|
||||
rows: [{ name: "A", qty: 1, total: 5 }],
|
||||
});
|
||||
try {
|
||||
t.render({
|
||||
x: 0,
|
||||
y: 800,
|
||||
maxWidth: 400,
|
||||
maxHeight: 1,
|
||||
fontResourceName: "F1",
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(PdfError);
|
||||
expect((err as PdfError).code).toBe(PDF_CODES.LAYOUT_OVERFLOW);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders borders without column dividers for single column", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new TableElement({
|
||||
columns: [{ key: "a", header: "A", width: 200 }],
|
||||
rows: [{ a: "row" }],
|
||||
border: true,
|
||||
});
|
||||
const out = t.render({
|
||||
x: 0,
|
||||
y: 800,
|
||||
maxWidth: 200,
|
||||
fontResourceName: "F1",
|
||||
});
|
||||
expect(out.stream).toContain("S");
|
||||
});
|
||||
|
||||
it("renders borders when requested", { timeout: 5000 }, () => {
|
||||
const t = new TableElement<Row>({
|
||||
columns: baseColumns,
|
||||
rows: [{ name: "A", qty: 1, total: 5 }],
|
||||
border: true,
|
||||
borderColor: "#000000",
|
||||
});
|
||||
const out = t.render({
|
||||
x: 0,
|
||||
y: 800,
|
||||
maxWidth: 400,
|
||||
fontResourceName: "F1",
|
||||
});
|
||||
expect(out.stream).toContain("S");
|
||||
// outer rectangle stroke + at least one column divider
|
||||
const sCount = (out.stream.match(/(?:^|\n)S(?=\n|$)/g) ?? []).length;
|
||||
expect(sCount).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("aligns right + center cells", { timeout: 5000 }, () => {
|
||||
const t = new TableElement<Row>({
|
||||
columns: [
|
||||
{ key: "name", header: "Name", width: 100, align: "center" },
|
||||
{ key: "qty", header: "Qty", width: 100, align: "right" },
|
||||
{ key: "total", header: "Total", width: 100 },
|
||||
],
|
||||
rows: [{ name: "X", qty: 7, total: 9 }],
|
||||
});
|
||||
const out = t.render({
|
||||
x: 0,
|
||||
y: 800,
|
||||
maxWidth: 300,
|
||||
fontResourceName: "F1",
|
||||
});
|
||||
expect(out.stream).toContain("(X) Tj");
|
||||
expect(out.stream).toContain("(7) Tj");
|
||||
expect(out.stream).toContain("(9) Tj");
|
||||
});
|
||||
|
||||
it("requires fontResourceName", { timeout: 5000 }, () => {
|
||||
const t = new TableElement<Row>({
|
||||
columns: baseColumns,
|
||||
rows: [{ name: "A", qty: 1, total: 5 }],
|
||||
});
|
||||
expect(() => t.render({ x: 0, y: 800, maxWidth: 400 })).toThrow(
|
||||
PdfError
|
||||
);
|
||||
});
|
||||
|
||||
it("aborts on aborted signal", { timeout: 5000 }, () => {
|
||||
const t = new TableElement<Row>({
|
||||
columns: baseColumns,
|
||||
rows: [{ name: "A", qty: 1, total: 5 }],
|
||||
});
|
||||
const c = new AbortController();
|
||||
c.abort();
|
||||
expect(() =>
|
||||
t.render({
|
||||
x: 0,
|
||||
y: 800,
|
||||
maxWidth: 400,
|
||||
fontResourceName: "F1",
|
||||
signal: c.signal,
|
||||
})
|
||||
).toThrow(PdfError);
|
||||
});
|
||||
|
||||
it("handles null / undefined row values", { timeout: 5000 }, () => {
|
||||
const t = new TableElement({
|
||||
columns: [
|
||||
{ key: "a", header: "A", width: 100 },
|
||||
{ key: "b", header: "B", width: 100 },
|
||||
],
|
||||
rows: [{ a: undefined, b: null }],
|
||||
});
|
||||
const out = t.render({
|
||||
x: 0,
|
||||
y: 800,
|
||||
maxWidth: 200,
|
||||
fontResourceName: "F1",
|
||||
});
|
||||
expect(out.stream).toContain("BT");
|
||||
});
|
||||
|
||||
it("round-trips through pdf-parse", { timeout: 30000 }, async () => {
|
||||
const writer = new PdfWriter();
|
||||
const fontResource = writer.registerFont("Helvetica");
|
||||
const t = new TableElement<Row>({
|
||||
columns: baseColumns,
|
||||
rows: [
|
||||
{ name: "Widget", qty: 2, total: 10 },
|
||||
{ name: "Gizmo", qty: 1, total: 5 },
|
||||
],
|
||||
});
|
||||
const out = t.render({
|
||||
x: 72,
|
||||
y: 770,
|
||||
maxWidth: 460,
|
||||
fontResourceName: fontResource,
|
||||
});
|
||||
const streamId = writer.addStream(out.stream);
|
||||
writer.addPage({
|
||||
width: 612,
|
||||
height: 792,
|
||||
contentStreamId: streamId,
|
||||
});
|
||||
const bytes = writer.generate({ Title: "Table" });
|
||||
const parser = new PDFParse({ data: bytes });
|
||||
try {
|
||||
const info = await parser.getInfo();
|
||||
const text = await parser.getText();
|
||||
expect(info.total).toBe(1);
|
||||
expect(text.text).toContain("Widget");
|
||||
expect(text.text).toContain("Gizmo");
|
||||
} finally {
|
||||
await parser.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
329
packages/devtools/docs/pdf/src/table-element/table-element.ts
Normal file
329
packages/devtools/docs/pdf/src/table-element/table-element.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import z from "zod";
|
||||
import { hexToPdfColor, isTransparent } from "../colors/colors.js";
|
||||
import { ContentStreamEncoder } from "../content-stream/content-stream.js";
|
||||
import { PDF_CODES, PdfError } from "../error-codes/error-codes.js";
|
||||
import { parseOrThrow } from "../schemas/schemas.js";
|
||||
import { measureLineWidth, wrapText } from "../text-element/text-element.js";
|
||||
import type {
|
||||
ElementMeasurement,
|
||||
ElementRenderContext,
|
||||
ElementRenderResult,
|
||||
PdfTextAlign,
|
||||
} from "../text-element/text-element.types.js";
|
||||
import type {
|
||||
TableColumn,
|
||||
TableElementOptions,
|
||||
TablePageRender,
|
||||
} from "./table-element.types.js";
|
||||
|
||||
/** Zod schema for {@link TableElementOptions}. */
|
||||
export const TableElementOptionsSchema = z.object({
|
||||
columns: z
|
||||
.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
header: z.string(),
|
||||
width: z.number().positive().optional(),
|
||||
align: z
|
||||
.enum(["left", "center", "right", "justify"])
|
||||
.optional(),
|
||||
formatter: z
|
||||
.custom<(v: unknown, r: unknown) => string>(
|
||||
(fn) => typeof fn === "function"
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
)
|
||||
.min(1),
|
||||
rows: z.array(z.unknown()),
|
||||
headerBackground: z.string().optional(),
|
||||
headerColor: z.string().optional(),
|
||||
rowColor: z.string().optional(),
|
||||
alternateRowBackground: z.string().optional(),
|
||||
showHeader: z.boolean().optional(),
|
||||
repeatHeader: z.boolean().optional(),
|
||||
cellPadding: z.number().nonnegative().optional(),
|
||||
fontSize: z.number().positive().optional(),
|
||||
lineHeight: z.number().positive().optional(),
|
||||
rowHeight: z.number().positive().optional(),
|
||||
border: z.boolean().optional(),
|
||||
borderColor: z.string().optional(),
|
||||
});
|
||||
|
||||
const DEFAULTS = {
|
||||
cellPadding: 4,
|
||||
fontSize: 10,
|
||||
lineHeight: 1.2,
|
||||
headerBackground: "#f0f0f0",
|
||||
headerColor: "#000000",
|
||||
rowColor: "#000000",
|
||||
alternateRowBackground: "transparent",
|
||||
borderColor: "#cccccc",
|
||||
};
|
||||
|
||||
/**
|
||||
* Distribute column widths across `maxWidth`. Columns with an explicit
|
||||
* `width` keep it; remaining columns share the leftover space evenly.
|
||||
*/
|
||||
export function computeColumnWidths<TRow>(
|
||||
columns: ReadonlyArray<TableColumn<TRow>>,
|
||||
maxWidth: number
|
||||
): number[] {
|
||||
const used = columns.reduce((s, c) => s + (c.width ?? 0), 0);
|
||||
const remaining = Math.max(0, maxWidth - used);
|
||||
const auto = columns.filter((c) => c.width === undefined).length;
|
||||
const autoWidth = auto > 0 ? remaining / auto : 0;
|
||||
return columns.map((c) => c.width ?? autoWidth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tabular element with header repeat, alternating row backgrounds,
|
||||
* per-column alignment + width, and pagination support.
|
||||
*/
|
||||
export class TableElement<TRow = unknown> {
|
||||
readonly options: Readonly<TableElementOptions<TRow>>;
|
||||
|
||||
constructor(options: TableElementOptions<TRow>) {
|
||||
this.options = parseOrThrow(
|
||||
TableElementOptionsSchema,
|
||||
options,
|
||||
PDF_CODES.INVALID_ELEMENT,
|
||||
"invalid TableElement options"
|
||||
) as Readonly<TableElementOptions<TRow>>;
|
||||
}
|
||||
|
||||
/** Vertical space the full table would occupy. */
|
||||
measure(maxWidth: number): ElementMeasurement {
|
||||
const cellPadding = this.options.cellPadding ?? DEFAULTS.cellPadding;
|
||||
const fontSize = this.options.fontSize ?? DEFAULTS.fontSize;
|
||||
const lineHeight =
|
||||
(this.options.lineHeight ?? DEFAULTS.lineHeight) * fontSize;
|
||||
const rowHeight =
|
||||
this.options.rowHeight ?? lineHeight + cellPadding * 2;
|
||||
const showHeader = this.options.showHeader !== false;
|
||||
const total = this.options.rows.length + (showHeader ? 1 : 0);
|
||||
return {
|
||||
width: maxWidth,
|
||||
height: total * rowHeight,
|
||||
lineCount: total,
|
||||
};
|
||||
}
|
||||
|
||||
/** Render the table at the given context (single page). */
|
||||
render(context: ElementRenderContext): ElementRenderResult {
|
||||
const page = this.renderPage(context, 0);
|
||||
return {
|
||||
stream: page.stream,
|
||||
width: context.maxWidth,
|
||||
height: page.height,
|
||||
overflowed: page.rowEnd < this.options.rows.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render rows `[rowStart, ...)` until the next would not fit in
|
||||
* `maxHeight`. Used by the layout engine to break tables across
|
||||
* pages with optional header repeat.
|
||||
*/
|
||||
renderPage(
|
||||
context: ElementRenderContext,
|
||||
rowStart: number
|
||||
): TablePageRender {
|
||||
if (context.signal?.aborted) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.ABORTED,
|
||||
"TableElement.render aborted"
|
||||
);
|
||||
}
|
||||
if (!context.fontResourceName) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.RENDER_FAILED,
|
||||
"TableElement.render requires fontResourceName"
|
||||
);
|
||||
}
|
||||
const cellPadding = this.options.cellPadding ?? DEFAULTS.cellPadding;
|
||||
const fontSize = this.options.fontSize ?? DEFAULTS.fontSize;
|
||||
const lineHeight =
|
||||
(this.options.lineHeight ?? DEFAULTS.lineHeight) * fontSize;
|
||||
const rowHeight =
|
||||
this.options.rowHeight ?? lineHeight + cellPadding * 2;
|
||||
const showHeader = this.options.showHeader !== false;
|
||||
const repeatHeader = this.options.repeatHeader !== false;
|
||||
const widths = computeColumnWidths(
|
||||
this.options.columns,
|
||||
context.maxWidth
|
||||
);
|
||||
const maxHeight = context.maxHeight ?? Number.POSITIVE_INFINITY;
|
||||
|
||||
const encoder = new ContentStreamEncoder();
|
||||
let currentY = context.y;
|
||||
let consumed = 0;
|
||||
|
||||
const includeHeader = showHeader && (rowStart === 0 || repeatHeader);
|
||||
if (includeHeader) {
|
||||
if (consumed + rowHeight > maxHeight) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.LAYOUT_OVERFLOW,
|
||||
"Table cannot fit even its header in the available height"
|
||||
);
|
||||
}
|
||||
this.renderRow(
|
||||
encoder,
|
||||
this.options.columns.map((c) => c.header),
|
||||
widths,
|
||||
context.x,
|
||||
currentY,
|
||||
rowHeight,
|
||||
cellPadding,
|
||||
fontSize,
|
||||
context.fontResourceName,
|
||||
this.options.headerBackground ?? DEFAULTS.headerBackground,
|
||||
this.options.headerColor ?? DEFAULTS.headerColor,
|
||||
true
|
||||
);
|
||||
currentY -= rowHeight;
|
||||
consumed += rowHeight;
|
||||
}
|
||||
|
||||
let rowEnd = rowStart;
|
||||
for (let i = rowStart; i < this.options.rows.length; i++) {
|
||||
if (consumed + rowHeight > maxHeight) break;
|
||||
const row = this.options.rows[i] as TRow;
|
||||
const values = this.options.columns.map((col) => {
|
||||
const raw = (row as Record<string, unknown>)[col.key];
|
||||
return col.formatter
|
||||
? col.formatter(raw, row)
|
||||
: String(raw ?? "");
|
||||
});
|
||||
const isAlt = i % 2 === 1;
|
||||
const bg = isAlt
|
||||
? (this.options.alternateRowBackground ??
|
||||
DEFAULTS.alternateRowBackground)
|
||||
: "transparent";
|
||||
this.renderRow(
|
||||
encoder,
|
||||
values,
|
||||
widths,
|
||||
context.x,
|
||||
currentY,
|
||||
rowHeight,
|
||||
cellPadding,
|
||||
fontSize,
|
||||
context.fontResourceName,
|
||||
bg,
|
||||
this.options.rowColor ?? DEFAULTS.rowColor,
|
||||
false
|
||||
);
|
||||
currentY -= rowHeight;
|
||||
consumed += rowHeight;
|
||||
rowEnd = i + 1;
|
||||
}
|
||||
|
||||
if (this.options.border) {
|
||||
this.renderBorders(encoder, context.x, context.y, widths, consumed);
|
||||
}
|
||||
|
||||
return {
|
||||
stream: encoder.encode(),
|
||||
height: consumed,
|
||||
rowStart,
|
||||
rowEnd,
|
||||
};
|
||||
}
|
||||
|
||||
private renderRow(
|
||||
encoder: ContentStreamEncoder,
|
||||
values: string[],
|
||||
widths: number[],
|
||||
x: number,
|
||||
y: number,
|
||||
rowHeight: number,
|
||||
padding: number,
|
||||
fontSize: number,
|
||||
fontResource: string,
|
||||
bgColor: string,
|
||||
textColor: string,
|
||||
_isHeader: boolean
|
||||
): void {
|
||||
let cellX = x;
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const w = widths[i] as number;
|
||||
if (!isTransparent(bgColor)) {
|
||||
encoder.saveGraphicsState();
|
||||
encoder.setFillColor(hexToPdfColor(bgColor));
|
||||
encoder.rectangle(cellX, y - rowHeight, w, rowHeight);
|
||||
encoder.fill();
|
||||
encoder.restoreGraphicsState();
|
||||
}
|
||||
// Wrap cell content to width.
|
||||
const innerWidth = Math.max(1, w - padding * 2);
|
||||
const wrapped = wrapText(values[i] as string, fontSize, innerWidth);
|
||||
const display = wrapped[0] as string;
|
||||
encoder.saveGraphicsState();
|
||||
encoder.setFillColor(hexToPdfColor(textColor));
|
||||
encoder.beginText();
|
||||
encoder.setFont(fontResource, fontSize);
|
||||
const align = this.options.columns[i]?.align ?? "left";
|
||||
const textX = alignCellText(
|
||||
display,
|
||||
fontSize,
|
||||
cellX,
|
||||
w,
|
||||
padding,
|
||||
align
|
||||
);
|
||||
const textY = y - padding - fontSize;
|
||||
encoder.moveTextCursor(textX, textY);
|
||||
encoder.showText(display);
|
||||
encoder.endText();
|
||||
encoder.restoreGraphicsState();
|
||||
cellX += w;
|
||||
}
|
||||
}
|
||||
|
||||
private renderBorders(
|
||||
encoder: ContentStreamEncoder,
|
||||
x: number,
|
||||
topY: number,
|
||||
widths: number[],
|
||||
totalHeight: number
|
||||
): void {
|
||||
const color = this.options.borderColor ?? DEFAULTS.borderColor;
|
||||
encoder.saveGraphicsState();
|
||||
encoder.setStrokeColor(hexToPdfColor(color));
|
||||
encoder.setLineWidth(0.5);
|
||||
const totalWidth = widths.reduce((s, w) => s + w, 0);
|
||||
const bottomY = topY - totalHeight;
|
||||
// Outer rectangle.
|
||||
encoder.rectangle(x, bottomY, totalWidth, totalHeight);
|
||||
encoder.stroke();
|
||||
// Column dividers.
|
||||
let cellX = x;
|
||||
for (let i = 0; i < widths.length - 1; i++) {
|
||||
cellX += widths[i] as number;
|
||||
encoder.moveTo(cellX, topY);
|
||||
encoder.lineTo(cellX, bottomY);
|
||||
encoder.stroke();
|
||||
}
|
||||
encoder.restoreGraphicsState();
|
||||
}
|
||||
}
|
||||
|
||||
function alignCellText(
|
||||
text: string,
|
||||
fontSize: number,
|
||||
cellX: number,
|
||||
cellWidth: number,
|
||||
padding: number,
|
||||
align: PdfTextAlign
|
||||
): number {
|
||||
const innerWidth = cellWidth - padding * 2;
|
||||
const textWidth = measureLineWidth(text, fontSize);
|
||||
if (align === "center") {
|
||||
return cellX + padding + (innerWidth - textWidth) / 2;
|
||||
}
|
||||
if (align === "right") {
|
||||
return cellX + cellWidth - padding - textWidth;
|
||||
}
|
||||
return cellX + padding;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { PdfTextAlign } from "../text-element/text-element.types.js";
|
||||
|
||||
/** Definition of a single column in a {@link TableElement}. */
|
||||
export interface TableColumn<TRow = unknown> {
|
||||
readonly key: string;
|
||||
readonly header: string;
|
||||
readonly width?: number | undefined;
|
||||
readonly align?: PdfTextAlign | undefined;
|
||||
readonly formatter?: ((value: unknown, row: TRow) => string) | undefined;
|
||||
}
|
||||
|
||||
/** Options accepted by {@link TableElement}. */
|
||||
export interface TableElementOptions<TRow = unknown> {
|
||||
readonly columns: ReadonlyArray<TableColumn<TRow>>;
|
||||
readonly rows: ReadonlyArray<TRow>;
|
||||
readonly headerBackground?: string | undefined;
|
||||
readonly headerColor?: string | undefined;
|
||||
readonly rowColor?: string | undefined;
|
||||
readonly alternateRowBackground?: string | undefined;
|
||||
readonly showHeader?: boolean | undefined;
|
||||
readonly repeatHeader?: boolean | undefined;
|
||||
readonly cellPadding?: number | undefined;
|
||||
readonly fontSize?: number | undefined;
|
||||
readonly lineHeight?: number | undefined;
|
||||
readonly rowHeight?: number | undefined;
|
||||
readonly border?: boolean | undefined;
|
||||
readonly borderColor?: string | undefined;
|
||||
}
|
||||
|
||||
/** A single rendered page of a paginated table. */
|
||||
export interface TablePageRender {
|
||||
readonly stream: string;
|
||||
readonly height: number;
|
||||
readonly rowStart: number;
|
||||
readonly rowEnd: number;
|
||||
}
|
||||
19
packages/devtools/docs/pdf/src/telemetry/index.ts
Normal file
19
packages/devtools/docs/pdf/src/telemetry/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export {
|
||||
createPdfObservability,
|
||||
recordDocumentRendered,
|
||||
recordDocumentSize,
|
||||
recordPagesEmitted,
|
||||
recordRenderDuration,
|
||||
recordRenderError,
|
||||
recordValidationFailure,
|
||||
} from "./telemetry.js";
|
||||
export type {
|
||||
Counter,
|
||||
Histogram,
|
||||
Meter,
|
||||
PdfCounters,
|
||||
PdfHistograms,
|
||||
PdfObservability,
|
||||
PdfObservabilityOptions,
|
||||
Tracer,
|
||||
} from "./telemetry.types.js";
|
||||
104
packages/devtools/docs/pdf/src/telemetry/telemetry.md
Normal file
104
packages/devtools/docs/pdf/src/telemetry/telemetry.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# `telemetry`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
OpenTelemetry surface for `@suaveplan/pdf`. PDF generation is a
|
||||
load-bearing primitive in any system that emits invoices, receipts, or
|
||||
financial reports, so the package needs first-class observability —
|
||||
counters for throughput / failure-rate, histograms for latency and
|
||||
output size, all tagged with stable `PDF.*` codes. The
|
||||
`createPdfObservability` factory constructs the bundle once at module
|
||||
load and binds to the global no-op meter / tracer when no telemetry
|
||||
pipeline is wired up, so the recording helpers are always safe to call
|
||||
from the hot path.
|
||||
|
||||
## Features
|
||||
|
||||
- Six instruments on the `@suaveplan/pdf` meter:
|
||||
- `pdf_documents_rendered_total` — counter tagged `success`.
|
||||
- `pdf_pages_emitted_total` — counter.
|
||||
- `pdf_render_errors_total` — counter tagged `code`.
|
||||
- `pdf_validation_failures_total` — counter tagged `code`.
|
||||
- `pdf_render_duration_ms` — histogram (ms).
|
||||
- `pdf_document_size_bytes` — histogram (bytes).
|
||||
- `createPdfObservability(opts?)` factory accepting either an explicit
|
||||
bundle (`opts.observability`), an explicit meter + tracer pair, or
|
||||
nothing (binds to the no-op providers).
|
||||
- Six matching `record*` helpers so call sites do not have to know the
|
||||
attribute keys.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createPdfObservability, recordDocumentRendered } from "@suaveplan/pdf/telemetry";
|
||||
|
||||
const obs = createPdfObservability();
|
||||
recordDocumentRendered(obs, { success: true });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
In tests, supply a meter from a `MeterProvider` configured with a
|
||||
metric reader to assert exact recorded values:
|
||||
|
||||
```ts
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
import {
|
||||
createPdfObservability,
|
||||
recordRenderDuration,
|
||||
recordRenderError,
|
||||
} from "@suaveplan/pdf/telemetry";
|
||||
|
||||
const provider = new MeterProvider();
|
||||
const meter = provider.getMeter("test");
|
||||
const obs = createPdfObservability({ meter });
|
||||
|
||||
recordRenderError(obs, { code: "PDF.RENDER_FAILED" });
|
||||
recordRenderDuration(obs, 42);
|
||||
```
|
||||
|
||||
In production, install the OTLP / Prometheus exporter once at process
|
||||
start-up; every call site in `@suaveplan/pdf` will then surface metrics
|
||||
to your collector automatically because `createPdfObservability()` (no
|
||||
args) reads from the global `metrics`/`trace` API providers.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createPdfObservability(opts?: PdfObservabilityOptions): PdfObservability`
|
||||
— factory returning `{ meter, tracer, counters, histograms }`.
|
||||
- `recordDocumentRendered(obs, { success })` — adds `1` to the
|
||||
documents-rendered counter tagged with `success` as the string
|
||||
`"true"` / `"false"`.
|
||||
- `recordPagesEmitted(obs, count)` — adds `count` to the pages-emitted
|
||||
counter.
|
||||
- `recordRenderError(obs, { code })` — adds `1` to the render-errors
|
||||
counter tagged with the `PDF.*` code.
|
||||
- `recordValidationFailure(obs, { code })` — adds `1` to the
|
||||
validation-failures counter tagged with the `PDF.*` code.
|
||||
- `recordRenderDuration(obs, ms)` — records a sample on the
|
||||
render-duration histogram.
|
||||
- `recordDocumentSize(obs, bytes)` — records a sample on the
|
||||
document-size histogram.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `createCounter` / `createHistogram` come from
|
||||
`@suaveplan/opentelemetry`; they wrap the standard
|
||||
`Meter.createCounter` / `Meter.createHistogram` so the no-op meter is
|
||||
picked up automatically when no `MeterProvider` is installed.
|
||||
- Histogram units follow the OpenTelemetry convention: `"ms"` for
|
||||
durations, `"By"` for byte sizes.
|
||||
- Attribute values are stringified at the call-site (`success` becomes
|
||||
`"true"` / `"false"`) because OTel SDKs reject boolean attribute
|
||||
values in some backends; sticking to strings is the portable form.
|
||||
- `createPdfObservability({ observability })` short-circuits because
|
||||
call sites embedded inside a larger service want to pass through the
|
||||
bundle their caller already built, avoiding duplicate counter
|
||||
registration warnings on the OTel SDK side.
|
||||
- Each record helper is its own export — colocating them with the
|
||||
bundle factory lets call sites tree-shake unused recorders without
|
||||
pulling the entire factory.
|
||||
57
packages/devtools/docs/pdf/src/telemetry/telemetry.test.ts
Normal file
57
packages/devtools/docs/pdf/src/telemetry/telemetry.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { metrics, trace } from "@opentelemetry/api";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
createPdfObservability,
|
||||
recordDocumentRendered,
|
||||
recordDocumentSize,
|
||||
recordPagesEmitted,
|
||||
recordRenderDuration,
|
||||
recordRenderError,
|
||||
recordValidationFailure,
|
||||
} from "./telemetry.js";
|
||||
|
||||
describe("createPdfObservability", () => {
|
||||
it("createPdfObservability exposes six instruments", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const obs = createPdfObservability();
|
||||
expect(obs.meter).toBeDefined();
|
||||
expect(obs.tracer).toBeDefined();
|
||||
expect(obs.counters.documentsRendered).toBeDefined();
|
||||
expect(obs.counters.pagesEmitted).toBeDefined();
|
||||
expect(obs.counters.renderErrors).toBeDefined();
|
||||
expect(obs.counters.validationFailures).toBeDefined();
|
||||
expect(obs.histograms.renderDurationMs).toBeDefined();
|
||||
expect(obs.histograms.documentSizeBytes).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns supplied bundle when observability option is set", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const first = createPdfObservability();
|
||||
const second = createPdfObservability({ observability: first });
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it("accepts custom meter and tracer", { timeout: 5000 }, () => {
|
||||
const meter = metrics.getMeter("pdf-test-meter");
|
||||
const tracer = trace.getTracer("pdf-test-tracer");
|
||||
const obs = createPdfObservability({ meter, tracer });
|
||||
expect(obs.meter).toBe(meter);
|
||||
expect(obs.tracer).toBe(tracer);
|
||||
});
|
||||
});
|
||||
|
||||
describe("record helpers", () => {
|
||||
it("record helpers fire on the supplied meter", { timeout: 5000 }, () => {
|
||||
const obs = createPdfObservability();
|
||||
// All six helpers must execute without throwing on the no-op meter.
|
||||
recordDocumentRendered(obs, { success: true });
|
||||
recordDocumentRendered(obs, { success: false });
|
||||
recordPagesEmitted(obs, 3);
|
||||
recordRenderError(obs, { code: "PDF.RENDER_FAILED" });
|
||||
recordValidationFailure(obs, { code: "PDF.INVALID_HEX_COLOR" });
|
||||
recordRenderDuration(obs, 12.5);
|
||||
recordDocumentSize(obs, 4096);
|
||||
});
|
||||
});
|
||||
111
packages/devtools/docs/pdf/src/telemetry/telemetry.ts
Normal file
111
packages/devtools/docs/pdf/src/telemetry/telemetry.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { metrics, trace } from "@opentelemetry/api";
|
||||
import { createCounter, createHistogram } from "@suaveplan/opentelemetry";
|
||||
import type {
|
||||
Meter,
|
||||
PdfObservability,
|
||||
PdfObservabilityOptions,
|
||||
Tracer,
|
||||
} from "./telemetry.types.js";
|
||||
|
||||
const METER_NAME = "@suaveplan/pdf";
|
||||
const TRACER_NAME = "@suaveplan/pdf";
|
||||
|
||||
/**
|
||||
* Build a {@link PdfObservability} bundle on the supplied meter and tracer,
|
||||
* or on the no-op providers from `@opentelemetry/api` when none are
|
||||
* supplied. Safe to call from every public entry point even when no
|
||||
* telemetry pipeline is wired up.
|
||||
*/
|
||||
export function createPdfObservability(
|
||||
opts?: PdfObservabilityOptions
|
||||
): PdfObservability {
|
||||
if (opts?.observability !== undefined) {
|
||||
return opts.observability;
|
||||
}
|
||||
const meter: Meter = opts?.meter ?? metrics.getMeter(METER_NAME);
|
||||
const tracer: Tracer = opts?.tracer ?? trace.getTracer(TRACER_NAME);
|
||||
return {
|
||||
meter,
|
||||
tracer,
|
||||
counters: {
|
||||
documentsRendered: createCounter(
|
||||
meter,
|
||||
"pdf_documents_rendered_total",
|
||||
"Total documents rendered, tagged success."
|
||||
),
|
||||
pagesEmitted: createCounter(
|
||||
meter,
|
||||
"pdf_pages_emitted_total",
|
||||
"Total pages emitted across every rendered document."
|
||||
),
|
||||
renderErrors: createCounter(
|
||||
meter,
|
||||
"pdf_render_errors_total",
|
||||
"Total render errors partitioned by code."
|
||||
),
|
||||
validationFailures: createCounter(
|
||||
meter,
|
||||
"pdf_validation_failures_total",
|
||||
"Total Zod-boundary validation failures partitioned by code."
|
||||
),
|
||||
},
|
||||
histograms: {
|
||||
renderDurationMs: createHistogram(
|
||||
meter,
|
||||
"pdf_render_duration_ms",
|
||||
"Wall-clock duration of each renderDocument call.",
|
||||
"ms"
|
||||
),
|
||||
documentSizeBytes: createHistogram(
|
||||
meter,
|
||||
"pdf_document_size_bytes",
|
||||
"Size of each emitted document.",
|
||||
"By"
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Record a document-rendered event tagged with `success` (`"true"` / `"false"`). */
|
||||
export function recordDocumentRendered(
|
||||
obs: PdfObservability,
|
||||
args: { readonly success: boolean }
|
||||
): void {
|
||||
obs.counters.documentsRendered.add(1, {
|
||||
success: args.success ? "true" : "false",
|
||||
});
|
||||
}
|
||||
|
||||
/** Record the number of pages emitted by a single render call. */
|
||||
export function recordPagesEmitted(obs: PdfObservability, count: number): void {
|
||||
obs.counters.pagesEmitted.add(count);
|
||||
}
|
||||
|
||||
/** Record a render error tagged with the originating PDF code. */
|
||||
export function recordRenderError(
|
||||
obs: PdfObservability,
|
||||
args: { readonly code: string }
|
||||
): void {
|
||||
obs.counters.renderErrors.add(1, { code: args.code });
|
||||
}
|
||||
|
||||
/** Record a Zod-validation failure tagged with the originating PDF code. */
|
||||
export function recordValidationFailure(
|
||||
obs: PdfObservability,
|
||||
args: { readonly code: string }
|
||||
): void {
|
||||
obs.counters.validationFailures.add(1, { code: args.code });
|
||||
}
|
||||
|
||||
/** Record the wall-clock duration of a render call. */
|
||||
export function recordRenderDuration(
|
||||
obs: PdfObservability,
|
||||
durationMs: number
|
||||
): void {
|
||||
obs.histograms.renderDurationMs.record(durationMs);
|
||||
}
|
||||
|
||||
/** Record the size of an emitted document in bytes. */
|
||||
export function recordDocumentSize(obs: PdfObservability, bytes: number): void {
|
||||
obs.histograms.documentSizeBytes.record(bytes);
|
||||
}
|
||||
41
packages/devtools/docs/pdf/src/telemetry/telemetry.types.ts
Normal file
41
packages/devtools/docs/pdf/src/telemetry/telemetry.types.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Counter, Histogram, Meter, Tracer } from "@opentelemetry/api";
|
||||
|
||||
export type { Counter, Histogram, Meter, Tracer };
|
||||
|
||||
/** Counters emitted by `@suaveplan/pdf`. */
|
||||
export interface PdfCounters {
|
||||
/** Total documents rendered, tagged `success` (`"true"` / `"false"`). */
|
||||
readonly documentsRendered: Counter;
|
||||
/** Total pages emitted across every rendered document. */
|
||||
readonly pagesEmitted: Counter;
|
||||
/** Total render errors partitioned by `code`. */
|
||||
readonly renderErrors: Counter;
|
||||
/** Total Zod-boundary validation failures partitioned by `code`. */
|
||||
readonly validationFailures: Counter;
|
||||
}
|
||||
|
||||
/** Histograms emitted by `@suaveplan/pdf`. */
|
||||
export interface PdfHistograms {
|
||||
/** Wall-clock duration of each `renderDocument` call (ms). */
|
||||
readonly renderDurationMs: Histogram;
|
||||
/** Size of each emitted document (bytes). */
|
||||
readonly documentSizeBytes: Histogram;
|
||||
}
|
||||
|
||||
/** Bundle of meter + tracer + counters + histograms. */
|
||||
export interface PdfObservability {
|
||||
readonly meter: Meter;
|
||||
readonly tracer: Tracer;
|
||||
readonly counters: PdfCounters;
|
||||
readonly histograms: PdfHistograms;
|
||||
}
|
||||
|
||||
/** Options accepted by `createPdfObservability`. */
|
||||
export interface PdfObservabilityOptions {
|
||||
/** Pre-built bundle — if supplied, skips meter / tracer construction. */
|
||||
observability?: PdfObservability;
|
||||
/** Explicit meter; defaults to `metrics.getMeter("@suaveplan/pdf")`. */
|
||||
meter?: Meter;
|
||||
/** Explicit tracer; defaults to `trace.getTracer("@suaveplan/pdf")`. */
|
||||
tracer?: Tracer;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export type { InvoiceTotals } from "./invoice-template.js";
|
||||
export {
|
||||
computeInvoiceTotals,
|
||||
createInvoiceTemplate,
|
||||
formatInvoiceAmount,
|
||||
InvoiceDataSchema,
|
||||
} from "./invoice-template.js";
|
||||
export type {
|
||||
InvoiceAddress,
|
||||
InvoiceData,
|
||||
InvoiceDocument,
|
||||
InvoiceLineItem,
|
||||
InvoiceTemplateOptions,
|
||||
} from "./invoice-template.types.js";
|
||||
@@ -0,0 +1,142 @@
|
||||
# `invoice-template`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`createInvoiceTemplate(data, options?)` converts a typed `InvoiceData`
|
||||
payload into a renderer-ready `Document` for professional A4 invoices.
|
||||
The factory eagerly validates the payload through `InvoiceDataSchema`,
|
||||
then walks every layout decision (the `INVOICE` headline, invoice
|
||||
number, `From` / `Bill To` blocks, issue + due dates, line-item table,
|
||||
totals ladder, and optional notes) via the chunk D `DocumentBuilder`,
|
||||
producing a `Document<InvoiceData>` that `renderDocument` flattens to
|
||||
PDF 1.4 bytes.
|
||||
|
||||
## Features
|
||||
|
||||
- Eager Zod validation with `PdfError.INVALID_TEMPLATE_INPUT`
|
||||
- Subtotal / tax / discount / total aggregation via
|
||||
`computeInvoiceTotals` (tax and discount rates are per-item
|
||||
percentages applied to that item's line total)
|
||||
- Configurable tax label (`data.taxLabel`) defaulting to `"Tax"`
|
||||
- Currency symbol formatting for USD, EUR, GBP, JPY, AUD, CAD; unknown
|
||||
codes fall back to a prefixed code (e.g. `"CHF 12.00"`)
|
||||
- A4 page size default, overridable via `options.page`
|
||||
- Optional repeating header / footer slots (`header` + `headerHeight`)
|
||||
- Theme cascade — overrides `DEFAULT_THEME` colour palette; the
|
||||
template colours match the upstream invoice visual layout
|
||||
- Optional notes block rendered after a divider
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createInvoiceTemplate, renderDocument } from "@suaveplan/pdf";
|
||||
|
||||
const { bytes } = renderDocument(
|
||||
createInvoiceTemplate({
|
||||
invoiceNumber: "INV-1",
|
||||
issueDate: new Date(),
|
||||
dueDate: new Date(),
|
||||
from: {
|
||||
name: "Suaveplan Ltd",
|
||||
line1: "1 Atlas Way",
|
||||
city: "London",
|
||||
postalCode: "EC1A 1AA",
|
||||
country: "UK",
|
||||
},
|
||||
to: {
|
||||
name: "Acme Co",
|
||||
line1: "100 Market St",
|
||||
city: "San Francisco",
|
||||
state: "CA",
|
||||
postalCode: "94103",
|
||||
country: "USA",
|
||||
},
|
||||
items: [
|
||||
{ description: "Consulting", quantity: 10, unitPrice: 200 },
|
||||
],
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
createInvoiceTemplate,
|
||||
DEFAULT_THEME,
|
||||
renderDocument,
|
||||
TextElement,
|
||||
} from "@suaveplan/pdf";
|
||||
|
||||
const headerNode = {
|
||||
type: "text" as const,
|
||||
element: new TextElement({ content: "SUAVEPLAN LTD" }),
|
||||
};
|
||||
|
||||
const doc = createInvoiceTemplate(
|
||||
{
|
||||
invoiceNumber: "INV-42",
|
||||
issueDate: new Date("2026-05-01"),
|
||||
dueDate: new Date("2026-06-01"),
|
||||
from: {
|
||||
name: "Suaveplan Ltd",
|
||||
line1: "1 Atlas Way",
|
||||
city: "London",
|
||||
postalCode: "EC1A 1AA",
|
||||
country: "UK",
|
||||
},
|
||||
to: {
|
||||
name: "Acme Co",
|
||||
line1: "100 Market St",
|
||||
city: "San Francisco",
|
||||
state: "CA",
|
||||
postalCode: "94103",
|
||||
country: "USA",
|
||||
},
|
||||
items: [
|
||||
{ description: "Service", quantity: 1, unitPrice: 100, taxRate: 20 },
|
||||
],
|
||||
currency: "EUR",
|
||||
taxLabel: "VAT",
|
||||
notes: "Pay-by-the-due-date.",
|
||||
},
|
||||
{
|
||||
theme: { ...DEFAULT_THEME, name: "brand" },
|
||||
header: [headerNode],
|
||||
headerHeight: 28,
|
||||
}
|
||||
);
|
||||
|
||||
const { bytes, pageCount } = renderDocument(doc);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createInvoiceTemplate(data, options?) => Document<InvoiceData>` —
|
||||
main factory.
|
||||
- `InvoiceDataSchema` — Zod schema for the input payload.
|
||||
- `computeInvoiceTotals(items)` — pure helper returning
|
||||
`{ subtotal, tax, discount, total }`.
|
||||
- `formatInvoiceAmount(amount, currency)` — symbol-prefixed two-decimal
|
||||
string.
|
||||
- `InvoiceData`, `InvoiceAddress`, `InvoiceLineItem`,
|
||||
`InvoiceTemplateOptions`, `InvoiceDocument`, `InvoiceTotals` — public
|
||||
types.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The factory does not run `renderDocument` itself — it returns a
|
||||
`Document<InvoiceData>` so callers control telemetry, abort signals,
|
||||
and clock injection. Invalid payloads surface as `PdfError` with the
|
||||
failing issue paths in `context.path`, enabling structured logging
|
||||
downstream. The line-item table uses fixed column widths (`250`, `60`,
|
||||
`100`, `100`) to fit the A4 default; supplying `options.page` with
|
||||
`LETTER` keeps the layout visually consistent because the table
|
||||
element preserves explicit column widths under auto-distribution. The
|
||||
totals ladder always emits `Subtotal` and `Total`; the `Tax` and
|
||||
`Discount` rows appear only when their aggregates are non-zero, so
|
||||
zero-tax invoices stay visually clean.
|
||||
@@ -0,0 +1,312 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PDFParse } from "pdf-parse";
|
||||
import type { Document } from "../../document-builder/document-builder.types.js";
|
||||
import { PDF_CODES, PdfError } from "../../error-codes/error-codes.js";
|
||||
import { renderDocument } from "../../renderer/renderer.js";
|
||||
import { DEFAULT_THEME } from "../../style-resolver/style-resolver.js";
|
||||
import { TextElement } from "../../text-element/text-element.js";
|
||||
import { PageSize } from "../../types/page-size.js";
|
||||
import {
|
||||
computeInvoiceTotals,
|
||||
createInvoiceTemplate,
|
||||
formatInvoiceAmount,
|
||||
InvoiceDataSchema,
|
||||
} from "./invoice-template.js";
|
||||
import type { InvoiceData } from "./invoice-template.types.js";
|
||||
|
||||
const ISSUE_DATE = new Date("2026-05-01T00:00:00Z");
|
||||
const DUE_DATE = new Date("2026-06-01T00:00:00Z");
|
||||
|
||||
const BASE_DATA: InvoiceData = {
|
||||
invoiceNumber: "INV-1001",
|
||||
issueDate: ISSUE_DATE,
|
||||
dueDate: DUE_DATE,
|
||||
from: {
|
||||
name: "Suaveplan Ltd",
|
||||
line1: "1 Atlas Way",
|
||||
city: "London",
|
||||
postalCode: "EC1A 1AA",
|
||||
country: "UK",
|
||||
},
|
||||
to: {
|
||||
name: "Acme Co",
|
||||
line1: "100 Market St",
|
||||
city: "San Francisco",
|
||||
state: "CA",
|
||||
postalCode: "94103",
|
||||
country: "USA",
|
||||
},
|
||||
items: [
|
||||
{ description: "Consulting", quantity: 10, unitPrice: 200 },
|
||||
{ description: "Travel", quantity: 1, unitPrice: 500 },
|
||||
],
|
||||
};
|
||||
|
||||
function collectText(doc: Document<InvoiceData>): string {
|
||||
const parts: string[] = [];
|
||||
for (const page of doc.pages) {
|
||||
for (const node of page.nodes) {
|
||||
if (node.type === "text") {
|
||||
parts.push(node.element.options.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
describe("InvoiceDataSchema", () => {
|
||||
it("accepts a minimum valid payload", { timeout: 5000 }, () => {
|
||||
const parsed = InvoiceDataSchema.safeParse(BASE_DATA);
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects empty invoice number", { timeout: 5000 }, () => {
|
||||
const parsed = InvoiceDataSchema.safeParse({
|
||||
...BASE_DATA,
|
||||
invoiceNumber: "",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects empty items", { timeout: 5000 }, () => {
|
||||
const parsed = InvoiceDataSchema.safeParse({
|
||||
...BASE_DATA,
|
||||
items: [],
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeInvoiceTotals", () => {
|
||||
it("aggregates subtotal across items", { timeout: 5000 }, () => {
|
||||
const totals = computeInvoiceTotals(BASE_DATA.items);
|
||||
expect(totals.subtotal).toBeCloseTo(10 * 200 + 500, 5);
|
||||
expect(totals.tax).toBe(0);
|
||||
expect(totals.discount).toBe(0);
|
||||
expect(totals.total).toBeCloseTo(totals.subtotal, 5);
|
||||
});
|
||||
|
||||
it("applies tax + discount percentages per item", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const totals = computeInvoiceTotals([
|
||||
{
|
||||
description: "X",
|
||||
quantity: 1,
|
||||
unitPrice: 100,
|
||||
taxRate: 20,
|
||||
discount: 5,
|
||||
},
|
||||
]);
|
||||
expect(totals.tax).toBeCloseTo(20, 5);
|
||||
expect(totals.discount).toBeCloseTo(5, 5);
|
||||
expect(totals.total).toBeCloseTo(115, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatInvoiceAmount", () => {
|
||||
it("uses the known symbol for USD", { timeout: 5000 }, () => {
|
||||
expect(formatInvoiceAmount(12, "USD")).toBe("$12.00");
|
||||
});
|
||||
|
||||
it("falls back to the currency code prefix when unknown", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(formatInvoiceAmount(12, "CHF")).toBe("CHF 12.00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createInvoiceTemplate — input validation", () => {
|
||||
it("raises INVALID_TEMPLATE_INPUT on missing from name", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
createInvoiceTemplate({
|
||||
...BASE_DATA,
|
||||
from: { ...BASE_DATA.from, name: "" },
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(PdfError);
|
||||
expect((err as PdfError).code).toBe(
|
||||
PDF_CODES.INVALID_TEMPLATE_INPUT
|
||||
);
|
||||
const ctx = (err as PdfError).context as { path?: unknown };
|
||||
expect(ctx.path).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("raises INVALID_TEMPLATE_INPUT when items is empty", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
createInvoiceTemplate({ ...BASE_DATA, items: [] })
|
||||
).toThrow(PdfError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createInvoiceTemplate — Document assembly", () => {
|
||||
it("returns a Document with from-name author + invoice title", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = createInvoiceTemplate(BASE_DATA);
|
||||
expect(doc.metadata.title).toBe("Invoice INV-1001");
|
||||
expect(doc.metadata.author).toBe("Suaveplan Ltd");
|
||||
expect(doc.data).toEqual(BASE_DATA);
|
||||
});
|
||||
|
||||
it("renders the INVOICE headline + From/Bill To blocks", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = createInvoiceTemplate(BASE_DATA);
|
||||
const haystack = collectText(doc);
|
||||
expect(haystack).toContain("INVOICE");
|
||||
expect(haystack).toContain("#INV-1001");
|
||||
expect(haystack).toContain("From:");
|
||||
expect(haystack).toContain("Bill To:");
|
||||
expect(haystack).toContain("Suaveplan Ltd");
|
||||
expect(haystack).toContain("Acme Co");
|
||||
expect(haystack).toContain("Issue Date: 2026-05-01");
|
||||
expect(haystack).toContain("Due Date: 2026-06-01");
|
||||
});
|
||||
|
||||
it("emits tax + discount lines (with custom tax label) when present", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = createInvoiceTemplate({
|
||||
...BASE_DATA,
|
||||
items: [
|
||||
{
|
||||
description: "Service",
|
||||
quantity: 1,
|
||||
unitPrice: 100,
|
||||
taxRate: 20,
|
||||
discount: 5,
|
||||
},
|
||||
],
|
||||
taxLabel: "VAT",
|
||||
});
|
||||
const haystack = collectText(doc);
|
||||
expect(haystack).toContain("Subtotal: $100.00");
|
||||
expect(haystack).toContain("VAT: $20.00");
|
||||
expect(haystack).toContain("Discount: -$5.00");
|
||||
expect(haystack).toContain("Total: $115.00");
|
||||
});
|
||||
|
||||
it("falls back to 'Tax' label when none supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = createInvoiceTemplate({
|
||||
...BASE_DATA,
|
||||
items: [
|
||||
{
|
||||
description: "Service",
|
||||
quantity: 1,
|
||||
unitPrice: 100,
|
||||
taxRate: 10,
|
||||
},
|
||||
],
|
||||
});
|
||||
const haystack = collectText(doc);
|
||||
expect(haystack).toContain("Tax: $10.00");
|
||||
});
|
||||
|
||||
it("attaches a Notes block when provided", { timeout: 5000 }, () => {
|
||||
const doc = createInvoiceTemplate({
|
||||
...BASE_DATA,
|
||||
notes: "Pay-by-the-due-date.",
|
||||
});
|
||||
const haystack = collectText(doc);
|
||||
expect(haystack).toContain("Notes:");
|
||||
expect(haystack).toContain("Pay-by-the-due-date.");
|
||||
});
|
||||
|
||||
it("respects payload currency over options currency", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = createInvoiceTemplate(
|
||||
{ ...BASE_DATA, currency: "EUR" },
|
||||
{ currency: "GBP" }
|
||||
);
|
||||
const haystack = collectText(doc);
|
||||
expect(haystack).toContain("€");
|
||||
expect(haystack).not.toContain("£");
|
||||
});
|
||||
|
||||
it("falls back to options currency when payload omits it", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = createInvoiceTemplate(BASE_DATA, { currency: "GBP" });
|
||||
const haystack = collectText(doc);
|
||||
expect(haystack).toContain("£");
|
||||
});
|
||||
|
||||
it("formats the to-address city line without state when omitted", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = createInvoiceTemplate({
|
||||
...BASE_DATA,
|
||||
to: {
|
||||
...BASE_DATA.to,
|
||||
state: undefined,
|
||||
line2: "Floor 5",
|
||||
},
|
||||
});
|
||||
const haystack = collectText(doc);
|
||||
expect(haystack).toContain("Floor 5");
|
||||
expect(haystack).toContain("San Francisco 94103");
|
||||
});
|
||||
|
||||
it("accepts theme + page + header/footer overrides", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const headerNode = {
|
||||
type: "text" as const,
|
||||
element: new TextElement({ content: "INVOICE-HEADER" }),
|
||||
};
|
||||
const footerNode = {
|
||||
type: "text" as const,
|
||||
element: new TextElement({ content: "INVOICE-FOOTER" }),
|
||||
};
|
||||
const doc = createInvoiceTemplate(BASE_DATA, {
|
||||
theme: { ...DEFAULT_THEME, name: "alt" },
|
||||
page: { size: PageSize.LETTER },
|
||||
header: [headerNode],
|
||||
headerHeight: 30,
|
||||
footer: [footerNode],
|
||||
footerHeight: 20,
|
||||
});
|
||||
expect(doc.defaultPage.size).toBe(PageSize.LETTER);
|
||||
expect(doc.headerHeight).toBe(30);
|
||||
expect(doc.footerHeight).toBe(20);
|
||||
expect(doc.header?.length).toBe(1);
|
||||
expect(doc.footer?.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createInvoiceTemplate — PDF round-trip", () => {
|
||||
it("renders a real PDF round-trippable via pdf-parse", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const doc = createInvoiceTemplate({
|
||||
...BASE_DATA,
|
||||
notes: "Bank-details-on-file.",
|
||||
});
|
||||
const result = renderDocument(doc);
|
||||
expect(result.bytes).toBeInstanceOf(Uint8Array);
|
||||
expect(result.pageCount).toBeGreaterThanOrEqual(1);
|
||||
const parser = new PDFParse({ data: result.bytes });
|
||||
try {
|
||||
const info = await parser.getInfo();
|
||||
const text = await parser.getText();
|
||||
expect(info.info.Title).toBe("Invoice INV-1001");
|
||||
expect(info.info.Author).toBe("Suaveplan Ltd");
|
||||
expect(text.text).toContain("INVOICE");
|
||||
expect(text.text).toContain("Consulting");
|
||||
expect(text.text).toContain("Travel");
|
||||
expect(text.text).toContain("Bank-details-on-file.");
|
||||
} finally {
|
||||
await parser.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import z from "zod";
|
||||
import { createDocument } from "../../document-builder/document-builder.js";
|
||||
import { PDF_CODES, PdfError } from "../../error-codes/error-codes.js";
|
||||
import { DEFAULT_THEME } from "../../style-resolver/style-resolver.js";
|
||||
import type { PdfTheme } from "../../style-resolver/style-resolver.types.js";
|
||||
import type { TableColumn } from "../../table-element/table-element.types.js";
|
||||
import type {
|
||||
InvoiceAddress,
|
||||
InvoiceData,
|
||||
InvoiceDocument,
|
||||
InvoiceLineItem,
|
||||
InvoiceTemplateOptions,
|
||||
} from "./invoice-template.types.js";
|
||||
|
||||
const AddressSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
line1: z.string().min(1),
|
||||
line2: z.string().optional(),
|
||||
city: z.string().min(1),
|
||||
state: z.string().optional(),
|
||||
postalCode: z.string().min(1),
|
||||
country: z.string().min(1),
|
||||
});
|
||||
|
||||
/** Zod schema for {@link InvoiceData} payloads. */
|
||||
export const InvoiceDataSchema = z.object({
|
||||
invoiceNumber: z.string().min(1),
|
||||
issueDate: z.date(),
|
||||
dueDate: z.date(),
|
||||
from: AddressSchema,
|
||||
to: AddressSchema,
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
description: z.string().min(1),
|
||||
quantity: z.number().finite().nonnegative(),
|
||||
unitPrice: z.number().finite().nonnegative(),
|
||||
taxRate: z.number().finite().nonnegative().optional(),
|
||||
discount: z.number().finite().nonnegative().optional(),
|
||||
})
|
||||
)
|
||||
.min(1),
|
||||
currency: z.string().min(1).optional(),
|
||||
taxLabel: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
/** Row type for the invoice line-item table. */
|
||||
interface InvoiceRow {
|
||||
readonly description: string;
|
||||
readonly quantity: string;
|
||||
readonly unitPrice: string;
|
||||
readonly total: string;
|
||||
}
|
||||
|
||||
/** Aggregate totals derived from an invoice's line items. */
|
||||
export interface InvoiceTotals {
|
||||
readonly subtotal: number;
|
||||
readonly tax: number;
|
||||
readonly discount: number;
|
||||
readonly total: number;
|
||||
}
|
||||
|
||||
/** Compute subtotal / tax / discount / total from a set of line items. */
|
||||
export function computeInvoiceTotals(
|
||||
items: ReadonlyArray<InvoiceLineItem>
|
||||
): InvoiceTotals {
|
||||
let subtotal = 0;
|
||||
let tax = 0;
|
||||
let discount = 0;
|
||||
for (const item of items) {
|
||||
const lineTotal = item.quantity * item.unitPrice;
|
||||
subtotal += lineTotal;
|
||||
if (item.taxRate !== undefined) {
|
||||
tax += lineTotal * (item.taxRate / 100);
|
||||
}
|
||||
if (item.discount !== undefined) {
|
||||
discount += lineTotal * (item.discount / 100);
|
||||
}
|
||||
}
|
||||
return { subtotal, tax, discount, total: subtotal + tax - discount };
|
||||
}
|
||||
|
||||
const CURRENCY_SYMBOLS: Record<string, string> = {
|
||||
USD: "$",
|
||||
EUR: "€",
|
||||
GBP: "£",
|
||||
JPY: "¥",
|
||||
AUD: "A$",
|
||||
CAD: "C$",
|
||||
};
|
||||
|
||||
/** Format `amount` using the supplied ISO currency code. */
|
||||
export function formatInvoiceAmount(amount: number, currency: string): string {
|
||||
const symbol = CURRENCY_SYMBOLS[currency] ?? `${currency} `;
|
||||
return `${symbol}${amount.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatAddress(addr: InvoiceAddress): string {
|
||||
const parts = [addr.name, addr.line1];
|
||||
if (addr.line2) parts.push(addr.line2);
|
||||
const cityLine = addr.state
|
||||
? `${addr.city}, ${addr.state} ${addr.postalCode}`
|
||||
: `${addr.city} ${addr.postalCode}`;
|
||||
parts.push(cityLine);
|
||||
parts.push(addr.country);
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function formatInvoiceDate(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a professional invoice PDF from a typed {@link InvoiceData}
|
||||
* payload. Includes the `INVOICE` headline, invoice number, the
|
||||
* `From` / `Bill To` blocks, issue + due dates, a line-item table,
|
||||
* and the totals ladder (subtotal, optional tax, optional discount,
|
||||
* total).
|
||||
*
|
||||
* @throws `PdfError(PDF.INVALID_TEMPLATE_INPUT)` when the payload fails
|
||||
* {@link InvoiceDataSchema} validation.
|
||||
*/
|
||||
export function createInvoiceTemplate(
|
||||
data: InvoiceData,
|
||||
options?: InvoiceTemplateOptions
|
||||
): InvoiceDocument {
|
||||
const parsed = InvoiceDataSchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
const issues = parsed.error.issues;
|
||||
throw new PdfError(
|
||||
PDF_CODES.INVALID_TEMPLATE_INPUT,
|
||||
`invoice template input invalid: ${parsed.error.message}`,
|
||||
{
|
||||
cause: parsed.error,
|
||||
context: { path: issues.map((issue) => issue.path) },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const theme: PdfTheme = options?.theme ?? DEFAULT_THEME;
|
||||
const currency = data.currency ?? options?.currency ?? "USD";
|
||||
const totals = computeInvoiceTotals(data.items);
|
||||
|
||||
const builder = createDocument<InvoiceData>()
|
||||
.title(`Invoice ${data.invoiceNumber}`)
|
||||
.author(data.from.name)
|
||||
.subject("Invoice")
|
||||
.creator("@suaveplan/pdf invoice-template")
|
||||
.withData(data);
|
||||
if (options?.page) builder.pageConfig(options.page);
|
||||
if (options?.header && options.headerHeight !== undefined) {
|
||||
builder.setHeader(options.header, options.headerHeight);
|
||||
}
|
||||
if (options?.footer && options.footerHeight !== undefined) {
|
||||
builder.setFooter(options.footer, options.footerHeight);
|
||||
}
|
||||
|
||||
builder.addText("INVOICE", {
|
||||
fontSize: 28,
|
||||
fontWeight: "bold",
|
||||
color: theme.colors.primary,
|
||||
});
|
||||
builder.addText(`#${data.invoiceNumber}`, {
|
||||
fontSize: 12,
|
||||
color: theme.colors.textLight,
|
||||
});
|
||||
builder.addDivider({ color: theme.colors.border });
|
||||
|
||||
builder.addText("From:", {
|
||||
fontSize: 10,
|
||||
fontWeight: "bold",
|
||||
color: theme.colors.textLight,
|
||||
});
|
||||
builder.addText(formatAddress(data.from), { fontSize: 10 });
|
||||
builder.addText("Bill To:", {
|
||||
fontSize: 10,
|
||||
fontWeight: "bold",
|
||||
color: theme.colors.textLight,
|
||||
});
|
||||
builder.addText(formatAddress(data.to), { fontSize: 10 });
|
||||
|
||||
builder.addText(`Issue Date: ${formatInvoiceDate(data.issueDate)}`, {
|
||||
fontSize: 10,
|
||||
});
|
||||
builder.addText(`Due Date: ${formatInvoiceDate(data.dueDate)}`, {
|
||||
fontSize: 10,
|
||||
});
|
||||
builder.addDivider({ color: theme.colors.border });
|
||||
|
||||
const columns: ReadonlyArray<TableColumn<InvoiceRow>> = [
|
||||
{ key: "description", header: "Description", width: 250 },
|
||||
{ key: "quantity", header: "Qty", width: 60, align: "center" },
|
||||
{ key: "unitPrice", header: "Unit Price", width: 100, align: "right" },
|
||||
{ key: "total", header: "Total", width: 100, align: "right" },
|
||||
];
|
||||
const rows: ReadonlyArray<InvoiceRow> = data.items.map((item) => ({
|
||||
description: item.description,
|
||||
quantity: item.quantity.toString(),
|
||||
unitPrice: formatInvoiceAmount(item.unitPrice, currency),
|
||||
total: formatInvoiceAmount(item.quantity * item.unitPrice, currency),
|
||||
}));
|
||||
|
||||
builder.addTable<InvoiceRow>(columns, rows, {
|
||||
headerBackground: theme.colors.primary,
|
||||
headerColor: "#ffffff",
|
||||
alternateRowBackground: "#f9f9f9",
|
||||
});
|
||||
|
||||
builder.addDivider({ color: theme.colors.border });
|
||||
builder.addText(
|
||||
`Subtotal: ${formatInvoiceAmount(totals.subtotal, currency)}`,
|
||||
{ fontSize: 10, align: "right" }
|
||||
);
|
||||
if (totals.tax > 0) {
|
||||
builder.addText(
|
||||
`${data.taxLabel ?? "Tax"}: ${formatInvoiceAmount(totals.tax, currency)}`,
|
||||
{ fontSize: 10, align: "right" }
|
||||
);
|
||||
}
|
||||
if (totals.discount > 0) {
|
||||
builder.addText(
|
||||
`Discount: -${formatInvoiceAmount(totals.discount, currency)}`,
|
||||
{ fontSize: 10, align: "right" }
|
||||
);
|
||||
}
|
||||
builder.addText(`Total: ${formatInvoiceAmount(totals.total, currency)}`, {
|
||||
fontSize: 14,
|
||||
fontWeight: "bold",
|
||||
align: "right",
|
||||
color: theme.colors.primary,
|
||||
});
|
||||
|
||||
if (data.notes) {
|
||||
builder.addDivider({ color: theme.colors.border });
|
||||
builder.addText("Notes:", { fontSize: 10, fontWeight: "bold" });
|
||||
builder.addText(data.notes, {
|
||||
fontSize: 9,
|
||||
color: theme.colors.textLight,
|
||||
});
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Public types for the {@link createInvoiceTemplate} factory.
|
||||
*
|
||||
* Invoice templates produce professional A4 invoices with `From` /
|
||||
* `Bill To` blocks, line-item table, subtotal / tax / discount /
|
||||
* total ladder, and optional notes. The shape mirrors the upstream
|
||||
* `@suaveplan/pdf` `InvoiceData` so existing consumers can flip the
|
||||
* import path without changing their payloads.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Document,
|
||||
DocumentNode,
|
||||
} from "../../document-builder/document-builder.types.js";
|
||||
import type { PdfTheme } from "../../style-resolver/style-resolver.types.js";
|
||||
import type { PageConfig } from "../../types/page-size.js";
|
||||
|
||||
/** Postal address used for the `From` / `Bill To` blocks. */
|
||||
export interface InvoiceAddress {
|
||||
readonly name: string;
|
||||
readonly line1: string;
|
||||
readonly line2?: string;
|
||||
readonly city: string;
|
||||
readonly state?: string;
|
||||
readonly postalCode: string;
|
||||
readonly country: string;
|
||||
}
|
||||
|
||||
/** Single line item on an invoice. */
|
||||
export interface InvoiceLineItem {
|
||||
readonly description: string;
|
||||
readonly quantity: number;
|
||||
readonly unitPrice: number;
|
||||
readonly taxRate?: number;
|
||||
readonly discount?: number;
|
||||
}
|
||||
|
||||
/** Invoice payload — everything the template needs to build a PDF. */
|
||||
export interface InvoiceData {
|
||||
readonly invoiceNumber: string;
|
||||
readonly issueDate: Date;
|
||||
readonly dueDate: Date;
|
||||
readonly from: InvoiceAddress;
|
||||
readonly to: InvoiceAddress;
|
||||
readonly items: ReadonlyArray<InvoiceLineItem>;
|
||||
readonly currency?: string;
|
||||
readonly taxLabel?: string;
|
||||
readonly notes?: string;
|
||||
}
|
||||
|
||||
/** Options accepted by {@link createInvoiceTemplate}. */
|
||||
export interface InvoiceTemplateOptions {
|
||||
/** Theme override — defaults to `DEFAULT_THEME`. */
|
||||
readonly theme?: PdfTheme;
|
||||
/** Per-page config override — defaults to A4 portrait. */
|
||||
readonly page?: Partial<PageConfig>;
|
||||
/** Fallback currency code when `data.currency` is omitted. */
|
||||
readonly currency?: string;
|
||||
/** Optional repeating header nodes printed on every emitted page. */
|
||||
readonly header?: ReadonlyArray<DocumentNode>;
|
||||
/** Header height in points — required when `header` is supplied. */
|
||||
readonly headerHeight?: number;
|
||||
/** Optional repeating footer nodes printed on every emitted page. */
|
||||
readonly footer?: ReadonlyArray<DocumentNode>;
|
||||
/** Footer height in points — required when `footer` is supplied. */
|
||||
readonly footerHeight?: number;
|
||||
}
|
||||
|
||||
/** Type alias the factory returns — Document with the invoice payload. */
|
||||
export type InvoiceDocument = Document<InvoiceData>;
|
||||
@@ -0,0 +1,14 @@
|
||||
export type { ReceiptTotals } from "./receipt-template.js";
|
||||
export {
|
||||
computeReceiptTotals,
|
||||
createReceiptTemplate,
|
||||
formatReceiptAmount,
|
||||
ReceiptDataSchema,
|
||||
} from "./receipt-template.js";
|
||||
export type {
|
||||
ReceiptData,
|
||||
ReceiptDocument,
|
||||
ReceiptLineItem,
|
||||
ReceiptMerchant,
|
||||
ReceiptTemplateOptions,
|
||||
} from "./receipt-template.types.js";
|
||||
@@ -0,0 +1,122 @@
|
||||
# `receipt-template`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`createReceiptTemplate(data, options?)` converts a typed `ReceiptData`
|
||||
payload into a renderer-ready `Document` for compact A5 receipts that
|
||||
mirror the layout of point-of-sale printers and emailed receipts. The
|
||||
factory eagerly validates the payload through `ReceiptDataSchema`, then
|
||||
walks every layout decision (merchant block, receipt number + date,
|
||||
line-item table, totals ladder, optional notes + payment-method
|
||||
sentinels, and the closing message) via the chunk D `DocumentBuilder`,
|
||||
producing a `Document<ReceiptData>` that `renderDocument` flattens to
|
||||
PDF 1.4 bytes.
|
||||
|
||||
## Features
|
||||
|
||||
- Eager Zod validation at the boundary with `PdfError.INVALID_TEMPLATE_INPUT`
|
||||
- Subtotal / tax / discount / total aggregation via `computeReceiptTotals`
|
||||
- Currency symbol formatting for USD, EUR, GBP, JPY, AUD, CAD; unknown
|
||||
codes fall back to a prefixed code (e.g. `"CHF 12.00"`)
|
||||
- A5 page size default with a 36-point margin block, overridable via
|
||||
`options.page`
|
||||
- Optional repeating header / footer slots (`header` + `headerHeight`)
|
||||
- Theme cascade — overrides `DEFAULT_THEME` colour palette without
|
||||
re-defining the entire theme
|
||||
- Closing message customisable via `data.footerMessage`; falls back to
|
||||
`"Thank you!"`
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createReceiptTemplate } from "@suaveplan/pdf";
|
||||
import { renderDocument } from "@suaveplan/pdf";
|
||||
|
||||
const { bytes } = renderDocument(
|
||||
createReceiptTemplate({
|
||||
receiptNumber: "RCPT-1",
|
||||
date: new Date(),
|
||||
merchant: {
|
||||
name: "Suaveplan Cafe",
|
||||
line1: "1 Bean Lane",
|
||||
city: "Pourtown",
|
||||
state: "CA",
|
||||
postalCode: "90210",
|
||||
},
|
||||
items: [
|
||||
{ description: "Espresso", quantity: 2, unitPrice: 3.5 },
|
||||
],
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createReceiptTemplate, DEFAULT_THEME } from "@suaveplan/pdf";
|
||||
import { renderDocument, TextElement } from "@suaveplan/pdf";
|
||||
|
||||
const headerNode = {
|
||||
type: "text" as const,
|
||||
element: new TextElement({ content: "SUAVEPLAN CAFE" }),
|
||||
};
|
||||
|
||||
const doc = createReceiptTemplate(
|
||||
{
|
||||
receiptNumber: "RCPT-42",
|
||||
date: new Date(),
|
||||
merchant: {
|
||||
name: "Suaveplan Cafe",
|
||||
line1: "1 Bean Lane",
|
||||
city: "Pourtown",
|
||||
state: "CA",
|
||||
postalCode: "90210",
|
||||
},
|
||||
items: [
|
||||
{ description: "Latte", quantity: 1, unitPrice: 4.5, taxRate: 8.5 },
|
||||
],
|
||||
paymentMethod: "Visa **** 4242",
|
||||
notes: "Loyalty: 25 pts",
|
||||
currency: "EUR",
|
||||
footerMessage: "Come again!",
|
||||
},
|
||||
{
|
||||
theme: { ...DEFAULT_THEME, name: "merchant" },
|
||||
header: [headerNode],
|
||||
headerHeight: 28,
|
||||
}
|
||||
);
|
||||
|
||||
const { bytes, pageCount } = renderDocument(doc);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createReceiptTemplate(data, options?) => Document<ReceiptData>` — main
|
||||
factory.
|
||||
- `ReceiptDataSchema` — Zod schema for the input payload.
|
||||
- `computeReceiptTotals(items)` — pure helper returning
|
||||
`{ subtotal, tax, discount, total }`.
|
||||
- `formatReceiptAmount(amount, currency)` — symbol-prefixed two-decimal
|
||||
string.
|
||||
- `ReceiptData`, `ReceiptMerchant`, `ReceiptLineItem`,
|
||||
`ReceiptTemplateOptions`, `ReceiptDocument`, `ReceiptTotals` — public
|
||||
types.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The factory does not run `renderDocument` itself — it returns a
|
||||
`Document<ReceiptData>` so callers control telemetry, abort signals, and
|
||||
clock injection. Invalid payloads surface as `PdfError` with the failing
|
||||
issue paths in `context.path`, enabling structured logging downstream.
|
||||
Currency symbols are intentionally simple (no `Intl.NumberFormat`
|
||||
dependency) so server-tier renderers remain locale-free; consumers that
|
||||
need full ICU formatting should pre-format their values before passing
|
||||
them in. The line-item table uses fixed column widths (`160`, `50`,
|
||||
`80`) to fit the A5 default; supplying `options.page` with `LETTER` or
|
||||
`A4` keeps the table visually consistent because the table element's
|
||||
auto-distribution preserves the explicit widths.
|
||||
@@ -0,0 +1,273 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PDFParse } from "pdf-parse";
|
||||
import type { Document } from "../../document-builder/document-builder.types.js";
|
||||
import { PDF_CODES, PdfError } from "../../error-codes/error-codes.js";
|
||||
import { renderDocument } from "../../renderer/renderer.js";
|
||||
import { DEFAULT_THEME } from "../../style-resolver/style-resolver.js";
|
||||
import { TextElement } from "../../text-element/text-element.js";
|
||||
import { PageSize } from "../../types/page-size.js";
|
||||
import {
|
||||
computeReceiptTotals,
|
||||
createReceiptTemplate,
|
||||
formatReceiptAmount,
|
||||
ReceiptDataSchema,
|
||||
} from "./receipt-template.js";
|
||||
import type { ReceiptData } from "./receipt-template.types.js";
|
||||
|
||||
const FIXED_DATE = new Date("2026-05-25T10:30:00Z");
|
||||
|
||||
const BASE_DATA: ReceiptData = {
|
||||
receiptNumber: "RCPT-001",
|
||||
date: FIXED_DATE,
|
||||
merchant: {
|
||||
name: "Suaveplan Cafe",
|
||||
line1: "1 Bean Lane",
|
||||
city: "Pourtown",
|
||||
state: "CA",
|
||||
postalCode: "90210",
|
||||
},
|
||||
items: [
|
||||
{ description: "Espresso", quantity: 2, unitPrice: 3.5 },
|
||||
{ description: "Croissant", quantity: 1, unitPrice: 4.25 },
|
||||
],
|
||||
};
|
||||
|
||||
function collectText(doc: Document<ReceiptData>): string {
|
||||
const parts: string[] = [];
|
||||
for (const page of doc.pages) {
|
||||
for (const node of page.nodes) {
|
||||
if (node.type === "text") {
|
||||
parts.push(node.element.options.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
describe("ReceiptDataSchema", () => {
|
||||
it("accepts a minimum valid payload", { timeout: 5000 }, () => {
|
||||
const parsed = ReceiptDataSchema.safeParse(BASE_DATA);
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects empty items", { timeout: 5000 }, () => {
|
||||
const parsed = ReceiptDataSchema.safeParse({
|
||||
...BASE_DATA,
|
||||
items: [],
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeReceiptTotals", () => {
|
||||
it("aggregates subtotal across items", { timeout: 5000 }, () => {
|
||||
const totals = computeReceiptTotals(BASE_DATA.items);
|
||||
expect(totals.subtotal).toBeCloseTo(2 * 3.5 + 4.25, 5);
|
||||
expect(totals.tax).toBe(0);
|
||||
expect(totals.discount).toBe(0);
|
||||
expect(totals.total).toBeCloseTo(totals.subtotal, 5);
|
||||
});
|
||||
|
||||
it("applies tax + discount percentages per item", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const totals = computeReceiptTotals([
|
||||
{
|
||||
description: "X",
|
||||
quantity: 1,
|
||||
unitPrice: 100,
|
||||
taxRate: 10,
|
||||
discount: 5,
|
||||
},
|
||||
]);
|
||||
expect(totals.tax).toBeCloseTo(10, 5);
|
||||
expect(totals.discount).toBeCloseTo(5, 5);
|
||||
expect(totals.total).toBeCloseTo(105, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatReceiptAmount", () => {
|
||||
it("uses the known symbol for USD", { timeout: 5000 }, () => {
|
||||
expect(formatReceiptAmount(12, "USD")).toBe("$12.00");
|
||||
});
|
||||
|
||||
it("falls back to the currency code prefix when unknown", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(formatReceiptAmount(12, "CHF")).toBe("CHF 12.00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createReceiptTemplate — input validation", () => {
|
||||
it("raises INVALID_TEMPLATE_INPUT on empty receipt number", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
createReceiptTemplate({ ...BASE_DATA, receiptNumber: "" });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(PdfError);
|
||||
expect((err as PdfError).code).toBe(
|
||||
PDF_CODES.INVALID_TEMPLATE_INPUT
|
||||
);
|
||||
const ctx = (err as PdfError).context as { path?: unknown };
|
||||
expect(ctx.path).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("raises INVALID_TEMPLATE_INPUT when items is empty", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
createReceiptTemplate({ ...BASE_DATA, items: [] })
|
||||
).toThrow(PdfError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createReceiptTemplate — Document assembly", () => {
|
||||
it("returns a Document with merchant author and A5 page size", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = createReceiptTemplate(BASE_DATA);
|
||||
expect(doc.metadata.title).toBe("Receipt RCPT-001");
|
||||
expect(doc.metadata.author).toBe("Suaveplan Cafe");
|
||||
expect(doc.defaultPage.size).toBe(PageSize.A5);
|
||||
expect(doc.data).toEqual(BASE_DATA);
|
||||
});
|
||||
|
||||
it("includes notes + payment method when supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = createReceiptTemplate({
|
||||
...BASE_DATA,
|
||||
paymentMethod: "Visa **** 4242",
|
||||
notes: "Loyalty: 25 pts",
|
||||
});
|
||||
expect(doc.pages.length).toBe(1);
|
||||
const haystack = collectText(doc);
|
||||
expect(haystack).toContain("Paid via: Visa **** 4242");
|
||||
expect(haystack).toContain("Loyalty: 25 pts");
|
||||
});
|
||||
|
||||
it("emits tax + discount totals lines when present", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = createReceiptTemplate({
|
||||
...BASE_DATA,
|
||||
items: [
|
||||
{
|
||||
description: "Coffee",
|
||||
quantity: 1,
|
||||
unitPrice: 100,
|
||||
taxRate: 10,
|
||||
discount: 5,
|
||||
},
|
||||
],
|
||||
});
|
||||
const haystack = collectText(doc);
|
||||
expect(haystack).toContain("Tax: $10.00");
|
||||
expect(haystack).toContain("Discount: -$5.00");
|
||||
expect(haystack).toContain("Total: $105.00");
|
||||
});
|
||||
|
||||
it("uses the supplied theme override + footer message", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = createReceiptTemplate(
|
||||
{ ...BASE_DATA, footerMessage: "Come again!" },
|
||||
{
|
||||
theme: { ...DEFAULT_THEME, name: "test-theme" },
|
||||
currency: "EUR",
|
||||
}
|
||||
);
|
||||
const haystack = collectText(doc);
|
||||
expect(haystack).toContain("Come again!");
|
||||
expect(haystack).toContain("€");
|
||||
});
|
||||
|
||||
it("respects payload currency over options currency", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = createReceiptTemplate(
|
||||
{ ...BASE_DATA, currency: "GBP" },
|
||||
{ currency: "EUR" }
|
||||
);
|
||||
const haystack = collectText(doc);
|
||||
expect(haystack).toContain("£");
|
||||
expect(haystack).not.toContain("€");
|
||||
});
|
||||
|
||||
it("formats the city line without state when omitted", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = createReceiptTemplate({
|
||||
...BASE_DATA,
|
||||
merchant: {
|
||||
...BASE_DATA.merchant,
|
||||
state: undefined,
|
||||
line2: "Suite 200",
|
||||
country: "USA",
|
||||
},
|
||||
});
|
||||
const haystack = collectText(doc);
|
||||
expect(haystack).toContain("Suite 200");
|
||||
expect(haystack).toContain("Pourtown 90210");
|
||||
expect(haystack).toContain("USA");
|
||||
});
|
||||
|
||||
it("accepts custom header + footer slots", { timeout: 5000 }, () => {
|
||||
const headerNode = {
|
||||
type: "text" as const,
|
||||
element: new TextElement({ content: "RECEIPT-HEADER" }),
|
||||
};
|
||||
const footerNode = {
|
||||
type: "text" as const,
|
||||
element: new TextElement({ content: "RECEIPT-FOOTER" }),
|
||||
};
|
||||
const doc = createReceiptTemplate(BASE_DATA, {
|
||||
header: [headerNode],
|
||||
headerHeight: 30,
|
||||
footer: [footerNode],
|
||||
footerHeight: 20,
|
||||
});
|
||||
expect(doc.headerHeight).toBe(30);
|
||||
expect(doc.footerHeight).toBe(20);
|
||||
expect(doc.header?.length).toBe(1);
|
||||
expect(doc.footer?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("accepts a custom page config override", { timeout: 5000 }, () => {
|
||||
const doc = createReceiptTemplate(BASE_DATA, {
|
||||
page: { size: PageSize.A4 },
|
||||
});
|
||||
expect(doc.defaultPage.size).toBe(PageSize.A4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createReceiptTemplate — PDF round-trip", () => {
|
||||
it("renders a real PDF round-trippable via pdf-parse", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const doc = createReceiptTemplate({
|
||||
...BASE_DATA,
|
||||
notes: "Thank-you-note",
|
||||
});
|
||||
const result = renderDocument(doc);
|
||||
expect(result.bytes).toBeInstanceOf(Uint8Array);
|
||||
expect(result.pageCount).toBeGreaterThanOrEqual(1);
|
||||
const parser = new PDFParse({ data: result.bytes });
|
||||
try {
|
||||
const info = await parser.getInfo();
|
||||
const text = await parser.getText();
|
||||
expect(info.info.Title).toBe("Receipt RCPT-001");
|
||||
expect(info.info.Author).toBe("Suaveplan Cafe");
|
||||
expect(text.text).toContain("Receipt #RCPT-001");
|
||||
expect(text.text).toContain("Espresso");
|
||||
expect(text.text).toContain("Croissant");
|
||||
expect(text.text).toContain("Thank you!");
|
||||
expect(text.text).toContain("Thank-you-note");
|
||||
} finally {
|
||||
await parser.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import z from "zod";
|
||||
import { createDocument } from "../../document-builder/document-builder.js";
|
||||
import { PDF_CODES, PdfError } from "../../error-codes/error-codes.js";
|
||||
import { DEFAULT_THEME } from "../../style-resolver/style-resolver.js";
|
||||
import type { PdfTheme } from "../../style-resolver/style-resolver.types.js";
|
||||
import type { TableColumn } from "../../table-element/table-element.types.js";
|
||||
import { PageSize } from "../../types/page-size.js";
|
||||
import type {
|
||||
ReceiptData,
|
||||
ReceiptDocument,
|
||||
ReceiptLineItem,
|
||||
ReceiptMerchant,
|
||||
ReceiptTemplateOptions,
|
||||
} from "./receipt-template.types.js";
|
||||
|
||||
/**
|
||||
* Zod schema validating a {@link ReceiptData} payload at the template
|
||||
* boundary. Failures raise `PdfError(PDF.INVALID_TEMPLATE_INPUT)` with
|
||||
* the issue path attached as context.
|
||||
*/
|
||||
export const ReceiptDataSchema = z.object({
|
||||
receiptNumber: z.string().min(1),
|
||||
date: z.date(),
|
||||
merchant: z.object({
|
||||
name: z.string().min(1),
|
||||
line1: z.string().min(1),
|
||||
line2: z.string().optional(),
|
||||
city: z.string().min(1),
|
||||
state: z.string().optional(),
|
||||
postalCode: z.string().min(1),
|
||||
country: z.string().optional(),
|
||||
}),
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
description: z.string().min(1),
|
||||
quantity: z.number().finite().nonnegative(),
|
||||
unitPrice: z.number().finite().nonnegative(),
|
||||
taxRate: z.number().finite().nonnegative().optional(),
|
||||
discount: z.number().finite().nonnegative().optional(),
|
||||
})
|
||||
)
|
||||
.min(1),
|
||||
currency: z.string().min(1).optional(),
|
||||
paymentMethod: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
footerMessage: z.string().optional(),
|
||||
});
|
||||
|
||||
/** Row type for the receipt line-item table. */
|
||||
interface ReceiptRow {
|
||||
readonly item: string;
|
||||
readonly qty: string;
|
||||
readonly price: string;
|
||||
}
|
||||
|
||||
/** Aggregate totals derived from a receipt's line items. */
|
||||
export interface ReceiptTotals {
|
||||
readonly subtotal: number;
|
||||
readonly tax: number;
|
||||
readonly discount: number;
|
||||
readonly total: number;
|
||||
}
|
||||
|
||||
/** Compute subtotal / tax / discount / total from a set of line items. */
|
||||
export function computeReceiptTotals(
|
||||
items: ReadonlyArray<ReceiptLineItem>
|
||||
): ReceiptTotals {
|
||||
let subtotal = 0;
|
||||
let tax = 0;
|
||||
let discount = 0;
|
||||
for (const item of items) {
|
||||
const lineTotal = item.quantity * item.unitPrice;
|
||||
subtotal += lineTotal;
|
||||
if (item.taxRate !== undefined) {
|
||||
tax += lineTotal * (item.taxRate / 100);
|
||||
}
|
||||
if (item.discount !== undefined) {
|
||||
discount += lineTotal * (item.discount / 100);
|
||||
}
|
||||
}
|
||||
return { subtotal, tax, discount, total: subtotal + tax - discount };
|
||||
}
|
||||
|
||||
const CURRENCY_SYMBOLS: Record<string, string> = {
|
||||
USD: "$",
|
||||
EUR: "€",
|
||||
GBP: "£",
|
||||
JPY: "¥",
|
||||
AUD: "A$",
|
||||
CAD: "C$",
|
||||
};
|
||||
|
||||
/** Format `amount` using the supplied ISO currency code. */
|
||||
export function formatReceiptAmount(amount: number, currency: string): string {
|
||||
const symbol = CURRENCY_SYMBOLS[currency] ?? `${currency} `;
|
||||
return `${symbol}${amount.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatMerchantAddress(merchant: ReceiptMerchant): string {
|
||||
const cityLine = merchant.state
|
||||
? `${merchant.city}, ${merchant.state} ${merchant.postalCode}`
|
||||
: `${merchant.city} ${merchant.postalCode}`;
|
||||
const parts = [merchant.line1];
|
||||
if (merchant.line2) parts.push(merchant.line2);
|
||||
parts.push(cityLine);
|
||||
if (merchant.country) parts.push(merchant.country);
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function formatReceiptDate(date: Date): string {
|
||||
return date.toISOString().replace("T", " ").slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a compact receipt PDF from a typed {@link ReceiptData} payload.
|
||||
*
|
||||
* The receipt is laid out as: merchant header, dashed divider, receipt
|
||||
* number + ISO date, line-item table with `Item` / `Qty` / `Price`
|
||||
* columns, dashed totals divider, subtotal + tax (when non-zero) +
|
||||
* total, optional payment method and notes, and a closing message
|
||||
* defaulting to `"Thank you!"`. The returned `Document` is ready for
|
||||
* `renderDocument` — no extra wiring required.
|
||||
*
|
||||
* @throws `PdfError(PDF.INVALID_TEMPLATE_INPUT)` when the payload fails
|
||||
* {@link ReceiptDataSchema} validation.
|
||||
*/
|
||||
export function createReceiptTemplate(
|
||||
data: ReceiptData,
|
||||
options?: ReceiptTemplateOptions
|
||||
): ReceiptDocument {
|
||||
const parsed = ReceiptDataSchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
const issues = parsed.error.issues;
|
||||
throw new PdfError(
|
||||
PDF_CODES.INVALID_TEMPLATE_INPUT,
|
||||
`receipt template input invalid: ${parsed.error.message}`,
|
||||
{
|
||||
cause: parsed.error,
|
||||
context: { path: issues.map((issue) => issue.path) },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const theme: PdfTheme = options?.theme ?? DEFAULT_THEME;
|
||||
const currency = data.currency ?? options?.currency ?? "USD";
|
||||
const totals = computeReceiptTotals(data.items);
|
||||
|
||||
const builder = createDocument<ReceiptData>()
|
||||
.title(`Receipt ${data.receiptNumber}`)
|
||||
.author(data.merchant.name)
|
||||
.subject("Receipt")
|
||||
.creator("@suaveplan/pdf receipt-template")
|
||||
.withData(data)
|
||||
.pageConfig({
|
||||
size: PageSize.A5,
|
||||
orientation: "portrait",
|
||||
margins: { top: 36, right: 36, bottom: 36, left: 36 },
|
||||
...options?.page,
|
||||
});
|
||||
|
||||
if (options?.header && options.headerHeight !== undefined) {
|
||||
builder.setHeader(options.header, options.headerHeight);
|
||||
}
|
||||
if (options?.footer && options.footerHeight !== undefined) {
|
||||
builder.setFooter(options.footer, options.footerHeight);
|
||||
}
|
||||
|
||||
builder.addText(data.merchant.name, {
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
align: "center",
|
||||
color: theme.colors.primary,
|
||||
});
|
||||
builder.addText(formatMerchantAddress(data.merchant), {
|
||||
fontSize: 8,
|
||||
align: "center",
|
||||
color: theme.colors.textLight,
|
||||
});
|
||||
builder.addDivider({ color: theme.colors.border, dashPattern: [2, 2] });
|
||||
builder.addText(`Receipt #${data.receiptNumber}`, {
|
||||
fontSize: 10,
|
||||
fontWeight: "bold",
|
||||
});
|
||||
builder.addText(formatReceiptDate(data.date), {
|
||||
fontSize: 9,
|
||||
color: theme.colors.textLight,
|
||||
});
|
||||
builder.addDivider({ color: theme.colors.border });
|
||||
|
||||
const columns: ReadonlyArray<TableColumn<ReceiptRow>> = [
|
||||
{ key: "item", header: "Item", width: 160 },
|
||||
{ key: "qty", header: "Qty", width: 50, align: "center" },
|
||||
{ key: "price", header: "Price", width: 80, align: "right" },
|
||||
];
|
||||
const rows: ReadonlyArray<ReceiptRow> = data.items.map((item) => ({
|
||||
item: item.description,
|
||||
qty: item.quantity.toString(),
|
||||
price: formatReceiptAmount(item.quantity * item.unitPrice, currency),
|
||||
}));
|
||||
|
||||
builder.addTable<ReceiptRow>(columns, rows, {
|
||||
cellPadding: 3,
|
||||
headerBackground: theme.colors.border,
|
||||
headerColor: theme.colors.text,
|
||||
});
|
||||
|
||||
builder.addDivider({ color: theme.colors.border, dashPattern: [2, 2] });
|
||||
builder.addText(
|
||||
`Subtotal: ${formatReceiptAmount(totals.subtotal, currency)}`,
|
||||
{ fontSize: 9, align: "right" }
|
||||
);
|
||||
if (totals.tax > 0) {
|
||||
builder.addText(`Tax: ${formatReceiptAmount(totals.tax, currency)}`, {
|
||||
fontSize: 9,
|
||||
align: "right",
|
||||
});
|
||||
}
|
||||
if (totals.discount > 0) {
|
||||
builder.addText(
|
||||
`Discount: -${formatReceiptAmount(totals.discount, currency)}`,
|
||||
{ fontSize: 9, align: "right" }
|
||||
);
|
||||
}
|
||||
builder.addText(`Total: ${formatReceiptAmount(totals.total, currency)}`, {
|
||||
fontSize: 12,
|
||||
fontWeight: "bold",
|
||||
align: "right",
|
||||
});
|
||||
|
||||
if (data.paymentMethod) {
|
||||
builder.addText(`Paid via: ${data.paymentMethod}`, {
|
||||
fontSize: 8,
|
||||
align: "right",
|
||||
color: theme.colors.textLight,
|
||||
});
|
||||
}
|
||||
|
||||
if (data.notes) {
|
||||
builder.addDivider({ color: theme.colors.border });
|
||||
builder.addText(data.notes, {
|
||||
fontSize: 8,
|
||||
align: "center",
|
||||
color: theme.colors.textLight,
|
||||
});
|
||||
}
|
||||
|
||||
builder.addText(data.footerMessage ?? "Thank you!", {
|
||||
fontSize: 10,
|
||||
align: "center",
|
||||
color: theme.colors.textLight,
|
||||
});
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Public types for the {@link createReceiptTemplate} factory.
|
||||
*
|
||||
* Receipt templates produce compact A5 PDFs intended for point-of-sale
|
||||
* printers and emailed receipts. The data shape mirrors the upstream
|
||||
* `@suaveplan/pdf` template surface so existing consumers can flip the
|
||||
* import path without changing their payloads.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Document,
|
||||
DocumentNode,
|
||||
} from "../../document-builder/document-builder.types.js";
|
||||
import type { PdfTheme } from "../../style-resolver/style-resolver.types.js";
|
||||
import type { PageConfig } from "../../types/page-size.js";
|
||||
|
||||
/** Merchant address printed at the top of the receipt. */
|
||||
export interface ReceiptMerchant {
|
||||
readonly name: string;
|
||||
readonly line1: string;
|
||||
readonly line2?: string;
|
||||
readonly city: string;
|
||||
readonly state?: string;
|
||||
readonly postalCode: string;
|
||||
readonly country?: string;
|
||||
}
|
||||
|
||||
/** Single line item on a receipt. */
|
||||
export interface ReceiptLineItem {
|
||||
readonly description: string;
|
||||
readonly quantity: number;
|
||||
readonly unitPrice: number;
|
||||
readonly taxRate?: number;
|
||||
readonly discount?: number;
|
||||
}
|
||||
|
||||
/** Receipt payload — everything the template needs to build a PDF. */
|
||||
export interface ReceiptData {
|
||||
readonly receiptNumber: string;
|
||||
readonly date: Date;
|
||||
readonly merchant: ReceiptMerchant;
|
||||
readonly items: ReadonlyArray<ReceiptLineItem>;
|
||||
readonly currency?: string;
|
||||
readonly paymentMethod?: string;
|
||||
readonly notes?: string;
|
||||
readonly footerMessage?: string;
|
||||
}
|
||||
|
||||
/** Options accepted by {@link createReceiptTemplate}. */
|
||||
export interface ReceiptTemplateOptions {
|
||||
/** Optional theme — defaults to `DEFAULT_THEME`. */
|
||||
readonly theme?: PdfTheme;
|
||||
/** Optional page config — defaults to A5 portrait with 36pt margins. */
|
||||
readonly page?: Partial<PageConfig>;
|
||||
/** Optional currency code override; the payload's `currency` wins. */
|
||||
readonly currency?: string;
|
||||
/** Optional pre-built header nodes — register a logo or banner. */
|
||||
readonly header?: ReadonlyArray<DocumentNode>;
|
||||
/** Header height in points — required when `header` is supplied. */
|
||||
readonly headerHeight?: number;
|
||||
/** Optional pre-built footer nodes — supplemental footer furniture. */
|
||||
readonly footer?: ReadonlyArray<DocumentNode>;
|
||||
/** Footer height in points — required when `footer` is supplied. */
|
||||
readonly footerHeight?: number;
|
||||
}
|
||||
|
||||
/** Type alias the factory returns — Document with the receipt payload. */
|
||||
export type ReceiptDocument = Document<ReceiptData>;
|
||||
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
createReportTemplate,
|
||||
ReportDataSchema,
|
||||
} from "./report-template.js";
|
||||
export type {
|
||||
ReportColumn,
|
||||
ReportData,
|
||||
ReportDocument,
|
||||
ReportSection,
|
||||
ReportTemplateOptions,
|
||||
} from "./report-template.types.js";
|
||||
@@ -0,0 +1,119 @@
|
||||
# `report-template`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`createReportTemplate(data, options?)` converts a typed `ReportData`
|
||||
payload into a renderer-ready `Document` for multi-section reports —
|
||||
compliance exports, dashboard snapshots, weekly digests, and the
|
||||
analytics-style documents that pair a title block with one or more
|
||||
section heading + body + optional data-table units. The factory
|
||||
eagerly validates the payload through `ReportDataSchema`, then walks
|
||||
every layout decision via the chunk D `DocumentBuilder`, producing a
|
||||
`Document<ReportData>` that `renderDocument` flattens to PDF 1.4 bytes.
|
||||
|
||||
## Features
|
||||
|
||||
- Eager Zod validation with `PdfError.INVALID_TEMPLATE_INPUT`
|
||||
- Title block: title (h1 + primary colour), optional subtitle, optional
|
||||
author + ISO date meta line
|
||||
- One section per entry: `h2` heading + body paragraph + optional data
|
||||
table (renders only when both `data` and `columns` are present)
|
||||
- Alternating row background + secondary-coloured table header drawn
|
||||
from the active theme
|
||||
- Optional summary tail rendered after a final divider
|
||||
- Optional repeating header / footer slots (`header` + `headerHeight`)
|
||||
- Theme cascade — `DEFAULT_THEME` colour palette is overridable; the
|
||||
factory wires the theme through every emitted node
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createReportTemplate, renderDocument } from "@suaveplan/pdf";
|
||||
|
||||
const { bytes } = renderDocument(
|
||||
createReportTemplate({
|
||||
title: "Weekly Health",
|
||||
date: new Date(),
|
||||
sections: [
|
||||
{
|
||||
title: "Uptime",
|
||||
content: "All services held > 99.9% over the week.",
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
createReportTemplate,
|
||||
DEFAULT_THEME,
|
||||
renderDocument,
|
||||
TextElement,
|
||||
} from "@suaveplan/pdf";
|
||||
|
||||
const headerNode = {
|
||||
type: "text" as const,
|
||||
element: new TextElement({ content: "Q1 REPORT" }),
|
||||
};
|
||||
|
||||
const doc = createReportTemplate(
|
||||
{
|
||||
title: "Quarterly Review",
|
||||
subtitle: "Q1 2026",
|
||||
author: "Auditor Bot",
|
||||
date: new Date("2026-04-01"),
|
||||
sections: [
|
||||
{ title: "Overview", content: "..." },
|
||||
{
|
||||
title: "Numbers",
|
||||
content: "Per-region revenue:",
|
||||
data: [
|
||||
{ region: "AMER", revenue: 100 },
|
||||
{ region: "EMEA", revenue: 80 },
|
||||
],
|
||||
columns: [
|
||||
{ key: "region", header: "Region" },
|
||||
{ key: "revenue", header: "Revenue", align: "right" },
|
||||
],
|
||||
},
|
||||
],
|
||||
summary: "Targets met.",
|
||||
},
|
||||
{
|
||||
theme: { ...DEFAULT_THEME, name: "audit" },
|
||||
header: [headerNode],
|
||||
headerHeight: 30,
|
||||
}
|
||||
);
|
||||
|
||||
const { bytes, pageCount } = renderDocument(doc);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createReportTemplate(data, options?) => Document<ReportData>` — main
|
||||
factory.
|
||||
- `ReportDataSchema` — Zod schema for the input payload.
|
||||
- `ReportData`, `ReportSection`, `ReportColumn`, `ReportTemplateOptions`,
|
||||
`ReportDocument` — public types.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The factory deliberately runs every emission through `DocumentBuilder`
|
||||
so the chunk D layout engine handles pagination, header/footer slot
|
||||
reservation, and table-row pagination uniformly. A section with `data`
|
||||
but no `columns` (or vice versa) silently skips the table — the missing
|
||||
half is treated as a no-op rather than a validation error, because the
|
||||
caller's intent (heading + body only) is unambiguous. The factory does
|
||||
not run `renderDocument` itself — it returns a `Document<ReportData>`
|
||||
so callers control telemetry, abort signals, and clock injection. The
|
||||
`Intl.*`-free ISO date string keeps the renderer locale-independent;
|
||||
consumers that need a localised header should pre-format their dates
|
||||
into `data.subtitle` or render a custom header slot.
|
||||
@@ -0,0 +1,220 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PDFParse } from "pdf-parse";
|
||||
import type { Document } from "../../document-builder/document-builder.types.js";
|
||||
import { PDF_CODES, PdfError } from "../../error-codes/error-codes.js";
|
||||
import { renderDocument } from "../../renderer/renderer.js";
|
||||
import { DEFAULT_THEME } from "../../style-resolver/style-resolver.js";
|
||||
import { TextElement } from "../../text-element/text-element.js";
|
||||
import { PageSize } from "../../types/page-size.js";
|
||||
import { createReportTemplate, ReportDataSchema } from "./report-template.js";
|
||||
import type { ReportData } from "./report-template.types.js";
|
||||
|
||||
const FIXED_DATE = new Date("2026-05-25T00:00:00Z");
|
||||
|
||||
const BASE_DATA: ReportData = {
|
||||
title: "Quarterly Review",
|
||||
subtitle: "Q1 2026",
|
||||
author: "Auditor Bot",
|
||||
date: FIXED_DATE,
|
||||
sections: [
|
||||
{
|
||||
title: "Overview",
|
||||
content: "Things-went-well-this-quarter.",
|
||||
},
|
||||
{
|
||||
title: "Numbers",
|
||||
content: "Tabular-breakdown:",
|
||||
data: [
|
||||
{ region: "AMER", revenue: 100 },
|
||||
{ region: "EMEA", revenue: 80 },
|
||||
],
|
||||
columns: [
|
||||
{ key: "region", header: "Region" },
|
||||
{
|
||||
key: "revenue",
|
||||
header: "Revenue",
|
||||
width: 80,
|
||||
align: "right",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
summary: "Closing-summary-text.",
|
||||
};
|
||||
|
||||
function collectText(doc: Document<ReportData>): string {
|
||||
const parts: string[] = [];
|
||||
for (const page of doc.pages) {
|
||||
for (const node of page.nodes) {
|
||||
if (node.type === "text") {
|
||||
parts.push(node.element.options.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
describe("ReportDataSchema", () => {
|
||||
it("accepts a minimum valid payload", { timeout: 5000 }, () => {
|
||||
const parsed = ReportDataSchema.safeParse(BASE_DATA);
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects empty sections", { timeout: 5000 }, () => {
|
||||
const parsed = ReportDataSchema.safeParse({
|
||||
...BASE_DATA,
|
||||
sections: [],
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an empty title", { timeout: 5000 }, () => {
|
||||
const parsed = ReportDataSchema.safeParse({
|
||||
...BASE_DATA,
|
||||
title: "",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createReportTemplate — input validation", () => {
|
||||
it("raises INVALID_TEMPLATE_INPUT on empty title", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
createReportTemplate({ ...BASE_DATA, title: "" });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(PdfError);
|
||||
expect((err as PdfError).code).toBe(
|
||||
PDF_CODES.INVALID_TEMPLATE_INPUT
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("raises INVALID_TEMPLATE_INPUT when sections is empty", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
createReportTemplate({ ...BASE_DATA, sections: [] })
|
||||
).toThrow(PdfError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createReportTemplate — Document assembly", () => {
|
||||
it("emits title + author metadata + carries withData payload", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = createReportTemplate(BASE_DATA);
|
||||
expect(doc.metadata.title).toBe("Quarterly Review");
|
||||
expect(doc.metadata.author).toBe("Auditor Bot");
|
||||
expect(doc.data).toEqual(BASE_DATA);
|
||||
});
|
||||
|
||||
it("renders title block + each section heading + summary", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = createReportTemplate(BASE_DATA);
|
||||
const haystack = collectText(doc);
|
||||
expect(haystack).toContain("Quarterly Review");
|
||||
expect(haystack).toContain("Q1 2026");
|
||||
expect(haystack).toContain("Author: Auditor Bot");
|
||||
expect(haystack).toContain("Overview");
|
||||
expect(haystack).toContain("Things-went-well-this-quarter.");
|
||||
expect(haystack).toContain("Numbers");
|
||||
expect(haystack).toContain("Summary");
|
||||
expect(haystack).toContain("Closing-summary-text.");
|
||||
});
|
||||
|
||||
it("omits the author meta token when payload has no author", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = createReportTemplate({
|
||||
...BASE_DATA,
|
||||
author: undefined,
|
||||
subtitle: undefined,
|
||||
summary: undefined,
|
||||
});
|
||||
const haystack = collectText(doc);
|
||||
expect(haystack).not.toContain("Author:");
|
||||
expect(haystack).not.toContain("Summary");
|
||||
expect(haystack).toContain("Quarterly Review");
|
||||
});
|
||||
|
||||
it("emits a table for the Numbers section", { timeout: 5000 }, () => {
|
||||
const doc = createReportTemplate(BASE_DATA);
|
||||
const tableNodes = doc.pages[0]?.nodes.filter(
|
||||
(n) => n.type === "table"
|
||||
);
|
||||
expect(tableNodes?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("skips the table when data is supplied without columns", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = createReportTemplate({
|
||||
...BASE_DATA,
|
||||
sections: [
|
||||
{
|
||||
title: "Just text",
|
||||
content: "no-table-here",
|
||||
data: [{ a: 1 }],
|
||||
},
|
||||
],
|
||||
summary: undefined,
|
||||
});
|
||||
const tableNodes = doc.pages[0]?.nodes.filter(
|
||||
(n) => n.type === "table"
|
||||
);
|
||||
expect(tableNodes?.length).toBe(0);
|
||||
});
|
||||
|
||||
it("respects custom theme + page config + header/footer slots", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const headerNode = {
|
||||
type: "text" as const,
|
||||
element: new TextElement({ content: "REPORT-HEADER" }),
|
||||
};
|
||||
const footerNode = {
|
||||
type: "text" as const,
|
||||
element: new TextElement({ content: "REPORT-FOOTER" }),
|
||||
};
|
||||
const doc = createReportTemplate(BASE_DATA, {
|
||||
theme: { ...DEFAULT_THEME, name: "alt" },
|
||||
page: { size: PageSize.LETTER },
|
||||
header: [headerNode],
|
||||
headerHeight: 30,
|
||||
footer: [footerNode],
|
||||
footerHeight: 20,
|
||||
});
|
||||
expect(doc.defaultPage.size).toBe(PageSize.LETTER);
|
||||
expect(doc.headerHeight).toBe(30);
|
||||
expect(doc.footerHeight).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createReportTemplate — PDF round-trip", () => {
|
||||
it("renders a real PDF round-trippable via pdf-parse", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const doc = createReportTemplate(BASE_DATA);
|
||||
const result = renderDocument(doc);
|
||||
expect(result.bytes).toBeInstanceOf(Uint8Array);
|
||||
expect(result.pageCount).toBeGreaterThanOrEqual(1);
|
||||
const parser = new PDFParse({ data: result.bytes });
|
||||
try {
|
||||
const info = await parser.getInfo();
|
||||
const text = await parser.getText();
|
||||
expect(info.info.Title).toBe("Quarterly Review");
|
||||
expect(info.info.Author).toBe("Auditor Bot");
|
||||
expect(text.text).toContain("Quarterly Review");
|
||||
expect(text.text).toContain("Overview");
|
||||
expect(text.text).toContain("AMER");
|
||||
expect(text.text).toContain("EMEA");
|
||||
expect(text.text).toContain("Closing-summary-text.");
|
||||
} finally {
|
||||
await parser.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import z from "zod";
|
||||
import {
|
||||
createDocument,
|
||||
type DocumentBuilder,
|
||||
} from "../../document-builder/document-builder.js";
|
||||
import { PDF_CODES, PdfError } from "../../error-codes/error-codes.js";
|
||||
import { DEFAULT_THEME } from "../../style-resolver/style-resolver.js";
|
||||
import type { PdfTheme } from "../../style-resolver/style-resolver.types.js";
|
||||
import type { TableColumn } from "../../table-element/table-element.types.js";
|
||||
import type {
|
||||
ReportData,
|
||||
ReportDocument,
|
||||
ReportSection,
|
||||
ReportTemplateOptions,
|
||||
} from "./report-template.types.js";
|
||||
|
||||
/**
|
||||
* Zod schema validating a {@link ReportData} payload at the template
|
||||
* boundary. Failures raise `PdfError(PDF.INVALID_TEMPLATE_INPUT)`.
|
||||
*/
|
||||
export const ReportDataSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
subtitle: z.string().optional(),
|
||||
author: z.string().optional(),
|
||||
date: z.date(),
|
||||
sections: z
|
||||
.array(
|
||||
z.object({
|
||||
title: z.string().min(1),
|
||||
content: z.string(),
|
||||
data: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||
columns: z
|
||||
.array(
|
||||
z.object({
|
||||
key: z.string().min(1),
|
||||
header: z.string(),
|
||||
width: z.number().finite().positive().optional(),
|
||||
align: z
|
||||
.enum(["left", "center", "right"])
|
||||
.optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
)
|
||||
.min(1),
|
||||
summary: z.string().optional(),
|
||||
});
|
||||
|
||||
function formatReportDate(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function emitTitleBlock(
|
||||
builder: DocumentBuilder<ReportData>,
|
||||
data: ReportData,
|
||||
theme: PdfTheme
|
||||
): void {
|
||||
builder.addText(data.title, {
|
||||
fontSize: theme.typography.headingSizes.h1,
|
||||
fontWeight: "bold",
|
||||
color: theme.colors.primary,
|
||||
});
|
||||
if (data.subtitle) {
|
||||
builder.addText(data.subtitle, {
|
||||
fontSize: theme.typography.headingSizes.h3,
|
||||
color: theme.colors.textLight,
|
||||
});
|
||||
}
|
||||
const metaParts: string[] = [];
|
||||
if (data.author) metaParts.push(`Author: ${data.author}`);
|
||||
metaParts.push(`Date: ${formatReportDate(data.date)}`);
|
||||
builder.addText(metaParts.join(" | "), {
|
||||
fontSize: 9,
|
||||
color: theme.colors.textLight,
|
||||
});
|
||||
}
|
||||
|
||||
function emitSection(
|
||||
builder: DocumentBuilder<ReportData>,
|
||||
section: ReportSection,
|
||||
theme: PdfTheme
|
||||
): void {
|
||||
builder.addText(section.title, {
|
||||
fontSize: theme.typography.headingSizes.h2,
|
||||
fontWeight: "bold",
|
||||
color: theme.colors.secondary,
|
||||
});
|
||||
builder.addText(section.content, {
|
||||
fontSize: theme.typography.baseFontSize,
|
||||
lineHeight: theme.typography.lineHeight,
|
||||
color: theme.colors.text,
|
||||
});
|
||||
if (section.data && section.columns) {
|
||||
const columns: ReadonlyArray<TableColumn<Record<string, unknown>>> =
|
||||
section.columns.map((col) => {
|
||||
const base: TableColumn<Record<string, unknown>> = {
|
||||
key: col.key,
|
||||
header: col.header,
|
||||
...(col.width !== undefined ? { width: col.width } : {}),
|
||||
...(col.align !== undefined ? { align: col.align } : {}),
|
||||
};
|
||||
return base;
|
||||
});
|
||||
builder.addTable<Record<string, unknown>>(columns, section.data, {
|
||||
headerBackground: theme.colors.secondary,
|
||||
headerColor: "#ffffff",
|
||||
alternateRowBackground: "#f5f5f5",
|
||||
cellPadding: 4,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a multi-section report PDF from a typed {@link ReportData}
|
||||
* payload. Produces a title block (title + subtitle + meta line), a
|
||||
* full-width divider, one section per `data.sections` entry (heading +
|
||||
* body text + optional table), and an optional summary tail.
|
||||
*
|
||||
* @throws `PdfError(PDF.INVALID_TEMPLATE_INPUT)` when the payload fails
|
||||
* {@link ReportDataSchema} validation.
|
||||
*/
|
||||
export function createReportTemplate(
|
||||
data: ReportData,
|
||||
options?: ReportTemplateOptions
|
||||
): ReportDocument {
|
||||
const parsed = ReportDataSchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
const issues = parsed.error.issues;
|
||||
throw new PdfError(
|
||||
PDF_CODES.INVALID_TEMPLATE_INPUT,
|
||||
`report template input invalid: ${parsed.error.message}`,
|
||||
{
|
||||
cause: parsed.error,
|
||||
context: { path: issues.map((issue) => issue.path) },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const theme = options?.theme ?? DEFAULT_THEME;
|
||||
|
||||
const builder = createDocument<ReportData>()
|
||||
.title(data.title)
|
||||
.subject("Report")
|
||||
.creator("@suaveplan/pdf report-template")
|
||||
.withData(data);
|
||||
if (data.author) builder.author(data.author);
|
||||
if (options?.page) builder.pageConfig(options.page);
|
||||
if (options?.header && options.headerHeight !== undefined) {
|
||||
builder.setHeader(options.header, options.headerHeight);
|
||||
}
|
||||
if (options?.footer && options.footerHeight !== undefined) {
|
||||
builder.setFooter(options.footer, options.footerHeight);
|
||||
}
|
||||
|
||||
emitTitleBlock(builder, data, theme);
|
||||
builder.addDivider({ color: theme.colors.border, thickness: 2 });
|
||||
for (const section of data.sections) {
|
||||
emitSection(builder, section, theme);
|
||||
}
|
||||
if (data.summary) {
|
||||
builder.addDivider({ color: theme.colors.border });
|
||||
builder.addText("Summary", {
|
||||
fontSize: theme.typography.headingSizes.h2,
|
||||
fontWeight: "bold",
|
||||
color: theme.colors.primary,
|
||||
});
|
||||
builder.addText(data.summary, {
|
||||
fontSize: theme.typography.baseFontSize,
|
||||
color: theme.colors.text,
|
||||
});
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Public types for the {@link createReportTemplate} factory.
|
||||
*
|
||||
* Report templates produce multi-section A4 reports with a title block,
|
||||
* per-section heading + body, optional data tables, and an optional
|
||||
* summary tail. Useful for compliance exports, dashboard snapshots, and
|
||||
* automated weekly digests.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Document,
|
||||
DocumentNode,
|
||||
} from "../../document-builder/document-builder.types.js";
|
||||
import type { PdfTheme } from "../../style-resolver/style-resolver.types.js";
|
||||
import type { PageConfig } from "../../types/page-size.js";
|
||||
|
||||
/** Column declaration on a report section table. */
|
||||
export interface ReportColumn {
|
||||
readonly key: string;
|
||||
readonly header: string;
|
||||
readonly width?: number;
|
||||
readonly align?: "left" | "center" | "right";
|
||||
}
|
||||
|
||||
/** A single section in a report. */
|
||||
export interface ReportSection {
|
||||
readonly title: string;
|
||||
readonly content: string;
|
||||
readonly data?: ReadonlyArray<Record<string, unknown>>;
|
||||
readonly columns?: ReadonlyArray<ReportColumn>;
|
||||
}
|
||||
|
||||
/** Report payload — title, optional metadata, and one or more sections. */
|
||||
export interface ReportData {
|
||||
readonly title: string;
|
||||
readonly subtitle?: string;
|
||||
readonly author?: string;
|
||||
readonly date: Date;
|
||||
readonly sections: ReadonlyArray<ReportSection>;
|
||||
readonly summary?: string;
|
||||
}
|
||||
|
||||
/** Options accepted by {@link createReportTemplate}. */
|
||||
export interface ReportTemplateOptions {
|
||||
/** Theme override — defaults to `DEFAULT_THEME`. */
|
||||
readonly theme?: PdfTheme;
|
||||
/** Per-page config override — defaults to A4 portrait. */
|
||||
readonly page?: Partial<PageConfig>;
|
||||
/** Optional repeating header nodes printed on every emitted page. */
|
||||
readonly header?: ReadonlyArray<DocumentNode>;
|
||||
/** Header height in points — required when `header` is supplied. */
|
||||
readonly headerHeight?: number;
|
||||
/** Optional repeating footer nodes printed on every emitted page. */
|
||||
readonly footer?: ReadonlyArray<DocumentNode>;
|
||||
/** Footer height in points — required when `footer` is supplied. */
|
||||
readonly footerHeight?: number;
|
||||
}
|
||||
|
||||
/** Type alias the factory returns — Document with the report payload. */
|
||||
export type ReportDocument = Document<ReportData>;
|
||||
17
packages/devtools/docs/pdf/src/text-element/index.ts
Normal file
17
packages/devtools/docs/pdf/src/text-element/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export {
|
||||
measureLineWidth,
|
||||
resolveStandardFont,
|
||||
TextElement,
|
||||
TextElementOptionsSchema,
|
||||
wrapText,
|
||||
} from "./text-element.js";
|
||||
export type {
|
||||
ElementMeasurement,
|
||||
ElementRenderContext,
|
||||
ElementRenderResult,
|
||||
PdfFontFamily,
|
||||
PdfFontStyle,
|
||||
PdfFontWeight,
|
||||
PdfTextAlign,
|
||||
TextElementOptions,
|
||||
} from "./text-element.types.js";
|
||||
113
packages/devtools/docs/pdf/src/text-element/text-element.md
Normal file
113
packages/devtools/docs/pdf/src/text-element/text-element.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# text-element
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`TextElement` is the flowed-text primitive of `@suaveplan/pdf`. It accepts
|
||||
a content string plus typography options (font family, weight, style, size,
|
||||
line height, alignment, colour, kerning, line cap) and produces a PDF
|
||||
content-stream snippet that can be embedded in a page by `PdfWriter`. The
|
||||
element wraps long content to fit a column, supports left / center / right /
|
||||
justify alignment, exposes both `measure()` (for layout) and `render()` (for
|
||||
output), and validates every option through a Zod schema so consumers
|
||||
receive typed `PDF.INVALID_ELEMENT` errors instead of runtime crashes.
|
||||
|
||||
This is the first true rendering primitive that consumes Chunk B's
|
||||
`ContentStreamEncoder` + `PdfWriter` font registry. Chunks D and E (layout
|
||||
engine + templates) compose `TextElement` instances; the layout engine calls
|
||||
`measure()` to decide page breaks and `render()` once a position has been
|
||||
chosen.
|
||||
|
||||
## Features
|
||||
|
||||
- Standard-14 PDF font selection via `(family, weight, style)` triple.
|
||||
- Word-wrap with average-glyph-width approximation, hard-split for
|
||||
oversized words, paragraph-aware newline handling.
|
||||
- `measure(maxWidth)` returns `{ width, height, lineCount }` for layout.
|
||||
- `render(context)` emits a graphics-state-clean snippet with `q…Q`,
|
||||
fill-colour application, `BT/ET` text-mode, multi-line cursor moves,
|
||||
optional character-spacing (kerning).
|
||||
- Alignment: `left`, `center`, `right`, `justify` (currently maps to left
|
||||
for the X coordinate — full justification lands in the layout engine).
|
||||
- `maxLines` cap + `overflowed` flag for callers that must enforce a
|
||||
ceiling.
|
||||
- `AbortSignal` propagation — aborted contexts raise `PDF.ABORTED`.
|
||||
- Skips the fill-colour operator entirely for `transparent` / `none`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { PdfWriter, TextElement } from "@suaveplan/pdf";
|
||||
|
||||
const writer = new PdfWriter();
|
||||
const fontName = writer.registerFont("Helvetica");
|
||||
const text = new TextElement({ content: "Hello PDF", fontSize: 14 });
|
||||
const out = text.render({
|
||||
x: 72,
|
||||
y: 770,
|
||||
maxWidth: 460,
|
||||
fontResourceName: fontName,
|
||||
});
|
||||
const streamId = writer.addStream(out.stream);
|
||||
writer.addPage({ width: 612, height: 792, contentStreamId: streamId });
|
||||
const bytes = writer.generate({ Title: "Hello" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { TextElement, wrapText } from "@suaveplan/pdf";
|
||||
|
||||
// Pre-measure for a multi-column layout.
|
||||
const text = new TextElement({
|
||||
content: "Aenean commodo ligula eget dolor. Aenean massa.",
|
||||
fontSize: 10,
|
||||
fontFamily: "Times-Roman",
|
||||
fontWeight: "bold",
|
||||
fontStyle: "italic",
|
||||
color: "#222244",
|
||||
lineHeight: 1.35,
|
||||
align: "right",
|
||||
maxLines: 6,
|
||||
kerning: 0.25,
|
||||
});
|
||||
const { lineCount, height } = text.measure(220);
|
||||
const lines = wrapText(text.options.content, 10, 220);
|
||||
// Choose page placement based on `lineCount` + `height`, then render.
|
||||
const out = text.render({
|
||||
x: 320,
|
||||
y: 700,
|
||||
maxWidth: 220,
|
||||
fontResourceName: "F1",
|
||||
});
|
||||
if (out.overflowed) {
|
||||
// Insert a continuation marker on the next page.
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class TextElement(options)` — validates `TextElementOptions` and
|
||||
exposes `measure(maxWidth)` + `render(context)` + `fontName()`.
|
||||
- `TextElementOptionsSchema` — Zod schema for option validation.
|
||||
- `wrapText(text, fontSize, maxWidth)` — word-wrap helper used by
|
||||
`measure` / `render` and exported for layout-engine use.
|
||||
- `measureLineWidth(line, fontSize)` — average-glyph width estimate.
|
||||
- `resolveStandardFont(family, weight, style)` — resolves to one of the
|
||||
14 standard PDF font names or throws `PDF.FONT_UNKNOWN`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The wrap algorithm uses a deterministic `0.5em` average-character width
|
||||
because the standard 14 PDF fonts ship with every reader but do not embed
|
||||
glyph metrics in the document; downstream chunks may swap in true font
|
||||
metrics once subset embedding lands. Rendering goes through
|
||||
`ContentStreamEncoder` (not raw string interpolation) so the text-mode
|
||||
invariants are enforced and the resulting stream is guaranteed to balance
|
||||
`BT`/`ET` + `q`/`Q`. The fill-colour application happens outside `BT/ET`
|
||||
because PDF readers reject `rg` inside a text object on strict-mode
|
||||
viewers. `AbortSignal` is checked at the top of `render()` so the element
|
||||
can participate in cooperative cancellation alongside `PdfWriter.generate`.
|
||||
289
packages/devtools/docs/pdf/src/text-element/text-element.test.ts
Normal file
289
packages/devtools/docs/pdf/src/text-element/text-element.test.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PDFParse } from "pdf-parse";
|
||||
import { PDF_CODES, PdfError } from "../error-codes/error-codes.js";
|
||||
import { PdfWriter } from "../pdf-writer/pdf-writer.js";
|
||||
import {
|
||||
measureLineWidth,
|
||||
resolveStandardFont,
|
||||
TextElement,
|
||||
wrapText,
|
||||
} from "./text-element.js";
|
||||
|
||||
describe("TextElement", () => {
|
||||
it("fontName resolves from options", { timeout: 5000 }, () => {
|
||||
const el = new TextElement({
|
||||
content: "hi",
|
||||
fontFamily: "Helvetica",
|
||||
fontWeight: "bold",
|
||||
});
|
||||
expect(el.fontName()).toBe("Helvetica-Bold");
|
||||
});
|
||||
|
||||
it("validates options at construction", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
() =>
|
||||
new TextElement({
|
||||
content: "ok",
|
||||
fontSize: -1 as unknown as number,
|
||||
})
|
||||
).toThrow(PdfError);
|
||||
});
|
||||
|
||||
it("uses default font size + line height when omitted", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const el = new TextElement({ content: "Hi" });
|
||||
const m = el.measure(500);
|
||||
expect(m.lineCount).toBe(1);
|
||||
expect(m.height).toBeCloseTo(12 * 1.2, 5);
|
||||
const out = el.render({
|
||||
x: 0,
|
||||
y: 700,
|
||||
maxWidth: 200,
|
||||
fontResourceName: "F1",
|
||||
});
|
||||
expect(out.stream).toContain("/F1 12 Tf");
|
||||
});
|
||||
|
||||
it("measures a single line", { timeout: 5000 }, () => {
|
||||
const el = new TextElement({ content: "Hello", fontSize: 12 });
|
||||
const m = el.measure(500);
|
||||
expect(m.lineCount).toBe(1);
|
||||
expect(m.height).toBeCloseTo(12 * 1.2, 5);
|
||||
expect(m.width).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("wraps content across multiple lines", { timeout: 5000 }, () => {
|
||||
const el = new TextElement({
|
||||
content: "the quick brown fox jumps over the lazy dog",
|
||||
fontSize: 12,
|
||||
});
|
||||
const m = el.measure(60);
|
||||
expect(m.lineCount && m.lineCount > 1).toBe(true);
|
||||
});
|
||||
|
||||
it("respects maxLines and reports overflow", { timeout: 5000 }, () => {
|
||||
const el = new TextElement({
|
||||
content: "a b c d e f g h i j k l m n o p q r s t u v w x y z",
|
||||
fontSize: 12,
|
||||
maxLines: 1,
|
||||
});
|
||||
const out = el.render({
|
||||
x: 0,
|
||||
y: 800,
|
||||
maxWidth: 40,
|
||||
fontResourceName: "F1",
|
||||
});
|
||||
expect(out.overflowed).toBe(true);
|
||||
});
|
||||
|
||||
it("emits BT/ET around content", { timeout: 5000 }, () => {
|
||||
const el = new TextElement({
|
||||
content: "Hello",
|
||||
fontSize: 12,
|
||||
color: "#ff0000",
|
||||
});
|
||||
const out = el.render({
|
||||
x: 100,
|
||||
y: 700,
|
||||
maxWidth: 200,
|
||||
fontResourceName: "F1",
|
||||
});
|
||||
expect(out.stream).toContain("BT");
|
||||
expect(out.stream).toContain("/F1 12 Tf");
|
||||
expect(out.stream).toContain("(Hello) Tj");
|
||||
expect(out.stream).toContain("ET");
|
||||
expect(out.stream).toContain("1 0 0 rg");
|
||||
});
|
||||
|
||||
it("centers and right-aligns lines", { timeout: 5000 }, () => {
|
||||
const center = new TextElement({
|
||||
content: "hi",
|
||||
fontSize: 12,
|
||||
align: "center",
|
||||
}).render({ x: 0, y: 700, maxWidth: 100, fontResourceName: "F1" });
|
||||
const right = new TextElement({
|
||||
content: "hi",
|
||||
fontSize: 12,
|
||||
align: "right",
|
||||
}).render({ x: 0, y: 700, maxWidth: 100, fontResourceName: "F1" });
|
||||
expect(center.stream).toContain("Td");
|
||||
expect(right.stream).toContain("Td");
|
||||
expect(center.stream).not.toBe(right.stream);
|
||||
});
|
||||
|
||||
it("justify behaves like left for the alignment x", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const el = new TextElement({
|
||||
content: "abc",
|
||||
fontSize: 12,
|
||||
align: "justify",
|
||||
});
|
||||
const out = el.render({
|
||||
x: 10,
|
||||
y: 700,
|
||||
maxWidth: 200,
|
||||
fontResourceName: "F1",
|
||||
});
|
||||
expect(out.stream).toContain("(abc) Tj");
|
||||
});
|
||||
|
||||
it("skips fill colour when transparent", { timeout: 5000 }, () => {
|
||||
const el = new TextElement({ content: "Hi", color: "transparent" });
|
||||
const out = el.render({
|
||||
x: 0,
|
||||
y: 700,
|
||||
maxWidth: 200,
|
||||
fontResourceName: "F1",
|
||||
});
|
||||
expect(out.stream).not.toMatch(/\d \d \d rg/);
|
||||
});
|
||||
|
||||
it("applies kerning when provided", { timeout: 5000 }, () => {
|
||||
const el = new TextElement({ content: "Hi", kerning: 1 });
|
||||
const out = el.render({
|
||||
x: 0,
|
||||
y: 700,
|
||||
maxWidth: 200,
|
||||
fontResourceName: "F1",
|
||||
});
|
||||
expect(out.stream).toContain("1 Tc");
|
||||
});
|
||||
|
||||
it("throws when fontResourceName missing", { timeout: 5000 }, () => {
|
||||
const el = new TextElement({ content: "Hi" });
|
||||
expect(() => el.render({ x: 0, y: 700, maxWidth: 200 })).toThrow(
|
||||
PdfError
|
||||
);
|
||||
});
|
||||
|
||||
it("aborts on aborted signal", { timeout: 5000 }, () => {
|
||||
const el = new TextElement({ content: "Hi" });
|
||||
const c = new AbortController();
|
||||
c.abort();
|
||||
expect(() =>
|
||||
el.render({
|
||||
x: 0,
|
||||
y: 700,
|
||||
maxWidth: 200,
|
||||
fontResourceName: "F1",
|
||||
signal: c.signal,
|
||||
})
|
||||
).toThrow(PdfError);
|
||||
});
|
||||
|
||||
it("emits multi-line cursor moves for wrapped output", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const el = new TextElement({
|
||||
content: "the quick brown fox jumps over the lazy dog",
|
||||
fontSize: 12,
|
||||
});
|
||||
const out = el.render({
|
||||
x: 0,
|
||||
y: 800,
|
||||
maxWidth: 50,
|
||||
fontResourceName: "F1",
|
||||
});
|
||||
const tds = (out.stream.match(/ Td/g) ?? []).length;
|
||||
expect(tds).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("round-trips through pdf-parse", { timeout: 30000 }, async () => {
|
||||
const writer = new PdfWriter();
|
||||
const fontResource = writer.registerFont("Helvetica");
|
||||
const el = new TextElement({ content: "Hello PDF", fontSize: 14 });
|
||||
const out = el.render({
|
||||
x: 72,
|
||||
y: 770,
|
||||
maxWidth: 460,
|
||||
fontResourceName: fontResource,
|
||||
});
|
||||
const streamId = writer.addStream(out.stream);
|
||||
writer.addPage({
|
||||
width: 612,
|
||||
height: 792,
|
||||
contentStreamId: streamId,
|
||||
});
|
||||
const bytes = writer.generate({ Title: "Text Round Trip" });
|
||||
const parser = new PDFParse({ data: bytes });
|
||||
try {
|
||||
const info = await parser.getInfo();
|
||||
const text = await parser.getText();
|
||||
expect(info.total).toBe(1);
|
||||
expect(text.text).toContain("Hello PDF");
|
||||
} finally {
|
||||
await parser.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapText", () => {
|
||||
it("requires positive maxWidth", { timeout: 5000 }, () => {
|
||||
expect(() => wrapText("hi", 12, 0)).toThrow(PdfError);
|
||||
});
|
||||
|
||||
it("preserves explicit newlines", { timeout: 5000 }, () => {
|
||||
expect(wrapText("a\nb", 12, 500)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("preserves empty paragraphs", { timeout: 5000 }, () => {
|
||||
expect(wrapText("a\n\nb", 12, 500)).toEqual(["a", "", "b"]);
|
||||
});
|
||||
|
||||
it("hard-splits words longer than the line", { timeout: 5000 }, () => {
|
||||
const out = wrapText("abcdefghij", 12, 20);
|
||||
expect(out.length).toBeGreaterThan(1);
|
||||
expect(out.join("")).toBe("abcdefghij");
|
||||
});
|
||||
|
||||
it("hard-splits cleanly when word length divides line", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// fontSize=10, maxWidth=10 → charWidth=5, charsPerLine=2.
|
||||
// "abcd" hard-splits into "ab","cd" leaving current="" → exercises
|
||||
// the false branch of `if (current)` at paragraph end.
|
||||
const out = wrapText("abcd", 10, 10);
|
||||
expect(out).toEqual(["ab", "cd"]);
|
||||
});
|
||||
|
||||
it("measures average line width", { timeout: 5000 }, () => {
|
||||
expect(measureLineWidth("ABCD", 10)).toBeCloseTo(20, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveStandardFont", () => {
|
||||
it("maps Helvetica bold italic", { timeout: 5000 }, () => {
|
||||
expect(resolveStandardFont("Helvetica", "bold", "italic")).toBe(
|
||||
"Helvetica-BoldOblique"
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to family default", { timeout: 5000 }, () => {
|
||||
expect(resolveStandardFont("Symbol")).toBe("Symbol");
|
||||
});
|
||||
|
||||
it("defaults all parameters to Helvetica", { timeout: 5000 }, () => {
|
||||
expect(resolveStandardFont()).toBe("Helvetica");
|
||||
});
|
||||
|
||||
it("rejects unknown combinations", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
resolveStandardFont(
|
||||
"Bogus" as unknown as Parameters<typeof resolveStandardFont>[0]
|
||||
)
|
||||
).toThrow(PdfError);
|
||||
});
|
||||
|
||||
it("emits PDF.FONT_UNKNOWN code on unknown", { timeout: 5000 }, () => {
|
||||
try {
|
||||
resolveStandardFont(
|
||||
"Bogus" as unknown as Parameters<typeof resolveStandardFont>[0]
|
||||
);
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(PdfError);
|
||||
expect((err as PdfError).code).toBe(PDF_CODES.FONT_UNKNOWN);
|
||||
}
|
||||
});
|
||||
});
|
||||
264
packages/devtools/docs/pdf/src/text-element/text-element.ts
Normal file
264
packages/devtools/docs/pdf/src/text-element/text-element.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import z from "zod";
|
||||
import { hexToPdfColor, isTransparent } from "../colors/colors.js";
|
||||
import { ContentStreamEncoder } from "../content-stream/content-stream.js";
|
||||
import { PDF_CODES, PdfError } from "../error-codes/error-codes.js";
|
||||
import { STANDARD_FONTS } from "../pdf-writer/pdf-writer.js";
|
||||
import { parseOrThrow } from "../schemas/schemas.js";
|
||||
import type {
|
||||
ElementMeasurement,
|
||||
ElementRenderContext,
|
||||
ElementRenderResult,
|
||||
PdfFontFamily,
|
||||
PdfFontStyle,
|
||||
PdfFontWeight,
|
||||
PdfTextAlign,
|
||||
TextElementOptions,
|
||||
} from "./text-element.types.js";
|
||||
|
||||
/**
|
||||
* Zod schema for {@link TextElementOptions}. Validated at the
|
||||
* `TextElement` constructor boundary so callers receive a typed
|
||||
* `PDF.INVALID_ELEMENT` error rather than runtime undefined-access.
|
||||
*/
|
||||
export const TextElementOptionsSchema = z.object({
|
||||
content: z.string(),
|
||||
fontSize: z.number().positive().optional(),
|
||||
fontFamily: z
|
||||
.enum(["Helvetica", "Times-Roman", "Courier", "Symbol", "ZapfDingbats"])
|
||||
.optional(),
|
||||
fontWeight: z.enum(["normal", "bold"]).optional(),
|
||||
fontStyle: z.enum(["normal", "italic"]).optional(),
|
||||
color: z.string().optional(),
|
||||
lineHeight: z.number().positive().optional(),
|
||||
align: z.enum(["left", "center", "right", "justify"]).optional(),
|
||||
maxLines: z.number().int().positive().optional(),
|
||||
kerning: z.number().optional(),
|
||||
});
|
||||
|
||||
const DEFAULT_FONT_SIZE = 12;
|
||||
const DEFAULT_LINE_HEIGHT = 1.2;
|
||||
const DEFAULT_COLOR = "#000000";
|
||||
const AVG_CHAR_WIDTH_RATIO = 0.5;
|
||||
|
||||
/**
|
||||
* Resolve a `(family, weight, style)` triple to one of the 14 standard
|
||||
* PDF font names. Falls back to the requested family when no
|
||||
* weight/style combo exists (e.g. `Symbol` has no bold variant).
|
||||
*/
|
||||
export function resolveStandardFont(
|
||||
family: PdfFontFamily = "Helvetica",
|
||||
weight: PdfFontWeight = "normal",
|
||||
style: PdfFontStyle = "normal"
|
||||
): string {
|
||||
const candidates: Record<string, string> = {
|
||||
"Helvetica-normal-normal": "Helvetica",
|
||||
"Helvetica-bold-normal": "Helvetica-Bold",
|
||||
"Helvetica-normal-italic": "Helvetica-Oblique",
|
||||
"Helvetica-bold-italic": "Helvetica-BoldOblique",
|
||||
"Times-Roman-normal-normal": "Times-Roman",
|
||||
"Times-Roman-bold-normal": "Times-Bold",
|
||||
"Times-Roman-normal-italic": "Times-Italic",
|
||||
"Times-Roman-bold-italic": "Times-BoldItalic",
|
||||
"Courier-normal-normal": "Courier",
|
||||
"Courier-bold-normal": "Courier-Bold",
|
||||
"Courier-normal-italic": "Courier-Oblique",
|
||||
"Courier-bold-italic": "Courier-BoldOblique",
|
||||
"Symbol-normal-normal": "Symbol",
|
||||
"ZapfDingbats-normal-normal": "ZapfDingbats",
|
||||
};
|
||||
const key = `${family}-${weight}-${style}`;
|
||||
const candidate = candidates[key] ?? family;
|
||||
if (!STANDARD_FONTS.has(candidate)) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.FONT_UNKNOWN,
|
||||
`unknown font combination: ${key}`
|
||||
);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap `text` into lines that fit within `maxWidth` at `fontSize`.
|
||||
*
|
||||
* Uses a deterministic average-glyph approximation (`0.5em`) since the
|
||||
* standard 14 font metrics are not embedded in PDF readers. Newlines
|
||||
* in the input force a paragraph break.
|
||||
*/
|
||||
export function wrapText(
|
||||
text: string,
|
||||
fontSize: number,
|
||||
maxWidth: number
|
||||
): string[] {
|
||||
if (maxWidth <= 0) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.LAYOUT_OVERFLOW,
|
||||
"wrapText requires positive maxWidth"
|
||||
);
|
||||
}
|
||||
const charWidth = fontSize * AVG_CHAR_WIDTH_RATIO;
|
||||
const charsPerLine = Math.max(1, Math.floor(maxWidth / charWidth));
|
||||
|
||||
const paragraphs = text.split("\n");
|
||||
const out: string[] = [];
|
||||
|
||||
for (const para of paragraphs) {
|
||||
if (para.length === 0) {
|
||||
out.push("");
|
||||
continue;
|
||||
}
|
||||
const words = para.split(" ");
|
||||
let current = "";
|
||||
for (const word of words) {
|
||||
const candidate = current ? `${current} ${word}` : word;
|
||||
if (candidate.length > charsPerLine && current) {
|
||||
out.push(current);
|
||||
current = word;
|
||||
} else if (candidate.length > charsPerLine) {
|
||||
// word longer than the line — hard split
|
||||
let remaining = candidate;
|
||||
while (remaining.length > charsPerLine) {
|
||||
out.push(remaining.slice(0, charsPerLine));
|
||||
remaining = remaining.slice(charsPerLine);
|
||||
}
|
||||
current = remaining;
|
||||
} else {
|
||||
current = candidate;
|
||||
}
|
||||
}
|
||||
// Every non-empty paragraph leaves `current` non-empty by the
|
||||
// loop invariant; push it as the paragraph's last line.
|
||||
out.push(current);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Measure the approximate rendered width of `line` at `fontSize`. */
|
||||
export function measureLineWidth(line: string, fontSize: number): number {
|
||||
return line.length * fontSize * AVG_CHAR_WIDTH_RATIO;
|
||||
}
|
||||
|
||||
/**
|
||||
* High-level text element. Wraps the input string, applies alignment,
|
||||
* resolves the standard PDF font, and emits a content-stream snippet
|
||||
* suitable for `PdfWriter.addStream`.
|
||||
*/
|
||||
export class TextElement {
|
||||
readonly options: Readonly<TextElementOptions>;
|
||||
|
||||
constructor(options: TextElementOptions) {
|
||||
this.options = parseOrThrow(
|
||||
TextElementOptionsSchema,
|
||||
options,
|
||||
PDF_CODES.INVALID_ELEMENT,
|
||||
"invalid TextElement options"
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolved standard PDF font name for the configured options. */
|
||||
fontName(): string {
|
||||
return resolveStandardFont(
|
||||
this.options.fontFamily,
|
||||
this.options.fontWeight,
|
||||
this.options.fontStyle
|
||||
);
|
||||
}
|
||||
|
||||
/** Measure the element at the given max width. */
|
||||
measure(maxWidth: number): ElementMeasurement {
|
||||
const fontSize = this.options.fontSize ?? DEFAULT_FONT_SIZE;
|
||||
const lines = this.computeLines(maxWidth);
|
||||
const lineHeight =
|
||||
fontSize * (this.options.lineHeight ?? DEFAULT_LINE_HEIGHT);
|
||||
const widest = lines.reduce(
|
||||
(acc, line) => Math.max(acc, measureLineWidth(line, fontSize)),
|
||||
0
|
||||
);
|
||||
return {
|
||||
width: Math.min(widest, maxWidth),
|
||||
height: lines.length * lineHeight,
|
||||
lineCount: lines.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Render the element into a PDF content-stream string. */
|
||||
render(context: ElementRenderContext): ElementRenderResult {
|
||||
if (context.signal?.aborted) {
|
||||
throw new PdfError(PDF_CODES.ABORTED, "TextElement.render aborted");
|
||||
}
|
||||
if (!context.fontResourceName) {
|
||||
throw new PdfError(
|
||||
PDF_CODES.RENDER_FAILED,
|
||||
"TextElement.render requires fontResourceName"
|
||||
);
|
||||
}
|
||||
const fontSize = this.options.fontSize ?? DEFAULT_FONT_SIZE;
|
||||
const lineHeight =
|
||||
fontSize * (this.options.lineHeight ?? DEFAULT_LINE_HEIGHT);
|
||||
const color = this.options.color ?? DEFAULT_COLOR;
|
||||
const align: PdfTextAlign = this.options.align ?? "left";
|
||||
const lines = this.computeLines(context.maxWidth);
|
||||
|
||||
const encoder = new ContentStreamEncoder();
|
||||
encoder.saveGraphicsState();
|
||||
if (!isTransparent(color)) {
|
||||
encoder.setFillColor(hexToPdfColor(color));
|
||||
}
|
||||
encoder.beginText();
|
||||
encoder.setFont(context.fontResourceName, fontSize);
|
||||
if (this.options.kerning !== undefined && this.options.kerning !== 0) {
|
||||
encoder.appendRaw(`${this.options.kerning} Tc`);
|
||||
}
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i] as string;
|
||||
const lineX = computeAlignedX(
|
||||
line,
|
||||
fontSize,
|
||||
context.maxWidth,
|
||||
context.x,
|
||||
align
|
||||
);
|
||||
const lineY = context.y - (i + 1) * lineHeight;
|
||||
encoder.moveTextCursor(lineX, lineY);
|
||||
encoder.showText(line);
|
||||
if (i < lines.length - 1) {
|
||||
encoder.moveTextCursor(-lineX, -lineY);
|
||||
}
|
||||
}
|
||||
encoder.endText();
|
||||
encoder.restoreGraphicsState();
|
||||
|
||||
return {
|
||||
stream: encoder.encode(),
|
||||
height: lines.length * lineHeight,
|
||||
width: context.maxWidth,
|
||||
overflowed: this.didOverflow(context.maxWidth),
|
||||
};
|
||||
}
|
||||
|
||||
private computeLines(maxWidth: number): string[] {
|
||||
const fontSize = this.options.fontSize ?? DEFAULT_FONT_SIZE;
|
||||
const wrapped = wrapText(this.options.content, fontSize, maxWidth);
|
||||
const max = this.options.maxLines ?? 0;
|
||||
return max > 0 ? wrapped.slice(0, max) : wrapped;
|
||||
}
|
||||
|
||||
private didOverflow(maxWidth: number): boolean {
|
||||
const fontSize = this.options.fontSize ?? DEFAULT_FONT_SIZE;
|
||||
const wrapped = wrapText(this.options.content, fontSize, maxWidth);
|
||||
const max = this.options.maxLines ?? 0;
|
||||
return max > 0 && wrapped.length > max;
|
||||
}
|
||||
}
|
||||
|
||||
function computeAlignedX(
|
||||
line: string,
|
||||
fontSize: number,
|
||||
maxWidth: number,
|
||||
baseX: number,
|
||||
align: PdfTextAlign
|
||||
): number {
|
||||
if (align === "left" || align === "justify") return baseX;
|
||||
const textWidth = measureLineWidth(line, fontSize);
|
||||
if (align === "center") return baseX + (maxWidth - textWidth) / 2;
|
||||
return baseX + maxWidth - textWidth;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Public types for the {@link TextElement} feature.
|
||||
*
|
||||
* `TextElement` renders flowed, word-wrapped text using one of the
|
||||
* 14 standard PDF fonts. The shape mirrors the surface used by the
|
||||
* downstream layout engine (Chunk D) and the document builder so the
|
||||
* element can be composed without re-defining option bundles.
|
||||
*/
|
||||
|
||||
import type { PageOrientation } from "../types/page-size.js";
|
||||
|
||||
/** Horizontal alignment for text + table cells. */
|
||||
export type PdfTextAlign = "left" | "center" | "right" | "justify";
|
||||
|
||||
/** Font weight selector for the standard 14 PDF fonts. */
|
||||
export type PdfFontWeight = "normal" | "bold";
|
||||
|
||||
/** Font style selector for the standard 14 PDF fonts. */
|
||||
export type PdfFontStyle = "normal" | "italic";
|
||||
|
||||
/** Standard PDF font families (the 14 base fonts ship with every reader). */
|
||||
export type PdfFontFamily =
|
||||
| "Helvetica"
|
||||
| "Times-Roman"
|
||||
| "Courier"
|
||||
| "Symbol"
|
||||
| "ZapfDingbats";
|
||||
|
||||
/** Options accepted by {@link TextElement}. */
|
||||
export interface TextElementOptions {
|
||||
readonly content: string;
|
||||
readonly fontSize?: number | undefined;
|
||||
readonly fontFamily?: PdfFontFamily | undefined;
|
||||
readonly fontWeight?: PdfFontWeight | undefined;
|
||||
readonly fontStyle?: PdfFontStyle | undefined;
|
||||
readonly color?: string | undefined;
|
||||
readonly lineHeight?: number | undefined;
|
||||
readonly align?: PdfTextAlign | undefined;
|
||||
readonly maxLines?: number | undefined;
|
||||
readonly kerning?: number | undefined;
|
||||
}
|
||||
|
||||
/** Result returned by an element's `measure()` call. */
|
||||
export interface ElementMeasurement {
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly lineCount?: number | undefined;
|
||||
}
|
||||
|
||||
/** Options accepted by every element's `render()` call. */
|
||||
export interface ElementRenderContext {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly maxWidth: number;
|
||||
readonly maxHeight?: number | undefined;
|
||||
readonly fontResourceName?: string | undefined;
|
||||
readonly orientation?: PageOrientation | undefined;
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
}
|
||||
|
||||
/** Result returned by an element's `render()` call. */
|
||||
export interface ElementRenderResult {
|
||||
readonly stream: string;
|
||||
readonly height: number;
|
||||
readonly width: number;
|
||||
readonly overflowed?: boolean | undefined;
|
||||
}
|
||||
15
packages/devtools/docs/pdf/src/types/index.ts
Normal file
15
packages/devtools/docs/pdf/src/types/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export type {
|
||||
DocumentDefinition,
|
||||
DocumentMetadata,
|
||||
ElementNode,
|
||||
Margins,
|
||||
PageConfig,
|
||||
PageDefinition,
|
||||
PageOrientation,
|
||||
} from "./page-size.js";
|
||||
export {
|
||||
DEFAULT_MARGINS,
|
||||
DEFAULT_PAGE_CONFIG,
|
||||
PAGE_DIMENSIONS,
|
||||
PageSize,
|
||||
} from "./page-size.js";
|
||||
86
packages/devtools/docs/pdf/src/types/page-size.md
Normal file
86
packages/devtools/docs/pdf/src/types/page-size.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# `page-size`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the page-size + page-config primitives used across every other
|
||||
module in `@suaveplan/pdf`. PDF measurements are expressed in **points**
|
||||
where one point equals one seventy-second of an inch (`1pt = 1/72 in`),
|
||||
so a US Letter page is `612pt × 792pt` and an A4 page is
|
||||
`595.28pt × 841.89pt`. The module is split out of `types/` because it
|
||||
carries runtime exports (the `PageSize` enum and the `PAGE_DIMENSIONS`
|
||||
lookup table) in addition to compile-time-only types, and it is imported
|
||||
by the renderer + every template.
|
||||
|
||||
## Features
|
||||
|
||||
- `PageSize` enum with five presets: `A4`, `A3`, `A5`, `LETTER`, `LEGAL`.
|
||||
- `PAGE_DIMENSIONS` constant — width × height in points for each preset.
|
||||
- `PageOrientation` string union (`"portrait" | "landscape"`).
|
||||
- `Margins` and `DEFAULT_MARGINS` (one-inch / 72pt all round).
|
||||
- `DocumentMetadata`, `PageConfig`, `DEFAULT_PAGE_CONFIG`,
|
||||
`DocumentDefinition`, `PageDefinition`, and the polymorphic
|
||||
`ElementNode` discriminator type used by the future renderer.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
DEFAULT_MARGINS,
|
||||
PAGE_DIMENSIONS,
|
||||
PageSize,
|
||||
} from "@suaveplan/pdf/types";
|
||||
|
||||
const a4 = PAGE_DIMENSIONS[PageSize.A4];
|
||||
console.log(`A4 is ${a4.width}pt × ${a4.height}pt`);
|
||||
console.log(`Default margins: ${DEFAULT_MARGINS.top}pt`);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Custom page sizes are expressed as plain `PageConfig` literals — the
|
||||
renderer treats anything matching the shape uniformly, so US `Tabloid`
|
||||
(`792pt × 1224pt`) or a custom envelope is one structural step away
|
||||
from the presets.
|
||||
|
||||
```ts
|
||||
import type { PageConfig } from "@suaveplan/pdf/types";
|
||||
import { PageSize } from "@suaveplan/pdf/types";
|
||||
|
||||
const tabloid: PageConfig = {
|
||||
size: PageSize.LETTER, // treat as nearest preset for downstream code
|
||||
orientation: "landscape",
|
||||
margins: { top: 36, right: 36, bottom: 36, left: 36 },
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `enum PageSize { A4, A3, A5, LETTER, LEGAL }` — discriminator for the
|
||||
built-in presets.
|
||||
- `PAGE_DIMENSIONS: Record<PageSize, { width: number; height: number }>`
|
||||
— width × height in points for each preset.
|
||||
- `type PageOrientation = "portrait" | "landscape"`.
|
||||
- `interface Margins { top, right, bottom, left: number }` (all in points).
|
||||
- `const DEFAULT_MARGINS: Margins` — `{ 72, 72, 72, 72 }`.
|
||||
- `interface DocumentMetadata { title?, author?, subject?, keywords?, creator?, creationDate? }`.
|
||||
- `interface PageConfig { size, orientation, margins }`.
|
||||
- `const DEFAULT_PAGE_CONFIG: PageConfig` — `A4` portrait with default margins.
|
||||
- `interface DocumentDefinition<TData = unknown> { metadata, defaultPage, pages, data? }`.
|
||||
- `interface PageDefinition { config?, elements }`.
|
||||
- `interface ElementNode { type, props, children? }` — discriminated by `type`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Dimensions are stored to two decimal places (the value at which ISO A
|
||||
sizes round in points); the renderer uses `fmt(n)` to emit them with
|
||||
at most four decimals which is the PDF reader's expected precision.
|
||||
- `PAGE_DIMENSIONS` is typed `Record<PageSize, …>` and not `as const` so
|
||||
the lookup is type-safe even when the consumer holds a `PageSize`
|
||||
value from a string parse.
|
||||
- `ElementNode` is intentionally permissive (`props: Record<string,
|
||||
unknown>`) at the type layer; the concrete element shape is enforced
|
||||
by Zod schemas at the public boundary in Chunk C.
|
||||
73
packages/devtools/docs/pdf/src/types/page-size.test.ts
Normal file
73
packages/devtools/docs/pdf/src/types/page-size.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
DEFAULT_MARGINS,
|
||||
DEFAULT_PAGE_CONFIG,
|
||||
PAGE_DIMENSIONS,
|
||||
PageSize,
|
||||
} from "./page-size.js";
|
||||
|
||||
describe("PageSize + PAGE_DIMENSIONS", () => {
|
||||
it("A4 dimensions", { timeout: 5000 }, () => {
|
||||
expect(PAGE_DIMENSIONS[PageSize.A4]).toEqual({
|
||||
width: 595.28,
|
||||
height: 841.89,
|
||||
});
|
||||
});
|
||||
|
||||
it("A3 dimensions", { timeout: 5000 }, () => {
|
||||
expect(PAGE_DIMENSIONS[PageSize.A3]).toEqual({
|
||||
width: 841.89,
|
||||
height: 1190.55,
|
||||
});
|
||||
});
|
||||
|
||||
it("A5 dimensions", { timeout: 5000 }, () => {
|
||||
expect(PAGE_DIMENSIONS[PageSize.A5]).toEqual({
|
||||
width: 419.53,
|
||||
height: 595.28,
|
||||
});
|
||||
});
|
||||
|
||||
it("LETTER dimensions", { timeout: 5000 }, () => {
|
||||
expect(PAGE_DIMENSIONS[PageSize.LETTER]).toEqual({
|
||||
width: 612,
|
||||
height: 792,
|
||||
});
|
||||
});
|
||||
|
||||
it("LEGAL dimensions", { timeout: 5000 }, () => {
|
||||
expect(PAGE_DIMENSIONS[PageSize.LEGAL]).toEqual({
|
||||
width: 612,
|
||||
height: 1008,
|
||||
});
|
||||
});
|
||||
|
||||
it("every PageSize has dimensions", { timeout: 5000 }, () => {
|
||||
for (const size of Object.values(PageSize)) {
|
||||
const dims = PAGE_DIMENSIONS[size];
|
||||
expect(dims.width).toBeGreaterThan(0);
|
||||
expect(dims.height).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("DEFAULT_MARGINS", () => {
|
||||
it("DEFAULT_MARGINS is one inch", { timeout: 5000 }, () => {
|
||||
expect(DEFAULT_MARGINS).toEqual({
|
||||
top: 72,
|
||||
right: 72,
|
||||
bottom: 72,
|
||||
left: 72,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DEFAULT_PAGE_CONFIG", () => {
|
||||
it("is A4 portrait with one-inch margins", { timeout: 5000 }, () => {
|
||||
expect(DEFAULT_PAGE_CONFIG).toEqual({
|
||||
size: PageSize.A4,
|
||||
orientation: "portrait",
|
||||
margins: DEFAULT_MARGINS,
|
||||
});
|
||||
});
|
||||
});
|
||||
96
packages/devtools/docs/pdf/src/types/page-size.ts
Normal file
96
packages/devtools/docs/pdf/src/types/page-size.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Page-size + page-config types and constants for `@suaveplan/pdf`.
|
||||
*
|
||||
* PDF measurements are in points where `1pt = 1/72 inch`. The five page-size
|
||||
* presets here are the ones we ship templates for; consumers needing a
|
||||
* custom size can compose a `PageConfig` literal directly.
|
||||
*/
|
||||
|
||||
/** Standard page size presets in points (`1pt = 1/72 inch`). */
|
||||
export enum PageSize {
|
||||
A4 = "A4",
|
||||
A3 = "A3",
|
||||
A5 = "A5",
|
||||
LETTER = "LETTER",
|
||||
LEGAL = "LEGAL",
|
||||
}
|
||||
|
||||
/** Page size dimensions in points. */
|
||||
export const PAGE_DIMENSIONS: Record<
|
||||
PageSize,
|
||||
{ readonly width: number; readonly height: number }
|
||||
> = {
|
||||
[PageSize.A4]: { width: 595.28, height: 841.89 },
|
||||
[PageSize.A3]: { width: 841.89, height: 1190.55 },
|
||||
[PageSize.A5]: { width: 419.53, height: 595.28 },
|
||||
[PageSize.LETTER]: { width: 612, height: 792 },
|
||||
[PageSize.LEGAL]: { width: 612, height: 1008 },
|
||||
};
|
||||
|
||||
/** Page orientation. */
|
||||
export type PageOrientation = "portrait" | "landscape";
|
||||
|
||||
/** Margin specification in points. */
|
||||
export interface Margins {
|
||||
readonly top: number;
|
||||
readonly right: number;
|
||||
readonly bottom: number;
|
||||
readonly left: number;
|
||||
}
|
||||
|
||||
/** Default page margins (`72pt = 1 inch`). */
|
||||
export const DEFAULT_MARGINS: Margins = {
|
||||
top: 72,
|
||||
right: 72,
|
||||
bottom: 72,
|
||||
left: 72,
|
||||
};
|
||||
|
||||
/** Metadata for the PDF document (PDF `/Info` dictionary). */
|
||||
export interface DocumentMetadata {
|
||||
readonly title?: string;
|
||||
readonly author?: string;
|
||||
readonly subject?: string;
|
||||
readonly keywords?: ReadonlyArray<string>;
|
||||
readonly creator?: string;
|
||||
readonly creationDate?: Date;
|
||||
}
|
||||
|
||||
/** Configuration for a single page in the document. */
|
||||
export interface PageConfig {
|
||||
readonly size: PageSize;
|
||||
readonly orientation: PageOrientation;
|
||||
readonly margins: Margins;
|
||||
}
|
||||
|
||||
/** Default page configuration (A4 portrait, one-inch margins). */
|
||||
export const DEFAULT_PAGE_CONFIG: PageConfig = {
|
||||
size: PageSize.A4,
|
||||
orientation: "portrait",
|
||||
margins: DEFAULT_MARGINS,
|
||||
};
|
||||
|
||||
/** A document definition containing all pages and metadata. */
|
||||
export interface DocumentDefinition<TData = unknown> {
|
||||
readonly metadata: DocumentMetadata;
|
||||
readonly defaultPage: PageConfig;
|
||||
readonly pages: ReadonlyArray<PageDefinition>;
|
||||
readonly data?: TData;
|
||||
}
|
||||
|
||||
/** A single page definition containing its elements. */
|
||||
export interface PageDefinition {
|
||||
readonly config?: Partial<PageConfig>;
|
||||
readonly elements: ReadonlyArray<ElementNode>;
|
||||
}
|
||||
|
||||
/**
|
||||
* An element node in the document tree. The discriminator is `type`; the
|
||||
* concrete element kinds (`"text"`, `"table"`, `"image"`, `"divider"`)
|
||||
* land in Chunk C.
|
||||
*/
|
||||
export interface ElementNode {
|
||||
readonly type: string;
|
||||
readonly props: Record<string, unknown>;
|
||||
readonly children?: ReadonlyArray<ElementNode>;
|
||||
}
|
||||
8
packages/devtools/docs/pdf/src/units/index.ts
Normal file
8
packages/devtools/docs/pdf/src/units/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type { Unit } from "./units.js";
|
||||
export {
|
||||
convert,
|
||||
formatUnit,
|
||||
fromPoints,
|
||||
parseUnit,
|
||||
toPoints,
|
||||
} from "./units.js";
|
||||
91
packages/devtools/docs/pdf/src/units/units.md
Normal file
91
packages/devtools/docs/pdf/src/units/units.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# `units`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Unit-conversion utilities for `@suaveplan/pdf`. PDF's native measurement
|
||||
is the **point** (`1pt = 1/72 inch`); every coordinate, font-size, line
|
||||
width, and margin passed to the writer must ultimately be in points.
|
||||
This module lets callers express measurements in any of the five common
|
||||
units (`pt`, `mm`, `cm`, `in`, `px`) and converts to and from points
|
||||
deterministically. All failures surface as `PdfError(PDF_CODES.INVALID_UNIT)`
|
||||
so consumers can discriminate programmatically.
|
||||
|
||||
## Features
|
||||
|
||||
- `toPoints(value, unit)` — convert any supported unit to points.
|
||||
- `fromPoints(points, unit)` — inverse of the above.
|
||||
- `convert(value, from, to)` — pairwise conversion between any two units;
|
||||
identity short-circuits to avoid round-trip floating-point drift.
|
||||
- `parseUnit(string)` — parse `"10mm"`, `"2.5in"`, `"72pt"`, `"100px"`,
|
||||
plain `"42"`, with surrounding whitespace and negative values supported.
|
||||
- `formatUnit(points, unit, decimals)` — format a point value with a
|
||||
unit suffix and a configurable decimal-place count.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { convert, parseUnit, toPoints } from "@suaveplan/pdf/units";
|
||||
|
||||
const inchInPoints = toPoints(1, "in"); // 72
|
||||
const mmFromInches = convert(1, "in", "mm"); // ≈ 25.4
|
||||
const fromString = parseUnit("10mm"); // ≈ 28.346
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
`formatUnit` is useful for emitting human-readable margin / padding
|
||||
strings in design-time debug output, while `parseUnit` is the canonical
|
||||
entry point for accepting user-supplied measurements from a config
|
||||
file or HTTP request body:
|
||||
|
||||
```ts
|
||||
import { formatUnit, parseUnit } from "@suaveplan/pdf/units";
|
||||
import { PDF_CODES, PdfError } from "@suaveplan/pdf/error-codes";
|
||||
|
||||
function loadMarginPoints(raw: string): number {
|
||||
try {
|
||||
return parseUnit(raw);
|
||||
} catch (err) {
|
||||
if (err instanceof PdfError && err.code === PDF_CODES.INVALID_UNIT) {
|
||||
// …fall back to default
|
||||
return 72;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Round-trip with controlled precision:
|
||||
const display = formatUnit(parseUnit("1in"), "mm", 4); // "25.4000mm"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type Unit = "pt" | "mm" | "cm" | "in" | "px"` — the five supported
|
||||
unit identifiers.
|
||||
- `toPoints(value: number, unit?: Unit): number` — defaults to `"pt"`.
|
||||
- `fromPoints(points: number, unit?: Unit): number` — defaults to `"pt"`.
|
||||
- `convert(value: number, from: Unit, to: Unit): number`.
|
||||
- `parseUnit(input: string): number` — returns points; throws `PdfError`
|
||||
with `code === PDF_CODES.INVALID_UNIT` on bad input.
|
||||
- `formatUnit(points: number, unit?: Unit, decimals?: number): string`
|
||||
— defaults to `"pt"` and `2` decimals.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The conversion table is a `Record<Unit, number>`; the `factorFor`
|
||||
helper validates the runtime input even when TypeScript would accept
|
||||
it (defensive cast paths from JSON / form data).
|
||||
- `convert(x, u, u)` short-circuits to avoid `(x * f) / f` floating-point
|
||||
drift, which is observable for non-power-of-two conversions like
|
||||
`convert(0.1, "in", "in")`.
|
||||
- `parseUnit`'s regex `^(-?\d+(?:\.\d+)?)\s*(pt|mm|cm|in|px)?$` accepts
|
||||
optional unit suffix, an optional sign, and optional whitespace
|
||||
between the value and the suffix. Bare numbers (no suffix) are
|
||||
treated as points to align with PDF's native measurement.
|
||||
- No `Intl.NumberFormat` is used in `formatUnit` — the suffix is fixed
|
||||
and `toFixed` is locale-free, which matters for downstream byte-level
|
||||
PDF comparison testing.
|
||||
126
packages/devtools/docs/pdf/src/units/units.test.ts
Normal file
126
packages/devtools/docs/pdf/src/units/units.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PDF_CODES, PdfError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
convert,
|
||||
formatUnit,
|
||||
fromPoints,
|
||||
parseUnit,
|
||||
toPoints,
|
||||
} from "./units.js";
|
||||
|
||||
describe("toPoints", () => {
|
||||
it("treats pt as identity", { timeout: 5000 }, () => {
|
||||
expect(toPoints(42)).toBe(42);
|
||||
expect(toPoints(42, "pt")).toBe(42);
|
||||
});
|
||||
|
||||
it("converts inches to points", { timeout: 5000 }, () => {
|
||||
expect(toPoints(1, "in")).toBe(72);
|
||||
expect(toPoints(2, "in")).toBe(144);
|
||||
});
|
||||
|
||||
it("converts millimetres to points", { timeout: 5000 }, () => {
|
||||
expect(toPoints(25.4, "mm")).toBeCloseTo(72, 9);
|
||||
});
|
||||
|
||||
it("converts centimetres to points", { timeout: 5000 }, () => {
|
||||
expect(toPoints(2.54, "cm")).toBeCloseTo(72, 9);
|
||||
});
|
||||
|
||||
it("converts pixels to points", { timeout: 5000 }, () => {
|
||||
expect(toPoints(96, "px")).toBe(72);
|
||||
});
|
||||
|
||||
it("rejects unknown unit at runtime", { timeout: 5000 }, () => {
|
||||
try {
|
||||
toPoints(1, "fathom" as unknown as "pt");
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(PdfError);
|
||||
expect((err as PdfError).code).toBe(PDF_CODES.INVALID_UNIT);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromPoints", () => {
|
||||
it("treats pt as identity", { timeout: 5000 }, () => {
|
||||
expect(fromPoints(42)).toBe(42);
|
||||
});
|
||||
|
||||
it("converts points to millimetres", { timeout: 5000 }, () => {
|
||||
expect(fromPoints(72, "mm")).toBeCloseTo(25.4, 9);
|
||||
});
|
||||
|
||||
it("rejects unknown unit", { timeout: 5000 }, () => {
|
||||
expect(() => fromPoints(72, "fathom" as unknown as "pt")).toThrow(
|
||||
PdfError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("convert", () => {
|
||||
it("returns input unchanged when units match", { timeout: 5000 }, () => {
|
||||
expect(convert(42, "mm", "mm")).toBe(42);
|
||||
});
|
||||
|
||||
it("converts inches to millimetres", { timeout: 5000 }, () => {
|
||||
expect(convert(1, "in", "mm")).toBeCloseTo(25.4, 9);
|
||||
});
|
||||
|
||||
it("round-trips px → pt → px", { timeout: 5000 }, () => {
|
||||
expect(convert(96, "px", "px")).toBe(96);
|
||||
expect(convert(96, "px", "pt")).toBe(72);
|
||||
expect(convert(72, "pt", "px")).toBe(96);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseUnit", () => {
|
||||
it("parses bare number as points", { timeout: 5000 }, () => {
|
||||
expect(parseUnit("42")).toBe(42);
|
||||
});
|
||||
|
||||
it("parses cm suffix", { timeout: 5000 }, () => {
|
||||
expect(parseUnit("2.5cm")).toBeCloseTo(toPoints(2.5, "cm"), 9);
|
||||
});
|
||||
|
||||
it("parses in suffix", { timeout: 5000 }, () => {
|
||||
expect(parseUnit("2in")).toBe(144);
|
||||
});
|
||||
|
||||
it("parses with whitespace", { timeout: 5000 }, () => {
|
||||
expect(parseUnit(" 10 mm ")).toBeCloseTo(toPoints(10, "mm"), 9);
|
||||
});
|
||||
|
||||
it("parses negative values", { timeout: 5000 }, () => {
|
||||
expect(parseUnit("-1in")).toBe(-72);
|
||||
});
|
||||
|
||||
it("rejects garbage", { timeout: 5000 }, () => {
|
||||
try {
|
||||
parseUnit("nope");
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(PdfError);
|
||||
expect((err as PdfError).code).toBe(PDF_CODES.INVALID_UNIT);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects empty string", { timeout: 5000 }, () => {
|
||||
expect(() => parseUnit("")).toThrow(PdfError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatUnit", () => {
|
||||
it("formats with default 2 decimals + pt suffix", { timeout: 5000 }, () => {
|
||||
expect(formatUnit(72)).toBe("72.00pt");
|
||||
});
|
||||
|
||||
it("formats with mm suffix", { timeout: 5000 }, () => {
|
||||
expect(formatUnit(72, "mm")).toBe("25.40mm");
|
||||
});
|
||||
|
||||
it("respects custom decimals", { timeout: 5000 }, () => {
|
||||
expect(formatUnit(72, "mm", 4)).toBe("25.4000mm");
|
||||
expect(formatUnit(72, "mm", 0)).toBe("25mm");
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user