@zumer/snapdom-plugins 3.0.0 → 4.0.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 (77) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +50 -16
  3. package/agent-map.d.ts +32 -0
  4. package/agent-map.js +5 -3
  5. package/ascii-export.d.ts +10 -0
  6. package/ascii-export.js +21 -6
  7. package/capture-frames.js +5 -5
  8. package/color-tint.js +4 -0
  9. package/context-export.d.ts +23 -0
  10. package/context-export.js +4 -1
  11. package/filter.js +1 -1
  12. package/gif-export.d.ts +6 -0
  13. package/gif-export.js +3 -3
  14. package/html-export.d.ts +8 -0
  15. package/html-export.js +6 -4
  16. package/index.d.ts +2 -1
  17. package/index.js +2 -1
  18. package/package.json +16 -5
  19. package/pdf/LICENSE +21 -0
  20. package/pdf/README.md +22 -0
  21. package/pdf/REFERENCE.md +520 -0
  22. package/pdf/src/frontmatter.js +200 -0
  23. package/pdf/src/furniture.js +398 -0
  24. package/pdf/src/index.js +2415 -0
  25. package/pdf/src/mega/README.md +47 -0
  26. package/pdf/src/mega/diag.js +148 -0
  27. package/pdf/src/mega/flow.js +56 -0
  28. package/pdf/src/mega/fonts.js +287 -0
  29. package/pdf/src/mega/forms.js +479 -0
  30. package/pdf/src/mega/labels.js +43 -0
  31. package/pdf/src/mega/meta.js +145 -0
  32. package/pdf/src/nav.js +357 -0
  33. package/pdf/src/paginate.js +583 -0
  34. package/pdf/src/tagged.js +355 -0
  35. package/pdf/src/text-layer.js +1771 -0
  36. package/pdf/src/writer/encrypt.js +218 -0
  37. package/pdf/src/writer/font.js +507 -0
  38. package/pdf/src/writer/index.js +1135 -0
  39. package/pdf/src/writer/subset.js +387 -0
  40. package/pdf.d.ts +294 -0
  41. package/pdf.js +1 -0
  42. package/redact-inputs.js +6 -6
  43. package/vector/CONTRACT.md +906 -0
  44. package/vector/LICENSE +21 -0
  45. package/vector/README.md +35 -0
  46. package/vector/src/adapters/snapdom.js +795 -0
  47. package/vector/src/collect/box.js +1676 -0
  48. package/vector/src/collect/font.js +487 -0
  49. package/vector/src/collect/image.js +1458 -0
  50. package/vector/src/collect/mathml.js +272 -0
  51. package/vector/src/collect/text.js +3027 -0
  52. package/vector/src/css/color.js +823 -0
  53. package/vector/src/css/gradient.js +1126 -0
  54. package/vector/src/css/matrix.js +398 -0
  55. package/vector/src/css/radius.js +124 -0
  56. package/vector/src/css/shadow.js +201 -0
  57. package/vector/src/emit/figma-json.js +1995 -0
  58. package/vector/src/emit/h2d-vector.js +215 -0
  59. package/vector/src/emit/h2d.js +1147 -0
  60. package/vector/src/emit/pdf.js +2571 -0
  61. package/vector/src/emit/svg-flat.js +4842 -0
  62. package/vector/src/exclusion.js +13 -0
  63. package/vector/src/index.js +2910 -0
  64. package/vector/src/paint.js +2908 -0
  65. package/vector/src/public.js +57 -0
  66. package/vector/src/writer/encrypt.js +227 -0
  67. package/vector/src/writer/font.js +507 -0
  68. package/vector/src/writer/index.js +1148 -0
  69. package/vector/src/writer/subset.js +387 -0
  70. package/vector/svd/index.js +72 -0
  71. package/vector/svd/schema.js +985 -0
  72. package/vector.d.ts +28 -0
  73. package/vector.js +1 -0
  74. package/video-export.d.ts +6 -0
  75. package/video-export.js +7 -7
  76. package/pdf-image.d.ts +0 -18
  77. package/pdf-image.js +0 -114
