
Svg Precision Skill
- 222 installs
- 84 repo stars
- Updated April 8, 2026
- dkyazzentwatwa/chatgpt-skills
Apply structured SVG precision rules when agents draft icons, diagrams, or interface graphics that must match design specs and export cleanly to code.
About
Skill-packaged version of SVG precision guidance for Claude Code workflows. Standardizes how agents author vectors with correct dimensions, minimal paths, and frontend-friendly markup so icons, badges, and illustrations slot into React, HTML, or design systems reliably.
- Structured SVG authoring workflow
- Spec-aligned icon and diagram output
- Optimized paths for smaller bundles
- Consistent coordinate and transform rules
- Design-to-code vector handoff
Svg Precision Skill by the numbers
- 222 all-time installs (skills.sh)
- Ranked #913 of 1,880 Design & UI/UX skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/dkyazzentwatwa/chatgpt-skills --skill svg-precision-skillAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 222 |
|---|---|
| repo stars | ★ 84 |
| Last updated | April 8, 2026 |
| Repository | dkyazzentwatwa/chatgpt-skills ↗ |
What it does
Apply structured SVG precision rules when agents draft icons, diagrams, or interface graphics that must match design specs and export cleanly to code.
Files
SVG Precision Skill
Build SVGs from explicit scene specifications, then validate before handing them off.
Workflow
1. Translate the request into a concrete spec with fixed dimensions and coordinates. 2. Use references/spec.md for templates and references/recipes.md for stable layout patterns. 3. Build the SVG with scripts/svg_cli.py build. 4. Validate with scripts/svg_cli.py validate. 5. Render a PNG preview when the user needs a quick visual check.
Rules
- Set
viewBox, width, and height explicitly. - Prefer absolute coordinates and simple shapes.
- Treat text as risky when exact rendering matters.
- Avoid exotic filters unless they are necessary and testable.
display_name: 'SVG Precision'
short_description: 'Generate deterministic SVGs with validation and previews.'
default_prompt: 'Help me create a precise SVG from a structured spec.'
Recipes (predictable rendering)
Universal
- Always include
xmlns="http://www.w3.org/2000/svg"(the builder does this). - Use
shape-rendering: geometricPrecisionfor technical drawings. - For crisp 1px lines on pixel grids: use integer coords and odd/even stroke widths intentionally.
- Prefer
fill="none"on stroked shapes to avoid accidental fills.
Icons
- Default canvas: 24x24 viewBox.
- Stroke-based:
strokeLinecap: round,strokeLinejoin: round,strokeWidth: 2. - Avoid filters; keep paths simple; expand strokes only if a consumer requires it.
Diagrams / Flowcharts
- Use a marker arrow in defs; connect shapes using
lineorpath. - Group each node: background shape + label text in a
group. - Keep consistent padding: 16-24px inside nodes.
- Route edges orthogonally when readability matters (use
pathwithM L Lsegments).
Charts
- Build chart area first (axes box), then plot area, then labels.
- Reserve margins (e.g., left 80, bottom 60, right 40, top 40) so labels never clip.
- Snap bars/points to integers; round tick labels; keep gridlines light.
UI mockups
- Start with a background rect (page) and a 8pt grid.
- Use large corner radii (16-24) for modern cards.
- Keep text styles consistent: titles 24-32, body 14-16.
- Shadows vary across renderers; prefer subtle strokes unless the shadow is essential.
Technical drawings
- Specify units in
canvas.unitsand include scale in metadata. - Use thin strokes (0.5-1.5) and set
vector-effect: non-scaling-strokewhen scaling output. - Dimensions: extension lines + dimension line + arrow markers + centered label.
- Add centerlines / dashed lines with
strokeDasharray.
SVG Spec (JSON)
This skill's scripts build SVGs from a strict JSON spec.
Top-level
{
"canvas": {"width": 800, "height": 450, "viewBox": "0 0 800 450", "units": "px", "background": "#ffffff"},
"defs": {"markers": [...], "gradients": [...], "clipPaths": [...]},
"elements": [...],
"metadata": {"title": "...", "desc": "..."}
}canvas.viewBoxis required ("minX minY width height").defsis optional. Use ids to referenceurl(#id).elementsis required (list of scene nodes).
Common fields
Every element supports:
id(optional)style(optional):{"fill": "#...", "stroke": "#...", "strokeWidth": 2, "opacity": 1, "strokeLinecap": "round", "strokeLinejoin": "round", "fontFamily": "...", "fontSize": 14, "fontWeight": 400}transform(optional): list of transforms, e.g.[{"translate": [10, 20]}, {"rotate": [45, 100, 100]}]
Element types
Group
{"type": "group", "id": "layer1", "style": {...}, "children": [ ... ]}Rect
{"type": "rect", "x": 10, "y": 10, "width": 100, "height": 40, "rx": 8, "ry": 8, "style": {...}}Circle / Ellipse
{"type": "circle", "cx": 50, "cy": 50, "r": 20, "style": {...}}
{"type": "ellipse", "cx": 50, "cy": 50, "rx": 30, "ry": 20, "style": {...}}Line / Polyline / Polygon
{"type": "line", "x1": 0, "y1": 0, "x2": 100, "y2": 100, "style": {...}}
{"type": "polyline", "points": [[0,0],[10,10],[20,0]], "style": {...}}
{"type": "polygon", "points": [[0,0],[10,10],[20,0]], "style": {...}}Path
{"type": "path", "d": "M10 10 L50 10 L50 50 Z", "style": {...}}Text
{"type": "text", "x": 100, "y": 100, "text": "Hello", "style": {"fontSize": 16, "fill": "#111"},
"anchor": "start", "baseline": "alphabetic", "maxWidth": 200, "lineHeight": 1.2}Anchors: start | middle | end Baselines: alphabetic | middle | hanging | central
Image
{"type": "image", "x": 0, "y": 0, "width": 200, "height": 100, "href": "data:image/png;base64,..."}Defs templates
Arrow marker
{
"defs": {
"markers": [
{"id": "arrow", "markerWidth": 10, "markerHeight": 10, "refX": 9, "refY": 5,
"orient": "auto", "pathD": "M0 0 L10 5 L0 10 Z", "style": {"fill": "#444"}}
]
}
}Use on a line/path: "style": {"markerEnd": "url(#arrow)"}
Linear gradient
{
"defs": {
"gradients": [
{"type": "linear", "id": "grad1", "x1": 0, "y1": 0, "x2": 1, "y2": 1,
"stops": [{"offset": 0, "color": "#fff"}, {"offset": 1, "color": "#ddd"}]}
]
}
}Use: "fill": "url(#grad1)"
---
Ready-to-copy templates
Icon (24x24 stroke)
{
"canvas": {"width": 24, "height": 24, "viewBox": "0 0 24 24", "units": "px"},
"elements": [
{"type": "path", "d": "M4 12 L20 12", "style": {"fill": "none", "stroke": "#111", "strokeWidth": 2, "strokeLinecap": "round"}}
]
}Diagram (nodes + arrows)
{
"canvas": {"width": 1200, "height": 800, "viewBox": "0 0 1200 800", "units": "px", "background": "#fff"},
"defs": {"markers": [{"id": "arrow", "markerWidth": 10, "markerHeight": 10, "refX": 9, "refY": 5, "orient": "auto",
"pathD": "M0 0 L10 5 L0 10 Z", "style": {"fill": "#444"}}]},
"elements": [
{"type": "rect", "x": 120, "y": 120, "width": 220, "height": 80, "rx": 12, "ry": 12,
"style": {"fill": "#f7f7f7", "stroke": "#444", "strokeWidth": 2}},
{"type": "text", "x": 230, "y": 168, "text": "Start", "anchor": "middle", "baseline": "middle",
"style": {"fill": "#111", "fontSize": 20, "fontFamily": "Inter, system-ui, sans-serif"}},
{"type": "line", "x1": 340, "y1": 160, "x2": 520, "y2": 160,
"style": {"stroke": "#444", "strokeWidth": 2, "markerEnd": "url(#arrow)"}}
]
}Chart (bar)
{
"canvas": {"width": 800, "height": 450, "viewBox": "0 0 800 450", "units": "px", "background": "#fff"},
"elements": [
{"type": "rect", "x": 100, "y": 50, "width": 600, "height": 320, "style": {"fill": "none", "stroke": "#ddd"}},
{"type": "rect", "x": 140, "y": 220, "width": 80, "height": 150, "style": {"fill": "#4c6ef5"}},
{"type": "rect", "x": 260, "y": 180, "width": 80, "height": 190, "style": {"fill": "#4c6ef5"}},
{"type": "text", "x": 400, "y": 410, "text": "Q1 - Q2 - Q3", "anchor": "middle", "baseline": "middle", "style": {"fill": "#666", "fontSize": 14}}
]
}UI mockup (card)
{
"canvas": {"width": 1440, "height": 900, "viewBox": "0 0 1440 900", "units": "px", "background": "#f2f2f2"},
"elements": [
{"type": "rect", "x": 360, "y": 180, "width": 720, "height": 420, "rx": 24, "ry": 24,
"style": {"fill": "#ffffff", "stroke": "#e6e6e6"}},
{"type": "text", "x": 420, "y": 260, "text": "Settings", "style": {"fill": "#111", "fontSize": 28, "fontWeight": 700}}
]
}Technical drawing (dimension line)
{
"canvas": {"width": 900, "height": 600, "viewBox": "0 0 900 600", "units": "mm", "background": "#fff"},
"defs": {"markers": [{"id": "dimArrow", "markerWidth": 8, "markerHeight": 8, "refX": 4, "refY": 4, "orient": "auto",
"pathD": "M0 4 L4 0 L8 4 L4 8 Z", "style": {"fill": "#111"}}]},
"elements": [
{"type": "rect", "x": 200, "y": 200, "width": 300, "height": 140, "style": {"fill": "none", "stroke": "#111", "strokeWidth": 1}},
{"type": "line", "x1": 200, "y1": 370, "x2": 500, "y2": 370,
"style": {"stroke": "#111", "strokeWidth": 1, "markerStart": "url(#dimArrow)", "markerEnd": "url(#dimArrow)"}},
{"type": "text", "x": 350, "y": 395, "text": "300 mm", "anchor": "middle", "style": {"fill": "#111", "fontSize": 16}}
]
}{
"canvas": {"width": 800, "height": 450, "viewBox": "0 0 800 450", "units": "px", "background": "#ffffff"},
"elements": [
{"type": "rect", "x": 80, "y": 50, "width": 670, "height": 320, "style": {"fill": "none", "stroke": "#e6e6e6"}},
{"type": "line", "x1": 80, "y1": 370, "x2": 750, "y2": 370, "style": {"stroke": "#999", "strokeWidth": 1}},
{"type": "rect", "x": 140, "y": 250, "width": 90, "height": 120, "style": {"fill": "#4c6ef5"}},
{"type": "rect", "x": 270, "y": 190, "width": 90, "height": 180, "style": {"fill": "#4c6ef5"}},
{"type": "rect", "x": 400, "y": 140, "width": 90, "height": 230, "style": {"fill": "#4c6ef5"}},
{"type": "rect", "x": 530, "y": 210, "width": 90, "height": 160, "style": {"fill": "#4c6ef5"}},
{"type": "text", "x": 400, "y": 30, "text": "Revenue by Quarter", "anchor": "middle", "baseline": "middle", "style": {"fill": "#111", "fontSize": 22, "fontWeight": 700, "fontFamily": "Inter, system-ui, sans-serif"}},
{"type": "text", "x": 185, "y": 405, "text": "Q1", "anchor": "middle", "style": {"fill": "#666", "fontSize": 14}},
{"type": "text", "x": 315, "y": 405, "text": "Q2", "anchor": "middle", "style": {"fill": "#666", "fontSize": 14}},
{"type": "text", "x": 445, "y": 405, "text": "Q3", "anchor": "middle", "style": {"fill": "#666", "fontSize": 14}},
{"type": "text", "x": 575, "y": 405, "text": "Q4", "anchor": "middle", "style": {"fill": "#666", "fontSize": 14}}
],
"metadata": {"title": "Bar chart example"}
}
{
"canvas": {"width": 900, "height": 520, "viewBox": "0 0 900 520", "units": "px", "background": "#ffffff"},
"defs": {
"markers": [
{"id": "arrow", "markerWidth": 10, "markerHeight": 10, "refX": 9, "refY": 5, "orient": "auto", "pathD": "M0 0 L10 5 L0 10 Z", "style": {"fill": "#444"}}
]
},
"elements": [
{"type": "rect", "x": 80, "y": 120, "width": 240, "height": 90, "rx": 14, "ry": 14, "style": {"fill": "#f7f7f7", "stroke": "#444", "strokeWidth": 2}},
{"type": "text", "x": 200, "y": 165, "text": "Start", "anchor": "middle", "baseline": "middle", "style": {"fill": "#111", "fontSize": 20, "fontFamily": "Inter, system-ui, sans-serif"}},
{"type": "rect", "x": 520, "y": 120, "width": 280, "height": 90, "rx": 14, "ry": 14, "style": {"fill": "#f7f7f7", "stroke": "#444", "strokeWidth": 2}},
{"type": "text", "x": 660, "y": 165, "text": "Process", "anchor": "middle", "baseline": "middle", "style": {"fill": "#111", "fontSize": 20, "fontFamily": "Inter, system-ui, sans-serif"}},
{"type": "line", "x1": 320, "y1": 165, "x2": 520, "y2": 165,
"style": {"stroke": "#444", "strokeWidth": 2, "markerEnd": "url(#arrow)"}},
{"type": "rect", "x": 520, "y": 300, "width": 280, "height": 90, "rx": 14, "ry": 14, "style": {"fill": "#f7f7f7", "stroke": "#444", "strokeWidth": 2}},
{"type": "text", "x": 660, "y": 345, "text": "Done", "anchor": "middle", "baseline": "middle", "style": {"fill": "#111", "fontSize": 20, "fontFamily": "Inter, system-ui, sans-serif"}},
{"type": "path", "d": "M660 210 L660 300", "style": {"fill": "none", "stroke": "#444", "strokeWidth": 2, "markerEnd": "url(#arrow)"}}
],
"metadata": {"title": "Diagram example"}
}
{
"canvas": {"width": 24, "height": 24, "viewBox": "0 0 24 24", "units": "px", "background": "#ffffff"},
"elements": [
{"type": "path", "d": "M4 12 L20 12", "style": {"fill": "none", "stroke": "#111", "strokeWidth": 2, "strokeLinecap": "round"}},
{"type": "path", "d": "M12 4 L12 20", "style": {"fill": "none", "stroke": "#111", "strokeWidth": 2, "strokeLinecap": "round"}}
],
"metadata": {"title": "Plus icon"}
}
{
"canvas": {"width": 900, "height": 600, "viewBox": "0 0 900 600", "units": "mm", "background": "#ffffff"},
"defs": {
"markers": [
{"id": "dimArrow", "markerWidth": 8, "markerHeight": 8, "refX": 4, "refY": 4, "orient": "auto", "pathD": "M0 4 L4 0 L8 4 L4 8 Z", "style": {"fill": "#111"}}
]
},
"elements": [
{"type": "rect", "x": 200, "y": 200, "width": 320, "height": 140, "style": {"fill": "none", "stroke": "#111", "strokeWidth": 1, "shapeRendering": "geometricPrecision"}},
{"type": "line", "x1": 200, "y1": 360, "x2": 200, "y2": 390, "style": {"stroke": "#111", "strokeWidth": 1}},
{"type": "line", "x1": 520, "y1": 360, "x2": 520, "y2": 390, "style": {"stroke": "#111", "strokeWidth": 1}},
{"type": "line", "x1": 200, "y1": 390, "x2": 520, "y2": 390, "style": {"stroke": "#111", "strokeWidth": 1, "markerStart": "url(#dimArrow)", "markerEnd": "url(#dimArrow)"}},
{"type": "text", "x": 360, "y": 415, "text": "320 mm", "anchor": "middle", "baseline": "middle", "style": {"fill": "#111", "fontSize": 16}}
],
"metadata": {"title": "Dimension example", "desc": "Units: mm"}
}
{
"canvas": {"width": 1440, "height": 900, "viewBox": "0 0 1440 900", "units": "px", "background": "#f2f2f2"},
"elements": [
{"type": "rect", "x": 360, "y": 180, "width": 720, "height": 480, "rx": 24, "ry": 24, "style": {"fill": "#ffffff", "stroke": "#e6e6e6"}},
{"type": "text", "x": 420, "y": 260, "text": "Account Settings", "style": {"fill": "#111", "fontSize": 28, "fontWeight": 700, "fontFamily": "Inter, system-ui, sans-serif"}},
{"type": "rect", "x": 420, "y": 310, "width": 600, "height": 56, "rx": 12, "ry": 12, "style": {"fill": "#fafafa", "stroke": "#dedede"}},
{"type": "text", "x": 448, "y": 345, "text": "Email", "baseline": "middle", "style": {"fill": "#777", "fontSize": 14, "fontFamily": "Inter, system-ui, sans-serif"}},
{"type": "rect", "x": 420, "y": 390, "width": 200, "height": 52, "rx": 14, "ry": 14, "style": {"fill": "#4c6ef5"}},
{"type": "text", "x": 520, "y": 416, "text": "Save", "anchor": "middle", "baseline": "middle", "style": {"fill": "#fff", "fontSize": 18, "fontWeight": 700, "fontFamily": "Inter, system-ui, sans-serif"}}
],
"metadata": {"title": "UI card example"}
}
# Optional dependencies for preview + diff
cairosvg>=2.5
pillow>=9
#!/usr/bin/env python3
from __future__ import annotations
import glob
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ""))
from svg_skill import build_svg
from svg_skill.validate import validate_svg
from svg_skill.render import render_png
def main() -> int:
base = os.path.join(os.path.dirname(__file__), "examples")
out = os.path.join(os.path.dirname(__file__), "_out")
os.makedirs(out, exist_ok=True)
specs = sorted(glob.glob(os.path.join(base, "*.json")))
if not specs:
print("no example specs found")
return 1
ok = True
for sp in specs:
name = os.path.splitext(os.path.basename(sp))[0]
spec = json.load(open(sp, "r", encoding="utf-8"))
svg_text = build_svg(spec)
svg_path = os.path.join(out, name + ".svg")
with open(svg_path, "w", encoding="utf-8") as f:
f.write(svg_text)
rep = validate_svg(svg_path, from_file=True)
if rep.get("errors"):
ok = False
print(f"FAIL {name}: {rep['errors']}")
else:
print(f"OK {name}")
# Optional render
png_path = os.path.join(out, name + ".png")
r = render_png(svg_path, png_path, scale=2.0, from_file=True)
if r.get("ok"):
print(f" rendered {os.path.basename(png_path)}")
else:
print(" (render skipped)")
print("done")
return 0 if ok else 2
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import sys
# Allow running from this folder without installation
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ""))
from svg_skill import build_svg, validate_svg, render_png, diff_svgs
def _read_json(path: str):
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def cmd_build(args: argparse.Namespace) -> int:
spec = _read_json(args.spec_json)
svg_text = build_svg(spec)
with open(args.out_svg, "w", encoding="utf-8") as f:
f.write(svg_text)
return 0
def cmd_validate(args: argparse.Namespace) -> int:
report = validate_svg(args.svg, from_file=True)
print(json.dumps(report, indent=2))
return 1 if report.get("errors") else 0
def cmd_render(args: argparse.Namespace) -> int:
rep = render_png(args.svg, args.out_png, scale=args.scale, from_file=True)
print(json.dumps(rep, indent=2))
return 0 if rep.get("ok") else 1
def cmd_diff(args: argparse.Namespace) -> int:
rep = diff_svgs(args.a_svg, args.b_svg, args.out_png, scale=args.scale)
print(json.dumps(rep, indent=2))
return 0 if rep.get("ok") and not rep.get("changed") else (1 if rep.get("ok") else 2)
def main(argv=None) -> int:
p = argparse.ArgumentParser(prog="svg_cli", description="Build/validate/render deterministic SVGs")
sp = p.add_subparsers(dest="cmd", required=True)
p_build = sp.add_parser("build", help="Build SVG from spec.json")
p_build.add_argument("spec_json")
p_build.add_argument("out_svg")
p_build.set_defaults(func=cmd_build)
p_val = sp.add_parser("validate", help="Validate an SVG file")
p_val.add_argument("svg")
p_val.set_defaults(func=cmd_validate)
p_r = sp.add_parser("render", help="Render SVG -> PNG (requires CairoSVG)")
p_r.add_argument("svg")
p_r.add_argument("out_png")
p_r.add_argument("--scale", type=float, default=1.0)
p_r.set_defaults(func=cmd_render)
p_d = sp.add_parser("diff", help="Render two SVGs and diff (requires CairoSVG + Pillow)")
p_d.add_argument("a_svg")
p_d.add_argument("b_svg")
p_d.add_argument("out_png")
p_d.add_argument("--scale", type=float, default=1.0)
p_d.set_defaults(func=cmd_diff)
args = p.parse_args(argv)
return int(args.func(args))
if __name__ == "__main__":
raise SystemExit(main())
"""svg_skill: deterministic SVG generation + validation helpers."""
from .core import build_svg
from .validate import validate_svg
from .render import render_png, diff_svgs
__all__ = ["build_svg", "validate_svg", "render_png", "diff_svgs"]
from __future__ import annotations
import json
import math
import re
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
from xml.etree.ElementTree import Element, SubElement, tostring
_SVG_NS = "http://www.w3.org/2000/svg"
_XLINK_NS = "http://www.w3.org/1999/xlink"
def build_svg(spec: Dict[str, Any]) -> str:
"""Build an SVG string from a spec dict.
The spec schema is documented in references/spec.md.
"""
if not isinstance(spec, dict):
raise TypeError("spec must be a dict")
canvas = spec.get("canvas") or {}
width = canvas.get("width")
height = canvas.get("height")
view_box = canvas.get("viewBox")
units = canvas.get("units", "px")
if view_box is None:
raise ValueError("canvas.viewBox is required")
_assert_viewbox(view_box)
# width/height optional but strongly recommended; default to viewBox dims
if width is None or height is None:
vb = [float(x) for x in str(view_box).split()]
width = width or vb[2]
height = height or vb[3]
root = Element(
"svg",
{
"xmlns": _SVG_NS,
"xmlns:xlink": _XLINK_NS,
"width": _fmt_len(width, units),
"height": _fmt_len(height, units),
"viewBox": str(view_box),
"version": "1.1",
},
)
# Optional metadata
metadata = spec.get("metadata") or {}
if isinstance(metadata, dict):
title = metadata.get("title")
desc = metadata.get("desc")
if title:
SubElement(root, "title").text = str(title)
if desc:
SubElement(root, "desc").text = str(desc)
# Optional background
background = canvas.get("background")
if background:
vb = [float(x) for x in str(view_box).split()]
SubElement(
root,
"rect",
{
"x": _fmt_num(vb[0]),
"y": _fmt_num(vb[1]),
"width": _fmt_num(vb[2]),
"height": _fmt_num(vb[3]),
"fill": str(background),
},
)
# defs
defs_spec = spec.get("defs") or {}
if isinstance(defs_spec, dict) and defs_spec:
defs_el = SubElement(root, "defs")
_emit_defs(defs_el, defs_spec)
# elements
elements = spec.get("elements")
if not isinstance(elements, list):
raise ValueError("elements must be a list")
for node in elements:
_emit_node(root, node)
# Pretty-ish output (ElementTree doesn't pretty print reliably without minidom)
svg_bytes = tostring(root, encoding="utf-8", xml_declaration=True)
return svg_bytes.decode("utf-8")
def _emit_defs(defs_el: Element, defs_spec: Dict[str, Any]) -> None:
for marker in defs_spec.get("markers") or []:
_emit_marker(defs_el, marker)
for grad in defs_spec.get("gradients") or []:
_emit_gradient(defs_el, grad)
for cp in defs_spec.get("clipPaths") or []:
_emit_clip_path(defs_el, cp)
def _emit_marker(parent: Element, marker: Dict[str, Any]) -> None:
if not isinstance(marker, dict):
return
mid = marker.get("id")
if not mid:
raise ValueError("marker missing id")
attrs = {
"id": str(mid),
"markerWidth": _fmt_num(marker.get("markerWidth", 10)),
"markerHeight": _fmt_num(marker.get("markerHeight", 10)),
"refX": _fmt_num(marker.get("refX", 0)),
"refY": _fmt_num(marker.get("refY", 0)),
"orient": str(marker.get("orient", "auto")),
}
if marker.get("markerUnits"):
attrs["markerUnits"] = str(marker["markerUnits"])
mel = SubElement(parent, "marker", attrs)
# viewBox optional for marker
if marker.get("viewBox"):
mel.set("viewBox", str(marker["viewBox"]))
path_d = marker.get("pathD")
if not path_d:
raise ValueError(f"marker {mid} missing pathD")
pel = SubElement(mel, "path", {"d": str(path_d)})
_apply_style(pel, marker.get("style") or {})
def _emit_gradient(parent: Element, grad: Dict[str, Any]) -> None:
if not isinstance(grad, dict):
return
gid = grad.get("id")
if not gid:
raise ValueError("gradient missing id")
gtype = grad.get("type", "linear")
if gtype not in {"linear", "radial"}:
raise ValueError(f"unsupported gradient type: {gtype}")
tag = "linearGradient" if gtype == "linear" else "radialGradient"
gel = SubElement(parent, tag, {"id": str(gid)})
# units + coords
if grad.get("gradientUnits"):
gel.set("gradientUnits", str(grad["gradientUnits"]))
for k in ("x1", "y1", "x2", "y2", "cx", "cy", "r", "fx", "fy"):
if k in grad and grad[k] is not None:
gel.set(k, _fmt_num(grad[k]))
# stops
stops = grad.get("stops") or []
if not isinstance(stops, list) or not stops:
raise ValueError(f"gradient {gid} missing stops")
for s in stops:
if not isinstance(s, dict):
continue
off = s.get("offset")
col = s.get("color")
if off is None or col is None:
continue
sel = SubElement(gel, "stop", {"offset": _fmt_offset(off), "stop-color": str(col)})
if s.get("opacity") is not None:
sel.set("stop-opacity", _fmt_num(s["opacity"]))
def _emit_clip_path(parent: Element, cp: Dict[str, Any]) -> None:
if not isinstance(cp, dict):
return
cid = cp.get("id")
if not cid:
raise ValueError("clipPath missing id")
cel = SubElement(parent, "clipPath", {"id": str(cid)})
for node in cp.get("elements") or []:
_emit_node(cel, node)
def _emit_node(parent: Element, node: Dict[str, Any]) -> None:
if not isinstance(node, dict):
raise TypeError("element node must be an object")
etype = node.get("type")
if not etype:
raise ValueError("element missing type")
if etype == "group":
el = SubElement(parent, "g")
_apply_common(el, node)
for child in node.get("children") or []:
_emit_node(el, child)
return
tag = _map_type_to_tag(etype)
el = SubElement(parent, tag)
_apply_common(el, node)
if etype == "rect":
_set(el, "x", node.get("x")); _set(el, "y", node.get("y"))
_set(el, "width", node.get("width")); _set(el, "height", node.get("height"))
if node.get("rx") is not None: _set(el, "rx", node.get("rx"))
if node.get("ry") is not None: _set(el, "ry", node.get("ry"))
elif etype == "circle":
_set(el, "cx", node.get("cx")); _set(el, "cy", node.get("cy")); _set(el, "r", node.get("r"))
elif etype == "ellipse":
_set(el, "cx", node.get("cx")); _set(el, "cy", node.get("cy")); _set(el, "rx", node.get("rx")); _set(el, "ry", node.get("ry"))
elif etype == "line":
_set(el, "x1", node.get("x1")); _set(el, "y1", node.get("y1")); _set(el, "x2", node.get("x2")); _set(el, "y2", node.get("y2"))
elif etype in {"polyline", "polygon"}:
pts = node.get("points")
if not isinstance(pts, list) or not pts:
raise ValueError(f"{etype} missing points")
el.set("points", _fmt_points(pts))
elif etype == "path":
d = node.get("d")
if not d:
raise ValueError("path missing d")
el.set("d", str(d))
elif etype == "text":
_emit_text(el, node)
elif etype == "image":
_set(el, "x", node.get("x")); _set(el, "y", node.get("y"))
_set(el, "width", node.get("width")); _set(el, "height", node.get("height"))
href = node.get("href")
if not href:
raise ValueError("image missing href")
el.set("href", str(href))
# Older viewers
el.set(f"{{{_XLINK_NS}}}href", str(href))
else:
raise ValueError(f"unsupported element type: {etype}")
def _emit_text(el: Element, node: Dict[str, Any]) -> None:
_set(el, "x", node.get("x")); _set(el, "y", node.get("y"))
anchor = node.get("anchor")
baseline = node.get("baseline")
if anchor:
el.set("text-anchor", str(anchor))
if baseline:
el.set("dominant-baseline", str(baseline))
# Allow explicit lines for deterministic layout
lines = node.get("lines")
if lines is None:
text = node.get("text", "")
if "\n" in str(text):
lines = str(text).split("\n")
else:
lines = [str(text)]
if not isinstance(lines, list):
lines = [str(lines)]
lh = float(node.get("lineHeight", 1.2))
font_size = _style_font_size(node.get("style") or {})
# First line as text, rest as tspans with dy
if not lines:
return
el.text = str(lines[0])
if len(lines) > 1:
dy = (font_size * lh) if font_size else (16 * lh)
for i, line in enumerate(lines[1:], start=1):
tspan = SubElement(el, "tspan", {"x": el.get("x", "0"), "dy": _fmt_num(dy)})
tspan.text = str(line)
def _apply_common(el: Element, node: Dict[str, Any]) -> None:
if node.get("id"):
el.set("id", str(node["id"]))
# style
_apply_style(el, node.get("style") or {})
# transform
tf = node.get("transform")
if tf:
el.set("transform", _fmt_transform(tf))
# clip-path convenience
cp = (node.get("clipPath") or node.get("clip-path"))
if cp:
el.set("clip-path", f"url(#{cp})" if not str(cp).startswith("url(") else str(cp))
_STYLE_KEY_MAP = {
"strokeWidth": "stroke-width",
"strokeLinecap": "stroke-linecap",
"strokeLinejoin": "stroke-linejoin",
"strokeDasharray": "stroke-dasharray",
"strokeDashoffset": "stroke-dashoffset",
"opacity": "opacity",
"fillOpacity": "fill-opacity",
"strokeOpacity": "stroke-opacity",
"fontFamily": "font-family",
"fontSize": "font-size",
"fontWeight": "font-weight",
"letterSpacing": "letter-spacing",
"vectorEffect": "vector-effect",
"shapeRendering": "shape-rendering",
"textRendering": "text-rendering",
"markerEnd": "marker-end",
"markerStart": "marker-start",
"markerMid": "marker-mid",
}
def _apply_style(el: Element, style: Dict[str, Any]) -> None:
if not isinstance(style, dict):
return
# accept snake_case or kebab-case too
for k, v in style.items():
if v is None:
continue
kk = str(k)
if kk in _STYLE_KEY_MAP:
kk = _STYLE_KEY_MAP[kk]
kk = kk.replace("_", "-")
if kk in {"stroke-width", "font-size", "opacity", "fill-opacity", "stroke-opacity", "letter-spacing"}:
el.set(kk, _fmt_num(v))
else:
el.set(kk, str(v))
def _fmt_transform(tf: Any) -> str:
if isinstance(tf, str):
return tf
if not isinstance(tf, list):
raise TypeError("transform must be a list of ops")
parts: List[str] = []
for op in tf:
if not isinstance(op, dict) or len(op) != 1:
raise ValueError("each transform op must be single-key object")
name, val = next(iter(op.items()))
name = str(name)
if name == "translate":
x, y = _pair(val)
parts.append(f"translate({_fmt_num(x)} {_fmt_num(y)})")
elif name == "scale":
if isinstance(val, (int, float)):
parts.append(f"scale({_fmt_num(val)})")
else:
x, y = _pair(val)
parts.append(f"scale({_fmt_num(x)} {_fmt_num(y)})")
elif name == "rotate":
vals = list(val) if isinstance(val, (list, tuple)) else [val]
if len(vals) == 1:
parts.append(f"rotate({_fmt_num(vals[0])})")
elif len(vals) == 3:
parts.append(f"rotate({_fmt_num(vals[0])} {_fmt_num(vals[1])} {_fmt_num(vals[2])})")
else:
raise ValueError("rotate expects [angle] or [angle,cx,cy]")
elif name == "skewX":
parts.append(f"skewX({_fmt_num(val)})")
elif name == "skewY":
parts.append(f"skewY({_fmt_num(val)})")
elif name == "matrix":
vals = list(val)
if len(vals) != 6:
raise ValueError("matrix expects 6 numbers")
parts.append("matrix(" + " ".join(_fmt_num(x) for x in vals) + ")")
else:
raise ValueError(f"unsupported transform op: {name}")
return " ".join(parts)
def _map_type_to_tag(etype: str) -> str:
mapping = {
"rect": "rect",
"circle": "circle",
"ellipse": "ellipse",
"line": "line",
"polyline": "polyline",
"polygon": "polygon",
"path": "path",
"text": "text",
"image": "image",
}
if etype not in mapping:
raise ValueError(f"unsupported element type: {etype}")
return mapping[etype]
def _set(el: Element, attr: str, val: Any) -> None:
if val is None:
raise ValueError(f"missing required attribute: {attr}")
el.set(attr, _fmt_num(val))
def _fmt_points(points: List[Any]) -> str:
out = []
for p in points:
x, y = _pair(p)
out.append(f"{_fmt_num(x)},{_fmt_num(y)}")
return " ".join(out)
def _pair(val: Any) -> Tuple[float, float]:
if isinstance(val, (list, tuple)) and len(val) == 2:
return float(val[0]), float(val[1])
raise ValueError("expected [x,y]")
def _fmt_num(v: Any) -> str:
try:
f = float(v)
except Exception as e:
raise ValueError(f"expected number, got {v!r}") from e
if not math.isfinite(f):
raise ValueError("non-finite number")
# limit precision to keep SVG size sane
s = f"{f:.4f}".rstrip("0").rstrip(".")
return s if s else "0"
def _fmt_len(v: Any, units: str) -> str:
# Allow percent strings etc.
if isinstance(v, str):
return v
return _fmt_num(v) + str(units or "")
def _fmt_offset(off: Any) -> str:
if isinstance(off, str):
return off
f = float(off)
if 0 <= f <= 1:
return f"{f * 100:.2f}%".rstrip("0").rstrip(".") + "%"
return _fmt_num(f)
def _assert_viewbox(vb: Any) -> None:
if isinstance(vb, str):
parts = vb.split()
elif isinstance(vb, (list, tuple)):
parts = list(vb)
vb = " ".join(str(x) for x in parts)
else:
raise ValueError("viewBox must be a string or [minX,minY,w,h]")
if len(parts) != 4:
raise ValueError("viewBox must have 4 numbers")
nums = [float(x) for x in parts]
if nums[2] <= 0 or nums[3] <= 0:
raise ValueError("viewBox width/height must be > 0")
def _style_font_size(style: Dict[str, Any]) -> Optional[float]:
if not isinstance(style, dict):
return None
fs = style.get("fontSize") or style.get("font-size") or style.get("font_size")
try:
return float(fs) if fs is not None else None
except Exception:
return None
from __future__ import annotations
import io
import os
import tempfile
from typing import Any, Dict, Optional, Union
def render_png(svg: Union[str, bytes], out_png: str, *, scale: float = 1.0, from_file: bool = True) -> Dict[str, Any]:
"""Render an SVG to PNG using CairoSVG (optional dependency).
Args:
svg: path (if from_file) or SVG bytes/string
out_png: output file path
scale: render scale multiplier
from_file: treat `svg` as file path when True
"""
try:
import cairosvg # type: ignore
except Exception as e:
return {"ok": False, "error": "CairoSVG not available. Install with: pip install cairosvg", "detail": str(e)}
try:
if from_file:
data = open(str(svg), "rb").read()
else:
data = svg if isinstance(svg, (bytes, bytearray)) else str(svg).encode("utf-8")
cairosvg.svg2png(bytestring=data, write_to=out_png, scale=float(scale))
return {"ok": True, "out_png": out_png}
except Exception as e:
return {"ok": False, "error": f"render failed: {e}"}
def diff_svgs(a_svg: str, b_svg: str, out_png: str, *, scale: float = 1.0) -> Dict[str, Any]:
"""Render two SVGs and output a pixel diff PNG (requires CairoSVG + Pillow)."""
try:
from PIL import Image, ImageChops # type: ignore
except Exception as e:
return {"ok": False, "error": "Pillow not available. Install with: pip install pillow", "detail": str(e)}
with tempfile.TemporaryDirectory() as td:
a_png = os.path.join(td, "a.png")
b_png = os.path.join(td, "b.png")
ra = render_png(a_svg, a_png, scale=scale, from_file=True)
if not ra.get("ok"):
return ra
rb = render_png(b_svg, b_png, scale=scale, from_file=True)
if not rb.get("ok"):
return rb
im_a = Image.open(a_png).convert("RGBA")
im_b = Image.open(b_png).convert("RGBA")
# Pad to same size
w = max(im_a.size[0], im_b.size[0])
h = max(im_a.size[1], im_b.size[1])
if im_a.size != (w, h):
tmp = Image.new("RGBA", (w, h), (0, 0, 0, 0))
tmp.paste(im_a, (0, 0))
im_a = tmp
if im_b.size != (w, h):
tmp = Image.new("RGBA", (w, h), (0, 0, 0, 0))
tmp.paste(im_b, (0, 0))
im_b = tmp
diff = ImageChops.difference(im_a, im_b)
diff.save(out_png)
bbox = diff.getbbox()
changed = bbox is not None
return {"ok": True, "out_png": out_png, "changed": changed, "bbox": bbox}
from __future__ import annotations
import json
import os
import re
from typing import Any, Dict, List, Optional, Set, Tuple, Union
from xml.etree import ElementTree as ET
_URL_REF_RE = re.compile(r"url\(#(?P<id>[A-Za-z_][\w:.-]*)\)")
def validate_svg(svg: Union[str, bytes], *, from_file: bool = False) -> Dict[str, Any]:
"""Validate an SVG string or a file path.
Returns: {"errors": [...], "warnings": [...]}.
"""
errors: List[str] = []
warnings: List[str] = []
if from_file:
path = str(svg)
try:
data = open(path, "rb").read()
except Exception as e:
return {"errors": [f"cannot read SVG file: {e}"], "warnings": []}
else:
data = svg if isinstance(svg, (bytes, bytearray)) else str(svg).encode("utf-8")
try:
root = ET.fromstring(data)
except Exception as e:
return {"errors": [f"invalid XML: {e}"], "warnings": []}
tag = _strip_ns(root.tag)
if tag != "svg":
errors.append(f"root element is <{tag}>, expected <svg>")
view_box = root.attrib.get("viewBox")
if not view_box:
errors.append("missing viewBox")
else:
vb_ok, vb_msg = _check_viewbox(view_box)
if not vb_ok:
errors.append(vb_msg)
# width/height not strictly required but a quality signal
if not root.attrib.get("width") or not root.attrib.get("height"):
warnings.append("missing width/height on root <svg> (may render with unexpected scaling)")
# collect ids
ids: Set[str] = set()
dup_ids: Set[str] = set()
for el in root.iter():
eid = el.attrib.get("id")
if eid:
if eid in ids:
dup_ids.add(eid)
ids.add(eid)
if dup_ids:
errors.append(f"duplicate ids: {', '.join(sorted(dup_ids))}")
# find url(#id) references in attributes
refs: Set[str] = set()
for el in root.iter():
for _, v in el.attrib.items():
for m in _URL_REF_RE.finditer(v):
refs.add(m.group("id"))
missing = sorted(r for r in refs if r not in ids)
if missing:
errors.append("missing referenced ids: " + ", ".join(missing))
# text gotchas
for el in root.iter():
if _strip_ns(el.tag) == "text":
if "font-family" not in el.attrib and "style" not in el.attrib:
warnings.append("text element without explicit font-family may render differently across systems")
break
return {"errors": errors, "warnings": warnings}
def _strip_ns(tag: str) -> str:
if "}" in tag:
return tag.split("}", 1)[1]
return tag
def _check_viewbox(vb: str) -> Tuple[bool, str]:
parts = str(vb).split()
if len(parts) != 4:
return False, "viewBox must have 4 numbers"
try:
nums = [float(x) for x in parts]
except Exception:
return False, "viewBox contains non-numeric values"
if nums[2] <= 0 or nums[3] <= 0:
return False, "viewBox width/height must be > 0"
return True, "ok"