Skip to content

ER/Schema Visualizer — Go source

Paste CREATE TABLE DDL and get an ER diagram as SVG: tables with typed columns, primary keys, and foreign-key arrows in a deterministic layered layout. Pan and zoom the live diagram; export the SVG.

This is the Go implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Package schemavisualizer is the Go twin of CosmoDev's
// src/lib/schema-visualizer.ts (dual source: the web lib is TypeScript, the
// CLI lib is Go — kept in lock-step). Pure + deterministic, never panics.
// The table-driven tests in schema-visualizer_test.go share the V1..V14
// vectors with src/lib/schema-visualizer.test.ts so the two implementations
// are held to one contract, including byte-identical SVG output on V2/V10.
//
// The twin mirrors the TS lib exactly: a tolerant CREATE TABLE DDL parser
// (hand-ported statement splitter + column/modifier scanner — no SQL
// library; unparseable statements degrade to Notes entries, never an error),
// a deterministic layered layout in INTEGER coordinates only (longest-path
// layering with the same cycle pass-cap), and a pure string-building SVG
// renderer with the same element order, attribute order, and class names.
package schemavisualizer

import (
	"math"
	"regexp"
	"strconv"
	"strings"
	"unicode"
)

// ---------------------------------------------------------------------------
// Public types (mirroring the TS interfaces)
// ---------------------------------------------------------------------------

// ColumnDef mirrors the TS ColumnDef.
type ColumnDef struct {
	Name         string
	Type         string
	Nullable     bool
	IsPrimaryKey bool
}

// ForeignKey mirrors the TS ForeignKey.
type ForeignKey struct {
	FromTable  string
	FromColumn string
	ToTable    string
	ToColumn   string
}

// TableDef mirrors the TS TableDef.
type TableDef struct {
	Name    string
	Columns []ColumnDef
}

// ParsedSchema mirrors the TS ParsedSchema: notes, never an error.
type ParsedSchema struct {
	Tables      []TableDef
	ForeignKeys []ForeignKey
	Notes       []string
}

// Box mirrors the TS Box: integer coordinates only.
type Box struct {
	X int
	Y int
	W int
	H int
}

// LaidOutTable mirrors the TS Geometry tables entry.
type LaidOutTable struct {
	Table      TableDef
	Box        Box
	TitleBar   Box
	ColumnRows []Box
}

// Edge mirrors the TS Geometry edges entry.
type Edge struct {
	Fk    ForeignKey
	Path  string
	Label string
}

// Geometry mirrors the TS Geometry (width/height plus placed tables/edges).
type Geometry struct {
	Width  int
	Height int
	Tables []LaidOutTable
	Edges  []Edge
}

// LayoutOptions mirrors the TS LayoutOptions. The zero value (or nil) means
// "use the TS defaults": rowHeight 24, charWidth 7, padding 8, layerGap 60,
// columnGap 40.
type LayoutOptions struct {
	RowHeight  int // 0 → 24
	CharWidth  int // 0 → 7
	Padding    int // 0 → 8
	LayerGap   int // 0 → 60
	ColumnGap  int // 0 → 40
}

// RenderOptions mirrors the TS RenderOptions. Zero values resolve to the TS
// defaults: title "Schema diagram", viewBox "0 0 <width> <height>".
type RenderOptions struct {
	Title   string // "" → "Schema diagram"
	ViewBox string // "" → "0 0 <geo.Width> <geo.Height>"
}

// DdlToSvgResult mirrors the TS ddlToSvg return shape.
type DdlToSvgResult struct {
	Svg    string
	Schema ParsedSchema
}

// TS DEFAULT_LAYOUT.
const (
	defaultRowHeight = 24
	defaultCharWidth = 7
	defaultPadding   = 8
	defaultLayerGap  = 60
	defaultColumnGap = 40
)

// ---------------------------------------------------------------------------
// Tokenizer
// ---------------------------------------------------------------------------

type tokenKind int

const (
	tokWord tokenKind = iota
	tokQIdent
	tokString
	tokPunct
)

type token struct {
	text string
	kind tokenKind
}

