refactor(fig): own raw metadata invalidation

- Track normalized edited fields in SceneGraph without naming Figma payload fields\n- Preserve original raw provenance while filtering stale values through Fig-owned effective readers\n- Rewire export and renderer fallbacks and add package, SceneGraph, and exhaustive round-trip coverage
This commit is contained in:
Danila Poyarkov 2026-07-18 02:22:01 +03:00
parent 2bd2304bcb
commit 02b7261c9e
21 changed files with 276 additions and 136 deletions

View file

@ -6,6 +6,7 @@
- Move complete `.fig` archive parsing, NodeChange-to-SceneGraph conversion, and component/instance interpretation into `@open-pencil/fig`, keeping `@open-pencil/kiwi` focused on Kiwi schema, message, and raw container mechanics.
- Remove internal cross-package forwarding modules; import `@open-pencil/fig`, `@open-pencil/pen`, and `@open-pencil/scene-graph` from their owning public exports.
- Track normalized source edits in SceneGraph while preserving original `.fig` provenance and filtering stale raw fields through Fig-owned metadata policy.
- Add Figma-style page management in the Pages panel, including rename/delete actions and drag-and-drop page reordering.
- Add DOM/CSS import and authoring support so HTML, CSS, Tailwind, and JSX can be converted into editable OpenPencil documents from the app, CLI, and SDK.
- Add Tailwind class serialization for DOM/CSS HTML export in the SDK and CLI.

View file

@ -1,5 +1,6 @@
import type { Canvas } from 'canvaskit-wasm'
import { readEffectiveFigmaRawField } from '@open-pencil/fig'
import type { SceneNode } from '@open-pencil/scene-graph'
import type { Color } from '@open-pencil/scene-graph/primitives'
@ -35,7 +36,7 @@ function rawLayoutGrids(node: SceneNode): RawLayoutGrid[] {
const modeledGrids = (node as Partial<SceneNode>).layoutGrids ?? []
if (modeledGrids.length > 0) return modeledGrids
const source = (node as Partial<SceneNode>).source
const grids = source?.fig.rawNodeFields.layoutGrids
const grids = source ? readEffectiveFigmaRawField(node, 'layoutGrids') : undefined
if (!Array.isArray(grids)) return []
return grids.filter((grid): grid is RawLayoutGrid => grid !== null && typeof grid === 'object')
}

View file

