# Architecture

## Overview

`meo-canvas` renders a declarative component tree into a raster image. It combines a **flexbox layout engine** (yoga-layout) with a **2D drawing library** (meo-skia-canvas), plus an optional **worker-thread pool** (Comlink) for non-blocking server-side rendering.

```
User Code
  │
  ▼
Root({ width, children: [...] })
  │
  ▼
┌─────────────────────────────────────────┐
│ RootNode.render()                       │
│                                         │
│  1. Register fonts (serialized)         │
│  2. Load images (concurrency-limited)    │
│  3. Layout tree (yoga-layout)           │
│  4. Draw tree (meo-skia-canvas)             │
│  5. Export (PNG/JPEG/PDF)               │
└─────────────────────────────────────────┘
         │                    │
    workerMode: false    workerMode: true
         │                    │
         ▼                    ▼
   Direct render      ComlinkPool.render()
   on main thread         │
                          ▼
                    render.worker.ts
                    RootNode.render()
                          │
                          ▼
                    Buffer returned
                    to main thread
```

## Directory Structure

```
src/
├── canvas/              # Core rendering nodes
│   ├── canvas.type.ts   # TypeScript types & interfaces
│   ├── canvas.helper.ts # Shared drawing utilities (borders)
│   ├── canvas.engine.ts # Canvas construction, and mirroring engine options onto an offscreen
│   ├── root.canvas.ts   # Root() entry point & rendering pipeline
│   ├── page.plan.ts     # Page-count resolution & page builder invocation
│   ├── layout.canvas.ts # Box, Column, Row (flexbox via yoga-layout)
│   ├── text.canvas.ts   # Text with inline HTML-like styling
│   ├── text.metrics.ts  # Cached text measurement, invalidated when fonts register
│   ├── gradient.canvas.ts # Gradient construction, shared by backgrounds and masks
│   ├── mask.canvas.ts   # Mask geometry, and gradient masking through an offscreen
│   ├── image.canvas.ts  # Image loading, caching, fit/position
│   ├── image.frames.ts  # Frame extraction from animated sources
│   ├── path.canvas.ts   # Arbitrary shapes from SVG path data
│   ├── chart.canvas.ts  # Bar, Line, Pie, Doughnut charts
│   └── grid.canvas.ts   # CSS Grid-like layout
├── worker/                # Worker thread infrastructure
│   ├── comlink.pool.ts    # Fixed-size worker pool with queue
│   ├── comlink.setup.ts   # Comlink adapter for worker_threads
│   ├── canvas-handlers.ts # Allowlisted canvas methods callable from the pool
│   ├── sync.bridge.ts     # Blocking channel for the *Sync export methods
│   ├── render.worker.ts   # Worker entry point (exposes WorkerAPI)
│   └── worker.types.ts    # Worker message types
├── animate/                 # Sampling helpers for paged renders — no drawing of their own
│   ├── easing.ts            # Named easings, cubic-bezier and steps
│   ├── interpolate.ts       # lerp, mapRange, and interpolation across types
│   ├── color.ts             # Colour parsing and mixing, in every notation the engine takes
│   ├── spring.ts            # Springs solved in closed form rather than integrated
│   ├── track.ts             # One value over time, from keyframes
│   ├── sequence.ts          # Tracks run one after another
│   └── parallel.ts          # Tracks sampled together, optionally staggered
├── util/
│   ├── disk.cache.ts    # Disk-based image cache for re-decodes
│   └── http.options.ts  # Request options for fetched image sources
├── constant/
│   └── common.const.ts  # Style enums (Border), Yoga constants re-export
├── types/               # Ambient declarations for optional peers and DOM shims
└── index.ts             # Public API barrel export
```

## Node Hierarchy

```
BoxNode                     (every node is one — flexbox, margins, padding, borders, background)
  ├── ColumnNode            (shorthand: flexDirection = 'column')
  │     └── RootNode        (entry point — font registration, image loading, render)
  ├── RowNode               (shorthand: flexDirection = 'row')
  │     └── GridNode        (CSS Grid container)
  ├── GridItemNode          (grid cell, spanning columns or rows)
  ├── TextNode              (rich text with <color>, <weight>, <size>, <b>, <i>)
  ├── ImageNode             (URL / file / buffer, objectFit, objectPosition, saturate)
  ├── PathNode              (arbitrary shapes from SVG path data)
  └── ChartNode             (Bar / Line / Pie / Doughnut)
```

There is no abstract base above `BoxNode`: everything that draws is a box first, which is what lets
`mask`, `opacity`, `boxShadow`, `overflow` and the rest apply to every component without any of them
knowing about it.

