references/export.md
A supporting file of the diagram-design skill.
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 incommands/export-diagram.mdat the repo root). - The user asks in natural language to export, save, rasterize, convert, or download a diagram in
.svgor.pngform. 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>]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)
-
Read the source HTML file.
-
Extract the first
<svg ...>...</svg>block. Use a multiline regex anchored on<svgand</svg>. Most generated diagrams have only one SVG; if there are multiple, the first is the diagram (gallery files are an exception — see Edge cases). -
Make it standalone:
- Ensure the opening tag has
xmlns="http://www.w3.org/2000/svg". Add it if missing. - Ensure a
viewBoxis 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>) becomesdata-motion-item="", and an unquoted value gets double quotes. Comments and CDATA sections stay as written. - 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.
- Ensure the opening tag has
-
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:- Strip CSS comments first, so a comment in front of a rule does not become part of its selector.
- Re-scope
:root { … }custom properties onto#<slug>-rootso the figure keeps its own tokens. - Start selectors that begin at the
<svg>element at the root instead:svg .zonebecomes#<slug>-root .zoneandsvg textbecomes#<slug>-root text. The exported root element is the<svg>itself, so#<slug>-root svg .zonewould match nothing. - 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 baresvg { min-width: … }layout rule. Only the baresvgselector is layout;svg .zoneandsvg textare diagram rules (see above). Those must not follow a fragment. - Carry
colorandfont-familyfrom the droppedbodyrule onto#<slug>-root. SVG content inherits both:stroke="currentColor"readscolor, and text without its own font rule readsfont-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.
-
Inject Google Fonts
@importso the SVG renders with correct typography in a browser. XML-escape the&separators as&— a standalone.svgis 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>):<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> -
Namespace
<defs>IDs. Prefix every referenceable defs ID — onmarker,pattern,linearGradient,radialGradient,filter,clipPath,mask, andsymbol— with<slug>-, and rewrite matchingurl(#…)andhref="#…"/xlink:href="#…"references. Rewrite longest-id-first soarrow-accentis not clipped by a shorterarrowrule. Example:id="arrow"inexample-loop.htmlbecomesid="example-loop-arrow"withmarker-end="url(#example-loop-arrow)". -
Normalize colors for strict SVG 1.1 consumers. This design system's tokens are authored as
rgba(...)(seestyle-guide.md) and render correctly wherever colors are read as CSS — browsers, Figma, Illustrator. PowerPoint's SVG importer does not: it treatsrgba(...)andtransparentas unrecognized and paints them opaque black, turning a barely-there tint into a solid block that swallows the label inside it. The transform is lossless (every replacement renders identically to the original in a browser), so apply it to presentation attributes before writing the file:import re svg = re.sub( r'(fill|stroke)="rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d*\.?\d+)\s*\)"', lambda m: '{0}="#{1:02x}{2:02x}{3:02x}" {0}-opacity="{4}"'.format( m.group(1), int(m.group(2)), int(m.group(3)), int(m.group(4)), m.group(5) ), svg, ) svg = re.sub(r'(fill|stroke)="transparent"', r'\1="none"', svg)The
\s*around each channel tolerates a spacedrgba(45, 49, 66, 0.03)as well as the compactrgba(45,49,66,0.03)the templates normally use;\d*\.?\d+accepts an alpha value with or without a leading zero (both0.03and.03appear in shipped tokens). Matching is scoped to thefill="..."/stroke="..."presentation attribute. Class-styled diagrams may still carryrgba(...)inside the embedded<style>block via custom properties (e.g.--accent-tint); that form is correct in browsers and in Figma/Illustrator, and is out of scope for this presentation-attribute pass. (A brand's onboarded palette instyle-guide.mdcould in principle add a third notation such ashsl(); none exists in any shipped token today, so this pass doesn't handle it — extend the regex if one is ever introduced.) -
Gate: if the exported SVG still contains
class=but no diagram CSS rules, stop and fix the CSS carry step — that fragment will render as black boxes. A fonts-only<style>(Google Fonts@importwith no rules) does not satisfy the gate. -
Prepend
<?xml version="1.0" encoding="UTF-8"?>\nso the file is well-formed XML. -
Write to
<basename>.svgnext to the source (e.g.example-architecture.html→example-architecture.svg). Honour an explicit output path if the user provides one.
Caveat to surface to the user
Tools that don't fetch remote fonts at import time (offline Illustrator, some Figma import paths, older SVG viewers) will substitute typography. The SVG renders correctly in any modern browser. For pixel-perfect portability, recommend the PNG export.
PNG export procedure
Render the original HTML (not the extracted SVG) and screenshot only the <svg> element's bounding box. This keeps font loading reliable (already wired in the source HTML) while satisfying the "diagram only" rule. The PNG always has a transparent background (omit_background=True) so it can be placed on any slide or doc colour without a white halo. For motion-enabled HTML, append ?motion=static, await document.fonts.ready, and assert the motion root has data-frame="static" before capture; never export at an arbitrary wall-clock delay.
Detection
Before running anything, verify Playwright is installed:
python -c "import playwright" 2>NUL || python -c "import playwright"If the import fails, surface this exact instruction to the user and stop:
Playwright is not available. PNG export requires an approved Playwright installation and a compatible browser to be provisioned by the host environment; the skill never installs runtime dependencies automatically. Then ask me to export again.
Don't auto-install. The user asked for one feature, not a system change.
Rasterize
Write the snippet below to a temp file and run it with python <tmp.py> <src.html> <out.png>:
from playwright.sync_api import sync_playwright
import sys, pathlib
src, out = sys.argv[1], sys.argv[2]
scale = int(sys.argv[3]) if len(sys.argv) > 3 else 2
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(device_scale_factor=scale)
page.goto(f"file://{pathlib.Path(src).resolve()}")
page.wait_for_load_state("networkidle")
svg = page.locator("svg").first
# Release every clipping ancestor (local scroller, overflow:hidden chrome)
# so an SVG wider than its frame is captured whole.
svg.evaluate("el => { for (let a = el.parentElement; a; a = a.parentElement) a.style.setProperty('overflow', 'visible', 'important'); }")
svg.screenshot(path=out, omit_background=True)
browser.close()Default device_scale_factor=2 for crisp output. Accept 1 for compact assets or 3 for print/retina hero use, passed as a third CLI arg.
The overflow release matters for the wide presets. min-width equals the viewBox width (see output-spec.md), so a doc-wide or slide-16x9 SVG is 1280px inside a 1200px frame, and its .diagram-container scroller clips the last 80px on screen. The screenshot covers the SVG's box but not what an ancestor clipped, so without the release the PNG comes out full size with a blank right edge. This is the screen-side counterpart of the templates' @media print rule.
Output naming
example-architecture.html → example-architecture.png, written next to the source. Honour explicit user-provided paths.
Sizing the export
The PNG's pixel dimensions are the SVG's viewBox × device_scale_factor. So the size decision was already made when the diagram was drawn — see output-spec.md §2 for the presets. Export only picks the multiplier.
| Destination | Scale | Result from a 1280×720 viewBox |
|---|---|---|
| Docs, README, wiki | 2 | 2560×1440 |
| Slide deck (projected) | 2 | 2560×1440 |
| Print / PDF handout | 3 | 3840×2160 |
| Inline thumbnail, email | 1 | 1280×720 |
Hitting an exact pixel size
When the user needs specific dimensions (an OG card at exactly 1200×630, a slide image at 1920×1080), compute the scale factor instead of guessing — Playwright accepts fractional values:
scale = target_width / viewBox_widthA 960-wide viewBox at a 1200px target is scale=1.25. Two rules:
- Never scale below 1 to hit a small target — that soft-focuses the type. Redraw at a smaller preset instead.
- Never scale past 4 — beyond that you're upscaling a layout that was designed for a smaller canvas; redraw at
slide-16x9or a print preset.
If the target aspect ratio doesn't match the viewBox aspect ratio, say so and offer to redraw at the matching preset. Padding or cropping a finished diagram to fit a frame is not an export operation — it breaks the 40px safe margin.
Edge cases
- Source is
assets/index.html(the gallery, multiple SVGs in one file): refuse the export and ask the user which specific diagram file they meant. Don't guess. - No
<svg>block found: the source isn't a diagram file. Tell the user; don't write anything. - Surrounding HTML matters to the user: they want cards/header in the image. Tell them this skill exports diagrams only, and recommend a browser-based full-page screenshot (or a separate PDF print).
- Source is missing fonts at runtime: Playwright will substitute, the screenshot will look off. Check that the source HTML has the
<link href="...fonts.googleapis.com...">tag in<head>. If absent, the file isn't from a current template — fix the source rather than working around it in export.
What this command never does
- Modifies the source HTML.
- Adds export buttons or
<script>tags. Static diagrams remain script-free; an already motion-enabled source may retain the scoped controller fromanimation.md, but export never injects another controller. - Auto-emits
.svgor.pngalongside HTML generation. Manual on every call. - Embeds an HTML wrapper (cards, headers) into the SVG via
foreignObject. Too fragile across renderers.