domotion-svg 0.1.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.
- package/FEATURES.md +102 -0
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/animator.d.ts +158 -0
- package/dist/animator.js +424 -0
- package/dist/animator.test.d.ts +5 -0
- package/dist/animator.test.js +169 -0
- package/dist/border-radius.test.d.ts +1 -0
- package/dist/border-radius.test.js +148 -0
- package/dist/capture.d.ts +193 -0
- package/dist/capture.js +786 -0
- package/dist/chrome.d.ts +45 -0
- package/dist/chrome.js +107 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +512 -0
- package/dist/client/dom.d.ts +10 -0
- package/dist/client/dom.js +17 -0
- package/dist/conic-raster.d.ts +58 -0
- package/dist/conic-raster.js +292 -0
- package/dist/conic-raster.test.d.ts +1 -0
- package/dist/conic-raster.test.js +187 -0
- package/dist/coretext-extractor.test.d.ts +1 -0
- package/dist/coretext-extractor.test.js +94 -0
- package/dist/coretext-helper.d.ts +60 -0
- package/dist/coretext-helper.js +205 -0
- package/dist/cross-origin-font-face.test.d.ts +1 -0
- package/dist/cross-origin-font-face.test.js +107 -0
- package/dist/cursor-overlay.d.ts +123 -0
- package/dist/cursor-overlay.js +207 -0
- package/dist/cursor-overlay.test.d.ts +1 -0
- package/dist/cursor-overlay.test.js +88 -0
- package/dist/dark-mode-capture.test.d.ts +1 -0
- package/dist/dark-mode-capture.test.js +158 -0
- package/dist/dark-mode-form-controls.test.d.ts +1 -0
- package/dist/dark-mode-form-controls.test.js +218 -0
- package/dist/dom-to-svg.d.ts +1016 -0
- package/dist/dom-to-svg.js +7717 -0
- package/dist/embed-remote-images.test.d.ts +1 -0
- package/dist/embed-remote-images.test.js +424 -0
- package/dist/form-controls.d.ts +70 -0
- package/dist/form-controls.js +1151 -0
- package/dist/frame-merge.d.ts +95 -0
- package/dist/frame-merge.js +374 -0
- package/dist/frame-merge.test.d.ts +6 -0
- package/dist/frame-merge.test.js +144 -0
- package/dist/gradients.d.ts +184 -0
- package/dist/gradients.js +937 -0
- package/dist/gradients.test.d.ts +1 -0
- package/dist/gradients.test.js +150 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +7 -0
- package/dist/jsx-runtime.d.ts +27 -0
- package/dist/jsx-runtime.js +96 -0
- package/dist/jsx-runtime.test.d.ts +1 -0
- package/dist/jsx-runtime.test.js +41 -0
- package/dist/kerfjs-imports.test.d.ts +1 -0
- package/dist/kerfjs-imports.test.js +36 -0
- package/dist/mask.test.d.ts +1 -0
- package/dist/mask.test.js +206 -0
- package/dist/optimize.d.ts +12 -0
- package/dist/optimize.js +32 -0
- package/dist/preserve-aspect-ratio.test.d.ts +1 -0
- package/dist/preserve-aspect-ratio.test.js +38 -0
- package/dist/resize-embedded-images.d.ts +33 -0
- package/dist/resize-embedded-images.js +164 -0
- package/dist/resize-embedded-images.test.d.ts +9 -0
- package/dist/resize-embedded-images.test.js +255 -0
- package/dist/stacking-context.test.d.ts +1 -0
- package/dist/stacking-context.test.js +927 -0
- package/dist/text-renderer.d.ts +42 -0
- package/dist/text-renderer.js +608 -0
- package/dist/text-renderer.test.d.ts +1 -0
- package/dist/text-renderer.test.js +150 -0
- package/dist/text-to-path.d.ts +265 -0
- package/dist/text-to-path.js +1800 -0
- package/dist/text-to-path.test.d.ts +1 -0
- package/dist/text-to-path.test.js +570 -0
- package/dist/utils/escapeHtml.d.ts +2 -0
- package/dist/utils/escapeHtml.js +15 -0
- package/dist/webfont-unicode-range.test.d.ts +1 -0
- package/dist/webfont-unicode-range.test.js +174 -0
- package/package.json +55 -0
- package/src/animator.test.ts +179 -0
- package/src/animator.ts +660 -0
- package/src/border-radius.test.ts +160 -0
- package/src/capture.ts +810 -0
- package/src/cli.ts +582 -0
- package/src/conic-raster.test.ts +213 -0
- package/src/conic-raster.ts +309 -0
- package/src/coretext-extractor.test.ts +130 -0
- package/src/coretext-helper.ts +256 -0
- package/src/cross-origin-font-face.test.ts +119 -0
- package/src/cursor-overlay.test.ts +95 -0
- package/src/cursor-overlay.ts +297 -0
- package/src/dark-mode-capture.test.ts +177 -0
- package/src/dark-mode-form-controls.test.ts +228 -0
- package/src/dom-to-svg.ts +8376 -0
- package/src/embed-remote-images.test.ts +461 -0
- package/src/form-controls.ts +1174 -0
- package/src/frame-merge.test.ts +157 -0
- package/src/frame-merge.ts +447 -0
- package/src/globals.d.ts +2 -0
- package/src/gradients.test.ts +175 -0
- package/src/gradients.ts +955 -0
- package/src/index.ts +12 -0
- package/src/kerf-jsx-augmentation.d.ts +36 -0
- package/src/kerfjs-imports.test.tsx +45 -0
- package/src/mask.test.ts +274 -0
- package/src/optimize.ts +34 -0
- package/src/preserve-aspect-ratio.test.ts +49 -0
- package/src/resize-embedded-images.test.ts +292 -0
- package/src/resize-embedded-images.ts +180 -0
- package/src/stacking-context.test.ts +967 -0
- package/src/text-renderer.test.ts +162 -0
- package/src/text-renderer.ts +623 -0
- package/src/text-to-path.test.ts +639 -0
- package/src/text-to-path.ts +1810 -0
- package/src/utils/escapeHtml.ts +16 -0
- package/src/webfont-unicode-range.test.ts +207 -0
package/dist/capture.js
ADDED
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page Capture
|
|
3
|
+
*
|
|
4
|
+
* Uses Playwright to navigate to a URL, wait for it to settle,
|
|
5
|
+
* and capture the DOM as SVG via the dom-to-svg converter.
|
|
6
|
+
*/
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { chromium } from "@playwright/test";
|
|
9
|
+
import { captureElementTree, elementTreeToSvg, embedRemoteImages } from "./dom-to-svg.js";
|
|
10
|
+
import { resizeEmbeddedImages } from "./resize-embedded-images.js";
|
|
11
|
+
import { rasterizeConicGradients } from "./conic-raster.js";
|
|
12
|
+
import { registerLocalFontAlias, registerWebfont } from "./text-to-path.js";
|
|
13
|
+
/**
|
|
14
|
+
* Launch Chromium via Playwright, auto-installing the browser binary on first
|
|
15
|
+
* use if it's missing. Use this instead of importing `chromium` from
|
|
16
|
+
* `@playwright/test` directly when you want a frictionless first-run
|
|
17
|
+
* experience for users of your tool.
|
|
18
|
+
*
|
|
19
|
+
* The install step is `npx playwright install chromium` and runs synchronously
|
|
20
|
+
* (stdout / stderr inherited) so the user sees its progress. Subsequent calls
|
|
21
|
+
* are a normal `chromium.launch()` with no overhead.
|
|
22
|
+
*/
|
|
23
|
+
export async function launchChromium(opts) {
|
|
24
|
+
try {
|
|
25
|
+
return await chromium.launch(opts);
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
if (!isMissingBrowserError(err))
|
|
29
|
+
throw err;
|
|
30
|
+
console.error("[domotion] Chromium binary not found — installing via 'npx playwright install chromium'…");
|
|
31
|
+
const result = spawnSync("npx", ["playwright", "install", "chromium"], {
|
|
32
|
+
stdio: "inherit",
|
|
33
|
+
shell: process.platform === "win32",
|
|
34
|
+
});
|
|
35
|
+
if (result.status !== 0) {
|
|
36
|
+
throw new Error("[domotion] Failed to auto-install Playwright Chromium. " +
|
|
37
|
+
"Run 'npx playwright install chromium' manually and try again.");
|
|
38
|
+
}
|
|
39
|
+
return chromium.launch(opts);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function isMissingBrowserError(err) {
|
|
43
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
44
|
+
return /Executable doesn't exist|playwright install|browserType\.launch.*Failed to launch/i.test(msg);
|
|
45
|
+
}
|
|
46
|
+
export class DemoRecorder {
|
|
47
|
+
browser = null;
|
|
48
|
+
context = null;
|
|
49
|
+
page = null;
|
|
50
|
+
width;
|
|
51
|
+
height;
|
|
52
|
+
baseUrl;
|
|
53
|
+
selfContained;
|
|
54
|
+
embedRemoteImagesTimeoutMs;
|
|
55
|
+
embedRemoteImagesRetries;
|
|
56
|
+
embedRemoteImagesRetryBackoffMs;
|
|
57
|
+
embedRemoteImagesResize;
|
|
58
|
+
embedRemoteImagesHiDPIFactor;
|
|
59
|
+
constructor(baseUrl, opts) {
|
|
60
|
+
this.baseUrl = baseUrl;
|
|
61
|
+
this.width = opts.width;
|
|
62
|
+
this.height = opts.height;
|
|
63
|
+
this.selfContained = opts.selfContained ?? false;
|
|
64
|
+
this.embedRemoteImagesTimeoutMs = opts.embedRemoteImagesTimeoutMs;
|
|
65
|
+
this.embedRemoteImagesRetries = opts.embedRemoteImagesRetries;
|
|
66
|
+
this.embedRemoteImagesRetryBackoffMs = opts.embedRemoteImagesRetryBackoffMs;
|
|
67
|
+
this.embedRemoteImagesResize = opts.embedRemoteImagesResize ?? false;
|
|
68
|
+
this.embedRemoteImagesHiDPIFactor = opts.embedRemoteImagesHiDPIFactor;
|
|
69
|
+
}
|
|
70
|
+
async init(opts) {
|
|
71
|
+
this.browser = await launchChromium();
|
|
72
|
+
this.context = await this.browser.newContext({
|
|
73
|
+
viewport: { width: opts.width, height: opts.height },
|
|
74
|
+
isMobile: opts.mobile ?? false,
|
|
75
|
+
...(opts.mobile ? { userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)" } : {}),
|
|
76
|
+
...(opts.colorScheme != null ? { colorScheme: opts.colorScheme } : {}),
|
|
77
|
+
});
|
|
78
|
+
// Dev auth if requested
|
|
79
|
+
if (opts.devUser != null) {
|
|
80
|
+
const res = await fetch(`${this.baseUrl}/api/v1/auth/dev-login`, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: { "Content-Type": "application/json" },
|
|
83
|
+
body: JSON.stringify({ username: opts.devUser }),
|
|
84
|
+
redirect: "manual",
|
|
85
|
+
});
|
|
86
|
+
if (res.ok) {
|
|
87
|
+
const json = await res.json();
|
|
88
|
+
const setCookie = res.headers.get("set-cookie") ?? "";
|
|
89
|
+
if (setCookie !== "") {
|
|
90
|
+
const parts = setCookie.split(";")[0].split("=");
|
|
91
|
+
await this.context.addCookies([{
|
|
92
|
+
name: parts[0],
|
|
93
|
+
value: parts.slice(1).join("="),
|
|
94
|
+
domain: new URL(this.baseUrl).hostname,
|
|
95
|
+
path: "/",
|
|
96
|
+
}]);
|
|
97
|
+
}
|
|
98
|
+
// Set localStorage for client-side auth
|
|
99
|
+
await this.context.addInitScript((token) => {
|
|
100
|
+
localStorage.setItem("sk_token", token);
|
|
101
|
+
localStorage.setItem("sk_csrf", "");
|
|
102
|
+
}, json.data.token);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
this.page = await this.context.newPage();
|
|
106
|
+
// DM-479: bump per-page Playwright operation timeouts from the 30 s
|
|
107
|
+
// default to 90 s. Heavy CSS / font / image loads on real-world sites
|
|
108
|
+
// and large captures push past 30 s without being genuinely stuck.
|
|
109
|
+
this.page.setDefaultTimeout(90_000);
|
|
110
|
+
this.page.setDefaultNavigationTimeout(90_000);
|
|
111
|
+
}
|
|
112
|
+
/** Navigate to a URL and capture the visible DOM as SVG. */
|
|
113
|
+
async captureUrl(path, waitMs = 800, idPrefix = "") {
|
|
114
|
+
if (this.page == null)
|
|
115
|
+
throw new Error("Call init() first");
|
|
116
|
+
await this.page.goto(`${this.baseUrl}${path}`, { waitUntil: "networkidle" });
|
|
117
|
+
await this.page.waitForTimeout(waitMs);
|
|
118
|
+
return this.captureCurrent(idPrefix);
|
|
119
|
+
}
|
|
120
|
+
/** Capture the current page state as SVG content. */
|
|
121
|
+
async captureCurrent(idPrefix = "") {
|
|
122
|
+
if (this.page == null)
|
|
123
|
+
throw new Error("Call init() first");
|
|
124
|
+
const tree = await captureElementTree(this.page, "body", {
|
|
125
|
+
x: 0, y: 0, width: this.width, height: this.height,
|
|
126
|
+
});
|
|
127
|
+
if (this.selfContained)
|
|
128
|
+
await embedRemoteImages(tree, {
|
|
129
|
+
timeoutMs: this.embedRemoteImagesTimeoutMs,
|
|
130
|
+
retries: this.embedRemoteImagesRetries,
|
|
131
|
+
retryBackoffMs: this.embedRemoteImagesRetryBackoffMs,
|
|
132
|
+
});
|
|
133
|
+
if (this.selfContained && this.embedRemoteImagesResize) {
|
|
134
|
+
await resizeEmbeddedImages(tree, { hiDPIFactor: this.embedRemoteImagesHiDPIFactor });
|
|
135
|
+
}
|
|
136
|
+
// DM-549: rasterize conic-gradient layers into PNG tiles. No-op when the
|
|
137
|
+
// tree contains no conic content; otherwise the renderer (DM-550) emits
|
|
138
|
+
// <pattern><image href="data:..."/></pattern> instead of dropping the layer.
|
|
139
|
+
await rasterizeConicGradients(tree, { hiDPIFactor: this.embedRemoteImagesHiDPIFactor });
|
|
140
|
+
return elementTreeToSvg(tree, this.width, this.height, idPrefix, true, this.embedRemoteImagesHiDPIFactor ?? 2);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Capture a full-page (scrollable) DOM as SVG.
|
|
144
|
+
* Returns SVG content that may be taller than the viewport.
|
|
145
|
+
*/
|
|
146
|
+
async captureFullPage(idPrefix = "") {
|
|
147
|
+
if (this.page == null)
|
|
148
|
+
throw new Error("Call init() first");
|
|
149
|
+
const pageHeight = await this.page.evaluate(() => document.body.scrollHeight);
|
|
150
|
+
const tree = await captureElementTree(this.page, "body", {
|
|
151
|
+
x: 0, y: 0, width: this.width, height: pageHeight,
|
|
152
|
+
});
|
|
153
|
+
if (this.selfContained)
|
|
154
|
+
await embedRemoteImages(tree, {
|
|
155
|
+
timeoutMs: this.embedRemoteImagesTimeoutMs,
|
|
156
|
+
retries: this.embedRemoteImagesRetries,
|
|
157
|
+
retryBackoffMs: this.embedRemoteImagesRetryBackoffMs,
|
|
158
|
+
});
|
|
159
|
+
if (this.selfContained && this.embedRemoteImagesResize) {
|
|
160
|
+
await resizeEmbeddedImages(tree, { hiDPIFactor: this.embedRemoteImagesHiDPIFactor });
|
|
161
|
+
}
|
|
162
|
+
// DM-549: rasterize conic-gradient layers — see captureCurrent above.
|
|
163
|
+
await rasterizeConicGradients(tree, { hiDPIFactor: this.embedRemoteImagesHiDPIFactor });
|
|
164
|
+
const svgContent = elementTreeToSvg(tree, this.width, pageHeight, idPrefix, true, this.embedRemoteImagesHiDPIFactor ?? 2);
|
|
165
|
+
return { svgContent, pageHeight };
|
|
166
|
+
}
|
|
167
|
+
/** Get the underlying Playwright page for custom interactions. */
|
|
168
|
+
getPage() {
|
|
169
|
+
if (this.page == null)
|
|
170
|
+
throw new Error("Call init() first");
|
|
171
|
+
return this.page;
|
|
172
|
+
}
|
|
173
|
+
/** Get the bounding box of an element (for positioning overlays). */
|
|
174
|
+
async getBoundingBox(selector) {
|
|
175
|
+
if (this.page == null)
|
|
176
|
+
return null;
|
|
177
|
+
const el = this.page.locator(selector).first();
|
|
178
|
+
return el.boundingBox();
|
|
179
|
+
}
|
|
180
|
+
async close() {
|
|
181
|
+
await this.browser?.close();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Install a `requestfinished` listener that records every font-file URL the
|
|
186
|
+
* browser fetches into a Set. Returns the set + a detach handle. Pair with
|
|
187
|
+
* `discoverAndRegisterWebfonts(page, tracker.urls)` after the page loads.
|
|
188
|
+
*
|
|
189
|
+
* Needed because `performance.getEntriesByType("resource")` omits
|
|
190
|
+
* cross-origin fonts that don't send `Timing-Allow-Origin: *` (most CDNs
|
|
191
|
+
* don't), and most webfonts in the wild are served cross-origin.
|
|
192
|
+
*
|
|
193
|
+
* Attach BEFORE navigation so the listener catches the initial fetches.
|
|
194
|
+
*/
|
|
195
|
+
export function attachWebfontTracker(page) {
|
|
196
|
+
const urls = new Set();
|
|
197
|
+
const handler = (req) => {
|
|
198
|
+
const u = req.url();
|
|
199
|
+
if (/\.(woff2?|ttf|otf)(\?|$)/i.test(u))
|
|
200
|
+
urls.add(u);
|
|
201
|
+
};
|
|
202
|
+
page.on("requestfinished", handler);
|
|
203
|
+
return { urls, detach: () => page.off("requestfinished", handler) };
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Discover all `@font-face` rules in the page's stylesheets, fetch each
|
|
207
|
+
* font file via the browser context's request API (so cookies / CORS / auth
|
|
208
|
+
* follow whatever the browser is using), and register the bytes with
|
|
209
|
+
* `text-to-path.ts` so the renderer can draw with the actual webfont glyphs
|
|
210
|
+
* instead of falling through to the system-font substitutes.
|
|
211
|
+
*
|
|
212
|
+
* Should be called AFTER `await page.evaluate(() => document.fonts.ready)`
|
|
213
|
+
* — otherwise late-loading fonts may not be in `document.styleSheets` yet.
|
|
214
|
+
*
|
|
215
|
+
* Cross-origin stylesheets whose `cssRules` throw a SecurityError are silently
|
|
216
|
+
* skipped (we can't enumerate their rules from JS). Same-origin sheets and
|
|
217
|
+
* inline `<style>` blocks always work.
|
|
218
|
+
*
|
|
219
|
+
* Caller is responsible for `clearWebfonts()` between captures if needed.
|
|
220
|
+
* No-op when the page declares no `@font-face` rules.
|
|
221
|
+
*/
|
|
222
|
+
export async function discoverAndRegisterWebfonts(page, observedFontUrls = []) {
|
|
223
|
+
const fromPage = await page.evaluate(() => {
|
|
224
|
+
// tsx/esbuild wraps named arrow consts in `__name(fn, "name")` for nicer
|
|
225
|
+
// stack traces. That helper isn't injected into page.evaluate's
|
|
226
|
+
// serialized scope, so we polyfill it here. Without this, our local
|
|
227
|
+
// helpers below throw "__name is not defined" at construction time.
|
|
228
|
+
if (typeof window.__name === "undefined") {
|
|
229
|
+
window.__name = function (fn) { return fn; };
|
|
230
|
+
}
|
|
231
|
+
// Parse a CSS `unicode-range` descriptor value into inclusive [from, to]
|
|
232
|
+
// intervals. Accepts the three forms in CSS Fonts 4 §4.5: single codepoint
|
|
233
|
+
// (`U+26`), interval (`U+0-7F`), and wildcard (`U+4??`). Returns `null`
|
|
234
|
+
// when the value is missing/unparseable so the caller treats the variant
|
|
235
|
+
// as covering the default range U+0..U+10FFFF.
|
|
236
|
+
const parseUnicodeRangeInline = function (value) {
|
|
237
|
+
const v = value.trim();
|
|
238
|
+
if (v === "")
|
|
239
|
+
return undefined;
|
|
240
|
+
const out = [];
|
|
241
|
+
for (const raw of v.split(",")) {
|
|
242
|
+
const tok = raw.trim().replace(/^U\+/i, "");
|
|
243
|
+
if (tok === "")
|
|
244
|
+
continue;
|
|
245
|
+
if (tok.includes("?")) {
|
|
246
|
+
const lo = parseInt(tok.replace(/\?/g, "0"), 16);
|
|
247
|
+
const hi = parseInt(tok.replace(/\?/g, "F"), 16);
|
|
248
|
+
if (Number.isFinite(lo) && Number.isFinite(hi))
|
|
249
|
+
out.push([lo, hi]);
|
|
250
|
+
}
|
|
251
|
+
else if (tok.includes("-")) {
|
|
252
|
+
const [a, b] = tok.split("-");
|
|
253
|
+
const lo = parseInt(a, 16);
|
|
254
|
+
const hi = parseInt(b, 16);
|
|
255
|
+
if (Number.isFinite(lo) && Number.isFinite(hi))
|
|
256
|
+
out.push([lo, hi]);
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
const cp = parseInt(tok, 16);
|
|
260
|
+
if (Number.isFinite(cp))
|
|
261
|
+
out.push([cp, cp]);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return out.length > 0 ? out : undefined;
|
|
265
|
+
};
|
|
266
|
+
const out = [];
|
|
267
|
+
const seenUrls = new Set();
|
|
268
|
+
// DM-545: stylesheets whose `cssRules` access threw (cross-origin without
|
|
269
|
+
// CORS). The Node side fetches their text and parses `@font-face` rules
|
|
270
|
+
// server-side. Without this, sites that serve their CSS from a CDN
|
|
271
|
+
// different from the page origin (Stripe → b.stripecdn.com, Apple → many
|
|
272
|
+
// sub-CDNs, …) miss every CSS-declared font-family alias and the
|
|
273
|
+
// resource-fallback registers under fontkit's internal `familyName` —
|
|
274
|
+
// which for license-protected fonts (Sohne) is often a copyright string.
|
|
275
|
+
const crossOriginSheetUrls = [];
|
|
276
|
+
// Width-probe helpers are defined inline below as needed. We avoid hoisting
|
|
277
|
+
// them into named arrow consts because tsx wraps such names in __name(...)
|
|
278
|
+
// calls (a runtime helper for stack traces) that isn't available inside
|
|
279
|
+
// page.evaluate's serialized context. (DM-445 — original symptom: the
|
|
280
|
+
// entire discovery throws "ReferenceError: __name is not defined".)
|
|
281
|
+
const probeWidthInline = function (familyExpr, weight, style, sample) {
|
|
282
|
+
const span = document.createElement("span");
|
|
283
|
+
span.style.cssText = "position:absolute;left:-9999px;top:-9999px;visibility:hidden;font-size:16px;line-height:1;white-space:pre";
|
|
284
|
+
span.style.fontFamily = familyExpr;
|
|
285
|
+
span.style.fontWeight = weight;
|
|
286
|
+
span.style.fontStyle = style;
|
|
287
|
+
span.textContent = sample;
|
|
288
|
+
document.body.appendChild(span);
|
|
289
|
+
const w = span.getBoundingClientRect().width;
|
|
290
|
+
document.body.removeChild(span);
|
|
291
|
+
return w;
|
|
292
|
+
};
|
|
293
|
+
// Strip trailing weight/style suffix from a local() candidate so the
|
|
294
|
+
// direct family-name probe hits the installed family. e.g. "Georgia
|
|
295
|
+
// Italic" → "Georgia". The font-style / font-weight descriptors of the
|
|
296
|
+
// alias rule already set the right variant, so the probe gets the same
|
|
297
|
+
// face Chrome's local() lookup would reach.
|
|
298
|
+
const stripVariantSuffixInline = function (n) {
|
|
299
|
+
return n.replace(/\s+(Bold Italic|Italic Bold|Bold|Italic|Oblique|Regular|Light|Medium|Semibold|Black)$/i, "").trim();
|
|
300
|
+
};
|
|
301
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
302
|
+
let cssRules;
|
|
303
|
+
try {
|
|
304
|
+
cssRules = sheet.cssRules;
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
// Cross-origin sheet — record its URL so the Node side can fetch and
|
|
308
|
+
// parse it for @font-face rules. (DM-545)
|
|
309
|
+
if (sheet.href)
|
|
310
|
+
crossOriginSheetUrls.push(sheet.href);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
for (const rule of Array.from(cssRules)) {
|
|
314
|
+
if (rule.constructor.name !== "CSSFontFaceRule")
|
|
315
|
+
continue;
|
|
316
|
+
const r = rule;
|
|
317
|
+
const family = r.style.getPropertyValue("font-family").trim().replace(/^["']|["']$/g, "");
|
|
318
|
+
const weight = r.style.getPropertyValue("font-weight") || "400";
|
|
319
|
+
const style = r.style.getPropertyValue("font-style") || "normal";
|
|
320
|
+
const src = r.style.getPropertyValue("src");
|
|
321
|
+
// DM-513: parse ALL `url(...) format(...)` pairs and return them in
|
|
322
|
+
// priority order (woff2 > woff > ttf/otf > unknown). Sites like
|
|
323
|
+
// Slashdot list legacy `eot`/`svg` (fontkit can't parse) alongside
|
|
324
|
+
// `woff` — without ordering, we'd grab the eot, fail to register,
|
|
325
|
+
// and lose every `::before` icon glyph. The Node side iterates the
|
|
326
|
+
// ranked list and uses the first URL whose response fontkit parses.
|
|
327
|
+
const srcEntries = [];
|
|
328
|
+
const urlRe = /url\(\s*["']?([^"')]+)["']?\s*\)(?:\s*format\(\s*["']?([^"')]+)["']?\s*\))?/g;
|
|
329
|
+
let urlMatch;
|
|
330
|
+
while ((urlMatch = urlRe.exec(src)) !== null) {
|
|
331
|
+
srcEntries.push({ url: urlMatch[1], format: (urlMatch[2] ?? "").toLowerCase() });
|
|
332
|
+
}
|
|
333
|
+
const formatRank = (fmt, url) => {
|
|
334
|
+
const lower = fmt.toLowerCase();
|
|
335
|
+
if (/embedded-opentype|svg/.test(lower))
|
|
336
|
+
return -1;
|
|
337
|
+
if (lower.includes("woff2") || /\.woff2(\?|$)/i.test(url))
|
|
338
|
+
return 4;
|
|
339
|
+
if (lower === "woff" || /\.woff(\?|$)/i.test(url))
|
|
340
|
+
return 3;
|
|
341
|
+
if (/truetype|opentype/.test(lower) || /\.(ttf|otf)(\?|$)/i.test(url))
|
|
342
|
+
return 2;
|
|
343
|
+
if (/\.eot(\?|$)/i.test(url) || /\.svg(\?|$)/i.test(url))
|
|
344
|
+
return -1;
|
|
345
|
+
return 1;
|
|
346
|
+
};
|
|
347
|
+
const rankedUrls = srcEntries
|
|
348
|
+
.map((e) => ({ url: e.url, rank: formatRank(e.format, e.url) }))
|
|
349
|
+
.filter((e) => e.rank >= 0)
|
|
350
|
+
.sort((a, b) => b.rank - a.rank)
|
|
351
|
+
.map((e) => e.url);
|
|
352
|
+
const m = rankedUrls.length > 0 ? [src, rankedUrls[0]] : null;
|
|
353
|
+
if (m == null) {
|
|
354
|
+
// No url() — but src may carry one or more local() entries. Capture
|
|
355
|
+
// the local names so the Node side can register an alias to the
|
|
356
|
+
// matching system font (DM-303). Without this, `font-family: Foo`
|
|
357
|
+
// where Foo is declared via @font-face { src: local("Georgia") }
|
|
358
|
+
// falls through the family chain to the next name and renders in
|
|
359
|
+
// the wrong face.
|
|
360
|
+
const locals = Array.from(src.matchAll(/local\(\s*["']?([^"')]+?)["']?\s*\)/g)).map((mm) => mm[1].trim()).filter((n) => n !== "");
|
|
361
|
+
if (locals.length > 0) {
|
|
362
|
+
// Probe to identify which local() candidate Chrome actually
|
|
363
|
+
// resolved this alias to. A sample of common monospace + serif
|
|
364
|
+
// glyphs ('mIw0') gives us enough horizontal divergence between
|
|
365
|
+
// candidates to disambiguate even closely-related faces.
|
|
366
|
+
const sample = "mIw0";
|
|
367
|
+
// Chrome's local() lookup only matches a font's full name or
|
|
368
|
+
// PostScript name (per CSS Fonts 4 §11.2), not its CSS family
|
|
369
|
+
// name. So `local("Menlo")` (a family name) fails to match —
|
|
370
|
+
// but the @font-face still resolves to the next candidate. To
|
|
371
|
+
// figure out which one, render the alias face and compare its
|
|
372
|
+
// width to each candidate via DIRECT family-name lookup (which
|
|
373
|
+
// does match installed system fonts).
|
|
374
|
+
const aliasW = probeWidthInline(`"${family}"`, weight, style, sample);
|
|
375
|
+
let resolved = null;
|
|
376
|
+
for (const cand of locals) {
|
|
377
|
+
const candW = probeWidthInline(`"${stripVariantSuffixInline(cand)}"`, weight, style, sample);
|
|
378
|
+
// Tolerate sub-px FP noise; match if widths agree within 0.05px.
|
|
379
|
+
if (Math.abs(candW - aliasW) < 0.05) {
|
|
380
|
+
resolved = cand;
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
out.push({ kind: "local", family, localNames: locals, weight, style, resolvedLocalName: resolved });
|
|
385
|
+
}
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
const base = sheet.href ?? document.baseURI;
|
|
389
|
+
let absUrl;
|
|
390
|
+
try {
|
|
391
|
+
absUrl = new URL(m[1], base).href;
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
// Resolve the full ranked URL list to absolute URLs for the Node-side
|
|
397
|
+
// iterator (DM-513). The first entry IS `absUrl`; the rest are
|
|
398
|
+
// fallback candidates the Node side tries if fontkit rejects a
|
|
399
|
+
// higher-priority URL's bytes.
|
|
400
|
+
const absUrls = [];
|
|
401
|
+
for (const ru of rankedUrls) {
|
|
402
|
+
try {
|
|
403
|
+
absUrls.push(new URL(ru, base).href);
|
|
404
|
+
}
|
|
405
|
+
catch { /* skip */ }
|
|
406
|
+
}
|
|
407
|
+
for (const u of absUrls)
|
|
408
|
+
seenUrls.add(u);
|
|
409
|
+
const unicodeRange = parseUnicodeRangeInline(r.style.getPropertyValue("unicode-range") || "");
|
|
410
|
+
out.push({ kind: "font-face", family, weight, style, url: absUrl, urls: absUrls, unicodeRange });
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
for (const entry of performance.getEntriesByType("resource")) {
|
|
414
|
+
if (!/\.(woff2?|ttf|otf)(\?|$)/i.test(entry.name))
|
|
415
|
+
continue;
|
|
416
|
+
if (seenUrls.has(entry.name))
|
|
417
|
+
continue;
|
|
418
|
+
seenUrls.add(entry.name);
|
|
419
|
+
out.push({ kind: "resource", url: entry.name });
|
|
420
|
+
}
|
|
421
|
+
return { entries: out, seenUrls: Array.from(seenUrls), crossOriginSheetUrls };
|
|
422
|
+
});
|
|
423
|
+
const discovered = fromPage.entries;
|
|
424
|
+
const seen = new Set(fromPage.seenUrls);
|
|
425
|
+
// DM-545: fetch each cross-origin stylesheet text and parse `@font-face`
|
|
426
|
+
// rules server-side. Cross-origin sheets throw on `cssRules` access from
|
|
427
|
+
// the page context, so without this the page-side walker misses every
|
|
428
|
+
// CDN-hosted CSS @font-face declaration. Sites affected (verified): Stripe
|
|
429
|
+
// (b.stripecdn.com), and likely most marketing sites whose CSS is served
|
|
430
|
+
// from a different host than the page. The resource-fallback path that
|
|
431
|
+
// ran in this scenario registers under fontkit's internal `familyName`,
|
|
432
|
+
// which for license-protected fonts (Sohne) is a copyright string —
|
|
433
|
+
// unmatchable against the CSS-declared `font-family: sohne-var` query.
|
|
434
|
+
for (const sheetUrl of fromPage.crossOriginSheetUrls ?? []) {
|
|
435
|
+
let cssText;
|
|
436
|
+
try {
|
|
437
|
+
const resp = await page.context().request.get(sheetUrl);
|
|
438
|
+
if (!resp.ok())
|
|
439
|
+
continue;
|
|
440
|
+
cssText = await resp.text();
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
for (const face of parseFontFaceRulesFromCssText(cssText, sheetUrl)) {
|
|
446
|
+
// De-dupe by all URLs in the ranked list — we may have already seen
|
|
447
|
+
// this src via the resource-fallback (whose entry registers under the
|
|
448
|
+
// wrong name); having BOTH a font-face entry (correct name) AND a
|
|
449
|
+
// resource entry (file's internal name) is fine — both will be tried.
|
|
450
|
+
for (const u of face.urls ?? [face.url])
|
|
451
|
+
seen.add(u);
|
|
452
|
+
discovered.push(face);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
for (const url of observedFontUrls) {
|
|
456
|
+
if (seen.has(url))
|
|
457
|
+
continue;
|
|
458
|
+
seen.add(url);
|
|
459
|
+
discovered.push({ kind: "resource", url });
|
|
460
|
+
}
|
|
461
|
+
const report = [];
|
|
462
|
+
for (const item of discovered) {
|
|
463
|
+
if (item.kind === "local") {
|
|
464
|
+
// The page-side probe identified which local() candidate Chrome
|
|
465
|
+
// actually resolved the alias to (by comparing rendered widths). We
|
|
466
|
+
// route to that one specifically — NOT the first candidate we happen
|
|
467
|
+
// to recognize — because Chrome's local() lookup only matches a font's
|
|
468
|
+
// PostScript or full name, not its CSS family name (DM-445). Walking
|
|
469
|
+
// the candidate list with a family-name-based lookup table would
|
|
470
|
+
// mis-route e.g. `src: local("Menlo"), local("Monaco")` to Menlo when
|
|
471
|
+
// Chrome actually paints Monaco (Menlo's PostScript name is
|
|
472
|
+
// "Menlo-Regular", so the bare "Menlo" form doesn't match).
|
|
473
|
+
//
|
|
474
|
+
// If the probe didn't identify a match (none of the candidates
|
|
475
|
+
// measured the same width as the alias), we fall back to the legacy
|
|
476
|
+
// family-name lookup over the full candidate list — better than
|
|
477
|
+
// nothing, and preserves behavior for cases the probe can't resolve
|
|
478
|
+
// (e.g. an alias whose width happened to disagree with all candidates
|
|
479
|
+
// due to layout shaping that the simple sample didn't exercise).
|
|
480
|
+
const declaredWeight = parseWeightDescriptor(item.weight);
|
|
481
|
+
const declaredStyle = String(item.style ?? "normal").toLowerCase();
|
|
482
|
+
const declaredItalic = declaredStyle !== "" && declaredStyle !== "normal";
|
|
483
|
+
const resolved = item.resolvedLocalName;
|
|
484
|
+
const candidates = resolved != null ? [resolved] : item.localNames;
|
|
485
|
+
for (const localName of candidates) {
|
|
486
|
+
const key = systemFontKeyForLocalName(localName);
|
|
487
|
+
if (key != null) {
|
|
488
|
+
registerLocalFontAlias(item.family, key, declaredWeight, declaredItalic);
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
// DM-513: try each URL in the ranked list (highest-priority format first)
|
|
495
|
+
// until one fetches AND fontkit can parse the bytes. Falls through eot/svg-
|
|
496
|
+
// first cascades like Slashdot's sdicon font where the woff is the 3rd or
|
|
497
|
+
// 4th `url()` in `src:`.
|
|
498
|
+
const candidates = item.kind === "font-face" && Array.isArray(item.urls) && item.urls.length > 0
|
|
499
|
+
? item.urls
|
|
500
|
+
: [item.url];
|
|
501
|
+
let lastError;
|
|
502
|
+
let registered = false;
|
|
503
|
+
for (const candidateUrl of candidates) {
|
|
504
|
+
try {
|
|
505
|
+
const resp = await page.context().request.get(candidateUrl);
|
|
506
|
+
if (!resp.ok()) {
|
|
507
|
+
lastError = `HTTP ${resp.status()}`;
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
const fetched = Buffer.from(await resp.body());
|
|
511
|
+
const buf = await ensureNonWoff2(fetched);
|
|
512
|
+
if (item.kind === "font-face") {
|
|
513
|
+
// Verify fontkit can actually parse the bytes before registering;
|
|
514
|
+
// otherwise the registry holds an unusable entry and `pickWebfontVariant`
|
|
515
|
+
// returns it without scoring against later candidates.
|
|
516
|
+
const meta = await readFontMetadata(buf);
|
|
517
|
+
if (meta == null) {
|
|
518
|
+
lastError = "fontkit could not parse";
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
const weightNum = parseWeightDescriptor(item.weight);
|
|
522
|
+
registerWebfont(item.family, weightNum, item.style, buf, item.unicodeRange);
|
|
523
|
+
report.push({ family: item.family, weight: weightNum, style: item.style, url: candidateUrl, source: "font-face", ok: true });
|
|
524
|
+
}
|
|
525
|
+
else {
|
|
526
|
+
const meta = await readFontMetadata(buf);
|
|
527
|
+
if (meta == null) {
|
|
528
|
+
lastError = "fontkit could not parse";
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
registerWebfont(meta.family, meta.weight, meta.italic ? "italic" : "normal", buf);
|
|
532
|
+
report.push({ family: meta.family, weight: meta.weight, style: meta.italic ? "italic" : "normal", url: candidateUrl, source: "resource", ok: true });
|
|
533
|
+
}
|
|
534
|
+
registered = true;
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
537
|
+
catch (e) {
|
|
538
|
+
lastError = e instanceof Error ? e.message : String(e);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
if (!registered) {
|
|
542
|
+
report.push({ family: item.kind === "font-face" ? item.family : "", weight: item.kind === "font-face" ? parseWeightDescriptor(item.weight) : 400, style: item.kind === "font-face" ? item.style : "normal", url: item.url, source: item.kind, ok: false, error: lastError ?? "no candidates" });
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return report;
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Resolve a CSS `local("Name")` argument to a `resolveFontKey`-style key
|
|
549
|
+
* that matches our on-disk FONT_PATHS table. Returns null when the local
|
|
550
|
+
* name isn't a system font we know about (so the caller walks to the next
|
|
551
|
+
* `local()` in the @font-face's src list). DM-303.
|
|
552
|
+
*
|
|
553
|
+
* The names we recognize are limited to the families we actually ship paths
|
|
554
|
+
* for in `text-to-path.ts` — Georgia / Menlo / Monaco / Courier / Times /
|
|
555
|
+
* Helvetica / Arial. For unknown names we punt; the rest of the font-family
|
|
556
|
+
* chain (e.g. `, serif`) will catch the gap.
|
|
557
|
+
*/
|
|
558
|
+
function systemFontKeyForLocalName(localName) {
|
|
559
|
+
const n = localName.toLowerCase().replace(/^["']|["']$/g, "").trim();
|
|
560
|
+
// Strip a trailing weight/style suffix so "Georgia Bold" / "Georgia Italic"
|
|
561
|
+
// / "Georgia Bold Italic" all collapse to "georgia" — `getFontInstance`
|
|
562
|
+
// dispatches to the right sibling file based on the requested CSS weight
|
|
563
|
+
// and style, so the alias just needs to point at the family.
|
|
564
|
+
const base = n.replace(/\s+(bold|italic|oblique|regular|light|medium|semibold|black)\b/g, "").trim();
|
|
565
|
+
if (base === "georgia")
|
|
566
|
+
return "georgia";
|
|
567
|
+
if (base === "menlo")
|
|
568
|
+
return "menlo";
|
|
569
|
+
if (base === "monaco")
|
|
570
|
+
return "monaco";
|
|
571
|
+
if (base === "courier" || base === "courier new")
|
|
572
|
+
return "courier";
|
|
573
|
+
if (base === "times new roman")
|
|
574
|
+
return "times-new-roman";
|
|
575
|
+
if (base === "times")
|
|
576
|
+
return "times";
|
|
577
|
+
if (base === "helvetica" || base === "helvetica neue")
|
|
578
|
+
return "helvetica";
|
|
579
|
+
if (base === "arial")
|
|
580
|
+
return "arial";
|
|
581
|
+
if (base === "sf pro" || base === "sf pro text" || base === "sf pro display")
|
|
582
|
+
return "sf-pro";
|
|
583
|
+
if (base === "sf mono" || base === "sfmono-regular")
|
|
584
|
+
return "sf-mono";
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
async function readFontMetadata(buf) {
|
|
588
|
+
const fontkit = await import("fontkit");
|
|
589
|
+
try {
|
|
590
|
+
const f = fontkit.create(buf);
|
|
591
|
+
const family = f.familyName ?? "";
|
|
592
|
+
if (family === "")
|
|
593
|
+
return null;
|
|
594
|
+
const weight = (f["OS/2"]?.usWeightClass) ?? 400;
|
|
595
|
+
const italic = !!(f["OS/2"]?.fsSelection?.italic);
|
|
596
|
+
return { family, weight, italic };
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* If `buf` is WOFF2 (magic `wOF2`), decompress it to plain TTF bytes via
|
|
604
|
+
* `wawoff2`. Otherwise return the buffer untouched.
|
|
605
|
+
*
|
|
606
|
+
* Why we need this: fontkit *parses* WOFF2 fine, but `getVariation()` on a
|
|
607
|
+
* WOFF2 font returns an instance whose internal stream can't read the
|
|
608
|
+
* parent's tables (`unitsPerEm` / `layout()` throw). Most webfonts in the
|
|
609
|
+
* wild are WOFF2, so without this step variable-axis support (DM-228 / 229)
|
|
610
|
+
* would silently degrade to the registered base instance for every weight.
|
|
611
|
+
*
|
|
612
|
+
* Decompressing to TTF before fontkit.create produces a font whose variation
|
|
613
|
+
* results retain access to all tables — same as a TTF loaded from disk.
|
|
614
|
+
*/
|
|
615
|
+
async function ensureNonWoff2(buf) {
|
|
616
|
+
if (buf.length < 4)
|
|
617
|
+
return buf;
|
|
618
|
+
const isWoff2 = buf[0] === 0x77 && buf[1] === 0x4F && buf[2] === 0x46 && buf[3] === 0x32;
|
|
619
|
+
if (!isWoff2)
|
|
620
|
+
return buf;
|
|
621
|
+
try {
|
|
622
|
+
// wawoff2 ships no .d.ts; the runtime export is `{ compress, decompress }`.
|
|
623
|
+
const wawoff = await import("wawoff2");
|
|
624
|
+
const ttf = await wawoff.decompress(new Uint8Array(buf));
|
|
625
|
+
return Buffer.from(ttf);
|
|
626
|
+
}
|
|
627
|
+
catch {
|
|
628
|
+
return buf; // fall through: register the WOFF2 anyway, variations just won't work
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* DM-545: parse `@font-face` rules out of a raw CSS text fetched server-side.
|
|
633
|
+
* Used for cross-origin stylesheets that the page-side `cssRules` walker can't
|
|
634
|
+
* read. Tolerant scanner — handles top-level `@font-face` and rules nested
|
|
635
|
+
* inside any number of `@media` / `@supports` / `@layer` / `@container`
|
|
636
|
+
* blocks (recurses through balanced braces). Returns the same FaceRule shape
|
|
637
|
+
* the page-side walker emits so the downstream registration loop is uniform.
|
|
638
|
+
*
|
|
639
|
+
* Not a full CSS parser — comment-stripping handles `/* … */`, but exotic
|
|
640
|
+
* inputs (custom properties holding @font-face strings, CSSOM-injected rules
|
|
641
|
+
* that never serialised back to text) aren't covered. Adequate for the
|
|
642
|
+
* mainstream marketing-site case that motivated the change.
|
|
643
|
+
*/
|
|
644
|
+
export function parseFontFaceRulesFromCssText(cssText, baseUrl) {
|
|
645
|
+
const stripped = cssText.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
646
|
+
const out = [];
|
|
647
|
+
const re = /@font-face\s*\{/gi;
|
|
648
|
+
let m;
|
|
649
|
+
while ((m = re.exec(stripped)) !== null) {
|
|
650
|
+
const start = m.index + m[0].length;
|
|
651
|
+
let depth = 1;
|
|
652
|
+
let i = start;
|
|
653
|
+
while (i < stripped.length && depth > 0) {
|
|
654
|
+
const c = stripped[i];
|
|
655
|
+
if (c === "{")
|
|
656
|
+
depth++;
|
|
657
|
+
else if (c === "}")
|
|
658
|
+
depth--;
|
|
659
|
+
i++;
|
|
660
|
+
}
|
|
661
|
+
if (depth !== 0)
|
|
662
|
+
break;
|
|
663
|
+
const body = stripped.substring(start, i - 1);
|
|
664
|
+
const rule = parseFontFaceBody(body, baseUrl);
|
|
665
|
+
if (rule != null)
|
|
666
|
+
out.push(rule);
|
|
667
|
+
}
|
|
668
|
+
return out;
|
|
669
|
+
}
|
|
670
|
+
function parseFontFaceBody(body, baseUrl) {
|
|
671
|
+
const familyMatch = /font-family\s*:\s*([^;}]+)/i.exec(body);
|
|
672
|
+
if (familyMatch == null)
|
|
673
|
+
return null;
|
|
674
|
+
const family = familyMatch[1].trim().replace(/^["']|["']$/g, "").trim();
|
|
675
|
+
if (family === "")
|
|
676
|
+
return null;
|
|
677
|
+
const weight = (/font-weight\s*:\s*([^;}]+)/i.exec(body)?.[1].trim()) ?? "400";
|
|
678
|
+
const style = (/font-style\s*:\s*([^;}]+)/i.exec(body)?.[1].trim()) ?? "normal";
|
|
679
|
+
const urMatch = /unicode-range\s*:\s*([^;}]+)/i.exec(body);
|
|
680
|
+
const unicodeRange = urMatch != null ? parseUnicodeRangeDescriptor(urMatch[1]) : undefined;
|
|
681
|
+
const srcMatch = /src\s*:\s*([\s\S]+?)(?:;|$)/i.exec(body);
|
|
682
|
+
if (srcMatch == null)
|
|
683
|
+
return null;
|
|
684
|
+
const src = srcMatch[1];
|
|
685
|
+
const srcEntries = [];
|
|
686
|
+
const urlRe = /url\(\s*["']?([^"')]+)["']?\s*\)(?:\s*format\(\s*["']?([^"')]+)["']?\s*\))?/g;
|
|
687
|
+
let um;
|
|
688
|
+
while ((um = urlRe.exec(src)) !== null) {
|
|
689
|
+
srcEntries.push({ url: um[1], format: (um[2] ?? "").toLowerCase() });
|
|
690
|
+
}
|
|
691
|
+
if (srcEntries.length === 0)
|
|
692
|
+
return null;
|
|
693
|
+
// Same ranking as the page-side walker (woff2 > woff > ttf/otf, skip
|
|
694
|
+
// eot/svg). Keeping the two ranks identical means the Node-side iterator
|
|
695
|
+
// tries URLs in the same order whether they came from the page-side or
|
|
696
|
+
// server-side path.
|
|
697
|
+
const formatRank = (fmt, url) => {
|
|
698
|
+
const lower = fmt.toLowerCase();
|
|
699
|
+
if (/embedded-opentype|svg/.test(lower))
|
|
700
|
+
return -1;
|
|
701
|
+
if (lower.includes("woff2") || /\.woff2(\?|$)/i.test(url))
|
|
702
|
+
return 4;
|
|
703
|
+
if (lower === "woff" || /\.woff(\?|$)/i.test(url))
|
|
704
|
+
return 3;
|
|
705
|
+
if (/truetype|opentype/.test(lower) || /\.(ttf|otf)(\?|$)/i.test(url))
|
|
706
|
+
return 2;
|
|
707
|
+
if (/\.eot(\?|$)/i.test(url) || /\.svg(\?|$)/i.test(url))
|
|
708
|
+
return -1;
|
|
709
|
+
return 1;
|
|
710
|
+
};
|
|
711
|
+
const ranked = srcEntries
|
|
712
|
+
.map((e) => ({ url: e.url, rank: formatRank(e.format, e.url) }))
|
|
713
|
+
.filter((e) => e.rank >= 0)
|
|
714
|
+
.sort((a, b) => b.rank - a.rank)
|
|
715
|
+
.map((e) => e.url);
|
|
716
|
+
if (ranked.length === 0)
|
|
717
|
+
return null;
|
|
718
|
+
const absUrls = [];
|
|
719
|
+
for (const u of ranked) {
|
|
720
|
+
try {
|
|
721
|
+
absUrls.push(new URL(u, baseUrl).href);
|
|
722
|
+
}
|
|
723
|
+
catch { /* skip */ }
|
|
724
|
+
}
|
|
725
|
+
if (absUrls.length === 0)
|
|
726
|
+
return null;
|
|
727
|
+
return { kind: "font-face", family, weight, style, url: absUrls[0], urls: absUrls, unicodeRange };
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Parse a CSS `unicode-range` descriptor value (CSS Fonts 4 §4.5) into a list
|
|
731
|
+
* of inclusive `[from, to]` codepoint intervals. Accepts the three forms:
|
|
732
|
+
* single (`U+26`), interval (`U+0-7F`), and wildcard (`U+4??`). Multiple
|
|
733
|
+
* ranges are comma-separated. Returns `undefined` for empty / unparseable
|
|
734
|
+
* input — callers treat that as the CSS default coverage (U+0..U+10FFFF).
|
|
735
|
+
*
|
|
736
|
+
* This is the Node-side twin of the in-page parser inlined inside
|
|
737
|
+
* `discoverAndRegisterWebfonts`'s `page.evaluate` body. The page-side copy
|
|
738
|
+
* can't import from here (it runs in the browser context), but the logic must
|
|
739
|
+
* stay aligned — covered by tests on this exported version.
|
|
740
|
+
*/
|
|
741
|
+
export function parseUnicodeRangeDescriptor(value) {
|
|
742
|
+
const v = value.trim();
|
|
743
|
+
if (v === "")
|
|
744
|
+
return undefined;
|
|
745
|
+
const out = [];
|
|
746
|
+
for (const raw of v.split(",")) {
|
|
747
|
+
const tok = raw.trim().replace(/^U\+/i, "");
|
|
748
|
+
if (tok === "")
|
|
749
|
+
continue;
|
|
750
|
+
if (tok.includes("?")) {
|
|
751
|
+
const lo = parseInt(tok.replace(/\?/g, "0"), 16);
|
|
752
|
+
const hi = parseInt(tok.replace(/\?/g, "F"), 16);
|
|
753
|
+
if (Number.isFinite(lo) && Number.isFinite(hi))
|
|
754
|
+
out.push([lo, hi]);
|
|
755
|
+
}
|
|
756
|
+
else if (tok.includes("-")) {
|
|
757
|
+
const [a, b] = tok.split("-");
|
|
758
|
+
const lo = parseInt(a, 16);
|
|
759
|
+
const hi = parseInt(b, 16);
|
|
760
|
+
if (Number.isFinite(lo) && Number.isFinite(hi))
|
|
761
|
+
out.push([lo, hi]);
|
|
762
|
+
}
|
|
763
|
+
else {
|
|
764
|
+
const cp = parseInt(tok, 16);
|
|
765
|
+
if (Number.isFinite(cp))
|
|
766
|
+
out.push([cp, cp]);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
return out.length > 0 ? out : undefined;
|
|
770
|
+
}
|
|
771
|
+
function parseWeightDescriptor(value) {
|
|
772
|
+
// CSS keywords and numeric, including weight ranges like "100 900".
|
|
773
|
+
const v = value.trim().toLowerCase();
|
|
774
|
+
if (v === "normal")
|
|
775
|
+
return 400;
|
|
776
|
+
if (v === "bold")
|
|
777
|
+
return 700;
|
|
778
|
+
// Range form: take the first number.
|
|
779
|
+
const m = /-?\d+/.exec(v);
|
|
780
|
+
if (m != null) {
|
|
781
|
+
const n = parseInt(m[0], 10);
|
|
782
|
+
if (Number.isFinite(n) && n >= 1 && n <= 1000)
|
|
783
|
+
return n;
|
|
784
|
+
}
|
|
785
|
+
return 400;
|
|
786
|
+
}
|