Skip to content

ER/Schema Visualizer — C 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 C implementation — the same logic the interactive tool runs, in a shareable, citable form.

/* schema-visualizer — pure CREATE TABLE DDL → layered ER diagram as SVG.
 * C port (canonical TS: src/lib/schema-visualizer.ts; Go twin:
 * cli/schema-visualizer). Tolerant common subset of Postgres/MySQL/SQLite:
 * unparseable statements degrade to notes, never abort. Integer geometry only
 * (half-up rounding — JS Math.round parity), so every port draws the
 * byte-identical diagram. Caller frees the result with sv_free_result(). */
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/* ---- small growable string (house style: see csv-to-sql/c.c) ---- */

typedef struct { char *buf; size_t len, cap; } Str;

static void str_reserve(Str *s, size_t need) {
    if (need >= s->cap) {
        size_t cap = s->cap ? s->cap : 64;
        while (need >= cap) cap *= 2;
        s->buf = realloc(s->buf, cap);
        s->cap = cap;
    }
}

static void str_push(Str *s, char c) {
    str_reserve(s, s->len + 1);
    s->buf[s->len++] = c;
    s->buf[s->len] = '\0';
}

static void str_app(Str *s, const char *t) {
    size_t tl = strlen(t);
    str_reserve(s, s->len + tl);
    memcpy(s->buf + s->len, t, tl + 1);
    s->len += tl;
}

/* ---- shared helpers ---- */

/* Layout constants (LAYOUT in the TS/JS/… ports). */
enum { ROW_H = 24, CHAR_W = 7, PAD = 8, LAYER_GAP = 60, COL_GAP = 40 };

/* Half-up rounding: (int)(v + 0.5), JS Math.round parity. */
static int sv_round(double v) { return (int)(v + 0.5); }

/* ASCII case-insensitive equality (JS/Python toUpperCase + == on keywords). */
static int ci_eq(const char *a, const char *b) {
    while (*a && *b) {
        if (toupper((unsigned char)*a) != toupper((unsigned char)*b)) return 0;
        a++; b++;
    }
    return *a == *b;
}

/* JS \s for the ASCII range (space, \t, \n, \v, \f, \r). */
static int sv_isspace(char c) {
    return c == ' ' || c == '\t' || c == '\n' || c == '\v' || c == '\f' || c == '\r';
}

static const char *sv_trim(const char *s, size_t n, size_t *out_n) {
    size_t b = 0, e = n;
    while (b < e && sv_isspace(s[b])) b++;
    while (e > b && sv_isspace(s[e - 1])) e--;
    *out_n = e - b;
    return s + b;
}

/* ---- statement splitting: `;` outside strings/quoted identifiers ---- */

typedef struct { const char *s; size_t n; } Slice;
typedef struct { Slice *v; size_t n; } Slices;

static void slices_push(Slices *l, Slice s) {
    l->v = realloc(l->v, (l->n + 1) * sizeof(Slice));
    l->v[l->n++] = s;
}

static int is_quote_open(char c) { return c == '\'' || c == '"' || c == '`' || c == '['; }

static Slices split_statements(const char *ddl) {
    Slices out = {0};
    size_t i = 0, n = strlen(ddl), start = 0;
    while (i < n) {
        char ch = ddl[i];
        if (is_quote_open(ch)) {
            char close = ch == '[' ? ']' : ch;
            i++;
            while (i < n) {
                if (ddl[i] == close) {
                    if (close == '\'' && i + 1 < n && ddl[i + 1] == '\'') { i += 2; continue; }
                    break;
                }
                i++;
            }
            i++;
            continue;
        }
        if (ch == ';') {
            slices_push(&out, (Slice){ ddl + start, i - start });
            i++;
            start = i;
            continue;
        }
        i++;
    }
    size_t tail;
    sv_trim(ddl + start, n - start, &tail);
    if (tail) slices_push(&out, (Slice){ ddl + start, n - start });
    return out;
}

/* ---- tokenizer ---- */

typedef enum { TK_WORD, TK_STRING, TK_QIDENT, TK_PUNCT } TokKind;
typedef struct { TokKind kind; char *text; } Tok;
typedef struct { Tok *v; size_t n; } TokOwn;            /* owns array + texts */
typedef struct { const Tok *v; size_t n; } Toks;        /* borrowed view */

