@zseven-w/pen-core 0.6.0 → 0.7.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 (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +177 -25
  3. package/package.json +19 -5
  4. package/src/__tests__/arc-path.test.ts +23 -23
  5. package/src/__tests__/codegen-utils.test.ts +50 -0
  6. package/src/__tests__/design-md-parser.test.ts +49 -0
  7. package/src/__tests__/font-utils.test.ts +15 -15
  8. package/src/__tests__/layout-engine.test.ts +169 -83
  9. package/src/__tests__/merge-helpers.test.ts +143 -0
  10. package/src/__tests__/node-diff.test.ts +139 -0
  11. package/src/__tests__/node-helpers.test.ts +19 -19
  12. package/src/__tests__/node-merge.test.ts +425 -0
  13. package/src/__tests__/normalize-stroke-fill-schema.test.ts +416 -0
  14. package/src/__tests__/normalize-tree-layout.test.ts +294 -0
  15. package/src/__tests__/normalize.test.ts +119 -80
  16. package/src/__tests__/path-anchors.test.ts +98 -0
  17. package/src/__tests__/resolve-variables-recursive.test.ts +109 -54
  18. package/src/__tests__/strip-redundant-section-fills.test.ts +278 -0
  19. package/src/__tests__/text-measure.test.ts +84 -79
  20. package/src/__tests__/tree-utils.test.ts +133 -102
  21. package/src/__tests__/unwrap-fake-phone-mockup.test.ts +322 -0
  22. package/src/__tests__/variables.test.ts +68 -65
  23. package/src/arc-path.ts +35 -35
  24. package/src/boolean-ops.ts +131 -142
  25. package/src/constants.ts +36 -36
  26. package/src/design-md-parser.ts +363 -0
  27. package/src/font-utils.ts +30 -15
  28. package/src/id.ts +1 -1
  29. package/src/index.ts +47 -13
  30. package/src/layout/engine.ts +255 -224
  31. package/src/layout/normalize-tree.ts +140 -0
  32. package/src/layout/strip-redundant-section-fills.ts +155 -0
  33. package/src/layout/text-measure.ts +130 -106
  34. package/src/layout/unwrap-fake-phone-mockup.ts +147 -0
  35. package/src/merge/index.ts +16 -0
  36. package/src/merge/merge-helpers.ts +113 -0
  37. package/src/merge/node-diff.ts +123 -0
  38. package/src/merge/node-merge.ts +651 -0
  39. package/src/node-helpers.ts +18 -5
  40. package/src/normalize/normalize-stroke-fill-schema.ts +297 -0
  41. package/src/normalize.ts +75 -81
  42. package/src/path-anchors.ts +331 -0
  43. package/src/sync-lock.ts +3 -3
  44. package/src/tree-utils.ts +180 -158
  45. package/src/variables/replace-refs.ts +63 -60
  46. package/src/variables/resolve.ts +98 -106
@@ -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,48 +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
68
  // text nodes: normalize `text` field to `content` (MCP/CLI use `text`, renderer expects `content`)
69
69
  if (out.type === 'text' && !('content' in out) && typeof out.text === 'string') {
70
- out.content = out.text as string
71
- delete out.text
70
+ out.content = out.text as string;
71
+ delete out.text;
72
72
  }
73
73
 
74
74
  // icon_font: default to lucide family
75
75
  if (out.type === 'icon_font' && !out.iconFontFamily) {
76
- out.iconFontFamily = 'lucide'
76
+ out.iconFontFamily = 'lucide';
77
77
  }
78
78
 
79
79
  // children
80
80
  if ('children' in out && Array.isArray(out.children)) {
81
- out.children = (out.children as PenNode[]).map((c) => normalizeNode(c))
81
+ out.children = (out.children as PenNode[]).map((c) => normalizeNode(c));
82
82
  }
83
83
 
84
- return out as unknown as PenNode
84
+ return out as unknown as PenNode;
85
85
  }
86
86
 
87
87
  // ---------------------------------------------------------------------------
