diff --git a/ci/performance-results/native-go-20260915.md b/ci/performance-results/native-go-20260915.md new file mode 100644 index 0000000000..ed044be85c --- /dev/null +++ b/ci/performance-results/native-go-20260915.md @@ -0,0 +1,101 @@ +# Native Go optimization evidence — 2026-09-15 UTC + +The PR retains three Go changes with modest, workload-specific measured gains: avoid building painted SVG during Markdown dimension measurement; reuse per-operation text measurement state and measure legend labels once; and use typed node sorting. None establishes that every diagram becomes faster. The routing scratch-map candidate was rejected because its predeclared screen did not establish a runtime improvement. + +The direct original-baseline comparison reduced pipeline time by 0.254% across all 246 successful cases and by 0.913% across the ten public diagrams. The 24 Markdown cases showed a larger pipeline reduction of 1.750%. These are workload-specific aggregate estimates; the full intervals and unfavorable observations follow. + +The [compact evidence ZIP](native-go-20260915.zip) contains original numeric rows, exact frozen runners and estimator, full per-case results and confidence intervals, reproduction instructions, and the rejected experiment. Private source diagrams, descriptive identifiers, private paths, font bytes, errors and SVGs are excluded. Private cases use stable opaque IDs. + +Measured original source: `bf33790338b9854cb2a34418e69c17f9abf8de4b`. Measured final accepted source: `cccf1c79f309f4220e5bd49dcfdcffea61857a9e`. These identify the measured production states; later documentation or comment-only PR commits do not constitute a new performance measurement. + +## Retained changes and incremental results + +1. **Markdown dimensions only** (`d6a52f13359dc957e346c792507728c6fd3b0c76`): preserve layout and validation while avoiding the painting path when only dimensions are needed. Its fixed 24-case Markdown confirmation reduced pipeline time by 0.783%. +2. **Ruler and legend measurement reuse** (`bb3efe8217e676663a502f9d49b9598e310b10d2`): let the native SVG CLI reuse its compiler ruler, share lazy measurement state within a render operation, and measure legend rows once. The fixed 25-case target confirmation reduced pipeline time by 0.935%; full-corpus pipeline time was inconclusive. +3. **Typed node sorting** (`cccf1c79f309f4220e5bd49dcfdcffea61857a9e`): replace reflection through `sort.Slice` with `slices.SortFunc` and `cmp.Compare`. The Go 1.27 implementation retains the same sorting algorithm, comparator order and guards. Full-corpus pipeline time fell 0.396%, with fewer allocated bytes and objects. + +Each cell is the candidate percentage change and its 95% interval, in percentage points. Negative means lower. **(inc.) means inconclusive:** the interval includes no change. Aggregate estimates are ratios of summed per-case medians. Each incremental full-corpus campaign used the same 246 successful cases, ten paired timing rounds after two warmups, and three separate allocation rounds. + +| Comparison | Process wall | Pipeline | CPU | Peak RSS | Allocated bytes | Allocation count | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| Step 1 vs original | -0.195% [-0.332, -0.102] | -0.204% [-0.340, -0.085] | -0.257% [-0.390, -0.169] | -0.068% [-0.170, +0.057] (inc.) | -0.230% [-0.266, +0.046] (inc.) | -0.050% [-0.083, -0.031] | +| Step 2 vs Step 1 | +0.030% [-0.432, +0.505] (inc.) | +0.028% [-0.550, +0.624] (inc.) | +0.005% [-0.629, +0.469] (inc.) | -0.346% [-0.470, -0.172] | -0.349% [-0.794, -0.349] | -0.138% [-0.154, -0.131] | +| Step 3 vs Step 2 | -0.273% [-0.629, +0.058] (inc.) | -0.396% [-0.846, -0.027] | -0.437% [-0.887, -0.095] | -0.043% [-0.242, +0.178] (inc.) | -1.024% [-1.142, -0.873] | -3.271% [-3.309, -3.244] | + +The following confirmations use fixed affected cohorts, twenty paired timing rounds after two warmups, and three allocation rounds. They are separate campaigns with their own adjacent-step baselines; they are not cumulative estimates. + +| Comparison | Process wall | Pipeline | CPU | Peak RSS | Allocated bytes | Allocation count | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| Step 1: 24 Markdown cases | -0.676% [-0.910, -0.430] | -0.783% [-1.040, -0.530] | -0.766% [-1.001, -0.374] | -0.406% [-0.645, -0.130] | -2.055% [-4.050, -1.421] | -1.059% [-1.066, -1.058] | +| Step 2: 24 Markdown + 1 native legend | -0.868% [-1.267, -0.443] | -0.935% [-1.234, -0.673] | -1.130% [-1.586, -0.722] | -1.400% [-1.579, -1.192] | -4.806% [-5.284, -4.497] | -1.978% [-1.992, -1.953] | + +Step 2 target rendering time fell 18.736% [−20.058%, −17.164%], but the pipeline gain above is the relevant end-to-end measure. Within the Step 2 campaigns, every accepted-Step-1 control row reports `ruler_reused=false` and every Step 2 row reports `true`. The original v1 Step 1 campaigns did not record this field. Steps 2 and 3 use the same v2 runner; its compatibility adapter explicitly adopts the ruler option as native CLI code does. Unchanged library callers that do not supply the ruler do not get that opt-in reuse. + +## Direct original-baseline versus final comparison + +| Comparison | Process wall | Pipeline | CPU | Peak RSS | Allocated bytes | Allocation count | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| Final vs original: all 246 | -0.172% [-0.444, -0.008] | -0.254% [-0.541, -0.061] | -0.472% [-0.747, -0.273] | -0.486% [-0.594, -0.369] | -1.483% [-1.608, -1.192] | -3.466% [-3.494, -3.462] | + +| Comparison | Process wall | Pipeline | CPU | Peak RSS | Allocated bytes | Allocation count | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| Final vs original: ten public | -0.798% [-1.943, -0.212] | -0.913% [-2.031, -0.239] | -1.542% [-2.319, -0.837] | -0.954% [-1.834, +0.092] (inc.) | -3.435% [-3.549, -3.435] | -9.568% [-9.722, -9.566] | + +These are direct paired measurements from the original baseline and final accepted source, both built with the same v2 runner. The cumulative estimates and confidence intervals are computed from this campaign alone. A predeclared policy separately confirms every case whose original wall/pipeline/CPU interval lies wholly above no change; confirmation samples are never pooled with or substituted for this full run. + +## All ten public diagrams, direct comparison + +| Public diagram | Pipeline ms, original → final | Process wall | Pipeline | CPU | Peak RSS | Allocated bytes | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| fulcro_rad | 91.664 → 89.966 | -1.918% [-2.871, -0.914] | -1.853% [-2.894, -0.693] | -2.602% [-4.280, -0.953] | -1.436% [-2.465, +0.125] (inc.) | -5.247% [-9.636, -4.865] | +| jupyter_aws_eks | 10.658 → 10.788 | +1.604% [+0.553, +3.052] | +1.220% [-2.040, +3.900] (inc.) | +1.044% [-3.877, +6.485] (inc.) | +0.202% [-0.876, +1.129] (inc.) | -0.115% [-0.333, +12.868] (inc.) | +| jupyter_k8s_oidc | 15.169 → 15.225 | +0.500% [-2.563, +1.851] (inc.) | +0.369% [-1.352, +1.758] (inc.) | +0.005% [-1.409, +4.656] (inc.) | +0.927% [-0.577, +2.049] (inc.) | +0.078% [-9.321, +0.124] (inc.) | +| leios_simulator | 19.377 → 19.175 | +0.473% [-0.502, +1.409] (inc.) | -1.042% [-2.093, +0.856] (inc.) | -2.395% [-4.981, +0.804] (inc.) | -1.459% [-2.836, +0.790] (inc.) | +0.534% [-0.147, +0.586] (inc.) | +| lion_reader_frontend | 72.751 → 70.727 | -2.252% [-4.094, -1.214] | -2.782% [-5.063, -1.767] | -2.077% [-5.473, -1.259] | -0.968% [-1.524, +0.840] (inc.) | -5.152% [-5.259, +0.019] (inc.) | +| mocha_soc | 170.781 → 170.386 | -0.006% [-2.149, +1.107] (inc.) | -0.231% [-1.945, +0.923] (inc.) | -0.047% [-1.710, +0.940] (inc.) | +0.426% [-1.033, +2.097] (inc.) | -1.123% [-3.683, -1.075] | +| queue_workers | 161.441 → 160.995 | -0.204% [-1.362, +0.521] (inc.) | -0.276% [-1.474, +0.528] (inc.) | -0.172% [-1.109, +0.693] (inc.) | +2.429% [-1.359, +4.547] (inc.) | -2.844% [-2.868, -2.844] | +| ross_overview | 79.733 → 79.778 | +0.394% [-0.596, +1.521] (inc.) | +0.056% [-1.145, +1.132] (inc.) | +0.731% [-0.344, +2.298] (inc.) | -0.358% [-1.439, +0.169] (inc.) | -2.489% [-2.683, +3.069] (inc.) | +| spyre_encoder | 58.348 → 56.265 | -2.565% [-4.448, -0.404] | -3.570% [-4.680, -1.704] | -2.438% [-4.244, -0.481] | -0.439% [-1.332, +0.377] (inc.) | -11.497% [-11.793, -11.302] | +| tpmjs_architecture | 2026.851 → 2008.747 | -0.860% [-2.347, -0.018] | -0.893% [-2.357, -0.052] | -1.804% [-2.771, -0.839] | -5.107% [-9.069, +0.460] (inc.) | -3.508% [-3.595, -3.481] | + +Every public diagram is shown, including unfavorable and inconclusive results. These individual intervals are unadjusted for multiple comparisons; an interval spanning zero establishes neither improvement nor absence of regression. + +## Selection, controls and rejected experiment + +Before the full direct results were inspected, the policy required one independent confirmation of every case whose original wall/pipeline/CPU interval lay wholly above no change. All seven triggered cases (nine metric signals) were included, with twenty paired timing rounds after two warmups and three allocation rounds: 350 rows. None of the original signals repeated with such an interval, and no confirmed case had a higher wall/pipeline/CPU/RSS interval. This does not prove zero regression. No further selected reruns were performed. The original full run remains the cumulative estimate; confirmation results are not pooled, substituted or used to revise its intervals. + +| Case / original signal | Original full run | Independent confirmation | +| --- | ---: | ---: | +| public/jupyter_aws_eks / wall_ms | +1.604% [+0.553, +3.052] | +0.013% [-1.733, +0.965] (inc.) | +| private-0034 / cpu_ms | +3.349% [+0.460, +8.721] | -5.165% [-7.449, -1.029] | +| private-0049 / cpu_ms | +2.373% [+1.104, +4.390] | -0.860% [-2.289, +1.002] (inc.) | +| private-0058 / pipeline_ms | +1.514% [+0.173, +2.222] | -0.253% [-1.381, +0.719] (inc.) | +| private-0058 / cpu_ms | +2.456% [+0.665, +4.853] | -0.690% [-2.660, +1.124] (inc.) | +| private-0160 / cpu_ms | +1.033% [+0.066, +2.003] | -0.011% [-0.932, +1.061] (inc.) | +| private-0179 / wall_ms | +4.594% [+1.186, +7.256] | -0.222% [-2.558, +1.707] (inc.) | +| private-0191 / wall_ms | +1.577% [+0.025, +3.223] | -0.162% [-0.777, +1.216] (inc.) | +| private-0191 / cpu_ms | +2.230% [+0.496, +3.545] | -0.146% [-1.037, +1.023] (inc.) | + +In particular, the original `jupyter_aws_eks` wall signal remains visible in the public table; its separate confirmation was +0.013% [−1.733%, +0.965%], inconclusive. Two original RSS signals were outside the timing-based follow-up selection: `private-0054` +0.605% [+0.072%, +1.417%] and `private-0126` +1.749% [+0.387%, +2.390%]. These unfavorable observations remain reported; they were not independently remeasured, and their intervals are unadjusted. + +All campaigns and less favorable observations are retained. The Step 1 fixed Markdown confirmation was chosen by its predeclared 24-case cohort. Its two initially flagged control cases did not repeat with an interval excluding no change: `private-0218` pipeline −0.782% [−3.572%, +1.875%] and `private-0223` wall +1.280% [−0.618%, +2.866%]. One Markdown case, `private-0207`, had higher render and RSS medians without a confirmed wall/pipeline/CPU regression. This does not establish zero regression. + +Step 2 confirmed all 24 Markdown cases plus the native legend, and separately remeasured six cases flagged for wall/pipeline/CPU regression. The original signals did not repeat with an interval excluding no change; neither target nor control follow-up had such a wall/pipeline/CPU/RSS regression. Step 3 separately confirmed ten initially flagged wall/pipeline/CPU cases; none of those original signals repeated. A new RSS signal for `private-0078` appeared (+0.764%, interval +0.359% to +1.373%), without a runtime or CPU regression. Individual intervals remain unadjusted. No further selected reruns were used for these decisions. + +**Step 4 was rejected after a 14-case screen; no full-corpus Step 4 timing campaign ran.** The candidate retained six distinct port maps on each router, clearing or replacing them before reuse. The screen was declared before measurement: all ten public diagrams, all three supplemental routing records, and profiled case `private-0141`. It used ten paired timing rounds, two warmups and three separate allocation rounds: 420 rows. + +Screen pipeline time changed −0.099% [−0.531%, +0.487%], CPU −0.076% [−0.459%, +0.496%], and allocated bytes +0.136% [−0.204%, +0.512%]. Public allocated bytes increased +0.174% [+0.040%, +0.560%]. The required runtime gain was not established, so the candidate was removed without additional selected reruns. This is a decision about this implementation and screen, not a general claim about scratch reuse. Its exact production patch and focused test remain under `step4-rejected/source/` in the ZIP, with source hashes, declared selection, decision and numeric results. They are evidence only, not accepted production changes. + +## Correctness and measurement boundaries + +The initial corpus contained ten public real-world diagrams and 237 private records. Each full correctness preflight produced 246 byte-identical successful SVG pairs and one matching existing error. All retained benchmark sample SVGs matched their oracle. Step 4 also passed full-corpus correctness before its smaller performance screen. Local correctness evidence includes renderer/CLI/E2E and focused race checks, 84 exact pointer-sequence sort differential cases, TALA tests, and routing lifecycle/accounting/cancellation tests. Correctness alone did not qualify an optimization for acceptance. + +A clean rebuild of final accepted source reproduced the frozen Step 3 runner SHA-256, `a7d907dbf13113349fb6d957603da5e4b153baf18d8d5f44365290d5ff6938dc`. Separate native CLI checks matched all ten public outputs. An actual public reproduction-controller smoke also passed ten exact preflight pairs and 100 sample rows (two timing pairs, zero warmups and three allocation pairs per case), including real identities, AB/BA ordering and phase completion. These 100 rows are validation only and are excluded from campaign totals and performance claims. Applicable hosted checks for the measured accepted source passed; skipped nonapplicable checks are recorded separately. These local and hosted correctness claims are distinct from numeric recalculation. + +Runs use Go 1.27.0 on an Apple M4 with 10 CPUs and 16 GiB RAM, running macOS 26.6.2/arm64, CGO disabled, one fresh process at a time, unchanged seeded TALA work and native inner parallelism. Baseline/candidate invocations are adjacent pairs with alternating AB/BA order; cases are shuffled each round. Wall time includes startup, input/output and teardown. CPU sums user and system time across threads; RSS is peak process memory, not allocated bytes. Pipeline timers include font/ruler setup through rendering. Allocation counters are measured separately, without forced GC. This is an active desktop without CPU affinity or OS isolation, and is not a warm-service benchmark. + +Intervals use 2,000 paired percentile bootstrap replicates, with whole round blocks shared across cases. The corpus itself is not resampled: intervals describe these observed workloads, not unseen diagrams. Individual intervals are unadjusted for multiple comparisons. The frozen estimator also retains equal-case geometric estimates in the ZIP. Do not add incremental percentages or multiply their intervals to infer a cumulative result. + +The bundle retains 33,640 original numeric rows across eleven campaigns, including the rejected screen, direct cumulative comparison and its separate confirmation. Step 1 contributes 8,680 rows; Step 2, 8,930; Step 3, 7,880; the rejected Step 4 screen, 420; and the direct full run plus confirmation, 7,730. Recalculating distributed rows verifies numeric values, pairing, completeness and every published statistic. It cannot independently establish omitted private input/config/SVG identities. Authentic original-file digests identify retained local evidence; no fabricated hashes stand in for redacted fields. + +The ZIP contains exact v1/v2 runners and the frozen analysis script. Step 1 used v1. Later comparisons use v2, which reflectively adopts the optional ruler field to support identical benchmark source against both old and new APIs; this adapter cost is inside measured rendering/pipeline/allocation work. Production CLI code uses direct assignment. The expanded ZIP, including duplicate runner main packages and the rejected Go test, must be extracted outside a Go module. Only this report and the ZIP belong under `ci/performance-results/` in the repository. diff --git a/ci/performance-results/native-go-20260915.zip b/ci/performance-results/native-go-20260915.zip new file mode 100644 index 0000000000..f1315181de Binary files /dev/null and b/ci/performance-results/native-go-20260915.zip differ diff --git a/d2cli/main.go b/d2cli/main.go index b44880e8ed..019f029be6 100644 --- a/d2cli/main.go +++ b/d2cli/main.go @@ -909,6 +909,7 @@ func _renderWithPNGEncoder(ctx context.Context, ms *xmain.State, opts d2svg.Rend scale = go2.Pointer(1.) } renderOpts := &d2svg.RenderOpts{ + Ruler: ruler, Pad: opts.Pad, Sketch: opts.Sketch, Center: opts.Center, diff --git a/d2cli/svg_ruler_test.go b/d2cli/svg_ruler_test.go new file mode 100644 index 0000000000..66e1e78587 --- /dev/null +++ b/d2cli/svg_ruler_test.go @@ -0,0 +1,60 @@ +package d2cli + +import ( + "bytes" + "context" + "testing" + "time" + + "github.com/d2lang/d2/d2graph" + "github.com/d2lang/d2/d2layouts/d2dagrelayout" + "github.com/d2lang/d2/d2lib" + "github.com/d2lang/d2/d2renderers/d2svg" + "github.com/d2lang/d2/lib/log" + "github.com/d2lang/d2/lib/textmeasure" + "github.com/d2lang/util-go/xmain" +) + +func TestSVGOutputReusesCompilerRuler(t *testing.T) { + ruler, err := textmeasure.NewRuler() + if err != nil { + t.Fatal(err) + } + opts := d2svg.RenderOpts{} + diagram, _, err := d2lib.Compile(log.WithTB(context.Background(), t), "a: |md\n **Native** `SVG`\n|\na -> b\n", &d2lib.CompileOptions{ + Ruler: ruler, + LayoutResolver: func(string) (d2graph.LayoutGraph, error) { return d2dagrelayout.DefaultLayout, nil }, + }, &opts) + if err != nil { + t.Fatal(err) + } + want, err := d2svg.Render(diagram, &opts) + if err != nil { + t.Fatal(err) + } + dot := ruler.Dot + stdout := &controlledWriteCloser{limit: -1} + state := &xmain.TestState{ + Run: func(ctx context.Context, ms *xmain.State) error { + _, _, err := _renderWithPNGEncoder(ctx, ms, opts, "input.d2", "-", false, false, ruler, diagram, SVG, "", false, nil) + return err + }, + Args: []string{"d2"}, PWD: t.TempDir(), Stdout: stdout, + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + state.Start(t, ctx) + defer state.Cleanup(t) + if err := state.Wait(ctx); err != nil { + t.Fatal(err) + } + if !bytes.Equal(stdout.Bytes(), append(want, '\n')) { + t.Fatal("native CLI SVG differs from standalone rendering") + } + if ruler.Dot == dot { + t.Fatal("native CLI SVG did not use the compiler ruler") + } + if opts.Ruler != nil { + t.Fatal("native CLI mutated caller options") + } +} diff --git a/d2layouts/d2talalayout/internal/layoutgraph/node.go b/d2layouts/d2talalayout/internal/layoutgraph/node.go index 366bd2be47..984aabf235 100644 --- a/d2layouts/d2talalayout/internal/layoutgraph/node.go +++ b/d2layouts/d2talalayout/internal/layoutgraph/node.go @@ -1,6 +1,7 @@ package layoutgraph import ( + "cmp" "fmt" "math" "slices" @@ -1060,8 +1061,8 @@ func sortNodesByID(nodes []*Node) { if len(nodes) < 2 { return } - sort.Slice(nodes, func(i, j int) bool { - return nodes[i].entityID() < nodes[j].entityID() + slices.SortFunc(nodes, func(a, b *Node) int { + return cmp.Compare(a.entityID(), b.entityID()) }) } diff --git a/d2layouts/d2talalayout/internal/layoutgraph/node_sort_test.go b/d2layouts/d2talalayout/internal/layoutgraph/node_sort_test.go new file mode 100644 index 0000000000..1ad6d6816d --- /dev/null +++ b/d2layouts/d2talalayout/internal/layoutgraph/node_sort_test.go @@ -0,0 +1,85 @@ +package layoutgraph + +import ( + "fmt" + "math" + "math/rand" + "slices" + "sort" + "testing" +) + +// Keep the previous comparator and reflection-based sort as an independent +// ordering oracle. Equal IDs need exact pointer-order parity, not just sorted IDs. +func legacySortNodesByID(nodes []*Node) { + if len(nodes) < 2 { + return + } + sort.Slice(nodes, func(i, j int) bool { + return nodes[i].entityID() < nodes[j].entityID() + }) +} + +func TestSortNodesByIDMatchesLegacyPointerOrder(t *testing.T) { + // Cover both sides of pdqsort's insertion-sort and pivot-selection thresholds, + // plus larger partitions with repeated IDs and pointers. + for _, size := range []int{0, 1, 2, 11, 12, 13, 48, 49, 50, 51, 127, 128, 257, 1024} { + for _, pattern := range []string{"sorted", "reverse", "shuffled", "all equal", "few IDs", "nil zero extrema repeated"} { + t.Run(fmt.Sprintf("%s/%d", pattern, size), func(t *testing.T) { + nodes := make([]*Node, size) + for i := range nodes { + nodes[i] = &Node{ID: int64(i) - int64(size/2)} + } + switch pattern { + case "reverse": + slices.Reverse(nodes) + case "shuffled": + rng := rand.New(rand.NewSource(int64(size))) + rng.Shuffle(len(nodes), func(i, j int) { nodes[i], nodes[j] = nodes[j], nodes[i] }) + case "all equal": + for _, node := range nodes { + node.ID = 7 + } + case "few IDs": + for i, node := range nodes { + node.ID = int64(i%5) - 2 + } + case "nil zero extrema repeated": + zero := &Node{ID: 0} + minimum, maximum := &Node{ID: math.MinInt64}, &Node{ID: math.MaxInt64} + values := []*Node{nil, zero, maximum, minimum, {ID: 0}, {ID: -1}, zero, {ID: 1}, minimum, nil, maximum} + for i := range nodes { + nodes[i] = values[i%len(values)] + } + } + checkNodeSortPointerOrder(t, nodes) + }) + } + } +} + +func checkNodeSortPointerOrder(t *testing.T, input []*Node) { + t.Helper() + // Sort a subslice with writable capacity beyond its length. Neither sorting + // implementation may move the surrounding sentinels or change its slice header. + left, right := &Node{ID: math.MaxInt64}, &Node{ID: math.MinInt64} + gotBacking := make([]*Node, len(input)+4) + gotBacking[0], gotBacking[1] = left, nil + copy(gotBacking[2:], input) + gotBacking[len(gotBacking)-2], gotBacking[len(gotBacking)-1] = right, left + wantBacking := slices.Clone(gotBacking) + got := gotBacking[2 : len(gotBacking)-2] + want := wantBacking[2 : len(wantBacking)-2] + beforeLen, beforeCap := len(got), cap(got) + legacySortNodesByID(want) + sortNodesByID(got) + if len(got) != beforeLen || cap(got) != beforeCap { + t.Fatal("node slice header changed") + } + for i := range gotBacking { + if gotBacking[i] != wantBacking[i] { + t.Fatalf("pointer at backing index %d: got %p (ID %d), want %p (ID %d)", + i, gotBacking[i], gotBacking[i].entityID(), wantBacking[i], wantBacking[i].entityID()) + } + } +} diff --git a/d2renderers/d2svg/d2svg.go b/d2renderers/d2svg/d2svg.go index 2d5fbfc156..99f8795ef8 100644 --- a/d2renderers/d2svg/d2svg.go +++ b/d2renderers/d2svg/d2svg.go @@ -23,7 +23,6 @@ import ( "github.com/alecthomas/chroma/v2/styles" "github.com/d2lang/d2/d2ast" - "github.com/d2lang/d2/d2graph" "github.com/d2lang/d2/d2renderers/d2fonts" "github.com/d2lang/d2/d2renderers/d2latex" "github.com/d2lang/d2/d2renderers/d2sketch" @@ -80,6 +79,13 @@ var dots string var lines string type RenderOpts struct { + // Ruler optionally reuses font measurements from compilation. Register fonts + // before creating it and keep the font configuration fixed through rendering. + // Keep Orig and LineHeightFactor at their defaults to match rendering with a + // nil ruler; custom settings can change text measurements such as legend rows. + // A supplied ruler must not be used concurrently. Nil creates private, lazy + // measurement state without modifying these options. + Ruler *textmeasure.Ruler `json:"-"` Pad *int64 Sketch *bool Center *bool @@ -173,84 +179,39 @@ func expandDimensions(left, top, width, height, amount int) (int, int, int, int, return int(left64), int(top64), int(width64), int(height64), true } -func dimensions(diagram *d2target.Diagram, pad int, tl, br d2target.Point) (left, top, width, height int) { +func dimensions(diagram *d2target.Diagram, pad int, tl, br d2target.Point, legend *legendMeasurements) (left, top, width, height int) { left = tl.X - pad top = tl.Y - pad width = br.X - tl.X + pad*2 height = br.Y - tl.Y + pad*2 - // Account for legend dimensions if present - if diagram.Legend != nil && (len(diagram.Legend.Shapes) > 0 || len(diagram.Legend.Connections) > 0) { - totalHeight := LEGEND_PADDING + LEGEND_FONT_SIZE + LEGEND_ITEM_SPACING - maxLabelWidth := 0 - itemCount := 0 - - ruler, err := textmeasure.NewRuler() - if err == nil && ruler != nil { - for _, s := range diagram.Legend.Shapes { - if s.Label == "" { - continue - } - mtext := &d2target.MText{ - Text: s.Label, - FontSize: LEGEND_FONT_SIZE, - } - dims := d2graph.GetTextDimensions(nil, ruler, mtext, fontToFamily(s.FontFamily)) - maxLabelWidth = go2.IntMax(maxLabelWidth, dims.Width) - totalHeight += go2.IntMax(dims.Height, LEGEND_ICON_SIZE) + LEGEND_ITEM_SPACING - itemCount++ + if legend != nil { + totalHeight := legend.height(len(diagram.Legend.Connections) > 0) + maxLabelWidth := legend.maxLabelWidth + if totalHeight > 0 && maxLabelWidth > 0 { + legendWidth := LEGEND_PADDING*2 + LEGEND_ICON_SIZE + LEGEND_PADDING + maxLabelWidth + legendY := br.Y - totalHeight + if legendY < tl.Y { + legendY = tl.Y } - for _, c := range diagram.Legend.Connections { - if c.Label == "" { - continue - } - mtext := &d2target.MText{ - Text: c.Label, - FontSize: LEGEND_FONT_SIZE, - } - dims := d2graph.GetTextDimensions(nil, ruler, mtext, fontToFamily(c.FontFamily)) - maxLabelWidth = go2.IntMax(maxLabelWidth, dims.Width) - totalHeight += go2.IntMax(dims.Height, LEGEND_ICON_SIZE) + LEGEND_ITEM_SPACING - itemCount++ + legendRight := br.X + LEGEND_CORNER_PADDING + legendWidth + if left+width < legendRight { + width = legendRight - left + pad/2 } - if itemCount > 0 { - totalHeight -= LEGEND_ITEM_SPACING / 2 + if legendY < top { + diffY := top - legendY + top -= diffY + height += diffY } - if itemCount > 0 && len(diagram.Legend.Connections) > 0 { - totalHeight += LEGEND_PADDING * 1.5 - } else { - totalHeight += LEGEND_PADDING * 1.2 - } - - if totalHeight > 0 && maxLabelWidth > 0 { - legendWidth := LEGEND_PADDING*2 + LEGEND_ICON_SIZE + LEGEND_PADDING + maxLabelWidth - legendY := br.Y - totalHeight - if legendY < tl.Y { - legendY = tl.Y - } - - legendRight := br.X + LEGEND_CORNER_PADDING + legendWidth - if left+width < legendRight { - width = legendRight - left + pad/2 - } - - if legendY < top { - diffY := top - legendY - top -= diffY - height += diffY - } - - legendBottom := legendY + totalHeight - if top+height < legendBottom { - height = legendBottom - top + pad/2 - } + legendBottom := legendY + totalHeight + if top+height < legendBottom { + height = legendBottom - top + pad/2 } } } - return left, top, width, height } @@ -258,69 +219,33 @@ func RenderLegend(buf *bytes.Buffer, diagram *d2target.Diagram, diagramHash stri if diagram.Legend == nil || (len(diagram.Legend.Shapes) == 0 && len(diagram.Legend.Connections) == 0) { return nil } - for _, targetShape := range diagram.Legend.Shapes { - if textmeasure.IsDangerousLink(targetShape.Link) { - return fmt.Errorf("legend shape %q uses an unsafe link URL scheme", targetShape.ID) - } + if err := validateLegendLinks(diagram.Legend); err != nil { + return err } - - _, br := diagram.BoundingBox() - - ruler, err := textmeasure.NewRuler() + tl, br := diagram.BoundingBox() + measurements := newRenderMeasurements(nil) + legend, err := measureLegend(diagram.Legend, measurements) if err != nil { return err } + return renderLegend(buf, diagram, diagramHash, theme, tl, br, legend, measurements) +} - totalHeight := LEGEND_PADDING + LEGEND_FONT_SIZE + LEGEND_ITEM_SPACING - maxLabelWidth := 0 - - itemCount := 0 - - for _, s := range diagram.Legend.Shapes { - if s.Label == "" { - continue - } - - mtext := &d2target.MText{ - Text: s.Label, - FontSize: LEGEND_FONT_SIZE, - } - - dims := d2graph.GetTextDimensions(nil, ruler, mtext, fontToFamily(s.FontFamily)) - maxLabelWidth = go2.IntMax(maxLabelWidth, dims.Width) - totalHeight += go2.IntMax(dims.Height, LEGEND_ICON_SIZE) + LEGEND_ITEM_SPACING - itemCount++ - } - - for _, c := range diagram.Legend.Connections { - if c.Label == "" { - continue - } - - mtext := &d2target.MText{ - Text: c.Label, - FontSize: LEGEND_FONT_SIZE, +func validateLegendLinks(legend *d2target.Legend) error { + for _, targetShape := range legend.Shapes { + if textmeasure.IsDangerousLink(targetShape.Link) { + return fmt.Errorf("legend shape %q uses an unsafe link URL scheme", targetShape.ID) } - - dims := d2graph.GetTextDimensions(nil, ruler, mtext, fontToFamily(c.FontFamily)) - maxLabelWidth = go2.IntMax(maxLabelWidth, dims.Width) - totalHeight += go2.IntMax(dims.Height, LEGEND_ICON_SIZE) + LEGEND_ITEM_SPACING - itemCount++ - } - - if itemCount > 0 { - totalHeight -= LEGEND_ITEM_SPACING / 2 } + return nil +} - if itemCount > 0 && len(diagram.Legend.Connections) > 0 { - totalHeight += LEGEND_PADDING * 1.5 - } else { - totalHeight += LEGEND_PADDING * 1.2 - } +func renderLegend(buf *bytes.Buffer, diagram *d2target.Diagram, diagramHash string, theme *d2themes.Theme, tl, br d2target.Point, legend *legendMeasurements, measurements *renderMeasurements) error { + totalHeight := legend.height(len(diagram.Legend.Connections) > 0) + maxLabelWidth := legend.maxLabelWidth legendWidth := LEGEND_PADDING*2 + LEGEND_ICON_SIZE + LEGEND_PADDING + maxLabelWidth legendX := br.X + LEGEND_CORNER_PADDING - tl, _ := diagram.BoundingBox() legendY := br.Y - totalHeight if legendY < tl.Y { legendY = tl.Y @@ -358,7 +283,7 @@ func RenderLegend(buf *bytes.Buffer, diagram *d2target.Diagram, diagramHash stri currentY := legendY + LEGEND_PADDING*2 + LEGEND_FONT_SIZE shapeCount := 0 - for _, s := range diagram.Legend.Shapes { + for i, s := range diagram.Legend.Shapes { if s.Label == "" { continue } @@ -366,18 +291,13 @@ func RenderLegend(buf *bytes.Buffer, diagram *d2target.Diagram, diagramHash stri iconX := legendX + LEGEND_PADDING iconY := currentY - shapeIcon, err := renderLegendShapeIcon(s, iconX, iconY, diagramHash, theme) + shapeIcon, err := renderLegendShapeIcon(s, iconX, iconY, diagramHash, theme, measurements) if err != nil { return err } fmt.Fprint(buf, shapeIcon) - mtext := &d2target.MText{ - Text: s.Label, - FontSize: LEGEND_FONT_SIZE, - } - - dims := d2graph.GetTextDimensions(nil, ruler, mtext, fontToFamily(s.FontFamily)) + dims := legend.shapes[i] rowHeight := go2.IntMax(dims.Height, LEGEND_ICON_SIZE) textY := currentY + rowHeight/2 + int(float64(dims.Height)*0.3) @@ -405,7 +325,7 @@ func RenderLegend(buf *bytes.Buffer, diagram *d2target.Diagram, diagramHash stri currentY += LEGEND_ITEM_SPACING } - for _, c := range diagram.Legend.Connections { + for i, c := range diagram.Legend.Connections { if c.Label == "" { continue } @@ -413,18 +333,13 @@ func RenderLegend(buf *bytes.Buffer, diagram *d2target.Diagram, diagramHash stri iconX := legendX + LEGEND_PADDING iconY := currentY + LEGEND_ICON_SIZE/2 - connIcon, err := renderLegendConnectionIcon(c, iconX, iconY, theme) + connIcon, err := renderLegendConnectionIcon(c, iconX, iconY, theme, measurements) if err != nil { return err } fmt.Fprint(buf, connIcon) - mtext := &d2target.MText{ - Text: c.Label, - FontSize: LEGEND_FONT_SIZE, - } - - dims := d2graph.GetTextDimensions(nil, ruler, mtext, fontToFamily(c.FontFamily)) + dims := legend.connections[i] rowHeight := go2.IntMax(dims.Height, LEGEND_ICON_SIZE) textY := currentY + rowHeight/2 + int(float64(dims.Height)*0.2) @@ -439,7 +354,7 @@ func RenderLegend(buf *bytes.Buffer, diagram *d2target.Diagram, diagramHash stri return nil } -func renderLegendShapeIcon(s d2target.Shape, x, y int, diagramHash string, theme *d2themes.Theme) (string, error) { +func renderLegendShapeIcon(s d2target.Shape, x, y int, diagramHash string, theme *d2themes.Theme, measurements *renderMeasurements) (string, error) { iconShape := s const sizeFactor = 5 iconShape.Pos.X = 0 @@ -452,7 +367,7 @@ func renderLegendShapeIcon(s d2target.Shape, x, y int, diagramHash string, theme finalBuf := &bytes.Buffer{} fmt.Fprintf(finalBuf, ``, x, y, svg.FormatFloat(1.0/sizeFactor)) - _, err := drawShape(buf, appendixBuf, diagramHash, iconShape, false, theme, newMarkdownRenderer(nil, nil, theme)) + _, err := drawShape(buf, appendixBuf, diagramHash, iconShape, false, theme, newMarkdownRenderer(measurements, nil, nil, theme)) if err != nil { return "", err } @@ -464,7 +379,7 @@ func renderLegendShapeIcon(s d2target.Shape, x, y int, diagramHash string, theme return finalBuf.String(), nil } -func renderLegendConnectionIcon(c d2target.Connection, x, y int, theme *d2themes.Theme) (string, error) { +func renderLegendConnectionIcon(c d2target.Connection, x, y int, theme *d2themes.Theme, measurements *renderMeasurements) (string, error) { finalBuf := &bytes.Buffer{} buf := &bytes.Buffer{} @@ -501,7 +416,7 @@ func renderLegendConnectionIcon(c d2target.Connection, x, y int, theme *d2themes fmt.Fprintf(finalBuf, ``, x, y, svg.FormatFloat(1.0/sizeFactor)) - _, err := drawConnection(buf, legendHash, legendConn, markers, idToShape, false, theme, newMarkdownRenderer(nil, nil, theme)) + _, err := drawConnection(buf, legendHash, legendConn, markers, idToShape, false, theme, newMarkdownRenderer(measurements, nil, nil, theme)) if err != nil { return "", err } @@ -2871,7 +2786,7 @@ func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) { if err := d2target.ValidateRenderTarget(diagram); err != nil { return nil, err } - return renderValidated(diagram, opts) + return renderValidated(diagram, opts, newRenderMeasurements(opts)) } func validateRenderLinks(diagram *d2target.Diagram) error { @@ -2891,7 +2806,7 @@ func validateRenderLinks(diagram *d2target.Diagram) error { // renderValidated renders one board after its complete target tree has been // validated. Keeping this internal lets RenderMultiboard avoid repeatedly // walking every descendant subtree. -func renderValidated(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) { +func renderValidated(diagram *d2target.Diagram, opts *RenderOpts, measurements *renderMeasurements) ([]byte, error) { if err := validateRenderPaints(diagram, opts); err != nil { return nil, err } @@ -2993,7 +2908,7 @@ func renderValidated(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error inlineTheme = go2.Pointer(d2themescatalog.Find(themeID)) inlineTheme.ApplyOverrides(opts.ThemeOverrides) } - markdown := newMarkdownRenderer(diagram.FontFamily, diagram.MonoFontFamily, inlineTheme) + markdown := newMarkdownRenderer(measurements, diagram.FontFamily, diagram.MonoFontFamily, inlineTheme) for _, obj := range allObjects { if c, is := obj.(d2target.Connection); is { labelMask, err := drawConnection(buf, isolatedDiagramHash, c, markers, idToShape, sketch, inlineTheme, markdown) @@ -3017,9 +2932,17 @@ func renderValidated(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error // add all appendix items afterwards so they are always on top fmt.Fprint(buf, appendixItemBuf) + var legend *legendMeasurements if diagram.Legend != nil && (len(diagram.Legend.Shapes) > 0 || len(diagram.Legend.Connections) > 0) { + if err := validateLegendLinks(diagram.Legend); err != nil { + return nil, err + } + legend, err = measureLegend(diagram.Legend, measurements) + if err != nil { + return nil, err + } legendBuf := &bytes.Buffer{} - err := RenderLegend(legendBuf, diagram, diagramHash, inlineTheme) + err := renderLegend(legendBuf, diagram, diagramHash, inlineTheme, tl, br, legend, measurements) if err != nil { return nil, err } @@ -3027,71 +2950,34 @@ func renderValidated(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error } // Note: we always want this since we reference it on connections even if there end up being no masked labels - left, top, w, h := dimensions(diagram, pad, tl, br) - - if diagram.Legend != nil && (len(diagram.Legend.Shapes) > 0 || len(diagram.Legend.Connections) > 0) { - totalHeight := LEGEND_PADDING + LEGEND_FONT_SIZE + LEGEND_ITEM_SPACING - maxLabelWidth := 0 - itemCount := 0 - ruler, _ := textmeasure.NewRuler() - if ruler != nil { - for _, s := range diagram.Legend.Shapes { - if s.Label == "" { - continue - } - mtext := &d2target.MText{ - Text: s.Label, - FontSize: LEGEND_FONT_SIZE, - } - dims := d2graph.GetTextDimensions(nil, ruler, mtext, fontToFamily(s.FontFamily)) - maxLabelWidth = go2.IntMax(maxLabelWidth, dims.Width) - totalHeight += go2.IntMax(dims.Height, LEGEND_ICON_SIZE) + LEGEND_ITEM_SPACING - itemCount++ + left, top, w, h := dimensions(diagram, pad, tl, br, legend) + + if legend != nil { + // Preserve the separate viewport padding used by the existing renderer. + totalHeight := legend.totalHeight + LEGEND_PADDING + maxLabelWidth := legend.maxLabelWidth + if totalHeight > 0 && maxLabelWidth > 0 { + legendWidth := LEGEND_PADDING*2 + LEGEND_ICON_SIZE + LEGEND_PADDING + maxLabelWidth + + legendY := br.Y - totalHeight + if legendY < tl.Y { + legendY = tl.Y } - for _, c := range diagram.Legend.Connections { - if c.Label == "" { - continue - } - mtext := &d2target.MText{ - Text: c.Label, - FontSize: LEGEND_FONT_SIZE, - } - dims := d2graph.GetTextDimensions(nil, ruler, mtext, fontToFamily(c.FontFamily)) - maxLabelWidth = go2.IntMax(maxLabelWidth, dims.Width) - totalHeight += go2.IntMax(dims.Height, LEGEND_ICON_SIZE) + LEGEND_ITEM_SPACING - itemCount++ + legendRight := br.X + LEGEND_CORNER_PADDING + legendWidth + if left+w < legendRight { + w = legendRight - left + pad/2 } - if itemCount > 0 { - totalHeight -= LEGEND_ITEM_SPACING / 2 + if legendY < top { + diffY := top - legendY + top -= diffY + h += diffY } - totalHeight += LEGEND_PADDING - - if totalHeight > 0 && maxLabelWidth > 0 { - legendWidth := LEGEND_PADDING*2 + LEGEND_ICON_SIZE + LEGEND_PADDING + maxLabelWidth - - legendY := br.Y - totalHeight - if legendY < tl.Y { - legendY = tl.Y - } - - legendRight := br.X + LEGEND_CORNER_PADDING + legendWidth - if left+w < legendRight { - w = legendRight - left + pad/2 - } - - if legendY < top { - diffY := top - legendY - top -= diffY - h += diffY - } - - legendBottom := legendY + totalHeight - if top+h < legendBottom { - h = legendBottom - top + pad/2 - } + legendBottom := legendY + totalHeight + if top+h < legendBottom { + h = legendBottom - top + pad/2 } } } @@ -3571,27 +3457,27 @@ func RenderMultiboard(diagram *d2target.Diagram, opts *RenderOpts) ([][]byte, er if err := d2target.ValidateRenderTarget(diagram); err != nil { return nil, err } - return renderMultiboard(diagram, opts) + return renderMultiboard(diagram, opts, newRenderMeasurements(opts)) } -func renderMultiboard(diagram *d2target.Diagram, opts *RenderOpts) ([][]byte, error) { +func renderMultiboard(diagram *d2target.Diagram, opts *RenderOpts, measurements *renderMeasurements) ([][]byte, error) { var boards [][]byte for _, dl := range diagram.Layers { - childrenBoards, err := renderMultiboard(dl, opts) + childrenBoards, err := renderMultiboard(dl, opts, measurements) if err != nil { return nil, err } boards = append(boards, childrenBoards...) } for _, dl := range diagram.Scenarios { - childrenBoards, err := renderMultiboard(dl, opts) + childrenBoards, err := renderMultiboard(dl, opts, measurements) if err != nil { return nil, err } boards = append(boards, childrenBoards...) } for _, dl := range diagram.Steps { - childrenBoards, err := renderMultiboard(dl, opts) + childrenBoards, err := renderMultiboard(dl, opts, measurements) if err != nil { return nil, err } @@ -3602,7 +3488,7 @@ func renderMultiboard(diagram *d2target.Diagram, opts *RenderOpts) ([][]byte, er if err := validateRenderLinks(diagram); err != nil { return boards, err } - out, err := renderValidated(diagram, opts) + out, err := renderValidated(diagram, opts, measurements) if err != nil { return boards, err } diff --git a/d2renderers/d2svg/font_corpus_test.go b/d2renderers/d2svg/font_corpus_test.go index 373fddbefd..f1769b4298 100644 --- a/d2renderers/d2svg/font_corpus_test.go +++ b/d2renderers/d2svg/font_corpus_test.go @@ -181,7 +181,7 @@ func TestFontCorporaGeneratedCode(t *testing.T) { func TestFontCorporaNativeMarkdownPrimitives(t *testing.T) { for _, baseFont := range []string{"", "mono"} { - renderer := newMarkdownRenderer(nil, nil, nil) + renderer := newMarkdownRenderer(newRenderMeasurements(nil), nil, nil, nil) layout, err := renderer.layout("# Heading Ω\n\n- plain & **bold** *italic* ***both***\n- `code` **`strong code`**\n\n| A | B |\n| - | - |\n| é | Ж |", baseFont, 16) if err != nil { t.Fatal(err) diff --git a/d2renderers/d2svg/markdown.go b/d2renderers/d2svg/markdown.go index 7a77ae5e56..995308d9b0 100644 --- a/d2renderers/d2svg/markdown.go +++ b/d2renderers/d2svg/markdown.go @@ -11,20 +11,19 @@ import ( "github.com/d2lang/d2/lib/textmeasure" ) -// markdownRenderer owns the shared measurement state used while rendering a +// markdownRenderer uses the shared measurement state while rendering one // diagram. LayoutMarkdown returns both the dimensions used by graph layout and // the primitives painted here, so native SVG output cannot drift onto a // separate browser layout path. type markdownRenderer struct { - ruler *textmeasure.Ruler - rulerErr error + measurements *renderMeasurements fontFamily *d2fonts.FontFamily monoFontFamily *d2fonts.FontFamily inlineTheme *d2themes.Theme corpus strings.Builder } -func newMarkdownRenderer(fontFamily, monoFontFamily *d2fonts.FontFamily, inlineTheme *d2themes.Theme) *markdownRenderer { +func newMarkdownRenderer(measurements *renderMeasurements, fontFamily, monoFontFamily *d2fonts.FontFamily, inlineTheme *d2themes.Theme) *markdownRenderer { if fontFamily == nil || *fontFamily == "" { family := d2fonts.SourceSansPro fontFamily = &family @@ -34,6 +33,7 @@ func newMarkdownRenderer(fontFamily, monoFontFamily *d2fonts.FontFamily, inlineT monoFontFamily = &family } return &markdownRenderer{ + measurements: measurements, fontFamily: fontFamily, monoFontFamily: monoFontFamily, inlineTheme: inlineTheme, @@ -54,13 +54,11 @@ func (r *markdownRenderer) layout(markdown, fontName string, fontSize int) (*tex } func (r *markdownRenderer) layoutWithFont(markdown string, fontFamily, monoFontFamily *d2fonts.FontFamily, fontSize int) (*textmeasure.MarkdownLayout, error) { - if r.ruler == nil && r.rulerErr == nil { - r.ruler, r.rulerErr = textmeasure.NewRuler() - } - if r.rulerErr != nil { - return nil, r.rulerErr + ruler, err := r.measurements.getRuler() + if err != nil { + return nil, err } - layout, err := textmeasure.LayoutMarkdown(markdown, r.ruler, fontFamily, monoFontFamily, fontSize) + layout, err := textmeasure.LayoutMarkdown(markdown, ruler, fontFamily, monoFontFamily, fontSize) if err != nil { return nil, err } diff --git a/d2renderers/d2svg/measurement.go b/d2renderers/d2svg/measurement.go new file mode 100644 index 0000000000..42851ecffc --- /dev/null +++ b/d2renderers/d2svg/measurement.go @@ -0,0 +1,82 @@ +package d2svg + +import ( + "github.com/d2lang/d2/d2graph" + "github.com/d2lang/d2/d2target" + "github.com/d2lang/d2/lib/textmeasure" +) + +// renderMeasurements belongs to one render operation. Only the ruler is shared +// between boards; generated Markdown corpora and legend dimensions remain local +// to each board. Initialization stays lazy for diagrams that need no ruler. +type renderMeasurements struct { + ruler *textmeasure.Ruler + err error +} + +func newRenderMeasurements(opts *RenderOpts) *renderMeasurements { + m := &renderMeasurements{} + if opts != nil { + m.ruler = opts.Ruler + } + return m +} + +func (m *renderMeasurements) getRuler() (*textmeasure.Ruler, error) { + if m.ruler == nil && m.err == nil { + m.ruler, m.err = textmeasure.NewRuler() + } + return m.ruler, m.err +} + +type legendMeasurements struct { + shapes []d2target.TextDimensions + connections []d2target.TextDimensions + totalHeight int + maxLabelWidth int + itemCount int +} + +func measureLegend(legend *d2target.Legend, measurements *renderMeasurements) (*legendMeasurements, error) { + ruler, err := measurements.getRuler() + if err != nil { + return nil, err + } + m := &legendMeasurements{ + shapes: make([]d2target.TextDimensions, len(legend.Shapes)), + connections: make([]d2target.TextDimensions, len(legend.Connections)), + totalHeight: LEGEND_PADDING + LEGEND_FONT_SIZE + LEGEND_ITEM_SPACING, + } + measure := func(text, family string) d2target.TextDimensions { + if text == "" { + return d2target.TextDimensions{} + } + dims := d2graph.GetTextDimensions(nil, ruler, &d2target.MText{ + Text: text, FontSize: LEGEND_FONT_SIZE, + }, fontToFamily(family)) + m.maxLabelWidth = max(m.maxLabelWidth, dims.Width) + m.totalHeight += max(dims.Height, LEGEND_ICON_SIZE) + LEGEND_ITEM_SPACING + m.itemCount++ + return *dims + } + for i, s := range legend.Shapes { + m.shapes[i] = measure(s.Label, s.FontFamily) + } + for i, c := range legend.Connections { + m.connections[i] = measure(c.Label, c.FontFamily) + } + if m.itemCount > 0 { + m.totalHeight -= LEGEND_ITEM_SPACING / 2 + } + return m, nil +} + +func (m *legendMeasurements) height(hasConnections bool) int { + height := m.totalHeight + if m.itemCount > 0 && hasConnections { + height += LEGEND_PADDING * 1.5 + } else { + height += LEGEND_PADDING * 1.2 + } + return height +} diff --git a/d2renderers/d2svg/measurement_parity_test.go b/d2renderers/d2svg/measurement_parity_test.go new file mode 100644 index 0000000000..ee91952b35 --- /dev/null +++ b/d2renderers/d2svg/measurement_parity_test.go @@ -0,0 +1,93 @@ +package d2svg_test + +import ( + "bytes" + "crypto/sha256" + "fmt" + "testing" + + "github.com/d2lang/d2/d2renderers/d2fonts" + "github.com/d2lang/d2/d2renderers/d2svg" + "github.com/d2lang/d2/d2target" +) + +func measurementDiagram(kind string) *d2target.Diagram { + d := validRenderTarget() + d.Shapes[0].Language = "markdown" + d.Shapes[0].Label = "**bold** and `code`\n\n- 日本語" + d.Shapes[0].LabelWidth = 180 + d.Shapes[0].LabelHeight = 80 + d.Shapes[0].Width = 200 + d.Shapes[0].Height = 100 + shape := *d2target.BaseShape() + shape.ID, shape.Type, shape.Label = "legend-shape", d2target.ShapeRectangle, "First\n日本語" + shape.Fill, shape.Stroke, shape.FontFamily = "#ffffff", "#000000", "mono" + blankShape := shape + blankShape.ID, blankShape.Label = "blank-shape", "" + connection := *d2target.BaseConnection() + connection.ID, connection.Label, connection.Stroke = "legend-edge", "Second\nline", "#000000" + connection.FontFamily = "default" + blankConnection := connection + blankConnection.ID, blankConnection.Label = "blank-edge", "" + if kind != "markdown" { + d.Legend = &d2target.Legend{Label: "Legend <&>"} + } + switch kind { + case "shapes": + d.Legend.Shapes = []d2target.Shape{blankShape, shape} + case "connections": + d.Legend.Connections = []d2target.Connection{connection, blankConnection} + case "mixed", "tooltip": + d.Legend.Shapes = []d2target.Shape{shape, blankShape} + d.Legend.Connections = []d2target.Connection{blankConnection, connection} + case "blank connections": + d.Legend.Shapes = []d2target.Shape{shape} + d.Legend.Connections = []d2target.Connection{blankConnection} + case "all blank": + d.Legend.Shapes = []d2target.Shape{blankShape} + d.Legend.Connections = []d2target.Connection{blankConnection} + } + if kind == "tooltip" { + family := d2fonts.HandDrawn + d.FontFamily = &family + d.Shapes[0].Tooltip = "**positioned** `tooltip`" + d.Shapes[0].TooltipPosition = "top-left" + } + return d +} + +// These digests capture complete SVG bytes from the pre-reuse renderer at +// d6a52f13359dc957e346c792507728c6fd3b0c76, including its distinct legend padding +// formulas, blank items, Unicode measurements, and positioned tooltip bounds. +func TestRenderMeasurementParity(t *testing.T) { + for _, tc := range []struct{ name, svg, legend string }{ + {name: "markdown", svg: "984e36f5cc782def1d5cdb6e0a3d906ca22fdba3e407cd7b5b009323545c2396", legend: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}, + {name: "shapes", svg: "24772abdb043c754a7ebb00bbb335b402e64b12411d3571f03ffb50fa71bde55", legend: "b522b0ff093e8335413c423df531bea16d7a96bec1c2a4fdc60a63b59822a3c0"}, + {name: "connections", svg: "8a80dece2aa0f9b31597157b31b3d4f07a31c608347986248ba5a5444000ec3e", legend: "3254b992b8bd20a4c4f1f0a0f2fdff44d5dc4b8e722a410678a0dc07597fa980"}, + {name: "mixed", svg: "90aa757421ba5f19c86e654e34a884550b43c5c1811bf82c1043c9295ce00c3b", legend: "bd9eb5a642f979b9c10037bae5016bf3aca0bcae49c923880a357c0d4d23bc3a"}, + {name: "blank connections", svg: "8fdab20844b7a7d25796c59e3c437fb962c77f8fc6d827f008e6d72756ed4976", legend: "d941acd11b5a0a74a562f9cfeb01c522d0b87284e25cd963baa1386d890067fb"}, + {name: "all blank", svg: "1446439ae6fdb38eefc71ea72e469b368c74e6a5db90c7201575d8d6223f150a", legend: "27df5a328d39685c637aabdd080e6b7d0b269398afc8106e15156b9724fa6a15"}, + {name: "tooltip", svg: "e1e7642736d84a2d1fae7a33253c2229287353b70e261bfb8156956adcb4f800", legend: "d6942a2bfd4304c6a1283e1b94f2fd4d514a973eb64dceea445fdba15f98434b"}, + } { + t.Run(tc.name, func(t *testing.T) { + d := measurementDiagram(tc.name) + omitVersion := true + out, err := d2svg.Render(d, &d2svg.RenderOpts{OmitVersion: &omitVersion}) + if err != nil { + t.Fatal(err) + } + got := fmt.Sprintf("%x", sha256.Sum256(out)) + if got != tc.svg { + t.Fatalf("SVG changed: got digest %s, want %s", got, tc.svg) + } + var legend bytes.Buffer + if err := d2svg.RenderLegend(&legend, d, "legend-parity", nil); err != nil { + t.Fatal(err) + } + got = fmt.Sprintf("%x", sha256.Sum256(legend.Bytes())) + if got != tc.legend { + t.Fatalf("legend changed: got digest %s, want %s", got, tc.legend) + } + }) + } +} diff --git a/d2renderers/d2svg/measurement_test.go b/d2renderers/d2svg/measurement_test.go new file mode 100644 index 0000000000..bc3cf448f9 --- /dev/null +++ b/d2renderers/d2svg/measurement_test.go @@ -0,0 +1,164 @@ +package d2svg_test + +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" + "strings" + "testing" + + "github.com/d2lang/d2/d2renderers/d2fonts" + "github.com/d2lang/d2/d2renderers/d2svg" + "github.com/d2lang/d2/d2target" + "github.com/d2lang/d2/lib/textmeasure" +) + +func TestRenderReusesSuppliedRuler(t *testing.T) { + regular := d2fonts.FontFaces.Get(d2fonts.HandDrawn.Font(0, d2fonts.FONT_STYLE_REGULAR)) + italic := d2fonts.FontFaces.Get(d2fonts.HandDrawn.Font(0, d2fonts.FONT_STYLE_ITALIC)) + bold := d2fonts.FontFaces.Get(d2fonts.HandDrawn.Font(0, d2fonts.FONT_STYLE_BOLD)) + semibold := d2fonts.FontFaces.Get(d2fonts.HandDrawn.Font(0, d2fonts.FONT_STYLE_SEMIBOLD)) + family, err := d2fonts.AddFontFamily("svg-ruler-reuse", regular, italic, bold, semibold) + if err != nil { + t.Fatal(err) + } + ruler, err := textmeasure.NewRuler() + if err != nil { + t.Fatal(err) + } + for _, kind := range []string{"markdown", "shapes", "connections", "mixed", "all blank", "tooltip"} { + d := measurementDiagram(kind) + d.FontFamily = family + want, err := d2svg.Render(d, nil) + if err != nil { + t.Fatal(err) + } + for repeat := 0; repeat < 2; repeat++ { + // Warm the caller-owned font cache and observe the public drawing state: + // the renderer must actually use this ruler, not merely accept it. + ruler.Measure(family.Font(16, d2fonts.FONT_STYLE_REGULAR), "before rendering") + dot := ruler.Dot + opts := &d2svg.RenderOpts{Ruler: ruler} + got, err := d2svg.Render(d, opts) + if err != nil || !bytes.Equal(got, want) { + t.Fatalf("%s repeat %d: supplied ruler changed SVG: %v", kind, repeat, err) + } + if ruler.Dot == dot || opts.Ruler != ruler || ruler.LineHeightFactor != 1 { + t.Fatalf("%s: caller ruler was not reused or its configuration changed", kind) + } + } + } +} + +func TestRenderRulerIsExcludedFromJSON(t *testing.T) { + ruler, err := textmeasure.NewRuler() + if err != nil { + t.Fatal(err) + } + pad := int64(12) + opts := d2svg.RenderOpts{Pad: &pad} + want, err := json.Marshal(opts) + if err != nil { + t.Fatal(err) + } + opts.Ruler = ruler + got, err := json.Marshal(opts) + if err != nil || !bytes.Equal(got, want) { + t.Fatalf("ruler changed serialized options: %s (%v)", got, err) + } + var decoded d2svg.RenderOpts + if err := json.Unmarshal([]byte(`{"Ruler":{},"Pad":12}`), &decoded); err != nil { + t.Fatal(err) + } + if decoded.Ruler != nil || decoded.Pad == nil || *decoded.Pad != pad { + t.Fatalf("JSON supplied mutable measurement state: %+v", decoded) + } +} + +func TestRenderMeasurementIsolation(t *testing.T) { + d := measurementDiagram("mixed") + shared := &d2svg.RenderOpts{} + for _, mode := range []string{"nil options", "shared options", "separate rulers"} { + t.Run(mode, func(t *testing.T) { + want, err := d2svg.Render(d, nil) + if err != nil { + t.Fatal(err) + } + const workers = 4 + results := make(chan error, workers) + for i := 0; i < workers; i++ { + var opts *d2svg.RenderOpts + if mode == "shared options" { + opts = shared + } else if mode == "separate rulers" { + ruler, err := textmeasure.NewRuler() + if err != nil { + t.Fatal(err) + } + opts = &d2svg.RenderOpts{Ruler: ruler} + } + go func() { + for repeat := 0; repeat < 2; repeat++ { + got, err := d2svg.Render(d, opts) + if err != nil || !bytes.Equal(got, want) { + results <- fmt.Errorf("render changed: %v", err) + return + } + } + results <- nil + }() + } + for i := 0; i < workers; i++ { + if err := <-results; err != nil { + t.Fatal(err) + } + } + if shared.Ruler != nil { + t.Fatal("render installed mutable state in shared options") + } + }) + } +} + +func TestRenderMultiboardMeasurementIsolation(t *testing.T) { + root := measurementDiagram("mixed") + first, second := measurementDiagram("markdown"), measurementDiagram("tooltip") + first.Shapes[0].Label = "**first Ω**" + second.Shapes[0].Label = "second Ж `code`" + mono := d2fonts.SourceCodePro + first.FontFamily = &mono + root.Layers = []*d2target.Diagram{first} + root.Steps = []*d2target.Diagram{second} + for _, masterID := range []string{"", "animation-root"} { + base := &d2svg.RenderOpts{MasterID: masterID} + var want [][]byte + for _, d := range []*d2target.Diagram{root, first, second} { + out, err := d2svg.Render(d, base) + if err != nil { + t.Fatal(err) + } + want = append(want, out) + } + for _, supplied := range []bool{false, true} { + opts := *base + if supplied { + var err error + opts.Ruler, err = textmeasure.NewRuler() + if err != nil { + t.Fatal(err) + } + } + got, err := d2svg.RenderMultiboard(root, &opts) + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("master=%q supplied=%v: board order or SVG changed: %v", masterID, supplied, err) + } + root.Shapes[0].Link = "javascript:alert(1)" + got, err = d2svg.RenderMultiboard(root, &opts) + root.Shapes[0].Link = "" + if err == nil || !strings.Contains(err.Error(), "unsafe link URL scheme") || !reflect.DeepEqual(got, want[1:]) { + t.Fatalf("master=%q supplied=%v: partial child outputs/error changed: %v", masterID, supplied, err) + } + } + } +} diff --git a/lib/textmeasure/markdown.go b/lib/textmeasure/markdown.go index 9568d34d4d..a770516afd 100644 --- a/lib/textmeasure/markdown.go +++ b/lib/textmeasure/markdown.go @@ -135,7 +135,7 @@ func init() { } func MeasureMarkdown(mdText string, ruler *Ruler, fontFamily *d2fonts.FontFamily, monoFontFamily *d2fonts.FontFamily, fontSize int) (width, height int, err error) { - layout, err := LayoutMarkdown(mdText, ruler, fontFamily, monoFontFamily, fontSize) + layout, err := layoutMarkdown(mdText, ruler, fontFamily, monoFontFamily, fontSize, false) if err != nil { return 0, 0, err } diff --git a/lib/textmeasure/markdown_measurement_test.go b/lib/textmeasure/markdown_measurement_test.go new file mode 100644 index 0000000000..c29166ef89 --- /dev/null +++ b/lib/textmeasure/markdown_measurement_test.go @@ -0,0 +1,139 @@ +package textmeasure + +import ( + "testing" + + "github.com/d2lang/d2/d2renderers/d2fonts" + "github.com/d2lang/d2/lib/geo" +) + +func TestMeasureMarkdownMatchesPaintedDimensions(t *testing.T) { + // LayoutMarkdown remains the painting path. In particular, its graph-sized + // viewport uses the original HTML tree, before CSS whitespace normalization. + cases := []struct { + name string + markdown string + wantErr string + }{ + {name: "empty", markdown: " \n\t\n"}, + {name: "styled whitespace", markdown: "left **bold**\n*italic* right  end"}, + {name: "break before unicode newline", markdown: "first
\n日本語 👩🏼‍❤️‍👨🏼\n\nlast"}, + {name: "heading and inherited code styles", markdown: "# *heading* `日本語`\n\n**`bold code`** and *`italic code`*"}, + {name: "fenced tabs and blank lines", markdown: "```\na\tb\n\n日本語\n```\n\nafter"}, + {name: "nested and empty list items", markdown: "3. outer\n - nested\n -\n - last\n\n> quote\n\n---"}, + {name: "table with empty cells", markdown: "| Left | Center |\n|---|:---:|\n| **bold** | `code` |\n| | |\n\nafter"}, + {name: "links and omitted image", markdown: "[a & b](https://example.com/?a=1&b=2) ![image](https://example.com/image.png)"}, + {name: "repaired raw html", markdown: "