// isWordBreak mirrors the TS word-terminator class [\s"',().`[]] (the \s
// inside the class is ASCII — Unicode spaces are consumed at top level).
func isWordBreak(r rune) bool {
	switch r {
	case ' ', '\t', '\n', '\r', '\v', '\f', '"', '\'', '(', ')', ',', '.', '`', '[', ']':
		return true
	}
	return false
}

// tokenize ports the TS tokenizer: quoted identifiers ("x", `x`, [x]) and
// strings ('x', '' escape) become single tokens (qident/string, quotes
// stripped); ( ) , . are punct; everything else is a word.
func tokenize(s string) []token {
	r := []rune(s)
	var toks []token
	i := 0
	for i < len(r) {
		ch := r[i]
		if unicode.IsSpace(ch) {
			i++
			continue
		}
		if ch == '\'' || ch == '"' || ch == '`' || ch == '[' {
			closer := ']'
			if ch != '[' {
				closer = ch
			}
			var text []rune
			i++
			for i < len(r) {
				if r[i] == closer {
					// '' inside a single-quoted string is an escaped quote.
					if closer == '\'' && i+1 < len(r) && r[i+1] == '\'' {
						text = append(text, '\'')
						i += 2
						continue
					}
					break
				}
				text = append(text, r[i])
				i++
			}
			i++ // consume the closer (or run off the end — tolerant)
			kind := tokQIdent
			if ch == '\'' {
				kind = tokString
			}
			toks = append(toks, token{text: string(text), kind: kind})
			continue
		}
		if ch == '(' || ch == ')' || ch == ',' || ch == '.' {
			toks = append(toks, token{text: string(ch), kind: tokPunct})
			i++
			continue
		}
		var word []rune
		for i < len(r) && !isWordBreak(r[i]) {
			word = append(word, r[i])
			i++
		}
		toks = append(toks, token{text: string(word), kind: tokWord})
	}
	return toks
}

func punctT(t token, p string) bool { return t.kind == tokPunct && t.text == p }

// punctAt is isPunct with the TS undefined-token tolerance (out of range →
// false) folded in.
func punctAt(toks []token, i int, p string) bool {
	return i >= 0 && i < len(toks) && punctT(toks[i], p)
}

func kwT(t token, word string) bool {
	return t.kind == tokWord && strings.EqualFold(t.text, word)
}

// kwAt is the TS kw helper over a token slice (out of range → false).
func kwAt(toks []token, i int, word string) bool {
	return i >= 0 && i < len(toks) && kwT(toks[i], word)
}

// splitStatements ports the TS splitter: split DDL text on ';' outside
// strings and quoted identifiers. Depth is deliberately NOT tracked: an
// unterminated paren is the common breakage, and letting its ';' still split
// keeps one broken statement from swallowing the good ones after it.
func splitStatements(ddl string) []string {
	var out []string
	var current []rune
	r := []rune(ddl)
	i := 0
	for i < len(r) {
		ch := r[i]
		if ch == '\'' || ch == '"' || ch == '`' || ch == '[' {
			closer := ']'
			if ch != '[' {
				closer = ch
			}
			current = append(current, ch)
			i++
			for i < len(r) {
				current = append(current, r[i])
				if r[i] == closer {
					if closer == '\'' && i+1 < len(r) && r[i+1] == '\'' {
						current = append(current, r[i+1])
						i += 2
						continue
					}
					break
				}
				i++
			}
			i++
			continue
		}
		if ch == ';' {
			out = append(out, string(current))
			current = nil
			i++
			continue
		}
		current = append(current, ch)
		i++
	}
	if strings.TrimSpace(string(current)) != "" {
		out = append(out, string(current))
	}
	return out
}

// ---------------------------------------------------------------------------
// Statement parsing
// ---------------------------------------------------------------------------

// TS MODIFIERS set.
var modifiers = map[string]bool{
	"NOT": true, "NULL": true, "PRIMARY": true, "KEY": true, "UNIQUE": true,
	"DEFAULT": true, "REFERENCES": true, "AUTO_INCREMENT": true,
	"AUTOINCREMENT": true, "ON": true, "COMMENT": true, "CHECK": true,
	"CONSTRAINT": true,
}

