@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.
- package/README.md +18 -9
- package/package.json +5 -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 +173 -0
- 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 +158 -111
- 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 -230
- package/src/layout/normalize-tree.ts +140 -0
- package/src/layout/strip-redundant-section-fills.ts +155 -0
- package/src/layout/text-measure.ts +133 -105
- 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 +79 -79
- 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 +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
|
|
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
|
|
209
|
-
|
|
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
|
}
|