@zumer/snapdom-plugins 3.0.0-beta.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.
- package/CHANGELOG.md +13 -0
- package/README.md +54 -20
- package/agent-map.d.ts +32 -0
- package/agent-map.js +5 -3
- package/ascii-export.d.ts +10 -0
- package/ascii-export.js +21 -6
- package/capture-frames.js +5 -5
- package/color-tint.js +4 -0
- package/context-export.d.ts +23 -0
- package/context-export.js +4 -1
- package/filter.js +1 -1
- package/gif-export.d.ts +6 -0
- package/gif-export.js +3 -3
- package/html-export.d.ts +8 -0
- package/html-export.js +6 -4
- package/index.d.ts +2 -1
- package/index.js +2 -1
- package/package.json +17 -6
- package/pdf/LICENSE +21 -0
- package/pdf/README.md +22 -0
- package/pdf/REFERENCE.md +520 -0
- package/pdf/src/frontmatter.js +200 -0
- package/pdf/src/furniture.js +398 -0
- package/pdf/src/index.js +2415 -0
- package/pdf/src/mega/README.md +47 -0
- package/pdf/src/mega/diag.js +148 -0
- package/pdf/src/mega/flow.js +56 -0
- package/pdf/src/mega/fonts.js +287 -0
- package/pdf/src/mega/forms.js +479 -0
- package/pdf/src/mega/labels.js +43 -0
- package/pdf/src/mega/meta.js +145 -0
- package/pdf/src/nav.js +357 -0
- package/pdf/src/paginate.js +583 -0
- package/pdf/src/tagged.js +355 -0
- package/pdf/src/text-layer.js +1771 -0
- package/pdf/src/writer/encrypt.js +218 -0
- package/pdf/src/writer/font.js +507 -0
- package/pdf/src/writer/index.js +1135 -0
- package/pdf/src/writer/subset.js +387 -0
- package/pdf.d.ts +294 -0
- package/pdf.js +1 -0
- package/redact-inputs.js +6 -6
- package/vector/CONTRACT.md +906 -0
- package/vector/LICENSE +21 -0
- package/vector/README.md +35 -0
- package/vector/src/adapters/snapdom.js +795 -0
- package/vector/src/collect/box.js +1676 -0
- package/vector/src/collect/font.js +487 -0
- package/vector/src/collect/image.js +1458 -0
- package/vector/src/collect/mathml.js +272 -0
- package/vector/src/collect/text.js +3027 -0
- package/vector/src/css/color.js +823 -0
- package/vector/src/css/gradient.js +1126 -0
- package/vector/src/css/matrix.js +398 -0
- package/vector/src/css/radius.js +124 -0
- package/vector/src/css/shadow.js +201 -0
- package/vector/src/emit/figma-json.js +1995 -0
- package/vector/src/emit/h2d-vector.js +215 -0
- package/vector/src/emit/h2d.js +1147 -0
- package/vector/src/emit/pdf.js +2571 -0
- package/vector/src/emit/svg-flat.js +4842 -0
- package/vector/src/exclusion.js +13 -0
- package/vector/src/index.js +2910 -0
- package/vector/src/paint.js +2908 -0
- package/vector/src/public.js +57 -0
- package/vector/src/writer/encrypt.js +227 -0
- package/vector/src/writer/font.js +507 -0
- package/vector/src/writer/index.js +1148 -0
- package/vector/src/writer/subset.js +387 -0
- package/vector/svd/index.js +72 -0
- package/vector/svd/schema.js +985 -0
- package/vector.d.ts +28 -0
- package/vector.js +1 -0
- package/video-export.d.ts +6 -0
- package/video-export.js +7 -7
- package/pdf-image.d.ts +0 -18
- 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.
|