805 lines
36 KiB
Markdown
805 lines
36 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# 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"`.
|