func isModifier(t token) bool {
	return t.kind == tokWord && modifiers[strings.ToUpper(t.text)]
}

// pendingFk mirrors the TS PendingFk (toColumn string | null → hasToColumn).
type pendingFk struct {
	fromTable   string
	fromColumn  string
	toTable     string
	toColumn    string
	hasToColumn bool
}

// takeName consumes an identifier (quoted or bare, dotted) from toks at
// index i. Returns (name, nextIndex, ok).
func takeName(toks []token, i int) (string, int, bool) {
	if i >= len(toks) || (toks[i].kind != tokQIdent && toks[i].kind != tokWord) {
		return "", 0, false
	}
	name := toks[i].text
	j := i + 1
	for punctAt(toks, j, ".") && j+1 < len(toks) &&
		(toks[j+1].kind == tokQIdent || toks[j+1].kind == tokWord) {
		name += "." + toks[j+1].text
		j += 2
	}
	return name, j, true
}

// takeParenList collects the comma-separated identifiers inside a paren
// group starting at toks[i] == '('. Returns (names, nextIndex, ok).
func takeParenList(toks []token, i int) ([]string, int, bool) {
	if !punctAt(toks, i, "(") {
		return nil, 0, false
	}
	var names []string
	j := i + 1
	for {
		name, nj, ok := takeName(toks, j)
		if !ok {
			return nil, 0, false
		}
		names = append(names, name)
		j = nj
		if punctAt(toks, j, ",") {
			j++
			continue
		}
		if punctAt(toks, j, ")") {
			return names, j + 1, true
		}
		return nil, 0, false
	}
}

var (
	reParenOpen  = regexp.MustCompile(`\s*\(\s*`)
	reParenClose = regexp.MustCompile(`\s*\)\s*`)
	reComma      = regexp.MustCompile(`\s*,\s*`)
)

// joinType rejoins type tokens: words + nested paren args, uppercased,
// spaces removed adjacent to parens/commas (VARCHAR ( 100 ) -> VARCHAR(100)).
func joinType(toks []token) string {
	texts := make([]string, len(toks))
	for i, t := range toks {
		texts[i] = t.text
	}
	raw := strings.Join(texts, " ")
	raw = reParenOpen.ReplaceAllString(raw, "(")
	raw = reParenClose.ReplaceAllString(raw, ")")
	raw = reComma.ReplaceAllString(raw, ",")
	return strings.ToUpper(strings.TrimSpace(raw))
}

// markPrimaryKeys marks the named columns (first match each, like the TS
// Array.find) as primary key + not null.
func markPrimaryKeys(columns []ColumnDef, names []string) {
	for _, name := range names {
		for i := range columns {
			if columns[i].Name == name {
				columns[i].IsPrimaryKey = true
				columns[i].Nullable = false
				break
			}
		}
	}
}

