@zseven-w/pen-core 0.5.2 → 0.7.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.
Files changed (45) hide show
  1. package/README.md +18 -9
  2. package/package.json +5 -5
  3. package/src/__tests__/arc-path.test.ts +23 -23
  4. package/src/__tests__/codegen-utils.test.ts +50 -0
  5. package/src/__tests__/design-md-parser.test.ts +49 -0
  6. package/src/__tests__/font-utils.test.ts +15 -15
  7. package/src/__tests__/layout-engine.test.ts +169 -83
  8. package/src/__tests__/merge-helpers.test.ts +143 -0
  9. package/src/__tests__/node-diff.test.ts +139 -0
  10. package/src/__tests__/node-helpers.test.ts +19 -19
  11. package/src/__tests__/node-merge.test.ts +425 -0
  12. package/src/__tests__/normalize-stroke-fill-schema.test.ts +416 -0
  13. package/src/__tests__/normalize-tree-layout.test.ts +294 -0
  14. package/src/__tests__/normalize.test.ts +119 -80
  15. package/src/__tests__/path-anchors.test.ts +98 -0
  16. package/src/__tests__/resolve-variables-recursive.test.ts +173 -0
  17. package/src/__tests__/strip-redundant-section-fills.test.ts +278 -0
  18. package/src/__tests__/text-measure.test.ts +84 -79
  19. package/src/__tests__/tree-utils.test.ts +133 -102
  20. package/src/__tests__/unwrap-fake-phone-mockup.test.ts +322 -0
  21. package/src/__tests__/variables.test.ts +68 -65
  22. package/src/arc-path.ts +35 -35
  23. package/src/boolean-ops.ts +158 -111
  24. package/src/constants.ts +36 -36
  25. package/src/design-md-parser.ts +363 -0
  26. package/src/font-utils.ts +30 -15
  27. package/src/id.ts +1 -1
  28. package/src/index.ts +47 -13
  29. package/src/layout/engine.ts +255 -230
  30. package/src/layout/normalize-tree.ts +140 -0
  31. package/src/layout/strip-redundant-section-fills.ts +155 -0
  32. package/src/layout/text-measure.ts +133 -105
  33. package/src/layout/unwrap-fake-phone-mockup.ts +147 -0
  34. package/src/merge/index.ts +16 -0
  35. package/src/merge/merge-helpers.ts +113 -0
  36. package/src/merge/node-diff.ts +123 -0
  37. package/src/merge/node-merge.ts +651 -0
  38. package/src/node-helpers.ts +18 -5
  39. package/src/normalize/normalize-stroke-fill-schema.ts +297 -0
  40. package/src/normalize.ts +79 -79
  41. package/src/path-anchors.ts +331 -0
  42. package/src/sync-lock.ts +3 -3
  43. package/src/tree-utils.ts +180 -158
  44. package/src/variables/replace-refs.ts +63 -60
  45. package/src/variables/resolve.ts +107 -102
