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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +54 -20
  3. package/agent-map.d.ts +32 -0
  4. package/agent-map.js +5 -3
  5. package/ascii-export.d.ts +10 -0
  6. package/ascii-export.js +21 -6
  7. package/capture-frames.js +5 -5
  8. package/color-tint.js +4 -0
  9. package/context-export.d.ts +23 -0
  10. package/context-export.js +4 -1
  11. package/filter.js +1 -1
  12. package/gif-export.d.ts +6 -0
  13. package/gif-export.js +3 -3
  14. package/html-export.d.ts +8 -0
  15. package/html-export.js +6 -4
  16. package/index.d.ts +2 -1
  17. package/index.js +2 -1
  18. package/package.json +17 -6
  19. package/pdf/LICENSE +21 -0
  20. package/pdf/README.md +22 -0
  21. package/pdf/REFERENCE.md +520 -0
  22. package/pdf/src/frontmatter.js +200 -0
  23. package/pdf/src/furniture.js +398 -0
  24. package/pdf/src/index.js +2415 -0
  25. package/pdf/src/mega/README.md +47 -0
  26. package/pdf/src/mega/diag.js +148 -0
  27. package/pdf/src/mega/flow.js +56 -0
  28. package/pdf/src/mega/fonts.js +287 -0
  29. package/pdf/src/mega/forms.js +479 -0
  30. package/pdf/src/mega/labels.js +43 -0
  31. package/pdf/src/mega/meta.js +145 -0
  32. package/pdf/src/nav.js +357 -0
  33. package/pdf/src/paginate.js +583 -0
  34. package/pdf/src/tagged.js +355 -0
  35. package/pdf/src/text-layer.js +1771 -0
  36. package/pdf/src/writer/encrypt.js +218 -0
  37. package/pdf/src/writer/font.js +507 -0
  38. package/pdf/src/writer/index.js +1135 -0
  39. package/pdf/src/writer/subset.js +387 -0
  40. package/pdf.d.ts +294 -0
  41. package/pdf.js +1 -0
  42. package/redact-inputs.js +6 -6
  43. package/vector/CONTRACT.md +906 -0
  44. package/vector/LICENSE +21 -0
  45. package/vector/README.md +35 -0
  46. package/vector/src/adapters/snapdom.js +795 -0
  47. package/vector/src/collect/box.js +1676 -0
  48. package/vector/src/collect/font.js +487 -0
  49. package/vector/src/collect/image.js +1458 -0
  50. package/vector/src/collect/mathml.js +272 -0
  51. package/vector/src/collect/text.js +3027 -0
  52. package/vector/src/css/color.js +823 -0
  53. package/vector/src/css/gradient.js +1126 -0
  54. package/vector/src/css/matrix.js +398 -0
  55. package/vector/src/css/radius.js +124 -0
  56. package/vector/src/css/shadow.js +201 -0
  57. package/vector/src/emit/figma-json.js +1995 -0
  58. package/vector/src/emit/h2d-vector.js +215 -0
  59. package/vector/src/emit/h2d.js +1147 -0
  60. package/vector/src/emit/pdf.js +2571 -0
  61. package/vector/src/emit/svg-flat.js +4842 -0
  62. package/vector/src/exclusion.js +13 -0
  63. package/vector/src/index.js +2910 -0
  64. package/vector/src/paint.js +2908 -0
  65. package/vector/src/public.js +57 -0
  66. package/vector/src/writer/encrypt.js +227 -0
  67. package/vector/src/writer/font.js +507 -0
  68. package/vector/src/writer/index.js +1148 -0
  69. package/vector/src/writer/subset.js +387 -0
  70. package/vector/svd/index.js +72 -0
  71. package/vector/svd/schema.js +985 -0
  72. package/vector.d.ts +28 -0
  73. package/vector.js +1 -0
  74. package/video-export.d.ts +6 -0
  75. package/video-export.js +7 -7
  76. package/pdf-image.d.ts +0 -18
  77. package/pdf-image.js +0 -114
