Add shared IO format registry

This commit is contained in:
Danila Poyarkov 2026-03-28 01:58:05 +03:00
parent d52d38cb3b
commit 0001802dda
34 changed files with 1099 additions and 195 deletions

View file

@ -15,6 +15,10 @@
- Resume pen drawing from existing open path endpoints — click an endpoint to continue the curve
- Close open paths by dragging one endpoint to the other
- Align selected anchor points relative to each other in vector edit mode — the standard alignment buttons in the position panel now operate on selected vertices when 2 or more are selected
- Unified core IO format registry — `.fig` is now modeled as the native document format alongside shared export adapters for PNG, JPG, WEBP, SVG, and JSX
- 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
- CLI export now supports `.fig` output and routes PNG/JPG/WEBP/SVG/JSX/`.fig` through the shared IO layer
### Fixes

View file

@ -0,0 +1,56 @@
import { basename, extname, resolve } from 'node:path'
import { defineCommand } from 'citty'
import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core'
import { requireFile } from '../app-client'
import { ok, printError } from '../format'
import { loadDocument } from '../headless'
const io = new IORegistry(BUILTIN_IO_FORMATS)
const WRITABLE_FORMATS = ['FIG'] as const
type WritableFormat = (typeof WRITABLE_FORMATS)[number]
function defaultOutput(file: string, format: WritableFormat): string {
const base = basename(file, extname(file))
return resolve(`${base}.${format.toLowerCase()}`)
}
export default defineCommand({
meta: { description: 'Convert a document to another writable format' },
args: {
file: {
type: 'positional',
description: 'Input document file path',
required: true
},
output: {
type: 'string',
alias: 'o',
description: 'Output file path (default: <name>.<format>)',
required: false
},
format: {
type: 'string',
alias: 'f',
description: 'Output format: fig (default: fig)',
default: 'fig'
}
},
async run({ args }) {
const format = args.format.toUpperCase() as WritableFormat
if (!WRITABLE_FORMATS.includes(format)) {
printError(`Invalid format "${args.format}". Use fig.`)
process.exit(1)
}
const file = requireFile(args.file)
const graph = await loadDocument(file)
const result = await io.writeDocument(format.toLowerCase(), graph)
const output = args.output ? resolve(args.output) : defaultOutput(file, format)
await Bun.write(output, result.data as Uint8Array)
console.log(ok(`Converted ${file}${output}`))
}
})

View file

