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