openpencil/packages/cli/src/commands/export.ts
2026-09-01 15:18:23 +03:00

384 lines
12 KiB
TypeScript

import { mkdir, writeFile } from 'node:fs/promises'
import { basename, dirname, extname, join, resolve } from 'node:path'
import { defineCommand } from 'citty'
import { decodeBase64 } from '@open-pencil/core/bytes'
import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core/io'
import type { RasterExportFormat } from '@open-pencil/core/io'
import {
exportHTMLBundle,
sceneGraphToDesignDocument,
type ExportHTMLBundleOptions
} from '@open-pencil/dom-css'
import { isAppMode, requireFile, rpc } from '#cli/app-client'
import { appTargetOptions, appTargetRPCArgs } from '#cli/app-target'
import { applyExportFontPolicy, exportFontRoots, FONT_POLICIES } from '#cli/export-font-policy'
import { ok, printError } from '#cli/format'
import { loadDocument, populateDocumentPage, populateWholeDocument } 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', 'PPTX', 'JSX', 'FIG', 'HTML'])
const JSX_STYLES = new Set(['openpencil', 'tailwind'])
const HTML_STYLES = new Set(['inline', 'tailwind'])
const HTML_MODES = new Set(['fragment', 'standalone'])
const HTML_ASSETS = new Set(['inline', 'external'])
const HTML_FONTS = new Set(['assets', 'none'])
interface ExportArgs {
file?: string
output?: string
format: string
scale: string
quality?: string
page?: string
node?: string
style: string
html: string
css: string
assets: string
fonts: string
'font-policy': string
thumbnail?: boolean
width: string
height: string
'document-id'?: string
'page-id'?: 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) {
const targetArgs = appTargetRPCArgs(args)
if (format === 'SVG') {
const result = await rpc<{ svg: string }>('tool', {
...targetArgs,
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', {
...targetArgs,
name: 'export_pdf',
args: { ids: args.node ? [args.node] : undefined }
})
if (!result.base64) {
printError('Nothing to export.')
process.exit(1)
}
const data = decodeBase64(result.base64)
await writeAndLog(resolve(args.output ?? 'export.pdf'), data)
return
}
if (format === 'JSX' || format === 'HTML' || format === 'FIG' || format === 'PPTX') {
printError(`${format} export is only available in file mode right now.`)
process.exit(1)
}
const result = await rpc<{ base64: string }>('export', {
...targetArgs,
nodeIds: args.node ? [args.node] : undefined,
scale: Number(args.scale),
format: format.toLowerCase()
})
const data = decodeBase64(result.base64)
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, wholeDocument = false): string {
if (wholeDocument) return 'whole document'
if (nodeId) return `node ${nodeId}`
return pageName ? `page "${pageName}"` : 'first page'
}
type FileExportTarget = { scope: 'node'; nodeId: string } | { scope: 'page'; pageId: string }
async function writeHTMLFiles(
output: string,
bundle: Awaited<ReturnType<typeof exportHTMLBundle>>
) {
const entrypoint = bundle.files.find((file) => file.path === bundle.entrypoint)
if (!entrypoint) {
printError(`HTML export did not include ${bundle.entrypoint}.`)
process.exit(1)
}
await writeAndLog(output, entrypoint.content)
const outputDir = dirname(output)
const assetFiles = bundle.files.filter((file) => file.path !== bundle.entrypoint)
for (const file of assetFiles) {
const assetPath = join(outputDir, file.path)
await mkdir(dirname(assetPath), { recursive: true })
await writeFile(assetPath, file.content)
}
if (assetFiles.length > 0) console.log(ok(`Assets: ${assetFiles.length} files`))
}
async function exportHTMLFromFile(
args: ExportArgs,
graph: Awaited<ReturnType<typeof loadDocument>>,
target: FileExportTarget,
defaultName: string
) {
const document = sceneGraphToDesignDocument(graph, {
rootId: target.scope === 'page' ? target.pageId : target.nodeId
})
const output = resolve(args.output ?? exportFileName(defaultName, 'html'))
const assetBasePath = `${basename(output, extname(output))}.assets`
const bundle = await exportHTMLBundle(document, {
html: args.html as ExportHTMLBundleOptions['html'],
style: args.css as ExportHTMLBundleOptions['style'],
assets: args.assets as ExportHTMLBundleOptions['assets'],
fonts: args.fonts as ExportHTMLBundleOptions['fonts'],
assetBasePath
})
await writeHTMLFiles(output, bundle)
console.log(ok(`Target: ${targetLabel(args.page, args.node)}`))
}
function prepareGraphForExport(
graph: Awaited<ReturnType<typeof loadDocument>>,
pageId: string,
format: string,
args: ExportArgs
): boolean {
const wholeDocument = (format === 'FIG' || format === 'PPTX') && !args.page && !args.node
if (wholeDocument || args.node) populateWholeDocument(graph)
else populateDocumentPage(graph, pageId)
return wholeDocument
}
async function executeFileExport(
formatId: string,
graph: Awaited<ReturnType<typeof loadDocument>>,
target: FileExportTarget,
options:
| { format?: string; scale?: number; quality?: number; renderThumbnail?: boolean }
| undefined,
wholeDocument: boolean
) {
if (wholeDocument) {
if (formatId === 'fig') return io.writeDocument(formatId, graph, options)
return io.exportContent(formatId, { graph, target: { scope: 'document' } }, options)
}
return io.exportContent(formatId, { graph, target }, options)
}
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 wholeDocument = prepareGraphForExport(graph, page.id, format, args)
const target = args.node
? { scope: 'node' as const, nodeId: args.node }
: { scope: 'page' as const, pageId: page.id }
await applyExportFontPolicy(
graph,
exportFontRoots(
args.node,
page.id,
pages.map((candidate) => candidate.id),
wholeDocument
),
format,
args['font-policy']
)
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 === 'HTML') {
await exportHTMLFromFile(args, graph, target, defaultName)
return
}
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 executeFileExport(formatId, graph, target, options, wholeDocument)
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, wholeDocument)}`))
}
export default defineCommand({
meta: { description: 'Export a document to PNG, JPG, WEBP, SVG, PDF, PPTX, JSX, HTML, 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, pptx, jsx, html, 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 (FIG defaults to the whole document)',
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 or tailwind (default: openpencil)',
default: 'openpencil'
},
html: {
type: 'string',
description: 'HTML output mode: fragment or standalone (default: fragment)',
default: 'fragment'
},
css: {
type: 'string',
description: 'HTML CSS output: inline or tailwind (default: inline)',
default: 'inline'
},
assets: {
type: 'string',
description: 'HTML asset output: inline or external (default: inline)',
default: 'inline'
},
fonts: {
type: 'string',
description: 'HTML font output: assets or none (default: none)',
default: 'none'
},
'font-policy': {
type: 'string',
description: 'Raster/PDF font policy: warn, strict, or allow (default: warn)',
default: 'warn'
},
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' },
...appTargetOptions
},
async run({ args }) {
const format = args.format.toUpperCase() as RasterExportFormat | 'SVG' | 'JSX' | 'FIG' | 'HTML'
if (!ALL_FORMATS.has(format)) {
printError(
`Invalid format "${args.format}". Use png, jpg, webp, svg, pdf, pptx, jsx, html, 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 (format === 'HTML' && !HTML_MODES.has(args.html)) {
printError(`Invalid HTML mode "${args.html}". Use fragment or standalone.`)
process.exit(1)
}
if (format === 'HTML' && !HTML_STYLES.has(args.css)) {
printError(`Invalid HTML CSS output "${args.css}". Use inline or tailwind.`)
process.exit(1)
}
if (format === 'HTML' && !HTML_ASSETS.has(args.assets)) {
printError(`Invalid HTML asset output "${args.assets}". Use inline or external.`)
process.exit(1)
}
if (format === 'HTML' && !HTML_FONTS.has(args.fonts)) {
printError(`Invalid HTML font output "${args.fonts}". Use assets or none.`)
process.exit(1)
}
if (!FONT_POLICIES.has(args['font-policy'])) {
printError(`Invalid font policy "${args['font-policy']}". Use warn, strict, or allow.`)
process.exit(1)
}
if (isAppMode(args.file)) {
await exportViaApp(format, args)
} else {
await exportFromFile(format, args)
}
}
})