From 02c4904ae5cac43daf3127e43facb14a22e241df Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?C=C3=A9dric=20Vogel?=
<104342347+cedricvogel-leo@users.noreply.github.com>
Date: Fri, 6 Nov 2026 14:57:32 +0100
Subject: [PATCH 1/2] Static tree pictures: renderTreeSvg and glpython-tree
(P43)
A list that reads as a tree, drawn as an SVG file in Node without a
browser: the runtime's tree rule, treeLayout's positions and TreeView's
look on a light card; marks for subtrees; byte-identical output; a
with the list. Parses Python's literal syntax, writes leaves
with Python's repr. Built as its own entry (glpython-editor/tree-svg,
no React or Monaco), with the command glpython-tree; the peer
dependencies are optional, so a repo using only this doesn't get React.
Checks: test:svg (snapshots, positions, CPython's repr, errors, the
command).
Co-Authored-By: Claude Opus 5.5
---
.github/workflows/ci.yml | 3 +
CLAUDE.md | 3 +
README.md | 38 ++
bin/glpython-tree.mjs | 58 +++
package.json | 17 +-
scripts/check-tree-svg.mjs | 121 +++++++
.../tree-svg/2-plus-3-in-klammern-mal-4.svg | 18 +
scripts/tree-svg/2-plus-3-mal-4.svg | 18 +
scripts/tree-svg/breit.svg | 30 ++
scripts/tree-svg/f-1-2.svg | 12 +
scripts/tree-svg/marked-root.svg | 18 +
scripts/tree-svg/marked-subtree.svg | 18 +
scripts/tree-svg/minus-1-plus-2.svg | 15 +
scripts/tree-svg/single-value.svg | 6 +
scripts/tree-svg/uneven-both-sides.svg | 36 ++
scripts/tree-svg/uneven-to-the-left.svg | 30 ++
scripts/tree-svg/x-plus-1.svg | 15 +
src/treeSvg.ts | 333 ++++++++++++++++++
vite.config.ts | 6 +-
19 files changed, 792 insertions(+), 3 deletions(-)
create mode 100755 bin/glpython-tree.mjs
create mode 100644 scripts/check-tree-svg.mjs
create mode 100644 scripts/tree-svg/2-plus-3-in-klammern-mal-4.svg
create mode 100644 scripts/tree-svg/2-plus-3-mal-4.svg
create mode 100644 scripts/tree-svg/breit.svg
create mode 100644 scripts/tree-svg/f-1-2.svg
create mode 100644 scripts/tree-svg/marked-root.svg
create mode 100644 scripts/tree-svg/marked-subtree.svg
create mode 100644 scripts/tree-svg/minus-1-plus-2.svg
create mode 100644 scripts/tree-svg/single-value.svg
create mode 100644 scripts/tree-svg/uneven-both-sides.svg
create mode 100644 scripts/tree-svg/uneven-to-the-left.svg
create mode 100644 scripts/tree-svg/x-plus-1.svg
create mode 100644 src/treeSvg.ts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e4d85c5..4d0a20d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -30,6 +30,9 @@ jobs:
- run: npm run build:embed
# Builds the library again and checks the embed protocol against dist/.
- run: npm run test:protocol
+ # The static tree pictures (renderTreeSvg, glpython-tree) against their
+ # snapshots, treeLayout's positions and CPython's repr, with that build.
+ - run: npm run test:svg
# The hints' wording in German and English, against that build.
- run: npm run test:hints
# The runtime under CPython: the visualizer's columns, docstrings,
diff --git a/CLAUDE.md b/CLAUDE.md
index fe81be5..a8d3908 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -18,6 +18,7 @@ npm run test:hints # the error hints' wording (de/en) against the built dist
npm run test:console # the console in Chrome with Pyodide, against dist-embed/ (run build:embed first)
npm run test:layout # the trees' layout (src/treeLayout.ts) against d3-flextree (a devDependency, only for this check)
npm run test:tree # the visualizer's mode «Baum» (src/treeMode.ts) on traces of the runtime under CPython
+npm run test:svg # the static tree pictures (src/treeSvg.ts, bin/glpython-tree.mjs) against scripts/tree-svg/ (-- --update after a deliberate change)
npm run build:embed # builds the iframe app + demo page into dist-embed/ (plus embed/public/: coi-serviceworker.js)
npm run preview:embed # serves dist-embed/ with COOP/COEP headers; open /demo.html
```
@@ -26,6 +27,8 @@ npm run preview:embed # serves dist-embed/ with COOP/COEP headers; open /demo.h
There is no unit test framework. The automated checks are `test:protocol` (plain `node:assert` against the built `dist/index.js`; add protocol validation cases there) and `test:runtime` (plain Python scripts that exec `src/runtime/` under CPython 3.12, Pyodide's version; add runtime regression cases there). `.github/workflows/ci.yml` runs lint, both builds and the checks on every pull request and on pushes to `main`. The one browser check is `test:console` (`scripts/check-console-browser.mjs`): `playwright-core` drives the installed Chrome (GitHub's runner has one) against `vite preview` of `dist-embed/`, through the embed protocol, with Pyodide from its CDN; add browser regression cases there. Everything else that needs Pyodide in a browser (the worker, most of the UI) is not checked automatically. `npm run lint` has one known warning in `GlPythonWorkspace.tsx` (`react(immutability)` on the `cursor` variable in `Frame`).
+**Static tree pictures.** `src/treeSvg.ts` (`renderTreeSvg`) draws a list that reads as a tree as an SVG for a compile step in Node, built as its own entry `dist/tree-svg.js` (export `glpython-editor/tree-svg`, no React or Monaco) and run by `bin/glpython-tree.mjs`. It parses Python's literal syntax and writes leaves with Python's repr (`test:svg` compares with CPython), uses `layoutTree` and copies `TreeView`'s look with the light tokens as literals (a static file can't read `--glp-*`); keep the two in step. Output must stay byte-identical for the same input: fixed precision, no ids, no dates. The peer dependencies are optional so that a repo installing it only for this doesn't get React.
+
**Licence.** MIT (`LICENSE`). `THIRD_PARTY_NOTICES.md` lists the third-party code in the repo and in the embed's bundle, with license texts (the build strips license comments, so `build:embed` copies `LICENSE` and the notices into `dist-embed/`). When a bundled dependency is added or changed, or code is copied in, update it: the embed's bundled packages are the `node_modules` paths in `dist-embed/assets/*.js.map`.
**Releases.**
diff --git a/README.md b/README.md
index 6e51db3..e9d6400 100644
--- a/README.md
+++ b/README.md
@@ -237,6 +237,44 @@ tidy-tree algorithm for nodes of different widths (`src/treeLayout.ts`,
checked against [d3-flextree](https://github.com/Klortho/d3-flextree)), in
characters of the code font, so a tree scales with the text around it.
+### Static tree pictures (`glpython-editor/tree-svg`, `glpython-tree`)
+
+For slides, handouts and quiz options, a list that reads as a tree can also be
+drawn as a static SVG file, without a browser: the same tree rule and layout as
+`listTree`, the same look (solid node boxes, dashed leaf boxes, the light
+theme's colours) on a light card with rounded corners, so the picture reads the
+same on a light and a dark page.
+
+```js
+import { renderTreeSvg } from 'glpython-editor/tree-svg'
+
+const svg = renderTreeSvg("['+', 2, ['*', 3, 4]]", { mark: ["['*', 3, 4]"], lang: 'de' })
+```
+
+```sh
+npx glpython-tree "['+', 2, ['*', 3, 4]]" --mark "['*', 3, 4]" -o baum.svg
+```
+
+- **Input:** the list as Python writes it: numbers (also negative and
+ decimal), strings in single or double quotes with Python's escapes, `True`,
+ `False`, `None`, lists and tuples. Leaves read as Python's repr writes them
+ (`2.50` → `2.5`, `"it's"`). A single value is a tree of one leaf.
+- **`mark`:** subtrees to highlight, written the same way (the accent colour on
+ their boxes and the edges between them; the whole tree marks all of it). A
+ mark that isn't a subtree, or occurs more than once, is an error.
+- **`fontSize`** in pixels (default 16), **`lang`** (`en`, `de`) for the
+ description. The picture is `role="img"` with a ``: the list, and
+ «– markiert: …» / «– marked: …» for the marks.
+- **Text and errors:** widths are in characters of the code font, as on screen;
+ each text is fitted to its characters' width (`textLength`), so a monospace
+ font with other proportions doesn't overflow its box. Invalid input throws a
+ `TreeSvgError` naming the position; `glpython-tree` prints it and exits
+ with 1 (2 for wrong usage). Without a list it reads one from stdin.
+- **Deterministic:** the same input and options always give the same bytes.
+
+Long labels aren't shortened, unlike the visualizer's trees.
+`npm run test:svg` checks the pictures against `scripts/tree-svg/`.
+
### Copying the evaluation as a → chain
The **Copy → chain** item in the toolbar's **⋯ Options** menu copies the evaluation of the statement
diff --git a/bin/glpython-tree.mjs b/bin/glpython-tree.mjs
new file mode 100755
index 0000000..61a90b5
--- /dev/null
+++ b/bin/glpython-tree.mjs
@@ -0,0 +1,58 @@
+#!/usr/bin/env node
+// Draws a list that reads as a tree as an SVG picture (see src/treeSvg.ts):
+//
+// glpython-tree "['+', 2, ['*', 3, 4]]" --mark "['*', 3, 4]" -o baum.svg
+//
+// Without -o, the SVG goes to stdout; without a list, it is read from stdin.
+// Options: --mark (repeatable), --lang en|de (the picture's
+// description), --font-size . Exit code 1 on invalid input or a mark
+// that isn't exactly one subtree, 2 on wrong usage.
+import { readFileSync, writeFileSync } from 'node:fs'
+import { renderTreeSvg, TreeSvgError } from '../dist/tree-svg.js'
+
+const usage = 'Usage: glpython-tree "" [--mark ""]... [--lang en|de] [--font-size ] [-o ]'
+const args = process.argv.slice(2)
+const marks = []
+let text = null
+let output = null
+let lang = 'en'
+let fontSize
+for (let index = 0; index < args.length; index++) {
+ const arg = args[index]
+ const next = () => {
+ if (index + 1 >= args.length) {
+ console.error(`${arg} needs a value.\n${usage}`)
+ process.exit(2)
+ }
+ return args[++index]
+ }
+ if (arg === '--mark' || arg === '-m') marks.push(next())
+ else if (arg === '-o' || arg === '--output') output = next()
+ else if (arg === '--lang') lang = next()
+ else if (arg === '--font-size') fontSize = Number(next())
+ else if (arg === '-h' || arg === '--help') {
+ console.log(usage)
+ process.exit(0)
+ } else if (text === null) text = arg
+ else {
+ console.error(`Unexpected argument ${JSON.stringify(arg)}.\n${usage}`)
+ process.exit(2)
+ }
+}
+if (lang !== 'en' && lang !== 'de') {
+ console.error(`--lang must be en or de.\n${usage}`)
+ process.exit(2)
+}
+if (fontSize !== undefined && !(fontSize > 0)) {
+ console.error(`--font-size must be a positive number.\n${usage}`)
+ process.exit(2)
+}
+try {
+ const svg = renderTreeSvg(text ?? readFileSync(0, 'utf8').trim(), { mark: marks, lang, fontSize })
+ if (output) writeFileSync(output, svg)
+ else process.stdout.write(svg)
+} catch (error) {
+ if (!(error instanceof TreeSvgError)) throw error
+ console.error(error.message)
+ process.exit(1)
+}
diff --git a/package.json b/package.json
index 484766f..c39332e 100644
--- a/package.json
+++ b/package.json
@@ -10,14 +10,22 @@
"types": "./dist/src/index.d.ts",
"files": [
"dist",
+ "bin",
"THIRD_PARTY_NOTICES.md"
],
+ "bin": {
+ "glpython-tree": "./bin/glpython-tree.mjs"
+ },
"exports": {
".": {
"types": "./dist/src/index.d.ts",
"import": "./dist/index.js"
},
- "./style.css": "./dist/style.css"
+ "./style.css": "./dist/style.css",
+ "./tree-svg": {
+ "types": "./dist/src/treeSvg.d.ts",
+ "import": "./dist/tree-svg.js"
+ }
},
"scripts": {
"dev": "vite build --watch",
@@ -31,6 +39,7 @@
"test:console": "node scripts/check-console-browser.mjs",
"test:layout": "node --experimental-strip-types --no-warnings scripts/check-tree-layout.mjs",
"test:tree": "node --experimental-strip-types --no-warnings scripts/check-tree-mode.mjs",
+ "test:svg": "node --experimental-strip-types --no-warnings scripts/check-tree-svg.mjs",
"test:runtime": "python3 -B scripts/check-runtime-columns.py && python3 -B scripts/check-runtime-values.py && python3 -B scripts/check-runtime-doctests.py && python3 -B scripts/check-runtime-coverage.py && python3 -B scripts/check-runtime-calls.py && python3 -B scripts/check-runtime-hints.py && python3 -B scripts/check-runtime-fidelity.py && python3 -B scripts/check-runtime-replay.py && python3 -B scripts/check-runtime-imports.py && python3 -B scripts/check-runtime-repl.py && python3 -B scripts/check-runtime-input.py && python3 -B scripts/check-runtime-lists.py && python3 -B scripts/check-runtime-main.py && python3 -B scripts/check-runtime-trees.py && python3 -B scripts/check-runtime-box-reasons.py",
"prepare": "npm run build"
},
@@ -40,6 +49,12 @@
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
+ "peerDependenciesMeta": {
+ "@monaco-editor/react": { "optional": true },
+ "lucide-react": { "optional": true },
+ "react": { "optional": true },
+ "react-dom": { "optional": true }
+ },
"devDependencies": {
"@monaco-editor/react": "^4.7.0",
"@types/node": "^24.13.3",
diff --git a/scripts/check-tree-svg.mjs b/scripts/check-tree-svg.mjs
new file mode 100644
index 0000000..85b15f3
--- /dev/null
+++ b/scripts/check-tree-svg.mjs
@@ -0,0 +1,121 @@
+// Check of the static tree pictures (src/treeSvg.ts, P43): renderTreeSvg
+// draws the trees of the layout check (the course's parser output and two
+// uneven trees), a mark on a subtree, the whole tree marked and a single
+// value exactly as the snapshots in scripts/tree-svg/ - byte for byte, and
+// the same twice - with every box where treeLayout.ts places its node (the
+// positions TreeView uses). It reads lists as Python writes them, with
+// Python's repr for the leaves (compared with CPython's), and reports
+// invalid input, a mark that occurs twice and one that doesn't occur. The
+// glpython-tree command (bin/) is checked against the build, when there is
+// one (CI builds first).
+//
+// npm run test:svg (--update rewrites the snapshots)
+import assert from 'node:assert/strict'
+import { spawnSync } from 'node:child_process'
+import { existsSync, readFileSync, writeFileSync } from 'node:fs'
+import { layoutTree } from '../src/treeLayout.ts'
+import { parsePythonValue, pythonRepr, renderTree, renderTreeSvg } from '../src/treeSvg.ts'
+
+const update = process.argv.includes('--update')
+const snapshot = (name) => new URL(`tree-svg/${name}.svg`, import.meta.url)
+
+let failed = 0
+const check = (label, test) => {
+ try {
+ test()
+ console.log(`${label}: ok`)
+ } catch (error) {
+ failed += 1
+ console.error(`${label}: ${error.message}`)
+ }
+}
+
+const cases = [
+ ['breit', "['f', ['g', 'erstes', 'zweites'], ['h', 'drittes', 'viertes'], ['k', 'fünftes']]"],
+ ['2-plus-3-mal-4', "['+', 2, ['*', 3, 4]]"],
+ ['2-plus-3-in-klammern-mal-4', "['*', ['+', 2, 3], 4]"],
+ ['minus-1-plus-2', "['-', ['+', 1, 2]]"],
+ ['x-plus-1', "['+', ['var', 'x'], 1]"],
+ ['f-1-2', "['f', 1, 2]"],
+ ['uneven-both-sides', "['+', ['*', 2, ['-', 7, 3]], ['/', ['+', 1, 2], 3]]"],
+ ['uneven-to-the-left', "['+', ['+', ['+', 1, 2], 3], ['f', ['var', 'x']]]"],
+ ['marked-subtree', "['+', 2, ['*', 3, 4]]", { mark: ["['*', 3, 4]"], lang: 'de' }],
+ ['marked-root', "['+', 2, ['*', 3, 4]]", { mark: ["['+', 2, ['*', 3, 4]]"] }],
+ ['single-value', '42'],
+]
+for (const [name, text, options] of cases) {
+ check(`${name}: as the snapshot, the same twice`, () => {
+ const svg = renderTreeSvg(text, options)
+ assert.equal(renderTreeSvg(text, options), svg, 'two runs differ')
+ if (update || !existsSync(snapshot(name))) writeFileSync(snapshot(name), svg)
+ assert.equal(svg, readFileSync(snapshot(name), 'utf8'), `differs from scripts/tree-svg/${name}.svg (npm run test:svg -- --update after a deliberate change)`)
+ })
+ check(`${name}: the boxes where treeLayout places them`, () => {
+ const { svg, nodes } = renderTree(text, options)
+ const expected = layoutTree(nodes[0].node)
+ assert.deepEqual(nodes.map(({ key, x, depth, width }) => [key, x, depth, width]), expected.nodes.map(({ key, x, depth, width }) => [key, x, depth, width]))
+ // At 16px with characters 0.6em wide, a box starts at 12 + (x - width / 2) * 9.6 (plus half its stroke).
+ const boxes = [...svg.matchAll(/]*stroke-width="(\d)"/g)]
+ assert.equal(boxes.length, nodes.length, 'one box per node')
+ boxes.forEach((box, index) => {
+ const node = nodes[index]
+ const inset = Number(box[4]) / 2
+ assert.ok(Math.abs(Number(box[1]) - (12 + (node.x - node.width / 2) * 9.6 + inset)) < 0.01, `box ${node.key} at x ${box[1]}`)
+ assert.ok(Math.abs(Number(box[2]) - (12 + node.depth * 2.8 * 16 + inset)) < 0.01, `box ${node.key} at y ${box[2]}`)
+ assert.ok(Math.abs(Number(box[3]) - (node.width * 9.6 - 2 * inset)) < 0.01, `box ${node.key} ${box[3]} wide`)
+ })
+ })
+}
+
+check('the picture is described: role="img", with the list and its marks', () => {
+ const svg = renderTreeSvg("['+', 2, ['*', 3, 4]]", { mark: ["['*', 3, 4]"], lang: 'de' })
+ assert.match(svg, /^