@zumer/snapdom-plugins 3.0.0 → 4.0.1

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 +21 -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 +22 -6
  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,2910 @@
1
+ /**
2
+ * snapdom Pro — vector capture, as a snapdom PLUGIN. One capture in, one SVD
3
+ * document out:
4
+ *
5
+ * const result = await snapdom(el, { plugins: [vector()] })
6
+ * const doc = await result.toVector() // the SVD document
7
+ * const svg = await result.toVectorSvg() // the flat SVG
8
+ * const h2d = await result.toVectorFigmaClipboard() // Figma's paste envelope
9
+ *
10
+ * The third one is a PARALLEL backend off the same clone, not a consumer of the
11
+ * SVD: it writes Figma's own `h2d` clipboard payload — the computed DOM, which
12
+ * Figma converts on paste — and touches neither `emit/svg-flat.js` nor the
13
+ * document. See `emit/h2d.js` for what that format costs.
14
+ *
15
+ * `vector()` at the bottom of this file is the whole public surface. There is no
16
+ * second way in — no loose `toVector(element)` that makes its own snapdom call —
17
+ * because two ways in means two behaviours to keep in step, and the one that
18
+ * hid the `snapdom()` call also hid its options from the person making it.
19
+ *
20
+ * This module is an ORCHESTRATOR and nothing else. It owns four things and
21
+ * delegates everything else:
22
+ *
23
+ * 1. the capture context — the root rect, the memoised `getComputedStyle`, the
24
+ * font-metric cache, the id counter, the shared measuring canvas and the
25
+ * warnings array every collector writes into;
26
+ * 2. the traversal — `buildPaintTree` decides structure and paint order, and
27
+ * each emitted node is handed to the collector its type calls for, with the
28
+ * asynchronous ones (images, background bytes) batched through `Promise.all`
29
+ * rather than serialised;
30
+ * 3. the bookkeeping no collector can do because each sees one element at a
31
+ * time — asset ids, text-style ids deduplicated across blocks, the fonts the
32
+ * page supplies and this build does not embed, and turning every `warnings`
33
+ * entry into a `diagnostics` entry with a fidelity grade. The GRADE belongs
34
+ * to the collector: a warning may arrive as `{code, grade, message}` and
35
+ * that grade is used verbatim. Guessing one from the prose is a fallback for
36
+ * the collectors that still speak only prose, and it is marked as such;
37
+ * 4. the refusal to ship a broken document: `validate` runs on our own output
38
+ * and a failure throws, with the schema's errors in the message.
39
+ *
40
+ * The rule that outranks the rest of this file: **nothing degrades silently.**
41
+ * A collector warning always becomes a diagnostic AND a grade on the node it
42
+ * came from. A node graded other than `E` that no diagnostic names is a bug the
43
+ * validator rejects — and it is right to.
44
+ *
45
+ * **The tree that gets walked is snapdom's CLONE, not the live DOM.** That is the
46
+ * founding decision of this engine and `packages/CONTRACT.md` carries the
47
+ * evidence: the plugin's `beforeRender` hook takes the resolved clone out of the
48
+ * capture, `adapters/snapdom.js` mounts it offscreen and hands it over already
49
+ * laid out, with pseudo-elements materialised
50
+ * into real elements, shadow DOM flattened, icon fonts resolved and images inlined
51
+ * past CORS. Membership in the tree is solved before this file runs; only geometry
52
+ * and paint are ours.
53
+ *
54
+ * Two consequences the rest of this file depends on:
55
+ *
56
+ * - **`capture.selector` and `capture.url` describe the ORIGINAL element.** The
57
+ * user wants to know what was captured, not where we mounted a copy of it.
58
+ * - **the mount is verified, every time.** `capture.drift` is the mounted
59
+ * clone's boxes measured against the live boxes they came from; it lands in
60
+ * `report.drift` and, past 1px, in a diagnostic. Every coordinate this engine
61
+ * emits is measured on the clone, so an unverified mount is an unverified
62
+ * document.
63
+ *
64
+ * The clone is transient: the export unmounts it in a `finally`, and drops the
65
+ * `el` back-reference each node carries so the document does not pin a detached
66
+ * copy of the user's subtree in memory for as long as it lives.
67
+ */
68
+ import { emptyDocument, validate } from '../svd/index.js'
69
+ import { handoffFrom, mountClone } from './adapters/snapdom.js'
70
+ import { buildPaintTree } from './paint.js'
71
+ import { collectBox, resolveLayerRect } from './collect/box.js'
72
+ import { collectText } from './collect/text.js'
73
+ import { collectImage, svgBackgroundAsset, looksLikeSvg } from './collect/image.js'
74
+ import { collectMathML } from './collect/mathml.js'
75
+ import { collectFonts } from './collect/font.js'
76
+ import { svdToSvg, rasterFilterPlan } from './emit/svg-flat.js'
77
+ import { svdToFigma } from './emit/figma-json.js'
78
+ import { cloneToH2d, h2dEnvelope, h2dClipboardItem, H2D_PROPS, H2D_VERSION } from './emit/h2d.js'
79
+ import { findFallbackTargets, svdSliceToDataUri } from './emit/h2d-vector.js'
80
+
81
+ export { svdToSvg, svdToFigma }
82
+ export { cloneToH2d, h2dEnvelope, h2dClipboardItem, H2D_PROPS, H2D_VERSION }
83
+
84
+ /** Fidelity grades, worst-wins. Mirrors `FIDELITY` in @zumer/svd and `paint.js`. */
85
+ const GRADE_RANK = { E: 0, A: 1, C: 2, R: 3, RH: 3, O: 4 }
86
+
87
+ /**
88
+ * CSS `mix-blend-mode` keywords the schema accepts. Duplicated from the
89
+ * validator deliberately: an unknown keyword has to become a diagnostic here,
90
+ * not a validation failure that takes a whole capture down.
91
+ */
92
+ const BLEND_MODES = new Set([
93
+ 'normal', 'multiply', 'screen', 'overlay', 'darken', 'lighten',
94
+ 'color-dodge', 'color-burn', 'hard-light', 'soft-light', 'difference',
95
+ 'exclusion', 'hue', 'saturation', 'color', 'luminosity', 'plus-lighter',
96
+ ])
97
+
98
+ /** Past this a background asset is worth reporting; it is still embedded. */
99
+ const BIG_ASSET_BYTES = 8 * 1024 * 1024
100
+
101
+ const EXT_MIME = {
102
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
103
+ webp: 'image/webp', avif: 'image/avif', svg: 'image/svg+xml', svgz: 'image/svg+xml',
104
+ bmp: 'image/bmp', ico: 'image/x-icon',
105
+ }
106
+
107
+ /**
108
+ * Warnings that report WORK DONE rather than fidelity lost. They still become
109
+ * diagnostics — the document says what happened — but they must not degrade the
110
+ * node, or a capture that succeeded would grade itself down for succeeding.
111
+ */
112
+ const INFORMATIONAL = [
113
+ /have been copied into it\.$/,
114
+ /were inlined as data URIs\.$/,
115
+ // CSS itself ignores the declaration, so the capture matches the page exactly.
116
+ // Nothing was lost; the reader still gets told why the property had no effect.
117
+ /is ignored by CSS/,
118
+ ]
119
+
120
+ /**
121
+ * Structural approximations: the cases where the contract says the CALLER
122
+ * composes several primitives and v0 does not. Each gets its own code so the
123
+ * count is readable in the grouped log and greppable in `diagnostics`.
124
+ */
125
+ const COMPOSE_CASES = [
126
+ {
127
+ test: /border sides differ/,
128
+ code: 'compose.border-sides',
129
+ note: 'per-side border colors ride on one stroke (the first visible side wins); ' +
130
+ 'one shape per side is not composed in v0',
131
+ },
132
+ {
133
+ test: /background layers use more than one clip box/,
134
+ code: 'compose.background-clip',
135
+ note: 'the background layers stay on one node; splitting them per clip box is not done in v0',
136
+ },
137
+ {
138
+ test: /outline is emitted as a sibling stroke/,
139
+ code: 'compose.outline',
140
+ note: 'the outline stays on this node as an outside-aligned stroke instead of becoming a ' +
141
+ 'sibling painted last in the parent z-order',
142
+ },
143
+ ]
144
+
145
+ /**
146
+ * Loss keywords, in priority order: the first match decides the grade.
147
+ *
148
+ * LAST RESORT ONLY. A collector that knows what it lost says so with a grade
149
+ * (see `classify`), and this list is what is left for the ones that still return
150
+ * bare prose. Reading a grade off English is guessing, so the patterns are
151
+ * anchored to what a message can only mean:
152
+ *
153
+ * - `R` needs an actual rasterization VERB. The old pattern matched a bare
154
+ * `/canvas/i`, which graded *"the canvas rejected the font shorthand … metrics
155
+ * were measured with a generic sans-serif"* as R — a wholly vector text node
156
+ * counted as raster in `report.grades`, against a product whose claim is zero
157
+ * raster. A measuring canvas is not a rasterized node.
158
+ * - `O` needs something to be gone. `is ignored` used to be here and matched
159
+ * *"object-fit … is ignored by CSS"*, where nothing is missing at all — it is
160
+ * informational now.
161
+ */
162
+ const GRADE_PATTERNS = [
163
+ {
164
+ // Every message that really means "pixels now" carries one of these verbs.
165
+ // The word `canvas` is deliberately absent: this engine MEASURES with a
166
+ // canvas (font metrics, colour resolution) far more often than it rasterizes
167
+ // with one, and those two must never share a grade.
168
+ test: /\brasteriz|\bre-encoded\b|\breadback\b|\bread back\b|current frame/i,
169
+ grade: 'R',
170
+ },
171
+ {
172
+ test: /dropped|placeholder|not applied|not represented|not emitted|not captured|omitted|left out|could not be read|could not be fetched|unreachable|no pixels|with no data/i,
173
+ grade: 'O',
174
+ },
175
+ ]
176
+
177
+ const SEVERITY_OF = { E: 'info', A: 'info', C: 'info', R: 'warn', RH: 'warn', O: 'warn' }
178
+
179
+ /** The three the schema accepts; anything else a collector offers is ignored. */
180
+ const SEVERITIES = new Set(['info', 'warn', 'error'])
181
+
182
+ const clamp01 = (n) => (Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 1)
183
+
184
+ const hasOwn = (o, k) => Object.prototype.hasOwnProperty.call(o, k)
185
+
186
+ /** Worst grade wins: a node that is both approximate and omitted is omitted. */
187
+ function degradeNode (node, grade, note) {
188
+ if (!node || !hasOwn(GRADE_RANK, grade)) return
189
+ if (!node.fidelity) node.fidelity = { grade: 'E', notes: [] }
190
+ if (!Array.isArray(node.fidelity.notes)) node.fidelity.notes = []
191
+ if (GRADE_RANK[grade] > GRADE_RANK[node.fidelity.grade]) node.fidelity.grade = grade
192
+ if (note && !node.fidelity.notes.includes(note)) node.fidelity.notes.push(note)
193
+ }
194
+
195
+ function escapeIdent (value) {
196
+ if (typeof CSS !== 'undefined' && CSS && typeof CSS.escape === 'function') return CSS.escape(value)
197
+ return String(value).replace(/([^\w-])/g, '\\$1')
198
+ }
199
+
200
+ /**
201
+ * A selector that finds the capture root again in the page it came from. It
202
+ * stops at the first ancestor carrying an id, because everything above that is
203
+ * noise to whoever reads `capture.selector`.
204
+ */
205
+ function cssSelector (el) {
206
+ if (!el || el.nodeType !== 1) return ''
207
+ const parts = []
208
+ for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
209
+ if (n.id) {
210
+ parts.unshift(`#${escapeIdent(n.id)}`)
211
+ break
212
+ }
213
+ const tag = n.tagName.toLowerCase()
214
+ const parent = n.parentElement
215
+ if (!parent) {
216
+ parts.unshift(tag)
217
+ break
218
+ }
219
+ let i = 1
220
+ for (const sib of parent.children) {
221
+ if (sib === n) break
222
+ i++
223
+ }
224
+ parts.unshift(`${tag}:nth-child(${i})`)
225
+ }
226
+ return parts.join(' > ')
227
+ }
228
+
229
+ /**
230
+ * Ids are issued at CALL time, not at registration time: `collectImage` runs
231
+ * concurrently, so two sources with the same file name would both be handed
232
+ * `a_logo` if the set were only updated once the asset came back. Same
233
+ * convention as `collect/image.js`, whose helper is not exported.
234
+ */
235
+ function nextAssetId (ctx, seed) {
236
+ const used = ctx.assets || {}
237
+ const taken = (id) => ctx.assetIds.has(id) || hasOwn(used, id)
238
+ let id = `a_${seed}`
239
+ let n = 1
240
+ while (taken(id)) id = `a_${seed}_${n++}`
241
+ ctx.assetIds.add(id)
242
+ return id
243
+ }
244
+
245
+ function seedFromUrl (url, fallback) {
246
+ if (!url || url.startsWith('data:')) return fallback
247
+ const clean = String(url).split(/[?#]/)[0]
248
+ const name = clean.slice(clean.lastIndexOf('/') + 1).replace(/\.[^.]+$/, '')
249
+ const safe = name.replace(/[^A-Za-z0-9_]/g, '_').slice(0, 24)
250
+ return safe || fallback
251
+ }
252
+
253
+ // ——— the capture context ———
254
+
255
+ /**
256
+ * @typedef {object} CollectCtx
257
+ * @property {'design'|'replica'} mode
258
+ * @property {(el: Element) => CSSStyleDeclaration} styleOf memoised computed styles
259
+ * @property {{x:number,y:number,w:number,h:number}} rootRect viewport rect of the capture root
260
+ * @property {Map<string,{ascent:number,descent:number,ok:boolean}>} metrics by full font string
261
+ * @property {CanvasRenderingContext2D|null} measureCtx one reusable 2D context
262
+ * @property {(prefix: string) => string} nextId the capture's id counter
263
+ * @property {Array<{code:string,node:(string|undefined),message:string}>} warnings
264
+ */
265
+
266
+ /**
267
+ * @param {Element} rootEl the MOUNTED CLONE root — everything the collectors
268
+ * read comes off this tree, including `rootRect`, which is the origin `abs` is
269
+ * expressed against. It is the clone's rect and not the live one on purpose:
270
+ * both sides are relative to their own root, so the offscreen mount offset
271
+ * cancels, and mixing the two would offset the whole document by ~99999px.
272
+ * @param {object} options
273
+ */
274
+ function makeCtx (rootEl, options) {
275
+ const rect = rootEl.getBoundingClientRect()
276
+ const cache = new WeakMap()
277
+ const canvas = typeof document !== 'undefined' ? document.createElement('canvas') : null
278
+ const measureCtx = canvas ? canvas.getContext('2d') : null
279
+ let seq = 0
280
+
281
+ const ctx = {
282
+ mode: options.mode === 'replica' ? 'replica' : 'design',
283
+ rootEl,
284
+ rootRect: { x: rect.left, y: rect.top, w: rect.width, h: rect.height },
285
+
286
+ styleOf (el) {
287
+ let cs = cache.get(el)
288
+ if (!cs) {
289
+ cs = getComputedStyle(el)
290
+ cache.set(el, cs)
291
+ }
292
+ return cs
293
+ },
294
+
295
+ metrics: new Map(),
296
+ measureCtx,
297
+
298
+ /**
299
+ * Ascent/descent for one EXACT font string, cached on that string and never
300
+ * on the family: Chromium rounds `fontBoundingBox*` to integers per size, so
301
+ * a 12px ascent scaled down from a 16px measurement can be a whole pixel out.
302
+ * `ok:false` means the context refused the shorthand and kept its previous
303
+ * font — the numbers describe some other font and must not be used.
304
+ */
305
+ measureFont (fontCss) {
306
+ const hit = ctx.metrics.get(fontCss)
307
+ if (hit) return hit
308
+ let out = { ascent: 0, descent: 0, ok: false }
309
+ if (measureCtx) {
310
+ measureCtx.font = fontCss
311
+ if (measureCtx.font) {
312
+ const m = measureCtx.measureText('Hxg')
313
+ out = {
314
+ ascent: Number.isFinite(m.fontBoundingBoxAscent) ? m.fontBoundingBoxAscent : 0,
315
+ descent: Number.isFinite(m.fontBoundingBoxDescent) ? m.fontBoundingBoxDescent : 0,
316
+ ok: true,
317
+ }
318
+ }
319
+ }
320
+ ctx.metrics.set(fontCss, out)
321
+ return out
322
+ },
323
+
324
+ /** The capture's one id counter, for anything not content-addressed. */
325
+ nextId: (prefix) => `${prefix}_${seq++}`,
326
+
327
+ warnings: [],
328
+
329
+ // Filled in once the document exists; `collect/image.js` reads both.
330
+ assets: null,
331
+ assetIds: new Set(),
332
+ assetCache: new Map(),
333
+
334
+ maxNodes: Number.isFinite(options.maxNodes) ? options.maxNodes : 20000,
335
+ maxDepth: Number.isFinite(options.maxDepth) ? options.maxDepth : 256,
336
+ maxDiagnosticsPerCode: Number.isFinite(options.maxDiagnosticsPerCode)
337
+ ? options.maxDiagnosticsPerCode
338
+ : 100,
339
+ exclude: options.exclude,
340
+ fetchTimeout: Number.isFinite(options.fetchTimeout) ? options.fetchTimeout : 8000,
341
+ maxAssetBytes: Number.isFinite(options.maxAssetBytes) ? options.maxAssetBytes : BIG_ASSET_BYTES,
342
+
343
+ // OFF, and not an option. `paint.js` uses this to probe `getComputedStyle(el,
344
+ // '::before')` and declare generated content out of scope — which was true
345
+ // when the walk was over the live DOM and is false now: snapdom materialises
346
+ // `::before`/`::after`/`::first-letter` into real elements with real boxes,
347
+ // so they arrive as ordinary nodes and are collected like any other. Probing
348
+ // the clone would report a loss that did not happen, on top of the node that
349
+ // represents the thing it claims is missing.
350
+ //
351
+ // What the clone does NOT materialise says so under its own code:
352
+ // `B4.list-marker` for a `::marker` (snapdom keeps it as a native marker, and
353
+ // a native marker has no element for the collectors to read), and
354
+ // `capture.pseudo-not-materialised` below for a painting pseudo that snapdom
355
+ // was asked for and did not produce.
356
+ pseudo: false,
357
+ }
358
+ return ctx
359
+ }
360
+
361
+ // ——— warnings -> diagnostics + fidelity ———
362
+
363
+ /**
364
+ * One collector warning becomes exactly one diagnostic and one grade.
365
+ *
366
+ * **The grade belongs to the collector.** It is the only place that knows
367
+ * whether a message means "I approximated this" or "this is gone", so a warning
368
+ * may be `{code, grade, message}` and that grade is used verbatim. Bare strings
369
+ * are still accepted — `box` and `text` still speak prose — and only those fall
370
+ * through to `GRADE_PATTERNS`, which is a heuristic and says so. The fallback is
371
+ * deliberately pessimistic: an unrecognised warning grades `A`, so an
372
+ * approximation nobody anticipated is still declared.
373
+ *
374
+ * `image` sets a grade on the fill as well, and `applyImage` declares that
375
+ * separately; worst-wins makes the two agree.
376
+ *
377
+ * @param {string} source collector name, for the default code
378
+ * @param {string} message
379
+ * @param {string|null} declared the collector's own grade, when it gave one
380
+ * @param {string|null} code the collector's own code, when it gave one
381
+ */
382
+ function classify (source, message, declared, code) {
383
+ const fallback = code || `collect.${source}`
384
+ for (const rule of COMPOSE_CASES) {
385
+ if (rule.test.test(message)) {
386
+ return { code: code || rule.code, grade: declared || 'A', note: rule.note }
387
+ }
388
+ }
389
+ if (declared) return { code: fallback, grade: declared, note: null }
390
+ for (const rule of INFORMATIONAL) {
391
+ if (rule.test(message)) return { code: fallback, grade: 'E', note: null }
392
+ }
393
+ for (const rule of GRADE_PATTERNS) {
394
+ if (rule.test.test(message)) return { code: fallback, grade: rule.grade, note: null }
395
+ }
396
+ return { code: fallback, grade: 'A', note: null }
397
+ }
398
+
399
+ /**
400
+ * Record one degradation: a `diagnostics` entry, a `warnings` entry for the
401
+ * grouped log, and the grade on the node. Every path that loses something goes
402
+ * through here — there is no other way to write a diagnostic in this file.
403
+ */
404
+ function report (st, nodeId, code, grade, message, severity) {
405
+ const entry = { code, severity: severity || SEVERITY_OF[grade] || 'warn', message }
406
+ if (nodeId) entry.node = nodeId
407
+ st.doc.diagnostics.push(entry)
408
+ st.ctx.warnings.push({ code, node: nodeId, message })
409
+ if (nodeId && grade !== 'E') degradeNode(st.doc.nodes[nodeId], grade, message)
410
+ return entry
411
+ }
412
+
413
+ /**
414
+ * One collector warning, in either shape the collectors speak.
415
+ *
416
+ * @param {object} st
417
+ * @param {string|undefined} nodeId
418
+ * @param {string} source
419
+ * @param {string|{code?:string, grade?:string, message:string, severity?:string}} w
420
+ */
421
+ function warn (st, nodeId, source, w) {
422
+ const object = w && typeof w === 'object'
423
+ const message = object ? w.message : w
424
+ if (typeof message !== 'string' || !message) return
425
+ const declared = object && hasOwn(GRADE_RANK, w.grade) ? w.grade : null
426
+ const code = object && typeof w.code === 'string' && w.code ? w.code : null
427
+ const severity = object && SEVERITIES.has(w.severity) ? w.severity : undefined
428
+ const classified = classify(source, message, declared, code)
429
+ const entry = report(st, nodeId, classified.code, classified.grade, message, severity)
430
+ const note = classified.note
431
+ if (note) entry.note = note
432
+ }
433
+
434
+ /**
435
+ * `warnings` hanging off a collected paint, stroke or effect.
436
+ *
437
+ * `parseColor()` returns `{rgba, srcCss, warnings}` and every approximation it
438
+ * measured lives in that array: an out-of-gamut colour mapped by chroma
439
+ * reduction WITH its ΔEOK computed, or a translucent `oklch()`/`lab()`/`color()`
440
+ * resolved through a canvas pixel and un-premultiplied from an 8-bit store.
441
+ * Tailwind v4 puts `oklch()` on every colour in its palette, so dropping this
442
+ * channel means a whole page of approximated colour exported at grade `E` with
443
+ * the error already calculated and thrown away.
444
+ *
445
+ * The collectors forward it by hanging it on the object they built; this is the
446
+ * receiving end. The array is consumed — it is a channel, not a document field.
447
+ */
448
+ function foldWarnings (st, id, source, carriers) {
449
+ for (const carrier of carriers || []) {
450
+ if (!carrier || typeof carrier !== 'object') continue
451
+ const list = carrier.warnings
452
+ if (!Array.isArray(list)) continue
453
+ delete carrier.warnings
454
+ for (const w of list) warn(st, id, source, w)
455
+ }
456
+ }
457
+
458
+ // ——— box ———
459
+
460
+ const isWeightList = (v) => Array.isArray(v) && v.length === 4 && v.every(Number.isFinite)
461
+
462
+ function normalizeStrokes (strokes, warnings) {
463
+ const out = []
464
+ for (const stroke of strokes || []) {
465
+ if (!stroke) continue
466
+ const next = { ...stroke }
467
+ // `collect/box.js` still speaks per-side `weights`; the schema's field is
468
+ // `weight`, scalar when the four sides agree so a backend without per-side
469
+ // support does not have to compare four numbers to find out they are the
470
+ // same. A stroke that already arrives schema-shaped is left alone, so this
471
+ // becomes a no-op the day the collector emits `weight` itself — and the
472
+ // collector's spelling is REMOVED either way, because two emitters currently
473
+ // carry a `|| stroke.weights` fallback for a private dialect that has no
474
+ // business leaving the collector.
475
+ if (!Number.isFinite(next.weight) && !isWeightList(next.weight)) {
476
+ if (isWeightList(next.weights)) {
477
+ const [t, r, b, l] = next.weights
478
+ next.weight = (t === r && r === b && b === l) ? t : next.weights.slice()
479
+ } else {
480
+ warnings.push('a stroke arrived with no weight and was dropped.')
481
+ continue
482
+ }
483
+ }
484
+ delete next.weights
485
+ if (!next.align) next.align = 'inside'
486
+ out.push(next)
487
+ }
488
+ return out
489
+ }
490
+
491
+ /** Everything `collectBox` found, mapped onto the node's schema fields. */
492
+ function applyBox (st, id, node, box) {
493
+ const extra = []
494
+ node.fills = Array.isArray(box.fills) ? box.fills : []
495
+ node.strokes = normalizeStrokes(box.strokes, extra)
496
+ node.effects = Array.isArray(box.effects) ? box.effects : []
497
+ node.clip = box.clip || null
498
+ // `clip-path` is not `clip`: the overflow clip only cuts descendants, this one
499
+ // also cuts the node's own background, border and shadow. It travels as its
500
+ // own field for that reason.
501
+ if (box.clipShape) node.clipShape = box.clipShape
502
+ if (box.radii) node.radii = box.radii
503
+ node.opacity = clamp01(box.opacity)
504
+ if (typeof box.blend === 'string' && BLEND_MODES.has(box.blend)) node.blend = box.blend
505
+ // `collectBox` reads the `mask-image` layers with the same grammar backgrounds
506
+ // use, and then had nowhere to put them: the schema's `mask` is a REFERENCE to
507
+ // another node, and a paint is not a node. That gap is why a masked box came
508
+ // out as a full rectangle with a warning attached. The layers are parked here
509
+ // and `wireMasks` turns each set into the node the reference needs, once the
510
+ // whole tree exists and a free id can be chosen.
511
+ if (box.mask && Array.isArray(box.mask.layers) && box.mask.layers.length) {
512
+ st.masks.push({ id, mask: box.mask })
513
+ }
514
+
515
+ for (const message of box.warnings || []) warn(st, id, 'box', message)
516
+ for (const message of extra) warn(st, id, 'box', message)
517
+ // Whatever `parseColor`/`parseShadows` measured and hung on the paint itself.
518
+ foldWarnings(st, id, 'box', node.fills)
519
+ foldWarnings(st, id, 'box', node.strokes)
520
+ foldWarnings(st, id, 'box', node.strokes.map((s) => s && s.paint))
521
+ foldWarnings(st, id, 'box', node.effects)
522
+ }
523
+
524
+ // ——— text ———
525
+
526
+ /**
527
+ * Text styles are deduplicated across the whole document: `collectText` numbers
528
+ * them `ts_0..ts_n` per BLOCK, so keeping its ids would have every block after
529
+ * the first overwrite the styles of the one before it.
530
+ *
531
+ * The shape changes too — the schema's key is `size`, not `fontSize` — which is
532
+ * the one place the orchestrator translates rather than forwards.
533
+ *
534
+ * `warnings` on the style (the colour `parseColor` could only approximate, for
535
+ * one) are collected into `sink` rather than stored: they belong to the node
536
+ * that uses the style, and the caller is the only one holding its id.
537
+ */
538
+ function registerTextStyle (st, style, sink) {
539
+ if (Array.isArray(style.warnings)) for (const w of style.warnings) sink.push(w)
540
+ const svd = {
541
+ // `family` is the family that PAINTED, resolved out of the stack by
542
+ // `collect/text.js`, and `stack` carries it in front of the page's own list —
543
+ // a font picker reads one name, never a stack. The rest of the resolution
544
+ // travels with it because a consumer has to know how good the name is:
545
+ // `cssFamily` is what `@font-face` calls the same face, `styleName` the
546
+ // subfamily its binary claims (`SemiBold`), `referenceable` false when the
547
+ // winner is a system keyword no picker can be handed.
548
+ family: style.family,
549
+ stack: style.fontFamily,
550
+ cssFamily: style.cssFamily || style.family,
551
+ stackCss: style.stackCss || style.fontFamily,
552
+ styleName: style.resolvedStyleName || null,
553
+ resolvedFrom: style.resolvedFrom || null,
554
+ genericClass: style.genericClass || null,
555
+ referenceable: style.referenceable !== false,
556
+ webfont: style.webfont === true,
557
+ size: style.fontSize,
558
+ weight: style.fontWeight,
559
+ italic: /^(italic|oblique)/.test(style.fontStyle || ''),
560
+ fontStyle: style.fontStyle,
561
+ stretch: style.fontStretch,
562
+ variant: style.fontVariant,
563
+ features: style.fontFeatureSettings,
564
+ variations: style.fontVariationSettings,
565
+ letterSpacing: style.letterSpacing,
566
+ wordSpacing: style.wordSpacing,
567
+ lineHeight: style.lineHeight,
568
+ case: style.textTransform,
569
+ fills: [{ type: 'solid', color: style.color, srcCss: style.colorCss }],
570
+ decoration: style.decorations && style.decorations.length ? style.decorations : null,
571
+ strokeWidth: style.strokeWidth,
572
+ strokeColor: style.strokeColor || null,
573
+ ascent: style.ascent,
574
+ descent: style.descent,
575
+ fontCss: style.fontCss,
576
+ }
577
+ const key = JSON.stringify(svd)
578
+ let id = st.textStyleIds.get(key)
579
+ if (!id) {
580
+ id = `ts_${st.textStyleIds.size}`
581
+ st.textStyleIds.set(key, id)
582
+ st.doc.styles.text[id] = svd
583
+ }
584
+ return id
585
+ }
586
+
587
+ /**
588
+ * A `text` node whose block turned out to paint no characters. It cannot stay a
589
+ * text node — the schema requires non-empty `characters`, and rightly: a text
590
+ * node with nothing in it is a hole in the export nobody would notice. So it
591
+ * becomes a plain box if it paints anything, and disappears if it does not.
592
+ *
593
+ * When it disappears the diagnostic must NOT name it: `applyDrops` deletes every
594
+ * entry naming a node that no longer exists, so an entry naming this one would
595
+ * take the only record of the block with it. The element's DOM path goes in the
596
+ * message instead, which is what someone reading the report needs anyway.
597
+ */
598
+ function demoteTextNode (st, id, node, why, code = 'collect.text-empty') {
599
+ const paints = (node.fills && node.fills.length) || (node.strokes && node.strokes.length) ||
600
+ (node.effects && node.effects.length) || node.clip || node.transform ||
601
+ (node.children && node.children.length)
602
+ const where = (node.source && node.source.path) || node.name || id
603
+ if (!paints && id !== st.doc.root) {
604
+ st.dropped.add(id)
605
+ report(st, null, code, 'E', `${where}: ${why}; the text node was pruned.`,
606
+ code === 'collect.text-empty' ? 'info' : 'warn')
607
+ return
608
+ }
609
+ node.type = node.fills && node.fills.length ? 'frame' : 'group'
610
+ node.text = null
611
+ report(st, id, code, 'O', `${why}; the node keeps its box and loses its text.`)
612
+ }
613
+
614
+ function collectTextInto (st, id, node, cs) {
615
+ let out = null
616
+ try {
617
+ out = collectText(node.el, st.ctx)
618
+ } catch (error) {
619
+ // The reason travels INSIDE the demotion, not alongside it: a separate entry
620
+ // naming this node is deleted with the node on the pruning path, and a text
621
+ // block that vanished because a collector threw is the last thing that may
622
+ // go unrecorded.
623
+ demoteTextNode(st, id, node,
624
+ `the text of this block could not be measured — collectText threw: ${error && error.message}`,
625
+ 'collect.text-failed')
626
+ return
627
+ }
628
+ if (!out || !out.characters) {
629
+ demoteTextNode(st, id, node, 'no painted characters were found in this block')
630
+ return
631
+ }
632
+
633
+ const styleIds = {}
634
+ const styleWarnings = []
635
+ for (const local of Object.keys(out.styles || {})) {
636
+ styleIds[local] = registerTextStyle(st, out.styles[local], styleWarnings)
637
+ }
638
+ for (const w of styleWarnings) warn(st, id, 'text', w)
639
+ const runs = []
640
+ for (const run of out.runs || []) {
641
+ const styleId = styleIds[run.style]
642
+ if (!styleId) continue
643
+ // `dy` is the measured offset of a raised/lowered run from its line's
644
+ // baseline (sup/sub, `vertical-align`). It is a MEASUREMENT, not a hint: the
645
+ // fold that put the run on this line already removed it from the baseline,
646
+ // so dropping it here is the difference between H₂O and H2O.
647
+ runs.push({
648
+ start: run.start,
649
+ end: run.end,
650
+ style: styleId,
651
+ href: run.href || null,
652
+ dy: Number.isFinite(run.dy) ? run.dy : 0,
653
+ })
654
+ }
655
+ if (!runs.length) {
656
+ demoteTextNode(st, id, node, 'the block has characters but no styled runs')
657
+ return
658
+ }
659
+
660
+ // The schema requires a positive pitch: a backend that steps by it would stack
661
+ // every line on one baseline otherwise. A block with a single visual line has
662
+ // no baseline difference to measure, and `collectText` already says so in its
663
+ // own warnings — this only covers the case where even the fallback was zero.
664
+ let pitch = out.pitch
665
+ if (!(pitch > 0)) {
666
+ const first = out.lines && out.lines[0]
667
+ const box = first ? (first.asc || 0) + (first.desc || 0) : 0
668
+ pitch = box > 0 ? box : (st.doc.styles.text[runs[0].style].size || 1)
669
+ warn(st, id, 'text',
670
+ `the measured baseline pitch was ${out.pitch}; it was replaced by ${pitch.toFixed(2)}px ` +
671
+ 'taken from the line box, which is not a measurement of the line spacing.')
672
+ }
673
+
674
+ const style = st.doc.styles.text[runs[0].style]
675
+ node.type = 'text'
676
+ node.text = {
677
+ characters: out.characters,
678
+ sourceText: out.sourceText,
679
+ lines: out.lines,
680
+ runs,
681
+ pitch,
682
+ align: out.align,
683
+ direction: out.direction,
684
+ letterSpacing: style.letterSpacing,
685
+ wordSpacing: style.wordSpacing,
686
+ // Visual lines are joined with LSEP, so the block is ONE paragraph by
687
+ // construction and nothing downstream may add paragraph spacing to it.
688
+ paragraphSpacing: 0,
689
+ }
690
+ for (const message of out.warnings || []) warn(st, id, 'text', message)
691
+
692
+ // `text-shadow` paints the glyphs, and this node IS the glyphs — but nothing
693
+ // on the text path collects it and `node.effects` would follow the box
694
+ // silhouette even if it did. `collect/box.js` covers the elements that also
695
+ // carry box paint (it hands the parsed shadows over on `textEffects`, which
696
+ // `applyBox` still drops) and says in as many words that the block whose only
697
+ // content is text "never reaches collectBox" — that half is here. Without
698
+ // this, a shadowed heading exports with no effect and no entry at all: the
699
+ // export looks clean and is wrong, which is the one outcome this format exists
700
+ // to prevent.
701
+ const shadow = cs && cs.textShadow
702
+ if (shadow && shadow !== 'none') {
703
+ report(st, id, 'collect.text-shadow-dropped', 'O',
704
+ `text-shadow "${shadow}" paints these glyphs and is not emitted: no backend here carries a ` +
705
+ 'shadow on text, so the letters export flat.')
706
+ }
707
+ }
708
+
709
+ // ——— replaced content ———
710
+
711
+ /**
712
+ * The image paint goes ON TOP of whatever `collectBox` found: in CSS the
713
+ * replaced content paints above the element's own backgrounds and below its
714
+ * borders, and `fills` runs back to front.
715
+ */
716
+ function applyImage (st, id, node, result) {
717
+ const { asset, fill, warnings } = result
718
+ st.doc.assets[asset.id] = asset
719
+ node.fills = [...(node.fills || []), fill]
720
+ if (asset.kind === 'vector' && node.type === 'image') node.type = 'vector'
721
+
722
+ // A vector asset carries `markup`, never `data` — the whole point of the SVG
723
+ // path is that it is NOT bytes. The image fill can only ever be an `<image>`
724
+ // pointing at a data URI, so leaving it in `fills` bought one
725
+ // `emit.svg.image-no-data` per icon and painted nothing at all. `node.svg` is
726
+ // the field both emitters already read (`emit/svg-flat.js` `emitVector`,
727
+ // `emit/figma-json.js:1270`); nothing ever wrote it, which is why every inline
728
+ // SVG in `demo/challenges.html#fx-svg` came out as an empty group.
729
+ //
730
+ // The placement travels with it because the markup is in the asset's own
731
+ // natural units and the fill is what knows where those land: `crop` is the
732
+ // source-pixel window `object-fit`/`object-position` selected and `transform`
733
+ // maps the unit square of that crop onto the node's box. For an inline `<svg>`
734
+ // that reduces to a translate; for `<img src=*.svg>` it is what makes
735
+ // `object-fit: cover` crop instead of stretch.
736
+ if (asset.kind === 'vector' && typeof asset.markup === 'string' && asset.markup) {
737
+ node.svg = asset.markup
738
+ node.svgPlacement = { crop: fill.crop, transform: fill.transform, clipBox: fill.clipBox }
739
+ // The namespace the collector minted travels with the markup: one asset can
740
+ // reach two nodes (assets are cached by URL, so one icon used twice is one
741
+ // asset used twice), and the emitter has to rename the second copy's ids.
742
+ if (typeof asset.idPrefix === 'string' && asset.idPrefix) node.svgPrefix = asset.idPrefix
743
+ node.fills = node.fills.filter((f) => f !== fill)
744
+ }
745
+
746
+ // MathML's SVG contains native text without SVD text nodes. Register its
747
+ // painted font uses so they share the ordinary font embedding/diagnostics
748
+ // pass, including unicode-range checks. These references stay in the
749
+ // assembly state; the resulting font assets are all the emitter needs.
750
+ for (const style of result.fontStyles || []) {
751
+ const sink = []
752
+ const sid = registerTextStyle(st, style, sink)
753
+ st.markupTextUses.push({ nodeId: id, style: sid, characters: style.characters || '' })
754
+ for (const warning of sink) warn(st, id, 'text', warning)
755
+ }
756
+
757
+ for (const message of warnings || []) warn(st, id, 'image', message)
758
+ foldWarnings(st, id, 'image', [fill])
759
+
760
+ // The grade travels on the fill, and some of the paths that set it emit no
761
+ // warning at all (a clean canvas readback is grade R and says nothing). An
762
+ // undeclared grade is exactly what the schema rejects, so it is declared here.
763
+ if (fill.grade && fill.grade !== 'E') {
764
+ const notes = (fill.notes || []).join(', ')
765
+ report(st, id, 'collect.image-grade', fill.grade,
766
+ `replaced content graded ${fill.grade}${notes ? ` (${notes})` : ''}.`)
767
+ }
768
+ }
769
+
770
+ // ——— background images ———
771
+
772
+ function bytesToBase64 (bytes) {
773
+ let binary = ''
774
+ const CHUNK = 0x8000
775
+ for (let i = 0; i < bytes.length; i += CHUNK) {
776
+ binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK))
777
+ }
778
+ return btoa(binary)
779
+ }
780
+
781
+ function absoluteUrl (url) {
782
+ try {
783
+ return new URL(url, typeof location !== 'undefined' ? location.href : undefined).href
784
+ } catch {
785
+ return url
786
+ }
787
+ }
788
+
789
+ function mimeFromUrl (url) {
790
+ const m = /\.([a-z0-9]+)(?:$|[?#])/i.exec(String(url).split(/[?#]/)[0])
791
+ return (m && EXT_MIME[m[1].toLowerCase()]) || ''
792
+ }
793
+
794
+ async function fetchBytes (url, timeout) {
795
+ const controller = typeof AbortController === 'function' ? new AbortController() : null
796
+ const timer = controller && timeout > 0 ? setTimeout(() => controller.abort(), timeout) : null
797
+ try {
798
+ // force-cache: the page already downloaded this image, so the HTTP cache
799
+ // should answer without a second trip to the network.
800
+ const res = await fetch(url, {
801
+ signal: controller ? controller.signal : undefined,
802
+ cache: 'force-cache',
803
+ })
804
+ if (!res.ok) return null
805
+ const bytes = new Uint8Array(await res.arrayBuffer())
806
+ const declared = (res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase()
807
+ const mime = declared && declared !== 'application/octet-stream'
808
+ ? declared
809
+ : (mimeFromUrl(url) || 'application/octet-stream')
810
+ return { bytes, mime }
811
+ } catch {
812
+ return null
813
+ } finally {
814
+ if (timer) clearTimeout(timer)
815
+ }
816
+ }
817
+
818
+ /**
819
+ * Intrinsic size of an image source. It is loaded rather than fetched because
820
+ * `naturalWidth/Height` are the only numbers that agree with what CSS sized the
821
+ * layer against, and a cross-origin image still reports them — only its PIXELS
822
+ * are unreadable.
823
+ */
824
+ function intrinsicSize (src) {
825
+ return new Promise((resolve) => {
826
+ if (typeof Image !== 'function' || !src) {
827
+ resolve(null)
828
+ return
829
+ }
830
+ const img = new Image()
831
+ let settled = false
832
+ const done = (value) => {
833
+ if (settled) return
834
+ settled = true
835
+ resolve(value)
836
+ }
837
+ img.onload = () => done({ w: img.naturalWidth || 0, h: img.naturalHeight || 0 })
838
+ img.onerror = () => done(null)
839
+ img.src = src
840
+ if (img.complete && img.naturalWidth) done({ w: img.naturalWidth, h: img.naturalHeight })
841
+ })
842
+ }
843
+
844
+ /**
845
+ * One `background-image: url(...)` layer as an embedded asset.
846
+ *
847
+ * Invariant 7 says nothing may be external, so the bytes are fetched and
848
+ * inlined. When they are unreachable — a cross-origin CDN with no CORS header is
849
+ * the common case — the asset keeps its URL, carries no data, and says so: an
850
+ * export that silently references a URL nobody downstream can read is exactly
851
+ * the failure this format exists to make visible.
852
+ *
853
+ * **An SVG is collected as markup, not as bytes.** `collectImage` has always
854
+ * done that for `<img src="*.svg">`; a background layer took the other road and
855
+ * became `<image href="data:image/svg+xml…">`, which Figma draws as nothing at
856
+ * all (FIGMA_FINDINGS §6). Same rule, both roads, one asset shape: `kind:
857
+ * 'vector'` with `markup` and `idPrefix`.
858
+ *
859
+ * @returns {Promise<{asset:object, warnings:object[], svgWarnings:string[],
860
+ * intrinsic:{w:number,h:number}|null}>} `svgWarnings` is bare prose in
861
+ * `collectImage`'s own voice, so the caller classifies it the way the same
862
+ * sentence is classified when an `<img>` produces it.
863
+ */
864
+ async function loadBackgroundAsset (url, st) {
865
+ const ctx = st.ctx
866
+ const id = nextAssetId(ctx, seedFromUrl(url, 'bg'))
867
+ const warnings = []
868
+ const svgWarnings = []
869
+ const fetched = await fetchBytes(url, ctx.fetchTimeout)
870
+
871
+ const mime = fetched ? fetched.mime : (mimeFromUrl(url) || 'application/octet-stream')
872
+ let data = null
873
+ if (fetched) {
874
+ if (fetched.bytes.length > ctx.maxAssetBytes) {
875
+ warnings.push({
876
+ grade: 'E',
877
+ message: `background-image ${url} is ${(fetched.bytes.length / 1048576).toFixed(1)} MB and is embedded verbatim.`,
878
+ })
879
+ }
880
+ data = `data:${mime};base64,${bytesToBase64(fetched.bytes)}`
881
+ } else {
882
+ warnings.push({
883
+ grade: 'O',
884
+ message: `background-image ${url}: the bytes could not be fetched (CORS or network); the asset is referenced by URL with no data.`,
885
+ })
886
+ }
887
+
888
+ // The data URI is same-origin and cannot taint anything, so it is the better
889
+ // source to measure; the original URL is the fallback when there is no data.
890
+ const size = (await intrinsicSize(data)) || (await intrinsicSize(url))
891
+ if (!size || !(size.w > 0) || !(size.h > 0)) {
892
+ warnings.push({
893
+ grade: 'A',
894
+ message: `background-image ${url}: its intrinsic size could not be read, so the CSS sizing ` +
895
+ 'could not be resolved against it; the layer is sized to the positioning area instead.',
896
+ })
897
+ }
898
+
899
+ const intrinsic = size && size.w > 0 && size.h > 0 ? size : null
900
+
901
+ if (fetched && looksLikeSvg(fetched.bytes, url, mime)) {
902
+ const vector = await svgBackgroundAsset(fetched.bytes, url, id, ctx, intrinsic, svgWarnings, ctx.fetchTimeout)
903
+ if (vector) {
904
+ // `origin` and the URL stay on the asset: the markup is the payload, but a
905
+ // reader still has to be able to say where it came from.
906
+ return { asset: { ...vector.asset, origin: 'original-bytes' }, warnings, svgWarnings, intrinsic }
907
+ }
908
+ warnings.push({
909
+ grade: 'A',
910
+ message: `background-image ${url} looks like an SVG but did not parse, so it stays raster bytes inside ` +
911
+ 'an <image>. A target that does not rasterize an SVG in an <image> (Figma, measured) draws nothing ' +
912
+ 'for this layer.',
913
+ })
914
+ }
915
+
916
+ const asset = {
917
+ id,
918
+ kind: mime === 'image/svg+xml' ? 'vector' : 'bitmap',
919
+ mime,
920
+ w: size ? size.w : 0,
921
+ h: size ? size.h : 0,
922
+ src: url,
923
+ origin: data ? 'original-bytes' : 'unresolved',
924
+ data,
925
+ }
926
+ return { asset, warnings, svgWarnings, intrinsic }
927
+ }
928
+
929
+ /**
930
+ * `collectBox` cannot place a `url()` layer: the intrinsic size is unknown until
931
+ * the file loads, so it emits `asset: {pending:true, url}` and `rect: null` and
932
+ * leaves both to us. One fetch per distinct URL, all of them in flight at once.
933
+ */
934
+ async function resolveBackgroundAssets (st) {
935
+ const pending = new Map()
936
+ for (const id of Object.keys(st.doc.nodes)) {
937
+ if (st.dropped.has(id)) continue
938
+ for (const fill of st.doc.nodes[id].fills || []) {
939
+ if (!fill || fill.type !== 'image') continue
940
+ if (!fill.asset || typeof fill.asset !== 'object' || !fill.asset.pending) continue
941
+ const url = absoluteUrl(fill.asset.url)
942
+ if (!pending.has(url)) pending.set(url, [])
943
+ pending.get(url).push({ id, fill })
944
+ }
945
+ }
946
+ if (!pending.size) return
947
+
948
+ await Promise.all([...pending].map(async ([url, uses]) => {
949
+ let loaded
950
+ try {
951
+ loaded = await loadBackgroundAsset(url, st)
952
+ } catch (error) {
953
+ for (const use of uses) {
954
+ report(st, use.id, 'assets.background-failed', 'O',
955
+ `background-image ${url} could not be loaded: ${error && error.message}; the layer is dropped.`)
956
+ const node = st.doc.nodes[use.id]
957
+ node.fills = node.fills.filter((f) => f !== use.fill)
958
+ }
959
+ return
960
+ }
961
+ st.doc.assets[loaded.asset.id] = loaded.asset
962
+ for (const use of uses) {
963
+ use.fill.asset = loaded.asset.id
964
+ if (!use.fill.rect && use.fill.placement) {
965
+ use.fill.rect = resolveLayerRect(use.fill.placement, loaded.intrinsic)
966
+ }
967
+ for (const w of loaded.warnings) report(st, use.id, 'assets.background', w.grade, w.message)
968
+ // The SVG path speaks `collectImage`'s prose, and `warn` grades a sentence
969
+ // the same way whether an <img> or a background layer produced it — the
970
+ // alternative is one message with two grades depending on where it came
971
+ // from, which is how a diagnostic stops meaning anything.
972
+ for (const w of loaded.svgWarnings || []) warn(st, use.id, 'image', w)
973
+ }
974
+ }))
975
+ }
976
+
977
+ // ——— filters the destination will not run: the opt-in PNG ———
978
+
979
+ /**
980
+ * Device px per CSS px a filter raster is drawn at, and the largest side it may
981
+ * reach. 2x is what a design tool's canvas wants; the cap is what stops a
982
+ * full-page backdrop copy (fx-glass's frost is a copy of the whole capture root)
983
+ * from becoming a 4000px PNG for a 130px panel.
984
+ */
985
+ const FILTER_RASTER_SCALE = 2
986
+ const FILTER_RASTER_MAX_PX = 2048
987
+
988
+ /**
989
+ * Bytes past which the 2x pass is thrown away and repainted at 1x — the same
990
+ * shape, and the same number, as `TILE_RASTER_BYTES` in the emitter, and for the
991
+ * same reason: density is chosen against the FILE, not assumed. Measured on
992
+ * fx-glass, whose frost node is a copy of the whole capture root seen through a
993
+ * 416×130 window: 2x is 230 kB of PNG, which the emitter writes twice (`href` and
994
+ * `xlink:href`) and so 460 kB of a 14 kB document. 1x is a quarter of that on a
995
+ * layer that is a 14px blur to begin with and has no detail to lose.
996
+ */
997
+ const FILTER_RASTER_BYTES = 128 * 1024
998
+
999
+ /** Attributes the raster pass stamps on the mounted clone, and removes again. */
1000
+ const RASTER_OFF = 'data-snapdom-vector-raster-off'
1001
+ const RASTER_ON = 'data-snapdom-vector-raster-on'
1002
+ const RASTER_UP = 'data-snapdom-vector-raster-up'
1003
+ const RASTER_ROOT = 'data-snapdom-vector-raster-root'
1004
+
1005
+ /**
1006
+ * The isolation, and it is the whole trick.
1007
+ *
1008
+ * The raster has to be of ONE node's subtree, because it replaces one node's
1009
+ * subtree in the SVG — a crop of the mounted clone would carry whatever sits
1010
+ * behind it and paint it twice. Detaching the subtree and rendering it alone is
1011
+ * what snapdom does for a whole capture and it does not survive here: half these
1012
+ * nodes are `<th>`s, and a table cell outside its table lays out as something
1013
+ * else entirely. So the tree stays whole and everything except the target is
1014
+ * made INVISIBLE — `visibility` is the one property that hides an element's paint
1015
+ * while keeping every box where it was, and it is inheritable, so one rule on the
1016
+ * root and one on the target cover the whole document.
1017
+ *
1018
+ * `!important` is not decoration: snapdom's own per-node classes carry
1019
+ * `visibility: visible` for every element in the clone, and an ordinary rule
1020
+ * would lose to them.
1021
+ *
1022
+ * The ancestors are neutralised rather than refused. Everything above the target
1023
+ * — its transform, its opacity, its blend mode, its own filter — is applied AGAIN
1024
+ * by the SVG groups the `<image>` will sit inside, so leaving it in the pixels
1025
+ * would double it. Transform in particular has to go: `abs`, the coordinate this
1026
+ * whole placement is computed in, is defined as the border box with the transform
1027
+ * chain neutralised (`paint.js`), so a raster taken through that chain would not
1028
+ * land where the document says the node is. The target keeps its own filter (it
1029
+ * is the point) and loses its own transform (the SVG group still applies it).
1030
+ */
1031
+ const RASTER_CSS =
1032
+ `[${RASTER_OFF}],[${RASTER_OFF}] *{visibility:hidden!important}` +
1033
+ `[${RASTER_ON}],[${RASTER_ON}] *{visibility:visible!important}` +
1034
+ `[${RASTER_UP}]{transform:none!important;opacity:1!important;mix-blend-mode:normal!important;` +
1035
+ 'filter:none!important;backdrop-filter:none!important;-webkit-backdrop-filter:none!important}' +
1036
+ `[${RASTER_ON}]{transform:none!important}` +
1037
+ `[${RASTER_ROOT}]{margin:0!important}` +
1038
+ 'svg{overflow:visible}foreignObject{overflow:visible}'
1039
+
1040
+ /** Rect helpers, local to this pass: everything here is an axis-aligned box in `abs` px. */
1041
+ const rectIntersect = (a, b) => {
1042
+ const x = Math.max(a.x, b.x)
1043
+ const y = Math.max(a.y, b.y)
1044
+ const w = Math.min(a.x + a.w, b.x + b.w) - x
1045
+ const h = Math.min(a.y + a.h, b.y + b.h) - y
1046
+ return { x, y, w, h }
1047
+ }
1048
+
1049
+ /**
1050
+ * The ancestors of `id`, nearest first, as node ids. Built from `children`
1051
+ * because the schema has no parent pointer and inventing one on the document
1052
+ * would be a field every consumer then has to be told to ignore.
1053
+ */
1054
+ function ancestorsOf (doc, id, parents) {
1055
+ const chain = []
1056
+ for (let cur = parents.get(id); cur; cur = parents.get(cur)) {
1057
+ if (chain.includes(cur)) break
1058
+ chain.push(cur)
1059
+ }
1060
+ return chain.filter((cid) => isPlainObject(doc.nodes[cid]))
1061
+ }
1062
+
1063
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
1064
+
1065
+ /**
1066
+ * Shrink the raster box to what an ancestor's overflow clip will let through.
1067
+ *
1068
+ * Not an optimisation with a rounding error in it: the `<image>` is emitted
1069
+ * INSIDE the same clip groups, so anything the clip removes is invisible in both
1070
+ * forms and costs only bytes. It costs a great many — fx-glass's frost node is a
1071
+ * copy of the entire 470×320 capture root showing through a 416×130 window, and
1072
+ * without this the PNG is the whole root with 88% of it transparent.
1073
+ *
1074
+ * @param {object} doc
1075
+ * @param {string} id the node being rasterized
1076
+ * @param {Map<string,string>} parents
1077
+ * @param {{x,y,w,h}} box in the node's LOCAL px
1078
+ * @returns {{x,y,w,h}} the same box, clipped
1079
+ */
1080
+ function clipToAncestors (doc, id, parents, box) {
1081
+ const node = doc.nodes[id]
1082
+ const origin = isPlainObject(node.abs) ? node.abs : { x: 0, y: 0 }
1083
+ let out = box
1084
+ for (const aid of ancestorsOf(doc, id, parents)) {
1085
+ const anc = doc.nodes[aid]
1086
+ const clip = isPlainObject(anc.clip) ? anc.clip : null
1087
+ if (!clip) continue
1088
+ const abs = isPlainObject(anc.abs) ? anc.abs : null
1089
+ const frame = isPlainObject(anc.frame) ? anc.frame : abs
1090
+ if (!abs || !frame) continue
1091
+ // The clip rect in the ancestor's own local px — `clip.rect` when it carries
1092
+ // one, its border box otherwise, which is what `emitClipShape` falls back to.
1093
+ const rect = isPlainObject(clip.rect)
1094
+ ? clip.rect
1095
+ : { x: 0, y: 0, w: Number(frame.w) || 0, h: Number(frame.h) || 0 }
1096
+ if (!(rect.w > 0) || !(rect.h > 0)) continue
1097
+ // Both boxes are transform-neutral, so `abs` deltas are the whole mapping.
1098
+ out = rectIntersect(out, {
1099
+ x: (abs.x - origin.x) + rect.x,
1100
+ y: (abs.y - origin.y) + rect.y,
1101
+ w: rect.w,
1102
+ h: rect.h,
1103
+ })
1104
+ if (!(out.w > 0) || !(out.h > 0)) return out
1105
+ }
1106
+ return out
1107
+ }
1108
+
1109
+ /**
1110
+ * Everything the mounted clone needs to render on its own, once per export.
1111
+ *
1112
+ * `mountClone` deliberately injects only the FRONT half of snapdom's CSS into the
1113
+ * page — the per-node style snapshots would freeze used widths over the page's
1114
+ * own cascade and re-wrap text — so the style tag it leaves in `<head>` is not
1115
+ * enough to render the clone anywhere else. Inside a `foreignObject` there is no
1116
+ * page cascade at all, which is exactly the case snapdom itself builds for, so
1117
+ * the raster uses snapdom's full set: the base reset that neutralises the UA
1118
+ * stylesheet, the embedded faces, and the `.cN` snapshots.
1119
+ */
1120
+ function rasterCss (handoff) {
1121
+ return `${handoff.baseCSS || ''}${handoff.fontsCSS || ''}${handoff.classCSS || ''}${RASTER_CSS}`
1122
+ }
1123
+
1124
+ /**
1125
+ * One node's subtree, painted through its own filter, as PNG bytes.
1126
+ *
1127
+ * The clone is COPIED rather than moved: it is still mounted and still being
1128
+ * measured by everything after this. What goes into the `foreignObject` is that
1129
+ * copy plus the capture's whole CSS, and the geometry is snapdom's own
1130
+ * (`core/capture.js`): the container's padding carries a positive offset and the
1131
+ * `foreignObject`'s x/y carries a negative one, because Chrome does not paint
1132
+ * `foreignObject` overflow when it rasterizes an SVG as an image.
1133
+ *
1134
+ * @param {Element} cloneRoot the mounted clone root
1135
+ * @param {Element} el the element to isolate, inside it
1136
+ * @param {Element[]} up `el` and its ancestors up to `cloneRoot`
1137
+ * @param {string} css
1138
+ * @param {{x,y,w,h}} box the raster window in `abs` px (root border box origin)
1139
+ * @returns {Promise<{data: string, w: number, h: number, scale: number}|null>}
1140
+ */
1141
+ async function paintFilterRaster (cloneRoot, el, up, css, box, want) {
1142
+ if (typeof document === 'undefined' || typeof Image !== 'function') return null
1143
+ const vbW = Math.max(1, Math.ceil(box.w))
1144
+ const vbH = Math.max(1, Math.ceil(box.h))
1145
+ const scale = Math.min(want, FILTER_RASTER_MAX_PX / Math.max(vbW, vbH))
1146
+ const outW = Math.max(1, Math.round(vbW * scale))
1147
+ const outH = Math.max(1, Math.round(vbH * scale))
1148
+
1149
+ const offX = -box.x
1150
+ const offY = -box.y
1151
+ const padL = Math.max(0, offX)
1152
+ const padT = Math.max(0, offY)
1153
+ const foX = Math.min(0, offX)
1154
+ const foY = Math.min(0, offY)
1155
+ const foW = vbW - foX
1156
+ const foH = vbH - foY
1157
+
1158
+ cloneRoot.setAttribute(RASTER_OFF, '')
1159
+ cloneRoot.setAttribute(RASTER_ROOT, '')
1160
+ for (const a of up) a.setAttribute(RASTER_UP, '')
1161
+ el.removeAttribute(RASTER_UP)
1162
+ el.setAttribute(RASTER_ON, '')
1163
+ let markup
1164
+ try {
1165
+ const svgNS = 'http://www.w3.org/2000/svg'
1166
+ const fo = document.createElementNS(svgNS, 'foreignObject')
1167
+ fo.setAttribute('x', String(foX))
1168
+ fo.setAttribute('y', String(foY))
1169
+ fo.setAttribute('width', String(foW))
1170
+ fo.setAttribute('height', String(foH))
1171
+ fo.style.overflow = 'visible'
1172
+ const style = document.createElement('style')
1173
+ style.textContent = css
1174
+ fo.appendChild(style)
1175
+ const container = document.createElement('div')
1176
+ container.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml')
1177
+ // The synthesized-node rules `mountClone` generates are scoped to the host
1178
+ // attribute, so the container has to BE the host or every invented node
1179
+ // (materialised pseudos, scroll wrappers) renders unstyled.
1180
+ container.setAttribute('data-snapdom-vector', 'clone-host')
1181
+ container.style.cssText =
1182
+ 'all:initial;box-sizing:border-box;display:block;overflow:visible;' +
1183
+ `width:${foW}px;height:${foH}px` +
1184
+ ((padL || padT) ? `;padding:${padT}px 0 0 ${padL}px !important` : '')
1185
+ container.appendChild(cloneRoot.cloneNode(true))
1186
+ fo.appendChild(container)
1187
+ markup = `<svg xmlns="${svgNS}" width="${outW}" height="${outH}" viewBox="0 0 ${vbW} ${vbH}">` +
1188
+ `${new XMLSerializer().serializeToString(fo)}</svg>`
1189
+ } finally {
1190
+ cloneRoot.removeAttribute(RASTER_OFF)
1191
+ cloneRoot.removeAttribute(RASTER_ROOT)
1192
+ el.removeAttribute(RASTER_ON)
1193
+ for (const a of up) a.removeAttribute(RASTER_UP)
1194
+ }
1195
+
1196
+ const img = new Image()
1197
+ img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(markup)}`
1198
+ await img.decode()
1199
+ const canvas = document.createElement('canvas')
1200
+ canvas.width = outW
1201
+ canvas.height = outH
1202
+ const c2d = canvas.getContext('2d')
1203
+ if (!c2d) return null
1204
+ c2d.drawImage(img, 0, 0, outW, outH)
1205
+ const data = canvas.toDataURL('image/png')
1206
+ if (typeof data !== 'string' || !data.startsWith('data:image/png')) return null
1207
+ return { data, w: outW, h: outH, scale }
1208
+ }
1209
+
1210
+ /**
1211
+ * `rasterFilters`, end to end.
1212
+ *
1213
+ * The emitter decides WHAT — `rasterFilterPlan` is the single predicate, so the
1214
+ * pass that paints and the pass that emits cannot hold two opinions about which
1215
+ * filters survive an import — and this decides whether a raster was possible at
1216
+ * all. A node it cannot paint keeps its vector form and its declaration; nothing
1217
+ * here silently leaves a node without either.
1218
+ *
1219
+ * It runs before the `el` back-references are dropped, which is the only window
1220
+ * in which the clone is both mounted and still attached to the document.
1221
+ */
1222
+ async function rasterizeUnrunFilters (st, capture, handoff) {
1223
+ const doc = st.doc
1224
+ const parents = new Map()
1225
+ for (const id of Object.keys(doc.nodes)) {
1226
+ for (const kid of doc.nodes[id].children || []) parents.set(kid, id)
1227
+ }
1228
+ const targets = []
1229
+ for (const id of Object.keys(doc.nodes)) {
1230
+ if (st.dropped.has(id)) continue
1231
+ const plan = rasterFilterPlan(doc.nodes[id], doc.nodes)
1232
+ if (plan) targets.push({ id, plan, depth: ancestorsOf(doc, id, parents).length })
1233
+ }
1234
+ if (!targets.length) return
1235
+ // Outermost first, so the ancestor-already-baked test below is decidable in one
1236
+ // pass and never depends on `Object.keys` happening to be in tree order.
1237
+ targets.sort((a, b) => a.depth - b.depth)
1238
+
1239
+ const css = rasterCss(handoff)
1240
+ const done = new Set()
1241
+ let rasterized = 0
1242
+
1243
+ for (const { id, plan } of targets) {
1244
+ const node = doc.nodes[id]
1245
+ const el = node.el
1246
+ const why = plan.why.join('; ')
1247
+ // An ancestor already baked this subtree — the emitter stops at the outermost
1248
+ // raster and never reaches this node, so a second PNG of the same pixels would
1249
+ // be bytes nothing draws and a `report.raster` count of nodes that are not
1250
+ // separately rastered. It keeps its declaration; it just does not get its own
1251
+ // bitmap.
1252
+ if (ancestorsOf(doc, id, parents).some((aid) => done.has(aid))) continue
1253
+ if (!el || el.nodeType !== 1 || !el.isConnected) {
1254
+ report(st, id, 'raster.filter-unavailable', 'A',
1255
+ `rasterFilters could not rasterize this node (${why}): it has no element in the mounted clone. ` +
1256
+ 'It keeps its vector form, and the filter is still declared as one the destination will not run.')
1257
+ continue
1258
+ }
1259
+ // The node's own transform is neutralised for the paint (the SVG group still
1260
+ // applies it), but a transform is only invertible for this if the emitter can
1261
+ // reproduce it — an unreadable one means the group applies nothing, and then
1262
+ // the raster and the vector disagree about where the node is.
1263
+ const readableTransform = node.transform === null || node.transform === undefined ||
1264
+ (isPlainObject(node.transform) && Array.isArray(node.transform.m) && node.transform.m.length === 6)
1265
+ if (!readableTransform) {
1266
+ report(st, id, 'raster.filter-unavailable', 'A',
1267
+ `rasterFilters could not rasterize this node (${why}): its transform is unreadable, so a raster ` +
1268
+ 'taken with the transform chain neutralised could not be put back. It keeps its vector form.')
1269
+ continue
1270
+ }
1271
+ const local = clipToAncestors(doc, id, parents, plan.box)
1272
+ if (!(local.w > 0) || !(local.h > 0)) {
1273
+ report(st, id, 'raster.filter-unavailable', 'E',
1274
+ `rasterFilters had nothing to rasterize for this node (${why}): every pixel its filter reaches is ` +
1275
+ 'outside an ancestor\'s overflow clip. Nothing is emitted for it either way.')
1276
+ continue
1277
+ }
1278
+ const abs = isPlainObject(node.abs) ? node.abs : { x: 0, y: 0 }
1279
+ const up = []
1280
+ for (let a = el; a && a !== capture.clone; a = a.parentElement) up.push(a)
1281
+ up.push(capture.clone)
1282
+
1283
+ const pane = { x: abs.x + local.x, y: abs.y + local.y, w: local.w, h: local.h }
1284
+ let painted = null
1285
+ let failure = ''
1286
+ try {
1287
+ painted = await paintFilterRaster(capture.clone, el, up, css, pane, FILTER_RASTER_SCALE)
1288
+ // Density decided by what the pass COSTS, which is not knowable until it
1289
+ // has run — the same rule `rasterTiles` follows in the emitter.
1290
+ if (painted && painted.scale > 1 && painted.data.length > FILTER_RASTER_BYTES) {
1291
+ painted = (await paintFilterRaster(capture.clone, el, up, css, pane, 1)) || painted
1292
+ }
1293
+ } catch (error) {
1294
+ failure = (error && error.message) || String(error)
1295
+ }
1296
+ if (!painted) {
1297
+ report(st, id, 'raster.filter-failed', 'A',
1298
+ `rasterFilters could not rasterize this node (${why})${failure ? `: ${failure}` : ''}. It keeps its ` +
1299
+ 'vector form, and the filter is still declared as one the destination will not run.')
1300
+ continue
1301
+ }
1302
+
1303
+ const assetId = nextAssetId(st.ctx, `fx_${id}`)
1304
+ doc.assets[assetId] = {
1305
+ id: assetId,
1306
+ kind: 'bitmap',
1307
+ mime: 'image/png',
1308
+ w: painted.w,
1309
+ h: painted.h,
1310
+ origin: 'rasterized',
1311
+ data: painted.data,
1312
+ }
1313
+ node.filterRaster = { asset: assetId, box: local, scale: painted.scale, why: plan.why }
1314
+ done.add(id)
1315
+ // The grade is on the node here as well as in the emitter's own diagnostic,
1316
+ // because a document handed to `svdToFigma` or to the PDF backend carries the
1317
+ // same loss and never goes through `svdToSvg` to hear about it.
1318
+ report(st, id, 'raster.filter', 'R',
1319
+ `this node and its whole subtree are one ${painted.w}×${painted.h} PNG at ` +
1320
+ `${Math.round(painted.scale * 100) / 100}x, because ${why}.`)
1321
+ if (!rasterized++) {
1322
+ report(st, null, 'raster.filter', 'R',
1323
+ 'rasterFilters is on, so every node whose filter the destination will not run — and only those — ' +
1324
+ 'was painted from the MOUNTED CLONE, where snapdom has already pre-composed any backdrop-filter, ' +
1325
+ 'over the filter\'s own region rather than the border box. Those subtrees are no longer editable ' +
1326
+ 'anywhere — no text, no fills, no children, one bitmap each. ' +
1327
+ 'AND IT DOES NOT ALWAYS LOOK BETTER: this trades a filter the destination drops for a bitmap it ' +
1328
+ 'keeps, which wins only where the vector form was already wrong. Measured on these fixtures: the ' +
1329
+ 'glass panel improved (its vector form was re-drawing by hand a backdrop-filter snapdom had ' +
1330
+ 'already composed, 8.97 -> 7.38 mean px), while five table headers got WORSE (3.00 -> 3.04 ' +
1331
+ 'overall, 8.93 -> 13.87 over the band that changed) because their inner-shadow chain already ' +
1332
+ 'painted exactly in a browser and rasterising only resampled their text. Judge it per node, not ' +
1333
+ 'per document. rasterFilters:false keeps the vector form and declares the loss instead. ' +
1334
+ 'ONLY the flat-SVG backend reads these rasters: svdToFigma and the PDF backend do not know the ' +
1335
+ 'field yet and emit these nodes\' vector paint, so a payload built through them carries the ' +
1336
+ 'unfiltered node and not the bitmap — the loss this option buys back is bought back in ' +
1337
+ 'toVectorSvg() and nowhere else.')
1338
+ }
1339
+ }
1340
+ }
1341
+
1342
+ // ——— the walk over emitted nodes ———
1343
+
1344
+ /**
1345
+ * One node, one collector, per its type. Returns a promise only when the node
1346
+ * carries replaced content, so the caller can await them all together instead of
1347
+ * serialising one fetch per image.
1348
+ */
1349
+ function collectNode (st, id, node) {
1350
+ const el = node.el
1351
+ if (!el) return null
1352
+
1353
+ let cs
1354
+ try {
1355
+ cs = st.ctx.styleOf(el)
1356
+ } catch (error) {
1357
+ report(st, id, 'collect.style-failed', 'O',
1358
+ `the computed style of this element is unreadable: ${error && error.message}`)
1359
+ return null
1360
+ }
1361
+
1362
+ // A text node's fills are the GLYPH fills in every backend, so it never takes
1363
+ // the box paint: `buildPaintTree` already split the block's box onto a parent
1364
+ // node wherever both exist.
1365
+ if (node.type === 'text') {
1366
+ collectTextInto(st, id, node, cs)
1367
+ return null
1368
+ }
1369
+
1370
+ try {
1371
+ applyBox(st, id, node, collectBox(el, cs, st.ctx))
1372
+ } catch (error) {
1373
+ report(st, id, 'collect.box-failed', 'O',
1374
+ `collectBox threw on this element: ${error && error.message}; the node is emitted with no paint.`)
1375
+ }
1376
+
1377
+ if (node.type !== 'image' && node.type !== 'vector') return null
1378
+
1379
+ const isMath = el.localName === 'math' && el.namespaceURI === 'http://www.w3.org/1998/Math/MathML'
1380
+ return (isMath ? collectMathML(el, cs, st.ctx) : collectImage(el, cs, st.ctx))
1381
+ .then((result) => {
1382
+ if (result) {
1383
+ applyImage(st, id, node, result)
1384
+ return
1385
+ }
1386
+ // `<iframe>`, `<embed>`, `<object>` and `<input type=image>` reach here:
1387
+ // the paint tree types them as replaced content and no collector in v0
1388
+ // knows how to read their contents.
1389
+ report(st, id, 'collect.replaced-unsupported', 'O',
1390
+ `<${node.source && node.source.tag}> is replaced content v0 cannot read; the box is emitted empty.`)
1391
+ })
1392
+ .catch((error) => {
1393
+ report(st, id, isMath ? 'collect.mathml-failed' : 'collect.image-failed', 'O',
1394
+ `${isMath ? 'collectMathML' : 'collectImage'} threw on this element: ${error && error.message}; the box is emitted empty.`)
1395
+ })
1396
+ }
1397
+
1398
+ // ——— assembly ———
1399
+
1400
+ /** Remove the nodes that collection pruned, and every reference to them. */
1401
+ function applyDrops (st) {
1402
+ if (!st.dropped.size) return
1403
+ const { nodes } = st.doc
1404
+ for (const id of st.dropped) delete nodes[id]
1405
+ for (const id of Object.keys(nodes)) {
1406
+ const node = nodes[id]
1407
+ if (Array.isArray(node.children) && node.children.some((c) => st.dropped.has(c))) {
1408
+ node.children = node.children.filter((c) => !st.dropped.has(c))
1409
+ }
1410
+ }
1411
+ // A diagnostic naming a node that no longer exists is a validation error, and
1412
+ // the node it described is gone, so the entry goes with it.
1413
+ st.doc.diagnostics = st.doc.diagnostics.filter((d) => !(d.node && st.dropped.has(d.node)))
1414
+ for (const d of st.doc.diagnostics) {
1415
+ for (const key of ['from', 'to']) if (d[key] && st.dropped.has(d[key])) delete d[key]
1416
+ if (Array.isArray(d.overlaps)) d.overlaps = d.overlaps.filter((n) => !st.dropped.has(n))
1417
+ }
1418
+ }
1419
+
1420
+ /**
1421
+ * A gradient that collapsed to a single stop is a flat colour, and the schema
1422
+ * requires two, so the stop is repeated at both ends — same paint, no loss.
1423
+ *
1424
+ * This function used to do a second job: rename every stop's `t` to `p`.
1425
+ * `css/gradient.js` has always emitted `t` and never emitted `p`, so `p` was a
1426
+ * spelling invented right here, in the one module that is supposed to forward
1427
+ * rather than translate. The contract now fixes the stop as `{t, color, srcCss}`
1428
+ * and `schema.js` validates `t`, so the rename is gone.
1429
+ *
1430
+ * Consumers read `stop.t`: `emit/svg-flat.js` (`readStops`), `emit/figma-json.js`
1431
+ * (`toPaint`) and `figma-plugin/code.js` (`gradientStops`).
1432
+ */
1433
+ /**
1434
+ * Turns each set of collected `mask-image` layers into the node the schema's
1435
+ * `mask` reference needs, and points the masked node at it.
1436
+ *
1437
+ * The mask node hangs off the node it masks. It has to hang off SOMETHING — the
1438
+ * schema rejects a document with a node the root cannot reach, and rightly: an
1439
+ * unreachable node is one a backend can silently never emit. Being a child is
1440
+ * also what `emit/svg-flat.js` already assumes, since it filters mask targets out
1441
+ * of the children it paints. Its `abs` is the masked node's own, so the two
1442
+ * spaces coincide and the layer rects — already node-local, the same ones `fills`
1443
+ * use — need no translation at all.
1444
+ */
1445
+ function wireMasks (st) {
1446
+ if (!st.masks.length) return
1447
+ for (const { id, mask } of st.masks) {
1448
+ const node = st.doc.nodes[id]
1449
+ if (!node || node.mask) continue
1450
+ let maskId = `${id}__mask`
1451
+ for (let n = 2; st.doc.nodes[maskId]; n++) maskId = `${id}__mask${n}`
1452
+ const frame = node.frame || node.abs
1453
+ st.doc.nodes[maskId] = {
1454
+ type: 'frame',
1455
+ name: `${node.name || id} mask`,
1456
+ // The mask paints in the masked node's OWN space: same origin, same size,
1457
+ // and `frame.x/y` are zero because `emitMask` has already translated by
1458
+ // the difference of the two `abs` boxes (here, zero).
1459
+ frame: { x: 0, y: 0, w: frame.w, h: frame.h },
1460
+ abs: { ...node.abs },
1461
+ paint: { z: 0, stackingContext: false },
1462
+ opacity: 1,
1463
+ radii: mask.radii || null,
1464
+ fills: mask.layers,
1465
+ strokes: [],
1466
+ effects: [],
1467
+ children: [],
1468
+ fidelity: { grade: 'E', notes: [] },
1469
+ }
1470
+ node.children = [...(node.children || []), maskId]
1471
+ node.mask = { node: maskId, type: mask.type === 'luminance' ? 'luminance' : 'alpha' }
1472
+ report(st, id, 'collect.box.mask-applied', 'A',
1473
+ `mask-image "${mask.srcCss}" is applied: its ${mask.layers.length} layer(s) are painted into node ` +
1474
+ `"${maskId}" and referenced as ${mask.type === 'luminance' ? 'a luminance' : 'an alpha'} mask over the ` +
1475
+ `${mask.clipBox} box. It is not exact — mask-composite is not modelled and the layers stack as "add", ` +
1476
+ 'and a backend that cannot express a mask by reference (Figma flags a sibling instead) still has to ' +
1477
+ 'build it. What is no longer true is that the box comes out rectangular.', 'info')
1478
+ }
1479
+ }
1480
+
1481
+ function padGradients (st) {
1482
+ let padded = 0
1483
+
1484
+ const fix = (paint) => {
1485
+ if (!paint || !Array.isArray(paint.stops) || paint.stops.length !== 1) return
1486
+ const only = paint.stops[0]
1487
+ paint.stops = [{ ...only, t: 0 }, { ...only, t: 1 }]
1488
+ padded++
1489
+ }
1490
+
1491
+ for (const id of Object.keys(st.doc.nodes)) {
1492
+ const node = st.doc.nodes[id]
1493
+ for (const fill of node.fills || []) fix(fill)
1494
+ for (const stroke of node.strokes || []) fix(stroke && stroke.paint)
1495
+ }
1496
+ for (const id of Object.keys(st.doc.styles.text)) {
1497
+ for (const fill of st.doc.styles.text[id].fills || []) fix(fill)
1498
+ }
1499
+
1500
+ if (padded) {
1501
+ st.doc.diagnostics.push({
1502
+ code: 'schema.gradient-stops-padded',
1503
+ severity: 'info',
1504
+ message: `${padded} gradient(s) had a single stop and it was repeated at both ends so the ` +
1505
+ 'ramp is a flat colour rather than an invalid document. No colour or position was changed.',
1506
+ })
1507
+ }
1508
+ }
1509
+
1510
+ // ——— fonts ———
1511
+
1512
+ /** Families CSS resolves itself; none of them can come from an `@font-face`. */
1513
+ const GENERIC_FAMILIES = new Set([
1514
+ 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui', 'math',
1515
+ 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded', 'emoji', 'fangsong',
1516
+ '-apple-system', 'blinkmacsystemfont',
1517
+ ])
1518
+
1519
+ const unquote = (v) => String(v == null ? '' : v).trim().replace(/^["']|["']$/g, '')
1520
+
1521
+ /**
1522
+ * The families this document's own page defines with `@font-face`.
1523
+ *
1524
+ * `document.fonts` is set-like and holds a CSS-connected `FontFace` for every
1525
+ * `@font-face` rule the page parsed, so membership answers exactly the question
1526
+ * that matters: is this family something the machine opening the export is
1527
+ * likely to have, or something the page shipped?
1528
+ *
1529
+ * Returns `null` when the API is not there — "I could not check" and "there is
1530
+ * nothing to declare" are different answers and must not share a return value.
1531
+ */
1532
+ function webfontFamilies () {
1533
+ const set = typeof document !== 'undefined' ? document.fonts : null
1534
+ if (!set || typeof set.forEach !== 'function') return null
1535
+ const out = new Set()
1536
+ try {
1537
+ set.forEach((face) => {
1538
+ const name = unquote(face && face.family).toLowerCase()
1539
+ if (name && !GENERIC_FAMILIES.has(name)) out.add(name)
1540
+ })
1541
+ } catch {
1542
+ return null
1543
+ }
1544
+ return out
1545
+ }
1546
+
1547
+ /**
1548
+ * What is STILL not embedded after `collect/font.js` ran, declared per family
1549
+ * and naming every node that uses it.
1550
+ *
1551
+ * The collector writes `style.font` for every `@font-face` face it could fetch
1552
+ * and cover, and each failure mode it hits is its own `assets.*` diagnostic —
1553
+ * so what reaches this function is what has no bytes to bring (a face whose
1554
+ * rule vanished, an uncovered subset, a failed fetch, or `embedFonts: false`).
1555
+ * For those the export carries the family NAME and gets whatever the viewer has
1556
+ * installed, and the loss compounds: their lines carry no `textLength`
1557
+ * (`measuredFontTravels` refuses to pin a substituted face), so a substitute
1558
+ * sets its own advances and the line widths are the viewer's.
1559
+ */
1560
+ function declareFonts (st) {
1561
+ const { nodes, styles, diagnostics } = st.doc
1562
+ const styleIds = Object.keys(styles.text)
1563
+ if (!styleIds.length) return
1564
+
1565
+ const faces = webfontFamilies()
1566
+ if (faces === null) {
1567
+ diagnostics.push({
1568
+ code: 'assets.font-embedding-unknown',
1569
+ severity: 'warn',
1570
+ message: 'this engine exposes no document.fonts, so whether the families in use come from ' +
1571
+ '@font-face could not be determined and none was embedded: the export names families ' +
1572
+ 'and the viewer substitutes what it has.',
1573
+ })
1574
+ return
1575
+ }
1576
+ if (!faces.size) return
1577
+
1578
+ // style id -> family, for the ones that are page-supplied. `cssFamily` is what
1579
+ // the `@font-face` rule calls this face and `family` may now be the name its
1580
+ // BINARY claims — `"Inter var"` against `Inter` — so the membership test is on
1581
+ // the first and the message names the second. Matching only on `family` stopped
1582
+ // recognising every renamed webfont, which is the exact case this exists for.
1583
+ const familyOf = new Map()
1584
+ for (const sid of styleIds) {
1585
+ const style = styles.text[sid]
1586
+ // A style whose face travels in `assets` is the solved case, not this one.
1587
+ if (typeof style.font === 'string' && style.font) continue
1588
+ const declared = unquote(style.cssFamily || style.family)
1589
+ if (declared && faces.has(declared.toLowerCase())) familyOf.set(sid, unquote(style.family) || declared)
1590
+ }
1591
+ if (!familyOf.size) return
1592
+
1593
+ const used = new Map()
1594
+ for (const id of Object.keys(nodes)) {
1595
+ const node = nodes[id]
1596
+ if (node.type !== 'text' || !node.text || !Array.isArray(node.text.runs)) continue
1597
+ for (const run of node.text.runs) {
1598
+ const family = familyOf.get(run.style)
1599
+ if (!family) continue
1600
+ if (!used.has(family)) used.set(family, new Set())
1601
+ used.get(family).add(id)
1602
+ }
1603
+ }
1604
+ for (const use of st.markupTextUses) {
1605
+ const family = familyOf.get(use.style)
1606
+ if (!family) continue
1607
+ if (!used.has(family)) used.set(family, new Set())
1608
+ used.get(family).add(use.nodeId)
1609
+ }
1610
+
1611
+ for (const [family, ids] of used) {
1612
+ const list = [...ids]
1613
+ diagnostics.push({
1614
+ code: 'assets.font-not-embedded',
1615
+ severity: 'warn',
1616
+ family,
1617
+ nodes: list,
1618
+ message: `"${family}" is served by an @font-face rule on this page and its bytes did not make ` +
1619
+ 'it into the document (the assets.* diagnostics above say why, or embedFonts is off), so the ' +
1620
+ 'export carries the family NAME and nothing else. On a machine without it the viewer ' +
1621
+ 'substitutes another face with its own advances, and no line of it carries textLength ' +
1622
+ '(justified lines excepted — see emit.svg.text-justified-length): pinning a substitute to ' +
1623
+ 'widths another font measured squeezes the glyphs.',
1624
+ })
1625
+ for (const id of list) {
1626
+ degradeNode(nodes[id], 'A', `the font "${family}" is referenced by name and not embedded`)
1627
+ }
1628
+ }
1629
+ }
1630
+
1631
+ // ——— tracking, and what the destination will do with it ———
1632
+
1633
+ /** A font shorthand no caller passes, so a refused assignment is detectable. */
1634
+ const FONT_SENTINEL = '1px serif'
1635
+
1636
+ /**
1637
+ * The advance of `text` in one font, or null when the context refused the
1638
+ * shorthand — which it does silently, keeping the font it had, so every width
1639
+ * measured after one would describe a font nobody asked for.
1640
+ */
1641
+ function measureWidth (ctx, css, text) {
1642
+ const c = ctx.measureCtx
1643
+ if (!c || !text) return null
1644
+ c.font = FONT_SENTINEL
1645
+ c.font = css
1646
+ if (c.font === FONT_SENTINEL) return null
1647
+ const w = c.measureText(text).width
1648
+ return Number.isFinite(w) ? w : null
1649
+ }
1650
+
1651
+ /** @param {string} familyCss a family list, already CSS-ready — quoted if it needs to be */
1652
+ function fontShorthand (style, familyCss) {
1653
+ const italic = style.italic ? 'italic ' : ''
1654
+ const weight = Number.isFinite(style.weight) ? Math.round(style.weight) : 400
1655
+ const size = Number.isFinite(style.size) && style.size > 0 ? style.size : 16
1656
+ return `${italic}${weight} ${size}px ${familyCss}`
1657
+ }
1658
+
1659
+ let segmenter
1660
+ /**
1661
+ * Typographic character units, which is what `letter-spacing` is added after —
1662
+ * NOT UTF-16 code units, and not code points either.
1663
+ *
1664
+ * MEASURED in Chromium (a span with and without `letter-spacing: 10px`, width
1665
+ * difference over 10): `🎯🌋👩🏽‍🚀🇪🇸🧑‍🚒` takes the spacing **5** times and is 11 code
1666
+ * points and 20 UTF-16 units; `áé👩🏽‍🚀x` takes it 4 times against 7 and 10. Counting
1667
+ * `string.length` would report four times the tracking on an emoji line — more
1668
+ * error than the whole effect being reported.
1669
+ */
1670
+ function unitsOf (text) {
1671
+ if (!text) return 0
1672
+ if (segmenter === undefined) {
1673
+ try {
1674
+ segmenter = typeof Intl !== 'undefined' && Intl.Segmenter
1675
+ ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
1676
+ : null
1677
+ } catch {
1678
+ segmenter = null
1679
+ }
1680
+ }
1681
+ if (!segmenter) return [...text].length
1682
+ let n = 0
1683
+ for (const piece of segmenter.segment(text)) n += piece ? 1 : 0
1684
+ return n
1685
+ }
1686
+
1687
+ /**
1688
+ * The line of a text block that loses the most to a destination ignoring
1689
+ * `letter-spacing`, and how much that is.
1690
+ *
1691
+ * The arithmetic is not an estimate: CSS adds the tracking after every character
1692
+ * of the line, so a target that drops it paints the line `|letterSpacing| ×
1693
+ * characters` narrower than it was measured — negative tracking WIDENS it by that
1694
+ * much, positive tracking narrows it, and either way the line no longer ends
1695
+ * where anything anchored to its end expects. Computed per line because runs on
1696
+ * one line can carry different tracking, and the worst line is what is reported:
1697
+ * an average would hide the one that overflows.
1698
+ */
1699
+ function worstTrackedLine (text, textStyles) {
1700
+ const lines = Array.isArray(text.lines) ? text.lines : []
1701
+ const runs = Array.isArray(text.runs) ? text.runs : []
1702
+ const characters = typeof text.characters === 'string' ? text.characters : ''
1703
+ let worst = null
1704
+ let tracked = 0
1705
+ for (const line of lines) {
1706
+ let delta = 0
1707
+ let units = 0
1708
+ let dominant = null
1709
+ let dominantUnits = 0
1710
+ let spacing = 0
1711
+ for (const run of runs) {
1712
+ const from = Math.max(run.start, line.start)
1713
+ const to = Math.min(run.end, line.end)
1714
+ if (to <= from) continue
1715
+ const style = textStyles[run.style]
1716
+ if (!style) continue
1717
+ const n = unitsOf(characters.slice(from, to))
1718
+ if (n > dominantUnits) { dominantUnits = n; dominant = style }
1719
+ const ls = Number.isFinite(style.letterSpacing) ? style.letterSpacing : 0
1720
+ if (!ls) continue
1721
+ delta += Math.abs(ls) * n
1722
+ units += n
1723
+ if (Math.abs(ls) > Math.abs(spacing)) spacing = ls
1724
+ }
1725
+ if (!(delta > 0)) continue
1726
+ tracked++
1727
+ if (!worst || delta > worst.delta) worst = { line, delta, units, spacing, style: dominant }
1728
+ }
1729
+ if (worst) worst.lines = tracked
1730
+ return worst
1731
+ }
1732
+
1733
+ /**
1734
+ * How much wider the same characters come out in the generic fallback of their
1735
+ * class than in the face the page actually painted them with. MEASURED on this
1736
+ * machine, where both faces are present, and never a constant: which face the
1737
+ * destination substitutes is its own business, but the ORDER of the error is the
1738
+ * distance between the real one and the generic its stack falls through to.
1739
+ *
1740
+ * The reference is the page's whole STACK, not the resolved family on its own,
1741
+ * and that is a measurement rather than a preference: MEASURED on this machine,
1742
+ * `bold 22px -apple-system` on a canvas is 397.24px where the same string laid
1743
+ * out by CSS is 425.72px and the canvas given the whole stack is 426.39px. A
1744
+ * system keyword on its own resolves to a different face in a canvas than it does
1745
+ * in layout, so measuring it alone would report a substitution that never
1746
+ * happened; the stack reproduces layout to 0.16%.
1747
+ */
1748
+ function substitutionWidth (ctx, style, text) {
1749
+ const generic = typeof style.genericClass === 'string' && style.genericClass ? style.genericClass : 'sans-serif'
1750
+ const stack = typeof style.stackCss === 'string' && style.stackCss ? style.stackCss : style.stack
1751
+ const mine = measureWidth(ctx, fontShorthand(style, stack), text)
1752
+ const theirs = measureWidth(ctx, fontShorthand(style, generic), text)
1753
+ if (mine === null || theirs === null || !(mine > 0) || !(theirs > 0)) return null
1754
+ return { generic, mine, theirs, ratio: theirs / mine }
1755
+ }
1756
+
1757
+ /**
1758
+ * The one loss this engine can measure exactly and no destination will honour.
1759
+ *
1760
+ * `letter-spacing` survives into the SVD and into the SVG, and Figma — VERIFIED by
1761
+ * pasting, `FIGMA_FINDINGS.md` §5 — drops it: the pasted node reports
1762
+ * `letter-spacing: 0`. Nothing here can force it, and four ways of trying are
1763
+ * written up as dead ends in that file. What is left is to say, before anyone
1764
+ * pastes, exactly how many px wider each line will arrive — and to add the second
1765
+ * half of the same sum when the family cannot travel either, because the reader
1766
+ * is looking at one line, not at two independent facts about it.
1767
+ */
1768
+ function declareTracking (st) {
1769
+ const { nodes, styles } = st.doc
1770
+ for (const id of Object.keys(nodes)) {
1771
+ const node = nodes[id]
1772
+ if (node.type !== 'text' || !node.text) continue
1773
+ const worst = worstTrackedLine(node.text, styles.text)
1774
+ if (!worst) continue
1775
+
1776
+ const { line, delta, units, spacing } = worst
1777
+ const style = worst.style || {}
1778
+ const text = typeof node.text.characters === 'string'
1779
+ ? node.text.characters.slice(line.start, line.end)
1780
+ : ''
1781
+ const excerpt = text.length > 46 ? `${text.slice(0, 46)}…` : text
1782
+ const width = Number.isFinite(line.w) && line.w > 0 ? line.w : 0
1783
+ // What the glyphs advance to on their own: the tracking comes OUT of the
1784
+ // measured line, because that is what a target dropping it is left holding.
1785
+ const untracked = width ? width + (spacing < 0 ? delta : -delta) : 0
1786
+ const signed = (n) => `${n >= 0 ? '+' : '−'}${Math.abs(n).toFixed(1)}`
1787
+ const pct = (n, of) => `${n >= 0 ? '+' : '−'}${Math.abs(100 * n / of).toFixed(1)}%`
1788
+
1789
+ const parts = [
1790
+ `this text is tracked and the target may well drop it — Figma does, verified by pasting: ` +
1791
+ `letter-spacing ${spacing}px over the ${units} character(s) of line ${line.i}` +
1792
+ `${worst.lines > 1 ? `, the worst of ${worst.lines} tracked lines,` : ''} is ${delta.toFixed(1)}px of ` +
1793
+ 'advance that a target ignoring tracking never adds' +
1794
+ (width
1795
+ ? `: the line measures ${width.toFixed(1)}px here, its glyphs alone advance ${untracked.toFixed(1)}px, ` +
1796
+ `so it arrives ${signed(untracked - width)}px ${spacing < 0 ? 'wider' : 'narrower'} ` +
1797
+ `(${pct(untracked - width, width)})`
1798
+ : '') +
1799
+ `. The line is «${excerpt}».`,
1800
+ ]
1801
+
1802
+ // The family is the other half of the same sum, and only when it really
1803
+ // cannot travel: a face the destination already has is not substituted.
1804
+ const travels = style.referenceable !== false && style.webfont !== true
1805
+ if (!travels) {
1806
+ const sub = substitutionWidth(st.ctx, style, text)
1807
+ const why = style.referenceable === false
1808
+ ? `"${style.family}" is a system keyword and not a font name — no picker can be handed it`
1809
+ : `"${style.family}" is served by an @font-face rule of this page and v0 embeds no font bytes`
1810
+ if (sub && Math.abs(sub.ratio - 1) > 0.001) {
1811
+ const wide = 100 * (sub.ratio - 1)
1812
+ const dest = untracked * sub.ratio
1813
+ parts.push(`Its family does not travel either: ${why}, so the destination substitutes. Measured on this ` +
1814
+ `machine, these same characters are ${sub.mine.toFixed(1)}px in the page's own stack and ` +
1815
+ `${sub.theirs.toFixed(1)}px in the ${sub.generic} it falls back to — ${wide >= 0 ? '+' : '−'}` +
1816
+ `${Math.abs(wide).toFixed(1)}% of glyph advance. Which face the destination picks is its own business — ` +
1817
+ 'pasting this engine\'s SVG into Figma, it picked Inter and the line came out 6.1% wider, measured ' +
1818
+ 'once and on one line — but that is the order of the error' +
1819
+ (untracked
1820
+ ? `, and on this line it puts the two together at about ${dest.toFixed(1)}px against the ` +
1821
+ `${width.toFixed(1)}px the page paints: ${signed(dest - width)}px, ${pct(dest - width, width)}`
1822
+ : '') + '.')
1823
+ } else {
1824
+ parts.push(`Its family does not travel either: ${why}, so the destination substitutes and the substitute ` +
1825
+ 'sets its own advances' +
1826
+ (sub
1827
+ ? ' — how much could not be shown here, because the generic fallback of its class measures the same on this machine'
1828
+ : ' — how much could not be measured here') + '.')
1829
+ }
1830
+ }
1831
+
1832
+ parts.push('Nothing is lost from THIS document — the tracking is in `styles.text` and on the emitted ' +
1833
+ 'text — and nothing this engine can emit makes a target honour it: four ways of forcing it were tried ' +
1834
+ 'against Figma and all four are written up as dead ends.')
1835
+
1836
+ report(st, id, 'text.tracking-may-be-ignored', 'A', parts.join(' '), 'warn')
1837
+ }
1838
+ }
1839
+
1840
+ // ——— the clone itself ———
1841
+
1842
+ /**
1843
+ * Past this the mount is not reproducing the live layout and every coordinate in
1844
+ * the document is measured against something the user never saw. In px, on the
1845
+ * MEDIAN — a single outlier is a node, a median is the mount.
1846
+ */
1847
+ const DRIFT_LIMIT_PX = 1
1848
+
1849
+ /**
1850
+ * The mounted clone measured against the live element, as a diagnostic.
1851
+ *
1852
+ * This is the safety net for the whole architecture, so it is loud in both of its
1853
+ * failure modes and silent in neither: drift past the limit says so, and a mount
1854
+ * that could not be measured at all says THAT, because "no pairs" and "zero
1855
+ * drift" are different answers and only one of them is good news.
1856
+ *
1857
+ * The capture is not aborted either way. A drifted clone still produces a
1858
+ * document, and a document that declares its own drift is more useful than an
1859
+ * exception with the same information in it.
1860
+ */
1861
+ function declareDrift (st, drift) {
1862
+ if (!drift) return
1863
+ const { pairs, median, p95, max } = drift
1864
+ // `measureDrift` now filters out the nodes that generate no box at all
1865
+ // (`<stop>`, `<option>`, `<clipPath>` …): subtracting two origins when one of
1866
+ // them is not a position was producing ~100.451px of "drift" that had nothing
1867
+ // to do with layout. The denominator below is therefore a FILTERED one, and a
1868
+ // fidelity number whose denominator is silently smaller than the tree is the
1869
+ // same class of lie this format exists to prevent — so it is stated.
1870
+ const skipped = Number.isFinite(drift.skipped) ? drift.skipped : 0
1871
+ const filtered = skipped
1872
+ ? ` ${skipped} clone node(s) generate no box on either side (no client rect at all — an SVG ` +
1873
+ '<stop>, an <option>, a display:none subtree) and are not comparable, so they are not in ' +
1874
+ 'that count; a node with a box on ONE side is counted, because that is an element the ' +
1875
+ 'export lost or invented.'
1876
+ : ''
1877
+ // Names for the offenders, so a drift warning points at an element instead of
1878
+ // asking the reader to go and find it.
1879
+ const worst = (drift.worst || []).slice(0, 3)
1880
+ .map((w) => `<${w.tag}${w.id ? '#' + w.id : ''}${w.cls ? '.' + w.cls.trim().split(/\s+/).join('.') : ''}> ` +
1881
+ `off by ${w.delta.toFixed(2)}px (clone ${w.clone}, live ${w.live})`)
1882
+ const blame = worst.length ? ` Worst: ${worst.join('; ')}.` : ''
1883
+ if (!pairs) {
1884
+ report(st, null, 'capture.clone-unverified', 'A',
1885
+ 'not one clone node could be matched to a live element, so the mounted clone\'s layout was ' +
1886
+ 'never checked against the layout it copies. Every coordinate in this document is measured ' +
1887
+ 'on the clone and nothing here confirms the clone is right.' + filtered, 'warn')
1888
+ return
1889
+ }
1890
+ if (median > DRIFT_LIMIT_PX) {
1891
+ report(st, null, 'capture.clone-drift', 'A',
1892
+ `the mounted clone does not lay out like the element it copies: median ${median.toFixed(2)}px, ` +
1893
+ `p95 ${p95.toFixed(2)}px, max ${max.toFixed(2)}px over ${pairs} matched node(s), against a ` +
1894
+ `${DRIFT_LIMIT_PX}px limit. Every box, baseline and gradient in this document was measured on ` +
1895
+ 'the clone, so they are all off by about that much.' + filtered + blame, 'warn')
1896
+ return
1897
+ }
1898
+ // The median is the mount and it is fine — but a median cannot see one node
1899
+ // that landed nowhere, and that is a whole element missing from the export
1900
+ // rather than a rounding error. The known cause is a custom element: mounting
1901
+ // the clone in the page CONNECTS it, the browser upgrades it, its
1902
+ // `connectedCallback` attaches a fresh shadow root, and the shadow content
1903
+ // snapdom had flattened into its light DOM stops being rendered. `paint.js`
1904
+ // sees the same thing from the other side and files `B4.unresolved-shadow-root`.
1905
+ if (max > DRIFT_LIMIT_PX) {
1906
+ report(st, null, 'capture.clone-drift-outlier', 'A',
1907
+ `the mounted clone lays out like its source overall (median ${median.toFixed(2)}px over ` +
1908
+ `${pairs} node(s)) but not everywhere: p95 ${p95.toFixed(2)}px, max ${max.toFixed(2)}px. A ` +
1909
+ 'few nodes are somewhere else entirely, or have no box at all. The usual cause is a custom ' +
1910
+ 'element upgrading when the clone is connected and hiding the shadow content snapdom ' +
1911
+ 'flattened into it — look for `B4.unresolved-shadow-root` alongside this.' + filtered + blame, 'warn')
1912
+ }
1913
+ }
1914
+
1915
+ /** clone attribute -> the pseudo snapdom stamps it for. See `snapdom/src/modules/pseudo.js`. */
1916
+ const PSEUDO_ATTRS = [
1917
+ ['::before', 'data-snapdom-has-before'],
1918
+ ['::after', 'data-snapdom-has-after'],
1919
+ ]
1920
+
1921
+ const TRANSPARENT_RE = /^rgba\(\s*0,\s*0,\s*0,\s*0\s*\)$/
1922
+
1923
+ /**
1924
+ * Does this pseudo put anything on the screen?
1925
+ *
1926
+ * Deliberately mirrors snapdom's own `hasVisibleBox` test: a pseudo it decided
1927
+ * not to materialise BECAUSE it paints nothing is not a gap, and reporting one
1928
+ * would bury the real gaps in noise.
1929
+ */
1930
+ function pseudoPaints (cs) {
1931
+ if (!cs || cs.display === 'none' || cs.visibility === 'hidden') return false
1932
+ const content = cs.content
1933
+ if (!content || content === 'none' || content === 'normal') return false
1934
+ if (content !== '""' && content !== "''") return true
1935
+ // `content:''` is the empty-box case: it paints only if it has a box.
1936
+ if (cs.backgroundImage && cs.backgroundImage !== 'none') return true
1937
+ const bg = cs.backgroundColor
1938
+ if (bg && bg !== 'transparent' && !TRANSPARENT_RE.test(bg)) return true
1939
+ if (cs.boxShadow && cs.boxShadow !== 'none') return true
1940
+ for (const side of ['borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth']) {
1941
+ if (parseFloat(cs[side]) > 0) return true
1942
+ }
1943
+ return false
1944
+ }
1945
+
1946
+ /**
1947
+ * Generated content the clone was supposed to carry and does not.
1948
+ *
1949
+ * The clone materialises `::before`/`::after` into real `<span>`s and stamps the
1950
+ * host with `data-snapdom-has-before`/`-after` when it does, so the check is
1951
+ * exact in the direction that matters: the live element paints a pseudo, its
1952
+ * clone carries no stamp, therefore the export is missing it. That is a grade
1953
+ * `O` — something on the page is not in the document.
1954
+ *
1955
+ * This replaces `B4.pseudo-element`, which meant "generated content is out of
1956
+ * scope" and no longer is. The two must not share a code: one described a
1957
+ * limitation of the engine, this one describes a failure of a capture, and a
1958
+ * report that cannot tell them apart cannot be acted on.
1959
+ *
1960
+ * `::marker` is not here — snapdom leaves it as a native marker rather than an
1961
+ * element, so there is nothing for a collector to read and `paint.js` still
1962
+ * declares it under `B4.list-marker`.
1963
+ */
1964
+ function declarePseudoGaps (st, nodeMap) {
1965
+ if (!nodeMap || typeof nodeMap.entries !== 'function') return
1966
+ if (typeof getComputedStyle !== 'function') return
1967
+
1968
+ let elToId = null
1969
+ const idFor = (el) => {
1970
+ if (!elToId) {
1971
+ elToId = new Map()
1972
+ for (const id of Object.keys(st.doc.nodes)) {
1973
+ const node = st.doc.nodes[id]
1974
+ if (node.el) elToId.set(node.el, id)
1975
+ }
1976
+ }
1977
+ // The element itself may have been pruned; the loss is still inside the
1978
+ // nearest ancestor that survived, and that node is where it gets declared.
1979
+ for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
1980
+ const id = elToId.get(n)
1981
+ if (id) return id
1982
+ }
1983
+ return null
1984
+ }
1985
+
1986
+ let found = 0
1987
+ for (const [cloneEl, srcEl] of nodeMap.entries()) {
1988
+ if (found >= st.ctx.maxDiagnosticsPerCode) break
1989
+ if (!cloneEl || cloneEl.nodeType !== 1 || !srcEl || srcEl.nodeType !== 1) continue
1990
+ if (srcEl.isConnected === false) continue
1991
+ for (const [sel, attr] of PSEUDO_ATTRS) {
1992
+ if (cloneEl.getAttribute(attr) !== null) continue
1993
+ let cs
1994
+ try {
1995
+ cs = getComputedStyle(srcEl, sel)
1996
+ } catch {
1997
+ continue
1998
+ }
1999
+ if (!pseudoPaints(cs)) continue
2000
+ found++
2001
+ report(st, idFor(cloneEl), 'capture.pseudo-not-materialised', 'O',
2002
+ `${cssSelector(srcEl)}${sel} paints on the page (content: ${cs.content}) and the clone ` +
2003
+ 'carries no element for it, so it is not in this document. The clone is where generated ' +
2004
+ 'content becomes real; when it does not, nothing downstream can recover it.')
2005
+ if (found >= st.ctx.maxDiagnosticsPerCode) break
2006
+ }
2007
+ }
2008
+ }
2009
+
2010
+ /**
2011
+ * The last sweep before validation. Two jobs, both of them about the invariant
2012
+ * that outranks the schema:
2013
+ *
2014
+ * - a `blend` the schema does not know is replaced and declared, rather than
2015
+ * failing the whole document over one exotic keyword;
2016
+ * - a node graded other than `E` that no diagnostic names gets one. That
2017
+ * happens when `paint.js` suppresses repeats past its per-code cap: the node
2018
+ * is degraded, its notes say why, and the entry that said so was counted
2019
+ * instead of listed. The grade is real, so the declaration has to be too.
2020
+ */
2021
+ function declareEverything (st) {
2022
+ const { nodes, diagnostics } = st.doc
2023
+ const named = new Set()
2024
+ for (const d of diagnostics) {
2025
+ for (const key of ['node', 'from', 'to']) if (typeof d[key] === 'string') named.add(d[key])
2026
+ for (const key of ['nodes', 'overlaps']) {
2027
+ if (Array.isArray(d[key])) for (const n of d[key]) if (typeof n === 'string') named.add(n)
2028
+ }
2029
+ }
2030
+
2031
+ for (const id of Object.keys(nodes)) {
2032
+ const node = nodes[id]
2033
+ if (typeof node.blend === 'string' && !BLEND_MODES.has(node.blend)) {
2034
+ const bad = node.blend
2035
+ node.blend = 'normal'
2036
+ report(st, id, 'compose.blend-unsupported', 'A',
2037
+ `mix-blend-mode:${bad} is not a mode the schema carries; the node blends normally.`)
2038
+ named.add(id)
2039
+ }
2040
+ node.opacity = clamp01(node.opacity)
2041
+
2042
+ const grade = node.fidelity && node.fidelity.grade
2043
+ if (!grade || grade === 'E' || named.has(id)) continue
2044
+ const notes = (node.fidelity.notes || []).join('; ')
2045
+ diagnostics.push({
2046
+ code: 'fidelity.undeclared',
2047
+ severity: 'warn',
2048
+ node: id,
2049
+ message: `graded ${grade} with no diagnostic naming it — reconstructed from fidelity.notes` +
2050
+ `${notes ? `: ${notes}` : ' (which are empty; the degradation was recorded without a reason)'}`,
2051
+ })
2052
+ }
2053
+ }
2054
+
2055
+ /**
2056
+ * @param {object} st
2057
+ * @param {Element} element the ORIGINAL element — `domElements` is a fact about
2058
+ * the user's DOM and must not quietly become a fact about our clone, which has
2059
+ * more elements in it (materialised pseudos, scroll wrappers, backdrops).
2060
+ * @param {Element} cloneRoot the tree that was actually walked
2061
+ * @param {object} drift
2062
+ * @param {number} startedAt when THIS export started; snapdom's own capture is
2063
+ * not inside it any more — the caller makes that call and can time it.
2064
+ */
2065
+ function buildReport (st, element, cloneRoot, drift, startedAt) {
2066
+ const { nodes, styles, assets } = st.doc
2067
+ const ids = Object.keys(nodes)
2068
+ const grades = { E: 0, A: 0, C: 0, R: 0, RH: 0, O: 0 }
2069
+ let raster = 0
2070
+ let textBlocks = 0
2071
+
2072
+ // What the clone bought. Every one of these is a node that walking the live
2073
+ // DOM could not have produced: `pseudo` and `iconFont` do not exist as
2074
+ // elements there at all, `shadow` was behind a boundary the walker could not
2075
+ // cross, `artifact` is scaffolding snapdom added and we chose to keep. They
2076
+ // are counted, not asserted — if a number here is 0 for a fixture that
2077
+ // visibly has the feature, the migration did not deliver it there.
2078
+ const coverage = { pseudo: 0, shadow: 0, iconFont: 0, artifact: 0, synthetic: 0 }
2079
+ const nodeMap = st.nodeMap
2080
+ const live = (el) => nodeMap && typeof nodeMap.get === 'function' ? nodeMap.get(el) : undefined
2081
+
2082
+ for (const id of ids) {
2083
+ const node = nodes[id]
2084
+ const grade = node.fidelity && node.fidelity.grade
2085
+ if (hasOwn(grades, grade)) grades[grade]++
2086
+ if (node.type === 'text') textBlocks++
2087
+ const hasBitmap = (node.fills || []).some((f) => {
2088
+ if (!f || f.type !== 'image' || typeof f.asset !== 'string') return false
2089
+ const asset = assets[f.asset]
2090
+ return !asset || asset.kind !== 'vector'
2091
+ })
2092
+ // `rasterFilters` produces a node that is a bitmap and carries no image fill
2093
+ // at all. Counting only fills would have reported `raster: 0` over a document
2094
+ // that had just baked a panel into a PNG — the exact false claim this field
2095
+ // was cleaned up to stop making.
2096
+ if (hasBitmap || (node.filterRaster && typeof node.filterRaster.asset === 'string')) raster++
2097
+
2098
+ const source = node.source || {}
2099
+ if (source.pseudo) coverage.pseudo++
2100
+ if (source.clone) coverage.artifact++
2101
+ const el = node.el
2102
+ if (el && el.nodeType === 1) {
2103
+ const src = live(el)
2104
+ if (!src) {
2105
+ coverage.synthetic++
2106
+ // snapdom turns a ligature glyph into an <img> with a baked data URL; the
2107
+ // <img> has no live counterpart because in the page it was a text node in
2108
+ // an icon font. That pair of facts is the only signal it leaves.
2109
+ if (el.tagName === 'IMG' && String(el.getAttribute('src') || '').startsWith('data:image')) {
2110
+ coverage.iconFont++
2111
+ }
2112
+ } else if (behindShadowBoundary(src)) {
2113
+ // Counted on the SOURCE, not on the clone: the clone has the shadow tree
2114
+ // flattened into light DOM, so by then there is no boundary left to see.
2115
+ // The host element itself is often pruned for painting nothing, so
2116
+ // counting hosts would report 0 for a component that came through whole.
2117
+ coverage.shadow++
2118
+ } else if (source.shadowHost) {
2119
+ coverage.shadow++
2120
+ }
2121
+ }
2122
+ }
2123
+
2124
+ const domElements = element.querySelectorAll ? element.querySelectorAll('*').length + 1 : 1
2125
+ const cloneElements = cloneRoot && cloneRoot.querySelectorAll
2126
+ ? cloneRoot.querySelectorAll('*').length + 1
2127
+ : 0
2128
+ const fonts = Object.keys(styles.text).map((id) => ({
2129
+ id,
2130
+ family: styles.text[id].family,
2131
+ weight: styles.text[id].weight,
2132
+ italic: styles.text[id].italic,
2133
+ }))
2134
+
2135
+ return {
2136
+ nodes: ids.length,
2137
+ domElements,
2138
+ // The clone is the tree that was walked and it is BIGGER than the source:
2139
+ // both numbers are here so `nodeRatio` cannot be read as a claim about the
2140
+ // wrong one.
2141
+ cloneElements,
2142
+ // Measured, never promised: the whole point of the pruning in `paint.js` is
2143
+ // a number you can check, not a number in a pitch deck.
2144
+ nodeRatio: domElements ? ids.length / domElements : 0,
2145
+ // The mounted clone against the live element it copies, in px. Everything
2146
+ // above is measured ON the clone, so this is the error bar on all of it.
2147
+ drift,
2148
+ // Nodes the live-DOM walk could not have reached. See `coverage` above.
2149
+ coverage,
2150
+ vector: ids.length - raster,
2151
+ raster,
2152
+ textBlocks,
2153
+ fonts,
2154
+ grades,
2155
+ assets: Object.keys(assets).length,
2156
+ textStyles: Object.keys(styles.text).length,
2157
+ diagnostics: st.doc.diagnostics.length,
2158
+ promotions: st.doc.diagnostics.filter((d) => d.code === 'B4.promoted').length,
2159
+ // The mount, the walk, the collectors and the asset fetches — this export
2160
+ // and nothing else. What snapdom's own capture cost used to be itemised here
2161
+ // beside it, back when this module made that call itself; now the caller
2162
+ // does, and the caller is the only one who can time it honestly (the last
2163
+ // hook fires well before `captureDOM` finishes serializing).
2164
+ durationMs: now() - startedAt,
2165
+ }
2166
+ }
2167
+
2168
+ function now () {
2169
+ return typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now()
2170
+ }
2171
+
2172
+ /**
2173
+ * Is this live node inside a shadow tree? `getRootNode()` walks past nested
2174
+ * boundaries to the top, so the test is "the root is not the document" rather
2175
+ * than an `instanceof` against a global that may not be there.
2176
+ * @param {Node} node
2177
+ */
2178
+ function behindShadowBoundary (node) {
2179
+ if (!node || typeof node.getRootNode !== 'function') return false
2180
+ const root = node.getRootNode()
2181
+ return !!root && root.nodeType === 11 && !!root.host
2182
+ }
2183
+
2184
+ /** Grouped by code: 400 identical warnings are one line and a count, not 400 lines. */
2185
+ function printDiagnostics (doc) {
2186
+ if (typeof console === 'undefined' || !doc.diagnostics.length) return
2187
+ const groups = new Map()
2188
+ for (const d of doc.diagnostics) {
2189
+ if (!groups.has(d.code)) groups.set(d.code, [])
2190
+ groups.get(d.code).push(d)
2191
+ }
2192
+ const text = (d) => d.message || d.note || d.reason || ''
2193
+ const ordered = [...groups].sort((a, b) => b[1].length - a[1].length)
2194
+
2195
+ console.warn(
2196
+ `[snapdom-vector] ${doc.diagnostics.length} diagnostic(s) in ${ordered.length} code(s); ` +
2197
+ 'all of them are in doc.diagnostics.'
2198
+ )
2199
+ for (const [code, list] of ordered) {
2200
+ const seen = []
2201
+ for (const d of list) {
2202
+ const message = text(d)
2203
+ if (message && !seen.includes(message)) seen.push(message)
2204
+ if (seen.length === 3) break
2205
+ }
2206
+ console.warn(`[snapdom-vector] ${code} ×${list.length}`)
2207
+ for (const message of seen) console.warn(`[snapdom-vector] ${message}`)
2208
+ if (list.length > seen.length && seen.length === 3) {
2209
+ console.warn(`[snapdom-vector] …and ${list.length - 3} more of this code`)
2210
+ }
2211
+ }
2212
+ }
2213
+
2214
+ // ——— entry points ———
2215
+
2216
+ /** Snapshot only plain option data, keeping functions and DOM objects opaque. */
2217
+ function snapshotOption (value, seen = new WeakMap()) {
2218
+ const snapshot = { value }
2219
+ if (!value || typeof value !== 'object') return snapshot
2220
+ const proto = Object.getPrototypeOf(value)
2221
+ if (!Array.isArray(value) && proto !== Object.prototype && proto !== null) return snapshot
2222
+ if (seen.has(value)) return seen.get(value)
2223
+ seen.set(value, snapshot)
2224
+ snapshot.entries = new Map(Object.keys(value).map(key => [key, snapshotOption(value[key], seen)]))
2225
+ if (Array.isArray(value)) snapshot.length = value.length
2226
+ return snapshot
2227
+ }
2228
+
2229
+ /** Identity changes and in-place edits both count; cyclic option bags are safe. */
2230
+ function sameOption (value, snapshot, seen = new Set()) {
2231
+ if (!Object.is(value, snapshot.value)) return false
2232
+ if (!snapshot.entries || seen.has(snapshot)) return true
2233
+ seen.add(snapshot)
2234
+ if (Array.isArray(value) && value.length !== snapshot.length) return false
2235
+ const keys = Object.keys(value)
2236
+ return keys.length === snapshot.entries.size && keys.every(key =>
2237
+ snapshot.entries.has(key) && sameOption(value[key], snapshot.entries.get(key), seen))
2238
+ }
2239
+
2240
+ /**
2241
+ * v3 passes normalized capture options to exporters, including defaults such as
2242
+ * `exclude: []` and `embedFonts: 'auto'`. Those are not per-export requests and
2243
+ * must not erase defaults passed to vector(). Explicit export options and
2244
+ * detectable beforeExport changes win, including in-place array/object edits.
2245
+ * An assignment of the unchanged scalar value cannot be distinguished from an
2246
+ * untouched default; use an explicit export option for that case. Direct calls
2247
+ * without the v3 facade keep the usual defaults-then-options merge.
2248
+ */
2249
+ function exportOptions (ctx, defaults, opts, captured) {
2250
+ const requested = ctx && ctx.export && ctx.export.requestedOptions
2251
+ if (!requested) return { ...defaults, ...opts }
2252
+ const merged = { ...opts, ...defaults }
2253
+ for (const [key, snapshot] of captured) {
2254
+ if (hasOwn(opts, key) && (hasOwn(requested, key) || !sameOption(opts[key], snapshot))) merged[key] = opts[key]
2255
+ }
2256
+ return merged
2257
+ }
2258
+
2259
+ /**
2260
+ * The engine, as a snapdom plugin — the one way to use it.
2261
+ *
2262
+ * Shaped like the twelve official plugins (`snapdom/packages/plugins/`): a
2263
+ * factory returning `{name, hook, defineExports}`. What is specific to this one
2264
+ * is that the export does nearly all of the work, and the hook does almost none:
2265
+ * a capture whose result nobody asks a vector question of costs one object.
2266
+ *
2267
+ * const result = await snapdom(el, { plugins: [vector({ mode: 'replica' })] })
2268
+ * const doc = await result.toVector() // SVD document
2269
+ * const svg = await result.toVectorSvg() // flat SVG markup
2270
+ * const fig = svdToFigma(doc) // …or any emitter, off the doc
2271
+ * const h2d = await result.toVectorFigmaClipboard() // Figma's paste envelope
2272
+ *
2273
+ * **The export names.** snapdom's own `svg` export is `result.toSvg()` — an
2274
+ * `<img>` of the capture (`snapdom/src/api/snapdom.js:333`) — and plugin exports
2275
+ * OVERRIDE core ones by name (`buildResult`: `{...coreExports, ...provided}`), so
2276
+ * a plugin publishing `svg` would silently replace it for every caller of that
2277
+ * capture. `vector` and `vectorSvg` are free, and `buildResult` turns them into
2278
+ * `toVector()` / `toVectorSvg()` by the same rule it uses for every other plugin
2279
+ * (`'to' + key[0].toUpperCase() + key.slice(1)`).
2280
+ *
2281
+ * `vectorFigmaClipboard` is free by the same check, and it is prefixed for the
2282
+ * same reason the other two are: taken are the core `img|svg|canvas|blob|png|
2283
+ * jpeg|jpg|webp|download` and the official plugins' `agentMap|ascii|context|gif|
2284
+ * html|htmlInCanvas|pdfImage|mp4`, plus `pdf` from the sibling package here — a
2285
+ * bare `figma` or `html` would be a name a future plugin can take from us, or we
2286
+ * from it, with no warning either way. It is also NOT `svdToFigma`, which exists
2287
+ * and is a different thing: that one turns an SVD document into JSON for our own
2288
+ * Figma plugin to build nodes with. This one produces the clipboard envelope
2289
+ * FIGMA'S OWN importer opens, and never touches the SVD.
2290
+ *
2291
+ * Options given here are defaults for every export; the same names passed to an
2292
+ * export call override them for that call.
2293
+ *
2294
+ * @param {object} [options]
2295
+ * @param {'design'|'replica'} [options.mode='design']
2296
+ * @param {number} [options.maxNodes=20000] ceiling; the walk stops and says so
2297
+ * @param {number} [options.maxDepth=256]
2298
+ * @param {string|Function|Array} [options.exclude] selectors or predicates to skip
2299
+ * @param {number} [options.fetchTimeout=8000] per asset, in ms
2300
+ * @param {number} [options.maxAssetBytes] size past which an asset is reported
2301
+ * @param {boolean} [options.embedFonts=true] fetch the bytes behind every
2302
+ * `@font-face` family the capture paints with and carry them in `doc.assets`
2303
+ * (`collect/font.js`): the emitted SVG then renders without the viewer's
2304
+ * installed fonts and every line is pinned with `textLength`. The file travels
2305
+ * WHOLE — there is no subsetter — and each face declares its size under
2306
+ * `assets.font-embedded`; `false` keeps the export small and the families
2307
+ * travel by name, declared per family instead.
2308
+ * @param {boolean} [options.silent=false] do not print the grouped diagnostics
2309
+ * @param {boolean} [options.debugPaint=false] `toVectorSvg` only: tint nodes by
2310
+ * stacking-context depth
2311
+ * @param {boolean} [options.rasterFilters=false] rasterize, to a PNG `<image>`,
2312
+ * the nodes whose FILTER an importer of Figma's class does not run — the colour
2313
+ * functions of `filter`/`backdrop-filter` (`saturate`, `brightness`, `contrast`,
2314
+ * `grayscale`, `sepia`, `hue-rotate`, `invert`), which become one `feColorMatrix`,
2315
+ * and inner shadows, which have no primitive and stay a composite chain. Those
2316
+ * nodes and no others: a filter that arrives intact is never touched. Off by
2317
+ * default because it is a real loss — the subtree stops being editable in every
2318
+ * consumer, not only in the one it is for — and because with it off the document
2319
+ * is unchanged and the loss is declared instead (`emit.svg.filter-not-imported`).
2320
+ * The pixels come from the MOUNTED CLONE, which is where snapdom has already
2321
+ * pre-composed `backdrop-filter`, over the filter's own region (`3σ + |offset|`)
2322
+ * rather than the border box. Every node it rasterizes says so (`raster.filter`,
2323
+ * grade R) and every node it could not says that (`raster.filter-failed`).
2324
+ * **Only `toVectorSvg` consumes them.** `svdToFigma` and the PDF backend do not
2325
+ * read `node.filterRaster` yet and emit the node's vector paint instead; that is
2326
+ * said in the diagnostic rather than left for someone to discover.
2327
+ *
2328
+ * **`toVectorFigmaClipboard()` is a different KIND of export from the other two**
2329
+ * and should be read before it is shipped behind a button. It does not produce a
2330
+ * document this engine defines: it produces Figma's own `h2d` clipboard payload —
2331
+ * the computed DOM, which Figma converts on paste — in a format that is
2332
+ * proprietary, undocumented, reverse-engineered from one decoded capture of their
2333
+ * extension, and already at `version: 2`. Figma can change it in any release,
2334
+ * with no negotiation and no error; the only symptom is a ⌘V that does nothing.
2335
+ * What it buys is what the SVG importer cannot do at all: `letter-spacing`
2336
+ * survives (FIGMA_FINDINGS.md §5). What it costs is that only Figma opens it,
2337
+ * which is why `toVectorSvg()` is not going anywhere.
2338
+ *
2339
+ * @returns {object} snapdom plugin
2340
+ */
2341
+ export function vector (options = {}) {
2342
+ return {
2343
+ name: 'vector',
2344
+
2345
+ // No `pure: true`, and that is load-bearing rather than an omission. The flag
2346
+ // opts a plugin back into auto-burst (`snapdom/src/core/plugins.js:184`), and
2347
+ // burst's memo and differential paths both answer with a URL WITHOUT
2348
+ // re-entering `captureDOM` — so the hook below would not run and the export
2349
+ // would have no clone. Declaring a read-only hook impure is what buys the
2350
+ // `burst: false` the loose API used to force by hand.
2351
+
2352
+ /**
2353
+ * The hand-off, and nothing else. v3 passes one capture context to every
2354
+ * hook; `state.options` aliases that same context. The export views preserve
2355
+ * its enumerable fields, including this hand-off.
2356
+ */
2357
+ beforeRender (state) {
2358
+ if (!state || !state.options) return
2359
+ const handoff = handoffFrom(state)
2360
+ // `baseCSS` is snapdom's UA-stylesheet reset, and it belongs to the RASTER
2361
+ // path and to nothing else. `handoffFrom` stays the adapter's six fields:
2362
+ // the mount does not want this string — injecting a tag reset into the live
2363
+ // page would restyle the page the clone is measured against — and only
2364
+ // `rasterizeUnrunFilters`, which renders the clone inside a foreignObject
2365
+ // where there is no page cascade at all, has any use for it. Read here
2366
+ // because `beforeRender` is the one hook where snapdom's state carries it
2367
+ // (`engines/svg.js` assembles the CSS before the hook runs).
2368
+ if (handoff) handoff.baseCSS = state.baseCSS || ''
2369
+ state.options.__vectorHandoff = handoff
2370
+ },
2371
+
2372
+ defineExports (captureCtx) {
2373
+ // Only collisions need snapshots. Never traverse the whole capture context:
2374
+ // it owns the clone and maps, and export hooks share its option references.
2375
+ const captured = new Map(Object.keys(options).map(key => [key, snapshotOption(captureCtx && captureCtx[key])]))
2376
+ return {
2377
+ vector: (ctx, opts = {}) => buildDocument(ctx, exportOptions(ctx, options, opts, captured)),
2378
+ vectorSvg: async (ctx, opts = {}) => {
2379
+ const merged = exportOptions(ctx, options, opts, captured)
2380
+ return svgFrom(await buildDocument(ctx, merged), merged)
2381
+ },
2382
+ vectorFigmaClipboard: (ctx, opts = {}) => buildFigmaClipboard(ctx, exportOptions(ctx, options, opts, captured)),
2383
+ }
2384
+ },
2385
+ }
2386
+ }
2387
+
2388
+ export default vector
2389
+
2390
+ /**
2391
+ * The two guards every export that walks the clone has to pass, in one place so
2392
+ * the second backend cannot drift from the first one's diagnosis.
2393
+ *
2394
+ * @param {object} ctx the export context
2395
+ * @param {string} label the export's own name, so the error names what was called
2396
+ * @returns {{handoff: object, element: Element}}
2397
+ */
2398
+ function openCapture (ctx, label) {
2399
+ const handoff = ctx && ctx.__vectorHandoff
2400
+ if (!handoff) {
2401
+ throw new Error(
2402
+ `${label}: this capture carries no clone. The vector plugin's \`beforeRender\` hook never ` +
2403
+ 'ran, so the capture did not go through captureDOM — the usual cause is an explicit ' +
2404
+ '`burst: true`, which serves a memoized URL instead. Leave `burst` unset: a plugin with ' +
2405
+ 'render hooks suspends auto-burst on its own.'
2406
+ )
2407
+ }
2408
+
2409
+ // No "is there a DOM?" check any more: the hand-off only exists because a hook
2410
+ // ran inside a capture, and a capture only happens in a browser.
2411
+ const element = handoff.element
2412
+ const rect = element.getBoundingClientRect()
2413
+ if (!(rect.width > 0) || !(rect.height > 0)) {
2414
+ const cs = getComputedStyle(element)
2415
+ const why = cs.display === 'none'
2416
+ ? 'it is display:none'
2417
+ : element.isConnected === false
2418
+ ? 'it is not connected to a document'
2419
+ : `its border box is ${rect.width}×${rect.height}`
2420
+ throw new Error(`${label}: the captured element has no layout box (${why}); there is nothing to capture`)
2421
+ }
2422
+ return { handoff, element }
2423
+ }
2424
+
2425
+ /**
2426
+ * The `h2d` backend, end to end: mount the clone, walk it into Figma's own
2427
+ * clipboard payload, wrap it in the envelope, unmount.
2428
+ *
2429
+ * **It does not build an SVD document, and that is the point.** This is a
2430
+ * parallel backend off the same clone, not a second consumer of the first one's
2431
+ * output: Figma converts the DOM itself on this channel, so a paint tree,
2432
+ * gradients, measured text lines and everything else the SVD models would be work
2433
+ * done twice — once here and once by Figma, from raw styles, differently.
2434
+ * `emit/svg-flat.js` and the SVD are untouched by this path.
2435
+ *
2436
+ * The clone is unmounted in a `finally` for the same reason `buildDocument` does
2437
+ * it: without that, every call leaves a full copy of the user's subtree in their
2438
+ * page.
2439
+ *
2440
+ * @param {object} ctx
2441
+ * @param {object} options
2442
+ * @returns {Promise<import('./emit/h2d.js').H2DResult & {html: string, plain: string,
2443
+ * json: string, bytes: number, clipboardItem: () => ClipboardItem}>}
2444
+ */
2445
+ /**
2446
+ * The hybrid, in one place: find the nodes whose paint the h2d channel cannot spell,
2447
+ * draw them with the SVD backend, hand back a clone-element -> data-URI map.
2448
+ *
2449
+ * It is skipped entirely when the capture has none of them, and that is the common
2450
+ * case — assembling an SVD is the expensive half of this library, and paying for it
2451
+ * on a page of text and boxes to discover there was nothing to draw would make every
2452
+ * capture slower for the benefit of the ones that need it. The probe that decides is
2453
+ * a style read per element.
2454
+ *
2455
+ * @param {Element} element
2456
+ * @param {import('./adapters/snapdom.js').CloneCapture} capture
2457
+ * @param {object} options
2458
+ * @param {object} handoff
2459
+ * @returns {Promise<{overrides: Map<Element,string>|null, withheld: object[], bytes: number,
2460
+ * diagnostics: object[]}>}
2461
+ */
2462
+ async function hybridPaint (element, capture, options, handoff) {
2463
+ const none = { overrides: null, withheld: [], bytes: 0, diagnostics: [] }
2464
+ // `vectorFallback: false` turns off the DRAWING, not the reporting. A capture that
2465
+ // stops fixing something and also stops mentioning it is the failure mode this whole
2466
+ // engine is built against.
2467
+ const mode = options.mode === 'replica' ? 'replica' : 'design'
2468
+ const { targets, withheld, backdrop } = findFallbackTargets(capture.clone, {
2469
+ mode,
2470
+ isExcluded: (el) => !!(el.closest && el.closest('[data-capture="exclude"]')),
2471
+ })
2472
+ if (!targets.size && !withheld.length && !backdrop.length) return none
2473
+
2474
+ const diagnostics = []
2475
+ if (backdrop.length) {
2476
+ const byCode = new Map()
2477
+ for (const c of backdrop) byCode.set(c, (byCode.get(c) || 0) + 1)
2478
+ diagnostics.push({
2479
+ code: 'h2d.backdrop-effects-lost',
2480
+ severity: 'warn',
2481
+ message: `${backdrop.length} node(s) use an effect that reads what is UNDERNEATH them ` +
2482
+ `(${[...byCode].map(([c, n]) => `${c}×${n}`).join(', ')}). The channel does not carry those ` +
2483
+ 'properties and the vector fallback cannot supply them either: a per-node asset is its own ' +
2484
+ 'document with a transparent backdrop, so multiplying against nothing produces the same flat ' +
2485
+ 'shape as describing it as a box — measured on `demo/hybrid-probe.html`, where a ' +
2486
+ '`mix-blend-mode: multiply` square drew identically both ways. They arrive unblended. Figma ' +
2487
+ 'does not apply blend on import either, so this is not a gap against their extension.',
2488
+ })
2489
+ }
2490
+ if (withheld.length) {
2491
+ const byCode = new Map()
2492
+ for (const w of withheld) for (const c of w.codes) byCode.set(c, (byCode.get(c) || 0) + 1)
2493
+ diagnostics.push({
2494
+ code: 'h2d.vector-paint-withheld',
2495
+ severity: 'warn',
2496
+ message: `${withheld.length} node(s) paint something this channel cannot describe ` +
2497
+ `(${[...byCode].map(([c, n]) => `${c}×${n}`).join(', ')}) and were NOT redrawn as vector, ` +
2498
+ 'because they have text inside and replacing them would replace the editable text with a ' +
2499
+ 'drawing. In `design` mode the text wins and the effect is lost; run the capture with ' +
2500
+ '`mode: "replica"` to trade it the other way. Figma\'s own extension loses these too — ' +
2501
+ 'measured, FIGMA_FINDINGS.md § «El híbrido» — so this is not a gap against their tool.',
2502
+ })
2503
+ }
2504
+ if (!targets.size) return { ...none, withheld, diagnostics }
2505
+ if (options.vectorFallback === false) {
2506
+ diagnostics.push({
2507
+ code: 'h2d.vector-paint-off',
2508
+ severity: 'warn',
2509
+ message: `${targets.size} node(s) would have had their paint drawn as vector and \`vectorFallback: ` +
2510
+ 'false\' turned it off. They are in the payload as plain CSS boxes, which means Figma paints ' +
2511
+ 'them without the effect — no conic ramp, no filter, no clip.',
2512
+ })
2513
+ return { ...none, withheld, diagnostics }
2514
+ }
2515
+
2516
+ const elementIndex = new Map()
2517
+ let doc
2518
+ try {
2519
+ doc = await assemble(element, capture, options, now(), handoff, elementIndex)
2520
+ } catch (error) {
2521
+ diagnostics.push({
2522
+ code: 'h2d.vector-paint-failed',
2523
+ severity: 'warn',
2524
+ message: `${targets.size} node(s) needed their paint drawn as vector and the SVD document ` +
2525
+ `could not be assembled: ${error && error.message}. They are in the payload described as ` +
2526
+ 'plain CSS boxes, which is what they looked like before this step existed — the effect is ' +
2527
+ 'lost, not the node.',
2528
+ })
2529
+ return { ...none, withheld, diagnostics }
2530
+ }
2531
+
2532
+ const overrides = new Map()
2533
+ let bytes = 0
2534
+ const unresolved = []
2535
+ for (const [el, info] of targets) {
2536
+ const id = elementIndex.get(el)
2537
+ const slice = id ? svdSliceToDataUri(doc, id) : null
2538
+ if (!slice) { unresolved.push({ tag: el.tagName, codes: info.codes }); continue }
2539
+ overrides.set(el, slice.uri)
2540
+ bytes += slice.bytes
2541
+ }
2542
+ if (unresolved.length) {
2543
+ diagnostics.push({
2544
+ code: 'h2d.vector-paint-unresolved',
2545
+ severity: 'warn',
2546
+ message: `${unresolved.length} node(s) were selected for vector paint and no SVD node could be ` +
2547
+ `found for them (${unresolved.slice(0, 4).map((u) => u.tag + ':' + u.codes.join('+')).join(', ')}). ` +
2548
+ 'They arrive as plain CSS boxes without the effect. This is an engine bug, not a page one: ' +
2549
+ 'the paint tree and the h2d walk disagreed about which elements exist.',
2550
+ })
2551
+ }
2552
+ if (overrides.size) {
2553
+ const byCode = new Map()
2554
+ for (const [, info] of targets) for (const c of info.codes) byCode.set(c, (byCode.get(c) || 0) + 1)
2555
+ diagnostics.push({
2556
+ code: 'h2d.vector-paint',
2557
+ severity: 'info',
2558
+ message: `${overrides.size} node(s) have their paint drawn by this engine's SVD backend and ` +
2559
+ `carried as image/svg+xml instead of described as CSS (${[...byCode].map(([c, n]) => `${c}×${n}`).join(', ')}), ` +
2560
+ `${Math.round(bytes / 1024)} kB of markup. Measured in Figma (2026-08-07): an SVG asset from ` +
2561
+ 'this channel imports AS PATHS in a group, not as an image fill, so those layers stay ' +
2562
+ 'editable. It is done because the alternative is not a worse paste but no paint at all — ' +
2563
+ 'Figma\'s importer has no conic gradient, does not run filters and does not clip, and their ' +
2564
+ 'own extension loses exactly the same things. Turn it off with `vectorFallback: false`.',
2565
+ })
2566
+ }
2567
+ return { overrides: overrides.size ? overrides : null, withheld, bytes, diagnostics }
2568
+ }
2569
+
2570
+ async function buildFigmaClipboard (ctx, options) {
2571
+ const { handoff, element } = openCapture(ctx, 'toVectorFigmaClipboard')
2572
+ const rect = element.getBoundingClientRect()
2573
+ const capture = mountClone(handoff)
2574
+ let out
2575
+ let hybrid = { overrides: null, withheld: [], bytes: 0, diagnostics: [] }
2576
+ try {
2577
+ hybrid = await hybridPaint(element, capture, options, handoff)
2578
+ out = cloneToH2d(capture.clone, {
2579
+ paintOverrides: hybrid.overrides,
2580
+ // The LIVE element's document position. Every rect in the payload is
2581
+ // measured on the clone and re-expressed against this, so the paste lands
2582
+ // where the element sits on the page and not 99999px to the left of it.
2583
+ docOrigin: {
2584
+ x: rect.left + (typeof scrollX === 'number' ? scrollX : 0),
2585
+ y: rect.top + (typeof scrollY === 'number' ? scrollY : 0),
2586
+ },
2587
+ rootSize: { width: rect.width, height: rect.height },
2588
+ // Clone node -> live node. The walk reads the resolved styles off the CLONE and
2589
+ // the authored ones off the live twin: snapdom resolves `width: 100%` into a px
2590
+ // when it clones, and that percentage is the only thing that tells Figma a layer
2591
+ // fills its parent instead of being pinned to the width it happens to have.
2592
+ nodeMap: capture.nodeMap,
2593
+ documentTitle: typeof document !== 'undefined' ? document.title : '',
2594
+ devicePixelRatio: typeof devicePixelRatio === 'number' ? devicePixelRatio : 1,
2595
+ exclude: options.exclude,
2596
+ maxNodes: options.maxNodes,
2597
+ // Off puts inline <svg> back to being a tree of CSS boxes, which Figma imports as
2598
+ // an empty frame. It exists so a paste that fails can be bisected against the one
2599
+ // behaviour of this backend that rewrites a node instead of describing it.
2600
+ inlineSvgAsAsset: options.inlineSvgAsAsset,
2601
+ })
2602
+ } finally {
2603
+ capture.unmount()
2604
+ }
2605
+ out.diagnostics.push(...hybrid.diagnostics)
2606
+
2607
+ // The mount is verified on this path too, by the same `DRIFT_LIMIT_PX` the SVD
2608
+ // path uses. `drift` is the whole claim behind reading the clone instead of the
2609
+ // page: a payload built on an unverified mount is a paste whose coordinates
2610
+ // nobody checked, and here they are the ONLY geometry — Figma lays the tree out
2611
+ // from these rects.
2612
+ const drift = capture.drift || {}
2613
+ if (!drift.pairs) {
2614
+ out.diagnostics.push({
2615
+ code: 'h2d.clone-unverified',
2616
+ severity: 'warn',
2617
+ message: 'Not one clone node could be matched to a live element, so the mounted clone\'s layout ' +
2618
+ 'was never checked against the layout it copies. Every rect in this payload is measured on the ' +
2619
+ 'clone and nothing here confirms the clone is right.',
2620
+ })
2621
+ } else if (drift.median > DRIFT_LIMIT_PX || drift.max > DRIFT_LIMIT_PX) {
2622
+ out.diagnostics.push({
2623
+ code: 'h2d.clone-drift',
2624
+ severity: 'warn',
2625
+ message: `The mounted clone does not lay out like the element it copies: median ` +
2626
+ `${drift.median.toFixed(2)}px, p95 ${drift.p95.toFixed(2)}px, max ${drift.max.toFixed(2)}px ` +
2627
+ `over ${drift.pairs} matched node(s), against a ${DRIFT_LIMIT_PX}px limit. Every rect in this ` +
2628
+ 'payload was measured on the clone, so the paste is off by about that much.',
2629
+ })
2630
+ }
2631
+
2632
+ const envelope = h2dEnvelope(out.payload, {
2633
+ selector: cssSelector(element),
2634
+ url: typeof location !== 'undefined' ? location.href : '',
2635
+ })
2636
+ const result = {
2637
+ ...envelope,
2638
+ payload: out.payload,
2639
+ diagnostics: out.diagnostics,
2640
+ stats: out.stats,
2641
+ drift: capture.drift,
2642
+ /** The envelope as a ClipboardItem, with the empty `text/plain` half. */
2643
+ clipboardItem: () => h2dClipboardItem(envelope),
2644
+ }
2645
+ if (!options.silent) printDiagnostics({ diagnostics: out.diagnostics })
2646
+ return result
2647
+ }
2648
+
2649
+ /**
2650
+ * One export call: take the clone the hook parked, mount it, walk it, validate
2651
+ * the document, unmount.
2652
+ *
2653
+ * @param {object} ctx the export context snapdom builds from the capture context
2654
+ * @param {object} options
2655
+ * @returns {Promise<SVDDocument>}
2656
+ * @throws when the capture handed over no clone, when the element has nothing to
2657
+ * capture, or when the document we assembled does not validate — the engine
2658
+ * does not emit broken documents.
2659
+ */
2660
+ async function buildDocument (ctx, options) {
2661
+ const startedAt = now()
2662
+
2663
+ const { handoff, element } = openCapture(ctx, 'toVector')
2664
+
2665
+ // From here on the tree being read is the CLONE. `element` is still what the
2666
+ // capture is ABOUT — its selector, its url, its position in the document — and
2667
+ // nothing below may take a measurement off it: the two trees sit ~99999px
2668
+ // apart and every coordinate is relative to its own root.
2669
+ const capture = mountClone(handoff)
2670
+ try {
2671
+ return await assemble(element, capture, options, startedAt, handoff)
2672
+ } finally {
2673
+ // Not optional and not on the happy path: without this every export leaves
2674
+ // a full copy of the user's subtree, and a <style> for it, in their page.
2675
+ capture.unmount()
2676
+ }
2677
+ }
2678
+
2679
+ /**
2680
+ * Everything between a mounted clone and a validated document. Split out of
2681
+ * `buildDocument` for one reason: it lets the unmount live in a `finally` that
2682
+ * cannot be skipped by an early return or a throw halfway down.
2683
+ *
2684
+ * @param {Element} element the original, for the capture header and the report
2685
+ * @param {import('./adapters/snapdom.js').CloneCapture} capture
2686
+ * @param {object} options
2687
+ * @param {number} startedAt
2688
+ * @param {object} handoff the capture's own CSS, for `rasterFilters` — the only
2689
+ * step here that has to RENDER the clone rather than read it
2690
+ * @param {Map<Element,string>} [elementIndex] filled with clone element -> node id
2691
+ * just before the back-references are dropped, for callers that have to go back
2692
+ * from the DOM to the document (the h2d hybrid does)
2693
+ * @returns {Promise<SVDDocument>}
2694
+ */
2695
+ async function assemble (element, capture, options, startedAt, handoff, elementIndex) {
2696
+ const { clone, nodeMap, drift } = capture
2697
+ const rect = element.getBoundingClientRect()
2698
+
2699
+ const ctx = makeCtx(clone, options)
2700
+ const tree = buildPaintTree(clone, ctx)
2701
+ const rootNode = tree.nodes[tree.rootId]
2702
+
2703
+ const doc = emptyDocument({
2704
+ url: typeof location !== 'undefined' ? location.href : '',
2705
+ selector: cssSelector(element),
2706
+ capturedAt: new Date().toISOString(),
2707
+ viewport: {
2708
+ w: typeof innerWidth === 'number' ? innerWidth : 0,
2709
+ h: typeof innerHeight === 'number' ? innerHeight : 0,
2710
+ dpr: typeof devicePixelRatio === 'number' ? devicePixelRatio : 1,
2711
+ },
2712
+ // The root's border box in DOCUMENT coordinates: `abs` is relative to it, so
2713
+ // this is what puts a capture back where it came from.
2714
+ docOrigin: {
2715
+ x: rect.left + (typeof scrollX === 'number' ? scrollX : 0),
2716
+ y: rect.top + (typeof scrollY === 'number' ? scrollY : 0),
2717
+ },
2718
+ root: { w: rootNode.abs.w, h: rootNode.abs.h },
2719
+ mode: ctx.mode,
2720
+ })
2721
+ doc.nodes = tree.nodes
2722
+ doc.root = tree.rootId
2723
+ doc.diagnostics = tree.diagnostics.slice()
2724
+ ctx.assets = doc.assets
2725
+
2726
+ const st = {
2727
+ ctx,
2728
+ doc,
2729
+ dropped: new Set(),
2730
+ textStyleIds: new Map(),
2731
+ markupTextUses: [],
2732
+ /** `{id, mask}` per node whose `mask-image` layers still need a node. */
2733
+ masks: [],
2734
+ // clone node -> live node. `buildReport` needs it to tell a node snapdom
2735
+ // SYNTHESIZED (a materialised pseudo, an icon-font <img>, a scroll wrapper)
2736
+ // from one that was already in the user's DOM — that difference is the whole
2737
+ // coverage claim of walking the clone.
2738
+ nodeMap,
2739
+ }
2740
+
2741
+ // Every node, one collector each. The image calls are awaited together: a card
2742
+ // with twelve icons is twelve fetches in flight, not twelve round trips.
2743
+ const pending = []
2744
+ for (const id of Object.keys(doc.nodes)) {
2745
+ const task = collectNode(st, id, doc.nodes[id])
2746
+ if (task) pending.push(task)
2747
+ }
2748
+ if (pending.length) await Promise.all(pending)
2749
+
2750
+ // Background `url()` layers can only be placed once their intrinsic size is
2751
+ // known, so they are a second batch rather than part of the first.
2752
+ await resolveBackgroundAssets(st)
2753
+
2754
+ applyDrops(st)
2755
+ // snapdom empties four properties on the clone ROOT so a raster capture is not
2756
+ // asked to draw outside its own bitmap; the adapter puts them back from the
2757
+ // live element because a vector export has somewhere to put them. That is a
2758
+ // repair of the input, so it is stated — a reader comparing this document to
2759
+ // the clone would otherwise find paint the clone does not have.
2760
+ if (Array.isArray(capture.restoredRootPaint) && capture.restoredRootPaint.length) {
2761
+ report(st, doc.root, 'capture.root-paint-restored', 'A',
2762
+ `snapdom strips ${capture.restoredRootPaint.join(', ')} from the capture root (and only from it, ` +
2763
+ '`stripRootShadows`): for a bitmap that paint would bleed past the edge of the image. It is read ' +
2764
+ 'back off the live element and put on the clone before anything is measured, so this document ' +
2765
+ 'carries the root\'s own outer paint instead of silently losing it. It paints OUTSIDE the root box ' +
2766
+ 'in every case, so a backend whose canvas is that box (a standalone SVG, whose viewport is the ' +
2767
+ 'root frame) still clips it; one whose frames can overflow (Figma) draws it.', 'info')
2768
+ }
2769
+ // The mount got the pseudo-suppressor and nothing else: the shadow-scoped
2770
+ // rules could not be cut out of `classCSS`. Everything under a flattened
2771
+ // shadow root is then painted by the page's cascade alone, which does not
2772
+ // reach it — so it is declared here rather than left to be read off `drift`.
2773
+ if (capture.classPrefixExact === false) {
2774
+ report(st, null, 'capture.clone-css-partial', 'A',
2775
+ 'snapdom\'s shadow-scoped rules could not be separated from its per-node style snapshots ' +
2776
+ '(the anchor `classPrefixFrom` cuts on has moved in this build of snapdom), so the mounted ' +
2777
+ 'clone was styled with the pseudo-element suppressor alone. Content that came out of a ' +
2778
+ 'shadow root is laid out and painted without its component\'s own CSS; check `drift` before ' +
2779
+ 'trusting any coordinate in this document.', 'warn')
2780
+ }
2781
+ wireMasks(st)
2782
+ padGradients(st)
2783
+ // Last of the passes that touch the clone, and the only one that RENDERS it.
2784
+ // It runs here rather than in the emitter because the emitter is synchronous
2785
+ // and DOM-free by contract (`svdToSvg`, the oracle backend, has to run in
2786
+ // Node): this is the one place that is both inside the mount and allowed to
2787
+ // wait. After it, the clone is only measured — and shortly after that, gone.
2788
+ if (options.rasterFilters) await rasterizeUnrunFilters(st, capture, handoff || {})
2789
+ // After every text style exists and before `declareFonts`, which then declares
2790
+ // only what is STILL not embedded. Inside the mount like the raster pass above,
2791
+ // because this is the other collector that is allowed to await.
2792
+ if (options.embedFonts !== false) await collectFonts(st, report)
2793
+ declareFonts(st)
2794
+ declareTracking(st)
2795
+ declareDrift(st, drift)
2796
+ declarePseudoGaps(st, nodeMap)
2797
+ declareEverything(st)
2798
+ doc.report = buildReport(st, element, clone, drift, startedAt)
2799
+
2800
+ // The clone is unmounted the moment this returns, and every node holds a
2801
+ // non-enumerable `el` pointing into it. Left in place, a document nobody has
2802
+ // finished with keeps a detached copy of the whole subtree alive. Nothing
2803
+ // downstream reads it — the collectors are the last consumers.
2804
+ // The h2d hybrid needs to go from a clone element to the SVD node that describes it,
2805
+ // and `el` is the only link there is — `source.path` is a selector rebuilt from
2806
+ // classes snapdom generated, which is a string match where an identity is available.
2807
+ // It is handed over HERE because the next statement destroys it.
2808
+ if (elementIndex instanceof Map) {
2809
+ for (const id of Object.keys(doc.nodes)) {
2810
+ const el = doc.nodes[id].el
2811
+ if (el && !elementIndex.has(el)) elementIndex.set(el, id)
2812
+ }
2813
+ }
2814
+
2815
+ for (const id of Object.keys(doc.nodes)) {
2816
+ const node = doc.nodes[id]
2817
+ if (node.el) node.el = null
2818
+ if (node._kids) node._kids = null
2819
+ }
2820
+
2821
+ const result = validate(doc)
2822
+ if (!result.ok) {
2823
+ const shown = result.errors.slice(0, 20)
2824
+ const rest = result.errors.length - shown.length
2825
+ throw new Error(
2826
+ `toVector: the assembled SVD document is invalid (${result.errors.length} error(s)) and ` +
2827
+ 'was not emitted — a document that does not validate is a bug in the engine, not in the page:\n ' +
2828
+ shown.join('\n ') + (rest > 0 ? `\n …and ${rest} more` : '')
2829
+ )
2830
+ }
2831
+
2832
+ if (!options.silent) printDiagnostics(doc)
2833
+ return doc
2834
+ }
2835
+
2836
+ /**
2837
+ * A validated document through the flat SVG backend, which is the oracle the
2838
+ * rest of the pipeline is checked against. No `foreignObject`: text is `<text>`
2839
+ * with one `<tspan>` per measured visual line.
2840
+ *
2841
+ * This is what `toVectorSvg()` is: `toVector()` and then this. Already holding a
2842
+ * document? `svdToSvg(doc)` is exported and walking the clone twice is not free.
2843
+ *
2844
+ * @param {SVDDocument} doc
2845
+ * @param {object} [options]
2846
+ * @param {object} [options]
2847
+ * @param {'universal'|'figma'} [options.target='universal'] who this file is FOR.
2848
+ * Not a style preference — the destinations disagree on things that cannot be
2849
+ * satisfied at once, and one file cannot serve both. Today it decides exactly one
2850
+ * choice, gradient text:
2851
+ *
2852
+ * - `universal` — `<text fill="url(#lg)">`: plain SVG 1.1, an editable and
2853
+ * searchable text layer, correct in every conformant renderer. **Figma's SVG
2854
+ * importer does not resolve the reference and paints it black.**
2855
+ * - `figma` — the same text as a `<mask>` with the gradient painted through it:
2856
+ * the colours arrive in Figma, and the block stops being a text layer there. A
2857
+ * renderer without `<mask>` support paints the rect unmasked and loses the text
2858
+ * entirely, which is why this is not the default.
2859
+ *
2860
+ * **Getting a capture INTO Figma: paste the universal SVG first (product call,
2861
+ * Martin, 2026-08-13).** In practice the universal file pastes better into
2862
+ * today's Figma than the h2d clipboard channel lands, so the default dev flow
2863
+ * is `toVectorSvg()` → paste, accepting the importer's known losses (tracking
2864
+ * dropped, gradient text black unless `target: 'figma'`, @font-face and
2865
+ * textLength ignored — all measured, FIGMA_FINDINGS). `toVectorFigmaClipboard()`
2866
+ * stays for when the editable text layer is worth more than paste fidelity;
2867
+ * it does not go through the importer at all.
2868
+ * @param {boolean} [options.debugPaint=false] tint nodes by stacking-context depth
2869
+ * @param {boolean} [options.textGradientMask] the same choice, per-property. Wins
2870
+ * over `target` when given, so an explicit false still means false.
2871
+ * @returns {string} SVG markup
2872
+ */
2873
+ function svgFrom (doc, options = {}) {
2874
+ const out = svdToSvg(doc, {
2875
+ debugPaint: !!options.debugPaint,
2876
+ // Same destination split as the gradient: the exact chain is more faithful
2877
+ // everywhere and Figma cannot run `feMorphology`, so `target: 'figma'` gives the
2878
+ // single-primitive form back.
2879
+ exactShadows: typeof options.exactShadows === 'boolean'
2880
+ ? options.exactShadows
2881
+ : options.target !== 'figma',
2882
+ // Opt-IN since 2026-08-07: the mask is the Figma-SVG-importer branch, and Figma
2883
+ // has `toVectorFigmaClipboard()` now. See `svdToSvg` for the measurement.
2884
+ textGradientMask: typeof options.textGradientMask === 'boolean'
2885
+ ? options.textGradientMask
2886
+ : options.target === 'figma',
2887
+ })
2888
+ if (typeof out === 'string') return out
2889
+
2890
+ // `svdToSvg` is moving to `{svg, diagnostics}` so its approximations — a conic
2891
+ // fanned into 128 wedges, a gradient stroke sampled on the node box, a line
2892
+ // with no measured width — stop living in an XML comment nobody opens. Both
2893
+ // shapes are accepted while that lands, and the diagnostics, when they come,
2894
+ // are merged into the document and degrade the nodes they name, exactly like
2895
+ // the ones the collectors produce.
2896
+ if (!out || typeof out.svg !== 'string') {
2897
+ throw new Error('toVectorSvg: svdToSvg returned neither markup nor {svg, diagnostics}')
2898
+ }
2899
+ const extra = Array.isArray(out.diagnostics) ? out.diagnostics : []
2900
+ for (const d of extra) {
2901
+ if (!d || typeof d !== 'object') continue
2902
+ doc.diagnostics.push(d)
2903
+ const grade = hasOwn(GRADE_RANK, d.grade) ? d.grade : 'A'
2904
+ if (typeof d.node === 'string') {
2905
+ degradeNode(doc.nodes[d.node], grade, d.message || d.note || d.code)
2906
+ }
2907
+ }
2908
+ if (extra.length && !options.silent) printDiagnostics({ diagnostics: extra })
2909
+ return out.svg
2910
+ }