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