Files
2026-05-27 23:29:53 +01:00

126 lines
5.1 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.