# 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` 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.