From 4cd187c76633c42e717d2b653d85ed173beb3d53 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 26 May 2026 11:57:13 +0300 Subject: [PATCH] fix(kiwi): preserve more Figma metadata --- packages/core/src/kiwi/fig/codec/index.ts | 16 +++++++++++++ .../core/src/kiwi/fig/node-change/convert.ts | 14 +++++++++++ .../core/src/scene-graph/source-metadata.ts | 2 ++ packages/docs/development/roadmap.md | 4 ++-- .../io/fig/import/raw-field-coverage.test.ts | 14 +++++++++++ .../io/fig/import/schema-coverage.test.ts | 23 +++++++++++++------ .../io/fig/roundtrip/source-metadata.test.ts | 12 ++++++++++ 7 files changed, 76 insertions(+), 9 deletions(-) diff --git a/packages/core/src/kiwi/fig/codec/index.ts b/packages/core/src/kiwi/fig/codec/index.ts index 506fe68b4..9d9634ce0 100644 --- a/packages/core/src/kiwi/fig/codec/index.ts +++ b/packages/core/src/kiwi/fig/codec/index.ts @@ -293,12 +293,18 @@ export interface NodeChange { cornerRadius?: number fillPaints?: Paint[] strokePaints?: Paint[] + backgroundPaints?: Paint[] strokeWeight?: number strokeAlign?: string strokeCap?: string strokeJoin?: string dashPattern?: number[] effects?: Effect[] + mask?: boolean + maskType?: string + maskIsOutline?: boolean + exportSettings?: unknown[] + layoutGrids?: unknown[] // Layout stackMode?: 'NONE' | 'HORIZONTAL' | 'VERTICAL' stackSpacing?: number @@ -380,6 +386,12 @@ export interface NodeChange { textBidiVersion?: number textDecoration?: string textDecorationSkipInk?: boolean + textDecorationFillPaints?: Paint[] + textUnderlineOffset?: { value?: number; units?: string } + textDecorationThickness?: { value?: number; units?: string } + textDecorationStyle?: string + toggledOnOTFeatures?: string[] + toggledOffOTFeatures?: string[] fontVariations?: Array<{ axisTag?: number; axisName?: string; value?: number }> fontVariantCommonLigatures?: boolean fontVariantContextualLigatures?: boolean @@ -404,6 +416,10 @@ export interface NodeChange { // Constraints horizontalConstraint?: string verticalConstraint?: string + // Prototype + prototypeStartNodeID?: GUID + prototypeInteractions?: unknown[] + transitionInfo?: unknown // Variables variableData?: VariableDataEntry variableConsumptionMap?: { entries?: VariableConsumptionEntry[] } diff --git a/packages/core/src/kiwi/fig/node-change/convert.ts b/packages/core/src/kiwi/fig/node-change/convert.ts index 58aff038a..37e58974b 100644 --- a/packages/core/src/kiwi/fig/node-change/convert.ts +++ b/packages/core/src/kiwi/fig/node-change/convert.ts @@ -760,6 +760,9 @@ export const FIGMA_RAW_NODE_FIELD_KEYS = [ 'styleIdForText', 'styleIdForEffect', 'styleIdForGrid', + 'backgroundPaints', + 'layoutGrids', + 'exportSettings', 'componentPropDefs', 'componentPropRefs', 'variantPropSpecs', @@ -784,6 +787,9 @@ export const FIGMA_RAW_NODE_FIELD_KEYS = [ 'handoffStatusMap', 'annotationCategories', 'miterLimit', + 'mask', + 'maskType', + 'maskIsOutline', 'strokeWeight', 'strokeJoin', 'borderStrokeWeightsIndependent', @@ -815,7 +821,12 @@ export const FIGMA_RAW_NODE_FIELD_KEYS = [ 'fontVariations', 'fontVariantCommonLigatures', 'fontVariantContextualLigatures', + 'toggledOnOTFeatures', + 'toggledOffOTFeatures', 'leadingTrim', + 'textDecorationFillPaints', + 'textUnderlineOffset', + 'textDecorationThickness', 'textDecorationStyle', 'semanticWeight', 'semanticItalic', @@ -826,6 +837,9 @@ export const FIGMA_RAW_NODE_FIELD_KEYS = [ 'strokePaints', 'effects', 'sectionStatusInfo', + 'prototypeStartNodeID', + 'prototypeInteractions', + 'transitionInfo', 'codeSyntax', 'lockMode', 'slideThemeMap', diff --git a/packages/core/src/scene-graph/source-metadata.ts b/packages/core/src/scene-graph/source-metadata.ts index 395ab90bc..9eea3332a 100644 --- a/packages/core/src/scene-graph/source-metadata.ts +++ b/packages/core/src/scene-graph/source-metadata.ts @@ -64,6 +64,8 @@ const RAW_NODE_FIELD_KEYS = new Set([ 'vectorNetwork', 'fillGeometry', 'strokeGeometry', + 'isMask', + 'maskType', 'clipsContent' ]) diff --git a/packages/docs/development/roadmap.md b/packages/docs/development/roadmap.md index c5c5cf7c3..6cf5b38c6 100644 --- a/packages/docs/development/roadmap.md +++ b/packages/docs/development/roadmap.md @@ -195,9 +195,9 @@ OpenPencil deliberately preserves many Figma/Kiwi fields even when they are not | Version/sort/publish/library metadata | ↩ | — | ◐ | Assets UI shows a subset; publish/update workflow is missing. | | Variable and parameter consumption maps | ✅ | ◐ | ◐ | Filtered/preserved for safe round-trip; normalized bindings cover common cases. | | Page fields: background, page type, guides | ↩ | ◐ | — | Background/page type/guides mostly round-trip. Guides are not rendered/editable. | -| Text internals: `textData`, layout versions, font version, derived data | ✅ | ✅ | — | Important for text fidelity; most internals are not editable. Imported derived text data, leading trim, decoration style, and semantic font metadata are preserved for round-trip when safe. | +| Text internals: `textData`, layout versions, font version, derived data | ✅ | ✅ | — | Important for text fidelity; most internals are not editable. Imported derived text data, leading trim, decoration style, underline decoration paint/offset/thickness, semantic font metadata, and raw OpenType feature toggles are preserved for round-trip when safe. | | `fontVariations` | ✅ | ✅ | — | Variable font axes are imported, rendered, and exported for text nodes and style runs. | -| Raw paint/effect/vector/geometry payloads | ✅ | ✅ | ◐ | Converted fields render; raw payloads preserve Figma import/export details. | +| Raw paint/effect/vector/geometry payloads | ✅ | ✅ | ◐ | Converted fields render; raw payloads preserve Figma import/export details, including mask, background paint, layout grid, export setting, and prototype interaction metadata where safe. | ## Highest-priority visual gaps diff --git a/tests/engine/io/fig/import/raw-field-coverage.test.ts b/tests/engine/io/fig/import/raw-field-coverage.test.ts index 3089c68db..06547167f 100644 --- a/tests/engine/io/fig/import/raw-field-coverage.test.ts +++ b/tests/engine/io/fig/import/raw-field-coverage.test.ts @@ -11,6 +11,7 @@ const RAW_FIELD_COVERAGE = { 'borderStrokeWeightsIndependent', 'borderTopWeight', 'derivedTextData', + 'backgroundPaints', 'effects', 'fillGeometry', 'fillPaints', @@ -19,26 +20,36 @@ const RAW_FIELD_COVERAGE = { 'fontVariantCommonLigatures', 'fontVariantContextualLigatures', 'fontVariations', + 'layoutGrids', 'leadingTrim', 'letterSpacing', 'lineHeight', + 'mask', + 'maskIsOutline', + 'maskType', 'maxLines', 'miterLimit', 'semanticItalic', 'semanticWeight', 'strokeGeometry', + 'textDecorationFillPaints', 'strokeJoin', 'strokePaints', 'strokeWeight', 'textAutoResize', 'textData', 'textDecorationStyle', + 'textUnderlineOffset', + 'textDecorationThickness', 'textPathStart', 'textTracking', + 'toggledOffOTFeatures', + 'toggledOnOTFeatures', 'vectorData' ], uiEditable: [ 'componentPropDefs', + 'exportSettings', 'fontSize', 'letterSpacing', 'lineHeight', @@ -88,6 +99,8 @@ const RAW_FIELD_COVERAGE = { 'minSize', 'pageType', 'parameterConsumptionMap', + 'prototypeInteractions', + 'prototypeStartNodeID', 'scatterStrokeSettings', 'sectionStatusInfo', 'slideThemeMap', @@ -102,6 +115,7 @@ const RAW_FIELD_COVERAGE = { 'textExplicitLayoutVersion', 'textUserLayoutVersion', 'targetAspectRatio', + 'transitionInfo', 'userFacingVersion', 'variableConsumptionMap', 'variableModeBySetMap', diff --git a/tests/engine/io/fig/import/schema-coverage.test.ts b/tests/engine/io/fig/import/schema-coverage.test.ts index 12d0d854a..ed97c259e 100644 --- a/tests/engine/io/fig/import/schema-coverage.test.ts +++ b/tests/engine/io/fig/import/schema-coverage.test.ts @@ -373,17 +373,17 @@ describe('Figma Kiwi schema coverage', () => { expect( Object.fromEntries([...buckets].map(([bucket, items]) => [bucket, items.length])) ).toEqual({ - modeled: 89, + modeled: 104, schemaTag: 60, - internalBookkeeping: 18, - rawPreserved: 53, + internalBookkeeping: 17, + rawPreserved: 52, styleLibraryMetadata: 39, componentInstanceMetadata: 42, - textMetadata: 28, + textMetadata: 23, slideFigjamMetadata: 39, - visualGeometryMetadata: 42, - layoutMetadata: 30, - prototypeConnectorMetadata: 35, + visualGeometryMetadata: 38, + layoutMetadata: 29, + prototypeConnectorMetadata: 32, variableDevMetadata: 14, widgetMetadata: 11, codeCmsAiMetadata: 67, @@ -405,6 +405,15 @@ describe('Figma Kiwi schema coverage', () => { expect(covered('textDecorationStyle')).toBe(true) expect(covered('semanticWeight')).toBe(true) expect(covered('semanticItalic')).toBe(true) + expect(covered('toggledOnOTFeatures')).toBe(true) + expect(covered('toggledOffOTFeatures')).toBe(true) + expect(covered('textDecorationFillPaints')).toBe(true) + expect(covered('textUnderlineOffset')).toBe(true) + expect(covered('textDecorationThickness')).toBe(true) + expect(covered('mask')).toBe(true) + expect(covered('maskType')).toBe(true) + expect(covered('maskIsOutline')).toBe(true) + expect(covered('layoutGrids')).toBe(true) expect(covered('gridChildVerticalAlign')).toBe(true) expect(covered('gridChildHorizontalAlign')).toBe(true) expect(covered('slideThemeMap')).toBe(true) diff --git a/tests/engine/io/fig/roundtrip/source-metadata.test.ts b/tests/engine/io/fig/roundtrip/source-metadata.test.ts index 778f2ea94..61f59181d 100644 --- a/tests/engine/io/fig/roundtrip/source-metadata.test.ts +++ b/tests/engine/io/fig/roundtrip/source-metadata.test.ts @@ -137,6 +137,13 @@ describe('fig roundtrip source metadata', () => { text.source.id = '4:502' text.source.fig.rawNodeFields.leadingTrim = 'CAP_HEIGHT' text.source.fig.rawNodeFields.textDecorationStyle = 'WAVY' + text.source.fig.rawNodeFields.textDecorationFillPaints = [ + { type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1 } + ] + text.source.fig.rawNodeFields.textUnderlineOffset = { value: 2, units: 'PIXELS' } + text.source.fig.rawNodeFields.textDecorationThickness = { value: 1.5, units: 'PIXELS' } + text.source.fig.rawNodeFields.toggledOnOTFeatures = ['DLIG'] + text.source.fig.rawNodeFields.toggledOffOTFeatures = ['LIGA'] text.source.fig.rawNodeFields.semanticWeight = 'BOLD' text.source.fig.rawNodeFields.semanticItalic = 'ITALIC' text.source.fig.rawNodeFields.derivedTextData = { @@ -151,6 +158,11 @@ describe('fig roundtrip source metadata', () => { expect(exported?.leadingTrim).toBe('CAP_HEIGHT') expect(exported?.textDecorationStyle).toBe('WAVY') + expect(exported?.textDecorationFillPaints?.[0]?.type).toBe('SOLID') + expect(exported?.textUnderlineOffset).toEqual({ value: 2, units: 'PIXELS' }) + expect(exported?.textDecorationThickness).toEqual({ value: 1.5, units: 'PIXELS' }) + expect(exported?.toggledOnOTFeatures).toEqual(['DLIG']) + expect(exported?.toggledOffOTFeatures).toEqual(['LIGA']) expect(exported?.semanticWeight).toBe('BOLD') expect(exported?.semanticItalic).toBe('ITALIC') expect(exported?.derivedTextData?.layoutSize).toEqual({ x: 80, y: 20 })