@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,1147 @@
1
+ /**
2
+ * snapdom Pro — the `h2d` backend: Figma's own clipboard channel, fed from the
3
+ * clone.
4
+ *
5
+ * **What this is.** Figma's official "code to canvas" extension does not convert
6
+ * anything. It writes the DOM — tags, computed styles, boxes — into a clipboard
7
+ * envelope, and FIGMA does the conversion on paste. `FIGMA_FINDINGS.md` §«El canal
8
+ * h2d acepta datos propios» has the decode of a real capture from their extension
9
+ * 1.3.24, and the verified fact this file rests on: **the channel does not check
10
+ * who wrote the payload.** A hand-built one was pasted and imported. `demo/h2d.html`
11
+ * is that probe.
12
+ *
13
+ * **What this channel does NOT buy, corrected 2026-08-07 after pasting it.** This
14
+ * header used to say the payload arrives "with the `letter-spacing` intact", and that
15
+ * it is what the SVG importer throws away. The first half was a guess from the payload
16
+ * carrying the property; the second half was true of the SVG importer and irrelevant.
17
+ * Measured on `fx-type` in real Figma, both by this channel and by their own
18
+ * extension: **the tracking arrives at 0 either way.** Figma does not represent it on
19
+ * import, from any source, which §«Contraste con la extensión OFICIAL» had already
20
+ * established and this file went on contradicting. What the channel actually buys is
21
+ * frames, semantics, editable text runs and the asset carrier the hybrid rides on —
22
+ * not typography.
23
+ *
24
+ * **What this is NOT.** It is not a second SVG emitter and it shares nothing with
25
+ * `emit/svg-flat.js`, which stays the destination-independent path. This one only
26
+ * Figma understands. Illustrator, Penpot, a vector PDF and a sanitised SVG still
27
+ * need the SVD.
28
+ *
29
+ * **The one thing that separates this from the probe: it reads the CLONE.** The
30
+ * probe walks the live DOM, which is the cheap half. Walking snapdom's mounted
31
+ * clone is what makes a `::before` a real element, a shadow tree light DOM, an
32
+ * icon font an `<img>`, and a cross-origin image a data URI — all of them already
33
+ * solved before this file runs (`adapters/snapdom.js`), and none of them
34
+ * reachable from the live tree. The clone's boxes are measured at 0.00px of drift
35
+ * against the live ones, so its geometry IS the page's geometry.
36
+ *
37
+ * **This module is browser-bound on purpose**, unlike `svdToSvg`, which is a pure
38
+ * function of a document so the oracle can run it in Node. There is no
39
+ * intermediate representation here to be pure about: the payload is computed
40
+ * styles and client rects, and both only exist next to a layout engine.
41
+ *
42
+ * ### The format is undocumented, proprietary, and already at `version: 2`
43
+ *
44
+ * Figma can change it in any release, with no version negotiation, no error and no
45
+ * way to detect it — the failure mode is that someone hits ⌘V and nothing appears.
46
+ * A field we shape wrong does not throw either; it is ignored, in silence.
47
+ *
48
+ * **Two captures of their extension have been decoded, and the second one changed
49
+ * this file substantially** (2026-08-07, `FIGMA_FINDINGS.md` §«El payload h2d,
50
+ * decodificado sobre imágenes»). It was taken over `demo/challenges.html` → `#fx-img`,
51
+ * the images fixture, and it carried the three things the first one did not show:
52
+ * a non-empty `assets` map, an `<img>` reduced to `currentSrc`, and a `computedStyles`
53
+ * with something other than `transformOrigin` in it. Everything this module now does
54
+ * with images, with the property filter and with `computedStyles` was measured against
55
+ * that payload — the live subtree was walked side by side with it, at the same layout
56
+ * width, and every candidate rule scored per property over the 26 elements.
57
+ *
58
+ * @module emit/h2d
59
+ */
60
+ import { isExcluded as matchesExclusion } from '../exclusion.js'
61
+
62
+ /** The payload's own version field, as decoded from their extension. */
63
+ export const H2D_VERSION = 2
64
+
65
+ /**
66
+ * The properties their payload can carry per node, camelCase.
67
+ *
68
+ * **This list is transcribed, not designed** — it is the union of what two real
69
+ * captures of their extension contained, and it is deliberately not extended: a
70
+ * property outside it is one we would be guessing Figma reads. The first capture
71
+ * (a card, `#fx-card`) showed 47; the images fixture showed 32 more, among them
72
+ * `objectFit`, `overflow`, `position`, `height`, `backgroundSize`, `background
73
+ * PositionX/Y`, `flexShrink` and the grid templates.
74
+ *
75
+ * That difference is the whole lesson: **a property missing from a capture is not a
76
+ * property their extension refuses to send.** Each node only carries the properties
77
+ * whose value is not the initial one (see `pickStyles`), so a fixture that never
78
+ * uses `object-fit` produces a payload where `objectFit` does not exist. The old
79
+ * version of this file read that absence as a ceiling and declared, on every
80
+ * payload, that `overflow`, `object-fit`, `position`, flex/grid and `height` "cannot
81
+ * arrive". They can, and now they do.
82
+ *
83
+ * Sending all of `getComputedStyle` instead is still not the improvement: it is
84
+ * ~340 properties per node, megabytes on a real page, and it would not tell Figma
85
+ * which of them it is supposed to honour.
86
+ */
87
+ export const H2D_PROPS = [
88
+ 'alignItems',
89
+ 'backgroundAttachment', 'backgroundBlendMode', 'backgroundClip', 'backgroundColor',
90
+ 'backgroundImage', 'backgroundOrigin', 'backgroundPositionX', 'backgroundPositionY',
91
+ 'backgroundRepeat', 'backgroundSize',
92
+ 'borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomLeftRadius', 'borderBottomRightRadius',
93
+ 'borderTopColor', 'borderTopStyle', 'borderTopWidth',
94
+ 'borderRightColor', 'borderRightStyle', 'borderRightWidth',
95
+ 'borderBottomColor', 'borderBottomStyle', 'borderBottomWidth',
96
+ 'borderLeftColor', 'borderLeftStyle', 'borderLeftWidth',
97
+ 'bottom', 'boxShadow', 'boxSizing', 'color',
98
+ 'columnGap', 'columnRuleColor',
99
+ 'display', 'flexDirection', 'flexShrink',
100
+ 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight',
101
+ 'gridTemplateColumns', 'gridTemplateRows',
102
+ 'height', 'justifyContent', 'left', 'letterSpacing', 'lineHeight',
103
+ 'marginBottom', 'marginLeft', 'marginRight', 'marginTop',
104
+ 'maxWidth', 'minHeight', 'minWidth', 'objectFit', 'opacity', 'outlineColor',
105
+ 'overflow', 'overflowX', 'overflowY',
106
+ 'paddingBottom', 'paddingLeft', 'paddingRight', 'paddingTop',
107
+ 'position', 'right', 'rowGap',
108
+ 'textAlign', 'textDecorationColor', 'textDecorationLine', 'textDecorationStyle',
109
+ 'textTransform', 'top', 'transform', 'transformOrigin', 'verticalAlign', 'width',
110
+ ]
111
+
112
+ /**
113
+ * The two properties their serializer sends on EVERY node and this one sends on
114
+ * none.
115
+ *
116
+ * Measured on the images capture: `outlineWidth: "3px"` and `columnRuleWidth:
117
+ * "1.5px"` on all 26 elements, byte-identical, while `getComputedStyle` on the same
118
+ * live elements answers `0px` for both — outline-style and column-rule-style are
119
+ * `none` there, so the used width is zero and nothing is painted. Constant across
120
+ * every node, absent from the page: they are a default table inside their
121
+ * extension leaking into the payload, not information about the document.
122
+ *
123
+ * They are dropped rather than mirrored because the failure is asymmetric. If Figma
124
+ * ignores them, mirroring costs bytes; if Figma ever reads a width without checking
125
+ * its style, mirroring paints a 3px stroke around every layer of the paste.
126
+ */
127
+ const H2D_CONSTANT_JUNK = new Set(['outlineWidth', 'columnRuleWidth'])
128
+
129
+ /**
130
+ * The properties whose Typed OM value their payload repeats in `computedStyles`.
131
+ *
132
+ * NOT the whole list, and the exclusions are measured, not assumed: on the same
133
+ * capture `gridTemplateRows` (resolved `168.398px`, computed `none`) and the
134
+ * avatar's `left`/`top` (resolved `-8.63px`, computed `-16%`) also differ between
135
+ * the two APIs and are NOT in `computedStyles`. What is there is sizing and the
136
+ * transform origin.
137
+ */
138
+ const H2D_TYPED_EXPORT = ['width', 'height', 'transformOrigin', 'gridTemplateColumns']
139
+
140
+ /** Border sides: `{sideProp: styleProp}`. See `pickStyles` for why they are special. */
141
+ const BORDER_SIDES = {
142
+ borderTopColor: 'borderTopStyle', borderTopWidth: 'borderTopStyle',
143
+ borderRightColor: 'borderRightStyle', borderRightWidth: 'borderRightStyle',
144
+ borderBottomColor: 'borderBottomStyle', borderBottomWidth: 'borderBottomStyle',
145
+ borderLeftColor: 'borderLeftStyle', borderLeftWidth: 'borderLeftStyle',
146
+ }
147
+
148
+ /** Empty is empty. A property with no value says nothing that its absence does not. */
149
+ const EMPTY_VALUES = new Set([''])
150
+
151
+ /**
152
+ * Attributes this engine or snapdom stamped on the clone, which describe the
153
+ * CAPTURE and not the page. They are cut so the payload cannot be read as a
154
+ * record of markup the user never wrote.
155
+ */
156
+ const CAPTURE_ATTR_RE = /^data-snapdom/
157
+
158
+ /**
159
+ * Elements whose visible text is not in the DOM at all. Their content is IDL
160
+ * state (`input.value`), a platform-drawn popup (`<option>`) or a UA-rendered
161
+ * bar (`<progress>`), and none of it is a text node anything can walk.
162
+ */
163
+ const CONTROL_TAGS = new Set(['INPUT', 'SELECT', 'TEXTAREA', 'PROGRESS', 'METER'])
164
+
165
+ /** Two decimals is the precision their own payload carries (`y: 123.19`). */
166
+ function r2 (v) { return Math.round(v * 100) / 100 }
167
+
168
+ /**
169
+ * Properties sent on every node no matter what they are worth.
170
+ *
171
+ * Both were measured against their payload rather than reasoned about. `display`
172
+ * arrives as `inline` on the six inline elements of the capture — the initial value,
173
+ * which every other property gets dropped for — and `transformOrigin` arrives on all
174
+ * 26, always the resolved px pair.
175
+ */
176
+ const H2D_ALWAYS = new Set(['display', 'transformOrigin'])
177
+
178
+ /**
179
+ * Properties whose inclusion is decided on the RESOLVED value, not the computed one.
180
+ *
181
+ * Everything else is decided on the Typed OM value, which is the pre-layout computed
182
+ * value and the only one that can tell `width: auto` from `width: 100%`. These four
183
+ * families are where that reading is blind:
184
+ *
185
+ * - insets: a `position: relative` box has `top: auto` computed and `0px` resolved,
186
+ * and their payload carries the `0px`. Reading the computed value drops the four
187
+ * insets on every relatively-positioned node.
188
+ * - grid templates: `grid-template-rows` computes to `none` and resolves to the
189
+ * track list (`168.398px`). Their payload carries the track list.
190
+ */
191
+ const H2D_RESOLVED_DECIDES = new Set([
192
+ 'top', 'right', 'bottom', 'left', 'gridTemplateColumns', 'gridTemplateRows',
193
+ ])
194
+
195
+ /** camelCase -> the dashed name the Typed OM asks for. */
196
+ function dashed (prop) { return prop.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()) }
197
+
198
+ /**
199
+ * The Typed OM (`computedStyleMap`) value of one property, as a string, or `null`.
200
+ *
201
+ * **`getAll` first, `get` as the fallback.** `get` returns only the FIRST value of a
202
+ * list-valued property, so a two-layer background reads back as `scroll` instead of
203
+ * `scroll, scroll` — which is exactly the initial value, and the property would be
204
+ * dropped as "unchanged" on every node that has two backgrounds. Measured: reading
205
+ * with `get` disagreed with their payload on the whole `background-*` family.
206
+ *
207
+ * Typed OM is Chromium-only. A browser without it makes this return `null`, and the
208
+ * filter falls back to the resolved value — which is the pre-2026-08-07 behaviour,
209
+ * degraded but not broken.
210
+ */
211
+ function typedValue (map, prop) {
212
+ if (!map) return null
213
+ const name = dashed(prop)
214
+ try {
215
+ const all = map.getAll(name)
216
+ if (all && all.length) return all.map(String).join(', ')
217
+ } catch { /* not a list-valued property, or not supported */ }
218
+ try {
219
+ const v = map.get(name)
220
+ return v == null ? null : String(v)
221
+ } catch { return null }
222
+ }
223
+
224
+ /** The Typed OM of an element, or null where it does not exist. */
225
+ function styleMapOf (el) {
226
+ try { return el.computedStyleMap ? el.computedStyleMap() : null } catch { return null }
227
+ }
228
+
229
+ /**
230
+ * The initial value of every property in `H2D_PROPS`, read off a real element rather
231
+ * than a table: one `all: initial` div, mounted in the same document so it is laid
232
+ * out (a `display: none` probe answers with computed values, which is a different
233
+ * animal and would break every length).
234
+ *
235
+ * This is what makes the payload small: a property is only sent when its value is
236
+ * NOT the initial one, which is the rule their extension applies and the reason two
237
+ * captures of the same extension showed 47 and 79 properties.
238
+ *
239
+ * @param {Document} doc
240
+ * @returns {{resolved: Object<string,string>, typed: Object<string,string|null>, dispose: Function}}
241
+ */
242
+ function initialBaseline (doc) {
243
+ const resolved = {}; const typed = {}
244
+ let holder = null
245
+ try {
246
+ holder = doc.createElement('div')
247
+ // The holder carries the offscreen placement so the probe itself can be a clean
248
+ // `all: initial` — putting `position: absolute` on the probe blockifies it, and
249
+ // `display` would then read `block` and be dropped on every block element.
250
+ holder.style.cssText = 'position:absolute!important;left:-99999px!important;top:0!important;' +
251
+ 'width:400px!important;height:0!important;overflow:hidden!important;pointer-events:none!important'
252
+ const probe = doc.createElement('div')
253
+ probe.style.cssText = 'all: initial'
254
+ holder.appendChild(probe)
255
+ ;(doc.body || doc.documentElement).appendChild(holder)
256
+ const view = doc.defaultView || (typeof window !== 'undefined' ? window : null)
257
+ const cs = view ? view.getComputedStyle(probe) : null
258
+ const map = styleMapOf(probe)
259
+ for (const prop of H2D_PROPS) {
260
+ resolved[prop] = cs ? cs[prop] : ''
261
+ typed[prop] = typedValue(map, prop)
262
+ }
263
+ } catch { /* no document to probe: every value stays undefined and nothing is dropped */ }
264
+ return { resolved, typed, dispose: () => { try { holder && holder.remove() } catch {} } }
265
+ }
266
+
267
+ /**
268
+ * One element's `styles`: the resolved value of every property that is not at its
269
+ * initial value.
270
+ *
271
+ * Three exceptions, all measured against their payload and each one a bug if it is
272
+ * dropped — see `H2D_ALWAYS`, `H2D_RESOLVED_DECIDES` and `BORDER_SIDES`. A fourth is
273
+ * about the VALUE and not the inclusion: when the computed value is the keyword
274
+ * `auto`, that keyword is what travels, not the used length. Their payload carries
275
+ * `marginLeft: "auto"` for the centred banner, where `getComputedStyle` answers
276
+ * `7.75px` — a number that is the outcome of the centring, and that would pin the
277
+ * image to a fixed offset on the other side.
278
+ *
279
+ * @param {CSSStyleDeclaration} cs resolved styles, off the CLONE
280
+ * @param {StyleMap|null} map Typed OM, off the LIVE element when there is one
281
+ * @param {{resolved:Object,typed:Object}} base
282
+ */
283
+ function pickStyles (cs, map, base) {
284
+ const styles = {}
285
+ for (const prop of H2D_PROPS) {
286
+ if (H2D_CONSTANT_JUNK.has(prop)) continue
287
+ const value = cs[prop]
288
+ if (value == null || EMPTY_VALUES.has(value)) continue
289
+ const sideStyle = BORDER_SIDES[prop]
290
+ // A side with no border still has a colour and a width, and their payload sends
291
+ // neither. Both are `currentColor`/`medium` resolved against a box that paints
292
+ // nothing, so they describe the text colour, not a border anybody drew.
293
+ if (sideStyle && cs[sideStyle] === 'none') continue
294
+
295
+ const typed = H2D_RESOLVED_DECIDES.has(prop) ? null : typedValue(map, prop)
296
+ if (!H2D_ALWAYS.has(prop)) {
297
+ const mine = typed != null ? typed : value
298
+ const initial = typed != null ? base.typed[prop] : base.resolved[prop]
299
+ if (initial !== undefined && mine === initial) continue
300
+ }
301
+ styles[prop] = typed === 'auto' ? 'auto' : normalizeValue(prop, value)
302
+ }
303
+ return styles
304
+ }
305
+
306
+ /**
307
+ * `computedStyles`: the Typed OM value where it says something the resolved one
308
+ * cannot.
309
+ *
310
+ * **This is the fill/hug signal.** `width: 100%` resolves to `229px`, and a payload
311
+ * that only carries the px describes a fixed-width layer; the percentage is what says
312
+ * the box was sized by its parent. Their capture carries exactly four properties this
313
+ * way — `width`, `height`, `transformOrigin` and `gridTemplateColumns` — and NOT the
314
+ * others that also differ between the two APIs (`gridTemplateRows`, and the avatar's
315
+ * `left`/`top` at `-16%`/`-12%`), which is why this list is transcribed and not
316
+ * derived. `auto` is excluded: it differs from the resolved length on almost every
317
+ * node and their payload never carries it.
318
+ *
319
+ * It is read from the LIVE element, never from the clone: snapdom resolves the
320
+ * author's `100%` into a px on the clone, so the clone cannot answer this question.
321
+ */
322
+ function pickComputedStyles (cs, map) {
323
+ const out = {}
324
+ for (const prop of H2D_TYPED_EXPORT) {
325
+ const typed = typedValue(map, prop)
326
+ if (typed == null || typed === 'auto') continue
327
+ if (typed !== cs[prop]) out[prop] = typed
328
+ }
329
+ return out
330
+ }
331
+
332
+ /**
333
+ * The value as their payload writes it.
334
+ *
335
+ * `font-family` is the one that is not verbatim: `getComputedStyle` quotes any family
336
+ * with a space (`"Segoe UI"`) and their payload does not. It is the string Figma
337
+ * matches a font against, so the difference is not cosmetic.
338
+ */
339
+ function normalizeValue (prop, value) {
340
+ if (prop === 'fontFamily') return value.replace(/"/g, '')
341
+ return value
342
+ }
343
+
344
+ /**
345
+ * @typedef {object} H2DResult
346
+ * @property {Array<object>} payload the document array, ready to serialise
347
+ * @property {Array<{code:string,severity:string,message:string}>} diagnostics
348
+ * @property {{nodes:number,textNodes:number,skipped:number,truncated:boolean,
349
+ * fonts:number,dataUriAttrs:number}} stats
350
+ */
351
+
352
+ /**
353
+ * Walk a MOUNTED clone into the h2d payload.
354
+ *
355
+ * Coordinates: every rect is measured on the clone and then re-expressed in the
356
+ * live page's document coordinates — the clone's own root origin is subtracted
357
+ * (which cancels the ~-99999px offscreen mount) and `docOrigin` is added back.
358
+ * The result is byte-identical in meaning to what the probe produced off the live
359
+ * DOM, which is the shape Figma is known to accept.
360
+ *
361
+ * @param {Element} clone the mounted clone root
362
+ * @param {object} opts
363
+ * @param {{x:number,y:number}} opts.docOrigin the LIVE root's document position
364
+ * @param {{width:number,height:number}} opts.rootSize the LIVE root's border box
365
+ * @param {Map<Node,Node>} [opts.nodeMap] clone node -> live node, for the authored
366
+ * sizing the clone no longer has. Without it every node loses `computedStyles`.
367
+ * @param {string} [opts.documentTitle]
368
+ * @param {number} [opts.devicePixelRatio=1]
369
+ * @param {string|Function|Array} [opts.exclude]
370
+ * @param {number} [opts.maxNodes=20000]
371
+ * @returns {H2DResult}
372
+ */
373
+ export function cloneToH2d (clone, opts = {}) {
374
+ const docOrigin = opts.docOrigin || { x: 0, y: 0 }
375
+ const rootSize = opts.rootSize || { width: 0, height: 0 }
376
+ const maxNodes = Number.isFinite(opts.maxNodes) && opts.maxNodes > 0 ? opts.maxNodes : 20000
377
+ /** Clone element -> `data:image/svg+xml` for its paint. @see emit/h2d-vector.js */
378
+ const paintOverrides = opts.paintOverrides instanceof Map ? opts.paintOverrides : null
379
+ const exclude = [].concat(opts.exclude || []).filter((r) => typeof r === 'string' || typeof r === 'function')
380
+
381
+ const cloneRoot = clone.getBoundingClientRect()
382
+ // Clone viewport coords -> live document coords. Both trees are measured
383
+ // against their OWN root, so the offscreen offset cancels; what is left is the
384
+ // 0.00px-drift geometry the mount is verified to reproduce.
385
+ const dx = docOrigin.x - cloneRoot.left
386
+ const dy = docOrigin.y - cloneRoot.top
387
+ const place = (rect) => ({
388
+ x: r2(rect.left + dx), y: r2(rect.top + dy),
389
+ width: r2(rect.width), height: r2(rect.height),
390
+ })
391
+
392
+ const styleCache = new WeakMap()
393
+ const styleOf = (el) => {
394
+ let cs = styleCache.get(el)
395
+ if (!cs) { cs = getComputedStyle(el); styleCache.set(el, cs) }
396
+ return cs
397
+ }
398
+
399
+ const diagnostics = []
400
+ const nodeMap = opts.nodeMap
401
+ const doc = clone.ownerDocument || (typeof document !== 'undefined' ? document : null)
402
+ const base = doc ? initialBaseline(doc) : { resolved: {}, typed: {}, dispose: () => {} }
403
+ const state = {
404
+ n: 0, elements: 0, textNodes: 0, skipped: 0, truncated: false,
405
+ dataUriAttrs: 0, excluded: 0, inlineStyles: 0, fonts: new Map(), controls: new Set(),
406
+ measure: makeMeasurer(), base, mapped: 0, unmapped: 0,
407
+ assets: new Map(), assetBytes: 0, remoteAssets: new Set(), imageRefs: 0, svgAssets: 0,
408
+ textStroke: 0, textStrokeSample: null, vectorPaint: 0, whitespaceNodes: 0,
409
+ liveOf: (el) => (nodeMap && typeof nodeMap.get === 'function' ? nodeMap.get(el) : null),
410
+ }
411
+
412
+ const isExcluded = (el) => matchesExclusion(el, exclude)
413
+
414
+ /**
415
+ * One element. Returns null for an element that generates no box at all —
416
+ * `<style>`, `<script>`, `<defs>` and everything under it, an `<option>` the
417
+ * platform draws itself, a `display:none` subtree. Their rect is the all-zero
418
+ * rect, and a node placed at the document origin with no size is not a
419
+ * degraded version of that element: it is a frame the person pasting has to
420
+ * find and delete.
421
+ */
422
+ const walk = (el) => {
423
+ if (state.n >= maxNodes) { state.truncated = true; return null }
424
+ if (isExcluded(el)) { state.excluded++; return null }
425
+ const rect = el.getBoundingClientRect()
426
+ const boxed = rect.width || rect.height || rect.left || rect.top ||
427
+ (el.getClientRects && el.getClientRects().length > 0)
428
+ if (!boxed) { state.skipped++; return null }
429
+
430
+ if (CONTROL_TAGS.has(el.tagName)) state.controls.add(el.tagName)
431
+ const cs = styleOf(el)
432
+ // The clone answers what the box LOOKS like; the live element answers what the
433
+ // author WROTE. `width: 100%` only exists on the second one — snapdom resolves it
434
+ // into a px on the clone — and it is the difference between a layer that fills its
435
+ // parent in Figma and one pinned to 229px. Nodes snapdom invented (a `::before`
436
+ // made real, a flattened shadow tree) have no live twin and lose only this.
437
+ const live = state.liveOf(el)
438
+ if (live) state.mapped++; else state.unmapped++
439
+ const map = styleMapOf(live || el)
440
+ // An inline <svg> is the one element this channel cannot describe as a box, and
441
+ // the one Figma vectorises when it arrives as an asset (see `inlineSvgDataUri`).
442
+ // The node keeps its place, its size and its styles; only its INSIDE changes hands.
443
+ // The zero-area <svg> is snapdom's own `inline-defs-container`, and any other one
444
+ // paints nothing either: as an asset it would be an empty image, and it is the
445
+ // holder the defs are pulled OUT of, not a drawing.
446
+ let svgUri = el.localName === 'svg' && opts.inlineSvgAsAsset !== false &&
447
+ rect.width >= 1 && rect.height >= 1
448
+ ? inlineSvgDataUri(el, clone)
449
+ : ''
450
+ // The hybrid. `paintOverrides` is filled by `emit/h2d-vector.js` with the nodes
451
+ // whose paint this channel cannot spell at all — a conic gradient, a filter, a
452
+ // clip-path — drawn as vector by the SVD backend. It wins over the inline-<svg>
453
+ // rule because a filtered <svg> needs the filter, not the markup.
454
+ const override = paintOverrides && paintOverrides.get ? paintOverrides.get(el) : null
455
+ if (override) { svgUri = override; state.vectorPaint++ }
456
+ const node = {
457
+ nodeType: 1,
458
+ id: `h2d-node-${++state.n}`,
459
+ tag: svgUri ? 'IMG' : el.tagName,
460
+ attributes: svgUri ? svgImgAttributes(el, rect, svgUri) : attributesOf(el, state),
461
+ styles: pickStyles(cs, map, state.base),
462
+ rect: place(rect),
463
+ childNodes: [],
464
+ computedStyles: pickComputedStyles(cs, map),
465
+ }
466
+ collectAssets(el, node, state)
467
+ state.elements++
468
+ // Its children are the asset's contents now. Sending them as well would paint the
469
+ // drawing twice — once as vectors and once as the empty frames this replaced.
470
+ if (svgUri) { if (!override) state.svgAssets++; return node }
471
+
472
+ let sawText = false
473
+ for (const child of el.childNodes) {
474
+ if (child.nodeType === 3) {
475
+ const t = textNode(child, state, place, maxNodes)
476
+ if (t) { node.childNodes.push(t); sawText = true }
477
+ } else if (child.nodeType === 1) {
478
+ const sub = walk(child)
479
+ if (sub) node.childNodes.push(sub)
480
+ }
481
+ }
482
+ // Fonts are collected only where text actually is. A family listed for a
483
+ // wrapper that paints no glyph is a font Figma may go looking for.
484
+ if (sawText) {
485
+ collectFont(state, cs)
486
+ // `-webkit-text-stroke` is outside the property list and cannot be added to it:
487
+ // it is not in their capture, so what Figma does with it is unknown and sending
488
+ // it is guessing. What is NOT acceptable is losing it in silence — an outlined
489
+ // headline whose fill was chosen to be invisible without the stroke arrives as
490
+ // blank space, and no other diagnostic here would mention it.
491
+ const sw = parseFloat(cs.webkitTextStrokeWidth || cs.WebkitTextStrokeWidth || '0')
492
+ if (sw > 0) {
493
+ state.textStroke++
494
+ if (!state.textStrokeSample) {
495
+ state.textStrokeSample = { fill: cs.color, stroke: cs.webkitTextStrokeColor || '', width: `${sw}px` }
496
+ }
497
+ }
498
+ }
499
+ return node
500
+ }
501
+
502
+ // `finally`, for the same reason the clone is unmounted in one: the baseline is a
503
+ // real element in the user's document, and a throw between here and its removal
504
+ // would leave it there.
505
+ let root
506
+ try { root = walk(clone) } finally { base.dispose() }
507
+ if (!root) {
508
+ throw new Error(
509
+ 'toVectorFigmaClipboard: the clone root generates no box, so there is nothing to paste. ' +
510
+ 'This is the same condition `toVector` refuses on, reached one step later.'
511
+ )
512
+ }
513
+
514
+ const payload = [{
515
+ documentTitle: opts.documentTitle || '',
516
+ root,
517
+ // As in the probe: the capture's own box at the origin, while the node rects
518
+ // are document coordinates. The two disagree, and that is the shape that was
519
+ // verified to import — Figma places the tree from `root.rect`. Left alone
520
+ // rather than "fixed" into a shape nobody has pasted.
521
+ documentRect: { x: 0, y: 0, width: Math.round(rootSize.width), height: Math.round(rootSize.height) },
522
+ viewportRect: { x: 0, y: 0, width: rootSize.width, height: rootSize.height },
523
+ devicePixelRatio: Number.isFinite(opts.devicePixelRatio) ? opts.devicePixelRatio : 1,
524
+ version: H2D_VERSION,
525
+ assets: Object.fromEntries(state.assets),
526
+ fonts: Object.fromEntries(state.fonts),
527
+ }]
528
+
529
+ declare(diagnostics, state, payload[0])
530
+
531
+ return {
532
+ payload,
533
+ diagnostics,
534
+ stats: {
535
+ nodes: state.elements,
536
+ textNodes: state.textNodes,
537
+ skipped: state.skipped,
538
+ excluded: state.excluded,
539
+ truncated: state.truncated,
540
+ fonts: state.fonts.size,
541
+ dataUriAttrs: state.dataUriAttrs,
542
+ inlineStyles: state.inlineStyles,
543
+ assets: state.assets.size,
544
+ svgAssets: state.svgAssets,
545
+ vectorPaint: state.vectorPaint,
546
+ whitespaceNodes: state.whitespaceNodes,
547
+ assetBytes: state.assetBytes,
548
+ mapped: state.mapped,
549
+ unmapped: state.unmapped,
550
+ },
551
+ }
552
+ }
553
+
554
+ /**
555
+ * A text node, with its own box. Measured with a `Range`, like their payload:
556
+ * a text node is not an element and has no `getBoundingClientRect` of its own,
557
+ * and `lineCount` is the number of client rects the range produces — one per
558
+ * visual line, which is how Figma knows a paragraph wrapped.
559
+ *
560
+ * The whitespace test is on the RECTS, not on `trim()`: the single space between
561
+ * two inline elements is a real box the page paints, while the newline and
562
+ * indentation between two block tags collapse to nothing. Trimming drops both,
563
+ * and the first one closes up a gap the page has.
564
+ */
565
+ function textNode (child, state, place, maxNodes) {
566
+ if (state.n >= maxNodes) { state.truncated = true; return null }
567
+ const text = child.textContent
568
+ if (!text) return null
569
+ const range = child.ownerDocument.createRange()
570
+ range.selectNodeContents(child)
571
+ const rects = [...range.getClientRects()]
572
+ const painted = rects.some((r) => r.width > 0 && r.height > 0)
573
+ if (!painted) {
574
+ // Whitespace between two block elements paints nothing, and this backend used to
575
+ // drop it. Their extension does NOT: measured on the images capture, their payload
576
+ // carries 42 text nodes where ours carried 16, and the 26 extra are exactly these —
577
+ // `"\n "` with an all-zero rect and `lineCount: 0`. It was the last structural
578
+ // difference left between the two payloads, and a consumer that assembles a
579
+ // paragraph out of child nodes is a consumer whose input we should not be editing.
580
+ // Kept as its own branch, and out of `textNodes`, because it is not text anyone
581
+ // reads — it is shape.
582
+ if (!text.trim()) {
583
+ state.whitespaceNodes++
584
+ return { nodeType: 3, id: `h2d-node-${++state.n}`, text, rect: { x: 0, y: 0, width: 0, height: 0 }, lineCount: 0 }
585
+ }
586
+ state.skipped++
587
+ return null
588
+ }
589
+ state.textNodes++
590
+ return {
591
+ nodeType: 3,
592
+ id: `h2d-node-${++state.n}`,
593
+ text,
594
+ rect: place(range.getBoundingClientRect()),
595
+ lineCount: rects.length,
596
+ }
597
+ }
598
+
599
+ /**
600
+ * The URL an `<img>` actually painted, and the key its pixels are filed under.
601
+ *
602
+ * `currentSrc` is an IDL property, not an attribute — it does not appear in
603
+ * `el.attributes` and no amount of copying markup produces it. Their payload puts it
604
+ * INTO the attributes map, in place of `src`, and files the bytes in `assets` under
605
+ * that same string. It is also the only correct answer for `srcset`: the browser
606
+ * already chose a candidate against the viewport and the DPR, and `src`/`srcset`
607
+ * describe the choice rather than the result.
608
+ *
609
+ * **It asks the LIVE element first, and that is not a detail.** snapdom re-rasterises
610
+ * raster images while cloning, at something near their painted size: measured on the
611
+ * images fixture (2026-08-07), a 720×480 PNG that the page holds as a data URI comes
612
+ * back off the clone at 435 px wide — the same picture, 63 kB instead of 77 kB, and
613
+ * 40% of the pixels gone. That is the right trade for snapdom, whose output is a
614
+ * bitmap of a known size. It is the wrong one here: the paste is meant to be EDITED,
615
+ * and the first person to scale that layer up in Figma is looking at a blur. Their
616
+ * extension ships the full 720×480 because it reads the live DOM and never had a
617
+ * downscaled copy to choose from.
618
+ *
619
+ * The live source is preferred only when it is a `data:` URI, i.e. only when it is
620
+ * bytes this file can actually use. When the page points at a remote URL that snapdom
621
+ * fetched and inlined, the clone's copy is the ONLY reachable one — `addAsset` is
622
+ * synchronous and cannot go fetch anything — so in that case the downscale is kept
623
+ * rather than trading real pixels for a URL Figma may not be allowed to load.
624
+ */
625
+ function imageSource (el, state) {
626
+ if (el.tagName !== 'IMG') return ''
627
+ const own = (() => {
628
+ try { return el.currentSrc || el.getAttribute('src') || '' } catch { return '' }
629
+ })()
630
+ const live = state && state.liveOf ? state.liveOf(el) : null
631
+ if (live && live.tagName === 'IMG') {
632
+ try {
633
+ const src = live.currentSrc || live.getAttribute('src') || ''
634
+ if (src.startsWith('data:')) return src
635
+ } catch { /* a cross-origin or detached live node just leaves `own` in place */ }
636
+ }
637
+ return own
638
+ }
639
+
640
+ /**
641
+ * The element's attributes, minus the capture's own bookkeeping.
642
+ *
643
+ * They travel because this is the channel's whole idea — Figma reads the DOM. Their
644
+ * extension is stricter than this (its capture carries `id`, `alt` and `currentSrc`
645
+ * and drops even `class`), and the one place that strictness is copied is the image
646
+ * candidates: `src`, `srcset` and `sizes` are cut on an `<img>` because snapdom has
647
+ * already inlined the pixels into `src` as a data URI, and keeping it would put the
648
+ * same bytes in the payload a fourth time — they already travel as `currentSrc`, as
649
+ * the `assets` key, and as the blob.
650
+ */
651
+ function attributesOf (el, state) {
652
+ const out = {}
653
+ const src = imageSource(el, state)
654
+ if (src) out.currentSrc = src
655
+ if (!el.attributes) return out
656
+ for (const attr of el.attributes) {
657
+ if (CAPTURE_ATTR_RE.test(attr.name)) continue
658
+ if (attr.name === 'data-vector-exclude') continue
659
+ if (src && (attr.name === 'src' || attr.name === 'srcset' || attr.name === 'sizes')) continue
660
+ // `style` is CUT, and it is the one attribute that is not the page's.
661
+ // snapdom writes its own normalisation onto every clone node — measured on
662
+ // `demo/vector.html`'s fx-card, 18 of 18 nodes carry
663
+ // `margin:0;inset:auto;animation:…;transition:none;will-change:auto;float:none;
664
+ // clear:none;box-shadow:…;filter:none;background-position:…`, 4 727 of the
665
+ // payload's 31 094 bytes. Nothing is lost by dropping it: every declaration in
666
+ // it is already resolved into the computed values in `styles`, which is the
667
+ // channel Figma's own payload uses. Passing it on would put a reset
668
+ // (`margin:0`, `float:none`) next to those values for an importer that may
669
+ // read either, and would describe the user's markup as something it is not.
670
+ if (attr.name === 'style') { state.inlineStyles++; continue }
671
+ out[attr.name] = attr.value
672
+ if (attr.value.startsWith('data:')) state.dataUriAttrs++
673
+ }
674
+ return out
675
+ }
676
+
677
+ /**
678
+ * Every `url(...)` in a value, unquoted.
679
+ *
680
+ * Hand-scanned instead of `/url\((['"]?)(.*?)\1\)/`, because a regex like that reads
681
+ * an inlined SVG wrong: snapdom's `data:image/svg+xml,…` payloads contain `url(%23t)`
682
+ * for their own gradient references, so the first `)` inside the data URI ends the
683
+ * match early and the engine files a fragment — or, worse, matches the INNER `url(`
684
+ * and reports `%23t` as an image it could not fetch. Measured on `#im-hero`, whose
685
+ * two-layer background produced four regex hits and one usable URL.
686
+ */
687
+ function urlsIn (value) {
688
+ const out = []
689
+ let i = 0
690
+ while ((i = value.indexOf('url(', i)) !== -1) {
691
+ let j = i + 4
692
+ while (j < value.length && /\s/.test(value[j])) j++
693
+ const quote = value[j] === '"' || value[j] === "'" ? value[j] : ''
694
+ if (quote) {
695
+ const end = value.indexOf(quote, j + 1)
696
+ if (end === -1) break
697
+ out.push(value.slice(j + 1, end))
698
+ i = end + 1
699
+ } else {
700
+ // Unquoted: balance the parens, so the `url(%23t)` inside an inlined SVG closes
701
+ // its own bracket instead of this one.
702
+ let depth = 1; let k = j
703
+ for (; k < value.length; k++) {
704
+ if (value[k] === '(') depth++
705
+ else if (value[k] === ')' && --depth === 0) break
706
+ }
707
+ out.push(value.slice(j, k).trim())
708
+ i = k + 1
709
+ }
710
+ }
711
+ return out
712
+ }
713
+
714
+ /**
715
+ * Every image this node paints, into the payload's `assets` map.
716
+ *
717
+ * **The shape is no longer a guess.** A capture of their extension over the images
718
+ * fixture (2026-08-07) carries it, and it is a flat map keyed by the URL string
719
+ * exactly as the node spells it — `attributes.currentSrc` for an `<img>`, the inside
720
+ * of `url(...)` for a background:
721
+ *
722
+ * ```json
723
+ * "assets": { "<url>": { "url": "<the same url>",
724
+ * "blob": { "type": "image/png", "base64Blob": "data:application/octet-stream;base64,…" } } }
725
+ * ```
726
+ *
727
+ * `base64Blob` is the RAW bytes, base64'd, under a generic media type: for a
728
+ * `data:image/png;base64,…` source it is the same base64 back, and for a
729
+ * percent-encoded SVG it is the decoded markup. Verified byte for byte on all four
730
+ * assets of that capture. Two `<img>`s pointing at one URL share one entry.
731
+ *
732
+ * The cost is real and worth saying out loud: with snapdom having inlined everything,
733
+ * one 77 kB PNG travels three times in this payload — the `currentSrc`, the key, and
734
+ * the blob. Their own capture is 560 kB of JSON for a 713×420 box, and 309 kB of that
735
+ * is one photograph repeated.
736
+ */
737
+ /**
738
+ * An inline `<svg>` re-carried as an `image/svg+xml` asset instead of as a tree of
739
+ * CSS boxes.
740
+ *
741
+ * **Measured in Figma, 2026-08-07, `demo/hybrid-probe.html` pasted three ways.** Sent
742
+ * the way every other element is sent — `svg`, `circle` and `path` as nodes with their
743
+ * attributes and their rects — Figma imports an EMPTY FRAME. Their own extension gets
744
+ * the same drawing to arrive, and an SVG asset of ours arrives too, **as vectors**:
745
+ * paths inside a group, not an image fill. So the fix is not to send the subtree
746
+ * better, it is to stop sending it as a subtree.
747
+ *
748
+ * The `<defs>` pass is DEFENSIVE, not measured. snapdom keeps an `inline-defs-container`
749
+ * of its own next to the drawing, so a paint server can end up outside the `<svg>` that
750
+ * references it, and serialising the element alone would then produce markup whose
751
+ * `fill="url(#hg2)"` points at nothing. On the probe it did not happen — the gradient
752
+ * stayed inside and resolved on its own — so what is verified is that the pass costs
753
+ * nothing when there is nothing to fix, not that it rescues a case that was observed.
754
+ * Ids are resolved out of the clone transitively, because a gradient can reference
755
+ * another, and capped, because a circular reference must not hang a clipboard write.
756
+ *
757
+ * @param {Element} el the `<svg>` on the clone
758
+ * @param {Node} root the clone, where the hoisted defs live
759
+ * @returns {string} a `data:image/svg+xml` URI, or '' if it could not be serialised
760
+ */
761
+ function inlineSvgDataUri (el, root) {
762
+ let markup = ''
763
+ try { markup = new XMLSerializer().serializeToString(el) } catch { return '' }
764
+ if (!markup.startsWith('<svg')) return ''
765
+
766
+ const REFS = /url\(#([^)"']+)\)|(?:xlink:)?href="#([^"]+)"/g
767
+ const defs = []
768
+ const taken = new Set()
769
+ // Transitive, capped: a gradient can reference a gradient. Three passes is well past
770
+ // anything a page produces, and a cap is what keeps a circular reference from hanging
771
+ // a clipboard write.
772
+ for (let pass = 0; pass < 3; pass++) {
773
+ const pending = []
774
+ const scope = markup + defs.join('')
775
+ for (const m of scope.matchAll(REFS)) {
776
+ const id = m[1] || m[2]
777
+ if (!id || taken.has(id)) continue
778
+ if (new RegExp(`id="${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`).test(scope)) continue
779
+ pending.push(id)
780
+ }
781
+ if (!pending.length) break
782
+ for (const id of pending) {
783
+ taken.add(id)
784
+ let found = null
785
+ const sel = `[id="${typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(id) : id}"]`
786
+ try { found = (root && root.querySelector && root.querySelector(sel)) || null } catch { found = null }
787
+ if (!found) { try { found = el.ownerDocument.getElementById(id) } catch { /* detached */ } }
788
+ if (found) { try { defs.push(new XMLSerializer().serializeToString(found)) } catch { /* skip */ } }
789
+ }
790
+ }
791
+ if (defs.length) markup = markup.replace(/^(<svg\b[^>]*>)/, `$1<defs>${defs.join('')}</defs>`)
792
+
793
+ if (!/\sxmlns=/.test(markup)) {
794
+ markup = markup.replace(/^<svg\b/, '<svg xmlns="http://www.w3.org/2000/svg"')
795
+ }
796
+ // Without an intrinsic size a data URI in an <img> is rendered at the UA default,
797
+ // not at the box it occupied on the page.
798
+ if (!/\swidth=/.test(markup.slice(0, markup.indexOf('>')))) {
799
+ const r = el.getBoundingClientRect()
800
+ markup = markup.replace(/^<svg\b/, `<svg width="${Math.round(r.width)}" height="${Math.round(r.height)}"`)
801
+ }
802
+ return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(markup)
803
+ }
804
+
805
+ /**
806
+ * The `<img>` attributes for an inline `<svg>` carried as an asset. Same shape their
807
+ * extension gives a real image — `currentSrc` and not `src` — so nothing downstream
808
+ * has to know this node was something else on the page.
809
+ */
810
+ function svgImgAttributes (el, rect, uri) {
811
+ const attrs = { currentSrc: uri, width: String(Math.round(rect.width)), height: String(Math.round(rect.height)) }
812
+ for (const name of ['id', 'class', 'role']) {
813
+ const v = el.getAttribute && el.getAttribute(name)
814
+ if (v) attrs[name] = v
815
+ }
816
+ const label = el.getAttribute && (el.getAttribute('aria-label') || el.getAttribute('title'))
817
+ if (label) attrs.alt = label
818
+ return attrs
819
+ }
820
+
821
+ function collectAssets (el, node, state) {
822
+ const src = node.attributes && node.attributes.currentSrc
823
+ if (src) {
824
+ state.imageRefs++
825
+ if (src.startsWith('data:')) state.dataUriAttrs++
826
+ addAsset(state, src)
827
+ }
828
+ const bg = node.styles && node.styles.backgroundImage
829
+ if (bg && bg.includes('url(')) {
830
+ for (const url of urlsIn(bg)) { state.imageRefs++; addAsset(state, url) }
831
+ }
832
+ }
833
+
834
+ /**
835
+ * One entry, or a recorded reason there is none.
836
+ *
837
+ * A URL that is not a data URI cannot be turned into bytes from here: this function
838
+ * is synchronous, the payload has to be on the clipboard inside the user gesture that
839
+ * asked for it, and fetching would need both a round trip and CORS permission the page
840
+ * may not have. It should not happen — snapdom inlines every image it can reach before
841
+ * this backend runs — so when it does it is recorded and declared rather than silently
842
+ * shipped as a live URL that Figma may or may not go and fetch.
843
+ */
844
+ function addAsset (state, url) {
845
+ if (!url || state.assets.has(url)) return
846
+ if (!url.startsWith('data:')) { state.remoteAssets.add(url.slice(0, 120)); return }
847
+ const comma = url.indexOf(',')
848
+ if (comma < 0) return
849
+ const head = url.slice(5, comma)
850
+ const body = url.slice(comma + 1)
851
+ const isB64 = /;base64$/i.test(head)
852
+ const type = head.replace(/;base64$/i, '').split(';')[0] || 'application/octet-stream'
853
+ let base64
854
+ try {
855
+ base64 = isB64 ? body.replace(/\s+/g, '') : b64(decodeURIComponent(body))
856
+ } catch { return }
857
+ state.assets.set(url, {
858
+ url,
859
+ blob: { type, base64Blob: `data:application/octet-stream;base64,${base64}` },
860
+ })
861
+ state.assetBytes += base64.length
862
+ }
863
+
864
+ /**
865
+ * `fontBoundingBoxAscent/Descent` per exact font string, cached on that string
866
+ * and never on the family: Chromium rounds those to integers per size, so an
867
+ * ascent scaled from another size can be a whole pixel out. Same rule as
868
+ * `makeCtx.measureFont` in `index.js`.
869
+ */
870
+ function makeMeasurer () {
871
+ const cache = new Map()
872
+ let ctx = null
873
+ try { ctx = document.createElement('canvas').getContext('2d') } catch { ctx = null }
874
+ return (fontCss) => {
875
+ if (cache.has(fontCss)) return cache.get(fontCss)
876
+ let out = null
877
+ if (ctx) {
878
+ ctx.font = fontCss
879
+ // A shorthand the context refuses leaves the PREVIOUS font in place, and
880
+ // the numbers would then describe some other face.
881
+ if (ctx.font) {
882
+ const m = ctx.measureText('Hxg')
883
+ if (Number.isFinite(m.fontBoundingBoxAscent)) {
884
+ out = {
885
+ fontBoundingBoxAscent: Math.round(m.fontBoundingBoxAscent),
886
+ fontBoundingBoxDescent: Math.round(m.fontBoundingBoxDescent),
887
+ }
888
+ }
889
+ }
890
+ }
891
+ cache.set(fontCss, out)
892
+ return out
893
+ }
894
+ }
895
+
896
+ /**
897
+ * One element's typography into the `fonts` map: family -> `{familyName, faces,
898
+ * usages}`, one usage per distinct size/weight/style/stretch.
899
+ *
900
+ * `faces` is empty and stays empty: this engine embeds no font bytes on any
901
+ * backend. The family travels by NAME and the destination substitutes what it
902
+ * has — the same trade `assets.font-not-embedded` declares on the SVD path.
903
+ */
904
+ function collectFont (state, cs) {
905
+ const stack = cs.fontFamily
906
+ if (!stack) return
907
+ const key = stack.split(',')[0].replace(/["']/g, '').trim()
908
+ if (!key) return
909
+ let entry = state.fonts.get(key)
910
+ if (!entry) { entry = { familyName: key, faces: [], usages: [] }; state.fonts.set(key, entry) }
911
+ const usage = {
912
+ fontWeight: cs.fontWeight, fontStyle: cs.fontStyle,
913
+ fontStretch: cs.fontStretch, fontSize: cs.fontSize,
914
+ }
915
+ for (const u of entry.usages) {
916
+ if (u.fontSize === usage.fontSize && u.fontWeight === usage.fontWeight &&
917
+ u.fontStyle === usage.fontStyle && u.fontStretch === usage.fontStretch) return
918
+ }
919
+ // Measured against the STACK, not the family alone: the stack is what the page
920
+ // laid out with, and the first family may not be the one that won.
921
+ const metrics = state.measure(`${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${stack}`)
922
+ if (metrics) usage.metrics = metrics
923
+ entry.usages.push(usage)
924
+ }
925
+
926
+ /**
927
+ * What this payload does not carry. Every one of these is a silent loss on the
928
+ * other side — an undocumented format does not reject a field, it ignores it —
929
+ * so the only defence is saying it here, on every capture.
930
+ */
931
+ export const H2D_ASSETS_NOTE =
932
+ '`assets` is filled from a DECODED capture of their extension (2026-08-07, the images fixture), not ' +
933
+ 'from documentation: a flat map keyed by the URL the node spells, each entry `{url, blob:{type, ' +
934
+ 'base64Blob}}` with the raw bytes base64\'d under `data:application/octet-stream;base64,`. Verified ' +
935
+ 'byte for byte against their four assets. What is NOT verified is what Figma does with it — whether ' +
936
+ 'it reads pixels from here, from `currentSrc`, or from either — because nobody has pasted a payload ' +
937
+ 'with one and not the other. The bytes are the page\'s own whenever the live node still spells a ' +
938
+ 'data URI (`imageSource`, via `nodeMap`): snapdom re-rasterises while cloning — the same fixture\'s ' +
939
+ '720x480 PNG comes off the clone at 435x290, right for a bitmap export and wrong for a paste meant ' +
940
+ 'to be edited. Where the page points at a remote URL snapdom inlined, the clone\'s downscaled copy ' +
941
+ 'is the only one reachable from a synchronous clipboard write, and that one IS below source size.'
942
+
943
+ function declare (diagnostics, state, doc) {
944
+ const push = (code, severity, message) => diagnostics.push({ code, severity, message })
945
+
946
+ push('h2d.proprietary-format', 'warn',
947
+ 'This payload is Figma\'s own clipboard format: proprietary, undocumented, already at ' +
948
+ `version ${H2D_VERSION}, and reverse-engineered from ONE decoded capture of their extension ` +
949
+ '(FIGMA_FINDINGS.md, «El canal h2d acepta datos propios»). Figma can change it in any release ' +
950
+ 'with no version negotiation and no error: the only symptom is that a paste does nothing. ' +
951
+ 'It is also Figma-only — no other tool opens it, which is why the SVG backend is not retired.')
952
+
953
+ // Only when this capture HAS images. "No assets" on a fixture with no image is not a
954
+ // finding, and a warning that fires on every text-only capture is a warning nobody reads.
955
+ if (state.imageRefs) {
956
+ push('h2d.assets', state.assets.size ? 'info' : 'warn', H2D_ASSETS_NOTE +
957
+ (state.assets.size
958
+ ? ` This payload carries ${state.assets.size} asset(s), ${Math.round(state.assetBytes / 1024)} kB of ` +
959
+ 'base64. Each one is in here THREE times — the node\'s `currentSrc` or `url(…)`, the map key, and ' +
960
+ 'the blob — because that is the shape their extension produces.'
961
+ : ` ${state.imageRefs} image reference(s) in this capture produced NO asset, so nothing in it can ` +
962
+ 'arrive from that side.'))
963
+ }
964
+
965
+ if (state.textStroke) {
966
+ const s = state.textStrokeSample || {}
967
+ push('h2d.text-stroke-dropped', 'warn',
968
+ `${state.textStroke} text block(s) paint their glyphs with \`-webkit-text-stroke\`, which this ` +
969
+ 'channel does not send: it is not in their extension\'s capture either, so what Figma reads is ' +
970
+ 'unknown and sending it would be a guess. The glyphs arrive with their FILL only. Where the fill ' +
971
+ 'was chosen on the assumption that the outline is doing the work, the text arrives unreadable — ' +
972
+ `measured on \`demo/challenges.html\` → fx-type: fill ${s.fill}, outline ${s.width} ${s.stroke}, ` +
973
+ 'over a white card, so that line pastes as blank space. This is the one place in this payload ' +
974
+ 'where text can vanish without the box vanishing with it.')
975
+ }
976
+
977
+ if (state.svgAssets) {
978
+ push('h2d.inline-svg-as-asset', 'info',
979
+ `${state.svgAssets} inline <svg> element(s) were re-carried as image/svg+xml assets instead of as ` +
980
+ 'trees of CSS boxes. Measured in Figma (2026-08-07, demo/hybrid-probe.html): sent as nodes the ' +
981
+ 'drawing imports as an EMPTY FRAME — a <path> has no CSS box for the channel to describe — and ' +
982
+ 'sent as an asset it imports AS VECTORS, paths in a group, not an image fill. Their own extension ' +
983
+ 'gets the drawing to arrive too, so this is parity, not an extra. What is lost is the DOM shape: ' +
984
+ 'the individual <path>/<circle> are no longer nodes in this payload, and any CSS the page applied ' +
985
+ 'to them from outside the <svg> is only in the asset if it reached the clone as a presentation ' +
986
+ 'attribute. Turn it off with `inlineSvgAsAsset: false` to get the old empty frames back.')
987
+ }
988
+
989
+ push('h2d.style-subset', 'info',
990
+ `Each node carries, of the ${H2D_PROPS.length} properties transcribed from two captures of their ` +
991
+ 'extension, the ones whose value is not the initial one — the same filter theirs applies, which is ' +
992
+ 'why a property missing from a node is a property that was at its default. `outline-width` and ' +
993
+ '`column-rule-width` are the two this engine deliberately does NOT send: their serializer emits a ' +
994
+ 'constant 3px/1.5px on every node while the page resolves both to 0px, and a width with no style is ' +
995
+ 'a stroke waiting to be drawn. Anything outside the list — `mix-blend-mode`, `filter`, `z-index`, ' +
996
+ '`gap` shorthands — is not sent, because sending it means guessing what Figma reads.')
997
+
998
+ if (state.unmapped) {
999
+ push('h2d.unmapped-nodes', 'info',
1000
+ `${state.unmapped} of ${state.unmapped + state.mapped} node(s) exist only on the clone — a ` +
1001
+ 'pseudo-element made real, a flattened shadow tree, an icon font turned into an <img> — so there ' +
1002
+ 'is no live element to read the AUTHORED sizing off. Their `styles` are complete; what they lose ' +
1003
+ 'is `computedStyles`, the `width: 100%` that tells Figma the layer fills its parent instead of ' +
1004
+ 'being pinned to the px it happens to measure.')
1005
+ }
1006
+
1007
+ if (state.remoteAssets.size) {
1008
+ push('h2d.remote-assets', 'warn',
1009
+ `${state.remoteAssets.size} image URL(s) are not data URIs and could not be turned into bytes ` +
1010
+ 'from inside a synchronous clipboard write, so they are NOT in `assets`: ' +
1011
+ `${[...state.remoteAssets].slice(0, 3).join(', ')}. snapdom inlines every image it can reach, so ` +
1012
+ 'this means one it could not — a CORS-blocked host, or a source added after the clone was taken.')
1013
+ }
1014
+
1015
+ push('h2d.no-variable-bindings', 'info',
1016
+ 'Their payload can carry `variableStyles` (a property -> `--custom-property` map) and this one ' +
1017
+ 'never does. snapdom RESOLVES custom properties into the clone (`resolveCSSVars`, clone.js:348) — ' +
1018
+ 'which is what makes every value here final and correct — and a computed value cannot be traced ' +
1019
+ 'back to the variable that produced it. Colours and sizes arrive right; they arrive unbound, so ' +
1020
+ 'they will not follow a Figma variable afterwards.')
1021
+
1022
+ push('h2d.fonts-by-name', 'info',
1023
+ `${state.fonts.size} font ${state.fonts.size === 1 ? 'family' : 'families'} travel by NAME with ` +
1024
+ 'their canvas metrics and an empty `faces` array: no backend of this engine embeds font bytes. ' +
1025
+ '`styles.fontFamily` carries the whole CSS stack, exactly as their own extension sends it, and ' +
1026
+ 'which face Figma resolves out of it is Figma\'s decision (FIGMA_FINDINGS.md §5 measured the SVG ' +
1027
+ 'importer falling to Inter; this channel is not that importer and was not measured the same way).')
1028
+
1029
+ if (state.skipped) {
1030
+ push('h2d.boxless-nodes-skipped', 'info',
1031
+ `${state.skipped} node(s) generate no box on the clone and were left out: <style>, <script>, SVG ` +
1032
+ 'paint-server elements, an <option> the platform draws, a display:none subtree. An all-zero rect ' +
1033
+ 'is not a degraded box — it would land as a sizeless frame at the document origin.')
1034
+ }
1035
+ if (state.excluded) {
1036
+ push('h2d.excluded', 'info',
1037
+ `${state.excluded} subtree(s) were dropped by \`exclude\` or by a capture-exclude attribute, the ` +
1038
+ 'same rules `paint.js` applies on the SVD path.')
1039
+ }
1040
+ if (state.truncated) {
1041
+ push('h2d.node-budget', 'warn',
1042
+ `The walk hit its \`maxNodes\` ceiling and STOPPED: this payload is a prefix of the subtree, not ` +
1043
+ 'all of it. Raise `maxNodes` or capture a smaller element.')
1044
+ }
1045
+ if (state.controls.size) {
1046
+ push('h2d.form-control-text', 'warn',
1047
+ `This capture contains ${[...state.controls].join(', ')}, and the TEXT a form control shows is ` +
1048
+ 'not in the DOM: an input\'s value and placeholder are IDL state, an `<option>` is drawn by the ' +
1049
+ 'platform\'s own popup, a `<progress>` is drawn by the UA. Measured on `demo/challenges.html` → ' +
1050
+ 'fx-form: 13 words of the live element\'s textContent — the whole option list of its <select> — ' +
1051
+ 'are not in this payload, because those <option>s generate no box and were skipped. The control ' +
1052
+ 'boxes arrive; they arrive EMPTY. It is the same hole the SVD path has (`collect.box.form-control`).')
1053
+ }
1054
+ if (state.inlineStyles) {
1055
+ push('h2d.inline-style-dropped', 'info',
1056
+ `The \`style\` attribute was cut from ${state.inlineStyles} node(s): on the clone it is snapdom's ` +
1057
+ 'own normalisation, not the page\'s markup, and every declaration in it is already resolved into ' +
1058
+ 'the computed values this payload sends. It is dropped so an importer that reads inline style ' +
1059
+ 'cannot see a reset that contradicts them.')
1060
+ }
1061
+ if (state.dataUriAttrs) {
1062
+ push('h2d.inline-assets', 'info',
1063
+ `${state.dataUriAttrs} attribute(s) carry a data URI inlined by snapdom (images past CORS, ` +
1064
+ 'resolved icon fonts). They are the whole of the pixel payload while `assets` is empty, and they ' +
1065
+ 'are why this envelope can be megabytes.')
1066
+ }
1067
+ const textish = countText(doc.root)
1068
+ if (!textish) {
1069
+ push('h2d.no-text', 'warn',
1070
+ 'Not one text node in this payload has a painted box. Either the capture really has no text, or ' +
1071
+ 'the clone was measured before layout — the second one produces a paste of empty frames.')
1072
+ }
1073
+ }
1074
+
1075
+ function countText (node) {
1076
+ if (!node) return 0
1077
+ if (node.nodeType === 3) return 1
1078
+ let n = 0
1079
+ for (const c of node.childNodes || []) n += countText(c)
1080
+ return n
1081
+ }
1082
+
1083
+ /**
1084
+ * The clipboard envelope, verbatim from `h2d_toolbar.js` of their extension
1085
+ * 1.3.24: two `<span>`s, each carrying a base64 JSON blob between literal
1086
+ * markers, inside an HTML comment inside an attribute.
1087
+ *
1088
+ * It is written as **`text/html`** with **`text/plain` empty**, which is why a
1089
+ * copy made this way looks like an empty clipboard in a text editor. The
1090
+ * conversion is triggered by this envelope and by nothing else: pasting raw HTML
1091
+ * into Figma was tried and does not import (FIGMA_FINDINGS.md).
1092
+ *
1093
+ * @param {Array<object>} payload what `cloneToH2d` returned
1094
+ * @param {object} [meta] the `figmeta` half; source/timestamp, not the drawing
1095
+ * @returns {{html: string, plain: string, json: string, bytes: number}}
1096
+ */
1097
+ export function h2dEnvelope (payload, meta = {}) {
1098
+ const json = JSON.stringify(payload)
1099
+ const metaJson = JSON.stringify({
1100
+ dataType: 'h2d',
1101
+ source: 'snapdom-vector',
1102
+ capturedAtIso: new Date().toISOString(),
1103
+ ...meta,
1104
+ })
1105
+ const html =
1106
+ `<span data-metadata="<!--(figmeta)${b64(metaJson)}(/figmeta)-->"></span>` +
1107
+ `<span data-h2d="<!--(figh2d)${b64(json)}(/figh2d)-->"></span>`
1108
+ return { html, plain: '', json, bytes: json.length }
1109
+ }
1110
+
1111
+ /**
1112
+ * UTF-8 -> base64, in chunks.
1113
+ *
1114
+ * `String.fromCharCode(...bytes)` is the one-liner every probe uses and it blows
1115
+ * the argument limit somewhere around 100 kB — which is one inlined screenshot.
1116
+ * A payload that throws at exactly the size that makes this backend worth using
1117
+ * is not a corner case.
1118
+ */
1119
+ function b64 (s) {
1120
+ const bytes = new TextEncoder().encode(s)
1121
+ let out = ''
1122
+ const CHUNK = 0x8000
1123
+ for (let i = 0; i < bytes.length; i += CHUNK) {
1124
+ out += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK))
1125
+ }
1126
+ return btoa(out)
1127
+ }
1128
+
1129
+ /**
1130
+ * The envelope as a `ClipboardItem`, with the empty `text/plain` half that the
1131
+ * channel requires. Kept here because it is the one part that is easy to get
1132
+ * silently wrong: a non-empty `text/plain` is what a paste target prefers, and
1133
+ * Figma would then receive text instead of a document.
1134
+ *
1135
+ * Must be called from a user gesture, like any clipboard write.
1136
+ *
1137
+ * @param {{html: string}} envelope
1138
+ * @returns {ClipboardItem}
1139
+ */
1140
+ export function h2dClipboardItem (envelope) {
1141
+ return new ClipboardItem({
1142
+ 'text/html': new Blob([envelope.html], { type: 'text/html' }),
1143
+ 'text/plain': new Blob([''], { type: 'text/plain' }),
1144
+ })
1145
+ }
1146
+
1147
+ export default cloneToH2d