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:
parent
7cfa08aec5
commit
578b93e189
3
bun.lock
3
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=="],
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in a new issue