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
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,26 @@ with open("graph.svg", "wb") as f:
print(d2.render(diagram, "txt").decode())
```

The format can be `"svg"` (the default), `"png"`, `"pdf"` or `"txt"`, which is ASCII art. You can also pass `layout="dagre" | "elk" | "tala"` and `theme=<id>`. For a diagram with layers, scenarios or steps, `target=` picks the board: the root by default, or a path such as `"layers.detail"`. A source string works in place of a diagram. If d2 fails, `d2.D2Error` is raised with the message from d2. PNG and PDF make d2 download a headless browser the first time you use them.
The format can be `"svg"` (the default), `"png"`, `"pdf"`, `"gif"`, `"pptx"` or `"txt"`, which is ASCII art. You can also pass `layout="dagre" | "elk" | "tala"` and `theme=<id>`. Other keyword options are `dark_theme`, `sketch`, `pad`, `scale`, `center`, `omit_version`, `no_xml_tag`, `salt`, `animate_interval`, `ascii_mode` and `timeout`. An option you leave out is not sent to d2. For a diagram with layers, scenarios or steps, `target=` picks the board: the root by default, or a path such as `"layers.detail"`. A source string works in place of a diagram. If d2 fails, `d2.D2Error` is raised with the message from d2. d2 0.9 renders PNG, PDF, GIF and PPTX itself. Older d2 versions download a headless browser for PNG and PDF the first time.

## Validating and formatting

`d2.validate` checks D2 source with `d2 validate` and raises `d2.D2Error` with d2's message when the source is wrong. `d2.fmt` returns the source formatted by `d2 fmt`. Both take a `Diagram` or a string, so they suit code or an LLM that writes D2 text and needs to check it.

```python
import d2

d2.validate("a -> b")

try:
d2.validate("a -> {")
except d2.D2Error as error:
print(error)

