@zumer/snapdom-plugins 3.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +50 -16
  3. package/agent-map.d.ts +32 -0
  4. package/agent-map.js +5 -3
  5. package/ascii-export.d.ts +10 -0
  6. package/ascii-export.js +21 -6
  7. package/capture-frames.js +5 -5
  8. package/color-tint.js +4 -0
  9. package/context-export.d.ts +23 -0
  10. package/context-export.js +4 -1
  11. package/filter.js +1 -1
  12. package/gif-export.d.ts +6 -0
  13. package/gif-export.js +3 -3
  14. package/html-export.d.ts +8 -0
  15. package/html-export.js +6 -4
  16. package/index.d.ts +2 -1
  17. package/index.js +2 -1
  18. package/package.json +16 -5
  19. package/pdf/LICENSE +21 -0
  20. package/pdf/README.md +22 -0
  21. package/pdf/REFERENCE.md +520 -0
  22. package/pdf/src/frontmatter.js +200 -0
  23. package/pdf/src/furniture.js +398 -0
  24. package/pdf/src/index.js +2415 -0
  25. package/pdf/src/mega/README.md +47 -0
  26. package/pdf/src/mega/diag.js +148 -0
  27. package/pdf/src/mega/flow.js +56 -0
  28. package/pdf/src/mega/fonts.js +287 -0
  29. package/pdf/src/mega/forms.js +479 -0
  30. package/pdf/src/mega/labels.js +43 -0
  31. package/pdf/src/mega/meta.js +145 -0
  32. package/pdf/src/nav.js +357 -0
  33. package/pdf/src/paginate.js +583 -0
  34. package/pdf/src/tagged.js +355 -0
  35. package/pdf/src/text-layer.js +1771 -0
  36. package/pdf/src/writer/encrypt.js +218 -0
  37. package/pdf/src/writer/font.js +507 -0
  38. package/pdf/src/writer/index.js +1135 -0
  39. package/pdf/src/writer/subset.js +387 -0
  40. package/pdf.d.ts +294 -0
  41. package/pdf.js +1 -0
  42. package/redact-inputs.js +6 -6
  43. package/vector/CONTRACT.md +906 -0
  44. package/vector/LICENSE +21 -0
  45. package/vector/README.md +35 -0
  46. package/vector/src/adapters/snapdom.js +795 -0
  47. package/vector/src/collect/box.js +1676 -0
  48. package/vector/src/collect/font.js +487 -0
  49. package/vector/src/collect/image.js +1458 -0
  50. package/vector/src/collect/mathml.js +272 -0
  51. package/vector/src/collect/text.js +3027 -0
  52. package/vector/src/css/color.js +823 -0
  53. package/vector/src/css/gradient.js +1126 -0
  54. package/vector/src/css/matrix.js +398 -0
  55. package/vector/src/css/radius.js +124 -0
  56. package/vector/src/css/shadow.js +201 -0
  57. package/vector/src/emit/figma-json.js +1995 -0
  58. package/vector/src/emit/h2d-vector.js +215 -0
  59. package/vector/src/emit/h2d.js +1147 -0
  60. package/vector/src/emit/pdf.js +2571 -0
  61. package/vector/src/emit/svg-flat.js +4842 -0
  62. package/vector/src/exclusion.js +13 -0
  63. package/vector/src/index.js +2910 -0
  64. package/vector/src/paint.js +2908 -0
  65. package/vector/src/public.js +57 -0
  66. package/vector/src/writer/encrypt.js +227 -0
  67. package/vector/src/writer/font.js +507 -0
  68. package/vector/src/writer/index.js +1148 -0
  69. package/vector/src/writer/subset.js +387 -0
  70. package/vector/svd/index.js +72 -0
  71. package/vector/svd/schema.js +985 -0
  72. package/vector.d.ts +28 -0
  73. package/vector.js +1 -0
  74. package/video-export.d.ts +6 -0
  75. package/video-export.js +7 -7
  76. package/pdf-image.d.ts +0 -18
  77. package/pdf-image.js +0 -114