static void tok_push(TokOwn *l, TokKind kind, char *owned_text) {
    l->v = realloc(l->v, (l->n + 1) * sizeof(Tok));
    l->v[l->n].kind = kind;
    l->v[l->n].text = owned_text;
    l->n++;
}

static void free_tokens(TokOwn *t) {
    for (size_t i = 0; i < t->n; i++) free(t->v[i].text);
    free(t->v);
    *t = (TokOwn){0};
}

static int word_break(char c) {
    return sv_isspace(c) || c == '\'' || c == '"' || c == ',' || c == '(' ||
           c == ')' || c == '.' || c == '`' || c == '[' || c == ']';
}

/* Quoted identifiers/strings carry their text (quotes stripped); ( ) , . are
 * punct; everything else is a word. */
static TokOwn tokenize(const char *s, size_t n) {
    TokOwn toks = {0};
    size_t i = 0;
    while (i < n) {
        char ch = s[i];
        if (sv_isspace(ch)) { i++; continue; }
        if (is_quote_open(ch)) {
            char close = ch == '[' ? ']' : ch;
            Str text = {0};
            i++;
            while (i < n) {
                if (s[i] == close) {
                    if (close == '\'' && i + 1 < n && s[i + 1] == '\'') {
                        str_push(&text, '\'');
                        i += 2;
                        continue;
                    }
                    break;
                }
                str_push(&text, s[i++]);
            }
            i++;
            tok_push(&toks, ch == '\'' ? TK_STRING : TK_QIDENT, text.buf);
            continue;
        }
        if (ch == '(' || ch == ')' || ch == ',' || ch == '.') {
            char *p = malloc(2);
            p[0] = ch; p[1] = '\0';
            tok_push(&toks, TK_PUNCT, p);
            i++;
            continue;
        }
        Str w = {0};
        while (i < n && !word_break(s[i])) str_push(&w, s[i++]);
        tok_push(&toks, TK_WORD, w.buf);
    }
    return toks;
}

static const Tok *g(const Toks *t, size_t i) { return i < t->n ? &t->v[i] : NULL; }

static int is_p(const Tok *t, const char *p) {
    return t && t->kind == TK_PUNCT && strcmp(t->text, p) == 0;
}

static int kw(const Tok *t, const char *w) {
    return t && t->kind == TK_WORD && ci_eq(t->text, w);
}

/* ---- schema model ---- */

typedef struct { char *name, *type; int nullable, is_pk; } Column;
typedef struct { char *name; Column *cols; size_t ncols; } Table;
typedef struct { Table *v; size_t n; } Tables;
typedef struct { char *from_table, *from_col, *to_table, *to_col; } Fk; /* to_col NULL = omitted */
typedef struct { Fk *v; size_t n; } Fks;
typedef struct { Tables tables; Fks fks; char **notes; size_t nnotes; } Schema;

static void tables_push(Tables *l, Table t) {
    l->v = realloc(l->v, (l->n + 1) * sizeof(Table));
    l->v[l->n++] = t;
}

static void fks_push(Fks *l, Fk f) {
    l->v = realloc(l->v, (l->n + 1) * sizeof(Fk));
    l->v[l->n++] = f;
}

static void notes_push(Schema *sc, const char *msg) {
    sc->notes = realloc(sc->notes, (sc->nnotes + 1) * sizeof(char *));
    sc->notes[sc->nnotes++] = strdup(msg);
}

static void table_add_col(Table *t, Column c) {
    t->cols = realloc(t->cols, (t->ncols + 1) * sizeof(Column));
    t->cols[t->ncols++] = c;
}

typedef struct { char **v; size_t n; } Names;

static void names_push(Names *l, char *owned) {
    l->v = realloc(l->v, (l->n + 1) * sizeof(char *));
    l->v[l->n++] = owned;
}

static void free_names(Names *l) {
    for (size_t i = 0; i < l->n; i++) free(l->v[i]);
    free(l->v);
    *l = (Names){0};
}

/* ---- name / paren-list consumers ---- */

/* Consume an identifier (quoted or bare, dotted). Returns 0 when absent. */
static int take_name(const Toks *t, size_t i, Str *name, size_t *end) {
    const Tok *first = g(t, i);
    if (!first || (first->kind != TK_QIDENT && first->kind != TK_WORD)) return 0;
    str_app(name, first->text);
    size_t j = i + 1;
    while (is_p(g(t, j), ".") && g(t, j + 1) &&
           (g(t, j + 1)->kind == TK_QIDENT || g(t, j + 1)->kind == TK_WORD)) {
        str_push(name, '.');
        str_app(name, g(t, j + 1)->text);
        j += 2;
    }
    *end = j;
    return 1;
}

