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
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
export declare function isCoretextHelperAvailable(): boolean;
|
|
17
|
+
interface PathCommand {
|
|
18
|
+
command: string;
|
|
19
|
+
args: number[];
|
|
20
|
+
}
|
|
21
|
+
interface CoretextGlyph {
|
|
22
|
+
id: number;
|
|
23
|
+
advanceWidth: number;
|
|
24
|
+
path: {
|
|
25
|
+
commands: PathCommand[];
|
|
26
|
+
};
|
|
27
|
+
codePoints?: number[];
|
|
28
|
+
}
|
|
29
|
+
export interface CoretextFontInstance {
|
|
30
|
+
unitsPerEm: number;
|
|
31
|
+
ascent: number;
|
|
32
|
+
descent: number;
|
|
33
|
+
underlinePosition: number;
|
|
34
|
+
underlineThickness: number;
|
|
35
|
+
"OS/2"?: {
|
|
36
|
+
yStrikeoutPosition?: number;
|
|
37
|
+
yStrikeoutSize?: number;
|
|
38
|
+
};
|
|
39
|
+
availableFeatures?: string[];
|
|
40
|
+
glyphForCodePoint(cp: number): CoretextGlyph;
|
|
41
|
+
getGlyph(id: number): CoretextGlyph;
|
|
42
|
+
layout(text: string, features?: string[]): {
|
|
43
|
+
glyphs: CoretextGlyph[];
|
|
44
|
+
positions: Array<{
|
|
45
|
+
xAdvance: number;
|
|
46
|
+
yAdvance: number;
|
|
47
|
+
xOffset: number;
|
|
48
|
+
yOffset: number;
|
|
49
|
+
}>;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export declare function createCoretextFont(spec: {
|
|
53
|
+
postscriptName?: string;
|
|
54
|
+
fontPath?: string;
|
|
55
|
+
}): CoretextFontInstance | null;
|
|
56
|
+
/** Drop the in-memory glyph-resolution caches. Currently a no-op since each
|
|
57
|
+
* `createCoretextFont` returns its own closure-bound cache, but exposed for
|
|
58
|
+
* parity with `clearWebfonts` / `clearGlyphDefs`. */
|
|
59
|
+
export declare function clearCoretextCache(): void;
|
|
60
|
+
export {};
|
|
@@ -0,0 +1,205 @@
|
|
|
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
|
+
import { spawnSync } from "node:child_process";
|
|
17
|
+
import { existsSync } from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const HELPER_PATH = process.env.DOMOTION_HELPER_PATH
|
|
22
|
+
?? path.resolve(HERE, "..", "tools", "macos-glyph-extractor", "domotion-glyph-paths");
|
|
23
|
+
let helperAvailable = null;
|
|
24
|
+
export function isCoretextHelperAvailable() {
|
|
25
|
+
if (helperAvailable != null)
|
|
26
|
+
return helperAvailable;
|
|
27
|
+
if (process.platform !== "darwin") {
|
|
28
|
+
helperAvailable = false;
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
if (process.env.DOMOTION_DISABLE_HELPER) {
|
|
32
|
+
helperAvailable = false;
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
helperAvailable = existsSync(HELPER_PATH);
|
|
36
|
+
return helperAvailable;
|
|
37
|
+
}
|
|
38
|
+
// Parse the Swift helper's SVG path-data string into fontkit's command-array
|
|
39
|
+
// format. The helper emits exactly: `M x y`, `L x y`, `Q cx cy x y`,
|
|
40
|
+
// `C c1x c1y c2x c2y x y`, `Z` — space-separated, no relative variants.
|
|
41
|
+
function parseSvgPath(d) {
|
|
42
|
+
if (d.length === 0)
|
|
43
|
+
return [];
|
|
44
|
+
const tokens = d.match(/[MLQCZ]|-?\d+(?:\.\d+)?/g) ?? [];
|
|
45
|
+
const out = [];
|
|
46
|
+
let i = 0;
|
|
47
|
+
const num = () => Number(tokens[i++]);
|
|
48
|
+
while (i < tokens.length) {
|
|
49
|
+
const t = tokens[i++];
|
|
50
|
+
switch (t) {
|
|
51
|
+
case "M":
|
|
52
|
+
out.push({ command: "moveTo", args: [num(), num()] });
|
|
53
|
+
break;
|
|
54
|
+
case "L":
|
|
55
|
+
out.push({ command: "lineTo", args: [num(), num()] });
|
|
56
|
+
break;
|
|
57
|
+
case "Q":
|
|
58
|
+
out.push({ command: "quadraticCurveTo", args: [num(), num(), num(), num()] });
|
|
59
|
+
break;
|
|
60
|
+
case "C":
|
|
61
|
+
out.push({ command: "bezierCurveTo", args: [num(), num(), num(), num(), num(), num()] });
|
|
62
|
+
break;
|
|
63
|
+
case "Z":
|
|
64
|
+
out.push({ command: "closePath", args: [] });
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
function callHelper(request) {
|
|
71
|
+
const proc = spawnSync(HELPER_PATH, [], {
|
|
72
|
+
input: JSON.stringify(request),
|
|
73
|
+
encoding: "utf-8",
|
|
74
|
+
maxBuffer: 64 * 1024 * 1024
|
|
75
|
+
});
|
|
76
|
+
if (proc.status !== 0) {
|
|
77
|
+
throw new Error(`coretext helper failed (exit ${proc.status}): ${proc.stderr}`);
|
|
78
|
+
}
|
|
79
|
+
return JSON.parse(proc.stdout);
|
|
80
|
+
}
|
|
81
|
+
export function createCoretextFont(spec) {
|
|
82
|
+
if (!isCoretextHelperAvailable())
|
|
83
|
+
return null;
|
|
84
|
+
// Open at size=1000 first so we can read unitsPerEm. Then re-open at
|
|
85
|
+
// size=unitsPerEm so all glyph paths come back in design-unit space — this
|
|
86
|
+
// matches fontkit's coordinate convention so the existing
|
|
87
|
+
// `scale(fontSize/unitsPerEm, ...)` transform in text-to-path.ts works.
|
|
88
|
+
let metaResp;
|
|
89
|
+
try {
|
|
90
|
+
const probe = callHelper({
|
|
91
|
+
fonts: [{ ref: "f", postscriptName: spec.postscriptName, fontPath: spec.fontPath, size: 1000 }],
|
|
92
|
+
queries: [{ type: "meta", fontRef: "f" }]
|
|
93
|
+
});
|
|
94
|
+
const r = probe.results[0];
|
|
95
|
+
if (r.type !== "meta")
|
|
96
|
+
throw new Error("unexpected response shape");
|
|
97
|
+
metaResp = r;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
const unitsPerEm = metaResp.unitsPerEm;
|
|
103
|
+
const renderSize = unitsPerEm;
|
|
104
|
+
// Per-(cp, id) caches — each glyph is fetched at most once per Node process.
|
|
105
|
+
const cpToGlyph = new Map();
|
|
106
|
+
const idToGlyph = new Map();
|
|
107
|
+
const missingCp = new Set();
|
|
108
|
+
function fetchByCps(cps) {
|
|
109
|
+
const need = cps.filter((cp) => !cpToGlyph.has(cp) && !missingCp.has(cp));
|
|
110
|
+
if (need.length === 0)
|
|
111
|
+
return;
|
|
112
|
+
const resp = callHelper({
|
|
113
|
+
fonts: [{ ref: "f", postscriptName: spec.postscriptName, fontPath: spec.fontPath, size: renderSize }],
|
|
114
|
+
queries: [{ type: "glyphs", fontRef: "f", glyphs: need.map((cp) => ({ cp })) }]
|
|
115
|
+
});
|
|
116
|
+
const r = resp.results[0];
|
|
117
|
+
if (r.type !== "glyphs")
|
|
118
|
+
return;
|
|
119
|
+
for (let i = 0; i < need.length; i++) {
|
|
120
|
+
const cp = need[i];
|
|
121
|
+
const g = r.glyphs[i];
|
|
122
|
+
if (g == null || g.id === 0) {
|
|
123
|
+
missingCp.add(cp);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const glyph = {
|
|
127
|
+
id: g.id,
|
|
128
|
+
advanceWidth: g.advance,
|
|
129
|
+
path: { commands: parseSvgPath(g.d) },
|
|
130
|
+
codePoints: [cp]
|
|
131
|
+
};
|
|
132
|
+
cpToGlyph.set(cp, glyph);
|
|
133
|
+
idToGlyph.set(g.id, glyph);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function fetchById(id) {
|
|
137
|
+
const cached = idToGlyph.get(id);
|
|
138
|
+
if (cached != null)
|
|
139
|
+
return cached;
|
|
140
|
+
const resp = callHelper({
|
|
141
|
+
fonts: [{ ref: "f", postscriptName: spec.postscriptName, fontPath: spec.fontPath, size: renderSize }],
|
|
142
|
+
queries: [{ type: "glyphs", fontRef: "f", glyphs: [{ id }] }]
|
|
143
|
+
});
|
|
144
|
+
const r = resp.results[0];
|
|
145
|
+
if (r.type !== "glyphs") {
|
|
146
|
+
const empty = { id, advanceWidth: 0, path: { commands: [] } };
|
|
147
|
+
idToGlyph.set(id, empty);
|
|
148
|
+
return empty;
|
|
149
|
+
}
|
|
150
|
+
const g = r.glyphs[0];
|
|
151
|
+
const glyph = {
|
|
152
|
+
id: g.id,
|
|
153
|
+
advanceWidth: g.advance,
|
|
154
|
+
path: { commands: parseSvgPath(g.d) }
|
|
155
|
+
};
|
|
156
|
+
idToGlyph.set(id, glyph);
|
|
157
|
+
return glyph;
|
|
158
|
+
}
|
|
159
|
+
function notdef(id = 0) {
|
|
160
|
+
return { id, advanceWidth: 0, path: { commands: [] } };
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
unitsPerEm,
|
|
164
|
+
ascent: metaResp.ascent ?? 0,
|
|
165
|
+
descent: metaResp.descent ?? 0,
|
|
166
|
+
underlinePosition: metaResp.underlinePosition ?? 0,
|
|
167
|
+
underlineThickness: metaResp.underlineThickness ?? 0,
|
|
168
|
+
"OS/2": {
|
|
169
|
+
yStrikeoutPosition: metaResp.strikeoutPosition,
|
|
170
|
+
yStrikeoutSize: metaResp.strikeoutThickness
|
|
171
|
+
},
|
|
172
|
+
availableFeatures: [],
|
|
173
|
+
glyphForCodePoint(cp) {
|
|
174
|
+
if (missingCp.has(cp))
|
|
175
|
+
return notdef(0);
|
|
176
|
+
if (!cpToGlyph.has(cp))
|
|
177
|
+
fetchByCps([cp]);
|
|
178
|
+
return cpToGlyph.get(cp) ?? notdef(0);
|
|
179
|
+
},
|
|
180
|
+
getGlyph(id) {
|
|
181
|
+
return fetchById(id);
|
|
182
|
+
},
|
|
183
|
+
layout(text) {
|
|
184
|
+
// Batch every codepoint in one helper call before assembling the result.
|
|
185
|
+
const cps = [];
|
|
186
|
+
for (const ch of text)
|
|
187
|
+
cps.push(ch.codePointAt(0));
|
|
188
|
+
fetchByCps(cps);
|
|
189
|
+
const glyphs = [];
|
|
190
|
+
const positions = [];
|
|
191
|
+
for (const cp of cps) {
|
|
192
|
+
const g = cpToGlyph.get(cp) ?? notdef(0);
|
|
193
|
+
glyphs.push(g);
|
|
194
|
+
positions.push({ xAdvance: g.advanceWidth, yAdvance: 0, xOffset: 0, yOffset: 0 });
|
|
195
|
+
}
|
|
196
|
+
return { glyphs, positions };
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/** Drop the in-memory glyph-resolution caches. Currently a no-op since each
|
|
201
|
+
* `createCoretextFont` returns its own closure-bound cache, but exposed for
|
|
202
|
+
* parity with `clearWebfonts` / `clearGlyphDefs`. */
|
|
203
|
+
export function clearCoretextCache() {
|
|
204
|
+
helperAvailable = null;
|
|
205
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { parseFontFaceRulesFromCssText } from "./capture.js";
|
|
3
|
+
// DM-545: cross-origin stylesheets throw on `cssRules` access from the page
|
|
4
|
+
// context, so we fetch them server-side and parse `@font-face` rules with
|
|
5
|
+
// this helper. Sites affected (verified): Stripe (b.stripecdn.com), and
|
|
6
|
+
// likely most marketing sites whose CSS is served from a different host
|
|
7
|
+
// than the page.
|
|
8
|
+
describe("parseFontFaceRulesFromCssText", () => {
|
|
9
|
+
const BASE = "https://cdn.example.com/css/site.css";
|
|
10
|
+
it("returns empty list for CSS with no @font-face rules", () => {
|
|
11
|
+
expect(parseFontFaceRulesFromCssText("body { color: red; }", BASE)).toEqual([]);
|
|
12
|
+
});
|
|
13
|
+
it("parses a single top-level @font-face rule with a single src url()", () => {
|
|
14
|
+
const css = `@font-face { font-family: "MyFont"; src: url("/fonts/myfont.woff2"); }`;
|
|
15
|
+
const out = parseFontFaceRulesFromCssText(css, BASE);
|
|
16
|
+
expect(out).toHaveLength(1);
|
|
17
|
+
expect(out[0].family).toBe("MyFont");
|
|
18
|
+
expect(out[0].weight).toBe("400"); // default
|
|
19
|
+
expect(out[0].style).toBe("normal"); // default
|
|
20
|
+
expect(out[0].url).toBe("https://cdn.example.com/fonts/myfont.woff2");
|
|
21
|
+
expect(out[0].urls).toEqual(["https://cdn.example.com/fonts/myfont.woff2"]);
|
|
22
|
+
});
|
|
23
|
+
it("parses font-weight, font-style, and unicode-range descriptors", () => {
|
|
24
|
+
const css = `
|
|
25
|
+
@font-face {
|
|
26
|
+
font-family: "Geist";
|
|
27
|
+
font-weight: 400;
|
|
28
|
+
font-style: italic;
|
|
29
|
+
src: url("/g.woff2") format("woff2");
|
|
30
|
+
unicode-range: U+0000-00FF, U+0131;
|
|
31
|
+
}`;
|
|
32
|
+
const out = parseFontFaceRulesFromCssText(css, BASE);
|
|
33
|
+
expect(out).toHaveLength(1);
|
|
34
|
+
expect(out[0].family).toBe("Geist");
|
|
35
|
+
expect(out[0].weight).toBe("400");
|
|
36
|
+
expect(out[0].style).toBe("italic");
|
|
37
|
+
expect(out[0].unicodeRange).toEqual([[0x0000, 0x00ff], [0x0131, 0x0131]]);
|
|
38
|
+
});
|
|
39
|
+
it("ranks src urls woff2 > woff > ttf/otf, skipping eot/svg", () => {
|
|
40
|
+
const css = `@font-face {
|
|
41
|
+
font-family: "sdicon";
|
|
42
|
+
src: url("/sdicon.eot"),
|
|
43
|
+
url("/sdicon.eot?#iefix") format("embedded-opentype"),
|
|
44
|
+
url("/sdicon.woff") format("woff"),
|
|
45
|
+
url("/sdicon.ttf") format("truetype"),
|
|
46
|
+
url("/sdicon.svg#sdicon") format("svg");
|
|
47
|
+
}`;
|
|
48
|
+
const out = parseFontFaceRulesFromCssText(css, BASE);
|
|
49
|
+
expect(out).toHaveLength(1);
|
|
50
|
+
// woff first (woff2 not present), then ttf. eot/svg dropped.
|
|
51
|
+
expect(out[0].urls).toEqual([
|
|
52
|
+
"https://cdn.example.com/sdicon.woff",
|
|
53
|
+
"https://cdn.example.com/sdicon.ttf",
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
56
|
+
it("recurses into @media-nested @font-face (Stripe pattern)", () => {
|
|
57
|
+
const css = `
|
|
58
|
+
@media (min-width: 600px) {
|
|
59
|
+
@font-face {
|
|
60
|
+
font-family: sohne-var;
|
|
61
|
+
src: url(/sohne.woff2) format("woff2-variations");
|
|
62
|
+
font-weight: 1 1000;
|
|
63
|
+
}
|
|
64
|
+
}`;
|
|
65
|
+
const out = parseFontFaceRulesFromCssText(css, BASE);
|
|
66
|
+
expect(out).toHaveLength(1);
|
|
67
|
+
expect(out[0].family).toBe("sohne-var");
|
|
68
|
+
expect(out[0].weight).toBe("1 1000");
|
|
69
|
+
expect(out[0].url).toBe("https://cdn.example.com/sohne.woff2");
|
|
70
|
+
});
|
|
71
|
+
it("strips CSS comments before parsing (no false positives in /* @font-face */ comment)", () => {
|
|
72
|
+
const css = `
|
|
73
|
+
/* @font-face { font-family: "ShouldNotMatch"; src: url("nope.woff2"); } */
|
|
74
|
+
@font-face { font-family: "Real"; src: url("real.woff2"); }
|
|
75
|
+
`;
|
|
76
|
+
const out = parseFontFaceRulesFromCssText(css, BASE);
|
|
77
|
+
expect(out).toHaveLength(1);
|
|
78
|
+
expect(out[0].family).toBe("Real");
|
|
79
|
+
});
|
|
80
|
+
it("handles unquoted family names and resolves relative urls against the base", () => {
|
|
81
|
+
const css = `@font-face { font-family: MyFont; src: url(./fonts/x.woff2); }`;
|
|
82
|
+
const out = parseFontFaceRulesFromCssText(css, "https://cdn.example.com/css/site.css");
|
|
83
|
+
expect(out).toHaveLength(1);
|
|
84
|
+
expect(out[0].family).toBe("MyFont");
|
|
85
|
+
expect(out[0].url).toBe("https://cdn.example.com/css/fonts/x.woff2");
|
|
86
|
+
});
|
|
87
|
+
it("returns multiple rules when the CSS declares many @font-face entries", () => {
|
|
88
|
+
const css = `
|
|
89
|
+
@font-face { font-family: A; src: url(a.woff2); }
|
|
90
|
+
@font-face { font-family: B; src: url(b.woff2); font-weight: 700; }
|
|
91
|
+
@font-face { font-family: C; src: url(c.woff2); font-style: italic; }
|
|
92
|
+
`;
|
|
93
|
+
const out = parseFontFaceRulesFromCssText(css, BASE);
|
|
94
|
+
expect(out).toHaveLength(3);
|
|
95
|
+
expect(out.map((r) => r.family)).toEqual(["A", "B", "C"]);
|
|
96
|
+
expect(out[1].weight).toBe("700");
|
|
97
|
+
expect(out[2].style).toBe("italic");
|
|
98
|
+
});
|
|
99
|
+
it("skips a rule with no parseable src (all eot/svg)", () => {
|
|
100
|
+
const css = `@font-face { font-family: X; src: url(x.eot), url(x.svg#x) format("svg"); }`;
|
|
101
|
+
expect(parseFontFaceRulesFromCssText(css, BASE)).toEqual([]);
|
|
102
|
+
});
|
|
103
|
+
it("skips a rule missing font-family", () => {
|
|
104
|
+
const css = `@font-face { src: url(x.woff2); }`;
|
|
105
|
+
expect(parseFontFaceRulesFromCssText(css, BASE)).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor / click overlay for animated SVGs (DM-277).
|
|
3
|
+
*
|
|
4
|
+
* Paints a macOS-style cursor moving along a user-authored timeline and
|
|
5
|
+
* QuickTime-style click pulses at click events. Single pointer at a time;
|
|
6
|
+
* multi-touch is out of scope for v1.
|
|
7
|
+
*
|
|
8
|
+
* The overlay is opt-in via `AnimationConfig.cursorOverlay`. The emitted
|
|
9
|
+
* markup goes inside the viewport-clipped group, after the frame groups,
|
|
10
|
+
* so it paints above the frame content and is clipped to the viewport.
|
|
11
|
+
*
|
|
12
|
+
* Selector resolution: events that target a captured element via `selector`
|
|
13
|
+
* are resolved by an optional `resolveSelector(sel, frameIndex)` callback
|
|
14
|
+
* the caller supplies. The frame index is computed from the event's `t`
|
|
15
|
+
* (each frame's start/end time is derived from the animation timing).
|
|
16
|
+
*
|
|
17
|
+
* See docs/13-cursor-overlay.md for the design.
|
|
18
|
+
*/
|
|
19
|
+
export interface CursorStyle {
|
|
20
|
+
/** Pointer variant. v1: only `mouse` is rendered (touch falls through to the same arrow glyph). */
|
|
21
|
+
pointer: "mouse" | "touch";
|
|
22
|
+
/**
|
|
23
|
+
* Inner ring + cursor stroke color. Defaults to white with a thin black
|
|
24
|
+
* outline so the cursor reads on light and dark backgrounds alike.
|
|
25
|
+
*/
|
|
26
|
+
cursorFill: string;
|
|
27
|
+
cursorStroke: string;
|
|
28
|
+
/** Click pulse stroke color. Default white with a black hairline. */
|
|
29
|
+
pulseStroke: string;
|
|
30
|
+
pulseStrokeOuter: string;
|
|
31
|
+
/** Click pulse duration in ms. Default 500. */
|
|
32
|
+
pulseDurationMs: number;
|
|
33
|
+
/** Click pulse max radius (outer edge) in px. Default 32. */
|
|
34
|
+
pulseRadius: number;
|
|
35
|
+
/** Cursor scale (1 = the 18-px-tall macOS arrow). Default 1. */
|
|
36
|
+
cursorScale: number;
|
|
37
|
+
}
|
|
38
|
+
export interface CursorMoveEvent {
|
|
39
|
+
type: "move";
|
|
40
|
+
/** Time when the move begins, ms from animation start. */
|
|
41
|
+
t: number;
|
|
42
|
+
/** Move duration in ms. Default 0 (instant jump). */
|
|
43
|
+
duration?: number;
|
|
44
|
+
/** Absolute viewport-coord target. */
|
|
45
|
+
to?: {
|
|
46
|
+
x: number;
|
|
47
|
+
y: number;
|
|
48
|
+
};
|
|
49
|
+
/** Relative offset from current cursor position. */
|
|
50
|
+
by?: {
|
|
51
|
+
dx: number;
|
|
52
|
+
dy: number;
|
|
53
|
+
};
|
|
54
|
+
/** CSS selector. Cursor moves to the center of the matched element's rect. */
|
|
55
|
+
selector?: string;
|
|
56
|
+
/** Optional offset added to `selector`'s resolved center. */
|
|
57
|
+
offset?: {
|
|
58
|
+
dx: number;
|
|
59
|
+
dy: number;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export interface CursorClickEvent {
|
|
63
|
+
type: "click";
|
|
64
|
+
t: number;
|
|
65
|
+
/** Default `primary`. `middle` renders identically to `primary`. */
|
|
66
|
+
button?: "primary" | "secondary" | "middle";
|
|
67
|
+
/** Per-event style override (merged on top of `CursorOverlay.style`). */
|
|
68
|
+
style?: Partial<CursorStyle>;
|
|
69
|
+
}
|
|
70
|
+
export interface CursorShowEvent {
|
|
71
|
+
type: "show";
|
|
72
|
+
t: number;
|
|
73
|
+
x: number;
|
|
74
|
+
y: number;
|
|
75
|
+
}
|
|
76
|
+
export interface CursorHideEvent {
|
|
77
|
+
type: "hide";
|
|
78
|
+
t: number;
|
|
79
|
+
}
|
|
80
|
+
export type CursorEvent = CursorMoveEvent | CursorClickEvent | CursorShowEvent | CursorHideEvent;
|
|
81
|
+
export interface CursorOverlay {
|
|
82
|
+
events: CursorEvent[];
|
|
83
|
+
/** Default styles for every event. Per-event `style` overrides take precedence. */
|
|
84
|
+
style?: Partial<CursorStyle>;
|
|
85
|
+
}
|
|
86
|
+
/** Optional resolver for `selector`-based move events. */
|
|
87
|
+
export type SelectorResolver = (sel: string, frameIndex: number) => {
|
|
88
|
+
x: number;
|
|
89
|
+
y: number;
|
|
90
|
+
w: number;
|
|
91
|
+
h: number;
|
|
92
|
+
} | null;
|
|
93
|
+
interface KeyframePoint {
|
|
94
|
+
t: number;
|
|
95
|
+
x: number;
|
|
96
|
+
y: number;
|
|
97
|
+
visible: boolean;
|
|
98
|
+
}
|
|
99
|
+
interface ResolvedClick {
|
|
100
|
+
t: number;
|
|
101
|
+
x: number;
|
|
102
|
+
y: number;
|
|
103
|
+
button: "primary" | "secondary" | "middle";
|
|
104
|
+
style: CursorStyle;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Resolve a script into absolute-coord position keyframes + click pulses.
|
|
108
|
+
* Caller passes a `resolveSelector(sel, frameIndex)` if the script uses
|
|
109
|
+
* selectors; otherwise pass `null` and selector events become no-ops with
|
|
110
|
+
* a console warning.
|
|
111
|
+
*/
|
|
112
|
+
export declare function resolveCursorScript(overlay: CursorOverlay, totalDurationMs: number, frameStartTimes: number[], resolveSelector: SelectorResolver | null): {
|
|
113
|
+
positions: KeyframePoint[];
|
|
114
|
+
clicks: ResolvedClick[];
|
|
115
|
+
style: CursorStyle;
|
|
116
|
+
};
|
|
117
|
+
/**
|
|
118
|
+
* Emit the `<g class="cursor-overlay">` markup for an already-resolved
|
|
119
|
+
* timeline. Returns "" when the timeline has no positions or every keyframe
|
|
120
|
+
* is invisible.
|
|
121
|
+
*/
|
|
122
|
+
export declare function cursorOverlayMarkup(positions: KeyframePoint[], clicks: ResolvedClick[], style: CursorStyle, totalDurationMs: number): string;
|
|
123
|
+
export {};
|