@@ -0,0 +1,47 @@
1
+ # pdfmega — the "pro pro" layer
2
+
3
+ The modules that took `@zumer/snapdom-pdf` from *exact capture in a PDF* to
4
+ *document generator*: identity, diagnostics, flow control, folios, fillable
5
+ forms, and the accessible-structure edge. They are part of the plugin — bundled
6
+ by `build.mjs` like everything in `src/` — and live in their own folder because
7
+ each one is a bounded contract that `src/index.js` consumes, not a place where
8
+ page drawing happens.
9
+
10
+ | Module | Feature | Public surface |
11
+ |---|---|---|
12
+ | `meta.js` | `/Info`, XMP packet, `DisplayDocTitle` | `meta` export option |
13
+ | `diag.js` | stable warning codes + report | `onReport` export option |
14
+ | `flow.js` | progress + cancellation | `onProgress`, `signal` export options |
15
+ | `labels.js` | `/PageLabels` agreeing with the printed folio | automatic |
16
+ | `forms.js` | fillable AcroForm fields from captured controls | `forms` (capture), `fields` (export) |
17
+ | `fonts.js` | `@font-face` rules → fetched, parsed, subset, embeddable faces | `embedFonts`, `vectorText` export options |
18
+
19
+ The writer half of these features — trailer `/Info`/`/ID`, `%PDF-1.7`, `/OBJR`
20
+ and table attributes in the structure tree — lives in `src/writer`,
21
+ because bytes are its job. The capture half of forms — control records measured
22
+ under the same visibility, clip and transform rules as every text run — lives in
23
+ `src/text-layer.js`, because measurement is its job. What is in this folder is
24
+ the part in between: planning and byte-shaping.
25
+
26
+ Decisions that are settled (see the triage memory and REFERENCE.md):
27
+
28
+ - **The `pdfuaid` claim is earned, never configured.** Fonts embed from the
29
+ capture's own `@font-face` (`fonts.js` + `pdf-writer/font.js`), subset to the
30
+ glyphs the document drew (`pdf-writer/subset.js`; `embedFonts: 'full'` opts
31
+ out, and CFF faces always travel whole). The claim is written
32
+ exactly when every text operation rode an embedded face AND the export is
33
+ tagged, has a language and displays a title; otherwise the blockers are named
34
+ (`ua-withheld`). Fillable fields block it: their `/DA` types in unembedded
35
+ Helvetica, so a UA-claiming export pairs with `fields: false`.
36
+ - **Dates are never invented.** `meta.created`/`modified` are written when the
37
+ caller passes them. The trailer `/ID` is a content hash, not a random number.
38
+ Byte determinism survives every feature in this folder.
39
+ - **A password value never reaches the file.** The field exists, flagged,
40
+ empty.
41
+ - **No JavaScript actions, no submit URLs** in generated forms.
42
+
43
+ Verification: `node test/megacheck.mjs` (fields, identity, labels, structure
44
+ edge, determinism, abort — against `test/fixtures/mega.html`), plus the main
45
+ suite `node test/verify.mjs`, whose text-layer fixtures export with
46
+ `fields: false` because they assert the layer the values deliberately leave.
47
+ Demo: `demo/pdfmega.html` under `node test/serve.mjs`.
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Structured diagnostics: the same honesty the console already gets, made
3
+ * machine-readable.
4
+ *
5
+ * Every fidelity loss this exporter can name is already a sentence somewhere in
6
+ * the pipeline — clipped runs, unresolved links, a thead too tall to repeat.
7
+ * What a CI gate needs is not a different sentence but a STABLE NAME for it, so
8
+ * "fail the build when the PDF lost annotations" is a filter and not a regex an
9
+ * integrator maintains against our prose.
10
+ *
11
+ * The names are assigned here, in one table, by classifying the finished
12
+ * message. Classifying beats threading a code through thirty call sites for one
13
+ * reason: the sentence and the code can never disagree about which warning this
14
+ * is, because the sentence IS the key. The cost is that rewording a message
15
+ * means touching its pattern here — which is exactly the reminder a stable
16
+ * contract needs.
17
+ *
18
+ * `code` values are the public contract and follow `area-what`:
19
+ *
20
+ * page-fallback an unknown paper name fell back to A4
21
+ * layout-split a block or field had to be split across a page break
22
+ * layout-break a declared page break could not be honoured
23
+ * layout-repeat a table header could not repeat
24
+ * text-clipped runs/links dropped by clip or overflow — fail-safe
25
+ * text-invisible runs/links dropped as invisible or degenerate
26
+ * text-effects runs/links omitted under unmappable visual effects
27
+ * text-off-page runs land outside the page box
28
+ * text-overflow the Type0 font ran out of codepoints
29
+ * link-unsafe malformed URL or unsafe protocol
30
+ * link-unresolved fragment with no destination in the capture
31
+ * link-cropped annotation cropped to its visible area
32
+ * dest-collision a matter page reused a body destination name
33
+ * geometry-approx transforms/zoom/SVG placed approximately
34
+ * shadow-closed unreachable closed shadow roots
35
+ * content-skipped content-visibility may have skipped content
36
+ * raster-limit the capture exceeds browser bitmap ceilings
37
+ * image-off image:false omitted an element band or watermark
38
+ * fit-furniture furniture/matter options that need pagination on 'fit'
39
+ * toc-empty a contents page was asked for with no headings
40
+ * toc-raster index lines drawn from the heading's own pixels
41
+ * furniture-script base-14 furniture cannot represent its script
42
+ * quality-noop lowered quality reached nothing (Flate won)
43
+ * flate-off Flate unavailable in this runtime
44
+ * no-lang tagged output without a document language
45
+ * field-skipped a control did not become a fillable field
46
+ * field-password password fields written empty, flagged, never valued
47
+ * field-appearance a field's appearance is approximate or viewer-drawn
48
+ * field-name duplicate field names were uniquified
49
+ * font-embedded the faces whose whole file travels in the document
50
+ * font-fallback a face that could not be embedded, and its reason
51
+ * font-coverage runs whose embedded face lacks some glyph — fallback
52
+ * vector-painted runs drawn as real vector glyphs from the embedded fonts
53
+ * vector-missed vector text asked for but a run could not be painted
54
+ * ua-claimed the export verified and wrote its PDF/UA-1 identifier
55
+ * ua-withheld fonts are clean but a named blocker stops the claim
56
+ * option-misplaced a capture-time option was passed at export
57
+ * option-unknown an option or property this plugin does not have
58
+ * capture-empty the capture has no layout box
59
+ * other a warning this table does not name yet
60
+ *
61
+ * An unmatched message is a bug in this table, not in the caller; it still
62
+ * reaches the report as `other` rather than being dropped.
63
+ */
64
+
65
+ const RULES = [
66
+ [/the document is encrypted with AES-256/, 'encrypted'],
67
+ [/is not a permission — ignored/, 'encrypt-option'],
68
+ [/is not a usable size|unknown page size/, 'page-fallback'],
69
+ [/block\(s\) had to be split|field annotation\(s\) straddle/, 'layout-split'],
70
+ [/page break\(s\) could not be honoured/, 'layout-break'],
71
+ [/header is more than half a page tall/, 'layout-repeat'],
72
+ [/crossing the snapdom clip edge|dropped because an overflow clip/, 'text-clipped'],
73
+ [/dropped because their paint is invisible/, 'text-invisible'],
74
+ [/omitted because a visual effect/, 'text-effects'],
75
+ [/land outside the page box/, 'text-off-page'],
76
+ [/more than 65,534 distinct codepoints/, 'text-overflow'],
77
+ [/URL is malformed or uses an unsafe protocol/, 'link-unsafe'],
78
+ [/point outside the final captured tree|got no annotation/, 'link-unresolved'],
79
+ [/link annotation\(s\) cropped/, 'link-cropped'],
80
+ [/destination name collision/, 'dest-collision'],
81
+ [/non-identity transform|off-centre origin|without a screen CTM|upright glyphs|zoom on an ancestor|axis-aligned/, 'geometry-approx'],
82
+ [/closed shadow root/, 'shadow-closed'],
83
+ [/content-visibility/, 'content-skipped'],
84
+ [/raster pixels wide|image limit/, 'raster-limit'],
85
+ [/omitted because image:false/, 'image-off'],
86
+ [/need a paginated page size|does nothing on page:'fit'/, 'fit-furniture'],
87
+ [/no headings to put in one/, 'toc-empty'],
88
+ [/heading's own pixels/, 'toc-raster'],
89
+ [/furniture could not be drawn|cannot represent its script/, 'furniture-script'],
90
+ [/quality .* changed nothing/, 'quality-noop'],
91
+ [/Flate|CompressionStream/, 'flate-off'],
92
+ [/no `lang`/, 'no-lang'],
93
+ [/did not become a fillable field|field\(s\) skipped/, 'field-skipped'],
94
+ [/password field\(s\) written empty/, 'field-password'],
95
+ [/appearance|NeedAppearances/, 'field-appearance'],
96
+ [/duplicate field name/, 'field-name'],
97
+ [/^fonts embedded:/, 'font-embedded'],
98
+ [/none reached the capture/, 'font-fallback'],
99
+ [/stays searchable but is not embedded/, 'font-fallback'],
100
+ [/characters their embedded face lacks/, 'font-coverage'],
101
+ [/painted as real vector glyphs/, 'vector-painted'],
102
+ [/could not be painted as vector/, 'vector-missed'],
103
+ [/^document claims PDF\/UA-1/, 'ua-claimed'],
104
+ [/^PDF\/UA-1 claim withheld/, 'ua-withheld'],
105
+ [/decided when the capture measures/, 'option-misplaced'],
106
+ [/is not a PDF option|is not a document property/, 'option-unknown'],
107
+ [/capture element has no layout box/, 'capture-empty'],
108
+ ]
109
+
110
+ /** One warning sentence → its stable code. */
111
+ export function classify(message) {
112
+ for (const [pattern, code] of RULES) {
113
+ if (pattern.test(message)) return code
114
+ }
115
+ return 'other'
116
+ }
117
+
118
+ /**
119
+ * The object `onReport` receives — everything a caller can gate on, and nothing
120
+ * that varies between identical exports. Timing is deliberately absent: two
121
+ * identical exports must produce two identical reports, or the report cannot sit
122
+ * in a snapshot test.
123
+ *
124
+ * @param {object} args
125
+ * @param {string[]} args.messages every warning, in emission order
126
+ * @param {number} args.pages page count, front and back matter included
127
+ * @param {number} args.bytes the finished blob's size
128
+ * @param {number} args.fields fillable fields written
129
+ * @param {number} args.annotations link annotations written
130
+ */
131
+ export function buildReport({
132
+ messages, pages, bytes, fields = 0, annotations = 0, fontsEmbedded = [], pdfua = false,
133
+ vectorText = 0,
134
+ }) {
135
+ const warnings = messages.map(message => ({ code: classify(message), message }))
136
+ const counts = {}
137
+ for (const w of warnings) counts[w.code] = (counts[w.code] || 0) + 1
138
+ return { warnings, counts, pages, bytes, fields, annotations, fontsEmbedded, pdfua, vectorText }
139
+ }
140
+
141
+ /** Validate the option before any expensive work runs. */
142
+ export function reportSpec(onReport) {
143
+ if (onReport == null) return null
144
+ if (typeof onReport !== 'function') {
145
+ throw new TypeError('[snapdom-pdf] onReport must be a function')
146
+ }
147
+ return onReport
148
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Progress and cancellation for an export that is no longer instant.
3
+ *
4
+ * A 40-page report rasterizes forty page regions through an image codec; that is
5
+ * seconds, and seconds behind a mute button is how users learn to distrust a
6
+ * feature. `onProgress` narrates, `signal` aborts — the same two primitives the
7
+ * platform already standardised, so nothing here invents a vocabulary.
8
+ *
9
+ * Cancellation is checked BETWEEN pages, not inside codec calls: an abort lands
10
+ * within one page's worth of work, and the capture's own caches stay coherent
11
+ * because nothing is torn down mid-object. An aborted export throws the
12
+ * platform's own `AbortError` — the one shape `fetch` taught every caller to
13
+ * catch — and produces no blob, no download, no report.
14
+ *
15
+ * Phases, in the order a caller will see them:
16
+ *
17
+ * measure the capture's final artifact is being measured (first export only)
18
+ * page one body page drawn; done/total are page counts
19
+ * matter cover, contents or closing page drawn
20
+ * assemble objects, structure and metadata being written
21
+ *
22
+ * `total` is the body page count once pagination has run and `null` before
23
+ * anything knows it. A progress bar treats null as indeterminate, which it is.
24
+ */
25
+
26
+ /** Validate both options before any expensive work, and bind them into the two
27
+ * calls the pipeline actually makes. */
28
+ export function flowSpec({ signal, onProgress } = {}) {
29
+ if (signal != null && !(typeof AbortSignal !== 'undefined' && signal instanceof AbortSignal)) {
30
+ throw new TypeError('[snapdom-pdf] signal must be an AbortSignal')
31
+ }
32
+ if (onProgress != null && typeof onProgress !== 'function') {
33
+ throw new TypeError('[snapdom-pdf] onProgress must be a function')
34
+ }
35
+ if (!signal && !onProgress) return null
36
+ return {
37
+ /** Throws the platform AbortError if the caller has cancelled. */
38
+ check() {
39
+ if (!signal?.aborted) return
40
+ throw signal.reason instanceof Error
41
+ ? signal.reason
42
+ : new DOMException('[snapdom-pdf] export aborted', 'AbortError')
43
+ },
44
+ /** One progress heartbeat. A throwing callback must not kill the export —
45
+ * progress is narration, not control flow — so it is reported and dropped. */
46
+ tick(phase, done = null, total = null) {
47
+ if (!onProgress) return
48
+ try {
49
+ onProgress({ phase, done, total })
50
+ } catch (error) {
51
+ console.warn(`[snapdom-pdf] onProgress threw: ${error?.message || error}`)
52
+ onProgress = null
53
+ }
54
+ },
55
+ }
56
+ }
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Which font FILES stand behind the capture's text, and the bytes of each.
3
+ *
4
+ * The path from a CSS `font-family` stack to an embeddable binary has four
5
+ * hops, and every one can fail in its own way:
6
+ *
7
+ * stack → family the first non-generic name with a readable @font-face
8
+ * family → rule CSS matching: style first, nearest weight, then the
9
+ * unicode-range subset that covers what this style paints
10
+ * rule → bytes a data: URI inlined by snapdom, or the src URL fetched
11
+ * force-cache (the page already downloaded it to paint)
12
+ * bytes → sfnt WOFF unpacks; WOFF2, collections and license-restricted
13
+ * faces refuse, each with its reason
14
+ *
15
+ * Whatever survives becomes ONE parsed face per underlying file, shared by
16
+ * every style that matched it. Whatever does not is a named diagnostic, never
17
+ * a silent substitution: a system font has no bytes a page can hand over, and
18
+ * saying so is the difference between "not embeddable" and "forgot".
19
+ *
20
+ * Coverage is settled LATER, per run: a face travels bound to a style even
21
+ * when its cmap lacks some character that style paints, because the emitter
22
+ * checks every run against the real cmap and falls back run-by-run. Refusing
23
+ * the whole binding for one missing glyph (what the SVG channel does for its
24
+ * textLength contract) would throw away a page of embeddable Latin over one
25
+ * stray dingbat.
26
+ *
27
+ * The sibling vector product solves the same problem the same way — same rule
28
+ * reading, same matching order — so the two channels embed the same file for
29
+ * the same page or disagree loudly, never quietly.
30
+ */
31
+
32
+ import { sfntFromBytes, parseFont } from '../writer/font.js'
33
+
34
+ /** Families that name a CATEGORY, not a file. Nothing behind them to embed. */
35
+ const GENERIC = new Set([
36
+ 'sans-serif', 'serif', 'monospace', 'cursive', 'fantasy', 'system-ui', 'math',
37
+ 'ui-sans-serif', 'ui-serif', 'ui-monospace', 'ui-rounded', 'emoji', 'fangsong',
38
+ '-apple-system', 'blinkmacsystemfont',
39
+ ])
40
+
41
+ const unquote = (s) => String(s || '').trim().replace(/^["']|["']$/g, '').trim()
42
+
43
+ /** Split at top-level `sep`, parens and quotes respected. */
44
+ function splitTop(s, sep) {
45
+ const out = []
46
+ let depth = 0
47
+ let quote = null
48
+ let at = 0
49
+ for (let i = 0; i < s.length; i++) {
50
+ const c = s[i]
51
+ if (quote) {
52
+ if (c === quote) quote = null
53
+ continue
54
+ }
55
+ if (c === '"' || c === "'") quote = c
56
+ else if (c === '(') depth++
57
+ else if (c === ')') depth = Math.max(0, depth - 1)
58
+ else if (c === sep && depth === 0) {
59
+ out.push(s.slice(at, i))
60
+ at = i + 1
61
+ }
62
+ }
63
+ out.push(s.slice(at))
64
+ return out
65
+ }
66
+
67
+ /** `font-weight` descriptor → [lo, hi]. A single value pins both ends. */
68
+ function weightRange(value) {
69
+ const words = String(value || '').trim().toLowerCase()
70
+ .replace(/\bnormal\b/g, '400').replace(/\bbold\b/g, '700').split(/\s+/)
71
+ const lo = parseFloat(words[0])
72
+ const hi = parseFloat(words[1])
73
+ if (!Number.isFinite(lo)) return [400, 400]
74
+ return [lo, Number.isFinite(hi) ? hi : lo]
75
+ }
76
+
77
+ /** `unicode-range` → [[lo,hi],…], or null for "everything". */
78
+ function parseUnicodeRange(value) {
79
+ const src = String(value || '').trim()
80
+ if (!src) return null
81
+ const out = []
82
+ for (const part of src.split(',')) {
83
+ const m = /^\s*U\+([0-9a-f?]+)(?:-([0-9a-f]+))?\s*$/i.exec(part)
84
+ if (!m) continue
85
+ if (m[1].includes('?')) {
86
+ out.push([parseInt(m[1].replace(/\?/g, '0'), 16), parseInt(m[1].replace(/\?/g, 'f'), 16)])
87
+ } else {
88
+ const lo = parseInt(m[1], 16)
89
+ out.push([lo, m[2] ? parseInt(m[2], 16) : lo])
90
+ }
91
+ }
92
+ return out.length ? out : null
93
+ }
94
+
95
+ function dataUriBytes(uri) {
96
+ const comma = uri.indexOf(',')
97
+ if (comma < 0) return null
98
+ const head = uri.slice(0, comma)
99
+ const payload = uri.slice(comma + 1)
100
+ try {
101
+ if (/;base64/i.test(head)) {
102
+ const bin = atob(payload)
103
+ const out = new Uint8Array(bin.length)
104
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
105
+ return out
106
+ }
107
+ return new TextEncoder().encode(decodeURIComponent(payload))
108
+ } catch {
109
+ return null
110
+ }
111
+ }
112
+
113
+ /** Formats an engine would load; anything else in `format()` never painted. */
114
+ const LOADABLE = new Set([
115
+ 'woff2', 'woff', 'truetype', 'opentype',
116
+ 'woff2-variations', 'woff-variations', 'truetype-variations', 'opentype-variations',
117
+ ])
118
+
119
+ /** `src:` in source order — the browser takes the first loadable entry. */
120
+ function srcCandidates(src, base) {
121
+ const out = []
122
+ for (const item of splitTop(String(src || ''), ',')) {
123
+ if (/^\s*local\s*\(/i.test(item)) continue
124
+ const u = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^)\s]*))\s*\)/i.exec(item)
125
+ if (!u) continue
126
+ const raw = u[1] || u[2] || u[3] || ''
127
+ if (!raw) continue
128
+ const f = /format\(\s*(?:"([^"]*)"|'([^']*)'|([^)\s]*))\s*\)/i.exec(item)
129
+ const format = (f ? (f[1] || f[2] || f[3] || '') : '').toLowerCase()
130
+ if (format && !LOADABLE.has(format)) continue
131
+ let url = raw
132
+ if (!/^data:/i.test(raw)) {
133
+ try { url = new URL(raw, base).href } catch { continue }
134
+ }
135
+ out.push(url)
136
+ }
137
+ return out
138
+ }
139
+
140
+ /**
141
+ * Every `@font-face` the measurement document can show. Runs while the isolated
142
+ * frame is ALIVE — snapdom serialized the capture's stylesheets into it, fonts
143
+ * usually inlined as data: URIs — because this CSSOM is gone the moment the
144
+ * frame is torn down, and the export that wants the bytes runs later.
145
+ */
146
+ export function collectFaceRules(frameDoc) {
147
+ const rules = []
148
+ const base = frameDoc.baseURI
149
+ const walk = (list, sheetBase) => {
150
+ for (const rule of list) {
151
+ if (rule.type === 5) {
152
+ const family = unquote(rule.style.getPropertyValue('font-family'))
153
+ const src = rule.style.getPropertyValue('src')
154
+ if (family && src) {
155
+ rules.push({
156
+ family,
157
+ src,
158
+ weight: rule.style.getPropertyValue('font-weight') || '',
159
+ style: rule.style.getPropertyValue('font-style') || '',
160
+ unicodeRange: rule.style.getPropertyValue('unicode-range') || '',
161
+ base: sheetBase,
162
+ })
163
+ }
164
+ continue
165
+ }
166
+ if (rule.cssRules) {
167
+ try { walk(rule.cssRules, sheetBase) } catch { /* nested cross-origin */ }
168
+ }
169
+ }
170
+ }
171
+ for (const sheet of frameDoc.styleSheets) {
172
+ try { walk(sheet.cssRules, sheet.href || base) } catch { /* cross-origin: nothing to read */ }
173
+ }
174
+ return rules
175
+ }
176
+
177
+ /** Style first, then nearest weight — the browser's own ordering, compressed. */
178
+ function matchScore(rule, weight, italic) {
179
+ const [lo, hi] = weightRange(rule.weight)
180
+ const distance = weight < lo ? lo - weight : weight > hi ? weight - hi : 0
181
+ const ruleItalic = /^(italic|oblique)/.test((rule.style || 'normal').trim())
182
+ return (ruleItalic === italic ? 1000 : 0) - distance
183
+ }
184
+
185
+ function coversAll(rule, points) {
186
+ const ranges = parseUnicodeRange(rule.unicodeRange)
187
+ if (!ranges) return true
188
+ for (const cp of points) {
189
+ let hit = false
190
+ for (const [lo, hi] of ranges) {
191
+ if (cp >= lo && cp <= hi) { hit = true; break }
192
+ }
193
+ if (!hit) return false
194
+ }
195
+ return true
196
+ }
197
+
198
+ async function fetchBytes(url, timeout) {
199
+ const controller = typeof AbortController === 'function' ? new AbortController() : null
200
+ const timer = controller && timeout > 0 ? setTimeout(() => controller.abort(), timeout) : null
201
+ try {
202
+ const res = await fetch(url, { signal: controller?.signal, cache: 'force-cache' })
203
+ if (!res.ok) return null
204
+ return new Uint8Array(await res.arrayBuffer())
205
+ } catch {
206
+ return null
207
+ } finally {
208
+ if (timer) clearTimeout(timer)
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Resolve every font key the layer measured to a parsed, embeddable face — or
214
+ * to `null` and a sentence.
215
+ *
216
+ * @param {Array<{stack: string, weight: number, italic: boolean}>} fontKeys
217
+ * @param {Array<Set<number>>} pointsOfFk code points each key's runs paint
218
+ * @param {Array<object>} rules `collectFaceRules` output, captured at measure
219
+ * @param {Map} cache capture-lifetime: url/data-uri → {parsed, sfnt}|{error}
220
+ * @param {(msg: string) => void} report
221
+ * @returns {Promise<Array<{parsed, sfnt, label}|null>>} parallel to fontKeys;
222
+ * entries are SHARED where two keys resolved to one file.
223
+ */
224
+ export async function resolveFaces(fontKeys, pointsOfFk, rules, cache, report) {
225
+ const byFamily = new Map()
226
+ for (const rule of rules) {
227
+ const key = rule.family.toLowerCase()
228
+ if (!byFamily.has(key)) byFamily.set(key, [])
229
+ byFamily.get(key).push(rule)
230
+ }
231
+
232
+ const faceByUrl = new Map()
233
+ const out = []
234
+ const failed = new Map() // one sentence per distinct failure, however many keys hit it
235
+
236
+ for (let i = 0; i < fontKeys.length; i++) {
237
+ const fk = fontKeys[i]
238
+ out.push(null)
239
+ const points = pointsOfFk[i] || new Set()
240
+ for (const name of splitTop(fk.stack, ',').map(unquote)) {
241
+ if (!name || GENERIC.has(name.toLowerCase())) continue
242
+ const candidates = byFamily.get(name.toLowerCase())
243
+ if (!candidates) continue
244
+ const scored = candidates
245
+ .map((rule, at) => ({ rule, at, score: matchScore(rule, fk.weight, fk.italic) }))
246
+ .sort((a, b) => (b.score - a.score) || (a.at - b.at))
247
+ const best = scored.filter(s => s.score === scored[0].score)
248
+ const winner = (best.find(s => coversAll(s.rule, points)) || best[0]).rule
249
+ const label = `"${winner.family}" ${fk.weight}${fk.italic ? ' italic' : ''}`
250
+
251
+ let face = null
252
+ for (const url of srcCandidates(winner.src, winner.base)) {
253
+ if (faceByUrl.has(url)) { face = faceByUrl.get(url); break }
254
+ let entry = cache.get(url)
255
+ if (!entry) {
256
+ const bytes = /^data:/i.test(url) ? dataUriBytes(url) : await fetchBytes(url, 8000)
257
+ if (!bytes || !bytes.length) {
258
+ entry = { error: 'its bytes could not be read' }
259
+ } else {
260
+ const container = await sfntFromBytes(bytes)
261
+ if (container.error) {
262
+ entry = { error: container.error }
263
+ } else {
264
+ const parsed = parseFont(container.sfnt)
265
+ entry = parsed.error ? { error: parsed.error } : { parsed, sfnt: container.sfnt }
266
+ }
267
+ }
268
+ cache.set(url, entry)
269
+ }
270
+ if (!entry.error) {
271
+ face = { parsed: entry.parsed, sfnt: entry.sfnt, label }
272
+ faceByUrl.set(url, face)
273
+ break
274
+ }
275
+ failed.set(`${label}: ${entry.error}`, true)
276
+ }
277
+ if (face) out[i] = face
278
+ break // first non-generic family with rules decides, matching the browser
279
+ }
280
+ }
281
+
282
+ for (const sentence of failed.keys()) {
283
+ report(`font ${sentence} — text in that face stays searchable but is not embedded, and the ` +
284
+ 'export cannot claim conformance.')
285
+ }
286
+ return out
287
+ }