fix(cli): populate lazy FIG pages

- Populate requested pages for scoped reads and all pages for document-wide operations

- Export whole FIG documents by default while keeping explicit page exports partial
This commit is contained in:
Danila Poyarkov 2026-07-22 14:56:58 +03:00
parent 072c159fdb
commit 7124d0045f
11 changed files with 254 additions and 26 deletions

View file

@ -32,6 +32,7 @@
- Improve `.fig` import and rendering fidelity for groups, booleans, instances, rotated vectors, complex text fills, auto-sized text, layout grids, page guides, patterns, noise effects, masks, and canvas backgrounds.
- Preserve pages, components, prototype and library metadata, export settings, unsupported effects, and other unrelated Figma data when editing and resaving `.fig` files.
- Prevent duplicate generated IDs from corrupting `.fig` round trips.
- Populate lazy `.fig` pages for file-mode CLI inspection and preserve the whole document when exporting FIG unless a page is explicitly requested.
- Match Figma auto-layout reflow after deleting children, hiding optional instance slots, or syncing component changes.
- Make group and boolean-operation children scale with their parent during resize.
- Restore desktop copy, cut, and paste when browser clipboard events are unavailable.

View file

@ -7,7 +7,7 @@ import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core/io'
import { requireFile } from '#cli/app-client'
import { ok, printError } from '#cli/format'
import { loadDocument } from '#cli/headless'
import { loadDocument, populateWholeDocument } from '#cli/headless'
const io = new IORegistry(BUILTIN_IO_FORMATS)
@ -51,6 +51,7 @@ export default defineCommand({
const file = requireFile(args.file)
const graph = await loadDocument(file)
populateWholeDocument(graph)
const result = await io.writeDocument(format, graph)
const output = args.output ? resolve(args.output) : defaultOutput(file, format)
await writeFile(output, result.data as Uint8Array)

View file

@ -7,7 +7,7 @@ import { FigmaAPI } from '@open-pencil/core/figma-api'
import { isAppMode, requireFile, rpc } from '#cli/app-client'
import { appTargetOptions, appTargetRpcArgs } from '#cli/app-target'
import { printError } from '#cli/format'
import { loadDocument } from '#cli/headless'
import { loadDocument, populateWholeDocument } from '#cli/headless'
function printResult(value: unknown, json: boolean) {
if (json || !process.stdout.isTTY) {
@ -71,6 +71,7 @@ export default defineCommand({
const file = requireFile(args.file)
const graph = await loadDocument(file)
populateWholeDocument(graph)
const figma = new FigmaAPI(graph)
type AsyncFunctionConstructor = new (

View file

@ -14,7 +14,7 @@ import {
import { isAppMode, requireFile, rpc } from '#cli/app-client'
import { appTargetOptions, appTargetRpcArgs } from '#cli/app-target'
import { ok, printError } from '#cli/format'
import { loadDocument } from '#cli/headless'
import { loadDocument, populateDocumentPage, populateWholeDocument } from '#cli/headless'
const io = new IORegistry(BUILTIN_IO_FORMATS)
const RASTER_FORMATS = ['PNG', 'JPG', 'WEBP']
@ -102,7 +102,8 @@ function exportFileName(defaultName: string, extension: string, scale?: number):
return scale ? `${defaultName}@${scale}x.${extension}` : `${defaultName}.${extension}`
}
function targetLabel(pageName?: string, nodeId?: string): string {
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'
}
@ -152,6 +153,31 @@ async function exportHTMLFromFile(
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' && !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) return io.writeDocument(formatId, graph, 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)
@ -175,6 +201,8 @@ async function exportFromFile(format: string, args: ExportArgs) {
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 }
@ -205,7 +233,7 @@ async function exportFromFile(format: string, args: ExportArgs) {
}
}
const result = await io.exportContent(formatId, { graph, target }, options)
const result = await executeFileExport(formatId, graph, target, options, wholeDocument)
const output = resolve(
args.output ??
exportFileName(
@ -215,7 +243,7 @@ async function exportFromFile(format: string, args: ExportArgs) {
)
)
await writeAndLog(output, result.data as string | Uint8Array)
console.log(ok(`Target: ${targetLabel(args.page, args.node)}`))
console.log(ok(`Target: ${targetLabel(args.page, args.node, wholeDocument)}`))
}
export default defineCommand({
@ -247,7 +275,7 @@ export default defineCommand({
},
page: {
type: 'string',
description: 'Export a specific page by name (default: first page)',
description: 'Export a specific page by name (FIG defaults to the whole document)',
required: false
},
node: {

View file

@ -1,6 +1,7 @@
import { readFile } from 'node:fs/promises'
import { BUILTIN_IO_FORMATS, IORegistry, initCanvasKit } from '@open-pencil/core/io'
import { populateAllLazyFigImportRoots, populateLazyFigImportRoots } from '@open-pencil/core/kiwi'
import { computeAllLayouts } from '@open-pencil/core/layout'
import type { SceneGraph } from '@open-pencil/scene-graph'
@ -14,3 +15,42 @@ export async function loadDocument(filePath: string): Promise<SceneGraph> {
computeAllLayouts(graph)
return graph
}
export function populateDocumentPage(graph: SceneGraph, pageId: string): boolean {
const changed = populateLazyFigImportRoots(graph, [pageId])
if (changed) computeAllLayouts(graph, pageId)
return changed
}
export function populateWholeDocument(graph: SceneGraph): boolean {
const changed = populateAllLazyFigImportRoots(graph)
if (changed) computeAllLayouts(graph)
return changed
}
function pageNameFromArgs(args: unknown): string | undefined {
if (!args || typeof args !== 'object' || Array.isArray(args)) return undefined
const page = (args as { page?: unknown }).page
return typeof page === 'string' ? page : undefined
}
function populateRequestedPage(graph: SceneGraph, pageName?: string): void {
const pages = graph.getPages()
const page = pageName ? pages.find((candidate) => candidate.name === pageName) : pages[0]
if (page) populateDocumentPage(graph, page.id)
}
export function prepareDocumentForRpc(graph: SceneGraph, command: string, args?: unknown): void {
if (command === 'pages' || command === 'variables') return
if (command === 'tree') {
populateRequestedPage(graph, pageNameFromArgs(args))
return
}
if (command === 'find' || command === 'query') {
const pageName = pageNameFromArgs(args)
if (pageName) populateRequestedPage(graph, pageName)
else populateWholeDocument(graph)
return
}
populateWholeDocument(graph)
}

View file

@ -2,7 +2,7 @@ import { executeRpcCommand } from '@open-pencil/core/rpc'
import { isAppMode, requireFile, rpc } from '#cli/app-client'
import { appTargetRpcArgs, type AppTargetCliArgs } from '#cli/app-target'
import { loadDocument } from '#cli/headless'
import { loadDocument, prepareDocumentForRpc } from '#cli/headless'
type RpcArgs = { [key: string]: unknown }
@ -23,5 +23,6 @@ export async function loadRpcData<Result>(
})
}
const graph = await loadDocument(requireFile(file))
prepareDocumentForRpc(graph, command, args)
return executeRpcCommand(graph, command, args) as Result
}

View file

@ -19,6 +19,24 @@ export function getLazyFigImportContext(graph: SceneGraph): LazyFigImportContext
return lazyFigImportContexts.get(graph)
}
function applyPopulation(
graph: SceneGraph,
context: LazyFigImportContext,
rootIds?: string[]
): void {
graph.preserveSourceMetadataDuring(() => {
populateAndApplyOverrides(
graph,
context.changeMap,
context.guidToNodeId,
context.blobs,
rootIds
)
})
const populatedRootIds = rootIds ?? graph.getPages(true).map((page) => page.id)
for (const id of populatedRootIds) context.populatedRootIds.add(id)
}
function populateRoots(
graph: SceneGraph,
context: LazyFigImportContext,
@ -26,18 +44,7 @@ function populateRoots(
): boolean {
const pending = [...rootIds].filter((id) => id && !context.populatedRootIds.has(id))
if (pending.length === 0) return false
graph.preserveSourceMetadataDuring(() => {
populateAndApplyOverrides(
graph,
context.changeMap,
context.guidToNodeId,
context.blobs,
pending
)
})
for (const id of pending) context.populatedRootIds.add(id)
applyPopulation(graph, context, pending)
return true
}
@ -49,9 +56,12 @@ export function populateLazyFigImportRoots(graph: SceneGraph, rootIds: Iterable<
export function populateAllLazyFigImportRoots(graph: SceneGraph): boolean {
const context = getLazyFigImportContext(graph)
if (!context) return false
return populateRoots(
graph,
context,
graph.getPages(true).map((page) => page.id)
)
const rootIds = graph.getPages(true).map((page) => page.id)
if (rootIds.every((id) => context.populatedRootIds.has(id))) return false
// Revisit the initially populated page without an active-root filter.
// Cross-page component chains can only stabilize when global override
// resolution can see every source and target in the same pass.
applyPopulation(graph, context)
return true
}

View file

@ -1,6 +1,7 @@
export { prefetchFigmaSchema } from '#core/clipboard'
export { readFigFile, parseFigFile } from './fig/file'
export { importNodeChanges } from './fig/import'
export { populateAllLazyFigImportRoots, populateLazyFigImportRoots } from './fig/lazy-import'
export { deduplicateNodeChangePluginData } from '@open-pencil/kiwi/fig/parse'
export {
initCodec,

View file

@ -31,11 +31,70 @@ async function createFigFixture() {
rect.paddingBottom = 16
rect.fills = [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 } }]
const secondPage = graph.addPage('Second Page')
createRect(graph, secondPage.id, {
name: 'Second Card',
x: 0,
y: 0,
width: 120,
height: 60
})
const result = await io.writeDocument('fig', graph)
await Bun.write(figPath, result.data as Uint8Array)
return { dir, figPath }
}
test('FIG export preserves the whole document by default', async () => {
const { dir, figPath } = await createFigFixture()
const output = join(dir, 'whole.fig')
const { stdout, stderr, exitCode } = await runOpenPencilCLI([
'export',
figPath,
'--format',
'fig',
'--output',
output
])
expect(stderr).toBe('')
expect(exitCode).toBe(0)
expect(stdout).toContain('Target: whole document')
const { graph } = await io.readDocument({
name: output,
data: new Uint8Array(await Bun.file(output).arrayBuffer())
})
expect(graph.getPages()).toHaveLength(3)
expect(graph.getPages().map((page) => page.name)).toContain('Second Page')
})
test('FIG export requires an explicit page for a partial archive', async () => {
const { dir, figPath } = await createFigFixture()
const output = join(dir, 'page.fig')
const { stderr, exitCode } = await runOpenPencilCLI([
'export',
figPath,
'--format',
'fig',
'--page',
'Second Page',
'--output',
output
])
expect(stderr).toBe('')
expect(exitCode).toBe(0)
const { graph } = await io.readDocument({
name: output,
data: new Uint8Array(await Bun.file(output).arrayBuffer())
})
expect(graph.getPages().map((page) => page.name)).toEqual(['Second Page'])
})
test('export CLI writes HTML with inline styles by default', async () => {
const { dir, figPath } = await createFigFixture()
const output = join(dir, 'card.html')

View file

@ -0,0 +1,86 @@
import { describe, expect, test } from 'bun:test'
import { mkdtemp } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core/io'
import { SceneGraph } from '@open-pencil/scene-graph'
import { loadDocument, prepareDocumentForRpc } from '#cli/headless'
const io = new IORegistry(BUILTIN_IO_FORMATS)
async function createLazyFixture() {
const graph = new SceneGraph()
const [page1] = graph.getPages()
page1.name = 'Page 1'
const page2 = graph.addPage('Page 2')
const component = graph.createNode('COMPONENT', page1.id, {
name: 'Button',
width: 100,
height: 40
})
graph.createNode('RECTANGLE', component.id, {
name: 'Background',
width: 100,
height: 40
})
graph.createNode('INSTANCE', page1.id, {
name: 'Button instance 1',
componentId: component.id,
width: 100,
height: 40
})
graph.createNode('INSTANCE', page2.id, {
name: 'Button instance 2',
componentId: component.id,
width: 100,
height: 40
})
const dir = await mkdtemp(join(tmpdir(), 'open-pencil-headless-lazy-'))
const path = join(dir, 'lazy.fig')
const result = await io.writeDocument('fig', graph)
await Bun.write(path, result.data as Uint8Array)
return loadDocument(path)
}
function pageInstance(graph: SceneGraph, pageName: string) {
const page = graph.getPages().find((candidate) => candidate.name === pageName)
return page
? graph.getChildren(page.id).find((candidate) => candidate.type === 'INSTANCE')
: undefined
}
describe('headless CLI lazy .fig preparation', () => {
test('populates only the requested tree page', async () => {
const graph = await createLazyFixture()
const page1Instance = pageInstance(graph, 'Page 1')
const page2Instance = pageInstance(graph, 'Page 2')
expect(page1Instance ? graph.getChildren(page1Instance.id) : []).toHaveLength(1)
expect(page2Instance ? graph.getChildren(page2Instance.id) : []).toHaveLength(0)
prepareDocumentForRpc(graph, 'tree', { page: 'Page 2' })
expect(page2Instance ? graph.getChildren(page2Instance.id) : []).toHaveLength(1)
})
test('populates all pages for document-wide searches', async () => {
const graph = await createLazyFixture()
const page2Instance = pageInstance(graph, 'Page 2')
prepareDocumentForRpc(graph, 'find', {})
expect(page2Instance ? graph.getChildren(page2Instance.id) : []).toHaveLength(1)
})
test('keeps page listings lazy', async () => {
const graph = await createLazyFixture()
const page2Instance = pageInstance(graph, 'Page 2')
prepareDocumentForRpc(graph, 'pages')
expect(page2Instance ? graph.getChildren(page2Instance.id) : []).toHaveLength(0)
})
})

View file

@ -64,7 +64,7 @@ describe('lazy .fig page population', () => {
const { graph, page1Instance, page2Instance } = createLazyGraph()
expect(populateAllLazyFigImportRoots(graph)).toBe(true)
expect(graph.getChildren(page1Instance.id)).toHaveLength(0)
expect(graph.getChildren(page1Instance.id)).toHaveLength(1)
expect(graph.getChildren(page2Instance.id)).toHaveLength(1)
expect(populateAllLazyFigImportRoots(graph)).toBe(false)
})