@ -1,5 +1,6 @@
import type { Canvas, Path } from 'canvaskit-wasm'
import { readEffectiveFigmaRawField } from '@open-pencil/fig'
import type { SceneNode } from '@open-pencil/scene-graph'
import type { Color, Vector } from '@open-pencil/scene-graph/primitives'
@ -35,7 +36,7 @@ function resetEffectLayerPaint(r: SkiaRenderer): void {
function rawNoiseEffects(node: SceneNode): RawNoiseEffect[] {
const source = (node as Partial<SceneNode>).source
const effects = source?.fig.rawNodeFields.effects
const effects = source ? readEffectiveFigmaRawField(node, 'effects') : undefined
if (!Array.isArray(effects)) return []
return effects.filter(
(effect): effect is RawNoiseEffect =>

View file

@ -1,3 +1,4 @@
import { effectiveFigmaRawNodeFields, effectiveFigmaSourcePayload } from '@open-pencil/fig'
import {
applyExportSettingsPluginData,
mergePluginData,
@ -363,7 +364,7 @@ function applyRawFigmaNodeFields(
node: SceneNode,
nc: KiwiNodeChange
): void {
const materialized = materializeFigmaPayload(node.source.fig.rawNodeFields, context.blobs, {
const materialized = materializeFigmaPayload(effectiveFigmaRawNodeFields(node), context.blobs, {
blobIndexByHex: context.blobIndexByHex,
includePaintVariables: true,
includeVariableMaps: true
@ -543,7 +544,7 @@ function shouldSerializeRawBackedField(
hasValue: boolean,
alreadySerialized = false
): boolean {
return hasValue && !(rawField in node.source.fig.rawNodeFields) && !alreadySerialized
return hasValue && !(rawField in effectiveFigmaRawNodeFields(node)) && !alreadySerialized
}
function applyComponentMetadata(
@ -629,26 +630,22 @@ function applyComponentMetadata(
}
function exportNodeSize(node: SceneNode): Vector {
return node.source.fig.rawSize
? { ...node.source.fig.rawSize }
: { x: node.width, y: node.height }
const rawSize = effectiveFigmaSourcePayload(node).rawSize
return rawSize ? { ...rawSize } : { x: node.width, y: node.height }
}
function exportNodeTransform(context: SceneNodeToKiwiContext, node: SceneNode): Matrix {
return node.source.fig.rawTransform
? { ...node.source.fig.rawTransform }
: context.computeExportTransform(node)
const rawTransform = effectiveFigmaSourcePayload(node).rawTransform
return rawTransform ? { ...rawTransform } : context.computeExportTransform(node)
}
function hasRawGeometryPayload(node: SceneNode): boolean {
return (
'fillGeometry' in node.source.fig.rawNodeFields ||
'strokeGeometry' in node.source.fig.rawNodeFields
)
const rawNodeFields = effectiveFigmaRawNodeFields(node)
return 'fillGeometry' in rawNodeFields || 'strokeGeometry' in rawNodeFields
}
function hasRawVectorPayload(node: SceneNode): boolean {
return 'vectorData' in node.source.fig.rawNodeFields
return 'vectorData' in effectiveFigmaRawNodeFields(node)
}
const SUPPORTED_NORMALIZED_EFFECT_TYPES = new Set([
@ -660,7 +657,7 @@ const SUPPORTED_NORMALIZED_EFFECT_TYPES = new Set([
])
function hasRawUnsupportedEffects(node: SceneNode): boolean {
const effects = node.source.fig.rawNodeFields.effects
const effects = effectiveFigmaRawNodeFields(node).effects
return (
Array.isArray(effects) &&
effects.some(
@ -747,13 +744,11 @@ function applyNodeVisualProps(
if (node.horizontalConstraint !== 'MIN') nc.horizontalConstraint = node.horizontalConstraint
if (node.verticalConstraint !== 'MIN') nc.verticalConstraint = node.verticalConstraint
if (node.strokeCap !== 'NONE') nc.strokeCap = node.strokeCap
if (node.strokeJoin !== 'MITER' || 'strokeJoin' in node.source.fig.rawNodeFields) {
const rawNodeFields = effectiveFigmaRawNodeFields(node)
if (node.strokeJoin !== 'MITER' || 'strokeJoin' in rawNodeFields) {
nc.strokeJoin = node.strokeJoin
}
if (
node.strokeMiterLimit !== DEFAULT_STROKE_MITER_LIMIT ||
'miterLimit' in node.source.fig.rawNodeFields
) {
if (node.strokeMiterLimit !== DEFAULT_STROKE_MITER_LIMIT || 'miterLimit' in rawNodeFields) {
nc.miterLimit = node.strokeMiterLimit
}
if (node.dashPattern.length > 0) nc.dashPattern = node.dashPattern

View file

@ -1,3 +1,5 @@
import { effectiveFigmaRawNodeFields } from '@open-pencil/fig'
import { bytesToHex, hexToBytes } from '#core/bytes/hex'
import { encodePathCommandsBlob } from '#core/kiwi/fig/node-change/path-commands'
import { buildDerivedTextData as buildSharedDerivedTextData } from '#core/text/derived-text/data'
@ -247,7 +249,8 @@ function serializeCornerRadii(node: SceneNode, nc: KiwiNodeChange): void {
// the raw Figma data. Figma may emit per-corner radii without setting the
// independent flag (preserve rectangleCornerRadiiIndependent).
const rawIndependent = node.source.id
? (node.source.fig.rawNodeFields as JsonObject | undefined)?.rectangleCornerRadiiIndependent
? (effectiveFigmaRawNodeFields(node) as JsonObject | undefined)
?.rectangleCornerRadiiIndependent
: undefined
nc.rectangleCornerRadiiIndependent =
typeof rawIndependent === 'boolean' ? rawIndependent : node.independentCorners
@ -256,7 +259,7 @@ function serializeCornerRadii(node: SceneNode, nc: KiwiNodeChange): void {
nc.rectangleBottomLeftCornerRadius = node.bottomLeftRadius
nc.rectangleBottomRightCornerRadius = node.bottomRightRadius
}
if (node.cornerSmoothing > 0 || 'cornerSmoothing' in node.source.fig.rawNodeFields) {
if (node.cornerSmoothing > 0 || 'cornerSmoothing' in effectiveFigmaRawNodeFields(node)) {
nc.cornerSmoothing = node.cornerSmoothing
}
}

View file

@ -224,8 +224,9 @@ These are parsed or visible in Figma docs and most likely to cause visible diffe
| Concern | Files |
| ---------------------------- | -------------------------------------------------------------------------------------------------------------- |
| Scene graph fields | `packages/scene-graph/src/types.ts` |
| Source metadata invalidation | `packages/scene-graph/src/source-metadata.ts` |
| Kiwi import mapping | `packages/core/src/kiwi/fig/node-change/convert.ts` |
| Source edit tracking | `packages/scene-graph/src/source-metadata.ts` |
| `.fig` metadata policy | `packages/fig/src/source-metadata.ts` |
| Kiwi import mapping | `packages/fig/src/node-change/convert.ts` |
| Kiwi export mapping | `packages/core/src/kiwi/fig/node-change/export-node.ts`, `packages/core/src/kiwi/fig/node-change/serialize.ts` |
| Kiwi schema | `packages/kiwi/src/fig/schema/fig.kiwi`, `tests/engine/io/fig/import/schema-coverage.test.ts` |
| Renderer dispatch | `packages/core/src/canvas/scene.ts` |

View file

@ -15,6 +15,7 @@ Current ownership:
- `.fig` source and archive result types
- NodeChange-to-SceneGraph property conversion, including styles, plugin metadata, text, paint, vector, and font policy, through `@open-pencil/fig/node-change`
- Component-property, symbol-override, derived-symbol-data, and instance synchronization policy through `@open-pencil/fig/instance-overrides`
- Effective raw-metadata precedence and invalidation over SceneGraph's format-neutral edited-field tracking
- Package-local archive, conversion, instance, and dist smoke tests
Planned ownership:

View file

@ -6,6 +6,7 @@ const nodeChange = await import('../dist/node-change.js')
if (
mod.FIG_PACKAGE_STATUS !== 'archive-api' ||
typeof mod.effectiveFigmaRawNodeFields !== 'function' ||
typeof mod.parseFigBuffer !== 'function' ||
typeof mod.writeFigArchive !== 'function' ||
typeof instanceOverrides.populateAndApplyOverrides !== 'function' ||

View file

@ -6,6 +6,12 @@ export {
type FigParseResult,
type WriteFigArchiveInput
} from './archive'
export {
effectiveFigmaRawNodeFields,
effectiveFigmaSourcePayload,
readEffectiveFigmaRawField,
staleFigmaRawFields
} from './source-metadata'
import {
FIG_KIWI_DEFAULT_VERSION,

View file

@ -896,6 +896,7 @@ function extractSourceMetadata(nc: NodeChange, blobs: Uint8Array[]): SceneNode['
format: 'fig',
id: nc.guid ? guidToString(nc.guid) : null,
orderKey: nc.parentIndex?.position ?? null,
editedFields: [],
fig: {
...extractFigmaRawGeometry(nc, blobs),
...extractFigmaSymbolMetadata(nc, blobs),

View file

@ -5,9 +5,12 @@ import {
type ExportFormatId,
type ExportSetting,
type PluginDataEntry,
type PluginRelaunchDataEntry
type PluginRelaunchDataEntry,
type SceneNode
} from '@open-pencil/scene-graph'
import { readEffectiveFigmaRawField } from '../source-metadata'
export const OPEN_PENCIL_PLUGIN_ID = 'open-pencil'
export const TEXT_DIRECTION_PLUGIN_KEY = 'textDirection'
export const LAYOUT_DIRECTION_PLUGIN_KEY = 'layoutDirection'
@ -34,15 +37,13 @@ export function upsertPluginData(
node.pluginData = pluginData
}
export function applyExportSettingsPluginData(node: {
exportSettings: ExportSetting[]
pluginData: PluginDataEntry[]
source?: { fig?: { rawNodeFields?: Record<string, unknown> } }
}): void {
export function applyExportSettingsPluginData(
node: Pick<SceneNode, 'exportSettings' | 'pluginData' | 'source'>
): void {
if (node.exportSettings.length === 0) return
if (
!hasOpenPencilExportSettingsPluginData(node.pluginData) &&
Array.isArray(node.source?.fig?.rawNodeFields?.exportSettings)
Array.isArray(readEffectiveFigmaRawField(node, 'exportSettings'))
) {
return
}

View file

@ -0,0 +1,122 @@
import type { FigmaSourcePayload, SceneNode } from '@open-pencil/scene-graph'
const RAW_SIZE_KEYS = new Set(['width', 'height'])
const RAW_TRANSFORM_KEYS = new Set(['x', 'y', 'rotation', 'flipX', 'flipY'])
const TEXT_DERIVED_RAW_FIELDS = [
'textData',
'derivedTextData',
'textUserLayoutVersion',
'textExplicitLayoutVersion'
] as const
const STROKE_GEOMETRY_RAW_FIELDS = ['strokeGeometry', 'vectorData'] as const
const EDITED_RAW_FIELDS: Partial<Record<string, readonly string[]>> = {
fillStyleId: ['styleIdForFill'],
strokeStyleId: ['styleIdForStrokeFill'],
textStyleId: ['styleIdForText'],
effectStyleId: ['styleIdForEffect'],
gridStyleId: ['styleIdForGrid'],
fills: ['fillPaints', 'backgroundPaints', 'backgroundColor'],
strokes: ['strokePaints'],
effects: ['effects'],
layoutGrids: ['layoutGrids'],
exportSettings: ['exportSettings'],
cornerRadius: ['cornerRadius'],
independentCorners: ['rectangleCornerRadiiIndependent'],
topLeftRadius: ['rectangleTopLeftCornerRadius', 'rectangleCornerRadiiIndependent'],
topRightRadius: ['rectangleTopRightCornerRadius', 'rectangleCornerRadiiIndependent'],
bottomLeftRadius: ['rectangleBottomLeftCornerRadius', 'rectangleCornerRadiiIndependent'],
bottomRightRadius: ['rectangleBottomRightCornerRadius', 'rectangleCornerRadiiIndependent'],
cornerSmoothing: ['cornerSmoothing'],
borderTopWeight: ['borderTopWeight', ...STROKE_GEOMETRY_RAW_FIELDS],
borderRightWeight: ['borderRightWeight', ...STROKE_GEOMETRY_RAW_FIELDS],
borderBottomWeight: ['borderBottomWeight', ...STROKE_GEOMETRY_RAW_FIELDS],
borderLeftWeight: ['borderLeftWeight', ...STROKE_GEOMETRY_RAW_FIELDS],
independentStrokeWeights: [
'borderStrokeWeightsIndependent',
'borderTopWeight',
'borderRightWeight',
'borderBottomWeight',
'borderLeftWeight',
...STROKE_GEOMETRY_RAW_FIELDS
],
strokeWeight: ['strokeWeight', ...STROKE_GEOMETRY_RAW_FIELDS],
strokeJoin: ['strokeJoin', ...STROKE_GEOMETRY_RAW_FIELDS],
strokeMiterLimit: ['miterLimit', ...STROKE_GEOMETRY_RAW_FIELDS],
strokeCap: [...STROKE_GEOMETRY_RAW_FIELDS],
dashPattern: [...STROKE_GEOMETRY_RAW_FIELDS],
text: [...TEXT_DERIVED_RAW_FIELDS],
styleRuns: [...TEXT_DERIVED_RAW_FIELDS],
fontSize: ['fontSize', ...TEXT_DERIVED_RAW_FIELDS],
fontFamily: ['fontName', 'fontVersion', ...TEXT_DERIVED_RAW_FIELDS],
fontWeight: ['semanticWeight', ...TEXT_DERIVED_RAW_FIELDS],
italic: ['semanticItalic', ...TEXT_DERIVED_RAW_FIELDS],
lineHeight: ['lineHeight', ...TEXT_DERIVED_RAW_FIELDS],
letterSpacing: ['letterSpacing', 'textTracking', ...TEXT_DERIVED_RAW_FIELDS],
textAutoResize: ['textAutoResize', ...TEXT_DERIVED_RAW_FIELDS],
textDecorationStyle: ['textDecorationStyle', ...TEXT_DERIVED_RAW_FIELDS],
textDecorationThickness: ['textDecorationThickness', ...TEXT_DERIVED_RAW_FIELDS],
textDecorationFills: ['textDecorationFillPaints', ...TEXT_DERIVED_RAW_FIELDS],
textUnderlineOffset: ['textUnderlineOffset', ...TEXT_DERIVED_RAW_FIELDS],
leadingTrim: ['leadingTrim', ...TEXT_DERIVED_RAW_FIELDS],
maxLines: ['maxLines', ...TEXT_DERIVED_RAW_FIELDS],
fontVariations: ['fontVariations', ...TEXT_DERIVED_RAW_FIELDS],
fontFeatures: [
'fontVariantCommonLigatures',
'fontVariantContextualLigatures',
'toggledOnOTFeatures',
'toggledOffOTFeatures',
...TEXT_DERIVED_RAW_FIELDS
],
minWidth: ['minSize'],
minHeight: ['minSize'],
maxWidth: ['maxSize'],
maxHeight: ['maxSize'],
vectorNetwork: ['vectorData', 'fillGeometry', 'strokeGeometry'],
fillGeometry: ['fillGeometry', 'vectorData'],
strokeGeometry: ['strokeGeometry', 'vectorData'],
isMask: ['mask'],
maskType: ['maskType'],
maskIsOutline: ['maskIsOutline'],
componentPropertyDefinitions: ['componentPropDefs'],
componentPropertyReferences: ['componentPropRefs'],
componentPropertyAssignments: ['componentPropAssignments'],
variantPropSpecs: ['variantPropSpecs']
}
export function staleFigmaRawFields(editedFields: readonly string[]): ReadonlySet<string> {
return new Set(editedFields.flatMap((key) => EDITED_RAW_FIELDS[key] ?? []))
}
export function effectiveFigmaRawNodeFields(
node: Pick<SceneNode, 'source'>
): Record<string, unknown> {
const staleFields = staleFigmaRawFields(node.source.editedFields)
if (staleFields.size === 0) return node.source.fig.rawNodeFields
return Object.fromEntries(
Object.entries(node.source.fig.rawNodeFields).filter(([key]) => !staleFields.has(key))
)
}
export function effectiveFigmaSourcePayload(node: Pick<SceneNode, 'source'>): FigmaSourcePayload {
const sourceEditedFields = node.source.editedFields
return {
...node.source.fig,
rawNodeFields: effectiveFigmaRawNodeFields(node),
rawSize: sourceEditedFields.some((key) => RAW_SIZE_KEYS.has(key))
? null
: node.source.fig.rawSize,
rawTransform: sourceEditedFields.some((key) => RAW_TRANSFORM_KEYS.has(key))
? null
: node.source.fig.rawTransform
}
}
export function readEffectiveFigmaRawField(
node: Pick<SceneNode, 'source'>,
field: string
): unknown {
if (staleFigmaRawFields(node.source.editedFields).has(field)) return undefined
return node.source.fig.rawNodeFields[field]
}

View file

@ -0,0 +1,41 @@
import { describe, expect, test } from 'bun:test'
import { SceneGraph } from '@open-pencil/scene-graph'
import { effectiveFigmaRawNodeFields, effectiveFigmaSourcePayload } from '../src/source-metadata'
describe('@open-pencil/fig source metadata policy', () => {
test('filters only raw fields made stale by normalized edits', () => {
const graph = new SceneGraph()
const node = graph.createNode('RECTANGLE', graph.getPages()[0].id)
node.source.fig.rawNodeFields = {
fillPaints: [{ type: 'SOLID' }],
effects: [{ type: 'NOISE' }],
prototypeInteractions: [{ trigger: 'ON_CLICK' }]
}
graph.updateNode(node.id, { fills: [] })
expect(effectiveFigmaRawNodeFields(node)).toEqual({
effects: [{ type: 'NOISE' }],
prototypeInteractions: [{ trigger: 'ON_CLICK' }]
})
expect(node.source.fig.rawNodeFields.fillPaints).toBeDefined()
})
test('invalidates effective raw geometry without deleting provenance', () => {
const graph = new SceneGraph()
const node = graph.createNode('RECTANGLE', graph.getPages()[0].id)
node.source.fig.rawSize = { x: 100, y: 100 }
node.source.fig.rawTransform = { m00: 1, m01: 0, m02: 4, m10: 0, m11: 1, m12: 8 }
graph.updateNode(node.id, { width: 200, x: 20 })
expect(effectiveFigmaSourcePayload(node)).toMatchObject({
rawSize: null,
rawTransform: null
})
expect(node.source.fig.rawSize).toEqual({ x: 100, y: 100 })
expect(node.source.fig.rawTransform).toBeDefined()
})
})

View file

@ -21,7 +21,7 @@ import * as Instances from './instances'
import { CONTAINER_TYPES, createDefaultNode } from './node-defaults'
import { updateNodePreview } from './preview'
import { styleDetachmentChanges } from './shared-styles'
import { clearEditedSourceMetadata } from './source-metadata'
import { markSourceFieldsEdited } from './source-metadata'
import { TEXT_PICTURE_KEYS } from './text-picture'
import * as Variables from './variables'
import { normalizeVectorNetwork } from './vector-network'
@ -390,7 +390,7 @@ export class SceneGraph {
entries.filter(([, value]) => value !== undefined)
) as Partial<SceneNode>
if (this.sourceMetadataPreservationDepth === 0) {
clearEditedSourceMetadata(node, Object.keys(changes))
markSourceFieldsEdited(node, Object.keys(changes))
}
if (changes.vectorNetwork) {
changes = { ...changes, vectorNetwork: normalizeVectorNetwork(changes.vectorNetwork) }

View file

@ -21,6 +21,7 @@ export function createDefaultNode(
format: null,
id: null,
orderKey: null,
editedFields: [],
fig: {
rawSize: null,
rawTransform: null,

View file

@ -1,92 +1,8 @@
import { omit } from 'es-toolkit/object'
import type { SceneNode } from './types'
const RAW_SIZE_KEYS = new Set(['width', 'height'])
const RAW_TRANSFORM_KEYS = new Set(['x', 'y', 'rotation', 'flipX', 'flipY'])
const TEXT_DERIVED_RAW_FIELDS = [
'textData',
'derivedTextData',
'textUserLayoutVersion',
'textExplicitLayoutVersion'
] as const
const STROKE_GEOMETRY_RAW_FIELDS = ['strokeGeometry', 'vectorData'] as const
const EDITED_RAW_FIELDS: Partial<Record<string, readonly string[]>> = {
fillStyleId: ['styleIdForFill'],
strokeStyleId: ['styleIdForStrokeFill'],
textStyleId: ['styleIdForText'],
effectStyleId: ['styleIdForEffect'],
gridStyleId: ['styleIdForGrid'],
fills: ['fillPaints', 'backgroundPaints', 'backgroundColor'],
strokes: ['strokePaints'],
effects: ['effects'],
layoutGrids: ['layoutGrids'],
exportSettings: ['exportSettings'],
borderTopWeight: ['borderTopWeight', ...STROKE_GEOMETRY_RAW_FIELDS],
borderRightWeight: ['borderRightWeight', ...STROKE_GEOMETRY_RAW_FIELDS],
borderBottomWeight: ['borderBottomWeight', ...STROKE_GEOMETRY_RAW_FIELDS],
borderLeftWeight: ['borderLeftWeight', ...STROKE_GEOMETRY_RAW_FIELDS],
independentStrokeWeights: [
'borderStrokeWeightsIndependent',
'borderTopWeight',
'borderRightWeight',
'borderBottomWeight',
'borderLeftWeight',
...STROKE_GEOMETRY_RAW_FIELDS
],
strokeWeight: ['strokeWeight', ...STROKE_GEOMETRY_RAW_FIELDS],
strokeJoin: ['strokeJoin', ...STROKE_GEOMETRY_RAW_FIELDS],
strokeMiterLimit: ['miterLimit', ...STROKE_GEOMETRY_RAW_FIELDS],
strokeCap: [...STROKE_GEOMETRY_RAW_FIELDS],
dashPattern: [...STROKE_GEOMETRY_RAW_FIELDS],
text: [...TEXT_DERIVED_RAW_FIELDS],
styleRuns: [...TEXT_DERIVED_RAW_FIELDS],
fontSize: ['fontSize', ...TEXT_DERIVED_RAW_FIELDS],
fontFamily: ['fontName', 'fontVersion', ...TEXT_DERIVED_RAW_FIELDS],
fontWeight: ['semanticWeight', ...TEXT_DERIVED_RAW_FIELDS],
italic: ['semanticItalic', ...TEXT_DERIVED_RAW_FIELDS],
lineHeight: ['lineHeight', ...TEXT_DERIVED_RAW_FIELDS],
letterSpacing: ['letterSpacing', 'textTracking', ...TEXT_DERIVED_RAW_FIELDS],
textAutoResize: ['textAutoResize', ...TEXT_DERIVED_RAW_FIELDS],
textDecorationStyle: ['textDecorationStyle', ...TEXT_DERIVED_RAW_FIELDS],
textDecorationThickness: ['textDecorationThickness', ...TEXT_DERIVED_RAW_FIELDS],
textDecorationFills: ['textDecorationFillPaints', ...TEXT_DERIVED_RAW_FIELDS],
textUnderlineOffset: ['textUnderlineOffset', ...TEXT_DERIVED_RAW_FIELDS],
leadingTrim: ['leadingTrim', ...TEXT_DERIVED_RAW_FIELDS],
maxLines: ['maxLines', ...TEXT_DERIVED_RAW_FIELDS],
fontVariations: ['fontVariations', ...TEXT_DERIVED_RAW_FIELDS],
fontFeatures: [
'fontVariantCommonLigatures',
'fontVariantContextualLigatures',
'toggledOnOTFeatures',
'toggledOffOTFeatures',
...TEXT_DERIVED_RAW_FIELDS
],
minWidth: ['minSize'],
minHeight: ['minSize'],
maxWidth: ['maxSize'],
maxHeight: ['maxSize'],
vectorNetwork: ['vectorData', 'fillGeometry', 'strokeGeometry'],
fillGeometry: ['fillGeometry', 'vectorData'],
strokeGeometry: ['strokeGeometry', 'vectorData'],
isMask: ['mask'],
maskType: ['maskType'],
maskIsOutline: ['maskIsOutline'],
componentPropertyDefinitions: ['componentPropDefs'],
componentPropertyReferences: ['componentPropRefs'],
componentPropertyAssignments: ['componentPropAssignments'],
variantPropSpecs: ['variantPropSpecs']
}
export function clearEditedSourceMetadata(node: SceneNode, changeKeys: string[]): void {
const editedRawFields = [...new Set(changeKeys.flatMap((key) => EDITED_RAW_FIELDS[key] ?? []))]
if (editedRawFields.length > 0) {
node.source.fig.rawNodeFields = omit(node.source.fig.rawNodeFields, editedRawFields)
}
if (changeKeys.some((key) => RAW_SIZE_KEYS.has(key))) node.source.fig.rawSize = null
if (changeKeys.some((key) => RAW_TRANSFORM_KEYS.has(key))) node.source.fig.rawTransform = null
export function markSourceFieldsEdited(node: SceneNode, changeKeys: string[]): void {
if (changeKeys.length === 0) return
const editedFields = new Set(node.source.editedFields)
for (const key of changeKeys) editedFields.add(key)
node.source.editedFields = [...editedFields]
}

View file

@ -36,6 +36,7 @@ export interface SourceMetadata {
format: 'fig' | null
id: string | null
orderKey: string | null
editedFields: string[]
fig: FigmaSourcePayload
}

View file

@ -8,7 +8,7 @@ import {
SceneGraph,
type NodeChange
} from '@open-pencil/core'
import { parseFigBuffer } from '@open-pencil/fig'
import { effectiveFigmaRawNodeFields, parseFigBuffer } from '@open-pencil/fig'
import { MAX_EXPORT_SCALE } from '@open-pencil/scene-graph'
function decodeExport(bytes: Uint8Array) {
@ -129,10 +129,11 @@ describe('fig roundtrip export settings', () => {
if (!node) throw new Error('imported frame not found')
expect(node.exportSettings).toEqual([{ scale: 2, format: 'png' }])
// User removes every export row; the raw native settings must be dropped so they
// don't come back via the import fallback on reopen.
// User removes every export row; the raw native settings become stale so they
// cannot come back through the import fallback on reopen.
graph.updateNode(node.id, { exportSettings: [] })
expect(node.source.fig.rawNodeFields?.exportSettings).toBeUndefined()
expect(effectiveFigmaRawNodeFields(node).exportSettings).toBeUndefined()
expect(node.source.fig.rawNodeFields.exportSettings).toBeDefined()
const reimported = await parseFigFile((await exportFigFile(graph)).buffer as ArrayBuffer)
const reNode = [...reimported.getAllNodes()].find((n) => n.name === 'Export settings frame')

View file

@ -1,7 +1,7 @@
import { beforeAll, describe, expect, test } from 'bun:test'
import { exportFigFile, initCodec, parseFigFile, SceneGraph } from '@open-pencil/core'
import { parseFigBuffer } from '@open-pencil/fig'
import { effectiveFigmaRawNodeFields, parseFigBuffer } from '@open-pencil/fig'
import { guidToString } from '@open-pencil/fig/node-change'
function decodeExport(bytes: Uint8Array) {
@ -256,11 +256,12 @@ describe('fig roundtrip source metadata', () => {
]
})
expect(frame.source.fig.rawNodeFields).toEqual({
expect(effectiveFigmaRawNodeFields(frame)).toEqual({
layoutGrids: [{ type: 'MIN', axis: 'X', visible: true }],
exportSettings: [{ suffix: '@2x' }],
prototypeInteractions: [{ trigger: 'ON_CLICK' }]
})
expect(frame.source.fig.rawNodeFields.fillPaints).toEqual([{ type: 'SOLID' }])
})
test('preserves imported unsupported effect payloads for round-trip', async () => {
@ -306,7 +307,7 @@ describe('fig roundtrip source metadata', () => {
expect(exported?.effects?.[0]?.density).toBeCloseTo(0.4)
})
test('clears raw unsupported effects when normalized effects are edited', () => {
test('ignores raw unsupported effects when normalized effects are edited', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const rect = graph.createNode('RECTANGLE', page.id, { name: 'Edited effect metadata' })
@ -339,10 +340,11 @@ describe('fig roundtrip source metadata', () => {
]
})
expect(rect.source.fig.rawNodeFields.effects).toBeUndefined()
expect(effectiveFigmaRawNodeFields(rect).effects).toBeUndefined()
expect(rect.source.fig.rawNodeFields.effects).toBeDefined()
})
test('clears raw geometry payloads when independent stroke weights are edited', () => {
test('ignores raw geometry payloads when independent stroke weights are edited', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const rect = graph.createNode('RECTANGLE', page.id, { name: 'Independent stroke metadata' })
@ -359,12 +361,13 @@ describe('fig roundtrip source metadata', () => {
borderLeftWeight: 8
})
expect(rect.source.fig.rawNodeFields).toEqual({
expect(effectiveFigmaRawNodeFields(rect)).toEqual({
fillGeometry: [{ windingRule: 'NONZERO', commands: [] }]
})
expect(rect.source.fig.rawNodeFields.strokeGeometry).toBeDefined()
})
test('clears raw font variation payloads when normalized axes are edited', async () => {
test('ignores raw font variation payloads when normalized axes are edited', async () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const text = graph.createNode('TEXT', page.id, {
@ -383,7 +386,8 @@ describe('fig roundtrip source metadata', () => {
(nodeChange) => nodeChange.guid && guidToString(nodeChange.guid) === '4:501'
)
expect(text.source.fig.rawNodeFields.fontVariations).toBeUndefined()
expect(effectiveFigmaRawNodeFields(text).fontVariations).toBeUndefined()
expect(text.source.fig.rawNodeFields.fontVariations).toBeDefined()
expect(exported?.fontVariations).toEqual([
{ axisTag: 0x77676874, axisName: 'wght', value: 650 }
])

View file

@ -0,0 +1,42 @@
import { describe, expect, test } from 'bun:test'
import { SceneGraph } from '@open-pencil/scene-graph'
function firstPageId(graph: SceneGraph): string {
return graph.getPages()[0].id
}
describe('SceneGraph source dirty tracking', () => {
test('records normalized edited fields once in update order', () => {
const graph = new SceneGraph()
const node = graph.createNode('RECTANGLE', firstPageId(graph))
graph.updateNode(node.id, { width: 200, fills: [] })
graph.updateNode(node.id, { width: 240, opacity: 0.5 })
expect(node.source.editedFields).toEqual(['width', 'fills', 'opacity'])
})
test('preservation scope suppresses import-time dirty tracking', () => {
const graph = new SceneGraph()
const node = graph.createNode('RECTANGLE', firstPageId(graph))
graph.preserveSourceMetadataDuring(() => graph.updateNode(node.id, { width: 200 }))
expect(node.source.editedFields).toEqual([])
})
test('cloned source dirty fields are independent', () => {
const graph = new SceneGraph()
const node = graph.createNode('RECTANGLE', firstPageId(graph))
graph.updateNode(node.id, { width: 200 })
const clone = graph.cloneTree(node.id, firstPageId(graph))
expect(clone).not.toBeNull()
if (!clone) return
graph.updateNode(clone.id, { height: 300 })
expect(node.source.editedFields).toEqual(['width'])
expect(clone.source.editedFields).toEqual(['width', 'height'])
})
})

View file

@ -90,7 +90,7 @@ try {
tempDir
)
nodeEval(
"const { FIG_PACKAGE_STATUS, parseFigBuffer, writeFigArchive, readFigContainer, writeFigContainer } = await import('@open-pencil/fig'); if (FIG_PACKAGE_STATUS !== 'archive-api' || typeof parseFigBuffer !== 'function' || typeof writeFigArchive !== 'function') throw new Error('Fig package status smoke failed'); const document = readFigContainer(writeFigContainer({ schemaDeflated: new Uint8Array([1]), dataRaw: new Uint8Array([2]) })); if (document.dataRaw[0] !== 2) throw new Error('Fig container smoke failed')",
"const { FIG_PACKAGE_STATUS, effectiveFigmaRawNodeFields, parseFigBuffer, writeFigArchive, readFigContainer, writeFigContainer } = await import('@open-pencil/fig'); if (FIG_PACKAGE_STATUS !== 'archive-api' || typeof effectiveFigmaRawNodeFields !== 'function' || typeof parseFigBuffer !== 'function' || typeof writeFigArchive !== 'function') throw new Error('Fig package status smoke failed'); const document = readFigContainer(writeFigContainer({ schemaDeflated: new Uint8Array([1]), dataRaw: new Uint8Array([2]) })); if (document.dataRaw[0] !== 2) throw new Error('Fig container smoke failed')",
tempDir
)
nodeEval(