"""Small, isolated SVG -> Illustrator 5 (PostScript) converter. The WordCloud renderer emits paths, solid fills, strokes and affine transforms. This service intentionally supports that deterministic subset and rejects SVG features that would otherwise produce a silently corrupted AI document. """ from __future__ import annotations import json import math import re from datetime import datetime, timezone from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Iterable from xml.etree import ElementTree as ET MAX_SVG_BYTES = 25 * 1024 * 1024 SVG_NS = "{http://www.w3.org/2000/svg}" NUMBER = r"[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?" PATH_TOKENS = re.compile(rf"[AaCcHhLlMmQqSsTtVvZz]|{NUMBER}") TRANSFORM = re.compile(rf"([A-Za-z]+)\s*\(([^)]*)\)") def convert_svg(raw: bytes) -> bytes: if not raw: raise RequestError(HTTPStatus.BAD_REQUEST, "SVG 内容不能为空") if len(raw) > MAX_SVG_BYTES: raise RequestError(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "SVG 文件超过 25 MB 限制") if b" None: if self.path == "/health": self.send_bytes(HTTPStatus.OK, b'{"status":"ok"}', "application/json") return self.send_error(HTTPStatus.NOT_FOUND) def do_POST(self) -> None: if self.path != "/convert": self.send_error(HTTPStatus.NOT_FOUND) return try: length = int(self.headers.get("Content-Length", "")) if length < 0: raise ValueError except ValueError: self.send_json_error(HTTPStatus.BAD_REQUEST, "缺少或无效 Content-Length") return if length > MAX_SVG_BYTES: self.send_json_error(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "SVG 文件超过 25 MB 限制") return raw = self.rfile.read(length) try: ai = convert_svg(raw) except RequestError as exc: self.send_json_error(exc.status, exc.detail) return self.send_bytes( HTTPStatus.OK, ai, "application/postscript", {"Content-Disposition": 'attachment; filename="wordcloud.ai"'}, ) def send_json_error(self, status: HTTPStatus, detail: str) -> None: body = json.dumps({"detail": detail}, ensure_ascii=False).encode("utf-8") self.send_bytes(status, body, "application/json; charset=utf-8") def send_bytes(self, status: HTTPStatus, body: bytes, content_type: str, headers: dict[str, str] | None = None) -> None: self.send_response(status) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(body))) for key, value in (headers or {}).items(): self.send_header(key, value) self.end_headers() self.wfile.write(body) def log_message(self, _format: str, *_args: object) -> None: # The reverse proxy already logs requests; avoid echoing SVG payloads. return class UnsupportedSvg(ValueError): pass def svg_to_ai(root: ET.Element) -> str: if local_name(root.tag) != "svg": raise UnsupportedSvg("根节点必须是 svg") width, height = canvas_size(root) now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") lines = [ "%!PS-Adobe-3.0", "%%Creator: WordCloud AI Converter", "%%Title: WordCloud export", f"%%CreationDate: {now}", f"%%BoundingBox: 0 0 {format_number(width * 0.75)} {format_number(height * 0.75)}", "%%DocumentProcessColors: Cyan Magenta Yellow Black", "%%DocumentNeededResources: procset Adobe_packedarray 2.0 0", "%%DocumentSuppliedResources: procset Adobe_Illustrator 1.0 0", "%%AI5_FileFormat 3", "%%EndComments", "%%BeginProlog", "%%EndProlog", "%%BeginSetup", "%%EndSetup", "%%Page: 1 1", "q", # SVG uses 96 px/in and a top-left origin; AI/PostScript uses points and # a bottom-left origin. This CTM preserves physical dimensions. f"[0.75 0 0 -0.75 0 {format_number(height * 0.75)}] concat", ] emit_children(root, lines, Style()) lines.extend(["Q", "showpage", "%%Trailer", "%%EOF", ""]) return "\n".join(lines) def emit_children(parent: ET.Element, lines: list[str], inherited: "Style") -> None: for element in parent: tag = local_name(element.tag) if tag in {"title", "desc", "metadata", "defs", "clipPath", "mask"}: if tag in {"clipPath", "mask"}: raise UnsupportedSvg(f"暂不支持 SVG {tag},请先将其转为路径") continue if tag in {"g", "svg"}: lines.append("q") transform = element.get("transform") if transform: lines.extend(postscript_transform(transform)) emit_children(element, lines, inherited.merged(element)) lines.append("Q") continue if tag == "path": emit_path(element, lines, inherited) continue if tag == "rect": emit_rect(element, lines, inherited) continue if tag == "circle": emit_circle(element, lines, inherited) continue if tag == "ellipse": emit_ellipse(element, lines, inherited) continue if tag == "line": emit_line(element, lines, inherited) continue if tag in {"image", "text", "use", "polygon", "polyline"}: raise UnsupportedSvg(f"暂不支持 SVG {tag};词云导出仅支持路径、基础形状和描边") raise UnsupportedSvg(f"不支持的 SVG 节点: {tag}") class Style: def __init__(self, *, fill: str = "#000000", stroke: str = "none", stroke_width: float = 1, opacity: float = 1): self.fill = fill self.stroke = stroke self.stroke_width = stroke_width self.opacity = opacity def merged(self, element: ET.Element) -> "Style": values: dict[str, str] = {} if element.get("style"): for declaration in element.get("style", "").split(";"): key, separator, value = declaration.partition(":") if separator: values[key.strip()] = value.strip() for key in ("fill", "stroke", "stroke-width", "opacity", "fill-opacity", "stroke-opacity"): if element.get(key) is not None: values[key] = element.get(key, "") opacity = float(values.get("opacity", self.opacity)) if float(values.get("fill-opacity", "1")) != 1 or float(values.get("stroke-opacity", "1")) != 1 or opacity != 1: raise UnsupportedSvg("AI5 导出暂不支持透明度;请先展平透明效果") return Style( fill=values.get("fill", self.fill), stroke=values.get("stroke", self.stroke), stroke_width=parse_number(values.get("stroke-width", str(self.stroke_width))), opacity=opacity, ) def emit_path(element: ET.Element, lines: list[str], inherited: Style) -> None: path = element.get("d") if not path: return emit_geometry(element, lines, inherited, path_to_postscript(path)) def emit_rect(element: ET.Element, lines: list[str], inherited: Style) -> None: if element.get("rx") or element.get("ry"): raise UnsupportedSvg("暂不支持圆角矩形") x = parse_number(element.get("x", "0")) y = parse_number(element.get("y", "0")) width = positive_number(element.get("width"), "rect width") height = positive_number(element.get("height"), "rect height") geometry = [ f"{n(x)} {n(y)} m", f"{n(x + width)} {n(y)} l", f"{n(x + width)} {n(y + height)} l", f"{n(x)} {n(y + height)} l", "h", ] emit_geometry(element, lines, inherited, geometry) def emit_circle(element: ET.Element, lines: list[str], inherited: Style) -> None: cx = parse_number(element.get("cx", "0")) cy = parse_number(element.get("cy", "0")) radius = positive_number(element.get("r"), "circle r") emit_geometry(element, lines, inherited, ellipse_path(cx, cy, radius, radius)) def emit_ellipse(element: ET.Element, lines: list[str], inherited: Style) -> None: cx = parse_number(element.get("cx", "0")) cy = parse_number(element.get("cy", "0")) rx = positive_number(element.get("rx"), "ellipse rx") ry = positive_number(element.get("ry"), "ellipse ry") emit_geometry(element, lines, inherited, ellipse_path(cx, cy, rx, ry)) def emit_line(element: ET.Element, lines: list[str], inherited: Style) -> None: x1 = parse_number(element.get("x1", "0")) y1 = parse_number(element.get("y1", "0")) x2 = parse_number(element.get("x2", "0")) y2 = parse_number(element.get("y2", "0")) emit_geometry(element, lines, inherited, [f"{n(x1)} {n(y1)} m", f"{n(x2)} {n(y2)} l"], force_stroke=True) def emit_geometry(element: ET.Element, lines: list[str], inherited: Style, geometry: Iterable[str], force_stroke: bool = False) -> None: style = inherited.merged(element) path_commands = list(geometry) lines.append("q") if element.get("transform"): lines.extend(postscript_transform(element.get("transform", ""))) draw_fill = style.fill.lower() != "none" and not force_stroke draw_stroke = style.stroke.lower() != "none" or force_stroke if draw_fill: lines.extend(path_commands) lines.extend(color_command(style.fill)) if draw_fill: lines.append("f") if draw_stroke: # PostScript's combined fill-and-stroke operator uses one color for # both operations. Replay the path after the fill so distinct SVG # fill and stroke colors remain distinct in Illustrator. lines.extend(path_commands) lines.append(f"{n(style.stroke_width)} w") lines.extend(color_command(style.stroke if style.stroke.lower() != "none" else "#000000")) lines.append("S") lines.append("Q") def path_to_postscript(data: str) -> list[str]: tokens = PATH_TOKENS.findall(data.replace(",", " ")) if not tokens: raise UnsupportedSvg("path 缺少可识别的路径数据") index = 0 command: str | None = None x = y = start_x = start_y = 0.0 previous_control: tuple[float, float] | None = None output: list[str] = [] def take(count: int) -> list[float]: nonlocal index if index + count > len(tokens) or any(is_command(token) for token in tokens[index:index + count]): raise UnsupportedSvg("path 参数不完整") result = [float(token) for token in tokens[index:index + count]] index += count return result while index < len(tokens): if is_command(tokens[index]): command = tokens[index] index += 1 elif command is None: raise UnsupportedSvg("path 缺少命令") assert command is not None relative = command.islower() op = command.upper() if op == "Z": output.append("h") x, y = start_x, start_y previous_control = None command = None continue if op == "M": values = take(2) x, y = coordinate(values[0], values[1], x, y, relative) start_x, start_y = x, y output.append(f"{n(x)} {n(y)} m") command = "l" if relative else "L" previous_control = None continue if op == "L": values = take(2) x, y = coordinate(values[0], values[1], x, y, relative) output.append(f"{n(x)} {n(y)} l") previous_control = None continue if op == "H": value = take(1)[0] x = x + value if relative else value output.append(f"{n(x)} {n(y)} l") previous_control = None continue if op == "V": value = take(1)[0] y = y + value if relative else value output.append(f"{n(x)} {n(y)} l") previous_control = None continue if op in {"C", "S"}: values = take(6 if op == "C" else 4) if op == "C": c1x, c1y = coordinate(values[0], values[1], x, y, relative) c2x, c2y = coordinate(values[2], values[3], x, y, relative) nx, ny = coordinate(values[4], values[5], x, y, relative) else: c1x, c1y = (2 * x - previous_control[0], 2 * y - previous_control[1]) if previous_control else (x, y) c2x, c2y = coordinate(values[0], values[1], x, y, relative) nx, ny = coordinate(values[2], values[3], x, y, relative) output.append(f"{n(c1x)} {n(c1y)} {n(c2x)} {n(c2y)} {n(nx)} {n(ny)} c") x, y, previous_control = nx, ny, (c2x, c2y) continue if op in {"Q", "T"}: values = take(4 if op == "Q" else 2) if op == "Q": qx, qy = coordinate(values[0], values[1], x, y, relative) nx, ny = coordinate(values[2], values[3], x, y, relative) else: qx, qy = (2 * x - previous_control[0], 2 * y - previous_control[1]) if previous_control else (x, y) nx, ny = coordinate(values[0], values[1], x, y, relative) c1x, c1y = x + (2 / 3) * (qx - x), y + (2 / 3) * (qy - y) c2x, c2y = nx + (2 / 3) * (qx - nx), ny + (2 / 3) * (qy - ny) output.append(f"{n(c1x)} {n(c1y)} {n(c2x)} {n(c2y)} {n(nx)} {n(ny)} c") x, y, previous_control = nx, ny, (qx, qy) continue if op == "A": raise UnsupportedSvg("暂不支持椭圆弧路径;请先转换为贝塞尔路径") raise UnsupportedSvg(f"不支持的 path 命令: {command}") return output def postscript_transform(value: str) -> list[str]: commands: list[str] = [] consumed = "".join(match.group(0) for match in TRANSFORM.finditer(value)) if consumed.replace(" ", "") != value.replace(" ", ""): raise UnsupportedSvg(f"无法解析 transform: {value}") for match in TRANSFORM.finditer(value): name = match.group(1) values = [float(token) for token in re.findall(NUMBER, match.group(2))] if name == "translate" and len(values) in {1, 2}: commands.append(f"[1 0 0 1 {n(values[0])} {n(values[1] if len(values) == 2 else 0)}] concat") elif name == "scale" and len(values) in {1, 2}: commands.append(f"[{n(values[0])} 0 0 {n(values[1] if len(values) == 2 else values[0])} 0 0] concat") elif name == "matrix" and len(values) == 6: commands.append("[{}] concat".format(" ".join(n(number) for number in values))) elif name == "rotate" and len(values) in {1, 3}: angle = values[0] if len(values) == 3: commands.append(f"[1 0 0 1 {n(values[1])} {n(values[2])}] concat") commands.append(f"{n(angle)} rotate") if len(values) == 3: commands.append(f"[1 0 0 1 {n(-values[1])} {n(-values[2])}] concat") else: raise UnsupportedSvg(f"不支持的 transform: {name}") return commands def ellipse_path(cx: float, cy: float, rx: float, ry: float) -> list[str]: kappa = 0.5522847498307936 return [ f"{n(cx + rx)} {n(cy)} m", f"{n(cx + rx)} {n(cy + kappa * ry)} {n(cx + kappa * rx)} {n(cy + ry)} {n(cx)} {n(cy + ry)} c", f"{n(cx - kappa * rx)} {n(cy + ry)} {n(cx - rx)} {n(cy + kappa * ry)} {n(cx - rx)} {n(cy)} c", f"{n(cx - rx)} {n(cy - kappa * ry)} {n(cx - kappa * rx)} {n(cy - ry)} {n(cx)} {n(cy - ry)} c", f"{n(cx + kappa * rx)} {n(cy - ry)} {n(cx + rx)} {n(cy - kappa * ry)} {n(cx + rx)} {n(cy)} c", "h", ] def color_command(value: str) -> list[str]: text = value.strip() match = re.fullmatch(r"#([0-9a-fA-F]{6})", text) if not match: raise UnsupportedSvg(f"仅支持 #RRGGBB 颜色,收到: {value}") rgb = match.group(1) return [f"{n(int(rgb[0:2], 16) / 255)} {n(int(rgb[2:4], 16) / 255)} {n(int(rgb[4:6], 16) / 255)} setrgbcolor"] def canvas_size(root: ET.Element) -> tuple[float, float]: view_box = root.get("viewBox") or root.get("viewbox") if view_box: values = [float(token) for token in re.findall(NUMBER, view_box)] if len(values) == 4 and values[2] > 0 and values[3] > 0: if values[0] != 0 or values[1] != 0: raise UnsupportedSvg("暂不支持非零 viewBox 起点") return values[2], values[3] return positive_number(root.get("width"), "svg width"), positive_number(root.get("height"), "svg height") def coordinate(x: float, y: float, current_x: float, current_y: float, relative: bool) -> tuple[float, float]: return (x + current_x, y + current_y) if relative else (x, y) def local_name(tag: str) -> str: return tag.rsplit("}", 1)[-1] def is_command(value: str) -> bool: return len(value) == 1 and value.isalpha() def parse_number(value: str) -> float: match = re.match(NUMBER, value.strip()) if not match: raise ValueError(f"无效数字: {value}") return float(match.group(0)) def positive_number(value: str | None, label: str) -> float: if value is None: raise ValueError(f"缺少 {label}") number = parse_number(value) if not math.isfinite(number) or number <= 0: raise ValueError(f"无效 {label}") return number def format_number(value: float) -> str: if not math.isfinite(value): raise ValueError("数值必须是有限数") return f"{value:.6f}".rstrip("0").rstrip(".") or "0" def n(value: float) -> str: return format_number(value) if __name__ == "__main__": ThreadingHTTPServer(("0.0.0.0", 8090), ConverterHandler).serve_forever()