Merge branch 'open-pencil:master' into master

This commit is contained in:
Santos Alarcón Asensio 2026-05-22 11:27:48 +02:00 committed by GitHub
commit 6b2de4ad7a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
155 changed files with 2852 additions and 918 deletions

View file

@ -268,7 +268,7 @@ OpenPencil follows a Reka UI-inspired component namespace structure:
- No module-level mutable state in components — use the editor store
- Prefer `tw-animate-css` for animations — don't hand-write `<style>` transition keyframes
- No duplicated component logic — if two components share data (icon maps, util functions, constants), export from one place and import in both
- `packages/core/src/kiwi/kiwi-schema/` is vendored — don't modify
- `packages/core/src/kiwi/schema-runtime/` contains the vendored Kiwi codec runtime; keep runtime changes minimal and prefer wrappers/helpers for project-specific validation
- Core code must guard browser APIs: `typeof window !== 'undefined'`, `typeof document === 'undefined'`
- Constants in `src/constants.ts` — no magic numbers in components or composables
@ -375,8 +375,9 @@ Self-review checklist:
## Publishing
- `bun publish` from package dirs — resolves `workspace:*` → actual versions
- Core: `prepublishOnly` runs `tsc` to build `dist/` for Node.js consumers
- CLI requires Bun runtime (`#!/usr/bin/env bun`)
- Public packages publish built `dist/` output, not runtime TypeScript entrypoints
- Core, Vue, MCP, and CLI build with tsdown before publishing
- CLI publishes a Node-compatible `bin/openpencil.js` wrapper; do not point package `bin` entries at TypeScript source
## Reference

View file

@ -2,6 +2,24 @@
## Unreleased
### Fixes
- Greatly improve importing Figma `.fig` files with complex component systems: badges, avatars, icons, links, input fields, lists, date pickers, nested instances, component swaps, and variant properties now open much closer to their original Figma appearance.
- Fix missing or white content in imported `.fig` files caused by unresolved Figma variable bindings, including image/avatar badges, icon colors, text colors, and variable-backed component overrides.
- Preserve more Figma document details when opening and saving `.fig` files, including internal component pages, component ordering, page metadata, canvas backgrounds, text layout, glyph rendering, vector geometry, effects, shadows, and instance overrides.
- Keep user edits after opening an imported `.fig` file: changing size, position, fills, text, or layout now wins over preserved Figma round-trip data when the document is saved again.
- Fix `.fig` exports so files reopened in Figma or OpenPencil keep their pages, components, instances, text wrapping, icons, avatars, and preview thumbnail intact.
- Fix live canvas updates during move/resize/edit previews so visible scene changes repaint immediately.
- Fix accidental duplicate creation when Alt-clicking without dragging.
- Fix MCP startup in the browser.
- Fix CanvasKit loading outside the browser when project paths contain spaces.
### Performance
- Open large `.fig` files faster by deferring work for pages you have not viewed yet while still preparing all needed content before export.
- Improve canvas responsiveness during zooming, panning, dragging, and editing by reusing cached scene backing where safe.
- Speed up `.fig` export for documents with many preserved Figma paint and variable payloads.
## 0.12.2 — 2026-05-19
### Added

View file