@@ -0,0 +1,297 @@
1
+ import type { PenNode, PenFill, PenStroke, SolidFill } from '@zseven-w/pen-types';
2
+
3
+ /**
4
+ * Normalize stroke/fill schema violations commonly emitted by AI sub-agents
5
+ * (MiniMax M2.7, GLM, Kimi) that don't strictly follow the PenNode types.
6
+ *
7
+ * Three classes of schema violation are repaired in place, recursively
8
+ * across the tree:
9
+ *
10
+ * 1. `stroke` as an array of one entry — AI wraps the stroke object in an
11
+ * array as if it were `fill` (which IS an array). We unwrap the first
12
+ * element and continue normalizing it.
13
+ *
14
+ * 2. Stroke value shaped like a `SolidFill` ({ type, color }) instead of
15
+ * a `PenStroke` ({ thickness, fill }). We migrate the inner `color`
16
+ * into a proper `stroke.fill[0]`, pull the `strokeWidth` top-level
17
+ * field (the CSS/SVG-style spelling that many models emit) into
18
+ * `stroke.thickness`, and delete the stray `strokeWidth`. If neither
19
+ * a thickness nor a strokeWidth is present, default to 2 so the
20
+ * stroke actually draws something.
21
+ *
22
+ * 3. Fill entries with illegal CSS-keyword colors (`"none"`, `"transparent"`)
23
+ * are dropped. The 8-digit transparent hex (`"#00000000"`) is valid
24
+ * and kept. The same rule applies to any `stroke.fill[]` entries.
25
+ *
26
+ * Returns nothing — the tree is mutated in place, matching the other
27
+ * pen-core normalize passes. Callers that rely on Zustand publish
28
+ * semantics should route the result through `forcePageResync()` the same
29
+ * way they already do for other mutating post-streaming passes.
30
+ */
31
+ export function normalizeStrokeFillSchema(node: PenNode): void {
32
+ normalizeNodeStroke(node);
33
+ normalizeNodeFill(node);
34
+
35
+ if ('children' in node && Array.isArray(node.children)) {
36
+ for (const child of node.children) {
37
+ normalizeStrokeFillSchema(child);
38
+ }
39
+ }
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Stroke normalization
44
+ // ---------------------------------------------------------------------------
45
+
46
+ interface MaybeStrokeHolder {
47
+ stroke?: unknown;
48
+ strokeWidth?: unknown;
49
+ 'stroke-width'?: unknown;
50
+ 'stroke-dasharray'?: unknown;
51
+ 'stroke-dashoffset'?: unknown;
52
+ 'stroke-linecap'?: unknown;
53
+ 'stroke-linejoin'?: unknown;
54
+ }
55
+
56
+ function normalizeNodeStroke(node: PenNode): void {
57
+ const rec = node as unknown as MaybeStrokeHolder;
58
+ const rawStroke = rec.stroke;
59
+ const hasSvgStrokeAttrs =
60
+ rec['stroke-width'] !== undefined ||
61
+ rec['stroke-dasharray'] !== undefined ||
62
+ rec['stroke-dashoffset'] !== undefined ||
63
+ rec['stroke-linecap'] !== undefined ||
64
+ rec['stroke-linejoin'] !== undefined;
65
+ if ((rawStroke === undefined || rawStroke === null) && !hasSvgStrokeAttrs) return;
66
+
67
+ // (1) Unwrap `stroke: [ ... ]` by taking the first element.
68
+ let stroke: unknown = rawStroke;
69
+ if (typeof stroke === 'string') {
70
+ stroke = { type: 'solid', color: stroke };
71
+ }
72
+ if (Array.isArray(stroke)) {
73
+ stroke = stroke.length > 0 ? stroke[0] : undefined;
74
+ }
75
+ if ((!stroke || typeof stroke !== 'object') && !hasSvgStrokeAttrs) {
76
+ delete rec.stroke;
77
+ delete rec.strokeWidth;
78
+ delete rec['stroke-width'];
79
+ delete rec['stroke-dasharray'];
80
+ delete rec['stroke-dashoffset'];
81
+ delete rec['stroke-linecap'];
82
+ delete rec['stroke-linejoin'];
83
+ return;
84
+ }
85
+
86
+ // (2) Detect the fill-shape-as-stroke pattern and migrate it.
87
+ const maybeFillShape = (stroke ?? {}) as {
88
+ type?: unknown;
89
+ color?: unknown;
90
+ thickness?: unknown;
91
+ fill?: unknown;
92
+ };
93
+ const looksLikeFillShape =
94
+ typeof maybeFillShape.type === 'string' &&
95
+ typeof maybeFillShape.color === 'string' &&
96
+ maybeFillShape.thickness === undefined &&
97
+ maybeFillShape.fill === undefined;
98
+
99
+ if (looksLikeFillShape) {
100
+ const thickness = readThickness(rec);
101
+ rec.stroke = {
102
+ thickness,
103
+ fill: [
104
+ {
105
+ type: 'solid',
106
+ color: maybeFillShape.color as string,
107
+ } as SolidFill,
108
+ ],
109
+ } as PenStroke;
110
+ delete rec.strokeWidth;
111
+ // Now clean illegal color inside the migrated stroke.fill
112
+ stripIllegalColorsFromStrokeFill(node);
113
+ return;
114
+ }
115
+
116
+ // Otherwise we have something that looks like a real PenStroke — fix
117
+ // missing thickness, clean up illegal colors, and persist any
118
+ // strokeWidth field that survived as a top-level property.
119
+ const strokeObj = (stroke ?? {}) as Partial<PenStroke> & { [k: string]: unknown };
120
+ if (strokeObj.thickness === undefined || strokeObj.thickness === null) {
121
+ const width = readThickness(rec);
122
+ (strokeObj as { thickness?: number }).thickness = width;
123
+ }
124
+ const dashPattern = readDashPattern(rec['stroke-dasharray']);
125
+ if (dashPattern && dashPattern.length > 0 && strokeObj.dashPattern === undefined) {
126
+ strokeObj.dashPattern = dashPattern;
127
+ }
128
+ const dashOffset = readDashOffset(rec['stroke-dashoffset']);
129
+ if (dashOffset !== null && strokeObj.dashOffset === undefined) {
130
+ strokeObj.dashOffset = dashOffset;
131
+ }
132
+ const cap = readCap(rec['stroke-linecap']);
133
+ if (cap && strokeObj.cap === undefined) {
134
+ strokeObj.cap = cap;
135
+ }
136
+ const join = readJoin(rec['stroke-linejoin']);
137
+ if (join && strokeObj.join === undefined) {
138
+ strokeObj.join = join;
139
+ }
140
+ if (
141
+ (!Array.isArray(strokeObj.fill) || strokeObj.fill.length === 0) &&
142
+ typeof maybeFillShape.color !== 'string'
143
+ ) {
144
+ const inferredColor = inferStrokeColor(node);
145
+ if (inferredColor) {
146
+ strokeObj.fill = [{ type: 'solid', color: inferredColor }] as PenFill[];
147
+ }
148
+ }
149
+ rec.stroke = strokeObj as PenStroke;
150
+ delete rec.strokeWidth;
151
+ delete rec['stroke-width'];
152
+ delete rec['stroke-dasharray'];
153
+ delete rec['stroke-dashoffset'];
154
+ delete rec['stroke-linecap'];
155
+ delete rec['stroke-linejoin'];
156
+ stripIllegalColorsFromStrokeFill(node);
157
+
158
+ // If after cleanup the stroke has no fill at all, drop the whole stroke.
159
+ const cleaned = rec.stroke as PenStroke | undefined;
160
+ if (cleaned && (!cleaned.fill || cleaned.fill.length === 0)) {
161
+ delete rec.stroke;
162
+ }
163
+ }
164
+
165
+ function readThickness(rec: MaybeStrokeHolder): number {
166
+ const raw = rec.strokeWidth ?? rec['stroke-width'];
167
+ if (typeof raw === 'number' && raw > 0) return raw;
168
+ if (typeof raw === 'string') {
169
+ const n = parseFloat(raw);
170
+ if (Number.isFinite(n) && n > 0) return n;
171
+ }
172
+ return 2;
173
+ }
174
+
175
+ function readDashPattern(raw: unknown): number[] | null {
176
+ if (Array.isArray(raw)) {
177
+ const nums = raw.filter((value): value is number => typeof value === 'number' && value > 0);
178
+ if (nums.length === 1) return [nums[0], nums[0]];
179
+ return nums.length > 0 ? nums : null;
180
+ }
181
+ if (typeof raw === 'string') {
182
+ const nums = raw
183
+ .split(/[,\s]+/)
184
+ .map((part) => parseFloat(part))
185
+ .filter((value) => Number.isFinite(value) && value > 0);
186
+ if (nums.length === 1) return [nums[0], nums[0]];
187
+ return nums.length > 0 ? nums : null;
188
+ }
189
+ return null;
190
+ }
191
+
192
+ function readDashOffset(raw: unknown): number | null {
193
+ if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
194
+ if (typeof raw === 'string') {
195
+ const parsed = parseFloat(raw);
196
+ if (Number.isFinite(parsed)) return parsed;
197
+ }
198
+ return null;
199
+ }
200
+
201
+ function readCap(raw: unknown): PenStroke['cap'] | null {
202
+ return raw === 'round' || raw === 'square' || raw === 'none' ? raw : null;
203
+ }
204
+
205
+ function readJoin(raw: unknown): PenStroke['join'] | null {
206
+ return raw === 'round' || raw === 'bevel' || raw === 'miter' ? raw : null;
207
+ }
208
+
209
+ function inferStrokeColor(node: PenNode): string | null {
210
+ const name = typeof node.name === 'string' ? node.name.toLowerCase() : '';
211
+ if (/track/.test(name)) return '#2A2A2A';
212
+ if (/(progress|chart line|line|curve|wave)/.test(name)) return '#22C55E';
213
+ return null;
214
+ }
215
+
216
+ function stripIllegalColorsFromStrokeFill(node: PenNode): void {
217
+ const rec = node as unknown as { stroke?: { fill?: unknown } };
218
+ const stroke = rec.stroke;
219
+ if (!stroke || typeof stroke !== 'object') return;
220
+ const fillArr = stroke.fill;
221
+ if (!Array.isArray(fillArr)) return;
222
+ (stroke as { fill?: PenFill[] }).fill = fillArr.filter((f) => isLegalFillEntry(f)) as PenFill[];
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // Fill normalization
227
+ // ---------------------------------------------------------------------------
228
+
229
+ /**
230
+ * Explicit transparent hex. Used for SHAPE fills (frame, rectangle,
231
+ * ellipse, path, group…) where "no fill" really means a hollow shape
232
+ * that should let the background show through. We write the 8-digit
233
+ * transparent hex so canvas-object-factory doesn't fall back to an
234
+ * opaque default gray fill.
235
+ */
236
+ const EXPLICIT_TRANSPARENT_FILL: SolidFill = {
237
+ type: 'solid',
238
+ color: '#00000000',
239
+ };
240
+
241
+ /**
242
+ * Node types whose `fill` represents a FOREGROUND color (text color,
243
+ * icon color) rather than a shape's background. On these types an
244
+ * illegal "none" / "transparent" fill is almost certainly a mistake:
245
+ * the user meant "default text color", not "invisible text". Freezing
246
+ * them to #00000000 would hide the content entirely, so we delete the
247
+ * field and let downstream layers (role defaults, button contrast,
248
+ * style inheritance) supply a visible color.
249
+ */
250
+ const FOREGROUND_NODE_TYPES = new Set<string>(['text', 'icon_font']);
251
+
252
+ function normalizeNodeFill(node: PenNode): void {
253
+ const rec = node as unknown as { fill?: unknown };
254
+ const raw = rec.fill;
255
+ if (!raw) return;
256
+ if (!Array.isArray(raw)) return;
257
+ // Separate legal entries from CSS-keyword illegal entries.
258
+ const cleaned = raw.filter((f) => isLegalFillEntry(f));
259
+ if (cleaned.length > 0) {
260
+ rec.fill = cleaned as PenFill[];
261
+ return;
262
+ }
263
+ // Empty in, empty out — leave unchanged.
264
+ if (raw.length === 0) {
265
+ rec.fill = [] as PenFill[];
266
+ return;
267
+ }
268
+ // Every original entry was a CSS keyword ("none" / "transparent").
269
+ // The correct repair depends on whether `fill` is a background or a
270
+ // foreground color on this node type:
271
+ //
272
+ // SHAPE types (frame, rectangle, ellipse, path, group, …)
273
+ // fill = shape background. "no fill" means hollow. Keep the
274
+ // explicit transparent hex so canvas doesn't fall back to the
275
+ // default gray fill.
276
+ //
277
+ // FOREGROUND types (text, icon_font)
278
+ // fill = text/icon color. "no fill" would hide the content —
279
+ // almost certainly not what the AI meant. Delete the field so
280
+ // downstream layers can populate a visible color.
281
+ if (FOREGROUND_NODE_TYPES.has(node.type)) {
282
+ delete rec.fill;
283
+ } else {
284
+ rec.fill = [EXPLICIT_TRANSPARENT_FILL] as PenFill[];
285
+ }
286
+ }
287
+
288
+ /** Reject fill entries whose color is an unsupported CSS keyword. */
289
+ function isLegalFillEntry(entry: unknown): boolean {
290
+ if (!entry || typeof entry !== 'object') return false;
291
+ const e = entry as { type?: unknown; color?: unknown };
292
+ if (e.type === 'solid' && typeof e.color === 'string') {
293
+ const c = e.color.trim().toLowerCase();
294
+ if (c === 'none' || c === 'transparent') return false;
295
+ }
296
+ return true;
297
+ }
package/src/normalize.ts CHANGED
@@ -13,8 +13,8 @@
13
13
  * canvas render time, preserving $variable bindings in the document.
14
14
  */
15
15
 
16
- import type { PenDocument, PenNode } from '@zseven-w/pen-types'
17
- import type { PenFill, PenStroke, GradientStop } from '@zseven-w/pen-types'
16
+ import type { PenDocument, PenNode } from '@zseven-w/pen-types';
17
+ import type { PenFill, PenStroke, GradientStop } from '@zseven-w/pen-types';
18
18
 
19
19
  // ---------------------------------------------------------------------------
20
20
  // Public API
@@ -24,15 +24,15 @@ export function normalizePenDocument(doc: PenDocument): PenDocument {
24
24
  const normalized = {
25
25
  ...doc,
26
26
  children: doc.children.map((n) => normalizeNode(n)),
27
- }
27
+ };
28
28
  // Normalize all pages' children too
29
29
  if (normalized.pages && normalized.pages.length > 0) {
30
30
  normalized.pages = normalized.pages.map((p) => ({
31
31
  ...p,
32
32
  children: p.children.map((n) => normalizeNode(n)),
33
- }))
33
+ }));
34
34
  }
