feat: render Markdown, mathematics and Mermaid diagrams server-side
@@ -0,0 +1,65 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package diagram renders Mermaid diagrams to SVG with an engine of its
|
||||
// own, built on the standard library alone. The flowchart grammar,
|
||||
// including the historical "graph" spelling, and the sequenceDiagram
|
||||
// grammar are covered; every other diagram type is refused with an error
|
||||
// naming it.
|
||||
//
|
||||
// The output is deterministic: the same source always renders
|
||||
// byte-identical SVG, because every layout decision falls back to the
|
||||
// order of appearance and never to map iteration.
|
||||
package diagram
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Render renders the Mermaid diagram source to SVG. Statements are lines;
|
||||
// blank lines and lines opening with %% are comments. A diagram type the
|
||||
// engine does not carry is refused with an error naming the type, and a
|
||||
// statement the grammar does not know is refused with an error naming the
|
||||
// line.
|
||||
func Render(source []byte) ([]byte, error) {
|
||||
lines := statementLines(source)
|
||||
if len(lines) == 0 {
|
||||
return nil, fmt.Errorf("diagram: empty source")
|
||||
}
|
||||
header := strings.Fields(lines[0])
|
||||
switch strings.ToLower(header[0]) {
|
||||
case "flowchart", "graph":
|
||||
dir := "TB"
|
||||
if len(header) > 1 {
|
||||
dir = strings.ToUpper(header[1])
|
||||
if !flowDirections[dir] {
|
||||
return nil, fmt.Errorf("diagram: unknown flow direction %q", header[1])
|
||||
}
|
||||
}
|
||||
return renderFlowchart(lines[1:], dir)
|
||||
case "sequencediagram":
|
||||
auto := len(header) > 1 && strings.ToLower(header[1]) == "autonumber"
|
||||
return renderSequence(lines[1:], auto)
|
||||
default:
|
||||
return nil, fmt.Errorf("diagram: unsupported diagram type %q", header[0])
|
||||
}
|
||||
}
|
||||
|
||||
var flowDirections = map[string]bool{
|
||||
"TB": true, "TD": true, "BT": true, "LR": true, "RL": true,
|
||||
}
|
||||
|
||||
// statementLines splits the source into statements: one statement per
|
||||
// line, comments and blank lines dropped.
|
||||
func statementLines(source []byte) []string {
|
||||
var lines []string
|
||||
for raw := range strings.SplitSeq(string(source), "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" || strings.HasPrefix(line, "%%") {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package diagram
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// goldenCorpus is the project's own hand-written corpus. Every input has
|
||||
// its rendered SVG in testdata, byte for byte.
|
||||
var goldenCorpus = []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"fc-basic", "flowchart TD\n A[Start] --> B[End]\n"},
|
||||
{"fc-lr", "flowchart LR\n A --> B --> C\n"},
|
||||
{"fc-shapes", "flowchart TD\n A[Rect]\n B(Round)\n C{Diamond}\n D((Circle))\n E[[Subroutine]]\n F>Asymmetric]\n G([Stadium])\n H{{Hexagon}}\n"},
|
||||
{"fc-edge-kinds", "flowchart TD\n A --- B\n B --> C\n C -.- D\n D -.-> E\n E ==> F\n F ---> G\n"},
|
||||
{"fc-edge-labels", "flowchart TD\n A -- text --> B\n A -->|piped| C\n A -. dotted text .-> D\n A == thick text ==> E\n"},
|
||||
{"fc-branching", "flowchart TD\n A --> B & C\n D & E --> F\n B --> F\n"},
|
||||
{"fc-quoted-labels", `flowchart TD
|
||||
A["Label with (brackets) and \"quotes\""] --> B['single']
|
||||
`},
|
||||
{"fc-subgraph", "flowchart TD\n A --> B\n subgraph Group One\n B --> C\n end\n C --> D\n"},
|
||||
{"fc-subgraph-direction", "flowchart TB\n A --> B\n subgraph Inner\n direction LR\n B --> C\n B --> D\n end\n"},
|
||||
{"fc-classes", "flowchart TD\n A[First]:::urgent --> B[Second]\n classDef urgent fill:#f96,stroke:#333,stroke-width:2px\n class A,B urgent\n style B fill:#9f9\n linkStyle 0 stroke:#f39\n"},
|
||||
{"fc-graph-alias", "graph LR\n A[Alpha] --> B[Beta]\n"},
|
||||
{"fc-cycle", "flowchart TD\n A --> B --> C --> A\n C --> D\n"},
|
||||
{"fc-single", "flowchart TD\n Lonely\n"},
|
||||
{"fc-multiline", "flowchart TD\n A[\"first line<br/>second line\"] --> B\n"},
|
||||
{"fc-direction-line", "flowchart TD\n A --> B\n direction LR\n"},
|
||||
{"sq-basic", "sequenceDiagram\n Alice->>Bob: Hello\n Bob-->>Alice: Hi\n"},
|
||||
{"sq-arrows", "sequenceDiagram\n A->>B: solid arrow\n B-->>A: dashed arrow\n A-xB: solid cross\n B--xA: dashed cross\n A->B: solid open\n B-->A: dashed open\n"},
|
||||
{"sq-participants", "sequenceDiagram\n participant A as Alice\n actor B as Bob\n participant C\n A->>B: hi\n B->>C: forward\n"},
|
||||
{"sq-notes", "sequenceDiagram\n participant A\n participant B\n Note left of A: on the left\n Note right of B: on the right\n Note over A: alone\n Note over A,B: spanning\n"},
|
||||
{"sq-alt", "sequenceDiagram\n A->>B: check\n alt yes\n A->>B: proceed\n else no\n B-->>A: refuse\n end\n"},
|
||||
{"sq-loop-opt", "sequenceDiagram\n loop each round\n A->>B: tick\n end\n opt maybe\n B-->>A: tock\n end\n"},
|
||||
{"sq-par", "sequenceDiagram\n par left\n A->>B: one\n and right\n C->>D: two\n end\n"},
|
||||
{"sq-critical", "sequenceDiagram\n critical locked\n A->>B: work\n option unlocked\n B-->>A: skip\n end\n"},
|
||||
{"sq-break", "sequenceDiagram\n break failure\n A->>B: stop\n end\n"},
|
||||
{"sq-rect", "sequenceDiagram\n rect rgb(200, 255, 200)\n A->>B: inside\n end\n rect #e0e0ff\n B-->>A: also inside\n end\n"},
|
||||
{"sq-autonumber", "sequenceDiagram autonumber\n A->>B: first\n B-->>A: second\n A->>A: self\n"},
|
||||
{"sq-divider", "sequenceDiagram\n A->>B: before\n ... section break ...\n A->>B: after\n"},
|
||||
{"sq-activation", "sequenceDiagram\n activate A\n A->>+B: request\n B-->>-A: response\n deactivate A\n"},
|
||||
{"sq-self", "sequenceDiagram\n A->>A: think\n A-->A: rethink\n"},
|
||||
{"sq-nested", "sequenceDiagram\n alt outer\n loop inner\n A->>B: x\n end\n else other\n B->>A: y\n end\n"},
|
||||
}
|
||||
|
||||
func TestGolden(t *testing.T) {
|
||||
for _, tc := range goldenCorpus {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := Render([]byte(tc.input))
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
golden := filepath.Join("testdata", tc.name+".svg")
|
||||
if os.Getenv("SCRIPTORIUM_GOLDEN_UPDATE") == "1" {
|
||||
if err := os.WriteFile(golden, got, 0o644); err != nil {
|
||||
t.Fatalf("write golden: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
want, err := os.ReadFile(golden)
|
||||
if err != nil {
|
||||
t.Fatalf("read golden: %v (run with SCRIPTORIUM_GOLDEN_UPDATE=1 once)", err)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Errorf("output changed; run with SCRIPTORIUM_GOLDEN_UPDATE=1 after review\ngot: %s\nwant: %s", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWellFormed(t *testing.T) {
|
||||
for _, tc := range goldenCorpus {
|
||||
out, err := Render([]byte(tc.input))
|
||||
if err != nil {
|
||||
t.Fatalf("%s: render: %v", tc.name, err)
|
||||
}
|
||||
dec := xml.NewDecoder(bytes.NewReader(out))
|
||||
for {
|
||||
_, err := dec.Token()
|
||||
if err != nil {
|
||||
if err.Error() == "EOF" {
|
||||
break
|
||||
}
|
||||
t.Errorf("%s: malformed XML: %v\n%s", tc.name, err, out)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeterministic(t *testing.T) {
|
||||
for _, tc := range goldenCorpus {
|
||||
first, err := Render([]byte(tc.input))
|
||||
if err != nil {
|
||||
t.Fatalf("%s: render: %v", tc.name, err)
|
||||
}
|
||||
for range 5 {
|
||||
next, err := Render([]byte(tc.input))
|
||||
if err != nil || !bytes.Equal(first, next) {
|
||||
t.Fatalf("%s: output differs between calls", tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefusesOtherTypes(t *testing.T) {
|
||||
for _, src := range []string{
|
||||
"pie\n \"a\": 50\n",
|
||||
"gantt\n title x\n",
|
||||
"classDiagram\n A <|-- B\n",
|
||||
"stateDiagram-v2\n [*] --> A\n",
|
||||
"erDiagram\n",
|
||||
} {
|
||||
_, err := Render([]byte(src))
|
||||
if err == nil {
|
||||
t.Errorf("expected refusal for %q", src)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
src string
|
||||
want string
|
||||
}{
|
||||
{"flowchart TD\n A -->\n", "flowchart line 2"},
|
||||
{"flowchart TD\n subgraph S\n A --> B\n", "has no end"},
|
||||
{"flowchart TD\n end\n", "end without subgraph"},
|
||||
{"flowchart TD\n click A callback\n", "click is unsupported"},
|
||||
{"flowchart SIDEWAYS\n A --> B\n", "unknown flow direction"},
|
||||
{"flowchart TD\n A -- B\n", "unfinished edge token"},
|
||||
{"sequenceDiagram\n A->B no colon\n", "message needs a colon"},
|
||||
{"sequenceDiagram\n nonsense line\n", "cannot parse"},
|
||||
{"sequenceDiagram\n end\n", "end without a block"},
|
||||
{"sequenceDiagram\n alt x\n A->>B: y\n", "has no end"},
|
||||
{"sequenceDiagram\n rect blue\n A->>B: y\n end\n", "rect needs"},
|
||||
{"sequenceDiagram\n note behind A: x\n", "note needs left of"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
_, err := Render([]byte(tc.src))
|
||||
if err == nil {
|
||||
t.Errorf("input %q: expected error", tc.src)
|
||||
continue
|
||||
}
|
||||
if !bytes.Contains([]byte(err.Error()), []byte(tc.want)) {
|
||||
t.Errorf("input %q: error %q does not mention %q", tc.src, err, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptySource(t *testing.T) {
|
||||
if _, err := Render([]byte("%% only a comment\n\n")); err == nil {
|
||||
t.Error("expected an error for an empty diagram")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package diagram
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Layout constants, in SVG units.
|
||||
const (
|
||||
margin = 32
|
||||
rankGap = 64
|
||||
laneGap = 48
|
||||
nodeMinWidth = 96
|
||||
textPad = 28
|
||||
)
|
||||
|
||||
type laidNode struct {
|
||||
x, y, w, h int // centre and size in the TB orientation
|
||||
}
|
||||
|
||||
type flowLayout struct {
|
||||
laid []laidNode // parallel to d.nodes
|
||||
rank []int // parallel to d.nodes
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
func renderFlowchart(lines []string, dir string) ([]byte, error) {
|
||||
d, err := parseFlowchart(lines, dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lay := layoutFlowchart(d)
|
||||
return writeFlowchart(d, lay), nil
|
||||
}
|
||||
|
||||
// layoutFlowchart assigns ranks by longest path, orders each rank by
|
||||
// barycentre sweeps with the order of appearance as the tie breaker, and
|
||||
// packs the rows. Every step walks nodes and edges by index, so the
|
||||
// layout never depends on map iteration.
|
||||
func layoutFlowchart(d *flowDiagram) *flowLayout {
|
||||
n := len(d.nodes)
|
||||
lay := &flowLayout{laid: make([]laidNode, n), rank: make([]int, n)}
|
||||
|
||||
for i, node := range d.nodes {
|
||||
lay.laid[i].w, lay.laid[i].h = nodeSize(node)
|
||||
}
|
||||
|
||||
// Ranks: relax along the edges, in index order, one pass per node at
|
||||
// most. A cycle cannot push the ranks past that bound.
|
||||
for pass := 0; pass <= len(d.nodes); pass++ {
|
||||
changed := false
|
||||
for _, e := range d.edges {
|
||||
if lay.rank[e.to] < lay.rank[e.from]+1 {
|
||||
lay.rank[e.to] = lay.rank[e.from] + 1
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
maxRank := 0
|
||||
for _, r := range lay.rank {
|
||||
if r > maxRank {
|
||||
maxRank = r
|
||||
}
|
||||
}
|
||||
rows := make([][]int, maxRank+1)
|
||||
for i, r := range lay.rank {
|
||||
rows[r] = append(rows[r], i)
|
||||
}
|
||||
|
||||
// Four alternating barycentre sweeps.
|
||||
for pass := range 4 {
|
||||
down := pass%2 == 0
|
||||
ranks := make([]int, 0, len(rows))
|
||||
if down {
|
||||
for r := range rows {
|
||||
ranks = append(ranks, r)
|
||||
}
|
||||
} else {
|
||||
for r := range slices.Backward(rows) {
|
||||
ranks = append(ranks, r)
|
||||
}
|
||||
}
|
||||
for _, r := range ranks {
|
||||
if down && r == 0 || !down && r == len(rows)-1 {
|
||||
continue
|
||||
}
|
||||
neighbour := r - 1
|
||||
if !down {
|
||||
neighbour = r + 1
|
||||
}
|
||||
position := map[int]int{}
|
||||
for p, i := range rows[neighbour] {
|
||||
position[i] = p
|
||||
}
|
||||
bary := map[int]int{}
|
||||
sum := map[int]int{}
|
||||
for _, e := range d.edges {
|
||||
from, to := e.from, e.to
|
||||
if !down {
|
||||
from, to = to, from
|
||||
}
|
||||
if lay.rank[from] == neighbour && lay.rank[to] == r {
|
||||
if p, ok := position[from]; ok {
|
||||
sum[to] += p
|
||||
bary[to]++
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.SliceStable(rows[r], func(a, b int) bool {
|
||||
ia, ib := rows[r][a], rows[r][b]
|
||||
switch {
|
||||
case bary[ia] == 0 && bary[ib] == 0:
|
||||
return ia < ib
|
||||
case bary[ia] == 0:
|
||||
return false
|
||||
case bary[ib] == 0:
|
||||
return true
|
||||
}
|
||||
ma := sum[ia] / bary[ia]
|
||||
mb := sum[ib] / bary[ib]
|
||||
if ma != mb {
|
||||
return ma < mb
|
||||
}
|
||||
return ia < ib
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Rank heights, row widths and positions.
|
||||
rowHeight := make([]int, len(rows))
|
||||
rowWidth := make([]int, len(rows))
|
||||
for r, row := range rows {
|
||||
for _, i := range row {
|
||||
if lay.laid[i].h > rowHeight[r] {
|
||||
rowHeight[r] = lay.laid[i].h
|
||||
}
|
||||
rowWidth[r] += lay.laid[i].w + laneGap
|
||||
}
|
||||
rowWidth[r] -= laneGap
|
||||
}
|
||||
maxWidth := 0
|
||||
y := margin
|
||||
rowY := make([]int, len(rows))
|
||||
for r := range rows {
|
||||
rowY[r] = y + rowHeight[r]/2
|
||||
y += rowHeight[r] + rankGap
|
||||
if rowWidth[r] > maxWidth {
|
||||
maxWidth = rowWidth[r]
|
||||
}
|
||||
}
|
||||
for r, row := range rows {
|
||||
x := margin + (maxWidth-rowWidth[r])/2 + laneGap/2
|
||||
for _, i := range row {
|
||||
x += lay.laid[i].w / 2
|
||||
lay.laid[i].x = x
|
||||
lay.laid[i].y = rowY[r]
|
||||
x += lay.laid[i].w/2 + laneGap/2
|
||||
}
|
||||
}
|
||||
lay.width = maxWidth + 2*margin
|
||||
lay.height = y - rankGap + margin
|
||||
|
||||
// A subgraph with its own direction lays its members out inside the
|
||||
// box they occupy, in that direction.
|
||||
for _, sg := range d.subgraphs {
|
||||
if sg.dir == "" || sg.dir == d.dir || len(sg.nodes) < 2 {
|
||||
continue
|
||||
}
|
||||
reLayoutSubgraph(d, lay, sg)
|
||||
}
|
||||
return lay
|
||||
}
|
||||
|
||||
func reLayoutSubgraph(d *flowDiagram, lay *flowLayout, sg *flowSubgraph) {
|
||||
minX, minY, maxX, maxY := boundingBox(lay, sg.nodes)
|
||||
horizontal := sg.dir == "LR" || sg.dir == "RL"
|
||||
members := map[int]bool{}
|
||||
for _, i := range sg.nodes {
|
||||
members[i] = true
|
||||
}
|
||||
rank := map[int]int{}
|
||||
for pass := 0; pass <= len(sg.nodes); pass++ {
|
||||
changed := false
|
||||
for _, e := range d.edges {
|
||||
if members[e.from] && members[e.to] && rank[e.to] < rank[e.from]+1 {
|
||||
rank[e.to] = rank[e.from] + 1
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
break
|
||||
}
|
||||
}
|
||||
maxRank := 0
|
||||
for _, i := range sg.nodes {
|
||||
if rank[i] > maxRank {
|
||||
maxRank = rank[i]
|
||||
}
|
||||
}
|
||||
rows := make([][]int, maxRank+1)
|
||||
for _, i := range sg.nodes {
|
||||
rows[rank[i]] = append(rows[rank[i]], i)
|
||||
}
|
||||
centreS := (minX + maxX) / 2
|
||||
centreP := (minY + maxY) / 2
|
||||
if horizontal {
|
||||
centreS, centreP = centreP, centreS
|
||||
}
|
||||
spanP := (maxY - minY) - laneGap
|
||||
if horizontal {
|
||||
spanP = (maxX - minX) - laneGap
|
||||
}
|
||||
for r, row := range rows {
|
||||
p := centreP - spanP/2 + (spanP*(2*r+1))/(2*(maxRank+1))
|
||||
var sSize int
|
||||
for _, i := range row {
|
||||
if horizontal {
|
||||
sSize += lay.laid[i].h
|
||||
} else {
|
||||
sSize += lay.laid[i].w
|
||||
}
|
||||
}
|
||||
sSize += laneGap / 2 * (len(row) - 1)
|
||||
s := centreS - sSize/2
|
||||
for _, i := range row {
|
||||
if horizontal {
|
||||
s += lay.laid[i].h / 2
|
||||
lay.laid[i].x = p
|
||||
lay.laid[i].y = s
|
||||
s += lay.laid[i].h/2 + laneGap/2
|
||||
} else {
|
||||
s += lay.laid[i].w / 2
|
||||
lay.laid[i].y = p
|
||||
lay.laid[i].x = s
|
||||
s += lay.laid[i].w/2 + laneGap/2
|
||||
}
|
||||
}
|
||||
}
|
||||
// Keep every member inside the box.
|
||||
for _, i := range sg.nodes {
|
||||
l := &lay.laid[i]
|
||||
l.x = clamp(l.x, minX+l.w/2, maxX-l.w/2)
|
||||
l.y = clamp(l.y, minY+l.h/2, maxY-l.h/2)
|
||||
}
|
||||
}
|
||||
|
||||
func clamp(v, low, high int) int {
|
||||
if v < low {
|
||||
return low
|
||||
}
|
||||
if v > high {
|
||||
return high
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func boundingBox(lay *flowLayout, nodes []int) (int, int, int, int) {
|
||||
minX, minY := 1<<30, 1<<30
|
||||
maxX, maxY := -1<<30, -1<<30
|
||||
for _, i := range nodes {
|
||||
l := lay.laid[i]
|
||||
minX = min(minX, l.x-l.w/2)
|
||||
maxX = max(maxX, l.x+l.w/2)
|
||||
minY = min(minY, l.y-l.h/2)
|
||||
maxY = max(maxY, l.y+l.h/2)
|
||||
}
|
||||
return minX, minY, maxX, maxY
|
||||
}
|
||||
|
||||
func nodeSize(node *flowNode) (int, int) {
|
||||
lines := labelLines(node.label)
|
||||
width := 0
|
||||
for _, l := range lines {
|
||||
width = max(width, textWidth(l, 14))
|
||||
}
|
||||
h := 26 + 18*len(lines)
|
||||
w := max(width+textPad, nodeMinWidth)
|
||||
switch node.shape {
|
||||
case "diamond":
|
||||
w = max(width*2+textPad*2, 150)
|
||||
h = max(30+26*len(lines), w/2)
|
||||
case "circle":
|
||||
d := max(max(width+40, h), 68)
|
||||
w, h = d, d
|
||||
}
|
||||
return w, h
|
||||
}
|
||||
|
||||
// mapPoint maps a TB-space point into the final orientation.
|
||||
func mapPoint(dir string, p point, width, height int) point {
|
||||
switch dir {
|
||||
case "BT":
|
||||
return point{p.x, height - p.y}
|
||||
case "LR":
|
||||
return point{p.y, p.x}
|
||||
case "RL":
|
||||
return point{height - p.y, p.x}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// edgeStroke gives the path attributes for an edge kind, with the styles
|
||||
// of the linkStyle declarations appended.
|
||||
func edgeStroke(kind string, styles []stylePair) string {
|
||||
attrs := ` fill="none" stroke="#555" stroke-width="2"`
|
||||
switch {
|
||||
case strings.HasPrefix(kind, "thick"):
|
||||
attrs = ` fill="none" stroke="#555" stroke-width="3.5"`
|
||||
case strings.HasPrefix(kind, "dotted"):
|
||||
attrs = ` fill="none" stroke="#555" stroke-width="2" stroke-dasharray="6 5"`
|
||||
}
|
||||
return attrs + styleString(styles)
|
||||
}
|
||||
|
||||
// nodeStyles gathers the inline styles and the class declarations of a
|
||||
// node, declaration order preserved.
|
||||
func (d *flowDiagram) nodeStyles(n *flowNode) []stylePair {
|
||||
pairs := append([]stylePair{}, n.styles...)
|
||||
for _, class := range n.classes {
|
||||
pairs = append(pairs, d.classes[class]...)
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
func writeFlowchart(d *flowDiagram, lay *flowLayout) []byte {
|
||||
width, height := lay.width, lay.height
|
||||
if d.dir == "LR" || d.dir == "RL" {
|
||||
width, height = height, width
|
||||
}
|
||||
svg := newSVGBuilder(width, height)
|
||||
|
||||
// Subgraph boxes, outer before inner, so the parents frame their
|
||||
// children.
|
||||
for _, sg := range slices.Backward(d.subgraphs) {
|
||||
|
||||
if len(sg.nodes) == 0 {
|
||||
continue
|
||||
}
|
||||
minX, minY, maxX, maxY := boundingBox(lay, sg.nodes)
|
||||
a := mapPoint(d.dir, point{minX - 20, minY - 40}, width, height)
|
||||
b := mapPoint(d.dir, point{maxX + 20, maxY + 18}, width, height)
|
||||
x0, y0 := min(a.x, b.x), min(a.y, b.y)
|
||||
x1, y1 := max(a.x, b.x), max(a.y, b.y)
|
||||
svg.rect(x0, y0, x1-x0, y1-y0, 8, ` fill="#f5f5f5" fill-opacity="0.7" stroke="#999"`)
|
||||
svg.text(point{x0 + 10, y0 + 18}, sg.title, "start", ` font-size="14" font-weight="bold"`)
|
||||
}
|
||||
|
||||
// Edges.
|
||||
type drawn struct {
|
||||
p0, c1, c2, p3 point
|
||||
arrow bool
|
||||
label string
|
||||
attrs string
|
||||
}
|
||||
var drawnEdges []drawn
|
||||
for i, e := range d.edges {
|
||||
a, b := lay.laid[e.from], lay.laid[e.to]
|
||||
var p0, p3, c1, c2 point
|
||||
switch {
|
||||
case e.from == e.to:
|
||||
p0 = point{a.x + a.w/2, a.y - 8}
|
||||
p3 = point{a.x + a.w/2, a.y + 8}
|
||||
c1 = point{a.x + a.w/2 + 46, a.y - 28}
|
||||
c2 = point{a.x + a.w/2 + 46, a.y + 28}
|
||||
case lay.rank[e.to] > lay.rank[e.from]:
|
||||
p0 = point{a.x, a.y + a.h/2}
|
||||
p3 = point{b.x, b.y - b.h/2}
|
||||
mid := max((p3.y-p0.y)/2, 24)
|
||||
c1 = point{p0.x, p0.y + mid}
|
||||
c2 = point{p3.x, p3.y - mid}
|
||||
case lay.rank[e.to] < lay.rank[e.from]:
|
||||
p0 = point{a.x, a.y - a.h/2}
|
||||
p3 = point{b.x, b.y + b.h/2}
|
||||
mid := max((p0.y-p3.y)/2, 24)
|
||||
c1 = point{p0.x, p0.y - mid}
|
||||
c2 = point{p3.x, p3.y + mid}
|
||||
default:
|
||||
if b.x >= a.x {
|
||||
p0 = point{a.x + a.w/2, a.y}
|
||||
p3 = point{b.x - b.w/2, b.y}
|
||||
} else {
|
||||
p0 = point{a.x - a.w/2, a.y}
|
||||
p3 = point{b.x + b.w/2, b.y}
|
||||
}
|
||||
c1 = point{p0.x + 42, p0.y}
|
||||
c2 = point{p3.x - 42, p3.y}
|
||||
}
|
||||
styles := append([]stylePair{}, d.linkDefault...)
|
||||
styles = append(styles, d.linkByIndex[i]...)
|
||||
drawnEdges = append(drawnEdges, drawn{
|
||||
p0: mapPoint(d.dir, p0, width, height),
|
||||
c1: mapPoint(d.dir, c1, width, height),
|
||||
c2: mapPoint(d.dir, c2, width, height),
|
||||
p3: mapPoint(d.dir, p3, width, height),
|
||||
arrow: strings.HasSuffix(e.kind, "-arrow"),
|
||||
label: e.label,
|
||||
attrs: edgeStroke(e.kind, styles),
|
||||
})
|
||||
}
|
||||
for _, e := range drawnEdges {
|
||||
svg.path(fmt.Sprintf("M %d %d C %d %d, %d %d, %d %d", e.p0.x, e.p0.y, e.c1.x, e.c1.y, e.c2.x, e.c2.y, e.p3.x, e.p3.y), e.attrs)
|
||||
}
|
||||
for _, e := range drawnEdges {
|
||||
if !e.arrow {
|
||||
continue
|
||||
}
|
||||
svg.polygon(arrowHead(e.p3, e.c2), ` fill="#555"`)
|
||||
}
|
||||
|
||||
// Nodes.
|
||||
for i, node := range d.nodes {
|
||||
l := lay.laid[i]
|
||||
c := mapPoint(d.dir, point{l.x, l.y}, width, height)
|
||||
w, h := l.w, l.h
|
||||
if d.dir == "LR" || d.dir == "RL" {
|
||||
w, h = h, w
|
||||
}
|
||||
attrs := ` fill="#ffffff" stroke="#333" stroke-width="1.5"` + styleString(d.nodeStyles(node))
|
||||
switch node.shape {
|
||||
case "round":
|
||||
svg.rect(c.x-w/2, c.y-h/2, w, h, 10, attrs)
|
||||
case "stadium":
|
||||
svg.rect(c.x-w/2, c.y-h/2, w, h, min(w, h)/2, attrs)
|
||||
case "circle":
|
||||
svg.rect(c.x-w/2, c.y-h/2, w, h, w/2, attrs)
|
||||
case "diamond":
|
||||
svg.polygon([]point{{c.x, c.y - h/2}, {c.x + w/2, c.y}, {c.x, c.y + h/2}, {c.x - w/2, c.y}}, attrs)
|
||||
case "hex":
|
||||
cut := min(20, w/4)
|
||||
svg.polygon([]point{{c.x - w/2 + cut, c.y - h/2}, {c.x + w/2 - cut, c.y - h/2}, {c.x + w/2, c.y}, {c.x + w/2 - cut, c.y + h/2}, {c.x - w/2 + cut, c.y + h/2}, {c.x - w/2, c.y}}, attrs)
|
||||
case "asym":
|
||||
svg.polygon([]point{{c.x - w/2, c.y - h/2}, {c.x + w/2 - 18, c.y - h/2}, {c.x + w/2, c.y}, {c.x + w/2 - 18, c.y + h/2}, {c.x - w/2, c.y + h/2}}, attrs)
|
||||
case "sub":
|
||||
svg.rect(c.x-w/2, c.y-h/2, w, h, 0, attrs)
|
||||
if d.dir == "LR" || d.dir == "RL" {
|
||||
svg.line(c.x, c.y-h/2+5, c.x, c.y+h/2-5, ` stroke="#333" stroke-width="1.5"`)
|
||||
svg.line(c.x, c.y-h/2+10, c.x, c.y+h/2-10, ` stroke="#333" stroke-width="1.5"`)
|
||||
} else {
|
||||
svg.line(c.x-w/2+5, c.y, c.x+w/2-5, c.y, ` stroke="#333" stroke-width="1.5"`)
|
||||
}
|
||||
default:
|
||||
svg.rect(c.x-w/2, c.y-h/2, w, h, 0, attrs)
|
||||
}
|
||||
lines := labelLines(node.label)
|
||||
for k, ln := range lines {
|
||||
y := c.y + 5 + (k-(len(lines)-1)/2)*18
|
||||
if len(lines)%2 == 0 {
|
||||
y = c.y - 4 + k*18
|
||||
}
|
||||
svg.text(point{c.x, y}, ln, "middle", ` font-size="14"`)
|
||||
}
|
||||
}
|
||||
|
||||
// Edge labels on top.
|
||||
for _, e := range drawnEdges {
|
||||
if e.label == "" {
|
||||
continue
|
||||
}
|
||||
mid := point{(e.p0.x + e.p3.x) / 2, (e.p0.y+e.p3.y)/2 - 7}
|
||||
svg.text(mid, e.label, "middle", ` font-size="12"`)
|
||||
}
|
||||
return svg.finish()
|
||||
}
|
||||
|
||||
// arrowHead builds a filled triangle at tip pointing from the control
|
||||
// point towards the tip.
|
||||
func arrowHead(tip, ctrl point) []point {
|
||||
dx, dy := tip.x-ctrl.x, tip.y-ctrl.y
|
||||
n := max(abs(dx)+abs(dy), 1)
|
||||
ux, uy := dx*1000/n, dy*1000/n
|
||||
base := point{tip.x - ux*11/1000, tip.y - uy*11/1000}
|
||||
return []point{
|
||||
tip,
|
||||
{base.x - uy*5/1000, base.y + ux*5/1000},
|
||||
{base.x + uy*5/1000, base.y - ux*5/1000},
|
||||
}
|
||||
}
|
||||
|
||||
func abs(v int) int {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package diagram
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// stylePair is one key:value declaration of a classDef, style or
|
||||
// linkStyle statement.
|
||||
type stylePair struct {
|
||||
key string
|
||||
value string
|
||||
}
|
||||
|
||||
type flowNode struct {
|
||||
id string
|
||||
label string
|
||||
shape string
|
||||
classes []string
|
||||
styles []stylePair
|
||||
}
|
||||
|
||||
type flowEdge struct {
|
||||
from, to int
|
||||
kind string
|
||||
label string
|
||||
styles []stylePair
|
||||
}
|
||||
|
||||
type flowSubgraph struct {
|
||||
id, title, dir string
|
||||
nodes []int
|
||||
depth int
|
||||
}
|
||||
|
||||
type flowDiagram struct {
|
||||
dir string
|
||||
nodes []*flowNode
|
||||
index map[string]int
|
||||
edges []*flowEdge
|
||||
subgraphs []*flowSubgraph
|
||||
classes map[string][]stylePair
|
||||
linkDefault []stylePair
|
||||
linkByIndex map[int][]stylePair
|
||||
}
|
||||
|
||||
func parseFlowchart(lines []string, dir string) (*flowDiagram, error) {
|
||||
d := &flowDiagram{
|
||||
dir: dir,
|
||||
index: map[string]int{},
|
||||
classes: map[string][]stylePair{},
|
||||
linkByIndex: map[int][]stylePair{},
|
||||
}
|
||||
var stack []*flowSubgraph
|
||||
for n, line := range lines {
|
||||
if err := d.parseFlowStatement(line, &stack, n+2); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(stack) > 0 {
|
||||
return nil, fmt.Errorf("diagram: flowchart line %d: subgraph %q has no end", len(lines)+1, stack[len(stack)-1].id)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *flowDiagram) parseFlowStatement(line string, stack *[]*flowSubgraph, lineNo int) error {
|
||||
sc := &scanner{src: []rune(line)}
|
||||
word := sc.word()
|
||||
switch strings.ToLower(word) {
|
||||
case "subgraph":
|
||||
return d.parseSubgraph(sc, stack, lineNo)
|
||||
case "end":
|
||||
if len(*stack) == 0 {
|
||||
return fmt.Errorf("diagram: flowchart line %d: end without subgraph", lineNo)
|
||||
}
|
||||
*stack = (*stack)[:len(*stack)-1]
|
||||
return nil
|
||||
case "direction":
|
||||
sc.skipSpaces()
|
||||
dir := strings.ToUpper(sc.rest())
|
||||
if !flowDirections[dir] {
|
||||
return fmt.Errorf("diagram: flowchart line %d: unknown flow direction %q", lineNo, dir)
|
||||
}
|
||||
if len(*stack) > 0 {
|
||||
(*stack)[len(*stack)-1].dir = dir
|
||||
} else {
|
||||
d.dir = dir
|
||||
}
|
||||
return nil
|
||||
case "classdef":
|
||||
return d.parseClassDef(sc, lineNo)
|
||||
case "class":
|
||||
return d.parseClass(sc, lineNo)
|
||||
case "style":
|
||||
return d.parseStyle(sc, lineNo)
|
||||
case "linkstyle":
|
||||
return d.parseLinkStyle(sc, lineNo)
|
||||
case "click":
|
||||
return fmt.Errorf("diagram: flowchart line %d: click is unsupported, the SVG carries no interactivity", lineNo)
|
||||
}
|
||||
sc.pos = 0
|
||||
return d.parseChain(sc, *stack, lineNo)
|
||||
}
|
||||
|
||||
// node returns the index of a node, creating it when it first appears.
|
||||
// The innermost open subgraph claims a node at its first appearance.
|
||||
func (d *flowDiagram) node(id, label, shape string, stack []*flowSubgraph) int {
|
||||
if i, ok := d.index[id]; ok {
|
||||
if shape != "" {
|
||||
d.nodes[i].shape = shape
|
||||
d.nodes[i].label = label
|
||||
}
|
||||
return i
|
||||
}
|
||||
if label == "" {
|
||||
label = id
|
||||
}
|
||||
if shape == "" {
|
||||
shape = "rect"
|
||||
}
|
||||
n := &flowNode{id: id, label: label, shape: shape}
|
||||
d.nodes = append(d.nodes, n)
|
||||
i := len(d.nodes) - 1
|
||||
d.index[id] = i
|
||||
if len(stack) > 0 {
|
||||
sg := stack[len(stack)-1]
|
||||
sg.nodes = append(sg.nodes, i)
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
func (d *flowDiagram) parseChain(sc *scanner, stack []*flowSubgraph, lineNo int) error {
|
||||
left, err := d.parseNodeList(sc, stack, lineNo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for {
|
||||
sc.skipSpaces()
|
||||
if !sc.atEdgeChar() {
|
||||
return nil
|
||||
}
|
||||
kind, label, err := sc.parseEdge(lineNo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
right, err := d.parseNodeList(sc, stack, lineNo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, from := range left {
|
||||
for _, to := range right {
|
||||
d.edges = append(d.edges, &flowEdge{from: from, to: to, kind: kind, label: label})
|
||||
}
|
||||
}
|
||||
left = right
|
||||
}
|
||||
}
|
||||
|
||||
// parseNodeList reads one or more nodes separated by &. A missing node is
|
||||
// an error: an edge must land somewhere.
|
||||
func (d *flowDiagram) parseNodeList(sc *scanner, stack []*flowSubgraph, lineNo int) ([]int, error) {
|
||||
var list []int
|
||||
for {
|
||||
i, err := d.parseNode(sc, stack, lineNo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, i)
|
||||
sc.skipSpaces()
|
||||
if sc.peek() == '&' {
|
||||
sc.pos++
|
||||
continue
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (d *flowDiagram) parseNode(sc *scanner, stack []*flowSubgraph, lineNo int) (int, error) {
|
||||
sc.skipSpaces()
|
||||
var id strings.Builder
|
||||
for !sc.eof() && isIDRune(sc.peek()) {
|
||||
id.WriteRune(sc.peek())
|
||||
sc.pos++
|
||||
}
|
||||
if id.Len() == 0 {
|
||||
return 0, fmt.Errorf("diagram: flowchart line %d: expected a node", lineNo)
|
||||
}
|
||||
label, shape := sc.parseShape()
|
||||
return d.node(id.String(), label, shape, stack), nil
|
||||
}
|
||||
|
||||
// parseShape reads an optional shape with its label.
|
||||
func (sc *scanner) parseShape() (string, string) {
|
||||
switch {
|
||||
case sc.hasPrefix("[["):
|
||||
return sc.bracketed("[[", "]]"), "sub"
|
||||
case sc.hasPrefix("(["):
|
||||
return sc.bracketed("([", "])"), "stadium"
|
||||
case sc.hasPrefix("(("):
|
||||
return sc.bracketed("((", "))"), "circle"
|
||||
case sc.hasPrefix("["):
|
||||
return sc.bracketed("[", "]"), "rect"
|
||||
case sc.hasPrefix("{{"):
|
||||
return sc.bracketed("{{", "}}"), "hex"
|
||||
case sc.hasPrefix("{"):
|
||||
return sc.bracketed("{", "}"), "diamond"
|
||||
case sc.hasPrefix("("):
|
||||
return sc.bracketed("(", ")"), "round"
|
||||
case sc.hasPrefix(">"):
|
||||
return sc.bracketed(">", "]"), "asym"
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// bracketed consumes the opener and reads until the closer, honouring
|
||||
// double quotes. A label that is exactly a quoted string loses its
|
||||
// quotes.
|
||||
func (sc *scanner) bracketed(open, close string) string {
|
||||
sc.pos += len(open)
|
||||
start := sc.pos
|
||||
for !sc.eof() {
|
||||
if sc.peek() == '"' {
|
||||
sc.pos++
|
||||
for !sc.eof() && sc.peek() != '"' {
|
||||
sc.pos++
|
||||
}
|
||||
sc.pos++
|
||||
continue
|
||||
}
|
||||
if sc.hasPrefix(close) {
|
||||
label := string(sc.src[start:sc.pos])
|
||||
sc.pos += len(close)
|
||||
return unquote(strings.TrimSpace(label))
|
||||
}
|
||||
sc.pos++
|
||||
}
|
||||
return unquote(strings.TrimSpace(string(sc.src[start:])))
|
||||
}
|
||||
|
||||
func unquote(s string) string {
|
||||
if len(s) >= 2 && strings.HasPrefix(s, `"`) && strings.HasSuffix(s, `"`) {
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (d *flowDiagram) parseSubgraph(sc *scanner, stack *[]*flowSubgraph, lineNo int) error {
|
||||
sc.skipSpaces()
|
||||
id := sc.word()
|
||||
title := id
|
||||
sc.skipSpaces()
|
||||
switch {
|
||||
case id == "":
|
||||
id = fmt.Sprintf("subgraph-%d", len(d.subgraphs))
|
||||
title = id
|
||||
case sc.hasPrefix("["):
|
||||
title = sc.bracketed("[", "]")
|
||||
default:
|
||||
rest := strings.TrimSpace(sc.rest())
|
||||
if rest != "" {
|
||||
id = id + " " + rest
|
||||
title = id
|
||||
}
|
||||
}
|
||||
sg := &flowSubgraph{id: id, title: title, depth: len(*stack)}
|
||||
d.subgraphs = append(d.subgraphs, sg)
|
||||
*stack = append(*stack, sg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *flowDiagram) parseClassDef(sc *scanner, lineNo int) error {
|
||||
sc.skipSpaces()
|
||||
name := sc.word()
|
||||
if name == "" {
|
||||
return fmt.Errorf("diagram: flowchart line %d: classDef needs a name", lineNo)
|
||||
}
|
||||
pairs, err := parseStylePairs(sc.rest())
|
||||
if err != nil {
|
||||
return fmt.Errorf("diagram: flowchart line %d: %v", lineNo, err)
|
||||
}
|
||||
d.classes[name] = append(d.classes[name], pairs...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *flowDiagram) parseClass(sc *scanner, lineNo int) error {
|
||||
sc.skipSpaces()
|
||||
ids := strings.Split(sc.word(), ",")
|
||||
sc.skipSpaces()
|
||||
names := strings.Split(strings.TrimSpace(sc.rest()), ",")
|
||||
if len(ids) == 0 || ids[0] == "" || len(names) == 0 || names[0] == "" {
|
||||
return fmt.Errorf("diagram: flowchart line %d: class needs nodes and a class name", lineNo)
|
||||
}
|
||||
for _, id := range ids {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
i, ok := d.index[id]
|
||||
if !ok {
|
||||
i = d.node(id, "", "", nil)
|
||||
}
|
||||
for _, name := range names {
|
||||
name = strings.TrimSpace(name)
|
||||
if name != "" {
|
||||
d.nodes[i].classes = append(d.nodes[i].classes, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *flowDiagram) parseStyle(sc *scanner, lineNo int) error {
|
||||
sc.skipSpaces()
|
||||
id := sc.word()
|
||||
if id == "" {
|
||||
return fmt.Errorf("diagram: flowchart line %d: style needs a node", lineNo)
|
||||
}
|
||||
pairs, err := parseStylePairs(sc.rest())
|
||||
if err != nil {
|
||||
return fmt.Errorf("diagram: flowchart line %d: %v", lineNo, err)
|
||||
}
|
||||
i, ok := d.index[id]
|
||||
if !ok {
|
||||
i = d.node(id, "", "", nil)
|
||||
}
|
||||
d.nodes[i].styles = append(d.nodes[i].styles, pairs...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *flowDiagram) parseLinkStyle(sc *scanner, lineNo int) error {
|
||||
sc.skipSpaces()
|
||||
target := sc.word()
|
||||
rest := sc.rest()
|
||||
pairs, err := parseStylePairs(rest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("diagram: flowchart line %d: %v", lineNo, err)
|
||||
}
|
||||
if strings.ToLower(target) == "default" {
|
||||
d.linkDefault = append(d.linkDefault, pairs...)
|
||||
return nil
|
||||
}
|
||||
for part := range strings.SplitSeq(target, ",") {
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(strings.TrimSpace(part), "%d", &n); err != nil {
|
||||
return fmt.Errorf("diagram: flowchart line %d: linkStyle needs an index or default", lineNo)
|
||||
}
|
||||
d.linkByIndex[n] = append(d.linkByIndex[n], pairs...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseStylePairs reads comma separated key:value declarations.
|
||||
func parseStylePairs(s string) ([]stylePair, error) {
|
||||
var pairs []stylePair
|
||||
for part := range strings.SplitSeq(s, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
key, value, found := strings.Cut(part, ":")
|
||||
if !found {
|
||||
return nil, fmt.Errorf("expected key:value, got %q", part)
|
||||
}
|
||||
pairs = append(pairs, stylePair{key: strings.TrimSpace(key), value: unquote(strings.TrimSpace(value))})
|
||||
}
|
||||
if len(pairs) == 0 {
|
||||
return nil, fmt.Errorf("expected style declarations")
|
||||
}
|
||||
return pairs, nil
|
||||
}
|
||||
|
||||
func isIDRune(r rune) bool {
|
||||
return r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_'
|
||||
}
|
||||
|
||||
// scanner walks one statement.
|
||||
type scanner struct {
|
||||
src []rune
|
||||
pos int
|
||||
}
|
||||
|
||||
func (sc *scanner) eof() bool { return sc.pos >= len(sc.src) }
|
||||
|
||||
func (sc *scanner) peek() rune {
|
||||
if sc.eof() {
|
||||
return 0
|
||||
}
|
||||
return sc.src[sc.pos]
|
||||
}
|
||||
|
||||
func (sc *scanner) skipSpaces() {
|
||||
for !sc.eof() && (sc.peek() == ' ' || sc.peek() == '\t') {
|
||||
sc.pos++
|
||||
}
|
||||
}
|
||||
|
||||
func (sc *scanner) rest() string { return string(sc.src[sc.pos:]) }
|
||||
|
||||
func (sc *scanner) word() string {
|
||||
start := sc.pos
|
||||
for !sc.eof() && sc.peek() != ' ' && sc.peek() != '\t' {
|
||||
sc.pos++
|
||||
}
|
||||
return string(sc.src[start:sc.pos])
|
||||
}
|
||||
|
||||
func (sc *scanner) hasPrefix(s string) bool {
|
||||
runes := []rune(s)
|
||||
if sc.pos+len(runes) > len(sc.src) {
|
||||
return false
|
||||
}
|
||||
for i, r := range runes {
|
||||
if sc.src[sc.pos+i] != r {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (sc *scanner) atEdgeChar() bool {
|
||||
c := sc.peek()
|
||||
return c == '-' || c == '.' || c == '='
|
||||
}
|
||||
|
||||
func isEdgeRune(r rune) bool { return r == '-' || r == '.' || r == '=' }
|
||||
|
||||
// edgeRun consumes a run of edge characters and returns it.
|
||||
func (sc *scanner) edgeRun() string {
|
||||
start := sc.pos
|
||||
for !sc.eof() && isEdgeRune(sc.peek()) {
|
||||
sc.pos++
|
||||
}
|
||||
return string(sc.src[start:sc.pos])
|
||||
}
|
||||
|
||||
// parseEdge reads one edge token: a run of -, . and = characters, an
|
||||
// optional arrow head, and a label either in pipes or as text between two
|
||||
// runs.
|
||||
func (sc *scanner) parseEdge(lineNo int) (kind, label string, err error) {
|
||||
runs := sc.edgeRun()
|
||||
arrow := false
|
||||
switch {
|
||||
case sc.peek() == '|':
|
||||
sc.pos++
|
||||
start := sc.pos
|
||||
for !sc.eof() && sc.peek() != '|' {
|
||||
sc.pos++
|
||||
}
|
||||
label = unquote(strings.TrimSpace(string(sc.src[start:sc.pos])))
|
||||
if !sc.eof() {
|
||||
sc.pos++
|
||||
}
|
||||
case sc.peek() == '>':
|
||||
sc.pos++
|
||||
arrow = true
|
||||
if sc.peek() == '|' {
|
||||
// A pipe label behind the arrow head: -->|text|
|
||||
sc.pos++
|
||||
start := sc.pos
|
||||
for !sc.eof() && sc.peek() != '|' {
|
||||
sc.pos++
|
||||
}
|
||||
label = unquote(strings.TrimSpace(string(sc.src[start:sc.pos])))
|
||||
if !sc.eof() {
|
||||
sc.pos++
|
||||
}
|
||||
}
|
||||
default:
|
||||
save := sc.pos
|
||||
sc.skipSpaces()
|
||||
if !sc.eof() && !isEdgeRune(sc.peek()) {
|
||||
start := sc.pos
|
||||
for !sc.eof() && !isEdgeRune(sc.peek()) {
|
||||
sc.pos++
|
||||
}
|
||||
second := sc.edgeRun()
|
||||
if second == "" {
|
||||
// No closing run: the words ahead are the next node, not a
|
||||
// label.
|
||||
sc.pos = save
|
||||
} else {
|
||||
label = unquote(strings.TrimSpace(string(sc.src[start : sc.pos-len(second)])))
|
||||
runs += second
|
||||
if sc.peek() == '>' {
|
||||
sc.pos++
|
||||
arrow = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sc.pos = save
|
||||
}
|
||||
}
|
||||
if len(runs) < 2 || !arrow && len(runs) < 3 {
|
||||
return "", "", fmt.Errorf("diagram: flowchart line %d: unfinished edge token", lineNo)
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(runs, "="):
|
||||
kind = "thick"
|
||||
case strings.Contains(runs, "."):
|
||||
kind = "dotted"
|
||||
default:
|
||||
kind = "solid"
|
||||
}
|
||||
if arrow {
|
||||
kind += "-arrow"
|
||||
}
|
||||
return kind, label, nil
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package diagram
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Sequence layout constants, in SVG units.
|
||||
const (
|
||||
seqMargin = 24
|
||||
seqHeaderTop = 24
|
||||
seqHeaderH = 44
|
||||
seqRowGap = 30
|
||||
)
|
||||
|
||||
type seqParticipant struct {
|
||||
id, name string
|
||||
actor bool
|
||||
x int
|
||||
}
|
||||
|
||||
// seqItem is one row: a message, a note, a divider, a frame boundary or
|
||||
// an activation change.
|
||||
type seqItem struct {
|
||||
kind string
|
||||
from string
|
||||
to string
|
||||
arrow string
|
||||
text string
|
||||
num int
|
||||
side string
|
||||
over string
|
||||
frame *seqFrame
|
||||
act string
|
||||
actOn bool
|
||||
y, h int
|
||||
}
|
||||
|
||||
// seqFrame is one frame of a block construct: from its label row to the
|
||||
// row of the boundary that closes it.
|
||||
type seqFrame struct {
|
||||
kind, label string
|
||||
colour string
|
||||
first, last int
|
||||
depth int
|
||||
}
|
||||
|
||||
type seqDiagram struct {
|
||||
participants []*seqParticipant
|
||||
index map[string]int
|
||||
items []*seqItem
|
||||
frames []*seqFrame
|
||||
autonumber bool
|
||||
}
|
||||
|
||||
type seqConstruct struct {
|
||||
frame *seqFrame
|
||||
}
|
||||
|
||||
func renderSequence(lines []string, auto bool) ([]byte, error) {
|
||||
d, err := parseSequence(lines, auto)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return writeSequence(d), nil
|
||||
}
|
||||
|
||||
func (d *seqDiagram) participant(id string) int {
|
||||
if i, ok := d.index[id]; ok {
|
||||
return i
|
||||
}
|
||||
p := &seqParticipant{id: id, name: id}
|
||||
d.participants = append(d.participants, p)
|
||||
i := len(d.participants) - 1
|
||||
d.index[id] = i
|
||||
return i
|
||||
}
|
||||
|
||||
func parseSequence(lines []string, auto bool) (*seqDiagram, error) {
|
||||
d := &seqDiagram{index: map[string]int{}, autonumber: auto}
|
||||
var stack []*seqConstruct
|
||||
for n, line := range lines {
|
||||
lineNo := n + 2
|
||||
if err := d.parseSeqStatement(line, &stack, lineNo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(stack) > 0 {
|
||||
return nil, fmt.Errorf("diagram: sequence line %d: block %q has no end", len(lines)+1, stack[len(stack)-1].frame.kind)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
var seqBlockOpeners = map[string]bool{
|
||||
"alt": true, "opt": true, "loop": true, "par": true,
|
||||
"critical": true, "break": true,
|
||||
}
|
||||
|
||||
var seqBlockElse = map[string]bool{
|
||||
"else": true, "and": true, "option": true,
|
||||
}
|
||||
|
||||
func (d *seqDiagram) parseSeqStatement(line string, stack *[]*seqConstruct, lineNo int) error {
|
||||
lower := strings.ToLower(line)
|
||||
keyword := firstWord(lower)
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "participant "), strings.HasPrefix(lower, "actor "):
|
||||
return d.parseSeqParticipant(line, lineNo)
|
||||
case keyword == "autonumber":
|
||||
d.autonumber = !strings.HasSuffix(lower, "off")
|
||||
return nil
|
||||
case strings.HasPrefix(lower, "activate "), strings.HasPrefix(lower, "deactivate "):
|
||||
on := strings.HasPrefix(lower, "activate")
|
||||
id := strings.TrimSpace(line[len("activate "):])
|
||||
if strings.HasPrefix(lower, "deactivate ") {
|
||||
id = strings.TrimSpace(line[len("deactivate "):])
|
||||
}
|
||||
if id == "" {
|
||||
return fmt.Errorf("diagram: sequence line %d: activation needs a participant", lineNo)
|
||||
}
|
||||
d.participant(id)
|
||||
d.items = append(d.items, &seqItem{kind: "act", act: id, actOn: on, h: 8})
|
||||
return nil
|
||||
case strings.HasPrefix(lower, "note "):
|
||||
return d.parseSeqNote(line, lineNo)
|
||||
case strings.HasPrefix(line, "..."):
|
||||
text := strings.Trim(line, ". ")
|
||||
d.items = append(d.items, &seqItem{kind: "divider", text: text, h: 34})
|
||||
return nil
|
||||
case seqBlockOpeners[keyword] || (keyword == "rect" && strings.HasPrefix(lower, "rect ")):
|
||||
text := strings.TrimSpace(line[len(keyword):])
|
||||
frame := &seqFrame{kind: keyword, depth: len(*stack)}
|
||||
if keyword == "rect" {
|
||||
colour, err := parseRectColour(text, lineNo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
frame.colour = colour
|
||||
} else {
|
||||
frame.label = keyword + " " + text
|
||||
}
|
||||
frame.first = len(d.items)
|
||||
d.frames = append(d.frames, frame)
|
||||
d.items = append(d.items, &seqItem{kind: "open", frame: frame, h: 30})
|
||||
*stack = append(*stack, &seqConstruct{frame: frame})
|
||||
return nil
|
||||
case seqBlockElse[keyword]:
|
||||
if len(*stack) == 0 {
|
||||
return fmt.Errorf("diagram: sequence line %d: %s outside a block", lineNo, keyword)
|
||||
}
|
||||
text := strings.TrimSpace(line[len(keyword):])
|
||||
construct := (*stack)[len(*stack)-1]
|
||||
construct.frame.last = len(d.items) - 1
|
||||
frame := &seqFrame{kind: keyword, label: keyword + " " + text, depth: construct.frame.depth}
|
||||
frame.first = len(d.items)
|
||||
d.frames = append(d.frames, frame)
|
||||
d.items = append(d.items, &seqItem{kind: "open", frame: frame, h: 30})
|
||||
construct.frame = frame
|
||||
return nil
|
||||
case keyword == "end":
|
||||
if len(*stack) == 0 {
|
||||
return fmt.Errorf("diagram: sequence line %d: end without a block", lineNo)
|
||||
}
|
||||
construct := (*stack)[len(*stack)-1]
|
||||
*stack = (*stack)[:len(*stack)-1]
|
||||
construct.frame.last = len(d.items)
|
||||
d.items = append(d.items, &seqItem{kind: "close", frame: construct.frame, h: 14})
|
||||
return nil
|
||||
}
|
||||
return d.parseSeqMessage(line, lineNo)
|
||||
}
|
||||
|
||||
func firstWord(s string) string {
|
||||
w, _, _ := strings.Cut(s, " ")
|
||||
return w
|
||||
}
|
||||
|
||||
func (d *seqDiagram) parseSeqParticipant(line string, lineNo int) error {
|
||||
sc := &scanner{src: []rune(line)}
|
||||
sc.word() // participant or actor
|
||||
actor := strings.HasPrefix(strings.ToLower(line), "actor")
|
||||
sc.skipSpaces()
|
||||
id := sc.word()
|
||||
if id == "" {
|
||||
return fmt.Errorf("diagram: sequence line %d: participant needs an id", lineNo)
|
||||
}
|
||||
sc.skipSpaces()
|
||||
name := id
|
||||
if strings.HasPrefix(sc.rest(), "as ") {
|
||||
name = unquote(strings.TrimSpace(sc.rest()[3:]))
|
||||
} else if strings.HasPrefix(sc.rest(), "[") {
|
||||
name = sc.bracketed("[", "]")
|
||||
} else if rest := strings.TrimSpace(sc.rest()); rest != "" {
|
||||
name = unquote(rest)
|
||||
}
|
||||
i := d.participant(id)
|
||||
d.participants[i].name = name
|
||||
d.participants[i].actor = actor
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *seqDiagram) parseSeqNote(line string, lineNo int) error {
|
||||
rest := strings.TrimSpace(line[len("note "):])
|
||||
lower := strings.ToLower(rest)
|
||||
var side, spec string
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "left of "):
|
||||
side, spec = "left", strings.TrimSpace(rest[len("left of "):])
|
||||
case strings.HasPrefix(lower, "right of "):
|
||||
side, spec = "right", strings.TrimSpace(rest[len("right of "):])
|
||||
case strings.HasPrefix(lower, "over "):
|
||||
side, spec = "over", strings.TrimSpace(rest[len("over "):])
|
||||
default:
|
||||
return fmt.Errorf("diagram: sequence line %d: note needs left of, right of or over", lineNo)
|
||||
}
|
||||
colon := strings.Index(spec, ":")
|
||||
if colon < 0 {
|
||||
return fmt.Errorf("diagram: sequence line %d: note needs a colon before its text", lineNo)
|
||||
}
|
||||
text := strings.TrimSpace(spec[colon+1:])
|
||||
spec = strings.TrimSpace(spec[:colon])
|
||||
id, over := spec, ""
|
||||
if side == "over" {
|
||||
if a, b, found := strings.Cut(spec, ","); found {
|
||||
id, over = strings.TrimSpace(a), strings.TrimSpace(b)
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
return fmt.Errorf("diagram: sequence line %d: note needs a participant", lineNo)
|
||||
}
|
||||
d.participant(id)
|
||||
if over != "" {
|
||||
d.participant(over)
|
||||
}
|
||||
d.items = append(d.items, &seqItem{kind: "note", side: side, from: id, over: over, text: text})
|
||||
return nil
|
||||
}
|
||||
|
||||
// seqArrows lists the message arrows longest first, so the scan prefers
|
||||
// the long form.
|
||||
var seqArrows = []string{"-->>", "-->", "->>", "-x", "--x", "->"}
|
||||
|
||||
// parseSeqMessage reads FROM arrow TO: text.
|
||||
func (d *seqDiagram) parseSeqMessage(line string, lineNo int) error {
|
||||
bestAt, bestLen := -1, 0
|
||||
for _, a := range seqArrows {
|
||||
if at := strings.Index(line, a); at >= 0 && (bestAt < 0 || at < bestAt || at == bestAt && len(a) > bestLen) {
|
||||
if bestAt < 0 || at < bestAt || len(a) > bestLen {
|
||||
bestAt, bestLen = at, len(a)
|
||||
}
|
||||
}
|
||||
}
|
||||
if bestAt < 0 {
|
||||
return fmt.Errorf("diagram: sequence line %d: cannot parse %q", lineNo, line)
|
||||
}
|
||||
arrow := line[bestAt : bestAt+bestLen]
|
||||
left := strings.TrimSpace(line[:bestAt])
|
||||
rest := strings.TrimSpace(line[bestAt+bestLen:])
|
||||
before, after, ok := strings.Cut(rest, ":")
|
||||
if !ok {
|
||||
return fmt.Errorf("diagram: sequence line %d: message needs a colon before its text", lineNo)
|
||||
}
|
||||
right := strings.TrimSpace(before)
|
||||
text := strings.TrimSpace(after)
|
||||
|
||||
// Activation flags cling to the participant names.
|
||||
fromAct, fromDeact := false, false
|
||||
if before, ok := strings.CutSuffix(left, "+"); ok {
|
||||
fromAct, left = true, before
|
||||
} else if before, ok := strings.CutSuffix(left, "-"); ok {
|
||||
fromDeact, left = true, before
|
||||
}
|
||||
left = strings.TrimSpace(left)
|
||||
toAct, toDeact := false, false
|
||||
right = strings.TrimSpace(right)
|
||||
if after, ok := strings.CutPrefix(right, "+"); ok {
|
||||
toAct, right = true, after
|
||||
} else if after, ok := strings.CutPrefix(right, "-"); ok {
|
||||
toDeact, right = true, after
|
||||
}
|
||||
if before, ok := strings.CutSuffix(right, "+"); ok {
|
||||
toAct, right = true, before
|
||||
} else if before, ok := strings.CutSuffix(right, "-"); ok {
|
||||
toDeact, right = true, before
|
||||
}
|
||||
right = strings.TrimSpace(right)
|
||||
if left == "" || right == "" {
|
||||
return fmt.Errorf("diagram: sequence line %d: message needs two participants", lineNo)
|
||||
}
|
||||
d.participant(left)
|
||||
d.participant(right)
|
||||
num := 0
|
||||
if d.autonumber {
|
||||
num = d.countMessages() + 1
|
||||
}
|
||||
d.items = append(d.items, &seqItem{
|
||||
kind: "msg", from: left, to: right, arrow: arrow, text: text, num: num,
|
||||
})
|
||||
if fromAct {
|
||||
d.activate(left, lineNo)
|
||||
}
|
||||
if toAct {
|
||||
d.activate(right, lineNo)
|
||||
}
|
||||
if fromDeact {
|
||||
d.deactivate(left, lineNo)
|
||||
}
|
||||
if toDeact {
|
||||
d.deactivate(right, lineNo)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *seqDiagram) countMessages() int {
|
||||
n := 0
|
||||
for _, it := range d.items {
|
||||
if it.kind == "msg" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// activations collects the open and closed activation spans while the
|
||||
// items lay out; the parser records the changes as items.
|
||||
type seqActivation struct {
|
||||
id string
|
||||
y1 int
|
||||
y2 int
|
||||
open bool
|
||||
}
|
||||
|
||||
func (d *seqDiagram) activate(id string, lineNo int) {
|
||||
d.items = append(d.items, &seqItem{kind: "act", act: id, actOn: true, h: 8})
|
||||
}
|
||||
|
||||
func (d *seqDiagram) deactivate(id string, lineNo int) {
|
||||
d.items = append(d.items, &seqItem{kind: "act", act: id, actOn: false, h: 8})
|
||||
}
|
||||
|
||||
// parseRectColour reads rgb(r,g,b) or #hex.
|
||||
func parseRectColour(s string, lineNo int) (string, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if strings.HasPrefix(s, "rgb(") && strings.HasSuffix(s, ")") {
|
||||
parts := strings.Split(strings.TrimSuffix(strings.TrimPrefix(s, "rgb("), ")"), ",")
|
||||
if len(parts) != 3 {
|
||||
return "", fmt.Errorf("diagram: sequence line %d: rgb takes three numbers", lineNo)
|
||||
}
|
||||
r, g, b := 0, 0, 0
|
||||
var err error
|
||||
if _, err = fmt.Sscanf(strings.TrimSpace(parts[0]), "%d", &r); err != nil {
|
||||
return "", fmt.Errorf("diagram: sequence line %d: rgb takes three numbers", lineNo)
|
||||
}
|
||||
if _, err = fmt.Sscanf(strings.TrimSpace(parts[1]), "%d", &g); err != nil {
|
||||
return "", fmt.Errorf("diagram: sequence line %d: rgb takes three numbers", lineNo)
|
||||
}
|
||||
if _, err = fmt.Sscanf(strings.TrimSpace(parts[2]), "%d", &b); err != nil {
|
||||
return "", fmt.Errorf("diagram: sequence line %d: rgb takes three numbers", lineNo)
|
||||
}
|
||||
return fmt.Sprintf("#%02x%02x%02x", clamp(r, 0, 255), clamp(g, 0, 255), clamp(b, 0, 255)), nil
|
||||
}
|
||||
if strings.HasPrefix(s, "#") && len(s) == 7 {
|
||||
for i := 1; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F') {
|
||||
return "", fmt.Errorf("diagram: sequence line %d: bad colour %q", lineNo, s)
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
return "", fmt.Errorf("diagram: sequence line %d: rect needs rgb(r,g,b) or #rrggbb", lineNo)
|
||||
}
|
||||
|
||||
func writeSequence(d *seqDiagram) []byte {
|
||||
// Lanes.
|
||||
laneWidth := 150
|
||||
for _, p := range d.participants {
|
||||
laneWidth = max(laneWidth, textWidth(p.name, 14)+80)
|
||||
}
|
||||
width := seqMargin*2 + laneWidth*len(d.participants)
|
||||
for i, p := range d.participants {
|
||||
p.x = seqMargin + laneWidth*i + laneWidth/2
|
||||
}
|
||||
|
||||
// Rows.
|
||||
y := seqMargin + seqHeaderH + seqRowGap
|
||||
for _, it := range d.items {
|
||||
switch it.kind {
|
||||
case "note":
|
||||
it.h = 16 + 18*len(labelLines(it.text))
|
||||
case "msg":
|
||||
it.h = 38
|
||||
}
|
||||
it.y = y
|
||||
y += it.h + 10
|
||||
}
|
||||
bottom := y + 10
|
||||
if len(d.items) == 0 {
|
||||
bottom = seqMargin + seqHeaderH + seqRowGap + 40
|
||||
}
|
||||
height := bottom + seqMargin
|
||||
|
||||
svg := newSVGBuilder(width, height)
|
||||
|
||||
// Frame backgrounds, then lifelines, headers, activations, notes and
|
||||
// messages, then frame outlines.
|
||||
for _, f := range d.frames {
|
||||
if f.colour == "" {
|
||||
continue
|
||||
}
|
||||
x0 := seqMargin + f.depth*12
|
||||
fw := width - 2*seqMargin - 2*f.depth*12
|
||||
top := d.items[f.first].y + 4
|
||||
low := d.items[f.last].y + d.items[f.last].h
|
||||
svg.rect(x0, top, fw, low-top, 6, fmt.Sprintf(` fill="%s" fill-opacity="0.18" stroke="none"`, f.colour))
|
||||
}
|
||||
|
||||
lifelineBottom := bottom
|
||||
for _, p := range d.participants {
|
||||
svg.line(p.x, seqMargin+seqHeaderH, p.x, lifelineBottom, ` stroke="#999" stroke-dasharray="5 5"`)
|
||||
}
|
||||
|
||||
// Activations: the act items, in row order, open and close the spans.
|
||||
var activations []seqActivation
|
||||
open := map[string][]int{}
|
||||
for _, it := range d.items {
|
||||
if it.kind != "act" {
|
||||
continue
|
||||
}
|
||||
y := it.y + it.h/2
|
||||
if it.actOn {
|
||||
open[it.act] = append(open[it.act], y)
|
||||
} else if len(open[it.act]) > 0 {
|
||||
start := open[it.act][len(open[it.act])-1]
|
||||
open[it.act] = open[it.act][:len(open[it.act])-1]
|
||||
activations = append(activations, seqActivation{id: it.act, y1: start, y2: y})
|
||||
}
|
||||
}
|
||||
for _, a := range activations {
|
||||
x := d.participants[d.index[a.id]].x
|
||||
svg.rect(x-5, a.y1, 10, max(a.y2-a.y1, 8), 0, ` fill="#e8e8e8" stroke="#666" stroke-width="1"`)
|
||||
}
|
||||
|
||||
// Participant headers.
|
||||
for _, p := range d.participants {
|
||||
attrs := ` fill="#eef2f7" stroke="#333" stroke-width="1.5"`
|
||||
if p.actor {
|
||||
attrs = ` fill="#e8f0e8" stroke="#333" stroke-width="2.5"`
|
||||
}
|
||||
w := max(laneWidth-24, textWidth(p.name, 14)+30)
|
||||
svg.rect(p.x-w/2, seqHeaderTop, w, seqHeaderH, 8, attrs)
|
||||
svg.text(point{p.x, seqHeaderTop + 27}, p.name, "middle", ` font-size="14" font-weight="bold"`)
|
||||
}
|
||||
|
||||
// Messages.
|
||||
for _, it := range d.items {
|
||||
if it.kind != "msg" {
|
||||
continue
|
||||
}
|
||||
xA := d.participants[d.index[it.from]].x
|
||||
xB := d.participants[d.index[it.to]].x
|
||||
y := it.y + 28
|
||||
text := it.text
|
||||
if it.num > 0 {
|
||||
text = fmt.Sprintf("%d: %s", it.num, text)
|
||||
}
|
||||
attrs := ` stroke="#333" stroke-width="1.8"`
|
||||
if strings.HasPrefix(it.arrow, "--") {
|
||||
attrs += ` stroke-dasharray="7 5"`
|
||||
}
|
||||
if xA == xB {
|
||||
svg.path(fmt.Sprintf("M %d %d H %d V %d H %d", xA+8, y-14, xA+64, y+4, xA+14), attrs)
|
||||
if strings.HasSuffix(it.arrow, "x") {
|
||||
svg.line(xA+14, y-4, xA+26, y+12, ` stroke="#333" stroke-width="1.8"`)
|
||||
svg.line(xA+26, y-4, xA+14, y+12, ` stroke="#333" stroke-width="1.8"`)
|
||||
} else {
|
||||
svg.polygon([]point{{xA + 14, y + 4}, {xA + 25, y - 1}, {xA + 25, y + 9}}, ` fill="#333"`)
|
||||
}
|
||||
svg.text(point{xA + 74, y - 6}, text, "start", ` font-size="13"`)
|
||||
continue
|
||||
}
|
||||
svg.line(xA, y, xB, y, attrs)
|
||||
s := 1
|
||||
if xB < xA {
|
||||
s = -1
|
||||
}
|
||||
tip := xB
|
||||
if strings.HasSuffix(it.arrow, "x") {
|
||||
svg.line(tip-5*s, y-6, tip+4*s, y+6, ` stroke="#333" stroke-width="1.8"`)
|
||||
svg.line(tip+4*s, y-6, tip-5*s, y+6, ` stroke="#333" stroke-width="1.8"`)
|
||||
} else if strings.HasSuffix(it.arrow, ">") {
|
||||
svg.polygon([]point{{tip, y}, {tip - 11*s, y - 5}, {tip - 11*s, y + 5}}, ` fill="#333"`)
|
||||
}
|
||||
midX := (xA + xB) / 2
|
||||
svg.text(point{midX, y - 7}, text, "middle", ` font-size="13"`)
|
||||
}
|
||||
|
||||
// Notes.
|
||||
for _, it := range d.items {
|
||||
if it.kind != "note" {
|
||||
continue
|
||||
}
|
||||
lines := labelLines(it.text)
|
||||
w := 0
|
||||
for _, l := range lines {
|
||||
w = max(w, textWidth(l, 13))
|
||||
}
|
||||
w += 26
|
||||
h := it.h - 4
|
||||
var x int
|
||||
anchor := "start"
|
||||
switch it.side {
|
||||
case "left":
|
||||
x = d.participants[d.index[it.from]].x - 14 - w
|
||||
if it.over != "" {
|
||||
x = d.participants[d.index[it.over]].x - 14 - w
|
||||
}
|
||||
case "right":
|
||||
x = d.participants[d.index[it.from]].x + 14
|
||||
default:
|
||||
xA := d.participants[d.index[it.from]].x
|
||||
xB := xA
|
||||
if it.over != "" {
|
||||
xB = d.participants[d.index[it.over]].x
|
||||
}
|
||||
x = min(xA, xB) - 30
|
||||
w = abs(xB-xA) + 60
|
||||
anchor = "middle"
|
||||
}
|
||||
svg.rect(x, it.y+2, w, h, 4, ` fill="#fffbe0" stroke="#999"`)
|
||||
for k, l := range lines {
|
||||
tx := x + 12
|
||||
if anchor == "middle" {
|
||||
tx = x + w/2
|
||||
}
|
||||
svg.text(point{tx, it.y + 22 + k*18}, l, anchor, ` font-size="13"`)
|
||||
}
|
||||
}
|
||||
|
||||
// Dividers.
|
||||
for _, it := range d.items {
|
||||
if it.kind != "divider" {
|
||||
continue
|
||||
}
|
||||
y := it.y + 17
|
||||
svg.line(seqMargin, y, width-seqMargin, y, ` stroke="#999" stroke-dasharray="6 5"`)
|
||||
w := textWidth(it.text, 13) + 24
|
||||
svg.rect((width-w)/2, y-11, w, 22, 10, ` fill="#eee" stroke="#999"`)
|
||||
svg.text(point{width / 2, y + 4}, it.text, "middle", ` font-size="13"`)
|
||||
}
|
||||
|
||||
// Frame outlines and label tabs.
|
||||
for _, f := range d.frames {
|
||||
x0 := seqMargin + f.depth*12
|
||||
fw := width - 2*seqMargin - 2*f.depth*12
|
||||
top := d.items[f.first].y + 4
|
||||
low := d.items[f.last].y + d.items[f.last].h
|
||||
svg.rect(x0, top, fw, low-top, 6, ` fill="none" stroke="#888"`)
|
||||
if f.label != "" {
|
||||
tw := min(textWidth(f.label, 13)+16, fw)
|
||||
svg.rect(x0, top, tw, 22, 0, ` fill="#eee" stroke="#888"`)
|
||||
svg.text(point{x0 + 8, top + 15}, f.label, "start", ` font-size="13"`)
|
||||
}
|
||||
}
|
||||
return svg.finish()
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package diagram
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// point is a coordinate in the layout. Everything is an integer, so the
|
||||
// same layout always writes the same bytes.
|
||||
type point struct {
|
||||
x, y int
|
||||
}
|
||||
|
||||
// svgBuilder writes SVG elements in the order they are given.
|
||||
type svgBuilder struct {
|
||||
b strings.Builder
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
func newSVGBuilder(width, height int) *svgBuilder {
|
||||
s := &svgBuilder{width: width, height: height}
|
||||
fmt.Fprintf(&s.b, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" font-family="sans-serif">`, width, height)
|
||||
return s
|
||||
}
|
||||
|
||||
// rect draws a rectangle, rounded when rx is positive.
|
||||
func (s *svgBuilder) rect(x, y, w, h, rx int, attrs string) {
|
||||
if w < 0 || h < 0 {
|
||||
return
|
||||
}
|
||||
if rx > 0 {
|
||||
fmt.Fprintf(&s.b, `<rect x="%d" y="%d" width="%d" height="%d" rx="%d"%s/>`, x, y, w, h, rx, attrs)
|
||||
} else {
|
||||
fmt.Fprintf(&s.b, `<rect x="%d" y="%d" width="%d" height="%d"%s/>`, x, y, w, h, attrs)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *svgBuilder) line(x1, y1, x2, y2 int, attrs string) {
|
||||
fmt.Fprintf(&s.b, `<line x1="%d" y1="%d" x2="%d" y2="%d"%s/>`, x1, y1, x2, y2, attrs)
|
||||
}
|
||||
|
||||
func (s *svgBuilder) path(d, attrs string) {
|
||||
s.b.WriteString(`<path d="`)
|
||||
s.b.WriteString(d)
|
||||
s.b.WriteString(`"`)
|
||||
s.b.WriteString(attrs)
|
||||
s.b.WriteString(`/>`)
|
||||
}
|
||||
|
||||
// text writes one line of text. The anchor is middle, start or end.
|
||||
func (s *svgBuilder) text(p point, str, anchor, attrs string) {
|
||||
s.b.WriteString(`<text x="`)
|
||||
fmt.Fprintf(&s.b, `%d`, p.x)
|
||||
s.b.WriteString(`" y="`)
|
||||
fmt.Fprintf(&s.b, `%d`, p.y)
|
||||
if anchor != "" {
|
||||
s.b.WriteString(`" text-anchor="`)
|
||||
s.b.WriteString(anchor)
|
||||
}
|
||||
s.b.WriteString(`"`)
|
||||
s.b.WriteString(attrs)
|
||||
s.b.WriteString(`>`)
|
||||
s.b.WriteString(escapeXML(str))
|
||||
s.b.WriteString(`</text>`)
|
||||
}
|
||||
|
||||
func (s *svgBuilder) polygon(pts []point, attrs string) {
|
||||
if len(pts) < 3 {
|
||||
return
|
||||
}
|
||||
s.b.WriteString(`<polygon points="`)
|
||||
for i, p := range pts {
|
||||
if i > 0 {
|
||||
s.b.WriteString(" ")
|
||||
}
|
||||
fmt.Fprintf(&s.b, `%d,%d`, p.x, p.y)
|
||||
}
|
||||
s.b.WriteString(`"`)
|
||||
s.b.WriteString(attrs)
|
||||
s.b.WriteString(`/>`)
|
||||
}
|
||||
|
||||
func (s *svgBuilder) finish() []byte {
|
||||
s.b.WriteString("</svg>")
|
||||
return []byte(s.b.String())
|
||||
}
|
||||
|
||||
var xmlEscaper = strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """)
|
||||
|
||||
func escapeXML(s string) string {
|
||||
return xmlEscaper.Replace(s)
|
||||
}
|
||||
|
||||
// textWidth estimates the width a string needs at the given font size.
|
||||
// Characters outside the basic plane are wide: the estimate stays on the
|
||||
// generous side so labels keep their padding.
|
||||
func textWidth(s string, size int) int {
|
||||
w := 0
|
||||
for _, r := range s {
|
||||
if r > 0x2E7F {
|
||||
w += size
|
||||
} else {
|
||||
w += (size*6 + 4) / 10
|
||||
}
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// labelLines splits a label into its lines.
|
||||
func labelLines(s string) []string {
|
||||
if s == "" {
|
||||
return []string{""}
|
||||
}
|
||||
normalised := strings.ReplaceAll(s, "<br/>", "<br>")
|
||||
normalised = strings.ReplaceAll(normalised, "<br />", "<br>")
|
||||
lines := strings.Split(normalised, "<br>")
|
||||
if len(lines) == 0 {
|
||||
return []string{""}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// styleString joins style pairs into a style attribute, declaration order
|
||||
// preserved.
|
||||
func styleString(pairs []stylePair) string {
|
||||
if len(pairs) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(` style="`)
|
||||
for i, p := range pairs {
|
||||
if i > 0 {
|
||||
b.WriteString(";")
|
||||
}
|
||||
b.WriteString(p.key)
|
||||
b.WriteString(":")
|
||||
b.WriteString(p.value)
|
||||
}
|
||||
b.WriteString(`"`)
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 216" font-family="sans-serif"><path d="M 104 76 C 104 108, 104 108, 104 140" fill="none" stroke="#555" stroke-width="2"/><polygon points="104,140 99,129 109,129" fill="#555"/><rect x="56" y="32" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="104" y="59" text-anchor="middle" font-size="14">Start</text><rect x="56" y="140" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="104" y="167" text-anchor="middle" font-size="14">End</text></svg>
|
||||
|
After Width: | Height: | Size: 560 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 324" font-family="sans-serif"><path d="M 104 76 C 104 108, 176 108, 176 140" fill="none" stroke="#555" stroke-width="2"/><path d="M 104 76 C 104 108, 296 108, 296 140" fill="none" stroke="#555" stroke-width="2"/><path d="M 224 76 C 224 162, 248 162, 248 248" fill="none" stroke="#555" stroke-width="2"/><path d="M 344 76 C 344 162, 248 162, 248 248" fill="none" stroke="#555" stroke-width="2"/><path d="M 176 184 C 176 216, 248 216, 248 248" fill="none" stroke="#555" stroke-width="2"/><polygon points="176,140 171,129 181,129" fill="#555"/><polygon points="296,140 291,129 301,129" fill="#555"/><polygon points="248,248 243,237 253,237" fill="#555"/><polygon points="248,248 243,237 253,237" fill="#555"/><polygon points="248,248 243,237 253,237" fill="#555"/><rect x="56" y="32" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="104" y="59" text-anchor="middle" font-size="14">A</text><rect x="128" y="140" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="176" y="167" text-anchor="middle" font-size="14">B</text><rect x="248" y="140" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="296" y="167" text-anchor="middle" font-size="14">C</text><rect x="176" y="32" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="224" y="59" text-anchor="middle" font-size="14">D</text><rect x="296" y="32" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="344" y="59" text-anchor="middle" font-size="14">E</text><rect x="200" y="248" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="248" y="275" text-anchor="middle" font-size="14">F</text></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 304 108" font-family="sans-serif"><rect x="56" y="32" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5" style="fill:#f96;stroke:#333;stroke-width:2px"/><text x="104" y="59" text-anchor="middle" font-size="14">First</text><rect x="176" y="32" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5" style="fill:#9f9;fill:#f96;stroke:#333;stroke-width:2px"/><text x="224" y="59" text-anchor="middle" font-size="14">B</text></svg>
|
||||
|
After Width: | Height: | Size: 516 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 304 1156" font-family="sans-serif"><path d="M 104 1080 C 104 994, 176 994, 176 908" fill="none" stroke="#555" stroke-width="2"/><path d="M 176 908 C 176 940, 176 940, 176 972" fill="none" stroke="#555" stroke-width="2"/><path d="M 176 1016 C 176 1048, 104 1048, 104 1080" fill="none" stroke="#555" stroke-width="2"/><path d="M 176 1016 C 176 1048, 224 1048, 224 1080" fill="none" stroke="#555" stroke-width="2"/><polygon points="176,908 181,919 171,919" fill="#555"/><polygon points="176,972 171,961 181,961" fill="#555"/><polygon points="104,1080 99,1069 109,1069" fill="#555"/><polygon points="224,1080 219,1069 229,1069" fill="#555"/><rect x="56" y="1080" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="104" y="1107" text-anchor="middle" font-size="14">A</text><rect x="128" y="864" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="176" y="891" text-anchor="middle" font-size="14">B</text><rect x="128" y="972" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="176" y="999" text-anchor="middle" font-size="14">C</text><rect x="176" y="1080" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="224" y="1107" text-anchor="middle" font-size="14">D</text></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 216 160" font-family="sans-serif"><path d="M 76 104 C 108 104, 108 104, 140 104" fill="none" stroke="#555" stroke-width="2"/><polygon points="140,104 129,109 129,99" fill="#555"/><rect x="32" y="56" width="44" height="96" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="54" y="109" text-anchor="middle" font-size="14">A</text><rect x="140" y="56" width="44" height="96" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="162" y="109" text-anchor="middle" font-size="14">B</text></svg>
|
||||
|
After Width: | Height: | Size: 554 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 756" font-family="sans-serif"><path d="M 104 76 C 104 108, 104 108, 104 140" fill="none" stroke="#555" stroke-width="2"/><path d="M 104 184 C 104 216, 104 216, 104 248" fill="none" stroke="#555" stroke-width="2"/><path d="M 104 292 C 104 324, 104 324, 104 356" fill="none" stroke="#555" stroke-width="2" stroke-dasharray="6 5"/><path d="M 104 400 C 104 432, 104 432, 104 464" fill="none" stroke="#555" stroke-width="2" stroke-dasharray="6 5"/><path d="M 104 508 C 104 540, 104 540, 104 572" fill="none" stroke="#555" stroke-width="3.5"/><path d="M 104 616 C 104 648, 104 648, 104 680" fill="none" stroke="#555" stroke-width="2"/><polygon points="104,248 99,237 109,237" fill="#555"/><polygon points="104,464 99,453 109,453" fill="#555"/><polygon points="104,572 99,561 109,561" fill="#555"/><polygon points="104,680 99,669 109,669" fill="#555"/><rect x="56" y="32" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="104" y="59" text-anchor="middle" font-size="14">A</text><rect x="56" y="140" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="104" y="167" text-anchor="middle" font-size="14">B</text><rect x="56" y="248" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="104" y="275" text-anchor="middle" font-size="14">C</text><rect x="56" y="356" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="104" y="383" text-anchor="middle" font-size="14">D</text><rect x="56" y="464" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="104" y="491" text-anchor="middle" font-size="14">E</text><rect x="56" y="572" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="104" y="599" text-anchor="middle" font-size="14">F</text><rect x="56" y="680" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="104" y="707" text-anchor="middle" font-size="14">G</text></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 592 216" font-family="sans-serif"><path d="M 320 76 C 320 108, 104 108, 104 140" fill="none" stroke="#555" stroke-width="2"/><path d="M 320 76 C 320 108, 224 108, 224 140" fill="none" stroke="#555" stroke-width="2"/><path d="M 320 76 C 320 108, 344 108, 344 140" fill="none" stroke="#555" stroke-width="2" stroke-dasharray="6 5"/><path d="M 320 76 C 320 108, 464 108, 464 140" fill="none" stroke="#555" stroke-width="3.5"/><polygon points="104,140 99,129 109,129" fill="#555"/><polygon points="224,140 219,129 229,129" fill="#555"/><polygon points="344,140 339,129 349,129" fill="#555"/><polygon points="464,140 459,129 469,129" fill="#555"/><rect x="272" y="32" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="320" y="59" text-anchor="middle" font-size="14">A</text><rect x="56" y="140" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="104" y="167" text-anchor="middle" font-size="14">B</text><rect x="176" y="140" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="224" y="167" text-anchor="middle" font-size="14">C</text><rect x="296" y="140" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="344" y="167" text-anchor="middle" font-size="14">D</text><rect x="416" y="140" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="464" y="167" text-anchor="middle" font-size="14">E</text><text x="212" y="101" text-anchor="middle" font-size="12">text</text><text x="272" y="101" text-anchor="middle" font-size="12">piped</text><text x="332" y="101" text-anchor="middle" font-size="12">dotted text</text><text x="392" y="101" text-anchor="middle" font-size="12">thick text</text></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 216 160" font-family="sans-serif"><path d="M 76 104 C 108 104, 108 104, 140 104" fill="none" stroke="#555" stroke-width="2"/><polygon points="140,104 129,109 129,99" fill="#555"/><rect x="32" y="56" width="44" height="96" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="54" y="109" text-anchor="middle" font-size="14">Alpha</text><rect x="140" y="56" width="44" height="96" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="162" y="109" text-anchor="middle" font-size="14">Beta</text></svg>
|
||||
|
After Width: | Height: | Size: 561 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 324 160" font-family="sans-serif"><path d="M 76 104 C 108 104, 108 104, 140 104" fill="none" stroke="#555" stroke-width="2"/><path d="M 184 104 C 216 104, 216 104, 248 104" fill="none" stroke="#555" stroke-width="2"/><polygon points="140,104 129,109 129,99" fill="#555"/><polygon points="248,104 237,109 237,99" fill="#555"/><rect x="32" y="56" width="44" height="96" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="54" y="109" text-anchor="middle" font-size="14">A</text><rect x="140" y="56" width="44" height="96" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="162" y="109" text-anchor="middle" font-size="14">B</text><rect x="248" y="56" width="44" height="96" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="270" y="109" text-anchor="middle" font-size="14">C</text></svg>
|
||||
|
After Width: | Height: | Size: 859 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 234" font-family="sans-serif"><path d="M 114 94 C 114 126, 114 126, 114 158" fill="none" stroke="#555" stroke-width="2"/><polygon points="114,158 109,147 119,147" fill="#555"/><rect x="56" y="32" width="116" height="62" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="114" y="59" text-anchor="middle" font-size="14">first line</text><text x="114" y="77" text-anchor="middle" font-size="14">second line</text><rect x="66" y="158" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="114" y="185" text-anchor="middle" font-size="14">B</text></svg>
|
||||
|
After Width: | Height: | Size: 640 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 380 216" font-family="sans-serif"><path d="M 214 76 C 214 108, 214 108, 214 140" fill="none" stroke="#555" stroke-width="2"/><polygon points="214,140 209,129 219,129" fill="#555"/><rect x="56" y="32" width="316" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="214" y="59" text-anchor="middle" font-size="14">Label with (brackets) and \"quotes\"</text><rect x="166" y="140" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="214" y="167" text-anchor="middle" font-size="14">'single'</text></svg>
|
||||
|
After Width: | Height: | Size: 609 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1256 152" font-family="sans-serif"><rect x="56" y="54" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="104" y="81" text-anchor="middle" font-size="14">Rect</text><rect x="176" y="54" width="96" height="44" rx="10" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="224" y="81" text-anchor="middle" font-size="14">Round</text><polygon points="380,34 464,76 380,118 296,76" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="380" y="81" text-anchor="middle" font-size="14">Diamond</text><rect x="488" y="32" width="88" height="88" rx="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="532" y="81" text-anchor="middle" font-size="14">Circle</text><rect x="600" y="54" width="108" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><line x1="605" y1="76" x2="703" y2="76" stroke="#333" stroke-width="1.5"/><text x="654" y="81" text-anchor="middle" font-size="14">Subroutine</text><polygon points="732,54 822,54 840,76 822,98 732,98" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="786" y="81" text-anchor="middle" font-size="14">Asymmetric</text><rect x="864" y="54" width="96" height="44" rx="22" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="912" y="81" text-anchor="middle" font-size="14">Stadium</text><polygon points="1004,54 1060,54 1080,76 1060,98 1004,98 984,76" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="1032" y="81" text-anchor="middle" font-size="14">Hexagon</text></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 108" font-family="sans-serif"><rect x="56" y="32" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="104" y="59" text-anchor="middle" font-size="14">Lonely</text></svg>
|
||||
|
After Width: | Height: | Size: 255 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 304 324" font-family="sans-serif"><rect x="96" y="208" width="136" height="102" rx="8" fill="#f5f5f5" fill-opacity="0.7" stroke="#999"/><text x="106" y="226" text-anchor="start" font-size="14" font-weight="bold">Inner</text><path d="M 176 76 C 176 108, 176 108, 176 140" fill="none" stroke="#555" stroke-width="2"/><path d="M 176 184 C 176 216, 164 216, 164 248" fill="none" stroke="#555" stroke-width="2"/><path d="M 176 184 C 176 216, 164 216, 164 248" fill="none" stroke="#555" stroke-width="2"/><polygon points="176,140 171,129 181,129" fill="#555"/><polygon points="164,248 159,237 169,237" fill="#555"/><polygon points="164,248 159,237 169,237" fill="#555"/><rect x="128" y="32" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="176" y="59" text-anchor="middle" font-size="14">A</text><rect x="128" y="140" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="176" y="167" text-anchor="middle" font-size="14">B</text><rect x="116" y="248" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="164" y="275" text-anchor="middle" font-size="14">C</text><rect x="116" y="248" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="164" y="275" text-anchor="middle" font-size="14">D</text></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 432" font-family="sans-serif"><rect x="36" y="208" width="136" height="102" rx="8" fill="#f5f5f5" fill-opacity="0.7" stroke="#999"/><text x="46" y="226" text-anchor="start" font-size="14" font-weight="bold">Group One</text><path d="M 104 76 C 104 108, 104 108, 104 140" fill="none" stroke="#555" stroke-width="2"/><path d="M 104 184 C 104 216, 104 216, 104 248" fill="none" stroke="#555" stroke-width="2"/><path d="M 104 292 C 104 324, 104 324, 104 356" fill="none" stroke="#555" stroke-width="2"/><polygon points="104,140 99,129 109,129" fill="#555"/><polygon points="104,248 99,237 109,237" fill="#555"/><polygon points="104,356 99,345 109,345" fill="#555"/><rect x="56" y="32" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="104" y="59" text-anchor="middle" font-size="14">A</text><rect x="56" y="140" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="104" y="167" text-anchor="middle" font-size="14">B</text><rect x="56" y="248" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="104" y="275" text-anchor="middle" font-size="14">C</text><rect x="56" y="356" width="96" height="44" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="104" y="383" text-anchor="middle" font-size="14">D</text></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 348 300" font-family="sans-serif"><line x1="99" y1="68" x2="99" y2="276" stroke="#999" stroke-dasharray="5 5"/><line x1="249" y1="68" x2="249" y2="276" stroke="#999" stroke-dasharray="5 5"/><rect x="94" y="102" width="10" height="132" fill="#e8e8e8" stroke="#666" stroke-width="1"/><rect x="36" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="99" y="51" text-anchor="middle" font-size="14" font-weight="bold">A</text><rect x="186" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="249" y="51" text-anchor="middle" font-size="14" font-weight="bold">B</text><line x1="99" y1="144" x2="249" y2="144" stroke="#333" stroke-width="1.8"/><polygon points="249,144 238,139 238,149" fill="#333"/><text x="174" y="137" text-anchor="middle" font-size="13">request</text><line x1="249" y1="210" x2="99" y2="210" stroke="#333" stroke-width="1.8" stroke-dasharray="7 5"/><polygon points="99,210 110,205 110,215" fill="#333"/><text x="174" y="203" text-anchor="middle" font-size="13">response</text></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 348 380" font-family="sans-serif"><line x1="99" y1="68" x2="99" y2="356" stroke="#999" stroke-dasharray="5 5"/><line x1="249" y1="68" x2="249" y2="356" stroke="#999" stroke-dasharray="5 5"/><rect x="36" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="99" y="51" text-anchor="middle" font-size="14" font-weight="bold">A</text><rect x="186" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="249" y="51" text-anchor="middle" font-size="14" font-weight="bold">B</text><line x1="99" y1="126" x2="249" y2="126" stroke="#333" stroke-width="1.8"/><polygon points="249,126 238,121 238,131" fill="#333"/><text x="174" y="119" text-anchor="middle" font-size="13">check</text><line x1="99" y1="214" x2="249" y2="214" stroke="#333" stroke-width="1.8"/><polygon points="249,214 238,209 238,219" fill="#333"/><text x="174" y="207" text-anchor="middle" font-size="13">proceed</text><line x1="249" y1="302" x2="99" y2="302" stroke="#333" stroke-width="1.8" stroke-dasharray="7 5"/><polygon points="99,302 110,297 110,307" fill="#333"/><text x="174" y="295" text-anchor="middle" font-size="13">refuse</text><rect x="24" y="150" width="300" height="74" rx="6" fill="none" stroke="#888"/><rect x="24" y="150" width="72" height="22" fill="#eee" stroke="#888"/><text x="32" y="165" text-anchor="start" font-size="13">alt yes</text><rect x="24" y="238" width="300" height="98" rx="6" fill="none" stroke="#888"/><rect x="24" y="238" width="72" height="22" fill="#eee" stroke="#888"/><text x="32" y="253" text-anchor="start" font-size="13">else no</text></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 348 420" font-family="sans-serif"><line x1="99" y1="68" x2="99" y2="396" stroke="#999" stroke-dasharray="5 5"/><line x1="249" y1="68" x2="249" y2="396" stroke="#999" stroke-dasharray="5 5"/><rect x="36" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="99" y="51" text-anchor="middle" font-size="14" font-weight="bold">A</text><rect x="186" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="249" y="51" text-anchor="middle" font-size="14" font-weight="bold">B</text><line x1="99" y1="126" x2="249" y2="126" stroke="#333" stroke-width="1.8"/><polygon points="249,126 238,121 238,131" fill="#333"/><text x="174" y="119" text-anchor="middle" font-size="13">solid arrow</text><line x1="249" y1="174" x2="99" y2="174" stroke="#333" stroke-width="1.8" stroke-dasharray="7 5"/><polygon points="99,174 110,169 110,179" fill="#333"/><text x="174" y="167" text-anchor="middle" font-size="13">dashed arrow</text><line x1="99" y1="222" x2="249" y2="222" stroke="#333" stroke-width="1.8"/><line x1="244" y1="216" x2="253" y2="228" stroke="#333" stroke-width="1.8"/><line x1="253" y1="216" x2="244" y2="228" stroke="#333" stroke-width="1.8"/><text x="174" y="215" text-anchor="middle" font-size="13">solid cross</text><line x1="249" y1="270" x2="99" y2="270" stroke="#333" stroke-width="1.8" stroke-dasharray="7 5"/><line x1="104" y1="264" x2="95" y2="276" stroke="#333" stroke-width="1.8"/><line x1="95" y1="264" x2="104" y2="276" stroke="#333" stroke-width="1.8"/><text x="174" y="263" text-anchor="middle" font-size="13">dashed cross</text><line x1="99" y1="318" x2="249" y2="318" stroke="#333" stroke-width="1.8"/><polygon points="249,318 238,313 238,323" fill="#333"/><text x="174" y="311" text-anchor="middle" font-size="13">solid open</text><line x1="249" y1="366" x2="99" y2="366" stroke="#333" stroke-width="1.8" stroke-dasharray="7 5"/><polygon points="99,366 110,361 110,371" fill="#333"/><text x="174" y="359" text-anchor="middle" font-size="13">dashed open</text></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 348 276" font-family="sans-serif"><line x1="99" y1="68" x2="99" y2="252" stroke="#999" stroke-dasharray="5 5"/><line x1="249" y1="68" x2="249" y2="252" stroke="#999" stroke-dasharray="5 5"/><rect x="36" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="99" y="51" text-anchor="middle" font-size="14" font-weight="bold">A</text><rect x="186" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="249" y="51" text-anchor="middle" font-size="14" font-weight="bold">B</text><line x1="99" y1="126" x2="249" y2="126" stroke="#333" stroke-width="1.8"/><polygon points="249,126 238,121 238,131" fill="#333"/><text x="174" y="119" text-anchor="middle" font-size="13">1: first</text><line x1="249" y1="174" x2="99" y2="174" stroke="#333" stroke-width="1.8" stroke-dasharray="7 5"/><polygon points="99,174 110,169 110,179" fill="#333"/><text x="174" y="167" text-anchor="middle" font-size="13">2: second</text><path d="M 107 208 H 163 V 226 H 113" stroke="#333" stroke-width="1.8"/><polygon points="113,226 124,221 124,231" fill="#333"/><text x="173" y="216" text-anchor="start" font-size="13">3: self</text></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 348 228" font-family="sans-serif"><line x1="99" y1="68" x2="99" y2="204" stroke="#999" stroke-dasharray="5 5"/><line x1="249" y1="68" x2="249" y2="204" stroke="#999" stroke-dasharray="5 5"/><rect x="36" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="99" y="51" text-anchor="middle" font-size="14" font-weight="bold">Alice</text><rect x="186" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="249" y="51" text-anchor="middle" font-size="14" font-weight="bold">Bob</text><line x1="99" y1="126" x2="249" y2="126" stroke="#333" stroke-width="1.8"/><polygon points="249,126 238,121 238,131" fill="#333"/><text x="174" y="119" text-anchor="middle" font-size="13">Hello</text><line x1="249" y1="174" x2="99" y2="174" stroke="#333" stroke-width="1.8" stroke-dasharray="7 5"/><polygon points="99,174 110,169 110,179" fill="#333"/><text x="174" y="167" text-anchor="middle" font-size="13">Hi</text></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 348 244" font-family="sans-serif"><line x1="99" y1="68" x2="99" y2="220" stroke="#999" stroke-dasharray="5 5"/><line x1="249" y1="68" x2="249" y2="220" stroke="#999" stroke-dasharray="5 5"/><rect x="36" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="99" y="51" text-anchor="middle" font-size="14" font-weight="bold">A</text><rect x="186" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="249" y="51" text-anchor="middle" font-size="14" font-weight="bold">B</text><line x1="99" y1="166" x2="249" y2="166" stroke="#333" stroke-width="1.8"/><polygon points="249,166 238,161 238,171" fill="#333"/><text x="174" y="159" text-anchor="middle" font-size="13">stop</text><rect x="24" y="102" width="300" height="98" rx="6" fill="none" stroke="#888"/><rect x="24" y="102" width="120" height="22" fill="#eee" stroke="#888"/><text x="32" y="117" text-anchor="start" font-size="13">break failure</text></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 348 332" font-family="sans-serif"><line x1="99" y1="68" x2="99" y2="308" stroke="#999" stroke-dasharray="5 5"/><line x1="249" y1="68" x2="249" y2="308" stroke="#999" stroke-dasharray="5 5"/><rect x="36" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="99" y="51" text-anchor="middle" font-size="14" font-weight="bold">A</text><rect x="186" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="249" y="51" text-anchor="middle" font-size="14" font-weight="bold">B</text><line x1="99" y1="166" x2="249" y2="166" stroke="#333" stroke-width="1.8"/><polygon points="249,166 238,161 238,171" fill="#333"/><text x="174" y="159" text-anchor="middle" font-size="13">work</text><line x1="249" y1="254" x2="99" y2="254" stroke="#333" stroke-width="1.8" stroke-dasharray="7 5"/><polygon points="99,254 110,249 110,259" fill="#333"/><text x="174" y="247" text-anchor="middle" font-size="13">skip</text><rect x="24" y="102" width="300" height="74" rx="6" fill="none" stroke="#888"/><rect x="24" y="102" width="136" height="22" fill="#eee" stroke="#888"/><text x="32" y="117" text-anchor="start" font-size="13">critical locked</text><rect x="24" y="190" width="300" height="98" rx="6" fill="none" stroke="#888"/><rect x="24" y="190" width="136" height="22" fill="#eee" stroke="#888"/><text x="32" y="205" text-anchor="start" font-size="13">option unlocked</text></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 348 272" font-family="sans-serif"><line x1="99" y1="68" x2="99" y2="248" stroke="#999" stroke-dasharray="5 5"/><line x1="249" y1="68" x2="249" y2="248" stroke="#999" stroke-dasharray="5 5"/><rect x="36" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="99" y="51" text-anchor="middle" font-size="14" font-weight="bold">A</text><rect x="186" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="249" y="51" text-anchor="middle" font-size="14" font-weight="bold">B</text><line x1="99" y1="126" x2="249" y2="126" stroke="#333" stroke-width="1.8"/><polygon points="249,126 238,121 238,131" fill="#333"/><text x="174" y="119" text-anchor="middle" font-size="13">before</text><line x1="99" y1="218" x2="249" y2="218" stroke="#333" stroke-width="1.8"/><polygon points="249,218 238,213 238,223" fill="#333"/><text x="174" y="211" text-anchor="middle" font-size="13">after</text><line x1="24" y1="163" x2="324" y2="163" stroke="#999" stroke-dasharray="6 5"/><rect x="110" y="152" width="128" height="22" rx="10" fill="#eee" stroke="#999"/><text x="174" y="167" text-anchor="middle" font-size="13">section break</text></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 348 356" font-family="sans-serif"><line x1="99" y1="68" x2="99" y2="332" stroke="#999" stroke-dasharray="5 5"/><line x1="249" y1="68" x2="249" y2="332" stroke="#999" stroke-dasharray="5 5"/><rect x="36" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="99" y="51" text-anchor="middle" font-size="14" font-weight="bold">A</text><rect x="186" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="249" y="51" text-anchor="middle" font-size="14" font-weight="bold">B</text><line x1="99" y1="166" x2="249" y2="166" stroke="#333" stroke-width="1.8"/><polygon points="249,166 238,161 238,171" fill="#333"/><text x="174" y="159" text-anchor="middle" font-size="13">tick</text><line x1="249" y1="278" x2="99" y2="278" stroke="#333" stroke-width="1.8" stroke-dasharray="7 5"/><polygon points="99,278 110,273 110,283" fill="#333"/><text x="174" y="271" text-anchor="middle" font-size="13">tock</text><rect x="24" y="102" width="300" height="98" rx="6" fill="none" stroke="#888"/><rect x="24" y="102" width="136" height="22" fill="#eee" stroke="#888"/><text x="32" y="117" text-anchor="start" font-size="13">loop each round</text><rect x="24" y="214" width="300" height="98" rx="6" fill="none" stroke="#888"/><rect x="24" y="214" width="88" height="22" fill="#eee" stroke="#888"/><text x="32" y="229" text-anchor="start" font-size="13">opt maybe</text></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 348 396" font-family="sans-serif"><line x1="99" y1="68" x2="99" y2="372" stroke="#999" stroke-dasharray="5 5"/><line x1="249" y1="68" x2="249" y2="372" stroke="#999" stroke-dasharray="5 5"/><rect x="36" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="99" y="51" text-anchor="middle" font-size="14" font-weight="bold">A</text><rect x="186" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="249" y="51" text-anchor="middle" font-size="14" font-weight="bold">B</text><line x1="99" y1="206" x2="249" y2="206" stroke="#333" stroke-width="1.8"/><polygon points="249,206 238,201 238,211" fill="#333"/><text x="174" y="199" text-anchor="middle" font-size="13">x</text><line x1="249" y1="318" x2="99" y2="318" stroke="#333" stroke-width="1.8"/><polygon points="99,318 110,313 110,323" fill="#333"/><text x="174" y="311" text-anchor="middle" font-size="13">y</text><rect x="24" y="102" width="300" height="138" rx="6" fill="none" stroke="#888"/><rect x="24" y="102" width="88" height="22" fill="#eee" stroke="#888"/><text x="32" y="117" text-anchor="start" font-size="13">alt outer</text><rect x="36" y="142" width="276" height="98" rx="6" fill="none" stroke="#888"/><rect x="36" y="142" width="96" height="22" fill="#eee" stroke="#888"/><text x="44" y="157" text-anchor="start" font-size="13">loop inner</text><rect x="24" y="254" width="300" height="98" rx="6" fill="none" stroke="#888"/><rect x="24" y="254" width="96" height="22" fill="#eee" stroke="#888"/><text x="32" y="269" text-anchor="start" font-size="13">else other</text></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 348 308" font-family="sans-serif"><line x1="99" y1="68" x2="99" y2="284" stroke="#999" stroke-dasharray="5 5"/><line x1="249" y1="68" x2="249" y2="284" stroke="#999" stroke-dasharray="5 5"/><rect x="36" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="99" y="51" text-anchor="middle" font-size="14" font-weight="bold">A</text><rect x="186" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="249" y="51" text-anchor="middle" font-size="14" font-weight="bold">B</text><rect x="-29" y="100" width="114" height="30" rx="4" fill="#fffbe0" stroke="#999"/><text x="-17" y="120" text-anchor="start" font-size="13">on the left</text><rect x="263" y="144" width="122" height="30" rx="4" fill="#fffbe0" stroke="#999"/><text x="275" y="164" text-anchor="start" font-size="13">on the right</text><rect x="69" y="188" width="60" height="30" rx="4" fill="#fffbe0" stroke="#999"/><text x="99" y="208" text-anchor="middle" font-size="13">alone</text><rect x="69" y="232" width="210" height="30" rx="4" fill="#fffbe0" stroke="#999"/><text x="174" y="252" text-anchor="middle" font-size="13">spanning</text></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 648 332" font-family="sans-serif"><line x1="99" y1="68" x2="99" y2="308" stroke="#999" stroke-dasharray="5 5"/><line x1="249" y1="68" x2="249" y2="308" stroke="#999" stroke-dasharray="5 5"/><line x1="399" y1="68" x2="399" y2="308" stroke="#999" stroke-dasharray="5 5"/><line x1="549" y1="68" x2="549" y2="308" stroke="#999" stroke-dasharray="5 5"/><rect x="36" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="99" y="51" text-anchor="middle" font-size="14" font-weight="bold">A</text><rect x="186" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="249" y="51" text-anchor="middle" font-size="14" font-weight="bold">B</text><rect x="336" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="399" y="51" text-anchor="middle" font-size="14" font-weight="bold">C</text><rect x="486" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="549" y="51" text-anchor="middle" font-size="14" font-weight="bold">D</text><line x1="99" y1="166" x2="249" y2="166" stroke="#333" stroke-width="1.8"/><polygon points="249,166 238,161 238,171" fill="#333"/><text x="174" y="159" text-anchor="middle" font-size="13">one</text><line x1="399" y1="254" x2="549" y2="254" stroke="#333" stroke-width="1.8"/><polygon points="549,254 538,249 538,259" fill="#333"/><text x="474" y="247" text-anchor="middle" font-size="13">two</text><rect x="24" y="102" width="600" height="74" rx="6" fill="none" stroke="#888"/><rect x="24" y="102" width="80" height="22" fill="#eee" stroke="#888"/><text x="32" y="117" text-anchor="start" font-size="13">par left</text><rect x="24" y="190" width="600" height="98" rx="6" fill="none" stroke="#888"/><rect x="24" y="190" width="88" height="22" fill="#eee" stroke="#888"/><text x="32" y="205" text-anchor="start" font-size="13">and right</text></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 498 228" font-family="sans-serif"><line x1="99" y1="68" x2="99" y2="204" stroke="#999" stroke-dasharray="5 5"/><line x1="249" y1="68" x2="249" y2="204" stroke="#999" stroke-dasharray="5 5"/><line x1="399" y1="68" x2="399" y2="204" stroke="#999" stroke-dasharray="5 5"/><rect x="36" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="99" y="51" text-anchor="middle" font-size="14" font-weight="bold">Alice</text><rect x="186" y="24" width="126" height="44" rx="8" fill="#e8f0e8" stroke="#333" stroke-width="2.5"/><text x="249" y="51" text-anchor="middle" font-size="14" font-weight="bold">Bob</text><rect x="336" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="399" y="51" text-anchor="middle" font-size="14" font-weight="bold">C</text><line x1="99" y1="126" x2="249" y2="126" stroke="#333" stroke-width="1.8"/><polygon points="249,126 238,121 238,131" fill="#333"/><text x="174" y="119" text-anchor="middle" font-size="13">hi</text><line x1="249" y1="174" x2="399" y2="174" stroke="#333" stroke-width="1.8"/><polygon points="399,174 388,169 388,179" fill="#333"/><text x="324" y="167" text-anchor="middle" font-size="13">forward</text></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 348 356" font-family="sans-serif"><rect x="24" y="102" width="300" height="98" rx="6" fill="#c8ffc8" fill-opacity="0.18" stroke="none"/><rect x="24" y="214" width="300" height="98" rx="6" fill="#e0e0ff" fill-opacity="0.18" stroke="none"/><line x1="99" y1="68" x2="99" y2="332" stroke="#999" stroke-dasharray="5 5"/><line x1="249" y1="68" x2="249" y2="332" stroke="#999" stroke-dasharray="5 5"/><rect x="36" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="99" y="51" text-anchor="middle" font-size="14" font-weight="bold">A</text><rect x="186" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="249" y="51" text-anchor="middle" font-size="14" font-weight="bold">B</text><line x1="99" y1="166" x2="249" y2="166" stroke="#333" stroke-width="1.8"/><polygon points="249,166 238,161 238,171" fill="#333"/><text x="174" y="159" text-anchor="middle" font-size="13">inside</text><line x1="249" y1="278" x2="99" y2="278" stroke="#333" stroke-width="1.8" stroke-dasharray="7 5"/><polygon points="99,278 110,273 110,283" fill="#333"/><text x="174" y="271" text-anchor="middle" font-size="13">also inside</text><rect x="24" y="102" width="300" height="98" rx="6" fill="none" stroke="#888"/><rect x="24" y="214" width="300" height="98" rx="6" fill="none" stroke="#888"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198 228" font-family="sans-serif"><line x1="99" y1="68" x2="99" y2="204" stroke="#999" stroke-dasharray="5 5"/><rect x="36" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="99" y="51" text-anchor="middle" font-size="14" font-weight="bold">A</text><path d="M 107 112 H 163 V 130 H 113" stroke="#333" stroke-width="1.8"/><polygon points="113,130 124,125 124,135" fill="#333"/><text x="173" y="120" text-anchor="start" font-size="13">think</text><path d="M 107 160 H 163 V 178 H 113" stroke="#333" stroke-width="1.8" stroke-dasharray="7 5"/><polygon points="113,178 124,173 124,183" fill="#333"/><text x="173" y="168" text-anchor="start" font-size="13">rethink</text></svg>
|
||||
|
After Width: | Height: | Size: 770 B |