From c946337f3dae44f61b3c7e8eb8057445a5c8d91a Mon Sep 17 00:00:00 2001 From: PAVEL TKACHEV Date: Sun, 6 Sep 2026 23:50:03 +0300 Subject: [PATCH] fix(pen): resolve ordinary variable references (#622) * fix(pen): resolve ordinary variable references Recognize nonempty dollar-prefixed variable names while preserving double-dash references and existing bindings. Add parser regressions, a reproducible .pen fixture, and visual coverage comparing variable references with literal values. Update the Unreleased changelog for #563. * test(pen): strengthen variable reference coverage --------- Co-authored-by: Danila Poyarkov --- CHANGELOG.md | 1 + packages/pen/src/convert.ts | 2 +- tests/e2e/canvas/pen-variables.spec.ts | 33 ++++ ...ble-colors-and-fonts-openpencil-darwin.png | Bin 0 -> 16641 bytes tests/engine/pen/var-padding.test.ts | 9 ++ tests/engine/pen/variables.test.ts | 145 ++++++++++++++++++ tests/fixtures/pen-variables.pen | 77 ++++++++++ 7 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/canvas/pen-variables.spec.ts create mode 100644 tests/e2e/canvas/pen-variables.spec.ts-snapshots/pen-variable-colors-and-fonts-openpencil-darwin.png create mode 100644 tests/engine/pen/variables.test.ts create mode 100644 tests/fixtures/pen-variables.pen diff --git a/CHANGELOG.md b/CHANGELOG.md index 982c89c17..31a51cbfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ ### Fixed +- Resolve `$name` references in imported `.pen` fills, stroke fills, font families, dimensions, and spacing without requiring a `--` prefix. (#563) - Prevent the stock photo tool from replacing text, lines, structural layers, or containers with content while supporting closed shape geometry. - Preserve explicit text alignment metadata on imported Figma vectors across save and reload. diff --git a/packages/pen/src/convert.ts b/packages/pen/src/convert.ts index 137b1f90e..79b9a18d5 100644 --- a/packages/pen/src/convert.ts +++ b/packages/pen/src/convert.ts @@ -145,7 +145,7 @@ function defaultForType(type: VariableType): VariableValue { } export function isVarRef(val: unknown): val is string { - return typeof val === 'string' && val.startsWith('$--') + return typeof val === 'string' && val.startsWith('$') && val.length > 1 } function varName(ref: string): string { diff --git a/tests/e2e/canvas/pen-variables.spec.ts b/tests/e2e/canvas/pen-variables.spec.ts new file mode 100644 index 000000000..25456b2fa --- /dev/null +++ b/tests/e2e/canvas/pen-variables.spec.ts @@ -0,0 +1,33 @@ +import { expect, test, useEditorSetup } from '#tests/e2e/fixtures' + +const editor = useEditorSetup('/?test&no-chrome&no-rulers') + +test('renders imported .pen variable colors and fonts like literal values', async () => { + await editor.page.evaluate(() => + window.openPencil?.openFile?.('/tests/fixtures/pen-variables.pen') + ) + await editor.canvas.waitForInit() + expect( + await editor.page.evaluate(() => { + const store = window.openPencil?.getStore?.() + return ['literal-text', 'plain-text', 'dashed-text'].map( + (id) => store?.graph.getNode(id)?.fontFamily + ) + }) + ).toEqual(['Noto Naskh Arabic', 'Noto Naskh Arabic', 'Noto Naskh Arabic']) + await editor.page.evaluate(() => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('OpenPencil store not initialized') + store.state.zoom = 1 + store.state.panX = 0 + store.state.panY = 0 + store.clearSelection() + store.requestRender() + }) + await editor.canvas.waitForRender() + editor.canvas.assertNoErrors() + + expect(await editor.canvas.screenshotCanvasRegion(860, 300)).toMatchSnapshot( + 'pen-variable-colors-and-fonts.png' + ) +}) diff --git a/tests/e2e/canvas/pen-variables.spec.ts-snapshots/pen-variable-colors-and-fonts-openpencil-darwin.png b/tests/e2e/canvas/pen-variables.spec.ts-snapshots/pen-variable-colors-and-fonts-openpencil-darwin.png new file mode 100644 index 0000000000000000000000000000000000000000..78ac570bf585af5746f9f589146d973552f5230c GIT binary patch literal 16641 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 roiz(|7A7V-J>%|?HE=02 { }) describe('isVarRef', () => { + test.each(['$merk-blauw', '$font-tekst', '$color.background', '$x'])( + 'recognizes %s as a variable reference', + (reference) => { + expect(isVarRef(reference)).toBe(true) + } + ) + test('recognizes $--xxx as variable reference', () => { expect(isVarRef('$--spacing-lg')).toBe(true) expect(isVarRef('$--color-primary')).toBe(true) @@ -130,6 +137,8 @@ describe('isVarRef', () => { expect(isVarRef('24')).toBe(false) expect(isVarRef('spacing-lg')).toBe(false) expect(isVarRef('')).toBe(false) + expect(isVarRef('$')).toBe(false) + expect(isVarRef('price$')).toBe(false) }) test('rejects non-string values', () => { diff --git a/tests/engine/pen/variables.test.ts b/tests/engine/pen/variables.test.ts new file mode 100644 index 000000000..965f9da36 --- /dev/null +++ b/tests/engine/pen/variables.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, test } from 'bun:test' + +import { parsePenFile, type PenDocument, type PenNode } from '@open-pencil/pen' + +const BLUE = { r: 64 / 255, g: 148 / 255, b: 208 / 255, a: 1 } + +function parseVariableDocument(prefix: string, children: PenNode[]) { + const document: PenDocument = { + version: '2.14', + variables: { + [`${prefix}merk-blauw`]: { type: 'color', value: '#4094D0' }, + [`${prefix}font-tekst`]: { type: 'string', value: 'Barlow' }, + [`${prefix}spacing`]: { type: 'number', value: 24 }, + [`${prefix}size`]: { type: 'number', value: 240 } + }, + children + } + return parsePenFile(JSON.stringify(document)) +} + +test('keeps plain and double-dash variable names distinct in one document', () => { + const document: PenDocument = { + version: '2.14', + variables: { + blue: { type: 'color', value: '#4094D0' }, + '--blue': { type: 'color', value: '#FFFFFF' } + }, + children: [ + { id: 'plain', type: 'frame', fill: '$blue' }, + { id: 'dashed', type: 'frame', fill: '$--blue' } + ] + } + const graph = parsePenFile(JSON.stringify(document)) + const plain = graph.getNode('plain') + const dashed = graph.getNode('dashed') + const plainId = plain?.boundVariables['fills[0]'] + const dashedId = dashed?.boundVariables['fills[0]'] + + expect(plain?.fills[0]?.color).toEqual(BLUE) + expect(dashed?.fills[0]?.color).toEqual({ r: 1, g: 1, b: 1, a: 1 }) + expect(plainId).toBeDefined() + expect(dashedId).toBeDefined() + expect(plainId).not.toBe(dashedId) + expect(graph.variables.get(plainId ?? '')?.name).toBe('blue') + expect(graph.variables.get(dashedId ?? '')?.name).toBe('--blue') +}) + +describe.each(['', '--'])('parsePenFile — $%sname references (#563)', (prefix) => { + const colorRef = `$${prefix}merk-blauw` + const fontRef = `$${prefix}font-tekst` + const spacingRef = `$${prefix}spacing` + const sizeRef = `$${prefix}size` + + test.each([ + ['string', colorRef], + ['object', { type: 'color', color: colorRef }], + ['array', [{ type: 'color', color: colorRef }]] + ] satisfies [string, PenNode['fill']][])( + 'resolves %s fills and preserves color bindings', + (_name, fill) => { + const graph = parseVariableDocument(prefix, [ + { id: 'literal', type: 'frame', fill: '#4094D0', width: 240, height: 120 }, + { + id: 'bound', + type: 'frame', + fill, + stroke: { align: 'inside', thickness: 2, fill: colorRef }, + width: 240, + height: 120 + } + ]) + const node = graph.getNode('bound') + const variable = [...graph.variables.values()].find( + (candidate) => candidate.name === `${prefix}merk-blauw` + ) + + expect(variable).toBeDefined() + expect(node?.fills).toEqual(graph.getNode('literal')?.fills) + expect(node?.fills[0]?.color).toEqual(BLUE) + expect(node?.strokes[0]?.color).toEqual(BLUE) + expect(node?.boundVariables).toEqual({ + 'fills[0]': variable?.id, + 'strokes[0]': variable?.id + }) + expect(graph.getNode('literal')?.boundVariables).toEqual({}) + } + ) + + test('resolves and binds font families without expanding text content', () => { + const graph = parseVariableDocument(prefix, [ + { id: 'literal', type: 'text', content: colorRef, fontFamily: 'Barlow' }, + { id: 'bound', type: 'text', content: colorRef, fontFamily: fontRef } + ]) + const node = graph.getNode('bound') + const fontId = node?.boundVariables.fontFamily + + expect(node?.fontFamily).toBe('Barlow') + expect(node?.fontFamily).toBe(graph.getNode('literal')?.fontFamily) + expect(node?.text).toBe(colorRef) + expect(fontId).toBeDefined() + expect(graph.variables.get(fontId ?? '')?.name).toBe(`${prefix}font-tekst`) + expect(graph.getNode('literal')?.boundVariables).toEqual({}) + }) + + test('resolves numeric references in sizing, spacing, padding, and corners', () => { + const graph = parseVariableDocument(prefix, [ + { + id: 'frame', + type: 'frame', + layout: 'horizontal', + width: sizeRef, + height: sizeRef, + gap: spacingRef, + padding: spacingRef, + cornerRadius: spacingRef + } + ]) + + expect(graph.getNode('frame')).toMatchObject({ + width: 240, + height: 240, + itemSpacing: 24, + paddingTop: 24, + paddingRight: 24, + paddingBottom: 24, + paddingLeft: 24, + cornerRadius: 24 + }) + }) + + test('uses existing fallbacks without binding unknown variables', () => { + const unknown = `$${prefix}unknown` + const graph = parseVariableDocument(prefix, [ + { id: 'frame', type: 'frame', fill: unknown, width: unknown }, + { id: 'text', type: 'text', fontFamily: unknown } + ]) + + expect(graph.getNode('frame')).toMatchObject({ + fills: [{ color: { r: 0, g: 0, b: 0, a: 1 } }], + width: 0, + boundVariables: {} + }) + expect(graph.getNode('text')).toMatchObject({ fontFamily: '', boundVariables: {} }) + }) +}) diff --git a/tests/fixtures/pen-variables.pen b/tests/fixtures/pen-variables.pen new file mode 100644 index 000000000..2bc557ab3 --- /dev/null +++ b/tests/fixtures/pen-variables.pen @@ -0,0 +1,77 @@ +{ + "version": "2.14", + "variables": { + "merk-blauw": { "type": "color", "value": "#4094D0" }, + "font-tekst": { "type": "string", "value": "Noto Naskh Arabic" }, + "--merk-blauw": { "type": "color", "value": "#4094D0" }, + "--font-tekst": { "type": "string", "value": "Noto Naskh Arabic" } + }, + "children": [ + { + "id": "literal", + "type": "frame", + "name": "Literal values", + "x": 60, + "y": 80, + "width": 220, + "height": 160, + "fill": "#4094D0", + "children": [ + { + "id": "literal-text", + "type": "text", + "x": 24, + "y": 60, + "content": "مرحبا بالعالم", + "fontFamily": "Noto Naskh Arabic", + "fontSize": 22, + "fill": "#FFFFFF" + } + ] + }, + { + "id": "plain", + "type": "frame", + "name": "$name references", + "x": 320, + "y": 80, + "width": 220, + "height": 160, + "fill": "$merk-blauw", + "children": [ + { + "id": "plain-text", + "type": "text", + "x": 24, + "y": 60, + "content": "مرحبا بالعالم", + "fontFamily": "$font-tekst", + "fontSize": 22, + "fill": "#FFFFFF" + } + ] + }, + { + "id": "dashed", + "type": "frame", + "name": "$--name references", + "x": 580, + "y": 80, + "width": 220, + "height": 160, + "fill": "$--merk-blauw", + "children": [ + { + "id": "dashed-text", + "type": "text", + "x": 24, + "y": 60, + "content": "مرحبا بالعالم", + "fontFamily": "$--font-tekst", + "fontSize": 22, + "fill": "#FFFFFF" + } + ] + } + ] +}