refactor(export): derive export format lists from the IO registry (#755)

The Export panel, SDK helpers, app menus, and CLI each kept their own
hand-written format lists, so new formats such as PPTX reached some
surfaces and not others.

Scene Graph now owns the persisted export-setting format ids, Core IO
adapters carry literal ids so that list is checked against real adapters,
and the panel labels, scale handling, app format types, and CLI format
validation/help are derived from the registry. PPTX joins the Export
panel as a result.
This commit is contained in:
Danila Poyarkov 2026-09-25 15:42:42 +04:00 committed by GitHub
parent 99925e4c25
commit 2d9e83d58a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 127 additions and 74 deletions

View file

@ -6,6 +6,10 @@
- The desktop app now requires macOS 13 or later; the web app supports Chrome 111, Edge 111, Firefox 128, and Safari 16.4 or later.
### Added
- Choose PPTX in the Export panel's format list, alongside PNG, JPG, WEBP, SVG, and PDF.
### Fixed
- Show what to update instead of a blank window when the browser or system WebView is too old, naming the detected macOS, Safari, Chrome, Edge, Firefox, WebKitGTK, or WebView2 version and linking a prefilled bug report, and explain a failed start the same way (#744).

View file

@ -5,7 +5,6 @@ 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,
@ -19,8 +18,14 @@ 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'])
// HTML goes through the DOM/CSS bundle exporter; every other format is a Core IO adapter.
const FORMAT_IDS = [...io.listExportFormats('node').map((format) => format.id), 'html']
const ALL_FORMATS = new Set(FORMAT_IDS.map((id) => id.toUpperCase()))
const FORMAT_LIST = `${FORMAT_IDS.slice(0, -1).join(', ')}, or ${FORMAT_IDS.at(-1)}`
function formatSupportsScale(format: string): boolean {
return io.getFormat(format.toLowerCase())?.exportOptions?.scale ?? false
}
const JSX_STYLES = new Set(['openpencil', 'tailwind'])
const HTML_STYLES = new Set(['inline', 'tailwind'])
const HTML_MODES = new Set(['fragment', 'standalone'])
@ -242,7 +247,7 @@ async function exportFromFile(format: string, args: ExportArgs) {
options = { format: args.style }
} else if (format === 'FIG') {
options = { renderThumbnail: true }
} else if (format === 'PNG' || format === 'JPG' || format === 'WEBP') {
} else if (formatSupportsScale(format)) {
options = {
format,
scale: Number(args.scale),
@ -256,7 +261,7 @@ async function exportFromFile(format: string, args: ExportArgs) {
exportFileName(
defaultName,
result.extension,
format === 'PNG' || format === 'JPG' || format === 'WEBP' ? Number(args.scale) : undefined
formatSupportsScale(format) ? Number(args.scale) : undefined
)
)
await writeAndLog(output, result.data as string | Uint8Array)
@ -264,7 +269,7 @@ async function exportFromFile(format: string, args: ExportArgs) {
}
export default defineCommand({
meta: { description: 'Export a document to PNG, JPG, WEBP, SVG, PDF, PPTX, JSX, HTML, or .fig' },
meta: { description: `Export a document to ${FORMAT_LIST}` },
args: {
file: {
type: 'positional',
@ -280,7 +285,7 @@ export default defineCommand({
format: {
type: 'string',
alias: 'f',
description: 'Export format: png, jpg, webp, svg, pdf, pptx, jsx, html, fig (default: png)',
description: `Export format: ${FORMAT_IDS.join(', ')} (default: png)`,
default: 'png'
},
scale: { type: 'string', alias: 's', description: 'Export scale (default: 1)', default: '1' },
@ -336,11 +341,9 @@ export default defineCommand({
...appTargetOptions
},
async run({ args }) {
const format = args.format.toUpperCase() as RasterExportFormat | 'SVG' | 'JSX' | 'FIG' | 'HTML'
const format = args.format.toUpperCase()
if (!ALL_FORMATS.has(format)) {
printError(
`Invalid format "${args.format}". Use png, jpg, webp, svg, pdf, pptx, jsx, html, or fig.`
)
printError(`Invalid format "${args.format}". Use ${FORMAT_LIST}.`)
process.exit(1)
}

View file

@ -93,8 +93,8 @@ async function renderRaster(
})
}
function rasterFormat(format: RasterExportFormat): IOFormatAdapter {
const extension = format === 'JPG' ? 'jpg' : format.toLowerCase()
function rasterFormat<F extends RasterExportFormat>(format: F): IOFormatAdapter<Lowercase<F>> {
const extension = format.toLowerCase() as Lowercase<F>
let mimeType = 'image/png'
if (format === 'JPG') mimeType = 'image/jpeg'
else if (format === 'WEBP') mimeType = 'image/webp'
@ -138,7 +138,7 @@ function rasterFormat(format: RasterExportFormat): IOFormatAdapter {
}
}
export const figFormat: IOFormatAdapter = {
export const figFormat: IOFormatAdapter<'fig'> = {
id: 'fig',
label: 'OpenPencil Document',
role: 'native-document',
@ -198,7 +198,7 @@ export const figFormat: IOFormatAdapter = {
}
}
export const penFormat: IOFormatAdapter = {
export const penFormat: IOFormatAdapter<'pen'> = {
id: 'pen',
label: 'Pencil Document',
role: 'interchange-document',
@ -222,7 +222,7 @@ export const pngFormat = rasterFormat('PNG')
export const jpgFormat = rasterFormat('JPG')
export const webpFormat = rasterFormat('WEBP')
export const svgFormat: IOFormatAdapter = {
export const svgFormat: IOFormatAdapter<'svg'> = {
id: 'svg',
label: 'SVG',
role: 'derived-export',
@ -255,7 +255,7 @@ export const svgFormat: IOFormatAdapter = {
}
}
export const pdfFormat: IOFormatAdapter = {
export const pdfFormat: IOFormatAdapter<'pdf'> = {
id: 'pdf',
label: 'PDF',
role: 'derived-export',
@ -300,7 +300,7 @@ function resolvePPTXExportNodes(
return { pageId: pages[0].id, nodeIds: pages.flatMap((page) => page.childIds) }
}
export const pptxFormat: IOFormatAdapter = {
export const pptxFormat: IOFormatAdapter<'pptx'> = {
id: 'pptx',
label: 'PowerPoint',
role: 'derived-export',
@ -335,7 +335,7 @@ export const pptxFormat: IOFormatAdapter = {
}
}
export const jsxFormat: IOFormatAdapter = {
export const jsxFormat: IOFormatAdapter<'jsx'> = {
id: 'jsx',
label: 'JSX',
role: 'derived-export',
@ -370,7 +370,7 @@ export const jsxFormat: IOFormatAdapter = {
}
}
export const BUILTIN_IO_FORMATS: IOFormatAdapter[] = [
export const BUILTIN_IO_FORMATS = [
figFormat,
penFormat,
pngFormat,
@ -380,4 +380,6 @@ export const BUILTIN_IO_FORMATS: IOFormatAdapter[] = [
pdfFormat,
pptxFormat,
jsxFormat
]
] as const satisfies readonly IOFormatAdapter[]
export type BuiltinIOFormatId = (typeof BUILTIN_IO_FORMATS)[number]['id']

View file

@ -2,6 +2,7 @@ export { IORegistry } from './registry'
export { extractExportGraph } from './subgraph'
export {
BUILTIN_IO_FORMATS,
type BuiltinIOFormatId,
figFormat,
penFormat,
pngFormat,

View file

@ -3,9 +3,9 @@ import type { SceneGraph } from '@open-pencil/scene-graph'
import type { ExportRequest, IOContext, IOFormatAdapter, ReadDocumentInput } from './types'
export class IORegistry {
constructor(private readonly adapters: IOFormatAdapter[]) {}
constructor(private readonly adapters: readonly IOFormatAdapter[]) {}
listFormats(): IOFormatAdapter[] {
listFormats(): readonly IOFormatAdapter[] {
return this.adapters
}

View file

@ -109,8 +109,8 @@ export interface IOFormatExportOptions {
colorSpace?: boolean
}
export interface IOFormatAdapter {
id: string
export interface IOFormatAdapter<Id extends string = string> {
id: Id
label: string
role: IOFormatRole
category: IOFormatCategory

View file

@ -44,7 +44,7 @@ exportState.addSetting()
```ts
exportState.updateScale(0, 2)
exportState.updateFormat(0, 'WEBP')
exportState.updateFormat(0, 'webp')
```
## Siehe auch

View file

@ -44,7 +44,7 @@ exportState.addSetting()
```ts
exportState.updateScale(0, 2)
exportState.updateFormat(0, 'WEBP')
exportState.updateFormat(0, 'webp')
```
## Zobacz też

View file

@ -12,7 +12,7 @@ It manages:
- export settings rows
- selected node ids
- export name labeling
- supported scales and formats
- supported scales and formats (`formats` ids and labeled `formatOptions`)
## Usage
@ -30,6 +30,7 @@ const {
nodeName,
scales,
formats,
formatOptions,
addSetting,
updateScale,
updateFormat,
@ -48,7 +49,7 @@ exportState.addSetting()
```ts
exportState.updateScale(0, 2)
exportState.updateFormat(0, 'WEBP')
exportState.updateFormat(0, 'webp')
```
## Related APIs

View file

@ -48,7 +48,7 @@ exportState.addSetting()
```ts
exportState.updateScale(0, 2)
exportState.updateFormat(0, 'WEBP')
exportState.updateFormat(0, 'webp')
```
## Связанные API

View file

@ -2,6 +2,7 @@ import type { NodeChange, PluginData, PluginRelaunchData } from '@open-pencil/ki
import { guidToString } from '@open-pencil/kiwi/fig/guid'
import {
clampExportScale,
isExportFormatId,
type ExportFormatId,
type ExportSetting,
type PluginDataEntry,
@ -135,12 +136,6 @@ export function extractBoundVariables(nc: NodeChange): Record<string, string> {
return bindings
}
function isExportFormatId(value: unknown): value is ExportFormatId {
return (
value === 'png' || value === 'jpg' || value === 'webp' || value === 'svg' || value === 'pdf'
)
}
function parseExportSettingsPluginValue(value: string | null): ExportSetting[] | null {
if (!value) return null
try {

View file

@ -0,0 +1,12 @@
/**
* Formats a node's export settings may persist. Each id must name a Core IO
* export adapter that supports node export; labels and options come from that
* adapter, so this list only decides what the Export panel offers.
*/
export const EXPORT_FORMAT_IDS = ['png', 'jpg', 'webp', 'svg', 'pdf', 'pptx'] as const
export type ExportFormatId = (typeof EXPORT_FORMAT_IDS)[number]
export function isExportFormatId(value: unknown): value is ExportFormatId {
return EXPORT_FORMAT_IDS.some((id) => id === value)
}

View file

@ -21,6 +21,7 @@ export {
type InstanceOverrideState
} from './instance-overrides'
export * from './snap'
export * from './export-format'
export * from './export-scale'
export * from './coordinate'
export * from './constants'

View file

@ -1,5 +1,6 @@
/* eslint-disable max-lines -- scene node contracts are kept together as the public graph type surface */
import type { ExportFormatId } from './export-format'
import type { CanvasGuide } from './guides'
import type { InstanceOverrideState } from './instance-overrides'
import type { Color, Matrix, Rect, Vector } from './primitives'
@ -305,8 +306,6 @@ export interface PluginDataEntry {
value: string
}
export type ExportFormatId = 'png' | 'jpg' | 'webp' | 'svg' | 'pdf'
export interface ExportSetting {
scale: number
format: ExportFormatId

View file

@ -2,14 +2,25 @@ import { computed } from 'vue'
import type { ComputedRef } from 'vue'
import type { Editor } from '@open-pencil/core/editor'
import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core/io'
import { MAX_EXPORT_SCALE, MIN_EXPORT_SCALE, clampExportScale } from '@open-pencil/scene-graph'
import { BUILTIN_IO_FORMATS, IORegistry, type BuiltinIOFormatId } from '@open-pencil/core/io'
import {
EXPORT_FORMAT_IDS,
MAX_EXPORT_SCALE,
MIN_EXPORT_SCALE,
clampExportScale
} from '@open-pencil/scene-graph'
import type { ExportFormatId, ExportSetting, PluginDataEntry } from '@open-pencil/scene-graph'
import { useSceneComputed } from '#vue/internal/scene-computed/use'
export const EXPORT_SCALES = [0.5, 0.75, 1, 1.5, 2, 3, 4] as const
export const EXPORT_FORMATS: ExportFormatId[] = ['png', 'jpg', 'webp', 'svg', 'pdf']
// Persisted export-setting formats must name built-in IO adapters.
export const EXPORT_FORMATS = EXPORT_FORMAT_IDS satisfies readonly BuiltinIOFormatId[]
export interface ExportFormatOption {
value: ExportFormatId
label: string
}
export type ExportPanelTarget = 'selection' | 'page'
@ -22,6 +33,12 @@ export { MIN_EXPORT_SCALE, MAX_EXPORT_SCALE, clampExportScale }
const io = new IORegistry(BUILTIN_IO_FORMATS)
/** Short, extension-style labels for the node export formats (PNG, PDF, PPTX, …). */
export const EXPORT_FORMAT_OPTIONS: ExportFormatOption[] = EXPORT_FORMATS.map((id) => ({
value: id,
label: (io.getFormat(id)?.extensions[0] ?? id).toUpperCase()
}))
export function createDefaultExportSetting(): ExportSetting {
return { scale: 1, format: 'png' }
}

View file

@ -1,5 +1,6 @@
import {
EXPORT_FORMATS,
EXPORT_FORMAT_OPTIONS,
EXPORT_SCALES,
MAX_EXPORT_SCALE,
MIN_EXPORT_SCALE,
@ -12,7 +13,7 @@ import { useEditor } from '#vue/editor/context'
import { useSceneComputed } from '#vue/internal/scene-computed/use'
export type { ExportFormatId, ExportSetting } from '@open-pencil/scene-graph'
export type { ExportPanelTarget } from '#vue/document/export/helpers'
export type { ExportFormatOption, ExportPanelTarget } from '#vue/document/export/helpers'
export function useExport() {
const editor = useEditor()
@ -30,6 +31,7 @@ export function useExport() {
minScale: MIN_EXPORT_SCALE,
clampExportScale,
formats: EXPORT_FORMATS,
formatOptions: EXPORT_FORMAT_OPTIONS,
formatSupportsScale,
...targetState,
...settingActions

View file

@ -107,7 +107,7 @@ export type {
UseDocumentWorkspaceOptions
} from '#vue/document/workspace/use'
export { useExport } from '#vue/document/export/use'
export type { ExportFormatId, ExportSetting } from '#vue/document/export/use'
export type { ExportFormatId, ExportFormatOption, ExportSetting } from '#vue/document/export/use'
export { useFillControls } from '#vue/controls/fill/use'
export { useColorVariableBinding } from '#vue/controls/color-variable-binding/use'
export { useNumberVariableBinding } from '#vue/controls/number-variable-binding/use'

View file

@ -1,5 +1,5 @@
import type { Editor, EditorState } from '@open-pencil/core/editor'
import type { ExportRequest, IORegistry } from '@open-pencil/core/io'
import type { BuiltinIOFormatId, ExportRequest, IORegistry } from '@open-pencil/core/io'
import {
bundleExportFiles,
@ -38,7 +38,7 @@ export function createDocumentExportActions(
const format = io.getFormat(formatId)
if (!format) throw new Error(`Unknown export format: ${formatId}`)
const exportOptions = getExportOptions(formatId, options)
const exportOptions = getExportOptions(format, options)
const result = await io.exportContent(
formatId,
@ -50,7 +50,7 @@ export function createDocumentExportActions(
const baseName = getExportBaseName(editor.graph, target)
return {
bytes: getExportBytes(result.data),
fileName: getExportFileName(baseName, formatId, result.extension, options),
fileName: getExportFileName(baseName, format, result.extension, options),
format: format.label,
ext: `.${result.extension}`,
mime: result.mimeType
@ -103,10 +103,7 @@ export function createDocumentExportActions(
)
}
async function exportSelection(
scale: number,
formatId: 'png' | 'jpg' | 'webp' | 'svg' | 'pdf' | 'pptx' | 'fig'
) {
async function exportSelection(scale: number, formatId: BuiltinIOFormatId) {
await exportTarget(getSelectionExportTarget(), formatId, { scale })
}

View file

@ -74,25 +74,21 @@ export function getExportBaseName(graph: SceneGraph, target: ExportRequest['targ
return 'Export'
}
export function getExportOptions(formatId: string, options?: ExportOptions): unknown {
if (formatId === 'png' || formatId === 'jpg' || formatId === 'webp') {
return {
format: formatId.toUpperCase(),
scale: options?.scale ?? 1,
quality: options?.quality
}
export function getExportOptions(format: IOFormatAdapter, options?: ExportOptions): unknown {
if (format.exportOptions?.scale) {
return { scale: options?.scale ?? 1, quality: options?.quality }
}
if (formatId === 'jsx') return { format: options?.jsxFormat ?? 'openpencil' }
if (format.id === 'jsx') return { format: options?.jsxFormat ?? 'openpencil' }
return undefined
}
export function getExportFileName(
baseName: string,
formatId: string,
format: IOFormatAdapter,
extension: string,
options?: ExportOptions
): string {
return formatId === 'png' || formatId === 'jpg' || formatId === 'webp'
return format.exportOptions?.scale
? `${baseName}@${options?.scale ?? 1}x.${extension}`
: `${baseName}.${extension}`
}

View file

@ -15,6 +15,7 @@ import IconUndo from '~icons/lucide/undo-2'
import IconZoomIn from '~icons/lucide/zoom-in'
import IconZoomOut from '~icons/lucide/zoom-out'
import type { BuiltinIOFormatId } from '@open-pencil/core/io'
import type { CommandPaletteGroup, CommandPaletteItem, MenuEntry } from '@open-pencil/vue'
import { shortcutPlatform, useEditorCommands, useI18n } from '@open-pencil/vue'
@ -143,7 +144,7 @@ export function useAppMenu() {
}))
)
function exportSelection(format: 'png' | 'svg' | 'pptx' | 'fig') {
function exportSelection(format: BuiltinIOFormatId) {
if (store.state.selectedIds.size > 0) void store.exportSelection(1, format)
}

View file

@ -3,7 +3,6 @@ import { tryOnScopeDispose, useObjectUrl } from '@vueuse/core'
import { computed, onActivated, onDeactivated, ref, shallowRef, watch } from 'vue'
import { useExport, useI18n, useRetainedActivity } from '@open-pencil/vue'
import type { ExportFormatId } from '@open-pencil/vue'
import { useEditorStore } from '@/app/editor/active-store'
import ExportScaleInput from '@/components/properties/ExportScaleInput.vue'
@ -28,18 +27,11 @@ const {
updateScale,
updateFormat,
formatSupportsScale,
formatOptions,
scales,
clampExportScale
} = useExport()
const FORMAT_OPTIONS: { value: ExportFormatId; label: string }[] = [
{ value: 'png', label: 'PNG' },
{ value: 'jpg', label: 'JPG' },
{ value: 'webp', label: 'WEBP' },
{ value: 'svg', label: 'SVG' },
{ value: 'pdf', label: 'PDF' }
]
const previewBlob = shallowRef<Blob | null>(null)
const previewURL = useObjectUrl(previewBlob)
const showPreview = ref(false)
@ -155,11 +147,11 @@ watch(previewKey, updatePreview, { flush: 'post' })
</div>
<AppSelect
:model-value="setting.format"
:options="FORMAT_OPTIONS"
:options="formatOptions"
:label="panels.exportFormat"
:ui="{ trigger: 'w-auto flex-1' }"
data-property="export-format"
@update:model-value="updateFormat(index, $event as ExportFormatId)"
@update:model-value="updateFormat(index, $event)"
/>
<template #rail="{ removeClass }">
<IconButton

View file

@ -109,7 +109,7 @@ test('format selector changes to JPG', async () => {
await expect(formatTrigger).toHaveAttribute('aria-label', 'Export format')
await formatTrigger.click()
for (const label of ['PNG', 'JPG', 'WEBP', 'SVG', 'PDF']) {
for (const label of ['PNG', 'JPG', 'WEBP', 'SVG', 'PDF', 'PPTX']) {
await expect(page.locator('[role="option"]').filter({ hasText: label })).toBeVisible()
}
@ -218,6 +218,16 @@ test('a single export format downloads the file directly', async () => {
canvas.assertNoErrors()
})
test('a PPTX export setting downloads a PowerPoint file', async () => {
await createExportableRect([{ scale: 1, format: 'pptx' }])
await forceBlobDownload()
await expect(exportItems().first().getByRole('textbox', { name: 'Export scale' })).toHaveCount(0)
const [download] = await Promise.all([page.waitForEvent('download'), exportButton().click()])
expect(download.suggestedFilename()).toBe('Export rect 1.pptx')
canvas.assertNoErrors()
})
test('preview toggle shows image with blob src', async () => {
const formatTrigger = exportItems().first().getByRole('combobox', { name: 'Export format' })
await formatTrigger.click()

View file

@ -0,0 +1,19 @@
import { describe, expect, test } from 'bun:test'
import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core/io'
import { EXPORT_FORMAT_IDS, isExportFormatId } from '@open-pencil/scene-graph'
const io = new IORegistry(BUILTIN_IO_FORMATS)
describe('export setting formats', () => {
test('every persisted export format is a node-exportable IO adapter', () => {
const nodeExportIds = new Set(io.listExportFormats('node').map((format) => format.id))
for (const id of EXPORT_FORMAT_IDS) expect(nodeExportIds.has(id)).toBe(true)
})
test('recognizes only persisted export formats', () => {
expect(isExportFormatId('pptx')).toBe(true)
expect(isExportFormatId('fig')).toBe(false)
expect(isExportFormatId('PNG')).toBe(false)
})
})

View file

@ -67,7 +67,8 @@ describe('fig roundtrip export settings', () => {
name: 'Exportable rect',
exportSettings: [
{ scale: 2, format: 'png' },
{ scale: 3, format: 'webp' }
{ scale: 3, format: 'webp' },
{ scale: 1, format: 'pptx' }
]
})