scripts/export_svg.py
A supporting file of the diagram-design skill, shown as source.
#!/usr/bin/env python3
"""Export a diagram HTML file to a standalone, inline-safe SVG.
Ships inside the skill so an installed agent can produce a portable SVG without
re-deriving the transform:
python3 <skill-dir>/scripts/export_svg.py my-diagram.html
python3 <skill-dir>/scripts/export_svg.py my-diagram.html out.svg
Fixes the export gaps that make a fragment unsafe to open or inline:
1. Class-styled diagrams keep their page ``<style>`` rules by embedding a
scoped copy inside the SVG (otherwise every shape falls back to black).
2. Referenceable ``<defs>`` IDs (markers, patterns, gradients, …) are prefixed
with the file slug so several exported figures can share one host document
without ``url(#arrow)`` resolving to the wrong declaration.
3. HTML-only attribute syntax (``<g data-motion-item>``, ``data-step=1``) is
rewritten as XML so the standalone file parses.
The algorithm matches ``references/export.md``. No third-party deps.
"""
from __future__ import annotations
import argparse
import re
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
GOOGLE_FONTS_IMPORT = (
"@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');"
)
# Page chrome that must not follow a diagram fragment out of its host document.
# The bare `svg { width; min-width }` rule is page layout too (see
# is_chrome_selector); `svg .zone` and `svg text` are diagram rules.
CHROME_SELECTOR_RE = re.compile(
r"^(?:"
r"\*|html|body|main|header|footer|h1|h2|h3|p"
r"|\.frame|\.eyebrow|\.summary|\.cards?|\.card|\.footer|\.header"
r")(?:\s|:|,|$)",
re.IGNORECASE,
)
# A selector that starts at the <svg> element: `svg .zone`, `svg text`, `svg>g`.
SVG_TYPE_PREFIX_RE = re.compile(r"^svg(?![\w-])", re.IGNORECASE)
# Inherited properties the page sets on `body` that SVG content relies on:
# `stroke="currentColor"` reads `color`, and text without its own font rule
# reads `font-family`.
BODY_INHERITED_RE = re.compile(r"(?:^|;)\s*(color|font-family)\s*:\s*([^;]+)", re.IGNORECASE)
CSS_COMMENT_RE = re.compile(r"/\*.*?\*/", re.DOTALL)
# HTML-only attribute syntax that strict XML rejects: a valueless attribute
# (`<g data-motion-item>`) or an unquoted value (`data-step=1`).
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\"'=<>`]+))?"
)
DEFS_ID_TAGS = (
"marker",
"pattern",
"linearGradient",
"radialGradient",
"filter",
"clipPath",
"mask",
"symbol",
)
STYLE_BLOCK_RE = re.compile(r"<style\b[^>]*>(.*?)</style>", re.IGNORECASE | re.DOTALL)
SVG_BLOCK_RE = re.compile(r"<svg\b[^>]*>.*?</svg>", re.IGNORECASE | re.DOTALL)
RULE_RE = re.compile(r"([^{}]+)\{([^{}]*)\}", re.DOTALL)
RGBA_ATTR_RE = re.compile(
r'(fill|stroke)="rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d*\.?\d+)\s*\)"'
)
TRANSPARENT_ATTR_RE = re.compile(r'(fill|stroke)="transparent"')
def slug_for(path: Path) -> str:
"""Stable ID prefix from the source basename (no extension)."""
slug = re.sub(r"[^a-zA-Z0-9_-]+", "-", path.stem).strip("-")
if not slug:
raise ValueError(f"cannot derive an ID slug from {path.name!r}")
if slug[0].isdigit():
slug = f"d-{slug}"
return slug
def extract_first_svg(html: str) -> str:
match = SVG_BLOCK_RE.search(html)
if not match:
raise ValueError("no <svg> block found in source")
return match.group(0)
def _xml_start_tag(match: re.Match[str]) -> str:
def attr(m: re.Match[str]) -> str:
space, name, equals, value = m.groups()
if value is None:
return f'{space}{name}=""'
if value[0] in "\"'":
return m.group(0)
return f'{space}{name}{equals}"{value}"'
name, attrs, end = match.groups()
return f"<{name}{TAG_ATTR_RE.sub(attr, attrs)}{end}>"
def xmlify_attributes(svg: str) -> str:
"""Rewrite valueless and unquoted HTML attributes as XML (`attr=""`).
Comments and CDATA sections are copied unchanged.
"""
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(_xml_start_tag, 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 ensure_xmlns(svg: str) -> str:
if re.search(r'\bxmlns\s*=\s*["\']http://www\.w3\.org/2000/svg["\']', svg):
return svg
return re.sub(r"<svg\b", '<svg xmlns="http://www.w3.org/2000/svg"', svg, count=1)
def ensure_viewbox(svg: str) -> None:
if not re.search(r"\bviewBox\s*=", svg, re.IGNORECASE):
raise ValueError("SVG is missing a viewBox; refuse to guess")
def set_root_id(svg: str, root_id: str) -> str:
"""Put `id="{root_id}"` on the opening <svg> tag (replace any existing id)."""
def repl(match: re.Match[str]) -> str:
tag = match.group(0)
if re.search(r"\bid\s*=", tag, re.IGNORECASE):
tag = re.sub(r'\bid\s*=\s*("[^"]*"|\'[^\']*\')', f'id="{root_id}"', tag, count=1)
else:
tag = tag[:-1] + f' id="{root_id}">'
return tag
return re.sub(r"<svg\b[^>]*>", repl, svg, count=1, flags=re.IGNORECASE)
def is_chrome_selector(selector: str) -> bool:
parts = [part.strip() for part in selector.split(",") if part.strip()]
if not parts:
return True
return all(
part.lower() == "svg" or CHROME_SELECTOR_RE.match(part) is not None for part in parts
)
def scope_selector(selector: str, root_id: str) -> str:
scoped: list[str] = []
for part in selector.split(","):
part = part.strip()
if not part:
continue
if part == ":root" or part.startswith(":root"):
# `:root { … }` and rare `:root .x` → bind tokens to the SVG root.
remainder = part[len(":root") :].strip()
scoped.append(f"#{root_id}" + (f" {remainder}" if remainder else ""))
elif part.startswith("#"):
# Already an ID selector — leave alone (title/desc IDs stay global).
scoped.append(part)
elif SVG_TYPE_PREFIX_RE.match(part):
# The exported root is the <svg> itself, so `svg .zone` becomes
# `#root .zone` (`#root svg .zone` would match nothing). A bare
# `svg` in a mixed list is page layout and is dropped.
rest = part[len("svg") :]
if rest:
scoped.append(f"#{root_id}{rest}")
else:
scoped.append(f"#{root_id} {part}")
return ", ".join(scoped)
def body_inherited_css(selector: str, body: str, root_id: str) -> str:
"""Bind `color` / `font-family` from a dropped `body` rule to the SVG root."""
parts = [part.strip().lower() for part in selector.split(",")]
if "body" not in parts:
return ""
declarations = [
f"{name.lower()}: {value.strip()}" for name, value in BODY_INHERITED_RE.findall(body)
]
if not declarations:
return ""
return f"#{root_id} {{ {'; '.join(declarations)}; }}"
def escape_css_for_xml(css: str) -> str:
"""Escape XML-sensitive characters in CSS embedded inside an SVG <style>."""
# Order matters: amp first so we do not re-escape entities we just wrote.
return css.replace("&", "&").replace("<", "<")
def diagram_css_from_html(html: str, root_id: str) -> str:
"""Filter page <style> rules down to diagram rules, scoped under root_id."""
kept: list[str] = []
for block in STYLE_BLOCK_RE.findall(html):
# A comment before a rule would otherwise become part of its selector
# (`/* Tokens */ :root` is not recognised as `:root`).
block = CSS_COMMENT_RE.sub("", block)
for match in RULE_RE.finditer(block):
selector = " ".join(match.group(1).split())
body = match.group(2).strip()
if not selector or not body:
continue
inherited = body_inherited_css(selector, body, root_id)
if inherited:
kept.append(escape_css_for_xml(inherited))
if is_chrome_selector(selector):
continue
# Escape rule text before it lands in SVG XML (e.g. content:"R&D").
kept.append(
escape_css_for_xml(f"{scope_selector(selector, root_id)} {{ {body} }}")
)
return "\n ".join(kept)
def merge_style_into_defs(svg: str, style_css: str) -> str:
"""Ensure one <defs> and place a <style> with fonts + diagram CSS first."""
style_inner = GOOGLE_FONTS_IMPORT
if style_css.strip():
style_inner = f"{GOOGLE_FONTS_IMPORT}\n {style_css.strip()}"
style_tag = f"<style>{style_inner}</style>"
defs_match = re.search(r"<defs\b[^>]*>", svg, re.IGNORECASE)
if defs_match:
insert_at = defs_match.end()
return svg[:insert_at] + "\n " + style_tag + svg[insert_at:]
# No defs yet — insert one right after the opening svg tag (after title/desc
# would also be fine; putting defs first keeps markers available).
open_match = re.match(r"<svg\b[^>]*>", svg, re.IGNORECASE)
assert open_match is not None
insert_at = open_match.end()
return (
svg[:insert_at]
+ f"\n <defs>\n {style_tag}\n </defs>"
+ svg[insert_at:]
)
def find_defs_ids(svg: str) -> list[str]:
"""IDs on referenceable elements living under <defs>."""
defs_blocks = re.findall(r"<defs\b[^>]*>(.*?)</defs>", svg, re.IGNORECASE | re.DOTALL)
if not defs_blocks:
return []
tag_alt = "|".join(DEFS_ID_TAGS)
pattern = re.compile(
rf"<(?:{tag_alt})\b[^>]*\bid\s*=\s*[\"']([^\"']+)[\"']",
re.IGNORECASE,
)
found: list[str] = []
seen: set[str] = set()
for block in defs_blocks:
for match in pattern.finditer(block):
ident = match.group(1)
if ident not in seen:
seen.add(ident)
found.append(ident)
return found
def namespace_defs_ids(svg: str, prefix: str) -> str:
"""Prefix defs IDs and rewrite url(#…)/href="#…" references, longest first."""
ids = find_defs_ids(svg)
if not ids:
return svg
for old in sorted(ids, key=len, reverse=True):
new = f"{prefix}-{old}"
svg = re.sub(
rf'(\bid\s*=\s*[\'"]){re.escape(old)}([\'"])',
rf"\1{new}\2",
svg,
)
svg = re.sub(
rf"url\(\s*#\s*{re.escape(old)}\s*\)",
f"url(#{new})",
svg,
flags=re.IGNORECASE,
)
svg = re.sub(
rf"""((?:xlink:)?href\s*=\s*['"])#{re.escape(old)}(['"])""",
rf"\1#{new}\2",
svg,
flags=re.IGNORECASE,
)
return svg
def normalize_rgba_presentation_attrs(svg: str) -> str:
"""Split rgba()/transparent presentation attrs for strict SVG 1.1 importers."""
def repl(match: re.Match[str]) -> str:
prop, r, g, b, a = match.groups()
return '{0}="#{1:02x}{2:02x}{3:02x}" {0}-opacity="{4}"'.format(
prop, int(r), int(g), int(b), a
)
svg = RGBA_ATTR_RE.sub(repl, svg)
svg = TRANSPARENT_ATTR_RE.sub(r'\1="none"', svg)
return svg
def has_diagram_stylesheet(svg: str) -> bool:
"""True when a <style> block contains CSS rules beyond the fonts @import."""
for block in STYLE_BLOCK_RE.findall(svg):
stripped = re.sub(r"@import\b[^;]*;", "", block, flags=re.IGNORECASE)
stripped = re.sub(r"/\*.*?\*/", "", stripped, flags=re.DOTALL)
if RULE_RE.search(stripped):
return True
return False
def assert_export_gate(svg: str) -> None:
"""Refuse a class-styled fragment that shipped without diagram CSS.
A fonts-only <style> (Google Fonts @import with no rules) does not count —
class-based fills would still render as black boxes.
"""
if re.search(r"\bclass\s*=", svg) and not has_diagram_stylesheet(svg):
raise ValueError(
"exported SVG uses class= but has no diagram CSS rules; class-based "
"fills would render as black boxes. Carry the page CSS into the SVG."
)
def export_svg_document(html: str, source_path: Path) -> str:
"""Transform source HTML into a standalone SVG document string."""
slug = slug_for(source_path)
root_id = f"{slug}-root"
svg = xmlify_attributes(extract_first_svg(html))
ensure_viewbox(svg)
svg = ensure_xmlns(svg)
svg = set_root_id(svg, root_id)
diagram_css = diagram_css_from_html(html, root_id)
svg = merge_style_into_defs(svg, diagram_css)
svg = namespace_defs_ids(svg, slug)
svg = normalize_rgba_presentation_attrs(svg)
assert_export_gate(svg)
document = '<?xml version="1.0" encoding="UTF-8"?>\n' + svg + "\n"
# Catch any remaining XML-breaking characters in carried content.
try:
ET.fromstring(document)
except ET.ParseError as exc:
raise ValueError(f"exported SVG is not well-formed XML: {exc}") from exc
return document
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Export a diagram-design HTML file to a standalone SVG."
)
parser.add_argument("source", type=Path, help="Source .html diagram file")
parser.add_argument(
"output",
type=Path,
nargs="?",
help="Output .svg path (default: <source-stem>.svg next to the source)",
)
args = parser.parse_args(argv)
source: Path = args.source
if not source.is_file():
print(f"error: source not found: {source}", file=sys.stderr)
return 2
if source.name == "index.html" and source.parent.name == "assets":
print(
"error: refuse to export the gallery (assets/index.html); "
"pick a specific diagram file",
file=sys.stderr,
)
return 2
try:
html = source.read_text(encoding="utf-8")
document = export_svg_document(html, source)
except ValueError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
output = args.output if args.output is not None else source.with_suffix(".svg")
output.write_text(document, encoding="utf-8")
print(output)
return 0
if __name__ == "__main__":
sys.exit(main())