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:
parent
02b7261c9e
commit
85c3c8fad1
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
export {
|
||||
applyFontFeaturesToKiwi,
|
||||
convertFontFeatures,
|
||||
convertFontVariations,
|
||||
figmaAxisTagToString,
|
||||
stringToFigmaAxisTag
|
||||
} from '@open-pencil/fig/node-change'
|
||||
export { buildFontDigestMap } from './digests'
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'>>,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
52
packages/fig/src/node-change/basics.ts
Normal file
52
packages/fig/src/node-change/basics.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
20
packages/fig/src/node-change/export-runtime.ts
Normal file
20
packages/fig/src/node-change/export-runtime.ts
Normal 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
|
||||
}
|
||||
7
packages/fig/src/node-change/font/style.ts
Normal file
7
packages/fig/src/node-change/font/style.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
546
packages/fig/src/node-change/serialize.ts
Normal file
546
packages/fig/src/node-change/serialize.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
56
packages/fig/tests/export.test.ts
Normal file
56
packages/fig/tests/export.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue