@zumer/snapdom-plugins 3.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +50 -16
- package/agent-map.d.ts +32 -0
- package/agent-map.js +5 -3
- package/ascii-export.d.ts +10 -0
- package/ascii-export.js +21 -6
- package/capture-frames.js +5 -5
- package/color-tint.js +4 -0
- package/context-export.d.ts +23 -0
- package/context-export.js +4 -1
- package/filter.js +1 -1
- package/gif-export.d.ts +6 -0
- package/gif-export.js +3 -3
- package/html-export.d.ts +8 -0
- package/html-export.js +6 -4
- package/index.d.ts +2 -1
- package/index.js +2 -1
- package/package.json +16 -5
- package/pdf/LICENSE +21 -0
- package/pdf/README.md +22 -0
- package/pdf/REFERENCE.md +520 -0
- package/pdf/src/frontmatter.js +200 -0
- package/pdf/src/furniture.js +398 -0
- package/pdf/src/index.js +2415 -0
- package/pdf/src/mega/README.md +47 -0
- package/pdf/src/mega/diag.js +148 -0
- package/pdf/src/mega/flow.js +56 -0
- package/pdf/src/mega/fonts.js +287 -0
- package/pdf/src/mega/forms.js +479 -0
- package/pdf/src/mega/labels.js +43 -0
- package/pdf/src/mega/meta.js +145 -0
- package/pdf/src/nav.js +357 -0
- package/pdf/src/paginate.js +583 -0
- package/pdf/src/tagged.js +355 -0
- package/pdf/src/text-layer.js +1771 -0
- package/pdf/src/writer/encrypt.js +218 -0
- package/pdf/src/writer/font.js +507 -0
- package/pdf/src/writer/index.js +1135 -0
- package/pdf/src/writer/subset.js +387 -0
- package/pdf.d.ts +294 -0
- package/pdf.js +1 -0
- package/redact-inputs.js +6 -6
- package/vector/CONTRACT.md +906 -0
- package/vector/LICENSE +21 -0
- package/vector/README.md +35 -0
- package/vector/src/adapters/snapdom.js +795 -0
- package/vector/src/collect/box.js +1676 -0
- package/vector/src/collect/font.js +487 -0
- package/vector/src/collect/image.js +1458 -0
- package/vector/src/collect/mathml.js +272 -0
- package/vector/src/collect/text.js +3027 -0
- package/vector/src/css/color.js +823 -0
- package/vector/src/css/gradient.js +1126 -0
- package/vector/src/css/matrix.js +398 -0
- package/vector/src/css/radius.js +124 -0
- package/vector/src/css/shadow.js +201 -0
- package/vector/src/emit/figma-json.js +1995 -0
- package/vector/src/emit/h2d-vector.js +215 -0
- package/vector/src/emit/h2d.js +1147 -0
- package/vector/src/emit/pdf.js +2571 -0
- package/vector/src/emit/svg-flat.js +4842 -0
- package/vector/src/exclusion.js +13 -0
- package/vector/src/index.js +2910 -0
- package/vector/src/paint.js +2908 -0
- package/vector/src/public.js +57 -0
- package/vector/src/writer/encrypt.js +227 -0
- package/vector/src/writer/font.js +507 -0
- package/vector/src/writer/index.js +1148 -0
- package/vector/src/writer/subset.js +387 -0
- package/vector/svd/index.js +72 -0
- package/vector/svd/schema.js +985 -0
- package/vector.d.ts +28 -0
- package/vector.js +1 -0
- package/video-export.d.ts +6 -0
- package/video-export.js +7 -7
- package/pdf-image.d.ts +0 -18
- package/pdf-image.js +0 -114
|
@@ -0,0 +1,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
|