@zumer/snapdom-plugins 3.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +50 -16
  3. package/agent-map.d.ts +32 -0
  4. package/agent-map.js +5 -3
  5. package/ascii-export.d.ts +10 -0
  6. package/ascii-export.js +21 -6
  7. package/capture-frames.js +5 -5
  8. package/color-tint.js +4 -0
  9. package/context-export.d.ts +23 -0
  10. package/context-export.js +4 -1
  11. package/filter.js +1 -1
  12. package/gif-export.d.ts +6 -0
  13. package/gif-export.js +3 -3
  14. package/html-export.d.ts +8 -0
  15. package/html-export.js +6 -4
  16. package/index.d.ts +2 -1
  17. package/index.js +2 -1
  18. package/package.json +16 -5
  19. package/pdf/LICENSE +21 -0
  20. package/pdf/README.md +22 -0
  21. package/pdf/REFERENCE.md +520 -0
  22. package/pdf/src/frontmatter.js +200 -0
  23. package/pdf/src/furniture.js +398 -0
  24. package/pdf/src/index.js +2415 -0
  25. package/pdf/src/mega/README.md +47 -0
  26. package/pdf/src/mega/diag.js +148 -0
  27. package/pdf/src/mega/flow.js +56 -0
  28. package/pdf/src/mega/fonts.js +287 -0
  29. package/pdf/src/mega/forms.js +479 -0
  30. package/pdf/src/mega/labels.js +43 -0
  31. package/pdf/src/mega/meta.js +145 -0
  32. package/pdf/src/nav.js +357 -0
  33. package/pdf/src/paginate.js +583 -0
  34. package/pdf/src/tagged.js +355 -0
  35. package/pdf/src/text-layer.js +1771 -0
  36. package/pdf/src/writer/encrypt.js +218 -0
  37. package/pdf/src/writer/font.js +507 -0
  38. package/pdf/src/writer/index.js +1135 -0
  39. package/pdf/src/writer/subset.js +387 -0
  40. package/pdf.d.ts +294 -0
  41. package/pdf.js +1 -0
  42. package/redact-inputs.js +6 -6
  43. package/vector/CONTRACT.md +906 -0
  44. package/vector/LICENSE +21 -0
  45. package/vector/README.md +35 -0
  46. package/vector/src/adapters/snapdom.js +795 -0
  47. package/vector/src/collect/box.js +1676 -0
  48. package/vector/src/collect/font.js +487 -0
  49. package/vector/src/collect/image.js +1458 -0
  50. package/vector/src/collect/mathml.js +272 -0
  51. package/vector/src/collect/text.js +3027 -0
  52. package/vector/src/css/color.js +823 -0
  53. package/vector/src/css/gradient.js +1126 -0
  54. package/vector/src/css/matrix.js +398 -0
  55. package/vector/src/css/radius.js +124 -0
  56. package/vector/src/css/shadow.js +201 -0
  57. package/vector/src/emit/figma-json.js +1995 -0
  58. package/vector/src/emit/h2d-vector.js +215 -0
  59. package/vector/src/emit/h2d.js +1147 -0
  60. package/vector/src/emit/pdf.js +2571 -0
  61. package/vector/src/emit/svg-flat.js +4842 -0
  62. package/vector/src/exclusion.js +13 -0
  63. package/vector/src/index.js +2910 -0
  64. package/vector/src/paint.js +2908 -0
  65. package/vector/src/public.js +57 -0
  66. package/vector/src/writer/encrypt.js +227 -0
  67. package/vector/src/writer/font.js +507 -0
  68. package/vector/src/writer/index.js +1148 -0
  69. package/vector/src/writer/subset.js +387 -0
  70. package/vector/svd/index.js +72 -0
  71. package/vector/svd/schema.js +985 -0
  72. package/vector.d.ts +28 -0
  73. package/vector.js +1 -0
  74. package/video-export.d.ts +6 -0
  75. package/video-export.js +7 -7
  76. package/pdf-image.d.ts +0 -18
  77. package/pdf-image.js +0 -114