## Rendering Pipeline

### Phase 1 — Font Registration

Fonts are registered into `meo-skia-canvas`'s `FontLibrary` with a **serialization lock** (`_fontRegistrationLock`). Only new fonts (not already in `registeredFonts`) trigger `FontLibrary.use()`. The lock prevents concurrent `Root()` calls from racing on font registration.

### Phase 2 — Image Loading

All `ImageNode` instances in the tree are collected via BFS. Images from URLs or local paths are fetched concurrently (default: 5 at a time, configurable via `imageConcurrency`). A `RenderImageCache` deduplicates identical `src` + `color` combinations. The cache spans the whole render rather than one page, so a source referenced by every frame of an animation is fetched once. Optional `useDiskCache` writes fetched images to disk for faster re-decode within the same render pass.

### Phase 3 — Layout

Each node creates a `yoga-layout` node and wires up flexbox properties (`width`, `height`, `flexDirection`, `justifyContent`, `alignItems`, `gap`, `margin`, `padding`, `border`). The tree is calculated top-down — `RootNode` calls `calculateLayout()` on the root yoga node, then each child reads its computed position/dimensions via `getComputedLayout()`.

Text is the expensive part of this phase, and most of that expense is repetition: Yoga calls a text node's measure function several times per pass while it searches for a width that fits, truncation walks a string character by character, and every page of an animation re-measures text that did not change. On a 24-line card that came to 720 measurements per page resolving to 58 distinct questions. `text.metrics.ts` answers repeats from a bounded LRU keyed by the string and every piece of context state that shapes it — font, letter spacing, variant, baseline, direction — so a hit is the number the renderer would have computed. Registering a font bumps an epoch that makes every earlier answer unreachable, because the same `12px Roboto` measures differently once Roboto exists.

### Phase 4 — Drawing

With layout computed, each node draws itself on the `meo-skia-canvas` context:

- **BoxNode** draws background color, background image, and borders before recursing into children
- **TextNode** parses inline HTML-like tags and draws styled text segments. Line boxes are built
  from the face's own ascent and descent rather than from the ink of the glyphs, which is what keeps
  a line from moving when it gains a descender and puts baselines within a fraction of a pixel of a
  browser's. A line carrying a `textDecoration` is drawn in one call so its rule is unbroken across
  the spaces; one carrying rich-text markup cannot be, and is drawn a word at a time
- **ImageNode** draws the loaded image with `objectFit` / `objectPosition` / `saturate`. Its
  intrinsic proportions are given to Yoga only when a dimension was left out, so an image told how
  big to be is that size rather than being reshaped to its own ratio
- **PathNode** fills and strokes SVG path data, in the node's own coordinates
- **ChartNode** draws chart elements (axes, bars, lines, pie slices)
- **GridNode** positions children in a 2D grid based on column/row definitions

Every one of those arrives through `BoxNode.render`; subclasses override `_renderContent` rather than `render` itself. That single entry is what lets `mask`, `opacity` and `dither` apply to all of them without each knowing about it.

`opacity` opens an isolated compositing layer rather than setting `globalAlpha`, because CSS fades a
subtree once rather than fading each drawing in it — otherwise two overlapping children inside a
half-transparent parent come out twice as opaque where they meet.

`dither` is context state, so it is set around the node's drawing and put back afterwards. That is
what makes it inherit: a node that says nothing leaves the context alone and draws with whatever its
nearest ancestor set, and a node that sets its own leaves its siblings untouched. A shape or path mask clips the context around the node's drawing. A gradient mask cannot — clipping is a yes-or-no test per pixel — so the node is drawn into an offscreen canvas of its own box, multiplied by the gradient's alpha with `destination-in`, and composited back. The offscreen is sized from `ctx.getTransform()`, not from the layout box, so a masked node on a 2× render is drawn at the same resolution as everything beside it.

### Phase 5 — Export

The canvas is handed back unencoded; the caller chooses a format. `toBuffer(format, options)` reaches the matching `meo-skia-canvas` encoder on the worker (or main thread) — `png`, `jpg`, `webp`, `avif`, `tiff`, `bmp`, `ico`, `svg`, `pdf`, `gif`, `apng` and `raw`.

Export signatures are split by format: `fps`, `loop` and `frameDelays` are accepted for `gif` and `apng` and rejected for everything else, which turns a documented runtime `TypeError` into a compile error.

## Paged Rendering

