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, ¬es)
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("&", "&", "<", "<", ">", ">", `"`, """)
// 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 →