Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 'latest'

- name: Setup pnpm
uses: pnpm/action-setup@v4

Expand Down
35 changes: 33 additions & 2 deletions Readme.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,34 @@
# layout svg
# Modular SVG Layout

`modular-svg` is a small TypeScript library for generating SVG diagrams from a
declarative scene description. It takes inspiration from the
[Bluefish](https://bluefishjs.org/) project where a scene is expressed as a tree
of primitives (such as `StackH` or `Align`) that impose layout relations between
shapes. The library parses a JSON version of this syntax, solves the layout and
produces an SVG string.

## Usage

```bash
bun ./bin/modular-svg examples/planet.json planet.svg
```

The CLI reads a JSON scene either from a file or from `stdin` and writes the
resulting SVG to `stdout` or to the file specified as the second argument.

## Design

The solver compiles every node into a flat array of variables representing its
`x`, `y`, `width` and `height`. Layout relations are implemented as operators.
Each operator reads from the current state and writes its suggestions into the
next state. By repeatedly applying all operators in sequence (a damped Picard
iteration) the layout converges towards a fixed point that satisfies all
relations. The process starts by parsing the JSON scene into a flat array of
numeric variables for every node. Operators such as `StackH`, `Align` or
`Distribute` read these variables and suggest new positions in a fresh array.
After each pass the solver mixes the new values with the previous ones using a
damping factor. Iteration stops once the maximum change falls below a small
threshold or a safety limit is hit. This local fixed‑point approach keeps the
solver fast—time grows roughly linearly with scene size—and stable even when
relations overlap or form cycles.

A modular layout engine for SVG inspired by Bluefish
43 changes: 43 additions & 0 deletions bin/modular-svg
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#!/usr/bin/env bun
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { buildSceneFromJson, solveLayout, layoutToSvg, validate } from "../src/index.ts";


async function readInput(arg) {
if (arg && arg !== "-") {
return readFileSync(arg, "utf8");
}
if (process.stdin.isTTY) {
console.error("Usage: modular-svg <scene.json|-> [output.svg|-]");
process.exit(1);
}
let data = "";
for await (const chunk of process.stdin) data += chunk;
return data;
}

try {
const inputArg = process.argv[2];
const raw = await readInput(inputArg);
const data = JSON.parse(raw);
try {
validate(data);
} catch (err) {
console.error("Invalid scene:", err);
process.exit(1);
}
const scene = buildSceneFromJson(data);
const layout = solveLayout(scene);
const svg = layoutToSvg(layout, scene.nodes);
const out = process.argv[3];
if (out && out !== "-") {
writeFileSync(out, svg);
} else {
process.stdout.write(svg);
}
} catch (err) {
console.error(err);
process.exit(1);
}
22 changes: 22 additions & 0 deletions bin/modular-svg.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { spawnSync } from "node:child_process";
import { readFileSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";

const bin = join(__dirname, "modular-svg");
const example = join(__dirname, "../examples/stack.json");

describe("CLI", () => {
it("runs with stdin", () => {
const json = readFileSync(example, "utf8");
const out = join(__dirname, "tmp.svg");
const { status } = spawnSync(bin, ["-", out], {
input: json,
encoding: "utf8",
});
expect(status).toBe(0);
const svg = readFileSync(out, "utf8");
expect(svg.startsWith("<svg")).toBe(true);
unlinkSync(out);
});
});
41 changes: 41 additions & 0 deletions examples/align-distribute.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"type": "Group",
"id": "grp",
"children": [
{
"type": "Rect",
"id": "A",
"props": { "width": 40, "height": 20, "x": 0, "y": 0 }
},
{
"type": "Rect",
"id": "B",
"props": { "width": 60, "height": 30, "x": 100, "y": 50 }
},
{
"type": "Rect",
"id": "C",
"props": { "width": 50, "height": 40, "x": 200, "y": 20 }
},
{
"type": "Align",
"id": "align1",
"props": { "axis": "y", "alignment": "top" },
"children": [
{ "type": "Ref", "target": "A" },
{ "type": "Ref", "target": "B" },
{ "type": "Ref", "target": "C" }
]
},
{
"type": "Distribute",
"id": "dist1",
"props": { "axis": "x" },
"children": [
{ "type": "Ref", "target": "A" },
{ "type": "Ref", "target": "B" },
{ "type": "Ref", "target": "C" }
]
}
]
}
86 changes: 86 additions & 0 deletions examples/planet.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
{
"type": "Group",
"id": "scene",
"children": [
{
"type": "Background",
"id": "bg",
"props": { "padding": 10 },
"children": [
{
"type": "StackH",
"id": "planets",
"props": { "spacing": 50, "alignment": "centerY" },
"children": [
{
"type": "Circle",
"id": "mercury",
"props": {
"r": 15,
"fill": "#EBE3CF",
"stroke-width": 3,
"stroke": "black"
}
},
{
"type": "Circle",
"id": "venus",
"props": {
"r": 36,
"fill": "#DC933C",
"stroke-width": 3,
"stroke": "black"
}
},
{
"type": "Circle",
"id": "earth",
"props": {
"r": 38,
"fill": "#179DD7",
"stroke-width": 3,
"stroke": "black"
}
},
{
"type": "Circle",
"id": "mars",
"props": {
"r": 21,
"fill": "#F1CF8E",
"stroke-width": 3,
"stroke": "black"
}
}
]
}
]
},
{
"type": "Align",
"id": "alignLabel",
"props": { "axis": "x", "alignment": "center" },
"children": [
{ "type": "Text", "id": "label", "props": { "text": "Mercury" } },
{ "type": "Ref", "target": "mercury" }
]
},
{
"type": "Distribute",
"id": "distVertical",
"props": { "axis": "y", "spacing": 60 },
"children": [
{ "type": "Ref", "target": "label" },
{ "type": "Ref", "target": "mercury" }
]
},
{
"type": "Arrow",
"id": "arrow1",
"children": [
{ "type": "Ref", "target": "label" },
{ "type": "Ref", "target": "mercury" }
]
}
]
}
10 changes: 10 additions & 0 deletions examples/stack.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"type": "StackV",
"id": "stack",
"props": { "spacing": 10, "alignment": "centerX" },
"children": [
{ "type": "Rect", "id": "A", "props": { "width": 100, "height": 50 } },
{ "type": "Rect", "id": "B", "props": { "width": 80, "height": 80 } },
{ "type": "Rect", "id": "C", "props": { "width": 120, "height": 30 } }
]
}
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,15 @@
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "2.1.1",
"@types/node": "^24.0.13",
"fast-check": "^3.23.2",
"husky": "^9.1.7",
"typescript": "^5.8.3",
"vitest": "^3.2.4"
},
"packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0"
"packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0",
"dependencies": {
"ajv": "^8.17.1"
},
"type": "module"
}
Loading