Files
2026-05-27 23:29:53 +01:00

805 lines
36 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- markdownlint-disable MD024 -->
# Capability Spec: pdf
**Package**: `@suaveplan/pdf`
**Status**: SHIPPED (Chunks A–E)
> Every requirement below carries a `**Verified by:**` line.
## ADDED Requirements
### Requirement: PDF error registry
The package SHALL declare `PDF_CODES` via `defineCodes("PDF", [...])` covering
`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`. A `PdfError` class SHALL extend `SuaveplanError`
and tag itself with `name === "PdfError"`. An `asPdfError(code, cause, fallback)`
helper SHALL lift unknown thrown values into `PdfError` preserving the
original cause and adopting its message when present.
#### Scenario: every code is exported
- **WHEN** the `error-codes` barrel is imported
- **THEN** `PDF_CODES.INVALID_UNIT === "PDF.INVALID_UNIT"` and every other
declared code resolves to its dotted form.
#### Scenario: PdfError preserves cause
- **WHEN** `asPdfError(PDF_CODES.RENDER_FAILED, new Error("boom"), "fallback")` is called
- **THEN** the returned error is a `PdfError`, its `code === PDF_CODES.RENDER_FAILED`,
its `message === "boom"`, and `(err.cause as Error).message === "boom"`.
**Verified by:** `src/error-codes/error-codes.test.ts::"every PDF code is registered"`
AND `src/error-codes/error-codes.test.ts::"asPdfError preserves cause"`.
---
### Requirement: Unit conversion utilities
The package SHALL provide `toPoints(value, unit)`, `fromPoints(points, unit)`,
`convert(value, from, to)`, `parseUnit(input)`, and `formatUnit(points, unit, decimals)`
with `Unit = "pt" | "mm" | "cm" | "in" | "px"`. Conversion factors SHALL be
`pt=1`, `mm=72/25.4`, `cm=72/2.54`, `in=72`, `px=72/96`. Invalid input MUST raise
`PdfError(PDF_CODES.INVALID_UNIT)`.
#### Scenario: 1 inch round-trip
- **WHEN** `convert(1, "in", "mm")` is called
- **THEN** the result is `25.4` ± `1e-9`.
#### Scenario: parseUnit accepts a hex-like prefix
- **WHEN** `parseUnit("2.5cm")` is called
- **THEN** the result equals `toPoints(2.5, "cm")`.
#### Scenario: parseUnit rejects garbage
- **WHEN** `parseUnit("nope")` is called
- **THEN** the call throws `PdfError` with `code === PDF_CODES.INVALID_UNIT`.
**Verified by:** `src/units/units.test.ts::"converts inches to millimetres"` AND
`src/units/units.test.ts::"parseUnit parses cm"` AND
`src/units/units.test.ts::"parseUnit rejects garbage"`.
---
### Requirement: Colour utilities
The package SHALL provide `hexToRgb`, `rgbToPdfColor`, `hexToPdfColor`,
`rgbToHex`, `lighten(hex, percent)`, `darken(hex, percent)`, a `COLORS`
constant bag (`black`, `white`, `red`, `green`, `blue`, `gray`, `lightGray`,
`darkGray`, `transparent`), and `isTransparent(color)`. Hex strings of length
3 or 6 with or without a leading `#` SHALL be accepted; anything else MUST
raise `PdfError(PDF_CODES.INVALID_HEX_COLOR)`.
#### Scenario: 3-char hex expands to 6-char
- **WHEN** `hexToRgb("#f00")` is called
- **THEN** the result equals `{ r: 255, g: 0, b: 0 }`.
#### Scenario: invalid hex raises typed error
- **WHEN** `hexToRgb("#zz")` is called
- **THEN** the call throws `PdfError` with `code === PDF_CODES.INVALID_HEX_COLOR`.
#### Scenario: lighten + darken are clamp-safe
- **WHEN** `lighten("#000000", 100)` is called
- **THEN** the result is `"#ffffff"`.
- **WHEN** `darken("#ffffff", 100)` is called
- **THEN** the result is `"#000000"`.
**Verified by:** `src/colors/colors.test.ts::"hexToRgb expands 3-char form"` AND
`src/colors/colors.test.ts::"hexToRgb rejects invalid"` AND
`src/colors/colors.test.ts::"lighten clamps at white"` AND
`src/colors/colors.test.ts::"darken clamps at black"`.
---
### Requirement: Zod boundary schemas
The package SHALL declare `UnitSchema`, `HexColorSchema`, `RgbColorSchema`,
`MarginsSchema`, `PageConfigSchema`, and `DocumentMetadataSchema` Zod
schemas. A `parseOrThrow(schema, value, code, message)` helper SHALL convert
Zod parse failures into `PdfError` with the supplied code, attach the Zod
issues as `cause`, and include the issue paths in `context.path`.
#### Scenario: HexColorSchema accepts canonical form
- **WHEN** `HexColorSchema.parse("#ffaa00")` is called
- **THEN** the result is `"#ffaa00"`.
#### Scenario: parseOrThrow lifts Zod errors
- **WHEN** `parseOrThrow(RgbColorSchema, { r: 300, g: 0, b: 0 }, PDF_CODES.INVALID_RGB, "bad")` is called
- **THEN** the call throws `PdfError` with `code === PDF_CODES.INVALID_RGB` and
a non-empty `(err.context as { path: unknown }).path`.
**Verified by:** `src/schemas/schemas.test.ts::"HexColorSchema accepts canonical form"`
AND `src/schemas/schemas.test.ts::"parseOrThrow lifts Zod errors"`.
---
### Requirement: OpenTelemetry surface
The package SHALL expose `createPdfObservability(opts?)` returning a struct
with four counters (`documentsRendered`, `pagesEmitted`, `renderErrors`,
`validationFailures`) and two histograms (`renderDurationMs`,
`documentSizeBytes`) on the `@suaveplan/pdf` meter, plus matching
`recordDocumentRendered`, `recordPagesEmitted`, `recordRenderError`,
`recordValidationFailure`, `recordRenderDuration`, `recordDocumentSize`
helpers. When `opts.meter` is omitted, the helpers SHALL bind to the
no-op meter from `@opentelemetry/api`.
#### Scenario: telemetry exposes six instruments
- **WHEN** `createPdfObservability()` is called
- **THEN** the returned object has every counter and histogram listed above.
#### Scenario: record helpers fire on the supplied meter
- **WHEN** `createPdfObservability({ meter })` is called with a custom meter
and `recordDocumentRendered(obs, { success: true })` follows
- **THEN** the meter's `createCounter("pdf_documents_rendered_total", ...)`
recipient observes one `add(1, { success: "true" })` event.
**Verified by:** `src/telemetry/telemetry.test.ts::"createPdfObservability exposes six instruments"`
AND `src/telemetry/telemetry.test.ts::"recordDocumentRendered fires on the supplied meter"`.
---
### Requirement: Page-size + margin types
The package SHALL export a `PageSize` enum (`A4`, `A3`, `A5`, `LETTER`,
`LEGAL`), a `PAGE_DIMENSIONS` lookup with width / height in points,
`PageOrientation = "portrait" | "landscape"`, `Margins` (top / right / bottom / left
all in points), `DEFAULT_MARGINS` (`{ top: 72, right: 72, bottom: 72, left: 72 }`),
`DocumentMetadata`, `PageConfig`, `DEFAULT_PAGE_CONFIG`, `DocumentDefinition`,
`PageDefinition`, and `ElementNode`. `PAGE_DIMENSIONS[PageSize.A4]` MUST equal
`{ width: 595.28, height: 841.89 }`.
#### Scenario: A4 dimensions are correct
- **WHEN** `PAGE_DIMENSIONS[PageSize.A4]` is read
- **THEN** it equals `{ width: 595.28, height: 841.89 }`.
#### Scenario: DEFAULT_MARGINS is one-inch all round
- **WHEN** `DEFAULT_MARGINS` is read
- **THEN** every side is `72`.
**Verified by:** `src/types/page-size.test.ts::"A4 dimensions"` AND
`src/types/page-size.test.ts::"DEFAULT_MARGINS is one inch"`.
---
### Requirement: PDF 1.4 object writer
The package SHALL provide a `PdfWriter` class that emits valid PDF 1.4 binary
output containing:
1. A header (`%PDF-1.4\n` followed by a binary-marker comment line whose body
contains four high-bit bytes so the file is detected as binary).
2. A sequence of indirect objects of every type required by PDF 1.4: `null`,
booleans, numbers, literal + hexadecimal strings, names, arrays,
dictionaries, streams (with `/Length`), and indirect references.
3. A cross-reference table (`xref`) listing the byte offset of every
indirect object, beginning with the canonical
`0000000000 65535 f` free entry.
4. A trailer dictionary referencing `/Size`, `/Root` (catalog), and
`/Info` (when document metadata is supplied), followed by
`startxref`, the xref offset, and the `%%EOF` marker.
`PdfWriter` SHALL expose:
- `allocateId()` — reserve the next free object id.
- `addObject(value)` — write any PDF value (helpers cover the eight object
types above).
- `addStream(data, filter?)` — emit a stream object; when `filter ===
"FlateDecode"` the writer SHALL compress the payload using `node:zlib`'s
`deflateSync` and tag the dictionary with `/Filter /FlateDecode`.
- `registerFont(name)` — register a standard PDF font, returning the
resource name (`F1`, `F2`, …); identical names MUST return the same
resource name (idempotent).
- `addPage({ width, height, contentStreamId, fontResources, xobjectResources? })`
— append a page object referencing a content stream and resource dictionary.
- `generate(metadata?)` — emit the complete byte stream as a `Uint8Array`,
applying the optional document metadata (`Title`/`Author`/`Subject`/
`Creator`/`Producer`) with proper escaping and the injected `Clock`'s
current time as `/CreationDate` in PDF date format
(`D:YYYYMMDDHHmmSS+0000`).
- `dispose()` — release internal state; subsequent `generate` calls MUST
raise `PdfError(PDF_CODES.WRITER_DISPOSED)`.
The writer SHALL accept an injectable `Clock` (from `@suaveplan/types`) and
an optional `AbortSignal`. When the signal aborts mid-`generate`, the call
MUST raise `PdfError(PDF_CODES.ABORTED)` and not return partial bytes.
#### Scenario: header and EOF markers
- **WHEN** any `PdfWriter` instance calls `generate()` after at least one page
- **THEN** the returned bytes start with `%PDF-1.4\n` AND end with
`%%EOF\n` AND contain the binary-marker comment.
#### Scenario: xref offsets are byte-accurate
- **WHEN** a `PdfWriter` produces a multi-object document
- **THEN** every offset in the xref table equals the byte position of the
corresponding `<id> 0 obj` opener in the output.
#### Scenario: FlateDecode compresses + inflates round-trip
- **WHEN** `addStream(payload, "FlateDecode")` is followed by `generate()`
- **THEN** the emitted stream bytes (between `stream\n` and `\nendstream`)
decompress via `node:zlib.inflateSync` back to `payload` exactly.
#### Scenario: dispose blocks further writes
- **WHEN** `dispose()` is called and `generate()` follows
- **THEN** the call throws `PdfError` with `code === PDF_CODES.WRITER_DISPOSED`.
#### Scenario: AbortSignal cancels generate
- **WHEN** the writer's `AbortSignal` is aborted before `generate()` returns
- **THEN** the call throws `PdfError` with `code === PDF_CODES.ABORTED`.
**Verified by:**
`src/pdf-writer/pdf-writer.test.ts::"emits %PDF-1.4 header and %%EOF trailer"`
AND `src/pdf-writer/pdf-writer.test.ts::"xref offsets match object byte positions"`
AND `src/pdf-writer/pdf-writer.test.ts::"FlateDecode streams round-trip via node:zlib"`
AND `src/pdf-writer/pdf-writer.test.ts::"dispose blocks further generate"`
AND `src/pdf-writer/pdf-writer.test.ts::"AbortSignal aborts generate"`.
---
### Requirement: PDF object encoder
The package SHALL provide `encodePdfValue(value)` capable of serialising a
discriminated `PdfValue` union covering `null`, booleans, numbers, literal
strings (with `\\\\`, `\\(`, `\\)` escapes), hex strings, names (with the `/`
prefix), arrays, dictionaries, and indirect references. Numbers SHALL be
formatted as integers when integral and to four decimal places otherwise;
`-0` MUST collapse to `0`. The encoder MUST be deterministic — two equal
inputs MUST produce byte-identical output.
#### Scenario: literal string escaping
- **WHEN** `encodePdfValue({ kind: "string", value: "(Hello\\World)" })` is called
- **THEN** the result equals `"(\\(Hello\\\\World\\))"`.
#### Scenario: dictionary deterministic order
- **WHEN** `encodePdfValue({ kind: "dict", entries: [["/A", ...], ["/B", ...]] })`
is called twice
- **THEN** both calls return byte-identical bytes.
**Verified by:**
`src/pdf-writer/pdf-writer.test.ts::"encodePdfValue escapes literal strings"`
AND `src/pdf-writer/pdf-writer.test.ts::"encodePdfValue is deterministic for dictionaries"`.
---
### Requirement: Content-stream encoder
The package SHALL provide a `ContentStreamEncoder` with chainable methods
covering the PDF 1.4 operators needed by the element layer:
- **Graphics state**: `saveGraphicsState()` (`q`), `restoreGraphicsState()`
(`Q`), `concatMatrix(a,b,c,d,e,f)` (`cm`), `setLineWidth(w)` (`w`).
- **Path construction + painting**: `moveTo(x,y)` (`m`), `lineTo(x,y)`
(`l`), `rectangle(x,y,w,h)` (`re`), `closePath()` (`h`), `stroke()`
(`S`), `closeAndStroke()` (`s`), `fill()` (`f`), `fillAndStroke()`
(`B`).
- **Colour**: `setFillColor(color)` (`rg`), `setStrokeColor(color)` (`RG`)
— both accept `PdfColor` (0-1 channels), validated via `RgbColorSchema`.
- **Text**: `beginText()` (`BT`), `endText()` (`ET`), `setFont(name,size)`
(`Tf`), `setTextMatrix(a,b,c,d,e,f)` (`Tm`), `moveTextCursor(tx,ty)`
(`Td`), `setLeading(leading)` (`TL`), `nextLine()` (`T*`),
`showText(str)` (`Tj`) with literal-string escaping and WinAnsi-safe
fall-through for outside-BMP bytes.
- **External objects**: `drawImage(resourceName)` (`Do`) — references an
XObject the writer registers in the page-resources dictionary.
`encode()` SHALL return the joined operator sequence (newline-separated)
as a UTF-8-safe `string` suitable for `PdfWriter.addStream`. Calls outside
text mode (`showText` without `beginText`) MUST raise
`PdfError(PDF_CODES.RENDER_FAILED)`.
#### Scenario: text-showing operator round-trips
- **WHEN** `new ContentStreamEncoder().beginText().setFont("F1", 12)
.moveTextCursor(100, 700).showText("Hello").endText().encode()` is called
- **THEN** the result contains `BT`, `/F1 12 Tf`, `100 700 Td`, `(Hello) Tj`,
and `ET` in order.
#### Scenario: showText outside text mode raises
- **WHEN** `new ContentStreamEncoder().showText("oops")` is called
- **THEN** the call throws `PdfError` with `code === PDF_CODES.RENDER_FAILED`.
#### Scenario: rectangle and fill render
- **WHEN** `new ContentStreamEncoder().setFillColor({ r: 1, g: 0, b: 0 })
.rectangle(10, 10, 100, 50).fill().encode()` is called
- **THEN** the result contains `1 0 0 rg`, `10 10 100 50 re`, and `f`.
**Verified by:**
`src/content-stream/content-stream.test.ts::"text-showing operators round-trip"`
AND `src/content-stream/content-stream.test.ts::"showText outside text mode raises"`
AND `src/content-stream/content-stream.test.ts::"rectangle + fill emits expected ops"`.
---
### Requirement: Real PDF round-trip via pdf-parse
The package SHALL produce PDFs that pass round-trip through the `pdf-parse`
reader: text written via the content-stream encoder MUST be recoverable
from the emitted bytes, and the `/Info` metadata MUST be reported.
#### Scenario: pdf-parse extracts text + metadata
- **WHEN** a `PdfWriter` emits a document containing a single page whose
content stream prints the literal `"Hello PDF"` via a Helvetica font, with
`metadata = { Title: "Round Trip", Author: "Suaveplan" }`
- **THEN** `pdf-parse` (`pdf-parse@^2.4`) reports `numpages === 1`,
`text` containing `"Hello PDF"`, and `info.Title === "Round Trip"`.
**Verified by:**
`src/pdf-writer/pdf-writer.test.ts::"pdf-parse round-trips text and metadata"`.
---
### Requirement: TextElement primitive (Chunk C)
The package SHALL expose a `TextElement` class that validates a Zod-typed
`TextElementOptions` bundle (`content`, optional `fontSize`, `fontFamily`,
`fontWeight`, `fontStyle`, `color`, `lineHeight`, `align`, `maxLines`,
`kerning`), exposes `measure(maxWidth)` returning `{ width, height,
lineCount }`, and emits a content-stream snippet via `render(context)`. The
element MUST resolve the standard PDF font through `resolveStandardFont`,
wrap content via `wrapText` (re-exported), apply alignment (`left` /
`center` / `right` / `justify`), respect `maxLines` with an `overflowed`
flag, skip the fill-colour operator when the colour is `transparent` /
`none`, and propagate `AbortSignal` via `PDF.ABORTED`.
#### Scenario: emits BT/ET around content
- **WHEN** `new TextElement({ content: "Hello", fontSize: 12, color: "#ff0000" })
.render({ x: 100, y: 700, maxWidth: 200, fontResourceName: "F1" })` is called
- **THEN** the result stream contains `BT`, `/F1 12 Tf`, `(Hello) Tj`, `ET`,
and `1 0 0 rg` in order.
#### Scenario: maxLines flags overflow
- **WHEN** a `TextElement` with `maxLines: 1` renders into a narrow width
that would otherwise wrap to multiple lines
- **THEN** the returned `overflowed` flag is `true`.
#### Scenario: aborts cooperatively
- **WHEN** `render({ ..., signal })` is called with an aborted signal
- **THEN** the call throws `PdfError` with `code === PDF_CODES.ABORTED`.
**Verified by:**
`src/text-element/text-element.test.ts::"emits BT/ET around content"`
AND `src/text-element/text-element.test.ts::"respects maxLines and reports overflow"`
AND `src/text-element/text-element.test.ts::"aborts on aborted signal"`.
---
### Requirement: TableElement primitive (Chunk C)
The package SHALL expose a `TableElement<TRow>` class that validates a
Zod-typed `TableElementOptions<TRow>` bundle (`columns`, `rows`, optional
header / row colour + background, `cellPadding`, `fontSize`,
`lineHeight`, `rowHeight`, `showHeader`, `repeatHeader`, `border`,
`borderColor`, `alternateRowBackground`), exposes `measure(maxWidth)`,
emits a single-page snippet via `render(context)`, and supports
cross-page pagination via `renderPage(context, rowStart)` returning
`{ stream, height, rowStart, rowEnd }`. Header repeat MUST be enabled by
default when `repeatHeader !== false`. The element MUST raise
`PDF.LAYOUT_OVERFLOW` when the available height cannot fit the header
itself, and propagate `AbortSignal` via `PDF.ABORTED`.
#### Scenario: paginates with header repeat
- **WHEN** `renderPage(context, rowEnd)` is called for a second page
- **THEN** the second-page stream contains the header row again.
#### Scenario: header-only overflow raises PDF.LAYOUT_OVERFLOW
- **WHEN** `render({ ..., maxHeight: 1 })` is called on a table whose
header row is taller than `1`
- **THEN** the call throws `PdfError` with
`code === PDF_CODES.LAYOUT_OVERFLOW`.
#### Scenario: column-width auto-distribution
- **WHEN** `computeColumnWidths([{ width: 100 }, {}], 300)` is called
- **THEN** the result is `[100, 200]`.
**Verified by:**
`src/table-element/table-element.test.ts::"renders consecutive pages with header repeat"`
AND `src/table-element/table-element.test.ts::"raises LAYOUT_OVERFLOW when header itself does not fit"`
AND `src/table-element/table-element.test.ts::"distributes remaining width across auto columns"`.
---
### Requirement: ImageElement primitive (Chunk C)
The package SHALL expose an `ImageElement` class that validates a Zod-typed
`ImageElementOptions` bundle (`src: Uint8Array`, `width`, `height`,
optional `format`, optional `alt`), decodes the image at construction time
via `decodeImage`, and emits a `cm`+`Do` content-stream snippet via
`render(context)`. PNG decoding MUST go through `pngjs`, separate the
alpha plane only when at least one pixel is non-opaque, and surface an
RGB primary stream. JPEG bytes MUST pass through unchanged. The element
MUST raise `PDF.IMAGE_DECODE_FAILED` for unrecognised inputs and when the
declared `format` does not match the detected magic-byte format.
`AbortSignal` MUST be propagated via `PDF.ABORTED`.
#### Scenario: detects PNG + JPEG magic bytes
- **WHEN** `detectImageFormat` is called with a PNG signature followed by
a JPEG SOI marker
- **THEN** the calls return `"png"` and `"jpeg"` respectively.
#### Scenario: PNG decode separates alpha
- **WHEN** `decodeImage` runs on a translucent PNG (alpha < 255)
- **THEN** the returned `DecodedImage.alpha` is a `Uint8Array` and the
primary `data` is the RGB plane.
#### Scenario: render emits cm + Do operators
- **WHEN** `ImageElement({ src, width: 100, height: 50 }).render({ x: 72,
y: 700, maxWidth: 460, fontResourceName: "Im0" })` is called
- **THEN** the stream contains `100 0 0 50 72 650 cm` and `/Im0 Do`.
**Verified by:**
`src/image-element/image-element.test.ts::"detects PNG signature"`
AND `src/image-element/image-element.test.ts::"captures alpha plane for translucent PNGs"`
AND `src/image-element/image-element.test.ts::"render emits the cm + Do operators"`.
---
### Requirement: DividerElement primitive (Chunk C)
The package SHALL expose a `DividerElement` class that validates a
Zod-typed `DividerElementOptions` bundle (optional `thickness`, `color`,
`orientation`, `dashPattern`, `length`, `spacingAbove`, `spacingBelow`),
exposes `measure(maxWidth, maxHeight?)`, and emits a `q`/`Q`-clean
content-stream snippet via `render(context)`. Horizontal dividers MUST
default to the supplied `maxWidth` length when no `length` is provided;
vertical dividers MUST fall back to `maxHeight` and finally to `100`.
Dash patterns MUST be applied through the PDF `d` operator. The element
MUST propagate `AbortSignal` via `PDF.ABORTED`.
#### Scenario: horizontal stroke render
- **WHEN** `new DividerElement({ color: "#ff0000", thickness: 2 })
.render({ x: 10, y: 100, maxWidth: 200 })` is called
- **THEN** the stream contains `1 0 0 RG`, `2 w`, `10 100 m`, `210 100 l`,
and `S` in order.
#### Scenario: dash pattern applies
- **WHEN** `new DividerElement({ dashPattern: [3, 2] })
.render({ x: 0, y: 100, maxWidth: 50 })` is called
- **THEN** the stream contains `[3 2] 0 d`.
#### Scenario: vertical fallback chain
- **WHEN** `new DividerElement({ orientation: "vertical" })
.render({ x: 0, y: 500, maxWidth: 50 })` is called with no `length`
and no `maxHeight`
- **THEN** the stream contains `0 400 l` (defaulting length to 100).
**Verified by:**
`src/divider-element/divider-element.test.ts::"renders a horizontal stroke"`
AND `src/divider-element/divider-element.test.ts::"applies dash pattern"`
AND `src/divider-element/divider-element.test.ts::"vertical render with neither length nor maxHeight defaults to 100"`.
---
### Requirement: StyleResolver cascade (Chunk D)
The package SHALL expose a `StyleResolver<TTheme>` class that resolves
the final `PdfStyle` from a four-layer cascade: `DEFAULT_STYLE` → active
theme → optional base layer → caller overrides. The resolver MUST be
re-entrant — `resolve` and `resolveWith` MUST not mutate the active
theme. A `withTheme(overrides)` method MUST return a child resolver
without mutating the parent. The package MUST also export the
`DEFAULT_STYLE` and `DEFAULT_THEME` constants, the `mergeStyles` /
`mergeTheme` helpers, and a `normalizeSpacing(value)` helper that
expands a `SpacingValue` (scalar or four-sided object) into the
canonical `ResolvedSpacing` shape with a `PDF.INVALID_ELEMENT` typed
error on non-finite inputs.
#### Scenario: cascade applies defaults before overrides
- **WHEN** `new StyleResolver().resolve({ fontSize: 20 })` is called
- **THEN** the returned style has `fontSize === 20`, `fontFamily ===
DEFAULT_THEME.typography.fontFamily`, and `color === DEFAULT_THEME.colors.text`.
#### Scenario: resolveWith inserts a base between theme and overrides
- **WHEN** `resolver.resolveWith({ fontSize: 18 }, { color: "#112233" })` runs
- **THEN** the returned style has `fontSize === 18` and `color === "#112233"`.
#### Scenario: normalizeSpacing rejects non-finite values
- **WHEN** `normalizeSpacing(Number.NaN)` is called
- **THEN** it throws a `PdfError` with code `PDF.INVALID_ELEMENT`.
**Verified by:**
`src/style-resolver/style-resolver.test.ts::"applies cascade DEFAULT_STYLE → theme → overrides"`
AND `src/style-resolver/style-resolver.test.ts::"resolveWith inserts a base layer between theme and overrides"`
AND `src/style-resolver/style-resolver.test.ts::"rejects non-finite scalars"`.
---
### Requirement: LayoutEngine block-flow with header/footer slots (Chunk D)
The package SHALL expose a `LayoutEngine<TConfig>` class that owns the
per-page cursor (`x`, `y`, `availableWidth`, `availableHeight`,
`pageIndex`). The engine MUST swap dimensions for landscape pages,
reserve configurable `headerHeight` + `footerHeight` slots, and report
the corresponding header / footer / content rectangles through
`getHeaderArea`, `getFooterArea`, and `getContentArea`. `advance(cursor,
height)` MUST return `{ fit, remaining }` and MUST NOT mutate the
cursor when the element does not fit. `nextPage(cursor)` MUST reset the
cursor to the top of a fresh page and bump `pageIndex`. The engine
MUST honour an injected `AbortSignal` on every flow operation via
`PDF.ABORTED`. Negative header / footer heights MUST raise
`PDF.INVALID_PAGE_CONFIG`; oversized slots MUST raise
`PDF.LAYOUT_OVERFLOW`.
#### Scenario: content area reserves slots
- **WHEN** `new LayoutEngine({ headerHeight: 40, footerHeight: 20 }).getContentArea()` is called
- **THEN** the returned area's `height` is the full page minus margins minus 40 minus 20.
#### Scenario: advance returns fit=false on overflow
- **WHEN** `advance(cursor, cursor.availableHeight + 100)` is called
- **THEN** the returned result has `fit === false`.
#### Scenario: abort propagates through createCursor
- **WHEN** the engine is constructed with an already-aborted signal and `createCursor()` is called
- **THEN** the call throws a `PdfError` with code `PDF.ABORTED`.
**Verified by:**
`src/layout-engine/layout-engine.test.ts::"getContentArea reserves header + footer slot heights"`
AND `src/layout-engine/layout-engine.test.ts::"advance returns fit=false when overflow"`
AND `src/layout-engine/layout-engine.test.ts::"aborts createCursor when signal trips"`.
---
### Requirement: DocumentBuilder fluent API (Chunk D)
The package SHALL expose a `DocumentBuilder<TData>` class plus a
`createDocument<TData>()` factory. The builder MUST support
`title` / `author` / `subject` / `keywords` / `creator` /
`creationDate` / `pageConfig` / `withData` setters; `addText` /
`addTable` / `addImage` / `addDivider` element shortcuts (each
eagerly running the element's Zod boundary validation); `newPage(config?)`
to flush + optionally declare a per-page config override;
`setHeader(nodes, height)` and `setFooter(nodes, height)` to register
repeating page furniture; and a terminal `build()` returning a
`Document<TData>` AST. `build()` MUST stamp default `creator` and
`creationDate` values when none are supplied. Negative or non-finite
slot heights MUST raise `PDF.INVALID_PAGE_CONFIG`. A `build()` call
with no pages and no flushed nodes MUST raise `PDF.INVALID_DOCUMENT`.
#### Scenario: builder stamps default creator + creationDate
- **WHEN** `createDocument().addText("hello").build()` is called
- **THEN** the returned document has `metadata.creator === "@suaveplan/pdf"`
and `metadata.creationDate instanceof Date`.
#### Scenario: setHeader rejects negative height
- **WHEN** `createDocument().setHeader([], -1)` is called
- **THEN** it throws a `PdfError` with code `PDF.INVALID_PAGE_CONFIG`.
#### Scenario: empty build throws
- **WHEN** `createDocument().build()` is called with no nodes
- **THEN** it throws a `PdfError` with code `PDF.INVALID_DOCUMENT`.
**Verified by:**
`src/document-builder/document-builder.test.ts::"stamps default creator and creationDate on build"`
AND `src/document-builder/document-builder.test.ts::"setHeader rejects negative height"`
AND `src/document-builder/document-builder.test.ts::"build with no pages throws INVALID_DOCUMENT"`.
---
### Requirement: renderDocument orchestrator (Chunk D)
The package SHALL expose a `renderDocument<TData>(doc, options?)` entry
point that converts a `Document` AST into a PDF 1.4 binary
`Uint8Array`. The orchestrator MUST: (a) drive the chunk B `PdfWriter`,
(b) call each element's `measure()` + `render()` from chunk C, (c)
flow content through the chunk D `LayoutEngine`, (d) flush + retry on
page break, (e) split oversize tables across pages via
`TableElement.renderPage`, (f) register image XObjects lazily through
an `ImageRegistry` and share them across logical pages, (g) emit
headers + footers on every physical page, (h) honour `AbortSignal`
between every node placement and every page flush, (i) emit six OTel
instruments (documents-rendered counter, pages-emitted counter,
render-errors counter tagged by code, render-duration histogram,
document-size histogram, validation-failures counter), and (j)
return `{ bytes, pageCount, durationMs }`. Empty documents MUST raise
`PDF.INVALID_DOCUMENT`. An element that cannot fit on a fresh page
MUST raise `PDF.LAYOUT_OVERFLOW`. Errors from any sub-component MUST
be wrapped in a typed `PdfError` and re-thrown.
#### Scenario: single-page hello world round-trips through pdf-parse
- **WHEN** `renderDocument(createDocument().title("Hello").addText("Hello, world.").build())` is called
- **THEN** the returned `bytes` parse via `pdf-parse`, the parsed text contains `"Hello, world."`, and `pageCount === 1`.
#### Scenario: oversize table paginates with header repeat
- **WHEN** a document with a 200-row `TableElement` (`repeatHeader: true`) is rendered
- **THEN** the returned `pageCount > 1` and pdf-parse extracts both `"row-0"` and `"row-199"`.
#### Scenario: header stamps on every physical page
- **WHEN** a document with `setHeader([{ type: "text", element: TextElement("REPEATING-HEADER") }], 40)` and 50 oversize text paragraphs is rendered
- **THEN** pdf-parse extracts `"REPEATING-HEADER"` at least once per emitted page.
#### Scenario: oversize element fails fast
- **WHEN** a document containing a single divider with `thickness: 5000` is rendered
- **THEN** `renderDocument` throws a `PdfError` with code `PDF.LAYOUT_OVERFLOW`.
#### Scenario: pre-aborted signal short-circuits
- **WHEN** `renderDocument(doc, { signal: alreadyAbortedSignal })` is called
- **THEN** it throws a `PdfError` with code `PDF.ABORTED`.
**Verified by:**
`src/renderer/renderer.test.ts::"emits valid PDF bytes for a hello-world document"`
AND `src/renderer/renderer.test.ts::"paginates an oversize table with header repeat"`
AND `src/renderer/renderer.test.ts::"renders header text on every physical page"`
AND `src/renderer/renderer.test.ts::"throws LAYOUT_OVERFLOW when an element cannot fit a fresh page"`
AND `src/renderer/renderer.test.ts::"aborts when signal trips before render"`.
---
### Requirement: Receipt template (Chunk E)
The package SHALL expose `createReceiptTemplate(data, options?)` returning a
`Document<ReceiptData>` built via `DocumentBuilder` and ready for
`renderDocument`. The factory MUST eagerly validate `data` through
`ReceiptDataSchema`; failures MUST raise `PdfError(PDF.INVALID_TEMPLATE_INPUT)`
with the failing issue paths attached as `context.path`. The receipt MUST be
laid out with merchant header + address (centred), dashed divider, receipt
number + ISO date, a 3-column `Item` / `Qty` / `Price` table, dashed totals
divider, subtotal + optional tax + optional discount + total rows, optional
payment method + notes block, and a closing message defaulting to
`"Thank you!"`. The factory SHALL accept theme + page + header/footer +
fallback-currency overrides through the `ReceiptTemplateOptions` bundle.
`computeReceiptTotals(items)` SHALL aggregate subtotal / tax / discount /
total across a `ReadonlyArray<ReceiptLineItem>`. `formatReceiptAmount(amount,
currency)` SHALL prefix the amount with the symbol for USD / EUR / GBP / JPY
/ AUD / CAD; unknown codes SHALL fall back to a `"<CODE> "` prefix.
#### Scenario: invalid payload raises INVALID_TEMPLATE_INPUT
- **WHEN** `createReceiptTemplate({ ...valid, receiptNumber: "" })` is called
- **THEN** the call throws a `PdfError` with `code ===
PDF_CODES.INVALID_TEMPLATE_INPUT` and a non-empty
`(err.context as { path: unknown }).path`.
#### Scenario: A5 default + payload currency wins
- **WHEN** `createReceiptTemplate({ ...valid, currency: "GBP" }, { currency: "EUR" })` is built
- **THEN** the returned document's default page size is `PageSize.A5` and
the emitted text contains `"£"` (and no `"€"`).
#### Scenario: pdf-parse round-trip
- **WHEN** `renderDocument(createReceiptTemplate({ ...valid, notes: "Thank-you-note" }))` is called
- **THEN** the returned bytes parse via `pdf-parse`, the info reports
`info.Title === "Receipt <number>"`, and the extracted text contains the
merchant name, receipt number, every item description, and the notes
string.
**Verified by:**
`src/templates/receipt-template/receipt-template.test.ts::"raises INVALID_TEMPLATE_INPUT on empty receipt number"`
AND `src/templates/receipt-template/receipt-template.test.ts::"respects payload currency over options currency"`
AND `src/templates/receipt-template/receipt-template.test.ts::"renders a real PDF round-trippable via pdf-parse"`.
---
### Requirement: Report template (Chunk E)
The package SHALL expose `createReportTemplate(data, options?)` returning a
`Document<ReportData>` built via `DocumentBuilder` and ready for
`renderDocument`. The factory MUST eagerly validate `data` through
`ReportDataSchema`; failures MUST raise `PdfError(PDF.INVALID_TEMPLATE_INPUT)`.
The report MUST be laid out with a title block (title at h1 size + optional
subtitle + meta line with optional author + ISO date), a 2-point divider,
one section per `data.sections` entry (h2 heading + body paragraph + optional
data table when both `data` and `columns` are present), and an optional
summary tail. Tables MUST inherit the secondary theme colour for the header
row and an alternating row background. The factory SHALL accept theme + page
config + header/footer overrides through the `ReportTemplateOptions` bundle.
#### Scenario: invalid payload raises INVALID_TEMPLATE_INPUT
- **WHEN** `createReportTemplate({ ...valid, sections: [] })` is called
- **THEN** the call throws a `PdfError` with `code ===
PDF_CODES.INVALID_TEMPLATE_INPUT`.
#### Scenario: section data without columns skips the table
- **WHEN** a section is built with `data` set but `columns` omitted
- **THEN** the resulting document contains zero table nodes for that page.
#### Scenario: pdf-parse round-trip
- **WHEN** `renderDocument(createReportTemplate(validPayload))` is called
- **THEN** the returned bytes parse via `pdf-parse`, the info reports
`info.Title === <title>`, and the extracted text contains every section
title plus the summary string when present.
**Verified by:**
`src/templates/report-template/report-template.test.ts::"raises INVALID_TEMPLATE_INPUT on empty title"`
AND `src/templates/report-template/report-template.test.ts::"skips the table when data is supplied without columns"`
AND `src/templates/report-template/report-template.test.ts::"renders a real PDF round-trippable via pdf-parse"`.
---
### Requirement: Invoice template (Chunk E)
The package SHALL expose `createInvoiceTemplate(data, options?)` returning a
`Document<InvoiceData>` built via `DocumentBuilder` and ready for
`renderDocument`. The factory MUST eagerly validate `data` through
`InvoiceDataSchema`; failures MUST raise `PdfError(PDF.INVALID_TEMPLATE_INPUT)`.
The invoice MUST be laid out with an `INVOICE` headline in the theme's
primary colour, the invoice number, a divider, `From:` + `Bill To:` address
blocks, issue + due dates, a 4-column line-item table (`Description` / `Qty`
/ `Unit Price` / `Total`), a totals ladder (subtotal, optional tax with
configurable label, optional discount, total), and an optional notes block.
The factory SHALL accept theme + page + header/footer + fallback-currency
overrides through the `InvoiceTemplateOptions` bundle.
`computeInvoiceTotals(items)` SHALL aggregate subtotal / tax / discount /
total across a `ReadonlyArray<InvoiceLineItem>`. `formatInvoiceAmount(amount,
currency)` SHALL prefix the amount with the symbol for USD / EUR / GBP / JPY
/ AUD / CAD; unknown codes SHALL fall back to a `"<CODE> "` prefix.
#### Scenario: invalid payload raises INVALID_TEMPLATE_INPUT
- **WHEN** `createInvoiceTemplate({ ...valid, from: { ...valid.from, name: "" } })` is called
- **THEN** the call throws a `PdfError` with `code ===
PDF_CODES.INVALID_TEMPLATE_INPUT` and a non-empty
`(err.context as { path: unknown }).path`.
#### Scenario: configurable tax label
- **WHEN** a payload supplies `taxLabel: "VAT"` and an item with `taxRate: 20`
- **THEN** the emitted text contains `"VAT: $20.00"` and not `"Tax: $20.00"`.
#### Scenario: pdf-parse round-trip
- **WHEN** `renderDocument(createInvoiceTemplate({ ...valid, notes: "Bank-details-on-file." }))` is called
- **THEN** the returned bytes parse via `pdf-parse`, the info reports
`info.Title === "Invoice <number>"`, and the extracted text contains
`"INVOICE"`, every item description, and the notes string.
**Verified by:**
`src/templates/invoice-template/invoice-template.test.ts::"raises INVALID_TEMPLATE_INPUT on missing from name"`
AND `src/templates/invoice-template/invoice-template.test.ts::"emits tax + discount lines (with custom tax label) when present"`
AND `src/templates/invoice-template/invoice-template.test.ts::"renders a real PDF round-trippable via pdf-parse"`.