"""SVG manipulation: namespace-aware DOM helpers using stdlib ``xml.etree``.
Pure stdlib so the package stays dependency-light. Operations supported:
- :func:`promote_inkscape_labels_to_ids` — copy ``inkscape:label`` to ``id``
on each group, since Inkscape doesn't auto-promote labels (a 2008-vintage
bug; see research §1.2).
- :func:`normalize_svg` — promote labels, ensure a viewBox is set, return
the parsed ``ElementTree``.
- :func:`extract_pivots` — read the ``<g id="skeleton">`` group of named
``<circle>`` elements and return ``{name: (cx, cy)}``.
- :func:`extract_part` — emit a standalone SVG containing only the named
group. By default it writes a viewBox **cropped** to the part's own bbox
while copying the parent's ``width``/``height``, which letterboxes the part
under ``preserveAspectRatio="xMidYMid meet"`` (see #75). The crop rect's
parent-space origin survives as the viewBox's first two numbers.
- :func:`write_svg` — pretty-print an ``ElementTree`` (or ``Element``) to
disk with the SVG namespace set as the default.
>>> raw = '''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
... <g id="skeleton"><circle id="neck" cx="50" cy="40" r="2"/></g>
... <g id="illustration"><g id="head"><circle cx="50" cy="40" r="20"/></g></g>
... </svg>'''
>>> import io
>>> tree = normalize_svg(io.StringIO(raw))
>>> extract_pivots(tree)
{'neck': (50.0, 40.0)}
>>> part = extract_part(tree, 'head')
>>> b'<g id="head"' in write_svg(part)
True
"""
from __future__ import annotations
import io
import re
from pathlib import Path
from typing import Any
from xml.etree import ElementTree as ET
SVG_NS = "http://www.w3.org/2000/svg"
INKSCAPE_NS = "http://www.inkscape.org/namespaces/inkscape"
SODIPODI_NS = "http://sodipodi.sourceforge.net/DTD/sodipodi-0.0.dtd"
XLINK_NS = "http://www.w3.org/1999/xlink"
ET.register_namespace("", SVG_NS)
ET.register_namespace("inkscape", INKSCAPE_NS)
ET.register_namespace("sodipodi", SODIPODI_NS)
ET.register_namespace("xlink", XLINK_NS)
_SVG_TAG = f"{{{SVG_NS}}}svg"
_G_TAG = f"{{{SVG_NS}}}g"
_CIRCLE_TAG = f"{{{SVG_NS}}}circle"
_INK_LABEL = f"{{{INKSCAPE_NS}}}label"
# Strict id-safe identifier: spaces become underscores, then keep [A-Za-z0-9_-].
_ID_BAD_CHAR = re.compile(r"[^A-Za-z0-9_\-]+")
def _parse(source: Any) -> ET.ElementTree:
"""Parse ``source`` (path, file-like, or already-parsed ``ElementTree``)."""
if isinstance(source, ET.ElementTree):
return source
if isinstance(source, ET.Element):
return ET.ElementTree(source)
if isinstance(source, (str, Path)) and Path(str(source)).exists():
return ET.parse(str(source))
if hasattr(source, "read"):
return ET.parse(source)
if isinstance(source, str):
return ET.ElementTree(ET.fromstring(source))
if isinstance(source, Path):
# A Path that did not match above is a path that does not exist. Saying
# "unsupported svg source: PosixPath" for that is a type complaint about
# a file problem, and sends the reader to the wrong question entirely.
raise FileNotFoundError(f"no SVG at {source}")
raise TypeError(f"unsupported svg source: {type(source).__name__}")
def _label_to_id(label: str) -> str:
"""Convert an Inkscape label to an id-safe string."""
cleaned = label.strip().replace(" ", "_")
cleaned = _ID_BAD_CHAR.sub("", cleaned)
return cleaned or "unnamed"
def _ensure_viewbox(tree: ET.ElementTree, fallback: str = "0 0 1024 1024") -> str:
"""Ensure the root SVG has a viewBox attribute; return the resolved value.
If the root has only ``width``/``height``, derive a viewBox from those.
"""
root = tree.getroot()
vb = root.get("viewBox")
if vb:
return vb
w = root.get("width")
h = root.get("height")
if w and h:
# Strip units (Inkscape may emit "100mm").
w_num = re.sub(r"[^\d.\-]", "", w) or "1024"
h_num = re.sub(r"[^\d.\-]", "", h) or "1024"
vb = f"0 0 {w_num} {h_num}"
else:
vb = fallback
root.set("viewBox", vb)
return vb
[docs]
def normalize_svg(
source: Any, *, fallback_viewbox: str = "0 0 1024 1024"
) -> ET.ElementTree:
"""Promote Inkscape labels to ids and ensure a viewBox is set.
Returns the parsed ``ElementTree``. Idempotent: running it twice is a
no-op on the second pass.
"""
tree = _parse(source)
promote_inkscape_labels_to_ids(tree)
_ensure_viewbox(tree, fallback=fallback_viewbox)
return tree
#: Attributes an SVG root may use to declare its rasterised size.
_SIZE_ATTRS: tuple[str, str] = ("width", "height")
#: Trailing units we accept on a width/height and ignore (SVG user units).
_UNIT_SUFFIXES: tuple[str, ...] = ("px", "pt", "cm", "mm", "in", "pc")
def _strip_units(value: str) -> float:
"""Parse an SVG length, tolerating a unit suffix. Percentages are refused."""
text = value.strip()
if text.endswith("%"):
raise ValueError(f"percentage length {value!r} has no intrinsic size")
for suffix in _UNIT_SUFFIXES:
if text.endswith(suffix):
text = text[: -len(suffix)]
break
return float(text)
[docs]
def raster_size(source: Any) -> tuple[float, float]:
"""Return the ``(width, height)`` an SVG declares for its own raster.
This is the size the browser rasterises the file at, which is what a
``Sprite`` then scales — **not** the extent of the drawn art. The two differ
whenever :func:`extract_part` has cropped the viewBox while copying the
parent's dimensions, which is the defect behind #75.
Falls back to the viewBox extent when no ``width``/``height`` is declared,
matching the browser.
>>> raster_size('<svg xmlns="http://www.w3.org/2000/svg" '
... 'viewBox="0 0 10 20" width="100" height="100"/>')
(100.0, 100.0)
>>> raster_size('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 20"/>')
(10.0, 20.0)
"""
root = _parse(source).getroot()
declared = [root.get(name) for name in _SIZE_ATTRS]
if all(declared):
return (_strip_units(declared[0]), _strip_units(declared[1]))
view_box = root.get("viewBox")
if not view_box:
raise ValueError("SVG declares neither width/height nor a viewBox")
parts = view_box.split()
if len(parts) != 4:
raise ValueError(f"malformed viewBox {view_box!r}")
return (float(parts[2]), float(parts[3]))
_DEFS_TAG = f"{{{SVG_NS}}}defs"
_RECT_TAG = f"{{{SVG_NS}}}rect"
_ELLIPSE_TAG = f"{{{SVG_NS}}}ellipse"
_PATH_TAG = f"{{{SVG_NS}}}path"
_PATH_NUMBER = re.compile(r"-?\d*\.?\d+(?:[eE][-+]?\d+)?")
def _bbox_union(
a: tuple[float, float, float, float] | None,
b: tuple[float, float, float, float] | None,
) -> tuple[float, float, float, float] | None:
"""Union of two ``(x_min, y_min, x_max, y_max)`` bounding boxes."""
if a is None:
return b
if b is None:
return a
return (min(a[0], b[0]), min(a[1], b[1]), max(a[2], b[2]), max(a[3], b[3]))
def _element_bbox(
el: ET.Element,
) -> tuple[float, float, float, float] | None:
"""Approximate bbox of a primitive SVG element from its attributes.
Handles ``<rect>``, ``<circle>``, ``<ellipse>``, and ``<path>``. Path
bbox is derived from all numeric pairs in the ``d`` attribute — that
overestimates curves with off-curve control points, but the result is
safe (always contains the visible art) and sufficient for cropping.
Returns ``None`` for elements with no inferable bbox.
"""
tag = el.tag
def _f(name: str, default: float = 0.0) -> float:
try:
return float(el.get(name, default))
except (TypeError, ValueError):
return default
if tag == _RECT_TAG:
x, y, w, h = _f("x"), _f("y"), _f("width"), _f("height")
return (x, y, x + w, y + h)
if tag == _CIRCLE_TAG:
cx, cy, r = _f("cx"), _f("cy"), _f("r")
return (cx - r, cy - r, cx + r, cy + r)
if tag == _ELLIPSE_TAG:
cx, cy, rx, ry = _f("cx"), _f("cy"), _f("rx"), _f("ry")
return (cx - rx, cy - ry, cx + rx, cy + ry)
if tag == _PATH_TAG:
d = el.get("d") or ""
nums = [float(n) for n in _PATH_NUMBER.findall(d)]
if len(nums) < 2:
return None
xs = nums[0::2]
ys = nums[1::2]
if not xs or not ys:
return None
return (min(xs), min(ys), max(xs), max(ys))
return None
def _subtree_bbox(
el: ET.Element,
) -> tuple[float, float, float, float] | None:
"""Recursive bbox over all primitive descendants of ``el``."""
box = _element_bbox(el)
for child in el:
box = _bbox_union(box, _subtree_bbox(child))
return box
def _find_by_id(root: ET.Element, target_id: str) -> ET.Element | None:
"""Depth-first search for the first element whose ``id`` matches.
A character's parts are always ``<g>`` groups; the matching ``id`` may
*also* appear on a skeleton pivot ``<circle>`` (e.g.
``<circle id="head">``). Prefer the ``<g>`` so part extraction picks
up the art group, not the pivot point.
"""
matches: list[ET.Element] = [el for el in root.iter() if el.get("id") == target_id]
if not matches:
return None
for el in matches:
if el.tag == _G_TAG:
return el
return matches[0]
[docs]
def write_svg(tree_or_element: Any, path: str | Path | None = None) -> bytes:
"""Serialize an ``ElementTree`` or ``Element`` to bytes (and optionally disk).
Always emits ``<?xml version="1.0" encoding="UTF-8"?>`` and the SVG
namespace as the default, so the output is a valid standalone SVG.
"""
if isinstance(tree_or_element, ET.ElementTree):
root = tree_or_element.getroot()
else:
root = tree_or_element
buf = io.BytesIO()
# Don't pass default_namespace=SVG_NS — that forces ElementTree to
# require every attribute be namespace-qualified, which fails on the
# mixed input we get from wrapping/inlining external SVGs. The default
# serialization preserves the input namespace mapping.
ET.ElementTree(root).write(buf, encoding="utf-8", xml_declaration=True)
data = buf.getvalue()
if path is not None:
Path(path).write_bytes(data)
return data