From 9a87d6f6330de73e09e2f4e452aaaedfc5901f5e Mon Sep 17 00:00:00 2001 From: robby Date: Fri, 23 Oct 2026 10:47:13 -0400 Subject: [PATCH] Fix keyword names, hex style values and multi-board render - Quote every D2 keyword used as a name (shadow, fill, top, ...), using the full keyword list from D2 v0.9.0. - Quote style values D2 cannot read bare, such as #dbeafe, which D2 otherwise reads as a comment. - render() takes target=, default the root board, so diagrams with layers, scenarios or steps render instead of failing on stdout. Co-Authored-By: Claude Opus 5.5 --- src/d2/_render.py | 3 ++- src/d2/helpers.py | 48 +++++++++++++++++++++++++--------- src/d2/style.py | 4 +-- tests/test_d2/test_d2_style.py | 4 +++ tests/test_d2/test_helpers.py | 5 ++++ tests/test_d2/test_render.py | 20 ++++++++++++++ 6 files changed, 69 insertions(+), 15 deletions(-) diff --git a/src/d2/_render.py b/src/d2/_render.py index 9a318da..2861811 100644 --- a/src/d2/_render.py +++ b/src/d2/_render.py @@ -28,13 +28,14 @@ def render( *, layout: Literal["dagre", "elk", "tala"] | None = None, theme: int | None = None, + target: str = "", ) -> bytes: command = [find_d2()] if layout is not None: command += ["--layout", layout] if theme is not None: command += ["--theme", str(theme)] - command += ["--stdout-format", format, "-", "-"] + command += ["--target", target, "--stdout-format", format, "-", "-"] result = subprocess.run( command, input=str(diagram).encode(), capture_output=True, check=False diff --git a/src/d2/helpers.py b/src/d2/helpers.py index f552993..3d1dadb 100644 --- a/src/d2/helpers.py +++ b/src/d2/helpers.py @@ -2,28 +2,52 @@ RESERVED_KEYWORDS = frozenset( { - "shape", - "style", "label", - "near", - "layers", - "scenarios", - "steps", - "vars", - "classes", - "class", - "direction", + "shape", "icon", - "link", + "constraint", "tooltip", + "link", + "near", "width", "height", - "constraint", + "direction", + "top", + "left", "grid-rows", "grid-columns", "grid-gap", + "vertical-gap", + "horizontal-gap", + "class", + "vars", + "style", "source-arrowhead", "target-arrowhead", + "classes", + "opacity", + "stroke", + "fill", + "fill-pattern", + "stroke-width", + "stroke-dash", + "border-radius", + "font", + "font-size", + "font-color", + "bold", + "italic", + "underline", + "text-transform", + "shadow", + "multiple", + "double-border", + "3d", + "animated", + "filled", + "layers", + "scenarios", + "steps", } ) diff --git a/src/d2/style.py b/src/d2/style.py index b5fde56..6dffd11 100644 --- a/src/d2/style.py +++ b/src/d2/style.py @@ -1,7 +1,7 @@ import sys from typing import Literal -from d2.helpers import add_label_and_properties +from d2.helpers import add_label_and_properties, quote_label if sys.version_info >= (3, 12): from typing import override @@ -21,7 +21,7 @@ def render_value(value: str | float | bool) -> str | None: if isinstance(value, bool): return stringify_bool(value) if isinstance(value, str): - return value if value != "" else None + return quote_label(value) if value != "" else None return str(value) diff --git a/tests/test_d2/test_d2_style.py b/tests/test_d2/test_d2_style.py index 0055ae0..5e17cb8 100644 --- a/tests/test_d2/test_d2_style.py +++ b/tests/test_d2/test_d2_style.py @@ -137,3 +137,7 @@ def test_d2_style_shape_options() -> None: "}", ] assert str(style) == "\n".join(expected_lines) + + +def test_d2_style_quotes_hex_colors() -> None: + assert str(Style(fill="#dbeafe")) == 'style: {\n fill: "#dbeafe"\n}' diff --git a/tests/test_d2/test_helpers.py b/tests/test_d2/test_helpers.py index 24bb1f3..00fd269 100644 --- a/tests/test_d2/test_helpers.py +++ b/tests/test_d2/test_helpers.py @@ -95,3 +95,8 @@ def test_quote_label_keeps_already_quoted_labels() -> None: def test_add_label_and_properties_quotes_label() -> None: assert add_label_and_properties("a", "x: y", []) == ['a: "x: y"'] + + +def test_quote_name_quotes_style_keywords() -> None: + assert quote_name("shadow") == '"shadow"' + assert quote_name("Fill") == '"Fill"' diff --git a/tests/test_d2/test_render.py b/tests/test_d2/test_render.py index 4655faa..1b16e3d 100644 --- a/tests/test_d2/test_render.py +++ b/tests/test_d2/test_render.py @@ -73,3 +73,23 @@ def test_render__txt() -> None: def test_render__invalid_source_raises_d2_error() -> None: with pytest.raises(d2.D2Error): d2.render("a -> {") + + +@requires_d2 +def test_render__boards_renders_root_or_target() -> None: + diagram = d2.Diagram( + shapes=[d2.Shape(name="root_shape")], + layers=[ + d2.Layer(name="detail", diagram=d2.Diagram(shapes=[d2.Shape(name="inner")])) + ], + ) + assert b"root_shape" in d2.render(diagram, "txt") + assert b"inner" in d2.render(diagram, "txt", target="layers.detail") + + +@requires_d2 +def test_render__hex_colors_and_keyword_names() -> None: + diagram = d2.Diagram( + shapes=[d2.Shape(name="shadow", style=d2.Style(fill="#dbeafe"))] + ) + assert d2.render(diagram).startswith(b"