@@ -89,66 +89,64 @@ function normalizeNode(node: PenNode): PenNode {
89
89
  // ---------------------------------------------------------------------------
90
90
 
91
91
  function normalizeFills(raw: unknown): PenFill[] {
92
- if (!raw) return []
92
+ if (!raw) return [];
93
93
 
94
94
  // String shorthand: "#hex" or "$variable" → solid fill
95
95
  if (typeof raw === 'string') {
96
- return [{ type: 'solid', color: raw }]
96
+ return [{ type: 'solid', color: raw }];
97
97
  }
98
98
 
99
99
  // Array of fills
100
100
  if (Array.isArray(raw)) {
101
- return raw.map((f) => normalizeSingleFill(f)).filter(Boolean) as PenFill[]
101
+ return raw.map((f) => normalizeSingleFill(f)).filter(Boolean) as PenFill[];
102
102
  }
103
103
 
104
104
  // Single fill object
105
105
  if (typeof raw === 'object') {
106
- const f = normalizeSingleFill(raw as Record<string, unknown>)
107
- return f ? [f] : []
106
+ const f = normalizeSingleFill(raw as Record<string, unknown>);
107
+ return f ? [f] : [];
108
108
  }
109
109
 
110
- return []
110
+ return [];
111
111
  }
112
112
 
113
- function normalizeSingleFill(
114
- raw: Record<string, unknown> | string,
115
- ): PenFill | null {
113
+ function normalizeSingleFill(raw: Record<string, unknown> | string): PenFill | null {
116
114
  // String shorthand inside array: "#hex" or "$variable" → solid fill
117
115
  if (typeof raw === 'string') {
118
- return raw ? { type: 'solid', color: raw } : null
116
+ return raw ? { type: 'solid', color: raw } : null;
119
117
  }
120
- if (!raw || typeof raw !== 'object') return null
121
- const t = raw.type as string | undefined
118
+ if (!raw || typeof raw !== 'object') return null;
119
+ const t = raw.type as string | undefined;
122
120
 
123
121
  // Pencil "color" → OpenPencil "solid"
124
122
  if (t === 'color' || t === 'solid') {
125
123
  return {
126
124
  type: 'solid',
127
125
  color: typeof raw.color === 'string' ? raw.color : '#000000',
128
- }
126
+ };
129
127
  }
130
128
 
131
129
  // Pencil "gradient" → split by gradientType
132
130
  if (t === 'gradient') {
133
- const gt = (raw.gradientType as string) ?? 'linear'
134
- const stops = normalizeGradientStops(raw.colors as unknown[])
131
+ const gt = (raw.gradientType as string) ?? 'linear';
132
+ const stops = normalizeGradientStops(raw.colors as unknown[]);
135
133
 
136
134
  if (gt === 'radial') {
137
- const center = raw.center as Record<string, unknown> | undefined
135
+ const center = raw.center as Record<string, unknown> | undefined;
138
136
  return {
139
137
  type: 'radial_gradient',
140
138
  cx: typeof center?.x === 'number' ? center.x : 0.5,
141
139
  cy: typeof center?.y === 'number' ? center.y : 0.5,
142
140
  radius: 0.5,
143
141
  stops,
144
- }
142
+ };
145
143
  }
146
144
  // linear or angular
147
145
  return {
148
146
  type: 'linear_gradient',
149
147
  angle: typeof raw.rotation === 'number' ? raw.rotation : 0,
150
148
  stops,
151
- }
149
+ };
152
150
  }
153
151
 
154
152
  // Already our format
@@ -158,85 +156,81 @@ function normalizeSingleFill(
158
156
  ? normalizeGradientStops(raw.stops as unknown[])
159
157
  : 'colors' in raw
160
158
  ? normalizeGradientStops(raw.colors as unknown[])
161
- : []
162
- return { ...(raw as unknown as PenFill), stops } as PenFill
159
+ : [];
160
+ return { ...(raw as unknown as PenFill), stops } as PenFill;
163
161
  }
164
162
 
165
163
  // Image fill — pass through
166
- if (t === 'image') return raw as unknown as PenFill
164
+ if (t === 'image') return raw as unknown as PenFill;
167
165
 
168
166
  // Fallback: if there's a color field, treat as solid
169
167
  if ('color' in raw) {
170
168
  return {
171
169
  type: 'solid',
172
170
  color: typeof raw.color === 'string' ? raw.color : '#000000',
173
- }
171
+ };
174
172
  }
175
173
 
176
- return null
174
+ return null;
177
175
  }
178
176
 
179
- function normalizeGradientStops(
180
- raw: unknown[] | undefined,
181
- ): GradientStop[] {
182
- if (!Array.isArray(raw) || raw.length === 0) return []
177
+ function normalizeGradientStops(raw: unknown[] | undefined): GradientStop[] {
178
+ if (!Array.isArray(raw) || raw.length === 0) return [];
183
179
 
184
180
  // First pass: parse offsets, collecting which ones are explicitly set
185
181
  const parsed = raw.map((s: unknown) => {
186
- const stop = s as Record<string, unknown>
182
+ const stop = s as Record<string, unknown>;
187
183
  const rawOffset =
188
184
  typeof stop.offset === 'number' && Number.isFinite(stop.offset)
189
185
  ? stop.offset
190
186
  : typeof stop.position === 'number' && Number.isFinite(stop.position)
191
187
  ? stop.position
192
- : null
188
+ : null;
193
189
  // Normalize percentage-format offsets (AI sometimes outputs 0-100 instead of 0-1)
194
- const offset = rawOffset !== null && rawOffset > 1 ? rawOffset / 100 : rawOffset
190
+ const offset = rawOffset !== null && rawOffset > 1 ? rawOffset / 100 : rawOffset;
195
191
  return {
196
192
  offset,
197
193
  color: typeof stop.color === 'string' ? stop.color : '#000000',
198
- }
199
- })
194
+ };
195
+ });
200
196
 
