Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions d2js/d2wasm/functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import (
"oss.terrastruct.com/d2/d2oracle"
"oss.terrastruct.com/d2/d2parser"
"oss.terrastruct.com/d2/d2renderers/d2animate"
"oss.terrastruct.com/d2/d2renderers/d2ascii"
"oss.terrastruct.com/d2/d2renderers/d2ascii/charset"
"oss.terrastruct.com/d2/d2renderers/d2fonts"
"oss.terrastruct.com/d2/d2renderers/d2svg"
"oss.terrastruct.com/d2/d2renderers/d2svg/appendix"
Expand All @@ -29,6 +31,7 @@ import (
"oss.terrastruct.com/d2/lib/textmeasure"
"oss.terrastruct.com/d2/lib/urlenc"
"oss.terrastruct.com/d2/lib/version"
"oss.terrastruct.com/util-go/go2"
)

const DEFAULT_INPUT_PATH = "index"
Expand Down Expand Up @@ -242,6 +245,12 @@ func Compile(args []js.Value) (interface{}, error) {
compileOpts.Layout = input.Opts.Layout
}

if input.Opts != nil && input.Opts.ASCII != nil && *input.Opts.ASCII {
if compileOpts.Layout == nil || *compileOpts.Layout == "dagre" {
compileOpts.Layout = go2.Pointer("elk")
}
}

renderOpts := &d2svg.RenderOpts{}
if input.Opts != nil && input.Opts.Sketch != nil {
renderOpts.Sketch = input.Opts.Sketch
Expand Down Expand Up @@ -293,6 +302,8 @@ func Compile(args []js.Value) (interface{}, error) {
AnimateInterval: input.Opts.AnimateInterval,
Salt: input.Opts.Salt,
NoXMLTag: input.Opts.NoXMLTag,
ASCII: input.Opts.ASCII,
ASCIIMode: input.Opts.ASCIIMode,
},
}, nil
}
Expand Down Expand Up @@ -397,6 +408,40 @@ func Render(args []js.Value) (interface{}, error) {
renderOpts.NoXMLTag = input.Opts.NoXMLTag
}

if input.Opts != nil && input.Opts.ASCII != nil && *input.Opts.ASCII {
if !noChildren && animateInterval > 0 {
return nil, &WASMError{Message: "ASCII rendering does not support multi-board animation", Code: 400}
}
if !noChildren {
return nil, &WASMError{Message: "ASCII rendering does not support multi-board targets", Code: 400}
}

artist := d2ascii.NewASCIIartist()
asciiOpts := &d2ascii.RenderOpts{}
if input.Opts.Scale != nil {
asciiOpts.Scale = input.Opts.Scale
}
// Set charset based on ASCII mode (default to "extended"/Unicode)
var charsetType charset.Type
asciiMode := "extended" // Default
if input.Opts.ASCIIMode != nil {
asciiMode = *input.Opts.ASCIIMode
}
switch asciiMode {
case "standard":
charsetType = charset.ASCII
default: // "extended" or any other value defaults to Unicode
charsetType = charset.Unicode
}
asciiOpts.Charset = charsetType

out, err := artist.Render(diagram, asciiOpts)
if err != nil {
return nil, &WASMError{Message: fmt.Sprintf("ASCII render failed: %s", err.Error()), Code: 500}
}
return out, nil
}

forceAppendix := input.Opts != nil && input.Opts.ForceAppendix != nil && *input.Opts.ForceAppendix

var boards [][]byte
Expand Down
2 changes: 2 additions & 0 deletions d2js/d2wasm/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ type RenderOptions struct {
AnimateInterval *int64 `json:"animateInterval"`
Salt *string `json:"salt"`
NoXMLTag *bool `json:"noXMLTag"`
ASCII *bool `json:"ascii"`
ASCIIMode *string `json:"asciiMode"`
}

type CompileOptions struct {
Expand Down
82 changes: 78 additions & 4 deletions d2js/js/examples/customizable.html
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,30 @@
min-width: 100%;
max-height: 90vh;
}

#output pre {
font-family: monospace;
white-space: pre;
overflow-x: auto;
background: #f5f5f5;
padding: 16px;
border-radius: 4px;
}
</style>
</head>

<body>
<div class="controls">
<textarea id="input">x -> y</textarea>
<textarea id="input">
server: {shape: rectangle}
database: {shape: cylinder}
user: {shape: person}

