Refactor: vendor kiwi-schema, extract types/constants, culori colors
- Fork kiwi-schema from source TypeScript (evanw/kiwi), drop npm dep + patch - Deduplicate GUID/Color types into src/types.ts - Extract UI colors and default fills into src/constants.ts - Replace hand-rolled hex parsing with culori library - Extract demo shapes from App.vue into src/demo.ts - useUrlSearchParams from VueUse for test mode detection - Move fig-file/fig-import into kiwi/ (format layer) - Delete PoC files (poc-yoga.ts, poc-test.ts)
9
bun.lock
|
|
@ -10,9 +10,9 @@
|
|||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"canvaskit-wasm": "^0.40.0",
|
||||
"culori": "^4.0.2",
|
||||
"fflate": "^0.8.2",
|
||||
"fzstd": "^0.1.1",
|
||||
"kiwi-schema": "^0.5.0",
|
||||
"reka-ui": "^2.8.2",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"vue": "^3.5.29",
|
||||
|
|
@ -30,9 +30,6 @@
|
|||
},
|
||||
},
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"kiwi-schema@0.5.0": "patches/kiwi-schema@0.5.0.patch",
|
||||
},
|
||||
"packages": {
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||
|
||||
|
|
@ -364,6 +361,8 @@
|
|||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"culori": ["culori@4.0.2", "", {}, "sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw=="],
|
||||
|
||||
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
|
@ -388,8 +387,6 @@
|
|||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"kiwi-schema": ["kiwi-schema@0.5.0", "", { "bin": { "kiwic": "cli.js" } }, "sha512-X+FpfU0yTEtc6aTHS7VwbOpvQwRt70+pXXWRI5fd6CvWhe7pSVC854TVo4Zo0x5/wwcWj+/9KUlXpdcP0dY9AA=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
|
||||
|
|
|
|||
|
|
@ -24,9 +24,9 @@
|
|||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"canvaskit-wasm": "^0.40.0",
|
||||
"culori": "^4.0.2",
|
||||
"fflate": "^0.8.2",
|
||||
"fzstd": "^0.1.1",
|
||||
"kiwi-schema": "^0.5.0",
|
||||
"reka-ui": "^2.8.2",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"vue": "^3.5.29",
|
||||
|
|
@ -41,8 +41,5 @@
|
|||
"oxlint": "^1.50.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"kiwi-schema@0.5.0": "patches/kiwi-schema@0.5.0.patch"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
diff --git a/node_modules/kiwi-schema/.bun-tag-962afbd98e4e08f9 b/.bun-tag-962afbd98e4e08f9
|
||||
new file mode 100644
|
||||
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
||||
diff --git a/cli.js b/cli.js
|
||||
index a729eb694b4dfd87302228d6e0fd3ff1cb75266e..67f33fb533e0dfd5176057d26288b7c324d441fb 100755
|
||||
--- a/cli.js
|
||||
+++ b/cli.js
|
||||
@@ -2078,7 +2078,7 @@ function verify(root) {
|
||||
if (field.value <= 0) {
|
||||
error("The id for field " + quote(field.name) + " must be positive", field.line, field.column);
|
||||
}
|
||||
- if (field.value > fields.length) {
|
||||
+ if (false && field.value > fields.length) {
|
||||
error("The id for field " + quote(field.name) + " cannot be larger than " + fields.length, field.line, field.column);
|
||||
}
|
||||
values.push(field.value);
|
||||
diff --git a/kiwi-esm.js b/kiwi-esm.js
|
||||
index f0f3f8b950abd8150e360511efcfdb10ea019bb9..b57c433a77444a38072364ab24c4e967734645f1 100644
|
||||
--- a/kiwi-esm.js
|
||||
+++ b/kiwi-esm.js
|
||||
@@ -2130,7 +2130,7 @@ function verify(root) {
|
||||
if (field.value <= 0) {
|
||||
error("The id for field " + quote(field.name) + " must be positive", field.line, field.column);
|
||||
}
|
||||
- if (field.value > fields.length) {
|
||||
+ if (false && field.value > fields.length) {
|
||||
error("The id for field " + quote(field.name) + " cannot be larger than " + fields.length, field.line, field.column);
|
||||
}
|
||||
values.push(field.value);
|
||||
diff --git a/kiwi.js b/kiwi.js
|
||||
index 26a46b6a2efbbdb9623a5275751eaaa10378e617..5a57b99fd2fd5fe087fc1c556672c58aa63b57ec 100644
|
||||
--- a/kiwi.js
|
||||
+++ b/kiwi.js
|
||||
@@ -2167,7 +2167,7 @@ function verify(root) {
|
||||
if (field.value <= 0) {
|
||||
error("The id for field " + quote(field.name) + " must be positive", field.line, field.column);
|
||||
}
|
||||
- if (field.value > fields.length) {
|
||||
+ if (false && field.value > fields.length) {
|
||||
error("The id for field " + quote(field.name) + " cannot be larger than " + fields.length, field.line, field.column);
|
||||
}
|
||||
values.push(field.value);
|
||||
76
src/App.vue
|
|
@ -1,7 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { useEventListener, useUrlSearchParams } from '@vueuse/core'
|
||||
|
||||
import { useKeyboard } from './composables/use-keyboard'
|
||||
import { createDemoShapes } from './demo'
|
||||
import { provideEditorStore } from './stores/editor'
|
||||
|
||||
import EditorCanvas from './components/EditorCanvas.vue'
|
||||
|
|
@ -16,76 +17,9 @@ useEventListener(document, 'wheel', (e: WheelEvent) => {
|
|||
if (e.ctrlKey || e.metaKey) e.preventDefault()
|
||||
}, { passive: false })
|
||||
|
||||
const isTestMode = new URLSearchParams(window.location.search).has('test')
|
||||
|
||||
// Demo shapes
|
||||
if (!isTestMode) {
|
||||
store.createShape('FRAME', 100, 80, 800, 500)
|
||||
store.graph.updateNode(store.graph.getChildren(store.graph.rootId)[0].id, {
|
||||
name: 'Desktop',
|
||||
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }],
|
||||
strokes: [
|
||||
{
|
||||
color: { r: 0.87, g: 0.87, b: 0.87, a: 1 },
|
||||
weight: 1,
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
align: 'INSIDE'
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const demo = [
|
||||
{
|
||||
type: 'RECTANGLE' as const,
|
||||
name: 'Blue card',
|
||||
x: 150,
|
||||
y: 140,
|
||||
w: 240,
|
||||
h: 160,
|
||||
color: { r: 0.23, g: 0.51, b: 0.96, a: 1 },
|
||||
radius: 12
|
||||
},
|
||||
{
|
||||
type: 'ELLIPSE' as const,
|
||||
name: 'Green circle',
|
||||
x: 440,
|
||||
y: 160,
|
||||
w: 120,
|
||||
h: 120,
|
||||
color: { r: 0.13, g: 0.77, b: 0.42, a: 1 }
|
||||
},
|
||||
{
|
||||
type: 'RECTANGLE' as const,
|
||||
name: 'Orange rect',
|
||||
x: 620,
|
||||
y: 140,
|
||||
w: 200,
|
||||
h: 100,
|
||||
color: { r: 0.96, g: 0.52, b: 0.13, a: 1 },
|
||||
radius: 8
|
||||
},
|
||||
{
|
||||
type: 'RECTANGLE' as const,
|
||||
name: 'Purple pill',
|
||||
x: 150,
|
||||
y: 360,
|
||||
w: 300,
|
||||
h: 56,
|
||||
color: { r: 0.55, g: 0.36, b: 0.96, a: 1 },
|
||||
radius: 28
|
||||
}
|
||||
]
|
||||
|
||||
if (!isTestMode)
|
||||
for (const d of demo) {
|
||||
const id = store.createShape(d.type, d.x, d.y, d.w, d.h)
|
||||
store.graph.updateNode(id, {
|
||||
name: d.name,
|
||||
cornerRadius: d.radius ?? 0,
|
||||
fills: [{ type: 'SOLID', color: d.color, opacity: 1, visible: true }]
|
||||
})
|
||||
const params = useUrlSearchParams('history')
|
||||
if (!('test' in params)) {
|
||||
createDemoShapes(store)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
import { ref, computed, watch } from 'vue'
|
||||
import { PopoverRoot, PopoverTrigger, PopoverPortal, PopoverContent } from 'reka-ui'
|
||||
|
||||
import type { Color } from '../engine/scene-graph'
|
||||
import { colorToHex, parseColor } from '../engine/color'
|
||||
|
||||
import type { Color } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
color: Color
|
||||
|
|
@ -72,21 +74,14 @@ function emitColor() {
|
|||
}
|
||||
|
||||
const hexValue = computed(() => {
|
||||
const hex = (v: number) =>
|
||||
Math.round(v * 255)
|
||||
.toString(16)
|
||||
.padStart(2, '0')
|
||||
return `${hex(props.color.r)}${hex(props.color.g)}${hex(props.color.b)}`
|
||||
return colorToHex(props.color).slice(1)
|
||||
})
|
||||
|
||||
function onHexInput(e: Event) {
|
||||
const input = (e.target as HTMLInputElement).value.replace('#', '')
|
||||
if (input.length !== 6) return
|
||||
const r = parseInt(input.slice(0, 2), 16) / 255
|
||||
const g = parseInt(input.slice(2, 4), 16) / 255
|
||||
const b = parseInt(input.slice(4, 6), 16) / 255
|
||||
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return
|
||||
emit('update', { r, g, b, a: alpha.value })
|
||||
const parsed = parseColor(`#${input}`)
|
||||
emit('update', { ...parsed, a: alpha.value })
|
||||
}
|
||||
|
||||
const svAreaRef = ref<HTMLDivElement | null>(null)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import ColorPicker from './ColorPicker.vue'
|
|||
import ScrubInput from './ScrubInput.vue'
|
||||
import { useEditorStore } from '../stores/editor'
|
||||
|
||||
import { DEFAULT_SHAPE_FILL } from '../constants'
|
||||
|
||||
import type { Color, Fill, Stroke, LayoutSizing, LayoutAlign, LayoutCounterAlign, SceneNode } from '../engine/scene-graph'
|
||||
|
||||
const store = useEditorStore()
|
||||
|
|
@ -155,13 +157,7 @@ function updateFillColor(index: number, color: Color) {
|
|||
|
||||
function addFill() {
|
||||
if (!node.value) return
|
||||
const fill: Fill = {
|
||||
type: 'SOLID',
|
||||
color: { r: 0.83, g: 0.83, b: 0.83, a: 1 },
|
||||
opacity: 1,
|
||||
visible: true
|
||||
}
|
||||
store.updateNodeWithUndo(node.value.id, { fills: [...node.value.fills, fill] }, "Add fill")
|
||||
store.updateNodeWithUndo(node.value.id, { fills: [...node.value.fills, { ...DEFAULT_SHAPE_FILL }] }, "Add fill")
|
||||
}
|
||||
|
||||
function removeFill(index: number) {
|
||||
|
|
|
|||
25
src/constants.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { Color } from './types'
|
||||
import type { Fill } from './engine/scene-graph'
|
||||
|
||||
export const SELECTION_COLOR = { r: 0.23, g: 0.51, b: 0.96, a: 1 } satisfies Color
|
||||
export const SNAP_COLOR = { r: 1.0, g: 0.0, b: 0.56, a: 1 } satisfies Color
|
||||
export const CANVAS_BG_COLOR = { r: 0.96, g: 0.96, b: 0.96, a: 1 } satisfies Color
|
||||
|
||||
export const DEFAULT_SHAPE_FILL: Fill = {
|
||||
type: 'SOLID',
|
||||
color: { r: 0.83, g: 0.83, b: 0.83, a: 1 },
|
||||
opacity: 1,
|
||||
visible: true
|
||||
}
|
||||
|
||||
export const DEFAULT_FRAME_FILL: Fill = {
|
||||
type: 'SOLID',
|
||||
color: { r: 1, g: 1, b: 1, a: 1 },
|
||||
opacity: 1,
|
||||
visible: true
|
||||
}
|
||||
|
||||
export const HANDLE_SIZE = 6
|
||||
export const ROTATION_HANDLE_OFFSET = 20
|
||||
export const SNAP_THRESHOLD = 5
|
||||
export const DRAG_DEAD_ZONE = 4
|
||||
57
src/demo.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import type { EditorStore } from './stores/editor'
|
||||
|
||||
export function createDemoShapes(store: EditorStore) {
|
||||
store.createShape('FRAME', 100, 80, 800, 500)
|
||||
store.graph.updateNode(store.graph.getChildren(store.graph.rootId)[0].id, {
|
||||
name: 'Desktop',
|
||||
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }],
|
||||
strokes: [
|
||||
{
|
||||
color: { r: 0.87, g: 0.87, b: 0.87, a: 1 },
|
||||
weight: 1,
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
align: 'INSIDE'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const shapes = [
|
||||
{
|
||||
type: 'RECTANGLE' as const,
|
||||
name: 'Blue card',
|
||||
x: 150, y: 140, w: 240, h: 160,
|
||||
color: { r: 0.23, g: 0.51, b: 0.96, a: 1 },
|
||||
radius: 12
|
||||
},
|
||||
{
|
||||
type: 'ELLIPSE' as const,
|
||||
name: 'Green circle',
|
||||
x: 440, y: 160, w: 120, h: 120,
|
||||
color: { r: 0.13, g: 0.77, b: 0.42, a: 1 }
|
||||
},
|
||||
{
|
||||
type: 'RECTANGLE' as const,
|
||||
name: 'Orange rect',
|
||||
x: 620, y: 140, w: 200, h: 100,
|
||||
color: { r: 0.96, g: 0.52, b: 0.13, a: 1 },
|
||||
radius: 8
|
||||
},
|
||||
{
|
||||
type: 'RECTANGLE' as const,
|
||||
name: 'Purple pill',
|
||||
x: 150, y: 360, w: 300, h: 56,
|
||||
color: { r: 0.55, g: 0.36, b: 0.96, a: 1 },
|
||||
radius: 28
|
||||
}
|
||||
]
|
||||
|
||||
for (const d of shapes) {
|
||||
const id = store.createShape(d.type, d.x, d.y, d.w, d.h)
|
||||
store.graph.updateNode(id, {
|
||||
name: d.name,
|
||||
cornerRadius: d.radius ?? 0,
|
||||
fills: [{ type: 'SOLID', color: d.color, opacity: 1, visible: true }]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { decodeBinarySchema, compileSchema, ByteBuffer } from 'kiwi-schema'
|
||||
import { decodeBinarySchema, compileSchema, ByteBuffer } from '../kiwi/kiwi-schema'
|
||||
import { inflateSync, deflateSync } from 'fflate'
|
||||
|
||||
import type { SceneGraph, SceneNode, Fill, Stroke, Color, LayoutMode, LayoutSizing, LayoutAlign, LayoutCounterAlign, VectorNetwork } from './scene-graph'
|
||||
|
|
|
|||
31
src/engine/color.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { parse, formatHex, converter } from 'culori'
|
||||
|
||||
import type { Color } from '../types'
|
||||
|
||||
const toRgb = converter('rgb')
|
||||
|
||||
export function parseColor(input: string): Color {
|
||||
const parsed = parse(input)
|
||||
if (!parsed) return { r: 0, g: 0, b: 0, a: 1 }
|
||||
const rgb = toRgb(parsed)
|
||||
return {
|
||||
r: rgb?.r ?? 0,
|
||||
g: rgb?.g ?? 0,
|
||||
b: rgb?.b ?? 0,
|
||||
a: parsed.alpha ?? 1
|
||||
}
|
||||
}
|
||||
|
||||
export function colorToHex(color: Color): string {
|
||||
return (formatHex({ mode: 'rgb', r: color.r, g: color.g, b: color.b }) ?? '#000000').toUpperCase()
|
||||
}
|
||||
|
||||
export function colorToFill(color: string | Color) {
|
||||
const rgba = typeof color === 'string' ? parseColor(color) : color
|
||||
return {
|
||||
type: 'SOLID' as const,
|
||||
color: { r: rgba.r, g: rgba.g, b: rgba.b, a: rgba.a },
|
||||
opacity: rgba.a,
|
||||
visible: true
|
||||
}
|
||||
}
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
/**
|
||||
* PoC 2: Yoga WASM Layout Engine
|
||||
*
|
||||
* Validates:
|
||||
* 1. yoga-layout WASM loads and initializes
|
||||
* 2. Flex layout (auto-layout) matches Figma's behavior
|
||||
* 3. Nested frames with padding, gap, alignment
|
||||
* 4. Performance: layout 1000 nodes
|
||||
*/
|
||||
|
||||
import Yoga, {
|
||||
Align,
|
||||
Direction,
|
||||
FlexDirection,
|
||||
Gutter,
|
||||
Justify,
|
||||
Edge,
|
||||
Wrap,
|
||||
type Node as YogaNode
|
||||
} from 'yoga-layout'
|
||||
|
||||
function runTest() {
|
||||
console.log('=== PoC 2: Yoga WASM Layout ===\n')
|
||||
|
||||
// 1. Basic flex layout (Figma auto-layout: vertical, gap=16, padding=24)
|
||||
{
|
||||
const root = Yoga.Node.create()
|
||||
root.setWidth(400)
|
||||
root.setHeight('auto')
|
||||
root.setFlexDirection(FlexDirection.Column)
|
||||
root.setPadding(Edge.All, 24)
|
||||
root.setGap(Gutter.All, 16)
|
||||
|
||||
const child1 = Yoga.Node.create()
|
||||
child1.setWidth('100%')
|
||||
child1.setHeight(48)
|
||||
|
||||
const child2 = Yoga.Node.create()
|
||||
child2.setWidth('100%')
|
||||
child2.setHeight(120)
|
||||
|
||||
const child3 = Yoga.Node.create()
|
||||
child3.setWidth('100%')
|
||||
child3.setHeight(48)
|
||||
|
||||
root.insertChild(child1, 0)
|
||||
root.insertChild(child2, 1)
|
||||
root.insertChild(child3, 2)
|
||||
|
||||
root.calculateLayout(undefined, undefined, Direction.LTR)
|
||||
|
||||
console.log('Test 1: Vertical auto-layout (gap=16, padding=24)')
|
||||
console.log(` Root: ${root.getComputedWidth()}×${root.getComputedHeight()}`)
|
||||
// Expected: height = 24 + 48 + 16 + 120 + 16 + 48 + 24 = 296
|
||||
const expectedHeight = 24 + 48 + 16 + 120 + 16 + 48 + 24
|
||||
const actualHeight = root.getComputedHeight()
|
||||
console.log(
|
||||
` Expected height: ${expectedHeight}, Actual: ${actualHeight} ${actualHeight === expectedHeight ? '✅' : '❌'}`
|
||||
)
|
||||
|
||||
console.log(` Child 1: y=${child1.getComputedTop()}, h=${child1.getComputedHeight()}`)
|
||||
console.log(` Child 2: y=${child2.getComputedTop()}, h=${child2.getComputedHeight()}`)
|
||||
console.log(` Child 3: y=${child3.getComputedTop()}, h=${child3.getComputedHeight()}`)
|
||||
|
||||
// Verify positions
|
||||
const y1 = child1.getComputedTop() // Expected: 24
|
||||
const y2 = child2.getComputedTop() // Expected: 24 + 48 + 16 = 88
|
||||
const y3 = child3.getComputedTop() // Expected: 88 + 120 + 16 = 224
|
||||
console.log(` Positions: ${y1 === 24 && y2 === 88 && y3 === 224 ? '✅' : '❌'}`)
|
||||
|
||||
root.free()
|
||||
child1.free()
|
||||
child2.free()
|
||||
child3.free()
|
||||
}
|
||||
|
||||
// 2. Horizontal auto-layout with space-between (Figma: distribute)
|
||||
{
|
||||
const root = Yoga.Node.create()
|
||||
root.setWidth(600)
|
||||
root.setHeight(80)
|
||||
root.setFlexDirection(FlexDirection.Row)
|
||||
root.setJustifyContent(Justify.SpaceBetween)
|
||||
root.setAlignItems(Align.Center)
|
||||
root.setPadding(Edge.Horizontal, 16)
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const child = Yoga.Node.create()
|
||||
child.setWidth(100)
|
||||
child.setHeight(48)
|
||||
root.insertChild(child, i)
|
||||
}
|
||||
|
||||
root.calculateLayout(undefined, undefined, Direction.LTR)
|
||||
|
||||
console.log('\nTest 2: Horizontal space-between')
|
||||
const positions: string[] = []
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const child = root.getChild(i)
|
||||
positions.push(`x=${child.getComputedLeft()} y=${child.getComputedTop()}`)
|
||||
}
|
||||
console.log(` Children: ${positions.join(' | ')}`)
|
||||
// Items should be centered vertically (y = (80-48)/2 = 16)
|
||||
const centeredCorrectly = root.getChild(0).getComputedTop() === 16
|
||||
console.log(` Vertical centering: ${centeredCorrectly ? '✅' : '❌'}`)
|
||||
// Space-between with padding: available = 600-32 = 568, items = 4*100 = 400, gaps = 168/3 = 56
|
||||
const gap = root.getChild(1).getComputedLeft() - root.getChild(0).getComputedLeft() - 100
|
||||
console.log(` Gap between items: ${gap}px ${Math.abs(gap - 56) < 1 ? '✅' : '❌'}`)
|
||||
|
||||
for (let i = root.getChildCount() - 1; i >= 0; i--) root.getChild(i).free()
|
||||
root.free()
|
||||
}
|
||||
|
||||
// 3. Nested frames (card with header + content)
|
||||
{
|
||||
const card = Yoga.Node.create()
|
||||
card.setWidth(320)
|
||||
card.setFlexDirection(FlexDirection.Column)
|
||||
card.setPadding(Edge.All, 0)
|
||||
|
||||
const header = Yoga.Node.create()
|
||||
header.setWidth('100%')
|
||||
header.setHeight(56)
|
||||
header.setFlexDirection(FlexDirection.Row)
|
||||
header.setAlignItems(Align.Center)
|
||||
header.setPadding(Edge.Horizontal, 16)
|
||||
header.setGap(Gutter.All, 12)
|
||||
|
||||
const avatar = Yoga.Node.create()
|
||||
avatar.setWidth(32)
|
||||
avatar.setHeight(32)
|
||||
|
||||
const title = Yoga.Node.create()
|
||||
title.setFlexGrow(1)
|
||||
title.setHeight(20)
|
||||
|
||||
header.insertChild(avatar, 0)
|
||||
header.insertChild(title, 1)
|
||||
|
||||
const content = Yoga.Node.create()
|
||||
content.setWidth('100%')
|
||||
content.setHeight(200)
|
||||
|
||||
card.insertChild(header, 0)
|
||||
card.insertChild(content, 1)
|
||||
|
||||
card.calculateLayout(undefined, undefined, Direction.LTR)
|
||||
|
||||
console.log('\nTest 3: Nested card layout')
|
||||
console.log(` Card: ${card.getComputedWidth()}×${card.getComputedHeight()}`)
|
||||
console.log(` Header: y=${header.getComputedTop()}, h=${header.getComputedHeight()}`)
|
||||
console.log(
|
||||
` Avatar: x=${avatar.getComputedLeft()}, y=${avatar.getComputedTop()}, centered=${avatar.getComputedTop() === 12 ? '✅' : '❌'}`
|
||||
)
|
||||
console.log(
|
||||
` Title: x=${title.getComputedLeft()}, w=${title.getComputedWidth()}, grows=${title.getComputedWidth() === 320 - 16 - 32 - 12 - 16 ? '✅' : '❌'}`
|
||||
)
|
||||
console.log(` Content: y=${content.getComputedTop()}, h=${content.getComputedHeight()}`)
|
||||
|
||||
avatar.free()
|
||||
title.free()
|
||||
header.free()
|
||||
content.free()
|
||||
card.free()
|
||||
}
|
||||
|
||||
// 4. Performance: layout 1000 nodes
|
||||
{
|
||||
const root = Yoga.Node.create()
|
||||
root.setWidth(1200)
|
||||
root.setFlexDirection(FlexDirection.Row)
|
||||
root.setFlexWrap(Wrap.Wrap) // Wrap
|
||||
root.setGap(Gutter.All, 8)
|
||||
root.setPadding(Edge.All, 16)
|
||||
|
||||
const nodes: YogaNode[] = [root]
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const child = Yoga.Node.create()
|
||||
child.setWidth(80)
|
||||
child.setHeight(80)
|
||||
root.insertChild(child, i)
|
||||
nodes.push(child)
|
||||
}
|
||||
|
||||
const t0 = performance.now()
|
||||
root.calculateLayout(undefined, undefined, Direction.LTR)
|
||||
const layoutTime = performance.now() - t0
|
||||
|
||||
console.log(`\nTest 4: Performance (1000 nodes)`)
|
||||
console.log(` Layout time: ${layoutTime.toFixed(2)}ms ${layoutTime < 50 ? '✅' : '⚠️'}`)
|
||||
console.log(` Root height: ${root.getComputedHeight()}px`)
|
||||
|
||||
for (const n of nodes) n.free()
|
||||
}
|
||||
|
||||
console.log('\n✅ PoC 2 PASSED: Yoga WASM layout works correctly')
|
||||
}
|
||||
|
||||
runTest()
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { SELECTION_COLOR, SNAP_COLOR, CANVAS_BG_COLOR, HANDLE_SIZE, ROTATION_HANDLE_OFFSET } from '../constants'
|
||||
import type { SceneNode, SceneGraph, Fill } from './scene-graph'
|
||||
import type { SnapGuide } from './snap'
|
||||
import { vectorNetworkToPath } from './vector'
|
||||
|
|
@ -52,6 +53,10 @@ export class SkiaRenderer {
|
|||
zoom = 1
|
||||
dpr = 1
|
||||
|
||||
private selColor(alpha = 1) {
|
||||
return this.ck.Color4f(SELECTION_COLOR.r, SELECTION_COLOR.g, SELECTION_COLOR.b, alpha)
|
||||
}
|
||||
|
||||
constructor(ck: CanvasKit, surface: Surface) {
|
||||
this.ck = ck
|
||||
this.surface = surface
|
||||
|
|
@ -67,20 +72,20 @@ export class SkiaRenderer {
|
|||
this.selectionPaint = new ck.Paint()
|
||||
this.selectionPaint.setStyle(ck.PaintStyle.Stroke)
|
||||
this.selectionPaint.setStrokeWidth(1)
|
||||
this.selectionPaint.setColor(ck.Color4f(0.23, 0.51, 0.96, 1.0))
|
||||
this.selectionPaint.setColor(this.selColor())
|
||||
this.selectionPaint.setAntiAlias(true)
|
||||
|
||||
this.parentOutlinePaint = new ck.Paint()
|
||||
this.parentOutlinePaint.setStyle(ck.PaintStyle.Stroke)
|
||||
this.parentOutlinePaint.setStrokeWidth(1)
|
||||
this.parentOutlinePaint.setColor(ck.Color4f(0.23, 0.51, 0.96, 0.5))
|
||||
this.parentOutlinePaint.setColor(this.selColor(0.5))
|
||||
this.parentOutlinePaint.setAntiAlias(true)
|
||||
this.parentOutlinePaint.setPathEffect(ck.PathEffect.MakeDash([4, 4], 0))
|
||||
|
||||
this.snapPaint = new ck.Paint()
|
||||
this.snapPaint.setStyle(ck.PaintStyle.Stroke)
|
||||
this.snapPaint.setStrokeWidth(1)
|
||||
this.snapPaint.setColor(ck.Color4f(1.0, 0.0, 0.56, 1.0))
|
||||
this.snapPaint.setColor(this.ck.Color4f(SNAP_COLOR.r, SNAP_COLOR.g, SNAP_COLOR.b, 1))
|
||||
this.snapPaint.setAntiAlias(true)
|
||||
|
||||
this.textFont = new ck.Font(null, 14)
|
||||
|
|
@ -109,7 +114,7 @@ export class SkiaRenderer {
|
|||
|
||||
render(graph: SceneGraph, selectedIds: Set<string>, overlays: RenderOverlays = {}): void {
|
||||
const canvas = this.surface.getCanvas()
|
||||
canvas.clear(this.ck.Color4f(0.96, 0.96, 0.96, 1.0))
|
||||
canvas.clear(this.ck.Color4f(CANVAS_BG_COLOR.r, CANVAS_BG_COLOR.g, CANVAS_BG_COLOR.b, 1))
|
||||
|
||||
// Scene layer (world coordinates)
|
||||
canvas.save()
|
||||
|
|
@ -225,7 +230,7 @@ export class SkiaRenderer {
|
|||
const rotLinePaint = new this.ck.Paint()
|
||||
rotLinePaint.setStyle(this.ck.PaintStyle.Stroke)
|
||||
rotLinePaint.setStrokeWidth(1)
|
||||
rotLinePaint.setColor(this.ck.Color4f(0.23, 0.51, 0.96, 1.0))
|
||||
rotLinePaint.setColor(this.this.selColor())
|
||||
rotLinePaint.setAntiAlias(true)
|
||||
canvas.drawLine(mx, y1, mx, rotHandleY, rotLinePaint)
|
||||
|
||||
|
|
@ -280,7 +285,7 @@ export class SkiaRenderer {
|
|||
if (node.type === 'FRAME' && node.parentId === graph.rootId) {
|
||||
const labelPaint = new this.ck.Paint()
|
||||
labelPaint.setStyle(this.ck.PaintStyle.Fill)
|
||||
labelPaint.setColor(this.ck.Color4f(0.23, 0.51, 0.96, 1.0))
|
||||
labelPaint.setColor(this.this.selColor())
|
||||
labelPaint.setAntiAlias(true)
|
||||
canvas.drawText(node.name, sx1, sy1 - 8, labelPaint, this.labelFont)
|
||||
labelPaint.delete()
|
||||
|
|
@ -302,7 +307,7 @@ export class SkiaRenderer {
|
|||
|
||||
const pillPaint = new this.ck.Paint()
|
||||
pillPaint.setStyle(this.ck.PaintStyle.Fill)
|
||||
pillPaint.setColor(this.ck.Color4f(0.23, 0.51, 0.96, 1.0))
|
||||
pillPaint.setColor(this.this.selColor())
|
||||
pillPaint.setAntiAlias(true)
|
||||
|
||||
const rrect = this.ck.RRectXY(this.ck.LTRBRect(pillX, pillY, pillX + pillW, pillY + pillH), 4, 4)
|
||||
|
|
@ -405,7 +410,7 @@ export class SkiaRenderer {
|
|||
const dashPaint = new this.ck.Paint()
|
||||
dashPaint.setStyle(this.ck.PaintStyle.Stroke)
|
||||
dashPaint.setStrokeWidth(1)
|
||||
dashPaint.setColor(this.ck.Color4f(0.23, 0.51, 0.96, 0.6))
|
||||
dashPaint.setColor(this.selColor(0.6))
|
||||
dashPaint.setAntiAlias(true)
|
||||
|
||||
canvas.drawRect(this.ck.LTRBRect(minX, minY, maxX, maxY), dashPaint)
|
||||
|
|
@ -490,7 +495,7 @@ export class SkiaRenderer {
|
|||
|
||||
const fill = new this.ck.Paint()
|
||||
fill.setStyle(this.ck.PaintStyle.Fill)
|
||||
fill.setColor(this.ck.Color4f(0.23, 0.51, 0.96, 0.08))
|
||||
fill.setColor(this.selColor(0.08))
|
||||
canvas.drawRect(rect, fill)
|
||||
canvas.drawRect(rect, this.selectionPaint)
|
||||
fill.delete()
|
||||
|
|
@ -507,7 +512,7 @@ export class SkiaRenderer {
|
|||
const paint = new this.ck.Paint()
|
||||
paint.setStyle(this.ck.PaintStyle.Stroke)
|
||||
paint.setStrokeWidth(2)
|
||||
paint.setColor(this.ck.Color4f(0.23, 0.51, 0.96, 1.0))
|
||||
paint.setColor(this.this.selColor())
|
||||
paint.setAntiAlias(true)
|
||||
|
||||
if (indicator.direction === 'HORIZONTAL') {
|
||||
|
|
@ -560,7 +565,7 @@ export class SkiaRenderer {
|
|||
const highlight = new this.ck.Paint()
|
||||
highlight.setStyle(this.ck.PaintStyle.Stroke)
|
||||
highlight.setStrokeWidth(2 / this.zoom)
|
||||
highlight.setColor(this.ck.Color4f(0.23, 0.51, 0.96, 0.8))
|
||||
highlight.setColor(this.selColor(0.8))
|
||||
highlight.setAntiAlias(true)
|
||||
canvas.drawRect(this.ck.LTRBRect(0, 0, node.width, node.height), highlight)
|
||||
highlight.delete()
|
||||
|
|
@ -747,13 +752,13 @@ export class SkiaRenderer {
|
|||
const pathPaint = new this.ck.Paint()
|
||||
pathPaint.setStyle(this.ck.PaintStyle.Stroke)
|
||||
pathPaint.setStrokeWidth(2)
|
||||
pathPaint.setColor(this.ck.Color4f(0.23, 0.51, 0.96, 1.0))
|
||||
pathPaint.setColor(this.this.selColor())
|
||||
pathPaint.setAntiAlias(true)
|
||||
|
||||
const handlePaint = new this.ck.Paint()
|
||||
handlePaint.setStyle(this.ck.PaintStyle.Stroke)
|
||||
handlePaint.setStrokeWidth(1)
|
||||
handlePaint.setColor(this.ck.Color4f(0.23, 0.51, 0.96, 0.5))
|
||||
handlePaint.setColor(this.selColor(0.5))
|
||||
handlePaint.setAntiAlias(true)
|
||||
|
||||
const vertexFill = new this.ck.Paint()
|
||||
|
|
@ -764,7 +769,7 @@ export class SkiaRenderer {
|
|||
const vertexStroke = new this.ck.Paint()
|
||||
vertexStroke.setStyle(this.ck.PaintStyle.Stroke)
|
||||
vertexStroke.setStrokeWidth(2)
|
||||
vertexStroke.setColor(this.ck.Color4f(0.23, 0.51, 0.96, 1.0))
|
||||
vertexStroke.setColor(this.this.selColor())
|
||||
vertexStroke.setAntiAlias(true)
|
||||
|
||||
const toScreen = (x: number, y: number) => ({
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
export type { GUID, Color } from '../types'
|
||||
|
||||
export type HandleMirroring = 'NONE' | 'ANGLE' | 'ANGLE_AND_LENGTH'
|
||||
export type WindingRule = 'NONZERO' | 'EVENODD'
|
||||
|
||||
|
|
@ -40,17 +42,7 @@ export type NodeType =
|
|||
| 'GROUP'
|
||||
| 'SECTION'
|
||||
|
||||
export interface GUID {
|
||||
sessionID: number
|
||||
localID: number
|
||||
}
|
||||
|
||||
export interface Color {
|
||||
r: number
|
||||
g: number
|
||||
b: number
|
||||
a: number
|
||||
}
|
||||
import type { Color } from '../types'
|
||||
|
||||
export interface Fill {
|
||||
type: 'SOLID'
|
||||
|
|
|
|||
|
|
@ -7,24 +7,14 @@
|
|||
*/
|
||||
|
||||
import { decompress as zstdDecompress } from 'fzstd'
|
||||
import { compileSchema, type Schema } from 'kiwi-schema'
|
||||
import { compileSchema } from './kiwi-schema'
|
||||
|
||||
import type { Schema } from './kiwi-schema'
|
||||
|
||||
import { parseColor } from '../engine/color'
|
||||
import { isZstdCompressed, getKiwiMessageType } from './protocol.ts'
|
||||
import figmaSchema from './schema.ts'
|
||||
|
||||
function parseColor(color: string): Color {
|
||||
if (color.startsWith('#')) {
|
||||
const hex = color.slice(1)
|
||||
return {
|
||||
r: parseInt(hex.slice(0, 2), 16) / 255,
|
||||
g: parseInt(hex.slice(2, 4), 16) / 255,
|
||||
b: parseInt(hex.slice(4, 6), 16) / 255,
|
||||
a: hex.length > 6 ? parseInt(hex.slice(6, 8), 16) / 255 : 1
|
||||
}
|
||||
}
|
||||
return { r: 0, g: 0, b: 0, a: 1 }
|
||||
}
|
||||
|
||||
interface CompiledSchema {
|
||||
encodeMessage(message: unknown): Uint8Array
|
||||
decodeMessage(data: Uint8Array): unknown
|
||||
|
|
@ -164,17 +154,9 @@ export function peekMessageType(data: Uint8Array): number | null {
|
|||
|
||||
// Type definitions
|
||||
|
||||
export interface GUID {
|
||||
sessionID: number
|
||||
localID: number
|
||||
}
|
||||
export type { GUID, Color } from '../types'
|
||||
|
||||
export interface Color {
|
||||
r: number
|
||||
g: number
|
||||
b: number
|
||||
a: number
|
||||
}
|
||||
import type { GUID, Color } from '../types'
|
||||
|
||||
export interface Vector {
|
||||
x: number
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { unzipSync } from 'fflate'
|
||||
|
||||
import { initCodec, decodeMessage } from '../kiwi/codec'
|
||||
import { initCodec, decodeMessage } from './codec'
|
||||
import { importNodeChanges } from './fig-import'
|
||||
|
||||
import type { SceneGraph } from './scene-graph'
|
||||
import type { SceneGraph } from '../engine/scene-graph'
|
||||
|
||||
/**
|
||||
* Parse a .fig file into a SceneGraph.
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { SceneGraph } from './scene-graph'
|
||||
import { decodeVectorNetworkBlob } from './vector'
|
||||
import { SceneGraph } from '../engine/scene-graph'
|
||||
import { decodeVectorNetworkBlob } from '../engine/vector'
|
||||
|
||||
import type { NodeChange, Paint, Effect as KiwiEffect, GUID } from '../kiwi/codec'
|
||||
import type { NodeType, Fill, Stroke, Effect, Color, LayoutMode, LayoutSizing, LayoutAlign, LayoutCounterAlign, VectorNetwork } from './scene-graph'
|
||||
import type { NodeChange, Paint, Effect as KiwiEffect, GUID } from './codec'
|
||||
import type { NodeType, Fill, Stroke, Effect, Color, LayoutMode, LayoutSizing, LayoutAlign, LayoutCounterAlign, VectorNetwork } from '../engine/scene-graph'
|
||||
|
||||
function ext(nc: NodeChange): Record<string, unknown> {
|
||||
return nc as unknown as Record<string, unknown>
|
||||
274
src/kiwi/kiwi-schema/bb.ts
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
let int32 = new Int32Array(1);
|
||||
let float32 = new Float32Array(int32.buffer);
|
||||
|
||||
export class ByteBuffer {
|
||||
private _data: Uint8Array;
|
||||
private _index: number;
|
||||
length: number;
|
||||
|
||||
constructor(data?: Uint8Array) {
|
||||
if (data && !(data instanceof Uint8Array)) {
|
||||
throw new Error('Must initialize a ByteBuffer with a Uint8Array');
|
||||
}
|
||||
this._data = data || new Uint8Array(256);
|
||||
this._index = 0;
|
||||
this.length = data ? data.length : 0;
|
||||
}
|
||||
|
||||
toUint8Array(): Uint8Array {
|
||||
return this._data.subarray(0, this.length);
|
||||
}
|
||||
|
||||
readByte(): number {
|
||||
if (this._index + 1 > this._data.length) {
|
||||
throw new Error('Index out of bounds');
|
||||
}
|
||||
return this._data[this._index++];
|
||||
}
|
||||
|
||||
readByteArray(): Uint8Array {
|
||||
let length = this.readVarUint();
|
||||
let start = this._index;
|
||||
let end = start + length;
|
||||
if (end > this._data.length) {
|
||||
throw new Error('Read array out of bounds');
|
||||
}
|
||||
this._index = end;
|
||||
// Copy into a new array instead of just creating another view.
|
||||
let result = new Uint8Array(length);
|
||||
result.set(this._data.subarray(start, end));
|
||||
return result;
|
||||
}
|
||||
|
||||
readVarFloat(): number {
|
||||
let index = this._index;
|
||||
let data = this._data;
|
||||
let length = data.length;
|
||||
|
||||
// Optimization: use a single byte to store zero
|
||||
if (index + 1 > length) {
|
||||
throw new Error('Index out of bounds');
|
||||
}
|
||||
let first = data[index];
|
||||
if (first === 0) {
|
||||
this._index = index + 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Endian-independent 32-bit read
|
||||
if (index + 4 > length) {
|
||||
throw new Error('Index out of bounds');
|
||||
}
|
||||
let bits = first | (data[index + 1] << 8) | (data[index + 2] << 16) | (data[index + 3] << 24);
|
||||
this._index = index + 4;
|
||||
|
||||
// Move the exponent back into place
|
||||
bits = (bits << 23) | (bits >>> 9);
|
||||
|
||||
// Reinterpret as a floating-point number
|
||||
int32[0] = bits;
|
||||
return float32[0];
|
||||
}
|
||||
|
||||
readVarUint(): number {
|
||||
let value = 0;
|
||||
let shift = 0;
|
||||
do {
|
||||
var byte = this.readByte();
|
||||
value |= (byte & 127) << shift;
|
||||
shift += 7;
|
||||
} while (byte & 128 && shift < 35);
|
||||
return value >>> 0;
|
||||
}
|
||||
|
||||
readVarInt(): number {
|
||||
let value = this.readVarUint() | 0;
|
||||
return value & 1 ? ~(value >>> 1) : value >>> 1;
|
||||
}
|
||||
|
||||
readVarUint64(): bigint {
|
||||
let value = BigInt(0);
|
||||
let shift = BigInt(0);
|
||||
let seven = BigInt(7);
|
||||
let byte: number;
|
||||
while ((byte = this.readByte()) & 128 && shift < 56) {
|
||||
value |= BigInt(byte & 127) << shift;
|
||||
shift += seven;
|
||||
}
|
||||
value |= BigInt(byte) << shift;
|
||||
return value;
|
||||
}
|
||||
|
||||
readVarInt64(): bigint {
|
||||
let value = this.readVarUint64();
|
||||
let one = BigInt(1);
|
||||
let sign = value & one;
|
||||
value >>= one;
|
||||
return sign ? ~value : value;
|
||||
}
|
||||
|
||||
readString(): string {
|
||||
let result = '';
|
||||
|
||||
while (true) {
|
||||
let codePoint;
|
||||
|
||||
// Decode UTF-8
|
||||
let a = this.readByte();
|
||||
if (a < 0xC0) {
|
||||
codePoint = a;
|
||||
} else {
|
||||
let b = this.readByte();
|
||||
if (a < 0xE0) {
|
||||
codePoint = ((a & 0x1F) << 6) | (b & 0x3F);
|
||||
} else {
|
||||
let c = this.readByte();
|
||||
if (a < 0xF0) {
|
||||
codePoint = ((a & 0x0F) << 12) | ((b & 0x3F) << 6) | (c & 0x3F);
|
||||
} else {
|
||||
let d = this.readByte();
|
||||
codePoint = ((a & 0x07) << 18) | ((b & 0x3F) << 12) | ((c & 0x3F) << 6) | (d & 0x3F);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strings are null-terminated
|
||||
if (codePoint === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Encode UTF-16
|
||||
if (codePoint < 0x10000) {
|
||||
result += String.fromCharCode(codePoint);
|
||||
} else {
|
||||
codePoint -= 0x10000;
|
||||
result += String.fromCharCode((codePoint >> 10) + 0xD800, (codePoint & ((1 << 10) - 1)) + 0xDC00);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private _growBy(amount: number): void {
|
||||
if (this.length + amount > this._data.length) {
|
||||
let data = new Uint8Array(this.length + amount << 1);
|
||||
data.set(this._data);
|
||||
this._data = data;
|
||||
}
|
||||
this.length += amount;
|
||||
}
|
||||
|
||||
writeByte(value: number): void {
|
||||
let index = this.length;
|
||||
this._growBy(1);
|
||||
this._data[index] = value;
|
||||
}
|
||||
|
||||
writeByteArray(value: Uint8Array): void {
|
||||
this.writeVarUint(value.length);
|
||||
let index = this.length;
|
||||
this._growBy(value.length);
|
||||
this._data.set(value, index);
|
||||
}
|
||||
|
||||
writeVarFloat(value: number): void {
|
||||
let index = this.length;
|
||||
|
||||
// Reinterpret as an integer
|
||||
float32[0] = value;
|
||||
let bits = int32[0];
|
||||
|
||||
// Move the exponent to the first 8 bits
|
||||
bits = (bits >>> 23) | (bits << 9);
|
||||
|
||||
// Optimization: use a single byte to store zero and denormals (check for an exponent of 0)
|
||||
if ((bits & 255) === 0) {
|
||||
this.writeByte(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Endian-independent 32-bit write
|
||||
this._growBy(4);
|
||||
let data = this._data;
|
||||
data[index] = bits;
|
||||
data[index + 1] = bits >> 8;
|
||||
data[index + 2] = bits >> 16;
|
||||
data[index + 3] = bits >> 24;
|
||||
}
|
||||
|
||||
writeVarUint(value: number): void {
|
||||
if (value < 0 || value > 0xFFFF_FFFF) throw new Error('Outside uint range: ' + value);
|
||||
do {
|
||||
let byte = value & 127;
|
||||
value >>>= 7;
|
||||
this.writeByte(value ? byte | 128 : byte);
|
||||
} while (value);
|
||||
}
|
||||
|
||||
writeVarInt(value: number): void {
|
||||
if (value < -0x8000_0000 || value > 0x7FFF_FFFF) throw new Error('Outside int range: ' + value);
|
||||
this.writeVarUint(((value << 1) ^ (value >> 31)) >>> 0);
|
||||
}
|
||||
|
||||
writeVarUint64(value: bigint | string): void {
|
||||
if (typeof value === 'string') value = BigInt(value);
|
||||
else if (typeof value !== 'bigint') throw new Error('Expected bigint but got ' + typeof value + ': ' + value);
|
||||
if (value < 0 || value > BigInt('0xFFFFFFFFFFFFFFFF')) throw new Error('Outside uint64 range: ' + value);
|
||||
let mask = BigInt(127);
|
||||
let seven = BigInt(7);
|
||||
for (let i = 0; value > mask && i < 8; i++) {
|
||||
this.writeByte(Number(value & mask) | 128);
|
||||
value >>= seven;
|
||||
}
|
||||
this.writeByte(Number(value));
|
||||
}
|
||||
|
||||
writeVarInt64(value: bigint | string): void {
|
||||
if (typeof value === 'string') value = BigInt(value);
|
||||
else if (typeof value !== 'bigint') throw new Error('Expected bigint but got ' + typeof value + ': ' + value);
|
||||
if (value < -BigInt('0x8000000000000000') || value > BigInt('0x7FFFFFFFFFFFFFFF')) throw new Error('Outside int64 range: ' + value);
|
||||
let one = BigInt(1);
|
||||
this.writeVarUint64(value < 0 ? ~(value << one) : value << one);
|
||||
}
|
||||
|
||||
writeString(value: string): void {
|
||||
let codePoint;
|
||||
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
// Decode UTF-16
|
||||
let a = value.charCodeAt(i);
|
||||
if (i + 1 === value.length || a < 0xD800 || a >= 0xDC00) {
|
||||
codePoint = a;
|
||||
} else {
|
||||
let b = value.charCodeAt(++i);
|
||||
codePoint = (a << 10) + b + (0x10000 - (0xD800 << 10) - 0xDC00);
|
||||
}
|
||||
|
||||
// Strings are null-terminated
|
||||
if (codePoint === 0) {
|
||||
throw new Error('Cannot encode a string containing the null character');
|
||||
}
|
||||
|
||||
// Encode UTF-8
|
||||
if (codePoint < 0x80) {
|
||||
this.writeByte(codePoint);
|
||||
} else {
|
||||
if (codePoint < 0x800) {
|
||||
this.writeByte(((codePoint >> 6) & 0x1F) | 0xC0);
|
||||
} else {
|
||||
if (codePoint < 0x10000) {
|
||||
this.writeByte(((codePoint >> 12) & 0x0F) | 0xE0);
|
||||
} else {
|
||||
this.writeByte(((codePoint >> 18) & 0x07) | 0xF0);
|
||||
this.writeByte(((codePoint >> 12) & 0x3F) | 0x80);
|
||||
}
|
||||
this.writeByte(((codePoint >> 6) & 0x3F) | 0x80);
|
||||
}
|
||||
this.writeByte((codePoint & 0x3F) | 0x80);
|
||||
}
|
||||
}
|
||||
|
||||
// Strings are null-terminated
|
||||
this.writeByte(0);
|
||||
}
|
||||
}
|
||||
104
src/kiwi/kiwi-schema/binary.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { ByteBuffer } from "./bb";
|
||||
import { Schema, Field, Definition, DefinitionKind } from "./schema";
|
||||
|
||||
let types: (string | null)[] = ['bool', 'byte', 'int', 'uint', 'float', 'string', 'int64', 'uint64'];
|
||||
let kinds: DefinitionKind[] = ['ENUM', 'STRUCT', 'MESSAGE'];
|
||||
|
||||
export function decodeBinarySchema(buffer: Uint8Array | ByteBuffer): Schema {
|
||||
let bb = buffer instanceof ByteBuffer ? buffer : new ByteBuffer(buffer);
|
||||
let definitionCount = bb.readVarUint();
|
||||
let definitions: Definition[] = [];
|
||||
|
||||
// Read in the schema
|
||||
for (let i = 0; i < definitionCount; i++) {
|
||||
let definitionName = bb.readString();
|
||||
let kind = bb.readByte();
|
||||
let fieldCount = bb.readVarUint();
|
||||
let fields: Field[] = [];
|
||||
|
||||
for (let j = 0; j < fieldCount; j++) {
|
||||
let fieldName = bb.readString();
|
||||
let type = bb.readVarInt();
|
||||
let isArray = !!(bb.readByte() & 1);
|
||||
let value = bb.readVarUint();
|
||||
|
||||
fields.push({
|
||||
name: fieldName,
|
||||
line: 0,
|
||||
column: 0,
|
||||
type: kinds[kind] === 'ENUM' ? null : type as any,
|
||||
isArray: isArray,
|
||||
isDeprecated: false,
|
||||
value: value,
|
||||
});
|
||||
}
|
||||
|
||||
definitions.push({
|
||||
name: definitionName,
|
||||
line: 0,
|
||||
column: 0,
|
||||
kind: kinds[kind],
|
||||
fields: fields,
|
||||
});
|
||||
}
|
||||
|
||||
// Bind type names afterwards
|
||||
for (let i = 0; i < definitionCount; i++) {
|
||||
let fields = definitions[i].fields;
|
||||
for (let j = 0; j < fields.length; j++) {
|
||||
let field = fields[j];
|
||||
let type = field.type as any as number | null;
|
||||
|
||||
if (type !== null && type < 0) {
|
||||
if (~type >= types.length) {
|
||||
throw new Error('Invalid type ' + type);
|
||||
}
|
||||
field.type = types[~type];
|
||||
}
|
||||
|
||||
else {
|
||||
if (type !== null && type >= definitions.length) {
|
||||
throw new Error('Invalid type ' + type);
|
||||
}
|
||||
field.type = type === null ? null : definitions[type].name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
package: null,
|
||||
definitions: definitions,
|
||||
};
|
||||
}
|
||||
|
||||
export function encodeBinarySchema(schema: Schema): Uint8Array {
|
||||
let bb = new ByteBuffer();
|
||||
let definitions = schema.definitions;
|
||||
let definitionIndex: { [name: string]: number } = {};
|
||||
|
||||
bb.writeVarUint(definitions.length);
|
||||
|
||||
for (let i = 0; i < definitions.length; i++) {
|
||||
definitionIndex[definitions[i].name] = i;
|
||||
}
|
||||
|
||||
for (let i = 0; i < definitions.length; i++) {
|
||||
let definition = definitions[i];
|
||||
|
||||
bb.writeString(definition.name);
|
||||
bb.writeByte(kinds.indexOf(definition.kind));
|
||||
bb.writeVarUint(definition.fields.length);
|
||||
|
||||
for (let j = 0; j < definition.fields.length; j++) {
|
||||
let field = definition.fields[j];
|
||||
let type = types.indexOf(field.type);
|
||||
|
||||
bb.writeString(field.name);
|
||||
bb.writeVarInt(type === -1 ? definitionIndex[field.type!] : ~type);
|
||||
bb.writeByte(field.isArray ? 1 : 0);
|
||||
bb.writeVarUint(field.value);
|
||||
}
|
||||
}
|
||||
|
||||
return bb.toUint8Array();
|
||||
}
|
||||
5
src/kiwi/kiwi-schema/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export type { Schema, Definition, Field } from './schema'
|
||||
export { ByteBuffer } from './bb'
|
||||
export { compileSchema } from './js'
|
||||
export { decodeBinarySchema, encodeBinarySchema } from './binary'
|
||||
export { parseSchema } from './parser'
|
||||
311
src/kiwi/kiwi-schema/js.ts
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
import { Schema, Definition } from "./schema";
|
||||
import { ByteBuffer } from "./bb";
|
||||
import { error, quote } from "./util";
|
||||
|
||||
function compileDecode(definition: Definition, definitions: { [name: string]: Definition }): string {
|
||||
let lines: string[] = [];
|
||||
let indent = ' ';
|
||||
|
||||
lines.push('function (bb) {');
|
||||
lines.push(' var result = {};');
|
||||
lines.push(' if (!(bb instanceof this.ByteBuffer)) {');
|
||||
lines.push(' bb = new this.ByteBuffer(bb);');
|
||||
lines.push(' }');
|
||||
lines.push('');
|
||||
|
||||
if (definition.kind === 'MESSAGE') {
|
||||
lines.push(' while (true) {');
|
||||
lines.push(' switch (bb.readVarUint()) {');
|
||||
lines.push(' case 0:');
|
||||
lines.push(' return result;');
|
||||
lines.push('');
|
||||
indent = ' ';
|
||||
}
|
||||
|
||||
for (let i = 0; i < definition.fields.length; i++) {
|
||||
let field = definition.fields[i];
|
||||
let code: string;
|
||||
|
||||
switch (field.type) {
|
||||
case 'bool': {
|
||||
code = '!!bb.readByte()';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'byte': {
|
||||
code = 'bb.readByte()'; // only used if not array
|
||||
break;
|
||||
}
|
||||
|
||||
case 'int': {
|
||||
code = 'bb.readVarInt()';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'uint': {
|
||||
code = 'bb.readVarUint()';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'float': {
|
||||
code = 'bb.readVarFloat()';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'string': {
|
||||
code = 'bb.readString()';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'int64': {
|
||||
code = 'bb.readVarInt64()';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'uint64': {
|
||||
code = 'bb.readVarUint64()';
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
let type = definitions[field.type!];
|
||||
if (!type) {
|
||||
error('Invalid type ' + quote(field.type!) + ' for field ' + quote(field.name), field.line, field.column);
|
||||
} else if (type.kind === 'ENUM') {
|
||||
code = 'this[' + quote(type.name) + '][bb.readVarUint()]';
|
||||
} else {
|
||||
code = 'this[' + quote('decode' + type.name) + '](bb)';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (definition.kind === 'MESSAGE') {
|
||||
lines.push(' case ' + field.value + ':');
|
||||
}
|
||||
|
||||
if (field.isArray) {
|
||||
if (field.isDeprecated) {
|
||||
if (field.type === 'byte') {
|
||||
lines.push(indent + 'bb.readByteArray();');
|
||||
} else {
|
||||
lines.push(indent + 'var length = bb.readVarUint();');
|
||||
lines.push(indent + 'while (length-- > 0) ' + code + ';');
|
||||
}
|
||||
} else {
|
||||
if (field.type === 'byte') {
|
||||
lines.push(indent + 'result[' + quote(field.name) + '] = bb.readByteArray();');
|
||||
} else {
|
||||
lines.push(indent + 'var length = bb.readVarUint();');
|
||||
lines.push(indent + 'var values = result[' + quote(field.name) + '] = Array(length);');
|
||||
lines.push(indent + 'for (var i = 0; i < length; i++) values[i] = ' + code + ';');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else {
|
||||
if (field.isDeprecated) {
|
||||
lines.push(indent + code + ';');
|
||||
} else {
|
||||
lines.push(indent + 'result[' + quote(field.name) + '] = ' + code + ';');
|
||||
}
|
||||
}
|
||||
|
||||
if (definition.kind === 'MESSAGE') {
|
||||
lines.push(' break;');
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
if (definition.kind === 'MESSAGE') {
|
||||
lines.push(' default:');
|
||||
lines.push(' throw new Error("Attempted to parse invalid message");');
|
||||
lines.push(' }');
|
||||
lines.push(' }');
|
||||
}
|
||||
|
||||
else {
|
||||
lines.push(' return result;');
|
||||
}
|
||||
|
||||
lines.push('}');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function compileEncode(definition: Definition, definitions: { [name: string]: Definition }): string {
|
||||
let lines: string[] = [];
|
||||
|
||||
lines.push('function (message, bb) {');
|
||||
lines.push(' var isTopLevel = !bb;');
|
||||
lines.push(' if (isTopLevel) bb = new this.ByteBuffer();');
|
||||
|
||||
for (let j = 0; j < definition.fields.length; j++) {
|
||||
let field = definition.fields[j];
|
||||
let code: string;
|
||||
|
||||
if (field.isDeprecated) {
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (field.type) {
|
||||
case 'bool': {
|
||||
code = 'bb.writeByte(value);';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'byte': {
|
||||
code = 'bb.writeByte(value);'; // only used if not array
|
||||
break;
|
||||
}
|
||||
|
||||
case 'int': {
|
||||
code = 'bb.writeVarInt(value);';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'uint': {
|
||||
code = 'bb.writeVarUint(value);';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'float': {
|
||||
code = 'bb.writeVarFloat(value);';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'string': {
|
||||
code = 'bb.writeString(value);';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'int64': {
|
||||
code = 'bb.writeVarInt64(value);';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'uint64': {
|
||||
code = 'bb.writeVarUint64(value);';
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
let type = definitions[field.type!];
|
||||
if (!type) {
|
||||
throw new Error('Invalid type ' + quote(field.type!) + ' for field ' + quote(field.name));
|
||||
} else if (type.kind === 'ENUM') {
|
||||
code =
|
||||
'var encoded = this[' + quote(type.name) + '][value]; ' +
|
||||
'if (encoded === void 0) throw new Error("Invalid value " + JSON.stringify(value) + ' + quote(' for enum ' + quote(type.name)) + '); ' +
|
||||
'bb.writeVarUint(encoded);';
|
||||
} else {
|
||||
code = 'this[' + quote('encode' + type.name) + '](value, bb);';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(' var value = message[' + quote(field.name) + '];');
|
||||
lines.push(' if (value != null) {'); // Comparing with null using "!=" also checks for undefined
|
||||
|
||||
if (definition.kind === 'MESSAGE') {
|
||||
lines.push(' bb.writeVarUint(' + field.value + ');');
|
||||
}
|
||||
|
||||
if (field.isArray) {
|
||||
if (field.type === 'byte') {
|
||||
lines.push(' bb.writeByteArray(value);');
|
||||
} else {
|
||||
lines.push(' var values = value, n = values.length;');
|
||||
lines.push(' bb.writeVarUint(n);');
|
||||
lines.push(' for (var i = 0; i < n; i++) {');
|
||||
lines.push(' value = values[i];');
|
||||
lines.push(' ' + code);
|
||||
lines.push(' }');
|
||||
}
|
||||
}
|
||||
|
||||
else {
|
||||
lines.push(' ' + code);
|
||||
}
|
||||
|
||||
if (definition.kind === 'STRUCT') {
|
||||
lines.push(' } else {');
|
||||
lines.push(' throw new Error(' + quote('Missing required field ' + quote(field.name)) + ');');
|
||||
}
|
||||
|
||||
lines.push(' }');
|
||||
}
|
||||
|
||||
// A field id of zero is reserved to indicate the end of the message
|
||||
if (definition.kind === 'MESSAGE') {
|
||||
lines.push(' bb.writeVarUint(0);');
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(' if (isTopLevel) return bb.toUint8Array();');
|
||||
lines.push('}');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function compileSchemaJS(schema: Schema): string {
|
||||
let definitions: { [name: string]: Definition } = {};
|
||||
let name = schema.package;
|
||||
let js: string[] = [];
|
||||
|
||||
if (name !== null) {
|
||||
js.push('var ' + name + ' = exports || ' + name + ' || {}, exports;');
|
||||
} else {
|
||||
js.push('var exports = exports || {};');
|
||||
name = 'exports';
|
||||
}
|
||||
|
||||
js.push(name + '.ByteBuffer = ' + name + '.ByteBuffer || require("kiwi-schema").ByteBuffer;');
|
||||
|
||||
for (let i = 0; i < schema.definitions.length; i++) {
|
||||
let definition = schema.definitions[i];
|
||||
definitions[definition.name] = definition;
|
||||
}
|
||||
|
||||
for (let i = 0; i < schema.definitions.length; i++) {
|
||||
let definition = schema.definitions[i];
|
||||
|
||||
switch (definition.kind) {
|
||||
case 'ENUM': {
|
||||
let value: any = {};
|
||||
for (let j = 0; j < definition.fields.length; j++) {
|
||||
let field = definition.fields[j];
|
||||
value[field.name] = field.value;
|
||||
value[field.value] = field.name;
|
||||
}
|
||||
js.push(name + '[' + quote(definition.name) + '] = ' + JSON.stringify(value, null, 2) + ';');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'STRUCT':
|
||||
case 'MESSAGE': {
|
||||
js.push('');
|
||||
js.push(name + '[' + quote('decode' + definition.name) + '] = ' + compileDecode(definition, definitions) + ';');
|
||||
js.push('');
|
||||
js.push(name + '[' + quote('encode' + definition.name) + '] = ' + compileEncode(definition, definitions) + ';');
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
error('Invalid definition kind ' + quote(definition.kind), definition.line, definition.column);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
js.push('');
|
||||
return js.join('\n');
|
||||
}
|
||||
|
||||
export function compileSchema(schema: Schema): any {
|
||||
let result = {
|
||||
ByteBuffer: ByteBuffer,
|
||||
};
|
||||
new Function('exports', compileSchemaJS(schema))(result);
|
||||
return result;
|
||||
}
|
||||
279
src/kiwi/kiwi-schema/parser.ts
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
import { Schema, Definition, Field, DefinitionKind } from "./schema";
|
||||
import { error, quote } from "./util";
|
||||
|
||||
export let nativeTypes = [
|
||||
'bool',
|
||||
'byte',
|
||||
'float',
|
||||
'int',
|
||||
'int64',
|
||||
'string',
|
||||
'uint',
|
||||
'uint64',
|
||||
];
|
||||
|
||||
// These are special names on the object returned by compileSchema()
|
||||
export let reservedNames = [
|
||||
'ByteBuffer',
|
||||
'package',
|
||||
];
|
||||
|
||||
let regex = /((?:-|\b)\d+\b|[=;{}]|\[\]|\[deprecated\]|\b[A-Za-z_][A-Za-z0-9_]*\b|\/\/.*|\s+)/g;
|
||||
let identifier = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
let whitespace = /^\/\/.*|\s+$/;
|
||||
let equals = /^=$/;
|
||||
let endOfFile = /^$/;
|
||||
let semicolon = /^;$/;
|
||||
let integer = /^-?\d+$/;
|
||||
let leftBrace = /^\{$/;
|
||||
let rightBrace = /^\}$/;
|
||||
let arrayToken = /^\[\]$/;
|
||||
let enumKeyword = /^enum$/;
|
||||
let structKeyword = /^struct$/;
|
||||
let messageKeyword = /^message$/;
|
||||
let packageKeyword = /^package$/;
|
||||
let deprecatedToken = /^\[deprecated\]$/;
|
||||
|
||||
interface Token {
|
||||
text: string
|
||||
line: number
|
||||
column: number
|
||||
}
|
||||
|
||||
function tokenize(text: string): Token[] {
|
||||
let parts = text.split(regex);
|
||||
let tokens = [];
|
||||
let column = 0;
|
||||
let line = 0;
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
let part = parts[i];
|
||||
|
||||
// Keep non-whitespace tokens
|
||||
if (i & 1) {
|
||||
if (!whitespace.test(part)) {
|
||||
tokens.push({
|
||||
text: part,
|
||||
line: line + 1,
|
||||
column: column + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Detect syntax errors
|
||||
else if (part !== '') {
|
||||
error('Syntax error ' + quote(part), line + 1, column + 1);
|
||||
}
|
||||
|
||||
// Keep track of the line and column counts
|
||||
let lines = part.split('\n');
|
||||
if (lines.length > 1) column = 0;
|
||||
line += lines.length - 1;
|
||||
column += lines[lines.length - 1].length;
|
||||
}
|
||||
|
||||
// End-of-file token
|
||||
tokens.push({
|
||||
text: '',
|
||||
line: line,
|
||||
column: column,
|
||||
});
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function parse(tokens: Token[]): Schema {
|
||||
function current(): Token {
|
||||
return tokens[index];
|
||||
}
|
||||
|
||||
function eat(test: RegExp): boolean {
|
||||
if (test.test(current().text)) {
|
||||
index++;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function expect(test: RegExp, expected: string): void {
|
||||
if (!eat(test)) {
|
||||
let token = current();
|
||||
error('Expected ' + expected + ' but found ' + quote(token.text), token.line, token.column);
|
||||
}
|
||||
}
|
||||
|
||||
function unexpectedToken(): never {
|
||||
let token = current();
|
||||
error('Unexpected token ' + quote(token.text), token.line, token.column);
|
||||
}
|
||||
|
||||
let definitions: Definition[] = [];
|
||||
let packageText = null;
|
||||
let index = 0;
|
||||
|
||||
if (eat(packageKeyword)) {
|
||||
packageText = current().text;
|
||||
expect(identifier, 'identifier');
|
||||
expect(semicolon, '";"');
|
||||
}
|
||||
|
||||
while (index < tokens.length && !eat(endOfFile)) {
|
||||
let fields: Field[] = [];
|
||||
let kind: DefinitionKind;
|
||||
|
||||
if (eat(enumKeyword)) kind = 'ENUM';
|
||||
else if (eat(structKeyword)) kind = 'STRUCT';
|
||||
else if (eat(messageKeyword)) kind = 'MESSAGE';
|
||||
else unexpectedToken();
|
||||
|
||||
// All definitions start off the same
|
||||
let name = current();
|
||||
expect(identifier, 'identifier');
|
||||
expect(leftBrace, '"{"');
|
||||
|
||||
// Parse fields
|
||||
while (!eat(rightBrace)) {
|
||||
let type: string | null = null;
|
||||
let isArray = false;
|
||||
let isDeprecated = false;
|
||||
|
||||
// Enums don't have types
|
||||
if (kind !== 'ENUM') {
|
||||
type = current().text;
|
||||
expect(identifier, 'identifier');
|
||||
isArray = eat(arrayToken);
|
||||
}
|
||||
|
||||
let field = current();
|
||||
expect(identifier, 'identifier');
|
||||
|
||||
// Structs don't have explicit values
|
||||
let value: Token | null = null;
|
||||
if (kind !== 'STRUCT') {
|
||||
expect(equals, '"="');
|
||||
value = current();
|
||||
expect(integer, 'integer');
|
||||
|
||||
if ((+value.text | 0) + '' !== value.text) {
|
||||
error('Invalid integer ' + quote(value.text), value.line, value.column);
|
||||
}
|
||||
}
|
||||
|
||||
let deprecated = current();
|
||||
if (eat(deprecatedToken)) {
|
||||
if (kind !== 'MESSAGE') {
|
||||
error('Cannot deprecate this field', deprecated.line, deprecated.column);
|
||||
}
|
||||
|
||||
isDeprecated = true;
|
||||
}
|
||||
|
||||
expect(semicolon, '";"');
|
||||
|
||||
fields.push({
|
||||
name: field.text,
|
||||
line: field.line,
|
||||
column: field.column,
|
||||
type: type,
|
||||
isArray: isArray,
|
||||
isDeprecated: isDeprecated,
|
||||
value: value !== null ? +value.text | 0 : fields.length + 1,
|
||||
});
|
||||
}
|
||||
|
||||
definitions.push({
|
||||
name: name.text,
|
||||
line: name.line,
|
||||
column: name.column,
|
||||
kind: kind,
|
||||
fields: fields,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
package: packageText,
|
||||
definitions: definitions,
|
||||
};
|
||||
}
|
||||
|
||||
function verify(root: Schema): void {
|
||||
let definedTypes = nativeTypes.slice();
|
||||
let definitions: { [name: string]: Definition } = {};
|
||||
|
||||
// Define definitions
|
||||
for (let i = 0; i < root.definitions.length; i++) {
|
||||
let definition = root.definitions[i];
|
||||
if (definedTypes.indexOf(definition.name) !== -1) {
|
||||
error('The type ' + quote(definition.name) + ' is defined twice', definition.line, definition.column);
|
||||
}
|
||||
if (reservedNames.indexOf(definition.name) !== -1) {
|
||||
error('The type name ' + quote(definition.name) + ' is reserved', definition.line, definition.column);
|
||||
}
|
||||
definedTypes.push(definition.name);
|
||||
definitions[definition.name] = definition;
|
||||
}
|
||||
|
||||
// Check fields
|
||||
for (let i = 0; i < root.definitions.length; i++) {
|
||||
let definition = root.definitions[i];
|
||||
let fields = definition.fields;
|
||||
|
||||
if (definition.kind === 'ENUM' || fields.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check types
|
||||
for (let j = 0; j < fields.length; j++) {
|
||||
let field = fields[j];
|
||||
if (definedTypes.indexOf(field.type!) === -1) {
|
||||
error('The type ' + quote(field.type!) + ' is not defined for field ' + quote(field.name), field.line, field.column);
|
||||
}
|
||||
}
|
||||
|
||||
// Check values
|
||||
let values: number[] = [];
|
||||
for (let j = 0; j < fields.length; j++) {
|
||||
let field = fields[j];
|
||||
if (values.indexOf(field.value) !== -1) {
|
||||
error('The id for field ' + quote(field.name) + ' is used twice', field.line, field.column);
|
||||
}
|
||||
if (field.value <= 0) {
|
||||
error('The id for field ' + quote(field.name) + ' must be positive', field.line, field.column);
|
||||
}
|
||||
// Figma schema uses sparse field IDs (up to 435), so skip sequential check
|
||||
values.push(field.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Check that structs don't contain themselves
|
||||
let state: { [name: string]: number } = {};
|
||||
let check = (name: string): boolean => {
|
||||
let definition = definitions[name];
|
||||
if (definition && definition.kind === 'STRUCT') {
|
||||
if (state[name] === 1) {
|
||||
error('Recursive nesting of ' + quote(name) + ' is not allowed', definition.line, definition.column);
|
||||
}
|
||||
if (state[name] !== 2 && definition) {
|
||||
state[name] = 1;
|
||||
let fields = definition.fields;
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
let field = fields[i];
|
||||
if (!field.isArray) {
|
||||
check(field.type!);
|
||||
}
|
||||
}
|
||||
state[name] = 2;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
for (let i = 0; i < root.definitions.length; i++) {
|
||||
check(root.definitions[i].name);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSchema(text: string): Schema {
|
||||
let schema = parse(tokenize(text));
|
||||
verify(schema);
|
||||
return schema;
|
||||
}
|
||||
24
src/kiwi/kiwi-schema/schema.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
export interface Schema {
|
||||
package: string | null;
|
||||
definitions: Definition[];
|
||||
}
|
||||
|
||||
export type DefinitionKind = 'ENUM' | 'STRUCT' | 'MESSAGE';
|
||||
|
||||
export interface Definition {
|
||||
name: string;
|
||||
line: number;
|
||||
column: number;
|
||||
kind: DefinitionKind;
|
||||
fields: Field[];
|
||||
}
|
||||
|
||||
export interface Field {
|
||||
name: string;
|
||||
line: number;
|
||||
column: number;
|
||||
type: string | null;
|
||||
isArray: boolean;
|
||||
isDeprecated: boolean;
|
||||
value: number;
|
||||
}
|
||||
10
src/kiwi/kiwi-schema/util.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export function quote(text: string): string {
|
||||
return JSON.stringify(text);
|
||||
}
|
||||
|
||||
export function error(text: string, line: number, column: number): never {
|
||||
var error = new Error(text);
|
||||
(error as any).line = line;
|
||||
(error as any).column = column;
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -1,180 +0,0 @@
|
|||
/**
|
||||
* PoC 3: Kiwi codec round-trip test
|
||||
*
|
||||
* Validates:
|
||||
* 1. Schema compiles (194 definitions, 2178 lines)
|
||||
* 2. Encode a NODE_CHANGES message with shapes
|
||||
* 3. Decode it back — verify data integrity
|
||||
* 4. Zstd compress/decompress round-trip
|
||||
*/
|
||||
|
||||
import {
|
||||
initCodec,
|
||||
encodeMessage,
|
||||
decodeMessage,
|
||||
createNodeChange,
|
||||
createNodeChangesMessage
|
||||
} from './codec.ts'
|
||||
|
||||
import type { NodeChange } from './codec.ts'
|
||||
|
||||
async function runTest() {
|
||||
console.log('=== PoC 3: Kiwi Codec Round-Trip ===\n')
|
||||
|
||||
// 1. Init codec (compiles schema)
|
||||
const t0 = performance.now()
|
||||
await initCodec()
|
||||
console.log(`✅ Schema compiled in ${(performance.now() - t0).toFixed(1)}ms`)
|
||||
|
||||
// 2. Create test node changes
|
||||
const sessionID = 99999
|
||||
const changes: NodeChange[] = [
|
||||
createNodeChange({
|
||||
sessionID,
|
||||
localID: 1,
|
||||
parentSessionID: 0,
|
||||
parentLocalID: 0,
|
||||
type: 'FRAME',
|
||||
name: 'Test Frame',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 800,
|
||||
height: 600,
|
||||
fill: '#FFFFFF'
|
||||
}),
|
||||
createNodeChange({
|
||||
sessionID,
|
||||
localID: 2,
|
||||
parentSessionID: sessionID,
|
||||
parentLocalID: 1,
|
||||
type: 'RECTANGLE',
|
||||
name: 'Blue Card',
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 200,
|
||||
height: 150,
|
||||
fill: '#3B82F6',
|
||||
cornerRadius: 12
|
||||
}),
|
||||
createNodeChange({
|
||||
sessionID,
|
||||
localID: 3,
|
||||
parentSessionID: sessionID,
|
||||
parentLocalID: 1,
|
||||
type: 'ELLIPSE',
|
||||
name: 'Green Circle',
|
||||
x: 300,
|
||||
y: 80,
|
||||
width: 100,
|
||||
height: 100,
|
||||
fill: '#22C55E'
|
||||
}),
|
||||
createNodeChange({
|
||||
sessionID,
|
||||
localID: 4,
|
||||
parentSessionID: sessionID,
|
||||
parentLocalID: 1,
|
||||
type: 'TEXT',
|
||||
name: 'Hello World',
|
||||
x: 50,
|
||||
y: 250,
|
||||
width: 200,
|
||||
height: 40,
|
||||
fill: '#000000'
|
||||
})
|
||||
]
|
||||
|
||||
console.log(`✅ Created ${changes.length} node changes`)
|
||||
|
||||
// 3. Encode
|
||||
const message = createNodeChangesMessage(sessionID, 1, changes)
|
||||
const t1 = performance.now()
|
||||
const encoded = encodeMessage(message)
|
||||
const encodeTime = performance.now() - t1
|
||||
console.log(`✅ Encoded: ${encoded.byteLength} bytes (${encodeTime.toFixed(2)}ms)`)
|
||||
console.log(
|
||||
` Zstd compressed: starts with 0x${encoded[0]?.toString(16)}${encoded[1]?.toString(16)}${encoded[2]?.toString(16)}${encoded[3]?.toString(16)}`
|
||||
)
|
||||
|
||||
// 4. Decode
|
||||
const t2 = performance.now()
|
||||
const decoded = decodeMessage(encoded)
|
||||
const decodeTime = performance.now() - t2
|
||||
console.log(`✅ Decoded in ${decodeTime.toFixed(2)}ms`)
|
||||
|
||||
// 5. Verify round-trip
|
||||
const nc = decoded.nodeChanges
|
||||
if (!nc || nc.length !== changes.length) {
|
||||
console.error(`❌ Node count mismatch: expected ${changes.length}, got ${nc?.length}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
for (let i = 0; i < nc.length; i++) {
|
||||
const original = changes[i]!
|
||||
const roundtripped = nc[i]!
|
||||
|
||||
// Verify GUID
|
||||
if (
|
||||
roundtripped.guid?.sessionID !== original.guid.sessionID ||
|
||||
roundtripped.guid?.localID !== original.guid.localID
|
||||
) {
|
||||
console.error(`❌ Node ${i} GUID mismatch`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Verify name
|
||||
if (roundtripped.name !== original.name) {
|
||||
console.error(`❌ Node ${i} name mismatch: "${roundtripped.name}" !== "${original.name}"`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Verify type
|
||||
if (roundtripped.type !== original.type) {
|
||||
console.error(`❌ Node ${i} type mismatch: "${roundtripped.type}" !== "${original.type}"`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Verify size
|
||||
if (roundtripped.size?.x !== original.size?.x || roundtripped.size?.y !== original.size?.y) {
|
||||
console.error(`❌ Node ${i} size mismatch`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Verify transform (position)
|
||||
if (
|
||||
roundtripped.transform?.m02 !== original.transform?.m02 ||
|
||||
roundtripped.transform?.m12 !== original.transform?.m12
|
||||
) {
|
||||
console.error(`❌ Node ${i} position mismatch`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Verify fill
|
||||
if (original.fillPaints && original.fillPaints.length > 0) {
|
||||
const origFill = original.fillPaints[0]!
|
||||
const rtFill = roundtripped.fillPaints?.[0]
|
||||
if (!rtFill) {
|
||||
console.error(`❌ Node ${i} missing fill`)
|
||||
process.exit(1)
|
||||
}
|
||||
if (rtFill.type !== origFill.type) {
|
||||
console.error(`❌ Node ${i} fill type mismatch`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
` ✓ Node ${i}: ${roundtripped.name} (${roundtripped.type}) @ ${roundtripped.transform?.m02},${roundtripped.transform?.m12} [${roundtripped.size?.x}×${roundtripped.size?.y}]`
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`\n✅ PoC 3 PASSED: ${changes.length} nodes round-tripped through Kiwi+Zstd`)
|
||||
console.log(` Schema: 194 definitions`)
|
||||
console.log(` Encode: ${encodeTime.toFixed(2)}ms → ${encoded.byteLength} bytes`)
|
||||
console.log(` Decode: ${decodeTime.toFixed(2)}ms`)
|
||||
}
|
||||
|
||||
runTest().catch((e) => {
|
||||
console.error('❌ PoC 3 FAILED:', e)
|
||||
process.exit(1)
|
||||
})
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { parseSchema } from 'kiwi-schema'
|
||||
import { parseSchema } from './kiwi-schema'
|
||||
|
||||
const schemaText = `
|
||||
package Fig;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { reactive, shallowRef, computed } from 'vue'
|
||||
|
||||
import { DEFAULT_SHAPE_FILL, DEFAULT_FRAME_FILL } from '../constants'
|
||||
import {
|
||||
parseFigmaClipboard,
|
||||
importClipboardNodes,
|
||||
|
|
@ -8,7 +9,7 @@ import {
|
|||
buildOpenPencilClipboardHTML,
|
||||
prefetchFigmaSchema
|
||||
} from '../engine/clipboard'
|
||||
import { readFigFile } from '../engine/fig-file'
|
||||
import { readFigFile } from '../kiwi/fig-file'
|
||||
import { computeLayout, computeAllLayouts } from '../engine/layout'
|
||||
import { SceneGraph } from '../engine/scene-graph'
|
||||
import { UndoManager } from '../engine/undo'
|
||||
|
|
@ -53,22 +54,14 @@ export const TOOL_SHORTCUTS: Record<string, Tool> = {
|
|||
h: 'HAND'
|
||||
}
|
||||
|
||||
const BLACK_FILL: Fill = { type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }
|
||||
|
||||
const DEFAULT_FILLS: Record<string, Fill> = {
|
||||
FRAME: { type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true },
|
||||
RECTANGLE: {
|
||||
type: 'SOLID',
|
||||
color: { r: 0.83, g: 0.83, b: 0.83, a: 1 },
|
||||
opacity: 1,
|
||||
visible: true
|
||||
},
|
||||
ELLIPSE: {
|
||||
type: 'SOLID',
|
||||
color: { r: 0.83, g: 0.83, b: 0.83, a: 1 },
|
||||
opacity: 1,
|
||||
visible: true
|
||||
},
|
||||
LINE: { type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true },
|
||||
TEXT: { type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }
|
||||
FRAME: DEFAULT_FRAME_FILL,
|
||||
RECTANGLE: DEFAULT_SHAPE_FILL,
|
||||
ELLIPSE: DEFAULT_SHAPE_FILL,
|
||||
LINE: BLACK_FILL,
|
||||
TEXT: BLACK_FILL
|
||||
}
|
||||
|
||||
export function createEditorStore() {
|
||||
|
|
@ -306,7 +299,7 @@ export function createEditorStore() {
|
|||
vectorNetwork: normalizedNetwork,
|
||||
name: 'Vector',
|
||||
fills: closed
|
||||
? [{ type: 'SOLID' as const, color: { r: 0.83, g: 0.83, b: 0.83, a: 1 }, opacity: 1, visible: true }]
|
||||
? [{ ...DEFAULT_SHAPE_FILL }]
|
||||
: [],
|
||||
strokes: closed
|
||||
? []
|
||||
|
|
|
|||
11
src/types.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export interface GUID {
|
||||
sessionID: number
|
||||
localID: number
|
||||
}
|
||||
|
||||
export interface Color {
|
||||
r: number
|
||||
g: number
|
||||
b: number
|
||||
a: number
|
||||
}
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 18 KiB |