@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.
- package/API_REPORT.md +262 -0
- package/CHANGELOG.md +168 -0
- package/CONFORMANCE.md +496 -0
- package/LICENSE +21 -0
- package/README.md +203 -0
- package/THIRD_PARTY_NOTICES.md +75 -0
- package/dist/module.d.mts +24 -0
- package/dist/module.json +12 -0
- package/dist/module.mjs +368 -0
- package/dist/runtime/authoring.d.ts +141 -0
- package/dist/runtime/authoring.js +25 -0
- package/dist/runtime/components/_props.d.ts +196 -0
- package/dist/runtime/components/_props.js +63 -0
- package/dist/runtime/components/document.d.ts +9 -0
- package/dist/runtime/components/document.js +26 -0
- package/dist/runtime/components/index.d.ts +4 -0
- package/dist/runtime/components/index.js +27 -0
- package/dist/runtime/components/svg.d.ts +17 -0
- package/dist/runtime/components/svg.js +50 -0
- package/dist/runtime/composables/index.d.ts +2 -0
- package/dist/runtime/composables/index.js +3 -0
- package/dist/runtime/composables/use-pdf-page-numbers.d.ts +39 -0
- package/dist/runtime/composables/use-pdf-page-numbers.js +17 -0
- package/dist/runtime/define-pdf.d.ts +6 -0
- package/dist/runtime/define-pdf.js +5 -0
- package/dist/runtime/fonts.d.ts +16 -0
- package/dist/runtime/fonts.js +0 -0
- package/dist/runtime/renderer/index.d.ts +3 -0
- package/dist/runtime/renderer/index.js +1 -0
- package/dist/runtime/renderer/node-ops.d.ts +5 -0
- package/dist/runtime/renderer/node-ops.js +281 -0
- package/dist/runtime/renderer/patch-prop.d.ts +3 -0
- package/dist/runtime/renderer/patch-prop.js +223 -0
- package/dist/runtime/renderer/render-component.d.ts +14 -0
- package/dist/runtime/renderer/render-component.js +201 -0
- package/dist/runtime/renderer/types.d.ts +33 -0
- package/dist/runtime/renderer/types.js +56 -0
- package/dist/runtime/renderer/validate-tree.d.ts +3 -0
- package/dist/runtime/renderer/validate-tree.js +428 -0
- package/dist/runtime/server/assets/errors.d.ts +12 -0
- package/dist/runtime/server/assets/errors.js +15 -0
- package/dist/runtime/server/assets/remote.d.ts +48 -0
- package/dist/runtime/server/assets/remote.js +234 -0
- package/dist/runtime/server/assets/resolve-asset.d.ts +53 -0
- package/dist/runtime/server/assets/resolve-asset.js +482 -0
- package/dist/runtime/server/engine/CONTRACTS.md +386 -0
- package/dist/runtime/server/engine/fonts.d.ts +8 -0
- package/dist/runtime/server/engine/fonts.js +19 -0
- package/dist/runtime/server/engine/layout-passes.d.ts +42 -0
- package/dist/runtime/server/engine/layout-passes.js +58 -0
- package/dist/runtime/server/engine/react-pdf-pdfkit.d.ts +7 -0
- package/dist/runtime/server/engine/render-document.d.ts +31 -0
- package/dist/runtime/server/engine/render-document.js +236 -0
- package/dist/runtime/server/index.d.ts +5 -0
- package/dist/runtime/server/index.js +9 -0
- package/dist/runtime/server/preview.d.ts +17 -0
- package/dist/runtime/server/preview.js +332 -0
- package/dist/runtime/server/registry.d.ts +41 -0
- package/dist/runtime/server/registry.js +270 -0
- package/dist/runtime/server/render-limits.d.ts +51 -0
- package/dist/runtime/server/render-limits.js +143 -0
- package/dist/runtime/server/result.d.ts +6 -0
- package/dist/runtime/server/result.js +81 -0
- package/dist/runtime/server/tsconfig.json +3 -0
- package/dist/runtime/shared/errors.d.ts +22 -0
- package/dist/runtime/shared/errors.js +22 -0
- package/dist/runtime/shared/index.d.ts +4 -0
- package/dist/runtime/shared/index.js +7 -0
- package/dist/runtime/shared/template.d.ts +63 -0
- package/dist/runtime/shared/template.js +1 -0
- package/dist/shared/nuxt-pdf.D2ZziYn4.mjs +1132 -0
- package/dist/test.d.mts +198 -0
- package/dist/test.mjs +696 -0
- package/dist/types.d.mts +13 -0
- 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,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 {};
|