Skip to content

Node.js support #27

@kurtbruns

Description

@kurtbruns

Goal

Enable the library to construct scenes and serialize SVGs in Node.js without a browser. The browser remains the primary front-end for authoring and interactive use, but generation and export should not require one.

// Browser — consumer passes their container
const scene = new Scene({ root: document.getElementById('root'), width: 960, height: 540 });

// Node — no container needed
const scene = new Scene({ width: 960, height: 540 });
const svg = scene.serialize();

This unlocks scriptable SVG export, batch generation for consumer repos, and CI-based asset pipelines. The Node pipeline output (SVG → PNG via @resvg/resvg-js) should match the browser's FIGMA export output, validated by regression tests (#23).

Prerequisite

What this involves

Currently, the library produces browser-independent SVG output — but the process of getting there requires the browser. The browser resolves CSS variables, computes styles, and provides SVG geometry APIs that the export pipeline depends on. This refactor makes the library own its rendering directly, so the SVG is self-contained from construction time — no browser needed to produce the final output.

Eliminates multiple export targets

The library currently has three export targets (BROWSER, FIGMA, ILLUSTRATOR) that exist to work around differences in how each consumer implements the SVG spec:

  • FIGMA/ILLUSTRATOR: vector-effect: non-scaling-stroke isn't supported, so stroke widths are computed via getScreenCTM() and baked in
  • FIGMA: MathJax SVG output needs specific scaling adjustments
  • BROWSER: Different MathJax rendering hacks

If the library owns its rendering fully and produces SVGs where all styles are explicit attributes, stroke widths are pre-computed, MathJax is flattened with explicit sizing, and markers are embedded as paths — then there is nothing left for Chrome, Figma, resvg, or Illustrator to disagree about. The export targets collapse into a single unambiguous output format.

Element creation (~31 files)

  • Replace direct document.createElementNS() / document.createElement() calls with a centralized factory
  • In browser: delegates to document; in Node: delegates to a lightweight DOM (e.g., linkedom)
  • Breaking change: Scene/Frame constructors no longer implicitly reach for document or #root
  • Most mechanical part of the refactor

Theming (22 files, 32 CSS variables)

  • Theme currently uses CSS custom properties resolved by the browser's CSS engine via getComputedStyle()
  • Light/dark switching works via CSSOM rule manipulation (insertRule/deleteRule)
  • Refactor: Theme object provides resolved color values directly; elements consume theme.get('blue') instead of var(--blue)
  • In the browser, CSS variables can still be maintained as a side effect for live rendering and DevTools inspection, but the library does not rely on them for export

Before:

// Theme injects CSS rules via CSSOM, elements reference CSS variables
this.backgroundRectangle.style.fill = 'var(--background)';
circle.setAttribute('fill', 'var(--blue)');
axesColor: 'var(--font-color-subtle)',

After:

// Theme object provides resolved values directly
const theme = Theme.getInstance();
this.backgroundRectangle.setAttribute('fill', theme.get('background'));
circle.setAttribute('fill', theme.get('blue'));
axesColor: theme.get('font-color-subtle'),

TeX/MathJax

  • Currently loads MathJax as a browser global and calls MathJax.tex2svg() against the browser DOM
  • Uses getBoundingClientRect() for measurement and alignment after rendering
  • flattenSVG() resolves <use> references from MathJax output
  • Node.js path: mathjax-full (available since MathJax 3.0) provides a liteAdaptor that replaces the browser DOM entirely — tex2svg works in Node with no browser, jsdom, or headless Chrome needed. The SVG output includes computed width, height, and vertical-align from MathJax's internal font metrics, so getBoundingClientRect() should not be needed
  • Dependency change: Replace the mathjax peer dependency with mathjax-full as a regular dependency. mathjax-full works in both browser and Node (it's the superset), and making it a regular dependency means consumers don't need to think about MathJax at all — they just call new Tex(...) and the library handles the rest. Conditional exports and tree-shaking keep the browser bundle lean.
  • Regression tests (Set up Playwright-based visual regression tests #23) would verify that Node-rendered TeX matches browser-rendered TeX

Tricky parts

Arrows/Markers

  • embedMarkers() uses getTotalLength() and getPointAtLength() to position arrows along paths — browser SVG geometry APIs with no pure-JS equivalent
  • updateArrow() uses document.getElementById() to find markers in the global DOM
  • Marker fill defaults to var(--font-color), passed explicitly by callers

SVG geometry APIs

  • getTotalLength() / getPointAtLength() — path measurement for marker positioning
  • getScreenCTM() — transformation matrix for stroke width adjustment (currently needed per export target, eliminated if strokes are pre-computed)
  • getBoundingClientRect() — element measurement (partially eliminated by MathJax's built-in metrics)
  • These need either a path math library or acceptance that some features stay browser-dependent

getScreenCTM() / createSVGPoint() / matrixTransform() — Screen ↔ SVG coordinate conversion

  • Used across Grid, GridAlt, PlotGridBased, PlotOld, Control, and GridPoint to convert mouse/touch coordinates into SVG-space for drag interactions
  • The pattern is: createSVGPoint() → set .x/.y.matrixTransform(svg.getScreenCTM().inverse())
  • Inherently browser-interactive — stays browser-only, but won't work with a lightweight DOM shim since getScreenCTM() requires layout
  • createSVGPoint() is a deprecated browser API that could be replaced with {x, y} + manual matrix math to decouple from the SVG DOM

DOMParser / parseSVG()

  • src/util/svg.ts uses new DOMParser() to parse SVG strings — needs a Node equivalent (linkedom provides this)

getEncompassingBoundingClientRectangle()

  • src/util/svg.ts iterates elements calling getBoundingClientRect() and constructs a new DOMRect() — both are browser APIs with no Node equivalent

Consumer repo impact

Checked wumbo-site-images and wumbo-inventing-complex-numbers. Consumer code is clean — it mostly works through library abstractions (ScenePlayer, Plot, CoordinateSystem, .frame.path(), .frame.tex(), etc.). The library is the bottleneck; once it supports Node, consumers need only mechanical updates:

  • var(--blue)theme.get('blue') (~7 files in wumbo-site-images set .style.fill/.style.stroke with CSS variables)
  • document.createElementNS() → element factory (2 call sites)
  • bundle(root, ExportTarget.FIGMA)scene.serialize() (export targets collapse)
  • forceMode/restoreMode dance → pass 'light' or 'dark' to serialize()

Approach

Incremental — each piece can be tackled independently, validated by regression tests from #23. Sub-issues will be created as work begins. Some parts (path geometry) may remain browser-dependent longer than others.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions