feat(export): add server-side AI export
Build, Push and Deploy / build (push) Failing after 6m44s
Build, Push and Deploy / deploy (push) Skipped

This commit is contained in:
lai_hong
2026-09-13 13:01:34 +08:00
parent 0877ae73e8
commit 7a506114a2
12 changed files with 632 additions and 3 deletions
+1 -1
View File
@@ -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"
+16
View File
@@ -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"]
+412
View File
@@ -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)
+2
View File
@@ -0,0 +1,2 @@
fastapi>=0.115,<1
uvicorn[standard]>=0.30,<1
+1
View File
@@ -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
+68
View File
@@ -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 生产任务,需登录)
# ═══════════════════════════════════════════════════════════
+26
View File
@@ -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
View File
@@ -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
View File
@@ -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 总图导出
- 线距分析结果显示在元素属性面板中,辅助激光加工参数设定
+38
View File
@@ -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 }}>
+32
View File
@@ -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>
+9
View File
@@ -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))