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