/* Comma-separated identifiers inside a paren group at tokens[i] === '('.
 * On failure the out list is left empty. */
static int paren_list(const Toks *t, size_t i, Names *out, size_t *end) {
    if (!is_p(g(t, i), "(")) return 0;
    size_t j = i + 1;
    for (;;) {
        Str nm = {0};
        size_t next;
        if (!take_name(t, j, &nm, &next)) {
            free(nm.buf);
            free_names(out);
            return 0;
        }
        names_push(out, nm.buf);
        j = next;
        if (is_p(g(t, j), ",")) { j++; continue; }
        if (is_p(g(t, j), ")")) { *end = j + 1; return 1; }
        free_names(out);
        return 0;
    }
}

/* ---- column line ---- */

static const char *const MODIFIERS[] = {
    "NOT", "NULL", "PRIMARY", "KEY", "UNIQUE", "DEFAULT", "REFERENCES",
    "AUTO_INCREMENT", "AUTOINCREMENT", "ON", "COMMENT", "CHECK", "CONSTRAINT", NULL,
};

static int is_modifier(const Tok *t) {
    if (!t || t->kind != TK_WORD) return 0;
    for (size_t i = 0; MODIFIERS[i]; i++) if (ci_eq(t->text, MODIFIERS[i])) return 1;
    return 0;
}

/* Join type tokens with single spaces, collapse whitespace around ( ) and ,
 * (the ports' three regex subs), trim, then upper-case in place. */
static void join_type(const Toks *toks, Str *out) {
    Str raw = {0};
    for (size_t i = 0; i < toks->n; i++) {
        if (i) str_push(&raw, ' ');
        str_app(&raw, toks->v[i].text);
    }
    for (size_t i = 0; i < raw.len; i++) {
        char c = raw.buf[i];
        if (c == '(' || c == ')' || c == ',') {
            while (out->len && sv_isspace(out->buf[out->len - 1])) out->buf[--out->len] = '\0';
            str_push(out, c);
            while (i + 1 < raw.len && sv_isspace(raw.buf[i + 1])) i++;
        } else str_push(out, c);
    }
    while (out->len && sv_isspace(out->buf[0])) {
        memmove(out->buf, out->buf + 1, out->len);
        out->buf[--out->len] = '\0';
    }
    while (out->len && sv_isspace(out->buf[out->len - 1])) out->buf[--out->len] = '\0';
    for (char *p = out->buf ? out->buf : ""; *p; p++) *p = (char)toupper((unsigned char)*p);
    free(raw.buf);
}

static int parse_column(const Toks *line, const char *table, Fks *fks, Column *out) {
    Str name = {0};
    size_t i = 0;
    if (!take_name(line, 0, &name, &i)) { free(name.buf); return 0; }
    size_t m = i;
    while (m < line->n && !is_modifier(&line->v[m])) m++;
    Toks type_toks = { line->v + i, m - i };
    Str type = {0};
    join_type(&type_toks, &type);
    int nullable = 1, pk = 0;
    i = m;
    while (i < line->n) {
        const Tok *t = &line->v[i];
        if (kw(t, "NOT") && kw(g(line, i + 1), "NULL")) { nullable = 0; i += 2; continue; }
        if (kw(t, "NULL")) { i++; continue; }
        if (kw(t, "PRIMARY") && kw(g(line, i + 1), "KEY")) { pk = 1; nullable = 0; i += 2; continue; }
        if (kw(t, "UNIQUE") || kw(t, "AUTO_INCREMENT") || kw(t, "AUTOINCREMENT")) { i++; continue; }
        if (kw(t, "DEFAULT")) {
            i++;
            if (is_p(g(line, i), "(")) {
                int depth = 0;
                while (i < line->n) {
                    if (is_p(&line->v[i], "(")) depth++;
                    if (is_p(&line->v[i], ")")) depth--;
                    i++;
                    if (depth == 0) break;
                }
            } else if (i < line->n) i++;
            continue;
        }
        if (kw(t, "COMMENT")) { i++; if (i < line->n && line->v[i].kind == TK_STRING) i++; continue; }
        if (kw(t, "ON")) {
            i += 2;
            if (kw(g(line, i), "SET") || kw(g(line, i), "NO")) i += 2;
            else if (i < line->n) i++;
            continue;
        }
        if (kw(t, "REFERENCES")) {
            i++;
            Str target = {0};
            size_t j;
            if (take_name(line, i, &target, &j)) {
                i = j;
                char *to_col = NULL;
                if (is_p(g(line, i), "(")) {
                    Names list = {0};
                    size_t k;
                    if (paren_list(line, i, &list, &k)) {
                        to_col = list.v[0];
                        i = k;
                        for (size_t q = 1; q < list.n; q++) free(list.v[q]);
                        free(list.v);
                    }
                }
                Fk fk = { strdup(table), strdup(name.buf), strdup(target.buf), to_col };
                fks_push(fks, fk);
            }
            free(target.buf);
            continue;
        }
        i++; /* unknown modifier tolerated */
    }
    out->name = name.buf;
    out->type = type.buf;
    out->nullable = nullable;
    out->is_pk = pk;
    return 1;
}

