openpencil/packages/cli/src/commands/export.ts
Joseph Cumines f862beb08d Add heuristic overlap detection tool and CLI
Implement the analyze overlaps CLI command, RPC endpoint, and ToolDef
to enable heuristic detection of visual overlaps, parent overflows, and
overlay patterns. Geometry computation now uses world-matrix visual
bounds to handle nested ancestor rotations and clipping frames correctly.

Update file-backed CLI commands to use Node fs/promises instead of Bun
runtime file APIs. This is required for the published CLI to function
when installed and executed in a standard Node environment. Add the
package.json subpath export to the core package for metadata consumers.
2026-06-19 09:54:27 +10:00

216 lines
6.7 KiB
TypeScript

import { writeFile } from 'node:fs/promises'
import { basename, extname, resolve } from 'node:path'
import { defineCommand } from 'citty'
import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core/io'
import type { RasterExportFormat } from '@open-pencil/core/io'
import { isAppMode, requireFile, rpc } from '#cli/app-client'
import { ok, printError } from '#cli/format'
import { loadDocument } from '#cli/headless'
const io = new IORegistry(BUILTIN_IO_FORMATS)
const RASTER_FORMATS = ['PNG', 'JPG', 'WEBP']
const ALL_FORMATS = new Set([...RASTER_FORMATS, 'SVG', 'PDF', 'JSX', 'FIG'])
const JSX_STYLES = new Set(['openpencil', 'tailwind'])
interface ExportArgs {
file?: string
output?: string
format: string
scale: string
quality?: string
page?: string
node?: string
style: string
thumbnail?: boolean
width: string
height: string
}
async function writeAndLog(path: string, content: string | Uint8Array) {
await writeFile(path, content)
const size = typeof content === 'string' ? content.length : content.length
console.log(ok(`Exported ${path} (${(size / 1024).toFixed(1)} KB)`))
}
async function exportViaApp(format: string, args: ExportArgs) {
if (format === 'SVG') {
const result = await rpc<{ svg: string }>('tool', {
name: 'export_svg',
args: { ids: args.node ? [args.node] : undefined }
})
if (!result.svg) {
printError('Nothing to export.')
process.exit(1)
}
await writeAndLog(resolve(args.output ?? 'export.svg'), result.svg)
return
}
if (format === 'PDF') {
const result = await rpc<{ base64: string }>('tool', {
name: 'export_pdf',
args: { ids: args.node ? [args.node] : undefined }
})
if (!result.base64) {
printError('Nothing to export.')
process.exit(1)
}
const data = Uint8Array.from(atob(result.base64), (c) => c.charCodeAt(0))
await writeAndLog(resolve(args.output ?? 'export.pdf'), data)
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', {
nodeIds: args.node ? [args.node] : undefined,
scale: Number(args.scale),
format: format.toLowerCase()
})
const data = Uint8Array.from(atob(result.base64), (c) => c.charCodeAt(0))
const ext = format.toLowerCase() === 'jpg' ? 'jpg' : format.toLowerCase()
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)
const pages = graph.getPages()
const page = args.page ? pages.find((p) => p.name === args.page) : pages[0]
if (!page) {
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 (args.page && args.node) {
printError('--page and --node cannot be used together.')
process.exit(1)
}
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; renderThumbnail?: boolean }
| undefined
if (format === 'JSX') {
options = { format: args.style }
} else if (format === 'FIG') {
options = { renderThumbnail: true }
} 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 document to PNG, JPG, WEBP, SVG, PDF, JSX, or .fig' },
args: {
file: {
type: 'positional',
description: 'Document file path (omit to connect to running app)',
required: false
},
output: {
type: 'string',
alias: 'o',
description: 'Output file path (default: <name>.<format>)',
required: false
},
format: {
type: 'string',
alias: 'f',
description: 'Export format: png, jpg, webp, svg, pdf, jsx, fig (default: png)',
default: 'png'
},
scale: { type: 'string', alias: 's', description: 'Export scale (default: 1)', default: '1' },
quality: {
type: 'string',
alias: 'q',
description: 'Quality 0-100 for JPG/WEBP (default: 90)',
required: false
},
page: {
type: 'string',
description: 'Export a specific page by name (default: first page)',
required: false
},
node: {
type: 'string',
description: 'Export a specific node by ID (cannot be combined with --page)',
required: false
},
style: {
type: 'string',
description: 'JSX style: openpencil, tailwind (default: openpencil)',
default: 'openpencil'
},
thumbnail: { type: 'boolean', description: 'Export page thumbnail instead of full render' },
width: { type: 'string', description: 'Thumbnail width (default: 1920)', default: '1920' },
height: { type: 'string', description: 'Thumbnail height (default: 1080)', default: '1080' }
},
async run({ args }) {
const format = args.format.toUpperCase() as RasterExportFormat | 'SVG' | 'JSX' | 'FIG'
if (!ALL_FORMATS.has(format)) {
printError(`Invalid format "${args.format}". Use png, jpg, webp, svg, pdf, jsx, or fig.`)
process.exit(1)
}
if (format === 'JSX' && !JSX_STYLES.has(args.style)) {
printError(`Invalid JSX style "${args.style}". Use openpencil or tailwind.`)
process.exit(1)
}
if (isAppMode(args.file)) {
await exportViaApp(format, args)
} else {
await exportFromFile(format, args)
}
}
})