Relev Works
Saraswati Engine - Extracting a Design Tool from Fabric.js

Avnac Studio

April 2026

4 min read

Principal Engineer (contract)

Saraswati Engine - Extracting a Design Tool from Fabric.js

Avnac Studio's desktop port was blocked by Fabric.js acting as the document model, not just the renderer. We extracted scene logic into a renderer-agnostic engine without pausing the product.

Incremental

Migration

Deterministic

Undo/redo

Swappable

Renderers

Built the Saraswati rendering engine for Avnac Studio, an open-source desktop design tool.

Read the code →

Written by

Relev Works EngineeringPrincipal Engineer

At a glance

Problem

Porting Avnac Studio from web to desktop could not ship while Fabric.js remained the document model. Undo/redo was unreliable, coordinates diverged from stored values, and every save carried renderer-specific JSON the product did not own.

Approach

Introduced Saraswati: scene as a plain value, all changes through commands, render commands as a swappable contract, and Avnac-owned document format. Migrated incrementally with a Fabric-to-scene adapter so shipping continued.

Result

Desktop shipped with multi-page workspaces and reliable undo/redo. The product can adopt new renderers without rewriting editor logic. Open-source code gives technical buyers direct visibility before a call.

Saraswati renderer-agnostic architectureSCENEplain valueCOMMANDSundo / redoRENDERERswappable
Architecture overview

Write-up

Context

The web product used Fabric.js directly on the canvas. The desktop rewrite (Wails + Go + React) needed OS-native file storage, lighter binaries, and a document format that could outlive any one renderer.

Leadership had two options: embed Fabric again, or decouple scene logic from rendering. They chose extraction because:

  • IndexedDB on web would not translate to per-workspace folders on disk.
  • Fabric ties scene state to its runtime. Any renderer change risked breaking undo and saved files.
  • A design tool that lasts needs to swap Canvas2D for WebGL or native compositing without rewriting the editor.

The product could not pause for a rewrite. Migration had to be incremental.

The dependency problem

Fabric was not failing because it is a bad library. It was failing because it sat too deep in the stack:

  1. Serialization was Fabric JSON. Saves included ActiveSelection, corner styles, and other internals the product did not define.
  2. Undo was post-hoc. Diffs were recorded after mutations. Reversing state was guesswork.
  3. Coordinates were ambiguous. A shape at (400, 300) on screen could store as (500, 400) depending on origin settings. Three parts of the codebase computed bounds differently.
  4. Tests needed a browser. You could not validate scene logic without a live canvas.
  5. Export needed Fabric running. Even PNG export required the full runtime.

The coordinate bug made the cost visible: migrated positions were off by half the shape dimensions until we normalized Fabric's origin model to a top-left scene space. That fix only worked because we stopped treating Fabric as source of truth.

// Normalize Fabric origin to scene top-left
const x =
  fabricObj.left - (fabricObj.originX === "center" ? fabricObj.width / 2 : 0);
const y =
  fabricObj.top - (fabricObj.originY === "center" ? fabricObj.height / 2 : 0);

Constraints

  • Desktop had to keep shipping during migration.
  • React UI needed predictable scene updates.
  • File format had to survive cold storage and future renderers.
  • Canvas performance could not regress from per-frame allocation churn.
  • Team size did not allow a big-bang rewrite.

Approach

Treat the scene as a value, not a runtime.

1. Plain scene graph

SaraswatiScene is a serializable object: nodes, transforms, fills. No Fabric instances, no hidden renderer state.

type SaraswatiRectNode = {
  id: string;
  type: "rect";
  x: number;
  y: number;
  width: number;
  height: number;
  rotation: number;
  scaleX: number;
  scaleY: number;
  fill: BgValue;
  stroke: BgValue | null;
  strokeWidth: number;
};

One pure getNodeBounds() drives hit testing, inspector values, and selection overlays.

2. Commands, not mutations

Every edit is a command applied by a reducer. The scene is immutable per step.

interface SaraswatiCommand {
  type: "MOVE_NODE" | "ROTATE_NODE" | "RESIZE_NODE" | "ADD_NODE" | ...;
  id: string;
  payload?: unknown;
}
 
function applyCommand(scene: Scene, command: Command): Scene {
  // Returns new scene. Original unchanged.
}

Undo replays commands in reverse. Drag, keyboard, inspector, and import all funnel through the same path.

3. Render commands as a boundary

The engine emits drawing instructions, not canvas calls:

type SaraswatiRenderCommand =
  | { type: "rect"; x: number; y: number; width: number; height: number; fill: BgValue; ... }
  | { type: "text"; text: string; fontSize: number; fontFamily: string; ... }
  | { type: "image"; src: string; cropX: number; cropY: number; ... }
  | { type: "line"; x1: number; y1: number; x2: number; y2: number; ... }
  | { type: "__artboard__"; width: number; height: number; background: string };

Canvas2D implements the contract today. A future WebGL or native backend implements the same list without importing scene modules.

4. Avnac-owned document format

Saved files are Saraswati scenes, not Fabric exports:

{
  "artboard": { "width": 1920, "height": 1080, "background": "#ffffff" },
  "nodes": {
    "rect-1": {
      "type": "rect",
      "x": 100,
      "y": 100,
      "width": 200,
      "height": 150,
      "fill": { "r": 255, "g": 0, "b": 0 }
    }
  }
}

No renderer-specific fields. Intent is explicit.

5. Incremental migration

  1. Introduced a scene-workspace/ boundary where legacy Fabric and Saraswati coexisted.
  2. Swapped the visible surface to Saraswati progressively.
  3. Shipped a read-only adapter from legacy Fabric JSON to SaraswatiScene.
  4. New saves are Saraswati documents. Fabric shapes are not carried forward.

What changed

AreaBeforeAfter
Document modelFabric objectsPlain SaraswatiScene
Undo/redoFragile diffsCommand replay
MutationsScattered handlersCentral reducer
RenderingFabric-onlySwappable backends
TestsDOM + canvas requiredPure functions in Node
File formatFabric JSONAvnac-owned schema
BoundsThree divergent implementationsOne pure function

Outcome

  • Undo/redo is reliable under normal editing load.
  • Scene logic is renderer-agnostic. Canvas2D is the current backend, not a permanent lock-in.
  • Interaction math and commands run in Node tests without a browser.
  • Desktop shipped with multi-page workspaces and vector boards.
  • Render commands enable headless export without a visible canvas.
  • Command streams serialize cleanly for future collaboration work.

Key takeaway

When a third-party library becomes your document model, you inherit its bugs, its JSON, and its upgrade path. Extracting scene logic costs upfront — commands, render boundaries, migration adapters — but pays back in reliable undo, testable math, and renderers you can swap without betting the product again.

Saraswati engine

For a long-lived design tool, that extraction is not polish. It is how you keep shipping while the platform moves.

Technical appendix

Technical problem

Fabric coupled scene state, hit testing, serialization, and rendering. Direct mutations scattered across handlers. Tests required a live DOM. Export and undo depended on a running Fabric canvas.

Technical approach

Introduced Saraswati: scene as a plain value, all changes through commands, render commands as a swappable contract, and Avnac-owned document format. Migrated incrementally with a Fabric-to-scene adapter so shipping continued.

Technical outcome

Deterministic command replay for undo. Pure bounds and interaction math testable in Node. Canvas2D renderer today; WebGL or native backends later without touching scene modules.