user -> server: "HTTP request"
server -> database: "SQL query"
database -> server: "result"
server -> user: "response"</textarea
>
<div class="options-group">
<div class="option">
<div class="option-toggle">
Expand Down Expand Up @@ -137,6 +155,30 @@
</div>
</div>
</div>
<div class="option">
<div class="option-toggle">
<label class="checkbox-label">
<input type="checkbox" id="ascii-toggle" class="option-toggle-box" />
<span>ASCII Mode</span>
</label>
</div>
<div class="option-select">
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="ascii-select" value="true" checked />
Enabled
</label>
<label class="radio-label">
<input type="radio" name="ascii-select" value="false" />
Disabled
</label>
</div>
<select id="ascii-mode-select">
<option selected value="extended">Extended (Unicode)</option>
<option value="standard">Standard (ASCII)</option>
</select>
</div>
</div>
<div class="option">
<div class="option-toggle">
<label class="checkbox-label">
Expand Down Expand Up @@ -384,6 +426,13 @@
const layout = document.getElementById("layout-toggle").checked
? document.querySelector('input[name="layout-select"]:checked').value
: null;
const ascii = document.getElementById("ascii-toggle").checked
? document.querySelector('input[name="ascii-select"]:checked').value == "true"
: null;
const asciiModeSelector = document.getElementById("ascii-mode-select");
const asciiMode = ascii
? asciiModeSelector.options[asciiModeSelector.selectedIndex].value
: null;
const sketch = document.getElementById("sketch-toggle").checked
? document.querySelector('input[name="sketch-select"]:checked').value == "true"
: null;
Expand Down Expand Up @@ -430,7 +479,7 @@
try {
const result = await d2.compile(input, {
layout,
sketch,
sketch: ascii ? false : sketch, // Disable sketch when ASCII is enabled
themeId,
darkThemeId,
scale,
Expand All @@ -445,14 +494,39 @@
fontSemibold,
fontBold,
noXmlTag: true,
ascii: ascii,
asciiMode: asciiMode,
});
const output = await d2.render(result.diagram, {
...result.renderOptions,
ascii: ascii,
asciiMode: asciiMode,
});
const svg = await d2.render(result.diagram, result.renderOptions);
document.getElementById("output").innerHTML = svg;

if (ascii) {
document.getElementById("output").innerHTML = `<pre>${output}</pre>`;
} else {
document.getElementById("output").innerHTML = output;
}
} catch (err) {
console.error(err);
document.getElementById("output").textContent = err.message;
}
};

// Make ASCII and Sketch modes mutually exclusive
document.getElementById("ascii-toggle").addEventListener("change", function () {
if (this.checked) {
document.getElementById("sketch-toggle").checked = false;
}
});

document.getElementById("sketch-toggle").addEventListener("change", function () {
if (this.checked) {
document.getElementById("ascii-toggle").checked = false;
}
});

compile();
</script>
</body>
Expand Down
4 changes: 4 additions & 0 deletions d2js/js/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ export interface RenderOptions {
salt?: string;
/** Omit XML tag (<?xml ...?>) from output SVG files. Useful when generating SVGs for direct HTML embedding. */
noXMLTag?: boolean;
/** Render as ASCII instead of SVG [default: false] */
ascii?: boolean;
/** ASCII rendering mode for text outputs. Options: 'standard' (basic ASCII chars) or 'extended' (Unicode chars) [default: 'extended'] */
asciiMode?: "standard" | "extended";
}

