@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,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Variable resolution utilities.
|
|
3
|
+
*
|
|
4
|
+
* Resolves `$variableName` references against a VariableDefinition map,
|
|
5
|
+
* optionally matching themed values to an active theme context.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PenNode } from '@zseven-w/pen-types'
|
|
9
|
+
import type { PenFill, PenStroke, PenEffect } from '@zseven-w/pen-types'
|
|
10
|
+
import type { VariableDefinition, ThemedValue } from '@zseven-w/pen-types'
|
|
11
|
+
|
|
12
|
+
type Vars = Record<string, VariableDefinition>
|
|
13
|
+
type Theme = Record<string, string>
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Helpers
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
/** Check whether a value is a `$variable` reference string. */
|
|
20
|
+
export function isVariableRef(value: unknown): value is string {
|
|
21
|
+
return typeof value === 'string' && value.startsWith('$')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Build the default theme map (first value per axis) from PenDocument.themes. */
|
|
25
|
+
export function getDefaultTheme(
|
|
26
|
+
themes: Record<string, string[]> | undefined,
|
|
27
|
+
): Theme {
|
|
28
|
+
const result: Theme = {}
|
|
29
|
+
if (!themes) return result
|
|
30
|
+
for (const [key, values] of Object.entries(themes)) {
|
|
31
|
+
if (values.length > 0) result[key] = values[0]
|
|
32
|
+
}
|
|
33
|
+
return result
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Core resolution
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
/** Pick the concrete value from a `ThemedValue[]` for the given theme. */
|
|
41
|
+
function resolveThemedValue(
|
|
42
|
+
values: ThemedValue[],
|
|
43
|
+
activeTheme?: Theme,
|
|
44
|
+
): string | number | boolean | undefined {
|
|
45
|
+
if (activeTheme && Object.keys(activeTheme).length > 0) {
|
|
46
|
+
const match = values.find((v) => {
|
|
47
|
+
if (!v.theme) return false
|
|
48
|
+
return Object.entries(activeTheme).every(
|
|
49
|
+
([key, expected]) => v.theme?.[key] === expected,
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
if (match) return match.value
|
|
53
|
+
}
|
|
54
|
+
return values[0]?.value
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve a single `$variableName` reference to its concrete value.
|
|
59
|
+
* Returns `undefined` if the variable does not exist or has an incompatible type.
|
|
60
|
+
*/
|
|
61
|
+
export function resolveVariableRef(
|
|
62
|
+
ref: string,
|
|
63
|
+
variables: Vars,
|
|
64
|
+
activeTheme?: Theme,
|
|
65
|
+
): string | number | boolean | undefined {
|
|
66
|
+
if (!ref.startsWith('$')) return undefined
|
|
67
|
+
const name = ref.slice(1)
|
|
68
|
+
const def = variables[name]
|
|
69
|
+
if (!def) return undefined
|
|
70
|
+
|
|
71
|
+
const val = def.value
|
|
72
|
+
if (Array.isArray(val)) {
|
|
73
|
+
const resolved = resolveThemedValue(val, activeTheme)
|
|
74
|
+
// Circular guard: if resolved value is also a $ref, stop
|
|
75
|
+
if (typeof resolved === 'string' && resolved.startsWith('$')) return undefined
|
|
76
|
+
return resolved
|
|
77
|
+
}
|
|
78
|
+
// Circular guard
|
|
79
|
+
if (typeof val === 'string' && val.startsWith('$')) return undefined
|
|
80
|
+
return val
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve a color string that may be a `$variable` reference.
|
|
85
|
+
* Returns the original string if it's not a ref, or the resolved color.
|
|
86
|
+
*/
|
|
87
|
+
export function resolveColorRef(
|
|
88
|
+
color: string | undefined,
|
|
89
|
+
variables: Vars,
|
|
90
|
+
activeTheme?: Theme,
|
|
91
|
+
): string | undefined {
|
|
92
|
+
if (color === undefined) return undefined
|
|
93
|
+
if (!isVariableRef(color)) return color
|
|
94
|
+
const resolved = resolveVariableRef(color, variables, activeTheme)
|
|
95
|
+
return typeof resolved === 'string' ? resolved : undefined
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Resolve a numeric value that may be a `$variable` reference.
|
|
100
|
+
* Returns the original number if it's not a ref.
|
|
101
|
+
*/
|
|
102
|
+
export function resolveNumericRef(
|
|
103
|
+
value: unknown,
|
|
104
|
+
variables: Vars,
|
|
105
|
+
activeTheme?: Theme,
|
|
106
|
+
): number | undefined {
|
|
107
|
+
if (typeof value === 'number') return value
|
|
108
|
+
if (typeof value === 'string') {
|
|
109
|
+
if (isVariableRef(value)) {
|
|
110
|
+
const resolved = resolveVariableRef(value, variables, activeTheme)
|
|
111
|
+
return typeof resolved === 'number' ? resolved : undefined
|
|
112
|
+
}
|
|
113
|
+
const num = parseFloat(value)
|
|
114
|
+
return isNaN(num) ? undefined : num
|
|
115
|
+
}
|
|
116
|
+
return undefined
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Fill / stroke / effect resolution
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
function resolveFillsForCanvas(
|
|
124
|
+
fills: PenFill[] | string | undefined,
|
|
125
|
+
vars: Vars,
|
|
126
|
+
theme?: Theme,
|
|
127
|
+
): PenFill[] | string | undefined {
|
|
128
|
+
if (!fills) return fills
|
|
129
|
+
if (typeof fills === 'string') return fills
|
|
130
|
+
return fills.map((fill) => {
|
|
131
|
+
if (fill.type === 'solid') {
|
|
132
|
+
const color = resolveColorRef(fill.color, vars, theme)
|
|
133
|
+
return color !== fill.color ? { ...fill, color: color ?? '#000000' } : fill
|
|
134
|
+
}
|
|
135
|
+
if (fill.type === 'linear_gradient' || fill.type === 'radial_gradient') {
|
|
136
|
+
const newStops = fill.stops.map((stop) => {
|
|
137
|
+
const color = resolveColorRef(stop.color, vars, theme)
|
|
138
|
+
return color !== stop.color ? { ...stop, color: color ?? '#000000' } : stop
|
|
139
|
+
})
|
|
140
|
+
return newStops !== fill.stops ? { ...fill, stops: newStops } : fill
|
|
141
|
+
}
|
|
142
|
+
return fill
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function resolveStrokeForCanvas(
|
|
147
|
+
stroke: PenStroke | undefined,
|
|
148
|
+
vars: Vars,
|
|
149
|
+
theme?: Theme,
|
|
150
|
+
): PenStroke | undefined {
|
|
151
|
+
if (!stroke) return stroke
|
|
152
|
+
let changed = false
|
|
153
|
+
const out: Record<string, unknown> = { ...stroke }
|
|
154
|
+
|
|
155
|
+
// Resolve thickness
|
|
156
|
+
if (typeof stroke.thickness === 'string' && isVariableRef(stroke.thickness)) {
|
|
157
|
+
out.thickness = resolveNumericRef(stroke.thickness, vars, theme) ?? 1
|
|
158
|
+
changed = true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Resolve stroke fill colors
|
|
162
|
+
if (stroke.fill) {
|
|
163
|
+
const resolved = resolveFillsForCanvas(stroke.fill, vars, theme)
|
|
164
|
+
if (resolved !== stroke.fill) {
|
|
165
|
+
out.fill = resolved
|
|
166
|
+
changed = true
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return changed ? (out as unknown as PenStroke) : stroke
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function resolveEffectsForCanvas(
|
|
174
|
+
effects: PenEffect[] | undefined,
|
|
175
|
+
vars: Vars,
|
|
176
|
+
theme?: Theme,
|
|
177
|
+
): PenEffect[] | undefined {
|
|
178
|
+
if (!effects) return effects
|
|
179
|
+
return effects.map((effect) => {
|
|
180
|
+
if (effect.type !== 'shadow') return effect
|
|
181
|
+
let changed = false
|
|
182
|
+
const out: Record<string, unknown> = { ...effect }
|
|
183
|
+
|
|
184
|
+
if (typeof effect.color === 'string' && isVariableRef(effect.color)) {
|
|
185
|
+
out.color = resolveColorRef(effect.color, vars, theme) ?? '#000000'
|
|
186
|
+
changed = true
|
|
187
|
+
}
|
|
188
|
+
for (const key of ['blur', 'offsetX', 'offsetY', 'spread'] as const) {
|
|
189
|
+
const val = effect[key]
|
|
190
|
+
if (typeof val === 'string' && isVariableRef(val)) {
|
|
191
|
+
out[key] = resolveNumericRef(val, vars, theme) ?? 0
|
|
192
|
+
changed = true
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return changed ? (out as unknown as PenEffect) : effect
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// Full node resolution for canvas rendering
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Resolve all `$variable` references in a PenNode, returning a new node
|
|
206
|
+
* with concrete values suitable for Fabric.js rendering.
|
|
207
|
+
*
|
|
208
|
+
* Returns the same object reference when no variables are present.
|
|
209
|
+
*/
|
|
210
|
+
export function resolveNodeForCanvas(
|
|
211
|
+
node: PenNode,
|
|
212
|
+
variables: Vars,
|
|
213
|
+
activeTheme?: Theme,
|
|
214
|
+
): PenNode {
|
|
215
|
+
if (!variables || Object.keys(variables).length === 0) return node
|
|
216
|
+
|
|
217
|
+
let changed = false
|
|
218
|
+
const out: Record<string, unknown> = { ...node }
|
|
219
|
+
|
|
220
|
+
// Opacity
|
|
221
|
+
if (typeof node.opacity === 'string' && isVariableRef(node.opacity)) {
|
|
222
|
+
out.opacity = resolveNumericRef(node.opacity, variables, activeTheme) ?? 1
|
|
223
|
+
changed = true
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Gap
|
|
227
|
+
if ('gap' in node && typeof (node as unknown as Record<string, unknown>).gap === 'string') {
|
|
228
|
+
const gap = (node as unknown as Record<string, unknown>).gap as string
|
|
229
|
+
if (isVariableRef(gap)) {
|
|
230
|
+
out.gap = resolveNumericRef(gap, variables, activeTheme) ?? 0
|
|
231
|
+
changed = true
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Padding
|
|
236
|
+
if ('padding' in node) {
|
|
237
|
+
const padding = (node as unknown as Record<string, unknown>).padding
|
|
238
|
+
if (typeof padding === 'string' && isVariableRef(padding)) {
|
|
239
|
+
out.padding = resolveNumericRef(padding, variables, activeTheme) ?? 0
|
|
240
|
+
changed = true
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Fill
|
|
245
|
+
if ('fill' in node && (node as unknown as Record<string, unknown>).fill) {
|
|
246
|
+
const fills = (node as unknown as Record<string, unknown>).fill as PenFill[] | string
|
|
247
|
+
const resolved = resolveFillsForCanvas(fills, variables, activeTheme)
|
|
248
|
+
if (resolved !== fills) {
|
|
249
|
+
out.fill = resolved
|
|
250
|
+
changed = true
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Stroke
|
|
255
|
+
if ('stroke' in node && (node as unknown as Record<string, unknown>).stroke) {
|
|
256
|
+
const stroke = (node as unknown as Record<string, unknown>).stroke as PenStroke
|
|
257
|
+
const resolved = resolveStrokeForCanvas(stroke, variables, activeTheme)
|
|
258
|
+
if (resolved !== stroke) {
|
|
259
|
+
out.stroke = resolved
|
|
260
|
+
changed = true
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Effects
|
|
265
|
+
if ('effects' in node && (node as unknown as Record<string, unknown>).effects) {
|
|
266
|
+
const effects = (node as unknown as Record<string, unknown>).effects as PenEffect[]
|
|
267
|
+
const resolved = resolveEffectsForCanvas(effects, variables, activeTheme)
|
|
268
|
+
if (resolved !== effects) {
|
|
269
|
+
out.effects = resolved
|
|
270
|
+
changed = true
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Text content
|
|
275
|
+
if (node.type === 'text' && typeof node.content === 'string' && isVariableRef(node.content)) {
|
|
276
|
+
const resolved = resolveVariableRef(node.content, variables, activeTheme)
|
|
277
|
+
if (typeof resolved === 'string') {
|
|
278
|
+
out.content = resolved
|
|
279
|
+
changed = true
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return changed ? (out as unknown as PenNode) : node
|
|
284
|
+
}
|