SVG in Markdown

VERSION a9b38ce
SVG in Markdown

Showing an SVG (a diagram, a formula, a chart) inside a node's Markdown, the same way in the web UI, meshfox tui, meshfox static and meshfox pdf — with no renderer built into meshfox: any command that can print an SVG will do (Graphviz, Typst, Mermaid, PlantUML, Vega, matplotlib, ...).

An SVG reaches the page as an ordinary Markdown image whose URL is a data:image/svg+xml;base64,… URI, so nothing has to sit next to the canvas. There are two ways to produce it, both on a cached block so the picture is saved in the file under the fence:

  • output="markdown" — the block's stdout is Markdown; the script prints the ![alt](data:…) line itself (as in pandas-dataframe.canvas.md). Full control, a little boilerplate. See output=markdown below.
  • output="image" — the block's stdout is the SVG; meshfox wraps it into that image line. One-liners like plantuml -tsvg -pipe just work. See output=image below.

Two more things are covered further down: how an SVG follows the light/dark theme, and the bg= background for diagrams drawn for a white page.

Every block below is self-contained (it prints a small hand-written SVG), so this file runs without any extra tools — except the Real renderers node, whose blocks need the named tool installed (the Python one creates its own virtualenv on first run).

output=markdown

The script prints the whole Markdown image itself. base64 | tr -d '\n' is the portable spelling (GNU base64 wraps lines at 76 columns, the macOS one doesn't).

SVG='<svg xmlns="http://www.w3.org/2000/svg" width="160" height="80" viewBox="0 0 160 80"><rect x="4" y="4" width="152" height="72" rx="10" fill="#ffd166" stroke="#444" stroke-width="3"/><text x="80" y="47" text-anchor="middle" font-family="sans-serif" font-size="18" fill="#222">output=markdown</text></svg>'
echo "Anything Markdown can go around the picture — a **caption**, a table, ..."
echo
printf '![hand-made card](data:image/svg+xml;base64,%s)\n' "$(printf '%s' "$SVG" | base64 | tr -d '\n')"

Anything Markdown can go around the picture — a caption, a table, ...

hand-made card

output=image

stdout is the SVG and nothing else; meshfox turns it into the image. No base64, no Markdown. stderr (warnings, progress) is still captured separately and shown as its own block above the picture.

echo "this goes to stderr, shown above the image" >&2
cat <<'EOF'
<svg xmlns="http://www.w3.org/2000/svg" width="160" height="80" viewBox="0 0 160 80">
  <rect x="4" y="4" width="152" height="72" rx="10" fill="#9bd1a5" stroke="#444" stroke-width="3"/>
  <text x="80" y="47" text-anchor="middle" font-family="sans-serif" font-size="18" fill="#222">output=image</text>
</svg>
EOF
this goes to stderr, shown above the image

plain-svg

If the block fails, or prints something that isn't an SVG, there is no broken image: you see what it printed, as plain text (and, for a failure, the exit code).

echo "the renderer said: unexpected token on line 3"
the renderer said: unexpected token on line 3
Theme

An SVG is shown through an <img> (web) or rasterized (TUI), where it can't see the page's CSS — so a diagram drawn in black vanishes on a dark theme. Right before showing it, meshfox rewrites the SVG's text (the file keeps exactly what the tool printed) so it can follow the theme, in two ways:

  • currentColor becomes the theme's text color;
  • var(--mf-fg), var(--mf-bg), var(--mf-accent), var(--mf-border) become the theme's colors. Write a fallback — var(--mf-fg, #000) — and a viewer that knows nothing about meshfox (GitHub, a plain browser) still draws sensible colors.

Switch the web UI's theme (or look at the TUI, which is always dark) and compare these three. The first one is the problem; the other two are the fix.

Hard-coded black — doesn't follow anything:

cat <<'EOF'
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="56" viewBox="0 0 200 56">
  <rect x="2" y="2" width="196" height="52" rx="8" fill="none" stroke="#000" stroke-width="2"/>
  <text x="100" y="33" text-anchor="middle" font-family="sans-serif" font-size="16" fill="#000">hard-coded black</text>
</svg>
EOF

hardcoded

currentColor — the text color of the theme:

cat <<'EOF'
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="56" viewBox="0 0 200 56">
  <rect x="2" y="2" width="196" height="52" rx="8" fill="none" stroke="currentColor" stroke-width="2"/>
  <text x="100" y="33" text-anchor="middle" font-family="sans-serif" font-size="16" fill="currentColor">currentColor</text>
</svg>
EOF

current-color

--mf-* variables with fallbacks — a themed card with an accent dot:

cat <<'EOF'
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="56" viewBox="0 0 200 56">
  <rect x="2" y="2" width="196" height="52" rx="8" fill="var(--mf-bg, #fff)" stroke="var(--mf-border, #ccc)" stroke-width="2"/>
  <circle cx="22" cy="28" r="8" fill="var(--mf-accent, #ea580c)"/>
  <text x="40" y="33" font-family="sans-serif" font-size="16" fill="var(--mf-fg, #000)">--mf-* variables</text>
</svg>
EOF

mf-vars

Only the SVG's text is rewritten, and only for display; a tool's own explicit colors (fill="#ffd166") are never touched. The SVG still goes through <img>, so scripts inside it stay inert.

Background

Many tools draw for a white page: transparent background, dark text, fixed colors that currentColor/--mf-* can't reach (Mermaid, PlantUML, a Typst formula). On a dark theme that is unreadable. Rather than a global rule, you pick a backing per block — bg= accepts #rgb, #rrggbb or transparent, and nothing else (not free-form CSS; the same few words work in the web UI, the TUI, the static site and the PDF).

With output="image", put it in the fence's output-attrs= — the image-attribute syntax (width=/height= as an integer or NN%, bg=), forwarded onto the generated image:

cat <<'EOF'
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="56" viewBox="0 0 200 56">
  <rect x="2" y="2" width="196" height="52" rx="8" fill="none" stroke="#000" stroke-width="2"/>
  <text x="100" y="33" text-anchor="middle" font-family="sans-serif" font-size="16" fill="#000">black on bg=#fff</text>
</svg>
EOF

on-white

Combined with a size (% is the only form the TUI honours — a terminal has no pixel grid):

cat <<'EOF'
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="112" viewBox="0 0 400 112">
  <rect x="4" y="4" width="392" height="104" rx="14" fill="#cfe8ff" stroke="#1d4e89" stroke-width="4"/>
  <text x="200" y="66" text-anchor="middle" font-family="sans-serif" font-size="30" fill="#1d4e89">width=50% bg=#fff</text>
</svg>
EOF

on-white-half

transparent says "no backing" explicitly (the default anyway):

cat <<'EOF'
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="56" viewBox="0 0 200 56">
  <rect x="2" y="2" width="196" height="52" rx="8" fill="none" stroke="currentColor" stroke-width="2"/>
  <text x="100" y="33" text-anchor="middle" font-family="sans-serif" font-size="16" fill="currentColor">bg=transparent</text>
</svg>
EOF

see-through

With output="markdown" — or in any hand-written Markdown — it's the same syntax straight after the image, no space: ![alt](url){bg=#fff} (also {width=50%}, {width=50% bg=#fff}):

SVG='<svg xmlns="http://www.w3.org/2000/svg" width="200" height="56" viewBox="0 0 200 56"><rect x="2" y="2" width="196" height="52" rx="8" fill="none" stroke="#000" stroke-width="2"/><text x="100" y="33" text-anchor="middle" font-family="sans-serif" font-size="16" fill="#000">{bg=#fff} in Markdown</text></svg>'
printf '![black on white](data:image/svg+xml;base64,%s){bg=#fff}\n' "$(printf '%s' "$SVG" | base64 | tr -d '\n')"

black on white

meshfox validate rejects an output-attrs= that isn't in this grammar (bg=white, color=red) and one used without output="image".

Real renderers

The same output="image" recipe with real tools. None of them is bundled with meshfox, so each block first checks that its tool is on PATH and says so if not — pick the ones you use. All of them print an SVG to stdout, which is all output="image" needs; the bg=#fff is there because these tools draw for a white page (see Background).

Graphviz — a graph from dot:

command -v dot >/dev/null || { echo "graphviz is not installed"; exit 1; }
dot -Tsvg <<'EOF'
digraph { rankdir=LR; node [shape=box, style=rounded]; canvas -> block -> svg -> picture }
EOF

Typst — a formula, rendered by Typst's own math engine. Its glyphs are outlines, so no font is needed to view it. Typst's math syntax, not LaTeX's:

command -v typst >/dev/null || { echo "typst is not installed"; exit 1; }
typst compile --format svg - - <<'EOF'
#set page(width: auto, height: auto, margin: 8pt)
$ integral_0^oo e^(-x^2) dif x = sqrt(pi) / 2 $
EOF

Mermaid (npm i -g @mermaid-js/mermaid-cli; needs a headless Chromium) — mmdc writes the SVG where -o says, here stdout:

command -v mmdc >/dev/null || { echo "mermaid-cli is not installed (npm i -g @mermaid-js/mermaid-cli)"; exit 1; }
mmdc -q -i - -o /dev/stdout -e svg <<'EOF'
flowchart LR
  A[Block] -->|prints SVG| B(output=image) --> C{{Picture}}
EOF

Mermaid, without a browser — Pele is a small pure-JS Mermaid renderer: no DOM, no Chromium, a few milliseconds per diagram. It is young (October 2026) and draws with its own layout and style, so a diagram can look different from mmdc's. It is an npm package, so the block needs it installed: node-env.canvas.md shows the whole thing — a @node_env block that installs pele into the canvas's own node_modules, and a plain import { render } from "pele" block with output="image" that prints the SVG.

PlantUML (needs Java) — -pipe reads stdin and writes stdout:

command -v plantuml >/dev/null || { echo "plantuml is not installed"; exit 1; }
plantuml -tsvg -pipe <<'EOF'
@startuml
Alice -> Bob: SVG, please
Bob --> Alice: <svg ...>
@enduml
EOF

Vega-Lite (npm i -g vega-lite vega-cli) — a chart: vl2vg compiles the spec, vg2svg draws it:

command -v vl2vg >/dev/null && command -v vg2svg >/dev/null || { echo "vega is not installed (npm i -g vega-lite vega-cli)"; exit 1; }
vl2vg <<'EOF' | vg2svg
{
  "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
  "data": {"values": [{"x": "a", "y": 3}, {"x": "b", "y": 7}, {"x": "c", "y": 5}]},
  "mark": "bar",
  "encoding": {"x": {"field": "x", "type": "nominal"}, "y": {"field": "y", "type": "quantitative"}}
}
EOF

matplotlib — savefig(..., format="svg") to a buffer, printed as the SVG (compare the PNG-in-data-URL version in pandas-dataframe.canvas.md). One self-contained block: it creates a private virtualenv next to the canvas on the first run (the same .meshfox/<canvas>.venv place meshfox's @python_venv would use), installs matplotlib into it, and runs the script there. All of that setup chatter goes to stderr, so stdout stays the SVG alone:

set -euo pipefail
VENV=".meshfox/svg.canvas.md.venv"
[ -x "$VENV/bin/python" ] || python3 -m venv "$VENV" >&2
"$VENV/bin/python" -c "import matplotlib" 2>/dev/null || "$VENV/bin/python" -m pip install -q --disable-pip-version-check matplotlib >&2
"$VENV/bin/python" - <<'PY'
import io

import matplotlib

matplotlib.use("Agg")  # render to a buffer; no display needed
import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(4, 2.2))
ax.bar(["a", "b", "c"], [3, 7, 5], color="#5b8def")
fig.tight_layout()
buf = io.StringIO()
fig.savefig(buf, format="svg")
print(buf.getvalue())
PY

matplotlib