domotion-svg 0.1.1

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