@ -36,7 +36,8 @@ Or download from the [releases page](https://github.com/open-pencil/open-pencil/
## CLI
```sh
bun add -g @open-pencil/cli
npm install -g @open-pencil/cli
# or: bun add -g @open-pencil/cli
```
### Inspect design files

View file

@ -98,9 +98,9 @@
},
"packages/cli": {
"name": "@open-pencil/cli",
"version": "0.12.0",
"version": "0.12.2",
"bin": {
"openpencil": "./src/index.ts",
"openpencil": "./bin/openpencil.js",
},
"dependencies": {
"@open-pencil/core": "workspace:*",
@ -114,9 +114,11 @@
},
"packages/core": {
"name": "@open-pencil/core",
"version": "0.12.0",
"version": "0.12.2",
"dependencies": {
"@chenglou/pretext": "^0.0.7",
"@iconify/utils": "^3.1.0",
"@tauri-apps/api": "^2",
"acorn": "^8.16.0",
"canvaskit-wasm": "^0.40.0",
"culori": "^4.0.2",
@ -126,9 +128,11 @@
"fflate": "^0.8.2",
"fontoxpath": "^3.34.0",
"fzstd": "^0.1.1",
"jspdf": "^4.2.1",
"nanoevents": "^9.1.0",
"opentype.js": "^1.3.4",
"sucrase": "^3.35.1",
"svg2pdf.js": "^2.7.0",
"svgpath": "^2.6.0",
"twirlwind": "^0.3.0",
"yoga-layout": "npm:@open-pencil/yoga-layout@3.3.0-grid.3",
@ -152,10 +156,10 @@
},
"packages/mcp": {
"name": "@open-pencil/mcp",
"version": "0.12.0",
"version": "0.12.2",
"bin": {
"openpencil-mcp": "./dist/stdio.js",
"openpencil-mcp-http": "./dist/index.js",
"openpencil-mcp": "./bin/openpencil-mcp.js",
"openpencil-mcp-http": "./bin/openpencil-mcp-http.js",
},
"dependencies": {
"@hono/node-server": "^1.19.9",
@ -173,12 +177,16 @@
},
"packages/vue": {
"name": "@open-pencil/vue",
"version": "0.12.0",
"version": "0.12.2",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.9",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0",
"@nanostores/i18n": "^1.2.2",
"@nanostores/vue": "^1.1.0",
"@tanstack/vue-table": "^8.21.3",
"@vueuse/core": "^14.2.1",
"nanostores": "^1.2.0",
"reka-ui": "^2.9.0",
},
"devDependencies": {
"tsdown": "^0.21.7",

View file

@ -415,7 +415,7 @@
},
{
"files": [
"**/kiwi/kiwi-schema/**"
"**/kiwi/schema-runtime/**"
],
"rules": {
"typescript/no-explicit-any": "off",

View file

@ -9,14 +9,15 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "bun run lint && vite build",
"build": "bun run build:packages && bun run lint && vite build",
"preview": "vite preview",
"tauri": "tauri",
"lint": "bun run lint:structure && oxlint -c oxlint.json --type-aware --type-check src/ packages/core/src/ packages/vue/src/ packages/cli/src/ packages/mcp/src/",
"lint:structure": "oxlint -c oxlint.json vite.config.ts vite/ src/ packages/core/src/ packages/vue/src/ packages/cli/src/ packages/mcp/src/ tests/ scripts/",
"format": "oxfmt --write .oxfmtrc.json vite.config.ts vite/ src/ packages/core/src/ packages/cli/src/ packages/mcp/src/ packages/vue/src/ tests scripts/",
"check": "bun run lint && tsgo --noEmit && bun run check:vue && bun run check:i18n && bun run check:arch && bun run test:dupes",
"check": "bun run build:packages && bun run lint && tsgo --noEmit && bun run check:vue && bun run check:i18n && bun run check:packages && bun run check:arch && bun run test:dupes",
"check:i18n": "bun scripts/check-locales.ts",
"check:packages": "bun scripts/check-package-metadata.ts",
"check:arch": "steiger .",
"check:vue": "vue-tsc --noEmit -p tsconfig.json && vue-tsc --noEmit -p packages/vue/tsconfig.json",
"test": "playwright test --project=openpencil",
@ -26,6 +27,8 @@
"test:unit": "bun test ./tests/engine",
"test:coverage": "bun test --coverage ./tests/engine",
"test:dupes": "jscpd packages/core/src packages/cli/src src --min-lines 5 --min-tokens 50 --format typescript --threshold 0",
"test:packages": "bun scripts/check-package-metadata.ts && bun scripts/smoke-packages.ts",
"build:packages": "bun --filter @open-pencil/core build && bun --filter @open-pencil/vue build && bun --filter @open-pencil/mcp build && bun --filter @open-pencil/cli build",
"open-pencil": "bun packages/cli/src/index.ts",
"docs:dev": "bun --filter @open-pencil/docs dev",
"docs:build": "bun --filter @open-pencil/docs build",

2
packages/cli/bin/openpencil.js Executable file
View file

@ -0,0 +1,2 @@
#!/usr/bin/env node
import '../dist/index.mjs'

View file

@ -7,9 +7,16 @@
"#cli/*": "./src/*"
},
"bin": {
"openpencil": "./src/index.ts"
"openpencil": "./bin/openpencil.js"
},
"files": [
"bin",
"dist"
],
"scripts": {
"build": "bunx tsdown --config tsdown.config.ts",
"prepublishOnly": "bun run build"
},
"files": ["src"],
"repository": {
"type": "git",
"url": "git+https://github.com/open-pencil/open-pencil.git",

View file

@ -119,9 +119,11 @@ async function exportFromFile(format: string, args: ExportArgs) {
}
const formatId = format.toLowerCase()
let options: { format?: string; scale?: number; quality?: number } | undefined
let options: { format?: string; scale?: number; quality?: number; renderThumbnail?: boolean } | undefined
if (format === 'JSX') {
options = { format: args.style }
} else if (format === 'FIG') {
options = { renderThumbnail: true }
} else if (format === 'PNG' || format === 'JPG' || format === 'WEBP') {
options = {
format,

View file

@ -0,0 +1,17 @@
import { defineConfig } from 'tsdown'
export default defineConfig({
entry: {
index: './src/index.ts'
},
platform: 'node',
format: ['esm'],
sourcemap: true,
clean: true,
outDir: './dist',
treeshake: false,
deps: {
neverBundle: ['@open-pencil/core', /^@open-pencil\/core\//, 'canvaskit-wasm', /^node:/],
onlyBundle: false
}
})

View file

@ -9,150 +9,149 @@
"sideEffects": false,
"exports": {
".": {
"types": "./src/index.ts",
"bun": "./src/index.ts",
"default": "./src/index.ts"
"types": "./dist/index.d.ts",
"default": "./dist/index.js",
"import": "./dist/index.js"
},
"./scene-graph": {
"types": "./src/scene-graph/index.ts",
"bun": "./src/scene-graph/index.ts",
"default": "./src/scene-graph/index.ts"
"types": "./dist/scene-graph/index.d.ts",
"default": "./dist/scene-graph/index.js",
"import": "./dist/scene-graph/index.js"
},
"./color": {
"types": "./src/color/index.ts",
"bun": "./src/color/index.ts",
"default": "./src/color/index.ts"
"types": "./dist/color/index.d.ts",
"default": "./dist/color/index.js",
"import": "./dist/color/index.js"
},
"./text": {
"types": "./src/text/index.ts",
"bun": "./src/text/index.ts",
"default": "./src/text/index.ts"
"types": "./dist/text/index.d.ts",
"default": "./dist/text/index.js",
"import": "./dist/text/index.js"
},
"./vector": {
"types": "./src/vector/index.ts",
"bun": "./src/vector/index.ts",
"default": "./src/vector/index.ts"
"types": "./dist/vector/index.d.ts",
"default": "./dist/vector/index.js",
"import": "./dist/vector/index.js"
},
"./figma-api": {
"types": "./src/figma-api/index.ts",
"bun": "./src/figma-api/index.ts",
"default": "./src/figma-api/index.ts"
"types": "./dist/figma-api/index.d.ts",
"default": "./dist/figma-api/index.js",
"import": "./dist/figma-api/index.js"
},
"./icons": {
"types": "./src/icons/index.ts",
"bun": "./src/icons/index.ts",
"default": "./src/icons/index.ts"
"types": "./dist/icons/index.d.ts",
"default": "./dist/icons/index.js",
"import": "./dist/icons/index.js"
},
"./canvas": {
"types": "./src/canvas/index.ts",
"bun": "./src/canvas/index.ts",
"default": "./src/canvas/index.ts"
"types": "./dist/canvas/index.d.ts",
"default": "./dist/canvas/index.js",
"import": "./dist/canvas/index.js"
},
"./design-jsx": {
"types": "./src/design-jsx/index.ts",
"bun": "./src/design-jsx/index.ts",
"default": "./src/design-jsx/index.ts"
"types": "./dist/design-jsx/index.d.ts",
"default": "./dist/design-jsx/index.js",
"import": "./dist/design-jsx/index.js"
},
"./editor": {
"types": "./src/editor/index.ts",
"bun": "./src/editor/index.ts",
"default": "./src/editor/index.ts"
"types": "./dist/editor/index.d.ts",
"default": "./dist/editor/index.js",
"import": "./dist/editor/index.js"
},
"./tools": {
"types": "./src/tools/index.ts",
"bun": "./src/tools/index.ts",
"default": "./src/tools/index.ts"
"types": "./dist/tools/index.d.ts",
"default": "./dist/tools/index.js",
"import": "./dist/tools/index.js"
},
"./profiler": {
"types": "./src/profiler/index.ts",
"bun": "./src/profiler/index.ts",
"default": "./src/profiler/index.ts"
"types": "./dist/profiler/index.d.ts",
"default": "./dist/profiler/index.js",
"import": "./dist/profiler/index.js"
},
"./rpc": {
"types": "./src/rpc/index.ts",
"bun": "./src/rpc/index.ts",
"default": "./src/rpc/index.ts"
"types": "./dist/rpc/index.d.ts",
"default": "./dist/rpc/index.js",
"import": "./dist/rpc/index.js"
},
"./lint": {
"types": "./src/lint/index.ts",
"bun": "./src/lint/index.ts",
"default": "./src/lint/index.ts"
"types": "./dist/lint/index.d.ts",
"default": "./dist/lint/index.js",
"import": "./dist/lint/index.js"
},
"./io": {
"types": "./src/io/index.ts",
"bun": "./src/io/index.ts",
"default": "./src/io/index.ts"
"types": "./dist/io/index.d.ts",
"default": "./dist/io/index.js",
"import": "./dist/io/index.js"
},
"./io/formats/fig": {
"types": "./src/io/formats/fig/index.ts",
"bun": "./src/io/formats/fig/index.ts",
"default": "./src/io/formats/fig/index.ts"
"types": "./dist/io/formats/fig/index.d.ts",
"default": "./dist/io/formats/fig/index.js",
"import": "./dist/io/formats/fig/index.js"
},
"./io/formats/pen": {
"types": "./src/io/formats/pen/index.ts",
"bun": "./src/io/formats/pen/index.ts",
"default": "./src/io/formats/pen/index.ts"
"types": "./dist/io/formats/pen/index.d.ts",
"default": "./dist/io/formats/pen/index.js",
"import": "./dist/io/formats/pen/index.js"
},
"./io/formats/jsx": {
"types": "./src/io/formats/jsx/index.ts",
"bun": "./src/io/formats/jsx/index.ts",
"default": "./src/io/formats/jsx/index.ts"
"types": "./dist/io/formats/jsx/index.d.ts",
"default": "./dist/io/formats/jsx/index.js",
"import": "./dist/io/formats/jsx/index.js"
},
"./io/formats/raster": {
"types": "./src/io/formats/raster/index.ts",
"bun": "./src/io/formats/raster/index.ts",
"default": "./src/io/formats/raster/index.ts"
"types": "./dist/io/formats/raster/index.d.ts",
"default": "./dist/io/formats/raster/index.js",
"import": "./dist/io/formats/raster/index.js"
},
"./io/formats/svg": {
"types": "./src/io/formats/svg/index.ts",
"bun": "./src/io/formats/svg/index.ts",
"default": "./src/io/formats/svg/index.ts"
"types": "./dist/io/formats/svg/index.d.ts",
"default": "./dist/io/formats/svg/index.js",
"import": "./dist/io/formats/svg/index.js"
},
"./kiwi": {
"types": "./src/kiwi/index.ts",
"bun": "./src/kiwi/index.ts",
"default": "./src/kiwi/index.ts"
"types": "./dist/kiwi/index.d.ts",
"default": "./dist/kiwi/index.js",
"import": "./dist/kiwi/index.js"
},
"./constants": {
"types": "./src/constants.ts",
"bun": "./src/constants.ts",
"default": "./src/constants.ts"
"types": "./dist/constants.d.ts",
"default": "./dist/constants.js",
"import": "./dist/constants.js"
},
"./random": {
"types": "./src/random.ts",
"bun": "./src/random.ts",
"default": "./src/random.ts"
"types": "./dist/random.d.ts",
"default": "./dist/random.js",
"import": "./dist/random.js"
},
"./xpath": {
"types": "./src/xpath.ts",
"bun": "./src/xpath.ts",
"default": "./src/xpath.ts"
"types": "./dist/xpath.d.ts",
"default": "./dist/xpath.js",
"import": "./dist/xpath.js"
},
"./types": {
"types": "./src/types.ts",
"bun": "./src/types.ts",
"default": "./src/types.ts"
"types": "./dist/types.d.ts",
"default": "./dist/types.js",
"import": "./dist/types.js"
},
"./canvaskit": {
"types": "./src/canvaskit.ts",
"bun": "./src/canvaskit.ts",
"default": "./src/canvaskit.ts"
"types": "./dist/canvaskit.d.ts",
"default": "./dist/canvaskit.js",
"import": "./dist/canvaskit.js"
},
"./layout": {
"types": "./src/layout.ts",
"bun": "./src/layout.ts",
"default": "./src/layout.ts"
"types": "./dist/layout.d.ts",
"default": "./dist/layout.js",
"import": "./dist/layout.js"
},
"./geometry": {
"types": "./src/geometry.ts",
"bun": "./src/geometry.ts",
"default": "./src/geometry.ts"
"types": "./dist/geometry.d.ts",
"default": "./dist/geometry.js",
"import": "./dist/geometry.js"
}
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"src",
"dist",
"assets"
],
@ -167,154 +166,12 @@
},
"publishConfig": {
"access": "public",
"provenance": true,
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./scene-graph": {
"types": "./dist/scene-graph/index.d.ts",
"import": "./dist/scene-graph/index.js",
"default": "./dist/scene-graph/index.js"
},
"./color": {
"types": "./dist/color/index.d.ts",
"import": "./dist/color/index.js",
"default": "./dist/color/index.js"
},
"./text": {
"types": "./dist/text/index.d.ts",
"import": "./dist/text/index.js",
"default": "./dist/text/index.js"
},
"./vector": {
"types": "./dist/vector/index.d.ts",
"import": "./dist/vector/index.js",
"default": "./dist/vector/index.js"
},
"./figma-api": {
"types": "./dist/figma-api/index.d.ts",
"import": "./dist/figma-api/index.js",
"default": "./dist/figma-api/index.js"
},
"./icons": {
"types": "./dist/icons/index.d.ts",
"import": "./dist/icons/index.js",
"default": "./dist/icons/index.js"
},
"./canvas": {
"types": "./dist/canvas/index.d.ts",
"import": "./dist/canvas/index.js",
"default": "./dist/canvas/index.js"
},
"./design-jsx": {
"types": "./dist/design-jsx/index.d.ts",
"import": "./dist/design-jsx/index.js",
"default": "./dist/design-jsx/index.js"
},
"./editor": {
"types": "./dist/editor/index.d.ts",
"import": "./dist/editor/index.js",
"default": "./dist/editor/index.js"
},
"./tools": {
"types": "./dist/tools/index.d.ts",
"import": "./dist/tools/index.js",
"default": "./dist/tools/index.js"
},
"./profiler": {
"types": "./dist/profiler/index.d.ts",
"import": "./dist/profiler/index.js",
"default": "./dist/profiler/index.js"
},
"./rpc": {
"types": "./dist/rpc/index.d.ts",
"import": "./dist/rpc/index.js",
"default": "./dist/rpc/index.js"
},
"./lint": {
"types": "./dist/lint/index.d.ts",
"import": "./dist/lint/index.js",
"default": "./dist/lint/index.js"
},
"./io": {
"types": "./dist/io/index.d.ts",
"import": "./dist/io/index.js",
"default": "./dist/io/index.js"
},
"./io/formats/fig": {
"types": "./dist/io/formats/fig/index.d.ts",
"import": "./dist/io/formats/fig/index.js",
"default": "./dist/io/formats/fig/index.js"
},
"./io/formats/pen": {
"types": "./dist/io/formats/pen/index.d.ts",
"import": "./dist/io/formats/pen/index.js",
"default": "./dist/io/formats/pen/index.js"
},
"./io/formats/jsx": {
"types": "./dist/io/formats/jsx/index.d.ts",
"import": "./dist/io/formats/jsx/index.js",
"default": "./dist/io/formats/jsx/index.js"
},
"./io/formats/raster": {
"types": "./dist/io/formats/raster/index.d.ts",
"import": "./dist/io/formats/raster/index.js",
"default": "./dist/io/formats/raster/index.js"
},
"./io/formats/svg": {
"types": "./dist/io/formats/svg/index.d.ts",
"import": "./dist/io/formats/svg/index.js",
"default": "./dist/io/formats/svg/index.js"
},
"./kiwi": {
"types": "./dist/kiwi/index.d.ts",
"import": "./dist/kiwi/index.js",
"default": "./dist/kiwi/index.js"
},
"./constants": {
"types": "./dist/constants.d.ts",
"import": "./dist/constants.js",
"default": "./dist/constants.js"
},
"./random": {
"types": "./dist/random.d.ts",
"import": "./dist/random.js",
"default": "./dist/random.js"
},
"./xpath": {
"types": "./dist/xpath.d.ts",
"import": "./dist/xpath.js",
"default": "./dist/xpath.js"
},
"./types": {
"types": "./dist/types.d.ts",
"import": "./dist/types.js",
"default": "./dist/types.js"
},
"./canvaskit": {
"types": "./dist/canvaskit.d.ts",
"import": "./dist/canvaskit.js",
"default": "./dist/canvaskit.js"
},
"./layout": {
"types": "./dist/layout.d.ts",
"import": "./dist/layout.js",
"default": "./dist/layout.js"
},
"./geometry": {
"types": "./dist/geometry.d.ts",
"import": "./dist/geometry.js",
"default": "./dist/geometry.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
"provenance": true
},
"dependencies": {
"@chenglou/pretext": "^0.0.7",
"@iconify/utils": "^3.1.0",
"@tauri-apps/api": "^2",
"acorn": "^8.16.0",
"canvaskit-wasm": "^0.40.0",
"culori": "^4.0.2",
@ -324,9 +181,11 @@
"fflate": "^0.8.2",
"fontoxpath": "^3.34.0",
"fzstd": "^0.1.1",
"jspdf": "^4.2.1",
"nanoevents": "^9.1.0",
"opentype.js": "^1.3.4",
"sucrase": "^3.35.1",
"svg2pdf.js": "^2.7.0",
"svgpath": "^2.6.0",
"twirlwind": "^0.3.0",
"yoga-layout": "npm:@open-pencil/yoga-layout@3.3.0-grid.3"

View file

@ -67,7 +67,7 @@ function lineStrokePath(r: SkiaRenderer, node: SceneNode): Path | null {
path.lineTo(node.width, node.height)
const stroke = node.strokes.find((item) => item.visible)
const outline = path.stroke({ width: stroke?.weight ?? 1 })
path.delete()
if (outline !== path) path.delete()
return outline
}

View file

@ -4,7 +4,7 @@ import { uniq } from 'es-toolkit/array'
import { getCanvasKit } from '#core/canvaskit'
import { resolveRGBAForPreview } from '#core/color/management'
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from '#core/constants'
import type { NodeChange } from '#core/kiwi/binary/codec'
import type { NodeChange } from '#core/kiwi/fig/codec'
import type { SceneNode } from '#core/scene-graph'
import { resolveNodeTextDirection } from '#core/text/direction'
import { fontManager, weightToStyle } from '#core/text/fonts'

View file

@ -13,7 +13,10 @@ export async function getCanvasKit(options?: CanvasKitOptions): Promise<CanvasKi
if (instance) return instance
const defaultLocate = (file: string) => {
if (!IS_BROWSER) return file
if (!IS_BROWSER) {
const ckPath = import.meta.resolve('canvaskit-wasm')
return decodeURIComponent(new URL(file, ckPath).pathname)
}
const base = 'env' in import.meta ? import.meta.env.BASE_URL : '/'
const prefix = base === '/' ? '' : base.replace(/\/$/, '')
return `${prefix}/${file}`

View file

@ -1,11 +1,11 @@
import { inflateSync, deflateSync } from 'fflate'
import { initCodec, getCompiledSchema, getSchemaBytes } from './kiwi/binary/codec'
import type { NodeChange as KiwiNodeChange } from './kiwi/binary/codec'
import { populateAndApplyOverrides } from './kiwi/instance-overrides'
import type { InstanceNodeChange } from './kiwi/instance-overrides'
import { decodeBinarySchema, compileSchema, ByteBuffer } from './kiwi/kiwi-schema'
import { nodeChangeToProps, sortChildren } from './kiwi/node-change/convert'
import { initCodec, getCompiledSchema, getSchemaBytes } from './kiwi/fig/codec'
import type { NodeChange as KiwiNodeChange } from './kiwi/fig/codec'
import { populateAndApplyOverrides } from './kiwi/fig/instance-overrides'
import type { InstanceNodeChange } from './kiwi/fig/instance-overrides'
import { decodeBinarySchema, compileSchema, ByteBuffer } from './kiwi/schema-runtime'
import { nodeChangeToProps, sortChildren } from './kiwi/fig/node-change/convert'
import {
sceneNodeToKiwi,
buildFigKiwi,
@ -14,7 +14,7 @@ import {
makeDocumentNodeChange,
makeCanvasNodeChange,
buildFontDigestMap
} from './kiwi/node-change/serialize'
} from './kiwi/fig/node-change/serialize'
import { randomInt } from './random'
import type { SceneGraph, SceneNode } from './scene-graph'
import { shapeTextForClipboard } from './canvas/text'
@ -337,6 +337,7 @@ export async function buildFigmaClipboardHTML(
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)
})

View file

@ -22,3 +22,8 @@ declare module '*.md' {
const content: string
export default content
}
declare module '*?raw' {
const content: string
export default content
}

View file

@ -301,7 +301,7 @@ export {
sceneNodeToKiwi,
fractionalPosition,
mapToFigmaType
} from './kiwi/node-change/serialize'
} from './kiwi/fig/node-change/serialize'
export { buildDerivedTextDataV4 } from './text/derived-text/clipboard'
export {

View file

@ -168,7 +168,8 @@ export const figFormat: IOFormatAdapter = {
graph,
context?.canvasKit,
context?.renderer,
options?.thumbnailPageId
options?.thumbnailPageId,
options?.renderThumbnail ?? false
)
return {
format: 'fig',
@ -183,7 +184,8 @@ export const figFormat: IOFormatAdapter = {
extracted.graph,
context?.canvasKit,
context?.renderer,
options?.thumbnailPageId ?? extracted.pageId ?? undefined
options?.thumbnailPageId ?? extracted.pageId ?? undefined,
options?.renderThumbnail ?? false
)
return {
format: 'fig',

View file

@ -1,6 +1,6 @@
import { zipSync, type Zippable } from 'fflate'
import { buildFigKiwi } from '#core/kiwi/node-change/serialize'
import { buildFigKiwi } from '#core/kiwi/fig/node-change/serialize'
export function compressFigDataSync(
schemaDeflated: Uint8Array,

View file

@ -6,10 +6,18 @@ interface CompressMessage {
thumbnailPng: Uint8Array
metaJson: string
images: Array<{ name: string; data: Uint8Array }>
figKiwiVersion?: number
}
self.onmessage = (e: MessageEvent<CompressMessage>) => {
const { schemaDeflated, kiwiData, thumbnailPng, metaJson, images } = e.data
const result = compressFigDataSync(schemaDeflated, kiwiData, thumbnailPng, metaJson, images)
const { schemaDeflated, kiwiData, thumbnailPng, metaJson, images, figKiwiVersion } = e.data
const result = compressFigDataSync(
schemaDeflated,
kiwiData,
thumbnailPng,
metaJson,
images,
figKiwiVersion
)
self.postMessage(result, { transfer: [result.buffer] })
}

View file

@ -5,9 +5,10 @@ import type { SkiaRenderer } from '#core/canvas'
import { CANVAS_BG_COLOR, IS_BROWSER, IS_TAURI } from '#core/constants'
import { renderThumbnail } from '#core/io/formats/raster'
import { populateAllLazyFigImportRoots } from '#core/kiwi/fig/lazy-import'
import { initCodec, getCompiledSchema, getSchemaBytes } from '#core/kiwi/binary/codec'
import type { NodeChange } from '#core/kiwi/binary/codec'
import { stringToGuid } from '#core/kiwi/node-change/convert'
import { initCodec, getCompiledSchema, getSchemaBytes } from '#core/kiwi/fig/codec'
import type { NodeChange } from '#core/kiwi/fig/codec'
import { stringToGuid } from '#core/kiwi/fig/node-change/convert'
import { buildFigmaPaintVariableColorMap } from '#core/kiwi/fig/node-change/export-node'
import {
sceneNodeToKiwi,
fractionalPosition,
@ -15,7 +16,7 @@ import {
safeColor,
makeDocumentNodeChange,
makeCanvasNodeChange
} from '#core/kiwi/node-change/serialize'
} from '#core/kiwi/fig/node-change/serialize'
import type { SceneGraph, VariableValue } from '#core/scene-graph'
import type { GUID } from '#core/types'
@ -29,6 +30,13 @@ const THUMBNAIL_1X1 = Uint8Array.from(
)
type KiwiNodeChange = NodeChange & Record<string, unknown>
type FigExportPage = ReturnType<SceneGraph['getPages']>[number]
interface CanvasExportEntry {
page: FigExportPage
canvasGuid: GUID
canvasNc: KiwiNodeChange
}
function variableValueToKiwi(
value: VariableValue,
@ -74,6 +82,22 @@ function collectImageEntries(graph: SceneGraph): Array<{ name: string; data: Uin
const THUMBNAIL_WIDTH = 400
const THUMBNAIL_HEIGHT = 225
async function renderFigThumbnail(
graph: SceneGraph,
pageId: string | undefined,
ck?: CanvasKit,
renderer?: SkiaRenderer,
renderHeadless = false
): Promise<Uint8Array> {
if (!pageId) return THUMBNAIL_1X1
if (ck && renderer) {
return renderThumbnail(ck, renderer, graph, pageId, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT) ?? THUMBNAIL_1X1
}
if (!renderHeadless || IS_BROWSER || IS_TAURI) return THUMBNAIL_1X1
const { headlessRenderThumbnail } = await import('#core/io/formats/raster')
return (await headlessRenderThumbnail(graph, pageId, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)) ?? THUMBNAIL_1X1
}
function assignVariableGuids(
graph: SceneGraph,
localIdCounter: { value: number },
@ -170,11 +194,75 @@ function appendVariablesForCollection(
}
}
function applyImportedCanvasFields(page: FigExportPage, canvasNc: KiwiNodeChange): void {
if (!page.source.id) return
if (!('pageType' in page.source.fig.rawNodeFields)) delete canvasNc.pageType
if ('backgroundColor' in page.source.fig.rawNodeFields) {
canvasNc.backgroundColor = structuredClone(page.source.fig.rawNodeFields.backgroundColor)
}
const strokeJoin = page.source.fig.rawNodeFields.strokeJoin
if (typeof strokeJoin === 'string') canvasNc.strokeJoin = strokeJoin
const strokeWeight = page.source.fig.rawNodeFields.strokeWeight
if (typeof strokeWeight === 'number') canvasNc.strokeWeight = strokeWeight
}
function buildCanvasEntries(
graph: SceneGraph,
pages: FigExportPage[],
docGuid: GUID,
localIdCounter: { value: number },
nodeIdToGuid: Map<string, GUID>
): { canvasEntries: CanvasExportEntry[]; internalCanvasGuid: GUID | null } {
const canvasEntries: CanvasExportEntry[] = []
let internalCanvasGuid: GUID | null = null
for (let p = 0; p < pages.length; p++) {
const page = pages[p]
const canvasGuid = page.source.id
? stringToGuid(page.source.id)
: { sessionID: 0, localID: localIdCounter.value++ }
nodeIdToGuid.set(page.id, canvasGuid)
if (page.internalOnly) internalCanvasGuid = canvasGuid
const canvasNc = makeCanvasNodeChange(
canvasGuid,
docGuid,
page.source.orderKey ?? fractionalPosition(p),
page.name,
{
backgroundOpacity: 1,
backgroundColor: { ...CANVAS_BG_COLOR },
backgroundEnabled: true
}
)
applyImportedCanvasFields(page, canvasNc)
if (page.internalOnly) canvasNc.internalOnly = true
canvasEntries.push({ page, canvasGuid, canvasNc })
}
if (graph.variableCollections.size > 0 && internalCanvasGuid === null) {
internalCanvasGuid = { sessionID: 0, localID: localIdCounter.value++ }
canvasEntries.push({
page: { id: '', name: 'Internal Only Canvas', internalOnly: true } as FigExportPage,
canvasGuid: internalCanvasGuid,
canvasNc: makeCanvasNodeChange(
internalCanvasGuid,
docGuid,
fractionalPosition(canvasEntries.length),
'Internal Only Canvas',
{ internalOnly: true }
)
})
}
return { canvasEntries, internalCanvasGuid }
}
export async function exportFigFile(
graph: SceneGraph,
ck?: CanvasKit,
renderer?: SkiaRenderer,
pageId?: string
pageId?: string,
renderHeadlessThumbnail = false
): Promise<Uint8Array> {
populateAllLazyFigImportRoots(graph)
await initCodec()
@ -184,7 +272,10 @@ export async function exportFigFile(
const docGuid = { sessionID: 0, localID: 0 }
const localIdCounter = { value: 2 }
const nodeChanges: KiwiNodeChange[] = [makeDocumentNodeChange(docGuid, graph.documentColorSpace)]
const documentNc = makeDocumentNodeChange(docGuid, graph.documentColorSpace)
const rootNode = graph.getNode(graph.rootId)
if (rootNode) Object.assign(documentNc, rootNode.source.fig.rawNodeFields)
const nodeChanges: KiwiNodeChange[] = [documentNc]
const blobs: Uint8Array[] = []
const pages = graph.getPages(true)
@ -192,25 +283,27 @@ export async function exportFigFile(
const varIdToGuid = new Map<string, GUID>()
const modeIdToGuid = new Map<string, GUID>()
const fontDigestMap = await buildFontDigestMap(graph)
let internalCanvasGuid: GUID | null = null
const glyphBlobMap = new Map<string, number>()
const blobIndexByHex = new Map<string, number>()
const paintVariableColorMap = buildFigmaPaintVariableColorMap(graph)
assignVariableGuids(graph, localIdCounter, varIdToGuid, modeIdToGuid)
for (let p = 0; p < pages.length; p++) {
const page = pages[p]
const canvasLocalID = localIdCounter.value++
const canvasGuid = { sessionID: 0, localID: canvasLocalID }
const { canvasEntries, internalCanvasGuid } = buildCanvasEntries(
graph,
pages,
docGuid,
localIdCounter,
nodeIdToGuid
)
if (page.internalOnly) internalCanvasGuid = canvasGuid
const canvasNc = makeCanvasNodeChange(canvasGuid, docGuid, fractionalPosition(p), page.name, {
backgroundOpacity: 1,
backgroundColor: { ...CANVAS_BG_COLOR },
backgroundEnabled: true
})
if (page.internalOnly) canvasNc.internalOnly = true
nodeChanges.push(canvasNc)
for (const entry of canvasEntries) nodeChanges.push(entry.canvasNc)
const orderedCanvasEntries = [
...canvasEntries.filter((entry) => entry.page.internalOnly),
...canvasEntries.filter((entry) => !entry.page.internalOnly)
]
for (const { page, canvasGuid } of orderedCanvasEntries) {
const children = graph.getChildren(page.id).filter((child) => !child.internalOnly)
for (let i = 0; i < children.length; i++) {
nodeChanges.push(
@ -223,27 +316,16 @@ export async function exportFigFile(
blobs,
nodeIdToGuid,
fontDigestMap,
varIdToGuid
varIdToGuid,
glyphBlobMap,
paintVariableColorMap,
blobIndexByHex
)
)
}
}
if (graph.variableCollections.size > 0) {
if (!internalCanvasGuid) {
const internalLocalID = localIdCounter.value++
internalCanvasGuid = { sessionID: 0, localID: internalLocalID }
nodeChanges.push(
makeCanvasNodeChange(
internalCanvasGuid,
docGuid,
fractionalPosition(pages.length),
'Internal Only Canvas',
{ internalOnly: true }
)
)
}
if (graph.variableCollections.size > 0 && internalCanvasGuid) {
appendVariableNodeChanges(graph, nodeChanges, internalCanvasGuid, varIdToGuid, modeIdToGuid)
}
@ -261,10 +343,13 @@ export async function exportFigFile(
const kiwiData = compiled.encodeMessage(msg)
const currentPageId = pageId ?? pages[0]?.id
const thumbnailPng =
(ck && renderer && currentPageId
? renderThumbnail(ck, renderer, graph, currentPageId, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
: null) ?? THUMBNAIL_1X1
const thumbnailPng = await renderFigThumbnail(
graph,
currentPageId,
ck,
renderer,
renderHeadlessThumbnail
)
const metaJson = JSON.stringify({
version: 1,
@ -304,7 +389,8 @@ function compressViaWorker(
kiwiData: Uint8Array,
thumbnailPng: Uint8Array,
metaJson: string,
imageEntries: Array<{ name: string; data: Uint8Array }>
imageEntries: Array<{ name: string; data: Uint8Array }>,
figKiwiVersion?: number
): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('./export-worker.ts', import.meta.url), {
@ -324,7 +410,14 @@ function compressViaWorker(
// internal buffer, so transferring kiwiData.buffer or schemaDeflated.buffer detaches
// buffers that may be shared with other views, causing "already detached" errors on
// subsequent saves. Structured clone (the default) copies the data safely.
worker.postMessage({ schemaDeflated, kiwiData, thumbnailPng, metaJson, images: imageEntries })
worker.postMessage({
schemaDeflated,
kiwiData,
thumbnailPng,
metaJson,
images: imageEntries,
figKiwiVersion
})
})
}
@ -337,7 +430,14 @@ export function compressFigData(
figKiwiVersion?: number
): Promise<Uint8Array> {
if (canUseWorker()) {
return compressViaWorker(schemaDeflated, kiwiData, thumbnailPng, metaJson, imageEntries)
return compressViaWorker(
schemaDeflated,
kiwiData,
thumbnailPng,
metaJson,
imageEntries,
figKiwiVersion
)
}
return Promise.resolve(
compressFigDataSync(

View file

@ -65,13 +65,5 @@ export async function headlessRenderThumbnail(
): Promise<Uint8Array | null> {
const { ck, renderer } = await getRenderer()
renderer.invalidateAllPictures()
const page = graph.getNode(pageId)
const restoreTextMeasurer = page
? await renderer.prepareForExport(graph, pageId, page.childIds)
: () => undefined
try {
return renderThumbnail(ck, renderer, graph, pageId, width, height)
} finally {
restoreTextMeasurer()
}
return renderThumbnail(ck, renderer, graph, pageId, width, height)
}

View file

@ -10,15 +10,9 @@ export interface ExtractedGraph {
function cloneIntoGraph(source: SceneGraph, ids: Set<string>): SceneGraph {
const graph = new SceneGraph()
const root = graph.getNode(graph.rootId)
if (root) {
root.childIds = []
root.width = 0
root.height = 0
}
graph.rootId = source.rootId
graph.nodes = new Map()
if (root) graph.nodes.set(root.id, root)
graph.images = new Map(source.images)
graph.images = new Map()
graph.variables = new Map()
graph.variableCollections = new Map()
graph.activeMode = new Map(source.activeMode)
@ -57,11 +51,11 @@ function cloneIntoGraph(source: SceneGraph, ids: Set<string>): SceneGraph {
node.childIds = node.childIds.filter((childId) => ids.has(childId))
}
const variableIds = new Set<string>()
for (const node of graph.nodes.values()) {
for (const variableId of Object.values(node.boundVariables)) {
collectVariableClosure(source, variableId, variableIds)
}
const { imageHashes, variableIds } = collectReferencedResources(source, graph)
for (const imageHash of imageHashes) {
const image = source.images.get(imageHash)
if (image) graph.images.set(imageHash, image)
}
for (const variableId of variableIds) {
@ -84,6 +78,20 @@ function cloneIntoGraph(source: SceneGraph, ids: Set<string>): SceneGraph {
return graph
}
function collectReferencedResources(source: SceneGraph, graph: SceneGraph) {
const imageHashes = new Set<string>()
const variableIds = new Set<string>()
for (const node of graph.nodes.values()) {
for (const fill of node.fills) {
if (fill.type === 'IMAGE' && fill.imageHash) imageHashes.add(fill.imageHash)
}
for (const variableId of Object.values(node.boundVariables)) {
collectVariableClosure(source, variableId, variableIds)
}
}
return { imageHashes, variableIds }
}
function collectVariableClosure(source: SceneGraph, variableId: string, out: Set<string>) {
if (out.has(variableId)) return
const variable = source.variables.get(variableId)
@ -116,6 +124,43 @@ function collectDescendants(source: SceneGraph, id: string, out: Set<string>) {
}
}
function collectAncestors(source: SceneGraph, id: string, out: Set<string>) {
let current = source.getNode(id)
while (current?.parentId) {
out.add(current.parentId)
current = source.getNode(current.parentId)
}
}
function resolveInstanceComponentId(source: SceneGraph, componentId: string): string {
const seen = new Set<string>()
let currentId = componentId
while (!seen.has(currentId)) {
seen.add(currentId)
const node = source.getNode(currentId)
if (node?.type !== 'INSTANCE' || !node.componentId) return currentId
currentId = node.componentId
}
return componentId
}
function collectComponentDependencies(source: SceneGraph, ids: Set<string>) {
let changed = true
while (changed) {
changed = false
for (const id of Array.from(ids)) {
const node = source.getNode(id)
if (node?.type !== 'INSTANCE' || !node.componentId) continue
const componentId = resolveInstanceComponentId(source, node.componentId)
if (ids.has(componentId)) continue
const before = ids.size
collectAncestors(source, componentId, ids)
collectDescendants(source, componentId, ids)
changed ||= ids.size !== before
}
}
}
export function findPageId(source: SceneGraph, nodeId: string): string | null {
let current = source.getNode(nodeId)
while (current?.parentId) {
@ -156,12 +201,14 @@ function collectSelectionIds(source: SceneGraph, nodeIds: string[]): Set<string>
ids.add(pageId)
}
collectComponentDependencies(source, ids)
return ids
}
function pageNodeIds(source: SceneGraph, pageId: string): Set<string> {
const ids = new Set<string>([source.rootId, pageId])
const ids = new Set<string>([source.rootId])
collectDescendants(source, pageId, ids)
collectComponentDependencies(source, ids)
return ids
}

View file

@ -66,6 +66,7 @@ export interface IOContext {
export interface FigWriteOptions {
thumbnailPageId?: string
renderThumbnail?: boolean
}
export interface RasterExportOptions {

View file

@ -10,7 +10,7 @@
import { decompress as zstdDecompress } from 'fzstd'
import { parseColor } from '#core/color'
import { compileSchema, encodeBinarySchema } from '#core/kiwi/kiwi-schema'
import { compileSchema, encodeBinarySchema } from '#core/kiwi/schema-runtime'
import { isZstdCompressed, getKiwiMessageType } from './protocol'
import figmaSchema from './schema'
@ -227,6 +227,7 @@ export interface VariableAnyValue {
floatValue?: number
colorValue?: Color
alias?: { guid?: GUID; assetRef?: { key: string; version?: string } }
symbolIdValue?: { guid?: GUID }
}
export interface VariableDataEntry {

View file

@ -1,7 +1,3 @@
/* eslint-disable max-lines -- generated kiwi schema definition */
import { parseSchema } from '#core/kiwi/kiwi-schema'
const schemaText = `
package Fig;
enum MessageType {
@ -846,7 +842,8 @@ message Paint {
Video video = 18;
uint originalImageWidth = 19;
uint originalImageHeight = 20;
PaintVariableBinding variableBinding = 21; // Not in .fig files. Discovered via WS sniffing 2026-01
VariableData colorVar = 21;
VariableData opacityVar = 38;
}
message FontMetaData {
@ -904,6 +901,7 @@ message Glyph {
float fontSize = 4;
uint firstCharacter = 5;
float advance = 6;
float rotation = 7;
}
message Decoration {
@ -1198,9 +1196,23 @@ message WidgetPointer {
GUID nodeId = 1;
}
message EditInfo {
string timestampIso8601 = 1;
string userId = 2;
uint lastEditedAt = 3;
uint createdAt = 4;
}
enum EditorType {
DESIGN = 0;
WHITEBOARD = 1;
SLIDES = 2;
DEV_HANDOFF = 3;
SITES = 4;
COOPER = 5;
ILLUSTRATION = 6;
FIGMAKE = 7;
FIGSPEC = 8;
}
message NodeChange {
@ -1369,6 +1381,11 @@ message NodeChange {
GUID inheritEffectStyleID = 169;
GUID inheritGridStyleID = 170;
GUID inheritFillStyleIDForStroke = 185;
StyleId styleIdForFill = 332;
StyleId styleIdForStrokeFill = 333;
StyleId styleIdForText = 334;
StyleId styleIdForEffect = 335;
StyleId styleIdForGrid = 336;
bool isFillStyle = 157 [deprecated];
bool isStrokeStyle = 161 [deprecated];
StyleType styleType = 163;
@ -1515,10 +1532,15 @@ message NodeChange {
VariableSetID variableSetID = 313;
VariableResolvedDataType variableResolvedType = 314;
VariableDataValues variableDataValues = 315;
EditInfo editInfo = 331;
VariableScope[] variableScopes = 353;
DerivedTextData derivedTextData = 359;
EmojiImageSet emojiImageSet = 391;
string sourceLibraryKey = 395;
uint textExplicitLayoutVersion = 396;
EditorType pageType = 397;
string userFacingVersion = 399;
VariableDataMap parameterConsumptionMap = 445;
Paint[] textDecorationFillPaints = 411;
bool textDecorationSkipInk = 412;
Number textUnderlineOffset = 413;
@ -1529,6 +1551,7 @@ message NodeChange {
float gridColumnGap = 438;
GridTrackSize[] gridColumnSizes = 474;
GridTrackSize[] gridRowSizes = 475;
VariantPropSpec[] variantPropSpecs = 483;
StackWrap stackWrap = 476;
float stackCounterSpacing = 477;
}
@ -1570,16 +1593,24 @@ message ComponentPropRef {
bool isDeleted = 5;
}
message VariantPropSpec {
GUID propDefId = 1;
string value = 2;
}
enum ComponentPropNodeField {
VISIBLE = 0;
TEXT_DATA = 1;
OVERRIDDEN_SYMBOL_ID = 2;
INHERIT_FILL_STYLE_ID = 3;
SLOT_CONTENT_ID = 4;
}
message ComponentPropAssignment {
GUID defID = 1;
ComponentPropValue value = 2;
VariableData varValue = 3;
DerivedTextData legacyDerivedTextData = 4;
}
message ComponentPropDef {
@ -1591,12 +1622,15 @@ message ComponentPropDef {
ComponentPropType type = 6;
bool isDeleted = 7;
ComponentPropPreferredValues preferredValues = 8;
VariableData varValue = 9;
string description = 11;
}
message ComponentPropValue {
bool boolValue = 1;
TextData textValue = 2;
GUID guidValue = 3;
float floatValue = 4;
}
enum ComponentPropType {
@ -1604,10 +1638,34 @@ enum ComponentPropType {
TEXT = 1;
COLOR = 2;
INSTANCE_SWAP = 3;
VARIANT = 4;
NUMBER = 5;
IMAGE = 6;
SLOT = 7;
EASING = 8;
COLOR_ARRAY = 9;
VECTOR = 10;
LINE = 11;
CIRCLE = 12;
ROTATION_3D = 13;
CIRCLE_POINT = 14;
GRADIENT = 15;
COLOR_POINT = 16;
}
enum InstanceSwapPreferredValueType {
COMPONENT = 0;
STATE_GROUP = 1;
}
message InstanceSwapPreferredValue {
InstanceSwapPreferredValueType type = 1;
string key = 2;
}
message ComponentPropPreferredValues {
string[] stringValues = 1;
InstanceSwapPreferredValue[] instanceSwapValues = 2;
}
enum WidgetEvent {
@ -1698,6 +1756,12 @@ message TextLineData {
int indentationLevel = 2;
Directionality directionality = 3;
DirectionalityIntent directionalityIntent = 4;
int downgradeStyleId = 5;
int consistencyStyleId = 6;
int listStartOffset = 7;
bool isFirstLineOfList = 8;
SourceDirectionality sourceDirectionality = 9;
int styleId = 10;
}
enum BulletType {
@ -1718,6 +1782,12 @@ enum Directionality {
RTL = 1;
}
enum SourceDirectionality {
AUTO = 0;
LTR = 1;
RTL = 2;
}
enum DirectionalityIntent {
IMPLICIT = 0;
EXPLICIT = 1;
@ -2044,6 +2114,9 @@ enum VariableDataType {
STRING = 2;
ALIAS = 3;
COLOR = 4;
SYMBOL_ID = 7;
TEXT_DATA = 9;
PROP_REF = 13;
}
enum VariableResolvedDataType {
@ -2051,10 +2124,31 @@ enum VariableResolvedDataType {
FLOAT = 1;
STRING = 2;
COLOR = 4;
SYMBOL_ID = 6;
TEXT_DATA = 8;
}
message AssetRef {
string key = 1;
string version = 2;
}
message StyleId {
GUID guid = 1;
AssetRef assetRef = 2;
}
message VariableID {
GUID guid = 1;
AssetRef assetRef = 2;
}
message SymbolId {
GUID guid = 1;
}
message PropRefValue {
GUID defId = 1;
}
message VariableSetID {
@ -2067,6 +2161,8 @@ message VariableAnyValue {
float floatValue = 3;
VariableID alias = 4;
Color colorValue = 5;
SymbolId symbolIdValue = 8;
PropRefValue propRefValue = 13;
}
message VariableData {
@ -2114,6 +2210,8 @@ enum VariableScope {
enum VariableField {
MISSING = 0;
CORNER_RADIUS = 1;
PARAGRAPH_SPACING = 2;
PARAGRAPH_INDENT = 3;
STROKE_WEIGHT = 4;
STACK_SPACING = 5;
STACK_PADDING_LEFT = 6;
@ -2121,17 +2219,61 @@ enum VariableField {
STACK_PADDING_RIGHT = 8;
STACK_PADDING_BOTTOM = 9;
VISIBLE = 10;
TEXT_DATA = 11;
WIDTH = 12;
HEIGHT = 13;
RECTANGLE_TOP_LEFT_CORNER_RADIUS = 14;
RECTANGLE_TOP_RIGHT_CORNER_RADIUS = 15;
RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS = 16;
RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS = 17;
BORDER_TOP_WEIGHT = 18;
BORDER_BOTTOM_WEIGHT = 19;
BORDER_LEFT_WEIGHT = 20;
BORDER_RIGHT_WEIGHT = 21;
VARIANT_PROPERTIES = 22;
STACK_COUNTER_SPACING = 23;
MIN_WIDTH = 24;
MAX_WIDTH = 25;
MIN_HEIGHT = 26;
MAX_HEIGHT = 27;
FONT_FAMILY = 28;
FONT_STYLE = 29;
FONT_VARIATIONS = 30;
OPACITY = 31;
FONT_SIZE = 32;
LETTER_SPACING = 34;
LINE_HEIGHT = 36;
STACK_COUNTER_SPACING = 23;
OVERRIDDEN_SYMBOL_ID = 37;
HYPERLINK = 38;
CMS_SERIALIZED_RICH_TEXT_DATA = 39;
SLOT_CONTENT_ID = 40;
GRID_ROW_GAP = 41;
GRID_COLUMN_GAP = 42;
X_POSITION = 43;
Y_POSITION = 44;
ROTATION = 45;
MOTION_TRANSLATION_X = 46;
MOTION_TRANSLATION_Y = 47;
MOTION_ROTATION = 48;
MOTION_SCALE_X = 49;
MOTION_SCALE_Y = 50;
MOTION_SHEAR = 51;
SCROLL_OFFSET_X = 52;
SCROLL_OFFSET_Y = 53;
PATH_TRIM_START = 54;
PATH_TRIM_END = 55;
DISSOLVE_PROGRESS = 56;
EASING_DATA = 57;
MEDIA_CURRENT_TIME = 58;
TRANSFORM_3D_PERSPECTIVE = 59;
TRANSFORM_3D_TRANSLATION_Z = 60;
TRANSFORM_3D_ROTATION_X = 61;
TRANSFORM_3D_ROTATION_Y = 62;
TRANSFORM_3D_ROTATION_Z = 63;
POLYGON_COUNT = 64;
ARC_DATA_STARTING_ANGLE = 65;
ARC_DATA_ENDING_ANGLE = 66;
ARC_DATA_INNER_RADIUS = 67;
}
message VariableDataMapEntry {
@ -2307,6 +2449,3 @@ enum ARIARole {
ALERTDIALOG = 80;
DIALOG = 81;
}
`
export default parseSchema(schemaText)

View file

@ -0,0 +1,8 @@
import { parseSchema, validateSchema } from '#core/kiwi/schema-runtime'
import schemaText from './fig.kiwi?raw'
const schema = parseSchema(schemaText)
validateSchema(schema)
export default schema

View file

@ -1,7 +1,7 @@
import { hexToBytes } from '#core/bytes/hex'
import type { GUID } from '#core/types'
import type { NodeChange, Paint } from './codec'
import type { NodeChange, Paint } from './index'
export interface VariableBindingCodec {
encodePaint(paint: Paint): Uint8Array

View file

@ -2,23 +2,40 @@ import { isNotNil } from 'es-toolkit/predicate'
import { BLACK } from '#core/constants'
import { setLazyFigImportContext } from '#core/kiwi/fig/lazy-import'
import type { NodeChange, VariableDataValuesEntry, Color, GUID } from '#core/kiwi/binary/codec'
import { populateAndApplyOverrides } from '#core/kiwi/instance-overrides'
import type { InstanceNodeChange } from '#core/kiwi/instance-overrides'
import type { NodeChange, VariableDataValuesEntry, Color, GUID } from '#core/kiwi/fig/codec'
import { populateAndApplyOverrides } from '#core/kiwi/fig/instance-overrides'
import type { InstanceNodeChange } from '#core/kiwi/fig/instance-overrides'
import {
guidToString,
nodeChangeToProps,
sortChildren,
setVariableColorResolver,
VARIABLE_BINDING_FIELDS_INVERSE
} from '#core/kiwi/node-change/convert'
import { applyStyleRefsToFields } from '#core/kiwi/node-change/style-refs'
} from '#core/kiwi/fig/node-change/convert'
import { applyStyleRefsToFields } from '#core/kiwi/fig/node-change/style-refs'
import { SceneGraph } from '#core/scene-graph'
import type { VariableType, VariableValue } from '#core/scene-graph'
type AssetRef = { key: string; version?: string }
type AliasRef = { guid?: GUID; assetRef?: AssetRef }
function applyImportedCanvasMetadata(page: ReturnType<SceneGraph['addPage']>, canvasNc: NodeChange) {
page.source.format = 'fig'
page.source.orderKey = canvasNc.parentIndex?.position ?? null
if (canvasNc.backgroundColor) page.source.fig.rawNodeFields.backgroundColor = structuredClone(canvasNc.backgroundColor)
page.source.fig.rawNodeFields.strokeJoin = canvasNc.strokeJoin
page.source.fig.rawNodeFields.strokeWeight = canvasNc.strokeWeight
if (canvasNc.pageType) page.source.fig.rawNodeFields.pageType = canvasNc.pageType
}
function applyImportedDocumentMetadata(graph: SceneGraph, docNc: NodeChange | undefined) {
const rootNode = graph.getNode(graph.rootId)
if (!docNc || !rootNode) return
rootNode.source.format = 'fig'
rootNode.source.fig.rawNodeFields.strokeJoin = docNc.strokeJoin
rootNode.source.fig.rawNodeFields.strokeWeight = docNc.strokeWeight
}
function assetRefKey(assetRef: AssetRef): string {
return assetRef.version ? `${assetRef.key}@${assetRef.version}` : assetRef.key
}
@ -281,11 +298,15 @@ function importPages(
}
if (docId) {
applyImportedDocumentMetadata(graph, changeMap.get(docId))
for (const canvasId of childrenMap.get(docId) ?? []) {
const canvasNc = changeMap.get(canvasId)
if (!canvasNc) continue
if (canvasNc.type === 'CANVAS') {
const page = graph.addPage(canvasNc.name ?? 'Page')
page.source.id = canvasId
applyImportedCanvasMetadata(page, canvasNc)
canvasIdToPageId.set(canvasId, page.id)
if (canvasNc.internalOnly) page.internalOnly = true
created.add(canvasId)
@ -445,13 +466,15 @@ export function importNodeChanges(
? [firstPageId, ...componentPageIds].filter(isNotNil)
: undefined
populateAndApplyOverrides(
graph,
changeMap as Map<string, InstanceNodeChange>,
guidToNodeId,
blobs,
activeRootIds
)
graph.preserveSourceMetadataDuring(() => {
populateAndApplyOverrides(
graph,
changeMap as Map<string, InstanceNodeChange>,
guidToNodeId,
blobs,
activeRootIds
)
})
if (activeRootIds) rememberLazyFigImportContext(graph, changeMap, guidToNodeId, blobs, activeRootIds)

View file

@ -1,11 +1,11 @@
import { applyOverridePatch, type OverridePatch } from '#core/kiwi/instance-overrides/patches'
import { getComponentRoot } from '#core/kiwi/instance-overrides/resolve'
import { applyOverridePatch, type OverridePatch } from '#core/kiwi/fig/instance-overrides/patches'
import { getComponentRoot } from '#core/kiwi/fig/instance-overrides/resolve'
import type {
ComponentPropRef,
ComponentPropValue,
OverrideContext
} from '#core/kiwi/instance-overrides/types'
import { guidToString } from '#core/kiwi/node-change/convert'
} from '#core/kiwi/fig/instance-overrides/types'
import { guidToString } from '#core/kiwi/fig/node-change/convert'
import { copyFills, copyStyleRuns } from '#core/scene-graph/copy'
import { propTextCharacters } from './values'

View file

@ -1,18 +1,18 @@
import { buildCloneIndex, instanceAndClones } from '#core/kiwi/instance-overrides/clone-index'
import { applyComponentPropRef } from '#core/kiwi/instance-overrides/component-props/apply'
import { buildCloneIndex, instanceAndClones } from '#core/kiwi/fig/instance-overrides/clone-index'
import { applyComponentPropRef } from '#core/kiwi/fig/instance-overrides/component-props/apply'
import {
fallbackRefsForChild,
findPropRefs,
valueForRef
} from '#core/kiwi/instance-overrides/component-props/refs'
import { assignmentsToValueMap } from '#core/kiwi/instance-overrides/component-props/values'
import { resolveOverrideTarget } from '#core/kiwi/instance-overrides/resolve'
} from '#core/kiwi/fig/instance-overrides/component-props/refs'
import { assignmentsToValueMap } from '#core/kiwi/fig/instance-overrides/component-props/values'
import { resolveOverrideTarget } from '#core/kiwi/fig/instance-overrides/resolve'
import type {
ComponentPropAssignment,
ComponentPropRef,
ComponentPropValue,
OverrideContext
} from '#core/kiwi/instance-overrides/types'
} from '#core/kiwi/fig/instance-overrides/types'
import type { SceneNode } from '#core/scene-graph'
function applyChildPropRefs(

View file

@ -3,7 +3,7 @@ import {
applyOverrideAssignments
} from './assignments'
import { collectAssignmentsMap, collectPropRefsMap } from './maps'
import type { OverrideContext } from '#core/kiwi/instance-overrides/types'
import type { OverrideContext } from '#core/kiwi/fig/instance-overrides/types'
/**
* Apply all component property assignments (visibility toggles, instance swaps).

View file

@ -2,7 +2,7 @@ import type {
ComponentPropAssignment,
ComponentPropRef,
OverrideContext
} from '#core/kiwi/instance-overrides/types'
} from '#core/kiwi/fig/instance-overrides/types'
export function collectPropRefsMap(ctx: OverrideContext): Map<string, ComponentPropRef[]> {
const result = new Map<string, ComponentPropRef[]>()

View file

@ -2,8 +2,8 @@ import type {
ComponentPropRef,
ComponentPropValue,
OverrideContext
} from '#core/kiwi/instance-overrides/types'
import { guidToString } from '#core/kiwi/node-change/convert'
} from '#core/kiwi/fig/instance-overrides/types'
import { guidToString } from '#core/kiwi/fig/node-change/convert'
import { normalizePropName, stringToGuidParts } from './values'

View file

@ -1,11 +1,11 @@
import type { GUID } from '#core/kiwi/binary/codec'
import { guidToString } from '#core/kiwi/node-change/convert'
import type { GUID } from '#core/kiwi/fig/codec'
import { guidToString } from '#core/kiwi/fig/node-change/convert'
import type {
ComponentPropAssignment,
ComponentPropValue,
OverrideContext
} from '#core/kiwi/instance-overrides/types'
} from '#core/kiwi/fig/instance-overrides/types'
export function normalizePropName(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]/g, '')

View file

@ -1,5 +1,5 @@
import { resolveGeometryPaths } from '#core/kiwi/node-change/convert'
import type { DerivedSymbolOverride } from '#core/kiwi/instance-overrides/types'
import { resolveGeometryPaths } from '#core/kiwi/fig/node-change/convert'
import type { DerivedSymbolOverride } from '#core/kiwi/fig/instance-overrides/types'
import type { GeometryPath, SceneNode } from '#core/scene-graph'
function scaleGeometryBlobs(geom: GeometryPath[], sx: number, sy: number): GeometryPath[] {

View file

@ -1,6 +1,6 @@
import { applyOverridePatch } from '#core/kiwi/instance-overrides/patches'
import { resolveOverrideTarget } from '#core/kiwi/instance-overrides/resolve'
import type { DerivedSymbolOverride, OverrideContext } from '#core/kiwi/instance-overrides/types'
import { applyOverridePatch } from '#core/kiwi/fig/instance-overrides/patches'
import { resolveOverrideTarget } from '#core/kiwi/fig/instance-overrides/resolve'
import type { DerivedSymbolOverride, OverrideContext } from '#core/kiwi/fig/instance-overrides/types'
import { buildDsdLayoutUpdates } from './layout'
import { propagateDsdChanges } from './propagate'

View file

@ -1,6 +1,6 @@
import { convertLetterSpacing, convertLineHeight } from '#core/kiwi/node-change/convert'
import { convertFigmaDerivedTextGlyphs } from '#core/kiwi/node-change/derived-text-glyphs'
import type { DerivedSymbolOverride, OverrideContext } from '#core/kiwi/instance-overrides/types'
import { convertLetterSpacing, convertLineHeight } from '#core/kiwi/fig/node-change/convert'
import { convertFigmaDerivedTextGlyphs } from '#core/kiwi/fig/node-change/derived-text-glyphs'
import type { DerivedSymbolOverride, OverrideContext } from '#core/kiwi/fig/instance-overrides/types'
import type { SceneNode } from '#core/scene-graph'
import { resolveDsdGeometry } from './geometry'

View file

@ -1,5 +1,5 @@
import { buildClonesMap } from '#core/kiwi/instance-overrides/sync'
import type { OverrideContext } from '#core/kiwi/instance-overrides/types'
import { buildClonesMap } from '#core/kiwi/fig/instance-overrides/sync'
import type { OverrideContext } from '#core/kiwi/fig/instance-overrides/types'
import type { SceneNode } from '#core/scene-graph'
import { copyGeometryPaths } from '#core/scene-graph/copy'
@ -54,7 +54,9 @@ export function propagateDsdChanges(
const clone = ctx.graph.getNode(cloneId)
if (!clone) continue
const updates = buildCloneUpdates(ctx, source, clone, cloneId, sizeSet)
if (Object.keys(updates).length > 0) ctx.graph.updateNode(cloneId, updates)
if (Object.keys(updates).length > 0) {
ctx.graph.preserveSourceMetadataDuring(() => ctx.graph.updateNode(cloneId, updates))
}
queue.push(cloneId)
}
}

View file

@ -10,7 +10,7 @@ export type {
SymbolOverride
} from './types'
import { guidToString } from '#core/kiwi/node-change/convert'
import { guidToString } from '#core/kiwi/fig/node-change/convert'
import type { SceneGraph, SceneNode } from '#core/scene-graph'
import { copyFills, copyStyleRuns } from '#core/scene-graph/copy'
@ -273,4 +273,5 @@ export function populateAndApplyOverrides(
)
propagateResolvedTextClones(graph)
applyConstraintScaling(ctx)
applyComponentProperties(ctx)
}

View file

@ -1,5 +1,5 @@
import { repopulateInstance } from '#core/kiwi/instance-overrides/resolve'
import type { OverrideContext } from '#core/kiwi/instance-overrides/types'
import { repopulateInstance } from '#core/kiwi/fig/instance-overrides/resolve'
import type { OverrideContext } from '#core/kiwi/fig/instance-overrides/types'
import type { SceneNode } from '#core/scene-graph'
import { protectField, protectPatchProps } from './protection'
@ -37,9 +37,10 @@ export function applyOverridePatch(ctx: OverrideContext, patch: OverridePatch):
if (patch.props && Object.keys(patch.props).length > 0) {
const target = ctx.graph.getNode(patch.targetId)
if (target) {
preserveStrokeShapeProps(target, patch.props)
ctx.graph.updateNode(patch.targetId, patch.props)
protectPatchProps(ctx.protectedFields, patch.targetId, patch.props)
const props = patch.props
preserveStrokeShapeProps(target, props)
ctx.graph.preserveSourceMetadataDuring(() => ctx.graph.updateNode(patch.targetId, props))
protectPatchProps(ctx.protectedFields, patch.targetId, props)
changed = true
}
}

View file

@ -1,5 +1,5 @@
import type { GUID } from '#core/kiwi/binary/codec'
import { guidToString } from '#core/kiwi/node-change/convert'
import type { GUID } from '#core/kiwi/fig/codec'
import { guidToString } from '#core/kiwi/fig/node-change/convert'
import type { SceneNode } from '#core/scene-graph'
import { copyStrokes } from '#core/scene-graph/copy'
@ -119,26 +119,26 @@ function getSiblingGroups(ctx: OverrideContext): Map<string, string[]> {
return groups
}
function sourceSiblingIndex(ctx: OverrideContext, figmaGuid: string): number | null {
function sourceSiblingIndex(ctx: OverrideContext, sourceId: string): number | null {
let cache = siblingIndexCache.get(ctx)
if (!cache) {
cache = new Map()
siblingIndexCache.set(ctx, cache)
}
if (cache.has(figmaGuid)) return cache.get(figmaGuid) ?? null
if (cache.has(sourceId)) return cache.get(sourceId) ?? null
const nc = ctx.changeMap.get(figmaGuid)
const nc = ctx.changeMap.get(sourceId)
const parentId = nc?.parentIndex?.guid ? guidToString(nc.parentIndex.guid) : null
const symbolId = nc?.symbolData?.symbolID ? guidToString(nc.symbolData.symbolID) : null
if (!nc || !parentId || !symbolId) {
cache.set(figmaGuid, null)
cache.set(sourceId, null)
return null
}
const siblings = getSiblingGroups(ctx).get(`${parentId}\0${symbolId}`) ?? []
const index = siblings.indexOf(figmaGuid)
const index = siblings.indexOf(sourceId)
const result = index !== -1 ? index : null
cache.set(figmaGuid, result)
cache.set(sourceId, result)
return result
}
@ -169,9 +169,9 @@ function findNodeBySourceSiblingIndex(
ctx: OverrideContext,
parentId: string,
componentId: string,
figmaGuid: string
sourceId: string
): string | null {
const index = sourceSiblingIndex(ctx, figmaGuid)
const index = sourceSiblingIndex(ctx, sourceId)
if (index == null) return null
const targetRoot = ctx.preComputedRoot.get(componentId) ?? getComponentRoot(ctx, componentId)
@ -265,13 +265,13 @@ export function findNodeByComponentId(
/**
* Resolve a guidPath to a target node within an instance subtree.
*
* Each GUID in the path identifies an overrideKey → figmaGuid → graph node.
* Each GUID in the path identifies an overrideKey → source id → graph node.
* The chain walks from the instance down to the target.
*/
function resolveOverrideStep(
ctx: OverrideContext,
currentId: string,
figmaGuid: string,
sourceId: string,
remapped: string | undefined,
targetNc: InstanceNodeChange | undefined
): string | null {
@ -282,7 +282,7 @@ function resolveOverrideStep(
return (
findNodeByComponentId(ctx, currentId, remapped) ??
findNodeBySourceSiblingIndex(ctx, currentId, remapped, figmaGuid) ??
findNodeBySourceSiblingIndex(ctx, currentId, remapped, sourceId) ??
findNodeByNameAndType(ctx, currentId, targetNc?.name, targetNc?.type)
)
}
@ -295,14 +295,14 @@ export function resolveOverrideTarget(
let currentId = instanceId
for (let index = 0; index < guids.length; index++) {
const key = guidToString(guids[index])
const figmaGuid = ctx.overrideKeyToGuid.get(key) ?? key
const targetNc = ctx.changeMap.get(figmaGuid)
const sourceId = ctx.overrideKeyToGuid.get(key) ?? key
const targetNc = ctx.changeMap.get(sourceId)
const symbolGuid = targetNc?.symbolData?.symbolID
? guidToString(targetNc.symbolData.symbolID)
: null
const remapped =
ctx.guidToNodeId.get(figmaGuid) ?? (symbolGuid ? ctx.guidToNodeId.get(symbolGuid) : undefined)
const resolved = resolveOverrideStep(ctx, currentId, figmaGuid, remapped, targetNc)
ctx.guidToNodeId.get(sourceId) ?? (symbolGuid ? ctx.guidToNodeId.get(symbolGuid) : undefined)
const resolved = resolveOverrideStep(ctx, currentId, sourceId, remapped, targetNc)
if (resolved) {
currentId = resolved
continue
@ -343,7 +343,11 @@ function applyStrokeDescendants(ctx: OverrideContext, nodeId: string, strokes: S
const node = ctx.graph.getNode(id)
if (!node) return
if (node.strokes.length > 0) {
if (index < strokes.length) ctx.graph.updateNode(id, { strokes: copyStrokes(strokes[index]) })
if (index < strokes.length) {
ctx.graph.preserveSourceMetadataDuring(() => {
ctx.graph.updateNode(id, { strokes: copyStrokes(strokes[index]) })
})
}
index++
}
for (const childId of node.childIds) visit(childId)
@ -364,7 +368,7 @@ export function repopulateInstance(ctx: OverrideContext, nodeId: string, compId:
if (comp?.name && rootComp?.name && node.name === rootComp.name) {
updates.name = comp.name
}
ctx.graph.updateNode(nodeId, updates)
ctx.graph.preserveSourceMetadataDuring(() => ctx.graph.updateNode(nodeId, updates))
if (comp && comp.childIds.length > 0) {
ctx.graph.populateInstanceChildren(nodeId, compId)
applyStrokeDescendants(ctx, nodeId, previousStrokes)

View file

@ -1,6 +1,6 @@
import { applyOverridePatch } from '#core/kiwi/instance-overrides/patches'
import { resolveOverrideTarget } from '#core/kiwi/instance-overrides/resolve'
import type { OverrideContext } from '#core/kiwi/instance-overrides/types'
import { applyOverridePatch } from '#core/kiwi/fig/instance-overrides/patches'
import { resolveOverrideTarget } from '#core/kiwi/fig/instance-overrides/resolve'
import type { OverrideContext } from '#core/kiwi/fig/instance-overrides/types'
import { patchFromSymbolOverride } from './patches'

View file

@ -1,7 +1,7 @@
import type { OverridePatch } from '#core/kiwi/instance-overrides/patches'
import type { OverrideContext, SymbolOverride } from '#core/kiwi/instance-overrides/types'
import { guidToString } from '#core/kiwi/node-change/convert'
import { applyStyleRefsToFields } from '#core/kiwi/node-change/style-refs'
import type { OverridePatch } from '#core/kiwi/fig/instance-overrides/patches'
import type { OverrideContext, SymbolOverride } from '#core/kiwi/fig/instance-overrides/types'
import { guidToString } from '#core/kiwi/fig/node-change/convert'
import { applyStyleRefsToFields } from '#core/kiwi/fig/node-change/style-refs'
import { convertOverrideToProps } from './props'

View file

@ -1,4 +1,4 @@
import type { NodeChange, Paint, Effect as KiwiEffect } from '#core/kiwi/binary/codec'
import type { NodeChange, Paint, Effect as KiwiEffect } from '#core/kiwi/fig/codec'
import {
convertFills,
mapStackSizing,
@ -12,7 +12,7 @@ import {
importStyleRuns,
convertStrokes,
convertEffects
} from '#core/kiwi/node-change/convert'
} from '#core/kiwi/fig/node-change/convert'
import type { SceneNode, ArcData, TextAutoResize } from '#core/scene-graph'
import { styleToWeight } from '#core/text/fonts'
import type { Vector } from '#core/types'

View file

@ -1,4 +1,4 @@
import type { ProtectionMap } from '#core/kiwi/instance-overrides/patches'
import type { ProtectionMap } from '#core/kiwi/fig/instance-overrides/patches'
import type { SceneGraph, SceneNode } from '#core/scene-graph'
import { syncNodeProps } from './fields'

View file

@ -1,5 +1,5 @@
import type { ProtectionMap, ProtectedField } from '#core/kiwi/instance-overrides/patches'
import { isFieldProtected } from '#core/kiwi/instance-overrides/patches'
import type { ProtectionMap, ProtectedField } from '#core/kiwi/fig/instance-overrides/patches'
import { isFieldProtected } from '#core/kiwi/fig/instance-overrides/patches'
import type { SceneGraph, SceneNode } from '#core/scene-graph'
import { copyFills, copyStrokes, copyEffects, copyStyleRuns } from '#core/scene-graph/copy'

View file

@ -1,4 +1,4 @@
import type { ProtectionMap } from '#core/kiwi/instance-overrides/patches'
import type { ProtectionMap } from '#core/kiwi/fig/instance-overrides/patches'
import type { SceneGraph } from '#core/scene-graph'
import { buildClonesMap, syncChildrenDeep } from './clones'

View file

@ -1,4 +1,4 @@
import type { GUID, NodeChange } from '#core/kiwi/binary/codec'
import type { GUID, NodeChange } from '#core/kiwi/fig/codec'
import type { SceneGraph } from '#core/scene-graph'
import type { Matrix, Vector } from '#core/types'

View file

@ -1,5 +1,5 @@
import { populateAndApplyOverrides } from '#core/kiwi/instance-overrides'
import type { InstanceNodeChange } from '#core/kiwi/instance-overrides'
import { populateAndApplyOverrides } from '#core/kiwi/fig/instance-overrides'
import type { InstanceNodeChange } from '#core/kiwi/fig/instance-overrides'
import type { SceneGraph } from '#core/scene-graph'
export interface LazyFigImportContext {
@ -27,13 +27,15 @@ function populateRoots(
const pending = [...rootIds].filter((id) => id && !context.populatedRootIds.has(id))
if (pending.length === 0) return false
populateAndApplyOverrides(
graph,
context.changeMap,
context.guidToNodeId,
context.blobs,
pending
)
graph.preserveSourceMetadataDuring(() => {
populateAndApplyOverrides(
graph,
context.changeMap,
context.guidToNodeId,
context.blobs,
pending
)
})
for (const id of pending) context.populatedRootIds.add(id)
return true

View file

@ -23,7 +23,7 @@ import {
import { resolveGeometryPaths, resolveVectorNetwork } from './vector-geometry'
export { resolveGeometryPaths } from './vector-geometry'
import type { NodeChange } from '#core/kiwi/binary/codec'
import type { NodeChange } from '#core/kiwi/fig/codec'
import type {
SceneNode,
NodeType,
@ -460,6 +460,7 @@ export function nodeChangeToProps(
return {
nodeType,
name: nc.name ?? nodeType,
source: extractSourceMetadata(nc, blobs),
...convertTransformProps(nc),
opacity: nc.opacity ?? 1,
visible: nc.visible ?? true,
@ -628,6 +629,43 @@ function isComponentSet(nc: NodeChange): boolean {
return defs.some((d) => d.type === 'VARIANT')
}
function extractFigmaLayoutMetadata(nc: NodeChange): SceneNode['source']['fig']['layout'] {
return {
stackMode: nc.stackMode,
stackSpacing: nc.stackSpacing,
stackPadding: nc.stackPadding,
stackPaddingRight: nc.stackPaddingRight,
stackPaddingBottom: nc.stackPaddingBottom,
stackCounterAlign: nc.stackCounterAlign,
stackJustify: nc.stackJustify,
stackCounterAlignItems: nc.stackCounterAlignItems,
stackPrimaryAlignItems: nc.stackPrimaryAlignItems,
stackPrimarySizing: nc.stackPrimarySizing,
stackCounterSizing: nc.stackCounterSizing,
stackVerticalPadding: nc.stackVerticalPadding,
stackHorizontalPadding: nc.stackHorizontalPadding,
stackWrap: nc.stackWrap,
stackPositioning: nc.stackPositioning,
stackChildPrimaryGrow: nc.stackChildPrimaryGrow,
stackChildAlignSelf: nc.stackChildAlignSelf,
stackCounterSpacing: nc.stackCounterSpacing,
bordersTakeSpace: nc.bordersTakeSpace as boolean | undefined
}
}
function extractSourceMetadata(nc: NodeChange, blobs: Uint8Array[]): SceneNode['source'] {
return {
format: 'fig',
id: nc.guid ? guidToString(nc.guid) : null,
orderKey: nc.parentIndex?.position ?? null,
fig: {
...extractFigmaRawGeometry(nc, blobs),
...extractFigmaSymbolMetadata(nc, blobs),
layout: extractFigmaLayoutMetadata(nc)
}
}
}
export function sortChildren(
children: string[],
parentNc: NodeChange,
@ -652,6 +690,122 @@ export function sortChildren(
}
}
interface PreservedFigmaBlob {
__openPencilFigmaBlob: Uint8Array
}
function preserveFigmaPayloadBlobs(value: unknown, blobs: Uint8Array[]): unknown {
if (value instanceof Uint8Array) return value
if (Array.isArray(value)) return value.map((item) => preserveFigmaPayloadBlobs(item, blobs))
if (!value || typeof value !== 'object') return value
const result: Record<string, unknown> = {}
for (const [key, child] of Object.entries(value)) {
if ((key === 'commandsBlob' || key === 'vectorNetworkBlob') && typeof child === 'number') {
const blob: unknown = blobs[child]
if (blob == null) {
result[key] = child
} else {
result[key] = {
__openPencilFigmaBlob:
blob instanceof Uint8Array ? blob : new Uint8Array(Object.values(blob as Record<string, number>))
} satisfies PreservedFigmaBlob
}
} else {
result[key] = preserveFigmaPayloadBlobs(child, blobs)
}
}
return result
}
const FIGMA_RAW_NODE_FIELD_KEYS = [
'styleIdForFill',
'styleIdForStrokeFill',
'styleIdForText',
'styleIdForEffect',
'styleIdForGrid',
'componentPropDefs',
'componentPropRefs',
'variantPropSpecs',
'stateGroupPropertyValueOrders',
'isStateGroup',
'version',
'sourceLibraryKey',
'userFacingVersion',
'sortPosition',
'variableConsumptionMap',
'parameterConsumptionMap',
'editInfo',
'backgroundColor',
'pageType',
'guides',
'miterLimit',
'strokeWeight',
'strokeJoin',
'borderStrokeWeightsIndependent',
'borderTopWeight',
'borderRightWeight',
'borderBottomWeight',
'borderLeftWeight',
'textAutoResize',
'textData',
'lineHeight',
'fontName',
'fontSize',
'letterSpacing',
'textTracking',
'fontVersion',
'textUserLayoutVersion',
'textExplicitLayoutVersion',
'fontVariations',
'derivedTextData',
'fillPaints',
'strokePaints',
'effects',
'vectorData',
'fillGeometry',
'strokeGeometry'
]
function extractFigmaRawGeometry(
nc: NodeChange,
blobs: Uint8Array[]
): Pick<SceneNode['source']['fig'], 'rawSize' | 'rawTransform' | 'rawNodeFields'> {
const rawNodeFields: Record<string, unknown> = {}
for (const key of FIGMA_RAW_NODE_FIELD_KEYS) {
const value = (nc as Record<string, unknown>)[key]
if (value !== undefined) rawNodeFields[key] = preserveFigmaPayloadBlobs(value, blobs)
}
return {
rawSize: nc.size ? { ...nc.size } : null,
rawTransform: nc.transform ? { ...nc.transform } : null,
rawNodeFields
}
}
function extractFigmaSymbolMetadata(
nc: NodeChange,
blobs: Uint8Array[]
): Pick<
SceneNode['source']['fig'],
| 'symbolOverrides'
| 'componentPropAssignments'
| 'derivedSymbolData'
| 'derivedSymbolDataLayoutVersion'
| 'uniformScaleFactor'
> {
const sd = nc.symbolData as
| { symbolOverrides?: unknown[]; uniformScaleFactor?: number }
| undefined
return {
symbolOverrides: preserveFigmaPayloadBlobs(sd?.symbolOverrides ?? [], blobs) as unknown[],
componentPropAssignments: preserveFigmaPayloadBlobs(nc.componentPropAssignments ?? [], blobs) as unknown[],
derivedSymbolData: preserveFigmaPayloadBlobs(nc.derivedSymbolData ?? [], blobs) as unknown[],
derivedSymbolDataLayoutVersion:
typeof nc.derivedSymbolDataLayoutVersion === 'number' ? nc.derivedSymbolDataLayoutVersion : null,
uniformScaleFactor: typeof sd?.uniformScaleFactor === 'number' ? sd.uniformScaleFactor : null
}
}
function extractSymbolId(nc: NodeChange): string {
const sd = nc.symbolData as { symbolID?: GUID } | undefined
if (!sd?.symbolID) return ''

View file

@ -1,4 +1,4 @@
import type { NodeChange } from '#core/kiwi/binary/codec'
import type { NodeChange } from '#core/kiwi/fig/codec'
import type { FigmaDerivedTextGlyph } from '#core/scene-graph'
export function convertFigmaDerivedTextGlyphs(

View file

@ -0,0 +1,552 @@
import { bytesToHex } from '#core/bytes/hex'
import type { NodeChange, Paint } from '#core/kiwi/fig/codec'
import type { SceneGraph, SceneNode } from '#core/scene-graph'
import type { Color, GUID, Matrix, Vector } from '#core/types'
import { stringToGuid } from './guid'
import {
mergePluginData,
NODE_TYPE_PLUGIN_KEY,
serializePluginRelaunchData,
upsertPluginData
} from './plugin-data'
export type KiwiNodeChange = NodeChange & Record<string, unknown>
interface SceneNodeToKiwiContext {
graph: SceneGraph
blobs: Uint8Array[]
blobIndexByHex?: Map<string, number>
nodeIdToGuid?: Map<string, GUID>
fontDigestMap?: Map<string, Uint8Array>
glyphBlobMap?: Map<string, number>
varIdToGuid?: Map<string, GUID>
paintVariableColorMap?: Map<string, Color>
fractionalPosition: (index: number) => string
mapToFigmaType: (type: SceneNode['type']) => string
fillToKiwiPaint: (fill: SceneNode['fills'][number]) => Paint
safeColor: (color: Color) => Color
computeExportTransform: (node: SceneNode) => Matrix
serializeCornerRadii: (node: SceneNode, nc: KiwiNodeChange) => void
serializeTextProps: (
node: SceneNode,
nc: KiwiNodeChange,
graph: SceneGraph,
fontDigestMap: Map<string, Uint8Array> | undefined,
blobs: Uint8Array[],
glyphBlobMap: Map<string, number> | undefined
) => void
serializeLayoutProps: (node: SceneNode, nc: KiwiNodeChange) => void
serializeGeometry: (node: SceneNode, nc: KiwiNodeChange, blobs: Uint8Array[]) => void
serializeVariableBindings: (
node: SceneNode,
nc: KiwiNodeChange,
graph: SceneGraph,
varIdToGuid?: Map<string, GUID>
) => void
sceneNodeToKiwi: (
node: SceneNode,
parentGuid: GUID,
childIndex: number,
localIdCounter: { value: number },
context: SceneNodeToKiwiContext
) => KiwiNodeChange[]
}
const DEFAULT_STROKE_WEIGHT = 1
function applyColorVariableBinding(
context: SceneNodeToKiwiContext,
node: SceneNode,
paint: Paint,
field: string
): Paint {
const variableId = node.boundVariables[field]
if (!variableId) return paint
return {
...paint,
colorVariableBinding: {
variableID: context.varIdToGuid?.get(variableId) ?? stringToGuid(variableId)
}
}
}
function createStrokePaints(context: SceneNodeToKiwiContext, node: SceneNode): Paint[] {
return node.strokes.map((stroke, index) =>
applyColorVariableBinding(
context,
node,
{
type: 'SOLID',
color: context.safeColor(stroke.color),
opacity: stroke.opacity,
visible: stroke.visible,
blendMode: 'NORMAL'
},
`strokes/${index}/color`
)
)
}
function componentPropertyValue(value: string) {
return { textValue: { characters: value } }
}
function componentPropertyTypeForKiwi(type: string) {
if (type === 'BOOLEAN') return 'BOOL'
if (type === 'VARIANT') return 'TEXT'
return type
}
function parseGuidOrNull(value: string) {
return /^\d+:\d+$/.test(value) ? stringToGuid(value) : null
}
const FIGMA_PAYLOAD_VARIABLE_MAP_FIELDS = new Set(['variableConsumptionMap', 'parameterConsumptionMap'])
const FIGMA_PAYLOAD_PAINT_VARIABLE_FIELDS = new Set(['colorVar', 'opacityVar'])
const SUPPORTED_VARIABLE_DATA_TYPES = new Set([
'BOOLEAN',
'FLOAT',
'STRING',
'ALIAS',
'COLOR',
'SYMBOL_ID',
'TEXT_DATA',
'PROP_REF'
])
function isSupportedVariableMapEntry(value: unknown): boolean {
if (!value || typeof value !== 'object') return false
const entry = value as { variableData?: { dataType?: string; value?: { propRefValue?: unknown } } }
const dataType = entry.variableData?.dataType
return (typeof dataType === 'string' && SUPPORTED_VARIABLE_DATA_TYPES.has(dataType)) || !!entry.variableData?.value?.propRefValue
}
function isPropRefVariableMapEntry(value: unknown): boolean {
if (!value || typeof value !== 'object') return false
const entry = value as { variableData?: { dataType?: string; value?: { propRefValue?: unknown } } }
return entry.variableData?.dataType === 'PROP_REF' || !!entry.variableData?.value?.propRefValue
}
function materializeSafeVariableMap(
value: unknown,
blobs: Uint8Array[],
options: MaterializeFigmaPayloadOptions,
predicate: (value: unknown) => boolean
): unknown {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
const entries = (value as { entries?: unknown[] }).entries?.filter(predicate) ?? []
if (entries.length === 0) return undefined
return { entries: entries.map((entry) => materializeFigmaPayload(entry, blobs, options)) }
}
function paintVariableKey(value: unknown): string | null {
if (!value || typeof value !== 'object') return null
const assetRef = (value as { value?: { alias?: { assetRef?: { key?: unknown; version?: unknown } } } })
.value?.alias?.assetRef
return typeof assetRef?.key === 'string'
? `${assetRef.key}:${typeof assetRef.version === 'string' ? assetRef.version : ''}`
: null
}
interface MaterializeFigmaPayloadOptions {
blobIndexByHex?: Map<string, number>
includePaintVariables?: boolean
includeVariableMaps?: boolean
paintVariableColorMap?: Map<string, Color>
}
function materializeFigmaBlob(
value: { __openPencilFigmaBlob?: Uint8Array | Record<string, number> },
blobs: Uint8Array[],
options: MaterializeFigmaPayloadOptions
): number {
const blob = value.__openPencilFigmaBlob
const bytes = blob instanceof Uint8Array ? blob : new Uint8Array(Object.values(blob ?? {}))
const key = bytesToHex(bytes)
const existing = options.blobIndexByHex?.get(key)
if (existing !== undefined) return existing
const index = blobs.length
blobs.push(bytes)
options.blobIndexByHex?.set(key, index)
return index
}
function normalizeFigmaPayloadValue(key: string, value: unknown): unknown {
if (
(key === 'stackJustify' ||
key === 'stackPrimaryAlignItems' ||
key === 'stackCounterAlign' ||
key === 'stackCounterAlignItems') &&
value === 'SPACE_EVENLY'
) {
return 'SPACE_BETWEEN'
}
return value
}
function materializeFigmaPayload(
value: unknown,
blobs: Uint8Array[],
options: MaterializeFigmaPayloadOptions = {}
): unknown {
if (value instanceof Uint8Array) return value
if (Array.isArray(value)) return value.map((item) => materializeFigmaPayload(item, blobs, options))
if (!value || typeof value !== 'object') return value
if ('__openPencilFigmaBlob' in value) {
return materializeFigmaBlob(
value as { __openPencilFigmaBlob?: Uint8Array | Record<string, number> },
blobs,
options
)
}
const materialized: Record<string, unknown> = {}
const paintVariableColor = options.paintVariableColorMap?.get(
paintVariableKey((value as { colorVar?: unknown }).colorVar) ?? ''
)
for (const [key, child] of Object.entries(value)) {
if (FIGMA_PAYLOAD_PAINT_VARIABLE_FIELDS.has(key) && !options.includePaintVariables) continue
if (FIGMA_PAYLOAD_VARIABLE_MAP_FIELDS.has(key)) {
const variableMap = materializeSafeVariableMap(
child,
blobs,
options,
options.includeVariableMaps ? isSupportedVariableMapEntry : isPropRefVariableMapEntry
)
if (variableMap !== undefined) materialized[key] = variableMap
continue
}
materialized[key] = normalizeFigmaPayloadValue(
key,
materializeFigmaPayload(child, blobs, options)
)
}
if (paintVariableColor) materialized.color = paintVariableColor
return materialized
}
function collectPaintVariableColorCounts(
value: unknown,
counts: Map<string, Map<string, { color: Color; count: number }>>
): void {
if (!value || typeof value !== 'object' || ArrayBuffer.isView(value)) return
if (Array.isArray(value)) {
for (const item of value) {
if (item && typeof item === 'object') collectPaintVariableColorCounts(item, counts)
}
return
}
const paint = value as { color?: Color; colorVar?: unknown }
const key = paintVariableKey(paint.colorVar)
if (key && paint.color) {
const colorKey = [paint.color.r, paint.color.g, paint.color.b, paint.color.a]
.map((component) => Math.round(component * 255))
.join(',')
const colorCounts = counts.get(key) ?? new Map<string, { color: Color; count: number }>()
const current = colorCounts.get(colorKey)
colorCounts.set(colorKey, { color: paint.color, count: (current?.count ?? 0) + 1 })
counts.set(key, colorCounts)
}
for (const child of Object.values(value)) collectPaintVariableColorCounts(child, counts)
}
export function buildFigmaPaintVariableColorMap(graph: SceneGraph): Map<string, Color> {
const counts = new Map<string, Map<string, { color: Color; count: number }>>()
for (const node of graph.nodes.values()) {
collectPaintVariableColorCounts(node.source.fig.rawNodeFields, counts)
collectPaintVariableColorCounts(node.source.fig.symbolOverrides, counts)
collectPaintVariableColorCounts(node.source.fig.componentPropAssignments, counts)
collectPaintVariableColorCounts(node.source.fig.derivedSymbolData, counts)
}
const colors = new Map<string, Color>()
for (const [key, colorCounts] of counts) {
const [mostCommon] = [...colorCounts.values()].sort((a, b) => b.count - a.count)
colors.set(key, mostCommon.color)
}
return colors
}
function resolveInstanceComponentId(context: SceneNodeToKiwiContext, componentId: string): string {
const seen = new Set<string>()
let currentId = componentId
while (!seen.has(currentId)) {
seen.add(currentId)
const node = context.graph.getNode(currentId)
if (node?.type !== 'INSTANCE' || !node.componentId) return currentId
currentId = node.componentId
}
return componentId
}
function getOrCreateNodeGuid(
context: SceneNodeToKiwiContext,
nodeId: string,
localIdCounter: { value: number }
): GUID | undefined {
if (!context.graph.getNode(nodeId)) return undefined
const existing = context.nodeIdToGuid?.get(nodeId)
if (existing) return existing
const node = context.graph.getNode(nodeId)
const importedGuid = node?.source.id ? parseGuidOrNull(node.source.id) : null
const guid = importedGuid ?? { sessionID: 1, localID: localIdCounter.value++ }
context.nodeIdToGuid?.set(nodeId, guid)
return guid
}
function applyRawFigmaNodeFields(
context: SceneNodeToKiwiContext,
node: SceneNode,
nc: KiwiNodeChange
): void {
Object.assign(
nc,
materializeFigmaPayload(node.source.fig.rawNodeFields, context.blobs, {
blobIndexByHex: context.blobIndexByHex
})
)
}
function applyInstancePayload(
context: SceneNodeToKiwiContext,
node: SceneNode,
nc: KiwiNodeChange,
localIdCounter: { value: number }
): void {
if (node.type !== 'INSTANCE' || !node.componentId) return
const symbolID = getOrCreateNodeGuid(
context,
resolveInstanceComponentId(context, node.componentId),
localIdCounter
)
if (symbolID) {
const symbolData: Record<string, unknown> = { symbolID }
if (node.source.fig.symbolOverrides.length > 0) {
symbolData.symbolOverrides = materializeFigmaPayload(node.source.fig.symbolOverrides, context.blobs, {
blobIndexByHex: context.blobIndexByHex,
includeVariableMaps: true,
paintVariableColorMap: context.paintVariableColorMap
})
}
if (node.source.fig.uniformScaleFactor != null) {
symbolData.uniformScaleFactor = node.source.fig.uniformScaleFactor
}
nc.symbolData = symbolData as KiwiNodeChange['symbolData']
}
if (node.source.fig.componentPropAssignments.length > 0) {
nc.componentPropAssignments = materializeFigmaPayload(
node.source.fig.componentPropAssignments,
context.blobs,
{
blobIndexByHex: context.blobIndexByHex,
includeVariableMaps: true,
paintVariableColorMap: context.paintVariableColorMap
}
)
}
if (node.source.fig.derivedSymbolData.length > 0) {
nc.derivedSymbolData = materializeFigmaPayload(node.source.fig.derivedSymbolData, context.blobs, {
blobIndexByHex: context.blobIndexByHex,
includeVariableMaps: true,
paintVariableColorMap: context.paintVariableColorMap
})
}
if (node.source.fig.derivedSymbolDataLayoutVersion != null) {
nc.derivedSymbolDataLayoutVersion = node.source.fig.derivedSymbolDataLayoutVersion
}
}
function applyComponentMetadata(node: SceneNode, nc: KiwiNodeChange): void {
if (node.componentKey) nc.componentKey = node.componentKey
if (node.sourceLibraryKey) nc.sourceLibraryKey = node.sourceLibraryKey
const publishId = node.publishId ? parseGuidOrNull(node.publishId) : null
const overrideKey = node.overrideKey ? parseGuidOrNull(node.overrideKey) : null
if (publishId) nc.publishID = publishId
if (overrideKey) nc.overrideKey = overrideKey
if (node.sharedSymbolVersion) nc.sharedSymbolVersion = node.sharedSymbolVersion
if (node.publishedVersion) nc.publishedVersion = node.publishedVersion
if (node.type === 'COMPONENT_SET' || node.isPublishable) nc.isPublishable = node.isPublishable
if (node.type === 'COMPONENT' || node.isSymbolPublishable) {
nc.isSymbolPublishable = node.isSymbolPublishable
}
if (node.symbolDescription) nc.symbolDescription = node.symbolDescription
if (node.symbolLinks.length > 0) nc.symbolLinks = structuredClone(node.symbolLinks)
const componentPropDefs = node.componentPropertyDefinitions
.map((def) => {
const id = parseGuidOrNull(def.id)
return id
? {
id,
name: def.name,
type: componentPropertyTypeForKiwi(def.type),
initialValue: componentPropertyValue(def.defaultValue)
}
: null
})
.filter((def): def is NonNullable<typeof def> => def !== null)
if (componentPropDefs.length > 0) nc.componentPropDefs = componentPropDefs
const variantPropSpecs = node.variantPropSpecs
.map((spec) => {
const propDefId = parseGuidOrNull(spec.propDefId)
return propDefId ? { propDefId, value: spec.value } : null
})
.filter((spec): spec is NonNullable<typeof spec> => spec !== null)
if (variantPropSpecs.length > 0) nc.variantPropSpecs = variantPropSpecs
}
function exportNodeSize(node: SceneNode): Vector {
return node.source.fig.rawSize ? { ...node.source.fig.rawSize } : { x: node.width, y: node.height }
}
function exportNodeTransform(context: SceneNodeToKiwiContext, node: SceneNode): Matrix {
return node.source.fig.rawTransform ? { ...node.source.fig.rawTransform } : context.computeExportTransform(node)
}
function hasRawGeometryPayload(node: SceneNode): boolean {
return 'fillGeometry' in node.source.fig.rawNodeFields || 'strokeGeometry' in node.source.fig.rawNodeFields
}
function hasRawVectorPayload(node: SceneNode): boolean {
return 'vectorData' in node.source.fig.rawNodeFields
}
function nodeForGeometryExport(node: SceneNode): SceneNode {
if (!hasRawGeometryPayload(node) && !hasRawVectorPayload(node)) return node
return {
...node,
fillGeometry: hasRawGeometryPayload(node) ? [] : node.fillGeometry,
strokeGeometry: hasRawGeometryPayload(node) ? [] : node.strokeGeometry,
vectorNetwork: hasRawVectorPayload(node) ? null : node.vectorNetwork
}
}
function applyNodeVisualProps(
context: SceneNodeToKiwiContext,
node: SceneNode,
nc: KiwiNodeChange
): void {
if (node.independentStrokeWeights) {
nc.borderStrokeWeightsIndependent = true
nc.borderTopWeight = node.borderTopWeight
nc.borderRightWeight = node.borderRightWeight
nc.borderBottomWeight = node.borderBottomWeight
nc.borderLeftWeight = node.borderLeftWeight
}
if (node.fills.length > 0) {
nc.fillPaints = node.fills.map((fill, index) =>
applyColorVariableBinding(
context,
node,
context.fillToKiwiPaint(fill),
`fills/${index}/color`
)
)
}
context.serializeCornerRadii(node, nc)
if (node.effects.length > 0) {
nc.effects = node.effects.map((effect) => ({
type: effect.type === 'LAYER_BLUR' ? 'FOREGROUND_BLUR' : effect.type,
color: context.safeColor(effect.color),
offset: effect.offset,
radius: effect.radius,
spread: effect.spread,
visible: effect.visible,
showShadowBehindNode: effect.showShadowBehindNode
}))
}
if (node.type === 'TEXT') {
context.serializeTextProps(
node,
nc,
context.graph,
context.fontDigestMap,
context.blobs,
context.glyphBlobMap
)
}
if (node.type !== 'VECTOR') nc.frameMaskDisabled = !node.clipsContent
if (node.horizontalConstraint !== 'MIN') nc.horizontalConstraint = node.horizontalConstraint
if (node.verticalConstraint !== 'MIN') nc.verticalConstraint = node.verticalConstraint
if (node.strokeCap !== 'NONE') nc.strokeCap = node.strokeCap
if (node.strokeJoin !== 'MITER') nc.strokeJoin = node.strokeJoin
if (!node.source.id && node.strokeMiterLimit !== 28.96) nc.miterLimit = node.strokeMiterLimit
if (node.dashPattern.length > 0) nc.dashPattern = node.dashPattern
if (node.arcData) {
nc.arcData = {
startingAngle: node.arcData.startingAngle,
endingAngle: node.arcData.endingAngle,
innerRadius: node.arcData.innerRadius
}
}
if (!node.autoRename) nc.autoRename = false
}
export function sceneNodeToKiwiWithContext(
node: SceneNode,
parentGuid: GUID,
childIndex: number,
localIdCounter: { value: number },
context: SceneNodeToKiwiContext
): KiwiNodeChange[] {
const guid = getOrCreateNodeGuid(context, node.id, localIdCounter) ?? {
sessionID: 1,
localID: localIdCounter.value++
}
const strokePaints = createStrokePaints(context, node)
const nc: KiwiNodeChange = {
guid,
parentIndex: {
guid: parentGuid,
position: node.source.orderKey ?? context.fractionalPosition(childIndex)
},
type: context.mapToFigmaType(node.type),
name: node.name,
visible: node.visible,
opacity: node.opacity,
phase: 'CREATED',
size: exportNodeSize(node),
transform: exportNodeTransform(context, node),
strokeWeight: node.strokes[0]?.weight ?? DEFAULT_STROKE_WEIGHT,
strokeAlign: node.strokes[0]?.align ?? 'INSIDE'
}
applyNodeVisualProps(context, node, nc)
applyComponentMetadata(node, nc)
applyInstancePayload(context, node, nc, localIdCounter)
if (node.type === 'COMPONENT_SET') upsertPluginData(node, NODE_TYPE_PLUGIN_KEY, node.type)
if (nc.type === 'CANVAS') nc.pageType = 'DESIGN'
if (strokePaints.length > 0) nc.strokePaints = strokePaints
context.serializeLayoutProps(node, nc)
context.serializeGeometry(nodeForGeometryExport(node), nc, context.blobs)
context.serializeVariableBindings(node, nc, context.graph, context.varIdToGuid)
applyRawFigmaNodeFields(context, node, nc)
const pluginData = mergePluginData(node.pluginData)
if (pluginData.length > 0) nc.pluginData = pluginData
if (node.pluginRelaunchData.length > 0) {
nc.pluginRelaunchData = serializePluginRelaunchData(node.pluginRelaunchData)
}
const result: KiwiNodeChange[] = [nc]
const children =
node.type === 'INSTANCE'
? []
: context.graph.getChildren(node.id).filter((child) => !child.internalOnly)
for (let i = 0; i < children.length; i++) {
result.push(...context.sceneNodeToKiwi(children[i], guid, i, localIdCounter, context))
}
return result
}

View file

@ -1,4 +1,4 @@
import type { GUID } from '#core/kiwi/binary/codec'
import type { GUID } from '#core/kiwi/fig/codec'
export function guidToString(guid: GUID): string {
return `${guid.sessionID}:${guid.localID}`

View file

@ -1,5 +1,5 @@
import { normalizeColor } from '#core/color'
import type { Paint, Effect as KiwiEffect } from '#core/kiwi/binary/codec'
import type { Paint, Effect as KiwiEffect } from '#core/kiwi/fig/codec'
import type {
Fill,
FillType,

View file

@ -1,4 +1,4 @@
import type { NodeChange, PluginData, PluginRelaunchData } from '#core/kiwi/binary/codec'
import type { NodeChange, PluginData, PluginRelaunchData } from '#core/kiwi/fig/codec'
import type { PluginDataEntry, PluginRelaunchDataEntry } from '#core/scene-graph'
import { guidToString } from './guid'

View file

@ -1,5 +1,5 @@
import { hexToBytes } from '#core/bytes/hex'
import { encodePathCommandsBlob } from '#core/kiwi/node-change/path-commands'
import { bytesToHex, hexToBytes } from '#core/bytes/hex'
import { encodePathCommandsBlob } from '#core/kiwi/fig/node-change/path-commands'
import { buildDerivedTextData as buildSharedDerivedTextData } from '#core/text/derived-text/data'
import { normalizeFontFamily, weightToFigmaStyle, weightToStyle } from '#core/text/fonts'
import { getGlyphOutlineMetricsSync } from '#core/text/opentype'
@ -10,10 +10,10 @@ export {
decompressFigKiwiDataAsync,
FIG_KIWI_DEFAULT_VERSION,
parseFigKiwiChunks
} from './fig/container'
} from '#core/kiwi/fig/container/kiwi'
export { buildFontDigestMap } from './font-digests'
import type { NodeChange, Paint, VariableConsumptionEntry } from '#core/kiwi/binary/codec'
import type { NodeChange, Paint, VariableConsumptionEntry } from '#core/kiwi/fig/codec'
import type { SceneGraph, SceneNode, CharacterStyleOverride } from '#core/scene-graph'
import type { Color, GUID, Matrix } from '#core/types'
@ -53,7 +53,7 @@ export function mapToFigmaType(type: SceneNode['type']): string {
case 'COMPONENT':
return 'SYMBOL'
case 'COMPONENT_SET':
return 'SYMBOL'
return 'FRAME'
case 'INSTANCE':
return 'INSTANCE'
case 'CONNECTOR':
@ -74,10 +74,24 @@ function textLines(text: string): NonNullable<NodeChange['textData']>['lines'] {
return Array.from({ length: lineCount }, () => ({ lineType: 'PLAIN' }))
}
function appendGlyphBlob(
blobs: Uint8Array[],
glyphBlobMap: Map<string, number>,
blob: Uint8Array
): number {
const key = bytesToHex(blob)
const existing = glyphBlobMap.get(key)
if (existing !== undefined) return existing
const index = blobs.push(blob) - 1
glyphBlobMap.set(key, index)
return index
}
function buildDerivedTextData(
node: SceneNode,
digestMap: Map<string, Uint8Array>,
blobs: Uint8Array[]
blobs: Uint8Array[],
glyphBlobMap: Map<string, number>
): NodeChange['derivedTextData'] {
const fontMeta: NonNullable<NodeChange['derivedTextData']>['fontMetaData'] = []
const seen = new Set<string>()
@ -106,20 +120,41 @@ function buildDerivedTextData(
)
}
const style = weightToStyle(node.fontWeight, node.italic)
const glyphMetrics =
getGlyphOutlineMetricsSync(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 = glyphMetrics.map((glyph, index) => ({
commandsBlob: blobs.push(encodePathCommandsBlob(glyph.commands, node.fontSize)) - 1,
position: { x: glyph.x || index * glyphAdvance, y: lineHeight },
fontSize: node.fontSize,
firstCharacter: index,
advance: glyph.advance || glyphAdvance,
rotation: 0
}))
const derivedGlyphs = node.figmaDerivedTextGlyphs ?? []
const glyphs =
derivedGlyphs.length > 0
? derivedGlyphs.map((glyph, index) => ({
commandsBlob: appendGlyphBlob(blobs, glyphBlobMap, glyph.commandsBlob),
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,
rotation: 0
}))
: (getGlyphOutlineMetricsSync(
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 },
@ -215,21 +250,21 @@ function fillToKiwiPaint(f: SceneNode['fills'][number]): Paint {
}
function serializeCornerRadii(node: SceneNode, nc: KiwiNodeChange): void {
if (node.cornerRadius > 0 || node.independentCorners) {
const hasCornerRadius = node.independentCorners
? node.topLeftRadius > 0 ||
node.topRightRadius > 0 ||
node.bottomLeftRadius > 0 ||
node.bottomRightRadius > 0
: node.cornerRadius > 0
if (hasCornerRadius) {
nc.cornerRadius = node.cornerRadius
nc.rectangleCornerRadiiIndependent = node.independentCorners
nc.rectangleTopLeftCornerRadius = node.independentCorners
? node.topLeftRadius
: node.cornerRadius
nc.rectangleTopRightCornerRadius = node.independentCorners
? node.topRightRadius
: node.cornerRadius
nc.rectangleBottomLeftCornerRadius = node.independentCorners
? node.bottomLeftRadius
: node.cornerRadius
nc.rectangleBottomRightCornerRadius = node.independentCorners
? node.bottomRightRadius
: node.cornerRadius
if (node.independentCorners) {
nc.rectangleCornerRadiiIndependent = true
nc.rectangleTopLeftCornerRadius = node.topLeftRadius
nc.rectangleTopRightCornerRadius = node.topRightRadius
nc.rectangleBottomLeftCornerRadius = node.bottomLeftRadius
nc.rectangleBottomRightCornerRadius = node.bottomRightRadius
}
}
if (node.cornerSmoothing > 0) {
nc.cornerSmoothing = node.cornerSmoothing
@ -254,7 +289,8 @@ function serializeTextProps(
nc: KiwiNodeChange,
graph: SceneGraph,
fontDigestMap: Map<string, Uint8Array> | undefined,
blobs: Uint8Array[]
blobs: Uint8Array[],
glyphBlobMap: Map<string, number> | undefined
): void {
upsertPluginData(node, TEXT_DIRECTION_PLUGIN_KEY, node.textDirection)
nc.fontSize = node.fontSize
@ -276,7 +312,9 @@ function serializeTextProps(
nc.fontVariantContextualLigatures = true
nc.fontVersion = ''
nc.emojiImageSet = 'APPLE'
if (fontDigestMap) nc.derivedTextData = buildDerivedTextData(node, fontDigestMap, blobs)
if (fontDigestMap) {
nc.derivedTextData = buildDerivedTextData(node, fontDigestMap, blobs, glyphBlobMap ?? new Map())
}
if (node.lineHeight != null) nc.lineHeight = { value: node.lineHeight, units: 'PIXELS' }
nc.letterSpacing = { value: node.letterSpacing, units: 'PIXELS' }
if (node.textDecoration !== 'NONE') {
@ -284,8 +322,53 @@ function serializeTextProps(
}
}
function normalizeStackMode(
value: string | undefined
): KiwiNodeChange['stackMode'] {
return value === 'HORIZONTAL' || value === 'VERTICAL' || value === 'NONE' ? value : undefined
}
function normalizeStackSizing(
value: string | undefined
): KiwiNodeChange['stackPrimarySizing'] {
return value === 'FIXED' || value === 'RESIZE_TO_FIT' || value === 'RESIZE_TO_FIT_WITH_IMPLICIT_SIZE'
? value
: undefined
}
function normalizeStackJustify(value: string | undefined): string | undefined {
return value === 'SPACE_EVENLY' ? 'SPACE_BETWEEN' : value
}
function normalizeStackCounterAlign(value: string | undefined): string | undefined {
return value === 'SPACE_EVENLY' ? 'SPACE_BETWEEN' : value
}
function serializeLayoutProps(node: SceneNode, nc: KiwiNodeChange): void {
upsertPluginData(node, LAYOUT_DIRECTION_PLUGIN_KEY, node.layoutDirection)
if (!node.source.id) upsertPluginData(node, LAYOUT_DIRECTION_PLUGIN_KEY, node.layoutDirection)
const figLayout = node.source.fig.layout
if (figLayout) {
nc.stackMode = normalizeStackMode(figLayout.stackMode)
nc.stackSpacing = figLayout.stackSpacing
nc.stackPadding = figLayout.stackPadding
nc.stackPaddingRight = figLayout.stackPaddingRight
nc.stackPaddingBottom = figLayout.stackPaddingBottom
nc.stackCounterAlign = normalizeStackCounterAlign(figLayout.stackCounterAlign)
nc.stackJustify = normalizeStackJustify(figLayout.stackJustify)
nc.stackCounterAlignItems = normalizeStackCounterAlign(figLayout.stackCounterAlignItems)
nc.stackPrimaryAlignItems = normalizeStackJustify(figLayout.stackPrimaryAlignItems)
nc.stackPrimarySizing = normalizeStackSizing(figLayout.stackPrimarySizing)
nc.stackCounterSizing = normalizeStackSizing(figLayout.stackCounterSizing)
nc.stackVerticalPadding = figLayout.stackVerticalPadding
nc.stackHorizontalPadding = figLayout.stackHorizontalPadding
nc.stackWrap = figLayout.stackWrap
nc.stackPositioning = figLayout.stackPositioning
nc.stackChildPrimaryGrow = figLayout.stackChildPrimaryGrow
nc.stackChildAlignSelf = figLayout.stackChildAlignSelf
nc.stackCounterSpacing = figLayout.stackCounterSpacing
nc.bordersTakeSpace = figLayout.bordersTakeSpace
return
}
if (node.layoutMode !== 'NONE' && node.layoutMode !== 'GRID') {
nc.stackMode = node.layoutMode
nc.stackSpacing = node.itemSpacing
@ -295,8 +378,8 @@ function serializeLayoutProps(node: SceneNode, nc: KiwiNodeChange): void {
nc.stackPaddingRight = node.paddingRight
nc.stackPrimarySizing = node.primaryAxisSizing === 'HUG' ? 'RESIZE_TO_FIT' : 'FIXED'
nc.stackCounterSizing = node.counterAxisSizing === 'HUG' ? 'RESIZE_TO_FIT' : 'FIXED'
nc.stackPrimaryAlignItems = node.primaryAxisAlign
nc.stackCounterAlignItems = node.counterAxisAlign
nc.stackPrimaryAlignItems = normalizeStackJustify(node.primaryAxisAlign)
nc.stackCounterAlignItems = normalizeStackCounterAlign(node.counterAxisAlign)
if (node.layoutWrap === 'WRAP') nc.stackWrap = 'WRAP'
if (node.counterAxisSpacing > 0) nc.stackCounterSpacing = node.counterAxisSpacing
nc.bordersTakeSpace = node.strokesIncludedInLayout
@ -372,20 +455,11 @@ function serializeVariableBindings(
if (entries.length > 0) nc.variableConsumptionMap = { entries }
}
function computeExportTransform(node: SceneNode, graph: SceneGraph): Matrix {
function computeExportTransform(node: SceneNode): Matrix {
const sx = node.flipX ? -1 : 1
const cos = Math.cos((node.rotation * Math.PI) / 180)
const sin = Math.sin((node.rotation * Math.PI) / 180)
// Auto-layout children should have (0,0) transform — Figma computes
// their positions from the layout engine at render time.
const parent = node.parentId ? graph.getNode(node.parentId) : undefined
const isAutoLayoutChild =
parent &&
parent.layoutMode !== 'NONE' &&
parent.layoutMode !== 'GRID' &&
node.layoutPositioning !== 'ABSOLUTE'
const m00 = cos * sx
const m01 = -sin
const m10 = sin * sx
@ -405,10 +479,10 @@ function computeExportTransform(node: SceneNode, graph: SceneGraph): Matrix {
return {
m00,
m01,
m02: isAutoLayoutChild ? 0 : node.x - offsetX,
m02: node.x - offsetX,
m10,
m11,
m12: isAutoLayoutChild ? 0 : node.y - offsetY
m12: node.y - offsetY
}
}
@ -421,14 +495,20 @@ export function sceneNodeToKiwi(
blobs: Uint8Array[],
nodeIdToGuid?: Map<string, GUID>,
fontDigestMap?: Map<string, Uint8Array>,
varIdToGuid?: Map<string, GUID>
varIdToGuid?: Map<string, GUID>,
glyphBlobMap = new Map<string, number>(),
paintVariableColorMap?: Map<string, Color>,
blobIndexByHex?: Map<string, number>
): KiwiNodeChange[] {
return sceneNodeToKiwiWithContext(node, parentGuid, childIndex, localIdCounter, {
graph,
blobs,
blobIndexByHex,
nodeIdToGuid,
fontDigestMap,
glyphBlobMap,
varIdToGuid,
paintVariableColorMap,
fractionalPosition,
mapToFigmaType,
fillToKiwiPaint,
@ -484,6 +564,7 @@ export function makeCanvasNodeChange(
strokeWeight: DEFAULT_STROKE_WEIGHT,
strokeAlign: 'CENTER',
strokeJoin: 'MITER',
pageType: 'DESIGN',
...extra
}
}

View file

@ -1,5 +1,5 @@
import type { GUID, NodeChange } from '#core/kiwi/binary/codec'
import { guidToString } from '#core/kiwi/node-change/guid'
import type { GUID, NodeChange } from '#core/kiwi/fig/codec'
import { guidToString } from '#core/kiwi/fig/node-change/guid'
const TEXT_STYLE_FIELDS = [
'fontSize',

View file

@ -1,4 +1,4 @@
import type { NodeChange } from '#core/kiwi/binary/codec'
import type { NodeChange } from '#core/kiwi/fig/codec'
import type { CharacterStyleOverride, StyleRun } from '#core/scene-graph'
import { styleToWeight } from '#core/text/fonts'

View file

@ -1,4 +1,4 @@
import type { NodeChange } from '#core/kiwi/binary/codec'
import type { NodeChange } from '#core/kiwi/fig/codec'
import type { GeometryPath, VectorNetwork, WindingRule } from '#core/scene-graph'
import type { Vector } from '#core/types'
import { decodeVectorNetworkBlob } from '#core/vector'

View file

@ -1,9 +1,9 @@
import { unzipSync, inflateSync } from 'fflate'
import { decompress as zstdDecompress } from 'fzstd'
import type { FigmaMessage, NodeChange } from '#core/kiwi/binary/codec'
import { isZstdCompressed } from '#core/kiwi/binary/protocol'
import { decodeBinarySchema, compileSchema, ByteBuffer } from '#core/kiwi/kiwi-schema'
import type { FigmaMessage, NodeChange } from '#core/kiwi/fig/codec'
import { isZstdCompressed } from '#core/kiwi/fig/codec/protocol'
import { decodeBinarySchema, compileSchema, ByteBuffer } from '#core/kiwi/schema-runtime'
/**
* Deduplicates pluginData/pluginRelaunchData entries on raw NodeChange objects.

View file

@ -1,5 +1,5 @@
import { getLazyFigImportContext, setLazyFigImportContext } from '#core/kiwi/fig/lazy-import'
import type { InstanceNodeChange } from '#core/kiwi/instance-overrides'
import type { InstanceNodeChange } from '#core/kiwi/fig/instance-overrides'
import { SceneGraph } from '#core/scene-graph'
import type { SceneNode, Variable, VariableCollection, DocumentColorSpace } from '#core/scene-graph'

View file

@ -29,7 +29,7 @@ export {
type VariableDataValuesEntry,
type ParentIndex,
type FigmaMessage
} from './binary/codec'
} from './fig/codec'
export {
MESSAGE_TYPES,
NODE_TYPES,
@ -48,4 +48,4 @@ export {
getKiwiMessageType,
parseVarint,
FIG_WIRE_MAGIC
} from './binary/protocol'
} from './fig/codec/protocol'

View file

@ -1,244 +0,0 @@
import type { NodeChange, Paint } from '#core/kiwi/binary/codec'
import type { SceneGraph, SceneNode } from '#core/scene-graph'
import type { Color, GUID, Matrix } from '#core/types'
import { stringToGuid } from './guid'
import {
mergePluginData,
NODE_TYPE_PLUGIN_KEY,
serializePluginRelaunchData,
upsertPluginData
} from './plugin-data'
export type KiwiNodeChange = NodeChange & Record<string, unknown>
interface SceneNodeToKiwiContext {
graph: SceneGraph
blobs: Uint8Array[]
nodeIdToGuid?: Map<string, GUID>
fontDigestMap?: Map<string, Uint8Array>
varIdToGuid?: Map<string, GUID>
fractionalPosition: (index: number) => string
mapToFigmaType: (type: SceneNode['type']) => string
fillToKiwiPaint: (fill: SceneNode['fills'][number]) => Paint
safeColor: (color: Color) => Color
computeExportTransform: (node: SceneNode, graph: SceneGraph) => Matrix
serializeCornerRadii: (node: SceneNode, nc: KiwiNodeChange) => void
serializeTextProps: (
node: SceneNode,
nc: KiwiNodeChange,
graph: SceneGraph,
fontDigestMap: Map<string, Uint8Array> | undefined,
blobs: Uint8Array[]
) => void
serializeLayoutProps: (node: SceneNode, nc: KiwiNodeChange) => void
serializeGeometry: (node: SceneNode, nc: KiwiNodeChange, blobs: Uint8Array[]) => void
serializeVariableBindings: (
node: SceneNode,
nc: KiwiNodeChange,
graph: SceneGraph,
varIdToGuid?: Map<string, GUID>
) => void
sceneNodeToKiwi: (
node: SceneNode,
parentGuid: GUID,
childIndex: number,
localIdCounter: { value: number },
context: SceneNodeToKiwiContext
) => KiwiNodeChange[]
}
const DEFAULT_STROKE_WEIGHT = 1
function applyColorVariableBinding(
context: SceneNodeToKiwiContext,
node: SceneNode,
paint: Paint,
field: string
): Paint {
const variableId = node.boundVariables[field]
if (!variableId) return paint
return {
...paint,
colorVariableBinding: {
variableID: context.varIdToGuid?.get(variableId) ?? stringToGuid(variableId)
}
}
}
function createStrokePaints(context: SceneNodeToKiwiContext, node: SceneNode): Paint[] {
return node.strokes.map((stroke, index) =>
applyColorVariableBinding(
context,
node,
{
type: 'SOLID',
color: context.safeColor(stroke.color),
opacity: stroke.opacity,
visible: stroke.visible,
blendMode: 'NORMAL'
},
`strokes/${index}/color`
)
)
}
function componentPropertyValue(value: string) {
return { textValue: { characters: value } }
}
function componentPropertyTypeForKiwi(type: string) {
if (type === 'BOOLEAN') return 'BOOL'
if (type === 'VARIANT') return 'TEXT'
return type
}
function parseGuidOrNull(value: string) {
return /^\d+:\d+$/.test(value) ? stringToGuid(value) : null
}
function applyComponentMetadata(node: SceneNode, nc: KiwiNodeChange): void {
if (node.componentKey) nc.componentKey = node.componentKey
if (node.sourceLibraryKey) nc.sourceLibraryKey = node.sourceLibraryKey
const publishId = node.publishId ? parseGuidOrNull(node.publishId) : null
const overrideKey = node.overrideKey ? parseGuidOrNull(node.overrideKey) : null
if (publishId) nc.publishID = publishId
if (overrideKey) nc.overrideKey = overrideKey
if (node.sharedSymbolVersion) nc.sharedSymbolVersion = node.sharedSymbolVersion
if (node.publishedVersion) nc.publishedVersion = node.publishedVersion
if (node.isPublishable) nc.isPublishable = true
if (node.isSymbolPublishable) nc.isSymbolPublishable = true
if (node.symbolDescription) nc.symbolDescription = node.symbolDescription
if (node.symbolLinks.length > 0) nc.symbolLinks = structuredClone(node.symbolLinks)
const componentPropDefs = node.componentPropertyDefinitions
.map((def) => {
const id = parseGuidOrNull(def.id)
return id
? {
id,
name: def.name,
type: componentPropertyTypeForKiwi(def.type),
initialValue: componentPropertyValue(def.defaultValue)
}
: null
})
.filter((def): def is NonNullable<typeof def> => def !== null)
if (componentPropDefs.length > 0) nc.componentPropDefs = componentPropDefs
const variantPropSpecs = node.variantPropSpecs
.map((spec) => {
const propDefId = parseGuidOrNull(spec.propDefId)
return propDefId ? { propDefId, value: spec.value } : null
})
.filter((spec): spec is NonNullable<typeof spec> => spec !== null)
if (variantPropSpecs.length > 0) nc.variantPropSpecs = variantPropSpecs
}
function applyNodeVisualProps(
context: SceneNodeToKiwiContext,
node: SceneNode,
nc: KiwiNodeChange
): void {
if (node.independentStrokeWeights) {
nc.borderStrokeWeightsIndependent = true
nc.borderTopWeight = node.borderTopWeight
nc.borderRightWeight = node.borderRightWeight
nc.borderBottomWeight = node.borderBottomWeight
nc.borderLeftWeight = node.borderLeftWeight
}
if (node.fills.length > 0) {
nc.fillPaints = node.fills.map((fill, index) =>
applyColorVariableBinding(
context,
node,
context.fillToKiwiPaint(fill),
`fills/${index}/color`
)
)
}
context.serializeCornerRadii(node, nc)
if (node.effects.length > 0) {
nc.effects = node.effects.map((effect) => ({
type: effect.type === 'LAYER_BLUR' ? 'FOREGROUND_BLUR' : effect.type,
color: context.safeColor(effect.color),
offset: effect.offset,
radius: effect.radius,
spread: effect.spread,
visible: effect.visible,
showShadowBehindNode: effect.showShadowBehindNode
}))
}
if (node.type === 'TEXT') {
context.serializeTextProps(node, nc, context.graph, context.fontDigestMap, context.blobs)
}
nc.frameMaskDisabled = !node.clipsContent
if (node.horizontalConstraint !== 'MIN') nc.horizontalConstraint = node.horizontalConstraint
if (node.verticalConstraint !== 'MIN') nc.verticalConstraint = node.verticalConstraint
if (node.strokeCap !== 'NONE') nc.strokeCap = node.strokeCap
if (node.strokeJoin !== 'MITER') nc.strokeJoin = node.strokeJoin
if (node.strokeMiterLimit !== 28.96) nc.miterLimit = node.strokeMiterLimit
if (node.dashPattern.length > 0) nc.dashPattern = node.dashPattern
if (node.arcData) {
nc.arcData = {
startingAngle: node.arcData.startingAngle,
endingAngle: node.arcData.endingAngle,
innerRadius: node.arcData.innerRadius
}
}
if (!node.autoRename) nc.autoRename = false
}
export function sceneNodeToKiwiWithContext(
node: SceneNode,
parentGuid: GUID,
childIndex: number,
localIdCounter: { value: number },
context: SceneNodeToKiwiContext
): KiwiNodeChange[] {
const localID = localIdCounter.value++
const guid = { sessionID: 1, localID }
context.nodeIdToGuid?.set(node.id, guid)
const strokePaints = createStrokePaints(context, node)
const nc: KiwiNodeChange = {
guid,
parentIndex: { guid: parentGuid, position: context.fractionalPosition(childIndex) },
type: context.mapToFigmaType(node.type),
name: node.name,
visible: node.visible,
opacity: node.opacity,
phase: 'CREATED',
size: { x: node.width, y: node.height },
transform: context.computeExportTransform(node, context.graph),
strokeWeight: node.strokes[0]?.weight ?? DEFAULT_STROKE_WEIGHT,
strokeAlign: node.strokes[0]?.align ?? 'INSIDE'
}
applyNodeVisualProps(context, node, nc)
applyComponentMetadata(node, nc)
if (node.type === 'COMPONENT_SET') upsertPluginData(node, NODE_TYPE_PLUGIN_KEY, node.type)
if (strokePaints.length > 0) nc.strokePaints = strokePaints
context.serializeLayoutProps(node, nc)
context.serializeGeometry(node, nc, context.blobs)
context.serializeVariableBindings(node, nc, context.graph, context.varIdToGuid)
const pluginData = mergePluginData(node.pluginData)
if (pluginData.length > 0) nc.pluginData = pluginData
if (node.pluginRelaunchData.length > 0) {
nc.pluginRelaunchData = serializePluginRelaunchData(node.pluginRelaunchData)
}
const result: KiwiNodeChange[] = [nc]
const children = context.graph.getChildren(node.id).filter((child) => !child.internalOnly)
for (let i = 0; i < children.length; i++) {
result.push(...context.sceneNodeToKiwi(children[i], guid, i, localIdCounter, context))
}
return result
}

View file

@ -3,3 +3,4 @@ export { ByteBuffer } from './bb'
export { compileSchema } from './js'
export { decodeBinarySchema, encodeBinarySchema } from './binary'
export { parseSchema } from './parser'
export { validateSchema, expectFieldNumber, expectEnumValue, findDefinition, findField } from './validate'

View file

@ -0,0 +1,86 @@
import type { Definition, Field, Schema } from './schema'
import { error, quote } from './util'
export function findDefinition(schema: Schema, name: string): Definition | null {
return schema.definitions.find((definition) => definition.name === name) ?? null
}
export function findField(schema: Schema, definitionName: string, fieldName: string): Field | null {
return findDefinition(schema, definitionName)?.fields.find((field) => field.name === fieldName) ?? null
}
export function expectFieldNumber(
schema: Schema,
definitionName: string,
fieldName: string,
expectedValue: number
): void {
const field = findField(schema, definitionName, fieldName)
if (!field) {
throw new Error(`Missing field ${definitionName}.${fieldName}`)
}
if (field.value !== expectedValue) {
throw new Error(
`Expected ${definitionName}.${fieldName} to use field ${expectedValue}, got ${field.value}`
)
}
}
export function expectEnumValue(
schema: Schema,
enumName: string,
memberName: string,
expectedValue: number
): void {
const definition = findDefinition(schema, enumName)
if (!definition) {
throw new Error(`Missing enum ${enumName}`)
}
if (definition.kind !== 'ENUM') {
throw new Error(`${enumName} is a ${definition.kind}, not an enum`)
}
const field = definition.fields.find((candidate) => candidate.name === memberName)
if (!field) {
throw new Error(`Missing enum member ${enumName}.${memberName}`)
}
if (field.value !== expectedValue) {
throw new Error(
`Expected ${enumName}.${memberName} to use value ${expectedValue}, got ${field.value}`
)
}
}
export function validateSchema(schema: Schema): void {
for (const definition of schema.definitions) {
validateUniqueFieldNames(definition)
if (definition.kind === 'ENUM') validateUniqueEnumValues(definition)
}
}
function validateUniqueFieldNames(definition: Definition): void {
const fieldsByName = new Set<string>()
for (const field of definition.fields) {
if (fieldsByName.has(field.name)) {
error(
`The field ${quote(field.name)} is defined twice in ${quote(definition.name)}`,
field.line,
field.column
)
}
fieldsByName.add(field.name)
}
}
function validateUniqueEnumValues(definition: Definition): void {
const fieldsByValue = new Set<number>()
for (const field of definition.fields) {
if (fieldsByValue.has(field.value)) {
error(
`The enum value ${field.value} is used twice in ${quote(definition.name)}`,
field.line,
field.column
)
}
fieldsByValue.add(field.value)
}
}

View file

@ -7,6 +7,8 @@ import * as HitTest from './hit-test'
import * as Instances from './instances'
import { CONTAINER_TYPES, createDefaultNode } from './node-defaults'
import { updateNodePreview } from './preview'
import { clearEditedSourceMetadata } from './source-metadata'
import { TEXT_PICTURE_KEYS } from './text-picture'
import * as Variables from './variables'
import { normalizeVectorNetwork } from './vector-network'
@ -50,6 +52,7 @@ export class SceneGraph {
readonly emitter: Emitter<SceneGraphEvents> = createNanoEvents()
private absPosCache = new Map<string, Vector>()
private previewMutationDepth = 0
private sourceMetadataPreservationDepth = 0
positionPreviewVersion = 0
instanceIndex = new Map<string, Set<string>>()
@ -97,11 +100,6 @@ export class SceneGraph {
}
}
/**
* Count all descendants of a node (children, grandchildren, etc.).
* Used for per-page node counts in the renderer to determine if a page
* is "large" without counting nodes on other pages.
*/
countDescendants(nodeId: string): number {
const node = this.nodes.get(nodeId)
if (!node) return 0
@ -113,9 +111,6 @@ export class SceneGraph {
count++
const child = this.nodes.get(id)
if (child) {
// Use a for-of loop instead of spread: stack.push(...ids) places every
// element on the call stack as function arguments and crashes V8/JSC
// with RangeError on nodes with >~125k direct children.
for (const childId of child.childIds) {
stack.push(childId)
}
@ -289,14 +284,8 @@ export class SceneGraph {
return node
}
/**
* Properties that affect absolute position computation (getNodeLocalMatrix).
* Changing any of these on a node invalidates the absPosCache for that node
* and all its descendants. Other changes (fills, strokes, effects, plugin data)
* do NOT affect absolute position and can skip the expensive cache clear.
*
* These names MUST match the actual SceneNode field names (not Figma API proxy names).
*/
static TEXT_PICTURE_KEYS: ReadonlySet<string> = TEXT_PICTURE_KEYS
static LAYOUT_AFFECTING_KEYS: ReadonlySet<string> = new Set([
'x',
'y',
@ -336,24 +325,7 @@ export class SceneGraph {
'maxHeight'
])
static TEXT_PICTURE_KEYS: ReadonlySet<string> = new Set([
'text',
'fontSize',
'fontFamily',
'fontWeight',
'italic',
'textAlignHorizontal',
'textDirection',
'textAlignVertical',
'lineHeight',
'letterSpacing',
'textDecoration',
'textCase',
'styleRuns',
'fills',
'width',
'height'
])
runPreviewUpdates(fn: () => void): void {
this.previewMutationDepth++
@ -363,6 +335,14 @@ export class SceneGraph {
this.previewMutationDepth--
}
}
preserveSourceMetadataDuring(fn: () => void): void {
this.sourceMetadataPreservationDepth++
try {
fn()
} finally {
this.sourceMetadataPreservationDepth--
}
}
updateNodePositionPreview(id: string, x: number, y: number): void {
this.updateNodePreview(id, { x, y })
}
@ -398,7 +378,7 @@ export class SceneGraph {
}
}
if (node.type === 'TEXT') {
const textChanged = Object.keys(changes).some((k) => SceneGraph.TEXT_PICTURE_KEYS.has(k))
const textChanged = Object.keys(changes).some((k) => TEXT_PICTURE_KEYS.has(k))
if (node.textPicture && textChanged) node.textPicture = null
if (node.figmaDerivedTextGlyphs && 'text' in changes) node.figmaDerivedTextGlyphs = null
}
@ -406,6 +386,9 @@ export class SceneGraph {
changes = Object.fromEntries(
entries.filter(([, value]) => value !== undefined)
) as Partial<SceneNode>
if (this.sourceMetadataPreservationDepth === 0) {
clearEditedSourceMetadata(node, Object.keys(changes))
}
if (changes.vectorNetwork) {
changes = { ...changes, vectorNetwork: normalizeVectorNetwork(changes.vectorNetwork) }
}

View file

@ -18,6 +18,22 @@ export function createDefaultNode(
width: 100,
height: 100,
rotation: 0,
source: {
format: null,
id: null,
orderKey: null,
fig: {
rawSize: null,
rawTransform: null,
rawNodeFields: {},
layout: null,
symbolOverrides: [],
componentPropAssignments: [],
derivedSymbolData: [],
derivedSymbolDataLayoutVersion: null,
uniformScaleFactor: null
}
},
figmaDerivedLayout: null,
fills:
type === 'TEXT'

View file

@ -0,0 +1,72 @@
import type { SceneNode } from './types'
const RAW_SIZE_KEYS = new Set(['width', 'height'])
const RAW_TRANSFORM_KEYS = new Set(['x', 'y', 'rotation', 'flipX', 'flipY'])
const RAW_NODE_FIELD_KEYS = new Set([
'visible',
'opacity',
'blendMode',
'fills',
'strokes',
'effects',
'cornerRadius',
'topLeftRadius',
'topRightRadius',
'bottomRightRadius',
'bottomLeftRadius',
'independentCorners',
'cornerSmoothing',
'text',
'fontSize',
'fontFamily',
'fontWeight',
'italic',
'textAlignHorizontal',
'textAlignVertical',
'textAutoResize',
'textCase',
'textDecoration',
'lineHeight',
'letterSpacing',
'maxLines',
'styleRuns',
'textTruncation',
'layoutMode',
'itemSpacing',
'paddingTop',
'paddingBottom',
'paddingLeft',
'paddingRight',
'primaryAxisSizing',
'counterAxisSizing',
'primaryAxisAlign',
'counterAxisAlign',
'layoutWrap',
'counterAxisSpacing',
'layoutPositioning',
'layoutGrow',
'layoutAlignSelf',
'counterAxisAlignContent',
'itemReverseZIndex',
'strokesIncludedInLayout',
'layoutDirection',
'horizontalConstraint',
'verticalConstraint',
'strokeCap',
'strokeJoin',
'strokeMiterLimit',
'dashPattern',
'arcData',
'vectorNetwork',
'fillGeometry',
'strokeGeometry',
'clipsContent'
])
export function clearEditedSourceMetadata(node: SceneNode, changeKeys: string[]): void {
if (changeKeys.some((key) => RAW_SIZE_KEYS.has(key))) node.source.fig.rawSize = null
if (changeKeys.some((key) => RAW_TRANSFORM_KEYS.has(key))) node.source.fig.rawTransform = null
if (changeKeys.some((key) => RAW_NODE_FIELD_KEYS.has(key))) node.source.fig.rawNodeFields = {}
}

View file

@ -0,0 +1,18 @@
export const TEXT_PICTURE_KEYS: ReadonlySet<string> = new Set([
'text',
'fontSize',
'fontFamily',
'fontWeight',
'italic',
'textAlignHorizontal',
'textDirection',
'textAlignVertical',
'lineHeight',
'letterSpacing',
'textDecoration',
'textCase',
'styleRuns',
'fills',
'width',
'height'
])

View file

@ -18,6 +18,25 @@ export type SceneGraphEventHandlers = Partial<{
export type DocumentColorSpace = 'srgb' | 'display-p3'
export interface FigmaSourcePayload {
rawSize: Vector | null
rawTransform: Matrix | null
rawNodeFields: Record<string, unknown>
layout: FigmaLayoutMetadata | null
symbolOverrides: unknown[]
componentPropAssignments: unknown[]
derivedSymbolData: unknown[]
derivedSymbolDataLayoutVersion: number | null
uniformScaleFactor: number | null
}
export interface SourceMetadata {
format: 'fig' | null
id: string | null
orderKey: string | null
fig: FigmaSourcePayload
}
export type HandleMirroring = 'NONE' | 'ANGLE' | 'ANGLE_AND_LENGTH'
export type WindingRule = 'NONZERO' | 'EVENODD'
@ -229,6 +248,34 @@ export interface VariantPropSpec {
value: string
}
export type FigmaLayoutMetadata = Partial<
Record<
| 'stackMode'
| 'stackCounterAlign'
| 'stackJustify'
| 'stackCounterAlignItems'
| 'stackPrimaryAlignItems'
| 'stackPrimarySizing'
| 'stackCounterSizing'
| 'stackWrap'
| 'stackPositioning'
| 'stackChildAlignSelf',
string
> &
Record<
| 'stackSpacing'
| 'stackPadding'
| 'stackPaddingRight'
| 'stackPaddingBottom'
| 'stackVerticalPadding'
| 'stackHorizontalPadding'
| 'stackChildPrimaryGrow'
| 'stackCounterSpacing',
number
> &
Record<'bordersTakeSpace', boolean>
>
export interface SceneNode {
id: string
type: NodeType
@ -241,6 +288,7 @@ export interface SceneNode {
width: number
height: number
rotation: number
source: SourceMetadata
figmaDerivedLayout: Partial<Rect> | null
fills: Fill[]

View file

@ -1,9 +1,9 @@
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext'
import type { NodeChange } from '#core/kiwi/binary/codec'
import type { NodeChange } from '#core/kiwi/fig/codec'
import type { SceneNode } from '#core/scene-graph'
import { encodePathCommandsBlob } from '#core/kiwi/node-change/path-commands'
import { encodePathCommandsBlob } from '#core/kiwi/fig/node-change/path-commands'
import { normalizeFontFamily, weightToFigmaStyle, weightToStyle } from '#core/text/fonts'
import { type GlyphOutlineMetrics, getGlyphOutlineMetricsSync } from '#core/text/opentype'

View file

@ -1,4 +1,4 @@
import type { NodeChange } from '#core/kiwi/binary/codec'
import type { NodeChange } from '#core/kiwi/fig/codec'
import type { SceneNode } from '#core/scene-graph'
interface DerivedTextDataOptions {

View file

@ -4,6 +4,7 @@
"lib": ["ESNext", "DOM"],
"module": "ESNext",
"moduleResolution": "bundler",
"allowArbitraryExtensions": true,
"types": ["node", "bun"],
"strict": true,

View file

@ -1,9 +1,21 @@
import { readFileSync } from 'node:fs'
import { defineConfig } from 'tsdown'
import type { Plugin } from 'rolldown'
function rawMd(): Plugin {
const packageJson = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')) as {
dependencies?: Record<string, string>
}
function rawText(): Plugin {
return {
name: 'raw-md',
name: 'raw-text',
load(id) {
if (id.endsWith('?raw')) {
const path = id.slice(0, -'?raw'.length)
return `export default ${JSON.stringify(readFileSync(path, 'utf8'))}`
}
},
transform(code, id) {
if (id.endsWith('.md')) {
return { code: `export default ${JSON.stringify(code)}`, map: null }
@ -14,7 +26,7 @@ function rawMd(): Plugin {
export default defineConfig({
entry: ['src/**/*.ts', '!src/**/*.d.ts'],
plugins: [rawMd()],
plugins: [rawText()],
unbundle: true,
platform: 'neutral',
format: ['esm'],
@ -23,22 +35,7 @@ export default defineConfig({
clean: true,
outDir: './dist',
deps: {
neverBundle: [
'@iconify/utils',
'canvaskit-wasm',
'culori',
'diff',
'expr-eval',
'fflate',
'fontoxpath',
'fzstd',
'nanoevents',
'opentype.js',
'sucrase',
'svgpath',
'yoga-layout',
/^node:/
],
neverBundle: [...Object.keys(packageJson.dependencies ?? {}), /^node:/],
onlyBundle: false
}
})

View file

@ -111,7 +111,7 @@ open-pencil tree # Live-Dokument
open-pencil export -f png # Canvas-Screenshot
```
Alle Befehle unterstützen `--json`. Installation: `bun add -g @open-pencil/cli`
Alle Befehle unterstützen `--json`. Installation: `npm install -g @open-pencil/cli`
## Echtzeit-Kollaboration

View file

@ -9,7 +9,7 @@ Das CLI ermöglicht es, `.fig`-Dateien zu erkunden, ohne den Editor zu öffnen.
::: tip Installation
```sh
bun add -g @open-pencil/cli
npm install -g @open-pencil/cli
# oder
brew install open-pencil/tap/open-pencil
```

View file

@ -111,7 +111,7 @@ open-pencil tree # Documento en vivo
open-pencil export -f png # Captura del canvas
```
Todos los comandos soportan `--json`. Instalar: `bun add -g @open-pencil/cli`
Todos los comandos soportan `--json`. Instalar: `npm install -g @open-pencil/cli`
## Colaboración en tiempo real

View file

@ -9,7 +9,7 @@ El CLI te permite explorar archivos `.fig` sin abrir el editor. Cada comando tam
::: tip Instalar
```sh
bun add -g @open-pencil/cli
npm install -g @open-pencil/cli
# o
brew install open-pencil/tap/open-pencil
```

View file

@ -111,7 +111,7 @@ open-pencil tree # Document en direct
open-pencil export -f png # Capture du canevas
```
Toutes les commandes supportent `--json`. Installation : `bun add -g @open-pencil/cli`
Toutes les commandes supportent `--json`. Installation : `npm install -g @open-pencil/cli`
## Collaboration en temps réel

View file

@ -9,7 +9,7 @@ Le CLI vous permet d'explorer des fichiers `.fig` sans ouvrir l'éditeur. Chaque
::: tip Installation
```sh
bun add -g @open-pencil/cli
npm install -g @open-pencil/cli
# ou
brew install open-pencil/tap/open-pencil
```

View file

@ -111,7 +111,7 @@ open-pencil tree # Live document
open-pencil export -f png # Screenshot canvas
```
All commands support `--json`. Install: `bun add -g @open-pencil/cli`
All commands support `--json`. Install: `npm install -g @open-pencil/cli` (or `bun add -g @open-pencil/cli`).
## Real-Time Collaboration

Some files were not shown because too many files have changed in this diff Show more