#!/usr/bin/env python3 """Structured SVG checks used by validate-svg.sh. The validator intentionally uses only the Python standard library so it works inside a freshly installed skill without adding another runtime dependency. """ from __future__ import annotations import argparse import math import re import sys import xml.etree.ElementTree as ET from dataclasses import dataclass from pathlib import Path from typing import Iterator, Optional, Sequence NUMBER_RE = re.compile(r"[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?") PATH_TOKEN_RE = re.compile(r"[AaCcHhLlMmQqSsTtVvZz]|[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?") URL_REF_RE = re.compile(r"url\(\s*#([^\s)]+)\s*\)") MARKER_ATTRIBUTES = ("marker-start", "marker-mid", "marker-end") EXCLUDED_ROLES = {"background", "container", "decoration", "label", "legend"} IDENTITY = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0) Point = tuple[float, float] Matrix = tuple[float, float, float, float, float, float] @dataclass(frozen=True) class Bounds: left: float top: float right: float bottom: float @dataclass(frozen=True) class Collision: edge: str obstacle: str @dataclass(frozen=True) class ElementContext: element: ET.Element matrix: Matrix role: Optional[str] in_defs: bool def local_name(tag: str) -> str: return tag.split("}", 1)[-1] def parse_number(value: Optional[str], default: Optional[float] = 0.0) -> Optional[float]: if value is None: return default match = NUMBER_RE.match(value.strip()) if not match: return default return float(match.group(0)) def multiply(left: Matrix, right: Matrix) -> Matrix: a1, b1, c1, d1, e1, f1 = left a2, b2, c2, d2, e2, f2 = right return ( a1 * a2 + c1 * b2, b1 * a2 + d1 * b2, a1 * c2 + c1 * d2, b1 * c2 + d1 * d2, a1 * e2 + c1 * f2 + e1, b1 * e2 + d1 * f2 + f1, ) def transform_point(matrix: Matrix, point: Point) -> Point: a, b, c, d, e, f = matrix x, y = point return (a * x + c * y + e, b * x + d * y + f) def parse_transform(value: Optional[str]) -> Matrix: result = IDENTITY if not value: return result for name, raw_values in re.findall(r"([A-Za-z]+)\s*\(([^)]*)\)", value): values = [float(item) for item in NUMBER_RE.findall(raw_values)] name = name.lower() current = IDENTITY if name == "matrix" and len(values) == 6: current = tuple(values) # type: ignore[assignment] elif name == "translate" and values: current = (1, 0, 0, 1, values[0], values[1] if len(values) > 1 else 0) elif name == "scale" and values: current = (values[0], 0, 0, values[1] if len(values) > 1 else values[0], 0, 0) elif name == "rotate" and values: angle = math.radians(values[0]) rotation = (math.cos(angle), math.sin(angle), -math.sin(angle), math.cos(angle), 0, 0) if len(values) >= 3: cx, cy = values[1], values[2] current = multiply( multiply((1, 0, 0, 1, cx, cy), rotation), (1, 0, 0, 1, -cx, -cy), ) else: current = rotation result = multiply(result, current) return result def infer_role(element: ET.Element, inherited: Optional[str]) -> Optional[str]: explicit = element.get("data-graph-role") if explicit: return explicit.strip().lower() identity = " ".join(filter(None, (element.get("id"), element.get("class")))).lower() if any(token in identity for token in ("legend", "key-box", "key_box")): return "legend" if local_name(element.tag) == "g": text = " ".join("".join(child.itertext()) for child in element if local_name(child.tag) == "text").lower() if "legend" in text: return "legend" return inherited def walk(element: ET.Element, matrix: Matrix = IDENTITY, role: Optional[str] = None, in_defs: bool = False) -> Iterator[ElementContext]: current_matrix = multiply(matrix, parse_transform(element.get("transform"))) current_role = infer_role(element, role) current_in_defs = in_defs or local_name(element.tag) == "defs" yield ElementContext(element, current_matrix, current_role, current_in_defs) child_role = current_role if current_role in {"background", "decoration", "label", "legend", "node"} else None for child in element: yield from walk(child, current_matrix, child_role, current_in_defs) def canvas_size(root: ET.Element) -> Point: values = [float(item) for item in NUMBER_RE.findall(root.get("viewBox", ""))] if len(values) == 4: return values[2], values[3] return ( float(parse_number(root.get("width"), 0.0) or 0.0), float(parse_number(root.get("height"), 0.0) or 0.0), ) def bounds_from_points(points: Sequence[Point]) -> Optional[Bounds]: if not points: return None xs = [point[0] for point in points] ys = [point[1] for point in points] return Bounds(min(xs), min(ys), max(xs), max(ys)) def transformed_bounds(matrix: Matrix, points: Sequence[Point]) -> Optional[Bounds]: return bounds_from_points([transform_point(matrix, point) for point in points]) def shape_bounds(context: ElementContext, canvas: Point) -> Optional[Bounds]: element = context.element tag = local_name(element.tag) role = context.role if context.in_defs or role in EXCLUDED_ROLES: return None if tag == "rect": x = float(parse_number(element.get("x"), 0.0) or 0.0) y = float(parse_number(element.get("y"), 0.0) or 0.0) width = parse_number(element.get("width"), None) height = parse_number(element.get("height"), None) if width is None or height is None or width <= 0 or height <= 0: return None canvas_width, canvas_height = canvas if role != "node": if element.get("x") is None and element.get("y") is None: return None if height <= 28 or width <= 8: return None nearly_canvas = canvas_width > 0 and canvas_height > 0 and width >= canvas_width * 0.9 and height >= canvas_height * 0.9 if nearly_canvas: return None container_like = bool(element.get("stroke-dasharray")) or element.get("fill", "").strip().lower() == "none" if container_like and ( (canvas_width > 0 and width >= canvas_width * 0.45) or (canvas_height > 0 and height >= canvas_height * 0.45) ): return None return transformed_bounds( context.matrix, ((x, y), (x + width, y), (x + width, y + height), (x, y + height)), ) if tag in {"circle", "ellipse"}: cx = float(parse_number(element.get("cx"), 0.0) or 0.0) cy = float(parse_number(element.get("cy"), 0.0) or 0.0) rx = float(parse_number(element.get("r") or element.get("rx"), 0.0) or 0.0) ry = float(parse_number(element.get("r") or element.get("ry"), 0.0) or 0.0) if rx < 12 or ry < 12: return None return transformed_bounds( context.matrix, ((cx - rx, cy - ry), (cx + rx, cy - ry), (cx + rx, cy + ry), (cx - rx, cy + ry)), ) if tag in {"polygon", "polyline"} and not has_marker(element): values = [float(item) for item in NUMBER_RE.findall(element.get("points", ""))] points = list(zip(values[::2], values[1::2])) if len(points) < 3: return None return transformed_bounds(context.matrix, points) return None def has_marker(element: ET.Element) -> bool: return any(element.get(attribute) for attribute in MARKER_ATTRIBUTES) def marker_references(root: ET.Element) -> tuple[set[str], set[str]]: definitions = { element.get("id", "") for element in root.iter() if local_name(element.tag) == "marker" and element.get("id") } references: set[str] = set() for element in root.iter(): for attribute in MARKER_ATTRIBUTES: value = element.get(attribute, "") references.update(URL_REF_RE.findall(value)) return definitions, references def sample_quadratic(start: Point, control: Point, end: Point, steps: int = 12) -> list[Point]: return [ ( (1 - t) ** 2 * start[0] + 2 * (1 - t) * t * control[0] + t**2 * end[0], (1 - t) ** 2 * start[1] + 2 * (1 - t) * t * control[1] + t**2 * end[1], ) for t in (index / steps for index in range(1, steps + 1)) ] def sample_cubic(start: Point, first: Point, second: Point, end: Point, steps: int = 16) -> list[Point]: return [ ( (1 - t) ** 3 * start[0] + 3 * (1 - t) ** 2 * t * first[0] + 3 * (1 - t) * t**2 * second[0] + t**3 * end[0], (1 - t) ** 3 * start[1] + 3 * (1 - t) ** 2 * t * first[1] + 3 * (1 - t) * t**2 * second[1] + t**3 * end[1], ) for t in (index / steps for index in range(1, steps + 1)) ] def path_points(path_data: str) -> list[Point]: tokens = PATH_TOKEN_RE.findall(path_data or "") points: list[Point] = [] index = 0 command = "" current = (0.0, 0.0) start = current previous_cubic: Optional[Point] = None previous_quadratic: Optional[Point] = None def read(count: int) -> Optional[list[float]]: nonlocal index if index + count > len(tokens) or any(re.fullmatch(r"[A-Za-z]", token) for token in tokens[index : index + count]): return None values = [float(token) for token in tokens[index : index + count]] index += count return values def absolute(x: float, y: float, relative: bool) -> Point: return (current[0] + x, current[1] + y) if relative else (x, y) while index < len(tokens): if re.fullmatch(r"[A-Za-z]", tokens[index]): command = tokens[index] index += 1 if not command: return [] relative = command.islower() op = command.upper() if op == "Z": if current != start: points.append(start) current = start previous_cubic = previous_quadratic = None command = "" continue count = {"M": 2, "L": 2, "H": 1, "V": 1, "C": 6, "S": 4, "Q": 4, "T": 2, "A": 7}.get(op) if count is None: return [] values = read(count) if values is None: return [] if op == "M": current = absolute(values[0], values[1], relative) start = current points.append(current) command = "l" if relative else "L" elif op == "L": current = absolute(values[0], values[1], relative) points.append(current) elif op == "H": current = (current[0] + values[0], current[1]) if relative else (values[0], current[1]) points.append(current) elif op == "V": current = (current[0], current[1] + values[0]) if relative else (current[0], values[0]) points.append(current) elif op == "C": first = absolute(values[0], values[1], relative) second = absolute(values[2], values[3], relative) end = absolute(values[4], values[5], relative) points.extend(sample_cubic(current, first, second, end)) current, previous_cubic = end, second previous_quadratic = None elif op == "S": first = (2 * current[0] - previous_cubic[0], 2 * current[1] - previous_cubic[1]) if previous_cubic else current second = absolute(values[0], values[1], relative) end = absolute(values[2], values[3], relative) points.extend(sample_cubic(current, first, second, end)) current, previous_cubic = end, second previous_quadratic = None elif op == "Q": control = absolute(values[0], values[1], relative) end = absolute(values[2], values[3], relative) points.extend(sample_quadratic(current, control, end)) current, previous_quadratic = end, control previous_cubic = None elif op == "T": control = (2 * current[0] - previous_quadratic[0], 2 * current[1] - previous_quadratic[1]) if previous_quadratic else current end = absolute(values[0], values[1], relative) points.extend(sample_quadratic(current, control, end)) current, previous_quadratic = end, control previous_cubic = None elif op == "A": # Arc endpoints are exact; a chord is a conservative collision check. current = absolute(values[5], values[6], relative) points.append(current) previous_cubic = previous_quadratic = None if op not in {"C", "S", "Q", "T"}: previous_cubic = previous_quadratic = None return points def edge_points(context: ElementContext) -> list[Point]: element = context.element tag = local_name(element.tag) if context.in_defs or context.role in {"background", "decoration", "label", "legend", "node"} or not has_marker(element): return [] if tag == "line": points = [ (float(parse_number(element.get("x1"), 0.0) or 0.0), float(parse_number(element.get("y1"), 0.0) or 0.0)), (float(parse_number(element.get("x2"), 0.0) or 0.0), float(parse_number(element.get("y2"), 0.0) or 0.0)), ] elif tag == "polyline": values = [float(item) for item in NUMBER_RE.findall(element.get("points", ""))] points = list(zip(values[::2], values[1::2])) elif tag == "path": points = path_points(element.get("d", "")) else: return [] return [transform_point(context.matrix, point) for point in points] def segment_hits_bounds(start: Point, end: Point, bounds: Bounds, epsilon: float = 1e-5) -> bool: left, right = bounds.left + epsilon, bounds.right - epsilon top, bottom = bounds.top + epsilon, bounds.bottom - epsilon if left >= right or top >= bottom: return False x1, y1 = start dx, dy = end[0] - x1, end[1] - y1 low, high = 0.0, 1.0 for p, q in ((-dx, x1 - left), (dx, right - x1), (-dy, y1 - top), (dy, bottom - y1)): if abs(p) < epsilon: if q < 0: return False continue ratio = q / p if p < 0: low = max(low, ratio) else: high = min(high, ratio) if low > high: return False return high - low > epsilon and high > epsilon and low < 1 - epsilon def points_within_bounds(points: Sequence[Point], bounds: Bounds, epsilon: float = 1e-5) -> bool: return bool(points) and all( bounds.left - epsilon <= x <= bounds.right + epsilon and bounds.top - epsilon <= y <= bounds.bottom + epsilon for x, y in points ) def find_collisions(root: ET.Element) -> list[Collision]: contexts = list(walk(root)) obstacles = [ (context, bounds) for context in contexts if (bounds := shape_bounds(context, canvas_size(root))) is not None ] edges = [(context, edge_points(context)) for context in contexts] legend_bounds = { bounds for _, bounds in obstacles if sum(points_within_bounds(points, bounds) for _, points in edges if len(points) >= 2) >= 2 } obstacles = [(context, bounds) for context, bounds in obstacles if bounds not in legend_bounds] collisions: list[Collision] = [] for edge_context, points in edges: if len(points) < 2: continue if any(points_within_bounds(points, bounds) for bounds in legend_bounds): continue edge = edge_context.element for obstacle_context, bounds in obstacles: obstacle = obstacle_context.element if any(segment_hits_bounds(first, second, bounds) for first, second in zip(points, points[1:])): collisions.append( Collision( describe_element(edge), describe_element(obstacle), ) ) break return collisions def describe_element(element: ET.Element) -> str: tag = local_name(element.tag) if element.get("id"): return f"{tag}#{element.get('id')}" if tag == "path": path_data = re.sub(r"\s+", " ", element.get("d", "")).strip() return f"path[d={path_data[:72]}]" attributes = [] for name in ("x", "y", "width", "height", "cx", "cy", "r", "rx", "ry"): if element.get(name) is not None: attributes.append(f"{name}={element.get(name)}") return f"{tag}[{' '.join(attributes)}]" if attributes else tag def parse_svg(path: Path) -> ET.Element: return ET.parse(path).getroot() def run_check(path: Path, check: str) -> tuple[bool, list[str]]: try: root = parse_svg(path) except (ET.ParseError, OSError) as error: return False, [str(error)] if check == "xml": return True, [] if check == "markers": definitions, references = marker_references(root) missing = sorted(references - definitions) return not missing, [f"missing marker: {marker}" for marker in missing] collisions = find_collisions(root) details = [ f"{item.edge} intersects {item.obstacle}" for item in collisions ] return not collisions, details def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("svg_file", type=Path) parser.add_argument("--check", choices=("xml", "markers", "collisions"), required=True) return parser.parse_args() def main() -> int: args = parse_args() ok, details = run_check(args.svg_file, args.check) for detail in details: print(detail) return 0 if ok else 1 if __name__ == "__main__": raise SystemExit(main())