feat(export): add server-side AI export
This commit is contained in:
@@ -0,0 +1,412 @@
|
||||
"""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 math
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Iterable
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import Response
|
||||
|
||||
app = FastAPI(title="WordCloud AI Converter", version="0.1.0")
|
||||
|
||||
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*\(([^)]*)\)")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/convert")
|
||||
async def convert(request: Request) -> Response:
|
||||
raw = await request.body()
|
||||
if not raw:
|
||||
raise HTTPException(status_code=400, detail="SVG 内容不能为空")
|
||||
if len(raw) > MAX_SVG_BYTES:
|
||||
raise HTTPException(status_code=413, detail="SVG 文件超过 25 MB 限制")
|
||||
if b"<!DOCTYPE" in raw.upper() or b"<!ENTITY" in raw.upper():
|
||||
raise HTTPException(status_code=400, detail="不支持包含 DTD 或实体的 SVG")
|
||||
|
||||
try:
|
||||
root = ET.fromstring(raw)
|
||||
ai = svg_to_ai(root)
|
||||
except UnsupportedSvg as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except (ET.ParseError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=f"SVG 解析失败: {exc}") from exc
|
||||
|
||||
return Response(
|
||||
content=ai.encode("ascii"),
|
||||
media_type="application/postscript",
|
||||
headers={"Content-Disposition": 'attachment; filename="wordcloud.ai"'},
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user