@@ -0,0 +1,1676 @@
1
+ /**
2
+ * Box paint: everything an element paints that is neither text nor replaced
3
+ * content — backgrounds, borders, outline, shadows, filters, the overflow clip
4
+ * and the two compositing properties.
5
+ *
6
+ * Two conventions govern the whole file:
7
+ *
8
+ * 1. Geometry is element-local CSS px with the origin at the top-left of the
9
+ * BORDER box, so it composes with the node's `frame` untouched.
10
+ * 2. CSS paints the FIRST background layer on TOP and `fills` runs
11
+ * back-to-front, so the layer list comes out REVERSED, with
12
+ * `background-color` — which is behind every layer — at index 0.
13
+ *
14
+ * Everything this module cannot express exactly leaves a `warnings` entry. The
15
+ * two structural ones are addressed to the caller rather than to the user:
16
+ * per-side border colors and mixed `background-clip` boxes both mean "this is
17
+ * more than one node", and only the caller can split it.
18
+ */
19
+ import { parseColor, isTransparent } from '../css/color.js'
20
+ import { parseGradient } from '../css/gradient.js'
21
+ import { parseShadows } from '../css/shadow.js'
22
+ import { parseRadii } from '../css/radius.js'
23
+
24
+ const GRADIENT = /^(?:-webkit-|-moz-|-o-)?(?:repeating-)?(?:linear|radial|conic)-gradient\s*\(/i
25
+ const IMAGE_SET = /^(?:-webkit-)?image-set\s*\(/i
26
+ /** One nesting level is enough: `drop-shadow(0 1px 2px rgb(0 0 0 / .3))`. */
27
+ const FN = /([a-zA-Z-]+)\(((?:[^()]|\([^()]*\))*)\)/g
28
+ /**
29
+ * The filter functions that resample colour. CSS Filter Effects §8 defines each
30
+ * of them AS a colour matrix (`invert`, `brightness` and `contrast` as component
31
+ * transfers that are affine per channel, which is the same thing), so they are
32
+ * not approximated here: they are composed, exactly, into the matrices
33
+ * `colorMatrixChain` builds — usually one for the whole chain.
34
+ *
35
+ * `opacity` is left out of this set because the `filter` branch folds it into
36
+ * layer opacity, which is exact and cheaper; it is in `COLOR_MATRIX_FILTERS`,
37
+ * which is what the `backdrop-filter` branch uses — there the alpha belongs to
38
+ * the backdrop and folding it into the node's own opacity would fade the node.
39
+ */
40
+ const COLOR_FILTERS = new Set([
41
+ 'brightness', 'contrast', 'grayscale', 'hue-rotate', 'invert', 'saturate', 'sepia',
42
+ ])
43
+ const COLOR_MATRIX_FILTERS = new Set([...COLOR_FILTERS, 'opacity'])
44
+
45
+ /**
46
+ * Native controls the UA paints by itself. `<button>` is deliberately absent: its
47
+ * label is a real text node the collectors read, and its box is ordinary CSS.
48
+ */
49
+ const CONTROL_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT', 'PROGRESS', 'METER'])
50
+
51
+ /** `<input>` types whose painted content is the value (or the placeholder) as text. */
52
+ const TEXTUAL_INPUTS = new Set([
53
+ 'text', 'email', 'search', 'tel', 'url', 'password', 'number',
54
+ 'date', 'datetime-local', 'month', 'week', 'time',
55
+ ])
56
+
57
+ const BOX_KEYWORD = {
58
+ 'border-box': 'border',
59
+ 'padding-box': 'padding',
60
+ 'content-box': 'content',
61
+ text: 'text',
62
+ }
63
+
64
+ const px = (v) => {
65
+ const n = parseFloat(v)
66
+ return Number.isFinite(n) ? n : 0
67
+ }
68
+
69
+ const clamp01 = (n) => (Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 1)
70
+
71
+ /**
72
+ * Split a computed value at top level, on commas (layer lists) or on whitespace
73
+ * (component lists). Depth-aware so `calc(100% - 10px)` and `rgb(0 0 0 / .5)`
74
+ * survive intact.
75
+ */
76
+ function splitTop (value, comma = true) {
77
+ const out = []
78
+ let depth = 0
79
+ let quote = ''
80
+ let buf = ''
81
+ for (const c of String(value)) {
82
+ if (quote) {
83
+ buf += c
84
+ if (c === quote) quote = ''
85
+ continue
86
+ }
87
+ if (c === '"' || c === "'") { quote = c; buf += c; continue }
88
+ if (c === '(') depth++
89
+ else if (c === ')') depth--
90
+ if (depth === 0 && (comma ? c === ',' : /\s/.test(c))) {
91
+ if (comma || buf.trim()) out.push(buf.trim())
92
+ buf = ''
93
+ continue
94
+ }
95
+ buf += c
96
+ }
97
+ if (comma || buf.trim()) out.push(buf.trim())
98
+ return out
99
+ }
100
+
101
+ /**
102
+ * Background component lists are shorter than the layer list whenever the author
103
+ * wrote fewer values; CSS repeats them cyclically.
104
+ */
105
+ const at = (list, i) => list[i % list.length]
106
+
107
+ /**
108
+ * A `<length-percentage>` as `{len, pct}`, kept unresolved because a percentage
109
+ * in `background-position` resolves against `area - image`, which is not known
110
+ * until the image is.
111
+ *
112
+ * Chromium serialises the 3- and 4-value position forms as `calc(100% - 10px)`,
113
+ * so the simple calc sums have to be understood here. No `new Function`: the
114
+ * grammar accepted is a whitespace-separated sum of `<number><px|%>` terms,
115
+ * which is exactly what those serialisations contain.
116
+ */
117
+ function parseLenPct (value, warnings, what) {
118
+ let expr = String(value).trim()
119
+ const calc = /^(?:-webkit-)?calc\((.*)\)$/is.exec(expr)
120
+ if (calc) expr = calc[1].trim()
121
+ // `a - b` becomes `a + -b`; in calc the operators are always space-delimited,
122
+ // so this cannot touch the sign of a term.
123
+ const terms = expr.replace(/\s+-\s+/g, ' + -').split(/\s+\+\s+/)
124
+ let len = 0
125
+ let pct = 0
126
+ for (const term of terms) {
127
+ const m = /^([+-]?(?:\d*\.)?\d+(?:e[+-]?\d+)?)(px|%)?$/i.exec(term.trim())
128
+ if (!m) {
129
+ warnings.push(`${what}: could not read "${value}"; treated as 0.`)
130
+ return { len: 0, pct: 0 }
131
+ }
132
+ const n = parseFloat(m[1])
133
+ if (m[2] === '%') pct += n / 100
134
+ else len += n
135
+ }
136
+ return { len, pct }
137
+ }
138
+
139
+ /** `background-size` for one layer, left unresolved (`auto` needs the intrinsic size). */
140
+ function parseBgSize (value, warnings) {
141
+ const v = String(value).trim().toLowerCase()
142
+ if (v === 'cover' || v === 'contain') return { kind: v }
143
+ const parts = splitTop(value, false)
144
+ const axis = (token) => (
145
+ token === undefined || token.toLowerCase() === 'auto'
146
+ ? 'auto'
147
+ : parseLenPct(token, warnings, 'background-size')
148
+ )
149
+ return { kind: 'explicit', x: axis(parts[0]), y: axis(parts[1]) }
150
+ }
151
+
152
+ /** `background-position` for one layer: two `{len, pct}` pairs. */
153
+ function parseBgPosition (value, warnings) {
154
+ const parts = splitTop(value, false)
155
+ const named = { left: '0%', top: '0%', center: '50%', right: '100%', bottom: '100%' }
156
+ const axis = (token, fallback) => {
157
+ if (token === undefined) return parseLenPct(fallback, warnings, 'background-position')
158
+ const key = token.toLowerCase()
159
+ return parseLenPct(named[key] !== undefined ? named[key] : token, warnings, 'background-position')
160
+ }
161
+ if (parts.length > 2) {
162
+ // The 3/4-value form only reaches here if the engine did not fold it into
163
+ // calc(); the edge keyword is then lost rather than mis-applied.
164
+ warnings.push(`background-position: the multi-value form "${value}" is approximated by its first two components.`)
165
+ }
166
+ return { x: axis(parts[0], '0%'), y: axis(parts[1], parts.length === 1 ? '50%' : '0%') }
167
+ }
168
+
169
+ /** `background-repeat` for one layer, normalised to per-axis values. */
170
+ function parseBgRepeat (value, warnings) {
171
+ const parts = splitTop(value, false).map((s) => s.toLowerCase())
172
+ let x = parts[0] || 'repeat'
173
+ let y = parts[1] || parts[0] || 'repeat'
174
+ if (x === 'repeat-x') { x = 'repeat'; y = 'no-repeat' }
175
+ else if (x === 'repeat-y') { x = 'no-repeat'; y = 'repeat' }
176
+ for (const v of [x, y]) {
177
+ if (v === 'round' || v === 'space') {
178
+ warnings.push(`background-repeat: "${v}" is approximated by "repeat"; the tile is neither rescaled nor gapped.`)
179
+ }
180
+ }
181
+ const norm = (v) => (v === 'round' || v === 'space' ? 'repeat' : v)
182
+ return { x: norm(x), y: norm(y) }
183
+ }
184
+
185
+ /**
186
+ * The functions of a `filter` / `backdrop-filter` chain, in source order, each
187
+ * with the text that produced it.
188
+ *
189
+ * The name alone is not a diagnostic. "filter resamples color (saturate)" does
190
+ * not tell a reader whether the panel lost a 5% tint or a 180% one, and the
191
+ * glassmorphism fixture loses exactly `saturate(1.8)` — the number is the whole
192
+ * message.
193
+ *
194
+ * @param {string} css
195
+ * @returns {Array<{name:string, arg:string, css:string}>}
196
+ */
197
+ function readFilterFunctions (css) {
198
+ const out = []
199
+ FN.lastIndex = 0
200
+ let m
201
+ while ((m = FN.exec(css)) !== null) {
202
+ const arg = m[2].trim()
203
+ out.push({ name: m[1].toLowerCase(), arg, css: `${m[1]}(${arg})` })
204
+ }
205
+ return out
206
+ }
207
+
208
+ /** The first of the two spellings that is set, or `'none'`. */
209
+ function pickFilter (standard, prefixed) {
210
+ if (standard && standard !== 'none') return standard
211
+ if (prefixed && prefixed !== 'none') return prefixed
212
+ return 'none'
213
+ }
214
+
215
+ const listOf = (fns) => fns.map((f) => f.css).join(', ')
216
+
217
+ // ——— colour matrices (CSS Filter Effects §8) ———
218
+ //
219
+ // Each colour filter function has its matrix written out in the spec, so a chain
220
+ // of them is a product of matrices and nothing is lost by composing it. The
221
+ // arithmetic runs in the 5x5 homogeneous form — four rows of coefficients plus
222
+ // the constant column, and a fifth row `[0,0,0,0,1]` that only exists so the
223
+ // product is defined — and the last row is dropped on the way out, because
224
+ // `feColorMatrix type="matrix"` wants the 4x5.
225
+ //
226
+ // The matrices act on NON-PREMULTIPLIED sRGB in 0..1, which is what §8 assumes
227
+ // and what `feColorMatrix` does with `color-interpolation-filters="sRGB"`. The
228
+ // unit matters: the same numbers applied in linearRGB are a different colour.
229
+
230
+ const IDENTITY5 = [
231
+ [1, 0, 0, 0, 0],
232
+ [0, 1, 0, 0, 0],
233
+ [0, 0, 1, 0, 0],
234
+ [0, 0, 0, 1, 0],
235
+ [0, 0, 0, 0, 1],
236
+ ]
237
+
238
+ /** `after` applied to the result of `before`: the product `after · before`. */
239
+ function mul5 (after, before) {
240
+ const out = []
241
+ for (let i = 0; i < 5; i++) {
242
+ const row = []
243
+ for (let j = 0; j < 5; j++) {
244
+ let sum = 0
245
+ for (let k = 0; k < 5; k++) sum += after[i][k] * before[k][j]
246
+ row.push(sum)
247
+ }
248
+ out.push(row)
249
+ }
250
+ return out
251
+ }
252
+
253
+ /**
254
+ * Coefficients are quantised to 1e-6 so a composed chain does not ship
255
+ * `0.21299999999999997`. One part in a million of a channel is 1/4000 of an
256
+ * 8-bit level: below anything representable, let alone visible.
257
+ */
258
+ const q6 = (n) => Math.round(n * 1e6) / 1e6
259
+
260
+ /**
261
+ * The `<number-percentage>` argument of a colour filter, clamped to the range
262
+ * §8 gives that function, with the spec's default when the argument is omitted
263
+ * (`grayscale()` means `grayscale(1)`).
264
+ *
265
+ * Null when the argument is not a number at all. A matrix built on a misread
266
+ * argument is worse than no matrix: it would be wrong content, declared exact.
267
+ */
268
+ function filterAmount (arg, def, min, max) {
269
+ const s = String(arg).trim()
270
+ if (!s) return def
271
+ const m = /^([+-]?(?:\d*\.)?\d+(?:e[+-]?\d+)?)(%?)$/i.exec(s)
272
+ if (!m) return null
273
+ let n = parseFloat(m[1])
274
+ if (m[2]) n /= 100
275
+ if (!Number.isFinite(n)) return null
276
+ return Math.min(max, Math.max(min, n))
277
+ }
278
+
279
+ const ANGLE_TO_DEG = { deg: 1, grad: 0.9, rad: 180 / Math.PI, turn: 360 }
280
+
281
+ /** The `<angle>` argument of `hue-rotate`, in degrees. Null when unreadable. */
282
+ function filterAngle (arg) {
283
+ const s = String(arg).trim()
284
+ if (!s) return 0
285
+ const m = /^([+-]?(?:\d*\.)?\d+(?:e[+-]?\d+)?)(deg|grad|rad|turn)?$/i.exec(s)
286
+ if (!m) return null
287
+ const n = parseFloat(m[1])
288
+ if (!Number.isFinite(n)) return null
289
+ // A unitless argument is only legal as zero, and zero is zero in every unit.
290
+ return n * (m[2] ? ANGLE_TO_DEG[m[2].toLowerCase()] : 1)
291
+ }
292
+
293
+ /** `feColorMatrix type="saturate"`, §8.5. `grayscale(a)` is this at `1 - a`. */
294
+ function saturateMatrix (s) {
295
+ return [
296
+ [0.213 + 0.787 * s, 0.715 - 0.715 * s, 0.072 - 0.072 * s, 0, 0],
297
+ [0.213 - 0.213 * s, 0.715 + 0.285 * s, 0.072 - 0.072 * s, 0, 0],
298
+ [0.213 - 0.213 * s, 0.715 - 0.715 * s, 0.072 + 0.928 * s, 0, 0],
299
+ [0, 0, 0, 1, 0],
300
+ [0, 0, 0, 0, 1],
301
+ ]
302
+ }
303
+
304
+ /** `feColorMatrix type="hueRotate"`, §8.6. */
305
+ function hueRotateMatrix (deg) {
306
+ const c = Math.cos((deg * Math.PI) / 180)
307
+ const s = Math.sin((deg * Math.PI) / 180)
308
+ return [
309
+ [0.213 + c * 0.787 - s * 0.213, 0.715 - c * 0.715 - s * 0.715, 0.072 - c * 0.072 + s * 0.928, 0, 0],
310
+ [0.213 - c * 0.213 + s * 0.143, 0.715 + c * 0.285 + s * 0.140, 0.072 - c * 0.072 - s * 0.283, 0, 0],
311
+ [0.213 - c * 0.213 - s * 0.787, 0.715 - c * 0.715 + s * 0.715, 0.072 + c * 0.928 + s * 0.072, 0, 0],
312
+ [0, 0, 0, 1, 0],
313
+ [0, 0, 0, 0, 1],
314
+ ]
315
+ }
316
+
317
+ /** `sepia(amount)`, §8.4. `t` is how much of the original survives. */
318
+ function sepiaMatrix (a) {
319
+ const t = 1 - a
320
+ return [
321
+ [0.393 + 0.607 * t, 0.769 - 0.769 * t, 0.189 - 0.189 * t, 0, 0],
322
+ [0.349 - 0.349 * t, 0.686 + 0.314 * t, 0.168 - 0.168 * t, 0, 0],
323
+ [0.272 - 0.272 * t, 0.534 - 0.534 * t, 0.131 + 0.869 * t, 0, 0],
324
+ [0, 0, 0, 1, 0],
325
+ [0, 0, 0, 0, 1],
326
+ ]
327
+ }
328
+
329
+ /**
330
+ * The same affine ramp on each of R, G and B, alpha untouched. This is what the
331
+ * spec's `feComponentTransfer type="linear"` comes to for `invert`, `brightness`
332
+ * and `contrast`, all three of which are `slope * c + intercept`.
333
+ */
334
+ function channelMatrix (slope, intercept) {
335
+ return [
336
+ [slope, 0, 0, 0, intercept],
337
+ [0, slope, 0, 0, intercept],
338
+ [0, 0, slope, 0, intercept],
339
+ [0, 0, 0, 1, 0],
340
+ [0, 0, 0, 0, 1],
341
+ ]
342
+ }
343
+
344
+ /** `opacity(amount)`, §8.8: alpha alone, colours untouched. */
345
+ function alphaMatrix (o) {
346
+ return [
347
+ [1, 0, 0, 0, 0],
348
+ [0, 1, 0, 0, 0],
349
+ [0, 0, 1, 0, 0],
350
+ [0, 0, 0, o, 0],
351
+ [0, 0, 0, 0, 1],
352
+ ]
353
+ }
354
+
355
+ /**
356
+ * The 5x5 for ONE colour-filter function, plus whether that function can only
357
+ * produce values inside 0..1 when its input is inside 0..1. Null when the
358
+ * argument cannot be read or the function is not one of §8's colour filters.
359
+ *
360
+ * `closed` is what decides how far a chain may be composed, and it is not a
361
+ * detail — see `colorMatrixChain`. `grayscale` and `saturate` at or below 1 have
362
+ * non-negative rows that sum to exactly 1, so they are convex mixes of the
363
+ * channels; `invert`, and `brightness`/`contrast` at or below 1, are monotone
364
+ * affine ramps whose endpoints land inside the range. Everything else can leave
365
+ * it: `saturate(2)` overshoots, `sepia` sums to more than 1, and `hue-rotate`
366
+ * has negative coefficients at every angle that is not a whole turn.
367
+ *
368
+ * @param {{name:string, arg:string}} fn
369
+ * @returns {{m:number[][], closed:boolean}|null}
370
+ */
371
+ function colorFilterMatrix (fn) {
372
+ const { name, arg } = fn
373
+ if (name === 'saturate') {
374
+ const s = filterAmount(arg, 1, 0, Infinity)
375
+ return s === null ? null : { m: saturateMatrix(s), closed: s <= 1 }
376
+ }
377
+ if (name === 'grayscale') {
378
+ const a = filterAmount(arg, 1, 0, 1)
379
+ return a === null ? null : { m: saturateMatrix(1 - a), closed: true }
380
+ }
381
+ if (name === 'sepia') {
382
+ // Not range-preserving, though it looks it: the first row of §8.4's matrix
383
+ // sums to 1.351, so white comes out at 1.351 and the primitive clamps it.
384
+ // Only `sepia(0)`, which is the identity, is closed.
385
+ const a = filterAmount(arg, 1, 0, 1)
386
+ return a === null ? null : { m: sepiaMatrix(a), closed: a === 0 }
387
+ }
388
+ if (name === 'hue-rotate') {
389
+ const d = filterAngle(arg)
390
+ return d === null ? null : { m: hueRotateMatrix(d), closed: ((d % 360) + 360) % 360 === 0 }
391
+ }
392
+ if (name === 'invert') {
393
+ // tableValues="a 1-a" is the ramp `c -> a + (1 - 2a)c`.
394
+ const a = filterAmount(arg, 1, 0, 1)
395
+ return a === null ? null : { m: channelMatrix(1 - 2 * a, a), closed: true }
396
+ }
397
+ if (name === 'brightness') {
398
+ const b = filterAmount(arg, 1, 0, Infinity)
399
+ return b === null ? null : { m: channelMatrix(b, 0), closed: b <= 1 }
400
+ }
401
+ if (name === 'contrast') {
402
+ const c = filterAmount(arg, 1, 0, Infinity)
403
+ return c === null ? null : { m: channelMatrix(c, 0.5 - 0.5 * c), closed: c <= 1 }
404
+ }
405
+ if (name === 'opacity') {
406
+ const o = filterAmount(arg, 1, 0, 1)
407
+ return o === null ? null : { m: alphaMatrix(o), closed: true }
408
+ }
409
+ return null
410
+ }
411
+
412
+ const matrixCount = (steps) => (steps.length === 1 ? 'one colour matrix' : `${steps.length} colour matrices`)
413
+
414
+ /**
415
+ * Why a chain came out as more than one matrix. A reader who sees four
416
+ * primitives where the CSS had four functions should not have to guess whether
417
+ * the engine failed to compose them or refused to.
418
+ */
419
+ const matrixSplit = (steps) => (steps.length < 2
420
+ ? ''
421
+ : 'They stay separate because a filter chain clamps to 0..1 between functions, and composing ' +
422
+ 'across a function that leaves that range would move colours by up to 29/255 (measured against ' +
423
+ 'Chromium); each of these is exact on its own. ')
424
+
425
+ /**
426
+ * A run of colour-filter functions, composed into as few matrices as can be
427
+ * composed EXACTLY. CSS applies a chain left to right, so the last function is
428
+ * the outermost and each product runs right to left — `Mn · … · M1`.
429
+ *
430
+ * **A chain does not compose into one matrix in general, and this was measured,
431
+ * not reasoned about.** A filter chain clamps to 0..1 between functions — each
432
+ * function is a filter primitive and a primitive's result is clamped — so
433
+ * whenever a function can push a channel out of range, the next one sees the
434
+ * CLAMPED value and the product of the two matrices does not. Rendering the
435
+ * composed matrix as `feColorMatrix` against Chromium's own chain, 141 of 147
436
+ * colour × chain pairs matched to the byte and 6 did not, by up to **29/255**:
437
+ * `hue-rotate(45deg) saturate(2) invert(0.1)` on pure red came out 235,49,0
438
+ * instead of 227,41,26. Those six are exactly the ones with a clamping function
439
+ * in a non-final position.
440
+ *
441
+ * So a run is closed after any function that can leave the range (`closed:
442
+ * false` above), and the next matrix starts from the clamped value the way CSS
443
+ * does. Everything else composes: the common chain — one colour function, or
444
+ * several convex ones — still comes out as a single exact matrix, which is the
445
+ * whole gain. Splitting costs one more effect in the chain and buys back the
446
+ * 29/255.
447
+ *
448
+ * @param {Array<{name:string, arg:string, css:string}>} fns colour filters only
449
+ * @returns {{steps:Array<{values:number[], srcCss:string, used:Array}>,
450
+ * used:Array, unreadable:Array}}
451
+ */
452
+ function colorMatrixChain (fns) {
453
+ const steps = []
454
+ const used = []
455
+ const unreadable = []
456
+ let m = IDENTITY5
457
+ let run = []
458
+ const close = () => {
459
+ if (!run.length) return
460
+ const values = []
461
+ for (let i = 0; i < 4; i++) for (let j = 0; j < 5; j++) values.push(q6(m[i][j]))
462
+ steps.push({ values, srcCss: run.map((f) => f.css).join(' '), used: run })
463
+ m = IDENTITY5
464
+ run = []
465
+ }
466
+ for (const fn of fns) {
467
+ const step = colorFilterMatrix(fn)
468
+ if (!step) {
469
+ unreadable.push(fn)
470
+ continue
471
+ }
472
+ m = mul5(step.m, m)
473
+ run.push(fn)
474
+ used.push(fn)
475
+ if (!step.closed) close()
476
+ }
477
+ close()
478
+ return { steps, used, unreadable }
479
+ }
480
+
481
+ /** The single URL inside a `url()` or the first candidate of an `image-set()`. */
482
+ function readUrl (layer) {
483
+ const m = /url\(\s*(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|([^)\s]*))\s*\)/.exec(layer)
484
+ if (!m) return null
485
+ const raw = m[1] !== undefined ? m[1] : m[2] !== undefined ? m[2] : m[3]
486
+ return raw.replace(/\\(.)/g, '$1')
487
+ }
488
+
489
+ /**
490
+ * CSS Images §5.3 default sizing, reduced to the cases a background can be in.
491
+ * `intrinsic` is null for gradients — no intrinsic size and no intrinsic ratio —
492
+ * which collapses every branch to the positioning area, as the spec requires.
493
+ *
494
+ * @param {object} sizing from `parseBgSize`
495
+ * @param {{w:number,h:number}} area
496
+ * @param {{w:number,h:number}|null} intrinsic
497
+ * @returns {{w:number,h:number}}
498
+ */
499
+ function computeSize (sizing, area, intrinsic) {
500
+ const has = !!(intrinsic && intrinsic.w > 0 && intrinsic.h > 0)
501
+ if (sizing.kind === 'cover' || sizing.kind === 'contain') {
502
+ if (!has) return { w: area.w, h: area.h }
503
+ const sx = area.w / intrinsic.w
504
+ const sy = area.h / intrinsic.h
505
+ const s = sizing.kind === 'cover' ? Math.max(sx, sy) : Math.min(sx, sy)
506
+ return { w: intrinsic.w * s, h: intrinsic.h * s }
507
+ }
508
+ const ratio = has ? intrinsic.w / intrinsic.h : 0
509
+ const x = sizing.x === 'auto' ? null : sizing.x.len + sizing.x.pct * area.w
510
+ const y = sizing.y === 'auto' ? null : sizing.y.len + sizing.y.pct * area.h
511
+ if (x !== null && y !== null) return { w: x, h: y }
512
+ if (x !== null) return { w: x, h: ratio ? x / ratio : area.h }
513
+ if (y !== null) return { w: ratio ? y * ratio : area.w, h: y }
514
+ if (has) return { w: intrinsic.w, h: intrinsic.h }
515
+ return { w: area.w, h: area.h }
516
+ }
517
+
518
+ /**
519
+ * Where the first tile of a background layer lands, in element-local px.
520
+ *
521
+ * Exported because a `url()` layer cannot be placed until its asset has loaded:
522
+ * `collectBox` emits the placement with `rect: null` and the orchestrator calls
523
+ * this once it knows the intrinsic size, instead of re-deriving the arithmetic.
524
+ * The percentage rule is the one everybody gets wrong — `position: X%` puts X%
525
+ * of the IMAGE over X% of the AREA, i.e. `(area - image) * X`, not `area * X`.
526
+ *
527
+ * @param {object} placement the `placement` of an image or gradient fill
528
+ * @param {{w:number,h:number}|null} [intrinsic]
529
+ * @returns {{x:number,y:number,w:number,h:number}}
530
+ */
531
+ export function resolveLayerRect (placement, intrinsic = null) {
532
+ const { area, sizing, position } = placement
533
+ const size = computeSize(sizing, area, intrinsic)
534
+ return {
535
+ x: area.x + position.x.len + position.x.pct * (area.w - size.w),
536
+ y: area.y + position.y.len + position.y.pct * (area.h - size.h),
537
+ w: size.w,
538
+ h: size.h,
539
+ }
540
+ }
541
+
542
+ /** The border box in element-local px, preferring measurements a transform cannot skew. */
543
+ function borderBoxSize (el, cs, warnings) {
544
+ const bw = px(cs.borderLeftWidth) + px(cs.borderRightWidth)
545
+ const bh = px(cs.borderTopWidth) + px(cs.borderBottomWidth)
546
+ const pw = px(cs.paddingLeft) + px(cs.paddingRight)
547
+ const ph = px(cs.paddingTop) + px(cs.paddingBottom)
548
+ // The resolved value of `width` is fractional and transform-free — the best
549
+ // source there is. It is `auto` only for inline boxes and detached /
550
+ // `display:none` subtrees.
551
+ //
552
+ // What it MEASURES, though, follows `box-sizing`: Chromium (and every engine
553
+ // that resolves `width` against the used box) reports the CONTENT width under
554
+ // `content-box` and the BORDER width under `border-box`. Adding the padding
555
+ // and borders unconditionally inflated every `box-sizing:border-box` element
556
+ // by exactly its own border+padding — and since `*{box-sizing:border-box}` is
557
+ // the modern default, that was most of them. The damage is invisible on a flat
558
+ // fill (the backend paints the node's own frame) and glaring on anything
559
+ // positioned against the box: `.pcard-ring` (96px border-box, 10px border) got
560
+ // a 116px border box, so its conic's padding-box positioning area came out
561
+ // 96×96 at (10,10) instead of 76×76, moving the sweep's centre 10px down-right
562
+ // of where the browser paints it. Percentage radii were scaled by the same
563
+ // error.
564
+ if (/px$/.test(cs.width) && /px$/.test(cs.height)) {
565
+ const content = cs.boxSizing !== 'border-box'
566
+ return {
567
+ w: parseFloat(cs.width) + (content ? pw + bw : 0),
568
+ h: parseFloat(cs.height) + (content ? ph + bh : 0),
569
+ }
570
+ }
571
+ const transformed = cs.transform !== 'none' ||
572
+ (cs.rotate || 'none') !== 'none' || (cs.scale || 'none') !== 'none'
573
+ if (!transformed && typeof el.getBoundingClientRect === 'function') {
574
+ const r = el.getBoundingClientRect()
575
+ if (r.width || r.height) return { w: r.width, h: r.height }
576
+ }
577
+ if (el.offsetWidth || el.offsetHeight) {
578
+ warnings.push('box size read from offsetWidth/offsetHeight; it is rounded to whole px.')
579
+ return { w: el.offsetWidth, h: el.offsetHeight }
580
+ }
581
+ const r = typeof el.getBoundingClientRect === 'function'
582
+ ? el.getBoundingClientRect()
583
+ : { width: 0, height: 0 }
584
+ warnings.push('box size could not be measured without the element transform; the painted box may be scaled.')
585
+ return { w: r.width, h: r.height }
586
+ }
587
+
588
+ /** Radii of a box inset from the border box by `[t, r, b, l]`, per CSS Backgrounds §5.4. */
589
+ function insetRadii (radii, [t, r, b, l]) {
590
+ const [tl, tr, br, bl] = radii
591
+ return [
592
+ [Math.max(0, tl[0] - l), Math.max(0, tl[1] - t)],
593
+ [Math.max(0, tr[0] - r), Math.max(0, tr[1] - t)],
594
+ [Math.max(0, br[0] - r), Math.max(0, br[1] - b)],
595
+ [Math.max(0, bl[0] - l), Math.max(0, bl[1] - b)],
596
+ ]
597
+ }
598
+
599
+ /** An outline follows the border radius grown by its offset, but a square corner stays square. */
600
+ function expandRadii (radii, d) {
601
+ return radii.map(([rx, ry]) => [rx > 0 ? Math.max(0, rx + d) : 0, ry > 0 ? Math.max(0, ry + d) : 0])
602
+ }
603
+
604
+ /**
605
+ * @typedef {object} CollectCtx built by `../index.js`; nothing here is required.
606
+ * @property {'design'|'replica'} [mode]
607
+ */
608
+
609
+ /**
610
+ * @param {Element} el
611
+ * @param {CSSStyleDeclaration} cs already-computed style for `el`
612
+ * @param {CollectCtx} ctx
613
+ * @returns {{fills:Paint[], strokes:Stroke[], effects:Effect[], textEffects:Effect[],
614
+ * clip:Clip|null, mask:object|null, radii, opacity:number, blend:string,
615
+ * warnings:Array<string|{code:string,grade:string,severity:string,message:string}>}}
616
+ * `textEffects`: `text-shadow` paints the glyphs of this block, so it cannot
617
+ * ride in `effects` (which filters the box) and the caller must hand it to the
618
+ * block's text node. `mask`: the collected `mask-image` layers, ready for a
619
+ * caller to turn into the mask node `node.mask` references. Both are inert
620
+ * until such a caller exists, and both say so in `warnings`.
621
+ */
622
+ export function collectBox (el, cs, ctx = {}) {
623
+ const warnings = []
624
+
625
+ const { w, h } = borderBoxSize(el, cs, warnings)
626
+ const border = [
627
+ px(cs.borderTopWidth), px(cs.borderRightWidth),
628
+ px(cs.borderBottomWidth), px(cs.borderLeftWidth),
629
+ ]
630
+ const padding = [
631
+ px(cs.paddingTop), px(cs.paddingRight),
632
+ px(cs.paddingBottom), px(cs.paddingLeft),
633
+ ]
634
+ const boxes = {
635
+ border: { x: 0, y: 0, w, h },
636
+ padding: {
637
+ x: border[3], y: border[0],
638
+ w: Math.max(0, w - border[1] - border[3]),
639
+ h: Math.max(0, h - border[0] - border[2]),
640
+ },
641
+ content: {
642
+ x: border[3] + padding[3], y: border[0] + padding[0],
643
+ w: Math.max(0, w - border[1] - border[3] - padding[1] - padding[3]),
644
+ h: Math.max(0, h - border[0] - border[2] - padding[0] - padding[2]),
645
+ },
646
+ }
647
+ // `text` has no box of its own; the layer is placed on the border box and the
648
+ // caller is told it still owes a text mask.
649
+ boxes.text = boxes.border
650
+
651
+ const radii = parseRadii(cs, w, h)
652
+
653
+ if (/^inline($|-)/.test(cs.display) && cs.display !== 'inline-block' &&
654
+ typeof el.getClientRects === 'function' && el.getClientRects().length > 1) {
655
+ warnings.push('inline box spans several line fragments; its paint is collapsed onto one rectangle.')
656
+ }
657
+
658
+ // `visibility` is inherited but revocable, so the node has to survive — a
659
+ // `visibility:visible` descendant still paints inside it — while its own paint
660
+ // must not. `collect/text.js` already does this for the glyphs; without the
661
+ // equivalent here a `visibility:hidden` tooltip with a background and an
662
+ // `overflow:hidden` of its own was emitted as a solid bubble painted OVER the
663
+ // page. That is a degradation of inverted polarity: something appears that
664
+ // does not exist, which is worse than losing something that does.
665
+ const painted = (cs.visibility || 'visible') === 'visible'
666
+
667
+ const fills = painted ? collectFills(el, cs, boxes, warnings) : []
668
+ const { strokes, strokeWarnings } = painted
669
+ ? collectStrokes(cs, radii, border, el)
670
+ : { strokes: [], strokeWarnings: [] }
671
+ warnings.push(...strokeWarnings)
672
+ const { effects, filterOpacity } = painted
673
+ ? collectEffects(cs, warnings)
674
+ : { effects: [], filterOpacity: 1 }
675
+ const textEffects = painted ? collectTextShadow(el, cs, warnings) : []
676
+ // The clip still applies: `visibility` hides this box, not its descendants.
677
+ const clip = collectClip(cs, radii, border, warnings)
678
+
679
+ if (!painted) {
680
+ warnings.push(
681
+ `visibility:${cs.visibility} — the page paints nothing here, so this box carries no ` +
682
+ 'background, border, outline or effect; the node stays as the container of descendants ' +
683
+ 'that may set visibility:visible.'
684
+ )
685
+ }
686
+
687
+ let opacity = clamp01(parseFloat(cs.opacity)) * filterOpacity
688
+ if (!Number.isFinite(opacity)) opacity = 1
689
+
690
+ const clipPath = pickFilter(cs.clipPath, cs.webkitClipPath)
691
+ const clipShape = clipPath === 'none' ? null : parseClipPath(clipPath, boxes, radii, warnings)
692
+ if (clipPath !== 'none' && !clipShape) {
693
+ warnings.push({
694
+ code: 'collect.box.clip-path-not-applied',
695
+ grade: 'O',
696
+ severity: 'warn',
697
+ message: `clip-path "${clipPath}" is not a basic shape this collector can resolve (it reads ` +
698
+ 'polygon(), inset(), rect(), circle() and ellipse() against the reference box); the box is ' +
699
+ 'emitted unclipped, at its full rectangle.',
700
+ })
701
+ }
702
+
703
+ // `mask-image` used to leave one line of prose and nothing else, so the box
704
+ // came out rectangular with the same words `clip-path` gets and none of the
705
+ // data. The layers are collected now — they are a background painted into an
706
+ // alpha channel and cost the same to read — and the warning says both that the
707
+ // mask is not applied AND that it is sitting there ready.
708
+ const mask = collectMask(cs, boxes, radii, border, padding, warnings)
709
+ // The layers are returned on `mask` in the shape the schema's mask REFERENCE
710
+ // needs a node for; `index.js` (`wireMasks`) builds that node and files
711
+ // `collect.box.mask-applied` when it does. Only the case this collector cannot
712
+ // hand over is a degradation, and only that case is reported here — a warning
713
+ // that fires when nothing was lost is how a diagnostics table stops being read.
714
+ if (mask && !mask.layers.length) {
715
+ warnings.push({
716
+ code: 'collect.box.mask-not-applied',
717
+ grade: 'O',
718
+ severity: 'warn',
719
+ message: `mask-image "${mask.srcCss}" is not applied and the box is emitted unmasked: none of its ` +
720
+ 'layers could be collected, so there is nothing for a caller to apply either.',
721
+ })
722
+ }
723
+
724
+ const control = painted ? describeControl(el, cs) : null
725
+ if (control && (control.chrome.length || control.content.length)) {
726
+ // Always, and always with the type. What this box carries is the CSS half of a
727
+ // native control — box, background, border, radii — and nothing the UA drew
728
+ // inside it. The other half now travels as SEMANTICS on `node.control`
729
+ // (`paint.js`, `describeWidget`), so whether the reader sees a widget depends on
730
+ // the backend: `emit/svg-flat.js` reconstructs the chrome from it at Chromium's
731
+ // measured proportions (`emit.svg.control-reconstructed`), Figma and PDF ignore
732
+ // it and the control arrives as an empty box. Saying "this engine has no
733
+ // primitive for that" was true until the widgets landed and is now the kind of
734
+ // stale warning that teaches a reader to ignore warnings.
735
+ warnings.push({
736
+ code: 'collect.box.form-control',
737
+ grade: 'O',
738
+ severity: 'warn',
739
+ message: `${control.label} is a native control: the UA paints ` +
740
+ [...control.content, ...control.chrome].join(' and ') +
741
+ ', and no CSS property in the cascade describes any of it. This box carries only the ' +
742
+ 'CSS half — background, border, radii. The state that would let a backend redraw the rest ' +
743
+ '(type, checked/value, accent-color) travels on `node.control`; the SVG backend uses it, ' +
744
+ 'the Figma and PDF backends do not, so there the control is an empty box. The text of the ' +
745
+ 'control — its value, its placeholder, its chosen option — is not redrawn anywhere.',
746
+ })
747
+ }
748
+
749
+ if (painted && cs.borderImageSource && cs.borderImageSource !== 'none') {
750
+ warnings.push('border-image is not represented; only the border color/width fallback is emitted.')
751
+ }
752
+ return {
753
+ fills,
754
+ strokes,
755
+ effects,
756
+ // The glyph shadows of this block, kept apart from `effects` on purpose: an
757
+ // effect on the box node filters the background too. The caller owns the
758
+ // text node and is the only one that can attach them.
759
+ textEffects,
760
+ clip,
761
+ // `clip-path` as resolved geometry in node-local px, or null when the shape
762
+ // is one this collector cannot read (in which case `warnings` says so). It is
763
+ // NOT `clip`: the overflow clip applies to descendants, this one also cuts
764
+ // the element's own background, border and shadow silhouette.
765
+ clipShape,
766
+ // The collected `mask-image`, in the shape a caller needs to build the mask
767
+ // node the SVD references. Inert until one does — like `textEffects`.
768
+ mask,
769
+ radii,
770
+ opacity,
771
+ blend: cs.mixBlendMode || 'normal',
772
+ warnings,
773
+ }
774
+ }
775
+
776
+ /** Whether the element itself carries characters, rather than inheriting a text property. */
777
+ function hasOwnText (el) {
778
+ const kids = el && el.childNodes
779
+ if (!kids || !kids.length) return false
780
+ for (let i = 0; i < kids.length; i++) {
781
+ const n = kids[i]
782
+ if (n.nodeType !== 3) continue
783
+ const value = n.nodeValue || ''
784
+ // NBSP is whitespace to `\s` and a painted glyph to the renderer.
785
+ if (/\S/.test(value) || value.indexOf('\u00a0') !== -1) return true
786
+ }
787
+ return false
788
+ }
789
+
790
+ /**
791
+ * `text-shadow`, which nobody was reading at all: `collect/text.js` never looks
792
+ * at it and both calls in this file were `box-shadow` and `filter`, so a shadowed
793
+ * heading exported with `effects: []` and no diagnostic.
794
+ *
795
+ * It is NOT pushed into `effects`: that array is the box's own, and a drop shadow
796
+ * there follows the background silhouette instead of the letters. It travels on
797
+ * `textEffects` for whoever builds the text node, and until that caller exists the
798
+ * warning below is what keeps the loss visible.
799
+ *
800
+ * `text-shadow` is inherited, so only the element that actually holds characters
801
+ * is asked — otherwise every wrapper in the subtree would report its ancestor's
802
+ * shadow. An element whose text became its own `text` node (no box paint at all)
803
+ * never reaches `collectBox`; that half belongs to `collect/text.js`.
804
+ */
805
+ function collectTextShadow (el, cs, warnings) {
806
+ const css = cs.textShadow
807
+ if (!css || css === 'none') return []
808
+ if (!hasOwnText(el)) return []
809
+
810
+ const { shadows, warnings: shadowWarnings } = parseShadows(css, { inset: false, currentColor: cs.color })
811
+ for (const message of shadowWarnings) warnings.push(`text-shadow: ${message}`)
812
+ if (!shadows.length) {
813
+ warnings.push(`text-shadow "${css}" could not be parsed; it is dropped.`)
814
+ return []
815
+ }
816
+ warnings.push(
817
+ `text-shadow "${css}" paints the glyphs, not this box; it is collected on \`textEffects\` ` +
818
+ 'for the text node, and no backend emits it yet.'
819
+ )
820
+ return shadows.map((s) => ({ ...s, source: 'text-shadow' }))
821
+ }
822
+
823
+ /** A user string quoted into a diagnostic, short enough to read in a table. */
824
+ function quoteValue (value) {
825
+ const s = String(value == null ? '' : value).replace(/\s+/g, ' ').trim()
826
+ return s.length > 48 ? `"${s.slice(0, 47)}…"` : `"${s}"`
827
+ }
828
+
829
+ /**
830
+ * What a native control paints that no CSS declares and no collector here reads.
831
+ *
832
+ * Every branch names the control's TYPE, because "a form control lost its
833
+ * internals" is not actionable and "`<input type="range">` lost its track and
834
+ * thumb at 62 of 0..100" is. The two halves are kept apart on purpose:
835
+ *
836
+ * - `chrome` is the UA widget — the tick, the dot, the chevron, the track. It
837
+ * exists only while `appearance` is not `none`; an author who set
838
+ * `appearance:none` painted the whole thing in CSS and nothing is missing.
839
+ * - `content` is the value, the placeholder, the selected label. The UA paints
840
+ * it whatever `appearance` says, and it is lost either way.
841
+ *
842
+ * @param {Element} el
843
+ * @param {CSSStyleDeclaration} cs
844
+ * @returns {{label:string, chrome:string[], content:string[]}|null}
845
+ */
846
+ function describeControl (el, cs) {
847
+ const tag = (el.tagName || '').toUpperCase()
848
+ if (!CONTROL_TAGS.has(tag)) return null
849
+
850
+ const chrome = []
851
+ const content = []
852
+ const accent = cs.accentColor && cs.accentColor !== 'auto' ? `, accent-color ${cs.accentColor}` : ''
853
+ const value = typeof el.value === 'string' ? el.value : ''
854
+ let label = `<${tag.toLowerCase()}>`
855
+
856
+ if (tag === 'INPUT') {
857
+ // `el.type` normalises an unknown or missing attribute to `text`, which is
858
+ // what the UA actually painted; the attribute is only the fallback.
859
+ const type = String(el.type ||
860
+ (typeof el.getAttribute === 'function' && el.getAttribute('type')) || 'text').toLowerCase()
861
+ label = `<input type="${type}">`
862
+ // `type=image` is replaced content; `collect/image.js` owns it and says so.
863
+ if (type === 'image' || type === 'hidden') return null
864
+ if (type === 'checkbox' || type === 'radio') {
865
+ chrome.push(`the ${type === 'radio' ? 'radio dot' : 'checkmark'} and its ` +
866
+ `${el.checked ? 'CHECKED' : 'unchecked'} box${accent}`)
867
+ } else if (type === 'range') {
868
+ chrome.push(`the track and the thumb (value ${quoteValue(value)} of ` +
869
+ `${el.min || 0}..${el.max || 100}${accent})`)
870
+ } else if (type === 'file') {
871
+ chrome.push('the "Choose file" button and the chosen-file label')
872
+ } else if (type === 'color') {
873
+ content.push(`the colour swatch ${quoteValue(value)}`)
874
+ } else if (type === 'submit' || type === 'reset' || type === 'button') {
875
+ content.push(`the button label ${quoteValue(value || type)}`)
876
+ } else if (TEXTUAL_INPUTS.has(type)) {
877
+ if (value) content.push(`the value ${quoteValue(value)}`)
878
+ else if (el.placeholder) content.push(`the placeholder ${quoteValue(el.placeholder)}`)
879
+ if (type === 'number') chrome.push('the spinner arrows')
880
+ if (type.startsWith('date') || type === 'month' || type === 'week' || type === 'time') {
881
+ chrome.push('the date/time segments and the picker button')
882
+ }
883
+ } else if (value) {
884
+ content.push(`the value ${quoteValue(value)}`)
885
+ }
886
+ } else if (tag === 'TEXTAREA') {
887
+ if (value) content.push(`the value ${quoteValue(value)}`)
888
+ else if (el.placeholder) content.push(`the placeholder ${quoteValue(el.placeholder)}`)
889
+ chrome.push('the resize grip')
890
+ } else if (tag === 'SELECT') {
891
+ const options = el.selectedOptions || []
892
+ const chosen = options.length ? [...options].map((o) => o.label || o.text || o.value).join(', ') : value
893
+ content.push(`the selected option ${quoteValue(chosen)}`)
894
+ chrome.push(el.multiple ? 'the option list' : 'the drop-down chevron')
895
+ } else {
896
+ // <progress> and <meter>: the bar IS the control, and it is all UA paint.
897
+ const max = el.max != null ? el.max : 1
898
+ chrome.push(`the track and the filled bar (value ${quoteValue(value || el.value)} of ` +
899
+ `0..${max}${accent})`)
900
+ }
901
+
902
+ const styled = (cs.appearance || cs.webkitAppearance || 'auto') === 'none'
903
+ return { label, chrome: styled ? [] : chrome, content }
904
+ }
905
+
906
+ /** Background color plus the image layers, back-to-front. */
907
+ function collectFills (el, cs, boxes, warnings) {
908
+ const fills = []
909
+
910
+ const layers = cs.backgroundImage && cs.backgroundImage !== 'none'
911
+ ? splitTop(cs.backgroundImage)
912
+ : []
913
+ const sizes = splitTop(cs.backgroundSize || 'auto')
914
+ const positions = splitTop(cs.backgroundPosition || '0% 0%')
915
+ const repeats = splitTop(cs.backgroundRepeat || 'repeat')
916
+ const origins = splitTop(cs.backgroundOrigin || 'padding-box')
917
+ const clips = splitTop(cs.backgroundClip || cs.webkitBackgroundClip || 'border-box')
918
+ const attachments = splitTop(cs.backgroundAttachment || 'scroll')
919
+ const blends = splitTop(cs.backgroundBlendMode || 'normal')
920
+
921
+ // The background color is painted inside the clip of the LAST layer.
922
+ const colorClip = BOX_KEYWORD[String(at(clips, layers.length ? layers.length - 1 : 0)).toLowerCase()] || 'border'
923
+ const bg = parseColor(cs.backgroundColor)
924
+ if (bg && !isTransparent(bg.rgba)) {
925
+ // `parseColor` measures what it approximated (canvas round-trip, sRGB gamut
926
+ // mapping with its ΔEOK); read here or the number is thrown away.
927
+ for (const message of bg.warnings || []) warnings.push(`background-color: ${message}`)
928
+ fills.push({
929
+ type: 'solid',
930
+ color: bg.rgba,
931
+ srcCss: bg.srcCss,
932
+ blend: 'normal',
933
+ clipBox: colorClip,
934
+ })
935
+ } else if (!bg && cs.backgroundColor && cs.backgroundColor !== 'transparent') {
936
+ warnings.push(`background-color "${cs.backgroundColor}" could not be parsed; the fill is dropped.`)
937
+ }
938
+
939
+ const used = new Set(fills.length ? [colorClip] : [])
940
+
941
+ // Reversed: CSS paints layer 0 on top, `fills` runs back-to-front.
942
+ for (let i = layers.length - 1; i >= 0; i--) {
943
+ const layer = layers[i]
944
+ if (!layer || layer.toLowerCase() === 'none') continue
945
+
946
+ const clipBox = BOX_KEYWORD[String(at(clips, i)).toLowerCase()] || 'border'
947
+ const originBox = BOX_KEYWORD[String(at(origins, i)).toLowerCase()] || 'padding'
948
+ if (clipBox === 'text') {
949
+ // The old wording — "the layer is emitted clipped to the border box" — described
950
+ // the SVD, not what anyone sees. MEASURED 2026-08-07 by pasting this fixture into
951
+ // a generic SVG editor (mediamodifier): a renderer without `<mask>` support paints
952
+ // the gradient rect UNMASKED and the glyphs never appear at all, so the headline
953
+ // arrives as a coloured bar with no text in it. That is not a degraded layer, it
954
+ // is the content gone, and the previous message would have let someone read the
955
+ // panel and still be surprised. Figma does apply the mask, and `textGradientMask:
956
+ // false` trades it for an editable text layer painted in a solid colour.
957
+ warnings.push('background-clip:text needs a glyph mask over the background layer. A target ' +
958
+ 'that supports <mask> (Figma does) shows the gradient in the glyph shapes; one that does ' +
959
+ 'NOT paints the rectangle unmasked and the text DISAPPEARS — measured in a generic SVG ' +
960
+ 'editor. Use `textGradientMask: false` to emit a real text layer in a solid colour instead.')
961
+ }
962
+ used.add(clipBox)
963
+
964
+ const attachment = String(at(attachments, i)).toLowerCase()
965
+ if (attachment === 'fixed') {
966
+ warnings.push('background-attachment:fixed positions the layer against the viewport; it is placed against the element box instead.')
967
+ } else if (attachment === 'local' && (cs.overflowX !== 'visible' || cs.overflowY !== 'visible')) {
968
+ warnings.push('background-attachment:local scrolls with the content; it is placed at the unscrolled origin.')
969
+ }
970
+
971
+ const area = boxes[originBox === 'text' ? 'border' : originBox]
972
+ const placement = {
973
+ area: { ...area },
974
+ sizing: parseBgSize(at(sizes, i), warnings),
975
+ position: parseBgPosition(at(positions, i), warnings),
976
+ repeat: parseBgRepeat(at(repeats, i), warnings),
977
+ }
978
+ const blend = String(at(blends, i)).toLowerCase()
979
+
980
+ if (GRADIENT.test(layer)) {
981
+ // A gradient has neither intrinsic size nor intrinsic ratio, so its tile is
982
+ // fully determined here and `parseGradient` can be given a real box.
983
+ const rect = resolveLayerRect(placement, null)
984
+ if (rect.w <= 0 || rect.h <= 0) continue
985
+ const paint = parseGradient(layer, { w: rect.w, h: rect.h })
986
+ if (!paint) {
987
+ warnings.push(`background-image layer "${layer}" looks like a gradient but could not be parsed; it is dropped.`)
988
+ continue
989
+ }
990
+ for (const note of paint.notes || []) warnings.push(`gradient: ${note}`)
991
+ fills.push({ ...paint, srcCss: layer, blend, clipBox, originBox, placement, rect })
992
+ continue
993
+ }
994
+
995
+ const isSet = IMAGE_SET.test(layer)
996
+ const url = readUrl(layer)
997
+ if (url) {
998
+ if (isSet) {
999
+ warnings.push(`image-set() in "${layer}" is reduced to its first candidate.`)
1000
+ }
1001
+ // The intrinsic size is unknown until the asset loads, so the rect is only
1002
+ // resolvable here when both axes are given explicitly.
1003
+ const resolvable = placement.sizing.kind === 'explicit' &&
1004
+ placement.sizing.x !== 'auto' && placement.sizing.y !== 'auto'
1005
+ fills.push({
1006
+ type: 'image',
1007
+ asset: { pending: true, kind: 'url', url, srcCss: layer },
1008
+ srcCss: layer,
1009
+ blend,
1010
+ clipBox,
1011
+ originBox,
1012
+ placement,
1013
+ rect: resolvable ? resolveLayerRect(placement, null) : null,
1014
+ })
1015
+ continue
1016
+ }
1017
+
1018
+ warnings.push(`background-image layer "${layer}" is not a gradient or a URL; it is dropped.`)
1019
+ }
1020
+
1021
+ if (used.size > 1) {
1022
+ warnings.push(
1023
+ `background layers use more than one clip box (${[...used].join(', ')}); ` +
1024
+ 'they cannot share one node and must be split by the caller.'
1025
+ )
1026
+ }
1027
+
1028
+ return fills
1029
+ }
1030
+
1031
+ /**
1032
+ * `mask-image`, collected the same way a background is.
1033
+ *
1034
+ * A mask is a background painted into an alpha channel: the same layer list, the
1035
+ * same `-size` / `-position` / `-repeat` / `-origin` / `-clip` grammar, resolved
1036
+ * against the same boxes. So the layers come back in `fills` shape and a caller
1037
+ * has everything it needs to synthesize the mask NODE the SVD asks for —
1038
+ * `node.mask = {node, type}` (see `emit/svg-flat.js:emitMask`, which already
1039
+ * reads it and paints the referenced node into a `<mask>`).
1040
+ *
1041
+ * What it does NOT do is create that node: `collectBox` collects the paint of one
1042
+ * element and the orchestrator owns node ids. Until that caller exists the mask
1043
+ * is collected and unused, which is why the warning is graded `O` — the box is
1044
+ * emitted unmasked, exactly as `clip-path` is, and now says so in the same shape.
1045
+ *
1046
+ * @returns {{type:'alpha'|'luminance', mode:string, composite:string, clipBox:string,
1047
+ * box:{x,y,w,h}, radii, layers:Paint[], srcCss:string}|null}
1048
+ */
1049
+ function collectMask (cs, boxes, radii, border, padding, warnings) {
1050
+ const image = pickFilter(cs.maskImage, cs.webkitMaskImage)
1051
+ if (image === 'none') return null
1052
+
1053
+ const layers = splitTop(image)
1054
+ const sizes = splitTop(cs.maskSize || cs.webkitMaskSize || 'auto')
1055
+ const positions = splitTop(cs.maskPosition || cs.webkitMaskPosition || '0% 0%')
1056
+ const repeats = splitTop(cs.maskRepeat || cs.webkitMaskRepeat || 'repeat')
1057
+ const origins = splitTop(cs.maskOrigin || cs.webkitMaskOrigin || 'border-box')
1058
+ const clips = splitTop(cs.maskClip || cs.webkitMaskClip || 'border-box')
1059
+ const composite = String(cs.maskComposite || cs.webkitMaskComposite || 'add').toLowerCase()
1060
+
1061
+ const clipKeyword = String(at(clips, layers.length ? layers.length - 1 : 0)).toLowerCase()
1062
+ const clipBox = BOX_KEYWORD[clipKeyword] || 'border'
1063
+ if (!BOX_KEYWORD[clipKeyword]) {
1064
+ warnings.push(`mask-clip:${clipKeyword} has no box in this model; the mask is clipped to the border box.`)
1065
+ }
1066
+
1067
+ const out = []
1068
+ let svgReference = false
1069
+ // Reversed for the same reason `fills` is: layer 0 paints on top.
1070
+ for (let i = layers.length - 1; i >= 0; i--) {
1071
+ const layer = layers[i]
1072
+ if (!layer || layer.toLowerCase() === 'none') continue
1073
+
1074
+ const originBox = BOX_KEYWORD[String(at(origins, i)).toLowerCase()] || 'padding'
1075
+ const area = boxes[originBox === 'text' ? 'border' : originBox]
1076
+ const placement = {
1077
+ area: { ...area },
1078
+ sizing: parseBgSize(at(sizes, i), warnings),
1079
+ position: parseBgPosition(at(positions, i), warnings),
1080
+ repeat: parseBgRepeat(at(repeats, i), warnings),
1081
+ }
1082
+
1083
+ if (GRADIENT.test(layer)) {
1084
+ const rect = resolveLayerRect(placement, null)
1085
+ if (rect.w <= 0 || rect.h <= 0) continue
1086
+ const paint = parseGradient(layer, { w: rect.w, h: rect.h })
1087
+ if (!paint) {
1088
+ warnings.push(`mask-image layer "${layer}" looks like a gradient but could not be parsed; it is dropped.`)
1089
+ continue
1090
+ }
1091
+ for (const note of paint.notes || []) warnings.push(`mask gradient: ${note}`)
1092
+ out.push({ ...paint, srcCss: layer, blend: 'normal', clipBox, originBox, placement, rect })
1093
+ continue
1094
+ }
1095
+
1096
+ const url = readUrl(layer)
1097
+ // `url(#id)` is an SVG <mask> element, not an image: its content is a
1098
+ // subtree of the document, and reading it is `collect/image.js`'s job, not
1099
+ // this one's.
1100
+ if (url && url.startsWith('#')) {
1101
+ svgReference = true
1102
+ warnings.push(`mask-image layer "${layer}" references an SVG <mask> element; its geometry is not read.`)
1103
+ continue
1104
+ }
1105
+ if (url) {
1106
+ const resolvable = placement.sizing.kind === 'explicit' &&
1107
+ placement.sizing.x !== 'auto' && placement.sizing.y !== 'auto'
1108
+ out.push({
1109
+ type: 'image',
1110
+ asset: { pending: true, kind: 'url', url, srcCss: layer },
1111
+ srcCss: layer,
1112
+ blend: 'normal',
1113
+ clipBox,
1114
+ originBox,
1115
+ placement,
1116
+ rect: resolvable ? resolveLayerRect(placement, null) : null,
1117
+ })
1118
+ continue
1119
+ }
1120
+ warnings.push(`mask-image layer "${layer}" is not a gradient or a URL; it is dropped.`)
1121
+ }
1122
+
1123
+ // `match-source` is luminance only for an SVG <mask> element; for an image or a
1124
+ // gradient — which is every layer that gets this far — it is alpha.
1125
+ const mode = String(cs.maskMode || 'match-source').toLowerCase()
1126
+ const type = mode === 'luminance' || (mode === 'match-source' && svgReference) ? 'luminance' : 'alpha'
1127
+ if (out.length > 1 && composite !== 'add') {
1128
+ warnings.push(`mask-composite:${composite} is not modelled; the collected layers stack as "add".`)
1129
+ }
1130
+
1131
+ const inset = clipBox === 'padding'
1132
+ ? border
1133
+ : (clipBox === 'content' ? border.map((v, i) => v + padding[i]) : null)
1134
+ return {
1135
+ type,
1136
+ mode,
1137
+ composite,
1138
+ clipBox,
1139
+ box: { ...boxes[clipBox === 'text' ? 'border' : clipBox] },
1140
+ radii: inset ? insetRadii(radii, inset) : radii,
1141
+ layers: out,
1142
+ srcCss: image,
1143
+ }
1144
+ }
1145
+
1146
+ /**
1147
+ * `clip-path` as geometry, in the same node-local px every other field here is in.
1148
+ *
1149
+ * Only the basic shapes, and only resolved — a backend cannot be handed
1150
+ * `polygon(50% 0%, …)` and be expected to know what box the percentages are
1151
+ * against. Chromium serialises the computed value with the reference box spelled
1152
+ * out when it is not the default, which is the one thing that makes this
1153
+ * readable without re-implementing the cascade.
1154
+ *
1155
+ * `url(#id)` is a `<clipPath>` ELEMENT: its content is a subtree of the document
1156
+ * and reading it is `collect/image.js`'s job, not this one's — so it answers null
1157
+ * and the caller reports it, exactly like an `url(#…)` mask layer.
1158
+ *
1159
+ * @returns {{kind:string, box:string, ...}|null} null when the value is not a
1160
+ * basic shape, or is one whose grammar could not be resolved.
1161
+ */
1162
+ function parseClipPath (value, boxes, radii, warnings) {
1163
+ const parts = splitTop(value, false)
1164
+ let boxKey = 'border'
1165
+ let shape = ''
1166
+ for (const part of parts) {
1167
+ const lower = part.toLowerCase()
1168
+ if (BOX_KEYWORD[lower]) { boxKey = BOX_KEYWORD[lower] === 'text' ? 'border' : BOX_KEYWORD[lower]; continue }
1169
+ if (lower === 'fill-box' || lower === 'stroke-box' || lower === 'view-box') { boxKey = 'border'; continue }
1170
+ if (/^[a-z-]+\(/i.test(part)) shape = part
1171
+ }
1172
+ const area = boxes[boxKey] || boxes.border
1173
+ // A bare `<geometry-box>` with no shape IS a valid clip-path: the box itself.
1174
+ if (!shape) {
1175
+ return parts.length ? { kind: 'rect', box: boxKey, rect: { ...area }, radii: boxKey === 'border' ? radii : null } : null
1176
+ }
1177
+ const m = /^([a-z-]+)\((.*)\)$/is.exec(shape)
1178
+ if (!m) return null
1179
+ const fn = m[1].toLowerCase()
1180
+ const args = m[2].trim()
1181
+ // A percentage resolves against the reference box on its own axis; the
1182
+ // "reference distance" a circle radius uses is the CSS diagonal formula.
1183
+ const onX = (t) => { const v = parseLenPct(t, warnings, `clip-path ${fn}`); return area.x + v.len + v.pct * area.w }
1184
+ const onY = (t) => { const v = parseLenPct(t, warnings, `clip-path ${fn}`); return area.y + v.len + v.pct * area.h }
1185
+ const sizeX = (t) => { const v = parseLenPct(t, warnings, `clip-path ${fn}`); return v.len + v.pct * area.w }
1186
+ const sizeY = (t) => { const v = parseLenPct(t, warnings, `clip-path ${fn}`); return v.len + v.pct * area.h }
1187
+ const diagonal = Math.sqrt((area.w * area.w + area.h * area.h) / 2)
1188
+
1189
+ if (fn === 'polygon') {
1190
+ let body = args
1191
+ let rule = 'nonzero'
1192
+ const first = splitTop(body)[0] || ''
1193
+ if (/^(nonzero|evenodd)$/i.test(first.trim())) {
1194
+ rule = first.trim().toLowerCase()
1195
+ body = splitTop(body).slice(1).join(',')
1196
+ }
1197
+ const points = []
1198
+ for (const pair of splitTop(body)) {
1199
+ const xy = splitTop(pair, false)
1200
+ if (xy.length < 2) return null
1201
+ points.push([onX(xy[0]), onY(xy[1])])
1202
+ }
1203
+ if (points.length < 3) return null
1204
+ return { kind: 'polygon', box: boxKey, rule, points }
1205
+ }
1206
+ if (fn === 'inset' || fn === 'rect' || fn === 'xywh') {
1207
+ // `inset()` is the only one of the three CSS serialises for `clip-path`
1208
+ // today, but all three share a shape once resolved.
1209
+ const [geom, roundRaw] = args.split(/\bround\b/i)
1210
+ const t = splitTop(geom.trim(), false)
1211
+ if (!t.length) return null
1212
+ let rect
1213
+ if (fn === 'inset') {
1214
+ const top = sizeY(t[0])
1215
+ const right = sizeX(t[1] !== undefined ? t[1] : t[0])
1216
+ const bottom = sizeY(t[2] !== undefined ? t[2] : t[0])
1217
+ const left = sizeX(t[3] !== undefined ? t[3] : (t[1] !== undefined ? t[1] : t[0]))
1218
+ rect = { x: area.x + left, y: area.y + top, w: Math.max(0, area.w - left - right), h: Math.max(0, area.h - top - bottom) }
1219
+ } else if (fn === 'xywh') {
1220
+ rect = { x: area.x + sizeX(t[0]), y: area.y + sizeY(t[1]), w: Math.max(0, sizeX(t[2])), h: Math.max(0, sizeY(t[3])) }
1221
+ } else {
1222
+ const top = onY(t[0]); const right = onX(t[1]); const bottom = onY(t[2]); const left = onX(t[3])
1223
+ rect = { x: left, y: top, w: Math.max(0, right - left), h: Math.max(0, bottom - top) }
1224
+ }
1225
+ let round = null
1226
+ if (roundRaw && roundRaw.trim()) {
1227
+ const r = splitTop(roundRaw.trim(), false).map((v) => Math.max(0, sizeX(v)))
1228
+ round = [r[0] || 0, r[1] !== undefined ? r[1] : r[0] || 0,
1229
+ r[2] !== undefined ? r[2] : r[0] || 0, r[3] !== undefined ? r[3] : (r[1] !== undefined ? r[1] : r[0] || 0)]
1230
+ }
1231
+ return { kind: 'inset', box: boxKey, rect, round }
1232
+ }
1233
+ if (fn === 'circle' || fn === 'ellipse') {
1234
+ const [radiiRaw, posRaw] = args.split(/\bat\b/i)
1235
+ const rs = splitTop((radiiRaw || '').trim(), false).filter(Boolean)
1236
+ const pos = parseBgPosition((posRaw || '50% 50%').trim(), warnings)
1237
+ const cx = area.x + pos.x.len + pos.x.pct * area.w
1238
+ const cy = area.y + pos.y.len + pos.y.pct * area.h
1239
+ const side = (token, axis, extent) => {
1240
+ const v = String(token || 'closest-side').trim().toLowerCase()
1241
+ if (v === 'closest-side') return Math.min(Math.abs(axis - area[extent === 'w' ? 'x' : 'y']), Math.abs(area[extent === 'w' ? 'x' : 'y'] + area[extent] - axis))
1242
+ if (v === 'farthest-side') return Math.max(Math.abs(axis - area[extent === 'w' ? 'x' : 'y']), Math.abs(area[extent === 'w' ? 'x' : 'y'] + area[extent] - axis))
1243
+ return null
1244
+ }
1245
+ if (fn === 'circle') {
1246
+ const token = rs[0]
1247
+ let r = side(token, cx, 'w')
1248
+ if (r !== null && String(token || '').trim().toLowerCase().startsWith('closest')) {
1249
+ r = Math.min(r, side(token, cy, 'h'))
1250
+ } else if (r !== null) {
1251
+ r = Math.max(r, side(token, cy, 'h'))
1252
+ } else {
1253
+ const v = parseLenPct(token, warnings, 'clip-path circle')
1254
+ r = v.len + v.pct * diagonal
1255
+ }
1256
+ return { kind: 'ellipse', box: boxKey, cx, cy, rx: r, ry: r }
1257
+ }
1258
+ const rx = side(rs[0], cx, 'w') ?? sizeX(rs[0])
1259
+ const ry = side(rs[1] !== undefined ? rs[1] : rs[0], cy, 'h') ?? sizeY(rs[1] !== undefined ? rs[1] : rs[0])
1260
+ return { kind: 'ellipse', box: boxKey, cx, cy, rx, ry }
1261
+ }
1262
+ if (fn === 'path') {
1263
+ const q = /^(?:(nonzero|evenodd)\s*,\s*)?["'](.*)["']$/is.exec(args)
1264
+ if (!q) return null
1265
+ return { kind: 'path', box: boxKey, rule: (q[1] || 'nonzero').toLowerCase(), d: q[2] }
1266
+ }
1267
+ return null
1268
+ }
1269
+
1270
+ /**
1271
+ * A collapsed table border does not travel with a `position: sticky` cell.
1272
+ *
1273
+ * Under `border-collapse: collapse` the border stops belonging to the cell and
1274
+ * becomes the TABLE's, painted on the grid line. When the cell is then stuck
1275
+ * somewhere else, Chromium paints nothing at the stuck position — the border stays
1276
+ * behind on the static grid line, where the scrolled-away row is.
1277
+ *
1278
+ * Measured on `demo/challenges.html` -> fx-table (2026-08-07). Its `tfoot td` is
1279
+ * `position: sticky; bottom: 0` with `border-top: 5px double`, stuck at y=335 while
1280
+ * its static position is y~487. Reading the live pixels at x=60: rows 335..340 are
1281
+ * (251,253,255) and (255,255,255) — no ink at all. This engine read
1282
+ * `border-top-width` off the computed style, saw 5px, and painted a solid
1283
+ * `#64748b` band there. Those four rows were the hottest lines in the entire
1284
+ * corpus: ~124 mean channel difference across the full 500px width.
1285
+ *
1286
+ * The control page settles that it is the displacement and not the sticky: with
1287
+ * `scrollTop: 0` (the cell at its static position) Chromium DOES paint it, and the
1288
+ * dark rows appear.
1289
+ */
1290
+ function stickyCollapsedBorderIsElsewhere (el, cs) {
1291
+ if ((cs.position || 'static') !== 'sticky') return false
1292
+ const tag = el.tagName
1293
+ if (tag !== 'TD' && tag !== 'TH') return false
1294
+ const table = el.closest && el.closest('table')
1295
+ if (!table) return false
1296
+ const view = el.ownerDocument && el.ownerDocument.defaultView
1297
+ let collapse = ''
1298
+ try { collapse = view ? view.getComputedStyle(table).borderCollapse : '' } catch { return false }
1299
+ if (collapse !== 'collapse') return false
1300
+ // Stuck, or merely sticky and sitting where it always was? Only the first one
1301
+ // leaves the border behind; dropping it in the second case would lose a border
1302
+ // the page really paints.
1303
+ const row = el.parentElement
1304
+ if (!row) return false
1305
+ try {
1306
+ return Math.abs(el.getBoundingClientRect().top - row.getBoundingClientRect().top) > 0.5
1307
+ } catch { return false }
1308
+ }
1309
+
1310
+ /** Borders as one inside stroke with per-side weights, plus the outline as a sibling. */
1311
+ function collectStrokes (cs, radii, border, el) {
1312
+ const strokeWarnings = []
1313
+ const strokes = []
1314
+
1315
+ if (el && stickyCollapsedBorderIsElsewhere(el, cs)) {
1316
+ strokeWarnings.push('this cell is stuck away from its static position and its table collapses borders, ' +
1317
+ 'so the border belongs to the table and Chromium paints it on the static grid line, not here. It is ' +
1318
+ 'not emitted. Measured on fx-table: the live pixels under the stuck footer carry no ink at all, and ' +
1319
+ 'painting it there was the hottest error in the corpus (~124 mean channel difference over 500px).')
1320
+ return { strokes, strokeWarnings }
1321
+ }
1322
+
1323
+ const SIDES = ['Top', 'Right', 'Bottom', 'Left']
1324
+ const sides = SIDES.map((name, i) => {
1325
+ const style = cs[`border${name}Style`] || 'none'
1326
+ const hidden = style === 'none' || style === 'hidden'
1327
+ const parsed = parseColor(cs[`border${name}Color`])
1328
+ return {
1329
+ side: name.toLowerCase(),
1330
+ width: hidden ? 0 : border[i],
1331
+ style,
1332
+ color: parsed ? parsed.rgba : null,
1333
+ srcCss: parsed ? parsed.srcCss : cs[`border${name}Color`],
1334
+ colorWarnings: (parsed && parsed.warnings) || [],
1335
+ }
1336
+ })
1337
+
1338
+ const visible = sides.filter((s) => s.width > 0 && s.color && !isTransparent(s.color))
1339
+ for (const s of visible) {
1340
+ for (const message of s.colorWarnings) strokeWarnings.push(`border-${s.side}-color: ${message}`)
1341
+ }
1342
+ if (visible.length) {
1343
+ const key = (s) => `${s.style}|${s.color.join(',')}`
1344
+ const uniform = visible.every((s) => key(s) === key(visible[0]))
1345
+ const model = visible[0]
1346
+ const weights = sides.map((s) => s.width)
1347
+ const maxWeight = Math.max(...visible.map((s) => s.width))
1348
+
1349
+ const stroke = {
1350
+ kind: 'border',
1351
+ align: 'inside',
1352
+ weights,
1353
+ radii,
1354
+ paint: { type: 'solid', color: model.color, srcCss: model.srcCss },
1355
+ style: model.style,
1356
+ uniform,
1357
+ // `colorWarnings` is a collection channel, not part of the stroke shape.
1358
+ sides: sides.map(({ colorWarnings, ...rest }) => rest),
1359
+ }
1360
+
1361
+ if (model.style === 'dashed' || model.style === 'dotted') {
1362
+ // A 3w/3w period. Chromium's dotted run is nearer w/w with round caps; the
1363
+ // cap is carried, the period is not, and that is the approximation.
1364
+ stroke.dash = [3 * maxWeight, 3 * maxWeight]
1365
+ stroke.cap = model.style === 'dotted' ? 'round' : 'butt'
1366
+ if (model.style === 'dotted') {
1367
+ strokeWarnings.push('border-style:dotted is emitted as a round-capped 3w/3w dash; the rendered period is tighter.')
1368
+ }
1369
+ } else if (model.style === 'double') {
1370
+ // Not "shading". Chromium splits the width into line / gap / line — measured
1371
+ // 2/1/2 for the 5px border on fx-table — and the middle third is BACKGROUND,
1372
+ // not a lighter shade of the colour. Emitted solid, that third arrives as ink.
1373
+ // The corpus has exactly one `double` border and the sticky-cell rule above
1374
+ // already drops it, so no fixture would measure a split implementation today:
1375
+ // it is declared rather than written blind.
1376
+ strokeWarnings.push(`border-style:double is emitted as one solid band ${maxWeight}px wide. Chromium ` +
1377
+ 'splits that width into line / gap / line — 2/1/2 for a 5px border, measured — and the middle third ' +
1378
+ 'is background showing through, not a lighter shade. Emitted this way it arrives as solid ink.')
1379
+ } else if (!['solid', 'none', 'hidden'].includes(model.style)) {
1380
+ strokeWarnings.push(`border-style:${model.style} is emitted as a solid stroke; its shading is lost.`)
1381
+ }
1382
+
1383
+ if (!uniform) {
1384
+ const detail = visible
1385
+ .map((s) => `${s.side}: ${s.style} ${s.srcCss}`)
1386
+ .join('; ')
1387
+ strokeWarnings.push(
1388
+ `border sides differ (${detail}); one stroke cannot carry them and the caller ` +
1389
+ 'must compose one shape per side.'
1390
+ )
1391
+ }
1392
+ strokes.push(stroke)
1393
+ } else {
1394
+ for (const s of sides) {
1395
+ if (s.width > 0 && !s.color) {
1396
+ strokeWarnings.push(`border-${s.side}-color "${s.srcCss}" could not be parsed; the side is dropped.`)
1397
+ }
1398
+ }
1399
+ }
1400
+
1401
+ const outlineWidth = px(cs.outlineWidth)
1402
+ const outlineStyle = cs.outlineStyle || 'none'
1403
+ const outlineColor = parseColor(cs.outlineColor)
1404
+ if (outlineWidth > 0 && outlineStyle !== 'none' && outlineColor && !isTransparent(outlineColor.rgba)) {
1405
+ const offset = px(cs.outlineOffset)
1406
+ for (const message of outlineColor.warnings || []) strokeWarnings.push(`outline-color: ${message}`)
1407
+ strokes.push({
1408
+ kind: 'outline',
1409
+ // The outline is painted over everything, is not clipped by the box and
1410
+ // does not participate in its layout, so it can only be a sibling emitted
1411
+ // last in the parent's z-order.
1412
+ sibling: true,
1413
+ order: 'last',
1414
+ align: 'outside',
1415
+ offset,
1416
+ weights: [outlineWidth, outlineWidth, outlineWidth, outlineWidth],
1417
+ radii: expandRadii(radii, offset),
1418
+ paint: { type: 'solid', color: outlineColor.rgba, srcCss: outlineColor.srcCss },
1419
+ style: outlineStyle === 'auto' ? 'solid' : outlineStyle,
1420
+ })
1421
+ if (outlineStyle === 'auto') {
1422
+ strokeWarnings.push('outline-style:auto is the UA focus ring; it is emitted as a solid stroke.')
1423
+ } else if (outlineStyle === 'dashed' || outlineStyle === 'dotted') {
1424
+ strokes[strokes.length - 1].dash = [3 * outlineWidth, 3 * outlineWidth]
1425
+ strokes[strokes.length - 1].cap = outlineStyle === 'dotted' ? 'round' : 'butt'
1426
+ }
1427
+ strokeWarnings.push('outline is emitted as a sibling stroke at the end of the z-order, not as part of this node.')
1428
+ }
1429
+
1430
+ return { strokes, strokeWarnings }
1431
+ }
1432
+
1433
+ /** box-shadow and the filter chain. */
1434
+ function collectEffects (cs, warnings) {
1435
+ const effects = []
1436
+ let filterOpacity = 1
1437
+
1438
+ if (cs.boxShadow && cs.boxShadow !== 'none') {
1439
+ const { shadows, warnings: shadowWarnings } = parseShadows(cs.boxShadow, { inset: true, currentColor: cs.color })
1440
+ // Every assumption `parseShadows` made — a dropped shadow in a list of three,
1441
+ // a colour that fell back to opaque black — is only visible here.
1442
+ for (const message of shadowWarnings) warnings.push(`box-shadow: ${message}`)
1443
+ if (!shadows.length) {
1444
+ warnings.push(`box-shadow "${cs.boxShadow}" could not be parsed; it is dropped.`)
1445
+ }
1446
+ effects.push(...shadows)
1447
+ }
1448
+
1449
+ const filter = pickFilter(cs.filter, cs.webkitFilter)
1450
+
1451
+ if (filter !== 'none') {
1452
+ const fns = readFilterFunctions(filter)
1453
+ const kept = []
1454
+ const droppedColor = []
1455
+ const droppedOther = []
1456
+ const pendingOpacity = []
1457
+ const matrices = []
1458
+ // A colour run is flushed the moment something that is NOT a colour filter
1459
+ // reaches the chain, so the effects come out in source order and the emitter
1460
+ // can build its chain by walking the array. `opacity()` does not break a run:
1461
+ // it is taken out of the chain entirely (see below) and it commutes with a
1462
+ // colour matrix, so what is left either side of it really is adjacent.
1463
+ let colorRun = []
1464
+ const flushColor = () => {
1465
+ if (!colorRun.length) return
1466
+ const chain = colorMatrixChain(colorRun)
1467
+ colorRun = []
1468
+ for (const fn of chain.unreadable) droppedColor.push(fn)
1469
+ for (const fn of chain.used) kept.push(fn)
1470
+ for (const step of chain.steps) {
1471
+ effects.push({ type: 'colorMatrix', values: step.values, srcCss: step.srcCss, backdrop: false })
1472
+ matrices.push(step)
1473
+ }
1474
+ }
1475
+ for (const fn of fns) {
1476
+ const { name, arg } = fn
1477
+ if (COLOR_FILTERS.has(name)) {
1478
+ colorRun.push(fn)
1479
+ continue
1480
+ }
1481
+ if (name !== 'opacity') flushColor()
1482
+ if (name === 'blur') {
1483
+ // The CSS radius verbatim — SVD invariant 5 says sigma = blur / 2 and
1484
+ // that division belongs to the emitter.
1485
+ effects.push({ type: 'layerBlur', blur: px(arg), srcCss: fn.css })
1486
+ kept.push(fn)
1487
+ } else if (name === 'drop-shadow') {
1488
+ const { shadows, warnings: shadowWarnings } = parseShadows(arg, { inset: false, currentColor: cs.color })
1489
+ // Chromium serialises `drop-shadow(0 1px 2px)` without a colour — the
1490
+ // initial value is `currentColor`, which this module cannot resolve — so
1491
+ // an indigo shadow silently became black until this line existed.
1492
+ for (const message of shadowWarnings) warnings.push(`filter: ${fn.css}: ${message}`)
1493
+ if (!shadows.length) {
1494
+ warnings.push(`filter: ${fn.css} could not be parsed; it is dropped.`)
1495
+ }
1496
+ for (const s of shadows) effects.push({ ...s, source: 'filter' })
1497
+ warnings.push('filter: drop-shadow follows the alpha silhouette; it is emitted as a box drop shadow.')
1498
+ kept.push(fn)
1499
+ } else if (name === 'opacity') {
1500
+ pendingOpacity.push(arg.endsWith('%') ? parseFloat(arg) / 100 : parseFloat(arg))
1501
+ kept.push(fn)
1502
+ } else {
1503
+ droppedOther.push(fn)
1504
+ }
1505
+ }
1506
+ flushColor()
1507
+
1508
+ // What "dropped" means here is worth being exact about, because the old
1509
+ // message was not. It said the node "must be rasterized to keep it", which
1510
+ // `index.js` reads as grade `R` — a node the document then CLAIMS to have
1511
+ // rasterized. Nothing in this engine rasterizes a box filter (`emit/pdf.js`
1512
+ // says so in as many words at its own filter branch), so the output was
1513
+ // vector, unfiltered, and graded raster. The function is gone: grade `O`.
1514
+ const partial = kept.length
1515
+ ? `The rest of the chain is still applied (${listOf(kept)}), so this node is painted with a ` +
1516
+ 'PARTIAL filter — neither the filtered nor the unfiltered image.'
1517
+ : 'The node is painted unfiltered.'
1518
+ if (matrices.length) {
1519
+ warnings.push({
1520
+ code: 'collect.box.filter-color-matrix',
1521
+ grade: 'E',
1522
+ severity: 'info',
1523
+ message: `filter: ${matrices.map((m) => m.srcCss).join(' ')} is carried as ` +
1524
+ `${matrixCount(matrices)}, which is exactly what CSS Filter Effects §8 defines those ` +
1525
+ 'functions to be, so nothing is approximated here; the matrices act on non-premultiplied ' +
1526
+ `sRGB. ${matrixSplit(matrices)}Whether they survive is the backend's to declare (SVG has ` +
1527
+ 'feColorMatrix; Figma has no colour effect).',
1528
+ })
1529
+ }
1530
+ if (droppedColor.length) {
1531
+ warnings.push({
1532
+ code: 'collect.box.filter-color-dropped',
1533
+ grade: 'O',
1534
+ severity: 'warn',
1535
+ message: `filter: ${listOf(droppedColor)} resamples colour and its argument could not be ` +
1536
+ `read, so no colour matrix can be built for it and it is dropped. ${partial}`,
1537
+ })
1538
+ }
1539
+ for (const fn of droppedOther) {
1540
+ warnings.push({
1541
+ code: fn.name === 'url' ? 'collect.box.filter-svg-reference' : 'collect.box.filter-unsupported',
1542
+ grade: 'O',
1543
+ severity: 'warn',
1544
+ message: fn.name === 'url'
1545
+ ? `filter references the SVG filter ${fn.arg}; its definition is not read and the ` +
1546
+ `reference is dropped. ${partial}`
1547
+ : `filter function ${fn.css} is not represented and is dropped. ${partial}`,
1548
+ })
1549
+ }
1550
+ if (pendingOpacity.length) {
1551
+ // Alone among blurs and drop-shadows, opacity() commutes with the rest of
1552
+ // the chain, so folding it into layer opacity is exact. It used to be
1553
+ // withheld "for the raster" whenever a colour function shared the chain,
1554
+ // which — there being no raster — lost it outright on top of the colour.
1555
+ for (const o of pendingOpacity) filterOpacity *= clamp01(o)
1556
+ }
1557
+ if (!fns.length) {
1558
+ warnings.push(`filter "${filter}" could not be parsed; it is dropped.`)
1559
+ }
1560
+ }
1561
+
1562
+ // snapdom pre-composes `backdrop-filter` in the clone (a copy of what paints
1563
+ // below, carrying the functions as a plain `filter`), so this branch is what is
1564
+ // left for the cases it cannot: a 0×0 source box, or the filtered element being
1565
+ // the capture root, whose backdrop lies outside the capture.
1566
+ const backdrop = pickFilter(cs.backdropFilter, cs.webkitBackdropFilter)
1567
+ if (backdrop !== 'none') {
1568
+ const fns = readFilterFunctions(backdrop)
1569
+ const dropped = []
1570
+ const matrices = []
1571
+ let blur = null
1572
+ // Same flush discipline as the `filter` branch, with one difference:
1573
+ // `opacity()` belongs to the MATRIX here. On `backdrop-filter` it fades what
1574
+ // shows through the node, not the node, so folding it into layer opacity —
1575
+ // which is what the other branch does — would fade the wrong pixels.
1576
+ let colorRun = []
1577
+ const flushColor = () => {
1578
+ if (!colorRun.length) return
1579
+ const chain = colorMatrixChain(colorRun)
1580
+ colorRun = []
1581
+ for (const fn of chain.unreadable) dropped.push(fn)
1582
+ for (const step of chain.steps) {
1583
+ effects.push({ type: 'colorMatrix', values: step.values, srcCss: step.srcCss, backdrop: true })
1584
+ matrices.push(step)
1585
+ }
1586
+ }
1587
+ for (const fn of fns) {
1588
+ if (COLOR_MATRIX_FILTERS.has(fn.name)) {
1589
+ colorRun.push(fn)
1590
+ continue
1591
+ }
1592
+ flushColor()
1593
+ if (fn.name === 'blur' && !blur) {
1594
+ blur = fn
1595
+ effects.push({ type: 'backgroundBlur', blur: px(fn.arg), srcCss: fn.css })
1596
+ } else {
1597
+ dropped.push(fn)
1598
+ }
1599
+ }
1600
+ flushColor()
1601
+ const carried = [
1602
+ blur ? `${blur.css} as a backgroundBlur effect` : null,
1603
+ matrices.length ? `${matrices.map((m) => m.srcCss).join(' ')} as ${matrixCount(matrices)}` : null,
1604
+ ].filter(Boolean).join(' and ')
1605
+ if (!dropped.length && carried) {
1606
+ warnings.push({
1607
+ code: blur ? 'collect.box.backdrop-blur' : 'collect.box.backdrop-color-matrix',
1608
+ grade: 'E',
1609
+ severity: 'info',
1610
+ message: `backdrop-filter: ${carried} — exactly what those functions mean, so nothing is ` +
1611
+ 'approximated here; a colour matrix acts on non-premultiplied sRGB. ' +
1612
+ `${matrixSplit(matrices)}Whether they survive is the backend's to declare (Figma has the ` +
1613
+ 'blur and no colour effect; SVG has feColorMatrix and no readback of the backdrop).',
1614
+ })
1615
+ } else if (dropped.length) {
1616
+ warnings.push({
1617
+ code: 'collect.box.backdrop-filter-dropped',
1618
+ grade: 'O',
1619
+ severity: 'warn',
1620
+ message: `backdrop-filter: ${listOf(dropped)} has no vector primitive and is dropped; ` +
1621
+ (carried
1622
+ ? `what is carried is ${carried}, and nothing more.`
1623
+ : 'the node is emitted with no backdrop effect at all.'),
1624
+ })
1625
+ }
1626
+ if (!fns.length) {
1627
+ warnings.push({
1628
+ code: 'collect.box.backdrop-filter-dropped',
1629
+ grade: 'O',
1630
+ severity: 'warn',
1631
+ message: `backdrop-filter "${backdrop}" could not be read; the node is emitted without it.`,
1632
+ })
1633
+ }
1634
+ }
1635
+
1636
+ return { effects, filterOpacity }
1637
+ }
1638
+
1639
+ /**
1640
+ * The paint clip: the padding box with the inner radii.
1641
+ *
1642
+ * `overflow` is not the only property that clips. `contain: paint|strict|content`
1643
+ * and `content-visibility: auto|hidden` clip to the padding box too, and they do
1644
+ * NOT change the computed `overflow` — so reading `overflowX/Y` alone said "no
1645
+ * clip" for a box the page was clipping. `paint.js:clipsContent()` already knows
1646
+ * all three (it is why the element gets its own layer at all); the knowledge
1647
+ * simply never crossed into the collector, and an absolutely-positioned child
1648
+ * that the page cut at the padding box was emitted whole, over its neighbours.
1649
+ */
1650
+ const CONTAIN_CLIP = /\b(paint|strict|content)\b/
1651
+
1652
+ function collectClip (cs, radii, border, warnings) {
1653
+ const ox = cs.overflowX || 'visible'
1654
+ const oy = cs.overflowY || 'visible'
1655
+ const byOverflow = ox !== 'visible' || oy !== 'visible'
1656
+ const contain = CONTAIN_CLIP.test(cs.contain || '') ? cs.contain : ''
1657
+ const cv = cs.contentVisibility === 'auto' || cs.contentVisibility === 'hidden'
1658
+ ? cs.contentVisibility
1659
+ : ''
1660
+ if (!byOverflow && !contain && !cv) return null
1661
+
1662
+ if (byOverflow && ox !== oy && (ox === 'visible' || oy === 'visible')) {
1663
+ warnings.push(`overflow is clipped on one axis only (x:${ox}, y:${oy}); the clip is applied to both.`)
1664
+ }
1665
+ // No warning for the clip itself: it is the same padding-box rect with the same
1666
+ // inner radii the page uses, so nothing is approximated and a diagnostic here
1667
+ // would only degrade a node that is exact.
1668
+ if (cv === 'hidden') {
1669
+ // The box itself still paints; its contents are skipped entirely, and the
1670
+ // walk in `paint.js` does not know that, so it collects them from layout.
1671
+ warnings.push('content-visibility:hidden — the page renders none of this subtree; descendants collected from layout are painted here although the page omitted them.')
1672
+ }
1673
+ return { mode: 'rect', box: 'padding', radii: insetRadii(radii, border) }
1674
+ }
1675
+
1676
+ export default collectBox