Add clipboard text v4 export (#164)

* chore: remove unused pi context file

- Delete .pi/context.md
- Remove an unused local context artifact from the repository

* Add clipboard text v4 export

* fix(clipboard): rebase v4 export branch onto master

- Resolve conflicts after the core text module reorganization
- Fix clipboard text export imports and bundled font asset loading
- Update raw binary expectations for textUserLayoutVersion 4
- Re-run check and unit tests after the rebase
This commit is contained in:
Danila Poyarkov 2026-04-08 15:35:00 +03:00 committed by GitHub
parent 825d3bbe49
commit 28272ebda0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 491 additions and 134 deletions

View file

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

View file

@ -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=="],

View file

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

View file

@ -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<string>()
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<ClipboardShapedText | null> {
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
}
}

View file

@ -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<OpenTypeModule> | null = null
async function loadOpenTypeModule() {
openTypeModulePromise ??= import('opentype.js') as Promise<typeof OpenTypeSync & OpenTypeModule>
return openTypeModulePromise
}
function commandsToFigmaNumbers(commands: OutlineCommand[]): Array<string | number> {
const result: Array<string | number> = []
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<Array<string | number>> | 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<GlyphOutlineProbe | null> {
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
}
}

View file

@ -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<string | null> {
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<string, unknown> = {
type: 'NODE_CHANGES',
sessionID: 0,

View file

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

View file

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

View file

@ -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<string | number>
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
}

View file

@ -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<string, Uint8Array>,
shaped?: ShapedClipboardText | null
): Promise<NodeChange['derivedTextData']> {
const style = weightToStyle(node.fontWeight, node.italic)
const normalizedFamily = normalizeFontFamily(node.fontFamily)
const key = `${normalizedFamily}|${style}`
const lineHeightFallback = node.lineHeight ?? Math.ceil(node.fontSize * 1.2)
const 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
}
}

View file

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

View file

@ -160,7 +160,7 @@ export async function fetchBundledFont(url: string): Promise<ArrayBuffer | null>
}
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)
}

27
packages/core/src/types-opentype.d.ts vendored Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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