@@ -0,0 +1,3027 @@
1
+ /**
2
+ * Text collection: the string a block actually paints, and where every visual
3
+ * line of it sits.
4
+ *
5
+ * The module exists because three things the DOM will happily tell you are all
6
+ * wrong for this job.
7
+ *
8
+ * 1. `textContent` is not what is painted. Whitespace collapses — 54 DOM chars
9
+ * to 37 rendered on the fixture this was built against — and
10
+ * `text-transform` rewrites glyphs. DOM offsets and rendered offsets are
11
+ * therefore DIFFERENT SPACES, and every offset a consumer sees here is in
12
+ * the rendered one. DOM offsets survive only inside the per-node map that
13
+ * `Range` has to be driven with.
14
+ * 2. `selectNodeContents(blockEl).getClientRects()` returns duplicate rects the
15
+ * moment the block has inline children — 12 for 7 runs, measured. One Range
16
+ * per TEXT NODE is the only shape that reports each fragment exactly once.
17
+ * Per character is the other extreme and costs 95ms/35k chars against 4.1ms
18
+ * for the node-level walk; the one place a per-character probe is
19
+ * unavoidable — finding where a wrapped node changes line — is a binary
20
+ * search over PREFIXES of the stretch still in play (40ms for 35k justified,
21
+ * hyphenating chars over 372 lines).
22
+ * 3. `rect.top` does not identify a line. A larger `font-size` span shares its
23
+ * line's BASELINE and not its top, so lines are clustered on
24
+ * `rect.top + fontBoundingBoxAscent` and on nothing else.
25
+ * 4. A rect does not say which characters produced it. Where a line ENDS is
26
+ * therefore derived, and it is the one derivation whose bug corrupts the text
27
+ * instead of the geometry — so it is checked rather than trusted, and a break
28
+ * that cannot be checked is declared (`collect.text-line-break-unverified`).
29
+ * It was not, and the demo shipped `…de cada lín`/`ea, así que` where the
30
+ * browser paints `…de cada lí-`/`nea, así que`, in silence.
31
+ * 5. The hyphen at a hyphenated break is not in the document at all. It is not a
32
+ * character the DOM has, so it cannot be in `characters` without inventing an
33
+ * offset; the SAME rect that proves the break happened also measures the
34
+ * glyph, and it is reported on `line.hyphen` (`collect.text-hyphen`).
35
+ *
36
+ * Metrics come from a canvas 2D context keyed on the FULL font shorthand, size
37
+ * included: Chromium rounds `fontBoundingBox*` to integers per size, so a 12px
38
+ * ascent scaled down from a 16px measurement can be a whole pixel out — two
39
+ * baseline tolerances. Metrics are never scaled between sizes.
40
+ *
41
+ * Coordinates in `lines` are relative to the BORDER BOX of `blockEl`, which is
42
+ * what `getBoundingClientRect` reports, so a caller that already holds the
43
+ * block's frame needs no further arithmetic.
44
+ */
45
+
46
+ import { parseColor } from '../css/color.js'
47
+ import { isExcluded } from '../exclusion.js'
48
+
49
+ /** Visual lines join with LSEP, never `\n`: `\n` makes a paragraph in Figma. */
50
+ const LSEP = '\u2028'
51
+
52
+ /** Two fonts on one line can disagree by half a px once their ascents are rounded. */
53
+ const BASELINE_TOL = 0.5
54
+
55
+ /** Above this spread the baselines are not one pitch and the caller must place per line. */
56
+ const PITCH_TOL = 0.5
57
+
58
+ const EPS = 1e-6
59
+
60
+ /** Elements carrying no inline text of their own, or whose text is another box's problem. */
61
+ const SKIP_TAGS = new Set([
62
+ 'SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'TITLE', 'OPTION', 'OPTGROUP', 'DATALIST',
63
+ 'RT', 'RP', 'INPUT', 'TEXTAREA', 'SELECT', 'IMG', 'SVG', 'MATH', 'CANVAS', 'VIDEO', 'IFRAME', 'OBJECT',
64
+ ])
65
+
66
+ const RUBY_TAGS = new Set(['RT', 'RP'])
67
+
68
+ /**
69
+ * The display values that keep a child's text inside THIS block's inline
70
+ * formatting context. Everything else — `inline-block` included — lays its text
71
+ * out in a context of its own and becomes its own node; descending into one
72
+ * would put two independent line grids in a single `lines` array. Out-of-flow
73
+ * and floated inlines need no test of their own: `position: absolute` and
74
+ * `float` both blockify the computed `display`.
75
+ */
76
+ const INLINE_DISPLAY = /^(inline|ruby|ruby-base|ruby-text|ruby-base-container|ruby-text-container)$/
77
+
78
+ /** CSS collapsible white space. U+00A0 is deliberately absent — NBSP never collapses. */
79
+ function isCollapsible(ch) {
80
+ return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' || ch === '\f'
81
+ }
82
+
83
+ /** Strong RTL blocks, enough to know bidi reordering is in play. */
84
+ const RTL_CHARS = /[\u0590-\u05FF\u0600-\u06FF\u0700-\u074F\u0780-\u07BF\u0800-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF]/
85
+ const NON_ASCII = /[^\u0000-\u007F]/
86
+
87
+ /**
88
+ * Scripts that wrap between two letters with no space and no hyphen — Han, Kana,
89
+ * Hangul, Thai, Lao, Khmer, Myanmar. A break found between two of these is a
90
+ * break opportunity the browser is entitled to take, and so is verified rather
91
+ * than suspect.
92
+ */
93
+ const BREAK_ANYWHERE = new RegExp(
94
+ '[\\u0E00-\\u0EFF\\u1000-\\u109F\\u1780-\\u17FF\\u1100-\\u11FF\\u2E80-\\u303F' +
95
+ '\\u3040-\\u30FF\\u3130-\\u318F\\u31F0-\\u31FF\\u3400-\\u4DBF\\u4E00-\\u9FFF' +
96
+ '\\uA960-\\uA97F\\uAC00-\\uD7FF\\uF900-\\uFAFF\\uFF00-\\uFFEF]'
97
+ )
98
+
99
+ /**
100
+ * Characters a line may end on with no white space after them: the browser takes
101
+ * a break opportunity after a hyphen, a dash, a slash, and the two invisible
102
+ * ones that exist to offer it.
103
+ */
104
+ const BREAK_AFTER = new RegExp('[-\\u00AD\\u2010\\u2012\\u2013\\u2014\\u200B\\u2027/\\\\]')
105
+
106
+ /**
107
+ * What Chromium paints at a hyphenation break when `hyphenate-character` is
108
+ * `auto`, in the order Blink picks it.
109
+ *
110
+ * `ComputedStyle::HyphenString()` returns U+2010 HYPHEN, and U+002D HYPHEN-MINUS
111
+ * only when the primary font has no glyph for U+2010. The two are different
112
+ * characters that almost every font gives the SAME advance, so nothing measurable
113
+ * separates them and the order here is Blink's preference, not a measurement —
114
+ * which is why the choice is declared rather than stated.
115
+ */
116
+ const AUTO_HYPHENS = ['‐', '-']
117
+
118
+ /** The painted hyphen must sit on the line's trailing edge to within this, in px. */
119
+ const HYPHEN_EDGE_TOL = 0.75
120
+
121
+ /** Escapes inside a CSS `<string>`: `\2010`, `\2010 `, `\"`. */
122
+ const CSS_ESCAPE = /\\(?:([0-9a-fA-F]{1,6})[ \t\n\f]?|([^\n]))/g
123
+
124
+ /**
125
+ * The value of a computed CSS `<string>`, unquoted and unescaped, or null when
126
+ * the value is a keyword (`auto`) rather than a string.
127
+ */
128
+ function cssString(value) {
129
+ const s = String(value == null ? '' : value).trim()
130
+ if (s.length < 2) return null
131
+ const q = s[0]
132
+ if ((q !== '"' && q !== "'") || s[s.length - 1] !== q) return null
133
+ return s.slice(1, -1).replace(CSS_ESCAPE, (_, hex, ch) => (
134
+ hex ? String.fromCodePoint(parseInt(hex, 16)) : ch
135
+ ))
136
+ }
137
+
138
+ /**
139
+ * The characters this block may paint at a hyphenation break, best first.
140
+ *
141
+ * An author who sets `hyphenate-character` has said which character it is and
142
+ * there is nothing to guess; `auto` is a UA default and leaves two candidates,
143
+ * which the caller separates by measuring the ink.
144
+ *
145
+ * @returns {{chars: string[], declared: string, explicit: boolean}}
146
+ */
147
+ function hyphenCandidates(cs) {
148
+ const declared = cs.hyphenateCharacter || cs.webkitHyphenateCharacter || 'auto'
149
+ const explicit = cssString(declared)
150
+ if (explicit !== null) return { chars: [explicit], declared, explicit: true }
151
+ return { chars: AUTO_HYPHENS, declared, explicit: false }
152
+ }
153
+
154
+ /** `font-variant-caps` values that repaint letters as capitals. */
155
+ const SMALL_CAPS = /(small|petite)-caps|unicase/
156
+
157
+ /** Feature-detected once: `\p{...}` is a syntax error on engines without the flag. */
158
+ const EMOJI = (() => {
159
+ try { return new RegExp('\\p{Extended_Pictographic}|\\p{Regional_Indicator}', 'u') } catch { /* older engine */ }
160
+ try { return new RegExp('[\\u{1F000}-\\u{1FAFF}\\u{2600}-\\u{27BF}\\u{FE0F}]', 'u') } catch { return null }
161
+ })()
162
+
163
+ /**
164
+ * Off-screen, zero-height, out of flow and unpainted — the probe has to attach to
165
+ * a live element without moving one pixel of it. `white-space: pre` is what makes
166
+ * `innerText` hand the characters back one for one; `opacity` rather than
167
+ * `visibility`, because a `visibility: hidden` subtree is skipped by the very
168
+ * text iterator we are asking the question of.
169
+ */
170
+ const PROBE_CSS = 'position:absolute!important;left:0!important;top:0!important;' +
171
+ 'width:max-content!important;height:0!important;overflow:hidden!important;' +
172
+ 'opacity:0!important;pointer-events:none!important;white-space:pre!important;' +
173
+ 'margin:0!important;padding:0!important;border:0!important;contain:layout style!important'
174
+
175
+ // ——— per-capture caches ———
176
+
177
+ const FALLBACK_KEY = {}
178
+ const STATES = new WeakMap()
179
+
180
+ /**
181
+ * Caches keyed on the collect context, so one capture measures each font and each
182
+ * element once across every block. `ctx` is never mutated — an unknown object's
183
+ * key space belongs to whoever defined it.
184
+ *
185
+ * `root` is the capture root, and it bounds every walk that leaves the block:
186
+ * a transform above it is re-applied by nobody, and an `<a>` above it is still
187
+ * the link the text is inside of.
188
+ */
189
+ function stateFor(ctx) {
190
+ const key = ctx && typeof ctx === 'object' ? ctx : FALLBACK_KEY
191
+ let S = STATES.get(key)
192
+ if (!S) {
193
+ S = {
194
+ ctx2d: null,
195
+ metrics: new Map(),
196
+ styles: new WeakMap(),
197
+ css: new WeakMap(),
198
+ linear: new WeakMap(),
199
+ lang: new WeakMap(),
200
+ painted: new Map(),
201
+ segmenters: new Map(),
202
+ // Font resolution, all of it per capture: which family of a stack won, which
203
+ // families this machine has, the page's FontFaces by family, its @font-face
204
+ // rules, and what each embedded binary calls itself.
205
+ family: new Map(),
206
+ installed: new Map(),
207
+ nameTables: new Map(),
208
+ root: key.rootEl && key.rootEl.nodeType === 1 ? key.rootEl : null,
209
+ exclude: [].concat(key.exclude || []),
210
+ }
211
+ STATES.set(key, S)
212
+ }
213
+ return S
214
+ }
215
+
216
+ function styleOf(el, S) {
217
+ let cs = S.css.get(el)
218
+ if (!cs) { cs = getComputedStyle(el); S.css.set(el, cs) }
219
+ return cs
220
+ }
221
+
222
+ /**
223
+ * The INHERITED language of an element, not its own `lang` attribute.
224
+ *
225
+ * It keys the `text-transform` cache, and the difference is the whole point:
226
+ * `lang` is what makes `titi` upper-case to `TİTİ` in Turkish and `TITI` in
227
+ * English. Keying on `el.lang` — the attribute, usually absent on the `<p>` and
228
+ * present on a `<div>` three levels up — made two blocks with the same text and
229
+ * different languages share one answer, and whichever was measured first won.
230
+ */
231
+ function langOf(el, S) {
232
+ const hit = S.lang.get(el)
233
+ if (hit !== undefined) return hit
234
+ let lang = ''
235
+ for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
236
+ const own = n.getAttribute && (n.getAttribute('lang') || n.getAttribute('xml:lang'))
237
+ if (own) { lang = own; break }
238
+ }
239
+ const out = lang.toLowerCase()
240
+ S.lang.set(el, out)
241
+ return out
242
+ }
243
+
244
+ function px(value, fallback = 0) {
245
+ const n = parseFloat(value)
246
+ return Number.isFinite(n) ? n : fallback
247
+ }
248
+
249
+ function median(values) {
250
+ if (!values.length) return 0
251
+ const s = [...values].sort((a, b) => a - b)
252
+ const mid = s.length >> 1
253
+ return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2
254
+ }
255
+
256
+ // ——— font metrics ———
257
+
258
+ function measurer(S) {
259
+ if (!S.ctx2d) S.ctx2d = document.createElement('canvas').getContext('2d')
260
+ return S.ctx2d
261
+ }
262
+
263
+ /**
264
+ * The canvas font shorthand for a computed style. `font-stretch` is left out on
265
+ * purpose: Chromium computes it as a percentage, which the shorthand does not
266
+ * accept, and a rejected shorthand silently leaves the CONTEXT'S PREVIOUS FONT in
267
+ * place — the one failure mode that would poison every metric measured after it.
268
+ * The caller checks the round-trip rather than trust the assignment.
269
+ */
270
+ function fontShorthand(cs, size) {
271
+ const style = cs.fontStyle && cs.fontStyle !== 'normal' ? `${cs.fontStyle} ` : ''
272
+ return `${style}${cs.fontWeight || 400} ${size}px ${cs.fontFamily}`
273
+ }
274
+
275
+ /**
276
+ * Ascent and descent for one exact font string, cached on that string and never
277
+ * on the family: the size is part of the key because the numbers are not linear
278
+ * in it. `Hxg` spans a cap, an x-height and a descender, so a font whose
279
+ * `fontBoundingBox*` is missing still yields something usable from the ink box.
280
+ *
281
+ * `note` travels with the record so a degraded measurement is reported by EVERY
282
+ * block that uses the font, not only the first one to measure it.
283
+ */
284
+ function metricsOf(fontCss, size, S) {
285
+ const hit = S.metrics.get(fontCss)
286
+ if (hit) return hit
287
+
288
+ const c = measurer(S)
289
+ c.font = fontCss
290
+ let used = fontCss
291
+ let note = null
292
+ if (!c.font) {
293
+ // The context refused the shorthand and kept whatever it had. Fall back to a
294
+ // string it cannot refuse rather than silently measure the previous font.
295
+ used = `${size}px sans-serif`
296
+ c.font = used
297
+ note = `the canvas rejected the font shorthand "${fontCss}" — metrics were measured with a generic sans-serif`
298
+ }
299
+ const m = c.measureText('Hxg')
300
+ let ascent = m.fontBoundingBoxAscent
301
+ let descent = m.fontBoundingBoxDescent
302
+ if (!Number.isFinite(ascent) || !Number.isFinite(descent) || (!ascent && !descent)) {
303
+ ascent = Number.isFinite(m.actualBoundingBoxAscent) ? m.actualBoundingBoxAscent : size * 0.8
304
+ descent = Number.isFinite(m.actualBoundingBoxDescent) ? m.actualBoundingBoxDescent : size * 0.2
305
+ note = 'fontBoundingBox metrics unavailable — baselines derived from the ink box, accurate to about a pixel'
306
+ }
307
+ const out = { ascent, descent, fontCss: used, note }
308
+ S.metrics.set(fontCss, out)
309
+ return out
310
+ }
311
+
312
+ /**
313
+ * The advance of a short string in a run's font, in the block's own px.
314
+ *
315
+ * Safe to call after `metricsOf` and in any order: that function only assigns to
316
+ * the shared context on a cache MISS and reads nothing back later, so moving the
317
+ * font here cannot poison a cached metric. `letterSpacing` is added because
318
+ * Chromium adds it after every glyph, the painted hyphen included.
319
+ */
320
+ function advanceOf(text, style, S) {
321
+ const c = measurer(S)
322
+ c.font = style.fontCss
323
+ const m = c.measureText(text)
324
+ return m.width + (style.letterSpacing || 0) * [...text].length
325
+ }
326
+
327
+ // ——— text-transform ———
328
+
329
+ /**
330
+ * Does this transform have anything to do to this string? Only used to tell a
331
+ * probe that came back unchanged because there was nothing to change from one
332
+ * that came back unchanged because the browser never answered.
333
+ *
334
+ * Built through `new RegExp` for the same reason EMOJI is: a `\p{...}` LITERAL is
335
+ * a syntax error at parse time on an engine without the flag, which would take
336
+ * the whole module down instead of one warning.
337
+ */
338
+ const EXPECTS_CHANGE = (() => {
339
+ try { return { uppercase: new RegExp('\\p{Ll}', 'u'), lowercase: new RegExp('\\p{Lu}', 'u') } } catch { /* older engine */ }
340
+ return { uppercase: /[a-z]/, lowercase: /[A-Z]/ }
341
+ })()
342
+
343
+ /**
344
+ * The PAINTED form of a text node's data, read back from the browser instead of
345
+ * computed here.
346
+ *
347
+ * `text-transform: uppercase` is locale-dependent — Turkish dotless i, Greek
348
+ * final sigma — and `String.prototype.toUpperCase` does not know the element's
349
+ * language, so casing in JS would quietly report one string and paint another.
350
+ * The probe inherits the transform AND the language from the real parent, so the
351
+ * browser answers the only question it can answer. It is out of flow, zero-height
352
+ * and removed in a `finally`, so nothing observes it and nothing reflows around
353
+ * it; it is also only ever built for a node whose transform is not `none`.
354
+ *
355
+ * `prev` is the character the block has already painted before this node, and it
356
+ * is load-bearing: `capitalize` upcases the letter after a word boundary, so the
357
+ * same node reads "Wide" after a space and "wide" after a letter. Probing a node
358
+ * on its own answers as though it started the block — MEASURED, the first word of
359
+ * a `text-transform: capitalize` block came back lowercase, because the probe sat
360
+ * directly after the element's own text with nothing between. The character goes
361
+ * in front of the probe's text and comes straight back off the answer.
362
+ *
363
+ * Returns null when the answer is unusable, and the two ways it can be are
364
+ * different: no answer at all (a detached or unrendered subtree), or an answer of
365
+ * a different length. Length matters because every offset in this module reaches
366
+ * the DOM through a `Range` over the source string; ß -> SS would shift all of
367
+ * them onto a string that does not exist.
368
+ */
369
+ function paintedText(el, data, prev, S) {
370
+ // Every kind of white space is the same word boundary, and a real newline in
371
+ // the probe risks `innerText` normalizing it away — which would fail the length
372
+ // check below and throw away an answer that was going to be right.
373
+ const lead = prev && /\s/.test(prev) ? ' ' : (prev || '')
374
+ const key = `${styleOf(el, S).textTransform}\u0000${langOf(el, S)}\u0000${lead}\u0000${data}`
375
+ if (S.painted.has(key)) return S.painted.get(key)
376
+
377
+ let out = null
378
+ let probe = null
379
+ try {
380
+ probe = document.createElement('span')
381
+ probe.setAttribute('aria-hidden', 'true')
382
+ probe.style.cssText = PROBE_CSS
383
+ probe.textContent = lead + data
384
+ // FIRST child, not appended. Blink transforms a text node against the
385
+ // character before it in the inline formatting context — and being out of
386
+ // flow does not exempt the probe from that, MEASURED: appended last, the
387
+ // probe sat directly after the element's own text and the first word of a
388
+ // `capitalize` block came back lowercase. At the front, the only thing in
389
+ // front of it is `lead`, which is the character that really precedes it.
390
+ el.insertBefore(probe, el.firstChild)
391
+ const text = probe.innerText
392
+ if (typeof text === 'string' && text.length === lead.length + data.length) out = text.slice(lead.length)
393
+ } catch {
394
+ out = null
395
+ } finally {
396
+ if (probe && probe.parentNode) probe.parentNode.removeChild(probe)
397
+ }
398
+ S.painted.set(key, out)
399
+ return out
400
+ }
401
+
402
+ // ——— run styles ———
403
+
404
+ /**
405
+ * Whether a decoration set on this box's PARENT stops here instead of reaching
406
+ * its contents. Out-of-flow and floated boxes take no decoration from their
407
+ * parent, and neither do atomic inline-level boxes — an `inline-block` lays out
408
+ * its own text and an underline on the line it sits in does not cross into it.
409
+ * Internal table boxes are in the same family: the propagation the spec grants
410
+ * to in-flow BLOCK-LEVEL boxes does not reach a cell through the anonymous table
411
+ * wrappers between them.
412
+ */
413
+ function stopsDecoration(cs) {
414
+ if (!cs) return true
415
+ if (cs.float && cs.float !== 'none') return true
416
+ if (cs.position === 'absolute' || cs.position === 'fixed') return true
417
+ const display = cs.display || ''
418
+ return /^inline-|^-webkit-inline-/.test(display) || (/^table-/.test(display))
419
+ }
420
+
421
+ /**
422
+ * One `text-decoration` declaration, split into one entry per painted line.
423
+ * `underline line-through` is a single declaration and two lines, and a consumer
424
+ * that holds one decoration has to see both to know which one it is dropping.
425
+ */
426
+ function pushDecoration(out, cs) {
427
+ const line = cs.textDecorationLine || 'none'
428
+ if (!line || line === 'none') return
429
+ const color = parseColor(cs.textDecorationColor || cs.color)
430
+ const style = cs.textDecorationStyle || 'solid'
431
+ const thickness = cs.textDecorationThickness === 'auto' ? null : px(cs.textDecorationThickness, 0)
432
+ // `text-underline-offset` moves the underline off the position the font asks
433
+ // for. Not reporting it is not neutral: a backend that DRAWS the line rather
434
+ // than asking a renderer for it has to put it somewhere, and the only place it
435
+ // can put it is the default — measured, that left `.ty-wavy`'s wave 3px above
436
+ // the one Chromium paints, with the extent exactly right. `auto` stays null,
437
+ // which is the honest answer: the offset is then the font's, and the font's
438
+ // underline position is not in this document either.
439
+ const rawOffset = cs.textUnderlineOffset || 'auto'
440
+ const offset = rawOffset === 'auto' || rawOffset === 'normal' ? null : px(rawOffset, 0)
441
+ for (const one of String(line).trim().split(/\s+/)) {
442
+ if (!one || one === 'none') continue
443
+ out.push({
444
+ line: one,
445
+ style,
446
+ color: color ? color.rgba : null,
447
+ colorCss: color ? color.srcCss : (cs.textDecorationColor || ''),
448
+ thickness,
449
+ offset,
450
+ offsetCss: rawOffset,
451
+ })
452
+ }
453
+ }
454
+
455
+ /**
456
+ * `text-decoration` does not inherit — it PROPAGATES. A `<span>` inside an
457
+ * underlined `<a>` computes `text-decoration-line: none` and is still underlined,
458
+ * so the only way to know is to walk back up. Each ancestor draws with its own
459
+ * colour and thickness, which is why this is a list and not a value.
460
+ *
461
+ * The walk does NOT stop at the block: propagation reaches in-flow block-level
462
+ * boxes too, and `<a href><div>card</div></a>` — the linked-card pattern — is
463
+ * underlined by an anchor that sits above the block entirely. It stops at the
464
+ * first box that breaks propagation, and at the capture root, because a
465
+ * decoration set above the capture is not part of the capture.
466
+ *
467
+ * Order is innermost first: the decoration nearest the glyphs is `[0]`.
468
+ */
469
+ function decorationsOf(el, S) {
470
+ const out = []
471
+ const root = S.root
472
+ const top = typeof document !== 'undefined' ? document.documentElement : null
473
+ for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
474
+ const cs = styleOf(n, S)
475
+ pushDecoration(out, cs)
476
+ if (n === root || n === top) break
477
+ if (stopsDecoration(cs)) break
478
+ }
479
+ return out
480
+ }
481
+
482
+ /** The link the glyphs are inside of, from any depth up to the capture root. */
483
+ function hrefOf(el, S) {
484
+ const root = S.root
485
+ const top = typeof document !== 'undefined' ? document.documentElement : null
486
+ for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
487
+ if (n.tagName === 'A' && n.hasAttribute('href')) {
488
+ // SVG anchors expose href as an SVGAnimatedString, not a string.
489
+ return typeof n.href === 'string' ? n.href : (n.href && n.href.baseVal) || n.getAttribute('href')
490
+ }
491
+ if (n === root || n === top) break
492
+ }
493
+ return null
494
+ }
495
+
496
+ // ——— which family of the stack actually painted ———
497
+
498
+ /**
499
+ * A CSS font stack is a list of WISHES and a font picker outside this machine
500
+ * cannot read it: it is handed `-apple-system, "Segoe UI", system-ui, sans-serif`,
501
+ * looks for one family of that name, finds nothing and substitutes. MEASURED
502
+ * pasting into Figma (`FIGMA_FINDINGS.md` §5): the substitution alone widens a
503
+ * line by 6.1%.
504
+ *
505
+ * So the family that WON in this browser is resolved here and emitted first, with
506
+ * the page's stack kept behind it as the fallback it always was. Three sources,
507
+ * in the order a browser itself would consult them:
508
+ *
509
+ * 1. `document.fonts` — a loaded `@font-face` whose family, style and
510
+ * `unicode-range` cover the text is what the browser used, full stop.
511
+ * 2. the `name` table of that face's BINARY, when the rule carries the bytes
512
+ * inline. This is the classic mismatch: the page declares `"Inter var"` and
513
+ * the file says `Inter` / `SemiBold`, and only the second pair exists in a
514
+ * font picker. nameID 16/17 (typographic family/subfamily) with 1/2 behind.
515
+ * 3. advance matching — the isolated candidate against the whole stack, over the
516
+ * block's own text. A family that is on this machine AND reproduces the
517
+ * stack's advances to within 0.01% is the one the browser picked. Both sides
518
+ * are measured at the same probe size, not the run's, so the comparison is
519
+ * self-consistent and the difference between two faces is as large as it gets.
520
+ *
521
+ * Nothing is emitted on a guess: a family that fails (3) is declared instead,
522
+ * because forcing the wrong name on a font picker is worse than letting it
523
+ * substitute — the substitution is at least visible to whoever pastes.
524
+ */
525
+
526
+ /** Big enough that two different faces cannot agree on an advance by rounding. */
527
+ const FAMILY_PROBE_SIZE = 72
528
+
529
+ /** Advance difference, in px at `FAMILY_PROBE_SIZE`, that means "a different face". */
530
+ const FAMILY_EPS = 0.05
531
+
532
+ /** Relative tolerance when the two advances compared are the block's own text. */
533
+ const FAMILY_REL = 1e-4
534
+
535
+ /** The generics a candidate is probed against; a real face matches at most two by accident. */
536
+ const FAMILY_BASELINES = ['monospace', 'serif', 'sans-serif']
537
+
538
+ /** Wide, narrow, round and flat: any two faces disagree on the sum of these. */
539
+ const INSTALLED_PROBE = 'mmmMWWiil1I0OQ@gj'
540
+
541
+ /** Of the block's own text, the most that is worth measuring an advance over. */
542
+ const FAMILY_PROBE_MAX = 64
543
+
544
+ /** A font shorthand no caller passes, so a rejected assignment is detectable. */
545
+ const FONT_SENTINEL = '1px serif'
546
+
547
+ /**
548
+ * Families CSS resolves by itself. Two different kinds live here and the
549
+ * difference is the whole point of the diagnostic below: a GENERIC (`sans-serif`)
550
+ * names a class and every viewer has one, while a SYSTEM keyword (`-apple-system`,
551
+ * `system-ui`) names a real face — San Francisco on a Mac — that nothing outside
552
+ * this machine can ask for by name.
553
+ */
554
+ const SYSTEM_FAMILIES = new Set([
555
+ '-apple-system', 'blinkmacsystemfont', 'system-ui', 'ui-serif', 'ui-sans-serif',
556
+ 'ui-monospace', 'ui-rounded',
557
+ ])
558
+
559
+ const GENERIC_FAMILIES = new Set([
560
+ 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'math', 'emoji', 'fangsong',
561
+ ...SYSTEM_FAMILIES,
562
+ ])
563
+
564
+ /** The generic class a family belongs to, for a consumer estimating a substitution. */
565
+ const FAMILY_CLASS = {
566
+ 'ui-serif': 'serif', 'ui-monospace': 'monospace', 'ui-sans-serif': 'sans-serif',
567
+ 'system-ui': 'sans-serif', '-apple-system': 'sans-serif', blinkmacsystemfont: 'sans-serif',
568
+ 'ui-rounded': 'sans-serif',
569
+ }
570
+
571
+ /** An identifier CSS accepts unquoted; anything else has to be a `<string>`. */
572
+ const BARE_FAMILY = /^-?[A-Za-z_][A-Za-z0-9_-]*$/
573
+
574
+ /** `"Inter var", system-ui, sans-serif` -> `['Inter var', 'system-ui', 'sans-serif']`. */
575
+ function splitStack(stack) {
576
+ const out = []
577
+ let buf = ''
578
+ let quote = ''
579
+ for (const ch of String(stack == null ? '' : stack)) {
580
+ if (quote) {
581
+ if (ch === quote) quote = ''
582
+ else buf += ch
583
+ continue
584
+ }
585
+ if (ch === '"' || ch === "'") { quote = ch; continue }
586
+ if (ch === ',') {
587
+ const name = buf.trim()
588
+ if (name) out.push(name)
589
+ buf = ''
590
+ continue
591
+ }
592
+ buf += ch
593
+ }
594
+ const last = buf.trim()
595
+ if (last) out.push(last)
596
+ return out
597
+ }
598
+
599
+ function quoteFamily(name) {
600
+ const clean = String(name == null ? '' : name).trim()
601
+ return BARE_FAMILY.test(clean) ? clean : `"${clean.replace(/["\\]/g, '')}"`
602
+ }
603
+
604
+ /**
605
+ * Assign a font shorthand and say whether the context TOOK it. A rejected
606
+ * shorthand leaves the previous font in place, so a width measured after one
607
+ * describes a font nobody asked for — the single failure mode that would poison
608
+ * every comparison below.
609
+ */
610
+ function setFont(c, css) {
611
+ c.font = FONT_SENTINEL
612
+ c.font = css
613
+ return c.font !== FONT_SENTINEL
614
+ }
615
+
616
+ /** The advance of `text` in one font shorthand, or null when the shorthand was refused. */
617
+ function widthOf(css, text, S) {
618
+ const c = measurer(S)
619
+ if (!setFont(c, css)) return null
620
+ return c.measureText(text).width
621
+ }
622
+
623
+ /**
624
+ * Is this family on the machine at all?
625
+ *
626
+ * The probe is the classic one and its limits are known: a family is measured
627
+ * against three generics, and it counts as present when it disagrees with any of
628
+ * them. A face that is metrically identical to all three cannot be told from a
629
+ * fallback this way — which is exactly why presence alone never decides the
630
+ * winner, and the advance of the whole stack has to agree as well.
631
+ */
632
+ function isInstalled(name, S) {
633
+ const key = name.toLowerCase()
634
+ const hit = S.installed.get(key)
635
+ if (hit !== undefined) return hit
636
+ let out = false
637
+ if (GENERIC_FAMILIES.has(key)) {
638
+ out = true
639
+ } else {
640
+ const q = quoteFamily(name)
641
+ for (const base of FAMILY_BASELINES) {
642
+ const alone = widthOf(`${FAMILY_PROBE_SIZE}px ${base}`, INSTALLED_PROBE, S)
643
+ const with_ = widthOf(`${FAMILY_PROBE_SIZE}px ${q}, ${base}`, INSTALLED_PROBE, S)
644
+ if (alone === null || with_ === null) continue
645
+ if (Math.abs(alone - with_) > FAMILY_EPS) { out = true; break }
646
+ }
647
+ }
648
+ S.installed.set(key, out)
649
+ return out
650
+ }
651
+
652
+ // ——— document.fonts ———
653
+
654
+ // Exported for `collect/font.js`, the asynchronous other half of font resolution:
655
+ // it matches rules and reads binaries with the SAME arithmetic this module uses,
656
+ // because two copies of `weightRange` is how a bold run gets a regular file.
657
+ export const unquoteFamily = (v) => String(v == null ? '' : v).trim().replace(/^["']|["']$/g, '')
658
+
659
+ /** Every `FontFace` the page has, by lower-cased family. Null when there is no API. */
660
+ function facesByFamily(S) {
661
+ if (S.faces !== undefined) return S.faces
662
+ const set = typeof document !== 'undefined' ? document.fonts : null
663
+ if (!set || typeof set.forEach !== 'function') {
664
+ S.faces = null
665
+ return null
666
+ }
667
+ const out = new Map()
668
+ try {
669
+ set.forEach((face) => {
670
+ const family = unquoteFamily(face && face.family)
671
+ if (!family) return
672
+ const key = family.toLowerCase()
673
+ if (!out.has(key)) out.set(key, [])
674
+ out.get(key).push(face)
675
+ })
676
+ } catch {
677
+ S.faces = null
678
+ return null
679
+ }
680
+ S.faces = out
681
+ return out
682
+ }
683
+
684
+ /** `U+0-10FFFF, U+4??` -> `[[lo, hi], …]`. Null means "everything", which is the default. */
685
+ export function parseUnicodeRange(value) {
686
+ const s = String(value == null ? '' : value).trim()
687
+ if (!s || s === 'U+0-10FFFF' || s === 'u+0-10ffff') return null
688
+ const out = []
689
+ for (const part of s.split(',')) {
690
+ const m = /^u\+([0-9a-f?]{1,6})(?:-([0-9a-f]{1,6}))?$/i.exec(part.trim())
691
+ if (!m) return null
692
+ if (m[2]) { out.push([parseInt(m[1], 16), parseInt(m[2], 16)]); continue }
693
+ if (m[1].includes('?')) {
694
+ out.push([parseInt(m[1].replace(/\?/g, '0'), 16), parseInt(m[1].replace(/\?/g, 'F'), 16)])
695
+ continue
696
+ }
697
+ const one = parseInt(m[1], 16)
698
+ out.push([one, one])
699
+ }
700
+ return out.length ? out : null
701
+ }
702
+
703
+ /** Does this face cover any of the code points the block paints in it? */
704
+ function coversAny(face, points) {
705
+ const ranges = parseUnicodeRange(face && face.unicodeRange)
706
+ if (!ranges) return true
707
+ for (const cp of points) {
708
+ for (const [lo, hi] of ranges) if (cp >= lo && cp <= hi) return true
709
+ }
710
+ return false
711
+ }
712
+
713
+ /** `700`, `bold`, `100 900` -> `[min, max]`. */
714
+ export function weightRange(value) {
715
+ const s = String(value == null ? '' : value).trim().toLowerCase()
716
+ if (!s || s === 'normal') return [400, 400]
717
+ if (s === 'bold') return [700, 700]
718
+ const parts = s.split(/\s+/).map(Number).filter(Number.isFinite)
719
+ if (!parts.length) return [400, 400]
720
+ return [parts[0], parts.length > 1 ? parts[1] : parts[0]]
721
+ }
722
+
723
+ /**
724
+ * The face of a family the browser would use for this weight and style, or null
725
+ * when the family has none that covers the text.
726
+ *
727
+ * Weight does NOT decide whether the FAMILY wins — a family with one 400 face
728
+ * still serves a 700 request, synthesised — so it only picks among the faces that
729
+ * are there. Nearest weight, style first, exactly as CSS font matching orders it.
730
+ */
731
+ function pickFace(faces, weight, italic, points) {
732
+ let best = null
733
+ let bestScore = -Infinity
734
+ for (const face of faces) {
735
+ if (face.status && face.status !== 'loaded') continue
736
+ if (!coversAny(face, points)) continue
737
+ const faceItalic = /^(italic|oblique)/.test(String(face.style || 'normal'))
738
+ const [lo, hi] = weightRange(face.weight)
739
+ const distance = weight < lo ? lo - weight : weight > hi ? weight - hi : 0
740
+ const score = (faceItalic === italic ? 1000 : 0) - distance
741
+ if (score > bestScore) { bestScore = score; best = face }
742
+ }
743
+ return best
744
+ }
745
+
746
+ // ——— the `name` table of the binary ———
747
+
748
+ /**
749
+ * The bytes of a `data:` URI, or null when the URI carries none we can read
750
+ * synchronously. Only base64 is decoded: a percent-encoded font is legal and
751
+ * nobody writes one, and guessing wrong would hand the parser noise.
752
+ */
753
+ export function dataUriBytes(uri) {
754
+ const m = /^data:([^;,]*)((?:;[^;,]*)*),/i.exec(uri)
755
+ if (!m || !/;base64/i.test(m[2] || '')) return null
756
+ try {
757
+ const binary = atob(uri.slice(m[0].length))
758
+ const bytes = new Uint8Array(binary.length)
759
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
760
+ return bytes
761
+ } catch {
762
+ return null
763
+ }
764
+ }
765
+
766
+ /** Every `@font-face` rule of the document, including the ones nested in `@media`. */
767
+ function fontFaceRules(S) {
768
+ if (S.faceRules !== undefined) return S.faceRules
769
+ const out = []
770
+ const sheets = typeof document !== 'undefined' && document.styleSheets ? document.styleSheets : []
771
+ const walk = (rules) => {
772
+ for (const rule of rules) {
773
+ // 5 is CSSFontFaceRule, 4 @media, 12 @supports, 13 @layer. `constructor.name`
774
+ // is not reliable across engines and the numeric types are.
775
+ if (rule.type === 5 || (rule.style && typeof rule.cssText === 'string' && /^@font-face/.test(rule.cssText))) {
776
+ out.push(rule)
777
+ continue
778
+ }
779
+ if (rule.cssRules) {
780
+ try { walk(rule.cssRules) } catch { /* cross-origin */ }
781
+ }
782
+ }
783
+ }
784
+ for (const sheet of sheets) {
785
+ try { walk(sheet.cssRules) } catch { /* a cross-origin sheet exposes no rules */ }
786
+ }
787
+ S.faceRules = out
788
+ return out
789
+ }
790
+
791
+ const NAME_PLATFORM_SCORE = { 3: 10, 0: 8, 1: 6 }
792
+
793
+ function readTag(view, at) {
794
+ return String.fromCharCode(view.getUint8(at), view.getUint8(at + 1), view.getUint8(at + 2), view.getUint8(at + 3))
795
+ }
796
+
797
+ /** The `name` table of a bare SFNT (`.ttf`, `.otf`, `.ttc`). */
798
+ function sfntTable(view, want) {
799
+ let base = 0
800
+ if (readTag(view, 0) === 'ttcf') base = view.getUint32(12)
801
+ const count = view.getUint16(base + 4)
802
+ for (let i = 0; i < count; i++) {
803
+ const at = base + 12 + 16 * i
804
+ if (readTag(view, at) !== want) continue
805
+ return { offset: view.getUint32(at + 8), length: view.getUint32(at + 12) }
806
+ }
807
+ return null
808
+ }
809
+
810
+ /**
811
+ * The `name` table of a WOFF1. Its tables are individually zlib-compressed and a
812
+ * table that did not shrink is stored raw — which the `name` table, being short
813
+ * and full of UTF-16 padding, very often is. When it is not, we say so rather
814
+ * than pull in an inflater.
815
+ */
816
+ function woffTable(view, want) {
817
+ const count = view.getUint16(12)
818
+ for (let i = 0; i < count; i++) {
819
+ const at = 44 + 20 * i
820
+ if (readTag(view, at) !== want) continue
821
+ const offset = view.getUint32(at + 4)
822
+ const comp = view.getUint32(at + 8)
823
+ const orig = view.getUint32(at + 12)
824
+ if (comp !== orig) {
825
+ return { error: 'its `name` table is zlib-compressed inside the WOFF and nothing in the platform inflates it synchronously' }
826
+ }
827
+ return { offset, length: comp }
828
+ }
829
+ return null
830
+ }
831
+
832
+ function readNameString(view, at, length, platform) {
833
+ let out = ''
834
+ if (platform === 1) {
835
+ for (let i = 0; i < length; i++) out += String.fromCharCode(view.getUint8(at + i))
836
+ } else {
837
+ for (let i = 0; i + 1 < length; i += 2) out += String.fromCharCode(view.getUint16(at + i))
838
+ }
839
+ return out.replace(/\u0000/g, '').trim()
840
+ }
841
+
842
+ /**
843
+ * nameID 16/17 (typographic family and subfamily) with 1/2 behind them, English
844
+ * preferred. The pair is what a font picker lists: the page's `"Inter var"` is a
845
+ * CSS name the file never claims, and `Inter` / `SemiBold` is what the binary
846
+ * says it is.
847
+ *
848
+ * @returns {{family: string|null, sub: string|null}|{error: string}}
849
+ */
850
+ export function parseFontNames(bytes) {
851
+ try {
852
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
853
+ const tag = readTag(view, 0)
854
+ if (tag === 'wOF2') {
855
+ return { error: 'it is WOFF2, whose every table is Brotli-compressed; nothing in the platform decompresses that synchronously' }
856
+ }
857
+ const table = tag === 'wOFF' ? woffTable(view, 'name') : sfntTable(view, 'name')
858
+ if (!table) return { error: 'it carries no `name` table' }
859
+ if (table.error) return table
860
+
861
+ const at = table.offset
862
+ const count = view.getUint16(at + 2)
863
+ const strings = at + view.getUint16(at + 4)
864
+ const best = { family: null, sub: null }
865
+ const score = { family: -Infinity, sub: -Infinity }
866
+ for (let i = 0; i < count; i++) {
867
+ const rec = at + 6 + 12 * i
868
+ const platform = view.getUint16(rec)
869
+ const language = view.getUint16(rec + 4)
870
+ const nameId = view.getUint16(rec + 6)
871
+ const length = view.getUint16(rec + 8)
872
+ const offset = view.getUint16(rec + 10)
873
+ let key = null
874
+ let rank = 0
875
+ if (nameId === 16 || nameId === 1) { key = 'family'; rank = nameId === 16 ? 100 : 0 }
876
+ else if (nameId === 17 || nameId === 2) { key = 'sub'; rank = nameId === 17 ? 100 : 0 }
877
+ if (!key) continue
878
+ const english = (platform === 3 && language === 0x0409) || (platform === 1 && language === 0) || platform === 0
879
+ const total = rank + (english ? 20 : 0) + (NAME_PLATFORM_SCORE[platform] || 0)
880
+ if (total <= score[key]) continue
881
+ const value = readNameString(view, strings + offset, length, platform)
882
+ if (!value) continue
883
+ score[key] = total
884
+ best[key] = value
885
+ }
886
+ if (!best.family && !best.sub) return { error: 'its `name` table holds no family record' }
887
+ return best
888
+ } catch (error) {
889
+ return { error: `its binary could not be read (${error && error.message})` }
890
+ }
891
+ }
892
+
893
+ /**
894
+ * What the binary behind an `@font-face` family calls itself, when the rule
895
+ * carries the bytes inline. A `url()` pointing at a file is deliberately not
896
+ * fetched: this collector is synchronous, and a family resolved on the next tick
897
+ * is a family the emitters have already written.
898
+ *
899
+ * @returns {{family: string|null, sub: string|null}|{error: string}|null}
900
+ */
901
+ function nameTableFor(cssFamily, weight, italic, S) {
902
+ const key = `${cssFamily.toLowerCase()}${weight}${italic}`
903
+ const hit = S.nameTables.get(key)
904
+ if (hit !== undefined) return hit
905
+
906
+ // One family is several rules — 400, 700, italic — and they are different
907
+ // FILES with different `name` tables. Reading whichever comes first would hand
908
+ // a bold run the subfamily of the regular file, so the rules are ordered the
909
+ // way CSS orders faces: style first, then nearest weight.
910
+ const rules = []
911
+ for (const rule of fontFaceRules(S)) {
912
+ let declared = ''
913
+ let src = ''
914
+ let w = ''
915
+ let style = ''
916
+ try {
917
+ declared = unquoteFamily(rule.style.getPropertyValue('font-family'))
918
+ src = rule.style.getPropertyValue('src') || ''
919
+ w = rule.style.getPropertyValue('font-weight') || ''
920
+ style = rule.style.getPropertyValue('font-style') || ''
921
+ } catch { continue }
922
+ if (declared.toLowerCase() !== cssFamily.toLowerCase()) continue
923
+ const [lo, hi] = weightRange(w)
924
+ const distance = weight < lo ? lo - weight : weight > hi ? weight - hi : 0
925
+ const ruleItalic = /^(italic|oblique)/.test(style || 'normal')
926
+ rules.push({ src, score: (ruleItalic === italic ? 1000 : 0) - distance })
927
+ }
928
+ rules.sort((a, b) => b.score - a.score)
929
+
930
+ let out = null
931
+ for (const rule of rules) {
932
+ const m = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^)\s]*))\s*\)/i.exec(rule.src)
933
+ const url = m ? (m[1] || m[2] || m[3] || '') : ''
934
+ if (!/^data:/i.test(url)) {
935
+ out = out || { error: 'its `@font-face` rule points at a file rather than carrying the bytes inline, and this collector is synchronous' }
936
+ continue
937
+ }
938
+ const bytes = dataUriBytes(url)
939
+ if (!bytes) {
940
+ out = out || { error: 'its `@font-face` rule carries a data URI this build cannot decode (only base64 is read)' }
941
+ continue
942
+ }
943
+ const names = parseFontNames(bytes)
944
+ if (!names.error) { out = names; break }
945
+ out = out || names
946
+ }
947
+ S.nameTables.set(key, out)
948
+ return out
949
+ }
950
+
951
+ // ——— the resolution ———
952
+
953
+ /**
954
+ * @typedef {object} FamilyResolution
955
+ * @property {string} family the name to emit — the binary's when it was readable
956
+ * @property {string} cssFamily the family OF THE STACK that won, before any rename
957
+ * @property {string} stack the emitted stack: resolved family first, page's behind
958
+ * @property {string} source 'fontface' | 'name-table' | 'metrics' | 'generic' | 'unresolved'
959
+ * @property {string|null} styleName the binary's typographic subfamily (`SemiBold`)
960
+ * @property {string} genericClass 'sans-serif' | 'serif' | 'monospace' | …
961
+ * @property {boolean} referenceable false when the winner is a system face nobody can name
962
+ * @property {boolean} webfont the page supplied it with `@font-face`
963
+ * @property {boolean} verified the isolated family reproduces the whole stack's advances
964
+ */
965
+
966
+ /** Up to `FAMILY_PROBE_MAX` characters of what this element actually paints. */
967
+ function familyProbe(el) {
968
+ const text = (el && typeof el.textContent === 'string' ? el.textContent : '').replace(/\s+/g, ' ').trim()
969
+ return text ? text.slice(0, FAMILY_PROBE_MAX) : INSTALLED_PROBE
970
+ }
971
+
972
+ /**
973
+ * Which family of `cs.fontFamily` this browser actually painted with.
974
+ *
975
+ * @param {CSSStyleDeclaration|Record<string,string>} cs
976
+ * @param {Element} el the element the characters live in — its text is the probe
977
+ * @returns {FamilyResolution}
978
+ */
979
+ function resolveFamily(cs, el, S, warn) {
980
+ const stack = String(cs.fontFamily || '')
981
+ const families = splitStack(stack)
982
+ const first = families[0] || 'sans-serif'
983
+ const weight = parseInt(cs.fontWeight, 10) || (cs.fontWeight === 'bold' ? 700 : 400)
984
+ const italic = /^(italic|oblique)/.test(cs.fontStyle || '')
985
+ const probe = familyProbe(el)
986
+ const key = `${stack}\u0000${weight}\u0000${italic}\u0000${probe}`
987
+ const hit = S.family.get(key)
988
+ if (hit) {
989
+ if (hit.note) warn(hit.note)
990
+ return hit
991
+ }
992
+
993
+ const points = [...new Set([...probe])].map((ch) => ch.codePointAt(0))
994
+ const size = FAMILY_PROBE_SIZE
995
+ const head = `${italic ? 'italic ' : ''}${weight} ${size}px `
996
+ const stackWidth = widthOf(head + stack, probe, S)
997
+ const matches = (family) => {
998
+ if (stackWidth === null) return false
999
+ const w = widthOf(head + quoteFamily(family), probe, S)
1000
+ return w !== null && Math.abs(w - stackWidth) <= Math.max(FAMILY_EPS, FAMILY_REL * stackWidth)
1001
+ }
1002
+
1003
+ const faces = facesByFamily(S)
1004
+ let out = null
1005
+ let note = null
1006
+ /** A family that is present but does not reproduce the stack — kept for the message. */
1007
+ const nearMiss = []
1008
+
1009
+ for (const family of families) {
1010
+ const lower = family.toLowerCase()
1011
+ const list = faces ? faces.get(lower) : null
1012
+ const face = list && list.length ? pickFace(list, weight, italic, points) : null
1013
+ if (face) {
1014
+ // Step 2: the binary renames it wherever the page's CSS name is not the
1015
+ // name the file claims — `"Inter var"` -> `Inter` / `SemiBold`.
1016
+ const names = nameTableFor(family, weight, italic, S)
1017
+ // A variable face covers a RANGE of weights and its `name` table names one
1018
+ // instance of it — `Inter` / `Regular` for a file that also paints 700. The
1019
+ // family is still right; the subfamily would be a lie, so it is dropped and
1020
+ // the consumer derives the style from the weight it already has.
1021
+ const [lo, hi] = weightRange(face.weight)
1022
+ const variable = lo !== hi
1023
+ const renamed = names && !names.error && names.family ? names.family : null
1024
+ if (names && names.error) {
1025
+ note = {
1026
+ code: 'collect.text-font-name-unread',
1027
+ grade: 'A',
1028
+ severity: 'warn',
1029
+ message: `"${family}" is served by an @font-face rule of this page and the name its own binary ` +
1030
+ `claims could not be read: ${names.error}. The export therefore carries the CSS family name, ` +
1031
+ 'which is a name the page invented and the file may not answer to — a font picker that lists ' +
1032
+ 'fonts by what their `name` table says (Figma does) will not find it and will substitute.',
1033
+ }
1034
+ }
1035
+ out = {
1036
+ family: renamed || family,
1037
+ cssFamily: family,
1038
+ source: renamed ? 'name-table' : 'fontface',
1039
+ styleName: !variable && names && !names.error && names.sub ? names.sub : null,
1040
+ webfont: true,
1041
+ verified: matches(family),
1042
+ }
1043
+ break
1044
+ }
1045
+ if (GENERIC_FAMILIES.has(lower)) {
1046
+ out = { family, cssFamily: family, source: 'generic', styleName: null, webfont: false, verified: true }
1047
+ break
1048
+ }
1049
+ if (!isInstalled(family, S)) continue
1050
+ if (!matches(family)) { nearMiss.push(family); continue }
1051
+ out = { family, cssFamily: family, source: 'metrics', styleName: null, webfont: false, verified: true }
1052
+ break
1053
+ }
1054
+
1055
+ if (!out) {
1056
+ out = {
1057
+ family: first,
1058
+ cssFamily: first,
1059
+ source: 'unresolved',
1060
+ styleName: null,
1061
+ webfont: false,
1062
+ verified: false,
1063
+ }
1064
+ note = {
1065
+ code: 'collect.text-family-unresolved',
1066
+ grade: 'A',
1067
+ severity: 'warn',
1068
+ message: `the family that painted this text could not be identified inside the stack ${stack}: ` +
1069
+ (nearMiss.length
1070
+ ? `${nearMiss.join(', ')} ${nearMiss.length > 1 ? 'are' : 'is'} on this machine but ` +
1071
+ `${nearMiss.length > 1 ? 'none of them reproduces' : 'it does not reproduce'} the advances the ` +
1072
+ 'whole stack measures'
1073
+ : 'no family in it is on this machine and none reproduces the advances the whole stack measures') +
1074
+ '. The stack is emitted exactly as the page wrote it, so a font picker that cannot read a stack ' +
1075
+ 'substitutes — which is what it would have done anyway, and is now said out loud instead of guessed at.',
1076
+ }
1077
+ }
1078
+
1079
+ const lower = out.family.toLowerCase()
1080
+ out.generic = GENERIC_FAMILIES.has(lower)
1081
+ out.system = SYSTEM_FAMILIES.has(lower)
1082
+ // A system keyword names a REAL face — San Francisco, Segoe UI — that only this
1083
+ // machine can call by that name; a generic names a class every viewer resolves
1084
+ // to something of its own. Only the first of the two is a face the export can
1085
+ // neither carry nor name, which is what `referenceable` is asked about.
1086
+ out.referenceable = !out.system
1087
+ out.genericClass = FAMILY_CLASS[lower] || (GENERIC_FAMILIES.has(lower) ? out.family : null) ||
1088
+ familyClassOf(families)
1089
+ // Only a resolution that reproduced the stack's own advances is put in FRONT of
1090
+ // it. Everything else leaves the page's stack exactly as it was: a name forced
1091
+ // on a font picker is a substitution nobody can see, and a stack it cannot read
1092
+ // is one it substitutes for visibly. A generic never goes in front either — it
1093
+ // would beat a family the destination may well have.
1094
+ const wins = out.verified && !out.generic &&
1095
+ unquoteFamily(first).toLowerCase() !== out.family.toLowerCase()
1096
+ out.stack = wins ? `${quoteFamily(out.family)}, ${stack}` : stack
1097
+ out.note = note
1098
+
1099
+ S.family.set(key, out)
1100
+ if (note) warn(note)
1101
+ return out
1102
+ }
1103
+
1104
+ /** How the winner was decided, in words, for the diagnostic that reports it. */
1105
+ const RESOLVED_BY = {
1106
+ fontface: 'an @font-face rule of this page declares it and the face is loaded',
1107
+ 'name-table': 'an @font-face rule of this page declares it, and this is the name its own binary claims ' +
1108
+ '(the `name` table, ids 16/17) — which is the name a font picker lists and the CSS name is not',
1109
+ metrics: 'it is the first family of the stack that is on this machine AND reproduces the advances the ' +
1110
+ 'whole stack measures, character for character',
1111
+ generic: 'it is the generic the stack falls through to',
1112
+ unresolved: 'nothing in the stack could be identified',
1113
+ }
1114
+
1115
+ /** The UI face a system keyword names here, when the platform is one we can name. */
1116
+ function systemFaceName() {
1117
+ const ua = typeof navigator !== 'undefined' ? String(navigator.userAgent || '') : ''
1118
+ if (/Mac OS X|Macintosh/.test(ua)) return 'San Francisco, which this machine reports as macOS'
1119
+ if (/Windows/.test(ua)) return 'Segoe UI, which this machine reports as Windows'
1120
+ if (/Android/.test(ua)) return 'Roboto, which this machine reports as Android'
1121
+ return null
1122
+ }
1123
+
1124
+ /**
1125
+ * What the export will say the font is, and what the destination will make of it.
1126
+ *
1127
+ * Two different pieces of news, and conflating them would hide the actionable one:
1128
+ * a family that was resolved and put in front of the stack is a WIN and is stated
1129
+ * so a reader can see it happened, while a family that is a system keyword is a
1130
+ * dead end — `-apple-system` is not a font name, it is this machine's word for its
1131
+ * own UI face, and no picker anywhere can be handed it.
1132
+ */
1133
+ function declareFamilies(styles, warn) {
1134
+ // Named by FAMILY and never by style id: the ids in `styles` are local to this
1135
+ // block, and the document renumbers them — a message naming `ts_0` would name a
1136
+ // different style in the document it ends up in.
1137
+ const promoted = new Set()
1138
+ const unnameable = new Set()
1139
+ const by = new Set()
1140
+ for (const id of Object.keys(styles)) {
1141
+ const st = styles[id]
1142
+ if (!st || typeof st.family !== 'string') continue
1143
+ if (st.fontFamily !== st.stackCss) {
1144
+ promoted.add(`${st.cssFamily} → ${st.family}${st.resolvedStyleName ? ` / ${st.resolvedStyleName}` : ''}`)
1145
+ by.add(st.resolvedFrom)
1146
+ }
1147
+ if (st.referenceable === false) unnameable.add(st.family)
1148
+ }
1149
+
1150
+ if (promoted.size) {
1151
+ warn({
1152
+ code: 'collect.text-family-resolved',
1153
+ grade: 'E',
1154
+ severity: 'info',
1155
+ message: 'the family that actually painted was resolved and is emitted IN FRONT of the page\'s stack — ' +
1156
+ `${[...promoted].join(', ')}. A font picker does not resolve a CSS stack: it takes one name, fails to ` +
1157
+ 'find it and substitutes, which measures 6.1% wider in Figma. The stack stays behind the resolved name ' +
1158
+ `as the fallback it always was. Resolved because ${[...by].map((s) => RESOLVED_BY[s] || s).join('; ')}.`,
1159
+ })
1160
+ }
1161
+ if (unnameable.size) {
1162
+ const face = systemFaceName()
1163
+ warn({
1164
+ code: 'collect.text-family-system',
1165
+ grade: 'A',
1166
+ severity: 'warn',
1167
+ message: `the family that painted this text (${[...unnameable].join(', ')}) is a system keyword, and a ` +
1168
+ 'system keyword is not a font name: it is this machine\'s word for its own UI face' +
1169
+ (face ? ` — ${face} — ` : ' ') +
1170
+ 'and no font picker anywhere can be asked for it by name. There is nothing to resolve and nothing to ' +
1171
+ 'emit: the export carries the stack as the page wrote it and the destination substitutes. MEASURED ' +
1172
+ 'pasting into Figma, that substitution alone makes a line 6.1% wider, and everything anchored to the ' +
1173
+ 'end of a line moves with it. The only real fix is embedding the binary, which v0 does not do.',
1174
+ })
1175
+ }
1176
+ }
1177
+
1178
+ /** The generic the stack itself names, which is the class a substitute is drawn from. */
1179
+ function familyClassOf(families) {
1180
+ for (let i = families.length - 1; i >= 0; i--) {
1181
+ const lower = families[i].toLowerCase()
1182
+ if (FAMILY_CLASS[lower]) return FAMILY_CLASS[lower]
1183
+ if (GENERIC_FAMILIES.has(lower)) return lower
1184
+ }
1185
+ return 'sans-serif'
1186
+ }
1187
+
1188
+ /**
1189
+ * Every computed property a run style is built from. `::first-letter` overlays a
1190
+ * few of them onto the style of the element the letter actually lives in, and
1191
+ * this is the list that overlay has to cover.
1192
+ */
1193
+ const STYLE_PROPS = [
1194
+ 'fontStyle', 'fontWeight', 'fontFamily', 'fontSize', 'fontStretch', 'fontVariant', 'fontVariantCaps',
1195
+ 'fontFeatureSettings', 'fontVariationSettings', 'letterSpacing', 'wordSpacing', 'lineHeight',
1196
+ 'color', 'webkitTextFillColor', 'webkitTextStrokeWidth', 'webkitTextStrokeColor', 'textTransform',
1197
+ ]
1198
+
1199
+ /**
1200
+ * Everything that makes one stretch of characters a different run. Cached per
1201
+ * element, because a text node's style is entirely its parent's and the same
1202
+ * `<em>` is asked about once per text node inside it.
1203
+ */
1204
+ function textStyleOf(el, blockEl, S, warn) {
1205
+ const hit = S.styles.get(el)
1206
+ if (hit && hit.blockEl === blockEl) {
1207
+ if (hit.note) warn(hit.note)
1208
+ return hit
1209
+ }
1210
+ const style = buildTextStyle(el, styleOf(el, S), blockEl, S, warn)
1211
+ S.styles.set(el, style)
1212
+ return style
1213
+ }
1214
+
1215
+ /**
1216
+ * @param {Element} el the element the characters live in — it owns the
1217
+ * decorations and the link, which are not properties
1218
+ * of `cs` and do not come from a pseudo-element
1219
+ * @param {CSSStyleDeclaration|Record<string,string>} cs the computed values to read
1220
+ */
1221
+ function buildTextStyle(el, cs, blockEl, S, warn) {
1222
+ const size = px(cs.fontSize, 16)
1223
+ const metrics = metricsOf(fontShorthand(cs, size), size, S)
1224
+ if (metrics.note) warn(metrics.note)
1225
+
1226
+ // -webkit-text-fill-color wins over `color` wherever both are set, and it is
1227
+ // what `background-clip: text` sets to `transparent`.
1228
+ const fillCss = cs.webkitTextFillColor || cs.color
1229
+ const fill = parseColor(fillCss)
1230
+ const strokeWidth = px(cs.webkitTextStrokeWidth, 0)
1231
+ const stroke = strokeWidth > EPS ? parseColor(cs.webkitTextStrokeColor || cs.color) : null
1232
+
1233
+ // `font-variant` is a shorthand, and the ONE longhand of the family that
1234
+ // changes which glyphs are painted is `font-variant-caps`. Reading the
1235
+ // shorthand alone loses it whenever another longhand is set: Chromium then
1236
+ // serializes the shorthand as the list it can serialize, and `small-caps` is
1237
+ // simply not in it. Both are read and the caps value is merged in, because
1238
+ // `variant` is what the emitters look at — `emit/pdf.js` synthesises small
1239
+ // caps off exactly this string (`splitSmallCaps`), and inventing a second
1240
+ // field would give it two places to look.
1241
+ const variantCaps = cs.fontVariantCaps && cs.fontVariantCaps !== 'normal' ? cs.fontVariantCaps : ''
1242
+ const variantShort = cs.fontVariant && cs.fontVariant !== 'normal' ? cs.fontVariant : ''
1243
+ const fontVariant = variantCaps && !variantShort.includes(variantCaps)
1244
+ ? (variantShort ? `${variantShort} ${variantCaps}` : variantCaps)
1245
+ : (variantShort || 'normal')
1246
+
1247
+ // Which family of the stack this browser really painted with. It is emitted in
1248
+ // FRONT of the stack, because a font picker outside this machine reads a stack
1249
+ // as one name, fails to find it and substitutes — 6.1% wider, measured.
1250
+ const resolved = resolveFamily(cs, el, S, warn)
1251
+
1252
+ const lineHeightNormal = cs.lineHeight === 'normal'
1253
+ const style = {
1254
+ blockEl,
1255
+ note: fill ? null : `the text colour "${fillCss}" could not be parsed — treated as opaque black`,
1256
+ fontFamily: resolved.stack,
1257
+ family: resolved.family,
1258
+ // The family OF THE STACK that won, before the binary renamed it: it is the
1259
+ // name `@font-face` knows this face by, so it is what a lookup against the
1260
+ // page's own rules has to use.
1261
+ cssFamily: resolved.cssFamily,
1262
+ stackCss: cs.fontFamily,
1263
+ resolvedFrom: resolved.source,
1264
+ resolvedStyleName: resolved.styleName,
1265
+ genericClass: resolved.genericClass,
1266
+ referenceable: resolved.referenceable,
1267
+ webfont: resolved.webfont,
1268
+ fontSize: size,
1269
+ fontWeight: parseInt(cs.fontWeight, 10) || (cs.fontWeight === 'bold' ? 700 : 400),
1270
+ fontStyle: cs.fontStyle || 'normal',
1271
+ fontStretch: cs.fontStretch || 'normal',
1272
+ fontVariant,
1273
+ fontVariantCaps: variantCaps || 'normal',
1274
+ fontFeatureSettings: cs.fontFeatureSettings || 'normal',
1275
+ fontVariationSettings: cs.fontVariationSettings || 'normal',
1276
+ // 'normal' is not a length: a consumer that assigns it to a canvas keeps the
1277
+ // previous value, so both are resolved to numbers once, here.
1278
+ letterSpacing: cs.letterSpacing === 'normal' ? 0 : px(cs.letterSpacing, 0),
1279
+ wordSpacing: cs.wordSpacing === 'normal' ? 0 : px(cs.wordSpacing, 0),
1280
+ lineHeight: lineHeightNormal ? metrics.ascent + metrics.descent : px(cs.lineHeight, size),
1281
+ lineHeightNormal,
1282
+ color: fill ? fill.rgba : [0, 0, 0, 1],
1283
+ colorCss: fill ? fill.srcCss : fillCss,
1284
+ decorations: decorationsOf(el, S),
1285
+ textTransform: cs.textTransform || 'none',
1286
+ strokeWidth,
1287
+ strokeColor: stroke ? stroke.rgba : null,
1288
+ href: hrefOf(el, S),
1289
+ ascent: metrics.ascent,
1290
+ descent: metrics.descent,
1291
+ fontCss: metrics.fontCss,
1292
+ }
1293
+ style.key = [
1294
+ style.fontFamily, style.family, style.resolvedStyleName,
1295
+ style.fontSize, style.fontWeight, style.fontStyle, style.fontStretch,
1296
+ style.fontVariant, style.fontVariantCaps, style.fontFeatureSettings, style.fontVariationSettings,
1297
+ style.letterSpacing, style.wordSpacing, style.lineHeight, style.colorCss,
1298
+ style.decorations.map(d => `${d.line}|${d.style}|${d.colorCss}|${d.thickness}`).join('&'),
1299
+ style.textTransform, style.strokeWidth, style.strokeColor && style.strokeColor.join(','), style.href,
1300
+ ].join('\u0000')
1301
+
1302
+ if (style.note) warn(style.note)
1303
+ return style
1304
+ }
1305
+
1306
+ // ——— ::first-letter ———
1307
+
1308
+ /**
1309
+ * A difference between `getComputedStyle(el)` and `getComputedStyle(el,
1310
+ * '::first-letter')` in an INHERITED property means a rule matched the pseudo:
1311
+ * with no rule at all the browser hands back the element's own values.
1312
+ *
1313
+ * Only inherited ones, and that is the whole subtlety — a pseudo-element with no
1314
+ * rule at all reports `padding: 0` under an element with `padding: 8px`, and
1315
+ * `text-decoration-line: none` under an underlined `<a>`, because neither
1316
+ * inherits. Testing those said "first letter!" on every padded block in the page.
1317
+ */
1318
+ const FIRST_LETTER_PROPS = STYLE_PROPS
1319
+
1320
+ /** Unicode punctuation, which the first typographic letter unit swallows on both sides. */
1321
+ const PUNCT = (() => {
1322
+ try { return new RegExp('[\\p{P}\\p{S}]', 'u') } catch { /* older engine */ }
1323
+ return /["'“”‘’«»([{<¿¡.,;:!?\-—–]/
1324
+ })()
1325
+
1326
+ /**
1327
+ * How many characters of `data` the first typographic letter unit covers:
1328
+ * leading white space, then any leading punctuation, then ONE grapheme, then the
1329
+ * punctuation stuck to its right — which is what CSS puts in the pseudo's box.
1330
+ * Zero when there is no letter here at all.
1331
+ */
1332
+ function firstLetterLength(data, lang, S) {
1333
+ let i = 0
1334
+ while (i < data.length && /\s/.test(data[i])) i++
1335
+ while (i < data.length && PUNCT.test(data[i])) i++
1336
+ if (i >= data.length) return 0
1337
+ const starts = graphemeStarts(data.slice(i), lang, S)
1338
+ i += starts && starts.length > 1 ? starts[1] : (data.codePointAt(i) > 0xFFFF ? 2 : 1)
1339
+ while (i < data.length && PUNCT.test(data[i])) i++
1340
+ return i
1341
+ }
1342
+
1343
+ /**
1344
+ * Split the first piece in two when the block has a `::first-letter`.
1345
+ *
1346
+ * The pseudo is a real box with its own font, and measuring its glyph with the
1347
+ * ELEMENT'S metrics is how a 52px drop cap came out as a 13.5px letter stretched
1348
+ * to the drop cap's 37px advance — a smear, and the only visible defect left in
1349
+ * the demo. The overlay is per-property rather than wholesale: the pseudo's
1350
+ * computed style inherits from the block, so a first letter that sits inside a
1351
+ * `<b>` would lose its weight if the whole style were replaced.
1352
+ *
1353
+ * Mutates `pieces` in place, and reports through `warn` what the split could not
1354
+ * carry. Whether the split was right is decided later, against the rect the
1355
+ * browser painted.
1356
+ */
1357
+ function splitFirstLetter(pieces, blockEl, blockCs, S, warn) {
1358
+ if (!pieces.length) return
1359
+ let flCs = null
1360
+ try { flCs = getComputedStyle(blockEl, '::first-letter') } catch { return }
1361
+ if (!flCs) return
1362
+ let changed
1363
+ try {
1364
+ changed = FIRST_LETTER_PROPS.filter(prop => flCs[prop] !== blockCs[prop])
1365
+ } catch { return }
1366
+ if (!changed.length) return
1367
+
1368
+ // The first piece that paints a letter. Anything before it is white space that
1369
+ // collapses away, and the pseudo attaches to the first one that is not.
1370
+ let index = 0
1371
+ let length = 0
1372
+ for (; index < pieces.length; index++) {
1373
+ length = firstLetterLength(pieces[index].t.node.data, langOf(pieces[index].t.el, S), S)
1374
+ if (length > 0) break
1375
+ }
1376
+ if (index >= pieces.length || !length) return
1377
+ const piece = pieces[index]
1378
+
1379
+ const overlaid = {}
1380
+ for (const prop of STYLE_PROPS) overlaid[prop] = piece.t.cs[prop]
1381
+ for (const prop of changed) overlaid[prop] = flCs[prop]
1382
+ const style = buildTextStyle(piece.t.el, overlaid, blockEl, S, warn)
1383
+ // Not inherited, so it is only readable once the split is decided: a pseudo
1384
+ // with no rule reports `none` here whatever the element does.
1385
+ const line = flCs.textDecorationLine
1386
+ if (line && line !== 'none' && line !== piece.t.cs.textDecorationLine) {
1387
+ style.decorations = style.decorations.slice()
1388
+ pushDecoration(style.decorations, flCs)
1389
+ }
1390
+ // The style is per-block, not per-element: the cache would hand this one to
1391
+ // every other text node of the same element.
1392
+ style.key = `${style.key}::first-letter`
1393
+
1394
+ if (length < piece.to) {
1395
+ const rest = { ...piece, from: length, rStart: piece.t.rAt[length], style: piece.style }
1396
+ Object.assign(piece, { to: length, rEnd: piece.t.rAt[length], style, firstLetter: true })
1397
+ pieces.splice(index + 1, 0, rest)
1398
+ } else {
1399
+ // Nothing to split off — the letter already IS the whole text node. That is
1400
+ // the normal shape in snapdom's clone, which materialises `::first-letter`
1401
+ // into its own `<span>`; the old early return here treated "no split needed"
1402
+ // as "no pseudo", and a 52px orange drop cap was emitted with the paragraph's
1403
+ // 16.5px font stretched across its 37px advance. The overlay is the entire
1404
+ // point of this function and applies whether or not the piece has to be cut.
1405
+ Object.assign(piece, { style, firstLetter: true })
1406
+ }
1407
+
1408
+ if (changed.includes('textTransform')) {
1409
+ warn('::first-letter sets its own text-transform — the painted string was read back per text node ' +
1410
+ 'and carries the block\'s transform at that position')
1411
+ }
1412
+ const box = ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'borderTopWidth', 'borderLeftWidth']
1413
+ .some(prop => px(flCs[prop], 0) > 0)
1414
+ if (box) {
1415
+ warn('::first-letter has padding or a border of its own — it is a box around the glyph and the run ' +
1416
+ 'carries only the glyph, so a backend that draws a background or a border for it has nothing to draw')
1417
+ }
1418
+ }
1419
+
1420
+ // ——— the walk ———
1421
+
1422
+ /**
1423
+ * `white-space` still computes to one keyword in Chromium, but the longhands are
1424
+ * what the spec now defines the behaviour on, so they win where they exist.
1425
+ * `break-spaces` and `preserve-spaces` differ from `preserve` only in how they
1426
+ * WRAP, which the browser has already decided by the time we read rects.
1427
+ */
1428
+ function collapseMode(cs) {
1429
+ const long = cs.whiteSpaceCollapse
1430
+ if (long) {
1431
+ if (long === 'collapse') return 'collapse'
1432
+ if (long === 'preserve-breaks') return 'preserve-breaks'
1433
+ return 'preserve'
1434
+ }
1435
+ const ws = cs.whiteSpace
1436
+ if (ws === 'pre' || ws === 'pre-wrap' || ws === 'break-spaces') return 'preserve'
1437
+ if (ws === 'pre-line') return 'preserve-breaks'
1438
+ return 'collapse'
1439
+ }
1440
+
1441
+ /**
1442
+ * The text nodes of THIS block's inline formatting context, in document order.
1443
+ *
1444
+ * `SHOW_ELEMENT` is in the mask only so the filter can return `FILTER_REJECT` and
1445
+ * prune a subtree: with `SHOW_TEXT` alone the filter is never asked about an
1446
+ * element, and a nested block's text would land in its parent's line grid. Every
1447
+ * element that is not pruned returns `FILTER_SKIP` — visited for its children,
1448
+ * never emitted.
1449
+ */
1450
+ function collectTextNodes(blockEl, S, flags) {
1451
+ const texts = []
1452
+ // Something that is not a character has just gone past: a `<br>`, an image, an
1453
+ // `inline-block`, a form control. It carries a break opportunity that leaves no
1454
+ // white space behind, so the next text node has to remember it was there —
1455
+ // otherwise a line that ends at a `<br>` looks exactly like a word cut in half.
1456
+ let afterBox = false
1457
+ const walker = document.createTreeWalker(blockEl, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, {
1458
+ acceptNode(node) {
1459
+ if (node.nodeType === Node.TEXT_NODE) {
1460
+ return node.data ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT
1461
+ }
1462
+ // Paint traversal can omit this inline node while its parent still owns
1463
+ // the formatting context. Prune it here too, before reading any text.
1464
+ if (isExcluded(node, S.exclude)) { afterBox = true; return NodeFilter.FILTER_REJECT }
1465
+ const tag = node.tagName.toUpperCase()
1466
+ if (tag === 'BR' || tag === 'WBR') { afterBox = true; return NodeFilter.FILTER_REJECT }
1467
+ if (SKIP_TAGS.has(tag)) {
1468
+ if (RUBY_TAGS.has(tag)) flags.ruby = true
1469
+ else afterBox = true
1470
+ return NodeFilter.FILTER_REJECT
1471
+ }
1472
+ if (tag === 'SLOT') { flags.shadow = true; return NodeFilter.FILTER_SKIP }
1473
+ if (node.shadowRoot) flags.shadow = true
1474
+ const cs = styleOf(node, S)
1475
+ if (cs.display === 'none') return NodeFilter.FILTER_REJECT
1476
+ if (cs.contentVisibility === 'hidden') { flags.contentVisibility = true; return NodeFilter.FILTER_REJECT }
1477
+ // Generates no box: its children belong to the parent's context.
1478
+ if (cs.display === 'contents') return NodeFilter.FILTER_SKIP
1479
+ if (!INLINE_DISPLAY.test(cs.display)) { afterBox = true; return NodeFilter.FILTER_REJECT }
1480
+ if (cs.verticalAlign && cs.verticalAlign !== 'baseline') flags.verticalAlign = true
1481
+ return NodeFilter.FILTER_SKIP
1482
+ },
1483
+ })
1484
+
1485
+ while (walker.nextNode()) {
1486
+ const node = walker.currentNode
1487
+ const el = node.parentElement
1488
+ if (!el) continue
1489
+ const cs = styleOf(el, S)
1490
+ // visibility is inherited and a descendant may turn it back on, so the text
1491
+ // node's own parent is the whole answer for that node.
1492
+ // A text node that is not collected does not consume the pending box either:
1493
+ // the break opportunity belongs to the next node that really paints.
1494
+ if (cs.visibility !== 'visible') { flags.hidden += 1; continue }
1495
+ if (cs.verticalAlign && cs.verticalAlign !== 'baseline') flags.verticalAlign = true
1496
+ texts.push({ node, el, cs, mode: collapseMode(cs), afterBox })
1497
+ afterBox = false
1498
+ }
1499
+ return texts
1500
+ }
1501
+
1502
+ // ——— the rendered string ———
1503
+
1504
+ /**
1505
+ * Phase-one white space processing, over the whole block at once because a run of
1506
+ * collapsible space crosses text-node boundaries: `<em>a </em> <b>b</b>` paints
1507
+ * one space, not three.
1508
+ *
1509
+ * Produces, in one pass:
1510
+ * rendered the painted characters, `text-transform` applied
1511
+ * source the same positions BEFORE `text-transform` — the same length by
1512
+ * construction, so the two index identically
1513
+ * trimmable which of them may still be removed at a line edge (phase two)
1514
+ * isBreak which of them are forced breaks, and so are not characters at all
1515
+ * and, per text node, `rAt[domOffset] -> rendered index`: the only bridge between
1516
+ * the space `Range` speaks and the space everything else here does.
1517
+ */
1518
+ function buildRendered(texts, S, flags) {
1519
+ const rendered = []
1520
+ const source = []
1521
+ const trimmable = []
1522
+ const isBreak = []
1523
+ // Leading collapsible space at the start of a block is removed, so the machine
1524
+ // starts in the state that eats it.
1525
+ let pendingSpace = true
1526
+
1527
+ for (const t of texts) {
1528
+ const data = t.node.data
1529
+ const len = data.length
1530
+ const rAt = new Int32Array(len + 1).fill(-1)
1531
+ const mark = (k) => { if (rAt[k] === -1) rAt[k] = rendered.length }
1532
+
1533
+ let painted = null
1534
+ if (t.cs.textTransform && t.cs.textTransform !== 'none') {
1535
+ // The character already painted before this node is what tells `capitalize`
1536
+ // whether this node starts a word.
1537
+ painted = paintedText(t.el, data, rendered.length ? rendered[rendered.length - 1] : '', S)
1538
+ if (painted === null) flags.transformUnresolved = true
1539
+ else if (painted === data) {
1540
+ const expect = EXPECTS_CHANGE[t.cs.textTransform.split(' ')[0]]
1541
+ if (expect && expect.test(data)) flags.transformUnresolved = true
1542
+ }
1543
+ if (t.cs.textTransform.includes('capitalize')) flags.capitalize = true
1544
+ }
1545
+
1546
+ t.start = rendered.length
1547
+ for (let k = 0; k < len; k++) {
1548
+ const ch = data[k]
1549
+ if (t.mode !== 'preserve' && isCollapsible(ch)) {
1550
+ if (t.mode === 'preserve-breaks' && ch === '\n') {
1551
+ mark(k)
1552
+ rendered.push('\n'); source.push('\n'); trimmable.push(0); isBreak.push(1)
1553
+ pendingSpace = true
1554
+ continue
1555
+ }
1556
+ if (!pendingSpace) {
1557
+ // The collapsed space takes the position of the FIRST character of the
1558
+ // run it stands for, which is where a caret in it would sit.
1559
+ mark(k)
1560
+ rendered.push(' '); source.push(' '); trimmable.push(1); isBreak.push(0)
1561
+ pendingSpace = true
1562
+ }
1563
+ continue
1564
+ }
1565
+ if (t.mode === 'preserve' && (ch === '\n' || ch === '\r')) {
1566
+ // CR, LF and CRLF are one segment break between them.
1567
+ if (ch === '\r' && data[k + 1] === '\n') continue
1568
+ mark(k)
1569
+ rendered.push('\n'); source.push('\n'); trimmable.push(0); isBreak.push(1)
1570
+ pendingSpace = false
1571
+ continue
1572
+ }
1573
+ if (ch === '\t') flags.tabs = true
1574
+ mark(k)
1575
+ rendered.push(painted ? painted[k] : ch)
1576
+ source.push(ch)
1577
+ trimmable.push(0)
1578
+ isBreak.push(0)
1579
+ pendingSpace = false
1580
+ }
1581
+ rAt[len] = rendered.length
1582
+ for (let k = len - 1; k >= 0; k--) if (rAt[k] === -1) rAt[k] = rAt[k + 1]
1583
+ t.rAt = rAt
1584
+ t.end = rendered.length
1585
+ }
1586
+
1587
+ // Trailing collapsible space at the end of a block is removed too.
1588
+ while (rendered.length && trimmable[rendered.length - 1]) {
1589
+ rendered.pop(); source.pop(); trimmable.pop(); isBreak.pop()
1590
+ }
1591
+ for (const t of texts) {
1592
+ if (t.start > rendered.length) t.start = rendered.length
1593
+ if (t.end > rendered.length) t.end = rendered.length
1594
+ }
1595
+
1596
+ return { rendered, source, trimmable, isBreak }
1597
+ }
1598
+
1599
+ // ——— baseline clustering ———
1600
+
1601
+ /**
1602
+ * Index of the cluster a baseline belongs to, or the insertion point when it
1603
+ * belongs to none. Monotone in `baseline` either way, which is what lets the
1604
+ * line probe below be binary-searched.
1605
+ */
1606
+ function clusterAt(clusters, baseline) {
1607
+ let lo = 0
1608
+ let hi = clusters.length
1609
+ while (lo < hi) {
1610
+ const mid = (lo + hi) >> 1
1611
+ if (clusters[mid].baseline < baseline - BASELINE_TOL) lo = mid + 1
1612
+ else hi = mid
1613
+ }
1614
+ const c = clusters[lo]
1615
+ return { at: lo, hit: c && Math.abs(c.baseline - baseline) <= BASELINE_TOL ? c : null }
1616
+ }
1617
+
1618
+ function addRect(clusters, baseline, rect) {
1619
+ const { at, hit } = clusterAt(clusters, baseline)
1620
+ let c = hit
1621
+ if (!c) {
1622
+ c = { baseline, sum: 0, n: 0, rects: [], width: 0 }
1623
+ clusters.splice(at, 0, c)
1624
+ }
1625
+ c.sum += baseline
1626
+ c.n += 1
1627
+ // The running mean, not the first sighting: two fonts on one line disagree by
1628
+ // up to half a pixel, and the cluster should sit between them rather than on
1629
+ // whichever rect happened to be measured first.
1630
+ c.baseline = c.sum / c.n
1631
+ c.rects.push(rect)
1632
+ c.width += rect.width
1633
+ return c
1634
+ }
1635
+
1636
+ function overlaps(a, b, slack) {
1637
+ return a.left < b.right - slack && b.left < a.right - slack
1638
+ }
1639
+
1640
+ /** The ink box of a cluster: how far its rects reach above and below, in px. */
1641
+ function inkBox(cluster) {
1642
+ let top = Infinity
1643
+ let bottom = -Infinity
1644
+ for (const r of cluster.rects) {
1645
+ if (r.top < top) top = r.top
1646
+ if (r.bottom > bottom) bottom = r.bottom
1647
+ }
1648
+ return { top, bottom, h: bottom - top }
1649
+ }
1650
+
1651
+ /** How much of the smaller of two ink boxes the other one covers, 0..1. */
1652
+ function inkOverlap(a, b) {
1653
+ const top = Math.max(a.top, b.top)
1654
+ const bottom = Math.min(a.bottom, b.bottom)
1655
+ const min = Math.min(a.h, b.h)
1656
+ if (!(min > 0)) return 0
1657
+ return Math.max(0, bottom - top) / min
1658
+ }
1659
+
1660
+ /** Below this share of ink overlap two baseline groups are two real lines. */
1661
+ const FOLD_INK = 0.5
1662
+
1663
+ /** A shifted group sits well inside one line box of its host. */
1664
+ const FOLD_PITCH = 0.6
1665
+
1666
+ /**
1667
+ * `vertical-align` shifts a run's baseline without taking it off the line, so a
1668
+ * superscript clusters on its own and the block appears to paint twice the lines
1669
+ * it does. Folding it back is only attempted when some inline in the block really
1670
+ * is shifted; the ordinary case stays exactly the contract's rule — baseline, and
1671
+ * nothing else.
1672
+ *
1673
+ * The reference pitch comes from CSS — the largest USED `line-height` among the
1674
+ * block's runs — and never from the gaps being classified. Deriving it from them
1675
+ * was the bug: a one-line block with a `<sub>` has exactly ONE gap, so the median
1676
+ * gap WAS that gap, every test passed by construction, nothing ever folded, and
1677
+ * `H<sub>2</sub>O` came out as two lines with the `H` stretched to the width of
1678
+ * the whole block. Reading `line-height` as a CLASSIFIER is legitimate; what the
1679
+ * contract forbids is reporting it as a measurement, and `pitch` is still
1680
+ * measured between the baselines this function decides on.
1681
+ *
1682
+ * Two tests have to agree before a group folds, and each one alone is enough to
1683
+ * refuse: the two groups' INK must overlap vertically — two real lines never
1684
+ * overlap by half their height, a `sup` always does — and none of their rects may
1685
+ * overlap horizontally, because a superscript sits BETWEEN the glyphs of its line
1686
+ * where a genuine second line sits over them.
1687
+ *
1688
+ * @param {object[]} clusters baseline groups, ascending
1689
+ * @param {number} refPitch largest used line-height in the block, in the same
1690
+ * (viewport) units as the baselines
1691
+ * @returns {{lineOf: number[], folded: boolean[], shift: number[]}} cluster index
1692
+ * -> line index, which clusters were folded INTO another one, and how far
1693
+ * each one sits off its line's baseline (px, positive downwards)
1694
+ */
1695
+ function foldShifted(clusters, refPitch, warn) {
1696
+ const plain = {
1697
+ lineOf: clusters.map((_, i) => i),
1698
+ folded: clusters.map(() => false),
1699
+ shift: clusters.map(() => 0),
1700
+ }
1701
+ if (clusters.length < 2) return plain
1702
+
1703
+ const ink = clusters.map(inkBox)
1704
+ const host = clusters.map((_, i) => i)
1705
+ for (let i = 0; i < clusters.length; i++) {
1706
+ const c = clusters[i]
1707
+ let best = -1
1708
+ let bestGap = Infinity
1709
+ for (const j of [i - 1, i + 1]) {
1710
+ const n = clusters[j]
1711
+ // A cluster that has itself been folded is not a host: chains would make
1712
+ // "which line is this" depend on the order the folds were decided in.
1713
+ if (!n || host[j] !== j) continue
1714
+ // The wider group is the line; the narrower one is what was shifted off it.
1715
+ if (!(c.width < n.width)) continue
1716
+ const gap = Math.abs(c.baseline - n.baseline)
1717
+ if (refPitch > 0 && gap > FOLD_PITCH * refPitch) continue
1718
+ if (inkOverlap(ink[i], ink[j]) < FOLD_INK) continue
1719
+ let clear = true
1720
+ for (const a of c.rects) {
1721
+ for (const b of n.rects) if (overlaps(a, b, 1)) { clear = false; break }
1722
+ if (!clear) break
1723
+ }
1724
+ if (clear && gap < bestGap) { best = j; bestGap = gap }
1725
+ }
1726
+ if (best >= 0) host[i] = best
1727
+ }
1728
+
1729
+ let folded = 0
1730
+ let next = 0
1731
+ const lineOfHost = new Map()
1732
+ for (let i = 0; i < clusters.length; i++) {
1733
+ if (host[i] !== i) { folded++; continue }
1734
+ lineOfHost.set(i, next++)
1735
+ }
1736
+ if (!folded) return plain
1737
+ warn(`${folded} baseline group(s) shifted by vertical-align were folded back into the line they paint on — ` +
1738
+ 'the line carries the host baseline and each shifted run carries its own offset in `dy`, which a ' +
1739
+ 'backend that places one baseline per line has to apply to paint the run where the page paints it')
1740
+ return {
1741
+ lineOf: clusters.map((_, i) => lineOfHost.get(host[i])),
1742
+ folded: host.map((h, i) => h !== i),
1743
+ shift: clusters.map((c, i) => (host[i] === i ? 0 : c.baseline - clusters[host[i]].baseline)),
1744
+ }
1745
+ }
1746
+
1747
+ // ——— grapheme boundaries ———
1748
+
1749
+ function segmenterFor(lang, S) {
1750
+ const key = lang || ''
1751
+ if (S.segmenters.has(key)) return S.segmenters.get(key)
1752
+ let seg = null
1753
+ try {
1754
+ if (typeof Intl !== 'undefined' && Intl.Segmenter) {
1755
+ seg = new Intl.Segmenter(lang || undefined, { granularity: 'grapheme' })
1756
+ }
1757
+ } catch { seg = null }
1758
+ S.segmenters.set(key, seg)
1759
+ return seg
1760
+ }
1761
+
1762
+ function graphemeStarts(str, lang, S) {
1763
+ const seg = segmenterFor(lang, S)
1764
+ if (!seg || !str) return null
1765
+ const starts = []
1766
+ for (const piece of seg.segment(str)) starts.push(piece.index)
1767
+ starts.push(str.length)
1768
+ return starts
1769
+ }
1770
+
1771
+ /**
1772
+ * Nearest grapheme start. Boundaries are shared — one run's end is the next
1773
+ * one's start — so snapping the VALUE keeps both sides on the same character and
1774
+ * cannot open a gap.
1775
+ */
1776
+ function snapTo(starts, i) {
1777
+ if (!starts) return i
1778
+ let lo = 0
1779
+ let hi = starts.length - 1
1780
+ while (lo < hi) {
1781
+ const mid = (lo + hi) >> 1
1782
+ if (starts[mid] < i) lo = mid + 1
1783
+ else hi = mid
1784
+ }
1785
+ if (starts[lo] === i || lo === 0) return starts[lo]
1786
+ const prev = starts[lo - 1]
1787
+ return i - prev <= starts[lo] - i ? prev : starts[lo]
1788
+ }
1789
+
1790
+ // ——— block-level facts ———
1791
+
1792
+ function resolveAlign(value, direction, blockEl) {
1793
+ const rtl = direction === 'rtl'
1794
+ if (value === 'start') return rtl ? 'right' : 'left'
1795
+ if (value === 'end') return rtl ? 'left' : 'right'
1796
+ if (value === 'match-parent') {
1797
+ const p = blockEl.parentElement
1798
+ const pd = p ? getComputedStyle(p).direction : 'ltr'
1799
+ return pd === 'rtl' ? 'right' : 'left'
1800
+ }
1801
+ if (value === '-webkit-left' || value === '-internal-left') return 'left'
1802
+ if (value === '-webkit-right' || value === '-internal-right') return 'right'
1803
+ if (value === '-webkit-center' || value === '-internal-center') return 'center'
1804
+ return value
1805
+ }
1806
+
1807
+ const IDENTITY_LINEAR = { m: [1, 0, 0, 1], ok: true }
1808
+
1809
+ /** `[a,b,c,d]` of `p · q`, SVG order: x' = a·x + c·y, y' = b·x + d·y. */
1810
+ function mul2(p, q) {
1811
+ return [
1812
+ p[0] * q[0] + p[2] * q[1],
1813
+ p[1] * q[0] + p[3] * q[1],
1814
+ p[0] * q[2] + p[2] * q[3],
1815
+ p[1] * q[2] + p[3] * q[3],
1816
+ ]
1817
+ }
1818
+
1819
+ const ZERO_ANGLE = /^-?0(\.0+)?(deg|grad|rad|turn)?$/
1820
+
1821
+ /**
1822
+ * The 2x2 linear part of ONE element's transforms, in the order CSS applies
1823
+ * them. `rotate`/`scale`/`translate` are separate computed properties that the
1824
+ * `transform` string does not contain; a rotation is not decomposed, it is
1825
+ * refused — `ok:false` means "the chain is not an axis-aligned scale", which is
1826
+ * all the caller needs to know.
1827
+ */
1828
+ function ownLinear(cs) {
1829
+ let m = null
1830
+ const scale = cs.scale
1831
+ if (scale && scale !== 'none') {
1832
+ const parts = String(scale).trim().split(/\s+/).map(Number)
1833
+ if (!parts.length || !parts.every(Number.isFinite)) return { m: [1, 0, 0, 1], ok: false }
1834
+ m = [parts[0], 0, 0, parts.length > 1 ? parts[1] : parts[0]]
1835
+ }
1836
+ const rotate = cs.rotate
1837
+ if (rotate && rotate !== 'none' && !ZERO_ANGLE.test(String(rotate).trim())) {
1838
+ return { m: m || [1, 0, 0, 1], ok: false }
1839
+ }
1840
+ const transform = cs.transform
1841
+ if (transform && transform !== 'none') {
1842
+ try {
1843
+ const t = new DOMMatrix(transform)
1844
+ m = m ? mul2([t.a, t.b, t.c, t.d], m) : [t.a, t.b, t.c, t.d]
1845
+ } catch {
1846
+ // Unparseable means unknown, and unknown has to be reported.
1847
+ return { m: m || [1, 0, 0, 1], ok: false }
1848
+ }
1849
+ }
1850
+ return m ? { m, ok: true } : IDENTITY_LINEAR
1851
+ }
1852
+
1853
+ /**
1854
+ * The linear map from this element's own coordinates to the ones client rects
1855
+ * are reported in — every transform between it and the capture root, its own
1856
+ * included, composed outermost-last.
1857
+ *
1858
+ * It matters because a client rect is measured AFTER those transforms and the
1859
+ * node this text lands on is emitted BEFORE them: `paint.js` takes the node's
1860
+ * geometry from the untransformed layout chain and the backend re-applies the
1861
+ * matrix. A `scale(2)` anywhere above the block therefore doubled every width,
1862
+ * baseline and pitch reported here and the backend doubled them again.
1863
+ */
1864
+ function linearAbove(el, S) {
1865
+ const chain = []
1866
+ let base = null
1867
+ const top = typeof document !== 'undefined' ? document.documentElement : null
1868
+ for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
1869
+ const cached = S.linear.get(n)
1870
+ if (cached) { base = cached; break }
1871
+ chain.push(n)
1872
+ if (n === S.root || n === top) break
1873
+ }
1874
+ let out = base || IDENTITY_LINEAR
1875
+ for (let i = chain.length - 1; i >= 0; i--) {
1876
+ const own = ownLinear(styleOf(chain[i], S))
1877
+ out = own === IDENTITY_LINEAR && out.ok
1878
+ ? out
1879
+ : { m: mul2(out.m, own.m), ok: out.ok && own.ok }
1880
+ S.linear.set(chain[i], out)
1881
+ }
1882
+ return out
1883
+ }
1884
+
1885
+ /**
1886
+ * How much the chain above this element scales it, and whether that is all it
1887
+ * does. A pure axis-aligned scale is exactly divisible out of every measurement;
1888
+ * a rotation, a skew or a mirror is not, because a client rect is then the
1889
+ * bounding box of a quad and `rect.top + ascent` is not a baseline at all.
1890
+ */
1891
+ function chainScale(el, S) {
1892
+ const { m, ok } = linearAbove(el, S)
1893
+ const [a, b, c, d] = m
1894
+ const axis = ok && Math.abs(b) <= EPS && Math.abs(c) <= EPS && a > EPS && d > EPS
1895
+ const sx = axis ? a : 1
1896
+ const sy = axis ? d : 1
1897
+ return { sx, sy, skewed: !axis, scaled: axis && (Math.abs(sx - 1) > 1e-4 || Math.abs(sy - 1) > 1e-4) }
1898
+ }
1899
+
1900
+ // ——— fragments ———
1901
+
1902
+ /** Rects nearer than this are one fragment; anything wider is a real hole. */
1903
+ const FRAG_GAP = 0.5
1904
+
1905
+ /**
1906
+ * The painted pieces of one line, and the characters each one carries.
1907
+ *
1908
+ * A line's bounding box is not its text: an `<img>`, an `<input>` or an
1909
+ * `inline-block` sits INSIDE the line and inside the box, and is deliberately
1910
+ * absent from `characters` — it is another node's job. So is the padding of an
1911
+ * inline `<code>`. `line.w` used to be the box, and a backend fitting the string
1912
+ * to it stretched `icon label` across the icon as well: +83%, measured, with no
1913
+ * diagnostic. Splitting the line at the holes gives every stretch of glyphs the
1914
+ * x and the width the browser actually painted it at.
1915
+ *
1916
+ * Returns null when the line is one uninterrupted run of glyphs (the ordinary
1917
+ * case, where `line.w` is already right) or when the fragments cannot be trusted
1918
+ * to carry a contiguous range of characters each.
1919
+ *
1920
+ * @returns {{x:number, w:number, start:number, end:number}[]|null}
1921
+ */
1922
+ /**
1923
+ * The painted extent of each STYLE stretch on one line: where the run that
1924
+ * carries it starts and stops, in the same space as `line.x`.
1925
+ *
1926
+ * A `Run` is character offsets and nothing else, and `fragmentsOf` above merges
1927
+ * the per-piece rects it measured into holes-and-glyphs before anyone downstream
1928
+ * sees them — so a backend asked to draw a decoration SVG has no primitive for
1929
+ * (a wavy underline) over `characters 23-41 of this line` had no extent to draw
1930
+ * it over and fell back to a straight line. These are the same rects, kept
1931
+ * instead of thrown away: one entry per contiguous stretch of one piece on one
1932
+ * line, tiling the line's characters in logical order.
1933
+ *
1934
+ * @returns {{start:number, end:number, x:number, w:number}[]|null}
1935
+ */
1936
+ function spansOf(segs, rToChar, blockRect, sx) {
1937
+ if (!segs || !segs.length) return null
1938
+ const out = []
1939
+ for (const s of segs) {
1940
+ let left = Infinity
1941
+ let right = -Infinity
1942
+ for (let k = 0; k < s.piece.rects.length; k++) {
1943
+ if (s.piece.clusterOf[k] !== s.cluster) continue
1944
+ const r = s.piece.rects[k]
1945
+ if (!(r.width > 0)) continue
1946
+ if (r.left < left) left = r.left
1947
+ if (r.right > right) right = r.right
1948
+ }
1949
+ if (!Number.isFinite(left)) continue
1950
+ const start = rToChar[s.rStart]
1951
+ const end = rToChar[s.rEnd]
1952
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) continue
1953
+ out.push({ start, end, x: (left - blockRect.left) / sx, w: (right - left) / sx })
1954
+ }
1955
+ if (!out.length) return null
1956
+ out.sort((a, b) => a.start - b.start || a.x - b.x)
1957
+ return out
1958
+ }
1959
+
1960
+ function fragmentsOf(segs, line, rToChar, blockRect, sx) {
1961
+ if (!segs || segs.length < 2) return null
1962
+ const items = []
1963
+ for (const s of segs) {
1964
+ let left = Infinity
1965
+ let right = -Infinity
1966
+ for (let k = 0; k < s.piece.rects.length; k++) {
1967
+ if (s.piece.clusterOf[k] !== s.cluster) continue
1968
+ const r = s.piece.rects[k]
1969
+ if (!(r.width > 0)) continue
1970
+ if (r.left < left) left = r.left
1971
+ if (r.right > right) right = r.right
1972
+ }
1973
+ if (!Number.isFinite(left)) continue
1974
+ items.push({ left, right, start: rToChar[s.rStart] })
1975
+ }
1976
+ if (items.length < 2) return null
1977
+ items.sort((a, b) => a.left - b.left)
1978
+ // Visual order and logical order are the same thing only in one direction. If
1979
+ // they disagree the line is bidi-reordered and the caller has already been told
1980
+ // that its offsets are logical; splitting it here would be a lie with an x on it.
1981
+ for (let k = 1; k < items.length; k++) if (items[k].start < items[k - 1].start) return null
1982
+
1983
+ const groups = [{ left: items[0].left, right: items[0].right, start: items[0].start }]
1984
+ for (let k = 1; k < items.length; k++) {
1985
+ const last = groups[groups.length - 1]
1986
+ if (items[k].left <= last.right + FRAG_GAP) {
1987
+ if (items[k].right > last.right) last.right = items[k].right
1988
+ continue
1989
+ }
1990
+ groups.push({ left: items[k].left, right: items[k].right, start: items[k].start })
1991
+ }
1992
+ if (groups.length < 2) return null
1993
+
1994
+ const frags = []
1995
+ for (let k = 0; k < groups.length; k++) {
1996
+ const g = groups[k]
1997
+ const start = k === 0 ? line.start : Math.min(Math.max(g.start, line.start), line.end)
1998
+ frags.push({
1999
+ x: (g.left - blockRect.left) / sx,
2000
+ w: (g.right - g.left) / sx,
2001
+ start,
2002
+ end: line.end,
2003
+ })
2004
+ if (k > 0 && frags[k].start < frags[k - 1].start) frags[k].start = frags[k - 1].start
2005
+ if (k > 0) frags[k - 1].end = frags[k].start
2006
+ }
2007
+ return frags
2008
+ }
2009
+
2010
+ // ——— the collector ———
2011
+
2012
+ /**
2013
+ * @typedef {object} Line
2014
+ * @property {number} i visual line index, 0-based, top to bottom
2015
+ * @property {number} start offset into `characters`, inclusive
2016
+ * @property {number} end offset into `characters`, exclusive
2017
+ * @property {number} x left edge of the painted line, relative to blockEl's border box
2018
+ * @property {number} w painted width: the sum of the glyph advances, which is the
2019
+ * bounding box only while nothing interrupts the line
2020
+ * @property {number} baseline relative to blockEl's border box, Y down
2021
+ * @property {number} asc baseline to the top of the tallest rect on the line
2022
+ * @property {number} desc baseline to the bottom of the deepest rect on the line
2023
+ * @property {Frag[]} [frags] present only when something that is not a character sits
2024
+ * inside the line and splits it in two or more painted
2025
+ * stretches. They tile `[start, end)` and are left to right.
2026
+ * @property {string} [hyphen] the break glyph the browser painted at the END of this line
2027
+ * and the document does not contain — `hyphens: auto` cutting
2028
+ * a word. It is NOT in `characters`, because every offset here
2029
+ * indexes that string and `sourceText` matches it character for
2030
+ * character. Its advance IS already inside `w`, `spans` and
2031
+ * `frags`: the browser reports it as ink of this line. A backend
2032
+ * paints the line as `characters.slice(start, end) + hyphen`.
2033
+ */
2034
+
2035
+ /**
2036
+ * @typedef {object} Frag
2037
+ * @property {number} x left edge of this stretch, relative to blockEl's border box
2038
+ * @property {number} w its measured width
2039
+ * @property {number} start offset into `characters`, inclusive
2040
+ * @property {number} end offset into `characters`, exclusive
2041
+ */
2042
+
2043
+ /**
2044
+ * @typedef {object} Run
2045
+ * @property {number} start offset into `characters`, inclusive
2046
+ * @property {number} end offset into `characters`, exclusive
2047
+ * @property {string} style key into the returned `styles` map
2048
+ * @property {string|null} href the enclosing `<a>`'s href, or null
2049
+ * @property {number} dy offset from its line's baseline, positive down. Non-zero
2050
+ * only for a run `vertical-align` moved off the line it
2051
+ * belongs to — a `<sup>`, a `<sub>`. Its line still carries
2052
+ * the baseline the rest of the line paints on.
2053
+ */
2054
+
2055
+ /**
2056
+ * Collect the text of one block's inline formatting context.
2057
+ *
2058
+ * `characters` is the painted string: visual lines joined with U+2028 (LSEP),
2059
+ * never `\n`. `sourceText` is the same string before `text-transform`; the two
2060
+ * are the SAME LENGTH and index identically, because a transform that changed
2061
+ * the length is refused and reported in `warnings` instead. Every offset in
2062
+ * `lines` and `runs` indexes `characters`, and every one sits on a grapheme
2063
+ * boundary wherever `Intl.Segmenter` is there to say where those are.
2064
+ *
2065
+ * `runs` tile `characters` exactly — every character has one style and no more.
2066
+ * `styles` maps `ts_N` -> TextStyle, deduplicated within this call.
2067
+ *
2068
+ * `characters` holds only what the DOM holds. The one thing the browser paints
2069
+ * that no character stands for is the hyphen at a hyphenated break, and it goes
2070
+ * on `line.hyphen` for exactly that reason: a character invented here would move
2071
+ * every offset after it and would have to be invented in `sourceText` too, which
2072
+ * is the page's own text.
2073
+ *
2074
+ * `pitch` is MEASURED between consecutive baselines (their median difference),
2075
+ * never read from `line-height`. A single-line block has no difference to
2076
+ * measure and falls back to the used line-height, which says so in `warnings`.
2077
+ * The one other place CSS `line-height` is read is as the reference pitch that
2078
+ * tells a `vertical-align`-shifted baseline group from a second line — a
2079
+ * classifier, never a reported measurement.
2080
+ *
2081
+ * Every number in `lines` is in the block's OWN units: an axis-aligned scale
2082
+ * anywhere between the block and the capture root is divided out, because the
2083
+ * node these lines land on carries that transform and applies it again.
2084
+ *
2085
+ * `warnings` is prose the orchestrator grades, except where a warning reports
2086
+ * something exact, which arrives as `{code, grade, severity, message}` so that
2087
+ * succeeding cannot degrade the node.
2088
+ *
2089
+ * @param {Element} blockEl an element with a text-carrying inline formatting context
2090
+ * @param {CollectCtx} ctx
2091
+ * @returns {{characters:string, lines:Line[], runs:Run[], pitch:number,
2092
+ * align:string, direction:string, styles:Record<string,TextStyle>,
2093
+ * sourceText:string,
2094
+ * warnings:Array<string|{code:string,grade:string,severity:string,message:string}>}|null}
2095
+ */
2096
+ export function collectText(blockEl, ctx) {
2097
+ if (!blockEl || blockEl.nodeType !== 1) return null
2098
+
2099
+ const S = stateFor(ctx)
2100
+ if (isExcluded(blockEl, S.exclude)) return null
2101
+ /** @type {Array<string|{code:string, grade:string, severity:string, message:string}>} */
2102
+ const warnings = []
2103
+ // Prose is the default and the orchestrator grades it; the object form is for
2104
+ // the one case prose cannot express — something exact, which must not degrade
2105
+ // the node for having succeeded. Deduplicated by identity, or by code.
2106
+ const warn = (w) => {
2107
+ if (!w) return
2108
+ if (typeof w === 'object') {
2109
+ if (!warnings.some(x => x && typeof x === 'object' && x.code === w.code)) warnings.push(w)
2110
+ return
2111
+ }
2112
+ if (!warnings.includes(w)) warnings.push(w)
2113
+ }
2114
+
2115
+ const flags = {
2116
+ ruby: false, shadow: false, verticalAlign: false, tabs: false, capitalize: false,
2117
+ transformUnresolved: false, contentVisibility: false, hidden: 0, noRects: 0, fragmented: 0,
2118
+ holes: 0, holeLines: 0, mixedShift: false,
2119
+ }
2120
+
2121
+ const texts = collectTextNodes(blockEl, S, flags)
2122
+ if (!texts.length) return null
2123
+
2124
+ const { rendered, source, trimmable, isBreak } = buildRendered(texts, S, flags)
2125
+ if (!rendered.length) return null
2126
+
2127
+ const blockCs = styleOf(blockEl, S)
2128
+ const blockRect = blockEl.getBoundingClientRect()
2129
+ const range = document.createRange()
2130
+
2131
+ // Client rects are measured after every transform between here and the capture
2132
+ // root; the node this text lands on is emitted before them. Everything read off
2133
+ // a rect below is divided by this, so `lines` is in the block's own units.
2134
+ const chain = chainScale(blockEl, S)
2135
+ const { sx, sy } = chain
2136
+
2137
+ // ——— pieces: one measured stretch of one text node ———
2138
+
2139
+ /**
2140
+ * A piece is a DOM range inside ONE text node that carries ONE run style.
2141
+ * There is exactly one per text node until `::first-letter` splits the first
2142
+ * one, and the split has to happen before anything is measured: the pseudo has
2143
+ * its own font, so it has its own ascent, and the ascent is what turns a rect
2144
+ * into a baseline.
2145
+ */
2146
+ const pieces = []
2147
+ for (const t of texts) {
2148
+ t.style = textStyleOf(t.el, blockEl, S, warn)
2149
+ pieces.push({ t, from: 0, to: t.node.data.length, rStart: t.start, rEnd: t.end, style: t.style })
2150
+ }
2151
+ splitFirstLetter(pieces, blockEl, blockCs, S, warn)
2152
+
2153
+ // ——— rects -> baseline clusters ———
2154
+
2155
+ for (const p of pieces) {
2156
+ range.setStart(p.t.node, p.from)
2157
+ range.setEnd(p.t.node, p.to)
2158
+ const rects = []
2159
+ for (const r of range.getClientRects()) {
2160
+ // Zero height is not a fragment. Zero WIDTH is: an empty line inside a
2161
+ // `pre` paints nothing and its rect is the only evidence the line exists.
2162
+ if (r.height > 0) rects.push(r)
2163
+ }
2164
+ p.rects = rects
2165
+ if (!rects.length && /\S/.test(p.t.node.data.slice(p.from, p.to))) flags.noRects += 1
2166
+ }
2167
+
2168
+ // `::first-letter` does not apply to every block whose selector matches it —
2169
+ // a block that starts with an image has no first letter to give it. The box
2170
+ // the browser painted is the answer: a rect the height of the pseudo's font
2171
+ // means the pseudo is really there, and anything else means the split was a
2172
+ // guess, and a guess with the wrong ascent in it is a wrong baseline.
2173
+ for (let i = 0; i < pieces.length; i++) {
2174
+ const p = pieces[i]
2175
+ if (!p.firstLetter) continue
2176
+ const want = (p.style.ascent + p.style.descent) * sy
2177
+ const got = p.rects.length ? p.rects[0].height : 0
2178
+ if (want > 0 && Math.abs(got - want) <= Math.max(2, 0.25 * want)) continue
2179
+ warn('a ::first-letter rule matches this block but the browser painted the first letter at the ' +
2180
+ 'element\'s own size — the pseudo does not apply here and its style was not used')
2181
+ p.style = pieces[i + 1] ? pieces[i + 1].style : p.t.style
2182
+ p.firstLetter = false
2183
+ }
2184
+
2185
+ const clusters = []
2186
+ // The reference line box: the block's own strut, which is there whether or not
2187
+ // anything paints on it, and every run that does paint. `normal` is not a
2188
+ // length and contributes nothing — the runs cover it.
2189
+ let refPitch = blockCs.lineHeight === 'normal' ? 0 : px(blockCs.lineHeight, 0)
2190
+ for (const p of pieces) {
2191
+ // The ascent is a font measurement, in the block's own units; the rect it is
2192
+ // added to is in the viewport's. One of them has to move, and scaling the
2193
+ // ascent is exact where scaling a rounded rect back would not be.
2194
+ p.ascentVp = p.style.ascent * sy
2195
+ if (!p.rects.length) continue
2196
+ if (p.style.lineHeight > refPitch) refPitch = p.style.lineHeight
2197
+ p.clusterOf = new Array(p.rects.length)
2198
+ const seen = new Set()
2199
+ for (let k = 0; k < p.rects.length; k++) {
2200
+ const c = addRect(clusters, p.rects[k].top + p.ascentVp, p.rects[k])
2201
+ // The same node twice on one baseline is a visual split of one logical
2202
+ // stretch: bidi reordering, or a fragment that took a hyphen.
2203
+ if (seen.has(c)) flags.fragmented += 1
2204
+ seen.add(c)
2205
+ }
2206
+ }
2207
+ if (!clusters.length) return null
2208
+ for (const p of pieces) {
2209
+ if (!p.rects) continue
2210
+ for (let k = 0; k < p.rects.length; k++) {
2211
+ p.clusterOf[k] = clusterAt(clusters, p.rects[k].top + p.ascentVp).at
2212
+ }
2213
+ }
2214
+
2215
+ const fold = flags.verticalAlign
2216
+ ? foldShifted(clusters, refPitch * sy, warn)
2217
+ : { lineOf: clusters.map((_, i) => i), folded: clusters.map(() => false), shift: clusters.map(() => 0) }
2218
+ const lineOf = fold.lineOf
2219
+ let lineCount = 0
2220
+ for (const l of lineOf) if (l + 1 > lineCount) lineCount = l + 1
2221
+
2222
+ // ——— which characters are on which line ———
2223
+
2224
+ /**
2225
+ * The line the LAST painted fragment of `[from, off)` lands on, or -1 when
2226
+ * nothing in that range paints at all.
2227
+ *
2228
+ * The question is asked backwards on purpose. Asking it forwards — "which line
2229
+ * does the text from `off` on start on" — is what cut four words in half in the
2230
+ * demo, and it is not a rounding error: when the browser breaks a word it
2231
+ * paints a hyphen at the end of the line, and Chromium attributes that hyphen's
2232
+ * rect to the DOM offset AFTER the break. MEASURED on `.ty-just`: the range
2233
+ * starting at the `n` of `línea` reports two rects, the hyphen at x=905.7 on
2234
+ * line 1 and the `n` at x=478 on line 2, so a search reading the FIRST rect
2235
+ * concludes the `n` is still on line 1, steps one character right, and turns
2236
+ * `…de cada lí-` / `nea, así que…` into `…de cada lín` / `ea, así que…`. No
2237
+ * character is lost, so nothing downstream can notice.
2238
+ *
2239
+ * A prefix cannot be contaminated that way: the break glyph of line n is
2240
+ * painted before the glyphs of line n+1 and therefore never last. The predicate
2241
+ * `lastClusterTo(from, off) >= target` is monotone in `off` for the same reason
2242
+ * the forward one was, and it identifies the character at `off - 1` — the first
2243
+ * one on the new line — rather than the offset the hyphen hangs off.
2244
+ */
2245
+ const lastClusterTo = (p, from, off) => {
2246
+ range.setStart(p.t.node, from)
2247
+ range.setEnd(p.t.node, off)
2248
+ let at = -1
2249
+ for (const r of range.getClientRects()) {
2250
+ if (r.height <= 0) continue
2251
+ at = clusterAt(clusters, r.top + p.ascentVp).at
2252
+ }
2253
+ return at
2254
+ }
2255
+
2256
+ let lineStartR = new Array(lineCount).fill(Infinity)
2257
+ /** One contiguous stretch of one piece on one line, with the rects it painted. */
2258
+ const segments = []
2259
+ /**
2260
+ * Where every line's first character sits in the DOM, so the break that put it
2261
+ * there can be checked against the browser rather than trusted. Keyed by the
2262
+ * rendered offset the line starts at, which is what `lineStartR` holds.
2263
+ */
2264
+ const breakSites = new Map()
2265
+ for (const p of pieces) {
2266
+ if (!p.rects.length) continue
2267
+ const touched = [...new Set(p.clusterOf)].sort((a, b) => a - b)
2268
+ const mine = []
2269
+ let from = p.from
2270
+ for (let j = 0; j < touched.length; j++) {
2271
+ if (j > 0) {
2272
+ // Binary search for the first DOM offset that has crossed onto cluster
2273
+ // `touched[j]`. Walking characters from the start would be 95ms/35k
2274
+ // chars; this is log2(len) probes per break and measures ~18ms on the
2275
+ // same input. Each probe is a prefix bounded on the LEFT by the break
2276
+ // already found, so it measures the two lines around this one and not
2277
+ // the whole node.
2278
+ const prev = from
2279
+ const target = touched[j]
2280
+ let lo = prev + 1
2281
+ let hi = p.to
2282
+ while (lo < hi) {
2283
+ const mid = (lo + hi) >> 1
2284
+ if (lastClusterTo(p, prev, mid) >= target) hi = mid
2285
+ else lo = mid + 1
2286
+ }
2287
+ from = Math.max(prev, Math.min(lo, p.to) - 1)
2288
+ }
2289
+ const line = lineOf[touched[j]]
2290
+ const r = j === 0 ? p.rStart : p.t.rAt[Math.min(from, p.to)]
2291
+ if (r < lineStartR[line]) lineStartR[line] = r
2292
+ if (!breakSites.has(r)) breakSites.set(r, { piece: p, dom: Math.min(from, p.to), cluster: touched[j] })
2293
+ mine.push({ piece: p, cluster: touched[j], line, rStart: r, rEnd: p.rEnd })
2294
+ if (j > 0) mine[j - 1].rEnd = r
2295
+ }
2296
+ // The run this piece becomes paints off the line's baseline by this much.
2297
+ // Wrapping inside a shifted inline gives every fragment the same shift, so
2298
+ // the first one answers for the piece; anything else is reported.
2299
+ p.dy = fold.shift[touched[0]] / sy
2300
+ for (const c of touched) {
2301
+ if (Math.abs(fold.shift[c] / sy - p.dy) > BASELINE_TOL) flags.mixedShift = true
2302
+ }
2303
+ segments.push(...mine)
2304
+ }
2305
+
2306
+ // Lines are numbered by baseline, and all but one thing in an inline formatting
2307
+ // context paints in that order. The exception is a float — a `::first-letter`
2308
+ // drop cap sits ON the first line and BELOW its baseline — and it would put the
2309
+ // capital in the middle of `characters`. Where the two orders disagree, the
2310
+ // characters win: each line keeps its own measured baseline, so nothing moves
2311
+ // on the page, and the string stays readable in logical order.
2312
+ if (lineStartR.every(Number.isFinite)) {
2313
+ const order = lineStartR.map((_, i) => i)
2314
+ .sort((a, b) => (lineStartR[a] - lineStartR[b]) || (a - b))
2315
+ if (order.some((old, i) => old !== i)) {
2316
+ const renumber = new Array(lineCount)
2317
+ order.forEach((old, i) => { renumber[old] = i })
2318
+ for (let c = 0; c < lineOf.length; c++) lineOf[c] = renumber[lineOf[c]]
2319
+ for (const s of segments) s.line = renumber[s.line]
2320
+ lineStartR = order.map(old => lineStartR[old])
2321
+ warn('a line of this block paints out of top-to-bottom order (a floated ::first-letter, or a ' +
2322
+ 'float taking the first characters of the block below the ones after them) — the lines are ' +
2323
+ 'ordered by the characters they carry and each one carries its own measured baseline')
2324
+ }
2325
+ }
2326
+
2327
+ // The lines tile the rendered string with no gaps: whatever falls between two
2328
+ // measured fragments — collapsed space, a `<br>`, a soft hyphen — belongs to
2329
+ // the line before it, where phase-two trimming can still take it out.
2330
+ lineStartR[0] = 0
2331
+ for (let i = 1; i < lineCount; i++) {
2332
+ if (!Number.isFinite(lineStartR[i]) || lineStartR[i] < lineStartR[i - 1]) lineStartR[i] = lineStartR[i - 1]
2333
+ }
2334
+
2335
+ // ——— characters, sourceText, and the rendered -> characters map ———
2336
+
2337
+ const chars = []
2338
+ const srcChars = []
2339
+ const rToChar = new Int32Array(rendered.length + 1).fill(-1)
2340
+ const lineText = []
2341
+ for (let i = 0; i < lineCount; i++) {
2342
+ let s = lineStartR[i]
2343
+ let e = i + 1 < lineCount ? lineStartR[i + 1] : rendered.length
2344
+ // Phase two: space that survived collapsing is still removed at a line edge,
2345
+ // and a forced break is a separator rather than a character.
2346
+ while (s < e && (trimmable[s] || isBreak[s])) s++
2347
+ while (e > s && (trimmable[e - 1] || isBreak[e - 1])) e--
2348
+ if (i > 0) { chars.push(LSEP); srcChars.push(LSEP) }
2349
+ const start = chars.length
2350
+ for (let k = s; k < e; k++) {
2351
+ if (isBreak[k]) continue
2352
+ rToChar[k] = chars.length
2353
+ chars.push(rendered[k])
2354
+ srcChars.push(source[k])
2355
+ }
2356
+ // `rFrom`/`rTo` are the same span in the RENDERED string, which is where the
2357
+ // break check below has to look: what a line dropped at its edges is exactly
2358
+ // the evidence that the browser had a break opportunity there.
2359
+ lineText.push({ start, end: chars.length, rFrom: s, rTo: e })
2360
+ }
2361
+ const characters = chars.join('')
2362
+ const sourceText = srcChars.join('')
2363
+ if (!characters) return null
2364
+
2365
+ // Every rendered offset maps forward to the first character that survived at or
2366
+ // after it, so a boundary that landed inside trimmed space resolves to the same
2367
+ // place from both sides of it.
2368
+ rToChar[rendered.length] = characters.length
2369
+ for (let k = rendered.length - 1; k >= 0; k--) if (rToChar[k] === -1) rToChar[k] = rToChar[k + 1]
2370
+
2371
+ // ——— every break is checked against the browser, and one that cannot be is declared ———
2372
+
2373
+ /**
2374
+ * The ink the browser painted at this break — a hyphen — or null.
2375
+ *
2376
+ * The evidence is the same artefact that caused the bug: the rect of the first
2377
+ * character of the new line carries the hyphen of the OLD one. A rect on an
2378
+ * earlier cluster for a one-character range is therefore ink the browser put at
2379
+ * the break, and nothing else in an inline formatting context produces it.
2380
+ *
2381
+ * MEASURED on `.ty-just`: the range over the `n` of `línea` reports the hyphen
2382
+ * at 905.73..912 on line 1 and the `n` at 478..485.77 on line 2. The rect is
2383
+ * returned rather than a boolean because its WIDTH is the only thing that says
2384
+ * which character was painted — 6.266px against 6.2556px for U+2010 in this
2385
+ * font, and 7.765px for an en dash.
2386
+ *
2387
+ * @returns {{rect:DOMRect, cluster:number, style:object}|null}
2388
+ */
2389
+ const breakInkAt = (site) => {
2390
+ if (!site || site.dom >= site.piece.to) return null
2391
+ range.setStart(site.piece.t.node, site.dom)
2392
+ range.setEnd(site.piece.t.node, site.dom + 1)
2393
+ let best = null
2394
+ for (const r of range.getClientRects()) {
2395
+ if (r.height <= 0) continue
2396
+ const cluster = clusterAt(clusters, r.top + site.piece.ascentVp).at
2397
+ if (cluster >= site.cluster) continue
2398
+ // Nearest cluster first, then the widest rect on it: the break glyph is the
2399
+ // ink closest to the break, and the browser reports it as its own rect.
2400
+ if (!best || cluster > best.cluster || (cluster === best.cluster && r.width > best.rect.width)) {
2401
+ // `el` and `style` are the piece's, because the browser attributed the
2402
+ // ink to it: `hyphenate-character` inherits, so the element the
2403
+ // characters live in is the one that says which glyph this is, not the
2404
+ // block — an `<em>` may perfectly well set its own.
2405
+ best = { rect: r, cluster, style: site.piece.style, el: site.piece.t.el }
2406
+ }
2407
+ }
2408
+ return best
2409
+ }
2410
+
2411
+ /**
2412
+ * Line index -> the ink painted at the break that STARTS it, on the line
2413
+ * before. Probed only where a hyphen can be: a break with collapsible white
2414
+ * space between the two lines is a break ON that space, and the browser paints
2415
+ * no glyph for it — measured, the space itself then reports a 0.016px rect on
2416
+ * each line, which is a caret box and not a hyphen.
2417
+ */
2418
+ const breakInk = new Map()
2419
+ for (let i = 1; i < lineCount; i++) {
2420
+ const k = lineText[i].rFrom
2421
+ if (k <= 0 || k >= rendered.length || lineText[i].rTo <= k) continue
2422
+ if (k > lineText[i - 1].rTo) continue
2423
+ const ink = breakInkAt(breakSites.get(lineStartR[i]))
2424
+ if (ink) breakInk.set(i, ink)
2425
+ }
2426
+
2427
+ // The block asked for breaks inside words, so finding one is not evidence of
2428
+ // anything going wrong.
2429
+ const breaksWords = /break-all|anywhere/.test(blockCs.wordBreak || '') ||
2430
+ /anywhere|break-word/.test(blockCs.overflowWrap || blockCs.wordWrap || '') ||
2431
+ (blockCs.lineBreak === 'anywhere')
2432
+
2433
+ /**
2434
+ * The rendered offsets where two lines may part with no white space between
2435
+ * them for a reason that is not a wrap at all.
2436
+ *
2437
+ * A floated `::first-letter` is one: the drop cap sits on a baseline of its own
2438
+ * and becomes a line of its own, so `C` / `ada bloque…` looks exactly like a
2439
+ * word cut in half and is not one. The other is a box — a `<br>`, an image, a
2440
+ * control, an `inline-block` — sitting between two text nodes: it carries a
2441
+ * break opportunity and leaves no character behind, so `rotate` / `−14°` across
2442
+ * a `<br>` is as legitimate a break as one on a space.
2443
+ */
2444
+ const boxBreaks = new Set()
2445
+ for (const p of pieces) if (p.firstLetter) boxBreaks.add(p.rEnd)
2446
+ for (const t of texts) if (t.afterBox) boxBreaks.add(t.start)
2447
+
2448
+ const unverified = []
2449
+ for (let i = 1; i < lineCount; i++) {
2450
+ const k = lineText[i].rFrom
2451
+ // A line that carries no characters of its own has no break of its own.
2452
+ if (k <= 0 || k >= rendered.length || lineText[i].rTo <= k) continue
2453
+ // White space and a forced break ARE the break opportunity, and phase two
2454
+ // has already taken them out of both lines. Whatever the two lines dropped
2455
+ // between them is therefore the evidence, and the ordinary case leaves a
2456
+ // gap here. A break with NO gap cut two characters apart that the browser
2457
+ // was painting side by side, and that needs a reason.
2458
+ if (k > lineText[i - 1].rTo) continue
2459
+ if (boxBreaks.has(k)) continue
2460
+ const before = rendered[k - 1]
2461
+ const after = rendered[k]
2462
+ if (BREAK_AFTER.test(before)) continue
2463
+ if (BREAK_ANYWHERE.test(before) || BREAK_ANYWHERE.test(after)) continue
2464
+ if (breaksWords) continue
2465
+ if (breakInk.has(i)) continue
2466
+ unverified.push(`…${rendered.slice(Math.max(0, k - 12), k).join('')}|${rendered.slice(k, k + 12).join('')}…`)
2467
+ }
2468
+ if (unverified.length) {
2469
+ warn({
2470
+ code: 'collect.text-line-break-unverified',
2471
+ grade: 'A',
2472
+ severity: 'error',
2473
+ message: `${unverified.length} line break(s) of this block fall inside a word, and the browser painted ` +
2474
+ 'no hyphen there and asked for no break inside words — so where these lines were cut could not be ' +
2475
+ 'verified and the characters may be split between them differently than the page paints them: ' +
2476
+ unverified.slice(0, 6).join(' · ') + (unverified.length > 6 ? ` (and ${unverified.length - 6} more)` : ''),
2477
+ })
2478
+ }
2479
+ // The lines have to tile the rendered string with no gap and no overlap, or a
2480
+ // character belongs to two lines or to none. It is an invariant of how
2481
+ // `lineStartR` is built and it is asserted anyway: this is the one module
2482
+ // whose bug corrupts the TEXT, and the check costs one pass.
2483
+ let tiled = true
2484
+ for (let i = 1; i < lineCount && tiled; i++) tiled = lineStartR[i] >= lineStartR[i - 1]
2485
+ if (!tiled || lineStartR[0] !== 0) {
2486
+ warn({
2487
+ code: 'collect.text-lines-not-tiling',
2488
+ grade: 'O',
2489
+ severity: 'error',
2490
+ message: 'the visual lines of this block do not tile its rendered text — the characters reported per ' +
2491
+ 'line are not the characters the page paints on them',
2492
+ })
2493
+ }
2494
+
2495
+ // ——— lines ———
2496
+
2497
+ const buckets = []
2498
+ for (let i = 0; i < lineCount; i++) buckets.push({ rects: [], sum: 0, n: 0 })
2499
+ for (let c = 0; c < clusters.length; c++) {
2500
+ const b = buckets[lineOf[c]]
2501
+ b.rects.push(...clusters[c].rects)
2502
+ // A folded cluster contributes its ink — that is what it was folded for —
2503
+ // and NOT its baseline: averaging a superscript's raised baseline into the
2504
+ // line it sits on moved the whole line up by 2px and then reported the
2505
+ // result as "the pitch varies", which named the symptom and not the cause.
2506
+ if (fold.folded[c]) continue
2507
+ b.sum += clusters[c].sum
2508
+ b.n += clusters[c].n
2509
+ }
2510
+
2511
+ const segsByLine = []
2512
+ for (let i = 0; i < lineCount; i++) segsByLine.push([])
2513
+ for (const s of segments) if (segsByLine[s.line]) segsByLine[s.line].push(s)
2514
+
2515
+ const lines = []
2516
+ /** Each line's painted extent in VIEWPORT px, which is where rects are read. */
2517
+ const edgeVp = []
2518
+ for (let i = 0; i < lineCount; i++) {
2519
+ const b = buckets[i]
2520
+ const baselineVp = b.n ? b.sum / b.n : blockRect.top
2521
+ // A zero-width rect carries a baseline but no ink, so it establishes that the
2522
+ // line exists and never how wide it is — unless it is all the line has.
2523
+ const inked = b.rects.filter(r => r.width > 0)
2524
+ const used = inked.length ? inked : b.rects
2525
+ let left = Infinity
2526
+ let right = -Infinity
2527
+ for (const r of used) {
2528
+ if (r.left < left) left = r.left
2529
+ if (r.right > right) right = r.right
2530
+ }
2531
+ let asc = 0
2532
+ let desc = 0
2533
+ for (const r of b.rects) {
2534
+ if (baselineVp - r.top > asc) asc = baselineVp - r.top
2535
+ if (r.bottom - baselineVp > desc) desc = r.bottom - baselineVp
2536
+ }
2537
+ if (!Number.isFinite(left)) { left = blockRect.left; right = blockRect.left }
2538
+ edgeVp.push({ left, right })
2539
+ const line = {
2540
+ i,
2541
+ start: lineText[i].start,
2542
+ end: lineText[i].end,
2543
+ x: (left - blockRect.left) / sx,
2544
+ w: (right - left) / sx,
2545
+ baseline: (baselineVp - blockRect.top) / sy,
2546
+ asc: asc / sy,
2547
+ desc: desc / sy,
2548
+ }
2549
+ const spans = spansOf(segsByLine[i], rToChar, blockRect, sx)
2550
+ if (spans) line.spans = spans
2551
+ const frags = fragmentsOf(segsByLine[i], line, rToChar, blockRect, sx)
2552
+ if (frags) {
2553
+ line.frags = frags
2554
+ flags.holes += frags.length - 1
2555
+ flags.holeLines += 1
2556
+ // The advance the glyphs really take, which is what a backend has to fit
2557
+ // them into. The bounding box is not it the moment something that is not
2558
+ // a character sits inside the line.
2559
+ let advance = 0
2560
+ for (const f of frags) advance += f.w
2561
+ line.w = advance
2562
+ }
2563
+ lines.push(line)
2564
+ }
2565
+
2566
+ // ——— the hyphen the browser paints and the document does not contain ———
2567
+
2568
+ const direction = blockCs.direction || 'ltr'
2569
+ const align = resolveAlign(blockCs.textAlign || 'start', direction, blockEl)
2570
+
2571
+ /**
2572
+ * `hyphens: auto` cuts a word and paints a hyphen at the end of the line. It is
2573
+ * not a character of the document, so it cannot be in `characters` — and a line
2574
+ * that ends `…de cada lí` instead of `…de cada lí‐` does not read as a word
2575
+ * split in two, it reads as a mistake.
2576
+ *
2577
+ * It travels on the LINE, and that is a decision, not a shortcut. `characters`
2578
+ * is the coordinate system: every offset in `runs`, `lines`, `frags` and
2579
+ * `spans` indexes it, and `sourceText` is the same string before
2580
+ * `text-transform` — the SAME LENGTH, indexing identically, and asserted
2581
+ * character for character against the live `textContent`. A character inserted
2582
+ * into `characters` is a character the page does not have: it would shift every
2583
+ * offset after it in four arrays, and it would either break that identity or
2584
+ * put a hyphen into `sourceText`, which is the original text of the page. A
2585
+ * field the backend appends when it paints the line moves nothing and cannot be
2586
+ * mistaken for content.
2587
+ *
2588
+ * The advance is already inside `line.w`, `line.spans` and `line.frags`: the
2589
+ * browser reports the hyphen as ink of the line it is painted on (MEASURED —
2590
+ * the line's rect ends at 912, which is the hyphen's right edge and not the
2591
+ * `í`'s at 905.73), which is exactly why the trailing-edge test below is the
2592
+ * one that decides. So a backend that appends `line.hyphen` and fits the result
2593
+ * to `line.w` is exact, and one that ignores it paints a line 6.27px short of
2594
+ * the ink and a word that looks misspelt.
2595
+ */
2596
+ const hyphenated = []
2597
+ const hyphenUnsure = []
2598
+ const hyphenUnplaced = []
2599
+ const hyphenSoft = []
2600
+ const declaredChars = new Set()
2601
+ let anyExplicit = false
2602
+ for (const [i, ink] of breakInk) {
2603
+ const cand = hyphenCandidates(ink.el ? styleOf(ink.el, S) : blockCs)
2604
+ declaredChars.add(cand.declared)
2605
+ anyExplicit = anyExplicit || cand.explicit
2606
+ const prev = lines[i - 1]
2607
+ const edge = edgeVp[i - 1]
2608
+ const w = ink.rect.width / sx
2609
+ const at = `…${characters.slice(Math.max(prev ? prev.start : 0, (prev ? prev.end : 0) - 14), prev ? prev.end : 0)}`
2610
+ // Three things have to hold before a character the DOM does not have is
2611
+ // added to the output: the ink is on the line BEFORE the break, it sits on
2612
+ // that line's trailing edge, and it is wide enough to be a glyph. Anything
2613
+ // else is ink this function has not identified, and it is declared instead.
2614
+ if (!prev || !edge || lineOf[ink.cluster] !== i - 1 || !(w > EPS)) {
2615
+ hyphenUnplaced.push(`${at}| (${w.toFixed(2)}px of ink, on no line of its own)`)
2616
+ continue
2617
+ }
2618
+ const off = direction === 'rtl' ? ink.rect.left - edge.left : ink.rect.right - edge.right
2619
+ if (Math.abs(off) > HYPHEN_EDGE_TOL * sx) {
2620
+ hyphenUnplaced.push(`${at}| (${w.toFixed(2)}px of ink, ${Math.abs(off / sx).toFixed(2)}px off the line edge)`)
2621
+ continue
2622
+ }
2623
+ let pick = null
2624
+ let adv = 0
2625
+ let err = Infinity
2626
+ for (const ch of cand.chars) {
2627
+ if (!ch) continue
2628
+ const a = advanceOf(ch, ink.style, S)
2629
+ // Strictly nearer, so a tie keeps the first candidate — which is the one
2630
+ // Blink prefers, and the two `auto` candidates are almost always a tie.
2631
+ if (Math.abs(a - w) < err) { err = Math.abs(a - w); adv = a; pick = ch }
2632
+ }
2633
+ if (pick === null) {
2634
+ hyphenUnplaced.push(`${at}| (${w.toFixed(2)}px of ink, and hyphenate-character is ${cand.declared})`)
2635
+ continue
2636
+ }
2637
+ prev.hyphen = pick
2638
+ hyphenated.push(`${at}${pick}`)
2639
+ if (err > Math.max(0.5, 0.15 * w)) {
2640
+ hyphenUnsure.push(`${at}${pick} (${w.toFixed(2)}px painted, ${adv.toFixed(2)}px for the character chosen)`)
2641
+ }
2642
+ // A break the author asked for with U+00AD: the soft hyphen IS in
2643
+ // `characters`, at the end of this line, and it advances zero — MEASURED,
2644
+ // `measureText('­')` is 0 and the painted glyph is the same 6.27px
2645
+ // hyphen as an automatic break. So the glyph is still unaccounted for and
2646
+ // `line.hyphen` still carries it; what changes is that a renderer which
2647
+ // paints U+00AD as a hyphen of its own would then draw two.
2648
+ if (prev.end > prev.start && characters[prev.end - 1] === '­') hyphenSoft.push(i - 1)
2649
+ }
2650
+
2651
+ if (hyphenated.length) {
2652
+ warn({
2653
+ code: 'collect.text-hyphen',
2654
+ grade: 'E',
2655
+ severity: 'info',
2656
+ message: `${hyphenated.length} line(s) of this block end in a hyphen the browser painted at a break ` +
2657
+ `and the document does not contain (hyphens: ${blockCs.hyphens || 'manual'}): ` +
2658
+ `${hyphenated.slice(0, 6).join(' · ')}${hyphenated.length > 6 ? ` (and ${hyphenated.length - 6} more)` : ''}. ` +
2659
+ 'It is carried on `line.hyphen` and deliberately NOT in `characters`, which every offset in `runs`, ' +
2660
+ '`lines`, `frags` and `spans` indexes and which `sourceText` matches character for character — ' +
2661
+ 'inserting it there would move all of them. Its advance is already inside `line.w`, `line.spans` and ' +
2662
+ '`line.frags`, because the browser paints it as ink of the line, so a backend that appends ' +
2663
+ '`line.hyphen` to the line is exact and one that ignores it paints a word that reads as misspelt' +
2664
+ (anyExplicit
2665
+ ? ` (the character is hyphenate-character: ${[...declaredChars].join(', ')})`
2666
+ : ' (hyphenate-character is auto, so the character is the one Blink picks — U+2010 HYPHEN, or ' +
2667
+ 'U+002D HYPHEN-MINUS when the font has no glyph for it — chosen here by matching the painted ' +
2668
+ 'advance, which the two do not usually differ in)'),
2669
+ })
2670
+ }
2671
+ if (hyphenSoft.length) {
2672
+ warn({
2673
+ code: 'collect.text-hyphen-soft',
2674
+ grade: 'E',
2675
+ severity: 'info',
2676
+ message: `${hyphenSoft.length} of those line(s) (${hyphenSoft.join(', ')}) end in U+00AD SOFT HYPHEN, ` +
2677
+ 'which IS a character of the document and is in `characters` — it just advances zero and paints ' +
2678
+ 'nothing until a line breaks on it. The break glyph is still on `line.hyphen`, because a backend that ' +
2679
+ 'sets one line per element never asks its renderer to break and so never gets the glyph; a renderer ' +
2680
+ 'that paints U+00AD as a hyphen at the end of a run would draw two',
2681
+ })
2682
+ }
2683
+ if (hyphenUnsure.length) {
2684
+ warn({
2685
+ code: 'collect.text-hyphen-char-unverified',
2686
+ grade: 'A',
2687
+ severity: 'warn',
2688
+ message: `the hyphen painted at ${hyphenUnsure.length} break(s) of this block does not have the advance ` +
2689
+ `of any character hyphenate-character (${[...declaredChars].join(', ')}) allows: ` +
2690
+ `${hyphenUnsure.slice(0, 4).join(' · ')}. ` +
2691
+ 'The nearest candidate is on `line.hyphen` anyway — a line that reads as a word cut in two is worth ' +
2692
+ 'more than a line that reads as a mistake — but it is the wrong width and may be the wrong glyph',
2693
+ })
2694
+ }
2695
+ if (hyphenUnplaced.length) {
2696
+ warn({
2697
+ code: 'collect.text-hyphen-unplaced',
2698
+ grade: 'A',
2699
+ severity: 'warn',
2700
+ message: `the browser painted ink at ${hyphenUnplaced.length} line break(s) of this block that is not a ` +
2701
+ 'character of the document, and it could not be placed at the end of a line: ' +
2702
+ `${hyphenUnplaced.slice(0, 4).join(' · ')}. No hyphen is carried for those lines, so they end one ` +
2703
+ 'glyph short of what the page paints',
2704
+ })
2705
+ }
2706
+
2707
+ // ——— pitch ———
2708
+
2709
+ let pitch
2710
+ const diffs = []
2711
+ for (let i = 1; i < lines.length; i++) {
2712
+ const d = lines[i].baseline - lines[i - 1].baseline
2713
+ // A float that took the first characters below the ones after them makes one
2714
+ // step run backwards. It is not a line pitch and it must not be medianed into
2715
+ // one — the block already carries the warning that says why it is there.
2716
+ if (d > 0) diffs.push(d)
2717
+ }
2718
+ if (diffs.length) {
2719
+ pitch = median(diffs)
2720
+ let spread = 0
2721
+ for (const d of diffs) spread = Math.max(spread, Math.abs(d - pitch))
2722
+ if (spread > PITCH_TOL) {
2723
+ warn(`baseline pitch varies by ${spread.toFixed(2)}px around ${pitch.toFixed(2)}px — place every line at its own baseline instead of stepping by pitch`)
2724
+ }
2725
+ } else {
2726
+ // The LARGEST used line-height in the block, not the first run's: the first
2727
+ // text node of a block can perfectly well be a `<sup>` or a `<small>`.
2728
+ pitch = refPitch > 0 ? refPitch : px(blockCs.lineHeight, 0)
2729
+ warn(lines.length > 1
2730
+ ? 'no two lines of this block step downwards, so there is no baseline difference to measure — pitch falls back to the used line-height and is not a measurement'
2731
+ : 'a single visual line has no baseline difference to measure — pitch falls back to the used line-height and is not a measurement')
2732
+ }
2733
+
2734
+ // ——— runs and styles ———
2735
+
2736
+ const styles = {}
2737
+ const idByKey = new Map()
2738
+ const styleId = (style) => {
2739
+ let id = idByKey.get(style.key)
2740
+ if (!id) {
2741
+ id = `ts_${idByKey.size}`
2742
+ idByKey.set(style.key, id)
2743
+ const rest = { ...style }
2744
+ delete rest.blockEl
2745
+ delete rest.key
2746
+ delete rest.note
2747
+ styles[id] = rest
2748
+ }
2749
+ return id
2750
+ }
2751
+
2752
+ const raw = []
2753
+ for (const p of pieces) {
2754
+ const id = styleId(p.style)
2755
+ const dy = p.dy || 0
2756
+ const prev = raw[raw.length - 1]
2757
+ if (prev && prev.style === id && prev.dy === dy) { prev.rEnd = p.rEnd; continue }
2758
+ raw.push({ rStart: p.rStart, rEnd: p.rEnd, style: id, href: p.style.href, dy })
2759
+ }
2760
+
2761
+ const starts = graphemeStarts(characters, langOf(blockEl, S), S)
2762
+ if (!starts && NON_ASCII.test(characters)) {
2763
+ warn('Intl.Segmenter is unavailable — offsets are UTF-16 indices and may split a grapheme cluster')
2764
+ }
2765
+
2766
+ const mapped = []
2767
+ for (const r of raw) {
2768
+ const start = snapTo(starts, rToChar[r.rStart])
2769
+ const end = snapTo(starts, rToChar[r.rEnd])
2770
+ const prev = mapped[mapped.length - 1]
2771
+ if (prev && prev.style === r.style && prev.href === r.href && prev.dy === r.dy) {
2772
+ if (end > prev.end) prev.end = end
2773
+ continue
2774
+ }
2775
+ mapped.push({ start, end, style: r.style, href: r.href, dy: r.dy })
2776
+ }
2777
+ for (let i = 1; i < mapped.length; i++) {
2778
+ if (mapped[i].start < mapped[i - 1].start) mapped[i].start = mapped[i - 1].start
2779
+ }
2780
+ // A run whose start reaches the next one's is empty once the runs are tiled;
2781
+ // dropping it here — rather than after tiling — is what keeps the tiling total.
2782
+ const runs = mapped.filter((r, i) => i === mapped.length - 1 || mapped[i + 1].start > r.start)
2783
+ for (let i = 0; i < runs.length - 1; i++) runs[i].end = runs[i + 1].start
2784
+ if (runs.length) {
2785
+ runs[0].start = 0
2786
+ runs[runs.length - 1].end = characters.length
2787
+ }
2788
+
2789
+ for (const l of lines) {
2790
+ l.start = snapTo(starts, l.start)
2791
+ l.end = snapTo(starts, l.end)
2792
+ if (l.end < l.start) l.end = l.start
2793
+ if (l.spans) {
2794
+ for (const s of l.spans) {
2795
+ s.start = Math.min(Math.max(snapTo(starts, s.start), l.start), l.end)
2796
+ s.end = Math.min(Math.max(snapTo(starts, s.end), l.start), l.end)
2797
+ }
2798
+ l.spans = l.spans.filter((s) => s.end > s.start)
2799
+ if (!l.spans.length) delete l.spans
2800
+ }
2801
+ if (!l.frags) continue
2802
+ for (const f of l.frags) {
2803
+ f.start = Math.min(Math.max(snapTo(starts, f.start), l.start), l.end)
2804
+ f.end = Math.min(Math.max(snapTo(starts, f.end), l.start), l.end)
2805
+ }
2806
+ l.frags[0].start = l.start
2807
+ l.frags[l.frags.length - 1].end = l.end
2808
+ for (let i = 1; i < l.frags.length; i++) {
2809
+ if (l.frags[i].start < l.frags[i - 1].start) l.frags[i].start = l.frags[i - 1].start
2810
+ l.frags[i - 1].end = l.frags[i].start
2811
+ }
2812
+ }
2813
+
2814
+ // ——— per-word anchors for justified lines ———
2815
+ //
2816
+ // Justification is the one alignment where "x + advances" cannot reproduce a
2817
+ // line: the browser distributes each line's slack between its WORD GAPS, so
2818
+ // every word after the first sits somewhere no advance arithmetic predicts —
2819
+ // it has to be measured. One Range per word, only on justified blocks, so the
2820
+ // cost lands exactly where the problem is.
2821
+ //
2822
+ // Anchors are written only where they are trustworthy: an axis-aligned chain
2823
+ // (a bbox'd rect is not a word), no RTL anywhere (offsets here are logical
2824
+ // and an anchor is a visual claim — `fragmentsOf` refuses bidi the same way),
2825
+ // and lines with at least two words. Every line of the block gets them,
2826
+ // including the unjustified last one: its anchors are exact too, and one rule
2827
+ // with no special case beats two rules with one.
2828
+ if (align === 'justify' && !chain.skewed && direction !== 'rtl' && !RTL_CHARS.test(characters)) {
2829
+ // characters index -> rendered index. `rToChar` is the other direction, and
2830
+ // LSEP positions (which no word contains) simply stay -1.
2831
+ const rOf = new Int32Array(characters.length).fill(-1)
2832
+ for (let k = 0; k < rendered.length; k++) if (rToChar[k] >= 0) rOf[rToChar[k]] = k
2833
+ // Pieces are in rendered order, so a forward cursor finds each word's piece
2834
+ // without rescanning — words are visited left to right.
2835
+ const pieceAt = (r, fromIndex) => {
2836
+ for (let i = fromIndex; i < pieces.length; i++) {
2837
+ const p = pieces[i]
2838
+ if (p.rStart <= r && r < p.rEnd && p.to > p.from) return i
2839
+ }
2840
+ return -1
2841
+ }
2842
+ // First DOM offset of `p` whose rendered index has reached `r`. `rAt` is
2843
+ // monotone (built forward, holes filled from the right), so this is a plain
2844
+ // lower bound.
2845
+ const domAt = (p, r) => {
2846
+ const rAt = p.t.rAt
2847
+ let lo = p.from
2848
+ let hi = p.to
2849
+ while (lo < hi) {
2850
+ const mid = (lo + hi) >> 1
2851
+ if (rAt[mid] >= r) hi = mid
2852
+ else lo = mid + 1
2853
+ }
2854
+ return lo
2855
+ }
2856
+ for (const line of lines) {
2857
+ const words = []
2858
+ let at = line.start
2859
+ while (at < line.end) {
2860
+ while (at < line.end && /\s/.test(characters[at])) at++
2861
+ if (at >= line.end) break
2862
+ let end = at
2863
+ while (end < line.end && !/\s/.test(characters[end])) end++
2864
+ words.push({ start: at, end })
2865
+ at = end
2866
+ }
2867
+ if (words.length < 2) continue
2868
+ let ok = true
2869
+ let cursor = 0
2870
+ let prevLeft = -Infinity
2871
+ for (const word of words) {
2872
+ const rA = rOf[word.start]
2873
+ const rB = rOf[word.end - 1]
2874
+ const ia = rA >= 0 ? pieceAt(rA, cursor) : -1
2875
+ const ib = ia >= 0 && rB >= rA ? pieceAt(rB, ia) : -1
2876
+ if (ib < 0) { ok = false; break }
2877
+ cursor = ia
2878
+ const pa = pieces[ia]
2879
+ const pb = pieces[ib]
2880
+ range.setStart(pa.t.node, domAt(pa, rA))
2881
+ range.setEnd(pb.t.node, Math.min(domAt(pb, rB) + 1, pb.to))
2882
+ let left = Infinity
2883
+ let right = -Infinity
2884
+ for (const r of range.getClientRects()) {
2885
+ if (!(r.width > 0) || !(r.height > 0)) continue
2886
+ if (r.left < left) left = r.left
2887
+ if (r.right > right) right = r.right
2888
+ }
2889
+ // A word whose rect walks BACKWARDS is a reorder this walk does not
2890
+ // model (or a wrap the line map disagrees with); the whole line's
2891
+ // anchors are dropped rather than half-trusted.
2892
+ if (!Number.isFinite(left) || left <= prevLeft) { ok = false; break }
2893
+ prevLeft = left
2894
+ word.x = (left - blockRect.left) / sx
2895
+ word.w = (right - left) / sx
2896
+ }
2897
+ if (ok) line.words = words
2898
+ }
2899
+ }
2900
+
2901
+ // ——— everything that is not exact says so ———
2902
+
2903
+ if (!/^horizontal/.test(blockCs.writingMode || 'horizontal-tb')) {
2904
+ warn(`writing-mode: ${blockCs.writingMode} — lines, baselines and pitch are reported in the horizontal model and do not describe the painted geometry`)
2905
+ }
2906
+ if (chain.skewed) {
2907
+ warn('a rotation or skew above this block makes every client rect an axis-aligned bounding box — x, w and baseline are the box, not the line')
2908
+ } else if (chain.scaled) {
2909
+ // Exact, and therefore not a degradation: an axis-aligned scale divides out
2910
+ // of a client rect without residue. It is still declared, because the numbers
2911
+ // here disagree with the ones the page reports and a reader has to know why.
2912
+ warn({
2913
+ code: 'collect.text-scale-normalized',
2914
+ grade: 'E',
2915
+ severity: 'info',
2916
+ message: `a scale of ${sx.toFixed(4)}x${sy.toFixed(4)} above this block was divided out of every ` +
2917
+ 'measurement — x, w, baseline, asc, desc and pitch are in the block\'s own untransformed units, ' +
2918
+ 'which is where the emitted transform applies it again',
2919
+ })
2920
+ }
2921
+ if (/justify/.test(blockCs.textAlign || '')) {
2922
+ warn(lines.some((l) => l.words)
2923
+ ? 'text-align: justify — the slack goes into the word gaps, so every word\'s x was measured and travels on `line.words`; a backend that anchors each word reproduces the justification exactly'
2924
+ : 'text-align: justify — the extra advance is distributed between words per line, so one advance width per line will not reproduce it (and no per-word anchors could be measured here: a skewed chain, RTL, or one-word lines)')
2925
+ }
2926
+ // Two different findings, and conflating them would name the wrong cause: a
2927
+ // fragmented line is only evidence of bidi when there is RTL text to reorder.
2928
+ // A tab stop, a hyphenation break and an ellipsis fragment lines just as well.
2929
+ if (RTL_CHARS.test(characters)) {
2930
+ warn('bidirectional text — visual order within a line is not logical order, and every offset here is logical')
2931
+ } else if (flags.fragmented) {
2932
+ warn(`${flags.fragmented} line(s) carry a text node as more than one visual fragment (a tab stop, a hyphenation break or a font fallback) — the fragments are reported as one contiguous range`)
2933
+ }
2934
+ if (EMOJI && EMOJI.test(characters)) {
2935
+ warn('emoji present — a colour glyph does not advance like the font measured here, so the line box does not expand the same way')
2936
+ }
2937
+ const smallCaps = []
2938
+ const stroked = []
2939
+ for (const id of Object.keys(styles)) {
2940
+ const st = styles[id]
2941
+ if (st.decorations.length > 1) {
2942
+ // They all paint. `<a>` underlined + `<span>` struck through is two lines
2943
+ // over the same glyphs, and every consumer this repo has holds one.
2944
+ warn(`${st.decorations.length} text-decorations paint over ${id} (${st.decorations.map(d => d.line).join(' + ')}) — ` +
2945
+ 'they are reported innermost first in `decorations`, and a consumer that holds a single ' +
2946
+ 'decoration keeps the first and loses the rest')
2947
+ }
2948
+ if (st.strokeWidth > EPS) stroked.push(`${id} (${st.strokeWidth}px)`)
2949
+ if (SMALL_CAPS.test(st.fontVariantCaps)) smallCaps.push(`${id} (${st.fontVariantCaps})`)
2950
+ if (st.color[3] === 0) {
2951
+ warn(`${id} paints with a fully transparent fill (-webkit-text-fill-color or background-clip: text) — a solid fill does not reproduce it`)
2952
+ }
2953
+ }
2954
+ // Both of these used to travel in the style and be dropped by every backend
2955
+ // without a word. Collecting them is not enough — a consumer that ignores them
2956
+ // paints the wrong glyphs (small caps) or a filled shape where the page paints
2957
+ // a hollow one (a stroke), and the reader has to be told which one it is.
2958
+ if (smallCaps.length) {
2959
+ warn({
2960
+ code: 'collect.text-small-caps',
2961
+ grade: 'A',
2962
+ severity: 'warn',
2963
+ message: `font-variant-caps repaints ${smallCaps.join(', ')} as capitals. The value travels on the run ` +
2964
+ 'style as `variant`; a backend that ignores it paints the lower-case letters at full size, and one ' +
2965
+ 'that has no small-cap face can only synthesise them by drawing the lower-case letters upper-case at ' +
2966
+ 'about 0.8em (`emit/pdf.js` does exactly that), which is a different set of shapes from a designed face',
2967
+ })
2968
+ }
2969
+ if (stroked.length) {
2970
+ warn({
2971
+ code: 'collect.text-stroke',
2972
+ grade: 'A',
2973
+ severity: 'warn',
2974
+ message: `-webkit-text-stroke paints over ${stroked.join(', ')}. The width and the colour travel on the ` +
2975
+ 'run style as `strokeWidth`/`strokeColor`; a backend that ignores them paints solid glyphs where the ' +
2976
+ 'page paints outlined ones. The stroke is also CENTRED on the glyph outline and half of it falls ' +
2977
+ 'outside the rects measured here, so the line box is narrower than the ink',
2978
+ })
2979
+ }
2980
+ declareFamilies(styles, warn)
2981
+ if (blockCs.backgroundClip === 'text' || blockCs.webkitBackgroundClip === 'text') {
2982
+ warn('background-clip: text — the glyphs are a mask over the background and no solid text fill reproduces them')
2983
+ }
2984
+ if (blockCs.textOverflow && blockCs.textOverflow !== 'clip') {
2985
+ warn(`text-overflow: ${blockCs.textOverflow} — the painted ellipsis is not a character and is absent from characters`)
2986
+ }
2987
+ if (blockCs.webkitLineClamp && blockCs.webkitLineClamp !== 'none') {
2988
+ warn('-webkit-line-clamp — clamped lines paint nothing and report no rects, so text past the clamp has no geometry')
2989
+ }
2990
+ // Only when nothing was found: a hyphen that WAS found is reported by
2991
+ // `collect.text-hyphen` above, with the lines it sits on. This is the other
2992
+ // half — the block asked for hyphenation, several lines could have taken it,
2993
+ // and no break glyph turned up on any of them.
2994
+ if (blockCs.hyphens === 'auto' && lineCount > 1 && !hyphenated.length) {
2995
+ warn('hyphens: auto — no line of this block ends in a painted hyphen, so every break here fell on a ' +
2996
+ 'space or a character that already offered one; a hyphen the browser did paint is not a character of ' +
2997
+ 'the document and would be reported on `line.hyphen`, never inside `characters`')
2998
+ }
2999
+ if (flags.mixedShift) {
3000
+ warn('a run shifted by vertical-align wraps onto more than one line and its offset is not the same on ' +
3001
+ 'each of them — `dy` carries the offset it has on the first')
3002
+ }
3003
+ if (flags.holes) {
3004
+ warn(`${flags.holes} hole(s) across ${flags.holeLines} line(s): something that is not a character sits ` +
3005
+ 'inside the line (an image, an inline-block, a form control, or the padding of an inline box). ' +
3006
+ '`line.w` is the sum of the painted glyph advances and `line.frags` carries the measured x and width ' +
3007
+ 'of every stretch of glyphs — a backend that fits the whole string to one width stretches it across the hole')
3008
+ }
3009
+ if (flags.tabs) {
3010
+ warn('preserved tab characters — tab-stop advance is not modelled, so a line width containing one is approximate')
3011
+ }
3012
+ if (flags.capitalize && texts.length > 1) {
3013
+ warn('text-transform: capitalize is resolved one text node at a time, with the single preceding character for context — a word boundary that depends on more than that character may capitalize differently than it paints')
3014
+ }
3015
+ if (flags.transformUnresolved) {
3016
+ warn('text-transform could not be read back from the browser, or it changes the string length — characters carries the untransformed text at those positions')
3017
+ }
3018
+ if (flags.hidden) warn(`${flags.hidden} text node(s) under visibility:hidden were left out of the text`)
3019
+ if (flags.noRects) warn(`${flags.noRects} text node(s) have text but no client rects — clipped, clamped, or not laid out`)
3020
+ if (flags.ruby) warn('ruby annotations (rt/rp) were left out — they paint on their own baseline and belong to their own node')
3021
+ if (flags.shadow) warn('shadow DOM or slotted content inside this block is out of scope for v0 and its text was not collected')
3022
+ if (flags.contentVisibility) warn('a content-visibility:hidden subtree was skipped — its text is not laid out and has no geometry')
3023
+
3024
+ return { characters, lines, runs, pitch, align, direction, styles, sourceText, warnings }
3025
+ }
3026
+
3027
+ export default collectText