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,213 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { rasterizeConic, resolveConicStops } from "./conic-raster.js";
|
|
3
|
+
import { parseConicGradient } from "./gradients.js";
|
|
4
|
+
|
|
5
|
+
function pixel(buf: Buffer, w: number, x: number, y: number): { r: number; g: number; b: number; a: number } {
|
|
6
|
+
const i = (y * w + x) * 4;
|
|
7
|
+
return { r: buf[i], g: buf[i + 1], b: buf[i + 2], a: buf[i + 3] };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("resolveConicStops (DM-549)", () => {
|
|
11
|
+
it("auto-distributes stops without explicit positions", () => {
|
|
12
|
+
const g = parseConicGradient("conic-gradient(red, yellow, green, blue)");
|
|
13
|
+
const stops = resolveConicStops(g!.stops);
|
|
14
|
+
expect(stops).toHaveLength(4);
|
|
15
|
+
expect(stops[0].offset).toBe(0);
|
|
16
|
+
expect(stops[1].offset).toBeCloseTo(1 / 3);
|
|
17
|
+
expect(stops[2].offset).toBeCloseTo(2 / 3);
|
|
18
|
+
expect(stops[3].offset).toBe(1);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("preserves explicit angle positions", () => {
|
|
22
|
+
const g = parseConicGradient("conic-gradient(red 0deg, yellow 90deg, blue 180deg)");
|
|
23
|
+
const stops = resolveConicStops(g!.stops);
|
|
24
|
+
expect(stops[0].offset).toBe(0);
|
|
25
|
+
expect(stops[1].offset).toBe(0.25);
|
|
26
|
+
expect(stops[2].offset).toBe(0.5);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("emits hard stops as adjacent positions with same offset boundary", () => {
|
|
30
|
+
const g = parseConicGradient("conic-gradient(red 0% 25%, blue 25% 50%)");
|
|
31
|
+
const stops = resolveConicStops(g!.stops);
|
|
32
|
+
expect(stops).toHaveLength(4);
|
|
33
|
+
// The two middle stops both sit at 0.25.
|
|
34
|
+
expect(stops[1].offset).toBe(0.25);
|
|
35
|
+
expect(stops[2].offset).toBe(0.25);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("rasterizeConic: smooth color sweep (DM-549)", () => {
|
|
40
|
+
const g = parseConicGradient("conic-gradient(red, blue)")!;
|
|
41
|
+
const w = 32, h = 32;
|
|
42
|
+
const buf = rasterizeConic(g, w, h);
|
|
43
|
+
|
|
44
|
+
it("produces a non-empty buffer of the right size", () => {
|
|
45
|
+
expect(buf.length).toBe(w * h * 4);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("paints pure red at the top center (sweep origin)", () => {
|
|
49
|
+
// Just below the top edge at center x → angle ≈ 0 → first stop = red.
|
|
50
|
+
const px = pixel(buf, w, 16, 0);
|
|
51
|
+
expect(px.r).toBeGreaterThan(240);
|
|
52
|
+
expect(px.g).toBeLessThan(20);
|
|
53
|
+
expect(px.b).toBeLessThan(20);
|
|
54
|
+
expect(px.a).toBe(255);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("paints pure red at the bottom center too (last stop wraps to first)", () => {
|
|
58
|
+
// The non-repeating gradient has only two stops (red at 0, blue at 1).
|
|
59
|
+
// At the bottom-center (angle ≈ 180°) we're at fraction 0.5 — that's
|
|
60
|
+
// the midpoint between red and blue, not pure red.
|
|
61
|
+
const px = pixel(buf, w, 16, h - 1);
|
|
62
|
+
// Halfway between rgb(255,0,0) and rgb(0,0,255) is rgb(128,0,128).
|
|
63
|
+
expect(px.r).toBeGreaterThan(100);
|
|
64
|
+
expect(px.r).toBeLessThan(160);
|
|
65
|
+
expect(px.b).toBeGreaterThan(100);
|
|
66
|
+
expect(px.b).toBeLessThan(160);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("rasterizeConic: hard-stop checkerboard (DM-549, the canonical case)", () => {
|
|
71
|
+
// The 19-deep-color-mix tile: `repeating-conic-gradient(#ddd 0 25%, white 0 50%) 0/24px 24px`
|
|
72
|
+
// produces a four-quadrant alternating tile. We rasterize a 24×24 tile.
|
|
73
|
+
const g = parseConicGradient("repeating-conic-gradient(#ddd 0 25%, white 0 50%)")!;
|
|
74
|
+
const w = 24, h = 24;
|
|
75
|
+
const buf = rasterizeConic(g, w, h);
|
|
76
|
+
|
|
77
|
+
it("top-right quadrant (12 → 3 o'clock sweep, 0..25%) is #ddd", () => {
|
|
78
|
+
const px = pixel(buf, w, 18, 6);
|
|
79
|
+
// #ddd = rgb(221, 221, 221)
|
|
80
|
+
expect(px.r).toBe(221);
|
|
81
|
+
expect(px.g).toBe(221);
|
|
82
|
+
expect(px.b).toBe(221);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("bottom-right quadrant (3 → 6 o'clock sweep, 25..50%) is white", () => {
|
|
86
|
+
const px = pixel(buf, w, 18, 18);
|
|
87
|
+
expect(px.r).toBe(255);
|
|
88
|
+
expect(px.g).toBe(255);
|
|
89
|
+
expect(px.b).toBe(255);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("bottom-left quadrant (6 → 9 o'clock sweep, 50..75% wraps to #ddd) is #ddd", () => {
|
|
93
|
+
// Repeating period is 50% (one #ddd quadrant + one white quadrant), so
|
|
94
|
+
// the 50..75% sweep wraps back to #ddd.
|
|
95
|
+
const px = pixel(buf, w, 6, 18);
|
|
96
|
+
expect(px.r).toBe(221);
|
|
97
|
+
expect(px.g).toBe(221);
|
|
98
|
+
expect(px.b).toBe(221);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("top-left quadrant (9 → 12 o'clock sweep, 75..100% wraps to white) is white", () => {
|
|
102
|
+
const px = pixel(buf, w, 6, 6);
|
|
103
|
+
expect(px.r).toBe(255);
|
|
104
|
+
expect(px.g).toBe(255);
|
|
105
|
+
expect(px.b).toBe(255);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("rasterizeConic: from <angle> rotates the origin (DM-549)", () => {
|
|
110
|
+
const g = parseConicGradient("conic-gradient(from 90deg, red 0% 25%, blue 25% 50%)")!;
|
|
111
|
+
const w = 32, h = 32;
|
|
112
|
+
const buf = rasterizeConic(g, w, h);
|
|
113
|
+
|
|
114
|
+
it("with from=90deg, the red 0..25% wedge sits at 3-6 o'clock (right side, going down)", () => {
|
|
115
|
+
// CSS `from 90deg` shifts the sweep origin 90deg clockwise from the top:
|
|
116
|
+
// 0% → 3 o'clock (right)
|
|
117
|
+
// 25% → 6 o'clock (bottom)
|
|
118
|
+
// The red wedge spans 0..25%, so a point at the right edge slightly
|
|
119
|
+
// below center sits at frac ≈ 0.0..0.125 — pure red.
|
|
120
|
+
const px = pixel(buf, w, w - 1, h / 2 + 2);
|
|
121
|
+
expect(px.r).toBeGreaterThan(240);
|
|
122
|
+
expect(px.b).toBeLessThan(20);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("with from=90deg, the top-center is past the 50% boundary (clamped to blue)", () => {
|
|
126
|
+
// Top-center: angle ≈ 0deg from north. After from=90deg shift: frac ≈ 0.75.
|
|
127
|
+
// Past the last stop (0.5) → clamps to blue.
|
|
128
|
+
const px = pixel(buf, w, w / 2, 1);
|
|
129
|
+
expect(px.b).toBeGreaterThan(240);
|
|
130
|
+
expect(px.r).toBeLessThan(20);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("rasterizeConicGradients pre-pass + buildConicGradientDef end-to-end (DM-549/550)", () => {
|
|
135
|
+
it("populates the conic tile cache and the renderer emits <pattern><image>", async () => {
|
|
136
|
+
const { rasterizeConicGradients } = await import("./conic-raster.js");
|
|
137
|
+
const { _conicTileCache } = await import("./dom-to-svg.js");
|
|
138
|
+
_conicTileCache.clear();
|
|
139
|
+
|
|
140
|
+
// Minimal captured-tree shape with a conic-gradient bg layer.
|
|
141
|
+
const tree: any = [{
|
|
142
|
+
tagName: "div",
|
|
143
|
+
x: 0, y: 0, width: 24, height: 24,
|
|
144
|
+
styles: {
|
|
145
|
+
backgroundImage: "repeating-conic-gradient(#ddd 0 25%, white 0 50%)",
|
|
146
|
+
backgroundSize: "24px 24px",
|
|
147
|
+
},
|
|
148
|
+
children: [],
|
|
149
|
+
}];
|
|
150
|
+
|
|
151
|
+
await rasterizeConicGradients(tree as any, { hiDPIFactor: 2 });
|
|
152
|
+
|
|
153
|
+
const layer = "repeating-conic-gradient(#ddd 0 25%, white 0 50%)";
|
|
154
|
+
const sizeCache = _conicTileCache.get(layer);
|
|
155
|
+
expect(sizeCache).toBeDefined();
|
|
156
|
+
expect(sizeCache!.has("24x24")).toBe(true);
|
|
157
|
+
const dataUri = sizeCache!.get("24x24")!;
|
|
158
|
+
expect(dataUri.startsWith("data:image/png;base64,")).toBe(true);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("dedupes (layerText, tileSize) tuples — multiple consumers share one cache entry", async () => {
|
|
162
|
+
const { rasterizeConicGradients } = await import("./conic-raster.js");
|
|
163
|
+
const { _conicTileCache } = await import("./dom-to-svg.js");
|
|
164
|
+
_conicTileCache.clear();
|
|
165
|
+
|
|
166
|
+
const layer = "conic-gradient(red, blue)";
|
|
167
|
+
// Three sibling elements all painting the same conic.
|
|
168
|
+
const tree: any = [
|
|
169
|
+
{ tagName: "div", x: 0, y: 0, width: 100, height: 100, styles: { backgroundImage: layer }, children: [] },
|
|
170
|
+
{ tagName: "div", x: 0, y: 100, width: 100, height: 100, styles: { backgroundImage: layer }, children: [] },
|
|
171
|
+
{ tagName: "div", x: 0, y: 200, width: 100, height: 100, styles: { backgroundImage: layer }, children: [] },
|
|
172
|
+
];
|
|
173
|
+
await rasterizeConicGradients(tree as any, { hiDPIFactor: 2 });
|
|
174
|
+
const sizeCache = _conicTileCache.get(layer);
|
|
175
|
+
// All three consumers have the same rect (100×100, default size=auto so
|
|
176
|
+
// tile == element) — cache should have exactly one entry.
|
|
177
|
+
expect(sizeCache?.size).toBe(1);
|
|
178
|
+
expect(sizeCache?.has("100x100")).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("skips trees with no conic content (no cache pollution)", async () => {
|
|
182
|
+
const { rasterizeConicGradients } = await import("./conic-raster.js");
|
|
183
|
+
const { _conicTileCache } = await import("./dom-to-svg.js");
|
|
184
|
+
_conicTileCache.clear();
|
|
185
|
+
|
|
186
|
+
const tree: any = [{
|
|
187
|
+
tagName: "div",
|
|
188
|
+
x: 0, y: 0, width: 100, height: 100,
|
|
189
|
+
styles: {
|
|
190
|
+
backgroundImage: "linear-gradient(red, blue)",
|
|
191
|
+
},
|
|
192
|
+
children: [],
|
|
193
|
+
}];
|
|
194
|
+
await rasterizeConicGradients(tree as any, { hiDPIFactor: 2 });
|
|
195
|
+
expect(_conicTileCache.size).toBe(0);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("rasterizeConic: at <position> moves the center (DM-549)", () => {
|
|
200
|
+
const g = parseConicGradient("conic-gradient(at 0% 0%, red, blue)")!;
|
|
201
|
+
const w = 32, h = 32;
|
|
202
|
+
const buf = rasterizeConic(g, w, h);
|
|
203
|
+
|
|
204
|
+
it("with center at (0,0), the bottom-right is far from origin and blends red→blue", () => {
|
|
205
|
+
// Bottom-right pixel: angle from (0,0) is 135° measured from screen-down.
|
|
206
|
+
// That's frac ≈ 0.375 in the conic sweep (from-top, clockwise) which lies
|
|
207
|
+
// in the red→blue interpolation.
|
|
208
|
+
const px = pixel(buf, w, w - 1, h - 1);
|
|
209
|
+
// Should be a mix, neither pure red nor pure blue.
|
|
210
|
+
expect(px.r).toBeGreaterThan(60);
|
|
211
|
+
expect(px.b).toBeGreaterThan(60);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DM-549 — Conic-gradient rasterizer + tile cache + capture-tree pre-pass.
|
|
3
|
+
* See `docs/28-conic-gradient.md`.
|
|
4
|
+
*
|
|
5
|
+
* SVG has no native conic-gradient primitive. We rasterize each captured
|
|
6
|
+
* `conic-gradient(...)` / `repeating-conic-gradient(...)` background layer
|
|
7
|
+
* into a PNG tile (via sharp) and the renderer emits the bytes as
|
|
8
|
+
* `<pattern><image href="data:image/png;base64,…"/></pattern>` (DM-550).
|
|
9
|
+
*
|
|
10
|
+
* The pre-pass walks the captured tree, dedupes (layerText, tileW, tileH)
|
|
11
|
+
* tuples, rasterizes each unique tuple once at `tile × hiDPIFactor` then
|
|
12
|
+
* downsamples through sharp's lanczos kernel for antialiased edges, and
|
|
13
|
+
* stashes the resulting data URI in `_conicTileCache` keyed by
|
|
14
|
+
* `(layerText, "${tileW}x${tileH}")`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import sharp from "sharp";
|
|
18
|
+
import {
|
|
19
|
+
_conicTileCache,
|
|
20
|
+
type CapturedElement,
|
|
21
|
+
type RGBA,
|
|
22
|
+
parseColor,
|
|
23
|
+
} from "./dom-to-svg.js";
|
|
24
|
+
import { parseConicGradient, type ConicGradient, type ConicStop, type PosValue } from "./gradients.js";
|
|
25
|
+
|
|
26
|
+
export interface RasterizeConicOptions {
|
|
27
|
+
/**
|
|
28
|
+
* Multiplier on each tile's CSS-px dimensions when allocating the raw
|
|
29
|
+
* pixel buffer. The buffer is then downsampled through sharp.lanczos3 to
|
|
30
|
+
* the tile size for antialiased hard-stop edges. Default 2.0 — matches the
|
|
31
|
+
* embed pipeline's `embedRemoteImagesHiDPIFactor` per user direction.
|
|
32
|
+
* Values < 1 are clamped to 1.
|
|
33
|
+
*/
|
|
34
|
+
hiDPIFactor?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DEFAULT_HIDPI_FACTOR = 2;
|
|
38
|
+
const MIN_HIDPI_FACTOR = 1;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Walk the captured tree, identify every conic-gradient background layer
|
|
42
|
+
* with a known consumer rect, and populate `_conicTileCache` with rasterized
|
|
43
|
+
* PNG bytes for each unique (layerText, tileW, tileH) tuple.
|
|
44
|
+
*
|
|
45
|
+
* Idempotent: re-running with the same tree is a no-op (each tuple's PNG
|
|
46
|
+
* is already cached).
|
|
47
|
+
*/
|
|
48
|
+
export async function rasterizeConicGradients(
|
|
49
|
+
tree: CapturedElement[],
|
|
50
|
+
options: RasterizeConicOptions = {},
|
|
51
|
+
): Promise<void> {
|
|
52
|
+
const hiDPI = Math.max(MIN_HIDPI_FACTOR, options.hiDPIFactor ?? DEFAULT_HIDPI_FACTOR);
|
|
53
|
+
|
|
54
|
+
type Tuple = { layerText: string; gradient: ConicGradient; sizeKey: string; tileW: number; tileH: number };
|
|
55
|
+
const tuples = new Map<string, Tuple>();
|
|
56
|
+
|
|
57
|
+
const consider = (layerText: string, tileW: number, tileH: number): void => {
|
|
58
|
+
const tw = Math.max(1, Math.round(tileW));
|
|
59
|
+
const th = Math.max(1, Math.round(tileH));
|
|
60
|
+
const sizeKey = `${tw}x${th}`;
|
|
61
|
+
const dedupeKey = `${layerText}\n${sizeKey}`;
|
|
62
|
+
if (tuples.has(dedupeKey)) return;
|
|
63
|
+
const sizeCache = _conicTileCache.get(layerText);
|
|
64
|
+
if (sizeCache != null && sizeCache.has(sizeKey)) return;
|
|
65
|
+
const gradient = parseConicGradient(layerText);
|
|
66
|
+
if (gradient == null) return;
|
|
67
|
+
tuples.set(dedupeKey, { layerText, gradient, sizeKey, tileW: tw, tileH: th });
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const walk = (els: CapturedElement[]): void => {
|
|
71
|
+
for (const el of els) {
|
|
72
|
+
const bgImage = el.styles.backgroundImage;
|
|
73
|
+
if (bgImage != null && bgImage !== "" && bgImage !== "none" && /conic-gradient/i.test(bgImage)) {
|
|
74
|
+
const layers = splitTopLevelCommas(bgImage);
|
|
75
|
+
const sizes = splitTopLevelCommas(el.styles.backgroundSize ?? "auto");
|
|
76
|
+
for (let li = 0; li < layers.length; li++) {
|
|
77
|
+
const layer = layers[li].trim();
|
|
78
|
+
if (!/^(?:repeating-)?conic-gradient\(/i.test(layer)) continue;
|
|
79
|
+
const sizeCss = (sizes[li] ?? sizes[0] ?? "auto").trim();
|
|
80
|
+
const tile = computeTileSize(sizeCss, el.width, el.height);
|
|
81
|
+
consider(layer, tile.w, tile.h);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (el.children.length > 0) walk(el.children);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
walk(tree);
|
|
88
|
+
if (tuples.size === 0) return;
|
|
89
|
+
|
|
90
|
+
const tasks = Array.from(tuples.values(), async ({ layerText, gradient, sizeKey, tileW, tileH }) => {
|
|
91
|
+
try {
|
|
92
|
+
const renderW = Math.max(1, Math.round(tileW * hiDPI));
|
|
93
|
+
const renderH = Math.max(1, Math.round(tileH * hiDPI));
|
|
94
|
+
const raw = rasterizeConic(gradient, renderW, renderH);
|
|
95
|
+
// Decode raw RGBA → PNG bytes via sharp, downsampling to the CSS-px tile
|
|
96
|
+
// size with lanczos3 so hard-stop edges keep subpixel antialiasing that
|
|
97
|
+
// matches Chromium's painted output.
|
|
98
|
+
const png = await sharp(raw, { raw: { width: renderW, height: renderH, channels: 4 } })
|
|
99
|
+
.resize(tileW, tileH, { fit: "fill", kernel: "lanczos3" })
|
|
100
|
+
.png({ compressionLevel: 9 })
|
|
101
|
+
.toBuffer();
|
|
102
|
+
const dataUri = `data:image/png;base64,${png.toString("base64")}`;
|
|
103
|
+
rememberConicTile(layerText, sizeKey, dataUri);
|
|
104
|
+
} catch {
|
|
105
|
+
// Per-tuple failure: leave the cache miss in place; renderer falls back
|
|
106
|
+
// to the parse-failure warning path.
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
await Promise.all(tasks);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Rasterize a parsed conic gradient into a raw RGBA buffer of size
|
|
114
|
+
* `width × height`. Pure CPU; no I/O. Caller is responsible for downsampling
|
|
115
|
+
* / encoding (this module's pre-pass routes the raw buffer through sharp).
|
|
116
|
+
*
|
|
117
|
+
* Algorithm: per pixel, compute the angle from the gradient center, normalize
|
|
118
|
+
* to a fractional sweep offset in [0, 1), look up the color via stop-list
|
|
119
|
+
* interpolation, and write RGBA bytes.
|
|
120
|
+
*/
|
|
121
|
+
export function rasterizeConic(
|
|
122
|
+
gradient: ConicGradient,
|
|
123
|
+
width: number,
|
|
124
|
+
height: number,
|
|
125
|
+
): Buffer {
|
|
126
|
+
const buf = Buffer.allocUnsafe(width * height * 4);
|
|
127
|
+
const cx = resolvePx(gradient.position.x, width);
|
|
128
|
+
const cy = resolvePx(gradient.position.y, height);
|
|
129
|
+
// CSS conic: 0deg = top, sweep clockwise. atan2 in screen coords (y down)
|
|
130
|
+
// gives angle from +x axis, counterclockwise (mathematical). To convert:
|
|
131
|
+
// visualDeg = atan2(y - cy, x - cx) * 180/π + 90 // shift so 0 = top
|
|
132
|
+
// then add fromAngleDeg, then normalize to [0, 1).
|
|
133
|
+
// The `+ 90` rotates the +x axis (mathematical 0deg = right) to the top
|
|
134
|
+
// (CSS 0deg = top). Sweep direction: CSS clockwise == screen clockwise
|
|
135
|
+
// (both have y increasing downward), so atan2's natural counterclockwise
|
|
136
|
+
// signing inverts to clockwise correctly when we negate (or equivalently
|
|
137
|
+
// when the gradient stop offsets increase along clockwise sweep).
|
|
138
|
+
const stops = resolveConicStops(gradient.stops);
|
|
139
|
+
if (stops.length === 0) {
|
|
140
|
+
buf.fill(0);
|
|
141
|
+
return buf;
|
|
142
|
+
}
|
|
143
|
+
const repeating = gradient.repeating === true;
|
|
144
|
+
let i = 0;
|
|
145
|
+
for (let y = 0; y < height; y++) {
|
|
146
|
+
for (let x = 0; x < width; x++) {
|
|
147
|
+
const dx = x + 0.5 - cx;
|
|
148
|
+
const dy = y + 0.5 - cy;
|
|
149
|
+
// CSS conic: 0deg = top (12 o'clock), sweep clockwise. atan2(dx, -dy)
|
|
150
|
+
// gives an angle in [-π, π] where 0 = top and positive sweeps clockwise
|
|
151
|
+
// (because y increases downward in screen coords, so -dy points toward
|
|
152
|
+
// the top). Convert to a [0, 1) fractional offset around the sweep.
|
|
153
|
+
let theta = Math.atan2(dx, -dy);
|
|
154
|
+
let frac = theta / (Math.PI * 2);
|
|
155
|
+
frac -= gradient.fromAngleDeg / 360;
|
|
156
|
+
// Normalize to [0, 1).
|
|
157
|
+
frac = frac - Math.floor(frac);
|
|
158
|
+
const color = lookupColor(stops, frac, repeating);
|
|
159
|
+
buf[i++] = color.r;
|
|
160
|
+
buf[i++] = color.g;
|
|
161
|
+
buf[i++] = color.b;
|
|
162
|
+
buf[i++] = Math.round(color.a * 255);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return buf;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
interface ResolvedStop {
|
|
169
|
+
rgba: RGBA;
|
|
170
|
+
offset: number;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Resolve conic-gradient stop offsets for rasterization. Auto-distributes
|
|
175
|
+
* stops without explicit positions (CSS rule: missing position = midpoint
|
|
176
|
+
* between neighbors with positions; first defaults to 0; last defaults to 1).
|
|
177
|
+
* Drops stops whose color can't be parsed.
|
|
178
|
+
*/
|
|
179
|
+
export function resolveConicStops(stops: ConicStop[]): ResolvedStop[] {
|
|
180
|
+
if (stops.length === 0) return [];
|
|
181
|
+
const out: { rgba: RGBA; offset?: number; rawPos?: string }[] = stops.map((s) => ({
|
|
182
|
+
rgba: parseColor(s.color) ?? { r: 0, g: 0, b: 0, a: 0 },
|
|
183
|
+
offset: s.offset,
|
|
184
|
+
rawPos: s.rawPos,
|
|
185
|
+
}));
|
|
186
|
+
if (out[0].offset == null) out[0].offset = 0;
|
|
187
|
+
if (out[out.length - 1].offset == null) out[out.length - 1].offset = 1;
|
|
188
|
+
let i = 0;
|
|
189
|
+
while (i < out.length) {
|
|
190
|
+
if (out[i].offset != null) { i++; continue; }
|
|
191
|
+
let j = i + 1;
|
|
192
|
+
while (j < out.length && out[j].offset == null) j++;
|
|
193
|
+
const prev = out[i - 1].offset ?? 0;
|
|
194
|
+
const next = out[j]?.offset ?? 1;
|
|
195
|
+
const span = j - i + 1;
|
|
196
|
+
for (let k = i; k < j; k++) {
|
|
197
|
+
out[k].offset = prev + ((next - prev) * (k - (i - 1))) / span;
|
|
198
|
+
}
|
|
199
|
+
i = j;
|
|
200
|
+
}
|
|
201
|
+
// Enforce monotonicity: each stop's effective offset is max(self, previous).
|
|
202
|
+
let max = out[0].offset!;
|
|
203
|
+
for (const s of out) {
|
|
204
|
+
if (s.offset! < max) s.offset = max;
|
|
205
|
+
if (s.offset! > max) max = s.offset!;
|
|
206
|
+
}
|
|
207
|
+
return out.map((s) => ({ rgba: s.rgba, offset: s.offset! }));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Look up the interpolated color at `frac` ∈ [0, 1) in a resolved stop list.
|
|
212
|
+
* For repeating gradients, mod the input by the stop period before lookup;
|
|
213
|
+
* for non-repeating, clamp to the first/last stop outside [first.offset, last.offset].
|
|
214
|
+
*/
|
|
215
|
+
function lookupColor(stops: ResolvedStop[], frac: number, repeating: boolean): RGBA {
|
|
216
|
+
if (stops.length === 1) return stops[0].rgba;
|
|
217
|
+
const first = stops[0].offset;
|
|
218
|
+
const last = stops[stops.length - 1].offset;
|
|
219
|
+
if (repeating) {
|
|
220
|
+
const period = last - first;
|
|
221
|
+
if (period > 0) {
|
|
222
|
+
const t = ((frac - first) / period);
|
|
223
|
+
const wrapped = t - Math.floor(t);
|
|
224
|
+
frac = first + wrapped * period;
|
|
225
|
+
}
|
|
226
|
+
} else {
|
|
227
|
+
if (frac <= first) return stops[0].rgba;
|
|
228
|
+
if (frac >= last) return stops[stops.length - 1].rgba;
|
|
229
|
+
}
|
|
230
|
+
// Find the bracketing pair.
|
|
231
|
+
for (let i = 1; i < stops.length; i++) {
|
|
232
|
+
if (stops[i].offset >= frac) {
|
|
233
|
+
const a = stops[i - 1];
|
|
234
|
+
const b = stops[i];
|
|
235
|
+
const span = b.offset - a.offset;
|
|
236
|
+
if (span <= 0) return b.rgba; // hard stop
|
|
237
|
+
const t = (frac - a.offset) / span;
|
|
238
|
+
return blendRgba(a.rgba, b.rgba, t);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return stops[stops.length - 1].rgba;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function blendRgba(a: RGBA, b: RGBA, t: number): RGBA {
|
|
245
|
+
return {
|
|
246
|
+
r: Math.round(a.r + (b.r - a.r) * t),
|
|
247
|
+
g: Math.round(a.g + (b.g - a.g) * t),
|
|
248
|
+
b: Math.round(a.b + (b.b - a.b) * t),
|
|
249
|
+
a: a.a + (b.a - a.a) * t,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function resolvePx(p: PosValue, extent: number): number {
|
|
254
|
+
if (p.kind === "px") return p.value;
|
|
255
|
+
return p.value * extent;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Resolve a `background-size` value to a tile (w, h) in CSS px. `auto`,
|
|
260
|
+
* `cover`, `contain` all map to the element rect (no intrinsic for a
|
|
261
|
+
* gradient — there's no way to compute a smaller tile). Numeric values
|
|
262
|
+
* (px / %) resolve normally. Single-axis values default the missing axis
|
|
263
|
+
* to `auto` per CSS.
|
|
264
|
+
*/
|
|
265
|
+
function computeTileSize(sizeCss: string, elW: number, elH: number): { w: number; h: number } {
|
|
266
|
+
const trimmed = sizeCss.trim();
|
|
267
|
+
if (trimmed === "" || trimmed === "auto" || trimmed === "cover" || trimmed === "contain") {
|
|
268
|
+
return { w: elW, h: elH };
|
|
269
|
+
}
|
|
270
|
+
const parts = trimmed.split(/\s+/);
|
|
271
|
+
const parseDim = (tok: string, basis: number): number => {
|
|
272
|
+
if (tok === "auto") return basis;
|
|
273
|
+
const m = /^(-?\d+(?:\.\d+)?|-?\.\d+)(%|px)?$/.exec(tok);
|
|
274
|
+
if (m == null) return basis;
|
|
275
|
+
const v = parseFloat(m[1]);
|
|
276
|
+
const unit = m[2] ?? "px";
|
|
277
|
+
if (unit === "%") return (v / 100) * basis;
|
|
278
|
+
return v;
|
|
279
|
+
};
|
|
280
|
+
const w = parseDim(parts[0], elW);
|
|
281
|
+
const h = parts.length > 1 ? parseDim(parts[1], elH) : w;
|
|
282
|
+
return { w, h };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function rememberConicTile(layerText: string, sizeKey: string, dataUri: string): void {
|
|
286
|
+
let sizeCache = _conicTileCache.get(layerText);
|
|
287
|
+
if (sizeCache == null) {
|
|
288
|
+
sizeCache = new Map<string, string>();
|
|
289
|
+
_conicTileCache.set(layerText, sizeCache);
|
|
290
|
+
}
|
|
291
|
+
sizeCache.set(sizeKey, dataUri);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function splitTopLevelCommas(s: string): string[] {
|
|
295
|
+
const out: string[] = [];
|
|
296
|
+
let depth = 0;
|
|
297
|
+
let start = 0;
|
|
298
|
+
for (let i = 0; i < s.length; i++) {
|
|
299
|
+
const c = s[i];
|
|
300
|
+
if (c === "(") depth++;
|
|
301
|
+
else if (c === ")") depth--;
|
|
302
|
+
else if (c === "," && depth === 0) {
|
|
303
|
+
out.push(s.slice(start, i));
|
|
304
|
+
start = i + 1;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
out.push(s.slice(start));
|
|
308
|
+
return out;
|
|
309
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync, openSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import * as fontkit from "fontkit";
|
|
6
|
+
|
|
7
|
+
// DM-385 / DM-387: validates the Swift CoreText helper.
|
|
8
|
+
// Tests are skipped automatically when:
|
|
9
|
+
// - we're not on macOS (the helper is platform-specific)
|
|
10
|
+
// - the helper binary isn't built yet (developer hasn't run build.sh)
|
|
11
|
+
// so this file doesn't break Linux/Windows CI before DM-389/DM-390 land.
|
|
12
|
+
|
|
13
|
+
const HELPER = path.resolve(
|
|
14
|
+
path.dirname(new URL(import.meta.url).pathname),
|
|
15
|
+
"..",
|
|
16
|
+
"tools",
|
|
17
|
+
"macos-glyph-extractor",
|
|
18
|
+
"domotion-glyph-paths"
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const helperAvailable = process.platform === "darwin" && existsSync(HELPER);
|
|
22
|
+
const describeHelper = helperAvailable ? describe : describe.skip;
|
|
23
|
+
|
|
24
|
+
interface GlyphResult {
|
|
25
|
+
id: number;
|
|
26
|
+
advance: number;
|
|
27
|
+
bbox: { x: number; y: number; w: number; h: number };
|
|
28
|
+
d: string;
|
|
29
|
+
}
|
|
30
|
+
interface MetaResult {
|
|
31
|
+
unitsPerEm: number;
|
|
32
|
+
ascent: number;
|
|
33
|
+
descent: number;
|
|
34
|
+
underlinePosition?: number;
|
|
35
|
+
underlineThickness?: number;
|
|
36
|
+
strikeoutPosition?: number;
|
|
37
|
+
strikeoutThickness?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function callHelper(request: unknown): { results: any[] } {
|
|
41
|
+
const proc = spawnSync(HELPER, [], {
|
|
42
|
+
input: JSON.stringify(request),
|
|
43
|
+
encoding: "utf-8"
|
|
44
|
+
});
|
|
45
|
+
if (proc.status !== 0) {
|
|
46
|
+
throw new Error(`helper exit ${proc.status}: ${proc.stderr}`);
|
|
47
|
+
}
|
|
48
|
+
return JSON.parse(proc.stdout);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describeHelper("CoreText glyph extractor", () => {
|
|
52
|
+
it("extracts the Helvetica H outline at 100pt", () => {
|
|
53
|
+
const response = callHelper({
|
|
54
|
+
fonts: [{ ref: "h", postscriptName: "Helvetica", size: 100 }],
|
|
55
|
+
queries: [
|
|
56
|
+
{ type: "meta", fontRef: "h" },
|
|
57
|
+
{ type: "glyphs", fontRef: "h", glyphs: [{ cp: 0x48 }] }
|
|
58
|
+
]
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const meta = response.results[0] as MetaResult;
|
|
62
|
+
const glyphResult = response.results[1] as { glyphs: GlyphResult[] };
|
|
63
|
+
|
|
64
|
+
expect(meta.unitsPerEm).toBe(2048);
|
|
65
|
+
const H = glyphResult.glyphs[0];
|
|
66
|
+
expect(H.id).toBeGreaterThan(0);
|
|
67
|
+
expect(H.d).toMatch(/^M /);
|
|
68
|
+
expect(H.d).toMatch(/Z$/);
|
|
69
|
+
expect(H.advance).toBeGreaterThan(60);
|
|
70
|
+
expect(H.advance).toBeLessThan(80);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("extracts PingFang 漢 (U+6F22) where fontkit can't (DM-382)", () => {
|
|
74
|
+
const response = callHelper({
|
|
75
|
+
fonts: [{ ref: "p", postscriptName: "PingFangSC-Regular", size: 22 }],
|
|
76
|
+
queries: [{ type: "glyphs", fontRef: "p", glyphs: [{ cp: 0x6F22 }] }]
|
|
77
|
+
});
|
|
78
|
+
const result = response.results[0] as { glyphs: GlyphResult[] };
|
|
79
|
+
const han = result.glyphs[0];
|
|
80
|
+
expect(han.id).toBeGreaterThan(0);
|
|
81
|
+
expect(han.d.length).toBeGreaterThan(0);
|
|
82
|
+
expect(han.advance).toBeGreaterThan(0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("agrees with fontkit on Helvetica H advance within 1%", () => {
|
|
86
|
+
const SIZE = 100;
|
|
87
|
+
const response = callHelper({
|
|
88
|
+
fonts: [{ ref: "h", postscriptName: "Helvetica", size: SIZE }],
|
|
89
|
+
queries: [
|
|
90
|
+
{ type: "meta", fontRef: "h" },
|
|
91
|
+
{ type: "glyphs", fontRef: "h", glyphs: [{ cp: 0x48 }] }
|
|
92
|
+
]
|
|
93
|
+
});
|
|
94
|
+
const meta = response.results[0] as MetaResult;
|
|
95
|
+
const ctH = (response.results[1] as { glyphs: GlyphResult[] }).glyphs[0];
|
|
96
|
+
|
|
97
|
+
const collection = fontkit.openSync("/System/Library/Fonts/Helvetica.ttc") as any;
|
|
98
|
+
const helvetica = collection.getFont != null ? collection.getFont("Helvetica") : collection;
|
|
99
|
+
const fkGlyph = helvetica.glyphForCodePoint(0x48);
|
|
100
|
+
const fkAdvancePoints = (fkGlyph.advanceWidth * SIZE) / meta.unitsPerEm;
|
|
101
|
+
|
|
102
|
+
expect(Math.abs(ctH.advance - fkAdvancePoints)).toBeLessThan(fkAdvancePoints * 0.01);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("returns id=0 / empty path for codepoints the font lacks", () => {
|
|
106
|
+
const response = callHelper({
|
|
107
|
+
fonts: [{ ref: "h", postscriptName: "Helvetica", size: 16 }],
|
|
108
|
+
queries: [{ type: "glyphs", fontRef: "h", glyphs: [{ cp: 0x6F22 }] }] // Han ideograph in Helvetica
|
|
109
|
+
});
|
|
110
|
+
const result = response.results[0] as { glyphs: GlyphResult[] };
|
|
111
|
+
expect(result.glyphs[0].id).toBe(0);
|
|
112
|
+
expect(result.glyphs[0].d).toBe("");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("accepts pre-resolved glyph ids in addition to codepoints", () => {
|
|
116
|
+
const probe = callHelper({
|
|
117
|
+
fonts: [{ ref: "h", postscriptName: "Helvetica", size: 16 }],
|
|
118
|
+
queries: [{ type: "glyphs", fontRef: "h", glyphs: [{ cp: 0x48 }] }]
|
|
119
|
+
});
|
|
120
|
+
const id = (probe.results[0] as { glyphs: GlyphResult[] }).glyphs[0].id;
|
|
121
|
+
|
|
122
|
+
const byId = callHelper({
|
|
123
|
+
fonts: [{ ref: "h", postscriptName: "Helvetica", size: 16 }],
|
|
124
|
+
queries: [{ type: "glyphs", fontRef: "h", glyphs: [{ id }] }]
|
|
125
|
+
});
|
|
126
|
+
const byIdGlyph = (byId.results[0] as { glyphs: GlyphResult[] }).glyphs[0];
|
|
127
|
+
expect(byIdGlyph.id).toBe(id);
|
|
128
|
+
expect(byIdGlyph.d.length).toBeGreaterThan(0);
|
|
129
|
+
});
|
|
130
|
+
});
|