print(d2.fmt("a->b"))
```

`validate` checks syntax and meaning, but does not lay the diagram out. Some errors, such as `top` and `left` with the dagre layout, only show when you render.

See the [tests](/tests/test_d2) for more detailed usage examples.

Expand All @@ -116,6 +135,15 @@ See the [tests](/tests/test_d2) for more detailed usage examples.
- [x] Shape tooltips, size, grids, direction and classes
- [x] Diagram direction, classes and `d2-config` vars
- [x] Scenarios and steps
- [x] `d2.validate` and `d2.fmt` through the d2 command
- [x] Glob shapes such as `Shape(name="*")` to style every shape
- [x] Markdown, LaTeX or code as shape and connection labels (`label=d2.Text(...)`)
- [x] `label_near` and `icon_near` positions, `top` and `left`, grid `vertical_gap` and `horizontal_gap`
- [x] Connection icons, links and classes
- [x] `c4-person` shape and sequence diagrams
- [x] Variables (`variables=`) with `${name}` substitution, and gradient fills
- [x] Legends and board labels
- [x] More render options and gif and pptx output

## More features

Expand Down
7 changes: 6 additions & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,9 @@ uv run python examples/shapes.py
| [boards.py](boards.py) | Layers, scenarios and steps, rendering the root board and one layer |
| [config.py](config.py) | Layout engine, theme, direction and sketch mode through `vars` |
| [ascii.py](ascii.py) | ASCII output printed with `d2.render(diagram, "txt")` |

| [validation.py](validation.py) | `d2.validate`, `d2.fmt` and a glob shape that styles every shape |
| [labels_and_positions.py](labels_and_positions.py) | Markdown labels on shapes and connections, `label_near`, `icon_near`, connection icons and links |
| [fixed_positions.py](fixed_positions.py) | `top` and `left` positions (tala) and grid `vertical_gap` and `horizontal_gap` |
| [c4_and_sequence.py](c4_and_sequence.py) | The `c4-person` shape and a sequence diagram |
| [variables_and_legend.py](variables_and_legend.py) | `variables` with `${name}` substitution, gradients, a legend and a board label |
| [render_options.py](render_options.py) | Sketch, dark theme, pad, scale, salt, gif and pptx output, ASCII mode |
34 changes: 34 additions & 0 deletions examples/c4_and_sequence.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from pathlib import Path

import d2


def main() -> None:
c4 = d2.Diagram(
shapes=[
d2.Shape(name="customer", label="Customer", shape=d2.ShapeType.c4_person),
d2.Shape(name="shop", label="Web shop"),
],
connections=[d2.Connection("customer", "shop", "buys")],
)
Path("c4.svg").write_bytes(d2.render(c4))

sequence = d2.Diagram(
shapes=[
d2.Shape(
name="checkout",
shape=d2.ShapeType.sequence_diagram,
shapes=[d2.Shape(name="user"), d2.Shape(name="shop"), d2.Shape("bank")],
connections=[
d2.Connection("user", "shop", "pay"),
d2.Connection("shop", "bank", "charge"),
d2.Connection("bank", "shop", "ok", style=d2.Style(stroke_dash=3)),
],
)
]
)
Path("sequence.svg").write_bytes(d2.render(sequence))


if __name__ == "__main__":
main()
27 changes: 27 additions & 0 deletions examples/fixed_positions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from pathlib import Path

import d2


def main() -> None:
diagram = d2.Diagram(
vars=d2.Config(layout_engine="tala"),
shapes=[
d2.Shape(name="left_box", top=0, left=0),
d2.Shape(name="right_box", top=120, left=300),
d2.Shape(
name="table",
grid_columns=2,
vertical_gap=40,
horizontal_gap=10,
top=300,
left=100,
shapes=[d2.Shape(name=name) for name in "abcd"],
),
],
)
Path("fixed_positions.svg").write_bytes(d2.render(diagram))


if __name__ == "__main__":
main()
40 changes: 40 additions & 0 deletions examples/labels_and_positions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from pathlib import Path

import d2

ICON = "https://icons.terrastruct.com/essentials%2F112-server.svg"


def main() -> None:
diagram = d2.Diagram(
shapes=[
d2.Shape(
name="api",
label=d2.Text("# API\n\nPublic **REST** endpoint", "md"),
width=260,
height=160,
icon=ICON,
icon_near="border-top-left",
),
d2.Shape(
name="db",
label="Database",
shape=d2.ShapeType.cylinder,
label_near="outside-bottom-center",
),
],
connections=[
d2.Connection(
"api",
"db",
d2.Text("`SELECT *`\nover **TLS**", "md"),
icon=ICON,
link="https://d2lang.com",
)
],
)
Path("labels_and_positions.svg").write_bytes(d2.render(diagram))


if __name__ == "__main__":
main()
30 changes: 30 additions & 0 deletions examples/render_options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from pathlib import Path

import d2


def main() -> None:
diagram = d2.Diagram(
shapes=[d2.Shape(name="a"), d2.Shape(name="b")],
connections=[d2.Connection("a", "b", "hello")],
)
Path("render_options.svg").write_bytes(
d2.render(
diagram,
theme=4,
dark_theme=200,
sketch=True,
pad=20,
scale=1,
salt="one",
omit_version=True,
no_xml_tag=True,
)
)
Path("render_options.pptx").write_bytes(d2.render(diagram, "pptx"))
Path("render_options.gif").write_bytes(d2.render(diagram, "gif"))
print(d2.render(diagram, "txt", ascii_mode="standard").decode())


if __name__ == "__main__":
main()
29 changes: 29 additions & 0 deletions examples/validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from pathlib import Path

import d2


def main() -> None:
diagram = d2.Diagram(
shapes=[
d2.Shape(name="*", style=d2.Style(fill="#dbeafe")),
d2.Shape(name="api"),
d2.Shape(name="db"),
d2.Shape(name="cache"),
],
connections=[d2.Connection("api", "db"), d2.Connection("api", "cache")],
)

d2.validate(diagram)
print(d2.fmt(diagram))

try:
d2.validate("api -> {")
except d2.D2Error as error:
print(f"invalid: {error}")

Path("validation.svg").write_bytes(d2.render(diagram))


if __name__ == "__main__":
main()
39 changes: 39 additions & 0 deletions examples/variables_and_legend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from pathlib import Path

import d2


def main() -> None:
diagram = d2.Diagram(
variables={"env": "production", "primary": "#dbeafe"},
shapes=[
d2.Shape(
name="api", label="API (${env})", style=d2.Style(fill="${primary}")
),
d2.Shape(
name="db",
label="Database",
style=d2.Style(fill="linear-gradient(#f69d3c, #3f87a6)"),
),
],
connections=[d2.Connection("api", "db", style=d2.Style(stroke="red"))],
legend=d2.Legend(
label="Key",
shapes=[d2.Shape(name="gradient", label="Storage")],
connections=[
d2.Connection("a", "b", "critical", style=d2.Style(stroke="red"))
],
),
scenarios=[
d2.Layer(
"db_down",
d2.Diagram(shapes=[d2.Shape(name="db", style=d2.Style(fill="red"))]),
label="Database down",
)
],
)
Path("variables_and_legend.svg").write_bytes(d2.render(diagram))


if __name__ == "__main__":
main()
12 changes: 9 additions & 3 deletions src/d2/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from ._render import D2Error, render
from ._render import D2Error, fmt, render, validate
from .class_shape import ClassField, ClassMethod, ClassShape
from .connection import Arrowhead, ArrowheadShape, Connection, Direction
from .diagram import Config, Diagram, Layer
from .shape import Shape, ShapeType, Text
from .diagram import Config, Diagram, Layer, Legend
from .shape import LabelPosition, NearPosition, Shape, ShapeType
from .style import Style
from .text import Text

__all__ = [
"Arrowhead",
Expand All @@ -16,10 +17,15 @@
"D2Error",
"Diagram",
"Direction",
"LabelPosition",
"Layer",
"Legend",
"NearPosition",
"Shape",
"ShapeType",
"Style",
"Text",
"fmt",
"render",
"validate",
]
71 changes: 59 additions & 12 deletions src/d2/_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

from d2.diagram import Diagram

RenderFormat = Literal["svg", "png", "pdf", "txt", "gif", "pptx"]


class D2Error(Exception):
pass
Expand All @@ -22,24 +24,69 @@ def find_d2() -> str:
return find_d2_bin()


def run_d2(command: list[str], source: Diagram | str) -> bytes:
result = subprocess.run(
[find_d2(), *command],
input=str(source).encode(),
capture_output=True,
check=False,
)
if result.returncode != 0:
raise D2Error(result.stderr.decode(errors="replace"))
return result.stdout


def flag(name: str, *, value: str | float | bool | None) -> list[str]:
if value is None:
return []
if isinstance(value, bool):
return [f"--{name}={'true' if value else 'false'}"]
return [f"--{name}", str(value)]


def render(
diagram: Diagram | str,
format: Literal["svg", "png", "pdf", "txt"] = "svg",
format: RenderFormat = "svg",
*,
layout: Literal["dagre", "elk", "tala"] | None = None,
theme: int | None = None,
target: str = "",
dark_theme: int | None = None,
sketch: bool | None = None,
pad: int | None = None,
scale: float | None = None,
center: bool | None = None,
omit_version: bool | None = None,
no_xml_tag: bool | None = None,
salt: str | None = None,
animate_interval: int | None = None,
ascii_mode: Literal["standard", "extended"] | None = None,
timeout: int | None = None,
) -> bytes:
command = [find_d2()]
if layout is not None:
command += ["--layout", layout]
if theme is not None:
command += ["--theme", str(theme)]
options = (
("layout", layout),
("theme", theme),
("dark-theme", dark_theme),
("sketch", sketch),
("pad", pad),
("scale", scale),
("center", center),
("omit-version", omit_version),
("no-xml-tag", no_xml_tag),
("salt", salt),
("animate-interval", animate_interval),
("ascii-mode", ascii_mode),
("timeout", timeout),
)
command = [part for name, value in options for part in flag(name, value=value)]
command += ["--target", target, "--stdout-format", format, "-", "-"]
return run_d2(command, diagram)

result = subprocess.run(
command, input=str(diagram).encode(), capture_output=True, check=False
)
if result.returncode != 0:
raise D2Error(result.stderr.decode(errors="replace"))
return result.stdout

def validate(source: Diagram | str) -> None:
run_d2(["validate", "-"], source)


def fmt(source: Diagram | str) -> str:
formatted = run_d2(["fmt", "-"], source).decode()
return formatted if formatted != "" else str(source)
Loading
Loading