@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.
Files changed (77) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +50 -16
  3. package/agent-map.d.ts +32 -0
  4. package/agent-map.js +5 -3
  5. package/ascii-export.d.ts +10 -0
  6. package/ascii-export.js +21 -6
  7. package/capture-frames.js +5 -5
  8. package/color-tint.js +4 -0
  9. package/context-export.d.ts +23 -0
  10. package/context-export.js +4 -1
  11. package/filter.js +1 -1
  12. package/gif-export.d.ts +6 -0
  13. package/gif-export.js +3 -3
  14. package/html-export.d.ts +8 -0
  15. package/html-export.js +6 -4
  16. package/index.d.ts +2 -1
  17. package/index.js +2 -1
  18. package/package.json +16 -5
  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,2908 @@
1
+ /**
2
+ * The paint tree: DOM containment in, one pruned tree plus a back-to-front index out.
3
+ *
4
+ * This module owns the decision the rest of the export hangs from (D3): **the
5
+ * hierarchy is containment, the paint order is an index**. Every node carries
6
+ * `paint.z`, its rank among its emitted siblings per CSS 2.1 Appendix E, and the
7
+ * backend sorts `children` by it. A strict linearisation of Appendix E would give
8
+ * a flat reparented list, which is correct and useless — in Figma `children` is
9
+ * simultaneously z-order and Auto Layout flow order, so a flat list destroys hug
10
+ * sizing, padding, components and instances. Reparenting therefore happens only
11
+ * where the divergence is real AND visible: see `buildPaintTree`.
12
+ *
13
+ * Three exports, three different jobs:
14
+ *
15
+ * - `isStackingContext` and `paintOrder` are pure. They take plain objects, run
16
+ * in Node with no DOM, and are the two pieces the harness fuzzes.
17
+ * - `buildPaintTree` walks **snapdom's mounted clone**, not the live DOM (see the
18
+ * scope section of `packages/CONTRACT.md`). It produces structure and geometry
19
+ * only — it never calls a collector. Each node keeps its source element on a
20
+ * non-enumerable `el` (non-enumerable so the assembled document stays
21
+ * JSON-serialisable and `validate` never sees a DOM object in a node), and the
22
+ * orchestrator fills `fills`/`strokes`/`effects`/`text` from it.
23
+ *
24
+ * What walking the clone changes here, and nothing else does:
25
+ *
26
+ * - **Pseudo-elements are real elements.** `::before`/`::after`/`::first-letter`
27
+ * arrive as `<span data-snapdom-pseudo="::before">` with a measurable box, so
28
+ * they enter through the ordinary walk. There is no synthesis path and no "out
29
+ * of scope" declaration left for them; what remains is the two kinds the clone
30
+ * canNOT materialise (`::marker`, `::first-line`), which the browser still
31
+ * renders natively and we still lose.
32
+ * - **Shadow DOM is flattened**, its CSS rewritten to prefixed classes, so nothing
33
+ * traverses `shadowRoot` any more. A host that still HAS one means the caller
34
+ * handed us a live tree, and that is an error, not a silent omission — except
35
+ * for the one root the adapter itself provokes and repairs: mounting the clone
36
+ * upgrades its custom elements, the browser attaches a shadow root, and the
37
+ * adapter empties it back to a bare `<slot>` and marks it. That one is
38
+ * `B4.upgraded-custom-element`, info, and it is declared rather than hidden.
39
+ * - **The clone carries nodes the page does not have**: exclusion spacers, clip
40
+ * husks and scroll wrappers. Each is identified below and each says so — a node
41
+ * that disappears because it was an artifact still gets its own diagnostic code.
42
+ * - **Paint lives in prefixed CLASSES, not inline styles.** Every predicate here
43
+ * reads computed style, which is only real once the clone is mounted, so
44
+ * `buildPaintTree` refuses to walk a detached root rather than pruning a whole
45
+ * document that "paints nothing".
46
+ *
47
+ * Geometry convention: `abs` is the border box **with the whole transform chain
48
+ * from the capture root neutralised**, so a node's own `transform` composes with
49
+ * its `frame` instead of double-counting. Where a transform is in play the box
50
+ * cannot come from `getBoundingClientRect` — that returns the bbox of the
51
+ * transformed quad — so it comes from the layout offset chain, which is
52
+ * transform-free by definition and integer-rounded, and that rounding is a
53
+ * diagnostic like every other approximation.
54
+ */
55
+ import { parseTransform } from './css/matrix.js'
56
+ import { parseColor } from './css/color.js'
57
+ import { isExcluded } from './exclusion.js'
58
+
59
+ /** Never rendered, or rendered by something else that we walk instead. */
60
+ const SKIP_TAGS = new Set([
61
+ 'SCRIPT', 'STYLE', 'LINK', 'META', 'NOSCRIPT', 'TEMPLATE', 'TITLE', 'HEAD', 'BASE',
62
+ 'SOURCE', 'TRACK', 'PARAM', 'MAP', 'AREA', 'BR', 'WBR', 'SLOT',
63
+ ])
64
+
65
+ /** Replaced content that becomes an `image` node: one paint, no children of ours. */
66
+ const IMAGE_TAGS = new Set(['IMG', 'CANVAS', 'VIDEO', 'IFRAME', 'EMBED', 'OBJECT'])
67
+
68
+ /** Rasterised downstream — vector has nothing to say about their contents. */
69
+ const RASTER_ONLY_TAGS = new Set(['CANVAS', 'VIDEO', 'IFRAME', 'EMBED', 'OBJECT'])
70
+
71
+ /** UA-rendered widgets: a box we can read, plus internals we cannot. */
72
+ const CONTROL_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT', 'PROGRESS', 'METER'])
73
+
74
+ /** Subtrees whose paint needs a dedicated collector rather than the HTML walk. */
75
+ const OPAQUE_TAGS = new Set([
76
+ 'SVG', 'MATH', 'CANVAS', 'VIDEO', 'IFRAME', 'EMBED', 'OBJECT',
77
+ 'INPUT', 'TEXTAREA', 'SELECT', 'PROGRESS', 'METER', 'IMG',
78
+ ])
79
+ const MATH_NS = 'http://www.w3.org/1998/Math/MathML'
80
+
81
+ /** snapdom stamps every materialised pseudo-element with its own selector. */
82
+ const PSEUDO_ATTR = 'data-snapdom-pseudo'
83
+ /** The synthesized dim layer `prepareClone` appends for an open modal's `::backdrop`. */
84
+ const BACKDROP_ATTR = 'data-sd-backdrop'
85
+ /** Marks a clone whose flattened shadow tree was rewritten to this scope id. */
86
+ const SHADOW_SCOPE_ATTR = 'data-sd'
87
+ /**
88
+ * Marks a custom element the browser upgraded when the adapter connected the
89
+ * clone, and whose freshly attached shadow root the adapter emptied back down to
90
+ * a bare `<slot>`. The root is still there — you cannot detach one — so without
91
+ * this mark the check below would read our own repair as a live tree.
92
+ * Set by `adapters/snapdom.js`.
93
+ */
94
+ const DEFUSED_SHADOW_ATTR = 'data-snapdom-vector-defused'
95
+
96
+ /** `contain` values that contain paint or layout, and so make a stacking context. */
97
+ const CONTAIN_SC = /\b(layout|paint|strict|content)\b/
98
+ /** The subset that also clips. */
99
+ const CONTAIN_CLIP = /\b(paint|strict|content)\b/
100
+
101
+ /**
102
+ * `will-change` values that create a stacking context, per css-will-change: the
103
+ * properties that create one at any non-initial value. `z-index` is in Blink's
104
+ * list too but only bites on a positioned element, which the `position` test
105
+ * already caught.
106
+ */
107
+ const WILL_CHANGE_SC = new Set([
108
+ 'opacity', 'transform', 'rotate', 'scale', 'translate', 'perspective',
109
+ 'transform-style', 'filter', 'backdrop-filter', 'clip-path', 'mask',
110
+ 'mask-image', 'mask-border', 'isolation', 'mix-blend-mode', 'contain',
111
+ 'offset-path', 'offset', 'view-transition-name',
112
+ ])
113
+
114
+ /** Fidelity grades, worst-wins. Mirrors `FIDELITY` in @zumer/svd. */
115
+ const GRADE_RANK = { E: 0, A: 1, C: 2, R: 3, RH: 3, O: 4 }
116
+
117
+ const EPS = 0.01
118
+
119
+ // ——— pure helpers ———
120
+
121
+ /** A computed value that is present and not the "off" keyword. */
122
+ function isSet (v) {
123
+ return typeof v === 'string' && v !== '' && v !== 'none'
124
+ }
125
+
126
+ function num (v, fallback = 0) {
127
+ const n = parseFloat(v)
128
+ return Number.isFinite(n) ? n : fallback
129
+ }
130
+
131
+ /** `null` for `auto` — the distinction between auto and 0 decides the stacking context. */
132
+ function zIndexOf (cs) {
133
+ const raw = cs.zIndex
134
+ if (raw === undefined || raw === null || raw === '' || raw === 'auto') return null
135
+ const n = parseInt(raw, 10)
136
+ return Number.isFinite(n) ? n : null
137
+ }
138
+
139
+ function opacityOf (cs) {
140
+ const n = parseFloat(cs.opacity)
141
+ return Number.isFinite(n) ? n : 1
142
+ }
143
+
144
+ function floatOf (cs) {
145
+ return cs.float || cs.cssFloat || 'none'
146
+ }
147
+
148
+ function willChangeCreatesSC (value) {
149
+ if (!isSet(value)) return false
150
+ return value.split(',').some(v => WILL_CHANGE_SC.has(v.trim().toLowerCase()))
151
+ }
152
+
153
+ /** The document root element, which is a stacking context by definition. */
154
+ function isRootElement (el) {
155
+ const doc = el && el.ownerDocument
156
+ return !!doc && doc.documentElement === el
157
+ }
158
+
159
+ /**
160
+ * Only reached for `position: static` elements — everything positioned with a
161
+ * z-index has already returned true — so a flex/grid *container* child here is
162
+ * always a real flex/grid item.
163
+ */
164
+ function isFlexOrGridItem (el, parentCs) {
165
+ let cs = parentCs
166
+ if (!cs) {
167
+ const parent = el && el.parentElement
168
+ if (!parent || typeof getComputedStyle !== 'function') return false
169
+ cs = getComputedStyle(parent)
170
+ }
171
+ return /^(inline-)?(flex|grid)$/.test(cs.display || '')
172
+ }
173
+
174
+ /**
175
+ * Does `el` create a stacking context?
176
+ *
177
+ * Pure: `cs` may be any object with the computed properties as strings, so this
178
+ * runs in Node against a fake. `el` is only consulted for the two rules a style
179
+ * object cannot answer — being the document root, and being a flex/grid item —
180
+ * and both degrade to `false` when it is absent.
181
+ *
182
+ * @param {Element|null} el
183
+ * @param {CSSStyleDeclaration|Record<string,string>} cs computed style for `el`
184
+ * @param {CSSStyleDeclaration|Record<string,string>} [parentCs] computed style of
185
+ * the parent element; supplied by `buildPaintTree`, which already has it cached.
186
+ * Without it the flex/grid-item rule falls back to `getComputedStyle` and, with
187
+ * no DOM, is skipped.
188
+ * @returns {boolean}
189
+ */
190
+ export function isStackingContext (el, cs, parentCs) {
191
+ if (!cs) return false
192
+ if (isRootElement(el)) return true
193
+
194
+ const position = cs.position || 'static'
195
+ if (position === 'fixed' || position === 'sticky') return true
196
+
197
+ const z = zIndexOf(cs)
198
+ if (z !== null && position !== 'static') return true
199
+
200
+ if (opacityOf(cs) < 1) return true
201
+ if (isSet(cs.mixBlendMode) && cs.mixBlendMode !== 'normal') return true
202
+ if (cs.isolation === 'isolate') return true
203
+
204
+ if (isSet(cs.transform) || isSet(cs.rotate) || isSet(cs.scale) || isSet(cs.translate)) return true
205
+ if (isSet(cs.perspective)) return true
206
+ if (cs.transformStyle === 'preserve-3d' || cs.webkitTransformStyle === 'preserve-3d') return true
207
+ if (isSet(cs.offsetPath)) return true
208
+
209
+ if (isSet(cs.filter) || isSet(cs.webkitFilter)) return true
210
+ if (isSet(cs.backdropFilter) || isSet(cs.webkitBackdropFilter)) return true
211
+ if (isSet(cs.clipPath) || isSet(cs.webkitClipPath)) return true
212
+ if (isSet(cs.maskImage) || isSet(cs.webkitMaskImage)) return true
213
+ if (isSet(cs.maskBorderSource) || isSet(cs.webkitMaskBoxImage)) return true
214
+
215
+ if (CONTAIN_SC.test(cs.contain || '')) return true
216
+ if (cs.contentVisibility === 'auto' || cs.contentVisibility === 'hidden') return true
217
+ if (isSet(cs.viewTransitionName)) return true
218
+ if (willChangeCreatesSC(cs.willChange)) return true
219
+
220
+ return z !== null && isFlexOrGridItem(el, parentCs)
221
+ }
222
+
223
+ /** Inline-level display values, including `contents`, which joins the parent's IFC. */
224
+ function isInlineLevel (display) {
225
+ return /^(inline|ruby|contents)/.test(display || '')
226
+ }
227
+
228
+ /**
229
+ * Does this display keep the element's text in its PARENT's inline formatting
230
+ * context? Inline-level is not the same question: `inline-block`, `inline-flex`,
231
+ * `inline-grid` and `inline-table` are ATOMIC inlines — they take part in the
232
+ * parent's line box as one opaque item, and their own text is a context of their
233
+ * own. Treating them as non-atomic made `collect/text.js` (which draws the line
234
+ * in the right place) reject them while `paint.js` had already decided the parent
235
+ * spoke for them, and the text of every `<span class="pill">` in a table cell
236
+ * disappeared with nothing but a "no painted characters" note.
237
+ */
238
+ function joinsParentText (display) {
239
+ return /^(inline|ruby|contents)$/.test(display || '') || /^ruby-/.test(display || '')
240
+ }
241
+
242
+ /**
243
+ * Read an ordering descriptor off whatever the caller passed: a bare object with
244
+ * the CSS properties, or a record carrying a computed style under `cs`/`style`.
245
+ */
246
+ function descriptorOf (sibling, index) {
247
+ const src = sibling || {}
248
+ const cs = src.cs || src.style || src
249
+ const position = cs.position || 'static'
250
+ const z = zIndexOf(cs)
251
+ const sc = src.stackingContext !== undefined
252
+ ? !!src.stackingContext
253
+ : (src.paint && src.paint.stackingContext !== undefined
254
+ ? !!src.paint.stackingContext
255
+ : isStackingContext(src.el || null, cs))
256
+ const positioned = position !== 'static'
257
+ // A flex/grid item with a z-index is painted exactly as a positioned element
258
+ // with that z-index would be, so it takes the positioned lanes too.
259
+ const ordered = positioned || (z !== null && sc)
260
+
261
+ let layer
262
+ if (ordered && z !== null && z < 0) layer = 1
263
+ else if (ordered) layer = z === null || z === 0 ? 5 : 6
264
+ // css-color-3 §3.2 and css-transforms-1: an element that creates a stacking
265
+ // context without being positioned paints where a `position:relative;
266
+ // z-index:0` element would, NOT in its in-flow lane.
267
+ else if (sc) layer = 5
268
+ else if (floatOf(cs) !== 'none') layer = 3
269
+ else if (isInlineLevel(cs.display)) layer = 4
270
+ else layer = 2
271
+
272
+ return { layer, z: z === null ? 0 : z, index }
273
+ }
274
+
275
+ /**
276
+ * CSS 2.1 Appendix E, back to front, within one stacking context:
277
+ * the context root's own background and borders (that is the parent node, not a
278
+ * sibling, so it is not in this list), then negative stacking contexts ascending,
279
+ * in-flow non-inline blocks, non-positioned floats, in-flow inline content,
280
+ * z-index `0`/`auto` positioned boxes and non-positioned stacking contexts, then
281
+ * positive z-index ascending. Ties keep document order.
282
+ *
283
+ * The result is a **dense rank**, not the z-index: 0..n-1 in back-to-front order.
284
+ * That way sorting by `paint.z` reproduces the order with no tie-break rule, and
285
+ * the emitter never has to re-derive anything. The authored z-index survives in
286
+ * `source.css`.
287
+ *
288
+ * Pure.
289
+ *
290
+ * @param {Array<Object>} siblings each either a bare descriptor
291
+ * (`{position, zIndex, float, display, stackingContext}`) or a record with the
292
+ * computed style on `cs`/`style` and optionally the element on `el`.
293
+ * @returns {number[]} one integer per sibling, in input order
294
+ */
295
+ export function paintOrder (siblings) {
296
+ const list = (siblings || []).map(descriptorOf)
297
+ const sorted = list
298
+ .slice()
299
+ .sort((a, b) => a.layer - b.layer || a.z - b.z || a.index - b.index)
300
+ const out = new Array(list.length)
301
+ for (let rank = 0; rank < sorted.length; rank++) out[sorted[rank].index] = rank
302
+ return out
303
+ }
304
+
305
+ // ——— paint detection ———
306
+
307
+ /**
308
+ * `false` only when the colour is provably fully transparent. Computed colours
309
+ * that are not `rgb()`/`rgba()` (a `color()` or `oklch()` in a wide-gamut page)
310
+ * are assumed to paint: over-emitting a node costs a node, under-emitting loses
311
+ * the fill.
312
+ */
313
+ function paintsColor (value) {
314
+ if (!value || value === 'transparent') return false
315
+ const m = /^rgba?\(([^)]*)\)$/i.exec(value.trim())
316
+ if (!m) return true
317
+ const parts = m[1].split(/[,/]/).map(s => parseFloat(s))
318
+ return !(parts.length >= 4 && parts[3] === 0)
319
+ }
320
+
321
+ const SIDES = ['Top', 'Right', 'Bottom', 'Left']
322
+
323
+ function paintsBorder (cs) {
324
+ for (const side of SIDES) {
325
+ const style = cs[`border${side}Style`]
326
+ if (!style || style === 'none' || style === 'hidden') continue
327
+ if (num(cs[`border${side}Width`]) <= 0) continue
328
+ if (paintsColor(cs[`border${side}Color`])) return true
329
+ }
330
+ return false
331
+ }
332
+
333
+ function paintsOutline (cs) {
334
+ const style = cs.outlineStyle
335
+ if (!style || style === 'none' || style === 'hidden') return false
336
+ return num(cs.outlineWidth) > 0 && paintsColor(cs.outlineColor)
337
+ }
338
+
339
+ /** Anything `collectBox` would find: backgrounds, borders, outline, shadows, effects. */
340
+ function paintsBox (cs) {
341
+ return isSet(cs.backgroundImage) ||
342
+ paintsColor(cs.backgroundColor) ||
343
+ paintsBorder(cs) ||
344
+ paintsOutline(cs) ||
345
+ isSet(cs.boxShadow) ||
346
+ isSet(cs.filter) || isSet(cs.webkitFilter) ||
347
+ isSet(cs.backdropFilter) || isSet(cs.webkitBackdropFilter) ||
348
+ isSet(cs.maskImage) || isSet(cs.webkitMaskImage)
349
+ }
350
+
351
+ /**
352
+ * Compositing that must own a layer of its own. These force a node even with a
353
+ * single child — collapsing `opacity:.5` onto one child changes the result the
354
+ * moment that child has overlapping descendants, which is why the contract makes
355
+ * it a hard rule rather than a heuristic.
356
+ */
357
+ function forcesGroup (cs) {
358
+ return opacityOf(cs) < 1 ||
359
+ (isSet(cs.mixBlendMode) && cs.mixBlendMode !== 'normal') ||
360
+ cs.isolation === 'isolate' ||
361
+ isSet(cs.filter) || isSet(cs.webkitFilter) ||
362
+ isSet(cs.backdropFilter) || isSet(cs.webkitBackdropFilter) ||
363
+ isSet(cs.maskImage) || isSet(cs.webkitMaskImage)
364
+ }
365
+
366
+ function clipsContent (cs) {
367
+ return (cs.overflowX && cs.overflowX !== 'visible') ||
368
+ (cs.overflowY && cs.overflowY !== 'visible') ||
369
+ isSet(cs.clipPath) || isSet(cs.webkitClipPath) ||
370
+ CONTAIN_CLIP.test(cs.contain || '') ||
371
+ cs.contentVisibility === 'auto' || cs.contentVisibility === 'hidden'
372
+ }
373
+
374
+ /** Properties that move the border box away from where layout put it. */
375
+ function hasTransform (cs) {
376
+ return isSet(cs.transform) || isSet(cs.rotate) || isSet(cs.scale) ||
377
+ isSet(cs.translate) || isSet(cs.offsetPath)
378
+ }
379
+
380
+ /**
381
+ * Makes the element the containing block of `position:fixed` descendants — and,
382
+ * together with `position != static`, of absolutely positioned ones.
383
+ */
384
+ function isFixedContainingBlock (cs) {
385
+ return hasTransform(cs) ||
386
+ isSet(cs.filter) || isSet(cs.webkitFilter) ||
387
+ isSet(cs.backdropFilter) || isSet(cs.webkitBackdropFilter) ||
388
+ isSet(cs.perspective) ||
389
+ CONTAIN_SC.test(cs.contain || '') ||
390
+ willChangeCreatesSC(cs.willChange)
391
+ }
392
+
393
+ // ——— geometry ———
394
+
395
+ /**
396
+ * Untransformed border box in layout space. `offsetLeft`/`offsetTop` are defined
397
+ * against the offset parent's PADDING edge, hence the `clientLeft`/`clientTop`
398
+ * correction on the way up, and none of them sees a transform — which is the
399
+ * whole reason this exists next to `getBoundingClientRect`. They are rounded to
400
+ * integers, so every caller records the loss.
401
+ */
402
+ function layoutBox (el) {
403
+ if (typeof el.offsetWidth !== 'number' || typeof el.offsetLeft !== 'number') return null
404
+ let x = 0
405
+ let y = 0
406
+ let node = el
407
+ let guard = 0
408
+ while (node && guard++ < 1024) {
409
+ x += node.offsetLeft
410
+ y += node.offsetTop
411
+ const parent = node.offsetParent
412
+ if (!parent) break
413
+ x += parent.clientLeft
414
+ y += parent.clientTop
415
+ node = parent
416
+ }
417
+ return { x, y, w: el.offsetWidth, h: el.offsetHeight }
418
+ }
419
+
420
+ function rectOf (el) {
421
+ const r = el.getBoundingClientRect()
422
+ return { x: r.left, y: r.top, w: r.width, h: r.height }
423
+ }
424
+
425
+ /** MathMLElement has no offset box. Measure the clone with its transform chain
426
+ * neutralized, then restore it synchronously before any collector runs. Using
427
+ * its painted bbox as layout would apply translations and scales twice. */
428
+ function mathLayoutBox (el, rootEl) {
429
+ const saved = []
430
+ try {
431
+ for (let current = el; current; current = current.parentElement) {
432
+ const style = current.style
433
+ if (style) {
434
+ const transformed = hasTransform(current.ownerDocument.defaultView.getComputedStyle(current))
435
+ saved.push([current, current.getAttribute('style')])
436
+ for (const name of ['transition', 'translate', 'rotate', 'scale', 'offset-path']) {
437
+ style.setProperty(name, 'none', 'important')
438
+ }
439
+ // An identity matrix keeps the containing block that an existing
440
+ // transform establishes for fixed/absolute descendants.
441
+ style.setProperty('transform', transformed ? 'matrix(1,0,0,1,0,0)' : 'none', 'important')
442
+ }
443
+ if (current === rootEl) break
444
+ }
445
+ return { box: rectOf(el), root: rectOf(rootEl) }
446
+ } finally {
447
+ // Restore geometry while transitions are still disabled. Otherwise the
448
+ // measurement itself could start a new transition on the mounted clone.
449
+ for (const [element, style] of saved) {
450
+ if (style === null) element.removeAttribute('style')
451
+ else element.setAttribute('style', style)
452
+ element.style.setProperty('transition', 'none', 'important')
453
+ }
454
+ if (saved.length) el.getBoundingClientRect()
455
+ for (const [element, style] of saved) {
456
+ if (style === null) element.removeAttribute('style')
457
+ else element.setAttribute('style', style)
458
+ }
459
+ }
460
+ }
461
+
462
+ function unionBoxes (boxes) {
463
+ if (!boxes.length) return { x: 0, y: 0, w: 0, h: 0 }
464
+ let x0 = Infinity
465
+ let y0 = Infinity
466
+ let x1 = -Infinity
467
+ let y1 = -Infinity
468
+ for (const b of boxes) {
469
+ x0 = Math.min(x0, b.x)
470
+ y0 = Math.min(y0, b.y)
471
+ x1 = Math.max(x1, b.x + b.w)
472
+ y1 = Math.max(y1, b.y + b.h)
473
+ }
474
+ return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 }
475
+ }
476
+
477
+ function overlaps (a, b) {
478
+ return a.x < b.x + b.w - EPS && b.x < a.x + a.w - EPS &&
479
+ a.y < b.y + b.h - EPS && b.y < a.y + a.h - EPS
480
+ }
481
+
482
+ /** `outer` covers all of `inner`, so a clip at `outer` would cut nothing off it. */
483
+ function containsBox (outer, inner) {
484
+ return inner.x >= outer.x - EPS && inner.y >= outer.y - EPS &&
485
+ inner.x + inner.w <= outer.x + outer.w + EPS &&
486
+ inner.y + inner.h <= outer.y + outer.h + EPS
487
+ }
488
+
489
+ // ——— snapdom clone artifacts ———
490
+
491
+ /**
492
+ * Which pseudo-element this clone node IS, or `null` for an ordinary element.
493
+ *
494
+ * `pseudo.js` materialises `::before`, `::after` and `::first-letter` as spans that
495
+ * carry their own selector in `data-snapdom-pseudo`; `prepare.js` appends the
496
+ * `::backdrop` of a lifted top-layer element as a `[data-sd-backdrop]` div. Both are
497
+ * real boxes with real paint — the only thing they need from us is to say what they
498
+ * are, because a layer named `span` is a layer the designer cannot place.
499
+ *
500
+ * @param {Element} el
501
+ * @returns {string|null} e.g. `'::before'`
502
+ */
503
+ function pseudoSelectorOf (el) {
504
+ if (typeof el.getAttribute !== 'function') return null
505
+ const marked = el.getAttribute(PSEUDO_ATTR)
506
+ if (marked) return marked.startsWith('::') ? marked : `::${marked.replace(/^:/, '')}`
507
+ return el.hasAttribute(BACKDROP_ATTR) ? '::backdrop' : null
508
+ }
509
+
510
+ /**
511
+ * `makeHideSpacer` (snapdom/src/core/clone.js): the stand-in for an element the
512
+ * caller excluded in `hide` mode. A bare div whose entire style attribute is the
513
+ * four declarations below — it holds the space and paints nothing, and snapdom
514
+ * gives it no attribute of its own, so the signature IS the identification. An
515
+ * authored div cannot wear it by accident: it would have to carry those exact four
516
+ * declarations inline, no class and no content.
517
+ */
518
+ function isExclusionSpacer (el) {
519
+ if (el.tagName !== 'DIV' || el.firstChild) return false
520
+ if (el.attributes.length !== 1 || !el.hasAttribute('style')) return false
521
+ const s = el.style
522
+ return s.length === 4 && s.visibility === 'hidden' && !!s.display && !!s.width && !!s.height
523
+ }
524
+
525
+ /**
526
+ * `makeClipHusk` (same file): what is left of a subtree that painted entirely
527
+ * outside the capture's clip window. A shallow clone — so it is empty — with the
528
+ * original's computed styles, a frozen box pinned by matching min/max, and
529
+ * `visibility:hidden`. Unlike a spacer it stands for content that exists on the
530
+ * page, which is why it is emitted rather than pruned.
531
+ */
532
+ function isClipHusk (el) {
533
+ if (el.firstChild) return false
534
+ const s = el.style
535
+ if (!s || s.visibility !== 'hidden' || s.boxSizing !== 'border-box') return false
536
+ const pinnedW = !!s.width && s.minWidth === s.width && s.maxWidth === s.width
537
+ const pinnedH = !!s.height && s.minHeight === s.height && s.maxHeight === s.height
538
+ return pinnedW || pinnedH
539
+ }
540
+
541
+ /**
542
+ * `wrapScrolledClone` (snapdom/src/core/prepare.js): the inner div that carries
543
+ * `translate(-scrollX, -scrollY)` so a scrolled container shows what it showed live.
544
+ * It is not an element of the page, but it positions every child it holds, so it is
545
+ * kept — dropping the layer would drop the offset with it.
546
+ */
547
+ function isScrollWrapper (el) {
548
+ if (el.tagName !== 'DIV') return false
549
+ if (el.attributes.length !== 1 || !el.hasAttribute('style')) return false
550
+ const s = el.style
551
+ // `all: unset` is read from the CSSOM, never from the style ATTRIBUTE: Chromium
552
+ // serializes the shorthand expanded into every longhand it covers, so the attribute
553
+ // text has no `all:` in it at all while `style.all` still answers `unset`.
554
+ return s.all === 'unset' && s.display === 'inline-block' && s.width === '100%' &&
555
+ s.willChange === 'transform' && /^translate\(/.test(s.transform || '')
556
+ }
557
+
558
+ /**
559
+ * `svgDefs.js` (snapdom): a zero-sized `<svg class="inline-defs-container">`
560
+ * inserted as the clone's first child, holding copies of the `<defs>` that the
561
+ * captured SVGs reference but that live OUTSIDE the captured subtree. It is a
562
+ * lookup table, not a picture — it paints nothing at any size, and its whole
563
+ * content is reached through `url(#…)` from elsewhere.
564
+ *
565
+ * It has to be named here because the walk would otherwise type it `vector`,
566
+ * hand it to `collectImage`, and mint a 300×150 asset for a 0×0 box: a fourth
567
+ * icon that is not an icon. `collect/image.js` resolves references against this
568
+ * container by design, so pruning the node loses nothing.
569
+ */
570
+ function isInlineDefsContainer (el) {
571
+ return el.localName === 'svg' && !!el.classList &&
572
+ el.classList.contains('inline-defs-container') && el.getAttribute('aria-hidden') === 'true'
573
+ }
574
+
575
+ /**
576
+ * @param {Element} el
577
+ * @returns {'exclusion-spacer'|'clip-husk'|'scroll-wrapper'|'defs-container'|null}
578
+ */
579
+ function cloneArtifactOf (el) {
580
+ if (typeof el.hasAttribute !== 'function' || !el.style) return null
581
+ if (isExclusionSpacer(el)) return 'exclusion-spacer'
582
+ if (isScrollWrapper(el)) return 'scroll-wrapper'
583
+ if (isClipHusk(el)) return 'clip-husk'
584
+ if (isInlineDefsContainer(el)) return 'defs-container'
585
+ return null
586
+ }
587
+
588
+ // ——— CSS counters: verifying the numbers the clone arrived with ———
589
+
590
+ /**
591
+ * A materialised `::before` carries its number as literal text, so nothing
592
+ * downstream can tell `2.1` from `2.3` — the export is not low-fidelity, it is
593
+ * WRONG, and it says so nowhere. This section recomputes the counters the way
594
+ * css-lists-3 §4 defines them and compares, so a wrong number leaves as a
595
+ * declared wrong number instead of a confident one.
596
+ *
597
+ * It repairs nothing. The resolution happens in snapdom, before the clone exists
598
+ * (`snapdom/src/modules/counter.js`), and the numbers are baked into text nodes
599
+ * that were already laid out at that width; rewriting them here would be a
600
+ * second implementation of a feature that has one owner.
601
+ */
602
+
603
+ /** `counter-reset: a 1, b` → `[['a',1],['b',0]]`. `none`/empty → `[]`. */
604
+ function parseCounterDecl (value, fallback) {
605
+ if (typeof value !== 'string' || !value || value === 'none') return []
606
+ const out = []
607
+ for (const part of value.split(',')) {
608
+ const tokens = part.trim().split(/\s+/).filter(Boolean)
609
+ if (!tokens.length) continue
610
+ // `reversed(name)` is css-lists-3 and Chromium serializes it verbatim; the
611
+ // walk has no reversed support, so the counter is left out rather than
612
+ // counted forwards.
613
+ if (/^reversed\(/i.test(tokens[0])) continue
614
+ const n = Number(tokens[1])
615
+ out.push([tokens[0], Number.isFinite(n) ? n : fallback])
616
+ }
617
+ return out
618
+ }
619
+
620
+ const ROMAN = [[1000, 'M'], [900, 'CM'], [500, 'D'], [400, 'CD'], [100, 'C'], [90, 'XC'],
621
+ [50, 'L'], [40, 'XL'], [10, 'X'], [9, 'IX'], [5, 'V'], [4, 'IV'], [1, 'I']]
622
+
623
+ /**
624
+ * The counter styles whose rendering is defined in css-counter-styles-3 and can
625
+ * be reproduced here. A custom `@counter-style` cannot, and a token that names
626
+ * one aborts the whole comparison rather than guessing at it.
627
+ *
628
+ * @returns {string|null} null when the style is not one we can render
629
+ */
630
+ function formatCounter (value, style) {
631
+ switch (String(style || 'decimal').toLowerCase()) {
632
+ case 'decimal': return String(value)
633
+ case 'decimal-leading-zero': {
634
+ const abs = Math.abs(value)
635
+ return `${value < 0 ? '-' : ''}${abs < 10 ? '0' : ''}${abs}`
636
+ }
637
+ case 'lower-alpha': case 'lower-latin': case 'upper-alpha': case 'upper-latin': {
638
+ if (value < 1) return String(value)
639
+ let s = ''
640
+ let x = value
641
+ while (x > 0) { x--; s = String.fromCharCode(97 + (x % 26)) + s; x = Math.floor(x / 26) }
642
+ return /upper/.test(style) ? s.toUpperCase() : s
643
+ }
644
+ case 'lower-roman': case 'upper-roman': {
645
+ if (value < 1 || value > 3999) return String(value)
646
+ let num = value
647
+ let out = ''
648
+ for (const [v, sym] of ROMAN) while (num >= v) { out += sym; num -= v }
649
+ return style === 'upper-roman' ? out : out.toLowerCase()
650
+ }
651
+ default: return null
652
+ }
653
+ }
654
+
655
+ /**
656
+ * Correct counter values for every element under `root`, keyed by element.
657
+ *
658
+ * The scope rule is the whole game and it is the one snapdom gets wrong: a
659
+ * counter created by `counter-reset` on E is in scope for **E, E's descendants
660
+ * and E's following siblings**, and for nothing else. So the state an element
661
+ * hands to its next sibling is the state as of the element ITSELF — instances
662
+ * pushed by its descendants are gone, while increments those descendants applied
663
+ * to instances that were already in scope survive, which falls out of sharing
664
+ * the instance objects by reference instead of copying their values.
665
+ *
666
+ * @returns {WeakMap<Element, Map<string, number[]>>} innermost value last
667
+ */
668
+ function buildCounterValues (root, st) {
669
+ const values = new WeakMap()
670
+ const dfs = (el, incoming) => {
671
+ const cs = st.styleOf(el)
672
+ // `display:none` generates no boxes and therefore no counters.
673
+ if (!cs || cs.display === 'none') return incoming
674
+
675
+ let state = incoming
676
+ const push = (name, instance) => {
677
+ const next = new Map(state)
678
+ next.set(name, [...(state.get(name) || []), instance])
679
+ state = next
680
+ }
681
+ const top = (name) => {
682
+ const stack = state.get(name)
683
+ return stack && stack.length ? stack[stack.length - 1] : null
684
+ }
685
+
686
+ for (const [name, value] of parseCounterDecl(cs.counterReset, 0)) push(name, { value })
687
+ for (const [name, value] of parseCounterDecl(cs.counterSet, 0)) {
688
+ const instance = top(name)
689
+ if (instance) instance.value = value
690
+ else push(name, { value })
691
+ }
692
+ for (const [name, by] of parseCounterDecl(cs.counterIncrement, 1)) {
693
+ const instance = top(name)
694
+ // No counter in scope: css-lists-3 says the increment implies a
695
+ // `counter-reset: name 0` on this element, scope included.
696
+ if (instance) instance.value += by
697
+ else push(name, { value: by })
698
+ }
699
+
700
+ const snapshot = new Map()
701
+ for (const [name, stack] of state) snapshot.set(name, stack.map((i) => i.value))
702
+ values.set(el, snapshot)
703
+
704
+ let childState = state
705
+ for (const child of el.children) childState = dfs(child, childState)
706
+ // `state`, not `childState`: what a following sibling sees is this element's
707
+ // own scope, with the shared instances carrying whatever the subtree did.
708
+ return state
709
+ }
710
+ dfs(root, new Map())
711
+ return values
712
+ }
713
+
714
+ /** Rules that could be the source of a counter-bearing `content`, in sheet order. */
715
+ function counterContentRules (st) {
716
+ if (st.counterRules) return st.counterRules
717
+ const rules = []
718
+ const collect = (list) => {
719
+ for (const rule of list || []) {
720
+ const content = rule.style && rule.style.content
721
+ if (content && /\bcounters?\s*\(/.test(content)) {
722
+ for (const part of String(rule.selectorText || '').split(',')) {
723
+ const m = /^(.*?)::?(before|after)\s*$/i.exec(part.trim())
724
+ if (m && m[1]) rules.push({ base: m[1].trim(), sel: `::${m[2].toLowerCase()}`, content })
725
+ }
726
+ }
727
+ // Not `else`: CSS nesting gave CSSStyleRule a `cssRules` of its own, so a
728
+ // rule can both declare and contain. Testing containment first skipped
729
+ // every style rule in the sheet — an empty CSSRuleList is still truthy.
730
+ if (rule.cssRules) collect(rule.cssRules)
731
+ }
732
+ }
733
+ if (typeof document !== 'undefined' && document.styleSheets) {
734
+ for (const sheet of document.styleSheets) {
735
+ // A cross-origin sheet throws on `cssRules`; its templates are simply not
736
+ // knowable from here, and an unknown template means no claim is made.
737
+ try { collect(sheet.cssRules) } catch { /* not readable */ }
738
+ }
739
+ }
740
+ st.counterRules = rules
741
+ return rules
742
+ }
743
+
744
+ /**
745
+ * The `content` template behind a materialised pseudo — from the page's own
746
+ * stylesheets, because the clone puts it out of reach of `getComputedStyle`:
747
+ * snapdom suppresses `content` on the originating element
748
+ * (`[data-snapdom-has-before]::before{content:none}`) so the span is not painted
749
+ * on top of the pseudo it stands for.
750
+ *
751
+ * Last matching rule wins, which is the cascade for equal specificity and close
752
+ * enough for a check that fails silent — a wrong template does not match the
753
+ * rendered text and the comparison is abandoned rather than reported.
754
+ */
755
+ function pseudoContentTemplate (el, sel, st) {
756
+ let found = null
757
+ for (const rule of counterContentRules(st)) {
758
+ if (rule.sel !== sel) continue
759
+ try {
760
+ if (el.matches(rule.base)) found = rule.content
761
+ } catch { /* an unsupported selector is not a match */ }
762
+ }
763
+ return found
764
+ }
765
+
766
+ /**
767
+ * Splits a computed `content` value into literals and counter calls.
768
+ * @returns {Array<{lit:string}|{name:string, style:string, sep:string|null}>|null}
769
+ * null when the value holds anything else — `attr()`, `url()`, quotes — since
770
+ * only an exactly reconstructible template can convict a number of being wrong.
771
+ */
772
+ function parseContentTokens (css) {
773
+ const out = []
774
+ let i = 0
775
+ const text = String(css)
776
+ while (i < text.length) {
777
+ const ch = text[i]
778
+ if (/\s/.test(ch)) { i++; continue }
779
+ if (ch === '"' || ch === "'") {
780
+ let lit = ''
781
+ i++
782
+ while (i < text.length && text[i] !== ch) {
783
+ if (text[i] === '\\') {
784
+ // CSS escapes in a serialized string are `\"`, `\\` and hex escapes.
785
+ const hex = /^\\([0-9a-f]{1,6})\s?/i.exec(text.slice(i))
786
+ if (hex) { lit += String.fromCodePoint(parseInt(hex[1], 16)); i += hex[0].length; continue }
787
+ lit += text[i + 1] || ''
788
+ i += 2
789
+ continue
790
+ }
791
+ lit += text[i++]
792
+ }
793
+ i++
794
+ out.push({ lit })
795
+ continue
796
+ }
797
+ const call = /^counters?\s*\(/i.exec(text.slice(i))
798
+ if (!call) return null
799
+ const isCounters = /^counters/i.test(call[0])
800
+ const close = text.indexOf(')', i)
801
+ if (close === -1) return null
802
+ const args = text.slice(i + call[0].length, close).split(',').map((a) => a.trim())
803
+ i = close + 1
804
+ const unquote = (v) => (v && /^["'].*["']$/.test(v) ? v.slice(1, -1) : v)
805
+ if (isCounters) out.push({ name: args[0], sep: unquote(args[1]) || '', style: args[2] || 'decimal' })
806
+ else out.push({ name: args[0], sep: null, style: args[1] || 'decimal' })
807
+ }
808
+ return out.length ? out : null
809
+ }
810
+
811
+ const RX_ESCAPE = /[.*+?^${}()|[\]\\]/g
812
+
813
+ /**
814
+ * Checks one materialised pseudo's number against the counters the clone itself
815
+ * declares, and says so when they disagree.
816
+ *
817
+ * @param {Element} span the `<span data-snapdom-pseudo>` snapdom materialised
818
+ * @param {string} sel `'::before'` or `'::after'`
819
+ * @returns {{expected:string, observed:string, names:string[]}|null} null when
820
+ * they agree, when the template is not reconstructible, or when the element
821
+ * has no counter-bearing content at all
822
+ */
823
+ function checkCounterContent (span, sel, st) {
824
+ const origin = span.parentElement
825
+ if (!origin) return null
826
+ const template = pseudoContentTemplate(origin, sel, st)
827
+ if (!template) return null
828
+ const tokens = parseContentTokens(template)
829
+ if (!tokens) return null
830
+
831
+ if (!st.counters) st.counters = buildCounterValues(st.rootEl, st)
832
+ // The pseudo is a child of its originating element in the clone and DFS order
833
+ // put it after the element's own increments, which is where CSS puts it too.
834
+ const scope = st.counters.get(span) || st.counters.get(origin)
835
+ if (!scope) return null
836
+
837
+ let expected = ''
838
+ const names = []
839
+ let pattern = ''
840
+ for (const token of tokens) {
841
+ if (token.lit !== undefined) {
842
+ expected += token.lit
843
+ pattern += token.lit.replace(RX_ESCAPE, '\\$&')
844
+ continue
845
+ }
846
+ const stack = scope.get(token.name) || []
847
+ names.push(token.name)
848
+ const parts = (token.sep === null ? stack.slice(-1) : stack).map((v) => formatCounter(v, token.style))
849
+ if (parts.some((p) => p === null)) return null
850
+ expected += parts.join(token.sep === null ? '' : token.sep)
851
+ pattern += '(.*?)'
852
+ }
853
+
854
+ const observed = span.textContent || ''
855
+ if (observed === expected) return null
856
+ // The template has to explain the rendered string before it can accuse it: if
857
+ // it does not match, the rule we picked is not the rule that produced this
858
+ // text and nothing here is worth reporting.
859
+ if (!new RegExp(`^${pattern}$`).test(observed)) return null
860
+ return { expected, observed, names }
861
+ }
862
+
863
+ // ——— ::marker: rebuilding what the UA paints and the clone cannot carry ———
864
+
865
+ /**
866
+ * A list marker is the one piece of generated content snapdom leaves native: it
867
+ * has no element, no box and no `getComputedStyle` that answers where it is, so
868
+ * for as long as it stayed native the item exported without its number. What IS
869
+ * readable is everything it is MADE of — `list-style-type` plus the page's own
870
+ * `@counter-style` rules from the CSSOM, the marker's font and colour from
871
+ * `getComputedStyle(li, '::marker')`, and the ordinal from the list itself — so
872
+ * the marker is rebuilt as a real absolutely-positioned span in the clone and
873
+ * then walked, measured and collected like any other text. No geometry is
874
+ * invented: the span is placed, measured, corrected by the delta, and measured
875
+ * again, and one that does not land where it was aimed is removed rather than
876
+ * shipped.
877
+ *
878
+ * Two facts about Chromium's placement, both measured on `demo/challenges.html`
879
+ * rather than assumed (`§1 · ` at 12px/750 ends at 510.0 with the item's content
880
+ * edge at 510; `◆ ` at 11px ends at 534.0 with the edge at 534):
881
+ *
882
+ * - a `list-style-position: outside` marker is RIGHT-ALIGNED to the item's
883
+ * content edge — the marker string's advance ends exactly there;
884
+ * - it sits on the item's first line baseline.
885
+ *
886
+ * The string itself is the part that could be silently wrong, so it is checked
887
+ * against the browser rather than trusted: flipping the item to
888
+ * `list-style-position: inside` puts the real marker in the line box and the
889
+ * first text run moves right by exactly the real marker's advance. If our
890
+ * candidate string does not measure the same, we do not know what the UA is
891
+ * drawing and the item keeps its `B4.list-marker` declaration.
892
+ */
893
+
894
+ /** Ceiling on the probe: two forced layouts over a list this long is not worth it. */
895
+ const MAX_SYNTH_MARKERS = 400
896
+
897
+ /** Ascents come from a canvas, the same source `collect/text.js` measures baselines with. */
898
+ function measureContext (st) {
899
+ if (st.measure === undefined) {
900
+ try {
901
+ st.measure = document.createElement('canvas').getContext('2d')
902
+ } catch {
903
+ st.measure = null
904
+ }
905
+ }
906
+ return st.measure
907
+ }
908
+
909
+ function fontShorthand (cs) {
910
+ return `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`
911
+ }
912
+
913
+ /** Chromium rounds `fontBoundingBox*` per size, so this caches on the exact string. */
914
+ function ascentOf (font, st) {
915
+ if (!st.ascents) st.ascents = new Map()
916
+ if (st.ascents.has(font)) return st.ascents.get(font)
917
+ const g = measureContext(st)
918
+ let ascent = null
919
+ if (g) {
920
+ g.font = font
921
+ if (g.font) {
922
+ const m = g.measureText('Hxg')
923
+ if (Number.isFinite(m.fontBoundingBoxAscent)) ascent = m.fontBoundingBoxAscent
924
+ }
925
+ }
926
+ st.ascents.set(font, ascent)
927
+ return ascent
928
+ }
929
+
930
+ /** `"◆" "▲" "●"` → `['◆','▲','●']`; null when a token is not a quoted string. */
931
+ function parseSymbolList (css) {
932
+ if (typeof css !== 'string') return null
933
+ const out = []
934
+ let i = 0
935
+ while (i < css.length) {
936
+ const ch = css[i]
937
+ if (/\s/.test(ch)) { i++; continue }
938
+ if (ch !== '"' && ch !== "'") return null
939
+ let lit = ''
940
+ i++
941
+ while (i < css.length && css[i] !== ch) {
942
+ if (css[i] === '\\') {
943
+ const hex = /^\\([0-9a-f]{1,6})\s?/i.exec(css.slice(i))
944
+ if (hex) { lit += String.fromCodePoint(parseInt(hex[1], 16)); i += hex[0].length; continue }
945
+ lit += css[i + 1] || ''
946
+ i += 2
947
+ continue
948
+ }
949
+ lit += css[i++]
950
+ }
951
+ i++
952
+ out.push(lit)
953
+ }
954
+ return out
955
+ }
956
+
957
+ /** The page's `@counter-style` rules, by name. */
958
+ function counterStyleRules (st) {
959
+ if (st.counterStyles) return st.counterStyles
960
+ const map = new Map()
961
+ const collect = (list) => {
962
+ for (const rule of list || []) {
963
+ if (typeof rule.name === 'string' && typeof rule.system === 'string' &&
964
+ typeof rule.symbols === 'string') map.set(rule.name, rule)
965
+ if (rule.cssRules) collect(rule.cssRules)
966
+ }
967
+ }
968
+ if (typeof document !== 'undefined' && document.styleSheets) {
969
+ for (const sheet of document.styleSheets) {
970
+ try { collect(sheet.cssRules) } catch { /* cross-origin */ }
971
+ }
972
+ }
973
+ st.counterStyles = map
974
+ return map
975
+ }
976
+
977
+ /**
978
+ * The predefined styles css-counter-styles-3 §6 writes down as `@counter-style`
979
+ * rules, which is why they can be reproduced at all: they are normative text,
980
+ * not UA private data. The bullet styles declare `suffix: " "`; the numeric and
981
+ * alphabetic ones declare none and so take the property's initial value, `". "`.
982
+ * Anything not here (`disclosure-*`, the CJK and Ethiopic systems, `extends`)
983
+ * stays unreproduced rather than approximated.
984
+ */
985
+ const PREDEFINED_MARKERS = {
986
+ disc: '• ',
987
+ circle: '◦ ',
988
+ square: '▪ ',
989
+ }
990
+
991
+ /**
992
+ * The three markers Chromium does not render as text at all: it paints a shape,
993
+ * sized from the marker's font-size and from nothing else. Measured
994
+ * (`scratchpad/probe-marker2.mjs`, dpr 4, Arial / Georgia / Times at 10..40px):
995
+ * the ink is the SAME width in all three families at the same size, which is
996
+ * what proves the glyph is not the font's — a `•` set as text would be as wide
997
+ * as the family makes it, and would land where the string's advance puts it.
998
+ *
999
+ * That is why a bullet cannot be synthesized as text: at 14px the UA's marker
1000
+ * BOX is 19.00px wide, its ink is 4.00px, and `"• "` set in the page's own font
1001
+ * measures 10.25px. Right-aligning that string to the item's content edge — the
1002
+ * rule the text markers are placed by and verified against — puts the bullet
1003
+ * about 7px right of where Chromium paints it, which reads as a bullet stuck to
1004
+ * its own text.
1005
+ *
1006
+ * The three laws below are least-squares fits over those 12 sizes, and each one
1007
+ * carries the worst residual it had. They are Chromium's, and `B4.list-marker-
1008
+ * synthesized` says so on every node that uses them.
1009
+ */
1010
+ const SYMBOL_MARKERS = new Set(['disc', 'circle', 'square'])
1011
+
1012
+ /** Ink diameter. `round(0.3 · font-size)` reproduced all 12 measurements exactly. */
1013
+ const symbolDiameter = (fontSize) => Math.max(1, Math.round(0.3 * fontSize))
1014
+
1015
+ /** Gap from the ink's right edge to the item's content edge. Worst residual 0.5px. */
1016
+ const symbolGap = (fontSize) => 0.3 * fontSize + 6.7
1017
+
1018
+ /** Ink centre above the first baseline. Worst residual 0.7px (at 11px). */
1019
+ const symbolRise = (fontSize) => 0.29 * fontSize
1020
+
1021
+ /**
1022
+ * The `::marker`'s own `content`, which REPLACES the marker the list-style
1023
+ * produces (css-lists-3 §3.2) — and which, unlike everything else about a
1024
+ * marker, `getComputedStyle` answers honestly.
1025
+ *
1026
+ * Reading it is not a refinement, it is a correctness fix: `demo/figma.html`'s
1027
+ * `.cl-steps li::marker { content: counter(list-item) " ·" }` renders `1 ·` and
1028
+ * this build was emitting `1. ` — the string `list-style-type: decimal` would
1029
+ * have produced. It passed the advance check because the two are 0.4px apart,
1030
+ * so a wrong STRING shipped as a verified one.
1031
+ *
1032
+ * @returns {string|null|'none'} the raw CSS value, `'none'` when the author
1033
+ * suppressed the marker, null when there is no authored content
1034
+ */
1035
+ function markerContentOf (markerCs) {
1036
+ const raw = markerCs && typeof markerCs.content === 'string' ? markerCs.content.trim() : ''
1037
+ if (!raw || raw === 'normal' || raw === 'auto') return null
1038
+ if (raw === 'none') return 'none'
1039
+ return raw
1040
+ }
1041
+
1042
+ /**
1043
+ * The ordinals of `li` and of every list item it is nested in, outermost first —
1044
+ * what `counters(list-item, sep)` renders.
1045
+ */
1046
+ function listItemStack (li, st) {
1047
+ const out = []
1048
+ let node = li
1049
+ let guard = 0
1050
+ while (node && guard++ < 256) {
1051
+ const cs = st.styleOf(node)
1052
+ if (cs && /list-item/.test(cs.display || '')) {
1053
+ const ordinal = listItemOrdinal(node, st)
1054
+ if (ordinal === null) return null
1055
+ out.unshift(ordinal)
1056
+ }
1057
+ node = node.parentElement
1058
+ }
1059
+ return out
1060
+ }
1061
+
1062
+ /**
1063
+ * An authored `::marker { content }` rendered to the string it paints, or null
1064
+ * when it is not a template this build can reproduce exactly — `attr()`, a
1065
+ * `url()`, an image, or a counter style it cannot format. Null is a rejection,
1066
+ * never a fallback: the whole point of reading `content` is that guessing
1067
+ * produced the wrong glyphs.
1068
+ */
1069
+ function resolveMarkerContent (css, li, st) {
1070
+ let tokens = null
1071
+ try {
1072
+ tokens = parseContentTokens(css)
1073
+ } catch {
1074
+ return null
1075
+ }
1076
+ if (!tokens) return null
1077
+ let out = ''
1078
+ let nested = false
1079
+ for (const token of tokens) {
1080
+ if (token.lit !== undefined) {
1081
+ out += token.lit
1082
+ continue
1083
+ }
1084
+ let parts
1085
+ if (token.name === 'list-item') {
1086
+ const stack = listItemStack(li, st)
1087
+ if (!stack || !stack.length) return null
1088
+ parts = (token.sep === null ? stack.slice(-1) : stack).map((v) => formatCounter(v, token.style))
1089
+ } else {
1090
+ if (!st.counters) st.counters = buildCounterValues(st.rootEl, st)
1091
+ const scope = st.counters.get(li)
1092
+ if (!scope) return null
1093
+ const stack = scope.get(token.name) || []
1094
+ if (!stack.length) return null
1095
+ parts = (token.sep === null ? stack.slice(-1) : stack).map((v) => formatCounter(v, token.style))
1096
+ }
1097
+ if (!parts.length || parts.some((p) => p === null)) return null
1098
+ // `counters()` prints the whole stack, and the stack is what the clone gets
1099
+ // wrong: see `overruled` in `synthesizeMarkers`.
1100
+ if (token.sep !== null) nested = true
1101
+ out += parts.join(token.sep === null ? '' : token.sep)
1102
+ }
1103
+ return { text: out, nested }
1104
+ }
1105
+
1106
+ /**
1107
+ * The text a `::marker` renders for `value`, or null when we cannot reproduce it
1108
+ * exactly. A marker with the wrong suffix is wrong CONTENT, not a rough one, so
1109
+ * every string this returns is measured against the UA's own marker in
1110
+ * `synthesizeMarkers` before it is allowed to stand.
1111
+ */
1112
+ function markerTextFor (type, value, st) {
1113
+ const rule = counterStyleRules(st).get(type)
1114
+ if (!rule) {
1115
+ if (PREDEFINED_MARKERS[type]) return PREDEFINED_MARKERS[type]
1116
+ if (!Number.isFinite(value)) return null
1117
+ const body = formatCounter(value, type)
1118
+ return body === null ? null : `${body}. `
1119
+ }
1120
+ if (!Number.isFinite(value)) return null
1121
+ const symbols = parseSymbolList(rule.symbols)
1122
+ if (!symbols || !symbols.length) return null
1123
+ const prefixList = rule.prefix ? parseSymbolList(rule.prefix) : []
1124
+ const suffixList = rule.suffix ? parseSymbolList(rule.suffix) : []
1125
+ if (prefixList === null || suffixList === null) return null
1126
+ const prefix = prefixList.length ? prefixList[0] : ''
1127
+ // css-counter-styles-3: `suffix` has an initial value of ". ", not "".
1128
+ const suffix = rule.suffix ? (suffixList.length ? suffixList[0] : '') : '. '
1129
+
1130
+ const system = String(rule.system || 'symbolic').trim().toLowerCase()
1131
+ const n = symbols.length
1132
+ let body = null
1133
+ if (system === 'cyclic') {
1134
+ body = symbols[(value - 1 + n * Math.ceil(Math.abs(value) / n + 1)) % n]
1135
+ } else if (system === 'symbolic') {
1136
+ if (value < 1) return null
1137
+ body = symbols[(value - 1) % n].repeat(Math.ceil(value / n))
1138
+ } else if (system === 'numeric') {
1139
+ if (n < 2) return null
1140
+ let v = Math.abs(value)
1141
+ body = v === 0 ? symbols[0] : ''
1142
+ while (v > 0) { body = symbols[v % n] + body; v = Math.floor(v / n) }
1143
+ if (value < 0) return null
1144
+ } else if (system === 'alphabetic') {
1145
+ if (value < 1 || n < 2) return null
1146
+ let v = value
1147
+ body = ''
1148
+ while (v > 0) { v--; body = symbols[v % n] + body; v = Math.floor(v / n) }
1149
+ } else if (/^fixed\b/.test(system)) {
1150
+ const first = Number((/^fixed\s+(-?\d+)/.exec(system) || [])[1] ?? 1)
1151
+ const i = value - first
1152
+ if (i < 0 || i >= n) return null
1153
+ body = symbols[i]
1154
+ } else {
1155
+ // `additive` and `extends` need `additive-symbols` / the extended style's
1156
+ // own definition; neither is reproduced rather than approximated.
1157
+ return null
1158
+ }
1159
+ return body === null || body === undefined ? null : prefix + body + suffix
1160
+ }
1161
+
1162
+ /**
1163
+ * The ordinal the UA numbers this item with: `value` on the item wins, then the
1164
+ * running count from the list's `start`. `reversed` is not counted here.
1165
+ */
1166
+ function listItemOrdinal (li, st) {
1167
+ const parent = li.parentElement
1168
+ if (!parent) return null
1169
+ const ordered = parent.tagName === 'OL'
1170
+ if (ordered && parent.hasAttribute('reversed')) return null
1171
+ // Same trap as `value` below: `Number(null)` is 0, and taking it started every
1172
+ // ordered list at zero.
1173
+ const rawStart = ordered ? parent.getAttribute('start') : null
1174
+ const start = rawStart === null || rawStart.trim() === '' ? NaN : Number(rawStart)
1175
+ let current = (Number.isFinite(start) ? start : 1) - 1
1176
+ for (const sib of parent.children) {
1177
+ const cs = st.styleOf(sib)
1178
+ if (!cs || !/list-item/.test(cs.display || '')) {
1179
+ if (sib === li) return null
1180
+ continue
1181
+ }
1182
+ // `getAttribute` answers null when the attribute is absent, and `Number(null)`
1183
+ // is 0 — which numbered every item in the list zero and still passed the
1184
+ // advance check, because "§0 · " and "§1 · " are the same width.
1185
+ const raw = sib.getAttribute ? sib.getAttribute('value') : null
1186
+ const own = raw === null || raw.trim() === '' ? NaN : Number(raw)
1187
+ current = Number.isFinite(own) ? own : current + 1
1188
+ if (sib === li) return current
1189
+ }
1190
+ return null
1191
+ }
1192
+
1193
+ /** The first laid-out text run inside `el`, as its rect plus the element styling it. */
1194
+ function firstTextRun (el) {
1195
+ const doc = el.ownerDocument
1196
+ if (!doc || typeof doc.createTreeWalker !== 'function') return null
1197
+ const walker = doc.createTreeWalker(el, 4 /* SHOW_TEXT */)
1198
+ let node
1199
+ while ((node = walker.nextNode())) {
1200
+ if (!node.data || !node.data.trim()) continue
1201
+ const range = doc.createRange()
1202
+ range.selectNodeContents(node)
1203
+ const rects = range.getClientRects()
1204
+ if (rects.length) return { rect: rects[0], host: node.parentElement }
1205
+ }
1206
+ return null
1207
+ }
1208
+
1209
+ /**
1210
+ * `font-variant-numeric` is on this list for a reason worth writing down: the
1211
+ * `::marker` UA sheet sets `tabular-nums` (css-lists-3 §3.2), so the browser
1212
+ * lays `§1 · ` and `§2 · ` out at the SAME width — and a canvas cannot be told
1213
+ * about tabular figures at all, which is why the width check below is done in
1214
+ * the DOM and not with `measureText`.
1215
+ */
1216
+ const MARKER_STYLE_PROPS = [
1217
+ 'fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'fontVariant',
1218
+ 'fontVariantNumeric', 'fontFeatureSettings', 'fontSynthesis',
1219
+ 'letterSpacing', 'wordSpacing', 'color', 'textTransform', 'unicodeBidi',
1220
+ ]
1221
+
1222
+ /**
1223
+ * Rebuilds every reproducible `::marker` in the clone as a real element.
1224
+ * Runs before the walk and is layout-neutral: the spans are out of flow, and the
1225
+ * `list-style-position` probe is reverted before anything is measured.
1226
+ *
1227
+ * @param {Element} root
1228
+ * @param {object} st
1229
+ */
1230
+ function synthesizeMarkers (root, st) {
1231
+ if (typeof document === 'undefined' || !measureContext(st)) return
1232
+ const doc = root.ownerDocument
1233
+ if (!st.markerSynthesized) st.markerSynthesized = new WeakSet()
1234
+ if (!st.markerRejected) st.markerRejected = new Map()
1235
+ const candidates = []
1236
+ for (const el of [root, ...root.querySelectorAll('li, [style*="list-item"], [class]')]) {
1237
+ if (candidates.length >= MAX_SYNTH_MARKERS) break
1238
+ const cs = st.styleOf(el)
1239
+ if (!cs || !/list-item/.test(cs.display || '')) continue
1240
+ // Only the outside marker has the measured placement rule; `inside` is an
1241
+ // inline box in the line and would need the line broken around it.
1242
+ if (cs.listStylePosition !== 'outside') continue
1243
+ if (isSet(cs.listStyleImage)) continue
1244
+ if (cs.direction !== 'ltr' || cs.visibility !== 'visible') continue
1245
+ if (el.querySelector(`:scope > [${PSEUDO_ATTR}="::marker"]`)) continue
1246
+ let markerCs
1247
+ try {
1248
+ markerCs = getComputedStyle(el, '::marker')
1249
+ } catch {
1250
+ continue
1251
+ }
1252
+ if (!markerCs || !markerCs.fontSize) continue
1253
+
1254
+ // `content` outranks `list-style-type` — including `content: none`, which is
1255
+ // the author saying the marker paints nothing at all. Nothing is lost there,
1256
+ // so it is not declared either.
1257
+ const authored = markerContentOf(markerCs)
1258
+ if (authored === 'none') continue
1259
+ if (cs.listStyleType === 'none' && !authored) continue
1260
+
1261
+ let text = null
1262
+ let symbol = null
1263
+ let nested = false
1264
+ if (authored) {
1265
+ const resolved = resolveMarkerContent(authored, el, st)
1266
+ if (!resolved) {
1267
+ st.markerRejected.set(el, `its ::marker sets content: ${authored}, which is not a template this ` +
1268
+ 'build can resolve exactly (it reads strings, counter() and counters())')
1269
+ continue
1270
+ }
1271
+ text = resolved.text
1272
+ nested = resolved.nested
1273
+ } else if (SYMBOL_MARKERS.has(cs.listStyleType)) {
1274
+ const fontSize = num(markerCs.fontSize)
1275
+ if (!(fontSize > 0)) continue
1276
+ const d = symbolDiameter(fontSize)
1277
+ symbol = {
1278
+ type: cs.listStyleType,
1279
+ // A `circle` is the same disc drawn hollow, and its ink measured exactly
1280
+ // 1px wider than the filled one at every size: a 1px stroke on the
1281
+ // outside of the same circle.
1282
+ size: cs.listStyleType === 'circle' ? d + 1 : d,
1283
+ stroke: cs.listStyleType === 'circle' ? Math.max(1, Math.round(fontSize / 14)) : 0,
1284
+ gap: symbolGap(fontSize),
1285
+ rise: symbolRise(fontSize),
1286
+ color: markerCs.color,
1287
+ fontSize,
1288
+ }
1289
+ } else {
1290
+ // No ordinal, no number. Falling back to 1 was silent content corruption of
1291
+ // exactly the kind this whole path exists to avoid: every item of an
1292
+ // `<ol reversed>` would have printed "1." and PASSED the advance check,
1293
+ // because the UA sheet gives a marker `tabular-nums` and "1." and "3." are
1294
+ // the same width.
1295
+ const value = listItemOrdinal(el, st)
1296
+ if (value === null) {
1297
+ st.markerRejected.set(el, 'the ordinal this item is numbered with could not be resolved (an ' +
1298
+ '`<ol reversed>`, or an item whose list is not its parent element), and a marker with the ' +
1299
+ 'wrong number is wrong content rather than an approximation of it')
1300
+ continue
1301
+ }
1302
+ text = markerTextFor(cs.listStyleType, value, st)
1303
+ if (!text) continue
1304
+ }
1305
+ const anchor = firstTextRun(el)
1306
+ if (!anchor || !anchor.host) continue
1307
+ const hostCs = st.styleOf(anchor.host)
1308
+ const ascent = hostCs ? ascentOf(fontShorthand(hostCs), st) : null
1309
+ if (ascent === null) continue
1310
+ const box = el.getBoundingClientRect()
1311
+ candidates.push({
1312
+ el,
1313
+ text,
1314
+ symbol,
1315
+ authored: !!authored,
1316
+ nested,
1317
+ markerCs,
1318
+ // The probe below reads the marker from INSIDE the line box, where its
1319
+ // trailing white-space collapses against the text that follows it. A
1320
+ // suffix of two spaces measures one there, so that is the string the check
1321
+ // compares — otherwise every wide-suffix style is rejected for being right.
1322
+ collapsedText: text ? text.replace(/[ \t\n\r\f]+/g, ' ') : null,
1323
+ contentLeft: box.left + num(cs.borderLeftWidth) + num(cs.paddingLeft),
1324
+ baseline: anchor.rect.top + ascent,
1325
+ textLeft: anchor.rect.left,
1326
+ })
1327
+ }
1328
+ if (!candidates.length) return
1329
+
1330
+ // The browser's own answer for "how wide is the real marker": inside the line
1331
+ // box, the first text run starts exactly that much further right.
1332
+ for (const c of candidates) c.el.style.setProperty('list-style-position', 'inside', 'important')
1333
+ for (const c of candidates) {
1334
+ const probe = firstTextRun(c.el)
1335
+ c.measured = probe ? probe.rect.left - c.textLeft : null
1336
+ }
1337
+ for (const c of candidates) c.el.style.removeProperty('list-style-position')
1338
+
1339
+ const placed = []
1340
+ for (const c of candidates) {
1341
+ if (c.measured === null) {
1342
+ st.markerRejected.set(c.el, `${c.symbol ? `a ${c.symbol.type} bullet` : `"${c.text}"`} could not be ` +
1343
+ 'checked against the marker the UA draws')
1344
+ continue
1345
+ }
1346
+ const span = doc.createElement('span')
1347
+ span.setAttribute(PSEUDO_ATTR, '::marker')
1348
+ span.setAttribute('data-snapdom-vector-marker', '')
1349
+ span.style.cssText = 'position:absolute;left:0;top:0;margin:0;padding:0;border:0;' +
1350
+ 'white-space:pre;line-height:normal;text-decoration:none;pointer-events:none;'
1351
+ if (c.symbol) {
1352
+ // A shape, not a glyph: `display:block` so the box IS the ink, and
1353
+ // `box-sizing:border-box` so a hollow `circle`'s stroke stays inside the
1354
+ // size that was measured.
1355
+ span.style.display = 'block'
1356
+ span.style.boxSizing = 'border-box'
1357
+ span.style.width = `${c.symbol.size}px`
1358
+ span.style.height = `${c.symbol.size}px`
1359
+ if (c.symbol.type === 'circle') span.style.border = `${c.symbol.stroke}px solid ${c.symbol.color}`
1360
+ else span.style.background = c.symbol.color
1361
+ if (c.symbol.type !== 'square') span.style.borderRadius = '50%'
1362
+ } else {
1363
+ // Collapsed first: this is still the CHECK, laid out by the same engine that
1364
+ // drew the real marker, and the only measurement that can see tabular figures.
1365
+ span.textContent = c.collapsedText
1366
+ for (const prop of MARKER_STYLE_PROPS) {
1367
+ const value = c.markerCs[prop]
1368
+ if (value) span.style[prop] = value
1369
+ }
1370
+ }
1371
+ c.el.appendChild(span)
1372
+ c.span = span
1373
+ placed.push(c)
1374
+ }
1375
+ if (!placed.length) return
1376
+
1377
+ const checked = []
1378
+ for (const c of placed) {
1379
+ // A bullet has no advance to compare — the UA is not setting a string. What
1380
+ // the probe still says is how wide the marker BOX is, and the shape has to
1381
+ // fall inside it: that is what catches an engine whose bullet is somewhere
1382
+ // else entirely, which is the failure the string check exists to catch.
1383
+ if (c.symbol) {
1384
+ const room = c.symbol.gap + c.symbol.size
1385
+ if (room > c.measured + 1.5) {
1386
+ st.markerRejected.set(c.el, `a ${c.symbol.type} bullet drawn by the law measured in Chromium would ` +
1387
+ `sit ${room.toFixed(2)}px left of the content edge and this engine's marker box is only ` +
1388
+ `${c.measured.toFixed(2)}px wide, so the bullet would land outside the marker`)
1389
+ c.span.remove()
1390
+ continue
1391
+ }
1392
+ checked.push(c)
1393
+ continue
1394
+ }
1395
+ const run = firstTextRun(c.span)
1396
+ const mine = run ? run.rect.width : null
1397
+ if (mine === null || Math.abs(mine - c.measured) > 1) {
1398
+ // One disagreement is not evidence against us, and it is the one this file
1399
+ // already has a repair for elsewhere: `counters(list-item, …)` prints the
1400
+ // whole stack of list-item counters in scope, and the clone is MOUNTED
1401
+ // beside the page it was cloned from, so Chromium's sibling-carry
1402
+ // (`snapdom/src/modules/counter.js`, and see `repairCounters`) leaks the
1403
+ // live document's list-item instances into it. Measured on a two-level
1404
+ // list: the clone's own marker box is 28.03px where the item renders `1) `
1405
+ // (16.36px) on the page, because the clone renders `1.1) `. The stack
1406
+ // resolved above is the css-lists-3 one, taken from the tree, and the
1407
+ // extra levels can only make the UA's marker WIDER — so a wider marker on
1408
+ // a `counters()` template is overruled, and says so on the node.
1409
+ const overruled = c.nested && mine !== null && mine < c.measured
1410
+ if (!overruled) {
1411
+ st.markerRejected.set(c.el, mine === null
1412
+ ? `"${c.text}" could not be laid out to check against the marker the UA draws`
1413
+ : `"${c.text}" lays out ${mine.toFixed(2)}px wide in the line box and the marker the UA draws is ${c.measured.toFixed(2)}px`)
1414
+ c.span.remove()
1415
+ continue
1416
+ }
1417
+ c.overruled = { mine, measured: c.measured }
1418
+ }
1419
+ checked.push(c)
1420
+ }
1421
+ for (const c of checked) if (!c.symbol) c.span.textContent = c.text
1422
+ placed.length = 0
1423
+ placed.push(...checked)
1424
+ if (!placed.length) return
1425
+
1426
+ // Aim, measure, correct by the delta: nothing here depends on which box the
1427
+ // absolute position resolved against.
1428
+ for (const c of placed) {
1429
+ if (c.symbol) {
1430
+ const rect = c.span.getBoundingClientRect()
1431
+ c.fix = rect.width > 0
1432
+ ? {
1433
+ dx: (c.contentLeft - c.symbol.gap - c.symbol.size) - rect.left,
1434
+ dy: (c.baseline - c.symbol.rise - c.symbol.size / 2) - rect.top,
1435
+ }
1436
+ : null
1437
+ continue
1438
+ }
1439
+ const run = firstTextRun(c.span)
1440
+ const font = fontShorthand(st.styleOf(c.span) || c.markerCs)
1441
+ const ascent = ascentOf(font, st)
1442
+ c.fix = run && ascent !== null
1443
+ ? { dx: c.contentLeft - run.rect.right, dy: c.baseline - (run.rect.top + ascent), ascent, font }
1444
+ : null
1445
+ }
1446
+ for (const c of placed) {
1447
+ if (!c.fix) { c.span.remove(); continue }
1448
+ c.span.style.left = `${c.fix.dx}px`
1449
+ c.span.style.top = `${c.fix.dy}px`
1450
+ }
1451
+ for (const c of placed) {
1452
+ if (!c.fix) continue
1453
+ let ok
1454
+ if (c.symbol) {
1455
+ const rect = c.span.getBoundingClientRect()
1456
+ ok = Math.abs(rect.right - (c.contentLeft - c.symbol.gap)) < 0.5 &&
1457
+ Math.abs((rect.top + rect.height / 2) - (c.baseline - c.symbol.rise)) < 0.5
1458
+ } else {
1459
+ const run = firstTextRun(c.span)
1460
+ ok = !!run &&
1461
+ Math.abs(run.rect.right - c.contentLeft) < 0.5 &&
1462
+ Math.abs(run.rect.top + c.fix.ascent - c.baseline) < 0.5
1463
+ }
1464
+ if (!ok) {
1465
+ st.markerRejected.set(c.el, `${c.symbol ? `a ${c.symbol.type} bullet` : `"${c.text}"`} would not land ` +
1466
+ 'where it was aimed at, so the placement is not the one that was measured')
1467
+ c.span.remove()
1468
+ continue
1469
+ }
1470
+ st.markerSynthesized.add(c.el)
1471
+ c.el.setAttribute('data-snapdom-vector-marker-host', c.symbol ? `${c.symbol.type} bullet` : c.text)
1472
+ if (c.symbol) c.el.setAttribute('data-snapdom-vector-marker-shape', c.symbol.type)
1473
+ else if (c.authored) c.el.setAttribute('data-snapdom-vector-marker-authored', '')
1474
+ if (c.overruled) {
1475
+ c.el.setAttribute('data-snapdom-vector-marker-overruled',
1476
+ `${c.overruled.mine.toFixed(2)}/${c.overruled.measured.toFixed(2)}`)
1477
+ }
1478
+ }
1479
+ }
1480
+
1481
+ /**
1482
+ * Rewrites every materialised counter whose number disagrees with the counters
1483
+ * the clone itself declares, before anything in the tree is measured.
1484
+ *
1485
+ * Repairing rather than declaring is deliberate and it is the exception, not the
1486
+ * rule. Everywhere else this engine states what it lost; here there is nothing to
1487
+ * state a loss OF — by the time the clone exists the number is literal text, so
1488
+ * a reader has no way to tell "2.3" from a correct 2.3, and the export ships a
1489
+ * WRONG number rather than an approximated one. That is content corruption, the
1490
+ * same class as a word split in half, and this file already has the correct
1491
+ * value: `checkCounterContent` resolves the same counters with css-lists-3
1492
+ * scoping and only answers when the pseudo's own `content` template EXPLAINS the
1493
+ * rendered string and the two still disagree.
1494
+ *
1495
+ * The cause is upstream and is not fixed here: `snapdom/src/modules/counter.js`,
1496
+ * the sibling-carry at the end of `build()`, lets a counter created by a
1497
+ * `counter-reset` on a DESCENDANT escape into the next sibling subtree — so
1498
+ * `<h5 counter-reset:sub>` in one `<section>` leaks `sub` into the next one and
1499
+ * `2.1` renders as `2.3`. That is the fix worth having; this is the repair that
1500
+ * stops it reaching a designer's canvas in the meantime, and every instance of it
1501
+ * is declared.
1502
+ *
1503
+ * @param {Element} root
1504
+ * @param {object} st
1505
+ */
1506
+ function repairCounters (root, st) {
1507
+ if (typeof document === 'undefined') return
1508
+ const spans = root.querySelectorAll(`[${PSEUDO_ATTR}="::before"], [${PSEUDO_ATTR}="::after"]`)
1509
+ if (!spans.length) return
1510
+ if (!st.counterRepaired) st.counterRepaired = new Map()
1511
+ for (const span of spans) {
1512
+ const sel = span.getAttribute(PSEUDO_ATTR)
1513
+ let drift = null
1514
+ try {
1515
+ drift = checkCounterContent(span, sel, st)
1516
+ } catch { continue }
1517
+ if (!drift) continue
1518
+ span.textContent = drift.expected
1519
+ st.counterRepaired.set(span, { ...drift, sel })
1520
+ }
1521
+ }
1522
+
1523
+ /**
1524
+ * Declares one repaired counter against the node that carries its characters,
1525
+ * once per pseudo however many walk steps see it.
1526
+ *
1527
+ * @param {Element} span the materialised pseudo
1528
+ * @param {string} sel `'::before'` or `'::after'`
1529
+ * @param {object} node the emitted node the pseudo's text lands in
1530
+ */
1531
+ function reportCounterDrift (span, sel, node, st) {
1532
+ if (!st.counterChecked) st.counterChecked = new WeakSet()
1533
+ if (st.counterChecked.has(span)) return
1534
+ st.counterChecked.add(span)
1535
+ const drift = st.counterRepaired && st.counterRepaired.get(span)
1536
+ if (!drift) return
1537
+ pushDiag(st, {
1538
+ code: 'B4.counter-misnumbered',
1539
+ severity: 'warn',
1540
+ _node: node,
1541
+ pseudo: sel,
1542
+ counters: drift.names,
1543
+ expected: drift.expected,
1544
+ got: drift.observed,
1545
+ note: `snapdom materialised this ${sel} as "${drift.observed}"; the counters it is built from ` +
1546
+ `(${drift.names.join(', ')}) are worth "${drift.expected}" under css-lists-3 scoping, and ` +
1547
+ 'that is what this capture carries — the string was rewritten in the clone before anything ' +
1548
+ 'was measured, so the box and the baseline describe the repaired text. It is a REPAIR and ' +
1549
+ 'not a measurement, which is why it is here: the cause is `snapdom/src/modules/counter.js`, ' +
1550
+ 'the sibling-carry at the end of `build()`, which lets a counter created by a ' +
1551
+ '`counter-reset` on a DESCENDANT escape into the next sibling subtree',
1552
+ })
1553
+ degrade(node, 'A', `counter rewritten from "${drift.observed}" to "${drift.expected}"`)
1554
+ }
1555
+
1556
+ // ——— form controls: the widget the UA draws and no CSS property describes ———
1557
+
1558
+ /**
1559
+ * A native control is the one box in the tree whose CONTENTS are not in the tree.
1560
+ * `getComputedStyle` says nothing about the tick, the dot, the chevron, the track
1561
+ * or the thumb; the UA shadow pseudos that would (`::-webkit-slider-thumb` and
1562
+ * friends) return the ELEMENT's own style when no page rule matches them —
1563
+ * measured on `demo/challenges.html#fx-form`, where every one of the seven
1564
+ * answered `404px x 38px, rgb(255,255,255)`, i.e. the `<select>`'s own box. So
1565
+ * there is nothing to read and only two honest options: leave the control empty
1566
+ * and say so, or REBUILD the widget from what the element does declare — its
1567
+ * type, its state, its value and its `accent-color` — and say that too.
1568
+ *
1569
+ * This section builds a description, not a picture. It carries semantics and
1570
+ * colour; the geometry constants and the drawing live in the backend
1571
+ * (`emit/svg-flat.js`, `emitControl`), because "how wide is a checkmark stroke"
1572
+ * is a question with a different answer per backend and only one of them is SVG.
1573
+ *
1574
+ * **Every colour and every proportion here was measured off Chromium's own
1575
+ * raster**, at dpr 4, on the fixture and on a bare page, rather than taken from
1576
+ * documentation:
1577
+ *
1578
+ * - the unchecked box's border is `#767676` and 1px at a 16px control;
1579
+ * - the slider's unfilled track and the progress track are `#efefef`;
1580
+ * - `accent-color: auto` resolves to `#0075ff`, and a `<meter>` with no accent
1581
+ * paints `#107c10` in its optimum band and `#d83b01` outside it;
1582
+ * - the mark ON the accent (the tick, the radio dot's surround) is white on a
1583
+ * dark accent and near-black on a light one, which is a contrast decision the
1584
+ * UA makes and `inkOn` reproduces.
1585
+ *
1586
+ * It is a RECONSTRUCTION and the node says so: `B4.form-control` keeps its code
1587
+ * and now names what was drawn and what was not, so a reader who trusted the old
1588
+ * text ("nothing here paints the widget") is not silently told a new story.
1589
+ */
1590
+
1591
+ /** Native controls whose widget this build can rebuild. `<button>` paints its own label. */
1592
+ const WIDGET_TAGS = new Set(['INPUT', 'SELECT', 'PROGRESS', 'METER'])
1593
+
1594
+ /** `<input type>` values that carry a UA widget worth drawing. */
1595
+ const WIDGET_INPUT_TYPES = new Set(['checkbox', 'radio', 'range'])
1596
+
1597
+ /** Chromium's own colours, read off its raster (see the section header). */
1598
+ const UA_ACCENT = '#0075ff'
1599
+ const UA_BORDER = '#767676'
1600
+ const UA_TRACK = '#efefef'
1601
+ const UA_SURFACE = '#ffffff'
1602
+ const UA_METER_OPTIMUM = '#107c10'
1603
+ const UA_METER_SUBOPTIMUM = '#d83b01'
1604
+
1605
+ /** Fallbacks for the two colours above, so a descriptor is never colourless. */
1606
+ const RGBA_WHITE = [1, 1, 1, 1]
1607
+ const RGBA_NEAR_BLACK = [0.06, 0.06, 0.06, 1]
1608
+
1609
+ /** `parseColor` or null, with the warnings dropped — a colour we chose, not one we read. */
1610
+ function rgbaOf (css) {
1611
+ const hit = css ? parseColor(css) : null
1612
+ return hit ? hit.rgba : null
1613
+ }
1614
+
1615
+ /**
1616
+ * The mark the UA paints ON the accent colour. Chromium picks it for contrast,
1617
+ * not from a property, so the rule is reproduced rather than guessed: sRGB
1618
+ * relative luminance over 0.5 takes the dark mark, under it the white one.
1619
+ */
1620
+ function inkOn (rgba) {
1621
+ if (!rgba) return RGBA_WHITE
1622
+ const lin = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
1623
+ const L = 0.2126 * lin(rgba[0]) + 0.7152 * lin(rgba[1]) + 0.0722 * lin(rgba[2])
1624
+ return L > 0.45 ? RGBA_NEAR_BLACK : RGBA_WHITE
1625
+ }
1626
+
1627
+ /** A finite number from an attribute or an IDL property, or `fallback`. */
1628
+ function numberOr (value, fallback) {
1629
+ const n = typeof value === 'number' ? value : parseFloat(value)
1630
+ return Number.isFinite(n) ? n : fallback
1631
+ }
1632
+
1633
+ /**
1634
+ * Where a range/progress/meter sits in its own scale, 0..1, or null when the
1635
+ * element is indeterminate (a `<progress>` with no `value` attribute is a
1636
+ * DIFFERENT widget — an animated barber's pole — and drawing a bar for it would
1637
+ * invent a number the page does not have).
1638
+ */
1639
+ function fractionOf (el, tag, min, max) {
1640
+ if (tag === 'PROGRESS' && !(el.hasAttribute && el.hasAttribute('value'))) return null
1641
+ const value = numberOr(el.value, NaN)
1642
+ if (!Number.isFinite(value)) return null
1643
+ if (!(max > min)) return null
1644
+ return Math.min(1, Math.max(0, (value - min) / (max - min)))
1645
+ }
1646
+
1647
+ /**
1648
+ * What the UA draws inside this control, as data a backend can paint.
1649
+ *
1650
+ * @param {Element} el
1651
+ * @param {CSSStyleDeclaration} cs
1652
+ * @returns {object|null} null when the control has no widget this build rebuilds
1653
+ * (`appearance:none` took it away, or it is a text field, a file picker, a date
1654
+ * picker or a multiple `<select>`, whose widgets are text and buttons rather
1655
+ * than a shape)
1656
+ */
1657
+ function describeWidget (el, cs) {
1658
+ const tag = (el.tagName || '').toUpperCase()
1659
+ if (!WIDGET_TAGS.has(tag)) return null
1660
+ // `appearance:none` means the author replaced the widget with their own CSS,
1661
+ // which the ordinary collectors already read. Drawing ours on top of theirs
1662
+ // would put two checkboxes in the same box.
1663
+ if ((cs.appearance || cs.webkitAppearance || 'auto') === 'none') return null
1664
+ if ((cs.visibility || 'visible') !== 'visible') return null
1665
+
1666
+ const accentCss = cs.accentColor && cs.accentColor !== 'auto' ? cs.accentColor : null
1667
+ const accent = rgbaOf(accentCss) || rgbaOf(UA_ACCENT)
1668
+ const common = {
1669
+ accent,
1670
+ accentSource: accentCss ? 'accent-color' : 'ua-default',
1671
+ ink: inkOn(accent),
1672
+ border: rgbaOf(UA_BORDER),
1673
+ track: rgbaOf(UA_TRACK),
1674
+ surface: rgbaOf(UA_SURFACE),
1675
+ direction: cs.direction === 'rtl' ? 'rtl' : 'ltr',
1676
+ disabled: !!el.disabled,
1677
+ }
1678
+
1679
+ if (tag === 'INPUT') {
1680
+ const type = String(el.type || (el.getAttribute && el.getAttribute('type')) || 'text').toLowerCase()
1681
+ if (!WIDGET_INPUT_TYPES.has(type)) return null
1682
+ if (type === 'range') {
1683
+ const min = numberOr(el.min, 0)
1684
+ const max = numberOr(el.max, 100)
1685
+ return {
1686
+ ...common,
1687
+ kind: 'range',
1688
+ fraction: fractionOf(el, tag, min, max),
1689
+ value: String(el.value == null ? '' : el.value),
1690
+ min,
1691
+ max,
1692
+ }
1693
+ }
1694
+ return {
1695
+ ...common,
1696
+ kind: type,
1697
+ checked: !!el.checked,
1698
+ indeterminate: !!el.indeterminate,
1699
+ }
1700
+ }
1701
+
1702
+ if (tag === 'SELECT') {
1703
+ // A multiple/sized select is a LIST: the widget is its options, which are
1704
+ // text this backend has no box for. Nothing to draw, and `collectBox` says so.
1705
+ if (el.multiple || numberOr(el.size, 1) > 1) return null
1706
+ return {
1707
+ ...common,
1708
+ kind: 'select',
1709
+ // The arrow is painted in the select's own `color`, measured on the fixture
1710
+ // (`#0f172a`, the authored colour, not a UA grey).
1711
+ ink: rgbaOf(cs.color) || common.ink,
1712
+ inset: {
1713
+ top: num(cs.borderTopWidth),
1714
+ right: num(cs.borderRightWidth),
1715
+ bottom: num(cs.borderBottomWidth),
1716
+ left: num(cs.borderLeftWidth),
1717
+ },
1718
+ }
1719
+ }
1720
+
1721
+ const max = numberOr(el.max, 1)
1722
+ const min = tag === 'METER' ? numberOr(el.min, 0) : 0
1723
+ const widget = {
1724
+ ...common,
1725
+ kind: tag === 'METER' ? 'meter' : 'progress',
1726
+ fraction: fractionOf(el, tag, min, max),
1727
+ value: String(el.value == null ? '' : el.value),
1728
+ min,
1729
+ max,
1730
+ }
1731
+ if (widget.kind === 'meter' && !accentCss) {
1732
+ // Chromium recolours a meter by which band the value falls in. The bands are
1733
+ // readable (`low`/`high`/`optimum`), the exact rule for "even less good" is
1734
+ // not, so only the two measured colours are used and the note says so.
1735
+ const low = numberOr(el.low, min)
1736
+ const high = numberOr(el.high, max)
1737
+ const value = numberOr(el.value, min)
1738
+ widget.accent = rgbaOf(value >= low && value <= high ? UA_METER_OPTIMUM : UA_METER_SUBOPTIMUM)
1739
+ widget.accentSource = 'ua-meter-band'
1740
+ widget.ink = inkOn(widget.accent)
1741
+ }
1742
+ return widget
1743
+ }
1744
+
1745
+ // ——— names and paths ———
1746
+
1747
+ /** Framework-generated class names carry no meaning for a designer reading layers. */
1748
+ const NOISY_CLASS = /^(css|sc|emotion|jsx|svelte|ng|v)-|^[\w-]*[0-9a-f]{6,}$/i
1749
+
1750
+ function significantClasses (el) {
1751
+ const list = typeof el.className === 'string' ? el.className.trim().split(/\s+/) : []
1752
+ return list.filter(c => c && !NOISY_CLASS.test(c)).slice(0, 2)
1753
+ }
1754
+
1755
+ function escapeIdent (value) {
1756
+ if (typeof CSS !== 'undefined' && CSS && typeof CSS.escape === 'function') return CSS.escape(value)
1757
+ return String(value).replace(/([^\w-])/g, '\\$1')
1758
+ }
1759
+
1760
+ /**
1761
+ * First words of the text this block itself renders — `textContent` would be the
1762
+ * whole subtree, and a container that happens to hold one stray text node would
1763
+ * get named after every descendant it contains.
1764
+ */
1765
+ function textExcerpt (text) {
1766
+ const raw = (text || '').replace(/\s+/g, ' ').trim()
1767
+ if (!raw) return ''
1768
+ return raw.length > 32 ? `${raw.slice(0, 31)}…` : raw
1769
+ }
1770
+
1771
+ /**
1772
+ * @param {string|null} [pseudo] the pseudo-element this node came from, when it is
1773
+ * one: the span's own tag and generated class say nothing a reader can use, and a
1774
+ * layer called `span` next to the box it decorates is exactly the anonymous layer
1775
+ * this project promises not to produce.
1776
+ */
1777
+ function nodeName (el, tag, kind, text, pseudo) {
1778
+ if (pseudo) {
1779
+ const excerpt = textExcerpt(text)
1780
+ return excerpt ? `${pseudo} “${excerpt}”` : pseudo
1781
+ }
1782
+ if (kind === 'text') {
1783
+ const excerpt = textExcerpt(text)
1784
+ return excerpt ? `${tag} “${excerpt}”` : tag
1785
+ }
1786
+ if (el.id) return `#${el.id}`
1787
+ const classes = significantClasses(el)
1788
+ if (classes.length) return `.${classes.join('.')}`
1789
+ return tag
1790
+ }
1791
+
1792
+ function pathSegment (el, tag, pseudo) {
1793
+ // The clone's own spans have generated classes and a position among siblings that
1794
+ // the source document does not have; the selector that DOES address them there is
1795
+ // the pseudo itself.
1796
+ if (pseudo) return pseudo
1797
+ if (el.id) return `#${escapeIdent(el.id)}`
1798
+ const classes = significantClasses(el)
1799
+ if (classes.length) return `${tag}.${classes.map(escapeIdent).join('.')}`
1800
+ const parent = el.parentElement
1801
+ if (!parent) return tag
1802
+ let n = 1
1803
+ for (const sib of parent.children) {
1804
+ if (sib === el) break
1805
+ n++
1806
+ }
1807
+ return `${tag}:nth-child(${n})`
1808
+ }
1809
+
1810
+ /** The declarations a downstream reader needs to explain a node's placement. */
1811
+ function sourceCss (cs) {
1812
+ const out = { display: cs.display, position: cs.position }
1813
+ if (cs.zIndex && cs.zIndex !== 'auto') out.zIndex = cs.zIndex
1814
+ if (floatOf(cs) !== 'none') out.float = floatOf(cs)
1815
+ if (clipsContent(cs)) out.overflow = `${cs.overflowX} ${cs.overflowY}`.trim()
1816
+ if (/(flex|grid)/.test(cs.display || '')) {
1817
+ if (isSet(cs.flexDirection)) out.flexDirection = cs.flexDirection
1818
+ if (isSet(cs.gridTemplateColumns)) out.gridTemplateColumns = cs.gridTemplateColumns
1819
+ if (cs.gap && cs.gap !== 'normal') out.gap = cs.gap
1820
+ }
1821
+ return out
1822
+ }
1823
+
1824
+ // ——— diagnostics ———
1825
+
1826
+ function pushDiag (st, diag) {
1827
+ const seen = (st.diagCount.get(diag.code) || 0) + 1
1828
+ st.diagCount.set(diag.code, seen)
1829
+ if (seen <= st.maxPerCode) st.diagnostics.push(diag)
1830
+ else st.suppressed.set(diag.code, (st.suppressed.get(diag.code) || 0) + 1)
1831
+ }
1832
+
1833
+ /** Worst grade wins: a node that is both approximate and omitted is omitted. */
1834
+ function degrade (node, grade, note) {
1835
+ if (GRADE_RANK[grade] > GRADE_RANK[node.fidelity.grade]) node.fidelity.grade = grade
1836
+ if (note && !node.fidelity.notes.includes(note)) node.fidelity.notes.push(note)
1837
+ }
1838
+
1839
+ // ——— the walk ———
1840
+
1841
+ /**
1842
+ * @typedef {Object} CollectCtx
1843
+ * @property {(el: Element) => CSSStyleDeclaration} [styleOf] memoised computed styles
1844
+ * @property {number} [maxNodes=20000] hard ceiling; the walk stops and says so
1845
+ * @property {number} [maxDepth=256]
1846
+ * @property {boolean} [pseudo] **no longer read here.** It meant "probe
1847
+ * `getComputedStyle(el, '::before')` and declare generated content out of scope",
1848
+ * and the clone materialises that content, so the probe is gone. What the clone
1849
+ * cannot materialise (`::marker`, `::first-line`) is declared unconditionally —
1850
+ * see `findUnrepresentedPseudos`
1851
+ * @property {string|((el: Element) => boolean)|Array<string|((el: Element) => boolean)>} [exclude]
1852
+ * @property {number} [maxDiagnosticsPerCode=100]
1853
+ */
1854
+
1855
+ function makeState (rootEl, ctx) {
1856
+ const styles = new WeakMap()
1857
+ const styleOf = ctx.styleOf || ((el) => {
1858
+ let cs = styles.get(el)
1859
+ if (!cs) {
1860
+ cs = getComputedStyle(el)
1861
+ styles.set(el, cs)
1862
+ }
1863
+ return cs
1864
+ })
1865
+ const exclude = []
1866
+ for (const rule of [].concat(ctx.exclude || [])) {
1867
+ if (typeof rule === 'string' || typeof rule === 'function') exclude.push(rule)
1868
+ }
1869
+ return {
1870
+ rootEl,
1871
+ styleOf,
1872
+ exclude,
1873
+ maxNodes: Number.isFinite(ctx.maxNodes) ? ctx.maxNodes : 20000,
1874
+ maxDepth: Number.isFinite(ctx.maxDepth) ? ctx.maxDepth : 256,
1875
+ maxPerCode: Number.isFinite(ctx.maxDiagnosticsPerCode) ? ctx.maxDiagnosticsPerCode : 100,
1876
+ diagnostics: [],
1877
+ diagCount: new Map(),
1878
+ suppressed: new Map(),
1879
+ count: 0,
1880
+ truncated: false,
1881
+ rootOrigin: { x: 0, y: 0 },
1882
+ rootLayout: null,
1883
+ }
1884
+ }
1885
+
1886
+ /** Is `child` part of `el`'s own inline formatting context? */
1887
+ function joinsInlineContext (child, st) {
1888
+ if (isExcluded(child, st.exclude)) return false
1889
+ const tag = child.tagName.toUpperCase()
1890
+ if (SKIP_TAGS.has(tag) || OPAQUE_TAGS.has(tag)) return false
1891
+ const cs = st.styleOf(child)
1892
+ if (!cs || cs.display === 'none' || !joinsParentText(cs.display)) return false
1893
+ if (cs.position === 'absolute' || cs.position === 'fixed') return false
1894
+ return floatOf(cs) === 'none'
1895
+ }
1896
+
1897
+ /**
1898
+ * Does this element carry text in its OWN inline formatting context? The walk
1899
+ * follows in-flow inline descendants only: a block-level child starts a context
1900
+ * of its own and `collectText` runs per block, so descending into it here would
1901
+ * put the same characters in two nodes.
1902
+ */
1903
+ function hasOwnText (el, st) {
1904
+ for (const child of el.childNodes) {
1905
+ if (child.nodeType === 3) {
1906
+ if (/\S/.test(child.data)) return true
1907
+ continue
1908
+ }
1909
+ if (child.nodeType !== 1) continue
1910
+ if (joinsInlineContext(child, st) && hasOwnText(child, st)) return true
1911
+ }
1912
+ return false
1913
+ }
1914
+
1915
+ /** The same text, concatenated, for the layer name. Stops once it has enough. */
1916
+ function ownText (el, st, out = []) {
1917
+ for (const child of el.childNodes) {
1918
+ if (out.length > 40) break
1919
+ if (child.nodeType === 3) out.push(child.data)
1920
+ else if (child.nodeType === 1 && joinsInlineContext(child, st)) ownText(child, st, out)
1921
+ }
1922
+ return out.join('')
1923
+ }
1924
+
1925
+ /** An in-flow block-level child next to loose text means anonymous block boxes. */
1926
+ function hasInFlowBlockChild (el, st) {
1927
+ for (const child of el.children) {
1928
+ const tag = child.tagName.toUpperCase()
1929
+ if (SKIP_TAGS.has(tag)) continue
1930
+ const cs = st.styleOf(child)
1931
+ if (!cs || cs.display === 'none' || isInlineLevel(cs.display)) continue
1932
+ if (cs.position === 'absolute' || cs.position === 'fixed') continue
1933
+ if (floatOf(cs) !== 'none') continue
1934
+ return true
1935
+ }
1936
+ return false
1937
+ }
1938
+
1939
+ /**
1940
+ * The properties a `::first-letter` / `::first-line` rule is worth reporting for.
1941
+ * Anything that changes the SIZE or the ADVANCE of the glyphs, because that is
1942
+ * what makes the emitted text visibly wrong rather than merely off-colour.
1943
+ */
1944
+ const FIRST_PSEUDO_PROPS = [
1945
+ 'fontSize', 'fontWeight', 'fontFamily', 'fontStyle', 'fontVariant',
1946
+ 'lineHeight', 'letterSpacing', 'wordSpacing', 'textTransform', 'float', 'color',
1947
+ ]
1948
+
1949
+ /**
1950
+ * The pseudo-elements the clone does **not** materialise, and that the browser
1951
+ * therefore keeps rendering natively — inside the clone as much as it did on the
1952
+ * page, which means they paint and we cannot collect them.
1953
+ *
1954
+ * `::before`/`::after`/`::first-letter` are deliberately NOT here: `pseudo.js` turns
1955
+ * them into real `<span data-snapdom-pseudo>` elements with a measurable box, they
1956
+ * come in through the ordinary walk, and declaring them lost after that would be a
1957
+ * lie. What is left is the two kinds that have no element to become:
1958
+ *
1959
+ * - `::marker` — a list item's marker is painted by the UA from `list-style-*`, and
1960
+ * snapdom ships authored marker rules as a scoped `[data-sd-pN]::marker{}` rule
1961
+ * precisely so the browser re-renders it. There is no box in the tree for it.
1962
+ * - `::first-line` — generates no content at all; it RESTYLES glyphs that we do
1963
+ * collect, at the element's own style instead of the pseudo's, so the characters
1964
+ * are present with the wrong size, weight and advance.
1965
+ *
1966
+ * A pseudo that no rule targets returns the element's own values in Chromium, so any
1967
+ * difference in `FIRST_PSEUDO_PROPS` is a rule that hit. The probe runs against the
1968
+ * clone, where snapdom's scoped rule is in force, so it sees what actually renders.
1969
+ *
1970
+ * **Not gated on `ctx.pseudo`.** That flag meant "probe generated content and declare
1971
+ * it out of scope", and there is no such probe left — the orchestrator turns it off
1972
+ * for exactly that reason. Reusing it here would take a switch that exists to silence
1973
+ * a false report and use it to silence a true one. The gate is `textCarrier` instead,
1974
+ * which is also the honest one: both pseudos only exist where there is an inline
1975
+ * formatting context, so nothing else pays for the two `getComputedStyle` calls.
1976
+ */
1977
+ function findUnrepresentedPseudos (el, cs, textCarrier, st) {
1978
+ const out = []
1979
+ if (/list-item/.test(cs.display || '') &&
1980
+ (cs.listStyleType !== 'none' || isSet(cs.listStyleImage)) &&
1981
+ !(st && st.markerSynthesized && st.markerSynthesized.has(el))) {
1982
+ // `::marker { content: none }` paints nothing, so there is nothing to
1983
+ // declare — reporting it would be a loss that never happened.
1984
+ let suppressed = false
1985
+ try {
1986
+ suppressed = typeof getComputedStyle === 'function' &&
1987
+ markerContentOf(getComputedStyle(el, '::marker')) === 'none'
1988
+ } catch { /* no pseudo style available: report as before */ }
1989
+ if (!suppressed) {
1990
+ const why = st && st.markerRejected ? st.markerRejected.get(el) : null
1991
+ out.push({ sel: '::marker', content: cs.listStyleType, why })
1992
+ }
1993
+ }
1994
+ if (!textCarrier || typeof getComputedStyle !== 'function') return out
1995
+ // `::first-letter` only when the clone did not already materialise it: when it did,
1996
+ // the span is a node of its own with its own styles and there is nothing to declare.
1997
+ const selectors = el.querySelector(`:scope > [${PSEUDO_ATTR}="::first-letter"]`)
1998
+ ? ['::first-line']
1999
+ : ['::first-letter', '::first-line']
2000
+ for (const sel of selectors) {
2001
+ let pseudoCs
2002
+ try {
2003
+ pseudoCs = getComputedStyle(el, sel)
2004
+ } catch {
2005
+ break
2006
+ }
2007
+ if (!pseudoCs) continue
2008
+ const changed = FIRST_PSEUDO_PROPS.filter((prop) => pseudoCs[prop] !== cs[prop])
2009
+ if (!changed.length) continue
2010
+ out.push({ sel, content: changed.map((prop) => `${prop}: ${pseudoCs[prop]}`).join('; '), styleOnly: true })
2011
+ }
2012
+ return out
2013
+ }
2014
+
2015
+ /**
2016
+ * Untransformed border box in root space.
2017
+ *
2018
+ * With no transform between the capture root and `el` the painted rect IS the
2019
+ * layout box, and it is the only source with sub-pixel precision, so it wins.
2020
+ * Under a transform it is the bbox of a transformed quad — unusable — and the
2021
+ * offset chain answers instead, at integer precision, which is recorded.
2022
+ */
2023
+ function measure (el, cs, st, frameCtx, node) {
2024
+ const chainTransformed = frameCtx.transformed || hasTransform(cs)
2025
+ if (!chainTransformed) {
2026
+ const r = rectOf(el)
2027
+ return { x: r.x - st.rootOrigin.x, y: r.y - st.rootOrigin.y, w: r.w, h: r.h }
2028
+ }
2029
+ if (el.namespaceURI === MATH_NS && el.localName === 'math') {
2030
+ const { box, root } = mathLayoutBox(el, st.rootEl)
2031
+ return { x: box.x - root.x, y: box.y - root.y, w: box.w, h: box.h }
2032
+ }
2033
+ const lb = st.rootLayout && layoutBox(el)
2034
+ if (lb) {
2035
+ pushDiag(st, {
2036
+ code: 'B4.geometry-from-layout',
2037
+ severity: 'info',
2038
+ _node: node,
2039
+ note: 'a transform on this element or an ancestor makes getBoundingClientRect ' +
2040
+ 'report a transformed bbox; the box comes from the integer-rounded layout ' +
2041
+ 'offset chain instead, so it can be up to 0.5px off per axis',
2042
+ })
2043
+ if (node) degrade(node, 'A', 'geometry rounded to integers')
2044
+ return {
2045
+ x: lb.x - st.rootLayout.x - frameCtx.scrollX,
2046
+ y: lb.y - st.rootLayout.y - frameCtx.scrollY,
2047
+ w: lb.w,
2048
+ h: lb.h,
2049
+ }
2050
+ }
2051
+ const r = rectOf(el)
2052
+ pushDiag(st, {
2053
+ code: 'B4.geometry-transformed',
2054
+ severity: 'warn',
2055
+ _node: node,
2056
+ note: 'element has no layout offset box (SVG or detached); geometry is the ' +
2057
+ 'post-transform bounding box, so its own transform is applied twice by any ' +
2058
+ 'backend that also honours `transform`',
2059
+ })
2060
+ if (node) degrade(node, 'A', 'geometry measured post-transform')
2061
+ return { x: r.x - st.rootOrigin.x, y: r.y - st.rootOrigin.y, w: r.w, h: r.h }
2062
+ }
2063
+
2064
+ function readTransform (el, cs, box, node, st) {
2065
+ if (!hasTransform(cs)) return null
2066
+ // The individual properties are separate computed values and apply BEFORE
2067
+ // `transform`, in the order translate, rotate, scale. Handing the whole list to
2068
+ // one parser keeps a single origin convention instead of two.
2069
+ const parts = []
2070
+ if (isSet(cs.translate)) parts.push(`translate(${cs.translate.trim().split(/\s+/).slice(0, 2).join(', ')})`)
2071
+ if (isSet(cs.rotate)) parts.push(`rotate(${cs.rotate.trim().split(/\s+/).pop()})`)
2072
+ if (isSet(cs.scale)) parts.push(`scale(${cs.scale.trim().split(/\s+/).slice(0, 2).join(', ')})`)
2073
+ if (isSet(cs.transform)) parts.push(cs.transform)
2074
+ if (isSet(cs.offsetPath)) {
2075
+ pushDiag(st, {
2076
+ code: 'B4.offset-path',
2077
+ severity: 'warn',
2078
+ _node: node,
2079
+ note: 'motion-path placement (offset-path/offset-distance) is not modelled; ' +
2080
+ 'the box is taken from layout, which is where the element would sit at distance 0',
2081
+ })
2082
+ degrade(node, 'A', 'offset-path ignored')
2083
+ }
2084
+ if (!parts.length) return null
2085
+
2086
+ const parsed = parseTransform(parts.join(' '), cs.transformOrigin, box.w, box.h)
2087
+ if (!parsed) {
2088
+ pushDiag(st, {
2089
+ code: 'B4.transform-unreadable',
2090
+ severity: 'warn',
2091
+ _node: node,
2092
+ css: parts.join(' '),
2093
+ note: 'no transform function could be read; the node is emitted untransformed',
2094
+ })
2095
+ degrade(node, 'A', 'transform dropped')
2096
+ return null
2097
+ }
2098
+ if (parsed.is3d) {
2099
+ pushDiag(st, {
2100
+ code: 'B4.transform-3d',
2101
+ severity: 'warn',
2102
+ _node: node,
2103
+ css: parts.join(' '),
2104
+ note: 'a 3D transform was flattened to its 2D projection',
2105
+ })
2106
+ degrade(node, 'A', '3D transform flattened')
2107
+ }
2108
+ for (const note of parsed.notes || []) {
2109
+ pushDiag(st, { code: 'B4.transform-partial', severity: 'warn', _node: node, note })
2110
+ degrade(node, 'A', note)
2111
+ }
2112
+ return { m: parsed.m, decomposed: parsed.decomposed }
2113
+ }
2114
+
2115
+ function makeNode (fields) {
2116
+ return {
2117
+ type: fields.type,
2118
+ name: fields.name,
2119
+ frame: { x: 0, y: 0, w: 0, h: 0 },
2120
+ abs: { x: 0, y: 0, w: 0, h: 0 },
2121
+ paint: { z: 0, stackingContext: false },
2122
+ transform: null,
2123
+ opacity: 1,
2124
+ blend: 'normal',
2125
+ clip: null,
2126
+ fills: [],
2127
+ strokes: [],
2128
+ effects: [],
2129
+ text: null,
2130
+ fidelity: { grade: 'E', notes: [] },
2131
+ source: fields.source,
2132
+ children: [],
2133
+ }
2134
+ }
2135
+
2136
+ /**
2137
+ * One DOM element -> at most one emitted entry.
2138
+ *
2139
+ * Returns `{node, cs, el, sc}` where `node` is the emitted node and `cs`/`el`/`sc`
2140
+ * describe the OUTERMOST element of the flattened chain — a pass-through wrapper
2141
+ * hands its own ordering properties up with its child's node, so a
2142
+ * `position:absolute; z-index:5` div that emits nothing still places the box it
2143
+ * wraps. `null` when the subtree paints nothing.
2144
+ */
2145
+ function walk (el, st, frameCtx) {
2146
+ const tag = el.tagName ? el.tagName.toUpperCase() : ''
2147
+ if (!tag || SKIP_TAGS.has(tag)) return null
2148
+ if (isExcluded(el, st.exclude)) return null
2149
+ if (frameCtx.depth > st.maxDepth) return null
2150
+
2151
+ const cs = st.styleOf(el)
2152
+ if (!cs || cs.display === 'none') return null
2153
+
2154
+ const isRoot = el === st.rootEl
2155
+ const visible = cs.visibility === 'visible'
2156
+ const contents = cs.display === 'contents'
2157
+ const pseudo = isRoot ? null : pseudoSelectorOf(el)
2158
+ const segment = pathSegment(el, tag.toLowerCase(), pseudo)
2159
+ // A pseudo hangs off its originating element's selector, not below it as a child.
2160
+ const path = isRoot ? segment : (pseudo ? `${frameCtx.path}${segment}` : `${frameCtx.path} > ${segment}`)
2161
+
2162
+ // Artifacts of the clone: nodes snapdom added that the page never had. Each one
2163
+ // leaves under its own diagnostic code — a node that vanishes without a reason is
2164
+ // the failure mode this engine exists to avoid.
2165
+ const artifact = isRoot ? null : cloneArtifactOf(el)
2166
+ if (artifact === 'exclusion-spacer') {
2167
+ pushDiag(st, {
2168
+ code: 'B4.clone-exclusion-spacer',
2169
+ severity: 'info',
2170
+ path,
2171
+ note: 'snapdom replaced an excluded element with an invisible layout spacer ' +
2172
+ '(excludeMode:"hide"): it reserves the space the element took and paints ' +
2173
+ 'nothing at all, so it is pruned rather than emitted as an empty node',
2174
+ })
2175
+ return null
2176
+ }
2177
+ if (artifact === 'defs-container') {
2178
+ pushDiag(st, {
2179
+ code: 'B4.clone-defs-container',
2180
+ severity: 'info',
2181
+ path,
2182
+ note: 'snapdom hoisted the <defs> that the captured SVGs reference from outside the ' +
2183
+ 'subtree into a zero-sized <svg class="inline-defs-container">. It is a lookup table ' +
2184
+ 'and paints nothing; `collect/image.js` reads it when it resolves an SVG\'s ' +
2185
+ '`url(#…)` references, so the node is pruned and nothing it holds is lost',
2186
+ })
2187
+ return null
2188
+ }
2189
+
2190
+ const sc = isRoot ? true : isStackingContext(el, cs, frameCtx.parentCs)
2191
+ const own = hasTransform(cs)
2192
+ const childCtx = {
2193
+ path,
2194
+ depth: frameCtx.depth + 1,
2195
+ transformed: frameCtx.transformed || own,
2196
+ parentCs: cs,
2197
+ scrollX: frameCtx.scrollX + (el.scrollLeft || 0),
2198
+ scrollY: frameCtx.scrollY + (el.scrollTop || 0),
2199
+ inText: false,
2200
+ }
2201
+
2202
+ const opaque = OPAQUE_TAGS.has(tag)
2203
+ const isImage = IMAGE_TAGS.has(tag) || (tag === 'INPUT' && el.getAttribute('type') === 'image')
2204
+ const isSvg = tag === 'SVG'
2205
+ const isMath = tag === 'MATH' && el.namespaceURI === MATH_NS
2206
+ const textCarrier = !frameCtx.inText && visible && !contents && !opaque && hasOwnText(el, st)
2207
+ const inText = frameCtx.inText || textCarrier
2208
+
2209
+ // A form control paints something no CSS property announces: the UA draws the
2210
+ // value, the placeholder, the tick, the chevron, the track and the thumb, and
2211
+ // `appearance: none` only takes away the widget half. A checkbox with no
2212
+ // background, no border and no text failed every term above and was pruned
2213
+ // before `collect/box.js` could say it existed — 7 of the 11 controls in
2214
+ // `demo/challenges.html#fx-form` (3 checkboxes, 3 radios, the `<progress>`)
2215
+ // vanished with no node and therefore no diagnostic. Being a control IS paint.
2216
+ const isControl = CONTROL_TAGS.has(tag)
2217
+ const paints = visible && !contents && (paintsBox(cs) || isImage || isSvg || isMath || textCarrier || isControl)
2218
+ // The marker and first-line cases paint on the page and have no box in the tree,
2219
+ // so both have to be seen before the pruner decides — a node that says "something
2220
+ // was here" is the point, and it needs a box to say it in.
2221
+ const pseudos = contents || !visible ? [] : findUnrepresentedPseudos(el, cs, textCarrier, st)
2222
+ // A husk is empty and invisible by construction, so nothing below would keep it;
2223
+ // it is emitted anyway because the content it stands for is real, and its box is
2224
+ // the crop the clip window made.
2225
+ const forced = artifact === 'clip-husk'
2226
+ // The clone has no shadow trees left — `deepClone` flattens them and rewrites their
2227
+ // CSS to prefixed classes. One here means we were handed a live element instead,
2228
+ // UNLESS the adapter put it there: connecting the clone upgrades its custom
2229
+ // elements and the browser attaches a root we then empty, which is a repaired
2230
+ // node and not a live one.
2231
+ const defusedShadow = !!(el.shadowRoot && el.hasAttribute && el.hasAttribute(DEFUSED_SHADOW_ATTR))
2232
+ const liveShadow = !!el.shadowRoot && !defusedShadow
2233
+
2234
+ // Poda por pintura visible. An element with nothing to paint and no emitted
2235
+ // descendant is not a node, whatever its CSS says — this is where the node
2236
+ // ratio is won.
2237
+ const kids = []
2238
+ if (!opaque && !isSvg) {
2239
+ for (const child of el.children) {
2240
+ const childCs = st.styleOf(child)
2241
+ // Only the in-flow inline part of the subtree belongs to this block's
2242
+ // inline formatting context. A block-level or out-of-flow child starts one
2243
+ // of its own, so it is a text carrier in its own right — without this the
2244
+ // text of every block inside a mixed-content box would silently vanish.
2245
+ childCtx.inText = inText && !!childCs && joinsParentText(childCs.display) &&
2246
+ childCs.position !== 'absolute' && childCs.position !== 'fixed' &&
2247
+ floatOf(childCs) === 'none'
2248
+ const entry = walk(child, st, childCtx)
2249
+ if (entry) kids.push(entry)
2250
+ }
2251
+ }
2252
+ if (!paints && !kids.length && !isRoot && !forced && !liveShadow && !pseudos.length) return null
2253
+
2254
+ const needsOwnLayer = !contents && (forcesGroup(cs) || clipsContent(cs) || own)
2255
+ const emits = isRoot || paints || needsOwnLayer || forced || liveShadow ||
2256
+ pseudos.length > 0 || kids.length >= 2
2257
+
2258
+ if (!emits) {
2259
+ // Pass-through: at most one emitted child, so the wrapper adds nothing but a
2260
+ // level. Its ordering properties travel up with the child it wraps.
2261
+ if (!kids.length) return null
2262
+ return { node: kids[0].node, cs, el, sc }
2263
+ }
2264
+
2265
+ if (!isRoot && st.count >= st.maxNodes) {
2266
+ if (!st.truncated) {
2267
+ st.truncated = true
2268
+ pushDiag(st, {
2269
+ code: 'B4.max-nodes',
2270
+ severity: 'error',
2271
+ limit: st.maxNodes,
2272
+ note: 'node ceiling reached; the rest of the subtree is not in the document',
2273
+ })
2274
+ }
2275
+ return kids.length ? { node: kids[0].node, cs, el, sc } : null
2276
+ }
2277
+ st.count++
2278
+
2279
+ // The text of the block is a child, never the box itself: a `text` node's
2280
+ // `fills` are the glyph fills in every backend, so a background that landed
2281
+ // there would paint the letters instead of the box.
2282
+ const wantsTextChild = textCarrier && (kids.length > 0 || paintsBox(cs) || forcesGroup(cs) || clipsContent(cs))
2283
+ const asText = textCarrier && !wantsTextChild
2284
+
2285
+ let type
2286
+ if (isSvg || isMath) type = 'vector'
2287
+ else if (isImage) type = 'image'
2288
+ else if (asText) type = 'text'
2289
+ else if (kids.length || wantsTextChild) type = paintsBox(cs) || clipsContent(cs) ? 'frame' : 'group'
2290
+ else type = paints ? 'shape' : 'frame'
2291
+
2292
+ const source = { tag: tag.toLowerCase(), path, css: sourceCss(cs) }
2293
+ // Provenance the clone knows and the page does not. A consumer reading `source`
2294
+ // must be able to tell a `::before` from a span and an artifact from an element
2295
+ // without re-deriving either from the name.
2296
+ if (pseudo) source.pseudo = pseudo
2297
+ if (artifact) source.clone = artifact
2298
+ if (el.hasAttribute && el.hasAttribute(SHADOW_SCOPE_ATTR)) source.shadowHost = true
2299
+ const node = makeNode({
2300
+ type,
2301
+ name: artifact === 'scroll-wrapper'
2302
+ ? 'scroll offset'
2303
+ : nodeName(el, tag.toLowerCase(), asText ? 'text' : type,
2304
+ asText || pseudo ? ownText(el, st) : '', pseudo),
2305
+ source,
2306
+ })
2307
+ node.abs = contents
2308
+ ? unionBoxes(kids.map(k => k.node.abs))
2309
+ : measure(el, cs, st, frameCtx, node)
2310
+ node.paint.stackingContext = sc
2311
+ node.opacity = Math.min(1, Math.max(0, opacityOf(cs)))
2312
+ node.blend = cs.mixBlendMode && cs.mixBlendMode !== '' ? cs.mixBlendMode : 'normal'
2313
+ node.transform = contents ? null : readTransform(el, cs, node.abs, node, st)
2314
+
2315
+ Object.defineProperty(node, 'el', { value: el, writable: true, configurable: true, enumerable: false })
2316
+ Object.defineProperty(node, '_kids', { value: kids.map(k => k.node), writable: true, configurable: true, enumerable: false })
2317
+ Object.defineProperty(node, '_paints', { value: paints, writable: true, configurable: true, enumerable: false })
2318
+
2319
+ for (const { sel, content, styleOnly, why } of pseudos) {
2320
+ pushDiag(st, {
2321
+ code: styleOnly ? 'B4.pseudo-style' : 'B4.list-marker',
2322
+ severity: 'warn',
2323
+ _node: node,
2324
+ pseudo: sel,
2325
+ content,
2326
+ note: styleOnly
2327
+ ? `${sel} restyles glyphs that ARE collected, at the element's own style instead of the ` +
2328
+ 'pseudo\'s — the characters are present, their size, weight and advance are the wrong ones'
2329
+ : 'the list marker is painted by the UA from list-style-*, so the clone has no element for ' +
2330
+ 'it and nothing to collect. It is rebuilt where the string can be reproduced from the ' +
2331
+ `page's own @counter-style rules or from the predefined ones, AND checked against the ` +
2332
+ `UA's own advance; here ${
2333
+ why || `"${content}" is a counter style this build does not reproduce (an ` +
2334
+ '`extends`/`additive` @counter-style, or one of the CJK, Ethiopic or disclosure ' +
2335
+ 'systems), or the item is not an LTR `list-style-position: outside` one'
2336
+ }, so the item is emitted without its marker`,
2337
+ })
2338
+ degrade(node, styleOnly ? 'O' : 'A', `${sel} omitted`)
2339
+ }
2340
+ if (st.markerSynthesized && st.markerSynthesized.has(el)) {
2341
+ pushDiag(st, {
2342
+ code: 'B4.list-marker-synthesized',
2343
+ severity: 'info',
2344
+ _node: node,
2345
+ pseudo: '::marker',
2346
+ content: el.getAttribute('data-snapdom-vector-marker-host'),
2347
+ kind: el.getAttribute('data-snapdom-vector-marker-shape') ? 'symbol' : 'text',
2348
+ note: el.getAttribute('data-snapdom-vector-marker-shape')
2349
+ ? `the UA paints this ${el.getAttribute('data-snapdom-vector-marker-shape')} bullet as a SHAPE ` +
2350
+ 'and not as a glyph — measured across three font families and twelve sizes, its ink is the ' +
2351
+ 'same width in all of them, so it is not the font\'s bullet and cannot be rebuilt as text ' +
2352
+ '(the string would land ~7px right of the bullet at 14px). It is rebuilt as a real shape ' +
2353
+ 'instead, at the diameter (0.3 x the marker font-size), the gap to the item\'s content edge ' +
2354
+ '(0.3 x font-size + 6.7px) and the rise above the first baseline (0.29 x font-size) that ' +
2355
+ 'Chromium was measured to use, each fitted over 10..40px with a worst residual under 1px, ' +
2356
+ 'and then checked to land inside the marker box this engine itself reports. That is a LAW ' +
2357
+ 'rather than a measurement of this marker: another engine draws the bullet somewhere else'
2358
+ : 'the UA paints this list marker natively and the clone has no element for it, so one ' +
2359
+ `was rebuilt: the string from ${el.hasAttribute('data-snapdom-vector-marker-authored')
2360
+ ? 'the ::marker\'s own `content`, resolved against the counters in scope'
2361
+ : '`list-style-type` and the page\'s @counter-style rule'}, the ` +
2362
+ 'paint from `getComputedStyle(li, "::marker")`, the box right-aligned to the item\'s ' +
2363
+ 'content edge on its first baseline. The string was checked against the advance the UA ' +
2364
+ 'itself reports and the box against its own measured position, so this is a ' +
2365
+ 'reconstruction that agreed with the browser, not a guess — but it is a reconstruction' +
2366
+ (el.getAttribute('data-snapdom-vector-marker-overruled')
2367
+ ? `. The check DISAGREED here (${el.getAttribute('data-snapdom-vector-marker-overruled')}px, ours ` +
2368
+ 'over the clone\'s) and was overruled on purpose: this marker prints a counters() stack, ' +
2369
+ 'and the clone is mounted beside the document it was cloned from, so Chromium\'s ' +
2370
+ 'sibling-carry leaks the page\'s own list-item counters into it and its marker renders one ' +
2371
+ 'level too deep. The string above is the css-lists-3 resolution taken from the tree, which ' +
2372
+ 'is what the page paints'
2373
+ : ''),
2374
+ })
2375
+ degrade(node, 'A', el.getAttribute('data-snapdom-vector-marker-shape')
2376
+ ? `::marker rebuilt as a ${el.getAttribute('data-snapdom-vector-marker-shape')} shape at Chromium's measured proportions`
2377
+ : '::marker rebuilt from list-style-* rather than captured')
2378
+ }
2379
+ if (artifact === 'clip-husk') {
2380
+ pushDiag(st, {
2381
+ code: 'B4.clone-clip-husk',
2382
+ severity: 'warn',
2383
+ _node: node,
2384
+ note: 'snapdom culled this subtree before we saw it: it painted entirely outside ' +
2385
+ "the capture's clip window, and what reached the clone is a frozen empty " +
2386
+ 'stand-in. The box IS the crop the clip made and is emitted as such; the ' +
2387
+ 'content behind it was never cloned and cannot be recovered here',
2388
+ })
2389
+ degrade(node, 'O', 'subtree culled outside the clip window')
2390
+ }
2391
+ if (artifact === 'scroll-wrapper') {
2392
+ pushDiag(st, {
2393
+ code: 'B4.clone-scroll-wrapper',
2394
+ severity: 'info',
2395
+ _node: node,
2396
+ note: "snapdom's scroll compensation, not an element of the page: it carries the " +
2397
+ 'translate that puts a scrolled container back where it was seen. The layer is ' +
2398
+ 'kept because every child is positioned by it',
2399
+ })
2400
+ }
2401
+ if (liveShadow) {
2402
+ pushDiag(st, {
2403
+ code: 'B4.unresolved-shadow-root',
2404
+ severity: 'error',
2405
+ _node: node,
2406
+ note: 'this element has a shadowRoot that nothing accounted for. A clone never ' +
2407
+ 'inherits one (`cloneNode` does not copy shadow trees) and the adapter marks the ' +
2408
+ 'ones it provokes by mounting, so this is either a live tree reaching `buildPaintTree` ' +
2409
+ 'instead of the clone, or a `closed` shadow root the adapter could not reach to ' +
2410
+ 'repair. The host is emitted, its shadow tree is not',
2411
+ })
2412
+ degrade(node, 'O', 'shadow tree omitted (walked a live tree, not the clone)')
2413
+ }
2414
+ if (defusedShadow) {
2415
+ pushDiag(st, {
2416
+ code: 'B4.upgraded-custom-element',
2417
+ severity: 'info',
2418
+ _node: node,
2419
+ note: 'this is a custom element, and connecting the clone made the browser upgrade it: ' +
2420
+ 'its `connectedCallback` attached a new shadow root over the tree snapdom had already ' +
2421
+ 'flattened. The adapter emptied that root back to a bare `<slot>`, which renders the ' +
2422
+ 'flattened content unchanged — measured at 0.00px against the live element. What it ' +
2423
+ 'cannot undo is a `connectedCallback` that rewrites its own light DOM, or a `closed` ' +
2424
+ 'shadow root, which is not reachable from script at all',
2425
+ })
2426
+ }
2427
+ // The pseudo whose number is wrong is usually NOT a node of its own: its text
2428
+ // is inline and folds into the block's text node, which is the node that ends
2429
+ // up carrying the wrong characters and therefore the node to degrade.
2430
+ if (pseudo === '::before' || pseudo === '::after') reportCounterDrift(el, pseudo, node, st)
2431
+ for (const child of el.children || []) {
2432
+ const sel = pseudoSelectorOf(child)
2433
+ if (sel === '::before' || sel === '::after') reportCounterDrift(child, sel, node, st)
2434
+ }
2435
+ if (isSvg) {
2436
+ pushDiag(st, {
2437
+ code: 'B4.inline-svg',
2438
+ severity: 'info',
2439
+ _node: node,
2440
+ note: 'the subtree of an inline <svg> is not walked: it has no CSS box tree of its own, ' +
2441
+ 'so `collect/image.js` serializes it whole, namespaces its ids per instance and ' +
2442
+ 'resolves every `url(#…)` to the definition the DOCUMENT resolved it to. It travels ' +
2443
+ 'as passthrough markup on this node, which is why the node is `vector` and not `image`. ' +
2444
+ 'This is not a degradation and is not graded here — if the markup does not reach the ' +
2445
+ 'backend, the backend says so (`emit.svg.vector-no-markup`, grade O)',
2446
+ })
2447
+ }
2448
+ if (RASTER_ONLY_TAGS.has(tag)) {
2449
+ pushDiag(st, {
2450
+ code: 'B4.raster-only',
2451
+ severity: 'info',
2452
+ _node: node,
2453
+ tag: tag.toLowerCase(),
2454
+ note: 'replaced content with no vector form; the collector must rasterize it',
2455
+ })
2456
+ }
2457
+ if (CONTROL_TAGS.has(tag)) {
2458
+ let widget = null
2459
+ try {
2460
+ widget = describeWidget(el, cs)
2461
+ } catch (error) {
2462
+ pushDiag(st, {
2463
+ code: 'B4.form-control',
2464
+ severity: 'warn',
2465
+ _node: node,
2466
+ tag: tag.toLowerCase(),
2467
+ note: `reading this control's widget threw (${error && error.message}); it is emitted as an ` +
2468
+ 'empty box',
2469
+ })
2470
+ }
2471
+ if (widget) node.control = widget
2472
+ pushDiag(st, {
2473
+ code: 'B4.form-control',
2474
+ severity: 'warn',
2475
+ _node: node,
2476
+ tag: tag.toLowerCase(),
2477
+ widget: widget ? widget.kind : null,
2478
+ note: widget
2479
+ ? `a form control paints a widget no CSS property describes, so this one is REBUILT from what ` +
2480
+ `the element does declare: a ${widget.kind}, its state, and ` +
2481
+ `${widget.accentSource === 'accent-color' ? 'its own accent-color' : 'the UA accent this build measured'}. ` +
2482
+ 'The box, its background and its border are exact and come from CSS; the shapes inside it ' +
2483
+ '(the tick, the dot, the chevron, the track, the thumb) are shapes THIS ENGINE drew to ' +
2484
+ 'Chromium\'s measured proportions, not the widget the platform paints — a different browser, ' +
2485
+ 'a different platform theme or a different Chromium version draws it differently, and the ' +
2486
+ 'text a control paints (a value, a placeholder, an option label) is still not drawn. Read ' +
2487
+ '`collect.box.form-control` on this node for what the UA paints; the half of it that says ' +
2488
+ '"this engine has no primitive for that" is out of date for the shapes named here'
2489
+ : 'a form control paints a widget no CSS property describes and this one is not rebuilt (' +
2490
+ ((cs.appearance || cs.webkitAppearance || 'auto') === 'none'
2491
+ ? 'appearance:none — the author replaced the widget in CSS, which the ordinary ' +
2492
+ 'collectors already read'
2493
+ : 'its widget is text or a button — a value, a placeholder, an option label, a file ' +
2494
+ 'picker, a date picker or a list — and this build draws shapes, not UA text') +
2495
+ '). The box, its background and its border are exact; read `collect.box.form-control` ' +
2496
+ 'on this node for what the UA draws inside it',
2497
+ })
2498
+ degrade(node, 'A', widget
2499
+ ? `${widget.kind} widget reconstructed, not captured`
2500
+ : 'control widget declared, not drawn')
2501
+ }
2502
+ const mixed = textCarrier && hasInFlowBlockChild(el, st)
2503
+ if (mixed) {
2504
+ pushDiag(st, {
2505
+ code: 'B4.anonymous-block',
2506
+ severity: 'warn',
2507
+ _node: node,
2508
+ note: 'text and block-level children share this box, so the browser wrapped the ' +
2509
+ 'text in anonymous block boxes. One text node stands for all of them, placed ' +
2510
+ 'before the block children, and `collectText` MUST NOT descend into those ' +
2511
+ 'children or their characters land in two nodes',
2512
+ })
2513
+ degrade(node, 'A', 'anonymous block boxes merged into one text node')
2514
+ }
2515
+
2516
+ // Ordering: text runs paint above the inline decorations they sit on and below
2517
+ // anything positioned, which is exactly where an in-flow inline box lands. Where
2518
+ // the text is really a set of anonymous BLOCK boxes it takes the block lane
2519
+ // instead, ahead of the siblings it was interleaved with — usually right, always
2520
+ // closer than painting it over all of them.
2521
+ const descriptors = kids.map(k => ({ cs: k.cs, el: k.el, stackingContext: k.sc }))
2522
+ let textIndex = -1
2523
+ if (wantsTextChild) {
2524
+ const descriptor = mixed
2525
+ ? { position: 'static', float: 'none', display: 'block', zIndex: 'auto', stackingContext: false }
2526
+ : { position: 'static', float: 'none', display: 'inline', zIndex: 'auto', stackingContext: false }
2527
+ textIndex = mixed ? 0 : descriptors.length
2528
+ descriptors.splice(textIndex, 0, descriptor)
2529
+ }
2530
+ const ranks = paintOrder(descriptors)
2531
+ for (let i = 0; i < kids.length; i++) {
2532
+ kids[i].node.paint.z = ranks[i < textIndex || textIndex === -1 ? i : i + 1]
2533
+ }
2534
+
2535
+ if (wantsTextChild) {
2536
+ const textNode = makeNode({
2537
+ type: 'text',
2538
+ name: nodeName(el, tag.toLowerCase(), 'text', ownText(el, st), pseudo),
2539
+ source: pseudo
2540
+ ? { tag: tag.toLowerCase(), path: `${path}::text`, css: {}, pseudo }
2541
+ : { tag: tag.toLowerCase(), path: `${path}::text`, css: {} },
2542
+ })
2543
+ textNode.abs = { ...node.abs }
2544
+ textNode.paint.z = ranks[textIndex]
2545
+ Object.defineProperty(textNode, 'el', { value: el, writable: true, configurable: true, enumerable: false })
2546
+ Object.defineProperty(textNode, '_kids', { value: [], writable: true, configurable: true, enumerable: false })
2547
+ Object.defineProperty(textNode, '_paints', { value: true, writable: true, configurable: true, enumerable: false })
2548
+ node._kids.push(textNode)
2549
+ st.count++
2550
+ }
2551
+
2552
+ // Promotion candidates, evaluated once the whole tree and its paint order exist.
2553
+ const z = zIndexOf(cs)
2554
+ let promoteAs = null
2555
+ if (isRoot) promoteAs = null
2556
+ else if (z !== null && z < 0 && (cs.position !== 'static' || isFlexOrGridItem(el, frameCtx.parentCs))) {
2557
+ promoteAs = { kind: 'negative-z', z }
2558
+ } else if (isTopLayer(el)) promoteAs = { kind: 'top-layer' }
2559
+ else if (cs.position === 'fixed') promoteAs = { kind: 'fixed' }
2560
+ else if (cs.position === 'absolute') promoteAs = { kind: 'absolute' }
2561
+ const hide = (key, value) => Object.defineProperty(node, key, { value, writable: true, configurable: true, enumerable: false })
2562
+ if (promoteAs) hide('_promote', promoteAs)
2563
+ hide('_clips', !contents && clipsContent(cs))
2564
+ hide('_fixedCB', isRoot || isFixedContainingBlock(cs))
2565
+ hide('_absCB', isRoot || cs.position !== 'static' || isFixedContainingBlock(cs))
2566
+
2567
+ return { node, cs, el, sc }
2568
+ }
2569
+
2570
+ /** Modal dialogs, open popovers and the fullscreen element paint above everything. */
2571
+ function isTopLayer (el) {
2572
+ if (typeof el.matches !== 'function') return false
2573
+ try {
2574
+ if (el.matches(':modal')) return true
2575
+ } catch {
2576
+ if (el.tagName === 'DIALOG' && el.hasAttribute('open')) return true
2577
+ }
2578
+ try {
2579
+ if (el.matches(':popover-open')) return true
2580
+ } catch { /* older engine: no popover API, so no top layer entry either */ }
2581
+ const doc = el.ownerDocument
2582
+ return !!doc && doc.fullscreenElement === el
2583
+ }
2584
+
2585
+ // ——— assembly, promotion, frames ———
2586
+
2587
+ function assignIds (rootNode, st) {
2588
+ const nodes = {}
2589
+ const parentOf = new Map()
2590
+ let seq = 0
2591
+ const visit = (node, parentId) => {
2592
+ const id = `n_${seq++}`
2593
+ Object.defineProperty(node, 'id', { value: id, writable: true, configurable: true, enumerable: false })
2594
+ nodes[id] = node
2595
+ if (parentId !== null) parentOf.set(id, parentId)
2596
+ for (const kid of node._kids) visit(kid, id)
2597
+ node.children = node._kids.map(k => k.id)
2598
+ return id
2599
+ }
2600
+ const rootId = visit(rootNode, null)
2601
+ st.nodes = nodes
2602
+ st.parentOf = parentOf
2603
+ return rootId
2604
+ }
2605
+
2606
+ /** Back-to-front order of the whole document: a node paints before its children. */
2607
+ function flattenOrder (rootId, nodes) {
2608
+ const order = []
2609
+ const visit = (id) => {
2610
+ order.push(id)
2611
+ const kids = nodes[id].children.slice().sort((a, b) => nodes[a].paint.z - nodes[b].paint.z)
2612
+ for (const kid of kids) visit(kid)
2613
+ }
2614
+ visit(rootId)
2615
+ return order
2616
+ }
2617
+
2618
+ function subtreeIds (id, nodes, out = new Set()) {
2619
+ out.add(id)
2620
+ for (const kid of nodes[id].children) subtreeIds(kid, nodes, out)
2621
+ return out
2622
+ }
2623
+
2624
+ function ancestorsOf (id, st) {
2625
+ const out = []
2626
+ let cur = st.parentOf.get(id)
2627
+ while (cur !== undefined) {
2628
+ out.push(cur)
2629
+ cur = st.parentOf.get(cur)
2630
+ }
2631
+ return out
2632
+ }
2633
+
2634
+ /**
2635
+ * Ancestors between the node and its containing block that clip, and whose box
2636
+ * does not cover it. The node escapes those clips per CSS — the clip only applies
2637
+ * to boxes whose containing block is the clipper or inside it — but a tree that
2638
+ * keeps it there would have the backend clip it anyway, and clip it away entirely
2639
+ * in the usual case where it sits somewhere else on the page.
2640
+ */
2641
+ function clipEscapes (id, parentId, targetId, st) {
2642
+ const box = st.nodes[id].abs
2643
+ const out = []
2644
+ let cur = parentId
2645
+ while (cur !== undefined && cur !== targetId) {
2646
+ const ancestor = st.nodes[cur]
2647
+ if (ancestor._clips && !containsBox(ancestor.abs, box)) out.push(cur)
2648
+ cur = st.parentOf.get(cur)
2649
+ }
2650
+ return out
2651
+ }
2652
+
2653
+ /**
2654
+ * Reparenting, with the two-part test from D3. Condition (a) is structural — the
2655
+ * order cannot be had by reordering siblings within the parent — and is what
2656
+ * `targetId` computes; condition (b) is geometric, and is either a clip the node
2657
+ * escapes or an overlap with a box whose relative order would change. Both must
2658
+ * hold, so a `z-index:-1` decoration that overlaps nothing keeps its place in the
2659
+ * tree. That is the common case, and the reason most containers survive intact.
2660
+ *
2661
+ * Every candidate is tested against the pre-promotion order: promotions are rare
2662
+ * and independent in practice, and re-flattening after each one would make the
2663
+ * result depend on the order candidates happen to be visited in.
2664
+ */
2665
+ function promote (rootId, st) {
2666
+ const { nodes } = st
2667
+ const candidates = Object.keys(nodes).filter(id => nodes[id]._promote)
2668
+ if (!candidates.length) return
2669
+
2670
+ const order = flattenOrder(rootId, nodes)
2671
+ const index = new Map(order.map((id, i) => [id, i]))
2672
+
2673
+ for (const id of candidates) {
2674
+ const node = nodes[id]
2675
+ const parentId = st.parentOf.get(id)
2676
+ if (parentId === undefined) continue
2677
+ const { kind } = node._promote
2678
+
2679
+ // (a) where would the correct order — or the correct containing block — put it?
2680
+ let targetId = null
2681
+ let backwards = false
2682
+ let reason = ''
2683
+ if (kind === 'negative-z') {
2684
+ // A negative z-index paints in front of its stacking context's background
2685
+ // and behind everything else in it. Inside a parent that IS that context
2686
+ // this is just "first sibling"; otherwise it has to clear the parent's own
2687
+ // background, which no sibling order expresses.
2688
+ targetId = ancestorsOf(id, st).find(a => nodes[a].paint.stackingContext) || rootId
2689
+ backwards = true
2690
+ reason = `z-index:${node._promote.z} must paint behind the background of an ancestor that is not a stacking context`
2691
+ } else if (kind === 'fixed') {
2692
+ targetId = ancestorsOf(id, st).find(a => nodes[a]._fixedCB) || rootId
2693
+ reason = 'position:fixed — the containing block is an ancestor, so the clips and the paint order in between do not apply'
2694
+ } else if (kind === 'absolute') {
2695
+ targetId = ancestorsOf(id, st).find(a => nodes[a]._absCB) || rootId
2696
+ reason = 'position:absolute — its containing block is above an ancestor that clips, so that clip does not apply to it'
2697
+ } else {
2698
+ targetId = rootId
2699
+ reason = 'top layer (modal dialog, open popover or fullscreen element) paints above the whole document'
2700
+ }
2701
+ if (!targetId || targetId === parentId || targetId === id) continue
2702
+
2703
+ // (b) is the divergence observable? An escaped clip is the cheap answer and the
2704
+ // decisive one — left in place the node would be cropped, usually to nothing.
2705
+ let hits = clipEscapes(id, parentId, targetId, st)
2706
+ const escaped = hits.length > 0
2707
+ if (escaped) {
2708
+ reason += `; its box is not inside the clip of ${hits.join(', ')}`
2709
+ } else if (kind !== 'absolute') {
2710
+ // Otherwise it comes down to paint order, which is only observable against
2711
+ // boxes that paint and that this one overlaps.
2712
+ const scope = subtreeIds(targetId, nodes)
2713
+ const mine = subtreeIds(id, nodes)
2714
+ const myIndex = index.get(id)
2715
+ hits = []
2716
+ for (const other of scope) {
2717
+ if (other === targetId || mine.has(other)) continue
2718
+ const otherNode = nodes[other]
2719
+ if (!otherNode._paints) continue
2720
+ const before = index.get(other) < myIndex
2721
+ if (backwards ? !before : before) continue
2722
+ if (!overlaps(node.abs, otherNode.abs)) continue
2723
+ hits.push(other)
2724
+ if (hits.length >= 16) break
2725
+ }
2726
+ }
2727
+ if (!hits.length) continue
2728
+
2729
+ const siblings = nodes[targetId].children
2730
+ const zs = siblings.map(s => nodes[s].paint.z)
2731
+ // Escaping a clip is a change of containing block, not of paint order, so the
2732
+ // node lands immediately after the branch it came out of. Everything else moved
2733
+ // because of paint order, and goes to the end (or the start) of the target.
2734
+ let branch = escaped && kind !== 'top-layer' ? id : null
2735
+ for (let guard = 0; branch && st.parentOf.get(branch) !== targetId && guard < 4096; guard++) {
2736
+ branch = st.parentOf.get(branch)
2737
+ }
2738
+ if (backwards) {
2739
+ node.paint.z = Math.min(0, ...zs) - 1
2740
+ } else if (branch && nodes[branch]) {
2741
+ const at = nodes[branch].paint.z
2742
+ for (const sibling of siblings) if (nodes[sibling].paint.z > at) nodes[sibling].paint.z++
2743
+ node.paint.z = at + 1
2744
+ } else {
2745
+ node.paint.z = Math.max(0, ...zs) + 1
2746
+ }
2747
+
2748
+ const from = nodes[parentId].children
2749
+ from.splice(from.indexOf(id), 1)
2750
+ siblings.push(id)
2751
+ st.parentOf.set(id, targetId)
2752
+
2753
+ // `overlaps` is the geometric evidence for a paint-order move; a clip escape
2754
+ // has different evidence and says so under its own key rather than pretending
2755
+ // the clippers are boxes this one overlaps.
2756
+ const diag = { code: 'B4.promoted', severity: 'info', node: id, from: parentId, to: targetId, reason, overlaps: escaped ? [] : hits }
2757
+ if (escaped) diag.escapes = hits
2758
+ pushDiag(st, diag)
2759
+ }
2760
+ }
2761
+
2762
+ /** `frame` is `abs` in the emitted parent's space; `abs` is what survives reparenting. */
2763
+ function setFrames (rootId, st) {
2764
+ const { nodes } = st
2765
+ const visit = (id, origin) => {
2766
+ const node = nodes[id]
2767
+ node.frame = {
2768
+ x: node.abs.x - origin.x,
2769
+ y: node.abs.y - origin.y,
2770
+ w: node.abs.w,
2771
+ h: node.abs.h,
2772
+ }
2773
+ for (const kid of node.children) visit(kid, node.abs)
2774
+ }
2775
+ visit(rootId, { x: 0, y: 0 })
2776
+ }
2777
+
2778
+ /**
2779
+ * Build the paint tree for `rootEl`.
2780
+ *
2781
+ * `rootEl` is **snapdom's clone, already mounted** — the tree the `vector()` plugin's
2782
+ * `beforeRender` hook takes out of a capture, attached to the light DOM with
2783
+ * `fontsCSS + classPrefixCSS` in the document. Every predicate here reads computed style, and in the clone the
2784
+ * paint lives in prefixed classes rather than inline styles, so an unmounted clone
2785
+ * computes to nothing and would prune to a single empty node. That is checked, not
2786
+ * assumed: this function refuses a detached root instead of returning an empty
2787
+ * document that looks like a page with nothing on it.
2788
+ *
2789
+ * Produces structure, geometry and paint order — nothing else. No collector runs
2790
+ * here: every node keeps its source element on a non-enumerable `el` and the
2791
+ * orchestrator fills the paint arrays from it, so this function stays cheap
2792
+ * enough to run before any decision about rasterizing has been made.
2793
+ *
2794
+ * @param {Element} rootEl the capture root; its border box is the origin of `abs`
2795
+ * @param {CollectCtx} [ctx]
2796
+ * @returns {{nodes: Record<string, Object>, rootId: string, diagnostics: Object[]}}
2797
+ */
2798
+ export function buildPaintTree (rootEl, ctx = {}) {
2799
+ if (!rootEl || rootEl.nodeType !== 1) throw new Error('buildPaintTree: rootEl must be an Element')
2800
+ if (rootEl.isConnected === false) {
2801
+ throw new Error('buildPaintTree: rootEl is not in a document. The engine walks snapdom\'s ' +
2802
+ 'clone, and a clone only has computed styles — and therefore only has paint, since the ' +
2803
+ 'clone carries its paint in prefixed classes — once it is mounted. Attach it to the ' +
2804
+ 'light DOM with fontsCSS + classPrefixCSS before calling.')
2805
+ }
2806
+
2807
+ const st = makeState(rootEl, ctx)
2808
+ const rootCs = st.styleOf(rootEl)
2809
+ if (!rootCs) throw new Error('buildPaintTree: no computed style for rootEl (detached document?)')
2810
+ if (!rootCs.display) {
2811
+ throw new Error('buildPaintTree: rootEl computes to no style at all, so nothing in the tree ' +
2812
+ 'would appear to paint. It is attached to a document that does not lay it out (a detached ' +
2813
+ 'or inert one); mount the clone in the live document.')
2814
+ }
2815
+ if (typeof ShadowRoot === 'function' && typeof rootEl.getRootNode === 'function' &&
2816
+ rootEl.getRootNode() instanceof ShadowRoot) {
2817
+ pushDiag(st, {
2818
+ code: 'B4.clone-in-shadow-root',
2819
+ severity: 'warn',
2820
+ note: 'the clone is mounted inside a shadow root: Chromium ignores @font-face there, so ' +
2821
+ 'every text box was laid out with a substituted font and every measurement taken from ' +
2822
+ 'it is wrong. Mount it in the light DOM.',
2823
+ })
2824
+ }
2825
+
2826
+ // Before anything is measured: the marker spans are out of flow and move
2827
+ // nothing, but the `list-style-position` probe they are checked with does, and
2828
+ // it must be over and reverted before a single box is read.
2829
+ try {
2830
+ synthesizeMarkers(rootEl, st)
2831
+ } catch (error) {
2832
+ pushDiag(st, {
2833
+ code: 'B4.list-marker',
2834
+ severity: 'warn',
2835
+ note: `rebuilding the native list markers threw (${error && error.message}); every list item ` +
2836
+ 'in this capture is emitted without its marker',
2837
+ })
2838
+ }
2839
+ // Same reason, same place: a repaired counter is a different string and a
2840
+ // different advance, so it has to be in the clone before anything is measured.
2841
+ try {
2842
+ repairCounters(rootEl, st)
2843
+ } catch (error) {
2844
+ pushDiag(st, {
2845
+ code: 'B4.counter-misnumbered',
2846
+ severity: 'warn',
2847
+ note: `checking the materialised counters threw (${error && error.message}); the numbers in ` +
2848
+ 'this capture are snapdom\'s and nothing here has confirmed them',
2849
+ })
2850
+ }
2851
+
2852
+ const rootRect = rootEl.getBoundingClientRect()
2853
+ st.rootOrigin = { x: rootRect.left, y: rootRect.top }
2854
+ st.rootLayout = layoutBox(rootEl)
2855
+ if (!st.rootLayout && rootEl.namespaceURI === MATH_NS && hasTransform(rootCs)) {
2856
+ st.rootLayout = mathLayoutBox(rootEl, rootEl).box
2857
+ }
2858
+
2859
+ const entry = walk(rootEl, st, {
2860
+ path: '',
2861
+ depth: 0,
2862
+ // The capture root's own transform is outside the space `abs` is expressed
2863
+ // in, but it still transforms every descendant's painted rect, so the chain
2864
+ // starts "transformed" and descendants take the layout path.
2865
+ transformed: hasTransform(rootCs),
2866
+ parentCs: rootEl.parentElement ? st.styleOf(rootEl.parentElement) : null,
2867
+ scrollX: 0,
2868
+ scrollY: 0,
2869
+ inText: false,
2870
+ })
2871
+ if (!entry) throw new Error('buildPaintTree: the capture root emitted no node')
2872
+
2873
+ const rootNode = entry.node
2874
+ // The root's box is the origin by definition, whatever the chain above it does.
2875
+ rootNode.abs = {
2876
+ x: 0,
2877
+ y: 0,
2878
+ w: st.rootLayout && hasTransform(rootCs) ? st.rootLayout.w : rootRect.width,
2879
+ h: st.rootLayout && hasTransform(rootCs) ? st.rootLayout.h : rootRect.height,
2880
+ }
2881
+ rootNode.paint.z = 0
2882
+ rootNode.paint.stackingContext = true
2883
+
2884
+ const rootId = assignIds(rootNode, st)
2885
+ promote(rootId, st)
2886
+ setFrames(rootId, st)
2887
+
2888
+ // Diagnostics carry the node object until ids exist, because the walk emits
2889
+ // them before the tree is numbered.
2890
+ for (const diag of st.diagnostics) {
2891
+ if (diag._node) {
2892
+ diag.node = diag._node.id
2893
+ delete diag._node
2894
+ }
2895
+ }
2896
+ if (st.suppressed.size) {
2897
+ st.diagnostics.push({
2898
+ code: 'B4.diagnostics-suppressed',
2899
+ severity: 'warn',
2900
+ counts: Object.fromEntries(st.suppressed),
2901
+ note: `more than ${st.maxPerCode} occurrences of these codes; the rest are counted, not listed`,
2902
+ })
2903
+ }
2904
+
2905
+ return { nodes: st.nodes, rootId, diagnostics: st.diagnostics }
2906
+ }
2907
+
2908
+ export default buildPaintTree