// parseTableBody ports the TS parseTableBody: split the body into top-level
// comma-separated lines, then dispatch table-level constraint lines and
// column lines. Degrades to notes; never fails.
func parseTableBody(tableName string, body []token, columns *[]ColumnDef, fks *[]pendingFk, notes *[]string) {
	// Split into top-level comma-separated lines.
	var lines [][]token
	var line []token
	depth := 0
	for _, t := range body {
		if punctT(t, "(") {
			depth++
		}
		if punctT(t, ")") {
			depth--
		}
		if punctT(t, ",") && depth == 0 {
			lines = append(lines, line)
			line = nil
			continue
		}
		line = append(line, t)
	}
	if len(line) > 0 {
		lines = append(lines, line)
	}

	for _, toks := range lines {
		if len(toks) == 0 {
			continue
		}
		first := toks[0]
		u := ""
		if first.kind == tokWord {
			u = strings.ToUpper(first.text)
		}

		if u == "PRIMARY" && kwAt(toks, 1, "KEY") {
			if names, _, ok := takeParenList(toks, 2); ok {
				markPrimaryKeys(*columns, names)
			}
			continue
		}
		if u == "FOREIGN" && kwAt(toks, 1, "KEY") {
			parseForeignKeyLine(tableName, toks, 2, fks)
			continue
		}
		if u == "CONSTRAINT" {
			// CONSTRAINT <name> <constraint kind> ... — find the kind (its
			// index varies: the name may be quoted or absent) and re-dispatch
			// on it.
			fkIdx := -1
			for k := 1; k < len(toks); k++ {
				if kwT(toks[k], "FOREIGN") && kwAt(toks, k+1, "KEY") {
					fkIdx = k
					break
				}
			}
			if fkIdx >= 0 {
				parseForeignKeyLine(tableName, toks, fkIdx+2, fks)
				continue
			}
			pkIdx := -1
			for k := 1; k < len(toks); k++ {
				if kwT(toks[k], "PRIMARY") && kwAt(toks, k+1, "KEY") {
					pkIdx = k
					break
				}
			}
			if pkIdx >= 0 {
				if names, _, ok := takeParenList(toks, pkIdx+2); ok {
					markPrimaryKeys(*columns, names)
				}
			}
			// UNIQUE / CHECK / EXCLUDE constraint bodies: skipped silently.
			continue
		}
		switch u {
		case "UNIQUE", "KEY", "INDEX", "CHECK", "EXCLUDE", "FULLTEXT", "SPATIAL":
			continue // table-level options — skipped
		}
		parseColumnLine(tableName, toks, columns, fks, notes)
	}
}

// parseForeignKeyLine ports FOREIGN KEY (a[,b]) REFERENCES t [(c[,d])]
// starting at index i (== the token after KEY). Pairs columns positionally;
// missing target columns resolve in a post-pass.
func parseForeignKeyLine(tableName string, toks []token, i int, fks *[]pendingFk) {
	from, j, ok := takeParenList(toks, i)
	if !ok {
		return
	}
	if !kwAt(toks, j, "REFERENCES") {
		return
	}
	j++
	target, tj, ok := takeName(toks, j)
	if !ok {
		return
	}
	j = tj
	var toCols []string
	if punctAt(toks, j, "(") {
		if to, nj, ok := takeParenList(toks, j); ok {
			toCols = to
			j = nj
		}
	}
	for idx, fromCol := range from {
		pk := pendingFk{fromTable: tableName, fromColumn: fromCol, toTable: target}
		if toCols != nil {
			pk.toColumn = toCols[len(toCols)-1]
			if idx < len(toCols) {
				pk.toColumn = toCols[idx]
			}
			pk.hasToColumn = true
		}
		*fks = append(*fks, pk)
	}
}

