@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
@@ -1,5 +1,6 @@
1
- import type { PenNode, ContainerProps, SizingBehavior } from '@zseven-w/pen-types'
2
- import { isBadgeOverlayNode } from '../node-helpers.js'
1
+ import type { PenNode, ContainerProps, SizingBehavior, Padding } from '@zseven-w/pen-types';
2
+ export type { Padding } from '@zseven-w/pen-types';
3
+ import { isBadgeOverlayNode } from '../node-helpers.js';
3
4
  import {
4
5
  parseSizing,
5
6
  estimateTextWidth,
@@ -9,45 +10,31 @@ import {
9
10
  resolveTextContent,
10
11
  countExplicitTextLines,
11
12
  defaultLineHeight,
12
- } from './text-measure.js'
13
-
13
+ } from './text-measure.js';
14
14
 
15
15
  // ---------------------------------------------------------------------------
16
16
  // Padding
17
17
  // ---------------------------------------------------------------------------
18
18
 
19
- export interface Padding {
20
- top: number
21
- right: number
22
- bottom: number
23
- left: number
24
- }
25
-
26
19
  export function resolvePadding(
27
- padding:
28
- | number
29
- | [number, number]
30
- | [number, number, number, number]
31
- | string
32
- | undefined,
20
+ padding: number | [number, number] | [number, number, number, number] | string | undefined,
33
21
  ): Padding {
34
- if (!padding || typeof padding === 'string')
35
- return { top: 0, right: 0, bottom: 0, left: 0 }
22
+ if (!padding || typeof padding === 'string') return { top: 0, right: 0, bottom: 0, left: 0 };
36
23
  if (typeof padding === 'number')
37
- return { top: padding, right: padding, bottom: padding, left: padding }
24
+ return { top: padding, right: padding, bottom: padding, left: padding };
38
25
  if (padding.length === 2)
39
26
  return {
40
27
  top: padding[0],
41
28
  right: padding[1],
42
29
  bottom: padding[0],
43
30
  left: padding[1],
44
- }
31
+ };
45
32
  return {
46
33
  top: padding[0],
47
34
  right: padding[1],
48
35
  bottom: padding[2],
49
36
  left: padding[3],
50
- }
37
+ };
51
38
  }
52
39
 
53
40
  // ---------------------------------------------------------------------------