201
197
  // Second pass: auto-distribute any stops that are missing an offset
202
- const n = parsed.length
198
+ const n = parsed.length;
203
199
  return parsed.map((s, i) => ({
204
200
  color: s.color,
205
201
  offset: s.offset !== null ? Math.max(0, Math.min(1, s.offset!)) : i / Math.max(n - 1, 1),
206
- }))
202
+ }));
207
203
  }
208
204
 
209
205
  // ---------------------------------------------------------------------------
210
206
  // Stroke normalization
211
207
  // ---------------------------------------------------------------------------
212
208
 
213
- function normalizeStroke(
214
- raw: Record<string, unknown>,
215
- ): PenStroke | undefined {
216
- if (!raw) return undefined
217
- const out = { ...raw }
209
+ function normalizeStroke(raw: Record<string, unknown>): PenStroke | undefined {
210
+ if (!raw) return undefined;
211
+ const out = { ...raw };
218
212
 
219
213
  // Normalize fill inside stroke
220
214
  if ('fill' in out) {
221
- out.fill = normalizeFills(out.fill)
215
+ out.fill = normalizeFills(out.fill);
222
216
  }
223
217
 
224
218
  // Pencil may use "color" directly on stroke
225
219
  if ('color' in out && typeof out.color === 'string') {
226
- out.fill = [{ type: 'solid', color: out.color as string }]
227
- delete out.color
220
+ out.fill = [{ type: 'solid', color: out.color as string }];
221
+ delete out.color;
228
222
  }
229
223
 
230
224
  // Thickness: leave $variable strings as-is, normalise plain number strings
231
225
  if (typeof out.thickness === 'string') {
232
- const str = out.thickness as string
226
+ const str = out.thickness as string;
233
227
  if (!str.startsWith('$')) {
234
- const num = parseFloat(str)
235
- out.thickness = isNaN(num) ? 1 : num
228
+ const num = parseFloat(str);
229
+ out.thickness = isNaN(num) ? 1 : num;
236
230
  }
237
231
  }
238
232
 
239
- return out as unknown as PenStroke
233
+ return out as unknown as PenStroke;
240
234
  }
241
235
 
242
236
  // ---------------------------------------------------------------------------
@@ -244,46 +238,46 @@ function normalizeStroke(
244
238
  // ---------------------------------------------------------------------------
245
239
 
246
240
  function normalizeSizing(value: unknown): number | string {
247
- if (typeof value === 'number') return value
248
- if (typeof value !== 'string') return 0
241
+ if (typeof value === 'number') return value;
242
+ if (typeof value !== 'string') return 0;
249
243
 
250
244
  // $variable — pass through
251
- if (value.startsWith('$')) return value
245
+ if (value.startsWith('$')) return value;
252
246
 
253
247
  // fill_container must always resolve dynamically from parent dimensions
254
- if (value.startsWith('fill_container')) return 'fill_container'
248
+ if (value.startsWith('fill_container')) return 'fill_container';
255
249
 
256
250
  // fit_content with a hint value: use the hint (more accurate than our estimation)
257
251
  if (value.startsWith('fit_content')) {
258
- const match = value.match(/\((\d+(?:\.\d+)?)\)/)
259
- if (match) return parseFloat(match[1])
260
- return 'fit_content'
252
+ const match = value.match(/\((\d+(?:\.\d+)?)\)/);
253
+ if (match) return parseFloat(match[1]);
254
+ return 'fit_content';
261
255
  }
262
256
 
263
257
  // Try as a plain number string
264
- const num = parseFloat(value)
265
- return isNaN(num) ? 0 : num
258
+ const num = parseFloat(value);
259
+ return isNaN(num) ? 0 : num;
266
260
  }
267
261
 
268
262
  function normalizePadding(
269
263
  value: unknown,
270
264
  ): number | [number, number] | [number, number, number, number] | string | undefined {
271
- if (typeof value === 'number') return value
265
+ if (typeof value === 'number') return value;
272
266
  if (typeof value === 'string') {
273
267
  // $variable — pass through
274
- if (value.startsWith('$')) return value
275
- const num = parseFloat(value)
276
- return isNaN(num) ? 0 : num
268
+ if (value.startsWith('$')) return value;
269
+ const num = parseFloat(value);
270
+ return isNaN(num) ? 0 : num;
277
271
  }
278
272
  if (Array.isArray(value)) {
279
273
  return value.map((v) => {
280
- if (typeof v === 'number') return v
274
+ if (typeof v === 'number') return v;
281
275
  if (typeof v === 'string') {
282
- const num = parseFloat(v)
283
- return isNaN(num) ? 0 : num
276
+ const num = parseFloat(v);
277
+ return isNaN(num) ? 0 : num;
284
278
  }
285
- return 0
286
- }) as [number, number] | [number, number, number, number]
279
+ return 0;
280
+ }) as [number, number] | [number, number, number, number];
287
281
  }
288
- return undefined
282
+ return undefined;
289
283
  }