Files
suaveplan/openspec/archive/2026-05-25-enterprise-pdf/specs/pdf/spec.md
2026-05-27 23:29:53 +01:00

36 KiB

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