Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,125 @@
# Design — @suaveplan/pdf
## Package placement
`packages/devtools/docs/pdf/` per `scripts/folder-reorg/assignment.yml`
line 261 (`docs` plugin under the `devtools` super-category). Server-tier:
the writer uses `TextEncoder`/`TextDecoder` (universally available in
Node + Bun), no DOM APIs.
## Tier
Server-tier. Runs in Node ≥ 18 and Bun ≥ 1.0. The React preview component
(Chunk E) carries React as an **optional** peer dependency so server-only
consumers do not pay the cost.
## External runtime deps
Chunk A only:
- `@suaveplan/error` — `defineCodes`, `SuaveplanError` (workspace).
- `@suaveplan/opentelemetry` — `createCounter`, `createHistogram`
(workspace).
- `@suaveplan/types` — `Clock` for injectable timing in later chunks
(workspace).
- `@opentelemetry/api` — `metrics`, `trace` namespaces (catalog).
- `zod` — boundary validation (catalog).
- `tslib` — runtime helpers (catalog).
No new third-party deps are introduced. Upstream uses zero npm deps for
PDF generation (hand-rolled 1.4 writer); genesis preserves that property.
## Why hand-roll a PDF writer instead of `pdfkit`
`pdfkit` is ~600 KB unpacked, pulls in `crypto-js`, `fontkit`, and PNG/JPEG
streams it does not always need, and ships with CommonJS-only entry
points that complicate ESM bundling. The upstream writer is ~200 LOC,
produces valid PDF 1.4, and round-trips through every PDF reader we have
tested (`pdf-parse`, `pdfjs-dist`, Chrome, Safari, Preview, Adobe Reader).
Genesis preserves the hand-rolled approach because it is the smaller and
more auditable surface.
## Error model
Single `PdfError extends SuaveplanError<PdfErrorCode>` with the stable
`PDF.*` code registry. Every public function raises `PdfError` only —
never a plain `Error` — so consumers can `instanceof PdfError` + switch
on `err.code`.
Code coverage in Chunk A is `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`. The later codes are declared up front so
Chunks B–E do not have to expand the registry mid-flight (which would
break consumers that pin to `PdfErrorCode`).
## Observability
Six instruments on the `@suaveplan/pdf` meter, all created at module
load time via the `@suaveplan/opentelemetry` helpers:
- `pdf_documents_rendered_total` — counter tagged `success` (`true`/`false`).
- `pdf_pages_emitted_total` — counter.
- `pdf_render_errors_total` — counter tagged `code`.
- `pdf_validation_failures_total` — counter tagged `code` (Zod parse fails).
- `pdf_render_duration_ms` — histogram (ms).
- `pdf_document_size_bytes` — histogram (bytes).
A `createPdfObservability(opts?)` factory returns a struct so consumers
can inject a custom `Meter` / `Tracer` (used in tests to assert recorded
values).
## Zod boundary
`schemas/` declares Zod schemas matching every public type:
`UnitSchema = z.enum(["pt", "mm", "cm", "in", "px"])`,
`HexColorSchema = z.string().regex(/^#?[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/)`,
`RgbColorSchema = z.object({ r: z.number().min(0).max(255), … })`,
`MarginsSchema`, `PageConfigSchema`, `DocumentMetadataSchema`.
Parse failures are converted to `PdfError(PDF_CODES.INVALID_*)` with the
Zod issue as `cause` and the issue path as `context.path`.
## AbortSignal
Reserved in the public API of every async function in later chunks
(`renderDocument({ signal })`). Chunk A has no async surface so the
contract is documented but not exercised.
## Test strategy
- `units/` and `colors/` — exhaustive table-driven tests covering every
branch (positive + negative + edge values).
- `error-codes/` — verifies every code in `PDF_CODES` is reachable from a
real call site (`PdfError` constructor + a regression test that walks
the union).
- `schemas/` — Zod parse pass / fail per schema.
- `telemetry/` — asserts the six instruments are created and `record*`
helpers fire on the supplied meter.
- All tests use `@suaveplan/testing/runner` per rule 11.
## File-size discipline
Every implementation file ≤ 250 LOC soft, ≤ 300 LOC hard. Tests ≤ 500 LOC.
The hand-rolled `pdf-writer` (Chunk B) is the largest single file; it
will be split into `pdf-writer-objects.ts`, `pdf-writer-xref.ts`, and
`pdf-writer.ts` if it crosses 300 LOC.
## Chunking rationale
The full surface is ~30 source files + ~30 test files + ~30 docs at
≥ 200 words each, plus real PDF round-trip verification using
`pdf-parse`. That exceeds a single agent session's reliable working
capacity at 100 / 100 / 100 / 100 coverage. Splitting along the natural
dependency lines (utilities → writer → elements → layout → templates)
keeps every chunk independently verifiable and lets each one ship to
green before the next begins.
## Migration path
Chunks B–E follow as separate OpenSpec changes (`enterprise-pdf-writer`,
`enterprise-pdf-elements`, `enterprise-pdf-layout`,
`enterprise-pdf-templates`). The unshipped chunks are listed in
`tasks.md` with their dependency edges so the next agent can pick up
without re-reading the upstream source.

