@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
@@ -0,0 +1,140 @@
1
+ import type { PenNode, ContainerProps } from '@zseven-w/pen-types';
2
+ import { isBadgeOverlayNode } from '../node-helpers.js';
3
+ import { inferLayout } from './engine.js';
4
+
5
+ /**
6
+ * Normalize layout state across a node tree (mutates in place).
7
+ *
8
+ * Two fixes applied recursively to every frame:
9
+ *
10
+ * 1. When a frame has children but no explicit `layout`, write one:
11
+ * - First try `inferLayout()` (horizontal signals: gap, padding,
12
+ * fill_container children).
13
+ * - If that returns undefined and the frame has 2+ children, fall back
14
+ * to `vertical` — BUT only when no non-overlay child carries explicit
15
+ * `x`/`y`. Any such coordinate is treated as a deliberate signal that
16
+ * the frame is an absolute-positioning container (phone mockups, hero
17
+ * images with floating overlays, etc.) and we leave it alone.
18
+ *
19
+ * 2. When a frame has an active layout (`vertical` or `horizontal`), strip
20
+ * `x`/`y` from every non-overlay child. Overlay children (badges, pills,
21
+ * tags, floating indicators) keep their absolute coordinates.
22
+ *
23
+ * Used as a post-generation pass after an AI model produces a subtree. It
24
+ * corrects two common model mistakes:
25
+ * - Forgetting to set `layout` on a container (children would otherwise
26
+ * stack at (0,0) because `computeLayoutPositions` skips layout-less
27
+ * parents).
28
+ * - Leaving stale `x`/`y` on children of an auto-layout frame (causes
29
+ * visible misalignment when the layout engine also tries to position
30
+ * them).
31
+ *
32
+ * Ordering requirement: MUST run AFTER any role-based resolution pass that
33
+ * populates `layout` from semantic roles (e.g. navbar → 'horizontal').
34
+ * Running this first would write the generic 'vertical' fallback onto a
35
+ * navbar frame, and the later role resolver — which only fills undefined
36
+ * fields — would refuse to overwrite it. Treat this function as the last
37
+ * safety net, not the first opinion.
38
+ */
39
+ export function normalizeTreeLayout(node: PenNode): void {
40
+ if (node.type === 'frame' && 'children' in node && Array.isArray(node.children)) {
41
+ const c = node as PenNode & ContainerProps;
42
+ const children = node.children;
43
+
44
+ // (1) Ensure an explicit layout when children exist.
45
+ // Only fill in when `layout` is missing — an explicit `'none'` is
46
+ // intentional (absolute positioning) and must be preserved.
47
+ if (c.layout == null && children.length > 0) {
48
+ const inferred = inferLayout(node);
49
+ if (inferred) {
50
+ c.layout = inferred;
51
+ } else if (children.length >= 2 && !hasAbsolutePositionedChild(children)) {
52
+ // Safe to treat as a "model forgot layout" case: nobody carries x/y,
53
+ // so there's no absolute-positioning intent to destroy.
54
+ c.layout = 'vertical';
55
+ }
56
+ }
57
+
58
+ // (2) Strip x/y from non-overlay children of active-layout frames.
59
+ if (c.layout === 'vertical' || c.layout === 'horizontal') {
60
+ for (const child of children) {
61
+ if (!isBadgeOverlayNode(child)) {
62
+ if ('x' in child) delete (child as { x?: number }).x;
63
+ if ('y' in child) delete (child as { y?: number }).y;
64
+ }
65
+ }
66
+ }
67
+ }
68
+
69
+ // Recurse into children regardless of node type (groups/pages may nest frames).
70
+ if ('children' in node && Array.isArray(node.children)) {
71
+ for (const child of node.children) {
72
+ normalizeTreeLayout(child);
73
+ }
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Decide whether a layout-less parent should be treated as an absolute-
79
+ * positioning container (skip the `vertical` fallback) instead of being
80
+ * verticalized.
81
+ *
82
+ * Two signals count as "this is an absolute-positioning container":
83
+ *
84
+ * 1. A non-overlay child has a numeric `x` or `y` (including 0). Models
85
+ * that explicitly position children clearly intend absolute layout.
86
+ *
87
+ * 2. EVERY non-overlay child is a "composition primitive" — currently
88
+ * `frame`, `ellipse`, or `path`. AI models routinely emit structured
89
+ * compositions at (0,0) without writing x/y at all: nested-frame
90
+ * badge stacks, hero overlays, rings built from concentric
91
+ * ellipses, icon compositions built from paths. The previous check
92
+ * only counted frame children, so an ellipse-only or path-only
93
+ * composition (e.g. 3 concentric ellipses forming a progress ring)
94
+ * fell through to the vertical fallback and rendered as a broken
95
+ * list.
96
+ *
97
+ * The rule intentionally does NOT treat `rectangle` as a
98
+ * composition primitive: plain rectangles are by far the most
99
+ * common children in content stacks (nav buttons, list items,
100
+ * card backgrounds) and defaulting those to overlay would silently
101
+ * break many more designs than it fixes. When you really want a
102
+ * rectangle overlay composition, declare x/y explicitly.
103
+ *
104
+ * Mixed content stacks (frame + text, shape + icon_font, etc.)
105
+ * still get the vertical fallback, because those are typically
106
+ * content stacks where verticalization is the right call.
107
+ *
108
+ * Overlay nodes (badges/pills/tags via `isBadgeOverlayNode`) are excluded
109
+ * from the count — they legitimately carry x/y inside auto-layout frames
110
+ * and shouldn't tip the heuristic.
111
+ *
112
+ * This is intentionally conservative: it accepts a few false negatives
113
+ * (some genuinely vertical all-shape stacks will be left un-normalized,
114
+ * forcing the AI to declare layout explicitly) to eliminate the
115
+ * catastrophic false-positive of silently verticalizing an overlay
116
+ * composition.
117
+ */
118
+ const COMPOSITION_PRIMITIVE_TYPES = new Set(['frame', 'ellipse', 'path']);
119
+
120
+ function hasAbsolutePositionedChild(children: PenNode[]): boolean {
121
+ // Signal 1: explicit numeric x/y on any non-overlay child.
122
+ for (const child of children) {
123
+ if (isBadgeOverlayNode(child)) continue;
124
+ const c = child as PenNode & { x?: number; y?: number };
125
+ if (typeof c.x === 'number' || typeof c.y === 'number') return true;
126
+ }
127
+
128
+ // Signal 2: every non-overlay child is a composition primitive
129
+ // (>= 2 such children).
130
+ let nonOverlayCount = 0;
131
+ let primitiveCount = 0;
132
+ for (const child of children) {
133
+ if (isBadgeOverlayNode(child)) continue;
134
+ nonOverlayCount++;
135
+ if (COMPOSITION_PRIMITIVE_TYPES.has(child.type)) primitiveCount++;
136
+ }
137
+ if (nonOverlayCount >= 2 && primitiveCount === nonOverlayCount) return true;
138
+
139
+ return false;
140
+ }
@@ -0,0 +1,155 @@
1
+ import type { PenNode, PenFill, SolidFill } from '@zseven-w/pen-types';
2
+
3
+ /**
4
+ * Strip redundant "section-level" fills from the direct children of a
5
+ * page root frame.
6
+ *
7
+ * Weaker sub-agents (MiniMax M2, GLM, Kimi) often hedge by writing a
8
+ * hardcoded dark hex (`#0A0A0A`, `#111`, etc.) on every section root they
9
+ * produce. That hex then completely covers the page root's intended
10
+ * background color, breaking theme switching and creating visible seams
11
+ * between sections. Cards, buttons, chips and other legitimately filled
12
+ * components are NOT affected — only "section container" frames (direct
13
+ * children of the root that either have no role or have a structural
14
+ * role).
15
+ *
16
+ * ⚠️ SCOPE CONTRACT — callers MUST only pass the true page root frame.
17
+ * Calling this on an arbitrary nested frame (a card, a component, a
18
+ * sub-agent's own root that is NOT the page root) will incorrectly treat
19
+ * that frame's inner children as "sections" and may erase intended
20
+ * nested fills (e.g. a card's own dark header). The function itself is
21
+ * strictly non-recursive — it only touches direct children of the passed
22
+ * node — so mis-targeted calls are bounded, but still wrong.
23
+ *
24
+ * Returns `true` when any fill was stripped, so the caller can publish a
25
+ * store update.
26
+ */
27
+ export function stripRedundantSectionFills(rootFrame: PenNode): boolean {
28
+ if (!('children' in rootFrame) || !Array.isArray(rootFrame.children)) return false;
29
+
30
+ const rootFill = getFirstSolidColor(rootFrame);
31
+ let changed = false;
32
+
33
+ for (const child of rootFrame.children) {
34
+ if (child.type !== 'frame') continue;
35
+ if (!isSectionLevelFrame(child)) continue;
36
+
37
+ const childFill = getFirstSolidColor(child);
38
+ if (!childFill) continue;
39
+
40
+ if (shouldStripFill(childFill, rootFill)) {
41
+ delete (child as PenNode & { fill?: unknown }).fill;
42
+ changed = true;
43
+ }
44
+ }
45
+
46
+ return changed;
47
+ }
48
+
49
+ /**
50
+ * Roles that identify visually distinct components and must never have
51
+ * their fill stripped.
52
+ */
53
+ const PROTECTED_ROLES = new Set([
54
+ 'card',
55
+ 'stat-card',
56
+ 'pricing-card',
57
+ 'feature-card',
58
+ 'image-card',
59
+ 'testimonial',
60
+ 'button',
61
+ 'icon-button',
62
+ 'badge',
63
+ 'chip',
64
+ 'tag',
65
+ 'pill',
66
+ 'input',
67
+ 'form-input',
68
+ 'search-bar',
69
+ 'phone-mockup',
70
+ 'banner',
71
+ 'metric-card',
72
+ 'gallery-item',
73
+ 'status-bar',
74
+ ]);
75
+
76
+ /**
77
+ * Roles that are considered structural — just a container grouping other
78
+ * nodes. These are candidates for fill stripping when they echo the root
79
+ * background or hedge with a safe-dark fill.
80
+ */
81
+ const STRUCTURAL_ROLES = new Set([
82
+ 'section',
83
+ 'row',
84
+ 'column',
85
+ 'stack',
86
+ 'container',
87
+ 'content-area',
88
+ 'section-header',
89
+ 'wrapper',
90
+ 'group',
91
+ 'hero',
92
+ 'footer',
93
+ 'cta-section',
94
+ 'stats-section',
95
+ ]);
96
+
97
+ function isSectionLevelFrame(node: PenNode): boolean {
98
+ const role = (node as PenNode & { role?: string }).role;
99
+ if (!role) return true; // unrolled section root
100
+ if (PROTECTED_ROLES.has(role)) return false;
101
+ if (STRUCTURAL_ROLES.has(role)) return true;
102
+ // Unknown role: be conservative, treat as protected so we don't clobber
103
+ // future role additions.
104
+ return false;
105
+ }
106
+
107
+ /**
108
+ * Hex tints that sub-agents reach for when they want a "safe dark"
109
+ * background without knowing the real design background color. Any of
110
+ * these on a section container is almost certainly a hedge, not an
111
+ * intentional visual choice.
112
+ */
113
+ const SAFE_DARK_HEXES = new Set([
114
+ '#000000',
115
+ '#000',
116
+ '#0a0a0a',
117
+ '#0f0f0f',
118
+ '#111',
119
+ '#111111',
120
+ '#121212',
121
+ '#141414',
122
+ '#1a1a1a',
123
+ '#181818',
124
+ '#1c1c1c',
125
+ '#1e1e1e',
126
+ '#202020',
127
+ ]);
128
+
129
+ function shouldStripFill(childFill: string, rootFill: string | null): boolean {
130
+ const childKey = normalizeHex(childFill);
131
+ if (rootFill) {
132
+ const rootKey = normalizeHex(rootFill);
133
+ if (childKey === rootKey) return true;
134
+ }
135
+ return SAFE_DARK_HEXES.has(childKey);
136
+ }
137
+
138
+ function normalizeHex(color: string): string {
139
+ let c = color.trim().toLowerCase();
140
+ // Strip alpha if present (#rrggbbaa → #rrggbb)
141
+ if (c.length === 9 && c.startsWith('#')) c = c.slice(0, 7);
142
+ return c;
143
+ }
144
+
145
+ function getFirstSolidColor(node: PenNode): string | null {
146
+ const fill = (node as PenNode & { fill?: PenFill[] | string }).fill;
147
+ if (!fill) return null;
148
+ if (typeof fill === 'string') return fill;
149
+ if (!Array.isArray(fill) || fill.length === 0) return null;
150
+ const first = fill[0];
151
+ if (first && first.type === 'solid') {
152
+ return (first as SolidFill).color;
153
+ }
154
+ return null;
155
+ }
@@ -1,4 +1,4 @@
1
- import type { PenNode } from '@zseven-w/pen-types'
1
+ import type { PenNode } from '@zseven-w/pen-types';
2
2
 
3
3
  // ---------------------------------------------------------------------------
4
4
  // Sizing parser (shared by layout engine and text height estimation)
@@ -6,12 +6,16 @@ import type { PenNode } from '@zseven-w/pen-types'
6
6
 
7
7
  /** Parse a sizing value. Handles number, "fit_content", "fill_container" and parenthesized forms. */
8
8
  export function parseSizing(value: unknown): number | 'fit' | 'fill' {
9
- if (typeof value === 'number') return value
10
- if (typeof value !== 'string') return 0
11
- if (value.startsWith('fill_container')) return 'fill'
12
- if (value.startsWith('fit_content')) return 'fit'
13
- const n = parseFloat(value)
14
- return isNaN(n) ? 0 : n
9
+ if (typeof value === 'number') return value;
10
+ if (typeof value !== 'string') return 0;
11
+ if (value.startsWith('fill_container')) return 'fill';
12
+ if (value.startsWith('fit_content')) {
13
+ const match = value.match(/\((\d+(?:\.\d+)?)\)/);
14
+ if (match) return parseFloat(match[1]);
15
+ return 'fit';
16
+ }
17
+ const n = parseFloat(value);
18
+ return isNaN(n) ? 0 : n;
15
19
  }
16
20
 
17
21
  // ---------------------------------------------------------------------------
@@ -25,10 +29,10 @@ export function parseSizing(value: unknown): number | 'fit' | 'fill' {
25
29
  * MUST use this function instead of hardcoded fallbacks.
26
30
  */
27
31
  export function defaultLineHeight(fontSize: number): number {
28
- if (fontSize >= 40) return 1.0 // Display text: tight leading (matches Pencil 0.9-1.0)
29
- if (fontSize >= 28) return 1.15 // Heading text: moderate (matches Pencil 1.0-1.2)
30
- if (fontSize >= 20) return 1.2 // Subheading
31
- return 1.5 // Body text: comfortable reading
32
+ if (fontSize >= 40) return 1.0; // Display text: tight leading (matches Pencil 0.9-1.0)
33
+ if (fontSize >= 28) return 1.15; // Heading text: moderate (matches Pencil 1.0-1.2)
34
+ if (fontSize >= 20) return 1.2; // Subheading
35
+ return 1.5; // Body text: comfortable reading
32
36
  }
33
37
 
34
38
  // ---------------------------------------------------------------------------
@@ -36,20 +40,22 @@ export function defaultLineHeight(fontSize: number): number {
36
40
  // ---------------------------------------------------------------------------
37
41
 
38
42
  export function isCjkCodePoint(code: number): boolean {
39
- return (code >= 0x4E00 && code <= 0x9FFF) // CJK Unified Ideographs
40
- || (code >= 0x3400 && code <= 0x4DBF) // CJK Extension A
41
- || (code >= 0x3040 && code <= 0x30FF) // Hiragana + Katakana
42
- || (code >= 0xAC00 && code <= 0xD7AF) // Hangul
43
- || (code >= 0x3000 && code <= 0x303F) // CJK symbols/punctuation
44
- || (code >= 0xFF00 && code <= 0xFFEF) // Full-width forms
43
+ return (
44
+ (code >= 0x4e00 && code <= 0x9fff) || // CJK Unified Ideographs
45
+ (code >= 0x3400 && code <= 0x4dbf) || // CJK Extension A
46
+ (code >= 0x3040 && code <= 0x30ff) || // Hiragana + Katakana
47
+ (code >= 0xac00 && code <= 0xd7af) || // Hangul
48
+ (code >= 0x3000 && code <= 0x303f) || // CJK symbols/punctuation
49
+ (code >= 0xff00 && code <= 0xffef)
50
+ ); // Full-width forms
45
51
  }
46
52
 
47
53
  export function hasCjkText(text: string): boolean {
48
54
  for (const ch of text) {
49
- const code = ch.codePointAt(0) ?? 0
50
- if (isCjkCodePoint(code)) return true
55
+ const code = ch.codePointAt(0) ?? 0;
56
+ if (isCjkCodePoint(code)) return true;
51
57
  }
52
- return false
58
+ return false;
53
59
  }
54
60
 
55
61
  // ---------------------------------------------------------------------------
@@ -61,26 +67,30 @@ export function hasCjkText(text: string): boolean {
61
67
  * Values based on typical proportional font width scaling.
62
68
  */
63
69
  function fontWeightFactor(fontWeight?: string | number): number {
64
- const w = typeof fontWeight === 'string' ? parseInt(fontWeight, 10) : (fontWeight ?? 400)
65
- if (isNaN(w) || w <= 400) return 1.0
66
- if (w <= 500) return 1.03
67
- if (w <= 600) return 1.06
68
- if (w <= 700) return 1.09
69
- return 1.12
70
+ const w = typeof fontWeight === 'string' ? parseInt(fontWeight, 10) : (fontWeight ?? 400);
71
+ if (isNaN(w) || w <= 400) return 1.0;
72
+ if (w <= 500) return 1.03;
73
+ if (w <= 600) return 1.06;
74
+ if (w <= 700) return 1.09;
75
+ return 1.12;
70
76
  }
71
77
 
72
- export function estimateGlyphWidth(ch: string, fontSize: number, fontWeight?: string | number): number {
73
- if (ch === '\n' || ch === '\r') return 0
74
- if (ch === '\t') return fontSize * 1.2
75
- if (ch === ' ') return fontSize * 0.33
76
-
77
- const wf = fontWeightFactor(fontWeight)
78
- const code = ch.codePointAt(0) ?? 0
79
- if (isCjkCodePoint(code)) return fontSize * 1.12 * wf
80
- if (/[A-Z]/.test(ch)) return fontSize * 0.62 * wf
81
- if (/[a-z]/.test(ch)) return fontSize * 0.56 * wf
82
- if (/[0-9]/.test(ch)) return fontSize * 0.56 * wf
83
- return fontSize * 0.58 * wf
78
+ export function estimateGlyphWidth(
79
+ ch: string,
80
+ fontSize: number,
81
+ fontWeight?: string | number,
82
+ ): number {
83
+ if (ch === '\n' || ch === '\r') return 0;
84
+ if (ch === '\t') return fontSize * 1.2;
85
+ if (ch === ' ') return fontSize * 0.33;
86
+
87
+ const wf = fontWeightFactor(fontWeight);
88
+ const code = ch.codePointAt(0) ?? 0;
89
+ if (isCjkCodePoint(code)) return fontSize * 1.12 * wf;
90
+ if (/[A-Z]/.test(ch)) return fontSize * 0.62 * wf;
91
+ if (/[a-z]/.test(ch)) return fontSize * 0.56 * wf;
92
+ if (/[0-9]/.test(ch)) return fontSize * 0.56 * wf;
93
+ return fontSize * 0.58 * wf;
84
94
  }
85
95
 
86
96
  export function estimateLineWidth(
@@ -89,32 +99,37 @@ export function estimateLineWidth(
89
99
  letterSpacing = 0,
90
100
  fontWeight?: string | number,
91
101
  ): number {
92
- let width = 0
93
- let visibleChars = 0
102
+ let width = 0;
103
+ let visibleChars = 0;
94
104
  for (const ch of text) {
95
- width += estimateGlyphWidth(ch, fontSize, fontWeight)
96
- if (ch !== '\n' && ch !== '\r') visibleChars += 1
105
+ width += estimateGlyphWidth(ch, fontSize, fontWeight);
106
+ if (ch !== '\n' && ch !== '\r') visibleChars += 1;
97
107
  }
98
108
  if (visibleChars > 1 && letterSpacing !== 0) {
99
- width += (visibleChars - 1) * letterSpacing
109
+ width += (visibleChars - 1) * letterSpacing;
100
110
  }
101
- return Math.max(0, width)
111
+ return Math.max(0, width);
102
112
  }
103
113
 
104
114
  export function widthSafetyFactor(text: string): number {
105
115
  // Latin fonts vary a lot by weight/family; use a larger safety margin to
106
116
  // avoid underestimating width and causing accidental wraps.
107
- return hasCjkText(text) ? 1.06 : 1.14
117
+ return hasCjkText(text) ? 1.06 : 1.14;
108
118
  }
109
119
 
110
- export function estimateTextWidth(text: string, fontSize: number, letterSpacing = 0, fontWeight?: string | number): number {
111
- const lines = text.split(/\r?\n/)
120
+ export function estimateTextWidth(
121
+ text: string,
122
+ fontSize: number,
123
+ letterSpacing = 0,
124
+ fontWeight?: string | number,
125
+ ): number {
126
+ const lines = text.split(/\r?\n/);
112
127
  const maxLine = lines.reduce((max, line) => {
113
- const lineWidth = estimateLineWidth(line, fontSize, letterSpacing, fontWeight)
114
- const safeLineWidth = lineWidth * widthSafetyFactor(line)
115
- return Math.max(max, safeLineWidth)
116
- }, 0)
117
- return maxLine
128
+ const lineWidth = estimateLineWidth(line, fontSize, letterSpacing, fontWeight);
129
+ const safeLineWidth = lineWidth * widthSafetyFactor(line);
130
+ return Math.max(max, safeLineWidth);
131
+ }, 0);
132
+ return maxLine;
118
133
  }
119
134
 
120
135
  /**
@@ -123,11 +138,16 @@ export function estimateTextWidth(text: string, fontSize: number, letterSpacing
123
138
  * off-center (the overestimated width shifts the text box left when centered).
124
139
  * For wrapping/sizing decisions, use estimateTextWidth() which includes the safety factor.
125
140
  */
126
- export function estimateTextWidthPrecise(text: string, fontSize: number, letterSpacing = 0, fontWeight?: string | number): number {
127
- const lines = text.split(/\r?\n/)
141
+ export function estimateTextWidthPrecise(
142
+ text: string,
143
+ fontSize: number,
144
+ letterSpacing = 0,
145
+ fontWeight?: string | number,
146
+ ): number {
147
+ const lines = text.split(/\r?\n/);
128
148
  return lines.reduce((max, line) => {
129
- return Math.max(max, estimateLineWidth(line, fontSize, letterSpacing, fontWeight))
130
- }, 0)
149
+ return Math.max(max, estimateLineWidth(line, fontSize, letterSpacing, fontWeight));
150
+ }, 0);
131
151
  }
132
152
 
133
153
  // ---------------------------------------------------------------------------
@@ -135,19 +155,19 @@ export function estimateTextWidthPrecise(text: string, fontSize: number, letterS
135
155
  // ---------------------------------------------------------------------------
136
156
 
137
157
  export function resolveTextContent(node: PenNode): string {
138
- if (node.type !== 'text') return ''
139
- if (typeof node.content === 'string') return node.content
140
- if (Array.isArray(node.content)) return node.content.map((s) => s.text).join('')
158
+ if (node.type !== 'text') return '';
159
+ if (typeof node.content === 'string') return node.content;
160
+ if (Array.isArray(node.content)) return node.content.map((s) => s.text).join('');
141
161
  // Fallback: MCP/CLI nodes may use `text` instead of `content`
142
162
  if (typeof (node as unknown as Record<string, unknown>).text === 'string') {
143
- return (node as unknown as Record<string, unknown>).text as string
163
+ return (node as unknown as Record<string, unknown>).text as string;
144
164
  }
145
- return ''
165
+ return '';
146
166
  }
147
167
 
148
168
  export function countExplicitTextLines(text: string): number {
149
- if (!text) return 1
150
- return Math.max(1, text.split(/\r?\n/).length)
169
+ if (!text) return 1;
170
+ return Math.max(1, text.split(/\r?\n/).length);
151
171
  }
152
172
 
153
173
  // ---------------------------------------------------------------------------
@@ -161,18 +181,18 @@ export function countExplicitTextLines(text: string): number {
161
181
  * We nudge down proportionally to compensate.
162
182
  */
163
183
  export function getTextOpticalCenterYOffset(node: PenNode): number {
164
- if (node.type !== 'text') return 0
165
- const text = resolveTextContent(node).trim()
166
- if (!text) return 0
167
- if (countExplicitTextLines(text) > 1) return 0
184
+ if (node.type !== 'text') return 0;
185
+ const text = resolveTextContent(node).trim();
186
+ if (!text) return 0;
187
+ if (countExplicitTextLines(text) > 1) return 0;
168
188
 
169
- const fontSize = node.fontSize ?? 16
170
- const hasCjk = hasCjkText(text)
189
+ const fontSize = node.fontSize ?? 16;
190
+ const hasCjk = hasCjkText(text);
171
191
 
172
192
  // CJK glyphs sit higher in the em box than Latin glyphs
173
- const ratio = hasCjk ? 0.06 : 0.03
174
- const offset = fontSize * ratio
175
- return Math.max(0, Math.min(Math.round(fontSize * 0.05), Math.round(offset)))
193
+ const ratio = hasCjk ? 0.06 : 0.03;
194
+ const offset = fontSize * ratio;
195
+ return Math.max(0, Math.min(Math.round(fontSize * 0.05), Math.round(offset)));
176
196
  }
177
197
 
178
198
  // ---------------------------------------------------------------------------
@@ -191,9 +211,10 @@ export function countWrappedLinesFallback(
191
211
  fontWeight: string | number | undefined,
192
212
  ): number {
193
213
  return rawLines.reduce((sum, line) => {
194
- const lineWidth = estimateLineWidth(line, fontSize, letterSpacing, fontWeight) * widthSafetyFactor(line)
195
- return sum + Math.max(1, Math.ceil(lineWidth / wrapWidth))
196
- }, 0)
214
+ const lineWidth =
215
+ estimateLineWidth(line, fontSize, letterSpacing, fontWeight) * widthSafetyFactor(line);
216
+ return sum + Math.max(1, Math.ceil(lineWidth / wrapWidth));
217
+ }, 0);
197
218
  }
198
219
 
199
220
  /**
@@ -207,13 +228,13 @@ export type WrappedLineCounter = (
207
228
  fontWeight: string | number | undefined,
208
229
  fontFamily: string,
209
230
  letterSpacing: number,
210
- ) => number
231
+ ) => number;
211
232
 
212
- let _wrappedLineCounter: WrappedLineCounter | null = null
233
+ let _wrappedLineCounter: WrappedLineCounter | null = null;
213
234
 
214
235
  /** Set a custom wrapped line counter (e.g. Canvas 2D-based). */
215
236
  export function setWrappedLineCounter(counter: WrappedLineCounter): void {
216
- _wrappedLineCounter = counter
237
+ _wrappedLineCounter = counter;
217
238
  }
218
239
 
219
240
  // ---------------------------------------------------------------------------
@@ -223,51 +244,54 @@ export function setWrappedLineCounter(counter: WrappedLineCounter): void {
223
244
  /** Estimate text height including multi-line wrapping when available width is known. */
224
245
  export function estimateTextHeight(node: PenNode, availableWidth?: number): number {
225
246
  // Access text-specific properties via Record to avoid union type issues
226
- const n = node as unknown as Record<string, unknown>
227
- const fontSize = (typeof n.fontSize === 'number' ? n.fontSize : 16)
228
- const lineHeight = (typeof n.lineHeight === 'number' ? n.lineHeight : defaultLineHeight(fontSize))
247
+ const n = node as unknown as Record<string, unknown>;
248
+ const fontSize = typeof n.fontSize === 'number' ? n.fontSize : 16;
249
+ const lineHeight = typeof n.lineHeight === 'number' ? n.lineHeight : defaultLineHeight(fontSize);
229
250
  // Fabric.js uses _fontSizeMult = 1.13 for the glyph height of a single line.
230
251
  // lineHeight spacing applies *between* lines, not below the last line.
231
- const FABRIC_FONT_MULT = 1.13
232
- const glyphH = fontSize * FABRIC_FONT_MULT
233
- const lineStep = fontSize * lineHeight
252
+ const FABRIC_FONT_MULT = 1.13;
253
+ const glyphH = fontSize * FABRIC_FONT_MULT;
254
+ const lineStep = fontSize * lineHeight;
234
255
 
235
256
  // Get text content
236
- const rawContent = n.content
237
- const content = typeof rawContent === 'string'
238
- ? rawContent
239
- : Array.isArray(rawContent)
240
- ? rawContent.map((s: { text: string }) => s.text).join('')
241
- : ''
242
- if (!content) return glyphH
257
+ const rawContent = n.content;
258
+ const content =
259
+ typeof rawContent === 'string'
260
+ ? rawContent
261
+ : Array.isArray(rawContent)
262
+ ? rawContent.map((s: { text: string }) => s.text).join('')
263
+ : '';
264
+ if (!content) return glyphH;
243
265
 
244
266
  // Determine the effective text width for wrapping estimation
245
- let textWidth = 0
267
+ let textWidth = 0;
246
268
  if ('width' in node) {
247
- const w = parseSizing(node.width)
248
- if (typeof w === 'number' && w > 0) textWidth = w
249
- else if (w === 'fill' && availableWidth && availableWidth > 0) textWidth = availableWidth
269
+ const w = parseSizing(node.width);
270
+ if (typeof w === 'number' && w > 0) textWidth = w;
271
+ else if (w === 'fill' && availableWidth && availableWidth > 0) textWidth = availableWidth;
250
272
  }
251
273
 
252
274
  // If no width constraint is known, still count explicit newlines
253
275
  if (textWidth <= 0) {
254
- const explicitLines = content.split(/\r?\n/).length
255
- const n2 = Math.max(1, explicitLines)
256
- return Math.round(n2 <= 1 ? glyphH : (n2 - 1) * lineStep + glyphH)
276
+ const explicitLines = content.split(/\r?\n/).length;
277
+ const n2 = Math.max(1, explicitLines);
278
+ return Math.round(n2 <= 1 ? glyphH : (n2 - 1) * lineStep + glyphH);
257
279
  }
258
280
 
259
281
  // Use custom wrapped line counter if set (e.g. Canvas 2D), else fallback
260
- const fontWeight = n.fontWeight as string | number | undefined
261
- const fontFamily = (typeof n.fontFamily === 'string' ? n.fontFamily : '') || 'Inter, -apple-system, "Noto Sans SC", "PingFang SC", system-ui, sans-serif'
262
- const letterSpacing = (typeof n.letterSpacing === 'number' ? n.letterSpacing : 0)
263
- const rawLines = content.split(/\r?\n/)
282
+ const fontWeight = n.fontWeight as string | number | undefined;
283
+ const fontFamily =
284
+ (typeof n.fontFamily === 'string' ? n.fontFamily : '') ||
285
+ 'Inter, -apple-system, "Noto Sans SC", "PingFang SC", system-ui, sans-serif';
286
+ const letterSpacing = typeof n.letterSpacing === 'number' ? n.letterSpacing : 0;
287
+ const rawLines = content.split(/\r?\n/);
264
288
  // Add tolerance matching the renderer's wrapLine (w + fontSize * 0.2)
265
- const wrapWidth = textWidth + fontSize * 0.2
289
+ const wrapWidth = textWidth + fontSize * 0.2;
266
290
 
267
291
  const wrappedLineCount = _wrappedLineCounter
268
292
  ? _wrappedLineCounter(rawLines, wrapWidth, fontSize, fontWeight, fontFamily, letterSpacing)
269
- : countWrappedLinesFallback(rawLines, wrapWidth, fontSize, letterSpacing, fontWeight)
293
+ : countWrappedLinesFallback(rawLines, wrapWidth, fontSize, letterSpacing, fontWeight);
270
294
 
271
- const totalLines = Math.max(1, wrappedLineCount)
272
- return Math.round(totalLines <= 1 ? glyphH : (totalLines - 1) * lineStep + glyphH)
295
+ const totalLines = Math.max(1, wrappedLineCount);
296
+ return Math.round(totalLines <= 1 ? glyphH : (totalLines - 1) * lineStep + glyphH);
273
297
  }