From 85c3c8fad1e5d95080cb93fd891cdfc217c6cb77 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 18 Jul 2026 02:41:35 +0300 Subject: [PATCH] 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 --- AGENTS.md | 6 +- CHANGELOG.md | 2 +- .../src/kiwi/fig/node-change/font/index.ts | 8 - .../src/kiwi/fig/node-change/serialize.ts | 591 +----------------- .../core/src/text/derived-text/clipboard.ts | 10 +- packages/core/src/text/font-style.ts | 48 +- packages/docs/development/roadmap.md | 2 +- packages/fig/README.md | 5 +- packages/fig/scripts/smoke-dist.ts | 3 +- packages/fig/src/node-change/basics.ts | 52 ++ .../hex.ts => fig/src/node-change/bytes.ts} | 0 .../src/node-change/derived-text-data.ts} | 0 .../src}/node-change/export-node.ts | 20 +- .../fig/src/node-change/export-runtime.ts | 20 + packages/fig/src/node-change/font/style.ts | 7 + packages/fig/src/node-change/index.ts | 8 + .../src}/node-change/path-commands.ts | 10 +- packages/fig/src/node-change/serialize.ts | 546 ++++++++++++++++ .../src}/node-change/text-data-export.ts | 6 +- packages/fig/tests/export.test.ts | 56 ++ packages/scene-graph/src/font-style.ts | 30 + .../render/canvas/silhouette-autopsy.test.ts | 2 +- tools/package-quality/src/smoke.ts | 2 +- 23 files changed, 794 insertions(+), 640 deletions(-) delete mode 100644 packages/core/src/kiwi/fig/node-change/font/index.ts create mode 100644 packages/fig/src/node-change/basics.ts rename packages/{core/src/bytes/hex.ts => fig/src/node-change/bytes.ts} (100%) rename packages/{core/src/text/derived-text/data.ts => fig/src/node-change/derived-text-data.ts} (100%) rename packages/{core/src/kiwi/fig => fig/src}/node-change/export-node.ts (99%) create mode 100644 packages/fig/src/node-change/export-runtime.ts create mode 100644 packages/fig/src/node-change/font/style.ts rename packages/{core/src/kiwi/fig => fig/src}/node-change/path-commands.ts (93%) create mode 100644 packages/fig/src/node-change/serialize.ts rename packages/{core/src/kiwi/fig => fig/src}/node-change/text-data-export.ts (94%) create mode 100644 packages/fig/tests/export.test.ts diff --git a/AGENTS.md b/AGENTS.md index 8d67e0c0d..c8d14e099 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `` download fallback with deferred `revokeObjectURL`. SafariBanner warns users about limitations. diff --git a/CHANGELOG.md b/CHANGELOG.md index 08d381179..0948d45c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/packages/core/src/kiwi/fig/node-change/font/index.ts b/packages/core/src/kiwi/fig/node-change/font/index.ts deleted file mode 100644 index b83ab250f..000000000 --- a/packages/core/src/kiwi/fig/node-change/font/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { - applyFontFeaturesToKiwi, - convertFontFeatures, - convertFontVariations, - figmaAxisTagToString, - stringToFigmaAxisTag -} from '@open-pencil/fig/node-change' -export { buildFontDigestMap } from './digests' diff --git a/packages/core/src/kiwi/fig/node-change/serialize.ts b/packages/core/src/kiwi/fig/node-change/serialize.ts index 5a98cce44..6fd7e5561 100644 --- a/packages/core/src/kiwi/fig/node-change/serialize.ts +++ b/packages/core/src/kiwi/fig/node-change/serialize.ts @@ -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['lines'] { - const lineCount = Math.max(1, text.split('\n').length) - return Array.from({ length: lineCount }, () => ({ lineType: 'PLAIN' })) -} - -function appendGlyphBlob( - blobs: Uint8Array[], - glyphBlobMap: Map, - 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, - blobs: Uint8Array[], - glyphBlobMap: Map -): NodeChange['derivedTextData'] { - const fontMeta: NonNullable['fontMetaData'] = [] - const seen = new Set() - - 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 { - 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 | undefined, - blobs: Uint8Array[], - glyphBlobMap: Map | 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 = { - 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 -): void { - if (Object.keys(node.boundVariables).length === 0) return - const entries: VariableConsumptionEntry[] = [] - const roundtripBindings: Record = {} - const typeMap: Record = { 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, assignedGuidValues?: Set ): 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 { - 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 -): NodeChange & Record { - 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 + ) } diff --git a/packages/core/src/text/derived-text/clipboard.ts b/packages/core/src/text/derived-text/clipboard.ts index 9088c368d..06119b221 100644 --- a/packages/core/src/text/derived-text/clipboard.ts +++ b/packages/core/src/text/derived-text/clipboard.ts @@ -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>, diff --git a/packages/core/src/text/font-style.ts b/packages/core/src/text/font-style.ts index aa605463d..c6ad30710 100644 --- a/packages/core/src/text/font-style.ts +++ b/packages/core/src/text/font-style.ts @@ -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( return undefined } -export const FONT_WEIGHT_NAMES: Record = { - 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' diff --git a/packages/docs/development/roadmap.md b/packages/docs/development/roadmap.md index 6e87bc2bc..14c4cace7 100644 --- a/packages/docs/development/roadmap.md +++ b/packages/docs/development/roadmap.md @@ -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` | diff --git a/packages/fig/README.md b/packages/fig/README.md index 264b238f8..b79328aa1 100644 --- a/packages/fig/README.md +++ b/packages/fig/README.md @@ -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: diff --git a/packages/fig/scripts/smoke-dist.ts b/packages/fig/scripts/smoke-dist.ts index c63863d6f..6c6766fef 100644 --- a/packages/fig/scripts/smoke-dist.ts +++ b/packages/fig/scripts/smoke-dist.ts @@ -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') } diff --git a/packages/fig/src/node-change/basics.ts b/packages/fig/src/node-change/basics.ts new file mode 100644 index 000000000..72baed541 --- /dev/null +++ b/packages/fig/src/node-change/basics.ts @@ -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 +} diff --git a/packages/core/src/bytes/hex.ts b/packages/fig/src/node-change/bytes.ts similarity index 100% rename from packages/core/src/bytes/hex.ts rename to packages/fig/src/node-change/bytes.ts diff --git a/packages/core/src/text/derived-text/data.ts b/packages/fig/src/node-change/derived-text-data.ts similarity index 100% rename from packages/core/src/text/derived-text/data.ts rename to packages/fig/src/node-change/derived-text-data.ts diff --git a/packages/core/src/kiwi/fig/node-change/export-node.ts b/packages/fig/src/node-change/export-node.ts similarity index 99% rename from packages/core/src/kiwi/fig/node-change/export-node.ts rename to packages/fig/src/node-change/export-node.ts index 1fcdf50e4..33ebd9bab 100644 --- a/packages/core/src/kiwi/fig/node-change/export-node.ts +++ b/packages/fig/src/node-change/export-node.ts @@ -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 diff --git a/packages/fig/src/node-change/export-runtime.ts b/packages/fig/src/node-change/export-runtime.ts new file mode 100644 index 000000000..48879195a --- /dev/null +++ b/packages/fig/src/node-change/export-runtime.ts @@ -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 +} diff --git a/packages/fig/src/node-change/font/style.ts b/packages/fig/src/node-change/font/style.ts new file mode 100644 index 000000000..6463e620f --- /dev/null +++ b/packages/fig/src/node-change/font/style.ts @@ -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 +} diff --git a/packages/fig/src/node-change/index.ts b/packages/fig/src/node-change/index.ts index 5bf263c95..279fd8919 100644 --- a/packages/fig/src/node-change/index.ts +++ b/packages/fig/src/node-change/index.ts @@ -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' diff --git a/packages/core/src/kiwi/fig/node-change/path-commands.ts b/packages/fig/src/node-change/path-commands.ts similarity index 93% rename from packages/core/src/kiwi/fig/node-change/path-commands.ts rename to packages/fig/src/node-change/path-commands.ts index 1b657f1ff..782a87e10 100644 --- a/packages/core/src/kiwi/fig/node-change/path-commands.ts +++ b/packages/fig/src/node-change/path-commands.ts @@ -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 diff --git a/packages/fig/src/node-change/serialize.ts b/packages/fig/src/node-change/serialize.ts new file mode 100644 index 000000000..3257a9e13 --- /dev/null +++ b/packages/fig/src/node-change/serialize.ts @@ -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['lines'] { + const lineCount = Math.max(1, text.split('\n').length) + return Array.from({ length: lineCount }, () => ({ lineType: 'PLAIN' })) +} + +function appendGlyphBlob( + blobs: Uint8Array[], + glyphBlobMap: Map, + 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, + blobs: Uint8Array[], + glyphBlobMap: Map, + runtime: FigNodeChangeExportRuntime +): NodeChange['derivedTextData'] { + const fontMeta: NonNullable['fontMetaData'] = [] + const seen = new Set() + + 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 { + 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 | undefined, + blobs: Uint8Array[], + glyphBlobMap: Map | 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 = { + 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 +): void { + if (Object.keys(node.boundVariables).length === 0) return + const entries: VariableConsumptionEntry[] = [] + const roundtripBindings: Record = {} + const typeMap: Record = { 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, + fontDigestMap?: Map, + varIdToGuid?: Map, + glyphBlobMap = new Map(), + blobIndexByHex?: Map, + assignedGuidValues?: Set, + 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 { + 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 +): NodeChange & Record { + 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 + } +} diff --git a/packages/core/src/kiwi/fig/node-change/text-data-export.ts b/packages/fig/src/node-change/text-data-export.ts similarity index 94% rename from packages/core/src/kiwi/fig/node-change/text-data-export.ts rename to packages/fig/src/node-change/text-data-export.ts index 1393ad3ea..453c82d96 100644 --- a/packages/core/src/kiwi/fig/node-change/text-data-export.ts +++ b/packages/fig/src/node-change/text-data-export.ts @@ -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) diff --git a/packages/fig/tests/export.test.ts b/packages/fig/tests/export.test.ts new file mode 100644 index 000000000..fa9c81d75 --- /dev/null +++ b/packages/fig/tests/export.test.ts @@ -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) + }) +}) diff --git a/packages/scene-graph/src/font-style.ts b/packages/scene-graph/src/font-style.ts index b0fe32f1f..9a779eed0 100644 --- a/packages/scene-graph/src/font-style.ts +++ b/packages/scene-graph/src/font-style.ts @@ -3,6 +3,18 @@ export interface ParsedFontStyle { italic: boolean } +export const FONT_WEIGHT_NAMES: Record = { + 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 +} diff --git a/tests/engine/render/canvas/silhouette-autopsy.test.ts b/tests/engine/render/canvas/silhouette-autopsy.test.ts index 5cadf5592..f0ec4c188 100644 --- a/tests/engine/render/canvas/silhouette-autopsy.test.ts +++ b/tests/engine/render/canvas/silhouette-autopsy.test.ts @@ -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') diff --git a/tools/package-quality/src/smoke.ts b/tools/package-quality/src/smoke.ts index 8d0b5dc91..47c5105df 100644 --- a/tools/package-quality/src/smoke.ts +++ b/tools/package-quality/src/smoke.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(