Files
lai_hong d870d0ecf8
Build, Push and Deploy / build (push) Successful in 6s
Build, Push and Deploy / deploy (push) Successful in 14s
fix(export): make AI converter build offline
2026-09-13 13:12:07 +08:00

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()