export interface CompileOptions extends RenderOptions {
Expand Down
62 changes: 62 additions & 0 deletions d2js/js/test/unit/basic.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -429,4 +429,66 @@ describe("D2 Unit Tests", () => {
expect(jsVersion.length).toBeGreaterThan(0);
await d2.worker.terminate();
}, 20000);

test("ASCII render works", async () => {
const d2 = new D2();
const result = await d2.compile("x -> y");
const ascii = await d2.render(result.diagram, { ascii: true });
expect(ascii).toBeDefined();
expect(typeof ascii).toBe("string");
expect(ascii).toContain("x");
expect(ascii).toContain("y");
// ASCII art uses box drawing characters for connections
expect(ascii).toContain("┌") ||
expect(ascii).toContain("└") ||
expect(ascii).toContain("│");
await d2.worker.terminate();
}, 20000);

test("ASCII render with multiple shapes works", async () => {
const d2 = new D2();
const result = await d2.compile(`
a: {shape: rectangle}
b: {shape: circle}
c: {shape: diamond}
a -> b
b -> c
`);
const ascii = await d2.render(result.diagram, { ascii: true });
expect(ascii).toBeDefined();
expect(typeof ascii).toBe("string");
expect(ascii).toContain("a");
expect(ascii).toContain("b");
expect(ascii).toContain("c");
await d2.worker.terminate();
}, 20000);

test("ASCII mode options work correctly", async () => {
const d2 = new D2();
const result = await d2.compile("x -> y");

// Test extended mode (default)
const asciiExtended = await d2.render(result.diagram, {
ascii: true,
asciiMode: "extended",
});
expect(asciiExtended).toBeDefined();
expect(typeof asciiExtended).toBe("string");
expect(asciiExtended).toMatch(/[┌┐└┘│─]/); // Should contain Unicode box chars

// Test standard mode
const asciiStandard = await d2.render(result.diagram, {
ascii: true,
asciiMode: "standard",
});
expect(asciiStandard).toBeDefined();
expect(typeof asciiStandard).toBe("string");
expect(asciiStandard).not.toMatch(/[┌┐└┘│─]/); // Should not contain Unicode box chars
expect(asciiStandard).toMatch(/[+\-|]/); // Should contain basic ASCII chars

// Modes should produce different outputs
expect(asciiExtended).not.toBe(asciiStandard);

await d2.worker.terminate();
}, 20000);
});
27 changes: 26 additions & 1 deletion d2renderers/d2ascii/asciiroute/asciiroute.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package asciiroute

import (
"fmt"
"math"
"strings"

Expand All @@ -25,7 +26,7 @@ type Boundary struct {
}

func (b *Boundary) Contains(x int, y int) bool {
return x > b.TL.X && x < b.BR.X && y > b.TL.Y && y < b.BR.Y
return x >= b.TL.X && x <= b.BR.X && y >= b.TL.Y && y <= b.BR.Y
}

func NewBoundary(tl, br Point) *Boundary {
Expand All @@ -50,22 +51,46 @@ func DrawRoute(rd RouteDrawer, conn d2target.Connection) {
routes := conn.Route
label := conn.Label

fmt.Printf("[D2ASCII] Starting edge route for connection %s -> %s\n", conn.Src, conn.Dst)
fmt.Printf("[D2ASCII] Initial route points (%d points):\n", len(routes))
for i, pt := range routes {
fmt.Printf("[D2ASCII] Point %d: (%.2f, %.2f)\n", i, pt.X, pt.Y)
}

frmShapeBoundary, toShapeBoundary := getConnectionBoundaries(rd, conn.Src, conn.Dst)
fmt.Printf("[D2ASCII] Source boundary: TL(%d,%d) BR(%d,%d)\n",
frmShapeBoundary.TL.X, frmShapeBoundary.TL.Y,
frmShapeBoundary.BR.X, frmShapeBoundary.BR.Y)
fmt.Printf("[D2ASCII] Dest boundary: TL(%d,%d) BR(%d,%d)\n",
toShapeBoundary.TL.X, toShapeBoundary.TL.Y,
toShapeBoundary.BR.X, toShapeBoundary.BR.Y)

routes = processRoute(rd, routes, frmShapeBoundary, toShapeBoundary)

turnDir := calculateTurnDirections(routes)
fmt.Printf("[D2ASCII] Turn directions calculated: %d turns\n", len(turnDir))
for key, dir := range turnDir {
fmt.Printf("[D2ASCII] Turn at %s: direction %s\n", key, dir)
}

var labelPos *RouteLabelPosition
if strings.TrimSpace(label) != "" {
labelPos = calculateBestLabelPosition(rd, routes, label)
if labelPos != nil {
fmt.Printf("[D2ASCII] Label position calculated: segment %d, pos (%d, %d), maxDiff %.2f\n",
labelPos.I, labelPos.X, labelPos.Y, labelPos.MaxDiff)
}
}

corners, arrows := getCharacterMaps(rd)

fmt.Printf("[D2ASCII] Drawing %d segments\n", len(routes)-1)
for i := 1; i < len(routes); i++ {
fmt.Printf("[D2ASCII] Drawing segment %d: (%.2f,%.2f) -> (%.2f,%.2f)\n",
i-1, routes[i-1].X, routes[i-1].Y, routes[i].X, routes[i].Y)
drawSegmentBetweenPoints(rd, routes[i-1], routes[i], i, conn, corners, arrows, turnDir, frmShapeBoundary, toShapeBoundary, labelPos, label)
}
fmt.Printf("[D2ASCII] Edge route completed for %s -> %s\n", conn.Src, conn.Dst)
}

func getCharacterMaps(rd RouteDrawer) (corners, arrows map[string]string) {
Expand Down
Loading