Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

acii art renderer #2353

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
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
10 changes: 6 additions & 4 deletions d2cli/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand Down
19 changes: 18 additions & 1 deletion d2cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
291 changes: 291 additions & 0 deletions d2renderers/d2ascii/d2ascii.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading