From 582e6ddf88440406482d0e5fb2f31f4f4329abdd Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Thu, 20 Aug 2026 08:26:55 -0500 Subject: [PATCH] build: migrate importer to wasm --- CHANGELOG.md | 12 +- Cargo.lock | 12 + Cargo.toml | 6 +- ROADMAP.md | 55 ++ TODO.md | 143 ++-- apps/desktop/src-tauri/Cargo.toml | 2 +- apps/web/package.json | 2 + .../src/content/docs/internals/svg-import.md | 15 +- apps/web/src/lib/persistence/dexie.ts | 45 +- apps/web/src/lib/persistence/repository.ts | 49 +- .../src/lib/persistence/svg-import.test.ts | 60 ++ apps/web/src/lib/persistence/svg-import.ts | 103 +++ .../src/lib/persistence/svg-import.worker.ts | 20 + crates/inkfinite-cli/Cargo.toml | 2 +- .../src/bin/generate-bindings.rs | 24 +- crates/inkfinite-core/Cargo.toml | 3 + crates/inkfinite-core/src/ipc/mod.rs | 12 + crates/inkfinite-core/src/svg_import.rs | 37 +- .../tests/svg_import_fixtures.rs | 28 + crates/inkfinite-wasm/Cargo.toml | 19 + crates/inkfinite-wasm/src/lib.rs | 124 +++ .../tests/svg_import_fixtures.rs | 58 ++ fixtures/svg-import/README.md | 10 +- .../icons/bootstrap-filetype-svg.svg | 3 + package.json | 3 +- packages/bindings/package.json | 6 +- packages/bindings/src/index.ts | 1 + packages/bindings/src/svg-import.ts | 237 ++++++ packages/core/package.json | 1 + packages/core/src/interchange.ts | 43 +- packages/core/src/interchange/svg.ts | 714 +++++++----------- packages/core/src/model.ts | 42 ++ packages/core/src/persistence/document.ts | 6 +- .../lib/editor/canvas/canvas-store.svelte.ts | 131 +++- packages/ui/src/lib/editor/platform.ts | 46 +- .../src/lib/editor/svg-import.svelte.test.ts | 90 ++- packages/wasm/package.json | 26 + packages/wasm/src/index.ts | 37 + packages/wasm/tsconfig.json | 11 + pnpm-lock.yaml | 16 + scripts/build-wasm.mjs | 21 + 41 files changed, 1667 insertions(+), 608 deletions(-) create mode 100644 apps/web/src/lib/persistence/svg-import.test.ts create mode 100644 apps/web/src/lib/persistence/svg-import.ts create mode 100644 apps/web/src/lib/persistence/svg-import.worker.ts create mode 100644 crates/inkfinite-wasm/Cargo.toml create mode 100644 crates/inkfinite-wasm/src/lib.rs create mode 100644 crates/inkfinite-wasm/tests/svg_import_fixtures.rs create mode 100644 fixtures/svg-import/icons/bootstrap-filetype-svg.svg create mode 100644 packages/bindings/src/svg-import.ts create mode 100644 packages/wasm/package.json create mode 100644 packages/wasm/src/index.ts create mode 100644 packages/wasm/tsconfig.json create mode 100644 scripts/build-wasm.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1272077..0ed1070 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,10 +47,14 @@ valid/invalid geometry fixtures. - SVG imports committed as one validated transaction from the desktop file menu, browser file and drop entry points, and the CLI. -- SVG import fixture corpus covering Iconify-derived icons and logos, nested - groups, compound paths, unsupported content, and malformed inputs, with - importer regression tests for native mappings, `currentColor`, warnings, and - typed failures. +- SVG import fixture corpus covering Iconify-derived icons and logos, the + Bootstrap `filetype-svg` regression icon, nested groups, compound paths, + unsupported content, and malformed inputs, with importer regression tests for + native mappings, `currentColor`, warnings, and typed failures. +- Browser SVG imports now use the Rust importer through a lazy WASM facade and + reusable worker. The normalized result retains groups, composed transforms, + styles, fill rules, source assets, embedded assets, warnings, and structured + failures before one IndexedDB board import. ### Changed diff --git a/Cargo.lock b/Cargo.lock index 03d29bb..2af175c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1586,9 +1586,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -2134,6 +2136,16 @@ dependencies = [ "ts-rs", ] +[[package]] +name = "inkfinite-wasm" +version = "0.0.0" +dependencies = [ + "inkfinite-core", + "serde", + "serde_json", + "wasm-bindgen", +] + [[package]] name = "ipnet" version = "2.12.0" diff --git a/Cargo.toml b/Cargo.toml index ff45dfa..84da365 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "apps/desktop/src-tauri", "crates/inkfinite-cli", "crates/inkfinite-core", + "crates/inkfinite-wasm", ] resolver = "2" @@ -24,10 +25,11 @@ roxmltree = "0.21.1" schemars = "1.2.1" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -getrandom = "0.4.3" +getrandom = { version = "0.4.3", features = ["wasm_js"] } sha2 = "0.10.9" +wasm-bindgen = "=0.2.126" svgtypes = "0.16.1" -tokio = { version = "1.48", features = ["io-util", "macros", "net", "rt-multi-thread", "sync"] } +tokio = { version = "1.48", default-features = false, features = ["io-util", "macros", "rt", "sync"] } ts-rs = { version = "12.0.1", features = ["no-serde-warnings", "serde-json-impl"] } thiserror = "2.0" diff --git a/ROADMAP.md b/ROADMAP.md index e5fff76..fbbedc6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -90,6 +90,61 @@ The [native path geometry guide](apps/web/src/content/docs/internals/native-path documents the native representation, fill rules, validation, exact bounds, rendering, hit testing, and fixture coverage used by later import and editing work. +### WASM + +Use WebAssembly to bring Inkfinite's canonical Rust semantics to the browser, +not to replace the browser editor. Rust should own document meaning while +TypeScript continues to own immediate interaction and browser APIs. + +The web app currently persists a TypeScript document graph in Dexie, while the +desktop app and CLI use the Rust transaction engine and Automerge document. +Desktop projection also flattens native hierarchy without composing every +ancestor transform, and its reverse mirror can delete and recreate the scene for +an ordinary edit. Shared TypeScript types do not prevent these execution paths +from diverging. + +The first step is a thin `inkfinite-wasm` facade. SVG import establishes the +build, packaging, worker, and binding path. Deterministic SVG export should use +the Rust renderer through the same facade so browser, desktop, and CLI output +share hierarchy, bounds, and serialization behavior. Canvas rendering and PNG +export remain in TypeScript. + +The second step moves native-to-editor projection and editor-to-native +reconciliation into Rust. Projection must compose hierarchy transforms into a +consistent editor view. Editor commits should describe semantic changes such as +moving, resizing, restyling, reparenting, or changing path geometry. Rust should +translate those changes into minimal validated transactions instead of replacing +the scene. Native and WASM callers should exercise the same projection and +reconciliation fixtures. + +The third step makes the browser document session stateful in WASM. It should +open, mutate, undo, redo, and save the same canonical Automerge document used by +native entry points. IndexedDB remains the browser storage adapter, but stores +canonical document bytes rather than acting as a second shape graph. Existing +browser documents need an explicit migration before the Dexie graph can stop +being the source of truth. + +The JS/WASM boundary should carry coarse operations and batches. Strings, +objects, and arrays may require conversion or copying, so pointer movement, +hover, selection, camera state, drag previews, snap guides, Canvas rendering, +and DOM/Svelte work remain in TypeScript. A persistent WASM session avoids +serializing the complete document for each operation. + +Rust remains authoritative for committed path geometry, exact bounds, +transforms, reparenting, topology, arc conversion, validation, and final +freehand normalization. TypeScript may keep fast previews, cached bounds, hit +testing, and selection geometry for responsive interaction. Shared fixtures +must keep those previews acceptably aligned with committed results. Batched WASM +geometry queries should be considered only when profiling shows a need. + +Excalidraw and JSON Canvas conversion can remain TypeScript-only while they are +browser conveniences. Move them into Rust when desktop, CLI, or MCP also need +them. TypeScript history likewise continues to own ephemeral editor state; +durable document undo and redo belong to the Rust session. + +This sequencing leads to one document engine across browser, desktop, CLI, and +future MCP access without placing the frame-by-frame editor loop behind WASM. + ### Vector editing Build native vector editing on top of the path representation introduced for diff --git a/TODO.md b/TODO.md index 1c6acba..ac8020b 100644 --- a/TODO.md +++ b/TODO.md @@ -8,109 +8,92 @@ Completed work is in [CHANGELOG.md](CHANGELOG.md). ### Native path geometry -#### Representation and validation - -- [x] Add a native `path` shape kind -- [x] Define normalized path and subpath representation -- [x] Support move segments -- [x] Support line segments -- [x] Support quadratic curves -- [x] Support cubic curves -- [x] Support closed subpaths -- [x] Define compound-path fill rules -- [x] Generate TypeScript bindings for path geometry -- [x] Implement Rust path validation - -#### Geometry, rendering, and fixtures - -- [x] Implement path bounds -- [x] Include quadratic and cubic extrema in bounds -- [x] Implement Canvas path rendering -- [x] Implement path fill hit testing -- [x] Implement path stroke hit testing -- [x] Implement deterministic SVG path rendering -- [x] Support parent-relative path transforms -- [x] Add shared Rust/TypeScript path fixtures -- [x] Add invalid-path fixtures +Inkfinite now supports validated native paths across Rust and TypeScript, including compound geometry, transforms, bounds, rendering, hit testing, and shared fixtures. ### SVG import -#### Parsing and native mapping - -- [x] Add an SVG import boundary -- [x] Parse SVG into a normalized intermediate representation -- [x] Import `` as containers -- [x] Import `` as rect shapes -- [x] Import `` as ellipse shapes -- [x] Import `` as ellipse shapes -- [x] Import `` as line shapes -- [x] Import `` as path shapes -- [x] Import `` as path shapes -- [x] Import `` as path shapes -- [x] Preserve nested transforms -- [x] Preserve supported fill styles -- [x] Preserve supported stroke styles -- [x] Preserve opacity -- [x] Define SVG text import behavior -- [x] Import supported embedded raster images as assets - -#### Unsupported content and security - -- [x] Preserve original SVG source as an asset -- [x] Define warnings for unsupported SVG features -- [x] Define opaque fallback behavior for unsupported visual subtrees -- [x] Handle gradients explicitly -- [x] Handle clip paths explicitly -- [x] Handle masks explicitly -- [x] Handle filters explicitly -- [x] Reject or ignore scripts and animation explicitly - -#### Transactions and entry points - -- [x] Commit imports through one validated transaction -- [x] Add desktop SVG file import -- [x] Add web-app SVG file import - - [x] Add drag-and-drop SVG import -- [x] Add CLI SVG import - -#### Import fixtures - -- [x] Add SVG import fixtures for icons -- [x] Add SVG import fixtures for logos -- [x] Add SVG import fixtures for nested groups -- [x] Add SVG import fixtures for compound paths -- [x] Add SVG import fixtures for unsupported features -- [x] Add malformed SVG fixtures - -#### Shared Importer - -- [ ] Expose the Rust SVG importer to the browser through WASM -- [ ] Run browser SVG imports in a reusable web worker -- [ ] Project shared import results into browser documents -- [ ] Route every web SVG entry point through the shared importer -- [ ] Remove the handwritten TypeScript SVG parser -- [ ] Test browser WASM imports with the shared fixture corpus -- [ ] Add Bootstrap `filetype-svg` regression coverage +Inkfinite now imports SVGs through one validated Rust pipeline across desktop, web, and CLI while preserving supported content and safely retaining unsupported visual content. ### SVG round-trip #### Document workflows - [ ] Test SVG import → save → reopen + - Import representative SVGs, save each document, and reopen it. Confirm the scene + hierarchy, geometry, styles, and fallback content match the imported state. - [ ] Test SVG import → edit → SVG export + - Import an SVG, modify its shapes in Inkfinite, and export it as SVG. Confirm the + export includes the edits and remains valid when opened by an independent SVG renderer. - [ ] Test SVG import → undo → redo + - Import an SVG, undo the import, and redo it. Verify both operations restore the + expected document state without missing or duplicated shapes. - [ ] Test imported content through CRDT merge + - Import SVG content into one replica, merge it with concurrent changes from another replica, + and verify convergence. Cover native shapes, hierarchy, styles, and opaque fallback content. - [ ] Test imported shapes through CLI inspect + - Import an SVG and use the CLI inspection commands to examine the resulting document. + Confirm imported shape types, properties, hierarchy, and source metadata are reported correctly. - [ ] Test imported shapes through CLI query + - Query an imported document for representative native and fallback shapes. + Verify filters and selectors return the expected shapes and expose the properties needed by CLI users. - [ ] Test imported shapes through CLI mutation + - Apply CLI mutations to imported shapes, then inspect and render the result. + Confirm supported properties change correctly without corrupting unrelated SVG data or hierarchy. #### Export fidelity - [ ] Verify native vector geometry exports without rasterization + - Import supported SVG geometry and export it again. Assert that paths and native + primitives remain vector elements rather than images or other rasterized output. - [ ] Verify nested transforms export deterministically + - Round-trip fixtures with multiple transform levels and compare repeated exports. + Confirm transform composition preserves visual placement and produces identical serialized + output each time. - [ ] Verify compound fill rules survive import and export + - Round-trip compound paths that use both `nonzero` and `evenodd` fill rules. Render + or inspect the exports to confirm holes and overlapping regions retain their original fill behavior. - [ ] Verify opaque fallback content remains visually stable + - Round-trip unsupported SVG elements stored as opaque fallback content. Compare renders + before and after export to catch changes in appearance, placement, clipping, or styling. - [ ] Add deterministic round-trip fixtures + - Add focused fixtures for native geometry, transforms, compound fills, and opaque fallbacks. Make + tests compare canonical exports or renders so regressions produce stable, reviewable failures. + +## WASM + +### Browser facade + +- [ ] Establish the `inkfinite-wasm` build and TypeScript bindings +- [ ] Expose deterministic Rust SVG rendering to the browser +- [ ] Route browser SVG export through Rust +- [ ] Keep Canvas and PNG rendering in TypeScript + +### Editor projection and reconciliation + +- [ ] Move native-to-editor projection into Rust +- [ ] Compose ancestor transforms in editor projections +- [ ] Define semantic editor patches for durable changes +- [ ] Reconcile editor patches into minimal native transactions +- [ ] Stop rebuilding the native scene for ordinary edits +- [ ] Test native and WASM projection parity + +### Browser document engine + +- [ ] Add a stateful WASM document session +- [ ] Open and save canonical Automerge document bytes +- [ ] Apply validated Rust transactions in the browser +- [ ] Use Rust undo and redo for durable changes +- [ ] Persist canonical document state to IndexedDB +- [ ] Migrate existing browser documents to canonical state +- [ ] Retire the Dexie shape graph as the browser source of truth +- [ ] Keep ephemeral editor history in TypeScript + +### Canonical geometry + +- [ ] Commit path geometry through Rust validation +- [ ] Commit freehand strokes through Rust normalization +- [ ] Keep gesture previews and hit testing in TypeScript +- [ ] Add committed-geometry parity fixtures ## Vector Editing diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index be79b1b..a0e02b5 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -27,4 +27,4 @@ inkfinite-core.workspace = true log = "0.4" serde = { version = "1", features = ["derive"] } serde_json = "1" -tokio.workspace = true +tokio = { workspace = true, features = ["net", "rt-multi-thread"] } diff --git a/apps/web/package.json b/apps/web/package.json index 0352691..a4ce0e0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,6 +4,7 @@ "version": "0.0.1", "type": "module", "scripts": { + "prebuild": "pnpm --filter @inkfinite/wasm build", "dev": "pnpm pagefind:dev && vite dev", "dev:plain": "vite dev", "pagefind:dev": "vite build && pagefind --site build --output-path static/pagefind --quiet", @@ -20,6 +21,7 @@ }, "dependencies": { "@inkfinite/core": "workspace:*", + "@inkfinite/wasm": "workspace:*", "@inkfinite/ui": "workspace:*", "dexie": "^4.2.1" }, diff --git a/apps/web/src/content/docs/internals/svg-import.md b/apps/web/src/content/docs/internals/svg-import.md index 698d14b..fff31a9 100644 --- a/apps/web/src/content/docs/internals/svg-import.md +++ b/apps/web/src/content/docs/internals/svg-import.md @@ -138,9 +138,13 @@ the imported subtree movable as one object. Desktop imports use the native Tauri dialog plugin to select a path, then Rust reads, parses, and commits the file through the active session. The browser adapter exposes an Import menu with editable-document, SVG file, -and pasted SVG code/markup options. It uses the supported element mapping for -file selection, pasted markup, and SVG drop, then persists the imported board in -one IndexedDB operation. The CLI accepts +and pasted SVG code/markup options. SVG file bytes and pasted markup cross one +reusable worker, which lazy-loads the Rust WASM facade and returns the normalized +result. The browser projects that result into its local document model, retains +source and embedded assets, and persists the imported board in one IndexedDB +operation. File selection, drag-and-drop, and pasted markup use this path. The +web build runs `scripts/build-wasm.mjs` before Vite packages the worker; it +requires the matching `wasm-bindgen` CLI. The CLI accepts `inkfinite import svg FILE --input ARTWORK.svg` and can validate the transaction with `--dry-run` before saving. @@ -155,4 +159,7 @@ input data for provenance and future re-import, not executable document content. The parser accepts at most 16 MiB of UTF-8 input. XML, numeric attributes, transforms, path data, and embedded image data are validated before they enter -the result. Malformed input returns an error rather than a partial import. +the result. Malformed input returns an error rather than a partial import. The +WASM response preserves the parser's structured error code and message across +the worker boundary, and a failed import leaves the editor's busy state and +current document unchanged. diff --git a/apps/web/src/lib/persistence/dexie.ts b/apps/web/src/lib/persistence/dexie.ts index fd67006..579ed8d 100644 --- a/apps/web/src/lib/persistence/dexie.ts +++ b/apps/web/src/lib/persistence/dexie.ts @@ -9,6 +9,7 @@ import type { EditorPlatformAdapter, EditorPlatformSession } from '@inkfinite/ui import { liveQuery } from 'dexie'; import { InkfiniteDB } from './database'; import { createDexieDocRepo, createPersistenceSink, getBoardInspectorData } from './repository'; +import { importSvgInWorker } from './svg-import'; import type { PersistenceSinkOptions } from './repository'; type LiveQueryFactory = typeof liveQuery; @@ -156,9 +157,51 @@ export function createBrowserInterchangeFiles() { }); } + function pickSvgFile(): Promise<{ name: string; bytes: Uint8Array } | null> { + return new Promise((resolve, reject) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.svg,image/svg+xml'; + input.hidden = true; + const finish = (value: { name: string; bytes: Uint8Array } | null) => { + input.remove(); + resolve(value); + }; + input.addEventListener('cancel', () => finish(null), { once: true }); + input.addEventListener( + 'change', + () => { + const file = input.files?.[0]; + if (!file) { + finish(null); + return; + } + if (file.size > 16 * 1024 * 1024) { + input.remove(); + reject( + new Error('The selected file is larger than the 16 MB import limit.') + ); + return; + } + void file.arrayBuffer().then( + (bytes) => finish({ name: file.name, bytes: new Uint8Array(bytes) }), + (error) => { + input.remove(); + reject(new Error(`Failed to read the selected SVG: ${String(error)}`)); + } + ); + }, + { once: true } + ); + document.body.appendChild(input); + input.click(); + }); + } + return { pickImport: () => pickTextFile('.excalidraw,.canvas,application/json'), - pickSvg: () => pickTextFile('.svg,image/svg+xml'), + pickSvg: pickSvgFile, + importSvg: importSvgInWorker, async saveExport(file: InterchangeExport, defaultStem: string): Promise { const blob = new Blob([file.contents], { type: file.mimeType }); const url = URL.createObjectURL(blob); diff --git a/apps/web/src/lib/persistence/repository.ts b/apps/web/src/lib/persistence/repository.ts index b6b8853..6ef2de6 100644 --- a/apps/web/src/lib/persistence/repository.ts +++ b/apps/web/src/lib/persistence/repository.ts @@ -17,6 +17,8 @@ import type { Document, LoadedDoc, LayerRecord, + ImportedAsset, + ImportedGroup, PageRecord, PersistenceSink, PersistentDocRepo, @@ -50,6 +52,8 @@ const DEFAULT_BOARD_NAME = 'Untitled Board'; const PAGE_ORDER_META_PREFIX = 'page-order:'; const SHAPE_ORDER_META_PREFIX = 'shape-order:'; const LAYERS_META_PREFIX = 'layers:'; +const ASSETS_META_PREFIX = 'assets:'; +const SVG_GROUPS_META_PREFIX = 'svg-groups:'; const pageOrderKey = (boardId: string) => `${PAGE_ORDER_META_PREFIX}${boardId}`; @@ -57,6 +61,10 @@ const shapeOrderKey = (boardId: string) => `${SHAPE_ORDER_META_PREFIX}${boardId} const layersKey = (boardId: string) => `${LAYERS_META_PREFIX}${boardId}`; +const assetsKey = (boardId: string) => `${ASSETS_META_PREFIX}${boardId}`; + +const svgGroupsKey = (boardId: string) => `${SVG_GROUPS_META_PREFIX}${boardId}`; + /** * Create a Dexie-backed persistent DocRepo used by the web app. */ @@ -126,16 +134,20 @@ export function createDexieDocRepo( await meta().delete(pageOrderKey(boardId)); await meta().delete(shapeOrderKey(boardId)); await meta().delete(layersKey(boardId)); + await meta().delete(assetsKey(boardId)); + await meta().delete(svgGroupsKey(boardId)); } ); } async function loadDoc(boardId: string): Promise { const pageRows = await pages().where('boardId').equals(boardId).toArray(); - const [shapeRows, bindingRows, order] = await Promise.all([ + const [shapeRows, bindingRows, order, assetsRow, svgGroupsRow] = await Promise.all([ shapes().where('boardId').equals(boardId).toArray(), bindings().where('boardId').equals(boardId).toArray(), - loadOrder(boardId, pageRows) + loadOrder(boardId, pageRows), + meta().get(assetsKey(boardId)), + meta().get(svgGroupsKey(boardId)) ]); const docPages: Record = {}; @@ -158,6 +170,8 @@ export function createDexieDocRepo( layers: order.layers, shapes: docShapes, bindings: docBindings, + assets: assetsRow?.value as Record | undefined, + svgGroups: svgGroupsRow?.value as Record | undefined, order }; } @@ -248,8 +262,16 @@ export function createDexieDocRepo( throw new Error(`Board ${boardId} not found`); } - const { pages, layers, shapes, bindings, order } = await loadDoc(boardId); - const doc: Document = { pages, ...(layers ? { layers } : {}), shapes, bindings }; + const { pages, layers, shapes, bindings, assets, svgGroups, order } = + await loadDoc(boardId); + const doc: Document = { + pages, + ...(layers ? { layers } : {}), + ...(assets ? { assets } : {}), + ...(svgGroups ? { svgGroups } : {}), + shapes, + bindings + }; return { board, doc, order }; } @@ -296,6 +318,15 @@ export function createDexieDocRepo( if (importedLayers && Object.keys(importedLayers).length > 0) { await meta().put({ key: layersKey(boardId), value: importedLayers }); } + if (snapshot.doc.assets && Object.keys(snapshot.doc.assets).length > 0) { + await meta().put({ key: assetsKey(boardId), value: snapshot.doc.assets }); + } + if (snapshot.doc.svgGroups && Object.keys(snapshot.doc.svgGroups).length > 0) { + await meta().put({ + key: svgGroupsKey(boardId), + value: snapshot.doc.svgGroups + }); + } } ); @@ -561,7 +592,13 @@ export async function getSchemaInfo(database: Dexie): Promise { } /** Fetch complete inspector data for a board. */ -export async function getBoardInspectorData(database: Dexie, boardId: string): Promise { - const [stats, schema] = await Promise.all([getBoardStats(database, boardId), getSchemaInfo(database)]); +export async function getBoardInspectorData( + database: Dexie, + boardId: string +): Promise { + const [stats, schema] = await Promise.all([ + getBoardStats(database, boardId), + getSchemaInfo(database) + ]); return { storageType: 'IndexedDB (Dexie)', stats, schema }; } diff --git a/apps/web/src/lib/persistence/svg-import.test.ts b/apps/web/src/lib/persistence/svg-import.test.ts new file mode 100644 index 0000000..e65a592 --- /dev/null +++ b/apps/web/src/lib/persistence/svg-import.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { SvgImportWorkerClient } from './svg-import'; + +type Listener = (event: { data: unknown; message?: string }) => void; + +class FakeWorker { + private listeners = new Map>(); + lastMessage: { id: number; source: ArrayBuffer } | null = null; + lastTransfer: ArrayBuffer[] = []; + terminated = false; + + addEventListener(type: string, listener: Listener) { + const listeners = this.listeners.get(type) ?? new Set(); + listeners.add(listener); + this.listeners.set(type, listeners); + } + + removeEventListener(type: string, listener: Listener) { + this.listeners.get(type)?.delete(listener); + } + + postMessage(message: { id: number; source: ArrayBuffer }, transfer: ArrayBuffer[]) { + this.lastMessage = message; + this.lastTransfer = transfer; + queueMicrotask(() => + this.listeners + .get('message') + ?.forEach((listener) => + listener({ + data: { + id: message.id, + response: { status: 'success', import: {}, omitted_image_count: 0 } + } + }) + ) + ); + } + + terminate() { + this.terminated = true; + } +} + +describe('SVG import worker client', () => { + it('transfers bytes and resolves the normalized worker response', async () => { + const worker = new FakeWorker(); + const client = new SvgImportWorkerClient(worker as unknown as Worker); + const source = new Uint8Array([60, 115, 118, 103]); + + const result = await client.import(source); + + expect(worker.lastMessage?.source).toBeInstanceOf(ArrayBuffer); + expect(worker.lastTransfer).toHaveLength(1); + expect(worker.lastTransfer[0]).toBe(worker.lastMessage?.source); + expect(result.omitted_image_count).toBe(0); + expect(source).toEqual(new Uint8Array([60, 115, 118, 103])); + client.dispose(); + expect(worker.terminated).toBe(true); + }); +}); diff --git a/apps/web/src/lib/persistence/svg-import.ts b/apps/web/src/lib/persistence/svg-import.ts new file mode 100644 index 0000000..bb26a46 --- /dev/null +++ b/apps/web/src/lib/persistence/svg-import.ts @@ -0,0 +1,103 @@ +import { importSvg as importSvgWasm, type SvgImportResponse } from '@inkfinite/wasm'; +import type { SvgImportResult } from '@inkfinite/core'; + +/** A structured parser failure returned by the SVG worker. */ +export class SvgImportWorkerError extends Error { + constructor( + readonly code: string, + message: string + ) { + super(message); + this.name = 'SvgImportError'; + } +} + +/** A worker boundary that keeps SVG decoding and Rust parsing off the UI thread. */ +export class SvgImportWorkerClient { + private nextRequestId = 0; + private readonly pending = new Map< + number, + { resolve: (value: SvgImportResult) => void; reject: (error: Error) => void } + >(); + + constructor(private readonly worker: Worker) { + worker.addEventListener('message', this.handleMessage); + worker.addEventListener('error', this.handleError); + } + + /** Imports one transferred byte buffer. */ + import(source: Uint8Array): Promise { + const id = ++this.nextRequestId; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + const transferable = source.slice(); + this.worker.postMessage({ id, source: transferable.buffer }, [transferable.buffer]); + }); + } + + /** Stops the shared worker and rejects requests that have not completed. */ + dispose() { + this.worker.removeEventListener('message', this.handleMessage); + this.worker.removeEventListener('error', this.handleError); + this.worker.terminate(); + this.rejectPending(new Error('The SVG import worker was stopped.')); + } + + private readonly handleMessage = (event: MessageEvent) => { + const request = this.pending.get(event.data.id); + if (!request) return; + this.pending.delete(event.data.id); + if ('error' in event.data) { + request.reject(new Error(event.data.error)); + return; + } + const response = event.data.response; + if (response.status === 'error') { + request.reject(new SvgImportWorkerError(response.error.code, response.error.message)); + return; + } + request.resolve({ ...response.import, omitted_image_count: response.omitted_image_count }); + }; + + private readonly handleError = (event: ErrorEvent) => { + this.rejectPending(new Error(event.message || 'The SVG import worker failed.')); + }; + + private rejectPending(error: Error) { + for (const request of this.pending.values()) request.reject(error); + this.pending.clear(); + } +} + +type WorkerResponse = { id: number; response: SvgImportResponse } | { id: number; error: string }; + +let sharedClient: SvgImportWorkerClient | null = null; + +/** Returns the one SVG worker shared by browser file, drop, and markup imports. */ +export function getSharedSvgImportWorker(): SvgImportWorkerClient { + if (typeof Worker === 'undefined') + throw new Error('SVG import workers are unavailable in this environment.'); + sharedClient ??= new SvgImportWorkerClient( + new Worker(new URL('./svg-import.worker.ts', import.meta.url), { + type: 'module', + name: 'inkfinite-svg-import' + }) + ); + return sharedClient; +} + +/** Allows browser tests and hot reload teardown to release the shared worker. */ +export function resetSharedSvgImportWorker() { + sharedClient?.dispose(); + sharedClient = null; +} + +/** Imports SVG bytes through the shared worker. */ +export function importSvgInWorker(source: Uint8Array) { + return getSharedSvgImportWorker().import(source); +} + +/** Used by the worker entry point to run the generated Rust binding. */ +export async function importSvgInWorkerRuntime(source: Uint8Array) { + return importSvgWasm(source); +} diff --git a/apps/web/src/lib/persistence/svg-import.worker.ts b/apps/web/src/lib/persistence/svg-import.worker.ts new file mode 100644 index 0000000..3d699b9 --- /dev/null +++ b/apps/web/src/lib/persistence/svg-import.worker.ts @@ -0,0 +1,20 @@ +import { importSvgInWorkerRuntime } from './svg-import'; + +type Request = { id: number; source: ArrayBuffer }; +type WorkerScope = { + onmessage: ((event: MessageEvent) => void) | null; + postMessage(message: unknown): void; +}; + +const scope = globalThis as unknown as WorkerScope; +scope.onmessage = async (event) => { + try { + const response = await importSvgInWorkerRuntime(new Uint8Array(event.data.source)); + scope.postMessage({ id: event.data.id, response }); + } catch (error) { + scope.postMessage({ + id: event.data.id, + error: error instanceof Error ? error.message : String(error) + }); + } +}; diff --git a/crates/inkfinite-cli/Cargo.toml b/crates/inkfinite-cli/Cargo.toml index 487c1f3..263beaa 100644 --- a/crates/inkfinite-cli/Cargo.toml +++ b/crates/inkfinite-cli/Cargo.toml @@ -22,7 +22,7 @@ resvg.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true -tokio.workspace = true +tokio = { workspace = true, features = ["rt-multi-thread"] } ts-rs.workspace = true [lints] diff --git a/crates/inkfinite-cli/src/bin/generate-bindings.rs b/crates/inkfinite-cli/src/bin/generate-bindings.rs index 10d4102..27d7d55 100644 --- a/crates/inkfinite-cli/src/bin/generate-bindings.rs +++ b/crates/inkfinite-cli/src/bin/generate-bindings.rs @@ -8,6 +8,7 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use inkfinite_core::proto::*; +use inkfinite_core::svg_import::*; use inkfinite_core::*; use schemars::JsonSchema; use serde_json::{Value, json}; @@ -140,6 +141,10 @@ fn artifacts() -> Result, Box> { ); artifacts.insert(PathBuf::from("packages/bindings/src/protocol.ts"), protocol_bindings()); artifacts.insert(PathBuf::from("packages/bindings/src/registry.ts"), registry_bindings()); + artifacts.insert( + PathBuf::from("packages/bindings/src/svg-import.ts"), + svg_import_bindings(), + ); artifacts.insert(PathBuf::from("packages/bindings/src/index.ts"), index_bindings()); Ok(artifacts) } @@ -272,9 +277,26 @@ fn registry_bindings() -> String { ) } +fn svg_import_bindings() -> String { + let config = ts_config(); + let mut output = GENERATED_TS_HEADER.to_owned(); + output.push_str("import type { AssetId, JsonValue, ShapeKind, ShapeStyle, Transform } from './model.js';\n\n"); + append_declaration::(&mut output, &config); + append_declaration::(&mut output, &config); + append_clean_declaration::(&mut output, &config); + append_declaration::(&mut output, &config); + append_declaration::(&mut output, &config); + append_declaration::(&mut output, &config); + append_declaration::(&mut output, &config); + append_declaration::(&mut output, &config); + append_clean_declaration::(&mut output, &config); + append_declaration::(&mut output, &config); + output +} + fn index_bindings() -> String { format!( - "{GENERATED_TS_HEADER}export * from './model.js';\nexport * from './protocol.js';\nexport * from './registry.js';\nexport * from './transaction.js';\n" + "{GENERATED_TS_HEADER}export * from './model.js';\nexport * from './protocol.js';\nexport * from './registry.js';\nexport * from './svg-import.js';\nexport * from './transaction.js';\n" ) } diff --git a/crates/inkfinite-core/Cargo.toml b/crates/inkfinite-core/Cargo.toml index fc6a131..86a3b50 100644 --- a/crates/inkfinite-core/Cargo.toml +++ b/crates/inkfinite-core/Cargo.toml @@ -21,6 +21,9 @@ tokio.workspace = true ts-rs.workspace = true thiserror.workspace = true +[target.'cfg(any(unix, windows))'.dependencies] +tokio = { workspace = true, features = ["net"] } + [dev-dependencies] proptest = "1.7" diff --git a/crates/inkfinite-core/src/ipc/mod.rs b/crates/inkfinite-core/src/ipc/mod.rs index dcf5612..53de34b 100644 --- a/crates/inkfinite-core/src/ipc/mod.rs +++ b/crates/inkfinite-core/src/ipc/mod.rs @@ -340,6 +340,10 @@ pub fn endpoint_name() -> String { { format!(r"\\.\pipe\inkfinite-{}", user_component()) } + #[cfg(not(any(unix, windows)))] + { + format!("inkfinite-{}", user_component()) + } } /// Creates and protects the per-user IPC directory. @@ -717,6 +721,7 @@ pub fn session_protocol_error(error: &SessionError) -> ProtocolError { /// # Errors /// /// Returns discovery, connection, framing, or response-correlation failures. +#[cfg(any(unix, windows))] pub async fn send(request: AppRequest) -> Result { let discovery = read_discovery(&discovery_path())?; if discovery.endpoint != endpoint_name() { @@ -741,6 +746,13 @@ pub async fn send(request: AppRequest) -> Result { Ok(response) } +#[cfg(not(any(unix, windows)))] +pub async fn send(_request: AppRequest) -> Result { + Err(IpcError::Unavailable( + "local IPC is not available on this target".into(), + )) +} + #[cfg(unix)] async fn send_to_endpoint(endpoint: &str, request: &RequestEnvelope) -> Result { let mut stream = tokio::net::UnixStream::connect(endpoint).await?; diff --git a/crates/inkfinite-core/src/svg_import.rs b/crates/inkfinite-core/src/svg_import.rs index 8ec0e01..0e3e6d7 100644 --- a/crates/inkfinite-core/src/svg_import.rs +++ b/crates/inkfinite-core/src/svg_import.rs @@ -13,10 +13,12 @@ use std::str::FromStr; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; use roxmltree::{Document as XmlDocument, Node}; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use svgtypes::{PathParser, PathSegment}; use thiserror::Error; +use ts_rs::TS; use crate::engine::geometry::{Affine, path_bounds, union}; use crate::proto::Bounds; @@ -36,7 +38,9 @@ enum PreviousSegment { } /// An unsupported SVG feature identified during static import. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] pub enum SvgUnsupportedFeature { /// A linear, radial, or mesh gradient. Gradient, @@ -59,7 +63,9 @@ pub enum SvgUnsupportedFeature { } /// Action taken for unsupported SVG content during static import. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] pub enum SvgUnsupportedAction { /// Leave the content out of the normalized native tree. Omitted, @@ -90,7 +96,9 @@ impl fmt::Display for SvgUnsupportedAction { } /// A non-fatal condition encountered while importing an SVG. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] +#[serde(tag = "kind", rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] pub enum SvgImportWarning { /// An SVG element was skipped because it has no native mapping in this slice. UnsupportedElement { @@ -208,7 +216,7 @@ enum Axis { } /// A view box declared by the source SVG. -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize, TS)] pub struct SvgViewBox { /// Horizontal origin in SVG user units. pub x: f64, @@ -221,7 +229,7 @@ pub struct SvgViewBox { } /// A parsed SVG asset, either the original source or embedded raster data. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] pub struct SvgAsset { /// Deterministic asset identifier derived from the bytes. pub id: AssetId, @@ -252,7 +260,7 @@ impl SvgAsset { } /// One native shape produced by SVG mapping. -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] pub struct SvgShape { /// The source element's `id`, when present. pub source_id: Option, @@ -267,7 +275,7 @@ pub struct SvgShape { } /// A parsed group mapped to a native container candidate. -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] pub struct SvgGroup { /// The source group or root element's `id`, when present. pub source_id: Option, @@ -282,7 +290,7 @@ pub struct SvgGroup { } /// An image node referencing an extracted embedded raster asset. -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] pub struct SvgImage { /// The source image element's `id`, when present. pub source_id: Option, @@ -297,7 +305,9 @@ pub struct SvgImage { } /// A normalized SVG node. -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[serde(tag = "kind", content = "value", rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] pub enum SvgImportNode { /// A group mapped to a container candidate. Group(Box), @@ -320,7 +330,7 @@ impl SvgImportNode { } /// A normalized SVG import result. -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] pub struct SvgImport { /// Root view box, when the source declared one. pub view_box: Option, @@ -861,8 +871,11 @@ pub fn parse_svg(source: &str) -> Result { /// /// Returns [`SvgImportError`] when [`parse_svg`] rejects the source. pub fn import_svg(source: impl AsRef<[u8]>) -> Result { - let source = - std::str::from_utf8(source.as_ref()).map_err(|error| SvgImportError::InvalidUtf8(error.to_string()))?; + let bytes = source.as_ref(); + if bytes.len() > SVG_IMPORT_MAX_BYTES { + return Err(SvgImportError::InputTooLarge { limit: SVG_IMPORT_MAX_BYTES }); + } + let source = std::str::from_utf8(bytes).map_err(|error| SvgImportError::InvalidUtf8(error.to_string()))?; parse_svg(source) } diff --git a/crates/inkfinite-core/tests/svg_import_fixtures.rs b/crates/inkfinite-core/tests/svg_import_fixtures.rs index 4af3fff..d1553c8 100644 --- a/crates/inkfinite-core/tests/svg_import_fixtures.rs +++ b/crates/inkfinite-core/tests/svg_import_fixtures.rs @@ -2,6 +2,12 @@ use inkfinite_core::svg_import::{SvgImportError, SvgImportNode, SvgUnsupportedFe use inkfinite_core::{PathFillRule, path_geometry_from_properties}; const VALID_FIXTURES: &[(&str, &str, usize, usize)] = &[ + ( + "Bootstrap filetype SVG icon", + include_str!("../../../fixtures/svg-import/icons/bootstrap-filetype-svg.svg"), + 0, + 1, + ), ( "Catppuccin Android icon", include_str!("../../../fixtures/svg-import/icons/catppuccin-android.svg"), @@ -120,6 +126,28 @@ fn current_color_from_icon_sets_resolves_to_the_inherited_svg_color() { assert_eq!(shape.properties["stroke"], "#ed8796"); } +#[test] +fn bootstrap_filetype_svg_preserves_browser_regression_semantics() { + let import = parse_svg(include_str!( + "../../../fixtures/svg-import/icons/bootstrap-filetype-svg.svg" + )) + .expect("Bootstrap fixture should import"); + let SvgImportNode::Shape(shape) = &import.root.children[0] else { + panic!("Bootstrap path missing") + }; + let geometry = path_geometry_from_properties(&shape.properties).expect("Bootstrap path should validate"); + assert_eq!(shape.properties["fill"], "#000000"); + assert_eq!(geometry.fill_rule, PathFillRule::EvenOdd); + assert!(geometry.subpaths.len() >= 2); + assert!( + geometry + .subpaths + .iter() + .flat_map(|subpath| &subpath.segments) + .any(|segment| { matches!(segment, inkfinite_core::PathSegment::Quadratic { .. }) }) + ); +} + #[test] fn nested_and_compound_fixtures_preserve_geometry_semantics() { let nested = parse_svg(include_str!( diff --git a/crates/inkfinite-wasm/Cargo.toml b/crates/inkfinite-wasm/Cargo.toml new file mode 100644 index 0000000..7860105 --- /dev/null +++ b/crates/inkfinite-wasm/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "inkfinite-wasm" +description = "Browser adapter for Inkfinite's Rust-owned SVG importer" +edition.workspace = true +license.workspace = true +rust-version.workspace = true +version.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +inkfinite-core.workspace = true +serde.workspace = true +serde_json.workspace = true +wasm-bindgen.workspace = true + +[lints] +workspace = true diff --git a/crates/inkfinite-wasm/src/lib.rs b/crates/inkfinite-wasm/src/lib.rs new file mode 100644 index 0000000..37bd1a1 --- /dev/null +++ b/crates/inkfinite-wasm/src/lib.rs @@ -0,0 +1,124 @@ +//! Browser-facing bindings for the Rust-owned SVG import contract. +//! +//! The worker calls [`import_svg`] with transferred UTF-8 bytes. The function +//! always returns a JSON envelope so parse failures retain their structured +//! error code and message across the WebAssembly boundary. + +use inkfinite_core::svg_import::{SvgImport, SvgImportError, SvgImportNode, import_svg as parse_svg}; +use serde::Serialize; +use wasm_bindgen::prelude::*; + +/// The result envelope exchanged between the SVG worker and the browser. +#[derive(Debug, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum SvgImportResponse { + /// A normalized import and the image nodes that the current browser model omits. + Success { + /// The normalized Rust import tree. + import: Box, + /// Number of embedded image nodes in the tree. + omitted_image_count: usize, + }, + /// A structured failure that did not mutate a document. + Error { + /// Import failure details. + error: SvgImportFailure, + }, +} + +/// A stable error crossing the WASM boundary. +#[derive(Debug, Serialize)] +pub struct SvgImportFailure { + /// Machine-readable failure category. + pub code: &'static str, + /// Human-readable failure detail. + pub message: String, +} + +/// Imports UTF-8 SVG bytes and returns the serialized response envelope. +#[must_use] +pub fn import_svg_json(source: &[u8]) -> String { + let response = match parse_svg(source) { + Ok(import) => { + SvgImportResponse::Success { omitted_image_count: count_images(&import.root), import: Box::new(import) } + } + Err(error) => SvgImportResponse::Error { error: failure(&error) }, + }; + + match serde_json::to_string(&response) { + Ok(serialized) => serialized, + Err(error) => format!( + r#"{{"status":"error","error":{{"code":"serialization","message":"{}"}}}}"#, + escape_json_string(&error.to_string()) + ), + } +} + +/// Imports transferred UTF-8 SVG bytes from JavaScript. +#[wasm_bindgen] +pub fn import_svg(source: &[u8]) -> String { + import_svg_json(source) +} + +fn count_images(group: &inkfinite_core::svg_import::SvgGroup) -> usize { + group + .children + .iter() + .map(|node| match node { + SvgImportNode::Group(child) => count_images(child), + SvgImportNode::Image(_) => 1, + SvgImportNode::Shape(_) => 0, + }) + .sum() +} + +fn failure(error: &SvgImportError) -> SvgImportFailure { + let code = match error { + SvgImportError::InputTooLarge { .. } => "input_too_large", + SvgImportError::InvalidUtf8(_) => "invalid_utf8", + SvgImportError::Xml(_) => "invalid_xml", + SvgImportError::MissingRoot => "missing_root", + SvgImportError::InvalidAttribute { .. } => "invalid_attribute", + SvgImportError::InvalidPath { .. } => "invalid_path", + SvgImportError::UnsupportedTransform { .. } => "unsupported_transform", + SvgImportError::InvalidImage { .. } => "invalid_image", + }; + SvgImportFailure { code, message: error.to_string() } +} + +fn escape_json_string(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") + .replace('\r', "\\r") + .replace('\t', "\\t") +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + #[test] + fn returns_normalized_success_and_image_count() { + let response: Value = + serde_json::from_str(&import_svg_json(br#""#)) + .expect("response should be JSON"); + assert_eq!(response["status"], "success"); + assert_eq!(response["omitted_image_count"], 0); + assert_eq!(response["import"]["root"]["children"][0]["kind"], "group"); + } + + #[test] + fn returns_structured_errors() { + let response: Value = serde_json::from_str(&import_svg_json(b" + + diff --git a/package.json b/package.json index c5e8a72..44680ac 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "bindings:check": "cargo run -p inkfinite-cli --bin generate-bindings -- --check", "bindings:format": "pnpm --filter @inkfinite/bindings format", "bindings:format:check": "pnpm --filter @inkfinite/bindings format:check", - "bindings:test": "pnpm --filter @inkfinite/bindings test" + "bindings:test": "pnpm --filter @inkfinite/bindings test", + "wasm:build": "pnpm --filter @inkfinite/wasm build" }, "devDependencies": { "prettier": "^3.7.4", diff --git a/packages/bindings/package.json b/packages/bindings/package.json index 7526185..c093a34 100644 --- a/packages/bindings/package.json +++ b/packages/bindings/package.json @@ -3,7 +3,11 @@ "version": "0.0.0", "private": true, "type": "module", - "exports": "./dist/index.js", + "exports": { + ".": "./dist/index.js", + "./model": "./src/model.ts", + "./svg-import": "./src/svg-import.ts" + }, "types": "./dist/index.d.ts", "scripts": { "build": "tsc -p tsconfig.json", diff --git a/packages/bindings/src/index.ts b/packages/bindings/src/index.ts index 09e843b..d2a49dc 100644 --- a/packages/bindings/src/index.ts +++ b/packages/bindings/src/index.ts @@ -4,4 +4,5 @@ export * from './model.js'; export * from './protocol.js'; export * from './registry.js'; +export * from './svg-import.js'; export * from './transaction.js'; diff --git a/packages/bindings/src/svg-import.ts b/packages/bindings/src/svg-import.ts new file mode 100644 index 0000000..288092a --- /dev/null +++ b/packages/bindings/src/svg-import.ts @@ -0,0 +1,237 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +// Generated by Inkfinite's binding generator. Run `cargo run -p inkfinite-cli --bin generate-bindings`. + +import type { AssetId, JsonValue, ShapeKind, ShapeStyle, Transform } from './model.js'; + +/** + * An unsupported SVG feature identified during static import. + */ +export type SvgUnsupportedFeature = + | 'gradient' + | 'pattern' + | 'clip_path' + | 'mask' + | 'filter' + | 'script' + | 'animation' + | 'external_resource' + | 'stylesheet'; + +/** + * Action taken for unsupported SVG content during static import. + */ +export type SvgUnsupportedAction = 'omitted'; + +/** + * A non-fatal condition encountered while importing an SVG. + */ +export type SvgImportWarning = + | { + kind: 'unsupported_element'; + /** + * Element name. + */ + element: string; + /** + * Source element ID, when present. + */ + source_id: string | null; + /** + * Reason for skipping it. + */ + reason: string; + } + | { + kind: 'unsupported_feature'; + /** + * Unsupported SVG feature. + */ + feature: SvgUnsupportedFeature; + /** + * Element name. + */ + element: string; + /** + * Source element ID, when present. + */ + source_id: string | null; + /** + * Action taken by the importer. + */ + action: SvgUnsupportedAction; + } + | { + kind: 'unsupported_paint'; + /** + * Element name. + */ + element: string; + /** + * Paint property name. + */ + property: string; + /** + * Original paint value. + */ + value: string; + }; + +/** + * A view box declared by the source SVG. + */ +export type SvgViewBox = { + /** + * Horizontal origin in SVG user units. + */ + x: number; + /** + * Vertical origin in SVG user units. + */ + y: number; + /** + * Width in SVG user units. + */ + width: number; + /** + * Height in SVG user units. + */ + height: number; +}; + +/** + * A parsed SVG asset, either the original source or embedded raster data. + */ +export type SvgAsset = { + /** + * Deterministic asset identifier derived from the bytes. + */ + id: AssetId; + /** + * Suggested source filename for the asset. + */ + name: string; + /** + * IANA media type, such as `image/png`. + */ + media_type: string; + /** + * Content digest including its algorithm prefix. + */ + digest: string; + /** + * Embedded asset bytes. + */ + bytes: Array; +}; + +/** + * One native shape produced by SVG mapping. + */ +export type SvgShape = { + /** + * The source element's `id`, when present. + */ + source_id: string | null; + /** + * Native Inkfinite shape kind. + */ + kind: ShapeKind; + /** + * Transform relative to the containing SVG group. + */ + transform: Transform; + /** + * Kind-specific native properties. + */ + properties: { [key in string]: JsonValue }; + /** + * Common native opacity values. + */ + style: ShapeStyle; +}; + +/** + * A parsed group mapped to a native container candidate. + */ +export type SvgGroup = { + /** + * The source group or root element's `id`, when present. + */ + source_id: string | null; + /** + * Transform relative to the containing SVG group. + */ + transform: Transform; + /** + * Container opacity values. + */ + style: ShapeStyle; + /** + * Native container properties, including calculated width and height. + */ + properties: { [key in string]: JsonValue }; + /** + * Ordered child nodes in SVG paint order. + */ + children: Array; +}; + +/** + * An image node referencing an extracted embedded raster asset. + */ +export type SvgImage = { + /** + * The source image element's `id`, when present. + */ + source_id: string | null; + /** + * Extracted embedded asset. + */ + asset_id: AssetId; + /** + * Transform relative to the containing SVG group. + */ + transform: Transform; + /** + * Image properties, including width and height. + */ + properties: { [key in string]: JsonValue }; + /** + * Image opacity values. + */ + style: ShapeStyle; +}; + +/** + * A normalized SVG node. + */ +export type SvgImportNode = + | { kind: 'group'; value: SvgGroup } + | { kind: 'shape'; value: SvgShape } + | { kind: 'image'; value: SvgImage }; + +/** + * A normalized SVG import result. + */ +export type SvgImport = { + /** + * Root view box, when the source declared one. + */ + view_box: SvgViewBox | null; + /** + * Root group containing the source SVG's visual children. + */ + root: SvgGroup; + /** + * The original source retained for provenance and future fallback or re-import. + */ + source_asset: SvgAsset; + /** + * Embedded raster assets referenced by image nodes. + */ + assets: Array; + /** + * Non-fatal features skipped during parsing. + */ + warnings: Array; +}; diff --git a/packages/core/package.json b/packages/core/package.json index be90b9b..1cc7aa9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -44,6 +44,7 @@ "vitest": "^4.0.16" }, "dependencies": { + "@inkfinite/bindings": "workspace:*", "perfect-freehand": "^1.2.2", "rxjs": "^7.8.2", "uuid": "^13.0.0" diff --git a/packages/core/src/interchange.ts b/packages/core/src/interchange.ts index 4c62c2e..8799115 100644 --- a/packages/core/src/interchange.ts +++ b/packages/core/src/interchange.ts @@ -1,7 +1,9 @@ import type { BoardExport } from './persistence/document'; import { exportExcalidraw, importExcalidraw } from './interchange/excalidraw'; import { exportJsonCanvas, importJsonCanvas } from './interchange/json-canvas'; -import { importSvg } from './interchange/svg'; +import { projectSvgImport, type SvgImportResult } from './interchange/svg'; +export { projectSvgImport } from './interchange/svg'; +export type { SvgImportResult } from './interchange/svg'; import { object } from './interchange/shared'; /** External editable document formats supported by Inkfinite. */ @@ -31,13 +33,20 @@ export type InterchangeExport = { const MAX_IMPORT_BYTES = 16 * 1024 * 1024; -/** Detects and imports a supported editable canvas document. */ +/** Returns whether a filename or text prefix identifies an SVG source. */ +export function isSvgImport(contents: string | Uint8Array, fileName: string): boolean { + if (fileName.toLowerCase().endsWith('.svg')) return true; + if (typeof contents !== 'string') return false; + return contents.trimStart().startsWith(' MAX_IMPORT_BYTES) { + if (byteLength(contents) > MAX_IMPORT_BYTES) { throw new Error('The selected file is larger than the 16 MB import limit.'); } - if (fileName.toLowerCase().endsWith('.svg') || contents.trimStart().startsWith(' Promise +): Promise { + if (byteLength(contents) > MAX_IMPORT_BYTES) { + throw new Error('The selected file is larger than the 16 MB import limit.'); + } + if (isSvgImport(contents, fileName)) { + const source = typeof contents === 'string' ? new TextEncoder().encode(contents) : contents; + const response = await importSvg(source); + return projectSvgImport(response, fileName); + } + if (typeof contents !== 'string') { + throw new Error('Non-SVG interchange imports require text content.'); + } + return importInterchange(contents, fileName); +} + +function byteLength(contents: string | Uint8Array) { + return typeof contents === 'string' ? new TextEncoder().encode(contents).byteLength : contents.byteLength; +} + /** Exports one Inkfinite page to a supported editable canvas format. */ export function exportInterchange( snapshot: BoardExport, diff --git a/packages/core/src/interchange/svg.ts b/packages/core/src/interchange/svg.ts index 23c9a29..ada8fbe 100644 --- a/packages/core/src/interchange/svg.ts +++ b/packages/core/src/interchange/svg.ts @@ -1,330 +1,295 @@ -import { ShapeRecord, type Document, type PathSegment, type PathSubpath, type ShapeRecord as Shape } from '../model'; +import type { SvgAsset, SvgGroup, SvgImport, SvgImportWarning, SvgShape } from '@inkfinite/bindings/svg-import'; +import type { Transform } from '@inkfinite/bindings/model'; +import { + ShapeRecord, + type Document, + type ImportedGroup, + type PathGeometry, + type PathSegment, + type ShapeRecord as Shape +} from '../model'; import type { InterchangeImport } from '../interchange'; import { addShape, blankSnapshot, WarningCollector } from './shared'; +/** A source accepted by the browser WASM importer. */ +export type SvgImportSource = string | Uint8Array; + +/** Imports the normalized result produced by Rust into the browser document model. */ +export function projectSvgImport(imported: SvgImportResult, fileName: string): InterchangeImport { + const { snapshot, pageId, layerId } = blankSnapshot(fileName); + const warnings = new WarningCollector(); + const assets = [imported.source_asset, ...imported.assets]; + snapshot.doc.assets = Object.fromEntries(assets.map((asset) => [asset.id, assetRecord(asset)])); + + const context: ProjectionContext = { + pageId, + layerId, + document: snapshot.doc, + warnings, + ids: new Set(), + shapeIndex: 0, + groupIndex: 0, + groups: {} + }; + walkGroup(imported.root, IDENTITY, '', 1, context); + snapshot.doc.svgGroups = context.groups; + if (imported.omitted_image_count > 0) { + warnings.add( + 'svg-images-omitted', + 'Embedded image nodes were retained as assets but omitted because image shapes are not available yet.', + imported.omitted_image_count + ); + } + for (const warning of imported.warnings) addSvgWarning(warnings, warning); + + return { format: 'svg', snapshot, warnings: warnings.values() }; +} + +/** Normalized SVG result supplied by the generated Rust/WASM bindings. */ +export type SvgImportResult = SvgImport & { omitted_image_count: number }; + type Matrix = { a: number; b: number; c: number; d: number; e: number; f: number }; type Point = { x: number; y: number }; -type SvgStyle = { - fill: string; - stroke: string; - strokeWidth: number; - fillRule: 'nonzero' | 'evenodd'; - opacity: number; - fillOpacity: number; - strokeOpacity: number; - fontSize: number; - fontFamily: string; -}; - -type SvgContext = { +type ProjectionContext = { pageId: string; layerId: string; document: Document; warnings: WarningCollector; ids: Set; shapeIndex: number; + groupIndex: number; + groups: Record; }; const IDENTITY: Matrix = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }; -const DEFAULT_STYLE: SvgStyle = { - fill: '#000000', - stroke: 'none', - strokeWidth: 1, - fillRule: 'nonzero', - opacity: 1, - fillOpacity: 1, - strokeOpacity: 1, - fontSize: 16, - fontFamily: 'sans-serif' -}; - -/** Imports the supported static SVG subset into the browser document model. */ -export function importSvg(root: string, fileName: string): InterchangeImport { - if (new TextEncoder().encode(root).byteLength > 16 * 1024 * 1024) { - throw new Error('The selected file is larger than the 16 MB import limit.'); - } - if (typeof DOMParser === 'undefined') throw new Error('SVG import is only available in a browser.'); - const xml = new DOMParser().parseFromString(root, 'image/svg+xml'); - if (xml.querySelector('parsererror')) throw new Error('The selected SVG is malformed.'); - const svg = xml.documentElement; - if (svg.localName !== 'svg') throw new Error('The selected file does not have an root.'); - const { snapshot, pageId, layerId } = blankSnapshot(fileName); - const warnings = new WarningCollector(); - warnings.add('svg-source-asset', 'The browser document model does not retain the original SVG source asset.'); - const context: SvgContext = { pageId, layerId, document: snapshot.doc, warnings, ids: new Set(), shapeIndex: 0 }; - walk(svg, IDENTITY, DEFAULT_STYLE, context, true); - return { format: 'svg', snapshot, warnings: warnings.values() }; -} - -function walk(element: Element, parentMatrix: Matrix, parentStyle: SvgStyle, context: SvgContext, isRoot = false) { - const tag = element.localName; - const style = readStyle(element, parentStyle); - let matrix: Matrix; - try { - matrix = multiply(parentMatrix, parseTransform(element.getAttribute('transform'))); - } catch (error) { - context.warnings.add('svg-transform', `A transform on <${tag}> was skipped: ${String(error)}.`); - matrix = parentMatrix; - } - - if (!isRoot && ['script', 'style', 'animate', 'animateMotion', 'animateTransform', 'set'].includes(tag)) { - context.warnings.add('svg-active-content', `The <${tag}> element was omitted.`); - return; - } - if ( - !isRoot && - ['defs', 'linearGradient', 'radialGradient', 'pattern', 'clipPath', 'mask', 'filter'].includes(tag) - ) { - context.warnings.add('svg-unsupported-feature', `The <${tag}> definition was omitted.`); - return; - } - if (!isRoot && ['g', 'svg'].includes(tag)) { - if (tag === 'g') - context.warnings.add('svg-group-flattened', 'SVG groups were flattened into native shape coordinates.'); - for (const child of Array.from(element.children)) walk(child, matrix, style, context); - return; - } - if (!isRoot) { - try { - const shape = shapeFromElement(element, matrix, style, context); - if (shape) addShape(context.document, context.pageId, context.layerId, shape); - } catch (error) { - context.warnings.add('svg-element', `The <${tag}> element was omitted: ${String(error)}.`); +function walkGroup( + group: SvgGroup, + parentMatrix: Matrix, + groupPath: string, + parentOpacity: number, + context: ProjectionContext +) { + const matrix = multiply(parentMatrix, transformMatrix(group.transform)); + const opacity = parentOpacity * group.style.opacity; + const groupId = groupPath ? `svg:group:${groupPath}` : 'svg:group:root'; + const parentId = groupPath + ? groupPath.includes('/') + ? `svg:group:${groupPath.slice(0, groupPath.lastIndexOf('/'))}` + : 'svg:group:root' + : undefined; + context.groups[groupId] = { + id: groupId, + ...(group.source_id ? { sourceId: group.source_id } : {}), + ...(parentId && parentId !== groupId ? { parentId } : {}), + transform: group.transform, + style: group.style, + properties: group.properties + }; + for (const [index, node] of group.children.entries()) { + if (node.kind === 'group') { + const childName = node.value.source_id?.trim() || `group-${context.groupIndex++}`; + walkGroup(node.value, matrix, groupPath ? `${groupPath}/${childName}` : childName, opacity, context); + continue; } - } - if (tag === 'svg' || tag === 'g') { - for (const child of Array.from(element.children)) walk(child, matrix, style, context); + if (node.kind === 'image') continue; + const shape = projectShape(node.value, matrix, opacity, groupPath, context, index); + if (shape) addShape(context.document, context.pageId, context.layerId, shape); } } -function shapeFromElement(element: Element, matrix: Matrix, style: SvgStyle, context: SvgContext): Shape | null { - const tag = element.localName; - const id = nextId(element.getAttribute('id'), context); - const transform = decompose(matrix); - const opacity = style.opacity; - const fillOpacity = style.fillOpacity * opacity; - const strokeOpacity = style.strokeOpacity * opacity; +function projectShape( + source: SvgShape, + parentMatrix: Matrix, + parentOpacity: number, + groupPath: string, + context: ProjectionContext, + childIndex: number +): Shape | null { + const matrix = multiply(parentMatrix, transformMatrix(source.transform)); + const id = nextId(source.source_id, context); + const fillOpacity = source.style.fill_opacity ?? 1; + const strokeOpacity = source.style.stroke_opacity ?? 1; + const opacity = parentOpacity * source.style.opacity; + const groupId = groupPath ? `svg:group:${groupPath}` : undefined; + const properties = source.properties; + const kind = source.kind; - switch (tag) { + switch (kind) { case 'rect': { - const width = nonNegativeNumber(element, 'width', 0); - const height = nonNegativeNumber(element, 'height', 0); - const rectTransform = decompose( - multiply(matrix, translation(number(element, 'x', 0), number(element, 'y', 0))) - ); - const props = { - w: width * Math.abs(rectTransform.scaleX), - h: height * Math.abs(rectTransform.scaleY), - fill: style.fill, - stroke: style.stroke, - radius: Math.max(0, number(element, 'rx', number(element, 'ry', 0))) - }; - return withStyle( - ShapeRecord.createRect(context.pageId, rectTransform.x, rectTransform.y, props, id), - opacity, - fillOpacity, - strokeOpacity, - rectTransform.rotation + const transform = decompose(matrix); + const shape = ShapeRecord.createRect( + context.pageId, + transform.e, + transform.f, + { + w: numberProperty(properties, 'width'), + h: numberProperty(properties, 'height'), + fill: paintProperty(properties, 'fill'), + stroke: paintProperty(properties, 'stroke'), + radius: numberProperty(properties, 'radius') + }, + id ); + shape.rot = transform.rotation; + shape.props.w *= Math.abs(transform.scaleX); + shape.props.h *= Math.abs(transform.scaleY); + return styled(shape, opacity, fillOpacity, strokeOpacity, groupId); } - case 'circle': case 'ellipse': { - const rx = tag === 'circle' ? nonNegativeNumber(element, 'r', 0) : nonNegativeNumber(element, 'rx', 0); - const ry = tag === 'circle' ? rx : nonNegativeNumber(element, 'ry', 0); - const ellipseTransform = decompose( - multiply(matrix, translation(number(element, 'cx', 0) - rx, number(element, 'cy', 0) - ry)) - ); - const props = { - w: rx * 2 * Math.abs(ellipseTransform.scaleX), - h: ry * 2 * Math.abs(ellipseTransform.scaleY), - fill: style.fill, - stroke: style.stroke - }; - return withStyle( - ShapeRecord.createEllipse(context.pageId, ellipseTransform.x, ellipseTransform.y, props, id), - opacity, - fillOpacity, - strokeOpacity, - ellipseTransform.rotation + const transform = decompose(matrix); + const shape = ShapeRecord.createEllipse( + context.pageId, + transform.e, + transform.f, + { + w: numberProperty(properties, 'width'), + h: numberProperty(properties, 'height'), + fill: paintProperty(properties, 'fill'), + stroke: paintProperty(properties, 'stroke') + }, + id ); + shape.rot = transform.rotation; + shape.props.w *= Math.abs(transform.scaleX); + shape.props.h *= Math.abs(transform.scaleY); + return styled(shape, opacity, fillOpacity, strokeOpacity, groupId); } case 'line': { - const start = apply(matrix, { x: number(element, 'x1', 0), y: number(element, 'y1', 0) }); - const end = apply(matrix, { x: number(element, 'x2', 0), y: number(element, 'y2', 0) }); - const props = { - a: { x: 0, y: 0 }, - b: { x: end.x - start.x, y: end.y - start.y }, - stroke: style.stroke, - width: style.strokeWidth - }; - return withStyle( - ShapeRecord.createLine(context.pageId, start.x, start.y, props, id), - opacity, - 1, - strokeOpacity, - 0 - ); - } - case 'polygon': - case 'polyline': { - const points = parsePoints(element.getAttribute('points') ?? '').map((point) => apply(matrix, point)); - if (points.length < (tag === 'polygon' ? 3 : 2)) throw new Error('the points attribute has too few points'); - const segments: PathSegment[] = [ - { type: 'move', to: points[0] }, - ...points.slice(1).map((to) => ({ type: 'line', to }) satisfies PathSegment) - ]; - return withStyle( - ShapeRecord.createPath( - context.pageId, - 0, - 0, - { - subpaths: [{ segments, closed: tag === 'polygon' }], - fill_rule: style.fillRule, - fill: style.fill, - stroke: style.stroke, - stroke_width: style.strokeWidth - }, - id - ), - opacity, - fillOpacity, - strokeOpacity, - 0 + const start = apply(matrix, pointProperty(properties, 'a')); + const end = apply(matrix, pointProperty(properties, 'b')); + const shape = ShapeRecord.createLine( + context.pageId, + start.x, + start.y, + { + a: { x: 0, y: 0 }, + b: { x: end.x - start.x, y: end.y - start.y }, + stroke: paintProperty(properties, 'stroke'), + width: numberProperty(properties, 'width') + }, + id ); + return styled(shape, opacity, 1, strokeOpacity, groupId); } case 'path': { - const geometry = parsePath(element.getAttribute('d') ?? '').map((subpath) => ({ - ...subpath, - segments: subpath.segments.map((segment) => transformSegment(segment, matrix)) - })); - if (!geometry.length) throw new Error('the path has no segments'); - return withStyle( - ShapeRecord.createPath( - context.pageId, - 0, - 0, - { - subpaths: geometry, - fill_rule: style.fillRule, - fill: style.fill, - stroke: style.stroke, - stroke_width: style.strokeWidth - }, - id - ), - opacity, - fillOpacity, - strokeOpacity, - 0 + const geometry = transformGeometry(properties as PathGeometry, matrix); + const shape = ShapeRecord.createPath( + context.pageId, + 0, + 0, + { + ...geometry, + fill: paintProperty(properties, 'fill'), + stroke: paintProperty(properties, 'stroke'), + stroke_width: numberProperty(properties, 'stroke_width') + }, + id ); + return styled(shape, opacity, fillOpacity, strokeOpacity, groupId); } case 'text': { - const point = apply(matrix, { x: number(element, 'x', 0), y: number(element, 'y', 0) }); - return withStyle( - ShapeRecord.createText( - context.pageId, - point.x, - point.y, - { - text: element.textContent ?? '', - fontSize: style.fontSize, - fontFamily: style.fontFamily, - color: style.fill - }, - id - ), - opacity, - fillOpacity, - strokeOpacity, - transform.rotation + const transform = decompose(matrix); + const shape = ShapeRecord.createText( + context.pageId, + transform.e, + transform.f, + { + text: stringProperty(properties, 'text'), + fontSize: numberProperty(properties, 'font_size') * Math.abs(transform.scaleY), + fontFamily: stringProperty(properties, 'font_family'), + color: paintProperty(properties, 'color') + }, + id ); + shape.rot = transform.rotation; + return styled(shape, opacity, fillOpacity, strokeOpacity, groupId); } - case 'image': - context.warnings.add('svg-image', 'Embedded image nodes are not available in the browser document model.'); - return null; default: - context.warnings.add('svg-element', `The <${tag}> element is not supported.`); + context.warnings.add( + 'svg-unsupported-element', + `The Rust importer returned unsupported shape kind '${kind}' at child ${childIndex}.` + ); return null; } } -function withStyle( +function styled( shape: T, opacity: number, fillOpacity: number, strokeOpacity: number, - rotation: number + groupId?: string ): T { - return { ...shape, rot: rotation, opacity, fillOpacity, strokeOpacity }; -} - -function readStyle(element: Element, parent: SvgStyle): SvgStyle { - const style = { ...parent }; - const declarations = new Map(); - for (const name of [ - 'fill', - 'stroke', - 'stroke-width', - 'fill-rule', - 'opacity', - 'fill-opacity', - 'stroke-opacity', - 'font-size', - 'font-family' - ]) { - const value = element.getAttribute(name); - if (value !== null) declarations.set(name, value); - } - for (const declaration of (element.getAttribute('style') ?? '').split(';')) { - const [name, value] = declaration.split(':', 2).map((part) => part?.trim()); - if (name && value) declarations.set(name, value); - } - if (declarations.has('fill')) style.fill = paint(declarations.get('fill')!); - if (declarations.has('stroke')) style.stroke = paint(declarations.get('stroke')!); - if (declarations.has('stroke-width')) - style.strokeWidth = finite(declarations.get('stroke-width')!.replace(/px$/, ''), 'stroke-width'); - if (declarations.has('fill-rule')) - style.fillRule = declarations.get('fill-rule') === 'evenodd' ? 'evenodd' : 'nonzero'; - if (declarations.has('opacity')) style.opacity *= clampOpacity(declarations.get('opacity')!); - if (declarations.has('fill-opacity')) style.fillOpacity *= clampOpacity(declarations.get('fill-opacity')!); - if (declarations.has('stroke-opacity')) style.strokeOpacity *= clampOpacity(declarations.get('stroke-opacity')!); - if (declarations.has('font-size')) - style.fontSize = finite(declarations.get('font-size')!.replace(/px$/, ''), 'font-size'); - if (declarations.has('font-family')) style.fontFamily = declarations.get('font-family')!.split(',')[0].trim(); - return style; -} - -function paint(value: string) { - const normalized = value.trim(); - return normalized === 'none' || normalized === 'transparent' || normalized.startsWith('url(') ? 'none' : normalized; + shape.opacity = opacity; + shape.fillOpacity = fillOpacity; + shape.strokeOpacity = strokeOpacity; + shape.groupId = groupId; + return shape; } -function number(element: Element, attribute: string, fallback: number): number { - const value = element.getAttribute(attribute); - return value === null || value.trim() === '' ? fallback : finite(value.replace(/px$/, ''), attribute); +function transformGeometry(properties: PathGeometry, matrix: Matrix): PathGeometry { + return { + fill_rule: properties.fill_rule, + subpaths: properties.subpaths.map((subpath) => ({ + closed: subpath.closed, + segments: subpath.segments.map((segment) => transformSegment(segment, matrix)) + })) + }; } -function nonNegativeNumber(element: Element, attribute: string, fallback: number): number { - const value = number(element, attribute, fallback); - if (value < 0) throw new Error(`${attribute} must not be negative`); - return value; +function transformSegment(segment: PathSegment, matrix: Matrix): PathSegment { + switch (segment.type) { + case 'move': + case 'line': + return { ...segment, to: apply(matrix, segment.to) }; + case 'quadratic': + return { ...segment, control: apply(matrix, segment.control), to: apply(matrix, segment.to) }; + case 'cubic': + return { + ...segment, + control_1: apply(matrix, segment.control_1), + control_2: apply(matrix, segment.control_2), + to: apply(matrix, segment.to) + }; + } } -function finite(value: string, name: string) { - const parsed = Number(value.trim()); - if (!Number.isFinite(parsed)) throw new Error(`${name} must be a finite number`); - return parsed; +function assetRecord(asset: SvgAsset) { + return { + id: asset.id, + name: asset.name, + mediaType: asset.media_type, + digest: asset.digest, + bytes: [...asset.bytes] + }; } -function clampOpacity(value: string) { - return Math.min(1, Math.max(0, finite(value, 'opacity'))); +function addSvgWarning(warnings: WarningCollector, warning: SvgImportWarning) { + switch (warning.kind) { + case 'unsupported_element': + warnings.add( + 'svg-unsupported-element', + `Skipped SVG element <${warning.element}>${warning.source_id ? ` (${warning.source_id})` : ''}: ${warning.reason}` + ); + break; + case 'unsupported_feature': + warnings.add( + `svg-${warning.feature}`, + `Skipped SVG ${warning.feature.replaceAll('_', ' ')} on <${warning.element}>${warning.source_id ? ` (${warning.source_id})` : ''}: ${warning.action.replaceAll('_', ' ')}` + ); + break; + case 'unsupported_paint': + warnings.add( + 'svg-unsupported-paint', + `Skipped SVG ${warning.property} paint on <${warning.element}>: ${warning.value}` + ); + break; + } } -function nextId(sourceId: string | null, context: SvgContext) { - const base = sourceId?.trim() ? `svg:${sourceId.trim()}` : `svg:shape:${context.shapeIndex}`; - context.shapeIndex += 1; +function nextId(sourceId: string | null, context: ProjectionContext) { + const base = sourceId?.trim() ? `svg:${sourceId.trim()}` : `svg:shape:${context.shapeIndex++}`; let id = base; let suffix = 2; while (context.ids.has(id)) id = `${base}:${suffix++}`; @@ -332,51 +297,17 @@ function nextId(sourceId: string | null, context: SvgContext) { return id; } -function parsePoints(value: string): Point[] { - const values = value - .trim() - .split(/[\s,]+/) - .filter(Boolean) - .map((item) => finite(item, 'points')); - if (values.length % 2 !== 0) throw new Error('points must contain x/y pairs'); - const points: Point[] = []; - for (let index = 0; index < values.length; index += 2) points.push({ x: values[index], y: values[index + 1] }); - return points; -} - -function parseTransform(value: string | null): Matrix { - if (!value?.trim()) return IDENTITY; - let result = IDENTITY; - const pattern = /([a-z]+)\s*\(([^)]*)\)/gi; - let match: RegExpExecArray | null; - while ((match = pattern.exec(value))) { - const values = match[2] - .split(/[\s,]+/) - .filter(Boolean) - .map((item) => finite(item, 'transform')); - let local: Matrix; - switch (match[1].toLowerCase()) { - case 'translate': - local = { ...IDENTITY, e: values[0] ?? 0, f: values[1] ?? 0 }; - break; - case 'scale': - local = { a: values[0] ?? 1, b: 0, c: 0, d: values[1] ?? values[0] ?? 1, e: 0, f: 0 }; - break; - case 'rotate': { - const angle = ((values[0] ?? 0) * Math.PI) / 180; - local = { a: Math.cos(angle), b: Math.sin(angle), c: -Math.sin(angle), d: Math.cos(angle), e: 0, f: 0 }; - break; - } - case 'matrix': - if (values.length !== 6) throw new Error('matrix requires six values'); - local = { a: values[0], b: values[1], c: values[2], d: values[3], e: values[4], f: values[5] }; - break; - default: - throw new Error(`${match[1]} transforms are not supported`); - } - result = multiply(result, local); - } - return result; +function transformMatrix(transform: Transform): Matrix { + const cos = Math.cos(transform.rotation); + const sin = Math.sin(transform.rotation); + return { + a: transform.scale_x * cos, + b: transform.scale_x * sin, + c: -transform.scale_y * sin, + d: transform.scale_y * cos, + e: transform.translation.x, + f: transform.translation.y + }; } function multiply(left: Matrix, right: Matrix): Matrix { @@ -390,10 +321,6 @@ function multiply(left: Matrix, right: Matrix): Matrix { }; } -function translation(x: number, y: number): Matrix { - return { ...IDENTITY, e: x, f: y }; -} - function apply(matrix: Matrix, point: Point): Point { return { x: matrix.a * point.x + matrix.c * point.y + matrix.e, @@ -402,128 +329,37 @@ function apply(matrix: Matrix, point: Point): Point { } function decompose(matrix: Matrix) { - const scaleX = Math.hypot(matrix.a, matrix.b) || 1; + const scaleX = Math.hypot(matrix.a, matrix.b); const determinant = matrix.a * matrix.d - matrix.b * matrix.c; - const scaleY = determinant / scaleX || 1; - return { x: matrix.e, y: matrix.f, rotation: Math.atan2(matrix.b, matrix.a), scaleX, scaleY }; -} - -function transformSegment(segment: PathSegment, matrix: Matrix): PathSegment { - if (segment.type === 'move' || segment.type === 'line') return { ...segment, to: apply(matrix, segment.to) }; - if (segment.type === 'quadratic') - return { ...segment, control: apply(matrix, segment.control), to: apply(matrix, segment.to) }; + const scaleY = determinant < 0 ? -Math.hypot(matrix.c, matrix.d) : Math.hypot(matrix.c, matrix.d); return { - ...segment, - control_1: apply(matrix, segment.control_1), - control_2: apply(matrix, segment.control_2), - to: apply(matrix, segment.to) + e: matrix.e, + f: matrix.f, + rotation: scaleX > Number.EPSILON ? Math.atan2(matrix.b, matrix.a) : 0, + scaleX, + scaleY }; } -function parsePath(value: string): PathSubpath[] { - const tokens = value.match(/[a-zA-Z]|[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/g) ?? []; - let index = 0; - let command = ''; - let current: Point = { x: 0, y: 0 }; - let start: Point = { x: 0, y: 0 }; - let lastCubic: Point | null = null; - let lastQuadratic: Point | null = null; - const subpaths: PathSubpath[] = []; - let active: PathSubpath | null = null; - const isCommand = (token: string) => /^[a-zA-Z]$/.test(token); - const read = () => { - if (index >= tokens.length || isCommand(tokens[index])) - throw new Error('path command has incomplete parameters'); - return finite(tokens[index++], 'path'); - }; - while (index < tokens.length) { - if (isCommand(tokens[index])) command = tokens[index++]; - if (!command) throw new Error('path data must begin with a command'); - const relative = command === command.toLowerCase(); - const type = command.toUpperCase(); - if (type === 'Z') { - if (!active) throw new Error('close command has no subpath'); - active.closed = true; - current = start; - lastCubic = null; - lastQuadratic = null; - command = ''; - continue; - } - const point = (x: number, y: number): Point => (relative ? { x: current.x + x, y: current.y + y } : { x, y }); - if (type === 'M') { - const next = point(read(), read()); - active = { segments: [{ type: 'move', to: next }], closed: false }; - subpaths.push(active); - current = start = next; - lastCubic = lastQuadratic = null; - command = relative ? 'l' : 'L'; - continue; - } - if (!active) throw new Error('path segment has no move command'); - switch (type) { - case 'L': - current = point(read(), read()); - active.segments.push({ type: 'line', to: current }); - lastCubic = lastQuadratic = null; - break; - case 'H': { - const value = read(); - current = { x: relative ? current.x + value : value, y: current.y }; - active.segments.push({ type: 'line', to: current }); - lastCubic = lastQuadratic = null; - break; - } - case 'V': { - const value = read(); - current = { x: current.x, y: relative ? current.y + value : value }; - active.segments.push({ type: 'line', to: current }); - lastCubic = lastQuadratic = null; - break; - } - case 'C': { - const control1 = point(read(), read()); - const control2 = point(read(), read()); - current = point(read(), read()); - active.segments.push({ type: 'cubic', control_1: control1, control_2: control2, to: current }); - lastCubic = control2; - lastQuadratic = null; - break; - } - case 'S': { - const control1 = lastCubic - ? { x: current.x * 2 - lastCubic.x, y: current.y * 2 - lastCubic.y } - : current; - const control2 = point(read(), read()); - current = point(read(), read()); - active.segments.push({ type: 'cubic', control_1: control1, control_2: control2, to: current }); - lastCubic = control2; - lastQuadratic = null; - break; - } - case 'Q': { - const control: Point = point(read(), read()); - current = point(read(), read()); - active.segments.push({ type: 'quadratic', control, to: current }); - lastQuadratic = control; - lastCubic = null; - break; - } - case 'T': { - const control: Point = lastQuadratic - ? { x: current.x * 2 - lastQuadratic.x, y: current.y * 2 - lastQuadratic.y } - : current; - current = point(read(), read()); - active.segments.push({ type: 'quadratic', control, to: current }); - lastQuadratic = control; - lastCubic = null; - break; - } - case 'A': - throw new Error('arc commands are not supported in the browser importer'); - default: - throw new Error(`the ${type} command is not supported`); - } +function numberProperty(properties: Record, name: string) { + const value = properties[name]; + return typeof value === 'number' && Number.isFinite(value) ? value : 0; +} + +function stringProperty(properties: Record, name: string) { + return typeof properties[name] === 'string' ? properties[name] : ''; +} + +function paintProperty(properties: Record, name: string) { + const value = stringProperty(properties, name); + return value === 'none' || value === 'transparent' ? '' : value; +} + +function pointProperty(properties: Record, name: string): Point { + const value = properties[name]; + if (typeof value === 'object' && value !== null && 'x' in value && 'y' in value) { + const point = value as { x: unknown; y: unknown }; + if (typeof point.x === 'number' && typeof point.y === 'number') return { x: point.x, y: point.y }; } - return subpaths; + return { x: 0, y: 0 }; } diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index f973c46..4c9093e 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -367,10 +367,27 @@ export const BindingRecord = { } }; +/** A retained group imported from an external document. */ +export type ImportedGroup = { + id: string; + sourceId?: string; + parentId?: string; + transform: { translation: Vec2; rotation: number; scale_x: number; scale_y: number }; + style: { opacity: number; fill_opacity: number | null; stroke_opacity: number | null }; + properties: Record; +}; + +/** A retained binary asset imported from an external document. */ +export type ImportedAsset = { id: string; name: string; mediaType: string; digest: string; bytes: number[] }; + export type Document = { pages: Record; /** Layers indexed by stable ID. */ layers?: Record; + /** Binary assets retained by interchange imports. */ + assets?: Record; + /** SVG group hierarchy retained by interchange imports. */ + svgGroups?: Record; shapes: Record; bindings: Record; }; @@ -396,6 +413,31 @@ export const Document = { ) } : {}), + ...(document.assets + ? { + assets: Object.fromEntries( + Object.entries(document.assets).map(([id, asset]) => [ + id, + { ...asset, bytes: [...asset.bytes] } + ]) + ) + } + : {}), + ...(document.svgGroups + ? { + svgGroups: Object.fromEntries( + Object.entries(document.svgGroups).map(([id, group]) => [ + id, + { + ...group, + transform: { ...group.transform, translation: { ...group.transform.translation } }, + style: { ...group.style }, + properties: { ...group.properties } + } + ]) + ) + } + : {}), shapes: Object.fromEntries( Object.entries(document.shapes).map(([id, shape]) => [id, ShapeRecord.clone(shape)]) ), diff --git a/packages/core/src/persistence/document.ts b/packages/core/src/persistence/document.ts index 2f04df2..ea48041 100644 --- a/packages/core/src/persistence/document.ts +++ b/packages/core/src/persistence/document.ts @@ -6,7 +6,9 @@ import { type PageRecord, PageRecord as PageOps, type ShapeRecord, - ShapeRecord as ShapeOps + ShapeRecord as ShapeOps, + type ImportedAsset, + type ImportedGroup } from '../model'; import type { BoardMeta, DocRepo } from './repo'; @@ -32,6 +34,8 @@ export type LoadedDoc = { layers?: Record; shapes: Record; bindings: Record; + assets?: Record; + svgGroups?: Record; order: DocOrder; }; diff --git a/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts b/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts index c24f65f..3330fbc 100644 --- a/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts +++ b/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts @@ -1,6 +1,11 @@ import { createInputAdapter, type InputAdapter } from '../input'; import { initialPersistenceStatus } from '../platform'; -import type { DesktopDocumentRepo, EditorPlatformAdapter, EditorPlatformSession, LiveProposal } from '../platform'; +import type { + DesktopDocumentRepo, + EditorPlatformAdapter, + EditorPlatformSession, + LiveProposal +} from '../platform'; import { createBrushStore, createSnapStore, createStatusStore } from '../status'; import type { BrushStore, SnapStore, StatusStore } from '../status'; import { themeStore } from '../theme.svelte'; @@ -16,6 +21,7 @@ import { LayerRecord, exportInterchange, importInterchange, + importInterchangeAsync, MarkdownTool, PageRecord, PenTool, @@ -57,7 +63,10 @@ export type CanvasControllerBindings = { setHistoryViewerOpen(value: boolean): v export type CanvasController = ReturnType; -export function createCanvasController(platformAdapter: EditorPlatformAdapter, bindings: CanvasControllerBindings) { +export function createCanvasController( + platformAdapter: EditorPlatformAdapter, + bindings: CanvasControllerBindings +) { let repo: PersistentDocRepo | null = null; let sink: PersistenceSink | null = null; let platformSession: EditorPlatformSession | null = null; @@ -115,7 +124,12 @@ export function createCanvasController(platformAdapter: EditorPlatformAdapter, b shapes: {}, bindings: {} }, - ui: { currentPageId: initialPage.id, activeLayerId: initialLayer.id, selectionIds: [], toolId: 'select' }, + ui: { + currentPageId: initialPage.id, + activeLayerId: initialLayer.id, + selectionIds: [], + toolId: 'select' + }, camera: Camera.create() }, { @@ -188,21 +202,37 @@ export function createCanvasController(platformAdapter: EditorPlatformAdapter, b const rect = element.getBoundingClientRect(); const min = Camera.screenToWorld( state.camera, - { x: rect.left - canvasRect.left, y: rect.top - canvasRect.top }, + { + x: rect.left - canvasRect.left, + y: rect.top - canvasRect.top + }, viewport ); const max = Camera.screenToWorld( state.camera, - { x: rect.right - canvasRect.left, y: rect.bottom - canvasRect.top }, + { + x: rect.right - canvasRect.left, + y: rect.bottom - canvasRect.top + }, viewport ); - return { x: min.x, y: min.y, width: max.x - min.x, height: max.y - min.y }; + return { + x: min.x, + y: min.y, + width: max.x - min.x, + height: max.y - min.y + }; }); const context = { pageId: state.ui.currentPageId, activeLayerId: state.ui.activeLayerId ?? null, selectionIds: [...state.ui.selectionIds], - viewport: { x: state.camera.x - width / 2, y: state.camera.y - height / 2, width, height }, + viewport: { + x: state.camera.x - width / 2, + y: state.camera.y - height / 2, + width, + height + }, camera: { ...state.camera }, occludedRegions }; @@ -227,7 +257,10 @@ export function createCanvasController(platformAdapter: EditorPlatformAdapter, b } const cursor = computeCursor( textEditor.isEditing || arrowLabelEditor.isEditing || markdownEditor.isEditing, - { isPanning: runtime.getInteractionState().panning, spaceHeld: runtime.getInteractionState().spaceHeld }, + { + isPanning: runtime.getInteractionState().panning, + spaceHeld: runtime.getInteractionState().spaceHeld + }, { hover: handleState.hover, active: handleState.active }, runtime.getInteractionState().pointerDown ); @@ -246,6 +279,8 @@ export function createCanvasController(platformAdapter: EditorPlatformAdapter, b doc: { pages: doc.pages, layers: doc.layers ?? doc.order.layers, + ...(doc.assets ? { assets: doc.assets } : {}), + ...(doc.svgGroups ? { svgGroups: doc.svgGroups } : {}), shapes: doc.shapes, bindings: doc.bindings }, @@ -260,7 +295,12 @@ export function createCanvasController(platformAdapter: EditorPlatformAdapter, b const selectTool = new SelectTool(handleMarqueeChange, (point) => { const snap = snapStore.get(); - if (!snap.snapEnabled || !snap.gridEnabled || !Number.isFinite(snap.gridSize) || snap.gridSize <= 0) { + if ( + !snap.snapEnabled || + !snap.gridEnabled || + !Number.isFinite(snap.gridSize) || + snap.gridSize <= 0 + ) { return point; } return { @@ -295,7 +335,11 @@ export function createCanvasController(platformAdapter: EditorPlatformAdapter, b ]); const textEditor = new TextEditorController(store, getOverlayViewport, refreshCursor); - const arrowLabelEditor = new ArrowLabelEditorController(store, getOverlayViewport, refreshCursor); + const arrowLabelEditor = new ArrowLabelEditorController( + store, + getOverlayViewport, + refreshCursor + ); const markdownEditor = new MarkdownEditorController(store, getOverlayViewport, refreshCursor); const toolController = new ToolController(store, tools); const unsubscribeMarqueeCamera = store.subscribe((state) => { @@ -357,11 +401,17 @@ export function createCanvasController(platformAdapter: EditorPlatformAdapter, b } function handleAction(action: import('@inkfinite/core').Action) { - if (textEditor.isEditing && (action.type === 'pointer-down' || action.type === 'pointer-up')) { + if ( + textEditor.isEditing && + (action.type === 'pointer-down' || action.type === 'pointer-up') + ) { textEditor.commit(); } - if (markdownEditor.isEditing && (action.type === 'pointer-down' || action.type === 'pointer-up')) { + if ( + markdownEditor.isEditing && + (action.type === 'pointer-down' || action.type === 'pointer-up') + ) { markdownEditor.commit(); } @@ -370,7 +420,8 @@ export function createCanvasController(platformAdapter: EditorPlatformAdapter, b } if ( action.type === 'pointer-down' && - (action.button === 1 || (action.button === 0 && runtime.getInteractionState().spaceHeld)) + (action.button === 1 || + (action.button === 0 && runtime.getInteractionState().spaceHeld)) ) { camera.cancelFit(); } @@ -419,12 +470,21 @@ export function createCanvasController(platformAdapter: EditorPlatformAdapter, b } } - async function importBrowserSvgSource(source: { name: string; contents: string }) { + async function importBrowserSvgSource(source: { + name: string; + contents: string | Uint8Array; + }) { if (!repo || !sink) return; - if (new Blob([source.contents]).size > 16 * 1024 * 1024) { + const importSvg = platformSession?.interchange?.importSvg; + if (!importSvg) throw new Error('The browser SVG importer is not available.'); + const size = + typeof source.contents === 'string' + ? new TextEncoder().encode(source.contents).byteLength + : source.contents.byteLength; + if (size > 16 * 1024 * 1024) { throw new Error('The SVG source is larger than the 16 MB import limit.'); } - const imported = importInterchange(source.contents, source.name); + const imported = await importInterchangeAsync(source.contents, source.name, importSvg); await sink.flush(); const boardId = await repo.importBoard(imported.snapshot); const doc = await repo.loadDoc(boardId); @@ -471,7 +531,8 @@ export function createCanvasController(platformAdapter: EditorPlatformAdapter, b return; } const source = await platformSession.interchange.pickSvg?.(); - if (source) await importBrowserSvgSource(source); + if (source) + await importBrowserSvgSource({ name: source.name, contents: source.bytes }); } catch (error) { interchangeNotice = { title: 'SVG import failed', @@ -485,7 +546,9 @@ export function createCanvasController(platformAdapter: EditorPlatformAdapter, b } async function importBrowserSvgSourceWithStatus( - source: { name: string; contents: string } | Promise<{ name: string; contents: string }> + source: + | { name: string; contents: string | Uint8Array } + | Promise<{ name: string; contents: string | Uint8Array }> ) { if (platform !== 'web' || !platformSession?.interchange) return; interchangeBusy = true; @@ -508,7 +571,11 @@ export function createCanvasController(platformAdapter: EditorPlatformAdapter, b } async function importSvgFile(file: File) { - await importBrowserSvgSourceWithStatus(file.text().then((contents) => ({ name: file.name, contents }))); + await importBrowserSvgSourceWithStatus( + file + .arrayBuffer() + .then((bytes) => ({ name: file.name, contents: new Uint8Array(bytes) })) + ); } async function exportEditableCanvas(format: InterchangeFormat) { @@ -517,8 +584,15 @@ export function createCanvasController(platformAdapter: EditorPlatformAdapter, b try { await sink.flush(); const snapshot = await repo.exportBoard(activeBoardId); - const exported = exportInterchange(snapshot, format, store.getState().ui.currentPageId ?? undefined); - const saved = await platformSession.interchange.saveExport(exported, snapshot.board.name); + const exported = exportInterchange( + snapshot, + format, + store.getState().ui.currentPageId ?? undefined + ); + const saved = await platformSession.interchange.saveExport( + exported, + snapshot.board.name + ); if (!saved) return; interchangeNotice = { title: 'Export complete', @@ -590,7 +664,10 @@ export function createCanvasController(platformAdapter: EditorPlatformAdapter, b const clickedShape = shapes.some((shape) => { const bounds = shapeBounds(shape); return ( - world.x >= bounds.min.x && world.x <= bounds.max.x && world.y >= bounds.min.y && world.y <= bounds.max.y + world.x >= bounds.min.x && + world.x <= bounds.max.x && + world.y >= bounds.min.y && + world.y <= bounds.max.y ); }); if (!clickedShape) { @@ -643,7 +720,8 @@ export function createCanvasController(platformAdapter: EditorPlatformAdapter, b onCursorUpdate: (world, screen) => cursorStore.updateCursor(world, screen) }); - const resizeObserver = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(handleResize); + const resizeObserver = + typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(handleResize); resizeObserver?.observe(canvas); return () => { @@ -834,7 +912,12 @@ export function createCanvasController(platformAdapter: EditorPlatformAdapter, b state, nextState, 'Insert Stencil', - Action.keyDown('InsertStencil', 'InsertStencil', { ctrl: false, shift: false, alt: false, meta: false }) + Action.keyDown('InsertStencil', 'InsertStencil', { + ctrl: false, + shift: false, + alt: false, + meta: false + }) ); } } diff --git a/packages/ui/src/lib/editor/platform.ts b/packages/ui/src/lib/editor/platform.ts index 2290055..0f04fe0 100644 --- a/packages/ui/src/lib/editor/platform.ts +++ b/packages/ui/src/lib/editor/platform.ts @@ -4,7 +4,8 @@ import type { InterchangeExport, PersistenceSink, PersistenceStatus, - PersistentDocRepo + PersistentDocRepo, + SvgImportResult } from '@inkfinite/core'; import type { StatusStore } from './status'; @@ -15,8 +16,15 @@ export type EditorPlatform = 'web' | 'desktop'; export type LiveProposal = { id: string; transaction: { operations: readonly unknown[] }; - preview: { created: readonly unknown[]; changed: readonly unknown[]; deleted: readonly unknown[] }; - affected_regions: Array<{ page_id: string; bounds: { x: number; y: number; width: number; height: number } }>; + preview: { + created: readonly unknown[]; + changed: readonly unknown[]; + deleted: readonly unknown[]; + }; + affected_regions: Array<{ + page_id: string; + bounds: { x: number; y: number; width: number; height: number }; + }>; operation_previews?: Array<{ position: number; label: string; @@ -61,11 +69,16 @@ export type NativeFileMenuAction = /** User-selected external editable document. */ export type InterchangeSourceFile = { name: string; contents: string }; +/** User-selected SVG bytes kept out of the main-thread parser. */ +export type SvgSourceFile = { name: string; bytes: Uint8Array }; + /** Platform file operations used by shared editable-format import and export. */ export interface InterchangeFileAccess { pickImport(): Promise; - /** Picks an SVG source for the browser import path. */ - pickSvg?(): Promise; + /** Picks SVG bytes for the browser import path. */ + pickSvg?(): Promise; + /** Parses SVG bytes through the application's shared Rust/WASM worker. */ + importSvg?(source: Uint8Array): Promise; saveExport(file: InterchangeExport, defaultStem: string): Promise; } @@ -89,7 +102,9 @@ export interface DesktopDocumentRepo extends PersistentDocRepo { prepareToOpen?: () => Promise ): Promise<{ boardId: string; doc: import('@inkfinite/core').LoadedDoc }>; /** Opens the native dialog, then waits for pending editor writes before saving the selected path. */ - saveAs(prepareToSave?: () => Promise): Promise<{ boardId: string; doc: import('@inkfinite/core').LoadedDoc }>; + saveAs( + prepareToSave?: () => Promise + ): Promise<{ boardId: string; doc: import('@inkfinite/core').LoadedDoc }>; getWorkspaceDir(): Promise; setWorkspaceDir(path: string | null): Promise; pickWorkspaceDir(): Promise; @@ -98,12 +113,19 @@ export interface DesktopDocumentRepo extends PersistentDocRepo { getAgentAccess(): 'review' | 'direct'; subscribeProposal(listener: (update: ProposalUpdate) => void): () => void; /** Receives document snapshots committed by the live CLI or trusted sync peers. */ - subscribeLiveDocument(listener: (doc: import('@inkfinite/core').LoadedDoc) => void): () => void; + subscribeLiveDocument( + listener: (doc: import('@inkfinite/core').LoadedDoc) => void + ): () => void; /** Receives authenticated live CLI navigation without changing document history. */ subscribeAgentUi(listener: (control: AgentUiControl) => void): () => void; - acceptProposal(proposalId: string, operationPositions?: number[]): Promise; + acceptProposal( + proposalId: string, + operationPositions?: number[] + ): Promise; rejectProposal(proposalId: string): Promise; - setAgentAccess(agentAccess: 'review' | 'direct'): Promise<{ agent_access: 'review' | 'direct' }>; + setAgentAccess( + agentAccess: 'review' | 'direct' + ): Promise<{ agent_access: 'review' | 'direct' }>; /** Publishes the current page, selection, and visible world-space rectangle. */ updateAgentContext(context: AgentEditorContext): Promise; } @@ -129,5 +151,9 @@ export interface EditorPlatformAdapter { /** Creates the initial status shown while an application adapter connects. */ export function initialPersistenceStatus(platform: EditorPlatform): PersistenceStatus { - return { backend: platform === 'desktop' ? 'filesystem' : 'indexeddb', state: 'saved', pendingWrites: 0 }; + return { + backend: platform === 'desktop' ? 'filesystem' : 'indexeddb', + state: 'saved', + pendingWrites: 0 + }; } diff --git a/packages/ui/src/lib/editor/svg-import.svelte.test.ts b/packages/ui/src/lib/editor/svg-import.svelte.test.ts index 89b5d35..318aae1 100644 --- a/packages/ui/src/lib/editor/svg-import.svelte.test.ts +++ b/packages/ui/src/lib/editor/svg-import.svelte.test.ts @@ -1,21 +1,89 @@ -import { importInterchange } from '@inkfinite/core'; +import { projectSvgImport, type SvgImportResult } from '@inkfinite/core'; import { describe, expect, it } from 'vitest'; -describe('browser SVG import', () => { - it('maps nested groups and native primitives into one imported board', () => { - const imported = importInterchange( - '', - 'icon.svg' - ); +const style = { opacity: 1, fill_opacity: 1, stroke_opacity: 1 }; - expect(imported.format).toBe('svg'); - expect(Object.values(imported.snapshot.doc.shapes)).toHaveLength(1); - expect(Object.values(imported.snapshot.doc.shapes)[0]).toMatchObject({ +function svgImport(): SvgImportResult { + return { + view_box: { x: 0, y: 0, width: 100, height: 80 }, + root: { + source_id: null, + transform: { translation: { x: 0, y: 0 }, rotation: 0, scale_x: 1, scale_y: 1 }, + style, + properties: { width: 100, height: 80 }, + children: [ + { + kind: 'group', + value: { + source_id: 'translated', + transform: { + translation: { x: 10, y: 20 }, + rotation: 0, + scale_x: 1, + scale_y: 1 + }, + style, + properties: { width: 24, height: 35 }, + children: [ + { + kind: 'shape', + value: { + source_id: 'box', + kind: 'rect', + transform: { + translation: { x: 4, y: 5 }, + rotation: 0, + scale_x: 1, + scale_y: 1 + }, + properties: { + width: 20, + height: 30, + radius: 0, + fill: '#123456', + stroke: 'none' + }, + style + } + } + ] + } + } + ] + }, + source_asset: { + id: 'asset:source', + name: 'source-icon.svg', + media_type: 'image/svg+xml', + digest: 'sha256:test', + bytes: [60, 115, 118, 103] + }, + assets: [], + warnings: [], + omitted_image_count: 0 + }; +} + +describe('browser SVG projection', () => { + it('maps the shared normalized tree while retaining group identity and source assets', () => { + const imported = projectSvgImport(svgImport(), 'icon.svg'); + const shape = Object.values(imported.snapshot.doc.shapes)[0]; + + expect(shape).toMatchObject({ type: 'rect', x: 14, y: 25, + groupId: 'svg:group:translated', props: { w: 20, h: 30, fill: '#123456' } }); - expect(imported.warnings.some((warning) => warning.code === 'svg-group-flattened')).toBe(true); + expect(imported.snapshot.doc.assets?.['asset:source']).toMatchObject({ + mediaType: 'image/svg+xml', + bytes: [60, 115, 118, 103] + }); + expect(imported.snapshot.doc.svgGroups?.['svg:group:translated']).toMatchObject({ + parentId: 'svg:group:root', + transform: { translation: { x: 10, y: 20 } } + }); + expect(imported.warnings).toEqual([]); }); }); diff --git a/packages/wasm/package.json b/packages/wasm/package.json new file mode 100644 index 0000000..45cdcf8 --- /dev/null +++ b/packages/wasm/package.json @@ -0,0 +1,26 @@ +{ + "name": "@inkfinite/wasm", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "build": "node ../../scripts/build-wasm.mjs", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "files": [ + "src", + "dist" + ], + "dependencies": { + "@inkfinite/bindings": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.9.3" + } +} diff --git a/packages/wasm/src/index.ts b/packages/wasm/src/index.ts new file mode 100644 index 0000000..c96ac19 --- /dev/null +++ b/packages/wasm/src/index.ts @@ -0,0 +1,37 @@ +import type { SvgImport } from '@inkfinite/bindings/svg-import'; + +export type { SvgImport, SvgImportWarning } from '@inkfinite/bindings/svg-import'; + +/** Structured error returned when Rust rejects an SVG. */ +export type SvgImportFailure = { code: string; message: string }; + +/** JSON envelope emitted by the `inkfinite-wasm` crate. */ +export type SvgImportResponse = + | { status: 'success'; import: SvgImport; omitted_image_count: number } + | { status: 'error'; error: SvgImportFailure }; + +type GeneratedWasmModule = { default(input?: unknown): Promise; import_svg(source: Uint8Array): string }; + +let modulePromise: Promise | null = null; + +/** Loads the generated Rust module once and imports one SVG byte buffer. */ +export async function importSvg(source: Uint8Array): Promise { + const module = await loadModule(); + const response = JSON.parse(module.import_svg(source)) as SvgImportResponse; + return response; +} + +/** Clears the lazy module cache, primarily for worker tests and hot reload. */ +export function resetWasmModuleForTests() { + modulePromise = null; +} + +async function loadModule(): Promise { + modulePromise ??= (async () => { + const generatedUrl = new URL('../dist/inkfinite_wasm.js', import.meta.url); + const generated = (await import(/* @vite-ignore */ generatedUrl.href)) as GeneratedWasmModule; + await generated.default(); + return generated; + })(); + return modulePromise; +} diff --git a/packages/wasm/tsconfig.json b/packages/wasm/tsconfig.json new file mode 100644 index 0000000..042dab2 --- /dev/null +++ b/packages/wasm/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1fdd1ab..24890b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,9 @@ importers: '@inkfinite/core': specifier: workspace:* version: link:../../packages/core + '@inkfinite/wasm': + specifier: workspace:* + version: link:../../packages/wasm '@inkfinite/ui': specifier: workspace:* version: link:../../packages/ui @@ -176,6 +179,9 @@ importers: packages/core: dependencies: + '@inkfinite/bindings': + specifier: workspace:* + version: link:../bindings perfect-freehand: specifier: ^1.2.2 version: 1.2.2 @@ -217,6 +223,16 @@ importers: specifier: ^4.0.16 version: 4.0.16(@types/node@25.0.3)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(yaml@2.8.2) + packages/wasm: + dependencies: + '@inkfinite/bindings': + specifier: workspace:* + version: link:../bindings + devDependencies: + typescript: + specifier: ^5.9.3 + version: 5.9.3 + packages/input-dom: dependencies: '@inkfinite/runtime': diff --git a/scripts/build-wasm.mjs b/scripts/build-wasm.mjs new file mode 100644 index 0000000..2b923dd --- /dev/null +++ b/scripts/build-wasm.mjs @@ -0,0 +1,21 @@ +import { mkdir, rm } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const root = path.resolve(fileURLToPath(new URL('..', import.meta.url))); +const output = path.join(root, 'packages/wasm/dist'); +const target = path.join(root, 'target/wasm32-unknown-unknown/release/inkfinite_wasm.wasm'); + +function run(command, args) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd: root, stdio: 'inherit' }); + child.once('error', reject); + child.once('exit', (code) => (code === 0 ? resolve() : reject(new Error(`${command} exited with ${code}`)))); + }); +} + +await run('cargo', ['build', '-p', 'inkfinite-wasm', '--target', 'wasm32-unknown-unknown', '--release']); +await rm(output, { recursive: true, force: true }); +await mkdir(output, { recursive: true }); +await run('wasm-bindgen', [target, '--target', 'web', '--out-dir', output, '--out-name', 'inkfinite_wasm']); -- 2.51.2