/* Copyright (c) 2026 Richard Rodger and other contributors, MIT License */ /* svg.ts * Vertical-flow SVG renderer for railroad diagrams. Flow runs top->bottom: * sequences stack vertically, choice branches fan out side-by-side, and * optional / repetition rails run parallel on the side. This biases the * output tall-and-narrow, which suits laptop browsers and phones. * * Each node measures to a `VLayout { width, height, entryX, exitX, draw }`. * The rail enters the top edge at `entryX` and leaves the bottom edge at * `exitX` (both equal — all nodes are horizontally symmetric). `draw(x,y)` * emits SVG with the node's bounding box at top-left `(x,y)`. * * `modelToSvg` stacks one titled, anchored sub-diagram per rule and turns * nonterminal boxes into `` links. */ import { RailroadNode, GrammarModel, Item, norm, RailroadError } from './model' // ---- geometry constants -------------------------------------------- const CHARW = 8 const PADX = 10 const BOXH = 26 const MINW = 30 const VGAP = 18 // vertical gap between stacked items / split-merge stubs const HGAP = 26 // horizontal gap between choice branches const AR = 10 // loop rail inset const PAD = 16 // outer padding const TITLE_H = 26 // height reserved for a rule title const TRACK_GAP = 34 const LEAD = 14 // rail lead between a cap dot and the content export type SvgOptions = { // (name) => href for nonterminal boxes (whole-grammar linking). linkFor?: (name: string) => string | undefined } type VLayout = { width: number height: number entryX: number exitX: number isSkip?: boolean // a bypass branch — gets a tighter gap in a choice draw: (x: number, y: number) => string } // ---- primitives ---------------------------------------------------- function esc(s: string): string { return s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c] as string)) } const path = (d: string, cls = 'rr-line') => `` const vline = (x: number, y1: number, y2: number) => y1 === y2 ? '' : path(`M${x} ${y1}V${y2}`) const hline = (x1: number, x2: number, y: number) => x1 === x2 ? '' : path(`M${x1} ${y}H${x2}`) const cap = (x: number, y: number) => `` // ---- node layouts -------------------------------------------------- function boxLayout(text: string, cls: string, terminal: boolean, href?: string): VLayout { const w = Math.max(text.length * CHARW + 2 * PADX, MINW) const h = BOXH return { width: w, height: h, entryX: w / 2, exitX: w / 2, draw(x, y) { const r = terminal ? h / 2 : 4 const inner = `` + `${esc(text)}` return href ? `${inner}` : inner }, } } function commentLayout(text: string): VLayout { const w = Math.max(text.length * CHARW + 2 * PADX, MINW) return { width: w, height: BOXH, entryX: w / 2, exitX: w / 2, draw(x, y) { return vline(x + w / 2, y, y + BOXH) + `${esc(text)}` }, } } function skipLayout(): VLayout { const w = 16, h = 12 return { width: w, height: h, entryX: w / 2, exitX: w / 2, isSkip: true, draw(x, y) { return vline(x + w / 2, y, y + h) }, } } function seqLayout(children: VLayout[]): VLayout { if (0 === children.length) return skipLayout() if (1 === children.length) return children[0] const railX = Math.max(...children.map((c) => c.entryX)) const offs = children.map((c) => railX - c.entryX) const width = children.reduce((m, c, i) => Math.max(m, offs[i] + c.width), 0) const height = children.reduce((a, c) => a + c.height, 0) + VGAP * (children.length - 1) return { width, height, entryX: railX, exitX: railX, draw(x, y) { let out = '' let cy = y children.forEach((c, i) => { if (i > 0) { out += vline(x + railX, cy - VGAP, cy) } out += c.draw(x + offs[i], cy) cy += c.height + VGAP }) return out }, } } function choiceLayout(branches: VLayout[]): VLayout { if (1 === branches.length) return branches[0] const n = branches.length const maxBH = Math.max(...branches.map((c) => c.height)) // entry stub + fan-in + branches + fan-out + exit stub const height = 4 * VGAP + maxBH // branch x offsets within the box, and the entry on the fan midpoint. // A bypass (skip) branch hugs its neighbour with a tighter gap. const bxs: number[] = [] let bx = 0 branches.forEach((c, i) => { bxs.push(bx) const next = branches[i + 1] const gap = next && (c.isSkip || next.isSkip) ? AR : HGAP bx += c.width + gap }) const width = bxs[n - 1] + branches[n - 1].width const relCenters = branches.map((c, i) => bxs[i] + c.entryX) const entryX = (relCenters[0] + relCenters[n - 1]) / 2 return { width, height, entryX, exitX: entryX, draw(x, y) { const splitY = y + VGAP const branchY = splitY + VGAP const mergeY = branchY + maxBH + VGAP const centers = relCenters.map((c) => x + c) const lo = Math.min(x + entryX, ...centers) const hi = Math.max(x + entryX, ...centers) let out = '' out += vline(x + entryX, y, splitY) // entry stub out += hline(lo, hi, splitY) // split rail out += hline(lo, hi, mergeY) // merge rail out += vline(x + entryX, mergeY, y + height) // exit stub branches.forEach((c, i) => { const cx = x + bxs[i] const bex = cx + c.entryX out += vline(bex, splitY, branchY) // fan-in drop out += c.draw(cx, branchY) out += vline(bex, branchY + c.height, mergeY) // fan-out drop }) return out }, } } function oneOrMoreLayout(item: VLayout, rep?: VLayout): VLayout { const repW = rep ? rep.width : 0 const railX0 = item.width + AR + repW / 2 // x of the return rail (box-relative) const width = item.width + 2 * AR + repW const topGap = VGAP, botGap = VGAP const height = topGap + item.height + botGap const entryX = item.entryX return { width, height, entryX, exitX: item.exitX, draw(x, y) { const mainX = x + entryX const itemY = y + topGap const railX = x + railX0 const loopTopY = y + topGap / 2 const loopBottomY = y + height - botGap / 2 let out = '' out += vline(mainX, y, itemY) out += item.draw(x, itemY) out += vline(x + item.exitX, itemY + item.height, y + height) out += hline(mainX, railX, loopBottomY) out += hline(mainX, railX, loopTopY) if (rep) { const repH = rep.height const midY = (loopTopY + loopBottomY) / 2 const rTop = midY - repH / 2 const rBot = midY + repH / 2 out += vline(railX, rBot, loopBottomY) out += vline(railX, loopTopY, rTop) out += rep.draw(railX - rep.entryX, rTop) } else { out += vline(railX, loopTopY, loopBottomY) } return out }, } } function layoutNode(node: RailroadNode, opts: SvgOptions): VLayout { switch (node.kind) { case 'terminal': return boxLayout(node.text, 'rr-term', true) case 'nonterminal': return boxLayout(node.text, 'rr-nonterm', false, opts.linkFor ? opts.linkFor(node.text) : undefined) case 'comment': return commentLayout(node.text) case 'skip': return skipLayout() case 'seq': return seqLayout(node.items.map((n) => layoutNode(n, opts))) case 'choice': return choiceLayout(node.items.map((n) => layoutNode(n, opts))) case 'optional': return choiceLayout([layoutNode(node.item, opts), skipLayout()]) case 'oneOrMore': return oneOrMoreLayout( layoutNode(node.item, opts), node.rep ? layoutNode(node.rep, opts) : undefined) case 'zeroOrMore': return choiceLayout([ oneOrMoreLayout( layoutNode(node.item, opts), node.rep ? layoutNode(node.rep, opts) : undefined), skipLayout(), ]) case 'diagram': return seqLayout(node.items.map((n) => layoutNode(n, opts))) default: throw new RailroadError( 'railroad: unknown node kind ' + JSON.stringify((node as any).kind), node) } } // ---- document assembly --------------------------------------------- const STYLE = 'svg.railroad{background:#fff;font-family:monospace;font-size:13px}' + '.rr-line{stroke:#334;stroke-width:2;fill:none}' + '.rr-cap{fill:#334}' + '.rr-term{fill:#e8f0ff;stroke:#334;stroke-width:2}' + '.rr-nonterm{fill:#fff7e8;stroke:#334;stroke-width:2}' + '.rr-label{fill:#111;text-anchor:middle;dominant-baseline:middle}' + '.rr-comment{fill:#666;font-style:italic;text-anchor:middle;dominant-baseline:middle}' + '.rr-title{fill:#113;font-weight:bold;font-size:15px}' + '.rr-legend{fill:#333;font-size:12px}' + '.rr-legend-tok{fill:#113;font-weight:bold}' + 'a:hover .rr-nonterm{fill:#ffe6b3;cursor:pointer}' function svgDoc(body: string, w: number, h: number): string { const W = Math.ceil(w), H = Math.ceil(h) return ( `` + `${body}` ) } // Draw a layout with entry/exit cap dots, separated from the content by a // short rail lead so the dots never sit on top of a box. function withCaps(L: VLayout, x: number, top: number): string { const ct = top + LEAD const cb = ct + L.height const ex = x + L.entryX, xx = x + L.exitX return ( cap(ex, top) + vline(ex, top, ct) + L.draw(x, ct) + vline(xx, cb, cb + LEAD) + cap(xx, cb + LEAD) ) } // Render a single node (wrapped in its own SVG document). export function renderNodeSvg(node: Item, opts: SvgOptions = {}): string { const L = layoutNode(norm(node), opts) return svgDoc(withCaps(L, PAD, PAD), L.width + 2 * PAD, L.height + 2 * LEAD + 2 * PAD) } // Render a whole grammar: a vertical stack of titled, anchored rule // tracks; nonterminal boxes link to the referenced rule's track. export function modelToSvg(model: GrammarModel, _opts: SvgOptions = {}): string { const ruleNames = new Set(Object.keys(model.rules)) const linkFor = (name: string) => (ruleNames.has(name) ? '#' + name : undefined) const opts: SvgOptions = { linkFor } // Measure each rule track (title + capped diagram). const order = orderRules(model) type Track = { name: string; L: VLayout; h: number } const tracks: Track[] = order.map((name) => { const L = layoutNode(model.rules[name], opts) return { name, L, h: TITLE_H + L.height + 2 * LEAD } }) // Lay the tracks out in two newspaper-style columns (filled top to // bottom, balanced by height), using the available horizontal space. const twoCol = tracks.length > 1 const total = tracks.reduce((a, t) => a + t.h + TRACK_GAP, 0) const cols: Track[][] = [[], []] let acc = 0, ci = 0 for (const t of tracks) { cols[ci].push(t) acc += t.h + TRACK_GAP if (twoCol && 0 === ci && acc >= total / 2) ci = 1 } const COLGAP = 48 let x = PAD let pageH = 0 let body = '' for (const col of cols) { if (0 === col.length) continue let y = PAD let colW = 0 for (const t of col) { const dy = y + TITLE_H body += `` body += `${esc(t.name)}` body += withCaps(t.L, x, dy) body += `` colW = Math.max(colW, t.L.width) y = dy + t.L.height + 2 * LEAD + TRACK_GAP } pageH = Math.max(pageH, y - TRACK_GAP) x += colW + COLGAP } let pageW = x - COLGAP + PAD // Token key + ignored-token key below the rule tracks. const renderKey = (title: string, entries: { token: string; meaning: string }[]) => { let ly = pageH + TRACK_GAP body += `${esc(title)}` ly += TITLE_H for (const e of entries) { body += `` + `${esc(e.token)} — ${esc(e.meaning)}` pageW = Math.max(pageW, PAD + (e.token.length + e.meaning.length + 5) * 7.5 + PAD) ly += 18 } pageH = ly } if (model.legend && model.legend.length) renderKey('Tokens', model.legend) // Tokens the lexer silently skips (whitespace, comments, ...) — they never // appear in a rule, so they are listed separately from the diagram tokens. if (model.ignored && model.ignored.length) renderKey('Ignored tokens', model.ignored) return svgDoc(body, pageW, pageH + PAD) } function orderRules(model: GrammarModel): string[] { const names = Object.keys(model.rules) if (model.start && names.includes(model.start)) { return [model.start, ...names.filter((n) => n !== model.start)] } return names }