@@ -55,42 +42,56 @@ export function resolvePadding(
55
42
  // ---------------------------------------------------------------------------
56
43
 
57
44
  export function isNodeVisible(node: PenNode): boolean {
58
- return ('visible' in node ? node.visible : undefined) !== false
45
+ return (
46
+ ('visible' in node ? node.visible : undefined) !== false &&
47
+ ('enabled' in node ? node.enabled : undefined) !== false
48
+ );
59
49
  }
60
50
 
61
51
  // ---------------------------------------------------------------------------
62
52
  // Root fill-width fallback
63
53
  // ---------------------------------------------------------------------------
64
54
 
65
- const DEFAULT_FRAME_ID = 'root-frame'
55
+ const DEFAULT_FRAME_ID = 'root-frame';
66
56
 
67
57
  /** Resolve root fill-width fallback. Pass root children to avoid store coupling. */
68
- let _rootChildrenProvider: (() => PenNode[]) | null = null
58
+ let _rootChildrenProvider: (() => PenNode[]) | null = null;
69
59
 
70
60
  /** Set a provider function for root children (called once from app initialization). */
71
61
  export function setRootChildrenProvider(provider: () => PenNode[]): void {
72
- _rootChildrenProvider = provider
62
+ _rootChildrenProvider = provider;
73
63
  }
74
64
 
75
65
  export function getRootFillWidthFallback(): number {
76
- const roots = _rootChildrenProvider?.() ?? []
66
+ const roots = _rootChildrenProvider?.() ?? [];
77
67
  const rootFrame = roots.find(
78
- (n) => n.type === 'frame'
79
- && n.id === DEFAULT_FRAME_ID
80
- && 'width' in n
81
- && typeof n.width === 'number'
82
- && n.width > 0,
83
- )
84
- if (rootFrame && 'width' in rootFrame && typeof rootFrame.width === 'number' && rootFrame.width > 0) {
85
- return rootFrame.width
68
+ (n) =>
69
+ n.type === 'frame' &&
70
+ n.id === DEFAULT_FRAME_ID &&
71
+ 'width' in n &&
72
+ typeof n.width === 'number' &&
73
+ n.width > 0,
74
+ );
75
+ if (
76
+ rootFrame &&
77
+ 'width' in rootFrame &&
78
+ typeof rootFrame.width === 'number' &&
79
+ rootFrame.width > 0
80
+ ) {
81
+ return rootFrame.width;
86
82
  }
87
83
  const anyTopFrame = roots.find(
88
84
  (n) => n.type === 'frame' && 'width' in n && typeof n.width === 'number' && n.width > 0,
89
- )
90
- if (anyTopFrame && 'width' in anyTopFrame && typeof anyTopFrame.width === 'number' && anyTopFrame.width > 0) {
91
- return anyTopFrame.width
85
+ );
86
+ if (
87
+ anyTopFrame &&
88
+ 'width' in anyTopFrame &&
89
+ typeof anyTopFrame.width === 'number' &&
90
+ anyTopFrame.width > 0
91
+ ) {
92
+ return anyTopFrame.width;
92
93
  }
93
- return 1200
94
+ return 1200;
94
95
  }
95
96
 
96
97
  // ---------------------------------------------------------------------------
@@ -98,17 +99,17 @@ export function getRootFillWidthFallback(): number {
98
99
  // ---------------------------------------------------------------------------
99
100
 
100
101
  export function inferLayout(node: PenNode): 'horizontal' | undefined {
101
- if (node.type !== 'frame') return undefined
102
- const c = node as PenNode & ContainerProps
103
- if (c.gap != null || c.justifyContent || c.alignItems) return 'horizontal'
104
- if (c.padding != null) return 'horizontal'
102
+ if (node.type !== 'frame') return undefined;
103
+ const c = node as PenNode & ContainerProps;
104
+ if (c.gap != null || c.justifyContent || c.alignItems) return 'horizontal';
105
+ if (c.padding != null) return 'horizontal';
105
106
  if ('children' in node && node.children?.length) {
106
107
  for (const child of node.children) {
107
- if ('width' in child && child.width === 'fill_container') return 'horizontal'
108
- if ('height' in child && child.height === 'fill_container') return 'horizontal'
108
+ if ('width' in child && child.width === 'fill_container') return 'horizontal';
109
+ if ('height' in child && child.height === 'fill_container') return 'horizontal';
109
110
  }
110
111
  }
111
- return undefined
112
+ return undefined;
112
113
  }
113
114
 
114
115
  // ---------------------------------------------------------------------------
@@ -116,49 +117,58 @@ export function inferLayout(node: PenNode): 'horizontal' | undefined {
116
117
  // ---------------------------------------------------------------------------
117
118
 
118
119
  export function fitContentWidth(node: PenNode, parentAvail?: number): number {
119
- if (!('children' in node) || !node.children?.length) return 0
120
+ if (!('children' in node) || !node.children?.length) return 0;
120
121
  const visibleChildren = node.children.filter(
121
122
  (child) => isNodeVisible(child) && !isBadgeOverlayNode(child),
122
- )
123
- if (visibleChildren.length === 0) return 0
124
- const c = node as PenNode & ContainerProps
125
- const layout = c.layout || inferLayout(node)
126
- const pad = resolvePadding(c.padding)
127
- const nodeGap = typeof c.gap === 'number' ? c.gap : 0
123
+ );
124
+ if (visibleChildren.length === 0) return 0;
125
+ const c = node as PenNode & ContainerProps;
126
+ const layout = c.layout || inferLayout(node);
127
+ const pad = resolvePadding(c.padding);
128
+ const nodeGap = typeof c.gap === 'number' ? c.gap : 0;
128
129
  if (layout === 'horizontal') {
129
- const gapTotal = nodeGap * Math.max(0, visibleChildren.length - 1)
130
- const childAvail = parentAvail !== undefined
131
- ? Math.max(0, parentAvail - pad.left - pad.right - gapTotal)
132
- : undefined
133
- const childTotal = visibleChildren.reduce((sum, ch) => sum + getNodeWidth(ch, childAvail), 0)
134
- return childTotal + gapTotal + pad.left + pad.right
130
+ const gapTotal = nodeGap * Math.max(0, visibleChildren.length - 1);
131
+ const childAvail =
132
+ parentAvail !== undefined
133
+ ? Math.max(0, parentAvail - pad.left - pad.right - gapTotal)
134
+ : undefined;
135
+ const childTotal = visibleChildren.reduce((sum, ch) => sum + getNodeWidth(ch, childAvail), 0);
136
+ return childTotal + gapTotal + pad.left + pad.right;
135
137
  }
136
- const childAvail = parentAvail !== undefined
137
- ? Math.max(0, parentAvail - pad.left - pad.right)
138
- : undefined
139
- const maxChildW = visibleChildren.reduce((max, ch) => Math.max(max, getNodeWidth(ch, childAvail)), 0)
140
- return maxChildW + pad.left + pad.right
138
+ const childAvail =
139
+ parentAvail !== undefined ? Math.max(0, parentAvail - pad.left - pad.right) : undefined;
140
+ const maxChildW = visibleChildren.reduce(
141
+ (max, ch) => Math.max(max, getNodeWidth(ch, childAvail)),
142
+ 0,
143
+ );
144
+ return maxChildW + pad.left + pad.right;
141
145
  }
142
146
 
143
147
  export function fitContentHeight(node: PenNode, parentAvailW?: number): number {
144
- if (!('children' in node) || !node.children?.length) return 0
148
+ if (!('children' in node) || !node.children?.length) return 0;
145
149
  const visibleChildren = node.children.filter(
146
150
  (child) => isNodeVisible(child) && !isBadgeOverlayNode(child),
147
- )
148
- if (visibleChildren.length === 0) return 0
149
- const c = node as PenNode & ContainerProps
150
- const layout = c.layout || inferLayout(node)
151
- const pad = resolvePadding(c.padding)
152
- const nodeGap = typeof c.gap === 'number' ? c.gap : 0
153
- const nodeW = getNodeWidth(node, parentAvailW)
154
- const childAvailW = nodeW > 0 ? Math.max(0, nodeW - pad.left - pad.right) : parentAvailW
151
+ );
152
+ if (visibleChildren.length === 0) return 0;
153
+ const c = node as PenNode & ContainerProps;
154
+ const layout = c.layout || inferLayout(node);
155
+ const pad = resolvePadding(c.padding);
156
+ const nodeGap = typeof c.gap === 'number' ? c.gap : 0;
157
+ const nodeW = getNodeWidth(node, parentAvailW);
158
+ const childAvailW = nodeW > 0 ? Math.max(0, nodeW - pad.left - pad.right) : parentAvailW;
155
159
  if (layout === 'vertical') {
156
- const childTotal = visibleChildren.reduce((sum, ch) => sum + getNodeHeight(ch, undefined, childAvailW), 0)
157
- const gapTotal = nodeGap * Math.max(0, visibleChildren.length - 1)
158
- return childTotal + gapTotal + pad.top + pad.bottom
160
+ const childTotal = visibleChildren.reduce(
161
+ (sum, ch) => sum + getNodeHeight(ch, undefined, childAvailW),
162
+ 0,
163
+ );
164
+ const gapTotal = nodeGap * Math.max(0, visibleChildren.length - 1);
165
+ return childTotal + gapTotal + pad.top + pad.bottom;
159
166
  }
160
- const maxChildH = visibleChildren.reduce((max, ch) => Math.max(max, getNodeHeight(ch, undefined, childAvailW)), 0)
161
- return maxChildH + pad.top + pad.bottom
167
+ const maxChildH = visibleChildren.reduce(
168
+ (max, ch) => Math.max(max, getNodeHeight(ch, undefined, childAvailW)),
169
+ 0,
170
+ );
171
+ return maxChildH + pad.top + pad.bottom;
162
172
  }
163
173
 
164
174
  // ---------------------------------------------------------------------------
@@ -167,216 +177,206 @@ export function fitContentHeight(node: PenNode, parentAvailW?: number): number {
167
177
 
168
178
  export function getNodeWidth(node: PenNode, parentAvail?: number): number {
169
179
  if ('width' in node) {
170
- const s = parseSizing(node.width)
171
- if (typeof s === 'number' && s > 0) return s
180
+ const s = parseSizing(node.width);
181
+ if (typeof s === 'number' && s > 0) return s;
172
182
  if (s === 'fill') {
173
- if (parentAvail && parentAvail > 0) return parentAvail
183
+ if (parentAvail && parentAvail > 0) return parentAvail;
174
184
  if (node.type !== 'text') {
175
- const fallbackFillW = getRootFillWidthFallback()
176
- if (fallbackFillW > 0) return fallbackFillW
185
+ const fallbackFillW = getRootFillWidthFallback();
186
+ if (fallbackFillW > 0) return fallbackFillW;
177
187
  }
178
188
  if ('children' in node && node.children?.length) {
179
- const intrinsic = fitContentWidth(node)
180
- if (intrinsic > 0) return intrinsic
189
+ const intrinsic = fitContentWidth(node);
190
+ if (intrinsic > 0) return intrinsic;
181
191
  }
182
192
  if (node.type === 'text') {
183
- const fontSize = node.fontSize ?? 16
184
- const letterSpacing = node.letterSpacing ?? 0
185
- const fontWeight = node.fontWeight
186
- const content =
187
- typeof node.content === 'string'
188
- ? node.content
189
- : node.content.map((s2) => s2.text).join('')
190
- return Math.max(Math.ceil(estimateTextWidth(content, fontSize, letterSpacing, fontWeight)), 1)
193
+ const fontSize = node.fontSize ?? 16;
194
+ const letterSpacing = node.letterSpacing ?? 0;
195
+ const fontWeight = node.fontWeight;
196
+ const content = resolveTextContent(node);
197
+ return Math.max(
198
+ Math.ceil(estimateTextWidth(content, fontSize, letterSpacing, fontWeight)),
199
+ 1,
200
+ );
191
201
  }
192
202
  }
193
203
  if (s === 'fit') {
194
- const fit = fitContentWidth(node, parentAvail)
195
- if (fit > 0) return fit
204
+ const fit = fitContentWidth(node, parentAvail);
205
+ if (fit > 0) return fit;
196
206
  }
197
207
  }
198
208
  if ('children' in node && node.children?.length) {
199
- const fit = fitContentWidth(node, parentAvail)
200
- if (fit > 0) return fit
209
+ const fit = fitContentWidth(node, parentAvail);
210
+ if (fit > 0) return fit;
201
211
  }
202
212
  if (node.type === 'text') {
203
- const fontSize = node.fontSize ?? 16
204
- const letterSpacing = node.letterSpacing ?? 0
205
- const fontWeight = node.fontWeight
206
- const content =
207
- typeof node.content === 'string'
208
- ? node.content
209
- : node.content.map((s) => s.text).join('')
210
- return Math.max(Math.ceil(estimateTextWidthPrecise(content, fontSize, letterSpacing, fontWeight)), 1)
213
+ const fontSize = node.fontSize ?? 16;
214
+ const letterSpacing = node.letterSpacing ?? 0;
215
+ const fontWeight = node.fontWeight;
216
+ const content = resolveTextContent(node);
217
+ return Math.max(
218
+ Math.ceil(estimateTextWidthPrecise(content, fontSize, letterSpacing, fontWeight)),
219
+ 1,
220
+ );
211
221
  }
212
- return 0
222
+ return 0;
213
223
  }
214
224
 
215
225
  export function getNodeHeight(node: PenNode, parentAvail?: number, parentAvailW?: number): number {
216
226
  if ('height' in node) {
217
- const s = parseSizing(node.height)
218
- if (typeof s === 'number' && s > 0) return s
219
- if (s === 'fill' && parentAvail) return parentAvail
227
+ const s = parseSizing(node.height);
228
+ if (typeof s === 'number' && s > 0) return s;
229
+ if (s === 'fill' && parentAvail) return parentAvail;
220
230
  if (s === 'fit') {
221
- const fit = fitContentHeight(node, parentAvailW)
222
- if (fit > 0) return fit
231
+ const fit = fitContentHeight(node, parentAvailW);
232
+ if (fit > 0) return fit;
223
233
  }
224
234
  }
225
235
  if ('children' in node && node.children?.length) {
226
- const fit = fitContentHeight(node, parentAvailW)
227
- if (fit > 0) return fit
236
+ const fit = fitContentHeight(node, parentAvailW);
237
+ if (fit > 0) return fit;
228
238
  }
229
239
  if (node.type === 'text') {
230
- return estimateTextHeight(node, parentAvailW)
240
+ return estimateTextHeight(node, parentAvailW);
231
241
  }
232
- return 0
242
+ return 0;
233
243
  }
234
244
 
235
245
  // ---------------------------------------------------------------------------
236
246
  // Auto-layout position computation
237
247
  // ---------------------------------------------------------------------------
238
248
 
239
- export function computeLayoutPositions(
240
- parent: PenNode,
241
- children: PenNode[],
242
- ): PenNode[] {
243
- if (children.length === 0) return children
244
- const visibleChildren = children.filter((child) => isNodeVisible(child))
245
- if (visibleChildren.length === 0) return []
246
- const c = parent as PenNode & ContainerProps
247
- const layout = c.layout || inferLayout(parent)
248
- if (!layout || layout === 'none') return visibleChildren
249
-
250
- const badgeNodes = visibleChildren.filter(isBadgeOverlayNode)
251
- const layoutChildren = visibleChildren.filter((ch) => !isBadgeOverlayNode(ch))
252
- if (layoutChildren.length === 0) return visibleChildren
253
-
254
- const pW = parseSizing(c.width)
255
- const pH = parseSizing(c.height)
256
- const parentW = (typeof pW === 'number' && pW > 0) ? pW : (getNodeWidth(parent) || 100)
257
- const parentH = (typeof pH === 'number' && pH > 0) ? pH : (getNodeHeight(parent) || 100)
258
- const pad = resolvePadding(c.padding)
259
- const gap = typeof c.gap === 'number' ? c.gap : 0
260
- const justify = normalizeJustifyContent(c.justifyContent)
261
- const align = normalizeAlignItems(c.alignItems)
262
-
263
- const isVertical = layout === 'vertical'
264
- const availW = parentW - pad.left - pad.right
265
- const availH = parentH - pad.top - pad.bottom
266
- const availMain = isVertical ? availH : availW
267
- const totalGapSpace = gap * Math.max(0, layoutChildren.length - 1)
249
+ export function computeLayoutPositions(parent: PenNode, children: PenNode[]): PenNode[] {
250
+ if (children.length === 0) return children;
251
+ const visibleChildren = children.filter((child) => isNodeVisible(child));
252
+ if (visibleChildren.length === 0) return [];
253
+ const c = parent as PenNode & ContainerProps;
254
+ const layout = c.layout || inferLayout(parent);
255
+ if (!layout || layout === 'none') return visibleChildren;
256
+
257
+ const badgeNodes = visibleChildren.filter(isBadgeOverlayNode);
258
+ const layoutChildren = visibleChildren.filter((ch) => !isBadgeOverlayNode(ch));
259
+ if (layoutChildren.length === 0) return visibleChildren;
260
+
261
+ const pW = parseSizing(c.width);
262
+ const pH = parseSizing(c.height);
263
+ const parentW = typeof pW === 'number' && pW > 0 ? pW : getNodeWidth(parent) || 100;
264
+ const parentH = typeof pH === 'number' && pH > 0 ? pH : getNodeHeight(parent) || 100;
265
+ const pad = resolvePadding(c.padding);
266
+ const gap = typeof c.gap === 'number' ? c.gap : 0;
267
+ const justify = normalizeJustifyContent(c.justifyContent);
268
+ const align = normalizeAlignItems(c.alignItems);
269
+
270
+ const isVertical = layout === 'vertical';
271
+ const availW = parentW - pad.left - pad.right;
272
+ const availH = parentH - pad.top - pad.bottom;
273
+ const availMain = isVertical ? availH : availW;
274
+ const totalGapSpace = gap * Math.max(0, layoutChildren.length - 1);
268
275
 
269
276
  const mainSizing = layoutChildren.map((ch) => {
270
- const prop = isVertical ? 'height' : 'width'
277
+ const prop = isVertical ? 'height' : 'width';
271
278
  if (prop in ch) {
272
- const s = parseSizing((ch as PenNode & { width?: SizingBehavior; height?: SizingBehavior })[prop])
273
- if (s === 'fill') return 'fill' as const
279
+ const s = parseSizing(
280
+ (ch as PenNode & { width?: SizingBehavior; height?: SizingBehavior })[prop],
281
+ );
282
+ if (s === 'fill') return 'fill' as const;
274
283
  }
275
- return isVertical ? getNodeHeight(ch, availH, availW) : getNodeWidth(ch, availW)
276
- })
284
+ return isVertical ? getNodeHeight(ch, availH, availW) : getNodeWidth(ch, availW);
285
+ });
277
286
  const fixedTotal = mainSizing.reduce<number>(
278
287
  (sum, s) => sum + (typeof s === 'number' ? s : 0),
279
288
  0,
280
- )
281
- const fillCount = mainSizing.filter((s) => s === 'fill').length
282
- const remainingMain = Math.max(0, availMain - fixedTotal - totalGapSpace)
283
- const fillSize = fillCount > 0 ? remainingMain / fillCount : 0
289
+ );
290
+ const fillCount = mainSizing.filter((s) => s === 'fill').length;
291
+ const remainingMain = Math.max(0, availMain - fixedTotal - totalGapSpace);
292
+ const fillSize = fillCount > 0 ? remainingMain / fillCount : 0;
284
293
 
285
294
  const sizes = layoutChildren.map((ch, i) => {
286
- let mainSize = mainSizing[i] === 'fill' ? fillSize : (mainSizing[i] as number)
295
+ let mainSize = mainSizing[i] === 'fill' ? fillSize : (mainSizing[i] as number);
287
296
  if (isVertical && ch.type === 'text' && mainSizing[i] !== 'fill') {
288
- const content = resolveTextContent(ch)
297
+ const content = resolveTextContent(ch);
289
298
  if (countExplicitTextLines(content) <= 1) {
290
- const fontSize = ch.fontSize ?? 16
291
- const lineHeight = ch.lineHeight ?? defaultLineHeight(fontSize)
292
- const singleLineH = fontSize * lineHeight
293
- const estH = estimateTextHeight(ch, availW)
299
+ const fontSize = ch.fontSize ?? 16;
300
+ const lineHeight = ch.lineHeight ?? defaultLineHeight(fontSize);
301
+ const singleLineH = fontSize * lineHeight;
302
+ const estH = estimateTextHeight(ch, availW);
294
303
  if (estH <= singleLineH + 1) {
295
- mainSize = singleLineH
304
+ mainSize = singleLineH;
296
305
  }
297
306
  }
298
307
  }
299
308
  return {
300
309
  w: isVertical ? getNodeWidth(ch, availW) : mainSize,
301
310
  h: isVertical ? mainSize : getNodeHeight(ch, availH, isVertical ? availW : mainSize),
302
- }
303
- })
311
+ };
312
+ });
304
313
 