@ -28,7 +28,7 @@ export default defineCommand({
args: {
file: {
type: 'positional',
description: '.fig file path (omit to connect to running app)',
description: 'Document file path (omit to connect to running app)',
required: false
},
code: { type: 'string', alias: 'c', description: 'JavaScript code to execute' },
@ -89,10 +89,11 @@ export default defineCommand({
}
if (args.write || args.output) {
const { exportFigFile } = await import('@open-pencil/core')
const { BUILTIN_IO_FORMATS, IORegistry } = await import('@open-pencil/core')
const io = new IORegistry(BUILTIN_IO_FORMATS)
const outPath = args.output ? args.output : file
const data = await exportFigFile(graph)
await Bun.write(outPath, new Uint8Array(data))
const result = await io.writeDocument('fig', graph)
await Bun.write(outPath, result.data as Uint8Array)
if (!args.quiet) {
console.error(`Written to ${outPath}`)
}

View file

@ -2,16 +2,17 @@ import { basename, extname, resolve } from 'node:path'
import { defineCommand } from 'citty'
import { renderNodesToSVG, sceneNodeToJSX, selectionToJSX } from '@open-pencil/core'
import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core'
import { isAppMode, requireFile, rpc } from '../app-client'
import { ok, printError } from '../format'
import { loadDocument, exportNodes, exportThumbnail } from '../headless'
import { loadDocument } from '../headless'
import type { ExportFormat, JSXFormat } from '@open-pencil/core'
import type { RasterExportFormat } from '@open-pencil/core'
const io = new IORegistry(BUILTIN_IO_FORMATS)
const RASTER_FORMATS = ['PNG', 'JPG', 'WEBP']
const ALL_FORMATS = [...RASTER_FORMATS, 'SVG', 'JSX']
const ALL_FORMATS = [...RASTER_FORMATS, 'SVG', 'JSX', 'FIG']
const JSX_STYLES = ['openpencil', 'tailwind']
interface ExportArgs {
@ -48,17 +49,9 @@ async function exportViaApp(format: string, args: ExportArgs) {
return
}
if (format === 'JSX') {
const result = await rpc<{ jsx: string }>('export_jsx', {
nodeIds: args.node ? [args.node] : undefined,
style: args.style
})
if (!result.jsx) {
printError('Nothing to export.')
process.exit(1)
}
await writeAndLog(resolve(args.output ?? 'export.jsx'), result.jsx)
return
if (format === 'JSX' || format === 'FIG') {
printError(`${format} export is only available in file mode right now.`)
process.exit(1)
}
const result = await rpc<{ base64: string }>('export', {
@ -71,6 +64,15 @@ async function exportViaApp(format: string, args: ExportArgs) {
await writeAndLog(resolve(args.output ?? `export.${ext}`), data)
}
function exportFileName(defaultName: string, extension: string, scale?: number): string {
return scale ? `${defaultName}@${scale}x.${extension}` : `${defaultName}.${extension}`
}
function targetLabel(pageName?: string, nodeId?: string): string {
if (nodeId) return `node ${nodeId}`
return pageName ? `page "${pageName}"` : 'first page'
}
async function exportFromFile(format: string, args: ExportArgs) {
const file = requireFile(args.file)
const graph = await loadDocument(file)
@ -78,65 +80,62 @@ async function exportFromFile(format: string, args: ExportArgs) {
const pages = graph.getPages()
const page = args.page ? pages.find((p) => p.name === args.page) : pages[0]
if (!page) {
printError(`Page "${args.page}" not found.`)
const available = pages.map((p) => `"${p.name}"`).join(', ')
printError(
args.page
? `Page "${args.page}" not found. Available pages: ${available || 'none'}.`
: 'Document has no pages.'
)
process.exit(1)
}
const defaultName = basename(file, extname(file))
if (format === 'JSX') {
const nodeIds = args.node ? [args.node] : page.childIds
const jsxStr =
nodeIds.length === 1
? sceneNodeToJSX(nodeIds[0], graph, args.style as JSXFormat)
: selectionToJSX(nodeIds, graph, args.style as JSXFormat)
if (!jsxStr) {
printError('Nothing to export (empty page or no visible nodes).')
process.exit(1)
}
await writeAndLog(resolve(args.output ?? `${defaultName}.jsx`), jsxStr)
return
}
const ext = format.toLowerCase() === 'jpg' ? 'jpg' : format.toLowerCase()
const output = resolve(args.output ?? `${defaultName}.${ext}`)
if (format === 'SVG') {
const nodeIds = args.node ? [args.node] : page.childIds
const svgStr = renderNodesToSVG(graph, page.id, nodeIds)
if (!svgStr) {
printError('Nothing to export (empty page or no visible nodes).')
process.exit(1)
}
await writeAndLog(output, svgStr)
return
}
let data: Uint8Array | null
if (args.thumbnail) {
data = await exportThumbnail(graph, page.id, Number(args.width), Number(args.height))
} else {
const nodeIds = args.node ? [args.node] : page.childIds
data = await exportNodes(graph, page.id, nodeIds, {
scale: Number(args.scale),
format: format as ExportFormat,
quality: args.quality ? Number(args.quality) : undefined
})
}
if (!data) {
printError('Nothing to export (empty page or no visible nodes).')
if (args.page && args.node) {
printError('--page and --node cannot be used together.')
process.exit(1)
}
await writeAndLog(output, data)
const target = args.node
? { scope: 'node' as const, nodeId: args.node }
: { scope: 'page' as const, pageId: page.id }
if (args.thumbnail) {
printError('Thumbnail export is not supported by the shared file export path yet.')
process.exit(1)
}
const formatId = format.toLowerCase()
let options: { format?: string; scale?: number; quality?: number } | undefined
if (format === 'JSX') {
options = { format: args.style }
} else if (format === 'PNG' || format === 'JPG' || format === 'WEBP') {
options = {
format,
scale: Number(args.scale),
quality: args.quality ? Number(args.quality) : undefined
}
}
const result = await io.exportContent(formatId, { graph, target }, options)
const output = resolve(
args.output ??
exportFileName(
defaultName,
result.extension,
format === 'PNG' || format === 'JPG' || format === 'WEBP' ? Number(args.scale) : undefined
)
)
await writeAndLog(output, result.data as string | Uint8Array)
console.log(ok(`Target: ${targetLabel(args.page, args.node)}`))
}
export default defineCommand({
meta: { description: 'Export a .fig file to PNG, JPG, WEBP, SVG, or JSX' },
meta: { description: 'Export a document to PNG, JPG, WEBP, SVG, JSX, or .fig' },
args: {
file: {
type: 'positional',
description: '.fig file path (omit to connect to running app)',
description: 'Document file path (omit to connect to running app)',
required: false
},
output: {
@ -148,7 +147,7 @@ export default defineCommand({
format: {
type: 'string',
alias: 'f',
description: 'Export format: png, jpg, webp, svg, jsx (default: png)',
description: 'Export format: png, jpg, webp, svg, jsx, fig (default: png)',
default: 'png'
},
scale: { type: 'string', alias: 's', description: 'Export scale (default: 1)', default: '1' },
@ -158,10 +157,14 @@ export default defineCommand({
description: 'Quality 0-100 for JPG/WEBP (default: 90)',
required: false
},
page: { type: 'string', description: 'Page name (default: first page)', required: false },
page: {
type: 'string',
description: 'Export a specific page by name (default: first page)',
required: false
},
node: {
type: 'string',
description: 'Node ID to export (default: all top-level nodes)',
description: 'Export a specific node by ID (cannot be combined with --page)',
required: false
},
style: {
@ -174,9 +177,9 @@ export default defineCommand({
height: { type: 'string', description: 'Thumbnail height (default: 1080)', default: '1080' }
},
async run({ args }) {
const format = args.format.toUpperCase() as ExportFormat | 'JSX'
const format = args.format.toUpperCase() as RasterExportFormat | 'SVG' | 'JSX' | 'FIG'
if (!ALL_FORMATS.includes(format)) {
printError(`Invalid format "${args.format}". Use png, jpg, webp, svg, or jsx.`)
printError(`Invalid format "${args.format}". Use png, jpg, webp, svg, jsx, or fig.`)
process.exit(1)
}

View file

@ -28,7 +28,7 @@ export default defineCommand({
args: {
file: {
type: 'positional',
description: '.fig file path (omit to connect to running app)',
description: 'Document file path (omit to connect to running app)',
required: false
},
name: { type: 'string', description: 'Node name (partial match, case-insensitive)' },

View file

@ -0,0 +1,80 @@
import { defineCommand } from 'citty'
import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core'
import { bold, fmtList, kv } from '../format'
const io = new IORegistry(BUILTIN_IO_FORMATS)
function supportLabels(format: ReturnType<IORegistry['listFormats']>[number]): string[] {
const labels: string[] = []
if (format.support.readDocument) labels.push('read')
if (format.support.writeDocument) labels.push('write')
if (format.support.exportDocument) labels.push('export-document')
if (format.support.exportPage) labels.push('export-page')
if (format.support.exportSelection) labels.push('export-selection')
if (format.support.exportNode) labels.push('export-node')
return labels
}
export default defineCommand({
meta: { description: 'List supported document and export formats' },
args: {
json: { type: 'boolean', description: 'Output as JSON' }
},
async run({ args }) {
const formats = io.listFormats().map((format) => ({
id: format.id,
label: format.label,
role: format.role,
category: format.category,
extensions: format.extensions,
mimeTypes: format.mimeTypes,
support: supportLabels(format)
}))
if (args.json) {
console.log(JSON.stringify(formats, null, 2))
return
}
console.log('')
console.log(bold(` ${formats.length} format${formats.length !== 1 ? 's' : ''}`))
console.log('')
console.log(
fmtList(
formats.map((format) => ({
header: `${format.label} (${format.id})`,
details: {
role: format.role,
category: format.category,
ext: format.extensions.map((ext) => `.${ext}`).join(', '),
support: format.support.join(', '),
mime: format.mimeTypes.join(', ')
}
})),
{ compact: true }
)
)
console.log('')
console.log(
kv(
'Readable',
io
.listReadableFormats()
.map((f) => f.id)
.join(', ') || 'none'
)
)
console.log(
kv(
'Writable',
io
.listWritableFormats()
.map((f) => f.id)
.join(', ') || 'none'
)
)
console.log('')
}
})

View file

@ -19,7 +19,7 @@ export default defineCommand({
args: {
file: {
type: 'positional',
description: '.fig file path (omit to connect to running app)',
description: 'Document file path (omit to connect to running app)',
required: false
},
json: { type: 'boolean', description: 'Output as JSON' }

View file

@ -22,7 +22,7 @@ export default defineCommand({
args: {
file: {
type: 'positional',
description: '.fig file path (omit to connect to running app)',
description: 'Document file path (omit to connect to running app)',
required: false
},
id: { type: 'string', description: 'Node ID', required: true },

View file

@ -15,11 +15,11 @@ async function getData(file?: string): Promise<PageItem[]> {
}
export default defineCommand({
meta: { description: 'List pages in a .fig file' },
meta: { description: 'List pages in a document' },
args: {
file: {
type: 'positional',
description: '.fig file path (omit to connect to running app)',
description: 'Document file path (omit to connect to running app)',
required: false
},
json: { type: 'boolean', description: 'Output as JSON' }

View file

@ -31,13 +31,13 @@ Examples:
open-pencil query file.fig "//FRAME[@width < 300]" # Frames narrower than 300px
open-pencil query file.fig "//COMPONENT[starts-with(@name, 'Button')]" # Components starting with Button
open-pencil query file.fig "//SECTION/FRAME" # Direct frame children of sections
open-pencil query file.fig "//SECTION//TEXT" # All text inside sections
open-pencil query file.fig "//SECTION//TEXT" # All text inside sections
open-pencil query file.fig "//*[@cornerRadius > 0]" # Any node with corner radius`
},
args: {
file: {
type: 'positional',
description: '.fig file path (omit to connect to running app)',
description: 'Document file path (omit to connect to running app)',
required: false
},
selector: {

View file

@ -34,7 +34,7 @@ export default defineCommand({
args: {
file: {
type: 'positional',
description: '.fig file path (omit to connect to running app)',
description: 'Document file path (omit to connect to running app)',
required: false
},
page: { type: 'string', description: 'Page name (default: first page)' },

View file

@ -23,7 +23,7 @@ export default defineCommand({
args: {
file: {
type: 'positional',
description: '.fig file path (omit to connect to running app)',
description: 'Document file path (omit to connect to running app)',
required: false
},
collection: { type: 'string', description: 'Filter by collection name' },

View file

@ -1,36 +1,18 @@
import {
parseFigFile,
initCanvasKit,
type SceneGraph,
type ExportFormat,
BUILTIN_IO_FORMATS,
IORegistry,
computeAllLayouts,
headlessRenderNodes,
headlessRenderThumbnail
initCanvasKit,
type SceneGraph
} from '@open-pencil/core'
export { initCanvasKit }
const io = new IORegistry(BUILTIN_IO_FORMATS)
export async function loadDocument(filePath: string): Promise<SceneGraph> {
const data = await Bun.file(filePath).arrayBuffer()
const graph = await parseFigFile(data)
const bytes = new Uint8Array(await Bun.file(filePath).arrayBuffer())
const { graph } = await io.readDocument({ name: filePath, data: bytes })
computeAllLayouts(graph)
return graph
}
export async function exportNodes(
graph: SceneGraph,
pageId: string,
nodeIds: string[],
options: { scale?: number; format?: ExportFormat; quality?: number }
): Promise<Uint8Array | null> {
return headlessRenderNodes(graph, pageId, nodeIds, options)
}
export async function exportThumbnail(
graph: SceneGraph,
pageId: string,
width: number,
height: number
): Promise<Uint8Array | null> {
return headlessRenderThumbnail(graph, pageId, width, height)
}

View file

@ -2,9 +2,11 @@
import { defineCommand, runMain } from 'citty'
import analyze from './commands/analyze'
import convert from './commands/convert'
import evalCmd from './commands/eval'
import exportCmd from './commands/export'
import find from './commands/find'
import formats from './commands/formats'
import info from './commands/info'
import node from './commands/node'
import pages from './commands/pages'
@ -17,14 +19,16 @@ const { version } = await import('../package.json')
const main = defineCommand({
meta: {
name: 'open-pencil',
description: 'OpenPencil CLI — inspect, export, and lint .fig design files',
description: 'OpenPencil CLI — inspect, export, and lint OpenPencil design documents',
version
},
subCommands: {
analyze,
convert,
eval: evalCmd,
export: exportCmd,
find,
formats,
info,
query,
node,

View file

@ -74,6 +74,11 @@
"types": "./src/editor/index.ts",
"bun": "./src/editor/index.ts",
"default": "./src/editor/index.ts"
},
"./io": {
"types": "./src/io/index.ts",
"bun": "./src/io/index.ts",
"default": "./src/io/index.ts"
}
},
"main": "./src/index.ts",
@ -161,6 +166,11 @@
"types": "./dist/editor/index.d.ts",
"import": "./dist/editor/index.js",
"default": "./dist/editor/index.js"
},
"./io": {
"types": "./dist/io/index.d.ts",
"import": "./dist/io/index.js",
"default": "./dist/io/index.js"
}
},
"main": "./dist/index.js",

View file

@ -520,14 +520,12 @@ function solveMergedTangents(
const toRA = { x: vR.x - vA.x, y: vR.y - vA.y }
const toRB = { x: vR.x - vB.x, y: vR.y - vB.y }
const inner = { x: b1 * toRA.x + b2 * toRB.x, y: b1 * toRA.y + b2 * toRB.y }
const c =
Math.abs(inner.x) > Math.abs(inner.y)
? (inner.x !== 0
? rhs.x / inner.x
: 1)
: (inner.y !== 0
? rhs.y / inner.y
: 1)
let c = 1
if (Math.abs(inner.x) > Math.abs(inner.y)) {
if (inner.x !== 0) c = rhs.x / inner.x
} else if (inner.y !== 0) {
c = rhs.y / inner.y
}
return {
tangentStart: { x: c * toRA.x, y: c * toRA.y },
tangentEnd: { x: c * toRB.x, y: c * toRB.y }

View file

@ -9,6 +9,7 @@ import {
} from './figma-api-proxy'
import { computeBounds } from './geometry'
import type { RasterExportFormat } from './render-image'
import type {
SceneGraph,
NodeType,
@ -385,6 +386,6 @@ export class FigmaAPI implements NodeProxyHost {
exportImage?: (
nodeIds: string[],
options: { scale?: number; format?: 'PNG' | 'JPG' | 'WEBP'; quality?: number }
options: { scale?: number; format?: RasterExportFormat; quality?: number }
) => Promise<Uint8Array | null>
}

View file

@ -217,6 +217,7 @@ export {
renderNodesToImage,
renderThumbnail,
computeContentBounds,
type RasterExportFormat,
type ExportFormat
} from './render-image'
export { initCanvasKit, headlessRenderNodes, headlessRenderThumbnail } from './headless-render'
@ -341,6 +342,8 @@ export {
FIG_WIRE_MAGIC
} from './kiwi'
export * from './io'
export { CODEGEN_PROMPT } from './tools/prompts/codegen-prompt'
export {
setPexelsApiKey,

View file

@ -0,0 +1,276 @@
import { exportFigFile } from '../fig-export'
import { headlessRenderNodes } from '../headless-render'
import { parseFigFile } from '../kiwi'
import { sceneNodeToJSX, selectionToJSX } from '../render'
import { renderNodesToImage } from '../render-image'
import { renderNodesToSVG } from '../svg-export'
import { extractExportGraph } from './subgraph'
import type { RasterExportFormat } from '../render-image'
import type {
ExportRequest,
ExportResult,
FigWriteOptions,
IOContext,
IOFormatAdapter,
JSXExportOptions,
RasterExportOptions,
SVGExportOptions
} from './types'
function lowerExt(name: string): string {
const match = /\.([^.]+)$/.exec(name.toLowerCase())
return match?.[1] ?? ''
}
function ensureSingleNode(target: ExportRequest['target']): string | null {
if (target.scope === 'node') return target.nodeId
if (target.scope === 'selection' && target.nodeIds.length === 1) return target.nodeIds[0]
return null
}
function resolveExportNodes(request: ExportRequest): { pageId: string; nodeIds: string[] } | null {
switch (request.target.scope) {
case 'document': {
const page = request.graph.getPages()[0]
return { pageId: page.id, nodeIds: page.childIds }
}
case 'page': {
const page = request.graph.getNode(request.target.pageId)
if (!page) return null
return { pageId: page.id, nodeIds: page.childIds }
}
case 'selection': {
const first = request.target.nodeIds[0]
if (!first) return null
const node = request.graph.getNode(first)
if (!node) return null
let current = node.parentId ? request.graph.getNode(node.parentId) : undefined
while (current && current.type !== 'CANVAS') {
current = current.parentId ? request.graph.getNode(current.parentId) : undefined
}
if (!current) return null
return { pageId: current.id, nodeIds: request.target.nodeIds }
}
case 'node':
return resolveExportNodes({
...request,
target: { scope: 'selection', nodeIds: [request.target.nodeId] }
})
default:
return null
}
}
async function renderRaster(
request: ExportRequest,
options: RasterExportOptions,
context?: IOContext
): Promise<Uint8Array | null> {
const target = resolveExportNodes(request)
if (!target) return null
const scale = options.scale ?? 1
if (context?.canvasKit && context.renderer) {
return renderNodesToImage(
context.canvasKit,
context.renderer,
request.graph,
target.pageId,
target.nodeIds,
{
scale,
format: options.format,
quality: options.quality
}
)
}
return headlessRenderNodes(request.graph, target.pageId, target.nodeIds, {
scale,
format: options.format,
quality: options.quality
})
}
function rasterFormat(format: RasterExportFormat): IOFormatAdapter {
const extension = format === 'JPG' ? 'jpg' : format.toLowerCase()
let mimeType = 'image/png'
if (format === 'JPG') mimeType = 'image/jpeg'
else if (format === 'WEBP') mimeType = 'image/webp'
return {
id: extension,
label: format,
role: 'derived-export',
category: 'raster',
extensions: [extension],
mimeTypes: [mimeType],
support: {
exportDocument: true,
exportPage: true,
exportSelection: true,
exportNode: true
},
exportOptions: {
scale: true,
quality: format !== 'PNG'
},
async exportContent(request, options?: RasterExportOptions, context?: IOContext) {
const data = await renderRaster(
request,
{
format,
scale: options?.scale,
quality: options?.quality
},
context
)
if (!data) throw new Error('Nothing to export')
return {
format: extension,
mimeType,
extension,
data
}
}
}
}
export const figFormat: IOFormatAdapter = {
id: 'fig',
label: 'OpenPencil Document',
role: 'native-document',
category: 'document',
extensions: ['fig'],
mimeTypes: ['application/octet-stream'],
support: {
readDocument: true,
writeDocument: true,
exportDocument: true,
exportPage: true,
exportSelection: true,
exportNode: true
},
exportOptions: {
scale: false,
quality: false
},
matchesFile(fileName) {
return lowerExt(fileName) === 'fig'
},
async readDocument(input) {
const data = input.data.slice().buffer
const graph = await parseFigFile(data)
return { graph, sourceFormat: 'fig' }
},
async writeDocument(graph, options?: FigWriteOptions, context?: IOContext) {
const data = await exportFigFile(
graph,
context?.canvasKit,
context?.renderer,
options?.thumbnailPageId
)
return {
format: 'fig',
mimeType: 'application/octet-stream',
extension: 'fig',
data
}
},
async exportContent(request, options?: FigWriteOptions, context?: IOContext) {
const extracted = extractExportGraph(request.graph, request.target)
const data = await exportFigFile(
extracted.graph,
context?.canvasKit,
context?.renderer,
options?.thumbnailPageId ?? extracted.pageId ?? undefined
)
return {
format: 'fig',
mimeType: 'application/octet-stream',
extension: 'fig',
data
}
}
}
export const pngFormat = rasterFormat('PNG')
export const jpgFormat = rasterFormat('JPG')
export const webpFormat = rasterFormat('WEBP')
export const svgFormat: IOFormatAdapter = {
id: 'svg',
label: 'SVG',
role: 'derived-export',
category: 'vector',
extensions: ['svg'],
mimeTypes: ['image/svg+xml'],
support: {
exportDocument: true,
exportPage: true,
exportSelection: true,
exportNode: true
},
exportOptions: {
scale: false,
quality: false
},
async exportContent(request, options?: SVGExportOptions) {
const target = resolveExportNodes(request)
if (!target) throw new Error('Nothing to export')
const data = renderNodesToSVG(request.graph, target.pageId, target.nodeIds, options)
if (!data) throw new Error('Nothing to export')
return {
format: 'svg',
mimeType: 'image/svg+xml',
extension: 'svg',
data,
encoding: 'utf8'
}
}
}
export const jsxFormat: IOFormatAdapter = {
id: 'jsx',
label: 'JSX',
role: 'derived-export',
category: 'code',
extensions: ['jsx'],
mimeTypes: ['text/plain', 'text/jsx'],
support: {
exportSelection: true,
exportNode: true
},
exportOptions: {
scale: false,
quality: false
},
async exportContent(request, options?: JSXExportOptions): Promise<ExportResult> {
const format = options?.format ?? 'openpencil'
const nodeId = ensureSingleNode(request.target)
let data = ''
if (nodeId) {
data = sceneNodeToJSX(nodeId, request.graph, format)
} else if (request.target.scope === 'selection') {
data = selectionToJSX(request.target.nodeIds, request.graph, format)
}
if (!data) throw new Error('Nothing to export')
return {
format: 'jsx',
mimeType: 'text/plain',
extension: 'jsx',
data,
encoding: 'utf8'
}
}
}
export const BUILTIN_IO_FORMATS: IOFormatAdapter[] = [
figFormat,
pngFormat,
jpgFormat,
webpFormat,
svgFormat,
jsxFormat
]

View file

@ -0,0 +1,32 @@
export { IORegistry } from './registry'
export { extractExportGraph } from './subgraph'
export {
BUILTIN_IO_FORMATS,
figFormat,
pngFormat,
jpgFormat,
webpFormat,
svgFormat,
jsxFormat
} from './formats'
export type {
IOFormatRole,
IOFormatCategory,
IOTextEncoding,
IOBinaryData,
IOTextData,
IOData,
ReadDocumentInput,
ReadDocumentResult,
ExportTarget,
ExportRequest,
ExportResult,
IOContext,
FigWriteOptions,
RasterExportOptions,
SVGExportOptions,
JSXExportOptions,
IOFormatSupport,
IOFormatExportOptions,
IOFormatAdapter
} from './types'

View file

@ -0,0 +1,79 @@
import type { SceneGraph } from '../scene-graph'
import type { ExportRequest, IOContext, IOFormatAdapter, ReadDocumentInput } from './types'
export class IORegistry {
constructor(private readonly adapters: IOFormatAdapter[]) {}
listFormats(): IOFormatAdapter[] {
return this.adapters
}
getFormat(id: string): IOFormatAdapter | null {
return this.adapters.find((adapter) => adapter.id === id) ?? null
}
listReadableFormats(): IOFormatAdapter[] {
return this.adapters.filter((adapter) => adapter.support.readDocument)
}
listWritableFormats(): IOFormatAdapter[] {
return this.adapters.filter((adapter) => adapter.support.writeDocument)
}
listExportFormats(scope: ExportRequest['target']['scope']): IOFormatAdapter[] {
return this.adapters.filter((adapter) => {
switch (scope) {
case 'document':
return !!adapter.support.exportDocument
case 'page':
return !!adapter.support.exportPage
case 'selection':
return !!adapter.support.exportSelection
case 'node':
return !!adapter.support.exportNode
default:
return false
}
})
}
findReader(fileName: string, mimeType?: string): IOFormatAdapter | null {
return (
this.adapters.find((adapter) => {
if (!adapter.support.readDocument) return false
if (adapter.matchesFile) return adapter.matchesFile(fileName, mimeType)
const lower = fileName.toLowerCase()
return adapter.extensions.some((ext) => lower.endsWith(`.${ext}`))
}) ?? null
)
}
async readDocument(input: ReadDocumentInput, context?: IOContext) {
const reader = this.findReader(input.name ?? '', input.mimeType)
if (!reader?.readDocument) {
throw new Error(`Unsupported document format: ${input.name ?? 'unknown'}`)
}
return reader.readDocument(input, context)
}
async writeDocument(formatId: string, graph: SceneGraph, options?: unknown, context?: IOContext) {
const adapter = this.getFormat(formatId)
if (!adapter?.writeDocument) {
throw new Error(`Format does not support writeDocument: ${formatId}`)
}
return adapter.writeDocument(graph, options, context)
}
async exportContent(
formatId: string,
request: ExportRequest,
options?: unknown,
context?: IOContext
) {
const adapter = this.getFormat(formatId)
if (!adapter?.exportContent) {
throw new Error(`Format does not support exportContent: ${formatId}`)
}
return adapter.exportContent(request, options, context)
}
}

View file

@ -0,0 +1,202 @@
import { SceneGraph } from '../scene-graph'
import type { ExportTarget } from './types'
export interface ExtractedGraph {
graph: SceneGraph
pageId: string | null
nodeIds: string[]
}
function cloneIntoGraph(source: SceneGraph, ids: Set<string>): SceneGraph {
const graph = new SceneGraph()
const root = graph.getNode(graph.rootId)
if (root) {
root.childIds = []
root.width = 0
root.height = 0
}
graph.nodes = new Map()
if (root) graph.nodes.set(root.id, root)
graph.images = new Map(source.images)
graph.variables = new Map()
graph.variableCollections = new Map()
graph.activeMode = new Map(source.activeMode)
graph.figKiwiVersion = source.figKiwiVersion
const sortedIds = [...ids].sort((a, b) => {
if (a === source.rootId) return -1
if (b === source.rootId) return 1
const aNode = source.getNode(a)
const bNode = source.getNode(b)
const aDepth = depthOf(source, aNode)
const bDepth = depthOf(source, bNode)
return aDepth - bDepth
})
for (const id of sortedIds) {
const node = source.getNode(id)
if (!node) continue
graph.nodes.set(id, structuredClone(node))
}
const rootClone = graph.getNode(source.rootId)
if (rootClone) {
rootClone.parentId = null
rootClone.childIds = rootClone.childIds.filter((id) => ids.has(id))
}
for (const id of sortedIds) {
if (id === source.rootId) continue
const node = graph.getNode(id)
if (!node) continue
if (!node.parentId || !ids.has(node.parentId)) {
node.parentId = source.rootId
}
node.childIds = node.childIds.filter((childId) => ids.has(childId))
}
const variableIds = new Set<string>()
for (const node of graph.nodes.values()) {
for (const variableId of Object.values(node.boundVariables)) {
collectVariableClosure(source, variableId, variableIds)
}
}
for (const variableId of variableIds) {
const variable = source.variables.get(variableId)
if (!variable) continue
graph.variables.set(variableId, structuredClone(variable))
const collection = source.variableCollections.get(variable.collectionId)
if (!collection) continue
const existing = graph.variableCollections.get(collection.id)
if (!existing) {
graph.variableCollections.set(collection.id, {
...structuredClone(collection),
variableIds: []
})
}
graph.variableCollections.get(collection.id)?.variableIds.push(variableId)
}
graph.clearAbsPosCache()
return graph
}
function collectVariableClosure(source: SceneGraph, variableId: string, out: Set<string>) {
if (out.has(variableId)) return
const variable = source.variables.get(variableId)
if (!variable) return
out.add(variableId)
for (const value of Object.values(variable.valuesByMode)) {
if (typeof value === 'object' && 'aliasId' in value) {
collectVariableClosure(source, value.aliasId, out)
}
}
}
function depthOf(source: SceneGraph, node: ReturnType<SceneGraph['getNode']>): number {
let depth = 0
let current = node
while (current?.parentId) {
depth += 1
current = source.getNode(current.parentId)
}
return depth
}
function collectDescendants(source: SceneGraph, id: string, out: Set<string>) {
if (out.has(id)) return
out.add(id)
const node = source.getNode(id)
if (!node) return
for (const childId of node.childIds) {
collectDescendants(source, childId, out)
}
}
function ancestorChain(source: SceneGraph, id: string): string[] {
const chain: string[] = []
let current = source.getNode(id)
while (current?.parentId) {
chain.push(current.parentId)
current = source.getNode(current.parentId)
}
return chain.reverse()
}
function collectSelectionIds(source: SceneGraph, nodeIds: string[]): Set<string> {
const ids = new Set<string>([source.rootId])
const pageIds = new Set<string>()
for (const nodeId of nodeIds) {
const node = source.getNode(nodeId)
if (!node) continue
for (const ancestorId of ancestorChain(source, nodeId)) {
ids.add(ancestorId)
const ancestor = source.getNode(ancestorId)
if (ancestor?.type === 'CANVAS') pageIds.add(ancestorId)
}
collectDescendants(source, nodeId, ids)
}
for (const pageId of pageIds) {
ids.add(pageId)
}
return ids
}
function pageNodeIds(source: SceneGraph, pageId: string): Set<string> {
const ids = new Set<string>([source.rootId, pageId])
collectDescendants(source, pageId, ids)
return ids
}
function rootNodeIds(source: SceneGraph): Set<string> {
const ids = new Set<string>()
for (const node of source.nodes.values()) {
ids.add(node.id)
}
return ids
}
export function extractExportGraph(source: SceneGraph, target: ExportTarget): ExtractedGraph {
switch (target.scope) {
case 'document': {
const graph = cloneIntoGraph(source, rootNodeIds(source))
return {
graph,
pageId: graph.getPages()[0]?.id ?? null,
nodeIds: graph.getPages()[0]?.childIds ?? []
}
}
case 'page': {
const graph = cloneIntoGraph(source, pageNodeIds(source, target.pageId))
const page = graph.getNode(target.pageId)
return {
graph,
pageId: page?.id ?? null,
nodeIds: page?.childIds ?? []
}
}
case 'selection': {
const graph = cloneIntoGraph(source, collectSelectionIds(source, target.nodeIds))
const firstId = target.nodeIds[0]
const first = firstId ? source.getNode(firstId) : undefined
const pageId = first
? (ancestorChain(source, first.id).find((id) => source.getNode(id)?.type === 'CANVAS') ??
null)
: null
return {
graph,
pageId,
nodeIds: target.nodeIds.filter((id) => graph.getNode(id) !== undefined)
}
}
case 'node':
return extractExportGraph(source, { scope: 'selection', nodeIds: [target.nodeId] })
default:
return extractExportGraph(source, { scope: 'document' })
}
}

View file

@ -0,0 +1,123 @@
import type { JSXFormat } from '../render'
import type { RasterExportFormat } from '../render-image'
import type { SkiaRenderer } from '../renderer'
import type { SceneGraph } from '../scene-graph'
import type { CanvasKit } from 'canvaskit-wasm'
export type IOFormatRole = 'native-document' | 'interchange-document' | 'derived-export'
export type IOFormatCategory = 'document' | 'raster' | 'vector' | 'code' | 'print'
export type IOTextEncoding = 'utf8'
export type IOBinaryData = Uint8Array
export type IOTextData = string
export type IOData = IOBinaryData | IOTextData
export interface ReadDocumentInput {
name?: string
mimeType?: string
data: Uint8Array
}
export interface ReadDocumentResult {
graph: SceneGraph
sourceFormat: string
}
export interface ExportTargetDocument {
scope: 'document'
}
export interface ExportTargetPage {
scope: 'page'
pageId: string
}
export interface ExportTargetSelection {
scope: 'selection'
nodeIds: string[]
}
export interface ExportTargetNode {
scope: 'node'
nodeId: string
}
export type ExportTarget =
| ExportTargetDocument
| ExportTargetPage
| ExportTargetSelection
| ExportTargetNode
export interface ExportRequest {
graph: SceneGraph
target: ExportTarget
fileName?: string
}
export interface IOContext {
canvasKit?: CanvasKit
renderer?: SkiaRenderer
}
export interface FigWriteOptions {
thumbnailPageId?: string
}
export interface RasterExportOptions {
scale?: number
quality?: number
format: RasterExportFormat
}
export interface SVGExportOptions {
xmlDeclaration?: boolean
}
export interface JSXExportOptions {
format?: JSXFormat
}
export interface ExportResult {
format: string
mimeType: string
extension: string
data: IOData
encoding?: IOTextEncoding
}
export interface IOFormatSupport {
readDocument?: boolean
writeDocument?: boolean
exportDocument?: boolean
exportPage?: boolean
exportSelection?: boolean
exportNode?: boolean
}
export interface IOFormatExportOptions {
scale?: boolean
quality?: boolean
}
export interface IOFormatAdapter {
id: string
label: string
role: IOFormatRole
category: IOFormatCategory
extensions: string[]
mimeTypes: string[]
support: IOFormatSupport
exportOptions?: IOFormatExportOptions
matchesFile?(fileName: string, mimeType?: string): boolean
readDocument?(input: ReadDocumentInput, context?: IOContext): Promise<ReadDocumentResult>
writeDocument?(graph: SceneGraph, options?: unknown, context?: IOContext): Promise<ExportResult>
exportContent?(
request: ExportRequest,
options?: unknown,
context?: IOContext
): Promise<ExportResult>
}

View file

@ -2,7 +2,8 @@ import type { SkiaRenderer } from './renderer'
import type { SceneGraph } from './scene-graph'
import type { CanvasKit, Canvas } from 'canvaskit-wasm'
export type ExportFormat = 'PNG' | 'JPG' | 'WEBP' | 'SVG'
export type RasterExportFormat = 'PNG' | 'JPG' | 'WEBP'
export type ExportFormat = RasterExportFormat | 'SVG'
interface RenderOptions {
scale: number

View file

@ -2,6 +2,7 @@ import { cloneVectorNetwork } from '../scene-graph'
import { defineTool, nodeSummary } from './schema'
import type { FigmaAPI } from '../figma-api'
import type { RasterExportFormat } from '../render-image'
import type { SceneNode, VectorNetwork } from '../scene-graph'
function getVectorNode(
@ -304,7 +305,7 @@ export const exportImage = defineTool({
}
const ids =
args.ids && args.ids.length > 0 ? args.ids : figma.currentPage.children.map((n) => n.id)
const format = (args.format ?? 'PNG').toUpperCase() as 'PNG' | 'JPG' | 'WEBP'
const format = (args.format ?? 'PNG').toUpperCase() as RasterExportFormat
const data = await figma.exportImage(ids, {
scale: args.scale ?? 1,
format

View file

@ -1,22 +1,22 @@
import { ref } from 'vue'
import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core'
import { useEditor } from '@open-pencil/vue/context/editorContext'
import { useSceneComputed } from '@open-pencil/vue/internal/useSceneComputed'
import type { ExportFormat } from '@open-pencil/core'
export type ExportFormatId = 'png' | 'jpg' | 'webp' | 'svg' | 'fig'
/**
* Single export preset row managed by {@link useExport}.
*/
interface ExportSetting {
/** Export scale multiplier. */
scale: number
/** Output file format. */
format: ExportFormat
format: ExportFormatId
}
const SCALES = [0.5, 0.75, 1, 1.5, 2, 3, 4] as const
const FORMATS: ExportFormat[] = ['PNG', 'JPG', 'WEBP', 'SVG']
const FORMATS: ExportFormatId[] = ['png', 'jpg', 'webp', 'svg', 'fig']
const io = new IORegistry(BUILTIN_IO_FORMATS)
/**
* Returns selection-aware export settings for export panel UIs.
@ -27,10 +27,13 @@ const FORMATS: ExportFormat[] = ['PNG', 'JPG', 'WEBP', 'SVG']
export function useExport() {
const editor = useEditor()
const settings = ref<ExportSetting[]>([{ scale: 1, format: 'PNG' }])
const settings = ref<ExportSetting[]>([{ scale: 1, format: 'png' }])
const selectedIds = useSceneComputed(() => [...editor.state.selectedIds])
const formatSupportsScale = (format: ExportFormatId) =>
io.getFormat(format)?.exportOptions?.scale ?? false
const nodeName = useSceneComputed(() => {
const ids = editor.state.selectedIds
if (ids.size === 1) {
@ -43,7 +46,7 @@ export function useExport() {
function addSetting() {
const last = settings.value[settings.value.length - 1]
const nextScale = SCALES.find((s) => s > (last?.scale ?? 1)) ?? 2
settings.value.push({ scale: nextScale, format: last?.format ?? 'PNG' })
settings.value.push({ scale: nextScale, format: last?.format ?? 'png' })
}
function removeSetting(index: number) {
@ -54,7 +57,7 @@ export function useExport() {
settings.value[index] = { ...settings.value[index], scale }
}
function updateFormat(index: number, format: ExportFormat) {
function updateFormat(index: number, format: ExportFormatId) {
settings.value[index] = { ...settings.value[index], format }
}
@ -68,6 +71,7 @@ export function useExport() {
addSetting,
removeSetting,
updateScale,
updateFormat
updateFormat,
formatSupportsScale
}
}

View file

@ -1,7 +1,6 @@
import { FigmaAPI } from '@open-pencil/core'
import type { EditorStore } from '@/stores/editor'
import type { ExportFormat } from '@open-pencil/core'
export function makeFigmaFromStore(store: EditorStore): FigmaAPI {
const api = new FigmaAPI(store.graph)
@ -17,6 +16,6 @@ export function makeFigmaFromStore(store: EditorStore): FigmaAPI {
zoom: store.state.zoom
}
api.exportImage = (nodeIds, opts) =>
store.renderExportImage(nodeIds, opts.scale ?? 1, (opts.format ?? 'PNG') as ExportFormat)
store.renderExportImage(nodeIds, opts.scale ?? 1, opts.format ?? 'PNG')
return api
}

View file

@ -17,7 +17,7 @@ import {
} from '@open-pencil/core'
import type { EditorStore } from '@/stores/editor'
import type { ExportFormat } from '@open-pencil/core'
import type { RasterExportFormat } from '@open-pencil/core'
export function connectAutomation(getStore: () => EditorStore) {
const token = randomHex(32)
@ -96,7 +96,7 @@ export function connectAutomation(getStore: () => EditorStore) {
const data = await store.renderExportImage(
nodeIds,
exportArgs?.scale ?? 1,
(exportArgs?.format ?? 'PNG') as ExportFormat
(exportArgs?.format ?? 'PNG') as RasterExportFormat
)
if (!data) throw new Error('Export failed')
let binary = ''

View file

@ -81,7 +81,7 @@ const menuItems: MenuAction[] = [
{
icon: IconImageDown,
label: 'Export…',
action: () => store.exportSelection(1, 'PNG')
action: () => store.exportSelection(1, 'png')
},
{ icon: IconZoomIn, label: 'Zoom to fit', action: () => getCommand('view.zoomFit').run() }
]

View file

@ -7,18 +7,27 @@ import { sectionLabel, sectionWrapper } from '@/components/ui/section'
import { useEditorStore } from '@/stores/editor'
import { useExport, useI18n } from '@open-pencil/vue'
import type { ExportFormat } from '@open-pencil/core'
import type { ExportFormatId } from '@open-pencil/vue/controls/useExport'
const editorStore = useEditorStore()
const { panels } = useI18n()
const { settings, nodeName, addSetting, removeSetting, updateScale, updateFormat } = useExport()
const {
settings,
nodeName,
addSetting,
removeSetting,
updateScale,
updateFormat,
formatSupportsScale
} = useExport()
const SCALE_OPTIONS = [0.5, 0.75, 1, 1.5, 2, 3, 4].map((s) => ({ value: s, label: `${s}x` }))
const FORMAT_OPTIONS: { value: ExportFormat; label: string }[] = [
{ value: 'PNG', label: 'PNG' },
{ value: 'JPG', label: 'JPG' },
{ value: 'WEBP', label: 'WEBP' },
{ value: 'SVG', label: 'SVG' }
const FORMAT_OPTIONS: { value: ExportFormatId; label: string }[] = [
{ value: 'png', label: 'PNG' },
{ value: 'jpg', label: 'JPG' },
{ value: 'webp', label: 'WEBP' },
{ value: 'svg', label: 'SVG' },
{ value: 'fig', label: '.fig' }
]
const previewUrl = ref<string | null>(null)
@ -27,7 +36,7 @@ const exporting = ref(false)
const PREVIEW_WIDTH = 480
async function doExport(exportSettings: Array<{ scale: number; format: ExportFormat }>) {
async function doExport(exportSettings: Array<{ scale: number; format: ExportFormatId }>) {
exporting.value = true
try {
for (const s of exportSettings) await editorStore.exportSelection(s.scale, s.format)
@ -85,15 +94,15 @@ onScopeDispose(() => {
class="flex items-center gap-1.5 py-0.5"
>
<AppSelect
v-if="setting.format !== 'SVG'"
:model-value="setting.scale"
:options="SCALE_OPTIONS"
:disabled="!formatSupportsScale(setting.format)"
@update:model-value="updateScale(i, Number($event))"
/>
<AppSelect
:model-value="setting.format"
:options="FORMAT_OPTIONS"
@update:model-value="updateFormat(i, $event as ExportFormat)"
@update:model-value="updateFormat(i, $event as ExportFormatId)"
/>
<button :class="iconButton({ ui: { base: 'shrink-0' } })" @click="removeSetting(i)"></button>
</div>

View file

@ -49,10 +49,26 @@ export function useAppMenu(mod: string) {
{
label: t.value.exportSelection,
shortcut: `${mod}⇧E`,
action: () => {
void store.exportSelection(1, 'PNG')
},
disabled: store.state.selectedIds.size === 0
sub: [
{
label: 'PNG',
action: () => {
void store.exportSelection(1, 'png')
}
},
{
label: 'SVG',
action: () => {
void store.exportSelection(1, 'svg')
}
},
{
label: '.fig',
action: () => {
void store.exportSelection(1, 'fig')
}
}
]
},
{ separator: true as const },
{

View file

@ -167,7 +167,7 @@ export function useKeyboard() {
whenever(mod('shift+keyh'), () => runCommand('selection.toggleVisibility'))
whenever(mod('shift+keyl'), () => runCommand('selection.toggleLock'))
whenever(mod('shift+keye'), () => {
if (store.state.selectedIds.size > 0) void store.exportSelection(1, 'PNG')
if (store.state.selectedIds.size > 0) void store.exportSelection(1, 'png')
})
whenever(mod('shift+keys'), () => store.saveFigFileAs())
whenever(mod('shift+keyg'), () => runCommand('selection.ungroup'))

View file

@ -80,7 +80,7 @@ const MENU_ACTIONS: Partial<Record<string, () => void>> = {
'zoom-fit': () => store.zoomToFit(),
'zoom-selection': () => store.zoomToSelection(),
export: () => {
if (store.state.selectedIds.size > 0) void store.exportSelection(1, 'PNG')
if (store.state.selectedIds.size > 0) void store.exportSelection(1, 'png')
}
}

View file

@ -6,6 +6,7 @@ import { loadFont } from '@/engine/fonts'
import { toast } from '@/utils/toast'
import {
breakAtVertex,
BUILTIN_IO_FORMATS,
cloneVectorNetwork,
computeAccurateBounds,
createDefaultEditorState,
@ -14,12 +15,12 @@ import {
exportFigFile,
findAllHandles,
findOppositeHandle,
IORegistry,
mirrorHandle,
nearestPointOnNetwork,
readFigFile,
removeVertex,
renderNodesToImage,
renderNodesToSVG,
SceneGraph,
splitSegmentAt,
prefetchFigmaSchema
@ -27,8 +28,10 @@ import {
import type {
EditorState,
ExportFormat,
ExportRequest,
Fill,
IOFormatAdapter,
RasterExportFormat,
Rect,
SceneNode,
Vector,
@ -104,6 +107,7 @@ export function createEditorStore(initialGraph?: SceneGraph) {
})
const editor = createEditor({ graph, state, loadFont, skipInitialGraphSetup: !!initialGraph })
const io = new IORegistry(BUILTIN_IO_FORMATS)
if (initialGraph) {
editor.subscribeToGraph()
@ -790,7 +794,9 @@ export function createEditorStore(initialGraph?: SceneGraph) {
if (v > hi) hi = v
}
const target = align === 'min' ? lo : (align === 'max' ? hi : (lo + hi) / 2)
let target = (lo + hi) / 2
if (align === 'min') target = lo
else if (align === 'max') target = hi
for (const i of indices) {
es.vertices[i] = { ...es.vertices[i], [prop]: target }
}
@ -1034,7 +1040,7 @@ export function createEditorStore(initialGraph?: SceneGraph) {
async function renderExportImage(
nodeIds: string[],
scale: number,
format: ExportFormat
format: RasterExportFormat
): Promise<Uint8Array | null> {
const renderer = editor.renderer
if (!renderer) return null
@ -1047,59 +1053,71 @@ export function createEditorStore(initialGraph?: SceneGraph) {
})
}
function exportImageExtension(format: ExportFormat): string {
switch (format) {
case 'JPG':
return '.jpg'
case 'WEBP':
return '.webp'
default:
return '.png'
function getExportBaseName(target: ExportRequest['target']): string {
if (target.scope === 'node') {
return editor.graph.getNode(target.nodeId)?.name ?? 'Export'
}
if (target.scope === 'selection' && target.nodeIds.length === 1) {
return editor.graph.getNode(target.nodeIds[0])?.name ?? 'Export'
}
if (target.scope === 'page') {
return editor.graph.getNode(target.pageId)?.name ?? 'Page'
}
return 'Export'
}
function exportImageMime(format: ExportFormat): string {
switch (format) {
case 'JPG':
return 'image/jpeg'
case 'WEBP':
return 'image/webp'
default:
return 'image/png'
}
}
async function exportSelection(scale: number, format: ExportFormat) {
function getSelectionExportTarget(): ExportRequest['target'] {
const ids = [...state.selectedIds]
if (ids.length > 0) return { scope: 'selection', nodeIds: ids }
return { scope: 'page', pageId: state.currentPageId }
}
if (format === 'SVG') {
const nodeIds =
ids.length > 0 ? ids : editor.graph.getChildren(state.currentPageId).map((n) => n.id)
const svgStr = renderNodesToSVG(editor.graph, state.currentPageId, nodeIds)
if (!svgStr) {
console.error('Export failed: renderNodesToSVG returned null')
return
function listSelectionExportFormats(): IOFormatAdapter[] {
return io.listExportFormats(state.selectedIds.size > 0 ? 'selection' : 'page')
}
async function exportTarget(
target: ExportRequest['target'],
formatId: string,
options?: { scale?: number; quality?: number; jsxFormat?: 'openpencil' | 'tailwind' }
) {
const format = io.getFormat(formatId)
if (!format) throw new Error(`Unknown export format: ${formatId}`)
let exportOptions: unknown
if (formatId === 'png' || formatId === 'jpg' || formatId === 'webp') {
exportOptions = {
format: formatId.toUpperCase(),
scale: options?.scale ?? 1,
quality: options?.quality
}
const svgData = new TextEncoder().encode(svgStr)
const node = ids.length === 1 ? editor.graph.getNode(ids[0]) : undefined
const fileName = `${node?.name ?? 'Export'}.svg`
await saveExportedFile(svgData, fileName, 'SVG', '.svg', 'image/svg+xml')
return
} else if (formatId === 'jsx') {
exportOptions = { format: options?.jsxFormat ?? 'openpencil' }
}
const data = await renderExportImage(ids, scale, format)
if (!data) {
console.error(
`Export failed: renderExportImage returned null for format=${format} scale=${scale}`
)
return
}
const result = await io.exportContent(
formatId,
{ graph: editor.graph, target },
exportOptions,
editor.renderer ? { canvasKit: editor.renderer.ck, renderer: editor.renderer } : undefined
)
const node = ids.length === 1 ? editor.graph.getNode(ids[0]) : undefined
const baseName = node?.name ?? 'Export'
const ext = exportImageExtension(format)
const fileName = `${baseName}@${scale}x${ext}`
await saveExportedFile(new Uint8Array(data), fileName, format, ext, exportImageMime(format))
const baseName = getExportBaseName(target)
const fileName =
formatId === 'png' || formatId === 'jpg' || formatId === 'webp'
? `${baseName}@${options?.scale ?? 1}x.${result.extension}`
: `${baseName}.${result.extension}`
const bytes =
typeof result.data === 'string'
? new TextEncoder().encode(result.data)
: new Uint8Array(result.data)
await saveExportedFile(bytes, fileName, format.label, `.${result.extension}`, result.mimeType)
}
async function exportSelection(scale: number, formatId: 'png' | 'jpg' | 'webp' | 'svg' | 'fig') {
await exportTarget(getSelectionExportTarget(), formatId, { scale })
}
async function saveExportedFile(
@ -1205,6 +1223,8 @@ export function createEditorStore(initialGraph?: SceneGraph) {
saveFigFile,
saveFigFileAs,
renderExportImage,
listSelectionExportFormats,
exportTarget,
exportSelection,
mobileCopy,
mobileCut,