Files
g4borg 61af13521c 🤖 Restructure SVG skill: extract references, add legibility rules
Extract inline SVG rules to references/inline-svg.md for progressive
disclosure. Add references/layout-legibility.md with 7 rules for
readable SVG output (text sizing, contrast, margins, chart patterns,
palettes) based on concrete failure modes from issue #4.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-19 20:46:12 +02:00

8.2 KiB

Inline SVG in Markdown — Per-Renderer Rules

Read this document only when the user explicitly asks for inline SVG (e.g., "embed it inline", "put the SVG directly in the markdown"). Pick the rule set that matches the target renderer. If the target is unknown, ask.

Rule 1: Embed as a raw HTML block, never as a fenced code block

The two paths look similar but produce completely different output.

  • Embed SVG as a raw HTML block. Place <svg …>…</svg> directly in the Markdown source, with a blank line before and after. CommonMark / GFM treat that as a raw HTML block and pass it through verbatim; the browser then parses what survives the sanitizer as real SVG.
  • Never wrap renderable content in ```svg (or ```html, ```xml) fences. Fenced code blocks are by definition "display this as text". The renderer wraps the contents in <pre><code>, HTML-escapes every <, >, and &, and hands the escaped text to a syntax highlighter. The browser sees a string, not markup — no SVG ever gets parsed.
  • Don't assume non-standard "render this code block" extensions exist. Some renderers (GitLab, certain Obsidian plugins, custom static-site setups) special-case ```mermaid or ```svg to render. This is non-standard. GitHub, VS Code's default preview, and CommonMark do not. Default to the raw-HTML-block path.
  • Same rule applies to other inline-renderable HTML. <details>/<summary>, <picture>, <video>, raw <table> with attributes Markdown can't express — all use the raw-HTML-block path, never a fence.

Diagnostic: "I see SVG source code as text" → fence problem. "I see blank space or default-styled shapes" → sanitizer/styling problem (see Rule 2).

Rule 2: Per-renderer element / attribute rules

Element / feature GitHub inline .md Gitea (default) Obsidian Notes
<svg> stripped
<path> The most portable shape primitive.
<rect> Substitute with <path> rectangle on Gitea.
<circle> Substitute with <ellipse rx=R ry=R> (Obsidian only) or <path> arc circle.
<ellipse>
<line>, <polyline>, <polygon>
<text>, <tspan>
<g> Group inheritance dies on Gitea — repeat presentation attrs per element if cross-renderer matters.
<defs>, <linearGradient>, <radialGradient>, <stop>
<marker> Arrowheads only work on Obsidian inline.
<use> (empirically confirmed) Strip on Obsidian was unexpected; treat as not portable.
<style> element (empirically confirmed) Don't use it anywhere. CSS rules don't have a clear scope boundary so sanitizers strip them as injection risk.
<title> element (empirically confirmed)
<foreignObject> (empirically confirmed)
<script>, on* event handlers, javascript: URLs Always stripped, every renderer. Don't use.
class= attribute (for styling) Even when class survives, the <style> block defining the rules doesn't, so styling is lost — use inline presentation attributes.
Inline presentation attributes (fill, stroke, stroke-width, font-size, font-family, text-anchor, stroke-dasharray, etc.) These survive on Obsidian and any sanitizer that allows SVG at all. They never apply on GitHub-inline / Gitea because the elements themselves are stripped.

Reading the table: an Obsidian-only target gives you most of SVG. A Gitea target restricts you to <svg> + <path> (no colors, no text, no groups). A GitHub-inline target gives you nothing — switch to the external-file recipe.

Rule 3: Surviving the sanitizer where SVG is allowed

When the renderer allows SVG (Obsidian, VS Code preview, some self-hosted Gitea with extended app.ini, and the standalone-.svg-file path on GitHub), structure the SVG to survive its sanitizer:

  • Use SVG presentation attributes inline on every element. fill, stroke, stroke-width, stroke-dasharray, font-size, font-weight, font-family, text-anchor. These can't escape the SVG and survive every sanitizer that allows SVG at all.
  • Don't rely on <style> blocks or class= for styling. <style> is stripped on every renderer's inline sanitizer (including Obsidian, empirically). Even if class= survives, the rules are gone, so styling is lost.
  • Prefer longhand over CSS shorthand. Use font-size, font-weight, font-family separately rather than the font: shorthand. The shorthand is CSS-only; longhand maps directly to SVG presentation attributes.
  • <defs> and id references are fine on renderers that allow them at all (Obsidian; not Gitea/GitHub-inline). <marker>, <linearGradient> referenced via url(#id) survive the Obsidian sanitizer.
  • Group-level inheritance works for font-family and fill (set on <g>, inherited by children) on Obsidian. Don't rely on it for cross-renderer; set the attribute on the leaf element.

The visible failure mode is silent: shapes still render, but with browser-default fill (usually black), no stroke, and default font sizing. On dark themes this looks like "nothing showed up"; on light themes like "a row of black blobs with no labels". There is no console error and no Markdown lint warning.

Rule 4: No blank lines inside inline SVG

pulldown-cmark (and most CommonMark parsers) terminate an HTML block at a blank line. Any content after the blank line — even if it's still inside <svg>...</svg> — is parsed as Markdown, not HTML. Indented SVG elements become fenced code blocks (<pre><code>); unindented ones become paragraphs wrapping escaped angle brackets.

This means the SVG source must be one unbroken block with no empty lines between <svg> and </svg>. Use comments (<!-- section: axes -->) instead of blank lines if you need visual separation inside the SVG source.

Diagnostic: "Part of my SVG renders but the rest appears as code or escaped text" → blank-line-induced block break. Remove all blank lines between <svg> and </svg>.

Minimal example (inline, Obsidian-friendly)

Some prose before the diagram.

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" width="200" height="80">
  <g font-family="sans-serif" font-size="12" fill="#222">
    <!-- inputs -->
    <rect x="10" y="20" width="80" height="40" fill="#e8f0fe" stroke="#1a73e8" stroke-width="2"/>
    <text x="50" y="45" text-anchor="middle">Input</text>
    <!-- arrow -->
    <line x1="90" y1="40" x2="130" y2="40" stroke="#1a73e8" stroke-width="2"/>
    <!-- outputs -->
    <rect x="130" y="20" width="60" height="40" fill="#fce8e6" stroke="#d93025" stroke-width="2"/>
    <text x="160" y="45" text-anchor="middle">Output</text>
  </g>
</svg>

Prose after.

Note: blank line before <svg>, blank line after </svg>, no blank lines inside the SVG (use <!-- comments --> for section separation), no fence, all styling via presentation attributes, font longhand on the group. This will render on Obsidian and similar permissive renderers. It will not render on GitHub .md. It will partially render on Gitea (the <svg> survives, the rest is stripped).

Pre-commit checklist (inline SVG)

  • Confirmed the target renderer allows SVG inline (not GitHub .md).
  • <svg> block has a blank line before and after, no surrounding ``` fence.
  • No blank lines between <svg> and </svg> (pulldown-cmark terminates the HTML block at blank lines).
  • No <style>, <title>, <use>, <foreignObject> elements (don't survive Obsidian).
  • No class= for styling, no CSS font: shorthand.
  • Every shape has explicit fill and (where applicable) stroke / stroke-width as inline attributes.
  • No <script>, on* handlers, or javascript: URLs.
  • width ≤ 750px unless asked otherwise.

If the target is Gitea specifically and inline is required:

  • Only <svg> + <path> elements used.
  • No fill, stroke, class, style, transform attributes (stripped by default policy).
  • OR confirmed the Gitea instance has [markup.sanitizer.N] extensions in app.ini permitting more.