305
- const totalMain = sizes.reduce(
306
- (sum, s) => sum + (isVertical ? s.h : s.w),
307
- 0,
308
- )
309
- const freeSpace = Math.max(0, availMain - totalMain - totalGapSpace)
314
+ const totalMain = sizes.reduce((sum, s) => sum + (isVertical ? s.h : s.w), 0);
315
+ const freeSpace = Math.max(0, availMain - totalMain - totalGapSpace);
310
316
 
311
- let mainPos = 0
312
- let effectiveGap = gap
317
+ let mainPos = 0;
318
+ let effectiveGap = gap;
313
319
 
314
320
  switch (justify) {
315
321
  case 'center':
316
- mainPos = freeSpace / 2
317
- break
322
+ mainPos = freeSpace / 2;
323
+ break;
318
324
  case 'end':
319
- mainPos = freeSpace
320
- break
325
+ mainPos = freeSpace;
326
+ break;
321
327
  case 'space_between':
322
328
  effectiveGap =
323
- layoutChildren.length > 1
324
- ? (availMain - totalMain) / (layoutChildren.length - 1)
325
- : 0
326
- break
329
+ layoutChildren.length > 1 ? (availMain - totalMain) / (layoutChildren.length - 1) : 0;
330
+ break;
327
331
  case 'space_around': {
328
332
  const spacing =
329
- layoutChildren.length > 0
330
- ? (availMain - totalMain) / layoutChildren.length
331
- : 0
332
- mainPos = spacing / 2
333
- effectiveGap = spacing
334
- break
333
+ layoutChildren.length > 0 ? (availMain - totalMain) / layoutChildren.length : 0;
334
+ mainPos = spacing / 2;
335
+ effectiveGap = spacing;
336
+ break;
335
337
  }
336
338
  default:
337
- break
339
+ break;
338
340
  }