/* ---- statement ---- */

/* First token is a word equal (case-insensitively) to w. */
static int line_starts_with(const Toks *line, const char *w) {
    return line->n > 0 && line->v[0].kind == TK_WORD && ci_eq(line->v[0].text, w);
}

/* Half-open token index range [start, end). */
typedef struct { size_t start, end; } Range;

static void parse_statement(const Toks *toks, Schema *sc) {
    size_t i = 0, nj = 0, body_start = 0, body_end = 0, depth = 0, start = 0;
    Str name = {0};
    Table tb = {0};
    Range *lines = NULL;
    size_t nlines = 0;

    if (!kw(g(toks, i), "CREATE")) goto fail;
    i++;
    while (kw(g(toks, i), "TEMP") || kw(g(toks, i), "TEMPORARY") || kw(g(toks, i), "UNLOGGED")) i++;
    if (!kw(g(toks, i), "TABLE")) { notes_push(sc, "Skipped non-table statement."); return; }
    i++;
    if (kw(g(toks, i), "IF") && kw(g(toks, i + 1), "NOT") && kw(g(toks, i + 2), "EXISTS")) i += 3;
    if (!take_name(toks, i, &name, &nj) || !is_p(g(toks, nj), "(")) goto fail;
    i = nj + 1;

    /* body = tokens until the matching close paren (depth-aware) */
    body_start = i;
    for (; i < toks->n; i++) {
        if (is_p(&toks->v[i], "(")) depth++;
        if (is_p(&toks->v[i], ")")) {
            if (depth == 0) break;
            depth--;
        }
    }
    if (i >= toks->n) goto fail;
    body_end = i;

    /* split the body into top-level comma lines */
    depth = 0;
    start = body_start;
    for (size_t k = body_start; k < body_end; k++) {
        if (is_p(&toks->v[k], "(")) depth++;
        if (is_p(&toks->v[k], ")")) depth--;
        if (is_p(&toks->v[k], ",") && depth == 0) {
            lines = realloc(lines, (nlines + 1) * sizeof(Range));
            lines[nlines].start = start;
            lines[nlines].end = k;
            nlines++;
            start = k + 1;
        }
    }
    if (body_end > start) {
        lines = realloc(lines, (nlines + 1) * sizeof(Range));
        lines[nlines].start = start;
        lines[nlines].end = body_end;
        nlines++;
    }

    tb.name = strdup(name.buf);
    for (size_t li = 0; li < nlines; li++) {
        Toks line = { toks->v + lines[li].start, lines[li].end - lines[li].start };
        if (line.n == 0) continue;
        if (line_starts_with(&line, "PRIMARY") && kw(g(&line, 1), "KEY")) {
            Names list = {0};
            size_t end;
            if (paren_list(&line, 2, &list, &end)) {
                for (size_t q = 0; q < list.n; q++)
                    for (size_t c = 0; c < tb.ncols; c++)
                        if (strcmp(tb.cols[c].name, list.v[q]) == 0) {
                            tb.cols[c].is_pk = 1;
                            tb.cols[c].nullable = 0;
                            break; /* first matching column, as the TS/JS ports */
                        }
                free_names(&list);
            }
            continue;
        }
        if (line_starts_with(&line, "FOREIGN") && kw(g(&line, 1), "KEY")) {
            Names from = {0}, to = {0};
            size_t fend = 0, tend = 0;
            Str target = {0};
            if (paren_list(&line, 2, &from, &fend) && kw(g(&line, fend), "REFERENCES") &&
                take_name(&line, fend + 1, &target, &tend)) {
                /* omitted target columns fall back per-index, last one repeating */
                if (is_p(g(&line, tend), "(")) paren_list(&line, tend, &to, &tend);
                for (size_t q = 0; q < from.n; q++) {
                    char *tc = NULL;
                    if (to.n) tc = strdup(q < to.n ? to.v[q] : to.v[to.n - 1]);
                    Fk fk = { strdup(tb.name), strdup(from.v[q]), strdup(target.buf), tc };
                    fks_push(&sc->fks, fk);
                }
            }
            free_names(&from);
            free_names(&to);
            free(target.buf);
            continue;
        }
        if (line_starts_with(&line, "UNIQUE") || line_starts_with(&line, "KEY") ||
            line_starts_with(&line, "INDEX") || line_starts_with(&line, "CHECK") ||
            line_starts_with(&line, "EXCLUDE") || line_starts_with(&line, "CONSTRAINT")) continue;
        Column col;
        if (parse_column(&line, tb.name, &sc->fks, &col)) table_add_col(&tb, col);
    }
    tables_push(&sc->tables, tb);
    free(name.buf);
    free(lines);
    return;

fail:
    free(name.buf);
    free(lines);
    notes_push(sc, "Skipped unparseable statement.");
}