// parseColumnLine ports <name> <type tokens…> [modifiers…] — unknown
// modifiers ignored.
func parseColumnLine(tableName string, toks []token, columns *[]ColumnDef, fks *[]pendingFk, notes *[]string) {
	name, i, ok := takeName(toks, 0)
	if !ok {
		*notes = append(*notes, `Skipped a column line with no name in "`+tableName+`".`)
		return
	}

	// Type: consume until a modifier keyword (or end). Nested paren args
	// ride along.
	var typeToks []token
	for i < len(toks) && !isModifier(toks[i]) {
		typeToks = append(typeToks, toks[i])
		i++
	}

	nullable := true
	isPrimaryKey := false
	for i < len(toks) {
		if kwAt(toks, i, "NOT") && kwAt(toks, i+1, "NULL") {
			nullable = false
			i += 2
			continue
		}
		if kwAt(toks, i, "NULL") {
			nullable = true
			i++
			continue
		}
		if kwAt(toks, i, "PRIMARY") && kwAt(toks, i+1, "KEY") {
			isPrimaryKey = true
			nullable = false
			i += 2
			continue
		}
		if kwAt(toks, i, "UNIQUE") {
			i++
			continue
		}
		if kwAt(toks, i, "AUTO_INCREMENT") || kwAt(toks, i, "AUTOINCREMENT") {
			i++
			continue
		}
		if kwAt(toks, i, "DEFAULT") {
			i++
			if punctAt(toks, i, "(") { // skip ( expr ) by depth
				depth := 0
				for {
					if punctAt(toks, i, "(") {
						depth++
					}
					if punctAt(toks, i, ")") {
						depth--
					}
					i++
					if !(i < len(toks) && depth > 0) {
						break
					}
				}
			} else if i < len(toks) {
				i++
			}
			continue
		}
		if kwAt(toks, i, "COMMENT") {
			i++
			if i < len(toks) && toks[i].kind == tokString {
				i++
			}
			continue
		}
		if kwAt(toks, i, "ON") {
			// ON DELETE|UPDATE <action>: CASCADE | RESTRICT | SET NULL/DEFAULT
			// | NO ACTION
			i += 2 // ON + DELETE/UPDATE
			if kwAt(toks, i, "SET") || kwAt(toks, i, "NO") {
				i += 2
			} else if i < len(toks) {
				i++
			}
			continue
		}
		if kwAt(toks, i, "REFERENCES") {
			i++
			if target, tj, ok := takeName(toks, i); ok {
				i = tj
				pk := pendingFk{fromTable: tableName, fromColumn: name, toTable: target}
				if punctAt(toks, i, "(") {
					if list, nj, ok := takeParenList(toks, i); ok && len(list) > 0 {
						pk.toColumn = list[0]
						pk.hasToColumn = true
						i = nj
					}
				}
				*fks = append(*fks, pk)
			}
			continue
		}
		i++ // unknown modifier token — tolerated
	}

	*columns = append(*columns, ColumnDef{
		Name: name, Type: joinType(typeToks), Nullable: nullable, IsPrimaryKey: isPrimaryKey,
	})
}

// utf16Len reports the string's length in UTF-16 code units — the unit the
// TS lib's .length / .slice operate in (astral runes count 2).
func utf16Len(s string) int {
	n := 0
	for _, r := range s {
		n++
		if r > 0xFFFF {
			n++
		}
	}
	return n
}

// notePrefix mirrors the TS notePrefix: trim, collapse whitespace, truncate
// at 40 UTF-16 code units with an ellipsis.
func notePrefix(stmt string) string {
	trimmed := strings.Join(strings.Fields(stmt), " ")
	if utf16Len(trimmed) <= 40 {
		return trimmed
	}
	var b strings.Builder
	n := 0
	for _, r := range trimmed {
		w := 1
		if r > 0xFFFF {
			w = 2
		}
		if n+w > 40 {
			break
		}
		b.WriteRune(r)
		n += w
	}
	return b.String() + "…"
}

