5.1 KiB
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—Clockfor injectable timing in later chunks (workspace).@opentelemetry/api—metrics,tracenamespaces (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 taggedsuccess(true/false).pdf_pages_emitted_total— counter.pdf_render_errors_total— counter taggedcode.pdf_validation_failures_total— counter taggedcode(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/andcolors/— exhaustive table-driven tests covering every branch (positive + negative + edge values).error-codes/— verifies every code inPDF_CODESis reachable from a real call site (PdfErrorconstructor + a regression test that walks the union).schemas/— Zod parse pass / fail per schema.telemetry/— asserts the six instruments are created andrecord*helpers fire on the supplied meter.- All tests use
@suaveplan/testing/runnerper 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.