@zumer/snapdom-plugins 3.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +50 -16
- package/agent-map.d.ts +32 -0
- package/agent-map.js +5 -3
- package/ascii-export.d.ts +10 -0
- package/ascii-export.js +21 -6
- package/capture-frames.js +5 -5
- package/color-tint.js +4 -0
- package/context-export.d.ts +23 -0
- package/context-export.js +4 -1
- package/filter.js +1 -1
- package/gif-export.d.ts +6 -0
- package/gif-export.js +3 -3
- package/html-export.d.ts +8 -0
- package/html-export.js +6 -4
- package/index.d.ts +2 -1
- package/index.js +2 -1
- package/package.json +16 -5
- package/pdf/LICENSE +21 -0
- package/pdf/README.md +22 -0
- package/pdf/REFERENCE.md +520 -0
- package/pdf/src/frontmatter.js +200 -0
- package/pdf/src/furniture.js +398 -0
- package/pdf/src/index.js +2415 -0
- package/pdf/src/mega/README.md +47 -0
- package/pdf/src/mega/diag.js +148 -0
- package/pdf/src/mega/flow.js +56 -0
- package/pdf/src/mega/fonts.js +287 -0
- package/pdf/src/mega/forms.js +479 -0
- package/pdf/src/mega/labels.js +43 -0
- package/pdf/src/mega/meta.js +145 -0
- package/pdf/src/nav.js +357 -0
- package/pdf/src/paginate.js +583 -0
- package/pdf/src/tagged.js +355 -0
- package/pdf/src/text-layer.js +1771 -0
- package/pdf/src/writer/encrypt.js +218 -0
- package/pdf/src/writer/font.js +507 -0
- package/pdf/src/writer/index.js +1135 -0
- package/pdf/src/writer/subset.js +387 -0
- package/pdf.d.ts +294 -0
- package/pdf.js +1 -0
- package/redact-inputs.js +6 -6
- package/vector/CONTRACT.md +906 -0
- package/vector/LICENSE +21 -0
- package/vector/README.md +35 -0
- package/vector/src/adapters/snapdom.js +795 -0
- package/vector/src/collect/box.js +1676 -0
- package/vector/src/collect/font.js +487 -0
- package/vector/src/collect/image.js +1458 -0
- package/vector/src/collect/mathml.js +272 -0
- package/vector/src/collect/text.js +3027 -0
- package/vector/src/css/color.js +823 -0
- package/vector/src/css/gradient.js +1126 -0
- package/vector/src/css/matrix.js +398 -0
- package/vector/src/css/radius.js +124 -0
- package/vector/src/css/shadow.js +201 -0
- package/vector/src/emit/figma-json.js +1995 -0
- package/vector/src/emit/h2d-vector.js +215 -0
- package/vector/src/emit/h2d.js +1147 -0
- package/vector/src/emit/pdf.js +2571 -0
- package/vector/src/emit/svg-flat.js +4842 -0
- package/vector/src/exclusion.js +13 -0
- package/vector/src/index.js +2910 -0
- package/vector/src/paint.js +2908 -0
- package/vector/src/public.js +57 -0
- package/vector/src/writer/encrypt.js +227 -0
- package/vector/src/writer/font.js +507 -0
- package/vector/src/writer/index.js +1148 -0
- package/vector/src/writer/subset.js +387 -0
- package/vector/svd/index.js +72 -0
- package/vector/svd/schema.js +985 -0
- package/vector.d.ts +28 -0
- package/vector.js +1 -0
- package/video-export.d.ts +6 -0
- package/video-export.js +7 -7
- package/pdf-image.d.ts +0 -18
- package/pdf-image.js +0 -114
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Font collection: the bytes behind the `@font-face` families this document
|
|
3
|
+
* paints with.
|
|
4
|
+
*
|
|
5
|
+
* `collect/text.js` resolves WHICH face painted each run — `family`, `cssFamily`,
|
|
6
|
+
* `webfont`, verified against the stack's own advances — and stays synchronous on
|
|
7
|
+
* purpose, so it reads a binary only when the rule carries one inline
|
|
8
|
+
* (`nameTableFor`). This module is the asynchronous other half, named as the fix
|
|
9
|
+
* in `declareFonts` since v0: for every text style the resolution marked
|
|
10
|
+
* `webfont`, find the `@font-face` rule the browser matched, fetch its bytes,
|
|
11
|
+
* and put them in `doc.assets` with `style.font` pointing at them. From there
|
|
12
|
+
* machinery that has existed all along takes over: `emit/svg-flat.js` writes the
|
|
13
|
+
* `@font-face` with the data URI (`fontFaceRules`), prepends the family so it
|
|
14
|
+
* wins over the stack, and pins every line with `textLength`
|
|
15
|
+
* (`measuredFontTravels`) — and a standalone SVG stops depending on what the
|
|
16
|
+
* viewer happens to have installed.
|
|
17
|
+
*
|
|
18
|
+
* What this module deliberately does NOT do:
|
|
19
|
+
*
|
|
20
|
+
* - **Subset.** The whole binary travels and `subset` stays false: a subsetter
|
|
21
|
+
* is a font compiler, and a wrong one corrupts glyphs silently. The cost is
|
|
22
|
+
* bytes and it is declared per face (`assets.font-embedded` states the size);
|
|
23
|
+
* `embedFonts: false` refuses the trade entirely.
|
|
24
|
+
* - **Compose multi-subset families.** When no single rule covers every code
|
|
25
|
+
* point a style paints (Google Fonts serves latin / latin-ext / cyrillic as
|
|
26
|
+
* separate rules of the same family), the style keeps the family NAME and the
|
|
27
|
+
* gap is declared (`assets.font-uncovered`): embedding one subset would make
|
|
28
|
+
* `measuredFontTravels` pin lines whose characters the embedded file does not
|
|
29
|
+
* have, measuring one font and painting another.
|
|
30
|
+
* - **Serve Figma.** Its SVG importer runs no `@font-face` and no `textLength`
|
|
31
|
+
* (measured, FIGMA_FINDINGS); the h2d channel owns that destination. These
|
|
32
|
+
* bytes are for the editors and renderers that read SVG as specified.
|
|
33
|
+
*
|
|
34
|
+
* System and locally-installed faces are out of reach by nature — there is no
|
|
35
|
+
* URL a page can hand over — and stay the emitter's `font-not-embedded` story.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import {
|
|
39
|
+
unquoteFamily, parseUnicodeRange, weightRange, dataUriBytes, parseFontNames,
|
|
40
|
+
} from './text.js'
|
|
41
|
+
|
|
42
|
+
/** Formats Chromium/Firefox/WebKit all load; anything else in `format()` is skipped. */
|
|
43
|
+
const LOADABLE_FORMATS = new Set([
|
|
44
|
+
'woff2', 'woff', 'truetype', 'opentype', 'woff2-variations', 'woff-variations',
|
|
45
|
+
'truetype-variations', 'opentype-variations',
|
|
46
|
+
])
|
|
47
|
+
|
|
48
|
+
/** Magic bytes -> the mime `emit/svg-flat.js` FONT_FORMATS maps to a `format()`. */
|
|
49
|
+
function sniffFontMime (bytes) {
|
|
50
|
+
if (!bytes || bytes.length < 4) return null
|
|
51
|
+
const tag = String.fromCharCode(bytes[0], bytes[1], bytes[2], bytes[3])
|
|
52
|
+
if (tag === 'wOF2') return 'font/woff2'
|
|
53
|
+
if (tag === 'wOFF') return 'font/woff'
|
|
54
|
+
if (tag === 'OTTO') return 'font/otf'
|
|
55
|
+
if (tag === 'true' || tag === 'ttcf') return 'font/ttf'
|
|
56
|
+
if (bytes[0] === 0 && bytes[1] === 1 && bytes[2] === 0 && bytes[3] === 0) return 'font/ttf'
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Bytes -> base64, chunked: `String.fromCharCode(...400k)` overflows the stack. */
|
|
61
|
+
function base64Of (bytes) {
|
|
62
|
+
let binary = ''
|
|
63
|
+
for (let i = 0; i < bytes.length; i += 0x8000) {
|
|
64
|
+
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000))
|
|
65
|
+
}
|
|
66
|
+
return btoa(binary)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Same fetch discipline as `index.js` `fetchBytes` (force-cache: the page already
|
|
71
|
+
* downloaded this face to paint with, so the HTTP cache should answer without a
|
|
72
|
+
* second trip), written here because `index.js` imports this module.
|
|
73
|
+
*/
|
|
74
|
+
async function fetchFontBytes (url, timeout) {
|
|
75
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null
|
|
76
|
+
const timer = controller && timeout > 0 ? setTimeout(() => controller.abort(), timeout) : null
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetch(url, {
|
|
79
|
+
signal: controller ? controller.signal : undefined,
|
|
80
|
+
cache: 'force-cache',
|
|
81
|
+
})
|
|
82
|
+
if (!res.ok) return null
|
|
83
|
+
return new Uint8Array(await res.arrayBuffer())
|
|
84
|
+
} catch {
|
|
85
|
+
return null
|
|
86
|
+
} finally {
|
|
87
|
+
if (timer) clearTimeout(timer)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ——— reading the rules ———
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* One declaration block split at top-level semicolons, parens respected: a data
|
|
95
|
+
* URI is `;base64,` in the middle of `src`, and a split that does not track
|
|
96
|
+
* parentheses hands half a URL to the URL parser.
|
|
97
|
+
*/
|
|
98
|
+
function declarationsOf (block) {
|
|
99
|
+
const out = []
|
|
100
|
+
let depth = 0
|
|
101
|
+
let at = 0
|
|
102
|
+
for (let i = 0; i < block.length; i++) {
|
|
103
|
+
const c = block[i]
|
|
104
|
+
if (c === '(') depth++
|
|
105
|
+
else if (c === ')') depth = Math.max(0, depth - 1)
|
|
106
|
+
else if (c === ';' && depth === 0) {
|
|
107
|
+
out.push(block.slice(at, i))
|
|
108
|
+
at = i + 1
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
out.push(block.slice(at))
|
|
112
|
+
const props = {}
|
|
113
|
+
for (const decl of out) {
|
|
114
|
+
const colon = decl.indexOf(':')
|
|
115
|
+
if (colon < 0) continue
|
|
116
|
+
props[decl.slice(0, colon).trim().toLowerCase()] = decl.slice(colon + 1).trim()
|
|
117
|
+
}
|
|
118
|
+
return props
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The shape both rule sources produce, so everything downstream reads one thing. */
|
|
122
|
+
function faceRecord (props, base) {
|
|
123
|
+
const family = unquoteFamily(props['font-family'])
|
|
124
|
+
const src = props.src || ''
|
|
125
|
+
if (!family || !src) return null
|
|
126
|
+
return {
|
|
127
|
+
family,
|
|
128
|
+
src,
|
|
129
|
+
weight: props['font-weight'] || '',
|
|
130
|
+
style: props['font-style'] || '',
|
|
131
|
+
unicodeRange: props['unicode-range'] || '',
|
|
132
|
+
base,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Every `@font-face` of the document the CSSOM will show us, plus the `href` of
|
|
138
|
+
* each sheet it will not: a cross-origin stylesheet (Google Fonts) throws on
|
|
139
|
+
* `cssRules`, and those sheets are exactly where webfont rules live, so the
|
|
140
|
+
* hrefs come back for `refetchFaces` to read over HTTP instead.
|
|
141
|
+
*/
|
|
142
|
+
function readFaceRules () {
|
|
143
|
+
const rules = []
|
|
144
|
+
const blocked = []
|
|
145
|
+
const sheets = typeof document !== 'undefined' && document.styleSheets ? document.styleSheets : []
|
|
146
|
+
const base = typeof document !== 'undefined' ? document.baseURI : undefined
|
|
147
|
+
const walk = (list, sheetBase) => {
|
|
148
|
+
for (const rule of list) {
|
|
149
|
+
if (rule.type === 5 || (rule.style && typeof rule.cssText === 'string' && /^@font-face/.test(rule.cssText))) {
|
|
150
|
+
let rec = null
|
|
151
|
+
try {
|
|
152
|
+
rec = faceRecord({
|
|
153
|
+
'font-family': rule.style.getPropertyValue('font-family'),
|
|
154
|
+
src: rule.style.getPropertyValue('src'),
|
|
155
|
+
'font-weight': rule.style.getPropertyValue('font-weight'),
|
|
156
|
+
'font-style': rule.style.getPropertyValue('font-style'),
|
|
157
|
+
'unicode-range': rule.style.getPropertyValue('unicode-range'),
|
|
158
|
+
}, sheetBase)
|
|
159
|
+
} catch { /* a dead rule reads as absent */ }
|
|
160
|
+
if (rec) rules.push(rec)
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
if (rule.cssRules) {
|
|
164
|
+
try { walk(rule.cssRules, sheetBase) } catch { /* nested cross-origin */ }
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
for (const sheet of sheets) {
|
|
169
|
+
const sheetBase = sheet.href || base
|
|
170
|
+
try {
|
|
171
|
+
walk(sheet.cssRules, sheetBase)
|
|
172
|
+
} catch {
|
|
173
|
+
if (sheet.href) blocked.push(sheet.href)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return { rules, blocked }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The `@font-face` rules of one stylesheet the CSSOM refused, read over HTTP.
|
|
181
|
+
* Fonts are CORS-gated by spec, so a host that serves loadable webfonts serves
|
|
182
|
+
* readable CSS more often than not; a host that refuses both is declared, not
|
|
183
|
+
* guessed around.
|
|
184
|
+
*/
|
|
185
|
+
async function refetchFaces (href, timeout) {
|
|
186
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null
|
|
187
|
+
const timer = controller && timeout > 0 ? setTimeout(() => controller.abort(), timeout) : null
|
|
188
|
+
try {
|
|
189
|
+
const res = await fetch(href, { signal: controller ? controller.signal : undefined, cache: 'force-cache' })
|
|
190
|
+
if (!res.ok) return null
|
|
191
|
+
const css = (await res.text()).replace(/\/\*[\s\S]*?\*\//g, '')
|
|
192
|
+
const out = []
|
|
193
|
+
const re = /@font-face\s*\{([^}]*)\}/gi
|
|
194
|
+
let m
|
|
195
|
+
while ((m = re.exec(css))) {
|
|
196
|
+
const rec = faceRecord(declarationsOf(m[1]), href)
|
|
197
|
+
if (rec) out.push(rec)
|
|
198
|
+
}
|
|
199
|
+
return out
|
|
200
|
+
} catch {
|
|
201
|
+
return null
|
|
202
|
+
} finally {
|
|
203
|
+
if (timer) clearTimeout(timer)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ——— matching ———
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* `src:` as candidates in SOURCE order — the browser takes the first entry it
|
|
211
|
+
* can load, and mirroring that order is what makes "the bytes we embed" and
|
|
212
|
+
* "the bytes that painted the page" the same file. `local()` entries are
|
|
213
|
+
* skipped (a local face has no bytes a page can hand over), and so is any
|
|
214
|
+
* `format()` no engine loads.
|
|
215
|
+
*/
|
|
216
|
+
function srcCandidates (src, base) {
|
|
217
|
+
const items = []
|
|
218
|
+
let depth = 0
|
|
219
|
+
let at = 0
|
|
220
|
+
for (let i = 0; i < src.length; i++) {
|
|
221
|
+
const c = src[i]
|
|
222
|
+
if (c === '(') depth++
|
|
223
|
+
else if (c === ')') depth = Math.max(0, depth - 1)
|
|
224
|
+
else if (c === ',' && depth === 0) {
|
|
225
|
+
items.push(src.slice(at, i))
|
|
226
|
+
at = i + 1
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
items.push(src.slice(at))
|
|
230
|
+
|
|
231
|
+
const out = []
|
|
232
|
+
for (const item of items) {
|
|
233
|
+
if (/^\s*local\s*\(/i.test(item)) continue
|
|
234
|
+
const u = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^)\s]*))\s*\)/i.exec(item)
|
|
235
|
+
if (!u) continue
|
|
236
|
+
const raw = u[1] || u[2] || u[3] || ''
|
|
237
|
+
if (!raw) continue
|
|
238
|
+
const f = /format\(\s*(?:"([^"]*)"|'([^']*)'|([^)\s]*))\s*\)/i.exec(item)
|
|
239
|
+
const format = (f ? (f[1] || f[2] || f[3] || '') : '').toLowerCase()
|
|
240
|
+
if (format && !LOADABLE_FORMATS.has(format)) continue
|
|
241
|
+
let url = raw
|
|
242
|
+
if (!/^data:/i.test(raw)) {
|
|
243
|
+
try { url = new URL(raw, base).href } catch { continue }
|
|
244
|
+
}
|
|
245
|
+
out.push({ url, format })
|
|
246
|
+
}
|
|
247
|
+
return out
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* CSS font matching over rule records: style first, then nearest weight —
|
|
252
|
+
* the ordering `collect/text.js` uses for the same decision (`nameTableFor`,
|
|
253
|
+
* `pickFace`), applied to the same inputs.
|
|
254
|
+
*/
|
|
255
|
+
function matchScore (rule, weight, italic) {
|
|
256
|
+
const [lo, hi] = weightRange(rule.weight)
|
|
257
|
+
const distance = weight < lo ? lo - weight : weight > hi ? weight - hi : 0
|
|
258
|
+
const ruleItalic = /^(italic|oblique)/.test(rule.style || 'normal')
|
|
259
|
+
return (ruleItalic === italic ? 1000 : 0) - distance
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Does this rule's `unicode-range` cover EVERY code point the style paints? */
|
|
263
|
+
function coversAll (rule, points) {
|
|
264
|
+
const ranges = parseUnicodeRange(rule.unicodeRange)
|
|
265
|
+
if (!ranges) return true
|
|
266
|
+
for (const cp of points) {
|
|
267
|
+
let hit = false
|
|
268
|
+
for (const [lo, hi] of ranges) {
|
|
269
|
+
if (cp >= lo && cp <= hi) { hit = true; break }
|
|
270
|
+
}
|
|
271
|
+
if (!hit) return false
|
|
272
|
+
}
|
|
273
|
+
return true
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ——— the collector ———
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Every code point each text style actually paints, read off the runs — the
|
|
280
|
+
* coverage question (`coversAll`) is about painted characters, never about the
|
|
281
|
+
* family's whole repertoire. The hyphen a line took at a hyphenation break is
|
|
282
|
+
* painted from the same style and is not in `characters`, so `line.hyphen`
|
|
283
|
+
* joins the set of every style on its node.
|
|
284
|
+
*/
|
|
285
|
+
function pointsByStyle (doc, markupTextUses = []) {
|
|
286
|
+
const out = new Map()
|
|
287
|
+
const add = (sid, text) => {
|
|
288
|
+
let set = out.get(sid)
|
|
289
|
+
if (!set) {
|
|
290
|
+
set = new Set()
|
|
291
|
+
out.set(sid, set)
|
|
292
|
+
}
|
|
293
|
+
for (const ch of text) set.add(ch.codePointAt(0))
|
|
294
|
+
}
|
|
295
|
+
for (const id of Object.keys(doc.nodes)) {
|
|
296
|
+
const node = doc.nodes[id]
|
|
297
|
+
if (node.type !== 'text' || !node.text) continue
|
|
298
|
+
const chars = typeof node.text.characters === 'string' ? node.text.characters : ''
|
|
299
|
+
const runs = Array.isArray(node.text.runs) ? node.text.runs : []
|
|
300
|
+
const hyphens = (Array.isArray(node.text.lines) ? node.text.lines : [])
|
|
301
|
+
.map((line) => (line && typeof line.hyphen === 'string' ? line.hyphen : ''))
|
|
302
|
+
.join('')
|
|
303
|
+
for (const run of runs) {
|
|
304
|
+
if (typeof run.style !== 'string') continue
|
|
305
|
+
const from = Number.isInteger(run.start) ? run.start : 0
|
|
306
|
+
const to = Number.isInteger(run.end) ? run.end : chars.length
|
|
307
|
+
add(run.style, chars.slice(from, to) + hyphens)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
for (const use of markupTextUses) add(use.style, use.characters)
|
|
311
|
+
return out
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Fetch and embed the faces, mutating `doc.assets` and `styles.text[*].font`.
|
|
316
|
+
*
|
|
317
|
+
* Runs inside the mount, after the collectors and before `declareFonts` — which
|
|
318
|
+
* then declares only what is STILL not embedded. One face fetched once serves
|
|
319
|
+
* every style that matched it (dedup on resolved URL), and every outcome that
|
|
320
|
+
* is not bytes-in-the-document is a diagnostic, because a capture that quietly
|
|
321
|
+
* skipped a font is exactly the silence this engine exists to break.
|
|
322
|
+
*
|
|
323
|
+
* @param {object} st the assemble state: `{doc, ctx}` and the report plumbing
|
|
324
|
+
* @param {(st: object, nodeId: string|null, code: string, grade: string,
|
|
325
|
+
* message: string, severity?: string) => void} report `index.js`'s own
|
|
326
|
+
*/
|
|
327
|
+
export async function collectFonts (st, report) {
|
|
328
|
+
const { doc, ctx } = st
|
|
329
|
+
const styles = doc.styles.text
|
|
330
|
+
const styleIds = Object.keys(styles)
|
|
331
|
+
if (!styleIds.length) return
|
|
332
|
+
if (typeof fetch !== 'function' || typeof document === 'undefined') return
|
|
333
|
+
|
|
334
|
+
// The requests: one per (family, weight, italic) the webfont styles paint.
|
|
335
|
+
const points = pointsByStyle(doc, st.markupTextUses)
|
|
336
|
+
const groups = new Map()
|
|
337
|
+
for (const sid of styleIds) {
|
|
338
|
+
const style = styles[sid]
|
|
339
|
+
if (style.webfont !== true || style.font) continue
|
|
340
|
+
const family = unquoteFamily(style.cssFamily || style.family)
|
|
341
|
+
if (!family) continue
|
|
342
|
+
const weight = Number.isFinite(Number(style.weight)) ? Number(style.weight) : 400
|
|
343
|
+
const italic = style.italic === true
|
|
344
|
+
const key = `${family.toLowerCase()}${weight}${italic}`
|
|
345
|
+
let group = groups.get(key)
|
|
346
|
+
if (!group) {
|
|
347
|
+
group = { family, weight, italic, styleIds: [], points: new Set() }
|
|
348
|
+
groups.set(key, group)
|
|
349
|
+
}
|
|
350
|
+
group.styleIds.push(sid)
|
|
351
|
+
for (const cp of points.get(sid) || []) group.points.add(cp)
|
|
352
|
+
}
|
|
353
|
+
if (!groups.size) return
|
|
354
|
+
|
|
355
|
+
const { rules, blocked } = readFaceRules()
|
|
356
|
+
const byFamily = new Map()
|
|
357
|
+
const index = (list) => {
|
|
358
|
+
for (const rule of list) {
|
|
359
|
+
const key = rule.family.toLowerCase()
|
|
360
|
+
if (!byFamily.has(key)) byFamily.set(key, [])
|
|
361
|
+
byFamily.get(key).push(rule)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
index(rules)
|
|
365
|
+
|
|
366
|
+
// Refetch a blocked sheet only when a family this capture needs is missing:
|
|
367
|
+
// the common blocked sheet is an analytics stylesheet nobody paints with.
|
|
368
|
+
const missing = [...groups.values()].some((g) => !byFamily.has(g.family.toLowerCase()))
|
|
369
|
+
if (missing && blocked.length) {
|
|
370
|
+
const fetched = await Promise.all(blocked.map((href) => refetchFaces(href, ctx.fetchTimeout)))
|
|
371
|
+
fetched.forEach((list, i) => {
|
|
372
|
+
if (list) index(list)
|
|
373
|
+
else {
|
|
374
|
+
report(st, null, 'assets.font-css-unreadable', 'A',
|
|
375
|
+
`stylesheet ${blocked[i]} is cross-origin, exposes no cssRules, and could not be re-read over ` +
|
|
376
|
+
'HTTP; any @font-face it declares cannot be embedded and its families travel by name.', 'warn')
|
|
377
|
+
}
|
|
378
|
+
})
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const assetByUrl = new Map()
|
|
382
|
+
|
|
383
|
+
for (const group of groups.values()) {
|
|
384
|
+
const { family, weight, italic, styleIds: members } = group
|
|
385
|
+
const label = `"${family}" ${weight}${italic ? ' italic' : ''}`
|
|
386
|
+
const candidates = byFamily.get(family.toLowerCase()) || []
|
|
387
|
+
if (!candidates.length) {
|
|
388
|
+
report(st, null, 'assets.font-rule-missing', 'A',
|
|
389
|
+
`${label} is in document.fonts but no readable stylesheet declares its @font-face, so its bytes ` +
|
|
390
|
+
'could not be located and the family travels by name (a rule constructed with `new FontFace()` ' +
|
|
391
|
+
'never appears in a stylesheet at all).', 'warn')
|
|
392
|
+
continue
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// The faces the browser would order first for this request; among equals
|
|
396
|
+
// (unicode-range subsets of one weight) the one that covers everything the
|
|
397
|
+
// style paints. Ties broken by source order, which is also the CSS rule.
|
|
398
|
+
const scored = candidates
|
|
399
|
+
.map((rule, i) => ({ rule, i, score: matchScore(rule, weight, italic) }))
|
|
400
|
+
.sort((a, b) => (b.score - a.score) || (a.i - b.i))
|
|
401
|
+
const best = scored.filter((s) => s.score === scored[0].score)
|
|
402
|
+
const cps = [...group.points]
|
|
403
|
+
const winner = best.find((s) => coversAll(s.rule, cps))
|
|
404
|
+
if (!winner) {
|
|
405
|
+
report(st, null, 'assets.font-uncovered', 'A',
|
|
406
|
+
`${label} is served in ${best.length} unicode-range subsets and none alone covers all ` +
|
|
407
|
+
`${cps.length} distinct code points this capture paints with it. Embedding one subset would let ` +
|
|
408
|
+
'textLength pin lines to a file that lacks some of their characters — measuring one font and ' +
|
|
409
|
+
'painting another — so the family travels by name instead.', 'warn')
|
|
410
|
+
continue
|
|
411
|
+
}
|
|
412
|
+
const rule = winner.rule
|
|
413
|
+
|
|
414
|
+
// The bytes, from the first loadable src — the browser's own pick.
|
|
415
|
+
let bytes = null
|
|
416
|
+
let fromUrl = null
|
|
417
|
+
let inline = false
|
|
418
|
+
const sources = srcCandidates(rule.src, rule.base)
|
|
419
|
+
for (const cand of sources) {
|
|
420
|
+
if (/^data:/i.test(cand.url)) {
|
|
421
|
+
bytes = dataUriBytes(cand.url)
|
|
422
|
+
fromUrl = 'an inline data: URI'
|
|
423
|
+
inline = true
|
|
424
|
+
} else if (assetByUrl.has(cand.url)) {
|
|
425
|
+
bytes = null
|
|
426
|
+
fromUrl = cand.url
|
|
427
|
+
inline = false
|
|
428
|
+
break
|
|
429
|
+
} else {
|
|
430
|
+
bytes = await fetchFontBytes(cand.url, ctx.fetchTimeout)
|
|
431
|
+
fromUrl = cand.url
|
|
432
|
+
inline = false
|
|
433
|
+
}
|
|
434
|
+
if (bytes) break
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
let assetId = !inline && fromUrl ? assetByUrl.get(fromUrl) : undefined
|
|
438
|
+
if (!assetId) {
|
|
439
|
+
if (!bytes || !bytes.length) {
|
|
440
|
+
report(st, null, 'assets.font-fetch-failed', 'A',
|
|
441
|
+
`${label}: none of the ${sources.length} src candidate(s) of its @font-face could be read ` +
|
|
442
|
+
`(${sources.length ? sources.map((s) => s.url.slice(0, 96)).join(', ') : 'src lists only local() or unloadable formats'}), ` +
|
|
443
|
+
'so the family travels by name and the viewer substitutes on a machine without it.', 'warn')
|
|
444
|
+
continue
|
|
445
|
+
}
|
|
446
|
+
const mime = sniffFontMime(bytes) || 'font/ttf'
|
|
447
|
+
const [lo, hi] = weightRange(rule.weight)
|
|
448
|
+
assetId = ctx.nextId('f')
|
|
449
|
+
const asset = {
|
|
450
|
+
kind: 'font',
|
|
451
|
+
data: `data:${mime};base64,${base64Of(bytes)}`,
|
|
452
|
+
mime,
|
|
453
|
+
cssFamily: rule.family,
|
|
454
|
+
italic: /^(italic|oblique)/.test(rule.style || 'normal'),
|
|
455
|
+
subset: false,
|
|
456
|
+
}
|
|
457
|
+
// A fixed-weight rule pins the descriptor; a variable face covers a range
|
|
458
|
+
// and gets it verbatim, or every style but 400 would synthesise.
|
|
459
|
+
if (lo === hi) asset.weight = lo
|
|
460
|
+
else asset.weightRange = `${lo} ${hi}`
|
|
461
|
+
if (!inline && fromUrl) asset.src = fromUrl
|
|
462
|
+
// The name the binary claims, for `emit/figma-json.js` `fontOf`: the page
|
|
463
|
+
// says "Inter var", the file says Inter / SemiBold, and only the second
|
|
464
|
+
// exists in a font picker. WOFF2 name tables are brotli-compressed and
|
|
465
|
+
// stay unread — the consumer falls back to the stack, as before.
|
|
466
|
+
const names = parseFontNames(bytes)
|
|
467
|
+
if (names && !names.error && (names.family || names.sub)) {
|
|
468
|
+
asset.nameTable = {}
|
|
469
|
+
if (names.family) asset.nameTable['16'] = names.family
|
|
470
|
+
if (names.sub) asset.nameTable['17'] = names.sub
|
|
471
|
+
}
|
|
472
|
+
doc.assets[assetId] = asset
|
|
473
|
+
if (!inline && fromUrl) assetByUrl.set(fromUrl, assetId)
|
|
474
|
+
report(st, null, 'assets.font-embedded', 'E',
|
|
475
|
+
`${label} travels in the document: ${Math.round(bytes.length / 1024)} kB of ${mime} fetched from ` +
|
|
476
|
+
`${fromUrl}, serving ${members.length} text style(s). Every line in those styles now carries ` +
|
|
477
|
+
'textLength pinned to the advances this exact binary measures, and a standalone emit renders them ' +
|
|
478
|
+
'without depending on the viewer\'s installed fonts. The cost is the bytes: the file travels ' +
|
|
479
|
+
'WHOLE (no subsetter here — a wrong one corrupts glyphs silently), and embedFonts:false refuses ' +
|
|
480
|
+
'the trade for captures where size outranks portability.', 'info')
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
for (const sid of members) styles[sid].font = assetId
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export default collectFonts
|