first
second"}, + {name: "unsupported block", markdown: "", wantErr: "native Markdown SVG does not support HTML element

first second

", wantErr: "native Markdown SVG does not support HTML element "}, + } + proportional := d2fonts.HandDrawn + mono := d2fonts.SourceCodePro + fonts := []struct { + name string + family *d2fonts.FontFamily + monoFamily *d2fonts.FontFamily + size int + }{ + {name: "defaults", size: MarkdownFontSize}, + {name: "nondefault proportional", family: &proportional, monoFamily: &mono, size: 24}, + {name: "monospace base", family: &mono, monoFamily: &mono, size: 16}, + } + for _, font := range fonts { + t.Run(font.name, func(t *testing.T) { + measureRuler, err := NewRuler() + if err != nil { + t.Fatal(err) + } + paintRuler, err := NewRuler() + if err != nil { + t.Fatal(err) + } + // Reuse both rulers across cases to cover populated font caches too. + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + width, height, measureErr := MeasureMarkdown(tc.markdown, measureRuler, font.family, font.monoFamily, font.size) + layout, layoutErr := LayoutMarkdown(tc.markdown, paintRuler, font.family, font.monoFamily, font.size) + if tc.wantErr != "" { + if measureErr == nil || measureErr.Error() != tc.wantErr { + t.Fatalf("MeasureMarkdown error = %v, want %q", measureErr, tc.wantErr) + } + if layoutErr == nil || layoutErr.Error() != tc.wantErr { + t.Fatalf("LayoutMarkdown error = %v, want %q", layoutErr, tc.wantErr) + } + if width != 0 || height != 0 { + t.Fatalf("failed measurement returned dimensions (%d, %d)", width, height) + } + return + } + if measureErr != nil || layoutErr != nil { + t.Fatalf("unexpected errors: measurement=%v, layout=%v", measureErr, layoutErr) + } + if width != layout.Width || height != layout.Height { + t.Fatalf("measurement=(%d, %d), painted viewport=(%d, %d)", width, height, layout.Width, layout.Height) + } + }) + } + }) + } +} + +func TestMeasureMarkdownPreservesRulerSettings(t *testing.T) { + for _, boundsWithDot := range []bool{false, true} { + for _, markdown := range []string{"# heading\n\n```\nfirst\n\nlast\n```", "", ""} { + ruler, err := NewRuler() + if err != nil { + t.Fatal(err) + } + control, err := NewRuler() + if err != nil { + t.Fatal(err) + } + for _, r := range []*Ruler{ruler, control} { + r.Orig = geo.NewPoint(7.25, -3.5) + r.LineHeightFactor = 2.25 + r.boundsWithDot = boundsWithDot + } + origin := ruler.Orig + _, _, err = MeasureMarkdown(markdown, ruler, nil, nil, 16) + if (err != nil) != (markdown == "") { + t.Fatalf("markdown %q: unexpected error %v", markdown, err) + } + if ruler.LineHeightFactor != 2.25 || ruler.boundsWithDot != boundsWithDot || ruler.Orig != origin || *ruler.Orig != *control.Orig { + t.Fatalf("markdown %q changed ruler settings (boundsWithDot=%v)", markdown, boundsWithDot) + } + // A later ordinary measurement must behave as on an untouched ruler; + // transient drawing state and lazy font caches need not be identical. + spec := d2fonts.SourceSansPro.Font(16, d2fonts.FONT_STYLE_REGULAR) + width, height := ruler.MeasurePrecise(spec, "after\nmarkdown ") + wantWidth, wantHeight := control.MeasurePrecise(spec, "after\nmarkdown ") + if width != wantWidth || height != wantHeight || *ruler.Dot != *control.Dot { + t.Fatalf("markdown %q changed subsequent measurement: got (%v, %v, %v), want (%v, %v, %v)", markdown, width, height, ruler.Dot, wantWidth, wantHeight, control.Dot) + } + } + } +} + +func BenchmarkMeasureMarkdown(b *testing.B) { + const markdown = "# Service dependencies\n\n" + + "The **API** accepts requests and sends `job.created` events to the worker.\n\n" + + "| Component | Responsibility | Status |\n" + + "|---|---|---|\n" + + "| API | Validate requests | **Ready** |\n" + + "| Queue | Buffer `job.created` events | Durable |\n" + + "| Worker | Process jobs and retry failures | *Running* |\n\n" + + "- Retry transient failures\n- Record processing time\n\n" + + "```\nPOST /jobs\n -> queue\n -> worker\n```\n\n" + + "See the [service guide](https://example.com/guide) for details." + ruler, err := NewRuler() + if err != nil { + b.Fatal(err) + } + b.ReportAllocs() + for b.Loop() { + _, _, err := MeasureMarkdown(markdown, ruler, nil, nil, MarkdownFontSize) + if err != nil { + b.Fatal(err) + } + } +} diff --git a/lib/textmeasure/markdown_svg.go b/lib/textmeasure/markdown_svg.go index 96c696ad82..c51c2ae65a 100644 --- a/lib/textmeasure/markdown_svg.go +++ b/lib/textmeasure/markdown_svg.go @@ -101,8 +101,8 @@ type MarkdownPrimitive struct { } // MarkdownLayout contains the exact dimensions used by D2 layout and the -// positioned primitives used to paint those dimensions. MeasureMarkdown calls -// LayoutMarkdown, ensuring measurement and rendering share one code path. +// positioned primitives used to paint those dimensions. MeasureMarkdown uses +// the shared layout path with painting disabled. type MarkdownLayout struct { Width, Height int Primitives []MarkdownPrimitive @@ -136,6 +136,10 @@ type MarkdownSVGOptions struct { // LayoutMarkdown parses Markdown once, measures it with D2's existing box // model, and paints that same box model into native SVG primitives. func LayoutMarkdown(mdText string, ruler *Ruler, fontFamily *d2fonts.FontFamily, monoFontFamily *d2fonts.FontFamily, fontSize int) (*MarkdownLayout, error) { + return layoutMarkdown(mdText, ruler, fontFamily, monoFontFamily, fontSize, true) +} + +func layoutMarkdown(mdText string, ruler *Ruler, fontFamily *d2fonts.FontFamily, monoFontFamily *d2fonts.FontFamily, fontSize int, paint bool) (*MarkdownLayout, error) { render, err := RenderMarkdown(mdText) if err != nil { return nil, err @@ -152,18 +156,23 @@ func LayoutMarkdown(mdText string, ruler *Ruler, fontFamily *d2fonts.FontFamily, // measurement. Native painting normalizes whitespace to CSS semantics, but // measuring that normalized tree would subtly resize old diagrams (notably // a newline immediately after
in mixed-Unicode content). - legacyDoc, err := goquery.NewDocumentFromReader(strings.NewReader(render)) - if err != nil { - return nil, err - } - legacyBody := legacyDoc.Find("body").First() - if len(legacyBody.Nodes) == 0 { - return &MarkdownLayout{}, nil + legacyBody := body + if paint { + legacyDoc, err := goquery.NewDocumentFromReader(strings.NewReader(render)) + if err != nil { + return nil, err + } + legacyBody = legacyDoc.Find("body").First() + if len(legacyBody.Nodes) == 0 { + return &MarkdownLayout{}, nil + } } if err := validateMarkdownSVGNodes(body.Nodes[0]); err != nil { return nil, err } - normalizeMarkdownWhitespaceTree(body.Nodes[0]) + if paint { + normalizeMarkdownWhitespaceTree(body.Nodes[0]) + } originalLineHeight := ruler.LineHeightFactor originalBoundsWithDot := ruler.boundsWithDot @@ -202,6 +211,9 @@ func LayoutMarkdown(mdText string, ruler *Ruler, fontFamily *d2fonts.FontFamily, contentHeight := int(math.Ceil(rootAttrs.height)) p.layout.Width = contentWidth p.layout.Height = contentHeight + if !paint { + return p.layout, nil + } p.paintBlock(body.Nodes[0], 0, 0, ctx, float64(contentWidth)) var corpus strings.Builder for _, primitive := range p.layout.Primitives {