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
This commit is contained in:
Danila Poyarkov 2026-05-16 18:26:24 +03:00
parent 7cfa08aec5
commit 578b93e189
5 changed files with 109 additions and 26 deletions

View file

@ -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=="],

View file

@ -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:*",

View file

@ -284,36 +284,46 @@ export async function shapeTextForClipboard(node: SceneNode): Promise<ClipboardS
if (!fontProvider) return null
const paragraph = buildParagraph({ ck, fontProvider, fontsLoaded: true }, node)
paragraph.layout(node.textAutoResize === 'WIDTH_AND_HEIGHT' ? 1e6 : node.width)
const shapedLines = paragraph.getShapedLines()
const lineMetrics = paragraph.getLineMetrics()
const firstLine = shapedLines[0]
if (shapedLines.length === 0 || lineMetrics.length === 0) {
paragraph.delete()
return null
}
const firstMetrics = lineMetrics[0]
const glyphs: ClipboardShapedGlyph[] = []
const logicalIndexToCharacterOffsetMap = Array.from({ length: node.text.length + 1 }, () => 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
}
}

View file

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

View file

@ -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<NodeChange['derivedTextData']>['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),