# Capability Spec Delta: pdf **Package**: `@suaveplan/pdf` **Status**: PROPOSED (Chunk A scope) > Every requirement below carries a `**Verified by:**` line at authoring time. ## 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 ` 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` class that validates a Zod-typed `TableElementOptions` 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` 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` 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` class plus a `createDocument()` 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` 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(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` 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`. `formatReceiptAmount(amount, currency)` SHALL prefix the amount with the symbol for USD / EUR / GBP / JPY / AUD / CAD; unknown codes SHALL fall back to a `" "` 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 "`, 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` 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 === `, 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"`.