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 <noreply@anthropic.com> --- .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 `<title>`: 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 <subtree> (repeatable), --lang en|de (the picture's +// description), --font-size <px>. 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 "<list as Python writes it>" [--mark "<subtree>"]... [--lang en|de] [--font-size <px>] [-o <file.svg>]' +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(/<rect x="([\d.]+)" y="([\d.]+)" width="([\d.]+)" height="[\d.]+" rx="4"[^>]*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", <title> with the list and its marks', () => { + const svg = renderTreeSvg("['+', 2, ['*', 3, 4]]", { mark: ["['*', 3, 4]"], lang: 'de' }) + assert.match(svg, /^<svg [^>]*role="img"/) + assert.match(svg, /<title>\['\+', 2, \['\*', 3, 4\]\] – markiert: \['\*', 3, 4\]<\/title>/) + assert.match(renderTreeSvg("['f', 'a<b&c']"), /<title>\['f', 'a<b&c'\]<\/title>/) +}) +check('the tree rule: labels without quotes, strings with, other lists as leaves', () => { + const { nodes } = renderTree(`["+", "a", [1, 2], ['g'], ["Anna", 'Ben'], True, -2.5, None, (1, 'x')]`) + assert.deepEqual(nodes.map((placed) => placed.node.kind === 'node' ? `(${placed.node.label})` : placed.node.text), ['(+)', "'a'", '[1, 2]', '(g)', '(Anna)', "'Ben'", 'True', '-2.5', 'None', "(1, 'x')"]) +}) +check("leaves as Python's repr writes them", () => { + const values = String.raw`[1, -3, 2.50, 2., -0, 0.1, 1e16, 1.5e-7, 0.0001, 123456789012345678, 1_000, True, False, None, 'a', "it's", 'a"b', "both ' and \"", 'tab\t', 'nl\n', '\\', '\x07', 'é', 'é', (1,), (2), (), [[], [1, [2]]]]` + const ours = pythonRepr(parsePythonValue(values)) + const python = spawnSync('python3', ['-c', `import sys; print(repr(${values}), end="")`], { encoding: 'utf8' }) + assert.equal(python.status, 0, python.stderr) + assert.equal(ours, python.stdout) +}) +check('errors: invalid input, where it fails', () => { + for (const [text, message] of [ + ["['+', 2", /Missing "\]" at position 8/], + ["['+', 2 3]", /Expected "," or "\]" at position 9/], + ["['+', x]", /Unknown name "x" .* at position 7/], + ["['a', 'b]", /Unterminated string at position 7/], + ['', /Expected a value at position 1/], + ["['+', 1] 2", /Unexpected text after the value at position 10/], + ]) assert.throws(() => renderTreeSvg(text), message, text) +}) +check('errors: a mark that occurs twice, one that is missing, an invalid one', () => { + assert.throws(() => renderTreeSvg("['+', 3, ['*', 3, 4]]", { mark: ['3'] }), /The mark 3 occurs 2 times/) + assert.throws(() => renderTreeSvg("['+', 2, ['*', 3, 4]]", { mark: ["['*', 4, 3]"] }), /The mark \['\*', 4, 3\] is not a subtree/) + assert.throws(() => renderTreeSvg("['+', 2, 3]", { mark: ['[2'] }), /Invalid mark \[2/) +}) + +// The command, against the build. +if (existsSync(new URL('../dist/tree-svg.js', import.meta.url))) { + check('glpython-tree: stdout, -o, and exit code 1 on an error', () => { + const command = (args, input) => spawnSync(process.execPath, ['bin/glpython-tree.mjs', ...args], { encoding: 'utf8', input }) + const plain = command(["['+', 2, ['*', 3, 4]]", '--mark', "['*', 3, 4]", '--lang', 'de']) + assert.equal(plain.status, 0, plain.stderr) + assert.equal(plain.stdout, readFileSync(snapshot('marked-subtree'), 'utf8')) + assert.equal(command([], "['f', 1, 2]\n").stdout, readFileSync(snapshot('f-1-2'), 'utf8'), 'from stdin') + const wrong = command(["['+', 3, ['*', 3, 4]]", '--mark', '3']) + assert.equal(wrong.status, 1) + assert.match(wrong.stderr, /occurs 2 times/) + assert.equal(command(['--lang', 'fr', '1']).status, 2) + }) +} else { + console.log('glpython-tree: skipped (no build; npm run build first)') +} + +if (failed > 0) process.exit(1) +console.log('Tree SVG checks passed.') diff --git a/scripts/tree-svg/2-plus-3-in-klammern-mal-4.svg b/scripts/tree-svg/2-plus-3-in-klammern-mal-4.svg new file mode 100644 index 0000000..88e9aac --- /dev/null +++ b/scripts/tree-svg/2-plus-3-in-klammern-mal-4.svg @@ -0,0 +1,18 @@ +<svg xmlns="http://www.w3.org/2000/svg" role="img" width="124.8" height="139.2" viewBox="0 0 124.8 139.2" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="16"> +<title>['*', ['+', 2, 3], 4] + + + + + + +* + ++ + +2 + +3 + +4 + diff --git a/scripts/tree-svg/2-plus-3-mal-4.svg b/scripts/tree-svg/2-plus-3-mal-4.svg new file mode 100644 index 0000000..b8f789f --- /dev/null +++ b/scripts/tree-svg/2-plus-3-mal-4.svg @@ -0,0 +1,18 @@ + +['+', 2, ['*', 3, 4]] + + + + + + ++ + +2 + +* + +3 + +4 + diff --git a/scripts/tree-svg/breit.svg b/scripts/tree-svg/breit.svg new file mode 100644 index 0000000..6fc7db0 --- /dev/null +++ b/scripts/tree-svg/breit.svg @@ -0,0 +1,30 @@ + +['f', ['g', 'erstes', 'zweites'], ['h', 'drittes', 'viertes'], ['k', 'fünftes']] + + + + + + + + + + +f + +g + +'erstes' + +'zweites' + +h + +'drittes' + +'viertes' + +k + +'fünftes' + diff --git a/scripts/tree-svg/f-1-2.svg b/scripts/tree-svg/f-1-2.svg new file mode 100644 index 0000000..5b4d4b1 --- /dev/null +++ b/scripts/tree-svg/f-1-2.svg @@ -0,0 +1,12 @@ + +['f', 1, 2] + + + + +f + +1 + +2 + diff --git a/scripts/tree-svg/marked-root.svg b/scripts/tree-svg/marked-root.svg new file mode 100644 index 0000000..def0a9d --- /dev/null +++ b/scripts/tree-svg/marked-root.svg @@ -0,0 +1,18 @@ + +['+', 2, ['*', 3, 4]] – marked: ['+', 2, ['*', 3, 4]] + + + + + + ++ + +2 + +* + +3 + +4 + diff --git a/scripts/tree-svg/marked-subtree.svg b/scripts/tree-svg/marked-subtree.svg new file mode 100644 index 0000000..bbed26e --- /dev/null +++ b/scripts/tree-svg/marked-subtree.svg @@ -0,0 +1,18 @@ + +['+', 2, ['*', 3, 4]] – markiert: ['*', 3, 4] + + + + + + ++ + +2 + +* + +3 + +4 + diff --git a/scripts/tree-svg/minus-1-plus-2.svg b/scripts/tree-svg/minus-1-plus-2.svg new file mode 100644 index 0000000..1805101 --- /dev/null +++ b/scripts/tree-svg/minus-1-plus-2.svg @@ -0,0 +1,15 @@ + +['-', ['+', 1, 2]] + + + + + +- + ++ + +1 + +2 + diff --git a/scripts/tree-svg/single-value.svg b/scripts/tree-svg/single-value.svg new file mode 100644 index 0000000..eff8f35 --- /dev/null +++ b/scripts/tree-svg/single-value.svg @@ -0,0 +1,6 @@ + +42 + + +42 + diff --git a/scripts/tree-svg/uneven-both-sides.svg b/scripts/tree-svg/uneven-both-sides.svg new file mode 100644 index 0000000..232a840 --- /dev/null +++ b/scripts/tree-svg/uneven-both-sides.svg @@ -0,0 +1,36 @@ + +['+', ['*', 2, ['-', 7, 3]], ['/', ['+', 1, 2], 3]] + + + + + + + + + + + + ++ + +* + +2 + +- + +7 + +3 + +/ + ++ + +1 + +2 + +3 + diff --git a/scripts/tree-svg/uneven-to-the-left.svg b/scripts/tree-svg/uneven-to-the-left.svg new file mode 100644 index 0000000..d22b6b6 --- /dev/null +++ b/scripts/tree-svg/uneven-to-the-left.svg @@ -0,0 +1,30 @@ + +['+', ['+', ['+', 1, 2], 3], ['f', ['var', 'x']]] + + + + + + + + + + ++ + ++ + ++ + +1 + +2 + +3 + +f + +var + +'x' + diff --git a/scripts/tree-svg/x-plus-1.svg b/scripts/tree-svg/x-plus-1.svg new file mode 100644 index 0000000..f349613 --- /dev/null +++ b/scripts/tree-svg/x-plus-1.svg @@ -0,0 +1,15 @@ + +['+', ['var', 'x'], 1] + + + + + ++ + +var + +'x' + +1 + diff --git a/src/treeSvg.ts b/src/treeSvg.ts new file mode 100644 index 0000000..cef120c --- /dev/null +++ b/src/treeSvg.ts @@ -0,0 +1,333 @@ +// A list that reads as a tree, drawn as a static SVG picture - for slides, +// handouts and quiz options made by a compile step, without a browser. The +// tree is the one TreeView draws (the same rule as the runtime's +// _glpython_tree_of, the same layout, see treeLayout.ts) and looks like it: +// solid node boxes, dashed leaf boxes, the light theme's colours, on a light +// card so it reads the same on a light and a dark page. Widths are in +// characters of a monospace font, so no text needs measuring. The same input +// always gives the same bytes. +// +// renderTreeSvg("['+', 2, ['*', 3, 4]]", { mark: ["['*', 3, 4]"] }) +// +// With its extension, so the checks run it in Node. +import { layoutTree, type PlacedNode, type TreeNode } from './treeLayout.ts' + +// A value as Python writes it in a list: what the input may contain. +export type PythonValue = + | { kind: 'list'; items: PythonValue[] } + | { kind: 'tuple'; items: PythonValue[] } + | { kind: 'str'; value: string } + | { kind: 'int'; digits: string } + | { kind: 'float'; value: number } + | { kind: 'bool'; value: boolean } + | { kind: 'none' } + +export class TreeSvgError extends Error { + constructor(message: string) { + super(message) + this.name = 'TreeSvgError' + } +} + +// Python's literal syntax for these values, with the place it fails at. +export function parsePythonValue(text: string): PythonValue { + let position = 0 + const fail = (what: string): never => { + const shown = text.length > 60 ? `${text.slice(Math.max(0, position - 30), position + 30)}` : text + const caret = text.length > 60 ? Math.min(30, position) : position + throw new TreeSvgError(`${what} at position ${position + 1}:\n ${shown}\n ${' '.repeat(caret)}^`) + } + const skipSpace = () => { + while (position < text.length && /\s/.test(text[position])) position++ + } + const value = (): PythonValue => { + skipSpace() + const char = text[position] + if (char === undefined) return fail('Expected a value') + if (char === '[' || char === '(') return sequence(char === '[' ? ']' : ')') + if (char === "'" || char === '"') return string() + if (/[-0-9.]/.test(char)) return number() + const word = /^[A-Za-z_]\w*/.exec(text.slice(position))?.[0] + if (word === 'True' || word === 'False') { + position += word.length + return { kind: 'bool', value: word === 'True' } + } + if (word === 'None') { + position += word.length + return { kind: 'none' } + } + return fail(word ? `Unknown name "${word}" (only numbers, strings, True, False, None, lists and tuples)` : `Unexpected "${char}"`) + } + const sequence = (close: string): PythonValue => { + position++ + const items: PythonValue[] = [] + let trailingComma = false + skipSpace() + while (text[position] !== close) { + if (position >= text.length) fail(`Missing "${close}"`) + items.push(value()) + skipSpace() + trailingComma = false + if (text[position] === ',') { + position++ + trailingComma = true + skipSpace() + } else if (text[position] !== close) { + fail(position >= text.length ? `Missing "${close}"` : `Expected "," or "${close}"`) + } + } + position++ + if (close === ']') return { kind: 'list', items } + // "(1)" is the number 1, "(1,)" a tuple of one. + return items.length === 1 && !trailingComma ? items[0] : { kind: 'tuple', items } + } + const string = (): PythonValue => { + const quote = text[position] + const start = position + position++ + let result = '' + for (;;) { + const char = text[position] + if (char === undefined || char === '\n') { + position = start + fail('Unterminated string') + } + position++ + if (char === quote) break + if (char !== '\\') { + result += char + continue + } + const escape = text[position++] + const simple: Record = { '\\': '\\', "'": "'", '"': '"', n: '\n', t: '\t', r: '\r', a: '\x07', b: '\b', f: '\f', v: '\v', '0': '\0' } + if (escape in simple && !(escape === '0' && /[0-7]/.test(text[position] ?? ''))) { + result += simple[escape] + } else if (escape === 'x' || escape === 'u' || escape === 'U') { + const length = { x: 2, u: 4, U: 8 }[escape] + const hex = text.slice(position, position + length) + if (!new RegExp(`^[0-9a-fA-F]{${length}}$`).test(hex)) fail(`Invalid \\${escape} escape`) + const code = parseInt(hex, 16) + if (code > 0x10ffff) fail(`Invalid \\${escape} escape`) + result += String.fromCodePoint(code) + position += length + } else if (/[0-7]/.test(escape ?? '')) { + const octal = /^[0-7]{1,3}/.exec(text.slice(position - 1))![0] + result += String.fromCodePoint(parseInt(octal, 8)) + position += octal.length - 1 + } else { + // Python keeps an unknown escape as it is. + result += `\\${escape ?? ''}` + } + } + return { kind: 'str', value: result } + } + const number = (): PythonValue => { + const written = /^-?\s*(?:\d[\d_]*(?:\.[\d_]*)?|\.\d[\d_]*)(?:[eE][-+]?\d[\d_]*)?/.exec(text.slice(position))?.[0] + if (!written) return fail('Expected a number') + position += written.length + const negative = written.startsWith('-') + const body = written.replace(/^-\s*/, '').replace(/_/g, '') + if (!/[.eE]/.test(body)) { + if (/^0\d/.test(body) && /[1-9]/.test(body)) { + position -= written.length + fail('Leading zeros in a number') + } + const digits = body.replace(/^0+(?=\d)/, '') + return { kind: 'int', digits: negative && digits !== '0' ? `-${digits}` : digits } + } + const value = Number(body) + return { kind: 'float', value: negative ? -value : value } + } + const result = value() + skipSpace() + if (position < text.length) fail('Unexpected text after the value') + return result +} + +// Python's repr of a float: the shortest text that reads back as the same +// number, with ".0" for a whole number and an exponent from 1e16 or below +// 1e-4. +function floatRepr(value: number): string { + if (Number.isNaN(value)) return 'nan' + if (!Number.isFinite(value)) return value > 0 ? 'inf' : '-inf' + if (value === 0) return Object.is(value, -0) ? '-0.0' : '0.0' + const [mantissa, exponentText] = value.toExponential().split('e') + const exponent = Number(exponentText) + const sign = mantissa.startsWith('-') ? '-' : '' + const digits = mantissa.replace('-', '').replace('.', '') + if (exponent < -4 || exponent >= 16) { + const shown = digits.length > 1 ? `${digits[0]}.${digits.slice(1)}` : digits + return `${sign}${shown}e${exponent < 0 ? '-' : '+'}${String(Math.abs(exponent)).padStart(2, '0')}` + } + if (exponent < 0) return `${sign}0.${'0'.repeat(-exponent - 1)}${digits}` + const whole = digits.slice(0, exponent + 1).padEnd(exponent + 1, '0') + const fraction = digits.slice(exponent + 1) + return `${sign}${whole}.${fraction || '0'}` +} + +// Python's repr of a string: single quotes unless it has a ' and no ". +function stringRepr(value: string): string { + const quote = value.includes("'") && !value.includes('"') ? '"' : "'" + let result = quote + for (const char of value) { + const code = char.codePointAt(0)! + if (char === '\\') result += '\\\\' + else if (char === quote) result += `\\${quote}` + else if (char === '\n') result += '\\n' + else if (char === '\r') result += '\\r' + else if (char === '\t') result += '\\t' + else if (code < 0x20 || code === 0x7f) result += `\\x${code.toString(16).padStart(2, '0')}` + else if (code >= 0x80 && code < 0xa0) result += `\\x${code.toString(16)}` + else result += char + } + return result + quote +} + +// The value as Python's repr writes it. +export function pythonRepr(value: PythonValue): string { + switch (value.kind) { + case 'list': return `[${value.items.map(pythonRepr).join(', ')}]` + case 'tuple': return value.items.length === 1 ? `(${pythonRepr(value.items[0])},)` : `(${value.items.map(pythonRepr).join(', ')})` + case 'str': return stringRepr(value.value) + case 'int': return value.digits + case 'float': return floatRepr(value.value) + case 'bool': return value.value ? 'True' : 'False' + case 'none': return 'None' + } +} + +// The runtime's rule (_glpython_tree_of): a non-empty list whose first +// element is a string is a node with that label; its other elements are +// subtrees where they read as a tree too, else leaves (their repr). Any +// other value is a single leaf. Each node's key is its path ("", "0", "0.2"). +function treeOf(value: PythonValue, key: string): TreeNode { + const readsAsTree = value.kind === 'list' && value.items.length > 0 && value.items[0].kind === 'str' + if (!readsAsTree) return { kind: 'leaf', text: pythonRepr(value), key } + const [label, ...children] = value.items + return { kind: 'node', label: (label as { value: string }).value, key, children: children.map((child, index) => treeOf(child, key === '' ? String(index) : `${key}.${index}`)) } +} + +// Where each subtree's value sits, by its repr: to find a marked subtree. +function subtreesOf(value: PythonValue, key: string, found: Array<{ key: string; text: string }>) { + found.push({ key, text: pythonRepr(value) }) + const readsAsTree = value.kind === 'list' && value.items.length > 0 && value.items[0].kind === 'str' + if (readsAsTree) value.items.slice(1).forEach((child, index) => subtreesOf(child, key === '' ? String(index) : `${key}.${index}`, found)) +} + +export type TreeSvgOptions = { + // Subtrees to highlight, each written as in the input; each must occur + // exactly once (the whole tree marks the whole tree). + mark?: string[] + // The code font's size in pixels (default 16). + fontSize?: number + // The language of the picture's description: "… – marked: …" (en) or + // "… – markiert: …" (de). + lang?: 'en' | 'de' +} + +// The look of TreeView in the light theme (src/style.css's tokens), and its +// geometry: rows 2.8em apart, nodes 1.6em high, widths in characters. +const COLOURS = { + card: '#FFFFFF', + cardBorder: '#D1D5DB', + nodeFill: '#F3F4F6', + nodeStroke: '#6B7280', + nodeText: '#1F3A5F', + leafFill: '#FFFFFF', + leafStroke: '#D1D5DB', + leafText: '#1A1A1A', + stringText: '#8A3B12', + numberText: '#0F5F7A', + edge: '#6B7280', + mark: '#1F3A5F', + markFill: '#DCE6F2', +} +const FONT = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace' +const ROW_HEIGHT = 2.8 +const NODE_HEIGHT = 1.6 +// A character of the code font, in em (most monospace fonts: 0.6). +const CHARACTER = 0.6 +const RADIUS = 4 +const CARD_PADDING = 12 + +// Fixed precision, no trailing zeros: the same numbers give the same text. +const number = (value: number) => { + const text = (Math.round(value * 100) / 100).toFixed(2).replace(/\.?0+$/, '') + return text === '-0' ? '0' : text +} +const escapeXml = (text: string) => text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') + +export type RenderedTree = { + svg: string + // The positions it was drawn with (treeLayout's), for checks. + nodes: PlacedNode[] +} + +export function renderTree(text: string, options: TreeSvgOptions = {}): RenderedTree { + const value = parsePythonValue(text) + const tree = treeOf(value, '') + const subtrees: Array<{ key: string; text: string }> = [] + subtreesOf(value, '', subtrees) + const marked = new Set() + const markTexts: string[] = [] + for (const markText of options.mark ?? []) { + let mark: PythonValue + try { + mark = parsePythonValue(markText) + } catch (error) { + throw new TreeSvgError(`Invalid mark ${markText}: ${(error as Error).message}`) + } + const wanted = pythonRepr(mark) + const matches = subtrees.filter((subtree) => subtree.text === wanted) + if (matches.length === 0) throw new TreeSvgError(`The mark ${wanted} is not a subtree of ${pythonRepr(value)}`) + if (matches.length > 1) throw new TreeSvgError(`The mark ${wanted} occurs ${matches.length} times in ${pythonRepr(value)}; mark a larger subtree that occurs once`) + marked.add(matches[0].key) + markTexts.push(wanted) + } + const layout = layoutTree(tree) + const isMarked = (key: string) => [...marked].some((root) => root === '' || key === root || key.startsWith(`${root}.`)) + + const fontSize = options.fontSize ?? 16 + const em = fontSize + const ch = fontSize * CHARACTER + const width = layout.width * ch + 2 * CARD_PADDING + const height = (layout.depth * ROW_HEIGHT - (ROW_HEIGHT - NODE_HEIGHT)) * em + 2 * CARD_PADDING + const left = (placed: PlacedNode) => CARD_PADDING + (placed.x - placed.width / 2) * ch + const top = (placed: PlacedNode) => CARD_PADDING + placed.depth * ROW_HEIGHT * em + const byKey = new Map(layout.nodes.map((placed) => [placed.key, placed])) + + const description = `${pythonRepr(value)}${markTexts.length > 0 ? ` – ${options.lang === 'de' ? 'markiert' : 'marked'}: ${markTexts.join('; ')}` : ''}` + const lines: string[] = [] + lines.push(``) + lines.push(`${escapeXml(description)}`) + lines.push(``) + for (const placed of layout.nodes) { + const parent = placed.parentKey === null ? undefined : byKey.get(placed.parentKey) + if (!parent) continue + // An edge inside a marked subtree is marked too. + const mark = isMarked(placed.key) && isMarked(parent.key) + lines.push(``) + } + for (const placed of layout.nodes) { + const node = placed.node + const mark = isMarked(placed.key) + const isNode = node.kind === 'node' + const label = isNode ? node.label : node.text + const textColour = isNode ? COLOURS.nodeText : /^['"]/.test(label) ? COLOURS.stringText : /^-?\d/.test(label) ? COLOURS.numberText : COLOURS.leafText + const fill = mark ? COLOURS.markFill : isNode ? COLOURS.nodeFill : COLOURS.leafFill + const stroke = mark ? COLOURS.mark : isNode ? COLOURS.nodeStroke : COLOURS.leafStroke + const strokeWidth = mark ? 2 : 1 + const inset = strokeWidth / 2 + const boxWidth = placed.width * ch + lines.push(``) + // The text fills exactly its characters' width, whatever the font's. + const textWidth = [...label].length * ch + lines.push(` 0 ? ` textLength="${number(textWidth)}" lengthAdjust="spacingAndGlyphs"` : ''} xml:space="preserve">${escapeXml(label)}`) + } + lines.push('') + return { svg: `${lines.join('\n')}\n`, nodes: layout.nodes } +} + +export function renderTreeSvg(text: string, options: TreeSvgOptions = {}): string { + return renderTree(text, options).svg +} diff --git a/vite.config.ts b/vite.config.ts index 49f91fe..7be0c2f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -37,10 +37,12 @@ export default defineConfig({ }), ], build: { + // tree-svg.js: renderTreeSvg on its own (no React, no Monaco), for + // Node scripts and the glpython-tree command (bin/). lib: { - entry: `${rootDir}src/index.ts`, + entry: { index: `${rootDir}src/index.ts`, 'tree-svg': `${rootDir}src/treeSvg.ts` }, formats: ['es'], - fileName: () => 'index.js', + fileName: (_format, entryName) => `${entryName}.js`, }, sourcemap: true, rollupOptions: { From 4e7948e524e96da37aa38b2155f2d01e8a037a99 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 15:22:31 +0100 Subject: [PATCH 2/2] Tree pictures: markRoot, the root node's box alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit markRoot (--mark-root) highlights only the root's box, not its edges or children, with the same look as a marked subtree's boxes; it combines with mark and marks a single value's leaf. The description names it first: «– markiert: Wurzel» / «– marked: root». Snapshots for it alone, with a subtree, and on a single value. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++++ bin/glpython-tree.mjs | 10 +++++++--- scripts/check-tree-svg.mjs | 20 +++++++++++++++++-- .../tree-svg/marked-root-node-and-subtree.svg | 18 +++++++++++++++++ scripts/tree-svg/marked-root-node.svg | 18 +++++++++++++++++ scripts/tree-svg/marked-single-value.svg | 6 ++++++ src/treeSvg.ts | 11 +++++++--- 7 files changed, 79 insertions(+), 8 deletions(-) create mode 100644 scripts/tree-svg/marked-root-node-and-subtree.svg create mode 100644 scripts/tree-svg/marked-root-node.svg create mode 100644 scripts/tree-svg/marked-single-value.svg diff --git a/README.md b/README.md index e9d6400..07e4dfe 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,10 @@ npx glpython-tree "['+', 2, ['*', 3, 4]]" --mark "['*', 3, 4]" -o baum.svg - **`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. +- **`markRoot`:** the root node's box alone (not its edges or children), e.g. + for the operator computed last; it combines with `mark`, and marks the leaf + of a single value. In the description: «– markiert: Wurzel» / «– marked: + root» (first, before the marked subtrees). `glpython-tree --mark-root`. - **`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. diff --git a/bin/glpython-tree.mjs b/bin/glpython-tree.mjs index 61a90b5..e631c07 100755 --- a/bin/glpython-tree.mjs +++ b/bin/glpython-tree.mjs @@ -2,21 +2,24 @@ // 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 +// glpython-tree "['+', 2, ['*', 3, 4]]" --mark-root -o wurzel.svg // // Without -o, the SVG goes to stdout; without a list, it is read from stdin. -// Options: --mark <subtree> (repeatable), --lang en|de (the picture's +// Options: --mark <subtree> (repeatable), --mark-root (the root node's box +// alone), --lang en|de (the picture's // description), --font-size <px>. 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 "<list as Python writes it>" [--mark "<subtree>"]... [--lang en|de] [--font-size <px>] [-o <file.svg>]' +const usage = 'Usage: glpython-tree "<list as Python writes it>" [--mark "<subtree>"]... [--mark-root] [--lang en|de] [--font-size <px>] [-o <file.svg>]' const args = process.argv.slice(2) const marks = [] let text = null let output = null let lang = 'en' let fontSize +let markRoot = false for (let index = 0; index < args.length; index++) { const arg = args[index] const next = () => { @@ -27,6 +30,7 @@ for (let index = 0; index < args.length; index++) { return args[++index] } if (arg === '--mark' || arg === '-m') marks.push(next()) + else if (arg === '--mark-root') markRoot = true else if (arg === '-o' || arg === '--output') output = next() else if (arg === '--lang') lang = next() else if (arg === '--font-size') fontSize = Number(next()) @@ -48,7 +52,7 @@ if (fontSize !== undefined && !(fontSize > 0)) { process.exit(2) } try { - const svg = renderTreeSvg(text ?? readFileSync(0, 'utf8').trim(), { mark: marks, lang, fontSize }) + const svg = renderTreeSvg(text ?? readFileSync(0, 'utf8').trim(), { mark: marks, markRoot, lang, fontSize }) if (output) writeFileSync(output, svg) else process.stdout.write(svg) } catch (error) { diff --git a/scripts/check-tree-svg.mjs b/scripts/check-tree-svg.mjs index 85b15f3..1ef27da 100644 --- a/scripts/check-tree-svg.mjs +++ b/scripts/check-tree-svg.mjs @@ -1,7 +1,8 @@ // 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 +// uneven trees), a mark on a subtree, the whole tree marked, a single value, +// and the root node alone marked (also with a subtree, and on 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 @@ -42,6 +43,9 @@ const cases = [ ['marked-subtree', "['+', 2, ['*', 3, 4]]", { mark: ["['*', 3, 4]"], lang: 'de' }], ['marked-root', "['+', 2, ['*', 3, 4]]", { mark: ["['+', 2, ['*', 3, 4]]"] }], ['single-value', '42'], + ['marked-root-node', "['+', 2, ['*', 3, 4]]", { markRoot: true, lang: 'de' }], + ['marked-root-node-and-subtree', "['+', 2, ['*', 3, 4]]", { markRoot: true, mark: ["['*', 3, 4]"], lang: 'de' }], + ['marked-single-value', '42', { markRoot: true }], ] for (const [name, text, options] of cases) { check(`${name}: as the snapshot, the same twice`, () => { @@ -73,6 +77,17 @@ check('the picture is described: role="img", <title> with the list and its marks assert.match(svg, /<title>\['\+', 2, \['\*', 3, 4\]\] – markiert: \['\*', 3, 4\]<\/title>/) assert.match(renderTreeSvg("['f', 'a<b&c']"), /<title>\['f', 'a<b&c'\]<\/title>/) }) +check('markRoot: the root box alone, described as the root', () => { + const svg = renderTreeSvg("['+', 2, ['*', 3, 4]]", { markRoot: true, lang: 'de' }) + assert.equal(svg.match(/fill="#DCE6F2"/g)?.length, 1, 'one marked box') + assert.doesNotMatch(svg, /<line [^>]*stroke="#1F3A5F"/, 'no marked edge') + assert.match(svg, /<title>\['\+', 2, \['\*', 3, 4\]\] – markiert: Wurzel<\/title>/) + const both = renderTreeSvg("['+', 2, ['*', 3, 4]]", { markRoot: true, mark: ["['*', 3, 4]"], lang: 'de' }) + assert.equal(both.match(/fill="#DCE6F2"/g)?.length, 4, 'the root and the subtree') + assert.equal(both.match(/<line [^>]*stroke="#1F3A5F"/g)?.length, 2, "only the subtree's edges") + assert.match(both, /– markiert: Wurzel; \['\*', 3, 4\]<\/title>/) + assert.match(renderTreeSvg('42', { markRoot: true }), /<title>42 – marked: root<\/title>/) +}) check('the tree rule: labels without quotes, strings with, other lists as leaves', () => { const { nodes } = renderTree(`["+", "a", [1, 2], ['g'], ["Anna", 'Ben'], True, -2.5, None, (1, 'x')]`) assert.deepEqual(nodes.map((placed) => placed.node.kind === 'node' ? `(${placed.node.label})` : placed.node.text), ['(+)', "'a'", '[1, 2]', '(g)', '(Anna)', "'Ben'", 'True', '-2.5', 'None', "(1, 'x')"]) @@ -112,6 +127,7 @@ if (existsSync(new URL('../dist/tree-svg.js', import.meta.url))) { assert.equal(wrong.status, 1) assert.match(wrong.stderr, /occurs 2 times/) assert.equal(command(['--lang', 'fr', '1']).status, 2) + assert.equal(command(["['+', 2, ['*', 3, 4]]", '--mark-root', '--lang', 'de']).stdout, readFileSync(snapshot('marked-root-node'), 'utf8'), '--mark-root') }) } else { console.log('glpython-tree: skipped (no build; npm run build first)') diff --git a/scripts/tree-svg/marked-root-node-and-subtree.svg b/scripts/tree-svg/marked-root-node-and-subtree.svg new file mode 100644 index 0000000..555bdaf --- /dev/null +++ b/scripts/tree-svg/marked-root-node-and-subtree.svg @@ -0,0 +1,18 @@ +<svg xmlns="http://www.w3.org/2000/svg" role="img" width="124.8" height="139.2" viewBox="0 0 124.8 139.2" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="16"> +<title>['+', 2, ['*', 3, 4]] – markiert: Wurzel; ['*', 3, 4] + + + + + + ++ + +2 + +* + +3 + +4 + diff --git a/scripts/tree-svg/marked-root-node.svg b/scripts/tree-svg/marked-root-node.svg new file mode 100644 index 0000000..6719236 --- /dev/null +++ b/scripts/tree-svg/marked-root-node.svg @@ -0,0 +1,18 @@ + +['+', 2, ['*', 3, 4]] – markiert: Wurzel + + + + + + ++ + +2 + +* + +3 + +4 + diff --git a/scripts/tree-svg/marked-single-value.svg b/scripts/tree-svg/marked-single-value.svg new file mode 100644 index 0000000..985457c --- /dev/null +++ b/scripts/tree-svg/marked-single-value.svg @@ -0,0 +1,6 @@ + +42 – marked: root + + +42 + diff --git a/src/treeSvg.ts b/src/treeSvg.ts index cef120c..31d5a21 100644 --- a/src/treeSvg.ts +++ b/src/treeSvg.ts @@ -218,6 +218,9 @@ export type TreeSvgOptions = { // Subtrees to highlight, each written as in the input; each must occur // exactly once (the whole tree marks the whole tree). mark?: string[] + // Also the root node's box alone (not its edges or children), e.g. for + // "the operator computed last is at the top"; on a single value, its leaf. + markRoot?: boolean // The code font's size in pixels (default 16). fontSize?: number // The language of the picture's description: "… – marked: …" (en) or @@ -285,7 +288,8 @@ export function renderTree(text: string, options: TreeSvgOptions = {}): Rendered markTexts.push(wanted) } const layout = layoutTree(tree) - const isMarked = (key: string) => [...marked].some((root) => root === '' || key === root || key.startsWith(`${root}.`)) + const inMarkedSubtree = (key: string) => [...marked].some((root) => root === '' || key === root || key.startsWith(`${root}.`)) + const isMarked = (key: string) => inMarkedSubtree(key) || (options.markRoot === true && key === '') const fontSize = options.fontSize ?? 16 const em = fontSize @@ -296,7 +300,8 @@ export function renderTree(text: string, options: TreeSvgOptions = {}): Rendered const top = (placed: PlacedNode) => CARD_PADDING + placed.depth * ROW_HEIGHT * em const byKey = new Map(layout.nodes.map((placed) => [placed.key, placed])) - const description = `${pythonRepr(value)}${markTexts.length > 0 ? ` – ${options.lang === 'de' ? 'markiert' : 'marked'}: ${markTexts.join('; ')}` : ''}` + const markNames = [...(options.markRoot ? [options.lang === 'de' ? 'Wurzel' : 'root'] : []), ...markTexts] + const description = `${pythonRepr(value)}${markNames.length > 0 ? ` – ${options.lang === 'de' ? 'markiert' : 'marked'}: ${markNames.join('; ')}` : ''}` const lines: string[] = [] lines.push(``) lines.push(`${escapeXml(description)}`) @@ -305,7 +310,7 @@ export function renderTree(text: string, options: TreeSvgOptions = {}): Rendered const parent = placed.parentKey === null ? undefined : byKey.get(placed.parentKey) if (!parent) continue // An edge inside a marked subtree is marked too. - const mark = isMarked(placed.key) && isMarked(parent.key) + const mark = inMarkedSubtree(placed.key) && inMarkedSubtree(parent.key) lines.push(``) } for (const placed of layout.nodes) {