an.characters.svg_utils
SVG manipulation: namespace-aware DOM helpers using stdlib xml.etree.
Pure stdlib so the package stays dependency-light. Operations supported:
promote_inkscape_labels_to_ids()— copyinkscape:labeltoidon each group, since Inkscape doesn’t auto-promote labels (a 2008-vintage bug; see research §1.2).normalize_svg()— promote labels, ensure a viewBox is set, return the parsedElementTree.extract_pivots()— read the<g id="skeleton">group of named<circle>elements and return{name: (cx, cy)}.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’swidth/height, which letterboxes the part underpreserveAspectRatio="xMidYMid meet"(see #75). The crop rect’s parent-space origin survives as the viewBox’s first two numbers.write_svg()— pretty-print anElementTree(orElement) 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
- an.characters.svg_utils.extract_part(source: Any, part_id: str, *, crop_viewbox: bool = True, padding: float = 8.0) ElementTree[source]
Emit a standalone SVG tree containing only the group with the given id.
Any top-level
<defs>from the source is copied so the part can resolve gradient / pattern / filter references likefill="url(#some_gradient)". The matched group is appended unchanged.When
crop_viewboxis True (the default), the new SVG’s viewBox is cropped to the bounding box of the part’s primitive content (rect / circle / ellipse / path) pluspaddingunits on each side. This keeps a part’s texture proportional to its content instead of to the whole character canvas. Falls back to the source viewBox when no bbox can be derived.The emitted
width/heightalways match the emitted viewBox, so the part rasterises at its own extent and is never letterboxed inside a canvas it does not fill. The crop rect’s parent-space origin survives as the viewBox’s first two numbers, so where the part sat relative to its siblings is not lost and needs no separate record.If no match is found, raises
KeyError.
- an.characters.svg_utils.extract_pivots(source: Any, *, skeleton_id: str = 'skeleton') dict[str, tuple[float, float]][source]
Return
{name: (cx, cy)}for every named<circle>under skeleton.Pivots use the Pose Animator convention: a
<g id="skeleton">group sibling of the illustration, containing one<circle>per named joint. The circle’scx/cyis the pivot in the same coordinate system as the art (the SVG’s viewBox).
- an.characters.svg_utils.normalize_svg(source: Any, *, fallback_viewbox: str = '0 0 1024 1024') ElementTree[source]
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.
- an.characters.svg_utils.promote_inkscape_labels_to_ids(tree: ElementTree) int[source]
Copy
inkscape:labeltoidon each group missing an id.Returns the number of groups updated.
Inkscape stores the user-visible name in the
inkscape:labelattribute and does NOT promote it toidon save. This is a long-standing UX issue (Inkscape bug #243383); the workaround is to promote at parse time.
- an.characters.svg_utils.raster_size(source: Any) tuple[float, float][source]
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
Spritethen scales — not the extent of the drawn art. The two differ wheneverextract_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/heightis 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)
- an.characters.svg_utils.write_svg(tree_or_element: Any, path: str | Path | None = None) bytes[source]
Serialize an
ElementTreeorElementto 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.