@zseven-w/pen-core 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }