From 50bd016e40105372942af0f367934c8a54c5bd82 Mon Sep 17 00:00:00 2001 From: Fahmi Akbar Wildana Date: Fri, 14 Feb 2025 09:41:41 +0700 Subject: [PATCH] initial implementation --- d2cli/export.go | 10 +- d2cli/main.go | 19 ++- d2renderers/d2ascii/d2ascii.go | 291 +++++++++++++++++++++++++++++++++ 3 files changed, 315 insertions(+), 5 deletions(-) create mode 100644 d2renderers/d2ascii/d2ascii.go diff --git a/d2cli/export.go b/d2cli/export.go index 602cfd675..45bdfd56b 100644 --- a/d2cli/export.go +++ b/d2cli/export.go @@ -13,15 +13,17 @@ const PNG exportExtension = ".png" const PPTX exportExtension = ".pptx" const PDF exportExtension = ".pdf" const SVG exportExtension = ".svg" +const ASCII exportExtension = ".txt" -var SUPPORTED_EXTENSIONS = []exportExtension{SVG, PNG, PDF, PPTX, GIF} +var SUPPORTED_EXTENSIONS = []exportExtension{ASCII, SVG, PNG, PDF, PPTX, GIF} var STDOUT_FORMAT_MAP = map[string]exportExtension{ - "png": PNG, - "svg": SVG, + "png": PNG, + "svg": SVG, + "ascii": ASCII, } -var SUPPORTED_STDOUT_FORMATS = []string{"png", "svg"} +var SUPPORTED_STDOUT_FORMATS = []string{"png", "svg", "ascii"} func getOutputFormat(stdoutFormatFlag *string, outputPath string) (exportExtension, error) { if stdoutFormatFlag != nil && *stdoutFormatFlag != "" { diff --git a/d2cli/main.go b/d2cli/main.go index dbf1ae2c4..18949d4e1 100644 --- a/d2cli/main.go +++ b/d2cli/main.go @@ -28,6 +28,7 @@ import ( "oss.terrastruct.com/d2/d2parser" "oss.terrastruct.com/d2/d2plugin" "oss.terrastruct.com/d2/d2renderers/d2animate" + "oss.terrastruct.com/d2/d2renderers/d2ascii" "oss.terrastruct.com/d2/d2renderers/d2fonts" "oss.terrastruct.com/d2/d2renderers/d2svg" "oss.terrastruct.com/d2/d2renderers/d2svg/appendix" @@ -103,7 +104,7 @@ func Run(ctx context.Context, ms *xmain.State) (err error) { if err != nil { return err } - stdoutFormatFlag := ms.Opts.String("", "stdout-format", "", "", "output format when writing to stdout (svg, png). Usage: d2 input.d2 --stdout-format png - > output.png") + stdoutFormatFlag := ms.Opts.String("", "stdout-format", "", "", "output format when writing to stdout (svg, png, ascii). Usage: d2 input.d2 --stdout-format png - > output.png") if err != nil { return err } @@ -862,6 +863,22 @@ func renderSingle(ctx context.Context, ms *xmain.State, compileDur time.Duration } func _render(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opts d2svg.RenderOpts, inputPath, outputPath string, bundle, forceAppendix bool, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram, outputFormat exportExtension) ([]byte, error) { + if outputFormat == ASCII { + renderOpts := &d2ascii.RenderOpts{ + Pad: opts.Pad, + Scale: opts.Scale, + } + ascii, err := d2ascii.Render(diagram, renderOpts) + if err != nil { + return ascii, err + } + err = Write(ms, outputPath, ascii) + if err != nil { + return ascii, err + } + return ascii, nil + } + toPNG := outputFormat == PNG var scale *float64 diff --git a/d2renderers/d2ascii/d2ascii.go b/d2renderers/d2ascii/d2ascii.go new file mode 100644 index 000000000..1eb3e3ae7 --- /dev/null +++ b/d2renderers/d2ascii/d2ascii.go @@ -0,0 +1,291 @@ +package d2ascii + +import ( + "bytes" + "math" + "strings" + + "oss.terrastruct.com/d2/d2target" +) + +// RenderOpts contains options for ASCII rendering +type RenderOpts struct { + Pad *int64 // Optional padding around the diagram + Scale *float64 // Pixels per ASCII character ratio +} + +// Render converts a D2 diagram into ASCII art +func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) { + if opts == nil { + opts = &RenderOpts{} + } + + // Default padding matching d2svg + pad := int(8) + if opts.Pad != nil { + pad = int(*opts.Pad) + } + + // Scale for converting diagram coordinates to ASCII grid + // Default: roughly 1 ASCII char = 8x4 pixels + scale := struct{ x, y float64 }{8, 4} + if opts.Scale != nil { + s := *opts.Scale + scale.x = s + scale.y = s / 2 // Maintain aspect ratio + } + + // Calculate canvas dimensions + tl, br := diagram.NestedBoundingBox() + width := int(math.Ceil(float64(br.X-tl.X+(pad*2)) / scale.x)) + height := int(math.Ceil(float64(br.Y-tl.Y+(pad*2)) / scale.y)) + + // Create ASCII canvas + canvas := NewCanvas(width, height) + canvas.setScale(scale.x, scale.y) + canvas.setOffset(-int(tl.X), -int(tl.Y)) + canvas.setPad(pad) + + // Draw shapes + for _, shape := range diagram.Shapes { + err := canvas.drawShape(shape) + if err != nil { + return nil, err + } + } + + // Draw connections + for _, conn := range diagram.Connections { + err := canvas.drawConnection(conn) + if err != nil { + return nil, err + } + } + + return canvas.Bytes(), nil +} + +// Canvas handles the ASCII grid and drawing operations +type Canvas struct { + grid [][]rune + w, h int + + // Coordinate transformation + scaleX, scaleY float64 + offsetX, offsetY int + pad int +} + +func NewCanvas(w, h int) *Canvas { + grid := make([][]rune, h) + for i := range grid { + grid[i] = make([]rune, w) + for j := range grid[i] { + grid[i][j] = ' ' + } + } + return &Canvas{ + grid: grid, + w: w, + h: h, + } +} + +func (c *Canvas) setScale(x, y float64) { + c.scaleX = x + c.scaleY = y +} + +func (c *Canvas) setOffset(x, y int) { + c.offsetX = x + c.offsetY = y +} + +func (c *Canvas) setPad(pad int) { + c.pad = pad +} + +// transformPoint converts diagram coordinates to ASCII grid coordinates +func (c *Canvas) transformPoint(x, y int) (int, int) { + x = int(float64(x+c.offsetX+c.pad) / c.scaleX) + y = int(float64(y+c.offsetY+c.pad) / c.scaleY) + return x, y +} + +func (c *Canvas) drawShape(shape d2target.Shape) error { + x, y := c.transformPoint(int(shape.Pos.X), int(shape.Pos.Y)) + w := int(float64(shape.Width) / c.scaleX) + h := int(float64(shape.Height) / c.scaleY) + + switch shape.Type { + case d2target.ShapeCircle: + return c.drawCircle(x, y, w, h, shape.Label) + case d2target.ShapeSquare: + return c.drawRect(x, y, w, h, shape.Label) + // Add more shape types as needed + default: + return c.drawRect(x, y, w, h, shape.Label) + } +} + +func (c *Canvas) drawRect(x, y, w, h int, label string) error { + // Draw corners + c.set(x, y, '+') + c.set(x+w, y, '+') + c.set(x, y+h, '+') + c.set(x+w, y+h, '+') + + // Draw horizontal edges + for i := x + 1; i < x+w; i++ { + c.set(i, y, '-') + c.set(i, y+h, '-') + } + + // Draw vertical edges + for i := y + 1; i < y+h; i++ { + c.set(x, i, '|') + c.set(x+w, i, '|') + } + + // Draw label + if label != "" { + c.drawCenteredText(x+1, y+1, w-1, h-1, label) + } + + return nil +} + +func (c *Canvas) drawCircle(x, y, w, h int, label string) error { + // Approximate circle with ASCII characters + c.set(x+w/2, y, '.') + c.set(x+w/2, y+h, '\'') + c.set(x, y+h/2, '(') + c.set(x+w, y+h/2, ')') + + if label != "" { + c.drawCenteredText(x+1, y+1, w-1, h-1, label) + } + + return nil +} + +func (c *Canvas) drawConnection(conn d2target.Connection) error { + // Draw a simple line between points for now + points := make([]struct{ x, y int }, len(conn.Route)) + for i, p := range conn.Route { + points[i].x, points[i].y = c.transformPoint(int(p.X), int(p.Y)) + } + + for i := 0; i < len(points)-1; i++ { + c.drawLine(points[i].x, points[i].y, points[i+1].x, points[i+1].y) + } + + return nil +} + +func (c *Canvas) drawLine(x1, y1, x2, y2 int) { + // Draw horizontal line + if y1 == y2 { + for x := min(x1, x2); x <= max(x1, x2); x++ { + c.set(x, y1, '-') + } + return + } + + // Draw vertical line + if x1 == x2 { + for y := min(y1, y2); y <= max(y1, y2); y++ { + c.set(x1, y, '|') + } + return + } + + // Draw diagonal line + dx := abs(x2 - x1) + dy := abs(y2 - y1) + steep := dy > dx + + if steep { + x1, y1 = y1, x1 + x2, y2 = y2, x2 + } + if x1 > x2 { + x1, x2 = x2, x1 + y1, y2 = y2, y1 + } + + dx = x2 - x1 + dy = abs(y2 - y1) + err := dx / 2 + ystep := 1 + if y1 >= y2 { + ystep = -1 + } + + for ; x1 <= x2; x1++ { + if steep { + c.set(y1, x1, '/') + } else { + c.set(x1, y1, '/') + } + err -= dy + if err < 0 { + y1 += ystep + err += dx + } + } +} + +func (c *Canvas) drawCenteredText(x, y, w, h int, text string) { + lines := strings.Split(text, "\n") + startY := y + (h-len(lines))/2 + + for i, line := range lines { + if startY+i >= c.h { + break + } + startX := x + (w-len(line))/2 + for j, ch := range line { + if startX+j >= c.w { + break + } + c.set(startX+j, startY+i, ch) + } + } +} + +func (c *Canvas) set(x, y int, ch rune) { + if x >= 0 && x < c.w && y >= 0 && y < c.h { + c.grid[y][x] = ch + } +} + +func (c *Canvas) Bytes() []byte { + var buf bytes.Buffer + for _, row := range c.grid { + buf.WriteString(string(row)) + buf.WriteByte('\n') + } + return buf.Bytes() +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func abs(x int) int { + if x < 0 { + return -x + } + return x +}