openpencil/packages/pen-core/src/variables/resolve.ts
Fini dc656be409 fix(pen-core/variables): resolveNodeForCanvas — fontWeight + empty-vars fallback
GAP-1: add 'fontWeight' to the text-node key list in resolveNodeForCanvas so
$type-*-weight refs resolve to a number before reaching the renderer.

GAP-2: replace the early-exit `if (!variables || Object.keys(variables).length === 0)
return node` with `if (!variables) variables = {}` so DEFAULT_PALETTE_FALLBACK
fires even when the document has an empty variables map (un-seeded v1 docs).

Adds 2 new tests to fallback-equivalence.test.ts covering both gaps.
2026-04-29 09:50:32 +08:00

328 lines
11 KiB
TypeScript

/**
* Variable resolution utilities.
*
* Resolves `$variableName` references against a VariableDefinition map,
* optionally matching themed values to an active theme context.
*/
import type { PenNode } from '@zseven-w/pen-types';
import type { PenFill, PenStroke, PenEffect } from '@zseven-w/pen-types';
import type { VariableDefinition, ThemedValue } from '@zseven-w/pen-types';
import { DEFAULT_PALETTE_FALLBACK } from './default-palette-fallback.js';
type Vars = Record<string, VariableDefinition>;
type Theme = Record<string, string>;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Check whether a value is a `$variable` reference string. */
export function isVariableRef(value: unknown): value is string {
return typeof value === 'string' && value.startsWith('$');
}
/** Build the default theme map (first value per axis) from PenDocument.themes. */
export function getDefaultTheme(themes: Record<string, string[]> | undefined): Theme {
const result: Theme = {};
if (!themes) return result;
for (const [key, values] of Object.entries(themes)) {
if (values.length > 0) result[key] = values[0];
}
return result;
}
// ---------------------------------------------------------------------------
// Core resolution
// ---------------------------------------------------------------------------
/** Pick the concrete value from a `ThemedValue[]` for the given theme. */
function resolveThemedValue(
values: ThemedValue[],
activeTheme?: Theme,
): string | number | boolean | undefined {
if (activeTheme && Object.keys(activeTheme).length > 0) {
const match = values.find((v) => {
if (!v.theme) return false;
return Object.entries(activeTheme).every(([key, expected]) => v.theme?.[key] === expected);
});
if (match) return match.value;
}
return values[0]?.value;
}
/**
* Resolve a single `$variableName` reference to its concrete value.
* Returns `undefined` if the variable does not exist or has an incompatible type.
*/
export function resolveVariableRef(
ref: string,
variables: Vars,
activeTheme?: Theme,
): string | number | boolean | undefined {
if (!ref.startsWith('$')) return undefined;
const name = ref.slice(1);
const def = variables[name];
if (!def) {
// P1.6: fall back to DEFAULT_PALETTE_FALLBACK when vars doesn't have the token
const fallback = DEFAULT_PALETTE_FALLBACK[name];
if (fallback !== undefined) {
if (fallback.single !== undefined) return fallback.single;
const mode = activeTheme?.Mode ?? 'Light';
return mode === 'Dark' ? fallback.dark : fallback.light;
}
return undefined;
}
const val = def.value;
if (Array.isArray(val)) {
const resolved = resolveThemedValue(val, activeTheme);
// Circular guard: if resolved value is also a $ref, stop
if (typeof resolved === 'string' && resolved.startsWith('$')) return undefined;
return resolved;
}
// Circular guard
if (typeof val === 'string' && val.startsWith('$')) return undefined;
return val;
}
/**
* Resolve a color string that may be a `$variable` reference.
* Returns the original string if it's not a ref, or the resolved color.
*/
export function resolveColorRef(
color: string | undefined,
variables: Vars,
activeTheme?: Theme,
): string | undefined {
if (color === undefined) return undefined;
if (!isVariableRef(color)) return color;
const resolved = resolveVariableRef(color, variables, activeTheme);
return typeof resolved === 'string' ? resolved : undefined;
}
/**
* Resolve a numeric value that may be a `$variable` reference.
* Returns the original number if it's not a ref.
*/
export function resolveNumericRef(
value: unknown,
variables: Vars,
activeTheme?: Theme,
): number | undefined {
if (typeof value === 'number') return value;
if (typeof value === 'string') {
if (isVariableRef(value)) {
const resolved = resolveVariableRef(value, variables, activeTheme);
return typeof resolved === 'number' ? resolved : undefined;
}
const num = parseFloat(value);
return isNaN(num) ? undefined : num;
}
return undefined;
}
// ---------------------------------------------------------------------------
// Fill / stroke / effect resolution
// ---------------------------------------------------------------------------
function resolveFillsForCanvas(
fills: PenFill[] | string | undefined,
vars: Vars,
theme?: Theme,
): PenFill[] | string | undefined {
if (!fills) return fills;
if (typeof fills === 'string') return fills;
return fills.map((fill) => {
if (fill.type === 'solid') {
const color = resolveColorRef(fill.color, vars, theme);
return color !== fill.color ? { ...fill, color: color ?? '#000000' } : fill;
}
if (fill.type === 'linear_gradient' || fill.type === 'radial_gradient') {
const newStops = fill.stops.map((stop) => {
const color = resolveColorRef(stop.color, vars, theme);
return color !== stop.color ? { ...stop, color: color ?? '#000000' } : stop;
});
return newStops !== fill.stops ? { ...fill, stops: newStops } : fill;
}
return fill;
});
}
function resolveStrokeForCanvas(
stroke: PenStroke | undefined,
vars: Vars,
theme?: Theme,
): PenStroke | undefined {
if (!stroke) return stroke;
let changed = false;
const out: Record<string, unknown> = { ...stroke };
// Resolve thickness
if (typeof stroke.thickness === 'string' && isVariableRef(stroke.thickness)) {
out.thickness = resolveNumericRef(stroke.thickness, vars, theme) ?? 1;
changed = true;
}
// Resolve stroke fill colors
if (stroke.fill) {
const resolved = resolveFillsForCanvas(stroke.fill, vars, theme);
if (resolved !== stroke.fill) {
out.fill = resolved;
changed = true;
}
}
return changed ? (out as unknown as PenStroke) : stroke;
}
function resolveEffectsForCanvas(
effects: PenEffect[] | undefined,
vars: Vars,
theme?: Theme,
): PenEffect[] | undefined {
if (!effects) return effects;
return effects.map((effect) => {
if (effect.type !== 'shadow') return effect;
let changed = false;
const out: Record<string, unknown> = { ...effect };
if (typeof effect.color === 'string' && isVariableRef(effect.color)) {
out.color = resolveColorRef(effect.color, vars, theme) ?? '#000000';
changed = true;
}
for (const key of ['blur', 'offsetX', 'offsetY', 'spread'] as const) {
const val = effect[key];
if (typeof val === 'string' && isVariableRef(val)) {
out[key] = resolveNumericRef(val, vars, theme) ?? 0;
changed = true;
}
}
return changed ? (out as unknown as PenEffect) : effect;
});
}
// ---------------------------------------------------------------------------
// Full node resolution for canvas rendering
// ---------------------------------------------------------------------------
/**
* Resolve all `$variable` references in a PenNode, returning a new node
* with concrete values suitable for Fabric.js rendering.
*
* Returns the same object reference when no variables are present.
*/
export function resolveNodeForCanvas(node: PenNode, variables: Vars, activeTheme?: Theme): PenNode {
if (!variables) variables = {};
let changed = false;
const out: Record<string, unknown> = { ...node };
// Opacity
if (typeof node.opacity === 'string' && isVariableRef(node.opacity)) {
out.opacity = resolveNumericRef(node.opacity, variables, activeTheme) ?? 1;
changed = true;
}
// Gap
if ('gap' in node && typeof (node as unknown as Record<string, unknown>).gap === 'string') {
const gap = (node as unknown as Record<string, unknown>).gap as string;
if (isVariableRef(gap)) {
out.gap = resolveNumericRef(gap, variables, activeTheme) ?? 0;
changed = true;
}
}
// Padding
if ('padding' in node) {
const padding = (node as unknown as Record<string, unknown>).padding;
if (typeof padding === 'string' && isVariableRef(padding)) {
out.padding = resolveNumericRef(padding, variables, activeTheme) ?? 0;
changed = true;
}
}
// Fill
if ('fill' in node && (node as unknown as Record<string, unknown>).fill) {
const fills = (node as unknown as Record<string, unknown>).fill as PenFill[] | string;
const resolved = resolveFillsForCanvas(fills, variables, activeTheme);
if (resolved !== fills) {
out.fill = resolved;
changed = true;
}
}
// Stroke
if ('stroke' in node && (node as unknown as Record<string, unknown>).stroke) {
const stroke = (node as unknown as Record<string, unknown>).stroke as PenStroke;
const resolved = resolveStrokeForCanvas(stroke, variables, activeTheme);
if (resolved !== stroke) {
out.stroke = resolved;
changed = true;
}
}
// Effects
if ('effects' in node && (node as unknown as Record<string, unknown>).effects) {
const effects = (node as unknown as Record<string, unknown>).effects as PenEffect[];
const resolved = resolveEffectsForCanvas(effects, variables, activeTheme);
if (resolved !== effects) {
out.effects = resolved;
changed = true;
}
}
// Typography numeric refs (text nodes only) — fontSize / lineHeight / letterSpacing
// These must be resolved before the layout engine and renderer consume them,
// since both paths use `node.fontSize ?? 16` (not type-guarded for strings).
if (node.type === 'text') {
const n = node as unknown as Record<string, unknown>;
for (const key of ['fontSize', 'fontWeight', 'lineHeight', 'letterSpacing'] as const) {
if (typeof n[key] === 'string' && isVariableRef(n[key] as string)) {
const resolved = resolveNumericRef(n[key] as string, variables, activeTheme);
if (resolved !== undefined) {
out[key] = resolved;
changed = true;
}
}
}
}
// cornerRadius scalar ref (frames, rectangles, etc.)
{
const n = node as unknown as Record<string, unknown>;
if (typeof n.cornerRadius === 'string' && isVariableRef(n.cornerRadius)) {
const resolved = resolveNumericRef(n.cornerRadius, variables, activeTheme);
if (resolved !== undefined) {
out.cornerRadius = resolved;
changed = true;
}
}
}
// Text content
if (node.type === 'text' && typeof node.content === 'string' && isVariableRef(node.content)) {
const resolved = resolveVariableRef(node.content, variables, activeTheme);
if (typeof resolved === 'string') {
out.content = resolved;
changed = true;
}
}
// Recurse into children
if ('children' in node && node.children) {
const children = node.children;
const resolvedChildren = children.map((child) =>
resolveNodeForCanvas(child, variables, activeTheme),
);
// Only allocate new array if any child actually changed
if (resolvedChildren.some((rc, i) => rc !== children[i])) {
out.children = resolvedChildren;
changed = true;
}
}
return changed ? (out as unknown as PenNode) : node;
}