@@ -0,0 +1,906 @@
1
+ # Internal build contract — snapdom vector v0
2
+
3
+ This document describes the internal migrated engine, SVD format and optional Figma consumer in `src/`, `svd/` and `figma-plugin/`. Historical examples below use the engine's internal API; they are not the public npm API.
4
+
5
+ The public MIT-licensed plugin is `@zumer/snapdom-plugins/vector`: `vector({ exclude })` adds `toVector()` (SVG string) and `toFigma()` (clipboard write, returning `Promise<void>`). Only `exclude` is a public Vector option. See [usage](README.md), [public types](../vector.d.ts) and [the public adapter](src/public.js). Plugins 4.x require core 3.x and have not been published to npm yet.
6
+
7
+ **And the reverse duty, which is what this paragraph is for: a contract that
8
+ lies is worse than no contract.** When this file described a shape the code no
9
+ longer had, three consumers each invented their own probe list for the gradient
10
+ `unit` keys (`from || start || p0`); two were right by accident and the Figma
11
+ plugin was wrong, which flattened every linear gradient to top-to-bottom. The
12
+ same failure repeated with `decoration`, read as an object when it had become an
13
+ array — every underline in the document came out as NONE. Change the code and
14
+ this file in the same pass, or the next reader builds on the older of the two.
15
+ Last reconciled against the source: 2026-08-05 (text emitters, effect vocabulary,
16
+ `emit/*` signatures).
17
+
18
+ ## Scope: the engine walks snapdom's CLONE, not the live DOM
19
+
20
+ This is the founding decision and it is not an implementation detail.
21
+
22
+ The engine is a **snapdom plugin**. The caller writes the `snapdom()` call and
23
+ registers us on it; our `beforeRender` hook takes the clone off the hook state, and
24
+ the `toVector()` / `toVectorSvg()` exports we publish through `defineExports()` mount
25
+ that clone offscreen and walk **that**.
26
+
27
+ ```js
28
+ import { snapdom } from '@zumer/snapdom' // v3, local repo
29
+ import { vector, svdToSvg } from '@zumer/snapdom-vector'
30
+
31
+ const result = await snapdom(el, { plugins: [vector({ mode: 'design' })] })
32
+ const doc = await result.toVector() // SVD document
33
+ const svg = await result.toVectorSvg() // ...or straight to markup
34
+ ```
35
+
36
+ There is no `toVector(element)`, and there must not be one again — see
37
+ [Why a plugin, and not a hook in the core](#why-a-plugin-and-not-a-hook-in-the-core)
38
+ below. `svdToSvg` / `svdToFigma` stay module-level functions because they are
39
+ document→format, not capture APIs: give them a document and they need no DOM.
40
+
41
+ The clone is where snapdom has already done the ugly
42
+ half of the job: pseudo-elements materialised into real `<span>`s, shadow DOM
43
+ flattened with its CSS rewritten, icon fonts resolved to `<img>`, backgrounds and
44
+ images inlined past CORS, `backdrop-filter` pre-composed, scroll applied as a
45
+ translate. Membership in the tree is *solved*; only geometry and paint are ours.
46
+
47
+ **Measured, not assumed** (`test/_clonemetrics.mjs`, nine fixtures across two demo
48
+ pages plus `test/fixtures/scrolldrift.html`): the mount reproduces the live layout
49
+ with a delta of **0.00px** — median, p95 and max, across every mapped node. There is
50
+ no accuracy argument for preferring the live DOM, and the clone additionally carries
51
+ boxes for nodes the live DOM does not have at all.
52
+
53
+ That number is the *output* of four repairs, not a property of mounting. Borrowing
54
+ the page's cascade is what makes the clone faithful, and every way it fails comes
55
+ from the same place — the page's selectors were not written for a tree that sits
56
+ somewhere else, gained nodes, and connects custom elements on arrival. Each repair
57
+ lives in `adapters/snapdom.js` with the drift it removed:
58
+
59
+ | what breaks | repair | was |
60
+ |---|---|---|
61
+ | host has no width; a `width:auto` root shrink-wraps | `sizeHost` | 628px |
62
+ | nothing styles a node snapdom invented (`::before` at 0×0) | `synthRulesFrom` | 18px |
63
+ | a scroll wrapper breaks every `>` chain crossing it, whole subtree | `synthRulesFrom` | 268px |
64
+ | connecting the clone upgrades custom elements, re-attaching shadow roots | `defuseUpgradedShadowRoots` | 37.72px |
65
+
66
+ The snapshot rules go **only** to nodes the page cannot reach. Applying snapdom's
67
+ whole `classCSS` on top of the page's cascade is worse than applying none of it:
68
+ measured, fx-table's pixel diff went 1.83 → 10.25 and a paragraph re-wrapped.
69
+
70
+ Mount in the LIGHT dom, never a shadow root: Chromium ignores `@font-face` inside
71
+ one, and a clone laid out with substituted fonts measures nothing useful.
72
+
73
+ `nodeMap` (clone → source) stays available and is what a harness uses to assert the
74
+ clone has not drifted. It is a check, not the data path.
75
+
76
+ ## Why a plugin, and not a hook in the core
77
+
78
+ This section exists so nobody re-derives the hook. It has been invented twice.
79
+
80
+ The history: an early revision had the engine walking the **live DOM**, because
81
+ snapdom's internal hand-off (`__retain`, which burst's differential recapture uses to
82
+ keep a clone alive) was not reachable from outside — `createContext` is an allowlist
83
+ and silently drops any callback a caller passes. The fix at the time was to widen the
84
+ allowlist: a public `retain` option, committed to snapdom v3 as `2c9d22c`.
85
+
86
+ **That commit is reverted.** The access it added already existed. `snapdom(el, {
87
+ plugins: [...] })` runs render hooks, and a render hook is handed the clone — the same
88
+ clone, one line earlier in the same function. `retain` bought nothing a plugin could
89
+ not already do, and it charged for it in public surface: once `retain` is in the
90
+ allowlist it is a documented capability the core owes every caller forever, and the
91
+ private `__retain` channel it shadowed is an implementation detail of burst that
92
+ should stay private. So the core is untouched, and the engine is a plugin.
93
+
94
+ Four things that decision fixes on the way past, each of which the loose API had to
95
+ work around by hand:
96
+
97
+ - **`burst` no longer has to be forced off.** The old API passed `burst: false` on
98
+ every call, because burst's memo and differential paths answer with a URL without
99
+ re-entering `captureDOM`, so there would be no clone. A plugin with render hooks
100
+ that does not declare `pure: true` suspends auto-burst by construction
101
+ (`snapdom/src/core/plugins.js`, `hasImpureRenderPlugins`). Measured: five
102
+ back-to-back captures of one element all ran the pipeline; the loose API broke at
103
+ the third. **Omitting `pure` is therefore load-bearing, not an oversight.**
104
+ - **One capture, not two.** The loose API made its own `snapdom()` call, so a caller
105
+ who wanted a PNG *and* an SVD paid for two full captures. Now both come off one.
106
+ - **`toVector`, not `toSvg`.** `buildResult` derives the helper name from the export
107
+ key, and plugin exports override core exports by name (`{...coreExports,
108
+ ...provided}`). Publishing `svg` would have silently replaced `result.toSvg()` for
109
+ every caller of that capture. Hence `vector` / `vectorSvg` → `result.toVector()` /
110
+ `result.toVectorSvg()`.
111
+ - **The caller owns the capture options.** `dpr`, `outerTransforms` and `burst` are
112
+ all read before the first hook runs and **cannot** be forced from a plugin. That is
113
+ not a loss of control — it is where the control belongs now. `outerTransforms:false`
114
+ shows up as measured drift and gets filed, rather than being silently prevented.
115
+
116
+ Two prices, both paid deliberately:
117
+
118
+ - `report.timings.snapdomMs` is gone. It was only ever measurable while the engine
119
+ made the `snapdom()` call itself. The caller makes it now, so the caller is what can
120
+ hold a stopwatch — `demo/vector.html` times both halves and still shows the split.
121
+ - `classPrefixCSS` is the one capture value no hook sees. It is recovered inside the
122
+ plugin by `classPrefixFrom` (`adapters/snapdom.js`), which cuts it out of `classCSS`
123
+ anchored on the exact suppressor literal plus the `[data-sd-pN]::marker` grammar —
124
+ verified byte-identical to snapdom's own on 14/14 fixtures. If the anchor ever moves
125
+ it falls back to the literal and files `capture.clone-css-partial` rather than
126
+ guessing.
127
+
128
+ ## Module signatures
129
+
130
+ All modules are ESM, `.js`, no TypeScript, no build step. JSDoc for types.
131
+ Files under `src/css/` MUST be importable from Node with no DOM access.
132
+
133
+ ### `packages/svd/src/schema.js`
134
+
135
+ ```js
136
+ /** @returns {{ok: boolean, errors: string[]}} */
137
+ export function validate (doc)
138
+ /** @returns {string[]} errors, empty if valid */
139
+ export function validateNode (node, id)
140
+ ```
141
+
142
+ Strict: reject, never repair. Checks structural invariants (every `children` id
143
+ exists in `nodes`, `root` resolves, every node has `frame` and `abs` and
144
+ `fidelity.grade`, every non-`E` grade has a matching `diagnostics` entry, colors
145
+ are 4-float arrays in 0..1, `paint.z` is an integer).
146
+
147
+ **The effect vocabulary is closed and lives here**, because "what can an effect
148
+ be" is the question every backend answers differently:
149
+
150
+ ```
151
+ dropShadow | innerShadow {color, offset:[x,y], blur, spread, behind}
152
+ layerBlur | backgroundBlur {blur}
153
+ colorMatrix {values: 20 floats, srcCss: string, backdrop: boolean}
154
+ ```
155
+
156
+ `colorMatrix` is the one that is not a blur and does not carry one: it is the
157
+ 4x5 of `feColorMatrix type="matrix"`, row-major, composing the run of colour
158
+ functions (`saturate`, `hue-rotate`, `grayscale`, `sepia`, `invert`, `contrast`,
159
+ `brightness`) that `collect/box.js` (`colorMatrixOf`) folded together. `srcCss`
160
+ is REQUIRED and is that CSS chain verbatim — a backend that cannot paint the
161
+ matrix can still name what it dropped, and a diagnostic that cannot name the
162
+ function is not much of a diagnostic. `backdrop` distinguishes `filter` from
163
+ `backdrop-filter`: the two recolour different pixels.
164
+
165
+ ### `packages/vector/src/css/color.js`
166
+
167
+ ```js
168
+ /** Any CSS color string -> [r,g,b,a] floats 0..1 sRGB. Null if unparseable. */
169
+ export function parseColor (css) // => {rgba: [r,g,b,a], srcCss: string} | null
170
+ /** [r,g,b,a] -> "#rrggbb" / "rgba(...)" for SVG output. */
171
+ export function toCssString (rgba)
172
+ export function isTransparent (rgba) // => boolean (a === 0)
173
+ ```
174
+ Must handle: hex 3/4/6/8, `rgb()`/`rgba()` legacy and space-separated, `hsl()`/`hsla()`,
175
+ named colors, `transparent`, `currentColor` (caller passes resolved value),
176
+ and `oklch()`/`lab()`/`color()` by round-tripping through a canvas 2D context when a
177
+ DOM is present, falling back to a documented approximation when not. Gamut-map by
178
+ chroma reduction, never per-channel clipping.
179
+
180
+ ### `packages/vector/src/css/gradient.js`
181
+
182
+ ```js
183
+ /**
184
+ * @param {string} css one background-image layer, e.g. "linear-gradient(...)"
185
+ * @param {{w:number,h:number}} box the painting box in px
186
+ * @returns {Paint|null} null when the layer is not a gradient
187
+ */
188
+ export function parseGradient (css, box)
189
+ ```
190
+ Emits `{type:'linear'|'radial'|'angular', unit:{...}, transform:[a,b,c,d,e,f], stops:[...], notes:[]}`.
191
+
192
+ **`unit` keys are normative — do not guess them downstream.** Leaving them
193
+ undefined already cost us: three consumers each invented their own probe list
194
+ (`from||start||p0`), two were right by accident and the Figma plugin was wrong,
195
+ which silently flattened every linear gradient to top-to-bottom.
196
+
197
+ ```js
198
+ linear: { p0: [x, y], p1: [x, y] } // unit square, gradient line endpoints
199
+ radial: { center: [x, y], radius: [rx, ry] | r, shape: 'circle'|'ellipse' }
200
+ angular: { center: [x, y], startAngle: deg } // CW from 12 o'clock
201
+ ```
202
+
203
+ `radius` is a PAIR or a single number. `css/gradient.js` normalizes anisotropy
204
+ into `transform` and so writes the scalar; `svd/src/schema.js` accepts both and
205
+ every emitter reads a bare `r` as `[r, r]`, which is what it means. This is the
206
+ contract, not a divergence from it — do not "fix" one side without the other.
207
+
208
+ A stop is `{ t, color: [r,g,b,a], srcCss }` with `t` in 0..1, monotonic.
209
+ `transform` is the SVG-order 2x3 `[a,b,c,d,e,f]` taking unit space to painted px.
210
+
211
+ `angular` is the one asymmetry: a conic's angles are real screen angles, so its
212
+ matrix must stay a uniform similarity and the rotation lives in `unit.startAngle`.
213
+ The emitter composes it; the matrix never carries it.
214
+ Geometry normalized to the unit square; anisotropic ellipses absorbed into the matrix.
215
+ Color-stop hints resampled to 8–16 stops. `transparent` replaced by `rgba(R,G,B,0)`
216
+ of the neighbouring stop (this fixes premultiplication artifacts). `repeating-*`
217
+ unrolled up to K=64. Record every approximation in `notes`.
218
+
219
+ ### `packages/vector/src/css/shadow.js`
220
+
221
+ ```js
222
+ /**
223
+ * @param {string} css
224
+ * @param {{inset?: boolean, currentColor?: string}} [options]
225
+ * @returns {{shadows: Effect[], warnings: string[]}}
226
+ */
227
+ export function parseShadows (css, { inset = true, currentColor } = {})
228
+ ```
229
+ `{type:'dropShadow'|'innerShadow', color:[r,g,b,a], offset:[x,y], blur, spread, behind:false}`.
230
+ Multiple comma-separated shadows, in CSS order. `blur` is the CSS radius verbatim.
231
+ `warnings` is a RETURNED array, never a property hung off the list: a caller that
232
+ destructures the shadows must not be able to drop the reasons by accident. A
233
+ shadow that names no color *is* `currentColor`, and only the caller knows it —
234
+ pass `cs.color` or the shadow comes out declared-black.
235
+
236
+ ### `packages/vector/src/css/radius.js`
237
+
238
+ ```js
239
+ /** @returns {[[number,number],[number,number],[number,number],[number,number]]} tl,tr,br,bl as [rx,ry] */
240
+ export function parseRadii (cs, w, h)
241
+ ```
242
+ Percentages resolved against the box. **Apply the CSS overlap scale factor `f`**
243
+ (CSS Backgrounds §5.5) when adjacent radii exceed a side.
244
+
245
+ ### `packages/vector/src/css/matrix.js`
246
+
247
+ ```js
248
+ /** @returns {{m:[a,b,c,d,e,f], decomposed:{tx,ty,rot,sx,sy,skewX}}|null} */
249
+ export function parseTransform (cssTransform, cssOrigin, w, h)
250
+ export function multiply (m1, m2)
251
+ export function applyToPoint (m, x, y)
252
+ ```
253
+ `rot` in degrees clamped to -180..180. `null` for `none`. 3D transforms return the
254
+ flattened 2D projection and the caller emits a diagnostic.
255
+
256
+ ### `packages/vector/src/collect/box.js`
257
+
258
+ ```js
259
+ /**
260
+ * @param {Element} el
261
+ * @param {CSSStyleDeclaration} cs already-computed style for `el`
262
+ * @param {CollectCtx} [ctx]
263
+ * @returns {{fills:Paint[], strokes:Stroke[], effects:Effect[], textEffects:Effect[],
264
+ * clip:Clip|null, mask:object|null, radii, opacity:number, blend:string,
265
+ * warnings:Array<string|{code,grade,severity,message}>}}
266
+ */
267
+ export function collectBox (el, cs, ctx = {})
268
+ /** A background layer's painted rect, given its placement and the asset's intrinsic size. */
269
+ export function resolveLayerRect (placement, intrinsic = null)
270
+ ```
271
+ `textEffects` is `text-shadow`: it paints the GLYPHS, so it cannot ride in
272
+ `effects` (which filters the box) and the caller hands it to the block's text
273
+ node. `mask` is the collected `mask-image` layers — a paint, not a node, and the
274
+ schema's `mask` is a reference to a node, so `wireMasks` in `index.js` builds
275
+ that node and writes `node.mask = {node, type}`. Both are inert until such a
276
+ caller exists and both say so in `warnings`.
277
+
278
+ Background layers -> `fills` in **reverse CSS order** (CSS paints last layer first).
279
+ Coalesce: N background layers sharing a `clipBox` become N entries in one node's
280
+ `fills` array, not N nodes. Uniform borders -> one stroke with per-side weights.
281
+ Per-side differing colors -> `warnings` + caller composes.
282
+
283
+ ### `packages/vector/src/collect/text.js`
284
+
285
+ ```js
286
+ /**
287
+ * @param {Element} blockEl an element with a text-carrying inline formatting context
288
+ * @param {CollectCtx} ctx
289
+ * @returns {{characters:string, lines:Line[], runs:Run[], pitch:number,
290
+ * align:string, direction:string, styles:Record<string,TextStyle>,
291
+ * sourceText:string,
292
+ * warnings:Array<string|{code,grade,severity,message}>}|null}
293
+ */
294
+ export function collectText (blockEl, ctx)
295
+ ```
296
+
297
+ **This is the hard one. The rules are not negotiable:**
298
+
299
+ - `TreeWalker(SHOW_TEXT)` per block. **Never** `selectNodeContents` on a container
300
+ with inline children — it returns duplicate rects (12 for 7 runs). **Never**
301
+ per-character: measured 95ms/35k chars vs 4.1ms for 5600 nodes.
302
+ - `Range.getClientRects()` returns the font's ascent+descent box, **not** the CSS
303
+ line box. Baseline is `rect.top + ctx.measureText('Hxg').fontBoundingBoxAscent`.
304
+ - **Group rects into lines by baseline** (0.5px tolerance), **never by `top`** — a
305
+ larger-`font-size` span shares a baseline but not a top.
306
+ - `pitch` is **measured** between consecutive baselines. Never read from CSS
307
+ `line-height`.
308
+ - Never scale metrics between sizes: Chromium rounds `fontBoundingBox*` to integers
309
+ per size.
310
+ - Whitespace collapsing means DOM offsets != rendered offsets. Build the run map on
311
+ the **rendered** string. NBSP does not collapse.
312
+ - `characters` joins visual lines with `
` (LSEP). **Never `\n`** — that creates
313
+ a paragraph and triggers `paragraphSpacing` in Figma.
314
+ - `sourceText` holds the pre-`text-transform` original, the SAME LENGTH as
315
+ `characters` and indexed identically — a transform that changed the length is
316
+ refused and reported instead.
317
+ - **Every number in `lines` is in the block's OWN units**: an axis-aligned scale
318
+ between the block and the capture root is divided out, because the node these
319
+ lines land on carries that transform and would apply it again. The known gap:
320
+ under a ROTATION the lines are measured on the block's AABB, which is the
321
+ bounding box of a quad and cannot be undone from here. `emit/svg-flat.js`
322
+ inverts it exactly (`unbbox`, `emit.svg.text-unbboxed`); a backend that does not
323
+ must not compensate twice.
324
+ - `Line = {i, start, end, x, w, baseline, asc, desc, frags?, spans?, words?, hyphen?}`.
325
+ `w` is the sum of the painted glyph advances, **not** the bbox:
326
+ - `frags:[{x,w,start,end}]` — present only when something that is not a
327
+ character (an inline image, a float) splits the line into two or more painted
328
+ stretches. They tessellate `[start, end)` left to right and are the only place
329
+ the hole is visible. A consumer that ignores `frags` and stretches the line to
330
+ `w` squeezes the glyphs.
331
+ - `words:[{x,w,start,end}]` (2026-08-13) — measured anchor per word, written
332
+ only for `text-align: justify` blocks (every line of one, last included) and
333
+ only where trustworthy: axis-aligned chain, no RTL, two or more words. Unlike
334
+ `frags` they do NOT tessellate — the gaps between them are the space
335
+ characters, which belong to no word — and justification is the reason they
336
+ exist: the slack goes into the word gaps, so no advance arithmetic can place
337
+ a word. One Range measurement per word, paid only on justified blocks. A
338
+ consumer that anchors each word at its `x` reproduces the justification
339
+ exactly; one that ignores `words` falls back to the `textLength` tier below.
340
+ - `spans:[{start,end,x,w}]` — the painted extent of each STYLE stretch on the
341
+ line, one entry per contiguous piece, in logical order. A `Run` is character
342
+ offsets and nothing else, so this is the ONLY place a backend can find where
343
+ the run that carries a wavy underline starts and stops. Without it a
344
+ decoration over `characters 23..41` had no extent and fell back to a straight
345
+ line.
346
+ - `hyphen: string` — the glyph the browser painted at a hyphenation break
347
+ (`hyphens: auto`) and the document does not contain. **It is deliberately not
348
+ in `characters`**: every offset in `lines`, `runs`, `frags` and `spans` indexes
349
+ that string and `sourceText` matches it character for character, so inserting
350
+ it would move all four at once. Its advance is ALREADY inside `w`, `spans` and
351
+ `frags` — the browser reports it as ink of this line — so a backend paints the
352
+ line as `characters.slice(start, end) + hyphen` and nothing else moves.
353
+ Which character it is, is MEASURED (U+2010 vs U+002D vs a declared
354
+ `hyphenate-character`), never assumed.
355
+ - `Run = {start, end, style, href, dy}`. `dy` is the MEASURED offset of a raised
356
+ or lowered run (superscript, subscript, `vertical-align`) from its line's
357
+ baseline, positive downwards. The fold that put the run on this line already took
358
+ the offset out of the baseline, so a consumer that drops `dy` renders H₂O as H2O.
359
+ - A warning may be prose or `{code, grade, severity, message}`. A collector that
360
+ knows what it lost states the grade; the orchestrator's regex is the last resort.
361
+
362
+ **TextStyle**, as the collector returns it and as `index.js` (`registerTextStyle`)
363
+ rewrites it into `doc.styles.text[ts_N]`. The rewrite is the one place the
364
+ orchestrator translates rather than forwards, and both spellings are normative
365
+ for the side that sees them:
366
+
367
+ | collector | SVD | notes |
368
+ |---|---|---|
369
+ | `fontSize` | `size` | the only renamed key |
370
+ | `fontFamily` / `family` | `stack` / `family` | `family` is the first entry of the stack, unquoted |
371
+ | `fontWeight`, `fontStyle`, `fontStretch` | `weight`, `fontStyle`, `stretch` | plus `italic: boolean` |
372
+ | `fontVariant` (+ `fontVariantCaps` merged in) | `variant` | see below |
373
+ | `fontFeatureSettings`, `fontVariationSettings` | `features`, `variations` | computed CSS strings |
374
+ | `letterSpacing`, `wordSpacing`, `lineHeight` | same | resolved to px numbers, never `'normal'` |
375
+ | `textTransform` | `case` | `characters` is ALREADY transformed; this says what was applied |
376
+ | `color`/`colorCss` | `fills:[{type:'solid', color, srcCss}]` | |
377
+ | `decorations` (array) | `decoration` (array \| **null**) | |
378
+ | `strokeWidth`, `strokeColor` | same | |
379
+ | `ascent`, `descent`, `fontCss` | same | measured metrics for the face that actually painted |
380
+
381
+ - **`decoration` is an ARRAY, or null — never a single object.** `text-decoration`
382
+ does not inherit, it PROPAGATES: an `<a>` and a `<span>` inside it can each
383
+ contribute one, and `underline line-through` is one declaration and two lines.
384
+ Order is innermost first (`[0]` is the decoration nearest the glyphs). An entry
385
+ is `{line, style, color, colorCss, thickness, offset, offsetCss}`; `thickness`
386
+ and `offset` are `null` for `auto`, which is the honest answer — the value is
387
+ then the font's, and the font's underline position is not in this document.
388
+ Reading `decoration.line` off the array returns `undefined`, which is how a
389
+ consumer silently emitted NONE for every underline in the document.
390
+ - **`variant` is the whole `font-variant` shorthand with `font-variant-caps`
391
+ merged in.** Chromium drops `small-caps` from the shorthand's serialization as
392
+ soon as another longhand is set, so both are read and joined. It therefore
393
+ carries two unrelated things at once — the caps half (`small-caps`,
394
+ `all-small-caps`, …), which repaints letters, and the OpenType half
395
+ (`tabular-nums`, `lining-nums`, `slashed-zero`, …), which selects figures and
396
+ ligatures. Backends split it themselves (`emit/pdf.js` `splitSmallCaps`,
397
+ `emit/figma-json.js` `variantOf`); there is no second field, so nobody has two
398
+ places to look.
399
+ - **`strokeWidth` / `strokeColor` are `-webkit-text-stroke`**, resolved
400
+ (`strokeColor` falls back to `color`, and is `null` when the width is 0). It is
401
+ centred on the glyph outline and painted over the fill — in CSS, in SVG and in
402
+ Figma alike — so a backend that has a stroke primitive can carry the number
403
+ across unchanged.
404
+
405
+ ### `packages/vector/src/collect/image.js`
406
+
407
+ ```js
408
+ /** @returns {Promise<{asset:Asset, fill:Paint, warnings:string[]}|null>} */
409
+ export async function collectImage (el, cs, ctx = {})
410
+ ```
411
+ Read `currentSrc` (not `src`) and `naturalWidth/Height`. **If `currentSrc` is an SVG,
412
+ inline and vectorize it — do not rasterize.** Honour `object-fit`/`object-position`
413
+ as a crop plus matrix: the returned `fill` carries `crop` (the visible region in
414
+ the asset's OWN source pixels) and `transform` (a 2x3 mapping the unit square of
415
+ that crop onto the node's border box in px), and between them those express all
416
+ five `object-fit` values. `null` only when the element carries no replaced content
417
+ at all — the one case with nothing to report, because nothing was lost.
418
+
419
+ **Passthrough markup carries no active content.** `<script>` elements and `on*`
420
+ attributes are removed before serialization (`stripActiveContent`) and reported as
421
+ `collect.image.active-content-stripped`, grade `E` — nothing visual is lost. This is
422
+ not hygiene theatre: on 2026-08-08 a dev server injecting a live-reload snippet into
423
+ every `.svg` it served put three of them inside one capture, and the exported file
424
+ stopped parsing at the first `// <![CDATA[`. The engine copies what the page gave it;
425
+ it does not have to copy that.
426
+
427
+ `asset.kind === 'vector'` means the caller must emit a `vector` node and put the
428
+ markup on **`node.svg`**, with the placement on `node.svgPlacement`. Both emitters
429
+ read `node.svg`; while nobody wrote it, every inline `<svg>` exported as an empty
430
+ group with an `emit.svg.image-no-data` per icon.
431
+
432
+ ### `packages/vector/src/collect/mathml.js`
433
+
434
+ ```js
435
+ /** @returns {Promise<{asset:Asset, fill:Paint, warnings:Array, fontStyles:Array}|null>} */
436
+ export async function collectMathML (el, cs, ctx = {})
437
+ ```
438
+
439
+ Native MathML stays opaque to the HTML paint walk and is kept as a `vector`
440
+ node. The collector reads the mounted clone's mathematical layout and produces
441
+ native SVG text and geometry for presentation math. Its asset and placement use
442
+ the same interface as `collectImage`; it adds neither raster content nor
443
+ `foreignObject`. Root CSS paint and transforms remain the orchestrator/emitter's
444
+ responsibility. Excluded, phantom and annotation content must not become visible
445
+ text. Unsupported paint and approximated glyph geometry carry diagnostics.
446
+
447
+ `fontStyles` contains collector `TextStyle` objects plus `characters`, the actual
448
+ code points emitted in that style (including MathML `math-auto` substitutions).
449
+ The orchestrator registers them in `doc.styles.text` and keeps transient
450
+ `st.markupTextUses = [{nodeId, style, characters}]`. These uses join font coverage,
451
+ embedding and missing-font diagnostics; they do not create phantom SVD text
452
+ nodes. MathML behavior follows [MathML Core](https://www.w3.org/TR/mathml-core/).
453
+
454
+ ### `packages/vector/src/collect/font.js`
455
+
456
+ ```js
457
+ /** Mutates doc.assets and styles.text[*].font; every non-embed is a diagnostic. */
458
+ export async function collectFonts (st, report)
459
+ ```
460
+
461
+ The asynchronous other half of `collect/text.js`'s family resolution (2026-08-13),
462
+ run by `assemble` inside the mount, after every text style exists and before
463
+ `declareFonts`. For each style the resolution marked `webfont: true` it finds the
464
+ `@font-face` rule the browser matched — same ordering as `pickFace`/`nameTableFor`,
465
+ whose helpers (`weightRange`, `parseUnicodeRange`, `parseFontNames`, `dataUriBytes`)
466
+ it imports rather than re-deriving — fetches the first loadable `src` with
467
+ `force-cache`, and writes an asset `{kind: 'font', data, mime, cssFamily,
468
+ weight | weightRange, italic, nameTable?, subset: false}` with `style.font`
469
+ pointing at it. That is the key `fontFaceRules` and `measuredFontTravels` (emit)
470
+ and `fontOf` (figma-json, reads `nameTable`) have always looked for.
471
+
472
+ What it refuses, each with its own `assets.*` code: a family whose rule no
473
+ readable stylesheet declares (`font-rule-missing`; cross-origin CSS is re-read
474
+ over HTTP first, `font-css-unreadable` when that fails too), a family served in
475
+ unicode-range subsets none of which alone covers every painted code point
476
+ (`font-uncovered` — embedding one subset would pin `textLength` to a file
477
+ missing characters), and a failed fetch (`font-fetch-failed`). It does not
478
+ subset (the file travels whole; size stated per face in `font-embedded`), and
479
+ `embedFonts: false` skips it entirely. System and locally-installed faces have
480
+ no bytes a page can hand over and stay `declareFonts`' story. The fixture is
481
+ `demo/vector.html` -> `fx-font`, whose family name exists nowhere installed and
482
+ whose baseline was measured on a render page with no stylesheets at all.
483
+
484
+ ### `packages/vector/src/paint.js`
485
+
486
+ ```js
487
+ /**
488
+ * Pure given a snapshot: builds the containment tree, assigns paint order, prunes.
489
+ * @returns {{nodes:Record<string,SVDNode>, rootId:string, diagnostics:Diagnostic[]}}
490
+ */
491
+ export function buildPaintTree (rootEl, ctx = {})
492
+ export function isStackingContext (el, cs, parentCs) // pure, testable from Node with a fake cs
493
+ export function paintOrder (siblings) // CSS 2.1 Appendix E -> integer z per sibling
494
+ ```
495
+
496
+ `parentCs` is optional and is what decides the flex/grid-item rule (`z-index` on
497
+ a flex item creates a stacking context whatever its `position`); without it that
498
+ one rule falls back to `getComputedStyle`, and with no DOM it is skipped.
499
+ `buildPaintTree` **refuses a detached root**: the engine walks snapdom's mounted
500
+ clone, and an unmounted clone computes to nothing and would prune to one empty
501
+ node that looks like a page with nothing on it.
502
+
503
+ **The tree is DOM containment. Paint order is an index, not a hierarchy.** A node is
504
+ promoted out of its DOM parent only when *both* hold: (a) the correct paint order is
505
+ not expressible by reordering siblings within the parent (negative `z-index` behind
506
+ the parent background, `position:fixed`, top layer), **and** (b) the divergence is
507
+ geometrically observable — the node's box overlaps some box whose relative order
508
+ would change. Every promotion emits a `diagnostics` entry with `from`, `to`, `reason`.
509
+
510
+ Pruning: an element emits a node only if it has a fill, stroke, effect, clip, mask,
511
+ transform, `opacity < 1`, is text, or is replaced. Everything else is pass-through and
512
+ is flattened when it has <= 1 child. **`opacity < 1` always forces a `group`**, even
513
+ with a single child.
514
+
515
+ ### `packages/vector/src/emit/svg-flat.js`
516
+
517
+ ```js
518
+ /**
519
+ * @param {SVDDocument} doc
520
+ * @param {object} [options]
521
+ * @param {boolean} [options.debugPaint=false]
522
+ * @param {'auto'|'always'|'never'} [options.textPerLine='auto']
523
+ * @param {boolean} [options.exactShadows=false]
524
+ * @returns {{svg: string, diagnostics: Diagnostic[]}} standalone SVG markup plus
525
+ * what the SVG backend itself could not represent. The result also behaves as
526
+ * the markup string (`toString`, `length`, `valueOf`) for callers written
527
+ * against the older signature.
528
+ */
529
+ export function svdToSvg (doc, { debugPaint = false, textPerLine = 'auto', exactShadows = false } = {})
530
+ ```
531
+ Pure. No DOM. **No `foreignObject` anywhere** — that is the entire point. SVG blur
532
+ sigma is `blur / 2`. `debugPaint` tints nodes by stacking-context depth and gives
533
+ each a `<title>` naming its id, grade and paint order.
534
+ The returned `diagnostics` carry `{code, grade, severity, message, node?}` and are
535
+ meant to be concatenated onto `doc.diagnostics` — an approximation the emitter made
536
+ is a loss like any other, and `toSvg` merges them and degrades the nodes they name.
537
+ The same notes also go into an XML comment under the `<svg>` tag, so the file is
538
+ self-describing on its own.
539
+
540
+ **The text shape is NOT one `<text>` with a `<tspan>` per line, and has not been
541
+ since the Figma findings.** An earlier revision of this file said it was; three
542
+ consumers built on that sentence. What the emitter does:
543
+
544
+ - **One `<text>` per positioned piece** — per visual line, and per `frag` within a
545
+ line — each at its own measured baseline, whenever the block occupies more than
546
+ one piece. A block that fits on ONE line stays a single `<text>`, which is the
547
+ more editable object and has nothing to reflow. `textPerLine` moves that line:
548
+ `'auto'` (the default, as described), `'always'` (split even a single line),
549
+ `'never'` (the SVG-native shape, one element per block — and the lines stack on
550
+ top of each other in Figma). **Why**: Figma discards the `x`/`y` of an interior
551
+ `<tspan>` and reflows with its own leading.
552
+ - **`textLength` + `lengthAdjust="spacingAndGlyphs"` ONLY when the font binary
553
+ travels in the file** (`fontFaceRules` / `measuredFontTravels`). Pinning a line
554
+ to a width measured with the page's font, in a viewer that substituted the
555
+ family, does not degrade gracefully: it squeezes the glyphs into a width they
556
+ were never going to produce. Without a binary the width is the viewer's to
557
+ decide and `emit.svg.text-length-viewer-width` says so. **Justified lines are
558
+ the exception (2026-08-13), in two tiers.** Where the collector measured
559
+ per-word anchors (`line.words`), every anchored word becomes a `<tspan x="…">`
560
+ at the x the browser painted and `textLength` is OMITTED — absolute anchors
561
+ and a whole-piece length would fight over the same glyphs
562
+ (`emit.svg.text-justified-words`, grade E: exact, whatever font renders;
563
+ measured on `challenges/fx-type` -> `.ty-just`, diffMean 6.25 -> 3.61, and on
564
+ `vector/fx-font`, 8.62 -> 4.43). Where it could not (skewed chain, RTL,
565
+ one-word lines), a justified non-last line is pinned even without a binary,
566
+ with `lengthAdjust="spacing"` (glyphs unstretched) and
567
+ `emit.svg.text-justified-length`, grade A — unpinned, the justification is
568
+ simply gone and every word lands left of where the page painted it.
569
+ - **The `<text>` carries the typography of its dominant run**
570
+ (`inheritedTextAttrs` / `dominantStyle`) and the `<tspan>`s carry only what
571
+ differs, with explicit resets (`stroke="none"`, `font-variant="normal"`) where a
572
+ run does not inherit. Figma builds its TextNode from the `<text>` element and
573
+ does not look inside it for the real values.
574
+ - **A run raised or lowered off the baseline becomes its own positioned piece**
575
+ (`pushRange`), never a relative `dy` inside its line: `dy` is cumulative, and an
576
+ importer that carries part of the chain drifts everything after it.
577
+ - **`line.hyphen` is painted**, appended INSIDE the last `<tspan>` of the piece
578
+ that ends the line (`emitText`) — not after it, where a bare text node between
579
+ tspans is a separate character an importer can misplace. Its advance was already
580
+ inside the measured width, so nothing moves. `emit/figma-json.js` deliberately
581
+ does not paint it — see below.
582
+ - **`-webkit-text-stroke` is `stroke` + `stroke-width`** on the text (exact: both
583
+ are centred on the outline and SVG's paint order is WebKit's).
584
+ `background-clip: text` becomes the `<text>`'s own fill, never a glyph mask.
585
+ `text-decoration-style: wavy` becomes a `<path>` at Chromium's measured
586
+ wavelength and amplitude, ending where the run does, with no `<clipPath>`.
587
+ - **A `<pattern>` wrapper is emitted only when the layer genuinely tiles inside
588
+ the painted box.** Figma does not resolve `fill="url(#pattern)"` — the node does
589
+ not degrade, it disappears — and a single tile covering its own box never needed
590
+ the wrapper.
591
+ - **`box-shadow` is `feDropShadow` by default**, not the exact
592
+ `feMorphology → feGaussianBlur → feOffset → feFlood → feComposite → feMerge`
593
+ chain: Figma runs part of that chain and paints a black rectangle. `spread` has
594
+ no `feDropShadow` parameter and is folded into the blur, loudly
595
+ (`emit.svg.shadow-spread-in-blur`). `{exactShadows: true}` asks for the exact
596
+ chain back — exact in a browser, unimportable. **This is a measured pixel
597
+ regression accepted as a product decision**, recorded in
598
+ `test/vector-baselines.json` under `_acceptedRegressions`.
599
+
600
+ ### `packages/vector/src/emit/figma-json.js`
601
+
602
+ ```js
603
+ /** @param {SVDDocument} doc @returns {FigmaPayload} */
604
+ export function svdToFigma (doc)
605
+ ```
606
+
607
+ **`svdToFigma` is the single canonical translator and `figma-plugin/code.js`
608
+ consumes its output verbatim.** There must not be a second SVD-reading path
609
+ inside the plugin. The plugin validates `payload.version`, then builds; if it
610
+ finds itself deriving Figma properties from raw SVD fields, that logic belongs
611
+ in `svdToFigma` where it can be tested in Node without the Figma sandbox.
612
+
613
+ ```js
614
+ FigmaPayload = {
615
+ version: 1,
616
+ fonts: [{ key, family, style, weight, italic, stack,
617
+ nodes, chars, nodeIds, canOutline, canRaster }], // for the preflight UI
618
+ root: 'n_0',
619
+ nodes: { [id]: FigmaNodeSpec },
620
+ diagnostics: [...], // ONLY what the translation itself lost
621
+ document: { // everything the plugin cannot re-derive
622
+ assets, // asset bytes, addressed by id from image fills
623
+ capture, // the capture header
624
+ report,
625
+ diagnostics, // what the ENGINE lost, kept apart so the
626
+ }, // two lists never double-count
627
+ }
628
+ ```
629
+ The plugin is handed this object and nothing else — it never sees the SVD — so
630
+ `document` is not optional decoration: without it there are no image bytes and no
631
+ provenance. Every node also carries `provenance` (for `setPluginData`), and text
632
+ nodes carry `outlineAsset`/`rasterAsset`.
633
+ `FigmaNodeSpec` uses Figma's own vocabulary: `type` in caps, `x`/`y`/`width`/`height`
634
+ (never `frame`), `fills`/`strokes`/`effects` in Figma paint shape, and text ranges
635
+ as serialized `setRange*` instructions. Gradient geometry arrives as a finished
636
+ `gradientTransform` — the plugin never sees `unit`.
637
+ Pure. Produces the payload the Figma plugin consumes — **plain data, no Figma API
638
+ calls** (those live in the plugin sandbox). Gradient geometry converted to
639
+ `gradientTransform`. Per-side stroke weights only on rect-like nodes. Colors stay
640
+ 0..1 floats (Figma's native form).
641
+
642
+ A composed shape (a per-side border, an outline, an image that does not fill its
643
+ box) becomes a node of its own with `composed: <kind>` and an id `<owner>__cN`;
644
+ `root.siblings` holds the ones the capture root could not adopt, which the plugin
645
+ must append NEXT to the root. `degrade: {action:'OUTLINE'|'RASTER', reason, asset}`
646
+ marks a node the Figma model cannot hold at all (a skew, `text.emit.mode`).
647
+
648
+ **A TEXT node's own vocabulary**, since it is the one place the payload is richer
649
+ than a naive read of the SVD suggests:
650
+
651
+ ```js
652
+ { characters, fontName:{family,style}, fontSize, fills, lineHeight:{unit,value},
653
+ letterSpacing:{unit,value}, paragraphSpacing, textCase:'ORIGINAL',
654
+ textAutoResize:'NONE', textAlignHorizontal, textAlignVertical,
655
+ strokes, strokeWeight, strokeAlign:'CENTER', // -webkit-text-stroke, node-level
656
+ setRange: [{ start, end, fontName, fontSize, fills, hyperlink,
657
+ textDecoration, textDecorationStyle?, textDecorationColor?,
658
+ textDecorationThickness?,
659
+ letterSpacing?, // per run — the Plugin API HAS setRangeLetterSpacing
660
+ textCase?, // SMALL_CAPS / SMALL_CAPS_FORCED, from `variant`
661
+ baselineShift? }], // measured `run.dy`; no Figma setter, carried anyway
662
+ textLayout: { pitch, lineCount, baselines, lineWidths, lineX,
663
+ firstBaselineFromTop, trailingSpacingComp, widthSlackPx,
664
+ hyphenLines, svgPasteWidthDelta } }
665
+ ```
666
+
667
+ `textLayout` is consumed by no setter: it is the measured browser layout, kept so
668
+ the plugin can check its result against it and so the export carries a record of
669
+ what the browser saw.
670
+
671
+ **The two routes into Figma do not lose the same things.** This is
672
+ counter-intuitive and it is the source of repeated rediscovery, so it is
673
+ normative here: `letter-spacing`, `-webkit-text-stroke` and small caps ARE in the
674
+ Plugin API (`setRangeLetterSpacing`, `TextNode.strokes`, `setRangeTextCase`) and
675
+ this payload carries all three; **Figma's SVG importer discards all three**, which
676
+ is measured in FIGMA_FINDINGS.md §5 (a 22px/700 line arriving 19% wide, +50px of
677
+ it ignored letter-spacing). The hyphen goes the other way: `emit/svg-flat.js`
678
+ paints `line.hyphen` and this file deliberately does not — a Figma TextNode
679
+ reflows to its own box with its own substituted font, so the break moves and a
680
+ hyphen written into `characters` would land inside a word
681
+ (`figma.text-hyphen-dropped`; the lines it happened on are in
682
+ `textLayout.hyphenLines`).
683
+
684
+ **What has no Figma equivalent at all, and is therefore declared, never dropped
685
+ quietly:** the `colorMatrix` effect (Figma's effect list is DROP_SHADOW,
686
+ INNER_SHADOW, LAYER_BLUR, BACKGROUND_BLUR — there is no colour-matrix effect, no
687
+ colour-matrix paint and no blend mode that stands in for one, so
688
+ `figma.effect-colormatrix-dropped` names the CSS function from `srcCss` and says
689
+ whether it was `filter` or `backdrop-filter`); the OpenType half of `variant`
690
+ (`tabular-nums` and friends — the Plugin API exposes no feature control, same wall
691
+ as `font-feature-settings`); `-webkit-text-stroke` when the runs of a block
692
+ disagree, because a Figma stroke is per-NODE and applying it would contour glyphs
693
+ the page does not contour; and per-range baseline shift.
694
+
695
+ ### `packages/vector/src/emit/pdf.js`
696
+
697
+ ```js
698
+ /**
699
+ * @param {SVDDocument} doc
700
+ * @param {object} [options] {compress, background, shadowLayers, quality,
701
+ * onDiagnostic, onWarn, pages, links}
702
+ * @returns {Promise<Uint8Array>} the bytes, with a non-enumerable `diagnostics`
703
+ */
704
+ export async function svdToPdf (doc, options = {})
705
+ ```
706
+ The third backend, and the one with the smallest primitive set: PDF has no blur,
707
+ so every blurred shadow is `shadowLayers` concentric copies, and it has no
708
+ colour-matrix either. `pages` is finished page geometry from a caller that owns
709
+ the pagination — this emitter paginates nothing on its own. The diagnostics ride
710
+ on the returned array AND are written into the file as an uncompressed object off
711
+ the catalog, so `strings out.pdf` finds them: an approximation that only reaches a
712
+ stream nobody opens is a silent degradation.
713
+
714
+ ### `packages/vector/src/index.js`
715
+
716
+ ```js
717
+ /**
718
+ * @param {object} [options] {mode, maxNodes, maxDepth, exclude, fetchTimeout,
719
+ * maxAssetBytes, silent, debugPaint}
720
+ * @returns {object} snapdom plugin — {name, beforeRender, defineExports}
721
+ */
722
+ export function vector (options = {})
723
+ export { svdToSvg, svdToFigma }
724
+ export default vector
725
+ ```
726
+
727
+ `beforeRender(state)` does one thing: `state.options.__vectorHandoff = handoffFrom(state)`.
728
+ Everything else is in the exports it declares —
729
+
730
+ ```js
731
+ defineExports () {
732
+ return {
733
+ vector: (ctx, opts) => buildDocument(ctx, {...options, ...opts}), // result.toVector()
734
+ vectorSvg: (ctx, opts) => svgFrom(await buildDocument(ctx, ...), ...), // result.toVectorSvg()
735
+ }
736
+ }
737
+ ```
738
+
739
+ — which mount the clone, walk it, validate the document and `unmount()` in a
740
+ `finally`. Per-capture options may be passed to the factory or to the export; the
741
+ export's win.
742
+
743
+ There is **no** `toVector(element)` and **no** `options.snapdom`: the caller writes
744
+ the `snapdom()` call, so every capture option is already theirs to set. `dpr`,
745
+ `outerTransforms` and `burst` are read before the first hook and cannot be forced
746
+ from here in any case.
747
+
748
+ Orchestrator only: builds ctx, calls `buildPaintTree`, collects per node, assembles
749
+ the document, fills `report`, runs `validate` and **throws on invalid output**.
750
+ Every collector returns `{...data, warnings: []}`; the orchestrator prints them with
751
+ the `[snapdom-vector]` prefix and folds them into `diagnostics`.
752
+
753
+ It also owns the two wiring steps a collector cannot do from where it stands, and
754
+ both were silent holes until they existed: `wireMasks` turns `collectBox`'s mask
755
+ layers into the node `node.mask` references, and `applyImage` writes `node.svg` +
756
+ `node.svgPlacement` for a vector asset.
757
+
758
+ ## Shared node shape
759
+
760
+ ```js
761
+ {
762
+ type: 'frame', // NodeType
763
+ name: '#plan-card', // human-readable, from tag/id/class/text
764
+ frame: { x, y, w, h }, // relative to emitted parent
765
+ abs: { x, y, w, h }, // relative to capture root
766
+ paint: { z: 0, stackingContext: false },
767
+ transform: null, // {m, decomposed} | null
768
+ opacity: 1,
769
+ blend: 'normal',
770
+ clip: null, // {mode:'rect'|'path', box:'padding', radii, d?} | null
771
+ fills: [], strokes: [], effects: [],
772
+ text: null, // only on type:'text'
773
+ fidelity: { grade: 'E', notes: [] },
774
+ source: { tag, path, css: {} },
775
+ children: [],
776
+ }
777
+ ```
778
+
779
+ Optional, and read by both emitters when present:
780
+
781
+ ```js
782
+ radii: [[rx,ry] × 4], // tl, tr, br, bl — CSS §5.5 overlap factor already applied
783
+ isolate: true, // the node isolates its children's blending
784
+ mask: { node: 'n_7', type: 'alpha'|'luminance' }, // a REFERENCE, never a paint
785
+ svg: '<svg …>', // passthrough markup for an inline/vector asset
786
+ svgPlacement: { crop, transform, clipBox }, // where that markup lands
787
+ control: { kind, … }, // a native form widget, described — see below
788
+ ```
789
+
790
+ ### `node.control` — the one thing on a node that is not paint
791
+
792
+ ```js
793
+ control: {
794
+ kind: 'checkbox'|'radio'|'range'|'select'|'text'|'email'|…, // what the UA draws
795
+ checked: false, indeterminate: false, // checkbox/radio
796
+ fraction: 0.62, value: '62', min: 0, max: 100, // range/progress/meter
797
+ accent: [r,g,b,a], accentSource: 'accent-color'|'ua-default',
798
+ ink: [r,g,b,a], // tick/thumb colour, by luminance over accent
799
+ border: [r,g,b,a], track: [r,g,b,a], surface: [r,g,b,a],
800
+ direction: 'ltr'|'rtl', disabled: false,
801
+ }
802
+ ```
803
+
804
+ **Why it exists and why it is not fills.** A native control is the one thing on a page
805
+ that `getComputedStyle` cannot describe: Chromium paints the tick, the thumb, the track
806
+ and the chevron itself, from the element's TYPE and STATE, and no CSS property in the
807
+ cascade mentions any of them (`collect/box.js` files `collect.box.form-control` saying
808
+ so). So the collector records the semantics — what kind of control, in what state, with
809
+ which accent — and a backend reconstructs the picture from it at Chromium's measured
810
+ proportions, or declines to and says nothing was drawn. It is deliberately NOT a fill
811
+ list: fills would freeze one platform's drawing into the document, and a consumer that
812
+ knows better (Figma, which has real components) could never recognise the widget again.
813
+
814
+ `describeWidget` (`paint.js`) returns `null` — and the field is absent — when
815
+ `appearance: none` says the author replaced the widget with their own CSS, which the
816
+ ordinary collectors already read. Two checkboxes in one box is worse than none.
817
+
818
+ **Who reads it.** `emit/svg-flat.js` draws it (`emit.svg.control-reconstructed`, grade
819
+ A, one per control: a reconstruction is not a capture). `emit/figma-json.js` and
820
+ `emit/pdf.js` ignore it, so the widget is missing there and `B4.form-control` remains
821
+ the whole story on those two paths.
822
+
823
+ ## What the Figma importer does — read `FIGMA_FINDINGS.md` before changing an emitter
824
+
825
+ **The default flow into Figma is the universal SVG (product call, Martin,
826
+ 2026-08-13):** in practice it pastes better into today's Figma than the h2d
827
+ clipboard channel lands, so recommend `toVectorSvg()` → paste first, with the
828
+ importer's losses below as the accepted cost. h2d stays as the channel for when
829
+ the editable text layer outweighs paste fidelity. Do not bend the universal
830
+ emitter toward this importer — that is what `target: 'figma'` and h2d are for.
831
+
832
+ Five behaviours of Figma's SVG importer are the reason the emitters look the way
833
+ they do. Every one was **isolated by changing a single thing in the SVG and
834
+ pasting into Figma again** — none is deduced from documentation, and each has its
835
+ artefact in `out/vector/_clone-*.svg`. They are summarised here only so nobody
836
+ rediscovers them the hard way; the measurements, the controls and the ideas
837
+ already tried and rejected are in `FIGMA_FINDINGS.md`, which is normative for
838
+ this subject.
839
+
840
+ 1. **The `<text>` element must carry its own typography** (§1). Figma builds its
841
+ TextNode from `<text>` and does not look inside for a `<tspan>` with the real
842
+ values: a bare `<text>` lands at the importer's default size and weight.
843
+ 2. **Interior `<tspan>` `x`/`y` are discarded and the block is reflowed with
844
+ Figma's own leading** (§2). Hence one `<text>` per visual line.
845
+ 3. **`fill="url(#pattern)"` is not resolved and the whole node disappears** (§3)
846
+ — it does not degrade, it vanishes. Hence: no `<pattern>` wrapper for a layer
847
+ that does not actually tile.
848
+ 4. **A filter chain is only partly executed** (§4). Figma keeps the `feFlood` and
849
+ the blur and loses the `feComposite` that cuts the colour to the silhouette, so
850
+ the exact shadow chain arrives as a black rectangle. Hence `feDropShadow`, at
851
+ the cost of `spread`.
852
+ 5. **`letter-spacing` is ignored on paste, and font STACKS are not resolved**
853
+ (§5, the one with no workaround). Measured: 376.8px of text arriving at 449px,
854
+ +50px from the ignored spacing and +23px from falling back to Inter. Four ways
855
+ of forcing the width through the SVG were tried and all four failed — do not
856
+ retry them. Consequence: anything anchored to the end of a line drifts, which
857
+ is one cause behind three symptoms (the floating wavy underline, the short
858
+ justified line, the misplaced hyphen).
859
+
860
+ The corollary that keeps being got backwards: **the SVG-paste route is not the
861
+ lossless one.** For text metrics the Plugin API payload is strictly richer —
862
+ `letter-spacing`, per-range small caps and a text stroke all survive it and none
863
+ of them survives a paste. See the TEXT block under `emit/figma-json.js`.
864
+
865
+ ## The two paid plugins are separate products, and now separate repositories
866
+
867
+ `@zumer/snapdom-pdf` and `@zumer/snapdom-vector` are bought separately, and **neither
868
+ may import the other.** This is a licence boundary before it is an architectural one:
869
+ a vector customer who receives a slice of the PDF plugin has been shipped something
870
+ nobody sold them.
871
+
872
+ The history is worth keeping because it is the reason for the shape. Until 2026-08-08
873
+ `emit/pdf.js` imported the byte-level writer through `../../../pdf/src/pdf-writer.js`
874
+ — a relative path into the other product, undeclared in `package.json`, and visible to
875
+ users as `[snapdom-pdf]` warnings coming out of a vector capture. It was then lifted
876
+ into a shared private package, `@zumer/pdf-writer`, that both plugins depended on by
877
+ name and each build inlined. On 2026-08-28 the PDF product moved to a repository of
878
+ its own, and the shared package went with the split: **each product now owns its copy
879
+ of the writer as source** — `packages/vector/src/writer/` here, `src/writer/` there.
880
+
881
+ That trade is deliberate and it is not free. Nothing can leak between the two any
882
+ more, and nothing can be fixed in both at once either: a correction here does not
883
+ reach the PDF product, and the two will drift. That is the cost of the boundary being
884
+ a repository rather than a rule.
885
+
886
+ Three rules follow, and the first two are unchanged:
887
+
888
+ - **Nothing in `src/writer/` may name a product or know what it is drawing.** The
889
+ warning prefix is injected by whoever loaded it (`setWarnPrefix`).
890
+ - **A module only this product needs belongs to this product.** There is no shared
891
+ package left to put it in, and reaching into the other repository is not available.
892
+ - **The build enforces this, not review.** `packages/vector/build.mjs` fails if the
893
+ bundle names the other product, imports it, or leaves behind an import the buyer
894
+ cannot resolve. A rule with no test is a rule that gets re-broken by the next
895
+ person who needs one function.
896
+
897
+ ## House rules
898
+
899
+ - Match the surrounding code: ESM, 2-space indent, no semicolon-free style changes,
900
+ JSDoc on exported functions, comments only where the *why* is not obvious.
901
+ - Every approximation produces a `warnings` entry. **Nothing degrades silently** —
902
+ this is the product's differentiator, not a nicety.
903
+ - Pure modules (`css/*`, `emit/*`, `paint.js` predicates) must run in Node without a
904
+ DOM so the harness can fuzz them.
905
+ - No `new Function`, no `eval` — they detonate under a page's CSP. snapStudio already
906
+ has this bug at `content/index.js:236`; do not repeat it.