fix(fig): write text glyphs from the layout the renderer draws (#928)

* fix(fig): write text glyphs from the layout the renderer draws

Text without saved glyphs was written to .fig with outlines from a
character-by-character fallback: one unwrapped line at y = lineHeight,
no alignment, advances in pixels. Figma lays saved text out from that
data, so every wrapped OpenPencil label opened in Figma on one line, and
since saved glyphs now draw before the paragraph, OpenPencil did the same
after a reopen (#914). The Figma clipboard had a second writer with its
own shaping that matched outlines to characters by index.

One builder in @open-pencil/fig now writes derivedTextData for both. It
keeps glyphs a layer already has and otherwise asks the export runtime to
shape the text. Core shapes with the paragraph the renderer draws, so
lines, alignment, and baselines match, and takes each outline from the
glyph ID CanvasKit chose, so ligatures and contextual forms keep their
shapes. CanvasKit does not say which font drew a run, so outlines are
written only when the run's own font covers it; otherwise the layout is
written without outlines and readers lay the text out themselves.
Advances are in em units and the character offset map has one entry per
character, as in Figma's files.

The reader drops glyphs the earlier fallback wrote, recognised by its
offset map one entry longer than the text with every glyph on the one
written baseline, and any glyph set with a missing outline.

* fix(scene-graph): reflow resized text instead of stretching its glyphs

Resizing scaled a text layer's saved glyphs into the new box. That suits
path text, whose glyphs follow its path, but flat text reflows, and with
saved glyphs drawn before the paragraph a resized Figma text layer was
drawn stretched. Scale glyphs only for path text, including baked path
text that kept only rotated glyphs, and let the width change drop the
rest.

* docs(fig): describe how derived text is written

Figma draws saved text from derivedTextData even when it has the font, so the export docs record which glyphs the shared writer keeps, shapes, or leaves without outlines, and the units it writes. The README names the runtime service by what it now does.

* fix(fig): write text without glyphs when shaping fails

Glyphs are derived data, so a shaper error must not fail the .fig save or Figma clipboard copy that writes them. The builder now writes the layer without glyphs and logs a warning; the clipboard used to swallow the error silently, which hid a font-provider mismatch.
This commit is contained in:
Danila Poyarkov 2026-10-06 11:37:02 +00:00 committed by GitHub
parent bd7acd6e34
commit e77eeff11c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 1139 additions and 812 deletions

View file

@ -25,6 +25,7 @@
- `fractionalPosition`, `orderKeyBetween`, and `siblingOrderKeys` moved from `@open-pencil/fig/node-change` to `@open-pencil/scene-graph/order-keys`; the `@open-pencil/core` root still exports `fractionalPosition`. `orderKeyBetween` now always returns a key: when no printable key sorts between its bounds it returns a key above `lo`, which `hasOrderKeyBetween` detects, and it takes an optional suffix that `siblingOrderKeys` can request through `{ suffix }`.
- Shared rooms record the layer tree in a new format, so people on this version and on earlier versions can no longer join each other's rooms. A room this browser saved with an earlier version converts when you open it again.
- `SceneGraph.hitTestFrame` is now `hitTestDropTarget`, which returns only layers that take a drop, as in Figma: it skips groups, boolean operations, component sets, and locked layers, follows rotation, and respects clipping. The editor's `adoptNodesIntoSection` is now `adoptCoveredLayers`, which also takes a frame, and leaves locked layers out.
- `buildDerivedTextDataV4` is removed from `@open-pencil/core`, and `sceneNodeToKiwi` no longer writes glyph outlines by itself: pass the `runtime` that `withFigExportRuntime(graph, canvasKit, write)` hands its callback. In `@open-pencil/fig/node-change`, `FigNodeChangeExportRuntime` takes `shapeText(node)` instead of `getGlyphOutlineMetrics`, `buildDerivedTextData` takes the `baselines` it writes, and `convertFigmaDerivedTextGlyphs` takes the text's characters.
### Added
@ -87,6 +88,7 @@
### Fixed
- Text saved to `.fig` or copied to Figma keeps its layout in Figma: it wraps at the layer's width with its alignment and line height, and keeps ligatures and contextual forms such as Inter's arrows, where Figma previously drew every saved OpenPencil text layer on one unwrapped line (#914).
- Release a document's memory when its tab closes. Every closed tab kept its scene, canvas, and editor panels alive until reload, so memory grew with each document opened and closed. Menus and shortcuts now also follow the active document, so Undo and Redo are offered according to its history rather than the first document opened.
- Start Pi chats with OpenPencil's MCP tools when the Harness companion runs on Node 22.15 or later; the companion now installs the dependency Pi's MCP adapter needs and loads its TypeScript sources. A reopened Pi session starts fresh instead of failing to resume its in-memory sandbox, and npm output from Pi no longer mixes into the companion's protocol.
- Import HTML and CSS with the right shadow and border colors. A shadow whose color follows its lengths, as CSS usually writes it, and a `border` with a color function such as `rgb(226, 232, 240)` came in black. Every layer of a `box-shadow` list now imports, `inset` ones as inner shadows, and lengths in `%`, `em`, or `vh` are no longer read as pixels.

View file

@ -40,7 +40,7 @@ import {
getStrokeJoinEntity,
normalizeDashPattern
} from './strokes'
import { withTextParagraph } from './text'
import { textVerticalOffset, withTextParagraph } from './text'
import {
canDrawSavedText,
drawDerivedText,
@ -888,13 +888,6 @@ function hasComplexTextFill(fill?: Fill): boolean {
return fill !== undefined && fill.type !== 'SOLID'
}
export function textVerticalOffset(node: SceneNode, contentHeight: number): number {
const available = Math.max(0, node.height - contentHeight)
if (node.textAlignVertical === 'CENTER') return available / 2
if (node.textAlignVertical === 'BOTTOM') return available
return 0
}
function drawPaintedText(r: SkiaRenderer, canvas: Canvas, node: SceneNode): boolean {
if (!r.fontsLoaded || !r.fontProvider) return false
// Apply the shader directly to native glyphs: coverage layers add another rounding pass.

View file

@ -1,127 +0,0 @@
import type { Paragraph } from 'canvaskit-wasm'
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
import type { SceneNode } from '@open-pencil/scene-graph'
import { buildParagraph } from '#core/canvas/text'
import { getCanvasKit } from '#core/canvaskit'
import { fontManager } from '#core/text/fonts'
export interface ClipboardShapedGlyph {
glyphIndex: number
firstCharacter: number
x: number
y: number
advance: number
}
export interface ClipboardShapedText {
lineHeight: number
lineAscent: number
lineWidth: number
baseline: number
baselines?: NonNullable<NodeChange['derivedTextData']>['baselines']
glyphs: ClipboardShapedGlyph[]
logicalIndexToCharacterOffsetMap: number[]
}
function addShapedRunGlyphs(
run: ReturnType<Paragraph['getShapedLines']>[number]['runs'][number],
glyphs: ClipboardShapedGlyph[],
logicalIndexToCharacterOffsetMap: number[],
fallbackLineY: number,
fallbackLineWidth: number
): void {
const positions = run.positions
for (let index = 0; index < run.glyphs.length; index++) {
const x = positions[index * 2] ?? 0
const y = positions[index * 2 + 1] ?? fallbackLineY
const nextX = positions[(index + 1) * 2] ?? x
const glyphCharacter = run.offsets[index] ?? index
glyphs.push({
glyphIndex: index,
firstCharacter: glyphCharacter,
x,
y,
advance: nextX - x
})
if (glyphCharacter >= 0 && glyphCharacter < logicalIndexToCharacterOffsetMap.length) {
logicalIndexToCharacterOffsetMap[glyphCharacter] = x
}
}
const finalOffset = run.offsets[run.offsets.length - 1]
const finalX = positions[positions.length - 2] ?? fallbackLineWidth
if (finalOffset >= 0 && finalOffset < logicalIndexToCharacterOffsetMap.length) {
logicalIndexToCharacterOffsetMap[finalOffset] = finalX
}
}
function addLineBaseline(
metrics: ReturnType<Paragraph['getLineMetrics']>[number],
textLength: number,
baselines: NonNullable<ClipboardShapedText['baselines']>
): void {
if (metrics.startIndex >= textLength) return
baselines.push({
firstCharacter: metrics.startIndex,
endCharacter: metrics.endIndex,
position: { x: 0, y: metrics.baseline },
width: metrics.width,
lineY: metrics.startIndex === 0 ? 0 : metrics.baseline - Math.abs(metrics.ascent),
lineHeight: metrics.height,
lineAscent: Math.abs(metrics.ascent)
})
}
export async function shapeTextForClipboard(node: SceneNode): Promise<ClipboardShapedText | null> {
const ck = await getCanvasKit()
const fontProvider = fontManager.provider()
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()
if (shapedLines.length === 0 || lineMetrics.length === 0) {
paragraph.delete()
return null
}
const firstMetrics = lineMetrics[0]
const glyphs: ClipboardShapedGlyph[] = []
const baselines: NonNullable<ClipboardShapedText['baselines']> = []
const logicalIndexToCharacterOffsetMap = Array.from({ length: node.text.length + 1 }, () => 0)
for (let lineIndex = 0; lineIndex < shapedLines.length; lineIndex++) {
const line = shapedLines[lineIndex]
const metrics = lineMetrics[lineIndex] ?? firstMetrics
for (const run of line.runs) {
addShapedRunGlyphs(
run,
glyphs,
logicalIndexToCharacterOffsetMap,
metrics.baseline,
metrics.width
)
}
addLineBaseline(metrics, node.text.length, baselines)
}
for (let index = 1; index < logicalIndexToCharacterOffsetMap.length; index++) {
if (logicalIndexToCharacterOffsetMap[index] === 0) {
logicalIndexToCharacterOffsetMap[index] = logicalIndexToCharacterOffsetMap[index - 1]
}
}
paragraph.delete()
return {
lineHeight: firstMetrics.height,
lineAscent: Math.abs(firstMetrics.ascent),
lineWidth: firstMetrics.width,
baseline: firstMetrics.baseline,
baselines,
glyphs,
logicalIndexToCharacterOffsetMap
}
}

View file

@ -254,6 +254,14 @@ export function buildTextPicture(r: TextRenderer, node: SceneNode): Uint8Array |
return bytes ?? null
}
/** Offset that places laid-out text of `contentHeight` in the node box by vertical alignment. */
export function textVerticalOffset(node: SceneNode, contentHeight: number): number {
const available = Math.max(0, node.height - contentHeight)
if (node.textAlignVertical === 'CENTER') return available / 2
if (node.textAlignVertical === 'BOTTOM') return available
return 0
}
function resolveParagraphLayoutWidth(node: ParagraphNode, maxWidth?: number): number {
if (maxWidth !== undefined) return maxWidth
if (node.textAutoResize === 'WIDTH_AND_HEIGHT') return 1e6

View file

@ -0,0 +1,187 @@
import type { CanvasKit, Paragraph, TypefaceFontProvider } from 'canvaskit-wasm'
import {
EMPTY_EXPORT_RUNTIME,
type FigNodeChangeExportRuntime,
type ShapedText,
type ShapedTextGlyph
} from '@open-pencil/fig/node-change'
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import { buildParagraph, textVerticalOffset } from '#core/canvas/text'
import { getCanvasKit } from '#core/canvaskit'
import { transformTextCase } from '#core/text/case'
import { fontManager, weightToStyle } from '#core/text/fonts'
import { glyphOutlineSourceSync, type GlyphOutlineSource } from '#core/text/opentype'
type GlyphRun = ReturnType<Paragraph['getShapedLines']>[number]['runs'][number]
function fontStyleAt(node: SceneNode, index: number): { family: string; style: string } {
const run = node.styleRuns.find((item) => index >= item.start && index < item.start + item.length)
return {
family: run?.style.fontFamily ?? node.fontFamily,
style: weightToStyle(run?.style.fontWeight ?? node.fontWeight, run?.style.italic ?? node.italic)
}
}
/**
* The font a run was shaped with. CanvasKit leaves `GlyphRun.typeface` null, so the run's
* glyph IDs are trusted only when the style's own font covers every character of the run and
* so the paragraph had no reason to fall back to another font.
*/
function runOutlineSource(node: SceneNode, text: string, run: GlyphRun): GlyphOutlineSource | null {
if (run.fakeBold || run.fakeItalic || run.glyphs.length === 0) return null
const offsets = Array.from(run.offsets)
const start = Math.min(...offsets)
const end = Math.max(...offsets)
const { family, style } = fontStyleAt(node, start)
const source = glyphOutlineSourceSync(family, style)
const characters = text.slice(start, end).replace(/\s/g, '')
return source?.covers(characters) ? source : null
}
function hasDecoration(node: SceneNode): boolean {
return (
node.textDecoration !== 'NONE' ||
node.styleRuns.some((run) => run.style.textDecoration && run.style.textDecoration !== 'NONE')
)
}
/**
* Whether saved glyphs could stand for this text: truncation and case changes that alter its
* length move glyphs off the characters they came from, and path text is placed along its path.
*/
function canShape(node: SceneNode, text: string): boolean {
return (
node.text.length > 0 &&
node.textTruncation !== 'ENDING' &&
text.length === node.text.length &&
!node.textPathData
)
}
function shapedRunGlyphs(
run: GlyphRun,
source: GlyphOutlineSource | null,
line: { left: number; baseline: number },
characterOffsets: Array<number | undefined>
): ShapedTextGlyph[] {
const glyphs: ShapedTextGlyph[] = []
for (let index = 0; index < run.glyphs.length; index++) {
const commands = source?.outline(run.glyphs[index], run.size) ?? null
const x = run.positions[index * 2]
const nextX = run.positions[(index + 1) * 2]
const firstCharacter = run.offsets[index]
glyphs.push({
commands,
x,
// Run positions round the baseline to whole pixels; line metrics keep it exact.
y: line.baseline,
fontSize: run.size,
firstCharacter,
advance: nextX - x
})
characterOffsets[firstCharacter] ??= x - line.left
}
return glyphs
}
/** Characters inside a glyph cluster or without a glyph take the offset of the one before. */
function completeCharacterOffsets(offsets: Array<number | undefined>): number[] {
let previous = 0
return offsets.map((offset) => {
previous = offset ?? previous
return previous
})
}
/**
* Lay a text node out as the renderer draws it and pair each shaped glyph with the outline of
* the glyph ID CanvasKit chose, so ligatures and contextual forms keep their shapes. When a run's
* font is missing, variable, or a fallback, or the glyphs could not draw the text's decorations,
* no glyph gets an outline: the layout still stands, and readers draw the text themselves.
* Returns `null` for text saved glyphs cannot represent at all.
*/
export function shapeText(
ck: CanvasKit,
fontProvider: TypefaceFontProvider,
node: SceneNode
): ShapedText | null {
const text = transformTextCase(node.text, node.textCase)
if (!canShape(node, text)) return null
const paragraph = buildParagraph({ ck, fontProvider, fontsLoaded: true }, node, undefined, {
halfLeading: true
})
try {
const lines = paragraph.getShapedLines()
const metrics = paragraph.getLineMetrics()
if (lines.length === 0 || lines.length !== metrics.length) return null
const offsetY = textVerticalOffset(node, paragraph.getHeight())
const glyphs: ShapedTextGlyph[] = []
const characterOffsets: Array<number | undefined> = Array.from({ length: text.length })
for (const [lineIndex, line] of lines.entries()) {
for (const run of line.runs) {
glyphs.push(
...shapedRunGlyphs(
run,
runOutlineSource(node, text, run),
{ left: metrics[lineIndex].left, baseline: metrics[lineIndex].baseline + offsetY },
characterOffsets
)
)
}
}
// Outlines from some runs only would draw the text with characters missing, and saved
// glyphs draw decorations on one baseline.
const outlined =
glyphs.every((glyph) => glyph.commands) && !(lines.length > 1 && hasDecoration(node))
return {
glyphs: outlined ? glyphs : glyphs.map((glyph) => ({ ...glyph, commands: null })),
baselines: metrics.map((line) => ({
firstCharacter: line.startIndex,
endCharacter: Math.min(line.endIncludingNewline, text.length),
position: { x: line.left, y: line.baseline + offsetY },
width: line.width,
lineY: line.baseline - line.ascent + offsetY,
lineHeight: line.height,
lineAscent: line.ascent
})),
logicalIndexToCharacterOffsetMap: completeCharacterOffsets(characterOffsets)
}
} finally {
paragraph.delete()
}
}
function needsShaping(graph: SceneGraph): boolean {
for (const node of graph.nodes.values()) {
if (node.type === 'TEXT' && node.text && !node.derivedTextGlyphs?.length) return true
}
return false
}
/**
* Run a `.fig` or Figma clipboard write with text shaping. Unless the attached font provider
* belongs to this CanvasKit, the loaded fonts are registered in one that lives for this write only.
*/
export async function withFigExportRuntime<T>(
graph: SceneGraph,
ck: CanvasKit | undefined,
write: (runtime: FigNodeChangeExportRuntime) => Promise<T>
): Promise<T> {
if (!needsShaping(graph)) return write(EMPTY_EXPORT_RUNTIME)
const canvasKit = ck ?? (await getCanvasKit())
const reusable = fontManager.providerFor(canvasKit)
const provider = reusable ?? canvasKit.TypefaceFontProvider.Make()
if (!reusable) fontManager.attachProvider(canvasKit, provider)
try {
return await write({ shapeText: (node) => shapeText(canvasKit, provider, node) })
} finally {
if (!reusable) {
fontManager.detachProvider(provider)
provider.delete()
}
}
}

View file

@ -1,7 +1,7 @@
import { embedClipboardImages, encodeFigmaClipboard } from '@open-pencil/fig/clipboard'
import { randomInt } from '@open-pencil/scene-graph/random'
export { parseFigmaClipboard, figmaNodesBounds } from '@open-pencil/fig/clipboard'
import { placeSlotContent } from '@open-pencil/fig/node-change'
import { placeSlotContent, type FigNodeChangeExportRuntime } from '@open-pencil/fig/node-change'
import { initCodec } from '@open-pencil/kiwi/fig/codec'
import type { GUID, NodeChange as KiwiNodeChange } from '@open-pencil/kiwi/fig/codec'
import { ownsSlotContent, type SceneGraph, type SceneNode } from '@open-pencil/scene-graph'
@ -13,7 +13,7 @@ import {
assignVariableGuids
} from '#core/io/formats/fig/variable-export'
import { shapeTextForClipboard } from './canvas/text/clipboard'
import { withFigExportRuntime } from './canvas/text/shape'
import { prepareClipboardImport } from './clipboard/fig-import'
import {
sceneNodeToKiwi,
@ -21,7 +21,6 @@ import {
makeCanvasNodeChange,
buildFontDigestMap
} from './kiwi/fig/node-change/serialize'
import { buildDerivedTextDataV4 } from './text/derived-text/clipboard'
export async function prefetchFigmaSchema(): Promise<void> {
await initCodec()
@ -47,9 +46,19 @@ export function importClipboardNodes(
return operation.plan.rootIds
}
export async function buildFigmaClipboardHTML(
export function buildFigmaClipboardHTML(
nodes: SceneNode[],
graph: SceneGraph
): Promise<string | null> {
return withFigExportRuntime(graph, undefined, (runtime) =>
writeFigmaClipboardHTML(nodes, graph, runtime)
)
}
async function writeFigmaClipboardHTML(
nodes: SceneNode[],
graph: SceneGraph,
runtime: FigNodeChangeExportRuntime
): Promise<string | null> {
const fontDigestMap = await buildFontDigestMap(graph)
@ -93,7 +102,8 @@ export async function buildFigmaClipboardHTML(
varIdToGuid: variableIds,
assignedGuidValues,
modeIdToGuid: modeIds,
slotContentRecords
slotContentRecords,
runtime
})
)
}
@ -141,7 +151,8 @@ export async function buildFigmaClipboardHTML(
varIdToGuid: variableIds,
assignedGuidValues,
modeIdToGuid: modeIds,
slotContentRecords
slotContentRecords,
runtime
})
)
}
@ -162,21 +173,17 @@ export async function buildFigmaClipboardHTML(
const node = textById.get(id)
if (node) sourceByGuid.set(`${guid.sessionID}:${guid.localID}`, node)
}
await Promise.all(
nodeChanges.map(async (change) => {
if (change.type !== 'TEXT' || !change.guid) return
const source = sourceByGuid.get(`${change.guid.sessionID}:${change.guid.localID}`)
if (!source) return
change.textAutoResize = 'NONE'
change.textUserLayoutVersion = 5
change.lineHeight = {
value: source.lineHeight ?? 100,
units: source.lineHeight ? 'PIXELS' : 'PERCENT'
}
const shaped = await shapeTextForClipboard(source).catch(() => null)
change.derivedTextData = await buildDerivedTextDataV4(source, fontDigestMap, shaped, blobs)
})
)
for (const change of nodeChanges) {
if (change.type !== 'TEXT' || !change.guid) continue
const source = sourceByGuid.get(`${change.guid.sessionID}:${change.guid.localID}`)
if (!source) continue
change.textAutoResize = 'NONE'
change.textUserLayoutVersion = 5
change.lineHeight = {
value: source.lineHeight ?? 100,
units: source.lineHeight ? 'PIXELS' : 'PERCENT'
}
}
await embedClipboardImages(nodeChanges, blobs, graph.images)
return encodeFigmaClipboard(nodeChanges, blobs, randomInt())
}

View file

@ -301,7 +301,7 @@ export {
mapToFigmaType
} from './kiwi/fig/node-change/serialize'
export { fractionalPosition } from '@open-pencil/scene-graph/order-keys'
export { buildDerivedTextDataV4 } from './text/derived-text/clipboard'
export { withFigExportRuntime } from './canvas/text/shape'
export { renderJSX, renderTree } from './design-jsx'
export {

View file

@ -9,7 +9,8 @@ import {
placeSlotContent,
exportCanvasGuides,
importCanvasGuides,
stringToGuid
stringToGuid,
type FigNodeChangeExportRuntime
} from '@open-pencil/fig/node-change'
import { initCodec, getCompiledSchema, getSchemaBytes } from '@open-pencil/kiwi/fig/codec'
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
@ -19,6 +20,7 @@ import { fractionalPosition } from '@open-pencil/scene-graph/order-keys'
import type { GUID } from '@open-pencil/scene-graph/primitives'
import type { SkiaRenderer } from '#core/canvas'
import { withFigExportRuntime } from '#core/canvas/text/shape'
import { CANVAS_BG_COLOR, IS_BROWSER, IS_TAURI } from '#core/constants'
import { applyEnabledLibrariesPluginData } from '#core/io/formats/fig/library-metadata'
import { findFigThumbnailPageId } from '#core/io/formats/fig/thumbnail-page'
@ -243,6 +245,7 @@ interface InternalResourceContext {
assignedGuidValues: Set<string>
componentPropertyDefinitionsById: ReturnType<typeof buildComponentPropIndex>
propertyIdToGuid: Map<string, GUID>
runtime: FigNodeChangeExportRuntime
}
/**
@ -288,7 +291,8 @@ function appendInternalResources(context: InternalResourceContext): void {
assignedGuidValues: context.assignedGuidValues,
componentPropertyDefinitionsById: context.componentPropertyDefinitionsById,
modeIdToGuid: context.modeIdToGuid,
propertyIdToGuid: context.propertyIdToGuid
propertyIdToGuid: context.propertyIdToGuid,
runtime: context.runtime
}
)
)
@ -314,6 +318,19 @@ export async function exportFigFile(
): Promise<Uint8Array> {
const originalArchive = await originalFigArchive(sourceGraph)
if (originalArchive) return originalArchive.slice()
return withFigExportRuntime(sourceGraph, ck, (runtime) =>
writeFigFile(sourceGraph, runtime, ck, renderer, pageId, renderHeadlessThumbnail)
)
}
async function writeFigFile(
sourceGraph: SceneGraph,
runtime: FigNodeChangeExportRuntime,
ck: CanvasKit | undefined,
renderer: SkiaRenderer | undefined,
pageId: string | undefined,
renderHeadlessThumbnail: boolean
): Promise<Uint8Array> {
const graph = cloneSceneGraphForFigExport(sourceGraph)
populateReaderExport(sourceGraph, graph)
await initCodec()
@ -428,7 +445,8 @@ export async function exportFigFile(
blobIndexByHex,
assignedGuidValues,
componentPropertyDefinitionsById,
propertyIdToGuid
propertyIdToGuid,
runtime
})
const orderedCanvasEntries = [
@ -453,7 +471,8 @@ export async function exportFigFile(
componentPropertyDefinitionsById,
modeIdToGuid,
propertyIdToGuid,
slotContentRecords
slotContentRecords,
runtime
})
)
}

View file

@ -1,13 +1,3 @@
import {
sceneNodeToKiwi as sceneNodeToKiwiWithRuntime,
type KiwiNodeChange,
type SceneNodeToKiwiOptions
} from '@open-pencil/fig/node-change'
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import type { GUID } from '@open-pencil/scene-graph/primitives'
import { getGlyphOutlineMetricsSync } from '#core/text/opentype'
export {
buildFigKiwi,
decompressFigKiwiDataAsync,
@ -16,25 +6,9 @@ export {
makeDocumentNodeChange,
mapToFigmaType,
parseFigKiwiChunks,
safeColor
safeColor,
sceneNodeToKiwi,
type KiwiNodeChange,
type SceneNodeToKiwiOptions
} from '@open-pencil/fig/node-change'
export { buildFontDigestMap } from './font/digests'
const coreFigExportRuntime = {
getGlyphOutlineMetrics: getGlyphOutlineMetricsSync
}
export function sceneNodeToKiwi(
node: SceneNode,
parentGuid: GUID,
childIndex: number,
localIdCounter: { value: number },
graph: SceneGraph,
blobs: Uint8Array[],
options: SceneNodeToKiwiOptions = {}
): KiwiNodeChange[] {
return sceneNodeToKiwiWithRuntime(node, parentGuid, childIndex, localIdCounter, graph, blobs, {
...options,
runtime: options.runtime ?? coreFigExportRuntime
})
}

View file

@ -1,233 +0,0 @@
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext'
import {
buildDerivedTextData,
encodePathCommandsBlob,
weightToFigmaStyle
} from '@open-pencil/fig/node-change'
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
import { normalizeFontFamily, weightToStyle } from '@open-pencil/scene-graph'
import type { SceneNode } from '@open-pencil/scene-graph'
import { type GlyphOutlineMetrics, getGlyphOutlineMetricsSync } from '#core/text/opentype'
function computeWordWrapBreaks(
text: string,
glyphMetrics: Array<Pick<GlyphOutlineMetrics, 'advance'>>,
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: Array<Pick<GlyphOutlineMetrics, 'advance'>>,
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 + 0.5) {
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
lineWidth: number
baseline: number
baselines?: NonNullable<NodeChange['derivedTextData']>['baselines']
glyphs: Array<{
firstCharacter: number
x: number
y: number
advance: number
}>
logicalIndexToCharacterOffsetMap: number[]
}
type DerivedGlyphMetric = { advance: number; commands: GlyphOutlineMetrics['commands'] }
function buildTextGlyphs(
text: string,
glyphMetrics: GlyphOutlineMetrics[],
fallbackAdvance: number,
fontSize: number
): DerivedGlyphMetric[] {
if (glyphMetrics.length > 0) {
return glyphMetrics.map((glyph) => ({
advance: glyph.advance || fallbackAdvance,
commands: glyph.commands
}))
}
return Array.from({ length: text.length }, () => ({
advance: fallbackAdvance || fontSize * 0.6,
commands: []
}))
}
function computeLineBreaks(
node: SceneNode,
glyphMetrics: GlyphOutlineMetrics[],
textGlyphs: DerivedGlyphMetric[],
fallbackAdvance: number,
shaped?: ShapedClipboardText | null
): number[] {
if (shaped) return []
if (glyphMetrics.length > 0) {
return computeWordWrapBreaks(
node.text,
textGlyphs,
fallbackAdvance,
node.width,
node.fontSize,
node.fontFamily
)
}
return computeFallbackBreaks(node.text, textGlyphs, fallbackAdvance, node.width)
}
export async function buildDerivedTextDataV4(
node: SceneNode,
digestMap: Map<string, Uint8Array>,
shaped?: ShapedClipboardText | null,
blobs?: Uint8Array[]
): Promise<NodeChange['derivedTextData']> {
const style = weightToStyle(node.fontWeight, node.italic)
const normalizedFamily = normalizeFontFamily(node.fontFamily)
const key = `${normalizedFamily}|${style}`
const lineHeightFallback = node.lineHeight ?? Math.ceil(node.fontSize * 1.2)
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 textGlyphs = buildTextGlyphs(node.text, glyphMetrics, fallbackAdvance, node.fontSize)
const lineAscent = Math.max(lineHeightFallback - node.fontSize * 0.2, 0)
const lineBreaks = computeLineBreaks(node, glyphMetrics, textGlyphs, fallbackAdvance, shaped)
const lineBreakSet = new Set(lineBreaks)
const shapedByChar = new Map<
number,
(typeof shaped extends null | undefined ? never : NonNullable<typeof shaped>)['glyphs'][number]
>()
if (shaped) {
for (const g of shaped.glyphs) shapedByChar.set(g.firstCharacter, g)
}
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 glyphs = textGlyphs.map((glyph, index) => {
const shapedGlyph = shapedByChar.get(index)
const fallbackGlyphAdvance = glyph.advance || fallbackAdvance
if (!shapedGlyph && lineBreakSet.has(index)) {
fallbackBaselines.push({
firstCharacter: lineStart,
endCharacter: index,
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 && glyph.commands.length > 0
? blobs.push(encodePathCommandsBlob(glyph.commands, node.fontSize)) - 1
: undefined
return {
commandsBlob,
position: {
x: shapedGlyph?.x ?? glyphX,
y: shapedGlyph?.y ?? shaped?.baseline ?? fallbackY
},
fontSize: node.fontSize,
firstCharacter: shapedGlyph?.firstCharacter ?? index,
advance: (shapedGlyph?.advance ?? fallbackGlyphAdvance) / node.fontSize,
rotation: 0
}
})
fallbackOffsets[node.text.length] = fallbackX
if (node.text.length > 0) {
fallbackBaselines.push({
firstCharacter: lineStart,
endCharacter: node.text.length,
position: { x: 0, y: fallbackY },
width: fallbackX,
lineHeight: lineHeightFallback,
lineAscent
})
}
return buildDerivedTextData({
node,
glyphs,
fontMetaData: [
{
key: {
family: normalizedFamily,
style: weightToFigmaStyle(node.fontWeight, node.italic),
postscript: ''
},
fontLineHeight: 1.2,
fontDigest: digestMap.get(key),
fontStyle: node.italic ? 'ITALIC' : 'NORMAL',
fontWeight: node.fontWeight
}
],
baseline: shaped?.baseline ?? lineHeightFallback,
width: shaped?.lineWidth ?? node.width,
lineHeight: shaped?.lineHeight ?? lineHeightFallback,
lineAscent: shaped?.lineAscent ?? lineAscent,
baselines: shaped ? shaped.baselines : fallbackBaselines,
logicalIndexToCharacterOffsetMap: shaped?.logicalIndexToCharacterOffsetMap ?? fallbackOffsets
})
}

View file

@ -53,6 +53,7 @@ export class FontManager {
private blockedNodeIds = new Set<string>()
private fontProvider: TypefaceFontProvider | null = null
private fontProviders = new Set<TypefaceFontProvider>()
private providerCanvasKits = new WeakMap<TypefaceFontProvider, CanvasKit>()
private registrationGeneration = 0
private providerRegistrations = new WeakMap<TypefaceFontProvider, Map<string, Set<ArrayBuffer>>>()
private localFonts: FontInfo[] | null = null
@ -66,8 +67,9 @@ export class FontManager {
private arabicFallbackFamilies: string[] = []
private arabicFallbackPromise: Promise<string[]> | null = null
attachProvider(_canvasKit: CanvasKit, provider: TypefaceFontProvider): void {
attachProvider(canvasKit: CanvasKit, provider: TypefaceFontProvider): void {
this.fontProviders.add(provider)
this.providerCanvasKits.set(provider, canvasKit)
this.fontProvider = provider
this.providerRegistrations.set(provider, new Map())
this.registrationGeneration++
@ -99,6 +101,15 @@ export class FontManager {
return this.fontProvider
}
/** The latest attached provider made by `canvasKit`; another build's provider cannot shape with it. */
providerFor(canvasKit: CanvasKit): TypefaceFontProvider | null {
return (
Array.from(this.fontProviders)
.reverse()
.find((provider) => this.providerCanvasKits.get(provider) === canvasKit) ?? null
)
}
generation(): number {
return this.registrationGeneration
}

View file

@ -26,7 +26,8 @@ interface OutlineFont {
unitsPerEm: number
ascender: number
descender: number
tables: { os2?: { sTypoLineGap?: number } }
tables: { os2?: { sTypoLineGap?: number }; fvar?: unknown }
glyphs: { get(index: number): OutlineGlyph | undefined }
charToGlyphIndex(char: string): number
charToGlyph(char: string): OutlineGlyph
forEachGlyph(
@ -181,6 +182,28 @@ export function getGlyphOutlineMetricsSync(
}
}
/** Outlines of one loaded static font, addressed by the glyph IDs a shaper produced. */
export interface GlyphOutlineSource {
/** Whether the font has a glyph for every code point, so a shaper used it rather than a fallback. */
covers(text: string): boolean
outline(glyphId: number, fontSize: number): OutlineCommand[] | null
}
/**
* Glyph outlines by glyph ID for a loaded font, or `null` when it is not loaded, cannot be
* parsed, or is variable: opentype.js draws a variable font's default instance, not the
* instance a shaper used.
*/
export function glyphOutlineSourceSync(family: string, style: string): GlyphOutlineSource | null {
const font = getParsedFont(family, style)
if (!font || font.tables.fvar) return null
return {
covers: (text) => Array.from(text).every((char) => font.charToGlyphIndex(char) !== 0),
outline: (glyphId, fontSize) =>
font.glyphs.get(glyphId)?.getPath(0, 0, fontSize).commands ?? null
}
}
export async function probeGlyphOutlineCommands(
family: string,
style: string,

View file

@ -0,0 +1,109 @@
import { beforeAll, describe, expect, test } from 'bun:test'
import { expectDefined } from '#core-tests/helpers/assert'
import { withFigExportRuntime } from '#core/canvas/text/shape'
import { getCanvasKit } from '#core/canvaskit'
import { fontManager } from '#core/text/fonts'
import { getGlyphOutlineMetricsSync } from '#core/text/opentype'
import type { ShapedText } from '@open-pencil/fig/node-change'
import { SceneGraph } from '@open-pencil/scene-graph'
import type { SceneNode } from '@open-pencil/scene-graph'
const SENTENCE = 'A sentence long enough that it has to wrap onto a second line.'
async function shape(props: Partial<SceneNode>): Promise<ShapedText | null> {
const graph = new SceneGraph()
const node = graph.createNode('TEXT', graph.getPages()[0].id, {
fontFamily: 'Inter',
fontSize: 13,
lineHeight: 16,
width: 230,
height: 32,
textAutoResize: 'HEIGHT',
...props
})
return withFigExportRuntime(graph, await getCanvasKit(), async (runtime) =>
runtime.shapeText(node)
)
}
describe('text shaping for saved glyphs', () => {
beforeAll(async () => {
const inter = expectDefined(
await fontManager.fetchBundledFont('/Inter-Regular.ttf'),
'bundled Inter font'
)
fontManager.markLoaded('Inter', 'Regular', inter)
})
test('breaks lines at the layer width (#914)', async () => {
const shaped = expectDefined(await shape({ text: SENTENCE }), 'shaped text')
const [first, second] = shaped.baselines
expect(shaped.baselines).toHaveLength(2)
expect(second.firstCharacter).toBe(first.endCharacter)
expect(second.position.y - first.position.y).toBe(16)
for (const glyph of shaped.glyphs) {
expect(glyph.x + glyph.advance).toBeLessThanOrEqual(230.5)
expect([first.position.y, second.position.y]).toContain(glyph.y)
}
expect(shaped.logicalIndexToCharacterOffsetMap).toHaveLength(SENTENCE.length)
expect(shaped.logicalIndexToCharacterOffsetMap[second.firstCharacter]).toBe(0)
})
test('places lines by horizontal and vertical alignment', async () => {
const top = expectDefined(await shape({ text: 'Centered', height: 64 }), 'top')
const centered = expectDefined(
await shape({
text: 'Centered',
height: 64,
textAlignHorizontal: 'CENTER',
textAlignVertical: 'CENTER'
}),
'centered'
)
const line = centered.baselines[0]
expect(line.position.x).toBeCloseTo((230 - line.width) / 2, 1)
expect(centered.glyphs[0].x).toBeCloseTo(line.position.x, 3)
expect(line.position.y - top.baselines[0].position.y).toBe(24)
})
test('draws each glyph the shaper chose, such as a contextual arrow', async () => {
const shaped = expectDefined(await shape({ text: 'a->b' }), 'shaped text')
const arrow = expectDefined(
shaped.glyphs.find((glyph) => glyph.firstCharacter === 1),
'arrow glyph'
)
const hyphen = expectDefined(getGlyphOutlineMetricsSync('Inter', 'Regular', '-', 13)?.[0])
expect(shaped.glyphs).toHaveLength(3)
expect(arrow.commands).not.toEqual(hyphen.commands)
expect(shaped.logicalIndexToCharacterOffsetMap[2]).toBe(arrow.x)
})
test('keeps the layout but no outlines when the font is not loaded', async () => {
const shaped = expectDefined(
await shape({ text: SENTENCE, fontFamily: 'Missing Font' }),
'shaped text'
)
expect(shaped.baselines.length).toBeGreaterThan(1)
expect(shaped.glyphs.every((glyph) => glyph.commands === null)).toBe(true)
})
test('keeps the layout but no outlines for decorations across lines', async () => {
const shaped = expectDefined(
await shape({ text: SENTENCE, textDecoration: 'UNDERLINE' }),
'shaped text'
)
expect(shaped.baselines).toHaveLength(2)
expect(shaped.glyphs.every((glyph) => glyph.commands === null)).toBe(true)
})
test('does not shape truncated text', async () => {
expect(await shape({ text: SENTENCE, textTruncation: 'ENDING' })).toBeNull()
})
})

View file

@ -6,6 +6,7 @@ import { exportFigFile, initCodec, parseFigFile, SceneGraph } from '@open-pencil
import { fontManager } from '@open-pencil/core/text'
import { parseFigBuffer } from '@open-pencil/fig'
import { expectDefined } from '#core-tests/helpers/assert'
import { HEAVY_TEST_TIMEOUT_MS } from '#core-tests/helpers/test-utils'
import { FIXTURES } from '#core-tests/helpers/fig/fixtures'
@ -98,4 +99,48 @@ describe('roundtrip: text glyph blobs', () => {
expect(output.glyphsWithBlob).toBe(500)
expect(output.uniqueGlyphBlobs).toBeLessThanOrEqual(4)
})
test('reopens wrapped text on the lines it was drawn on (#914)', async () => {
loadInterFonts()
const graph = new SceneGraph()
const page = graph.getPages()[0]
const card = graph.createNode('FRAME', page.id, {
name: 'Card',
width: 260,
height: 52,
layoutMode: 'VERTICAL',
paddingTop: 10,
paddingRight: 10,
paddingBottom: 10,
paddingLeft: 10
})
graph.createNode('TEXT', card.id, {
name: 'Message',
text: 'A sentence long enough that it has to wrap onto a second line.',
width: 230,
height: 32,
fontFamily: 'Inter',
fontWeight: 400,
fontSize: 13,
lineHeight: 16,
textAutoResize: 'HEIGHT'
})
const reopenedLines = async (bytes: Uint8Array) => {
const reopened = await parseFigFile(new Uint8Array(bytes).buffer)
const message = expectDefined(
[...reopened.nodes.values()].find((node) => node.name === 'Message'),
'Message'
)
const glyphs = message.derivedTextGlyphs ?? []
expect(glyphs.length).toBeGreaterThan(0)
for (const glyph of glyphs) expect(glyph.x).toBeLessThan(230)
return { reopened, lines: new Set(glyphs.map((glyph) => glyph.y)).size }
}
const first = await reopenedLines(await exportFigFile(graph))
expect(first.lines).toBe(2)
expect((await reopenedLines(await exportFigFile(first.reopened))).lines).toBe(2)
})
})

View file

@ -16,7 +16,7 @@ Current ownership:
- NodeChange-to-SceneGraph property conversion, including styles, plugin metadata, text, paint, vector, and font policy, through `@open-pencil/fig/node-change`
- Component-property, symbol-override, derived-symbol-data, and instance synchronization policy through `@open-pencil/fig/instance-overrides`
- Effective raw-metadata precedence and invalidation over SceneGraph's format-neutral edited-field tracking
- SceneGraph-to-`NodeChange` export conversion with an explicit glyph-outline runtime service
- SceneGraph-to-`NodeChange` export conversion with an explicit text-shaping runtime service for saved glyphs
- Package-local archive, conversion, instance, export, and dist smoke tests
## Architecture documentation

View file

@ -87,6 +87,26 @@ Writing 50 x 20 into both places can apply the scale twice.
This example describes root dimensions only. Padding and other fields require their own
coordinate-space contracts; do not apply the same division to every numeric property.
## Derived text
Figma draws saved text from `derivedTextData`, even when it has the font, so the glyphs written
here are what Figma shows. [`buildNodeDerivedTextData`](../src/node-change/derived-text/build.ts)
writes them for `.fig` export and the Figma clipboard alike:
- Glyphs a layer already has (from Figma, or placed along a path) are kept, with their lines
recovered from their baselines.
- Other text is shaped by the export runtime's `shapeText`, which Core implements with the
renderer's paragraph: lines, alignment, and baselines match what OpenPencil draws, and each
outline belongs to the glyph ID the shaper chose, so ligatures and contextual forms survive.
- When the shaper cannot tell which font drew a run (a missing, variable, or fallback font), the
layout is written without outlines and readers lay the text out themselves. A partial set of
outlines would draw the text with gaps, so it is all or none.
- Advances are em units, and `logicalIndexToCharacterOffsetMap` has one entry per character,
measured from the start of its line, as in Figma's own files.
The reader drops glyphs an earlier writer laid on one unwrapped line, recognised by an offset
map one entry longer than the text with every glyph on the one written baseline.
## Component properties and parameters
Current Figma records use typed `varValue` data for definitions and assignments, and `PROP_REF`
@ -110,6 +130,7 @@ selected passing properties as complete editable-document compatibility.
- [Node-change serialization](../src/node-change/export/node.ts)
- [Override claims](../src/node-change/export/override-claims.ts)
- [Derived text](../src/node-change/derived-text/build.ts)
- [Export context and identity allocation](../src/node-change/export/context.ts)
- [Property/layout conversion](../src/node-change/serialize.ts)
- [Archive assembly](../src/archive.ts)

View file

@ -372,13 +372,22 @@ function convertTextProps(nc: NodeChange, blobs: Uint8Array[]): TextProps {
fontFeatures: convertFontFeatures(nc),
textTruncation: (nc.textTruncation as string) === 'ENDING' ? 'ENDING' : 'DISABLED',
textDirection: readNodeChangePluginData(nc, OPEN_PENCIL_PLUGIN_DATA.textDirection) ?? 'AUTO',
derivedLayout: nc.derivedTextData?.layoutSize
? {
width: nc.derivedTextData.layoutSize.x,
height: nc.derivedTextData.layoutSize.y
}
: null,
derivedTextGlyphs: convertFigmaDerivedTextGlyphs(nc.derivedTextData, blobs)
...convertDerivedText(nc, blobs)
}
}
function convertDerivedText(
nc: NodeChange,
blobs: Uint8Array[]
): Pick<SceneNode, 'derivedLayout' | 'derivedTextGlyphs'> {
const layoutSize = nc.derivedTextData?.layoutSize
return {
derivedLayout: layoutSize ? { width: layoutSize.x, height: layoutSize.y } : null,
derivedTextGlyphs: convertFigmaDerivedTextGlyphs(
nc.derivedTextData,
blobs,
nc.textData?.characters ?? ''
)
}
}

View file

@ -0,0 +1,219 @@
import { groupBy } from 'es-toolkit/array'
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
import { normalizeFontFamily, weightToStyle } from '@open-pencil/scene-graph'
import type { DerivedTextGlyph, SceneNode } from '@open-pencil/scene-graph'
import { bytesToHex } from '../bytes'
import { weightToFigmaStyle } from '../font/style'
import { bakeGlyphScale, encodePathCommandsBlob, type OutlineCommand } from '../path/commands'
import { buildDerivedTextData } from './data'
type DerivedTextData = NonNullable<NodeChange['derivedTextData']>
type DerivedTextBaseline = NonNullable<DerivedTextData['baselines']>[number]
type DerivedTextGlyphRecord = NonNullable<DerivedTextData['glyphs']>[number]
/**
* One shaped glyph in node space: `x`/`y` sit on its baseline, and `commands` are its outline in
* pixels at `fontSize`, or `null` when the shaper could not tell which font drew it.
*/
export interface ShapedTextGlyph {
commands: OutlineCommand[] | null
x: number
y: number
fontSize: number
firstCharacter: number
/** Horizontal advance in pixels. */
advance: number
}
/** Text laid out at the node's width, as the renderer draws it. */
export interface ShapedText {
glyphs: ShapedTextGlyph[]
baselines: DerivedTextBaseline[]
/** One entry per UTF-16 character: its x offset from the start of its line. */
logicalIndexToCharacterOffsetMap: number[]
}
export interface DerivedTextBuildContext {
digestMap: Map<string, Uint8Array>
blobs: Uint8Array[]
glyphBlobMap: Map<string, number>
shapeText(node: SceneNode): ShapedText | null
}
// Figma stores a whitespace glyph as a lone close command.
const EMPTY_GLYPH_COMMANDS: OutlineCommand[] = [{ type: 'Z' }]
function appendGlyphBlob(context: DerivedTextBuildContext, blob: Uint8Array): number {
const key = bytesToHex(blob)
const existing = context.glyphBlobMap.get(key)
if (existing !== undefined) return existing
const index = context.blobs.push(blob) - 1
context.glyphBlobMap.set(key, index)
return index
}
function fontMetaData(
node: SceneNode,
digestMap: Map<string, Uint8Array>
): DerivedTextData['fontMetaData'] {
const fonts: NonNullable<DerivedTextData['fontMetaData']> = []
const seen = new Set<string>()
const addFont = (family: string, weight: number, italic: boolean) => {
const normalized = normalizeFontFamily(family)
const key = `${normalized}|${weightToStyle(weight, italic)}`
if (seen.has(key)) return
seen.add(key)
fonts.push({
key: { family: normalized, style: weightToFigmaStyle(weight, italic), postscript: '' },
fontLineHeight: 1.2,
fontDigest: digestMap.get(key),
fontStyle: italic ? 'ITALIC' : 'NORMAL',
fontWeight: weight
})
}
addFont(node.fontFamily, node.fontWeight, node.italic)
for (const run of node.styleRuns) {
addFont(
run.style.fontFamily ?? node.fontFamily,
run.style.fontWeight ?? node.fontWeight,
run.style.italic ?? node.italic
)
}
return fonts
}
function shapedGlyphRecords(
shaped: ShapedText,
context: DerivedTextBuildContext
): DerivedTextGlyphRecord[] {
return shaped.glyphs.map((glyph) => ({
commandsBlob: glyph.commands
? appendGlyphBlob(
context,
encodePathCommandsBlob(
glyph.commands.length > 0 ? glyph.commands : EMPTY_GLYPH_COMMANDS,
glyph.fontSize
)
)
: undefined,
position: { x: glyph.x, y: glyph.y },
fontSize: glyph.fontSize,
firstCharacter: glyph.firstCharacter,
advance: glyph.fontSize > 0 ? glyph.advance / glyph.fontSize : 0,
rotation: 0
}))
}
interface SavedGlyph {
glyph: DerivedTextGlyph
character: number
}
/**
* Lines of glyphs the node already has, recovered from their baselines, top to bottom. Path
* text has no lines, so its glyphs share the one baseline the format requires.
*/
function savedGlyphLines(glyphs: SavedGlyph[]): SavedGlyph[][] {
if (glyphs.some(({ glyph }) => (glyph.rotation ?? 0) !== 0)) return [glyphs]
return Object.values(groupBy(glyphs, ({ glyph }) => String(glyph.y))).sort(
(a, b) => a[0].glyph.y - b[0].glyph.y
)
}
function savedGlyphLayout(
node: SceneNode,
glyphs: DerivedTextGlyph[]
): Pick<ShapedText, 'baselines' | 'logicalIndexToCharacterOffsetMap'> {
const lineHeight = node.lineHeight ?? Math.ceil(node.fontSize * 1.2)
const offsets = Array.from({ length: node.text.length }, () => 0)
const lines = savedGlyphLines(
glyphs.map((glyph, index) => ({ glyph, character: glyph.firstCharacter ?? index }))
)
const baselines = lines.map((line, lineIndex) => {
const startX = Math.min(...line.map(({ glyph }) => glyph.x))
for (const { glyph, character } of line) {
if (character < offsets.length) offsets[character] = glyph.x - startX
}
return {
firstCharacter: line[0].character,
endCharacter: lines[lineIndex + 1]?.[0].character ?? node.text.length,
position: { x: startX, y: line[0].glyph.y },
width: node.width,
lineY: lineIndex * lineHeight,
lineHeight,
lineAscent: Math.max(lineHeight - node.fontSize * 0.2, 0)
}
})
return { baselines, logicalIndexToCharacterOffsetMap: offsets }
}
function savedGlyphRecords(
glyphs: DerivedTextGlyph[],
context: DerivedTextBuildContext
): DerivedTextGlyphRecord[] {
return glyphs.map((glyph, index) => {
const next = glyphs[index + 1]
const sameLine =
index + 1 < glyphs.length &&
next.y === glyph.y &&
(next.rotation ?? 0) === (glyph.rotation ?? 0)
return {
commandsBlob: appendGlyphBlob(
context,
bakeGlyphScale(
glyph.commandsBlob,
glyph.scaleX ?? 1,
glyph.scaleY ?? 1,
glyph.rotation ?? 0
)
),
position: { x: glyph.x, y: glyph.y },
fontSize: glyph.fontSize,
firstCharacter: glyph.firstCharacter ?? index,
advance: sameLine && glyph.fontSize > 0 ? Math.max(next.x - glyph.x, 0) / glyph.fontSize : 0,
// Preserve path-text radians; hardcoding 0 used to flatten circular text on re-export.
rotation: glyph.rotation ?? 0
}
})
}
/** Glyphs are derived data: a shaper failure must not fail the save or copy that writes them. */
function shapeOrNull(node: SceneNode, context: DerivedTextBuildContext): ShapedText | null {
try {
return context.shapeText(node)
} catch (error) {
console.warn(`Writing "${node.name}" without glyphs; text shaping failed:`, error)
return null
}
}
/**
* The `derivedTextData` a text node is written with. Glyphs the node already has (from Figma,
* or placed along a path) are kept; other text is shaped at the node's width. Text that cannot
* be shaped gets no glyphs, so readers lay it out themselves instead of drawing a guess.
*/
export function buildNodeDerivedTextData(
node: SceneNode,
context: DerivedTextBuildContext
): NodeChange['derivedTextData'] {
const fonts = fontMetaData(node, context.digestMap)
const saved = node.derivedTextGlyphs ?? []
if (saved.length > 0) {
return buildDerivedTextData({
node,
glyphs: savedGlyphRecords(saved, context),
fontMetaData: fonts,
...savedGlyphLayout(node, saved)
})
}
const shaped = node.textPathData ? null : shapeOrNull(node, context)
return buildDerivedTextData({
node,
glyphs: shaped ? shapedGlyphRecords(shaped, context) : [],
fontMetaData: fonts,
baselines: shaped?.baselines ?? [],
logicalIndexToCharacterOffsetMap: shaped?.logicalIndexToCharacterOffsetMap ?? []
})
}

View file

@ -1,15 +1,13 @@
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
import type { SceneNode } from '@open-pencil/scene-graph'
type DerivedTextData = NonNullable<NodeChange['derivedTextData']>
interface DerivedTextDataOptions {
node: SceneNode
glyphs: NonNullable<NodeChange['derivedTextData']>['glyphs']
fontMetaData: NonNullable<NodeChange['derivedTextData']>['fontMetaData']
baseline: number
width: number
lineHeight: number
lineAscent: number
baselines?: NonNullable<NodeChange['derivedTextData']>['baselines']
glyphs: DerivedTextData['glyphs']
fontMetaData: DerivedTextData['fontMetaData']
baselines: DerivedTextData['baselines']
logicalIndexToCharacterOffsetMap: number[]
}
@ -18,16 +16,7 @@ export function buildDerivedTextData(
): NodeChange['derivedTextData'] {
return {
layoutSize: { x: options.node.width, y: options.node.height },
baselines: options.baselines ?? [
{
firstCharacter: 0,
endCharacter: Math.max(options.node.text.length - 1, 0),
position: { x: 0, y: options.baseline },
width: options.width,
lineHeight: options.lineHeight,
lineAscent: options.lineAscent
}
],
baselines: options.baselines,
glyphs: options.glyphs,
fontMetaData: options.fontMetaData,
logicalIndexToCharacterOffsetMap: options.logicalIndexToCharacterOffsetMap,

View file

@ -1,6 +1,26 @@
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
import type { DerivedTextGlyph } from '@open-pencil/scene-graph'
type DerivedTextData = NonNullable<NodeChange['derivedTextData']>
/**
* OpenPencil wrote text it had no glyphs for as one unwrapped line at `y = lineHeight`, with a
* character offset map one entry longer than the text. Figma's map has one entry per character,
* and later OpenPencil writers follow it, so that length together with every glyph on the one
* written baseline identifies glyphs that were never laid out at the node's width (#914).
*/
function isUnshapedOpenPencilText(derivedTextData: DerivedTextData, characters: string): boolean {
const baselines = derivedTextData.baselines ?? []
if (derivedTextData.logicalIndexToCharacterOffsetMap?.length !== characters.length + 1) {
return false
}
if (baselines.length !== 1) return false
const baselineY = baselines[0].position.y
return (derivedTextData.glyphs ?? []).every(
(glyph) => (glyph.rotation ?? 0) === 0 && glyph.position.y === baselineY
)
}
/**
* Resolve Figma derivedTextData.glyphs into scene glyphs.
* Dropping `rotation` used to flatten path text to axis-aligned scribbles;
@ -8,19 +28,23 @@ import type { DerivedTextGlyph } from '@open-pencil/scene-graph'
*/
export function convertFigmaDerivedTextGlyphs(
derivedTextData: NodeChange['derivedTextData'],
blobs: Uint8Array[]
blobs: Uint8Array[],
characters: string
): DerivedTextGlyph[] {
return (derivedTextData?.glyphs ?? [])
.map((glyph) => {
if (glyph.commandsBlob === undefined) return null
return {
commandsBlob: blobs[glyph.commandsBlob],
x: glyph.position.x,
y: glyph.position.y,
fontSize: glyph.fontSize,
firstCharacter: glyph.firstCharacter,
rotation: glyph.rotation
}
if (!derivedTextData || isUnshapedOpenPencilText(derivedTextData, characters)) return []
const glyphs: DerivedTextGlyph[] = []
for (const glyph of derivedTextData.glyphs ?? []) {
const commandsBlob = glyph.commandsBlob === undefined ? undefined : blobs[glyph.commandsBlob]
// Layout written without outlines, or with some missing, would draw text with gaps.
if (!commandsBlob) return []
glyphs.push({
commandsBlob,
x: glyph.position.x,
y: glyph.position.y,
fontSize: glyph.fontSize,
firstCharacter: glyph.firstCharacter,
rotation: glyph.rotation
})
.filter((glyph): glyph is NonNullable<typeof glyph> => !!glyph)
}
return glyphs
}

View file

@ -1,20 +1,13 @@
import type { OutlineCommand } from '../path/commands'
import type { SceneNode } from '@open-pencil/scene-graph'
export interface FigGlyphOutlineMetric {
commands: OutlineCommand[]
x: number
advance: number
}
import type { ShapedText } from '../derived-text/build'
/** Engine services the `.fig` writer needs but cannot provide without a text shaper. */
export interface FigNodeChangeExportRuntime {
getGlyphOutlineMetrics(
family: string,
style: string,
text: string,
fontSize: number
): FigGlyphOutlineMetric[] | null
/** Lay the node's text out at its width as the renderer draws it, or `null` when it cannot. */
shapeText(node: SceneNode): ShapedText | null
}
export const EMPTY_EXPORT_RUNTIME: FigNodeChangeExportRuntime = {
getGlyphOutlineMetrics: () => null
shapeText: () => null
}

View file

@ -1,6 +1,7 @@
export * from './basics'
export * from './canvas-guides'
export * from './convert'
export * from './derived-text/build'
export * from './derived-text/data'
export * from './derived-text/glyphs'
export * from './export/node'

View file

@ -1,20 +1,17 @@
import {
normalizeFontFamily,
OPEN_PENCIL_PLUGIN_DATA,
weightToStyle,
withPluginData
} from '@open-pencil/scene-graph'
import { fractionalPosition } from '@open-pencil/scene-graph/order-keys'
import { effectiveFigmaRawNodeFields } from '../source-metadata'
import { computeExportTransform, mapToFigmaType } from './basics'
import { bytesToHex } from './bytes'
import { buildDerivedTextData as buildSharedDerivedTextData } from './derived-text/data'
import { buildNodeDerivedTextData } from './derived-text/build'
import { EMPTY_EXPORT_RUNTIME, type FigNodeChangeExportRuntime } from './export/runtime'
import { applyFontFeaturesToKiwi } from './font/features'
import { weightToFigmaStyle } from './font/style'
import { fillToKiwiPaint, safeColor } from './paint'
import { bakeGlyphScale, encodePathCommandsBlob } from './path/commands'
import {
exportedVariableConsumptionEntries,
mergeVariableConsumptionMaps
@ -50,117 +47,6 @@ function textLines(text: string): NonNullable<NodeChange['textData']>['lines'] {
return Array.from({ length: lineCount }, () => ({ lineType: 'PLAIN' }))
}
function appendGlyphBlob(
blobs: Uint8Array[],
glyphBlobMap: Map<string, number>,
blob: Uint8Array
): number {
const key = bytesToHex(blob)
const existing = glyphBlobMap.get(key)
if (existing !== undefined) return existing
const index = blobs.push(blob) - 1
glyphBlobMap.set(key, index)
return index
}
function buildDerivedTextData(
node: SceneNode,
digestMap: Map<string, Uint8Array>,
blobs: Uint8Array[],
glyphBlobMap: Map<string, number>,
runtime: FigNodeChangeExportRuntime
): NodeChange['derivedTextData'] {
const fontMeta: NonNullable<NodeChange['derivedTextData']>['fontMetaData'] = []
const seen = new Set<string>()
const addFont = (family: string, weight: number, italic: boolean) => {
const style = weightToStyle(weight, italic)
const normalized = normalizeFontFamily(family)
const key = `${normalized}|${style}`
if (seen.has(key)) return
seen.add(key)
fontMeta.push({
key: { family: normalized, style: weightToFigmaStyle(weight, italic), postscript: '' },
fontLineHeight: 1.2,
fontDigest: digestMap.get(key),
fontStyle: italic ? 'ITALIC' : 'NORMAL',
fontWeight: weight
})
}
addFont(node.fontFamily, node.fontWeight, node.italic)
for (const run of node.styleRuns) {
addFont(
run.style.fontFamily ?? node.fontFamily,
run.style.fontWeight ?? node.fontWeight,
run.style.italic ?? node.italic
)
}
const lineHeight = node.lineHeight ?? Math.ceil(node.fontSize * 1.2)
const glyphAdvance = node.text.length > 0 ? node.width / Math.max(node.text.length, 1) : 0
const derivedGlyphs = node.derivedTextGlyphs ?? []
const glyphs =
derivedGlyphs.length > 0
? derivedGlyphs.map((glyph, index) => ({
commandsBlob: appendGlyphBlob(
blobs,
glyphBlobMap,
bakeGlyphScale(
glyph.commandsBlob,
glyph.scaleX ?? 1,
glyph.scaleY ?? 1,
glyph.rotation ?? 0
)
),
position: { x: glyph.x, y: glyph.y },
fontSize: glyph.fontSize,
firstCharacter: index,
advance:
index + 1 < derivedGlyphs.length
? Math.max(derivedGlyphs[index + 1].x - glyph.x, 0)
: glyphAdvance,
// Preserve path-text radians; hardcoding 0 used to flatten circular text on re-export.
rotation: glyph.rotation ?? 0
}))
: (
runtime.getGlyphOutlineMetrics(
node.fontFamily,
weightToStyle(node.fontWeight, node.italic),
node.text,
node.fontSize
) ?? []
).map((glyph, index) => ({
commandsBlob: appendGlyphBlob(
blobs,
glyphBlobMap,
encodePathCommandsBlob(glyph.commands, node.fontSize)
),
position: { x: glyph.x || index * glyphAdvance, y: lineHeight },
fontSize: node.fontSize,
firstCharacter: index,
advance: glyph.advance || glyphAdvance,
rotation: 0
}))
const logicalIndexToCharacterOffsetMap = Array.from(
{ length: node.text.length + 1 },
(_, index) => index * glyphAdvance
)
return buildSharedDerivedTextData({
node,
glyphs,
fontMetaData: fontMeta,
baseline: lineHeight,
width: node.width,
lineHeight,
lineAscent: Math.max(lineHeight - node.fontSize * 0.2, 0),
logicalIndexToCharacterOffsetMap
})
}
function serializeCornerRadii(node: SceneNode, nc: KiwiNodeChange): void {
const anyIndividual =
node.topLeftRadius > 0 ||
@ -235,13 +121,12 @@ function serializeTextProps(
if (node.textTruncation === 'ENDING') nc.textTruncation = 'ENDING'
if (node.maxLines != null) nc.maxLines = node.maxLines
if (fontDigestMap) {
nc.derivedTextData = buildDerivedTextData(
node,
fontDigestMap,
nc.derivedTextData = buildNodeDerivedTextData(node, {
digestMap: fontDigestMap,
blobs,
glyphBlobMap ?? new Map(),
runtime
)
glyphBlobMap: glyphBlobMap ?? new Map(),
shapeText: (textNode) => runtime.shapeText(textNode)
})
}
if (node.leadingTrim !== 'NONE') nc.leadingTrim = node.leadingTrim
if (node.lineHeight != null) nc.lineHeight = { value: node.lineHeight, units: 'PIXELS' }

View file

@ -0,0 +1,227 @@
import { describe, expect, spyOn, test } from 'bun:test'
import { expectDefined } from '#fig-tests/helpers/assert'
import {
buildNodeDerivedTextData,
convertFigmaDerivedTextGlyphs,
type DerivedTextBuildContext,
type ShapedText
} from '#fig/node-change/index'
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
import { SceneGraph } from '@open-pencil/scene-graph'
import type { SceneNode } from '@open-pencil/scene-graph'
const SQUARE = [
{ type: 'M', x: 0, y: 0 },
{ type: 'L', x: 8, y: 0 },
{ type: 'L', x: 8, y: -8 },
{ type: 'Z' }
]
function textNode(props: Partial<SceneNode> = {}): SceneNode {
const graph = new SceneGraph()
return graph.createNode('TEXT', graph.getPages()[0].id, {
text: 'Ab c',
width: 20,
height: 32,
fontSize: 16,
...props
})
}
function context(shaped: ShapedText | null): DerivedTextBuildContext & { blobs: Uint8Array[] } {
return {
digestMap: new Map(),
blobs: [],
glyphBlobMap: new Map(),
shapeText: () => shaped
}
}
function twoLines(commands: ShapedText['glyphs'][number]['commands']): ShapedText {
return {
glyphs: [
{ commands, x: 0, y: 12, fontSize: 16, firstCharacter: 0, advance: 8 },
{ commands, x: 8, y: 12, fontSize: 16, firstCharacter: 1, advance: 8 },
{ commands: commands && [], x: 16, y: 12, fontSize: 16, firstCharacter: 2, advance: 4 },
{ commands, x: 0, y: 28, fontSize: 16, firstCharacter: 3, advance: 8 }
],
baselines: [
{
firstCharacter: 0,
endCharacter: 3,
position: { x: 0, y: 12 },
width: 20,
lineY: 0,
lineHeight: 16,
lineAscent: 12
},
{
firstCharacter: 3,
endCharacter: 4,
position: { x: 0, y: 28 },
width: 8,
lineY: 16,
lineHeight: 16,
lineAscent: 12
}
],
logicalIndexToCharacterOffsetMap: [0, 8, 16, 0]
}
}
describe('derived text writer', () => {
test('writes shaped lines with em advances and deduplicated outlines', () => {
const writer = context(twoLines(SQUARE))
const data = expectDefined(buildNodeDerivedTextData(textNode(), writer), 'derived text')
const glyphs = expectDefined(data.glyphs, 'glyphs')
expect(data.baselines).toHaveLength(2)
expect(glyphs.map((glyph) => glyph.position.y)).toEqual([12, 12, 12, 28])
expect(glyphs[0].advance).toBe(0.5)
expect(data.logicalIndexToCharacterOffsetMap).toEqual([0, 8, 16, 0])
expect(glyphs[0].commandsBlob).toBe(glyphs[1].commandsBlob)
// Figma writes whitespace as a lone close command.
expect([...writer.blobs[expectDefined(glyphs[2].commandsBlob, 'space blob')]]).toEqual([0])
expect(writer.blobs).toHaveLength(2)
})
test('writes the layout without outlines when the shaper has none', () => {
const writer = context(twoLines(null))
const data = expectDefined(buildNodeDerivedTextData(textNode(), writer), 'derived text')
expect(data.baselines).toHaveLength(2)
expect(data.glyphs?.every((glyph) => glyph.commandsBlob === undefined)).toBe(true)
expect(writer.blobs).toHaveLength(0)
})
test('writes no glyphs for text that could not be shaped', () => {
const data = expectDefined(buildNodeDerivedTextData(textNode(), context(null)), 'derived')
expect(data.glyphs).toEqual([])
expect(data.baselines).toEqual([])
expect(data.layoutSize).toEqual({ x: 20, y: 32 })
})
test('writes no glyphs, rather than failing, when the shaper throws', () => {
const writer = context(null)
writer.shapeText = () => {
throw new Error('shaper failed')
}
const warn = spyOn(console, 'warn').mockImplementation(() => undefined)
try {
const data = expectDefined(buildNodeDerivedTextData(textNode(), writer), 'derived text')
expect(data.glyphs).toEqual([])
expect(warn).toHaveBeenCalledTimes(1)
} finally {
warn.mockRestore()
}
})
test('keeps saved glyphs on the lines they were placed on', () => {
const blob = new Uint8Array([0])
const node = textNode({
derivedTextGlyphs: [
{ commandsBlob: blob, x: 0, y: 12, fontSize: 16, firstCharacter: 0 },
{ commandsBlob: blob, x: 8, y: 12, fontSize: 16, firstCharacter: 1 },
{ commandsBlob: blob, x: 2, y: 28, fontSize: 16, firstCharacter: 3 }
]
})
const data = expectDefined(buildNodeDerivedTextData(node, context(null)), 'derived text')
expect(data.baselines?.map((line) => [line.firstCharacter, line.endCharacter])).toEqual([
[0, 3],
[3, 4]
])
expect(data.baselines?.[1].position).toEqual({ x: 2, y: 28 })
expect(data.logicalIndexToCharacterOffsetMap).toEqual([0, 8, 0, 0])
expect(data.glyphs?.map((glyph) => glyph.advance)).toEqual([0.5, 0, 0])
})
test('names font styles as Figma does', () => {
const data = buildNodeDerivedTextData(
textNode({ fontFamily: 'Inter', fontWeight: 600 }),
context(null)
)
expect(data?.fontMetaData?.[0].key.style).toBe('Semi Bold')
})
})
type DerivedTextData = NonNullable<NodeChange['derivedTextData']>
function earlierOpenPencilData(characters: string, lineHeight: number): DerivedTextData {
return {
baselines: [
{
firstCharacter: 0,
endCharacter: characters.length - 1,
position: { x: 0, y: lineHeight },
width: 230,
lineHeight,
lineAscent: lineHeight - 2.6
}
],
glyphs: Array.from(characters, (_, index) => ({
commandsBlob: 0,
position: { x: index * 7, y: lineHeight },
fontSize: 13,
firstCharacter: index,
advance: 7,
rotation: 0
})),
logicalIndexToCharacterOffsetMap: Array.from(
{ length: characters.length + 1 },
(_, index) => index * 7
)
}
}
describe('derived text reader', () => {
const blobs = [new Uint8Array([0])]
test('drops one-line glyphs an earlier OpenPencil wrote without shaping (#914)', () => {
const characters = 'A sentence long enough that it has to wrap onto a second line.'
expect(
convertFigmaDerivedTextGlyphs(earlierOpenPencilData(characters, 16), blobs, characters)
).toEqual([])
})
test('keeps Figma glyphs, whose offset map has one entry per character', () => {
const characters = 'Table item'
const data = earlierOpenPencilData(characters, 16)
data.logicalIndexToCharacterOffsetMap = data.logicalIndexToCharacterOffsetMap?.slice(0, -1)
expect(convertFigmaDerivedTextGlyphs(data, blobs, characters)).toHaveLength(characters.length)
})
test('keeps Figma glyphs an earlier OpenPencil carried over', () => {
const characters = 'Table item'
const data = earlierOpenPencilData(characters, 20)
for (const glyph of data.glyphs ?? []) glyph.position.y = 15.09
expect(convertFigmaDerivedTextGlyphs(data, blobs, characters)).toHaveLength(characters.length)
})
test('drops glyphs when any of them has no outline', () => {
const characters = 'Hi'
const data: DerivedTextData = {
glyphs: [
{
commandsBlob: 0,
position: { x: 0, y: 12 },
fontSize: 16,
firstCharacter: 0,
advance: 0.5
},
{ position: { x: 8, y: 12 }, fontSize: 16, firstCharacter: 1, advance: 0.5 }
],
logicalIndexToCharacterOffsetMap: [0, 8]
}
expect(convertFigmaDerivedTextGlyphs(data, blobs, characters)).toEqual([])
})
})

View file

@ -102,7 +102,7 @@ describe('@open-pencil/fig SceneGraph export policy', () => {
])
})
test('injects runtime glyph outlines into derived text data', () => {
test('writes text shaped by the runtime as derived text data', () => {
const graph = new SceneGraph()
const text = graph.createNode('TEXT', graph.getPages()[0].id, {
text: 'A',
@ -112,13 +112,30 @@ describe('@open-pencil/fig SceneGraph export policy', () => {
})
const blobs: Uint8Array[] = []
const runtime: FigNodeChangeExportRuntime = {
getGlyphOutlineMetrics: () => [
{
commands: [{ type: 'M', x: 0, y: 0 }, { type: 'L', x: 8, y: 16 }, { type: 'Z' }],
x: 0,
advance: 10
}
]
shapeText: () => ({
glyphs: [
{
commands: [{ type: 'M', x: 0, y: 0 }, { type: 'L', x: 8, y: 16 }, { type: 'Z' }],
x: 0,
y: 15,
fontSize: 16,
firstCharacter: 0,
advance: 10
}
],
baselines: [
{
firstCharacter: 0,
endCharacter: 1,
position: { x: 0, y: 15 },
width: 10,
lineY: 0,
lineHeight: 19,
lineAscent: 15
}
],
logicalIndexToCharacterOffsetMap: [0]
})
}
const [change] = sceneNodeToKiwi(
@ -136,6 +153,7 @@ describe('@open-pencil/fig SceneGraph export policy', () => {
)
expect(change.derivedTextData?.glyphs).toHaveLength(1)
expect(change.derivedTextData?.baselines?.[0]?.position.y).toBe(15)
expect(blobs).toHaveLength(1)
})

View file

@ -209,8 +209,13 @@ export function scaledGeometryChanges(
if (orig.strokeGeometry.length > 0) {
changes.strokeGeometry = scaleGeometryPaths(orig.strokeGeometry, sx, sy)
}
if (orig.derivedTextGlyphs?.length) {
changes.derivedTextGlyphs = scaleDerivedGlyphs(orig.derivedTextGlyphs, sx, sy)
// Path text scales with its path, including baked path text that kept only rotated glyphs;
// other text reflows in the new box, so its glyphs are dropped.
const glyphs = orig.derivedTextGlyphs ?? []
const pathText =
!!orig.textPathData || !!orig.textPathBox || glyphs.some((glyph) => (glyph.rotation ?? 0) !== 0)
if (glyphs.length > 0 && pathText) {
changes.derivedTextGlyphs = scaleDerivedGlyphs(glyphs, sx, sy)
// Keep the layout/selection box in sync with the scaled glyphs. reflow
// overrides this when it applies; this covers the fallback where reflow
// returns null (no path data) so the box doesn't carry a stale size.

View file

@ -2,6 +2,7 @@ import { expect, test } from 'bun:test'
import { SceneGraph, copyStyleRuns } from '@open-pencil/scene-graph'
import type { SceneNode } from '@open-pencil/scene-graph'
import { scaledGeometryChanges } from '@open-pencil/scene-graph/resize'
const shapingEdits: Partial<SceneNode>[] = [
{ fontVariations: [{ axis: 'wght', value: 600 }] },
@ -69,3 +70,29 @@ for (const preview of [false, true]) {
expect(node.derivedLayout).toBeNull()
})
}
test('resizing flat text drops its glyphs so it reflows, while path text scales them', () => {
const graph = new SceneGraph()
const glyphs = [{ commandsBlob: new Uint8Array([0]), x: 0, y: 10, fontSize: 12, rotation: 0 }]
const flat = graph.createNode('TEXT', graph.getPages()[0].id, {
text: 'Text',
width: 100,
height: 20,
derivedTextGlyphs: glyphs
})
const path = graph.createNode('TEXT', graph.getPages()[0].id, {
text: 'Text',
width: 100,
height: 20,
derivedTextGlyphs: glyphs.map((glyph) => ({ ...glyph, rotation: -0.5 }))
})
for (const node of [flat, path]) {
graph.updateNode(node.id, {
width: 200,
...scaledGeometryChanges(node, node.width, node.height, 200, node.height)
})
}
expect(flat.derivedTextGlyphs).toBeNull()
expect(path.derivedTextGlyphs?.[0].scaleX).toBe(2)
})

View file

@ -0,0 +1,67 @@
import { readFile } from 'node:fs/promises'
import { expect, test } from '#tests/e2e/fixtures'
import { CanvasHelper } from '#tests/helpers/canvas'
const MESSAGE = 'A sentence long enough that it has to wrap onto a second line.'
test('wrapped text keeps its lines after a .fig is saved and reopened (#914)', async ({ page }) => {
await page.goto('/?test&no-chrome&no-rulers')
const canvas = new CanvasHelper(page)
await canvas.waitForInit()
await page.evaluate((message) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('Editor unavailable')
const card = store.graph.createNode('FRAME', store.state.currentPageId, {
name: 'Card',
width: 260,
height: 52,
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, visible: true, opacity: 1 }]
})
store.graph.createNode('TEXT', card.id, {
name: 'Message',
text: message,
x: 10,
y: 10,
width: 230,
height: 32,
fontFamily: 'Inter',
fontSize: 13,
lineHeight: 16,
textAutoResize: 'HEIGHT',
fills: [{ type: 'SOLID', color: { r: 0.1, g: 0.1, b: 0.1, a: 1 }, visible: true, opacity: 1 }]
})
}, MESSAGE)
const picker = await page.evaluateHandle(() => {
const original = window.showSaveFilePicker
window.showSaveFilePicker = undefined
return {
restore: () => {
window.showSaveFilePicker = original
}
}
})
let bytes: number[]
try {
page.once('dialog', (dialog) => dialog.accept('Card.fig'))
const download = page.waitForEvent('download')
await page.evaluate(() => window.openPencil?.getStore?.().saveFigFileAs())
bytes = [...(await readFile(await (await download).path()))]
} finally {
await picker.evaluate((handle) => handle.restore())
await picker.dispose()
}
const lines = await page.evaluate(async (saved) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('Editor unavailable')
await store.openFigFile(new File([new Uint8Array(saved)], 'Card.fig'))
const message = [...store.graph.nodes.values()].find((node) => node.name === 'Message')
store.requestRender()
return new Set(message?.derivedTextGlyphs?.map((glyph) => glyph.y)).size
}, bytes)
expect(lines).toBe(2)
await canvas.waitForRender()
expect(await canvas.screenshotCanvasRegion()).toMatchSnapshot('reopened-wrapped-text.png')
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -2,6 +2,7 @@ import { beforeAll, describe, expect, it } from 'bun:test'
import {
buildFigmaClipboardHTML,
fontManager,
importClipboardNodes,
initCodec,
parseFigmaClipboard,
@ -29,6 +30,11 @@ function expectFigmaEditableTextDefaults(
describe('buildFigmaClipboardHTML', () => {
beforeAll(async () => {
await initCodec()
const inter = expectDefined(
await fontManager.fetchBundledFont('/Inter-Regular.ttf'),
'bundled Inter font'
)
fontManager.markLoaded('Inter', 'Regular', inter)
})
it('encodes a simple frame without throwing', async () => {
@ -87,9 +93,7 @@ describe('buildFigmaClipboardHTML', () => {
name
)
const characters = text.textData?.characters ?? ''
expect(text.derivedTextData?.logicalIndexToCharacterOffsetMap?.length).toBe(
characters.length + 1
)
expect(text.derivedTextData?.logicalIndexToCharacterOffsetMap?.length).toBe(characters.length)
}
})
@ -119,15 +123,18 @@ describe('buildFigmaClipboardHTML', () => {
const textNode = parsed?.nodes.find((node) => node.type === 'TEXT')
if (!textNode) throw new Error('Expected text node')
expectFigmaEditableTextDefaults(textNode)
expect(textNode.derivedTextData?.glyphs).toBeDefined()
expect(textNode.derivedTextData?.baselines?.length).toBeGreaterThan(0)
const glyphs = textNode.derivedTextData?.glyphs ?? []
expect(glyphs.length).toBeGreaterThan(0)
// Bold and italic Inter are not loaded, so no glyph gets an outline from the wrong font.
expect(glyphs.every((glyph) => glyph.commandsBlob === undefined)).toBe(true)
expect(textNode.derivedTextData?.baselines).toHaveLength(1)
expect(textNode.derivedTextData?.logicalIndexToCharacterOffsetMap?.length).toBe(
text.text.length + 1
text.text.length
)
expect(textNode.derivedTextData?.derivedLines).toEqual([{ directionality: 'LTR' }])
})
it('encodes fallback derived text metrics when outline fonts are unavailable', async () => {
it('writes the fallback layout without outlines when the font is unavailable', async () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
graph.createNode('TEXT', page.id, {
@ -151,8 +158,11 @@ describe('buildFigmaClipboardHTML', () => {
expect(textNode?.textUserLayoutVersion).toBe(5)
expect(textNode?.textAutoResize).toBe('NONE')
expect(textNode?.derivedTextData?.glyphs?.length).toBe('Analytics Overview'.length)
expect(baseline?.width).toBe(552)
const glyphs = textNode?.derivedTextData?.glyphs ?? []
expect(glyphs).toHaveLength('Analytics Overview'.length)
expect(glyphs.every((glyph) => glyph.commandsBlob === undefined)).toBe(true)
expect(textNode?.derivedTextData?.baselines).toHaveLength(1)
expect(baseline?.width).toBeLessThanOrEqual(552)
expect(baseline?.lineHeight).toBe(67)
expect(textNode?.derivedTextData?.layoutSize).toEqual({ x: 552, y: 70 })
})

View file

@ -88,7 +88,8 @@ describe('TEXT_PATH import mapping', () => {
}
]
},
[blob]
[blob],
'Hi'
)
expect(glyphs).toHaveLength(2)

View file

@ -13,8 +13,13 @@ import { createDefaultSourceMetadata } from '@open-pencil/scene-graph/node-defau
import { initCanvasKit } from '#cli/headless'
import type { SkiaRenderer } from '#core/canvas/renderer'
import { renderText, textVerticalOffset } from '#core/canvas/scene'
import { buildParagraph, isNodeFontLoaded, nodeFontReadiness } from '#core/canvas/text'
import { renderText } from '#core/canvas/scene'
import {
buildParagraph,
isNodeFontLoaded,
nodeFontReadiness,
textVerticalOffset
} from '#core/canvas/text'
import { transformTextCase } from '#core/text/case'
import { fontManager } from '#core/text/fonts'
import { fontFaceDemand, fontResolver, missingGlyphCharacters } from '#core/text/resolver'

View file

@ -1,191 +0,0 @@
import { describe, expect, test } from 'bun:test'
import {
fontManager,
SceneGraph,
buildFontDigestMap,
buildDerivedTextDataV4,
initCodec
} from '@open-pencil/core'
import { expectDefined } from '#tests/helpers/assert'
describe('clipboard derived text export', () => {
test('builds richer v4 derivedTextData from shaped text + glyph outlines', async () => {
await initCodec()
const font = expectDefined(
await fontManager.fetchBundledFont('/Inter-Regular.ttf'),
'bundled Inter font'
)
fontManager.markLoaded('Inter', 'Regular', font)
const graph = new SceneGraph()
const page = graph.getPages()[0]
const text = graph.createNode('TEXT', page.id, {
name: 'Hello',
text: 'Hello',
width: 120,
height: 24,
fontFamily: 'Inter',
fontSize: 16,
fontWeight: 400
})
const fontDigestMap = await buildFontDigestMap(graph)
const blobs: Uint8Array[] = []
const derived = await buildDerivedTextDataV4(
text,
fontDigestMap,
{
lineHeight: 20,
lineAscent: 15,
lineWidth: 42,
baseline: 16,
glyphs: [
{ firstCharacter: 0, x: 0, y: 16, advance: 8 },
{ firstCharacter: 1, x: 8, y: 16, advance: 8 },
{ firstCharacter: 2, x: 16, y: 16, advance: 8 },
{ firstCharacter: 3, x: 24, y: 16, advance: 8 },
{ firstCharacter: 4, x: 32, y: 16, advance: 10 }
],
logicalIndexToCharacterOffsetMap: [0, 8, 16, 24, 32, 42]
},
blobs
)
const derivedTextData = expectDefined(derived, 'derived text data')
const glyphs = expectDefined(derivedTextData.glyphs, 'glyphs')
const baselines = expectDefined(derivedTextData.baselines, 'baselines')
const fontMetaData = expectDefined(derivedTextData.fontMetaData, 'font metadata')
const offsetMap = expectDefined(
derivedTextData.logicalIndexToCharacterOffsetMap,
'character offset map'
)
const derivedLines = expectDefined(derivedTextData.derivedLines, 'derived lines')
const firstGlyph = expectDefined(glyphs[0], 'first glyph')
const lastGlyph = expectDefined(glyphs[4], 'last glyph')
const baseline = expectDefined(baselines[0], 'first baseline')
const line = expectDefined(derivedLines[0], 'first derived line')
expect(fontMetaData.length).toBeGreaterThan(0)
expect(fontMetaData[0].key.style).toBe('Regular')
expect(glyphs.length).toBeGreaterThan(0)
expect(baselines.length).toBeGreaterThan(0)
expect(offsetMap.length).toBe(text.text.length + 1)
expect(offsetMap[5]).toBe(42)
expect(line.directionality).toBe('LTR')
expect(derivedTextData.truncationStartIndex).toBe(-1)
expect(derivedTextData.truncatedHeight).toBe(-1)
expect(firstGlyph.commandsBlob).toBe(0)
expect(blobs[0].length).toBeGreaterThan(0)
expect(firstGlyph.position.x).toBe(0)
expect(lastGlyph.position.x).toBe(32)
expect(baseline.lineHeight).toBe(20)
expect(baseline.lineAscent).toBe(15)
expect(baseline.width).toBe(42)
})
test('preserves shaped multiline baselines for Figma text editing', async () => {
await initCodec()
const graph = new SceneGraph()
const page = graph.getPages()[0]
const text = graph.createNode('TEXT', page.id, {
name: 'Wrapped',
text: 'Analytics Overview',
width: 360,
height: 136,
fontFamily: 'Missing Preview Font',
fontSize: 56,
fontWeight: 700
})
const derived = expectDefined(
await buildDerivedTextDataV4(
text,
await buildFontDigestMap(graph),
{
lineHeight: 68,
lineAscent: 54,
lineWidth: 272,
baseline: 54.36,
baselines: [
{
firstCharacter: 0,
endCharacter: 10,
position: { x: 0, y: 54.36 },
width: 271.58,
lineY: 0,
lineHeight: 68,
lineAscent: 54
},
{
firstCharacter: 10,
endCharacter: 18,
position: { x: 0, y: 122.36 },
width: 261.9,
lineY: 68,
lineHeight: 68,
lineAscent: 54
}
],
glyphs: [
{ firstCharacter: 0, x: 0, y: 54.36, advance: 42 },
{ firstCharacter: 10, x: 0, y: 122.36, advance: 44 }
],
logicalIndexToCharacterOffsetMap: Array.from({ length: 19 }, () => 0)
},
undefined
),
'derived text'
)
const baselines = expectDefined(derived.baselines, 'baselines')
expect(baselines).toHaveLength(2)
expect(baselines[0].endCharacter).toBe(10)
expect(baselines[1].firstCharacter).toBe(10)
expect(baselines[1].position.y).toBe(122.36)
expect(derived.layoutSize).toEqual({ x: 360, y: 136 })
})
test('uses Figma font style names in metadata', async () => {
await initCodec()
const font = expectDefined(
await fontManager.fetchBundledFont('/Inter-SemiBold.ttf'),
'bundled Inter font'
)
fontManager.markLoaded('Inter', 'SemiBold', font)
const graph = new SceneGraph()
const page = graph.getPages()[0]
const text = graph.createNode('TEXT', page.id, {
text: 'Title',
fontFamily: 'Inter',
fontSize: 16,
fontWeight: 600
})
const fontDigestMap = await buildFontDigestMap(graph)
const blobs: Uint8Array[] = []
const derived = expectDefined(
await buildDerivedTextDataV4(text, fontDigestMap, null, blobs),
'derived text'
)
const fontMetaData = expectDefined(derived.fontMetaData, 'font metadata')
const glyphs = expectDefined(derived.glyphs, 'glyphs')
const offsetMap = expectDefined(
derived.logicalIndexToCharacterOffsetMap,
'character offset map'
)
expect(fontMetaData[0].key.style).toBe('Semi Bold')
expect(glyphs[0].commandsBlob).toBe(0)
expect(blobs[0].length).toBeGreaterThan(0)
expect(glyphs[1].position.x).toBeGreaterThan(0)
expect(glyphs[1].advance).toBeGreaterThan(0)
expect(offsetMap[1]).toBeGreaterThan(0)
})
})

View file

@ -425,7 +425,6 @@ tests/engine/tauri/mcp-spawn.test.ts
tests/engine/tauri/mock-cleanup.test.ts
tests/engine/tauri/processes.test.ts
tests/engine/text/coverage.test.ts
tests/engine/text/derived-text/clipboard.test.ts
tests/engine/text/edit-undo.test.ts
tests/engine/text/editor.test.ts
tests/engine/text/fonts/coverage.test.ts