From 7fc24bf063cef77c6bb308cf5cc7e3cfc27079b7 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Thu, 20 Aug 2026 16:30:27 -0500 Subject: [PATCH] feat: browser document engine verification --- CHANGELOG.md | 4 + ROADMAP.md | 143 +++--------------- TODO.md | 33 +--- apps/web/src/lib/persistence/dexie.ts | 8 +- ...import.test.ts => document-engine.test.ts} | 12 +- .../{svg-import.ts => document-engine.ts} | 28 ++-- ...rt.worker.ts => document-engine.worker.ts} | 2 +- .../lib/tests/document-engine.worker.test.ts | 94 ++++++++++++ crates/inkfinite-core/src/editor.rs | 24 +-- package.json | 2 +- 10 files changed, 160 insertions(+), 190 deletions(-) rename apps/web/src/lib/persistence/{svg-import.test.ts => document-engine.test.ts} (92%) rename apps/web/src/lib/persistence/{svg-import.ts => document-engine.ts} (91%) rename apps/web/src/lib/persistence/{svg-import.worker.ts => document-engine.worker.ts} (99%) create mode 100644 apps/web/src/lib/tests/document-engine.worker.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 82bf39b..b237345 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,8 @@ canonical Automerge bytes through one worker. - Canonical geometry commits that validate and normalize native paths and freehand strokes in Rust, compute committed stroke bounds, and exercise shared Rust/TypeScript geometry fixtures. +- Browser smoke coverage for the compiled WASM document engine and real worker across create, + mutate, save, reopen, projection, undo, redo, SVG import, and SVG rendering. #### SVG Interop @@ -72,6 +74,8 @@ reconciliation for page, layer, and shape changes - Browser canonical state now caches the Rust editor projection with Automerge bytes. WASM request and response payloads are generated from Rust. +- The shared browser worker and client are named for their document-engine role, + and root test verification builds the WASM package before running tests. ### Fixed diff --git a/ROADMAP.md b/ROADMAP.md index b7a66be..90ceaf2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -13,134 +13,31 @@ implementation work lives in [TODO.md](TODO.md). ### SVG interoperability -Make SVG a first-class interchange format. - -Inkfinite should be able to import useful SVG content into its native document -model, edit that content as ordinary shapes, and export deterministic SVG -without introducing an SVG-specific document model. - -SVG import should map supported elements onto native Inkfinite concepts: - -- groups become containers -- basic primitives become existing shape kinds -- arbitrary vector geometry becomes native paths -- transforms become parent-relative Inkfinite transforms -- fills, strokes, opacity, and other supported styles become native style data -- embedded images become assets - -The original SVG source is retained as an asset for provenance, future -re-import, and fallback handling. - -Desktop file-menu imports and the CLI already use the Rust importer and shared -SVG transaction builder. Browser file selection, drop, and pasted markup still -use a separate TypeScript parser, which supports a smaller SVG subset and can -produce different geometry, hierarchy, style, warning, and asset results. The -browser importer should be replaced rather than extended in parallel. - -A small browser-facing WASM crate should call the importer in -`inkfinite-core`. The WASM API should expose a serializable normalized tree, -source and embedded assets, structured warnings and errors, and omitted-image -counts. TypeScript bindings should be generated or checked from that result -schema so the browser does not acquire another handwritten importer contract. -The core parser and normalization logic remain in Rust; the WASM crate is only -the browser adapter. - -The web app should initialize and run WASM in a dedicated worker. It should -lazy-load the module, reuse one initialized worker across imports, and transfer -file bytes rather than parse on the main thread. The worker must enforce the -same 16 MB input limit and report failures without leaving the editor in a busy -state. Excalidraw and JSON Canvas imports should not wait for SVG WASM to load. - -The browser projects the normalized Rust result into its local document model, -preserving groups, local transforms, styles, fill rules, warnings, and assets. -Each completed import is committed as one document transaction and one undo -step. Once file selection, drag-and-drop, and pasted markup use this path, the -handwritten TypeScript SVG parser can be removed. - -The Bootstrap `filetype-svg` icon is the regression fixture for the original -browser failure. It combines inherited `currentColor`, `evenodd`, relative -commands, horizontal and vertical commands, arcs, and a compound path. Native -and browser-WASM tests should compare normalized geometry, hierarchy, styles, -warnings, source assets, and bounds. The imported result should be one editable -path, resolve `currentColor` to concrete SVG paint, normalize arcs to cubic -segments, retain `evenodd`, and match the artwork in its 16×16 viewBox. Canvas -output should also agree visually with deterministic Rust SVG output; malformed, -oversized, unsupported-feature, and embedded-image inputs must cross the worker -boundary with the same outcomes as native import. - -Desktop imports into the active layer; browser imports create a new local board -using the browser persistence adapter; CLI imports can target a file or a live -desktop session. - -Unsupported SVG features should be reported explicitly and preserve a path to -opaque fallback rather than silently disappearing. - -The importer has a checked-in fixture corpus under -[`fixtures/svg-import`](fixtures/svg-import/). It includes Iconify-derived -icons and logos, nested groups, compound paths, unsupported features, and -malformed inputs. Rust and browser-WASM tests should exercise this corpus rather -than maintain separate fixtures. They must validate native mappings, warning -coverage, geometry semantics, and failure behavior as the supported SVG subset -grows. - -SVG interoperability must use the same transaction, persistence, undo/redo, -CRDT, desktop, and CLI paths as content created directly in Inkfinite. +Inkfinite uses SVG as a first-class interchange format. Rust owns native path +geometry, import, export, validation, and transaction construction across the +desktop, web app, and CLI. Unsupported visual content retains an opaque fallback +when possible. + +Remaining work focuses on round-trip workflows: save and reopen, undo and redo, +CRDT merge, CLI inspection and mutation, deterministic transforms and compound +fills, and visual stability for opaque fallback content. The checked-in fixture +corpus under [`fixtures/svg-import`](fixtures/svg-import/) is shared across native +and browser tests. The [native path geometry guide](apps/web/src/content/docs/internals/native-path-geometry.md) -documents the native representation, fill rules, validation, exact bounds, -rendering, hit testing, and fixture coverage used by later import and editing work. +documents the representation and geometry semantics used by import and editing. ### WASM -WebAssembly brings Inkfinite's Rust document engine to the browser. A persistent -worker session opens, mutates, undoes, redoes, and saves the canonical Automerge -document used by native entry points. IndexedDB stores canonical bytes rather -than maintaining another shape graph. SVG parsing and export, committed -geometry, editor projection, and editor reconciliation call the Rust -implementations. - -The browser still has a TypeScript editor model for low-latency interaction and -browser APIs. Pointer movement, hover, selection, camera state, drag previews, -snap guides, hit testing, Canvas rendering, PNG export, and DOM/Svelte work do -not cross the WASM interface. TypeScript creates semantic editor patches; Rust -reconciles them into validated parent-relative transactions. Rust owns committed -path geometry, exact bounds, hierarchy transforms, reparenting, topology, arc -conversion, validation, and final freehand normalization. - -Canonical browser documents load their editor state from the Rust projection -returned with each session response. The projection is cached with canonical -IndexedDB state so reopening a board does not rebuild the editor view from a -second hierarchy representation. Browser SVG imports pass bytes to the worker; -the Rust session parses the source, builds the shared SVG transaction, commits it, -and returns the updated snapshot, projection, assets, and diagnostics. SVG group -compatibility metadata and the TypeScript SVG projector are no longer part of the -browser document model. - -Desktop reconciliation uses the same TypeScript before-and-after patch -builder as the web editor. Page, layer, and shape changes become semantic -patches, then Rust reconciles and commits them through the native transaction -engine. - -The WASM request and response payloads now use generated TypeScript types, and -native editor types are reused where the representations match. The shared worker -and client still need names that describe their document-engine role rather than -SVG import. Standalone projection or reconciliation exports can be removed when -the stateful session covers their use cases. - -Root verification should build and type-check the WASM package and run a browser -smoke test against the compiled module and real worker. The test should create a -document, commit a transaction, save and reopen its bytes, project editor state, -exercise undo and redo, import a representative SVG, render SVG, and verify the -canonical state after each operation. Remove fallback implementations only after -this path is covered. - -This cleanup does not move the frame-by-frame editor loop into WASM. TypeScript -keeps responsive previews, cached bounds, hit testing, selection geometry, and -ephemeral history. Shared fixtures compare those approximations with committed -Rust results. Legacy Dexie migration may still build an initial canonical -snapshot in TypeScript, and Excalidraw and JSON Canvas conversion can remain in -TypeScript until another native entry point needs them. Add batched WASM geometry -queries only when profiling identifies a browser geometry bottleneck. +The web app runs the canonical Rust document engine in a dedicated worker. The +worker owns Automerge state, validated commits, projection, reconciliation, +undo/redo, SVG import, and deterministic SVG rendering. IndexedDB stores canonical +bytes and the Rust projection. TypeScript continues to own interaction previews, +hit testing, Canvas rendering, and browser APIs. + +Browser smoke coverage exercises the compiled module and worker through document +creation, mutation, save and reopen, projection, undo and redo, SVG import, and +SVG rendering. Root verification builds the WASM package before running tests. ### Vector editing diff --git a/TODO.md b/TODO.md index 1515774..97489ca 100644 --- a/TODO.md +++ b/TODO.md @@ -61,36 +61,9 @@ Inkfinite now imports SVGs through one validated Rust pipeline across desktop, w ## WASM -Inkfinite now runs the canonical Rust document engine in a browser worker. The browser stores -Automerge bytes, commits validated transactions, uses Rust projection and reconciliation APIs, -and shares SVG and committed-geometry behavior with native callers. TypeScript continues to own -interaction state, previews, hit testing, Canvas rendering, and browser APIs. - -### Engine integration closure - -#### Canonical browser flow - -- [x] Return the Rust editor projection with browser document session state -- [x] Hydrate canonical browser documents from the Rust projection -- [x] Commit browser SVG imports through the Rust session and SVG transaction builder -- [x] Remove the TypeScript SVG projector and imported-group compatibility metadata -- [x] Generate TypeScript types for WASM request and response payloads - -#### Shared reconciliation - -- [x] Use one TypeScript editor-patch builder across web and desktop -- [x] Reconcile desktop page and layer changes through Rust -- [x] Retire the desktop whole-document mirror fallback -- [x] Reuse generated native geometry and transform types in the editor model - -#### Worker verification and cleanup - -- [ ] Rename the shared SVG worker and client around their document-engine role -- [ ] Add browser tests against the compiled WASM module and real worker -- [ ] Cover create, mutate, save, reopen, project, undo, redo, SVG import, and SVG render -- [ ] Run WASM build, type checking, and browser smoke tests from root verification -- [ ] Remove unused TypeScript validation and generated bounds helpers after the browser tests pass -- [ ] Remove unused standalone WASM APIs after migrating their consumers +Inkfinite now runs the canonical Rust document engine in a browser worker. Root +verification builds the WASM package and browser smoke coverage verifies the full +document lifecycle, including SVG import and rendering. ## Vector Editing diff --git a/apps/web/src/lib/persistence/dexie.ts b/apps/web/src/lib/persistence/dexie.ts index 29f9694..4531d8d 100644 --- a/apps/web/src/lib/persistence/dexie.ts +++ b/apps/web/src/lib/persistence/dexie.ts @@ -19,8 +19,8 @@ import type { EditorPlatformAdapter, EditorPlatformSession } from '@inkfinite/ui import { liveQuery } from 'dexie'; import { InkfiniteDB } from './database'; import { createDexieDocRepo, createPersistenceSink, getBoardInspectorData } from './repository'; -import { getSharedSvgImportWorker, renderSvgInWorker } from './svg-import'; -import type { BrowserDocumentState } from './svg-import'; +import { getSharedDocumentEngineWorker, renderSvgInWorker } from './document-engine'; +import type { BrowserDocumentState } from './document-engine'; import type { PersistenceSinkOptions } from './repository'; import type { LoadedDoc } from '@inkfinite/core'; @@ -56,7 +56,7 @@ export function createDexieSession( const liveQueryFactory = opts.liveQueryFn ?? liveQuery; let activeBoardId: string | null = null; let subscription: { unsubscribe(): void } | null = null; - let documentWorker: ReturnType | null = null; + let documentWorker: ReturnType | null = null; let documentBoardId: string | null = null; let documentReady: Promise | null = null; let documentQueue: Promise = Promise.resolve(); @@ -86,7 +86,7 @@ export function createDexieSession( function ensureDocumentWorker() { if (!canonicalEnabled) return null; - documentWorker ??= getSharedSvgImportWorker(); + documentWorker ??= getSharedDocumentEngineWorker(); return documentWorker; } diff --git a/apps/web/src/lib/persistence/svg-import.test.ts b/apps/web/src/lib/persistence/document-engine.test.ts similarity index 92% rename from apps/web/src/lib/persistence/svg-import.test.ts rename to apps/web/src/lib/persistence/document-engine.test.ts index 47f16de..cea3689 100644 --- a/apps/web/src/lib/persistence/svg-import.test.ts +++ b/apps/web/src/lib/persistence/document-engine.test.ts @@ -1,6 +1,6 @@ import type { DocumentSnapshot } from '@inkfinite/wasm'; import { describe, expect, it } from 'vitest'; -import { SvgImportWorkerClient } from './svg-import'; +import { DocumentEngineWorkerClient } from './document-engine'; type Listener = (event: { data: unknown; message?: string }) => void; @@ -109,10 +109,10 @@ class FakeWorker { } } -describe('SVG import worker client', () => { +describe('document engine 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 client = new DocumentEngineWorkerClient(worker as unknown as Worker); const source = new Uint8Array([60, 115, 118, 103]); const result = await client.import(source); @@ -133,7 +133,7 @@ describe('SVG import worker client', () => { it('routes document SVG imports through the worker-owned session', async () => { const worker = new FakeWorker(); - const client = new SvgImportWorkerClient(worker as unknown as Worker); + const client = new DocumentEngineWorkerClient(worker as unknown as Worker); const state = await client.importDocumentSvg( new Uint8Array([60, 115, 118, 103]), @@ -154,7 +154,7 @@ describe('SVG import worker client', () => { it('routes projection and reconciliation requests through the same worker', async () => { const worker = new FakeWorker(); - const client = new SvgImportWorkerClient(worker as unknown as Worker); + const client = new DocumentEngineWorkerClient(worker as unknown as Worker); const projection = await client.project({} as DocumentSnapshot); expect(worker.lastMessage).toMatchObject({ type: 'project' }); @@ -168,7 +168,7 @@ describe('SVG import worker client', () => { it('routes canonical render requests through the same worker', async () => { const worker = new FakeWorker(); - const client = new SvgImportWorkerClient(worker as unknown as Worker); + const client = new DocumentEngineWorkerClient(worker as unknown as Worker); const result = await client.render({} as DocumentSnapshot, { page_id: 'page:one' }); diff --git a/apps/web/src/lib/persistence/svg-import.ts b/apps/web/src/lib/persistence/document-engine.ts similarity index 91% rename from apps/web/src/lib/persistence/svg-import.ts rename to apps/web/src/lib/persistence/document-engine.ts index 1986223..5890621 100644 --- a/apps/web/src/lib/persistence/svg-import.ts +++ b/apps/web/src/lib/persistence/document-engine.ts @@ -84,7 +84,7 @@ type WorkerResponseValue = | BrowserSvgImportState; /** A worker boundary that keeps Rust parsing, rendering, and document state off the UI thread. */ -export class SvgImportWorkerClient { +export class DocumentEngineWorkerClient { private nextRequestId = 0; private readonly pending = new Map< number, @@ -197,7 +197,7 @@ export class SvgImportWorkerClient { this.worker.removeEventListener('message', this.handleMessage); this.worker.removeEventListener('error', this.handleError); this.worker.terminate(); - this.rejectPending(new Error('The SVG worker was stopped.')); + this.rejectPending(new Error('The document engine worker was stopped.')); } private request( @@ -232,35 +232,35 @@ export class SvgImportWorkerClient { } } -let sharedClient: SvgImportWorkerClient | null = null; +let sharedClient: DocumentEngineWorkerClient | null = null; -/** Returns the one SVG worker shared by browser file, drop, markup, and export actions. */ -export function getSharedSvgImportWorker(): SvgImportWorkerClient { +/** Returns the one document engine worker shared by browser persistence, import, and export actions. */ +export function getSharedDocumentEngineWorker(): DocumentEngineWorkerClient { if (typeof Worker === 'undefined') - throw new Error('SVG workers are unavailable in this environment.'); - sharedClient ??= new SvgImportWorkerClient( - new Worker(new URL('./svg-import.worker.ts', import.meta.url), { + throw new Error('document engine workers are unavailable in this environment.'); + sharedClient ??= new DocumentEngineWorkerClient( + new Worker(new URL('./document-engine.worker.ts', import.meta.url), { type: 'module', - name: 'inkfinite-svg' + name: 'inkfinite-document-engine' }) ); return sharedClient; } /** Allows browser tests and hot reload teardown to release the shared worker. */ -export function resetSharedSvgImportWorker() { +export function resetSharedDocumentEngineWorker() { sharedClient?.dispose(); sharedClient = null; } /** Imports SVG bytes through the shared worker. */ export function importSvgInWorker(source: Uint8Array) { - return getSharedSvgImportWorker().import(source); + return getSharedDocumentEngineWorker().import(source); } /** Projects a canonical snapshot through the shared worker. */ export function projectEditorInWorker(snapshot: DocumentSnapshot) { - return getSharedSvgImportWorker().project(snapshot); + return getSharedDocumentEngineWorker().project(snapshot); } /** Reconciles semantic editor patches through the shared worker. */ @@ -268,12 +268,12 @@ export function reconcileEditorPatchesInWorker( snapshot: DocumentSnapshot, request: EditorReconciliationRequest ) { - return getSharedSvgImportWorker().reconcile(snapshot, request); + return getSharedDocumentEngineWorker().reconcile(snapshot, request); } /** Renders a canonical snapshot through the shared worker. */ export function renderSvgInWorker(snapshot: DocumentSnapshot, options: SvgRenderOptions = {}) { - return getSharedSvgImportWorker().render(snapshot, options); + return getSharedDocumentEngineWorker().render(snapshot, options); } /** Used by the worker entry point to run the generated Rust/WASM bindings. */ diff --git a/apps/web/src/lib/persistence/svg-import.worker.ts b/apps/web/src/lib/persistence/document-engine.worker.ts similarity index 99% rename from apps/web/src/lib/persistence/svg-import.worker.ts rename to apps/web/src/lib/persistence/document-engine.worker.ts index 1c893b0..9e04aa2 100644 --- a/apps/web/src/lib/persistence/svg-import.worker.ts +++ b/apps/web/src/lib/persistence/document-engine.worker.ts @@ -12,7 +12,7 @@ import { projectEditorInWorkerRuntime, reconcileEditorPatchesInWorkerRuntime, renderSvgInWorkerRuntime -} from './svg-import'; +} from './document-engine'; type Request = | { type: 'import'; id: number; source: ArrayBuffer } diff --git a/apps/web/src/lib/tests/document-engine.worker.test.ts b/apps/web/src/lib/tests/document-engine.worker.test.ts new file mode 100644 index 0000000..c479715 --- /dev/null +++ b/apps/web/src/lib/tests/document-engine.worker.test.ts @@ -0,0 +1,94 @@ +import type { DocumentSnapshot, EditorReconciliationRequest } from '@inkfinite/wasm'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + getSharedDocumentEngineWorker, + resetSharedDocumentEngineWorker +} from '../persistence/document-engine'; + +const snapshot: DocumentSnapshot = { + format: 'inkfinite.document', + format_version: 2, + document_id: 'document:browser-smoke', + heads: [], + document: { + pages: { + 'page:one': { id: 'page:one', name: 'Page 1', layer_ids: ['layer:one'], version: 1 } + }, + page_ids: ['page:one'], + layers: { + 'layer:one': { + id: 'layer:one', + page_id: 'page:one', + name: 'Default', + shape_ids: [], + visible: true, + locked: false, + opacity: 1, + version: 1 + } + }, + shapes: {}, + bindings: {}, + assets: {} + } +}; + +const request: EditorReconciliationRequest = { + patches: [ + { + type: 'create_shape', + shape: { + id: 'shape:rect', + kind: 'rect', + properties: { w: 20, h: 10, fill: '#ff0000' }, + metadata: null, + style: { opacity: 1, fill_opacity: null, stroke_opacity: null }, + layout: null + }, + parent: { kind: 'layer', id: 'layer:one' }, + transform: { a: 1, b: 0, c: 0, d: 1, e: 4, f: 5 }, + anchor: { position: 'last' } + } + ], + actor_id: 'browser', + origin: 'human', + transaction_id: 'transaction:create', + description: 'Create rectangle', + timestamp: 1 +}; + +afterEach(resetSharedDocumentEngineWorker); + +describe('compiled document engine worker', () => { + it('runs the canonical browser document lifecycle', async () => { + const worker = getSharedDocumentEngineWorker(); + let state = await worker.createDocument(snapshot, 'browser'); + expect(state.editor_projection.shapes).toEqual({}); + + state = await worker.applyEditorPatches(request); + expect(state.editor_projection.shapes['shape:rect']).toBeDefined(); + expect(state.can_undo).toBe(true); + + expect( + (await worker.undoDocument()).snapshot.document.shapes['shape:rect'] + ).toBeUndefined(); + state = await worker.redoDocument(); + expect(state.snapshot.document.shapes['shape:rect']).toBeDefined(); + + const reopened = await worker.openDocument(state.bytes, 'browser'); + expect(reopened.snapshot.document.shapes['shape:rect']).toBeDefined(); + expect(reopened.editor_projection.shapes['shape:rect']).toBeDefined(); + + const imported = await worker.importDocumentSvg( + new TextEncoder().encode(''), + 'line.svg', + 'page:one', + 'layer:one', + 2 + ); + expect(imported.shape_ids.length).toBeGreaterThan(0); + const rendered = await worker.render(imported.snapshot, { page_id: 'page:one' }); + expect(rendered.status).toBe('success'); + if (rendered.status === 'success') expect(rendered.svg).toContain(' { + transform: Option, + properties: Option, + metadata: Option, + style: Option, + parent: Option<&'a ShapeParent>, + anchor: Option>, +} + fn reconcile_shape( - document: &Document, shape_id: ShapeId, transform: Option, properties: Option, - metadata: Option, style: Option, parent: Option<&ShapeParent>, - anchor: Option>, created_layers: &BTreeSet, operations: &mut Vec, + document: &Document, shape_id: ShapeId, options: ReconcileShapeOptions<'_>, created_layers: &BTreeSet, + operations: &mut Vec, ) -> Result<(), EditorReconciliationError> { + let ReconcileShapeOptions { transform, properties, metadata, style, parent, anchor } = options; let shape = document .shapes .get(&shape_id) diff --git a/package.json b/package.json index 44680ac..efbfd4a 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "format:check": "pnpm -r --if-present format:check", "lint": "pnpm -r --if-present lint", "check": "pnpm bindings:check && pnpm -r --if-present check && pnpm -r --if-present typecheck && pnpm -r --if-present lint", - "test": "pnpm -r --if-present test", + "test": "pnpm wasm:build && pnpm -r --if-present test", "performance:capture": "node scripts/capture-performance.mjs --output fixtures/native/performance/rendering-budget.json", "bindings:generate": "cargo run -p inkfinite-cli --bin generate-bindings", "bindings:check": "cargo run -p inkfinite-cli --bin generate-bindings -- --check", -- 2.51.2