@zumer/snapdom-plugins 3.0.0-beta.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +54 -20
  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 +17 -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,795 @@
1
+ /**
2
+ * snapdom Pro — the snapdom adapter.
3
+ *
4
+ * **This is the only file in the engine allowed to know snapdom exists.** Every
5
+ * other module takes elements and computed styles and does not care which tree
6
+ * they came from; this one produces that tree.
7
+ *
8
+ * The engine walks snapdom's CLONE, not the live DOM (see `packages/CONTRACT.md`).
9
+ * The clone is where snapdom has already solved membership: pseudo-elements are
10
+ * materialised into real `<span>`s, shadow DOM is flattened with its CSS
11
+ * rewritten, icon fonts are resolved to `<img>`, backgrounds and images are
12
+ * inlined past CORS, scroll is applied as a translate. Only geometry and paint
13
+ * are left, and those are what the collectors do.
14
+ *
15
+ * **The clone arrives through the plugin system, like everything else that
16
+ * extends snapdom.** `vector()` in `../index.js` is a plugin of exactly the shape
17
+ * the twelve official ones have (`snapdom/packages/plugins/`): a factory returning
18
+ * `{name, hook…, defineExports}`. `handoffFrom` below is the one function that
19
+ * knows what a hook state looks like, and `mountClone` takes it from there.
20
+ *
21
+ * The hook is `beforeRender`, after v3 has added `fontsCSS`, `baseCSS` and
22
+ * `scrollbarCSS` to the same capture context that already carries `clone`,
23
+ * `classCSS`, `styleCache` and `nodeMap`. `styleMap` is on `options.__session`.
24
+ *
25
+ * **`afterClone` is too early**, which is why the later hook: the
26
+ * images, background images and fonts have not been inlined yet (that is the
27
+ * `assetsPhase`/`fontsPhase` pair further down) and there is no `fontsCSS` at
28
+ * all. A clone whose assets are already resolved past CORS is half of what this
29
+ * engine walks the clone FOR.
30
+ *
31
+ * The hand-off rides on the capture context. `state.options` aliases `state`
32
+ * in v3; `buildResult` spreads that context into each export view, so the
33
+ * hand-off survives without storing capture state on the plugin instance.
34
+ *
35
+ * One value the hand-off cannot get that way is `classPrefixCSS`: `captureDOM`
36
+ * keeps it local and only passes it to internal `__retain`, not public hooks.
37
+ * It is recovered here from `classCSS`, which begins with it — see
38
+ * `classPrefixFrom`.
39
+ *
40
+ * Mounting is not a detail either:
41
+ *
42
+ * - **light DOM, never a shadow root.** Chromium ignores `@font-face` inside a
43
+ * shadow root, and a clone laid out with substituted fonts measures nothing
44
+ * useful — every `textLength` in the export would be a lie.
45
+ * - **the page's own stylesheets still apply.** The clone keeps its original
46
+ * class names and ids, so mounting it in the same document reproduces the
47
+ * live cascade rather than re-deriving it. `classPrefixCSS` supplies what the
48
+ * cascade cannot: the shadow-scoped rules, the scoped `::marker`/
49
+ * `::first-line` rules, and the `PSEUDO_SUPPRESS` rule that stops a
50
+ * materialised `::before` from being painted twice.
51
+ * - **offscreen, not hidden.** `display:none` has no layout and `visibility:
52
+ * hidden` still reflows the page; `position:fixed; left:-99999px` with
53
+ * `contain:layout style` has a full layout, is off every screen, and cannot
54
+ * disturb the document it is borrowed into.
55
+ *
56
+ * Borrowing the page's cascade is what makes the mount cheap and faithful, and
57
+ * it is also where all four ways it goes wrong come from. Each has its own
58
+ * repair below, each was found by `drift` rather than by reasoning, and each
59
+ * carries the number it was worth:
60
+ *
61
+ * 1. the host has no width, so a `width:auto` root shrink-wraps instead of
62
+ * filling the space it filled on the page — `sizeHost`, 628px;
63
+ * 2. nothing in the page styles a node snapdom INVENTED, so a materialised
64
+ * `::before` sized only by CSS arrives 0×0 — `synthRulesFrom`, 18px;
65
+ * 3. a scroll wrapper interposes itself and breaks every `>` chain that crosses
66
+ * it, for the whole subtree — `synthRulesFrom` again, 268px;
67
+ * 4. connecting the clone UPGRADES its custom elements and the browser gives
68
+ * them back the shadow roots snapdom flattened away —
69
+ * `defuseUpgradedShadowRoots`, 37.72px.
70
+ *
71
+ * With all four in, nine fixtures across two demo pages and a scrolled-container
72
+ * fixture measure 0.00px of drift — median, p95 and max. `drift` re-measures it
73
+ * on every capture, because the whole architecture rests on the number and a
74
+ * number nobody checks is a promise.
75
+ *
76
+ * @module adapters/snapdom
77
+ */
78
+
79
+ /**
80
+ * Stamped on the two nodes this module adds to the page, so anything it ever
81
+ * leaks is one `querySelectorAll` away instead of anonymous debris.
82
+ */
83
+ const MARK = 'data-snapdom-vector'
84
+
85
+ /**
86
+ * See the module comment: offscreen with a real layout, contained.
87
+ *
88
+ * `position:fixed` with no width shrink-wraps, and a block-level clone root then
89
+ * sizes to its own content instead of to the width it had on the page. Measured:
90
+ * a `<div>` that was 968px wide live came out 340px wide in the clone, and every
91
+ * line in it wrapped somewhere else. `sizeHost` puts the width back — this
92
+ * constant is only what does not depend on the element.
93
+ */
94
+ const HOST_CSS = 'position:fixed;left:-99999px;top:0;contain:layout style;display:block;'
95
+
96
+ /**
97
+ * Give the host the box the clone root had on the page, so a root with
98
+ * `width:auto` resolves against the same available space.
99
+ *
100
+ * The size is read from the computed style rather than `getBoundingClientRect`
101
+ * on purpose: `outerTransforms:true` keeps the root's own transform on the
102
+ * clone, and a bounding rect of a rotated element is its bounding box, not its
103
+ * layout box. `cs.width`/`cs.height` are the used content box, in subpixels,
104
+ * with no transform applied.
105
+ *
106
+ * Margins are added back because the host is the clone root's containing block
107
+ * now: a block child with `margin:0 24px` would otherwise lay out 48px narrower
108
+ * than it did on the page.
109
+ *
110
+ * @param {HTMLElement} host
111
+ * @param {Element} element live capture root
112
+ */
113
+ function sizeHost (host, element) {
114
+ const cs = typeof getComputedStyle === 'function' ? getComputedStyle(element) : null
115
+ if (!cs) return
116
+ const n = (v) => {
117
+ const value = parseFloat(v)
118
+ return Number.isFinite(value) ? value : 0
119
+ }
120
+ const w = n(cs.width) + n(cs.paddingLeft) + n(cs.paddingRight) +
121
+ n(cs.borderLeftWidth) + n(cs.borderRightWidth) + n(cs.marginLeft) + n(cs.marginRight)
122
+ const h = n(cs.height) + n(cs.paddingTop) + n(cs.paddingBottom) +
123
+ n(cs.borderTopWidth) + n(cs.borderBottomWidth) + n(cs.marginTop) + n(cs.marginBottom)
124
+ // A zero here would be worse than leaving the host to shrink-wrap: it would
125
+ // collapse the clone instead of merely letting it choose its own width.
126
+ if (w > 0) host.style.width = `${w}px`
127
+ if (h > 0) host.style.height = `${h}px`
128
+ }
129
+
130
+ /** Stamped on the clone nodes that get a snapshot rule of their own. */
131
+ const SYNTH = 'data-snapdom-vector-synth'
132
+
133
+ /**
134
+ * snapdom's own suppressor for the pseudo-elements it materialised, verbatim
135
+ * from `snapdom/src/core/prepare.js:130`. That file is the source of truth; this
136
+ * is a copy because the string is also the ANCHOR the split below needs, and a
137
+ * copy that stops matching is detected (see `classPrefixFrom`) rather than
138
+ * silently wrong.
139
+ */
140
+ const PSEUDO_SUPPRESS =
141
+ '[data-snapdom-has-after]::after,[data-snapdom-has-before]::before{content:none!important;display:none!important}'
142
+
143
+ /** One scoped `::marker`/`::first-line` rule, as `pseudo.js:460` writes them. */
144
+ const SCOPED_PSEUDO_RE = /^\[data-sd-p\d+\]::(?:marker|first-line)\{[^}]*\}/
145
+
146
+ /**
147
+ * `classPrefixCSS` back out of `classCSS`.
148
+ *
149
+ * The mount needs the front half of snapdom's CSS and must not have the back
150
+ * half. `prepare.js:133-134` builds the string as
151
+ *
152
+ * classCSS = shadowScopedCSS + PSEUDO_SUPPRESS + __pseudoCSS + `.c1{…}.c2{…}…`
153
+ * └───────────────── classPrefixCSS ─────────────┘
154
+ *
155
+ * and the boundary is findable exactly, without parsing CSS or guessing: the
156
+ * middle term is a fixed literal, and the third is a run of rules with one
157
+ * grammar (`[data-sd-pN]::marker{…}`). Everything after that run is the
158
+ * generated per-node style snapshots, which are the rules that must NOT be
159
+ * injected — mounting those on top of the page's cascade freezes used widths and
160
+ * re-wraps text (measured: fx-table's pixel diff 2.071 → 10.254).
161
+ *
162
+ * `exact:false` means the anchor was not found — a snapdom whose suppressor
163
+ * string moved. The caller declares that rather than guessing a boundary: the
164
+ * literal alone still stops a materialised `::before` painting twice, and what
165
+ * is lost is the shadow-scoped rules, which is a fidelity claim someone has to
166
+ * be told about.
167
+ *
168
+ * @param {string} classCSS
169
+ * @returns {{css: string, exact: boolean}}
170
+ */
171
+ function classPrefixFrom (classCSS) {
172
+ const css = typeof classCSS === 'string' ? classCSS : ''
173
+ const at = css.indexOf(PSEUDO_SUPPRESS)
174
+ if (at < 0) return { css: PSEUDO_SUPPRESS, exact: false }
175
+ let end = at + PSEUDO_SUPPRESS.length
176
+ for (;;) {
177
+ const m = SCOPED_PSEUDO_RE.exec(css.slice(end))
178
+ if (!m) break
179
+ end += m[0].length
180
+ }
181
+ return { css: css.slice(0, end), exact: true }
182
+ }
183
+
184
+ /**
185
+ * Stamped on a clone node whose shadow root this module emptied. `paint.js`
186
+ * reads it: a shadow root it did not expect is an error, and this one it did.
187
+ */
188
+ export const DEFUSED_SHADOW_ATTR = 'data-snapdom-vector-defused'
189
+
190
+ /**
191
+ * Undo the damage that mounting does to custom elements.
192
+ *
193
+ * `cloneNode` never copies a shadow root, so snapdom's clone arrives with every
194
+ * shadow tree already flattened into light DOM and its CSS rewritten to scoped
195
+ * classes. Then we connect it to the document — and the browser UPGRADES every
196
+ * custom element in it. `connectedCallback` runs, `attachShadow` gives the clone
197
+ * a brand-new shadow tree, and the flattened content snapdom worked out is
198
+ * either hidden behind it or, worse, slotted INSIDE the component's own markup
199
+ * and rendered twice.
200
+ *
201
+ * Measured on a `<sd-badge>` whose shadow root is `<span class="b"><slot></slot></span>`:
202
+ * the clone came out 130.63×34.50 against a live 92.91×25.75 — the badge inside
203
+ * the badge — and it dragged its three flex siblings 37.72px with it.
204
+ *
205
+ * The repair is exact, not a heuristic: **any** shadow root on a clone node was
206
+ * created by that upgrade, because a clone cannot have inherited one. Replacing
207
+ * its content with a bare `<slot>` is the identity transform for the flattened
208
+ * light DOM — it renders as if there were no shadow root at all. Measured on the
209
+ * same badge: 92.91×25.75, and the fixture's drift went 37.72px → 0.00px.
210
+ *
211
+ * Two things it cannot reach, both declared rather than papered over: a `closed`
212
+ * shadow root is not exposed to script at all, and a `connectedCallback` that
213
+ * rewrites its own light DOM has already done so by the time we look.
214
+ *
215
+ * @param {Element} clone mounted clone root
216
+ * @returns {Element[]} the hosts that were defused
217
+ */
218
+ function defuseUpgradedShadowRoots (clone) {
219
+ const defused = []
220
+ const all = [clone]
221
+ for (const el of clone.querySelectorAll('*')) all.push(el)
222
+ for (const el of all) {
223
+ if (!el.shadowRoot) continue
224
+ try {
225
+ el.shadowRoot.replaceChildren(el.ownerDocument.createElement('slot'))
226
+ el.setAttribute(DEFUSED_SHADOW_ATTR, '')
227
+ defused.push(el)
228
+ } catch { /* a shadow root we cannot rewrite stays as it is, and paint.js says so */ }
229
+ }
230
+ return defused
231
+ }
232
+
233
+ /** snapdom's marker on the span it materialises a `::first-letter` into. */
234
+ const FIRST_LETTER = '::first-letter'
235
+
236
+ /**
237
+ * The inner div `wrapScrolledClone` (snapdom/src/core/prepare.js) puts inside a
238
+ * scrolled container to carry `translate(-scrollX, -scrollY)`.
239
+ *
240
+ * Identified by signature because snapdom gives it no attribute of its own; this
241
+ * is the same test `paint.js` uses, kept as a copy rather than an import so the
242
+ * adapter stays the bottom of the stack and depends on nothing above it. Note
243
+ * that `all: unset` is read off the CSSOM, never off the style ATTRIBUTE, which
244
+ * Chromium serializes expanded into every longhand the shorthand covers.
245
+ *
246
+ * @param {Element} el
247
+ */
248
+ function isScrollWrapper (el) {
249
+ if (!el || el.tagName !== 'DIV' || !el.style) return false
250
+ if (el.attributes.length !== 1 || !el.hasAttribute('style')) return false
251
+ const s = el.style
252
+ return s.all === 'unset' && s.display === 'inline-block' && s.width === '100%' &&
253
+ s.willChange === 'transform' && /^translate\(/.test(s.transform || '')
254
+ }
255
+
256
+ /**
257
+ * Style rules for the clone nodes the page cannot style, and only those.
258
+ *
259
+ * snapdom keeps every clone node's computed style in a style key and stamps the
260
+ * node with a generated class (`c1`, `c2`, …) that carries it; those rules go
261
+ * into the SVG it renders and are deliberately left out of the mount — see
262
+ * `classPrefixFrom`, which cuts them off the front half of `classCSS`. Mounted in
263
+ * the page, the clone is painted by the page's own stylesheets instead, which
264
+ * is what makes the mount faithful — the clone kept its class names, so it gets
265
+ * the same cascade the original had. Measured: drift 0.00–0.03px that way.
266
+ *
267
+ * Injecting ALL of snapdom's rules on top of that is worse, not better, and this
268
+ * was measured too: scoped to the host they outrank the page's own selectors,
269
+ * and a frozen used-width where the page had none re-wrapped a paragraph
270
+ * (`<b>` 395×47 live vs 119×19 in the clone) and shifted a table row by 27.81px.
271
+ * fx-table's pixel diff went 2.071 → 10.254 on that experiment alone.
272
+ *
273
+ * What the page genuinely cannot style is the nodes snapdom INVENTED: a
274
+ * materialised `::before` is a `<span>` no author selector matches. Left to the
275
+ * cascade it has no box at all — measured, a `::before` with
276
+ * `content:""; width:6px; height:6px` came out 0×0 and every pill carrying one
277
+ * was 6px narrow. Those nodes, and no others, get their snapshot back here.
278
+ *
279
+ * A node with no live counterpart in `nodeMap` is exactly the definition of
280
+ * invented, so the map is the test rather than a list of snapdom's attribute
281
+ * names that would rot the next time it grows one.
282
+ *
283
+ * **`::first-letter` is the one exception, and it is not a special case for its
284
+ * own sake.** snapdom stops a materialised `::before`/`::after` from painting
285
+ * twice with `PSEUDO_SUPPRESS` (`[data-snapdom-has-before]::before{content:none}`),
286
+ * which leaves the span as the only carrier; there is no such suppressor for
287
+ * `::first-letter`, so the page's own rule still matches the clone paragraph and
288
+ * still draws the drop cap. Styling the span as well makes two floats out of one.
289
+ * Measured on fx-type: the paragraph re-wrapped and a `<b>` moved a whole line,
290
+ * 276.61px of drift, so the span is left alone here.
291
+ *
292
+ * That leaves a known hole, and it is a hole in the DESCRIPTION rather than the
293
+ * layout: the browser paints the drop cap from the page's rule, but the engine
294
+ * reads `getComputedStyle(span)`, which knows nothing about it. `collect/text.js`
295
+ * closes it by asking the parent for its `::first-letter` style — a paint-only
296
+ * subset applied here does not work, because a `font-size` on the span changes
297
+ * its own strut and moves the line (measured: 32.69px median drift, pixel diff
298
+ * 5.58 → 17.86).
299
+ *
300
+ * @param {Element} clone the clone root, not yet mounted
301
+ * @param {Map<Node, Node>|undefined} nodeMap clone node -> live node
302
+ * @param {Map<Node, string>|undefined} styleMap clone node -> style key
303
+ * @returns {string} CSS, '' when nothing was synthesized
304
+ */
305
+ function synthRulesFrom (clone, nodeMap, styleMap) {
306
+ if (!styleMap || typeof styleMap.get !== 'function') return ''
307
+ if (!nodeMap || typeof nodeMap.get !== 'function') return ''
308
+ let css = ''
309
+ let n = 0
310
+ const all = [clone]
311
+ for (const el of clone.querySelectorAll('*')) all.push(el)
312
+ // Everything below a scroll wrapper, found in one pass: `querySelectorAll`
313
+ // yields document order, so a parent is always seen before its children.
314
+ const stranded = new Set()
315
+
316
+ for (const el of all) {
317
+ // A real node under a scroll wrapper is in the same position as an invented
318
+ // one. The wrapper is interposed between a container and its content, and
319
+ // that breaks every `>` chain that crosses it — not only at the first level:
320
+ // `.scroller > div` fails, and so does `.scroller > div > p:first-child`
321
+ // three nodes down. Measured on a 300×150 scroller: the direct child laid
322
+ // out 300×48 against a live 300×316 (268px), and with only direct children
323
+ // repaired the paragraphs inside it were still 14px out, having picked up
324
+ // the UA's `margin: 1em 0` in place of the author's `margin: 0 0 8px`.
325
+ const parent = el.parentElement
326
+ if (parent && (stranded.has(parent) || isScrollWrapper(parent))) stranded.add(el)
327
+ // Not `!nodeMap.get(el.parentElement)`, which is the same idea and wrong:
328
+ // `nodeMap` does not hold every element, so "unmapped parent" also catches
329
+ // ordinary nodes and hands them a frozen snapshot. Measured when it did —
330
+ // every fixture regressed at once, fx-table's pixel diff 1.83 → 11.39 and
331
+ // fx-card's drift 0.00 → 20.88px. The wrapper's signature is exact; the
332
+ // absence of a map entry is not evidence of anything.
333
+ if (nodeMap.get(el) && !stranded.has(el)) continue
334
+ if (el.getAttribute && el.getAttribute('data-snapdom-pseudo') === FIRST_LETTER) continue
335
+ const key = styleMap.get(el)
336
+ if (!key) continue
337
+ // Keyed on a per-node attribute rather than the shared `cN` class: one key
338
+ // can be shared by an invented node and a real one, and the real one must
339
+ // keep the cascade it already lays out correctly under.
340
+ el.setAttribute(SYNTH, String(n))
341
+ // `all:initial` first, and it is load-bearing rather than tidiness. snapdom
342
+ // builds a style key by dropping every property whose value equals the CSS
343
+ // INITIAL value (`getDefaultStyleForTag` measures a bare tag with
344
+ // `style.all = 'initial'`), because inside its own SVG the key is paired with
345
+ // a generated base reset that neutralises the UA stylesheet. Without that
346
+ // reset the key is only half a style: measured on a scrolled container, a
347
+ // `<p>` whose author rule sets `margin-top:0` had no `margin-top` in its key
348
+ // at all, kept the UA's `1em`, and pushed the whole subtree 14px down.
349
+ css += `[${MARK}="clone-host"] [${SYNTH}="${n}"]{all:initial;${key}}`
350
+ n++
351
+ }
352
+ return css
353
+ }
354
+
355
+ /**
356
+ * @typedef {object} CloneDrift
357
+ * @property {number} pairs clone elements that had a live counterpart in `nodeMap`
358
+ * AND take part in layout on at least one side — the nodes the numbers below are
359
+ * computed over. A zero across 3 pairs is not the same promise as a zero across 300.
360
+ * @property {number} skipped mapped pairs left out because neither side generates a
361
+ * box (see `laysOut`). Reported so the reader can tell a small denominator from a
362
+ * filtered one.
363
+ * @property {number|null} median px, over `pairs`; `null` only when `pairs` is 0
364
+ * @property {number|null} p95 px
365
+ * @property {number|null} max px
366
+ * @property {{delta: number, tag: string, id: string, cls: string, clone: string,
367
+ * live: string, missing?: 'clone'|'live'}[]} worst
368
+ * the worst offenders, biggest first, so the number comes with somewhere to look.
369
+ * `missing` names the side that has no box at all, when only one of them does.
370
+ */
371
+
372
+ /**
373
+ * Does this element take part in layout at all?
374
+ *
375
+ * The nodes that do not are not drift and never were: `<stop>`, `<clipPath>`,
376
+ * `<defs>`, `<linearGradient>` and everything under them describe paint, not
377
+ * boxes, and an `<option>` is drawn by the platform's popup rather than by the
378
+ * document. All of them return the all-zero rect, and subtracting a root origin
379
+ * from a zero rect measures the OFFSCREEN OFFSET of the host instead of any
380
+ * disagreement — which is exactly the 100451.00px that `fx-svg` and `fx-form`
381
+ * used to report, i.e. `HOST_CSS`'s -99999px plus wherever the fixture sat.
382
+ *
383
+ * The test is the client-rect LIST, not a list of tag names that would rot the
384
+ * next time SVG grows an element, and not `display`/`checkVisibility` either.
385
+ * Measured on `fx-svg`, over the 22 mapped nodes that have no box on either
386
+ * side: `getComputedStyle(el).display` is `inline` for **all 22** (Chromium's
387
+ * SVG UA sheet does not use `display:none` for them) and
388
+ * `el.checkVisibility({contentVisibilityAuto: true})` is `true` for 15 of them —
389
+ * only the 7 `<stop>`s come out false (`fx-form`'s 3 `<option>`s do too, which is
390
+ * the whole of what the two agree on). An element that generates no box
391
+ * has an EMPTY rect list, and that is true for `display:none`, for a
392
+ * `content-visibility` subtree the browser skipped, and for the SVG paint-server
393
+ * elements all at once.
394
+ *
395
+ * `rect` is the caller's already-taken bounding rect, so the rect list is only
396
+ * consulted for the degenerate case that could mean either "no box" or "a real
397
+ * empty box at the viewport origin".
398
+ *
399
+ * @param {Element} el
400
+ * @param {DOMRect} rect `el.getBoundingClientRect()`, already measured
401
+ * @returns {boolean}
402
+ */
403
+ function laysOut (el, rect) {
404
+ if (rect.width || rect.height || rect.left || rect.top) return true
405
+ if (!el.getClientRects) return true
406
+ return el.getClientRects().length > 0
407
+ }
408
+
409
+ /**
410
+ * @typedef {object} CloneHandoff what one `beforeRender` hook state is worth
411
+ * @property {Element} element the LIVE capture root
412
+ * @property {Element} clone snapdom's resolved clone, still detached
413
+ * @property {Map<Node, Node>} nodeMap clone node -> source node
414
+ * @property {Map<Node, string>} styleMap clone node -> style key
415
+ * @property {string} fontsCSS `@font-face` rules snapdom embedded, '' when none
416
+ * @property {string} classCSS every rule snapdom generated for this capture
417
+ */
418
+
419
+ /**
420
+ * @typedef {object} CloneCapture
421
+ * @property {Element} clone snapdom's resolved clone, MOUNTED and laid out
422
+ * @property {Map<Node, Node>} nodeMap clone node -> source node
423
+ * @property {() => void} unmount idempotent; removes the host and the injected <style>
424
+ * @property {CloneDrift} drift the mounted clone measured against its live source
425
+ * @property {boolean} classPrefixExact false when `classPrefixCSS` could not be cut
426
+ * out of `classCSS` and the mount ran on the suppressor alone. See `classPrefixFrom`.
427
+ * @property {number} defusedShadowHosts custom elements the browser upgraded on
428
+ * mount and whose new shadow root was emptied again. See `defuseUpgradedShadowRoots`.
429
+ */
430
+
431
+ /**
432
+ * How far the mounted clone's boxes are from the live boxes they came from.
433
+ *
434
+ * Both sides are measured relative to their OWN root, so the offscreen offset
435
+ * cancels and what is left is real disagreement. This is the safety net for the
436
+ * whole clone architecture: if it is not ~0, every coordinate the engine emits
437
+ * is measured against a layout the user never saw.
438
+ *
439
+ * The offset only cancels for nodes that HAVE a box, which is why `laysOut`
440
+ * filters first: a node with no box on either side is not a node that agrees or
441
+ * disagrees, it is a node with nothing to compare, and running it through the
442
+ * subtraction anyway turned the host's own -99999px into a drift reading. Those
443
+ * are counted in `skipped` rather than dropped in silence — a number computed
444
+ * over a filtered population has to say how much it filtered, or the filter is
445
+ * just a nicer-looking version of the same lie.
446
+ *
447
+ * A node that lays out on ONE side is the opposite case and stays in: it is an
448
+ * element the export either invented or lost, which is the loudest thing this
449
+ * measurement can find. Its delta is the size of the box that does exist, since
450
+ * two origins cannot be subtracted when one of them is not a position.
451
+ *
452
+ * @param {Element} clone mounted clone root
453
+ * @param {Element} element live capture root
454
+ * @param {Map<Node, Node>} nodeMap
455
+ * @returns {CloneDrift}
456
+ */
457
+ function measureDrift (clone, element, nodeMap) {
458
+ const rows = []
459
+ let skipped = 0
460
+ if (nodeMap && typeof nodeMap.get === 'function') {
461
+ const cloneRoot = clone.getBoundingClientRect()
462
+ const liveRoot = element.getBoundingClientRect()
463
+ const all = [clone]
464
+ for (const el of clone.querySelectorAll('*')) all.push(el)
465
+ for (const el of all) {
466
+ const src = nodeMap.get(el)
467
+ // A clone node with no live counterpart is not drift: pseudo-element spans,
468
+ // scroll wrappers and synthesized backdrops have nothing to be compared to.
469
+ if (!src || src.nodeType !== 1 || src.isConnected === false) continue
470
+ const a = el.getBoundingClientRect()
471
+ const b = src.getBoundingClientRect()
472
+ const inClone = laysOut(el, a)
473
+ const inLive = laysOut(src, b)
474
+ if (!inClone && !inLive) { skipped++; continue }
475
+ const missing = inClone ? (inLive ? null : 'live') : 'clone'
476
+ rows.push({
477
+ delta: missing
478
+ ? Math.max(a.width, a.height, b.width, b.height)
479
+ : Math.max(
480
+ Math.abs((a.left - cloneRoot.left) - (b.left - liveRoot.left)),
481
+ Math.abs((a.top - cloneRoot.top) - (b.top - liveRoot.top)),
482
+ Math.abs(a.width - b.width),
483
+ Math.abs(a.height - b.height),
484
+ ),
485
+ missing,
486
+ el,
487
+ src,
488
+ a,
489
+ b,
490
+ })
491
+ }
492
+ }
493
+ // No pair means NOT MEASURED, and that is a different answer from "zero drift".
494
+ // Reporting 0 here would hand the orchestrator a clean bill of health for a
495
+ // mount nobody checked, which is the one failure this number exists to catch.
496
+ // `skipped` travels even then: "nothing to measure" and "nothing measurABLE"
497
+ // are read the same way by a human and are not the same fault.
498
+ if (!rows.length) return { pairs: 0, skipped, median: null, p95: null, max: null, worst: [] }
499
+
500
+ rows.sort((x, y) => x.delta - y.delta)
501
+ const q = (p) => rows[Math.min(rows.length - 1, Math.floor(rows.length * p))].delta
502
+ return {
503
+ pairs: rows.length,
504
+ skipped,
505
+ median: q(0.5),
506
+ p95: q(0.95),
507
+ max: rows[rows.length - 1].delta,
508
+ // A number without a culprit is a shrug. The worst few nodes, named, are the
509
+ // difference between "the clone is off by 628px somewhere" and a selector to
510
+ // go and look at — and the outlier case is exactly where the median is useless.
511
+ worst: rows.slice(-WORST_KEPT).reverse().filter((r) => r.delta > 0).map(describe),
512
+ }
513
+ }
514
+
515
+ /** How many named offenders `drift.worst` keeps. Enough to see a pattern, not a dump. */
516
+ const WORST_KEPT = 6
517
+
518
+ /**
519
+ * @param {{delta: number, missing: 'clone'|'live'|null, el: Element, src: Element,
520
+ * a: DOMRect, b: DOMRect}} row
521
+ * @returns {{delta: number, tag: string, id: string, cls: string, clone: string,
522
+ * live: string, missing?: 'clone'|'live'}}
523
+ */
524
+ function describe (row) {
525
+ const { el, a, b } = row
526
+ // `no box` rather than `0×0 @ 0,0`: the all-zero rect of an element that does
527
+ // not lay out reads like a measurement, and it is the absence of one.
528
+ const box = (r, gone) => gone ? 'no box' : `${round(r.width)}×${round(r.height)} @ ${round(r.left)},${round(r.top)}`
529
+ const out = {
530
+ delta: row.delta,
531
+ tag: el.tagName ? el.tagName.toLowerCase() : '?',
532
+ id: el.id || '',
533
+ cls: (el.getAttribute && el.getAttribute('class')) || '',
534
+ clone: box(a, row.missing === 'clone'),
535
+ live: box(b, row.missing === 'live'),
536
+ }
537
+ if (row.missing) out.missing = row.missing
538
+ return out
539
+ }
540
+
541
+ function round (v) { return Math.round(v * 100) / 100 }
542
+
543
+ /** Distinguishes one mounted clone's radio groups from the next one's. */
544
+ let radioEpoch = 0
545
+
546
+ /**
547
+ * Renames the clone's radio groups before the clone is CONNECTED.
548
+ *
549
+ * A radio button's group is every radio with the same `name` under the same form
550
+ * owner, and a radio with no form owner belongs to the whole DOCUMENT. Mounting the
551
+ * clone in `document.body` therefore drops copies of the page's radios into the page's
552
+ * own groups, and the browser enforces "one checked per group" at insertion time: the
553
+ * clone's checked radio wins and **the live one is unchecked**. Measured on
554
+ * `demo/challenges.html` → `fx-form`: `fo-scale=on:1` before a capture and
555
+ * `fo-scale=on:0` after it. Capturing a page must not change the page, and this is the
556
+ * worst way to break that rule — the damage is to the user's own state, it survives the
557
+ * capture, and nothing says it happened. The suite's leak check (#9) cannot see it
558
+ * either: `checked` is IDL state, and the signature it compares is attributes.
559
+ *
560
+ * Renaming cannot change how the clone paints. snapdom gives every clone node a
561
+ * generated class carrying the element's COMPUTED style (`prepare.js`,
562
+ * `applyStyleClass`), so a page rule like `input[name="x"]{…}` has already been
563
+ * resolved into that snapshot; losing the selector match loses nothing that is not
564
+ * already baked in.
565
+ *
566
+ * @param {Element} clone the clone, still detached
567
+ * @returns {number} how many radios were renamed
568
+ */
569
+ function isolateRadioGroups (clone) {
570
+ const prefix = `snapdom-vector-clone-${++radioEpoch}-`
571
+ let n = 0
572
+ const rename = (el) => {
573
+ if (!el || el.tagName !== 'INPUT' || el.type !== 'radio') return
574
+ const name = el.getAttribute('name')
575
+ if (!name) return
576
+ el.setAttribute('name', prefix + name)
577
+ n++
578
+ }
579
+ try {
580
+ rename(clone)
581
+ // `input` and not `input[type=radio]`: `type` can be an IDL-only value on a node
582
+ // built by script, and the property is what the browser groups by.
583
+ for (const el of clone.querySelectorAll ? clone.querySelectorAll('input') : []) rename(el)
584
+ } catch { /* a clone we cannot walk is one we cannot damage the page with either */ }
585
+ return n
586
+ }
587
+
588
+ /**
589
+ * Puts the capture root's own outer paint back on the clone.
590
+ *
591
+ * snapdom strips four properties from the CLONE ROOT and only from it —
592
+ * `box-shadow`, `text-shadow`, `outline` and any `drop-shadow()` in `filter`
593
+ * (`snapdom/src/utils/capture.helpers.js`, `stripRootShadows`). For a raster
594
+ * capture that is right: the bleed would fall outside the bitmap and snapdom
595
+ * would either clip it or grow the image. For a VECTOR export it is a hole. The
596
+ * properties are written as inline style on the clone root, so nothing in this
597
+ * engine can see them at all — `collect/box.js` reads a computed style that says
598
+ * `none`, finds no effects, and files no diagnostic, because from where it
599
+ * stands nothing was lost. Measured on `demo/challenges.html`: every one of the
600
+ * ten fixtures is a card with its own `box-shadow`, and all ten arrived with
601
+ * `effects: []` on the root and not one word about it.
602
+ *
603
+ * They are restored from the LIVE element's computed style, which is the only
604
+ * place they still exist. What a backend then does with a shadow that paints
605
+ * outside the frame is the backend's decision to declare — Figma draws it, a
606
+ * standalone SVG clips it to the viewport — and that is a decision it can only
607
+ * make if the shadow is in the document.
608
+ *
609
+ * @param {Element} element the live capture root
610
+ * @param {Element} clone the clone root
611
+ * @returns {string[]} the properties put back, for the caller to declare
612
+ */
613
+ function restoreRootPaint (element, clone) {
614
+ const restored = []
615
+ if (!clone || !clone.style) return restored
616
+ let cs
617
+ try { cs = getComputedStyle(element) } catch { return restored }
618
+ if (!cs) return restored
619
+ // The clone's own values are read ONCE, before a single write. Interleaving
620
+ // read and write here is a forced style recalc per property on a tree that was
621
+ // mounted milliseconds ago: measured, five of them took `demo/figma.html`'s
622
+ // fx-card capture from 4ms to 14ms.
623
+ let cloneCs
624
+ try { cloneCs = getComputedStyle(clone) } catch { return restored }
625
+ // `outline` is compared through its longhands: the shorthand serialises in a
626
+ // different order on each side, so comparing the strings would always disagree
627
+ // and report a restore that put back what was already there.
628
+ const outlineOf = (s) => `${s.outlineWidth} ${s.outlineStyle} ${s.outlineColor}`
629
+ const before = {
630
+ boxShadow: cloneCs.boxShadow || '',
631
+ textShadow: cloneCs.textShadow || '',
632
+ outline: outlineOf(cloneCs),
633
+ outlineOffset: cloneCs.outlineOffset || '',
634
+ filter: cloneCs.filter || '',
635
+ }
636
+ const put = (prop, value) => {
637
+ if (!value || value === 'none' || value === '0px none rgb(0, 0, 0)') return
638
+ // Only where the clone has actually been emptied: a value that already
639
+ // matches must not be touched, and neither must one the page still cascades.
640
+ if (before[prop] === value) return
641
+ try { clone.style[prop] = value } catch { return }
642
+ restored.push(prop)
643
+ }
644
+ put('boxShadow', cs.boxShadow)
645
+ put('textShadow', cs.textShadow)
646
+ // An outline that paints nothing must not be "restored": the serialisation of
647
+ // no-outline carries the element's own `color`, so comparing the strings
648
+ // reported a restore on every root that had no outline at all.
649
+ if (cs.outlineStyle && cs.outlineStyle !== 'none' && parseFloat(cs.outlineWidth) > 0) {
650
+ put('outline', outlineOf(cs))
651
+ if (cs.outlineOffset && cs.outlineOffset !== '0px') put('outlineOffset', cs.outlineOffset)
652
+ }
653
+ // `filter` is only half-stripped: `stripRootShadows` removes the
654
+ // `drop-shadow()` functions and keeps the rest, so putting the live value back
655
+ // restores the shadows without disturbing a `blur()` that survived.
656
+ if (/drop-shadow\(/i.test(cs.filter || '')) put('filter', cs.filter)
657
+ return restored
658
+ }
659
+
660
+ /**
661
+ * One `beforeRender` hook state, reduced to the six things this engine needs.
662
+ *
663
+ * **This is the only function in the package that knows the shape of snapdom's
664
+ * pipeline state.** It runs inside the capture, does no work beyond reading, and
665
+ * hands back a plain object the export can keep — see the module comment for
666
+ * where each field comes from and why the hook is `beforeRender`.
667
+ *
668
+ * @param {{element: Element, clone: Element, nodeMap: Map, classCSS: string,
669
+ * fontsCSS: string, options: object}} state
670
+ * @returns {CloneHandoff}
671
+ */
672
+ export function handoffFrom (state) {
673
+ const session = state && state.options && state.options.__session
674
+ return {
675
+ element: state.element,
676
+ clone: state.clone,
677
+ nodeMap: state.nodeMap,
678
+ // Not on the hook state itself: `prepareClone` returns it on the session's
679
+ // own map (`snapdom/src/core/prepare.js:33-37`), which is where it stays.
680
+ styleMap: session ? session.styleMap : undefined,
681
+ fontsCSS: state.fontsCSS || '',
682
+ classCSS: state.classCSS || '',
683
+ }
684
+ }
685
+
686
+ /**
687
+ * Mount the clone a capture handed over, lay it out, and measure it against the
688
+ * live element it came from.
689
+ *
690
+ * The caller OWNS `unmount()` and must call it — from a `finally`, not from the
691
+ * happy path. Every export that does not leaves a full copy of the user's
692
+ * subtree in their document.
693
+ *
694
+ * Three snapdom options change what arrives here, and NONE of them can be forced
695
+ * from a plugin — `dpr` is resolved in `createContext` and `outerTransforms` is
696
+ * read at `captureDOM`'s line 74, both before the first hook runs, and `burst`
697
+ * decides whether `captureDOM` runs at all. That is not a loss of control, it is
698
+ * where the control actually lives now: the caller writes the `snapdom()` call.
699
+ * What each one does if the caller changes it:
700
+ *
701
+ * - `outerTransforms:false` strips the root's own transform from the clone, so
702
+ * it lays out unlike the element it copies — `drift` measures exactly that and
703
+ * `declareDrift` files it;
704
+ * - `burst` left unset cannot bite: a plugin with render hooks that has not
705
+ * declared `pure: true` suspends auto-burst by construction
706
+ * (`snapdom/src/core/plugins.js:184`, `hasImpureRenderPlugins`) — MEASURED,
707
+ * five consecutive captures of one element all ran the pipeline, where the
708
+ * old loose API had to pass `burst: false` to survive the third. Explicit
709
+ * `burst: true` is the caller taking that back; four in a row still re-entered
710
+ * `captureDOM` when measured, so a memo serve is a defended case and not an
711
+ * observed one — if one is ever served the hook does not fire, and the export
712
+ * throws instead of walking a clone from some other capture;
713
+ * - `dpr` scales the inlined raster assets, not the layout: every measurement
714
+ * here is a CSS-pixel `getBoundingClientRect` on the mounted clone.
715
+ *
716
+ * @param {CloneHandoff} handoff
717
+ * @returns {CloneCapture}
718
+ * @throws when the hand-off carries no clone, or when the mounted clone cannot
719
+ * be measured — the engine has no second way to get a clone and will not
720
+ * quietly fall back to walking the live DOM, which is a different and worse
721
+ * capture.
722
+ */
723
+ export function mountClone (handoff) {
724
+ const { element, clone, nodeMap, styleMap, fontsCSS, classCSS } = handoff || {}
725
+ if (!element || element.nodeType !== 1) {
726
+ throw new Error('mountClone: the hand-off carries no live element')
727
+ }
728
+ if (!clone || clone.nodeType !== 1) {
729
+ throw new Error(
730
+ 'mountClone: the capture handed over no clone. The `beforeRender` hook never ran, so the ' +
731
+ 'capture did not go through captureDOM — leave `burst` unset (the plugin suspends it) so a ' +
732
+ 'memoized URL is not served instead of a capture.'
733
+ )
734
+ }
735
+
736
+ const prefix = classPrefixFrom(classCSS)
737
+ const synthCSS = synthRulesFrom(clone, nodeMap, styleMap)
738
+
739
+ // `fontsCSS` first: it is `@font-face` and has to be parsed before anything
740
+ // that uses the families. All three go in ONE <style> so unmounting is one
741
+ // removal, and the synthesized-node rules go last because nothing else in the
742
+ // document says anything about those nodes.
743
+ const style = document.createElement('style')
744
+ style.setAttribute(MARK, 'clone-css')
745
+ style.textContent = (fontsCSS || '') + prefix.css + synthCSS
746
+ document.head.appendChild(style)
747
+
748
+ const host = document.createElement('div')
749
+ host.setAttribute(MARK, 'clone-host')
750
+ host.setAttribute('aria-hidden', 'true')
751
+ host.style.cssText = HOST_CSS
752
+ sizeHost(host, element)
753
+ host.appendChild(clone)
754
+ const isolatedRadios = isolateRadioGroups(clone)
755
+ document.body.appendChild(host)
756
+
757
+ let unmounted = false
758
+ const unmount = () => {
759
+ if (unmounted) return
760
+ unmounted = true
761
+ host.remove()
762
+ style.remove()
763
+ }
764
+
765
+ // After mounting and before measuring: connecting the clone is what upgrades
766
+ // its custom elements, and drift measured over their doubled boxes would be
767
+ // measuring our own damage.
768
+ let defused = []
769
+ try {
770
+ defused = defuseUpgradedShadowRoots(clone)
771
+ } catch { /* leave them; paint.js files B4.unresolved-shadow-root on each */ }
772
+
773
+ const restoredRootPaint = restoreRootPaint(element, clone)
774
+
775
+ let drift
776
+ try {
777
+ drift = measureDrift(clone, element, nodeMap)
778
+ } catch (error) {
779
+ unmount()
780
+ throw new Error(`mountClone: the mounted clone could not be measured: ${error && error.message}`)
781
+ }
782
+
783
+ return {
784
+ clone,
785
+ nodeMap,
786
+ unmount,
787
+ drift,
788
+ classPrefixExact: prefix.exact,
789
+ defusedShadowHosts: defused.length,
790
+ restoredRootPaint,
791
+ isolatedRadios,
792
+ }
793
+ }
794
+
795
+ export default mountClone