Rename format files to match io folders

This commit is contained in:
Danila Poyarkov 2026-03-28 14:33:28 +03:00
parent decee0216e
commit e0878c1e68
19 changed files with 26 additions and 1674 deletions

View file

@ -60,11 +60,6 @@
"bun": "./src/color.ts",
"default": "./src/color.ts"
},
"./render-image": {
"types": "./src/render-image.ts",
"bun": "./src/render-image.ts",
"default": "./src/render-image.ts"
},
"./profiler": {
"types": "./src/profiler/index.ts",
"bun": "./src/profiler/index.ts",
@ -177,11 +172,6 @@
"import": "./dist/color.js",
"default": "./dist/color.js"
},
"./render-image": {
"types": "./dist/render-image.d.ts",
"import": "./dist/render-image.js",
"default": "./dist/render-image.js"
},
"./profiler": {
"types": "./dist/profiler/index.d.ts",
"import": "./dist/profiler/index.js",

View file

@ -1,7 +1,7 @@
import { iconToSVG } from '@iconify/utils'
import svgpath from 'svgpath'
import { parseSVGPath } from './svg-path-parse'
import { parseSVGPath } from './io/formats/svg/parse-path'
import type { VectorNetwork, WindingRule } from './scene-graph'

View file

@ -229,8 +229,8 @@ export {
vectorNetworkToSVGPaths,
type SVGExportOptions
} from './io/formats/svg/export'
export { svg, renderSVGNode, type SVGNode } from './svg-node'
export { parseSVGPath } from './svg-path-parse'
export { svg, renderSVGNode, type SVGNode } from './io/formats/svg/node'
export { parseSVGPath } from './io/formats/svg/parse-path'
export {
fetchIcon,
fetchIcons,
@ -241,7 +241,7 @@ export {
type IconPath,
type IconSearchResult
} from './iconify'
export { exportFigFile, compressFigData, compressFigDataSync } from './fig-export'
export { exportFigFile, compressFigData, compressFigDataSync } from './io/formats/fig/export'
export {
FIG_KIWI_DEFAULT_VERSION,
buildFigKiwi,

View file

@ -1,4 +1,4 @@
import { compressFigDataSync } from './fig-compress'
import { compressFigDataSync } from '@open-pencil/core/fig-compress'
interface CompressMessage {
schemaDeflated: Uint8Array

View file

@ -1,10 +1,10 @@
import { deflateSync } from 'fflate'
import { CANVAS_BG_COLOR, IS_BROWSER, IS_TAURI } from './constants'
import { compressFigDataSync } from './fig-compress'
import { renderThumbnail } from './io/formats/raster'
import { initCodec, getCompiledSchema, getSchemaBytes } from './kiwi/codec'
import { stringToGuid } from './kiwi/kiwi-convert'
import { CANVAS_BG_COLOR, IS_BROWSER, IS_TAURI } from '@open-pencil/core/constants'
import { compressFigDataSync } from '@open-pencil/core/fig-compress'
import { renderThumbnail } from '@open-pencil/core/io/formats/raster'
import { initCodec, getCompiledSchema, getSchemaBytes } from '@open-pencil/core/kiwi/codec'
import { stringToGuid } from '@open-pencil/core/kiwi/kiwi-convert'
import {
sceneNodeToKiwi,
fractionalPosition,
@ -12,12 +12,12 @@ import {
safeColor,
makeDocumentNodeChange,
makeCanvasNodeChange
} from './kiwi/kiwi-serialize'
} from '@open-pencil/core/kiwi/kiwi-serialize'
import type { NodeChange } from './kiwi/codec'
import type { SkiaRenderer } from './renderer'
import type { SceneGraph, VariableValue } from './scene-graph'
import type { GUID } from './types'
import type { NodeChange } from '@open-pencil/core/kiwi/codec'
import type { SkiaRenderer } from '@open-pencil/core/renderer'
import type { SceneGraph, VariableValue } from '@open-pencil/core/scene-graph'
import type { GUID } from '@open-pencil/core/types'
import type { CanvasKit } from 'canvaskit-wasm'
const THUMBNAIL_1X1 = Uint8Array.from(
@ -246,7 +246,7 @@ export async function exportFigFile(
return compressFigData(schemaDeflated, kiwiData, thumbnailPng, metaJson, imageEntries, version)
}
export { compressFigDataSync } from './fig-compress'
export { compressFigDataSync } from '@open-pencil/core/fig-compress'
function canUseWorker(): boolean {
return typeof Worker !== 'undefined' && IS_BROWSER
@ -260,7 +260,7 @@ function compressViaWorker(
imageEntries: Array<{ name: string; data: Uint8Array }>
): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('./fig-export-worker.ts', import.meta.url), {
const worker = new Worker(new URL('./export-worker.ts', import.meta.url), {
type: 'module'
})

View file

@ -1 +1 @@
export { exportFigFile, compressFigData, compressFigDataSync } from '../../../fig-export'
export { exportFigFile, compressFigData, compressFigDataSync } from './export'

View file

@ -20,9 +20,9 @@ import {
type PenNode,
type VarContext
} from '@open-pencil/core/io/formats/pen/convert'
import { parseSVGPath } from '@open-pencil/core/io/formats/svg/parse-path'
import { SceneGraph } from '@open-pencil/core/scene-graph'
import { populateInstanceChildren } from '@open-pencil/core/scene-graph-instances'
import { parseSVGPath } from '@open-pencil/core/svg-path-parse'
import type {
LayoutMode,

View file

@ -1,6 +1,6 @@
import { SkiaRenderer } from '@open-pencil/core/renderer'
import { renderNodesToImage, renderThumbnail, type ExportFormat } from './render-image'
import { renderNodesToImage, renderThumbnail, type ExportFormat } from './render'
import type { SceneGraph } from '@open-pencil/core/scene-graph'
import type { CanvasKit } from 'canvaskit-wasm'

View file

@ -4,5 +4,5 @@ export {
renderThumbnail,
type RasterExportFormat,
type ExportFormat
} from './render-image'
} from './render'
export { initCanvasKit, headlessRenderNodes, headlessRenderThumbnail } from './headless'

View file

@ -1,6 +1,6 @@
import { colorToHex, colorToHex8 } from '@open-pencil/core/color'
import { svg, type SVGNode } from '@open-pencil/core/svg-node'
import { svg, type SVGNode } from './node'
import { round } from './paths'
import type { Effect, Fill, SceneGraph, SceneNode } from '@open-pencil/core/scene-graph'

View file

@ -21,9 +21,10 @@ import {
export { geometryBlobToSVGPath, vectorNetworkToSVGPaths } from './paths'
import { svg, renderSVGNode } from '@open-pencil/core/svg-node'
import { svg, renderSVGNode } from './node'
import type { SVGExportContext } from './defs'
import type { SVGNode } from './node'
import type {
SceneGraph,
SceneNode,
@ -31,7 +32,6 @@ import type {
Stroke,
CharacterStyleOverride
} from '@open-pencil/core/scene-graph'
import type { SVGNode } from '@open-pencil/core/svg-node'
// --- Node rendering ---

View file

@ -6,7 +6,7 @@ import type {
VectorSegment,
VectorRegion,
WindingRule
} from './scene-graph'
} from '@open-pencil/core/scene-graph'
interface SubPath {
startVertexIndex: number

View file

@ -1,463 +0,0 @@
import { parseColor } from './color'
import { generateId } from './scene-graph'
import type {
Color,
Effect,
Fill,
LayoutAlign,
LayoutCounterAlign,
LayoutMode,
LayoutSizing,
NodeType,
SceneGraph,
SceneNode,
Stroke,
StrokeCap,
StrokeJoin,
TextAlignVertical,
Variable,
VariableCollection,
VariableCollectionMode,
VariableType,
VariableValue
} from './scene-graph'
import type { Vector } from './types'
export interface PenDocument {
version: string
children: PenNode[]
themes?: Record<string, string[]>
variables?: Record<string, PenVariable>
}
export interface PenVariable {
type: 'color' | 'string' | 'number'
value: PenVariableValue[] | PenVariableValue | string | number
}
interface PenVariableValue {
value: string | number
theme?: Record<string, string>
}
interface PenStroke {
align: 'inside' | 'center' | 'outside'
thickness: number | { top?: number; right?: number; bottom?: number; left?: number }
fill?: string
join?: string
cap?: string
}
interface PenEffect {
type: string
shadowType?: string
color?: string
offset?: Vector
blur?: number
spread?: number
}
interface PenFillObject {
type: string
color: string
enabled?: boolean
}
type PenFill = string | PenFillObject | PenFillObject[]
export interface PenNode {
type: string
id: string
name?: string
x?: number
y?: number
width?: number | string
height?: number | string
fill?: PenFill
opacity?: number
enabled?: boolean
clip?: boolean
rotation?: number
flipX?: boolean
flipY?: boolean
reusable?: boolean
cornerRadius?: number | string | (number | string)[]
stroke?: PenStroke
effect?: PenEffect | PenEffect[]
layout?: string
gap?: number
padding?: number | number[]
justifyContent?: string
alignItems?: string
children?: PenNode[]
content?: string
fontFamily?: string
fontSize?: number
fontWeight?: string | number
lineHeight?: number
letterSpacing?: number
textAlign?: string
textAlignVertical?: string
textGrowth?: string
ref?: string
descendants?: Record<string, Partial<PenNode>>
slot?: string[]
geometry?: string
iconFontName?: string
iconFontFamily?: string
weight?: number
model?: string
theme?: Record<string, string>
}
export interface VarContext {
byName: Map<string, { id: string; variable: Variable }>
activeModeId: string
collectionId: string
modeByThemeName: Map<string, string>
resolveColor(ref: string): Color
resolveNumber(ref: string): number
resolveString(ref: string): string
setActiveTheme(themeName: string): void
}
function penVarTypeToSceneType(t: string): VariableType {
if (t === 'color') return 'COLOR'
if (t === 'number') return 'FLOAT'
return 'STRING'
}
function penValueToSceneValue(raw: string | number, type: VariableType): VariableValue {
if (type === 'COLOR' && typeof raw === 'string') return parseColor(raw)
if (type === 'FLOAT' && typeof raw === 'number') return raw
if (type === 'STRING') return String(raw)
if (typeof raw === 'number') return raw
return String(raw)
}
function defaultForType(type: VariableType): VariableValue {
if (type === 'COLOR') return { r: 0, g: 0, b: 0, a: 1 }
if (type === 'FLOAT') return 0
if (type === 'BOOLEAN') return false
return ''
}
export function isVarRef(val: unknown): val is string {
return typeof val === 'string' && val.startsWith('$--')
}
function varName(ref: string): string {
return ref.replace(/^\$/, '')
}
export function bindIfVar(node: SceneNode, field: string, val: unknown, ctx: VarContext): void {
if (!isVarRef(val)) return
const entry = ctx.byName.get(varName(val))
if (entry) node.boundVariables[field] = entry.id
}
export function buildVarContext(
graph: SceneGraph,
penVars: Record<string, PenVariable>,
themes: Record<string, string[]>
): VarContext {
const collectionId = generateId()
const modes: VariableCollectionMode[] = []
const themeKeys = Object.keys(themes)
if (themeKeys.length > 0) {
const themeKey = themeKeys[0]
for (const modeName of themes[themeKey]) {
modes.push({ modeId: generateId(), name: modeName })
}
}
if (modes.length === 0) {
modes.push({ modeId: generateId(), name: 'Default' })
}
const collection: VariableCollection = {
id: collectionId,
name: 'Variables',
modes,
defaultModeId: modes[0].modeId,
variableIds: []
}
graph.addCollection(collection)
const modeByThemeValue = new Map<string, string>()
if (themeKeys.length > 0) {
const themeKey = themeKeys[0]
for (const mode of modes) {
modeByThemeValue.set(`${themeKey}:${mode.name}`, mode.modeId)
}
}
const byName = new Map<string, { id: string; variable: Variable }>()
for (const [name, def] of Object.entries(penVars)) {
const varId = generateId()
const varType = penVarTypeToSceneType(def.type)
const valuesByMode: Record<string, VariableValue> = {}
if (Array.isArray(def.value)) {
for (const entry of def.value) {
if (entry.theme) {
const [tKey, tVal] = Object.entries(entry.theme)[0]
const modeId = modeByThemeValue.get(`${tKey}:${tVal}`)
if (modeId) valuesByMode[modeId] = penValueToSceneValue(entry.value, varType)
} else {
valuesByMode[modes[0].modeId] = penValueToSceneValue(entry.value, varType)
}
}
} else {
valuesByMode[modes[0].modeId] = penValueToSceneValue(def.value as string | number, varType)
}
for (const mode of modes) {
if (!(mode.modeId in valuesByMode)) {
valuesByMode[mode.modeId] = valuesByMode[modes[0].modeId] ?? defaultForType(varType)
}
}
const variable: Variable = {
id: varId,
name,
type: varType,
collectionId,
valuesByMode,
description: '',
hiddenFromPublishing: false
}
graph.addVariable(variable)
byName.set(name, { id: varId, variable })
}
let activeModeId = modes[0].modeId
function resolveVal(ref: string): VariableValue | undefined {
const entry = byName.get(ref.replace(/^\$/, ''))
if (!entry) return undefined
return (
entry.variable.valuesByMode[activeModeId] ?? Object.values(entry.variable.valuesByMode)[0]
)
}
return {
byName,
activeModeId,
collectionId,
modeByThemeName: modeByThemeValue,
resolveColor(ref: string): Color {
const val = resolveVal(ref)
if (val === undefined) return parseColor(ref)
if (typeof val === 'object' && 'r' in val) return val
if (typeof val === 'string') return parseColor(val)
return { r: 0, g: 0, b: 0, a: 1 }
},
resolveNumber(ref: string): number {
const val = resolveVal(ref)
return typeof val === 'number' ? val : 0
},
resolveString(ref: string): string {
const val = resolveVal(ref)
return typeof val === 'string' ? val : ''
},
setActiveTheme(themeName: string) {
const modeId = modeByThemeValue.get(`theme:${themeName}`)
if (modeId) {
activeModeId = modeId
graph.activeMode.set(collectionId, modeId)
}
}
}
}
function parseFillColor(fill: string | PenFillObject, ctx: VarContext): Color {
const raw = typeof fill === 'string' ? fill : fill.color
return isVarRef(raw) ? ctx.resolveColor(raw) : parseColor(raw)
}
export function convertFill(fill: PenFill | undefined, ctx: VarContext, node?: SceneNode): Fill[] {
if (fill === undefined) return []
const fills = Array.isArray(fill) ? fill : [fill]
return fills.map((item, index) => {
const visible = typeof item === 'string' ? true : item.enabled !== false
const color = parseFillColor(item, ctx)
const result: Fill = { type: 'SOLID', visible, opacity: color.a, color }
if (node) bindIfVar(node, `fills[${index}]`, typeof item === 'string' ? item : item.color, ctx)
return result
})
}
function strokeWeight(stroke: PenStroke): number {
return typeof stroke.thickness === 'number'
? stroke.thickness
: Math.max(...Object.values(stroke.thickness))
}
export function convertStroke(
stroke: PenStroke | undefined,
ctx: VarContext,
node?: SceneNode
): Stroke[] {
if (!stroke?.fill) return []
const color = isVarRef(stroke.fill) ? ctx.resolveColor(stroke.fill) : parseColor(stroke.fill)
let align: Stroke['align'] = 'CENTER'
if (stroke.align === 'inside') align = 'INSIDE'
else if (stroke.align === 'outside') align = 'OUTSIDE'
const result: Stroke = {
visible: true,
color,
opacity: color.a,
weight: strokeWeight(stroke),
align,
dashPattern: []
}
if (node) {
bindIfVar(node, 'strokes[0]', stroke.fill, ctx)
if (typeof stroke.thickness === 'object') {
node.independentStrokeWeights = true
node.borderTopWeight = stroke.thickness.top ?? 0
node.borderRightWeight = stroke.thickness.right ?? 0
node.borderBottomWeight = stroke.thickness.bottom ?? 0
node.borderLeftWeight = stroke.thickness.left ?? 0
}
node.strokeJoin = mapStrokeJoin(stroke.join)
node.strokeCap = mapStrokeCap(stroke.cap)
}
return [result]
}
function mapStrokeJoin(join: string | undefined): StrokeJoin {
if (join === 'round') return 'ROUND'
if (join === 'bevel') return 'BEVEL'
return 'MITER'
}
function mapStrokeCap(cap: string | undefined): StrokeCap {
if (cap === 'round') return 'ROUND'
if (cap === 'square') return 'SQUARE'
return 'NONE'
}
export function convertEffects(effect: PenEffect | PenEffect[] | undefined): Effect[] {
if (!effect) return []
const effects = Array.isArray(effect) ? effect : [effect]
return effects.flatMap((item) => {
if (item.type !== 'shadow') return []
const color = item.color ? parseColor(item.color) : { r: 0, g: 0, b: 0, a: 0.25 }
return [
{
type: item.shadowType === 'inner' ? 'INNER_SHADOW' : 'DROP_SHADOW',
visible: true,
blendMode: 'NORMAL',
color,
offset: item.offset ?? { x: 0, y: 0 },
radius: item.blur ?? 0,
spread: item.spread ?? 0
} satisfies Effect
]
})
}
export function applyCornerRadius(
node: SceneNode,
radius: PenNode['cornerRadius'],
ctx: VarContext
): void {
if (radius === undefined) return
if (Array.isArray(radius)) {
const values = radius.map((value) => parseSize(value, 0, ctx).value)
node.independentCorners = true
node.topLeftRadius = values[0] ?? 0
node.topRightRadius = values[1] ?? 0
node.bottomRightRadius = values[2] ?? 0
node.bottomLeftRadius = values[3] ?? 0
return
}
node.cornerRadius = parseSize(radius, 0, ctx).value
}
export function applyPadding(node: SceneNode, padding: PenNode['padding']): void {
if (padding === undefined) return
if (Array.isArray(padding)) {
node.paddingTop = padding[0] ?? 0
node.paddingRight = padding[1] ?? 0
node.paddingBottom = padding[2] ?? 0
node.paddingLeft = padding[3] ?? 0
return
}
node.paddingTop = padding
node.paddingRight = padding
node.paddingBottom = padding
node.paddingLeft = padding
}
export function parseSize(value: number | string | undefined, fallback: number, ctx?: VarContext) {
if (value === undefined) return { value: fallback, sizing: 'FIXED' as LayoutSizing }
if (typeof value === 'number') return { value, sizing: 'FIXED' as LayoutSizing }
if (value === 'fill_container') return { value: fallback, sizing: 'FILL' as LayoutSizing }
if (value === 'hug_content') return { value: fallback, sizing: 'HUG' as LayoutSizing }
if (isVarRef(value) && ctx)
return { value: ctx.resolveNumber(value), sizing: 'FIXED' as LayoutSizing }
const parsed = Number(value)
return { value: Number.isFinite(parsed) ? parsed : fallback, sizing: 'FIXED' as LayoutSizing }
}
export function mapLayoutMode(pen: PenNode): LayoutMode {
if (pen.layout === 'row' || pen.layout === 'horizontal') return 'HORIZONTAL'
if (pen.layout === 'column' || pen.layout === 'vertical') return 'VERTICAL'
return 'NONE'
}
export function mapJustifyContent(value: string | undefined): LayoutAlign {
if (value === 'center') return 'CENTER'
if (value === 'end') return 'MAX'
if (value === 'space-between') return 'SPACE_BETWEEN'
return 'MIN'
}
export function mapAlignItems(value: string | undefined): LayoutCounterAlign {
if (value === 'center') return 'CENTER'
if (value === 'end') return 'MAX'
if (value === 'stretch') return 'STRETCH'
return 'MIN'
}
export function mapTextAlign(value: string | undefined): SceneNode['textAlignHorizontal'] {
if (value === 'center') return 'CENTER'
if (value === 'right' || value === 'end') return 'RIGHT'
if (value === 'justified') return 'JUSTIFIED'
return 'LEFT'
}
export function mapTextAlignVertical(value: string | undefined): TextAlignVertical {
if (value === 'center') return 'CENTER'
if (value === 'bottom' || value === 'end') return 'BOTTOM'
return 'TOP'
}
export function mapFontWeight(value: string | number | undefined): number {
if (typeof value === 'number') return value
if (value === 'thin') return 100
if (value === 'extralight') return 200
if (value === 'light') return 300
if (value === 'medium') return 500
if (value === 'semibold') return 600
if (value === 'bold') return 700
if (value === 'extrabold') return 800
if (value === 'black') return 900
return 400
}
export function mapNodeType(pen: PenNode): NodeType {
if (pen.type === 'frame') return pen.reusable ? 'COMPONENT' : 'FRAME'
if (pen.type === 'rectangle') return 'RECTANGLE'
if (pen.type === 'ellipse') return 'ELLIPSE'
if (pen.type === 'text' || pen.type === 'icon_font') return 'TEXT'
if (pen.type === 'path') return 'VECTOR'
if (pen.type === 'ref') return 'INSTANCE'
return 'FRAME'
}

View file

@ -1,513 +0,0 @@
import { copyEffects, copyFills, copyStrokes } from './copy'
import {
applyCornerRadius,
applyPadding,
bindIfVar,
buildVarContext,
convertEffects,
convertFill,
convertStroke,
isVarRef,
mapAlignItems,
mapFontWeight,
mapJustifyContent,
mapLayoutMode,
mapNodeType,
mapTextAlign,
mapTextAlignVertical,
parseSize,
type PenDocument,
type PenNode,
type VarContext
} from './pen-convert'
import { SceneGraph } from './scene-graph'
import { populateInstanceChildren } from './scene-graph-instances'
import { parseSVGPath } from './svg-path-parse'
import type { LayoutMode, LayoutSizing, SceneNode, VectorNetwork } from './scene-graph'
function scaleVectorNetwork(vn: VectorNetwork, targetW: number, targetH: number): void {
if (vn.vertices.length === 0) return
let minX = Infinity
let maxX = -Infinity
let minY = Infinity
let maxY = -Infinity
for (const v of vn.vertices) {
minX = Math.min(minX, v.x)
maxX = Math.max(maxX, v.x)
minY = Math.min(minY, v.y)
maxY = Math.max(maxY, v.y)
}
const vnW = maxX - minX
const vnH = maxY - minY
if (vnW < 0.01 || vnH < 0.01) return
const sx = targetW / vnW
const sy = targetH / vnH
if (Math.abs(sx - 1) < 0.01 && Math.abs(sy - 1) < 0.01) return
for (const v of vn.vertices) {
v.x = (v.x - minX) * sx
v.y = (v.y - minY) * sy
}
for (const s of vn.segments) {
s.tangentStart = { x: s.tangentStart.x * sx, y: s.tangentStart.y * sy }
s.tangentEnd = { x: s.tangentEnd.x * sx, y: s.tangentEnd.y * sy }
}
}
function resolveFontFamily(raw: string | undefined, ctx: VarContext): string {
if (!raw) return 'Inter'
if (isVarRef(raw)) return ctx.resolveString(raw)
return raw
}
function buildBaseOverrides(pen: PenNode): Partial<SceneNode> {
return {
id: pen.id,
name: pen.name ?? (pen.type === 'icon_font' ? (pen.iconFontName ?? 'Icon') : pen.type),
x: pen.x ?? 0,
y: pen.y ?? 0,
visible: pen.enabled !== false,
opacity: pen.opacity ?? 1,
rotation: pen.rotation ?? 0,
flipX: pen.flipX ?? false,
flipY: pen.flipY ?? false,
clipsContent: pen.clip ?? false,
boundVariables: {}
}
}
function applyAutoLayout(
overrides: Partial<SceneNode>,
layoutMode: LayoutMode,
pen: PenNode,
widthSizing: LayoutSizing,
heightSizing: LayoutSizing
): void {
overrides.layoutMode = layoutMode
overrides.primaryAxisAlign = mapJustifyContent(pen.justifyContent)
overrides.counterAxisAlign = mapAlignItems(pen.alignItems)
overrides.itemSpacing = pen.gap ?? 0
if (layoutMode === 'VERTICAL') {
overrides.primaryAxisSizing = heightSizing
overrides.counterAxisSizing = widthSizing
} else {
overrides.primaryAxisSizing = widthSizing
overrides.counterAxisSizing = heightSizing
}
}
function applyTextProps(node: SceneNode, pen: PenNode, ctx: VarContext): void {
node.text = pen.type === 'icon_font' ? (pen.iconFontName ?? '') : (pen.content ?? '')
node.fontFamily =
pen.type === 'icon_font'
? (pen.iconFontFamily ?? 'Material Symbols Sharp')
: resolveFontFamily(pen.fontFamily, ctx)
node.fontSize = pen.fontSize ?? 14
node.fontWeight = mapFontWeight(
pen.fontWeight ?? (pen.type === 'icon_font' ? pen.weight : undefined)
)
node.textAlignHorizontal = mapTextAlign(pen.textAlign)
node.textAlignVertical = mapTextAlignVertical(pen.textAlignVertical)
if (pen.lineHeight !== undefined) {
node.lineHeight = pen.lineHeight < 5 ? pen.lineHeight * node.fontSize : pen.lineHeight
}
if (pen.letterSpacing !== undefined) node.letterSpacing = pen.letterSpacing
node.textAutoResize = pen.textGrowth === 'fixed-width' ? 'HEIGHT' : 'WIDTH_AND_HEIGHT'
if (pen.fontFamily && isVarRef(pen.fontFamily)) {
bindIfVar(node, 'fontFamily', pen.fontFamily, ctx)
}
}
function resolveSizing(pen: PenNode, ctx: VarContext) {
const isTextLike = pen.type === 'text' || pen.type === 'icon_font'
const defaultSize = isTextLike ? 20 : 100
const defaultW = isTextLike && pen.width === undefined ? 10_000 : defaultSize
const w = parseSize(pen.width, defaultW, ctx)
const h = parseSize(pen.height, defaultSize, ctx)
const layout = mapLayoutMode(pen)
if (pen.width === undefined && layout !== 'NONE') w.sizing = 'HUG'
if (pen.height === undefined && layout !== 'NONE') h.sizing = 'HUG'
return { w, h, layout, isTextLike }
}
function inheritLayoutFromComp(node: SceneNode, pen: PenNode, comp: SceneNode): void {
const wasRow = node.layoutMode === 'HORIZONTAL'
node.layoutMode = comp.layoutMode
node.primaryAxisAlign = comp.primaryAxisAlign
node.counterAxisAlign = comp.counterAxisAlign
const isRow = node.layoutMode === 'HORIZONTAL'
if (wasRow !== isRow) {
const oldP = node.primaryAxisSizing
node.primaryAxisSizing = node.counterAxisSizing
node.counterAxisSizing = oldP
}
const widthAxis = isRow ? 'primaryAxisSizing' : 'counterAxisSizing'
const heightAxis = isRow ? 'counterAxisSizing' : 'primaryAxisSizing'
if (pen.width === undefined) node[widthAxis] = comp[widthAxis]
if (pen.height === undefined) node[heightAxis] = comp[heightAxis]
if (pen.gap === undefined) node.itemSpacing = comp.itemSpacing
if (pen.padding === undefined) {
node.paddingTop = comp.paddingTop
node.paddingRight = comp.paddingRight
node.paddingBottom = comp.paddingBottom
node.paddingLeft = comp.paddingLeft
}
if (pen.clip === undefined) node.clipsContent = comp.clipsContent
}
function applyRefVisuals(
node: SceneNode,
pen: PenNode,
compPen: PenNode | undefined,
ctx: VarContext
): void {
if (!compPen) return
if (pen.fill === undefined && compPen.fill !== undefined)
node.fills = convertFill(compPen.fill, ctx, node)
if (pen.stroke === undefined && compPen.stroke)
node.strokes = convertStroke(compPen.stroke, ctx, node)
if (pen.effect === undefined && compPen.effect) node.effects = convertEffects(compPen.effect)
if (pen.cornerRadius === undefined) applyCornerRadius(node, compPen.cornerRadius, ctx)
}
function applyRefProps(
node: SceneNode,
pen: PenNode,
graph: SceneGraph,
componentIds: Map<string, string>,
penSources: Map<string, PenNode>,
ctx: VarContext
): void {
if (!pen.ref) return
node.componentId = componentIds.get(pen.ref) ?? pen.ref
const comp = graph.getNode(node.componentId)
if (!comp) return
if (pen.width === undefined) node.width = comp.width
if (pen.height === undefined) node.height = comp.height
if (pen.layout === undefined) inheritLayoutFromComp(node, pen, comp)
applyRefVisuals(node, pen, penSources.get(pen.ref), ctx)
}
function applyAllRefProps(
penNodes: PenNode[],
graph: SceneGraph,
componentIds: Map<string, string>,
penSources: Map<string, PenNode>,
ctx: VarContext
): void {
for (const pen of penNodes) {
if (pen.type === 'ref') {
const node = graph.getNode(pen.id)
if (node) applyRefProps(node, pen, graph, componentIds, penSources, ctx)
}
if (pen.children) applyAllRefProps(pen.children, graph, componentIds, penSources, ctx)
}
}
function applyTheme(theme: Record<string, string>, ctx: VarContext): void {
const themeName = Object.values(theme)[0]
if (themeName) ctx.setActiveTheme(themeName)
}
// eslint-disable-next-line complexity -- .pen node mapping touches many format-specific fields
function createSceneNode(
pen: PenNode,
parentId: string,
graph: SceneGraph,
ctx: VarContext,
componentIds: Map<string, string>,
penSources: Map<string, PenNode>
): string | null {
if (pen.type === 'prompt') return null
if (pen.theme) applyTheme(pen.theme, ctx)
const { w, h, layout, isTextLike } = resolveSizing(pen, ctx)
const overrides = buildBaseOverrides(pen)
overrides.width = w.value
overrides.height = h.value
const parentLayout = graph.getNode(parentId)?.layoutMode ?? 'NONE'
if (layout !== 'NONE') {
const widthSizing =
parentLayout === 'NONE' && w.sizing === 'FILL' ? ('FIXED' as LayoutSizing) : w.sizing
const heightSizing =
parentLayout === 'NONE' && h.sizing === 'FILL' ? ('FIXED' as LayoutSizing) : h.sizing
applyAutoLayout(overrides, layout, pen, widthSizing, heightSizing)
}
const node = graph.createNode(mapNodeType(pen), parentId, overrides)
if (pen.fill !== undefined) node.fills = convertFill(pen.fill, ctx, node)
if (pen.stroke) node.strokes = convertStroke(pen.stroke, ctx, node)
node.effects = convertEffects(pen.effect)
applyCornerRadius(node, pen.cornerRadius, ctx)
applyPadding(node, pen.padding)
if (isTextLike) {
applyTextProps(node, pen, ctx)
if (parentLayout === 'NONE' && pen.width === undefined && !pen.textGrowth) {
node.textAutoResize = 'NONE'
node.width = node.text.length * node.fontSize * 0.65
node.height = node.fontSize * (node.lineHeight ? node.lineHeight / node.fontSize : 1.2)
}
}
if (pen.type === 'path' && pen.geometry) {
node.vectorNetwork = parseSVGPath(pen.geometry)
scaleVectorNetwork(node.vectorNetwork, node.width, node.height)
}
if (parentLayout !== 'NONE') {
const parentVertical = parentLayout === 'VERTICAL'
if (w.sizing === 'FILL') {
if (parentVertical) node.layoutAlignSelf = 'STRETCH'
else node.layoutGrow = 1
}
if (h.sizing === 'FILL') {
if (parentVertical) node.layoutGrow = 1
else node.layoutAlignSelf = 'STRETCH'
}
}
if (pen.reusable) {
componentIds.set(pen.id, node.id)
penSources.set(pen.id, pen)
}
if (pen.children) {
for (const child of pen.children) {
createSceneNode(child, node.id, graph, ctx, componentIds, penSources)
}
}
return node.id
}
function collectByNameType(
graph: SceneGraph,
parentId: string,
name: string,
type: string,
out: SceneNode[],
depth: number
): void {
if (depth > 2) return
const parent = graph.getNode(parentId)
if (!parent) return
for (const childId of parent.childIds) {
const child = graph.getNode(childId)
if (!child) continue
if (child.name === name && child.type === type) out.push(child)
collectByNameType(graph, childId, name, type, out, depth + 1)
}
}
function findCloneByComponentId(
graph: SceneGraph,
parentId: string,
origId: string
): SceneNode | undefined {
const parent = graph.getNode(parentId)
if (!parent) return undefined
for (const childId of parent.childIds) {
const child = graph.getNode(childId)
if (!child) continue
if (child.componentId === origId) return child
const deep = findCloneByComponentId(graph, childId, origId)
if (deep) return deep
}
return undefined
}
function findCloneByNameFallback(
graph: SceneGraph,
parentId: string,
origId: string
): SceneNode | undefined {
const orig = graph.getNode(origId)
if (!orig) return undefined
const matches: SceneNode[] = []
collectByNameType(graph, parentId, orig.name, orig.type, matches, 0)
return matches.length === 1 ? matches[0] : undefined
}
function applyOverrideProps(
target: SceneNode,
overrideData: Partial<PenNode>,
ctx: VarContext
): void {
if (overrideData.fill !== undefined) target.fills = convertFill(overrideData.fill, ctx, target)
if (overrideData.content !== undefined) target.text = overrideData.content
if (overrideData.x !== undefined) target.x = overrideData.x
if (overrideData.y !== undefined) target.y = overrideData.y
if (overrideData.enabled !== undefined) target.visible = overrideData.enabled
if (overrideData.width !== undefined)
target.width = parseSize(overrideData.width, target.width, ctx).value
if (overrideData.height !== undefined)
target.height = parseSize(overrideData.height, target.height, ctx).value
if (overrideData.rotation !== undefined) target.rotation = overrideData.rotation
if (overrideData.name !== undefined) target.name = overrideData.name
}
function populateInstances(graph: SceneGraph): void {
for (const node of graph.getAllNodes()) {
if (node.type === 'INSTANCE' && node.componentId && node.childIds.length === 0) {
const component = graph.getNode(node.componentId)
if (component) populateInstanceChildren(graph, node.id, node.componentId)
}
}
}
function applyDescendantOverrides(
graph: SceneGraph,
pen: PenNode,
ctx: VarContext,
componentIds: Map<string, string>,
penSources: Map<string, PenNode>
): void {
if (pen.type !== 'ref' || !pen.descendants) return
const instanceNode = graph.getNode(pen.id)
if (!instanceNode) return
for (const [origId, overrideData] of Object.entries(pen.descendants)) {
const clone =
findCloneByComponentId(graph, instanceNode.id, origId) ??
findCloneByNameFallback(graph, instanceNode.id, origId)
if (clone) {
if (overrideData.children) {
const toDelete = clone.childIds.slice()
for (const childId of toDelete) graph.deleteNode(childId)
for (const child of overrideData.children) {
createSceneNode(child, clone.id, graph, ctx, componentIds, penSources)
}
}
applyOverrideProps(clone, overrideData, ctx)
continue
}
if (overrideData.type && overrideData.id) {
createSceneNode(
overrideData as PenNode,
instanceNode.id,
graph,
ctx,
componentIds,
penSources
)
}
}
}
function walkAndApplyOverrides(
nodes: PenNode[],
graph: SceneGraph,
ctx: VarContext,
componentIds: Map<string, string>,
penSources: Map<string, PenNode>
): void {
for (const pen of nodes) {
applyDescendantOverrides(graph, pen, ctx, componentIds, penSources)
if (pen.children) walkAndApplyOverrides(pen.children, graph, ctx, componentIds, penSources)
}
}
function collectComponentIds(nodes: PenNode[], map: Map<string, string>): void {
for (const node of nodes) {
if (node.reusable) map.set(node.id, node.id)
if (node.children) collectComponentIds(node.children, map)
}
}
function resolveNodeVars(node: SceneNode, graph: SceneGraph, ctx: VarContext): void {
for (const [key, varId] of Object.entries(node.boundVariables)) {
const variable = graph.variables.get(varId)
if (!variable) continue
const modeVal =
variable.valuesByMode[ctx.activeModeId] ?? Object.values(variable.valuesByMode)[0]
if (key.startsWith('fills[') && typeof modeVal === 'object' && 'r' in modeVal) {
const idx = Number.parseInt(key.match(/\d+/)?.[0] ?? '0', 10)
if (node.fills[idx]) node.fills[idx].color = modeVal
} else if (key.startsWith('strokes[') && typeof modeVal === 'object' && 'r' in modeVal) {
const idx = Number.parseInt(key.match(/\d+/)?.[0] ?? '0', 10)
if (node.strokes[idx]) node.strokes[idx].color = modeVal
}
}
for (const childId of node.childIds) {
const child = graph.getNode(childId)
if (child) resolveNodeVars(child, graph, ctx)
}
}
function resolveThemeVariables(penNodes: PenNode[], graph: SceneGraph, ctx: VarContext): void {
for (const pen of penNodes) {
if (pen.theme) applyTheme(pen.theme, ctx)
const node = graph.getNode(pen.id)
if (node) resolveNodeVars(node, graph, ctx)
if (pen.children) resolveThemeVariables(pen.children, graph, ctx)
}
}
function fixInstanceWidths(graph: SceneGraph): void {
for (const node of graph.getAllNodes()) {
if (node.type !== 'INSTANCE' || !node.componentId) continue
const comp = graph.getNode(node.componentId)
if (!comp) continue
if (node.width <= 100 && comp.width > 100) node.width = comp.width
if (node.height <= 100 && comp.height > 100) node.height = comp.height
if (comp.layoutGrow > 0) node.layoutGrow = comp.layoutGrow
if (comp.layoutAlignSelf !== 'AUTO') node.layoutAlignSelf = comp.layoutAlignSelf
node.fills = copyFills(node.fills)
node.strokes = copyStrokes(node.strokes)
node.effects = copyEffects(node.effects)
}
}
function fixTextWidths(graph: SceneGraph): void {
for (const node of graph.getAllNodes()) {
if (node.type !== 'TEXT' || !node.text || node.text.length <= 1) continue
if (node.width >= node.fontSize * 2) continue
node.width = node.text.length * node.fontSize * 0.65
}
}
export function parsePenFile(json: string): SceneGraph {
const doc: PenDocument = JSON.parse(json)
const graph = new SceneGraph()
for (const page of graph.getPages(true)) {
graph.deleteNode(page.id)
}
const ctx = buildVarContext(graph, doc.variables ?? {}, doc.themes ?? {})
const componentIds = new Map<string, string>()
const penSources = new Map<string, PenNode>()
collectComponentIds(doc.children, componentIds)
const page = graph.addPage(doc.children[0]?.name ?? 'Page 1')
for (const child of doc.children) {
createSceneNode(child, page.id, graph, ctx, componentIds, penSources)
}
applyAllRefProps(doc.children, graph, componentIds, penSources, ctx)
populateInstances(graph)
walkAndApplyOverrides(doc.children, graph, ctx, componentIds, penSources)
populateInstances(graph)
resolveThemeVariables(doc.children, graph, ctx)
fixInstanceWidths(graph)
fixTextWidths(graph)
if (graph.getPages(true).length === 0) {
graph.addPage('Page 1')
}
return graph
}
export async function readPenFile(file: File): Promise<SceneGraph> {
return parsePenFile(await file.text())
}

View file

@ -1 +0,0 @@
export * from './io/formats/raster/render-image'

View file

@ -1,661 +0,0 @@
/* eslint-disable max-lines -- JSX export formats share helpers and node walking logic */
import { colorToHex8, colorToCSSCompact } from '../color'
import { DEFAULT_FONT_FAMILY } from '../constants'
import {
pxToSpacing,
colorToTwClass,
fontSizeToTw,
fontWeightToTw,
borderRadiusToTw,
opacityToTw
} from './tailwind'
import type {
SceneGraph,
SceneNode,
Fill,
Stroke,
Effect,
NodeType,
Color,
GridTrack
} from '../scene-graph'
export type JSXFormat = 'openpencil' | 'tailwind'
const NODE_TYPE_TO_TAG: Partial<Record<NodeType, string>> = {
FRAME: 'Frame',
RECTANGLE: 'Rectangle',
ROUNDED_RECTANGLE: 'Rectangle',
ELLIPSE: 'Ellipse',
TEXT: 'Text',
LINE: 'Line',
STAR: 'Star',
POLYGON: 'Polygon',
VECTOR: 'Vector',
GROUP: 'Group',
SECTION: 'Section',
COMPONENT: 'Component',
COMPONENT_SET: 'Frame',
INSTANCE: 'Frame'
}
const NODE_TYPE_TO_TW_TAG: Partial<Record<NodeType, string>> = {
FRAME: 'div',
RECTANGLE: 'div',
ROUNDED_RECTANGLE: 'div',
ELLIPSE: 'div',
TEXT: 'p',
LINE: 'div',
STAR: 'div',
POLYGON: 'div',
VECTOR: 'div',
GROUP: 'div',
SECTION: 'section',
COMPONENT: 'div',
COMPONENT_SET: 'div',
INSTANCE: 'div'
}
function formatColor(color: Color, opacity = 1): string {
return colorToHex8(color, opacity)
}
function solidFillColor(fills: Fill[]): string | null {
const visible = fills.filter((f) => f.visible && f.type === 'SOLID')
if (visible.length !== 1) return null
return formatColor(visible[0].color, visible[0].opacity)
}
function solidStroke(strokes: Stroke[]): { color: string; weight: number } | null {
const visible = strokes.filter((s) => s.visible)
if (visible.length !== 1) return null
return {
color: formatColor(visible[0].color, visible[0].opacity),
weight: visible[0].weight
}
}
function formatShadow(e: Effect): string | null {
if (e.type !== 'DROP_SHADOW' && e.type !== 'INNER_SHADOW') return null
return `${e.offset.x} ${e.offset.y} ${e.radius} ${formatColor(e.color, e.color.a)}`
}
function formatTailwindShadow(e: Effect): string | null {
if (e.type !== 'DROP_SHADOW' && e.type !== 'INNER_SHADOW') return null
const color = colorToCSSCompact(e.color)
const inset = e.type === 'INNER_SHADOW' ? 'inset_' : ''
const spread = e.spread !== 0 ? `_${e.spread}px` : ''
return `${inset}${e.offset.x}px_${e.offset.y}px_${e.radius}px${spread}_${color}`
}
function formatTailwindAngle(degrees: number): string {
const rounded = Number(degrees.toFixed(2))
const integer = Math.round(rounded)
const named = new Set([0, 1, 2, 3, 6, 12, 45, 90, 180])
if (rounded === integer && named.has(integer)) return String(integer)
return `[${rounded}deg]`
}
function formatTailwindFontFamily(fontFamily: string): string {
const escaped = fontFamily.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
return `['${escaped}']`
}
const JSX_ENTITY: Record<string, string> = {
'{': '&#123;',
'}': '&#125;',
'<': '&lt;',
'>': '&gt;',
'&': '&amp;'
}
function escapeJSXText(text: string): string {
return text.replace(/[{}<>&]/g, (c) => JSX_ENTITY[c])
}
function formatProp(key: string, value: unknown): string {
if (typeof value === 'string') return `${key}="${value}"`
if (typeof value === 'number') return `${key}={${value}}`
if (typeof value === 'boolean') return value ? key : `${key}={false}`
return `${key}={${JSON.stringify(value)}}`
}
function getNodeContext(node: SceneNode, graph: SceneGraph) {
const parent = node.parentId ? graph.getNode(node.parentId) : null
return {
isAutoLayout: node.layoutMode !== 'NONE',
isGrid: node.layoutMode === 'GRID',
isFlex: node.layoutMode === 'HORIZONTAL' || node.layoutMode === 'VERTICAL',
parentIsAutoLayout: parent ? parent.layoutMode !== 'NONE' : false,
parentIsGrid: parent ? parent.layoutMode === 'GRID' : false
}
}
type PaddingEdges = { pt: number; pr: number; pb: number; pl: number }
function collectPadding(node: SceneNode): PaddingEdges | null {
const { paddingTop: pt, paddingRight: pr, paddingBottom: pb, paddingLeft: pl } = node
if (pt === 0 && pr === 0 && pb === 0 && pl === 0) return null
return { pt, pr, pb, pl }
}
function emitPadding<T>(
edges: PaddingEdges,
uniform: (v: number) => T,
symmetric: (y: number, x: number) => T[],
individual: (edges: PaddingEdges) => T[]
): T[] {
const { pt, pr, pb, pl } = edges
if (pt === pr && pr === pb && pb === pl) return [uniform(pt)]
if (pt === pb && pl === pr) return symmetric(pt, pl)
return individual(edges)
}
interface CornerRadii {
tl: number
tr: number
br: number
bl: number
}
function collectCornerRadii(node: SceneNode): CornerRadii | null {
if (node.cornerRadius <= 0) return null
if (node.independentCorners) {
return {
tl: node.topLeftRadius,
tr: node.topRightRadius,
br: node.bottomRightRadius,
bl: node.bottomLeftRadius
}
}
const r = node.cornerRadius
return { tl: r, tr: r, br: r, bl: r }
}
function formatTrack(t: GridTrack): string {
if (t.sizing === 'FR') return `${t.value}fr`
if (t.sizing === 'FIXED') return `${t.value}px`
return 'auto'
}
function formatTracks(tracks: GridTrack[]): string {
return tracks.map(formatTrack).join(' ')
}
// --- OpenPencil format helpers ---
function collectGridSizingProps(node: SceneNode, props: [string, unknown][]): void {
props.push(['grid', true])
if (node.gridTemplateColumns.length > 0)
props.push(['columns', formatTracks(node.gridTemplateColumns)])
if (node.gridTemplateRows.length > 0) props.push(['rows', formatTracks(node.gridTemplateRows)])
if (node.width > 0) props.push(['w', node.width])
if (node.gridTemplateRows.length > 0 && node.height > 0) props.push(['h', node.height])
if (node.gridColumnGap > 0) props.push(['columnGap', node.gridColumnGap])
if (node.gridRowGap > 0) props.push(['rowGap', node.gridRowGap])
}
function collectFlexSizingProps(node: SceneNode, props: [string, unknown][]): void {
props.push(['flex', node.layoutMode === 'HORIZONTAL' ? 'row' : 'col'])
const primaryAxis = node.layoutMode === 'HORIZONTAL' ? 'width' : 'height'
const crossAxis = node.layoutMode === 'HORIZONTAL' ? 'height' : 'width'
if (node.primaryAxisSizing === 'FILL') props.push([primaryAxis === 'width' ? 'w' : 'h', 'fill'])
else if (node.primaryAxisSizing !== 'HUG')
props.push([primaryAxis === 'width' ? 'w' : 'h', node[primaryAxis]])
if (node.counterAxisSizing === 'FILL') props.push([crossAxis === 'width' ? 'w' : 'h', 'fill'])
else if (node.counterAxisSizing !== 'HUG')
props.push([crossAxis === 'width' ? 'w' : 'h', node[crossAxis]])
}
function collectGridPositionProps(node: SceneNode, props: [string, unknown][]): void {
if (!node.gridPosition) return
const pos = node.gridPosition
if (pos.column > 0) props.push(['colStart', pos.column])
if (pos.row > 0) props.push(['rowStart', pos.row])
if (pos.columnSpan > 1) props.push(['colSpan', pos.columnSpan])
if (pos.rowSpan > 1) props.push(['rowSpan', pos.rowSpan])
}
function collectFlexAlignmentProps(node: SceneNode, props: [string, unknown][]): void {
if (node.itemSpacing > 0) props.push(['gap', node.itemSpacing])
if (node.layoutWrap === 'WRAP') {
props.push(['wrap', true])
if (node.counterAxisSpacing > 0) props.push(['rowGap', node.counterAxisSpacing])
}
if (node.primaryAxisAlign === 'CENTER') props.push(['justify', 'center'])
else if (node.primaryAxisAlign === 'MAX') props.push(['justify', 'end'])
else if (node.primaryAxisAlign === 'SPACE_BETWEEN') props.push(['justify', 'between'])
if (node.counterAxisAlign === 'CENTER') props.push(['items', 'center'])
else if (node.counterAxisAlign === 'MAX') props.push(['items', 'end'])
else if (node.counterAxisAlign === 'STRETCH') props.push(['items', 'stretch'])
}
function collectAutoLayoutPaddingProps(node: SceneNode, props: [string, unknown][]): void {
const pad = collectPadding(node)
if (!pad) return
props.push(
...emitPadding(
pad,
(v) => ['p', v] as [string, unknown],
(y, x) =>
[
['py', y],
['px', x]
] as [string, unknown][],
({ pt, pr, pb, pl }) => {
const r: [string, unknown][] = []
if (pt > 0) r.push(['pt', pt])
if (pr > 0) r.push(['pr', pr])
if (pb > 0) r.push(['pb', pb])
if (pl > 0) r.push(['pl', pl])
return r
}
)
)
}
function collectCornerRadiiProps(node: SceneNode, props: [string, unknown][]): void {
const corners = collectCornerRadii(node)
if (!corners) return
const { tl, tr, br, bl } = corners
if (tl === tr && tr === br && br === bl) {
props.push(['rounded', tl])
} else {
if (tl > 0) props.push(['roundedTL', tl])
if (tr > 0) props.push(['roundedTR', tr])
if (br > 0) props.push(['roundedBR', br])
if (bl > 0) props.push(['roundedBL', bl])
}
}
function collectAppearanceProps(node: SceneNode, props: [string, unknown][]): void {
const bg = solidFillColor(node.fills)
if (bg) props.push(['bg', bg])
const stroke = solidStroke(node.strokes)
if (stroke) {
props.push(['stroke', stroke.color])
if (stroke.weight !== 1) props.push(['strokeWidth', stroke.weight])
}
collectCornerRadiiProps(node, props)
if (node.cornerSmoothing > 0) props.push(['cornerSmoothing', node.cornerSmoothing])
if (node.opacity < 1) props.push(['opacity', Math.round(node.opacity * 100) / 100])
if (node.rotation !== 0) props.push(['rotate', Math.round(node.rotation * 100) / 100])
if (node.blendMode !== 'PASS_THROUGH' && node.blendMode !== 'NORMAL') {
props.push(['blendMode', node.blendMode.toLowerCase()])
}
if (node.clipsContent) props.push(['overflow', 'hidden'])
for (const effect of node.effects) {
if (!effect.visible) continue
if (effect.type === 'DROP_SHADOW' || effect.type === 'INNER_SHADOW') {
const shadow = formatShadow(effect)
if (shadow) props.push(['shadow', shadow])
} else if (effect.type === 'LAYER_BLUR' || effect.type === 'BACKGROUND_BLUR') {
props.push(['blur', effect.radius])
}
}
}
function collectPositionProps(
node: SceneNode,
ctx: ReturnType<typeof getNodeContext>,
props: [string, unknown][]
): void {
if (ctx.parentIsAutoLayout || ctx.parentIsGrid) return
if (node.x !== 0) props.push(['x', node.x])
if (node.y !== 0) props.push(['y', node.y])
}
function collectSizingProps(
node: SceneNode,
ctx: ReturnType<typeof getNodeContext>,
graph: SceneGraph,
props: [string, unknown][]
): void {
if (ctx.isGrid) collectGridSizingProps(node, props)
else if (ctx.isFlex) collectFlexSizingProps(node, props)
else if (node.type === 'TEXT') collectTextSizingProps(node, graph, props)
else {
if (node.width > 0) props.push(['w', node.width])
if (node.height > 0) props.push(['h', node.height])
}
if (!ctx.parentIsAutoLayout) return
if (node.layoutGrow > 0) props.push(['grow', node.layoutGrow])
if (node.layoutAlignSelf === 'STRETCH') {
const parent = node.parentId ? graph.getNode(node.parentId) : null
if (parent && (parent.layoutMode === 'HORIZONTAL' || parent.layoutMode === 'VERTICAL')) {
const crossDim = parent.layoutMode === 'HORIZONTAL' ? 'h' : 'w'
if (!props.some(([k]) => k === crossDim)) props.push([crossDim, 'fill'])
}
}
}
function collectTextSizingProps(
node: SceneNode,
graph: SceneGraph,
props: [string, unknown][]
): void {
const autoResize = node.textAutoResize
const emitH = autoResize === 'NONE' || autoResize === 'TRUNCATE'
// Don't emit fixed w when text stretches to fill parent — the layoutAlignSelf
// check below will emit w="fill" instead. Without this guard, w={computedPx}
// gets emitted first and blocks the fill detection.
const isFillWidth =
node.layoutAlignSelf === 'STRETCH' &&
(() => {
const parent = node.parentId ? graph.getNode(node.parentId) : null
return parent?.layoutMode === 'VERTICAL'
})()
const isGrowWidth =
node.layoutGrow > 0 &&
(() => {
const parent = node.parentId ? graph.getNode(node.parentId) : null
return parent?.layoutMode === 'HORIZONTAL'
})()
const emitW = autoResize !== 'WIDTH_AND_HEIGHT' && !isFillWidth && !isGrowWidth
if (emitW && node.width > 0) props.push(['w', node.width])
if (emitH && node.height > 0) props.push(['h', node.height])
}
function collectTextNodeProps(node: SceneNode, props: [string, unknown][]): void {
if (node.fontSize !== 14) props.push(['size', node.fontSize])
if (node.fontFamily && node.fontFamily !== DEFAULT_FONT_FAMILY)
props.push(['font', node.fontFamily])
if (node.fontWeight !== 400) {
if (node.fontWeight === 700) props.push(['weight', 'bold'])
else if (node.fontWeight === 500) props.push(['weight', 'medium'])
else props.push(['weight', node.fontWeight])
}
if (node.textAlignHorizontal !== 'LEFT') {
props.push(['textAlign', node.textAlignHorizontal.toLowerCase()])
}
if (node.lineHeight != null) props.push(['lineHeight', node.lineHeight])
if (node.letterSpacing !== 0) props.push(['letterSpacing', node.letterSpacing])
if (node.textDecoration !== 'NONE')
props.push(['textDecoration', node.textDecoration.toLowerCase()])
if (node.textCase !== 'ORIGINAL') props.push(['textCase', node.textCase.toLowerCase()])
if (node.maxLines != null) props.push(['maxLines', node.maxLines])
if (node.textTruncation === 'ENDING' && node.maxLines == null) props.push(['truncate', true])
const textColor = solidFillColor(node.fills)
if (textColor) {
const bgIdx = props.findIndex(([k]) => k === 'bg')
if (bgIdx !== -1) props.splice(bgIdx, 1)
props.push(['color', textColor])
}
}
function collectShapeNodeProps(node: SceneNode, props: [string, unknown][]): void {
if (node.type === 'STAR') {
if (node.pointCount !== 5) props.push(['points', node.pointCount])
if (node.starInnerRadius !== 0.382) props.push(['innerRadius', node.starInnerRadius])
}
if (node.type === 'POLYGON' && node.pointCount !== 3) {
props.push(['points', node.pointCount])
}
}
function collectProps(node: SceneNode, graph: SceneGraph): [string, unknown][] {
const props: [string, unknown][] = []
const ctx = getNodeContext(node, graph)
if (node.name && node.name !== node.type) props.push(['name', node.name])
collectPositionProps(node, ctx, props)
collectSizingProps(node, ctx, graph, props)
if (ctx.parentIsGrid) collectGridPositionProps(node, props)
if (ctx.isFlex) collectFlexAlignmentProps(node, props)
if (ctx.isAutoLayout) collectAutoLayoutPaddingProps(node, props)
collectAppearanceProps(node, props)
if (node.type === 'TEXT') collectTextNodeProps(node, props)
collectShapeNodeProps(node, props)
return props
}
// --- Tailwind CSS v4 format helpers ---
function twRounded(prefix: string, px: number): string {
const r = borderRadiusToTw(px)
return r ? `${prefix}-${r}` : prefix
}
function gridTemplateTw(tracks: GridTrack[]): string {
const allEqual1Fr = tracks.every((t) => t.sizing === 'FR' && t.value === 1)
if (allEqual1Fr) return String(tracks.length)
return `[${tracks.map(formatTrack).join('_')}]`
}
function collectTwGridClasses(node: SceneNode, classes: string[]): void {
classes.push('grid')
if (node.gridTemplateColumns.length > 0)
classes.push(`grid-cols-${gridTemplateTw(node.gridTemplateColumns)}`)
if (node.gridTemplateRows.length > 0)
classes.push(`grid-rows-${gridTemplateTw(node.gridTemplateRows)}`)
if (node.width > 0) classes.push(`w-${pxToSpacing(node.width)}`)
if (node.gridTemplateRows.length > 0 && node.height > 0)
classes.push(`h-${pxToSpacing(node.height)}`)
if (node.gridColumnGap > 0) classes.push(`gap-x-${pxToSpacing(node.gridColumnGap)}`)
if (node.gridRowGap > 0) classes.push(`gap-y-${pxToSpacing(node.gridRowGap)}`)
}
function collectTwFlexSizingClasses(node: SceneNode, classes: string[]): void {
classes.push('flex')
if (node.layoutMode === 'VERTICAL') classes.push('flex-col')
const primaryAxis = node.layoutMode === 'HORIZONTAL' ? 'width' : 'height'
const crossAxis = node.layoutMode === 'HORIZONTAL' ? 'height' : 'width'
const wProp = primaryAxis === 'width' ? 'w' : 'h'
const hProp = crossAxis === 'width' ? 'w' : 'h'
if (node.primaryAxisSizing === 'FILL') classes.push(`${wProp}-full`)
else if (node.primaryAxisSizing !== 'HUG')
classes.push(`${wProp}-${pxToSpacing(node[primaryAxis])}`)
if (node.counterAxisSizing === 'FILL') classes.push(`${hProp}-full`)
else if (node.counterAxisSizing !== 'HUG')
classes.push(`${hProp}-${pxToSpacing(node[crossAxis])}`)
}
function collectTwGridPositionClasses(node: SceneNode, classes: string[]): void {
if (!node.gridPosition) return
const pos = node.gridPosition
if (pos.column > 0) classes.push(`col-start-${pos.column}`)
if (pos.row > 0) classes.push(`row-start-${pos.row}`)
if (pos.columnSpan > 1) classes.push(`col-span-${pos.columnSpan}`)
if (pos.rowSpan > 1) classes.push(`row-span-${pos.rowSpan}`)
}
function collectTwFlexAlignmentClasses(node: SceneNode, classes: string[]): void {
if (node.itemSpacing > 0) classes.push(`gap-${pxToSpacing(node.itemSpacing)}`)
if (node.layoutWrap === 'WRAP') {
classes.push('flex-wrap')
if (node.counterAxisSpacing > 0) classes.push(`gap-y-${pxToSpacing(node.counterAxisSpacing)}`)
}
if (node.primaryAxisAlign === 'CENTER') classes.push('justify-center')
else if (node.primaryAxisAlign === 'MAX') classes.push('justify-end')
else if (node.primaryAxisAlign === 'SPACE_BETWEEN') classes.push('justify-between')
if (node.counterAxisAlign === 'CENTER') classes.push('items-center')
else if (node.counterAxisAlign === 'MAX') classes.push('items-end')
else if (node.counterAxisAlign === 'STRETCH') classes.push('items-stretch')
}
function collectTwPaddingClasses(node: SceneNode, classes: string[]): void {
const pad = collectPadding(node)
if (!pad) return
classes.push(
...emitPadding(
pad,
(v) => `p-${pxToSpacing(v)}`,
(y, x) => [`py-${pxToSpacing(y)}`, `px-${pxToSpacing(x)}`],
({ pt, pr, pb, pl }) => {
const r: string[] = []
if (pt > 0) r.push(`pt-${pxToSpacing(pt)}`)
if (pr > 0) r.push(`pr-${pxToSpacing(pr)}`)
if (pb > 0) r.push(`pb-${pxToSpacing(pb)}`)
if (pl > 0) r.push(`pl-${pxToSpacing(pl)}`)
return r
}
)
)
}
function collectTwCornerRadiiClasses(node: SceneNode, classes: string[]): void {
const corners = collectCornerRadii(node)
if (!corners) return
const { tl, tr, br, bl } = corners
if (tl === tr && tr === br && br === bl) {
classes.push(twRounded('rounded', tl))
} else {
if (tl > 0) classes.push(twRounded('rounded-tl', tl))
if (tr > 0) classes.push(twRounded('rounded-tr', tr))
if (br > 0) classes.push(twRounded('rounded-br', br))
if (bl > 0) classes.push(twRounded('rounded-bl', bl))
}
}
function collectTwAppearanceClasses(node: SceneNode, classes: string[]): void {
const bg = solidFillColor(node.fills)
if (bg && node.type !== 'TEXT') classes.push(`bg-${colorToTwClass(bg)}`)
const stroke = solidStroke(node.strokes)
if (stroke) {
if (stroke.weight !== 1) classes.push(`border-${pxToSpacing(stroke.weight)}`)
else classes.push('border')
classes.push(`border-${colorToTwClass(stroke.color)}`)
}
collectTwCornerRadiiClasses(node, classes)
if (node.opacity < 1) classes.push(`opacity-${opacityToTw(node.opacity)}`)
if (node.rotation !== 0) classes.push(`rotate-${formatTailwindAngle(node.rotation)}`)
if (node.clipsContent) classes.push('overflow-hidden')
for (const effect of node.effects) {
if (!effect.visible) continue
if (effect.type === 'DROP_SHADOW' || effect.type === 'INNER_SHADOW') {
const shadow = formatTailwindShadow(effect)
if (shadow) classes.push(`shadow-[${shadow}]`)
} else if (effect.type === 'LAYER_BLUR' || effect.type === 'FOREGROUND_BLUR') {
classes.push(`blur-[${effect.radius}px]`)
} else {
classes.push(`backdrop-blur-[${effect.radius}px]`)
}
}
}
function collectTwTextClasses(node: SceneNode, classes: string[]): void {
classes.push(`text-${fontSizeToTw(node.fontSize)}`)
if (node.fontFamily && node.fontFamily !== DEFAULT_FONT_FAMILY) {
classes.push(`font-${formatTailwindFontFamily(node.fontFamily)}`)
}
if (node.fontWeight !== 400) classes.push(`font-${fontWeightToTw(node.fontWeight)}`)
if (node.textAlignHorizontal !== 'LEFT') {
classes.push(`text-${node.textAlignHorizontal.toLowerCase()}`)
}
const textColor = solidFillColor(node.fills)
if (textColor) classes.push(`text-${colorToTwClass(textColor)}`)
}
function collectTailwindClasses(node: SceneNode, graph: SceneGraph): string[] {
const classes: string[] = []
const ctx = getNodeContext(node, graph)
if (ctx.isGrid) collectTwGridClasses(node, classes)
else if (ctx.isFlex) collectTwFlexSizingClasses(node, classes)
else {
if (node.width > 0) classes.push(`w-${pxToSpacing(node.width)}`)
if (node.height > 0) classes.push(`h-${pxToSpacing(node.height)}`)
}
if (ctx.parentIsAutoLayout && node.layoutGrow > 0) classes.push('grow')
if (ctx.parentIsGrid) collectTwGridPositionClasses(node, classes)
if (ctx.isFlex) collectTwFlexAlignmentClasses(node, classes)
if (ctx.isAutoLayout) collectTwPaddingClasses(node, classes)
collectTwAppearanceClasses(node, classes)
if (node.type === 'TEXT') collectTwTextClasses(node, classes)
return classes
}
// --- JSX rendering ---
function nodeToJSX(node: SceneNode, graph: SceneGraph, indent: number, format: JSXFormat): string {
const tagMap = format === 'tailwind' ? NODE_TYPE_TO_TW_TAG : NODE_TYPE_TO_TAG
const tag = tagMap[node.type]
if (!tag) return ''
const prefix = ' '.repeat(indent)
let attrsStr: string
if (format === 'tailwind') {
const classes = collectTailwindClasses(node, graph)
const nameAttr = node.name && node.name !== node.type ? ` data-name="${node.name}"` : ''
const classAttr = classes.length > 0 ? ` className="${classes.join(' ')}"` : ''
attrsStr = `${nameAttr}${classAttr}`.trim()
} else {
const props = collectProps(node, graph)
attrsStr = props.map(([k, v]) => formatProp(k, v)).join(' ')
}
const opening = attrsStr ? `<${tag} ${attrsStr}` : `<${tag}`
const children = graph.getChildren(node.id)
if (node.type === 'TEXT') {
const text = node.text
if (!text) return `${prefix}${opening} />`
const escaped = escapeJSXText(text)
if (!escaped.includes('\n')) {
return `${prefix}${opening}>${escaped}</${tag}>`
}
return [
`${prefix}${opening}>`,
...escaped.split('\n').map((l) => `${prefix} ${l}`),
`${prefix}</${tag}>`
].join('\n')
}
if (children.length === 0) return `${prefix}${opening} />`
const childJSX = children
.filter((c) => c.visible)
.map((c) => nodeToJSX(c, graph, indent + 1, format))
.filter(Boolean)
if (childJSX.length === 0) return `${prefix}${opening} />`
return [`${prefix}${opening}>`, ...childJSX, `${prefix}</${tag}>`].join('\n')
}
export function sceneNodeToJSX(
nodeId: string,
graph: SceneGraph,
format: JSXFormat = 'openpencil'
): string {
const node = graph.getNode(nodeId)
if (!node) return ''
return nodeToJSX(node, graph, 0, format)
}
export function selectionToJSX(
nodeIds: string[],
graph: SceneGraph,
format: JSXFormat = 'openpencil'
): string {
return nodeIds
.map((id) => sceneNodeToJSX(id, graph, format))
.filter(Boolean)
.join('\n\n')
}

View file

@ -264,7 +264,7 @@ describe('flip roundtrip via kiwi', () => {
async function roundtrip(graph: SceneGraph) {
const { initCodec } = await import('../../packages/core/src/kiwi/codec')
const { parseFigFile } = await import('../../packages/core/src/kiwi/fig-file')
const { exportFigFile } = await import('../../packages/core/src/fig-export')
const { exportFigFile } = await import('../../packages/core/src/io/formats/fig/export')
await initCodec()
const buf = await exportFigFile(graph)
return parseFigFile(buf)