TASKVEGAS / documentation
Diagram export workflow
Selected pinned upstream documentation. Read its original repository guide for setup, costs and availability.
Unmodified upstream reference text. Treat instructions in this document as source material, not authorization to act.
- Repository
- cathrynlavery/diagram-design
- Source revision
- 3efa45939195092b1a50513e30a2521f6190e3f7
- License
- MIT
- Text range
- 0–16000 of 28667 characters
Read the pinned upstream source
License and notices: Pinned notice · Pinned notice
# Export to PNG / SVG
Convert a generated diagram HTML file into a portable `.svg` and/or `.png` next to it. **Manual only — never run unprompted.**
## Trigger
Load this file when:
- The user invokes `/diagram-design:export-diagram <html-file>` (the plugin's slash command — defined in `commands/export-diagram.md` at the repo root).
- The user asks in natural language to export, save, rasterize, convert, or download a diagram in `.svg` or `.png` form. Typical phrasings:
- "export this as PNG"
- "save as SVG"
- "give me a PNG of that diagram"
- "rasterize it"
- "convert to png and svg"
The slash command is a thin wrapper that delegates here — both paths run the same procedure below.
## Scope
Both formats are **diagram-only** — just the `<svg>` node. Editorial wrappers (header, summary cards, footer in `-full` variants) are intentionally dropped: the export deliverable is the diagram itself, suitable for Figma, slides, social cards, or blog images.
The SVG-only export keeps the source `<title>` and `<desc>` with the diagram. Their per-diagram and per-variant prefixed IDs keep accessible names unique when several figures share a page.
Inlining several exported SVGs in one host document also requires namespaced `<defs>` IDs (markers, patterns, gradients, filters, clip paths, masks, symbols). The export procedure prefixes those IDs with the source file slug and rewrites matching `url(#…)` / `href="#…"` references, so a light figure next to its `-dark` twin does not silently share arrowheads. The accessible-name guarantee alone is not enough.
If the user explicitly asks for "a screenshot of the whole page including the cards", that's a different request — fall back to a normal full-page screenshot via the user's OS or browser.
## SVG export procedure
**Prefer the packaged helper.** From this skill's directory run:
```
python3 scripts/export_svg.py <html-file> [<out.svg>] [--system-fonts]
```
`--system-fonts` skips the Google Fonts `@import` (step 5) so the SVG makes no network request; use it when the skin's font source is `system` (see [style-guide.md § Font source](style-guide.md#font-source)). The diagram CSS still carries over.
Source read and output write failures return exit 2 with an error diagnostic. The viewport must contain four finite SVG numbers with positive width and height; empty or disabled viewports are refused. The class-without-style gate inspects nonempty actual `class` attributes; comments, visible text, empty class values, and `data-class` metadata do not require a stylesheet. Export selects a live SVG and carries live style blocks; HTML comments and script literals do not contribute diagrams or CSS. The selected SVG ends at its own closing tag, so nested icon `<svg>` elements stay inside it. Comments inside the selected SVG remain intact, except that XML forbids `--` inside a comment and a comment ending in `-`: each `--` becomes `- -` and a trailing `-` gets a space. That script is the source of truth for the transform below (CSS carry-forward, defs ID namespacing, rgba normalization, and the class-without-style gate). Reimplement only when the helper is unavailable; keep the behaviour identical.
### Manual algorithm (what the helper does)
1. Read the source HTML file.
2. Extract the **first** top-level `<svg ...>...</svg>` element. Skip HTML comments, `<script>` and `<style>` content, then count `<svg` openings and `</svg>` closings (a self-closing `<svg .../>` does not open one) and stop when the count returns to zero. Read quoted attribute values whole so a `>` or `/>` inside one does not end the tag. A non-greedy regex stops at a nested icon's `</svg>` and truncates the diagram. Most generated diagrams have only one top-level SVG; if there are multiple, the first is the diagram (gallery files are an exception — see *Edge cases*).
3. Make it standalone:
- Ensure the opening tag has `xmlns="http://www.w3.org/2000/svg"`. Add it if missing. The SVG namespace must belong to the root SVG element; a namespace on a descendant does not satisfy it. An explicit incompatible root namespace is refused.
- Ensure a `viewBox` is present. The skill's templates always include one; warn the user if absent rather than guessing.
- Preserve `role="img"`, `aria-labelledby`, and the first-child `<title>` / `<desc>` exactly as authored.
- Rewrite HTML-only attribute syntax as XML: a valueless attribute (`<g data-motion-item>`) becomes `data-motion-item=""`, and an unquoted value gets double quotes. Known named HTML character references in labels and attributes become equivalent XML-safe text (for example, ` ` becomes a nonbreaking space). HTML's legacy names such as `©` and ` ` also decode without the semicolon, except inside an attribute value when the next character is a letter, digit, or `=`, as browsers do. Decode once and escape XML delimiters; already escaped references remain escaped. Comments, CDATA and raw style/script content stay as written; style/script opening-tag attributes follow the same reference conversion as other attributes.
- Make comments valid XML. Inside each `<!-- ... -->` outside CDATA, turn every `--` into `- -` and add a space after a body that ends in `-`. Refuse a comment that a browser closes before the next `-->` (`<!-->`, `<!--->`, or one containing `--!>`): the markup after it is live, and rewriting the comment would hide it.
- Set `id="<slug>-root"` on the opening `<svg>` tag, where `<slug>` is the source basename without extension (e.g. `example-loop.html` → `example-loop`). This ID scopes carried CSS so several inlined figures do not leak rules into each other. When replacing an existing SVG root ID, retarget whole CSS ID tokens for that root in the carried CSS, including escaped identifier spellings; compound selectors such as `.diagram#original` remain bound to the root without adding another ancestor. Descendant IDs and quoted selector values remain unchanged; an unescaped dot belongs to a class selector, not to the ID token.
4. **Carry page CSS into the SVG.** Class-styled diagrams (the loop family, process, medallion, data-flow, and others) declare fills and type in the page `<style>` block — `.station`, `.hub`, `.node-name`, and so on. Extracting the bare `<svg>` without those rules yields black boxes. Copy the page's diagram rules into a `<style>` inside `<defs>`, then:
- Unwrap CDATA sections in SVG stylesheet text before parsing rules, then strip real CSS comments, so neither XML delimiters nor a comment becomes part of a selector. Preserve comment-looking text inside quoted strings and escapes: `[data-label="net/*retained*/revenue"]` must still match the original attribute value. Keep CDATA content as raw text rather than XML-decoding it; the carried copy is XML-escaped below. The original embedded stylesheet stays in the SVG. CDATA-looking text in an HTML stylesheet remains literal, including inside SVG `foreignObject`, `desc`, or `title` elements. A nested `<svg>` inside HTML switches back to SVG stylesheet handling.
- Re-scope `:root { … }` custom properties onto `#<slug>-root` so the figure keeps its own tokens.
- Start selectors that begin at the `<svg>` element at the root instead: `svg .zone` becomes `#<slug>-root .zone` and `svg text` becomes `#<slug>-root text`. The exported root element is the `<svg>` itself, so `#<slug>-root svg .zone` would match nothing.
- Split selector lists only at top-level commas. Preserve quoted and escaped attribute values, function arguments, and meaningful whitespace while scoping.
- Prefix every other kept selector with `#<slug>-root ` (e.g. `.station` → `#example-loop-root .station`).
- **Drop** page chrome: `*`, `html`, `body`, `main`, `h1`/`h2`/`h3`, `p`, `.frame`, `.eyebrow`, `.summary`, `.card(s)`, `.footer`, `.header`, and the bare `svg { min-width: … }` layout rule. Only the bare `svg` selector is layout; `svg .zone` and `svg text` are diagram rules (see above). Those must not follow a fragment.
- Carry `color` and `font-family` from the dropped `body` rule onto `#<slug>-root`. SVG content inherits both: `stroke="currentColor"` reads `color`, and text without its own font rule reads `font-family`.
- **XML-escape** the carried CSS text (`&` → `&`, `<` → `<`) before inserting it into the SVG. Rule bodies can contain XML-sensitive characters (e.g. `content: "R&D"`); a bare `&` makes the standalone file fail to parse.
5. Inject Google Fonts `@import` so the SVG renders with correct typography in a browser. **XML-escape the `&` separators as `&`** — a standalone `.svg` is parsed as strict XML, where a bare `&` starts an entity reference and makes the whole file fail to parse. (Don't copy the raw URL from the HTML `<link href>`; that ampersand form is only valid in HTML.) Merge into the same `<defs>` `<style>` as the carried rules (don't add a second `<defs>`). If a new `<defs>` is needed, insert it after the existing leading `<title>` and `<desc>` so the first-child accessible-title contract is preserved:
```svg
<defs>
<style>@import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500;600&family=Noto+Serif:ital@0;1&family=Noto+Sans+KR:wght@400;500;600&family=Noto+Serif+KR:wght@400&family=Noto+Sans+TC:wght@400;500;600&family=Noto+Serif+TC:wght@400&display=swap');
/* …scoped diagram rules… */
</style>
<!-- existing markers / patterns stay here -->
</defs>
```
6. **Namespace `<defs>` IDs.** Prefix every referenceable defs ID — on `marker`, `pattern`, `linearGradient`, `radialGradient`, `filter`, `clipPath`, `mask`, `symbol`, and reusable graphics (`g`, `path`, `rect`, `circle`, `ellipse`, `line`, `polyline`, `polygon`, `text`, `image`, and `use`) — with `<slug>-`, and rewrite matching `url(#…)` and `href="#…"` / `xlink:href="#…"` references. Compare ID and fragment values after XML character-reference decoding, so equivalent named and numeric references stay connected. For recognized local CSS URL forms (literal `url(...)`, case-insensitive) in paint/reference attributes, inline styles, and stylesheets, decode hex escapes before comparing fragment identity, and preserve the original escape spelling when adding the prefix; `url(#\70 aint)` must stay connected to the namespaced `paint` definition. URL-looking metadata and quoted CSS string contents remain literal. Retarget ID selectors in embedded style rules while leaving declaration colors and unrelated IDs unchanged. Preserve URL quotes and match fragment IDs case-sensitively (the CSS `url` keyword remains case-insensitive). Rewrite **longest-id-first** so `arrow-accent` is not clipped by a shorter `arrow` rule. Quoted local URLs (`url("#arrow")`, `url('#arrow')`, including XML-escaped quotes in attributes) follow the same rewrite as unquoted URLs. Unrelated fragment IDs and non-local URLs remain unchanged. Example: `id="arrow"` in `example-loop.html` becomes `id="example-loop-arrow"` with `marker-end="url(#example-loop-arrow)"`.
7. Normalize colors for strict SVG 1.1 consumers. This design system's tokens are authored as `rgba(...)` (see `style-guide.md`) and render correctly wherever colors are read as CSS — browsers, Figma, Illustrator. PowerPoint's SVG importer does not: it treats `rgba(...)` and `transparent` as unrecognized and paints them **opaque black**, turning a barely-there tint into a solid block that swallows the label inside it. Normalize presentation paint only when its alpha can be preserved: combine a local presentation opacity, but retain RGBA when a CSS or inherited opacity, or a paint override on the element or its children, makes splitting unsafe. An unrelated simple CSS rule does not prevent normalization. Apply this full recipe before writing the file:
```python
import re
import math
import html as html_entities
import xml.etree.ElementTree as ET
from typing import Callable
CSS_COMMENT_RE = re.compile(r"/\*.*?\*/", re.DOTALL)
XML_OPAQUE_OPEN_RE = re.compile(r"<!--|<!\[CDATA\[")
START_TAG_RE = re.compile(
r"<([A-Za-z][\w:.-]*)"
r"((?:\s+[^\s\"'<>/=]+(?:\s*=\s*(?:\"[^\"]*\"|'[^']*'|[^\s\"'=<>`]+))?)*)"
r"(\s*/?)>"
)
TAG_ATTR_RE = re.compile(
r"(\s+)([^\s\"'<>/=]+)(?:(\s*=\s*)(\"[^\"]*\"|'[^']*'|[^\s\"'=<>`]+))?"
)
STYLE_BLOCK_RE = re.compile(r"<style\b[^>]*>(.*?)</style\s*>", re.IGNORECASE | re.DOTALL)
CSS_RULE_ATOM = r'''(?:/\*(?:[^*]|\*(?!/))*\*/|\\[\s\S]|"(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*'|/(?!\*)|[^{}"'\\/])'''
RULE_RE = re.compile(rf"({CSS_RULE_ATOM}+)\{{({CSS_RULE_ATOM}*)\}}", re.DOTALL)
RGBA_ATTR_RE = re.compile(
r'(fill|stroke)="rgba\(\s*([+-]?\d+)\s*,\s*([+-]?\d+)\s*,\s*([+-]?\d+)\s*,\s*([+-]?\d*\.?\d+)\s*\)"'
)
def _rewrite_start_tags(svg: str, transform: Callable[[re.Match[str]], str]) -> str:
"""Transform actual start tags, preserving comments and CDATA sections."""
out: list[str] = []
pos = 0
while True:
opener = XML_OPAQUE_OPEN_RE.search(svg, pos)
text_end = opener.start() if opener else len(svg)
out.append(START_TAG_RE.sub(transform, svg[pos:text_end]))
if opener is None:
break
closer = "-->" if opener.group(0) == "<!--" else "]]>"
close_at = svg.find(closer, opener.end())
stop = len(svg) if close_at == -1 else close_at + len(closer)
out.append(svg[opener.start() : stop])
pos = stop
if close_at == -1:
break
return "".join(out)
def _decoded_css_property_text(style: str) -> str:
"""Read opacity spellings conservatively without changing carried CSS."""
style = CSS_COMMENT_RE.sub("", html_entities.unescape(style))
def escape(match: re.Match[str]) -> str:
hexadecimal, literal = match.groups()
if hexadecimal is not None:
value = int(hexadecimal, 16)
return chr(value) if 0 < value <= 0x10FFFF and not 0xD800 <= value <= 0xDFFF else "\ufffd"
return "" if literal in "\r\n\f" else literal
return re.sub(r"\\(?:([0-9a-fA-F]{1,6})(?:\r\n|[ \t\r\n\f])?|([\s\S]))", escape, style)
def _css_paint_may_target_descendants(selector: str, children: list[ET.Element]) -> bool:
"""Exclude provably unrelated simple selectors; preserve unknown CSS cascades."""
for branch in selector.split(","):
terminal = re.split(r"[\s>+~]+", branch.strip())[-1]
# Unsupported selectors remain conservative, including escaped names,
# attribute selectors and pseudo-classes that can affect descendant paint.
if not re.fullmatch(r"(?:[A-Za-z][\w-]*|\*)?(?:[.#][\w-]+)*", terminal) or not terminal:
return True
tag = re.match(r"^[A-Za-z][\w-]*", terminal)
classes = re.findall(r"\.([\w-]+)", terminal)
ids = re.findall(r"#([\w-]+)", terminal)
for child in children:
if tag and child.tag.rsplit("}", 1)[-1] != tag.group(0):
continue
if all(name in child.get("class", "").split() for name in classes) and all(
name == child.get("id") for name in ids
):
return True
return False
def normalize_rgba_presentation_attrs(svg: str) -> str:
"""Split rgba paint and combine its alpha with existing presentation opacity."""
# A parent's paint and opacity may be inherited independently. Converting
# its color alpha into opacity would also change children overriding paint.
try:
elements = list(ET.fromstring(svg).iter())
descendants = {
index: list(element.iter())[1:]
for index, element in enumerate(elements) if len(element)
}
except ET.