diff --git a/.pi/context.md b/.pi/context.md deleted file mode 100644 index 9077390c2..000000000 --- a/.pi/context.md +++ /dev/null @@ -1,110 +0,0 @@ -# Code Context - -## Files Retrieved -1. `packages/core/src/tools/analyze.ts` (lines 1-391) - Full file: analyze tools, diff tools, eval tool -2. `packages/core/src/tools/vector.ts` (lines 1-264) - Full file: boolean ops, path ops, viewport, SVG/image export -3. `packages/core/src/tools/ai-adapter.ts` (lines 1-96) - Full file: Vercel AI SDK adapter -4. `packages/core/src/tools/read.ts` (lines 1-183) - Full file: query/read tools -5. `packages/core/src/tools/registry.ts` (lines 1-100) - Full file: ALL_TOOLS array - -## analyze.ts — Tool Definitions - -| Line | Tool Name | -|------|-----------| -| 128 | `analyze_colors` | -| 197 | `analyze_typography` | -| 251 | `analyze_spacing` | -| 295 | `analyze_clusters` | -| 356 | `diff_create` (under "Diff tools" section) | -| 398 | `diff_show` | -| 449 | `eval` | - -**No `describe` tool exists** in analyze.ts or anywhere in the tools directory. - -## read.ts — Tool Names - -| Tool Name | Description | -|-----------|-------------| -| `get_selection` | Currently selected nodes | -| `get_page_tree` | Node tree of current page | -| `get_node` | Detailed properties by ID | -| `find_nodes` | Find by name/type | -| `get_components` | List components | -| `list_pages` | List all pages | -| `switch_page` | Switch page by name/ID | -| `get_current_page` | Current page name/ID | -| `page_bounds` | Bounding box of all page objects | -| `select_nodes` | Select nodes by ID | -| `list_fonts` | Fonts used on current page | - -**Neither `get_jsx` nor `diff_jsx` exist** in read.ts or any other tool file. - -## vector.ts — exportImage Tool (line ~225) - -- **Default format:** `'PNG'` -- **Default scale:** `1` (min: 0.1, max: 4) -- **Base64 conversion:** Dual-path: - - Node.js: `Buffer.from(data).toString('base64')` - - Browser: `btoa(String.fromCharCode(...data))` -- Returns: `{ mimeType, base64, byteLength }` -- Falls back to error if `figma.exportImage` is unavailable - -## ai-adapter.ts — Full Contents - -- **`AIAdapterOptions`** interface: `getFigma()`, `onBeforeExecute?`, `onAfterExecute?`, `onFlashNodes?` -- **`toolsToAI()`** function: Converts `ToolDef[]` → Vercel AI SDK `tool()` objects - - Accepts deps: `{ v: valibot, valibotSchema, tool }` (lazy imports) - - Builds valibot schemas from `ParamDef` type declarations - - Wraps each `execute` with before/after hooks and error catching - - If tool `mutates` and returns node IDs, calls `onFlashNodes` for visual feedback -- **`paramToValibot()`**: Maps param types → valibot schemas - - `string` → `v.string()` or `v.picklist()` if enum - - `number` → `v.number()` with optional `minValue`/`maxValue` pipes - - `boolean` → `v.boolean()` - - `color` → `v.string()` with description - - `string[]` → `v.array(v.string())` with `minLength(1)` - - Non-required params wrapped in `v.optional()` -- **`extractNodeIds()`**: Extracts IDs from result objects (handles `id`, `selection[]`, `results[]`) - -## registry.ts — ALL_TOOLS Array (84 tools total) - -**Read (11):** get_selection, get_page_tree, get_node, find_nodes, get_components, list_pages, switch_page, get_current_page, page_bounds, select_nodes, list_fonts - -**Create (7):** createShape, render, createComponent, createInstance, createPage, createVector, createSlice - -**Modify (20):** setFill, setStroke, setEffects, updateNode, setLayout, setConstraints, setRotation, setOpacity, setRadius, setMinMax, setText, setFont, setFontRange, setTextResize, setVisible, setBlend, setLocked, setStrokeAlign, setTextProperties, setLayoutChild - -**Structure (17):** deleteNode, cloneNode, renameNode, reparentNode, groupNodes, ungroupNode, flattenNodes, nodeToComponent, nodeBounds, nodeMove, nodeResize, nodeAncestors, nodeChildren, nodeTree, nodeBindings, nodeReplaceWith, arrangeNodes - -**Variables (11):** listVariables, listCollections, getVariable, findVariables, createVariable, setVariable, deleteVariable, bindVariable, getCollection, createCollection, deleteCollection - -**Vector & Export (14):** booleanUnion, booleanSubtract, booleanIntersect, booleanExclude, pathGet, pathSet, pathScale, pathFlip, pathMove, viewportGet, viewportSet, viewportZoomToFit, exportSvg, exportImage - -**Analyze & Diff (6):** analyzeColors, analyzeTypography, analyzeSpacing, analyzeClusters, diffCreate, diffShow - -**Eval (1):** evalCode - -## Architecture - -``` -ToolDef (schema.ts) -- type-safe tool definition with params + execute - ├── read.ts -- query tools (selection, find, pages, fonts) - ├── create.ts -- shape/component/page creation - ├── modify.ts -- property setters - ├── structure.ts -- tree operations - ├── variables.ts -- variable/collection CRUD - ├── vector.ts -- boolean ops, paths, viewport, export - └── analyze.ts -- analysis, diff, eval - │ -registry.ts -- assembles ALL_TOOLS array - │ -ai-adapter.ts -- toolsToAI() → Vercel AI SDK tool() objects - (valibot schemas, before/after hooks, flash nodes) -``` - -Each `defineTool()` creates a `ToolDef` with typed params. The `registry.ts` collects all into `ALL_TOOLS[]`. The `ai-adapter.ts` converts them to Vercel AI SDK format with valibot validation schemas, injecting `FigmaAPI` at execution time via `getFigma()`. - -## Start Here - -- **`packages/core/src/tools/registry.ts`** — the central manifest of all 84 tools, organized by domain. Shows the full tool surface and imports from all domain files. -- **`packages/core/src/tools/ai-adapter.ts`** — the bridge between tool definitions and the AI chat system. Key to understanding how tools are exposed to LLMs. diff --git a/bun.lock b/bun.lock index c1133c601..dabb23fd2 100644 --- a/bun.lock +++ b/bun.lock @@ -33,6 +33,7 @@ "fzstd": "^0.1.1", "lib0": "^0.2.117", "motion-v": "^2.0.0", + "opentype.js": "^1.3.4", "prismjs": "^1.30.0", "reka-ui": "^2.9.0", "tailwind-merge": "^3.5.0", @@ -59,6 +60,7 @@ "@tauri-apps/cli": "^2", "@types/culori": "^4.0.1", "@types/node": "^25.3.2", + "@types/opentype.js": "^1.3.9", "@types/prismjs": "^1.26.6", "@types/ws": "^8.18.1", "@typescript/native-preview": "^7.0.0-dev.20260228.1", @@ -1071,6 +1073,8 @@ "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + "@types/opentype.js": ["@types/opentype.js@1.3.9", "", {}, "sha512-KOGywvDPncA4/tTWV5xKNhjpsoSSAHIx3mHOhL5l3XX+c6Xu2dQnHvGs7mRNQsQRte1EqmQ0cPQQ8Z14lkv+yw=="], + "@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="], "@types/readable-stream": ["@types/readable-stream@4.0.23", "", { "dependencies": { "@types/node": "*" } }, "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig=="], @@ -2021,6 +2025,8 @@ "oniguruma-to-es": ["oniguruma-to-es@3.1.1", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ=="], + "opentype.js": ["opentype.js@1.3.4", "", { "dependencies": { "string.prototype.codepointat": "^0.2.1", "tiny-inflate": "^1.0.3" }, "bin": { "ot": "bin/ot" } }, "sha512-d2JE9RP/6uagpQAVtJoF0pJJA/fgai89Cc50Yp0EJHk+eLp6QQ7gBoblsnubRULNY132I0J1QKMJ+JTbMqz4sw=="], + "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], "oxfmt": ["oxfmt@0.35.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.35.0", "@oxfmt/binding-android-arm64": "0.35.0", "@oxfmt/binding-darwin-arm64": "0.35.0", "@oxfmt/binding-darwin-x64": "0.35.0", "@oxfmt/binding-freebsd-x64": "0.35.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.35.0", "@oxfmt/binding-linux-arm-musleabihf": "0.35.0", "@oxfmt/binding-linux-arm64-gnu": "0.35.0", "@oxfmt/binding-linux-arm64-musl": "0.35.0", "@oxfmt/binding-linux-ppc64-gnu": "0.35.0", "@oxfmt/binding-linux-riscv64-gnu": "0.35.0", "@oxfmt/binding-linux-riscv64-musl": "0.35.0", "@oxfmt/binding-linux-s390x-gnu": "0.35.0", "@oxfmt/binding-linux-x64-gnu": "0.35.0", "@oxfmt/binding-linux-x64-musl": "0.35.0", "@oxfmt/binding-openharmony-arm64": "0.35.0", "@oxfmt/binding-win32-arm64-msvc": "0.35.0", "@oxfmt/binding-win32-ia32-msvc": "0.35.0", "@oxfmt/binding-win32-x64-msvc": "0.35.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-QYeXWkP+aLt7utt5SLivNIk09glWx9QE235ODjgcEZ3sd1VMaUBSpLymh6ZRCA76gD2rMP4bXanUz/fx+nLM9Q=="], @@ -2279,6 +2285,8 @@ "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "string.prototype.codepointat": ["string.prototype.codepointat@0.2.1", "", {}, "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg=="], + "string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="], "string.prototype.trim": ["string.prototype.trim@1.2.10", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="], @@ -2331,6 +2339,8 @@ "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], + "tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], diff --git a/package.json b/package.json index 37373e72e..98898977a 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "fzstd": "^0.1.1", "lib0": "^0.2.117", "motion-v": "^2.0.0", + "opentype.js": "^1.3.4", "prismjs": "^1.30.0", "reka-ui": "^2.9.0", "tailwind-merge": "^3.5.0", @@ -84,6 +85,7 @@ "@tauri-apps/cli": "^2", "@types/culori": "^4.0.1", "@types/node": "^25.3.2", + "@types/opentype.js": "^1.3.9", "@types/prismjs": "^1.26.6", "@types/ws": "^8.18.1", "@typescript/native-preview": "^7.0.0-dev.20260228.1", diff --git a/packages/core/src/canvas/text.ts b/packages/core/src/canvas/text.ts index 1731a8d7b..f66d90796 100644 --- a/packages/core/src/canvas/text.ts +++ b/packages/core/src/canvas/text.ts @@ -1,7 +1,13 @@ import { resolveRGBAForPreview } from '../color/management' +import { getCanvasKit } from '../canvaskit' import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from '../constants' import { resolveNodeTextDirection } from '../text/direction' -import { getArabicFallbackFamilies, getCJKFallbackFamilies, isFontLoaded } from '../text/fonts' +import { + getArabicFallbackFamilies, + getCJKFallbackFamilies, + getFontProvider, + isFontLoaded +} from '../text/fonts' import type { SceneNode } from '../scene-graph' import type { CanvasKit, FontWeight, Paragraph, TypefaceFontProvider } from 'canvaskit-wasm' @@ -12,6 +18,23 @@ interface TextRenderer { fontsLoaded: boolean } +export interface ClipboardShapedGlyph { + glyphIndex: number + firstCharacter: number + x: number + y: number + advance: number +} + +export interface ClipboardShapedText { + lineHeight: number + lineAscent: number + lineWidth: number + baseline: number + glyphs: ClipboardShapedGlyph[] + logicalIndexToCharacterOffsetMap: number[] +} + export function isNodeFontLoaded(_r: TextRenderer, node: SceneNode): boolean { const families = new Set() families.add(node.fontFamily || DEFAULT_FONT_FAMILY) @@ -224,3 +247,60 @@ export function buildParagraph( builder.delete() return paragraph } + +export async function shapeTextForClipboard(node: SceneNode): Promise { + const ck = await getCanvasKit() + const fontProvider = getFontProvider() + if (!fontProvider) return null + + const paragraph = buildParagraph({ ck, fontProvider, fontsLoaded: true }, node) + const shapedLines = paragraph.getShapedLines() + const lineMetrics = paragraph.getLineMetrics() + const firstLine = shapedLines[0] + const firstMetrics = lineMetrics[0] + + const glyphs: ClipboardShapedGlyph[] = [] + const logicalIndexToCharacterOffsetMap = Array.from({ length: node.text.length + 1 }, () => 0) + + for (const run of firstLine.runs) { + const positions = run.positions + for (let i = 0; i < run.glyphs.length; i++) { + const x = positions[i * 2] ?? 0 + const y = positions[i * 2 + 1] ?? firstLine.baseline + const nextX = positions[(i + 1) * 2] ?? x + const firstCharacter = run.offsets[i] ?? i + glyphs.push({ + glyphIndex: i, + firstCharacter, + x, + y, + advance: nextX - x + }) + if (firstCharacter >= 0 && firstCharacter < logicalIndexToCharacterOffsetMap.length) { + logicalIndexToCharacterOffsetMap[firstCharacter] = x + } + } + const finalOffset = run.offsets[run.offsets.length - 1] + const finalX = positions[positions.length - 2] ?? firstMetrics.width + if (finalOffset >= 0 && finalOffset < logicalIndexToCharacterOffsetMap.length) { + logicalIndexToCharacterOffsetMap[finalOffset] = finalX + } + } + + for (let i = 1; i < logicalIndexToCharacterOffsetMap.length; i++) { + if (logicalIndexToCharacterOffsetMap[i] === 0) { + logicalIndexToCharacterOffsetMap[i] = logicalIndexToCharacterOffsetMap[i - 1] + } + } + + paragraph.delete() + + return { + lineHeight: firstMetrics.height, + lineAscent: Math.abs(firstMetrics.ascent), + lineWidth: firstMetrics.width, + baseline: firstMetrics.baseline, + glyphs, + logicalIndexToCharacterOffsetMap + } +} diff --git a/packages/core/src/clipboard-text-outlines.ts b/packages/core/src/clipboard-text-outlines.ts new file mode 100644 index 000000000..1ef94fcf6 --- /dev/null +++ b/packages/core/src/clipboard-text-outlines.ts @@ -0,0 +1,98 @@ +import * as OpenTypeSync from 'opentype.js' + +import { getLoadedFontData } from './text/fonts' + +interface OutlineCommand { + type: string + x?: number + y?: number + x1?: number + y1?: number + x2?: number + y2?: number +} + +interface OutlinePath { + commands: OutlineCommand[] +} + +interface OutlineGlyph { + path: OutlinePath + getPath(x: number, y: number, fontSize: number): OutlinePath +} + +interface OutlineFont { + unitsPerEm: number + stringToGlyphs(text: string): OutlineGlyph[] +} + +export interface GlyphOutlineProbe { + family: string + style: string + unitsPerEm: number + commandCount: number + firstGlyphCommandSample: OutlineCommand[] +} + +interface OpenTypeModule { + parse(buffer: ArrayBuffer): OutlineFont +} + +let openTypeModulePromise: Promise | null = null + +async function loadOpenTypeModule() { + openTypeModulePromise ??= import('opentype.js') as Promise + return openTypeModulePromise +} + +function commandsToFigmaNumbers(commands: OutlineCommand[]): Array { + const result: Array = [] + for (const command of commands) { + result.push(command.type) + if (command.x1 !== undefined) result.push(command.x1) + if (command.y1 !== undefined) result.push(command.y1) + if (command.x2 !== undefined) result.push(command.x2) + if (command.y2 !== undefined) result.push(command.y2) + if (command.x !== undefined) result.push(command.x) + if (command.y !== undefined) result.push(command.y) + } + return result +} + +export function getGlyphOutlineCommandsSync( + family: string, + style: string, + text: string, + fontSize: number +): Array> | null { + const bytes = getLoadedFontData(family, style) + if (!bytes) return null + + const font = (OpenTypeSync as OpenTypeModule).parse(bytes.slice(0)) + const glyphs = font.stringToGlyphs(text) + return glyphs.map((glyph) => commandsToFigmaNumbers(glyph.getPath(0, 0, fontSize).commands)) +} + +export async function probeGlyphOutlineCommands( + family: string, + style: string, + text: string, + fontSize: number +): Promise { + const bytes = getLoadedFontData(family, style) + if (!bytes) return null + + const openType = await loadOpenTypeModule() + const font = openType.parse(bytes.slice(0)) + const glyphs = font.stringToGlyphs(text) + const firstGlyph = glyphs.find((glyph: OutlineGlyph) => glyph.path.commands.length > 0) + const firstGlyphCommandSample = (firstGlyph?.getPath(0, 0, fontSize).commands ?? []).slice(0, 12) + + return { + family, + style, + unitsPerEm: font.unitsPerEm, + commandCount: firstGlyph?.path.commands.length ?? 0, + firstGlyphCommandSample + } +} diff --git a/packages/core/src/clipboard.ts b/packages/core/src/clipboard.ts index 1e5aec126..e99770132 100644 --- a/packages/core/src/clipboard.ts +++ b/packages/core/src/clipboard.ts @@ -10,8 +10,10 @@ import { parseFigKiwiChunks, decompressFigKiwiDataAsync, makeDocumentNodeChange, - makeCanvasNodeChange + makeCanvasNodeChange, + buildFontDigestMap } from './kiwi/serialize' +import { buildDerivedTextDataV4 } from './kiwi/serialize-clipboard-v4' import { randomInt } from './random' import type { NodeChange as KiwiNodeChange } from './kiwi/codec' @@ -285,9 +287,13 @@ export function importClipboardNodes( return createdIds } -export function buildFigmaClipboardHTML(nodes: SceneNode[], graph: SceneGraph): string | null { +export async function buildFigmaClipboardHTML( + nodes: SceneNode[], + graph: SceneGraph +): Promise { const compiled = getCompiledSchema() const schemaDeflated = deflateSync(getSchemaBytes()) + const fontDigestMap = await buildFontDigestMap(graph) const docGuid = { sessionID: 0, localID: 0 } const canvasGuid = { sessionID: 0, localID: 1 } @@ -298,11 +304,34 @@ export function buildFigmaClipboardHTML(nodes: SceneNode[], graph: SceneGraph): makeCanvasNodeChange(canvasGuid, docGuid, '!', 'Page 1') ] + const exportedTextNodes: SceneNode[] = [] + const collectTextNodes = (node: SceneNode) => { + if (node.type === 'TEXT') exportedTextNodes.push(node) + for (const childId of node.childIds) { + const child = graph.getNode(childId) + if (child) collectTextNodes(child) + } + } + const blobs: Uint8Array[] = [] for (let i = 0; i < nodes.length; i++) { - nodeChanges.push(...sceneNodeToKiwi(nodes[i], canvasGuid, i, localIdCounter, graph, blobs)) + collectTextNodes(nodes[i]) + nodeChanges.push( + ...sceneNodeToKiwi(nodes[i], canvasGuid, i, localIdCounter, graph, blobs, undefined, fontDigestMap) + ) } + const textNodeQueue = [...exportedTextNodes] + await Promise.all( + nodeChanges.map(async (change) => { + if (change.type !== 'TEXT') return + const source = textNodeQueue.shift() + if (!source) return + change.textUserLayoutVersion = 4 + change.derivedTextData = await buildDerivedTextDataV4(source, fontDigestMap) + }) + ) + const msg: Record = { type: 'NODE_CHANGES', sessionID: 0, diff --git a/packages/core/src/editor/clipboard.ts b/packages/core/src/editor/clipboard.ts index e565e785f..df3d7d212 100644 --- a/packages/core/src/editor/clipboard.ts +++ b/packages/core/src/editor/clipboard.ts @@ -116,11 +116,11 @@ export function createClipboardActions(ctx: EditorContext) { } } - function writeCopyData(clipboardData: DataTransfer, selectedNodes: SceneNode[]) { + async function writeCopyData(clipboardData: DataTransfer, selectedNodes: SceneNode[]) { if (selectedNodes.length === 0) return const names = selectedNodes.map((n) => n.name).join('\n') - const html = buildFigmaClipboardHTML(selectedNodes, ctx.graph) + const html = await buildFigmaClipboardHTML(selectedNodes, ctx.graph) if (html) clipboardData.setData('text/html', html) clipboardData.setData('text/plain', names) } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 101b74e93..e2681e6b7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -289,10 +289,12 @@ export { parseFigKiwiChunks, decompressFigKiwiData, decompressFigKiwiDataAsync, + buildFontDigestMap, sceneNodeToKiwi, fractionalPosition, mapToFigmaType } from './kiwi/serialize' +export { buildDerivedTextDataV4 } from './kiwi/serialize-clipboard-v4' export { createElement, @@ -339,6 +341,7 @@ export { type TextPictureBuilder, type OpenPencilClipboardData } from './clipboard' +export { probeGlyphOutlineCommands, type GlyphOutlineProbe } from './clipboard-text-outlines' export { readPenFile, parsePenFile } from './io/formats/pen' diff --git a/packages/core/src/kiwi/codec.ts b/packages/core/src/kiwi/codec.ts index e1f27e344..aeb4ba924 100644 --- a/packages/core/src/kiwi/codec.ts +++ b/packages/core/src/kiwi/codec.ts @@ -320,6 +320,22 @@ export interface NodeChange { } derivedTextData?: { layoutSize?: Vector + baselines?: Array<{ + firstCharacter: number + endCharacter: number + position: Vector + width: number + lineHeight: number + lineAscent: number + }> + glyphs?: Array<{ + commands: Array + position: Vector + fontSize: number + firstCharacter: number + advance: number + rotation: number + }> fontMetaData?: Array<{ key: { family: string; style: string; postscript?: string } fontLineHeight: number @@ -327,6 +343,8 @@ export interface NodeChange { fontStyle?: string fontWeight?: number }> + logicalIndexToCharacterOffsetMap?: number[] + derivedLines?: Array<{ directionality: 'LTR' | 'RTL' }> truncationStartIndex?: number truncatedHeight?: number } diff --git a/packages/core/src/kiwi/serialize-clipboard-v4.ts b/packages/core/src/kiwi/serialize-clipboard-v4.ts new file mode 100644 index 000000000..bb18d5d2d --- /dev/null +++ b/packages/core/src/kiwi/serialize-clipboard-v4.ts @@ -0,0 +1,75 @@ +import { getGlyphOutlineCommandsSync } from '../clipboard-text-outlines' +import { normalizeFontFamily, weightToStyle } from '../text/fonts' + +import type { SceneNode } from '../scene-graph' +import type { NodeChange } from './codec' + +export interface ShapedClipboardText { + lineHeight: number + lineAscent: number + lineWidth: number + baseline: number + glyphs: Array<{ + firstCharacter: number + x: number + y: number + advance: number + }> + logicalIndexToCharacterOffsetMap: number[] +} + +export async function buildDerivedTextDataV4( + node: SceneNode, + digestMap: Map, + shaped?: ShapedClipboardText | null +): 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 glyphCommandLists = getGlyphOutlineCommandsSync(node.fontFamily, style, node.text, node.fontSize) ?? [] + + const glyphs = glyphCommandLists.map((commands, index) => { + const shapedGlyph = shaped?.glyphs[index] + return { + commands, + position: { + x: shapedGlyph?.x ?? 0, + y: shapedGlyph?.y ?? (shaped?.baseline ?? lineHeightFallback) + }, + fontSize: node.fontSize, + firstCharacter: shapedGlyph?.firstCharacter ?? index, + advance: shapedGlyph?.advance ?? 0, + rotation: 0 + } + }) + + return { + layoutSize: { x: node.width, y: node.height }, + baselines: [ + { + firstCharacter: 0, + endCharacter: Math.max(node.text.length - 1, 0), + position: { x: 0, y: shaped?.baseline ?? lineHeightFallback }, + width: shaped?.lineWidth ?? node.width, + lineHeight: shaped?.lineHeight ?? lineHeightFallback, + lineAscent: shaped?.lineAscent ?? Math.max(lineHeightFallback - (node.fontSize * 0.2), 0) + } + ], + glyphs, + fontMetaData: [ + { + key: { family: normalizedFamily, style, postscript: '' }, + fontLineHeight: 1.2, + fontDigest: digestMap.get(key), + fontStyle: node.italic ? 'ITALIC' : 'NORMAL', + fontWeight: node.fontWeight + } + ], + logicalIndexToCharacterOffsetMap: + shaped?.logicalIndexToCharacterOffsetMap ?? Array.from({ length: node.text.length + 1 }, () => 0), + derivedLines: [{ directionality: 'LTR' }], + truncationStartIndex: -1, + truncatedHeight: -1 + } +} diff --git a/packages/core/src/kiwi/serialize.ts b/packages/core/src/kiwi/serialize.ts index bd0bcf0f3..f08ba86aa 100644 --- a/packages/core/src/kiwi/serialize.ts +++ b/packages/core/src/kiwi/serialize.ts @@ -2,6 +2,7 @@ export const FIG_KIWI_DEFAULT_VERSION = 101 import { deflateSync, inflateSync } from 'fflate' +import { getGlyphOutlineCommandsSync } from '../clipboard-text-outlines' import { getLoadedFontData, normalizeFontFamily, weightToStyle } from '../text/fonts' import { encodeVectorNetworkBlob, buildStyleOverrideTable } from '../vector' import { stringToGuid, VARIABLE_BINDING_FIELDS } from './convert' @@ -206,9 +207,42 @@ function buildDerivedTextData( ) } + const style = weightToStyle(node.fontWeight, node.italic) + const glyphCommandLists = getGlyphOutlineCommandsSync(node.fontFamily, style, node.text, node.fontSize) ?? [] + 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 glyphs = glyphCommandLists.map((commands, index) => ({ + commands, + position: { x: index * glyphAdvance, y: lineHeight }, + fontSize: node.fontSize, + firstCharacter: index, + advance: glyphAdvance, + rotation: 0 + })) + + const logicalIndexToCharacterOffsetMap = Array.from({ length: node.text.length + 1 }, (_, index) => + index * glyphAdvance + ) + return { layoutSize: { x: node.width, y: node.height }, - fontMetaData: fontMeta + baselines: [ + { + firstCharacter: 0, + endCharacter: Math.max(node.text.length - 1, 0), + position: { x: 0, y: lineHeight }, + width: node.width, + lineHeight, + lineAscent: Math.max(lineHeight - (node.fontSize * 0.2), 0) + } + ], + glyphs, + fontMetaData: fontMeta, + logicalIndexToCharacterOffsetMap, + derivedLines: [{ directionality: 'LTR' }], + truncationStartIndex: -1, + truncatedHeight: -1 } } @@ -341,7 +375,7 @@ function serializeTextProps( const autoResize = resolveTextAutoResize(node, graph) if (autoResize !== 'NONE') nc.textAutoResize = autoResize nc.textAlignHorizontal = node.textAlignHorizontal - nc.textUserLayoutVersion = 3 + nc.textUserLayoutVersion = 4 if (fontDigestMap) nc.derivedTextData = buildDerivedTextData(node, fontDigestMap) // Figma needs explicit lineHeight to compute text bounding boxes. // Without it (and without baselines/glyphs data), text gets 0 height. diff --git a/packages/core/src/text/fonts.ts b/packages/core/src/text/fonts.ts index 238d7dfc9..aa4e93b3e 100644 --- a/packages/core/src/text/fonts.ts +++ b/packages/core/src/text/fonts.ts @@ -160,7 +160,7 @@ export async function fetchBundledFont(url: string): Promise } const { readFile } = await import('node:fs/promises') const { fileURLToPath } = await import('node:url') - const assetPath = fileURLToPath(new URL(`../assets${url}`, import.meta.url)) + const assetPath = fileURLToPath(new URL(`../../assets${url}`, import.meta.url)) const buf = await readFile(assetPath) return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) } diff --git a/packages/core/src/types-opentype.d.ts b/packages/core/src/types-opentype.d.ts new file mode 100644 index 000000000..294eaa519 --- /dev/null +++ b/packages/core/src/types-opentype.d.ts @@ -0,0 +1,27 @@ +declare module 'opentype.js' { + export interface OpenTypePathCommand { + type: string + x?: number + y?: number + x1?: number + y1?: number + x2?: number + y2?: number + } + + export interface OpenTypeGlyphPath { + commands: OpenTypePathCommand[] + } + + export interface OpenTypeGlyph { + path: OpenTypeGlyphPath + getPath(x: number, y: number, fontSize: number): OpenTypeGlyphPath + } + + export interface OpenTypeFont { + unitsPerEm: number + stringToGlyphs(text: string): OpenTypeGlyph[] + } + + export function parse(buffer: ArrayBuffer): OpenTypeFont +} diff --git a/src/composables/use-keyboard.ts b/src/composables/use-keyboard.ts index 98fd0e868..cfcda0775 100644 --- a/src/composables/use-keyboard.ts +++ b/src/composables/use-keyboard.ts @@ -65,13 +65,13 @@ export function useKeyboard() { useEventListener(window, 'copy', (e: ClipboardEvent) => { if (isEditing(e)) return e.preventDefault() - if (e.clipboardData) store.writeCopyData(e.clipboardData) + if (e.clipboardData) void store.writeCopyData(e.clipboardData) }) useEventListener(window, 'cut', (e: ClipboardEvent) => { if (isEditing(e)) return e.preventDefault() - if (e.clipboardData) store.writeCopyData(e.clipboardData) + if (e.clipboardData) void store.writeCopyData(e.clipboardData) store.deleteSelected() }) diff --git a/src/stores/editor.ts b/src/stores/editor.ts index 6a312439b..d657a9a20 100644 --- a/src/stores/editor.ts +++ b/src/stores/editor.ts @@ -1186,14 +1186,14 @@ export function createEditorStore(initialGraph?: SceneGraph) { // ─── Mobile clipboard ───────────────────────────────────────── - function mobileCopy() { + async function mobileCopy() { const transfer = new DataTransfer() - editor.writeCopyData(transfer) + await editor.writeCopyData(transfer) state.clipboardHtml = transfer.getData('text/html') } - function mobileCut() { - mobileCopy() + async function mobileCut() { + await mobileCopy() editor.deleteSelected() } diff --git a/tests/engine/clipboard-derived-text.test.ts b/tests/engine/clipboard-derived-text.test.ts new file mode 100644 index 000000000..49c117a03 --- /dev/null +++ b/tests/engine/clipboard-derived-text.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from 'bun:test' + +import { + fetchBundledFont, + markFontLoaded, + SceneGraph, + buildFontDigestMap, + buildDerivedTextDataV4, + initCodec +} from '@open-pencil/core' + +describe('clipboard derived text export', () => { + test('builds richer v4 derivedTextData from shaped text + glyph outlines', async () => { + await initCodec() + + const font = await fetchBundledFont('/Inter-Regular.ttf') + expect(font).toBeTruthy() + if (!font) return + markFontLoaded('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 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] + }) + + expect(derived?.fontMetaData?.length).toBeGreaterThan(0) + expect(derived?.glyphs?.length).toBeGreaterThan(0) + expect(derived?.baselines?.length).toBeGreaterThan(0) + expect(derived?.logicalIndexToCharacterOffsetMap?.length).toBe(text.text.length + 1) + expect(derived?.logicalIndexToCharacterOffsetMap?.[5]).toBe(42) + expect(derived?.derivedLines?.[0]?.directionality).toBe('LTR') + expect(derived?.truncationStartIndex).toBe(-1) + expect(derived?.truncatedHeight).toBe(-1) + expect(derived?.glyphs?.[0]?.commands.length).toBeGreaterThan(0) + expect(derived?.glyphs?.[0]?.position.x).toBe(0) + expect(derived?.glyphs?.[4]?.position.x).toBe(32) + expect(derived?.baselines?.[0]?.lineHeight).toBe(20) + expect(derived?.baselines?.[0]?.lineAscent).toBe(15) + expect(derived?.baselines?.[0]?.width).toBe(42) + }) +}) diff --git a/tests/engine/clipboard-text-outlines.test.ts b/tests/engine/clipboard-text-outlines.test.ts new file mode 100644 index 000000000..7523f518c --- /dev/null +++ b/tests/engine/clipboard-text-outlines.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'bun:test' + +import { fetchBundledFont, markFontLoaded, probeGlyphOutlineCommands } from '@open-pencil/core' + +describe('clipboard text outline probe', () => { + test('lazy-loads opentype.js and extracts glyph commands from a loaded font', async () => { + const font = await fetchBundledFont('/Inter-Regular.ttf') + expect(font).toBeTruthy() + if (!font) return + + markFontLoaded('Inter', 'Regular', font) + + const probe = await probeGlyphOutlineCommands('Inter', 'Regular', 'Hello', 16) + expect(probe).toBeTruthy() + expect(probe?.family).toBe('Inter') + expect(probe?.style).toBe('Regular') + expect(probe?.unitsPerEm).toBeGreaterThan(0) + expect(probe?.commandCount).toBeGreaterThan(0) + expect(probe?.firstGlyphCommandSample.length).toBeGreaterThan(0) + }) +}) diff --git a/tests/engine/clipboard.test.ts b/tests/engine/clipboard.test.ts index 429aba7f8..bccb32a90 100644 --- a/tests/engine/clipboard.test.ts +++ b/tests/engine/clipboard.test.ts @@ -598,7 +598,7 @@ describe('buildFigmaClipboardHTML', () => { await initCodec() }) - it('encodes a simple frame without throwing', () => { + it('encodes a simple frame without throwing', async () => { const graph = new SceneGraph() const page = graph.getPages()[0] const frame = graph.createNode('FRAME', page.id, { @@ -607,12 +607,12 @@ describe('buildFigmaClipboardHTML', () => { fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }], }) - const html = buildFigmaClipboardHTML([frame], graph) + const html = await buildFigmaClipboardHTML([frame], graph) expect(html).toContain('figmeta') expect(html).toContain('figma') }) - it('encodes text nodes with style runs', () => { + it('encodes text nodes with style runs', async () => { const graph = new SceneGraph() const page = graph.getPages()[0] const text = graph.createNode('TEXT', page.id, { @@ -628,11 +628,17 @@ describe('buildFigmaClipboardHTML', () => { ], }) - const html = buildFigmaClipboardHTML([text], graph) + const html = await buildFigmaClipboardHTML([text], graph) expect(html).toContain('figmeta') + + const parsed = await parseFigmaClipboard(html) + const textNode = parsed?.nodes.find((node) => node.type === 'TEXT') + expect(textNode?.textUserLayoutVersion).toBe(4) + expect(textNode?.derivedTextData?.glyphs).toBeDefined() + expect(textNode?.derivedTextData?.baselines?.length).toBeGreaterThan(0) }) - it('encodes auto-layout frames', () => { + it('encodes auto-layout frames', async () => { const graph = new SceneGraph() const page = graph.getPages()[0] const frame = graph.createNode('FRAME', page.id, { @@ -649,7 +655,7 @@ describe('buildFigmaClipboardHTML', () => { x: 0, y: 0, width: 50, height: 50, }) - const html = buildFigmaClipboardHTML([frame], graph) + const html = await buildFigmaClipboardHTML([frame], graph) expect(html).toContain('figmeta') }) @@ -682,7 +688,7 @@ describe('buildFigmaClipboardHTML', () => { fontSize: 14, }) - const html = buildFigmaClipboardHTML([frame], graph) + const html = await buildFigmaClipboardHTML([frame], graph) expect(html).not.toBeNull() const parsed = await parseFigmaClipboard(html!) @@ -767,7 +773,7 @@ describe('gold-preview.fig clipboard roundtrip', () => { }) it('Figma format: preserves node count, clipsContent, constraints, arcData, layoutAlignSelf', async () => { - const html = buildFigmaClipboardHTML(topLevelNodes, graph) + const html = await buildFigmaClipboardHTML(topLevelNodes, graph) expect(html).not.toBeNull() const parsed = await parseFigmaClipboard(html!) diff --git a/tests/engine/fig-roundtrip.test.ts b/tests/engine/fig-roundtrip.test.ts index 217aead23..2da50ef78 100644 --- a/tests/engine/fig-roundtrip.test.ts +++ b/tests/engine/fig-roundtrip.test.ts @@ -946,7 +946,7 @@ describe('text node export', () => { expect(textNc.textData.lines.length).toBeGreaterThanOrEqual(1) expect(textNc.textData.lines[0].lineType).toBe('PLAIN') - expect(textNc.textUserLayoutVersion).toBe(3) + expect(textNc.textUserLayoutVersion).toBe(4) expect(textNc.derivedTextData).toBeDefined() expect(textNc.derivedTextData.layoutSize).toBeDefined()