Add .pen document import

Co-authored-by: Anton Soldatov <eddclyde@yandex.ru>
This commit is contained in:
Danila Poyarkov 2026-03-28 03:02:29 +03:00
parent 0001802dda
commit e396df3224
11 changed files with 2227 additions and 10 deletions

View file

@ -19,6 +19,7 @@
- Export selection or current page as `.fig` from the app export UI and app menu - Export selection or current page as `.fig` from the app export UI and app menu
- New CLI commands: `open-pencil convert` for document conversion and `open-pencil formats` to inspect readable/writable/exportable formats - New CLI commands: `open-pencil convert` for document conversion and `open-pencil formats` to inspect readable/writable/exportable formats
- CLI export now supports `.fig` output and routes PNG/JPG/WEBP/SVG/JSX/`.fig` through the shared IO layer - CLI export now supports `.fig` output and routes PNG/JPG/WEBP/SVG/JSX/`.fig` through the shared IO layer
- `Open…` now supports `.pen` Pencil documents through the shared document reader pipeline while keeping `.fig` as the native save format
### Fixes ### Fixes

View file

@ -297,6 +297,8 @@ export {
type OpenPencilClipboardData type OpenPencilClipboardData
} from './clipboard' } from './clipboard'
export { readPenFile, parsePenFile } from './pen-file'
export { export {
readFigFile, readFigFile,
parseFigFile, parseFigFile,

View file

@ -1,6 +1,7 @@
import { exportFigFile } from '../fig-export' import { exportFigFile } from '../fig-export'
import { headlessRenderNodes } from '../headless-render' import { headlessRenderNodes } from '../headless-render'
import { parseFigFile } from '../kiwi' import { parseFigFile } from '../kiwi'
import { parsePenFile } from '../pen-file'
import { sceneNodeToJSX, selectionToJSX } from '../render' import { sceneNodeToJSX, selectionToJSX } from '../render'
import { renderNodesToImage } from '../render-image' import { renderNodesToImage } from '../render-image'
import { renderNodesToSVG } from '../svg-export' import { renderNodesToSVG } from '../svg-export'
@ -195,6 +196,26 @@ export const figFormat: IOFormatAdapter = {
} }
} }
export const penFormat: IOFormatAdapter = {
id: 'pen',
label: 'Pencil Document',
role: 'interchange-document',
category: 'document',
extensions: ['pen'],
mimeTypes: ['application/json', 'text/plain'],
support: {
readDocument: true
},
matchesFile(fileName, mimeType) {
return lowerExt(fileName) === 'pen' || mimeType === 'application/json'
},
async readDocument(input) {
const text = new TextDecoder().decode(input.data)
const graph = parsePenFile(text)
return { graph, sourceFormat: 'pen' }
}
}
export const pngFormat = rasterFormat('PNG') export const pngFormat = rasterFormat('PNG')
export const jpgFormat = rasterFormat('JPG') export const jpgFormat = rasterFormat('JPG')
export const webpFormat = rasterFormat('WEBP') export const webpFormat = rasterFormat('WEBP')
@ -268,6 +289,7 @@ export const jsxFormat: IOFormatAdapter = {
export const BUILTIN_IO_FORMATS: IOFormatAdapter[] = [ export const BUILTIN_IO_FORMATS: IOFormatAdapter[] = [
figFormat, figFormat,
penFormat,
pngFormat, pngFormat,
jpgFormat, jpgFormat,
webpFormat, webpFormat,

View file

@ -3,6 +3,7 @@ export { extractExportGraph } from './subgraph'
export { export {
BUILTIN_IO_FORMATS, BUILTIN_IO_FORMATS,
figFormat, figFormat,
penFormat,
pngFormat, pngFormat,
jpgFormat, jpgFormat,
webpFormat, webpFormat,

View file

@ -0,0 +1,463 @@
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

@ -0,0 +1,513 @@
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

@ -5,7 +5,7 @@ import { IS_BROWSER, IS_TAURI } from '@/constants'
import { useEditorStore } from '@/stores/editor' import { useEditorStore } from '@/stores/editor'
import { openFileInNewTab, createTab, closeTab, activeTab } from '@/stores/tabs' import { openFileInNewTab, createTab, closeTab, activeTab } from '@/stores/tabs'
const fileDialog = useFileDialog({ accept: '.fig', multiple: false, reset: true }) const fileDialog = useFileDialog({ accept: '.fig,.pen', multiple: false, reset: true })
fileDialog.onChange((files) => { fileDialog.onChange((files) => {
const file = files?.[0] const file = files?.[0]
if (file) void openFileInNewTab(file) if (file) void openFileInNewTab(file)
@ -28,7 +28,7 @@ export async function openFileDialog() {
const { open } = await import('@tauri-apps/plugin-dialog') const { open } = await import('@tauri-apps/plugin-dialog')
const { readFile } = await import('@tauri-apps/plugin-fs') const { readFile } = await import('@tauri-apps/plugin-fs')
const path = await open({ const path = await open({
filters: [{ name: 'Figma file', extensions: ['fig'] }], filters: [{ name: 'Design file', extensions: ['fig', 'pen'] }],
multiple: false multiple: false
}) })
if (!path) return if (!path) return
@ -43,8 +43,12 @@ export async function openFileDialog() {
const [handle] = await window.showOpenFilePicker({ const [handle] = await window.showOpenFilePicker({
types: [ types: [
{ {
description: 'Figma file', description: 'Design file',
accept: { 'application/octet-stream': ['.fig'] } accept: {
'application/octet-stream': ['.fig'],
'application/json': ['.pen'],
'text/plain': ['.pen']
}
} }
] ]
}) })
@ -59,11 +63,16 @@ export async function openFileDialog() {
fileDialog.open() fileDialog.open()
} }
export async function importFileDialog() {
await openFileDialog()
}
const store = useEditorStore() const store = useEditorStore()
const MENU_ACTIONS: Partial<Record<string, () => void>> = { const MENU_ACTIONS: Partial<Record<string, () => void>> = {
new: () => createTab(), new: () => createTab(),
open: () => void openFileDialog(), open: () => void openFileDialog(),
import: () => void importFileDialog(),
close: () => { close: () => {
if (activeTab.value) closeTab(activeTab.value.id) if (activeTab.value) closeTab(activeTab.value.id)
}, },

View file

@ -1,5 +1,7 @@
import { shallowRef, computed, triggerRef } from 'vue' import { shallowRef, computed, triggerRef } from 'vue'
import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core'
import { createEditorStore, setActiveEditorStore } from './editor' import { createEditorStore, setActiveEditorStore } from './editor'
import type { EditorStore } from './editor' import type { EditorStore } from './editor'
@ -10,6 +12,8 @@ export interface Tab {
store: EditorStore store: EditorStore
} }
const io = new IORegistry(BUILTIN_IO_FORMATS)
let nextTabId = 1 let nextTabId = 1
function generateTabId(): string { function generateTabId(): string {
@ -82,23 +86,26 @@ export async function openFileInNewTab(
const current = activeTab.value const current = activeTab.value
const isUntouched = const isUntouched =
current?.store.state.documentName === 'Untitled' && !current.store.undo.canUndo current?.store.state.documentName === 'Untitled' && !current.store.undo.canUndo
const bytes = new Uint8Array(await file.arrayBuffer())
const { graph: imported } = await io.readDocument({
name: file.name,
mimeType: file.type || undefined,
data: bytes
})
const documentName = file.name.replace(/\.[^.]+$/i, '')
if (isUntouched) { if (isUntouched) {
const { readFigFile } = await import('@open-pencil/core')
const imported = await readFigFile(file)
current.store.replaceGraph(imported) current.store.replaceGraph(imported)
current.store.undo.clear() current.store.undo.clear()
current.store.state.documentName = file.name.replace(/\.fig$/i, '') current.store.state.documentName = documentName
current.store.state.selectedIds = new Set() current.store.state.selectedIds = new Set()
const pageId = current.store.graph.getPages()[0]?.id ?? current.store.graph.rootId const pageId = current.store.graph.getPages()[0]?.id ?? current.store.graph.rootId
await current.store.switchPage(pageId) await current.store.switchPage(pageId)
} else { } else {
const { readFigFile } = await import('@open-pencil/core')
const imported = await readFigFile(file)
const store = createEditorStore(imported) const store = createEditorStore(imported)
createTab(store) createTab(store)
store.undo.clear() store.undo.clear()
store.state.documentName = file.name.replace(/\.fig$/i, '') store.state.documentName = documentName
store.state.selectedIds = new Set() store.state.selectedIds = new Set()
const pageId = store.graph.getPages()[0]?.id ?? store.graph.rootId const pageId = store.graph.getPages()[0]?.id ?? store.graph.rootId
await store.switchPage(pageId) await store.switchPage(pageId)

View file

@ -0,0 +1,37 @@
import { describe, expect, test } from 'bun:test'
import { join } from 'node:path'
import { parsePenFile } from '@open-pencil/core'
const FIXTURE_DIR = join(import.meta.dir, '..', 'fixtures')
async function loadFixture(name: string): Promise<string> {
return Bun.file(join(FIXTURE_DIR, name)).text()
}
describe('parsePenFile', () => {
test('imports variables and theme modes', async () => {
const graph = parsePenFile(await loadFixture('pencil_simple.pen'))
const collections = [...graph.variableCollections.values()]
expect(collections.length).toBe(1)
expect(collections[0].modes.map((mode) => mode.name)).toEqual(['Light', 'Dark'])
expect(graph.variables.size).toBeGreaterThan(0)
})
test('maps reusable frames to components', async () => {
const graph = parsePenFile(await loadFixture('pencil_button.pen'))
const components = [...graph.getAllNodes()].filter((node) => node.type === 'COMPONENT')
expect(components.length).toBeGreaterThan(0)
expect(components[0]?.name).toContain('Button')
})
test('maps path geometry to vector nodes', async () => {
const graph = parsePenFile(await loadFixture('pencil_button.pen'))
const vectors = [...graph.getAllNodes()].filter((node) => node.type === 'VECTOR')
expect(vectors.length).toBeGreaterThan(0)
expect(vectors.some((node) => (node.vectorNetwork?.vertices.length ?? 0) > 0)).toBe(true)
})
})

113
tests/fixtures/pencil_button.pen vendored Normal file
View file

@ -0,0 +1,113 @@
{
"version": "2.8",
"children": [
{
"type": "frame",
"id": "T3Um0",
"x": 462.7680165404179,
"y": 258.2749319076538,
"name": "Button/Large/Default",
"reusable": true,
"height": 48,
"fill": "$--primary",
"cornerRadius": "$--radius-pill",
"gap": 6,
"padding": [
12,
24
],
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "fd4qq",
"name": "plus",
"width": 24,
"height": 24,
"stroke": {
"align": "inside",
"thickness": 1
},
"layout": "none",
"children": [
{
"type": "path",
"id": "NWqkO",
"x": 6,
"y": 6,
"name": "Vector",
"geometry": "M7.07104 3.53555l-3.53552 0-3.53552 0m3.53552-3.53555l0 3.53555 0 3.53549",
"width": 12,
"height": 12,
"stroke": {
"align": "center",
"thickness": 1.5,
"join": "round",
"cap": "round",
"fill": "$--primary-foreground"
}
}
]
},
{
"type": "text",
"id": "8RtfK",
"name": "Button",
"fill": "$--primary-foreground",
"content": "Button",
"lineHeight": 1.5555555555555556,
"textAlign": "center",
"textAlignVertical": "middle",
"fontFamily": "$--font-primary",
"fontSize": 14,
"fontWeight": "500"
}
]
}
],
"themes": {
"Mode": [
"Light",
"Dark"
]
},
"variables": {
"--primary": {
"type": "color",
"value": [
{
"value": "#FF8400"
},
{
"value": "#FF8400",
"theme": {
"Mode": "Dark"
}
}
]
},
"--radius-pill": {
"type": "number",
"value": 999
},
"--primary-foreground": {
"type": "color",
"value": [
{
"value": "#111111"
},
{
"value": "#111111",
"theme": {
"Mode": "Dark"
}
}
]
},
"--font-primary": {
"type": "string",
"value": "JetBrains Mono"
}
}
}

1049
tests/fixtures/pencil_simple.pen vendored Normal file

File diff suppressed because it is too large Load diff