@zseven-w/pen-core 0.6.0 → 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 +109 -54
  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 +131 -142
  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 -224
  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 +130 -106
  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 +75 -81
  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 +98 -106
@@ -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,210 +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 = resolveTextContent(node)
187
- 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
+ );
188
201
  }
189
202
  }
190
203
  if (s === 'fit') {
191
- const fit = fitContentWidth(node, parentAvail)
192
- if (fit > 0) return fit
204
+ const fit = fitContentWidth(node, parentAvail);
205
+ if (fit > 0) return fit;
193
206
  }
194
207
  }
195
208
  if ('children' in node && node.children?.length) {
196
- const fit = fitContentWidth(node, parentAvail)
197
- if (fit > 0) return fit
209
+ const fit = fitContentWidth(node, parentAvail);
210
+ if (fit > 0) return fit;
198
211
  }
199
212
  if (node.type === 'text') {
200
- const fontSize = node.fontSize ?? 16
201
- const letterSpacing = node.letterSpacing ?? 0
202
- const fontWeight = node.fontWeight
203
- const content = resolveTextContent(node)
204
- 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
+ );
205
221
  }
206
- return 0
222
+ return 0;
207
223
  }
208
224
 
209
225
  export function getNodeHeight(node: PenNode, parentAvail?: number, parentAvailW?: number): number {
210
226
  if ('height' in node) {
211
- const s = parseSizing(node.height)
212
- if (typeof s === 'number' && s > 0) return s
213
- 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;
214
230
  if (s === 'fit') {
215
- const fit = fitContentHeight(node, parentAvailW)
216
- if (fit > 0) return fit
231
+ const fit = fitContentHeight(node, parentAvailW);
232
+ if (fit > 0) return fit;
217
233
  }
218
234
  }
219
235
  if ('children' in node && node.children?.length) {
220
- const fit = fitContentHeight(node, parentAvailW)
221
- if (fit > 0) return fit
236
+ const fit = fitContentHeight(node, parentAvailW);
237
+ if (fit > 0) return fit;
222
238
  }
223
239
  if (node.type === 'text') {
224
- return estimateTextHeight(node, parentAvailW)
240
+ return estimateTextHeight(node, parentAvailW);
225
241
  }
226
- return 0
242
+ return 0;
227
243
  }
228
244
 
229
245
  // ---------------------------------------------------------------------------
230
246
  // Auto-layout position computation
231
247
  // ---------------------------------------------------------------------------
232
248
 
233
- export function computeLayoutPositions(
234
- parent: PenNode,
235
- children: PenNode[],
236
- ): PenNode[] {
237
- if (children.length === 0) return children
238
- const visibleChildren = children.filter((child) => isNodeVisible(child))
239
- if (visibleChildren.length === 0) return []
240
- const c = parent as PenNode & ContainerProps
241
- const layout = c.layout || inferLayout(parent)
242
- if (!layout || layout === 'none') return visibleChildren
243
-
244
- const badgeNodes = visibleChildren.filter(isBadgeOverlayNode)
245
- const layoutChildren = visibleChildren.filter((ch) => !isBadgeOverlayNode(ch))
246
- if (layoutChildren.length === 0) return visibleChildren
247
-
248
- const pW = parseSizing(c.width)
249
- const pH = parseSizing(c.height)
250
- const parentW = (typeof pW === 'number' && pW > 0) ? pW : (getNodeWidth(parent) || 100)
251
- const parentH = (typeof pH === 'number' && pH > 0) ? pH : (getNodeHeight(parent) || 100)
252
- const pad = resolvePadding(c.padding)
253
- const gap = typeof c.gap === 'number' ? c.gap : 0
254
- const justify = normalizeJustifyContent(c.justifyContent)
255
- const align = normalizeAlignItems(c.alignItems)
256
-
257
- const isVertical = layout === 'vertical'
258
- const availW = parentW - pad.left - pad.right
259
- const availH = parentH - pad.top - pad.bottom
260
- const availMain = isVertical ? availH : availW
261
- 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);
262
275
 
263
276
  const mainSizing = layoutChildren.map((ch) => {
264
- const prop = isVertical ? 'height' : 'width'
277
+ const prop = isVertical ? 'height' : 'width';
265
278
  if (prop in ch) {
266
- const s = parseSizing((ch as PenNode & { width?: SizingBehavior; height?: SizingBehavior })[prop])
267
- 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;
268
283
  }
269
- return isVertical ? getNodeHeight(ch, availH, availW) : getNodeWidth(ch, availW)
270
- })
284
+ return isVertical ? getNodeHeight(ch, availH, availW) : getNodeWidth(ch, availW);
285
+ });
271
286
  const fixedTotal = mainSizing.reduce<number>(
272
287
  (sum, s) => sum + (typeof s === 'number' ? s : 0),
273
288
  0,
274
- )
275
- const fillCount = mainSizing.filter((s) => s === 'fill').length
276
- const remainingMain = Math.max(0, availMain - fixedTotal - totalGapSpace)
277
- 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;
278
293
 