339
341
 
340
342
  const positioned = layoutChildren.map((child, i) => {
341
- const size = sizes[i]
342
- const crossAvail = isVertical ? availW : availH
343
- const childCross = isVertical ? size.w : size.h
344
- let crossPos = 0
343
+ const size = sizes[i];
344
+ const crossAvail = isVertical ? availW : availH;
345
+ const childCross = isVertical ? size.w : size.h;
346
+ let crossPos = 0;
345
347
 
346
- let effectiveChildCross = childCross
348
+ let effectiveChildCross = childCross;
347
349
  if (align === 'center' && !isVertical && child.type === 'text') {
348
- const fontSize = child.fontSize ?? 16
349
- const lineHeight = child.lineHeight ?? defaultLineHeight(fontSize)
350
- const content = resolveTextContent(child)
351
- const isSingleLine = countExplicitTextLines(content) <= 1
350
+ const fontSize = child.fontSize ?? 16;
351
+ const lineHeight = child.lineHeight ?? defaultLineHeight(fontSize);
352
+ const content = resolveTextContent(child);
353
+ const isSingleLine = countExplicitTextLines(content) <= 1;
352
354
  if (isSingleLine) {
353
- effectiveChildCross = fontSize * lineHeight
355
+ effectiveChildCross = fontSize * lineHeight;
354
356
  }
355
357
  }
356
358
 
357
359
  switch (align) {
358
360
  case 'center':
359
- crossPos = (crossAvail - effectiveChildCross) / 2
360
- break
361
+ crossPos = (crossAvail - effectiveChildCross) / 2;
362
+ break;
361
363
  case 'end':
362
- crossPos = crossAvail - childCross
363
- break
364
+ crossPos = crossAvail - childCross;
365
+ break;
364
366
  default:
365
- break
367
+ break;
366
368
  }
367
369
 
368
370
  const clampCrossSize =
369
- (!isVertical && align === 'center' && child.type === 'text')
370
- ? effectiveChildCross
371
- : childCross
371
+ !isVertical && align === 'center' && child.type === 'text' ? effectiveChildCross : childCross;
372
372
  if (crossAvail >= clampCrossSize) {
373
- crossPos = Math.max(0, Math.min(crossPos, crossAvail - clampCrossSize))
373
+ crossPos = Math.max(0, Math.min(crossPos, crossAvail - clampCrossSize));
374
374
  }
375
375
 
376
- const computedX = Math.round(isVertical ? pad.left + crossPos : pad.left + mainPos)
377
- const computedY = Math.round(isVertical ? pad.top + mainPos : pad.top + crossPos)
376
+ const computedX = Math.round(isVertical ? pad.left + crossPos : pad.left + mainPos);
377
+ const computedY = Math.round(isVertical ? pad.top + mainPos : pad.top + crossPos);
378
378
 
379
- mainPos += (isVertical ? size.h : size.w) + effectiveGap
379
+ mainPos += (isVertical ? size.h : size.w) + effectiveGap;
380
380
 
381
381
  const out: Record<string, unknown> = {
382
382
  ...child,
@@ -384,77 +384,102 @@ export function computeLayoutPositions(
384
384
  y: computedY,
385
385
  width: size.w,
386
386
  height: size.h,
387
- }
387
+ };
388
388
 
389
389
  if (isVertical && align === 'center' && child.type === 'text') {
390
- const hasExplicitAlign = 'textAlign' in child && child.textAlign && child.textAlign !== 'left'
390
+ const hasExplicitAlign =
391
+ 'textAlign' in child && child.textAlign && child.textAlign !== 'left';
391
392
  if (!hasExplicitAlign) {
392
- out.width = availW
393
- out.x = Math.round(pad.left)
394
- out.textAlign = 'center'
393
+ out.width = availW;
394
+ out.x = Math.round(pad.left);
395
+ out.textAlign = 'center';
395
396
  }
396
397
  }
397
398
 
398
- return out as unknown as PenNode
399
- })
399
+ return out as unknown as PenNode;
400
+ });
400
401
 
