@lupinum/nuxt-pdf 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/API_REPORT.md +262 -0
  2. package/CHANGELOG.md +168 -0
  3. package/CONFORMANCE.md +496 -0
  4. package/LICENSE +21 -0
  5. package/README.md +203 -0
  6. package/THIRD_PARTY_NOTICES.md +75 -0
  7. package/dist/module.d.mts +24 -0
  8. package/dist/module.json +12 -0
  9. package/dist/module.mjs +368 -0
  10. package/dist/runtime/authoring.d.ts +141 -0
  11. package/dist/runtime/authoring.js +25 -0
  12. package/dist/runtime/components/_props.d.ts +196 -0
  13. package/dist/runtime/components/_props.js +63 -0
  14. package/dist/runtime/components/document.d.ts +9 -0
  15. package/dist/runtime/components/document.js +26 -0
  16. package/dist/runtime/components/index.d.ts +4 -0
  17. package/dist/runtime/components/index.js +27 -0
  18. package/dist/runtime/components/svg.d.ts +17 -0
  19. package/dist/runtime/components/svg.js +50 -0
  20. package/dist/runtime/composables/index.d.ts +2 -0
  21. package/dist/runtime/composables/index.js +3 -0
  22. package/dist/runtime/composables/use-pdf-page-numbers.d.ts +39 -0
  23. package/dist/runtime/composables/use-pdf-page-numbers.js +17 -0
  24. package/dist/runtime/define-pdf.d.ts +6 -0
  25. package/dist/runtime/define-pdf.js +5 -0
  26. package/dist/runtime/fonts.d.ts +16 -0
  27. package/dist/runtime/fonts.js +0 -0
  28. package/dist/runtime/renderer/index.d.ts +3 -0
  29. package/dist/runtime/renderer/index.js +1 -0
  30. package/dist/runtime/renderer/node-ops.d.ts +5 -0
  31. package/dist/runtime/renderer/node-ops.js +281 -0
  32. package/dist/runtime/renderer/patch-prop.d.ts +3 -0
  33. package/dist/runtime/renderer/patch-prop.js +223 -0
  34. package/dist/runtime/renderer/render-component.d.ts +14 -0
  35. package/dist/runtime/renderer/render-component.js +201 -0
  36. package/dist/runtime/renderer/types.d.ts +33 -0
  37. package/dist/runtime/renderer/types.js +56 -0
  38. package/dist/runtime/renderer/validate-tree.d.ts +3 -0
  39. package/dist/runtime/renderer/validate-tree.js +428 -0
  40. package/dist/runtime/server/assets/errors.d.ts +12 -0
  41. package/dist/runtime/server/assets/errors.js +15 -0
  42. package/dist/runtime/server/assets/remote.d.ts +48 -0
  43. package/dist/runtime/server/assets/remote.js +234 -0
  44. package/dist/runtime/server/assets/resolve-asset.d.ts +53 -0
  45. package/dist/runtime/server/assets/resolve-asset.js +482 -0
  46. package/dist/runtime/server/engine/CONTRACTS.md +386 -0
  47. package/dist/runtime/server/engine/fonts.d.ts +8 -0
  48. package/dist/runtime/server/engine/fonts.js +19 -0
  49. package/dist/runtime/server/engine/layout-passes.d.ts +42 -0
  50. package/dist/runtime/server/engine/layout-passes.js +58 -0
  51. package/dist/runtime/server/engine/react-pdf-pdfkit.d.ts +7 -0
  52. package/dist/runtime/server/engine/render-document.d.ts +31 -0
  53. package/dist/runtime/server/engine/render-document.js +236 -0
  54. package/dist/runtime/server/index.d.ts +5 -0
  55. package/dist/runtime/server/index.js +9 -0
  56. package/dist/runtime/server/preview.d.ts +17 -0
  57. package/dist/runtime/server/preview.js +332 -0
  58. package/dist/runtime/server/registry.d.ts +41 -0
  59. package/dist/runtime/server/registry.js +270 -0
  60. package/dist/runtime/server/render-limits.d.ts +51 -0
  61. package/dist/runtime/server/render-limits.js +143 -0
  62. package/dist/runtime/server/result.d.ts +6 -0
  63. package/dist/runtime/server/result.js +81 -0
  64. package/dist/runtime/server/tsconfig.json +3 -0
  65. package/dist/runtime/shared/errors.d.ts +22 -0
  66. package/dist/runtime/shared/errors.js +22 -0
  67. package/dist/runtime/shared/index.d.ts +4 -0
  68. package/dist/runtime/shared/index.js +7 -0
  69. package/dist/runtime/shared/template.d.ts +63 -0
  70. package/dist/runtime/shared/template.js +1 -0
  71. package/dist/shared/nuxt-pdf.D2ZziYn4.mjs +1132 -0
  72. package/dist/test.d.mts +198 -0
  73. package/dist/test.mjs +696 -0
  74. package/dist/types.d.mts +13 -0
  75. package/package.json +135 -0