35
- return normalized
35
+ return normalized;
36
36
  }
37
37
 
38
38
  // ---------------------------------------------------------------------------
@@ -40,42 +40,48 @@ export function normalizePenDocument(doc: PenDocument): PenDocument {
40
40
  // ---------------------------------------------------------------------------
41
41
 
42
42
  function normalizeNode(node: PenNode): PenNode {
43
- const out: Record<string, unknown> = { ...node }
43
+ const out: Record<string, unknown> = { ...node };
44
44
 
45
45
  // fill
46
46
  if ('fill' in out && out.fill !== undefined) {
47
- out.fill = normalizeFills(out.fill)
47
+ out.fill = normalizeFills(out.fill);
48
48
  }
49
49
 
50
50
  // stroke
51
51
  if ('stroke' in out && out.stroke != null) {
52
- out.stroke = normalizeStroke(out.stroke as Record<string, unknown>)
52
+ out.stroke = normalizeStroke(out.stroke as Record<string, unknown>);
53
53
  }
54
54
 
55
55
  // effects — pass through (no format changes needed)
56
56
 
57
57
  // sizing
58
- if ('width' in out) out.width = normalizeSizing(out.width)
59
- if ('height' in out) out.height = normalizeSizing(out.height)
58
+ if ('width' in out) out.width = normalizeSizing(out.width);
59
+ if ('height' in out) out.height = normalizeSizing(out.height);
60
60
 
61
61
  // gap — pass through ($variable strings preserved)
62
62
 
63
63
  // padding — normalize array format only (not variable resolution)
64
- if ('padding' in out) out.padding = normalizePadding(out.padding)
64
+ if ('padding' in out) out.padding = normalizePadding(out.padding);
65
65
 
66
66
  // opacity — pass through ($variable strings preserved)
67
67
 
68
+ // text nodes: normalize `text` field to `content` (MCP/CLI use `text`, renderer expects `content`)
69
+ if (out.type === 'text' && !('content' in out) && typeof out.text === 'string') {
70
+ out.content = out.text as string;
71
+ delete out.text;
72
+ }
73
+
68
74
  // icon_font: default to lucide family
69
75
  if (out.type === 'icon_font' && !out.iconFontFamily) {
70
- out.iconFontFamily = 'lucide'
76
+ out.iconFontFamily = 'lucide';
71
77
  }
72
78
 
73
79
  // children
74
80
  if ('children' in out && Array.isArray(out.children)) {
75
- out.children = (out.children as PenNode[]).map((c) => normalizeNode(c))
81
+ out.children = (out.children as PenNode[]).map((c) => normalizeNode(c));
76
82
  }
77
83
 
78
- return out as unknown as PenNode
84
+ return out as unknown as PenNode;
79
85
  }
80
86
 
81
87
  // ---------------------------------------------------------------------------
@@ -83,66 +89,64 @@ function normalizeNode(node: PenNode): PenNode {
83
89
  // ---------------------------------------------------------------------------
84
90
 
85
91
  function normalizeFills(raw: unknown): PenFill[] {
86
- if (!raw) return []
92
+ if (!raw) return [];
87
93
 
88
94
  // String shorthand: "#hex" or "$variable" → solid fill
89
95
  if (typeof raw === 'string') {
90
- return [{ type: 'solid', color: raw }]
96
+ return [{ type: 'solid', color: raw }];
91
97
  }
92
98
 
93
99
  // Array of fills
94
100
  if (Array.isArray(raw)) {
95
- return raw.map((f) => normalizeSingleFill(f)).filter(Boolean) as PenFill[]
101
+ return raw.map((f) => normalizeSingleFill(f)).filter(Boolean) as PenFill[];
96
102
  }
97
103
 
98
104
  // Single fill object
99
105
  if (typeof raw === 'object') {
100
- const f = normalizeSingleFill(raw as Record<string, unknown>)
101
- return f ? [f] : []
106
+ const f = normalizeSingleFill(raw as Record<string, unknown>);
107
+ return f ? [f] : [];
102
108
  }
103
109
 
104
- return []
110
+ return [];
105
111
  }
106
112
 
107
- function normalizeSingleFill(
108
- raw: Record<string, unknown> | string,
109
- ): PenFill | null {
113
+ function normalizeSingleFill(raw: Record<string, unknown> | string): PenFill | null {
110
114
  // String shorthand inside array: "#hex" or "$variable" → solid fill
111
115
  if (typeof raw === 'string') {
112
- return raw ? { type: 'solid', color: raw } : null
116
+ return raw ? { type: 'solid', color: raw } : null;
113
117
  }
114
- if (!raw || typeof raw !== 'object') return null
115
- const t = raw.type as string | undefined
118
+ if (!raw || typeof raw !== 'object') return null;
119
+ const t = raw.type as string | undefined;
116
120
 
117
121
  // Pencil "color" → OpenPencil "solid"
118
122
  if (t === 'color' || t === 'solid') {
119
123
  return {
120
124
  type: 'solid',
121
125
  color: typeof raw.color === 'string' ? raw.color : '#000000',
122
- }
126
+ };
123
127
  }
124
128
 
125
129
  // Pencil "gradient" → split by gradientType
126
130
  if (t === 'gradient') {
127
- const gt = (raw.gradientType as string) ?? 'linear'
128
- const stops = normalizeGradientStops(raw.colors as unknown[])
131
+ const gt = (raw.gradientType as string) ?? 'linear';
132
+ const stops = normalizeGradientStops(raw.colors as unknown[]);
129
133
 
130
134
  if (gt === 'radial') {
131
- const center = raw.center as Record<string, unknown> | undefined
135
+ const center = raw.center as Record<string, unknown> | undefined;
132
136
  return {
133
137
  type: 'radial_gradient',
134
138
  cx: typeof center?.x === 'number' ? center.x : 0.5,
135
139
  cy: typeof center?.y === 'number' ? center.y : 0.5,
136
140
  radius: 0.5,
137
141
  stops,
138
- }
142
+ };
139
143
  }
140
144
  // linear or angular
141
145
  return {
142
146
  type: 'linear_gradient',
143
147
  angle: typeof raw.rotation === 'number' ? raw.rotation : 0,
144
148
  stops,
145
- }
149
+ };
146
150
  }
147
151
 
148
152
  // Already our format
@@ -152,85 +156,81 @@ function normalizeSingleFill(
152
156
  ? normalizeGradientStops(raw.stops as unknown[])
153
157
  : 'colors' in raw
154
158
  ? normalizeGradientStops(raw.colors as unknown[])
155
- : []
156
- return { ...(raw as unknown as PenFill), stops } as PenFill
159
+ : [];
160
+ return { ...(raw as unknown as PenFill), stops } as PenFill;
157
161
  }
158
162
 
159
163
  // Image fill — pass through
160
- if (t === 'image') return raw as unknown as PenFill
164
+ if (t === 'image') return raw as unknown as PenFill;
161
165
 
162
166
  // Fallback: if there's a color field, treat as solid
163
167
  if ('color' in raw) {
164
168
  return {
165
169
  type: 'solid',
166
170
  color: typeof raw.color === 'string' ? raw.color : '#000000',
167
- }
171
+ };
168
172
  }
