yizhiyanhua-ai--fireworks-tech-graph
485 行
18 KiB
Python
485 行
18 KiB
Python
#!/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())
|