fix(clipboard): embed images with Figma-compatible hashes

This commit is contained in:
Danila Poyarkov 2026-09-13 13:22:17 +03:00
parent 449d09542f
commit fbacd1a34a
6 changed files with 103 additions and 3 deletions

View file

@ -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.

View file

@ -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())
}

View file

@ -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<string, Uint8Array>
): Promise<void> {
const embedded = new Map<string, { index: number; hash: Uint8Array }>()
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<void> {
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)
}

View file

@ -1,2 +1,3 @@
export { parseFigmaClipboard, importClipboardNodes, figmaNodesBounds } from './import'
export { encodeFigmaClipboard } from './encode'
export { embedClipboardImages } from './images'

View file

@ -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

View file

@ -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)
})