Metadata-Version: 2.4
Name: doxtr-d2
Version: 0.1.0
Summary: Sphinx extension that renders d2 diagrams, with first-class doxtr-pdf-theme-core PDF integration.
Author: Jens Frey
Project-URL: Homepage, https://github.com/doxtr/doxtr-d2
Classifier: Programming Language :: Python :: 3
Classifier: Framework :: Sphinx :: Extension
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: sphinx>=5.0
Provides-Extra: theme
Requires-Dist: doxtr-pdf-theme-core>=1.1.9; extra == "theme"
Provides-Extra: xlink
Requires-Dist: sphinxcontrib-xlink; extra == "xlink"
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: pytest-cov>=4.0; extra == "dev"
Requires-Dist: doxtr-pdf-theme-core>=1.1.9; extra == "dev"
Requires-Dist: sphinxcontrib-xlink; extra == "dev"
Dynamic: license-file

# doxtr-d2

A Sphinx extension that renders [d2](https://d2lang.com/) diagrams via the
external `d2` binary, with first-class
[`doxtr-pdf-theme-core`](https://github.com/doxtr/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 [`d2`](https://d2lang.com/tour/install) binary on `PATH`
  (or point `d2_binary` at it). Verified against **d2 v0.9.0**.
- For PDF output: **LuaLaTeX** (the theme core forces `lualatex`; graphicx/luatex
  embed PDF natively — no `epstopdf` needed).

## Install

```bash
pip install doxtr-d2
```

Optional extras:

```bash
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`:

```python
extensions = [
    # ...
    "doxtr_d2",
]
```

## Usage

Inline d2 script:

```rst
.. 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.

```rst
.. 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`) |

```python
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 optional `theme` extra pins
> `doxtr-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 the `xlink` 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
  `/URI` annotations 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**:
>
> ```d2
> 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.svg` becomes a *directory* in d2.
  To avoid that, `d2_multiboard_svg` selects 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). `\includegraphics` embeds **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 from
  `d2_latex_max_width`; a `:width:` value, including `%`, is translated to a
  LaTeX length).
- d2 PDFs embed real clickable `/URI` link 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:

```python
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:

```python
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](LICENSE).