279
294
  const sizes = layoutChildren.map((ch, i) => {
280
- let mainSize = mainSizing[i] === 'fill' ? fillSize : (mainSizing[i] as number)
295
+ let mainSize = mainSizing[i] === 'fill' ? fillSize : (mainSizing[i] as number);
281
296
  if (isVertical && ch.type === 'text' && mainSizing[i] !== 'fill') {
282
- const content = resolveTextContent(ch)
297
+ const content = resolveTextContent(ch);
283
298
  if (countExplicitTextLines(content) <= 1) {
284
- const fontSize = ch.fontSize ?? 16
285
- const lineHeight = ch.lineHeight ?? defaultLineHeight(fontSize)
286
- const singleLineH = fontSize * lineHeight
287
- 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);
288
303
  if (estH <= singleLineH + 1) {
289
- mainSize = singleLineH
304
+ mainSize = singleLineH;
290
305
  }
291
306
  }
292
307
  }
293
308
  return {
294
309
  w: isVertical ? getNodeWidth(ch, availW) : mainSize,
295
310
  h: isVertical ? mainSize : getNodeHeight(ch, availH, isVertical ? availW : mainSize),
296
- }
297
- })
311
+ };
312
+ });
298
313
 
299
- const totalMain = sizes.reduce(
300
- (sum, s) => sum + (isVertical ? s.h : s.w),
301
- 0,
302
- )
303
- 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);
304
316
 
305
- let mainPos = 0
306
- let effectiveGap = gap
317
+ let mainPos = 0;
318
+ let effectiveGap = gap;
307
319
 
308
320
  switch (justify) {
309
321
  case 'center':
310
- mainPos = freeSpace / 2
311
- break
322
+ mainPos = freeSpace / 2;
323
+ break;
312
324
  case 'end':
313
- mainPos = freeSpace
314
- break
325
+ mainPos = freeSpace;
326
+ break;
315
327
  case 'space_between':
316
328
  effectiveGap =
317
- layoutChildren.length > 1
318
- ? (availMain - totalMain) / (layoutChildren.length - 1)
319
- : 0
320
- break
329
+ layoutChildren.length > 1 ? (availMain - totalMain) / (layoutChildren.length - 1) : 0;
330
+ break;
321
331
  case 'space_around': {
322
332
  const spacing =
323
- layoutChildren.length > 0
324
- ? (availMain - totalMain) / layoutChildren.length
325
- : 0
326
- mainPos = spacing / 2
327
- effectiveGap = spacing
328
- break
333
+ layoutChildren.length > 0 ? (availMain - totalMain) / layoutChildren.length : 0;
334
+ mainPos = spacing / 2;
335
+ effectiveGap = spacing;
336
+ break;
329
337
  }
330
338
  default:
331
- break
339
+ break;
332
340
  }
333
341
 
