feat(cli): add export font policy

This commit is contained in:
Danila Poyarkov 2026-09-01 15:18:23 +03:00
parent c32ff03d03
commit f580d8a7e8
3 changed files with 88 additions and 0 deletions

View file

@ -14,6 +14,7 @@ import {
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'
@ -39,6 +40,7 @@ interface ExportArgs {
css: string
assets: string
fonts: string
'font-policy': string
thumbnail?: boolean
width: string
height: string
@ -210,6 +212,17 @@ async function exportFromFile(format: string, args: ExportArgs) {
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.')
@ -312,6 +325,11 @@ export default defineCommand({
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' },
@ -351,6 +369,11 @@ export default defineCommand({
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 {

View file

@ -0,0 +1,34 @@
import { prepareGraphFonts } from '@open-pencil/core/text'
import type { SceneGraph } from '@open-pencil/scene-graph'
import { fail, printError } from '#cli/format'
export const FONT_POLICIES = new Set(['allow', 'strict', 'warn'])
export async function applyExportFontPolicy(
graph: SceneGraph,
roots: string[],
format: string,
policy: string
): Promise<void> {
if (policy === 'allow' || !['PNG', 'JPG', 'WEBP', 'PDF'].includes(format)) return
const status = await prepareGraphFonts(graph, roots)
if (status.faithful) return
const names = status.issues.map((face) => `${face.family} ${face.style}`).join(', ')
const message = `Font substitution: ${names}`
if (policy === 'strict') {
printError(message)
process.exit(1)
}
console.error(fail(message))
}
export function exportFontRoots(
nodeId: string | undefined,
pageId: string,
pageIds: string[],
wholeDocument: boolean
): string[] {
if (nodeId) return [nodeId]
return wholeDocument ? pageIds : [pageId]
}

View file

@ -0,0 +1,31 @@
import { expect, test } from 'bun:test'
import { SceneGraph } from '@open-pencil/scene-graph'
import { FONT_POLICIES, applyExportFontPolicy, exportFontRoots } from '#cli/export-font-policy'
function graphWithMissingFont(): { graph: SceneGraph; pageId: string } {
const graph = new SceneGraph()
const page = graph.getPages()[0]
graph.createNode('TEXT', page.id, {
text: 'Hello',
fontFamily: 'Definitely Missing Font',
fontWeight: 400
})
return { graph, pageId: page.id }
}
test('font policy roots match node, page, and document export scopes', () => {
expect(exportFontRoots('node', 'page', ['page', 'page-2'], true)).toEqual(['node'])
expect(exportFontRoots(undefined, 'page', ['page', 'page-2'], false)).toEqual(['page'])
expect(exportFontRoots(undefined, 'page', ['page', 'page-2'], true)).toEqual(['page', 'page-2'])
})
test('font policy values stay bounded', () => {
expect([...FONT_POLICIES].sort()).toEqual(['allow', 'strict', 'warn'])
})
test('allow policy skips font preparation', async () => {
const { graph, pageId } = graphWithMissingFont()
await expect(applyExportFontPolicy(graph, [pageId], 'PNG', 'allow')).resolves.toBeUndefined()
})