@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.
Files changed (77) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +50 -16
  3. package/agent-map.d.ts +32 -0
  4. package/agent-map.js +5 -3
  5. package/ascii-export.d.ts +10 -0
  6. package/ascii-export.js +21 -6
  7. package/capture-frames.js +5 -5
  8. package/color-tint.js +4 -0
  9. package/context-export.d.ts +23 -0
  10. package/context-export.js +4 -1
  11. package/filter.js +1 -1
  12. package/gif-export.d.ts +6 -0
  13. package/gif-export.js +3 -3
  14. package/html-export.d.ts +8 -0
  15. package/html-export.js +6 -4
  16. package/index.d.ts +2 -1
  17. package/index.js +2 -1
  18. package/package.json +22 -6
  19. package/pdf/LICENSE +21 -0
  20. package/pdf/README.md +22 -0
  21. package/pdf/REFERENCE.md +520 -0
  22. package/pdf/src/frontmatter.js +200 -0
  23. package/pdf/src/furniture.js +398 -0
  24. package/pdf/src/index.js +2415 -0
  25. package/pdf/src/mega/README.md +47 -0
  26. package/pdf/src/mega/diag.js +148 -0
  27. package/pdf/src/mega/flow.js +56 -0
  28. package/pdf/src/mega/fonts.js +287 -0
  29. package/pdf/src/mega/forms.js +479 -0
  30. package/pdf/src/mega/labels.js +43 -0
  31. package/pdf/src/mega/meta.js +145 -0
  32. package/pdf/src/nav.js +357 -0
  33. package/pdf/src/paginate.js +583 -0
  34. package/pdf/src/tagged.js +355 -0
  35. package/pdf/src/text-layer.js +1771 -0
  36. package/pdf/src/writer/encrypt.js +218 -0
  37. package/pdf/src/writer/font.js +507 -0
  38. package/pdf/src/writer/index.js +1135 -0
  39. package/pdf/src/writer/subset.js +387 -0
  40. package/pdf.d.ts +294 -0
  41. package/pdf.js +1 -0
  42. package/redact-inputs.js +6 -6
  43. package/vector/CONTRACT.md +906 -0
  44. package/vector/LICENSE +21 -0
  45. package/vector/README.md +35 -0
  46. package/vector/src/adapters/snapdom.js +795 -0
  47. package/vector/src/collect/box.js +1676 -0
  48. package/vector/src/collect/font.js +487 -0
  49. package/vector/src/collect/image.js +1458 -0
  50. package/vector/src/collect/mathml.js +272 -0
  51. package/vector/src/collect/text.js +3027 -0
  52. package/vector/src/css/color.js +823 -0
  53. package/vector/src/css/gradient.js +1126 -0
  54. package/vector/src/css/matrix.js +398 -0
  55. package/vector/src/css/radius.js +124 -0
  56. package/vector/src/css/shadow.js +201 -0
  57. package/vector/src/emit/figma-json.js +1995 -0
  58. package/vector/src/emit/h2d-vector.js +215 -0
  59. package/vector/src/emit/h2d.js +1147 -0
  60. package/vector/src/emit/pdf.js +2571 -0
  61. package/vector/src/emit/svg-flat.js +4842 -0
  62. package/vector/src/exclusion.js +13 -0
  63. package/vector/src/index.js +2910 -0
  64. package/vector/src/paint.js +2908 -0
  65. package/vector/src/public.js +57 -0
  66. package/vector/src/writer/encrypt.js +227 -0
  67. package/vector/src/writer/font.js +507 -0
  68. package/vector/src/writer/index.js +1148 -0
  69. package/vector/src/writer/subset.js +387 -0
  70. package/vector/svd/index.js +72 -0
  71. package/vector/svd/schema.js +985 -0
  72. package/vector.d.ts +28 -0
  73. package/vector.js +1 -0
  74. package/video-export.d.ts +6 -0
  75. package/video-export.js +7 -7
  76. package/pdf-image.d.ts +0 -18
  77. package/pdf-image.js +0 -114
@@ -0,0 +1,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