334
342
  const positioned = layoutChildren.map((child, i) => {
335
- const size = sizes[i]
336
- const crossAvail = isVertical ? availW : availH
337
- const childCross = isVertical ? size.w : size.h
338
- 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;
339
347
 
340
- let effectiveChildCross = childCross
348
+ let effectiveChildCross = childCross;
341
349
  if (align === 'center' && !isVertical && child.type === 'text') {
342
- const fontSize = child.fontSize ?? 16
343
- const lineHeight = child.lineHeight ?? defaultLineHeight(fontSize)
344
- const content = resolveTextContent(child)
345
- 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;
346
354
  if (isSingleLine) {
347
- effectiveChildCross = fontSize * lineHeight
355
+ effectiveChildCross = fontSize * lineHeight;
348
356
  }
349
357
  }
350
358
 
351
359
  switch (align) {
352
360
  case 'center':
353
- crossPos = (crossAvail - effectiveChildCross) / 2
354
- break
361
+ crossPos = (crossAvail - effectiveChildCross) / 2;
362
+ break;
355
363
  case 'end':
356
- crossPos = crossAvail - childCross
357
- break
364
+ crossPos = crossAvail - childCross;
365
+ break;
358
366
  default:
359
- break
367
+ break;
360
368
  }
361
369
 
362
370
  const clampCrossSize =
363
- (!isVertical && align === 'center' && child.type === 'text')
364
- ? effectiveChildCross
365
- : childCross
371
+ !isVertical && align === 'center' && child.type === 'text' ? effectiveChildCross : childCross;
366
372
  if (crossAvail >= clampCrossSize) {
367
- crossPos = Math.max(0, Math.min(crossPos, crossAvail - clampCrossSize))
373
+ crossPos = Math.max(0, Math.min(crossPos, crossAvail - clampCrossSize));
368
374
  }
369
375
 
370
- const computedX = Math.round(isVertical ? pad.left + crossPos : pad.left + mainPos)
371
- 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);
372
378
 
373
- mainPos += (isVertical ? size.h : size.w) + effectiveGap
379
+ mainPos += (isVertical ? size.h : size.w) + effectiveGap;
374
380
 
375
381
  const out: Record<string, unknown> = {
376
382
  ...child,
@@ -378,77 +384,102 @@ export function computeLayoutPositions(
378
384
  y: computedY,
379
385
  width: size.w,
380
386
  height: size.h,
381
- }
387
+ };
382
388
 
383
389
  if (isVertical && align === 'center' && child.type === 'text') {
384
- const hasExplicitAlign = 'textAlign' in child && child.textAlign && child.textAlign !== 'left'
390
+ const hasExplicitAlign =
391
+ 'textAlign' in child && child.textAlign && child.textAlign !== 'left';
385
392
  if (!hasExplicitAlign) {
386
- out.width = availW
387
- out.x = Math.round(pad.left)
388
- out.textAlign = 'center'
393
+ out.width = availW;
394
+ out.x = Math.round(pad.left);
395
+ out.textAlign = 'center';
389
396
  }
390
397
  }
391
398
 
392
- return out as unknown as PenNode
393
- })
399
+ return out as unknown as PenNode;
400
+ });
394
401
 
395
402
  if (badgeNodes.length > 0) {
396
- return [...badgeNodes, ...positioned]
403
+ return [...badgeNodes, ...positioned];
397
404
  }
398
- return positioned
405
+ return positioned;
399
406
  }
400
407
 
401
408
  function normalizeJustifyContent(
402
409
  value: unknown,
403
410
  ): 'start' | 'center' | 'end' | 'space_between' | 'space_around' {
404
- if (typeof value !== 'string') return 'start'
405
- const v = value.trim().toLowerCase()
411
+ if (typeof value !== 'string') return 'start';
412
+ const v = value.trim().toLowerCase();
406
413
  switch (v) {
407
414
  case 'start':
408
415
  case 'flex-start':
409
416
  case 'left':
410
417
  case 'top':
411
- return 'start'
418
+ return 'start';
412
419
  case 'center':
413
420
  case 'middle':
414
- return 'center'
421
+ return 'center';
415
422
  case 'end':
416
423
  case 'flex-end':
417
424
  case 'right':
418
425
  case 'bottom':
419
- return 'end'
426
+ return 'end';
420
427
  case 'space_between':
421
428
  case 'space-between':
422
- return 'space_between'
429
+ return 'space_between';
423
430
  case 'space_around':
424
431
  case 'space-around':
425
- return 'space_around'
432
+ return 'space_around';
426
433
  default:
427
- return 'start'
434
+ return 'start';
428
435
  }
429
436
  }
430
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
+ */
431
459
  function normalizeAlignItems(value: unknown): 'start' | 'center' | 'end' {
432
- if (typeof value !== 'string') return 'start'
433
- const v = value.trim().toLowerCase()
460
+ if (typeof value !== 'string') return 'start';
461
+ const v = value.trim().toLowerCase();
434
462
  switch (v) {
435
463
  case 'start':
436
464
  case 'flex-start':
437
465
  case 'left':
438
466
  case 'top':
439
- return 'start'
467
+ return 'start';
440
468
  case 'center':
441
469
  case 'middle':
442
- return 'center'
470
+ return 'center';
443
471
  case 'end':
444
472
  case 'flex-end':
445
473
  case 'right':
446
474
  case 'bottom':
447
- return 'end'
475
+ case 'baseline':
476
+ case 'last baseline':
477
+ case 'first baseline':
478
+ return 'end';
448
479
  default:
449
- return 'start'
480
+ return 'start';
450
481
  }
451
482
  }
452
483
 
453
484
  // Re-export estimateLineWidth for convenience
454
- export { estimateLineWidth }
485
+ export { estimateLineWidth };