From fbacd1a34a3a2c48ed6f414b92c38533163d4311 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 13 Sep 2026 13:22:17 +0300 Subject: [PATCH] fix(clipboard): embed images with Figma-compatible hashes --- CHANGELOG.md | 2 +- packages/core/src/clipboard.ts | 3 +- packages/fig/src/clipboard/images.ts | 41 +++++++++++++++++ packages/fig/src/clipboard/index.ts | 1 + packages/kiwi/src/fig/codec.ts | 10 ++++- tests/engine/clipboard/figma/images.test.ts | 49 +++++++++++++++++++++ 6 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 packages/fig/src/clipboard/images.ts create mode 100644 tests/engine/clipboard/figma/images.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b534c138e..c6661169f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,7 @@ ### Changed -- Copy selections as Figma-compatible clipboard data while preserving typed geometry, text sizing, images, components, variables, modes, and shared styles for lossless in-app paste. +- Copy selections with embedded images into Figma while preserving typed geometry, text sizing, images, components, variables, modes, and shared styles for lossless in-app paste. - Choose whether interface animations follow the system motion preference or stay off, with live updates and a persistent override. - Put unbound fill and stroke style pickers in section headers, preserve applied and missing style rows, and remove the redundant Dimensions heading for text layers. - Open variable pickers below their trigger when space permits, flipping above near the viewport edge. diff --git a/packages/core/src/clipboard.ts b/packages/core/src/clipboard.ts index b4efb261c..e1dc7d620 100644 --- a/packages/core/src/clipboard.ts +++ b/packages/core/src/clipboard.ts @@ -1,4 +1,4 @@ -import { encodeFigmaClipboard } from '@open-pencil/fig/clipboard' +import { embedClipboardImages, encodeFigmaClipboard } from '@open-pencil/fig/clipboard' export { parseFigmaClipboard, importClipboardNodes, @@ -85,6 +85,7 @@ export async function buildFigmaClipboardHTML( change.derivedTextData = await buildDerivedTextDataV4(source, fontDigestMap, shaped, blobs) }) ) + await embedClipboardImages(nodeChanges, blobs, graph.images) return encodeFigmaClipboard(nodeChanges, blobs, randomInt()) } diff --git a/packages/fig/src/clipboard/images.ts b/packages/fig/src/clipboard/images.ts new file mode 100644 index 000000000..2bff7b70a --- /dev/null +++ b/packages/fig/src/clipboard/images.ts @@ -0,0 +1,41 @@ +import type { NodeChange, Paint } from '@open-pencil/kiwi/fig/codec' + +import { bytesToHex } from '../node-change/bytes' + +/** Clipboard recipients cannot fetch image hashes from an OpenPencil source file. */ +export async function embedClipboardImages( + changes: NodeChange[], + blobs: Uint8Array[], + images: ReadonlyMap +): Promise { + const embedded = new Map() + async function embed(paints: Paint[] = []) { + for (const paint of paints) { + for (const image of [paint.image, paint.imageThumbnail, paint.animatedImage]) { + if (!image?.hash) continue + const hash = typeof image.hash === 'string' ? image.hash : bytesToHex(image.hash) + const bytes = images.get(hash) + if (!bytes) continue + let entry = embedded.get(hash) + if (!entry) { + // Figma rejects bytes whose SHA-1 differs from the image reference. + // Internal SceneGraph image identifiers are not necessarily SHA-1. + const digest = await crypto.subtle.digest('SHA-1', new Uint8Array(bytes).buffer) + entry = { index: blobs.length, hash: new Uint8Array(digest) } + blobs.push(bytes) + embedded.set(hash, entry) + } + image.hash = entry.hash + image.dataBlob = entry.index + } + } + } + async function visit(change: NodeChange): Promise { + await embed(change.fillPaints) + await embed(change.strokePaints) + await embed(change.backgroundPaints) + await embed(change.textDecorationFillPaints) + for (const override of change.textData?.styleOverrideTable ?? []) await visit(override) + } + for (const change of changes) await visit(change) +} diff --git a/packages/fig/src/clipboard/index.ts b/packages/fig/src/clipboard/index.ts index b64fec952..a84fdac3f 100644 --- a/packages/fig/src/clipboard/index.ts +++ b/packages/fig/src/clipboard/index.ts @@ -1,2 +1,3 @@ export { parseFigmaClipboard, importClipboardNodes, figmaNodesBounds } from './import' export { encodeFigmaClipboard } from './encode' +export { embedClipboardImages } from './images' diff --git a/packages/kiwi/src/fig/codec.ts b/packages/kiwi/src/fig/codec.ts index 1c070f41b..10bbeeff5 100644 --- a/packages/kiwi/src/fig/codec.ts +++ b/packages/kiwi/src/fig/codec.ts @@ -186,6 +186,12 @@ export interface VariableBinding { variableID: GUID } +export interface ImageSource { + hash: string | Uint8Array + name?: string + dataBlob?: number +} + export interface Paint { type: | 'SOLID' @@ -204,7 +210,9 @@ export interface Paint { blendMode?: string stops?: { color: Color; position: number }[] transform?: Matrix - image?: { hash: string | Uint8Array } + image?: ImageSource + imageThumbnail?: ImageSource + animatedImage?: ImageSource imageScaleMode?: string sourceNodeId?: GUID scale?: number diff --git a/tests/engine/clipboard/figma/images.test.ts b/tests/engine/clipboard/figma/images.test.ts new file mode 100644 index 000000000..7b4f2ffa3 --- /dev/null +++ b/tests/engine/clipboard/figma/images.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from 'bun:test' + +import { buildFigmaClipboardHTML, FigmaAPI } from '@open-pencil/core' +import { parseFigmaClipboard } from '@open-pencil/fig/clipboard' +import { SceneGraph } from '@open-pencil/scene-graph' + +// Valid PNG: Figma validates the embedded bytes against the SHA-1 image reference. +const bytes = new Uint8Array( + await Bun.file('tests/fixtures/vectorize/python_logo.png').arrayBuffer() +) + +test('Figma clipboard embeds shared image bytes once with SHA-1 references', async () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + const { hash } = new FigmaAPI(graph).createImage(bytes) + const nodes = [0, 1].map((index) => + graph.createNode('RECTANGLE', page.id, { + name: `Image ${index}`, + width: 40, + height: 40, + fills: [ + { + type: 'IMAGE', + color: { r: 0, g: 0, b: 0, a: 1 }, + opacity: 1, + visible: true, + imageHash: hash, + imageScaleMode: 'FILL' + } + ] + }) + ) + const html = await buildFigmaClipboardHTML(nodes, graph) + if (!html) throw new Error('Missing clipboard HTML') + const parsed = await parseFigmaClipboard(html) + if (!parsed) throw new Error('Invalid clipboard HTML') + const paints = parsed.nodes.flatMap((node) => node.fillPaints ?? []) + expect(paints).toHaveLength(2) + const expectedHash = new Uint8Array(await crypto.subtle.digest('SHA-1', bytes)) + const firstIndex = paints[0].image?.dataBlob + expect(firstIndex).toBeDefined() + for (const paint of paints) { + expect(paint.image?.hash).toEqual(expectedHash) + expect(paint.image?.dataBlob).toBe(firstIndex) + expect(parsed.blobs[paint.image?.dataBlob ?? -1]).toEqual(bytes) + } + expect(nodes[0].fills[0].imageHash).toBe(hash) + expect(graph.images.get(hash)).toBe(bytes) +})