/* ---- DDL → schema ---- */

/* Index of the first table named `name`, or -1 (the ports' index map). */
static long index_of(const Schema *sc, const char *name) {
    for (size_t i = 0; i < sc->tables.n; i++)
        if (strcmp(sc->tables.v[i].name, name) == 0) return (long)i;
    return -1;
}

static void parse_ddl(const char *ddl, Schema *sc) {
    size_t trimmed = 0;
    sv_trim(ddl, strlen(ddl), &trimmed);
    if (trimmed == 0) { notes_push(sc, "No DDL input."); return; }
    Slices stmts = split_statements(ddl);
    for (size_t i = 0; i < stmts.n; i++) {
        size_t n = 0;
        const char *s = sv_trim(stmts.v[i].s, stmts.v[i].n, &n);
        if (n == 0) continue;
        TokOwn own = tokenize(s, n);
        Toks toks = { own.v, own.n };
        parse_statement(&toks, sc);
        free_tokens(&own);
    }
    free(stmts.v);
    /* resolve omitted FK target columns to the referenced table's first PK */
    for (size_t i = 0; i < sc->fks.n; i++) {
        Fk *fk = &sc->fks.v[i];
        if (fk->to_col) continue;
        long t = index_of(sc, fk->to_table);
        const char *pk = "id";
        if (t >= 0)
            for (size_t c = 0; c < (size_t)sc->tables.v[t].ncols; c++)
                if (sc->tables.v[t].cols[c].is_pk) { pk = sc->tables.v[t].cols[c].name; break; }
        fk->to_col = strdup(pk);
    }
}

/* ---- layout ---- */

typedef struct { int x, y, w, h; } Box;
/* The edge's `fk "col → col"` label is computed but never rendered, so the C
 * port carries only the path plus a borrowed FK pointer. */
typedef struct { const Fk *fk; char *path; } Edge;
typedef struct { const Table *table; Box box; Box title_bar; Box *rows; } Laid;
typedef struct { Laid *tables; size_t ntables; Edge *edges; size_t nedges; int w, h; } Geo;

static void geo_free(Geo *geo) {
    for (size_t i = 0; i < geo->ntables; i++) free((void *)geo->tables[i].rows);
    free(geo->tables);
    for (size_t i = 0; i < geo->nedges; i++) free(geo->edges[i].path);
    free(geo->edges);
    geo->tables = NULL;
    geo->edges = NULL;
}

