From 578b93e189b8037b1bb14b5c8eff6aa82fbfa776 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 16 May 2026 18:26:24 +0300 Subject: [PATCH] fix(clipboard): use CanvasKit shaping + pretext word wrap for Figma text - shapeTextForClipboard() now processes all lines, not just the first - buildFigmaClipboardHTML() passes CanvasKit-shaped positions to derivedTextData when available (browser with CanvasKit loaded) - Add @chenglou/pretext for proper Unicode-aware word wrapping as fallback when CanvasKit isn't available (CLI, headless, .fig export) - Falls back to basic word-boundary wrapping if pretext fails --- bun.lock | 3 + package.json | 1 + packages/core/src/canvas/text.ts | 54 ++++++++------ packages/core/src/clipboard.ts | 4 +- .../core/src/text/derived-text/clipboard.ts | 73 ++++++++++++++++++- 5 files changed, 109 insertions(+), 26 deletions(-) diff --git a/bun.lock b/bun.lock index 6f176da59..239dd33b5 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ "@ai-sdk/vue": "^3.0.174", "@atlaskit/pragmatic-drag-and-drop": "^1.7.9", "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0", + "@chenglou/pretext": "^0.0.7", "@open-pencil/cli": "workspace:*", "@open-pencil/core": "workspace:*", "@open-pencil/vue": "workspace:*", @@ -444,6 +445,8 @@ "@chainsafe/netmask": ["@chainsafe/netmask@2.0.0", "", { "dependencies": { "@chainsafe/is-ip": "^2.0.1" } }, "sha512-I3Z+6SWUoaljh3TBzCnCxjlUyN8tA+NAk5L6m9IxvCf1BENQTePzPMis97CoN/iMW1St3WN+AWCCRp+TTBRiDg=="], + "@chenglou/pretext": ["@chenglou/pretext@0.0.7", "", {}, "sha512-FV5hj3fGqpBlzbANUbR+s+6XuNRrghVVyuNs33zdH2SzU7MvK+7GlW6xREjDCreixKbexEn7OEkDgAFeWuu5Hg=="], + "@clack/core": ["@clack/core@0.4.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-Pxhij4UXg8KSr7rPek6Zowm+5M22rbd2g1nfojHJkxp5YkFqiZ2+YLEM/XGVIzvGOcM0nqjIFxrpDwWRZYWYjA=="], "@clack/prompts": ["@clack/prompts@0.9.1", "", { "dependencies": { "@clack/core": "0.4.1", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-JIpyaboYZeWYlyP0H+OoPPxd6nqueG/CmN6ixBiNFsIDHREevjIf0n0Ohh5gr5C8pEDknzgvz+pIJ8dMhzWIeg=="], diff --git a/package.json b/package.json index 043df739d..85af9b74e 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@ai-sdk/vue": "^3.0.174", "@atlaskit/pragmatic-drag-and-drop": "^1.7.9", "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0", + "@chenglou/pretext": "^0.0.7", "@open-pencil/cli": "workspace:*", "@open-pencil/core": "workspace:*", "@open-pencil/vue": "workspace:*", diff --git a/packages/core/src/canvas/text.ts b/packages/core/src/canvas/text.ts index 825c457e6..5a4e73397 100644 --- a/packages/core/src/canvas/text.ts +++ b/packages/core/src/canvas/text.ts @@ -284,36 +284,46 @@ export async function shapeTextForClipboard(node: SceneNode): Promise 0) - for (const run of firstLine.runs) { - const positions = run.positions - for (let i = 0; i < run.glyphs.length; i++) { - const x = positions[i * 2] ?? 0 - const y = positions[i * 2 + 1] ?? firstLine.baseline - const nextX = positions[(i + 1) * 2] ?? x - const firstCharacter = run.offsets[i] ?? i - glyphs.push({ - glyphIndex: i, - firstCharacter, - x, - y, - advance: nextX - x - }) - if (firstCharacter >= 0 && firstCharacter < logicalIndexToCharacterOffsetMap.length) { - logicalIndexToCharacterOffsetMap[firstCharacter] = x + for (let lineIdx = 0; lineIdx < shapedLines.length; lineIdx++) { + const line = shapedLines[lineIdx] + const metrics = lineMetrics[lineIdx] ?? firstMetrics + const lineY = metrics.baseline + + for (const run of line.runs) { + const positions = run.positions + for (let i = 0; i < run.glyphs.length; i++) { + const x = positions[i * 2] ?? 0 + const y = positions[i * 2 + 1] ?? lineY + const nextX = positions[(i + 1) * 2] ?? x + const firstCharacter = run.offsets[i] ?? i + glyphs.push({ + glyphIndex: i, + firstCharacter, + x, + y, + advance: nextX - x + }) + if (firstCharacter >= 0 && firstCharacter < logicalIndexToCharacterOffsetMap.length) { + logicalIndexToCharacterOffsetMap[firstCharacter] = x + } + } + const finalOffset = run.offsets[run.offsets.length - 1] + const finalX = positions[positions.length - 2] ?? metrics.width + if (finalOffset >= 0 && finalOffset < logicalIndexToCharacterOffsetMap.length) { + logicalIndexToCharacterOffsetMap[finalOffset] = finalX } - } - const finalOffset = run.offsets[run.offsets.length - 1] - const finalX = positions[positions.length - 2] ?? firstMetrics.width - if (finalOffset >= 0 && finalOffset < logicalIndexToCharacterOffsetMap.length) { - logicalIndexToCharacterOffsetMap[finalOffset] = finalX } } diff --git a/packages/core/src/clipboard.ts b/packages/core/src/clipboard.ts index 12774cbe6..afd9494b2 100644 --- a/packages/core/src/clipboard.ts +++ b/packages/core/src/clipboard.ts @@ -17,6 +17,7 @@ import { } from './kiwi/node-change/serialize' import { randomInt } from './random' import type { SceneGraph, SceneNode } from './scene-graph' +import { shapeTextForClipboard } from './canvas/text' import { buildDerivedTextDataV4 } from './text/derived-text/clipboard' interface FigmaClipboardMeta { @@ -335,7 +336,8 @@ export async function buildFigmaClipboardHTML( const source = textNodeQueue.shift() if (!source) return change.textUserLayoutVersion = 4 - change.derivedTextData = await buildDerivedTextDataV4(source, fontDigestMap, null, blobs) + const shaped = await shapeTextForClipboard(source).catch(() => null) + change.derivedTextData = await buildDerivedTextDataV4(source, fontDigestMap, shaped, blobs) }) ) diff --git a/packages/core/src/text/derived-text/clipboard.ts b/packages/core/src/text/derived-text/clipboard.ts index 8a7a83887..9232ae2db 100644 --- a/packages/core/src/text/derived-text/clipboard.ts +++ b/packages/core/src/text/derived-text/clipboard.ts @@ -1,13 +1,75 @@ +import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext' + import type { NodeChange } from '#core/kiwi/binary/codec' import type { SceneNode } from '#core/scene-graph' import { encodePathCommandsBlob } from '#core/kiwi/node-change/path-commands' import { normalizeFontFamily, weightToFigmaStyle, weightToStyle } from '#core/text/fonts' -import { getGlyphOutlineMetricsSync } from '#core/text/opentype' +import { type GlyphOutlineMetrics, getGlyphOutlineMetricsSync } from '#core/text/opentype' import { buildDerivedTextData } from './data' +function computeWordWrapBreaks( + text: string, + glyphMetrics: GlyphOutlineMetrics[], + fallbackAdvance: number, + maxWidth: number, + fontSize: number, + fontFamily: string +): number[] { + try { + const font = `${fontSize}px ${fontFamily}` + const prepared = prepareWithSegments(text, font) + const lineHeight = Math.ceil(fontSize * 1.2) + const { lines } = layoutWithLines(prepared, maxWidth, lineHeight) + const breaks: number[] = [] + let charOffset = 0 + for (const line of lines) { + if (charOffset > 0) breaks.push(charOffset) + charOffset += line.text.length + } + return breaks + } catch { + return computeFallbackBreaks(text, glyphMetrics, fallbackAdvance, maxWidth) + } +} + +function computeFallbackBreaks( + text: string, + glyphMetrics: GlyphOutlineMetrics[], + fallbackAdvance: number, + maxWidth: number +): number[] { + const breaks: number[] = [] + let x = 0 + let lastBreak = 0 + let lastBreakX = 0 + + for (let i = 0; i < glyphMetrics.length; i++) { + const advance = glyphMetrics[i].advance || fallbackAdvance + const ch = text[i] + if (ch === ' ' || ch === '\t' || (ch === '-' && i + 1 < text.length)) { + lastBreak = i + 1 + lastBreakX = x + advance + } + if (x > 0 && x + advance > maxWidth) { + const lineStart = breaks.length > 0 ? breaks[breaks.length - 1] : 0 + if (lastBreak > lineStart) { + breaks.push(lastBreak) + x -= lastBreakX + } else { + breaks.push(i) + x = 0 + } + lastBreak = breaks[breaks.length - 1] + lastBreakX = 0 + } + x += advance + } + return breaks +} + export interface ShapedClipboardText { lineHeight: number lineAscent: number @@ -35,17 +97,22 @@ export async function buildDerivedTextDataV4( const glyphMetrics = getGlyphOutlineMetricsSync(node.fontFamily, style, node.text, node.fontSize) ?? [] const fallbackAdvance = node.text.length > 0 ? node.width / Math.max(node.text.length, 1) : 0 + const lineAscent = Math.max(lineHeightFallback - node.fontSize * 0.2, 0) + const lineBreaks = shaped + ? [] + : computeWordWrapBreaks(node.text, glyphMetrics, fallbackAdvance, node.width, node.fontSize, node.fontFamily) + const lineBreakSet = new Set(lineBreaks) + const fallbackBaselines: NonNullable['baselines'] = [] const fallbackOffsets = Array.from({ length: node.text.length + 1 }, () => 0) let fallbackX = 0 let fallbackY = lineHeightFallback let lineStart = 0 - const lineAscent = Math.max(lineHeightFallback - node.fontSize * 0.2, 0) const glyphs = glyphMetrics.map((glyph, index) => { const shapedGlyph = shaped?.glyphs[index] const fallbackGlyphAdvance = glyph.advance || fallbackAdvance - if (!shapedGlyph && fallbackX > 0 && fallbackX + fallbackGlyphAdvance > node.width) { + if (!shapedGlyph && lineBreakSet.has(index)) { fallbackBaselines.push({ firstCharacter: lineStart, endCharacter: Math.max(index - 1, lineStart),