From 22a160bfc3439fce328aee232b690e7fdc992783 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 18 Jul 2026 01:51:13 +0300 Subject: [PATCH] refactor(fig): own NodeChange import conversion - Move NodeChange-to-SceneGraph property and style-run policy into @open-pencil/fig\n- Share normalized font-style parsing and model defaults through scene-graph instead of duplicating literals\n- Preserve core compatibility exports while conversion callers migrate --- CHANGELOG.md | 2 +- .../core/src/kiwi/fig/node-change/convert.ts | 1123 +---------------- .../src/kiwi/fig/node-change/style-runs.ts | 114 +- packages/core/src/text/face.ts | 42 +- packages/core/src/text/font-style.ts | 8 +- packages/fig/README.md | 7 +- packages/fig/src/node-change/convert.ts | 1112 ++++++++++++++++ packages/fig/src/node-change/index.ts | 2 + packages/fig/src/node-change/style-runs.ts | 109 ++ packages/scene-graph/src/font-style.ts | 42 + packages/scene-graph/src/index.ts | 2 + 11 files changed, 1283 insertions(+), 1280 deletions(-) create mode 100644 packages/fig/src/node-change/convert.ts create mode 100644 packages/fig/src/node-change/style-runs.ts create mode 100644 packages/scene-graph/src/font-style.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 19097c984..94e87f6fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Changed -- Move complete `.fig` archive parsing into `@open-pencil/fig`, keeping `@open-pencil/kiwi` focused on Kiwi schema, message, and raw container mechanics. +- Move complete `.fig` archive parsing and NodeChange-to-SceneGraph conversion policy into `@open-pencil/fig`, keeping `@open-pencil/kiwi` focused on Kiwi schema, message, and raw container mechanics. - Add Figma-style page management in the Pages panel, including rename/delete actions and drag-and-drop page reordering. - Add DOM/CSS import and authoring support so HTML, CSS, Tailwind, and JSX can be converted into editable OpenPencil documents from the app, CLI, and SDK. - Add Tailwind class serialization for DOM/CSS HTML export in the SDK and CLI. diff --git a/packages/core/src/kiwi/fig/node-change/convert.ts b/packages/core/src/kiwi/fig/node-change/convert.ts index 58b7c214d..88cc15b99 100644 --- a/packages/core/src/kiwi/fig/node-change/convert.ts +++ b/packages/core/src/kiwi/fig/node-change/convert.ts @@ -1,1122 +1 @@ -import { guidToString } from '@open-pencil/kiwi/fig/guid' -import { parseVariantName } from '@open-pencil/scene-graph/variant-name' - -/* eslint-disable max-lines -- kiwi↔scene conversion helpers are tightly coupled */ -import { DEFAULT_FONT_FAMILY, DEFAULT_STROKE_MITER_LIMIT } from '#core/constants' -import { styleToWeight } from '#core/text/fonts' - -import { importStyleRuns } from './style-runs' -export { importStyleRuns } from './style-runs' -import { - convertEffects, - convertFigmaDerivedTextGlyphs, - convertFills, - convertFontFeatures, - convertFontVariations, - convertLetterSpacing, - convertLineHeight, - convertStrokes, - extractBoundVariables, - extractExportSettings, - extractPluginData, - extractPluginRelaunchData, - getOpenPencilPluginValue, - LAYOUT_DIRECTION_PLUGIN_KEY, - NODE_TYPE_PLUGIN_KEY, - resolveGeometryPaths, - resolveVectorNetwork, - TEXT_DIRECTION_PLUGIN_KEY, - mapTextDecoration -} from '@open-pencil/fig/node-change' - -export { - convertEffects, - convertFills, - convertLetterSpacing, - convertLineHeight, - convertStrokes, - mapTextDecoration, - setVariableColorResolver -} from '@open-pencil/fig/node-change' -export { resolveGeometryPaths } from '@open-pencil/fig/node-change' - -import type { NodeChange } from '@open-pencil/kiwi/fig/codec' -import type { - SceneNode, - NodeType, - Fill, - StrokeCap, - StrokeJoin, - LayoutMode, - LayoutSizing, - LayoutAlign, - LayoutAlignSelf, - LayoutCounterAlign, - ConstraintType, - TextAutoResize, - TextAlignVertical, - TextCase, - ArcData, - LayoutGrid, - SharedStyleType, - VectorNetwork, - ComponentPropertyDefinition, - ComponentPropertyReference, - ComponentPropertyType, - SymbolLink, - VariantPropSpec -} from '@open-pencil/scene-graph' -import type { GUID } from '@open-pencil/scene-graph/primitives' - -export { guidToString, stringToGuid } from '@open-pencil/kiwi/fig/guid' - -export const VARIABLE_BINDING_FIELDS: Record = { - // Corner radius - cornerRadius: 'CORNER_RADIUS', - topLeftRadius: 'RECTANGLE_TOP_LEFT_CORNER_RADIUS', - topRightRadius: 'RECTANGLE_TOP_RIGHT_CORNER_RADIUS', - bottomLeftRadius: 'RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS', - bottomRightRadius: 'RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS', - // Stroke - strokeWeight: 'STROKE_WEIGHT', - borderTopWeight: 'BORDER_TOP_WEIGHT', - borderBottomWeight: 'BORDER_BOTTOM_WEIGHT', - borderLeftWeight: 'BORDER_LEFT_WEIGHT', - borderRightWeight: 'BORDER_RIGHT_WEIGHT', - // Auto-layout spacing & padding - itemSpacing: 'STACK_SPACING', - paddingLeft: 'STACK_PADDING_LEFT', - paddingTop: 'STACK_PADDING_TOP', - paddingRight: 'STACK_PADDING_RIGHT', - paddingBottom: 'STACK_PADDING_BOTTOM', - counterAxisSpacing: 'STACK_COUNTER_SPACING', - // Grid gaps - gridRowGap: 'GRID_ROW_GAP', - gridColumnGap: 'GRID_COLUMN_GAP', - // Visibility & opacity - visible: 'VISIBLE', - opacity: 'OPACITY', - // Dimensions - width: 'WIDTH', - height: 'HEIGHT', - minWidth: 'MIN_WIDTH', - maxWidth: 'MAX_WIDTH', - minHeight: 'MIN_HEIGHT', - maxHeight: 'MAX_HEIGHT', - // Position & rotation - x: 'X_POSITION', - y: 'Y_POSITION', - rotation: 'ROTATION', - // Text - fontSize: 'FONT_SIZE', - letterSpacing: 'LETTER_SPACING', - lineHeight: 'LINE_HEIGHT', - fontFamily: 'FONT_FAMILY' -} - -export const VARIABLE_BINDING_FIELDS_INVERSE: Record = Object.fromEntries( - Object.entries(VARIABLE_BINDING_FIELDS).map(([k, v]) => [v, k]) -) - -const NODE_TYPE_MAP: Record = { - DOCUMENT: 'DOCUMENT', - VARIABLE: 'VARIABLE', - CANVAS: 'CANVAS', - FRAME: 'FRAME', - RECTANGLE: 'RECTANGLE', - ROUNDED_RECTANGLE: 'ROUNDED_RECTANGLE', - ELLIPSE: 'ELLIPSE', - TEXT: 'TEXT', - LINE: 'LINE', - STAR: 'STAR', - REGULAR_POLYGON: 'POLYGON', - VECTOR: 'VECTOR', - BOOLEAN_OPERATION: 'BOOLEAN_OPERATION', - GROUP: 'GROUP', - SECTION: 'SECTION', - COMPONENT: 'COMPONENT', - COMPONENT_SET: 'COMPONENT_SET', - INSTANCE: 'INSTANCE', - SYMBOL: 'COMPONENT', - CONNECTOR: 'CONNECTOR', - SHAPE_WITH_TEXT: 'SHAPE_WITH_TEXT' -} - -function mapNodeType(type?: string): NodeType | 'DOCUMENT' | 'VARIABLE' { - if (type) return NODE_TYPE_MAP[type] ?? 'RECTANGLE' - return 'RECTANGLE' -} - -function mapBooleanOperation(nc: NodeChange): SceneNode['booleanOperation'] { - if (nc.type !== 'BOOLEAN_OPERATION') return undefined - const operation = nc.booleanOperation as NodeChange['booleanOperation'] | 'EXCLUDE' | undefined - switch (operation) { - case 'SUBTRACT': - case 'INTERSECT': - return operation - case 'EXCLUDE': - case 'XOR': - return 'EXCLUDE' - default: - return 'UNION' - } -} - -function mapStackMode(mode?: string): LayoutMode { - switch (mode) { - case 'HORIZONTAL': - return 'HORIZONTAL' - case 'VERTICAL': - return 'VERTICAL' - default: - return 'NONE' - } -} - -export function mapStackSizing(sizing?: string): LayoutSizing { - switch (sizing) { - case 'RESIZE_TO_FIT': - case 'RESIZE_TO_FIT_WITH_IMPLICIT_SIZE': - return 'HUG' - case 'FILL': - return 'FILL' - default: - return 'FIXED' - } -} - -export function mapStackJustify(justify?: string): LayoutAlign { - switch (justify) { - case 'CENTER': - return 'CENTER' - case 'MAX': - return 'MAX' - case 'SPACE_BETWEEN': - case 'SPACE_EVENLY': - return 'SPACE_BETWEEN' - default: - return 'MIN' - } -} - -export function mapStackCounterAlign(align?: string): LayoutCounterAlign { - switch (align) { - case 'CENTER': - return 'CENTER' - case 'MAX': - return 'MAX' - case 'STRETCH': - return 'STRETCH' - case 'BASELINE': - return 'BASELINE' - default: - return 'MIN' - } -} - -export function mapAlignSelf(align?: string): LayoutAlignSelf { - switch (align) { - case 'MIN': - return 'MIN' - case 'CENTER': - return 'CENTER' - case 'MAX': - return 'MAX' - case 'STRETCH': - return 'STRETCH' - case 'BASELINE': - return 'BASELINE' - default: - return 'AUTO' - } -} - -function mapConstraint(c?: string): ConstraintType { - switch (c) { - case 'CENTER': - return 'CENTER' - case 'MAX': - return 'MAX' - case 'STRETCH': - return 'STRETCH' - case 'SCALE': - return 'SCALE' - default: - return 'MIN' - } -} - -export function mapArcData(data?: Partial): ArcData | null { - if (!data) return null - return { - startingAngle: data.startingAngle ?? 0, - endingAngle: data.endingAngle ?? 2 * Math.PI, - innerRadius: data.innerRadius ?? 0 - } -} - -function convertTransformProps( - nc: NodeChange -): Pick { - const width = nc.size?.x ?? 100 - const height = nc.size?.y ?? 100 - - let x = nc.transform?.m02 ?? 0 - let y = nc.transform?.m12 ?? 0 - let rotation = 0 - let flipX = false - if (nc.transform) { - const t = nc.transform - const det = t.m00 * t.m11 - t.m01 * t.m10 - if (det < 0) flipX = true - const sx = flipX ? -1 : 1 - rotation = Math.atan2(t.m10 * sx, t.m00 * sx) * (180 / Math.PI) - - if (rotation !== 0 && !flipX) { - const radians = (rotation * Math.PI) / 180 - const cos = Math.cos(radians) - const sin = Math.sin(radians) - x = t.m02 - (width / 2) * (1 - cos) - sin * (height / 2) - y = t.m12 - (height / 2) * (1 - cos) + sin * (width / 2) - } else { - const corners = [ - { x: 0, y: 0 }, - { x: width, y: 0 }, - { x: 0, y: height }, - { x: width, y: height } - ].map((point) => ({ - x: t.m00 * point.x + t.m01 * point.y + t.m02, - y: t.m10 * point.x + t.m11 * point.y + t.m12 - })) - x = Math.min(...corners.map((point) => point.x)) - y = Math.min(...corners.map((point) => point.y)) - } - } - - return { x, y, width, height, rotation, flipX, flipY: false } -} - -function convertCornerProps( - nc: NodeChange -): Pick< - SceneNode, - | 'cornerRadius' - | 'topLeftRadius' - | 'topRightRadius' - | 'bottomRightRadius' - | 'bottomLeftRadius' - | 'independentCorners' - | 'cornerSmoothing' -> { - return { - cornerRadius: nc.cornerRadius ?? 0, - topLeftRadius: nc.rectangleTopLeftCornerRadius ?? nc.cornerRadius ?? 0, - topRightRadius: nc.rectangleTopRightCornerRadius ?? nc.cornerRadius ?? 0, - bottomRightRadius: nc.rectangleBottomRightCornerRadius ?? nc.cornerRadius ?? 0, - bottomLeftRadius: nc.rectangleBottomLeftCornerRadius ?? nc.cornerRadius ?? 0, - independentCorners: nc.rectangleCornerRadiiIndependent ?? false, - cornerSmoothing: nc.cornerSmoothing ?? 0 - } -} - -function importedTextLineHeight(nc: NodeChange): number | null { - const derivedLineHeight = nc.derivedTextData?.baselines?.[0]?.lineHeight - if (derivedLineHeight !== undefined && Number.isFinite(derivedLineHeight)) - return derivedLineHeight - return convertLineHeight(nc.lineHeight, nc.fontSize) -} - -type TextProps = Pick< - SceneNode, - | 'text' - | 'fontSize' - | 'fontFamily' - | 'fontWeight' - | 'italic' - | 'textAlignHorizontal' - | 'textAlignVertical' - | 'textAutoResize' - | 'textCase' - | 'textDecoration' - | 'textDecorationStyle' - | 'textDecorationThickness' - | 'textDecorationFills' - | 'leadingTrim' - | 'lineHeight' - | 'letterSpacing' - | 'maxLines' - | 'styleRuns' - | 'fontVariations' - | 'fontFeatures' - | 'textTruncation' - | 'textDirection' - | 'figmaDerivedLayout' - | 'figmaDerivedTextGlyphs' -> - -function convertTextDecorationProps( - nc: NodeChange -): Pick< - SceneNode, - | 'textDecoration' - | 'textDecorationStyle' - | 'textDecorationThickness' - | 'textDecorationFills' - | 'textDecorationSkipInk' - | 'textUnderlineOffset' -> { - return { - textDecoration: mapTextDecoration(nc.textDecoration as string), - textDecorationStyle: (nc.textDecorationStyle ?? 'SOLID') as SceneNode['textDecorationStyle'], - textDecorationThickness: nc.textDecorationThickness?.value ?? null, - textDecorationFills: convertFills(nc.textDecorationFillPaints), - textDecorationSkipInk: nc.textDecorationSkipInk ?? true, - textUnderlineOffset: nc.textUnderlineOffset?.value ?? null - } -} - -function convertTextProps(nc: NodeChange, blobs: Uint8Array[]): TextProps { - return { - text: nc.textData?.characters ?? '', - fontSize: nc.fontSize ?? 14, - fontFamily: nc.fontName?.family ?? DEFAULT_FONT_FAMILY, - fontWeight: styleToWeight(nc.fontName?.style ?? ''), - italic: nc.fontName?.style.toLowerCase().includes('italic') ?? false, - textAlignHorizontal: (nc.textAlignHorizontal ?? 'LEFT') as - | 'LEFT' - | 'CENTER' - | 'RIGHT' - | 'JUSTIFIED', - textAlignVertical: (nc.textAlignVertical ?? 'TOP') as TextAlignVertical, - textAutoResize: (nc.textAutoResize ?? 'NONE') as TextAutoResize, - textCase: (nc.textCase ?? 'ORIGINAL') as TextCase, - ...convertTextDecorationProps(nc), - leadingTrim: (nc.leadingTrim ?? 'NONE') as SceneNode['leadingTrim'], - lineHeight: importedTextLineHeight(nc), - letterSpacing: convertLetterSpacing(nc.letterSpacing, nc.fontSize), - maxLines: (nc.maxLines ?? null) as number | null, - styleRuns: importStyleRuns(nc), - fontVariations: convertFontVariations(nc), - fontFeatures: convertFontFeatures(nc), - textTruncation: (nc.textTruncation as string) === 'ENDING' ? 'ENDING' : 'DISABLED', - textDirection: - (getOpenPencilPluginValue(nc, TEXT_DIRECTION_PLUGIN_KEY) as - | SceneNode['textDirection'] - | null) || 'AUTO', - figmaDerivedLayout: nc.derivedTextData?.layoutSize - ? { - width: nc.derivedTextData.layoutSize.x, - height: nc.derivedTextData.layoutSize.y - } - : null, - figmaDerivedTextGlyphs: convertFigmaDerivedTextGlyphs(nc.derivedTextData, blobs) - } -} - -function convertLayoutPadding( - nc: NodeChange -): Pick { - return { - paddingTop: nc.stackVerticalPadding ?? nc.stackPadding ?? 0, - paddingBottom: nc.stackPaddingBottom ?? nc.stackVerticalPadding ?? nc.stackPadding ?? 0, - paddingLeft: nc.stackHorizontalPadding ?? nc.stackPadding ?? 0, - paddingRight: nc.stackPaddingRight ?? nc.stackHorizontalPadding ?? nc.stackPadding ?? 0 - } -} - -function visibleContainerDerivedLayout( - nc: NodeChange, - layoutMode: SceneNode['layoutMode'], - primaryAxisSizing: SceneNode['primaryAxisSizing'], - counterAxisSizing: SceneNode['counterAxisSizing'] -): SceneNode['figmaDerivedLayout'] | undefined { - const hasHugAxis = primaryAxisSizing === 'HUG' || counterAxisSizing === 'HUG' - const hasVisiblePaint = - (nc.fillPaints?.some((paint) => paint.visible !== false) ?? false) || - (nc.strokePaints?.some((paint) => paint.visible !== false) ?? false) - if (layoutMode === 'NONE' || !hasHugAxis || !hasVisiblePaint) return undefined - - return { - x: nc.transform?.m02 ?? 0, - y: nc.transform?.m12 ?? 0, - width: nc.size?.x ?? 100, - height: nc.size?.y ?? 100 - } -} - -function convertLayoutProps( - nc: NodeChange -): Pick< - SceneNode, - | 'layoutMode' - | 'itemSpacing' - | 'paddingTop' - | 'paddingBottom' - | 'paddingLeft' - | 'paddingRight' - | 'primaryAxisSizing' - | 'counterAxisSizing' - | 'primaryAxisAlign' - | 'counterAxisAlign' - | 'layoutWrap' - | 'counterAxisSpacing' - | 'layoutPositioning' - | 'layoutGrow' - | 'layoutAlignSelf' - | 'counterAxisAlignContent' - | 'itemReverseZIndex' - | 'strokesIncludedInLayout' - | 'layoutDirection' -> & - Partial> { - const layoutMode = mapStackMode(nc.stackMode) - const primaryAxisSizing = mapStackSizing(nc.stackPrimarySizing) - const counterAxisSizing = mapStackSizing(nc.stackCounterSizing) - const figmaDerivedLayout = visibleContainerDerivedLayout( - nc, - layoutMode, - primaryAxisSizing, - counterAxisSizing - ) - - return { - layoutMode, - itemSpacing: nc.stackSpacing ?? 0, - ...convertLayoutPadding(nc), - primaryAxisSizing, - counterAxisSizing, - primaryAxisAlign: mapStackJustify(nc.stackPrimaryAlignItems ?? nc.stackJustify), - counterAxisAlign: mapStackCounterAlign(nc.stackCounterAlignItems ?? nc.stackCounterAlign), - layoutWrap: nc.stackWrap === 'WRAP' ? 'WRAP' : 'NO_WRAP', - counterAxisSpacing: nc.stackCounterSpacing ?? 0, - layoutPositioning: nc.stackPositioning === 'ABSOLUTE' ? 'ABSOLUTE' : 'AUTO', - layoutGrow: nc.stackChildPrimaryGrow ?? 0, - layoutAlignSelf: mapAlignSelf(nc.stackChildAlignSelf), - counterAxisAlignContent: - (nc.stackCounterAlignContent as string) === 'SPACE_BETWEEN' ? 'SPACE_BETWEEN' : 'AUTO', - itemReverseZIndex: (nc.stackReverseZIndex ?? false) as boolean, - strokesIncludedInLayout: (nc.strokesIncludedInLayout ?? false) as boolean, - layoutDirection: - (getOpenPencilPluginValue(nc, LAYOUT_DIRECTION_PLUGIN_KEY) as - | SceneNode['layoutDirection'] - | null) || 'AUTO', - ...(figmaDerivedLayout ? { figmaDerivedLayout } : {}) - } -} - -function getVectorStrokeCap(nc: NodeChange, vectorNetwork: VectorNetwork | null): StrokeCap { - return (nc.strokeCap ?? - vectorNetwork?.vertices.find((v) => v.strokeCap)?.strokeCap ?? - 'NONE') as StrokeCap -} - -function getVectorStrokeJoin(nc: NodeChange, vectorNetwork: VectorNetwork | null): StrokeJoin { - return (nc.strokeJoin ?? - vectorNetwork?.vertices.find((v) => v.strokeJoin)?.strokeJoin ?? - 'MITER') as StrokeJoin -} - -function styleRefId(value: unknown): string | null { - if (!value || typeof value !== 'object' || !('guid' in value)) return null - const guid = value.guid - if (!guid || typeof guid !== 'object') return null - return guidToString(guid as GUID) -} - -function sharedStyleType(value: string | undefined): SharedStyleType | null { - if (value === 'FILL' || value === 'TEXT' || value === 'EFFECT' || value === 'GRID') return value - return null -} - -function convertLayoutGrids(value: unknown): LayoutGrid[] { - return Array.isArray(value) ? structuredClone(value as LayoutGrid[]) : [] -} - -function convertVectorAndStrokeProps(nc: NodeChange, blobs: Uint8Array[]) { - const vectorNetwork = resolveVectorNetwork(nc, blobs) - const strokeCap = getVectorStrokeCap(nc, vectorNetwork) - const strokeJoin = getVectorStrokeJoin(nc, vectorNetwork) - return { - vectorNetwork, - fillGeometry: resolveGeometryPaths(nc.fillGeometry, blobs), - strokeGeometry: resolveGeometryPaths(nc.strokeGeometry, blobs), - arcData: mapArcData(nc.arcData as Partial | undefined), - strokeCap, - strokeJoin, - dashPattern: nc.dashPattern ?? [], - borderTopWeight: (nc.borderTopWeight ?? 0) as number, - borderRightWeight: (nc.borderRightWeight ?? 0) as number, - borderBottomWeight: (nc.borderBottomWeight ?? 0) as number, - borderLeftWeight: (nc.borderLeftWeight ?? 0) as number, - independentStrokeWeights: (nc.borderStrokeWeightsIndependent ?? false) as boolean, - strokeMiterLimit: (nc.miterLimit ?? DEFAULT_STROKE_MITER_LIMIT) as number - } -} - -function resolveNodeType(nc: NodeChange): NodeType | 'DOCUMENT' | 'VARIABLE' { - const nodeType = mapNodeType(nc.type) - if ( - (nodeType === 'FRAME' && isComponentSet(nc)) || - getOpenPencilPluginValue(nc, NODE_TYPE_PLUGIN_KEY) === 'COMPONENT_SET' - ) { - return 'COMPONENT_SET' - } - // Figma stores plain groups as FRAME node-changes flagged with resizeToFit. - // Auto-layout "hug" frames instead use stackPrimarySizing/stackCounterSizing and - // always carry a stackMode, so guard on the absence of auto-layout — a real group - // never has one. This keeps component-sets and auto-layout frames from being - // misclassified as groups. - if ( - nodeType === 'FRAME' && - nc.resizeToFit === true && - (nc.stackMode === undefined || nc.stackMode === 'NONE') - ) { - return 'GROUP' - } - return nodeType -} - -function nearlyEqualSize(a: number | undefined, b: number | undefined): boolean { - return Math.abs((a ?? 0) - (b ?? 0)) <= 0.5 -} - -export function shouldImportTextAsAutoSize( - nc: NodeChange, - parentNc: NodeChange | undefined -): boolean { - if (nc.type !== 'TEXT' || nc.textAutoResize !== 'NONE') return false - if (parentNc?.stackMode !== 'HORIZONTAL' && parentNc?.stackMode !== 'VERTICAL') return false - if (!nc.textData?.characters) return false - const layoutSize = nc.derivedTextData?.layoutSize - if (!layoutSize || !nc.size) return false - return nearlyEqualSize(layoutSize.x, nc.size.x) && nearlyEqualSize(layoutSize.y, nc.size.y) -} - -export function nodeChangeToProps( - nc: NodeChange, - blobs: Uint8Array[] -): Partial & { nodeType: NodeType | 'DOCUMENT' | 'VARIABLE' } { - const nodeType = resolveNodeType(nc) - - const vectorAndStrokeProps = convertVectorAndStrokeProps(nc, blobs) - - return { - nodeType, - name: nc.name ?? nodeType, - source: extractSourceMetadata(nc, blobs), - ...convertTransformProps(nc), - opacity: nc.opacity ?? 1, - visible: nc.visible ?? true, - locked: nc.locked ?? false, - blendMode: (nc.blendMode as Fill['blendMode']) ?? 'PASS_THROUGH', - booleanOperation: mapBooleanOperation(nc), - fills: convertFills(nc.fillPaints), - strokes: convertStrokes( - nc.strokePaints, - nc.strokeWeight, - nc.strokeAlign, - vectorAndStrokeProps.strokeCap, - vectorAndStrokeProps.strokeJoin, - nc.dashPattern ?? [] - ), - effects: convertEffects(nc.effects), - layoutGrids: convertLayoutGrids(nc.layoutGrids), - fillStyleId: styleRefId(nc.styleIdForFill), - strokeStyleId: styleRefId(nc.styleIdForStrokeFill), - textStyleId: styleRefId(nc.styleIdForText), - effectStyleId: styleRefId(nc.styleIdForEffect), - gridStyleId: styleRefId(nc.styleIdForGrid), - sharedStyleType: sharedStyleType(nc.styleType), - ...convertCornerProps(nc), - ...convertTextProps(nc, blobs), - horizontalConstraint: mapConstraint(nc.horizontalConstraint as string), - verticalConstraint: mapConstraint(nc.verticalConstraint as string), - ...convertLayoutProps(nc), - ...vectorAndStrokeProps, - minWidth: (nc.minWidth ?? null) as number | null, - maxWidth: (nc.maxWidth ?? null) as number | null, - minHeight: (nc.minHeight ?? null) as number | null, - maxHeight: (nc.maxHeight ?? null) as number | null, - isMask: nc.mask ?? false, - maskType: (nc.maskType ?? 'ALPHA') as 'ALPHA' | 'VECTOR' | 'LUMINANCE', - maskIsOutline: nc.maskIsOutline ?? false, - expanded: true, - autoRename: (nc.autoRename ?? true) as boolean, - boundVariables: extractBoundVariables(nc), - exportSettings: extractExportSettings(nc), - pluginData: extractPluginData(nc), - pluginRelaunchData: extractPluginRelaunchData(nc), - clipsContent: nc.frameMaskDisabled === false && nc.resizeToFit !== true, - componentId: extractSymbolId(nc), - componentPropertyDefinitions: extractComponentPropertyDefs(nc), - componentPropertyReferences: extractComponentPropertyRefs(nc), - componentPropertyAssignments: extractComponentPropertyAssignments(nc), - componentPropertyValues: extractComponentPropertyValues(nc), - ...extractComponentMetadata(nc) - } -} - -const COMPONENT_PROP_TYPE_MAP: Record = { - VARIANT: 'VARIANT', - TEXT: 'TEXT', - BOOL: 'BOOLEAN', - BOOLEAN: 'BOOLEAN', - INSTANCE_SWAP: 'INSTANCE_SWAP' -} - -function componentPropValueToString(value: unknown): string { - if (!value || typeof value !== 'object') return '' - const propValue = value as { - boolValue?: boolean - textValue?: string | { characters?: string } - guidValue?: GUID - } - if (typeof propValue.boolValue === 'boolean') return String(propValue.boolValue) - if (typeof propValue.textValue === 'string') return propValue.textValue - if (propValue.textValue && typeof propValue.textValue === 'object') { - return propValue.textValue.characters ?? '' - } - return propValue.guidValue ? guidToString(propValue.guidValue) : '' -} - -interface RawComponentPropDef { - id?: GUID - name?: string - type?: string - initialValue?: unknown - preferredValues?: { - stringValues?: string[] - instanceSwapValues?: Array<{ key?: string }> - } -} - -interface RawComponentPropRef { - defID?: GUID - componentPropNodeField?: string | number - isDeleted?: boolean -} - -interface RawComponentPropValue { - boolValue?: boolean - textValue?: string | { characters?: string } - guidValue?: GUID -} - -interface RawComponentPropAssignment { - defID?: GUID - value?: RawComponentPropValue - varValue?: { - value?: { - boolValue?: boolean - textValue?: string - textDataValue?: { characters?: string } - symbolIdValue?: { guid?: GUID } - } - } -} - -interface RawSymbolData { - symbolOverrides?: unknown[] - uniformScaleFactor?: number -} - -function extractComponentPropertyDefs(nc: NodeChange): ComponentPropertyDefinition[] { - const defs = nc.componentPropDefs as RawComponentPropDef[] | undefined - if (!defs?.length) return [] - const result: ComponentPropertyDefinition[] = [] - for (const def of defs) { - if (!def.id || !def.name) continue - const propType = COMPONENT_PROP_TYPE_MAP[def.type ?? ''] ?? 'VARIANT' - result.push({ - id: guidToString(def.id), - name: def.name, - type: propType, - defaultValue: componentPropValueToString(def.initialValue), - variantOptions: propType === 'VARIANT' ? def.preferredValues?.stringValues : undefined, - preferredValues: - propType === 'INSTANCE_SWAP' - ? def.preferredValues?.instanceSwapValues - ?.map((value) => value.key) - .filter((value): value is string => value !== undefined) - : undefined - }) - } - return result -} - -function extractComponentPropertyRefs(nc: NodeChange): ComponentPropertyReference[] { - const refs = nc.componentPropRefs as RawComponentPropRef[] | undefined - if (!refs?.length) return [] - const fieldMap: Record = { - '0': 'VISIBLE', - '1': 'TEXT', - '2': 'INSTANCE_SWAP', - VISIBLE: 'VISIBLE', - TEXT_DATA: 'TEXT', - OVERRIDDEN_SYMBOL_ID: 'INSTANCE_SWAP' - } - return refs.flatMap((ref) => { - const field = fieldMap[String(ref.componentPropNodeField)] - return ref.defID && field && !ref.isDeleted - ? [{ propertyId: guidToString(ref.defID), field }] - : [] - }) -} - -function componentPropertyAssignmentValue(assignment: RawComponentPropAssignment): string { - if ( - assignment.value && - (assignment.value.boolValue !== undefined || - assignment.value.textValue !== undefined || - assignment.value.guidValue !== undefined) - ) { - return componentPropValueToString(assignment.value) - } - const variableValue = assignment.varValue?.value - if (variableValue?.symbolIdValue?.guid) return guidToString(variableValue.symbolIdValue.guid) - if (variableValue?.boolValue !== undefined) return String(variableValue.boolValue) - if (variableValue?.textValue !== undefined) return variableValue.textValue - return variableValue?.textDataValue?.characters ?? '' -} - -function extractComponentPropertyAssignments(nc: NodeChange): Record { - const assignments = nc.componentPropAssignments as RawComponentPropAssignment[] | undefined - if (!assignments?.length) return {} - return Object.fromEntries( - assignments.flatMap((assignment) => - assignment.defID - ? [[guidToString(assignment.defID), componentPropertyAssignmentValue(assignment)]] - : [] - ) - ) -} - -function extractVariantPropSpecs(nc: NodeChange): VariantPropSpec[] { - const specs = nc.variantPropSpecs as Array<{ propDefId?: GUID; value?: string }> | undefined - if (!specs?.length) return [] - return specs - .filter((spec): spec is { propDefId: GUID; value?: string } => !!spec.propDefId) - .map((spec) => ({ propDefId: guidToString(spec.propDefId), value: spec.value ?? '' })) -} - -function extractComponentPropertyValues(nc: NodeChange): Record { - const specs = extractVariantPropSpecs(nc) - const defs = new Map(extractComponentPropertyDefs(nc).map((def) => [def.id, def.name])) - if (specs.length > 0 && defs.size > 0) { - const values: Record = {} - for (const spec of specs) values[defs.get(spec.propDefId) ?? spec.propDefId] = spec.value - return values - } - - const name = nc.name - if (!name?.includes('=')) return {} - return parseVariantName(name) -} - -type ComponentMetadataProps = Pick< - SceneNode, - | 'componentKey' - | 'sourceLibraryKey' - | 'publishId' - | 'overrideKey' - | 'sharedSymbolVersion' - | 'publishedVersion' - | 'isPublishable' - | 'isSymbolPublishable' - | 'symbolDescription' - | 'symbolLinks' - | 'variantPropSpecs' -> - -function guidToStringOrNull(value: unknown): string | null { - if (!value || typeof value !== 'object') return null - const guid = value as Partial - if (typeof guid.sessionID !== 'number' || typeof guid.localID !== 'number') return null - return guidToString({ sessionID: guid.sessionID, localID: guid.localID }) -} - -function stringOrNull(value: unknown): string | null { - return typeof value === 'string' ? value : null -} - -function stringOrEmpty(value: unknown): string { - return typeof value === 'string' ? value : '' -} - -function booleanOrFalse(value: unknown): boolean { - return typeof value === 'boolean' ? value : false -} - -function extractComponentMetadata(nc: NodeChange): ComponentMetadataProps { - const symbolLinks = (nc.symbolLinks as Array> | undefined) ?? [] - return { - componentKey: stringOrNull(nc.componentKey), - sourceLibraryKey: stringOrNull(nc.sourceLibraryKey), - publishId: guidToStringOrNull(nc.publishID), - overrideKey: guidToStringOrNull(nc.overrideKey), - sharedSymbolVersion: stringOrNull(nc.sharedSymbolVersion), - publishedVersion: stringOrNull(nc.publishedVersion), - isPublishable: booleanOrFalse(nc.isPublishable), - isSymbolPublishable: booleanOrFalse(nc.isSymbolPublishable), - symbolDescription: stringOrEmpty(nc.symbolDescription), - symbolLinks: symbolLinks - .filter((link): link is SymbolLink => typeof link.uri === 'string') - .map((link) => ({ - uri: link.uri, - displayName: link.displayName, - displayText: link.displayText - })), - variantPropSpecs: extractVariantPropSpecs(nc) - } -} - -function isComponentSet(nc: NodeChange): boolean { - const defs = nc.componentPropDefs as Array<{ type?: string }> | undefined - if (!defs?.length) return false - return defs.some((d) => d.type === 'VARIANT') -} - -function extractFigmaLayoutMetadata(nc: NodeChange): SceneNode['source']['fig']['layout'] { - return { - stackMode: nc.stackMode, - stackSpacing: nc.stackSpacing, - stackPadding: nc.stackPadding, - stackPaddingRight: nc.stackPaddingRight, - stackPaddingBottom: nc.stackPaddingBottom, - stackCounterAlign: nc.stackCounterAlign, - stackJustify: nc.stackJustify, - stackCounterAlignItems: nc.stackCounterAlignItems, - stackPrimaryAlignItems: nc.stackPrimaryAlignItems, - stackPrimarySizing: nc.stackPrimarySizing, - stackCounterSizing: nc.stackCounterSizing, - stackVerticalPadding: nc.stackVerticalPadding, - stackHorizontalPadding: nc.stackHorizontalPadding, - stackWrap: nc.stackWrap, - stackPositioning: nc.stackPositioning, - stackChildPrimaryGrow: nc.stackChildPrimaryGrow, - stackChildAlignSelf: nc.stackChildAlignSelf, - stackCounterSpacing: nc.stackCounterSpacing, - bordersTakeSpace: nc.bordersTakeSpace as boolean | undefined, - stackReverseZIndex: nc.stackReverseZIndex as boolean | undefined - } -} - -function extractSourceMetadata(nc: NodeChange, blobs: Uint8Array[]): SceneNode['source'] { - return { - format: 'fig', - id: nc.guid ? guidToString(nc.guid) : null, - orderKey: nc.parentIndex?.position ?? null, - fig: { - ...extractFigmaRawGeometry(nc, blobs), - ...extractFigmaSymbolMetadata(nc, blobs), - layout: extractFigmaLayoutMetadata(nc) - } - } -} - -export function sortChildren( - children: string[], - parentNc: NodeChange, - nodeMap: Map -): void { - // Always sort by parentIndex.position first (canonical tree order) - const stackMode = parentNc.stackMode as string | undefined - const isHorizontal = stackMode === 'HORIZONTAL' - const isVertical = stackMode === 'VERTICAL' - - children.sort((a, b) => { - const aPos = nodeMap.get(a)?.parentIndex?.position ?? '' - const bPos = nodeMap.get(b)?.parentIndex?.position ?? '' - // Primary sort: parentIndex.position (exact tree order) - if (aPos < bPos) return -1 - if (aPos > bPos) return 1 - - // Tiebreaker for auto-layout: sort by transform position - if (isHorizontal || isVertical) { - const axis = isHorizontal ? 'm02' : 'm12' - const aT = nodeMap.get(a)?.transform?.[axis] ?? 0 - const bT = nodeMap.get(b)?.transform?.[axis] ?? 0 - if (aT !== bT) return aT - bT - } - - return 0 - }) -} - -interface PreservedFigmaBlob { - __openPencilFigmaBlob: Uint8Array -} - -function preserveFigmaPayloadBlobs(value: unknown, blobs: Uint8Array[]): unknown { - if (value instanceof Uint8Array) return value - if (Array.isArray(value)) return value.map((item) => preserveFigmaPayloadBlobs(item, blobs)) - if (!value || typeof value !== 'object') return value - const result: Record = {} - for (const [key, child] of Object.entries(value)) { - if ((key === 'commandsBlob' || key === 'vectorNetworkBlob') && typeof child === 'number') { - const blob: unknown = blobs[child] - if (blob == null) { - result[key] = child - } else { - result[key] = { - __openPencilFigmaBlob: - blob instanceof Uint8Array - ? blob - : new Uint8Array(Object.values(blob as Record)) - } satisfies PreservedFigmaBlob - } - } else { - result[key] = preserveFigmaPayloadBlobs(child, blobs) - } - } - return result -} - -export const FIGMA_RAW_NODE_FIELD_KEYS = [ - 'styleIdForFill', - 'styleIdForStrokeFill', - 'styleIdForText', - 'styleIdForEffect', - 'styleIdForGrid', - 'styleType', - 'componentPropAssignments', - 'backgroundPaints', - 'layoutGrids', - 'exportSettings', - 'componentPropDefs', - 'componentPropRefs', - 'variantPropSpecs', - 'stateGroupPropertyValueOrders', - 'isStateGroup', - 'version', - 'sourceLibraryKey', - 'userFacingVersion', - 'description', - 'key', - 'sortPosition', - 'detachedSymbolId', - 'documentColorProfile', - 'variableConsumptionMap', - 'variableModeBySetMap', - 'parameterConsumptionMap', - 'editInfo', - 'backgroundColor', - 'pageType', - 'isPageDivider', - 'guides', - 'handoffStatusMap', - 'annotationCategories', - 'miterLimit', - 'mask', - 'maskType', - 'maskIsOutline', - 'strokeWeight', - 'strokeJoin', - 'borderStrokeWeightsIndependent', - 'borderTopWeight', - 'borderRightWeight', - 'borderBottomWeight', - 'borderLeftWeight', - 'minSize', - 'maxSize', - 'targetAspectRatio', - 'gridRows', - 'gridColumns', - 'gridRowAnchor', - 'gridColumnAnchor', - 'gridColumnsSizing', - 'gridRowsSizing', - 'gridChildVerticalAlign', - 'gridChildHorizontalAlign', - 'textAutoResize', - 'textData', - 'lineHeight', - 'fontName', - 'fontSize', - 'letterSpacing', - 'textTracking', - 'fontVersion', - 'textUserLayoutVersion', - 'textExplicitLayoutVersion', - 'fontVariations', - 'fontVariantCommonLigatures', - 'fontVariantContextualLigatures', - 'toggledOnOTFeatures', - 'toggledOffOTFeatures', - 'leadingTrim', - 'textDecorationFillPaints', - 'textUnderlineOffset', - 'textDecorationThickness', - 'textDecorationStyle', - 'semanticWeight', - 'semanticItalic', - 'maxLines', - 'textPathStart', - 'derivedTextData', - 'fillPaints', - 'strokePaints', - 'effects', - 'sectionStatusInfo', - 'prototypeStartNodeID', - 'prototypeInteractions', - 'transitionInfo', - 'codeSyntax', - 'lockMode', - 'slideThemeMap', - 'isSoftDeleted', - 'brushType', - 'scatterStrokeSettings', - 'vectorOperationVersion', - 'vectorData', - 'fillGeometry', - 'strokeGeometry' -] as const satisfies readonly (keyof NodeChange)[] - -function extractFigmaRawGeometry( - nc: NodeChange, - blobs: Uint8Array[] -): Pick { - const rawNodeFields: Record = {} - for (const key of FIGMA_RAW_NODE_FIELD_KEYS) { - const value = nc[key] - if (value !== undefined) rawNodeFields[key] = preserveFigmaPayloadBlobs(value, blobs) - } - return { - rawSize: nc.size ? { ...nc.size } : null, - rawTransform: nc.transform ? { ...nc.transform } : null, - rawNodeFields - } -} - -function extractFigmaSymbolMetadata( - nc: NodeChange, - blobs: Uint8Array[] -): Pick< - SceneNode['source']['fig'], - | 'symbolOverrides' - | 'componentPropAssignments' - | 'derivedSymbolData' - | 'derivedSymbolDataLayoutVersion' - | 'uniformScaleFactor' -> { - const sd = nc.symbolData as RawSymbolData | undefined - return { - symbolOverrides: preserveFigmaPayloadBlobs(sd?.symbolOverrides ?? [], blobs) as unknown[], - componentPropAssignments: preserveFigmaPayloadBlobs( - nc.componentPropAssignments ?? [], - blobs - ) as unknown[], - derivedSymbolData: preserveFigmaPayloadBlobs(nc.derivedSymbolData ?? [], blobs) as unknown[], - derivedSymbolDataLayoutVersion: - typeof nc.derivedSymbolDataLayoutVersion === 'number' - ? nc.derivedSymbolDataLayoutVersion - : null, - uniformScaleFactor: typeof sd?.uniformScaleFactor === 'number' ? sd.uniformScaleFactor : null - } -} - -function extractSymbolId(nc: NodeChange): string { - const sd = nc.symbolData as { symbolID?: GUID } | undefined - if (!sd?.symbolID) return '' - return guidToString(sd.symbolID) -} +export * from '@open-pencil/fig/node-change' diff --git a/packages/core/src/kiwi/fig/node-change/style-runs.ts b/packages/core/src/kiwi/fig/node-change/style-runs.ts index fea68afcb..0c28c41af 100644 --- a/packages/core/src/kiwi/fig/node-change/style-runs.ts +++ b/packages/core/src/kiwi/fig/node-change/style-runs.ts @@ -1,113 +1 @@ -import { - convertFontFeatures, - convertFontVariations, - convertFills, - convertLetterSpacing, - convertLineHeight, - mapTextDecoration -} from '@open-pencil/fig/node-change' -import type { NodeChange } from '@open-pencil/kiwi/fig/codec' -import type { CharacterStyleOverride, StyleRun } from '@open-pencil/scene-graph' - -import { styleToWeight } from '#core/text/fonts' - -function applyTextDecorationOverride(style: CharacterStyleOverride, override: NodeChange): void { - const deco = override.textDecoration - if (deco) style.textDecoration = mapTextDecoration(deco) - if (override.textDecorationStyle) - style.textDecorationStyle = - override.textDecorationStyle as CharacterStyleOverride['textDecorationStyle'] - if (override.textDecorationThickness) - style.textDecorationThickness = override.textDecorationThickness.value ?? null - if (override.textDecorationSkipInk !== undefined) - style.textDecorationSkipInk = override.textDecorationSkipInk - if (override.textUnderlineOffset) - style.textUnderlineOffset = override.textUnderlineOffset.value ?? null - if (override.textDecorationFillPaints) { - const decorationFills = convertFills(override.textDecorationFillPaints) - if (decorationFills.length > 0) style.textDecorationFills = decorationFills - } -} - -function convertStyleOverride( - override: NodeChange, - fallbackFontSize: number | undefined -): CharacterStyleOverride { - const style: CharacterStyleOverride = {} - if (override.fontName) { - style.fontFamily = override.fontName.family - style.fontWeight = styleToWeight(override.fontName.style) - style.italic = override.fontName.style.toLowerCase().includes('italic') - } - if (override.fontSize !== undefined) style.fontSize = override.fontSize - const fontVariations = convertFontVariations(override) - if (fontVariations.length > 0) style.fontVariations = fontVariations - const fontFeatures = convertFontFeatures(override) - if (fontFeatures.length > 0) style.fontFeatures = fontFeatures - if (override.letterSpacing) { - style.letterSpacing = convertLetterSpacing( - override.letterSpacing, - override.fontSize ?? fallbackFontSize - ) - } - if (override.lineHeight) { - const lh = convertLineHeight(override.lineHeight, override.fontSize ?? fallbackFontSize) - if (lh != null) style.lineHeight = lh - } - applyTextDecorationOverride(style, override) - if (override.fillPaints) { - const fills = convertFills(override.fillPaints) - if (fills.length > 0) style.fills = fills - } - return style -} - -function buildStyleMap( - table: NodeChange[], - fallbackFontSize: number | undefined -): Map { - const styleMap = new Map() - for (const override of table) { - const id = override.styleID as number | undefined - if (id === undefined) continue - const style = convertStyleOverride(override, fallbackFontSize) - if (Object.keys(style).length > 0) styleMap.set(id, style) - } - return styleMap -} - -function collectStyleRuns( - ids: number[], - styleMap: Map -): StyleRun[] { - const runs: StyleRun[] = [] - let currentId = ids[0] - let start = 0 - - for (let i = 1; i <= ids.length; i++) { - if (i === ids.length || ids[i] !== currentId) { - if (currentId !== 0) { - const style = styleMap.get(currentId) - if (style) runs.push({ start, length: i - start, style }) - } - if (i < ids.length) { - currentId = ids[i] - start = i - } - } - } - return runs -} - -export function importStyleRuns(nc: NodeChange): StyleRun[] { - const td = nc.textData - if (!td?.characterStyleIDs || !td.styleOverrideTable) return [] - - const ids = td.characterStyleIDs - if (ids.length === 0 || td.styleOverrideTable.length === 0) return [] - - const styleMap = buildStyleMap(td.styleOverrideTable, nc.fontSize) - if (styleMap.size === 0) return [] - - return collectStyleRuns(ids, styleMap) -} +export { importStyleRuns } from '@open-pencil/fig/node-change' diff --git a/packages/core/src/text/face.ts b/packages/core/src/text/face.ts index e3c758da3..5334484b1 100644 --- a/packages/core/src/text/face.ts +++ b/packages/core/src/text/face.ts @@ -1,7 +1,8 @@ -export interface ParsedFontStyle { - weight: number - italic: boolean -} +import { parseFontStyle } from '@open-pencil/scene-graph' +import type { ParsedFontStyle } from '@open-pencil/scene-graph' + +export { normalizeFontStyleName, parseFontStyle, styleToWeight } from '@open-pencil/scene-graph' +export type { ParsedFontStyle } from '@open-pencil/scene-graph' export interface FontFaceRef extends ParsedFontStyle { family: string @@ -9,39 +10,6 @@ export interface FontFaceRef extends ParsedFontStyle { postscriptName?: string } -const FONT_WEIGHT_ALIASES = [ - { weight: 100, names: ['thin', 'hairline', 'extrathin', 'ultrathin'] }, - { weight: 200, names: ['extralight', 'ultralight'] }, - { weight: 300, names: ['light'] }, - { weight: 400, names: ['regular', 'normal', 'book', 'roman', 'plain'] }, - { weight: 500, names: ['medium'] }, - { weight: 600, names: ['semibold', 'demibold'] }, - { weight: 700, names: ['bold'] }, - { weight: 800, names: ['extrabold', 'ultrabold'] }, - { weight: 900, names: ['black', 'heavy'] } -] as const - -const FONT_WEIGHT_BY_STYLE: ReadonlyMap = new Map( - FONT_WEIGHT_ALIASES.flatMap(({ names, weight }) => names.map((name) => [name, weight] as const)) -) - -export function normalizeFontStyleName(style: string): string { - return style - .toLowerCase() - .replace(/italic|oblique/u, '') - .replace(/[^a-z0-9]+/gu, '') -} - -export function parseFontStyle(style: string | undefined): ParsedFontStyle { - const raw = style ?? '' - const italic = /(?:italic|oblique)/iu.test(raw) - const normalized = normalizeFontStyleName(raw) - const numericWeight = normalized.match(/(?:^|[^0-9])([1-9]00)(?:[^0-9]|$)/u)?.[1] - if (numericWeight) return { weight: Number(numericWeight), italic } - - return { weight: FONT_WEIGHT_BY_STYLE.get(normalized) ?? 400, italic } -} - export function fontFaceFromFigmaFontName(fontName: { family?: string style?: string diff --git a/packages/core/src/text/font-style.ts b/packages/core/src/text/font-style.ts index fb7ea6af7..aa605463d 100644 --- a/packages/core/src/text/font-style.ts +++ b/packages/core/src/text/font-style.ts @@ -1,5 +1,9 @@ +import { styleToWeight } from '@open-pencil/scene-graph' + import { parseFontStyle } from '#core/text/face' +export { styleToWeight } + interface LocalFontMatch { family: string style: string @@ -79,10 +83,6 @@ export function isVariableFont(data: ArrayBuffer): boolean { return false } -export function styleToWeight(style: string): number { - return parseFontStyle(style).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, '') diff --git a/packages/fig/README.md b/packages/fig/README.md index a9165c82f..b35ad9796 100644 --- a/packages/fig/README.md +++ b/packages/fig/README.md @@ -13,14 +13,15 @@ Current ownership: - Canvas payload and image resource handling - `readFigContainer()` / `writeFigContainer()` helpers for raw `fig-kiwi` payloads - `.fig` source and archive result types -- Dependency-free NodeChange policy helpers for styles, plugin metadata, text values, and font axes/features through `@open-pencil/fig/node-change` +- NodeChange-to-SceneGraph property conversion, including styles, plugin metadata, text, paint, vector, and font policy, through `@open-pencil/fig/node-change` +- Package-local archive, conversion, and dist smoke tests Planned ownership: -- SceneGraph ⇄ Figma `NodeChange` conversion +- Remaining SceneGraph-to-`NodeChange` export conversion and document orchestration - Raw Figma metadata precedence and invalidation policy - Component and instance interpretation -- Oracle-backed `.fig` fixtures and package-local tests +- Oracle-backed `.fig` fixtures Non-goals: diff --git a/packages/fig/src/node-change/convert.ts b/packages/fig/src/node-change/convert.ts new file mode 100644 index 000000000..6ad2ff1b0 --- /dev/null +++ b/packages/fig/src/node-change/convert.ts @@ -0,0 +1,1112 @@ +import { guidToString } from '@open-pencil/kiwi/fig/guid' +import { + DEFAULT_FONT_FAMILY, + DEFAULT_STROKE_MITER_LIMIT, + styleToWeight +} from '@open-pencil/scene-graph' +import { parseVariantName } from '@open-pencil/scene-graph/variant-name' +/* eslint-disable max-lines -- kiwi↔scene conversion helpers are tightly coupled */ + +import { convertFigmaDerivedTextGlyphs } from './derived-text-glyphs' +import { convertFontFeatures } from './font/features' +import { convertFontVariations } from './font/variations' +import { convertEffects, convertFills, convertStrokes } from './paint' +import { + extractBoundVariables, + extractExportSettings, + extractPluginData, + extractPluginRelaunchData, + getOpenPencilPluginValue, + LAYOUT_DIRECTION_PLUGIN_KEY, + NODE_TYPE_PLUGIN_KEY, + TEXT_DIRECTION_PLUGIN_KEY +} from './plugin-data' +import { importStyleRuns } from './style-runs' +import { convertLetterSpacing, convertLineHeight, mapTextDecoration } from './text-values' +import { resolveGeometryPaths, resolveVectorNetwork } from './vector-geometry' + +export { convertEffects, convertFills, convertStrokes, setVariableColorResolver } from './paint' +export { importStyleRuns } from './style-runs' +export { convertLetterSpacing, convertLineHeight, mapTextDecoration } from './text-values' +export { resolveGeometryPaths } from './vector-geometry' + +import type { NodeChange } from '@open-pencil/kiwi/fig/codec' +import type { + SceneNode, + NodeType, + Fill, + StrokeCap, + StrokeJoin, + LayoutMode, + LayoutSizing, + LayoutAlign, + LayoutAlignSelf, + LayoutCounterAlign, + ConstraintType, + TextAutoResize, + TextAlignVertical, + TextCase, + ArcData, + LayoutGrid, + SharedStyleType, + VectorNetwork, + ComponentPropertyDefinition, + ComponentPropertyReference, + ComponentPropertyType, + SymbolLink, + VariantPropSpec +} from '@open-pencil/scene-graph' +import type { GUID } from '@open-pencil/scene-graph/primitives' + +export { guidToString, stringToGuid } from '@open-pencil/kiwi/fig/guid' + +export const VARIABLE_BINDING_FIELDS: Record = { + // Corner radius + cornerRadius: 'CORNER_RADIUS', + topLeftRadius: 'RECTANGLE_TOP_LEFT_CORNER_RADIUS', + topRightRadius: 'RECTANGLE_TOP_RIGHT_CORNER_RADIUS', + bottomLeftRadius: 'RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS', + bottomRightRadius: 'RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS', + // Stroke + strokeWeight: 'STROKE_WEIGHT', + borderTopWeight: 'BORDER_TOP_WEIGHT', + borderBottomWeight: 'BORDER_BOTTOM_WEIGHT', + borderLeftWeight: 'BORDER_LEFT_WEIGHT', + borderRightWeight: 'BORDER_RIGHT_WEIGHT', + // Auto-layout spacing & padding + itemSpacing: 'STACK_SPACING', + paddingLeft: 'STACK_PADDING_LEFT', + paddingTop: 'STACK_PADDING_TOP', + paddingRight: 'STACK_PADDING_RIGHT', + paddingBottom: 'STACK_PADDING_BOTTOM', + counterAxisSpacing: 'STACK_COUNTER_SPACING', + // Grid gaps + gridRowGap: 'GRID_ROW_GAP', + gridColumnGap: 'GRID_COLUMN_GAP', + // Visibility & opacity + visible: 'VISIBLE', + opacity: 'OPACITY', + // Dimensions + width: 'WIDTH', + height: 'HEIGHT', + minWidth: 'MIN_WIDTH', + maxWidth: 'MAX_WIDTH', + minHeight: 'MIN_HEIGHT', + maxHeight: 'MAX_HEIGHT', + // Position & rotation + x: 'X_POSITION', + y: 'Y_POSITION', + rotation: 'ROTATION', + // Text + fontSize: 'FONT_SIZE', + letterSpacing: 'LETTER_SPACING', + lineHeight: 'LINE_HEIGHT', + fontFamily: 'FONT_FAMILY' +} + +export const VARIABLE_BINDING_FIELDS_INVERSE: Record = Object.fromEntries( + Object.entries(VARIABLE_BINDING_FIELDS).map(([k, v]) => [v, k]) +) + +const NODE_TYPE_MAP: Record = { + DOCUMENT: 'DOCUMENT', + VARIABLE: 'VARIABLE', + CANVAS: 'CANVAS', + FRAME: 'FRAME', + RECTANGLE: 'RECTANGLE', + ROUNDED_RECTANGLE: 'ROUNDED_RECTANGLE', + ELLIPSE: 'ELLIPSE', + TEXT: 'TEXT', + LINE: 'LINE', + STAR: 'STAR', + REGULAR_POLYGON: 'POLYGON', + VECTOR: 'VECTOR', + BOOLEAN_OPERATION: 'BOOLEAN_OPERATION', + GROUP: 'GROUP', + SECTION: 'SECTION', + COMPONENT: 'COMPONENT', + COMPONENT_SET: 'COMPONENT_SET', + INSTANCE: 'INSTANCE', + SYMBOL: 'COMPONENT', + CONNECTOR: 'CONNECTOR', + SHAPE_WITH_TEXT: 'SHAPE_WITH_TEXT' +} + +function mapNodeType(type?: string): NodeType | 'DOCUMENT' | 'VARIABLE' { + if (type) return NODE_TYPE_MAP[type] ?? 'RECTANGLE' + return 'RECTANGLE' +} + +function mapBooleanOperation(nc: NodeChange): SceneNode['booleanOperation'] { + if (nc.type !== 'BOOLEAN_OPERATION') return undefined + const operation = nc.booleanOperation as NodeChange['booleanOperation'] | 'EXCLUDE' | undefined + switch (operation) { + case 'SUBTRACT': + case 'INTERSECT': + return operation + case 'EXCLUDE': + case 'XOR': + return 'EXCLUDE' + default: + return 'UNION' + } +} + +function mapStackMode(mode?: string): LayoutMode { + switch (mode) { + case 'HORIZONTAL': + return 'HORIZONTAL' + case 'VERTICAL': + return 'VERTICAL' + default: + return 'NONE' + } +} + +export function mapStackSizing(sizing?: string): LayoutSizing { + switch (sizing) { + case 'RESIZE_TO_FIT': + case 'RESIZE_TO_FIT_WITH_IMPLICIT_SIZE': + return 'HUG' + case 'FILL': + return 'FILL' + default: + return 'FIXED' + } +} + +export function mapStackJustify(justify?: string): LayoutAlign { + switch (justify) { + case 'CENTER': + return 'CENTER' + case 'MAX': + return 'MAX' + case 'SPACE_BETWEEN': + case 'SPACE_EVENLY': + return 'SPACE_BETWEEN' + default: + return 'MIN' + } +} + +export function mapStackCounterAlign(align?: string): LayoutCounterAlign { + switch (align) { + case 'CENTER': + return 'CENTER' + case 'MAX': + return 'MAX' + case 'STRETCH': + return 'STRETCH' + case 'BASELINE': + return 'BASELINE' + default: + return 'MIN' + } +} + +export function mapAlignSelf(align?: string): LayoutAlignSelf { + switch (align) { + case 'MIN': + return 'MIN' + case 'CENTER': + return 'CENTER' + case 'MAX': + return 'MAX' + case 'STRETCH': + return 'STRETCH' + case 'BASELINE': + return 'BASELINE' + default: + return 'AUTO' + } +} + +function mapConstraint(c?: string): ConstraintType { + switch (c) { + case 'CENTER': + return 'CENTER' + case 'MAX': + return 'MAX' + case 'STRETCH': + return 'STRETCH' + case 'SCALE': + return 'SCALE' + default: + return 'MIN' + } +} + +export function mapArcData(data?: Partial): ArcData | null { + if (!data) return null + return { + startingAngle: data.startingAngle ?? 0, + endingAngle: data.endingAngle ?? 2 * Math.PI, + innerRadius: data.innerRadius ?? 0 + } +} + +function convertTransformProps( + nc: NodeChange +): Pick { + const width = nc.size?.x ?? 100 + const height = nc.size?.y ?? 100 + + let x = nc.transform?.m02 ?? 0 + let y = nc.transform?.m12 ?? 0 + let rotation = 0 + let flipX = false + if (nc.transform) { + const t = nc.transform + const det = t.m00 * t.m11 - t.m01 * t.m10 + if (det < 0) flipX = true + const sx = flipX ? -1 : 1 + rotation = Math.atan2(t.m10 * sx, t.m00 * sx) * (180 / Math.PI) + + if (rotation !== 0 && !flipX) { + const radians = (rotation * Math.PI) / 180 + const cos = Math.cos(radians) + const sin = Math.sin(radians) + x = t.m02 - (width / 2) * (1 - cos) - sin * (height / 2) + y = t.m12 - (height / 2) * (1 - cos) + sin * (width / 2) + } else { + const corners = [ + { x: 0, y: 0 }, + { x: width, y: 0 }, + { x: 0, y: height }, + { x: width, y: height } + ].map((point) => ({ + x: t.m00 * point.x + t.m01 * point.y + t.m02, + y: t.m10 * point.x + t.m11 * point.y + t.m12 + })) + x = Math.min(...corners.map((point) => point.x)) + y = Math.min(...corners.map((point) => point.y)) + } + } + + return { x, y, width, height, rotation, flipX, flipY: false } +} + +function convertCornerProps( + nc: NodeChange +): Pick< + SceneNode, + | 'cornerRadius' + | 'topLeftRadius' + | 'topRightRadius' + | 'bottomRightRadius' + | 'bottomLeftRadius' + | 'independentCorners' + | 'cornerSmoothing' +> { + return { + cornerRadius: nc.cornerRadius ?? 0, + topLeftRadius: nc.rectangleTopLeftCornerRadius ?? nc.cornerRadius ?? 0, + topRightRadius: nc.rectangleTopRightCornerRadius ?? nc.cornerRadius ?? 0, + bottomRightRadius: nc.rectangleBottomRightCornerRadius ?? nc.cornerRadius ?? 0, + bottomLeftRadius: nc.rectangleBottomLeftCornerRadius ?? nc.cornerRadius ?? 0, + independentCorners: nc.rectangleCornerRadiiIndependent ?? false, + cornerSmoothing: nc.cornerSmoothing ?? 0 + } +} + +function importedTextLineHeight(nc: NodeChange): number | null { + const derivedLineHeight = nc.derivedTextData?.baselines?.[0]?.lineHeight + if (derivedLineHeight !== undefined && Number.isFinite(derivedLineHeight)) + return derivedLineHeight + return convertLineHeight(nc.lineHeight, nc.fontSize) +} + +type TextProps = Pick< + SceneNode, + | 'text' + | 'fontSize' + | 'fontFamily' + | 'fontWeight' + | 'italic' + | 'textAlignHorizontal' + | 'textAlignVertical' + | 'textAutoResize' + | 'textCase' + | 'textDecoration' + | 'textDecorationStyle' + | 'textDecorationThickness' + | 'textDecorationFills' + | 'leadingTrim' + | 'lineHeight' + | 'letterSpacing' + | 'maxLines' + | 'styleRuns' + | 'fontVariations' + | 'fontFeatures' + | 'textTruncation' + | 'textDirection' + | 'figmaDerivedLayout' + | 'figmaDerivedTextGlyphs' +> + +function convertTextDecorationProps( + nc: NodeChange +): Pick< + SceneNode, + | 'textDecoration' + | 'textDecorationStyle' + | 'textDecorationThickness' + | 'textDecorationFills' + | 'textDecorationSkipInk' + | 'textUnderlineOffset' +> { + return { + textDecoration: mapTextDecoration(nc.textDecoration as string), + textDecorationStyle: (nc.textDecorationStyle ?? 'SOLID') as SceneNode['textDecorationStyle'], + textDecorationThickness: nc.textDecorationThickness?.value ?? null, + textDecorationFills: convertFills(nc.textDecorationFillPaints), + textDecorationSkipInk: nc.textDecorationSkipInk ?? true, + textUnderlineOffset: nc.textUnderlineOffset?.value ?? null + } +} + +function convertTextProps(nc: NodeChange, blobs: Uint8Array[]): TextProps { + return { + text: nc.textData?.characters ?? '', + fontSize: nc.fontSize ?? 14, + fontFamily: nc.fontName?.family ?? DEFAULT_FONT_FAMILY, + fontWeight: styleToWeight(nc.fontName?.style ?? ''), + italic: nc.fontName?.style.toLowerCase().includes('italic') ?? false, + textAlignHorizontal: (nc.textAlignHorizontal ?? 'LEFT') as + | 'LEFT' + | 'CENTER' + | 'RIGHT' + | 'JUSTIFIED', + textAlignVertical: (nc.textAlignVertical ?? 'TOP') as TextAlignVertical, + textAutoResize: (nc.textAutoResize ?? 'NONE') as TextAutoResize, + textCase: (nc.textCase ?? 'ORIGINAL') as TextCase, + ...convertTextDecorationProps(nc), + leadingTrim: (nc.leadingTrim ?? 'NONE') as SceneNode['leadingTrim'], + lineHeight: importedTextLineHeight(nc), + letterSpacing: convertLetterSpacing(nc.letterSpacing, nc.fontSize), + maxLines: (nc.maxLines ?? null) as number | null, + styleRuns: importStyleRuns(nc), + fontVariations: convertFontVariations(nc), + fontFeatures: convertFontFeatures(nc), + textTruncation: (nc.textTruncation as string) === 'ENDING' ? 'ENDING' : 'DISABLED', + textDirection: + (getOpenPencilPluginValue(nc, TEXT_DIRECTION_PLUGIN_KEY) as + | SceneNode['textDirection'] + | null) || 'AUTO', + figmaDerivedLayout: nc.derivedTextData?.layoutSize + ? { + width: nc.derivedTextData.layoutSize.x, + height: nc.derivedTextData.layoutSize.y + } + : null, + figmaDerivedTextGlyphs: convertFigmaDerivedTextGlyphs(nc.derivedTextData, blobs) + } +} + +function convertLayoutPadding( + nc: NodeChange +): Pick { + return { + paddingTop: nc.stackVerticalPadding ?? nc.stackPadding ?? 0, + paddingBottom: nc.stackPaddingBottom ?? nc.stackVerticalPadding ?? nc.stackPadding ?? 0, + paddingLeft: nc.stackHorizontalPadding ?? nc.stackPadding ?? 0, + paddingRight: nc.stackPaddingRight ?? nc.stackHorizontalPadding ?? nc.stackPadding ?? 0 + } +} + +function visibleContainerDerivedLayout( + nc: NodeChange, + layoutMode: SceneNode['layoutMode'], + primaryAxisSizing: SceneNode['primaryAxisSizing'], + counterAxisSizing: SceneNode['counterAxisSizing'] +): SceneNode['figmaDerivedLayout'] | undefined { + const hasHugAxis = primaryAxisSizing === 'HUG' || counterAxisSizing === 'HUG' + const hasVisiblePaint = + (nc.fillPaints?.some((paint) => paint.visible !== false) ?? false) || + (nc.strokePaints?.some((paint) => paint.visible !== false) ?? false) + if (layoutMode === 'NONE' || !hasHugAxis || !hasVisiblePaint) return undefined + + return { + x: nc.transform?.m02 ?? 0, + y: nc.transform?.m12 ?? 0, + width: nc.size?.x ?? 100, + height: nc.size?.y ?? 100 + } +} + +function convertLayoutProps( + nc: NodeChange +): Pick< + SceneNode, + | 'layoutMode' + | 'itemSpacing' + | 'paddingTop' + | 'paddingBottom' + | 'paddingLeft' + | 'paddingRight' + | 'primaryAxisSizing' + | 'counterAxisSizing' + | 'primaryAxisAlign' + | 'counterAxisAlign' + | 'layoutWrap' + | 'counterAxisSpacing' + | 'layoutPositioning' + | 'layoutGrow' + | 'layoutAlignSelf' + | 'counterAxisAlignContent' + | 'itemReverseZIndex' + | 'strokesIncludedInLayout' + | 'layoutDirection' +> & + Partial> { + const layoutMode = mapStackMode(nc.stackMode) + const primaryAxisSizing = mapStackSizing(nc.stackPrimarySizing) + const counterAxisSizing = mapStackSizing(nc.stackCounterSizing) + const figmaDerivedLayout = visibleContainerDerivedLayout( + nc, + layoutMode, + primaryAxisSizing, + counterAxisSizing + ) + + return { + layoutMode, + itemSpacing: nc.stackSpacing ?? 0, + ...convertLayoutPadding(nc), + primaryAxisSizing, + counterAxisSizing, + primaryAxisAlign: mapStackJustify(nc.stackPrimaryAlignItems ?? nc.stackJustify), + counterAxisAlign: mapStackCounterAlign(nc.stackCounterAlignItems ?? nc.stackCounterAlign), + layoutWrap: nc.stackWrap === 'WRAP' ? 'WRAP' : 'NO_WRAP', + counterAxisSpacing: nc.stackCounterSpacing ?? 0, + layoutPositioning: nc.stackPositioning === 'ABSOLUTE' ? 'ABSOLUTE' : 'AUTO', + layoutGrow: nc.stackChildPrimaryGrow ?? 0, + layoutAlignSelf: mapAlignSelf(nc.stackChildAlignSelf), + counterAxisAlignContent: + (nc.stackCounterAlignContent as string) === 'SPACE_BETWEEN' ? 'SPACE_BETWEEN' : 'AUTO', + itemReverseZIndex: (nc.stackReverseZIndex ?? false) as boolean, + strokesIncludedInLayout: (nc.strokesIncludedInLayout ?? false) as boolean, + layoutDirection: + (getOpenPencilPluginValue(nc, LAYOUT_DIRECTION_PLUGIN_KEY) as + | SceneNode['layoutDirection'] + | null) || 'AUTO', + ...(figmaDerivedLayout ? { figmaDerivedLayout } : {}) + } +} + +function getVectorStrokeCap(nc: NodeChange, vectorNetwork: VectorNetwork | null): StrokeCap { + return (nc.strokeCap ?? + vectorNetwork?.vertices.find((v) => v.strokeCap)?.strokeCap ?? + 'NONE') as StrokeCap +} + +function getVectorStrokeJoin(nc: NodeChange, vectorNetwork: VectorNetwork | null): StrokeJoin { + return (nc.strokeJoin ?? + vectorNetwork?.vertices.find((v) => v.strokeJoin)?.strokeJoin ?? + 'MITER') as StrokeJoin +} + +function styleRefId(value: unknown): string | null { + if (!value || typeof value !== 'object' || !('guid' in value)) return null + const guid = value.guid + if (!guid || typeof guid !== 'object') return null + return guidToString(guid as GUID) +} + +function sharedStyleType(value: string | undefined): SharedStyleType | null { + if (value === 'FILL' || value === 'TEXT' || value === 'EFFECT' || value === 'GRID') return value + return null +} + +function convertLayoutGrids(value: unknown): LayoutGrid[] { + return Array.isArray(value) ? structuredClone(value as LayoutGrid[]) : [] +} + +function convertVectorAndStrokeProps(nc: NodeChange, blobs: Uint8Array[]) { + const vectorNetwork = resolveVectorNetwork(nc, blobs) + const strokeCap = getVectorStrokeCap(nc, vectorNetwork) + const strokeJoin = getVectorStrokeJoin(nc, vectorNetwork) + return { + vectorNetwork, + fillGeometry: resolveGeometryPaths(nc.fillGeometry, blobs), + strokeGeometry: resolveGeometryPaths(nc.strokeGeometry, blobs), + arcData: mapArcData(nc.arcData as Partial | undefined), + strokeCap, + strokeJoin, + dashPattern: nc.dashPattern ?? [], + borderTopWeight: (nc.borderTopWeight ?? 0) as number, + borderRightWeight: (nc.borderRightWeight ?? 0) as number, + borderBottomWeight: (nc.borderBottomWeight ?? 0) as number, + borderLeftWeight: (nc.borderLeftWeight ?? 0) as number, + independentStrokeWeights: (nc.borderStrokeWeightsIndependent ?? false) as boolean, + strokeMiterLimit: (nc.miterLimit ?? DEFAULT_STROKE_MITER_LIMIT) as number + } +} + +function resolveNodeType(nc: NodeChange): NodeType | 'DOCUMENT' | 'VARIABLE' { + const nodeType = mapNodeType(nc.type) + if ( + (nodeType === 'FRAME' && isComponentSet(nc)) || + getOpenPencilPluginValue(nc, NODE_TYPE_PLUGIN_KEY) === 'COMPONENT_SET' + ) { + return 'COMPONENT_SET' + } + // Figma stores plain groups as FRAME node-changes flagged with resizeToFit. + // Auto-layout "hug" frames instead use stackPrimarySizing/stackCounterSizing and + // always carry a stackMode, so guard on the absence of auto-layout — a real group + // never has one. This keeps component-sets and auto-layout frames from being + // misclassified as groups. + if ( + nodeType === 'FRAME' && + nc.resizeToFit === true && + (nc.stackMode === undefined || nc.stackMode === 'NONE') + ) { + return 'GROUP' + } + return nodeType +} + +function nearlyEqualSize(a: number | undefined, b: number | undefined): boolean { + return Math.abs((a ?? 0) - (b ?? 0)) <= 0.5 +} + +export function shouldImportTextAsAutoSize( + nc: NodeChange, + parentNc: NodeChange | undefined +): boolean { + if (nc.type !== 'TEXT' || nc.textAutoResize !== 'NONE') return false + if (parentNc?.stackMode !== 'HORIZONTAL' && parentNc?.stackMode !== 'VERTICAL') return false + if (!nc.textData?.characters) return false + const layoutSize = nc.derivedTextData?.layoutSize + if (!layoutSize || !nc.size) return false + return nearlyEqualSize(layoutSize.x, nc.size.x) && nearlyEqualSize(layoutSize.y, nc.size.y) +} + +export function nodeChangeToProps( + nc: NodeChange, + blobs: Uint8Array[] +): Partial & { nodeType: NodeType | 'DOCUMENT' | 'VARIABLE' } { + const nodeType = resolveNodeType(nc) + + const vectorAndStrokeProps = convertVectorAndStrokeProps(nc, blobs) + + return { + nodeType, + name: nc.name ?? nodeType, + source: extractSourceMetadata(nc, blobs), + ...convertTransformProps(nc), + opacity: nc.opacity ?? 1, + visible: nc.visible ?? true, + locked: nc.locked ?? false, + blendMode: (nc.blendMode as Fill['blendMode']) ?? 'PASS_THROUGH', + booleanOperation: mapBooleanOperation(nc), + fills: convertFills(nc.fillPaints), + strokes: convertStrokes( + nc.strokePaints, + nc.strokeWeight, + nc.strokeAlign, + vectorAndStrokeProps.strokeCap, + vectorAndStrokeProps.strokeJoin, + nc.dashPattern ?? [] + ), + effects: convertEffects(nc.effects), + layoutGrids: convertLayoutGrids(nc.layoutGrids), + fillStyleId: styleRefId(nc.styleIdForFill), + strokeStyleId: styleRefId(nc.styleIdForStrokeFill), + textStyleId: styleRefId(nc.styleIdForText), + effectStyleId: styleRefId(nc.styleIdForEffect), + gridStyleId: styleRefId(nc.styleIdForGrid), + sharedStyleType: sharedStyleType(nc.styleType), + ...convertCornerProps(nc), + ...convertTextProps(nc, blobs), + horizontalConstraint: mapConstraint(nc.horizontalConstraint as string), + verticalConstraint: mapConstraint(nc.verticalConstraint as string), + ...convertLayoutProps(nc), + ...vectorAndStrokeProps, + minWidth: (nc.minWidth ?? null) as number | null, + maxWidth: (nc.maxWidth ?? null) as number | null, + minHeight: (nc.minHeight ?? null) as number | null, + maxHeight: (nc.maxHeight ?? null) as number | null, + isMask: nc.mask ?? false, + maskType: (nc.maskType ?? 'ALPHA') as 'ALPHA' | 'VECTOR' | 'LUMINANCE', + maskIsOutline: nc.maskIsOutline ?? false, + expanded: true, + autoRename: (nc.autoRename ?? true) as boolean, + boundVariables: extractBoundVariables(nc), + exportSettings: extractExportSettings(nc), + pluginData: extractPluginData(nc), + pluginRelaunchData: extractPluginRelaunchData(nc), + clipsContent: nc.frameMaskDisabled === false && nc.resizeToFit !== true, + componentId: extractSymbolId(nc), + componentPropertyDefinitions: extractComponentPropertyDefs(nc), + componentPropertyReferences: extractComponentPropertyRefs(nc), + componentPropertyAssignments: extractComponentPropertyAssignments(nc), + componentPropertyValues: extractComponentPropertyValues(nc), + ...extractComponentMetadata(nc) + } +} + +const COMPONENT_PROP_TYPE_MAP: Record = { + VARIANT: 'VARIANT', + TEXT: 'TEXT', + BOOL: 'BOOLEAN', + BOOLEAN: 'BOOLEAN', + INSTANCE_SWAP: 'INSTANCE_SWAP' +} + +function componentPropValueToString(value: unknown): string { + if (!value || typeof value !== 'object') return '' + const propValue = value as { + boolValue?: boolean + textValue?: string | { characters?: string } + guidValue?: GUID + } + if (typeof propValue.boolValue === 'boolean') return String(propValue.boolValue) + if (typeof propValue.textValue === 'string') return propValue.textValue + if (propValue.textValue && typeof propValue.textValue === 'object') { + return propValue.textValue.characters ?? '' + } + return propValue.guidValue ? guidToString(propValue.guidValue) : '' +} + +interface RawComponentPropDef { + id?: GUID + name?: string + type?: string + initialValue?: unknown + preferredValues?: { + stringValues?: string[] + instanceSwapValues?: Array<{ key?: string }> + } +} + +interface RawComponentPropRef { + defID?: GUID + componentPropNodeField?: string | number + isDeleted?: boolean +} + +interface RawComponentPropValue { + boolValue?: boolean + textValue?: string | { characters?: string } + guidValue?: GUID +} + +interface RawComponentPropAssignment { + defID?: GUID + value?: RawComponentPropValue + varValue?: { + value?: { + boolValue?: boolean + textValue?: string + textDataValue?: { characters?: string } + symbolIdValue?: { guid?: GUID } + } + } +} + +interface RawSymbolData { + symbolOverrides?: unknown[] + uniformScaleFactor?: number +} + +function extractComponentPropertyDefs(nc: NodeChange): ComponentPropertyDefinition[] { + const defs = nc.componentPropDefs as RawComponentPropDef[] | undefined + if (!defs?.length) return [] + const result: ComponentPropertyDefinition[] = [] + for (const def of defs) { + if (!def.id || !def.name) continue + const propType = COMPONENT_PROP_TYPE_MAP[def.type ?? ''] ?? 'VARIANT' + result.push({ + id: guidToString(def.id), + name: def.name, + type: propType, + defaultValue: componentPropValueToString(def.initialValue), + variantOptions: propType === 'VARIANT' ? def.preferredValues?.stringValues : undefined, + preferredValues: + propType === 'INSTANCE_SWAP' + ? def.preferredValues?.instanceSwapValues + ?.map((value) => value.key) + .filter((value): value is string => value !== undefined) + : undefined + }) + } + return result +} + +function extractComponentPropertyRefs(nc: NodeChange): ComponentPropertyReference[] { + const refs = nc.componentPropRefs as RawComponentPropRef[] | undefined + if (!refs?.length) return [] + const fieldMap: Record = { + '0': 'VISIBLE', + '1': 'TEXT', + '2': 'INSTANCE_SWAP', + VISIBLE: 'VISIBLE', + TEXT_DATA: 'TEXT', + OVERRIDDEN_SYMBOL_ID: 'INSTANCE_SWAP' + } + return refs.flatMap((ref) => { + const field = fieldMap[String(ref.componentPropNodeField)] + return ref.defID && field && !ref.isDeleted + ? [{ propertyId: guidToString(ref.defID), field }] + : [] + }) +} + +function componentPropertyAssignmentValue(assignment: RawComponentPropAssignment): string { + if ( + assignment.value && + (assignment.value.boolValue !== undefined || + assignment.value.textValue !== undefined || + assignment.value.guidValue !== undefined) + ) { + return componentPropValueToString(assignment.value) + } + const variableValue = assignment.varValue?.value + if (variableValue?.symbolIdValue?.guid) return guidToString(variableValue.symbolIdValue.guid) + if (variableValue?.boolValue !== undefined) return String(variableValue.boolValue) + if (variableValue?.textValue !== undefined) return variableValue.textValue + return variableValue?.textDataValue?.characters ?? '' +} + +function extractComponentPropertyAssignments(nc: NodeChange): Record { + const assignments = nc.componentPropAssignments as RawComponentPropAssignment[] | undefined + if (!assignments?.length) return {} + return Object.fromEntries( + assignments.flatMap((assignment) => + assignment.defID + ? [[guidToString(assignment.defID), componentPropertyAssignmentValue(assignment)]] + : [] + ) + ) +} + +function extractVariantPropSpecs(nc: NodeChange): VariantPropSpec[] { + const specs = nc.variantPropSpecs as Array<{ propDefId?: GUID; value?: string }> | undefined + if (!specs?.length) return [] + return specs + .filter((spec): spec is { propDefId: GUID; value?: string } => !!spec.propDefId) + .map((spec) => ({ propDefId: guidToString(spec.propDefId), value: spec.value ?? '' })) +} + +function extractComponentPropertyValues(nc: NodeChange): Record { + const specs = extractVariantPropSpecs(nc) + const defs = new Map(extractComponentPropertyDefs(nc).map((def) => [def.id, def.name])) + if (specs.length > 0 && defs.size > 0) { + const values: Record = {} + for (const spec of specs) values[defs.get(spec.propDefId) ?? spec.propDefId] = spec.value + return values + } + + const name = nc.name + if (!name?.includes('=')) return {} + return parseVariantName(name) +} + +type ComponentMetadataProps = Pick< + SceneNode, + | 'componentKey' + | 'sourceLibraryKey' + | 'publishId' + | 'overrideKey' + | 'sharedSymbolVersion' + | 'publishedVersion' + | 'isPublishable' + | 'isSymbolPublishable' + | 'symbolDescription' + | 'symbolLinks' + | 'variantPropSpecs' +> + +function guidToStringOrNull(value: unknown): string | null { + if (!value || typeof value !== 'object') return null + const guid = value as Partial + if (typeof guid.sessionID !== 'number' || typeof guid.localID !== 'number') return null + return guidToString({ sessionID: guid.sessionID, localID: guid.localID }) +} + +function stringOrNull(value: unknown): string | null { + return typeof value === 'string' ? value : null +} + +function stringOrEmpty(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +function booleanOrFalse(value: unknown): boolean { + return typeof value === 'boolean' ? value : false +} + +function extractComponentMetadata(nc: NodeChange): ComponentMetadataProps { + const symbolLinks = (nc.symbolLinks as Array> | undefined) ?? [] + return { + componentKey: stringOrNull(nc.componentKey), + sourceLibraryKey: stringOrNull(nc.sourceLibraryKey), + publishId: guidToStringOrNull(nc.publishID), + overrideKey: guidToStringOrNull(nc.overrideKey), + sharedSymbolVersion: stringOrNull(nc.sharedSymbolVersion), + publishedVersion: stringOrNull(nc.publishedVersion), + isPublishable: booleanOrFalse(nc.isPublishable), + isSymbolPublishable: booleanOrFalse(nc.isSymbolPublishable), + symbolDescription: stringOrEmpty(nc.symbolDescription), + symbolLinks: symbolLinks + .filter((link): link is SymbolLink => typeof link.uri === 'string') + .map((link) => ({ + uri: link.uri, + displayName: link.displayName, + displayText: link.displayText + })), + variantPropSpecs: extractVariantPropSpecs(nc) + } +} + +function isComponentSet(nc: NodeChange): boolean { + const defs = nc.componentPropDefs as Array<{ type?: string }> | undefined + if (!defs?.length) return false + return defs.some((d) => d.type === 'VARIANT') +} + +function extractFigmaLayoutMetadata(nc: NodeChange): SceneNode['source']['fig']['layout'] { + return { + stackMode: nc.stackMode, + stackSpacing: nc.stackSpacing, + stackPadding: nc.stackPadding, + stackPaddingRight: nc.stackPaddingRight, + stackPaddingBottom: nc.stackPaddingBottom, + stackCounterAlign: nc.stackCounterAlign, + stackJustify: nc.stackJustify, + stackCounterAlignItems: nc.stackCounterAlignItems, + stackPrimaryAlignItems: nc.stackPrimaryAlignItems, + stackPrimarySizing: nc.stackPrimarySizing, + stackCounterSizing: nc.stackCounterSizing, + stackVerticalPadding: nc.stackVerticalPadding, + stackHorizontalPadding: nc.stackHorizontalPadding, + stackWrap: nc.stackWrap, + stackPositioning: nc.stackPositioning, + stackChildPrimaryGrow: nc.stackChildPrimaryGrow, + stackChildAlignSelf: nc.stackChildAlignSelf, + stackCounterSpacing: nc.stackCounterSpacing, + bordersTakeSpace: nc.bordersTakeSpace as boolean | undefined, + stackReverseZIndex: nc.stackReverseZIndex as boolean | undefined + } +} + +function extractSourceMetadata(nc: NodeChange, blobs: Uint8Array[]): SceneNode['source'] { + return { + format: 'fig', + id: nc.guid ? guidToString(nc.guid) : null, + orderKey: nc.parentIndex?.position ?? null, + fig: { + ...extractFigmaRawGeometry(nc, blobs), + ...extractFigmaSymbolMetadata(nc, blobs), + layout: extractFigmaLayoutMetadata(nc) + } + } +} + +export function sortChildren( + children: string[], + parentNc: NodeChange, + nodeMap: Map +): void { + // Always sort by parentIndex.position first (canonical tree order) + const stackMode = parentNc.stackMode as string | undefined + const isHorizontal = stackMode === 'HORIZONTAL' + const isVertical = stackMode === 'VERTICAL' + + children.sort((a, b) => { + const aPos = nodeMap.get(a)?.parentIndex?.position ?? '' + const bPos = nodeMap.get(b)?.parentIndex?.position ?? '' + // Primary sort: parentIndex.position (exact tree order) + if (aPos < bPos) return -1 + if (aPos > bPos) return 1 + + // Tiebreaker for auto-layout: sort by transform position + if (isHorizontal || isVertical) { + const axis = isHorizontal ? 'm02' : 'm12' + const aT = nodeMap.get(a)?.transform?.[axis] ?? 0 + const bT = nodeMap.get(b)?.transform?.[axis] ?? 0 + if (aT !== bT) return aT - bT + } + + return 0 + }) +} + +interface PreservedFigmaBlob { + __openPencilFigmaBlob: Uint8Array +} + +function preserveFigmaPayloadBlobs(value: unknown, blobs: Uint8Array[]): unknown { + if (value instanceof Uint8Array) return value + if (Array.isArray(value)) return value.map((item) => preserveFigmaPayloadBlobs(item, blobs)) + if (!value || typeof value !== 'object') return value + const result: Record = {} + for (const [key, child] of Object.entries(value)) { + if ((key === 'commandsBlob' || key === 'vectorNetworkBlob') && typeof child === 'number') { + const blob: unknown = blobs[child] + if (blob == null) { + result[key] = child + } else { + result[key] = { + __openPencilFigmaBlob: + blob instanceof Uint8Array + ? blob + : new Uint8Array(Object.values(blob as Record)) + } satisfies PreservedFigmaBlob + } + } else { + result[key] = preserveFigmaPayloadBlobs(child, blobs) + } + } + return result +} + +export const FIGMA_RAW_NODE_FIELD_KEYS = [ + 'styleIdForFill', + 'styleIdForStrokeFill', + 'styleIdForText', + 'styleIdForEffect', + 'styleIdForGrid', + 'styleType', + 'componentPropAssignments', + 'backgroundPaints', + 'layoutGrids', + 'exportSettings', + 'componentPropDefs', + 'componentPropRefs', + 'variantPropSpecs', + 'stateGroupPropertyValueOrders', + 'isStateGroup', + 'version', + 'sourceLibraryKey', + 'userFacingVersion', + 'description', + 'key', + 'sortPosition', + 'detachedSymbolId', + 'documentColorProfile', + 'variableConsumptionMap', + 'variableModeBySetMap', + 'parameterConsumptionMap', + 'editInfo', + 'backgroundColor', + 'pageType', + 'isPageDivider', + 'guides', + 'handoffStatusMap', + 'annotationCategories', + 'miterLimit', + 'mask', + 'maskType', + 'maskIsOutline', + 'strokeWeight', + 'strokeJoin', + 'borderStrokeWeightsIndependent', + 'borderTopWeight', + 'borderRightWeight', + 'borderBottomWeight', + 'borderLeftWeight', + 'minSize', + 'maxSize', + 'targetAspectRatio', + 'gridRows', + 'gridColumns', + 'gridRowAnchor', + 'gridColumnAnchor', + 'gridColumnsSizing', + 'gridRowsSizing', + 'gridChildVerticalAlign', + 'gridChildHorizontalAlign', + 'textAutoResize', + 'textData', + 'lineHeight', + 'fontName', + 'fontSize', + 'letterSpacing', + 'textTracking', + 'fontVersion', + 'textUserLayoutVersion', + 'textExplicitLayoutVersion', + 'fontVariations', + 'fontVariantCommonLigatures', + 'fontVariantContextualLigatures', + 'toggledOnOTFeatures', + 'toggledOffOTFeatures', + 'leadingTrim', + 'textDecorationFillPaints', + 'textUnderlineOffset', + 'textDecorationThickness', + 'textDecorationStyle', + 'semanticWeight', + 'semanticItalic', + 'maxLines', + 'textPathStart', + 'derivedTextData', + 'fillPaints', + 'strokePaints', + 'effects', + 'sectionStatusInfo', + 'prototypeStartNodeID', + 'prototypeInteractions', + 'transitionInfo', + 'codeSyntax', + 'lockMode', + 'slideThemeMap', + 'isSoftDeleted', + 'brushType', + 'scatterStrokeSettings', + 'vectorOperationVersion', + 'vectorData', + 'fillGeometry', + 'strokeGeometry' +] as const satisfies readonly (keyof NodeChange)[] + +function extractFigmaRawGeometry( + nc: NodeChange, + blobs: Uint8Array[] +): Pick { + const rawNodeFields: Record = {} + for (const key of FIGMA_RAW_NODE_FIELD_KEYS) { + const value = nc[key] + if (value !== undefined) rawNodeFields[key] = preserveFigmaPayloadBlobs(value, blobs) + } + return { + rawSize: nc.size ? { ...nc.size } : null, + rawTransform: nc.transform ? { ...nc.transform } : null, + rawNodeFields + } +} + +function extractFigmaSymbolMetadata( + nc: NodeChange, + blobs: Uint8Array[] +): Pick< + SceneNode['source']['fig'], + | 'symbolOverrides' + | 'componentPropAssignments' + | 'derivedSymbolData' + | 'derivedSymbolDataLayoutVersion' + | 'uniformScaleFactor' +> { + const sd = nc.symbolData as RawSymbolData | undefined + return { + symbolOverrides: preserveFigmaPayloadBlobs(sd?.symbolOverrides ?? [], blobs) as unknown[], + componentPropAssignments: preserveFigmaPayloadBlobs( + nc.componentPropAssignments ?? [], + blobs + ) as unknown[], + derivedSymbolData: preserveFigmaPayloadBlobs(nc.derivedSymbolData ?? [], blobs) as unknown[], + derivedSymbolDataLayoutVersion: + typeof nc.derivedSymbolDataLayoutVersion === 'number' + ? nc.derivedSymbolDataLayoutVersion + : null, + uniformScaleFactor: typeof sd?.uniformScaleFactor === 'number' ? sd.uniformScaleFactor : null + } +} + +function extractSymbolId(nc: NodeChange): string { + const sd = nc.symbolData as { symbolID?: GUID } | undefined + if (!sd?.symbolID) return '' + return guidToString(sd.symbolID) +} diff --git a/packages/fig/src/node-change/index.ts b/packages/fig/src/node-change/index.ts index 19b3dc738..5bf263c95 100644 --- a/packages/fig/src/node-change/index.ts +++ b/packages/fig/src/node-change/index.ts @@ -1,9 +1,11 @@ +export * from './convert' export * from './derived-text-glyphs' export * from './font/features' export * from './font/variations' export * from './paint' export * from './plugin-data' export * from './style-refs' +export * from './style-runs' export * from './text-values' export * from './vector-geometry' export * from './vector-network' diff --git a/packages/fig/src/node-change/style-runs.ts b/packages/fig/src/node-change/style-runs.ts new file mode 100644 index 000000000..15cbb9ca6 --- /dev/null +++ b/packages/fig/src/node-change/style-runs.ts @@ -0,0 +1,109 @@ +import type { NodeChange } from '@open-pencil/kiwi/fig/codec' +import { styleToWeight } from '@open-pencil/scene-graph' +import type { CharacterStyleOverride, StyleRun } from '@open-pencil/scene-graph' + +import { convertFontFeatures } from './font/features' +import { convertFontVariations } from './font/variations' +import { convertFills } from './paint' +import { convertLetterSpacing, convertLineHeight, mapTextDecoration } from './text-values' + +function applyTextDecorationOverride(style: CharacterStyleOverride, override: NodeChange): void { + const deco = override.textDecoration + if (deco) style.textDecoration = mapTextDecoration(deco) + if (override.textDecorationStyle) + style.textDecorationStyle = + override.textDecorationStyle as CharacterStyleOverride['textDecorationStyle'] + if (override.textDecorationThickness) + style.textDecorationThickness = override.textDecorationThickness.value ?? null + if (override.textDecorationSkipInk !== undefined) + style.textDecorationSkipInk = override.textDecorationSkipInk + if (override.textUnderlineOffset) + style.textUnderlineOffset = override.textUnderlineOffset.value ?? null + if (override.textDecorationFillPaints) { + const decorationFills = convertFills(override.textDecorationFillPaints) + if (decorationFills.length > 0) style.textDecorationFills = decorationFills + } +} + +function convertStyleOverride( + override: NodeChange, + fallbackFontSize: number | undefined +): CharacterStyleOverride { + const style: CharacterStyleOverride = {} + if (override.fontName) { + style.fontFamily = override.fontName.family + style.fontWeight = styleToWeight(override.fontName.style) + style.italic = override.fontName.style.toLowerCase().includes('italic') + } + if (override.fontSize !== undefined) style.fontSize = override.fontSize + const fontVariations = convertFontVariations(override) + if (fontVariations.length > 0) style.fontVariations = fontVariations + const fontFeatures = convertFontFeatures(override) + if (fontFeatures.length > 0) style.fontFeatures = fontFeatures + if (override.letterSpacing) { + style.letterSpacing = convertLetterSpacing( + override.letterSpacing, + override.fontSize ?? fallbackFontSize + ) + } + if (override.lineHeight) { + const lh = convertLineHeight(override.lineHeight, override.fontSize ?? fallbackFontSize) + if (lh != null) style.lineHeight = lh + } + applyTextDecorationOverride(style, override) + if (override.fillPaints) { + const fills = convertFills(override.fillPaints) + if (fills.length > 0) style.fills = fills + } + return style +} + +function buildStyleMap( + table: NodeChange[], + fallbackFontSize: number | undefined +): Map { + const styleMap = new Map() + for (const override of table) { + const id = override.styleID as number | undefined + if (id === undefined) continue + const style = convertStyleOverride(override, fallbackFontSize) + if (Object.keys(style).length > 0) styleMap.set(id, style) + } + return styleMap +} + +function collectStyleRuns( + ids: number[], + styleMap: Map +): StyleRun[] { + const runs: StyleRun[] = [] + let currentId = ids[0] + let start = 0 + + for (let i = 1; i <= ids.length; i++) { + if (i === ids.length || ids[i] !== currentId) { + if (currentId !== 0) { + const style = styleMap.get(currentId) + if (style) runs.push({ start, length: i - start, style }) + } + if (i < ids.length) { + currentId = ids[i] + start = i + } + } + } + return runs +} + +export function importStyleRuns(nc: NodeChange): StyleRun[] { + const td = nc.textData + if (!td?.characterStyleIDs || !td.styleOverrideTable) return [] + + const ids = td.characterStyleIDs + if (ids.length === 0 || td.styleOverrideTable.length === 0) return [] + + const styleMap = buildStyleMap(td.styleOverrideTable, nc.fontSize) + if (styleMap.size === 0) return [] + + return collectStyleRuns(ids, styleMap) +} diff --git a/packages/scene-graph/src/font-style.ts b/packages/scene-graph/src/font-style.ts new file mode 100644 index 000000000..b0fe32f1f --- /dev/null +++ b/packages/scene-graph/src/font-style.ts @@ -0,0 +1,42 @@ +export interface ParsedFontStyle { + weight: number + italic: boolean +} + +const FONT_WEIGHT_ALIASES = [ + { weight: 100, names: ['thin', 'hairline', 'extrathin', 'ultrathin'] }, + { weight: 200, names: ['extralight', 'ultralight'] }, + { weight: 300, names: ['light'] }, + { weight: 400, names: ['regular', 'normal', 'book', 'roman', 'plain'] }, + { weight: 500, names: ['medium'] }, + { weight: 600, names: ['semibold', 'demibold'] }, + { weight: 700, names: ['bold'] }, + { weight: 800, names: ['extrabold', 'ultrabold'] }, + { weight: 900, names: ['black', 'heavy'] } +] as const + +const FONT_WEIGHT_BY_STYLE: ReadonlyMap = new Map( + FONT_WEIGHT_ALIASES.flatMap(({ names, weight }) => names.map((name) => [name, weight] as const)) +) + +export function normalizeFontStyleName(style: string): string { + return style + .toLowerCase() + .replace(/italic|oblique/u, '') + .replace(/[^a-z0-9]+/gu, '') +} + +export function parseFontStyle(style: string | undefined): ParsedFontStyle { + const raw = style ?? '' + const italic = /(?:italic|oblique)/iu.test(raw) + const normalized = normalizeFontStyleName(raw) + const numericWeight = normalized.match(/(?:^|[^0-9])([1-9]00)(?:[^0-9]|$)/u)?.[1] + return { + weight: numericWeight ? Number(numericWeight) : (FONT_WEIGHT_BY_STYLE.get(normalized) ?? 400), + italic + } +} + +export function styleToWeight(style: string | undefined): number { + return parseFontStyle(style).weight +} diff --git a/packages/scene-graph/src/index.ts b/packages/scene-graph/src/index.ts index 05debf90e..c9e2d0f3d 100644 --- a/packages/scene-graph/src/index.ts +++ b/packages/scene-graph/src/index.ts @@ -3,7 +3,9 @@ export * from './copy' export * from './snap' export * from './export-scale' export * from './coordinate' +export * from './constants' export * from './geometry' +export * from './font-style' export * from './shared-styles' export { default as TransformMatrix } from './matrix' export type { Mat3 } from './matrix'