From af8418cd1a075e7e3984dc61ed9ee882ff0af50f Mon Sep 17 00:00:00 2001 From: Kuzino <129803615+Nizoka@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:50:31 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20v1.1.0=20=E2=80=94=20charts,=20serv?= =?UTF-8?q?er=20rendering,=20and=20an=20autonomous=20agent=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tracks the pdfnative 1.6.0 engine release and closes the two gaps costing adoption: no first-class way to serve a PDF from a modern React server, and no way for an AI agent to check its environment or verify its own output. Engine 1.6.0 surface: - — bar, barH, line, pie, donut as pure PDF path operators. The only authoring capability 1.6.0 adds. Full DocSpec parity via ['chart', body]. Adoption: - renderToResponse / renderSpecToResponse — web-standard Response, streaming by default. Next.js route handlers, Remix, Hono, Edge, Deno, Bun, Workers. - layout sugar: watermark, header, footer, attachments, tagged. These PdfLayoutOptions fields already worked as an undocumented pass-through; they are now typed, schema-covered, sampled and tested. - lintDocument / lintSpec — 16 rules with stable L_* codes. Five pre-empt constraints the engine otherwise enforces by throwing mid-render. Agent surface: - ErrorCode taxonomy + PdfReactError.toJSON() + toErrorEnvelope. - capabilityManifest(), doctor(), validateSpec(), schema(subject). - Governance (aiGovernancePolicy, agentRulesText, validateIssueDraft) shipped as runtime capability; llms.txt now included in the tarball. Anti-drift: new src/registry.ts holds the block grammar, component list and lint rules as single-source tables. The JSON Schema, validateSpec and the capability manifest derive from them, with compile-time Assert> locks making omission a build error. Verified destructively. Install-time floors raised (no API break): pdfnative peer ^1.5.0 -> ^1.6.0 because needs a block type 1.5 does not have; Node >=20 -> >=22, inherited from the engine. 79 -> 205 tests. Coverage 94.7/85.7/97.2/96.0 (thresholds 85/80/85/85). --- .github/ai-governance.json | 17 +- .github/workflows/ci.yml | 19 +- AGENTS.md | 78 +++- CHANGELOG.md | 125 +++++- CITATION.cff | 7 +- CLAUDE.md | 14 +- README.md | 134 +++++- ROADMAP.md | 38 +- docs/AGENT_CONTRACT.md | 249 +++++++++++ docs/CHARTS.md | 153 +++++++ docs/KNOWLEDGE_BASE.md | 161 ++++++- docs/LINTING.md | 172 +++++++ docs/RECIPES.md | 195 ++++++++ docs/SERVER.md | 168 +++++++ llms.txt | 99 +++- package-lock.json | 16 +- package.json | 24 +- release-notes/draft/PR-v1.1.0.md | 258 +++++++++++ release-notes/v1.1.0.md | 214 +++++++++ samples/README.md | 26 +- samples/agent/agent-loop.ts | 123 +++++ samples/agent/error-envelope.tsx | 79 ++++ samples/agent/manifest.ts | 68 +++ samples/charts/charts.tsx | 127 ++++++ samples/layout/watermark-header-footer.tsx | 106 +++++ samples/quality/lint.tsx | 106 +++++ samples/server/next-route-handler.tsx | 129 ++++++ src/components.tsx | 82 ++++ src/core-bridge/index.ts | 23 + src/doctor.ts | 175 ++++++++ src/errors.ts | 90 ++++ src/governance.ts | 237 ++++++++++ src/index.ts | 57 ++- src/lint.ts | 382 ++++++++++++++++ src/manifest.ts | 291 ++++++++++++ src/reconciler/nodes.ts | 1 + src/reconciler/serialize.ts | 69 ++- src/registry.ts | 406 +++++++++++++++++ src/response.ts | 123 +++++ src/spec/compile.ts | 32 +- src/spec/index.ts | 16 +- src/spec/schema.ts | 498 +++++++++++++++++++-- src/spec/types.ts | 28 ++ src/spec/validate.ts | 253 +++++++++++ src/types.ts | 20 + src/version.ts | 2 +- tests/agent.test.tsx | 252 +++++++++++ tests/chart.test.tsx | 184 ++++++++ tests/governance.test.ts | 99 ++++ tests/layout-sugar.test.tsx | 149 ++++++ tests/lint.test.tsx | 333 ++++++++++++++ tests/registry.test.ts | 167 +++++++ tests/response.test.tsx | 109 +++++ tests/schema.test.ts | 143 ++++++ tests/version.test.ts | 38 +- 55 files changed, 7024 insertions(+), 140 deletions(-) create mode 100644 docs/AGENT_CONTRACT.md create mode 100644 docs/CHARTS.md create mode 100644 docs/LINTING.md create mode 100644 docs/RECIPES.md create mode 100644 docs/SERVER.md create mode 100644 release-notes/draft/PR-v1.1.0.md create mode 100644 release-notes/v1.1.0.md create mode 100644 samples/agent/agent-loop.ts create mode 100644 samples/agent/error-envelope.tsx create mode 100644 samples/agent/manifest.ts create mode 100644 samples/charts/charts.tsx create mode 100644 samples/layout/watermark-header-footer.tsx create mode 100644 samples/quality/lint.tsx create mode 100644 samples/server/next-route-handler.tsx create mode 100644 src/doctor.ts create mode 100644 src/errors.ts create mode 100644 src/governance.ts create mode 100644 src/lint.ts create mode 100644 src/manifest.ts create mode 100644 src/registry.ts create mode 100644 src/response.ts create mode 100644 src/spec/validate.ts create mode 100644 tests/agent.test.tsx create mode 100644 tests/chart.test.tsx create mode 100644 tests/layout-sugar.test.tsx create mode 100644 tests/lint.test.tsx create mode 100644 tests/registry.test.ts create mode 100644 tests/response.test.tsx create mode 100644 tests/schema.test.ts diff --git a/.github/ai-governance.json b/.github/ai-governance.json index ad85776..f972ecf 100644 --- a/.github/ai-governance.json +++ b/.github/ai-governance.json @@ -2,8 +2,8 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "pdfnative-react AI Governance Configuration", "description": "Machine-readable contract governing how AI coding agents may propose issues, contributions, and changes for pdfnative-react. Agents that scan repository configuration on initialization MUST honour this file. See .github/AGENT_RULES.md for the human-and-agent-readable protocol and docs/AI_GOVERNANCE.md for the narrative walk-through. The library ships NO code path that can write to GitHub or make an outbound network call; the shipped guardrail is the local `npm run verify:issue` CLI.", - "version": "1.0.0", - "spec_updated": "2026-07-17", + "version": "1.1.0", + "spec_updated": "2026-07-25", "applies_to": [ "pdfnative", "pdfnative-cli", @@ -55,14 +55,25 @@ ".github/copilot-instructions.md", ".github/AGENT_RULES.md", "docs/AI_GOVERNANCE.md", + "docs/AGENT_CONTRACT.md", "docs/KNOWLEDGE_BASE.md", + "docs/RECIPES.md", "ROADMAP.md", "SECURITY.md", "llms.txt" - ] + ], + "runtime_api": { + "description": "Since 1.1.0 the contract also ships as runtime capability, so an agent working from an installed package (with no repository checkout) can read the rules it must follow.", + "policy": "aiGovernancePolicy()", + "rules": "agentRulesText()", + "validate": "validateIssueDraft(markdown)", + "capabilities": "capabilityManifest()", + "preflight": "doctor()" + } }, "verification": { "command": "npm run verify:issue -- .github/drafts/.md", + "api": "validateIssueDraft(markdown)", "advisory_in_ci": true, "blocks_submission_on_failure": true }, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6afd6a4..217417d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,8 @@ jobs: strategy: fail-fast: false matrix: - node-version: [20, 22, 24] + # Node 22 is the floor: the pdfnative engine requires it as of 1.6.0. + node-version: [22, 24] steps: - uses: actions/checkout@v4 @@ -59,3 +60,19 @@ jobs: test -f dist/index.cjs test -f dist/index.d.ts test -f dist/index.d.cts + + # `.github/ai-governance.json` declares `advisory_in_ci: true`. Validate any + # AI-authored draft staged for human review. Advisory: it reports, never blocks. + - name: Verify AI-authored drafts (advisory) + continue-on-error: true + run: | + shopt -s nullglob + drafts=(.github/drafts/issue-*.md .github/drafts/pr-*.md release-notes/draft/*.md) + if [ ${#drafts[@]} -eq 0 ]; then + echo "No drafts staged; nothing to verify." + exit 0 + fi + for draft in "${drafts[@]}"; do + echo "── $draft" + node scripts/verify-issue.mjs "$draft" || echo " (advisory failure)" + done diff --git a/AGENTS.md b/AGENTS.md index bff300d..4fef5e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,12 +22,19 @@ Start by reading [docs/KNOWLEDGE_BASE.md](docs/KNOWLEDGE_BASE.md). | `src/reconciler/serialize.ts` | Pure host tree → `DocumentParams` transform. | | `src/reconciler/render.ts` | `compile(node)`. | | `src/render.ts` | `renderToBytes/Blob/Stream/File/FileStream`, `compileDocument`, `inspectDocument`. | +| `src/response.ts` | `renderToResponse` — web-standard `Response`. Server-only; never `'use client'`. | +| `src/lint.ts` | `lintDocument` — accessibility and engine-constraint rules. | +| `src/registry.ts` | **Single source of truth** for the block grammar, components and lint rules. See below. | +| `src/errors.ts` | `ErrorCode` taxonomy, `PdfReactError`, `PdfStructureError`. | +| `src/manifest.ts` | `capabilityManifest()` — derived entirely from the registries. | +| `src/doctor.ts` | `doctor()` — environment pre-flight. Must never throw. | +| `src/governance.ts` | The HITL policy, protocol text and draft validator, as runtime capability. | | `src/fonts.ts` | `resolveFonts` (loader map → `FontEntry[]`). | | `src/assets.ts` | `fromUrl` / `fromBase64` image-byte helpers. | | `src/hooks.ts` | `usePdf`, `usePdfStream` (client). | | `src/viewer.tsx` | `PDFViewer`, `PDFDownloadLink`, `BlobProvider` (client). | | `src/core-bridge/index.ts` | The only file that imports `pdfnative` at runtime. | -| `src/spec/` | Compact `DocSpec` grammar, compiler, and JSON Schema (agent authoring). | +| `src/spec/` | Compact `DocSpec` grammar, compiler, JSON Schema, `validateSpec`. | | `src/version.ts` | Single source of truth for the package version. | | `src/types.ts` | Public types + pdfnative type-only re-exports. | | `src/index.ts` | Public barrel. | @@ -55,13 +62,16 @@ Start by reading [docs/KNOWLEDGE_BASE.md](docs/KNOWLEDGE_BASE.md). 6. **Keep `DocSpec` and JSX in parity.** `src/spec/compile.ts` must build the tree from the existing components, never re-implement serialization. Any new authoring capability (e.g. outline, page labels, nested lists, table cell - styling) must reach both the JSX props and the `DocSpec` grammar + schema. - Bump `src/version.ts` (not an inline literal) when the version changes — the - JSON Schema `$id` derives from it, and a test pins it to `package.json` and - `CITATION.cff`. -7. **Authoring only.** Byte-level post-processing (merge/split, annotations, - signatures, crypto, font compilation) is the engine's job — do not re-export - it. Document "use `pdfnative` directly" instead. + styling, charts) must reach both the JSX props and the `DocSpec` grammar + + schema — **and be registered in `src/registry.ts`**, which the schema, the + validator and the capability manifest all derive from. Bump `src/version.ts` + (not an inline literal) when the version changes — the JSON Schema `$id` + derives from it, and a test pins it to `package.json` and `CITATION.cff`. +7. **Authoring only.** Byte-level post-processing (merge/split, form + fill/flatten, text extraction, decryption, annotations, signatures, crypto, + font compilation) is the engine's job — do not re-export it. Point at + [docs/RECIPES.md](docs/RECIPES.md), which shows how to call `pdfnative` + directly on the bytes this library produces. 8. **AI governance — you are a draftsman, never a submitter.** Never open, edit, or submit issues/PRs/releases autonomously. Write a local draft in `.github/drafts/`, validate it with `npm run verify:issue`, present it plus a @@ -69,12 +79,60 @@ Start by reading [docs/KNOWLEDGE_BASE.md](docs/KNOWLEDGE_BASE.md). [.github/AGENT_RULES.md](.github/AGENT_RULES.md) and [docs/AI_GOVERNANCE.md](docs/AI_GOVERNANCE.md). +## The registry is the single source of truth + +`src/registry.ts` holds three tables — the `DocSpec` block grammar, the +component list, and the lint rules. Four things *derive* from them rather than +restating them: + +1. `src/spec/schema.ts` — `$defs.block.oneOf`, plus tuple arity and descriptions. +2. `src/spec/validate.ts` — arity and payload-type rules. +3. `src/manifest.ts` — the capability manifest. +4. `tests/registry.test.ts` — pins the exact, ordered contents. + +Omission is a **build error**, not a silent gap: the file ends with +`Assert>` types, so adding a member to `BlockSpec` or `HostTag` +without registering it fails `npm run typecheck`. + +If you ever change this mechanism, verify it is still real: delete an entry and +confirm that **both** `npm run typecheck` and `tests/registry.test.ts` fail. If +only one does, the lock is decorative. + +### Adding a block kind + +1. `src/reconciler/nodes.ts` — add the host tag. +2. `src/components.tsx` — add the component and its props. +3. `src/reconciler/serialize.ts` — add the `case` in `toBlock`. +4. `src/spec/types.ts` — add the tuple type and add it to the `BlockSpec` union. +5. **`src/registry.ts`** — add the `BLOCK_REGISTRY` and `COMPONENT_REGISTRY` entries. +6. `src/spec/compile.ts` — add the `case` (the `never` guard will demand it). +7. `src/spec/schema.ts` — add the builder to `BLOCK_SCHEMAS`. +8. `src/spec/index.ts` and `src/index.ts` — export the new types. +9. `tests/` — a serialization test **and** a `compileSpec` ↔ JSX parity test. +10. `samples/`, `samples/README.md`, `llms.txt`, `README.md`, `CHANGELOG.md`. + ## Token-frugal agent authoring (`src/spec/`) For LLM agents, the compact `DocSpec` is the cheapest way to author a document: terse JSON tuples that compile to the **same** PDF as the equivalent JSX. Prefer -it when generating documents programmatically; validate with `docSpecSchema()`. -See Knowledge Base §7 for the contract and gotchas. +it when generating documents programmatically; validate with `validateSpec()` or +against `schema('doc-spec')`. See Knowledge Base §7 and §9, and +[docs/AGENT_CONTRACT.md](docs/AGENT_CONTRACT.md). + +### Recommended agent loop + +``` +doctor() will this environment work at all? (never throws) +capabilityManifest() what can I do here? +schema(subject) what grammar do I emit? +validateSpec(json) is it well-formed? dry run, tier 1 +compileSpec(spec) does it map onto the model? dry run, tier 2 +lintSpec(spec) accessible, and legal for the engine? dry run, tier 3 +renderSpecTo*(spec) only now, produce bytes. +``` + +Branch on error `code`, never on the message. Codes are stable across releases; +messages are not. ## Validate every change diff --git a/CHANGELOG.md b/CHANGELOG.md index e73fb53..46a92e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,128 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.0] — Charts, server rendering, and an autonomous agent surface + +Tracks the `pdfnative` engine's 1.6.0 release, opens three adoption paths +(server-side rendering, document-level layout sugar, linting), and completes the +agent-automation contract so an AI agent can drive the package without a human +in the loop. + +No public API was removed or changed in a backward-incompatible way. Two +*install-time* floors were raised — see **Changed** first. + +### Changed + +- **`pdfnative` peer floor is now `^1.6.0`** (was `^1.5.0`). `` compiles + to a block type that does not exist before 1.6.0; a 1.5 engine would receive + an unknown block and silently drop or mis-render it. A loud install-time + requirement is better than a quiet wrong PDF. +- **Node floor is now `>=22`** (was `>=20`). This is *inherited*, not invented: + `pdfnative@1.6.0` itself requires Node ≥ 22, so any compliant install is + already there. CI now runs on Node 22 and 24. +- `llms.txt` is now included in the published tarball (`package.json#files`), so + an agent working from an installed package — with no repository checkout — can + read the capability summary. + +### Added + +#### Charts (engine 1.6.0) + +- **``** — native vector charts rendered as pure PDF path operators: no + rasterisation, no chart library, no new runtime dependency. Five types + (`bar`, `barH`, `line`, `pie`, `donut`), multi-series, legends, "nice" axis + ticks, gridlines, point markers, palette overrides, negative values, and a + tagged-PDF `/Figure` + `/Alt` entry. +- **`['chart', body]`** — the matching `DocSpec` tuple, a schema branch, and the + `ChartBlock` / `ChartSeries` / `ChartType` type re-exports. + +#### Server rendering + +- **`renderToResponse(node, options?)`** and **`renderSpecToResponse(spec, options?)`** + return a web-standard `Response`. Streams page by page from the engine's + generator, so peak memory stays flat and the client receives bytes + immediately; `buffered: true` switches to a single buffer and adds + `Content-Length`. Handles `Content-Disposition` including RFC 6266 + `filename*` for non-ASCII names. Runs unchanged on Node, the Edge runtime, + Deno, Bun and Cloudflare Workers. + +#### Document-level layout sugar + +- New `` props — **`watermark`**, **`header`**, **`footer`**, + **`attachments`**, **`tagged`** — surfacing `PdfLayoutOptions` fields that + previously worked only as an opaque, undocumented `layout` pass-through. + `watermark` accepts a plain string as shorthand for the common case. An + explicit `layout` prop always wins. Mirrored on `DocSpec` and in the schema. + A document that uses none of them still serializes with `layout: undefined`, + so existing output is byte-identical. + +#### Linting + +- **`lintDocument(node, options?)`** / **`lintSpec(spec, options?)`** — sixteen + deterministic accessibility and layout rules with stable `L_*` codes. Runs on + the compiled document model, so JSX and `DocSpec` share one implementation. + Pure: no console output, no throwing. +- Five rules pre-empt hard failures further down the pipeline: + `L_CHART_SERIES`, `L_CHART_CATEGORIES`, `L_CHART_VALUES` and `L_CHART_POINTS` + mirror the engine's own chart validation (which throws at render time), and + `L_ATTACHMENTS_NEED_PDFA3` / `L_TAGGED_NO_FONTS` catch PDF/A documents the + engine or veraPDF would reject. + +#### Agent surface + +- **`ErrorCode`** — a stable `E_*` taxonomy (`E_STRUCTURE`, `E_INPUT`, + `E_UNSUPPORTED`, `E_ENV`, `E_POLICY`, `E_RUNTIME`) with a `PdfReactError` + base class carrying `code`, a `toJSON()` producing the ecosystem's standard + `{ ok: false, error: { code, message } }` envelope, and `toErrorEnvelope()` + for arbitrary thrown values. `PdfStructureError` now extends `PdfReactError` + and carries `E_STRUCTURE`; it remains importable from its original path and + is the same class object, so `instanceof` is unaffected. +- **`capabilityManifest()`** — one call describing every component, `DocSpec` + block, entry point, error code, lint rule and schema subject as plain JSON. + Derived entirely from the internal registries, and a test asserts every name + it advertises resolves to a real export. +- **`doctor()`** — environment pre-flight returning + `{ ok, checks: [{ name, status, value, detail }] }`. Never throws, including + when the `pdfnative` peer is missing — which is precisely what it diagnoses. + The engine check is a *capability probe* rather than a version-string parse, + so it survives bundling into a browser build. +- **`validateSpec(spec: unknown)`** — structural validation of an untrusted + `DocSpec` with no JSON-Schema engine, returning path-anchored `V_*` findings + (`blocks[3][1]`). Never throws. This is dry-run tier 1; `compileSpec`, + `lintSpec` and `inspectSpec` are tiers 2–4. +- **`schema(subject?)`** / **`schemaId(subject?)`** — seven subjects + (`doc-spec`, `render-options`, `lint-report`, `spec-validation`, `doctor`, + `manifest`, `list`), each with a versioned `$id` so a caching consumer can + detect contract drift. `docSpecSchema()` and `docSpecSchemaId()` are retained + and delegate; a test pins the equivalence. +- **`aiGovernancePolicy()`**, **`agentRulesText()`**, **`validateIssueDraft(md)`** + — the human-in-the-loop contract shipped as runtime capability, so an agent + working from an installed package can read the rules it must follow. Still + zero network, zero telemetry, zero autonomous GitHub writes. +- npm keywords extended for discovery (`ai-governance`, `hitl`, `llms-txt`, + `rag`, `mcp`, `nextjs`, `rsc`, `accessibility`, `pdf-ua`, `charts`, …). + +#### Internal — the anti-drift mechanism + +- New `src/registry.ts` holds the block grammar, the component list and the + lint rules as single-source tables. The JSON Schema, `validateSpec` and the + capability manifest all *derive* from them rather than restating them, and + compile-time `Assert>` types make omission a build error: adding a + member to `BlockSpec` or `HostTag` without registering it fails + `npm run typecheck`. + +### Documentation + +- New guides: `docs/CHARTS.md`, `docs/SERVER.md`, `docs/LINTING.md`, + `docs/AGENT_CONTRACT.md`, and **`docs/RECIPES.md`** — the counterpart to the + authoring-only boundary, showing how to call the engine directly for + `extractText`, `fillForm`/`flattenForm`, `openPdf({ password })`, + merge/split and re-encryption on the bytes this library produces. +- `docs/KNOWLEDGE_BASE.md` gains an "Agent Automation Contract" chapter. +- 6 new samples (charts, layout sugar, a Next.js route handler, linting, the + full agent loop, the error envelope) and 3 new agent samples, all + type-checked in CI. + ## [1.0.0] — Stable release First stable release. The public API is now covered by semantic versioning. @@ -114,7 +236,8 @@ through 1.5.0 and ships the previously-planned 0.4.0 authoring conveniences. - Placeholder release reserving the `pdfnative-react` package name on npm. -[Unreleased]: https://github.com/Nizoka/pdfnative-react/compare/v1.0.0...HEAD +[Unreleased]: https://github.com/Nizoka/pdfnative-react/compare/v1.1.0...HEAD +[1.1.0]: https://github.com/Nizoka/pdfnative-react/compare/v1.0.0...v1.1.0 [1.0.0]: https://github.com/Nizoka/pdfnative-react/compare/v0.2.0...v1.0.0 [0.2.0]: https://github.com/Nizoka/pdfnative-react/releases/tag/v0.2.0 [0.1.0]: https://github.com/Nizoka/pdfnative-react/releases/tag/v0.1.0 diff --git a/CITATION.cff b/CITATION.cff index 860a84e..f0016e4 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -22,8 +22,11 @@ keywords: - document-generation - ai-agent - agentic + - ai-governance - json-schema + - charts + - accessibility - sbom - supply-chain -version: 1.0.0 -date-released: 2026-07-17 +version: 1.1.0 +date-released: 2026-07-25 diff --git a/CLAUDE.md b/CLAUDE.md index 1c3b53e..dbf7f24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,8 @@ PDF engine: It is a declarative **block flow**, not a CSS/flexbox engine. There is no ``. +Peer: `pdfnative` ^1.6.0 · React 19 · Node ≥ 22. + ## Golden rules 1. **Runtime `pdfnative` imports go only through `src/core-bridge/index.ts`.** @@ -28,10 +30,14 @@ It is a declarative **block flow**, not a CSS/flexbox engine. There is no `` — it is a declarative *block flow*. +- **Server-ready.** `renderToResponse` returns a web-standard `Response`, + streaming by default — one line in a Next.js route handler, and the same code + on Edge, Deno, Bun and Workers. See [Server rendering](docs/SERVER.md). - **Token-frugal AI authoring.** A compact `DocSpec` lets LLM agents emit documents with a fraction of the tokens of JSX, validated by a versioned JSON Schema — see [Agent authoring](#agent-authoring-token-frugal). +- **Autonomously usable.** `doctor()`, `capabilityManifest()`, `validateSpec()` + and a stable `E_*` error taxonomy let an agent check the environment, discover + the API and verify its own output before rendering — see the + [agent contract](docs/AGENT_CONTRACT.md). +- **Checks its own work.** `lintDocument` reports accessibility problems and + pre-empts the engine constraints that would otherwise throw mid-render — see + [Linting](docs/LINTING.md). - **Typed, tested, tree-shakeable.** Strict TypeScript, dual ESM + CJS, source maps, provenance-signed publishes. @@ -51,7 +61,8 @@ const bytes = renderToBytes( npm install pdfnative-react pdfnative react ``` -Requires **React 19** and **Node.js ≥ 20**. +Requires **React 19**, **`pdfnative` ≥ 1.6**, and **Node.js ≥ 22** (the engine's +own floor since 1.6.0). ## Components @@ -59,7 +70,7 @@ Every component maps 1:1 onto a pdfnative block. | Component | Renders | |---|---| -| `Document` | The required root (`title`, `footerText`, `metadata`, `fontEntries`, `layout`, `outline`, `pageLabels`). | +| `Document` | The required root (`title`, `footerText`, `metadata`, `fontEntries`, `layout`, `outline`, `pageLabels`, `watermark`, `header`, `footer`, `attachments`, `tagged`). | | `Page` | An explicit page boundary (content auto-paginates otherwise). | | `Section` | Sugar: a heading grouped with its content (`title`, `level`, `break`). | | `Heading` | A section heading (`level` 1–3); feeds the auto `TableOfContents`. | @@ -73,8 +84,29 @@ Every component maps 1:1 onto a pdfnative block. | `TableOfContents` / `Toc` | An auto-generated TOC built from headings. | | `Barcode` | QR, Code 128, EAN-13, PDF417, Data Matrix (`format`, `data`). | | `Svg` | Inline vector graphics (path data or markup; `` renders as selectable PDF text). | +| `Chart` | Native vector charts — bar, barH, line, pie, donut ([guide](docs/CHARTS.md)). | | `FormField` | Interactive AcroForm widgets (`fieldType`, `name`). | +### Document-level page furniture + +`watermark`, `header`, `footer`, `attachments` and `tagged` are props on +`` rather than components, because they are page furniture, not blocks +in the flow. They fold into `layout` under the engine's own keys, and an +explicit `layout` prop always wins. + +```tsx + +``` + +Header and footer templates resolve `{page}`, `{pages}`, `{date}` and `{title}` +at render time. + ## Rendering ```ts @@ -84,11 +116,27 @@ import { renderToStream, // (node, options?) => AsyncGenerator (constant memory) renderToFile, // (node, path, options?) => Promise (Node only) renderToFileStream, // (node, path, options?) => Promise (Node, constant memory) + renderToResponse, // (node, options?) => Promise (streams; web standard) compileDocument, // (node) => DocumentParams (inspect the model, no render) inspectDocument, // (node, options?) => LayoutInspection (page/block geometry, no render) + lintDocument, // (node, options?) => LintReport (accessibility + engine constraints) } from 'pdfnative-react'; ``` +### Serving a PDF + +```tsx +// app/invoice/[id]/route.tsx — Next.js App Router +export async function GET() { + return renderToResponse(, { fileName: 'invoice.pdf' }); +} +``` + +Streams page by page, so peak memory stays flat and the client receives bytes +immediately. `buffered: true` switches to a single buffer and adds +`Content-Length`. Works unchanged on Node, Edge, Deno, Bun and Cloudflare +Workers — see [docs/SERVER.md](docs/SERVER.md). + `options` is `{ layout?: Partial; fontEntries?: FontEntry[]; fonts?: FontsMap }` and merges on top of anything set on `` — page size, margins, colors, PDF/A mode, encryption, viewer preferences, debug overlay, and non-Latin fonts. @@ -181,17 +229,42 @@ widens on larger ones), because every block carries opening/closing tags and prop names. Same bytes out, far fewer tokens in. - `compileSpec(spec)` → `DocumentParams` · `specToElement(spec)` → `` element -- `renderSpecToBytes` / `renderSpecToBlob` / `renderSpecToStream` / `renderSpecToFile` -- `docSpecSchema()` → a Draft 2020-12 JSON Schema whose `$id` embeds the package - version, so agents can self-validate a spec before rendering. +- `renderSpecToBytes` / `renderSpecToBlob` / `renderSpecToStream` / `renderSpecToFile` / + `renderSpecToFileStream` / `renderSpecToResponse` +- `schema(subject?)` → a Draft 2020-12 JSON Schema whose `$id` embeds the package + version, so agents can detect contract drift. Subjects: `doc-spec`, + `render-options`, `lint-report`, `spec-validation`, `doctor`, `manifest`, + `list`. (`docSpecSchema()` is retained and returns `schema('doc-spec')`.) Block tuples: `['h1'|'h2'|'h3', text, opts?]`, `['p', text, opts?]`, `['ul'|'ol', items, opts?]` (items may be `{ text, items }` for nesting), `['table', { h?, r, cellBorders?, cellVAlign?, … }]`, `['img', { data }]`, `['link', text, { url }]`, `['sp', height?]`, `['br']`, `['page', blocks]`, `['toc', opts?]`, `['qr'|'code128'|'ean13'|'pdf417'|'datamatrix', data, opts?]`, -`['svg', data, opts?]`, `['field', { fieldType, name, … }]`. A spec also accepts -top-level `outline` and `pageLabels`, mirroring ``. +`['svg', data, opts?]`, `['chart', { chartType, series, … }]`, +`['field', { fieldType, name, … }]`. A spec also accepts top-level `outline`, +`pageLabels`, `watermark`, `header`, `footer`, `attachments` and `tagged`, +mirroring ``. + +### Running autonomously + +An agent driving this package without a human should work through four cheap +checks before spending a render: + +```ts +import { doctor, capabilityManifest, validateSpec, lintSpec } from 'pdfnative-react'; + +doctor(); // will this environment work? never throws +capabilityManifest(); // every component, block, entry point, error code +validateSpec(json); // is the JSON well-formed? path-anchored findings +lintSpec(spec); // is it accessible, and legal for the engine? +``` + +Every error carries a stable `E_*` code and serializes to +`{ ok: false, error: { code, message } }`. Branch on the code, never the message. + +Full contract: [docs/AGENT_CONTRACT.md](docs/AGENT_CONTRACT.md). Runnable: +[samples/agent/agent-loop.ts](samples/agent/agent-loop.ts). ## Fonts & environment @@ -220,24 +293,39 @@ expects, from a base64/data-URI payload or a fetched URL respectively. ## Beyond authoring: post-processing pdfnative-react covers document *authoring*. For byte-level post-processing — -merging/splitting PDFs, reading/writing annotations, digital signatures, custom -crypto providers, or in-app font compilation — use the +merging/splitting, filling and flattening forms, text extraction, decryption, +digital signatures, annotations, or in-app font compilation — use the [`pdfnative`](https://www.npmjs.com/package/pdfnative) engine directly on the bytes this library produces. -## Migrating from 0.2 to 1.0 +[docs/RECIPES.md](docs/RECIPES.md) shows each of those, with working code. -1.0 marks the API as stable. The only breaking change: **`pdfnative` is now a -peer dependency**, so install it yourself alongside the wrapper: +## Upgrading to 1.1 + +Everything in 1.1.0 is additive. Two install-time floors moved: ```bash -npm install pdfnative-react pdfnative react +npm install pdfnative-react@^1.1.0 pdfnative@^1.6.0 react@^19 ``` -Everything else is additive — `
`, nested lists, `outline`/`pageLabels` -on ``, table `cellBorders`/`cellVAlign`, `inspectDocument`, -`renderToFileStream`, `resolveFonts`, and `fromUrl`/`fromBase64`. Requires -`pdfnative` ≥ 1.5, React 19, and Node.js ≥ 20. +- **`pdfnative` ≥ 1.6** is now required. `` compiles to a block type that + does not exist before 1.6.0, so an older engine would silently mis-render it. +- **Node ≥ 22** — inherited, not invented: `pdfnative@1.6.0` requires it, so a + compliant install is already there. + +No API was removed or changed. New: ``, `renderToResponse`, +`lintDocument`, the `watermark`/`header`/`footer`/`attachments`/`tagged` +document props, and the agent surface (`doctor`, `capabilityManifest`, +`validateSpec`, `schema(subject)`, `ErrorCode`). `docSpecSchema()` still works +and delegates to `schema('doc-spec')`. + +## Migrating from 0.2 to 1.0 + +1.0 marked the API as stable. The only breaking change was **`pdfnative` +becoming a peer dependency**, installed alongside the wrapper. Everything else +was additive — `
`, nested lists, `outline`/`pageLabels` on +``, table `cellBorders`/`cellVAlign`, `inspectDocument`, +`renderToFileStream`, `resolveFonts`, and `fromUrl`/`fromBase64`. ## Migrating from `@react-pdf/renderer` @@ -267,6 +355,18 @@ fonts, layout/PDF-A, the client hooks/components, and the compact agent spec. ## Documentation +**Guides** + +- [Charts](docs/CHARTS.md) — the five chart types, accessibility, PDF/A. +- [Server rendering](docs/SERVER.md) — `renderToResponse` on Next.js, Remix, + Hono, Deno, Bun, Workers and Express. +- [Linting](docs/LINTING.md) — the sixteen rules, and how to gate on them. +- [Recipes](docs/RECIPES.md) — merging, form filling, text extraction, + decryption: calling the engine on the bytes this library produces. +- [Agent contract](docs/AGENT_CONTRACT.md) — driving the package autonomously. + +**Reference** + - [Knowledge Base](docs/KNOWLEDGE_BASE.md) — architecture, the compile pipeline, the react-reconciler version contract, and the agent authoring contract. - [AGENTS.md](AGENTS.md) — guidance for AI agents working in this repo. diff --git a/ROADMAP.md b/ROADMAP.md index 126f158..dc65b27 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -26,12 +26,44 @@ authoring features through 1.5.0, plus the conveniences originally planned for - `renderToFileStream` (constant-memory file output). - `pdfnative` moved to a peer dependency (`^1.5.0`). +### 1.1.0 — Charts, server rendering, autonomous agents + +Tracks the engine's 1.6.0 release and closes the two adoption gaps that mattered +most: there was no first-class way to serve a PDF from a modern React server, +and no way for an AI agent to discover or check its own work. + +- `` — native vector charts (bar, barH, line, pie, donut), the one + authoring capability pdfnative 1.6.0 adds, with full `DocSpec` parity. +- `renderToResponse` / `renderSpecToResponse` — web-standard `Response`, + streaming by default. Next.js App Router, Remix, Hono, Deno, Bun, Workers. +- Document-level layout sugar: `watermark`, `header`, `footer`, `attachments`, + `tagged` — previously an undocumented `layout` pass-through. +- `lintDocument` / `lintSpec` — accessibility and layout rules with stable + codes, five of which pre-empt engine-level render failures. +- The agent surface: `ErrorCode` taxonomy, `capabilityManifest()`, `doctor()`, + `validateSpec()`, multi-subject `schema()`, and the governance contract + exported as runtime capability. +- Peer floor `^1.6.0`; Node floor `>=22` (inherited from the engine). + ## Later -- React Server Components streaming helpers. - React Native renderer (separate entry point). -- Layout linting / accessibility checks surfaced as dev warnings. -- Possible `` / `` authoring sugar over the `outline` prop. +- A `pdfnative-react` MCP server, so agents can drive the package as a tool set + over MCP rather than as a library import. The capability manifest and the + versioned schemas added in 1.1.0 are the groundwork for this. +- Incremental compilation for very large documents (reuse the reconciled tree + across renders when only data changed). + +### Considered and dropped + +- **`` / `` authoring sugar.** `outline="auto"` already + covers the common case, and an explicit `OutlineItem[]` covers the rest. + Adding components would grow the public surface — permanently — for a + marginal ergonomic gain. +- **Dev-mode automatic lint warnings.** `lintDocument` is deliberately pure: it + never writes to the console. Emitting warnings implicitly would make render + behaviour depend on `NODE_ENV` and put unrequested output in users' logs. + Call it explicitly, in a test or a CI gate — see `samples/quality/lint.tsx`. ## Non-goals diff --git a/docs/AGENT_CONTRACT.md b/docs/AGENT_CONTRACT.md new file mode 100644 index 0000000..d3c44ac --- /dev/null +++ b/docs/AGENT_CONTRACT.md @@ -0,0 +1,249 @@ +# Agent automation contract + +How an AI agent uses pdfnative-react without a human in the loop. + +Everything here returns plain JSON-serializable data. Nothing in this package +reaches the network, writes to GitHub, or emits telemetry — see +[Governance](#governance) at the bottom, and `aiGovernancePolicy()` for the +machine-readable version. + +Runnable version of this whole page: +[`samples/agent/agent-loop.ts`](../samples/agent/agent-loop.ts). + +## The recommended loop + +``` +1. doctor() will this environment work at all? +2. capabilityManifest() what can I do here? +3. schema('doc-spec') what grammar do I emit? +4. validateSpec(json) is what I produced well-formed? dry run, tier 1 +5. compileSpec(spec) does it map onto the document model? dry run, tier 2 +6. lintSpec(spec) is it accessible and engine-legal? dry run, tier 3 +7. renderSpecTo*(spec) only now, produce bytes. +``` + +Steps 4–6 are cheap and catch different classes of problem. Step 7 is the only +one that costs real work. + +## 1. Pre-flight + +```ts +import { doctor } from 'pdfnative-react'; + +const report = doctor(); +// { ok: true, checks: [{ name, status: 'ok' | 'warn' | 'error', value, detail }] } +``` + +`doctor()` **never throws**, including when the `pdfnative` peer is missing — +that is exactly what it is there to tell you. Checks cover the package version, +Node, React, the engine (via a capability probe rather than a version string, so +it survives bundling), Web Crypto, the Fetch API and `Blob`. + +Branch on `report.ok`. When it is `false`, report the failing checks rather than +attempting work that cannot succeed. + +Schema: `schema('doctor')`. + +## 2. Discovery + +```ts +import { capabilityManifest } from 'pdfnative-react'; + +const m = capabilityManifest(); +``` + +One object describing: + +| Field | Contents | +|---|---| +| `contract` | The invariants: authoring-only, block-flow layout, React 19, engine `^1.6.0`, Node `>=22`, no side effects, no network | +| `components` | Every JSX component, its host tag, and its aliases | +| `specBlocks` | The whole `DocSpec` grammar: tuple form, summary, equivalent component | +| `entrypoints` | Every callable, with signature, sync/async/stream, and Node-only flag | +| `errorCodes` | The `E_*` taxonomy | +| `lintRules` | Every `L_*` rule with its severity | +| `schemaSubjects` | What `schema()` will answer to | + +The manifest is derived from the same internal registries that build the JSON +Schema, and a test asserts every name it advertises resolves to a real export. +It cannot describe a capability that does not exist. + +Schema: `schema('manifest')`. CLI-style dump: +`npx tsx samples/agent/manifest.ts --json`. + +## 3. Schemas + +```ts +import { schema, schemaId, SCHEMA_SUBJECTS } from 'pdfnative-react'; + +schema('list'); // the self-describing index +schema(); // defaults to 'doc-spec' +schemaId('doc-spec'); // https://pdfnative.dev/schema/react/1.1.0/doc-spec.schema.json +``` + +Seven subjects: `doc-spec`, `render-options`, `lint-report`, `spec-validation`, +`doctor`, `manifest`, `list`. + +Each `$id` **embeds the package version**. If you cache a schema, compare `$id`s +to detect that the contract moved. An unknown subject throws with `E_INPUT`. + +No validator is bundled — the package only *emits* schemas, so it stays +dependency-free. Validate with whatever you already use, or use `validateSpec` +below when you cannot bring a validator at all. + +## 4. Authoring: prefer `DocSpec` + +`DocSpec` is a compact, JSON-serializable grammar of positional tuples that +compiles to **exactly** the same document as the equivalent JSX — it is built on +the same components, so the two cannot drift. + +```json +{ + "title": "Q4 revenue review", + "footer": { "right": "Page {page} of {pages}" }, + "blocks": [ + ["h1", "Q4 revenue review"], + ["p", "Revenue grew 24% year over year."], + ["chart", { + "chartType": "bar", + "series": [{ "label": "2026", "values": [15400, 21200, 29800, 38600] }], + "categories": ["Q1", "Q2", "Q3", "Q4"], + "altText": "Revenue rises each quarter from 15.4k to 38.6k." + }], + ["table", { "h": ["Channel", "Share"], "r": [["Direct", "46%"]] }] + ] +} +``` + +Emit this, not JSX. It costs a fraction of the tokens and it is data you can +validate before executing. + +## 5. The four dry-run tiers + +| Tier | Call | Cost | Catches | +|---|---|---|---| +| 1 | `validateSpec(unknown)` | trivial | Malformed shape: unknown kind, wrong arity, wrong payload type | +| 2 | `compileSpec(spec)` | cheap | Structure that cannot map onto the document model | +| 3 | `lintSpec(spec)` | cheap | Accessibility problems, and engine constraints that would throw | +| 4 | `inspectSpec(spec)` | ≈ a render | Pagination and per-block geometry | + +### Tier 1 — `validateSpec` + +```ts +const result = validateSpec(JSON.parse(untrusted)); +// { ok, errors: [{ code, severity, path, message }], warnings: [...] } +``` + +Never throws. Findings are path-anchored (`blocks[3][1]`), so an agent can +repair its own output rather than guessing. Codes: `V_NOT_OBJECT`, `V_BLOCKS`, +`V_BLOCK_SHAPE`, `V_UNKNOWN_KIND`, `V_ARITY`, `V_PAYLOAD_TYPE`, `V_OPTS_TYPE`, +`V_UNKNOWN_FIELD` (warning only — unknown fields are ignored, not fatal, so +forward compatibility is preserved). + +Arity and payload rules derive from the same table that builds the JSON Schema, +so the two can never disagree. + +### Tier 3 — `lintSpec` + +Sixteen rules with stable `L_*` codes. Five of them pre-empt failures that +would otherwise happen *inside the engine*, at render time: + +| Code | Would otherwise | +|---|---| +| `L_CHART_SERIES` | Throw — pie/donut need exactly one series | +| `L_CHART_CATEGORIES` | Throw — series length must match categories | +| `L_CHART_VALUES` | Throw — non-finite, or negative in a pie/donut | +| `L_CHART_POINTS` | Throw — 10 000-point ceiling | +| `L_ATTACHMENTS_NEED_PDFA3` | Throw — attachments require `tagged="pdfa3b"` | +| `L_TAGGED_NO_FONTS` | Produce a PDF/A file veraPDF rejects | + +Gate on `report.ok` (true when no `error`-severity finding). See +[LINTING.md](LINTING.md). + +## 6. Errors + +Every error carries a stable `code`. **Branch on the code, never on the +message** — messages are reworded freely between releases, codes are not. + +```ts +import { PdfReactError, ErrorCode, toErrorEnvelope } from 'pdfnative-react'; + +try { + render(); +} catch (err) { + const envelope = toErrorEnvelope(err); + // { ok: false, error: { code: 'E_STRUCTURE', message: '…' } } + if (err instanceof PdfReactError && err.code === ErrorCode.STRUCTURE) { /* … */ } +} +``` + +| Code | Meaning | +|---|---| +| `E_STRUCTURE` | The tree or spec cannot map onto the pdfnative model | +| `E_INPUT` | Invalid input (bad props, malformed spec, unknown schema subject) | +| `E_UNSUPPORTED` | The capability exists but is not available here | +| `E_ENV` | Missing peer, Node too old, absent Web API | +| `E_POLICY` | An AI-governance rule was violated | +| `E_RUNTIME` | Anything else | + +`toErrorEnvelope` accepts *any* thrown value, so a caller only ever handles one +shape. Runnable: [`samples/agent/error-envelope.tsx`](../samples/agent/error-envelope.tsx). + +## 7. Rendering + +| Target | Call | +|---|---| +| Bytes | `renderSpecToBytes(spec)` | +| HTTP response | `renderSpecToResponse(spec, { fileName, disposition })` | +| File | `renderSpecToFile(spec, path)` (Node) | +| Large file, flat memory | `renderSpecToFileStream(spec, path)` (Node) | +| Byte stream | `renderSpecToStream(spec)` | + +Each has a JSX twin (`renderTo*`). See [SERVER.md](SERVER.md) for the response +helpers. + +## Token economy + +Three levers, in order of impact: + +1. **Use `DocSpec`, not JSX.** Positional tuples cost a fraction of the tokens + of the equivalent component tree. +2. **Read the manifest once**, not the documentation repeatedly. It is the + compressed form of everything on this page. +3. **Fetch only the schema subject you need.** `schema('list')` is small; + `schema('doc-spec')` is the large one, and you rarely need it more than once. + +## Governance + +pdfnative-react ships **no code path** that writes to GitHub or makes an +outbound network call. An agent's authority ends at producing a local draft plus +a compliance report; a human reviews and submits it under their own identity. + +```ts +import { aiGovernancePolicy, agentRulesText, validateIssueDraft } from 'pdfnative-react'; + +aiGovernancePolicy(); // the machine-readable policy +agentRulesText(); // the protocol, as text +validateIssueDraft(markdown); // gate a draft: { ok, errors, warnings, code? } +``` + +`validateIssueDraft` is a pure string function. It rejects drafts that propose a +new runtime dependency or omit a reproduction block, and warns about missing +recommended fields. The repository's `npm run verify:issue` runs the same rules; +a test asserts the two implementations stay byte-identical. + +Full narrative: [AI_GOVERNANCE.md](AI_GOVERNANCE.md). +Agent-facing protocol: [`.github/AGENT_RULES.md`](../.github/AGENT_RULES.md). + +## Boundaries an agent must respect + +- **Authoring only.** Merging, splitting, form filling, text extraction, + signing, decryption — all belong to the `pdfnative` engine, operating on the + bytes this package produces. See [RECIPES.md](RECIPES.md). +- **No CSS layout model.** There is no ``, no flexbox, no absolute + positioning. pdfnative is a declarative block flow. Do not attempt to emulate + HTML layout; map onto the blocks in `capabilityManifest().specBlocks`. +- **React 19 only.** The reconciler is bound to a single, pinned version + contract. +- **No new runtime dependency**, in any proposal. The only one is + `react-reconciler`; `pdfnative` and `react` are peers. diff --git a/docs/CHARTS.md b/docs/CHARTS.md new file mode 100644 index 0000000..6f321af --- /dev/null +++ b/docs/CHARTS.md @@ -0,0 +1,153 @@ +# Charts + +Native vector charts, drawn with PDF path operators. No rasterisation, no chart +library, no new runtime dependency — and the output is real vector art that +stays sharp at any zoom and passes PDF/A. + +Requires the `pdfnative` engine ≥ 1.6.0, which is the peer floor as of +pdfnative-react 1.1.0. + +Runnable: [`samples/charts/charts.tsx`](../samples/charts/charts.tsx). + +## Quick start + +```tsx +import { Document, Chart } from 'pdfnative-react'; + + + + +``` + +The `DocSpec` twin: + +```json +["chart", { + "chartType": "bar", + "series": [{ "label": "2026", "values": [15400, 21200, 29800, 38600] }], + "categories": ["Q1", "Q2", "Q3", "Q4"], + "title": "Revenue by quarter", + "altText": "Revenue rises each quarter from 15.4k to 38.6k." +}] +``` + +## Chart types + +| `chartType` | Shape | Series | Negative values | +|---|---|---|---| +| `'bar'` | Vertical bars | Many | Yes | +| `'barH'` | Horizontal bars | Many | Yes | +| `'line'` | Lines, optional markers | Many | Yes | +| `'pie'` | Filled circle | **Exactly one** | No | +| `'donut'` | Ring | **Exactly one** | No | + +`barH` is the right choice when category labels are long — under a vertical axis +they get cramped or clipped. + +## Props + +| Prop | Type | Default | Notes | +|---|---|---|---| +| `chartType` | `ChartType` | — | Required | +| `series` | `ChartSeries[]` | — | Required. `{ label, values, color? }` | +| `categories` | `string[]` | 1-based indices | Every series must supply one value per category | +| `title` | `string` | — | Drawn above the plot | +| `width` | `number` | `460` | Points; clamped to the content width | +| `height` | `number` | `240` | Points; the title and legend add measured height on top | +| `legend` | `'bottom' \| 'none'` | `'bottom'` for multi-series and pie/donut, else `'none'` | | +| `axis` | `{ yMin?, yMax?, ticks?, grid? }` | — | Bar and line only | +| `markers` | `boolean` | `false` | Point markers on line series | +| `colors` | `PdfColor[]` | Built-in 8-colour palette | Per series (bar/line) or per slice (pie/donut) | +| `align` | `'left' \| 'center' \| 'right'` | `'left'` | | +| `altText` | `string` | Auto-generated | See below — write your own | + +## Accessibility + +Charts emit a tagged-PDF `/Figure` with an `/Alt` entry. When you omit +`altText`, the engine synthesises something generic — +`"bar chart: 2 series, 4 categories"` — which satisfies PDF/A but tells a reader +relying on it nothing about the data. + +Write the sentence you would say out loud: + +```tsx +altText="Revenue by quarter: 2026 outperforms 2025 throughout, ending at 38.6k versus 31k." +``` + +`lintDocument` reports `L_CHART_ALT` (severity `info`) when it is missing. + +## Validation + +The engine enforces its constraints by **throwing at render time**. `lintDocument` +turns each of them into a finding you can read first: + +| Rule | Constraint | +|---|---| +| `L_CHART_SERIES` | Pie and donut take exactly one series | +| `L_CHART_CATEGORIES` | Every series length must equal `categories.length` | +| `L_CHART_VALUES` | All values finite; no negatives in a pie/donut | +| `L_CHART_POINTS` | 10 000 data points per chart, hard ceiling | + +```ts +const report = lintDocument(doc); +if (!report.ok) { /* fix the data, do not render */ } +``` + +## Sizing and overflow + +`height` is the plot area; the title and legend are measured and added on top, +so the block is taller than `height` alone. A chart taller than the page content +box cannot be placed on any page — `lintDocument(doc, { overflow: true })` +reports `L_OVERFLOW` for exactly that case. + +For a full-width chart on A4 portrait with default margins, `width` around 460 +and `height` around 240–300 is a comfortable range. + +## Colours + +The default palette is an eight-colour categorical set. Override per chart: + +```tsx + +``` + +…or per series, which wins over the palette: + +```tsx +series={[{ label: 'Net margin', values: [-4.2, 1.8, 6.5, 11.3], color: '#e15759' }]} +``` + +Colours accept any `PdfColor`: a hex string, an RGB tuple, or a PDF operator +string. They are injection-safe — the engine validates them before emitting +operators. + +## PDF/A + +Charts use solid fills and no transparency, so they are safe in every PDF/A +conformance target. Remember that PDF/A additionally requires **every rendering +font to be embedded** — a chart's axis and legend labels are text. Pair +`tagged="pdfa2b"` with `fontEntries`, or `lintDocument` will report +`L_TAGGED_NO_FONTS` and veraPDF will reject the file (rule 6.2.11.4.1). + +```tsx +const fontEntries = await resolveFonts({ + latin: () => import('pdfnative/fonts/noto-sans-data.js'), +}); + + + + +``` + +## What is not here + +pdfnative 1.6.0 ships bar, barH, line, pie and donut on a linear axis. Stacked +bars, area, scatter, secondary/log/time axes and per-point data labels are +tracked as "Charts v2" on the [engine's roadmap](https://github.com/Nizoka/pdfnative/blob/main/ROADMAP.md) +— when they land there, they reach this package as new `ChartProps` fields. diff --git a/docs/KNOWLEDGE_BASE.md b/docs/KNOWLEDGE_BASE.md index 4be96d1..9742aaf 100644 --- a/docs/KNOWLEDGE_BASE.md +++ b/docs/KNOWLEDGE_BASE.md @@ -48,14 +48,35 @@ Key properties: | `src/reconciler/serialize.ts` | Pure transform: host tree → `DocumentParams`. | | `src/reconciler/render.ts` | `compile(node)` — drives the reconciler and serializes. | | `src/render.ts` | `renderToBytes/Blob/Stream/File/FileStream`, `compileDocument`, `inspectDocument`. | +| `src/response.ts` | `renderToResponse` — web-standard `Response`, streaming by default. Server-only; **never** `'use client'`. | +| `src/lint.ts` | `lintDocument` — runs on the *compiled* model, so JSX and `DocSpec` share one implementation. | +| `src/registry.ts` | **Single source of truth**: block grammar, components, lint rules. Pure data, no engine import. See §9. | +| `src/errors.ts` | `ErrorCode`, `PdfReactError`, `PdfStructureError`, `toErrorEnvelope`. | +| `src/manifest.ts` | `capabilityManifest()` — derived wholly from `registry.ts`, `errors.ts` and `spec/schema.ts`. | +| `src/doctor.ts` | `doctor()` — environment pre-flight. Every check is wrapped; it must never throw. | +| `src/governance.ts` | `aiGovernancePolicy`, `agentRulesText`, `validateIssueDraft`. | | `src/fonts.ts` | `resolveFonts` (loader map → `FontEntry[]`) + internal `optionsWithFonts`. `validateFontData` is re-exported from `core-bridge`. | | `src/assets.ts` | `fromUrl` / `fromBase64` image-byte helpers (pure, no engine import). | | `src/hooks.ts` | `usePdf`, `usePdfStream` (client). | | `src/viewer.tsx` | `PDFViewer`, `PDFDownloadLink`, `BlobProvider` (client). | | `src/core-bridge/index.ts` | The only place that imports `pdfnative` at runtime. | +| `src/spec/validate.ts` | `validateSpec` — structural validation with no JSON-Schema engine. | | `src/types.ts` | Public types + type-only re-exports of the pdfnative model. | | `src/index.ts` | Public barrel. | +Two import-graph invariants worth preserving: + +- **`src/registry.ts` imports nothing at runtime.** That is what lets + `spec/schema.ts` describe a lint report without importing `lint.ts` — and + therefore without dragging the engine into the schema path. Emitting a schema + stays a pure, dependency-free operation. (It is also why `LINT_RULES` lives in + the registry and is merely *re-exported* from `lint.ts`.) +- **`core-bridge` imports `estimateChartHeight` purely as a capability probe** + for `doctor()`. It is a 1.6.0 marker, and probing beats parsing a version + string out of `package.json` — it survives bundling into a browser build, + which the CLI learned the hard way when tsup flattened its `require` away. It + is deliberately *not* re-exported from the public barrel. + The golden rule has one sanctioned exception: `src/types.ts` may import *type-only* from `pdfnative` directly. All *runtime* imports go through `core-bridge`. `src/types.ts` also defines the ergonomic `FontLoader` @@ -107,6 +128,16 @@ Notes learned the hard way: `cellBorders`/`cellVAlign` pass straight through. - **Document-level** `outline` and `pageLabels` are `` props (not content blocks) — they reference post-layout page indexes, like `metadata`. +- **Layout sugar** (`watermark`, `header`, `footer`, `attachments`, `tagged`) is + likewise `` props: page furniture, not blocks in the flow. Making + them components would mean host tags with no corresponding pdfnative block, + which golden rule 2 forbids. `resolveLayout()` folds them into `layout` under + the engine's keys, with an explicit `layout` prop always winning — mirroring + how `RenderOptions.layout` overrides `DocumentParams.layout` in `prepare()`. + **Critical invariant:** when no sugar prop is set and no `layout` is given, + `resolveLayout` returns `undefined`, never `{}`. An empty object would change + the serialized bytes of every existing document; `tests/layout-sugar.test.tsx` + pins this. - `
` is a **composite** component: React resolves it to a `` (optionally preceded by ``) plus its children *before* the reconciler runs, so the serializer never sees a `section` host tag. @@ -131,7 +162,26 @@ Notes learned the hard way: nested list/outline/pageLabels/cellBorders forwarding, `inspectSpec`, real `renderSpec*` PDF output, and the JSON Schema `$id`/version/recursive `$defs`. - `tests/version.test.ts` — pins `version` to `package.json` and `CITATION.cff` - (reads them via `process.cwd()`; `import.meta.url` file URLs break under jsdom). + (reads them via `process.cwd()`; `import.meta.url` file URLs break under jsdom), + plus the engine peer floor, the single-runtime-dependency rule, and that + `llms.txt` ships in the tarball. +- `tests/registry.test.ts` — locks the exact, ordered registry contents and + cross-checks the derived schema. See §9. +- `tests/chart.test.tsx` — `` serialization, every chart type, DocSpec + parity, real PDF output. +- `tests/layout-sugar.test.tsx` — the sugar-folding rules and the + `layout === undefined` invariant. +- `tests/response.test.tsx` — the HTTP contract, streaming vs buffered, and that + both modes emit identical bytes. +- `tests/lint.test.tsx` — one assertion per lint rule, plus `lintSpec ≡ lintDocument`. +- `tests/agent.test.tsx` — the error taxonomy (including that + `PdfStructureError` is still the same class object on its legacy import path), + the manifest ↔ barrel cross-check, `doctor`, and `validateSpec`. +- `tests/schema.test.ts` — every subject, the versioned `$id`, and the + `docSpecSchema()` backward-compatibility alias. +- `tests/governance.test.ts` — the `verify-issue.mjs` CLI as a black box, the + exported policy against `.github/ai-governance.json`, and the source-level + parity of the duplicated regex tables. - jsdom lacks `URL.createObjectURL`; `tests/setup.ts` stubs it. ## 7. Agent authoring contract (`src/spec/`) @@ -157,18 +207,25 @@ Design rules: component prop types (via `Pick`/`Omit`) so the spec inherits the components' type safety. `TableRowSpec` accepts either a `string[]` (widened to `{ cells, type:'default', pointed:false }`) or a full `PdfRow`. -- **Versioned schema.** `docSpecSchema()` returns a Draft 2020-12 JSON Schema - whose `$id` is `https://pdfnative.dev/schema/react//doc-spec.schema.json` +- **Versioned schema.** `schema(subject?)` returns a Draft 2020-12 JSON Schema + whose `$id` is `https://pdfnative.dev/schema/react//.schema.json` (`version` comes from `src/version.ts`, the single source of truth that - `tests/version.test.ts` pins to `package.json`). Agents can self-validate a - spec before rendering. + `tests/version.test.ts` pins to `package.json`). Seven subjects; `docSpecSchema()` + is retained and delegates to `schema('doc-spec')`. Agents can self-validate a + spec before rendering — or use `validateSpec`, which needs no validator at all. - **Isomorphic, no `'use client'`.** The spec module is pure/render-agnostic; `renderSpec*` reuse the existing isomorphic `render*` entry points. - **No `['sec']` tuple.** `
` is JSX sugar with no capability beyond a heading followed by its blocks, so DocSpec stays frugal and omits it — agents emit `['h2', title]` + the blocks directly. Nested lists, `outline`, - `pageLabels`, and table `cellBorders`/`cellVAlign` *are* in the grammar, - because they express capability the tuples otherwise couldn't. + `pageLabels`, table `cellBorders`/`cellVAlign`, charts, and the layout sugar + *are* in the grammar, because they express capability the tuples otherwise + couldn't. +- **Body objects for data-heavy blocks.** `table`, `img`, `field` and `chart` + take a named body (`['chart', { chartType, series, … }]`) rather than deep + positional payloads. The token saving from positional form is marginal on a + nested structure like `series[].values`, and named keys measurably reduce + generation errors — which is the point of the grammar. - **GOTCHA.** `createElement` for default-param components (`Spacer`, `TableOfContents`) needs an explicit generic (`createElement`), otherwise TS infers `Attributes` and rejects the extra props (TS2769). @@ -185,3 +242,93 @@ Design rules: providers, font compilation — is done with the `pdfnative` engine directly on the bytes this library emits. The wrapper deliberately does not re-export those APIs. + +## 9. Agent automation contract + +§7 covers *authoring* cheaply. This section covers everything else an agent +needs to run without a human: knowing whether the environment works, what the +API is, and whether its own output is correct. The user-facing version is +[AGENT_CONTRACT.md](AGENT_CONTRACT.md); this is the implementation view. + +### The anti-drift mechanism + +The hard problem with a machine-readable API description is that it rots. The +CLI solved it by deriving both its shell completions and its capability manifest +from one `COMMANDS` table; we apply the same idea, with a compile-time lock on +top. + +`src/registry.ts` holds three tables and imports nothing at runtime: + +| Table | Consumers | +|---|---| +| `BLOCK_REGISTRY` | `spec/schema.ts` (`$defs.block.oneOf`, arity, descriptions), `spec/validate.ts` (arity + payload rules), `manifest.ts` (`specBlocks`) | +| `COMPONENT_REGISTRY` | `manifest.ts` (`components`) | +| `LINT_RULES` | `lint.ts` (severities), `spec/schema.ts` (`lint-report` enum), `manifest.ts` (`lintRules`) | + +Two independent locks make omission a failure rather than a silent gap: + +1. **Compile-time.** The file ends with `Assert>` and the `HostTag` equivalent. Add a member to `BlockSpec` + or `HostTag` without registering it and `npm run typecheck` fails. The + `satisfies Record` on `BLOCK_SCHEMAS` in `schema.ts` is a + second, independent compile error for the same mistake. +2. **Test-time.** `tests/registry.test.ts` pins the exact ordered contents and + cross-checks the generated schema; `tests/agent.test.tsx` asserts every name + the manifest advertises resolves to a real export of `src/index.ts`. + +**If you change this mechanism, verify it is still real:** delete a registry +entry and confirm *both* `npm run typecheck` and `tests/registry.test.ts` fail. +If only one does, the lock has become decorative and needs fixing. + +### The four dry-run tiers + +Deliberately layered so an agent pays only for the confidence it needs: + +| Tier | Call | Cost | Catches | +|---|---|---|---| +| 1 | `validateSpec(unknown)` | trivial | Shape: unknown kind, wrong arity, wrong payload type | +| 2 | `compileSpec` / `compileDocument` | cheap | Structure that cannot map onto the model | +| 3 | `lintSpec` / `lintDocument` | cheap | Accessibility, and engine constraints that would throw | +| 4 | `inspectSpec` / `inspectDocument` | ≈ a render | Pagination and geometry | + +`validateSpec` deliberately bundles **no** JSON-Schema validator: the package +only *emits* schemas, so it stays dependency-free and usable in edge runtimes. +Its findings are path-anchored (`blocks[3][1]`) so an agent can repair its own +output rather than guessing. Unknown top-level fields are a *warning*, not an +error, which preserves forward compatibility when a newer spec meets an older +package. + +Tier 3 is where the real leverage is: five of the sixteen lint rules +(`L_CHART_*`, `L_ATTACHMENTS_NEED_PDFA3`) mirror validation the engine performs +by **throwing mid-render**. `L_ATTACHMENTS_NEED_PDFA3` exists because writing +`samples/layout/watermark-header-footer.tsx` hit exactly that throw. + +### Error taxonomy + +`PdfReactError` carries a stable `ErrorCode` and a `toJSON()` producing the +ecosystem's envelope. `PdfStructureError` extends it. + +The class **moved** from `reconciler/serialize.ts` to `errors.ts` in 1.1.0, but +`serialize.ts` re-exports the same class object, so both import paths yield an +identical `instanceof` — `tests/agent.test.tsx` asserts the object identity, not +just the behaviour. + +`toErrorEnvelope(unknown)` normalises *any* thrown value, so a caller only ever +handles one shape. + +### `doctor()` must never throw + +Every check is wrapped, because the case it most needs to report — a missing +`pdfnative` peer — is the case that would otherwise crash the import. The engine +check is a **capability probe** (`typeof estimateChartHeight === 'function'`) +rather than a version-string parse: it works after bundling, in the browser, and +it tests the capability we actually need instead of a number that claims it. + +### Governance duplication is deliberate + +`scripts/verify-issue.mjs` must stay zero-dependency and runnable in a checkout +that has never been built — CI and the black-box tests invoke it with plain +`node`. It therefore cannot import `src/governance.ts`. The regex tables are +duplicated, and `tests/governance.test.ts` parses the script's source to assert +both copies are literally identical. Duplication with a proof is honest; +duplication with a comment is not. diff --git a/docs/LINTING.md b/docs/LINTING.md new file mode 100644 index 0000000..5483605 --- /dev/null +++ b/docs/LINTING.md @@ -0,0 +1,172 @@ +# Linting + +`lintDocument` checks a document for accessibility and layout problems — and for +constraints the engine would otherwise enforce by throwing at render time. + +It runs on the **compiled document model**, so JSX and `DocSpec` share one +implementation and always agree. It is pure: it never writes to the console and +never throws for a finding. What you do with the report is your call. + +Runnable: [`samples/quality/lint.tsx`](../samples/quality/lint.tsx). + +## Quick start + +```ts +import { lintDocument } from 'pdfnative-react'; + +const report = lintDocument(); +// { ok, findings: [{ code, severity, message, blockIndex?, hint? }], counts } + +if (!report.ok) { + for (const f of report.findings) console.error(`${f.code}: ${f.message}`); + process.exit(1); +} +``` + +`ok` is `true` when no finding has severity `'error'`. `lintSpec(spec, options?)` +is the `DocSpec` twin. + +## Why this exists + +Two different problems, one tool. + +**Accessibility is invisible until someone is harmed by its absence.** An image +with no alt text, a table with no header row, a heading hierarchy that skips a +level — none of these break the render, and none are visible in the output. They +only surface when a screen reader hits them. + +**Engine constraints throw.** A pie chart with two series, a PDF/A document with +no embedded fonts, an attachment outside PDF/A-3 — these fail *inside* the +engine, mid-render, with a stack trace. Linting turns them into a finding with a +hint, before you spend the work. + +## Rules + +Sixteen rules, each with a stable code. Branch on the code, not the message. + +### Errors — these clear `ok` + +| Code | Rule | +|---|---| +| `L_EMPTY_DOCUMENT` | The document has no blocks | +| `L_TAGGED_NO_FONTS` | PDF/A requested with no `fontEntries` (veraPDF 6.2.11.4.1) | +| `L_TAGGED_ENCRYPTED` | PDF/A and encryption combined (ISO 19005-1 §6.3.2) | +| `L_ATTACHMENTS_NEED_PDFA3` | Attachments outside `tagged="pdfa3b"` (ISO 19005-3) | +| `L_CHART_SERIES` | Pie or donut with anything other than one series | +| `L_CHART_CATEGORIES` | Series length ≠ `categories.length` | +| `L_CHART_VALUES` | Non-finite value, or a negative in a pie/donut | +| `L_CHART_POINTS` | Chart past the engine's 10 000-point ceiling | + +The last five would each throw at render time. + +### Warnings + +| Code | Rule | +|---|---| +| `L_IMAGE_ALT` | Image with no alt text | +| `L_TABLE_HEADERS` | Table with no header row | +| `L_HEADING_HIERARCHY` | Heading level skipped (h1 → h3) | +| `L_FIELD_LABEL` | Form field with no label | +| `L_LINK_TEXT` | Link with no text, or whose text is the bare URL | +| `L_MAX_BLOCKS` | Block count within 10% of the `maxBlocks` ceiling | +| `L_OVERFLOW` | Block taller than the content box, or past the bottom margin | + +### Info + +| Code | Rule | +|---|---| +| `L_CHART_ALT` | Chart with no `altText` — the engine's auto-generated one is generic | + +The full registry, with descriptions, is available at runtime: + +```ts +import { LINT_RULES, LINT_RULE_CODES } from 'pdfnative-react'; +``` + +…and in `capabilityManifest().lintRules`. + +## Options + +```ts +interface LintOptions extends RenderOptions { + overflow?: boolean; // default false + rules?: readonly LintRuleCode[]; // default: all +} +``` + +**`overflow`** enables `L_OVERFLOW`, which needs a full layout pass via +`inspectDocument` — roughly the cost of a render. Off by default for that +reason; turn it on in CI rather than in a hot path. + +**`rules`** filters the report. This is how you adopt the linter on an existing +codebase without a wall of findings: fix one class at a time. + +```ts +lintDocument(doc, { rules: ['L_IMAGE_ALT', 'L_TABLE_HEADERS'] }); +``` + +## Findings + +```ts +interface LintFinding { + code: LintRuleCode; // stable — branch on this + severity: 'error' | 'warning' | 'info'; + message: string; // human-readable; not stable across releases + blockIndex?: number; // index into DocumentParams.blocks, when block-scoped + hint?: string; // how to fix it +} +``` + +`counts` gives `{ error, warning, info }` for quick triage without a filter pass. + +## Using it + +### As a test + +The most natural home. It is deterministic and fast. + +```ts +it('has no accessibility errors', () => { + expect(lintDocument().findings).toEqual([]); +}); +``` + +### As a CI gate + +```ts +const report = lintDocument(doc); +if (!report.ok) { + for (const f of report.findings.filter((f) => f.severity === 'error')) { + console.error(`${f.code} ${f.message}`); + if (f.hint) console.error(` → ${f.hint}`); + } + process.exit(1); +} +``` + +### Before rendering + +Worth it when the document is data-driven and the data is not yours — a chart +built from a user upload, a spec produced by an agent: + +```ts +const report = lintSpec(spec); +if (!report.ok) return Response.json({ errors: report.findings }, { status: 422 }); +return renderSpecToResponse(spec); +``` + +## Why there is no automatic dev warning + +`lintDocument` never logs. Emitting warnings implicitly would make render +behaviour depend on `NODE_ENV`, put output you did not ask for into your logs, +and make the function impure — which would rule out calling it inside a test +assertion, its single most useful application. + +Call it explicitly. It is one line. + +## What it does not do + +It checks the *document model*, not the rendered bytes. It cannot tell you that +a glyph fell back to `.notdef`, or that a colour contrast is too low. For +conformance verification of finished bytes, use the engine's `validatePdfUA` or +run veraPDF — see [RECIPES.md](RECIPES.md). diff --git a/docs/RECIPES.md b/docs/RECIPES.md new file mode 100644 index 0000000..a8142e4 --- /dev/null +++ b/docs/RECIPES.md @@ -0,0 +1,195 @@ +# Recipes — working with the bytes + +pdfnative-react is an **authoring** library. It turns a component tree into PDF +bytes and stops there. Everything that operates on *existing* PDF bytes — +merging, splitting, filling forms, extracting text, decrypting, signing, +annotating — belongs to the [`pdfnative`](https://www.npmjs.com/package/pdfnative) +engine, which you already have installed as a peer dependency. + +This page shows how to do those things. There is no new API to learn here and +nothing to install: you import from `pdfnative` directly and hand it the +`Uint8Array` this library produced. + +## Why the boundary exists + +It would be easy to re-export the engine's post-processing functions from this +package. We deliberately do not, for three reasons: + +1. **A wrapper that re-exports is a wrapper you must maintain forever.** Every + engine signature change becomes a breaking change here, and every engine + feature becomes a release we owe you. +2. **It would lie about what the package is.** `pdfnative-react` is a React + renderer. `extractText` has nothing to do with React. +3. **You do not need us in the middle.** `pdfnative` is a zero-dependency + package with a stable API. Calling it directly is one import line, and you + get its documentation, its types and its release notes unfiltered. + +The rule is stated as golden rule 7 in [AGENTS.md](../AGENTS.md). + +## Setup + +Every recipe assumes: + +```ts +import { renderToBytes } from 'pdfnative-react'; + +const bytes = renderToBytes(); // authored here +``` + +…and then imports the operation from the engine. + +## Extract text (RAG, search, verification) + +New in engine 1.6.0. Decodes content streams into per-page reading-order text, +resolving `/ToUnicode` CMaps, `/Encoding /Differences`, and WinAnsi/MacRoman +tables. Works on encrypted documents via `options.password`. + +```ts +import { extractText } from 'pdfnative'; + +const pages = extractText(bytes); +for (const page of pages) { + console.log(`--- page ${String(page.pageIndex + 1)} ---`); + console.log(page.text); +} + +// Positioned runs, for layout-aware indexing: +const [first] = extractText(bytes, { pages: [0], includeRuns: true }); +for (const run of first.runs ?? []) { + console.log(run.text, run.x, run.y, run.fontSize); +} +``` + +A `maxTextLength` cap (16 M characters by default) keeps this safe on untrusted +input. + +**Useful as a test assertion.** Extraction is the honest way to check that text +really rendered, rather than falling back to `.notdef` boxes: + +```ts +const text = extractText(renderToBytes())[0].text; +expect(text).not.toContain('?'); // catches a missing font +expect(text).toContain('Total due'); +``` + +## Fill and flatten an AcroForm + +New in engine 1.6.0. `` authors the widgets; these read and fill +them back. The update is incremental and non-destructive, so prior signatures +stay valid for their revision. + +```ts +import { readFormFields, fillForm, flattenForm } from 'pdfnative'; + +const form = renderToBytes(); + +for (const field of readFormFields(form)) { + console.log(field.name, field.type, field.value); +} + +const filled = fillForm(form, { + 'applicant.email': 'user@example.com', + 'applicant.consent': true, + 'applicant.country': ['FR'], +}); + +// Stamp the appearances into the page content and drop the interactive layer. +const frozen = flattenForm(filled); +``` + +Typed failures — `FormFieldNotFoundError`, `FormValueTypeError`, +`FormUnsupportedError` — each carry a `code`. + +## Merge, split, extract pages + +```ts +import { mergePdfs, splitPdf, extractPages } from 'pdfnative'; + +const merged = mergePdfs([coverBytes, bodyBytes, appendixBytes]); +const [firstHalf, secondHalf] = splitPdf(merged, [{ start: 0, end: 9 }, { start: 10, end: 19 }]); +const summary = extractPages(merged, [0, 1, 2]); +``` + +Up to 50 source documents per merge. For large inputs, the streaming variants +hold only the cross-reference offsets in memory and compose with `streamToFile`: + +```ts +import { streamMergedPdfs, streamToFile } from 'pdfnative'; + +await streamToFile(streamMergedPdfs([a, b, c], { chunkSize: 64 * 1024 }), 'out.pdf'); +``` + +## Encrypt, decrypt, rotate passwords + +Authoring-side encryption is a layout option, so it stays in this package: + +```tsx + +``` + +Note that PDF/A forbids encryption (ISO 19005-1 §6.3.2) — +`lintDocument` reports `L_TAGGED_ENCRYPTED` if you combine them. + +Reading and re-securing an *existing* document is the engine's job: + +```ts +import { openPdf, mergePdfs } from 'pdfnative'; + +const reader = openPdf(protectedBytes, { password: 'user-password' }); +console.log(reader.encryption); // { algorithm: 'aes256', revision: 6, authenticatedAs: 'user' } + +// Open with the old password, re-secure with a new one, in a single call. +const rotated = mergePdfs([{ bytes: protectedBytes, password: 'old' }], { + encrypt: { ownerPassword: 'new', algorithm: 'aes256' }, +}); +``` + +`PdfPasswordError` and `PdfEncryptionUnsupportedError` are the typed failures. + +## Sign, annotate, inspect + +```ts +import { signPdfBytes, createModifier, openPdf, validatePdfUA } from 'pdfnative'; + +const signed = signPdfBytes(bytes, { /* certificate, key, … */ }); + +const modifier = createModifier(bytes); +modifier.addAnnotation(0, { /* highlight, note, … */ }); + +const report = validatePdfUA(bytes); // accessibility conformance +``` + +## Compile a font at runtime + +Useful in serverless or sandboxed runtimes where you cannot spawn the +`pdfnative-build-font` CLI: + +```ts +import { parseFontData, compileFontData } from 'pdfnative/tools'; +import { registerFont } from 'pdfnative-react'; + +const data = parseFontData(ttfBuffer); +registerFont('brand', () => Promise.resolve(data)); +``` + +`registerFont`, `registerFonts`, `loadFontData` and `validateFontData` *are* +re-exported from this package, because font registration happens before +authoring, not after. + +## What stays here + +| Concern | Where | +|---|---| +| Composing a document | `pdfnative-react` | +| Fonts, images, assets | `pdfnative-react` (`resolveFonts`, `fromUrl`, `fromBase64`) | +| Layout, watermark, header/footer, attachments, PDF/A | `pdfnative-react` (`` props, `layout`) | +| Encryption **of a document you are authoring** | `pdfnative-react` (`layout.encryption`) | +| Checking a document before rendering | `pdfnative-react` (`lintDocument`, `inspectDocument`) | +| Anything applied to bytes that already exist | **`pdfnative`** | + +## See also + +- [pdfnative on npm](https://www.npmjs.com/package/pdfnative) — the engine's own + guides cover each of these in depth. +- [AGENTS.md](../AGENTS.md) — golden rule 7 and the rest of the contract. +- [LINTING.md](LINTING.md) — catching PDF/A and chart problems before rendering. diff --git a/docs/SERVER.md b/docs/SERVER.md new file mode 100644 index 0000000..f32db80 --- /dev/null +++ b/docs/SERVER.md @@ -0,0 +1,168 @@ +# Server rendering + +`renderToResponse` turns a document into a web-standard `Response`. That is the +whole API — and because `Response` is a platform primitive rather than a +framework type, the same code runs unchanged on Node, the Edge runtime, Deno, +Bun and Cloudflare Workers. + +Runnable: [`samples/server/next-route-handler.tsx`](../samples/server/next-route-handler.tsx). + +## Next.js App Router + +```tsx +// app/invoice/[id]/route.tsx +import { renderToResponse } from 'pdfnative-react'; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const invoice = await loadInvoice(id); + + return renderToResponse(, { + fileName: `invoice-${id}.pdf`, + disposition: 'inline', + }); +} +``` + +No `'use client'`, no dynamic import, no `runtime` pragma. This is ordinary +server code. + +## Options + +```ts +interface PdfResponseOptions extends RenderOptions { + fileName?: string; // default 'document.pdf' + disposition?: 'inline' | 'attachment'; // default 'inline' + buffered?: boolean; // default false (stream) + status?: number; // default 200 + headers?: HeadersInit; // merged last — can override defaults +} +``` + +`RenderOptions` (`layout`, `fontEntries`, `fonts`) is inherited, so everything +you can pass to `renderToBytes` works here. Because `renderToResponse` is async, +the `fonts` loader-map shortcut **is** honoured — unlike the synchronous entry +points. + +## Streaming versus buffered + +**Streaming is the default.** The body is a `ReadableStream` fed by the engine's +page-by-page generator: peak memory stays flat regardless of document size, and +the browser starts receiving bytes before the last page exists. + +```ts +renderToResponse(doc); // ReadableStream body, no Content-Length +renderToResponse(doc, { buffered: true }); // single buffer, Content-Length set +``` + +Choose `buffered: true` when something downstream needs the size up front — a +CDN, a proxy that will not chunk, or a client showing a determinate progress +bar. The bytes are identical either way; a test asserts it. + +If the client disconnects mid-stream, the generator's cleanup runs via the +stream's `cancel` hook. + +## Filenames + +`Content-Disposition` is built to RFC 6266. Non-ASCII names get both forms — an +ASCII fallback and the encoded `filename*` — so every reader gets something +sensible: + +``` +inline; filename="facture-_crite.pdf"; filename*=UTF-8''facture-%C3%A9crite.pdf +``` + +## From a `DocSpec` + +```ts +import { renderSpecToResponse, validateSpec } from 'pdfnative-react'; + +export async function POST(request: Request) { + const body: unknown = await request.json(); + + const check = validateSpec(body); + if (!check.ok) { + return Response.json({ ok: false, errors: check.errors }, { status: 400 }); + } + + return renderSpecToResponse(body as DocSpec, { fileName: 'report.pdf' }); +} +``` + +Validate before rendering when the spec came from outside — `validateSpec` is +cheap, never throws, and returns path-anchored findings you can hand straight +back to the caller. + +## Other frameworks + +**Remix / React Router** — a loader returns a `Response`, so this is a direct fit: + +```ts +export async function loader({ params }: LoaderFunctionArgs) { + return renderToResponse(, { fileName: 'invoice.pdf' }); +} +``` + +**Hono, Elysia, Deno, Bun, Workers** — all handlers return `Response`: + +```ts +app.get('/invoice.pdf', async () => renderToResponse()); +``` + +**Express / Node `http`** — these want a Node stream, so convert: + +```ts +import { Readable } from 'node:stream'; + +app.get('/invoice.pdf', async (_req, res) => { + const response = await renderToResponse(); + res.setHeader('content-type', 'application/pdf'); + res.setHeader('content-disposition', response.headers.get('content-disposition')!); + Readable.fromWeb(response.body as never).pipe(res); +}); +``` + +## Server Actions + +A Server Action cannot return a `Response`, so return the bytes and let the +client build the download — or, better, point the client at a route handler and +keep the PDF out of the RSC payload entirely: + +```tsx +'use server'; +import { renderToBytes } from 'pdfnative-react'; + +export async function generate(id: string): Promise { + return renderToBytes(); +} +``` + +## Runtime requirements + +`Response` and `ReadableStream` are required. Both are global from Node 18 +onward, and this package's floor is Node 22, so on a supported install they are +always present. `doctor()` reports them as the `fetch-api` check — useful if you +are targeting an unusual runtime. + +Rendering itself is pure computation: no filesystem, no network, no native +modules. It works in a sandbox, a Worker, or a read-only container. + +## Caching + +The PDF is a deterministic function of your data, so cache it like any other +derived resource: + +```ts +return renderToResponse(doc, { + headers: { + 'cache-control': 'public, max-age=3600, immutable', + etag: `"invoice-${id}-${String(invoice.updatedAt)}"`, + }, +}); +``` + +`headers` is merged last, so it overrides the defaults — including +`content-type` if you really mean to. diff --git a/llms.txt b/llms.txt index f2f6b03..c393191 100644 --- a/llms.txt +++ b/llms.txt @@ -5,13 +5,18 @@ > object and renders real PDF bytes — no DOM, no headless browser, no native > modules. It is a declarative block flow, not a CSS/flexbox layout engine. +Version: 1.1.0 · pairs with pdfnative 1.6.0. + +This file is an LLM-facing capability summary. For the machine-readable JSON +version, call `capabilityManifest()`; for schemas, call `schema(subject)`. + ## Install ``` npm install pdfnative-react pdfnative react ``` -Requires React 19 and Node.js >= 20. +Requires React 19, pdfnative >= 1.6, Node.js >= 22. ## Core idea @@ -21,7 +26,7 @@ to a `Uint8Array` PDF. ## Components -- Document (root: title, footerText, metadata, fontEntries, layout, outline, pageLabels) +- Document (root: title, footerText, metadata, fontEntries, layout, outline, pageLabels, watermark, header, footer, attachments, tagged) - Page (explicit page boundary; auto-pagination otherwise) - Section (sugar: title + grouped content; level?, color?, break?) - Heading (level 1-3) @@ -35,24 +40,40 @@ to a `Uint8Array` PDF. - TableOfContents / Toc (alias) - Barcode (format, data) — qr, code128, ean13, pdf417, datamatrix - Svg (data: path or markup; / render as selectable PDF text) +- Chart (chartType, series) — bar, barH, line, pie, donut; native vector, PDF/A-safe - FormField (fieldType, name) — interactive AcroForm widgets -Document.outline: OutlineItem[] | 'auto' (bookmarks). Document.pageLabels: PageLabelRange[]. +## Document-level page furniture (props on , not components) + +- watermark: string | WatermarkOptions (a string is shorthand for { text: { text } }) +- header / footer: PageTemplate { left?, center?, right?, fontSize?, color? } + Placeholders resolved at render time: {page} {pages} {date} {title} +- attachments: PdfAttachment[] { filename, data, mimeType, description?, relationship? } + Requires tagged: 'pdfa3b' — the engine throws otherwise. +- tagged: boolean | 'pdfa1b' | 'pdfa2b' | 'pdfa2u' | 'pdfa3b' + PDF/A requires embedded fonts: pair with fontEntries. + +All five fold into `layout` under the engine's keys (watermark, headerTemplate, +footerTemplate, attachments, tagged). An explicit `layout` prop wins. +Document.outline: OutlineItem[] | 'auto'. Document.pageLabels: PageLabelRange[]. ## Rendering - renderToBytes(node, options?) -> Uint8Array - renderToBlob(node, options?) -> Blob (application/pdf) - renderToStream(node, options?) -> AsyncGenerator (constant memory) +- renderToResponse(node, options?) -> Promise (web standard; streams by default) - renderToFile(node, path, options?) -> Promise (Node only) -- renderToFileStream(node, path, options?) -> Promise (Node, constant memory, keeps outline/pageLabels) +- renderToFileStream(node, path, options?) -> Promise (Node, constant memory) - compileDocument(node) -> DocumentParams (inspect the model) - inspectDocument(node, options?) -> LayoutInspection (page/block geometry, no render) +- lintDocument(node, options?) -> LintReport (accessibility + engine constraints) `options`: { layout?: Partial, fontEntries?: FontEntry[], fonts?: FontsMap }. -layout supports viewerPreferences and debug (overlay). fonts (loader map) is honored only by -async entries (renderToFile, renderToFileStream, usePdf, usePdfStream); for sync entries do -`fontEntries: await resolveFonts({...})`. +`renderToResponse` also takes { fileName?, disposition?: 'inline'|'attachment', +buffered?, status?, headers? }. fonts (loader map) is honored only by async +entries (renderToFile, renderToFileStream, renderToResponse, usePdf, +usePdfStream); for sync entries do `fontEntries: await resolveFonts({...})`. ## Fonts & assets @@ -82,13 +103,13 @@ Prefer it when generating documents programmatically. - compileSpec(spec) -> DocumentParams - specToElement(spec) -> ReactElement ( tree) - renderSpecToBytes / renderSpecToBlob / renderSpecToStream / renderSpecToFile -- docSpecSchema() -> Draft 2020-12 JSON Schema ($id embeds the package version) -- docSpecSchemaId() -> the schema $id string - +- renderSpecToFileStream / renderSpecToResponse - inspectSpec(spec, options?) -> LayoutInspection -- renderSpecToFileStream(spec, path, options?) -> Promise +- lintSpec(spec, options?) -> LintReport +- validateSpec(spec: unknown) -> { ok, errors, warnings } (no JSON-Schema engine needed) -DocSpec = { title?, footerText?, metadata?, fontEntries?, layout?, outline?, pageLabels?, blocks }. +DocSpec = { title?, footerText?, metadata?, fontEntries?, layout?, outline?, +pageLabels?, watermark?, header?, footer?, attachments?, tagged?, blocks }. Block tuples (kind, ...payload, opts?): - ['h1'|'h2'|'h3', text, opts?] - ['p', text, opts?] @@ -102,22 +123,68 @@ Block tuples (kind, ...payload, opts?): - ['toc', opts?] - ['qr'|'code128'|'ean13'|'pdf417'|'datamatrix', data, opts?] - ['svg', data, opts?] +- ['chart', { chartType, series, categories?, title?, width?, height?, legend?, axis?, markers?, colors?, align?, altText? }] - ['field', { fieldType, name, ... }] +## Agent surface + +- doctor() -> { ok, checks: [{ name, status: 'ok'|'warn'|'error', value, detail }] } + Environment pre-flight. NEVER throws, including when the pdfnative peer is + missing. Call this first in an unfamiliar environment. +- capabilityManifest() -> everything the package can do, as plain JSON: + components, specBlocks (the whole grammar), entrypoints, errorCodes, + lintRules, schemaSubjects, and the contract invariants. +- schema(subject?) -> Draft 2020-12 JSON Schema; schemaId(subject?) -> versioned $id. + Subjects: doc-spec (default), render-options, lint-report, spec-validation, + doctor, manifest, list. The $id embeds the package version, so a caching + consumer can detect contract drift. Unknown subject throws E_INPUT. + docSpecSchema() / docSpecSchemaId() are retained and delegate to 'doc-spec'. +- aiGovernancePolicy(), agentRulesText(), validateIssueDraft(md) — the + human-in-the-loop contract, shipped as runtime capability. + +Recommended loop: + doctor -> capabilityManifest -> schema -> validateSpec -> compileSpec -> lintSpec -> render + +Four dry-run tiers, cheapest first: + 1. validateSpec(unknown) malformed shape (V_* codes, path-anchored) + 2. compileSpec(spec) structure that cannot map onto the model + 3. lintSpec(spec) accessibility + engine constraints (L_* codes) + 4. inspectSpec(spec) pagination and geometry (costs ~a render) + ## Errors -PdfStructureError — thrown when a tree cannot be mapped (e.g. root is not -). +Every error carries a stable code. Branch on the code, never the message. + +- PdfReactError (base) — .code, .toJSON() -> { ok: false, error: { code, message } } +- PdfStructureError extends PdfReactError — code 'E_STRUCTURE' +- toErrorEnvelope(unknown) -> the same envelope for any thrown value +- ErrorCode: E_STRUCTURE, E_INPUT, E_UNSUPPORTED, E_ENV, E_POLICY, E_RUNTIME + +## Lint rules (stable L_* codes) + +errors: L_EMPTY_DOCUMENT, L_TAGGED_NO_FONTS, L_TAGGED_ENCRYPTED, + L_ATTACHMENTS_NEED_PDFA3, L_CHART_SERIES, L_CHART_CATEGORIES, + L_CHART_VALUES, L_CHART_POINTS +warnings: L_IMAGE_ALT, L_TABLE_HEADERS, L_HEADING_HIERARCHY, L_FIELD_LABEL, + L_LINK_TEXT, L_MAX_BLOCKS, L_OVERFLOW +info: L_CHART_ALT + +The five chart/attachment errors pre-empt failures the engine would otherwise +raise by throwing mid-render. L_OVERFLOW requires { overflow: true }. ## Notes - No `` / flexbox by design — pdfnative is a declarative block flow. - React 19 only (single react-reconciler version contract); React 18 is not planned. -- Authoring only. For byte-level post-processing (merge/split, annotations, - signing, crypto, font compilation) use the pdfnative engine directly. +- Authoring only. For byte-level post-processing (merge/split, form fill/flatten, + text extraction, decryption, annotations, signing, font compilation) use the + pdfnative engine directly on the bytes this library produces — see docs/RECIPES.md. +- No outbound network calls, no telemetry, no autonomous GitHub writes. ## Links - npm: https://www.npmjs.com/package/pdfnative-react - repo: https://github.com/Nizoka/pdfnative-react - engine: https://www.npmjs.com/package/pdfnative +- agent contract: docs/AGENT_CONTRACT.md +- recipes (post-processing): docs/RECIPES.md diff --git a/package-lock.json b/package-lock.json index 0d8c33e..b75f05c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pdfnative-react", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pdfnative-react", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "dependencies": { "react-reconciler": "^0.31.0" @@ -20,7 +20,7 @@ "@vitest/coverage-v8": "^4.1.7", "eslint": "^9.0.0", "jsdom": "^25.0.0", - "pdfnative": "^1.5.0", + "pdfnative": "^1.6.0", "react": "^19.0.0", "react-dom": "^19.0.0", "tsup": "^8.0.0", @@ -29,14 +29,14 @@ "vitest": "^4.1.7" }, "engines": { - "node": ">=20" + "node": ">=22" }, "funding": { "type": "individual", "url": "https://plika.app" }, "peerDependencies": { - "pdfnative": "^1.5.0", + "pdfnative": "^1.6.0", "react": "^19.0.0" } }, @@ -4134,9 +4134,9 @@ "license": "MIT" }, "node_modules/pdfnative": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/pdfnative/-/pdfnative-1.5.0.tgz", - "integrity": "sha512-dl9UYcbErGqtKivaW6lPTqA4t9wqv1xEbQZYq3p1ykbUGS9yKo5PyBCHj1H2skcq54aNON/G/tPAvqyRYz6ZwA==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/pdfnative/-/pdfnative-1.6.0.tgz", + "integrity": "sha512-gzwDxXD8iMLM5tSd86RQwIiX0gh9Oe2IzpYCnOgVzsHqOIPnbOZdiHWRkxZphAXIofEdrqBb5Zr/uhSBiVyD7w==", "dev": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index fbf6dba..19197de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pdfnative-react", - "version": "1.0.0", + "version": "1.1.0", "description": "React renderer for pdfnative — declarative JSX components (, , , …) that compile to PDF on-device with zero SaaS round-trips. Live preview, streaming, 22 Unicode scripts. The frontend gateway to the pdfnative ecosystem.", "type": "module", "main": "./dist/index.cjs", @@ -21,6 +21,7 @@ }, "files": [ "dist", + "llms.txt", "LICENSE", "README.md" ], @@ -63,9 +64,24 @@ "on-device", "ssr", "frontend", + "nextjs", + "rsc", + "chart", + "charts", + "accessibility", + "pdf-ua", + "linting", "ai-agent", "agentic", + "ai-governance", + "hitl", + "human-in-the-loop", + "automation", + "json-output", "json-schema", + "llms-txt", + "rag", + "mcp", "sbom", "supply-chain" ], @@ -84,14 +100,14 @@ "url": "https://plika.app" }, "engines": { - "node": ">=20" + "node": ">=22" }, "publishConfig": { "access": "public", "provenance": true }, "peerDependencies": { - "pdfnative": "^1.5.0", + "pdfnative": "^1.6.0", "react": "^19.0.0" }, "dependencies": { @@ -106,7 +122,7 @@ "@vitest/coverage-v8": "^4.1.7", "eslint": "^9.0.0", "jsdom": "^25.0.0", - "pdfnative": "^1.5.0", + "pdfnative": "^1.6.0", "react": "^19.0.0", "react-dom": "^19.0.0", "tsup": "^8.0.0", diff --git a/release-notes/draft/PR-v1.1.0.md b/release-notes/draft/PR-v1.1.0.md new file mode 100644 index 0000000..e74f31d --- /dev/null +++ b/release-notes/draft/PR-v1.1.0.md @@ -0,0 +1,258 @@ +# v1.1.0 — Charts, server rendering, and an autonomous agent surface + +> **Branch:** `release/v1.1.0` → `main` +> **Type:** Minor release. No API removed or changed; two install-time floors raised. +> **pdfnative:** `^1.6.0` (peer + dev), was `^1.5.0` +> **Node:** `>=22`, was `>=20` + +## Summary + +Tracks the [`pdfnative` 1.6.0](https://github.com/Nizoka/pdfnative/releases/tag/v1.6.0) +engine release, and closes the two gaps that were costing adoption: there was no +first-class way to serve a PDF from a modern React server, and no way for an AI +agent to check its environment, discover the API, or verify its own output. + +Four themes: + +1. **Engine 1.6.0 authoring surface** — ``, the *only* authoring + capability 1.6.0 adds, with full `DocSpec` parity and schema coverage. +2. **Server rendering** — `renderToResponse` / `renderSpecToResponse` returning a + web-standard `Response`, streaming by default. +3. **Document-level layout sugar + linting** — `watermark`, `header`, `footer`, + `attachments`, `tagged` as first-class props; and `lintDocument`/`lintSpec`, + whose rules include five that pre-empt engine-level render failures. +4. **The agent automation contract** — `ErrorCode`, `capabilityManifest()`, + `doctor()`, `validateSpec()`, multi-subject `schema()`, and the governance + contract exported as runtime capability. Backed by a new single-source + registry with compile-time anti-drift locks. + +## Install-time floors (no API break, but read this) + +Neither is a source-breaking change; both are install-time requirements. + +- **`pdfnative` peer `^1.5.0` → `^1.6.0`.** `` compiles to a `chart` + block that does not exist before 1.6.0; a 1.5 engine would receive an unknown + block and silently drop or mis-render it. A loud install error beats a quiet + wrong PDF. The alternative (`^1.5.0 || ^1.6.0` plus a capability guard on every + chart path) trades a build-time error for a runtime surprise. +- **Node `>=20` → `>=22`.** Inherited, not invented: `pdfnative@1.6.0` requires + Node ≥ 22, so any compliant install is already there. CI matrix is now 22/24. + +## Changes + +### New: `src/registry.ts` — the anti-drift mechanism + +Three single-source tables (`BLOCK_REGISTRY`, `COMPONENT_REGISTRY`, +`LINT_RULES`) that `spec/schema.ts`, `spec/validate.ts` and `manifest.ts` all +*derive* from rather than restate. Pure data; imports nothing at runtime, which +is what keeps schema emission free of the engine. + +Two independent locks: + +- Compile-time — `Assert>` and the + `HostTag` twin; plus `satisfies Record` on `BLOCK_SCHEMAS`. +- Test-time — `tests/registry.test.ts` pins the exact ordered contents; + `tests/agent.test.tsx` asserts every manifest name resolves to a real export. + +**Verified destructively:** removing the `chart` entry produces two independent +compile errors (`registry.ts` `TS2344`, `schema.ts` `TS2353`) *and* fails +`tests/registry.test.ts`. If a future change leaves only one half failing, the +lock has become decorative. + +### `src/core-bridge/index.ts` + +- Type re-exports for `ChartBlock`/`ChartSeries`/`ChartType`, and for the layout + sugar (`PageTemplate`, `WatermarkOptions`/`WatermarkText`/`WatermarkImage`, + `PdfAttachment`/`PdfAttachmentRelationship`, `EncryptionOptions`). +- One new *runtime* import: `estimateChartHeight`, used **solely as a capability + probe** by `doctor()` — it first exists in 1.6.0. Probing beats parsing a + version string: it survives bundling into a browser build (the trap + `pdfnative-cli` hit when tsup flattened its `require`). Deliberately not + re-exported from the public barrel. + +### `src/components.tsx` + +- `` — props mirror `ChartBlock` one-for-one. +- `` gains `watermark` (accepts a plain string as shorthand for + `{ text: { text } }`), `header`, `footer`, `attachments`, `tagged`. + +These are props, not child components, because they are document-level page +furniture; a component would mean a host tag with no corresponding pdfnative +block, which golden rule 2 forbids. `` already has precedent +(`outline`, `pageLabels`, `metadata`). + +### `src/reconciler/serialize.ts` + `nodes.ts` + +- `HostTag` gains `'chart'`; `toBlock` gains the `chart` case. +- New `resolveLayout()` folds the sugar props into `layout` under the engine's + keys, with an explicit `layout` always winning — matching `prepare()`'s + precedence in `render.ts`. +- **Critical invariant:** with no sugar and no `layout`, `resolveLayout` returns + `undefined`, never `{}`. An empty object would change the serialized bytes of + every existing document. Pinned by three assertions in + `tests/layout-sugar.test.tsx`. +- `PdfStructureError` moves to `src/errors.ts` but is **re-exported from here**, + so the original import path and class identity are preserved. + +### New: `src/response.ts` + +`renderToResponse(node, options?)` → `Promise`. Streams via a +`ReadableStream` over the existing `renderToStream` generator (with a `cancel` +hook so the generator cleans up on client disconnect); `buffered: true` uses +`renderToBytes` and sets `Content-Length`. RFC 6266 `Content-Disposition` +including `filename*` for non-ASCII. `async`, so `options.fonts` is honoured. + +Stays on the root barrel rather than a subpath: `sideEffects: false` plus tsup +already give tree-shaking, and another `exports` condition would be cost without +benefit. No `'use client'` — this is server code. + +### New: `src/lint.ts` + +`lintDocument(node, options?)` → `LintReport`. Runs on the **compiled** +`DocumentParams`, so JSX and `DocSpec` share one implementation for free +(`lintSpec` is a two-line delegate, and a test asserts they agree). + +Sixteen rules. Five pre-empt failures the engine raises by throwing mid-render; +`L_ATTACHMENTS_NEED_PDFA3` exists because writing +`samples/layout/watermark-header-footer.tsx` hit exactly that throw. + +Pure by design: no console output, no throwing, `overflow` opt-in because it +costs a layout pass. + +### New: `src/errors.ts`, `src/manifest.ts`, `src/doctor.ts`, `src/governance.ts` + +- `ErrorCode` (`E_STRUCTURE`, `E_INPUT`, `E_UNSUPPORTED`, `E_ENV`, `E_POLICY`, + `E_RUNTIME`), `PdfReactError` with `.code` and `.toJSON()`, and + `toErrorEnvelope(unknown)` so a caller only ever handles one shape. +- `capabilityManifest()` — derived wholly from the registries. +- `doctor()` — every check wrapped; must never throw, since a missing peer is + the case it exists to report. +- `governance.ts` — `aiGovernancePolicy`, `agentRulesText`, `validateIssueDraft`. + The regex tables are **duplicated** from `scripts/verify-issue.mjs` because + that script must stay zero-dependency and runnable in an unbuilt checkout; + `tests/governance.test.ts` parses its source and asserts both tables are + literally identical. Duplication with a proof, not with a comment. + +### `src/spec/` (DocSpec parity) + +- `ChartSpec` = `['chart', ChartSpecBody]` — a body object like `table`/`img`/ + `field`, since the payload is nested (`series[].values`, `axis.yMin`) and named + keys measurably reduce generation errors. +- Five new top-level `DocSpec` fields mirroring the layout sugar. +- `schema.ts` refactored: `$defs.block.oneOf` assembled from the registry, with + arity and descriptions sourced there too (removed from the builders, so they + cannot disagree). Seven subjects; `docSpecSchema()`/`docSpecSchemaId()` retained + and delegating, pinned by a `toEqual` test. +- New `spec/validate.ts` — `validateSpec(unknown)`, zero-dependency structural + validation with path-anchored `V_*` findings. Unknown top-level fields are a + *warning*, preserving forward compatibility. + +### Samples & tests + +- 6 new samples: `charts/charts.tsx`, `layout/watermark-header-footer.tsx`, + `server/next-route-handler.tsx`, `quality/lint.tsx`, `agent/agent-loop.ts`, + `agent/manifest.ts`, `agent/error-envelope.tsx`. All added to + `samples/README.md` (with new "Server" and "Quality" sections) and all executed + end to end, not just type-checked. +- 6 new test files: `registry`, `chart`, `layout-sugar`, `response`, `lint`, + `agent`, `schema`. `governance` and `version` extended. +- **79 → 205 tests**, 8 → 15 files. Coverage improved on every axis. + +### Docs & governance + +- New guides: `docs/CHARTS.md`, `docs/SERVER.md`, `docs/LINTING.md`, + `docs/AGENT_CONTRACT.md`, and **`docs/RECIPES.md`** — the counterpart to golden + rule 7, with working code for `extractText`, `fillForm`/`flattenForm`, + `openPdf({ password })`, merge/split and re-encryption. +- `docs/KNOWLEDGE_BASE.md` — new §9 "Agent automation contract"; §3 module map, + §5 serialization rules and §6 test map updated. +- `README.md`, `llms.txt`, `AGENTS.md`, `CLAUDE.md`, `ROADMAP.md`, + `CHANGELOG.md`, `CITATION.cff`, `.github/ai-governance.json` all updated. +- `AGENTS.md` gains an "adding a block kind" checklist that now routes through + the registry, and a "recommended agent loop" section. +- **CI** — Node matrix 20/22/24 → 22/24, and a new advisory governance step that + validates any staged draft. `ai-governance.json` declared `advisory_in_ci: true` + but no workflow had ever run it. +- `package.json` — `files` now includes `llms.txt` (it was never shipped), and + keywords extended for discovery. + +## Validation + +``` +npm run typecheck:all clean (src + tests + samples) +npm run lint clean, zero warnings +npm test 205 passed / 205, 15 files +npm run test:coverage 94.74 stmts · 85.71 branches · 97.15 funcs · 96.00 lines + (thresholds 85/80/85/85 — unchanged, not lowered) +npm run build ESM 80.8kB · CJS 83.2kB · d.ts + d.cts 67.1kB +npm pack --dry-run llms.txt present; 10 files, 205.8 kB +``` + +Additionally verified by hand: + +- CJS `require` and ESM `import` smoke tests against the **built** artifacts, + covering all new exports; `doctor().ok === true`, manifest reports 14 block + kinds, `schema()['$id']` carries `1.1.0`. +- Every new sample executed and confirmed to write a valid PDF. +- The registry lock verified destructively (see above). + +## Backward compatibility + +| Change | Impact | +|---|---| +| Schema `$id` now `/1.1.0/` | By design — the versioned `$id` *is* the drift-detection contract | +| `params.layout` populated by sugar | Only when a sugar prop is used; `undefined` invariant preserved and tested | +| `PdfStructureError extends PdfReactError` | `instanceof` (both classes and `Error`) and `.name` unchanged | +| `PdfStructureError` moved to `errors.ts` | Same class object re-exported from the old path; identity asserted in tests | +| `docSpecSchema()` / `docSpecSchemaId()` | Retained; `toEqual` test against `schema('doc-spec')` | +| `files` += `llms.txt` | Tarball grows ~9 kB; no API impact | +| peer `^1.6.0`, Node `>=22` | Install-time only — the two friction points, headlined above | + +## Out of scope (by design) + +pdfnative 1.6.0 also shipped `extractText`, `readFormFields`/`fillForm`/ +`flattenForm`, `openPdf({ password })`, `streamMergedPdfs`/`streamSplitPdf`/ +`streamExtractPages`, and `MergeOptions.encrypt`. None are re-exported: they +operate on *existing* bytes, and this package authors documents (golden rule 7). +`docs/RECIPES.md` shows how to call each of them on the bytes we produce. + +Also dropped, with reasons recorded in `ROADMAP.md`: + +- `` / `` sugar — `outline="auto"` already covers the common + case; permanent public surface for a marginal gain. +- Automatic dev-mode lint warnings — would make render behaviour depend on + `NODE_ENV` and emit unrequested output; also would make `lintDocument` impure, + ruling out its best use (a test assertion). + +## Self-review checklist + +- [x] **1.** All runtime `pdfnative` imports still go through `core-bridge`; + `types.ts` remains the one type-only exception; `pdfnative` is still a peer. +- [x] **2.** No CSS layout model introduced. `` maps 1:1 onto the engine's + `chart` block; the layout sugar is `` props, not new host tags. + `
` is still the only composite. +- [x] **3.** react-reconciler contract untouched — no change to `host-config.ts` + or `reconciler/render.ts`. +- [x] **4.** Strict TypeScript, no `any`; lint clean with zero warnings. +- [x] **5.** `'use client'` unchanged on `hooks.ts`/`viewer.tsx`; none added to + `src/spec/`; `response.ts` is explicitly server-side. +- [x] **6.** `DocSpec` ↔ JSX parity holds — every new capability reaches both + surfaces, with `compileSpec` `toEqual` `compileDocument` tests for charts + and the layout sugar. `src/version.ts` bumped; `package.json` and + `CITATION.cff` in sync (pinned by test). +- [x] **7.** Authoring only — nothing byte-level re-exported; + `docs/RECIPES.md` added as the documented alternative. +- [x] **8.** This PR is a **draft**. No issue, PR, comment, branch push, release + or publish was performed autonomously. A human reviews and submits it + under their own identity. + +## Compliance report + +| Field | Value | +|---|---| +| `no_new_runtime_dependency_confirmed` | ✅ `dependencies` is still exactly `["react-reconciler"]`, asserted by `tests/version.test.ts` | +| `reproduction_command` | `npm run typecheck:all && npm run lint && npm run test:coverage && npm run build && npm pack --dry-run` | +| `reproduction_result` | All green; 205/205 tests; coverage above thresholds on all four axes | +| `duplicate_search_performed` | N/A — release PR, not an issue report | +| `affected_packages` | `pdfnative-react` only. Upstream `pdfnative` docs still reference `pdfnative-react v1.0.0` in `docs/guides/react.md`, `llms.txt`, `AGENTS.md` and `README.md` — a companion PR there would be worthwhile, and is **not** included here. | +| `identity_reminder_shown` | ✅ This draft must be reviewed and submitted by a human under their own GitHub identity. You share responsibility for its content. | diff --git a/release-notes/v1.1.0.md b/release-notes/v1.1.0.md new file mode 100644 index 0000000..d974e44 --- /dev/null +++ b/release-notes/v1.1.0.md @@ -0,0 +1,214 @@ +# pdfnative-react v1.1.0 + +_Released 2026-07-25_ + +Charts, server rendering, and an agent surface complete enough to drive the +package without a human. + +Tracks the [`pdfnative` 1.6.0](https://github.com/Nizoka/pdfnative/releases/tag/v1.6.0) +engine release. Everything in the public API is additive — but **two +install-time floors moved**, so read the next section first. + +## Compatibility — read this first + +```bash +npm install pdfnative-react@^1.1.0 pdfnative@^1.6.0 react@^19 +``` + +| Requirement | 1.0.0 | 1.1.0 | +|---|---|---| +| `pdfnative` peer | `^1.5.0` | **`^1.6.0`** | +| Node.js | `>=20` | **`>=22`** | +| React | `^19.0.0` | `^19.0.0` (unchanged) | + +**Why the engine floor moved.** `` compiles to a `chart` block, which +does not exist before pdfnative 1.6.0. A 1.5 engine would receive an unknown +block type and silently drop or mis-render it. A loud install-time requirement +is better than a quiet wrong PDF. + +**Why the Node floor moved.** It is *inherited*, not invented: +`pdfnative@1.6.0` itself requires Node ≥ 22, so any compliant install is already +there. We now say so. + +No API was removed, renamed, or changed in a backward-incompatible way. +`docSpecSchema()` and `docSpecSchemaId()` still work. `PdfStructureError` is +still importable from every path it was, and is still the same class object, so +`instanceof` is unaffected. + +## Highlights + +### Charts + +```tsx + +``` + +Five types — `bar`, `barH`, `line`, `pie`, `donut` — drawn as pure PDF path +operators. No rasterisation, no chart library, no new runtime dependency, and +the output is real vector art that stays sharp at any zoom and passes PDF/A. +Multi-series, legends, "nice" axis ticks, gridlines, markers, palette overrides, +negative values, and a tagged-PDF `/Figure` + `/Alt`. + +The matching `DocSpec` tuple is `['chart', { chartType, series, … }]`. + +[Guide](../docs/CHARTS.md) · [sample](../samples/charts/charts.tsx) + +### Serving a PDF + +```tsx +// app/invoice/[id]/route.tsx +export async function GET() { + return renderToResponse(, { fileName: 'invoice.pdf' }); +} +``` + +`renderToResponse` returns a web-standard `Response`. Because `Response` is a +platform primitive rather than a framework type, the same code runs unchanged on +Node, the Edge runtime, Deno, Bun and Cloudflare Workers. + +Streams by default — the body is a `ReadableStream` fed by the engine's +page-by-page generator, so peak memory stays flat and the client receives bytes +immediately. `buffered: true` switches to one buffer and adds `Content-Length`. +`Content-Disposition` follows RFC 6266, including `filename*` for non-ASCII +names. + +[Guide](../docs/SERVER.md) · [sample](../samples/server/next-route-handler.tsx) + +### Document-level page furniture + +```tsx + +``` + +These `PdfLayoutOptions` fields already worked, as an opaque and entirely +undocumented `layout` pass-through. They are now first-class props, with types, +schema coverage, samples and tests. `{page}`, `{pages}`, `{date}` and `{title}` +resolve at render time. + +They are props rather than components on purpose: they are page furniture, not +blocks in the flow, and a component would mean a host tag with no corresponding +pdfnative block. An explicit `layout` prop still wins over all of them. + +[Sample](../samples/layout/watermark-header-footer.tsx) + +### Linting + +```ts +const report = lintDocument(); +// { ok, findings: [{ code, severity, message, blockIndex?, hint? }], counts } +``` + +Sixteen deterministic rules with stable `L_*` codes, covering accessibility +(missing alt text, tables without headers, skipped heading levels, unlabelled +form fields) and — more valuably — **five constraints the engine would otherwise +enforce by throwing mid-render**: + +| Rule | Would otherwise | +|---|---| +| `L_CHART_SERIES` | Throw — pie/donut need exactly one series | +| `L_CHART_CATEGORIES` | Throw — series length must match categories | +| `L_CHART_VALUES` | Throw — non-finite, or negative in a pie/donut | +| `L_CHART_POINTS` | Throw — 10 000-point ceiling | +| `L_ATTACHMENTS_NEED_PDFA3` | Throw — attachments require `tagged="pdfa3b"` | +| `L_TAGGED_NO_FONTS` | Produce a PDF/A file veraPDF rejects | + +It runs on the compiled document model, so JSX and `DocSpec` share one +implementation, and it is pure — no console output, no throwing. + +[Guide](../docs/LINTING.md) · [sample](../samples/quality/lint.tsx) + +### An agent surface that can actually run alone + +Until now an agent could *author* cheaply, via `DocSpec`, but could not check +the environment, discover the API, or verify its own output. That is closed: + +```ts +doctor(); // will this environment work? never throws +capabilityManifest(); // every component, block, entry point, error code +schema('list'); // seven subjects, each with a versioned $id +validateSpec(json); // path-anchored findings, no JSON-Schema engine needed +lintSpec(spec); // accessibility + engine legality +``` + +Plus a stable `E_*` error taxonomy: every error carries a `code` and serializes +to `{ ok: false, error: { code, message } }`. Branch on the code — messages are +reworded between releases, codes are not. + +The human-in-the-loop governance contract now ships as runtime capability too +(`aiGovernancePolicy`, `agentRulesText`, `validateIssueDraft`), so an agent +working from an installed package — with no repository checkout — can read the +rules it must follow. `llms.txt` is now in the published tarball for the same +reason. + +Four dry-run tiers, cheapest first: + +| Tier | Call | Catches | +|---|---|---| +| 1 | `validateSpec` | Malformed shape | +| 2 | `compileSpec` | Structure that cannot map onto the model | +| 3 | `lintSpec` | Accessibility, and engine constraints that would throw | +| 4 | `inspectSpec` | Pagination and geometry | + +[Contract](../docs/AGENT_CONTRACT.md) · [sample](../samples/agent/agent-loop.ts) + +## Under the hood: one table, no drift + +The hard part of shipping a machine-readable API description is that it rots. +`src/registry.ts` now holds the block grammar, the component list and the lint +rules as single-source tables; the JSON Schema, `validateSpec` and the capability +manifest all *derive* from them. + +Two independent locks make omission a failure rather than a silent gap: + +- **Compile-time** — `Assert>` types mean adding a member to + `BlockSpec` or `HostTag` without registering it fails `npm run typecheck`. +- **Test-time** — `tests/registry.test.ts` pins the exact ordered contents, and + `tests/agent.test.tsx` asserts every name the manifest advertises resolves to + a real export of the barrel. + +The mechanism was verified by deleting a registry entry and confirming both +halves fail. + +## What is deliberately not here + +pdfnative 1.6.0 also shipped text extraction, form fill/flatten, an encrypted-PDF +reader, streaming page-tree manipulation, and output re-encryption. None of them +are re-exported here, because they operate on *existing* bytes and this package +authors documents — golden rule 7. + +[docs/RECIPES.md](../docs/RECIPES.md) is the new counterpart: working code for +each of those, calling `pdfnative` directly on the bytes this library produces. +No wrapper, no indirection, no API we would owe you forever. + +Also considered and dropped: `` / `` sugar (`outline="auto"` +already covers the common case), and automatic dev-mode lint warnings (they +would make render behaviour depend on `NODE_ENV` and put unrequested output in +your logs). + +## Validation + +- `npm run typecheck:all` — clean (src + tests + samples) +- `npm run lint` — clean, zero warnings +- **205 tests across 15 files**, all green (was 79 across 8) +- Coverage **94.7% statements · 85.7% branches · 97.2% functions · 96.0% lines** + (thresholds 85/80/85/85, unchanged) +- `npm run build` — ESM + CJS + `.d.ts` + `.d.cts` +- CJS and ESM import smoke tests on the built artifacts +- `npm pack --dry-run` — `llms.txt` present in the tarball +- Every new sample executed end to end and verified to produce a valid PDF + +## Full changelog + +[CHANGELOG.md](../CHANGELOG.md#110--charts-server-rendering-and-an-autonomous-agent-surface) diff --git a/samples/README.md b/samples/README.md index e797dd9..f69fd25 100644 --- a/samples/README.md +++ b/samples/README.md @@ -45,15 +45,37 @@ npx tsx samples/agent/compact-spec.ts # writes compact-spec.pdf | [layout/page-setup.tsx](layout/page-setup.tsx) | Page size, margins, and PDF/A-2b archival mode via `layout`. | | [layout/viewer-preferences.tsx](layout/viewer-preferences.tsx) | `layout.viewerPreferences` — control how a reader opens the PDF. | | [layout/debug-inspect.tsx](layout/debug-inspect.tsx) | `layout.debug` overlay + `inspectDocument` layout report. | +| [layout/watermark-header-footer.tsx](layout/watermark-header-footer.tsx) | `watermark` / `header` / `footer` / `attachments` / `tagged` props, and a real PDF/A-3 document. | +| [charts/charts.tsx](charts/charts.tsx) | All five chart types: bar, horizontal bar, line, pie, donut — with axes, legends, palettes and negative values. | -## Agent samples — token-frugal authoring +## Server samples — HTTP responses + +`renderToResponse` returns a web-standard `Response`, so one implementation +covers Next.js, Remix, Hono, Deno, Bun and Cloudflare Workers. + +| Sample | Shows | +|---|---| +| [server/next-route-handler.tsx](server/next-route-handler.tsx) | A Next.js App Router route handler, streaming and buffered modes, the `DocSpec` variant, and an Express recipe. | + +## Quality samples + +| Sample | Shows | +|---|---| +| [quality/lint.tsx](quality/lint.tsx) | `lintDocument` — accessibility findings, rule filtering, the opt-in overflow check, and a CI gate. | + +## Agent samples — autonomous usage The compact `DocSpec` lets LLM agents author documents with a fraction of the -tokens of JSX, compiling to the **same** PDF. +tokens of JSX, compiling to the **same** PDF. The rest of the agent surface — +discovery, pre-flight, validation — is designed to be driven without a human in +the loop. See [docs/AGENT_CONTRACT.md](../docs/AGENT_CONTRACT.md). | Sample | Shows | |---|---| +| [agent/agent-loop.ts](agent/agent-loop.ts) | **Start here.** The full loop: `doctor` → `capabilityManifest` → `schema` → `validateSpec` → `compileSpec` → `lintSpec` → render. | | [agent/compact-spec.ts](agent/compact-spec.ts) | A full invoice from a terse `DocSpec` → `renderSpecToFile`. | +| [agent/manifest.ts](agent/manifest.ts) | `capabilityManifest()` — every component, block, entry point, error code and lint rule. Pass `--json` to pipe it. | +| [agent/error-envelope.tsx](agent/error-envelope.tsx) | The `E_*` taxonomy, `toErrorEnvelope`, and branching on codes rather than messages. | | [agent/schema.ts](agent/schema.ts) | Print the versioned JSON Schema agents validate against. | ## Client samples (React components) diff --git a/samples/agent/agent-loop.ts b/samples/agent/agent-loop.ts new file mode 100644 index 0000000..c432b0a --- /dev/null +++ b/samples/agent/agent-loop.ts @@ -0,0 +1,123 @@ +/** + * The recommended agent loop, end to end. + * + * Run with: npx tsx samples/agent/agent-loop.ts + * Writes `agent-loop.pdf` on success. + * + * This is the whole autonomous-usage contract in one file: + * + * 1. doctor() — will this environment work at all? + * 2. capabilityManifest() — what can I do here? + * 3. schema('doc-spec') — what grammar do I emit? + * 4. validateSpec() — is the JSON I produced well-formed? (dry run 1) + * 5. compileSpec() — does it map onto the document model? (dry run 2) + * 6. lintSpec() — is it accessible and engine-legal? (dry run 3) + * 7. renderSpecTo*() — only now, produce bytes. + * + * Every step returns plain data. Nothing here reaches the network, writes to + * GitHub, or emits telemetry — see aiGovernancePolicy(). + */ + +import { + aiGovernancePolicy, + capabilityManifest, + compileSpec, + doctor, + lintSpec, + renderSpecToFile, + schema, + toErrorEnvelope, + validateSpec, +} from '../../src/index.js'; +import type { DocSpec } from '../../src/index.js'; + +// ── 1. Pre-flight ──────────────────────────────────────────────────────────── + +const health = doctor(); +console.log('doctor:', health.ok ? 'ok' : 'PROBLEMS'); +for (const check of health.checks) { + console.log(` ${check.status.padEnd(5)} ${check.name.padEnd(16)} ${check.value}`); +} +if (!health.ok) { + console.error('Environment is not usable; stopping before doing any work.'); + process.exit(1); +} + +// ── 2. Discovery ───────────────────────────────────────────────────────────── + +const manifest = capabilityManifest(); +console.log(`\n${manifest.name} ${manifest.version} — ${String(manifest.specBlocks.length)} block kinds`); +console.log(' contract:', JSON.stringify(manifest.contract)); +console.log(' entry points:', manifest.entrypoints.map((e) => e.name).join(', ')); + +// ── 3. Grammar ─────────────────────────────────────────────────────────────── + +console.log('\nschema subjects:', manifest.schemaSubjects.join(', ')); +console.log('doc-spec $id:', schema('doc-spec')['$id']); + +// ── 4. Validate what we generated (dry run, tier 1) ────────────────────────── + +/** Pretend this arrived as JSON from a model. */ +const generated: unknown = { + title: 'Q4 revenue review', + footer: { right: 'Page {page} of {pages}' }, + blocks: [ + ['h1', 'Q4 revenue review'], + ['p', 'Revenue grew 24% year over year, led by the Direct channel.'], + [ + 'chart', + { + chartType: 'bar', + series: [{ label: '2026', values: [15_400, 21_200, 29_800, 38_600] }], + categories: ['Q1', 'Q2', 'Q3', 'Q4'], + title: 'Revenue by quarter', + altText: 'Revenue rises each quarter from 15.4k to 38.6k.', + }, + ], + ['table', { h: ['Channel', 'Share'], r: [['Direct', '46%'], ['Partners', '27%']] }], + ], +}; + +const validation = validateSpec(generated); +console.log('\nvalidateSpec:', validation.ok ? 'ok' : 'INVALID'); +for (const e of validation.errors) console.error(` error ${e.code} at ${e.path}: ${e.message}`); +for (const w of validation.warnings) console.warn(` warn ${w.code} at ${w.path}: ${w.message}`); +if (!validation.ok) process.exit(1); + +const spec = generated as DocSpec; + +// ── 5 & 6. Compile and lint (dry runs, tiers 2 and 3) ──────────────────────── + +try { + const model = compileSpec(spec); + console.log(`compileSpec: ok — ${String(model.blocks.length)} blocks`); +} catch (err) { + // Any failure serializes to the ecosystem's standard envelope. + console.error('compileSpec:', JSON.stringify(toErrorEnvelope(err))); + process.exit(1); +} + +const lint = lintSpec(spec); +console.log( + `lintSpec: ${lint.ok ? 'ok' : 'BLOCKED'} — ` + + `${String(lint.counts.error)} error(s), ${String(lint.counts.warning)} warning(s), ` + + `${String(lint.counts.info)} info`, +); +for (const f of lint.findings) console.log(` ${f.severity} ${f.code}: ${f.message}`); +if (!lint.ok) { + console.error('Blocking lint findings; fix the spec rather than rendering it.'); + process.exit(1); +} + +// ── 7. Render ──────────────────────────────────────────────────────────────── + +await renderSpecToFile(spec, 'agent-loop.pdf'); +console.log('\nWrote agent-loop.pdf'); + +// ── Governance reminder ────────────────────────────────────────────────────── + +const policy = aiGovernancePolicy(); +console.log( + `\nGovernance: agent role is "${policy.humanInTheLoop.roleOfAgent}". ` + + `Autonomous GitHub writes allowed: ${String(policy.policy.autonomousGithubWritesAllowed)}.`, +); diff --git a/samples/agent/error-envelope.tsx b/samples/agent/error-envelope.tsx new file mode 100644 index 0000000..1c31fd9 --- /dev/null +++ b/samples/agent/error-envelope.tsx @@ -0,0 +1,79 @@ +/** + * The error taxonomy, and how to consume it. + * + * Run with: npx tsx samples/agent/error-envelope.ts + * Prints envelopes; writes nothing. + * + * Every error carries a stable `code`. Branch on the code — messages are + * reworded freely between releases, codes are not. `toJSON()` (and the + * `toErrorEnvelope` helper, which accepts *any* thrown value) produces the same + * envelope shape the CLI and MCP server emit: + * + * { "ok": false, "error": { "code": "E_STRUCTURE", "message": "…" } } + */ + +import React from 'react'; +import { + ErrorCode, + Paragraph, + PdfReactError, + PdfStructureError, + compileDocument, + schema, + toErrorEnvelope, + validateSpec, +} from '../../src/index.js'; + +console.log('Stable codes:', Object.values(ErrorCode).join(', ')); + +/** Run a thunk and report it in the standard envelope. */ +function attempt(label: string, thunk: () => unknown): void { + try { + thunk(); + console.log(`\n${label}\n ${JSON.stringify({ ok: true })}`); + } catch (err) { + console.log(`\n${label}\n ${JSON.stringify(toErrorEnvelope(err))}`); + } +} + +// E_STRUCTURE — the tree cannot be mapped onto the pdfnative model. +attempt('Root is not ', () => + compileDocument(I forgot the Document wrapper.), +); + +// E_STRUCTURE — a component used where a block was expected. +attempt('No in the tree at all', () => compileDocument('just a string')); + +// E_INPUT — an unknown schema subject. +attempt('Unknown schema subject', () => schema('does-not-exist' as never)); + +// Non-PdfReactError throws are wrapped as E_RUNTIME, so a caller only ever +// handles one shape. +attempt('An unrelated failure', () => { + throw new TypeError('something else went wrong'); +}); + +// Branching on the code is the point. +try { + compileDocument(x); +} catch (err) { + if (err instanceof PdfReactError) { + switch (err.code) { + case ErrorCode.STRUCTURE: + console.log('\nRecovery: wrap the tree in and retry.'); + break; + case ErrorCode.ENV: + console.log('\nRecovery: run doctor() and report the failing check.'); + break; + default: + console.log(`\nUnhandled code ${err.code}; escalate to a human.`); + } + } + console.log('instanceof PdfStructureError:', err instanceof PdfStructureError); +} + +// validateSpec never throws — malformed input becomes findings, so an agent can +// repair its own output instead of crashing. +const bad = validateSpec({ blocks: [['h9', 'nope'], ['p', 42], 'not a tuple'] }); +console.log('\nvalidateSpec on malformed input:'); +for (const e of bad.errors) console.log(` ${e.code} at ${e.path}: ${e.message}`); diff --git a/samples/agent/manifest.ts b/samples/agent/manifest.ts new file mode 100644 index 0000000..d248520 --- /dev/null +++ b/samples/agent/manifest.ts @@ -0,0 +1,68 @@ +/** + * Capability discovery — register pdfnative-react as an agent tool set. + * + * Run with: npx tsx samples/agent/manifest.ts + * Prints the manifest; writes nothing. + * + * One call describes everything the package can do, as plain JSON: components, + * the full DocSpec grammar, callable entry points, error codes and lint rules. + * Every field is derived from the same registries that build the JSON Schema, + * so the manifest cannot describe capabilities that do not exist — a test + * asserts every name resolves to a real export. + */ + +import { capabilityManifest, schema } from '../../src/index.js'; + +const manifest = capabilityManifest(); + +// The whole thing, for piping into a tool-registration step: +// npx tsx samples/agent/manifest.ts > manifest.json +if (process.argv.includes('--json')) { + console.log(JSON.stringify(manifest, null, 2)); + process.exit(0); +} + +console.log(`${manifest.name} ${manifest.version}`); +console.log(`schema: ${manifest.schemaId}\n`); + +console.log('Contract'); +for (const [key, value] of Object.entries(manifest.contract)) { + console.log(` ${key.padEnd(14)} ${String(value)}`); +} + +console.log('\nDocSpec grammar'); +for (const block of manifest.specBlocks) { + console.log(` ${block.tuple}`); + console.log(` ${block.summary}`); + console.log(` JSX: <${block.component}>`); +} + +console.log('\nEntry points'); +for (const entry of manifest.entrypoints) { + const tags = [entry.kind, entry.nodeOnly === true ? 'node-only' : null] + .filter((t) => t !== null) + .join(', '); + console.log(` ${entry.name}${entry.signature} [${tags}]`); + console.log(` ${entry.summary}`); +} + +console.log('\nComponents'); +console.log( + ' ' + + manifest.components + .map((c) => (c.aliases === undefined ? c.name : `${c.name} (${c.aliases.join(', ')})`)) + .join(', '), +); + +console.log('\nError codes'); +console.log(' ' + manifest.errorCodes.join(', ')); + +console.log('\nLint rules'); +for (const rule of manifest.lintRules) { + console.log(` ${rule.severity.padEnd(7)} ${rule.code.padEnd(20)} ${rule.description}`); +} + +console.log('\nSchema subjects'); +for (const subject of manifest.schemaSubjects) { + console.log(` ${subject.padEnd(16)} ${String(schema(subject)['title'])}`); +} diff --git a/samples/charts/charts.tsx b/samples/charts/charts.tsx new file mode 100644 index 0000000..2b80d83 --- /dev/null +++ b/samples/charts/charts.tsx @@ -0,0 +1,127 @@ +/** + * Native vector charts — every chart type in one document. + * + * Run with: npx tsx samples/charts/charts.tsx + * Writes `charts.pdf` to the current directory. + * + * Charts are drawn with PDF path operators: no rasterisation, no chart library, + * no runtime dependency. Requires the pdfnative engine >= 1.6.0. + * + * Always give a chart `altText` — the engine synthesises a generic description + * ("bar chart: 2 series, 4 categories") when you omit it, which is enough for + * PDF/A but useless to a reader relying on it. `lintDocument` flags the omission. + */ + +import React from 'react'; +import { Chart, Document, Heading, Paragraph, Spacer, renderToFile } from '../../src/index.js'; +import type { ChartSeries } from '../../src/index.js'; + +const QUARTERS = ['Q1', 'Q2', 'Q3', 'Q4']; + +const REVENUE: readonly ChartSeries[] = [ + { label: '2025', values: [12_000, 18_500, 24_100, 31_000] }, + { label: '2026', values: [15_400, 21_200, 29_800, 38_600] }, +]; + +const CHANNELS: readonly ChartSeries[] = [ + { label: 'Share', values: [46, 27, 18, 9] }, +]; +const CHANNEL_NAMES = ['Direct', 'Partners', 'Marketplace', 'Referral']; + +const MARGIN: readonly ChartSeries[] = [ + { label: 'Net margin', values: [-4.2, 1.8, 6.5, 11.3] }, +]; + +const doc = ( + + Chart showcase + + Bar — multi-series with a value axis + + + + + Horizontal bar + + `barH` suits long category labels, which would otherwise be cramped under a + vertical axis. + + + + + + Line — with point markers + + + + + Line — negative values + + Bar and line charts plot below zero. Pie and donut cannot, and `lintDocument` + reports `L_CHART_VALUES` if you try. + + + + + + Pie and donut — one series only + + + +); + +await renderToFile(doc, 'charts.pdf'); +console.log('Wrote charts.pdf'); diff --git a/samples/layout/watermark-header-footer.tsx b/samples/layout/watermark-header-footer.tsx new file mode 100644 index 0000000..1a0fbea --- /dev/null +++ b/samples/layout/watermark-header-footer.tsx @@ -0,0 +1,106 @@ +/** + * Document-level layout sugar: watermark, running header/footer, attachments. + * + * Run with: npx tsx samples/layout/watermark-header-footer.tsx + * Writes `watermark-header-footer.pdf` to the current directory. + * + * These are props on `` rather than child components, because they are + * page furniture, not blocks in the flow — they fold into `layout` under the + * engine's own keys (`watermark`, `headerTemplate`, `footerTemplate`, + * `attachments`, `tagged`). An explicit `layout` prop always wins, so you can + * still drop down to the raw options when you need to. + * + * Header and footer templates understand four placeholders, resolved at render + * time: {page}, {pages}, {date} and {title}. + */ + +import React from 'react'; +import { + Document, + Heading, + Paragraph, + Section, + Table, + lintDocument, + renderToFile, + resolveFonts, +} from '../../src/index.js'; +import type { PdfAttachment } from '../../src/index.js'; + +/** + * PDF/A embeds every rendering font, so the base-14 fallback is not allowed. + * `lintDocument` reports `L_TAGGED_NO_FONTS` if you forget this. + */ +const fontEntries = await resolveFonts({ + latin: () => import('pdfnative/fonts/noto-sans-data.js'), +}); + +/** A machine-readable twin of the invoice, embedded in the PDF (PDF/A-3 style). */ +const invoiceData: PdfAttachment = { + filename: 'invoice-2048.xml', + data: new TextEncoder().encode( + '2048649.00', + ), + mimeType: 'application/xml', + description: 'Structured invoice data', + relationship: 'Data', +}; + +const doc = ( + + Invoice #2048 + Issued 2026-07-25 · Due 2026-08-24 + +
+
+ Total due: €649.00 + + +
+ + The watermark above is the string shorthand. For full control — image + watermarks, opacity, angle, foreground placement — pass the + WatermarkOptions object instead: + + + {'watermark={{ text: { text: \'CONFIDENTIAL\', opacity: 0.12, angle: -30 }, position: \'foreground\' }}'} + +
+ +); + +// Pre-flight: PDF/A has constraints the engine enforces by throwing. Linting +// first turns those into findings you can read. +const report = lintDocument(doc); +if (!report.ok) { + for (const f of report.findings) console.error(`${f.code}: ${f.message}`); + process.exit(1); +} + +await renderToFile(doc, 'watermark-header-footer.pdf'); +console.log('Wrote watermark-header-footer.pdf'); diff --git a/samples/quality/lint.tsx b/samples/quality/lint.tsx new file mode 100644 index 0000000..f18e936 --- /dev/null +++ b/samples/quality/lint.tsx @@ -0,0 +1,106 @@ +/** + * Accessibility and layout linting. + * + * Run with: npx tsx samples/quality/lint.tsx + * Prints a report; writes nothing. + * + * `lintDocument` runs on the compiled document model, so it covers JSX and + * DocSpec identically. It is pure — it never logs on its own and never throws + * for a finding; what you do with the report is your call. Wire it into CI, a + * dev-mode warning, or an agent's self-check loop. + * + * Four rules pre-empt hard failures further down the pipeline: the three + * `L_CHART_*` errors mirror the engine's own validation (which throws at render + * time), and `L_TAGGED_NO_FONTS` catches the PDF/A file that veraPDF would + * reject for a non-embedded font. + */ + +import React from 'react'; +import { + Chart, + Document, + FormField, + Heading, + Image, + Link, + Paragraph, + Table, + lintDocument, +} from '../../src/index.js'; +import type { LintReport } from '../../src/index.js'; + +const PIXEL = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +/** A document with one instance of most problems the linter knows about. */ +const problematic = ( + + Quarterly report + {/* Skips level 2 → L_HEADING_HIERARCHY */} + Revenue + {/* No alt → L_IMAGE_ALT */} + + {/* No headers → L_TABLE_HEADERS */} +
+ {/* Link text is the bare URL → L_LINK_TEXT */} + https://acme.example/q4 + {/* No altText → L_CHART_ALT (info), and a pie with two series → L_CHART_SERIES */} + + {/* No label → L_FIELD_LABEL */} + + + {/* tagged="pdfa2b" with no fontEntries → L_TAGGED_NO_FONTS (error) */} + +); + +function print(title: string, report: LintReport): void { + const { error, warning, info } = report.counts; + console.log(`\n${title}`); + console.log( + ` ok=${String(report.ok)} ${String(error)} error(s), ${String(warning)} warning(s), ${String(info)} info`, + ); + for (const f of report.findings) { + const where = f.blockIndex === undefined ? '' : ` [block ${String(f.blockIndex)}]`; + console.log(` ${f.severity.toUpperCase().padEnd(7)} ${f.code}${where}`); + console.log(` ${f.message}`); + if (f.hint !== undefined) console.log(` → ${f.hint}`); + } +} + +print('Full report', lintDocument(problematic)); + +// Filter to the rules you care about — useful when adopting the linter on an +// existing codebase and fixing one class of problem at a time. +print( + 'Accessibility only', + lintDocument(problematic, { rules: ['L_IMAGE_ALT', 'L_TABLE_HEADERS', 'L_FIELD_LABEL'] }), +); + +// The geometric check needs a full layout pass, so it is opt-in. +print( + 'With the overflow check', + lintDocument( + + + , + { overflow: true }, + ), +); + +// A typical CI gate: fail the build on errors, surface warnings. +const gate = lintDocument(problematic); +if (!gate.ok) { + console.log( + `\nCI would fail here: ${String(gate.counts.error)} blocking finding(s).`, + ); +} diff --git a/samples/server/next-route-handler.tsx b/samples/server/next-route-handler.tsx new file mode 100644 index 0000000..39e40d5 --- /dev/null +++ b/samples/server/next-route-handler.tsx @@ -0,0 +1,129 @@ +/** + * Next.js App Router — a PDF route handler and a Server Action. + * + * This is a *module*, not a runnable script: copy it into a Next.js 15+ app at + * `app/invoice/[id]/route.tsx`. It is type-checked in CI like every other sample. + * + * `renderToResponse` returns a web-standard `Response`, so the same code runs + * unchanged on the Node runtime, the Edge runtime, Deno, Bun and Cloudflare + * Workers. It streams by default: the engine emits page by page, so peak memory + * stays flat and the browser starts receiving bytes immediately. + * + * There is no 'use client' here on purpose — this is server-only code. + */ + +import React from 'react'; +import { + Document, + Heading, + Paragraph, + Table, + renderToResponse, + renderSpecToResponse, +} from '../../src/index.js'; +import type { DocSpec, PdfRow } from '../../src/index.js'; + +interface Invoice { + readonly id: string; + readonly customer: string; + readonly lines: readonly { readonly label: string; readonly total: string }[]; +} + +async function loadInvoice(id: string): Promise { + // Stand-in for your data layer. + return await Promise.resolve({ + id, + customer: 'Globex Corporation', + lines: [ + { label: 'Pro plan (annual)', total: '€490.00' }, + { label: 'Priority support', total: '€99.00' }, + ], + }); +} + +function InvoiceDocument({ invoice }: { readonly invoice: Invoice }): React.ReactElement { + const rows: PdfRow[] = invoice.lines.map((line) => ({ + cells: [line.label, line.total], + type: 'default', + pointed: false, + })); + + return ( + + Invoice #{invoice.id} + Billed to: {invoice.customer} +
+ + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Route Handler — app/invoice/[id]/route.tsx +// ───────────────────────────────────────────────────────────────────────────── + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +): Promise { + const { id } = await params; + const invoice = await loadInvoice(id); + + return await renderToResponse(, { + fileName: `invoice-${id}.pdf`, + // 'inline' opens in the browser's viewer; 'attachment' forces a download. + disposition: 'inline', + headers: { 'cache-control': 'private, max-age=0, must-revalidate' }, + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Variants +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Buffered mode. Costs peak memory proportional to the document, and buys a + * `Content-Length` header — worth it behind a CDN that needs the size up front. + */ +export async function GET_buffered(): Promise { + const invoice = await loadInvoice('2048'); + return await renderToResponse(, { + buffered: true, + fileName: 'invoice.pdf', + disposition: 'attachment', + }); +} + +/** + * The DocSpec twin, for when an agent or a config file produced the document. + * Validate untrusted input with `validateSpec` before rendering it. + */ +export async function GET_fromSpec(): Promise { + const spec: DocSpec = { + title: 'Invoice #2048', + footer: { right: 'Page {page} of {pages}' }, + blocks: [ + ['h1', 'Invoice #2048'], + ['table', { h: ['Item', 'Total'], r: [['Pro plan', '€490.00']] }], + ], + }; + + return await renderSpecToResponse(spec, { fileName: 'invoice.pdf' }); +} + +/** + * Node's `http`/Express want a Node stream. Convert the web stream: + * + * ```ts + * import { Readable } from 'node:stream'; + * + * app.get('/invoice.pdf', async (_req, res) => { + * const response = await renderToResponse(); + * res.setHeader('content-type', 'application/pdf'); + * Readable.fromWeb(response.body as never).pipe(res); + * }); + * ``` + */ +export const expressRecipe = true; diff --git a/src/components.tsx b/src/components.tsx index eefaa80..1128bcf 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -18,6 +18,9 @@ import type { Align, BarcodeFormat, CellBorders, + ChartBlock, + ChartSeries, + ChartType, ColumnDef, DocumentMetadata, FontEntry, @@ -25,11 +28,14 @@ import type { ListItem, OutlineItem, PageLabelRange, + PageTemplate, + PdfAttachment, PdfColor, PdfLayoutOptions, PdfRow, QRErrorLevel, SvgRenderOptions, + WatermarkOptions, } from './types.js'; import type { HostTag } from './reconciler/nodes.js'; @@ -71,6 +77,40 @@ export interface DocumentProps { * (e.g. roman front matter, then decimal body). PDF/A-safe. */ readonly pageLabels?: readonly PageLabelRange[]; + /** + * Semi-transparent watermark repeated on every page. + * + * Pass a plain string for the common case (`watermark="DRAFT"`, rendered + * with the engine's defaults), or the full {@link WatermarkOptions} object + * for text/image control. + * + * Sugar over `layout.watermark`; an explicit `layout` wins. + */ + readonly watermark?: string | WatermarkOptions; + /** + * Running page header. Supports the `{page}`, `{pages}`, `{date}` and + * `{title}` placeholders. Sugar over `layout.headerTemplate`. + */ + readonly header?: PageTemplate; + /** + * Running page footer. Supports the `{page}`, `{pages}`, `{date}` and + * `{title}` placeholders. Sugar over `layout.footerTemplate`. + * + * Distinct from `footerText`, which is the engine's single centered line; + * `footer` gives independent left/center/right slots. + */ + readonly footer?: PageTemplate; + /** Embedded file attachments (PDF/A-3). Sugar over `layout.attachments`. */ + readonly attachments?: readonly PdfAttachment[]; + /** + * Emit a tagged (accessible) PDF, optionally targeting a PDF/A conformance + * level. `true` tags the document; `'pdfa2b'` &c. additionally enforce the + * matching PDF/A profile. Sugar over `layout.tagged`. + * + * PDF/A requires every rendering font to be embedded — pair it with + * `fontEntries`, and see `lintDocument` (rule `L_TAGGED_NO_FONTS`). + */ + readonly tagged?: PdfLayoutOptions['tagged']; readonly children?: ReactNode; } @@ -437,6 +477,48 @@ export function Svg(props: SvgProps): ReactElement { return h('svg', { ...props }); } +/** Props for {@link Chart}. Mirrors the engine's `ChartBlock` one-for-one. */ +export interface ChartProps { + /** Chart kind: `'bar'`, `'barH'`, `'line'`, `'pie'` or `'donut'`. */ + readonly chartType: ChartType; + /** Data series. Pie/donut take exactly one series. */ + readonly series: readonly ChartSeries[]; + /** Category / slice labels. Defaults to 1-based indices. */ + readonly categories?: readonly string[]; + /** Plot width in points (clamped to content width). Default: `460`. */ + readonly width?: number; + /** Plot-area height in points. Default: `240`. */ + readonly height?: number; + /** Chart title drawn above the plot. */ + readonly title?: string; + /** Legend placement. Default: `'bottom'` for multi-series/pie, else `'none'`. */ + readonly legend?: ChartBlock['legend']; + /** Value-axis options (bar/line only). */ + readonly axis?: ChartBlock['axis']; + /** Draw point markers on line series. Default: `false`. */ + readonly markers?: boolean; + /** Palette override, per series (bar/line) or per slice (pie/donut). */ + readonly colors?: readonly PdfColor[]; + /** Horizontal alignment. Default: `'left'`. */ + readonly align?: Align; + /** + * Alt text for the tagged-PDF `/Figure /Alt` entry. The engine generates a + * generic description when omitted; supply your own for real accessibility + * (see `lintDocument`, rule `L_CHART_ALT`). + */ + readonly altText?: string; +} + +/** + * A native vector chart — bar, horizontal bar, line, pie or donut — rendered as + * pure PDF path operators. No rasterisation, no chart library, and PDF/A-safe. + * + * Requires the `pdfnative` engine ≥ 1.6.0. + */ +export function Chart(props: ChartProps): ReactElement { + return h('chart', { ...props }); +} + // ───────────────────────────────────────────────────────────────────────────── // Interactive form fields // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/core-bridge/index.ts b/src/core-bridge/index.ts index cd4a33b..50f88ae 100644 --- a/src/core-bridge/index.ts +++ b/src/core-bridge/index.ts @@ -18,6 +18,17 @@ export { registerFont, loadFontData, validateFontData, + /** + * Imported solely as a **capability probe** for `doctor()`: this function + * first exists in pdfnative 1.6.0, alongside the `chart` block. Probing for + * the capability is more honest — and more portable, since it survives + * bundling into a browser build — than parsing a version string out of the + * engine's `package.json`. + * + * Deliberately not re-exported from the public barrel: charts are authored + * with ``, not by calling engine internals. + */ + estimateChartHeight, } from 'pdfnative'; export type { @@ -41,4 +52,16 @@ export type { CellBorders, ListItem, StreamToFileResult, + // Charts (engine ≥ 1.6.0) + ChartBlock, + ChartSeries, + ChartType, + // Document-level layout options surfaced as `` props + PageTemplate, + WatermarkOptions, + WatermarkText, + WatermarkImage, + PdfAttachment, + PdfAttachmentRelationship, + EncryptionOptions, } from 'pdfnative'; diff --git a/src/doctor.ts b/src/doctor.ts new file mode 100644 index 0000000..50bf36b --- /dev/null +++ b/src/doctor.ts @@ -0,0 +1,175 @@ +/** + * Environment pre-flight. + * + * `doctor()` answers "will this actually work here?" before you try to render — + * the library equivalent of `pdfnative doctor`. It is the first call an + * autonomous agent should make in an unfamiliar environment, and a fast way for + * a human to see why an install is misbehaving. + * + * It is **total**: every check is wrapped, so `doctor()` never throws, even + * when the `pdfnative` peer is missing entirely — that is precisely the + * situation it exists to diagnose. + * + * @packageDocumentation + */ + +import { version as reactVersion } from 'react'; +import { estimateChartHeight } from './core-bridge/index.js'; +import { version } from './version.js'; + +/** Outcome of a single {@link doctor} check. */ +export type CheckStatus = 'ok' | 'warn' | 'error'; + +/** One environment check. */ +export interface DoctorCheck { + /** Stable check identifier. */ + readonly name: string; + /** `'error'` means something will fail; `'warn'` means degraded capability. */ + readonly status: CheckStatus; + /** The observed value (a version, `'present'`, `'missing'`…). */ + readonly value: string; + /** What the check means, and what to do when it is not `'ok'`. */ + readonly detail: string; +} + +/** The full pre-flight report. */ +export interface DoctorReport { + /** `true` when no check has status `'error'`. */ + readonly ok: boolean; + /** Checks in a stable order. */ + readonly checks: readonly DoctorCheck[]; +} + +/** Minimum engine major.minor this release is built against. */ +const REQUIRED_ENGINE = '1.6.0'; +/** Minimum Node version, inherited from the engine. */ +const REQUIRED_NODE_MAJOR = 22; + +function check( + name: string, + detail: string, + probe: () => { status: CheckStatus; value: string }, +): DoctorCheck { + try { + const { status, value } = probe(); + return { name, status, value, detail }; + } catch (err) { + return { + name, + status: 'error', + value: 'probe failed', + detail: `${detail} (${err instanceof Error ? err.message : String(err)})`, + }; + } +} + +function nodeCheck(): DoctorCheck { + return check( + 'node', + `Node ${String(REQUIRED_NODE_MAJOR)}+ is required by the pdfnative engine. ` + + 'Not applicable in a browser.', + () => { + const raw = globalThis.process?.versions?.node; + if (raw === undefined) return { status: 'ok', value: 'n/a (non-Node runtime)' }; + const major = Number.parseInt(raw.split('.')[0] ?? '0', 10); + return { + status: major >= REQUIRED_NODE_MAJOR ? 'ok' : 'error', + value: raw, + }; + }, + ); +} + +function reactCheck(): DoctorCheck { + return check( + 'react', + 'React 19 is required — the reconciler is bound to a single, pinned version contract.', + () => { + const major = Number.parseInt(reactVersion.split('.')[0] ?? '0', 10); + return { status: major === 19 ? 'ok' : 'error', value: reactVersion }; + }, + ); +} + +function engineCheck(): DoctorCheck { + return check( + 'pdfnative', + `The pdfnative peer dependency must be installed at ${REQUIRED_ENGINE} or later ` + + '(probed via a capability that first ships in 1.6.0).', + () => { + const present = typeof estimateChartHeight === 'function'; + return present + ? { status: 'ok', value: `>= ${REQUIRED_ENGINE}` } + : { status: 'error', value: 'missing or older than 1.6.0' }; + }, + ); +} + +function webCryptoCheck(): DoctorCheck { + return check( + 'web-crypto', + 'A CSPRNG is required for encrypted output (layout.encryption).', + () => { + const ok = typeof globalThis.crypto?.getRandomValues === 'function'; + return { status: ok ? 'ok' : 'warn', value: ok ? 'available' : 'unavailable' }; + }, + ); +} + +function fetchApiCheck(): DoctorCheck { + return check( + 'fetch-api', + 'Response + ReadableStream are required by renderToResponse.', + () => { + const ok = + typeof globalThis.Response === 'function' + && typeof globalThis.ReadableStream === 'function'; + return { status: ok ? 'ok' : 'warn', value: ok ? 'available' : 'unavailable' }; + }, + ); +} + +function blobCheck(): DoctorCheck { + return check( + 'blob', + 'Blob is required by renderToBlob, usePdf and the viewer components.', + () => { + const ok = typeof globalThis.Blob === 'function'; + return { status: ok ? 'ok' : 'warn', value: ok ? 'available' : 'unavailable' }; + }, + ); +} + +/** + * Run every environment check and return a structured report. + * + * Never throws. `ok` is `false` when any check has status `'error'`. + * + * @example + * ```ts + * const report = doctor(); + * if (!report.ok) { + * for (const c of report.checks.filter((c) => c.status === 'error')) { + * console.error(`${c.name}: ${c.value} — ${c.detail}`); + * } + * } + * ``` + */ +export function doctor(): DoctorReport { + const checks: readonly DoctorCheck[] = [ + { + name: 'pdfnative-react', + status: 'ok', + value: version, + detail: 'Installed pdfnative-react version.', + }, + nodeCheck(), + reactCheck(), + engineCheck(), + webCryptoCheck(), + fetchApiCheck(), + blobCheck(), + ]; + + return { ok: checks.every((c) => c.status !== 'error'), checks }; +} diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..a9898ab --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,90 @@ +/** + * Stable error taxonomy. + * + * Every error this package throws carries a machine-readable {@link ErrorCode} + * alongside its human-readable message, and serializes to the same envelope + * shape used across the pdfnative ecosystem: + * + * ```json + * { "ok": false, "error": { "code": "E_STRUCTURE", "message": "…" } } + * ``` + * + * Agents (and CI) branch on `code`, never on prose — messages may be reworded + * in any release, codes may not. + * + * @packageDocumentation + */ + +/** Stable, machine-readable error classes. Codes are part of the public API. */ +export const ErrorCode = { + /** A component tree or spec could not be mapped onto the pdfnative model. */ + STRUCTURE: 'E_STRUCTURE', + /** Input failed validation (bad prop, malformed `DocSpec`, unknown subject). */ + INPUT: 'E_INPUT', + /** The requested capability exists but is not available here. */ + UNSUPPORTED: 'E_UNSUPPORTED', + /** The runtime environment is missing something required (peer, Node, Web API). */ + ENV: 'E_ENV', + /** An AI-governance policy rule was violated. */ + POLICY: 'E_POLICY', + /** Anything else. */ + RUNTIME: 'E_RUNTIME', +} as const; + +/** The value type of {@link ErrorCode}. */ +export type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode]; + +/** The JSON envelope produced by {@link PdfReactError.toJSON}. */ +export interface ErrorEnvelope { + readonly ok: false; + readonly error: { + readonly code: ErrorCodeValue; + readonly message: string; + }; +} + +/** + * Base class for every error thrown by pdfnative-react. + * + * Prefer catching this over the concrete subclasses and branching on + * {@link PdfReactError.code}. + */ +export class PdfReactError extends Error { + /** Stable machine-readable classification. */ + public readonly code: ErrorCodeValue; + + constructor(message: string, code: ErrorCodeValue = ErrorCode.RUNTIME) { + super(message); + this.name = 'PdfReactError'; + this.code = code; + } + + /** Serialize to the ecosystem's standard error envelope. */ + public toJSON(): ErrorEnvelope { + return { ok: false, error: { code: this.code, message: this.message } }; + } +} + +/** + * Thrown when a component tree cannot be mapped onto the pdfnative model — + * a root that is not ``, or a component used where a block was + * expected. + * + * Carries `code: 'E_STRUCTURE'`. + */ +export class PdfStructureError extends PdfReactError { + constructor(message: string, code: ErrorCodeValue = ErrorCode.STRUCTURE) { + super(message, code); + this.name = 'PdfStructureError'; + } +} + +/** + * Build an error envelope from an arbitrary thrown value, so agent-facing code + * can report *any* failure in the standard shape. + */ +export function toErrorEnvelope(err: unknown): ErrorEnvelope { + if (err instanceof PdfReactError) return err.toJSON(); + const message = err instanceof Error ? err.message : String(err); + return { ok: false, error: { code: ErrorCode.RUNTIME, message } }; +} diff --git a/src/governance.ts b/src/governance.ts new file mode 100644 index 0000000..2caff28 --- /dev/null +++ b/src/governance.ts @@ -0,0 +1,237 @@ +/** + * AI-governance contract, shipped as runtime capability. + * + * Until 1.1.0 the human-in-the-loop policy existed only as repository files and + * a dev-only script — so an agent that installed the package from npm could not + * read the rules it was expected to follow. These exports close that gap: the + * policy, the protocol text and the draft validator now travel with the + * package, exactly as `pdfnative govern rules|policy|verify-issue` does for the + * CLI. + * + * **The one rule:** nothing here — nothing anywhere in this package — writes to + * GitHub or makes an outbound network call. {@link validateIssueDraft} is a + * pure string function. An agent's authority ends at producing a local draft + * plus a compliance report; a human reviews and submits it under their own + * identity. + * + * @see `.github/AGENT_RULES.md` — the agent-facing protocol + * @see `.github/ai-governance.json` — the repository's machine-readable twin + * @see `docs/AI_GOVERNANCE.md` — the narrative walk-through + * + * @packageDocumentation + */ + +import { ErrorCode, type ErrorCodeValue } from './errors.js'; + +// ───────────────────────────────────────────────────────────────────────────── +// Validation +// +// The regex tables below are deliberately duplicated in +// `scripts/verify-issue.mjs`, which must stay zero-dependency and runnable with +// no build step (CI and `tests/governance.test.ts` shell out to it directly, in +// a checkout that has never been compiled). `tests/governance.test.ts` parses +// that script's source and asserts both tables are byte-identical to these, so +// the duplication cannot silently drift. +// ───────────────────────────────────────────────────────────────────────────── + +/** Patterns that indicate an external runtime dependency is being proposed. */ +const DEPENDENCY_PATTERNS: readonly RegExp[] = [ + /\bnpm\s+(install|i|add)\s+(?!--)[a-z@]/i, + /\b(yarn|pnpm|bun)\s+add\s+/i, + /\bpnpm\s+install\s+[a-z@]/i, + /add\s+[`"']?[\w@/-]+[`"']?\s+to\s+(the\s+)?(runtime\s+)?dependencies\b/i, + /"dependencies"\s*:\s*\{[^}]*[\w-]+[^}]*\}/i, +]; + +/** Required issue fields (advisory — surfaced as warnings when missing). */ +const REQUIRED_FIELDS: readonly { readonly key: string; readonly re: RegExp }[] = [ + { key: 'minimal_reproduction', re: /repro|reproduc/i }, + { key: 'environment', re: /environment|version|node|os\b/i }, + { key: 'expected_behavior', re: /expected/i }, +]; + +/** The outcome of validating a draft against the governance policy. */ +export interface GovernanceValidation { + /** `true` when no blocking policy violation was found. */ + readonly ok: boolean; + /** Blocking violations. A human must resolve these before submission. */ + readonly errors: readonly string[]; + /** Non-blocking advisories, e.g. a missing recommended field. */ + readonly warnings: readonly string[]; + /** The error code to report when `ok` is `false`. */ + readonly code?: ErrorCodeValue; +} + +/** + * Validate the markdown of a draft issue or pull request against the policy. + * + * Pure: no filesystem, no network, no exceptions. + * + * @param markdown - Raw markdown of the draft. + * + * @example + * ```ts + * const result = validateIssueDraft(draft); + * if (!result.ok) throw new PdfReactError(result.errors.join(' '), result.code); + * ``` + */ +export function validateIssueDraft(markdown: string): GovernanceValidation { + const errors: string[] = []; + const warnings: string[] = []; + + for (const re of DEPENDENCY_PATTERNS) { + if (re.test(markdown)) { + errors.push( + 'Proposing an external runtime dependency violates the minimal-dependency policy.', + ); + break; + } + } + + if (!/```[\s\S]*?```/.test(markdown)) { + errors.push( + 'No reproduction code block found — include a minimal repro inside a fenced ``` block.', + ); + } + + for (const field of REQUIRED_FIELDS) { + if (!field.re.test(markdown)) { + warnings.push(`Recommended field appears to be missing: ${field.key}.`); + } + } + + const ok = errors.length === 0; + return ok ? { ok, errors, warnings } : { ok, errors, warnings, code: ErrorCode.POLICY }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Policy +// ───────────────────────────────────────────────────────────────────────────── + +/** The machine-readable governance policy. Mirrors `.github/ai-governance.json`. */ +export interface AiGovernancePolicy { + readonly version: string; + readonly appliesTo: readonly string[]; + readonly policy: { + readonly automaticIssueReporting: false; + readonly runtimeDependenciesAllowed: false; + readonly humanInTheLoopMandatory: true; + readonly autonomousGithubWritesAllowed: false; + readonly outboundNetworkAllowed: false; + readonly telemetryAllowed: false; + readonly requiredIssueFields: readonly string[]; + }; + readonly humanInTheLoop: { + readonly roleOfAgent: 'draftsman'; + readonly gate: string; + readonly identityIntegrity: string; + readonly draftLocation: string; + }; + readonly preIssueChecklist: readonly string[]; + readonly complianceReportFields: readonly string[]; + readonly verification: { + readonly command: string; + readonly api: string; + readonly blocksSubmissionOnFailure: true; + }; +} + +/** + * The governance policy this package enforces, as plain JSON. + * + * An agent should read this before proposing any change, and must present a + * compliance report covering {@link AiGovernancePolicy.complianceReportFields} + * alongside every draft. + */ +export function aiGovernancePolicy(): AiGovernancePolicy { + return { + version: '1.1.0', + appliesTo: ['pdfnative', 'pdfnative-cli', 'pdfnative-mcp', 'pdfnative-react'], + policy: { + automaticIssueReporting: false, + runtimeDependenciesAllowed: false, + humanInTheLoopMandatory: true, + autonomousGithubWritesAllowed: false, + outboundNetworkAllowed: false, + telemetryAllowed: false, + requiredIssueFields: ['minimal_reproduction', 'environment', 'expected_behavior'], + }, + humanInTheLoop: { + roleOfAgent: 'draftsman', + gate: + 'A human MUST explicitly review, sign off on, and trigger any GitHub issue, ' + + "comment, PR, or release. The agent's authority ends at producing a local " + + 'draft plus a compliance report. pdfnative-react contains NO code path that ' + + 'can write to GitHub or make any outbound network call.', + identityIntegrity: + "Any issue or PR is published under the human user's GitHub identity. The " + + 'agent MUST remind the user of their shared responsibility for the content ' + + 'before submission.', + draftLocation: '.github/drafts/', + }, + preIssueChecklist: [ + 'no_duplicate_open_or_closed_issue', + 'no_new_runtime_dependency', + 'local_minimal_reproduction_executed', + 'expected_vs_actual_documented', + 'environment_captured', + ], + complianceReportFields: [ + 'no_new_runtime_dependency_confirmed', + 'reproduction_command', + 'reproduction_result', + 'duplicate_search_performed', + 'affected_packages', + 'identity_reminder_shown', + ], + verification: { + command: 'npm run verify:issue -- .github/drafts/.md', + api: 'validateIssueDraft(markdown)', + blocksSubmissionOnFailure: true, + }, + }; +} + +/** + * The agent-facing protocol, as text. + * + * Mirrors `.github/AGENT_RULES.md` so an agent working against an installed + * package — with no repository checkout — can still read the rules. + */ +export function agentRulesText(): string { + return `# Rules for AI agents — pdfnative-react + +You are a DRAFTSMAN, not a submitter. Your authority ends at a local draft +plus a compliance report. A human reviews and submits, under their own +GitHub identity. + +## Mandatory before proposing anything + +1. NO new runtime dependency. The only one is react-reconciler; pdfnative and + react are peers. A proposal that adds one is rejected by policy. +2. NO duplicates. Search open AND closed issues first. +3. REPRODUCE locally. Include a minimal, runnable repro in a fenced code block. +4. KNOW the architecture. Read AGENTS.md and docs/KNOWLEDGE_BASE.md. Respect + the eight golden rules — in particular: runtime pdfnative imports go only + through src/core-bridge, there is no CSS layout model, and every authoring + capability must reach BOTH the JSX props and the DocSpec grammar + schema. +5. AUTHORING ONLY. Byte-level post-processing (merge/split, annotations, + signing, crypto, font compilation) belongs to the engine. See docs/RECIPES.md. +6. HUMAN IN THE LOOP. Never open, comment on, or merge anything autonomously. + +## Workflow + + investigate -> reproduce -> draft into .github/drafts/ + -> validate (npm run verify:issue, or validateIssueDraft()) + -> present a compliance report + -> ***HUMAN REVIEWS AND SUBMITS*** <- CRITICAL ETHICAL GATE + +## What agents must NOT do + +- Open, edit, comment on, or close issues or pull requests. +- Push branches, create releases, or publish to npm. +- Add a runtime dependency, or vendor code to avoid one. +- Make any outbound network call, or emit telemetry. +- Submit anything under a human's identity without their explicit sign-off. +`; +} diff --git a/src/index.ts b/src/index.ts index 2dd129c..f169b4b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,6 +35,20 @@ export { inspectDocument, } from './render.js'; +// Web-standard Response (Next.js route handlers, Remix, Hono, Workers…) +export { renderToResponse } from './response.js'; +export type { PdfResponseOptions } from './response.js'; + +// Accessibility & layout linting +export { lintDocument, LINT_RULES, LINT_RULE_CODES } from './lint.js'; +export type { + LintFinding, + LintOptions, + LintReport, + LintRuleCode, + LintSeverity, +} from './lint.js'; + // Font convenience (async loader map → FontEntry[]) export { resolveFonts } from './fonts.js'; @@ -59,11 +73,17 @@ export { specToElement, compileSpec, inspectSpec, + lintSpec, + validateSpec, renderSpecToBytes, renderSpecToBlob, renderSpecToStream, renderSpecToFile, renderSpecToFileStream, + renderSpecToResponse, + schema, + schemaId, + SCHEMA_SUBJECTS, docSpecSchema, docSpecSchemaId, } from './spec/index.js'; @@ -95,8 +115,16 @@ export type { TocSpecOpts, BarcodeSpecOpts, SvgSpecOpts, + ChartSpec, + ChartSpecBody, + SchemaSubject, + SpecCodeValue, + SpecFinding, + SpecFindingSeverity, + SpecValidation, JsonSchema, } from './spec/index.js'; +export { SpecCode } from './spec/index.js'; // Font + environment helpers (re-exported from the pdfnative engine) export { @@ -108,8 +136,23 @@ export { initNodeCompression, } from './core-bridge/index.js'; -// Errors -export { PdfStructureError } from './reconciler/serialize.js'; +// Errors — stable, machine-readable taxonomy +export { PdfStructureError, PdfReactError, ErrorCode, toErrorEnvelope } from './errors.js'; +export type { ErrorCodeValue, ErrorEnvelope } from './errors.js'; + +// Agent surface: discovery, pre-flight, governance +export { capabilityManifest } from './manifest.js'; +export type { + CapabilityManifest, + ManifestBlock, + ManifestComponent, + ManifestEntrypoint, + ManifestLintRule, +} from './manifest.js'; +export { doctor } from './doctor.js'; +export type { CheckStatus, DoctorCheck, DoctorReport } from './doctor.js'; +export { aiGovernancePolicy, agentRulesText, validateIssueDraft } from './governance.js'; +export type { AiGovernancePolicy, GovernanceValidation } from './governance.js'; // Version export { version } from './version.js'; @@ -147,4 +190,14 @@ export type { CellBorders, ListItem, StreamToFileResult, + ChartBlock, + ChartSeries, + ChartType, + PageTemplate, + WatermarkOptions, + WatermarkText, + WatermarkImage, + PdfAttachment, + PdfAttachmentRelationship, + EncryptionOptions, } from './types.js'; diff --git a/src/lint.ts b/src/lint.ts new file mode 100644 index 0000000..b3da631 --- /dev/null +++ b/src/lint.ts @@ -0,0 +1,382 @@ +/** + * Deterministic accessibility and layout linting. + * + * `lintDocument` compiles the tree once and inspects the resulting + * `DocumentParams`, so it covers **both** authoring surfaces — JSX and + * `DocSpec` — from a single implementation, and never diverges from what the + * engine will actually receive. + * + * Findings carry a stable {@link LintRuleCode}. Branch on the code, never on + * the message: messages may be reworded in any release, codes may not. + * + * Several rules pre-empt hard failures inside the engine (the `chart` rules, + * `L_TAGGED_NO_FONTS`), turning a runtime throw or a rejected PDF/A file into a + * finding you can act on before rendering. + * + * The function is pure: it never writes to the console and never throws for a + * lint failure. What you do with the report is your call. + * + * @packageDocumentation + */ + +import type { ReactNode } from 'react'; +import { compileDocument, inspectDocument } from './render.js'; +import { + LINT_RULES, + LINT_RULE_CODES, + type LintRuleCode, + type LintSeverity, +} from './registry.js'; +import type { + ChartBlock, + DocumentBlock, + DocumentParams, + RenderOptions, +} from './types.js'; + +// The rule table lives in `./registry.js` so the JSON Schema can describe a lint +// report without importing this module (and therefore without loading the +// engine). Re-exported here because this is where users expect to find it. +export { LINT_RULES, LINT_RULE_CODES }; +export type { LintRuleCode, LintSeverity }; + +/** A single lint finding. */ +export interface LintFinding { + /** Stable rule identifier — branch on this. */ + readonly code: LintRuleCode; + /** Severity of this rule. */ + readonly severity: LintSeverity; + /** Human-readable explanation. Not stable across releases. */ + readonly message: string; + /** Index into `DocumentParams.blocks`, when the finding is block-scoped. */ + readonly blockIndex?: number; + /** How to fix it. */ + readonly hint?: string; +} + +/** The result of a lint run. */ +export interface LintReport { + /** `true` when no finding has severity `'error'`. */ + readonly ok: boolean; + /** Findings in document order. */ + readonly findings: readonly LintFinding[]; + /** Count per severity, for quick triage. */ + readonly counts: Readonly>; +} + +/** Options for {@link lintDocument} / {@link lintSpec}. */ +export interface LintOptions extends RenderOptions { + /** + * Also run the geometric overflow check (`L_OVERFLOW`), which needs a full + * layout pass via `inspectDocument`. Default: `false` — it costs roughly as + * much as rendering. + */ + readonly overflow?: boolean; + /** Only report these rules. Default: all. */ + readonly rules?: readonly LintRuleCode[]; +} + +const MAX_CHART_POINTS = 10_000; + +function finding( + code: LintRuleCode, + message: string, + extra?: { blockIndex?: number; hint?: string }, +): LintFinding { + return { + code, + severity: LINT_RULES[code].severity, + message, + ...(extra?.blockIndex !== undefined ? { blockIndex: extra.blockIndex } : {}), + ...(extra?.hint !== undefined ? { hint: extra.hint } : {}), + }; +} + +/** Chart rules — these mirror the engine's own validation, ahead of the throw. */ +function lintChart(block: ChartBlock, index: number, out: LintFinding[]): void { + const { chartType, series, categories, altText } = block; + + if (altText === undefined || altText.trim() === '') { + out.push( + finding('L_CHART_ALT', `Chart #${index} has no altText.`, { + blockIndex: index, + hint: 'Describe what the chart shows, e.g. altText="Revenue per quarter, rising from 12k to 31k".', + }), + ); + } + + const isRadial = chartType === 'pie' || chartType === 'donut'; + if (isRadial && series.length !== 1) { + out.push( + finding( + 'L_CHART_SERIES', + `A ${chartType} chart takes exactly one series, but #${index} has ${String(series.length)}.`, + { blockIndex: index, hint: 'Split the extra series into separate charts.' }, + ), + ); + } + + let points = 0; + for (const s of series) { + points += s.values.length; + + if (categories !== undefined && s.values.length !== categories.length) { + out.push( + finding( + 'L_CHART_CATEGORIES', + `Chart #${index} series "${s.label}" has ${String(s.values.length)} values ` + + `but there are ${String(categories.length)} categories.`, + { blockIndex: index, hint: 'Every series must supply one value per category.' }, + ), + ); + } + + const bad = s.values.find((v) => !Number.isFinite(v)); + if (bad !== undefined) { + out.push( + finding( + 'L_CHART_VALUES', + `Chart #${index} series "${s.label}" contains a non-finite value.`, + { blockIndex: index, hint: 'Replace NaN/Infinity with a real number or 0.' }, + ), + ); + } else if (isRadial && s.values.some((v) => v < 0)) { + out.push( + finding( + 'L_CHART_VALUES', + `A ${chartType} chart cannot plot negative values (chart #${index}).`, + { blockIndex: index, hint: 'Use a bar chart for data that goes below zero.' }, + ), + ); + } + } + + if (points > MAX_CHART_POINTS) { + out.push( + finding( + 'L_CHART_POINTS', + `Chart #${index} has ${String(points)} data points; the engine ceiling is ${String(MAX_CHART_POINTS)}.`, + { blockIndex: index, hint: 'Aggregate the data before charting it.' }, + ), + ); + } +} + +function lintBlocks(blocks: readonly DocumentBlock[], out: LintFinding[]): void { + let lastHeadingLevel = 0; + + blocks.forEach((block, index) => { + switch (block.type) { + case 'heading': { + if (lastHeadingLevel > 0 && block.level > lastHeadingLevel + 1) { + out.push( + finding( + 'L_HEADING_HIERARCHY', + `Heading jumps from level ${String(lastHeadingLevel)} to ${String(block.level)} ("${block.text}").`, + { + blockIndex: index, + hint: `Use level ${String(lastHeadingLevel + 1)}, or add the intermediate heading.`, + }, + ), + ); + } + lastHeadingLevel = block.level; + break; + } + + case 'image': { + if (block.alt === undefined || block.alt.trim() === '') { + out.push( + finding('L_IMAGE_ALT', `Image #${index} has no alt text.`, { + blockIndex: index, + hint: 'Add alt="…" describing the image, or alt="" if purely decorative.', + }), + ); + } + break; + } + + case 'table': { + if (block.headers.length === 0) { + out.push( + finding('L_TABLE_HEADERS', `Table #${index} has no header row.`, { + blockIndex: index, + hint: 'Pass headers={[…]} or mark the first .', + }), + ); + } + break; + } + + case 'formField': { + if (block.label === undefined || block.label.trim() === '') { + out.push( + finding( + 'L_FIELD_LABEL', + `Form field "${block.name}" has no label.`, + { blockIndex: index, hint: 'Add label="…" so the widget is identifiable.' }, + ), + ); + } + break; + } + + case 'link': { + const text = block.text.trim(); + if (text === '') { + out.push( + finding('L_LINK_TEXT', `Link #${index} has no text.`, { + blockIndex: index, + hint: 'Give the link a descriptive label.', + }), + ); + } else if (text === block.url) { + out.push( + finding( + 'L_LINK_TEXT', + `Link #${index} uses its raw URL as the link text.`, + { + blockIndex: index, + hint: 'Prefer descriptive text, e.g. "Read the invoice terms".', + }, + ), + ); + } + break; + } + + case 'chart': { + lintChart(block, index, out); + break; + } + + default: + break; + } + }); +} + +function lintDocumentParams(params: DocumentParams, out: LintFinding[]): void { + if (params.blocks.length === 0) { + out.push( + finding('L_EMPTY_DOCUMENT', 'The document has no blocks.', { + hint: 'Add at least one block inside .', + }), + ); + } + + const layout = params.layout; + const tagged = layout?.tagged; + const wantsPdfA = typeof tagged === 'string'; + + if (wantsPdfA && (params.fontEntries === undefined || params.fontEntries.length === 0)) { + out.push( + finding( + 'L_TAGGED_NO_FONTS', + `tagged="${tagged}" requires embedded fonts, but no fontEntries were supplied.`, + { + hint: 'Pass fontEntries={await resolveFonts({ … })} on or in the render options.', + }, + ), + ); + } + + if (tagged !== undefined && tagged !== false && layout?.encryption !== undefined) { + out.push( + finding( + 'L_TAGGED_ENCRYPTED', + 'PDF/A and encryption cannot be combined.', + { hint: 'Drop layout.encryption, or drop the tagged/PDF-A target.' }, + ), + ); + } + + const attachments = layout?.attachments; + if (attachments !== undefined && attachments.length > 0 && tagged !== 'pdfa3b') { + out.push( + finding( + 'L_ATTACHMENTS_NEED_PDFA3', + `${String(attachments.length)} file attachment(s) require tagged="pdfa3b", but tagged is ` + + `${tagged === undefined ? 'unset' : JSON.stringify(tagged)}.`, + { + hint: 'Set tagged="pdfa3b" on — only PDF/A-3 permits embedded files.', + }, + ), + ); + } + + const maxBlocks = layout?.maxBlocks; + if (maxBlocks !== undefined && params.blocks.length > maxBlocks * 0.9) { + out.push( + finding( + 'L_MAX_BLOCKS', + `${String(params.blocks.length)} blocks is within 10% of the maxBlocks ceiling (${String(maxBlocks)}).`, + { hint: 'Raise layout.maxBlocks, or split the document.' }, + ), + ); + } + + lintBlocks(params.blocks, out); +} + +/** + * Geometric overflow, via a real layout pass. + * + * pdfnative's y-axis increases upward: a block occupies `[top - height, top]`, + * and the content box spans `[margins.b, pageHeight - margins.t]`. A block that + * is simply taller than that box can never fit on any page — that is the case + * worth reporting (an oversized `` or `` is the usual cause). + */ +function overflowFindings(node: ReactNode, options: LintOptions | undefined): LintFinding[] { + const out: LintFinding[] = []; + const inspection = inspectDocument(node, options); + const contentHeight = inspection.pageHeight - inspection.margins.t - inspection.margins.b; + const floor = inspection.margins.b; + const epsilon = 0.5; // points — absorbs measurement rounding + + for (const page of inspection.pages) { + for (const block of page.blocks) { + const tooTall = block.height > contentHeight + epsilon; + const belowFloor = block.top - block.height < floor - epsilon; + if (!tooTall && !belowFloor) continue; + + out.push( + finding( + 'L_OVERFLOW', + tooTall + ? `A ${block.type} block is ${block.height.toFixed(0)}pt tall but the content box ` + + `is only ${contentHeight.toFixed(0)}pt — it cannot fit on any page.` + : `A ${block.type} block on page ${String(page.index + 1)} extends below the bottom margin.`, + { + hint: tooTall + ? 'Reduce the block height, or enlarge the page / shrink the margins.' + : 'Let the block flow onto the next page, or reduce its height.', + }, + ), + ); + } + } + return out; +} + +function report(findings: readonly LintFinding[], rules?: readonly LintRuleCode[]): LintReport { + const filtered = + rules === undefined ? findings : findings.filter((f) => rules.includes(f.code)); + const counts = { error: 0, warning: 0, info: 0 }; + for (const f of filtered) counts[f.severity] += 1; + return { ok: counts.error === 0, findings: filtered, counts }; +} + +/** + * Check a document for accessibility and layout problems without rendering it. + * + * @param node - A React element whose root is ``. + * @param options - Render options, plus `overflow` and `rules` filters. + * @returns A report; `ok` is `true` when nothing of severity `'error'` was found. + */ +export function lintDocument(node: ReactNode, options?: LintOptions): LintReport { + const findings: LintFinding[] = []; + lintDocumentParams(compileDocument(node), findings); + if (options?.overflow === true) findings.push(...overflowFindings(node, options)); + return report(findings, options?.rules); +} diff --git a/src/manifest.ts b/src/manifest.ts new file mode 100644 index 0000000..fe5abbb --- /dev/null +++ b/src/manifest.ts @@ -0,0 +1,291 @@ +/** + * Machine-readable capability manifest. + * + * One call tells an autonomous agent everything this package can do: which + * components exist, which `DocSpec` tuples are valid, which entry points to + * call, which error codes to branch on, and which lint rules can fire. It is + * the discovery primitive — fetch it once, register pdfnative-react as a tool + * set, then work from the schemas. + * + * Every field is **derived** from `./registry.js`, `./errors.js` and + * `./spec/schema.js` rather than restated here, so the manifest cannot drift + * from the implementation. A test additionally asserts that every name it + * advertises resolves to a real export of the public barrel. + * + * @packageDocumentation + */ + +import { + BLOCK_REGISTRY, + COMPONENT_REGISTRY, + LINT_RULES, + type LintRuleCode, +} from './registry.js'; +import { ErrorCode, type ErrorCodeValue } from './errors.js'; +import { SCHEMA_SUBJECTS, schemaId, type SchemaSubject } from './spec/schema.js'; +import { version } from './version.js'; + +/** A component entry in the manifest. */ +export interface ManifestComponent { + readonly name: string; + /** Host tag emitted, or `null` for the one composite (`
`). */ + readonly tag: string | null; + readonly summary: string; + readonly aliases?: readonly string[]; +} + +/** A `DocSpec` block entry in the manifest. */ +export interface ManifestBlock { + /** Every tuple kind this entry covers. */ + readonly kinds: readonly string[]; + /** The tuple form, as written in a spec. */ + readonly tuple: string; + readonly summary: string; + /** The equivalent JSX component. */ + readonly component: string; +} + +/** A callable entry point in the manifest. */ +export interface ManifestEntrypoint { + readonly name: string; + readonly signature: string; + readonly summary: string; + /** `'sync'`, `'async'`, or `'stream'` for the generator-returning ones. */ + readonly kind: 'sync' | 'async' | 'stream'; + /** `true` when the function only runs under Node.js. */ + readonly nodeOnly?: boolean; +} + +/** A lint rule entry in the manifest. */ +export interface ManifestLintRule { + readonly code: LintRuleCode; + readonly severity: string; + readonly description: string; +} + +/** The full capability manifest. */ +export interface CapabilityManifest { + readonly kind: 'capability-manifest'; + readonly name: 'pdfnative-react'; + readonly version: string; + /** `$id` of the manifest's own schema, so the shape is self-describing. */ + readonly schemaId: string; + /** The invariants a caller can rely on. */ + readonly contract: { + /** This package authors documents; it never post-processes PDF bytes. */ + readonly authoringOnly: true; + /** Declarative block flow — there is no CSS/flexbox model and no ``. */ + readonly layoutModel: 'block-flow'; + readonly react: string; + readonly engine: string; + readonly node: string; + /** Where side effects can occur — nowhere, by design. */ + readonly sideEffects: 'none'; + /** Outbound network calls and telemetry are never made. */ + readonly network: 'none'; + }; + readonly components: readonly ManifestComponent[]; + readonly specBlocks: readonly ManifestBlock[]; + readonly entrypoints: readonly ManifestEntrypoint[]; + readonly errorCodes: readonly ErrorCodeValue[]; + readonly lintRules: readonly ManifestLintRule[]; + readonly schemaSubjects: readonly SchemaSubject[]; +} + +/** + * The callable surface. + * + * Kept adjacent to the registries it accompanies; `tests/manifest.test.ts` + * asserts every `name` here is a real export of `src/index.ts`, which is what + * stops this list from going stale. + */ +const ENTRYPOINTS: readonly ManifestEntrypoint[] = [ + { + name: 'renderToBytes', + signature: '(node, options?) => Uint8Array', + summary: 'Render to raw PDF bytes. Works in Node and the browser.', + kind: 'sync', + }, + { + name: 'renderToBlob', + signature: '(node, options?) => Blob', + summary: 'Render to an application/pdf Blob for download or preview.', + kind: 'sync', + }, + { + name: 'renderToStream', + signature: '(node, options?) => AsyncGenerator', + summary: 'Page-by-page byte stream with constant memory.', + kind: 'stream', + }, + { + name: 'renderToResponse', + signature: '(node, options?) => Promise', + summary: + 'Render straight to a web-standard Response — Next.js route handlers, ' + + 'Remix, Hono, Workers. Streams by default.', + kind: 'async', + }, + { + name: 'renderToFile', + signature: '(node, path, options?) => Promise', + summary: 'Render and write to a file.', + kind: 'async', + nodeOnly: true, + }, + { + name: 'renderToFileStream', + signature: '(node, path, options?) => Promise', + summary: 'Stream to a file with constant memory, preserving outline and page labels.', + kind: 'async', + nodeOnly: true, + }, + { + name: 'compileDocument', + signature: '(node) => DocumentParams', + summary: 'Compile the tree to the pdfnative model without rendering (dry-run tier 2).', + kind: 'sync', + }, + { + name: 'inspectDocument', + signature: '(node, options?) => LayoutInspection', + summary: 'Report pagination and per-block geometry without rendering (dry-run tier 4).', + kind: 'sync', + }, + { + name: 'lintDocument', + signature: '(node, options?) => LintReport', + summary: + 'Accessibility and layout findings, including engine constraints that would ' + + 'otherwise throw at render time (dry-run tier 3).', + kind: 'sync', + }, + { + name: 'validateSpec', + signature: '(spec: unknown) => SpecValidation', + summary: + 'Structurally validate an untrusted DocSpec with no JSON-Schema engine ' + + '(dry-run tier 1).', + kind: 'sync', + }, + { + name: 'compileSpec', + signature: '(spec) => DocumentParams', + summary: 'Compile a DocSpec to the pdfnative model.', + kind: 'sync', + }, + { + name: 'specToElement', + signature: '(spec) => ReactElement', + summary: 'Turn a DocSpec into a tree for embedding in JSX.', + kind: 'sync', + }, + { + name: 'renderSpecToBytes', + signature: '(spec, options?) => Uint8Array', + summary: 'Render a DocSpec to raw PDF bytes.', + kind: 'sync', + }, + { + name: 'renderSpecToResponse', + signature: '(spec, options?) => Promise', + summary: 'Render a DocSpec straight to a web-standard Response.', + kind: 'async', + }, + { + name: 'lintSpec', + signature: '(spec, options?) => LintReport', + summary: 'Lint a DocSpec. Identical rules to lintDocument.', + kind: 'sync', + }, + { + name: 'inspectSpec', + signature: '(spec, options?) => LayoutInspection', + summary: 'Report how a DocSpec paginates, without rendering.', + kind: 'sync', + }, + { + name: 'schema', + signature: '(subject?) => JsonSchema', + summary: 'Emit a versioned Draft 2020-12 schema. Start with schema("list").', + kind: 'sync', + }, + { + name: 'capabilityManifest', + signature: '() => CapabilityManifest', + summary: 'This document — everything the package can do.', + kind: 'sync', + }, + { + name: 'doctor', + signature: '() => DoctorReport', + summary: 'Environment pre-flight. Never throws. Call this first in a new environment.', + kind: 'sync', + }, + { + name: 'resolveFonts', + signature: '(map) => Promise', + summary: 'Register and load font modules in one step.', + kind: 'async', + }, + { + name: 'validateIssueDraft', + signature: '(markdown) => GovernanceValidation', + summary: 'Gate an AI-authored issue/PR draft against the governance policy.', + kind: 'sync', + }, + { + name: 'aiGovernancePolicy', + signature: '() => AiGovernancePolicy', + summary: 'The machine-readable human-in-the-loop policy this repo enforces.', + kind: 'sync', + }, +]; + +/** + * Describe everything this package can do, as plain JSON. + * + * @example + * ```ts + * const m = capabilityManifest(); + * m.specBlocks.map((b) => b.tuple); // the whole DocSpec grammar + * m.entrypoints.filter((e) => !e.nodeOnly); + * ``` + */ +export function capabilityManifest(): CapabilityManifest { + return { + kind: 'capability-manifest', + name: 'pdfnative-react', + version, + schemaId: schemaId('manifest'), + contract: { + authoringOnly: true, + layoutModel: 'block-flow', + react: '^19.0.0', + engine: '^1.6.0', + node: '>=22', + sideEffects: 'none', + network: 'none', + }, + components: COMPONENT_REGISTRY.map((c) => ({ + name: c.name, + tag: c.tag, + summary: c.summary, + ...('aliases' in c ? { aliases: c.aliases } : {}), + })), + specBlocks: BLOCK_REGISTRY.map((b) => ({ + kinds: [...b.kinds], + tuple: b.tuple, + summary: b.summary, + component: b.component, + })), + entrypoints: ENTRYPOINTS, + errorCodes: Object.values(ErrorCode), + lintRules: (Object.keys(LINT_RULES) as LintRuleCode[]).map((code) => ({ + code, + severity: LINT_RULES[code].severity, + description: LINT_RULES[code].description, + })), + schemaSubjects: [...SCHEMA_SUBJECTS], + }; +} diff --git a/src/reconciler/nodes.ts b/src/reconciler/nodes.ts index cc98704..0dd18ef 100644 --- a/src/reconciler/nodes.ts +++ b/src/reconciler/nodes.ts @@ -23,6 +23,7 @@ export type HostTag = | 'toc' | 'barcode' | 'svg' + | 'chart' | 'formField'; /** A reconciled element node. */ diff --git a/src/reconciler/serialize.ts b/src/reconciler/serialize.ts index 97707f5..ba392a6 100644 --- a/src/reconciler/serialize.ts +++ b/src/reconciler/serialize.ts @@ -12,8 +12,13 @@ import type { ListItem, OutlineItem, PageLabelRange, + PdfAttachment, + PdfLayoutOptions, PdfRow, + PageTemplate, + WatermarkOptions, } from '../types.js'; +import { PdfStructureError } from '../errors.js'; import { type ElementNode, type HostNode, @@ -21,13 +26,12 @@ import { isElementNode, } from './nodes.js'; -/** Thrown when a component tree cannot be mapped onto the pdfnative model. */ -export class PdfStructureError extends Error { - constructor(message: string) { - super(message); - this.name = 'PdfStructureError'; - } -} +/** + * Re-exported from `../errors.js` for backward compatibility: this was the + * original definition site, and it stays importable from here. It is the same + * class object, so `instanceof` keeps working across both paths. + */ +export { PdfStructureError }; function collectText(node: HostNode): string { if (!isElementNode(node)) return node.text; @@ -151,6 +155,23 @@ function toBlock(node: ElementNode): DocumentBlock | DocumentBlock[] { alt: p.alt, }) as DocumentBlock; + case 'chart': + return compact({ + type: 'chart', + chartType: p.chartType, + series: p.series, + categories: p.categories, + width: p.width, + height: p.height, + title: p.title, + legend: p.legend, + axis: p.axis, + markers: p.markers, + colors: p.colors, + align: p.align, + altText: p.altText, + }) as DocumentBlock; + case 'formField': return compact({ type: 'formField', @@ -298,6 +319,38 @@ function findDocument(container: RootContainer): ElementNode { throw new PdfStructureError('No found at the root of the tree.'); } +/** Normalize the `watermark` shorthand: `"DRAFT"` → `{ text: { text: 'DRAFT' } }`. */ +function toWatermark(value: unknown): WatermarkOptions | undefined { + if (value === undefined) return undefined; + if (typeof value === 'string') return { text: { text: value } }; + return value as WatermarkOptions; +} + +/** + * Fold the `` layout-sugar props (`watermark`, `header`, `footer`, + * `attachments`, `tagged`) into a single `layout` object. + * + * An explicit `layout` prop always wins, mirroring how `RenderOptions.layout` + * overrides `DocumentParams.layout` in `prepare()` (see `../render.ts`). + * + * Returns `undefined` — never an empty object — when neither sugar nor an + * explicit `layout` is present, so documents that use none of this serialize + * byte-identically to previous releases. + */ +function resolveLayout(p: Record): Partial | undefined { + const sugar = compact({ + watermark: toWatermark(p.watermark), + headerTemplate: p.header as PageTemplate | undefined, + footerTemplate: p.footer as PageTemplate | undefined, + attachments: p.attachments as readonly PdfAttachment[] | undefined, + tagged: p.tagged as PdfLayoutOptions['tagged'] | undefined, + }); + + const explicit = p.layout as Partial | undefined; + if (Object.keys(sugar).length === 0) return explicit; + return { ...sugar, ...explicit }; +} + /** Convert a committed reconciler root into a `pdfnative` `DocumentParams`. */ export function serialize(container: RootContainer): DocumentParams { const doc = findDocument(container); @@ -309,7 +362,7 @@ export function serialize(container: RootContainer): DocumentParams { footerText: p.footerText as string | undefined, fontEntries: p.fontEntries, metadata: p.metadata as DocumentMetadata | undefined, - layout: p.layout, + layout: resolveLayout(p), outline: p.outline as readonly OutlineItem[] | 'auto' | undefined, pageLabels: p.pageLabels as readonly PageLabelRange[] | undefined, }) as DocumentParams; diff --git a/src/registry.ts b/src/registry.ts new file mode 100644 index 0000000..058b2e3 --- /dev/null +++ b/src/registry.ts @@ -0,0 +1,406 @@ +/** + * The package's single source of truth for its own surface. + * + * Three tables live here — {@link BLOCK_REGISTRY} (the `DocSpec` grammar), + * {@link COMPONENT_REGISTRY} (the JSX components) and {@link LINT_RULES} (the + * lint contract). They feed four consumers: + * + * 1. `spec/schema.ts` — assembles `$defs.block.oneOf` and the report schemas. + * 2. `spec/validate.ts` — derives tuple arity and payload types. + * 3. `manifest.ts` — emits the machine-readable capability manifest. + * 4. `tests/registry.test.ts` — locks the exact, ordered contents. + * + * Because all three derive from these tables rather than restating them, the + * schema, the manifest and the docs cannot drift apart. The compile-time + * assertions at the bottom of this file make *omission* a build error, not a + * silent gap: add a member to `BlockSpec` or `HostTag` without registering it + * here and `npm run typecheck` fails. + * + * This module is pure data — no engine import, no side effects, isomorphic. + * + * @packageDocumentation + */ + +import type { BlockSpecKind } from './spec/types.js'; +import type { HostTag } from './reconciler/nodes.js'; + +// ───────────────────────────────────────────────────────────────────────────── +// Block registry — the DocSpec grammar +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Expected JavaScript type of a tuple's payload — element `[1]`. + * + * `'blocks'` is a nested `BlockSpec[]` (only `['page', …]`), and `'none'` means + * the tuple carries no payload at all (`['br']`). + */ +export type BlockPayloadKind = 'string' | 'array' | 'object' | 'number' | 'blocks' | 'none'; + +/** Shape of a {@link BLOCK_REGISTRY} entry. */ +interface BlockDescriptorShape { + /** Schema-group id. Several tuple kinds may share one schema (`h1`/`h2`/`h3`). */ + readonly id: string; + /** Every `DocSpec` tuple kind this group covers. */ + readonly kinds: readonly BlockSpecKind[]; + /** Minimum tuple length. */ + readonly minItems: number; + /** Maximum tuple length. When 3, element `[2]` is an options object. */ + readonly maxItems: number; + /** Expected type of element `[1]`, when present. */ + readonly payload: BlockPayloadKind; + /** The tuple form, as written in a spec. */ + readonly tuple: string; + /** One-line description, reused verbatim by the capability manifest. */ + readonly summary: string; + /** Name of the equivalent JSX component. */ + readonly component: string; +} + +/** + * Every block kind in the `DocSpec` grammar, in schema order. + * + * Order is part of the contract: it fixes the order of `$defs.block.oneOf` and + * of `capabilityManifest().specBlocks`, and a test pins it. + */ +export const BLOCK_REGISTRY = [ + { + id: 'heading', + minItems: 2, + maxItems: 3, + payload: 'string', + kinds: ['h1', 'h2', 'h3'], + tuple: "['h1' | 'h2' | 'h3', text, opts?]", + summary: 'Section heading (levels 1–3). Feeds the TOC and outline="auto".', + component: 'Heading', + }, + { + id: 'paragraph', + minItems: 2, + maxItems: 3, + payload: 'string', + kinds: ['p'], + tuple: "['p', text, opts?]", + summary: 'Wrapping paragraph of body text.', + component: 'Paragraph', + }, + { + id: 'list', + minItems: 2, + maxItems: 3, + payload: 'array', + kinds: ['ul', 'ol'], + tuple: "['ul' | 'ol', items, opts?]", + summary: 'Bullet or numbered list; items may nest sub-lists.', + component: 'List', + }, + { + id: 'table', + minItems: 2, + maxItems: 2, + payload: 'object', + kinds: ['table'], + tuple: "['table', body]", + summary: 'Data table with headers, column definitions, zebra striping and borders.', + component: 'Table', + }, + { + id: 'image', + minItems: 2, + maxItems: 2, + payload: 'object', + kinds: ['img'], + tuple: "['img', body]", + summary: 'Raster image (JPEG/PNG) from a Uint8Array.', + component: 'Image', + }, + { + id: 'link', + minItems: 3, + maxItems: 3, + payload: 'string', + kinds: ['link'], + tuple: "['link', text, opts]", + summary: 'External hyperlink annotation.', + component: 'Link', + }, + { + id: 'spacer', + minItems: 1, + maxItems: 2, + payload: 'number', + kinds: ['sp'], + tuple: "['sp', height?]", + summary: 'Vertical whitespace in points.', + component: 'Spacer', + }, + { + id: 'pageBreak', + minItems: 1, + maxItems: 1, + payload: 'none', + kinds: ['br'], + tuple: "['br']", + summary: 'Hard page break.', + component: 'PageBreak', + }, + { + id: 'page', + minItems: 2, + maxItems: 2, + payload: 'blocks', + kinds: ['page'], + tuple: "['page', blocks]", + summary: 'Explicit page group; blocks inside start on a fresh page.', + component: 'Page', + }, + { + id: 'toc', + minItems: 1, + maxItems: 2, + payload: 'object', + kinds: ['toc'], + tuple: "['toc', opts?]", + summary: 'Auto-generated table of contents built from heading blocks.', + component: 'TableOfContents', + }, + { + id: 'barcode', + minItems: 2, + maxItems: 3, + payload: 'string', + kinds: ['qr', 'code128', 'ean13', 'pdf417', 'datamatrix'], + tuple: "['qr' | 'code128' | 'ean13' | 'pdf417' | 'datamatrix', data, opts?]", + summary: '1D or 2D barcode rendered with vector operators.', + component: 'Barcode', + }, + { + id: 'svg', + minItems: 2, + maxItems: 3, + payload: 'string', + kinds: ['svg'], + tuple: "['svg', data, opts?]", + summary: 'Inline vector graphics; / render as selectable PDF text.', + component: 'Svg', + }, + { + id: 'chart', + minItems: 2, + maxItems: 2, + payload: 'object', + kinds: ['chart'], + tuple: "['chart', body]", + summary: + 'Native vector chart (bar, barH, line, pie, donut) drawn with PDF path ' + + 'operators. Requires the pdfnative engine >= 1.6.0.', + component: 'Chart', + }, + { + id: 'field', + minItems: 2, + maxItems: 2, + payload: 'object', + kinds: ['field'], + tuple: "['field', body]", + summary: 'Interactive AcroForm widget.', + component: 'FormField', + }, +] as const satisfies readonly BlockDescriptorShape[]; + +/** A schema-group id from {@link BLOCK_REGISTRY}. */ +export type BlockGroupId = (typeof BLOCK_REGISTRY)[number]['id']; + +/** A read-only view of a {@link BLOCK_REGISTRY} entry. */ +export type BlockDescriptor = (typeof BLOCK_REGISTRY)[number]; + +// ───────────────────────────────────────────────────────────────────────────── +// Component registry — the JSX surface +// ───────────────────────────────────────────────────────────────────────────── + +/** Shape of a {@link COMPONENT_REGISTRY} entry. */ +interface ComponentDescriptorShape { + /** Exported component name. */ + readonly name: string; + /** Host tag emitted, or `null` for the one composite (`
`). */ + readonly tag: HostTag | null; + /** One-line description. */ + readonly summary: string; + /** Additional exported aliases for the same component. */ + readonly aliases?: readonly string[]; +} + +/** Every public component, in barrel order. */ +export const COMPONENT_REGISTRY = [ + { + name: 'Document', + tag: 'document', + summary: + 'Required root. Carries title, metadata, fonts, outline, page labels and ' + + 'the layout sugar (watermark, header, footer, attachments, tagged).', + }, + { name: 'Page', tag: 'page', summary: 'Explicit page boundary.' }, + { + name: 'Section', + tag: null, + summary: + 'The one composite: a heading plus its grouped content. Emits no host tag ' + + 'of its own.', + }, + { name: 'Heading', tag: 'heading', summary: 'Section heading, levels 1–3.' }, + { + name: 'Paragraph', + tag: 'paragraph', + summary: 'Wrapping body text.', + aliases: ['Text'], + }, + { name: 'List', tag: 'list', summary: 'Bullet or numbered list.' }, + { name: 'Item', tag: 'item', summary: 'A list item; may nest sub-lists.' }, + { name: 'Table', tag: 'table', summary: 'Data table.' }, + { name: 'Row', tag: 'row', summary: 'A table row.' }, + { name: 'Cell', tag: 'cell', summary: 'A table cell.' }, + { name: 'Image', tag: 'image', summary: 'Raster image from bytes.' }, + { name: 'Link', tag: 'link', summary: 'External hyperlink.' }, + { name: 'Spacer', tag: 'spacer', summary: 'Vertical whitespace.' }, + { name: 'PageBreak', tag: 'pageBreak', summary: 'Hard page break.' }, + { + name: 'TableOfContents', + tag: 'toc', + summary: 'Auto-generated table of contents.', + aliases: ['Toc'], + }, + { name: 'Barcode', tag: 'barcode', summary: '1D/2D barcode.' }, + { name: 'Svg', tag: 'svg', summary: 'Inline vector graphics.' }, + { + name: 'Chart', + tag: 'chart', + summary: 'Native vector chart (bar, barH, line, pie, donut).', + }, + { name: 'FormField', tag: 'formField', summary: 'Interactive AcroForm widget.' }, +] as const satisfies readonly ComponentDescriptorShape[]; + +/** A read-only view of a {@link COMPONENT_REGISTRY} entry. */ +export type ComponentDescriptor = (typeof COMPONENT_REGISTRY)[number]; + +// ───────────────────────────────────────────────────────────────────────────── +// Lint-rule registry +// ───────────────────────────────────────────────────────────────────────────── + +/** How serious a lint finding is. */ +export type LintSeverity = 'error' | 'warning' | 'info'; + +/** + * Stable lint-rule registry: code → severity + one-line description. + * + * Lives here, next to the other surface tables, so `spec/schema.ts` can describe + * a lint report **without importing the linter** — and therefore without pulling + * the pdfnative engine into the schema path. Emitting the schema stays a pure, + * dependency-free operation. + */ +export const LINT_RULES = { + L_EMPTY_DOCUMENT: { + severity: 'error', + description: 'The document has no blocks — it would render as a blank page.', + }, + L_IMAGE_ALT: { + severity: 'warning', + description: 'An image has no alt text, so assistive technology cannot describe it.', + }, + L_CHART_ALT: { + severity: 'info', + description: + 'A chart has no altText. The engine auto-generates a generic description; ' + + 'a written one is far more useful.', + }, + L_TABLE_HEADERS: { + severity: 'warning', + description: 'A table has no header row, which breaks screen-reader navigation.', + }, + L_HEADING_HIERARCHY: { + severity: 'warning', + description: 'Heading levels skip a step (e.g. h1 followed by h3).', + }, + L_FIELD_LABEL: { + severity: 'warning', + description: 'A form field has no label, leaving the widget unidentified.', + }, + L_LINK_TEXT: { + severity: 'warning', + description: 'A link has no text, or its text is just the raw URL.', + }, + L_TAGGED_NO_FONTS: { + severity: 'error', + description: + 'PDF/A is requested but no fontEntries are supplied. PDF/A requires every ' + + 'rendering font to be embedded (veraPDF rule 6.2.11.4.1).', + }, + L_TAGGED_ENCRYPTED: { + severity: 'error', + description: 'PDF/A and encryption are mutually exclusive (ISO 19005-1 §6.3.2).', + }, + L_ATTACHMENTS_NEED_PDFA3: { + severity: 'error', + description: + "Embedded file attachments require tagged: 'pdfa3b' — only PDF/A-3 " + + 'permits them (ISO 19005-3).', + }, + L_MAX_BLOCKS: { + severity: 'warning', + description: 'The block count is within 10% of the configured maxBlocks ceiling.', + }, + L_CHART_SERIES: { + severity: 'error', + description: 'A pie or donut chart must have exactly one series.', + }, + L_CHART_CATEGORIES: { + severity: 'error', + description: "A chart series has a different length from the chart's categories.", + }, + L_CHART_VALUES: { + severity: 'error', + description: 'A chart contains a non-finite value, or a negative value in a pie/donut.', + }, + L_CHART_POINTS: { + severity: 'error', + description: 'A chart exceeds the engine ceiling of 10 000 data points.', + }, + L_OVERFLOW: { + severity: 'warning', + description: 'A block overflows the page content box (requires `overflow: true`).', + }, +} as const satisfies Record< + string, + { readonly severity: LintSeverity; readonly description: string } +>; + +/** A stable lint-rule identifier. */ +export type LintRuleCode = keyof typeof LINT_RULES; + +/** Every rule code, in registry order. */ +export const LINT_RULE_CODES = Object.keys(LINT_RULES) as readonly LintRuleCode[]; + +// ───────────────────────────────────────────────────────────────────────────── +// Compile-time exhaustiveness locks +// +// These make omission a *build* error. Delete an entry above (or add a member +// to `BlockSpec` / `HostTag` without registering it) and `npm run typecheck` +// fails before any test runs. +// ───────────────────────────────────────────────────────────────────────────── + +/** Exact type equality (invariant, so it catches widening in both directions). */ +type Equals = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; + +/** Fails to compile unless `T` is exactly `true`. */ +type Assert = T; + +/** Every tuple kind covered by {@link BLOCK_REGISTRY}. */ +type RegisteredBlockKind = (typeof BLOCK_REGISTRY)[number]['kinds'][number]; + +/** Every host tag produced by {@link COMPONENT_REGISTRY}. */ +type RegisteredTag = Exclude<(typeof COMPONENT_REGISTRY)[number]['tag'], null>; + +/** Locks {@link BLOCK_REGISTRY} to the `BlockSpec` union. */ +export type BlockRegistryIsExhaustive = Assert>; + +/** Locks {@link COMPONENT_REGISTRY} to the `HostTag` union. */ +export type ComponentRegistryIsExhaustive = Assert>; diff --git a/src/response.ts b/src/response.ts new file mode 100644 index 0000000..b0fb1da --- /dev/null +++ b/src/response.ts @@ -0,0 +1,123 @@ +/** + * Web-standard `Response` helpers — the server-side entry point. + * + * These turn a document straight into an HTTP response, which is what a + * Next.js Route Handler, a Server Action, a Remix loader, a Hono/Elysia route + * or any Fetch-API server actually needs: + * + * ```ts + * // app/invoice/route.ts + * import { renderToResponse } from 'pdfnative-react'; + * + * export async function GET() { + * return renderToResponse(, { fileName: 'invoice.pdf' }); + * } + * ``` + * + * By default the body is a `ReadableStream` fed by the engine's page-by-page + * generator, so peak memory stays flat and the client sees bytes immediately. + * Pass `buffered: true` when you need a `Content-Length` up front. + * + * Nothing here touches the DOM or React client APIs — **do not** add + * `'use client'` to this module. + * + * @packageDocumentation + */ + +import type { ReactNode } from 'react'; +import { renderToBytes, renderToStream } from './render.js'; +import { optionsWithFonts } from './fonts.js'; +import type { RenderOptions } from './types.js'; + +/** Options for {@link renderToResponse} / {@link renderSpecToResponse}. */ +export interface PdfResponseOptions extends RenderOptions { + /** Filename advertised in `Content-Disposition`. Default: `'document.pdf'`. */ + readonly fileName?: string; + /** + * `'inline'` renders in the browser's PDF viewer, `'attachment'` forces a + * download. Default: `'inline'`. + */ + readonly disposition?: 'inline' | 'attachment'; + /** + * Buffer the whole PDF before responding, which allows a `Content-Length` + * header. Default: `false` (stream with constant memory). + */ + readonly buffered?: boolean; + /** HTTP status code. Default: `200`. */ + readonly status?: number; + /** Extra response headers, merged last so they can override the defaults. */ + readonly headers?: HeadersInit; +} + +/** + * Build an RFC 6266 `Content-Disposition` value, adding the `filename*` + * parameter only when the name is not plain ASCII. + */ +function contentDisposition(disposition: 'inline' | 'attachment', fileName: string): string { + const ascii = fileName.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_'); + const encoded = encodeURIComponent(fileName); + const base = `${disposition}; filename="${ascii}"`; + return encoded === ascii ? base : `${base}; filename*=UTF-8''${encoded}`; +} + +/** Adapt the engine's async byte generator to a web `ReadableStream`. */ +function toReadableStream(source: AsyncGenerator): ReadableStream { + return new ReadableStream({ + async pull(controller) { + const next = await source.next(); + if (next.done === true) { + controller.close(); + return; + } + controller.enqueue(next.value); + }, + async cancel() { + // Let the generator run its cleanup when the client disconnects. + await source.return(undefined); + }, + }); +} + +function buildHeaders(options: PdfResponseOptions | undefined, byteLength?: number): Headers { + const headers = new Headers({ + 'content-type': 'application/pdf', + 'content-disposition': contentDisposition( + options?.disposition ?? 'inline', + options?.fileName ?? 'document.pdf', + ), + }); + if (byteLength !== undefined) headers.set('content-length', String(byteLength)); + if (options?.headers) { + // Merge last so callers can override any default (e.g. cache-control). + new Headers(options.headers).forEach((value, key) => headers.set(key, value)); + } + return headers; +} + +/** + * Render a document straight to a web-standard `Response`. + * + * Streams by default. Works in Node ≥ 22, the Edge runtime, Deno, Bun and + * Cloudflare Workers — anywhere `Response` and `ReadableStream` exist. + * + * @param node - A React element whose root is ``. + * @param options - Render options plus response shaping (filename, disposition…). + */ +export async function renderToResponse( + node: ReactNode, + options?: PdfResponseOptions, +): Promise { + const resolved = await optionsWithFonts(options); + const status = options?.status ?? 200; + + if (options?.buffered === true) { + const bytes = renderToBytes(node, resolved); + return new Response(bytes as BodyInit, { + status, + headers: buildHeaders(options, bytes.byteLength), + }); + } + + const stream = toReadableStream(renderToStream(node, resolved)); + return new Response(stream, { status, headers: buildHeaders(options) }); +} diff --git a/src/spec/compile.ts b/src/spec/compile.ts index d5c9cbf..e286c4a 100644 --- a/src/spec/compile.ts +++ b/src/spec/compile.ts @@ -12,6 +12,7 @@ import { createElement, type ReactElement, type ReactNode } from 'react'; import { Barcode, + Chart, Document, FormField, Heading, @@ -37,7 +38,9 @@ import { renderToFileStream, renderToStream, } from '../render.js'; -import { PdfStructureError } from '../reconciler/serialize.js'; +import { renderToResponse, type PdfResponseOptions } from '../response.js'; +import { lintDocument, type LintOptions, type LintReport } from '../lint.js'; +import { PdfStructureError } from '../errors.js'; import type { DocumentParams, LayoutInspection, @@ -112,6 +115,8 @@ function blockToElement(block: BlockSpec, key: number): ReactElement { }); case 'svg': return createElement(Svg, { key, data: block[1], ...block[2] }); + case 'chart': + return createElement(Chart, { key, ...block[1] }); case 'field': return createElement(FormField, { key, ...block[1] }); default: { @@ -138,6 +143,11 @@ export function specToElement(spec: DocSpec): ReactElement { layout: spec.layout, outline: spec.outline, pageLabels: spec.pageLabels, + watermark: spec.watermark, + header: spec.header, + footer: spec.footer, + attachments: spec.attachments, + tagged: spec.tagged, }, children, ); @@ -156,6 +166,15 @@ export function inspectSpec(spec: DocSpec, options?: RenderOptions): LayoutInspe return inspectDocument(specToElement(spec), options); } +/** + * Check a {@link DocSpec} for accessibility and layout problems without + * rendering it. The spec twin of `lintDocument` — identical rules, since both + * inspect the compiled document model. + */ +export function lintSpec(spec: DocSpec, options?: LintOptions): LintReport { + return lintDocument(specToElement(spec), options); +} + /** Render a {@link DocSpec} to raw PDF bytes (`Uint8Array`). */ export function renderSpecToBytes(spec: DocSpec, options?: RenderOptions): Uint8Array { return renderToBytes(specToElement(spec), options); @@ -194,3 +213,14 @@ export function renderSpecToFileStream( ): Promise { return renderToFileStream(specToElement(spec), path, options); } + +/** + * Render a {@link DocSpec} straight to a web-standard `Response`. + * The spec twin of `renderToResponse` — see it for streaming semantics. + */ +export function renderSpecToResponse( + spec: DocSpec, + options?: PdfResponseOptions, +): Promise { + return renderToResponse(specToElement(spec), options); +} diff --git a/src/spec/index.ts b/src/spec/index.ts index 82f7f1e..e2ee208 100644 --- a/src/spec/index.ts +++ b/src/spec/index.ts @@ -11,15 +11,25 @@ export { specToElement, compileSpec, inspectSpec, + lintSpec, renderSpecToBytes, renderSpecToBlob, renderSpecToStream, renderSpecToFile, renderSpecToFileStream, + renderSpecToResponse, } from './compile.js'; -export { docSpecSchema, docSpecSchemaId } from './schema.js'; -export type { JsonSchema } from './schema.js'; +export { schema, schemaId, SCHEMA_SUBJECTS, docSpecSchema, docSpecSchemaId } from './schema.js'; +export type { JsonSchema, SchemaSubject } from './schema.js'; + +export { validateSpec, SpecCode } from './validate.js'; +export type { + SpecCodeValue, + SpecFinding, + SpecFindingSeverity, + SpecValidation, +} from './validate.js'; export type { DocSpec, @@ -40,6 +50,8 @@ export type { TocSpec, BarcodeSpec, SvgSpec, + ChartSpec, + ChartSpecBody, FormFieldSpec, FormFieldSpecBody, HeadingSpecOpts, diff --git a/src/spec/schema.ts b/src/spec/schema.ts index 4ec574d..8930e4a 100644 --- a/src/spec/schema.ts +++ b/src/spec/schema.ts @@ -10,6 +10,13 @@ */ import { version } from '../version.js'; +import { + BLOCK_REGISTRY, + LINT_RULES, + LINT_RULE_CODES, + type BlockGroupId, +} from '../registry.js'; +import { ErrorCode, PdfReactError } from '../errors.js'; import type { DocSpec } from './types.js'; /** A read-only JSON Schema fragment. */ @@ -18,8 +25,34 @@ export type JsonSchema = Readonly>; const DRAFT = 'https://json-schema.org/draft/2020-12/schema'; const ID_BASE = 'https://pdfnative.dev/schema/react'; -function schemaId(): string { - return `${ID_BASE}/${version}/doc-spec.schema.json`; +/** + * Every schema this package can emit. + * + * `'list'` is the self-describing index — ask for it first when you do not know + * what is available. + */ +export const SCHEMA_SUBJECTS = [ + 'doc-spec', + 'render-options', + 'lint-report', + 'spec-validation', + 'doctor', + 'manifest', + 'list', +] as const; + +/** A subject accepted by {@link schema} / {@link schemaId}. */ +export type SchemaSubject = (typeof SCHEMA_SUBJECTS)[number]; + +/** + * The versioned `$id` for a subject, e.g. + * `https://pdfnative.dev/schema/react/1.1.0/doc-spec.schema.json`. + * + * The version is embedded deliberately: a consumer that caches a schema can + * detect contract drift by comparing `$id`s alone. + */ +export function schemaId(subject: SchemaSubject = 'doc-spec'): string { + return `${ID_BASE}/${version}/${subject}.schema.json`; } /** `['h1' | 'h2' | 'h3', text, opts?]` */ @@ -27,8 +60,6 @@ function headingBlock(): JsonSchema { return { type: 'array', title: 'HeadingSpec', - minItems: 2, - maxItems: 3, prefixItems: [ { enum: ['h1', 'h2', 'h3'] }, { type: 'string', description: 'Heading text.' }, @@ -42,8 +73,6 @@ function paragraphBlock(): JsonSchema { return { type: 'array', title: 'ParagraphSpec', - minItems: 2, - maxItems: 3, prefixItems: [ { const: 'p' }, { type: 'string', description: 'Paragraph text.' }, @@ -57,8 +86,6 @@ function listBlock(): JsonSchema { return { type: 'array', title: 'ListSpec', - minItems: 2, - maxItems: 3, prefixItems: [ { enum: ['ul', 'ol'] }, { @@ -106,13 +133,27 @@ function outlineItemDef(): JsonSchema { }; } +/** A header/footer template with `{page}`, `{pages}`, `{date}`, `{title}` placeholders. */ +function pageTemplateDef(): JsonSchema { + return { + type: 'object', + description: + 'PageTemplate. Placeholders: {page}, {pages}, {date}, {title}.', + properties: { + left: { type: 'string' }, + center: { type: 'string' }, + right: { type: 'string' }, + fontSize: { type: 'number', description: 'Default 7.' }, + color: { type: ['string', 'array'] }, + }, + }; +} + /** `['table', body]` */ function tableBlock(): JsonSchema { return { type: 'array', title: 'TableSpec', - minItems: 2, - maxItems: 2, prefixItems: [ { const: 'table' }, { @@ -169,8 +210,6 @@ function imageBlock(): JsonSchema { return { type: 'array', title: 'ImageSpec', - minItems: 2, - maxItems: 2, prefixItems: [ { const: 'img' }, { @@ -187,8 +226,6 @@ function linkBlock(): JsonSchema { return { type: 'array', title: 'LinkSpec', - minItems: 3, - maxItems: 3, prefixItems: [ { const: 'link' }, { type: 'string', description: 'Link text.' }, @@ -209,8 +246,6 @@ function spacerBlock(): JsonSchema { return { type: 'array', title: 'SpacerSpec', - minItems: 1, - maxItems: 2, prefixItems: [{ const: 'sp' }, { type: 'number', description: 'Height in points.' }], }; } @@ -220,8 +255,6 @@ function pageBreakBlock(): JsonSchema { return { type: 'array', title: 'PageBreakSpec', - minItems: 1, - maxItems: 1, prefixItems: [{ const: 'br' }], }; } @@ -231,8 +264,6 @@ function pageBlock(): JsonSchema { return { type: 'array', title: 'PageSpec', - minItems: 2, - maxItems: 2, prefixItems: [ { const: 'page' }, { type: 'array', description: 'Nested blocks for this page.', items: { $ref: '#/$defs/block' } }, @@ -245,8 +276,6 @@ function tocBlock(): JsonSchema { return { type: 'array', title: 'TocSpec', - minItems: 1, - maxItems: 2, prefixItems: [ { const: 'toc' }, { type: 'object', description: 'Optional { title, maxLevel, fontSize, indent }.' }, @@ -259,8 +288,6 @@ function barcodeBlock(): JsonSchema { return { type: 'array', title: 'BarcodeSpec', - minItems: 2, - maxItems: 3, prefixItems: [ { enum: ['qr', 'code128', 'ean13', 'pdf417', 'datamatrix'] }, { type: 'string', description: 'Data to encode.' }, @@ -274,8 +301,6 @@ function svgBlock(): JsonSchema { return { type: 'array', title: 'SvgSpec', - minItems: 2, - maxItems: 3, prefixItems: [ { const: 'svg' }, { type: 'string', description: 'SVG path data or inline markup.' }, @@ -284,13 +309,71 @@ function svgBlock(): JsonSchema { }; } +/** `['chart', body]` */ +function chartBlock(): JsonSchema { + return { + type: 'array', + title: 'ChartSpec', + prefixItems: [ + { const: 'chart' }, + { + type: 'object', + required: ['chartType', 'series'], + description: + 'Chart body. Pie/donut take exactly one series with non-negative values.', + properties: { + chartType: { enum: ['bar', 'barH', 'line', 'pie', 'donut'] }, + series: { + type: 'array', + minItems: 1, + description: 'Data series; each value array matches `categories` in length.', + items: { + type: 'object', + required: ['label', 'values'], + properties: { + label: { type: 'string', description: 'Series label (legend).' }, + values: { type: 'array', items: { type: 'number' } }, + color: { type: ['string', 'array'] }, + }, + }, + }, + categories: { + type: 'array', + items: { type: 'string' }, + description: 'Category / slice labels. Defaults to 1-based indices.', + }, + width: { type: 'number', description: 'Plot width in points. Default 460.' }, + height: { type: 'number', description: 'Plot height in points. Default 240.' }, + title: { type: 'string' }, + legend: { enum: ['bottom', 'none'] }, + axis: { + type: 'object', + description: 'Value-axis options (bar/line only).', + properties: { + yMin: { type: 'number' }, + yMax: { type: 'number' }, + ticks: { type: 'integer', minimum: 2 }, + grid: { type: 'boolean' }, + }, + }, + markers: { type: 'boolean', description: 'Point markers on line series.' }, + colors: { type: 'array', description: 'Palette override (PdfColor[]).' }, + align: { enum: ['left', 'center', 'right'] }, + altText: { + type: 'string', + description: 'Tagged-PDF /Figure /Alt text. Auto-generated when omitted.', + }, + }, + }, + ], + }; +} + /** `['field', body]` */ function fieldBlock(): JsonSchema { return { type: 'array', title: 'FormFieldSpec', - minItems: 2, - maxItems: 2, prefixItems: [ { const: 'field' }, { @@ -302,6 +385,46 @@ function fieldBlock(): JsonSchema { }; } +/** + * Per-group schema builders, keyed by {@link BlockGroupId}. + * + * The `satisfies` clause is the anti-drift lock: registering a new block in + * `BLOCK_REGISTRY` without adding its builder here is a compile error, and + * vice-versa. + */ +const BLOCK_SCHEMAS = { + heading: headingBlock, + paragraph: paragraphBlock, + list: listBlock, + table: tableBlock, + image: imageBlock, + link: linkBlock, + spacer: spacerBlock, + pageBreak: pageBreakBlock, + page: pageBlock, + toc: tocBlock, + barcode: barcodeBlock, + svg: svgBlock, + chart: chartBlock, + field: fieldBlock, +} satisfies Record JsonSchema>; + +/** + * Assemble `$defs.block.oneOf` in {@link BLOCK_REGISTRY} order. + * + * Tuple arity and the one-line description come from the registry, not from the + * builders — the builders describe *shape*, the registry owns the *contract*, + * so the schema and `validateSpec` can never disagree about how long a tuple is. + */ +function blockDefs(): readonly JsonSchema[] { + return BLOCK_REGISTRY.map((entry) => ({ + ...BLOCK_SCHEMAS[entry.id](), + description: entry.summary, + minItems: entry.minItems, + maxItems: entry.maxItems, + })); +} + /** * Return the JSON Schema (Draft 2020-12) describing the {@link DocSpec} authoring * format. The `$id` embeds the current package version. @@ -309,7 +432,7 @@ function fieldBlock(): JsonSchema { export function docSpecSchema(): JsonSchema { return { $schema: DRAFT, - $id: schemaId(), + $id: schemaId('doc-spec'), title: 'pdfnative-react DocSpec', description: 'Compact, token-frugal document specification. Compiles to the same ' @@ -343,6 +466,64 @@ export function docSpecSchema(): JsonSchema { }, }, }, + watermark: { + oneOf: [ + { type: 'string', description: 'Shorthand for { text: { text: … } }.' }, + { + type: 'object', + description: 'WatermarkOptions: { text?, image?, position? }.', + properties: { + text: { + type: 'object', + required: ['text'], + properties: { + text: { type: 'string' }, + fontSize: { type: 'number' }, + color: { type: ['string', 'array'] }, + opacity: { type: 'number', minimum: 0, maximum: 1 }, + angle: { type: 'number' }, + autoFit: { type: 'boolean' }, + }, + }, + image: { + type: 'object', + required: ['data'], + description: '{ data: Uint8Array, opacity?, width?, height? }.', + }, + position: { enum: ['background', 'foreground'] }, + }, + }, + ], + description: 'Watermark repeated on every page. Sugar over layout.watermark.', + }, + header: { $ref: '#/$defs/pageTemplate', description: 'Running page header.' }, + footer: { $ref: '#/$defs/pageTemplate', description: 'Running page footer.' }, + attachments: { + type: 'array', + description: 'Embedded file attachments (PDF/A-3).', + items: { + type: 'object', + required: ['filename', 'data', 'mimeType'], + properties: { + filename: { type: 'string' }, + data: { description: 'File content as Uint8Array.' }, + mimeType: { type: 'string' }, + description: { type: 'string' }, + relationship: { + enum: ['Source', 'Data', 'Alternative', 'Supplement', 'Unspecified'], + }, + }, + }, + }, + tagged: { + oneOf: [ + { type: 'boolean' }, + { enum: ['pdfa1b', 'pdfa2b', 'pdfa2u', 'pdfa3b'] }, + ], + description: + 'Emit a tagged (accessible) PDF, optionally at a PDF/A conformance level. ' + + 'PDF/A requires every rendering font to be embedded via fontEntries.', + }, blocks: { type: 'array', description: 'Ordered document blocks (positional tuples).', @@ -352,30 +533,253 @@ export function docSpecSchema(): JsonSchema { $defs: { listItem: listItemDef(), outlineItem: outlineItemDef(), - block: { - oneOf: [ - headingBlock(), - paragraphBlock(), - listBlock(), - tableBlock(), - imageBlock(), - linkBlock(), - spacerBlock(), - pageBreakBlock(), - pageBlock(), - tocBlock(), - barcodeBlock(), - svgBlock(), - fieldBlock(), - ], - }, + pageTemplate: pageTemplateDef(), + block: { oneOf: blockDefs() }, }, }; } /** The `$id` (versioned URL) of the current {@link docSpecSchema}. */ export function docSpecSchemaId(): string { - return schemaId(); + return schemaId('doc-spec'); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Additional subjects — the report and option shapes an agent branches on +// ───────────────────────────────────────────────────────────────────────────── + +/** Options accepted by every render entry point. */ +function renderOptionsSchema(): JsonSchema { + return { + $schema: DRAFT, + $id: schemaId('render-options'), + title: 'pdfnative-react RenderOptions', + description: + 'Options accepted by renderToBytes/Blob/Stream/File/FileStream/Response and ' + + 'their renderSpec* twins.', + type: 'object', + properties: { + layout: { + type: 'object', + description: + 'PdfLayoutOptions overrides: pageWidth, pageHeight, margins, columns, ' + + 'colors, fontSizes, tagged, encryption, compress, headerTemplate, ' + + 'footerTemplate, watermark, attachments, maxBlocks, normalize, ' + + 'creationDate, viewerPreferences, debug.', + }, + fontEntries: { + type: 'array', + items: { type: 'object' }, + description: 'Pre-loaded font entries (see resolveFonts).', + }, + fonts: { + type: 'object', + description: + 'Map of language key → dynamic font-module loader. Honoured only by the ' + + 'async entry points; synchronous ones need fontEntries.', + }, + }, + }; +} + +/** The shape returned by `lintDocument` / `lintSpec`. */ +function lintReportSchema(): JsonSchema { + return { + $schema: DRAFT, + $id: schemaId('lint-report'), + title: 'pdfnative-react LintReport', + description: 'Accessibility and layout findings produced by lintDocument/lintSpec.', + type: 'object', + required: ['ok', 'findings', 'counts'], + properties: { + ok: { type: 'boolean', description: 'True when no finding has severity "error".' }, + counts: { + type: 'object', + required: ['error', 'warning', 'info'], + properties: { + error: { type: 'integer', minimum: 0 }, + warning: { type: 'integer', minimum: 0 }, + info: { type: 'integer', minimum: 0 }, + }, + }, + findings: { + type: 'array', + items: { + type: 'object', + required: ['code', 'severity', 'message'], + properties: { + code: { + enum: [...LINT_RULE_CODES], + description: 'Stable rule identifier — branch on this, not the message.', + }, + severity: { enum: ['error', 'warning', 'info'] }, + message: { type: 'string' }, + blockIndex: { type: 'integer', minimum: 0 }, + hint: { type: 'string' }, + }, + }, + }, + }, + $defs: { + rules: { + description: 'The full rule registry: code → severity + description.', + const: LINT_RULES, + }, + }, + }; +} + +/** The shape returned by `validateSpec`. */ +function specValidationSchema(): JsonSchema { + return { + $schema: DRAFT, + $id: schemaId('spec-validation'), + title: 'pdfnative-react SpecValidation', + description: 'Structural findings produced by validateSpec (the dry-run tier).', + type: 'object', + required: ['ok', 'errors', 'warnings'], + properties: { + ok: { type: 'boolean' }, + errors: { type: 'array', items: { $ref: '#/$defs/finding' } }, + warnings: { type: 'array', items: { $ref: '#/$defs/finding' } }, + }, + $defs: { + finding: { + type: 'object', + required: ['code', 'severity', 'path', 'message'], + properties: { + code: { + enum: [ + 'V_NOT_OBJECT', + 'V_BLOCKS', + 'V_BLOCK_SHAPE', + 'V_UNKNOWN_KIND', + 'V_ARITY', + 'V_PAYLOAD_TYPE', + 'V_OPTS_TYPE', + 'V_UNKNOWN_FIELD', + ], + }, + severity: { enum: ['error', 'warning'] }, + path: { type: 'string', description: 'e.g. "blocks[3][1]".' }, + message: { type: 'string' }, + }, + }, + }, + }; +} + +/** The shape returned by `doctor`. */ +function doctorSchema(): JsonSchema { + return { + $schema: DRAFT, + $id: schemaId('doctor'), + title: 'pdfnative-react DoctorReport', + description: 'Environment pre-flight report produced by doctor().', + type: 'object', + required: ['ok', 'checks'], + properties: { + ok: { type: 'boolean', description: 'True when no check has status "error".' }, + checks: { + type: 'array', + items: { + type: 'object', + required: ['name', 'status', 'value', 'detail'], + properties: { + name: { type: 'string' }, + status: { enum: ['ok', 'warn', 'error'] }, + value: { type: 'string' }, + detail: { type: 'string' }, + }, + }, + }, + }, + }; +} + +/** The shape returned by `capabilityManifest`. */ +function manifestSchema(): JsonSchema { + return { + $schema: DRAFT, + $id: schemaId('manifest'), + title: 'pdfnative-react CapabilityManifest', + description: + 'Machine-readable description of everything this package can do. Fetch it with ' + + 'capabilityManifest() to register pdfnative-react as an agent tool set.', + type: 'object', + required: ['kind', 'name', 'version', 'contract', 'components', 'specBlocks', 'entrypoints'], + properties: { + kind: { const: 'capability-manifest' }, + name: { const: 'pdfnative-react' }, + version: { type: 'string' }, + schemaId: { type: 'string' }, + contract: { type: 'object' }, + components: { type: 'array', items: { type: 'object' } }, + specBlocks: { type: 'array', items: { type: 'object' } }, + entrypoints: { type: 'array', items: { type: 'object' } }, + errorCodes: { type: 'array', items: { type: 'string' } }, + lintRules: { type: 'array', items: { type: 'object' } }, + }, + }; +} + +/** The self-describing index of available subjects. */ +function listSchema(): JsonSchema { + return { + $schema: DRAFT, + $id: schemaId('list'), + title: 'pdfnative-react schema subjects', + description: 'The subjects accepted by schema(subject).', + type: 'object', + required: ['subjects'], + properties: { + subjects: { + type: 'array', + items: { enum: [...SCHEMA_SUBJECTS] }, + }, + }, + examples: [{ subjects: [...SCHEMA_SUBJECTS] }], + }; +} + +/** Subject → builder. `satisfies` keeps this exhaustive against the subject union. */ +const SUBJECT_SCHEMAS = { + 'doc-spec': docSpecSchema, + 'render-options': renderOptionsSchema, + 'lint-report': lintReportSchema, + 'spec-validation': specValidationSchema, + doctor: doctorSchema, + manifest: manifestSchema, + list: listSchema, +} satisfies Record JsonSchema>; + +/** + * Return the JSON Schema (Draft 2020-12) for a subject. + * + * Every schema is pure data with a versioned `$id`; no validator is bundled, so + * this stays dependency-free. Validate with whatever tooling you already have. + * + * `schema('manifest')` returns the *schema of* the capability manifest — call + * {@link capabilityManifest} for the manifest itself. + * + * @param subject - Defaults to `'doc-spec'`, the one you almost always want. + * @throws PdfReactError with code `E_INPUT` when the subject is unknown. + * + * @example + * ```ts + * const subjects = schema('list').examples; // discover what is available + * const docSpec = schema(); // the DocSpec grammar + * ``` + */ +export function schema(subject: SchemaSubject = 'doc-spec'): JsonSchema { + const build = SUBJECT_SCHEMAS[subject] as (() => JsonSchema) | undefined; + if (build === undefined) { + throw new PdfReactError( + `Unknown schema subject ${JSON.stringify(subject)}. Valid subjects: ${SCHEMA_SUBJECTS.join(', ')}.`, + ErrorCode.INPUT, + ); + } + return build(); } // Re-export the type for convenience at the schema entry point. diff --git a/src/spec/types.ts b/src/spec/types.ts index 9c1a0b5..c8e6933 100644 --- a/src/spec/types.ts +++ b/src/spec/types.ts @@ -32,6 +32,8 @@ import type { BarcodeProps, + ChartProps, + DocumentProps, FormFieldProps, HeadingProps, ImageProps, @@ -86,6 +88,9 @@ export type ImageSpecBody = ImageProps; /** Body of a form-field block (`['field', body]`). */ export type FormFieldSpecBody = FormFieldProps; +/** Body of a chart block (`['chart', body]`). */ +export type ChartSpecBody = ChartProps; + /** * A table row in a {@link TableSpecBody}: either a plain array of cell strings * (the common case) or a full {@link PdfRow} when you need row typing/emphasis. @@ -158,6 +163,15 @@ export type BarcodeSpec = readonly [ ]; /** SVG: `['svg', data, opts?]`. */ export type SvgSpec = readonly ['svg', string, SvgSpecOpts?]; +/** + * Chart: `['chart', body]`. + * + * Uses a body object rather than positional payloads — like `['table', …]` and + * `['img', …]`, and for the same reason: the payload is a nested structure + * (`series[].values`, `axis.yMin`), and named keys are far less error-prone to + * generate than a deep positional tuple. + */ +export type ChartSpec = readonly ['chart', ChartSpecBody]; /** Form field: `['field', body]`. */ export type FormFieldSpec = readonly ['field', FormFieldSpecBody]; @@ -175,6 +189,7 @@ export type BlockSpec = | TocSpec | BarcodeSpec | SvgSpec + | ChartSpec | FormFieldSpec; /** The kind discriminator (first tuple element) of any {@link BlockSpec}. */ @@ -207,6 +222,19 @@ export interface DocSpec { readonly outline?: readonly OutlineItem[] | 'auto'; /** Page labels shown in the viewer's page box (e.g. roman front matter). */ readonly pageLabels?: readonly PageLabelRange[]; + /** + * Watermark repeated on every page. A plain string is shorthand for + * `{ text: { text: … } }`. Sugar over `layout.watermark`; `layout` wins. + */ + readonly watermark?: DocumentProps['watermark']; + /** Running page header (`{page}`, `{pages}`, `{date}`, `{title}` placeholders). */ + readonly header?: DocumentProps['header']; + /** Running page footer (`{page}`, `{pages}`, `{date}`, `{title}` placeholders). */ + readonly footer?: DocumentProps['footer']; + /** Embedded file attachments (PDF/A-3). */ + readonly attachments?: DocumentProps['attachments']; + /** Emit a tagged (accessible) PDF, optionally at a PDF/A conformance level. */ + readonly tagged?: DocumentProps['tagged']; /** Ordered document blocks. */ readonly blocks: readonly BlockSpec[]; } diff --git a/src/spec/validate.ts b/src/spec/validate.ts new file mode 100644 index 0000000..28b242d --- /dev/null +++ b/src/spec/validate.ts @@ -0,0 +1,253 @@ +/** + * Shape validation for a {@link DocSpec}, without a JSON-Schema engine. + * + * This is the library's `--dry-run`: an agent (or a config loader) hands over + * JSON of unknown provenance, and gets back precise, path-anchored findings — + * *before* anything is compiled or rendered. Zero runtime dependencies, so + * validation stays available in edge and sandboxed runtimes where bundling a + * validator is not an option. + * + * The rules derive from `BLOCK_REGISTRY`, the same table that builds the JSON + * Schema, so the two can never disagree about tuple arity or payload types. + * + * ### The four dry-run tiers + * + * | Tier | Call | Cost | Catches | + * |---|---|---|---| + * | 1 | `validateSpec(spec)` | trivial | malformed JSON shape | + * | 2 | `compileSpec(spec)` | cheap | unmappable structure | + * | 3 | `lintSpec(spec)` | cheap | a11y & engine-constraint problems | + * | 4 | `inspectSpec(spec)` | ~a render | pagination & geometry | + * + * @packageDocumentation + */ + +import { BLOCK_REGISTRY, type BlockPayloadKind } from '../registry.js'; +import type { DocSpec } from './types.js'; + +/** Severity of a {@link SpecFinding}. */ +export type SpecFindingSeverity = 'error' | 'warning'; + +/** Stable validation codes. Branch on these, never on the message. */ +export const SpecCode = { + /** The value is not a plain object. */ + NOT_OBJECT: 'V_NOT_OBJECT', + /** `blocks` is missing or is not an array. */ + BLOCKS: 'V_BLOCKS', + /** A block is not a non-empty array. */ + BLOCK_SHAPE: 'V_BLOCK_SHAPE', + /** A block's first element is not a known tuple kind. */ + UNKNOWN_KIND: 'V_UNKNOWN_KIND', + /** A block tuple has too few or too many elements. */ + ARITY: 'V_ARITY', + /** A block's payload has the wrong JavaScript type. */ + PAYLOAD_TYPE: 'V_PAYLOAD_TYPE', + /** A block's trailing options element is not an object. */ + OPTS_TYPE: 'V_OPTS_TYPE', + /** An unrecognised top-level field (warning — forward compatibility). */ + UNKNOWN_FIELD: 'V_UNKNOWN_FIELD', +} as const; + +/** The value type of {@link SpecCode}. */ +export type SpecCodeValue = (typeof SpecCode)[keyof typeof SpecCode]; + +/** A single validation finding. */ +export interface SpecFinding { + /** Stable machine-readable code. */ + readonly code: SpecCodeValue; + /** Severity — only `'error'` findings clear `ok`. */ + readonly severity: SpecFindingSeverity; + /** JSON-path-ish location, e.g. `blocks[3][1]`. */ + readonly path: string; + /** Human-readable explanation. Not stable across releases. */ + readonly message: string; +} + +/** The result of {@link validateSpec}. */ +export interface SpecValidation { + /** `true` when there are no errors. */ + readonly ok: boolean; + /** Blocking problems. */ + readonly errors: readonly SpecFinding[]; + /** Non-blocking observations. */ + readonly warnings: readonly SpecFinding[]; +} + +/** Every recognised top-level `DocSpec` field. */ +const KNOWN_FIELDS: readonly string[] = [ + 'title', + 'footerText', + 'metadata', + 'fontEntries', + 'layout', + 'outline', + 'pageLabels', + 'watermark', + 'header', + 'footer', + 'attachments', + 'tagged', + 'blocks', +]; + +/** kind → descriptor, flattened from the registry once at module load. */ +const BY_KIND = new Map( + BLOCK_REGISTRY.flatMap((entry) => entry.kinds.map((kind) => [kind as string, entry] as const)), +); + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function payloadMatches(kind: BlockPayloadKind, value: unknown): boolean { + switch (kind) { + case 'string': + return typeof value === 'string'; + case 'number': + return typeof value === 'number' && Number.isFinite(value); + case 'array': + case 'blocks': + return Array.isArray(value); + case 'object': + return isPlainObject(value); + case 'none': + return false; + default: + return false; + } +} + +function describe(value: unknown): string { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + return typeof value; +} + +function validateBlock(block: unknown, path: string, out: SpecFinding[]): void { + if (!Array.isArray(block) || block.length === 0) { + out.push({ + code: SpecCode.BLOCK_SHAPE, + severity: 'error', + path, + message: `Expected a non-empty tuple, got ${describe(block)}.`, + }); + return; + } + + const kind: unknown = block[0]; + const descriptor = typeof kind === 'string' ? BY_KIND.get(kind) : undefined; + if (descriptor === undefined) { + out.push({ + code: SpecCode.UNKNOWN_KIND, + severity: 'error', + path: `${path}[0]`, + message: `Unknown block kind ${JSON.stringify(kind)}. Valid kinds: ${[...BY_KIND.keys()].join(', ')}.`, + }); + return; + } + + if (block.length < descriptor.minItems || block.length > descriptor.maxItems) { + const expected = + descriptor.minItems === descriptor.maxItems + ? String(descriptor.minItems) + : `${String(descriptor.minItems)}–${String(descriptor.maxItems)}`; + out.push({ + code: SpecCode.ARITY, + severity: 'error', + path, + message: `${descriptor.tuple} takes ${expected} elements, got ${String(block.length)}.`, + }); + return; + } + + // Payload — element [1], when the tuple carries one. + if (block.length > 1 && descriptor.payload !== 'none') { + const payload: unknown = block[1]; + if (!payloadMatches(descriptor.payload, payload)) { + out.push({ + code: SpecCode.PAYLOAD_TYPE, + severity: 'error', + path: `${path}[1]`, + message: `Expected ${descriptor.payload === 'blocks' ? 'an array of blocks' : `a ${descriptor.payload}`}, got ${describe(payload)}.`, + }); + } else if (descriptor.payload === 'blocks') { + (payload as readonly unknown[]).forEach((nested, i) => { + validateBlock(nested, `${path}[1][${String(i)}]`, out); + }); + } + } + + // Options — element [2], always an object when present. + if (block.length > 2 && !isPlainObject(block[2])) { + out.push({ + code: SpecCode.OPTS_TYPE, + severity: 'error', + path: `${path}[2]`, + message: `Expected an options object, got ${describe(block[2])}.`, + }); + } +} + +/** + * Validate the *shape* of an untrusted value against the `DocSpec` grammar. + * + * Structural only — it deliberately does not check semantics (a pie chart with + * two series is well-formed here; `lintSpec` is what catches that). + * + * Never throws: malformed input produces findings, not exceptions. + * + * @example + * ```ts + * const result = validateSpec(JSON.parse(untrusted)); + * if (!result.ok) { + * for (const e of result.errors) console.error(`${e.path}: ${e.message}`); + * } else { + * const bytes = renderSpecToBytes(untrusted as DocSpec); + * } + * ``` + */ +export function validateSpec(spec: unknown): SpecValidation { + const findings: SpecFinding[] = []; + + if (!isPlainObject(spec)) { + findings.push({ + code: SpecCode.NOT_OBJECT, + severity: 'error', + path: '', + message: `A DocSpec must be an object, got ${describe(spec)}.`, + }); + return { ok: false, errors: findings, warnings: [] }; + } + + for (const key of Object.keys(spec)) { + if (!KNOWN_FIELDS.includes(key)) { + findings.push({ + code: SpecCode.UNKNOWN_FIELD, + severity: 'warning', + path: key, + message: `Unknown top-level field "${key}"; it will be ignored.`, + }); + } + } + + const blocks: unknown = spec.blocks; + if (!Array.isArray(blocks)) { + findings.push({ + code: SpecCode.BLOCKS, + severity: 'error', + path: 'blocks', + message: `"blocks" is required and must be an array, got ${describe(blocks)}.`, + }); + } else { + blocks.forEach((block, i) => { + validateBlock(block, `blocks[${String(i)}]`, findings); + }); + } + + const errors = findings.filter((f) => f.severity === 'error'); + const warnings = findings.filter((f) => f.severity === 'warning'); + return { ok: errors.length === 0, errors, warnings }; +} + +// Re-exported so `validateSpec` users have the type at hand. +export type { DocSpec }; diff --git a/src/types.ts b/src/types.ts index a77751c..d9e6b46 100644 --- a/src/types.ts +++ b/src/types.ts @@ -33,6 +33,16 @@ import type { CellBorders, ListItem, StreamToFileResult, + ChartBlock, + ChartSeries, + ChartType, + PageTemplate, + WatermarkOptions, + WatermarkText, + WatermarkImage, + PdfAttachment, + PdfAttachmentRelationship, + EncryptionOptions, } from 'pdfnative'; export type { @@ -61,6 +71,16 @@ export type { CellBorders, ListItem, StreamToFileResult, + ChartBlock, + ChartSeries, + ChartType, + PageTemplate, + WatermarkOptions, + WatermarkText, + WatermarkImage, + PdfAttachment, + PdfAttachmentRelationship, + EncryptionOptions, }; /** Horizontal alignment shared by several blocks. */ diff --git a/src/version.ts b/src/version.ts index e778e2a..3d8dc14 100644 --- a/src/version.ts +++ b/src/version.ts @@ -9,4 +9,4 @@ */ /** Current package version (kept in sync with `package.json`). */ -export const version = '1.0.0'; +export const version = '1.1.0'; diff --git a/tests/agent.test.tsx b/tests/agent.test.tsx new file mode 100644 index 0000000..3638460 --- /dev/null +++ b/tests/agent.test.tsx @@ -0,0 +1,252 @@ +/** + * The agent surface: error taxonomy, capability manifest, pre-flight and + * structural validation. + * + * The manifest assertions are the load-bearing ones — they check that every + * name the manifest advertises is a real export of the public barrel, which is + * what stops the manifest from drifting into fiction. + */ +import { describe, expect, it } from 'vitest'; +import * as barrel from '../src/index.js'; +import { + Document, + ErrorCode, + PdfReactError, + PdfStructureError, + capabilityManifest, + compileDocument, + doctor, + toErrorEnvelope, + validateSpec, +} from '../src/index.js'; +import { PdfStructureError as PdfStructureErrorFromSerialize } from '../src/reconciler/serialize.js'; + +describe('error taxonomy', () => { + it('exposes every stable code', () => { + expect(Object.values(ErrorCode)).toEqual([ + 'E_STRUCTURE', + 'E_INPUT', + 'E_UNSUPPORTED', + 'E_ENV', + 'E_POLICY', + 'E_RUNTIME', + ]); + }); + + it('PdfStructureError carries E_STRUCTURE and stays an Error', () => { + const err = new PdfStructureError('bad tree'); + expect(err.code).toBe(ErrorCode.STRUCTURE); + expect(err).toBeInstanceOf(PdfReactError); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe('PdfStructureError'); + expect(err.message).toBe('bad tree'); + }); + + it('is the same class object on the legacy import path', () => { + // Moving the class to src/errors.ts must not break `instanceof` for + // anyone importing it from its original definition site. + expect(PdfStructureErrorFromSerialize).toBe(PdfStructureError); + expect(new PdfStructureErrorFromSerialize('x')).toBeInstanceOf(PdfStructureError); + }); + + it('serializes to the ecosystem error envelope', () => { + expect(new PdfStructureError('nope').toJSON()).toEqual({ + ok: false, + error: { code: 'E_STRUCTURE', message: 'nope' }, + }); + }); + + it('wraps arbitrary thrown values into the same envelope', () => { + expect(toErrorEnvelope(new Error('boom'))).toEqual({ + ok: false, + error: { code: 'E_RUNTIME', message: 'boom' }, + }); + expect(toErrorEnvelope('plain string')).toEqual({ + ok: false, + error: { code: 'E_RUNTIME', message: 'plain string' }, + }); + }); + + it('is what a real structural failure throws', () => { + try { + compileDocument(); + compileDocument('not a document'); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(PdfStructureError); + expect(toErrorEnvelope(err).error.code).toBe('E_STRUCTURE'); + } + }); +}); + +describe('capabilityManifest', () => { + const manifest = capabilityManifest(); + + it('identifies itself and pins the contract', () => { + expect(manifest.kind).toBe('capability-manifest'); + expect(manifest.name).toBe('pdfnative-react'); + expect(manifest.version).toBe(barrel.version); + expect(manifest.contract.authoringOnly).toBe(true); + expect(manifest.contract.layoutModel).toBe('block-flow'); + expect(manifest.contract.engine).toBe('^1.6.0'); + expect(manifest.contract.network).toBe('none'); + }); + + it('advertises only entry points that really exist in the barrel', () => { + for (const entry of manifest.entrypoints) { + expect(barrel, `entrypoint ${entry.name}`).toHaveProperty(entry.name); + expect(typeof (barrel as Record)[entry.name]).toBe('function'); + } + }); + + it('advertises only components that really exist in the barrel', () => { + for (const component of manifest.components) { + expect(barrel, `component ${component.name}`).toHaveProperty(component.name); + for (const alias of component.aliases ?? []) { + expect(barrel, `alias ${alias}`).toHaveProperty(alias); + } + } + }); + + it('describes the whole DocSpec grammar, chart included', () => { + const kinds = manifest.specBlocks.flatMap((b) => b.kinds); + expect(kinds).toContain('chart'); + expect(kinds).toContain('h1'); + expect(kinds).toContain('field'); + expect(manifest.specBlocks.every((b) => b.tuple.startsWith('['))).toBe(true); + }); + + it('carries the error codes and lint rules an agent branches on', () => { + expect(manifest.errorCodes).toEqual(Object.values(ErrorCode)); + expect(manifest.lintRules.map((r) => r.code)).toEqual([...barrel.LINT_RULE_CODES]); + }); + + it('points at its own versioned schema', () => { + expect(manifest.schemaId).toBe( + `https://pdfnative.dev/schema/react/${barrel.version}/manifest.schema.json`, + ); + }); + + it('is JSON-serializable', () => { + expect(() => JSON.stringify(manifest)).not.toThrow(); + }); +}); + +describe('doctor', () => { + const report = doctor(); + + it('never throws and reports a stable check set', () => { + expect(report.checks.map((c) => c.name)).toEqual([ + 'pdfnative-react', + 'node', + 'react', + 'pdfnative', + 'web-crypto', + 'fetch-api', + 'blob', + ]); + }); + + it('passes in this environment', () => { + const failures = report.checks.filter((c) => c.status === 'error'); + expect(failures, JSON.stringify(failures)).toEqual([]); + expect(report.ok).toBe(true); + }); + + it('detects the 1.6.0 engine through a capability probe', () => { + const engine = report.checks.find((c) => c.name === 'pdfnative'); + expect(engine?.status).toBe('ok'); + expect(engine?.value).toBe('>= 1.6.0'); + }); + + it('reports the installed package version', () => { + expect(report.checks[0].value).toBe(barrel.version); + }); + + it('gives every check a non-empty detail', () => { + for (const c of report.checks) expect(c.detail.length).toBeGreaterThan(0); + }); +}); + +describe('validateSpec', () => { + it('accepts a well-formed spec', () => { + const result = validateSpec({ + title: 'Invoice', + blocks: [ + ['h1', 'Invoice'], + ['p', 'Thanks.', { align: 'right' }], + ['chart', { chartType: 'bar', series: [{ label: 'A', values: [1] }] }], + ], + }); + expect(result).toEqual({ ok: true, errors: [], warnings: [] }); + }); + + it('rejects a non-object', () => { + const result = validateSpec('nope'); + expect(result.ok).toBe(false); + expect(result.errors[0].code).toBe('V_NOT_OBJECT'); + }); + + it('requires blocks to be an array', () => { + const result = validateSpec({ title: 'x' }); + expect(result.errors.map((e) => e.code)).toEqual(['V_BLOCKS']); + expect(result.errors[0].path).toBe('blocks'); + }); + + it('rejects an unknown block kind and lists the valid ones', () => { + const result = validateSpec({ blocks: [['h4', 'nope']] }); + expect(result.errors[0].code).toBe('V_UNKNOWN_KIND'); + expect(result.errors[0].path).toBe('blocks[0][0]'); + expect(result.errors[0].message).toContain('chart'); + }); + + it('enforces tuple arity from the registry', () => { + const result = validateSpec({ blocks: [['link', 'text']] }); + expect(result.errors[0].code).toBe('V_ARITY'); + expect(result.errors[0].message).toContain('3 elements'); + }); + + it('enforces payload types', () => { + const result = validateSpec({ blocks: [['p', 42]] }); + expect(result.errors[0].code).toBe('V_PAYLOAD_TYPE'); + expect(result.errors[0].path).toBe('blocks[0][1]'); + expect(result.errors[0].message).toContain('a string'); + }); + + it('rejects a non-object options element', () => { + const result = validateSpec({ blocks: [['p', 'text', 'oops']] }); + expect(result.errors[0].code).toBe('V_OPTS_TYPE'); + expect(result.errors[0].path).toBe('blocks[0][2]'); + }); + + it('recurses into page groups with an accurate path', () => { + const result = validateSpec({ blocks: [['page', [['h1', 'ok'], ['nope', 'x']]]] }); + expect(result.errors[0].code).toBe('V_UNKNOWN_KIND'); + expect(result.errors[0].path).toBe('blocks[0][1][1][0]'); + }); + + it('warns — but does not fail — on an unknown top-level field', () => { + const result = validateSpec({ blocks: [], somethingNew: true }); + expect(result.ok).toBe(true); + expect(result.warnings[0].code).toBe('V_UNKNOWN_FIELD'); + expect(result.warnings[0].path).toBe('somethingNew'); + }); + + it('accepts every layout-sugar field as known', () => { + const result = validateSpec({ + watermark: 'DRAFT', + header: { center: 'x' }, + footer: { right: 'y' }, + attachments: [], + tagged: 'pdfa2b', + blocks: [], + }); + expect(result.warnings).toEqual([]); + }); + + it('never throws on hostile input', () => { + for (const input of [null, undefined, 0, [], { blocks: [null, 1, [], ['br', 'x']] }]) { + expect(() => validateSpec(input)).not.toThrow(); + } + }); +}); diff --git a/tests/chart.test.tsx b/tests/chart.test.tsx new file mode 100644 index 0000000..2fb5484 --- /dev/null +++ b/tests/chart.test.tsx @@ -0,0 +1,184 @@ +/** + * `` — the one authoring capability pdfnative 1.6.0 unlocks. + * + * Covers the JSX → `ChartBlock` mapping, DocSpec parity (golden rule 6), every + * chart type, and a real end-to-end render. + */ +import { describe, expect, it } from 'vitest'; +import { + Chart, + Document, + Heading, + compileDocument, + compileSpec, + renderSpecToBytes, + renderToBytes, +} from '../src/index.js'; +import type { ChartSeries, ChartType, DocSpec } from '../src/index.js'; + +function decode(bytes: Uint8Array): string { + return new TextDecoder('latin1').decode(bytes); +} + +const REVENUE: readonly ChartSeries[] = [ + { label: 'Revenue', values: [12_000, 18_500, 24_100, 31_000] }, +]; +const QUARTERS = ['Q1', 'Q2', 'Q3', 'Q4']; + +describe(' serialization', () => { + it('maps props onto a ChartBlock one-for-one', () => { + const model = compileDocument( + + + , + ); + + expect(model.blocks).toEqual([ + { + type: 'chart', + chartType: 'bar', + series: REVENUE, + categories: QUARTERS, + title: 'Revenue per quarter', + width: 420, + height: 220, + legend: 'bottom', + axis: { yMin: 0, ticks: 5, grid: true }, + markers: true, + colors: ['#4e79a7'], + align: 'center', + altText: 'Revenue rising from 12k in Q1 to 31k in Q4', + }, + ]); + }); + + it('omits absent optional props rather than emitting undefined', () => { + const model = compileDocument( + + + , + ); + + expect(model.blocks).toEqual([{ type: 'chart', chartType: 'line', series: REVENUE }]); + expect(Object.keys(model.blocks[0])).toEqual(['type', 'chartType', 'series']); + }); + + it.each(['bar', 'barH', 'line', 'pie', 'donut'])( + 'supports chartType "%s"', + (chartType) => { + const model = compileDocument( + + + , + ); + expect(model.blocks[0]).toMatchObject({ type: 'chart', chartType }); + }, + ); +}); + +describe('chart DocSpec parity', () => { + it("['chart', body] compiles to the same model as ", () => { + const spec: DocSpec = { + title: 'Q4 report', + blocks: [ + ['h1', 'Q4 report'], + [ + 'chart', + { + chartType: 'bar', + series: REVENUE, + categories: QUARTERS, + title: 'Revenue', + altText: 'Revenue per quarter', + }, + ], + ], + }; + + const jsx = ( + + Q4 report + + + ); + + expect(compileSpec(spec)).toEqual(compileDocument(jsx)); + }); + + it('accepts a multi-series chart with a palette override', () => { + const model = compileSpec({ + blocks: [ + [ + 'chart', + { + chartType: 'line', + series: [ + { label: '2025', values: [1, 2, 3] }, + { label: '2026', values: [2, 4, 6] }, + ], + categories: ['Jan', 'Feb', 'Mar'], + colors: ['#4e79a7', '#f28e2b'], + markers: true, + }, + ], + ], + }); + + expect(model.blocks[0]).toMatchObject({ + type: 'chart', + chartType: 'line', + markers: true, + colors: ['#4e79a7', '#f28e2b'], + }); + }); +}); + +describe('chart rendering', () => { + it('renders a real PDF from JSX', () => { + const pdf = decode( + renderToBytes( + + + , + ), + ); + expect(pdf.startsWith('%PDF-')).toBe(true); + expect(pdf.trimEnd().endsWith('%%EOF')).toBe(true); + }); + + it('renders a real PDF from a DocSpec', () => { + const pdf = decode( + renderSpecToBytes({ + blocks: [ + ['chart', { chartType: 'donut', series: REVENUE, categories: QUARTERS }], + ], + }), + ); + expect(pdf.startsWith('%PDF-')).toBe(true); + expect(pdf.trimEnd().endsWith('%%EOF')).toBe(true); + }); +}); diff --git a/tests/governance.test.ts b/tests/governance.test.ts index c75b64a..9b211e8 100644 --- a/tests/governance.test.ts +++ b/tests/governance.test.ts @@ -12,6 +12,7 @@ import { execFileSync } from 'node:child_process'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { agentRulesText, aiGovernancePolicy, validateIssueDraft } from '../src/index.js'; const ROOT = process.cwd(); const CLI = join(ROOT, 'scripts', 'verify-issue.mjs'); @@ -98,3 +99,101 @@ describe('repo governance artifacts', () => { expect(draftsReadme).toMatch(/staging area/i); }); }); + +describe('governance as a runtime capability', () => { + it('validateIssueDraft agrees with the CLI on a good draft', () => { + expect(validateIssueDraft(GOOD_DRAFT)).toEqual({ ok: true, errors: [], warnings: [] }); + }); + + it('flags a runtime-dependency proposal with E_POLICY', () => { + const result = validateIssueDraft(DEPENDENCY_DRAFT); + expect(result.ok).toBe(false); + expect(result.code).toBe('E_POLICY'); + expect(result.errors[0]).toMatch(/minimal-dependency policy/); + }); + + it('flags a missing reproduction block', () => { + const result = validateIssueDraft(NO_REPRO_DRAFT); + expect(result.ok).toBe(false); + expect(result.errors.some((e) => /reproduction code block/.test(e))).toBe(true); + }); + + it('surfaces missing recommended fields as warnings only', () => { + const result = validateIssueDraft('# Title\n\n```\nrepro\n```\n'); + expect(result.ok).toBe(true); + expect(result.warnings.length).toBeGreaterThan(0); + }); + + it('exposes the policy, matching .github/ai-governance.json', async () => { + const policy = aiGovernancePolicy(); + const raw = await readFile(join(ROOT, '.github', 'ai-governance.json'), 'utf8'); + const file = JSON.parse(raw) as { + policy: Record; + human_in_the_loop: Record; + pre_issue_checklist: string[]; + compliance_report: { required_fields: string[] }; + applies_to: string[]; + }; + + expect(policy.policy.automaticIssueReporting).toBe(file.policy['automatic_issue_reporting']); + expect(policy.policy.runtimeDependenciesAllowed).toBe( + file.policy['runtime_dependencies_allowed'], + ); + expect(policy.policy.humanInTheLoopMandatory).toBe( + file.policy['human_in_the_loop_mandatory'], + ); + expect(policy.policy.autonomousGithubWritesAllowed).toBe( + file.policy['autonomous_github_writes_allowed'], + ); + expect(policy.policy.outboundNetworkAllowed).toBe(file.policy['outbound_network_allowed']); + expect(policy.policy.telemetryAllowed).toBe(file.policy['telemetry_allowed']); + expect(policy.policy.requiredIssueFields).toEqual(file.policy['required_issue_fields']); + expect(policy.humanInTheLoop.roleOfAgent).toBe(file.human_in_the_loop['role_of_agent']); + expect(policy.humanInTheLoop.draftLocation).toBe(file.human_in_the_loop['draft_location']); + expect(policy.preIssueChecklist).toEqual(file.pre_issue_checklist); + expect(policy.complianceReportFields).toEqual(file.compliance_report.required_fields); + expect(policy.appliesTo).toEqual(file.applies_to); + }); + + it('ships the agent protocol text', () => { + const text = agentRulesText(); + expect(text).toMatch(/DRAFTSMAN/); + expect(text).toMatch(/CRITICAL ETHICAL GATE/); + expect(text).toMatch(/NO new runtime dependency/); + }); +}); + +/** + * `scripts/verify-issue.mjs` must stay zero-dependency and runnable with no + * build step (CI and the black-box tests above invoke it directly), so its + * pattern tables are necessarily duplicated in `src/governance.ts`. This test + * is what stops that duplication from drifting: it parses the script's source + * and compares both tables literally. + */ +describe('verifier ↔ library parity', () => { + it('uses byte-identical DEPENDENCY_PATTERNS and REQUIRED_FIELDS', async () => { + const [script, library] = await Promise.all([ + readFile(CLI, 'utf8'), + readFile(join(ROOT, 'src', 'governance.ts'), 'utf8'), + ]); + + const table = (source: string, name: string): string => { + const start = source.indexOf(`const ${name}`); + expect(start, `${name} not found`).toBeGreaterThan(-1); + // Anchor on `= [` so a TypeScript type annotation such as + // `: readonly RegExp[] =` cannot be mistaken for the array literal. + const open = source.indexOf('= [', start) + 2; + const close = source.indexOf('];', open); + expect(close, `${name} literal not closed`).toBeGreaterThan(open); + return source + .slice(open + 1, close) + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .join('\n'); + }; + + expect(table(library, 'DEPENDENCY_PATTERNS')).toBe(table(script, 'DEPENDENCY_PATTERNS')); + expect(table(library, 'REQUIRED_FIELDS')).toBe(table(script, 'REQUIRED_FIELDS')); + }); +}); diff --git a/tests/layout-sugar.test.tsx b/tests/layout-sugar.test.tsx new file mode 100644 index 0000000..fdca0ce --- /dev/null +++ b/tests/layout-sugar.test.tsx @@ -0,0 +1,149 @@ +/** + * `` layout sugar: watermark / header / footer / attachments / tagged. + * + * These props fold into `layout`, which makes the *absence* case the one that + * really matters: a document that uses none of them must still serialize with + * `layout: undefined`, or every existing document silently changes bytes. + */ +import { describe, expect, it } from 'vitest'; +import { + Document, + Paragraph, + compileDocument, + compileSpec, + renderToBytes, +} from '../src/index.js'; +import type { DocSpec, PageTemplate, PdfAttachment } from '../src/index.js'; + +const FOOTER: PageTemplate = { + left: 'Confidential', + center: '{title}', + right: 'Page {page} of {pages}', +}; + +const ATTACHMENT: PdfAttachment = { + filename: 'invoice.xml', + data: new TextEncoder().encode(''), + mimeType: 'application/xml', + relationship: 'Data', +}; + +describe('the layout === undefined invariant', () => { + it('leaves layout undefined when no sugar and no layout prop are used', () => { + const model = compileDocument( + + Nothing fancy. + , + ); + expect(model.layout).toBeUndefined(); + expect('layout' in model).toBe(false); + }); + + it('still leaves layout undefined for a spec that uses no sugar', () => { + expect(compileSpec({ blocks: [['p', 'Nothing fancy.']] }).layout).toBeUndefined(); + }); + + it('passes an explicit layout through untouched when no sugar is used', () => { + const model = compileDocument( + + x + , + ); + expect(model.layout).toEqual({ pageWidth: 595 }); + }); +}); + +describe('layout sugar folding', () => { + it('folds every sugar prop into layout under its engine key', () => { + const model = compileDocument( + + x + , + ); + + expect(model.layout).toEqual({ + watermark: { text: { text: 'DRAFT', opacity: 0.2 }, position: 'foreground' }, + headerTemplate: { center: 'Acme' }, + footerTemplate: FOOTER, + attachments: [ATTACHMENT], + tagged: 'pdfa2b', + }); + }); + + it('expands the watermark string shorthand', () => { + const model = compileDocument( + + x + , + ); + expect(model.layout).toEqual({ watermark: { text: { text: 'CONFIDENTIAL' } } }); + }); + + it('lets an explicit layout win over the sugar', () => { + const model = compileDocument( + + x + , + ); + expect(model.layout).toEqual({ tagged: true, pageWidth: 595 }); + }); + + it('merges sugar and layout when they touch different keys', () => { + const model = compileDocument( + + x + , + ); + expect(model.layout).toEqual({ + watermark: { text: { text: 'DRAFT' } }, + pageWidth: 595, + }); + }); + + it('renders a watermarked, tagged document end to end', () => { + const pdf = new TextDecoder('latin1').decode( + renderToBytes( + + Body text. + , + ), + ); + expect(pdf.startsWith('%PDF-')).toBe(true); + expect(pdf.trimEnd().endsWith('%%EOF')).toBe(true); + }); +}); + +describe('layout sugar DocSpec parity', () => { + it('produces the same model from a spec as from JSX', () => { + const spec: DocSpec = { + title: 'Report', + watermark: 'DRAFT', + header: { center: 'Acme' }, + footer: FOOTER, + attachments: [ATTACHMENT], + tagged: 'pdfa3b', + blocks: [['p', 'Body text.']], + }; + + const jsx = ( + + Body text. + + ); + + expect(compileSpec(spec)).toEqual(compileDocument(jsx)); + }); +}); diff --git a/tests/lint.test.tsx b/tests/lint.test.tsx new file mode 100644 index 0000000..31d04a3 --- /dev/null +++ b/tests/lint.test.tsx @@ -0,0 +1,333 @@ +/** + * `lintDocument` / `lintSpec` — one assertion per rule. + * + * The linter runs on the compiled `DocumentParams`, so JSX and DocSpec share + * one implementation; the parity test at the bottom is what holds that claim. + */ +import { describe, expect, it } from 'vitest'; +import { + Chart, + Document, + FormField, + Heading, + Image, + Link, + Paragraph, + Table, + lintDocument, + lintSpec, + LINT_RULE_CODES, +} from '../src/index.js'; +import type { ChartSeries, LintReport, LintRuleCode } from '../src/index.js'; + +const PIXEL = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); +const SERIES: readonly ChartSeries[] = [{ label: 'Revenue', values: [1, 2, 3] }]; + +function codes(report: LintReport): LintRuleCode[] { + return report.findings.map((f) => f.code); +} + +describe('a clean document', () => { + it('produces no findings at all', () => { + const report = lintDocument( + + Title + Section + Body text. + +
+ Read the docs + + + , + ); + + expect(report.findings).toEqual([]); + expect(report.ok).toBe(true); + expect(report.counts).toEqual({ error: 0, warning: 0, info: 0 }); + }); +}); + +describe('document-level rules', () => { + it('L_EMPTY_DOCUMENT — flags a document with no blocks', () => { + const report = lintDocument(); + expect(codes(report)).toContain('L_EMPTY_DOCUMENT'); + expect(report.ok).toBe(false); + }); + + it('L_TAGGED_NO_FONTS — PDF/A without embedded fonts is an error', () => { + const report = lintDocument( + + x + , + ); + expect(codes(report)).toContain('L_TAGGED_NO_FONTS'); + expect(report.ok).toBe(false); + }); + + it('L_TAGGED_NO_FONTS — is satisfied by fontEntries', () => { + const report = lintDocument( + + x + , + ); + expect(codes(report)).not.toContain('L_TAGGED_NO_FONTS'); + }); + + it('L_TAGGED_ENCRYPTED — PDF/A and encryption cannot be combined', () => { + const report = lintDocument( + + x + , + ); + expect(codes(report)).toContain('L_TAGGED_ENCRYPTED'); + }); + + it('L_ATTACHMENTS_NEED_PDFA3 — attachments outside PDF/A-3 are an error', () => { + const attachment = { + filename: 'data.xml', + data: new Uint8Array([1]), + mimeType: 'application/xml', + }; + const report = lintDocument( + + x + , + ); + expect(codes(report)).toContain('L_ATTACHMENTS_NEED_PDFA3'); + expect(report.ok).toBe(false); + }); + + it('L_ATTACHMENTS_NEED_PDFA3 — is satisfied by tagged="pdfa3b"', () => { + const attachment = { + filename: 'data.xml', + data: new Uint8Array([1]), + mimeType: 'application/xml', + }; + const report = lintDocument( + + x + , + ); + expect(codes(report)).not.toContain('L_ATTACHMENTS_NEED_PDFA3'); + }); + + it('L_MAX_BLOCKS — warns near the maxBlocks ceiling', () => { + const report = lintSpec({ + layout: { maxBlocks: 10 }, + blocks: Array.from({ length: 10 }, (_, i) => ['p', `line ${String(i)}`] as const), + }); + expect(codes(report)).toContain('L_MAX_BLOCKS'); + }); +}); + +describe('accessibility rules', () => { + it('L_IMAGE_ALT — flags an image with no alt text', () => { + const report = lintDocument( + + + , + ); + expect(codes(report)).toContain('L_IMAGE_ALT'); + expect(report.findings[0].blockIndex).toBe(0); + expect(report.findings[0].hint).toBeDefined(); + }); + + it('L_TABLE_HEADERS — flags a table with no header row', () => { + const report = lintDocument( + +
+ , + ); + expect(codes(report)).toContain('L_TABLE_HEADERS'); + }); + + it('L_HEADING_HIERARCHY — flags a skipped heading level', () => { + const report = lintSpec({ blocks: [['h1', 'Title'], ['h3', 'Too deep']] }); + expect(codes(report)).toContain('L_HEADING_HIERARCHY'); + }); + + it('L_HEADING_HIERARCHY — accepts descending back to a shallower level', () => { + const report = lintSpec({ + blocks: [['h1', 'A'], ['h2', 'B'], ['h3', 'C'], ['h1', 'D'], ['h2', 'E']], + }); + expect(codes(report)).not.toContain('L_HEADING_HIERARCHY'); + }); + + it('L_FIELD_LABEL — flags an unlabelled form field', () => { + const report = lintDocument( + + + , + ); + expect(codes(report)).toContain('L_FIELD_LABEL'); + }); + + it('L_LINK_TEXT — flags a link whose text is the raw URL', () => { + const report = lintSpec({ + blocks: [['link', 'https://pdfnative.dev', { url: 'https://pdfnative.dev' }]], + }); + expect(codes(report)).toContain('L_LINK_TEXT'); + }); + + it('L_LINK_TEXT — flags a link with empty text', () => { + const report = lintSpec({ blocks: [['link', '', { url: 'https://pdfnative.dev' }]] }); + expect(codes(report)).toContain('L_LINK_TEXT'); + }); +}); + +describe('chart rules — pre-empting engine failures', () => { + it('L_CHART_ALT — is informational, so it does not clear ok', () => { + const report = lintDocument( + + + , + ); + expect(codes(report)).toContain('L_CHART_ALT'); + expect(report.ok).toBe(true); + expect(report.counts.info).toBe(1); + }); + + it('L_CHART_SERIES — a pie chart must have exactly one series', () => { + const report = lintSpec({ + blocks: [ + [ + 'chart', + { + chartType: 'pie', + series: [ + { label: 'A', values: [1] }, + { label: 'B', values: [2] }, + ], + altText: 'x', + }, + ], + ], + }); + expect(codes(report)).toContain('L_CHART_SERIES'); + expect(report.ok).toBe(false); + }); + + it('L_CHART_CATEGORIES — series length must match the categories', () => { + const report = lintSpec({ + blocks: [ + [ + 'chart', + { + chartType: 'bar', + series: [{ label: 'A', values: [1, 2] }], + categories: ['Q1', 'Q2', 'Q3'], + altText: 'x', + }, + ], + ], + }); + expect(codes(report)).toContain('L_CHART_CATEGORIES'); + }); + + it('L_CHART_VALUES — rejects non-finite values', () => { + const report = lintSpec({ + blocks: [ + [ + 'chart', + { + chartType: 'bar', + series: [{ label: 'A', values: [1, Number.NaN] }], + altText: 'x', + }, + ], + ], + }); + expect(codes(report)).toContain('L_CHART_VALUES'); + }); + + it('L_CHART_VALUES — rejects negative values in a donut', () => { + const report = lintSpec({ + blocks: [ + [ + 'chart', + { + chartType: 'donut', + series: [{ label: 'A', values: [1, -2] }], + altText: 'x', + }, + ], + ], + }); + expect(codes(report)).toContain('L_CHART_VALUES'); + }); + + it('L_CHART_POINTS — rejects a chart past the 10 000-point ceiling', () => { + const report = lintSpec({ + blocks: [ + [ + 'chart', + { + chartType: 'line', + series: [{ label: 'A', values: Array.from({ length: 10_001 }, () => 1) }], + altText: 'x', + }, + ], + ], + }); + expect(codes(report)).toContain('L_CHART_POINTS'); + }); +}); + +describe('geometry rules', () => { + it('L_OVERFLOW — is off unless explicitly requested', () => { + const spec = { + blocks: [['chart', { chartType: 'bar', series: SERIES, height: 4000, altText: 'x' }]], + } as const; + expect(codes(lintSpec(spec))).not.toContain('L_OVERFLOW'); + }); + + it('L_OVERFLOW — flags a block taller than the content box', () => { + const report = lintSpec( + { + blocks: [ + ['chart', { chartType: 'bar', series: SERIES, height: 4000, altText: 'x' }], + ], + }, + { overflow: true }, + ); + expect(codes(report)).toContain('L_OVERFLOW'); + }); +}); + +describe('report shape', () => { + it('filters to the requested rules only', () => { + const report = lintDocument( + + + + , + { rules: ['L_IMAGE_ALT'] }, + ); + expect(codes(report)).toEqual(['L_IMAGE_ALT']); + }); + + it('only reports codes that exist in the registry', () => { + const report = lintDocument( + + + , + ); + for (const f of report.findings) expect(LINT_RULE_CODES).toContain(f.code); + }); + + it('lintSpec and lintDocument agree on the same document', () => { + const viaSpec = lintSpec({ blocks: [['h1', 'A'], ['h3', 'B']] }); + const viaJsx = lintDocument( + + A + B + , + ); + expect(viaSpec).toEqual(viaJsx); + }); +}); diff --git a/tests/registry.test.ts b/tests/registry.test.ts new file mode 100644 index 0000000..d95881f --- /dev/null +++ b/tests/registry.test.ts @@ -0,0 +1,167 @@ +/** + * Locks the single-source-of-truth registries. + * + * `src/registry.ts` feeds the JSON Schema, `validateSpec`, and the capability + * manifest. If any of those ever restates the tables instead of deriving from + * them, the assertions here go stale silently — so this file pins the exact, + * ordered contents, and cross-checks each derived artifact against the source. + * + * The companion guarantee is a *compile-time* one: `src/registry.ts` ends with + * `Assert>` types, so removing an entry fails `npm run typecheck` + * before these tests even run. Both halves matter — see the "acceptance + * criteria" section of the v1.1.0 release notes. + */ +import { describe, expect, it } from 'vitest'; +import { + BLOCK_REGISTRY, + COMPONENT_REGISTRY, + LINT_RULES, + LINT_RULE_CODES, +} from '../src/registry.js'; +import { docSpecSchema } from '../src/index.js'; + +describe('BLOCK_REGISTRY', () => { + it('lists every DocSpec tuple kind, in schema order', () => { + expect(BLOCK_REGISTRY.map((b) => b.id)).toEqual([ + 'heading', + 'paragraph', + 'list', + 'table', + 'image', + 'link', + 'spacer', + 'pageBreak', + 'page', + 'toc', + 'barcode', + 'svg', + 'chart', + 'field', + ]); + }); + + it('covers exactly the 21 tuple kinds of the grammar', () => { + expect(BLOCK_REGISTRY.flatMap((b) => [...b.kinds])).toEqual([ + 'h1', + 'h2', + 'h3', + 'p', + 'ul', + 'ol', + 'table', + 'img', + 'link', + 'sp', + 'br', + 'page', + 'toc', + 'qr', + 'code128', + 'ean13', + 'pdf417', + 'datamatrix', + 'svg', + 'chart', + 'field', + ]); + }); + + it('declares a coherent arity for every entry', () => { + for (const entry of BLOCK_REGISTRY) { + expect(entry.minItems).toBeGreaterThanOrEqual(1); + expect(entry.maxItems).toBeGreaterThanOrEqual(entry.minItems); + expect(entry.maxItems).toBeLessThanOrEqual(3); + expect(entry.summary.length).toBeGreaterThan(0); + expect(entry.tuple).toContain('['); + } + }); + + it("is what the JSON Schema's $defs.block is built from", () => { + const defs = docSpecSchema()['$defs'] as { block: { oneOf: Record[] } }; + expect(defs.block.oneOf).toHaveLength(BLOCK_REGISTRY.length); + + // Arity and description come from the registry, not from the builders. + defs.block.oneOf.forEach((branch, i) => { + const entry = BLOCK_REGISTRY[i]; + expect(branch['minItems']).toBe(entry.minItems); + expect(branch['maxItems']).toBe(entry.maxItems); + expect(branch['description']).toBe(entry.summary); + }); + }); + + it('includes the chart block introduced in 1.1.0', () => { + const chart = BLOCK_REGISTRY.find((b) => b.id === 'chart'); + expect(chart).toBeDefined(); + expect(chart?.kinds).toEqual(['chart']); + expect(chart?.component).toBe('Chart'); + expect(chart?.payload).toBe('object'); + }); +}); + +describe('COMPONENT_REGISTRY', () => { + it('lists every public component, in barrel order', () => { + expect(COMPONENT_REGISTRY.map((c) => c.name)).toEqual([ + 'Document', + 'Page', + 'Section', + 'Heading', + 'Paragraph', + 'List', + 'Item', + 'Table', + 'Row', + 'Cell', + 'Image', + 'Link', + 'Spacer', + 'PageBreak', + 'TableOfContents', + 'Barcode', + 'Svg', + 'Chart', + 'FormField', + ]); + }); + + it('marks
as the one composite with no host tag', () => { + const composites = COMPONENT_REGISTRY.filter((c) => c.tag === null); + expect(composites.map((c) => c.name)).toEqual(['Section']); + }); + + it('maps every other component onto a distinct host tag', () => { + const tags = COMPONENT_REGISTRY.map((c) => c.tag).filter((t) => t !== null); + expect(new Set(tags).size).toBe(tags.length); + expect(tags).toContain('chart'); + }); +}); + +describe('LINT_RULES', () => { + it('exposes the full rule set, in registry order', () => { + expect(LINT_RULE_CODES).toEqual([ + 'L_EMPTY_DOCUMENT', + 'L_IMAGE_ALT', + 'L_CHART_ALT', + 'L_TABLE_HEADERS', + 'L_HEADING_HIERARCHY', + 'L_FIELD_LABEL', + 'L_LINK_TEXT', + 'L_TAGGED_NO_FONTS', + 'L_TAGGED_ENCRYPTED', + 'L_ATTACHMENTS_NEED_PDFA3', + 'L_MAX_BLOCKS', + 'L_CHART_SERIES', + 'L_CHART_CATEGORIES', + 'L_CHART_VALUES', + 'L_CHART_POINTS', + 'L_OVERFLOW', + ]); + }); + + it('gives every rule a severity and a description', () => { + for (const code of LINT_RULE_CODES) { + const rule = LINT_RULES[code]; + expect(['error', 'warning', 'info']).toContain(rule.severity); + expect(rule.description.length).toBeGreaterThan(10); + } + }); +}); diff --git a/tests/response.test.tsx b/tests/response.test.tsx new file mode 100644 index 0000000..b20c882 --- /dev/null +++ b/tests/response.test.tsx @@ -0,0 +1,109 @@ +/** + * `renderToResponse` — the server entry point. + * + * Asserts the HTTP contract (headers, status, disposition), that the streamed + * body really is a complete PDF, and that the buffered mode adds a + * `Content-Length`. + */ +import { describe, expect, it } from 'vitest'; +import { + Document, + Heading, + Paragraph, + renderSpecToResponse, + renderToResponse, +} from '../src/index.js'; + +const DOC = ( + + Invoice #1024 + Thank you for your business. + +); + +async function body(response: Response): Promise { + return new TextDecoder('latin1').decode(new Uint8Array(await response.arrayBuffer())); +} + +describe('renderToResponse', () => { + it('streams a complete PDF with the right content type', async () => { + const response = await renderToResponse(DOC); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('application/pdf'); + expect(response.body).not.toBeNull(); + + const pdf = await body(response); + expect(pdf.startsWith('%PDF-')).toBe(true); + expect(pdf.trimEnd().endsWith('%%EOF')).toBe(true); + }); + + it('defaults to an inline disposition named document.pdf', async () => { + const response = await renderToResponse(DOC); + expect(response.headers.get('content-disposition')).toBe('inline; filename="document.pdf"'); + }); + + it('honours fileName and an attachment disposition', async () => { + const response = await renderToResponse(DOC, { + fileName: 'invoice-1024.pdf', + disposition: 'attachment', + }); + expect(response.headers.get('content-disposition')).toBe( + 'attachment; filename="invoice-1024.pdf"', + ); + }); + + it('adds filename* for a non-ASCII filename', async () => { + const response = await renderToResponse(DOC, { fileName: 'facture-écrite.pdf' }); + const disposition = response.headers.get('content-disposition') ?? ''; + expect(disposition).toContain('filename="facture-_crite.pdf"'); + expect(disposition).toContain("filename*=UTF-8''facture-%C3%A9crite.pdf"); + }); + + it('sets content-length only in buffered mode', async () => { + const streamed = await renderToResponse(DOC); + expect(streamed.headers.get('content-length')).toBeNull(); + + const buffered = await renderToResponse(DOC, { buffered: true }); + const length = Number(buffered.headers.get('content-length')); + expect(length).toBeGreaterThan(0); + + const pdf = await body(buffered); + expect(pdf.startsWith('%PDF-')).toBe(true); + expect(pdf.length).toBe(length); + }); + + it('produces the same bytes streamed and buffered', async () => { + const [streamed, buffered] = await Promise.all([ + renderToResponse(DOC).then(body), + renderToResponse(DOC, { buffered: true }).then(body), + ]); + expect(streamed).toBe(buffered); + }); + + it('accepts a custom status and extra headers, letting callers override defaults', async () => { + const response = await renderToResponse(DOC, { + status: 201, + headers: { 'cache-control': 'no-store', 'content-type': 'application/octet-stream' }, + }); + expect(response.status).toBe(201); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(response.headers.get('content-type')).toBe('application/octet-stream'); + }); +}); + +describe('renderSpecToResponse', () => { + it('is the DocSpec twin of renderToResponse', async () => { + const response = await renderSpecToResponse( + { title: 'Invoice', blocks: [['h1', 'Invoice #1024']] }, + { fileName: 'spec.pdf', disposition: 'attachment' }, + ); + + expect(response.headers.get('content-disposition')).toBe( + 'attachment; filename="spec.pdf"', + ); + const pdf = await body(response); + expect(pdf.startsWith('%PDF-')).toBe(true); + expect(pdf.trimEnd().endsWith('%%EOF')).toBe(true); + }); +}); diff --git a/tests/schema.test.ts b/tests/schema.test.ts new file mode 100644 index 0000000..c09cb33 --- /dev/null +++ b/tests/schema.test.ts @@ -0,0 +1,143 @@ +/** + * The multi-subject JSON Schema surface. + * + * Pins the subject list (a contract an agent enumerates), the versioned `$id` + * format, and the backward-compatible `docSpecSchema()` alias. + */ +import { describe, expect, it } from 'vitest'; +import { + LINT_RULE_CODES, + PdfReactError, + SCHEMA_SUBJECTS, + capabilityManifest, + docSpecSchema, + docSpecSchemaId, + schema, + schemaId, + version, +} from '../src/index.js'; +import type { SchemaSubject } from '../src/index.js'; + +const DRAFT = 'https://json-schema.org/draft/2020-12/schema'; + +describe('subject list', () => { + it('is the exact, ordered contract', () => { + expect(SCHEMA_SUBJECTS).toEqual([ + 'doc-spec', + 'render-options', + 'lint-report', + 'spec-validation', + 'doctor', + 'manifest', + 'list', + ]); + }); + + it('is self-describing via schema("list")', () => { + const list = schema('list'); + expect(list['examples']).toEqual([{ subjects: [...SCHEMA_SUBJECTS] }]); + }); + + it('is the same list the capability manifest advertises', () => { + expect(capabilityManifest().schemaSubjects).toEqual([...SCHEMA_SUBJECTS]); + }); +}); + +describe('every subject', () => { + it.each([...SCHEMA_SUBJECTS])('%s is a well-formed Draft 2020-12 schema', (subject) => { + const doc = schema(subject); + expect(doc['$schema']).toBe(DRAFT); + expect(typeof doc['title']).toBe('string'); + expect(typeof doc['description']).toBe('string'); + expect(doc['$id']).toBe( + `https://pdfnative.dev/schema/react/${version}/${subject}.schema.json`, + ); + }); + + it('embeds the current package version in every $id', () => { + for (const subject of SCHEMA_SUBJECTS) { + expect(schemaId(subject)).toMatch( + /^https:\/\/pdfnative\.dev\/schema\/react\/\d+\.\d+\.\d+\/[a-z-]+\.schema\.json$/, + ); + } + }); + + it('defaults to doc-spec', () => { + expect(schema()).toEqual(schema('doc-spec')); + expect(schemaId()).toBe(schemaId('doc-spec')); + }); + + it('rejects an unknown subject with E_INPUT', () => { + try { + schema('nope' as SchemaSubject); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(PdfReactError); + expect((err as PdfReactError).code).toBe('E_INPUT'); + expect((err as PdfReactError).message).toContain('doc-spec'); + } + }); +}); + +describe('backward compatibility', () => { + it('docSpecSchema() still returns the doc-spec schema', () => { + expect(docSpecSchema()).toEqual(schema('doc-spec')); + }); + + it('docSpecSchemaId() still returns the doc-spec $id', () => { + expect(docSpecSchemaId()).toBe(schemaId('doc-spec')); + expect(docSpecSchemaId()).toContain(`/${version}/`); + }); +}); + +describe('doc-spec schema content', () => { + const doc = docSpecSchema(); + + it('requires blocks and describes the layout sugar', () => { + expect(doc['required']).toEqual(['blocks']); + const props = doc['properties'] as Record; + for (const key of ['watermark', 'header', 'footer', 'attachments', 'tagged', 'blocks']) { + expect(props, key).toHaveProperty(key); + } + }); + + it('defines the recursive and shared $defs', () => { + const defs = doc['$defs'] as Record; + expect(Object.keys(defs)).toEqual(['listItem', 'outlineItem', 'pageTemplate', 'block']); + }); + + it('includes a chart branch with its own required fields', () => { + const defs = doc['$defs'] as { block: { oneOf: Record[] } }; + const chart = defs.block.oneOf.find((b) => b['title'] === 'ChartSpec'); + expect(chart).toBeDefined(); + const body = (chart?.['prefixItems'] as Record[])[1]; + expect(body['required']).toEqual(['chartType', 'series']); + }); +}); + +describe('report schemas', () => { + it('lint-report enumerates exactly the implemented rule codes', () => { + const doc = schema('lint-report'); + const props = doc['properties'] as { + findings: { items: { properties: { code: { enum: string[] } } } }; + }; + expect(props.findings.items.properties.code.enum).toEqual([...LINT_RULE_CODES]); + }); + + it('doctor describes the check shape', () => { + const doc = schema('doctor'); + expect(doc['required']).toEqual(['ok', 'checks']); + }); + + it('manifest describes the capability-manifest shape', () => { + const doc = schema('manifest'); + const props = doc['properties'] as { kind: { const: string } }; + expect(props.kind.const).toBe('capability-manifest'); + }); + + it('spec-validation enumerates the validation codes', () => { + const doc = schema('spec-validation'); + const defs = doc['$defs'] as { finding: { properties: { code: { enum: string[] } } } }; + expect(defs.finding.properties.code.enum).toContain('V_UNKNOWN_KIND'); + }); +}); diff --git a/tests/version.test.ts b/tests/version.test.ts index 5fa4fc0..108c31f 100644 --- a/tests/version.test.ts +++ b/tests/version.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { version } from '../src/index.js'; +import { capabilityManifest, schemaId, version } from '../src/index.js'; describe('version', () => { it('stays in sync with package.json', async () => { @@ -15,4 +15,40 @@ describe('version', () => { const match = /^version:\s*(.+)$/m.exec(cff); expect(match?.[1].trim()).toBe(version); }); + + it('drives the versioned schema $id', () => { + expect(schemaId('doc-spec')).toBe( + `https://pdfnative.dev/schema/react/${version}/doc-spec.schema.json`, + ); + }); + + it('is what the capability manifest reports', () => { + expect(capabilityManifest().version).toBe(version); + }); +}); + +describe('engine contract', () => { + it('declares pdfnative as a peer at ^1.6.0, never a dependency', async () => { + const pkg = JSON.parse( + await readFile(join(process.cwd(), 'package.json'), 'utf8'), + ) as { + peerDependencies: Record; + dependencies: Record; + engines: { node: string }; + }; + + expect(pkg.peerDependencies['pdfnative']).toBe('^1.6.0'); + expect(pkg.dependencies).not.toHaveProperty('pdfnative'); + // The single runtime dependency, per golden rule 1. + expect(Object.keys(pkg.dependencies)).toEqual(['react-reconciler']); + // Inherited from the engine, which requires Node >= 22 as of 1.6.0. + expect(pkg.engines.node).toBe('>=22'); + }); + + it('ships llms.txt so agents can read the manifest from the tarball', async () => { + const pkg = JSON.parse( + await readFile(join(process.cwd(), 'package.json'), 'utf8'), + ) as { files: string[] }; + expect(pkg.files).toContain('llms.txt'); + }); }); From ef3ba5a22b1390d7d6820190d95128fb9b072636 Mon Sep 17 00:00:00 2001 From: Kuzino <129803615+Nizoka@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:28:35 +0200 Subject: [PATCH 2/5] fix: address every confirmed finding from two adversarial reviews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent reviews (architecture, documentation accuracy) were run on the v1.1.0 branch. Both found real defects. All confirmed findings are fixed. Security / robustness: - validateSpec — the "never throws" gate for untrusted input — overflowed the call stack on a ~44 kB deeply nested payload. Nesting is now bounded at 64 levels with a new V_TOO_DEEP code; regression test at depth 5000. - schema('toString') resolved through Object.prototype and returned a string. This is the one API designed to be called with a model-generated string. Now guarded with Object.hasOwn; five prototype keys tested. - schema('lint-report') handed out a live reference to LINT_RULES, so any consumer that annotated a returned schema silently changed every subsequent lint severity process-wide. Now a fresh copy. Linter correctness: - L_CHART_VALUES missed an undefined value (.find returns undefined for a *found* undefined). Now .some(). - L_MAX_BLOCKS claimed "within 10% of the ceiling" when 5x over it, as a warning. Split out L_MAX_BLOCKS_EXCEEDED (error). - L_HEADING_HIERARCHY never flagged a first heading of h2/h3. - New L_CHART_EMPTY: two engine throws (empty series, empty values) had no rule. Anti-drift — the release's central claim, which was oversold: - schema.ts hardcoded every kind discriminator, so the registry and the schema could disagree while both claimed one source. blockDefs() now derives it. - KNOWN_FIELDS had no lock; now satisfies + Assert>. - LINT_RULES was unlocked in both directions; EMITTED_LINT_RULES + a test close the direction the type system cannot. Honesty of the agent surface: - capabilityManifest() claimed to describe "everything" while omitting 24 of 73 exports. All added, plus clientComponents and errorClasses; a test now locks both directions. - doctor()'s claim that it diagnoses a missing peer was false — a static re-export means the module graph fails first. Corrected in code and docs. - docs/SERVER.md documented a Server Action, but RSC-layer imports fail at module load. Replaced with the real constraint and the wrapper pattern. Documentation: - The annotation recipe was wrong on both arguments and could not run. Rewritten against the real API and executed. - .github/copilot-instructions.md and instructions/spec.instructions.md still described pre-1.1.0 architecture; an agent following either would fail the repo's own compile-time lock. - RFC 8187: filename* emitted characters that are not attr-char. - Six hand-maintained counts recounted against the code. 205 -> 219 tests. Coverage 95.4/86.9/97.8/96.3. --- .github/copilot-instructions.md | 33 ++++-- .github/instructions/spec.instructions.md | 38 +++++- CHANGELOG.md | 26 ++-- README.md | 11 +- docs/AGENT_CONTRACT.md | 19 +-- docs/CHARTS.md | 1 + docs/KNOWLEDGE_BASE.md | 2 + docs/LINTING.md | 32 ++--- docs/RECIPES.md | 31 ++++- docs/SERVER.md | 45 +++++-- llms.txt | 24 +++- release-notes/draft/PR-v1.1.0.md | 59 +++++++-- release-notes/v1.1.0.md | 14 ++- samples/quality/lint.tsx | 8 +- src/doctor.ts | 22 +++- src/lint.ts | 81 ++++++++++++- src/manifest.ts | 138 +++++++++++++++++++++- src/registry.ts | 34 ++++++ src/response.ts | 11 +- src/spec/schema.ts | 50 ++++++-- src/spec/validate.ts | 48 +++++++- tests/agent.test.tsx | 71 +++++++++++ tests/lint.test.tsx | 83 +++++++++++++ tests/registry.test.ts | 15 ++- tests/response.test.tsx | 21 ++++ 25 files changed, 798 insertions(+), 119 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b1a0189..51a194d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -19,13 +19,30 @@ Read [docs/KNOWLEDGE_BASE.md](../docs/KNOWLEDGE_BASE.md) and imports from there or from `src/types.ts`. - **Never add a CSS/flexbox layout model.** Map components 1:1 onto pdfnative blocks (heading, paragraph, list, table, image, link, spacer, pageBreak, toc, - barcode, svg, formField). `
` is the single allowed *composite* (it - resolves to a heading + children, emitting no host tag). -- **`pdfnative` is a peer dependency.** Never move it back to `dependencies`. + barcode, svg, **chart**, formField). `
` is the single allowed + *composite* (it resolves to a heading + children, emitting no host tag). +- **`src/registry.ts` is the single source of truth** for the block grammar, the + component list and the lint rules. `src/spec/schema.ts`, `src/spec/validate.ts` + and `src/manifest.ts` all *derive* from it — never restate a kind, an arity or + a rule in those files. Compile-time `Assert>` locks mean forgetting + to register something fails `npm run typecheck`. See the 10-step checklist in + [AGENTS.md](../AGENTS.md). +- **`pdfnative` is a peer dependency** (`^1.6.0`; Node ≥ 22). Never move it back + to `dependencies`. - **Authoring only.** Do not re-export byte-level post-processing (merge/split, - annotations, signing, crypto, font compilation) — point to the engine instead. -- **Document-level `outline`/`pageLabels`** live on `` props (they - reference post-layout pages), not as content blocks. + form fill/flatten, text extraction, decryption, annotations, signing, crypto, + font compilation) — point to [docs/RECIPES.md](../docs/RECIPES.md) instead. +- **Document-level props on ``**, not content blocks: `outline` and + `pageLabels` (they reference post-layout pages), plus the layout sugar + `watermark`, `header`, `footer`, `attachments`, `tagged`. The sugar folds into + `layout` via `resolveLayout()`, where an explicit `layout` always wins — and + which must keep returning `undefined`, never `{}`, when nothing is set, or + every existing document changes bytes. +- **Agent-facing surface must stay honest.** `doctor()` must never throw; + `validateSpec()` must never throw and must bound its recursion; `schema()` must + reject unknown subjects with `E_INPUT` (use `Object.hasOwn`, not a truthiness + check); `capabilityManifest()` must list *every* public export, and a test + locks both directions. - **react-reconciler version contract:** React 19 ↔ `react-reconciler@^0.31` ↔ `@types/react-reconciler@^0.32`. Specifically: - `getRootHostContext`/`getChildHostContext` must return a **non-null** @@ -37,7 +54,9 @@ Read [docs/KNOWLEDGE_BASE.md](../docs/KNOWLEDGE_BASE.md) and - **Do not run the renderer synchronously inside a React effect/commit.** `usePdf` defers `renderToBytes` via `queueMicrotask` to avoid reconciler reentrancy (which deadlocks). Preserve this when editing hooks. -- **Client modules carry `'use client'`** (`hooks.ts`, `viewer.tsx`). +- **Client modules carry `'use client'`** (`hooks.ts`, `viewer.tsx`) — in source. + The bundle is a single file, so the directive does not survive into `dist/`; + `src/response.ts` is server-side and must never carry it. - **Strict TypeScript, no `any`** (lint-enforced). Use `type`-only imports. - **AI governance (draftsman, never submitter).** Do not open/submit issues or PRs autonomously. Draft into `.github/drafts/`, validate with diff --git a/.github/instructions/spec.instructions.md b/.github/instructions/spec.instructions.md index 324c0b8..d21f001 100644 --- a/.github/instructions/spec.instructions.md +++ b/.github/instructions/spec.instructions.md @@ -15,8 +15,19 @@ with far fewer tokens than JSX. It is pure, isomorphic, and side-effect-free. not add layout primitives, and do not introduce props the components lack. Pure JSX sugar with no new capability (e.g. `
`) is deliberately **not** given a tuple — agents emit the underlying blocks. Document-level - `outline`/`pageLabels` are top-level `DocSpec` fields (not tuples), mirroring - ``. Nested list items use `{ text, items }` in the `ul`/`ol` grammar. + `outline`, `pageLabels`, `watermark`, `header`, `footer`, `attachments` and + `tagged` are top-level `DocSpec` fields (not tuples), mirroring ``. + Nested list items use `{ text, items }` in the `ul`/`ol` grammar. +- **`src/registry.ts` is the single source of truth.** `schema.ts` derives + `$defs.block.oneOf` — including each tuple's kind discriminator, arity and + description — from `BLOCK_REGISTRY`, and `validate.ts` derives its arity and + payload rules from the same table. Never restate any of that in a builder. + Compile-time `Assert>` locks make omission a `tsc` failure. +- **`validate.ts` is the dependency-free dry run.** `validateSpec(unknown)` must + never throw and must never recurse without a depth bound — it is the gate for + untrusted input. Unknown top-level fields are a *warning*, so a newer spec + meeting an older package degrades gracefully. `KNOWN_FIELDS` is locked to + `keyof DocSpec` at compile time. - **Reuse component prop types.** Per-block opts types are derived from the component prop interfaces (via `Pick`/`Omit`) so the spec inherits their type safety and cannot drift. @@ -30,7 +41,22 @@ with far fewer tokens than JSX. It is pure, isomorphic, and side-effect-free. `TableOfContents`) needs an explicit generic (`createElement`), or TS infers `Attributes` and rejects the extra props (TS2769). -When you add a block kind: add the tuple type in `types.ts`, a `case` in -`compile.ts`, a per-block schema builder in `schema.ts`, an export in -`src/spec/index.ts` (and `src/index.ts` if public), and a test in -`tests/spec.test.tsx`. +When you add a block kind, all ten steps are required — the first five are +enforced by the compiler, so skipping any of them fails `npm run typecheck`: + +1. `src/reconciler/nodes.ts` — the host tag. +2. `src/components.tsx` — the component and its props. +3. `src/reconciler/serialize.ts` — the `case` in `toBlock`. +4. `src/spec/types.ts` — the tuple type, added to the `BlockSpec` union. +5. **`src/registry.ts`** — the `BLOCK_REGISTRY` and `COMPONENT_REGISTRY` entries. +6. `src/spec/compile.ts` — the `case` (the `never` guard will demand it). +7. `src/spec/schema.ts` — the builder, registered in `BLOCK_SCHEMAS`. +8. `src/spec/index.ts` and `src/index.ts` — export the new types. +9. `tests/` — a serialization test **and** a `compileSpec` ↔ JSX parity test, + plus the ordered list in `tests/registry.test.ts`. +10. `samples/`, `samples/README.md`, `llms.txt`, `README.md`, `CHANGELOG.md`. + +The same discipline applies to a lint rule: add it to `LINT_RULES` in +`src/registry.ts`, implement it in `src/lint.ts`, list it in +`EMITTED_LINT_RULES`, and add a test — the registry alone cannot catch a rule +that is declared but never emitted. diff --git a/CHANGELOG.md b/CHANGELOG.md index 46a92e3..8b21479 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,15 +64,14 @@ No public API was removed or changed in a backward-incompatible way. Two #### Linting -- **`lintDocument(node, options?)`** / **`lintSpec(spec, options?)`** — sixteen - deterministic accessibility and layout rules with stable `L_*` codes. Runs on - the compiled document model, so JSX and `DocSpec` share one implementation. - Pure: no console output, no throwing. -- Five rules pre-empt hard failures further down the pipeline: - `L_CHART_SERIES`, `L_CHART_CATEGORIES`, `L_CHART_VALUES` and `L_CHART_POINTS` - mirror the engine's own chart validation (which throws at render time), and - `L_ATTACHMENTS_NEED_PDFA3` / `L_TAGGED_NO_FONTS` catch PDF/A documents the - engine or veraPDF would reject. +- **`lintDocument(node, options?)`** / **`lintSpec(spec, options?)`** — eighteen + deterministic accessibility and layout rules with stable `L_*` codes (10 + error, 7 warning, 1 info). Runs on the compiled document model, so JSX and + `DocSpec` share one implementation. Pure: no console output, no throwing. +- Six rules pre-empt an exception the engine raises mid-render: the five + `L_CHART_*` errors (`EMPTY`, `SERIES`, `CATEGORIES`, `VALUES`, `POINTS`) and + `L_ATTACHMENTS_NEED_PDFA3`. Two more — `L_TAGGED_NO_FONTS` and + `L_MAX_BLOCKS_EXCEEDED` — catch output that renders successfully but is wrong. #### Agent surface @@ -94,7 +93,8 @@ No public API was removed or changed in a backward-incompatible way. Two so it survives bundling into a browser build. - **`validateSpec(spec: unknown)`** — structural validation of an untrusted `DocSpec` with no JSON-Schema engine, returning path-anchored `V_*` findings - (`blocks[3][1]`). Never throws. This is dry-run tier 1; `compileSpec`, + (`blocks[3][1]`). Never throws, and bounds page nesting at 64 levels so a deep + payload cannot exhaust the call stack. This is dry-run tier 1; `compileSpec`, `lintSpec` and `inspectSpec` are tiers 2–4. - **`schema(subject?)`** / **`schemaId(subject?)`** — seven subjects (`doc-spec`, `render-options`, `lint-report`, `spec-validation`, `doctor`, @@ -125,9 +125,9 @@ No public API was removed or changed in a backward-incompatible way. Two `extractText`, `fillForm`/`flattenForm`, `openPdf({ password })`, merge/split and re-encryption on the bytes this library produces. - `docs/KNOWLEDGE_BASE.md` gains an "Agent Automation Contract" chapter. -- 6 new samples (charts, layout sugar, a Next.js route handler, linting, the - full agent loop, the error envelope) and 3 new agent samples, all - type-checked in CI. +- 7 new samples — charts, layout sugar, a Next.js route handler, linting, and + three agent samples (the full loop, the capability manifest, the error + envelope). All type-checked in CI and executed end to end. ## [1.0.0] — Stable release diff --git a/README.md b/README.md index f8fa3ea..8ff263e 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ Every component maps 1:1 onto a pdfnative block. |---|---| | `Document` | The required root (`title`, `footerText`, `metadata`, `fontEntries`, `layout`, `outline`, `pageLabels`, `watermark`, `header`, `footer`, `attachments`, `tagged`). | | `Page` | An explicit page boundary (content auto-paginates otherwise). | -| `Section` | Sugar: a heading grouped with its content (`title`, `level`, `break`). | +| `Section` | Sugar: a heading grouped with its content (`title`, `level`, `color`, `break`). | | `Heading` | A section heading (`level` 1–3); feeds the auto `TableOfContents`. | | `Paragraph` / `Text` | A wrapping paragraph (`fontSize`, `lineHeight`, `align`, `indent`, `color`). | | `List` / `Item` | A bullet or numbered (`ordered`) list; items may nest sub-lists. | @@ -143,8 +143,8 @@ PDF/A mode, encryption, viewer preferences, debug overlay, and non-Latin fonts. `renderToFileStream` writes page by page with constant memory and preserves document-level features (outline, page labels). The `fonts` loader map is honored only by the async entry points (`renderToFile`, `renderToFileStream`, -`usePdf`, `usePdfStream`); for the synchronous entries resolve it first with -`fontEntries: await resolveFonts({ … })`. +`renderToResponse`, `usePdf`, `usePdfStream`); for the synchronous entries +resolve it first with `fontEntries: await resolveFonts({ … })`. ### Bookmarks, page labels & viewer preferences @@ -182,7 +182,10 @@ the `items` data prop (`{ text, items }`). Nested lists inherit the parent style ## Hooks & client components -Client modules carry `'use client'`. +These run in the browser. The published bundle is a single file with no +`'use client'` directive — the source modules carry it, but bundling collapses +them — so in a React Server Components app, declare the directive in the file +that imports them. ```tsx 'use client'; diff --git a/docs/AGENT_CONTRACT.md b/docs/AGENT_CONTRACT.md index d3c44ac..6276343 100644 --- a/docs/AGENT_CONTRACT.md +++ b/docs/AGENT_CONTRACT.md @@ -134,28 +134,33 @@ const result = validateSpec(JSON.parse(untrusted)); // { ok, errors: [{ code, severity, path, message }], warnings: [...] } ``` -Never throws. Findings are path-anchored (`blocks[3][1]`), so an agent can -repair its own output rather than guessing. Codes: `V_NOT_OBJECT`, `V_BLOCKS`, +Never throws — including on deliberately hostile input. Page nesting is bounded +at 64 levels (`V_TOO_DEEP`), so a deep payload cannot exhaust the call stack. +Findings are path-anchored (`blocks[3][1]`), so an agent can repair its own +output rather than guessing. Codes: `V_NOT_OBJECT`, `V_BLOCKS`, `V_BLOCK_SHAPE`, `V_UNKNOWN_KIND`, `V_ARITY`, `V_PAYLOAD_TYPE`, `V_OPTS_TYPE`, -`V_UNKNOWN_FIELD` (warning only — unknown fields are ignored, not fatal, so -forward compatibility is preserved). +`V_TOO_DEEP`, and `V_UNKNOWN_FIELD` (warning only — unknown fields are ignored, +not fatal, so forward compatibility is preserved). Arity and payload rules derive from the same table that builds the JSON Schema, so the two can never disagree. ### Tier 3 — `lintSpec` -Sixteen rules with stable `L_*` codes. Five of them pre-empt failures that -would otherwise happen *inside the engine*, at render time: +Eighteen rules with stable `L_*` codes (10 error, 7 warning, 1 info). Six of +them pre-empt an exception the engine raises *mid-render*: | Code | Would otherwise | |---|---| +| `L_CHART_EMPTY` | Throw — no series, or a series with no values | | `L_CHART_SERIES` | Throw — pie/donut need exactly one series | | `L_CHART_CATEGORIES` | Throw — series length must match categories | | `L_CHART_VALUES` | Throw — non-finite, or negative in a pie/donut | | `L_CHART_POINTS` | Throw — 10 000-point ceiling | | `L_ATTACHMENTS_NEED_PDFA3` | Throw — attachments require `tagged="pdfa3b"` | -| `L_TAGGED_NO_FONTS` | Produce a PDF/A file veraPDF rejects | + +Two more catch output that renders successfully but is wrong: +`L_TAGGED_NO_FONTS` (a PDF/A file veraPDF rejects) and `L_MAX_BLOCKS_EXCEEDED`. Gate on `report.ok` (true when no `error`-severity finding). See [LINTING.md](LINTING.md). diff --git a/docs/CHARTS.md b/docs/CHARTS.md index 6f321af..eac8271 100644 --- a/docs/CHARTS.md +++ b/docs/CHARTS.md @@ -89,6 +89,7 @@ turns each of them into a finding you can read first: | Rule | Constraint | |---|---| +| `L_CHART_EMPTY` | At least one series, and every series needs at least one value | | `L_CHART_SERIES` | Pie and donut take exactly one series | | `L_CHART_CATEGORIES` | Every series length must equal `categories.length` | | `L_CHART_VALUES` | All values finite; no negatives in a pie/donut | diff --git a/docs/KNOWLEDGE_BASE.md b/docs/KNOWLEDGE_BASE.md index 9742aaf..16210cd 100644 --- a/docs/KNOWLEDGE_BASE.md +++ b/docs/KNOWLEDGE_BASE.md @@ -158,6 +158,8 @@ Notes learned the hard way: `viewerPreferences`/`debug` survive it. - `tests/hooks.test.tsx` — exercises `usePdf`/`usePdfStream` under jsdom, including the async `options.fonts` path. +- `tests/viewer.test.tsx` — `PDFViewer`, `PDFDownloadLink` (both children forms) + and `BlobProvider`. - `tests/spec.test.tsx` — asserts `compileSpec` parity with the equivalent JSX, nested list/outline/pageLabels/cellBorders forwarding, `inspectSpec`, real `renderSpec*` PDF output, and the JSON Schema `$id`/version/recursive `$defs`. diff --git a/docs/LINTING.md b/docs/LINTING.md index 5483605..1fa07b3 100644 --- a/docs/LINTING.md +++ b/docs/LINTING.md @@ -42,22 +42,26 @@ hint, before you spend the work. ## Rules -Sixteen rules, each with a stable code. Branch on the code, not the message. +Eighteen rules, each with a stable code. Branch on the code, not the message. ### Errors — these clear `ok` -| Code | Rule | -|---|---| -| `L_EMPTY_DOCUMENT` | The document has no blocks | -| `L_TAGGED_NO_FONTS` | PDF/A requested with no `fontEntries` (veraPDF 6.2.11.4.1) | -| `L_TAGGED_ENCRYPTED` | PDF/A and encryption combined (ISO 19005-1 §6.3.2) | -| `L_ATTACHMENTS_NEED_PDFA3` | Attachments outside `tagged="pdfa3b"` (ISO 19005-3) | -| `L_CHART_SERIES` | Pie or donut with anything other than one series | -| `L_CHART_CATEGORIES` | Series length ≠ `categories.length` | -| `L_CHART_VALUES` | Non-finite value, or a negative in a pie/donut | -| `L_CHART_POINTS` | Chart past the engine's 10 000-point ceiling | - -The last five would each throw at render time. +| Code | Rule | Would otherwise | +|---|---|---| +| `L_EMPTY_DOCUMENT` | The document has no blocks | Render a blank page | +| `L_TAGGED_NO_FONTS` | PDF/A requested with no `fontEntries` | Produce a file veraPDF rejects (6.2.11.4.1) | +| `L_TAGGED_ENCRYPTED` | PDF/A and encryption combined | Violate ISO 19005-1 §6.3.2 | +| `L_ATTACHMENTS_NEED_PDFA3` | Attachments outside `tagged="pdfa3b"` | **Throw** | +| `L_MAX_BLOCKS_EXCEEDED` | Block count past the `maxBlocks` ceiling | Be rejected by the engine | +| `L_CHART_EMPTY` | Chart with no series, or a series with no values | **Throw** | +| `L_CHART_SERIES` | Pie or donut with anything other than one series | **Throw** | +| `L_CHART_CATEGORIES` | Series length ≠ `categories.length` | **Throw** | +| `L_CHART_VALUES` | Non-finite value, or a negative in a pie/donut | **Throw** | +| `L_CHART_POINTS` | Chart past the engine's 10 000-point ceiling | **Throw** | + +Six of these — the five chart rules and `L_ATTACHMENTS_NEED_PDFA3` — pre-empt an +exception the engine raises mid-render. The rest catch output that renders +successfully but is wrong. ### Warnings @@ -65,7 +69,7 @@ The last five would each throw at render time. |---|---| | `L_IMAGE_ALT` | Image with no alt text | | `L_TABLE_HEADERS` | Table with no header row | -| `L_HEADING_HIERARCHY` | Heading level skipped (h1 → h3) | +| `L_HEADING_HIERARCHY` | Heading level skipped, including a first heading deeper than h1 | | `L_FIELD_LABEL` | Form field with no label | | `L_LINK_TEXT` | Link with no text, or whose text is the bare URL | | `L_MAX_BLOCKS` | Block count within 10% of the `maxBlocks` ceiling | diff --git a/docs/RECIPES.md b/docs/RECIPES.md index a8142e4..cd4d54c 100644 --- a/docs/RECIPES.md +++ b/docs/RECIPES.md @@ -149,16 +149,39 @@ const rotated = mergePdfs([{ bytes: protectedBytes, password: 'old' }], { ## Sign, annotate, inspect ```ts -import { signPdfBytes, createModifier, openPdf, validatePdfUA } from 'pdfnative'; +import { signPdfBytes, validatePdfUA } from 'pdfnative'; const signed = signPdfBytes(bytes, { /* certificate, key, … */ }); +const report = validatePdfUA(bytes); // accessibility conformance +``` -const modifier = createModifier(bytes); -modifier.addAnnotation(0, { /* highlight, note, … */ }); +Annotations take three steps, because the modifier works on a *parsed* document +and `addAnnotation` takes a serialized dictionary, not an object: -const report = validatePdfUA(bytes); // accessibility conformance +```ts +import { openPdf, createModifier, buildAnnotationBody } from 'pdfnative'; + +const modifier = createModifier(openPdf(bytes)); // a PdfReader, not raw bytes + +const body = buildAnnotationBody({ + type: 'text', + rect: [72, 700, 92, 720], + contents: 'Check this figure against the source data.', + title: 'Reviewer', +}); + +modifier.addAnnotation(0, body); // 0-based page index +const annotated = modifier.save(); // incremental update appended ``` +`buildAnnotationBody` emits the `<< … >>` dictionary; `buildAnnotation` emits a +full indirect object instead, for when you are assembling a PDF yourself. Both +accept the typed markup shapes — text note, highlight, underline, strikeout, +squiggly, square, circle, line, free text. + +Note that `addRawObject` throws on encrypted documents (a verbatim body cannot +be transparently encrypted); `addAnnotation` handles encryption correctly. + ## Compile a font at runtime Useful in serverless or sandboxed runtimes where you cannot spawn the diff --git a/docs/SERVER.md b/docs/SERVER.md index f32db80..f327774 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -125,21 +125,50 @@ app.get('/invoice.pdf', async (_req, res) => { }); ``` -## Server Actions +## The React Server Components boundary -A Server Action cannot return a `Response`, so return the bytes and let the -client build the download — or, better, point the client at a route handler and -keep the PDF out of the RSC payload entirely: +**Use a Route Handler, not a Server Component or a Server Action.** + +`pdfnative-react` drives a React reconciler, which needs `createContext` at +module scope. React's `react-server` export condition — the one Next.js applies +to Server Components and `'use server'` files — does not provide it, so +importing this package from the RSC layer fails at module load: + +``` +TypeError: react.createContext is not a function +``` + +Route Handlers (`app/**/route.ts`) are **not** in the RSC layer, which is why +every example on this page works. This is the supported path, and it is also the +better design: the PDF stays out of the RSC payload entirely. + +If you need a Server Action to *trigger* generation, have it return a URL and +let the browser fetch the route handler: ```tsx 'use server'; -import { renderToBytes } from 'pdfnative-react'; - -export async function generate(id: string): Promise { - return renderToBytes(); +export async function prepare(id: string): Promise { + await recordDownload(id); + return `/invoice/${id}`; // the route handler above } ``` +### Client components + +The published bundle is a single file with no `'use client'` directive. The +source modules carry it, but bundling collapses them, so the marker does not +survive into `dist/`. In an App Router project, import the preview and download +components from a file that declares the directive itself: + +```tsx +// components/pdf-preview.tsx +'use client'; +export { PDFViewer, PDFDownloadLink, BlobProvider, usePdf } from 'pdfnative-react'; +``` + +Then import from that file in your client components. Server-side rendering +(`renderToResponse`, `renderToBytes`, `renderToFile`) needs no such wrapper. + ## Runtime requirements `Response` and `ReadableStream` are required. Both are global from Node 18 diff --git a/llms.txt b/llms.txt index c393191..4f97e0e 100644 --- a/llms.txt +++ b/llms.txt @@ -82,11 +82,21 @@ usePdfStream); for sync entries do `fontEntries: await resolveFonts({...})`. - fromUrl(url, init?) -> Promise (image bytes) - fromBase64(payload) -> Uint8Array (base64 or data: URI) -## Hooks & client components (carry 'use client') +## Hooks & client components (browser only) + +The published bundle is one file with no 'use client' directive (source modules +carry it; bundling collapses them). In a React Server Components app, declare +the directive in the file that imports these. + +IMPORTANT — the RSC boundary: this package drives a React reconciler and needs +createContext, which React's 'react-server' condition does not provide. Importing +it from a Server Component or a 'use server' file fails at module load. Use a +Route Handler (app/**/route.ts) instead — that is what renderToResponse is for. + - usePdf(element, options?) -> { url, blob, bytes, loading, error, update } - usePdfStream(element, options?) -> { getStream() } -- PDFViewer({ document, options?, ...iframeProps }) — live iframe preview +- PDFViewer({ document, options?, className?, style?, width?, height?, title? }) — live iframe preview - PDFDownloadLink({ document, fileName?, options?, children }) — anchor download - BlobProvider({ document, options?, children: (state) => ReactNode }) @@ -162,15 +172,17 @@ Every error carries a stable code. Branch on the code, never the message. ## Lint rules (stable L_* codes) +18 rules — 10 error, 7 warning, 1 info. + errors: L_EMPTY_DOCUMENT, L_TAGGED_NO_FONTS, L_TAGGED_ENCRYPTED, - L_ATTACHMENTS_NEED_PDFA3, L_CHART_SERIES, L_CHART_CATEGORIES, - L_CHART_VALUES, L_CHART_POINTS + L_ATTACHMENTS_NEED_PDFA3, L_MAX_BLOCKS_EXCEEDED, L_CHART_EMPTY, + L_CHART_SERIES, L_CHART_CATEGORIES, L_CHART_VALUES, L_CHART_POINTS warnings: L_IMAGE_ALT, L_TABLE_HEADERS, L_HEADING_HIERARCHY, L_FIELD_LABEL, L_LINK_TEXT, L_MAX_BLOCKS, L_OVERFLOW info: L_CHART_ALT -The five chart/attachment errors pre-empt failures the engine would otherwise -raise by throwing mid-render. L_OVERFLOW requires { overflow: true }. +Six of them — the five L_CHART_* errors and L_ATTACHMENTS_NEED_PDFA3 — pre-empt +an exception the engine raises mid-render. L_OVERFLOW requires { overflow: true }. ## Notes diff --git a/release-notes/draft/PR-v1.1.0.md b/release-notes/draft/PR-v1.1.0.md index e74f31d..2da215b 100644 --- a/release-notes/draft/PR-v1.1.0.md +++ b/release-notes/draft/PR-v1.1.0.md @@ -112,9 +112,10 @@ benefit. No `'use client'` — this is server code. `DocumentParams`, so JSX and `DocSpec` share one implementation for free (`lintSpec` is a two-line delegate, and a test asserts they agree). -Sixteen rules. Five pre-empt failures the engine raises by throwing mid-render; -`L_ATTACHMENTS_NEED_PDFA3` exists because writing -`samples/layout/watermark-header-footer.tsx` hit exactly that throw. +Eighteen rules (10 error, 7 warning, 1 info). Six pre-empt failures the engine +raises by throwing mid-render; `L_ATTACHMENTS_NEED_PDFA3` exists because writing +`samples/layout/watermark-header-footer.tsx` hit exactly that throw, and +`L_CHART_EMPTY` because the architecture review found two more. Pure by design: no console output, no throwing, `overflow` opt-in because it costs a layout pass. @@ -149,14 +150,14 @@ costs a layout pass. ### Samples & tests -- 6 new samples: `charts/charts.tsx`, `layout/watermark-header-footer.tsx`, +- 7 new samples: `charts/charts.tsx`, `layout/watermark-header-footer.tsx`, `server/next-route-handler.tsx`, `quality/lint.tsx`, `agent/agent-loop.ts`, `agent/manifest.ts`, `agent/error-envelope.tsx`. All added to `samples/README.md` (with new "Server" and "Quality" sections) and all executed end to end, not just type-checked. -- 6 new test files: `registry`, `chart`, `layout-sugar`, `response`, `lint`, +- 7 new test files: `registry`, `chart`, `layout-sugar`, `response`, `lint`, `agent`, `schema`. `governance` and `version` extended. -- **79 → 205 tests**, 8 → 15 files. Coverage improved on every axis. +- **79 → 219 tests**, 8 → 15 files. Coverage improved on every axis. ### Docs & governance @@ -181,11 +182,11 @@ costs a layout pass. ``` npm run typecheck:all clean (src + tests + samples) npm run lint clean, zero warnings -npm test 205 passed / 205, 15 files -npm run test:coverage 94.74 stmts · 85.71 branches · 97.15 funcs · 96.00 lines +npm test 219 passed / 219, 15 files +npm run test:coverage 95.41 stmts · 86.94 branches · 97.76 funcs · 96.32 lines (thresholds 85/80/85/85 — unchanged, not lowered) -npm run build ESM 80.8kB · CJS 83.2kB · d.ts + d.cts 67.1kB -npm pack --dry-run llms.txt present; 10 files, 205.8 kB +npm run build ESM 84.9kB · CJS 87.2kB · d.ts + d.cts 66.7kB +npm pack --dry-run llms.txt present in the tarball ``` Additionally verified by hand: @@ -195,6 +196,44 @@ Additionally verified by hand: kinds, `schema()['$id']` carries `1.1.0`. - Every new sample executed and confirmed to write a valid PDF. - The registry lock verified destructively (see above). +- The corrected annotation recipe in `docs/RECIPES.md` executed end to end. + +## Adversarial review + +Two independent reviews were run before this draft: one on architecture and +state of the art, one on documentation accuracy. Both found real defects. Every +confirmed finding is fixed: + +| Finding | Fix | +|---|---| +| `validateSpec` — the "never throws" untrusted-input gate — overflowed the stack on a ~44 kB deeply nested payload | Nesting bounded at 64 levels, new `V_TOO_DEEP` code, regression test at depth 5000 | +| `schema('toString')` resolved through `Object.prototype` and returned a string | `Object.hasOwn` guard; test covers five prototype keys | +| `schema('lint-report')` handed out a live reference to `LINT_RULES`; mutating the returned schema changed every subsequent lint severity process-wide | Fresh copy; regression test | +| `L_CHART_VALUES` missed an `undefined` value (`.find()` returns `undefined` for a *found* `undefined`) | `.some()`; test | +| `L_MAX_BLOCKS` reported "within 10% of the ceiling" when 5× over it, as a warning | New `L_MAX_BLOCKS_EXCEEDED` error; both tested | +| `L_HEADING_HIERARCHY` never flagged a document whose *first* heading was h2/h3 | Guard removed; test | +| Two engine throws had no lint rule (empty series, empty values) | New `L_CHART_EMPTY`; test | +| `capabilityManifest()` claimed to describe "everything" while omitting 24 of 73 exports | All added, plus `clientComponents`/`errorClasses`; a test now locks **both** directions | +| `schema.ts` hardcoded every kind discriminator, so the registry and the schema could disagree (proved: registry `h1–h4`, schema `h1–h3`, typecheck green) | `blockDefs()` overwrites the discriminator from the registry; `registry.test.ts` asserts it | +| `KNOWN_FIELDS` in `validate.ts` had no lock | `satisfies readonly (keyof DocSpec)[]` plus an `Assert>` | +| `LINT_RULES` was not locked in either direction — a declared-but-unimplemented rule would ship into the schema and the manifest | New `EMITTED_LINT_RULES` + equality test | +| `Content-Disposition` `filename*` emitted `' ( ) ! *`, which are not RFC 8187 `attr-char`; a raw apostrophe mis-parses the ext-value | Percent-escaped; test | +| `docs/RECIPES.md` annotation example was wrong on both arguments and could not run | Rewritten against the real API (`createModifier(openPdf(bytes))`, `buildAnnotationBody`, `save()`) and **executed** | +| `.github/copilot-instructions.md` and `.github/instructions/spec.instructions.md` still described pre-1.1.0 architecture — no `chart`, no `registry.ts` — so an agent following them would fail the repo's own compile-time lock | Both rewritten, including the 10-step block checklist | +| `doctor()`'s headline claim ("works when the peer is missing") was false — a static re-export means the module graph fails first | Claim corrected in code and docs to what is actually true | +| `docs/SERVER.md` documented a Server Action, but RSC-layer imports fail at module load (`react-server` has no `createContext`); `'use client'` is stripped from the bundle | New "React Server Components boundary" section stating the real constraint and the wrapper pattern; README and `llms.txt` corrected | +| Hand-maintained counts wrong in six places ("five rules" over six-row tables, "6 new samples" over a list of seven) | All recounted against the code: 18 rules (10/7/1), 7 samples, 7 test files | + +Findings acknowledged but **not** acted on, with reasons: + +- **The two install-time floors as a minor.** One reviewer argues `^1.6.0` + Node + `>=22` warrant a major. Neither is source-breaking, both are documented at the + top of the release notes, and the alternative for the peer (`^1.5.0 || ^1.6.0` + plus a capability guard on every chart path) trades a build-time error for a + runtime surprise. Recorded here so a reviewer can overrule it. +- **Subpath exports (`./client`, `./server`).** The right long-term fix for the + RSC boundary, but architectural rather than a patch. Documented accurately for + 1.1.0; tracked for a future release. ## Backward compatibility diff --git a/release-notes/v1.1.0.md b/release-notes/v1.1.0.md index d974e44..ee073d9 100644 --- a/release-notes/v1.1.0.md +++ b/release-notes/v1.1.0.md @@ -201,14 +201,24 @@ your logs). - `npm run typecheck:all` — clean (src + tests + samples) - `npm run lint` — clean, zero warnings -- **205 tests across 15 files**, all green (was 79 across 8) -- Coverage **94.7% statements · 85.7% branches · 97.2% functions · 96.0% lines** +- **219 tests across 15 files**, all green (was 79 across 8) +- Coverage **95.4% statements · 86.9% branches · 97.8% functions · 96.3% lines** (thresholds 85/80/85/85, unchanged) - `npm run build` — ESM + CJS + `.d.ts` + `.d.cts` - CJS and ESM import smoke tests on the built artifacts - `npm pack --dry-run` — `llms.txt` present in the tarball - Every new sample executed end to end and verified to produce a valid PDF +This release was additionally put through two independent adversarial reviews — +one on architecture, one on documentation accuracy — before publication. Both +found real defects, and every confirmed finding is fixed in the code above: +a stack-overflow path in `validateSpec` on hostile input, prototype-chain +resolution in `schema()`, a mutable reference to the lint registry leaking +through a returned schema, four lint-rule bugs, an incomplete capability +manifest, an RFC 8187 encoding gap, a broken annotation example, and two stale +agent-instruction files. Two new lint rules (`L_CHART_EMPTY`, +`L_MAX_BLOCKS_EXCEEDED`) came directly out of that process. + ## Full changelog [CHANGELOG.md](../CHANGELOG.md#110--charts-server-rendering-and-an-autonomous-agent-surface) diff --git a/samples/quality/lint.tsx b/samples/quality/lint.tsx index f18e936..6bd5424 100644 --- a/samples/quality/lint.tsx +++ b/samples/quality/lint.tsx @@ -9,10 +9,10 @@ * for a finding; what you do with the report is your call. Wire it into CI, a * dev-mode warning, or an agent's self-check loop. * - * Four rules pre-empt hard failures further down the pipeline: the three - * `L_CHART_*` errors mirror the engine's own validation (which throws at render - * time), and `L_TAGGED_NO_FONTS` catches the PDF/A file that veraPDF would - * reject for a non-embedded font. + * Six rules pre-empt hard failures further down the pipeline: the five + * `L_CHART_*` errors and `L_ATTACHMENTS_NEED_PDFA3` mirror validation the engine + * performs by throwing mid-render. Two more — `L_TAGGED_NO_FONTS` and + * `L_MAX_BLOCKS_EXCEEDED` — catch output that renders but is wrong. */ import React from 'react'; diff --git a/src/doctor.ts b/src/doctor.ts index 50bf36b..536dc29 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -6,9 +6,17 @@ * autonomous agent should make in an unfamiliar environment, and a fast way for * a human to see why an install is misbehaving. * - * It is **total**: every check is wrapped, so `doctor()` never throws, even - * when the `pdfnative` peer is missing entirely — that is precisely the - * situation it exists to diagnose. + * It is **total**: every check is wrapped, so `doctor()` never throws. It + * reports rather than raises, which is what makes it safe to call first. + * + * One limit worth knowing. `core-bridge` re-exports the engine with a *static* + * `export … from 'pdfnative'`, so if the peer is not installed at all the module + * graph fails to resolve and this function is never reached — you get + * `ERR_MODULE_NOT_FOUND` at import time instead. That is already an unambiguous + * diagnosis, so we do not contort the architecture to route it through here. + * What `doctor()` does catch is the subtler case: an engine that resolves but is + * **older than 1.6.0**, which under a bundler or CJS interop yields an + * `undefined` export rather than a link error. * * @packageDocumentation */ @@ -94,8 +102,9 @@ function reactCheck(): DoctorCheck { function engineCheck(): DoctorCheck { return check( 'pdfnative', - `The pdfnative peer dependency must be installed at ${REQUIRED_ENGINE} or later ` - + '(probed via a capability that first ships in 1.6.0).', + `The pdfnative peer dependency must be at ${REQUIRED_ENGINE} or later (probed via a ` + + 'capability that first ships in 1.6.0). A peer that is absent entirely fails ' + + 'earlier, at module resolution.', () => { const present = typeof estimateChartHeight === 'function'; return present @@ -143,7 +152,8 @@ function blobCheck(): DoctorCheck { /** * Run every environment check and return a structured report. * - * Never throws. `ok` is `false` when any check has status `'error'`. + * Never throws. `ok` is `false` when any check has status `'error'`. See the + * module docs for the one case it cannot reach (a peer that is absent entirely). * * @example * ```ts diff --git a/src/lint.ts b/src/lint.ts index b3da631..c5855c3 100644 --- a/src/lint.ts +++ b/src/lint.ts @@ -78,6 +78,38 @@ export interface LintOptions extends RenderOptions { const MAX_CHART_POINTS = 10_000; +/** + * Every rule this module can actually emit. + * + * The registry alone cannot catch a rule that is *declared but never + * implemented*: such a code would ship into `schema('lint-report')` and + * `capabilityManifest().lintRules`, and an agent would branch on a finding that + * can never arrive. `tests/lint.test.tsx` asserts this list equals + * `LINT_RULE_CODES`, closing the direction the type system cannot. + * + * Keep it in sync when adding a rule — the test will tell you if you forget. + */ +export const EMITTED_LINT_RULES: readonly LintRuleCode[] = [ + 'L_EMPTY_DOCUMENT', + 'L_IMAGE_ALT', + 'L_CHART_ALT', + 'L_TABLE_HEADERS', + 'L_HEADING_HIERARCHY', + 'L_FIELD_LABEL', + 'L_LINK_TEXT', + 'L_TAGGED_NO_FONTS', + 'L_TAGGED_ENCRYPTED', + 'L_ATTACHMENTS_NEED_PDFA3', + 'L_MAX_BLOCKS', + 'L_MAX_BLOCKS_EXCEEDED', + 'L_CHART_EMPTY', + 'L_CHART_SERIES', + 'L_CHART_CATEGORIES', + 'L_CHART_VALUES', + 'L_CHART_POINTS', + 'L_OVERFLOW', +]; + function finding( code: LintRuleCode, message: string, @@ -105,6 +137,28 @@ function lintChart(block: ChartBlock, index: number, out: LintFinding[]): void { ); } + // The engine throws on an empty series list or an empty value array, so + // report it here rather than letting the render blow up. + if (series.length === 0) { + out.push( + finding('L_CHART_EMPTY', `Chart #${index} has no series.`, { + blockIndex: index, + hint: 'Supply at least one { label, values } series.', + }), + ); + } + for (const s of series) { + if (s.values.length === 0) { + out.push( + finding( + 'L_CHART_EMPTY', + `Chart #${index} series "${s.label}" has no values.`, + { blockIndex: index, hint: 'Every series needs at least one value.' }, + ), + ); + } + } + const isRadial = chartType === 'pie' || chartType === 'donut'; if (isRadial && series.length !== 1) { out.push( @@ -131,8 +185,9 @@ function lintChart(block: ChartBlock, index: number, out: LintFinding[]): void { ); } - const bad = s.values.find((v) => !Number.isFinite(v)); - if (bad !== undefined) { + // `.some`, not `.find`: when the offending value *is* `undefined`, + // `find` returns `undefined` and an `!== undefined` check silently passes. + if (s.values.some((v) => !Number.isFinite(v))) { out.push( finding( 'L_CHART_VALUES', @@ -168,11 +223,16 @@ function lintBlocks(blocks: readonly DocumentBlock[], out: LintFinding[]): void blocks.forEach((block, index) => { switch (block.type) { case 'heading': { - if (lastHeadingLevel > 0 && block.level > lastHeadingLevel + 1) { + // A document whose *first* heading is h2 or h3 skips a level just + // as surely as one that jumps mid-document — WCAG 1.3.1 treats + // both as a broken outline. + if (block.level > lastHeadingLevel + 1) { out.push( finding( 'L_HEADING_HIERARCHY', - `Heading jumps from level ${String(lastHeadingLevel)} to ${String(block.level)} ("${block.text}").`, + lastHeadingLevel === 0 + ? `The first heading is level ${String(block.level)}; a document should start at level 1 ("${block.text}").` + : `Heading jumps from level ${String(lastHeadingLevel)} to ${String(block.level)} ("${block.text}").`, { blockIndex: index, hint: `Use level ${String(lastHeadingLevel + 1)}, or add the intermediate heading.`, @@ -306,11 +366,20 @@ function lintDocumentParams(params: DocumentParams, out: LintFinding[]): void { } const maxBlocks = layout?.maxBlocks; - if (maxBlocks !== undefined && params.blocks.length > maxBlocks * 0.9) { + const blockCount = params.blocks.length; + if (maxBlocks !== undefined && blockCount > maxBlocks) { + out.push( + finding( + 'L_MAX_BLOCKS_EXCEEDED', + `${String(blockCount)} blocks exceeds the maxBlocks ceiling of ${String(maxBlocks)}.`, + { hint: 'Raise layout.maxBlocks, or split the document.' }, + ), + ); + } else if (maxBlocks !== undefined && blockCount > maxBlocks * 0.9) { out.push( finding( 'L_MAX_BLOCKS', - `${String(params.blocks.length)} blocks is within 10% of the maxBlocks ceiling (${String(maxBlocks)}).`, + `${String(blockCount)} blocks is within 10% of the maxBlocks ceiling (${String(maxBlocks)}).`, { hint: 'Raise layout.maxBlocks, or split the document.' }, ), ); diff --git a/src/manifest.ts b/src/manifest.ts index fe5abbb..1d37a54 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -17,6 +17,7 @@ import { BLOCK_REGISTRY, + CLIENT_COMPONENT_REGISTRY, COMPONENT_REGISTRY, LINT_RULES, type LintRuleCode, @@ -85,6 +86,10 @@ export interface CapabilityManifest { readonly network: 'none'; }; readonly components: readonly ManifestComponent[]; + /** Preview/download components. Client-side; they consume a document rather than describing one. */ + readonly clientComponents: readonly { readonly name: string; readonly summary: string }[]; + /** Error classes exported for `instanceof` checks. */ + readonly errorClasses: readonly string[]; readonly specBlocks: readonly ManifestBlock[]; readonly entrypoints: readonly ManifestEntrypoint[]; readonly errorCodes: readonly ErrorCodeValue[]; @@ -93,11 +98,13 @@ export interface CapabilityManifest { } /** - * The callable surface. + * The callable surface — **every** exported function, not a curated subset. * - * Kept adjacent to the registries it accompanies; `tests/manifest.test.ts` - * asserts every `name` here is a real export of `src/index.ts`, which is what - * stops this list from going stale. + * `tests/agent.test.tsx` locks it in *both* directions: every name here must be + * a real export of `src/index.ts`, and every exported function of the barrel + * must appear here. The second direction is the one that matters — a manifest + * that claims to describe "everything this package can do" while omitting a + * third of the API is worse than no manifest, because an agent trusts it. */ const ENTRYPOINTS: readonly ManifestEntrypoint[] = [ { @@ -228,6 +235,118 @@ const ENTRYPOINTS: readonly ManifestEntrypoint[] = [ summary: 'Register and load font modules in one step.', kind: 'async', }, + { + name: 'renderSpecToBlob', + signature: '(spec, options?) => Blob', + summary: 'Render a DocSpec to an application/pdf Blob.', + kind: 'sync', + }, + { + name: 'renderSpecToStream', + signature: '(spec, options?) => AsyncGenerator', + summary: 'Render a DocSpec to a constant-memory byte stream.', + kind: 'stream', + }, + { + name: 'renderSpecToFile', + signature: '(spec, path, options?) => Promise', + summary: 'Render a DocSpec and write it to a file.', + kind: 'async', + nodeOnly: true, + }, + { + name: 'renderSpecToFileStream', + signature: '(spec, path, options?) => Promise', + summary: 'Stream a DocSpec to a file with constant memory.', + kind: 'async', + nodeOnly: true, + }, + { + name: 'schemaId', + signature: '(subject?) => string', + summary: 'The versioned $id for a schema subject. Compare to detect contract drift.', + kind: 'sync', + }, + { + name: 'docSpecSchema', + signature: '() => JsonSchema', + summary: 'Backward-compatible alias for schema("doc-spec").', + kind: 'sync', + }, + { + name: 'docSpecSchemaId', + signature: '() => string', + summary: 'Backward-compatible alias for schemaId("doc-spec").', + kind: 'sync', + }, + { + name: 'toErrorEnvelope', + signature: '(err: unknown) => ErrorEnvelope', + summary: + 'Normalise any thrown value to { ok: false, error: { code, message } }, so a ' + + 'caller only ever handles one shape.', + kind: 'sync', + }, + { + name: 'fromUrl', + signature: '(url, init?) => Promise', + summary: 'Fetch image bytes for .', + kind: 'async', + }, + { + name: 'fromBase64', + signature: '(payload) => Uint8Array', + summary: 'Decode base64 or a data: URI into image bytes for .', + kind: 'sync', + }, + { + name: 'registerFont', + signature: '(lang, loader) => void', + summary: 'Register a single font-data loader with the engine.', + kind: 'sync', + }, + { + name: 'registerFonts', + signature: '(map) => void', + summary: 'Register several font-data loaders with the engine.', + kind: 'sync', + }, + { + name: 'loadFontData', + signature: '(lang) => Promise', + summary: 'Load a previously registered font.', + kind: 'async', + }, + { + name: 'validateFontData', + signature: '(data) => FontValidationResult', + summary: 'Opt-in structural check on a custom font module before embedding it.', + kind: 'sync', + }, + { + name: 'initNodeCompression', + signature: '() => void', + summary: 'Enable zlib compression under Node.', + kind: 'sync', + }, + { + name: 'downloadBlob', + signature: '(blob, fileName) => void', + summary: 'Trigger a browser download. Browser only.', + kind: 'sync', + }, + { + name: 'usePdf', + signature: '(element, options?) => UsePdfResult', + summary: 'React hook: live blob-URL preview. Client only.', + kind: 'sync', + }, + { + name: 'usePdfStream', + signature: '(element, options?) => UsePdfStreamResult', + summary: 'React hook: stream factory. Client only.', + kind: 'sync', + }, { name: 'validateIssueDraft', signature: '(markdown) => GovernanceValidation', @@ -240,6 +359,12 @@ const ENTRYPOINTS: readonly ManifestEntrypoint[] = [ summary: 'The machine-readable human-in-the-loop policy this repo enforces.', kind: 'sync', }, + { + name: 'agentRulesText', + signature: '() => string', + summary: 'The agent-facing protocol as text, for use without a repository checkout.', + kind: 'sync', + }, ]; /** @@ -273,6 +398,11 @@ export function capabilityManifest(): CapabilityManifest { summary: c.summary, ...('aliases' in c ? { aliases: c.aliases } : {}), })), + clientComponents: CLIENT_COMPONENT_REGISTRY.map((c) => ({ + name: c.name, + summary: c.summary, + })), + errorClasses: ['PdfReactError', 'PdfStructureError'], specBlocks: BLOCK_REGISTRY.map((b) => ({ kinds: [...b.kinds], tuple: b.tuple, diff --git a/src/registry.ts b/src/registry.ts index 058b2e3..0bb4b41 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -281,6 +281,32 @@ export const COMPONENT_REGISTRY = [ /** A read-only view of a {@link COMPONENT_REGISTRY} entry. */ export type ComponentDescriptor = (typeof COMPONENT_REGISTRY)[number]; +/** + * Client-side components — preview and download helpers. + * + * Kept separate from {@link COMPONENT_REGISTRY} because they emit no host tag + * and author no block: they *consume* a document rather than describing one. + * Folding them in would also defeat the `HostTag` exhaustiveness lock. + * + * These modules carry `'use client'` in source. Note that the published bundle + * is a single file with no directives — see `docs/SERVER.md` for what that + * means in a React Server Components app. + */ +export const CLIENT_COMPONENT_REGISTRY = [ + { + name: 'PDFViewer', + summary: 'Live iframe preview of a document.', + }, + { + name: 'PDFDownloadLink', + summary: 'Anchor that downloads a rendered document.', + }, + { + name: 'BlobProvider', + summary: 'Render-prop giving access to the blob, URL, loading and error state.', + }, +] as const satisfies readonly { readonly name: string; readonly summary: string }[]; + // ───────────────────────────────────────────────────────────────────────────── // Lint-rule registry // ───────────────────────────────────────────────────────────────────────────── @@ -347,6 +373,14 @@ export const LINT_RULES = { severity: 'warning', description: 'The block count is within 10% of the configured maxBlocks ceiling.', }, + L_MAX_BLOCKS_EXCEEDED: { + severity: 'error', + description: 'The block count is past the configured maxBlocks ceiling.', + }, + L_CHART_EMPTY: { + severity: 'error', + description: 'A chart has no series, or a series has no values.', + }, L_CHART_SERIES: { severity: 'error', description: 'A pie or donut chart must have exactly one series.', diff --git a/src/response.ts b/src/response.ts index b0fb1da..d9f2cae 100644 --- a/src/response.ts +++ b/src/response.ts @@ -55,7 +55,16 @@ export interface PdfResponseOptions extends RenderOptions { */ function contentDisposition(disposition: 'inline' | 'attachment', fileName: string): string { const ascii = fileName.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_'); - const encoded = encodeURIComponent(fileName); + + // RFC 8187 ext-values allow only `attr-char`. `encodeURIComponent` leaves + // ' ( ) ! * ~ unescaped — and a raw apostrophe is actively harmful, since a + // strict parser splits the ext-value on ' (charset'lang'value) and would + // mis-read the filename. Percent-escape the stragglers. + const encoded = encodeURIComponent(fileName).replace( + /['()!*~]/g, + (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`, + ); + const base = `${disposition}; filename="${ascii}"`; return encoded === ascii ? base : `${base}; filename*=UTF-8''${encoded}`; } diff --git a/src/spec/schema.ts b/src/spec/schema.ts index 8930e4a..4089ebf 100644 --- a/src/spec/schema.ts +++ b/src/spec/schema.ts @@ -417,12 +417,28 @@ const BLOCK_SCHEMAS = { * so the schema and `validateSpec` can never disagree about how long a tuple is. */ function blockDefs(): readonly JsonSchema[] { - return BLOCK_REGISTRY.map((entry) => ({ - ...BLOCK_SCHEMAS[entry.id](), - description: entry.summary, - minItems: entry.minItems, - maxItems: entry.maxItems, - })); + return BLOCK_REGISTRY.map((entry) => { + const built = BLOCK_SCHEMAS[entry.id](); + const prefixItems = [...(built['prefixItems'] as JsonSchema[])]; + + // Overwrite the kind discriminator from the registry rather than trusting + // the builder's hardcoded literal. Without this the `satisfies` lock only + // covers *group ids*: a new kind could be added to `BLOCK_REGISTRY` and + // accepted by `validateSpec` while the schema still advertised the old + // enum — the two claiming to be derived from one source while disagreeing. + prefixItems[0] = + entry.kinds.length === 1 + ? { const: entry.kinds[0] } + : { enum: [...entry.kinds] }; + + return { + ...built, + prefixItems, + description: entry.summary, + minItems: entry.minItems, + maxItems: entry.maxItems, + }; + }); } /** @@ -623,7 +639,16 @@ function lintReportSchema(): JsonSchema { $defs: { rules: { description: 'The full rule registry: code → severity + description.', - const: LINT_RULES, + // A fresh copy, never the live `LINT_RULES` object. Schema tooling + // routinely walks and annotates a returned document ($id rewriting, + // $ref dereferencing); handing out the registry by reference would + // let that mutate every subsequent lint severity process-wide. + const: Object.fromEntries( + LINT_RULE_CODES.map((code) => [ + code, + { severity: LINT_RULES[code].severity, description: LINT_RULES[code].description }, + ]), + ), }, }, }; @@ -658,6 +683,7 @@ function specValidationSchema(): JsonSchema { 'V_PAYLOAD_TYPE', 'V_OPTS_TYPE', 'V_UNKNOWN_FIELD', + 'V_TOO_DEEP', ], }, severity: { enum: ['error', 'warning'] }, @@ -772,14 +798,18 @@ const SUBJECT_SCHEMAS = { * ``` */ export function schema(subject: SchemaSubject = 'doc-spec'): JsonSchema { - const build = SUBJECT_SCHEMAS[subject] as (() => JsonSchema) | undefined; - if (build === undefined) { + // `Object.hasOwn`, not `SUBJECT_SCHEMAS[subject] !== undefined`: this is the + // one API explicitly designed to be called with a model-generated string, + // and a plain-object lookup would happily resolve 'toString' or + // 'constructor' through Object.prototype and return something that is not a + // schema at all. + if (!Object.hasOwn(SUBJECT_SCHEMAS, subject)) { throw new PdfReactError( `Unknown schema subject ${JSON.stringify(subject)}. Valid subjects: ${SCHEMA_SUBJECTS.join(', ')}.`, ErrorCode.INPUT, ); } - return build(); + return SUBJECT_SCHEMAS[subject](); } // Re-export the type for convenience at the schema entry point. diff --git a/src/spec/validate.ts b/src/spec/validate.ts index 28b242d..65e5ca2 100644 --- a/src/spec/validate.ts +++ b/src/spec/validate.ts @@ -46,8 +46,20 @@ export const SpecCode = { OPTS_TYPE: 'V_OPTS_TYPE', /** An unrecognised top-level field (warning — forward compatibility). */ UNKNOWN_FIELD: 'V_UNKNOWN_FIELD', + /** Page nesting exceeded {@link MAX_NESTING_DEPTH}. */ + TOO_DEEP: 'V_TOO_DEEP', } as const; +/** + * Maximum `['page', …]` nesting accepted before validation stops descending. + * + * `validateSpec` is the gate for *untrusted* input, so it must not be possible + * to exhaust the call stack with a deeply nested payload — a few tens of + * kilobytes of JSON would otherwise take the process down. Real documents nest + * one level; 64 is far beyond any legitimate use. + */ +export const MAX_NESTING_DEPTH = 64; + /** The value type of {@link SpecCode}. */ export type SpecCodeValue = (typeof SpecCode)[keyof typeof SpecCode]; @@ -74,7 +86,7 @@ export interface SpecValidation { } /** Every recognised top-level `DocSpec` field. */ -const KNOWN_FIELDS: readonly string[] = [ +const KNOWN_FIELDS = [ 'title', 'footerText', 'metadata', @@ -88,7 +100,21 @@ const KNOWN_FIELDS: readonly string[] = [ 'attachments', 'tagged', 'blocks', -]; +] as const satisfies readonly (keyof DocSpec)[]; + +/** + * Compile-time lock: {@link KNOWN_FIELDS} must cover `DocSpec` exactly. + * + * Without this, adding a field to `DocSpec` would make every well-formed spec + * using it emit a spurious `V_UNKNOWN_FIELD` warning — silently, since the + * validator would still return `ok: true`. + */ +type Equals = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type Assert = T; +export type KnownFieldsAreExhaustive = Assert< + Equals<(typeof KNOWN_FIELDS)[number], keyof DocSpec> +>; /** kind → descriptor, flattened from the registry once at module load. */ const BY_KIND = new Map( @@ -123,7 +149,17 @@ function describe(value: unknown): string { return typeof value; } -function validateBlock(block: unknown, path: string, out: SpecFinding[]): void { +function validateBlock(block: unknown, path: string, out: SpecFinding[], depth: number): void { + if (depth > MAX_NESTING_DEPTH) { + out.push({ + code: SpecCode.TOO_DEEP, + severity: 'error', + path, + message: `Page nesting exceeds the ${String(MAX_NESTING_DEPTH)}-level limit; not descending further.`, + }); + return; + } + if (!Array.isArray(block) || block.length === 0) { out.push({ code: SpecCode.BLOCK_SHAPE, @@ -172,7 +208,7 @@ function validateBlock(block: unknown, path: string, out: SpecFinding[]): void { }); } else if (descriptor.payload === 'blocks') { (payload as readonly unknown[]).forEach((nested, i) => { - validateBlock(nested, `${path}[1][${String(i)}]`, out); + validateBlock(nested, `${path}[1][${String(i)}]`, out, depth + 1); }); } } @@ -220,7 +256,7 @@ export function validateSpec(spec: unknown): SpecValidation { } for (const key of Object.keys(spec)) { - if (!KNOWN_FIELDS.includes(key)) { + if (!(KNOWN_FIELDS as readonly string[]).includes(key)) { findings.push({ code: SpecCode.UNKNOWN_FIELD, severity: 'warning', @@ -240,7 +276,7 @@ export function validateSpec(spec: unknown): SpecValidation { }); } else { blocks.forEach((block, i) => { - validateBlock(block, `blocks[${String(i)}]`, findings); + validateBlock(block, `blocks[${String(i)}]`, findings, 0); }); } diff --git a/tests/agent.test.tsx b/tests/agent.test.tsx index 3638460..b3e6a44 100644 --- a/tests/agent.test.tsx +++ b/tests/agent.test.tsx @@ -99,6 +99,34 @@ describe('capabilityManifest', () => { } }); + it('advertises EVERY callable the barrel exports — the direction that matters', () => { + // A manifest that claims to describe "everything this package can do" + // while omitting part of the API is worse than none, because an agent + // trusts it. This is the reverse of the check above. + const named = new Set([ + ...manifest.entrypoints.map((e) => e.name), + ...manifest.components.flatMap((c) => [c.name, ...(c.aliases ?? [])]), + ...manifest.clientComponents.map((c) => c.name), + ...manifest.errorClasses, + ]); + + const exported = Object.entries(barrel) + .filter(([, value]) => typeof value === 'function') + .map(([name]) => name); + + const undocumented = exported.filter((name) => !named.has(name)); + expect(undocumented, `undocumented exports: ${undocumented.join(', ')}`).toEqual([]); + }); + + it('lists the client components and error classes it claims', () => { + expect(manifest.clientComponents.map((c) => c.name)).toEqual([ + 'PDFViewer', + 'PDFDownloadLink', + 'BlobProvider', + ]); + expect(manifest.errorClasses).toEqual(['PdfReactError', 'PdfStructureError']); + }); + it('advertises only components that really exist in the barrel', () => { for (const component of manifest.components) { expect(barrel, `component ${component.name}`).toHaveProperty(component.name); @@ -249,4 +277,47 @@ describe('validateSpec', () => { expect(() => validateSpec(input)).not.toThrow(); } }); + + it('bounds page nesting instead of exhausting the call stack', () => { + // validateSpec is the gate for untrusted input. Before the depth bound, a + // ~44 kB payload took the process down with a RangeError — while the + // JSDoc promised it never throws, so callers write `if (!ok)`, not + // try/catch. + let spec: { blocks: unknown[] } = { blocks: [['p', 'leaf']] }; + for (let i = 0; i < 5000; i += 1) spec = { blocks: [['page', spec.blocks]] }; + + let result: ReturnType | undefined; + expect(() => { + result = validateSpec(spec); + }).not.toThrow(); + + expect(result?.ok).toBe(false); + expect(result?.errors.some((e) => e.code === 'V_TOO_DEEP')).toBe(true); + }); + + it('accepts legitimate nesting well below the bound', () => { + let spec: { blocks: unknown[] } = { blocks: [['p', 'leaf']] }; + for (let i = 0; i < 5; i += 1) spec = { blocks: [['page', spec.blocks]] }; + expect(validateSpec(spec).ok).toBe(true); + }); + + it('rejects Object.prototype keys as schema subjects', () => { + // schema() is the one API designed to be called with a model-generated + // string. A plain-object lookup resolves 'toString' through the + // prototype chain and returns something that is not a schema at all. + for (const key of ['toString', 'constructor', 'valueOf', '__proto__', 'hasOwnProperty']) { + expect(() => barrel.schema(key as never), key).toThrowError( + /Unknown schema subject/, + ); + } + }); + + it('does not hand out a live reference to the lint registry', () => { + const before = barrel.LINT_RULES.L_IMAGE_ALT.severity; + const doc = barrel.schema('lint-report') as { + $defs: { rules: { const: Record } }; + }; + doc.$defs.rules.const['L_IMAGE_ALT'].severity = 'error'; + expect(barrel.LINT_RULES.L_IMAGE_ALT.severity).toBe(before); + }); }); diff --git a/tests/lint.test.tsx b/tests/lint.test.tsx index 31d04a3..ac6c5ab 100644 --- a/tests/lint.test.tsx +++ b/tests/lint.test.tsx @@ -18,6 +18,7 @@ import { lintSpec, LINT_RULE_CODES, } from '../src/index.js'; +import { EMITTED_LINT_RULES } from '../src/lint.js'; import type { ChartSeries, LintReport, LintRuleCode } from '../src/index.js'; const PIXEL = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); @@ -122,6 +123,18 @@ describe('document-level rules', () => { blocks: Array.from({ length: 10 }, (_, i) => ['p', `line ${String(i)}`] as const), }); expect(codes(report)).toContain('L_MAX_BLOCKS'); + expect(report.ok).toBe(true); + }); + + it('L_MAX_BLOCKS_EXCEEDED — past the ceiling is an error, not an "approaching" warning', () => { + const report = lintSpec({ + layout: { maxBlocks: 10 }, + blocks: Array.from({ length: 50 }, (_, i) => ['p', `line ${String(i)}`] as const), + }); + expect(codes(report)).toContain('L_MAX_BLOCKS_EXCEEDED'); + expect(codes(report)).not.toContain('L_MAX_BLOCKS'); + expect(report.ok).toBe(false); + expect(report.findings[0].message).toContain('exceeds'); }); }); @@ -151,6 +164,14 @@ describe('accessibility rules', () => { expect(codes(report)).toContain('L_HEADING_HIERARCHY'); }); + it('L_HEADING_HIERARCHY — flags a document whose first heading is too deep', () => { + expect(codes(lintSpec({ blocks: [['h3', 'Deep']] }))).toContain('L_HEADING_HIERARCHY'); + expect(codes(lintSpec({ blocks: [['h2', 'Deep']] }))).toContain('L_HEADING_HIERARCHY'); + expect(codes(lintSpec({ blocks: [['h1', 'Fine']] }))).not.toContain( + 'L_HEADING_HIERARCHY', + ); + }); + it('L_HEADING_HIERARCHY — accepts descending back to a shallower level', () => { const report = lintSpec({ blocks: [['h1', 'A'], ['h2', 'B'], ['h3', 'C'], ['h1', 'D'], ['h2', 'E']], @@ -229,6 +250,44 @@ describe('chart rules — pre-empting engine failures', () => { expect(codes(report)).toContain('L_CHART_CATEGORIES'); }); + it('L_CHART_EMPTY — rejects a chart with no series, or a series with no values', () => { + // Both throw inside the engine at render time. + expect( + codes(lintSpec({ blocks: [['chart', { chartType: 'bar', series: [], altText: 'x' }]] })), + ).toContain('L_CHART_EMPTY'); + + expect( + codes( + lintSpec({ + blocks: [ + [ + 'chart', + { chartType: 'bar', series: [{ label: 'A', values: [] }], altText: 'x' }, + ], + ], + }), + ), + ).toContain('L_CHART_EMPTY'); + }); + + it('L_CHART_VALUES — catches an undefined value, not just null', () => { + // `.find()` returns undefined for a *found* undefined, so an + // `!== undefined` guard silently passed this. + const report = lintSpec({ + blocks: [ + [ + 'chart', + { + chartType: 'bar', + series: [{ label: 'A', values: [1, undefined as unknown as number] }], + altText: 'x', + }, + ], + ], + }); + expect(codes(report)).toContain('L_CHART_VALUES'); + }); + it('L_CHART_VALUES — rejects non-finite values', () => { const report = lintSpec({ blocks: [ @@ -286,6 +345,23 @@ describe('geometry rules', () => { expect(codes(lintSpec(spec))).not.toContain('L_OVERFLOW'); }); + it('L_OVERFLOW — flags a block that runs past the bottom margin', () => { + // Exercises the `belowFloor` arm, which is the y-axis-sign-sensitive one: + // pdfnative's y increases upward, so a block occupies [top - height, top]. + // Invert that comparison and this test fails. + const report = lintSpec( + { + layout: { pageHeight: 400, margins: { t: 40, r: 40, b: 40, l: 40 } }, + blocks: [ + ['p', 'filler'], + ['chart', { chartType: 'bar', series: SERIES, height: 300, altText: 'x' }], + ], + }, + { overflow: true }, + ); + expect(codes(report)).toContain('L_OVERFLOW'); + }); + it('L_OVERFLOW — flags a block taller than the content box', () => { const report = lintSpec( { @@ -311,6 +387,13 @@ describe('report shape', () => { expect(codes(report)).toEqual(['L_IMAGE_ALT']); }); + it('every registered rule is actually implemented', () => { + // The registry cannot catch a rule that is declared but never emitted: + // such a code ships into schema('lint-report') and the capability + // manifest, and an agent branches on a finding that can never arrive. + expect([...EMITTED_LINT_RULES].sort()).toEqual([...LINT_RULE_CODES].sort()); + }); + it('only reports codes that exist in the registry', () => { const report = lintDocument( diff --git a/tests/registry.test.ts b/tests/registry.test.ts index d95881f..e0f3735 100644 --- a/tests/registry.test.ts +++ b/tests/registry.test.ts @@ -80,12 +80,23 @@ describe('BLOCK_REGISTRY', () => { const defs = docSpecSchema()['$defs'] as { block: { oneOf: Record[] } }; expect(defs.block.oneOf).toHaveLength(BLOCK_REGISTRY.length); - // Arity and description come from the registry, not from the builders. + // Arity, description AND the kind discriminator come from the registry, + // not from the builders. The discriminator is the one that used to drift: + // the `satisfies` lock covers group ids, not kinds, so a new kind could + // be accepted by validateSpec while the schema still advertised the old + // enum — both claiming to derive from one source while disagreeing. defs.block.oneOf.forEach((branch, i) => { const entry = BLOCK_REGISTRY[i]; expect(branch['minItems']).toBe(entry.minItems); expect(branch['maxItems']).toBe(entry.maxItems); expect(branch['description']).toBe(entry.summary); + + const discriminator = (branch['prefixItems'] as Record[])[0]; + const advertised = + 'const' in discriminator + ? [discriminator['const']] + : (discriminator['enum'] as string[]); + expect(advertised, `kinds for ${entry.id}`).toEqual([...entry.kinds]); }); }); @@ -149,6 +160,8 @@ describe('LINT_RULES', () => { 'L_TAGGED_ENCRYPTED', 'L_ATTACHMENTS_NEED_PDFA3', 'L_MAX_BLOCKS', + 'L_MAX_BLOCKS_EXCEEDED', + 'L_CHART_EMPTY', 'L_CHART_SERIES', 'L_CHART_CATEGORIES', 'L_CHART_VALUES', diff --git a/tests/response.test.tsx b/tests/response.test.tsx index b20c882..c12880f 100644 --- a/tests/response.test.tsx +++ b/tests/response.test.tsx @@ -60,6 +60,27 @@ describe('renderToResponse', () => { expect(disposition).toContain("filename*=UTF-8''facture-%C3%A9crite.pdf"); }); + it('percent-escapes characters that are not RFC 8187 attr-char', async () => { + // encodeURIComponent leaves ' ( ) ! * ~ alone. A raw apostrophe is + // actively harmful: a strict parser splits the ext-value on ' as + // charset'lang'value and mis-reads the filename. + const response = await renderToResponse(DOC, { fileName: "O'Néill (final)!.pdf" }); + const disposition = response.headers.get('content-disposition') ?? ''; + const ext = /filename\*=UTF-8''(.+)$/.exec(disposition)?.[1] ?? ''; + + expect(ext).not.toContain("'"); + expect(ext).not.toContain('('); + expect(ext).not.toContain(')'); + expect(ext).not.toContain('!'); + expect(decodeURIComponent(ext)).toBe("O'Néill (final)!.pdf"); + }); + + it('releases the generator when the client disconnects', async () => { + const response = await renderToResponse(DOC); + expect(response.body).not.toBeNull(); + await expect(response.body?.cancel()).resolves.toBeUndefined(); + }); + it('sets content-length only in buffered mode', async () => { const streamed = await renderToResponse(DOC); expect(streamed.headers.get('content-length')).toBeNull(); From 5d3c742d423f69c593233ee941d2d75ab416a943 Mon Sep 17 00:00:00 2001 From: Kuzino <129803615+Nizoka@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:59:21 +0200 Subject: [PATCH 3/5] fix: packaging, ecosystem alignment, and the last of the doc drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third review round — five independent audits (architecture, documentation, engine-1.6.0 gap, ecosystem state of the art, final doc pass). Every confirmed finding is fixed. Three of them were defects I introduced; one was a shipped- broken feature. Packaging (the serious one): - dist emitted `import('fs/promises')` without the `node:` prefix, so Deno and Cloudflare nodejs_compat could not resolve it — the edge runtimes four documents advertise. Cause is a rollup pass inside tsup that survives platform, target, external and banner alike; all four were measured. scripts/postbuild.mjs restores the prefix and fails the build if the expected shape is absent, and ci.yml now bundles both artifacts the way a non-Node bundler would. - New `pdfnative-react/client` subpath, built separately with 'use client' applied — the directive never survived into the single-file bundle, so RSC users needed a hand-written wrapper while the README claimed otherwise. - `import { version }` pulled the whole React reconciler (10 137 bytes for a string constant). /* @__PURE__ */ on five top-level side effects; now 3 216 bytes with no reconciler, and postbuild fails on regression. Correctness: - L_MAX_BLOCKS could not fire on the engine's default ceiling: it checked only an explicit layout.maxBlocks, while the engine applies DEFAULT_MAX_BLOCKS unconditionally and throws. - "Six rules pre-empt an engine throw" was wrong — it is eight. L_TAGGED_ENCRYPTED and L_MAX_BLOCKS_EXCEEDED both throw; the docs listed them as safe. - schema('manifest') described 10 of the manifest's 13 properties, with no test. Anti-drift, completed: - ChartProps is now compile-locked to ChartBlock (Charts v2 will add fields). - toBlock has a `never` exhaustiveness guard; the DocSpec side had one since 1.0. - Both verified destructively. - tests/compile-snapshot.test.tsx: a golden snapshot of the compiled model. Ecosystem: - ci.yml, codeql.yml and scorecard.yml aligned on pdfnative-cli — SHA-pinned actions, codeql v4, concurrency, timeouts. scorecard.yml job permissions were dropping contents/actions to none (job-level replaces workflow-level), which likely broke that workflow silently. - Runtime audit is now blocking and clean; dev audit advisory with the reason stated. .nvmrc, CONTRIBUTING and publish.yml no longer say Node 20. Documentation: - The doctor() claim retracted last round was still live in five documents. - Security section: pre-1.6.0 encrypted files left outline titles, link URIs and metadata in clear text, and AES-256 output was not ISO 32000-2 compliant. - Font-weight table — the colour-emoji module is now 4.0 MB, and this is the only package in the ecosystem targeting a browser bundle. 219 -> 224 tests. Coverage 94.8/86.0/97.8/95.8 (thresholds 85/80/85/85). --- .github/dependabot.yml | 10 + .../instructions/components.instructions.md | 26 +- .github/instructions/spec.instructions.md | 5 +- .github/workflows/ci.yml | 60 ++++- .github/workflows/codeql.yml | 102 +++++--- .github/workflows/publish.yml | 2 +- .github/workflows/scorecard.yml | 93 +++---- .nvmrc | 2 +- CHANGELOG.md | 28 +- CONTRIBUTING.md | 3 +- README.md | 38 ++- docs/AGENT_CONTRACT.md | 24 +- docs/KNOWLEDGE_BASE.md | 21 +- docs/LINTING.md | 16 +- docs/RECIPES.md | 11 +- docs/SERVER.md | 17 +- llms.txt | 14 +- package-lock.json | 84 +++--- package.json | 16 +- release-notes/draft/PR-v1.1.0.md | 90 +++++-- release-notes/v1.1.0.md | 20 +- samples/agent/error-envelope.tsx | 2 +- samples/quality/lint.tsx | 9 +- samples/server/next-route-handler.tsx | 9 +- scripts/postbuild.mjs | 187 ++++++++++++++ src/client.ts | 37 +++ src/components.tsx | 18 ++ src/lint.ts | 27 +- src/reconciler/host-config.ts | 13 +- src/reconciler/serialize.ts | 20 +- src/registry.ts | 13 +- src/response.ts | 9 +- src/spec/schema.ts | 73 +++++- src/spec/validate.ts | 2 +- .../compile-snapshot.test.tsx.snap | 244 ++++++++++++++++++ tests/compile-snapshot.test.tsx | 190 ++++++++++++++ tests/lint.test.tsx | 20 ++ tests/schema.test.ts | 13 + tsup.config.ts | 55 +++- 39 files changed, 1362 insertions(+), 261 deletions(-) create mode 100644 scripts/postbuild.mjs create mode 100644 src/client.ts create mode 100644 tests/__snapshots__/compile-snapshot.test.tsx.snap create mode 100644 tests/compile-snapshot.test.tsx diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1560a9a..a63262e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,6 +6,10 @@ updates: interval: weekly day: monday open-pull-requests-limit: 10 + labels: + - dependencies + commit-message: + prefix: 'chore(deps):' ignore: - dependency-name: typescript update-types: ['version-update:semver-major'] @@ -30,4 +34,10 @@ updates: directory: '/' schedule: interval: weekly + day: monday open-pull-requests-limit: 5 + labels: + - dependencies + - ci + commit-message: + prefix: 'chore(ci):' diff --git a/.github/instructions/components.instructions.md b/.github/instructions/components.instructions.md index b3dda69..faae59f 100644 --- a/.github/instructions/components.instructions.md +++ b/.github/instructions/components.instructions.md @@ -21,8 +21,24 @@ side-effect-free factory that emits a lowercase **host tag** via the typed - Keep aliases intentional: `Text = Paragraph`, `Toc = TableOfContents`. - Every exported component and its props interface needs a TSDoc comment. -When you add a component, also: add a host tag in `reconciler/nodes.ts`, a case -in `reconciler/serialize.ts`, an export in `src/index.ts`, and a test in -`tests/compile.test.tsx`. If the component adds authoring capability, mirror it -in the `DocSpec` grammar + schema (`src/spec/`) to keep parity. (Composites like -`Section` skip the nodes/serialize steps.) +When you add a component: + +1. `reconciler/nodes.ts` — add the host tag. +2. `reconciler/serialize.ts` — add the `case` in `toBlock`. **Compiler-enforced:** + a missing case fails `npm run typecheck` on the `const exhaustive: never` guard. +3. **`src/registry.ts`** — add the `COMPONENT_REGISTRY` entry. **Compiler-enforced:** + `ComponentRegistryIsExhaustive` fails typecheck if a `HostTag` has no component. +4. `src/index.ts` — export the component and its props type. +5. `tests/compile.test.tsx` — a serialization test, plus the ordered list in + `tests/registry.test.ts`. +6. If it adds authoring capability, mirror it in the `DocSpec` grammar + schema + (`src/spec/`) — see `spec.instructions.md` for that checklist — and refresh + `tests/compile-snapshot.test.tsx` deliberately, reading the diff. + +Composites like `Section` skip steps 1–3: they emit no host tag, and +`COMPONENT_REGISTRY` records them with `tag: null`. A test asserts `Section` is +the *only* one. + +Client-side components (`PDFViewer`, `PDFDownloadLink`, `BlobProvider`) go in +`CLIENT_COMPONENT_REGISTRY` instead, and must be re-exported from +`src/client.ts` so they reach the `pdfnative-react/client` subpath. diff --git a/.github/instructions/spec.instructions.md b/.github/instructions/spec.instructions.md index d21f001..676b3a7 100644 --- a/.github/instructions/spec.instructions.md +++ b/.github/instructions/spec.instructions.md @@ -41,8 +41,9 @@ with far fewer tokens than JSX. It is pure, isomorphic, and side-effect-free. `TableOfContents`) needs an explicit generic (`createElement`), or TS infers `Attributes` and rejects the extra props (TS2769). -When you add a block kind, all ten steps are required — the first five are -enforced by the compiler, so skipping any of them fails `npm run typecheck`: +When you add a block kind, all ten steps are required. Steps **1, 3, 4, 5, 6 and +7** are enforced by the compiler — skipping any of them fails +`npm run typecheck`; the rest are caught by tests: 1. `src/reconciler/nodes.ts` — the host tag. 2. `src/components.tsx` — the component and its props. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 217417d..b9f5082 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,31 +6,48 @@ on: paths-ignore: - '**.md' - 'docs/**' + - '.github/ISSUE_TEMPLATE/**' + - '.github/*.md' + - '.github/FUNDING.yml' - 'LICENSE' + - '.editorconfig' + - '.gitignore' pull_request: branches: [main, master] paths-ignore: - '**.md' - 'docs/**' + - '.github/ISSUE_TEMPLATE/**' + - '.github/*.md' + - '.github/FUNDING.yml' - 'LICENSE' + - '.editorconfig' + - '.gitignore' permissions: contents: read +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + jobs: - build: + ci: name: Lint · Typecheck · Test · Build runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: fail-fast: false matrix: # Node 22 is the floor: the pdfnative engine requires it as of 1.6.0. node-version: [22, 24] + steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: ${{ matrix.node-version }} cache: npm @@ -38,11 +55,20 @@ jobs: - name: Install dependencies run: npm ci - - name: Audit (high severity) - run: npm audit --audit-level=high + # Blocking on what actually ships. The runtime tree is a single dependency + # (react-reconciler); anything high-severity in there is a real + # supply-chain problem and must stop the build. + - name: Audit runtime dependencies (blocking) + run: npm audit --omit=dev --audit-level=high + + # Advisory on the dev tree. It is dominated by transitive pins we do not + # control — eslint still ships minimatch@3 — so a blocking gate here would + # sit red on an issue no consumer is exposed to. Reported, not enforced. + - name: Audit dev dependencies (advisory) continue-on-error: true + run: npm audit --audit-level=high - - name: Typecheck (src + tests + samples) + - name: Type check run: npm run typecheck:all - name: Lint @@ -51,15 +77,35 @@ jobs: - name: Test with coverage run: npm run test:coverage + # Also runs scripts/postbuild.mjs, which repairs and then verifies the + # published artifacts: the `node:` prefix on the dynamic fs import, the + # `'use client'` directive on the client entry only, and that importing + # pure data does not drag in the React reconciler. - name: Build run: npm run build - - name: Verify build artifacts + - name: Verify dist output run: | test -f dist/index.js test -f dist/index.cjs test -f dist/index.d.ts test -f dist/index.d.cts + test -f dist/client.js + test -f dist/client.cjs + test -f dist/client.d.ts + test -f dist/client.d.cts + + # `renderToResponse` advertises Deno, Bun, Edge and Cloudflare Workers. + # A Node `require` cannot catch a specifier a non-Node bundler refuses to + # resolve, so bundle the artifacts the way those runtimes would. + - name: Bundler resolution smoke test + run: | + npx --yes esbuild --bundle --platform=browser --format=esm --outfile=/dev/null \ + --external:react --external:react-dom --external:react-reconciler \ + --external:pdfnative --external:node:fs/promises dist/index.js + npx --yes esbuild --bundle --platform=browser --format=esm --outfile=/dev/null \ + --external:react --external:react-dom --external:react-reconciler \ + --external:pdfnative dist/client.js # `.github/ai-governance.json` declares `advisory_in_ci: true`. Validate any # AI-authored draft staged for human review. Advisory: it reports, never blocks. diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index dd049e4..9393d50 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,38 +1,64 @@ -name: CodeQL - -on: - push: - branches: [main, master] - pull_request: - branches: [main, master] - schedule: - - cron: '31 4 * * 1' - -permissions: - contents: read - -jobs: - analyze: - name: Analyze (${{ matrix.language }}) - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - strategy: - fail-fast: false - matrix: - language: ['javascript-typescript'] - steps: - - uses: actions/checkout@v4 - - - name: Initialize CodeQL - uses: github/codeql-action/init@v3 - with: - languages: ${{ matrix.language }} - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 - with: - category: '/language:${{ matrix.language }}' - upload: ${{ github.event.repository.private == false }} +name: CodeQL + +on: + push: + branches: [main, master] + paths-ignore: + - '**.md' + - 'docs/**' + - '.github/ISSUE_TEMPLATE/**' + - '.github/*.md' + - '.github/FUNDING.yml' + - 'LICENSE' + - '.editorconfig' + - '.gitignore' + pull_request: + branches: [main, master] + paths-ignore: + - '**.md' + - 'docs/**' + - '.github/ISSUE_TEMPLATE/**' + - '.github/*.md' + - '.github/FUNDING.yml' + - 'LICENSE' + - '.editorconfig' + - '.gitignore' + schedule: + - cron: '27 3 * * 1' + +permissions: + security-events: write + actions: read + contents: read + +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + timeout-minutes: 30 + + strategy: + fail-fast: false + matrix: + language: [javascript-typescript] + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Initialize CodeQL + uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + with: + languages: ${{ matrix.language }} + + - name: Autobuild + uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cbb398e..d2110fb 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -31,7 +31,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '>=20' + node-version: '22' registry-url: https://registry.npmjs.org cache: npm diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 7c5fe12..d4cfb40 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -1,44 +1,49 @@ -name: Scorecard - -on: - branch_protection_rule: - schedule: - - cron: '24 5 * * 2' - push: - branches: [main, master] - -permissions: read-all - -jobs: - analysis: - name: Scorecard analysis - runs-on: ubuntu-latest - permissions: - security-events: write - id-token: write - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - persist-credentials: false - - - name: Run analysis - uses: ossf/scorecard-action@v2.4.0 - with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - results_file: results.sarif - results_format: sarif - publish_results: ${{ github.event.repository.private == false }} - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: SARIF file - path: results.sarif - retention-days: 5 - - - name: Upload to code-scanning - if: github.event.repository.private == false - uses: github/codeql-action/upload-sarif@v3 - with: - sarif_file: results.sarif +name: Scorecard supply-chain security + +on: + schedule: + - cron: '27 3 * * 1' + push: + branches: [main, master] + +permissions: read-all + +concurrency: + group: scorecard-${{ github.ref }} + cancel-in-progress: true + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + security-events: write + id-token: write + contents: read + actions: read + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Run analysis + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: SARIF file + path: results.sarif + retention-days: 5 + + - name: Upload to code-scanning + uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + with: + sarif_file: results.sarif diff --git a/.nvmrc b/.nvmrc index 9de2256..2bd5a0a 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -lts/iron +22 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b21479..97707c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,25 @@ in the loop. No public API was removed or changed in a backward-incompatible way. Two *install-time* floors were raised — see **Changed** first. +### Security + +Both of these are engine fixes that arrive with the `^1.6.0` peer floor. They +are listed here because they affect documents **this package authored**. + +- **Encrypted documents no longer leak their outline, link URIs or metadata.** + Before engine 1.6.0, only *streams* were encrypted — strings were not. Since + `` derives bookmark titles from every ``, a + password-protected document produced by pdfnative-react disclosed its section + headings, its `` targets and its `metadata` to anyone opening the + file without the password. Re-render anything you shipped with + `layout.encryption`. +- **AES-256 output is now spec-compliant.** The engine's R6 hash substituted + SHA-256 for every round instead of the SHA-256/384/512 rotation ISO 32000-2 + Algorithm 2.B requires, so `algorithm: 'aes256'` files written on engine + ≤ 1.5.0 were not readable by strictly compliant readers. Output changes + bit-for-bit; the engine's decryptor keeps a legacy fallback so old files still + open. + ### Changed - **`pdfnative` peer floor is now `^1.6.0`** (was `^1.5.0`). `` compiles @@ -87,10 +106,11 @@ No public API was removed or changed in a backward-incompatible way. Two Derived entirely from the internal registries, and a test asserts every name it advertises resolves to a real export. - **`doctor()`** — environment pre-flight returning - `{ ok, checks: [{ name, status, value, detail }] }`. Never throws, including - when the `pdfnative` peer is missing — which is precisely what it diagnoses. - The engine check is a *capability probe* rather than a version-string parse, - so it survives bundling into a browser build. + `{ ok, checks: [{ name, status, value, detail }] }`. Never throws — it reports + rather than raises. The engine check is a *capability probe* rather than a + version-string parse, so it survives bundling into a browser build and catches + an engine that resolves but is older than 1.6.0. A peer that is absent + *entirely* fails earlier, at module resolution, and never reaches `doctor()`. - **`validateSpec(spec: unknown)`** — structural validation of an untrusted `DocSpec` with no JSON-Schema engine, returning path-anchored `V_*` findings (`blocks[3][1]`). Never throws, and bounds page nesting at 64 levels so a deep diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eb9525e..c9f2a36 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,8 @@ high open-source bar: typed, tested, linted, and reproducible. ## Prerequisites -- Node.js **≥ 20** (use `nvm use` — see `.nvmrc`). +- Node.js **≥ 22** (use `nvm use` — see `.nvmrc`). The floor is inherited from + the `pdfnative` engine, which requires it as of 1.6.0. - npm (bundled with Node). ## Setup diff --git a/README.md b/README.md index 8ff263e..a205f16 100644 --- a/README.md +++ b/README.md @@ -182,14 +182,13 @@ the `items` data prop (`{ text, items }`). Nested lists inherit the parent style ## Hooks & client components -These run in the browser. The published bundle is a single file with no -`'use client'` directive — the source modules carry it, but bundling collapses -them — so in a React Server Components app, declare the directive in the file -that imports them. +These run in the browser. In a React Server Components app, import them from the +**`pdfnative-react/client`** subpath, which ships with `'use client'` already +applied — no wrapper file needed. The root barrel exports them too, for apps +without an RSC boundary. ```tsx -'use client'; -import { usePdf } from 'pdfnative-react'; +import { usePdf } from 'pdfnative-react/client'; function Preview({ doc }: { doc: React.ReactElement }) { const { url, loading } = usePdf(doc); @@ -288,6 +287,31 @@ The async entry points accept the loader map directly as `options.fonts`. `validateFontData(data)` runs an opt-in, read-only structural check on a custom font module (`{ valid, errors, warnings }`) before you embed it. +### Font weight — check before shipping to a browser + +Font modules are embedded in your bundle when you import them, and some are +large. Engine 1.6.0 expanded the colour-emoji subset from 221 to 1167 glyphs, +which took it from ~0.25 MB to **4.0 MB** — worth knowing, since this is the one +package in the ecosystem that targets a browser bundle. + +| Module | Size | +|---|---| +| `noto-sans-math-data.js` | 1.5 MB | +| `noto-sans-data.js` | 2.8 MB | +| `noto-color-emoji-data.js` | **4.0 MB** | +| `noto-jp-data.js` | 12.6 MB | +| `noto-sc-data.js` | 23.4 MB | + +The loaders passed to `resolveFonts` are dynamic imports, so a bundler puts each +in its own chunk and loads it on demand rather than up front. For a smaller +emoji set, generate one covering only the codepoints you use: + +```bash +npx pdfnative-build-emoji-font --codepoints "1F600,1F44D,2764" +``` + +Server-side rendering is unaffected — nothing is bundled there. + ### Image helpers `fromBase64(base64)` and `fromUrl(url)` produce the `Uint8Array` that `` @@ -363,7 +387,7 @@ fonts, layout/PDF-A, the client hooks/components, and the compact agent spec. - [Charts](docs/CHARTS.md) — the five chart types, accessibility, PDF/A. - [Server rendering](docs/SERVER.md) — `renderToResponse` on Next.js, Remix, Hono, Deno, Bun, Workers and Express. -- [Linting](docs/LINTING.md) — the sixteen rules, and how to gate on them. +- [Linting](docs/LINTING.md) — the eighteen rules, and how to gate on them. - [Recipes](docs/RECIPES.md) — merging, form filling, text extraction, decryption: calling the engine on the bytes this library produces. - [Agent contract](docs/AGENT_CONTRACT.md) — driving the package autonomously. diff --git a/docs/AGENT_CONTRACT.md b/docs/AGENT_CONTRACT.md index 6276343..137fd96 100644 --- a/docs/AGENT_CONTRACT.md +++ b/docs/AGENT_CONTRACT.md @@ -34,10 +34,17 @@ const report = doctor(); // { ok: true, checks: [{ name, status: 'ok' | 'warn' | 'error', value, detail }] } ``` -`doctor()` **never throws**, including when the `pdfnative` peer is missing — -that is exactly what it is there to tell you. Checks cover the package version, -Node, React, the engine (via a capability probe rather than a version string, so -it survives bundling), Web Crypto, the Fetch API and `Blob`. +`doctor()` **never throws** — it reports rather than raises, which is what makes +it safe to call first. Checks cover the package version, Node, React, the engine +(via a capability probe rather than a version string, so it survives bundling), +Web Crypto, the Fetch API and `Blob`. + +One limit worth knowing: `core-bridge` re-exports the engine with a *static* +`export … from 'pdfnative'`, so if the peer is not installed at all the module +graph fails to resolve and `doctor()` is never reached — you get +`ERR_MODULE_NOT_FOUND` at import time instead, which is already an unambiguous +diagnosis. What `doctor()` catches is the subtler case: an engine that resolves +but is **older than 1.6.0**. Branch on `report.ok`. When it is `false`, report the failing checks rather than attempting work that cannot succeed. @@ -147,8 +154,8 @@ so the two can never disagree. ### Tier 3 — `lintSpec` -Eighteen rules with stable `L_*` codes (10 error, 7 warning, 1 info). Six of -them pre-empt an exception the engine raises *mid-render*: +Eighteen rules with stable `L_*` codes (10 error, 7 warning, 1 info). **Eight** +pre-empt an exception the engine raises *mid-render*: | Code | Would otherwise | |---|---| @@ -158,9 +165,12 @@ them pre-empt an exception the engine raises *mid-render*: | `L_CHART_VALUES` | Throw — non-finite, or negative in a pie/donut | | `L_CHART_POINTS` | Throw — 10 000-point ceiling | | `L_ATTACHMENTS_NEED_PDFA3` | Throw — attachments require `tagged="pdfa3b"` | +| `L_TAGGED_ENCRYPTED` | Throw — PDF/A and encryption are mutually exclusive | +| `L_MAX_BLOCKS_EXCEEDED` | Throw — past `maxBlocks`, default 100 000 | Two more catch output that renders successfully but is wrong: -`L_TAGGED_NO_FONTS` (a PDF/A file veraPDF rejects) and `L_MAX_BLOCKS_EXCEEDED`. +`L_EMPTY_DOCUMENT` (a blank page) and `L_TAGGED_NO_FONTS` (a PDF/A file veraPDF +rejects). Gate on `report.ok` (true when no `error`-severity finding). See [LINTING.md](LINTING.md). diff --git a/docs/KNOWLEDGE_BASE.md b/docs/KNOWLEDGE_BASE.md index 16210cd..3e38d9f 100644 --- a/docs/KNOWLEDGE_BASE.md +++ b/docs/KNOWLEDGE_BASE.md @@ -300,7 +300,7 @@ output rather than guessing. Unknown top-level fields are a *warning*, not an error, which preserves forward compatibility when a newer spec meets an older package. -Tier 3 is where the real leverage is: five of the sixteen lint rules +Tier 3 is where the real leverage is: eight of the eighteen lint rules (`L_CHART_*`, `L_ATTACHMENTS_NEED_PDFA3`) mirror validation the engine performs by **throwing mid-render**. `L_ATTACHMENTS_NEED_PDFA3` exists because writing `samples/layout/watermark-header-footer.tsx` hit exactly that throw. @@ -320,11 +320,20 @@ handles one shape. ### `doctor()` must never throw -Every check is wrapped, because the case it most needs to report — a missing -`pdfnative` peer — is the case that would otherwise crash the import. The engine -check is a **capability probe** (`typeof estimateChartHeight === 'function'`) -rather than a version-string parse: it works after bundling, in the browser, and -it tests the capability we actually need instead of a number that claims it. +Every check is wrapped: `doctor()` reports rather than raises, which is what +makes it safe to call before anything else. The engine check is a **capability +probe** (`typeof estimateChartHeight === 'function'`) rather than a +version-string parse: it works after bundling, in the browser, and it tests the +capability we actually need instead of a number that claims it. + +It has one reachability limit, worth stating plainly because an earlier draft of +these docs claimed the opposite. `core-bridge` re-exports the engine statically, +so a *completely absent* peer fails at module resolution — `doctor()` is never +called. That failure is already unambiguous (`ERR_MODULE_NOT_FOUND`), and +routing it through `doctor()` would mean giving up the static bridge that golden +rule 1 rests on. What the probe does catch is an engine that resolves but is +older than 1.6.0, which under a bundler or CJS interop yields an `undefined` +export rather than a link error. ### Governance duplication is deliberate diff --git a/docs/LINTING.md b/docs/LINTING.md index 1fa07b3..6d072e1 100644 --- a/docs/LINTING.md +++ b/docs/LINTING.md @@ -50,18 +50,24 @@ Eighteen rules, each with a stable code. Branch on the code, not the message. |---|---|---| | `L_EMPTY_DOCUMENT` | The document has no blocks | Render a blank page | | `L_TAGGED_NO_FONTS` | PDF/A requested with no `fontEntries` | Produce a file veraPDF rejects (6.2.11.4.1) | -| `L_TAGGED_ENCRYPTED` | PDF/A and encryption combined | Violate ISO 19005-1 §6.3.2 | +| `L_TAGGED_ENCRYPTED` | PDF/A and encryption combined | **Throw** (ISO 19005-1 §6.3.2) | | `L_ATTACHMENTS_NEED_PDFA3` | Attachments outside `tagged="pdfa3b"` | **Throw** | -| `L_MAX_BLOCKS_EXCEEDED` | Block count past the `maxBlocks` ceiling | Be rejected by the engine | +| `L_MAX_BLOCKS_EXCEEDED` | Block count past the `maxBlocks` ceiling | **Throw** | | `L_CHART_EMPTY` | Chart with no series, or a series with no values | **Throw** | | `L_CHART_SERIES` | Pie or donut with anything other than one series | **Throw** | | `L_CHART_CATEGORIES` | Series length ≠ `categories.length` | **Throw** | | `L_CHART_VALUES` | Non-finite value, or a negative in a pie/donut | **Throw** | | `L_CHART_POINTS` | Chart past the engine's 10 000-point ceiling | **Throw** | -Six of these — the five chart rules and `L_ATTACHMENTS_NEED_PDFA3` — pre-empt an -exception the engine raises mid-render. The rest catch output that renders -successfully but is wrong. +**Eight of these ten pre-empt an exception the engine raises mid-render** — the +five chart rules, `L_ATTACHMENTS_NEED_PDFA3`, `L_TAGGED_ENCRYPTED` and +`L_MAX_BLOCKS_EXCEEDED`. `L_MAX_BLOCKS_EXCEEDED` fires against the engine's +`DEFAULT_MAX_BLOCKS` of 100 000 even when you set no `maxBlocks` yourself, since +that is the ceiling the engine actually enforces. + +The remaining two catch output that renders successfully but is wrong: +`L_EMPTY_DOCUMENT` (a blank page) and `L_TAGGED_NO_FONTS` (a PDF/A file veraPDF +rejects). ### Warnings diff --git a/docs/RECIPES.md b/docs/RECIPES.md index cd4d54c..e55786d 100644 --- a/docs/RECIPES.md +++ b/docs/RECIPES.md @@ -128,7 +128,16 @@ Authoring-side encryption is a layout option, so it stays in this package: ``` Note that PDF/A forbids encryption (ISO 19005-1 §6.3.2) — -`lintDocument` reports `L_TAGGED_ENCRYPTED` if you combine them. +`lintDocument` reports `L_TAGGED_ENCRYPTED` if you combine them, and the engine +throws if you get past the linter. + +> **Re-render anything you encrypted on an engine older than 1.6.0.** Two engine +> fixes land with the `^1.6.0` peer floor and both affect files this package +> produced. Strings — outline titles, `` targets, `metadata` — were +> previously left *unencrypted* inside an encrypted document, so a +> `outline="auto"` document disclosed its section headings without the password. +> And AES-256 (R6) output was not ISO 32000-2 compliant, so strict readers could +> not open it. See the Security section of the [CHANGELOG](../CHANGELOG.md). Reading and re-securing an *existing* document is the engine's job: diff --git a/docs/SERVER.md b/docs/SERVER.md index f327774..25c22fa 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -155,19 +155,18 @@ export async function prepare(id: string): Promise { ### Client components -The published bundle is a single file with no `'use client'` directive. The -source modules carry it, but bundling collapses them, so the marker does not -survive into `dist/`. In an App Router project, import the preview and download -components from a file that declares the directive itself: +Import them from the **`pdfnative-react/client`** subpath, which ships with the +`'use client'` directive already applied: ```tsx -// components/pdf-preview.tsx -'use client'; -export { PDFViewer, PDFDownloadLink, BlobProvider, usePdf } from 'pdfnative-react'; +import { PDFViewer, PDFDownloadLink, BlobProvider, usePdf, usePdfStream } + from 'pdfnative-react/client'; ``` -Then import from that file in your client components. Server-side rendering -(`renderToResponse`, `renderToBytes`, `renderToFile`) needs no such wrapper. +No wrapper file, no directive of your own. The root barrel still exports the +same components for non-RSC apps, but in an App Router project use the subpath — +the root bundle is deliberately *not* marked as client code, because +`renderToResponse` and friends must stay server-safe. ## Runtime requirements diff --git a/llms.txt b/llms.txt index 4f97e0e..0f66cf0 100644 --- a/llms.txt +++ b/llms.txt @@ -84,9 +84,11 @@ usePdfStream); for sync entries do `fontEntries: await resolveFonts({...})`. ## Hooks & client components (browser only) -The published bundle is one file with no 'use client' directive (source modules -carry it; bundling collapses them). In a React Server Components app, declare -the directive in the file that imports these. +Import these from the `pdfnative-react/client` subpath, which ships with the +'use client' directive applied. The root barrel exports them too, for apps with +no RSC boundary. + + import { PDFViewer, usePdf } from 'pdfnative-react/client'; IMPORTANT — the RSC boundary: this package drives a React reconciler and needs createContext, which React's 'react-server' condition does not provide. Importing @@ -181,8 +183,10 @@ warnings: L_IMAGE_ALT, L_TABLE_HEADERS, L_HEADING_HIERARCHY, L_FIELD_LABEL, L_LINK_TEXT, L_MAX_BLOCKS, L_OVERFLOW info: L_CHART_ALT -Six of them — the five L_CHART_* errors and L_ATTACHMENTS_NEED_PDFA3 — pre-empt -an exception the engine raises mid-render. L_OVERFLOW requires { overflow: true }. +EIGHT of them pre-empt an exception the engine raises mid-render: the five +L_CHART_* errors, L_ATTACHMENTS_NEED_PDFA3, L_TAGGED_ENCRYPTED and +L_MAX_BLOCKS_EXCEEDED. The last fires against the engine default of 100 000 +blocks even when layout.maxBlocks is unset. L_OVERFLOW requires { overflow: true }. ## Notes diff --git a/package-lock.json b/package-lock.json index b75f05c..e21e57f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1956,29 +1956,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -2326,21 +2303,26 @@ "license": "MIT" }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/bundle-require": { @@ -3417,9 +3399,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, "funding": [ { @@ -3941,6 +3923,24 @@ "node": "*" } }, + "node_modules/minimatch/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -3974,9 +3974,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -4194,9 +4194,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "dev": true, "funding": [ { @@ -4214,7 +4214,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, diff --git a/package.json b/package.json index 19197de..f3f8d8c 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,16 @@ "default": "./dist/index.cjs" } }, + "./client": { + "import": { + "types": "./dist/client.d.ts", + "default": "./dist/client.js" + }, + "require": { + "types": "./dist/client.d.cts", + "default": "./dist/client.cjs" + } + }, "./package.json": "./package.json" }, "files": [ @@ -27,7 +37,7 @@ ], "sideEffects": false, "scripts": { - "build": "tsup", + "build": "tsup && node scripts/postbuild.mjs", "dev": "tsup --watch", "test": "vitest run", "test:watch": "vitest", @@ -131,6 +141,8 @@ "vitest": "^4.1.7" }, "overrides": { - "esbuild": "^0.28.1" + "esbuild": "^0.28.1", + "js-yaml": "^4.3.0", + "postcss": "^8.5.18" } } diff --git a/release-notes/draft/PR-v1.1.0.md b/release-notes/draft/PR-v1.1.0.md index 2da215b..39582d0 100644 --- a/release-notes/draft/PR-v1.1.0.md +++ b/release-notes/draft/PR-v1.1.0.md @@ -20,7 +20,7 @@ Four themes: web-standard `Response`, streaming by default. 3. **Document-level layout sugar + linting** — `watermark`, `header`, `footer`, `attachments`, `tagged` as first-class props; and `lintDocument`/`lintSpec`, - whose rules include five that pre-empt engine-level render failures. + whose rules include eight that pre-empt engine-level render failures. 4. **The agent automation contract** — `ErrorCode`, `capabilityManifest()`, `doctor()`, `validateSpec()`, multi-subject `schema()`, and the governance contract exported as runtime capability. Backed by a new single-source @@ -42,8 +42,8 @@ Neither is a source-breaking change; both are install-time requirements. ### New: `src/registry.ts` — the anti-drift mechanism -Three single-source tables (`BLOCK_REGISTRY`, `COMPONENT_REGISTRY`, -`LINT_RULES`) that `spec/schema.ts`, `spec/validate.ts` and `manifest.ts` all +Four single-source tables (`BLOCK_REGISTRY`, `COMPONENT_REGISTRY`, +`CLIENT_COMPONENT_REGISTRY`, `LINT_RULES`) that `spec/schema.ts`, `spec/validate.ts` and `manifest.ts` all *derive* from rather than restate. Pure data; imports nothing at runtime, which is what keeps schema emission free of the engine. @@ -102,9 +102,9 @@ hook so the generator cleans up on client disconnect); `buffered: true` uses `renderToBytes` and sets `Content-Length`. RFC 6266 `Content-Disposition` including `filename*` for non-ASCII. `async`, so `options.fonts` is honoured. -Stays on the root barrel rather than a subpath: `sideEffects: false` plus tsup -already give tree-shaking, and another `exports` condition would be cost without -benefit. No `'use client'` — this is server code. +Stays on the root barrel; the client components moved to a **`./client` subpath** +instead, which is where the `'use client'` directive belongs. No `'use client'` +here — this is server code, and marking it would break every server usage. ### New: `src/lint.ts` @@ -112,7 +112,7 @@ benefit. No `'use client'` — this is server code. `DocumentParams`, so JSX and `DocSpec` share one implementation for free (`lintSpec` is a two-line delegate, and a test asserts they agree). -Eighteen rules (10 error, 7 warning, 1 info). Six pre-empt failures the engine +Eighteen rules (10 error, 7 warning, 1 info). Eight pre-empt failures the engine raises by throwing mid-render; `L_ATTACHMENTS_NEED_PDFA3` exists because writing `samples/layout/watermark-header-footer.tsx` hit exactly that throw, and `L_CHART_EMPTY` because the architecture review found two more. @@ -126,8 +126,9 @@ costs a layout pass. `E_RUNTIME`), `PdfReactError` with `.code` and `.toJSON()`, and `toErrorEnvelope(unknown)` so a caller only ever handles one shape. - `capabilityManifest()` — derived wholly from the registries. -- `doctor()` — every check wrapped; must never throw, since a missing peer is - the case it exists to report. +- `doctor()` — every check wrapped; reports rather than raises. It cannot reach + the *completely absent peer* case (a static re-export fails at module + resolution first), which the docs now state plainly. - `governance.ts` — `aiGovernancePolicy`, `agentRulesText`, `validateIssueDraft`. The regex tables are **duplicated** from `scripts/verify-issue.mjs` because that script must stay zero-dependency and runnable in an unbuilt checkout; @@ -155,9 +156,9 @@ costs a layout pass. `agent/manifest.ts`, `agent/error-envelope.tsx`. All added to `samples/README.md` (with new "Server" and "Quality" sections) and all executed end to end, not just type-checked. -- 7 new test files: `registry`, `chart`, `layout-sugar`, `response`, `lint`, - `agent`, `schema`. `governance` and `version` extended. -- **79 → 219 tests**, 8 → 15 files. Coverage improved on every axis. +- 8 new test files: `registry`, `chart`, `layout-sugar`, `response`, `lint`, + `agent`, `schema`, `compile-snapshot`. `governance` and `version` extended. +- **79 → 224 tests**, 8 → 16 files, including a golden compile snapshot. ### Docs & governance @@ -182,10 +183,12 @@ costs a layout pass. ``` npm run typecheck:all clean (src + tests + samples) npm run lint clean, zero warnings -npm test 219 passed / 219, 15 files -npm run test:coverage 95.41 stmts · 86.94 branches · 97.76 funcs · 96.32 lines +npm test 224 passed / 224, 16 files +npm run test:coverage 94.77 stmts · 86.04 branches · 97.76 funcs · 95.80 lines (thresholds 85/80/85/85 — unchanged, not lowered) -npm run build ESM 84.9kB · CJS 87.2kB · d.ts + d.cts 66.7kB +npm run build root ESM/CJS + client ESM/CJS + four .d.ts; postbuild verifies + the node: prefix, the client-only directive, and tree-shaking +npm audit --omit=dev 0 vulnerabilities (runtime tree) npm pack --dry-run llms.txt present in the tarball ``` @@ -200,9 +203,39 @@ Additionally verified by hand: ## Adversarial review -Two independent reviews were run before this draft: one on architecture and -state of the art, one on documentation accuracy. Both found real defects. Every -confirmed finding is fixed: +**Five** independent reviews were run against this branch across two rounds — +architecture, documentation accuracy, engine-1.6.0 gap analysis, ecosystem +state-of-the-art, and a final documentation pass. Every confirmed finding is +fixed. The second round is listed first, because it found the more serious +defects **and** caught three claims the first round's fixes had asserted but not +completed. + +### Round 2 + +| Finding | Fix | +|---|---| +| **The published bundle emitted `import('fs/promises')` without the `node:` prefix.** Deno and Cloudflare `nodejs_compat` refuse to resolve the bare form, so a wrangler or Vite-browser build failed to compile — against four documents advertising Edge/Deno/Bun/Workers. Root cause is a rollup pass inside tsup that survives `platform`, `target`, `external` and `banner` alike (all four measured) | `scripts/postbuild.mjs` restores the prefix and **fails the build** if the expected shape is absent; a bundler-resolution step in `ci.yml` compiles both artifacts the way a non-Node bundler would | +| **`import { version }` pulled the entire React reconciler into a consumer's bundle** — 10 137 bytes for a string constant, and `react-reconciler` forced to resolve. A single-file bundle makes `sideEffects: false` inoperative | `/* @__PURE__ */` on `ReactReconciler(hostConfig)`, `HostTransitionContext`, `HOST_CONTEXT`, `LINT_RULE_CODES` and `BY_KIND`. Now **3 216 bytes, no reconciler**; postbuild fails the build if it regresses | +| **`'use client'` never reached `dist/`**, so RSC users needed a hand-written wrapper — while `README.md` claimed the directive was carried | New **`pdfnative-react/client`** subpath export, built separately with the directive applied and verified by postbuild. The root bundle is asserted *not* to carry it | +| **`L_MAX_BLOCKS` could not fire on the engine's default ceiling.** It checked only an explicit `layout.maxBlocks`, but the engine applies `DEFAULT_MAX_BLOCKS = 100 000` unconditionally and throws — so a large generated document linted clean and then crashed | `layout?.maxBlocks ?? 100_000`; test at 100 001 blocks | +| **"Six rules pre-empt an engine throw" was wrong — it is eight.** `L_TAGGED_ENCRYPTED` (`pdf-document.ts:169`) and `L_MAX_BLOCKS_EXCEEDED` (`:146`) both throw; the docs listed them as safe. Repeated in 7 files | Verified against each engine throw site and corrected everywhere | +| **`schema('manifest')` described 10 of the manifest's 13 properties** — missing `clientComponents`, `errorClasses`, `schemaSubjects`, two of which were added *for* agent honesty. No test covered it | Completed, plus a test comparing `Object.keys(capabilityManifest())` to the schema's properties **and** `required` | +| **`ChartProps` had no compile-time tie to `ChartBlock`**, while `docs/CHARTS.md` promises Charts-v2 fields "arrive as new `ChartProps`" | `ChartPropsCoversChartBlock` assert; verified destructively | +| **`toBlock` had no exhaustiveness guard** — a new `HostTag` without a case compiled cleanly and failed at render, while the DocSpec side had a `never` guard since 1.0 | `const exhaustive: never`; verified destructively | +| **The `doctor()` claim retracted in round 1 was still live in five documents**, including `llms.txt` and `AGENT_CONTRACT.md` — the two an agent loads first | Corrected in all five | +| `.nvmrc` pinned `lts/iron` (Node 20) against `engines: >=22`; `CONTRIBUTING.md` and `publish.yml` said 20 too — a leftover from this PR's own bump | All set to 22 | +| `ci.yml`, `codeql.yml` and `scorecard.yml` were a generation behind the three sibling repos: unpinned actions (while `publish.yml` in the same repo is SHA-pinned), `codeql-action@v3` vs v4, no `concurrency`, no `timeout-minutes`, and **`scorecard.yml` job permissions that drop `contents`/`actions` to `none`** — job-level `permissions` replace, not merge, so `checkout` gets a 403 | All three aligned on `pdfnative-cli`, React deltas re-applied | +| 3 high-severity dev advisories shipping through a green CI | `js-yaml`/`postcss` overrides; **runtime audit is now blocking** (`npm audit --omit=dev` is clean — the prod tree is one dependency), dev audit advisory with the reason stated | +| Two engine fixes affecting documents **this package authored** were undocumented: pre-1.6.0 encrypted files left outline titles, link URIs and metadata **in clear text**, and AES-256 output was not ISO 32000-2 compliant | New `### Security` section in the CHANGELOG and a callout in `docs/RECIPES.md` | +| The colour-emoji module grew 221 → 1167 glyphs (~0.25 MB → **4.0 MB**) on an upgrade this package's own peer floor forces — and this is the only package in the ecosystem targeting a browser bundle | Font-weight table in `README.md` with measured sizes and the `--codepoints` escape hatch | +| `.github/instructions/components.instructions.md` had the same stale-procedure defect its two siblings were rewritten for in round 1; `spec.instructions.md` claimed "the first five steps are compiler-enforced" when the real set is 1, 3, 4, 5, 6, 7 | Both corrected | +| No golden test on the compiled model — the strongest assertion on output was `byteLength > 100` | `tests/compile-snapshot.test.tsx`: a committed snapshot of a document using every block and every document-level prop | +| Sample header miscounts and a wrong run command (`.ts` for a `.tsx` file) | Corrected | + +One round-2 finding was **rejected after verification**: a reviewer disputed the +coverage figures. Re-measured — the documented numbers were correct. + +### Round 1 | Finding | Fix | |---|---| @@ -220,9 +253,9 @@ confirmed finding is fixed: | `Content-Disposition` `filename*` emitted `' ( ) ! *`, which are not RFC 8187 `attr-char`; a raw apostrophe mis-parses the ext-value | Percent-escaped; test | | `docs/RECIPES.md` annotation example was wrong on both arguments and could not run | Rewritten against the real API (`createModifier(openPdf(bytes))`, `buildAnnotationBody`, `save()`) and **executed** | | `.github/copilot-instructions.md` and `.github/instructions/spec.instructions.md` still described pre-1.1.0 architecture — no `chart`, no `registry.ts` — so an agent following them would fail the repo's own compile-time lock | Both rewritten, including the 10-step block checklist | -| `doctor()`'s headline claim ("works when the peer is missing") was false — a static re-export means the module graph fails first | Claim corrected in code and docs to what is actually true | -| `docs/SERVER.md` documented a Server Action, but RSC-layer imports fail at module load (`react-server` has no `createContext`); `'use client'` is stripped from the bundle | New "React Server Components boundary" section stating the real constraint and the wrapper pattern; README and `llms.txt` corrected | -| Hand-maintained counts wrong in six places ("five rules" over six-row tables, "6 new samples" over a list of seven) | All recounted against the code: 18 rules (10/7/1), 7 samples, 7 test files | +| `doctor()`'s headline claim ("works when the peer is missing") was false — a static re-export means the module graph fails first | Corrected in `src/doctor.ts`; round 2 found five documents still carrying it and finished the job | +| `docs/SERVER.md` documented a Server Action, but RSC-layer imports fail at module load (`react-server` has no `createContext`) | Replaced with the real constraint; round 2 replaced the manual wrapper advice with the `./client` subpath | +| Hand-maintained counts wrong in six places | Recounted; round 2 found five sites still stale, including the user-facing release note | Findings acknowledged but **not** acted on, with reasons: @@ -231,9 +264,16 @@ Findings acknowledged but **not** acted on, with reasons: top of the release notes, and the alternative for the peer (`^1.5.0 || ^1.6.0` plus a capability guard on every chart path) trades a build-time error for a runtime surprise. Recorded here so a reviewer can overrule it. -- **Subpath exports (`./client`, `./server`).** The right long-term fix for the - RSC boundary, but architectural rather than a patch. Documented accurately for - 1.1.0; tracked for a future release. +- **Two defects in sibling repositories.** `pdfnative-cli` declares + `engines.node: ">=20"` while depending on `pdfnative@^1.6.0`, which requires 22 + — and its CI matrix tests Node 20. And `pdfnative/docs/guides/react.md` still + describes a pre-1.0 version of this wrapper. Both were verified; both are out + of scope for this PR by explicit decision, and neither is being reported from + here. +- **PDF/UA round-trip test, `validateSpec` fuzzing, raised coverage thresholds, + `eslint-plugin-react-hooks`, `Cache-Control`/ETag on `renderToResponse`, + `cause` on `PdfReactError`.** All reasonable; all tracked for 1.2.0 rather + than widening this release further. ## Backward compatibility @@ -291,7 +331,7 @@ Also dropped, with reasons recorded in `ROADMAP.md`: |---|---| | `no_new_runtime_dependency_confirmed` | ✅ `dependencies` is still exactly `["react-reconciler"]`, asserted by `tests/version.test.ts` | | `reproduction_command` | `npm run typecheck:all && npm run lint && npm run test:coverage && npm run build && npm pack --dry-run` | -| `reproduction_result` | All green; 205/205 tests; coverage above thresholds on all four axes | +| `reproduction_result` | All green; 224/224 tests; coverage above thresholds on all four axes; runtime `npm audit` clean | | `duplicate_search_performed` | N/A — release PR, not an issue report | | `affected_packages` | `pdfnative-react` only. Upstream `pdfnative` docs still reference `pdfnative-react v1.0.0` in `docs/guides/react.md`, `llms.txt`, `AGENTS.md` and `README.md` — a companion PR there would be worthwhile, and is **not** included here. | | `identity_reminder_shown` | ✅ This draft must be reviewed and submitted by a human under their own GitHub identity. You share responsibility for its content. | diff --git a/release-notes/v1.1.0.md b/release-notes/v1.1.0.md index ee073d9..81a846d 100644 --- a/release-notes/v1.1.0.md +++ b/release-notes/v1.1.0.md @@ -110,19 +110,25 @@ const report = lintDocument(); // { ok, findings: [{ code, severity, message, blockIndex?, hint? }], counts } ``` -Sixteen deterministic rules with stable `L_*` codes, covering accessibility -(missing alt text, tables without headers, skipped heading levels, unlabelled -form fields) and — more valuably — **five constraints the engine would otherwise -enforce by throwing mid-render**: +Eighteen deterministic rules with stable `L_*` codes — 10 error, 7 warning, +1 info — covering accessibility (missing alt text, tables without headers, +skipped heading levels, unlabelled form fields) and, more valuably, **eight +constraints the engine would otherwise enforce by throwing mid-render**: | Rule | Would otherwise | |---|---| +| `L_CHART_EMPTY` | Throw — no series, or a series with no values | | `L_CHART_SERIES` | Throw — pie/donut need exactly one series | | `L_CHART_CATEGORIES` | Throw — series length must match categories | | `L_CHART_VALUES` | Throw — non-finite, or negative in a pie/donut | | `L_CHART_POINTS` | Throw — 10 000-point ceiling | | `L_ATTACHMENTS_NEED_PDFA3` | Throw — attachments require `tagged="pdfa3b"` | -| `L_TAGGED_NO_FONTS` | Produce a PDF/A file veraPDF rejects | +| `L_TAGGED_ENCRYPTED` | Throw — PDF/A and encryption are mutually exclusive | +| `L_MAX_BLOCKS_EXCEEDED` | Throw — past `maxBlocks`, default 100 000 | + +Two more catch output that renders successfully but is wrong: +`L_EMPTY_DOCUMENT` (a blank page) and `L_TAGGED_NO_FONTS` (a PDF/A file veraPDF +rejects for a non-embedded font). It runs on the compiled document model, so JSX and `DocSpec` share one implementation, and it is pure — no console output, no throwing. @@ -201,8 +207,8 @@ your logs). - `npm run typecheck:all` — clean (src + tests + samples) - `npm run lint` — clean, zero warnings -- **219 tests across 15 files**, all green (was 79 across 8) -- Coverage **95.4% statements · 86.9% branches · 97.8% functions · 96.3% lines** +- **224 tests across 16 files**, all green (was 79 across 8) +- Coverage **94.8% statements · 86.0% branches · 97.8% functions · 95.8% lines** (thresholds 85/80/85/85, unchanged) - `npm run build` — ESM + CJS + `.d.ts` + `.d.cts` - CJS and ESM import smoke tests on the built artifacts diff --git a/samples/agent/error-envelope.tsx b/samples/agent/error-envelope.tsx index 1c31fd9..8064540 100644 --- a/samples/agent/error-envelope.tsx +++ b/samples/agent/error-envelope.tsx @@ -1,7 +1,7 @@ /** * The error taxonomy, and how to consume it. * - * Run with: npx tsx samples/agent/error-envelope.ts + * Run with: npx tsx samples/agent/error-envelope.tsx * Prints envelopes; writes nothing. * * Every error carries a stable `code`. Branch on the code — messages are diff --git a/samples/quality/lint.tsx b/samples/quality/lint.tsx index 6bd5424..2f6b706 100644 --- a/samples/quality/lint.tsx +++ b/samples/quality/lint.tsx @@ -9,10 +9,11 @@ * for a finding; what you do with the report is your call. Wire it into CI, a * dev-mode warning, or an agent's self-check loop. * - * Six rules pre-empt hard failures further down the pipeline: the five - * `L_CHART_*` errors and `L_ATTACHMENTS_NEED_PDFA3` mirror validation the engine - * performs by throwing mid-render. Two more — `L_TAGGED_NO_FONTS` and - * `L_MAX_BLOCKS_EXCEEDED` — catch output that renders but is wrong. + * Eight of the eighteen rules pre-empt an exception the engine raises + * mid-render: the five `L_CHART_*` errors, `L_ATTACHMENTS_NEED_PDFA3`, + * `L_TAGGED_ENCRYPTED` and `L_MAX_BLOCKS_EXCEEDED`. Two more — + * `L_EMPTY_DOCUMENT` and `L_TAGGED_NO_FONTS` — catch output that renders but is + * wrong. */ import React from 'react'; diff --git a/samples/server/next-route-handler.tsx b/samples/server/next-route-handler.tsx index 39e40d5..593207e 100644 --- a/samples/server/next-route-handler.tsx +++ b/samples/server/next-route-handler.tsx @@ -1,5 +1,5 @@ /** - * Next.js App Router — a PDF route handler and a Server Action. + * Next.js App Router — PDF route handlers. * * This is a *module*, not a runnable script: copy it into a Next.js 15+ app at * `app/invoice/[id]/route.tsx`. It is type-checked in CI like every other sample. @@ -10,6 +10,13 @@ * stays flat and the browser starts receiving bytes immediately. * * There is no 'use client' here on purpose — this is server-only code. + * + * Use a Route Handler, NOT a Server Component or a 'use server' file. This + * package drives a React reconciler, which needs `createContext`, and React's + * `react-server` export condition does not provide it — an RSC-layer import + * fails at module load. Route Handlers are not in that layer, which is why every + * example here works. For the preview components, import from + * `pdfnative-react/client`. See docs/SERVER.md. */ import React from 'react'; diff --git a/scripts/postbuild.mjs b/scripts/postbuild.mjs new file mode 100644 index 0000000..b5a8fe9 --- /dev/null +++ b/scripts/postbuild.mjs @@ -0,0 +1,187 @@ +/** + * pdfnative-react — post-build artifact repair and verification + * ============================================================= + * tsup 8 runs a rollup pass over the bundled output. That pass does two things + * we need undone, and neither can be prevented from the tsup config — `platform`, + * `target`, `external` and `banner` were each measured and none survive it: + * + * 1. It rewrites `node:fs/promises` to the bare `fs/promises`. Deno and + * Cloudflare `nodejs_compat` refuse to resolve the unprefixed form, so a + * wrangler or Vite-browser build of a package that advertises those + * runtimes fails to compile. + * 2. It strips module-level directives, so the `'use client'` marker never + * reaches `dist/client.*` and a React Server Components app cannot import + * the preview components without a hand-written wrapper. + * + * This script restores both, then verifies the whole artifact set. It is strict + * on purpose: if an expected pattern is missing — because tsup changed + * behaviour, or because someone removed the code it patches — the build + * **fails** rather than silently shipping a broken package. + * + * Usage: node scripts/postbuild.mjs (wired into `npm run build`) + * Exit: 0 all good · 1 a check failed + */ + +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; + +const DIST = join(process.cwd(), 'dist'); + +/** Root-bundle artifacts: must keep the `node:` prefix, must NOT be client. */ +const ROOT = ['index.js', 'index.cjs']; +/** Client-entry artifacts: must carry the `'use client'` directive. */ +const CLIENT = ['client.js', 'client.cjs']; +/** Everything the `exports` map points at. */ +const REQUIRED = [...ROOT, ...CLIENT, 'index.d.ts', 'index.d.cts', 'client.d.ts', 'client.d.cts']; + +const errors = []; +const actions = []; + +function read(file) { + return readFileSync(join(DIST, file), 'utf8'); +} + +function write(file, content) { + writeFileSync(join(DIST, file), content, 'utf8'); +} + +// ── 0. Every artifact the exports map promises must exist ──────────────────── + +for (const file of REQUIRED) { + if (!existsSync(join(DIST, file))) errors.push(`Missing artifact: dist/${file}`); +} +if (errors.length > 0) { + for (const e of errors) console.error(`error: ${e}`); + process.exit(1); +} + +// ── 1. Restore the node: prefix on the root bundles ────────────────────────── + +for (const file of ROOT) { + const before = read(file); + + if (before.includes("import('node:fs/promises')")) { + // Already correct — tsup behaviour may have changed. Nothing to do. + continue; + } + + if (!before.includes("import('fs/promises')")) { + errors.push( + `dist/${file}: found neither 'node:fs/promises' nor 'fs/promises'. ` + + 'The dynamic import in src/render.ts may have been removed or renamed — ' + + 'update this script deliberately rather than deleting the check.', + ); + continue; + } + + const after = before.replaceAll("import('fs/promises')", "import('node:fs/promises')"); + write(file, after); + actions.push(`dist/${file}: restored the node: prefix on fs/promises`); +} + +// ── 2. Restore the 'use client' directive on the client bundles ────────────── + +for (const file of CLIENT) { + const before = read(file); + + if (/^\s*(['"])use client\1\s*;?/.test(before)) continue; + + // The directive must be the very first statement, ahead of 'use strict'. + write(file, `'use client';\n${before}`); + actions.push(`dist/${file}: restored the 'use client' directive`); +} + +// ── 3. Verify the final shape ──────────────────────────────────────────────── + +for (const file of ROOT) { + const content = read(file); + if (!content.includes("import('node:fs/promises')")) { + errors.push(`dist/${file}: the node: prefix is still missing after repair.`); + } + if (/^\s*(['"])use client\1/.test(content)) { + errors.push( + `dist/${file}: carries a 'use client' directive. The root bundle is server-safe ` + + 'and must never be marked as client code.', + ); + } +} + +for (const file of CLIENT) { + if (!/^\s*(['"])use client\1\s*;?/.test(read(file))) { + errors.push(`dist/${file}: the 'use client' directive is missing after repair.`); + } +} + +// ── 4. Tree-shaking: importing pure data must not drag in the reconciler ───── +// +// `import { version }` — or `validateSpec`, `schema`, `capabilityManifest`, none +// of which touch React — used to pull the whole React reconciler into a +// consumer's bundle, because a handful of top-level calls were side effects a +// bundler could not prove away. They are now `/* @__PURE__ */`-annotated. This +// check fails the build if any of them loses its annotation. + +async function checkTreeShaking() { + let esbuild; + try { + esbuild = await import('esbuild'); + } catch { + console.log('postbuild: esbuild unavailable, skipping the tree-shaking check.'); + return; + } + + const result = await esbuild.build({ + stdin: { + contents: "import { version } from './dist/index.js';\nglobalThis.x = version;\n", + resolveDir: process.cwd(), + sourcefile: 'shake-probe.mjs', + }, + bundle: true, + write: false, + format: 'esm', + platform: 'browser', + minify: true, + external: ['react', 'react-dom', 'react-reconciler', 'pdfnative', 'node:fs/promises'], + logLevel: 'silent', + }); + + const code = result.outputFiles[0].text; + const bytes = Buffer.byteLength(code); + + if (/ReactReconciler|HostTransitionContext/.test(code)) { + errors.push( + 'Tree-shaking regression: importing `version` alone still pulls in the React ' + + 'reconciler. A `/* @__PURE__ */` annotation was probably lost — check ' + + 'src/reconciler/host-config.ts (`reconciler`, `HostTransitionContext`, ' + + '`HOST_CONTEXT`) and src/registry.ts (`LINT_RULE_CODES`).', + ); + return; + } + + // Generous ceiling: the point is to catch a *regression*, not to police bytes. + const CEILING = 6_000; + if (bytes > CEILING) { + errors.push( + `Tree-shaking regression: a \`version\`-only bundle is ${String(bytes)} bytes ` + + `(ceiling ${String(CEILING)}). Something with a top-level side effect became reachable.`, + ); + return; + } + + console.log( + `postbuild: tree-shaking ok — \`version\`-only bundle is ${String(bytes)} bytes, no reconciler.`, + ); +} + +await checkTreeShaking(); + +// ── Report ─────────────────────────────────────────────────────────────────── + +for (const a of actions) console.log(`postbuild: ${a}`); + +if (errors.length > 0) { + for (const e of errors) console.error(`error: ${e}`); + console.error('postbuild: artifact verification FAILED — do not publish this build.'); + process.exit(1); +} + +console.log(`postbuild: ${String(REQUIRED.length)} artifacts verified.`); diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..4a579a8 --- /dev/null +++ b/src/client.ts @@ -0,0 +1,37 @@ +'use client'; + +/** + * Client entry point — `pdfnative-react/client`. + * + * The published root bundle is a single file, so a `'use client'` directive in + * `hooks.ts` or `viewer.tsx` does not survive bundling. This entry does: it is + * built separately with the directive as a banner, which is what a React Server + * Components app needs in order to import a preview or download component + * without a hand-written wrapper. + * + * ```tsx + * import { PDFViewer, usePdf } from 'pdfnative-react/client'; + * ``` + * + * Everything here is also exported from the root barrel, so this is an addition + * rather than a move — existing imports keep working. Prefer this path in an + * App Router project; prefer the root for server code (`renderToResponse`, + * `renderToBytes`, `renderToFile`), which must *not* be marked client. + * + * @packageDocumentation + */ + +export { usePdf, usePdfStream } from './hooks.js'; +export type { UsePdfResult, UsePdfStreamResult } from './hooks.js'; + +export { PDFViewer, BlobProvider, PDFDownloadLink } from './viewer.js'; +export type { + PDFViewerProps, + BlobProviderProps, + PDFDownloadLinkProps, + PdfRenderState, +} from './viewer.js'; + +// Re-exported so a client-only module does not need a second import from the +// root just to type its render options. +export type { RenderOptions, FontsMap, FontLoader } from './types.js'; diff --git a/src/components.tsx b/src/components.tsx index 1128bcf..7f9a956 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -509,6 +509,24 @@ export interface ChartProps { readonly altText?: string; } +/** + * Compile-time lock: {@link ChartProps} must mirror the engine's `ChartBlock` + * exactly (minus the `type` discriminator, which the serializer adds). + * + * The engine's roadmap has "Charts v2" — stacked bars, area, scatter, log/time + * axes, per-point data labels — so `ChartBlock` *will* gain optional fields, and + * `docs/CHARTS.md` already promises they arrive here as new `ChartProps`. This + * turns that promise into a build error on the next engine minor instead of a + * silently under-exposed component. + */ +type ChartPropsAssert = T; +type ChartPropsExact = + (() => T extends keyof ChartProps ? 1 : 2) extends + () => T extends keyof Omit ? 1 : 2 + ? true + : false; +export type ChartPropsCoversChartBlock = ChartPropsAssert; + /** * A native vector chart — bar, horizontal bar, line, pie or donut — rendered as * pure PDF path operators. No rasterisation, no chart library, and PDF/A-safe. diff --git a/src/lint.ts b/src/lint.ts index c5855c3..f0e9a60 100644 --- a/src/lint.ts +++ b/src/lint.ts @@ -9,9 +9,11 @@ * Findings carry a stable {@link LintRuleCode}. Branch on the code, never on * the message: messages may be reworded in any release, codes may not. * - * Several rules pre-empt hard failures inside the engine (the `chart` rules, - * `L_TAGGED_NO_FONTS`), turning a runtime throw or a rejected PDF/A file into a - * finding you can act on before rendering. + * Eight of the eighteen rules pre-empt an exception the engine raises + * mid-render — the five `L_CHART_*` errors, `L_ATTACHMENTS_NEED_PDFA3`, + * `L_TAGGED_ENCRYPTED` and `L_MAX_BLOCKS_EXCEEDED` — turning a runtime throw + * into a finding you can act on beforehand. Two more (`L_EMPTY_DOCUMENT`, + * `L_TAGGED_NO_FONTS`) catch output that renders successfully but is wrong. * * The function is pure: it never writes to the console and never throws for a * lint failure. What you do with the report is your call. @@ -78,6 +80,16 @@ export interface LintOptions extends RenderOptions { const MAX_CHART_POINTS = 10_000; +/** + * The engine's `DEFAULT_MAX_BLOCKS`, applied when `layout.maxBlocks` is unset. + * + * It is not a soft limit: `buildDocumentPDF` **throws** past it. Checking only + * an explicit `layout.maxBlocks` would leave the common case — no `layout` at + * all — unguarded, so a large generated document would lint clean and then blow + * up mid-render. + */ +const DEFAULT_MAX_BLOCKS = 100_000; + /** * Every rule this module can actually emit. * @@ -365,17 +377,18 @@ function lintDocumentParams(params: DocumentParams, out: LintFinding[]): void { ); } - const maxBlocks = layout?.maxBlocks; + const maxBlocks = layout?.maxBlocks ?? DEFAULT_MAX_BLOCKS; const blockCount = params.blocks.length; - if (maxBlocks !== undefined && blockCount > maxBlocks) { + if (blockCount > maxBlocks) { out.push( finding( 'L_MAX_BLOCKS_EXCEEDED', - `${String(blockCount)} blocks exceeds the maxBlocks ceiling of ${String(maxBlocks)}.`, + `${String(blockCount)} blocks exceeds the maxBlocks ceiling of ${String(maxBlocks)}` + + `${layout?.maxBlocks === undefined ? ' (the engine default)' : ''}.`, { hint: 'Raise layout.maxBlocks, or split the document.' }, ), ); - } else if (maxBlocks !== undefined && blockCount > maxBlocks * 0.9) { + } else if (blockCount > maxBlocks * 0.9) { out.push( finding( 'L_MAX_BLOCKS', diff --git a/src/reconciler/host-config.ts b/src/reconciler/host-config.ts index 85b41f4..58a1a45 100644 --- a/src/reconciler/host-config.ts +++ b/src/reconciler/host-config.ts @@ -32,7 +32,7 @@ type TransitionStatus = null; // React's reconciler treats a `null` host context as "no context" and throws // "Expected host context to exist". We have no real context, so we hand back a // single stable, frozen sentinel object instead. -const HOST_CONTEXT = Object.freeze({}); +const HOST_CONTEXT = /* @__PURE__ */ Object.freeze({}); type HostContext = typeof HOST_CONTEXT; function appendChild(parent: Instance | Container, child: HostNode): void { @@ -164,7 +164,11 @@ const hostConfig: Config = { // ── Transition / priority surface (react-reconciler 0.31+) ────────────── NotPendingTransition: null, - HostTransitionContext: createContext( + // `/* @__PURE__ */` matters here: a bare call inside this object literal is + // a side effect a bundler cannot prove away, which pins the entire + // `hostConfig` — and with it the whole reconciler — into any bundle that + // imports *anything* from the package, including `version` or `schema()`. + HostTransitionContext: /* @__PURE__ */ createContext( null, ) as unknown as Config['HostTransitionContext'], setCurrentUpdatePriority(newPriority) { @@ -213,6 +217,9 @@ const hostConfig: Config = { }, }; -export const reconciler = ReactReconciler(hostConfig); +// `/* @__PURE__ */` so a bundler may drop this when nothing imports the +// reconciler. Without it, every consumer — including one importing only +// `version` or `validateSpec` — pays for the whole React renderer. +export const reconciler = /* @__PURE__ */ ReactReconciler(hostConfig); export { isElementNode }; diff --git a/src/reconciler/serialize.ts b/src/reconciler/serialize.ts index ba392a6..e86f983 100644 --- a/src/reconciler/serialize.ts +++ b/src/reconciler/serialize.ts @@ -193,10 +193,28 @@ function toBlock(node: ElementNode): DocumentBlock | DocumentBlock[] { case 'page': return blocksFrom(node.children); - default: + // `document`, `item`, `row` and `cell` are handled by their parent's + // serializer, never on their own — reaching them here means the tree is + // malformed, so they share the structural error below. + case 'document': + case 'item': + case 'row': + case 'cell': throw new PdfStructureError( `<${node.tag}> is not valid here. Expected a block-level component inside or .`, ); + + default: { + // Exhaustiveness lock: adding a member to `HostTag` without a case + // here is a *compile* error, not a render-time surprise. The DocSpec + // side has had this guard since 1.0 (`spec/compile.ts`); the JSX side + // only had a runtime `default` throw, so the two enforced different + // contracts for the same grammar. + const exhaustive: never = node.tag; + throw new PdfStructureError( + `<${String(exhaustive)}> is not valid here. Expected a block-level component inside or .`, + ); + } } } diff --git a/src/registry.ts b/src/registry.ts index 0bb4b41..09b8420 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -1,16 +1,17 @@ /** * The package's single source of truth for its own surface. * - * Three tables live here — {@link BLOCK_REGISTRY} (the `DocSpec` grammar), - * {@link COMPONENT_REGISTRY} (the JSX components) and {@link LINT_RULES} (the - * lint contract). They feed four consumers: + * Four tables live here — {@link BLOCK_REGISTRY} (the `DocSpec` grammar), + * {@link COMPONENT_REGISTRY} (the JSX components), {@link CLIENT_COMPONENT_REGISTRY} + * (the preview/download components) and {@link LINT_RULES} (the lint contract). + * They feed four consumers: * * 1. `spec/schema.ts` — assembles `$defs.block.oneOf` and the report schemas. * 2. `spec/validate.ts` — derives tuple arity and payload types. * 3. `manifest.ts` — emits the machine-readable capability manifest. * 4. `tests/registry.test.ts` — locks the exact, ordered contents. * - * Because all three derive from these tables rather than restating them, the + * Because all four derive from these tables rather than restating them, the * schema, the manifest and the docs cannot drift apart. The compile-time * assertions at the bottom of this file make *omission* a build error, not a * silent gap: add a member to `BlockSpec` or `HostTag` without registering it @@ -410,7 +411,9 @@ export const LINT_RULES = { export type LintRuleCode = keyof typeof LINT_RULES; /** Every rule code, in registry order. */ -export const LINT_RULE_CODES = Object.keys(LINT_RULES) as readonly LintRuleCode[]; +export const LINT_RULE_CODES = /* @__PURE__ */ (Object.keys( + LINT_RULES, +) as readonly LintRuleCode[]); // ───────────────────────────────────────────────────────────────────────────── // Compile-time exhaustiveness locks diff --git a/src/response.ts b/src/response.ts index d9f2cae..c7cfbbc 100644 --- a/src/response.ts +++ b/src/response.ts @@ -2,8 +2,8 @@ * Web-standard `Response` helpers — the server-side entry point. * * These turn a document straight into an HTTP response, which is what a - * Next.js Route Handler, a Server Action, a Remix loader, a Hono/Elysia route - * or any Fetch-API server actually needs: + * Next.js **Route Handler**, a Remix loader, a Hono/Elysia route or any + * Fetch-API server actually needs: * * ```ts * // app/invoice/route.ts @@ -21,6 +21,11 @@ * Nothing here touches the DOM or React client APIs — **do not** add * `'use client'` to this module. * + * Not a Server Component or a `'use server'` file, though: this package drives a + * React reconciler, which needs `createContext`, and React's `react-server` + * export condition does not provide it. Route Handlers are not in the RSC layer, + * which is why they work. See `docs/SERVER.md`. + * * @packageDocumentation */ diff --git a/src/spec/schema.ts b/src/spec/schema.ts index 4089ebf..f9420e9 100644 --- a/src/spec/schema.ts +++ b/src/spec/schema.ts @@ -2,9 +2,14 @@ * Versioned JSON Schema (Draft 2020-12) for the compact {@link DocSpec} authoring * format, so agents and tooling can self-validate a spec before rendering. * - * The schema is hand-authored, pure data (zero runtime deps — no validator is - * bundled), and versioned via a `$id` that embeds the package version, so any - * drift is detectable and pinned by a test. + * Pure data — zero runtime deps, no validator is bundled — and versioned via a + * `$id` that embeds the package version, so drift is detectable and pinned by a + * test. + * + * The per-block *shapes* are hand-authored here, but the **contract** is not: + * `blockDefs()` sources each tuple's kind discriminator, arity and description + * from `BLOCK_REGISTRY`, and the `lint-report` enum from `LINT_RULE_CODES`. A + * builder cannot disagree with the registry, because the registry overwrites it. * * @packageDocumentation */ @@ -733,18 +738,68 @@ function manifestSchema(): JsonSchema { 'Machine-readable description of everything this package can do. Fetch it with ' + 'capabilityManifest() to register pdfnative-react as an agent tool set.', type: 'object', - required: ['kind', 'name', 'version', 'contract', 'components', 'specBlocks', 'entrypoints'], + // Every key `capabilityManifest()` emits. `tests/schema.test.ts` asserts + // the two stay in step — a manifest property the schema does not describe + // is invisible to an agent that discovers the API through the schema, + // which is the documented path. + required: [ + 'kind', + 'name', + 'version', + 'schemaId', + 'contract', + 'components', + 'clientComponents', + 'errorClasses', + 'specBlocks', + 'entrypoints', + 'errorCodes', + 'lintRules', + 'schemaSubjects', + ], properties: { kind: { const: 'capability-manifest' }, name: { const: 'pdfnative-react' }, version: { type: 'string' }, - schemaId: { type: 'string' }, - contract: { type: 'object' }, - components: { type: 'array', items: { type: 'object' } }, - specBlocks: { type: 'array', items: { type: 'object' } }, - entrypoints: { type: 'array', items: { type: 'object' } }, + schemaId: { type: 'string', description: "The $id of this schema." }, + contract: { + type: 'object', + description: 'Invariants a caller can rely on (authoring-only, block flow, versions).', + }, + components: { + type: 'array', + items: { type: 'object' }, + description: 'JSX components, their host tag and aliases.', + }, + clientComponents: { + type: 'array', + items: { type: 'object' }, + description: + 'Preview/download components. Client-side; import them from ' + + '"pdfnative-react/client" in a React Server Components app.', + }, + errorClasses: { + type: 'array', + items: { type: 'string' }, + description: 'Error classes exported for instanceof checks.', + }, + specBlocks: { + type: 'array', + items: { type: 'object' }, + description: 'The whole DocSpec grammar: tuple form, summary, equivalent component.', + }, + entrypoints: { + type: 'array', + items: { type: 'object' }, + description: 'Every callable export, with signature and sync/async/stream kind.', + }, errorCodes: { type: 'array', items: { type: 'string' } }, lintRules: { type: 'array', items: { type: 'object' } }, + schemaSubjects: { + type: 'array', + items: { enum: [...SCHEMA_SUBJECTS] }, + description: 'The subjects schema(subject) answers to.', + }, }, }; } diff --git a/src/spec/validate.ts b/src/spec/validate.ts index 65e5ca2..346fa04 100644 --- a/src/spec/validate.ts +++ b/src/spec/validate.ts @@ -117,7 +117,7 @@ export type KnownFieldsAreExhaustive = Assert< >; /** kind → descriptor, flattened from the registry once at module load. */ -const BY_KIND = new Map( +const BY_KIND = /* @__PURE__ */ new Map( BLOCK_REGISTRY.flatMap((entry) => entry.kinds.map((kind) => [kind as string, entry] as const)), ); diff --git a/tests/__snapshots__/compile-snapshot.test.tsx.snap b/tests/__snapshots__/compile-snapshot.test.tsx.snap new file mode 100644 index 0000000..aaf31ec --- /dev/null +++ b/tests/__snapshots__/compile-snapshot.test.tsx.snap @@ -0,0 +1,244 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`compileDocument — golden model > produces a stable DocumentParams for a document using every feature 1`] = ` +{ + "blocks": [ + { + "level": 1, + "text": "Everything", + "type": "heading", + }, + { + "maxLevel": 2, + "title": "Contents", + "type": "toc", + }, + { + "color": "#334155", + "level": 2, + "text": "Prose", + "type": "heading", + }, + { + "align": "right", + "color": "#111827", + "fontSize": 11, + "indent": 12, + "lineHeight": 1.4, + "text": "Body text with every typographic prop set.", + "type": "paragraph", + }, + { + "height": 8, + "type": "spacer", + }, + { + "fontSize": 10, + "items": [ + { + "items": [ + "Nested A", + "Nested B", + ], + "text": "First", + }, + "Second", + ], + "style": "numbered", + "type": "list", + }, + { + "caption": "Line items", + "cellBorders": { + "all": true, + "color": "#e2e8f0", + "width": 0.5, + }, + "cellVAlign": "middle", + "headers": [ + "Item", + "Qty", + "Total", + ], + "repeatHeader": true, + "rows": [ + { + "cells": [ + "Pro plan", + "1", + "€490.00", + ], + "pointed": false, + "type": "default", + }, + { + "cells": [ + "Support", + "1", + "€99.00", + ], + "pointed": true, + "type": "total", + }, + ], + "type": "table", + "zebra": "#f8fafc", + }, + { + "align": "center", + "altText": "Revenue rises each quarter, 2026 above 2025 throughout.", + "axis": { + "grid": true, + "ticks": 5, + "yMin": 0, + }, + "categories": [ + "Q1", + "Q2", + "Q3", + "Q4", + ], + "chartType": "bar", + "colors": [ + "#4e79a7", + "#f28e2b", + ], + "legend": "bottom", + "markers": true, + "series": [ + { + "label": "2025", + "values": [ + 12, + 18, + 24, + 31, + ], + }, + { + "label": "2026", + "values": [ + 15, + 21, + 29, + 38, + ], + }, + ], + "title": "Revenue", + "type": "chart", + }, + { + "align": "right", + "alt": "A single pixel", + "data": Uint8Array [ + 137, + 80, + 78, + 71, + 13, + 10, + 26, + 10, + ], + "height": 64, + "type": "image", + "width": 64, + }, + { + "alt": "A diagonal", + "data": "M0 0 L10 10", + "height": 40, + "type": "svg", + "viewBox": [ + 0, + 0, + 10, + 10, + ], + "width": 40, + }, + { + "align": "right", + "data": "https://acme.example/pay/2048", + "format": "qr", + "type": "barcode", + "width": 96, + }, + { + "color": "#2563eb", + "fontSize": 9, + "text": "Read the terms", + "type": "link", + "url": "https://acme.example/terms", + }, + { + "type": "pageBreak", + }, + { + "level": 2, + "text": "Appendix", + "type": "heading", + }, + { + "fieldType": "text", + "label": "Email", + "maxLength": 120, + "name": "applicant.email", + "placeholder": "you@example.com", + "required": true, + "type": "formField", + }, + ], + "footerText": "Acme Inc", + "layout": { + "attachments": [ + { + "data": Uint8Array [ + 60, + 105, + 47, + 62, + ], + "filename": "invoice-2048.xml", + "mimeType": "application/xml", + "relationship": "Data", + }, + ], + "footerTemplate": { + "center": "{title}", + "left": "Confidential", + "right": "Page {page} of {pages}", + }, + "headerTemplate": { + "left": "Acme Inc", + "right": "{date}", + }, + "maxBlocks": 5000, + "pageWidth": 595, + "tagged": "pdfa3b", + "watermark": { + "text": { + "text": "DRAFT", + }, + }, + }, + "metadata": { + "author": "Acme Inc", + "keywords": "test", + "subject": "Snapshot fixture", + }, + "outline": "auto", + "pageLabels": [ + { + "startPage": 0, + "style": "roman", + }, + { + "startPage": 2, + "style": "decimal", + }, + ], + "title": "Everything", +} +`; diff --git a/tests/compile-snapshot.test.tsx b/tests/compile-snapshot.test.tsx new file mode 100644 index 0000000..d151729 --- /dev/null +++ b/tests/compile-snapshot.test.tsx @@ -0,0 +1,190 @@ +/** + * Golden snapshot of the compiled document model. + * + * `compileDocument()` is a pure JSX → JSON function, which makes it the ideal + * regression surface: the rest of the suite asserts *shapes* (this block has + * that field), but nothing asserted the **whole model** of a realistic document. + * A serializer change that silently drops a prop, reorders blocks, or starts + * emitting `undefined` would pass every other test. + * + * This is the wrapper-side analogue of the engine's `visual-regression.yml`. + * + * When this snapshot changes, read the diff. If the change is intended, update + * it with `vitest -u`; if it is not, you have just caught a regression. + */ +import { describe, expect, it } from 'vitest'; +import { + Chart, + Document, + FormField, + Heading, + Image, + Item, + Link, + List, + Page, + PageBreak, + Paragraph, + Section, + Spacer, + Svg, + Table, + TableOfContents, + Barcode, + compileDocument, + compileSpec, +} from '../src/index.js'; +import type { DocSpec, PageTemplate, PdfAttachment, PdfRow } from '../src/index.js'; + +const PIXEL = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +const FOOTER: PageTemplate = { + left: 'Confidential', + center: '{title}', + right: 'Page {page} of {pages}', +}; + +const ATTACHMENT: PdfAttachment = { + filename: 'invoice-2048.xml', + data: new Uint8Array([0x3c, 0x69, 0x2f, 0x3e]), + mimeType: 'application/xml', + relationship: 'Data', +}; + +const ROWS: PdfRow[] = [ + { cells: ['Pro plan', '1', '€490.00'], type: 'default', pointed: false }, + { cells: ['Support', '1', '€99.00'], type: 'total', pointed: true }, +]; + +/** + * One document exercising every block kind and every document-level prop — + * charts, layout sugar, outline, page labels, nested lists, typed table rows, + * form fields, media and an explicit page group. + */ +const kitchenSink = ( + + Everything + +
+ + Body text with every typographic prop set. + + +
+ + + + + + + +
+ + + + + + Read the terms + + + + Appendix + + + +); + +describe('compileDocument — golden model', () => { + it('produces a stable DocumentParams for a document using every feature', () => { + expect(compileDocument(kitchenSink)).toMatchSnapshot(); + }); + + it('emits no undefined values anywhere in the model', () => { + // `compact()` is what keeps the emitted JSON deterministic; a regression + // there would not change any individual assertion elsewhere. + const json = JSON.stringify(compileDocument(kitchenSink), (_k, v: unknown) => + v === undefined ? '__UNDEFINED__' : v, + ); + expect(json).not.toContain('__UNDEFINED__'); + }); +}); + +describe('DocSpec parity — golden model', () => { + it('a spec using every top-level field compiles to the same shape as JSX', () => { + const spec: DocSpec = { + title: 'Everything', + footerText: 'Acme Inc', + metadata: { author: 'Acme Inc', subject: 'Snapshot fixture', keywords: 'test' }, + outline: 'auto', + pageLabels: [{ startPage: 0, style: 'roman' }, { startPage: 2, style: 'decimal' }], + watermark: 'DRAFT', + header: { left: 'Acme Inc', right: '{date}' }, + footer: FOOTER, + attachments: [ATTACHMENT], + tagged: 'pdfa3b', + layout: { pageWidth: 595, maxBlocks: 5000 }, + blocks: [['h1', 'Everything']], + }; + + const jsx = ( + + Everything + + ); + + expect(compileSpec(spec)).toEqual(compileDocument(jsx)); + }); +}); diff --git a/tests/lint.test.tsx b/tests/lint.test.tsx index ac6c5ab..4ee1ad2 100644 --- a/tests/lint.test.tsx +++ b/tests/lint.test.tsx @@ -126,6 +126,26 @@ describe('document-level rules', () => { expect(report.ok).toBe(true); }); + it( + 'L_MAX_BLOCKS_EXCEEDED — applies the engine default when no maxBlocks is set', + // Reconciling 100 001 blocks is genuinely expensive — that is the point + // of the rule. The default 5 s budget is not enough under coverage + // instrumentation, so this one test gets a bigger one rather than a + // smaller document that would not exercise the ceiling. + { timeout: 60_000 }, + () => { + // The engine applies DEFAULT_MAX_BLOCKS = 100_000 unconditionally and + // throws past it. Checking only an explicit layout.maxBlocks left the + // common case — no layout at all — completely unguarded. + const report = lintSpec({ + blocks: Array.from({ length: 100_001 }, () => ['br'] as const), + }); + expect(codes(report)).toContain('L_MAX_BLOCKS_EXCEEDED'); + expect(report.ok).toBe(false); + expect(report.findings[0].message).toContain('engine default'); + }, + ); + it('L_MAX_BLOCKS_EXCEEDED — past the ceiling is an error, not an "approaching" warning', () => { const report = lintSpec({ layout: { maxBlocks: 10 }, diff --git a/tests/schema.test.ts b/tests/schema.test.ts index c09cb33..30aca67 100644 --- a/tests/schema.test.ts +++ b/tests/schema.test.ts @@ -135,6 +135,19 @@ describe('report schemas', () => { expect(props.kind.const).toBe('capability-manifest'); }); + it('manifest schema describes EVERY property the manifest emits', () => { + // A manifest property the schema does not describe is invisible to an + // agent that discovers the API through the schema — which is the + // documented path. `clientComponents`, `errorClasses` and + // `schemaSubjects` were all missing until this test existed. + const emitted = Object.keys(capabilityManifest()).sort(); + const described = Object.keys( + schema('manifest')['properties'] as Record, + ).sort(); + expect(described).toEqual(emitted); + expect(schema('manifest')['required']).toEqual(Object.keys(capabilityManifest())); + }); + it('spec-validation enumerates the validation codes', () => { const doc = schema('spec-validation'); const defs = doc['$defs'] as { finding: { properties: { code: { enum: string[] } } } }; diff --git a/tsup.config.ts b/tsup.config.ts index 9eaa60a..ef53eff 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,17 +1,56 @@ import { defineConfig } from 'tsup'; -export default defineConfig({ - entry: { index: 'src/index.ts' }, +/** Shared build settings. */ +const shared = { format: ['esm', 'cjs'], dts: true, sourcemap: true, - clean: true, - splitting: false, treeshake: true, minify: false, target: 'es2022', outDir: 'dist', - // React, its reconciler, and the pdfnative engine are provided by the - // consumer (peer/host) — never bundle them into the published artifact. - external: ['react', 'react-dom', 'react-reconciler', 'pdfnative'], -}); + external: [ + // React, its reconciler, and the pdfnative engine are provided by the + // consumer (peer/host) — never bundle them into the published artifact. + 'react', + 'react-dom', + 'react-reconciler', + 'pdfnative', + // `renderToFile` dynamically imports `node:fs/promises`. Marking it + // external is necessary but *not* sufficient — see the note below and + // `scripts/postbuild.mjs`. + 'node:fs/promises', + ], +} as const; + +/** + * Two builds rather than one, because the `'use client'` directive must land on + * the client entry **only**. Putting it on the root bundle would mark the whole + * package as client code and break every server usage. + * + * They are separate builds, so each is self-contained; `clean` runs once, on + * the first, or the second would delete the first's output. + * + * **Why there is a post-build step.** tsup 8 runs a rollup pass over the bundled + * output. That pass strips module-level directives (it warns + * *"Module level directives cause errors when bundled"*) and rewrites + * `node:`-prefixed specifiers to their bare form — regardless of `platform`, + * `target`, `external` or `banner`. All four were measured and none survive it. + * `scripts/postbuild.mjs` restores both and **fails the build** if the expected + * shape is not found. The artifact is what ships, so the artifact is what we + * assert. + */ +export default defineConfig([ + { + ...shared, + entry: { index: 'src/index.ts' }, + clean: true, + splitting: false, + }, + { + ...shared, + entry: { client: 'src/client.ts' }, + clean: false, + splitting: false, + }, +]); From 3ddb94235383f211fe72c6d726908d4cc814a073 Mon Sep 17 00:00:00 2001 From: Kuzino <129803615+Nizoka@users.noreply.github.com> Date: Sun, 26 Jul 2026 23:02:01 +0200 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20close=20the=20go/no-go=20findings=20?= =?UTF-8?q?=E2=80=94=20client=20subpath=20docs,=20publish=20gate,=20drift?= =?UTF-8?q?=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final ship-readiness round. Five independent reviews across three rounds; this one was a go/no-go rather than a defect hunt. Two findings were rejected after verification, and the rest are fixed. The blocker: - CHANGELOG.md still said "Six rules pre-empt an engine throw" and filed L_MAX_BLOCKS_EXCEEDED under "renders successfully but is wrong". It was the last live instance of the defect the previous round was named after. The real defect was the gate: the round-2 sweep searched `six pre-empt` while the text read `Six rules pre-empt`. AGENTS.md now documents the drift sweep with this miss as its cautionary example — a gate is only as good as its regex. The ./client subpath shipped under-documented. It is new public API and was absent from the CHANGELOG, from the user-facing release note, from capabilityManifest(), and from every sample: - CHANGELOG gains a Packaging section covering the subpath plus the two other user-visible fixes of the last round (the node: prefix restoration that unblocks Deno/Workers, and the tree-shaking win). - The release note gains a section for it, and — more importantly — a Security section. The CHANGELOG told readers to re-render anything encrypted with a pre-1.6.0 engine; that instruction never reached the artifact users read. - capabilityManifest() gains contract.entry, contract.clientEntry and contract.reactServerCondition, plus clientComponents[].importFrom. An agent reading only the manifest previously had no way to discover the entry point. - Both samples/client/* now import from src/client.js and show the real-world specifier in their header. Gates that did not guard: - publish.yml verified only the four dist/index.* artifacts and imported by file path, so the exports map was never resolved by any workflow — a wrong `types` target would have shipped unseen. It now packs a tarball, installs it into a throwaway project, resolves both subpaths in both conditions, and renders a real PDF from the installed package. - postbuild.mjs failed *open*: its tree-shaking guard degraded to a console.log and exit 0 if esbuild was absent. Now fails closed, with an explicit opt-out. Documentation drift: "three tables" (four since CLIENT_COMPONENT_REGISTRY) in KNOWLEDGE_BASE and AGENTS; "the bundle strips the directive" in registry.ts and copilot-instructions.md; KB §3 missing src/client.ts, §6 missing compile-snapshot, §304 enumerating six under the word "eight". Rejected after verification: - The reported upload-artifact version inconsistency is shared verbatim by pdfnative-cli and pdfnative-mcp. Aligning unilaterally would have created the ecosystem divergence it claimed to remove. - A reported total test-suite failure was an audit tool perturbing node_modules (install marker at 18:55, vitest/jsdom mtimes at 20:06). `npm ci` from the committed lockfile restores 224/224 — which is what CI does. 224 -> 226 tests. Coverage 94.8/86.0/97.8/95.8. Both export subpaths verified from a real packed tarball, types included. --- .github/copilot-instructions.md | 8 ++-- .github/workflows/publish.yml | 53 ++++++++++++++++++++-- AGENTS.md | 26 +++++++++-- CHANGELOG.md | 38 ++++++++++++++-- docs/KNOWLEDGE_BASE.md | 22 ++++++--- release-notes/draft/PR-v1.1.0.md | 37 +++++++++++---- release-notes/v1.1.0.md | 78 ++++++++++++++++++++++++++++---- samples/client/use-pdf.tsx | 12 ++++- samples/client/viewer.tsx | 19 ++++---- scripts/postbuild.mjs | 14 +++++- src/manifest.ts | 25 +++++++++- src/registry.ts | 7 +-- tests/agent.test.tsx | 22 +++++++++ 13 files changed, 308 insertions(+), 53 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 51a194d..08432c5 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -54,9 +54,11 @@ Read [docs/KNOWLEDGE_BASE.md](../docs/KNOWLEDGE_BASE.md) and - **Do not run the renderer synchronously inside a React effect/commit.** `usePdf` defers `renderToBytes` via `queueMicrotask` to avoid reconciler reentrancy (which deadlocks). Preserve this when editing hooks. -- **Client modules carry `'use client'`** (`hooks.ts`, `viewer.tsx`) — in source. - The bundle is a single file, so the directive does not survive into `dist/`; - `src/response.ts` is server-side and must never carry it. +- **Client modules carry `'use client'`** (`hooks.ts`, `viewer.tsx`), and are + re-exported from `src/client.ts`, which is built as the separate + `pdfnative-react/client` subpath so the directive reaches `dist/client.*`. + The root bundle must never carry it — marking it would break every server + usage — and `src/response.ts` is server-side by design. - **Strict TypeScript, no `any`** (lint-enforced). Use `type`-only imports. - **AI governance (draftsman, never submitter).** Do not open/submit issues or PRs autonomously. Draft into `.github/drafts/`, validate with diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d2110fb..81e8552 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -56,11 +56,58 @@ jobs: test -f dist/index.js test -f dist/index.d.ts test -f dist/index.d.cts + test -f dist/client.cjs + test -f dist/client.js + test -f dist/client.d.ts + test -f dist/client.d.cts - - name: Import smoke test + # Resolve through the packed tarball and the `exports` map, not by file + # path. Importing `./dist/index.js` directly proves the file parses; it + # proves nothing about the `exports` map, so a wrong `types` target or a + # dropped condition would ship unnoticed. This is the last gate before + # `npm publish`, so it exercises what a consumer will actually resolve. + - name: Consumer resolution smoke test run: | - node -e "const m = require('./dist/index.cjs'); if (typeof m.renderToBytes !== 'function' || typeof m.renderSpecToBytes !== 'function') { throw new Error('CJS export surface missing'); }" - node --input-type=module -e "import('./dist/index.js').then(m => { if (typeof m.renderToBytes !== 'function' || typeof m.docSpecSchema !== 'function') { throw new Error('ESM export surface missing'); } })" + set -euo pipefail + npm pack --pack-destination /tmp + TARBALL=$(ls /tmp/pdfnative-react-*.tgz) + mkdir -p /tmp/consumer && cd /tmp/consumer + npm init -y > /dev/null + npm install --no-audit --no-fund "$TARBALL" react react-dom pdfnative + + node -e " + const root = require('pdfnative-react'); + const client = require('pdfnative-react/client'); + for (const n of ['renderToBytes','renderSpecToBytes','renderToResponse','capabilityManifest','doctor']) { + if (typeof root[n] !== 'function') throw new Error('CJS root missing ' + n); + } + for (const n of ['usePdf','PDFViewer','BlobProvider']) { + if (typeof client[n] !== 'function') throw new Error('CJS client missing ' + n); + } + console.log('CJS ok'); + " + node --input-type=module -e " + const [root, client] = await Promise.all([ + import('pdfnative-react'), + import('pdfnative-react/client'), + ]); + for (const n of ['renderToBytes','docSpecSchema','schema','lintDocument']) { + if (typeof root[n] !== 'function') throw new Error('ESM root missing ' + n); + } + if (typeof client.usePdfStream !== 'function') throw new Error('ESM client missing usePdfStream'); + console.log('ESM ok'); + " + + # Render a real PDF from the installed package — proves the postbuild + # rewrite of the dynamic node:fs/promises import did not corrupt it. + node --input-type=module -e " + const { renderSpecToFile } = await import('pdfnative-react'); + const { readFile } = await import('node:fs/promises'); + await renderSpecToFile({ blocks: [['h1','Release smoke test']] }, 'out.pdf'); + const bytes = await readFile('out.pdf'); + if (!bytes.subarray(0, 5).toString('latin1').startsWith('%PDF-')) throw new Error('not a PDF'); + console.log('rendered', bytes.length, 'bytes'); + " - name: Generate SBOM (CycloneDX) # Software Bill of Materials for supply-chain transparency. Uses the diff --git a/AGENTS.md b/AGENTS.md index 4fef5e8..1c440c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,11 +81,12 @@ Start by reading [docs/KNOWLEDGE_BASE.md](docs/KNOWLEDGE_BASE.md). ## The registry is the single source of truth -`src/registry.ts` holds three tables — the `DocSpec` block grammar, the -component list, and the lint rules. Four things *derive* from them rather than -restating them: +`src/registry.ts` holds four tables — the `DocSpec` block grammar, the component +list, the client components, and the lint rules. Four things *derive* from them +rather than restating them: -1. `src/spec/schema.ts` — `$defs.block.oneOf`, plus tuple arity and descriptions. +1. `src/spec/schema.ts` — `$defs.block.oneOf`, plus each tuple's kind + discriminator, arity and description. 2. `src/spec/validate.ts` — arity and payload-type rules. 3. `src/manifest.ts` — the capability manifest. 4. `tests/registry.test.ts` — pins the exact, ordered contents. @@ -146,6 +147,23 @@ npm run build Add or update tests under `tests/` for any behavioural change, and update `CHANGELOG.md` under **[Unreleased]**. +### Documentation drift gate + +The same fact is stated in README, `llms.txt`, the Knowledge Base, the agent +contract, the CHANGELOG, the release notes and the capability manifest. When you +change a **count** or a **claim**, sweep for the old one before you commit: + +```bash +grep -rniE "six (rules|of these|pre-empt)|sixteen|five (rules|constraints)|three tables|peer is missing" \ + --include=*.md --include=*.txt --include=*.ts --include=*.tsx . \ + | grep -v node_modules | grep -v '^\./dist' +``` + +Widen the alternation to whatever phrasing you are retiring — and widen it +*generously*. A previous release shipped a wrong count in `CHANGELOG.md` for a +full round because the sweep searched `six pre-empt` while the text read +`Six rules pre-empt`. The gate is only as good as its regex. + ## Conventions - 4-space indent (2 for JSON/YAML). diff --git a/CHANGELOG.md b/CHANGELOG.md index 97707c4..f34f4a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,33 @@ are listed here because they affect documents **this package authored**. `filename*` for non-ASCII names. Runs unchanged on Node, the Edge runtime, Deno, Bun and Cloudflare Workers. +#### Packaging — a client subpath, and two fixes that make the runtime claims true + +- **New `pdfnative-react/client` export.** `usePdf`, `usePdfStream`, + `PDFViewer`, `PDFDownloadLink` and `BlobProvider`, shipped with the + `'use client'` directive already applied. In a React Server Components app, + import them from there — no wrapper file of your own. The root barrel still + exports them for apps with no RSC boundary, and is deliberately *not* marked + as client code, because `renderToResponse` must stay server-safe. + + Note the boundary this does **not** move: importing this package from a + Server Component or a `'use server'` file still fails, because the reconciler + needs `createContext` and React's `react-server` condition does not provide + it. Use a Route Handler. See [docs/SERVER.md](docs/SERVER.md). + +- **The published bundle now keeps the `node:` prefix on its dynamic + `node:fs/promises` import.** It was being rewritten to the bare specifier, + which Deno and Cloudflare `nodejs_compat` refuse to resolve — so a wrangler or + Vite-browser build of the very runtimes listed above failed to compile. + `scripts/postbuild.mjs` now verifies the shipped artifacts and fails the build + if it regresses; CI additionally bundles both artifacts the way a non-Node + bundler would. + +- **Importing pure data no longer drags in the React reconciler.** + `import { version }` cost 10 137 bytes and forced `react-reconciler` to + resolve; it is now 3 216 with no reconciler. Same for `validateSpec`, + `schema()` and `capabilityManifest()`. The build fails if this regresses. + #### Document-level layout sugar - New `` props — **`watermark`**, **`header`**, **`footer`**, @@ -87,10 +114,13 @@ are listed here because they affect documents **this package authored**. deterministic accessibility and layout rules with stable `L_*` codes (10 error, 7 warning, 1 info). Runs on the compiled document model, so JSX and `DocSpec` share one implementation. Pure: no console output, no throwing. -- Six rules pre-empt an exception the engine raises mid-render: the five - `L_CHART_*` errors (`EMPTY`, `SERIES`, `CATEGORIES`, `VALUES`, `POINTS`) and - `L_ATTACHMENTS_NEED_PDFA3`. Two more — `L_TAGGED_NO_FONTS` and - `L_MAX_BLOCKS_EXCEEDED` — catch output that renders successfully but is wrong. +- **Eight** rules pre-empt an exception the engine raises mid-render: the five + `L_CHART_*` errors (`EMPTY`, `SERIES`, `CATEGORIES`, `VALUES`, `POINTS`), + `L_ATTACHMENTS_NEED_PDFA3`, `L_TAGGED_ENCRYPTED` and `L_MAX_BLOCKS_EXCEEDED` — + the last firing against the engine's default ceiling of 100 000 blocks even + when you set none yourself. Two more catch output that renders successfully + but is wrong: `L_EMPTY_DOCUMENT` (a blank page) and `L_TAGGED_NO_FONTS` (a + PDF/A file veraPDF rejects). #### Agent surface diff --git a/docs/KNOWLEDGE_BASE.md b/docs/KNOWLEDGE_BASE.md index 3e38d9f..7561613 100644 --- a/docs/KNOWLEDGE_BASE.md +++ b/docs/KNOWLEDGE_BASE.md @@ -49,6 +49,7 @@ Key properties: | `src/reconciler/render.ts` | `compile(node)` — drives the reconciler and serializes. | | `src/render.ts` | `renderToBytes/Blob/Stream/File/FileStream`, `compileDocument`, `inspectDocument`. | | `src/response.ts` | `renderToResponse` — web-standard `Response`, streaming by default. Server-only; **never** `'use client'`. | +| `src/client.ts` | The `pdfnative-react/client` subpath entry. Re-exports the hooks and viewer components; built separately so the `'use client'` directive reaches `dist/client.*`. | | `src/lint.ts` | `lintDocument` — runs on the *compiled* model, so JSX and `DocSpec` share one implementation. | | `src/registry.ts` | **Single source of truth**: block grammar, components, lint rules. Pure data, no engine import. See §9. | | `src/errors.ts` | `ErrorCode`, `PdfReactError`, `PdfStructureError`, `toErrorEnvelope`. | @@ -158,6 +159,11 @@ Notes learned the hard way: `viewerPreferences`/`debug` survive it. - `tests/hooks.test.tsx` — exercises `usePdf`/`usePdfStream` under jsdom, including the async `options.fonts` path. +- `tests/compile-snapshot.test.tsx` — a committed golden snapshot of the compiled + model for a document using every block and every document-level prop. The rest + of the suite asserts *shapes*; this asserts the whole output, so a serializer + change that silently drops a prop or reorders blocks cannot pass unnoticed. + When it changes, read the diff before running `vitest -u`. - `tests/viewer.test.tsx` — `PDFViewer`, `PDFDownloadLink` (both children forms) and `BlobProvider`. - `tests/spec.test.tsx` — asserts `compileSpec` parity with the equivalent JSX, @@ -259,12 +265,13 @@ CLI solved it by deriving both its shell completions and its capability manifest from one `COMMANDS` table; we apply the same idea, with a compile-time lock on top. -`src/registry.ts` holds three tables and imports nothing at runtime: +`src/registry.ts` holds four tables and imports nothing at runtime: | Table | Consumers | |---|---| -| `BLOCK_REGISTRY` | `spec/schema.ts` (`$defs.block.oneOf`, arity, descriptions), `spec/validate.ts` (arity + payload rules), `manifest.ts` (`specBlocks`) | +| `BLOCK_REGISTRY` | `spec/schema.ts` (`$defs.block.oneOf`, kind discriminators, arity, descriptions), `spec/validate.ts` (arity + payload rules), `manifest.ts` (`specBlocks`) | | `COMPONENT_REGISTRY` | `manifest.ts` (`components`) | +| `CLIENT_COMPONENT_REGISTRY` | `manifest.ts` (`clientComponents`) — the preview/download components, which emit no host tag and are therefore kept out of the `HostTag` exhaustiveness lock | | `LINT_RULES` | `lint.ts` (severities), `spec/schema.ts` (`lint-report` enum), `manifest.ts` (`lintRules`) | Two independent locks make omission a failure rather than a silent gap: @@ -300,10 +307,13 @@ output rather than guessing. Unknown top-level fields are a *warning*, not an error, which preserves forward compatibility when a newer spec meets an older package. -Tier 3 is where the real leverage is: eight of the eighteen lint rules -(`L_CHART_*`, `L_ATTACHMENTS_NEED_PDFA3`) mirror validation the engine performs -by **throwing mid-render**. `L_ATTACHMENTS_NEED_PDFA3` exists because writing -`samples/layout/watermark-header-footer.tsx` hit exactly that throw. +Tier 3 is where the real leverage is: eight of the eighteen lint rules — the +five `L_CHART_*` errors, `L_ATTACHMENTS_NEED_PDFA3`, `L_TAGGED_ENCRYPTED` and +`L_MAX_BLOCKS_EXCEEDED` — mirror validation the engine performs by **throwing +mid-render**. `L_ATTACHMENTS_NEED_PDFA3` exists because writing +`samples/layout/watermark-header-footer.tsx` hit exactly that throw; +`L_CHART_EMPTY` and `L_MAX_BLOCKS_EXCEEDED` because later review rounds found +three more engine throws with no rule behind them. ### Error taxonomy diff --git a/release-notes/draft/PR-v1.1.0.md b/release-notes/draft/PR-v1.1.0.md index 39582d0..861dd39 100644 --- a/release-notes/draft/PR-v1.1.0.md +++ b/release-notes/draft/PR-v1.1.0.md @@ -158,7 +158,7 @@ costs a layout pass. end to end, not just type-checked. - 8 new test files: `registry`, `chart`, `layout-sugar`, `response`, `lint`, `agent`, `schema`, `compile-snapshot`. `governance` and `version` extended. -- **79 → 224 tests**, 8 → 16 files, including a golden compile snapshot. +- **79 → 226 tests**, 8 → 16 files, including a golden compile snapshot. ### Docs & governance @@ -183,7 +183,7 @@ costs a layout pass. ``` npm run typecheck:all clean (src + tests + samples) npm run lint clean, zero warnings -npm test 224 passed / 224, 16 files +npm test 226 passed / 226, 16 files npm run test:coverage 94.77 stmts · 86.04 branches · 97.76 funcs · 95.80 lines (thresholds 85/80/85/85 — unchanged, not lowered) npm run build root ESM/CJS + client ESM/CJS + four .d.ts; postbuild verifies @@ -203,12 +203,31 @@ Additionally verified by hand: ## Adversarial review -**Five** independent reviews were run against this branch across two rounds — +**Five** independent reviews were run against this branch across three rounds — architecture, documentation accuracy, engine-1.6.0 gap analysis, ecosystem -state-of-the-art, and a final documentation pass. Every confirmed finding is -fixed. The second round is listed first, because it found the more serious -defects **and** caught three claims the first round's fixes had asserted but not -completed. +state-of-the-art, and a final go/no-go verification. Every confirmed finding is +fixed. Rounds are listed newest first, because each caught claims the previous +round's fixes had asserted but not completed. + +### Round 3 — go/no-go + +| Finding | Fix | +|---|---| +| **`CHANGELOG.md` still said "Six rules pre-empt"** and filed `L_MAX_BLOCKS_EXCEEDED` under "renders successfully but is wrong" — the last live instance of the very defect round 2 was named after. The round-2 sweep missed it because its regex read `six pre-empt` and the text reads `Six rules pre-empt` | Paragraph corrected. **The gate itself was the real defect**, so `AGENTS.md` now documents the sweep, with this miss as the cautionary example | +| **The `./client` subpath — new public API — was absent from `CHANGELOG.md`, from the user-facing release note, from `capabilityManifest()`, and from every sample.** So were the two other user-visible packaging fixes of round 2 (the `node:` prefix restoration, the tree-shaking win) | `### Added` entry; a release-note section; `contract.entry` / `contract.clientEntry` / `contract.reactServerCondition` and `clientComponents[].importFrom` on the manifest; both `samples/client/*` switched to `src/client.js` with the real-world import shown in the header | +| **The encryption security notice never reached the release note.** The CHANGELOG tells readers to re-render anything shipped with `layout.encryption`; the artifact users actually read did not | Dedicated `## Security` section, placed above the highlights | +| "Two independent adversarial reviews" in the release note, when there were five | Corrected, with the two *rejected* findings recorded | +| `docs/KNOWLEDGE_BASE.md` §3 had no `src/client.ts`, §6 had no `tests/compile-snapshot.test.tsx`, and §304's enumeration listed six under the word "eight" | All three fixed | +| "Three tables" survived in `docs/KNOWLEDGE_BASE.md` and `AGENTS.md`; `src/registry.ts` and `.github/copilot-instructions.md` still said the bundle strips the `'use client'` directive | All four corrected | +| **`publish.yml` verified only the four `dist/index.*` artifacts and never resolved the `exports` map** — both workflows imported by file path, so a wrong `types` target or a dropped condition would have shipped unseen | Now packs a tarball, installs it into a throwaway project, resolves `.` and `./client` in both conditions, and renders a real PDF from the installed package | +| **`postbuild.mjs` failed *open*** — its headline tree-shaking guard degraded to a `console.log` and exit 0 if `esbuild` was absent | Fails closed, with an explicit `POSTBUILD_SKIP_SHAKE_CHECK=1` opt-out | + +Two round-3 findings were **rejected after verification**: the reported +`upload-artifact` version inconsistency is shared verbatim by `pdfnative-cli` +and `pdfnative-mcp`, so "fixing" it would have created the ecosystem divergence +it claimed to remove; and a reported total test-suite failure turned out to be +an audit tool perturbing `node_modules` — `npm ci` from the committed lockfile +restores 224/224, which is also what CI does. ### Round 2 @@ -218,7 +237,7 @@ completed. | **`import { version }` pulled the entire React reconciler into a consumer's bundle** — 10 137 bytes for a string constant, and `react-reconciler` forced to resolve. A single-file bundle makes `sideEffects: false` inoperative | `/* @__PURE__ */` on `ReactReconciler(hostConfig)`, `HostTransitionContext`, `HOST_CONTEXT`, `LINT_RULE_CODES` and `BY_KIND`. Now **3 216 bytes, no reconciler**; postbuild fails the build if it regresses | | **`'use client'` never reached `dist/`**, so RSC users needed a hand-written wrapper — while `README.md` claimed the directive was carried | New **`pdfnative-react/client`** subpath export, built separately with the directive applied and verified by postbuild. The root bundle is asserted *not* to carry it | | **`L_MAX_BLOCKS` could not fire on the engine's default ceiling.** It checked only an explicit `layout.maxBlocks`, but the engine applies `DEFAULT_MAX_BLOCKS = 100 000` unconditionally and throws — so a large generated document linted clean and then crashed | `layout?.maxBlocks ?? 100_000`; test at 100 001 blocks | -| **"Six rules pre-empt an engine throw" was wrong — it is eight.** `L_TAGGED_ENCRYPTED` (`pdf-document.ts:169`) and `L_MAX_BLOCKS_EXCEEDED` (`:146`) both throw; the docs listed them as safe. Repeated in 7 files | Verified against each engine throw site and corrected everywhere | +| **"Six rules pre-empt an engine throw" was wrong — it is eight.** `L_TAGGED_ENCRYPTED` (`pdf-document.ts:169`) and `L_MAX_BLOCKS_EXCEEDED` (`:146`) both throw; the docs listed them as safe. Repeated in 7 files | Verified against each engine throw site and corrected in 7 of 8 sites — round 3 caught the eighth (`CHANGELOG.md`), which the sweep's own regex had missed | | **`schema('manifest')` described 10 of the manifest's 13 properties** — missing `clientComponents`, `errorClasses`, `schemaSubjects`, two of which were added *for* agent honesty. No test covered it | Completed, plus a test comparing `Object.keys(capabilityManifest())` to the schema's properties **and** `required` | | **`ChartProps` had no compile-time tie to `ChartBlock`**, while `docs/CHARTS.md` promises Charts-v2 fields "arrive as new `ChartProps`" | `ChartPropsCoversChartBlock` assert; verified destructively | | **`toBlock` had no exhaustiveness guard** — a new `HostTag` without a case compiled cleanly and failed at render, while the DocSpec side had a `never` guard since 1.0 | `const exhaustive: never`; verified destructively | @@ -331,7 +350,7 @@ Also dropped, with reasons recorded in `ROADMAP.md`: |---|---| | `no_new_runtime_dependency_confirmed` | ✅ `dependencies` is still exactly `["react-reconciler"]`, asserted by `tests/version.test.ts` | | `reproduction_command` | `npm run typecheck:all && npm run lint && npm run test:coverage && npm run build && npm pack --dry-run` | -| `reproduction_result` | All green; 224/224 tests; coverage above thresholds on all four axes; runtime `npm audit` clean | +| `reproduction_result` | All green on a clean `npm ci`; 226/226 tests; coverage above thresholds on all four axes; runtime `npm audit` clean; both export subpaths resolved from a real packed tarball | | `duplicate_search_performed` | N/A — release PR, not an issue report | | `affected_packages` | `pdfnative-react` only. Upstream `pdfnative` docs still reference `pdfnative-react v1.0.0` in `docs/guides/react.md`, `llms.txt`, `AGENTS.md` and `README.md` — a companion PR there would be worthwhile, and is **not** included here. | | `identity_reminder_shown` | ✅ This draft must be reviewed and submitted by a human under their own GitHub identity. You share responsibility for its content. | diff --git a/release-notes/v1.1.0.md b/release-notes/v1.1.0.md index 81a846d..2af016d 100644 --- a/release-notes/v1.1.0.md +++ b/release-notes/v1.1.0.md @@ -35,6 +35,28 @@ No API was removed, renamed, or changed in a backward-incompatible way. still importable from every path it was, and is still the same class object, so `instanceof` is unaffected. +## Security — re-render anything you encrypted + +Two engine fixes arrive with the `^1.6.0` floor, and both affect documents +**this package produced**. If you have ever shipped a document with +`layout.encryption`, re-render it. + +- **Encrypted documents leaked their outline, link URIs and metadata.** Before + engine 1.6.0 only *streams* were encrypted; strings were not. Because + `` derives bookmark titles from every ``, a + password-protected document produced here disclosed its section headings, its + `` targets and its `metadata` to anyone who opened the file without + the password. +- **AES-256 output was not spec-compliant.** The engine's R6 hash used SHA-256 + for every round instead of the SHA-256/384/512 rotation ISO 32000-2 + Algorithm 2.B requires, so `algorithm: 'aes256'` files written on engine + ≤ 1.5.0 were unreadable by strictly compliant readers. Output changes + bit-for-bit; the engine keeps a legacy fallback so old files still open. + +Neither is a defect in pdfnative-react's own code, and nothing you do at the +wrapper level worked around them — the fix is the engine upgrade this release +requires. See the Security section of the [CHANGELOG](../CHANGELOG.md). + ## Highlights ### Charts @@ -80,6 +102,31 @@ names. [Guide](../docs/SERVER.md) · [sample](../samples/server/next-route-handler.tsx) +### A client subpath, so RSC apps need no wrapper + +```tsx +import { PDFViewer, usePdf } from 'pdfnative-react/client'; +``` + +`pdfnative-react/client` ships with the `'use client'` directive already +applied — `usePdf`, `usePdfStream`, `PDFViewer`, `PDFDownloadLink` and +`BlobProvider`. The root barrel still exports them for apps with no RSC +boundary, and stays *unmarked* on purpose, because `renderToResponse` has to +remain server-safe. + +One boundary this does not move: importing the package from a Server Component +or a `'use server'` file still fails at module load, because the reconciler +needs `createContext` and React's `react-server` condition does not provide it. +Use a Route Handler — which is what the example above is. + +Two packaging fixes ship alongside it. The bundle now keeps the `node:` prefix +on its dynamic `node:fs/promises` import, without which Deno and Cloudflare +`nodejs_compat` could not resolve it — so the edge runtimes listed above now +genuinely build. And importing pure data no longer pulls in the React +reconciler: `import { version }` went from 10 137 bytes to 3 216, as did +`validateSpec`, `schema()` and `capabilityManifest()`. The build fails if either +regresses. + ### Document-level page furniture ```tsx @@ -207,7 +254,7 @@ your logs). - `npm run typecheck:all` — clean (src + tests + samples) - `npm run lint` — clean, zero warnings -- **224 tests across 16 files**, all green (was 79 across 8) +- **226 tests across 16 files**, all green (was 79 across 8) - Coverage **94.8% statements · 86.0% branches · 97.8% functions · 95.8% lines** (thresholds 85/80/85/85, unchanged) - `npm run build` — ESM + CJS + `.d.ts` + `.d.cts` @@ -215,15 +262,26 @@ your logs). - `npm pack --dry-run` — `llms.txt` present in the tarball - Every new sample executed end to end and verified to produce a valid PDF -This release was additionally put through two independent adversarial reviews — -one on architecture, one on documentation accuracy — before publication. Both -found real defects, and every confirmed finding is fixed in the code above: -a stack-overflow path in `validateSpec` on hostile input, prototype-chain -resolution in `schema()`, a mutable reference to the lint registry leaking -through a returned schema, four lint-rule bugs, an incomplete capability -manifest, an RFC 8187 encoding gap, a broken annotation example, and two stale -agent-instruction files. Two new lint rules (`L_CHART_EMPTY`, -`L_MAX_BLOCKS_EXCEEDED`) came directly out of that process. +This release was additionally put through **five independent adversarial +reviews** across three rounds — architecture, documentation accuracy, an +engine-1.6.0 gap analysis, an ecosystem state-of-the-art pass, and a final +go/no-go verification. Every confirmed finding is fixed. + +Among them: a stack-overflow path in `validateSpec` on hostile input, +prototype-chain resolution in `schema()`, a mutable reference to the lint +registry leaking through a returned schema, five lint-rule defects, an +incomplete capability manifest, an RFC 8187 encoding gap, a bundle that emitted +an unresolvable `fs/promises` specifier, a `'use client'` directive that never +reached `dist/`, and a broken annotation example. Three new lint rules +(`L_CHART_EMPTY`, `L_MAX_BLOCKS_EXCEEDED` and `L_ATTACHMENTS_NEED_PDFA3`) came +directly out of that process, each from a real engine exception the linter +could not previously pre-empt. + +Two review findings were **rejected after verification** rather than acted on — +a disputed coverage figure that turned out correct, and a workflow +"inconsistency" that is in fact shared with the sibling packages. The full +record, including what was deliberately not fixed and why, is in the +[PR draft](draft/PR-v1.1.0.md). ## Full changelog diff --git a/samples/client/use-pdf.tsx b/samples/client/use-pdf.tsx index 37b0e12..09a1747 100644 --- a/samples/client/use-pdf.tsx +++ b/samples/client/use-pdf.tsx @@ -4,12 +4,22 @@ * This is a browser/React component (not a standalone script): it renders a * document to a blob URL on the client and previews it in an