462 lines
18 KiB
Python
462 lines
18 KiB
Python
"""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"<!DOCTYPE" in raw.upper() or b"<!ENTITY" in raw.upper():
|
|
raise RequestError(HTTPStatus.BAD_REQUEST, "不支持包含 DTD 或实体的 SVG")
|
|
|
|
try:
|
|
root = ET.fromstring(raw)
|
|
ai = svg_to_ai(root)
|
|
except UnsupportedSvg as exc:
|
|
raise RequestError(HTTPStatus.UNPROCESSABLE_ENTITY, str(exc)) from exc
|
|
except (ET.ParseError, ValueError) as exc:
|
|
raise RequestError(HTTPStatus.BAD_REQUEST, f"SVG 解析失败: {exc}") from exc
|
|
return ai.encode("ascii")
|
|
|
|
|
|
class RequestError(ValueError):
|
|
def __init__(self, status: HTTPStatus, detail: str):
|
|
self.status = status
|
|
self.detail = detail
|
|
super().__init__(detail)
|
|
|
|
|
|
class ConverterHandler(BaseHTTPRequestHandler):
|
|
server_version = "WordCloudAIConverter/0.1"
|
|
|
|
def do_GET(self) -> 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()
|