@zumer/snapdom-plugins 3.0.0-beta.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 +54 -20
- 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 +17 -6
- package/pdf/LICENSE +21 -0
- package/pdf/README.md +22 -0
- package/pdf/REFERENCE.md +520 -0
- package/pdf/src/frontmatter.js +200 -0
- package/pdf/src/furniture.js +398 -0
- package/pdf/src/index.js +2415 -0
- package/pdf/src/mega/README.md +47 -0
- package/pdf/src/mega/diag.js +148 -0
- package/pdf/src/mega/flow.js +56 -0
- package/pdf/src/mega/fonts.js +287 -0
- package/pdf/src/mega/forms.js +479 -0
- package/pdf/src/mega/labels.js +43 -0
- package/pdf/src/mega/meta.js +145 -0
- package/pdf/src/nav.js +357 -0
- package/pdf/src/paginate.js +583 -0
- package/pdf/src/tagged.js +355 -0
- package/pdf/src/text-layer.js +1771 -0
- package/pdf/src/writer/encrypt.js +218 -0
- package/pdf/src/writer/font.js +507 -0
- package/pdf/src/writer/index.js +1135 -0
- package/pdf/src/writer/subset.js +387 -0
- package/pdf.d.ts +294 -0
- package/pdf.js +1 -0
- package/redact-inputs.js +6 -6
- package/vector/CONTRACT.md +906 -0
- package/vector/LICENSE +21 -0
- package/vector/README.md +35 -0
- package/vector/src/adapters/snapdom.js +795 -0
- package/vector/src/collect/box.js +1676 -0
- package/vector/src/collect/font.js +487 -0
- package/vector/src/collect/image.js +1458 -0
- package/vector/src/collect/mathml.js +272 -0
- package/vector/src/collect/text.js +3027 -0
- package/vector/src/css/color.js +823 -0
- package/vector/src/css/gradient.js +1126 -0
- package/vector/src/css/matrix.js +398 -0
- package/vector/src/css/radius.js +124 -0
- package/vector/src/css/shadow.js +201 -0
- package/vector/src/emit/figma-json.js +1995 -0
- package/vector/src/emit/h2d-vector.js +215 -0
- package/vector/src/emit/h2d.js +1147 -0
- package/vector/src/emit/pdf.js +2571 -0
- package/vector/src/emit/svg-flat.js +4842 -0
- package/vector/src/exclusion.js +13 -0
- package/vector/src/index.js +2910 -0
- package/vector/src/paint.js +2908 -0
- package/vector/src/public.js +57 -0
- package/vector/src/writer/encrypt.js +227 -0
- package/vector/src/writer/font.js +507 -0
- package/vector/src/writer/index.js +1148 -0
- package/vector/src/writer/subset.js +387 -0
- package/vector/svd/index.js +72 -0
- package/vector/svd/schema.js +985 -0
- package/vector.d.ts +28 -0
- package/vector.js +1 -0
- package/video-export.d.ts +6 -0
- package/video-export.js +7 -7
- package/pdf-image.d.ts +0 -18
- package/pdf-image.js +0 -114
|
@@ -0,0 +1,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
|
+
}
|