@@ -0,0 +1,386 @@
1
+ # React PDF engine contracts
2
+
3
+ Nuxt PDF replaces React PDF's reconciler and reuses its published lower-level engine packages. This file records every non-obvious contract relied on by the compatibility kernel.
4
+
5
+ **Reference commit:** `d41a8207fb06a56e60fcb53ac0e18ce27e7d32d6`
6
+
7
+ ## Pinned packages
8
+
9
+ | Package | Version | Imported symbols |
10
+ |---|---:|---|
11
+ | `@react-pdf/font` | 4.0.8 | default `FontStore` |
12
+ | `@react-pdf/layout` | 4.6.1 | default `layoutDocument`, `DocumentNode`, `SafeDocumentNode` |
13
+ | `@react-pdf/pdfkit` | 5.1.1 | default `PDFDocument` |
14
+ | `@react-pdf/primitives` | 4.3.0 | primitive string constants |
15
+ | `@react-pdf/render` | 4.5.1 | default `renderPDF` |
16
+
17
+ All runtime imports use package roots. There are no imports from unpublished source paths and no patches.
18
+
19
+ ## Engine upgrade drill (runbook)
20
+
21
+ These pins are deliberate. React PDF ships no semver guarantee for the
22
+ lower-level engine packages, so every bump is decided by conformance evidence,
23
+ not by the version number. Run this drill whenever a newer stable release
24
+ appears.
25
+
26
+ **Steps**
27
+
28
+ 1. Check for newer *stable* releases only:
29
+ `npm view <pkg> dist-tags` for each of `@react-pdf/layout`, `render`, `font`,
30
+ `pdfkit`, `primitives`. Compare each `latest` tag against the pins table
31
+ above. Ignore `beta`/`reactpdf`/other pre-release tags. If no `latest`
32
+ exceeds its pin, record that fact and stop. The pins hold.
33
+ 2. On branch `drill/engine-bump`, bump all five pins together (keep
34
+ `@react-pdf/renderer` aligned with `render`), then `pnpm install`. Bisect
35
+ per-package only if the combined bump fails.
36
+ 3. Run the full evidence chain and record exact results:
37
+ - `pnpm lint`
38
+ - `pnpm test` (including the paired conformance fixtures and raster
39
+ baselines)
40
+ - `pnpm typecheck`
41
+ 4. Re-verify each documented contract below against the new engine source in the
42
+ read-only reference checkout (`react-pdf/`). Update the **Reference commit**
43
+ and this file for any wording that changed. The contracts most likely to
44
+ drift silently. Verify these contracts by reading the source:
45
+ - Layout contract: `layoutDocument(document, fontStore)` still forwards
46
+ `fontStore` as the second argument.
47
+ - Dynamic text contract: dynamic-node detection still keys on the `render`
48
+ prop.
49
+ - Layout purity: `transformLineHeight('')` still returns the fixed-point
50
+ sentinel; `resolveBookmarks` still mutates in place; pagination still
51
+ shares the same node object for fixed nodes across pages.
52
+ - Named destination contract: `setDestination` / `NameTree` last-write-wins
53
+ behavior is unchanged.
54
+
55
+ **Gates:** Every gate must pass:
56
+ `pnpm lint`, `pnpm test`, and `pnpm typecheck` (the release gate is
57
+ `pnpm release:verify`).
58
+
59
+ **Raster baselines:** A changed raster diff is *expected* upgrade evidence, not
60
+ an automatic failure. Inspect each failing baseline visually and classify:
61
+ - *benign drift* (sub-pixel antialiasing, identical layout): update the
62
+ baseline and note it in the merge commit;
63
+ - *rendering regression* (moved, missing, or reflowed content): treat it as a failed
64
+ gate.
65
+
66
+ **Decision rule**
67
+
68
+ - If all gates pass and every contract is verified, **merge** the bump to `main`
69
+ with the updated pins table, Reference commit, and CONFORMANCE.md version
70
+ table.
71
+ - If a gate fails or the fix is not small, **revert**
72
+ (delete the branch), keep the pins, and record in this file precisely *what*
73
+ broke and *which contract caught it*. A revert with a recorded cause is a
74
+ successful drill.
75
+
76
+ **Drill log**
77
+
78
+ - **2026-07-21:** All five `latest` dist-tags equaled the current pins
79
+ (`layout` 4.6.1, `render` 4.5.1, `font` 4.0.8, `pdfkit` 5.1.1, `primitives`
80
+ 4.3.0). Only pre-release `2.0.0-beta.*` tags and pdfkit's legacy `reactpdf`
81
+ 0.8.5 were newer, and neither is a stable upgrade. No bump. Pins hold.
82
+
83
+ ## Host node contract
84
+
85
+ The Vue renderer creates the same plain node shape as `@react-pdf/renderer`:
86
+
87
+ ```ts
88
+ type ElementNode = {
89
+ type: string
90
+ box: Record<string, never>
91
+ style: unknown
92
+ props: Record<string, unknown>
93
+ children: Node[]
94
+ }
95
+
96
+ type TextInstance = {
97
+ type: 'TEXT_INSTANCE'
98
+ value: string
99
+ }
100
+ ```
101
+
102
+ Only `DOCUMENT` is accepted as the root passed to layout. Raw text is retained only below `TEXT`, `LINK`, `TSPAN`, and `NOTE`. Vue comments and fragment anchors are renderer bookkeeping and never enter `children`.
103
+
104
+ Protected by `test/renderer.test.ts` and the paired conformance fixture. An incompatible change will fail exact tree assertions or layout before PDF serialization.
105
+
106
+ ### Closed authoring-input contract
107
+
108
+ The host-node shape matches the engine; the public input surface deliberately
109
+ does not match every upstream prop or stylesheet key. `runtime/authoring.ts` is
110
+ the single client-safe vocabulary shared by Vue components and the renderer;
111
+ the host-node model remains internal to `runtime/renderer/types.ts`. `PdfStyle`
112
+ is owned by Nuxt PDF and lists only the value shapes and units covered by this
113
+ repository's fixtures. Primitive props are likewise closed: `patchPdfProp` uses
114
+ an exact per-primitive allowlist tied to the exported prop types, then the
115
+ complete-tree validator checks cross-prop and context invariants. This catches
116
+ Vue fallthrough attributes that TypeScript cannot reliably exclude. Unknown
117
+ props, DOM/event attributes, props on the wrong primitive, both/neither image
118
+ sources, both/neither link targets, and page-flow/SVG text branch violations all
119
+ fail with `PDF_TREE_INVALID`; none reach layout as best-effort passthroughs.
120
+
121
+ `PdfImage` therefore has exactly one of `src`/`source`, and `PdfLink` exactly one
122
+ of `href`/`src`. SVG `PdfText` requires `x` and `y`, uses direct `fill` for paint,
123
+ and rejects page-flow-only props. `PdfTspan` accepts only `x`, `y`, and `fill`.
124
+ `PdfG` and shapes accept their direct presentation props but no generic `style`;
125
+ `PdfSvg.style` remains page-flow sizing/positioning, while SVG text style is for
126
+ text metrics rather than SVG paint.
127
+
128
+ Protected by the real Vue SFC/type fixtures and renderer invalid-tree tests. A
129
+ new public prop or style key requires types, runtime admission, invariant tests,
130
+ and conformance evidence together; there is no compatibility passthrough.
131
+
132
+ ## Layout contract
133
+
134
+ `layoutDocument(document, fontStore)` accepts a `DocumentNode` and returns a `SafeDocumentNode`. The published JavaScript pipeline forwards additional arguments to every layout step and React PDF itself passes `fontStore` as the second argument. The published declaration incorrectly exposes only the document argument, so Nuxt PDF narrows this one call through a local function signature.
135
+
136
+ Layout resolves styles, assets, text, dimensions, pagination, page indices, links, and bookmarks. Nuxt PDF does not duplicate those stages.
137
+
138
+ The layout result is derived and disposable. The mounted Vue tree remains the canonical pre-layout tree.
139
+
140
+ Protected by the engine integration and paired conformance tests. An incompatible change will fail type checking, semantic PDF assertions, or raster comparison.
141
+
142
+ ### Layout purity contract (multi-pass re-layout)
143
+
144
+ Registry ownership sits above this contract: after every page-number feed the
145
+ live Vue tree is re-admitted (tree limits, metadata, image/remote policy) before
146
+ the next layout. Layout itself still must treat the admitted tree as immutable
147
+ input.
148
+
149
+ The multi-pass loop (`layout-passes.ts`, used to resolve table-of-contents page
150
+ numbers) lays out the **same mounted tree repeatedly**, once per fixed-point
151
+ pass, without cloning it. This works because `layoutDocument` treats its
152
+ input as immutable for every node it re-parents:
153
+
154
+ - **Every pipeline step rebuilds nodes with `Object.assign`, never in place.**
155
+ `layoutDocument` is `asyncCompose(...)` (`layout/src/index.ts:20-38`), which
156
+ applies steps right-to-left. Each step maps children into a fresh array and
157
+ returns `Object.assign({}, node, { updated properties })`. See `resolveStyles.ts:49,53`,
158
+ `resolveOrigins.ts:13`, `resolveZIndex`, `resolveDimensions.ts:229,235`. The
159
+ first structural step to touch a node's descendants (`resolveStyles`, executed
160
+ 5th) deep-copies the whole subtree, so pagination, dimensions, and text layout
161
+ all mutate **copies**, never the input.
162
+ - **`box` on the input is never populated.** After `resolveStyles` a copied node
163
+ shares the original `box` reference, but `resolveDimensions` builds a brand-new
164
+ `box` object (`resolveDimensions.ts:221,229`) rather than writing into it, so
165
+ the mounted tree's `box` stays `{}`. Verified empirically: two full layouts of
166
+ the same mounted document leave `page.box` an empty object.
167
+ - **The one in-place mutation is `resolveBookmarks`.** It assigns
168
+ `child.props.bookmark = newHierarchy` on the original node's props
169
+ (`resolveBookmarks.ts:52`), executing before `resolveStyles` copies, so it
170
+ **does** write derived state back onto the canonical tree. `newHierarchy` is
171
+ `{ ref, parent, ...bookmark }`: when `bookmark` is already a resolved object
172
+ (as it is on the second pass), the spread carries the previous pass's `ref` and
173
+ `parent` forward, so re-layout is **not** idempotent whenever a node's stable
174
+ bookmark reference is not re-patched (a string bookmark, or a hoisted object).
175
+ `renderDocumentMultiPass` therefore snapshots every bookmark-carrying node's
176
+ authored `props.bookmark` before the loop and restores it before every pass
177
+ (`layout-passes.ts` `snapshotBookmarks`/`restoreBookmarks`). resolveBookmarks
178
+ only reassigns the reference (never mutates the object), so restoring the
179
+ captured reference is a complete reset. Protected by `test/bookmarks.test.ts`,
180
+ whose ancestry-shift fixture fails without the reset.
181
+
182
+ Because the mounted tree is otherwise untouched, the loop feeds each pass's
183
+ destination-page map back through a reactive prop, and our renderer **re-patches the
184
+ same node objects in place** (node identity is asserted stable across passes in
185
+ `test/toc-multipass.test.ts`); no per-pass re-mount and no structural clone are
186
+ required. Convergence is a fixed point. The map that a layout *produces* equals
187
+ the map that it used. An ordinary document reaches this state in **2 passes**.
188
+ Non-convergence (a TOC entry whose size depends on the number it prints) is
189
+ capped at `maxPasses` (default 5) and raises `PDF_LIMIT_EXCEEDED`.
190
+
191
+ ### Named destination / internal link contract
192
+
193
+ A node's `id` prop becomes a PDF named destination anchored at the node's page
194
+ and `box.top` (`render/src/operations/setDestination.ts:10`, called from
195
+ `renderNode.ts` for **every** node). A `Link` whose `src` (or `href`) starts with
196
+ `#` renders as a `goTo` to that name rather than an external `link`
197
+ (`render/src/operations/setLink.ts:7,13`). pdfjs reports the resulting Link
198
+ annotation's `dest` as the raw id string.
199
+
200
+ **First-page ownership (a Nuxt-PDF divergence).** Pagination splits a node that
201
+ crosses a page boundary into a fragment on every page it touches, each keeping
202
+ `props.id`. Upstream calls `setDestination` for all of them and pdfkit's NameTree
203
+ is last-writer-wins, so React PDF's destination for a page-spanning `id` points
204
+ at the section's **last** page. Nuxt PDF instead anchors every destination at the
205
+ section's **first** page, because a table-of-contents entry names where a section
206
+ *begins*. Two seams enforce this, both in `render-document.ts`:
207
+
208
+ - `extractDestinationPages` is **first-writer-wins** over the ordered page list
209
+ (`layout.children`), so the destination-page map that the multi-pass loop feeds back (and
210
+ the printed TOC number) is the earliest page an id appears on.
211
+ - `serializePdfLayout` runs `anchorDestinationsAtFirstPage`, which walks the
212
+ final pages in order and deletes `props.id` from every fragment of an
213
+ already-seen id **before** painting, so the single surviving `setDestination`
214
+ call targets the first fragment. The NameTree entry for a click has the same target.
215
+ This runs for the single-pass and multi-pass paths alike and mutates
216
+ only the derived, disposable layout.
217
+
218
+ The printed TOC number and the jump target thus share one source of truth.
219
+ Protected by `test/toc-multipass-invariants.test.ts` (the page-spanning regression),
220
+ `test/toc-multipass.test.ts`, and `test/internal-links.test.ts` (paired with
221
+ React on non-splitting targets, where first- and last-page resolution agree).
222
+
223
+ ## Dynamic text contract
224
+
225
+ Layout detects a dynamic node when the `render` key exists in its props. The Vue renderer must therefore delete nullish props instead of retaining `render: undefined`.
226
+
227
+ During pagination, layout invokes dynamic callbacks first with `pageNumber`, and later with final page and subpage totals. Its internal instance converter accepts scalar strings and numbers but assumes React-shaped objects for element results. Nuxt PDF 0.1 exposes only synchronous dynamic text callbacks returning scalar text. Vue VNodes are not supported as dynamic callback results.
228
+
229
+ **Dynamic lineHeight shield.** Before layout, `renderDocument` gives every dynamic
230
+ `TEXT` node its own `lineHeight: ''` on the disposable mounted tree. A dynamic
231
+ node has a `render` function in its props. `normalizeDynamicTextLineHeight`
232
+ replaces the `node.style` reference and does not mutate the shared object.
233
+
234
+ This behavior works around an upstream non-idempotency. `lineHeight` is the only
235
+ inherited property whose stylesheet transform is not a fixed point.
236
+ `transformLineHeight` returns `lineHeight * fontSize` for any unitless or absolute
237
+ number. See `@react-pdf/stylesheet` at `src/resolve/text.ts:48-67`. Pagination
238
+ resolves the styles of dynamic nodes more than once. See `@react-pdf/layout`,
239
+ `resolvePagination.ts`, and `resolveStyles.ts:62-71`. Each pass multiplies the
240
+ absolute value by `fontSize` again. The dynamic line box can then leave the page.
241
+
242
+ The value `''` stops `transformLineHeight`. See `text.ts:53`. The inheritance
243
+ merge lets the node's own `''` override the changing ancestor value. See
244
+ `resolveInheritance.ts:53-64`. Textkit then uses the font-derived line height.
245
+ This behavior differs from `@react-pdf/renderer` 4.5.1, which has the same defect
246
+ and drops these footers. Static text is not affected because its lines are fixed
247
+ after the first layout. Static text keeps its inherited `lineHeight`.
248
+
249
+ Protected by callback and page-number conformance tests, plus a geometry-equality test asserting the dynamic footer matches the static equivalent while body text keeps its inherited `lineHeight`. Expanding dynamic results requires a new explicit layout seam or an upstream change; it must not be implemented with a React-shaped compatibility object.
250
+
251
+ ## SVG contract
252
+
253
+ The Vue SVG primitives create the same host nodes the engine already draws; no
254
+ engine code is involved beyond the pinned `@react-pdf/layout` and
255
+ `@react-pdf/render` packages. The relied-on facts:
256
+
257
+ - **Svg is a self-contained flex leaf.** `resolveDimensions` gives an `SVG` node
258
+ a Yoga measure function derived from its `viewBox` aspect ratio
259
+ (`layout/src/svg/measureSvg.ts`), so it lays out like an `Image` in normal
260
+ page flow. `renderNode` does not recurse into SVG children. `renderSvg`
261
+ walks the subtree itself (`render/src/primitives/renderNode.ts`). Therefore
262
+ `SVG` is a valid child of `PAGE`/`VIEW` even though the upstream
263
+ `layout/src/types/{view,page}.ts` child unions omit `SvgNode`. That omission
264
+ is an incomplete advisory type, not a runtime rule; `node-ops.ts` deliberately
265
+ adds `Svg` to `PAGE_CHILDREN` on the evidence of the measure function. A future
266
+ reader must not "correct" this back to match the upstream union.
267
+ - **SVG presentation is a direct-prop contract.** `resolveSvg` keys off exact
268
+ camelCase names (`strokeWidth`, `fillOpacity`, `stopColor`, `viewBox`) via
269
+ `parseProps`/`pickStyleProps`; `transform` is a prop on SVG groups/shapes,
270
+ unlike `View`, where it is a style key. Although upstream internally merges a
271
+ node's style and props, Nuxt PDF does not expose generic style on `PdfG`,
272
+ shapes, or `PdfTspan`, so that merge is not public precedence behavior. The
273
+ thin components coerce kebab-case template attributes to camelCase
274
+ (`compactProps`); `data-`/`aria-` names stay untouched so `patchPdfProp`
275
+ rejects them.
276
+ - **The numeric surface is parsed and validated before layout.** Geometry is a
277
+ finite number/numeric string, plus percentages only for `PdfSvgLength` props;
278
+ stroke width excludes percentages. Dimensions/radii are non-negative,
279
+ opacities and stops stay in their unit intervals, `viewBox` has four finite
280
+ numbers with positive dimensions, and transforms are one to three unitless
281
+ `translate`/`rotate` operations. This is narrower than arbitrary browser SVG
282
+ syntax by design.
283
+ - **`url(#id)` references are scoped and fail closed.** Upstream `getDefs`
284
+ indexes a `DEFS` subtree, `replaceDefs` substitutes `fill`/`clipPath`
285
+ references, and then detaches `DEFS`. Nuxt PDF validates before layout that an
286
+ SVG has at most one `PdfDefs`, ids are safe and unique within that SVG, fills
287
+ target gradients, and clip paths target `PdfClipPath`. Missing, malformed, or
288
+ incompatible references fail with `PDF_TREE_INVALID`, so upstream's dangling
289
+ reference fallback is intentionally unreachable. Definition ids are scoped
290
+ per SVG and are a separate namespace from document destination ids.
291
+ - **Resolved explicit zeroes are repaired before paint.** Layout parses author
292
+ strings to numbers and resolves a gradient reference into `node.props.fill`;
293
+ the pinned renderer then uses truthiness fallbacks that lose some numeric
294
+ zeroes. `serializePdfLayout` walks the disposable resolved tree immediately
295
+ before `renderPDF`: `fillOpacity: 0` becomes the truthy numeric string `'0'`;
296
+ `strokeWidth: 0` clears the stroke so PDFKit cannot interpret width zero as a
297
+ hairline; linear-gradient `x2` and radial-gradient `cx`/`cy`/`fx`/`fy`/`r`
298
+ zeroes become `'0'` on the resolved definition object. Under the closed
299
+ object-bounding-box gradient contract the renderer coerces those strings back
300
+ to numeric zero at use, preserving authored SVG semantics.
301
+ - **Scope exclusions.** `Marker` (and `markerStart`/`Mid`/`End`) has additional
302
+ `resolveSvg` container logic that is intentionally not exposed; the `Defs`
303
+ child set and prop types exclude it. `List`, `Canvas`, `ImageBackground`, and
304
+ form primitives are also out of scope.
305
+
306
+ Protected by `test/svg-conformance.test.ts` (semantic/raster parity, direct SVG
307
+ text fill, and zero-value regressions) and the SVG nesting, prop-surface,
308
+ reference-scope, numeric, transform, and casing tests in
309
+ `test/renderer.test.ts`.
310
+
311
+ ## PDFKit contract
312
+
313
+ `@react-pdf/pdfkit` exports a readable `PDFDocument` constructor but publishes no TypeScript declaration. Nuxt PDF carries a narrow ambient declaration for only the constructor and readable-stream behavior it uses.
314
+
315
+ The constructor receives metadata and rendering options. `renderPDF(context, layout)` paints pages, calls `context.end()`, and returns the same readable context. Nuxt PDF must not call `end()` again.
316
+
317
+ Protected by PDF magic-byte, parse, metadata, stream completion, and stream error tests. The local declaration must grow only when a concrete call site requires another member.
318
+
319
+ ## Font contract
320
+
321
+ A fresh `FontStore` contains the standard PDF fonts. Additional local fonts are registered before layout and the same store is passed to `layoutDocument`.
322
+
323
+ Font resolution runs *inside* the layout pipeline (`resolveAssets` calls
324
+ `fontStore.load`), so a missing family throws during `layoutDocument`. Nuxt PDF
325
+ surfaces this as a single `PDF_LAYOUT_ERROR` carrying React PDF's own exact
326
+ message (`Font family not registered: <family>` / `Could not resolve font for
327
+ <family>, fontWeight <weight>`). Font failures are deliberately **not** sub-classified
328
+ into a separate error code: `@react-pdf/font` throws a plain `Error` with no
329
+ machine-readable signal, and the only way to isolate the font sub-stage would be
330
+ to walk the tree and call `fontStore.getFont` per descriptor. This would duplicate the
331
+ `resolveAssets` traversal this file forbids. The precise family is already named
332
+ in the preserved message, so no re-classification is needed.
333
+
334
+ Protected by the local-font conformance fixture. Asset policy, path validation, and registration ownership live outside the engine and must run before layout.
335
+
336
+ ## Remote resource boundary
337
+
338
+ Both of React PDF's remote fetch seams are structurally unreachable. Our code
339
+ converts every allowlisted remote image into validated bytes before layout, and
340
+ only registers build-embedded local font data URLs.
341
+
342
+ - **Image seam.** `@react-pdf/image` `resolve.ts` `fetchRemoteFile` calls global
343
+ `fetch(src.uri, {method, headers, body, credentials})` and reads
344
+ `response.arrayBuffer()` with no allowlist, timeout, byte cap, or redirect
345
+ control, selected by `resolveImageFromUrl` for any non-`file:` URL. Our
346
+ `resolvePdfImageAssets` replaces every image source with a validated buffer,
347
+ so layout always takes the `resolveImageFromData` branch and never reaches
348
+ `fetchRemoteFile`.
349
+ - **Font seam.** `@react-pdf/font` `font-source.ts` `fetchFont` calls global
350
+ `fetch(src, options)` when `isUrl(this.src)` is true. Our `createPdfFontStore`
351
+ only registers `data:font/(otf|ttf);base64,...` sources, so `FontSource._load`
352
+ always takes the data-URL branch and never reaches `fetchFont`.
353
+
354
+ When `pdf.remote` is configured, `fetchRemoteResource` (`assets/remote.ts`)
355
+ accepts exact HTTPS image prefixes only, re-checks every one of at most three
356
+ redirects, sends credential-less `GET`s, and uses the render's shared deadline,
357
+ abort signal, request/concurrency limits, and byte budgets. PNG/JPEG structure
358
+ and dimensions are validated before the engine receives bytes. Remote fonts are
359
+ unconditionally rejected. The engine's own fetch code stays dead. Protected by
360
+ `test/remote.test.ts`.
361
+
362
+ ## Deliberately unused contracts
363
+
364
+ ### Worker cancellation decision
365
+
366
+ The post-admission spike mounted 12,000 ordinary View/Text rows (about 36,000
367
+ canonical nodes, below the default 50,000-node cap) under a 10 ms deadline. On
368
+ the development reference machine it reached the first cooperative check after
369
+ roughly 4.5 seconds. This proves the deadline is not hard cancellation.
370
+
371
+ A worker was not adopted. The Vue component and its setup/module closures are
372
+ the canonical document source and cannot be transferred to a worker. Re-importing
373
+ the generated Nitro registry inside a worker requires a second worker-specific
374
+ entry/bundle and did not satisfy the single portable Node/Nitro path required for
375
+ both node-server and serverless builds. Serializing a second document schema is
376
+ explicitly out of scope. Admission budgets plus cooperative checks therefore
377
+ remain the supported boundary; hard cancellation is a documented non-feature.
378
+
379
+ - React reconciler and renderer lifecycle
380
+ - React hooks and DOM helpers
381
+ - `PDFViewer`, `PDFDownloadLink`, and `BlobProvider`
382
+ - deprecated string rendering
383
+ - private PDFKit page-mode mutation
384
+ - arbitrary React-node dynamic render results
385
+
386
+ Any proposal to use one of these requires a documented acceptance criterion and an update to this file.
@@ -0,0 +1,8 @@
1
+ import type { BundledPdfFontDescriptor } from '../../fonts.js';
2
+ declare const pdfFontStoreBrand: unique symbol;
3
+ /** Opaque server-only font store accepted by the PDF engine. */
4
+ export type PdfFontStore = {
5
+ readonly [pdfFontStoreBrand]: true;
6
+ };
7
+ export declare const createPdfFontStore: (fonts?: readonly BundledPdfFontDescriptor[]) => PdfFontStore;
8
+ export {};
@@ -0,0 +1,19 @@
1
+ import FontStore from "@react-pdf/font";
2
+ const isBundledFontSource = (src) => /^data:font\/(?:otf|ttf);base64,[A-Za-z0-9+/]+={0,2}$/.test(src);
3
+ export const createPdfFontStore = (fonts = []) => {
4
+ const fontStore = new FontStore();
5
+ for (const font of fonts) {
6
+ if (!isBundledFontSource(font.src)) {
7
+ throw new TypeError(
8
+ `PDF font "${font.family}" must use a validated embedded font source.`
9
+ );
10
+ }
11
+ fontStore.register({
12
+ family: font.family,
13
+ src: font.src,
14
+ fontStyle: font.fontStyle,
15
+ fontWeight: font.fontWeight
16
+ });
17
+ }
18
+ return fontStore;
19
+ };
@@ -0,0 +1,42 @@
1
+ import type { DocumentNode, SafeDocumentNode } from '@react-pdf/layout';
2
+ import { type PdfFontStore } from './fonts.js';
3
+ import type { RenderLimits } from '../render-limits.js';
4
+ import { extractDestinationPages, type DestinationPageMap } from './render-document.js';
5
+ export { extractDestinationPages, type DestinationPageMap };
6
+ /**
7
+ * A mountable document whose page-number feedback can be re-fed between passes.
8
+ * In production the registry backs `feed` with `MountedPdfComponent.feedPageNumbers`,
9
+ * which pushes the map into the reactive record the `usePdfPageNumbers()`
10
+ * composable exposes; engine tests back it with a reactive prop through `update`.
11
+ */
12
+ export interface MultiPassSource {
13
+ /** The live mounted document root; re-patched in place by `feed`. */
14
+ readonly document: DocumentNode;
15
+ /** Push the latest id→page map into the live tree and await the renderer flush. */
16
+ feed: (pages: DestinationPageMap) => Promise<void>;
17
+ }
18
+ export interface MultiPassOptions {
19
+ fontStore?: PdfFontStore;
20
+ compress?: boolean;
21
+ /** Hard cap on layout passes before declaring non-convergence. */
22
+ maxPasses?: number;
23
+ /** Render limits bounding the whole loop (page cap + shared time budget). */
24
+ limits?: RenderLimits;
25
+ }
26
+ export interface MultiPassResult {
27
+ bytes: Uint8Array;
28
+ layout: SafeDocumentNode;
29
+ /** Number of layout passes actually run (a converged doc needs ≥ 2). */
30
+ passes: number;
31
+ /** The stable id→page map the serialized PDF was produced with. */
32
+ pages: DestinationPageMap;
33
+ }
34
+ /**
35
+ * Lay the mounted document out repeatedly, feeding each pass's destination→page
36
+ * map back into the live tree, until the map the layout PRODUCES equals the map
37
+ * it was laid out WITH (a fixed point). Serializes that converged layout once.
38
+ *
39
+ * Throws a `PDF_LIMIT_EXCEEDED` NuxtPdfError on non-convergence rather than
40
+ * looping forever or shipping silently wrong page numbers.
41
+ */
42
+ export declare const renderDocumentMultiPass: (source: MultiPassSource, options?: MultiPassOptions) => Promise<MultiPassResult>;
@@ -0,0 +1,58 @@
1
+ import { createPdfFontStore } from "./fonts.js";
2
+ import { NuxtPdfError, PDF_ERROR_CODES } from "../../shared/errors.js";
3
+ import {
4
+ extractDestinationPages,
5
+ layoutPdfTree,
6
+ serializePdfLayout
7
+ } from "./render-document.js";
8
+ export { extractDestinationPages };
9
+ const DEFAULT_MAX_PASSES = 5;
10
+ const resetBookmarks = (root, snapshot) => {
11
+ const visit = (node) => {
12
+ if (!("children" in node)) return;
13
+ const element = node;
14
+ if ("bookmark" in element.props) {
15
+ if (snapshot.has(element)) element.props.bookmark = snapshot.get(element);
16
+ else snapshot.set(element, element.props.bookmark);
17
+ }
18
+ for (const child of element.children) visit(child);
19
+ };
20
+ visit(root);
21
+ };
22
+ const samePages = (a, b) => {
23
+ const keys = Object.keys(a);
24
+ if (keys.length !== Object.keys(b).length) return false;
25
+ return keys.every((key) => a[key] === b[key]);
26
+ };
27
+ const describeMap = (map) => {
28
+ const entries = Object.entries(map);
29
+ if (entries.length === 0) return "(empty)";
30
+ return entries.map(([id, page]) => `#${id}\u2192p${page}`).join(", ");
31
+ };
32
+ export const renderDocumentMultiPass = async (source, options = {}) => {
33
+ const fontStore = options.fontStore ?? createPdfFontStore();
34
+ const maxPasses = options.maxPasses ?? DEFAULT_MAX_PASSES;
35
+ const bookmarks = /* @__PURE__ */ new Map();
36
+ let fed = {};
37
+ let produced = {};
38
+ for (let pass = 1; pass <= maxPasses; pass += 1) {
39
+ await source.feed(fed);
40
+ resetBookmarks(source.document, bookmarks);
41
+ const layout = await layoutPdfTree(source.document, fontStore, options.limits);
42
+ produced = extractDestinationPages(layout);
43
+ if (samePages(produced, fed)) {
44
+ const bytes = await serializePdfLayout(
45
+ source.document.props,
46
+ layout,
47
+ options.compress ?? true,
48
+ options.limits
49
+ );
50
+ return { bytes, layout, passes: pass, pages: produced };
51
+ }
52
+ fed = produced;
53
+ }
54
+ throw new NuxtPdfError(
55
+ PDF_ERROR_CODES.LimitExceeded,
56
+ `Table-of-contents page numbers did not stabilize after ${maxPasses} layout passes. This means a document's layout depends on the page numbers it prints \u2014 usually a table-of-contents entry whose height changes with the page number it shows, forming a feedback loop. Fix the entry so its size does not depend on the number (e.g. a fixed-width number column, or reserve the line height up front), or raise maxPasses if the document is legitimately this deep. Last fed map: ${describeMap(fed)}; last produced map: ${describeMap(produced)}.`
57
+ );
58
+ };
@@ -0,0 +1,7 @@
1
+ declare module '@react-pdf/pdfkit' {
2
+ import type { Readable } from 'node:stream'
3
+
4
+ export default class PDFDocument extends Readable {
5
+ constructor(options?: Record<string, unknown>)
6
+ }
7
+ }
@@ -0,0 +1,31 @@
1
+ import { type DocumentNode, type SafeDocumentNode } from '@react-pdf/layout';
2
+ import { type PdfFontStore } from './fonts.js';
3
+ import { type RenderLimits } from '../render-limits.js';
4
+ export interface PdfEngineOptions {
5
+ compress?: boolean;
6
+ fontStore?: PdfFontStore;
7
+ limits?: RenderLimits;
8
+ }
9
+ /** id (named destination) → 1-based page number the destination resolves to. */
10
+ export type DestinationPageMap = Record<string, number>;
11
+ export interface PdfEngineResult {
12
+ bytes: Uint8Array;
13
+ layout: SafeDocumentNode;
14
+ }
15
+ type DocumentMetadata = DocumentNode['props'];
16
+ export declare const layoutPdfTree: (document: DocumentNode, fontStore: PdfFontStore, limits?: RenderLimits) => Promise<SafeDocumentNode>;
17
+ /** The number of laid-out pages in a serialized document. */
18
+ export declare const countPages: (layout: SafeDocumentNode) => number;
19
+ /**
20
+ * Map every `id` (named destination) to the **first** 1-based page it appears
21
+ * on. `id` is the key `render/src/operations/setDestination.ts` emits and that a
22
+ * Link `src="#id"` jumps to (`setLink.ts`), so this map is the table-of-contents'
23
+ * source of truth. Pagination splits a node that spans a page boundary into a
24
+ * fragment on every page it touches, each keeping `props.id`; a destination names
25
+ * where a section STARTS, so the first page wins (`first-writer-wins`).
26
+ * `layout.children` is the final ordered page list produced by `resolvePagination`.
27
+ */
28
+ export declare const extractDestinationPages: (layout: SafeDocumentNode) => DestinationPageMap;
29
+ export declare const serializePdfLayout: (props: DocumentMetadata, layout: SafeDocumentNode, compress: boolean, limits?: RenderLimits) => Promise<Uint8Array>;
30
+ export declare const renderDocument: (document: DocumentNode, options?: PdfEngineOptions) => Promise<PdfEngineResult>;
31
+ export {};