refactor(fig): own NodeChange export policy

- Move SceneGraph-to-NodeChange conversion, text payload assembly, and blob encoding into @open-pencil/fig\n- Inject core glyph outline lookup through an explicit runtime service\n- Centralize neutral font naming in scene-graph and retain core IO/runtime orchestration
This commit is contained in:
Danila Poyarkov 2026-07-18 02:41:35 +03:00
parent 02b7261c9e
commit 85c3c8fad1
23 changed files with 794 additions and 640 deletions

View file

@ -11,7 +11,7 @@ Bun workspace packages:
- `packages/scene-graph``@open-pencil/scene-graph`: SceneGraph, node types, copy/snap/undo helpers, variables, instances, hit testing. Framework-agnostic.
- `packages/pen``@open-pencil/pen`: Pencil.dev `.pen` document model, parser, and SceneGraph import adapter.
- `packages/kiwi``@open-pencil/kiwi`: pure Kiwi schema/runtime/protocol package. Owns low-level Figma Kiwi codec/container/parse helpers and stays SceneGraph-agnostic.
- `packages/fig``@open-pencil/fig`: `.fig` archive/parser package and staged home for Figma-specific SceneGraph conversion policy. Core keeps format-neutral IO registration and runtime rendering integration.
- `packages/fig``@open-pencil/fig`: `.fig` archive/parser package owning Figma-specific SceneGraph conversion, raw metadata policy, and component/instance interpretation. Core keeps format-neutral IO registration and runtime rendering/font integration.
- `packages/core``@open-pencil/core`: renderer, layout, editor core, Figma API, tools, clipboard, vector conversion, and app/CLI-facing document I/O. Depends on scene-graph/pen/kiwi but keeps browser DOM out of core.
- `packages/dom-css``@open-pencil/dom-css`: DOM/CSS projection layer for HTML/CSS/JSX/Tailwind compatibility. Owns DesignDOM types and browser/headless CSS runtime adapters; keeps DOM/CSS parser dependencies out of core.
- `packages/vue``@open-pencil/vue`: headless Vue 3 SDK (Reka UI-style) for building custom OpenPencil-powered editor shells and embedded editing surfaces. Renderless components and composables. The app is one consumer of the SDK.
@ -296,8 +296,8 @@ Self-review checklist:
## File format
- `.fig` files use Figma's Kiwi schema and `NodeChange[]` records. Low-level schema/runtime/codec/container helpers live in `packages/kiwi/src/fig/**` and `packages/kiwi/src/schema-runtime/**`; complete `.fig` archive parsing lives in `packages/fig`.
- Core still owns SceneGraph `.fig` policy: import/export orchestration in `packages/core/src/io/formats/fig/**`, SceneGraph ⇄ NodeChange conversion in `packages/core/src/kiwi/fig/node-change/**`, and component/instance override interpretation in `packages/core/src/kiwi/fig/instance-overrides/**`.
- `packages/fig` is the publishable boundary for future `.fig` policy extraction; do not move behavior there without package-local tests and dist smoke.
- `@open-pencil/fig` owns SceneGraph ⇄ NodeChange conversion in `packages/fig/src/node-change/**`, component/instance interpretation in `packages/fig/src/instance-overrides/**`, and effective raw metadata policy in `packages/fig/src/source-metadata.ts`.
- Core owns `.fig` IO orchestration in `packages/core/src/io/formats/fig/**`, runtime font/glyph integration, workers, and CanvasKit thumbnails. Keep Fig behavior covered by package-local tests and dist smoke.
- Vector data uses reverse-engineered `vectorNetworkBlob` binary format — encoder/decoder in `packages/core/src/vector/` and scene-graph vector-network types in `@open-pencil/scene-graph`.
- `showOpenFilePicker` / `showSaveFilePicker` are File System Access API (Chrome/Edge), not Tauri-only; code must keep browser fallbacks.
- Safari save: no File System Access API → use an `<a>` download fallback with deferred `revokeObjectURL`. SafariBanner warns users about limitations.

View file

@ -4,7 +4,7 @@
### Changed
- 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.
- Move complete `.fig` archive parsing, bidirectional SceneGraph/NodeChange conversion, and component/instance interpretation into `@open-pencil/fig`, keeping runtime font access and format-neutral IO orchestration in core and Kiwi schema/container mechanics in `@open-pencil/kiwi`.
- 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.

View file

@ -1,8 +0,0 @@
export {
applyFontFeaturesToKiwi,
convertFontFeatures,
convertFontVariations,
figmaAxisTagToString,
stringToFigmaAxisTag
} from '@open-pencil/fig/node-change'
export { buildFontDigestMap } from './digests'

View file

