@zakkster/lite-gradient-studio 1.0.0
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/CHANGELOG.md +105 -0
- package/LICENSE +21 -0
- package/README.md +307 -0
- package/llms.txt +173 -0
- package/package.json +81 -0
- package/src/bake.js +141 -0
- package/src/color-convert.js +233 -0
- package/src/css-emitters.js +86 -0
- package/src/exporters.js +310 -0
- package/src/gradient-parse.js +447 -0
- package/src/index.d.ts +380 -0
- package/src/index.js +38 -0
- package/src/mesh-css.js +113 -0
- package/src/mesh.js +673 -0
- package/src/palette-extract.js +216 -0
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS gradient string → editable Gradient Studio state.
|
|
3
|
+
*
|
|
4
|
+
* Counterpart to the export emitters. Accepts the CSS we produce, plus
|
|
5
|
+
* the broadly-used subset designers paste from other tools:
|
|
6
|
+
*
|
|
7
|
+
* linear-gradient([angle] [in oklch], <color> [pos], ...)
|
|
8
|
+
* radial-gradient([shape] [size] [at <pos>] [in oklch], <color> [pos], ...)
|
|
9
|
+
* conic-gradient([from <angle>] [at <pos>] [in oklch], <color> [pos], ...)
|
|
10
|
+
*
|
|
11
|
+
* Color formats: 3/4/6/8-char hex, rgb(), rgba(), oklch() with optional
|
|
12
|
+
* `/ alpha`, hsl(), hsla(). Named colors are not supported (the table
|
|
13
|
+
* is huge and 99% of pasted gradients use hex or rgb).
|
|
14
|
+
*
|
|
15
|
+
* Returns a normalized state object matching the 1D editor's schema —
|
|
16
|
+
* the app's loadG1d picks it up directly. Throws on malformed input
|
|
17
|
+
* (caller shows a friendly error).
|
|
18
|
+
*
|
|
19
|
+
* Not goals (yet):
|
|
20
|
+
* - Multiple background-image layers separated by top-level comma
|
|
21
|
+
* (the outer parser still splits on commas at depth 0, but only
|
|
22
|
+
* the first gradient is parsed; future versions can layer)
|
|
23
|
+
* - Direction keywords ("to right", "to bottom left") — translated
|
|
24
|
+
* to numeric angles here in a small table; partial coverage
|
|
25
|
+
* - `<length>` positions (px, em). Only `<percentage>` accepted.
|
|
26
|
+
* - Color spaces other than oklch. The `in <space>` modifier is
|
|
27
|
+
* parsed but currently ignored — Studio always interpolates in OKLCH.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { fromHex } from './color-convert.js';
|
|
31
|
+
|
|
32
|
+
/* ── public entry ─────────────────────────────────────────────── */
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse a CSS gradient string. Returns a 1D-state-shaped object:
|
|
36
|
+
* { mode, stops, angle?, radShape?, radCx?, radCy?, conFrom?, conCx?, conCy? }
|
|
37
|
+
*
|
|
38
|
+
* The fields not relevant to the parsed mode are left undefined (e.g.
|
|
39
|
+
* radShape is undefined for linear gradients).
|
|
40
|
+
*
|
|
41
|
+
* @throws Error on malformed input — message is user-facing.
|
|
42
|
+
*/
|
|
43
|
+
export function parseGradientCss(input) {
|
|
44
|
+
if (typeof input !== 'string') throw new Error('parseGradientCss: expected a string');
|
|
45
|
+
// Trim any leading `background:` / `background-image:` / trailing `;`
|
|
46
|
+
// — paste convenience.
|
|
47
|
+
let src = input.trim();
|
|
48
|
+
src = src.replace(/^background(?:-image)?\s*:\s*/i, '');
|
|
49
|
+
src = src.replace(/;[\s\n]*$/, '');
|
|
50
|
+
src = src.trim();
|
|
51
|
+
|
|
52
|
+
// Detect mode + extract inside of outer parens.
|
|
53
|
+
const m = /^(linear|radial|conic)-gradient\s*\(([\s\S]+)\)\s*$/i.exec(src);
|
|
54
|
+
if (!m) throw new Error('parseGradientCss: not a CSS gradient function');
|
|
55
|
+
const mode = m[1].toLowerCase();
|
|
56
|
+
const body = m[2];
|
|
57
|
+
|
|
58
|
+
// Top-level comma split (respecting nested parens — color functions
|
|
59
|
+
// contain commas).
|
|
60
|
+
const parts = splitTopLevel(body);
|
|
61
|
+
if (parts.length < 2) {
|
|
62
|
+
throw new Error('parseGradientCss: need at least two color stops');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// First part: either a direction/shape preamble or already a color.
|
|
66
|
+
// We sniff by attempting to parse the first part as a color — if it
|
|
67
|
+
// succeeds, it's a color (any format we support); if it throws, it
|
|
68
|
+
// must be a preamble. This catches named colors that a regex-based
|
|
69
|
+
// sniff would miss (`"red"` doesn't start with `#`/`rgb`/`oklch`).
|
|
70
|
+
const firstTrim = parts[0].trim();
|
|
71
|
+
const { color: firstColorPart } = splitColorAndPos(firstTrim);
|
|
72
|
+
let looksLikeColor = false;
|
|
73
|
+
try { parseColor(firstColorPart); looksLikeColor = true; }
|
|
74
|
+
catch { /* not a color → preamble */ }
|
|
75
|
+
|
|
76
|
+
let preamble = '';
|
|
77
|
+
let stopParts;
|
|
78
|
+
if (looksLikeColor) {
|
|
79
|
+
// No preamble — implicit defaults for the mode.
|
|
80
|
+
stopParts = parts;
|
|
81
|
+
} else {
|
|
82
|
+
preamble = firstTrim;
|
|
83
|
+
stopParts = parts.slice(1);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (stopParts.length < 2) {
|
|
87
|
+
throw new Error('parseGradientCss: need at least two color stops');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const stops = parseStops(stopParts, mode);
|
|
91
|
+
|
|
92
|
+
if (mode === 'linear') {
|
|
93
|
+
return { mode, stops, angle: parseLinearAngle(preamble) };
|
|
94
|
+
}
|
|
95
|
+
if (mode === 'radial') {
|
|
96
|
+
const { shape, cx, cy } = parseRadialPreamble(preamble);
|
|
97
|
+
return { mode, stops, radShape: shape, radCx: cx, radCy: cy };
|
|
98
|
+
}
|
|
99
|
+
// conic
|
|
100
|
+
const { from, cx, cy } = parseConicPreamble(preamble);
|
|
101
|
+
return { mode, stops, conFrom: from, conCx: cx, conCy: cy };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* ── splitting utilities ───────────────────────────────────────── */
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Split on commas that aren't inside parens. Color functions like
|
|
108
|
+
* `rgba(255, 0, 0, 0.5)` contain commas we don't want to split on.
|
|
109
|
+
*/
|
|
110
|
+
function splitTopLevel(s) {
|
|
111
|
+
const parts = [];
|
|
112
|
+
let depth = 0;
|
|
113
|
+
let start = 0;
|
|
114
|
+
for (let i = 0; i < s.length; i++) {
|
|
115
|
+
const ch = s[i];
|
|
116
|
+
if (ch === '(') depth++;
|
|
117
|
+
else if (ch === ')') depth--;
|
|
118
|
+
else if (ch === ',' && depth === 0) {
|
|
119
|
+
parts.push(s.slice(start, i));
|
|
120
|
+
start = i + 1;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
parts.push(s.slice(start));
|
|
124
|
+
return parts;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* ── stop parsing ─────────────────────────────────────────────── */
|
|
128
|
+
|
|
129
|
+
function parseStops(parts, mode) {
|
|
130
|
+
const stops = [];
|
|
131
|
+
const positionUnit = mode === 'conic' ? 'deg' : '%';
|
|
132
|
+
// For conic, positions are angles 0..360deg. For linear/radial they
|
|
133
|
+
// are percentages 0..100%. Both normalize to 0..1 in our stop schema.
|
|
134
|
+
|
|
135
|
+
for (const part of parts) {
|
|
136
|
+
const { color, position } = splitColorAndPos(part.trim());
|
|
137
|
+
const oklch = parseColor(color);
|
|
138
|
+
stops.push({
|
|
139
|
+
l: oklch.l, c: oklch.c, h: oklch.h,
|
|
140
|
+
a: oklch.a === undefined ? 1 : oklch.a,
|
|
141
|
+
// Position deferred — may be missing on some stops; fill in
|
|
142
|
+
// a second pass below.
|
|
143
|
+
stop: position === null
|
|
144
|
+
? null
|
|
145
|
+
: normalizeStopPos(position, positionUnit, mode),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Fill in missing positions: evenly distribute among unset ones,
|
|
150
|
+
// bracketed by their neighbours. Common pattern: paste
|
|
151
|
+
// "linear-gradient(red, blue)" → no positions at all.
|
|
152
|
+
fillMissingPositions(stops);
|
|
153
|
+
return stops;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Split a stop-spec into color + optional position. The color may
|
|
158
|
+
* itself contain spaces (e.g. `oklch(0.5 0.2 90)`), so we look for the
|
|
159
|
+
* LAST whitespace-separated token and check if it's a position.
|
|
160
|
+
*/
|
|
161
|
+
function splitColorAndPos(s) {
|
|
162
|
+
// Strip trailing whitespace.
|
|
163
|
+
s = s.trim();
|
|
164
|
+
// Search backwards for a position token: <number><unit>
|
|
165
|
+
const m = /(.+?)\s+(-?\d+(?:\.\d+)?(?:%|deg|turn|rad))\s*$/.exec(s);
|
|
166
|
+
if (m) return { color: m[1].trim(), position: m[2] };
|
|
167
|
+
return { color: s, position: null };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Convert a position token like '50%' / '0.25turn' / '90deg' to 0..1. */
|
|
171
|
+
function normalizeStopPos(pos, unit, mode) {
|
|
172
|
+
const m = /^(-?\d+(?:\.\d+)?)(%|deg|turn|rad)$/.exec(pos);
|
|
173
|
+
if (!m) throw new Error(`parseGradientCss: malformed position "${pos}"`);
|
|
174
|
+
const n = parseFloat(m[1]);
|
|
175
|
+
const u = m[2];
|
|
176
|
+
let normalized;
|
|
177
|
+
if (u === '%') normalized = n / 100;
|
|
178
|
+
else if (u === 'deg') normalized = n / 360;
|
|
179
|
+
else if (u === 'turn') normalized = n;
|
|
180
|
+
else normalized = n / (2 * Math.PI);
|
|
181
|
+
// For linear/radial we ignore deg units (they'd be unusual; bug if
|
|
182
|
+
// it happens). For conic, we want the deg/turn interpretation.
|
|
183
|
+
// Either way, clamp to [0, 1] so out-of-range positions don't break
|
|
184
|
+
// the rail.
|
|
185
|
+
return Math.max(0, Math.min(1, normalized));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function fillMissingPositions(stops) {
|
|
189
|
+
// Walk runs of null positions, distribute evenly between the last
|
|
190
|
+
// known position (or 0) and the next known position (or 1).
|
|
191
|
+
const n = stops.length;
|
|
192
|
+
if (stops[0].stop === null) stops[0].stop = 0;
|
|
193
|
+
if (stops[n - 1].stop === null) stops[n - 1].stop = 1;
|
|
194
|
+
let i = 0;
|
|
195
|
+
while (i < n) {
|
|
196
|
+
if (stops[i].stop !== null) { i++; continue; }
|
|
197
|
+
// Find run end.
|
|
198
|
+
let j = i;
|
|
199
|
+
while (j < n && stops[j].stop === null) j++;
|
|
200
|
+
// stops[i-1].stop is the anchor before, stops[j].stop the anchor after.
|
|
201
|
+
const before = stops[i - 1].stop;
|
|
202
|
+
const after = stops[j].stop;
|
|
203
|
+
const count = j - i + 1; // including anchors at j
|
|
204
|
+
for (let k = i; k < j; k++) {
|
|
205
|
+
stops[k].stop = before + (after - before) * ((k - i + 1) / count);
|
|
206
|
+
}
|
|
207
|
+
i = j;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/* ── color parsing ────────────────────────────────────────────── */
|
|
212
|
+
|
|
213
|
+
function parseColor(s) {
|
|
214
|
+
const t = s.trim();
|
|
215
|
+
|
|
216
|
+
// Hex
|
|
217
|
+
if (t.startsWith('#')) return fromHex(t);
|
|
218
|
+
|
|
219
|
+
// rgb()/rgba()
|
|
220
|
+
let m = /^rgba?\s*\(([^)]+)\)\s*$/i.exec(t);
|
|
221
|
+
if (m) return rgbFunctionToOklch(m[1]);
|
|
222
|
+
|
|
223
|
+
// oklch()
|
|
224
|
+
m = /^oklch\s*\(([^)]+)\)\s*$/i.exec(t);
|
|
225
|
+
if (m) return oklchFunctionToObj(m[1]);
|
|
226
|
+
|
|
227
|
+
// hsl()/hsla()
|
|
228
|
+
m = /^hsla?\s*\(([^)]+)\)\s*$/i.exec(t);
|
|
229
|
+
if (m) return hslFunctionToOklch(m[1]);
|
|
230
|
+
|
|
231
|
+
// Named color — small table covering the CSS Level 1 + a few
|
|
232
|
+
// extensions designers actually paste. Not exhaustive; if a user
|
|
233
|
+
// needs hotpink they'll paste hex.
|
|
234
|
+
const named = NAMED_COLORS[t.toLowerCase()];
|
|
235
|
+
if (named) return fromHex(named);
|
|
236
|
+
|
|
237
|
+
throw new Error(`parseGradientCss: unsupported color "${t}"`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const NAMED_COLORS = {
|
|
241
|
+
black: '#000000',
|
|
242
|
+
silver: '#c0c0c0',
|
|
243
|
+
gray: '#808080',
|
|
244
|
+
grey: '#808080',
|
|
245
|
+
white: '#ffffff',
|
|
246
|
+
maroon: '#800000',
|
|
247
|
+
red: '#ff0000',
|
|
248
|
+
purple: '#800080',
|
|
249
|
+
fuchsia: '#ff00ff',
|
|
250
|
+
magenta: '#ff00ff',
|
|
251
|
+
// CSS "green" is dark green; designers usually mean bright. Map
|
|
252
|
+
// "green" to CSS-spec dark green and provide "lime" for bright.
|
|
253
|
+
green: '#008000',
|
|
254
|
+
lime: '#00ff00',
|
|
255
|
+
olive: '#808000',
|
|
256
|
+
yellow: '#ffff00',
|
|
257
|
+
navy: '#000080',
|
|
258
|
+
blue: '#0000ff',
|
|
259
|
+
teal: '#008080',
|
|
260
|
+
aqua: '#00ffff',
|
|
261
|
+
cyan: '#00ffff',
|
|
262
|
+
orange: '#ffa500',
|
|
263
|
+
pink: '#ffc0cb',
|
|
264
|
+
brown: '#a52a2a',
|
|
265
|
+
transparent: '#00000000',
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
function rgbFunctionToOklch(inner) {
|
|
269
|
+
// Accept comma OR space separators, optional alpha with `/` or `,`.
|
|
270
|
+
const tokens = inner.split(/[\s,\/]+/).filter(Boolean);
|
|
271
|
+
if (tokens.length < 3) throw new Error('parseGradientCss: malformed rgb()');
|
|
272
|
+
const r = parseColorComponent(tokens[0], 255);
|
|
273
|
+
const g = parseColorComponent(tokens[1], 255);
|
|
274
|
+
const b = parseColorComponent(tokens[2], 255);
|
|
275
|
+
const a = tokens[3] !== undefined
|
|
276
|
+
? parseColorComponent(tokens[3], 1, true)
|
|
277
|
+
: 1;
|
|
278
|
+
// Build hex and reuse fromHex for the RGB→OKLCH path.
|
|
279
|
+
const hex = '#' + (
|
|
280
|
+
((r * 255 + 0.5) | 0).toString(16).padStart(2, '0') +
|
|
281
|
+
((g * 255 + 0.5) | 0).toString(16).padStart(2, '0') +
|
|
282
|
+
((b * 255 + 0.5) | 0).toString(16).padStart(2, '0')
|
|
283
|
+
);
|
|
284
|
+
const oklch = fromHex(hex);
|
|
285
|
+
oklch.a = a;
|
|
286
|
+
return oklch;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function oklchFunctionToObj(inner) {
|
|
290
|
+
// oklch(L C H [/ A]) — L can be "50%" or "0.5", others are numbers.
|
|
291
|
+
// Split on whitespace; the optional `/` denotes alpha.
|
|
292
|
+
const slashIdx = inner.indexOf('/');
|
|
293
|
+
const main = slashIdx >= 0 ? inner.slice(0, slashIdx) : inner;
|
|
294
|
+
const alphaPart = slashIdx >= 0 ? inner.slice(slashIdx + 1) : null;
|
|
295
|
+
const tokens = main.trim().split(/\s+/);
|
|
296
|
+
if (tokens.length < 3) throw new Error('parseGradientCss: malformed oklch()');
|
|
297
|
+
const l = parseColorComponent(tokens[0], 1, true); // 0..1 or 0..100%
|
|
298
|
+
const c = parseFloat(tokens[1]); // chroma 0..0.5ish
|
|
299
|
+
const h = parseFloat(tokens[2]); // hue 0..360
|
|
300
|
+
const a = alphaPart !== null
|
|
301
|
+
? parseColorComponent(alphaPart.trim(), 1, true)
|
|
302
|
+
: 1;
|
|
303
|
+
return { l, c, h, a };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function hslFunctionToOklch(inner) {
|
|
307
|
+
const tokens = inner.split(/[\s,\/]+/).filter(Boolean);
|
|
308
|
+
if (tokens.length < 3) throw new Error('parseGradientCss: malformed hsl()');
|
|
309
|
+
let h = parseFloat(tokens[0]);
|
|
310
|
+
// Strip 'deg'/'turn'/'rad' unit suffix on hue.
|
|
311
|
+
if (/turn/i.test(tokens[0])) h *= 360;
|
|
312
|
+
if (/rad/i.test(tokens[0])) h *= 180 / Math.PI;
|
|
313
|
+
const s = parseColorComponent(tokens[1], 1, true);
|
|
314
|
+
const lt = parseColorComponent(tokens[2], 1, true);
|
|
315
|
+
const a = tokens[3] !== undefined ? parseColorComponent(tokens[3], 1, true) : 1;
|
|
316
|
+
// HSL → RGB → hex → fromHex (same path rgb uses).
|
|
317
|
+
const [r, g, b] = hslToRgb(h, s, lt);
|
|
318
|
+
const hex = '#' + (
|
|
319
|
+
((r * 255 + 0.5) | 0).toString(16).padStart(2, '0') +
|
|
320
|
+
((g * 255 + 0.5) | 0).toString(16).padStart(2, '0') +
|
|
321
|
+
((b * 255 + 0.5) | 0).toString(16).padStart(2, '0')
|
|
322
|
+
);
|
|
323
|
+
const oklch = fromHex(hex);
|
|
324
|
+
oklch.a = a;
|
|
325
|
+
return oklch;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function hslToRgb(h, s, l) {
|
|
329
|
+
h = ((h % 360) + 360) % 360;
|
|
330
|
+
const c = (1 - Math.abs(2 * l - 1)) * s;
|
|
331
|
+
const hp = h / 60;
|
|
332
|
+
const x = c * (1 - Math.abs((hp % 2) - 1));
|
|
333
|
+
let r1, g1, b1;
|
|
334
|
+
if (hp <= 1) { r1 = c; g1 = x; b1 = 0; }
|
|
335
|
+
else if (hp <= 2) { r1 = x; g1 = c; b1 = 0; }
|
|
336
|
+
else if (hp <= 3) { r1 = 0; g1 = c; b1 = x; }
|
|
337
|
+
else if (hp <= 4) { r1 = 0; g1 = x; b1 = c; }
|
|
338
|
+
else if (hp <= 5) { r1 = x; g1 = 0; b1 = c; }
|
|
339
|
+
else { r1 = c; g1 = 0; b1 = x; }
|
|
340
|
+
const mLight = l - c / 2;
|
|
341
|
+
return [r1 + mLight, g1 + mLight, b1 + mLight];
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Parse a single component which may be "127", "50%", or "0.5".
|
|
346
|
+
* @param {string} tok
|
|
347
|
+
* @param {number} max Range max when the value is not a percentage.
|
|
348
|
+
* @param {boolean} [floatOK] If true, treat bare values as already-normalized 0..1.
|
|
349
|
+
*/
|
|
350
|
+
function parseColorComponent(tok, max, floatOK) {
|
|
351
|
+
if (tok.endsWith('%')) return parseFloat(tok) / 100;
|
|
352
|
+
const v = parseFloat(tok);
|
|
353
|
+
if (floatOK && v <= 1) return v;
|
|
354
|
+
return v / max;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/* ── preamble parsers (per mode) ──────────────────────────────── */
|
|
358
|
+
|
|
359
|
+
const DIRECTION_KEYWORDS = {
|
|
360
|
+
'to top': 0,
|
|
361
|
+
'to top right': 45,
|
|
362
|
+
'to right': 90,
|
|
363
|
+
'to bottom right': 135,
|
|
364
|
+
'to bottom': 180,
|
|
365
|
+
'to bottom left': 225,
|
|
366
|
+
'to left': 270,
|
|
367
|
+
'to top left': 315,
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
function parseLinearAngle(preamble) {
|
|
371
|
+
if (!preamble) return 180; // CSS default for linear-gradient
|
|
372
|
+
// Drop the `in <space>` modifier if present.
|
|
373
|
+
const clean = preamble.replace(/\s+in\s+\S+/i, '').trim();
|
|
374
|
+
if (!clean) return 180;
|
|
375
|
+
// Direction keyword?
|
|
376
|
+
const kw = DIRECTION_KEYWORDS[clean.toLowerCase()];
|
|
377
|
+
if (kw !== undefined) return kw;
|
|
378
|
+
// <angle> — accept deg, turn, rad.
|
|
379
|
+
const m = /^(-?\d+(?:\.\d+)?)(deg|turn|rad)?\s*$/.exec(clean);
|
|
380
|
+
if (!m) return 180;
|
|
381
|
+
let n = parseFloat(m[1]);
|
|
382
|
+
if (m[2] === 'turn') n *= 360;
|
|
383
|
+
if (m[2] === 'rad') n *= 180 / Math.PI;
|
|
384
|
+
// Wrap into 0..360.
|
|
385
|
+
return ((n % 360) + 360) % 360;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function parseRadialPreamble(preamble) {
|
|
389
|
+
let shape = 'ellipse';
|
|
390
|
+
let cx = 50, cy = 50;
|
|
391
|
+
if (!preamble) return { shape, cx, cy };
|
|
392
|
+
// Drop `in <space>`.
|
|
393
|
+
const clean = preamble.replace(/\s+in\s+\S+/i, '').trim();
|
|
394
|
+
if (/\bcircle\b/i.test(clean)) shape = 'circle';
|
|
395
|
+
if (/\bellipse\b/i.test(clean)) shape = 'ellipse';
|
|
396
|
+
// Extract position from `at <x> <y>`.
|
|
397
|
+
const m = /\bat\s+([^,]+)$/i.exec(clean);
|
|
398
|
+
if (m) {
|
|
399
|
+
const pos = parsePosition(m[1].trim());
|
|
400
|
+
cx = pos.cx;
|
|
401
|
+
cy = pos.cy;
|
|
402
|
+
}
|
|
403
|
+
return { shape, cx, cy };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function parseConicPreamble(preamble) {
|
|
407
|
+
let from = 0;
|
|
408
|
+
let cx = 50, cy = 50;
|
|
409
|
+
if (!preamble) return { from, cx, cy };
|
|
410
|
+
const clean = preamble.replace(/\s+in\s+\S+/i, '').trim();
|
|
411
|
+
const fromMatch = /\bfrom\s+(-?\d+(?:\.\d+)?)(deg|turn|rad)?/i.exec(clean);
|
|
412
|
+
if (fromMatch) {
|
|
413
|
+
let n = parseFloat(fromMatch[1]);
|
|
414
|
+
if (fromMatch[2] === 'turn') n *= 360;
|
|
415
|
+
if (fromMatch[2] === 'rad') n *= 180 / Math.PI;
|
|
416
|
+
from = ((n % 360) + 360) % 360;
|
|
417
|
+
}
|
|
418
|
+
const atMatch = /\bat\s+(.+)$/i.exec(clean);
|
|
419
|
+
if (atMatch) {
|
|
420
|
+
const pos = parsePosition(atMatch[1].trim());
|
|
421
|
+
cx = pos.cx;
|
|
422
|
+
cy = pos.cy;
|
|
423
|
+
}
|
|
424
|
+
return { from, cx, cy };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* `<position>` → { cx, cy } in 0..100 percent. Accepts keywords
|
|
429
|
+
* (center, top, etc.), single-axis keyword pairs, and `<percentage>
|
|
430
|
+
* <percentage>` numeric pairs.
|
|
431
|
+
*/
|
|
432
|
+
function parsePosition(s) {
|
|
433
|
+
const t = s.trim().toLowerCase();
|
|
434
|
+
// Numeric: "30% 70%" or "50% 50%"
|
|
435
|
+
const num = /^(-?\d+(?:\.\d+)?)\s*%\s+(-?\d+(?:\.\d+)?)\s*%$/.exec(t);
|
|
436
|
+
if (num) return { cx: parseFloat(num[1]), cy: parseFloat(num[2]) };
|
|
437
|
+
// Single percent: "30%" — y defaults to 50.
|
|
438
|
+
const single = /^(-?\d+(?:\.\d+)?)\s*%$/.exec(t);
|
|
439
|
+
if (single) return { cx: parseFloat(single[1]), cy: 50 };
|
|
440
|
+
// Keyword pair or single keyword.
|
|
441
|
+
let cx = 50, cy = 50;
|
|
442
|
+
if (t.includes('left')) cx = 0;
|
|
443
|
+
if (t.includes('right')) cx = 100;
|
|
444
|
+
if (t.includes('top')) cy = 0;
|
|
445
|
+
if (t.includes('bottom')) cy = 100;
|
|
446
|
+
return { cx, cy };
|
|
447
|
+
}
|