From e77eeff11c4c79c46f56b09a722701c82cf2c6b0 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 6 Oct 2026 11:37:02 +0000 Subject: [PATCH] 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. --- CHANGELOG.md | 2 + packages/core/src/canvas/scene.ts | 9 +- packages/core/src/canvas/text/clipboard.ts | 127 ---------- packages/core/src/canvas/text/index.ts | 8 + packages/core/src/canvas/text/shape.ts | 187 ++++++++++++++ packages/core/src/clipboard.ts | 49 ++-- packages/core/src/index.ts | 2 +- packages/core/src/io/formats/fig/export.ts | 27 +- .../src/kiwi/fig/node-change/serialize.ts | 34 +-- .../core/src/text/derived-text/clipboard.ts | 233 ------------------ packages/core/src/text/fonts.ts | 13 +- packages/core/src/text/opentype.ts | 25 +- packages/core/tests/canvas/text/shape.test.ts | 109 ++++++++ .../formats/fig/roundtrip/glyph-blob.test.ts | 45 ++++ packages/fig/README.md | 2 +- packages/fig/docs/export.md | 21 ++ packages/fig/src/node-change/convert.ts | 23 +- .../fig/src/node-change/derived-text/build.ts | 219 ++++++++++++++++ .../fig/src/node-change/derived-text/data.ts | 23 +- .../src/node-change/derived-text/glyphs.ts | 50 +++- .../fig/src/node-change/export/runtime.ts | 19 +- packages/fig/src/node-change/index.ts | 1 + packages/fig/src/node-change/serialize.ts | 127 +--------- .../tests/node-change/derived-text.test.ts | 227 +++++++++++++++++ packages/fig/tests/node-change/export.test.ts | 34 ++- packages/scene-graph/src/resize.ts | 9 +- .../tests/text/layout-invalidation.test.ts | 27 ++ tests/e2e/canvas/saved-text-visual.spec.ts | 67 +++++ ...eopened-wrapped-text-openpencil-darwin.png | Bin 0 -> 15124 bytes tests/engine/clipboard/figma/html.test.ts | 28 ++- tests/engine/io/fig/import/text-path.test.ts | 3 +- tests/engine/render/canvas/text.test.ts | 9 +- .../text/derived-text/clipboard.test.ts | 191 -------------- tools/checks/test-homes/engine-baseline.txt | 1 - 34 files changed, 1139 insertions(+), 812 deletions(-) delete mode 100644 packages/core/src/canvas/text/clipboard.ts create mode 100644 packages/core/src/canvas/text/shape.ts delete mode 100644 packages/core/src/text/derived-text/clipboard.ts create mode 100644 packages/core/tests/canvas/text/shape.test.ts create mode 100644 packages/fig/src/node-change/derived-text/build.ts create mode 100644 packages/fig/tests/node-change/derived-text.test.ts create mode 100644 tests/e2e/canvas/saved-text-visual.spec.ts create mode 100644 tests/e2e/canvas/saved-text-visual.spec.ts-snapshots/reopened-wrapped-text-openpencil-darwin.png delete mode 100644 tests/engine/text/derived-text/clipboard.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index eb8fb8f0d..53d814ec1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/packages/core/src/canvas/scene.ts b/packages/core/src/canvas/scene.ts index 7096b8ebc..c15169a8a 100644 --- a/packages/core/src/canvas/scene.ts +++ b/packages/core/src/canvas/scene.ts @@ -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. diff --git a/packages/core/src/canvas/text/clipboard.ts b/packages/core/src/canvas/text/clipboard.ts deleted file mode 100644 index edcc36472..000000000 --- a/packages/core/src/canvas/text/clipboard.ts +++ /dev/null @@ -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['baselines'] - glyphs: ClipboardShapedGlyph[] - logicalIndexToCharacterOffsetMap: number[] -} - -function addShapedRunGlyphs( - run: ReturnType[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[number], - textLength: number, - baselines: NonNullable -): 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 { - 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 = [] - 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 - } -} diff --git a/packages/core/src/canvas/text/index.ts b/packages/core/src/canvas/text/index.ts index 33377a755..09af213d9 100644 --- a/packages/core/src/canvas/text/index.ts +++ b/packages/core/src/canvas/text/index.ts @@ -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 diff --git a/packages/core/src/canvas/text/shape.ts b/packages/core/src/canvas/text/shape.ts new file mode 100644 index 000000000..01bb89b2e --- /dev/null +++ b/packages/core/src/canvas/text/shape.ts @@ -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[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 +): 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[] { + 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 = 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( + graph: SceneGraph, + ck: CanvasKit | undefined, + write: (runtime: FigNodeChangeExportRuntime) => Promise +): Promise { + 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() + } + } +} diff --git a/packages/core/src/clipboard.ts b/packages/core/src/clipboard.ts index 5b3a75fea..637677150 100644 --- a/packages/core/src/clipboard.ts +++ b/packages/core/src/clipboard.ts @@ -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 { 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 { + return withFigExportRuntime(graph, undefined, (runtime) => + writeFigmaClipboardHTML(nodes, graph, runtime) + ) +} + +async function writeFigmaClipboardHTML( + nodes: SceneNode[], + graph: SceneGraph, + runtime: FigNodeChangeExportRuntime ): Promise { 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()) } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0459ff2f7..6e8e1ae9e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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 { diff --git a/packages/core/src/io/formats/fig/export.ts b/packages/core/src/io/formats/fig/export.ts index 6409f24ca..3c1cb15c4 100644 --- a/packages/core/src/io/formats/fig/export.ts +++ b/packages/core/src/io/formats/fig/export.ts @@ -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 componentPropertyDefinitionsById: ReturnType propertyIdToGuid: Map + 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 { 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 { 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 }) ) } diff --git a/packages/core/src/kiwi/fig/node-change/serialize.ts b/packages/core/src/kiwi/fig/node-change/serialize.ts index 35d448d66..00083428f 100644 --- a/packages/core/src/kiwi/fig/node-change/serialize.ts +++ b/packages/core/src/kiwi/fig/node-change/serialize.ts @@ -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 - }) -} diff --git a/packages/core/src/text/derived-text/clipboard.ts b/packages/core/src/text/derived-text/clipboard.ts deleted file mode 100644 index 06119b221..000000000 --- a/packages/core/src/text/derived-text/clipboard.ts +++ /dev/null @@ -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>, - 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>, - 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['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, - shaped?: ShapedClipboardText | null, - blobs?: Uint8Array[] -): Promise { - 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)['glyphs'][number] - >() - if (shaped) { - for (const g of shaped.glyphs) shapedByChar.set(g.firstCharacter, g) - } - - const fallbackBaselines: NonNullable['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 - }) -} diff --git a/packages/core/src/text/fonts.ts b/packages/core/src/text/fonts.ts index a98ac6444..907c84912 100644 --- a/packages/core/src/text/fonts.ts +++ b/packages/core/src/text/fonts.ts @@ -53,6 +53,7 @@ export class FontManager { private blockedNodeIds = new Set() private fontProvider: TypefaceFontProvider | null = null private fontProviders = new Set() + private providerCanvasKits = new WeakMap() private registrationGeneration = 0 private providerRegistrations = new WeakMap>>() private localFonts: FontInfo[] | null = null @@ -66,8 +67,9 @@ export class FontManager { private arabicFallbackFamilies: string[] = [] private arabicFallbackPromise: Promise | 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 } diff --git a/packages/core/src/text/opentype.ts b/packages/core/src/text/opentype.ts index 268970f76..cd70e371e 100644 --- a/packages/core/src/text/opentype.ts +++ b/packages/core/src/text/opentype.ts @@ -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, diff --git a/packages/core/tests/canvas/text/shape.test.ts b/packages/core/tests/canvas/text/shape.test.ts new file mode 100644 index 000000000..8c2718162 --- /dev/null +++ b/packages/core/tests/canvas/text/shape.test.ts @@ -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): Promise { + 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() + }) +}) diff --git a/packages/core/tests/io/formats/fig/roundtrip/glyph-blob.test.ts b/packages/core/tests/io/formats/fig/roundtrip/glyph-blob.test.ts index e37185215..069a67bdb 100644 --- a/packages/core/tests/io/formats/fig/roundtrip/glyph-blob.test.ts +++ b/packages/core/tests/io/formats/fig/roundtrip/glyph-blob.test.ts @@ -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) + }) }) diff --git a/packages/fig/README.md b/packages/fig/README.md index 5f68bb04c..13d108f81 100644 --- a/packages/fig/README.md +++ b/packages/fig/README.md @@ -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 diff --git a/packages/fig/docs/export.md b/packages/fig/docs/export.md index 8e76d8c5a..216274165 100644 --- a/packages/fig/docs/export.md +++ b/packages/fig/docs/export.md @@ -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) diff --git a/packages/fig/src/node-change/convert.ts b/packages/fig/src/node-change/convert.ts index bd22620ba..30e5737cd 100644 --- a/packages/fig/src/node-change/convert.ts +++ b/packages/fig/src/node-change/convert.ts @@ -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 { + const layoutSize = nc.derivedTextData?.layoutSize + return { + derivedLayout: layoutSize ? { width: layoutSize.x, height: layoutSize.y } : null, + derivedTextGlyphs: convertFigmaDerivedTextGlyphs( + nc.derivedTextData, + blobs, + nc.textData?.characters ?? '' + ) } } diff --git a/packages/fig/src/node-change/derived-text/build.ts b/packages/fig/src/node-change/derived-text/build.ts new file mode 100644 index 000000000..3a385dc9d --- /dev/null +++ b/packages/fig/src/node-change/derived-text/build.ts @@ -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 +type DerivedTextBaseline = NonNullable[number] +type DerivedTextGlyphRecord = NonNullable[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 + blobs: Uint8Array[] + glyphBlobMap: Map + 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 +): DerivedTextData['fontMetaData'] { + const fonts: NonNullable = [] + const seen = new Set() + 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 { + 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 ?? [] + }) +} diff --git a/packages/fig/src/node-change/derived-text/data.ts b/packages/fig/src/node-change/derived-text/data.ts index a6f8e44a3..7e9d0758f 100644 --- a/packages/fig/src/node-change/derived-text/data.ts +++ b/packages/fig/src/node-change/derived-text/data.ts @@ -1,15 +1,13 @@ import type { NodeChange } from '@open-pencil/kiwi/fig/codec' import type { SceneNode } from '@open-pencil/scene-graph' +type DerivedTextData = NonNullable + interface DerivedTextDataOptions { node: SceneNode - glyphs: NonNullable['glyphs'] - fontMetaData: NonNullable['fontMetaData'] - baseline: number - width: number - lineHeight: number - lineAscent: number - baselines?: NonNullable['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, diff --git a/packages/fig/src/node-change/derived-text/glyphs.ts b/packages/fig/src/node-change/derived-text/glyphs.ts index 85a9903e8..626dad749 100644 --- a/packages/fig/src/node-change/derived-text/glyphs.ts +++ b/packages/fig/src/node-change/derived-text/glyphs.ts @@ -1,6 +1,26 @@ import type { NodeChange } from '@open-pencil/kiwi/fig/codec' import type { DerivedTextGlyph } from '@open-pencil/scene-graph' +type DerivedTextData = NonNullable + +/** + * 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 => !!glyph) + } + return glyphs } diff --git a/packages/fig/src/node-change/export/runtime.ts b/packages/fig/src/node-change/export/runtime.ts index f32fb5062..329ccf225 100644 --- a/packages/fig/src/node-change/export/runtime.ts +++ b/packages/fig/src/node-change/export/runtime.ts @@ -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 } diff --git a/packages/fig/src/node-change/index.ts b/packages/fig/src/node-change/index.ts index c7f571abd..bd157d136 100644 --- a/packages/fig/src/node-change/index.ts +++ b/packages/fig/src/node-change/index.ts @@ -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' diff --git a/packages/fig/src/node-change/serialize.ts b/packages/fig/src/node-change/serialize.ts index c6f1ee667..1c2d30c3a 100644 --- a/packages/fig/src/node-change/serialize.ts +++ b/packages/fig/src/node-change/serialize.ts @@ -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['lines'] { return Array.from({ length: lineCount }, () => ({ lineType: 'PLAIN' })) } -function appendGlyphBlob( - blobs: Uint8Array[], - glyphBlobMap: Map, - 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, - blobs: Uint8Array[], - glyphBlobMap: Map, - runtime: FigNodeChangeExportRuntime -): NodeChange['derivedTextData'] { - const fontMeta: NonNullable['fontMetaData'] = [] - const seen = new Set() - - 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' } diff --git a/packages/fig/tests/node-change/derived-text.test.ts b/packages/fig/tests/node-change/derived-text.test.ts new file mode 100644 index 000000000..cb675aaa0 --- /dev/null +++ b/packages/fig/tests/node-change/derived-text.test.ts @@ -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 { + 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 + +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([]) + }) +}) diff --git a/packages/fig/tests/node-change/export.test.ts b/packages/fig/tests/node-change/export.test.ts index 1aca79603..6e1cb9531 100644 --- a/packages/fig/tests/node-change/export.test.ts +++ b/packages/fig/tests/node-change/export.test.ts @@ -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) }) diff --git a/packages/scene-graph/src/resize.ts b/packages/scene-graph/src/resize.ts index 267c82f19..31d6cbc3a 100644 --- a/packages/scene-graph/src/resize.ts +++ b/packages/scene-graph/src/resize.ts @@ -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. diff --git a/packages/scene-graph/tests/text/layout-invalidation.test.ts b/packages/scene-graph/tests/text/layout-invalidation.test.ts index 0c8432ca8..11222c87d 100644 --- a/packages/scene-graph/tests/text/layout-invalidation.test.ts +++ b/packages/scene-graph/tests/text/layout-invalidation.test.ts @@ -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[] = [ { 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) +}) diff --git a/tests/e2e/canvas/saved-text-visual.spec.ts b/tests/e2e/canvas/saved-text-visual.spec.ts new file mode 100644 index 000000000..ac65944c6 --- /dev/null +++ b/tests/e2e/canvas/saved-text-visual.spec.ts @@ -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') +}) diff --git a/tests/e2e/canvas/saved-text-visual.spec.ts-snapshots/reopened-wrapped-text-openpencil-darwin.png b/tests/e2e/canvas/saved-text-visual.spec.ts-snapshots/reopened-wrapped-text-openpencil-darwin.png new file mode 100644 index 0000000000000000000000000000000000000000..350048d4fe6d05fcdb36589b7d42e4876b4d820d GIT binary patch literal 15124 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 literal 0 HcmV?d00001 diff --git a/tests/engine/clipboard/figma/html.test.ts b/tests/engine/clipboard/figma/html.test.ts index a08a6cded..32891f66f 100644 --- a/tests/engine/clipboard/figma/html.test.ts +++ b/tests/engine/clipboard/figma/html.test.ts @@ -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 }) }) diff --git a/tests/engine/io/fig/import/text-path.test.ts b/tests/engine/io/fig/import/text-path.test.ts index 628cc1880..7f66fe565 100644 --- a/tests/engine/io/fig/import/text-path.test.ts +++ b/tests/engine/io/fig/import/text-path.test.ts @@ -88,7 +88,8 @@ describe('TEXT_PATH import mapping', () => { } ] }, - [blob] + [blob], + 'Hi' ) expect(glyphs).toHaveLength(2) diff --git a/tests/engine/render/canvas/text.test.ts b/tests/engine/render/canvas/text.test.ts index 15d0fa7e1..24efa21bf 100644 --- a/tests/engine/render/canvas/text.test.ts +++ b/tests/engine/render/canvas/text.test.ts @@ -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' diff --git a/tests/engine/text/derived-text/clipboard.test.ts b/tests/engine/text/derived-text/clipboard.test.ts deleted file mode 100644 index 36b20d983..000000000 --- a/tests/engine/text/derived-text/clipboard.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/tools/checks/test-homes/engine-baseline.txt b/tools/checks/test-homes/engine-baseline.txt index 0a68597f2..2e3de8ab2 100644 --- a/tools/checks/test-homes/engine-baseline.txt +++ b/tools/checks/test-homes/engine-baseline.txt @@ -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