@ -1,513 +1,27 @@
import { effectiveFigmaRawNodeFields } from '@open-pencil/fig'
import {
sceneNodeToKiwi as sceneNodeToKiwiWithRuntime,
type KiwiNodeChange
} from '@open-pencil/fig/node-change'
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import type { GUID } from '@open-pencil/scene-graph/primitives'
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'
import { normalizeFontFamily, weightToFigmaStyle, weightToStyle } from '#core/text/fonts'
import { getGlyphOutlineMetricsSync } from '#core/text/opentype'
import { encodeVectorNetworkBlob, buildStyleOverrideTable } from '#core/vector'
export {
buildFigKiwi,
decompressFigKiwiDataAsync,
FIG_KIWI_DEFAULT_VERSION,
parseFigKiwiChunks
} from '@open-pencil/kiwi/fig/container'
fractionalPosition,
makeCanvasNodeChange,
makeDocumentNodeChange,
mapToFigmaType,
parseFigKiwiChunks,
safeColor
} from '@open-pencil/fig/node-change'
export { buildFontDigestMap } from './font/digests'
import {
applyFontFeaturesToKiwi,
BOUND_VARIABLES_PLUGIN_KEY,
guidToString,
LAYOUT_DIRECTION_PLUGIN_KEY,
stringToGuid,
TEXT_DIRECTION_PLUGIN_KEY,
upsertPluginData,
VARIABLE_BINDING_FIELDS
} from '@open-pencil/fig/node-change'
import type { NodeChange, Paint, VariableConsumptionEntry } from '@open-pencil/kiwi/fig/codec'
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import type { Color, GUID, JsonObject, Matrix } from '@open-pencil/scene-graph/primitives'
import {
buildAssetRefToVarGuidMap,
sceneNodeToKiwiWithContext,
type KiwiNodeChange
} from './export-node'
import { exportTextData, fontVariationToKiwi } from './text-data-export'
export function mapToFigmaType(type: SceneNode['type']): string {
switch (type) {
case 'FRAME':
return 'FRAME'
case 'RECTANGLE':
return 'RECTANGLE'
case 'ROUNDED_RECTANGLE':
return 'ROUNDED_RECTANGLE'
case 'ELLIPSE':
return 'ELLIPSE'
case 'TEXT':
return 'TEXT'
case 'LINE':
return 'LINE'
case 'STAR':
return 'STAR'
case 'POLYGON':
return 'REGULAR_POLYGON'
case 'VECTOR':
return 'VECTOR'
case 'BOOLEAN_OPERATION':
return 'BOOLEAN_OPERATION'
case 'GROUP':
return 'FRAME'
case 'SECTION':
return 'SECTION'
case 'COMPONENT':
return 'SYMBOL'
case 'COMPONENT_SET':
return 'FRAME'
case 'INSTANCE':
return 'INSTANCE'
case 'CONNECTOR':
return 'CONNECTOR'
case 'SHAPE_WITH_TEXT':
return 'SHAPE_WITH_TEXT'
default:
return 'RECTANGLE'
}
}
/**
* Generate a position string for parentIndex.position.
*
* Positions must be printable ASCII characters (space through tilde),
* with the last character at least '!' (code 33), and must sort
* lexicographically so sibling nodes order correctly.
* The encoding uses a telescoping scheme where each `~` prefix
* adds 94 more positions, like:
* 0:"!",1:"\"", ... 93:"~",94:"~!", ... 187:"~~",188:"~~!", ...
*/
export function fractionalPosition(index: number): string {
const BASE = 94
const FIRST = 33 // '!'.charCodeAt(0)
const TILDE = 126 // '~'.charCodeAt(0)
const numTildes = Math.floor(index / BASE)
const lastChar = String.fromCharCode(FIRST + (index % BASE))
return String.fromCharCode(TILDE).repeat(numTildes) + lastChar
}
function textLines(text: string): NonNullable<NodeChange['textData']>['lines'] {
const lineCount = Math.max(1, text.split('\n').length)
return Array.from({ length: lineCount }, () => ({ lineType: 'PLAIN' }))
}
function appendGlyphBlob(
blobs: Uint8Array[],
glyphBlobMap: Map<string, number>,
blob: Uint8Array
): number {
const key = bytesToHex(blob)
const existing = glyphBlobMap.get(key)
if (existing !== undefined) return existing
const index = blobs.push(blob) - 1
glyphBlobMap.set(key, index)
return index
}
function buildDerivedTextData(
node: SceneNode,
digestMap: Map<string, Uint8Array>,
blobs: Uint8Array[],
glyphBlobMap: Map<string, number>
): NodeChange['derivedTextData'] {
const fontMeta: NonNullable<NodeChange['derivedTextData']>['fontMetaData'] = []
const seen = new Set<string>()
const addFont = (family: string, weight: number, italic: boolean) => {
const style = weightToStyle(weight, italic)
const normalized = normalizeFontFamily(family)
const key = `${normalized}|${style}`
if (seen.has(key)) return
seen.add(key)
fontMeta.push({
key: { family: normalized, style: weightToFigmaStyle(weight, italic), postscript: '' },
fontLineHeight: 1.2,
fontDigest: digestMap.get(key),
fontStyle: italic ? 'ITALIC' : 'NORMAL',
fontWeight: weight
})
}
addFont(node.fontFamily, node.fontWeight, node.italic)
for (const run of node.styleRuns) {
addFont(
run.style.fontFamily ?? node.fontFamily,
run.style.fontWeight ?? node.fontWeight,
run.style.italic ?? node.italic
)
}
const lineHeight = node.lineHeight ?? Math.ceil(node.fontSize * 1.2)
const glyphAdvance = node.text.length > 0 ? node.width / Math.max(node.text.length, 1) : 0
const derivedGlyphs = node.figmaDerivedTextGlyphs ?? []
const glyphs =
derivedGlyphs.length > 0
? derivedGlyphs.map((glyph, index) => ({
commandsBlob: appendGlyphBlob(blobs, glyphBlobMap, glyph.commandsBlob),
position: { x: glyph.x, y: glyph.y },
fontSize: glyph.fontSize,
firstCharacter: index,
advance:
index + 1 < derivedGlyphs.length
? Math.max(derivedGlyphs[index + 1].x - glyph.x, 0)
: glyphAdvance,
rotation: 0
}))
: (
getGlyphOutlineMetricsSync(
node.fontFamily,
weightToStyle(node.fontWeight, node.italic),
node.text,
node.fontSize
) ?? []
).map((glyph, index) => ({
commandsBlob: appendGlyphBlob(
blobs,
glyphBlobMap,
encodePathCommandsBlob(glyph.commands, node.fontSize)
),
position: { x: glyph.x || index * glyphAdvance, y: lineHeight },
fontSize: node.fontSize,
firstCharacter: index,
advance: glyph.advance || glyphAdvance,
rotation: 0
}))
const logicalIndexToCharacterOffsetMap = Array.from(
{ length: node.text.length + 1 },
(_, index) => index * glyphAdvance
)
return buildSharedDerivedTextData({
node,
glyphs,
fontMetaData: fontMeta,
baseline: lineHeight,
width: node.width,
lineHeight,
lineAscent: Math.max(lineHeight - node.fontSize * 0.2, 0),
logicalIndexToCharacterOffsetMap
})
}
export function safeColor(c: Color | Omit<Color, 'a'>): Color {
return { r: c.r, g: c.g, b: c.b, a: 'a' in c ? c.a : 1 }
}
function fillToKiwiPaint(f: SceneNode['fills'][number]): Paint {
const paint: Paint = {
type: f.type,
color: safeColor(f.color),
opacity: f.opacity,
visible: f.visible,
blendMode: f.blendMode ?? 'NORMAL'
}
if (f.gradientStops) {
paint.stops = f.gradientStops.map((s) => ({ color: safeColor(s.color), position: s.position }))
}
if (f.gradientTransform) paint.transform = f.gradientTransform
if (f.imageHash) paint.image = { hash: hexToBytes(f.imageHash) }
if (f.imageScaleMode) paint.imageScaleMode = f.imageScaleMode
if (f.imageTransform) paint.transform = f.imageTransform
if (f.sourceNodeId) paint.sourceNodeId = stringToGuid(f.sourceNodeId)
if (f.scale) paint.scale = f.scale
if (f.spacing) paint.spacing = f.spacing
if (f.patternSpacing) paint.patternSpacing = f.patternSpacing
if (f.patternTileType) paint.patternTileType = f.patternTileType
if (f.verticalAlignment) paint.verticalAlignment = f.verticalAlignment
if (f.horizontalAlignment) paint.horizontalAlignment = f.horizontalAlignment
if (f.noiseType) paint.noiseType = f.noiseType
if (f.density !== undefined) paint.density = f.density
if (f.noiseSize) paint.noiseSize = f.noiseSize
if (f.customEffectId) paint.customEffectId = { guid: stringToGuid(f.customEffectId) }
return paint
}
function serializeCornerRadii(node: SceneNode, nc: KiwiNodeChange): void {
const anyIndividual =
node.topLeftRadius > 0 ||
node.topRightRadius > 0 ||
node.bottomLeftRadius > 0 ||
node.bottomRightRadius > 0
if (node.cornerRadius > 0) nc.cornerRadius = node.cornerRadius
// Always emit individual radii when present. A node may have
// independentCorners=false with non-zero individual values (e.g. imported
// from Figma where the flag wasn't set but per-corner values exist).
if (anyIndividual || node.independentCorners) {
// For imported nodes, preserve the original independentCorners flag from
// the raw Figma data. Figma may emit per-corner radii without setting the
// independent flag (preserve rectangleCornerRadiiIndependent).
const rawIndependent = node.source.id
? (effectiveFigmaRawNodeFields(node) as JsonObject | undefined)
?.rectangleCornerRadiiIndependent
: undefined
nc.rectangleCornerRadiiIndependent =
typeof rawIndependent === 'boolean' ? rawIndependent : node.independentCorners
nc.rectangleTopLeftCornerRadius = node.topLeftRadius
nc.rectangleTopRightCornerRadius = node.topRightRadius
nc.rectangleBottomLeftCornerRadius = node.bottomLeftRadius
nc.rectangleBottomRightCornerRadius = node.bottomRightRadius
}
if (node.cornerSmoothing > 0 || 'cornerSmoothing' in effectiveFigmaRawNodeFields(node)) {
nc.cornerSmoothing = node.cornerSmoothing
}
}
function resolveTextAutoResize(node: SceneNode, graph: SceneGraph): SceneNode['textAutoResize'] {
// For nodes imported from .fig files, preserve the original textAutoResize
// value. Forcing 'HEIGHT' for fixed-height text inside auto-layout causes
// layout drift on roundtrip.
if (node.source.id) return node.textAutoResize
const parent = node.parentId ? graph.getNode(node.parentId) : undefined
if (
parent &&
parent.layoutMode !== 'NONE' &&
parent.layoutMode !== 'GRID' &&
node.layoutPositioning !== 'ABSOLUTE'
) {
return 'HEIGHT'
}
return node.textAutoResize
}
function serializeTextProps(
node: SceneNode,
nc: KiwiNodeChange,
graph: SceneGraph,
fontDigestMap: Map<string, Uint8Array> | undefined,
blobs: Uint8Array[],
glyphBlobMap: Map<string, number> | undefined
): void {
upsertPluginData(node, TEXT_DIRECTION_PLUGIN_KEY, node.textDirection)
nc.fontSize = node.fontSize
nc.fontName = {
family: normalizeFontFamily(node.fontFamily),
style: weightToFigmaStyle(node.fontWeight, node.italic),
postscript: ''
}
nc.textData = exportTextData(node, textLines, fillToKiwiPaint)
if (node.fontVariations.length > 0) {
nc.fontVariations = node.fontVariations.map(fontVariationToKiwi)
}
const autoResize = resolveTextAutoResize(node, graph)
nc.textAutoResize = autoResize
nc.textAlignHorizontal = node.textAlignHorizontal
nc.textAlignVertical = node.textAlignVertical
nc.textUserLayoutVersion = 4
nc.textExplicitLayoutVersion = 1
nc.textBidiVersion = 1
nc.textDecorationSkipInk = node.textDecorationSkipInk
nc.fontVariantCommonLigatures = true
nc.fontVariantContextualLigatures = true
applyFontFeaturesToKiwi(nc, node.fontFeatures)
nc.fontVersion = ''
nc.emojiImageSet = 'APPLE'
if (node.textCase !== 'ORIGINAL') nc.textCase = node.textCase
if (node.textTruncation === 'ENDING') nc.textTruncation = 'ENDING'
if (node.maxLines != null) nc.maxLines = node.maxLines
if (fontDigestMap) {
nc.derivedTextData = buildDerivedTextData(node, fontDigestMap, blobs, glyphBlobMap ?? new Map())
}
if (node.leadingTrim !== 'NONE') nc.leadingTrim = node.leadingTrim
if (node.lineHeight != null) nc.lineHeight = { value: node.lineHeight, units: 'PIXELS' }
nc.letterSpacing = { value: node.letterSpacing, units: 'PIXELS' }
if (node.textDecoration !== 'NONE') {
nc.textDecoration = node.textDecoration === 'UNDERLINE' ? 'UNDERLINE' : 'STRIKETHROUGH'
}
if (node.textDecorationStyle !== 'SOLID') nc.textDecorationStyle = node.textDecorationStyle
if (node.textDecorationThickness != null) {
nc.textDecorationThickness = { value: node.textDecorationThickness, units: 'PIXELS' }
}
if (node.textUnderlineOffset != null) {
nc.textUnderlineOffset = { value: node.textUnderlineOffset, units: 'PIXELS' }
}
if (node.textDecorationFills.length > 0) {
nc.textDecorationFillPaints = node.textDecorationFills.map(fillToKiwiPaint)
}
}
function normalizeStackMode(value: string | undefined): KiwiNodeChange['stackMode'] {
return value === 'HORIZONTAL' || value === 'VERTICAL' || value === 'NONE' ? value : undefined
}
function normalizeStackSizing(value: string | undefined): KiwiNodeChange['stackPrimarySizing'] {
return value === 'FIXED' ||
value === 'RESIZE_TO_FIT' ||
value === 'RESIZE_TO_FIT_WITH_IMPLICIT_SIZE'
? value
: undefined
}
function normalizeStackJustify(value: string | undefined): string | undefined {
return value === 'SPACE_EVENLY' ? 'SPACE_BETWEEN' : value
}
function normalizeStackCounterAlign(value: string | undefined): string | undefined {
return value === 'SPACE_EVENLY' ? 'SPACE_BETWEEN' : value
}
function serializeLayoutProps(node: SceneNode, nc: KiwiNodeChange): void {
if (!node.source.id) upsertPluginData(node, LAYOUT_DIRECTION_PLUGIN_KEY, node.layoutDirection)
const figLayout = node.source.fig.layout
if (figLayout) {
nc.stackMode = normalizeStackMode(figLayout.stackMode)
nc.stackSpacing = figLayout.stackSpacing
nc.stackPadding = figLayout.stackPadding
nc.stackPaddingRight = figLayout.stackPaddingRight
nc.stackPaddingBottom = figLayout.stackPaddingBottom
nc.stackCounterAlign = normalizeStackCounterAlign(figLayout.stackCounterAlign)
nc.stackJustify = normalizeStackJustify(figLayout.stackJustify)
nc.stackCounterAlignItems = normalizeStackCounterAlign(figLayout.stackCounterAlignItems)
nc.stackPrimaryAlignItems = normalizeStackJustify(figLayout.stackPrimaryAlignItems)
// For imported nodes, figLayout captures the original kiwi NC values.
// Preserve omitted sizing fields instead of materializing schema defaults.
const stackPrimarySizing = normalizeStackSizing(figLayout.stackPrimarySizing)
if (stackPrimarySizing) nc.stackPrimarySizing = stackPrimarySizing
const stackCounterSizing = normalizeStackSizing(figLayout.stackCounterSizing)
if (stackCounterSizing) nc.stackCounterSizing = stackCounterSizing
nc.stackVerticalPadding = figLayout.stackVerticalPadding
nc.stackHorizontalPadding = figLayout.stackHorizontalPadding
nc.stackWrap = figLayout.stackWrap
nc.stackPositioning = figLayout.stackPositioning
nc.stackChildPrimaryGrow = figLayout.stackChildPrimaryGrow
nc.stackChildAlignSelf = figLayout.stackChildAlignSelf
nc.stackCounterSpacing = figLayout.stackCounterSpacing
nc.bordersTakeSpace = figLayout.bordersTakeSpace
if (figLayout.stackReverseZIndex) nc.stackReverseZIndex = true
return
}
if (node.layoutMode !== 'NONE' && node.layoutMode !== 'GRID') {
nc.stackMode = node.layoutMode
nc.stackSpacing = node.itemSpacing
nc.stackVerticalPadding = node.paddingTop
nc.stackHorizontalPadding = node.paddingLeft
nc.stackPaddingBottom = node.paddingBottom
nc.stackPaddingRight = node.paddingRight
nc.stackPrimarySizing = node.primaryAxisSizing === 'HUG' ? 'RESIZE_TO_FIT' : 'FIXED'
nc.stackCounterSizing = node.counterAxisSizing === 'HUG' ? 'RESIZE_TO_FIT' : 'FIXED'
nc.stackPrimaryAlignItems = normalizeStackJustify(node.primaryAxisAlign)
nc.stackCounterAlignItems = normalizeStackCounterAlign(node.counterAxisAlign)
if (node.layoutWrap === 'WRAP') nc.stackWrap = 'WRAP'
if (node.counterAxisSpacing > 0) nc.stackCounterSpacing = node.counterAxisSpacing
nc.bordersTakeSpace = node.strokesIncludedInLayout
}
if (node.itemReverseZIndex) nc.stackReverseZIndex = true
if (node.layoutPositioning === 'ABSOLUTE') nc.stackPositioning = 'ABSOLUTE'
if (node.layoutGrow > 0) nc.stackChildPrimaryGrow = node.layoutGrow
if (node.layoutAlignSelf !== 'AUTO') {
nc.stackChildAlignSelf = node.layoutAlignSelf
}
}
function serializeGeometry(node: SceneNode, nc: KiwiNodeChange, blobs: Uint8Array[]): void {
if (node.isMask) {
nc.mask = true
nc.maskType = node.maskType
if (node.maskIsOutline) nc.maskIsOutline = true
}
if (node.vectorNetwork && node.type === 'VECTOR') {
const { table, mirroringToId } = buildStyleOverrideTable(node.vectorNetwork)
const blobIdx = blobs.length
blobs.push(encodeVectorNetworkBlob(node.vectorNetwork, mirroringToId))
const vectorData: Record<string, unknown> = {
vectorNetworkBlob: blobIdx,
normalizedSize: { x: node.width, y: node.height }
}
if (table.length > 0) {
vectorData.styleOverrideTable = table
}
nc.vectorData = vectorData
}
if (node.fillGeometry.length > 0) {
nc.fillGeometry = node.fillGeometry.map((g) => {
const blobIdx = blobs.length
blobs.push(g.commandsBlob)
return { windingRule: g.windingRule, commandsBlob: blobIdx }
})
}
if (node.strokeGeometry.length > 0) {
nc.strokeGeometry = node.strokeGeometry.map((g) => {
const blobIdx = blobs.length
blobs.push(g.commandsBlob)
return { windingRule: g.windingRule, commandsBlob: blobIdx }
})
}
}
function serializeVariableBindings(
node: SceneNode,
nc: KiwiNodeChange,
graph: SceneGraph,
varIdToGuid?: Map<string, GUID>
): void {
if (Object.keys(node.boundVariables).length === 0) return
const entries: VariableConsumptionEntry[] = []
const roundtripBindings: Record<string, string> = {}
const typeMap: Record<string, string> = { COLOR: 'COLOR', BOOLEAN: 'BOOLEAN', STRING: 'STRING' }
for (const [field, varId] of Object.entries(node.boundVariables)) {
const variable = graph.variables.get(varId)
if (!variable) continue
const varGuid = varIdToGuid?.get(varId) ?? stringToGuid(varId)
roundtripBindings[field] = guidToString(varGuid)
const kiwiField = VARIABLE_BINDING_FIELDS[field]
if (!kiwiField) continue
const resolvedType = typeMap[variable.type] ?? 'FLOAT'
entries.push({
variableData: {
value: { alias: { guid: varGuid } },
dataType: 'ALIAS',
resolvedDataType: resolvedType
},
variableField: kiwiField
})
}
if (Object.keys(roundtripBindings).length > 0) {
upsertPluginData(node, BOUND_VARIABLES_PLUGIN_KEY, JSON.stringify(roundtripBindings))
}
if (entries.length > 0) nc.variableConsumptionMap = { entries }
}
function computeExportTransform(node: SceneNode): Matrix {
const sx = node.flipX ? -1 : 1
const cos = Math.cos((node.rotation * Math.PI) / 180)
const sin = Math.sin((node.rotation * Math.PI) / 180)
const m00 = cos * sx
const m01 = -sin
const m10 = sin * sx
const m11 = cos
const corners = [
{ x: 0, y: 0 },
{ x: node.width, y: 0 },
{ x: 0, y: node.height },
{ x: node.width, y: node.height }
].map((point) => ({
x: m00 * point.x + m01 * point.y,
y: m10 * point.x + m11 * point.y
}))
const offsetX = Math.min(...corners.map((point) => point.x))
const offsetY = Math.min(...corners.map((point) => point.y))
return {
m00,
m01,
m02: node.x - offsetX,
m10,
m11,
m12: node.y - offsetY
}
const coreFigExportRuntime = {
getGlyphOutlineMetrics: getGlyphOutlineMetricsSync
}
export function sceneNodeToKiwi(
@ -524,74 +38,19 @@ export function sceneNodeToKiwi(
blobIndexByHex?: Map<string, number>,
assignedGuidValues?: Set<string>
): KiwiNodeChange[] {
// Build assetRef to guid mapping for converting colorVar references in raw paints
const assetRefToVarGuid = varIdToGuid ? buildAssetRefToVarGuidMap(graph, varIdToGuid) : undefined
return sceneNodeToKiwiWithContext(node, parentGuid, childIndex, localIdCounter, {
return sceneNodeToKiwiWithRuntime(
node,
parentGuid,
childIndex,
localIdCounter,
graph,
blobs,
blobIndexByHex,
nodeIdToGuid,
assignedGuidValues,
fontDigestMap,
glyphBlobMap,
varIdToGuid,
assetRefToVarGuid,
fractionalPosition,
mapToFigmaType,
fillToKiwiPaint,
safeColor,
computeExportTransform,
serializeCornerRadii,
serializeTextProps,
serializeLayoutProps,
serializeGeometry,
serializeVariableBindings,
sceneNodeToKiwi: sceneNodeToKiwiWithContext
})
}
const IDENTITY_TRANSFORM = { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
const DEFAULT_STROKE_WEIGHT = 1
export function makeDocumentNodeChange(
guid: GUID,
documentColorSpace: 'srgb' | 'display-p3' = 'display-p3'
): NodeChange & Record<string, unknown> {
return {
guid,
type: 'DOCUMENT',
name: 'Document',
visible: true,
opacity: 1,
phase: 'CREATED',
transform: { ...IDENTITY_TRANSFORM },
strokeWeight: DEFAULT_STROKE_WEIGHT,
strokeAlign: 'CENTER',
strokeJoin: 'MITER',
documentColorProfile: documentColorSpace === 'display-p3' ? 'DISPLAY_P3' : 'SRGB'
}
}
export function makeCanvasNodeChange(
guid: GUID,
parentGuid: GUID,
position: string,
name: string,
extra?: Record<string, unknown>
): NodeChange & Record<string, unknown> {
return {
guid,
parentIndex: { guid: parentGuid, position },
type: 'CANVAS',
name,
visible: true,
opacity: 1,
phase: 'CREATED',
transform: { ...IDENTITY_TRANSFORM },
strokeWeight: DEFAULT_STROKE_WEIGHT,
strokeAlign: 'CENTER',
strokeJoin: 'MITER',
pageType: 'DESIGN',
...extra
}
glyphBlobMap,
blobIndexByHex,
assignedGuidValues,
coreFigExportRuntime
)
}

View file

@ -1,14 +1,16 @@
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext'
import {
buildDerivedTextData,
encodePathCommandsBlob,
weightToFigmaStyle
} from '@open-pencil/fig/node-change'
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
import { normalizeFontFamily, weightToStyle } from '@open-pencil/scene-graph'
import type { SceneNode } from '@open-pencil/scene-graph'
import { encodePathCommandsBlob } from '#core/kiwi/fig/node-change/path-commands'
import { normalizeFontFamily, weightToFigmaStyle, weightToStyle } from '#core/text/fonts'
import { type GlyphOutlineMetrics, getGlyphOutlineMetricsSync } from '#core/text/opentype'
import { buildDerivedTextData } from './data'
function computeWordWrapBreaks(
text: string,
glyphMetrics: Array<Pick<GlyphOutlineMetrics, 'advance'>>,

View file

@ -1,9 +1,15 @@
import { styleToWeight } from '@open-pencil/scene-graph'
import { normalizeFontFamily } from '@open-pencil/scene-graph'
export {
FONT_WEIGHT_NAMES,
normalizeFontFamily,
styleToVariant,
styleToWeight,
weightToStyle
} from '@open-pencil/scene-graph'
import { parseFontStyle } from '#core/text/face'
export { styleToWeight }
interface LocalFontMatch {
family: string
style: string
@ -43,30 +49,6 @@ export function chooseLocalFontMatch<T extends LocalFontMatch>(
return undefined
}
export const FONT_WEIGHT_NAMES: Record<number, string> = {
100: 'Thin',
200: 'Extra Light',
300: 'Light',
400: 'Regular',
500: 'Medium',
600: 'Semi Bold',
700: 'Bold',
800: 'Extra Bold',
900: 'Black'
}
export function normalizeFontFamily(family: string): string {
return family.replace(/\s+(Variable|\d+(?:pt|px|em))$/i, '')
}
export function styleToVariant(style: string): string {
const weight = styleToWeight(style)
const italic = style.toLowerCase().includes('italic')
if (weight === 400 && !italic) return 'regular'
if (weight === 400 && italic) return 'italic'
return italic ? `${weight}italic` : `${weight}`
}
export function isVariableFont(data: ArrayBuffer): boolean {
if (data.byteLength < 12) return false
const view = new DataView(data)
@ -83,14 +65,4 @@ export function isVariableFont(data: ArrayBuffer): boolean {
return false
}
export function weightToStyle(weight: number, italic = false): string {
const rounded = Math.round(weight / 100) * 100
const label = (FONT_WEIGHT_NAMES[rounded] ?? 'Regular').replace(/ /g, '')
return italic ? `${label} Italic` : label
}
export function weightToFigmaStyle(weight: number, italic = false): string {
const rounded = Math.round(weight / 100) * 100
const label = FONT_WEIGHT_NAMES[rounded] ?? 'Regular'
return italic ? `${label} Italic` : label
}
export { weightToFigmaStyle } from '@open-pencil/fig/node-change'

View file

@ -227,7 +227,7 @@ These are parsed or visible in Figma docs and most likely to cause visible diffe
| 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 export mapping | `packages/fig/src/node-change/export-node.ts`, `packages/fig/src/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` |
| Fills / images / gradients | `packages/core/src/canvas/fills.ts` |

View file

@ -16,12 +16,11 @@ Current ownership:
- 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
- SceneGraph-to-`NodeChange` export conversion with an explicit glyph-outline runtime service
- Package-local archive, conversion, instance, export, and dist smoke tests
Planned ownership:
- Remaining SceneGraph-to-`NodeChange` export conversion and document orchestration
- Raw Figma metadata precedence and invalidation policy
- Oracle-backed `.fig` fixtures
Non-goals:

View file

@ -10,7 +10,8 @@ if (
typeof mod.parseFigBuffer !== 'function' ||
typeof mod.writeFigArchive !== 'function' ||
typeof instanceOverrides.populateAndApplyOverrides !== 'function' ||
typeof nodeChange.convertLineHeight !== 'function'
typeof nodeChange.convertLineHeight !== 'function' ||
typeof nodeChange.sceneNodeToKiwi !== 'function'
) {
throw new Error('Expected @open-pencil/fig archive API exports')
}

View file

@ -0,0 +1,52 @@
import type { SceneNode } from '@open-pencil/scene-graph'
export function mapToFigmaType(type: SceneNode['type']): string {
switch (type) {
case 'FRAME':
return 'FRAME'
case 'RECTANGLE':
return 'RECTANGLE'
case 'ROUNDED_RECTANGLE':
return 'ROUNDED_RECTANGLE'
case 'ELLIPSE':
return 'ELLIPSE'
case 'TEXT':
return 'TEXT'
case 'LINE':
return 'LINE'
case 'STAR':
return 'STAR'
case 'POLYGON':
return 'REGULAR_POLYGON'
case 'VECTOR':
return 'VECTOR'
case 'BOOLEAN_OPERATION':
return 'BOOLEAN_OPERATION'
case 'GROUP':
return 'FRAME'
case 'SECTION':
return 'SECTION'
case 'COMPONENT':
return 'SYMBOL'
case 'COMPONENT_SET':
return 'FRAME'
case 'INSTANCE':
return 'INSTANCE'
case 'CONNECTOR':
return 'CONNECTOR'
case 'SHAPE_WITH_TEXT':
return 'SHAPE_WITH_TEXT'
default:
return 'RECTANGLE'
}
}
/** Generate a printable, lexicographically ordered parent position. */
export function fractionalPosition(index: number): string {
const BASE = 94
const FIRST = 33
const TILDE = 126
const numTildes = Math.floor(index / BASE)
const lastChar = String.fromCharCode(FIRST + (index % BASE))
return String.fromCharCode(TILDE).repeat(numTildes) + lastChar
}

View file

@ -1,13 +1,6 @@
import { effectiveFigmaRawNodeFields, effectiveFigmaSourcePayload } from '@open-pencil/fig'
import {
applyExportSettingsPluginData,
mergePluginData,
NODE_TYPE_PLUGIN_KEY,
serializePluginRelaunchData,
upsertPluginData
} from '@open-pencil/fig/node-change'
import type { NodeChange, Paint } from '@open-pencil/kiwi/fig/codec'
import { stringToGuid } from '@open-pencil/kiwi/fig/guid'
import { DEFAULT_STROKE_MITER_LIMIT } from '@open-pencil/scene-graph'
import type {
ComponentPropertyDefinition,
ComponentPropertyReferenceField,
@ -16,9 +9,16 @@ import type {
} from '@open-pencil/scene-graph'
import type { Color, GUID, Matrix, Vector } from '@open-pencil/scene-graph/primitives'
import { effectiveFigmaRawNodeFields, effectiveFigmaSourcePayload } from '../source-metadata'
/* eslint-disable max-lines */
import { bytesToHex } from '#core/bytes/hex'
import { DEFAULT_STROKE_MITER_LIMIT } from '#core/constants'
import { bytesToHex } from './bytes'
import {
applyExportSettingsPluginData,
mergePluginData,
NODE_TYPE_PLUGIN_KEY,
serializePluginRelaunchData,
upsertPluginData
} from './plugin-data'
export type KiwiNodeChange = NodeChange & Record<string, unknown>

View file

@ -0,0 +1,20 @@
import type { OutlineCommand } from './path-commands'
export interface FigGlyphOutlineMetric {
commands: OutlineCommand[]
x: number
advance: number
}
export interface FigNodeChangeExportRuntime {
getGlyphOutlineMetrics(
family: string,
style: string,
text: string,
fontSize: number
): FigGlyphOutlineMetric[] | null
}
export const EMPTY_EXPORT_RUNTIME: FigNodeChangeExportRuntime = {
getGlyphOutlineMetrics: () => null
}

View file

@ -0,0 +1,7 @@
import { FONT_WEIGHT_NAMES } from '@open-pencil/scene-graph'
export function weightToFigmaStyle(weight: number, italic = false): string {
const rounded = Math.round(weight / 100) * 100
const label = FONT_WEIGHT_NAMES[rounded] ?? 'Regular'
return italic ? `${label} Italic` : label
}

View file

@ -1,11 +1,19 @@
export * from './basics'
export * from './convert'
export * from './derived-text-data'
export * from './derived-text-glyphs'
export * from './export-node'
export * from './export-runtime'
export * from './font/features'
export * from './font/style'
export * from './font/variations'
export * from './paint'
export * from './path-commands'
export * from './plugin-data'
export * from './serialize'
export * from './style-refs'
export * from './style-runs'
export * from './text-data-export'
export * from './text-values'
export * from './vector-geometry'
export * from './vector-network'

View file

@ -1,4 +1,12 @@
import type { OutlineCommand } from '#core/text/opentype'
export interface OutlineCommand {
type: string
x?: number
y?: number
x1?: number
y1?: number
x2?: number
y2?: number
}
const CMD_CLOSE = 0
const CMD_MOVE_TO = 1

View file

@ -0,0 +1,546 @@
import { normalizeFontFamily, weightToStyle } from '@open-pencil/scene-graph'
import { effectiveFigmaRawNodeFields } from '../source-metadata'
import { fractionalPosition, mapToFigmaType } from './basics'
import { bytesToHex, hexToBytes } from './bytes'
import { VARIABLE_BINDING_FIELDS } from './convert'
import { buildDerivedTextData as buildSharedDerivedTextData } from './derived-text-data'
import { EMPTY_EXPORT_RUNTIME, type FigNodeChangeExportRuntime } from './export-runtime'
import { applyFontFeaturesToKiwi } from './font/features'
import { weightToFigmaStyle } from './font/style'
import { encodePathCommandsBlob } from './path-commands'
import {
BOUND_VARIABLES_PLUGIN_KEY,
LAYOUT_DIRECTION_PLUGIN_KEY,
TEXT_DIRECTION_PLUGIN_KEY,
upsertPluginData
} from './plugin-data'
import { buildStyleOverrideTable, encodeVectorNetworkBlob } from './vector-network'
export {
buildFigKiwi,
decompressFigKiwiDataAsync,
FIG_KIWI_DEFAULT_VERSION,
parseFigKiwiChunks
} from '@open-pencil/kiwi/fig/container'
import type { NodeChange, Paint, VariableConsumptionEntry } from '@open-pencil/kiwi/fig/codec'
import { guidToString, stringToGuid } from '@open-pencil/kiwi/fig/guid'
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import type { Color, GUID, JsonObject, Matrix } from '@open-pencil/scene-graph/primitives'
import {
buildAssetRefToVarGuidMap,
sceneNodeToKiwiWithContext,
type KiwiNodeChange
} from './export-node'
import { exportTextData, fontVariationToKiwi } from './text-data-export'
function textLines(text: string): NonNullable<NodeChange['textData']>['lines'] {
const lineCount = Math.max(1, text.split('\n').length)
return Array.from({ length: lineCount }, () => ({ lineType: 'PLAIN' }))
}
function appendGlyphBlob(
blobs: Uint8Array[],
glyphBlobMap: Map<string, number>,
blob: Uint8Array
): number {
const key = bytesToHex(blob)
const existing = glyphBlobMap.get(key)
if (existing !== undefined) return existing
const index = blobs.push(blob) - 1
glyphBlobMap.set(key, index)
return index
}
function buildDerivedTextData(
node: SceneNode,
digestMap: Map<string, Uint8Array>,
blobs: Uint8Array[],
glyphBlobMap: Map<string, number>,
runtime: FigNodeChangeExportRuntime
): NodeChange['derivedTextData'] {
const fontMeta: NonNullable<NodeChange['derivedTextData']>['fontMetaData'] = []
const seen = new Set<string>()
const addFont = (family: string, weight: number, italic: boolean) => {
const style = weightToStyle(weight, italic)
const normalized = normalizeFontFamily(family)
const key = `${normalized}|${style}`
if (seen.has(key)) return
seen.add(key)
fontMeta.push({
key: { family: normalized, style: weightToFigmaStyle(weight, italic), postscript: '' },
fontLineHeight: 1.2,
fontDigest: digestMap.get(key),
fontStyle: italic ? 'ITALIC' : 'NORMAL',
fontWeight: weight
})
}
addFont(node.fontFamily, node.fontWeight, node.italic)
for (const run of node.styleRuns) {
addFont(
run.style.fontFamily ?? node.fontFamily,
run.style.fontWeight ?? node.fontWeight,
run.style.italic ?? node.italic
)
}
const lineHeight = node.lineHeight ?? Math.ceil(node.fontSize * 1.2)
const glyphAdvance = node.text.length > 0 ? node.width / Math.max(node.text.length, 1) : 0
const derivedGlyphs = node.figmaDerivedTextGlyphs ?? []
const glyphs =
derivedGlyphs.length > 0
? derivedGlyphs.map((glyph, index) => ({
commandsBlob: appendGlyphBlob(blobs, glyphBlobMap, glyph.commandsBlob),
position: { x: glyph.x, y: glyph.y },
fontSize: glyph.fontSize,
firstCharacter: index,
advance:
index + 1 < derivedGlyphs.length
? Math.max(derivedGlyphs[index + 1].x - glyph.x, 0)
: glyphAdvance,
rotation: 0
}))
: (
runtime.getGlyphOutlineMetrics(
node.fontFamily,
weightToStyle(node.fontWeight, node.italic),
node.text,
node.fontSize
) ?? []
).map((glyph, index) => ({
commandsBlob: appendGlyphBlob(
blobs,
glyphBlobMap,
encodePathCommandsBlob(glyph.commands, node.fontSize)
),
position: { x: glyph.x || index * glyphAdvance, y: lineHeight },
fontSize: node.fontSize,
firstCharacter: index,
advance: glyph.advance || glyphAdvance,
rotation: 0
}))
const logicalIndexToCharacterOffsetMap = Array.from(
{ length: node.text.length + 1 },
(_, index) => index * glyphAdvance
)
return buildSharedDerivedTextData({
node,
glyphs,
fontMetaData: fontMeta,
baseline: lineHeight,
width: node.width,
lineHeight,
lineAscent: Math.max(lineHeight - node.fontSize * 0.2, 0),
logicalIndexToCharacterOffsetMap
})
}
export function safeColor(c: Color | Omit<Color, 'a'>): Color {
return { r: c.r, g: c.g, b: c.b, a: 'a' in c ? c.a : 1 }
}
function fillToKiwiPaint(f: SceneNode['fills'][number]): Paint {
const paint: Paint = {
type: f.type,
color: safeColor(f.color),
opacity: f.opacity,
visible: f.visible,
blendMode: f.blendMode ?? 'NORMAL'
}
if (f.gradientStops) {
paint.stops = f.gradientStops.map((s) => ({ color: safeColor(s.color), position: s.position }))
}
if (f.gradientTransform) paint.transform = f.gradientTransform
if (f.imageHash) paint.image = { hash: hexToBytes(f.imageHash) }
if (f.imageScaleMode) paint.imageScaleMode = f.imageScaleMode
if (f.imageTransform) paint.transform = f.imageTransform
if (f.sourceNodeId) paint.sourceNodeId = stringToGuid(f.sourceNodeId)
if (f.scale) paint.scale = f.scale
if (f.spacing) paint.spacing = f.spacing
if (f.patternSpacing) paint.patternSpacing = f.patternSpacing
if (f.patternTileType) paint.patternTileType = f.patternTileType
if (f.verticalAlignment) paint.verticalAlignment = f.verticalAlignment
if (f.horizontalAlignment) paint.horizontalAlignment = f.horizontalAlignment
if (f.noiseType) paint.noiseType = f.noiseType
if (f.density !== undefined) paint.density = f.density
if (f.noiseSize) paint.noiseSize = f.noiseSize
if (f.customEffectId) paint.customEffectId = { guid: stringToGuid(f.customEffectId) }
return paint
}
function serializeCornerRadii(node: SceneNode, nc: KiwiNodeChange): void {
const anyIndividual =
node.topLeftRadius > 0 ||
node.topRightRadius > 0 ||
node.bottomLeftRadius > 0 ||
node.bottomRightRadius > 0
if (node.cornerRadius > 0) nc.cornerRadius = node.cornerRadius
// Always emit individual radii when present. A node may have
// independentCorners=false with non-zero individual values (e.g. imported
// from Figma where the flag wasn't set but per-corner values exist).
if (anyIndividual || node.independentCorners) {
// For imported nodes, preserve the original independentCorners flag from
// the raw Figma data. Figma may emit per-corner radii without setting the
// independent flag (preserve rectangleCornerRadiiIndependent).
const rawIndependent = node.source.id
? (effectiveFigmaRawNodeFields(node) as JsonObject | undefined)
?.rectangleCornerRadiiIndependent
: undefined
nc.rectangleCornerRadiiIndependent =
typeof rawIndependent === 'boolean' ? rawIndependent : node.independentCorners
nc.rectangleTopLeftCornerRadius = node.topLeftRadius
nc.rectangleTopRightCornerRadius = node.topRightRadius
nc.rectangleBottomLeftCornerRadius = node.bottomLeftRadius
nc.rectangleBottomRightCornerRadius = node.bottomRightRadius
}
if (node.cornerSmoothing > 0 || 'cornerSmoothing' in effectiveFigmaRawNodeFields(node)) {
nc.cornerSmoothing = node.cornerSmoothing
}
}
function resolveTextAutoResize(node: SceneNode, graph: SceneGraph): SceneNode['textAutoResize'] {
// For nodes imported from .fig files, preserve the original textAutoResize
// value. Forcing 'HEIGHT' for fixed-height text inside auto-layout causes
// layout drift on roundtrip.
if (node.source.id) return node.textAutoResize
const parent = node.parentId ? graph.getNode(node.parentId) : undefined
if (
parent &&
parent.layoutMode !== 'NONE' &&
parent.layoutMode !== 'GRID' &&
node.layoutPositioning !== 'ABSOLUTE'
) {
return 'HEIGHT'
}
return node.textAutoResize
}
function serializeTextProps(
node: SceneNode,
nc: KiwiNodeChange,
graph: SceneGraph,
fontDigestMap: Map<string, Uint8Array> | undefined,
blobs: Uint8Array[],
glyphBlobMap: Map<string, number> | undefined,
runtime: FigNodeChangeExportRuntime
): void {
upsertPluginData(node, TEXT_DIRECTION_PLUGIN_KEY, node.textDirection)
nc.fontSize = node.fontSize
nc.fontName = {
family: normalizeFontFamily(node.fontFamily),
style: weightToFigmaStyle(node.fontWeight, node.italic),
postscript: ''
}
nc.textData = exportTextData(node, textLines, fillToKiwiPaint)
if (node.fontVariations.length > 0) {
nc.fontVariations = node.fontVariations.map(fontVariationToKiwi)
}
const autoResize = resolveTextAutoResize(node, graph)
nc.textAutoResize = autoResize
nc.textAlignHorizontal = node.textAlignHorizontal
nc.textAlignVertical = node.textAlignVertical
nc.textUserLayoutVersion = 4
nc.textExplicitLayoutVersion = 1
nc.textBidiVersion = 1
nc.textDecorationSkipInk = node.textDecorationSkipInk
nc.fontVariantCommonLigatures = true
nc.fontVariantContextualLigatures = true
applyFontFeaturesToKiwi(nc, node.fontFeatures)
nc.fontVersion = ''
nc.emojiImageSet = 'APPLE'
if (node.textCase !== 'ORIGINAL') nc.textCase = node.textCase
if (node.textTruncation === 'ENDING') nc.textTruncation = 'ENDING'
if (node.maxLines != null) nc.maxLines = node.maxLines
if (fontDigestMap) {
nc.derivedTextData = buildDerivedTextData(
node,
fontDigestMap,
blobs,
glyphBlobMap ?? new Map(),
runtime
)
}
if (node.leadingTrim !== 'NONE') nc.leadingTrim = node.leadingTrim
if (node.lineHeight != null) nc.lineHeight = { value: node.lineHeight, units: 'PIXELS' }
nc.letterSpacing = { value: node.letterSpacing, units: 'PIXELS' }
if (node.textDecoration !== 'NONE') {
nc.textDecoration = node.textDecoration === 'UNDERLINE' ? 'UNDERLINE' : 'STRIKETHROUGH'
}
if (node.textDecorationStyle !== 'SOLID') nc.textDecorationStyle = node.textDecorationStyle
if (node.textDecorationThickness != null) {
nc.textDecorationThickness = { value: node.textDecorationThickness, units: 'PIXELS' }
}
if (node.textUnderlineOffset != null) {
nc.textUnderlineOffset = { value: node.textUnderlineOffset, units: 'PIXELS' }
}
if (node.textDecorationFills.length > 0) {
nc.textDecorationFillPaints = node.textDecorationFills.map(fillToKiwiPaint)
}
}
function normalizeStackMode(value: string | undefined): KiwiNodeChange['stackMode'] {
return value === 'HORIZONTAL' || value === 'VERTICAL' || value === 'NONE' ? value : undefined
}
function normalizeStackSizing(value: string | undefined): KiwiNodeChange['stackPrimarySizing'] {
return value === 'FIXED' ||
value === 'RESIZE_TO_FIT' ||
value === 'RESIZE_TO_FIT_WITH_IMPLICIT_SIZE'
? value
: undefined
}
function normalizeStackJustify(value: string | undefined): string | undefined {
return value === 'SPACE_EVENLY' ? 'SPACE_BETWEEN' : value
}
function normalizeStackCounterAlign(value: string | undefined): string | undefined {
return value === 'SPACE_EVENLY' ? 'SPACE_BETWEEN' : value
}
function serializeLayoutProps(node: SceneNode, nc: KiwiNodeChange): void {
if (!node.source.id) upsertPluginData(node, LAYOUT_DIRECTION_PLUGIN_KEY, node.layoutDirection)
const figLayout = node.source.fig.layout
if (figLayout) {
nc.stackMode = normalizeStackMode(figLayout.stackMode)
nc.stackSpacing = figLayout.stackSpacing
nc.stackPadding = figLayout.stackPadding
nc.stackPaddingRight = figLayout.stackPaddingRight
nc.stackPaddingBottom = figLayout.stackPaddingBottom
nc.stackCounterAlign = normalizeStackCounterAlign(figLayout.stackCounterAlign)
nc.stackJustify = normalizeStackJustify(figLayout.stackJustify)
nc.stackCounterAlignItems = normalizeStackCounterAlign(figLayout.stackCounterAlignItems)
nc.stackPrimaryAlignItems = normalizeStackJustify(figLayout.stackPrimaryAlignItems)
// For imported nodes, figLayout captures the original kiwi NC values.
// Preserve omitted sizing fields instead of materializing schema defaults.
const stackPrimarySizing = normalizeStackSizing(figLayout.stackPrimarySizing)
if (stackPrimarySizing) nc.stackPrimarySizing = stackPrimarySizing
const stackCounterSizing = normalizeStackSizing(figLayout.stackCounterSizing)
if (stackCounterSizing) nc.stackCounterSizing = stackCounterSizing
nc.stackVerticalPadding = figLayout.stackVerticalPadding
nc.stackHorizontalPadding = figLayout.stackHorizontalPadding
nc.stackWrap = figLayout.stackWrap
nc.stackPositioning = figLayout.stackPositioning
nc.stackChildPrimaryGrow = figLayout.stackChildPrimaryGrow
nc.stackChildAlignSelf = figLayout.stackChildAlignSelf
nc.stackCounterSpacing = figLayout.stackCounterSpacing
nc.bordersTakeSpace = figLayout.bordersTakeSpace
if (figLayout.stackReverseZIndex) nc.stackReverseZIndex = true
return
}
if (node.layoutMode !== 'NONE' && node.layoutMode !== 'GRID') {
nc.stackMode = node.layoutMode
nc.stackSpacing = node.itemSpacing
nc.stackVerticalPadding = node.paddingTop
nc.stackHorizontalPadding = node.paddingLeft
nc.stackPaddingBottom = node.paddingBottom
nc.stackPaddingRight = node.paddingRight
nc.stackPrimarySizing = node.primaryAxisSizing === 'HUG' ? 'RESIZE_TO_FIT' : 'FIXED'
nc.stackCounterSizing = node.counterAxisSizing === 'HUG' ? 'RESIZE_TO_FIT' : 'FIXED'
nc.stackPrimaryAlignItems = normalizeStackJustify(node.primaryAxisAlign)
nc.stackCounterAlignItems = normalizeStackCounterAlign(node.counterAxisAlign)
if (node.layoutWrap === 'WRAP') nc.stackWrap = 'WRAP'
if (node.counterAxisSpacing > 0) nc.stackCounterSpacing = node.counterAxisSpacing
nc.bordersTakeSpace = node.strokesIncludedInLayout
}
if (node.itemReverseZIndex) nc.stackReverseZIndex = true
if (node.layoutPositioning === 'ABSOLUTE') nc.stackPositioning = 'ABSOLUTE'
if (node.layoutGrow > 0) nc.stackChildPrimaryGrow = node.layoutGrow
if (node.layoutAlignSelf !== 'AUTO') {
nc.stackChildAlignSelf = node.layoutAlignSelf
}
}
function serializeGeometry(node: SceneNode, nc: KiwiNodeChange, blobs: Uint8Array[]): void {
if (node.isMask) {
nc.mask = true
nc.maskType = node.maskType
if (node.maskIsOutline) nc.maskIsOutline = true
}
if (node.vectorNetwork && node.type === 'VECTOR') {
const { table, mirroringToId } = buildStyleOverrideTable(node.vectorNetwork)
const blobIdx = blobs.length
blobs.push(encodeVectorNetworkBlob(node.vectorNetwork, mirroringToId))
const vectorData: Record<string, unknown> = {
vectorNetworkBlob: blobIdx,
normalizedSize: { x: node.width, y: node.height }
}
if (table.length > 0) {
vectorData.styleOverrideTable = table
}
nc.vectorData = vectorData
}
if (node.fillGeometry.length > 0) {
nc.fillGeometry = node.fillGeometry.map((g) => {
const blobIdx = blobs.length
blobs.push(g.commandsBlob)
return { windingRule: g.windingRule, commandsBlob: blobIdx }
})
}
if (node.strokeGeometry.length > 0) {
nc.strokeGeometry = node.strokeGeometry.map((g) => {
const blobIdx = blobs.length
blobs.push(g.commandsBlob)
return { windingRule: g.windingRule, commandsBlob: blobIdx }
})
}
}
function serializeVariableBindings(
node: SceneNode,
nc: KiwiNodeChange,
graph: SceneGraph,
varIdToGuid?: Map<string, GUID>
): void {
if (Object.keys(node.boundVariables).length === 0) return
const entries: VariableConsumptionEntry[] = []
const roundtripBindings: Record<string, string> = {}
const typeMap: Record<string, string> = { COLOR: 'COLOR', BOOLEAN: 'BOOLEAN', STRING: 'STRING' }
for (const [field, varId] of Object.entries(node.boundVariables)) {
const variable = graph.variables.get(varId)
if (!variable) continue
const varGuid = varIdToGuid?.get(varId) ?? stringToGuid(varId)
roundtripBindings[field] = guidToString(varGuid)
const kiwiField = VARIABLE_BINDING_FIELDS[field]
if (!kiwiField) continue
const resolvedType = typeMap[variable.type] ?? 'FLOAT'
entries.push({
variableData: {
value: { alias: { guid: varGuid } },
dataType: 'ALIAS',
resolvedDataType: resolvedType
},
variableField: kiwiField
})
}
if (Object.keys(roundtripBindings).length > 0) {
upsertPluginData(node, BOUND_VARIABLES_PLUGIN_KEY, JSON.stringify(roundtripBindings))
}
if (entries.length > 0) nc.variableConsumptionMap = { entries }
}
function computeExportTransform(node: SceneNode): Matrix {
const sx = node.flipX ? -1 : 1
const cos = Math.cos((node.rotation * Math.PI) / 180)
const sin = Math.sin((node.rotation * Math.PI) / 180)
const m00 = cos * sx
const m01 = -sin
const m10 = sin * sx
const m11 = cos
const corners = [
{ x: 0, y: 0 },
{ x: node.width, y: 0 },
{ x: 0, y: node.height },
{ x: node.width, y: node.height }
].map((point) => ({
x: m00 * point.x + m01 * point.y,
y: m10 * point.x + m11 * point.y
}))
const offsetX = Math.min(...corners.map((point) => point.x))
const offsetY = Math.min(...corners.map((point) => point.y))
return {
m00,
m01,
m02: node.x - offsetX,
m10,
m11,
m12: node.y - offsetY
}
}
export function sceneNodeToKiwi(
node: SceneNode,
parentGuid: GUID,
childIndex: number,
localIdCounter: { value: number },
graph: SceneGraph,
blobs: Uint8Array[],
nodeIdToGuid?: Map<string, GUID>,
fontDigestMap?: Map<string, Uint8Array>,
varIdToGuid?: Map<string, GUID>,
glyphBlobMap = new Map<string, number>(),
blobIndexByHex?: Map<string, number>,
assignedGuidValues?: Set<string>,
runtime: FigNodeChangeExportRuntime = EMPTY_EXPORT_RUNTIME
): KiwiNodeChange[] {
// Build assetRef to guid mapping for converting colorVar references in raw paints
const assetRefToVarGuid = varIdToGuid ? buildAssetRefToVarGuidMap(graph, varIdToGuid) : undefined
return sceneNodeToKiwiWithContext(node, parentGuid, childIndex, localIdCounter, {
graph,
blobs,
blobIndexByHex,
nodeIdToGuid,
assignedGuidValues,
fontDigestMap,
glyphBlobMap,
varIdToGuid,
assetRefToVarGuid,
fractionalPosition,
mapToFigmaType,
fillToKiwiPaint,
safeColor,
computeExportTransform,
serializeCornerRadii,
serializeTextProps: (textNode, nc, textGraph, digests, textBlobs, glyphs) =>
serializeTextProps(textNode, nc, textGraph, digests, textBlobs, glyphs, runtime),
serializeLayoutProps,
serializeGeometry,
serializeVariableBindings,
sceneNodeToKiwi: sceneNodeToKiwiWithContext
})
}
const IDENTITY_TRANSFORM = { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
const DEFAULT_STROKE_WEIGHT = 1
export function makeDocumentNodeChange(
guid: GUID,
documentColorSpace: 'srgb' | 'display-p3' = 'display-p3'
): NodeChange & Record<string, unknown> {
return {
guid,
type: 'DOCUMENT',
name: 'Document',
visible: true,
opacity: 1,
phase: 'CREATED',
transform: { ...IDENTITY_TRANSFORM },
strokeWeight: DEFAULT_STROKE_WEIGHT,
strokeAlign: 'CENTER',
strokeJoin: 'MITER',
documentColorProfile: documentColorSpace === 'display-p3' ? 'DISPLAY_P3' : 'SRGB'
}
}
export function makeCanvasNodeChange(
guid: GUID,
parentGuid: GUID,
position: string,
name: string,
extra?: Record<string, unknown>
): NodeChange & Record<string, unknown> {
return {
guid,
parentIndex: { guid: parentGuid, position },
type: 'CANVAS',
name,
visible: true,
opacity: 1,
phase: 'CREATED',
transform: { ...IDENTITY_TRANSFORM },
strokeWeight: DEFAULT_STROKE_WEIGHT,
strokeAlign: 'CENTER',
strokeJoin: 'MITER',
pageType: 'DESIGN',
...extra
}
}

View file

@ -1,8 +1,10 @@
import { applyFontFeaturesToKiwi, stringToFigmaAxisTag } from '@open-pencil/fig/node-change'
import type { NodeChange, Paint } from '@open-pencil/kiwi/fig/codec'
import { normalizeFontFamily } from '@open-pencil/scene-graph'
import type { CharacterStyleOverride, SceneNode } from '@open-pencil/scene-graph'
import { normalizeFontFamily, weightToFigmaStyle } from '#core/text/fonts'
import { applyFontFeaturesToKiwi } from './font/features'
import { weightToFigmaStyle } from './font/style'
import { stringToFigmaAxisTag } from './font/variations'
export function fontVariationToKiwi(variation: SceneNode['fontVariations'][number]) {
const axisTag = stringToFigmaAxisTag(variation.axis)

View file

@ -0,0 +1,56 @@
import { describe, expect, test } from 'bun:test'
import { SceneGraph } from '@open-pencil/scene-graph'
import {
fractionalPosition,
mapToFigmaType,
sceneNodeToKiwi,
type FigNodeChangeExportRuntime
} from '../src/node-change'
describe('@open-pencil/fig SceneGraph export policy', () => {
test('maps node types and sibling positions deterministically', () => {
expect(mapToFigmaType('COMPONENT')).toBe('SYMBOL')
expect([0, 93, 94, 188].map(fractionalPosition)).toEqual(['!', '~', '~!', '~~!'])
})
test('injects runtime glyph outlines into derived text data', () => {
const graph = new SceneGraph()
const text = graph.createNode('TEXT', graph.getPages()[0].id, {
text: 'A',
width: 20,
height: 20,
fontSize: 16
})
const blobs: Uint8Array[] = []
const runtime: FigNodeChangeExportRuntime = {
getGlyphOutlineMetrics: () => [
{
commands: [{ type: 'M', x: 0, y: 0 }, { type: 'L', x: 8, y: 16 }, { type: 'Z' }],
x: 0,
advance: 10
}
]
}
const [change] = sceneNodeToKiwi(
text,
{ sessionID: 1, localID: 1 },
0,
{ value: 2 },
graph,
blobs,
undefined,
new Map([['Inter|Regular', new Uint8Array([1, 2, 3])]]),
undefined,
new Map(),
undefined,
undefined,
runtime
)
expect(change.derivedTextData?.glyphs).toHaveLength(1)
expect(blobs).toHaveLength(1)
})
})

View file

@ -3,6 +3,18 @@ export interface ParsedFontStyle {
italic: boolean
}
export const FONT_WEIGHT_NAMES: Record<number, string> = {
100: 'Thin',
200: 'Extra Light',
300: 'Light',
400: 'Regular',
500: 'Medium',
600: 'Semi Bold',
700: 'Bold',
800: 'Extra Bold',
900: 'Black'
}
const FONT_WEIGHT_ALIASES = [
{ weight: 100, names: ['thin', 'hairline', 'extrathin', 'ultrathin'] },
{ weight: 200, names: ['extralight', 'ultralight'] },
@ -40,3 +52,21 @@ export function parseFontStyle(style: string | undefined): ParsedFontStyle {
export function styleToWeight(style: string | undefined): number {
return parseFontStyle(style).weight
}
export function normalizeFontFamily(family: string): string {
return family.replace(/\s+(Variable|\d+(?:pt|px|em))$/i, '')
}
export function styleToVariant(style: string): string {
const weight = styleToWeight(style)
const italic = style.toLowerCase().includes('italic')
if (weight === 400 && !italic) return 'regular'
if (weight === 400 && italic) return 'italic'
return italic ? `${weight}italic` : `${weight}`
}
export function weightToStyle(weight: number, italic = false): string {
const rounded = Math.round(weight / 100) * 100
const label = (FONT_WEIGHT_NAMES[rounded] ?? 'Regular').replace(/ /g, '')
return italic ? `${label} Italic` : label
}

View file

@ -38,7 +38,7 @@ const effectsPath = coreSourcePath('canvas/effects.ts')
const scenePath = coreSourcePath('canvas/scene.ts')
const rendererPath = coreSourcePath('canvas/renderer.ts')
const sgTypesPath = repoPath('packages/scene-graph/src/types.ts')
const nodeExportPath = coreSourcePath('kiwi/fig/node-change/export-node.ts')
const nodeExportPath = repoPath('packages/fig/src/node-change/export-node.ts')
const convertPath = repoPath('packages/fig/src/node-change/paint.ts')
const schemaPath = repoPath('packages/kiwi/src/fig/schema/fig.kiwi')
const codecPath = repoPath('packages/kiwi/src/fig/codec.ts')

View file

@ -94,7 +94,7 @@ try {
tempDir
)
nodeEval(
"const { convertLineHeight } = await import('@open-pencil/fig/node-change'); if (convertLineHeight({ value: 120, units: 'PERCENT' }, 20) !== 24) throw new Error('Fig NodeChange subpath failed')",
"const { convertLineHeight, sceneNodeToKiwi } = await import('@open-pencil/fig/node-change'); if (convertLineHeight({ value: 120, units: 'PERCENT' }, 20) !== 24 || typeof sceneNodeToKiwi !== 'function') throw new Error('Fig NodeChange subpath failed')",
tempDir
)
nodeEval(