1import re2import xml.etree.ElementTree as ET34from defusedxml import ElementTree as SafeET56from protocol import ProtocolError, RenderInput789MAX_SVG_LENGTH = 4_194_30410SVG_NS = "http://www.w3.org/2000/svg"11XLINK_NS = "http://www.w3.org/1999/xlink"12BLOCKED_ELEMENTS = {"script", "iframe", "object", "embed", "audio", "video", "foreignobject"}13REFERENCE_ATTRIBUTES = {"href", f"{{{XLINK_NS}}}href", "src"}14STYLE_ATTRIBUTES = {"style", "filter", "fill", "stroke", "clip-path", "mask"}15SAFE_DATA_IMAGE = re.compile(r"^data:image/(?:png|jpeg|gif|webp);base64,[a-z0-9+/=\s]+$", re.IGNORECASE)16UNSAFE_CSS = re.compile(r"@import|expression\s*\(|javascript\s*:|data\s*:\s*text/html|behavior\s*:|-moz-binding", re.IGNORECASE)17CSS_URL = re.compile(r"url\s*\(\s*(['\"]?)(.*?)\1\s*\)", re.IGNORECASE)18NUMBER = re.compile(r"^-?\d+(?:\.\d+)?(?:px)?$")1920ET.register_namespace("", SVG_NS)21ET.register_namespace("xlink", XLINK_NS)222324def _local_name(value: str) -> str:25 return value.rsplit("}", 1)[-1].lower()262728def _unsafe_css(value: str) -> bool:29 if UNSAFE_CSS.search(value):30 return True31 for match in CSS_URL.finditer(value):32 reference = match.group(2).strip()33 if not reference.startswith("#") and not SAFE_DATA_IMAGE.fullmatch(reference):34 return True35 return "url(" in CSS_URL.sub("", value).lower().replace(" ", "")363738def _safe_attribute(name: str, value: str) -> bool:39 local = _local_name(name)40 if local.startswith("on") or local == "base":41 return False42 if name in REFERENCE_ATTRIBUTES or local in {"href", "src"}:43 stripped = value.strip()44 return stripped.startswith("#") or SAFE_DATA_IMAGE.fullmatch(stripped) is not None45 if local in STYLE_ATTRIBUTES:46 return not _unsafe_css(value)47 return "javascript:" not in value.lower()484950def _sanitize(parent: ET.Element) -> None:51 for name, value in list(parent.attrib.items()):52 if not _safe_attribute(name, value):53 del parent.attrib[name]54 for child in list(parent):55 if _local_name(child.tag) in BLOCKED_ELEMENTS:56 parent.remove(child)57 else:58 _sanitize(child)596061def _number(value: str | None) -> float | None:62 if value is None or not NUMBER.fullmatch(value.strip()):63 return None64 parsed = float(value.removesuffix("px"))65 return parsed if parsed > 0 else None666768def _bounds(root: ET.Element) -> tuple[float, float, float, float] | None:69 view_box = root.attrib.get("viewBox", "").replace(",", " ").split()70 if len(view_box) == 4:71 try:72 values = tuple(float(value) for value in view_box)73 if values[2] > 0 and values[3] > 0:74 return values75 except ValueError:76 pass77 width = _number(root.attrib.get("width"))78 height = _number(root.attrib.get("height"))79 return (0.0, 0.0, width, height) if width and height else None808182def _format_number(value: float) -> str:83 return str(int(value)) if value.is_integer() else str(value)848586def _white_fill(element: ET.Element) -> bool:87 fill = element.attrib.get("fill", "").replace(" ", "").lower()88 return fill in {"white", "#fff", "#ffffff", "rgb(255,255,255)"}899091def _remove_backdrop(root: ET.Element, bounds: tuple[float, float, float, float]) -> None:92 x, y, width, height = bounds93 for child in list(root):94 if _local_name(child.tag) != "rect" or not _white_fill(child):95 continue96 try:97 child_x = float(child.attrib.get("x", "0"))98 child_y = float(child.attrib.get("y", "0"))99 child_width = float(child.attrib.get("width", "nan"))100 child_height = float(child.attrib.get("height", "nan"))101 except ValueError:102 continue103 if child_x <= x + 0.01 and child_y <= y + 0.01 and child_x + child_width >= x + width - 0.01 and child_y + child_height >= y + height - 0.01:104 root.remove(child)105106107def sanitize_and_decorate(source: str, request: RenderInput) -> str:108 if len(source) > MAX_SVG_LENGTH:109 raise ProtocolError(413, "render_too_large", "Rendered SVG is too large.")110 try:111 root = SafeET.fromstring(source)112 except Exception as error:113 raise ProtocolError(422, "invalid_svg", "Renderer returned invalid SVG.") from error114 if _local_name(root.tag) != "svg":115 raise ProtocolError(422, "invalid_svg", "Renderer returned invalid SVG.")116 _sanitize(root)117118 bounds = _bounds(root)119 if (request.padding or request.background or request.frame) and bounds is None:120 raise ProtocolError(422, "missing_dimensions", "Renderer output has no usable SVG dimensions for presentation settings.")121122 additions: list[ET.Element] = []123 if request.title:124 title = ET.Element(f"{{{SVG_NS}}}title")125 title.text = request.title126 additions.append(title)127 if request.description:128 description = ET.Element(f"{{{SVG_NS}}}desc")129 description.text = request.description130 additions.append(description)131132 frame = None133 if bounds is not None:134 x, y, width, height = bounds135 padding = float(request.padding)136 next_bounds = (x - padding, y - padding, width + padding * 2, height + padding * 2)137 root.attrib["viewBox"] = " ".join(_format_number(value) for value in next_bounds)138 if "width" in root.attrib:139 root.attrib["width"] = _format_number(next_bounds[2])140 if "height" in root.attrib:141 root.attrib["height"] = _format_number(next_bounds[3])142 if request.background:143 _remove_backdrop(root, bounds)144 background = ET.Element(f"{{{SVG_NS}}}rect", {145 "x": _format_number(next_bounds[0]),146 "y": _format_number(next_bounds[1]),147 "width": _format_number(next_bounds[2]),148 "height": _format_number(next_bounds[3]),149 "fill": request.background,150 })151 additions.append(background)152 styles = [item.strip() for item in root.attrib.get("style", "").split(";") if item.strip() and not item.strip().lower().startswith(("background:", "background-color:"))]153 styles.append(f"background-color:{request.background}")154 root.attrib["style"] = ";".join(styles) + ";"155 if request.frame:156 frame = ET.Element(f"{{{SVG_NS}}}rect", {157 "x": _format_number(next_bounds[0] + 0.5),158 "y": _format_number(next_bounds[1] + 0.5),159 "width": _format_number(max(0.0, next_bounds[2] - 1)),160 "height": _format_number(max(0.0, next_bounds[3] - 1)),161 "fill": "none",162 "stroke": "#000000",163 "stroke-width": "1",164 "vector-effect": "non-scaling-stroke",165 })166167 for index, element in enumerate(additions):168 root.insert(index, element)169 if frame is not None:170 root.append(frame)171 return ET.tostring(root, encoding="unicode", short_empty_elements=False)