View File

@@ -0,0 +1,106 @@
# Proposal — Migrate @suaveplan/pdf to genesis (multi-chunk)
- **Design**: ./design.md
- **Spec delta**: ./specs/pdf/spec.md
- **Status**: PROPOSED (Chunk A ships in this change; Chunks B–E follow)
## Why
Upstream `@suaveplan/pdf` (`/mnt/s/web/suaveplan/packages/server/pdf/`, ~2 100
LOC implementation across eleven feature modules, ~7 300 LOC of tests) ships a
working server-tier PDF 1.4 generator built on the 14 standard PDF fonts and a
hand-rolled binary writer. It exports a fluent `DocumentBuilder`, four element
primitives (text, table, image, divider), a layout engine with cursor +
flow-block tracking, a style resolver with theme merging, three high-level
templates (invoice, receipt, report), unit + colour utilities, and a React
preview component. The genesis `enterprise/pdf.md` audit calls out the
cross-cutting gaps shared with every other migration target:
1. **No typed error registry.** Every failure path raises plain `Error`
instances (`new Error("Invalid hex color: …")`, `Unknown unit`, etc.) so
consumers cannot classify failures without parsing message strings.
2. **No OpenTelemetry surface.** PDF generation is a load-bearing primitive
(invoices, receipts, financial reports) yet emits zero counters,
histograms, or spans, leaving operators blind to throughput, latency, and
failure rates.
3. **No Zod-validated public boundary.** Hex strings, unit strings, element
props, document definitions, and template inputs are accepted as plain TS
types — invalid input surfaces as cryptic runtime errors deep in the
render pipeline.
4. **No AbortSignal support.** Rendering a large report cannot be cancelled.
5. **No injectable seams.** The PDF writer hard-codes `TextEncoder`/`Date`
so tests must rely on real wall-clock metadata.
## What
Land `@suaveplan/pdf@0.1.0` at `packages/devtools/docs/pdf/` (server-tier;
allocation confirmed in `scripts/folder-reorg/assignment.yml` line 261 under
the `docs` plugin) across five chunks. Each chunk is an independently
shippable subset with its own 100 / 100 / 100 / 100 coverage gate. The
chunks are sized so a single agent session can land each one with full
enterprise additions (error codes, Zod, OTel, AbortSignal, `.md` per source
file, real round-trip tests).
### Chunk A — Foundation (this change)
Pure utilities + the cross-cutting wiring. Zero external runtime deps beyond
`@suaveplan/error`, `@suaveplan/opentelemetry`, `@suaveplan/types`, `tslib`,
and `zod`. No PDF I/O. Easy to verify to 100 % in one pass.
- `types/` — `PageSize`, `PAGE_DIMENSIONS`, `PageOrientation`, `Margins`,
`DEFAULT_MARGINS`, `DocumentMetadata`, `PageConfig`, `DEFAULT_PAGE_CONFIG`,
`DocumentDefinition`, `PageDefinition`, `ElementNode`, `RgbColor`,
`PdfColor`, `Unit`.
- `error-codes/` — `PDF_CODES` via `defineCodes("PDF", [...])` (`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`) + `PdfError` class.
- `schemas/` — Zod schemas (`UnitSchema`, `HexColorSchema`, `RgbColorSchema`,
`MarginsSchema`, `PageConfigSchema`, `DocumentMetadataSchema`).
- `telemetry/` — OTel counters (`pdf_documents_rendered_total`,
`pdf_pages_emitted_total`, `pdf_render_errors_total`,
`pdf_validation_failures_total`) + histograms (`pdf_render_duration_ms`,
`pdf_document_size_bytes`) on the `@suaveplan/pdf` meter, plus recording
helpers.
- `units/` — `toPoints`, `fromPoints`, `convert`, `parseUnit`, `formatUnit`
raising `PdfError(PDF_CODES.INVALID_UNIT)` on bad input.
- `colors/` — `hexToRgb`, `rgbToPdfColor`, `hexToPdfColor`, `rgbToHex`,
`lighten`, `darken`, `COLORS`, `isTransparent` raising
`PdfError(PDF_CODES.INVALID_HEX_COLOR)` on bad input.
### Chunk B — Low-level writer + content streams (next change)
- `pdf-writer/` — Real PDF 1.4 binary writer with deterministic byte output
(injectable `Clock` for metadata), font registry for the 14 standard
fonts, indirect-object + xref + trailer assembly, `escapePdfString`,
`fmt`, AbortSignal-aware `generate`.
- `content-stream/` — Encoder for text-positioning, line-drawing, and
state-save / state-restore operators.
### Chunk C — Elements
- `text-element/`, `table-element/`, `image-element/`, `divider-element/` —
each with Zod input schemas, OTel counters tagged by element kind, and
round-trip verification via `pdf-parse`.
### Chunk D — Layout, styles, document builder, renderer
- `layout-engine/`, `style-resolver/`, `document-builder/`, `renderer/` —
cursor tracking, theme merging, fluent builder, top-level
`renderDocument(definition, opts)`.
### Chunk E — Templates + React preview
- `invoice-template/`, `receipt-template/`, `report-template/`,
`pdf-preview/` (React 19 component with peer-only React dep).
## Out of scope
- PDF/UA and PDF/A profiles — tracked in a follow-up enterprise plan.
- Embedded font subsetting — handled by `@suaveplan/font-subset` /
`@suaveplan/font-compress` and wired in via a future plugin slot.
- Digital signatures — gated on `@suaveplan/jws` reaching enterprise
parity for PDF (`/Sig` dictionaries).
- HTTP plugin (`@suaveplan/api-plugin-pdf`) — separate change once Chunks
A–E land.