169
173
 
170
- return null
174
+ return null;
171
175
  }
172
176
 
173
- function normalizeGradientStops(
174
- raw: unknown[] | undefined,
175
- ): GradientStop[] {
176
- if (!Array.isArray(raw) || raw.length === 0) return []
177
+ function normalizeGradientStops(raw: unknown[] | undefined): GradientStop[] {
178
+ if (!Array.isArray(raw) || raw.length === 0) return [];
177
179
 
178
180
  // First pass: parse offsets, collecting which ones are explicitly set
179
181
  const parsed = raw.map((s: unknown) => {
180
- const stop = s as Record<string, unknown>
182
+ const stop = s as Record<string, unknown>;
181
183
  const rawOffset =
182
184
  typeof stop.offset === 'number' && Number.isFinite(stop.offset)
183
185
  ? stop.offset
184
186
  : typeof stop.position === 'number' && Number.isFinite(stop.position)
185
187
  ? stop.position
186
- : null
188
+ : null;
187
189
  // Normalize percentage-format offsets (AI sometimes outputs 0-100 instead of 0-1)
188
- const offset = rawOffset !== null && rawOffset > 1 ? rawOffset / 100 : rawOffset
190
+ const offset = rawOffset !== null && rawOffset > 1 ? rawOffset / 100 : rawOffset;
189
191
  return {
190
192
  offset,
191
193
  color: typeof stop.color === 'string' ? stop.color : '#000000',
192
- }
193
- })
194
+ };
195
+ });
194
196
 
