From 358ac9ebf70ffc3f2cfc1eb6d288e30574b612d5 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 16 May 2026 11:52:22 +0300 Subject: [PATCH] fix(clipboard): orient text glyph outlines --- .../src/kiwi/node-change/path-commands.ts | 49 ++++++++++++++++ .../core/src/kiwi/node-change/serialize.ts | 3 +- .../core/src/text/clipboard-derived-text.ts | 52 ++++++++++++++--- packages/core/src/text/derived-text-data.ts | 3 +- packages/core/src/text/opentype.ts | 56 ++----------------- 5 files changed, 101 insertions(+), 62 deletions(-) create mode 100644 packages/core/src/kiwi/node-change/path-commands.ts diff --git a/packages/core/src/kiwi/node-change/path-commands.ts b/packages/core/src/kiwi/node-change/path-commands.ts new file mode 100644 index 000000000..f207e19c6 --- /dev/null +++ b/packages/core/src/kiwi/node-change/path-commands.ts @@ -0,0 +1,49 @@ +import type { OutlineCommand } from '#core/text/opentype' + +const CMD_CLOSE = 0 +const CMD_MOVE_TO = 1 +const CMD_LINE_TO = 2 +const CMD_CUBIC_TO = 4 + +export function encodePathCommandsBlob(commands: OutlineCommand[], scale = 1): Uint8Array { + const bytes: number[] = [] + const pushFloat = (value: number | undefined) => { + const buf = new ArrayBuffer(4) + new DataView(buf).setFloat32(0, (value ?? 0) / scale, true) + bytes.push(...new Uint8Array(buf)) + } + + for (const command of commands) { + switch (command.type) { + case 'M': + bytes.push(CMD_MOVE_TO) + pushFloat(command.x) + pushFloat(command.y === undefined ? undefined : -command.y) + break + case 'L': + bytes.push(CMD_LINE_TO) + pushFloat(command.x) + pushFloat(command.y === undefined ? undefined : -command.y) + break + case 'C': + bytes.push(CMD_CUBIC_TO) + pushFloat(command.x1) + pushFloat(command.y1 === undefined ? undefined : -command.y1) + pushFloat(command.x2) + pushFloat(command.y2 === undefined ? undefined : -command.y2) + pushFloat(command.x) + pushFloat(command.y === undefined ? undefined : -command.y) + break + case 'Q': + bytes.push(CMD_LINE_TO) + pushFloat(command.x) + pushFloat(command.y === undefined ? undefined : -command.y) + break + case 'Z': + bytes.push(CMD_CLOSE) + break + } + } + + return new Uint8Array(bytes) +} diff --git a/packages/core/src/kiwi/node-change/serialize.ts b/packages/core/src/kiwi/node-change/serialize.ts index 07ad50a06..1f841918c 100644 --- a/packages/core/src/kiwi/node-change/serialize.ts +++ b/packages/core/src/kiwi/node-change/serialize.ts @@ -1,5 +1,6 @@ import { buildDerivedTextData as buildSharedDerivedTextData } from '#core/text/derived-text-data' import { normalizeFontFamily, weightToFigmaStyle, weightToStyle } from '#core/text/fonts' +import { encodePathCommandsBlob } from '#core/kiwi/node-change/path-commands' import { getGlyphOutlineMetricsSync } from '#core/text/opentype' import { encodeVectorNetworkBlob, buildStyleOverrideTable } from '#core/vector' export { @@ -109,7 +110,7 @@ function buildDerivedTextData( const glyphAdvance = node.text.length > 0 ? node.width / Math.max(node.text.length, 1) : 0 const glyphs = glyphMetrics.map((glyph, index) => ({ - commandsBlob: blobs.push(glyph.commandsBlob) - 1, + commandsBlob: blobs.push(encodePathCommandsBlob(glyph.commands, node.fontSize)) - 1, position: { x: glyph.x || index * glyphAdvance, y: lineHeight }, fontSize: node.fontSize, firstCharacter: index, diff --git a/packages/core/src/text/clipboard-derived-text.ts b/packages/core/src/text/clipboard-derived-text.ts index 3977c9c1a..f8bb4f416 100644 --- a/packages/core/src/text/clipboard-derived-text.ts +++ b/packages/core/src/text/clipboard-derived-text.ts @@ -1,6 +1,8 @@ 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 { buildDerivedTextData } from './derived-text-data' import { normalizeFontFamily, weightToFigmaStyle, weightToStyle } from './fonts' import { getGlyphOutlineMetricsSync } from './opentype' @@ -32,16 +34,40 @@ 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 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 fallbackX = glyph.x || index * fallbackAdvance const fallbackGlyphAdvance = glyph.advance || fallbackAdvance - const commandsBlob = blobs ? blobs.push(glyph.commandsBlob) - 1 : undefined + if (!shapedGlyph && fallbackX > 0 && fallbackX + fallbackGlyphAdvance > node.width) { + fallbackBaselines.push({ + firstCharacter: lineStart, + endCharacter: Math.max(index - 1, lineStart), + position: { x: 0, y: fallbackY }, + width: fallbackX, + lineHeight: lineHeightFallback, + lineAscent + }) + lineStart = index + fallbackX = 0 + fallbackY += lineHeightFallback + } + const glyphX = fallbackX + fallbackOffsets[index] = glyphX + fallbackX += fallbackGlyphAdvance + const commandsBlob = blobs + ? blobs.push(encodePathCommandsBlob(glyph.commands, node.fontSize)) - 1 + : undefined return { commandsBlob, position: { - x: shapedGlyph?.x ?? fallbackX, - y: shapedGlyph?.y ?? shaped?.baseline ?? lineHeightFallback + x: shapedGlyph?.x ?? glyphX, + y: shapedGlyph?.y ?? shaped?.baseline ?? fallbackY }, fontSize: node.fontSize, firstCharacter: shapedGlyph?.firstCharacter ?? index, @@ -49,6 +75,17 @@ export async function buildDerivedTextDataV4( rotation: 0 } }) + fallbackOffsets[node.text.length] = fallbackX + if (node.text.length > 0) { + fallbackBaselines.push({ + firstCharacter: lineStart, + endCharacter: node.text.length - 1, + position: { x: 0, y: fallbackY }, + width: fallbackX, + lineHeight: lineHeightFallback, + lineAscent + }) + } return buildDerivedTextData({ node, @@ -65,9 +102,8 @@ export async function buildDerivedTextDataV4( baseline: shaped?.baseline ?? lineHeightFallback, width: shaped?.lineWidth ?? node.width, lineHeight: shaped?.lineHeight ?? lineHeightFallback, - lineAscent: shaped?.lineAscent ?? Math.max(lineHeightFallback - node.fontSize * 0.2, 0), - logicalIndexToCharacterOffsetMap: - shaped?.logicalIndexToCharacterOffsetMap ?? - Array.from({ length: node.text.length + 1 }, (_, index) => index * fallbackAdvance) + lineAscent: shaped?.lineAscent ?? lineAscent, + baselines: shaped ? undefined : fallbackBaselines, + logicalIndexToCharacterOffsetMap: shaped?.logicalIndexToCharacterOffsetMap ?? fallbackOffsets }) } diff --git a/packages/core/src/text/derived-text-data.ts b/packages/core/src/text/derived-text-data.ts index 00be9e820..f2fb66ba7 100644 --- a/packages/core/src/text/derived-text-data.ts +++ b/packages/core/src/text/derived-text-data.ts @@ -9,6 +9,7 @@ interface DerivedTextDataOptions { width: number lineHeight: number lineAscent: number + baselines?: NonNullable['baselines'] logicalIndexToCharacterOffsetMap: number[] } @@ -17,7 +18,7 @@ export function buildDerivedTextData( ): NodeChange['derivedTextData'] { return { layoutSize: { x: options.node.width, y: options.node.height }, - baselines: [ + baselines: options.baselines ?? [ { firstCharacter: 0, endCharacter: Math.max(options.node.text.length - 1, 0), diff --git a/packages/core/src/text/opentype.ts b/packages/core/src/text/opentype.ts index 02ba6f8cb..856ddbc33 100644 --- a/packages/core/src/text/opentype.ts +++ b/packages/core/src/text/opentype.ts @@ -2,7 +2,7 @@ import * as OpenTypeSync from 'opentype.js' import { fontManager } from './fonts' -interface OutlineCommand { +export interface OutlineCommand { type: string x?: number y?: number @@ -81,59 +81,11 @@ export function measureTextWithOpenType( } export interface GlyphOutlineMetrics { - commandsBlob: Uint8Array + commands: OutlineCommand[] x: number advance: number } -const CMD_CLOSE = 0 -const CMD_MOVE_TO = 1 -const CMD_LINE_TO = 2 -const CMD_CUBIC_TO = 4 - -function commandsToBlob(commands: OutlineCommand[], fontSize: number): Uint8Array { - const bytes: number[] = [] - const pushFloat = (value: number | undefined) => { - const buf = new ArrayBuffer(4) - new DataView(buf).setFloat32(0, (value ?? 0) / fontSize, true) - bytes.push(...new Uint8Array(buf)) - } - - for (const command of commands) { - switch (command.type) { - case 'M': - bytes.push(CMD_MOVE_TO) - pushFloat(command.x) - pushFloat(command.y) - break - case 'L': - bytes.push(CMD_LINE_TO) - pushFloat(command.x) - pushFloat(command.y) - break - case 'C': - bytes.push(CMD_CUBIC_TO) - pushFloat(command.x1) - pushFloat(command.y1) - pushFloat(command.x2) - pushFloat(command.y2) - pushFloat(command.x) - pushFloat(command.y) - break - case 'Q': - bytes.push(CMD_LINE_TO) - pushFloat(command.x) - pushFloat(command.y) - break - case 'Z': - bytes.push(CMD_CLOSE) - break - } - } - - return new Uint8Array(bytes) -} - export function getGlyphOutlineMetricsSync( family: string, style: string, @@ -147,9 +99,9 @@ export function getGlyphOutlineMetricsSync( let x = 0 const scale = fontSize / font.unitsPerEm return glyphs.map((glyph) => { - const commandsBlob = commandsToBlob(glyph.getPath(0, 0, fontSize).commands, fontSize) + const commands = glyph.getPath(0, 0, fontSize).commands const advance = (glyph.advanceWidth ?? 0) * scale - const metrics = { commandsBlob, x, advance } + const metrics = { commands, x, advance } x += advance return metrics })