// ParseDdl parses CREATE TABLE DDL into tables/columns/foreign-keys.
// Tolerant: unparseable statements degrade to Notes entries; never panics;
// zero tables is a valid empty state.
func ParseDdl(ddl string) ParsedSchema {
	var tables []TableDef
	var pendingFks []pendingFk
	var notes []string

	if strings.TrimSpace(ddl) == "" {
		return ParsedSchema{Notes: []string{"No DDL input."}}
	}

	skipNote := func(stmt string) {
		notes = append(notes, `Skipped unparseable statement starting "`+notePrefix(stmt)+`".`)
	}

	for _, stmt := range splitStatements(ddl) {
		if strings.TrimSpace(stmt) == "" {
			continue
		}
		toks := tokenize(stmt)
		i := 0
		if !kwAt(toks, i, "CREATE") {
			skipNote(stmt)
			continue
		}
		i++
		for kwAt(toks, i, "TEMP") || kwAt(toks, i, "TEMPORARY") || kwAt(toks, i, "UNLOGGED") {
			i++
		}
		if !kwAt(toks, i, "TABLE") {
			notes = append(notes, `Skipped non-table statement starting "`+notePrefix(stmt)+`".`)
			continue
		}
		i++
		if kwAt(toks, i, "IF") && kwAt(toks, i+1, "NOT") && kwAt(toks, i+2, "EXISTS") {
			i += 3
		}
		name, nj, ok := takeName(toks, i)
		if !ok {
			skipNote(stmt)
			continue
		}
		i = nj
		if !punctAt(toks, i, "(") {
			skipNote(stmt)
			continue
		}
		// Body = everything inside the OUTER parens (depth-aware slice).
		i++
		var body []token
		depth := 0
		for ; i < len(toks); i++ {
			if punctT(toks[i], "(") {
				depth++
			}
			if punctT(toks[i], ")") {
				if depth == 0 {
					break
				}
				depth--
			}
			body = append(body, toks[i])
		}
		if i >= len(toks) {
			skipNote(stmt) // unterminated column list
			continue
		}
		cols := []ColumnDef{}
		parseTableBody(name, body, &cols, &pendingFks, &notes)
		tables = append(tables, TableDef{Name: name, Columns: cols})
	}

	// Post-pass: resolve omitted FK target columns to the referenced table's
	// first primary key (or "id" when unknown/unmarked).
	foreignKeys := make([]ForeignKey, 0, len(pendingFks))
	for _, fk := range pendingFks {
		resolved := ForeignKey{
			FromTable: fk.fromTable, FromColumn: fk.fromColumn,
			ToTable: fk.toTable, ToColumn: fk.toColumn,
		}
		if !fk.hasToColumn {
			resolved.ToColumn = "id"
			for i := range tables {
				if tables[i].Name != fk.toTable {
					continue
				}
				for _, c := range tables[i].Columns {
					if c.IsPrimaryKey {
						resolved.ToColumn = c.Name
						break
					}
				}
				break
			}
		}
		foreignKeys = append(foreignKeys, resolved)
	}

	return ParsedSchema{Tables: tables, ForeignKeys: foreignKeys, Notes: notes}
}

// ---------------------------------------------------------------------------
// Layout (deterministic, INTEGER geometry only — TS/Go byte-parity)
// ---------------------------------------------------------------------------

// halfUp mirrors the TS Math.round(n / 2) for non-negative n: halves round
// up, where Go's integer division floors.
func halfUp(n int) int { return (n + 1) / 2 }

func resolveLayout(opts *LayoutOptions) (rowHeight, charWidth, padding, layerGap, columnGap int) {
	rowHeight, charWidth, padding, layerGap, columnGap =
		defaultRowHeight, defaultCharWidth, defaultPadding, defaultLayerGap, defaultColumnGap
	if opts == nil {
		return
	}
	if opts.RowHeight > 0 {
		rowHeight = opts.RowHeight
	}
	if opts.CharWidth > 0 {
		charWidth = opts.CharWidth
	}
	if opts.Padding > 0 {
		padding = opts.Padding
	}
	if opts.LayerGap > 0 {
		layerGap = opts.LayerGap
	}
	if opts.ColumnGap > 0 {
		columnGap = opts.ColumnGap
	}
	return
}

