@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.
- package/README.md +80 -0
- package/package.json +26 -0
- package/src/__tests__/arc-path.test.ts +39 -0
- package/src/__tests__/font-utils.test.ts +26 -0
- package/src/__tests__/layout-engine.test.ts +153 -0
- package/src/__tests__/node-helpers.test.ts +30 -0
- package/src/__tests__/normalize.test.ts +110 -0
- package/src/__tests__/text-measure.test.ts +147 -0
- package/src/__tests__/tree-utils.test.ts +170 -0
- package/src/__tests__/variables.test.ts +132 -0
- package/src/arc-path.ts +100 -0
- package/src/boolean-ops.ts +256 -0
- package/src/constants.ts +49 -0
- package/src/font-utils.ts +23 -0
- package/src/id.ts +1 -0
- package/src/index.ts +133 -0
- package/src/layout/engine.ts +460 -0
- package/src/layout/text-measure.ts +269 -0
- package/src/node-helpers.ts +14 -0
- package/src/normalize.ts +283 -0
- package/src/sync-lock.ts +16 -0
- package/src/tree-utils.ts +390 -0
- package/src/variables/replace-refs.ts +149 -0
- package/src/variables/resolve.ts +284 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import type { PenNode } from '@zseven-w/pen-types'
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Sizing parser (shared by layout engine and text height estimation)
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
/** Parse a sizing value. Handles number, "fit_content", "fill_container" and parenthesized forms. */
|
|
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
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Default line height — single source of truth for all modules
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Canonical default lineHeight when a text node has no explicit value.
|
|
23
|
+
* Display/heading text (>=28px) gets tighter spacing; body text gets looser.
|
|
24
|
+
* All modules (factory, layout engine, text estimation, AI generation)
|
|
25
|
+
* MUST use this function instead of hardcoded fallbacks.
|
|
26
|
+
*/
|
|
27
|
+
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
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// CJK detection
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
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
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function hasCjkText(text: string): boolean {
|
|
48
|
+
for (const ch of text) {
|
|
49
|
+
const code = ch.codePointAt(0) ?? 0
|
|
50
|
+
if (isCjkCodePoint(code)) return true
|
|
51
|
+
}
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Glyph / line width estimation
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Font weight multiplier — bold/semibold text is wider than regular text.
|
|
61
|
+
* Values based on typical proportional font width scaling.
|
|
62
|
+
*/
|
|
63
|
+
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
|
+
}
|
|
71
|
+
|
|
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
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function estimateLineWidth(
|
|
87
|
+
text: string,
|
|
88
|
+
fontSize: number,
|
|
89
|
+
letterSpacing = 0,
|
|
90
|
+
fontWeight?: string | number,
|
|
91
|
+
): number {
|
|
92
|
+
let width = 0
|
|
93
|
+
let visibleChars = 0
|
|
94
|
+
for (const ch of text) {
|
|
95
|
+
width += estimateGlyphWidth(ch, fontSize, fontWeight)
|
|
96
|
+
if (ch !== '\n' && ch !== '\r') visibleChars += 1
|
|
97
|
+
}
|
|
98
|
+
if (visibleChars > 1 && letterSpacing !== 0) {
|
|
99
|
+
width += (visibleChars - 1) * letterSpacing
|
|
100
|
+
}
|
|
101
|
+
return Math.max(0, width)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function widthSafetyFactor(text: string): number {
|
|
105
|
+
// Latin fonts vary a lot by weight/family; use a larger safety margin to
|
|
106
|
+
// avoid underestimating width and causing accidental wraps.
|
|
107
|
+
return hasCjkText(text) ? 1.06 : 1.14
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function estimateTextWidth(text: string, fontSize: number, letterSpacing = 0, fontWeight?: string | number): number {
|
|
111
|
+
const lines = text.split(/\r?\n/)
|
|
112
|
+
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
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Estimate text width WITHOUT safety factor.
|
|
122
|
+
* Used for layout centering where the safety margin causes text to appear
|
|
123
|
+
* off-center (the overestimated width shifts the text box left when centered).
|
|
124
|
+
* For wrapping/sizing decisions, use estimateTextWidth() which includes the safety factor.
|
|
125
|
+
*/
|
|
126
|
+
export function estimateTextWidthPrecise(text: string, fontSize: number, letterSpacing = 0, fontWeight?: string | number): number {
|
|
127
|
+
const lines = text.split(/\r?\n/)
|
|
128
|
+
return lines.reduce((max, line) => {
|
|
129
|
+
return Math.max(max, estimateLineWidth(line, fontSize, letterSpacing, fontWeight))
|
|
130
|
+
}, 0)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Text content helpers
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
export function resolveTextContent(node: PenNode): string {
|
|
138
|
+
if (node.type !== 'text') return ''
|
|
139
|
+
return typeof node.content === 'string'
|
|
140
|
+
? node.content
|
|
141
|
+
: node.content.map((s) => s.text).join('')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function countExplicitTextLines(text: string): number {
|
|
145
|
+
if (!text) return 1
|
|
146
|
+
return Math.max(1, text.split(/\r?\n/).length)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// Optical vertical correction for centered single-line text
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Optical vertical correction for centered single-line text.
|
|
155
|
+
* Within the Fabric text bounding box (fontSize * 1.13), glyph ink sits
|
|
156
|
+
* slightly above the mathematical center due to ascent/descent asymmetry.
|
|
157
|
+
* We nudge down proportionally to compensate.
|
|
158
|
+
*/
|
|
159
|
+
export function getTextOpticalCenterYOffset(node: PenNode): number {
|
|
160
|
+
if (node.type !== 'text') return 0
|
|
161
|
+
const text = resolveTextContent(node).trim()
|
|
162
|
+
if (!text) return 0
|
|
163
|
+
if (countExplicitTextLines(text) > 1) return 0
|
|
164
|
+
|
|
165
|
+
const fontSize = node.fontSize ?? 16
|
|
166
|
+
const hasCjk = hasCjkText(text)
|
|
167
|
+
|
|
168
|
+
// CJK glyphs sit higher in the em box than Latin glyphs
|
|
169
|
+
const ratio = hasCjk ? 0.06 : 0.03
|
|
170
|
+
const offset = fontSize * ratio
|
|
171
|
+
return Math.max(0, Math.min(Math.round(fontSize * 0.05), Math.round(offset)))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Wrapped line count — injectable for browser/non-browser environments
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Count wrapped lines using character-width estimation fallback.
|
|
180
|
+
* This is the pure (non-browser) implementation.
|
|
181
|
+
*/
|
|
182
|
+
export function countWrappedLinesFallback(
|
|
183
|
+
rawLines: string[],
|
|
184
|
+
wrapWidth: number,
|
|
185
|
+
fontSize: number,
|
|
186
|
+
letterSpacing: number,
|
|
187
|
+
fontWeight: string | number | undefined,
|
|
188
|
+
): number {
|
|
189
|
+
return rawLines.reduce((sum, line) => {
|
|
190
|
+
const lineWidth = estimateLineWidth(line, fontSize, letterSpacing, fontWeight) * widthSafetyFactor(line)
|
|
191
|
+
return sum + Math.max(1, Math.ceil(lineWidth / wrapWidth))
|
|
192
|
+
}, 0)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Injectable wrapped line counter. Browser environments can replace this
|
|
197
|
+
* with a Canvas 2D-based implementation for accurate word-wrap prediction.
|
|
198
|
+
*/
|
|
199
|
+
export type WrappedLineCounter = (
|
|
200
|
+
rawLines: string[],
|
|
201
|
+
wrapWidth: number,
|
|
202
|
+
fontSize: number,
|
|
203
|
+
fontWeight: string | number | undefined,
|
|
204
|
+
fontFamily: string,
|
|
205
|
+
letterSpacing: number,
|
|
206
|
+
) => number
|
|
207
|
+
|
|
208
|
+
let _wrappedLineCounter: WrappedLineCounter | null = null
|
|
209
|
+
|
|
210
|
+
/** Set a custom wrapped line counter (e.g. Canvas 2D-based). */
|
|
211
|
+
export function setWrappedLineCounter(counter: WrappedLineCounter): void {
|
|
212
|
+
_wrappedLineCounter = counter
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Text height estimation (multi-line wrapping aware)
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
/** Estimate text height including multi-line wrapping when available width is known. */
|
|
220
|
+
export function estimateTextHeight(node: PenNode, availableWidth?: number): number {
|
|
221
|
+
// Access text-specific properties via Record to avoid union type issues
|
|
222
|
+
const n = node as unknown as Record<string, unknown>
|
|
223
|
+
const fontSize = (typeof n.fontSize === 'number' ? n.fontSize : 16)
|
|
224
|
+
const lineHeight = (typeof n.lineHeight === 'number' ? n.lineHeight : defaultLineHeight(fontSize))
|
|
225
|
+
// Fabric.js uses _fontSizeMult = 1.13 for the glyph height of a single line.
|
|
226
|
+
// lineHeight spacing applies *between* lines, not below the last line.
|
|
227
|
+
const FABRIC_FONT_MULT = 1.13
|
|
228
|
+
const glyphH = fontSize * FABRIC_FONT_MULT
|
|
229
|
+
const lineStep = fontSize * lineHeight
|
|
230
|
+
|
|
231
|
+
// Get text content
|
|
232
|
+
const rawContent = n.content
|
|
233
|
+
const content = typeof rawContent === 'string'
|
|
234
|
+
? rawContent
|
|
235
|
+
: Array.isArray(rawContent)
|
|
236
|
+
? rawContent.map((s: { text: string }) => s.text).join('')
|
|
237
|
+
: ''
|
|
238
|
+
if (!content) return glyphH
|
|
239
|
+
|
|
240
|
+
// Determine the effective text width for wrapping estimation
|
|
241
|
+
let textWidth = 0
|
|
242
|
+
if ('width' in node) {
|
|
243
|
+
const w = parseSizing(node.width)
|
|
244
|
+
if (typeof w === 'number' && w > 0) textWidth = w
|
|
245
|
+
else if (w === 'fill' && availableWidth && availableWidth > 0) textWidth = availableWidth
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// If no width constraint is known, still count explicit newlines
|
|
249
|
+
if (textWidth <= 0) {
|
|
250
|
+
const explicitLines = content.split(/\r?\n/).length
|
|
251
|
+
const n2 = Math.max(1, explicitLines)
|
|
252
|
+
return Math.round(n2 <= 1 ? glyphH : (n2 - 1) * lineStep + glyphH)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Use custom wrapped line counter if set (e.g. Canvas 2D), else fallback
|
|
256
|
+
const fontWeight = n.fontWeight as string | number | undefined
|
|
257
|
+
const fontFamily = (typeof n.fontFamily === 'string' ? n.fontFamily : '') || 'Inter, -apple-system, "Noto Sans SC", "PingFang SC", system-ui, sans-serif'
|
|
258
|
+
const letterSpacing = (typeof n.letterSpacing === 'number' ? n.letterSpacing : 0)
|
|
259
|
+
const rawLines = content.split(/\r?\n/)
|
|
260
|
+
// Add tolerance matching the renderer's wrapLine (w + fontSize * 0.2)
|
|
261
|
+
const wrapWidth = textWidth + fontSize * 0.2
|
|
262
|
+
|
|
263
|
+
const wrappedLineCount = _wrappedLineCounter
|
|
264
|
+
? _wrappedLineCounter(rawLines, wrapWidth, fontSize, fontWeight, fontFamily, letterSpacing)
|
|
265
|
+
: countWrappedLinesFallback(rawLines, wrapWidth, fontSize, letterSpacing, fontWeight)
|
|
266
|
+
|
|
267
|
+
const totalLines = Math.max(1, wrappedLineCount)
|
|
268
|
+
return Math.round(totalLines <= 1 ? glyphH : (totalLines - 1) * lineStep + glyphH)
|
|
269
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { PenNode } from '@zseven-w/pen-types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Check if a node is a badge/overlay that uses absolute positioning
|
|
5
|
+
* and should not participate in layout flow.
|
|
6
|
+
*/
|
|
7
|
+
export function isBadgeOverlayNode(node: PenNode): boolean {
|
|
8
|
+
if ('role' in node) {
|
|
9
|
+
const role = (node as { role?: string }).role
|
|
10
|
+
if (role === 'badge' || role === 'pill' || role === 'tag') return true
|
|
11
|
+
}
|
|
12
|
+
const name = (node.name ?? '').toLowerCase()
|
|
13
|
+
return /badge|indicator|notification[-_\s]?dot|overlay|floating/i.test(name)
|
|
14
|
+
}
|
package/src/normalize.ts
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize a Pencil.dev .pen document into OpenPencil's internal format.
|
|
3
|
+
*
|
|
4
|
+
* Handles format normalization ONLY — does NOT resolve $variable references:
|
|
5
|
+
* - fill type: "color" → "solid"
|
|
6
|
+
* - fill shorthand string "#hex" → [{ type: "solid", color }]
|
|
7
|
+
* - gradient type: "gradient" → "linear_gradient" / "radial_gradient"
|
|
8
|
+
* - gradient stops { color, position } → { offset, color }
|
|
9
|
+
* - sizing "fit_content(N)" / "fill_container(N)" → fallback number
|
|
10
|
+
* - padding array normalization
|
|
11
|
+
*
|
|
12
|
+
* Variable resolution is handled separately by `resolve-variables.ts` at
|
|
13
|
+
* canvas render time, preserving $variable bindings in the document.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { PenDocument, PenNode } from '@zseven-w/pen-types'
|
|
17
|
+
import type { PenFill, PenStroke, GradientStop } from '@zseven-w/pen-types'
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Public API
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
export function normalizePenDocument(doc: PenDocument): PenDocument {
|
|
24
|
+
const normalized = {
|
|
25
|
+
...doc,
|
|
26
|
+
children: doc.children.map((n) => normalizeNode(n)),
|
|
27
|
+
}
|
|
28
|
+
// Normalize all pages' children too
|
|
29
|
+
if (normalized.pages && normalized.pages.length > 0) {
|
|
30
|
+
normalized.pages = normalized.pages.map((p) => ({
|
|
31
|
+
...p,
|
|
32
|
+
children: p.children.map((n) => normalizeNode(n)),
|
|
33
|
+
}))
|
|
34
|
+
}
|
|
35
|
+
return normalized
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Node normalizer (recursive)
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
function normalizeNode(node: PenNode): PenNode {
|
|
43
|
+
const out: Record<string, unknown> = { ...node }
|
|
44
|
+
|
|
45
|
+
// fill
|
|
46
|
+
if ('fill' in out && out.fill !== undefined) {
|
|
47
|
+
out.fill = normalizeFills(out.fill)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// stroke
|
|
51
|
+
if ('stroke' in out && out.stroke != null) {
|
|
52
|
+
out.stroke = normalizeStroke(out.stroke as Record<string, unknown>)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// effects — pass through (no format changes needed)
|
|
56
|
+
|
|
57
|
+
// sizing
|
|
58
|
+
if ('width' in out) out.width = normalizeSizing(out.width)
|
|
59
|
+
if ('height' in out) out.height = normalizeSizing(out.height)
|
|
60
|
+
|
|
61
|
+
// gap — pass through ($variable strings preserved)
|
|
62
|
+
|
|
63
|
+
// padding — normalize array format only (not variable resolution)
|
|
64
|
+
if ('padding' in out) out.padding = normalizePadding(out.padding)
|
|
65
|
+
|
|
66
|
+
// opacity — pass through ($variable strings preserved)
|
|
67
|
+
|
|
68
|
+
// icon_font: default to lucide family
|
|
69
|
+
if (out.type === 'icon_font' && !out.iconFontFamily) {
|
|
70
|
+
out.iconFontFamily = 'lucide'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// children
|
|
74
|
+
if ('children' in out && Array.isArray(out.children)) {
|
|
75
|
+
out.children = (out.children as PenNode[]).map((c) => normalizeNode(c))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return out as unknown as PenNode
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Fill normalization
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
function normalizeFills(raw: unknown): PenFill[] {
|
|
86
|
+
if (!raw) return []
|
|
87
|
+
|
|
88
|
+
// String shorthand: "#hex" or "$variable" → solid fill
|
|
89
|
+
if (typeof raw === 'string') {
|
|
90
|
+
return [{ type: 'solid', color: raw }]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Array of fills
|
|
94
|
+
if (Array.isArray(raw)) {
|
|
95
|
+
return raw.map((f) => normalizeSingleFill(f)).filter(Boolean) as PenFill[]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Single fill object
|
|
99
|
+
if (typeof raw === 'object') {
|
|
100
|
+
const f = normalizeSingleFill(raw as Record<string, unknown>)
|
|
101
|
+
return f ? [f] : []
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return []
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function normalizeSingleFill(
|
|
108
|
+
raw: Record<string, unknown> | string,
|
|
109
|
+
): PenFill | null {
|
|
110
|
+
// String shorthand inside array: "#hex" or "$variable" → solid fill
|
|
111
|
+
if (typeof raw === 'string') {
|
|
112
|
+
return raw ? { type: 'solid', color: raw } : null
|
|
113
|
+
}
|
|
114
|
+
if (!raw || typeof raw !== 'object') return null
|
|
115
|
+
const t = raw.type as string | undefined
|
|
116
|
+
|
|
117
|
+
// Pencil "color" → OpenPencil "solid"
|
|
118
|
+
if (t === 'color' || t === 'solid') {
|
|
119
|
+
return {
|
|
120
|
+
type: 'solid',
|
|
121
|
+
color: typeof raw.color === 'string' ? raw.color : '#000000',
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Pencil "gradient" → split by gradientType
|
|
126
|
+
if (t === 'gradient') {
|
|
127
|
+
const gt = (raw.gradientType as string) ?? 'linear'
|
|
128
|
+
const stops = normalizeGradientStops(raw.colors as unknown[])
|
|
129
|
+
|
|
130
|
+
if (gt === 'radial') {
|
|
131
|
+
const center = raw.center as Record<string, unknown> | undefined
|
|
132
|
+
return {
|
|
133
|
+
type: 'radial_gradient',
|
|
134
|
+
cx: typeof center?.x === 'number' ? center.x : 0.5,
|
|
135
|
+
cy: typeof center?.y === 'number' ? center.y : 0.5,
|
|
136
|
+
radius: 0.5,
|
|
137
|
+
stops,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// linear or angular
|
|
141
|
+
return {
|
|
142
|
+
type: 'linear_gradient',
|
|
143
|
+
angle: typeof raw.rotation === 'number' ? raw.rotation : 0,
|
|
144
|
+
stops,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Already our format
|
|
149
|
+
if (t === 'linear_gradient' || t === 'radial_gradient') {
|
|
150
|
+
const stops =
|
|
151
|
+
'stops' in raw
|
|
152
|
+
? normalizeGradientStops(raw.stops as unknown[])
|
|
153
|
+
: 'colors' in raw
|
|
154
|
+
? normalizeGradientStops(raw.colors as unknown[])
|
|
155
|
+
: []
|
|
156
|
+
return { ...(raw as unknown as PenFill), stops } as PenFill
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Image fill — pass through
|
|
160
|
+
if (t === 'image') return raw as unknown as PenFill
|
|
161
|
+
|
|
162
|
+
// Fallback: if there's a color field, treat as solid
|
|
163
|
+
if ('color' in raw) {
|
|
164
|
+
return {
|
|
165
|
+
type: 'solid',
|
|
166
|
+
color: typeof raw.color === 'string' ? raw.color : '#000000',
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function normalizeGradientStops(
|
|
174
|
+
raw: unknown[] | undefined,
|
|
175
|
+
): GradientStop[] {
|
|
176
|
+
if (!Array.isArray(raw) || raw.length === 0) return []
|
|
177
|
+
|
|
178
|
+
// First pass: parse offsets, collecting which ones are explicitly set
|
|
179
|
+
const parsed = raw.map((s: unknown) => {
|
|
180
|
+
const stop = s as Record<string, unknown>
|
|
181
|
+
const rawOffset =
|
|
182
|
+
typeof stop.offset === 'number' && Number.isFinite(stop.offset)
|
|
183
|
+
? stop.offset
|
|
184
|
+
: typeof stop.position === 'number' && Number.isFinite(stop.position)
|
|
185
|
+
? stop.position
|
|
186
|
+
: null
|
|
187
|
+
// Normalize percentage-format offsets (AI sometimes outputs 0-100 instead of 0-1)
|
|
188
|
+
const offset = rawOffset !== null && rawOffset > 1 ? rawOffset / 100 : rawOffset
|
|
189
|
+
return {
|
|
190
|
+
offset,
|
|
191
|
+
color: typeof stop.color === 'string' ? stop.color : '#000000',
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
// Second pass: auto-distribute any stops that are missing an offset
|
|
196
|
+
const n = parsed.length
|
|
197
|
+
return parsed.map((s, i) => ({
|
|
198
|
+
color: s.color,
|
|
199
|
+
offset: s.offset !== null ? Math.max(0, Math.min(1, s.offset!)) : i / Math.max(n - 1, 1),
|
|
200
|
+
}))
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Stroke normalization
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
function normalizeStroke(
|
|
208
|
+
raw: Record<string, unknown>,
|
|
209
|
+
): PenStroke | undefined {
|
|
210
|
+
if (!raw) return undefined
|
|
211
|
+
const out = { ...raw }
|
|
212
|
+
|
|
213
|
+
// Normalize fill inside stroke
|
|
214
|
+
if ('fill' in out) {
|
|
215
|
+
out.fill = normalizeFills(out.fill)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Pencil may use "color" directly on stroke
|
|
219
|
+
if ('color' in out && typeof out.color === 'string') {
|
|
220
|
+
out.fill = [{ type: 'solid', color: out.color as string }]
|
|
221
|
+
delete out.color
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Thickness: leave $variable strings as-is, normalise plain number strings
|
|
225
|
+
if (typeof out.thickness === 'string') {
|
|
226
|
+
const str = out.thickness as string
|
|
227
|
+
if (!str.startsWith('$')) {
|
|
228
|
+
const num = parseFloat(str)
|
|
229
|
+
out.thickness = isNaN(num) ? 1 : num
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return out as unknown as PenStroke
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// Sizing normalization
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
function normalizeSizing(value: unknown): number | string {
|
|
241
|
+
if (typeof value === 'number') return value
|
|
242
|
+
if (typeof value !== 'string') return 0
|
|
243
|
+
|
|
244
|
+
// $variable — pass through
|
|
245
|
+
if (value.startsWith('$')) return value
|
|
246
|
+
|
|
247
|
+
// fill_container must always resolve dynamically from parent dimensions
|
|
248
|
+
if (value.startsWith('fill_container')) return 'fill_container'
|
|
249
|
+
|
|
250
|
+
// fit_content with a hint value: use the hint (more accurate than our estimation)
|
|
251
|
+
if (value.startsWith('fit_content')) {
|
|
252
|
+
const match = value.match(/\((\d+(?:\.\d+)?)\)/)
|
|
253
|
+
if (match) return parseFloat(match[1])
|
|
254
|
+
return 'fit_content'
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Try as a plain number string
|
|
258
|
+
const num = parseFloat(value)
|
|
259
|
+
return isNaN(num) ? 0 : num
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function normalizePadding(
|
|
263
|
+
value: unknown,
|
|
264
|
+
): number | [number, number] | [number, number, number, number] | string | undefined {
|
|
265
|
+
if (typeof value === 'number') return value
|
|
266
|
+
if (typeof value === 'string') {
|
|
267
|
+
// $variable — pass through
|
|
268
|
+
if (value.startsWith('$')) return value
|
|
269
|
+
const num = parseFloat(value)
|
|
270
|
+
return isNaN(num) ? 0 : num
|
|
271
|
+
}
|
|
272
|
+
if (Array.isArray(value)) {
|
|
273
|
+
return value.map((v) => {
|
|
274
|
+
if (typeof v === 'number') return v
|
|
275
|
+
if (typeof v === 'string') {
|
|
276
|
+
const num = parseFloat(v)
|
|
277
|
+
return isNaN(num) ? 0 : num
|
|
278
|
+
}
|
|
279
|
+
return 0
|
|
280
|
+
}) as [number, number] | [number, number, number, number]
|
|
281
|
+
}
|
|
282
|
+
return undefined
|
|
283
|
+
}
|
package/src/sync-lock.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When locked, document-store → Fabric sync is skipped (Fabric is the source).
|
|
3
|
+
*
|
|
4
|
+
* Uses a getter function instead of a bare `let` export so that cross-module
|
|
5
|
+
* reads always resolve the current value — even if the bundler does not
|
|
6
|
+
* preserve ES-module live bindings for `let` variables.
|
|
7
|
+
*/
|
|
8
|
+
let _locked = false
|
|
9
|
+
|
|
10
|
+
export function isFabricSyncLocked(): boolean {
|
|
11
|
+
return _locked
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function setFabricSyncLock(v: boolean) {
|
|
15
|
+
_locked = v
|
|
16
|
+
}
|