fix(clipboard): embed images with Figma-compatible hashes
This commit is contained in:
parent
449d09542f
commit
fbacd1a34a
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
||||
|
|
|
|||
41
packages/fig/src/clipboard/images.ts
Normal file
41
packages/fig/src/clipboard/images.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
export { parseFigmaClipboard, importClipboardNodes, figmaNodesBounds } from './import'
|
||||
export { encodeFigmaClipboard } from './encode'
|
||||
export { embedClipboardImages } from './images'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
49
tests/engine/clipboard/figma/images.test.ts
Normal file
49
tests/engine/clipboard/figma/images.test.ts
Normal 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)
|
||||
})
|
||||
Loading…
Reference in a new issue