// LayoutSchema lays out the schema in layers: referenced tables above
// referencing ones. Layer numbers come from longest-path relaxation over the
// FK graph, capped at |tables| passes (cycle fallback — every table still
// gets a layer). FKs to tables absent from the DDL and self-FKs do not drive
// layering. Integer coordinates only.
func LayoutSchema(schema ParsedSchema, opts *LayoutOptions) Geometry {
	rowHeight, charWidth, padding, layerGap, columnGap := resolveLayout(opts)
	tables := schema.Tables
	if len(tables) == 0 {
		return Geometry{}
	}

	// First-occurrence index by table name (duplicate names share one node).
	index := map[string]int{}
	for i, t := range tables {
		if _, ok := index[t.Name]; !ok {
			index[t.Name] = i
		}
	}

	// Box sizes: width from the longest rendered line, height from rows.
	boxes := make([]Box, len(tables))
	for i, t := range tables {
		textLen := max(utf16Len(t.Name), 1)
		for _, c := range t.Columns {
			textLen = max(textLen, utf16Len(c.Name+" "+c.Type))
		}
		boxes[i] = Box{
			W: textLen*charWidth + 2*padding,
			H: rowHeight*(1+len(t.Columns)) + padding,
		}
	}

	// Longest-path layering with a pass cap (cycle fallback).
	layerOf := make([]int, len(tables))
	for pass := 0; pass < len(tables); pass++ {
		changed := false
		for _, fk := range schema.ForeignKeys {
			ti, oki := index[fk.FromTable]
			tj, okj := index[fk.ToTable]
			if !oki || !okj || ti == tj {
				continue
			}
			if layerOf[ti] < layerOf[tj]+1 {
				layerOf[ti] = layerOf[tj] + 1
				changed = true
			}
		}
		if !changed {
			break
		}
	}

	// Stack layers top-to-bottom; tables within a layer left-to-right in
	// first-seen (parse) order.
	layerCount := 0
	for _, l := range layerOf {
		layerCount = max(layerCount, l)
	}
	layerCount++
	layers := make([][]int, layerCount)
	for i := range tables {
		layers[layerOf[i]] = append(layers[layerOf[i]], i)
	}

	yCursor := 0
	width := 0
	height := 0
	for _, layer := range layers {
		xCursor := 0
		layerH := 0
		for _, i := range layer {
			boxes[i].X = xCursor
			boxes[i].Y = yCursor
			xCursor += boxes[i].W + columnGap
			layerH = max(layerH, boxes[i].H)
		}
		width = max(width, xCursor-columnGap)
		height = max(height, yCursor+layerH)
		yCursor += layerH + layerGap
	}

	laidOut := make([]LaidOutTable, len(tables))
	for i, table := range tables {
		box := boxes[i]
		columnRows := make([]Box, len(table.Columns))
		for ci := range table.Columns {
			columnRows[ci] = Box{
				X: box.X, Y: box.Y + rowHeight*(1+ci), W: box.W, H: rowHeight,
			}
		}
		laidOut[i] = LaidOutTable{
			Table:      table,
			Box:        box,
			TitleBar:   Box{X: box.X, Y: box.Y, W: box.W, H: rowHeight},
			ColumnRows: columnRows,
		}
	}

	// Orthogonal FK elbows: referenced bottom-center -> referencing
	// top-center.
	boxOf := func(name string) (Box, bool) {
		i, ok := index[name]
		if !ok {
			return Box{}, false
		}
		return boxes[i], true
	}
	edges := []Edge{}
	for _, fk := range schema.ForeignKeys {
		from, ok1 := boxOf(fk.FromTable)
		to, ok2 := boxOf(fk.ToTable)
		if !ok1 || !ok2 {
			continue
		}
		x1 := to.X + halfUp(to.W)
		y1 := to.Y + to.H
		x2 := from.X + halfUp(from.W)
		y2 := from.Y
		midY := halfUp(y1 + y2)
		edges = append(edges, Edge{
			Fk:    fk,
			Path:  "M " + strconv.Itoa(x1) + " " + strconv.Itoa(y1) + " V " + strconv.Itoa(midY) + " H " + strconv.Itoa(x2) + " V " + strconv.Itoa(y2),
			Label: fk.FromColumn + " → " + fk.ToColumn,
		})
	}

	return Geometry{Width: width, Height: height, Tables: laidOut, Edges: edges}
}

// ---------------------------------------------------------------------------
// SVG renderer (pure string builder — no DOM, theme-agnostic classes)
// ---------------------------------------------------------------------------

var xmlEscaper = strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;")

// EscapeXmlText escapes the four XML text characters the TS renderer does —
// the ONLY escaping the renderer performs; every DDL-derived string passes
// through here (V13 contract).
func EscapeXmlText(s string) string { return xmlEscaper.Replace(s) }

// round7 mirrors the TS renderer's Math.round(h * 0.7). 0.7 is not exactly
// representable in binary floating point, so the product must be computed in
// float64 (IEEE-754 semantics are identical in TS and Go) before rounding —
// pure integer math would diverge (24*0.7 rounds to 17, not 16).
func round7(h int) int { return int(math.Round(float64(h) * 0.7)) }

