@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.
- package/LICENSE +21 -0
- package/README.md +177 -25
- package/package.json +19 -5
- package/src/__tests__/arc-path.test.ts +23 -23
- package/src/__tests__/codegen-utils.test.ts +50 -0
- package/src/__tests__/design-md-parser.test.ts +49 -0
- package/src/__tests__/font-utils.test.ts +15 -15
- package/src/__tests__/layout-engine.test.ts +169 -83
- package/src/__tests__/merge-helpers.test.ts +143 -0
- package/src/__tests__/node-diff.test.ts +139 -0
- package/src/__tests__/node-helpers.test.ts +19 -19
- package/src/__tests__/node-merge.test.ts +425 -0
- package/src/__tests__/normalize-stroke-fill-schema.test.ts +416 -0
- package/src/__tests__/normalize-tree-layout.test.ts +294 -0
- package/src/__tests__/normalize.test.ts +119 -80
- package/src/__tests__/path-anchors.test.ts +98 -0
- package/src/__tests__/resolve-variables-recursive.test.ts +109 -54
- package/src/__tests__/strip-redundant-section-fills.test.ts +278 -0
- package/src/__tests__/text-measure.test.ts +84 -79
- package/src/__tests__/tree-utils.test.ts +133 -102
- package/src/__tests__/unwrap-fake-phone-mockup.test.ts +322 -0
- package/src/__tests__/variables.test.ts +68 -65
- package/src/arc-path.ts +35 -35
- package/src/boolean-ops.ts +131 -142
- package/src/constants.ts +36 -36
- package/src/design-md-parser.ts +363 -0
- package/src/font-utils.ts +30 -15
- package/src/id.ts +1 -1
- package/src/index.ts +47 -13
- package/src/layout/engine.ts +255 -224
- package/src/layout/normalize-tree.ts +140 -0
- package/src/layout/strip-redundant-section-fills.ts +155 -0
- package/src/layout/text-measure.ts +130 -106
- package/src/layout/unwrap-fake-phone-mockup.ts +147 -0
- package/src/merge/index.ts +16 -0
- package/src/merge/merge-helpers.ts +113 -0
- package/src/merge/node-diff.ts +123 -0
- package/src/merge/node-merge.ts +651 -0
- package/src/node-helpers.ts +18 -5
- package/src/normalize/normalize-stroke-fill-schema.ts +297 -0
- package/src/normalize.ts +75 -81
- package/src/path-anchors.ts +331 -0
- package/src/sync-lock.ts +3 -3
- package/src/tree-utils.ts +180 -158
- package/src/variables/replace-refs.ts +63 -60
- 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
|
|
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
|
|
215
|
-
|
|
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
|
}
|