@symbo.ls/brender 3.14.677 → 3.14.679
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 +55 -0
- package/README.md +12 -0
- package/dist/esm/drawnFaces.js +3 -0
- package/dist/esm/env.js +1 -1
- package/dist/esm/faces.js +5 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/render.js +25 -28
- package/drawnFaces.js +783 -0
- package/env.js +291 -2
- package/faces.js +193 -0
- package/index.js +15 -2
- package/package.json +17 -7
- package/render.js +5 -1
package/drawnFaces.js
ADDED
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
// ── The faces a built page's elements draw ──────────────────────────────────
|
|
2
|
+
// XMA r12 results (mirc #503, item 2): window.__smblsFacesReady and
|
|
3
|
+
// `brender.faces.inline: true` took every face of a family the page's styles
|
|
4
|
+
// name, when any character of the whole page was in its range — a page that
|
|
5
|
+
// draws Playfair Display only in bold headings still asked for its regular
|
|
6
|
+
// face, ~60 KB a page never needs.
|
|
7
|
+
//
|
|
8
|
+
// This reads the page as the browser would draw it: each element's
|
|
9
|
+
// font-family, font-weight and font-style from the rules that match it (the
|
|
10
|
+
// cascade: origin, !important, specificity, order; inheritance; var(); the
|
|
11
|
+
// `font` shorthand; the browser's own bold headings, italic emphasis and
|
|
12
|
+
// system-font form controls), then the characters of the element's own text
|
|
13
|
+
// against the faces sheet (`<style data-smbls-faces>`) by the browser's face
|
|
14
|
+
// matching (style, then weight; then unicode-range per character, falling to
|
|
15
|
+
// the next family for a character no face of a family covers).
|
|
16
|
+
//
|
|
17
|
+
// What can change after the first view counts too, so no face that may be
|
|
18
|
+
// drawn is left out: a rule under @media / @supports / @container / @layer,
|
|
19
|
+
// and a rule whose selector names a state (:hover, :focus, :checked, …) or
|
|
20
|
+
// :lang(), adds its value as a possibility wherever it would win. A rule for
|
|
21
|
+
// `::placeholder` reaches the field's placeholder, one for `::before` /
|
|
22
|
+
// `::after` the strings and attr() of its `content`; other generated content
|
|
23
|
+
// (`::marker`, counters' own text) is not read. A name in a family list that
|
|
24
|
+
// the sheet does not declare (a system font) is passed over, so the next
|
|
25
|
+
// family counts for every character; a generic family (`sans-serif`,
|
|
26
|
+
// `system-ui`, …) ends the list.
|
|
27
|
+
//
|
|
28
|
+
// The page's stylesheets are its `<style>` elements, its
|
|
29
|
+
// `<link rel="stylesheet">` files and their @import files, read by
|
|
30
|
+
// `options.readSheet(href, from)` (`from`: the address of the sheet that
|
|
31
|
+
// imports it, none for the page) — a `<link data-smbls-font>` (a font
|
|
32
|
+
// service's sheet of @font-face rules, smbls fontFaces.js) is not read.
|
|
33
|
+
//
|
|
34
|
+
// Returns Map<index, chars>: `index` the n-th `@font-face` rule of the faces
|
|
35
|
+
// sheet (in order, as /@font-face\s*\{([^}]*)\}/g finds them), `chars` the
|
|
36
|
+
// characters the page draws in it (unique, in order of first use) — or null
|
|
37
|
+
// when a stylesheet of the page cannot be read (no readSheet, another origin,
|
|
38
|
+
// a sheet that does not parse): any face may be drawn then.
|
|
39
|
+
import { parseHTML } from 'linkedom'
|
|
40
|
+
import { stylesheetRules } from './pageWeight.js'
|
|
41
|
+
|
|
42
|
+
const FACES_STYLE = /<style\b[^>]*\bdata-smbls-faces\b[^>]*>([\s\S]*?)<\/style\s*>/i
|
|
43
|
+
const FACE_RULE = /@font-face\s*\{([^}]*)\}/gi
|
|
44
|
+
const RAW = /<(script|template|noscript)\b([^>]*)>[\s\S]*?<\/\1\s*>/gi
|
|
45
|
+
|
|
46
|
+
// At most this many values per property and element: a page whose
|
|
47
|
+
// variables branch more is read with the first ones.
|
|
48
|
+
const MAX_VALUES = 8
|
|
49
|
+
|
|
50
|
+
const GENERIC = new Set([
|
|
51
|
+
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui', 'ui-serif', 'ui-sans-serif',
|
|
52
|
+
'ui-monospace', 'ui-rounded', 'math', 'emoji', 'fangsong', '-apple-system', 'blinkmacsystemfont'
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
// Selector states: matched as if they held (the value is a possibility).
|
|
56
|
+
const STATES = new Set([
|
|
57
|
+
'hover', 'focus', 'focus-visible', 'focus-within', 'active', 'visited', 'link', 'any-link', 'target',
|
|
58
|
+
'target-within', 'checked', 'indeterminate', 'default', 'disabled', 'enabled', 'read-only', 'read-write',
|
|
59
|
+
'required', 'optional', 'valid', 'invalid', 'user-valid', 'user-invalid', 'in-range', 'out-of-range',
|
|
60
|
+
'placeholder-shown', 'autofill', '-webkit-autofill', 'open', 'closed', 'popover-open', 'modal',
|
|
61
|
+
'fullscreen', 'picture-in-picture', 'playing', 'paused', 'current', 'past', 'future', 'defined',
|
|
62
|
+
'lang', 'dir', 'state', 'blank', 'local-link', 'scope', 'host', 'host-context'
|
|
63
|
+
])
|
|
64
|
+
const LEGACY_PSEUDO_ELEMENTS = new Set(['before', 'after', 'first-line', 'first-letter'])
|
|
65
|
+
const SELECTOR_LISTS = new Set(['is', 'where', 'matches', '-webkit-any', '-moz-any', 'has'])
|
|
66
|
+
|
|
67
|
+
// The browser's own rules (lowest in the cascade): bold headings, italic
|
|
68
|
+
// emphasis, a system font in form controls (`font: inherit` undoes it).
|
|
69
|
+
const UA_RULES = `h1,h2,h3,h4,h5,h6,th{font-weight:bold}b,strong{font-weight:bolder}
|
|
70
|
+
em,i,cite,var,dfn,address{font-style:italic}code,kbd,samp,pre,tt,listing,xmp,plaintext{font-family:monospace}
|
|
71
|
+
input,textarea,select,button{font-family:system-ui;font-weight:400;font-style:normal}`
|
|
72
|
+
|
|
73
|
+
const FONT_PROPS = new Set(['font', 'font-family', 'font-weight', 'font-style'])
|
|
74
|
+
const ALWAYS_MEDIA = /^\s*(?:only\s+)?(?:all|screen)\s*$/i
|
|
75
|
+
const VAR_NAME = /var\(\s*(--[\w-]+)/g
|
|
76
|
+
|
|
77
|
+
// ── Text helpers ────────────────────────────────────────────────────────────
|
|
78
|
+
// The end of a balanced (…) that opens at `at` (quotes skipped); -1 if none.
|
|
79
|
+
const closeOf = (text, at) => {
|
|
80
|
+
let depth = 0
|
|
81
|
+
let quote = null
|
|
82
|
+
for (let i = at; i < text.length; i++) {
|
|
83
|
+
const ch = text[i]
|
|
84
|
+
if (quote) {
|
|
85
|
+
if (ch === '\\') i++
|
|
86
|
+
else if (ch === quote) quote = null
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
if (ch === '"' || ch === "'") quote = ch
|
|
90
|
+
else if (ch === '(') depth++
|
|
91
|
+
else if (ch === ')' && !--depth) return i
|
|
92
|
+
}
|
|
93
|
+
return -1
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Split at top-level `sep` (outside quotes, (), [], {}).
|
|
97
|
+
const splitTop = (text, sep) => {
|
|
98
|
+
const out = []
|
|
99
|
+
let depth = 0
|
|
100
|
+
let quote = null
|
|
101
|
+
let start = 0
|
|
102
|
+
for (let i = 0; i < text.length; i++) {
|
|
103
|
+
const ch = text[i]
|
|
104
|
+
if (quote) {
|
|
105
|
+
if (ch === '\\') i++
|
|
106
|
+
else if (ch === quote) quote = null
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
if (ch === '"' || ch === "'") quote = ch
|
|
110
|
+
else if (ch === '(' || ch === '[' || ch === '{') depth++
|
|
111
|
+
else if (ch === ')' || ch === ']' || ch === '}') depth = Math.max(0, depth - 1)
|
|
112
|
+
else if (ch === sep && !depth) {
|
|
113
|
+
out.push(text.slice(start, i))
|
|
114
|
+
start = i + 1
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
out.push(text.slice(start))
|
|
118
|
+
return out
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const IDENT_CHAR = /[\w\- -]/
|
|
122
|
+
const identEnd = (text, at) => {
|
|
123
|
+
let i = at
|
|
124
|
+
while (i < text.length) {
|
|
125
|
+
if (text[i] === '\\') i += 2
|
|
126
|
+
else if (IDENT_CHAR.test(text[i])) i++
|
|
127
|
+
else break
|
|
128
|
+
}
|
|
129
|
+
return i
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Selectors ───────────────────────────────────────────────────────────────
|
|
133
|
+
// Specificity of one complex selector, as one number (a, b, c → a·1e6 + b·1e3 + c).
|
|
134
|
+
const specificityOf = (sel) => {
|
|
135
|
+
let a = 0
|
|
136
|
+
let b = 0
|
|
137
|
+
let c = 0
|
|
138
|
+
const add = (n) => {
|
|
139
|
+
a += Math.floor(n / 1e6)
|
|
140
|
+
b += Math.floor(n / 1e3) % 1e3
|
|
141
|
+
c += n % 1e3
|
|
142
|
+
}
|
|
143
|
+
for (let i = 0; i < sel.length;) {
|
|
144
|
+
const ch = sel[i]
|
|
145
|
+
if (ch === '"' || ch === "'") {
|
|
146
|
+
const end = sel.indexOf(ch, i + 1)
|
|
147
|
+
i = end === -1 ? sel.length : end + 1
|
|
148
|
+
} else if (ch === '#') {
|
|
149
|
+
a++
|
|
150
|
+
i = identEnd(sel, i + 1)
|
|
151
|
+
} else if (ch === '.') {
|
|
152
|
+
b++
|
|
153
|
+
i = identEnd(sel, i + 1)
|
|
154
|
+
} else if (ch === '[') {
|
|
155
|
+
b++
|
|
156
|
+
const end = sel.indexOf(']', i)
|
|
157
|
+
i = end === -1 ? sel.length : end + 1
|
|
158
|
+
} else if (ch === ':') {
|
|
159
|
+
const element = sel[i + 1] === ':'
|
|
160
|
+
const start = i + (element ? 2 : 1)
|
|
161
|
+
const end = identEnd(sel, start)
|
|
162
|
+
const name = sel.slice(start, end).toLowerCase()
|
|
163
|
+
let args = null
|
|
164
|
+
let next = end
|
|
165
|
+
if (sel[end] === '(') {
|
|
166
|
+
const close = closeOf(sel, end)
|
|
167
|
+
args = sel.slice(end + 1, close === -1 ? sel.length : close)
|
|
168
|
+
next = close === -1 ? sel.length : close + 1
|
|
169
|
+
}
|
|
170
|
+
if (element || LEGACY_PSEUDO_ELEMENTS.has(name)) c++
|
|
171
|
+
else if (name === 'where') { /* 0 */ } else if (SELECTOR_LISTS.has(name) || name === 'not') {
|
|
172
|
+
if (args) add(Math.max(0, ...splitTop(args, ',').map((s) => specificityOf(s.trim()))))
|
|
173
|
+
} else b++
|
|
174
|
+
i = next
|
|
175
|
+
} else if (ch === '*' || ch === '>' || ch === '+' || ch === '~' || ch === '|' || /\s/.test(ch)) i++
|
|
176
|
+
else if (IDENT_CHAR.test(ch) || ch === '\\') {
|
|
177
|
+
c++
|
|
178
|
+
i = identEnd(sel, i)
|
|
179
|
+
} else i++
|
|
180
|
+
}
|
|
181
|
+
return a * 1e6 + b * 1e3 + c
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// The selector css-select can match: states and pseudo-elements become
|
|
185
|
+
// `:is(*)` (a no-op). `conditional` when a state, :lang() or a ::first-*
|
|
186
|
+
// was taken out; `target` 'element', 'placeholder' (::placeholder),
|
|
187
|
+
// 'before' / 'after', or null for what draws no text of the page's
|
|
188
|
+
// (::marker, ::selection, …).
|
|
189
|
+
const staticSelector = (sel) => {
|
|
190
|
+
let conditional = false
|
|
191
|
+
let target = 'element'
|
|
192
|
+
const walk = (text) => {
|
|
193
|
+
let out = ''
|
|
194
|
+
for (let i = 0; i < text.length;) {
|
|
195
|
+
const ch = text[i]
|
|
196
|
+
if (ch === '"' || ch === "'") {
|
|
197
|
+
const end = text.indexOf(ch, i + 1)
|
|
198
|
+
const stop = end === -1 ? text.length : end + 1
|
|
199
|
+
out += text.slice(i, stop)
|
|
200
|
+
i = stop
|
|
201
|
+
} else if (ch === '[') {
|
|
202
|
+
const end = text.indexOf(']', i)
|
|
203
|
+
const stop = end === -1 ? text.length : end + 1
|
|
204
|
+
out += text.slice(i, stop)
|
|
205
|
+
i = stop
|
|
206
|
+
} else if (ch === ':') {
|
|
207
|
+
const element = text[i + 1] === ':'
|
|
208
|
+
const start = i + (element ? 2 : 1)
|
|
209
|
+
const end = identEnd(text, start)
|
|
210
|
+
const name = text.slice(start, end).toLowerCase()
|
|
211
|
+
let args = null
|
|
212
|
+
let next = end
|
|
213
|
+
if (text[end] === '(') {
|
|
214
|
+
const close = closeOf(text, end)
|
|
215
|
+
args = text.slice(end + 1, close === -1 ? text.length : close)
|
|
216
|
+
next = close === -1 ? text.length : close + 1
|
|
217
|
+
}
|
|
218
|
+
if (element || LEGACY_PSEUDO_ELEMENTS.has(name)) {
|
|
219
|
+
if (name === 'placeholder' || name === '-webkit-input-placeholder' || name === '-moz-placeholder') target = 'placeholder'
|
|
220
|
+
else if (name === 'before' || name === 'after') target = name
|
|
221
|
+
else if (name === 'first-line' || name === 'first-letter') conditional = true
|
|
222
|
+
else target = null
|
|
223
|
+
out += ':is(*)'
|
|
224
|
+
} else if (name === 'not' && args !== null) {
|
|
225
|
+
const inner = staticSelectorList(args)
|
|
226
|
+
// a state inside :not() — drop the :not() (matches more, never less)
|
|
227
|
+
if (inner.conditional) {
|
|
228
|
+
conditional = true
|
|
229
|
+
out += ':is(*)'
|
|
230
|
+
} else out += `:not(${inner.text})`
|
|
231
|
+
} else if (SELECTOR_LISTS.has(name) && args !== null) {
|
|
232
|
+
const inner = staticSelectorList(args)
|
|
233
|
+
if (inner.conditional) conditional = true
|
|
234
|
+
out += `:${name === 'matches' || name.endsWith('-any') ? 'is' : name}(${inner.text || '*'})`
|
|
235
|
+
} else if (STATES.has(name) || name.startsWith('-webkit-') || name.startsWith('-moz-')) {
|
|
236
|
+
conditional = true
|
|
237
|
+
out += ':is(*)'
|
|
238
|
+
} else out += text.slice(i, next)
|
|
239
|
+
i = next
|
|
240
|
+
} else {
|
|
241
|
+
out += ch
|
|
242
|
+
i++
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return out
|
|
246
|
+
}
|
|
247
|
+
const text = walk(sel)
|
|
248
|
+
return { text, conditional, target }
|
|
249
|
+
}
|
|
250
|
+
// What is left when css-select still cannot match a selector: every
|
|
251
|
+
// pseudo-class out (it matches more, never less).
|
|
252
|
+
const looseSelector = (text) => {
|
|
253
|
+
let out = ''
|
|
254
|
+
for (let i = 0; i < text.length;) {
|
|
255
|
+
const ch = text[i]
|
|
256
|
+
if (ch === '"' || ch === "'" || ch === '[') {
|
|
257
|
+
const end = text.indexOf(ch === '[' ? ']' : ch, i + 1)
|
|
258
|
+
const stop = end === -1 ? text.length : end + 1
|
|
259
|
+
out += text.slice(i, stop)
|
|
260
|
+
i = stop
|
|
261
|
+
} else if (ch === ':') {
|
|
262
|
+
let end = identEnd(text, i + (text[i + 1] === ':' ? 2 : 1))
|
|
263
|
+
if (text[end] === '(') {
|
|
264
|
+
const close = closeOf(text, end)
|
|
265
|
+
end = close === -1 ? text.length : close + 1
|
|
266
|
+
}
|
|
267
|
+
out += ':is(*)'
|
|
268
|
+
i = end
|
|
269
|
+
} else {
|
|
270
|
+
out += ch
|
|
271
|
+
i++
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return out
|
|
275
|
+
}
|
|
276
|
+
const staticSelectorList = (list) => {
|
|
277
|
+
let conditional = false
|
|
278
|
+
const parts = splitTop(list, ',').map((s) => {
|
|
279
|
+
const r = staticSelector(s.trim())
|
|
280
|
+
if (r.conditional) conditional = true
|
|
281
|
+
return r.text || '*'
|
|
282
|
+
})
|
|
283
|
+
return { text: parts.join(', '), conditional }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ── Declarations ────────────────────────────────────────────────────────────
|
|
287
|
+
// The declarations of a block (its nested rules aside) that name a font
|
|
288
|
+
// property, `content` or a custom property.
|
|
289
|
+
const declarationsOf = (block) => {
|
|
290
|
+
const out = []
|
|
291
|
+
for (const part of splitTop(block.replace(/\/\*[\s\S]*?\*\//g, ''), ';')) {
|
|
292
|
+
if (part.includes('{')) continue
|
|
293
|
+
const colon = part.indexOf(':')
|
|
294
|
+
if (colon === -1) continue
|
|
295
|
+
const prop = part.slice(0, colon).trim()
|
|
296
|
+
const key = prop.startsWith('--') ? prop : prop.toLowerCase()
|
|
297
|
+
if (!FONT_PROPS.has(key) && key !== 'content' && !key.startsWith('--')) continue
|
|
298
|
+
let value = part.slice(colon + 1).trim()
|
|
299
|
+
const important = /!\s*important\s*$/i.test(value)
|
|
300
|
+
if (important) value = value.replace(/!\s*important\s*$/i, '').trim()
|
|
301
|
+
out.push({ prop: key, value, important })
|
|
302
|
+
}
|
|
303
|
+
return out
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Every style rule of a stylesheet text, at any depth of @media /
|
|
307
|
+
// @supports / @container / @layer (and CSS nesting), and of the files its
|
|
308
|
+
// @import rules name (`ctx.readSheet`): { selector, block, conditional }.
|
|
309
|
+
// `ctx.unknown` turns true for a sheet that cannot be read.
|
|
310
|
+
const IMPORT = /^@import\s+(?:url\(\s*)?(['"]?)([^'")\s]+)\1\s*\)?\s*([^;]*)/i
|
|
311
|
+
const styleRulesOf = (css, conditional, ctx, from, parent = null, into = []) => {
|
|
312
|
+
const rules = stylesheetRules(css)
|
|
313
|
+
if (!rules) {
|
|
314
|
+
ctx.unknown = true
|
|
315
|
+
return into
|
|
316
|
+
}
|
|
317
|
+
for (const rule of rules) {
|
|
318
|
+
const open = rule.indexOf('{')
|
|
319
|
+
if (open === -1) {
|
|
320
|
+
const m = IMPORT.exec(rule)
|
|
321
|
+
if (!m) continue
|
|
322
|
+
const sheet = ctx.readSheet ? ctx.readSheet(m[2], from) : null
|
|
323
|
+
if (typeof sheet !== 'string') ctx.unknown = true
|
|
324
|
+
else styleRulesOf(sheet, conditional || (!!m[3].trim() && !ALWAYS_MEDIA.test(m[3])), ctx, m[2], parent, into)
|
|
325
|
+
continue
|
|
326
|
+
}
|
|
327
|
+
if (!rule.endsWith('}')) continue
|
|
328
|
+
const prelude = rule.slice(0, open).trim()
|
|
329
|
+
const body = rule.slice(open + 1, -1)
|
|
330
|
+
if (prelude[0] === '@') {
|
|
331
|
+
const name = (/^@([\w-]+)/.exec(prelude) || [])[1]?.toLowerCase()
|
|
332
|
+
if (name === 'media') styleRulesOf(body, conditional || !ALWAYS_MEDIA.test(prelude.slice(6)), ctx, from, parent, into)
|
|
333
|
+
// a layer reorders the cascade: its rules count as possibilities
|
|
334
|
+
else if (name === 'supports' || name === 'container' || name === 'document' || name === 'scope' || name === 'starting-style' || name === 'layer') {
|
|
335
|
+
styleRulesOf(body, true, ctx, from, parent, into)
|
|
336
|
+
}
|
|
337
|
+
continue
|
|
338
|
+
}
|
|
339
|
+
const selector = parent
|
|
340
|
+
? splitTop(prelude, ',').map((s) => s.includes('&') ? s.replace(/&/g, `:is(${parent})`) : `:is(${parent}) ${s.trim()}`).join(', ')
|
|
341
|
+
: prelude
|
|
342
|
+
into.push({ selector, block: body, conditional })
|
|
343
|
+
// nested rules (CSS nesting)
|
|
344
|
+
if (body.includes('{')) {
|
|
345
|
+
const nested = body.slice(body.search(/[^;{}]*\{/))
|
|
346
|
+
styleRulesOf(nested, conditional, ctx, from, selector, into)
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return into
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ── Values ──────────────────────────────────────────────────────────────────
|
|
353
|
+
// var() in `value`, from `vars` (name → values): every resulting text (at
|
|
354
|
+
// most MAX_VALUES); null when a var() has no value and no fallback.
|
|
355
|
+
const substitute = (value, vars, depth = 0) => {
|
|
356
|
+
const at = value.indexOf('var(')
|
|
357
|
+
if (at === -1) return [value]
|
|
358
|
+
if (depth > 8) return []
|
|
359
|
+
const close = closeOf(value, at + 3)
|
|
360
|
+
if (close === -1) return []
|
|
361
|
+
const inner = value.slice(at + 4, close)
|
|
362
|
+
const comma = splitTop(inner, ',')
|
|
363
|
+
const name = comma[0].trim()
|
|
364
|
+
const fallback = comma.length > 1 ? comma.slice(1).join(',').trim() : null
|
|
365
|
+
const known = vars.get(name)
|
|
366
|
+
const options = known && known.length ? known : fallback !== null ? substitute(fallback, vars, depth + 1) : []
|
|
367
|
+
const out = []
|
|
368
|
+
for (const option of options) {
|
|
369
|
+
for (const rest of substitute(value.slice(0, at) + option + value.slice(close + 1), vars, depth + 1)) {
|
|
370
|
+
if (!out.includes(rest)) out.push(rest)
|
|
371
|
+
if (out.length >= MAX_VALUES) return out
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return out
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const unquote = (name) => name.trim().replace(/^(['"])([\s\S]*)\1$/, '$2').replace(/\\(.)/g, '$1').trim().toLowerCase()
|
|
378
|
+
const familiesOf = (value) => splitTop(value, ',').map(unquote).filter(Boolean)
|
|
379
|
+
|
|
380
|
+
const WEIGHT_WORDS = { normal: 400, bold: 700 }
|
|
381
|
+
const weightOf = (value, parent) => {
|
|
382
|
+
const v = String(value).trim().toLowerCase()
|
|
383
|
+
if (v in WEIGHT_WORDS) return WEIGHT_WORDS[v]
|
|
384
|
+
if (v === 'bolder') return parent < 350 ? 400 : parent < 550 ? 700 : 900
|
|
385
|
+
if (v === 'lighter') return parent < 550 ? 100 : parent < 750 ? 400 : 700
|
|
386
|
+
const n = Number(v)
|
|
387
|
+
return Number.isFinite(n) && n >= 1 && n <= 1000 ? n : null
|
|
388
|
+
}
|
|
389
|
+
const styleOf = (value) => {
|
|
390
|
+
const v = String(value).trim().toLowerCase().split(/\s+/)[0]
|
|
391
|
+
return v === 'italic' || v === 'oblique' || v === 'normal' ? v : null
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// The text a `content` value draws: its strings (CSS escapes read) and
|
|
395
|
+
// attr() values; '' for none, normal, url(), counters and quotes.
|
|
396
|
+
const CSS_ESCAPE = /\\([0-9a-f]{1,6})\s?|\\([\s\S])/gi
|
|
397
|
+
const contentText = (value, el) => {
|
|
398
|
+
let out = ''
|
|
399
|
+
for (const m of value.matchAll(/(["'])((?:\\[\s\S]|(?!\1)[^\\])*)\1|attr\(\s*([\w-]+)[^)]*\)/gi)) {
|
|
400
|
+
if (m[3]) out += el.getAttribute(m[3]) || ''
|
|
401
|
+
else {
|
|
402
|
+
out += m[2].replace(CSS_ESCAPE, (all, hex, ch) => {
|
|
403
|
+
if (!hex) return ch
|
|
404
|
+
const code = parseInt(hex, 16)
|
|
405
|
+
return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : '\ufffd'
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return out
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const SYSTEM_FONTS = new Set(['caption', 'icon', 'menu', 'message-box', 'small-caption', 'status-bar'])
|
|
413
|
+
// The `font` shorthand → { family, weight, style } (null: not a font).
|
|
414
|
+
const fontShorthand = (value) => {
|
|
415
|
+
const v = value.trim()
|
|
416
|
+
if (SYSTEM_FONTS.has(v.toLowerCase())) return { family: 'system-ui', weight: 'normal', style: 'normal' }
|
|
417
|
+
const tokens = []
|
|
418
|
+
let rest = v
|
|
419
|
+
// the tokens before the size: style, variant, weight, stretch
|
|
420
|
+
for (;;) {
|
|
421
|
+
const m = /^\s*([^\s,'"]+)/.exec(rest)
|
|
422
|
+
if (!m) break
|
|
423
|
+
const word = m[1]
|
|
424
|
+
if (/^[\d.]/.test(word) && !/^\d{1,4}$/.test(word)) break // a size (12px, 1.2em, 100%)
|
|
425
|
+
if (/^\d{1,4}$/.test(word) && Number(word) > 1000) break
|
|
426
|
+
const low = word.toLowerCase()
|
|
427
|
+
if (/^(xx-small|x-small|small|medium|large|x-large|xx-large|xxx-large|larger|smaller|calc\(|clamp\(|min\(|max\()/.test(low)) break
|
|
428
|
+
tokens.push(low)
|
|
429
|
+
rest = rest.slice(m[0].length)
|
|
430
|
+
if (tokens.length > 4) break
|
|
431
|
+
}
|
|
432
|
+
// the size [/ line-height], then the families
|
|
433
|
+
const size = /^\s*(?:[^\s/]+|(?:calc|clamp|min|max)\([^)]*\))(?:\s*\/\s*[^\s]+)?\s+/.exec(rest)
|
|
434
|
+
if (!size) return null
|
|
435
|
+
const family = rest.slice(size[0].length).trim()
|
|
436
|
+
if (!family) return null
|
|
437
|
+
let weight = 'normal'
|
|
438
|
+
let style = 'normal'
|
|
439
|
+
for (const t of tokens) {
|
|
440
|
+
if (t === 'italic' || t === 'oblique') style = t
|
|
441
|
+
else if (t === 'bold' || t === 'bolder' || t === 'lighter' || /^\d{1,4}$/.test(t)) weight = t
|
|
442
|
+
}
|
|
443
|
+
return { family, weight, style }
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ── The faces sheet ─────────────────────────────────────────────────────────
|
|
447
|
+
const descriptor = (body, name) => {
|
|
448
|
+
const m = new RegExp(`(?:^|;|\\s)${name}\\s*:\\s*([^;]+)`, 'i').exec(body)
|
|
449
|
+
return m ? m[1].trim() : null
|
|
450
|
+
}
|
|
451
|
+
const rangesOf = (text) => {
|
|
452
|
+
if (!text) return null
|
|
453
|
+
const out = []
|
|
454
|
+
for (const part of String(text).split(',')) {
|
|
455
|
+
const p = part.trim().replace(/^u\+/i, '')
|
|
456
|
+
if (!p) continue
|
|
457
|
+
if (p.includes('?')) {
|
|
458
|
+
out.push([parseInt(p.replace(/\?/g, '0'), 16), parseInt(p.replace(/\?/g, 'f'), 16)])
|
|
459
|
+
continue
|
|
460
|
+
}
|
|
461
|
+
const [x, y] = p.split('-')
|
|
462
|
+
const from = parseInt(x, 16)
|
|
463
|
+
out.push([from, y ? parseInt(y, 16) : from])
|
|
464
|
+
}
|
|
465
|
+
return out.length ? out : null
|
|
466
|
+
}
|
|
467
|
+
const facesOf = (sheet) => {
|
|
468
|
+
const faces = []
|
|
469
|
+
let index = 0
|
|
470
|
+
for (const m of sheet.matchAll(FACE_RULE)) {
|
|
471
|
+
const body = m[1]
|
|
472
|
+
const family = unquote(descriptor(body, 'font-family') || '')
|
|
473
|
+
const weights = String(descriptor(body, 'font-weight') || '400').trim().split(/\s+/).map((w) => weightOf(w, 400) || 400)
|
|
474
|
+
faces.push({
|
|
475
|
+
index: index++,
|
|
476
|
+
family,
|
|
477
|
+
min: Math.min(...weights),
|
|
478
|
+
max: Math.max(...weights),
|
|
479
|
+
style: styleOf(descriptor(body, 'font-style') || 'normal') || 'normal',
|
|
480
|
+
ranges: rangesOf(descriptor(body, 'unicode-range'))
|
|
481
|
+
})
|
|
482
|
+
}
|
|
483
|
+
return faces
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// The browser's face matching (CSS Fonts 4, §5.2): style first, then weight.
|
|
487
|
+
const STYLE_ORDER = { italic: ['italic', 'oblique', 'normal'], oblique: ['oblique', 'italic', 'normal'], normal: ['normal', 'oblique', 'italic'] }
|
|
488
|
+
const byStyle = (faces, style) => {
|
|
489
|
+
for (const s of STYLE_ORDER[style] || STYLE_ORDER.normal) {
|
|
490
|
+
const got = faces.filter((f) => f.style === s)
|
|
491
|
+
if (got.length) return got
|
|
492
|
+
}
|
|
493
|
+
return faces
|
|
494
|
+
}
|
|
495
|
+
const byWeight = (faces, w) => {
|
|
496
|
+
const within = faces.filter((f) => f.min <= w && w <= f.max)
|
|
497
|
+
if (within.length) return within
|
|
498
|
+
const below = faces.filter((f) => f.max < w).sort((x, y) => y.max - x.max)
|
|
499
|
+
const above = faces.filter((f) => f.min > w).sort((x, y) => x.min - y.min)
|
|
500
|
+
let pick
|
|
501
|
+
if (w >= 400 && w <= 500) pick = above.find((f) => f.min <= 500) || below[0] || above[0]
|
|
502
|
+
else if (w < 400) pick = below[0] || above[0]
|
|
503
|
+
else pick = above[0] || below[0]
|
|
504
|
+
return pick ? faces.filter((f) => f.min === pick.min && f.max === pick.max) : []
|
|
505
|
+
}
|
|
506
|
+
const covers = (face, code) => !face.ranges || face.ranges.some(([from, to]) => code >= from && code <= to)
|
|
507
|
+
|
|
508
|
+
// ── The page ────────────────────────────────────────────────────────────────
|
|
509
|
+
// The cascade's order of origins: the browser's rules, the page's, the
|
|
510
|
+
// page's !important ones (the browser's !important ones are not read).
|
|
511
|
+
const levelOf = (d) => (d.ua ? 0 : d.important ? 2 : 1)
|
|
512
|
+
const beats = (x, y) => {
|
|
513
|
+
const lx = levelOf(x)
|
|
514
|
+
const ly = levelOf(y)
|
|
515
|
+
if (lx !== ly) return lx > ly
|
|
516
|
+
if (x.rank !== y.rank) return x.rank > y.rank
|
|
517
|
+
return x.order > y.order
|
|
518
|
+
}
|
|
519
|
+
// The values that may win: the winner of the rules that always hold, and
|
|
520
|
+
// every conditional one that would beat it; `inherit` when no rule always
|
|
521
|
+
// holds (the inherited value stays possible).
|
|
522
|
+
const winnersOf = (list) => {
|
|
523
|
+
let base = null
|
|
524
|
+
for (const d of list) if (!d.conditional && (!base || beats(d, base))) base = d
|
|
525
|
+
const out = base ? [base] : []
|
|
526
|
+
for (const d of list) if (d.conditional && (!base || beats(d, base))) out.push(d)
|
|
527
|
+
return { list: out, inherit: !base }
|
|
528
|
+
}
|
|
529
|
+
const uniq = (values) => {
|
|
530
|
+
const out = []
|
|
531
|
+
const keys = new Set()
|
|
532
|
+
for (const v of values) {
|
|
533
|
+
const key = JSON.stringify(v)
|
|
534
|
+
if (keys.has(key)) continue
|
|
535
|
+
keys.add(key)
|
|
536
|
+
out.push(v)
|
|
537
|
+
if (out.length >= MAX_VALUES) break
|
|
538
|
+
}
|
|
539
|
+
return out
|
|
540
|
+
}
|
|
541
|
+
const varsIn = (text, into) => {
|
|
542
|
+
VAR_NAME.lastIndex = 0
|
|
543
|
+
let m
|
|
544
|
+
while ((m = VAR_NAME.exec(text))) into.add(m[1])
|
|
545
|
+
return into
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const ROOT = { vars: new Map(), family: [['serif']], weight: [400], style: ['normal'] }
|
|
549
|
+
const SKIP = new Set(['script', 'style', 'template', 'noscript', 'head', 'title'])
|
|
550
|
+
|
|
551
|
+
// One element's possible fonts, from its declarations and its parent's.
|
|
552
|
+
const fontOf = (decls, parent) => {
|
|
553
|
+
// custom properties (inherited): the raw values first, then var() in them
|
|
554
|
+
const vars = new Map(parent.vars)
|
|
555
|
+
const names = [...new Set(decls.filter((d) => d.prop.startsWith('--')).map((d) => d.prop))]
|
|
556
|
+
for (const name of names) {
|
|
557
|
+
const { list, inherit } = winnersOf(decls.filter((d) => d.prop === name))
|
|
558
|
+
const values = inherit ? [...(parent.vars.get(name) || [])] : []
|
|
559
|
+
for (const d of list) {
|
|
560
|
+
const v = d.value.trim()
|
|
561
|
+
if (/^(inherit|unset)$/i.test(v)) values.push(...(parent.vars.get(name) || []))
|
|
562
|
+
else if (!/^(initial|revert|revert-layer)$/i.test(v)) values.push(v)
|
|
563
|
+
}
|
|
564
|
+
vars.set(name, uniq(values))
|
|
565
|
+
}
|
|
566
|
+
for (const name of names) vars.set(name, uniq(vars.get(name).flatMap((v) => substitute(v, vars))))
|
|
567
|
+
const font = { vars }
|
|
568
|
+
// font-family, font-weight, font-style (the `font` shorthand sets all three)
|
|
569
|
+
for (const prop of ['family', 'weight', 'style']) {
|
|
570
|
+
const longhand = 'font-' + prop
|
|
571
|
+
const { list, inherit } = winnersOf(decls.filter((d) => d.prop === longhand || d.prop === 'font'))
|
|
572
|
+
const values = inherit ? [...parent[prop]] : []
|
|
573
|
+
for (const d of list) {
|
|
574
|
+
const before = values.length
|
|
575
|
+
for (const text of substitute(d.value, vars)) {
|
|
576
|
+
const keyword = text.trim().toLowerCase()
|
|
577
|
+
if (keyword === 'inherit' || keyword === 'unset') {
|
|
578
|
+
values.push(...parent[prop])
|
|
579
|
+
continue
|
|
580
|
+
}
|
|
581
|
+
if (keyword === 'initial' || keyword === 'revert' || keyword === 'revert-layer') {
|
|
582
|
+
values.push(...ROOT[prop])
|
|
583
|
+
continue
|
|
584
|
+
}
|
|
585
|
+
let raw = text
|
|
586
|
+
if (d.prop === 'font') {
|
|
587
|
+
const f = fontShorthand(text)
|
|
588
|
+
if (!f) continue
|
|
589
|
+
raw = f[prop]
|
|
590
|
+
}
|
|
591
|
+
if (prop === 'family') values.push(familiesOf(raw))
|
|
592
|
+
else if (prop === 'style') {
|
|
593
|
+
const st = styleOf(raw)
|
|
594
|
+
if (st) values.push(st)
|
|
595
|
+
} else {
|
|
596
|
+
for (const p of parent.weight) {
|
|
597
|
+
const w = weightOf(raw, p)
|
|
598
|
+
if (w) values.push(w)
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
// A value that is no value (a var() with none, a word that is not a
|
|
603
|
+
// font) leaves the property inherited.
|
|
604
|
+
if (values.length === before) values.push(...parent[prop])
|
|
605
|
+
}
|
|
606
|
+
font[prop] = uniq(values)
|
|
607
|
+
}
|
|
608
|
+
return font
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* The faces of the page's faces sheet that its elements draw.
|
|
613
|
+
* @param {string} html - a page built ahead (its faces sheet in place)
|
|
614
|
+
* @param {{ readSheet?: (href: string, from?: string) => string | null }} [options]
|
|
615
|
+
* @returns {Map<number, string> | null} faces sheet rule index → the characters
|
|
616
|
+
* drawn in it; null when a stylesheet of the page cannot be read
|
|
617
|
+
*/
|
|
618
|
+
export const drawnFaces = (html, options = {}) => {
|
|
619
|
+
const drawn = new Map()
|
|
620
|
+
const sheet = FACES_STYLE.exec(html)
|
|
621
|
+
if (!sheet) return drawn
|
|
622
|
+
const faces = facesOf(sheet[1])
|
|
623
|
+
if (!faces.length) return drawn
|
|
624
|
+
const families = new Map()
|
|
625
|
+
for (const f of faces) {
|
|
626
|
+
if (!families.has(f.family)) families.set(f.family, [])
|
|
627
|
+
families.get(f.family).push(f)
|
|
628
|
+
}
|
|
629
|
+
// Scripts, templates and the faces sheet hold no text the page draws.
|
|
630
|
+
const lean = html.replace(FACES_STYLE, '<style data-smbls-faces></style>').replace(RAW, '<$1$2></$1>')
|
|
631
|
+
const { document } = parseHTML(lean)
|
|
632
|
+
|
|
633
|
+
// The rules: the browser's, then the page's sheets in document order,
|
|
634
|
+
// then the style attributes (above every rule).
|
|
635
|
+
const ctx = { readSheet: typeof options.readSheet === 'function' ? options.readSheet : null, unknown: false }
|
|
636
|
+
const rules = []
|
|
637
|
+
const add = (css, conditional, ua, from) => {
|
|
638
|
+
for (const rule of styleRulesOf(css, conditional, ctx, from)) {
|
|
639
|
+
const decls = declarationsOf(rule.block)
|
|
640
|
+
if (decls.length) rules.push({ selector: rule.selector, decls, conditional: rule.conditional, ua })
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
add(UA_RULES, false, true)
|
|
644
|
+
for (const el of document.querySelectorAll('style, link')) {
|
|
645
|
+
const media = el.getAttribute('media')
|
|
646
|
+
const conditional = !!media && !ALWAYS_MEDIA.test(media)
|
|
647
|
+
if (el.localName === 'style') {
|
|
648
|
+
if (!el.hasAttribute('data-smbls-faces')) add(el.textContent || '', conditional, false)
|
|
649
|
+
continue
|
|
650
|
+
}
|
|
651
|
+
const rel = (el.getAttribute('rel') || '').toLowerCase().split(/\s+/)
|
|
652
|
+
if (!rel.includes('stylesheet') || el.hasAttribute('data-smbls-font') || el.hasAttribute('disabled')) continue
|
|
653
|
+
const href = el.getAttribute('href') || ''
|
|
654
|
+
const css = ctx.readSheet && href ? ctx.readSheet(href) : null
|
|
655
|
+
if (typeof css !== 'string') return null
|
|
656
|
+
add(css, conditional || rel.includes('alternate'), false, href)
|
|
657
|
+
}
|
|
658
|
+
if (ctx.unknown) return null
|
|
659
|
+
const attributed = []
|
|
660
|
+
for (const el of document.querySelectorAll('[style]')) {
|
|
661
|
+
const decls = declarationsOf(el.getAttribute('style') || '')
|
|
662
|
+
if (decls.length) attributed.push({ el, decls })
|
|
663
|
+
}
|
|
664
|
+
// Only the custom properties a font or `content` declaration reads (at
|
|
665
|
+
// any depth).
|
|
666
|
+
const wanted = new Set()
|
|
667
|
+
const every = [...rules.flatMap((r) => r.decls), ...attributed.flatMap((a) => a.decls)]
|
|
668
|
+
for (const d of every) if (FONT_PROPS.has(d.prop) || d.prop === 'content') varsIn(d.value, wanted)
|
|
669
|
+
for (let size = -1; size !== wanted.size;) {
|
|
670
|
+
size = wanted.size
|
|
671
|
+
for (const d of every) if (wanted.has(d.prop)) varsIn(d.value, wanted)
|
|
672
|
+
}
|
|
673
|
+
const keep = (d) => FONT_PROPS.has(d.prop) || d.prop === 'content' || wanted.has(d.prop)
|
|
674
|
+
|
|
675
|
+
// Per element (and per ::placeholder, ::before, ::after of it): its
|
|
676
|
+
// declarations of those properties, each with its place in the cascade.
|
|
677
|
+
const declared = { element: new Map(), placeholder: new Map(), before: new Map(), after: new Map() }
|
|
678
|
+
let order = 0
|
|
679
|
+
for (const rule of rules) {
|
|
680
|
+
const decls = rule.decls.filter(keep)
|
|
681
|
+
if (!decls.length) continue
|
|
682
|
+
for (const part of splitTop(rule.selector, ',')) {
|
|
683
|
+
const selector = part.trim()
|
|
684
|
+
if (!selector) continue
|
|
685
|
+
let { text, conditional, target } = staticSelector(selector)
|
|
686
|
+
if (!target) continue
|
|
687
|
+
let matched
|
|
688
|
+
try {
|
|
689
|
+
matched = document.querySelectorAll(text)
|
|
690
|
+
} catch (err) {
|
|
691
|
+
// matched with its pseudo-classes out: a possibility, never certain
|
|
692
|
+
try { matched = document.querySelectorAll(looseSelector(text)) } catch (err2) { continue }
|
|
693
|
+
conditional = true
|
|
694
|
+
}
|
|
695
|
+
const rank = rule.ua ? 0 : specificityOf(selector)
|
|
696
|
+
const entries = decls.map((d) => ({ ...d, rank, order: ++order, conditional: conditional || rule.conditional, ua: rule.ua }))
|
|
697
|
+
const into = declared[target]
|
|
698
|
+
for (const el of matched) {
|
|
699
|
+
if (!into.has(el)) into.set(el, [])
|
|
700
|
+
into.get(el).push(...entries)
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
for (const { el, decls } of attributed) {
|
|
705
|
+
const own = decls.filter(keep)
|
|
706
|
+
if (!own.length) continue
|
|
707
|
+
if (!declared.element.has(el)) declared.element.set(el, [])
|
|
708
|
+
declared.element.get(el).push(...own.map((d) => ({ ...d, rank: 1e9, order: ++order, conditional: false, ua: false })))
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// The characters of each element's own text, per font it may be drawn in.
|
|
712
|
+
const textOf = new Map()
|
|
713
|
+
const addText = (font, text) => {
|
|
714
|
+
if (!text.trim()) return
|
|
715
|
+
for (const family of font.family) {
|
|
716
|
+
for (const weight of font.weight) {
|
|
717
|
+
for (const style of font.style) {
|
|
718
|
+
const key = JSON.stringify([family, weight, style])
|
|
719
|
+
textOf.set(key, (textOf.get(key) || '') + text)
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
const walk = (el, parent) => {
|
|
725
|
+
const font = fontOf(declared.element.get(el) || [], parent)
|
|
726
|
+
let own = ''
|
|
727
|
+
for (const node of el.childNodes) {
|
|
728
|
+
if (node.nodeType === 3) own += node.textContent
|
|
729
|
+
else if (node.nodeType === 1 && !SKIP.has(node.localName)) walk(node, font)
|
|
730
|
+
}
|
|
731
|
+
const tag = el.localName
|
|
732
|
+
if (tag === 'input' || tag === 'textarea') {
|
|
733
|
+
own += ' ' + (el.getAttribute('value') || '')
|
|
734
|
+
// ::placeholder inherits from its field
|
|
735
|
+
const placeholder = el.getAttribute('placeholder')
|
|
736
|
+
if (placeholder) addText(declared.placeholder.has(el) ? fontOf(declared.placeholder.get(el), font) : font, placeholder)
|
|
737
|
+
}
|
|
738
|
+
addText(font, own)
|
|
739
|
+
// ::before / ::after inherit from the element; their text is `content`
|
|
740
|
+
for (const kind of ['before', 'after']) {
|
|
741
|
+
const decls = declared[kind].get(el)
|
|
742
|
+
if (!decls) continue
|
|
743
|
+
const box = fontOf(decls, font)
|
|
744
|
+
for (const d of winnersOf(decls.filter((x) => x.prop === 'content')).list) {
|
|
745
|
+
for (const value of substitute(d.value, box.vars)) addText(box, contentText(value, el))
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
if (document.documentElement) walk(document.documentElement, ROOT)
|
|
750
|
+
|
|
751
|
+
// The faces those characters are drawn in: per family, the faces of the
|
|
752
|
+
// style and weight the browser picks; per character, the last of them
|
|
753
|
+
// whose unicode-range holds it (CSS Fonts 4: overlapping ranges, the last
|
|
754
|
+
// rule first), else the next family.
|
|
755
|
+
for (const [key, text] of textOf) {
|
|
756
|
+
const [list, weight, style] = JSON.parse(key)
|
|
757
|
+
const chosen = new Map()
|
|
758
|
+
for (const family of list) {
|
|
759
|
+
if (GENERIC.has(family)) break
|
|
760
|
+
const all = families.get(family)
|
|
761
|
+
if (all && !chosen.has(family)) chosen.set(family, byWeight(byStyle(all, style), weight))
|
|
762
|
+
}
|
|
763
|
+
if (!chosen.size) continue
|
|
764
|
+
const seen = new Set()
|
|
765
|
+
for (const ch of text) {
|
|
766
|
+
if (seen.has(ch)) continue
|
|
767
|
+
seen.add(ch)
|
|
768
|
+
const code = ch.codePointAt(0)
|
|
769
|
+
if (code <= 32 || code === 0xa0) continue
|
|
770
|
+
for (const family of list) {
|
|
771
|
+
if (GENERIC.has(family)) break
|
|
772
|
+
const set = chosen.get(family) || []
|
|
773
|
+
let face = null
|
|
774
|
+
for (let i = set.length - 1; i >= 0 && !face; i--) if (covers(set[i], code)) face = set[i]
|
|
775
|
+
if (!face) continue
|
|
776
|
+
const chars = drawn.get(face.index) || ''
|
|
777
|
+
if (!chars.includes(ch)) drawn.set(face.index, chars + ch)
|
|
778
|
+
break
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
return drawn
|
|
783
|
+
}
|