View File

@@ -0,0 +1,804 @@
<!-- 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"`.

View File

@@ -0,0 +1,137 @@
# Tasks — @suaveplan/pdf migration
## Chunk A — Foundation (THIS CHANGE)
Status: **SHIPPED** in this change.
- [x] Scaffold `packages/devtools/docs/pdf/` (package.json, vite, vitest,
tsconfig.lib.json, README, biome).
- [x] `src/types/` — page-size enum, dimensions table, margins, document /
page / element types, colour types, unit type. One `.md` per `.ts`.
- [x] `src/error-codes/` — `PDF_CODES` via `defineCodes`, `PdfError` class,
`asPdfError` helper.
- [x] `src/schemas/` — Zod schemas (`UnitSchema`, `HexColorSchema`,
`RgbColorSchema`, `MarginsSchema`, `PageConfigSchema`,
`DocumentMetadataSchema`) + `parseOrThrow` helper raising `PdfError`.
- [x] `src/telemetry/` — six OTel instruments + `createPdfObservability`
factory + recording helpers.
- [x] `src/units/` — `toPoints`, `fromPoints`, `convert`, `parseUnit`,
`formatUnit` raising typed errors.
- [x] `src/colors/` — `hexToRgb`, `rgbToPdfColor`, `hexToPdfColor`,
`rgbToHex`, `lighten`, `darken`, `COLORS`, `isTransparent`.
- [x] Pre-publish gate: `bunx turbo lint typecheck test build --filter=@suaveplan/pdf`
green, `bunx turbo test:coverage --filter=@suaveplan/pdf` 100 / 100 / 100 / 100.
- [x] Refresh `MANIFEST.md`.
## Chunk B — Low-level writer
Status: **SHIPPED** in this change.
- [x] `src/pdf-writer/` — hand-rolled PDF 1.4 writer with:
indirect-object table, font registry for the 14 standard fonts,
stream-object assembly (raw + FlateDecode via `node:zlib`),
xref + trailer, deterministic ordering, injectable `Clock` for
`/CreationDate`, AbortSignal-aware `generate`, `dispose()` guard.
- [x] `src/content-stream/` — encoder for `BT/ET`, `Td`/`T*`/`TL`/`Tm`,
`Tj`/`Tf`, `q/Q`, `rg/RG`, `m/l/h/S/s/f/B`, `re`, `cm`, `w`, and
image XObject `Do` placement with text-mode invariants.
- [x] Real PDF parse verification via `pdf-parse@^2.4` round-trip
(text content + page count + metadata + ProducerInfo).
## Chunk C — Elements
Status: **SHIPPED** in this change.
- [x] `src/text-element/` — `TextElement` class + `wrapText` +
`resolveStandardFont` + `measureLineWidth` with alignment,
max-lines overflow flag, kerning, `AbortSignal`.
- [x] `src/table-element/` — `TableElement<TRow>` with column-width
auto-distribution, header repeat across pages via
`renderPage(context, rowStart)`, alternating row backgrounds,
border + column dividers, `LAYOUT_OVERFLOW` typed error.
- [x] `src/image-element/` — `ImageElement` with PNG decode via
`pngjs` (RGB + optional SMask alpha plane), JPEG pass-through via
SOF-marker walk for width/height, `detectImageFormat`,
`scaleToFit`, `validateImageProps`, declared-format mismatch
detection.
- [x] `src/divider-element/` — `DividerElement` with horizontal /
vertical orientation, dash pattern, spacing above / below,
`computeDividerSpacing` flow helper.
- [x] Zod input schema per element type.
- [x] Real pdf-parse round-trip per text + table element.
- [x] Pre-publish gate: lint + typecheck + test + build green,
coverage 100 / 100 / 100 / 100.
## Chunk D — Layout, styles, document builder, renderer
Status: **SHIPPED** in this change.
- [x] `src/style-resolver/` — `StyleResolver` cascade
(`DEFAULT_STYLE` → theme → base → overrides), `mergeStyles`,
`mergeTheme` deep merge incl. `headingSizes`, `normalizeSpacing`
with `PDF.INVALID_ELEMENT` on non-finite input, `DEFAULT_THEME`
palette.
- [x] `src/layout-engine/` — `LayoutEngine` with cursor + content-area
+ header/footer slot rectangles, landscape orientation swap,
`advance(cursor, height)` returning `{ fit, remaining }`,
`nextPage`, `computeTextHeight`, `applyMargin`, AbortSignal
propagation, typed `PDF.LAYOUT_OVERFLOW` /
`PDF.INVALID_PAGE_CONFIG` errors.
- [x] `src/document-builder/` — `DocumentBuilder<TData>` fluent API
(`title` / `author` / `subject` / `keywords` / `creator` /
`creationDate` / `pageConfig` / `withData` / `addText` /
`addTable` / `addImage` / `addDivider` / `newPage` / `setHeader`
/ `setFooter` / `build`) + `createDocument<TData>()` factory.
- [x] `src/image-registry/` — `ImageRegistry` + `registerImageXObject`
that allocate per-element PDF XObjects (DCTDecode for JPEG,
FlateDecode for PNG RGB plane) and share them across logical
pages.
- [x] `src/render-pipeline/` — `placeNode` / `renderTableSlice` /
`renderSlot` / `pickFontResource` helpers operating on a
`PipelineState` bundle.
- [x] `src/renderer/` — top-level `renderDocument(doc, opts)`
returning `{ bytes, pageCount, durationMs }`. AbortSignal-aware,
emits six OTel instruments (documents-rendered counter,
pages-emitted counter, render-errors counter tagged by code,
validation-failures counter, render-duration histogram,
document-size histogram). Page-break + table-continuation loop;
raises `PDF.LAYOUT_OVERFLOW` for elements that cannot fit on a
fresh page.
- [x] Real pdf-parse round-trip tests covering text, multi-page flow,
oversize-table pagination with header repeat, header / footer
stamping across emitted pages, image XObject embedding.
- [x] Pre-publish gate: `bunx turbo lint typecheck test build
--filter=@suaveplan/pdf` green AND
`bunx turbo test:coverage --filter=@suaveplan/pdf`
100 / 100 / 100 / 100.
## Chunk E — Templates
Status: **SHIPPED** in this change.
- [x] `src/templates/invoice-template/` — `createInvoiceTemplate` +
`InvoiceDataSchema` + `computeInvoiceTotals` + `formatInvoiceAmount`.
- [x] `src/templates/receipt-template/` — `createReceiptTemplate` +
`ReceiptDataSchema` + `computeReceiptTotals` + `formatReceiptAmount`.
- [x] `src/templates/report-template/` — `createReportTemplate` +
`ReportDataSchema`.
- [x] Zod input schema per template with `PDF.INVALID_TEMPLATE_INPUT`
error path attaching the failing issue paths.
- [x] Real pdf-parse round-trip per template.
- [x] Pre-publish gate: `bunx turbo lint typecheck test build
--filter=@suaveplan/pdf` green AND
`bunx turbo test:coverage --filter=@suaveplan/pdf`
100 / 100 / 100 / 100.
React PDF preview component — deferred to a follow-up change
(`enterprise-react-pdf-preview` stub left under `openspec/changes/`).
## Cross-chunk invariants (preserved at every step)
- One `.md` per `.ts` ≥ 200 words covering Purpose, Features, Usage: Basic,
Usage: Advanced, API Reference, Implementation Notes.
- 100 / 100 / 100 / 100 Istanbul coverage on every touched module — no
`istanbul ignore` comments, no `// biome-ignore`.
- Every `it` / `test` carries `{ timeout: 5000 }` (30 000 for async).
- Tests import from `@suaveplan/testing/runner` per rule 11.
- No `export *` in non-root barrels (rule 10).