Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View 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.

View 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"
]
}

View 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()`.

View 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);
});
});

View 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";
}

View File

@@ -0,0 +1,11 @@
export type { PdfColor, RgbColor } from "./colors.js";
export {
COLORS,
darken,
hexToPdfColor,
hexToRgb,
isTransparent,
lighten,
rgbToHex,
rgbToPdfColor,
} from "./colors.js";

View 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.

View File

@@ -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");
});
});

View 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)}`
);
}
}

View File

@@ -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;

View File

@@ -0,0 +1,5 @@
export { ContentStreamEncoder } from "./content-stream.js";
export type {
ContentStreamColor,
ContentStreamOptions,
} from "./content-stream.types.js";

View File

@@ -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`.

View File

@@ -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,
});
});
});

View File

@@ -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,
};
}

View File

@@ -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;
}

View File

@@ -0,0 +1,10 @@
export {
computeDividerSpacing,
DIVIDER_DEFAULTS,
DividerElement,
DividerElementOptionsSchema,
} from "./divider-element.js";
export type {
DividerElementOptions,
DividerOrientation,
} from "./divider-element.types.js";

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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>();
}

View File

@@ -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,
};

View 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";

View 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.

View File

@@ -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");
});
});

View 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 });
}

View File

@@ -0,0 +1,2 @@
export type { PdfErrorCode } from "./error-codes.js";
export { asPdfError, PDF_CODES, PdfError } from "./error-codes.js";

View 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()`.

View File

@@ -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);
});
});

View 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"
);
}

View File

@@ -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;
}

View 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";

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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);
}

View File

@@ -0,0 +1 @@
export { ImageRegistry, registerImageXObject } from "./image-registry.js";

View 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";

View File

@@ -0,0 +1,8 @@
export { LayoutEngine } from "./layout-engine.js";
export type {
AdvanceResult,
LayoutBox,
LayoutCursor,
LayoutEngineOptions,
PageSlotMetrics,
} from "./layout-engine.types.js";

View File

@@ -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.

View File

@@ -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);
});
});

View 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");
}
}
}

View File

@@ -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;
}

View 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";

View 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.

View 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();
}
});
});

View 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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,7 @@
export type { PipelineState, PlacementResult } from "./render-pipeline.js";
export {
pickFontResource,
placeNode,
renderSlot,
renderTableSlice,
} from "./render-pipeline.js";

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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);
}

View File

@@ -0,0 +1,2 @@
export { renderDocument } from "./renderer.js";
export type { RenderOptions, RenderResult } from "./renderer.types.js";

View 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.

View 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;
};
}

View 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");
}
}

View 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;
}

View File

@@ -0,0 +1,10 @@
export {
DocumentMetadataSchema,
HexColorSchema,
MarginsSchema,
PageConfigSchema,
parseHexColor,
parseOrThrow,
RgbColorSchema,
UnitSchema,
} from "./schemas.js";

View 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.

View 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);
}
});
});

View 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"
);
}

View 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";

View File

@@ -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.

View File

@@ -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);
});
});

View 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,
};
}

View File

@@ -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;
}

View File

@@ -0,0 +1,10 @@
export {
computeColumnWidths,
TableElement,
TableElementOptionsSchema,
} from "./table-element.js";
export type {
TableColumn,
TableElementOptions,
TablePageRender,
} from "./table-element.types.js";

View 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.

View File

@@ -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();
}
});
});

View 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;
}

View File

@@ -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;
}

View 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";

View 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.

View 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);
});
});

View 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);
}

View 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;
}

View File

@@ -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";

View File

@@ -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.

View File

@@ -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();
}
});
});

View File

@@ -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();
}

View File

@@ -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>;

View File

@@ -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";

View File

@@ -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.

View File

@@ -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();
}
});
});

View File

@@ -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();
}

View File

@@ -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>;

View File

@@ -0,0 +1,11 @@
export {
createReportTemplate,
ReportDataSchema,
} from "./report-template.js";
export type {
ReportColumn,
ReportData,
ReportDocument,
ReportSection,
ReportTemplateOptions,
} from "./report-template.types.js";

View File

@@ -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.

View File

@@ -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();
}
});
});

View File

@@ -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();
}

View File

@@ -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>;

View 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";

View 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`.

View 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);
}
});
});

View 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;
}

View File

@@ -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;
}

View 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";

View 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.

View 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,
});
});
});

View 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>;
}

View File

@@ -0,0 +1,8 @@
export type { Unit } from "./units.js";
export {
convert,
formatUnit,
fromPoints,
parseUnit,
toPoints,
} from "./units.js";

View 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.

View 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