doxtr-d2
A Sphinx extension that renders d2 diagrams via the
external d2 binary, with first-class
doxtr-pdf-theme-core
integration for flawless light/dark LaTeX→PDF output.
The extension works standalone — the theme-core dependency is soft (optional). When the core is importable, dark-palette integration lights up automatically so diagrams use the same colors the theme computed for the page.
Requirements
- Python ≥ 3.8
- Sphinx ≥ 5.0
- The external
d2binary onPATH(or pointd2_binaryat it). Verified against d2 v0.9.0. - For PDF output: LuaLaTeX (the theme core forces
lualatex; graphicx/luatex embed PDF natively — noepstopdfneeded).
Install
pip install doxtr-d2
Optional extras:
pip install "doxtr-d2[theme]" # doxtr-pdf-theme-core dark-palette integration
pip install "doxtr-d2[xlink]" # sphinxcontrib-xlink :xlink: link support
pip install "doxtr-d2[dev]" # test/dev dependencies
Enable it in conf.py:
extensions = [
# ...
"doxtr_d2",
]
Usage
Inline d2 script:
.. d2::
a -> b: hello
b -> c
Or a .d2 file argument (resolved relative to the current document, and
registered as a rebuild dependency). The file argument and inline content are
mutually exclusive — supplying both (or neither) warns and skips the
diagram.
.. d2:: diagrams/architecture.d2
:caption: System architecture
:width: 80%
Directive options
| Option | Type | Notes |
|---|---|---|
align |
left/center/right |
figure/image alignment |
alt |
text | alt text (also the man/text placeholder) |
caption |
text | wraps the node in a figure with a caption (inline roles/xrefs ok) |
class |
class list | extra CSS/LaTeX classes |
width |
length or % |
figure width (translated to a LaTeX length for PDF) |
name |
text | cross-reference target |
theme |
nonneg int | base light theme id |
dark_theme |
nonneg int | dark fallback theme id (overrides d2_dark_theme) |
layout |
text | dagre/elk (+ any configured extra engine) |
pad |
positive int | px padding |
sketch |
bool | hand-drawn look |
scale |
float | render scale (only applied when > 0) |
center |
bool | center in the viewbox |
format |
svg/png/pdf/gif |
per-directive override of the resolved format |
target |
text | board selector (--target), e.g. layers.x.* |
animate_interval |
positive int | multi-board animated SVG/GIF interval (ms) |
Boolean options follow the common convention: a bare flag or
1/true/yes/on ⇒ True; 0/false/no/off ⇒ False.
Configuration
Every directive option has a d2_<option> config default; the directive value
wins over the config default.
| Config value | Default | Notes |
|---|---|---|
d2_binary |
"d2" |
binary path (argv[0]; invoked with shell=False) |
d2_theme |
0 |
base light theme id |
d2_dark_theme |
200 |
dark fallback theme id |
d2_layout |
"dagre" |
default layout engine |
d2_extra_layouts |
[] |
whitelist of extra engine names (e.g. ["tala"]); a resolved engine outside {dagre, elk} ∪ d2_extra_layouts warns and falls back to the default (raises under d2_strict) |
d2_pad |
100 |
px padding |
d2_sketch |
False |
hand-drawn look |
d2_scale |
-1 |
--scale appended only when > 0 |
d2_center |
False |
center in the viewbox |
d2_class |
"" |
default extra class |
d2_redirect_links_to_blank_page |
True |
SVG <a> target="_blank" for non-external links |
d2_animate_interval |
0 |
multi-board animated SVG/GIF interval (ms) |
d2_timeout |
120 |
--timeout=<n> and the subprocess timeout |
d2_fonts |
{} |
{role: ttf-path} (regular/italic/bold/semibold/mono/…) |
d2_multiboard_svg |
"animate" |
SVG multi-board policy: animate/root/error |
d2_builder_formats |
{} |
per-builder format overrides (merged over the defaults) |
d2_default_format |
"svg" |
fallback for builders not in the map |
d2_use_theme_core_palette |
True |
build custom theme-overrides from the core palette |
d2_match_page_background |
True |
match the d2 canvas (N7) to the resolved page color |
d2_dark_theme_overrides |
{} |
user dict deep-merged over the generated block |
d2_dark_html |
False |
opt-in to apply dark theme-overrides in HTML builds |
d2_allowed_link_schemes |
None |
None = all schemes except denylist {javascript, data, vbscript} |
d2_latex_max_width |
\linewidth |
default width cap for the LaTeX visitor |
d2_strict |
False |
when True, a d2 failure raises instead of warn+skip |
Per-builder output formats
Each builder gets a sensible default; override any of them via
d2_builder_formats.
| Builder | Default format |
|---|---|
html / dirhtml / singlehtml |
svg |
epub |
svg |
latex |
pdf |
confluence |
png (experimental) |
| (any other) | d2_default_format (svg) |
d2_builder_formats = {"latex": "png", "epub": "png"}
HTML-family builders can only embed svg/png. A per-directive :format:
that the active builder cannot embed (e.g. pdf/gif on an HTML builder) is
validated once the builder is known: the extension logs a build warning and
falls back to a safe embeddable format (SVG for HTML, PDF for LaTeX) rather than
emitting a broken diagram. Under d2_strict = True the mismatch raises a
D2Error instead.
Dark mode & theme-core integration
d2's --dark-theme only affects a browser in dark mode; a static PDF needs an
explicit theme. So for dark PDF/SVG this extension computes a custom d2
theme-overrides block from the theme-core's resolved dark palette and injects
it on top of the base --theme=<d2_dark_theme> (default 200). When the core
is absent or dark mode is inactive it falls back to that base dark theme id.
Integration is soft: the core import is guarded on ImportError only, so a
version-skew (core present but an expected API missing) surfaces as a warning
rather than silently degrading to "core absent". At startup the extension logs
whether palette integration is active.
You can also ship a hand-authored arch_dark.d2 next to arch.d2; in a dark
LaTeX build the core swaps the directive argument to the _dark file. When a
swap occurs the auto-generated theme-overrides are skipped so the hand-authored
file speaks for itself.
theme-core version floor. The four integration APIs (
get_dark_mode_context,is_dark_mode_active,register_dark_swap_directive,mark_image_dark_ready) are exported since 1.1.9, so the optionalthemeextra pinsdoxtr-pdf-theme-core>=1.1.9. (The original plan tentatively named>=1.1.10; 1.1.9 is the earliest release verified to export all four.)
Links in diagrams
d2 link: fields may carry Sphinx cross-references and arbitrary URLs. Three
kinds are supported:
- Internal xrefs —
link: :ref:\target`(and:doc:` / other domain roles). Resolved to a builder-correct relative URI in HTML. On the PDF path internal xrefs are dropped (the label is kept, but a relative-file URI is meaningless inside a single PDF). sphinxcontrib-xlink—link: :xlink:\link-id`resolves to an external URL via thexlink` config map. Degrades gracefully (warn + drop) when xlink is not installed.- Raw / custom-scheme URLs — any
link: <uri>passes through scheme-agnostically:https:,mailto:,obsidian://,x-devonthink-item://…, etc. External/custom-scheme links become clickable/URIannotations in the PDF too.
Security: javascript: / data: / vbscript: URIs are an XSS vector in SVG
output and are stripped with a warning by default. Configure
d2_allowed_link_schemes to change the policy (e.g. ["https", "mailto"] to
restrict to an allowlist, or include "data" to opt back in).
Own-line constraint. A
link:carrying a role must be on its own line:node: { link: :ref:`my-target` }
x: { link: :ref:\t` }` on a single line is not rewritten.
By default SVG links get target="_blank" (configurable via
d2_redirect_links_to_blank_page).
Multi-board compositions
d2 compositions (layers / scenarios / steps) declare multiple boards. The
output must always be a single file, never a directory, so the extension
picks a single-file representation per format:
- SVG — a multi-board SVG written to
foo.svgbecomes a directory in d2. To avoid that,d2_multiboard_svgselects the policy:"animate"(default) — render an animated SVG (--animate-interval)."root"— render the root board only."error"— warn and fall back to the root board. A:target:always selects a specific board and takes precedence.
- PDF — a multi-board diagram renders to a multipage PDF (one board per
page).
\includegraphicsembeds page 1 only, so set:target:to choose a specific board; without it the extension warns and shows the first board. - GIF — an animated multi-board GIF is the natural single-file form.
LaTeX / PDF notes
- PDF output requires LuaLaTeX.
- A raw d2 PDF (~203 mm) overflows the text block, so the LaTeX visitor always
emits
width=\linewidth,height=0.9\textheight,keepaspectratio(width fromd2_latex_max_width; a:width:value, including%, is translated to a LaTeX length). - d2 PDFs embed real clickable
/URIlink annotations, so external/custom-scheme links survive into the PDF. Internal:ref:cross-references are an HTML-only feature (a relative-file URI is meaningless inside a single PDF).
Layout engines
d2 ships two free layout engines, dagre (default) and elk. A licensed engine
such as tala must be whitelisted by name in d2_extra_layouts so a typo'd
engine is caught rather than silently handed to d2:
d2_extra_layouts = ["tala"] # then :layout: tala is accepted
A resolved layout engine outside {dagre, elk} ∪ d2_extra_layouts is rejected:
the extension logs a build warning and falls back to d2's default engine (or
raises a D2Error under d2_strict).
Replacing the renderer
Everything that shells out to d2 lives behind a single indirection,
doxtr_d2.render.render_backend (which defaults to
doxtr_d2.render.render_d2). The builder visitors never call render_d2
directly — they call render.render_backend(...). A child theme can therefore
replace all rendering by reassigning that one value, instead of overriding
every per-builder visitor:
import doxtr_d2.render as render
def my_render_backend(stdin_bytes, fmt, resolved_options, image_dir, **kwargs):
# e.g. call a d2 HTTP service or a different diagram tool.
# Contract: write the artifact under image_dir and return its filename
# RELATIVE to image_dir; raise doxtr_d2.errors.D2Error on failure.
...
return "d2-<hash>.%s" % fmt
render.render_backend = my_render_backend
Keep the signature and return/raise contract identical to render_d2
((stdin_bytes, fmt, resolved_options, image_dir, *, binary, fonts, timeout, cwd, post_process) -> out_name) and the visitors, caching key, and atomic-write
guarantees all continue to work unchanged.
License
MIT — see LICENSE.
Release files for doxtr-d2 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| doxtr_d2-0.1.0.tar.gz | 45.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| doxtr_d2-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 81.4 kB
Release files / doxtr_d2-0.1.0.tar.gz
| Download URL | doxtr_d2-0.1.0.tar.gz |
|---|---|
| Size | 45.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1098ea316b0064dcd4e4e6e8c619a3c07c3119b20e7234f14d34ec4d446f4c42
|
|
BLAKE2b-256 checksum How to use checksums |
e98ddba19fa1814ccb7feb40ea731397cadc80d60578412f8399d9cd77f1ffb9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 15, 2026.
Transparency logRelease files / doxtr_d2-0.1.0-py3-none-any.whl
| Download URL | doxtr_d2-0.1.0-py3-none-any.whl |
|---|---|
| Size | 35.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
de58c1ebcb288648a0c343cf126552d3f44ffd97161528a4aaa1c54adade0229
|
|
BLAKE2b-256 checksum How to use checksums |
9f74326983f6aab4d17385021f91ca53a634beedce1e2204d0bdbcd5ebeb4889
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 15, 2026.
Transparency log