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-codesbarrel 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, itscode === PDF_CODES.RENDER_FAILED, itsmessage === "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
PdfErrorwithcode === 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
PdfErrorwithcode === 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
PdfErrorwithcode === PDF_CODES.INVALID_RGBand 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 andrecordDocumentRendered(obs, { success: true })follows - THEN the meter's
createCounter("pdf_documents_rendered_total", ...)recipient observes oneadd(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_MARGINSis 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:
- A header (
%PDF-1.4\nfollowed by a binary-marker comment line whose body contains four high-bit bytes so the file is detected as binary). - 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. - A cross-reference table (
xref) listing the byte offset of every indirect object, beginning with the canonical0000000000 65535 ffree entry. - A trailer dictionary referencing
/Size,/Root(catalog), and/Info(when document metadata is supplied), followed bystartxref, the xref offset, and the%%EOFmarker.
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; whenfilter === "FlateDecode"the writer SHALL compress the payload usingnode:zlib'sdeflateSyncand 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 aUint8Array, applying the optional document metadata (Title/Author/Subject/Creator/Producer) with proper escaping and the injectedClock's current time as/CreationDatein PDF date format (D:YYYYMMDDHHmmSS+0000).dispose()— release internal state; subsequentgeneratecalls MUST raisePdfError(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
PdfWriterinstance callsgenerate()after at least one page - THEN the returned bytes start with
%PDF-1.4\nAND end with%%EOF\nAND contain the binary-marker comment.
Scenario: xref offsets are byte-accurate
- WHEN a
PdfWriterproduces a multi-object document - THEN every offset in the xref table equals the byte position of the
corresponding
<id> 0 objopener in the output.
Scenario: FlateDecode compresses + inflates round-trip
- WHEN
addStream(payload, "FlateDecode")is followed bygenerate() - THEN the emitted stream bytes (between
stream\nand\nendstream) decompress vianode:zlib.inflateSyncback topayloadexactly.
Scenario: dispose blocks further writes
- WHEN
dispose()is called andgenerate()follows - THEN the call throws
PdfErrorwithcode === PDF_CODES.WRITER_DISPOSED.
Scenario: AbortSignal cancels generate
- WHEN the writer's
AbortSignalis aborted beforegenerate()returns - THEN the call throws
PdfErrorwithcode === 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 acceptPdfColor(0-1 channels), validated viaRgbColorSchema. - 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, andETin order.
Scenario: showText outside text mode raises
- WHEN
new ContentStreamEncoder().showText("oops")is called - THEN the call throws
PdfErrorwithcode === 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, andf.
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
PdfWriteremits a document containing a single page whose content stream prints the literal"Hello PDF"via a Helvetica font, withmetadata = { Title: "Round Trip", Author: "Suaveplan" } - THEN
pdf-parse(pdf-parse@^2.4) reportsnumpages === 1,textcontaining"Hello PDF", andinfo.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, and1 0 0 rgin order.
Scenario: maxLines flags overflow
- WHEN a
TextElementwithmaxLines: 1renders into a narrow width that would otherwise wrap to multiple lines - THEN the returned
overflowedflag istrue.
Scenario: aborts cooperatively
- WHEN
render({ ..., signal })is called with an aborted signal - THEN the call throws
PdfErrorwithcode === 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 than1 - THEN the call throws
PdfErrorwithcode === 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
detectImageFormatis 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
decodeImageruns on a translucent PNG (alpha < 255) - THEN the returned
DecodedImage.alphais aUint8Arrayand the primarydatais 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 cmand/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, andSin 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 nolengthand nomaxHeight - 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, andcolor === 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 === 18andcolor === "#112233".
Scenario: normalizeSpacing rejects non-finite values
- WHEN
normalizeSpacing(Number.NaN)is called - THEN it throws a
PdfErrorwith codePDF.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
heightis 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
PdfErrorwith codePDF.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"andmetadata.creationDate instanceof Date.
Scenario: setHeader rejects negative height
- WHEN
createDocument().setHeader([], -1)is called - THEN it throws a
PdfErrorwith codePDF.INVALID_PAGE_CONFIG.
Scenario: empty build throws
- WHEN
createDocument().build()is called with no nodes - THEN it throws a
PdfErrorwith codePDF.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
bytesparse viapdf-parse, the parsed text contains"Hello, world.", andpageCount === 1.
Scenario: oversize table paginates with header repeat
- WHEN a document with a 200-row
TableElement(repeatHeader: true) is rendered - THEN the returned
pageCount > 1and 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: 5000is rendered - THEN
renderDocumentthrows aPdfErrorwith codePDF.LAYOUT_OVERFLOW.
Scenario: pre-aborted signal short-circuits
- WHEN
renderDocument(doc, { signal: alreadyAbortedSignal })is called - THEN it throws a
PdfErrorwith codePDF.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
PdfErrorwithcode === PDF_CODES.INVALID_TEMPLATE_INPUTand 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.A5and 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 reportsinfo.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
PdfErrorwithcode === PDF_CODES.INVALID_TEMPLATE_INPUT.
Scenario: section data without columns skips the table
- WHEN a section is built with
dataset butcolumnsomitted - 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 reportsinfo.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
PdfErrorwithcode === PDF_CODES.INVALID_TEMPLATE_INPUTand a non-empty(err.context as { path: unknown }).path.
Scenario: configurable tax label
- WHEN a payload supplies
taxLabel: "VAT"and an item withtaxRate: 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 reportsinfo.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".