401
402
  if (badgeNodes.length > 0) {
402
- return [...badgeNodes, ...positioned]
403
+ return [...badgeNodes, ...positioned];
403
404
  }
404
- return positioned
405
+ return positioned;
405
406
  }
406
407
 
407
408
  function normalizeJustifyContent(
408
409
  value: unknown,
409
410
  ): 'start' | 'center' | 'end' | 'space_between' | 'space_around' {
410
- if (typeof value !== 'string') return 'start'
411
- const v = value.trim().toLowerCase()
411
+ if (typeof value !== 'string') return 'start';
412
+ const v = value.trim().toLowerCase();
412
413
  switch (v) {
413
414
  case 'start':
414
415
  case 'flex-start':
415
416
  case 'left':
416
417
  case 'top':
417
- return 'start'
418
+ return 'start';
418
419
  case 'center':
419
420
  case 'middle':
420
- return 'center'
421
+ return 'center';
421
422
  case 'end':
422
423
  case 'flex-end':
423
424
  case 'right':
424
425
  case 'bottom':
425
- return 'end'
426
+ return 'end';
426
427
  case 'space_between':
427
428
  case 'space-between':
428
- return 'space_between'
429
+ return 'space_between';
429
430
  case 'space_around':
430
431
  case 'space-around':
431
- return 'space_around'
432
+ return 'space_around';
432
433
  default:
433
- return 'start'
434
+ return 'start';
434
435
  }
435
436
  }