A page is a frame for `gif` and `apng`, a sheet for `pdf` and `tiff`, and a size for `ico`. Passing a function as `children` renders a sequence — one page per call — and `page.plan.ts` owns the arithmetic:

- `resolvePageCount()` turns `pages` or `duration * fps` into a count and rejects every contradictory combination. It runs at runtime because the type system cannot reach JavaScript callers, `as any`, or props arriving over the worker boundary; the `Root` overloads reject the same shapes at compile time.
- `pageInfoAt()` builds the `PageInfo` a builder receives — `index`, `count`, `progress` for one-shot interpolation, `cycle` for anything that repeats, and `time` for physics integration. `progress` spans the sequence inclusively and `cycle` half-open, which is the difference between an animation that ends on its final value and one that closes back onto its first.
- `planPages()` runs the builder once per page, sequentially, so page order is the array order and a data-loading builder does not burst every request at once.

`renderPages()` then builds **one `RootNode` per page**. The tree is constructed in the node's constructor and freed once drawn, and a freed Yoga node cannot be laid out again — so pages cannot share a node. What is expensive is shared instead: one image cache and one font registration for the whole sequence. Each page's tree is released in a `finally` as soon as it is drawn, so memory stays flat across a long sequence rather than holding every page's layout at once.

The first page owns the canvas; each later one is appended with `newPage(width, height)`.

## Worker Architecture

### Why workers?

Server-side canvas rendering is CPU-heavy. Running it on the main thread blocks the event loop. Workers move rendering off-thread, keeping the main thread responsive.

### Pool design

`ComlinkPool` maintains a fixed pool of N workers (default: `cpus() - 1`). Each worker wraps a `WorkerAPI` object exposed via Comlink.

**Idle path**: If a worker is free, the render occurs immediately.

**Queued path**: If all workers are busy, tasks queue in FIFO order. When a worker finishes, `drain()` dequeues the next task.

### Function serialization

Some props contain callback functions (e.g. `ChartOptions.renderValueItem`). Functions can't be `structuredClone`'d. The pool uses a **sentinel protocol**:

1. `extractFunctions()` replaces every function with `{ __comlinkFnId: number }` and stores the original in a `Map`.
2. A single `Comlink.proxy()` callback is created that dispatches `{__comlinkFnId, args}` back to the main thread.
3. On the worker side, `restoreFunctions()` replaces sentinels with async proxy calls.

This adds 1 additional round-trip per function call, but avoids the complexity of per-function proxies.

### Lifecycle

- `Root({ workerMode: false })` — renders on the main thread, returns a bare `Canvas`. No `.release()`.
- `Root({ workerMode: true })` — renders in a worker, returns a `WorkerCanvas` proxy. Calls to `.toBuffer()`, `.toURL()`, `.toFile()` are proxied back to the worker via `callOnCanvas()`.
- `.release()` — tells the worker to free the canvas from its internal `Map`. Optional (garbage collection via `FinalizationRegistry`).
- `terminate()` — kills all worker threads. The pool lazily re-initializes on the next `Root()` call.

## Key Design Decisions

### Factory functions, not JSX

Each component is a plain function call: `Box({ ... })`, `Text('hello', { ... })`. No transpilation step needed. This keeps the API simple for server-side use cases.

### Yoga-based layout

Flexbox via yoga-layout gives predictable, CSS-like layouts without a browser DOM. Each node gets a yoga node; the parent calculates layout; children read their computed positions.

### `CanvasElement` discriminated union

For serialization across the worker boundary, the tree is convertible to a `CanvasElement[]` array — a discriminated union keyed by `__type`. This is the transport format between main thread and worker.

It also has no function member, which is what lets `typeof children === 'function'` tell a page builder from ordinary children without ambiguity.

### Page builders resolve on the calling thread

A worker render resolves the builder before dispatch and ships plain data. A function cannot be structured-cloned, and while the pool's sentinel protocol could marshal it, that would cost a round trip per page — and a tree returned back through the callback proxy would hit `DataCloneError`, since the `Image` factory's `Omit` is type-level only and its callbacks survive at runtime.

### No garbage collected canvas in workers

Workers hold canvases in a `Map<number, Canvas>`. The main-thread `FinalizationRegistry` calls `releaseCanvas()` when a `WorkerCanvas` proxy is GC'd. This is a safety net — users should call `.release()` explicitly for predictable cleanup.

### Font registration mutex

A promise-based lock (`_fontRegistrationLock`) serializes `FontLibrary.use()` calls. Without this, concurrent `Root()` calls could both call `FontLibrary.use()` for the same font, causing a meo-skia-canvas error.
