doxtr-d2 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
doxtr_d2-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Doxtr
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,301 @@
1
+ Metadata-Version: 2.4
2
+ Name: doxtr-d2
3
+ Version: 0.1.0
4
+ Summary: Sphinx extension that renders d2 diagrams, with first-class doxtr-pdf-theme-core PDF integration.
5
+ Author: Jens Frey
6
+ Project-URL: Homepage, https://github.com/doxtr/doxtr-d2
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Framework :: Sphinx :: Extension
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.8
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: sphinx>=5.0
15
+ Provides-Extra: theme
16
+ Requires-Dist: doxtr-pdf-theme-core>=1.1.9; extra == "theme"
17
+ Provides-Extra: xlink
18
+ Requires-Dist: sphinxcontrib-xlink; extra == "xlink"
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=7.0; extra == "dev"
21
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
22
+ Requires-Dist: doxtr-pdf-theme-core>=1.1.9; extra == "dev"
23
+ Requires-Dist: sphinxcontrib-xlink; extra == "dev"
24
+ Dynamic: license-file
25
+
26
+ # doxtr-d2
27
+
28
+ A Sphinx extension that renders [d2](https://d2lang.com/) diagrams via the
29
+ external `d2` binary, with first-class
30
+ [`doxtr-pdf-theme-core`](https://github.com/doxtr/doxtr-pdf-theme-core)
31
+ integration for flawless light/dark LaTeX→PDF output.
32
+
33
+ The extension works **standalone** — the theme-core dependency is soft
34
+ (optional). When the core is importable, dark-palette integration lights up
35
+ automatically so diagrams use the same colors the theme computed for the page.
36
+
37
+ ## Requirements
38
+
39
+ - Python ≥ 3.8
40
+ - Sphinx ≥ 5.0
41
+ - The external [`d2`](https://d2lang.com/tour/install) binary on `PATH`
42
+ (or point `d2_binary` at it). Verified against **d2 v0.9.0**.
43
+ - For PDF output: **LuaLaTeX** (the theme core forces `lualatex`; graphicx/luatex
44
+ embed PDF natively — no `epstopdf` needed).
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pip install doxtr-d2
50
+ ```
51
+
52
+ Optional extras:
53
+
54
+ ```bash
55
+ pip install "doxtr-d2[theme]" # doxtr-pdf-theme-core dark-palette integration
56
+ pip install "doxtr-d2[xlink]" # sphinxcontrib-xlink :xlink: link support
57
+ pip install "doxtr-d2[dev]" # test/dev dependencies
58
+ ```
59
+
60
+ Enable it in `conf.py`:
61
+
62
+ ```python
63
+ extensions = [
64
+ # ...
65
+ "doxtr_d2",
66
+ ]
67
+ ```
68
+
69
+ ## Usage
70
+
71
+ Inline d2 script:
72
+
73
+ ```rst
74
+ .. d2::
75
+
76
+ a -> b: hello
77
+ b -> c
78
+ ```
79
+
80
+ Or a `.d2` file argument (resolved relative to the current document, and
81
+ registered as a rebuild dependency). The file argument and inline content are
82
+ **mutually exclusive** — supplying both (or neither) warns and skips the
83
+ diagram.
84
+
85
+ ```rst
86
+ .. d2:: diagrams/architecture.d2
87
+ :caption: System architecture
88
+ :width: 80%
89
+ ```
90
+
91
+ ### Directive options
92
+
93
+ | Option | Type | Notes |
94
+ |--------|------|-------|
95
+ | `align` | `left`/`center`/`right` | figure/image alignment |
96
+ | `alt` | text | alt text (also the man/text placeholder) |
97
+ | `caption` | text | wraps the node in a `figure` with a caption (inline roles/xrefs ok) |
98
+ | `class` | class list | extra CSS/LaTeX classes |
99
+ | `width` | length or `%` | figure width (translated to a LaTeX length for PDF) |
100
+ | `name` | text | cross-reference target |
101
+ | `theme` | nonneg int | base light theme id |
102
+ | `dark_theme` | nonneg int | dark fallback theme id (overrides `d2_dark_theme`) |
103
+ | `layout` | text | `dagre`/`elk` (+ any configured extra engine) |
104
+ | `pad` | positive int | px padding |
105
+ | `sketch` | bool | hand-drawn look |
106
+ | `scale` | float | render scale (only applied when `> 0`) |
107
+ | `center` | bool | center in the viewbox |
108
+ | `format` | `svg`/`png`/`pdf`/`gif` | per-directive override of the resolved format |
109
+ | `target` | text | board selector (`--target`), e.g. `layers.x.*` |
110
+ | `animate_interval` | positive int | multi-board animated SVG/GIF interval (ms) |
111
+
112
+ Boolean options follow the common convention: a bare flag or
113
+ `1`/`true`/`yes`/`on` ⇒ `True`; `0`/`false`/`no`/`off` ⇒ `False`.
114
+
115
+ ## Configuration
116
+
117
+ Every directive option has a `d2_<option>` config default; the directive value
118
+ wins over the config default.
119
+
120
+ | Config value | Default | Notes |
121
+ |--------------|---------|-------|
122
+ | `d2_binary` | `"d2"` | binary path (argv[0]; invoked with `shell=False`) |
123
+ | `d2_theme` | `0` | base light theme id |
124
+ | `d2_dark_theme` | `200` | dark fallback theme id |
125
+ | `d2_layout` | `"dagre"` | default layout engine |
126
+ | `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`) |
127
+ | `d2_pad` | `100` | px padding |
128
+ | `d2_sketch` | `False` | hand-drawn look |
129
+ | `d2_scale` | `-1` | `--scale` appended only when `> 0` |
130
+ | `d2_center` | `False` | center in the viewbox |
131
+ | `d2_class` | `""` | default extra class |
132
+ | `d2_redirect_links_to_blank_page` | `True` | SVG `<a>` `target="_blank"` for non-external links |
133
+ | `d2_animate_interval` | `0` | multi-board animated SVG/GIF interval (ms) |
134
+ | `d2_timeout` | `120` | `--timeout=<n>` and the subprocess timeout |
135
+ | `d2_fonts` | `{}` | `{role: ttf-path}` (`regular`/`italic`/`bold`/`semibold`/`mono`/…) |
136
+ | `d2_multiboard_svg` | `"animate"` | SVG multi-board policy: `animate`/`root`/`error` |
137
+ | `d2_builder_formats` | `{}` | per-builder format overrides (merged over the defaults) |
138
+ | `d2_default_format` | `"svg"` | fallback for builders not in the map |
139
+ | `d2_use_theme_core_palette` | `True` | build custom theme-overrides from the core palette |
140
+ | `d2_match_page_background` | `True` | match the d2 canvas (`N7`) to the resolved page color |
141
+ | `d2_dark_theme_overrides` | `{}` | user dict deep-merged over the generated block |
142
+ | `d2_dark_html` | `False` | opt-in to apply dark theme-overrides in HTML builds |
143
+ | `d2_allowed_link_schemes` | `None` | `None` = all schemes except denylist `{javascript, data, vbscript}` |
144
+ | `d2_latex_max_width` | `\linewidth` | default width cap for the LaTeX visitor |
145
+ | `d2_strict` | `False` | when `True`, a d2 failure raises instead of warn+skip |
146
+
147
+ ## Per-builder output formats
148
+
149
+ Each builder gets a sensible default; override any of them via
150
+ `d2_builder_formats`.
151
+
152
+ | Builder | Default format |
153
+ |---------|----------------|
154
+ | `html` / `dirhtml` / `singlehtml` | `svg` |
155
+ | `epub` | `svg` |
156
+ | `latex` | `pdf` |
157
+ | `confluence` | `png` (experimental) |
158
+ | (any other) | `d2_default_format` (`svg`) |
159
+
160
+ ```python
161
+ d2_builder_formats = {"latex": "png", "epub": "png"}
162
+ ```
163
+
164
+ HTML-family builders can only embed `svg`/`png`. A per-directive `:format:`
165
+ that the active builder cannot embed (e.g. `pdf`/`gif` on an HTML builder) is
166
+ **validated once the builder is known**: the extension logs a build warning and
167
+ falls back to a safe embeddable format (SVG for HTML, PDF for LaTeX) rather than
168
+ emitting a broken diagram. Under `d2_strict = True` the mismatch raises a
169
+ `D2Error` instead.
170
+
171
+ ## Dark mode & theme-core integration
172
+
173
+ d2's `--dark-theme` only affects a browser in dark mode; a static PDF needs an
174
+ explicit theme. So for dark PDF/SVG this extension computes a custom d2
175
+ `theme-overrides` block from the theme-core's resolved dark palette and injects
176
+ it on top of the base `--theme=<d2_dark_theme>` (default `200`). When the core
177
+ is absent or dark mode is inactive it falls back to that base dark theme id.
178
+
179
+ Integration is **soft**: the core import is guarded on `ImportError` only, so a
180
+ version-skew (core present but an expected API missing) surfaces as a warning
181
+ rather than silently degrading to "core absent". At startup the extension logs
182
+ whether palette integration is active.
183
+
184
+ You can also ship a hand-authored `arch_dark.d2` next to `arch.d2`; in a dark
185
+ LaTeX build the core swaps the directive argument to the `_dark` file. When a
186
+ swap occurs the auto-generated `theme-overrides` are skipped so the hand-authored
187
+ file speaks for itself.
188
+
189
+ > **theme-core version floor.** The four integration APIs (`get_dark_mode_context`,
190
+ > `is_dark_mode_active`, `register_dark_swap_directive`, `mark_image_dark_ready`)
191
+ > are exported since **1.1.9**, so the optional `theme` extra pins
192
+ > `doxtr-pdf-theme-core>=1.1.9`. (The original plan tentatively named `>=1.1.10`;
193
+ > 1.1.9 is the earliest release verified to export all four.)
194
+
195
+ ## Links in diagrams
196
+
197
+ d2 `link:` fields may carry Sphinx cross-references and arbitrary URLs. Three
198
+ kinds are supported:
199
+
200
+ - **Internal xrefs** — `link: :ref:\`target\`` (and `:doc:` / other domain
201
+ roles). Resolved to a builder-correct relative URI in HTML. On the PDF path
202
+ internal xrefs are **dropped** (the label is kept, but a relative-file URI is
203
+ meaningless inside a single PDF).
204
+ - **`sphinxcontrib-xlink`** — `link: :xlink:\`link-id\`` resolves to an external
205
+ URL via the `xlink` config map. Degrades gracefully (warn + drop) when xlink
206
+ is not installed.
207
+ - **Raw / custom-scheme URLs** — any `link: <uri>` passes through
208
+ scheme-agnostically: `https:`, `mailto:`, `obsidian://`,
209
+ `x-devonthink-item://…`, etc. External/custom-scheme links become clickable
210
+ `/URI` annotations in the PDF too.
211
+
212
+ **Security:** `javascript:` / `data:` / `vbscript:` URIs are an XSS vector in SVG
213
+ output and are **stripped with a warning by default**. Configure
214
+ `d2_allowed_link_schemes` to change the policy (e.g. `["https", "mailto"]` to
215
+ restrict to an allowlist, or include `"data"` to opt back in).
216
+
217
+ > **Own-line constraint.** A `link:` carrying a role must be on its **own line**:
218
+ >
219
+ > ```d2
220
+ > node: {
221
+ > link: :ref:`my-target`
222
+ > }
223
+ > ```
224
+ >
225
+ > `x: { link: :ref:\`t\` }` on a single line is **not** rewritten.
226
+
227
+ By default SVG links get `target="_blank"` (configurable via
228
+ `d2_redirect_links_to_blank_page`).
229
+
230
+ ## Multi-board compositions
231
+
232
+ d2 compositions (`layers` / `scenarios` / `steps`) declare multiple boards. The
233
+ output must always be a **single file**, never a directory, so the extension
234
+ picks a single-file representation per format:
235
+
236
+ - **SVG** — a multi-board SVG written to `foo.svg` becomes a *directory* in d2.
237
+ To avoid that, `d2_multiboard_svg` selects the policy:
238
+ - `"animate"` (default) — render an animated SVG (`--animate-interval`).
239
+ - `"root"` — render the root board only.
240
+ - `"error"` — warn and fall back to the root board.
241
+ A `:target:` always selects a specific board and takes precedence.
242
+ - **PDF** — a multi-board diagram renders to a multipage PDF (one board per
243
+ page). `\includegraphics` embeds **page 1 only**, so set `:target:` to choose
244
+ a specific board; without it the extension warns and shows the first board.
245
+ - **GIF** — an animated multi-board GIF is the natural single-file form.
246
+
247
+ ## LaTeX / PDF notes
248
+
249
+ - PDF output requires **LuaLaTeX**.
250
+ - A raw d2 PDF (~203 mm) overflows the text block, so the LaTeX visitor always
251
+ emits `width=\linewidth,height=0.9\textheight,keepaspectratio` (width from
252
+ `d2_latex_max_width`; a `:width:` value, including `%`, is translated to a
253
+ LaTeX length).
254
+ - d2 PDFs embed real clickable `/URI` link annotations, so external/custom-scheme
255
+ links survive into the PDF. Internal `:ref:` cross-references are an HTML-only
256
+ feature (a relative-file URI is meaningless inside a single PDF).
257
+
258
+ ## Layout engines
259
+
260
+ d2 ships two free layout engines, `dagre` (default) and `elk`. A licensed engine
261
+ such as `tala` must be **whitelisted** by name in `d2_extra_layouts` so a typo'd
262
+ engine is caught rather than silently handed to d2:
263
+
264
+ ```python
265
+ d2_extra_layouts = ["tala"] # then :layout: tala is accepted
266
+ ```
267
+
268
+ A resolved layout engine outside `{dagre, elk} ∪ d2_extra_layouts` is rejected:
269
+ the extension logs a build warning and falls back to d2's default engine (or
270
+ raises a `D2Error` under `d2_strict`).
271
+
272
+ ## Replacing the renderer
273
+
274
+ Everything that shells out to `d2` lives behind a single indirection,
275
+ `doxtr_d2.render.render_backend` (which defaults to
276
+ `doxtr_d2.render.render_d2`). The builder visitors never call `render_d2`
277
+ directly — they call `render.render_backend(...)`. A child theme can therefore
278
+ replace **all** rendering by reassigning that one value, instead of overriding
279
+ every per-builder visitor:
280
+
281
+ ```python
282
+ import doxtr_d2.render as render
283
+
284
+ def my_render_backend(stdin_bytes, fmt, resolved_options, image_dir, **kwargs):
285
+ # e.g. call a d2 HTTP service or a different diagram tool.
286
+ # Contract: write the artifact under image_dir and return its filename
287
+ # RELATIVE to image_dir; raise doxtr_d2.errors.D2Error on failure.
288
+ ...
289
+ return "d2-<hash>.%s" % fmt
290
+
291
+ render.render_backend = my_render_backend
292
+ ```
293
+
294
+ Keep the signature and return/raise contract identical to `render_d2`
295
+ (`(stdin_bytes, fmt, resolved_options, image_dir, *, binary, fonts, timeout,
296
+ cwd, post_process) -> out_name`) and the visitors, caching key, and atomic-write
297
+ guarantees all continue to work unchanged.
298
+
299
+ ## License
300
+
301
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,276 @@
1
+ # doxtr-d2
2
+
3
+ A Sphinx extension that renders [d2](https://d2lang.com/) diagrams via the
4
+ external `d2` binary, with first-class
5
+ [`doxtr-pdf-theme-core`](https://github.com/doxtr/doxtr-pdf-theme-core)
6
+ integration for flawless light/dark LaTeX→PDF output.
7
+
8
+ The extension works **standalone** — the theme-core dependency is soft
9
+ (optional). When the core is importable, dark-palette integration lights up
10
+ automatically so diagrams use the same colors the theme computed for the page.
11
+
12
+ ## Requirements
13
+
14
+ - Python ≥ 3.8
15
+ - Sphinx ≥ 5.0
16
+ - The external [`d2`](https://d2lang.com/tour/install) binary on `PATH`
17
+ (or point `d2_binary` at it). Verified against **d2 v0.9.0**.
18
+ - For PDF output: **LuaLaTeX** (the theme core forces `lualatex`; graphicx/luatex
19
+ embed PDF natively — no `epstopdf` needed).
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pip install doxtr-d2
25
+ ```
26
+
27
+ Optional extras:
28
+
29
+ ```bash
30
+ pip install "doxtr-d2[theme]" # doxtr-pdf-theme-core dark-palette integration
31
+ pip install "doxtr-d2[xlink]" # sphinxcontrib-xlink :xlink: link support
32
+ pip install "doxtr-d2[dev]" # test/dev dependencies
33
+ ```
34
+
35
+ Enable it in `conf.py`:
36
+
37
+ ```python
38
+ extensions = [
39
+ # ...
40
+ "doxtr_d2",
41
+ ]
42
+ ```
43
+
44
+ ## Usage
45
+
46
+ Inline d2 script:
47
+
48
+ ```rst
49
+ .. d2::
50
+
51
+ a -> b: hello
52
+ b -> c
53
+ ```
54
+
55
+ Or a `.d2` file argument (resolved relative to the current document, and
56
+ registered as a rebuild dependency). The file argument and inline content are
57
+ **mutually exclusive** — supplying both (or neither) warns and skips the
58
+ diagram.
59
+
60
+ ```rst
61
+ .. d2:: diagrams/architecture.d2
62
+ :caption: System architecture
63
+ :width: 80%
64
+ ```
65
+
66
+ ### Directive options
67
+
68
+ | Option | Type | Notes |
69
+ |--------|------|-------|
70
+ | `align` | `left`/`center`/`right` | figure/image alignment |
71
+ | `alt` | text | alt text (also the man/text placeholder) |
72
+ | `caption` | text | wraps the node in a `figure` with a caption (inline roles/xrefs ok) |
73
+ | `class` | class list | extra CSS/LaTeX classes |
74
+ | `width` | length or `%` | figure width (translated to a LaTeX length for PDF) |
75
+ | `name` | text | cross-reference target |
76
+ | `theme` | nonneg int | base light theme id |
77
+ | `dark_theme` | nonneg int | dark fallback theme id (overrides `d2_dark_theme`) |
78
+ | `layout` | text | `dagre`/`elk` (+ any configured extra engine) |
79
+ | `pad` | positive int | px padding |
80
+ | `sketch` | bool | hand-drawn look |
81
+ | `scale` | float | render scale (only applied when `> 0`) |
82
+ | `center` | bool | center in the viewbox |
83
+ | `format` | `svg`/`png`/`pdf`/`gif` | per-directive override of the resolved format |
84
+ | `target` | text | board selector (`--target`), e.g. `layers.x.*` |
85
+ | `animate_interval` | positive int | multi-board animated SVG/GIF interval (ms) |
86
+
87
+ Boolean options follow the common convention: a bare flag or
88
+ `1`/`true`/`yes`/`on` ⇒ `True`; `0`/`false`/`no`/`off` ⇒ `False`.
89
+
90
+ ## Configuration
91
+
92
+ Every directive option has a `d2_<option>` config default; the directive value
93
+ wins over the config default.
94
+
95
+ | Config value | Default | Notes |
96
+ |--------------|---------|-------|
97
+ | `d2_binary` | `"d2"` | binary path (argv[0]; invoked with `shell=False`) |
98
+ | `d2_theme` | `0` | base light theme id |
99
+ | `d2_dark_theme` | `200` | dark fallback theme id |
100
+ | `d2_layout` | `"dagre"` | default layout engine |
101
+ | `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`) |
102
+ | `d2_pad` | `100` | px padding |
103
+ | `d2_sketch` | `False` | hand-drawn look |
104
+ | `d2_scale` | `-1` | `--scale` appended only when `> 0` |
105
+ | `d2_center` | `False` | center in the viewbox |
106
+ | `d2_class` | `""` | default extra class |
107
+ | `d2_redirect_links_to_blank_page` | `True` | SVG `<a>` `target="_blank"` for non-external links |
108
+ | `d2_animate_interval` | `0` | multi-board animated SVG/GIF interval (ms) |
109
+ | `d2_timeout` | `120` | `--timeout=<n>` and the subprocess timeout |
110
+ | `d2_fonts` | `{}` | `{role: ttf-path}` (`regular`/`italic`/`bold`/`semibold`/`mono`/…) |
111
+ | `d2_multiboard_svg` | `"animate"` | SVG multi-board policy: `animate`/`root`/`error` |
112
+ | `d2_builder_formats` | `{}` | per-builder format overrides (merged over the defaults) |
113
+ | `d2_default_format` | `"svg"` | fallback for builders not in the map |
114
+ | `d2_use_theme_core_palette` | `True` | build custom theme-overrides from the core palette |
115
+ | `d2_match_page_background` | `True` | match the d2 canvas (`N7`) to the resolved page color |
116
+ | `d2_dark_theme_overrides` | `{}` | user dict deep-merged over the generated block |
117
+ | `d2_dark_html` | `False` | opt-in to apply dark theme-overrides in HTML builds |
118
+ | `d2_allowed_link_schemes` | `None` | `None` = all schemes except denylist `{javascript, data, vbscript}` |
119
+ | `d2_latex_max_width` | `\linewidth` | default width cap for the LaTeX visitor |
120
+ | `d2_strict` | `False` | when `True`, a d2 failure raises instead of warn+skip |
121
+
122
+ ## Per-builder output formats
123
+
124
+ Each builder gets a sensible default; override any of them via
125
+ `d2_builder_formats`.
126
+
127
+ | Builder | Default format |
128
+ |---------|----------------|
129
+ | `html` / `dirhtml` / `singlehtml` | `svg` |
130
+ | `epub` | `svg` |
131
+ | `latex` | `pdf` |
132
+ | `confluence` | `png` (experimental) |
133
+ | (any other) | `d2_default_format` (`svg`) |
134
+
135
+ ```python
136
+ d2_builder_formats = {"latex": "png", "epub": "png"}
137
+ ```
138
+
139
+ HTML-family builders can only embed `svg`/`png`. A per-directive `:format:`
140
+ that the active builder cannot embed (e.g. `pdf`/`gif` on an HTML builder) is
141
+ **validated once the builder is known**: the extension logs a build warning and
142
+ falls back to a safe embeddable format (SVG for HTML, PDF for LaTeX) rather than
143
+ emitting a broken diagram. Under `d2_strict = True` the mismatch raises a
144
+ `D2Error` instead.
145
+
146
+ ## Dark mode & theme-core integration
147
+
148
+ d2's `--dark-theme` only affects a browser in dark mode; a static PDF needs an
149
+ explicit theme. So for dark PDF/SVG this extension computes a custom d2
150
+ `theme-overrides` block from the theme-core's resolved dark palette and injects
151
+ it on top of the base `--theme=<d2_dark_theme>` (default `200`). When the core
152
+ is absent or dark mode is inactive it falls back to that base dark theme id.
153
+
154
+ Integration is **soft**: the core import is guarded on `ImportError` only, so a
155
+ version-skew (core present but an expected API missing) surfaces as a warning
156
+ rather than silently degrading to "core absent". At startup the extension logs
157
+ whether palette integration is active.
158
+
159
+ You can also ship a hand-authored `arch_dark.d2` next to `arch.d2`; in a dark
160
+ LaTeX build the core swaps the directive argument to the `_dark` file. When a
161
+ swap occurs the auto-generated `theme-overrides` are skipped so the hand-authored
162
+ file speaks for itself.
163
+
164
+ > **theme-core version floor.** The four integration APIs (`get_dark_mode_context`,
165
+ > `is_dark_mode_active`, `register_dark_swap_directive`, `mark_image_dark_ready`)
166
+ > are exported since **1.1.9**, so the optional `theme` extra pins
167
+ > `doxtr-pdf-theme-core>=1.1.9`. (The original plan tentatively named `>=1.1.10`;
168
+ > 1.1.9 is the earliest release verified to export all four.)
169
+
170
+ ## Links in diagrams
171
+
172
+ d2 `link:` fields may carry Sphinx cross-references and arbitrary URLs. Three
173
+ kinds are supported:
174
+
175
+ - **Internal xrefs** — `link: :ref:\`target\`` (and `:doc:` / other domain
176
+ roles). Resolved to a builder-correct relative URI in HTML. On the PDF path
177
+ internal xrefs are **dropped** (the label is kept, but a relative-file URI is
178
+ meaningless inside a single PDF).
179
+ - **`sphinxcontrib-xlink`** — `link: :xlink:\`link-id\`` resolves to an external
180
+ URL via the `xlink` config map. Degrades gracefully (warn + drop) when xlink
181
+ is not installed.
182
+ - **Raw / custom-scheme URLs** — any `link: <uri>` passes through
183
+ scheme-agnostically: `https:`, `mailto:`, `obsidian://`,
184
+ `x-devonthink-item://…`, etc. External/custom-scheme links become clickable
185
+ `/URI` annotations in the PDF too.
186
+
187
+ **Security:** `javascript:` / `data:` / `vbscript:` URIs are an XSS vector in SVG
188
+ output and are **stripped with a warning by default**. Configure
189
+ `d2_allowed_link_schemes` to change the policy (e.g. `["https", "mailto"]` to
190
+ restrict to an allowlist, or include `"data"` to opt back in).
191
+
192
+ > **Own-line constraint.** A `link:` carrying a role must be on its **own line**:
193
+ >
194
+ > ```d2
195
+ > node: {
196
+ > link: :ref:`my-target`
197
+ > }
198
+ > ```
199
+ >
200
+ > `x: { link: :ref:\`t\` }` on a single line is **not** rewritten.
201
+
202
+ By default SVG links get `target="_blank"` (configurable via
203
+ `d2_redirect_links_to_blank_page`).
204
+
205
+ ## Multi-board compositions
206
+
207
+ d2 compositions (`layers` / `scenarios` / `steps`) declare multiple boards. The
208
+ output must always be a **single file**, never a directory, so the extension
209
+ picks a single-file representation per format:
210
+
211
+ - **SVG** — a multi-board SVG written to `foo.svg` becomes a *directory* in d2.
212
+ To avoid that, `d2_multiboard_svg` selects the policy:
213
+ - `"animate"` (default) — render an animated SVG (`--animate-interval`).
214
+ - `"root"` — render the root board only.
215
+ - `"error"` — warn and fall back to the root board.
216
+ A `:target:` always selects a specific board and takes precedence.
217
+ - **PDF** — a multi-board diagram renders to a multipage PDF (one board per
218
+ page). `\includegraphics` embeds **page 1 only**, so set `:target:` to choose
219
+ a specific board; without it the extension warns and shows the first board.
220
+ - **GIF** — an animated multi-board GIF is the natural single-file form.
221
+
222
+ ## LaTeX / PDF notes
223
+
224
+ - PDF output requires **LuaLaTeX**.
225
+ - A raw d2 PDF (~203 mm) overflows the text block, so the LaTeX visitor always
226
+ emits `width=\linewidth,height=0.9\textheight,keepaspectratio` (width from
227
+ `d2_latex_max_width`; a `:width:` value, including `%`, is translated to a
228
+ LaTeX length).
229
+ - d2 PDFs embed real clickable `/URI` link annotations, so external/custom-scheme
230
+ links survive into the PDF. Internal `:ref:` cross-references are an HTML-only
231
+ feature (a relative-file URI is meaningless inside a single PDF).
232
+
233
+ ## Layout engines
234
+
235
+ d2 ships two free layout engines, `dagre` (default) and `elk`. A licensed engine
236
+ such as `tala` must be **whitelisted** by name in `d2_extra_layouts` so a typo'd
237
+ engine is caught rather than silently handed to d2:
238
+
239
+ ```python
240
+ d2_extra_layouts = ["tala"] # then :layout: tala is accepted
241
+ ```
242
+
243
+ A resolved layout engine outside `{dagre, elk} ∪ d2_extra_layouts` is rejected:
244
+ the extension logs a build warning and falls back to d2's default engine (or
245
+ raises a `D2Error` under `d2_strict`).
246
+
247
+ ## Replacing the renderer
248
+
249
+ Everything that shells out to `d2` lives behind a single indirection,
250
+ `doxtr_d2.render.render_backend` (which defaults to
251
+ `doxtr_d2.render.render_d2`). The builder visitors never call `render_d2`
252
+ directly — they call `render.render_backend(...)`. A child theme can therefore
253
+ replace **all** rendering by reassigning that one value, instead of overriding
254
+ every per-builder visitor:
255
+
256
+ ```python
257
+ import doxtr_d2.render as render
258
+
259
+ def my_render_backend(stdin_bytes, fmt, resolved_options, image_dir, **kwargs):
260
+ # e.g. call a d2 HTTP service or a different diagram tool.
261
+ # Contract: write the artifact under image_dir and return its filename
262
+ # RELATIVE to image_dir; raise doxtr_d2.errors.D2Error on failure.
263
+ ...
264
+ return "d2-<hash>.%s" % fmt
265
+
266
+ render.render_backend = my_render_backend
267
+ ```
268
+
269
+ Keep the signature and return/raise contract identical to `render_d2`
270
+ (`(stdin_bytes, fmt, resolved_options, image_dir, *, binary, fonts, timeout,
271
+ cwd, post_process) -> out_name`) and the visitors, caching key, and atomic-write
272
+ guarantees all continue to work unchanged.
273
+
274
+ ## License
275
+
276
+ MIT — see [LICENSE](LICENSE).