static Geo layout_schema(const Schema *sc) {
    Geo geo = {0};
    size_t n = sc->tables.n;
    if (n == 0) return geo;

    Box *boxes = calloc(n, sizeof(Box));
    for (size_t i = 0; i < n; i++) {
        const Table *t = &sc->tables.v[i];
        size_t text_len = strlen(t->name);
        for (size_t c = 0; c < t->ncols; c++) {
            size_t cl = strlen(t->cols[c].name) + 1 + strlen(t->cols[c].type);
            if (cl > text_len) text_len = cl;
        }
        if (text_len < 1) text_len = 1;
        boxes[i].w = sv_round((double)text_len * CHAR_W + 2 * PAD);
        boxes[i].h = sv_round((double)ROW_H * (double)(1 + t->ncols) + PAD);
    }

    /* layer assignment: longest FK chain, fixed point over at most n passes */
    int *layer_of = calloc(n, sizeof(int));
    for (size_t pass = 0; pass < n; pass++) {
        int changed = 0;
        for (size_t k = 0; k < sc->fks.n; k++) {
            const Fk *fk = &sc->fks.v[k];
            long ti = index_of(sc, fk->from_table), tj = index_of(sc, fk->to_table);
            if (ti < 0 || tj < 0 || ti == tj) continue;
            if (layer_of[ti] < layer_of[tj] + 1) { layer_of[ti] = layer_of[tj] + 1; changed = 1; }
        }
        if (!changed) break;
    }
    int max_layer = 0;
    for (size_t i = 0; i < n; i++) if (layer_of[i] > max_layer) max_layer = layer_of[i];

    int y = 0;
    for (int li = 0; li <= max_layer; li++) {
        int x = 0, layer_h = 0;
        for (size_t i = 0; i < n; i++) {
            if (layer_of[i] != li) continue;
            boxes[i].x = x;
            boxes[i].y = y;
            x += boxes[i].w + COL_GAP;
            if (boxes[i].h > layer_h) layer_h = boxes[i].h;
        }
        if (x - COL_GAP > geo.w) geo.w = x - COL_GAP;
        if (y + layer_h > geo.h) geo.h = y + layer_h;
        y += layer_h + LAYER_GAP;
    }
    free(layer_of);

    geo.tables = calloc(n, sizeof(Laid));
    geo.ntables = n;
    for (size_t i = 0; i < n; i++) {
        Laid *l = &geo.tables[i];
        const Table *t = &sc->tables.v[i];
        l->table = t;
        l->box = boxes[i];
        l->title_bar = (Box){ boxes[i].x, boxes[i].y, boxes[i].w, ROW_H };
        l->rows = t->ncols ? calloc(t->ncols, sizeof(Box)) : NULL;
        for (size_t c = 0; c < t->ncols; c++)
            l->rows[c] = (Box){ boxes[i].x, boxes[i].y + ROW_H * (int)(1 + c), boxes[i].w, ROW_H };
    }

    geo.edges = sc->fks.n ? calloc(sc->fks.n, sizeof(Edge)) : NULL;
    for (size_t k = 0; k < sc->fks.n; k++) {
        const Fk *fk = &sc->fks.v[k];
        long fi = index_of(sc, fk->from_table), ti = index_of(sc, fk->to_table);
        if (fi < 0 || ti < 0) continue;
        const Box *fb = &boxes[fi], *tb = &boxes[ti];
        int x1 = tb->x + sv_round(tb->w / 2.0), y1 = tb->y + tb->h;
        int x2 = fb->x + sv_round(fb->w / 2.0), y2 = fb->y;
        int mid = sv_round((y1 + y2) / 2.0);
        Edge *e = &geo.edges[geo.nedges++];
        e->fk = fk;
        e->path = malloc(96);
        snprintf(e->path, 96, "M %d %d V %d H %d V %d", x1, y1, mid, x2, y2);
    }
    free(boxes);
    return geo;
}

/* ---- SVG rendering ---- */

static void sv_esc(Str *out, const char *s) {
    for (const char *p = s; *p; p++) {
        if (*p == '&') str_app(out, "&amp;");
        else if (*p == '<') str_app(out, "&lt;");
        else if (*p == '>') str_app(out, "&gt;");
        else if (*p == '"') str_app(out, "&quot;");
        else str_push(out, *p);
    }
}

/* Box of the table named `name` — last occurrence wins, as in the ports'
 * boxOf map built from the laid-out tables. */
static const Box *box_of(const Geo *geo, const char *name) {
    for (size_t i = geo->ntables; i > 0; i--)
        if (strcmp(geo->tables[i - 1].table->name, name) == 0) return &geo->tables[i - 1].box;
    return NULL;
}

