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

5.1 KiB
Raw Permalink Blame History

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.