126 lines
5.1 KiB
Markdown
126 lines
5.1 KiB
Markdown
# 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.
|