static char *render_svg(const Geo *geo) {
    Str out = {0};
    char head[160], buf[256];
    snprintf(head, sizeof head,
             "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 %d %d\" "
             "class=\"sv-root\" role=\"img\"><title>Schema diagram</title>", geo->w, geo->h);
    str_app(&out, head);
    for (size_t i = 0; i < geo->nedges; i++) {
        const Edge *e = &geo->edges[i];
        const Box *fb = box_of(geo, e->fk->from_table);
        if (!fb) continue;
        int ax = fb->x + sv_round(fb->w / 2.0);
        snprintf(buf, sizeof buf,
                 "<path class=\"sv-edge\" d=\"%s\"/><polygon class=\"sv-arrow\" "
                 "points=\"%d,%d %d,%d %d,%d\"/>",
                 e->path, ax - 5, fb->y - 8, ax + 5, fb->y - 8, ax, fb->y);
        str_app(&out, buf);
    }
    for (size_t i = 0; i < geo->ntables; i++) {
        const Laid *t = &geo->tables[i];
        const Box *b = &t->box, *tb = &t->title_bar;
        snprintf(buf, sizeof buf,
                 "<g class=\"sv-table\"><rect class=\"sv-box\" x=\"%d\" y=\"%d\" "
                 "width=\"%d\" height=\"%d\" rx=\"6\"/>"
                 "<rect class=\"sv-titlebar\" x=\"%d\" y=\"%d\" width=\"%d\" height=\"%d\" rx=\"6\"/>"
                 "<text class=\"sv-title\" x=\"%d\" y=\"%d\">",
                 b->x, b->y, b->w, b->h, tb->x, tb->y, tb->w, tb->h, b->x + 8, tb->y + 17);
        str_app(&out, buf);
        sv_esc(&out, t->table->name);
        str_app(&out, "</text>");
        for (size_t c = 0; c < t->table->ncols; c++) {
            const Column *col = &t->table->cols[c];
            snprintf(buf, sizeof buf, "<text class=\"%s\" x=\"%d\" y=\"%d\">",
                     col->is_pk ? "sv-pk" : "sv-col", t->rows[c].x + 8, t->rows[c].y + 17);
            str_app(&out, buf);
            sv_esc(&out, col->name);
            str_push(&out, ' ');
            sv_esc(&out, col->type);
            str_app(&out, "</text>");
        }
        str_app(&out, "</g>");
    }
    str_app(&out, "</svg>");
    return out.buf;
}

/* ---- main entry ---- */

typedef struct { char *svg; Schema schema; } SvResult;

SvResult sv_ddl_to_svg(const char *ddl) {
    SvResult r;
    r.schema = (Schema){0};
    parse_ddl(ddl, &r.schema);
    Geo geo = layout_schema(&r.schema);
    r.svg = render_svg(&geo);
    geo_free(&geo);
    return r;
}

/* Caller frees: sv_free_result(&res). */
void sv_free_result(SvResult *r) {
    free(r->svg);
    for (size_t i = 0; i < r->schema.tables.n; i++) {
        Table *t = &r->schema.tables.v[i];
        for (size_t c = 0; c < t->ncols; c++) { free(t->cols[c].name); free(t->cols[c].type); }
        free(t->cols);
        free(t->name);
    }
    free(r->schema.tables.v);
    for (size_t i = 0; i < r->schema.fks.n; i++) {
        Fk *fk = &r->schema.fks.v[i];
        free(fk->from_table);
        free(fk->from_col);
        free(fk->to_table);
        free(fk->to_col);
    }
    free(r->schema.fks.v);
    for (size_t i = 0; i < r->schema.nnotes; i++) free(r->schema.notes[i]);
    free(r->schema.notes);
    r->svg = NULL;
    r->schema = (Schema){0};
}

/* Example:
 *   SvResult r = sv_ddl_to_svg(
 *       "CREATE TABLE users (id INT PRIMARY KEY);"
 *       "CREATE TABLE posts (id INT PRIMARY KEY, "
 *       "user_id INT REFERENCES users(id), title TEXT);");
 *   puts(r.svg);   → users box on layer 0, posts below, one FK edge —
 *   byte-identical to the TS/Go/… ports (integer geometry, same defaults).
 *   sv_free_result(&r); */

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 →