fix(clipboard): orient text glyph outlines

This commit is contained in:
Danila Poyarkov 2026-05-16 11:52:22 +03:00
parent 91f516ecaa
commit 358ac9ebf7
5 changed files with 101 additions and 62 deletions

View file

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

View file

@ -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,

View file

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

View file

@ -9,6 +9,7 @@ interface DerivedTextDataOptions {
width: number
lineHeight: number
lineAscent: number
baselines?: NonNullable<NodeChange['derivedTextData']>['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),

View file

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