436
437
 
438
+ /**
439
+ * Normalize a raw `alignItems` string into the three values the layout
440
+ * engine actually implements: `start`, `center`, `end`.
441
+ *
442
+ * Accepts a generous set of aliases from CSS/flexbox terminology so
443
+ * AI-generated designs (and copy-pasted web snippets) don't silently
444
+ * fall through to the `start` default when they meant something else.
445
+ *
446
+ * `baseline` specifically is treated as `end`:
447
+ * OpenPencil's layout engine doesn't implement text-baseline
448
+ * alignment (there's no font-metrics pipeline in the position step).
449
+ * LLMs routinely emit `alignItems: 'baseline'` from web CSS reflex
450
+ * for patterns like "big number + small unit" (e.g. "72 BPM"), where
451
+ * the intent is that the small text bottom-aligns with the big
452
+ * text's visual baseline. The best approximation we can render is
453
+ * `end` — it bottom-aligns both children to the cross-axis end,
454
+ * which for a horizontal row of a large heading and a small unit
455
+ * looks essentially identical to true baseline alignment. Falling
456
+ * through to `start` (the old behavior) would top-align the unit,
457
+ * which is visually wrong.
458
+ */
437
459
  function normalizeAlignItems(value: unknown): 'start' | 'center' | 'end' {
438
- if (typeof value !== 'string') return 'start'
439
- const v = value.trim().toLowerCase()
460
+ if (typeof value !== 'string') return 'start';
461
+ const v = value.trim().toLowerCase();
440
462
  switch (v) {
441
463
  case 'start':
442
464
  case 'flex-start':
443
465
  case 'left':
444
466
  case 'top':
445
- return 'start'
467
+ return 'start';
446
468
  case 'center':
447
469
  case 'middle':
448
- return 'center'
470
+ return 'center';
449
471
  case 'end':
450
472
  case 'flex-end':
451
473
  case 'right':
452
474
  case 'bottom':
453
- return 'end'
475
+ case 'baseline':
476
+ case 'last baseline':
477
+ case 'first baseline':
478
+ return 'end';
454
479
  default:
455
- return 'start'
480
+ return 'start';
456
481
  }
457
482
  }
458
483
 
459
484
  // Re-export estimateLineWidth for convenience
460
- export { estimateLineWidth }
485
+ export { estimateLineWidth };