195
197
  // Second pass: auto-distribute any stops that are missing an offset
196
- const n = parsed.length
198
+ const n = parsed.length;
197
199
  return parsed.map((s, i) => ({
198
200
  color: s.color,
199
201
  offset: s.offset !== null ? Math.max(0, Math.min(1, s.offset!)) : i / Math.max(n - 1, 1),
200
- }))
202
+ }));
201
203
  }
202
204
 
203
205
  // ---------------------------------------------------------------------------
204
206
  // Stroke normalization
205
207
  // ---------------------------------------------------------------------------
206
208
 
207
- function normalizeStroke(
208
- raw: Record<string, unknown>,
209
- ): PenStroke | undefined {
210
- if (!raw) return undefined
211
- const out = { ...raw }
209
+ function normalizeStroke(raw: Record<string, unknown>): PenStroke | undefined {
210
+ if (!raw) return undefined;
211
+ const out = { ...raw };
212
212
 
213
213
  // Normalize fill inside stroke
214
214
  if ('fill' in out) {
215
- out.fill = normalizeFills(out.fill)
215
+ out.fill = normalizeFills(out.fill);
216
216
  }
217
217
 
218
218
  // Pencil may use "color" directly on stroke
219
219
  if ('color' in out && typeof out.color === 'string') {
220
- out.fill = [{ type: 'solid', color: out.color as string }]
221
- delete out.color
220
+ out.fill = [{ type: 'solid', color: out.color as string }];
221
+ delete out.color;
222
222
  }
223
223
 
224
224
  // Thickness: leave $variable strings as-is, normalise plain number strings
225
225
  if (typeof out.thickness === 'string') {
226
- const str = out.thickness as string
226
+ const str = out.thickness as string;
227
227
  if (!str.startsWith('$')) {
228
- const num = parseFloat(str)
229
- out.thickness = isNaN(num) ? 1 : num
228
+ const num = parseFloat(str);
229
+ out.thickness = isNaN(num) ? 1 : num;
230
230
  }
231
231
  }
232
232
 
233
- return out as unknown as PenStroke
233
+ return out as unknown as PenStroke;
234
234
  }
235
235
 
236
236
  // ---------------------------------------------------------------------------
@@ -238,46 +238,46 @@ function normalizeStroke(
238
238
  // ---------------------------------------------------------------------------
239
239
 
240
240
  function normalizeSizing(value: unknown): number | string {
241
- if (typeof value === 'number') return value
242
- if (typeof value !== 'string') return 0
241
+ if (typeof value === 'number') return value;
242
+ if (typeof value !== 'string') return 0;
243
243
 
244
244
  // $variable — pass through
245
- if (value.startsWith('$')) return value
245
+ if (value.startsWith('$')) return value;
246
246
 
247
247
  // fill_container must always resolve dynamically from parent dimensions
248
- if (value.startsWith('fill_container')) return 'fill_container'
248
+ if (value.startsWith('fill_container')) return 'fill_container';
249
249
 
250
250
  // fit_content with a hint value: use the hint (more accurate than our estimation)
251
251
  if (value.startsWith('fit_content')) {
252
- const match = value.match(/\((\d+(?:\.\d+)?)\)/)
253
- if (match) return parseFloat(match[1])
254
- return 'fit_content'
252
+ const match = value.match(/\((\d+(?:\.\d+)?)\)/);
253
+ if (match) return parseFloat(match[1]);
254
+ return 'fit_content';
255
255
  }
256
256
 
257
257
  // Try as a plain number string
258
- const num = parseFloat(value)
259
- return isNaN(num) ? 0 : num
258
+ const num = parseFloat(value);
259
+ return isNaN(num) ? 0 : num;
260
260
  }
261
261
 
262
262
  function normalizePadding(
263
263
  value: unknown,
264
264
  ): number | [number, number] | [number, number, number, number] | string | undefined {
265
- if (typeof value === 'number') return value
265
+ if (typeof value === 'number') return value;
266
266
  if (typeof value === 'string') {
267
267
  // $variable — pass through
268
- if (value.startsWith('$')) return value
269
- const num = parseFloat(value)
270
- return isNaN(num) ? 0 : num
268
+ if (value.startsWith('$')) return value;
269
+ const num = parseFloat(value);
270
+ return isNaN(num) ? 0 : num;
271
271
  }
272
272
  if (Array.isArray(value)) {
273
273
  return value.map((v) => {
274
- if (typeof v === 'number') return v
274
+ if (typeof v === 'number') return v;
275
275
  if (typeof v === 'string') {
276
- const num = parseFloat(v)
277
- return isNaN(num) ? 0 : num
276
+ const num = parseFloat(v);
277
+ return isNaN(num) ? 0 : num;
278
278
  }
279
- return 0
280
- }) as [number, number] | [number, number, number, number]
279
+ return 0;
280
+ }) as [number, number] | [number, number, number, number];
281
281
  }
282
- return undefined
282
+ return undefined;
283
283
  }