domotion-svg 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/FEATURES.md +102 -0
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/animator.d.ts +158 -0
- package/dist/animator.js +424 -0
- package/dist/animator.test.d.ts +5 -0
- package/dist/animator.test.js +169 -0
- package/dist/border-radius.test.d.ts +1 -0
- package/dist/border-radius.test.js +148 -0
- package/dist/capture.d.ts +193 -0
- package/dist/capture.js +786 -0
- package/dist/chrome.d.ts +45 -0
- package/dist/chrome.js +107 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +512 -0
- package/dist/client/dom.d.ts +10 -0
- package/dist/client/dom.js +17 -0
- package/dist/conic-raster.d.ts +58 -0
- package/dist/conic-raster.js +292 -0
- package/dist/conic-raster.test.d.ts +1 -0
- package/dist/conic-raster.test.js +187 -0
- package/dist/coretext-extractor.test.d.ts +1 -0
- package/dist/coretext-extractor.test.js +94 -0
- package/dist/coretext-helper.d.ts +60 -0
- package/dist/coretext-helper.js +205 -0
- package/dist/cross-origin-font-face.test.d.ts +1 -0
- package/dist/cross-origin-font-face.test.js +107 -0
- package/dist/cursor-overlay.d.ts +123 -0
- package/dist/cursor-overlay.js +207 -0
- package/dist/cursor-overlay.test.d.ts +1 -0
- package/dist/cursor-overlay.test.js +88 -0
- package/dist/dark-mode-capture.test.d.ts +1 -0
- package/dist/dark-mode-capture.test.js +158 -0
- package/dist/dark-mode-form-controls.test.d.ts +1 -0
- package/dist/dark-mode-form-controls.test.js +218 -0
- package/dist/dom-to-svg.d.ts +1016 -0
- package/dist/dom-to-svg.js +7717 -0
- package/dist/embed-remote-images.test.d.ts +1 -0
- package/dist/embed-remote-images.test.js +424 -0
- package/dist/form-controls.d.ts +70 -0
- package/dist/form-controls.js +1151 -0
- package/dist/frame-merge.d.ts +95 -0
- package/dist/frame-merge.js +374 -0
- package/dist/frame-merge.test.d.ts +6 -0
- package/dist/frame-merge.test.js +144 -0
- package/dist/gradients.d.ts +184 -0
- package/dist/gradients.js +937 -0
- package/dist/gradients.test.d.ts +1 -0
- package/dist/gradients.test.js +150 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +7 -0
- package/dist/jsx-runtime.d.ts +27 -0
- package/dist/jsx-runtime.js +96 -0
- package/dist/jsx-runtime.test.d.ts +1 -0
- package/dist/jsx-runtime.test.js +41 -0
- package/dist/kerfjs-imports.test.d.ts +1 -0
- package/dist/kerfjs-imports.test.js +36 -0
- package/dist/mask.test.d.ts +1 -0
- package/dist/mask.test.js +206 -0
- package/dist/optimize.d.ts +12 -0
- package/dist/optimize.js +32 -0
- package/dist/preserve-aspect-ratio.test.d.ts +1 -0
- package/dist/preserve-aspect-ratio.test.js +38 -0
- package/dist/resize-embedded-images.d.ts +33 -0
- package/dist/resize-embedded-images.js +164 -0
- package/dist/resize-embedded-images.test.d.ts +9 -0
- package/dist/resize-embedded-images.test.js +255 -0
- package/dist/stacking-context.test.d.ts +1 -0
- package/dist/stacking-context.test.js +927 -0
- package/dist/text-renderer.d.ts +42 -0
- package/dist/text-renderer.js +608 -0
- package/dist/text-renderer.test.d.ts +1 -0
- package/dist/text-renderer.test.js +150 -0
- package/dist/text-to-path.d.ts +265 -0
- package/dist/text-to-path.js +1800 -0
- package/dist/text-to-path.test.d.ts +1 -0
- package/dist/text-to-path.test.js +570 -0
- package/dist/utils/escapeHtml.d.ts +2 -0
- package/dist/utils/escapeHtml.js +15 -0
- package/dist/webfont-unicode-range.test.d.ts +1 -0
- package/dist/webfont-unicode-range.test.js +174 -0
- package/package.json +55 -0
- package/src/animator.test.ts +179 -0
- package/src/animator.ts +660 -0
- package/src/border-radius.test.ts +160 -0
- package/src/capture.ts +810 -0
- package/src/cli.ts +582 -0
- package/src/conic-raster.test.ts +213 -0
- package/src/conic-raster.ts +309 -0
- package/src/coretext-extractor.test.ts +130 -0
- package/src/coretext-helper.ts +256 -0
- package/src/cross-origin-font-face.test.ts +119 -0
- package/src/cursor-overlay.test.ts +95 -0
- package/src/cursor-overlay.ts +297 -0
- package/src/dark-mode-capture.test.ts +177 -0
- package/src/dark-mode-form-controls.test.ts +228 -0
- package/src/dom-to-svg.ts +8376 -0
- package/src/embed-remote-images.test.ts +461 -0
- package/src/form-controls.ts +1174 -0
- package/src/frame-merge.test.ts +157 -0
- package/src/frame-merge.ts +447 -0
- package/src/globals.d.ts +2 -0
- package/src/gradients.test.ts +175 -0
- package/src/gradients.ts +955 -0
- package/src/index.ts +12 -0
- package/src/kerf-jsx-augmentation.d.ts +36 -0
- package/src/kerfjs-imports.test.tsx +45 -0
- package/src/mask.test.ts +274 -0
- package/src/optimize.ts +34 -0
- package/src/preserve-aspect-ratio.test.ts +49 -0
- package/src/resize-embedded-images.test.ts +292 -0
- package/src/resize-embedded-images.ts +180 -0
- package/src/stacking-context.test.ts +967 -0
- package/src/text-renderer.test.ts +162 -0
- package/src/text-renderer.ts +623 -0
- package/src/text-to-path.test.ts +639 -0
- package/src/text-to-path.ts +1810 -0
- package/src/utils/escapeHtml.ts +16 -0
- package/src/webfont-unicode-range.test.ts +207 -0
package/src/gradients.ts
ADDED
|
@@ -0,0 +1,955 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS gradient parser + SVG <linearGradient> emitter.
|
|
3
|
+
*
|
|
4
|
+
* Converts CSS `linear-gradient(...)` text (as resolved by Chromium's
|
|
5
|
+
* computed-style serializer) into an SVG `<linearGradient>` def using
|
|
6
|
+
* `gradientUnits="userSpaceOnUse"`. Per-rect coordinates pin the gradient
|
|
7
|
+
* line in screen space so non-orthogonal angles round-trip correctly even
|
|
8
|
+
* for non-square boxes — the CSS "magic corner" rule
|
|
9
|
+
* (L = |w·sin θ| + |h·cos θ|) maps cleanly onto absolute SVG coords.
|
|
10
|
+
*
|
|
11
|
+
* Origin: SK-1224 (implementation of doc 29). v1 covers linear-gradient
|
|
12
|
+
* track/thumb fills on `<input type=range>`. SK-1225 adds radial; SK-1226
|
|
13
|
+
* adds px-positioned color stops.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface LinearStop {
|
|
17
|
+
/** Resolved CSS color (Chromium serializes to rgb()/rgba() form). */
|
|
18
|
+
color: string;
|
|
19
|
+
/**
|
|
20
|
+
* Final fractional offset (0..1). Populated by parseStop for percent
|
|
21
|
+
* positions and by resolveStops for px positions / auto-distribution.
|
|
22
|
+
*/
|
|
23
|
+
offset?: number;
|
|
24
|
+
/**
|
|
25
|
+
* Pending pixel offset along the gradient axis, awaiting rect dimensions
|
|
26
|
+
* (SK-1226). Resolved into `offset` once buildLinearGradientDef knows the
|
|
27
|
+
* gradient line length. Negative values are allowed (CSS spec).
|
|
28
|
+
*/
|
|
29
|
+
pxOffset?: number;
|
|
30
|
+
/**
|
|
31
|
+
* Pending mixed `calc(<pct>% ± <px>px)` offset (DM-275). Resolved into
|
|
32
|
+
* `offset` once the gradient line length is known: `offset = pct/100 + px/L`.
|
|
33
|
+
*/
|
|
34
|
+
calcOffset?: { pct: number; px: number };
|
|
35
|
+
/** Original raw position token (debugging / inspection). */
|
|
36
|
+
rawPos?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface LinearGradient {
|
|
40
|
+
kind: "linear";
|
|
41
|
+
/** Resolved angle in CSS degrees (0 = to top, 90 = to right, 180 = to bottom, 270 = to left). */
|
|
42
|
+
angleDeg: number;
|
|
43
|
+
stops: LinearStop[];
|
|
44
|
+
/** True when the source was `repeating-linear-gradient(...)`. The stop list spans one tile period; the emitter clones it across the full gradient line (DM-275). */
|
|
45
|
+
repeating?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Position component along one axis. Resolved against rect at emit time. */
|
|
49
|
+
export type PosValue = { kind: "frac"; value: number } | { kind: "px"; value: number };
|
|
50
|
+
|
|
51
|
+
/** Sizing of a radial gradient (CSS extent keyword or explicit lengths). */
|
|
52
|
+
export type RadialSize =
|
|
53
|
+
| { kind: "extent"; value: "closest-side" | "closest-corner" | "farthest-side" | "farthest-corner" }
|
|
54
|
+
| { kind: "px"; r1: number; r2?: number };
|
|
55
|
+
|
|
56
|
+
export interface RadialGradient {
|
|
57
|
+
kind: "radial";
|
|
58
|
+
shape: "circle" | "ellipse";
|
|
59
|
+
size: RadialSize;
|
|
60
|
+
/** Center position of the gradient (default: center of the painted rect). */
|
|
61
|
+
position: { x: PosValue; y: PosValue };
|
|
62
|
+
stops: LinearStop[];
|
|
63
|
+
/** True when the source was `repeating-radial-gradient(...)` (DM-275). */
|
|
64
|
+
repeating?: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ConicStop {
|
|
68
|
+
/** Resolved CSS color (Chromium serializes to rgb()/rgba() form). */
|
|
69
|
+
color: string;
|
|
70
|
+
/**
|
|
71
|
+
* Final fractional offset around the conic sweep (0..1). 0 = first stop,
|
|
72
|
+
* 1 = last stop. Populated by parseConicStop for percent and angle
|
|
73
|
+
* positions. Auto-distribution for missing offsets happens at rasterize
|
|
74
|
+
* time (the rasterizer needs the sweep period to interpolate).
|
|
75
|
+
*/
|
|
76
|
+
offset?: number;
|
|
77
|
+
/** Original raw position token (debugging / inspection). */
|
|
78
|
+
rawPos?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface ConicGradient {
|
|
82
|
+
kind: "conic";
|
|
83
|
+
/** `from <angle>` clause in CSS degrees. 0 = top per CSS spec, 90 = right. */
|
|
84
|
+
fromAngleDeg: number;
|
|
85
|
+
/** Center position. Default (50%, 50%). Reuses radial's PosValue grammar. */
|
|
86
|
+
position: { x: PosValue; y: PosValue };
|
|
87
|
+
stops: ConicStop[];
|
|
88
|
+
/** True when the source was `repeating-conic-gradient(...)`. */
|
|
89
|
+
repeating?: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type AnyGradient = LinearGradient | RadialGradient | ConicGradient;
|
|
93
|
+
|
|
94
|
+
/** Try every supported gradient type. Returns the first that parses or null. */
|
|
95
|
+
export function parseGradient(text: string | undefined | null): AnyGradient | null {
|
|
96
|
+
return parseLinearGradient(text) ?? parseRadialGradient(text) ?? parseConicGradient(text);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Parse `linear-gradient(...)` or `repeating-linear-gradient(...)` text. */
|
|
100
|
+
export function parseLinearGradient(text: string | undefined | null): LinearGradient | null {
|
|
101
|
+
if (text == null) return null;
|
|
102
|
+
const trimmed = text.trim();
|
|
103
|
+
const m = /^(repeating-)?linear-gradient\s*\(([\s\S]*)\)\s*$/.exec(trimmed);
|
|
104
|
+
if (m == null) return null;
|
|
105
|
+
const repeating = m[1] != null;
|
|
106
|
+
const inner = m[2].trim();
|
|
107
|
+
const tokens = splitTopLevelCommas(inner).map((t) => t.trim()).filter((t) => t !== "");
|
|
108
|
+
if (tokens.length < 2) return null;
|
|
109
|
+
|
|
110
|
+
let angleDeg = 180; // CSS default: to bottom
|
|
111
|
+
let stopsStart = 0;
|
|
112
|
+
const angle = parseAngleToken(tokens[0]);
|
|
113
|
+
if (angle != null) {
|
|
114
|
+
angleDeg = angle;
|
|
115
|
+
stopsStart = 1;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const stops: LinearStop[] = [];
|
|
119
|
+
for (let i = stopsStart; i < tokens.length; i++) {
|
|
120
|
+
const parsed = parseStopToken(tokens[i]);
|
|
121
|
+
if (parsed.length === 0) return null;
|
|
122
|
+
for (const s of parsed) stops.push(s);
|
|
123
|
+
}
|
|
124
|
+
if (stops.length < 2) return null;
|
|
125
|
+
|
|
126
|
+
// Don't auto-distribute here — px positions need the rect's gradient line
|
|
127
|
+
// length to resolve, which isn't known until buildLinearGradientDef.
|
|
128
|
+
return repeating ? { kind: "linear", angleDeg, stops, repeating: true } : { kind: "linear", angleDeg, stops };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Compute the gradient line length per the CSS magic-corner formula.
|
|
133
|
+
* L = |w·sin θ| + |h·cos θ|. Used to convert px stop offsets to fractions.
|
|
134
|
+
*/
|
|
135
|
+
export function gradientLineLength(angleDeg: number, w: number, h: number): number {
|
|
136
|
+
const θ = (angleDeg * Math.PI) / 180;
|
|
137
|
+
return Math.abs(w * Math.sin(θ)) + Math.abs(h * Math.cos(θ));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Build the `<linearGradient>` def markup for a parsed gradient applied to a
|
|
142
|
+
* specific painted rect. `userSpaceOnUse` puts x1/y1/x2/y2 in screen
|
|
143
|
+
* coordinates — so two sliders at different positions need different defs
|
|
144
|
+
* even if their CSS gradient text matches.
|
|
145
|
+
*/
|
|
146
|
+
export function buildLinearGradientDef(
|
|
147
|
+
gradient: LinearGradient,
|
|
148
|
+
id: string,
|
|
149
|
+
rect: { x: number; y: number; w: number; h: number },
|
|
150
|
+
): string {
|
|
151
|
+
const { x1, y1, x2, y2 } = computeUserSpaceLine(gradient.angleDeg, rect);
|
|
152
|
+
// Resolve stop positions against this rect. Clone first so two callers
|
|
153
|
+
// sharing the same parsed gradient don't mutate each other's offsets
|
|
154
|
+
// (different rects produce different L → different fractions for px stops).
|
|
155
|
+
const resolved = gradient.stops.map((s) => ({ ...s }));
|
|
156
|
+
resolveStops(resolved, gradientLineLength(gradient.angleDeg, rect.w, rect.h), { skipFirstLastDefaults: gradient.repeating === true });
|
|
157
|
+
const tiled = gradient.repeating === true ? tileRepeatingStops(resolved) : resolved;
|
|
158
|
+
const stops = tiled.map((s) => stopMarkup(s)).join("");
|
|
159
|
+
return `<linearGradient id="${id}" gradientUnits="userSpaceOnUse" x1="${num(x1)}" y1="${num(y1)}" x2="${num(x2)}" y2="${num(y2)}">${stops}</linearGradient>`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Map a CSS gradient angle onto absolute SVG coords for a rect.
|
|
164
|
+
* CSS convention: 0deg = up, 90deg = right, 180deg = down, 270deg = left.
|
|
165
|
+
* The gradient line passes through the rect center; its length is the
|
|
166
|
+
* "magic corner" projection L = |w·sin θ| + |h·cos θ|.
|
|
167
|
+
*/
|
|
168
|
+
export function computeUserSpaceLine(
|
|
169
|
+
angleDeg: number,
|
|
170
|
+
rect: { x: number; y: number; w: number; h: number },
|
|
171
|
+
): { x1: number; y1: number; x2: number; y2: number } {
|
|
172
|
+
const θ = (angleDeg * Math.PI) / 180;
|
|
173
|
+
const dx = Math.sin(θ);
|
|
174
|
+
const dy = -Math.cos(θ);
|
|
175
|
+
const cx = rect.x + rect.w / 2;
|
|
176
|
+
const cy = rect.y + rect.h / 2;
|
|
177
|
+
const L = Math.abs(rect.w * dx) + Math.abs(rect.h * dy);
|
|
178
|
+
return {
|
|
179
|
+
x1: cx - (dx * L) / 2,
|
|
180
|
+
y1: cy - (dy * L) / 2,
|
|
181
|
+
x2: cx + (dx * L) / 2,
|
|
182
|
+
y2: cy + (dy * L) / 2,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Stable key for dedup. Same gradient + same rect = same def = same id. */
|
|
187
|
+
export function gradientCacheKey(g: AnyGradient, rect: { x: number; y: number; w: number; h: number }): string {
|
|
188
|
+
const stopsKey = g.stops.map((s) => {
|
|
189
|
+
let pos: string;
|
|
190
|
+
if (s.offset != null) pos = num(s.offset);
|
|
191
|
+
else if ("pxOffset" in s && s.pxOffset != null) pos = `${num(s.pxOffset)}px`;
|
|
192
|
+
else if ("calcOffset" in s && s.calcOffset != null) pos = `c${num(s.calcOffset.pct)}/${num(s.calcOffset.px)}`;
|
|
193
|
+
else pos = "?";
|
|
194
|
+
return `${s.color}@${pos}`;
|
|
195
|
+
}).join(",");
|
|
196
|
+
const rectKey = `${num(rect.x)},${num(rect.y)},${num(rect.w)},${num(rect.h)}`;
|
|
197
|
+
const rep = g.repeating === true ? "r" : "n";
|
|
198
|
+
if (g.kind === "linear") return `L|${rep}|${num(g.angleDeg)}|${rectKey}|${stopsKey}`;
|
|
199
|
+
if (g.kind === "conic") {
|
|
200
|
+
const posKey = `${posKey1(g.position.x)},${posKey1(g.position.y)}`;
|
|
201
|
+
return `C|${rep}|${num(g.fromAngleDeg)}|${posKey}|${rectKey}|${stopsKey}`;
|
|
202
|
+
}
|
|
203
|
+
// Radial
|
|
204
|
+
const sizeKey = g.size.kind === "extent" ? `e:${g.size.value}` : `p:${num(g.size.r1)}/${g.size.r2 != null ? num(g.size.r2) : ""}`;
|
|
205
|
+
const posKey = `${posKey1(g.position.x)},${posKey1(g.position.y)}`;
|
|
206
|
+
return `R|${rep}|${g.shape}|${sizeKey}|${posKey}|${rectKey}|${stopsKey}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function posKey1(p: PosValue): string {
|
|
210
|
+
return p.kind === "frac" ? `${num(p.value)}f` : `${num(p.value)}px`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Parse a `radial-gradient(...)` text. Supports the common authoring forms:
|
|
215
|
+
* radial-gradient(red, blue) // ellipse, farthest-corner, center
|
|
216
|
+
* radial-gradient(circle, red, blue)
|
|
217
|
+
* radial-gradient(circle 50px, red, blue)
|
|
218
|
+
* radial-gradient(closest-side, red, blue)
|
|
219
|
+
* radial-gradient(circle at 25% 25%, red, blue)
|
|
220
|
+
* radial-gradient(ellipse 60px 40px at top right, red, blue)
|
|
221
|
+
*
|
|
222
|
+
* Defaults per CSS: shape=ellipse, size=farthest-corner, position=center.
|
|
223
|
+
*/
|
|
224
|
+
export function parseRadialGradient(text: string | undefined | null): RadialGradient | null {
|
|
225
|
+
if (text == null) return null;
|
|
226
|
+
const trimmed = text.trim();
|
|
227
|
+
const m = /^(repeating-)?radial-gradient\s*\(([\s\S]*)\)\s*$/.exec(trimmed);
|
|
228
|
+
if (m == null) return null;
|
|
229
|
+
const repeating = m[1] != null;
|
|
230
|
+
const tokens = splitTopLevelCommas(m[2]).map((t) => t.trim()).filter((t) => t !== "");
|
|
231
|
+
if (tokens.length < 2) return null;
|
|
232
|
+
|
|
233
|
+
// Decide whether the first token is a shape/size/position prefix or a stop.
|
|
234
|
+
// A prefix never contains the typical color-stop pattern (a color literal),
|
|
235
|
+
// so try parsing it as a stop first; if that fails AND it matches one of the
|
|
236
|
+
// prefix keywords or position syntax, treat as prefix.
|
|
237
|
+
let shape: "circle" | "ellipse" = "ellipse";
|
|
238
|
+
let size: RadialSize = { kind: "extent", value: "farthest-corner" };
|
|
239
|
+
let position: { x: PosValue; y: PosValue } = {
|
|
240
|
+
x: { kind: "frac", value: 0.5 },
|
|
241
|
+
y: { kind: "frac", value: 0.5 },
|
|
242
|
+
};
|
|
243
|
+
let stopsStart = 0;
|
|
244
|
+
|
|
245
|
+
const first = tokens[0];
|
|
246
|
+
if (looksLikeRadialPrefix(first)) {
|
|
247
|
+
const parsed = parseRadialPrefix(first);
|
|
248
|
+
if (parsed == null) return null;
|
|
249
|
+
shape = parsed.shape;
|
|
250
|
+
size = parsed.size;
|
|
251
|
+
position = parsed.position;
|
|
252
|
+
stopsStart = 1;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const stops: LinearStop[] = [];
|
|
256
|
+
for (let i = stopsStart; i < tokens.length; i++) {
|
|
257
|
+
const parsed = parseStopToken(tokens[i]);
|
|
258
|
+
if (parsed.length === 0) return null;
|
|
259
|
+
for (const s of parsed) stops.push(s);
|
|
260
|
+
}
|
|
261
|
+
if (stops.length < 2) return null;
|
|
262
|
+
|
|
263
|
+
return repeating
|
|
264
|
+
? { kind: "radial", shape, size, position, stops, repeating: true }
|
|
265
|
+
: { kind: "radial", shape, size, position, stops };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Parse a `conic-gradient(...)` text. Common authoring forms:
|
|
270
|
+
* conic-gradient(red, yellow, green, blue) // sweep starting at top
|
|
271
|
+
* conic-gradient(from 45deg, red, blue) // rotate origin 45deg
|
|
272
|
+
* conic-gradient(at 25% 75%, red, blue) // off-center
|
|
273
|
+
* conic-gradient(from 0.25turn at top right, red, blue) // both
|
|
274
|
+
* repeating-conic-gradient(#ddd 0 25%, white 0 50%) // alpha-checkerboard
|
|
275
|
+
* conic-gradient(red 0deg, yellow 90deg, blue 180deg) // angle-positioned stops
|
|
276
|
+
*
|
|
277
|
+
* Defaults: from=0deg (top), at center (50% 50%).
|
|
278
|
+
*/
|
|
279
|
+
export function parseConicGradient(text: string | undefined | null): ConicGradient | null {
|
|
280
|
+
if (text == null) return null;
|
|
281
|
+
const trimmed = text.trim();
|
|
282
|
+
const m = /^(repeating-)?conic-gradient\s*\(([\s\S]*)\)\s*$/.exec(trimmed);
|
|
283
|
+
if (m == null) return null;
|
|
284
|
+
const repeating = m[1] != null;
|
|
285
|
+
const tokens = splitTopLevelCommas(m[2]).map((t) => t.trim()).filter((t) => t !== "");
|
|
286
|
+
if (tokens.length < 2) return null;
|
|
287
|
+
|
|
288
|
+
let fromAngleDeg = 0;
|
|
289
|
+
let position: { x: PosValue; y: PosValue } = {
|
|
290
|
+
x: { kind: "frac", value: 0.5 },
|
|
291
|
+
y: { kind: "frac", value: 0.5 },
|
|
292
|
+
};
|
|
293
|
+
let stopsStart = 0;
|
|
294
|
+
|
|
295
|
+
// The first token may carry an optional "from <angle>" clause and/or an
|
|
296
|
+
// optional "at <position>" clause. Detect via leading keywords.
|
|
297
|
+
const first = tokens[0].toLowerCase();
|
|
298
|
+
if (first.startsWith("from ") || first.startsWith("at ") || /\bat\b/.test(first) && first.startsWith("from")) {
|
|
299
|
+
const parsed = parseConicPrefix(tokens[0]);
|
|
300
|
+
if (parsed == null) return null;
|
|
301
|
+
fromAngleDeg = parsed.fromAngleDeg;
|
|
302
|
+
position = parsed.position;
|
|
303
|
+
stopsStart = 1;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const stops: ConicStop[] = [];
|
|
307
|
+
for (let i = stopsStart; i < tokens.length; i++) {
|
|
308
|
+
const parsed = parseConicStopToken(tokens[i]);
|
|
309
|
+
if (parsed.length === 0) return null;
|
|
310
|
+
for (const s of parsed) stops.push(s);
|
|
311
|
+
}
|
|
312
|
+
if (stops.length < 2) return null;
|
|
313
|
+
|
|
314
|
+
return repeating
|
|
315
|
+
? { kind: "conic", fromAngleDeg, position, stops, repeating: true }
|
|
316
|
+
: { kind: "conic", fromAngleDeg, position, stops };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Parse the optional `from <angle> at <position>` prefix of a conic gradient.
|
|
321
|
+
* Either clause is optional, in either order. Returns null on parse failure.
|
|
322
|
+
*/
|
|
323
|
+
function parseConicPrefix(tok: string): { fromAngleDeg: number; position: { x: PosValue; y: PosValue } } | null {
|
|
324
|
+
let fromAngleDeg = 0;
|
|
325
|
+
let position: { x: PosValue; y: PosValue } = {
|
|
326
|
+
x: { kind: "frac", value: 0.5 },
|
|
327
|
+
y: { kind: "frac", value: 0.5 },
|
|
328
|
+
};
|
|
329
|
+
// Split into "from <angle>" clause and "at <position>" clause. Either may
|
|
330
|
+
// appear at the start of the prefix; both are optional. The "at" keyword
|
|
331
|
+
// boundary is matched at the start of the string OR after whitespace, since
|
|
332
|
+
// a bare "at <pos>" prefix has no preceding "from" clause.
|
|
333
|
+
const atSplit = tok.split(/(?:^|\s+)at\s+/i);
|
|
334
|
+
const beforeAt = atSplit[0].trim();
|
|
335
|
+
const afterAt = atSplit.length > 1 ? atSplit.slice(1).join(" at ").trim() : "";
|
|
336
|
+
// Parse the "from <angle>" clause if present in the before-at portion.
|
|
337
|
+
const fromMatch = /^from\s+(.+)$/i.exec(beforeAt);
|
|
338
|
+
if (fromMatch != null) {
|
|
339
|
+
const angle = parseAngleToken(fromMatch[1].trim());
|
|
340
|
+
if (angle == null) return null;
|
|
341
|
+
fromAngleDeg = angle;
|
|
342
|
+
} else if (beforeAt !== "") {
|
|
343
|
+
// before-at must be empty (bare "at" prefix) or "from <angle>".
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
if (afterAt !== "") {
|
|
347
|
+
const pos = parsePositionPair(afterAt);
|
|
348
|
+
if (pos == null) return null;
|
|
349
|
+
position = pos;
|
|
350
|
+
}
|
|
351
|
+
return { fromAngleDeg, position };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Parse a single conic-gradient stop token. Returns 1 stop, or 2 for the
|
|
356
|
+
* double-position hard-stop form `<color> <pos1> <pos2>`. Conic stops accept
|
|
357
|
+
* both `<percentage>` (relative to the sweep) and `<angle>` (deg/turn/rad/grad,
|
|
358
|
+
* absolute around the sweep), normalized to [0, 1) at parse time.
|
|
359
|
+
*/
|
|
360
|
+
function parseConicStopToken(tok: string): ConicStop[] {
|
|
361
|
+
const parts = splitTopLevelSpaces(tok);
|
|
362
|
+
if (parts.length === 0) return [];
|
|
363
|
+
const isPos = (t: string) => isConicPositionToken(t);
|
|
364
|
+
const positions: string[] = [];
|
|
365
|
+
let cut = parts.length;
|
|
366
|
+
while (cut > 0 && isPos(parts[cut - 1])) {
|
|
367
|
+
positions.unshift(parts[cut - 1]);
|
|
368
|
+
cut--;
|
|
369
|
+
}
|
|
370
|
+
if (cut === 0) return [];
|
|
371
|
+
const colorText = parts.slice(0, cut).join(" ").trim();
|
|
372
|
+
if (colorText === "") return [];
|
|
373
|
+
if (positions.length === 0) return [{ color: colorText }];
|
|
374
|
+
if (positions.length === 1) {
|
|
375
|
+
return [makeConicStop(colorText, positions[0])];
|
|
376
|
+
}
|
|
377
|
+
if (positions.length === 2) {
|
|
378
|
+
return [makeConicStop(colorText, positions[0]), makeConicStop(colorText, positions[1])];
|
|
379
|
+
}
|
|
380
|
+
return [];
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function isConicPositionToken(t: string): boolean {
|
|
384
|
+
// Plain percentage / bare number / 0 (special).
|
|
385
|
+
if (/^(-?\d+(?:\.\d+)?|-?\.\d+)(%)?$/.test(t)) return true;
|
|
386
|
+
// Angle units.
|
|
387
|
+
if (/^(-?\d+(?:\.\d+)?|-?\.\d+)(deg|grad|rad|turn)$/i.test(t)) return true;
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function makeConicStop(color: string, posTok: string): ConicStop {
|
|
392
|
+
const offset = parseConicPosition(posTok);
|
|
393
|
+
if (offset == null) return { color, rawPos: posTok };
|
|
394
|
+
return { color, offset, rawPos: posTok };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Parse a conic stop position to a fractional sweep offset (0..1).
|
|
399
|
+
* - Percentage `25%` → 0.25.
|
|
400
|
+
* - Bare number `25` (lenient): same as `25%` → 0.25.
|
|
401
|
+
* - `0` → 0.
|
|
402
|
+
* - Angle `90deg` / `0.25turn` / `1.57rad` / `100grad` → fraction of 360deg.
|
|
403
|
+
* Negative / >360 angles are not pre-normalized — the rasterizer applies
|
|
404
|
+
* the same monotonicity / clamping rules as linear/radial.
|
|
405
|
+
*/
|
|
406
|
+
function parseConicPosition(tok: string): number | null {
|
|
407
|
+
// Percent / bare number.
|
|
408
|
+
const pm = /^(-?\d+(?:\.\d+)?|-?\.\d+)(%)?$/.exec(tok);
|
|
409
|
+
if (pm != null) {
|
|
410
|
+
const v = parseFloat(pm[1]);
|
|
411
|
+
return v / 100;
|
|
412
|
+
}
|
|
413
|
+
// Angle units.
|
|
414
|
+
const am = /^(-?\d+(?:\.\d+)?|-?\.\d+)(deg|grad|rad|turn)$/i.exec(tok);
|
|
415
|
+
if (am != null) {
|
|
416
|
+
const v = parseFloat(am[1]);
|
|
417
|
+
const unit = am[2].toLowerCase();
|
|
418
|
+
if (unit === "deg") return v / 360;
|
|
419
|
+
if (unit === "turn") return v;
|
|
420
|
+
if (unit === "grad") return v / 400;
|
|
421
|
+
if (unit === "rad") return v / (Math.PI * 2);
|
|
422
|
+
}
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Build the `<radialGradient>` def for a parsed radial gradient applied to
|
|
428
|
+
* a specific painted rect. Elliptical gradients use gradientTransform to
|
|
429
|
+
* stretch the natively-circular SVG radial gradient.
|
|
430
|
+
*/
|
|
431
|
+
export function buildRadialGradientDef(
|
|
432
|
+
gradient: RadialGradient,
|
|
433
|
+
id: string,
|
|
434
|
+
rect: { x: number; y: number; w: number; h: number },
|
|
435
|
+
): string {
|
|
436
|
+
const cx = resolvePos(gradient.position.x, rect.x, rect.w);
|
|
437
|
+
const cy = resolvePos(gradient.position.y, rect.y, rect.h);
|
|
438
|
+
const { rx, ry } = resolveRadii(gradient, cx, cy, rect);
|
|
439
|
+
// Resolve stops against the gradient ray length. For ellipse, use rx (the
|
|
440
|
+
// x-axis radius) as the canonical ray length — gradientTransform rescales
|
|
441
|
+
// ry separately.
|
|
442
|
+
const resolved = gradient.stops.map((s) => ({ ...s }));
|
|
443
|
+
resolveStops(resolved, rx, { skipFirstLastDefaults: gradient.repeating === true });
|
|
444
|
+
const tiled = gradient.repeating === true ? tileRepeatingStops(resolved) : resolved;
|
|
445
|
+
const stopMarkup_ = tiled.map((s) => stopMarkup(s)).join("");
|
|
446
|
+
// SVG <radialGradient> takes one r. For ellipse, use rx and apply a
|
|
447
|
+
// gradientTransform to scale the y axis to ry.
|
|
448
|
+
const r = rx;
|
|
449
|
+
const transform = ry !== rx
|
|
450
|
+
? ` gradientTransform="translate(${num(cx)} ${num(cy)}) scale(1 ${num(ry / rx)}) translate(${num(-cx)} ${num(-cy)})"`
|
|
451
|
+
: "";
|
|
452
|
+
return `<radialGradient id="${id}" gradientUnits="userSpaceOnUse" cx="${num(cx)}" cy="${num(cy)}" r="${num(r)}"${transform}>${stopMarkup_}</radialGradient>`;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ── Internals ──────────────────────────────────────────────────────────────
|
|
456
|
+
|
|
457
|
+
function num(n: number): string {
|
|
458
|
+
return Number(n.toFixed(3)).toString();
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function splitTopLevelCommas(s: string): string[] {
|
|
462
|
+
const out: string[] = [];
|
|
463
|
+
let depth = 0;
|
|
464
|
+
let start = 0;
|
|
465
|
+
for (let i = 0; i < s.length; i++) {
|
|
466
|
+
const c = s[i];
|
|
467
|
+
if (c === "(") depth++;
|
|
468
|
+
else if (c === ")") depth--;
|
|
469
|
+
else if (c === "," && depth === 0) {
|
|
470
|
+
out.push(s.slice(start, i));
|
|
471
|
+
start = i + 1;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
out.push(s.slice(start));
|
|
475
|
+
return out;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Parse a CSS gradient angle token. Returns degrees, or null if not an angle. */
|
|
479
|
+
function parseAngleToken(tok: string): number | null {
|
|
480
|
+
const t = tok.trim().toLowerCase();
|
|
481
|
+
// Side keywords: "to <side>" or "to <side> <side>".
|
|
482
|
+
if (t.startsWith("to ")) {
|
|
483
|
+
const sides = t.slice(3).split(/\s+/).filter((s) => s !== "");
|
|
484
|
+
return sidesToAngle(sides);
|
|
485
|
+
}
|
|
486
|
+
// Numeric angle: <number><unit>
|
|
487
|
+
const m = /^(-?\d+(?:\.\d+)?|-?\.\d+)(deg|grad|rad|turn)?$/.exec(t);
|
|
488
|
+
if (m == null) return null;
|
|
489
|
+
const value = parseFloat(m[1]);
|
|
490
|
+
const unit = m[2] ?? "deg";
|
|
491
|
+
let deg: number;
|
|
492
|
+
if (unit === "deg") deg = value;
|
|
493
|
+
else if (unit === "turn") deg = value * 360;
|
|
494
|
+
else if (unit === "grad") deg = (value * 360) / 400;
|
|
495
|
+
else if (unit === "rad") deg = (value * 180) / Math.PI;
|
|
496
|
+
else return null;
|
|
497
|
+
// Normalize to [0, 360).
|
|
498
|
+
return ((deg % 360) + 360) % 360;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function sidesToAngle(sides: string[]): number | null {
|
|
502
|
+
// "to top" = 0deg, "to right" = 90, "to bottom" = 180, "to left" = 270.
|
|
503
|
+
// Combined sides go corner-ward but the actual angle is computed per the
|
|
504
|
+
// CSS "magic corner" rule and depends on the box aspect ratio. For the
|
|
505
|
+
// pseudo-element use case (small rects with predictable aspect ratios),
|
|
506
|
+
// approximate corner directions with 45deg increments — Chromium's
|
|
507
|
+
// computed style normalizes "to top right" to a numeric angle anyway, so
|
|
508
|
+
// this branch is mostly defensive.
|
|
509
|
+
const set = new Set(sides);
|
|
510
|
+
if (set.has("top") && set.has("right")) return 45;
|
|
511
|
+
if (set.has("right") && set.has("bottom")) return 135;
|
|
512
|
+
if (set.has("bottom") && set.has("left")) return 225;
|
|
513
|
+
if (set.has("top") && set.has("left")) return 315;
|
|
514
|
+
if (set.has("top")) return 0;
|
|
515
|
+
if (set.has("right")) return 90;
|
|
516
|
+
if (set.has("bottom")) return 180;
|
|
517
|
+
if (set.has("left")) return 270;
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Parse a single color-stop token. Returns 1 stop normally, 2 stops for the
|
|
523
|
+
* double-position hard-stop form `<color> <pos1> <pos2>`. Returns [] if the
|
|
524
|
+
* token is unparseable.
|
|
525
|
+
*/
|
|
526
|
+
function parseStopToken(tok: string): LinearStop[] {
|
|
527
|
+
// Find the boundary between the color and any trailing positions. Color
|
|
528
|
+
// values may contain spaces (e.g. `rgb(0 0 0 / 0.5)` modern syntax) and
|
|
529
|
+
// commas inside parens, so split by walking parens-aware.
|
|
530
|
+
const parts = splitTopLevelSpaces(tok);
|
|
531
|
+
if (parts.length === 0) return [];
|
|
532
|
+
// Heuristic: positions are tokens that match a length/percent pattern.
|
|
533
|
+
// calc(...) tokens that resolve to a length-percent are also positions
|
|
534
|
+
// (DM-275: repeating gradients commonly use `calc(N% - Mpx)` for stripe
|
|
535
|
+
// boundaries). Anything more complex falls through to color-text.
|
|
536
|
+
const posRe = /^(-?\d+(?:\.\d+)?|-?\.\d+)(%|px|em|rem|pt|cm|mm|in|pc)?$/;
|
|
537
|
+
const calcRe = /^calc\(.*\)$/;
|
|
538
|
+
const isPos = (t: string) => posRe.test(t) || calcRe.test(t);
|
|
539
|
+
// Walk from the end consuming positions.
|
|
540
|
+
const positions: string[] = [];
|
|
541
|
+
let cut = parts.length;
|
|
542
|
+
while (cut > 0 && isPos(parts[cut - 1])) {
|
|
543
|
+
positions.unshift(parts[cut - 1]);
|
|
544
|
+
cut--;
|
|
545
|
+
}
|
|
546
|
+
if (cut === 0) return [];
|
|
547
|
+
const colorText = parts.slice(0, cut).join(" ").trim();
|
|
548
|
+
if (colorText === "") return [];
|
|
549
|
+
if (positions.length === 0) return [{ color: colorText }];
|
|
550
|
+
if (positions.length === 1) {
|
|
551
|
+
return [makeStop(colorText, positions[0])];
|
|
552
|
+
}
|
|
553
|
+
// Double-position hard stop: emit two stops at p1, p2 sharing the color.
|
|
554
|
+
if (positions.length === 2) {
|
|
555
|
+
return [makeStop(colorText, positions[0]), makeStop(colorText, positions[1])];
|
|
556
|
+
}
|
|
557
|
+
return [];
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Build a LinearStop from a color and one position token. */
|
|
561
|
+
function makeStop(color: string, posTok: string): LinearStop {
|
|
562
|
+
const calc = parseCalcPosition(posTok);
|
|
563
|
+
if (calc != null) return { color, calcOffset: calc, rawPos: posTok };
|
|
564
|
+
const parsed = parsePosition(posTok);
|
|
565
|
+
if (parsed == null) return { color, rawPos: posTok };
|
|
566
|
+
if (parsed.kind === "frac") return { color, offset: parsed.value, rawPos: posTok };
|
|
567
|
+
return { color, pxOffset: parsed.value, rawPos: posTok };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Parse a `calc(<pct>% ± <px>px)` token into a {pct, px} pair (DM-275).
|
|
572
|
+
* Supports the limited form Chromium emits in computed gradient stops:
|
|
573
|
+
* a single percentage term plus an optional signed pixel offset, in either
|
|
574
|
+
* order. Anything else returns null and the caller falls back to the
|
|
575
|
+
* straight `parsePosition` path (which won't handle calc, leaving the stop
|
|
576
|
+
* un-positioned).
|
|
577
|
+
*
|
|
578
|
+
* Examples:
|
|
579
|
+
* `calc(10% - 1px)` → {pct: 10, px: -1}
|
|
580
|
+
* `calc(10%)` → {pct: 10, px: 0}
|
|
581
|
+
* `calc(10% + 2px)` → {pct: 10, px: 2}
|
|
582
|
+
* `calc(2px + 10%)` → {pct: 10, px: 2}
|
|
583
|
+
*/
|
|
584
|
+
function parseCalcPosition(tok: string): { pct: number; px: number } | null {
|
|
585
|
+
const m = /^calc\(\s*(.+?)\s*\)$/.exec(tok);
|
|
586
|
+
if (m == null) return null;
|
|
587
|
+
const inner = m[1];
|
|
588
|
+
// Tokenize: split on top-level + or -, preserving signs.
|
|
589
|
+
const terms: { sign: 1 | -1; raw: string }[] = [];
|
|
590
|
+
let sign: 1 | -1 = 1;
|
|
591
|
+
let buf = "";
|
|
592
|
+
for (let i = 0; i < inner.length; i++) {
|
|
593
|
+
const c = inner[i];
|
|
594
|
+
if ((c === "+" || c === "-") && buf.trim() !== "" && /\s/.test(inner[i - 1])) {
|
|
595
|
+
terms.push({ sign, raw: buf.trim() });
|
|
596
|
+
sign = c === "+" ? 1 : -1;
|
|
597
|
+
buf = "";
|
|
598
|
+
} else {
|
|
599
|
+
buf += c;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
if (buf.trim() !== "") terms.push({ sign, raw: buf.trim() });
|
|
603
|
+
let pct = 0;
|
|
604
|
+
let px = 0;
|
|
605
|
+
for (const t of terms) {
|
|
606
|
+
const pm = /^(-?\d+(?:\.\d+)?|-?\.\d+)(%|px)?$/.exec(t.raw);
|
|
607
|
+
if (pm == null) return null;
|
|
608
|
+
const val = parseFloat(pm[1]) * t.sign;
|
|
609
|
+
const unit = pm[2] ?? "px";
|
|
610
|
+
if (unit === "%") pct += val;
|
|
611
|
+
else if (unit === "px") px += val;
|
|
612
|
+
else return null;
|
|
613
|
+
}
|
|
614
|
+
return { pct, px };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** Split a single-stop token into space-separated parts, paren-aware. */
|
|
618
|
+
function splitTopLevelSpaces(s: string): string[] {
|
|
619
|
+
const out: string[] = [];
|
|
620
|
+
let depth = 0;
|
|
621
|
+
let start = 0;
|
|
622
|
+
let inToken = false;
|
|
623
|
+
for (let i = 0; i < s.length; i++) {
|
|
624
|
+
const c = s[i];
|
|
625
|
+
if (c === "(") depth++;
|
|
626
|
+
else if (c === ")") depth--;
|
|
627
|
+
if (depth === 0 && /\s/.test(c)) {
|
|
628
|
+
if (inToken) {
|
|
629
|
+
out.push(s.slice(start, i));
|
|
630
|
+
inToken = false;
|
|
631
|
+
}
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
if (!inToken) {
|
|
635
|
+
start = i;
|
|
636
|
+
inToken = true;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
if (inToken) out.push(s.slice(start));
|
|
640
|
+
return out;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
type ParsedPosition = { kind: "frac"; value: number } | { kind: "px"; value: number };
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Parse a stop position token to a fractional offset (0..1) or a pending
|
|
647
|
+
* pixel offset (resolved to a fraction once the painted rect's gradient
|
|
648
|
+
* line length is known — SK-1226).
|
|
649
|
+
*
|
|
650
|
+
* Length units other than px are coerced to px via a coarse approximation
|
|
651
|
+
* (1em = 16px, 1pt = 4/3 px, etc.) since real CSS context isn't available
|
|
652
|
+
* at parse time. Authors using em/rem on gradient stops are rare in
|
|
653
|
+
* practice; if the heuristic bites, the fallback is auto-distribution.
|
|
654
|
+
*/
|
|
655
|
+
function parsePosition(tok: string): ParsedPosition | null {
|
|
656
|
+
const m = /^(-?\d+(?:\.\d+)?|-?\.\d+)(%|px|em|rem|pt|cm|mm|in|pc)?$/.exec(tok);
|
|
657
|
+
if (m == null) return null;
|
|
658
|
+
const value = parseFloat(m[1]);
|
|
659
|
+
const unit = m[2] ?? "";
|
|
660
|
+
if (unit === "%") return { kind: "frac", value: value / 100 };
|
|
661
|
+
if (unit === "") return { kind: "frac", value: value / 100 }; // bare number → percent (lenient)
|
|
662
|
+
if (unit === "px") return { kind: "px", value };
|
|
663
|
+
// Coarse length conversions to px; rare on gradient stops.
|
|
664
|
+
if (unit === "em" || unit === "rem") return { kind: "px", value: value * 16 };
|
|
665
|
+
if (unit === "pt") return { kind: "px", value: value * (4 / 3) };
|
|
666
|
+
if (unit === "pc") return { kind: "px", value: value * 16 };
|
|
667
|
+
if (unit === "in") return { kind: "px", value: value * 96 };
|
|
668
|
+
if (unit === "cm") return { kind: "px", value: value * (96 / 2.54) };
|
|
669
|
+
if (unit === "mm") return { kind: "px", value: value * (96 / 25.4) };
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Resolve all stop positions to fractional offsets (0..1) for emission.
|
|
675
|
+
* - Px stops (pxOffset set) → offset = pxOffset / gradientLineLength.
|
|
676
|
+
* - First stop without an offset → 0.
|
|
677
|
+
* - Last stop without an offset → 1.
|
|
678
|
+
* - Middle stops without an offset → linearly interpolated between
|
|
679
|
+
* surrounding positioned stops.
|
|
680
|
+
* - Out-of-order positions are clamped (CSS rule: each stop's effective
|
|
681
|
+
* offset is max(self, previous)).
|
|
682
|
+
*
|
|
683
|
+
* Mutates `stops` in place. Caller may want to clone first if the same
|
|
684
|
+
* parsed gradient is being emitted against multiple rects (different L).
|
|
685
|
+
*/
|
|
686
|
+
function resolveStops(stops: LinearStop[], gradientLineLength: number, opts?: { skipFirstLastDefaults?: boolean }): void {
|
|
687
|
+
if (stops.length === 0) return;
|
|
688
|
+
// Resolve pending px / calc positions to fractions.
|
|
689
|
+
if (gradientLineLength > 0) {
|
|
690
|
+
for (const s of stops) {
|
|
691
|
+
if (s.offset == null && s.pxOffset != null) {
|
|
692
|
+
s.offset = s.pxOffset / gradientLineLength;
|
|
693
|
+
} else if (s.offset == null && s.calcOffset != null) {
|
|
694
|
+
s.offset = s.calcOffset.pct / 100 + s.calcOffset.px / gradientLineLength;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
// For repeating gradients, the first/last stop must NOT be forced to 0/1 —
|
|
699
|
+
// their explicit positions define the tile period. Authors may omit them
|
|
700
|
+
// and rely on auto-distribution within the tile, but our test cases
|
|
701
|
+
// always include explicit positions, so leave them alone (DM-275).
|
|
702
|
+
if (opts?.skipFirstLastDefaults !== true) {
|
|
703
|
+
if (stops[0].offset == null) stops[0].offset = 0;
|
|
704
|
+
if (stops[stops.length - 1].offset == null) stops[stops.length - 1].offset = 1;
|
|
705
|
+
}
|
|
706
|
+
let i = 0;
|
|
707
|
+
while (i < stops.length) {
|
|
708
|
+
if (stops[i].offset != null) {
|
|
709
|
+
i++;
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
// Find the next positioned stop.
|
|
713
|
+
let j = i + 1;
|
|
714
|
+
while (j < stops.length && stops[j].offset == null) j++;
|
|
715
|
+
const prev = stops[i - 1].offset ?? 0;
|
|
716
|
+
const next = stops[j]?.offset ?? 1;
|
|
717
|
+
const span = j - i + 1;
|
|
718
|
+
for (let k = i; k < j; k++) {
|
|
719
|
+
stops[k].offset = prev + ((next - prev) * (k - (i - 1))) / span;
|
|
720
|
+
}
|
|
721
|
+
i = j;
|
|
722
|
+
}
|
|
723
|
+
// Enforce monotonicity (CSS rule: each stop's effective offset is
|
|
724
|
+
// max(self, previous)). Out-of-order positions are still emitted, just
|
|
725
|
+
// clamped — SVG renderers honor monotonic offsets.
|
|
726
|
+
let max = stops[0].offset ?? 0;
|
|
727
|
+
for (const s of stops) {
|
|
728
|
+
if (s.offset != null && s.offset < max) s.offset = max;
|
|
729
|
+
if (s.offset != null && s.offset > max) max = s.offset;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Tile repeating-gradient stops across the full [0, 1] gradient line.
|
|
735
|
+
*
|
|
736
|
+
* The author specifies one tile period via the first and last stop offsets
|
|
737
|
+
* (e.g. `repeating-linear-gradient(90deg, transparent 0 9%, #94a3b8 9% 10%)`
|
|
738
|
+
* has period = 0.10 starting at 0). We replicate the stop list, shifted by
|
|
739
|
+
* the period, until we cover [0, 1]. SVG `<linearGradient>` doesn't have a
|
|
740
|
+
* native repeat mode (`spreadMethod="repeat"` only repeats *outside* the
|
|
741
|
+
* declared 0..1 range, which userSpaceOnUse already clips to the gradient
|
|
742
|
+
* line endpoints), so up-front replication is the simplest approach that
|
|
743
|
+
* works across renderers (DM-275).
|
|
744
|
+
*
|
|
745
|
+
* Caller is responsible for resolving stop offsets first (px → fraction).
|
|
746
|
+
* Stops without offsets are dropped from the tile (defensive: should not
|
|
747
|
+
* happen for the well-formed test fixtures).
|
|
748
|
+
*/
|
|
749
|
+
function tileRepeatingStops(stops: LinearStop[]): LinearStop[] {
|
|
750
|
+
if (stops.length < 2) return stops;
|
|
751
|
+
const sorted = stops.filter((s) => s.offset != null) as Array<LinearStop & { offset: number }>;
|
|
752
|
+
if (sorted.length < 2) return stops;
|
|
753
|
+
const tileStart = sorted[0].offset;
|
|
754
|
+
const tileEnd = sorted[sorted.length - 1].offset;
|
|
755
|
+
const period = tileEnd - tileStart;
|
|
756
|
+
// No meaningful period (degenerate tile) — emit one copy clamped to 0..1.
|
|
757
|
+
if (period <= 0 || period > 1) return sorted.map((s) => ({ ...s, offset: Math.max(0, Math.min(1, s.offset)) }));
|
|
758
|
+
const out: LinearStop[] = [];
|
|
759
|
+
// Shift backward until tileStart - n*period <= 0.
|
|
760
|
+
let n = 0;
|
|
761
|
+
while (tileStart - n * period > 0) n++;
|
|
762
|
+
// Then walk forward emitting tiles until we pass 1.
|
|
763
|
+
for (let k = -n; ; k++) {
|
|
764
|
+
const shift = k * period;
|
|
765
|
+
const tileFirst = tileStart + shift;
|
|
766
|
+
if (tileFirst > 1) break;
|
|
767
|
+
for (const s of sorted) {
|
|
768
|
+
const off = s.offset + shift;
|
|
769
|
+
if (off < 0 - 1e-9 || off > 1 + 1e-9) continue;
|
|
770
|
+
out.push({ ...s, offset: Math.max(0, Math.min(1, off)) });
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
if (out.length === 0) return sorted.map((s) => ({ ...s, offset: Math.max(0, Math.min(1, s.offset)) }));
|
|
774
|
+
return out;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function looksLikeRadialPrefix(tok: string): boolean {
|
|
778
|
+
const lower = tok.toLowerCase();
|
|
779
|
+
if (/\b(circle|ellipse|closest-side|closest-corner|farthest-side|farthest-corner)\b/.test(lower)) return true;
|
|
780
|
+
if (/\bat\b/.test(lower)) return true;
|
|
781
|
+
// A bare length like "50px 30px" with no color is also a prefix — but
|
|
782
|
+
// "50px 30px" alone would be unparseable as a stop (no color), so the
|
|
783
|
+
// stop parser would fail to add it. To be safe, treat as prefix only if
|
|
784
|
+
// the token contains an unambiguous shape/keyword/at marker.
|
|
785
|
+
return false;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function parseRadialPrefix(tok: string): { shape: "circle" | "ellipse"; size: RadialSize; position: { x: PosValue; y: PosValue } } | null {
|
|
789
|
+
// Split on " at " (case-insensitive) into [shape-and-size, position].
|
|
790
|
+
const atSplit = tok.split(/\s+at\s+/i);
|
|
791
|
+
const shapeSizeText = atSplit[0].trim();
|
|
792
|
+
const positionText = atSplit.length > 1 ? atSplit.slice(1).join(" at ").trim() : "";
|
|
793
|
+
|
|
794
|
+
const shapeSize = parseShapeAndSize(shapeSizeText);
|
|
795
|
+
if (shapeSize == null) return null;
|
|
796
|
+
const position = positionText !== "" ? parsePositionPair(positionText) : {
|
|
797
|
+
x: { kind: "frac" as const, value: 0.5 },
|
|
798
|
+
y: { kind: "frac" as const, value: 0.5 },
|
|
799
|
+
};
|
|
800
|
+
if (position == null) return null;
|
|
801
|
+
return { shape: shapeSize.shape, size: shapeSize.size, position };
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function parseShapeAndSize(text: string): { shape: "circle" | "ellipse"; size: RadialSize } | null {
|
|
805
|
+
const parts = splitTopLevelSpaces(text).map((p) => p.toLowerCase());
|
|
806
|
+
let shape: "circle" | "ellipse" = "ellipse";
|
|
807
|
+
let size: RadialSize = { kind: "extent", value: "farthest-corner" };
|
|
808
|
+
const sizes: number[] = [];
|
|
809
|
+
let extent: RadialSize | null = null;
|
|
810
|
+
for (const p of parts) {
|
|
811
|
+
if (p === "circle") shape = "circle";
|
|
812
|
+
else if (p === "ellipse") shape = "ellipse";
|
|
813
|
+
else if (p === "closest-side" || p === "closest-corner" || p === "farthest-side" || p === "farthest-corner") {
|
|
814
|
+
extent = { kind: "extent", value: p };
|
|
815
|
+
} else {
|
|
816
|
+
const parsed = parsePosition(p);
|
|
817
|
+
if (parsed != null && parsed.kind === "px") sizes.push(parsed.value);
|
|
818
|
+
else if (parsed != null && parsed.kind === "frac") sizes.push(parsed.value); // % treated as px-equivalent only loosely
|
|
819
|
+
else if (p === "") {
|
|
820
|
+
/* skip */
|
|
821
|
+
} else return null;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
if (sizes.length === 1) size = { kind: "px", r1: sizes[0] };
|
|
825
|
+
else if (sizes.length === 2) {
|
|
826
|
+
size = { kind: "px", r1: sizes[0], r2: sizes[1] };
|
|
827
|
+
shape = "ellipse"; // two sizes implies ellipse
|
|
828
|
+
} else if (sizes.length === 0 && extent != null) {
|
|
829
|
+
size = extent;
|
|
830
|
+
} else if (sizes.length === 0) {
|
|
831
|
+
// Default
|
|
832
|
+
size = { kind: "extent", value: "farthest-corner" };
|
|
833
|
+
} else {
|
|
834
|
+
return null;
|
|
835
|
+
}
|
|
836
|
+
return { shape, size };
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function parsePositionPair(text: string): { x: PosValue; y: PosValue } | null {
|
|
840
|
+
const parts = splitTopLevelSpaces(text).map((p) => p.toLowerCase());
|
|
841
|
+
// Resolve each token to a PosValue or a side keyword.
|
|
842
|
+
let x: PosValue = { kind: "frac", value: 0.5 };
|
|
843
|
+
let y: PosValue = { kind: "frac", value: 0.5 };
|
|
844
|
+
// Side keywords map to fractions.
|
|
845
|
+
const sideX: Record<string, number> = { left: 0, center: 0.5, right: 1 };
|
|
846
|
+
const sideY: Record<string, number> = { top: 0, center: 0.5, bottom: 1 };
|
|
847
|
+
// CSS position is up to 4 tokens but the common forms are 1 or 2.
|
|
848
|
+
if (parts.length === 1) {
|
|
849
|
+
const p = parts[0];
|
|
850
|
+
if (p in sideX) x = { kind: "frac", value: sideX[p] };
|
|
851
|
+
else if (p in sideY) y = { kind: "frac", value: sideY[p] };
|
|
852
|
+
else {
|
|
853
|
+
const v = parsePosition(p);
|
|
854
|
+
if (v == null) return null;
|
|
855
|
+
x = v.kind === "px" ? { kind: "px", value: v.value } : { kind: "frac", value: v.value };
|
|
856
|
+
}
|
|
857
|
+
} else if (parts.length >= 2) {
|
|
858
|
+
const [a, b] = parts;
|
|
859
|
+
// Order can be x y or y x — keyword 'top'/'bottom' before length means y.
|
|
860
|
+
let xTok = a;
|
|
861
|
+
let yTok = b;
|
|
862
|
+
if ((a === "top" || a === "bottom") && (b in sideX || /^[-\d.]/.test(b))) {
|
|
863
|
+
xTok = b;
|
|
864
|
+
yTok = a;
|
|
865
|
+
}
|
|
866
|
+
const xVal = xTok in sideX ? { kind: "frac" as const, value: sideX[xTok] } : posValueFromToken(xTok);
|
|
867
|
+
const yVal = yTok in sideY ? { kind: "frac" as const, value: sideY[yTok] } : posValueFromToken(yTok);
|
|
868
|
+
if (xVal == null || yVal == null) return null;
|
|
869
|
+
x = xVal;
|
|
870
|
+
y = yVal;
|
|
871
|
+
}
|
|
872
|
+
return { x, y };
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function posValueFromToken(tok: string): PosValue | null {
|
|
876
|
+
const v = parsePosition(tok);
|
|
877
|
+
if (v == null) return null;
|
|
878
|
+
if (v.kind === "px") return { kind: "px", value: v.value };
|
|
879
|
+
return { kind: "frac", value: v.value };
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function resolvePos(p: PosValue, rectStart: number, rectExtent: number): number {
|
|
883
|
+
if (p.kind === "px") return rectStart + p.value;
|
|
884
|
+
return rectStart + p.value * rectExtent;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Resolve a radial gradient's size to concrete (rx, ry) given its center
|
|
889
|
+
* and the painted rect. CSS extent keywords measure distance from the
|
|
890
|
+
* center to the named feature (side or corner) of the rect.
|
|
891
|
+
*/
|
|
892
|
+
function resolveRadii(g: RadialGradient, cx: number, cy: number, rect: { x: number; y: number; w: number; h: number }): { rx: number; ry: number } {
|
|
893
|
+
if (g.size.kind === "px") {
|
|
894
|
+
const r1 = g.size.r1;
|
|
895
|
+
const r2 = g.size.r2 ?? r1;
|
|
896
|
+
return { rx: r1, ry: g.shape === "circle" ? r1 : r2 };
|
|
897
|
+
}
|
|
898
|
+
const left = cx - rect.x;
|
|
899
|
+
const right = rect.x + rect.w - cx;
|
|
900
|
+
const top = cy - rect.y;
|
|
901
|
+
const bottom = rect.y + rect.h - cy;
|
|
902
|
+
const dxClosest = Math.min(Math.abs(left), Math.abs(right));
|
|
903
|
+
const dxFarthest = Math.max(Math.abs(left), Math.abs(right));
|
|
904
|
+
const dyClosest = Math.min(Math.abs(top), Math.abs(bottom));
|
|
905
|
+
const dyFarthest = Math.max(Math.abs(top), Math.abs(bottom));
|
|
906
|
+
switch (g.size.value) {
|
|
907
|
+
case "closest-side":
|
|
908
|
+
if (g.shape === "circle") {
|
|
909
|
+
const d = Math.min(dxClosest, dyClosest);
|
|
910
|
+
return { rx: d, ry: d };
|
|
911
|
+
}
|
|
912
|
+
return { rx: dxClosest, ry: dyClosest };
|
|
913
|
+
case "farthest-side":
|
|
914
|
+
if (g.shape === "circle") {
|
|
915
|
+
const d = Math.max(dxFarthest, dyFarthest);
|
|
916
|
+
return { rx: d, ry: d };
|
|
917
|
+
}
|
|
918
|
+
return { rx: dxFarthest, ry: dyFarthest };
|
|
919
|
+
case "closest-corner": {
|
|
920
|
+
const d = Math.sqrt(dxClosest * dxClosest + dyClosest * dyClosest);
|
|
921
|
+
if (g.shape === "circle") return { rx: d, ry: d };
|
|
922
|
+
// For ellipse, scale the closest-side radii so the corner sits on the curve.
|
|
923
|
+
const k = Math.sqrt((dxClosest * dxClosest + dyClosest * dyClosest) / (dxClosest * dxClosest + dyClosest * dyClosest)); // = 1; spec scaling is more nuanced
|
|
924
|
+
return { rx: dxClosest * k, ry: dyClosest * k };
|
|
925
|
+
}
|
|
926
|
+
case "farthest-corner": {
|
|
927
|
+
const d = Math.sqrt(dxFarthest * dxFarthest + dyFarthest * dyFarthest);
|
|
928
|
+
if (g.shape === "circle") return { rx: d, ry: d };
|
|
929
|
+
// CSS ellipse farthest-corner: rx = farthest x distance scaled so the corner lies on the curve.
|
|
930
|
+
// Approximation: rx = dxFarthest * sqrt(2), ry = dyFarthest * sqrt(2).
|
|
931
|
+
// This is the common-case approximation used in browsers.
|
|
932
|
+
return { rx: dxFarthest * Math.SQRT2, ry: dyFarthest * Math.SQRT2 };
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function stopMarkup(stop: LinearStop): string {
|
|
938
|
+
const offset = stop.offset != null ? Math.max(0, Math.min(1, stop.offset)) : 0;
|
|
939
|
+
const { color, opacity } = splitColorAndAlpha(stop.color);
|
|
940
|
+
const opAttr = opacity != null && opacity !== 1 ? ` stop-opacity="${num(opacity)}"` : "";
|
|
941
|
+
return `<stop offset="${num(offset)}" stop-color="${color}"${opAttr} />`;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* Split a CSS color into its base color and alpha channel for emission as
|
|
946
|
+
* separate `stop-color` + `stop-opacity` attrs (SVG renderers handle this
|
|
947
|
+
* pair more reliably than embedding the alpha in the color string).
|
|
948
|
+
*/
|
|
949
|
+
function splitColorAndAlpha(color: string): { color: string; opacity: number | null } {
|
|
950
|
+
const rgba = /^rgba\s*\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)\s*\)$/i.exec(color);
|
|
951
|
+
if (rgba != null) {
|
|
952
|
+
return { color: `rgb(${rgba[1]}, ${rgba[2]}, ${rgba[3]})`, opacity: parseFloat(rgba[4]) };
|
|
953
|
+
}
|
|
954
|
+
return { color, opacity: null };
|
|
955
|
+
}
|