feat(export): add server-side AI export
This commit is contained in:
@@ -40,7 +40,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SHA="${{ github.sha }}"
|
||||
for name in wordcloud-backend wordcloud-frontend; do
|
||||
for name in wordcloud-backend wordcloud-frontend wordcloud-ai-converter; do
|
||||
image="$HARBOR/$HARBOR_PROJECT/$name"
|
||||
docker tag "$image:latest" "$image:$SHA"
|
||||
docker push "$image:latest"
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt \
|
||||
&& useradd --system --no-create-home converter
|
||||
|
||||
COPY app.py ./
|
||||
USER converter
|
||||
|
||||
EXPOSE 8090
|
||||
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8090"]
|
||||
@@ -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)
|
||||
@@ -0,0 +1,2 @@
|
||||
fastapi>=0.115,<1
|
||||
uvicorn[standard]>=0.30,<1
|
||||
@@ -38,6 +38,7 @@ RUN cd EfficientWordCloud && python setup.py build_ext --inplace
|
||||
|
||||
# Runtime data directories (will be mounted as volumes)
|
||||
RUN mkdir -p service_workspace service_assets service_projects service_design_templates service_fonts \
|
||||
&& sed -i 's/\r$//' /app/docker-entrypoint.sh \
|
||||
&& chmod +x /app/docker-entrypoint.sh
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
@@ -15,11 +15,14 @@ import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request as UrlRequest, urlopen
|
||||
|
||||
from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core import config as wc_config
|
||||
from core.fonts import get_cached_font
|
||||
@@ -69,6 +72,12 @@ DESIGN_TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
METADATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
ORDERS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# The converter intentionally runs as a separate, unprivileged container. Do
|
||||
# not expose it publicly: the application validates payload size and forwards
|
||||
# only generated SVG documents to this private service.
|
||||
AI_CONVERTER_URL = os.environ.get("AI_CONVERTER_URL", "").rstrip("/")
|
||||
AI_EXPORT_MAX_BYTES = 25 * 1024 * 1024
|
||||
|
||||
metadata_store = MetadataStore(METADATA_DIR / "app.db")
|
||||
manager = JobManager(metadata_store)
|
||||
storage = Storage(WORKSPACE_DIR)
|
||||
@@ -993,6 +1002,65 @@ def get_file(job_id: str, kind: str):
|
||||
return FileResponse(path, media_type=media, filename=path.name)
|
||||
|
||||
|
||||
class AiExportRequest(BaseModel):
|
||||
"""The SVG is produced by this application, then converted in a private service."""
|
||||
|
||||
svg: str
|
||||
filename: str = "wordcloud.ai"
|
||||
|
||||
|
||||
def _ai_download_name(value: str) -> str:
|
||||
stem = Path(value).stem
|
||||
stem = re.sub(r"[^A-Za-z0-9._-]+", "-", stem).strip(".-")[:80]
|
||||
return f"{stem or 'wordcloud'}.ai"
|
||||
|
||||
|
||||
@app.post("/api/exports/ai")
|
||||
def export_ai(payload: AiExportRequest):
|
||||
"""Convert an application-generated SVG to an Illustrator-compatible AI file.
|
||||
|
||||
The conversion service has no public port. Keeping this proxy in the main
|
||||
API lets the frontend retain its same-origin API and gives us one place to
|
||||
enforce file-size limits and translate converter errors.
|
||||
"""
|
||||
if not AI_CONVERTER_URL:
|
||||
raise HTTPException(status_code=503, detail="AI 转换服务尚未配置")
|
||||
|
||||
svg_bytes = payload.svg.encode("utf-8")
|
||||
if not svg_bytes:
|
||||
raise HTTPException(status_code=400, detail="SVG 内容不能为空")
|
||||
if len(svg_bytes) > AI_EXPORT_MAX_BYTES:
|
||||
raise HTTPException(status_code=413, detail="SVG 文件超过 25 MB 限制")
|
||||
|
||||
request = UrlRequest(
|
||||
f"{AI_CONVERTER_URL}/convert",
|
||||
data=svg_bytes,
|
||||
headers={"Content-Type": "image/svg+xml; charset=utf-8"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=60) as response:
|
||||
ai_bytes = response.read()
|
||||
except HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")[:1000]
|
||||
try:
|
||||
detail = json.loads(detail).get("detail", detail)
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
raise HTTPException(status_code=422 if exc.code == 422 else 502, detail=f"AI 转换失败: {detail}") from exc
|
||||
except URLError as exc:
|
||||
raise HTTPException(status_code=503, detail="AI 转换服务不可用") from exc
|
||||
|
||||
if not ai_bytes:
|
||||
raise HTTPException(status_code=502, detail="AI 转换服务未返回文件")
|
||||
filename = _ai_download_name(payload.filename)
|
||||
return StreamingResponse(
|
||||
io.BytesIO(ai_bytes),
|
||||
media_type="application/postscript",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 3.9 生产订单列表(下单派单投递的 WCD 生产任务,需登录)
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
@@ -7,6 +7,8 @@ services:
|
||||
container_name: wordcloud-backend
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
AI_CONVERTER_URL: http://ai-converter:8090
|
||||
volumes:
|
||||
- wordcloud_workspace:/app/service_workspace
|
||||
- wordcloud_assets:/app/service_assets
|
||||
@@ -14,6 +16,9 @@ services:
|
||||
- wordcloud_design_templates:/app/service_design_templates
|
||||
- wordcloud_fonts:/app/service_fonts
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
ai-converter:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
|
||||
interval: 30s
|
||||
@@ -21,6 +26,27 @@ services:
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
# Private SVG -> AI5 converter. It deliberately has no published host port;
|
||||
# browser traffic must go through the backend's /api/exports/ai endpoint.
|
||||
ai-converter:
|
||||
image: "${HARBOR_REGISTRY:-114.55.99.6:10081}/wordcloud/wordcloud-ai-converter:${IMAGE_TAG:-latest}"
|
||||
build:
|
||||
context: ./ai-converter
|
||||
dockerfile: Dockerfile
|
||||
container_name: wordcloud-ai-converter
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8090/health', timeout=3).read()"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
frontend:
|
||||
image: "${HARBOR_REGISTRY:-114.55.99.6:10081}/wordcloud/wordcloud-frontend:${IMAGE_TAG:-latest}"
|
||||
build:
|
||||
|
||||
+17
@@ -211,6 +211,23 @@ SSE 事件流。事件数据模型:
|
||||
| `ring_width` | `1` | 环线宽 |
|
||||
| `ring_spacing` | `8` | 环间距 |
|
||||
|
||||
### POST `/api/exports/ai`
|
||||
|
||||
将应用生成的 SVG 转换为 Illustrator 5 兼容的 `.ai` 文件。该接口仅转发到 Compose 内部的 `ai-converter` 服务,不暴露转换容器端口。
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"svg": "<svg ...>...</svg>",
|
||||
"filename": "wordcloud.ai"
|
||||
}
|
||||
```
|
||||
|
||||
- 最大 SVG 大小:25 MB。
|
||||
- 当前支持路径、`rect`、`circle`、`ellipse`、`line`、纯色填充/描边和仿射变换。
|
||||
- 不支持图片、普通文字、SVG 图案/裁剪/透明效果时会返回 `422`,不会产生可能失真的 AI 文件。
|
||||
|
||||
## Templates
|
||||
|
||||
### GET `/api/templates`
|
||||
|
||||
+10
-2
@@ -57,7 +57,7 @@ TemplateHome(首页)
|
||||
- **层级调整**:上移、下移
|
||||
- **删除**:删除元素
|
||||
- 右侧面板支持修改画布宽高、背景色
|
||||
- 支持导出总图 SVG、清空画布
|
||||
- 支持导出总图 SVG、AI(受支持元素)、清空画布
|
||||
- **图层管理**:支持图层可见性、锁定、文件夹分组
|
||||
- **吸附对齐**:元素拖拽时自动吸附到附近元素边缘
|
||||
- **缩放**:编辑视图可缩放(不影响导出尺寸)
|
||||
@@ -73,6 +73,14 @@ TemplateHome(首页)
|
||||
- 基础形状输出为原生 SVG 的 `<rect>`、`<ellipse>`、`<line>`
|
||||
- 元素的位移和旋转写入 SVG `transform`,透明度写入 `opacity`
|
||||
|
||||
## AI 导出
|
||||
|
||||
"导出总图 AI"先按同一画布模型生成 SVG,再提交到后端 `/api/exports/ai`,由 Docker Compose 内部的 `ai-converter` 转换为 Illustrator 5 兼容 `.ai` 文件。
|
||||
|
||||
- 支持路径、矩形、椭圆、圆、线条、纯色填充/描边和仿射变换。
|
||||
- 图片贴纸、普通文字、透明度、SVG pattern/clipPath 等不保证保真的特性会明确失败,不会静默生成错误文件。
|
||||
- 词云本体由后端输出为路径,适合作为 AI 导出主场景。
|
||||
|
||||
## ZIP 导出
|
||||
|
||||
支持导出含以下内容的 ZIP 包:
|
||||
@@ -98,5 +106,5 @@ TemplateHome(首页)
|
||||
- 当前没有服务端素材库、项目文件格式或协作编辑接口(Projects 接口存在但服务层级较浅)
|
||||
- SVG 导入按用户信任文件处理;编辑器预览使用图片方式加载,不在页面中直接执行 SVG 内容
|
||||
- 当前缩放只影响编辑视图,不改变导出尺寸
|
||||
- 当前导出目标是 SVG;没有在画布页实现 PNG/JPG 总图导出
|
||||
- 画布页总图支持 SVG 和受支持元素的 AI;没有实现 PNG/JPG 总图导出
|
||||
- 线距分析结果显示在元素属性面板中,辅助激光加工参数设定
|
||||
|
||||
@@ -31,6 +31,7 @@ export default function ExportPanel({
|
||||
const [exportW, setExportW] = useState('1920');
|
||||
const [exportH, setExportH] = useState('1080');
|
||||
const [isSavingSticker, setIsSavingSticker] = useState(false);
|
||||
const [isExportingAi, setIsExportingAi] = useState(false);
|
||||
|
||||
const [stroke, setStroke] = useState(false);
|
||||
const [fillMode, setFillMode] = useState<FillMode>('fill');
|
||||
@@ -77,6 +78,27 @@ export default function ExportPanel({
|
||||
triggerDownload(url, 'wordcloud.svg');
|
||||
};
|
||||
|
||||
const handleExportAi = async () => {
|
||||
if (isExportingAi) return;
|
||||
const url = buildCustomSvgUrl();
|
||||
if (!url) return;
|
||||
setIsExportingAi(true);
|
||||
try {
|
||||
const svgResponse = await ensureOk(await fetch(url), '读取 SVG 失败');
|
||||
const svg = await svgResponse.text();
|
||||
const aiResponse = await ensureOk(await fetch(apiUrl('/api/exports/ai'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ svg, filename: 'wordcloud.ai' }),
|
||||
}), '导出 AI 失败');
|
||||
triggerDownloadBlob(await aiResponse.blob(), 'wordcloud.ai');
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : '导出 AI 失败');
|
||||
} finally {
|
||||
setIsExportingAi(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAsSticker = async (mode: 'add' | 'replace' = 'add') => {
|
||||
if (isSavingSticker) return;
|
||||
if (mode === 'replace' && !replaceTarget) {
|
||||
@@ -145,6 +167,12 @@ export default function ExportPanel({
|
||||
document.body.removeChild(a);
|
||||
};
|
||||
|
||||
const triggerDownloadBlob = (blob: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
triggerDownload(url, filename);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const hasResult = !!jobId;
|
||||
|
||||
return (
|
||||
@@ -339,6 +367,16 @@ export default function ExportPanel({
|
||||
>
|
||||
导出 SVG
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm btn-block"
|
||||
disabled={!hasResult || isExportingAi || fillMode === 'dot' || fillMode === 'ring'}
|
||||
onClick={handleExportAi}
|
||||
>
|
||||
{isExportingAi ? '正在生成 AI...' : '导出 AI'}
|
||||
</button>
|
||||
{(fillMode === 'dot' || fillMode === 'ring') && (
|
||||
<div className="note-text">AI 导出暂不支持点阵和空心圆填充;请改用填充或横线模式。</div>
|
||||
)}
|
||||
{replaceTarget ? (
|
||||
<>
|
||||
<div className="settings-note" style={{ marginTop: 8 }}>
|
||||
|
||||
@@ -471,6 +471,16 @@ export default function CanvasStudio({
|
||||
);
|
||||
}, [normalizedDocument, stickerById]);
|
||||
|
||||
const exportAi = useCallback(async (addRegistrationMarks: boolean) => {
|
||||
const svg = await serializeDocument(normalizedDocument, stickerById, { addRegistrationMarks });
|
||||
const response = await ensureOk(await fetch(apiUrl('/api/exports/ai'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ svg, filename: 'canvas-design.ai' }),
|
||||
}), '导出 AI 失败');
|
||||
downloadBlob(await response.blob(), 'canvas-design.ai');
|
||||
}, [normalizedDocument, stickerById]);
|
||||
|
||||
const exportLayerZip = async (layerIds: string[], folderIds: string[], addRegistrationMarks: boolean) => {
|
||||
const blob = await createLayerExportZip(normalizedDocument, stickerById, layerIds, folderIds, { addRegistrationMarks });
|
||||
downloadBlob(blob, 'canvas-layers.zip');
|
||||
@@ -892,6 +902,7 @@ export default function CanvasStudio({
|
||||
stickerById={stickerById}
|
||||
onUpdateDocument={updateDocument}
|
||||
onExportSvg={exportSvg}
|
||||
onExportAi={exportAi}
|
||||
onExportLayerZip={exportLayerZip}
|
||||
onReset={resetDesign}
|
||||
/>
|
||||
@@ -1414,6 +1425,7 @@ function CanvasExportPanel({
|
||||
stickerById,
|
||||
onUpdateDocument,
|
||||
onExportSvg,
|
||||
onExportAi,
|
||||
onExportLayerZip,
|
||||
onReset,
|
||||
}: {
|
||||
@@ -1421,6 +1433,7 @@ function CanvasExportPanel({
|
||||
stickerById: Map<string, StickerAsset>;
|
||||
onUpdateDocument: (partial: Partial<CanvasDocument>) => void;
|
||||
onExportSvg: (addRegistrationMarks: boolean) => void;
|
||||
onExportAi: (addRegistrationMarks: boolean) => Promise<void>;
|
||||
onExportLayerZip: (layerIds: string[], folderIds: string[], addRegistrationMarks: boolean) => void;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
@@ -1431,6 +1444,7 @@ function CanvasExportPanel({
|
||||
const [templateDescription, setTemplateDescription] = useState('');
|
||||
const [referenceFiles, setReferenceFiles] = useState<File[]>([]);
|
||||
const [addRegistrationMarks, setAddRegistrationMarks] = useState(false);
|
||||
const [exportingAi, setExportingAi] = useState(false);
|
||||
const [exportingPackage, setExportingPackage] = useState(false);
|
||||
const layers = documentModel.layers || [];
|
||||
const folders = documentModel.layerFolders || [];
|
||||
@@ -1556,6 +1570,24 @@ function CanvasExportPanel({
|
||||
<div className="note-text">在导出的总图和每个分层文件左上角、右下角添加对齐点</div>
|
||||
</div>
|
||||
<button className="btn btn-primary btn-block" onClick={() => onExportSvg(addRegistrationMarks)}>导出总图 SVG</button>
|
||||
<button
|
||||
className="btn btn-secondary btn-block"
|
||||
disabled={exportingAi}
|
||||
onClick={async () => {
|
||||
if (exportingAi) return;
|
||||
setExportingAi(true);
|
||||
try {
|
||||
await onExportAi(addRegistrationMarks);
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : '导出 AI 失败');
|
||||
} finally {
|
||||
setExportingAi(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{exportingAi ? '正在生成 AI...' : '导出总图 AI'}
|
||||
</button>
|
||||
<div className="note-text">AI 导出当前支持路径和基础形状;含图片贴纸或普通文字的画布会提示不支持。</div>
|
||||
|
||||
<div className="section-divider" />
|
||||
<div className="section-title">分层打包导出</div>
|
||||
|
||||
@@ -107,6 +107,13 @@ rsync -a \
|
||||
--exclude '.idea/' \
|
||||
"$ROOT_DIR/frontend/" "$STAGING_DIR/$PKG_NAME/frontend/"
|
||||
|
||||
# ── private AI converter ────────────────────────────────────
|
||||
mkdir -p "$STAGING_DIR/$PKG_NAME/ai-converter"
|
||||
rsync -a \
|
||||
--exclude '__pycache__/' \
|
||||
--exclude '*.py[cod]' \
|
||||
"$ROOT_DIR/ai-converter/" "$STAGING_DIR/$PKG_NAME/ai-converter/"
|
||||
|
||||
# ── docs ────────────────────────────────────────────────────
|
||||
if [[ -d "$ROOT_DIR/docs" ]]; then
|
||||
mkdir -p "$STAGING_DIR/$PKG_NAME/docs"
|
||||
@@ -154,6 +161,8 @@ assert "wordcloud/backend/wordcloud_generate_hybrid.py" in names
|
||||
assert "wordcloud/backend/service/app.py" in names
|
||||
assert "wordcloud/backend/service/line_spacing.py" in names
|
||||
assert "wordcloud/frontend/src/pages/CanvasStudio.tsx" in names
|
||||
assert "wordcloud/ai-converter/Dockerfile" in names
|
||||
assert "wordcloud/ai-converter/app.py" in names
|
||||
assert not any(".venv" in n for n in names), "venv leaked into archive"
|
||||
assert not any("/service_workspace/" in n and not n.endswith("/service_workspace") for n in names), "workspace leaked"
|
||||
print("archive entries:", len(names))
|
||||
|
||||
Reference in New Issue
Block a user