@zseven-w/pen-core 0.6.0 → 0.7.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.
- package/LICENSE +21 -0
- package/README.md +177 -25
- package/package.json +19 -5
- package/src/__tests__/arc-path.test.ts +23 -23
- package/src/__tests__/codegen-utils.test.ts +50 -0
- package/src/__tests__/design-md-parser.test.ts +49 -0
- package/src/__tests__/font-utils.test.ts +15 -15
- package/src/__tests__/layout-engine.test.ts +169 -83
- package/src/__tests__/merge-helpers.test.ts +143 -0
- package/src/__tests__/node-diff.test.ts +139 -0
- package/src/__tests__/node-helpers.test.ts +19 -19
- package/src/__tests__/node-merge.test.ts +425 -0
- package/src/__tests__/normalize-stroke-fill-schema.test.ts +416 -0
- package/src/__tests__/normalize-tree-layout.test.ts +294 -0
- package/src/__tests__/normalize.test.ts +119 -80
- package/src/__tests__/path-anchors.test.ts +98 -0
- package/src/__tests__/resolve-variables-recursive.test.ts +109 -54
- package/src/__tests__/strip-redundant-section-fills.test.ts +278 -0
- package/src/__tests__/text-measure.test.ts +84 -79
- package/src/__tests__/tree-utils.test.ts +133 -102
- package/src/__tests__/unwrap-fake-phone-mockup.test.ts +322 -0
- package/src/__tests__/variables.test.ts +68 -65
- package/src/arc-path.ts +35 -35
- package/src/boolean-ops.ts +131 -142
- package/src/constants.ts +36 -36
- package/src/design-md-parser.ts +363 -0
- package/src/font-utils.ts +30 -15
- package/src/id.ts +1 -1
- package/src/index.ts +47 -13
- package/src/layout/engine.ts +255 -224
- package/src/layout/normalize-tree.ts +140 -0
- package/src/layout/strip-redundant-section-fills.ts +155 -0
- package/src/layout/text-measure.ts +130 -106
- package/src/layout/unwrap-fake-phone-mockup.ts +147 -0
- package/src/merge/index.ts +16 -0
- package/src/merge/merge-helpers.ts +113 -0
- package/src/merge/node-diff.ts +123 -0
- package/src/merge/node-merge.ts +651 -0
- package/src/node-helpers.ts +18 -5
- package/src/normalize/normalize-stroke-fill-schema.ts +297 -0
- package/src/normalize.ts +75 -81
- package/src/path-anchors.ts +331 -0
- package/src/sync-lock.ts +3 -3
- package/src/tree-utils.ts +180 -158
- package/src/variables/replace-refs.ts +63 -60
- package/src/variables/resolve.ts +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'))
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
29
|
-
if (fontSize >= 28) return 1.15
|
|
30
|
-
if (fontSize >= 20) return 1.2
|
|
31
|
-
return 1.5
|
|
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 (
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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(
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
return fontSize *
|
|
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(
|
|
111
|
-
|
|
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(
|
|
127
|
-
|
|
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 =
|
|
195
|
-
|
|
196
|
-
|
|
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 =
|
|
228
|
-
const lineHeight =
|
|
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 =
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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 =
|
|
262
|
-
|
|
263
|
-
|
|
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
|
}
|