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.
Files changed (119) hide show
  1. package/FEATURES.md +102 -0
  2. package/LICENSE +21 -0
  3. package/README.md +66 -0
  4. package/dist/animator.d.ts +158 -0
  5. package/dist/animator.js +424 -0
  6. package/dist/animator.test.d.ts +5 -0
  7. package/dist/animator.test.js +169 -0
  8. package/dist/border-radius.test.d.ts +1 -0
  9. package/dist/border-radius.test.js +148 -0
  10. package/dist/capture.d.ts +193 -0
  11. package/dist/capture.js +786 -0
  12. package/dist/chrome.d.ts +45 -0
  13. package/dist/chrome.js +107 -0
  14. package/dist/cli.d.ts +16 -0
  15. package/dist/cli.js +512 -0
  16. package/dist/client/dom.d.ts +10 -0
  17. package/dist/client/dom.js +17 -0
  18. package/dist/conic-raster.d.ts +58 -0
  19. package/dist/conic-raster.js +292 -0
  20. package/dist/conic-raster.test.d.ts +1 -0
  21. package/dist/conic-raster.test.js +187 -0
  22. package/dist/coretext-extractor.test.d.ts +1 -0
  23. package/dist/coretext-extractor.test.js +94 -0
  24. package/dist/coretext-helper.d.ts +60 -0
  25. package/dist/coretext-helper.js +205 -0
  26. package/dist/cross-origin-font-face.test.d.ts +1 -0
  27. package/dist/cross-origin-font-face.test.js +107 -0
  28. package/dist/cursor-overlay.d.ts +123 -0
  29. package/dist/cursor-overlay.js +207 -0
  30. package/dist/cursor-overlay.test.d.ts +1 -0
  31. package/dist/cursor-overlay.test.js +88 -0
  32. package/dist/dark-mode-capture.test.d.ts +1 -0
  33. package/dist/dark-mode-capture.test.js +158 -0
  34. package/dist/dark-mode-form-controls.test.d.ts +1 -0
  35. package/dist/dark-mode-form-controls.test.js +218 -0
  36. package/dist/dom-to-svg.d.ts +1016 -0
  37. package/dist/dom-to-svg.js +7717 -0
  38. package/dist/embed-remote-images.test.d.ts +1 -0
  39. package/dist/embed-remote-images.test.js +424 -0
  40. package/dist/form-controls.d.ts +70 -0
  41. package/dist/form-controls.js +1151 -0
  42. package/dist/frame-merge.d.ts +95 -0
  43. package/dist/frame-merge.js +374 -0
  44. package/dist/frame-merge.test.d.ts +6 -0
  45. package/dist/frame-merge.test.js +144 -0
  46. package/dist/gradients.d.ts +184 -0
  47. package/dist/gradients.js +937 -0
  48. package/dist/gradients.test.d.ts +1 -0
  49. package/dist/gradients.test.js +150 -0
  50. package/dist/index.d.ts +12 -0
  51. package/dist/index.js +7 -0
  52. package/dist/jsx-runtime.d.ts +27 -0
  53. package/dist/jsx-runtime.js +96 -0
  54. package/dist/jsx-runtime.test.d.ts +1 -0
  55. package/dist/jsx-runtime.test.js +41 -0
  56. package/dist/kerfjs-imports.test.d.ts +1 -0
  57. package/dist/kerfjs-imports.test.js +36 -0
  58. package/dist/mask.test.d.ts +1 -0
  59. package/dist/mask.test.js +206 -0
  60. package/dist/optimize.d.ts +12 -0
  61. package/dist/optimize.js +32 -0
  62. package/dist/preserve-aspect-ratio.test.d.ts +1 -0
  63. package/dist/preserve-aspect-ratio.test.js +38 -0
  64. package/dist/resize-embedded-images.d.ts +33 -0
  65. package/dist/resize-embedded-images.js +164 -0
  66. package/dist/resize-embedded-images.test.d.ts +9 -0
  67. package/dist/resize-embedded-images.test.js +255 -0
  68. package/dist/stacking-context.test.d.ts +1 -0
  69. package/dist/stacking-context.test.js +927 -0
  70. package/dist/text-renderer.d.ts +42 -0
  71. package/dist/text-renderer.js +608 -0
  72. package/dist/text-renderer.test.d.ts +1 -0
  73. package/dist/text-renderer.test.js +150 -0
  74. package/dist/text-to-path.d.ts +265 -0
  75. package/dist/text-to-path.js +1800 -0
  76. package/dist/text-to-path.test.d.ts +1 -0
  77. package/dist/text-to-path.test.js +570 -0
  78. package/dist/utils/escapeHtml.d.ts +2 -0
  79. package/dist/utils/escapeHtml.js +15 -0
  80. package/dist/webfont-unicode-range.test.d.ts +1 -0
  81. package/dist/webfont-unicode-range.test.js +174 -0
  82. package/package.json +55 -0
  83. package/src/animator.test.ts +179 -0
  84. package/src/animator.ts +660 -0
  85. package/src/border-radius.test.ts +160 -0
  86. package/src/capture.ts +810 -0
  87. package/src/cli.ts +582 -0
  88. package/src/conic-raster.test.ts +213 -0
  89. package/src/conic-raster.ts +309 -0
  90. package/src/coretext-extractor.test.ts +130 -0
  91. package/src/coretext-helper.ts +256 -0
  92. package/src/cross-origin-font-face.test.ts +119 -0
  93. package/src/cursor-overlay.test.ts +95 -0
  94. package/src/cursor-overlay.ts +297 -0
  95. package/src/dark-mode-capture.test.ts +177 -0
  96. package/src/dark-mode-form-controls.test.ts +228 -0
  97. package/src/dom-to-svg.ts +8376 -0
  98. package/src/embed-remote-images.test.ts +461 -0
  99. package/src/form-controls.ts +1174 -0
  100. package/src/frame-merge.test.ts +157 -0
  101. package/src/frame-merge.ts +447 -0
  102. package/src/globals.d.ts +2 -0
  103. package/src/gradients.test.ts +175 -0
  104. package/src/gradients.ts +955 -0
  105. package/src/index.ts +12 -0
  106. package/src/kerf-jsx-augmentation.d.ts +36 -0
  107. package/src/kerfjs-imports.test.tsx +45 -0
  108. package/src/mask.test.ts +274 -0
  109. package/src/optimize.ts +34 -0
  110. package/src/preserve-aspect-ratio.test.ts +49 -0
  111. package/src/resize-embedded-images.test.ts +292 -0
  112. package/src/resize-embedded-images.ts +180 -0
  113. package/src/stacking-context.test.ts +967 -0
  114. package/src/text-renderer.test.ts +162 -0
  115. package/src/text-renderer.ts +623 -0
  116. package/src/text-to-path.test.ts +639 -0
  117. package/src/text-to-path.ts +1810 -0
  118. package/src/utils/escapeHtml.ts +16 -0
  119. package/src/webfont-unicode-range.test.ts +207 -0
@@ -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
+ }