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/copilot-instructions.md b/.github/copilot-instructions.md index b1a0189..08432c5 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,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`). +- **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/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 324c0b8..676b3a7 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,23 @@ 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. 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. +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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6afd6a4..b9f5082 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,30 +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-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 + - 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 @@ -37,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 @@ -50,12 +77,48 @@ 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. + - 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/.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..81e8552 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 @@ -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/.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/AGENTS.md b/AGENTS.md index bff300d..1c440c3 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,61 @@ 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 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 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. + +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 @@ -88,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 e73fb53..f34f4a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,178 @@ 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. + +### 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 + 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. + +#### 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`**, + **`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?)`** — 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. +- **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 + +- **`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 — 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 + 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`, + `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. +- 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 First stable release. The public API is now covered by semantic versioning. @@ -114,7 +286,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,9 +70,9 @@ 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`). | +| `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. | @@ -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,19 +116,35 @@ 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. `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 @@ -134,11 +182,13 @@ 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. 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); @@ -181,17 +231,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 @@ -212,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 `` @@ -220,24 +320,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. + +## Upgrading to 1.1 -1.0 marks the API as stable. The only breaking change: **`pdfnative` is now a -peer dependency**, so install it yourself alongside the wrapper: +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 +382,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 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. + +**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..137fd96 --- /dev/null +++ b/docs/AGENT_CONTRACT.md @@ -0,0 +1,264 @@ +# 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** — 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. + +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 — 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_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` + +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 | +|---|---| +| `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_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). + +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..eac8271 --- /dev/null +++ b/docs/CHARTS.md @@ -0,0 +1,154 @@ +# 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_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 | +| `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..7561613 100644 --- a/docs/KNOWLEDGE_BASE.md +++ b/docs/KNOWLEDGE_BASE.md @@ -48,14 +48,36 @@ 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/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`. | +| `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 +129,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. @@ -127,11 +159,37 @@ 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, 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 +215,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 +250,106 @@ 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 four tables and imports nothing at runtime: + +| Table | Consumers | +|---|---| +| `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: + +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: 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 + +`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: `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 + +`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..6d072e1 --- /dev/null +++ b/docs/LINTING.md @@ -0,0 +1,182 @@ +# 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 + +Eighteen rules, each with a stable code. Branch on the code, not the message. + +### Errors — these clear `ok` + +| 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 | **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 | **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** | + +**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 + +| Code | Rule | +|---|---| +| `L_IMAGE_ALT` | Image with no alt text | +| `L_TABLE_HEADERS` | Table with no header row | +| `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 | +| `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..e55786d --- /dev/null +++ b/docs/RECIPES.md @@ -0,0 +1,227 @@ +# 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, 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: + +```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, validatePdfUA } from 'pdfnative'; + +const signed = signPdfBytes(bytes, { /* certificate, key, … */ }); +const report = validatePdfUA(bytes); // accessibility conformance +``` + +Annotations take three steps, because the modifier works on a *parsed* document +and `addAnnotation` takes a serialized dictionary, not an object: + +```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 +`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..25c22fa --- /dev/null +++ b/docs/SERVER.md @@ -0,0 +1,196 @@ +# 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); +}); +``` + +## The React Server Components boundary + +**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'; +export async function prepare(id: string): Promise { + await recordDownload(id); + return `/invoice/${id}`; // the route handler above +} +``` + +### Client components + +Import them from the **`pdfnative-react/client`** subpath, which ships with the +`'use client'` directive already applied: + +```tsx +import { PDFViewer, PDFDownloadLink, BlobProvider, usePdf, usePdfStream } + from 'pdfnative-react/client'; +``` + +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 + +`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..0f66cf0 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 @@ -61,11 +82,23 @@ async entries (renderToFile, renderToFileStream, usePdf, usePdfStream); for sync - fromUrl(url, init?) -> Promise (image bytes) - fromBase64(payload) -> Uint8Array (base64 or data: URI) -## Hooks & client components (carry 'use client') +## Hooks & client components (browser only) + +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 +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 }) @@ -82,13 +115,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 +135,72 @@ 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) + +18 rules — 10 error, 7 warning, 1 info. + +errors: L_EMPTY_DOCUMENT, L_TAGGED_NO_FONTS, L_TAGGED_ENCRYPTED, + 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 + +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 - 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..e21e57f 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" } }, @@ -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": [ { @@ -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": { @@ -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 fbf6dba..f3f8d8c 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", @@ -17,16 +17,27 @@ "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": [ "dist", + "llms.txt", "LICENSE", "README.md" ], "sideEffects": false, "scripts": { - "build": "tsup", + "build": "tsup && node scripts/postbuild.mjs", "dev": "tsup --watch", "test": "vitest run", "test:watch": "vitest", @@ -63,9 +74,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 +110,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 +132,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", @@ -115,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 new file mode 100644 index 0000000..861dd39 --- /dev/null +++ b/release-notes/draft/PR-v1.1.0.md @@ -0,0 +1,356 @@ +# 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 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 + 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 + +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. + +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; 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` + +`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). + +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. + +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; 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; + `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 + +- 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. +- 8 new test files: `registry`, `chart`, `layout-sugar`, `response`, `lint`, + `agent`, `schema`, `compile-snapshot`. `governance` and `version` extended. +- **79 → 226 tests**, 8 → 16 files, including a golden compile snapshot. + +### 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 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 + 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 +``` + +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). +- The corrected annotation recipe in `docs/RECIPES.md` executed end to end. + +## Adversarial review + +**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 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 + +| 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 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 | +| **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 | +|---|---| +| `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 | 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: + +- **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. +- **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 + +| 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 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 new file mode 100644 index 0000000..2af016d --- /dev/null +++ b/release-notes/v1.1.0.md @@ -0,0 +1,288 @@ +# 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. + +## 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 + +```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) + +### 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 + +``` + +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 } +``` + +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_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. + +[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 +- **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` +- 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 **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 + +[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..8064540 --- /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.tsx + * 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/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