@zseven-w/pen-core 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ // ---------------------------------------------------------------------------
2
+ // CSS font family quoting — extracted for portability (no CanvasKit deps)
3
+ // ---------------------------------------------------------------------------
4
+
5
+ const GENERIC_FAMILIES = new Set([
6
+ 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui',
7
+ 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
8
+ '-apple-system', 'blinkmacsystemfont',
9
+ ])
10
+
11
+ export function cssFontFamily(family: string): string {
12
+ return family.split(',').map(f => {
13
+ const trimmed = f.trim()
14
+ if (!trimmed) return trimmed
15
+ // Already quoted
16
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
17
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))) return trimmed
18
+ // Generic families must not be quoted
19
+ if (GENERIC_FAMILIES.has(trimmed.toLowerCase())) return trimmed
20
+ // Quote everything else (safe even for single-word names)
21
+ return `"${trimmed}"`
22
+ }).join(', ')
23
+ }
package/src/id.ts ADDED
@@ -0,0 +1 @@
1
+ export { nanoid as generateId } from 'nanoid'
package/src/index.ts ADDED
@@ -0,0 +1,133 @@
1
+ // ID generation
2
+ export { generateId } from './id.js'
3
+
4
+ // Tree utilities
5
+ export {
6
+ DEFAULT_FRAME_ID,
7
+ DEFAULT_PAGE_ID,
8
+ createEmptyDocument,
9
+ getActivePage,
10
+ getActivePageChildren,
11
+ setActivePageChildren,
12
+ getAllChildren,
13
+ migrateToPages,
14
+ ensureDocumentNodeIds,
15
+ findNodeInTree,
16
+ findParentInTree,
17
+ removeNodeFromTree,
18
+ updateNodeInTree,
19
+ flattenNodes,
20
+ insertNodeInTree,
21
+ isDescendantOf,
22
+ getNodeBounds,
23
+ findClearX,
24
+ scaleChildrenInPlace,
25
+ rotateChildrenInPlace,
26
+ deepCloneNode,
27
+ cloneNodeWithNewIds,
28
+ cloneNodesWithNewIds,
29
+ } from './tree-utils.js'
30
+
31
+ // Variables
32
+ export {
33
+ isVariableRef,
34
+ getDefaultTheme,
35
+ resolveVariableRef,
36
+ resolveColorRef,
37
+ resolveNumericRef,
38
+ resolveNodeForCanvas,
39
+ } from './variables/resolve.js'
40
+ export { replaceVariableRefsInTree } from './variables/replace-refs.js'
41
+
42
+ // Normalization
43
+ export { normalizePenDocument } from './normalize.js'
44
+
45
+ // Layout
46
+ export {
47
+ type Padding,
48
+ resolvePadding,
49
+ isNodeVisible,
50
+ setRootChildrenProvider,
51
+ getRootFillWidthFallback,
52
+ inferLayout,
53
+ fitContentWidth,
54
+ fitContentHeight,
55
+ getNodeWidth,
56
+ getNodeHeight,
57
+ computeLayoutPositions,
58
+ } from './layout/engine.js'
59
+
60
+ // Text measurement
61
+ export {
62
+ parseSizing,
63
+ defaultLineHeight,
64
+ isCjkCodePoint,
65
+ hasCjkText,
66
+ estimateGlyphWidth,
67
+ estimateLineWidth,
68
+ widthSafetyFactor,
69
+ estimateTextWidth,
70
+ estimateTextWidthPrecise,
71
+ resolveTextContent,
72
+ countExplicitTextLines,
73
+ getTextOpticalCenterYOffset,
74
+ countWrappedLinesFallback,
75
+ type WrappedLineCounter,
76
+ setWrappedLineCounter,
77
+ estimateTextHeight,
78
+ } from './layout/text-measure.js'
79
+
80
+ // Constants
81
+ export {
82
+ MIN_ZOOM,
83
+ MAX_ZOOM,
84
+ ZOOM_STEP,
85
+ SNAP_THRESHOLD,
86
+ DEFAULT_FILL,
87
+ DEFAULT_STROKE,
88
+ DEFAULT_STROKE_WIDTH,
89
+ CANVAS_BACKGROUND_LIGHT,
90
+ CANVAS_BACKGROUND_DARK,
91
+ SELECTION_BLUE,
92
+ COMPONENT_COLOR,
93
+ INSTANCE_COLOR,
94
+ HOVER_BLUE,
95
+ HOVER_LINE_WIDTH,
96
+ HOVER_DASH,
97
+ INDICATOR_BLUE,
98
+ INDICATOR_LINE_WIDTH,
99
+ INDICATOR_DASH,
100
+ INDICATOR_ENDPOINT_RADIUS,
101
+ FRAME_LABEL_FONT_SIZE,
102
+ FRAME_LABEL_OFFSET_Y,
103
+ FRAME_LABEL_COLOR,
104
+ PEN_ANCHOR_FILL,
105
+ PEN_ANCHOR_RADIUS,
106
+ PEN_ANCHOR_FIRST_RADIUS,
107
+ PEN_HANDLE_DOT_RADIUS,
108
+ PEN_HANDLE_LINE_STROKE,
109
+ PEN_RUBBER_BAND_STROKE,
110
+ PEN_RUBBER_BAND_DASH,
111
+ PEN_CLOSE_HIT_THRESHOLD,
112
+ DIMENSION_LABEL_OFFSET_Y,
113
+ DEFAULT_FRAME_FILL,
114
+ DEFAULT_TEXT_FILL,
115
+ GUIDE_COLOR,
116
+ GUIDE_LINE_WIDTH,
117
+ GUIDE_DASH,
118
+ } from './constants.js'
119
+
120
+ // Sync lock
121
+ export { isFabricSyncLocked, setFabricSyncLock } from './sync-lock.js'
122
+
123
+ // Arc path
124
+ export { buildEllipseArcPath, isArcEllipse } from './arc-path.js'
125
+
126
+ // Boolean operations
127
+ export { type BooleanOpType, canBooleanOp, executeBooleanOp } from './boolean-ops.js'
128
+
129
+ // Font utilities
130
+ export { cssFontFamily } from './font-utils.js'
131
+
132
+ // Node helpers
133
+ export { isBadgeOverlayNode } from './node-helpers.js'
@@ -0,0 +1,460 @@
1
+ import type { PenNode, ContainerProps, SizingBehavior } from '@zseven-w/pen-types'
2
+ import { isBadgeOverlayNode } from '../node-helpers.js'
3
+ import {
4
+ parseSizing,
5
+ estimateTextWidth,
6
+ estimateTextWidthPrecise,
7
+ estimateTextHeight,
8
+ estimateLineWidth,
9
+ resolveTextContent,
10
+ countExplicitTextLines,
11
+ defaultLineHeight,
12
+ } from './text-measure.js'
13
+
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Padding
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export interface Padding {
20
+ top: number
21
+ right: number
22
+ bottom: number
23
+ left: number
24
+ }
25
+
26
+ export function resolvePadding(
27
+ padding:
28
+ | number
29
+ | [number, number]
30
+ | [number, number, number, number]
31
+ | string
32
+ | undefined,
33
+ ): Padding {
34
+ if (!padding || typeof padding === 'string')
35
+ return { top: 0, right: 0, bottom: 0, left: 0 }
36
+ if (typeof padding === 'number')
37
+ return { top: padding, right: padding, bottom: padding, left: padding }
38
+ if (padding.length === 2)
39
+ return {
40
+ top: padding[0],
41
+ right: padding[1],
42
+ bottom: padding[0],
43
+ left: padding[1],
44
+ }
45
+ return {
46
+ top: padding[0],
47
+ right: padding[1],
48
+ bottom: padding[2],
49
+ left: padding[3],
50
+ }
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Visibility check
55
+ // ---------------------------------------------------------------------------
56
+
57
+ export function isNodeVisible(node: PenNode): boolean {
58
+ return ('visible' in node ? node.visible : undefined) !== false
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Root fill-width fallback
63
+ // ---------------------------------------------------------------------------
64
+
65
+ const DEFAULT_FRAME_ID = 'root-frame'
66
+
67
+ /** Resolve root fill-width fallback. Pass root children to avoid store coupling. */
68
+ let _rootChildrenProvider: (() => PenNode[]) | null = null
69
+
70
+ /** Set a provider function for root children (called once from app initialization). */
71
+ export function setRootChildrenProvider(provider: () => PenNode[]): void {
72
+ _rootChildrenProvider = provider
73
+ }
74
+
75
+ export function getRootFillWidthFallback(): number {
76
+ const roots = _rootChildrenProvider?.() ?? []
77
+ 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
86
+ }
87
+ const anyTopFrame = roots.find(
88
+ (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
92
+ }
93
+ return 1200
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Layout inference — shared logic for detecting implicit layout
98
+ // ---------------------------------------------------------------------------
99
+
100
+ 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'
105
+ if ('children' in node && node.children?.length) {
106
+ 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'
109
+ }
110
+ }
111
+ return undefined
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Fit-content size computation
116
+ // ---------------------------------------------------------------------------
117
+
118
+ export function fitContentWidth(node: PenNode, parentAvail?: number): number {
119
+ if (!('children' in node) || !node.children?.length) return 0
120
+ const visibleChildren = node.children.filter(
121
+ (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
128
+ 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
135
+ }
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
141
+ }
142
+
143
+ export function fitContentHeight(node: PenNode, parentAvailW?: number): number {
144
+ if (!('children' in node) || !node.children?.length) return 0
145
+ const visibleChildren = node.children.filter(
146
+ (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
155
+ 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
159
+ }
160
+ const maxChildH = visibleChildren.reduce((max, ch) => Math.max(max, getNodeHeight(ch, undefined, childAvailW)), 0)
161
+ return maxChildH + pad.top + pad.bottom
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Node dimension resolution
166
+ // ---------------------------------------------------------------------------
167
+
168
+ export function getNodeWidth(node: PenNode, parentAvail?: number): number {
169
+ if ('width' in node) {
170
+ const s = parseSizing(node.width)
171
+ if (typeof s === 'number' && s > 0) return s
172
+ if (s === 'fill') {
173
+ if (parentAvail && parentAvail > 0) return parentAvail
174
+ if (node.type !== 'text') {
175
+ const fallbackFillW = getRootFillWidthFallback()
176
+ if (fallbackFillW > 0) return fallbackFillW
177
+ }
178
+ if ('children' in node && node.children?.length) {
179
+ const intrinsic = fitContentWidth(node)
180
+ if (intrinsic > 0) return intrinsic
181
+ }
182
+ 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)
191
+ }
192
+ }
193
+ if (s === 'fit') {
194
+ const fit = fitContentWidth(node, parentAvail)
195
+ if (fit > 0) return fit
196
+ }
197
+ }
198
+ if ('children' in node && node.children?.length) {
199
+ const fit = fitContentWidth(node, parentAvail)
200
+ if (fit > 0) return fit
201
+ }
202
+ 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)
211
+ }
212
+ return 0
213
+ }
214
+
215
+ export function getNodeHeight(node: PenNode, parentAvail?: number, parentAvailW?: number): number {
216
+ 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
220
+ if (s === 'fit') {
221
+ const fit = fitContentHeight(node, parentAvailW)
222
+ if (fit > 0) return fit
223
+ }
224
+ }
225
+ if ('children' in node && node.children?.length) {
226
+ const fit = fitContentHeight(node, parentAvailW)
227
+ if (fit > 0) return fit
228
+ }
229
+ if (node.type === 'text') {
230
+ return estimateTextHeight(node, parentAvailW)
231
+ }
232
+ return 0
233
+ }
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // Auto-layout position computation
237
+ // ---------------------------------------------------------------------------
238
+
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)
268
+
269
+ const mainSizing = layoutChildren.map((ch) => {
270
+ const prop = isVertical ? 'height' : 'width'
271
+ if (prop in ch) {
272
+ const s = parseSizing((ch as PenNode & { width?: SizingBehavior; height?: SizingBehavior })[prop])
273
+ if (s === 'fill') return 'fill' as const
274
+ }
275
+ return isVertical ? getNodeHeight(ch, availH, availW) : getNodeWidth(ch, availW)
276
+ })
277
+ const fixedTotal = mainSizing.reduce<number>(
278
+ (sum, s) => sum + (typeof s === 'number' ? s : 0),
279
+ 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
284
+
285
+ const sizes = layoutChildren.map((ch, i) => {
286
+ let mainSize = mainSizing[i] === 'fill' ? fillSize : (mainSizing[i] as number)
287
+ if (isVertical && ch.type === 'text' && mainSizing[i] !== 'fill') {
288
+ const content = resolveTextContent(ch)
289
+ 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)
294
+ if (estH <= singleLineH + 1) {
295
+ mainSize = singleLineH
296
+ }
297
+ }
298
+ }
299
+ return {
300
+ w: isVertical ? getNodeWidth(ch, availW) : mainSize,
301
+ h: isVertical ? mainSize : getNodeHeight(ch, availH, isVertical ? availW : mainSize),
302
+ }
303
+ })
304
+
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)
310
+
311
+ let mainPos = 0
312
+ let effectiveGap = gap
313
+
314
+ switch (justify) {
315
+ case 'center':
316
+ mainPos = freeSpace / 2
317
+ break
318
+ case 'end':
319
+ mainPos = freeSpace
320
+ break
321
+ case 'space_between':
322
+ effectiveGap =
323
+ layoutChildren.length > 1
324
+ ? (availMain - totalMain) / (layoutChildren.length - 1)
325
+ : 0
326
+ break
327
+ case 'space_around': {
328
+ const spacing =
329
+ layoutChildren.length > 0
330
+ ? (availMain - totalMain) / layoutChildren.length
331
+ : 0
332
+ mainPos = spacing / 2
333
+ effectiveGap = spacing
334
+ break
335
+ }
336
+ default:
337
+ break
338
+ }
339
+
340
+ 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
345
+
346
+ let effectiveChildCross = childCross
347
+ 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
352
+ if (isSingleLine) {
353
+ effectiveChildCross = fontSize * lineHeight
354
+ }
355
+ }
356
+
357
+ switch (align) {
358
+ case 'center':
359
+ crossPos = (crossAvail - effectiveChildCross) / 2
360
+ break
361
+ case 'end':
362
+ crossPos = crossAvail - childCross
363
+ break
364
+ default:
365
+ break
366
+ }
367
+
368
+ const clampCrossSize =
369
+ (!isVertical && align === 'center' && child.type === 'text')
370
+ ? effectiveChildCross
371
+ : childCross
372
+ if (crossAvail >= clampCrossSize) {
373
+ crossPos = Math.max(0, Math.min(crossPos, crossAvail - clampCrossSize))
374
+ }
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)
378
+
379
+ mainPos += (isVertical ? size.h : size.w) + effectiveGap
380
+
381
+ const out: Record<string, unknown> = {
382
+ ...child,
383
+ x: computedX,
384
+ y: computedY,
385
+ width: size.w,
386
+ height: size.h,
387
+ }
388
+
389
+ if (isVertical && align === 'center' && child.type === 'text') {
390
+ const hasExplicitAlign = 'textAlign' in child && child.textAlign && child.textAlign !== 'left'
391
+ if (!hasExplicitAlign) {
392
+ out.width = availW
393
+ out.x = Math.round(pad.left)
394
+ out.textAlign = 'center'
395
+ }
396
+ }
397
+
398
+ return out as unknown as PenNode
399
+ })
400
+
401
+ if (badgeNodes.length > 0) {
402
+ return [...badgeNodes, ...positioned]
403
+ }
404
+ return positioned
405
+ }
406
+
407
+ function normalizeJustifyContent(
408
+ value: unknown,
409
+ ): 'start' | 'center' | 'end' | 'space_between' | 'space_around' {
410
+ if (typeof value !== 'string') return 'start'
411
+ const v = value.trim().toLowerCase()
412
+ switch (v) {
413
+ case 'start':
414
+ case 'flex-start':
415
+ case 'left':
416
+ case 'top':
417
+ return 'start'
418
+ case 'center':
419
+ case 'middle':
420
+ return 'center'
421
+ case 'end':
422
+ case 'flex-end':
423
+ case 'right':
424
+ case 'bottom':
425
+ return 'end'
426
+ case 'space_between':
427
+ case 'space-between':
428
+ return 'space_between'
429
+ case 'space_around':
430
+ case 'space-around':
431
+ return 'space_around'
432
+ default:
433
+ return 'start'
434
+ }
435
+ }
436
+
437
+ function normalizeAlignItems(value: unknown): 'start' | 'center' | 'end' {
438
+ if (typeof value !== 'string') return 'start'
439
+ const v = value.trim().toLowerCase()
440
+ switch (v) {
441
+ case 'start':
442
+ case 'flex-start':
443
+ case 'left':
444
+ case 'top':
445
+ return 'start'
446
+ case 'center':
447
+ case 'middle':
448
+ return 'center'
449
+ case 'end':
450
+ case 'flex-end':
451
+ case 'right':
452
+ case 'bottom':
453
+ return 'end'
454
+ default:
455
+ return 'start'
456
+ }
457
+ }
458
+
459
+ // Re-export estimateLineWidth for convenience
460
+ export { estimateLineWidth }