fix(fig): preserve raw Figma override metadata

- Preserve raw paint variables, style ids, text line metadata, and variable maps
- Keep raw node paint/geometry/effect fields for imported Figma nodes
- Extend the Kiwi schema for current Figma override and variable fields
This commit is contained in:
Danila Poyarkov 2026-05-20 21:24:58 +03:00
parent 513d630653
commit 81fa529332
5 changed files with 106 additions and 7 deletions

View file

@ -846,7 +846,8 @@ message Paint {
Video video = 18;
uint originalImageWidth = 19;
uint originalImageHeight = 20;
PaintVariableBinding variableBinding = 21; // Not in .fig files. Discovered via WS sniffing 2026-01
VariableData colorVar = 21;
VariableData opacityVar = 38;
}
message FontMetaData {
@ -904,6 +905,7 @@ message Glyph {
float fontSize = 4;
uint firstCharacter = 5;
float advance = 6;
float rotation = 7;
}
message Decoration {
@ -1705,6 +1707,12 @@ message TextLineData {
int indentationLevel = 2;
Directionality directionality = 3;
DirectionalityIntent directionalityIntent = 4;
int downgradeStyleId = 5;
int consistencyStyleId = 6;
int listStartOffset = 7;
bool isFirstLineOfList = 8;
SourceDirectionality sourceDirectionality = 9;
int styleId = 10;
}
enum BulletType {
@ -1725,6 +1733,12 @@ enum Directionality {
RTL = 1;
}
enum SourceDirectionality {
AUTO = 0;
LTR = 1;
RTL = 2;
}
enum DirectionalityIntent {
IMPLICIT = 0;
EXPLICIT = 1;
@ -2053,6 +2067,7 @@ enum VariableDataType {
COLOR = 4;
SYMBOL_ID = 7;
TEXT_DATA = 9;
PROP_REF = 13;
}
enum VariableResolvedDataType {
@ -2076,6 +2091,7 @@ message StyleId {
message VariableID {
GUID guid = 1;
AssetRef assetRef = 2;
}
message SymbolId {
@ -2140,6 +2156,8 @@ enum VariableScope {
enum VariableField {
MISSING = 0;
CORNER_RADIUS = 1;
PARAGRAPH_SPACING = 2;
PARAGRAPH_INDENT = 3;
STROKE_WEIGHT = 4;
STACK_SPACING = 5;
STACK_PADDING_LEFT = 6;
@ -2154,11 +2172,54 @@ enum VariableField {
RECTANGLE_TOP_RIGHT_CORNER_RADIUS = 15;
RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS = 16;
RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS = 17;
BORDER_TOP_WEIGHT = 18;
BORDER_BOTTOM_WEIGHT = 19;
BORDER_LEFT_WEIGHT = 20;
BORDER_RIGHT_WEIGHT = 21;
VARIANT_PROPERTIES = 22;
STACK_COUNTER_SPACING = 23;
MIN_WIDTH = 24;
MAX_WIDTH = 25;
MIN_HEIGHT = 26;
MAX_HEIGHT = 27;
FONT_FAMILY = 28;
FONT_STYLE = 29;
FONT_VARIATIONS = 30;
OPACITY = 31;
FONT_SIZE = 32;
LETTER_SPACING = 34;
LINE_HEIGHT = 36;
STACK_COUNTER_SPACING = 23;
OVERRIDDEN_SYMBOL_ID = 37;
HYPERLINK = 38;
CMS_SERIALIZED_RICH_TEXT_DATA = 39;
SLOT_CONTENT_ID = 40;
GRID_ROW_GAP = 41;
GRID_COLUMN_GAP = 42;
X_POSITION = 43;
Y_POSITION = 44;
ROTATION = 45;
MOTION_TRANSLATION_X = 46;
MOTION_TRANSLATION_Y = 47;
MOTION_ROTATION = 48;
MOTION_SCALE_X = 49;
MOTION_SCALE_Y = 50;
MOTION_SHEAR = 51;
SCROLL_OFFSET_X = 52;
SCROLL_OFFSET_Y = 53;
PATH_TRIM_START = 54;
PATH_TRIM_END = 55;
DISSOLVE_PROGRESS = 56;
EASING_DATA = 57;
MEDIA_CURRENT_TIME = 58;
TRANSFORM_3D_PERSPECTIVE = 59;
TRANSFORM_3D_TRANSLATION_Z = 60;
TRANSFORM_3D_ROTATION_X = 61;
TRANSFORM_3D_ROTATION_Y = 62;
TRANSFORM_3D_ROTATION_Z = 63;
POLYGON_COUNT = 64;
ARC_DATA_STARTING_ANGLE = 65;
ARC_DATA_ENDING_ANGLE = 66;
ARC_DATA_INNER_RADIUS = 67;
}
message VariableDataMapEntry {

View file

@ -483,7 +483,7 @@ export function nodeChangeToProps(
nodeType,
name: nc.name ?? nodeType,
figmaGuid: nc.guid ? guidToString(nc.guid) : null,
...extractFigmaRawGeometry(nc),
...extractFigmaRawGeometry(nc, blobs),
...extractFigmaSymbolMetadata(nc, blobs),
...convertTransformProps(nc),
opacity: nc.opacity ?? 1,
@ -682,6 +682,7 @@ interface PreservedFigmaBlob {
}
function preserveFigmaPayloadBlobs(value: unknown, blobs: Uint8Array[]): unknown {
if (value instanceof Uint8Array) return value
if (Array.isArray(value)) return value.map((item) => preserveFigmaPayloadBlobs(item, blobs))
if (!value || typeof value !== 'object') return value
const result: Record<string, unknown> = {}
@ -699,12 +700,38 @@ function preserveFigmaPayloadBlobs(value: unknown, blobs: Uint8Array[]): unknown
return result
}
const FIGMA_RAW_NODE_FIELD_KEYS = [
'styleIdForFill',
'styleIdForStrokeFill',
'styleIdForText',
'styleIdForEffect',
'styleIdForGrid',
'variableConsumptionMap',
'parameterConsumptionMap',
'editInfo',
'miterLimit',
'strokeWeight',
'strokeJoin',
'fillPaints',
'strokePaints',
'effects',
'fillGeometry',
'strokeGeometry'
]
function extractFigmaRawGeometry(
nc: NodeChange
): Pick<SceneNode, 'figmaRawSize' | 'figmaRawTransform'> {
nc: NodeChange,
blobs: Uint8Array[]
): Pick<SceneNode, 'figmaRawSize' | 'figmaRawTransform' | 'figmaRawNodeFields'> {
const figmaRawNodeFields: Record<string, unknown> = {}
for (const key of FIGMA_RAW_NODE_FIELD_KEYS) {
const value = (nc as Record<string, unknown>)[key]
if (value !== undefined) figmaRawNodeFields[key] = preserveFigmaPayloadBlobs(value, blobs)
}
return {
figmaRawSize: nc.size ? { ...nc.size } : null,
figmaRawTransform: nc.transform ? { ...nc.transform } : null
figmaRawTransform: nc.transform ? { ...nc.transform } : null,
figmaRawNodeFields
}
}

View file

@ -100,6 +100,7 @@ function parseGuidOrNull(value: string) {
}
function materializeFigmaPayload(value: unknown, blobs: Uint8Array[]): unknown {
if (value instanceof Uint8Array) return value
if (Array.isArray(value)) return value.map((item) => materializeFigmaPayload(item, blobs))
if (!value || typeof value !== 'object') return value
if ('__openPencilFigmaBlob' in value) {
@ -113,7 +114,6 @@ function materializeFigmaPayload(value: unknown, blobs: Uint8Array[]): unknown {
const materialized: Record<string, unknown> = {}
for (const [key, child] of Object.entries(value)) {
if (key === 'fontMetaData') continue
materialized[key] = materializeFigmaPayload(child, blobs)
}
return materialized
@ -146,6 +146,14 @@ function getOrCreateNodeGuid(
return guid
}
function applyRawFigmaNodeFields(
context: SceneNodeToKiwiContext,
node: SceneNode,
nc: KiwiNodeChange
): void {
Object.assign(nc, materializeFigmaPayload(node.figmaRawNodeFields, context.blobs))
}
function applyInstancePayload(
context: SceneNodeToKiwiContext,
node: SceneNode,
@ -330,6 +338,7 @@ export function sceneNodeToKiwiWithContext(
context.serializeLayoutProps(node, nc)
context.serializeGeometry(node, nc, context.blobs)
context.serializeVariableBindings(node, nc, context.graph, context.varIdToGuid)
applyRawFigmaNodeFields(context, node, nc)
const pluginData = mergePluginData(node.pluginData)
if (pluginData.length > 0) nc.pluginData = pluginData

View file

@ -20,6 +20,7 @@ export function createDefaultNode(
rotation: 0,
figmaRawSize: null,
figmaRawTransform: null,
figmaRawNodeFields: {},
figmaDerivedLayout: null,
fills:
type === 'TEXT'

View file

@ -271,6 +271,7 @@ export interface SceneNode {
rotation: number
figmaRawSize: Vector | null
figmaRawTransform: Matrix | null
figmaRawNodeFields: Record<string, unknown>
figmaDerivedLayout: Partial<Rect> | null
fills: Fill[]