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,256 @@
1
+ /**
2
+ * Native macOS glyph-outline extraction via the CoreText helper binary
3
+ * (`tools/macos-glyph-extractor`). DM-385 / DM-388.
4
+ *
5
+ * Used as the path-extraction backend for fonts whose outlines fontkit
6
+ * can't read — primarily PingFang, whose outlines live in the proprietary
7
+ * Apple `hvgl` table. The helper opens the font through CoreText (which
8
+ * understands `hvgl`) and returns SVG path data we can drop into the same
9
+ * `<defs>`/`<use>` pipeline as fontkit-extracted glyphs.
10
+ *
11
+ * The wrapper exposes a fontkit-compatible subset of the `Font` API (the
12
+ * fields `text-to-path.ts` reads): `unitsPerEm`, ascent/descent, underline /
13
+ * strikeout metrics, `glyphForCodePoint`, `getGlyph`, and `layout`. The
14
+ * renderer treats it interchangeably with a fontkit Font.
15
+ */
16
+
17
+ import { spawnSync } from "node:child_process";
18
+ import { existsSync } from "node:fs";
19
+ import path from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
23
+ const HELPER_PATH = process.env.DOMOTION_HELPER_PATH
24
+ ?? path.resolve(HERE, "..", "tools", "macos-glyph-extractor", "domotion-glyph-paths");
25
+
26
+ let helperAvailable: boolean | null = null;
27
+ export function isCoretextHelperAvailable(): boolean {
28
+ if (helperAvailable != null) return helperAvailable;
29
+ if (process.platform !== "darwin") { helperAvailable = false; return false; }
30
+ if (process.env.DOMOTION_DISABLE_HELPER) { helperAvailable = false; return false; }
31
+ helperAvailable = existsSync(HELPER_PATH);
32
+ return helperAvailable;
33
+ }
34
+
35
+ interface PathCommand { command: string; args: number[] }
36
+
37
+ interface CoretextGlyph {
38
+ id: number;
39
+ advanceWidth: number;
40
+ path: { commands: PathCommand[] };
41
+ codePoints?: number[];
42
+ }
43
+
44
+ interface MetaResponse {
45
+ unitsPerEm: number;
46
+ ascent: number;
47
+ descent: number;
48
+ underlinePosition?: number;
49
+ underlineThickness?: number;
50
+ strikeoutPosition?: number;
51
+ strikeoutThickness?: number;
52
+ }
53
+
54
+ interface GlyphResponse {
55
+ id: number;
56
+ advance: number;
57
+ bbox: { x: number; y: number; w: number; h: number };
58
+ d: string;
59
+ }
60
+
61
+ // Parse the Swift helper's SVG path-data string into fontkit's command-array
62
+ // format. The helper emits exactly: `M x y`, `L x y`, `Q cx cy x y`,
63
+ // `C c1x c1y c2x c2y x y`, `Z` — space-separated, no relative variants.
64
+ function parseSvgPath(d: string): PathCommand[] {
65
+ if (d.length === 0) return [];
66
+ const tokens = d.match(/[MLQCZ]|-?\d+(?:\.\d+)?/g) ?? [];
67
+ const out: PathCommand[] = [];
68
+ let i = 0;
69
+ const num = () => Number(tokens[i++]);
70
+ while (i < tokens.length) {
71
+ const t = tokens[i++];
72
+ switch (t) {
73
+ case "M": out.push({ command: "moveTo", args: [num(), num()] }); break;
74
+ case "L": out.push({ command: "lineTo", args: [num(), num()] }); break;
75
+ case "Q": out.push({ command: "quadraticCurveTo", args: [num(), num(), num(), num()] }); break;
76
+ case "C": out.push({ command: "bezierCurveTo", args: [num(), num(), num(), num(), num(), num()] }); break;
77
+ case "Z": out.push({ command: "closePath", args: [] }); break;
78
+ }
79
+ }
80
+ return out;
81
+ }
82
+
83
+ // Spawn the helper once, request meta + a batch of glyphs in one envelope.
84
+ interface HelperRequest {
85
+ fonts: Array<{ ref: string; postscriptName?: string; fontPath?: string; size: number }>;
86
+ queries: Array<
87
+ | { type: "meta"; fontRef: string }
88
+ | { type: "glyphs"; fontRef: string; glyphs: Array<{ cp?: number; id?: number }> }
89
+ >;
90
+ }
91
+ interface HelperResponse {
92
+ results: Array<MetaResponse & { type: "meta" } | { type: "glyphs"; glyphs: GlyphResponse[] }>;
93
+ }
94
+
95
+ function callHelper(request: HelperRequest): HelperResponse {
96
+ const proc = spawnSync(HELPER_PATH, [], {
97
+ input: JSON.stringify(request),
98
+ encoding: "utf-8",
99
+ maxBuffer: 64 * 1024 * 1024
100
+ });
101
+ if (proc.status !== 0) {
102
+ throw new Error(`coretext helper failed (exit ${proc.status}): ${proc.stderr}`);
103
+ }
104
+ return JSON.parse(proc.stdout);
105
+ }
106
+
107
+ export interface CoretextFontInstance {
108
+ unitsPerEm: number;
109
+ ascent: number;
110
+ descent: number;
111
+ underlinePosition: number;
112
+ underlineThickness: number;
113
+ "OS/2"?: { yStrikeoutPosition?: number; yStrikeoutSize?: number };
114
+ availableFeatures?: string[];
115
+ glyphForCodePoint(cp: number): CoretextGlyph;
116
+ getGlyph(id: number): CoretextGlyph;
117
+ layout(text: string, features?: string[]): {
118
+ glyphs: CoretextGlyph[];
119
+ positions: Array<{ xAdvance: number; yAdvance: number; xOffset: number; yOffset: number }>;
120
+ };
121
+ }
122
+
123
+ export function createCoretextFont(spec: {
124
+ postscriptName?: string;
125
+ fontPath?: string;
126
+ }): CoretextFontInstance | null {
127
+ if (!isCoretextHelperAvailable()) return null;
128
+
129
+ // Open at size=1000 first so we can read unitsPerEm. Then re-open at
130
+ // size=unitsPerEm so all glyph paths come back in design-unit space — this
131
+ // matches fontkit's coordinate convention so the existing
132
+ // `scale(fontSize/unitsPerEm, ...)` transform in text-to-path.ts works.
133
+ let metaResp: MetaResponse;
134
+ try {
135
+ const probe = callHelper({
136
+ fonts: [{ ref: "f", postscriptName: spec.postscriptName, fontPath: spec.fontPath, size: 1000 }],
137
+ queries: [{ type: "meta", fontRef: "f" }]
138
+ });
139
+ const r = probe.results[0];
140
+ if (r.type !== "meta") throw new Error("unexpected response shape");
141
+ metaResp = r;
142
+ } catch {
143
+ return null;
144
+ }
145
+
146
+ const unitsPerEm = metaResp.unitsPerEm;
147
+ const renderSize = unitsPerEm;
148
+
149
+ // Per-(cp, id) caches — each glyph is fetched at most once per Node process.
150
+ const cpToGlyph = new Map<number, CoretextGlyph>();
151
+ const idToGlyph = new Map<number, CoretextGlyph>();
152
+ const missingCp = new Set<number>();
153
+
154
+ function fetchByCps(cps: number[]): void {
155
+ const need = cps.filter((cp) => !cpToGlyph.has(cp) && !missingCp.has(cp));
156
+ if (need.length === 0) return;
157
+ const resp = callHelper({
158
+ fonts: [{ ref: "f", postscriptName: spec.postscriptName, fontPath: spec.fontPath, size: renderSize }],
159
+ queries: [{ type: "glyphs", fontRef: "f", glyphs: need.map((cp) => ({ cp })) }]
160
+ });
161
+ const r = resp.results[0];
162
+ if (r.type !== "glyphs") return;
163
+ for (let i = 0; i < need.length; i++) {
164
+ const cp = need[i];
165
+ const g = r.glyphs[i];
166
+ if (g == null || g.id === 0) {
167
+ missingCp.add(cp);
168
+ continue;
169
+ }
170
+ const glyph: CoretextGlyph = {
171
+ id: g.id,
172
+ advanceWidth: g.advance,
173
+ path: { commands: parseSvgPath(g.d) },
174
+ codePoints: [cp]
175
+ };
176
+ cpToGlyph.set(cp, glyph);
177
+ idToGlyph.set(g.id, glyph);
178
+ }
179
+ }
180
+
181
+ function fetchById(id: number): CoretextGlyph {
182
+ const cached = idToGlyph.get(id);
183
+ if (cached != null) return cached;
184
+ const resp = callHelper({
185
+ fonts: [{ ref: "f", postscriptName: spec.postscriptName, fontPath: spec.fontPath, size: renderSize }],
186
+ queries: [{ type: "glyphs", fontRef: "f", glyphs: [{ id }] }]
187
+ });
188
+ const r = resp.results[0];
189
+ if (r.type !== "glyphs") {
190
+ const empty: CoretextGlyph = { id, advanceWidth: 0, path: { commands: [] } };
191
+ idToGlyph.set(id, empty);
192
+ return empty;
193
+ }
194
+ const g = r.glyphs[0];
195
+ const glyph: CoretextGlyph = {
196
+ id: g.id,
197
+ advanceWidth: g.advance,
198
+ path: { commands: parseSvgPath(g.d) }
199
+ };
200
+ idToGlyph.set(id, glyph);
201
+ return glyph;
202
+ }
203
+
204
+ function notdef(id = 0): CoretextGlyph {
205
+ return { id, advanceWidth: 0, path: { commands: [] } };
206
+ }
207
+
208
+ return {
209
+ unitsPerEm,
210
+ ascent: metaResp.ascent ?? 0,
211
+ descent: metaResp.descent ?? 0,
212
+ underlinePosition: metaResp.underlinePosition ?? 0,
213
+ underlineThickness: metaResp.underlineThickness ?? 0,
214
+ "OS/2": {
215
+ yStrikeoutPosition: metaResp.strikeoutPosition,
216
+ yStrikeoutSize: metaResp.strikeoutThickness
217
+ },
218
+ availableFeatures: [],
219
+
220
+ glyphForCodePoint(cp: number): CoretextGlyph {
221
+ if (missingCp.has(cp)) return notdef(0);
222
+ if (!cpToGlyph.has(cp)) fetchByCps([cp]);
223
+ return cpToGlyph.get(cp) ?? notdef(0);
224
+ },
225
+
226
+ getGlyph(id: number): CoretextGlyph {
227
+ return fetchById(id);
228
+ },
229
+
230
+ layout(text: string): {
231
+ glyphs: CoretextGlyph[];
232
+ positions: Array<{ xAdvance: number; yAdvance: number; xOffset: number; yOffset: number }>;
233
+ } {
234
+ // Batch every codepoint in one helper call before assembling the result.
235
+ const cps: number[] = [];
236
+ for (const ch of text) cps.push(ch.codePointAt(0)!);
237
+ fetchByCps(cps);
238
+
239
+ const glyphs: CoretextGlyph[] = [];
240
+ const positions: Array<{ xAdvance: number; yAdvance: number; xOffset: number; yOffset: number }> = [];
241
+ for (const cp of cps) {
242
+ const g = cpToGlyph.get(cp) ?? notdef(0);
243
+ glyphs.push(g);
244
+ positions.push({ xAdvance: g.advanceWidth, yAdvance: 0, xOffset: 0, yOffset: 0 });
245
+ }
246
+ return { glyphs, positions };
247
+ }
248
+ };
249
+ }
250
+
251
+ /** Drop the in-memory glyph-resolution caches. Currently a no-op since each
252
+ * `createCoretextFont` returns its own closure-bound cache, but exposed for
253
+ * parity with `clearWebfonts` / `clearGlyphDefs`. */
254
+ export function clearCoretextCache(): void {
255
+ helperAvailable = null;
256
+ }
@@ -0,0 +1,119 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { parseFontFaceRulesFromCssText } from "./capture.js";
3
+
4
+ // DM-545: cross-origin stylesheets throw on `cssRules` access from the page
5
+ // context, so we fetch them server-side and parse `@font-face` rules with
6
+ // this helper. Sites affected (verified): Stripe (b.stripecdn.com), and
7
+ // likely most marketing sites whose CSS is served from a different host
8
+ // than the page.
9
+
10
+ describe("parseFontFaceRulesFromCssText", () => {
11
+ const BASE = "https://cdn.example.com/css/site.css";
12
+
13
+ it("returns empty list for CSS with no @font-face rules", () => {
14
+ expect(parseFontFaceRulesFromCssText("body { color: red; }", BASE)).toEqual([]);
15
+ });
16
+
17
+ it("parses a single top-level @font-face rule with a single src url()", () => {
18
+ const css = `@font-face { font-family: "MyFont"; src: url("/fonts/myfont.woff2"); }`;
19
+ const out = parseFontFaceRulesFromCssText(css, BASE);
20
+ expect(out).toHaveLength(1);
21
+ expect(out[0].family).toBe("MyFont");
22
+ expect(out[0].weight).toBe("400"); // default
23
+ expect(out[0].style).toBe("normal"); // default
24
+ expect(out[0].url).toBe("https://cdn.example.com/fonts/myfont.woff2");
25
+ expect(out[0].urls).toEqual(["https://cdn.example.com/fonts/myfont.woff2"]);
26
+ });
27
+
28
+ it("parses font-weight, font-style, and unicode-range descriptors", () => {
29
+ const css = `
30
+ @font-face {
31
+ font-family: "Geist";
32
+ font-weight: 400;
33
+ font-style: italic;
34
+ src: url("/g.woff2") format("woff2");
35
+ unicode-range: U+0000-00FF, U+0131;
36
+ }`;
37
+ const out = parseFontFaceRulesFromCssText(css, BASE);
38
+ expect(out).toHaveLength(1);
39
+ expect(out[0].family).toBe("Geist");
40
+ expect(out[0].weight).toBe("400");
41
+ expect(out[0].style).toBe("italic");
42
+ expect(out[0].unicodeRange).toEqual([[0x0000, 0x00ff], [0x0131, 0x0131]]);
43
+ });
44
+
45
+ it("ranks src urls woff2 > woff > ttf/otf, skipping eot/svg", () => {
46
+ const css = `@font-face {
47
+ font-family: "sdicon";
48
+ src: url("/sdicon.eot"),
49
+ url("/sdicon.eot?#iefix") format("embedded-opentype"),
50
+ url("/sdicon.woff") format("woff"),
51
+ url("/sdicon.ttf") format("truetype"),
52
+ url("/sdicon.svg#sdicon") format("svg");
53
+ }`;
54
+ const out = parseFontFaceRulesFromCssText(css, BASE);
55
+ expect(out).toHaveLength(1);
56
+ // woff first (woff2 not present), then ttf. eot/svg dropped.
57
+ expect(out[0].urls).toEqual([
58
+ "https://cdn.example.com/sdicon.woff",
59
+ "https://cdn.example.com/sdicon.ttf",
60
+ ]);
61
+ });
62
+
63
+ it("recurses into @media-nested @font-face (Stripe pattern)", () => {
64
+ const css = `
65
+ @media (min-width: 600px) {
66
+ @font-face {
67
+ font-family: sohne-var;
68
+ src: url(/sohne.woff2) format("woff2-variations");
69
+ font-weight: 1 1000;
70
+ }
71
+ }`;
72
+ const out = parseFontFaceRulesFromCssText(css, BASE);
73
+ expect(out).toHaveLength(1);
74
+ expect(out[0].family).toBe("sohne-var");
75
+ expect(out[0].weight).toBe("1 1000");
76
+ expect(out[0].url).toBe("https://cdn.example.com/sohne.woff2");
77
+ });
78
+
79
+ it("strips CSS comments before parsing (no false positives in /* @font-face */ comment)", () => {
80
+ const css = `
81
+ /* @font-face { font-family: "ShouldNotMatch"; src: url("nope.woff2"); } */
82
+ @font-face { font-family: "Real"; src: url("real.woff2"); }
83
+ `;
84
+ const out = parseFontFaceRulesFromCssText(css, BASE);
85
+ expect(out).toHaveLength(1);
86
+ expect(out[0].family).toBe("Real");
87
+ });
88
+
89
+ it("handles unquoted family names and resolves relative urls against the base", () => {
90
+ const css = `@font-face { font-family: MyFont; src: url(./fonts/x.woff2); }`;
91
+ const out = parseFontFaceRulesFromCssText(css, "https://cdn.example.com/css/site.css");
92
+ expect(out).toHaveLength(1);
93
+ expect(out[0].family).toBe("MyFont");
94
+ expect(out[0].url).toBe("https://cdn.example.com/css/fonts/x.woff2");
95
+ });
96
+
97
+ it("returns multiple rules when the CSS declares many @font-face entries", () => {
98
+ const css = `
99
+ @font-face { font-family: A; src: url(a.woff2); }
100
+ @font-face { font-family: B; src: url(b.woff2); font-weight: 700; }
101
+ @font-face { font-family: C; src: url(c.woff2); font-style: italic; }
102
+ `;
103
+ const out = parseFontFaceRulesFromCssText(css, BASE);
104
+ expect(out).toHaveLength(3);
105
+ expect(out.map((r) => r.family)).toEqual(["A", "B", "C"]);
106
+ expect(out[1].weight).toBe("700");
107
+ expect(out[2].style).toBe("italic");
108
+ });
109
+
110
+ it("skips a rule with no parseable src (all eot/svg)", () => {
111
+ const css = `@font-face { font-family: X; src: url(x.eot), url(x.svg#x) format("svg"); }`;
112
+ expect(parseFontFaceRulesFromCssText(css, BASE)).toEqual([]);
113
+ });
114
+
115
+ it("skips a rule missing font-family", () => {
116
+ const css = `@font-face { src: url(x.woff2); }`;
117
+ expect(parseFontFaceRulesFromCssText(css, BASE)).toEqual([]);
118
+ });
119
+ });
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { type CursorOverlay, type SelectorResolver, cursorOverlayMarkup, resolveCursorScript } from "./cursor-overlay.js";
3
+
4
+ const TOTAL = 4000;
5
+ const FRAME_STARTS = [0, 1500, 3000];
6
+
7
+ describe("resolveCursorScript: position keyframes", () => {
8
+ it("anchors a hidden keyframe at t=0 even when the script starts later", () => {
9
+ const overlay: CursorOverlay = {
10
+ events: [{ type: "show", t: 500, x: 100, y: 200 }],
11
+ };
12
+ const r = resolveCursorScript(overlay, TOTAL, FRAME_STARTS, null);
13
+ expect(r.positions[0]).toMatchObject({ t: 0, visible: false });
14
+ expect(r.positions.find((p) => p.t === 500)).toMatchObject({ x: 100, y: 200, visible: true });
15
+ expect(r.positions[r.positions.length - 1].t).toBe(TOTAL);
16
+ });
17
+
18
+ it("resolves `to` (absolute), `by` (relative), and `selector` move targets", () => {
19
+ const overlay: CursorOverlay = {
20
+ events: [
21
+ { type: "show", t: 0, x: 10, y: 20 },
22
+ { type: "move", t: 100, to: { x: 50, y: 60 } },
23
+ { type: "move", t: 200, by: { dx: 10, dy: -5 } },
24
+ { type: "move", t: 300, selector: ".btn" },
25
+ ],
26
+ };
27
+ const resolveSelector: SelectorResolver = () => ({ x: 200, y: 300, w: 80, h: 24 });
28
+ const r = resolveCursorScript(overlay, TOTAL, FRAME_STARTS, resolveSelector);
29
+ const at100 = r.positions.find((p) => p.t === 100)!;
30
+ const at200 = r.positions.find((p) => p.t === 200)!;
31
+ const at300 = r.positions.find((p) => p.t === 300)!;
32
+ expect(at100).toMatchObject({ x: 50, y: 60 });
33
+ expect(at200).toMatchObject({ x: 60, y: 55 });
34
+ // Center of (200, 300, 80, 24) is (240, 312).
35
+ expect(at300).toMatchObject({ x: 240, y: 312 });
36
+ });
37
+
38
+ it("emits two keyframes for a duration move (start + end) so SMIL interpolates", () => {
39
+ const overlay: CursorOverlay = {
40
+ events: [
41
+ { type: "show", t: 0, x: 0, y: 0 },
42
+ { type: "move", t: 1000, to: { x: 100, y: 100 }, duration: 500 },
43
+ ],
44
+ };
45
+ const r = resolveCursorScript(overlay, TOTAL, FRAME_STARTS, null);
46
+ const at1000 = r.positions.find((p) => p.t === 1000)!;
47
+ const at1500 = r.positions.find((p) => p.t === 1500)!;
48
+ expect(at1000).toMatchObject({ x: 0, y: 0, visible: true });
49
+ expect(at1500).toMatchObject({ x: 100, y: 100, visible: true });
50
+ });
51
+
52
+ it("captures clicks with the cursor's current position at the click's t", () => {
53
+ const overlay: CursorOverlay = {
54
+ events: [
55
+ { type: "show", t: 0, x: 30, y: 40 },
56
+ { type: "click", t: 100, button: "primary" },
57
+ { type: "move", t: 200, to: { x: 80, y: 80 } },
58
+ { type: "click", t: 300, button: "secondary" },
59
+ ],
60
+ };
61
+ const r = resolveCursorScript(overlay, TOTAL, FRAME_STARTS, null);
62
+ expect(r.clicks).toHaveLength(2);
63
+ expect(r.clicks[0]).toMatchObject({ t: 100, x: 30, y: 40, button: "primary" });
64
+ expect(r.clicks[1]).toMatchObject({ t: 300, x: 80, y: 80, button: "secondary" });
65
+ });
66
+ });
67
+
68
+ describe("cursorOverlayMarkup: SVG emission", () => {
69
+ it("includes a cursor-arrow group and a click pulse per click", () => {
70
+ const overlay: CursorOverlay = {
71
+ events: [
72
+ { type: "show", t: 0, x: 50, y: 50 },
73
+ { type: "click", t: 200, button: "primary" },
74
+ { type: "click", t: 600, button: "secondary" },
75
+ ],
76
+ };
77
+ const r = resolveCursorScript(overlay, TOTAL, FRAME_STARTS, null);
78
+ const svg = cursorOverlayMarkup(r.positions, r.clicks, r.style, TOTAL);
79
+ expect(svg).toContain('class="cursor-overlay"');
80
+ expect(svg).toContain('class="cursor-arrow"');
81
+ expect(svg).toContain('class="cursor-click cursor-click-0"');
82
+ expect(svg).toContain('class="cursor-click cursor-click-1"');
83
+ // Secondary click adds the right-half-disc fill.
84
+ expect(svg).toContain('rgba(0,0,0,0.2)');
85
+ });
86
+
87
+ it("emits empty string when there are no events", () => {
88
+ const r = resolveCursorScript({ events: [] }, TOTAL, FRAME_STARTS, null);
89
+ const svg = cursorOverlayMarkup(r.positions, r.clicks, r.style, TOTAL);
90
+ // Even with no events, resolveCursorScript anchors t=0 and t=total
91
+ // (both invisible). The markup is non-empty but contains no clicks.
92
+ expect(svg).toContain('class="cursor-overlay"');
93
+ expect(svg).not.toContain('class="cursor-click');
94
+ });
95
+ });