From 8d10b9ca27af7e5cc02a8df406bdfdcc85874bfd Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 4 Oct 2026 13:55:23 +0000 Subject: [PATCH] fix: export layers and pages that are not on screen in app mode (#877) * fix(automation): export layers from a page that is not on screen The app's raster export rendered against the page on screen unless the caller passed a page, so MCP export_image with ids on any other page, or with page_id naming another page, failed with "Raster export selection must stay on a single page". Automation shares one app between clients, so the page on screen says nothing about what a request means. Render on the page that holds the requested layers instead. The user's view and selection stay where they were. * fix(cli): export the requested page from the running app `openpencil export --page` never reached the app: `exportViaApp` only forwarded `--document-id` and `--page-id`, and the app's `export` RPC exported the given nodes or the selection on screen, ignoring the target page. `--page` and `--page-id` therefore exported whatever was selected. The CLI now resolves `--page` to a page ID through `list_documents` and asks for a page-scoped export. The app answers a page-scoped export with the layers of the target page, loading a `.fig` page that has not been shown yet without switching to it. CLI tests address the package source by `#cli/`, as Core and fig tests already do, so the alias owner widens to the whole package. * fix(automation): prepare fonts and layout for a page exported off screen A page export loaded the layers of a page that had not been shown, but not its fonts or layout, so text and auto layout could render differently from the screen. preparePageNodes runs the same font and layout pass as a page switch, once per page, without switching or superseding a switch. The CLI export test now writes its own discovery file, so it no longer replaces or removes the record of an app that is running. * fix(automation): prepare a .fig page before running a tool on it A `.fig` opens with only its first page populated; the others get their layers, fonts and layout when first shown. The automation tool handler built its FigmaAPI on the target page without loading it, so MCP tools aimed at a page nobody had opened (`page_id`) saw an empty page: find_nodes found nothing, export_image reported "No visible nodes to export", and create_shape added a shape to a page that then held only that shape. Prepare the target page first with preparePageNodes, as page exports do: layers, fonts and layout, once per page. The page on screen does not change. * fix(automation): render explicit export IDs on the page that holds them Since the visual diff tools, the automation FigmaAPI passes its target page with every raster export, so export_image with IDs from another page asked to render them on the target page and failed with "Raster export selection must stay on a single page". The page now names which layers to export only when no IDs are given; an ID list is rendered on its own page. * fix(core): share one off-screen page preparation between concurrent callers Two concurrent preparePageNodes calls for the same page both populated it and resolved its fonts, and the font manager's blocked-node set has no reference count, so the first to finish unblocked text the second was still resolving. Callers now share the in-flight preparation, which is kept once it succeeds and retried after a failure. preparePageNodes also reports whether the page is ready, so a caller can refuse to run on a page whose document was closed or replaced mid-way instead of acting on a page with no layers. Its unused options are gone: one caller's signal cannot cancel a shared preparation. * fix(automation): prepare the target page once for every command Preparing an unshown .fig page lived in the page export handler, so explicit export IDs, export_jsx, eval, tools, and the RPC fallback still saw such a page as empty. The request dispatcher now prepares the resolved target page before any page-targeted command, and stops with an error when the page's document closed while it loaded. * docs(changelog): fold the off-screen page fixes into one entry * refactor(automation): rely on the dispatcher to prepare a tool's target page The request dispatcher now prepares the target page before every page-targeted command, so the tool handler no longer does it itself. The tests run tools through the dispatcher, which is where that guarantee lives. * docs(changelog): drop the tool entry now covered by the off-screen page fix --------- Co-authored-by: Jason Woltje <1139190+jetrich@users.noreply.github.com> --- CHANGELOG.md | 1 + packages/cli/src/commands/export/index.ts | 34 +++- .../cli/tests/commands/export/app.test.ts | 116 ++++++++++++ packages/core/src/editor/pages.ts | 47 +++++ packages/core/src/io/index.ts | 2 +- .../core/tests/editor/pages/loading.test.ts | 72 ++++++++ packages/docs/programmable/cli/exporting.md | 6 +- src/app/automation/bridge/export-handlers.ts | 14 +- src/app/automation/bridge/handlers.ts | 34 ++-- src/app/document/export/files.ts | 18 +- tests/app/automation/bridge/export.test.ts | 173 ++++++++++++++++++ tests/app/automation/bridge/tool-page.test.ts | 102 +++++++++++ .../architecture/src/steiger-rules/support.ts | 2 +- 13 files changed, 597 insertions(+), 24 deletions(-) create mode 100644 packages/cli/tests/commands/export/app.test.ts create mode 100644 tests/app/automation/bridge/export.test.ts create mode 100644 tests/app/automation/bridge/tool-page.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ea3db8df7..a00a08215 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,6 +100,7 @@ - Load the Bold, Medium, and other styles of macOS system fonts packaged as font collections, such as Menlo, Helvetica Neue, and Avenir Next, instead of reporting them as substituted or drawing a different style (#746). - Load the Medium, Semibold, Bold, and other styles of installed variable fonts such as SF Pro on macOS instead of reporting them as substituted (#752). - Ship the MIT license text in every published npm package, and add READMEs for `@open-pencil/core`, `@open-pencil/cli`, and `@open-pencil/mcp` on npm. +- Work with pages other than the one on screen through MCP, `eval`, and the CLI against the running app without switching to them: `openpencil export --page` and `--page-id` export that page instead of the selection, `export_image` and `openpencil export --node` export layers from any page instead of failing with "Raster export selection must stay on a single page", and a `.fig` page that has not been shown yet gets its layers, fonts, and layout before a command reads or changes it. - Fill the open subpaths of filled, unstroked SVG paths as if they were closed, as SVG does, in icons from `insert_icon` and Design JSX ``, inline Design JSX ``, SVG from `import_svg` or dropped and pasted files, and SVG clip paths. Icons that cut holes with open subpaths, such as some Font Awesome icons, no longer render with those holes filled in, and filled `` elements render filled instead of not at all. - Keep round and other stroke caps and joins after saving and reopening the file on icons from `insert_icon` and Design JSX ``, and on vectors from inline Design JSX ``, `import_svg`, and dropped or pasted SVG files. They were set only on the stroke paint, which `.fig` does not store, so outline icons such as Lucide's reopened with butt caps and miter joins and showed gaps where their strokes meet. diff --git a/packages/cli/src/commands/export/index.ts b/packages/cli/src/commands/export/index.ts index 88dce8304..173f4b946 100644 --- a/packages/cli/src/commands/export/index.ts +++ b/packages/cli/src/commands/export/index.ts @@ -5,6 +5,7 @@ import { defineCommand } from 'citty' import { toUint8Array } from 'js-base64' import { BUILTIN_IO_FORMATS, IORegistry, type ExportResult } from '@open-pencil/core/io' +import type { AutomationDocumentSummary } from '@open-pencil/core/rpc' import { isAppMode, requireFile, rpc } from '#cli/app-client' import { appTargetOptions, appTargetRPCArgs } from '#cli/app-target' @@ -64,8 +65,37 @@ async function writeAndLog(path: string, content: string | Uint8Array) { console.log(ok(`Exported ${path} (${(size / 1024).toFixed(1)} KB)`)) } +/** The app addresses pages by ID, so look the `--page` name up in the target document. */ +async function appPageId(args: ExportArgs, pageName: string): Promise { + const { documents } = await rpc<{ documents: AutomationDocumentSummary[] }>('list_documents') + const documentId = args['document-id'] + const document = documents.find((doc) => (documentId ? doc.id === documentId : doc.active)) + if (!document) { + printError(documentId ? `Document "${documentId}" not found.` : 'No active document.') + process.exit(1) + } + const page = document.pages.find((candidate) => candidate.name === pageName) + if (!page) { + const available = document.pages.map((candidate) => candidate.name).join(', ') + printError(`Page "${pageName}" not found. Available: ${available}`) + process.exit(1) + } + return page.id +} + +async function appExportTarget(args: ExportArgs) { + const target = appTargetRPCArgs(args) + if (!args.page) return target + if (args.node || args['page-id']) { + printError(`--page and ${args.node ? '--node' : '--page-id'} cannot be used together.`) + process.exit(1) + } + target.page_id = await appPageId(args, args.page) + return target +} + async function exportViaApp(format: string, args: ExportArgs) { - const targetArgs = appTargetRPCArgs(args) + const targetArgs = await appExportTarget(args) if (format === 'SVG') { const result = await rpc<{ svg: string }>('tool', { ...targetArgs, @@ -109,6 +139,8 @@ async function exportViaApp(format: string, args: ExportArgs) { const result = await rpc<{ base64: string }>('export', { ...targetArgs, nodeIds: args.node ? [args.node] : undefined, + // Without a node or a page the app exports the selection. + scope: !args.node && targetArgs.page_id ? 'page' : undefined, scale: Number(args.scale), format: format.toLowerCase() }) diff --git a/packages/cli/tests/commands/export/app.test.ts b/packages/cli/tests/commands/export/app.test.ts new file mode 100644 index 000000000..082f31ae4 --- /dev/null +++ b/packages/cli/tests/commands/export/app.test.ts @@ -0,0 +1,116 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test' +import { mkdtemp, rm } from 'node:fs/promises' +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { runCommand } from 'citty' + +import type { AutomationDocumentSummary } from '@open-pencil/core/rpc' +import { removeDiscoveryFile, writeDiscoveryFile } from '@open-pencil/mcp/discovery' + +import exportCommand from '#cli/commands/export' + +type RPCRequest = { command: string; args: Record } + +const DOCUMENT: AutomationDocumentSummary = { + id: 'tab-1', + name: 'Statements', + active: true, + current_page_id: '0:1', + current_page_name: 'Dashboard', + pages: [ + { id: '0:1', name: 'Dashboard' }, + { id: '0:2', name: 'Worklist' } + ] +} + +let server: Server +let requests: RPCRequest[] = [] +let dir: string +let discoveryDir: string +const savedDiscoveryPath = process.env.OPENPENCIL_MCP_DISCOVERY_PATH + +function reply(command: string): unknown { + if (command === 'list_documents') return { documents: [DOCUMENT] } + if (command === 'tool') return { svg: '' } + return { base64: 'AQID' } +} + +beforeAll(async () => { + // Our own discovery file, so a running app's record is neither replaced nor removed. + discoveryDir = await mkdtemp(join(tmpdir(), 'open-pencil-export-discovery-')) + process.env.OPENPENCIL_MCP_DISCOVERY_PATH = join(discoveryDir, 'mcp.json') + server = createServer((request, response) => { + const chunks: Buffer[] = [] + request.on('data', (chunk: Buffer) => chunks.push(chunk)) + request.on('end', () => { + const body = JSON.parse(Buffer.concat(chunks).toString('utf-8')) as RPCRequest + requests.push(body) + response.writeHead(200, { 'Content-Type': 'application/json' }) + response.end(JSON.stringify({ ok: true, result: reply(body.command) })) + }) + }) + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve) + }) + await writeDiscoveryFile({ + pid: process.pid, + socketPath: null, + httpPort: (server.address() as AddressInfo).port, + authRequired: false, + authToken: null, + version: '0.0.0-test', + startedAt: new Date().toISOString() + }) +}) + +afterAll(async () => { + await new Promise((resolve) => { + server.close(() => resolve()) + }) + await removeDiscoveryFile() + if (savedDiscoveryPath === undefined) delete process.env.OPENPENCIL_MCP_DISCOVERY_PATH + else process.env.OPENPENCIL_MCP_DISCOVERY_PATH = savedDiscoveryPath + await rm(discoveryDir, { recursive: true, force: true }) +}) + +beforeEach(async () => { + requests = [] + dir = await mkdtemp(join(tmpdir(), 'open-pencil-export-app-')) +}) + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }) +}) + +function lastRequest(command: string): RPCRequest | undefined { + return requests.findLast((request) => request.command === command) +} + +describe('export from the running app', () => { + test('--page exports the named page, not the page on screen', async () => { + await runCommand(exportCommand, { + rawArgs: ['--page', 'Worklist', '-o', join(dir, 'worklist.png')] + }) + + expect(lastRequest('export')?.args).toMatchObject({ page_id: '0:2', scope: 'page' }) + }) + + test('--page-id exports that page rather than the selection', async () => { + await runCommand(exportCommand, { + rawArgs: ['--page-id', '0:2', '-o', join(dir, 'worklist.png')] + }) + + expect(lastRequest('export')?.args).toMatchObject({ page_id: '0:2', scope: 'page' }) + }) + + test('--page targets the named page for vector formats', async () => { + await runCommand(exportCommand, { + rawArgs: ['--page', 'Worklist', '-f', 'svg', '-o', join(dir, 'worklist.svg')] + }) + + expect(lastRequest('tool')?.args).toMatchObject({ page_id: '0:2', name: 'export_svg' }) + }) +}) diff --git a/packages/core/src/editor/pages.ts b/packages/core/src/editor/pages.ts index cda2d14b8..f4ba6a0b1 100644 --- a/packages/core/src/editor/pages.ts +++ b/packages/core/src/editor/pages.ts @@ -1,5 +1,6 @@ import { limitAsync } from 'es-toolkit/promise' +import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph' import type { Color } from '@open-pencil/scene-graph/primitives' import { getPageColor, setPageBackgrounds } from '#core/figma-api/page-backgrounds' @@ -53,6 +54,9 @@ export function createPageActions(ctx: EditorContext) { let populationWorkerInstance: ReturnType | undefined let populationWorkerGeneration = 0 let pageSwitchGeneration = 0 + // Off-screen preparations per document, shared by concurrent callers and kept once they + // succeed. Later edits lay out their own scope, as they do on the page on screen. + const offscreenPreparations = new WeakMap>>() function populationWorker() { if (!canUseFigPopulationWorker(ctx.graph)) return null @@ -200,6 +204,48 @@ export function createPageActions(ctx: EditorContext) { if (ctx.graph.getNode(pageId)?.type === 'CANVAS') await populatePage(pageId, null) } + async function prepareOffscreenPage(graph: SceneGraph, page: SceneNode): Promise { + const populated = await populatePage(page.id, null) + if (populated === null || graph !== ctx.graph) return false + await resolvePageFonts(page.id, page.name, {}) + if (graph !== ctx.graph) return false + computeAllLayouts(graph, page.id) + return true + } + + /** + * Loads a page's layers with their fonts and layout, ready to render, without switching to + * it or superseding a page switch in progress. False when the document was closed or + * replaced first. The page on screen already was prepared, when it was shown. + */ + function preparePageNodes(pageId: string): Promise { + const graph = ctx.graph + const page = graph.getNode(pageId) + if (page?.type !== 'CANVAS') return Promise.resolve(false) + if (pageId === ctx.state.currentPageId) return Promise.resolve(true) + let preparations = offscreenPreparations.get(graph) + if (!preparations) { + preparations = new Map() + offscreenPreparations.set(graph, preparations) + } + const pending = preparations.get(pageId) + if (pending) return pending + // A failed or superseded preparation is retried by the next caller. + const forget = () => preparations.delete(pageId) + const preparation = prepareOffscreenPage(graph, page).then( + (ready) => { + if (!ready) forget() + return ready + }, + (error: unknown) => { + forget() + throw error + } + ) + preparations.set(pageId, preparation) + return preparation + } + async function switchPage(pageId: string, options: SwitchPageOptions = {}): Promise { const prepared = await preparePage(pageId, options) if (prepared) commitPageSwitch(prepared) @@ -266,6 +312,7 @@ export function createPageActions(ctx: EditorContext) { return { loadPageNodes, + preparePageNodes, pageSwitchCount, preparePage, commitPageSwitch, diff --git a/packages/core/src/io/index.ts b/packages/core/src/io/index.ts index 1dcab0734..7a6f07c0d 100644 --- a/packages/core/src/io/index.ts +++ b/packages/core/src/io/index.ts @@ -1,5 +1,5 @@ export { IORegistry } from './registry' -export { extractExportGraph } from './subgraph' +export { extractExportGraph, findPageId } from './subgraph' export { BUILTIN_IO_FORMATS, type BuiltinIOFormatId, diff --git a/packages/core/tests/editor/pages/loading.test.ts b/packages/core/tests/editor/pages/loading.test.ts index 216902827..8acf1b7d1 100644 --- a/packages/core/tests/editor/pages/loading.test.ts +++ b/packages/core/tests/editor/pages/loading.test.ts @@ -166,3 +166,75 @@ test('loading page nodes for a lookup does not supersede a page switch in progre expect(editor.state.currentPageId).toBe(target.id) }) + +test('preparing a page for rendering loads its fonts and layout once without switching to it', async () => { + const graph = new SceneGraph() + const firstPage = graph.getPages()[0] + if (!firstPage) throw new Error('Expected default page') + const target = graph.addPage('Target') + const prepared = graph.addPage('Prepared') + graph.createNode('TEXT', target.id, { text: 'Loading', fontFamily: 'Loader Test' }) + const row = graph.createNode('FRAME', prepared.id, { + width: 10, + height: 10, + layoutMode: 'HORIZONTAL', + primaryAxisSizing: 'HUG', + counterAxisSizing: 'FIXED' + }) + graph.createNode('FRAME', row.id, { width: 30, height: 10 }) + graph.createNode('TEXT', prepared.id, { text: 'Label', fontFamily: 'Prepared Font' }) + const loaded: string[] = [] + let release: () => void = () => undefined + const fontReady = new Promise((resolve) => { + release = resolve + }) + const editor = createEditor({ + graph, + skipInitialGraphSetup: true, + loadFont: async (family) => { + loaded.push(family) + if (family === 'Loader Test') await fontReady + return null + } + }) + + const switching = editor.switchPage(target.id) + await Promise.resolve() + const concurrent = await Promise.all([ + editor.preparePageNodes(prepared.id), + editor.preparePageNodes(prepared.id) + ]) + const again = await editor.preparePageNodes(prepared.id) + release() + await switching + + expect([...concurrent, again]).toEqual([true, true, true]) + expect(loaded.filter((family) => family === 'Prepared Font')).toHaveLength(1) + expect(graph.getNode(row.id)?.width).toBe(30) + expect(editor.state.currentPageId).toBe(target.id) +}) + +test('preparing a page reports failure when the document is replaced meanwhile', async () => { + const graph = new SceneGraph() + const page = graph.addPage('Prepared') + graph.createNode('TEXT', page.id, { text: 'Label', fontFamily: 'Prepared Font' }) + let release: () => void = () => undefined + const fontReady = new Promise((resolve) => { + release = resolve + }) + const editor = createEditor({ + graph, + skipInitialGraphSetup: true, + loadFont: async () => { + await fontReady + return null + } + }) + + const preparing = editor.preparePageNodes(page.id) + await Promise.resolve() + editor.replaceGraph(new SceneGraph()) + release() + + expect(await preparing).toBe(false) +}) diff --git a/packages/docs/programmable/cli/exporting.md b/packages/docs/programmable/cli/exporting.md index 317e608f8..b64b938a6 100644 --- a/packages/docs/programmable/cli/exporting.md +++ b/packages/docs/programmable/cli/exporting.md @@ -122,7 +122,11 @@ Stories carry `parameters.design` entries for [`@storybook/addon-designs`](https Omit the file to export from the running app: ```sh -openpencil export -f png # export from the current document +openpencil export -f png # export the selection in the active document +openpencil export --page "Components" -f png # export every layer of a page +openpencil export --node 1:23 -f png # export one layer, on any page ``` +`--page` takes a page name and `--page-id` a page ID from `openpencil documents`; either exports that page without switching the app to it. Add `--document-id` to export from a document other than the active one. + Live app mode supports PNG, JPG, WEBP, SVG, and PDF. PowerPoint, JSX, HTML, Storybook, and `.fig` exports require a file argument. File-mode thumbnail export is not currently supported. diff --git a/src/app/automation/bridge/export-handlers.ts b/src/app/automation/bridge/export-handlers.ts index 6e740e162..0e45f6e87 100644 --- a/src/app/automation/bridge/export-handlers.ts +++ b/src/app/automation/bridge/export-handlers.ts @@ -6,10 +6,20 @@ import { sceneNodesToTailwindJSX } from '@open-pencil/dom-css/export' import type { AutomationTarget } from '@/app/automation/bridge/target' +type ExportArgs = { nodeIds?: string[]; scope?: 'page'; scale?: number; format?: string } + +/** The requested layers, every layer of the target page for a page export, or the selection. */ +function exportNodeIds(target: AutomationTarget, args: ExportArgs | undefined): string[] { + const store = target.store + if (args?.nodeIds) return args.nodeIds + if (args?.scope !== 'page') return [...store.state.selectedIds] + return store.graph.getChildren(target.pageId).map((node) => node.id) +} + export async function handleExport(target: AutomationTarget, args: unknown): Promise { const store = target.store - const exportArgs = args as { nodeIds?: string[]; scale?: number; format?: string } | undefined - const nodeIds = exportArgs?.nodeIds ?? [...store.state.selectedIds] + const exportArgs = args as ExportArgs | undefined + const nodeIds = exportNodeIds(target, exportArgs) if (nodeIds.length === 0) throw new Error('No nodes to export') const data = await store.renderExportImage( nodeIds, diff --git a/src/app/automation/bridge/handlers.ts b/src/app/automation/bridge/handlers.ts index 2c7d954b8..e99002951 100644 --- a/src/app/automation/bridge/handlers.ts +++ b/src/app/automation/bridge/handlers.ts @@ -15,17 +15,16 @@ import { listAutomationDocuments, resolveAutomationTarget, responseWithTarget, - stripAutomationTargetArgs + stripAutomationTargetArgs, + type AutomationTarget, + type UnknownRecord } from '@/app/automation/bridge/target' import { createAutomationToolHandler } from '@/app/automation/bridge/tool-handlers' import type { EditorStore } from '@/app/editor/active-store' type FigmaFactory = (store: EditorStore, pageId?: string) => FigmaAPI -type CommandHandler = ( - target: ReturnType, - args: unknown -) => Promise +type CommandHandler = (target: AutomationTarget, args: unknown) => Promise export function createAutomationCommandHandlers(makeFigma: FigmaFactory) { const handleEval = createAutomationEvalHandler(makeFigma) @@ -43,6 +42,22 @@ export function createAutomationCommandHandlers(makeFigma: FigmaFactory) { open_file: handleOpenFile } + /** Runs a command on a resolved document page, loading the page first if it was never shown. */ + async function handleTargetCommand( + target: AutomationTarget, + command: string, + args: UnknownRecord + ): Promise { + if (!(await target.store.preparePageNodes(target.pageId))) { + throw new Error(`Page "${target.pageId}" was closed before it finished loading`) + } + const handler = commandHandlers[command] + const result = handler + ? await handler(target, args) + : await handleRPCFallback(target, command, args) + return responseWithTarget(result, target) + } + async function handleRequest( store: EditorStore, command: string, @@ -59,13 +74,8 @@ export function createAutomationCommandHandlers(makeFigma: FigmaFactory) { const rawArgs = isUnknownRecord(args) ? args : {} const target = resolveAutomationTarget(store, rawArgs) - const targetArgs = stripAutomationTargetArgs(rawArgs) - const handler = commandHandlers[command] - const result = handler - ? await handler(target, targetArgs) - : await handleRPCFallback(target, command, targetArgs) - return responseWithTarget(result, target) + return handleTargetCommand(target, command, stripAutomationTargetArgs(rawArgs)) } - return { handleRequest } + return { handleRequest, handleTargetCommand } } diff --git a/src/app/document/export/files.ts b/src/app/document/export/files.ts index f6899add6..3ab342fd7 100644 --- a/src/app/document/export/files.ts +++ b/src/app/document/export/files.ts @@ -1,11 +1,12 @@ import { zipSync, type Zippable } from 'fflate' import type { Editor, EditorState } from '@open-pencil/core/editor' -import type { - ExportRequest, - IOFormatAdapter, - IORegistry, - RasterExportFormat +import { + findPageId, + type ExportRequest, + type IOFormatAdapter, + type IORegistry, + type RasterExportFormat } from '@open-pencil/core/io' import { renderNodesToImage } from '@open-pencil/core/io/formats/raster' import type { SceneGraph } from '@open-pencil/scene-graph' @@ -97,6 +98,10 @@ export function getExportBytes(data: ExportData): Uint8Array { } export function createExportTargetActions(editor: Editor, state: EditorState, io: IORegistry) { + /** + * Renders `nodeIds` on the page that holds them, which need not be the page on screen; + * without IDs, renders every layer of `pageId`. + */ async function renderExportImage( nodeIds: string[], scale: number, @@ -107,7 +112,8 @@ export function createExportTargetActions(editor: Editor, state: EditorState, io if (!renderer) return null const ids = nodeIds.length > 0 ? nodeIds : editor.graph.getChildren(pageId).map((n) => n.id) if (ids.length === 0) return null - return renderNodesToImage(renderer.ck, renderer, editor.graph, pageId, ids, { + const ownerPageId = findPageId(editor.graph, ids[0]) ?? pageId + return renderNodesToImage(renderer.ck, renderer, editor.graph, ownerPageId, ids, { scale, format }) diff --git a/tests/app/automation/bridge/export.test.ts b/tests/app/automation/bridge/export.test.ts new file mode 100644 index 000000000..0fe2fe4ce --- /dev/null +++ b/tests/app/automation/bridge/export.test.ts @@ -0,0 +1,173 @@ +import 'fake-indexeddb/auto' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' + +import { toUint8Array } from 'js-base64' + +import { SkiaRenderer } from '@open-pencil/core/canvas' +import { BUILTIN_IO_FORMATS, IORegistry, initCanvasKit, parseFigFile } from '@open-pencil/core/io' +import { SceneGraph } from '@open-pencil/scene-graph' + +import { makeFigmaFromStore } from '@/app/automation/bridge/figma-factory' +import { createAutomationCommandHandlers } from '@/app/automation/bridge/handlers' +import type { AutomationTarget } from '@/app/automation/bridge/target' +import { createEditorStore, type EditorStore } from '@/app/editor/session/create' + +const RED = { type: 'SOLID' as const, color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true } +const { handleTargetCommand } = createAutomationCommandHandlers(makeFigmaFromStore) + +let stores: EditorStore[] = [] + +beforeEach(() => { + // The automation FigmaAPI reads the viewport size from the window. + Object.assign(globalThis, { window: { innerWidth: 1024, innerHeight: 768 } }) +}) + +afterEach(() => { + for (const store of stores) store.dispose() + stores = [] + Reflect.deleteProperty(globalThis, 'window') +}) + +async function storeWithCanvas(graph?: SceneGraph): Promise { + const store = createEditorStore(graph) + stores.push(store) + const ck = await initCanvasKit() + const surface = ck.MakeSurface(1, 1) + if (!surface) throw new Error('Failed to create CanvasKit surface') + store.setCanvasKit(ck, new SkiaRenderer(ck, surface)) + return store +} + +/** A `.fig` opened like the app does: only the first page has its layers. */ +async function storeWithUnshownPage(): Promise<{ store: EditorStore; pageId: string }> { + const source = new SceneGraph() + source.createNode('FRAME', source.getPages()[0].id, { width: 10, height: 10, fills: [RED] }) + const second = source.addPage('Second') + source.createNode('FRAME', second.id, { + name: 'Second frame', + width: 70, + height: 35, + fills: [RED] + }) + const written = await new IORegistry(BUILTIN_IO_FORMATS).writeDocument('fig', source) + const bytes = written.data as Uint8Array + const graph = await parseFigFile(bytes.slice().buffer, { populate: 'first-page' }) + const store = await storeWithCanvas(graph) + const page = graph.getPages().find((candidate) => candidate.name === 'Second') + if (!page) throw new Error('Missing second page') + expect(graph.getChildren(page.id)).toHaveLength(0) + return { store, pageId: page.id } +} + +function target(store: EditorStore, pageId: string): AutomationTarget { + const page = store.graph.getNode(pageId) + return { + store, + documentId: 'tab-1', + documentName: 'Document', + pageId, + pageName: page?.name ?? '' + } +} + +function pngSize(bytes: Uint8Array): { width: number; height: number } { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + return { width: view.getUint32(16), height: view.getUint32(20) } +} + +function resultBytes(response: unknown): Uint8Array { + const result = (response as { result?: { base64?: string } }).result + if (!result?.base64) throw new Error(`No image in ${JSON.stringify(response)}`) + return toUint8Array(result.base64) +} + +describe('automation export of a page that is not on screen', () => { + test('export_image renders nodes by ID from another page', async () => { + const store = await storeWithCanvas() + const shown = store.state.currentPageId + const other = store.graph.addPage('Other').id + const frame = store.graph.createNode('FRAME', other, { width: 40, height: 30, fills: [RED] }) + + const response = await handleTargetCommand(target(store, shown), 'tool', { + name: 'export_image', + args: { ids: [frame.id] } + }) + + expect(pngSize(resultBytes(response))).toEqual({ width: 40, height: 30 }) + expect(store.state.currentPageId).toBe(shown) + }) + + test('export_image renders the layers of the page named by page_id', async () => { + const store = await storeWithCanvas() + const shown = store.state.currentPageId + const other = store.graph.addPage('Other').id + store.graph.createNode('FRAME', other, { width: 50, height: 20, fills: [RED] }) + + const response = await handleTargetCommand(target(store, other), 'tool', { + name: 'export_image', + args: {} + }) + + expect(pngSize(resultBytes(response))).toEqual({ width: 50, height: 20 }) + expect(store.state.currentPageId).toBe(shown) + }) + + test('a page export renders the target page, not the selection on screen', async () => { + const store = await storeWithCanvas() + const shown = store.state.currentPageId + const selected = store.graph.createNode('FRAME', shown, { width: 10, height: 10, fills: [RED] }) + store.select([selected.id]) + const other = store.graph.addPage('Other').id + store.graph.createNode('FRAME', other, { width: 60, height: 25, fills: [RED] }) + + const response = await handleTargetCommand(target(store, other), 'export', { + scope: 'page', + format: 'png' + }) + + expect(pngSize(resultBytes(response))).toEqual({ width: 60, height: 25 }) + expect(store.state.currentPageId).toBe(shown) + }) + + test('a page export loads the layers of a .fig page that has not been shown', async () => { + const { store, pageId } = await storeWithUnshownPage() + const shown = store.state.currentPageId + + const response = await handleTargetCommand(target(store, pageId), 'export', { scope: 'page' }) + + expect(pngSize(resultBytes(response))).toEqual({ width: 70, height: 35 }) + expect(store.state.currentPageId).toBe(shown) + }) + + test('a JSX export lists the layers of a .fig page that has not been shown', async () => { + const { store, pageId } = await storeWithUnshownPage() + + const response = (await handleTargetCommand(target(store, pageId), 'export_jsx', {})) as { + result: { jsx: string } + } + + expect(response.result.jsx).toContain('Second frame') + }) + + test('a page export lays out the target page first', async () => { + const store = await storeWithCanvas() + const shown = store.state.currentPageId + const other = store.graph.addPage('Other').id + // Not laid out yet: the row hugs one 80 px child but still says 10 px, and clips. + const row = store.graph.createNode('FRAME', other, { + width: 10, + height: 20, + fills: [RED], + clipsContent: true, + layoutMode: 'HORIZONTAL', + primaryAxisSizing: 'HUG', + counterAxisSizing: 'FIXED' + }) + store.graph.createNode('FRAME', row.id, { width: 80, height: 20, fills: [RED] }) + + const response = await handleTargetCommand(target(store, other), 'export', { scope: 'page' }) + + expect(pngSize(resultBytes(response))).toEqual({ width: 80, height: 20 }) + expect(store.state.currentPageId).toBe(shown) + }) +}) diff --git a/tests/app/automation/bridge/tool-page.test.ts b/tests/app/automation/bridge/tool-page.test.ts new file mode 100644 index 000000000..3ff485742 --- /dev/null +++ b/tests/app/automation/bridge/tool-page.test.ts @@ -0,0 +1,102 @@ +import 'fake-indexeddb/auto' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' + +import { BUILTIN_IO_FORMATS, IORegistry, parseFigFile } from '@open-pencil/core/io' +import { SceneGraph } from '@open-pencil/scene-graph' + +import { makeFigmaFromStore } from '@/app/automation/bridge/figma-factory' +import { createAutomationCommandHandlers } from '@/app/automation/bridge/handlers' +import type { AutomationTarget } from '@/app/automation/bridge/target' +import { createEditorStore, type EditorStore } from '@/app/editor/session/create' + +const { handleTargetCommand } = createAutomationCommandHandlers(makeFigmaFromStore) +let stores: EditorStore[] = [] + +beforeEach(() => { + // The automation FigmaAPI reads the viewport size from the window. + Object.assign(globalThis, { window: { innerWidth: 1024, innerHeight: 768 } }) +}) + +afterEach(() => { + for (const store of stores) store.dispose() + stores = [] + Reflect.deleteProperty(globalThis, 'window') +}) + +/** A `.fig` opened like the app does: only the first page has its layers. */ +async function storeWithUnshownPage(): Promise<{ store: EditorStore; pageId: string }> { + const source = new SceneGraph() + source.createNode('FRAME', source.getPages()[0].id, { name: 'First frame' }) + const second = source.addPage('Second') + source.createNode('FRAME', second.id, { name: 'Second frame', width: 70, height: 35 }) + const written = await new IORegistry(BUILTIN_IO_FORMATS).writeDocument('fig', source) + const bytes = written.data as Uint8Array + const graph = await parseFigFile(bytes.slice().buffer, { populate: 'first-page' }) + const store = createEditorStore(graph) + stores.push(store) + const page = graph.getPages().find((p) => p.name === 'Second') + if (!page) throw new Error('Missing second page') + expect(graph.getChildren(page.id)).toHaveLength(0) + return { store, pageId: page.id } +} + +function target(store: EditorStore, pageId: string): AutomationTarget { + return { + store, + documentId: 'tab-1', + documentName: 'Document', + pageId, + pageName: store.graph.getNode(pageId)?.name ?? '' + } +} + +describe('automation tools on a page that has not been shown', () => { + test('find_nodes sees the layers of the target page', async () => { + const { store, pageId } = await storeWithUnshownPage() + const shown = store.state.currentPageId + + const response = (await handleTargetCommand(target(store, pageId), 'tool', { + name: 'find_nodes', + args: { name: 'Second frame' } + })) as { result: { count: number } } + + expect(response.result.count).toBe(1) + expect(store.state.currentPageId).toBe(shown) + }) + + test('a shape created there joins the existing layers', async () => { + const { store, pageId } = await storeWithUnshownPage() + + await handleTargetCommand(target(store, pageId), 'tool', { + name: 'create_shape', + args: { type: 'RECTANGLE', x: 0, y: 0, width: 10, height: 10, name: 'Added' } + }) + + expect(store.graph.getChildren(pageId).map((node) => node.name)).toEqual([ + 'Second frame', + 'Added' + ]) + }) + + test('a tool measures the target page after its layout runs', async () => { + const store = createEditorStore() + stores.push(store) + const other = store.graph.addPage('Other').id + // Not laid out yet: the row hugs one 80 px child but still says 10 px. + const row = store.graph.createNode('FRAME', other, { + width: 10, + height: 20, + layoutMode: 'HORIZONTAL', + primaryAxisSizing: 'HUG', + counterAxisSizing: 'FIXED' + }) + store.graph.createNode('FRAME', row.id, { width: 80, height: 20 }) + + const response = (await handleTargetCommand(target(store, other), 'tool', { + name: 'get_node', + args: { id: row.id } + })) as { result: { width: number } } + + expect(response.result.width).toBe(80) + }) +}) diff --git a/tools/checks/architecture/src/steiger-rules/support.ts b/tools/checks/architecture/src/steiger-rules/support.ts index 412e7a4e5..6cc8bdf60 100644 --- a/tools/checks/architecture/src/steiger-rules/support.ts +++ b/tools/checks/architecture/src/steiger-rules/support.ts @@ -59,7 +59,7 @@ export const PACKAGE_ALIAS_OWNERS: Record = { '#fig/': 'packages/fig/', '#fig-tests/': 'packages/fig/tests/', '#vue/': 'packages/vue/src/', - '#cli/': 'packages/cli/src/', + '#cli/': 'packages/cli/', '#mcp/': 'packages/mcp/src/' }