diff --git a/.gitattributes b/.gitattributes index fe07df655..5b491df70 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,9 @@ +# Leading ** is required. +**/testdata/**/*.json linguist-generated +go.mod linguist-generated +go.sum linguist-generated d2layouts/d2dagrelayout/dagre.js linguist-vendored d2layouts/d2elklayout/elk.js linguist-vendored d2renderers/d2svg/github-markdown.css linguist-vendored +d2renderers/d2latex/mathjax.js linguist-vendored +d2renderers/d2latex/polyfills.js linguist-vendored diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1157e58e7..b97e10c55 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,17 +5,7 @@ concurrency: cancel-in-progress: true jobs: - assert-linear: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - with: - submodules: recursive - - run: COLOR=1 ./make.sh assert-linear - env: - GITHUB_TOKEN: ${{ secrets._GITHUB_TOKEN }} - DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} - fmt: + ci: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 @@ -25,63 +15,7 @@ jobs: with: go-version-file: ./go.mod cache: true - - run: COLOR=1 ./make.sh fmt - env: - GITHUB_TOKEN: ${{ secrets._GITHUB_TOKEN }} - DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} - gen: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - with: - submodules: recursive - - uses: actions/setup-go@v3 - with: - go-version-file: ./go.mod - cache: true - - run: COLOR=1 ./make.sh gen - env: - GITHUB_TOKEN: ${{ secrets._GITHUB_TOKEN }} - DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - with: - submodules: recursive - - uses: actions/setup-go@v3 - with: - go-version-file: ./go.mod - cache: true - - run: COLOR=1 ./make.sh lint - env: - GITHUB_TOKEN: ${{ secrets._GITHUB_TOKEN }} - DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - with: - submodules: recursive - - uses: actions/setup-go@v3 - with: - go-version-file: ./go.mod - cache: true - - run: COLOR=1 ./make.sh build - env: - GITHUB_TOKEN: ${{ secrets._GITHUB_TOKEN }} - DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - with: - submodules: recursive - - uses: actions/setup-go@v3 - with: - go-version-file: ./go.mod - cache: true - - run: COLOR=1 ./make.sh test + - run: COLOR=1 ./make.sh all race env: GITHUB_TOKEN: ${{ secrets._GITHUB_TOKEN }} DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} @@ -90,22 +24,3 @@ jobs: with: name: d2chaos-test path: ./d2chaos/out - race: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - with: - submodules: recursive - - uses: actions/setup-go@v3 - with: - go-version-file: ./go.mod - cache: true - - run: COLOR=1 ./make.sh race - env: - GITHUB_TOKEN: ${{ secrets._GITHUB_TOKEN }} - DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} - - uses: actions/upload-artifact@v3 - if: always() - with: - name: d2chaos-race - path: ./d2chaos/out diff --git a/.github/workflows/daily.yml b/.github/workflows/daily.yml index 6334923c1..58b485496 100644 --- a/.github/workflows/daily.yml +++ b/.github/workflows/daily.yml @@ -10,7 +10,7 @@ concurrency: cancel-in-progress: true jobs: - all: + ci: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 @@ -20,7 +20,7 @@ jobs: with: go-version-file: ./go.mod cache: true - - run: CI_ALL=1 COLOR=1 ./make.sh + - run: COLOR=1 CI_FORCE=1 ./make.sh all race env: GITHUB_TOKEN: ${{ secrets._GITHUB_TOKEN }} DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} diff --git a/Makefile b/Makefile index 9780b7bc0..d4241d86f 100644 --- a/Makefile +++ b/Makefile @@ -2,9 +2,6 @@ .PHONY: all all: fmt gen lint build test -ifdef CI -all: assert-linear -endif .PHONY: fmt fmt: @@ -24,6 +21,3 @@ test: .PHONY: race race: prefix "$@" ./ci/test.sh --race ./... -.PHONY: assert-linear -assert-linear: - prefix "$@" ./ci/sub/assert_linear.sh diff --git a/ci/release/_install.sh b/ci/release/_install.sh index 4cd840ca9..a66f2d730 100755 --- a/ci/release/_install.sh +++ b/ci/release/_install.sh @@ -14,7 +14,7 @@ help() { fi cat <&2 + printf '$COLOR must be 0, 1, false or true but got %s\n' "$COLOR" >&2 fi fi @@ -219,8 +219,9 @@ header() { } bigheader() { + set -- "$(echo "$*" | sed "s/^/ * /")" logp "/** - * $1 +$* **/" } diff --git a/cmd/d2/main.go b/cmd/d2/main.go index d213390d8..c0207d19b 100644 --- a/cmd/d2/main.go +++ b/cmd/d2/main.go @@ -14,6 +14,7 @@ import ( "github.com/spf13/pflag" "oss.terrastruct.com/d2" + "oss.terrastruct.com/d2/d2layouts/d2sequence" "oss.terrastruct.com/d2/d2plugin" "oss.terrastruct.com/d2/d2renderers/d2svg" "oss.terrastruct.com/d2/d2renderers/textmeasure" @@ -189,8 +190,13 @@ func compile(ctx context.Context, ms *xmain.State, isWatching bool, plugin d2plu return nil, err } + layout := plugin.Layout + // TODO: remove, this is just a feature flag to test sequence diagrams as we work on them + if os.Getenv("D2_SEQUENCE") == "1" { + layout = d2sequence.Layout + } d, err := d2.Compile(ctx, string(input), &d2.CompileOptions{ - Layout: plugin.Layout, + Layout: layout, Ruler: ruler, ThemeID: themeID, }) diff --git a/d2compiler/compile.go b/d2compiler/compile.go index 0db0d62d4..f865bfb9b 100644 --- a/d2compiler/compile.go +++ b/d2compiler/compile.go @@ -266,6 +266,8 @@ func (c *compiler) compileKey(obj *d2graph.Object, m *d2ast.Map, mk *d2ast.Key) }, unresolvedObj) } else if obj.Parent == nil { // Top level reserved key set on root. + c.compileAttributes(&obj.Attributes, mk) + c.applyScalar(&obj.Attributes, reserved, mk.Value.ScalarBox()) return } @@ -377,7 +379,7 @@ func (c *compiler) applyScalar(attrs *d2graph.Attributes, reserved string, box d if ok { attrs.Language = fullTag } - if attrs.Language == "markdown" { + if attrs.Language == "markdown" || attrs.Language == "latex" { attrs.Shape.Value = d2target.ShapeText } else { attrs.Shape.Value = d2target.ShapeCode @@ -548,12 +550,13 @@ func (c *compiler) compileFlatKey(k *d2ast.KeyPath) ([]string, string, bool) { // TODO add more, e.g. C, bash var ShortToFullLanguageAliases = map[string]string{ - "md": "markdown", - "js": "javascript", - "go": "golang", - "py": "python", - "rb": "ruby", - "ts": "typescript", + "md": "markdown", + "tex": "latex", + "js": "javascript", + "go": "golang", + "py": "python", + "rb": "ruby", + "ts": "typescript", } var FullToShortLanguageAliases map[string]string diff --git a/d2compiler/compile_test.go b/d2compiler/compile_test.go index c43639cb8..dbcc86071 100644 --- a/d2compiler/compile_test.go +++ b/d2compiler/compile_test.go @@ -1501,6 +1501,26 @@ dst.id <-> src.dst_id } }, }, + { + name: "basic_sequence", + + text: `x: { + shape: sequence_diagram +} +`, + assertions: func(t *testing.T, g *d2graph.Graph) { + diff.AssertStringEq(t, "sequence_diagram", g.Objects[0].Attributes.Shape.Value) + }, + }, + { + name: "root_sequence", + + text: `shape: sequence_diagram +`, + assertions: func(t *testing.T, g *d2graph.Graph) { + diff.AssertStringEq(t, "sequence_diagram", g.Root.Attributes.Shape.Value) + }, + }, } for _, tc := range testCases { diff --git a/d2graph/d2graph.go b/d2graph/d2graph.go index 5687608f1..1fe88fba4 100644 --- a/d2graph/d2graph.go +++ b/d2graph/d2graph.go @@ -11,6 +11,7 @@ import ( "oss.terrastruct.com/d2/d2format" "oss.terrastruct.com/d2/d2parser" "oss.terrastruct.com/d2/d2renderers/d2fonts" + "oss.terrastruct.com/d2/d2renderers/d2latex" "oss.terrastruct.com/d2/d2renderers/textmeasure" "oss.terrastruct.com/d2/d2target" "oss.terrastruct.com/d2/d2themes" @@ -833,10 +834,18 @@ func (g *Graph) SetDimensions(mtexts []*d2target.MText, ruler *textmeasure.Ruler var dims *d2target.TextDimensions var innerLabelPadding = 5 if obj.Attributes.Shape.Value == d2target.ShapeText { - var err error - dims, err = getMarkdownDimensions(mtexts, ruler, obj.Text()) - if err != nil { - return err + if obj.Attributes.Language == "latex" { + width, height, err := d2latex.Measure(obj.Text().Text) + if err != nil { + return err + } + dims = d2target.NewTextDimensions(width, height) + } else { + var err error + dims, err = getMarkdownDimensions(mtexts, ruler, obj.Text()) + if err != nil { + return err + } } innerLabelPadding = 0 } else { diff --git a/d2layouts/d2sequence/constants.go b/d2layouts/d2sequence/constants.go new file mode 100644 index 000000000..bdffd77f8 --- /dev/null +++ b/d2layouts/d2sequence/constants.go @@ -0,0 +1,9 @@ +package d2sequence + +// leaves at least 25 units of space on the left/right when computing the space required between actors +const HORIZONTAL_PAD = 50. + +const MIN_ACTOR_DISTANCE = 200. + +// min vertical distance between edges +const MIN_EDGE_DISTANCE = 100. diff --git a/d2layouts/d2sequence/layout.go b/d2layouts/d2sequence/layout.go new file mode 100644 index 000000000..5d1107a39 --- /dev/null +++ b/d2layouts/d2sequence/layout.go @@ -0,0 +1,107 @@ +package d2sequence + +import ( + "context" + "fmt" + "math" + + "oss.terrastruct.com/d2/d2graph" + "oss.terrastruct.com/d2/lib/geo" + "oss.terrastruct.com/d2/lib/go2" + "oss.terrastruct.com/d2/lib/label" +) + +func Layout(ctx context.Context, g *d2graph.Graph) (err error) { + edgeYStep := MIN_EDGE_DISTANCE + actorXStep := MIN_ACTOR_DISTANCE + maxActorHeight := 0. + + actorRank := make(map[*d2graph.Object]int) + for rank, actor := range g.Objects { + actorRank[actor] = rank + } + for _, edge := range g.Edges { + edgeYStep = math.Max(edgeYStep, float64(edge.LabelDimensions.Height)+HORIZONTAL_PAD) + maxActorHeight = math.Max(maxActorHeight, edge.Src.Height+HORIZONTAL_PAD) + maxActorHeight = math.Max(maxActorHeight, edge.Dst.Height+HORIZONTAL_PAD) + // ensures that long labels, spanning over multiple actors, don't make for large gaps between actors + // by distributing the label length across the actors rank difference + rankDiff := math.Abs(float64(actorRank[edge.Src]) - float64(actorRank[edge.Dst])) + distributedLabelWidth := float64(edge.LabelDimensions.Width) / rankDiff + actorXStep = math.Max(actorXStep, distributedLabelWidth+HORIZONTAL_PAD) + } + + placeActors(g.Objects, maxActorHeight, actorXStep) + routeEdges(g.Edges, maxActorHeight, edgeYStep) + addLifelineEdges(g, g.Objects, edgeYStep) + + return nil +} + +// placeActors places actors bottom aligned, side by side +func placeActors(actors []*d2graph.Object, maxHeight, xStep float64) { + x := 0. + for _, actors := range actors { + yOffset := maxHeight - actors.Height + actors.TopLeft = geo.NewPoint(x, yOffset) + x += actors.Width + xStep + actors.LabelPosition = go2.Pointer(string(label.InsideMiddleCenter)) + } +} + +// routeEdges routes horizontal edges from Src to Dst +func routeEdges(edgesInOrder []*d2graph.Edge, startY, yStep float64) { + edgeY := startY + yStep // in case the first edge has a tall label + for _, edge := range edgesInOrder { + start := edge.Src.Center() + start.Y = edgeY + end := edge.Dst.Center() + end.Y = edgeY + edge.Route = []*geo.Point{start, end} + edgeY += yStep + + if edge.Attributes.Label.Value != "" { + isLeftToRight := edge.Src.TopLeft.X < edge.Dst.TopLeft.X + if isLeftToRight { + edge.LabelPosition = go2.Pointer(string(label.OutsideTopCenter)) + } else { + edge.LabelPosition = go2.Pointer(string(label.OutsideBottomCenter)) + } + } + } +} + +// addLifelineEdges adds a new edge for each actor in the graph that represents the +// edge below the actor showing its lifespan +// ┌──────────────┐ +// │ actor │ +// └──────┬───────┘ +// │ +// │ lifeline +// │ +// │ +func addLifelineEdges(g *d2graph.Graph, actors []*d2graph.Object, yStep float64) { + endY := g.Edges[len(g.Edges)-1].Route[0].Y + yStep + for _, actor := range actors { + actorBottom := actor.Center() + actorBottom.Y = actor.TopLeft.Y + actor.Height + actorLifelineEnd := actor.Center() + actorLifelineEnd.Y = endY + g.Edges = append(g.Edges, &d2graph.Edge{ + Attributes: d2graph.Attributes{ + Style: d2graph.Style{ + StrokeDash: &d2graph.Scalar{Value: "10"}, + Stroke: actor.Attributes.Style.Stroke, + StrokeWidth: actor.Attributes.Style.StrokeWidth, + }, + }, + Src: actor, + SrcArrow: false, + Dst: &d2graph.Object{ + ID: actor.ID + fmt.Sprintf("-lifeline-end-%d", go2.StringToIntHash(actor.ID+"-lifeline-end")), + }, + DstArrow: false, + Route: []*geo.Point{actorBottom, actorLifelineEnd}, + }) + } +} diff --git a/d2layouts/d2sequence/layout_test.go b/d2layouts/d2sequence/layout_test.go new file mode 100644 index 000000000..f01575292 --- /dev/null +++ b/d2layouts/d2sequence/layout_test.go @@ -0,0 +1,112 @@ +package d2sequence + +import ( + "context" + "testing" + + "oss.terrastruct.com/d2/d2graph" + "oss.terrastruct.com/d2/lib/geo" + "oss.terrastruct.com/d2/lib/log" +) + +func TestLayout(t *testing.T) { + g := d2graph.NewGraph(nil) + g.Objects = []*d2graph.Object{ + { + ID: "Alice", + Box: geo.NewBox(nil, 100, 100), + }, + { + ID: "Bob", + Box: geo.NewBox(nil, 30, 30), + }, + } + + g.Edges = []*d2graph.Edge{ + { + Src: g.Objects[0], + Dst: g.Objects[1], + }, + { + Src: g.Objects[1], + Dst: g.Objects[0], + }, + { + Src: g.Objects[0], + Dst: g.Objects[1], + }, + { + Src: g.Objects[1], + Dst: g.Objects[0], + }, + } + nEdges := len(g.Edges) + + ctx := log.WithTB(context.Background(), t, nil) + Layout(ctx, g) + + // asserts that actors were placed in the expected x order and at y=0 + actors := []*d2graph.Object{ + g.Objects[0], + g.Objects[1], + } + for i := 1; i < len(actors); i++ { + if actors[i].TopLeft.X < actors[i-1].TopLeft.X { + t.Fatalf("expected actor[%d].TopLeft.X > actor[%d].TopLeft.X", i, i-1) + } + actorBottom := actors[i].TopLeft.Y + actors[i].Height + prevActorBottom := actors[i-1].TopLeft.Y + actors[i-1].Height + if actorBottom != prevActorBottom { + t.Fatalf("expected actor[%d] and actor[%d] to be at the same bottom y", i, i-1) + } + } + + nExpectedEdges := nEdges + len(actors) + if len(g.Edges) != nExpectedEdges { + t.Fatalf("expected %d edges, got %d", nExpectedEdges, len(g.Edges)) + } + + // assert that edges were placed in y order and have the endpoints at their actors + // uses `nEdges` because Layout creates some vertical edges to represent the actor lifeline + for i := 0; i < nEdges; i++ { + edge := g.Edges[i] + if len(edge.Route) != 2 { + t.Fatalf("expected edge[%d] to have only 2 points", i) + } + if edge.Route[0].Y != edge.Route[1].Y { + t.Fatalf("expected edge[%d] to be a horizontal line", i) + } + if edge.Route[0].X != edge.Src.Center().X { + t.Fatalf("expected edge[%d] source endpoint to be at the middle of the source actor", i) + } + if edge.Route[1].X != edge.Dst.Center().X { + t.Fatalf("expected edge[%d] target endpoint to be at the middle of the target actor", i) + } + if i > 0 { + prevEdge := g.Edges[i-1] + if edge.Route[0].Y < prevEdge.Route[0].Y { + t.Fatalf("expected edge[%d].TopLeft.Y > edge[%d].TopLeft.Y", i, i-1) + } + } + } + + lastSequenceEdge := g.Edges[nEdges-1] + for i := nEdges; i < nExpectedEdges; i++ { + edge := g.Edges[i] + if len(edge.Route) != 2 { + t.Fatalf("expected edge[%d] to have only 2 points", i) + } + if edge.Route[0].X != edge.Route[1].X { + t.Fatalf("expected edge[%d] to be a vertical line", i) + } + if edge.Route[0].X != edge.Src.Center().X { + t.Fatalf("expected edge[%d] x to be at the actor center", i) + } + if edge.Route[0].Y != edge.Src.Height+edge.Src.TopLeft.Y { + t.Fatalf("expected edge[%d] to start at the bottom of the source actor", i) + } + if edge.Route[1].Y < lastSequenceEdge.Route[0].Y { + t.Fatalf("expected edge[%d] to end after the last sequence edge", i) + } + } +} diff --git a/d2parser/parse_test.go b/d2parser/parse_test.go index 0232cd28d..9c820e83e 100644 --- a/d2parser/parse_test.go +++ b/d2parser/parse_test.go @@ -158,17 +158,17 @@ meow.(x -> y -> z)[3].shape: "all hail corn" { name: "errs", text: ` ---: meow]]] -meow][: ok -ok: "dmsadmakls" dsamkldkmsa - -s.shape: orochimaru -x.shape: dasdasdas +--: meow]]] ` + ` +meow][: ok ` + ` +ok: "dmsadmakls" dsamkldkmsa ` + ` + ` + ` +s.shape: orochimaru ` + ` +x.shape: dasdasdas ` + ` wow: -: - +: ` + ` + ` + ` [] {} @@ -214,7 +214,7 @@ meow { name: "trailing_whitespace", text: ` -s.shape: orochimaru +s.shape: orochimaru ` + ` `, }, { @@ -334,16 +334,16 @@ a: | hello | name: "block_trailing_space", text: ` x: | - meow + meow ` + ` | -""" hello +""" hello ` + ` """ `, }, { name: "block_edge_case", text: ` -x: | meow +x: | meow ` + ` hello yes | diff --git a/d2renderers/d2latex/latex.go b/d2renderers/d2latex/latex.go new file mode 100644 index 000000000..79330ad3a --- /dev/null +++ b/d2renderers/d2latex/latex.go @@ -0,0 +1,83 @@ +//go:build cgo + +package d2latex + +import ( + _ "embed" + "fmt" + "math" + "regexp" + "strconv" + + "oss.terrastruct.com/xdefer" + v8 "rogchap.com/v8go" +) + +var pxPerEx = 8 + +//go:embed polyfills.js +var polyfillsJS string + +//go:embed setup.js +var setupJS string + +//go:embed mathjax.js +var mathjaxJS string + +// Matches this +//