// RenderSvg renders a layout as an SVG string. Edges sit under the table
// groups; each FK edge carries a path plus a small arrowhead polygon at the
// referencing table's top-center. Styling is class-based (sv-root, sv-table,
// sv-title, sv-pk, sv-edge, sv-arrow) — the page/island owns colors.
func RenderSvg(geo Geometry, opts *RenderOptions) string {
	title := "Schema diagram"
	viewBox := ""
	if opts != nil {
		if opts.Title != "" {
			title = opts.Title
		}
		viewBox = opts.ViewBox
	}
	if viewBox == "" {
		viewBox = "0 0 " + strconv.Itoa(geo.Width) + " " + strconv.Itoa(geo.Height)
	}

	var out strings.Builder
	out.WriteString(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="` + viewBox + `" class="sv-root" role="img">`)
	out.WriteString(`<title>` + EscapeXmlText(title) + `</title>`)

	// Edges first (visually under the tables). The arrowhead lands at the
	// referencing table's top-center — recomputed from its box, same formula
	// the layout used.
	boxByName := map[string]Box{}
	for _, t := range geo.Tables {
		if _, ok := boxByName[t.Table.Name]; !ok {
			boxByName[t.Table.Name] = t.Box
		}
	}
	for _, e := range geo.Edges {
		fromBox, ok := boxByName[e.Fk.FromTable]
		if !ok {
			continue
		}
		ax := fromBox.X + halfUp(fromBox.W)
		ay := fromBox.Y
		out.WriteString(`<path class="sv-edge" d="` + e.Path + `"/>`)
		out.WriteString(`<polygon class="sv-arrow" points="` +
			strconv.Itoa(ax-5) + "," + strconv.Itoa(ay-8) + " " +
			strconv.Itoa(ax+5) + "," + strconv.Itoa(ay-8) + " " +
			strconv.Itoa(ax) + "," + strconv.Itoa(ay) + `"/>`)
	}

	for _, t := range geo.Tables {
		table, box, titleBar := t.Table, t.Box, t.TitleBar
		out.WriteString(`<g class="sv-table">`)
		out.WriteString(`<rect class="sv-box" x="` + strconv.Itoa(box.X) +
			`" y="` + strconv.Itoa(box.Y) +
			`" width="` + strconv.Itoa(box.W) +
			`" height="` + strconv.Itoa(box.H) + `" rx="6"/>`)
		out.WriteString(`<rect class="sv-titlebar" x="` + strconv.Itoa(titleBar.X) +
			`" y="` + strconv.Itoa(titleBar.Y) +
			`" width="` + strconv.Itoa(titleBar.W) +
			`" height="` + strconv.Itoa(titleBar.H) + `" rx="6"/>`)
		titleY := titleBar.Y + round7(titleBar.H)
		out.WriteString(`<text class="sv-title" x="` + strconv.Itoa(titleBar.X+8) +
			`" y="` + strconv.Itoa(titleY) + `">` + EscapeXmlText(table.Name) + `</text>`)
		for ci, col := range table.Columns {
			// A hand-built Geometry may carry fewer rows than columns; the
			// TS lib would throw on row.y — the twin degrades to a zero row
			// instead (never panics).
			var row Box
			if ci < len(t.ColumnRows) {
				row = t.ColumnRows[ci]
			}
			textY := row.Y + round7(row.H)
			class := "sv-col"
			if col.IsPrimaryKey {
				class = "sv-pk"
			}
			out.WriteString(`<text class="` + class + `" x="` + strconv.Itoa(row.X+8) +
				`" y="` + strconv.Itoa(textY) + `">` +
				EscapeXmlText(col.Name) + ` ` + EscapeXmlText(col.Type) + `</text>`)
		}
		out.WriteString(`</g>`)
	}

	out.WriteString(`</svg>`)
	return out.String()
}

// DdlToSvg is the convenience parse + layout + render in one call (the
// island's initial state uses this at BUILD time — that prerendered markup
// is the zero-JS first paint). Equal to RenderSvg(LayoutSchema(ParseDdl(...))).
func DdlToSvg(ddl string, opts *LayoutOptions) DdlToSvgResult {
	schema := ParseDdl(ddl)
	return DdlToSvgResult{Svg: RenderSvg(LayoutSchema(schema, opts), nil), Schema: schema}
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →