Merge remote-tracking branch 'origin/master'
# Conflicts: # docker-compose.yml
This commit is contained in:
@@ -40,7 +40,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
SHA="${{ github.sha }}"
|
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"
|
image="$HARBOR/$HARBOR_PROJECT/$name"
|
||||||
docker tag "$image:latest" "$image:$SHA"
|
docker tag "$image:latest" "$image:$SHA"
|
||||||
docker push "$image:latest"
|
docker push "$image:latest"
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN useradd --system --no-create-home converter
|
||||||
|
|
||||||
|
COPY app.py ./
|
||||||
|
USER converter
|
||||||
|
|
||||||
|
EXPOSE 8090
|
||||||
|
|
||||||
|
CMD ["python", "app.py"]
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
"""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()
|
||||||
@@ -38,6 +38,7 @@ RUN cd EfficientWordCloud && python setup.py build_ext --inplace
|
|||||||
|
|
||||||
# Runtime data directories (will be mounted as volumes)
|
# Runtime data directories (will be mounted as volumes)
|
||||||
RUN mkdir -p service_workspace service_assets service_projects service_design_templates service_fonts \
|
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
|
&& chmod +x /app/docker-entrypoint.sh
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|||||||
@@ -16,11 +16,14 @@ from contextlib import asynccontextmanager
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
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 import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse, StreamingResponse
|
from fastapi.responses import FileResponse, StreamingResponse
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from core import config as wc_config
|
from core import config as wc_config
|
||||||
from core.fonts import get_cached_font
|
from core.fonts import get_cached_font
|
||||||
@@ -82,6 +85,12 @@ METADATA_DIR.mkdir(parents=True, exist_ok=True)
|
|||||||
ORDERS_DIR.mkdir(parents=True, exist_ok=True)
|
ORDERS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
PRODUCT_ARCHIVES_DIR.mkdir(parents=True, exist_ok=True)
|
PRODUCT_ARCHIVES_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")
|
metadata_store = MetadataStore(METADATA_DIR / "app.db")
|
||||||
manager = JobManager(metadata_store)
|
manager = JobManager(metadata_store)
|
||||||
storage = Storage(WORKSPACE_DIR)
|
storage = Storage(WORKSPACE_DIR)
|
||||||
@@ -1019,6 +1028,65 @@ def get_file(job_id: str, kind: str):
|
|||||||
return FileResponse(path, media_type=media, filename=path.name)
|
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 生产任务,需登录)
|
# 3.9 生产订单列表(下单派单投递的 WCD 生产任务,需登录)
|
||||||
# ═══════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ services:
|
|||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
environment:
|
environment:
|
||||||
CLEANUP_APPLY_ENABLED: "${CLEANUP_APPLY_ENABLED:-false}"
|
CLEANUP_APPLY_ENABLED: "${CLEANUP_APPLY_ENABLED:-false}"
|
||||||
|
AI_CONVERTER_URL: http://ai-converter:8090
|
||||||
volumes:
|
volumes:
|
||||||
- wordcloud_workspace:/app/service_workspace
|
- wordcloud_workspace:/app/service_workspace
|
||||||
- wordcloud_assets:/app/service_assets
|
- wordcloud_assets:/app/service_assets
|
||||||
@@ -19,6 +20,9 @@ services:
|
|||||||
- wordcloud_metadata:/app/service_metadata
|
- wordcloud_metadata:/app/service_metadata
|
||||||
- wordcloud_orders:/app/service_orders
|
- wordcloud_orders:/app/service_orders
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
ai-converter:
|
||||||
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -26,6 +30,27 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 60s
|
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:
|
frontend:
|
||||||
image: "${HARBOR_REGISTRY:-114.55.99.6:10081}/wordcloud/wordcloud-frontend:${IMAGE_TAG:-latest}"
|
image: "${HARBOR_REGISTRY:-114.55.99.6:10081}/wordcloud/wordcloud-frontend:${IMAGE_TAG:-latest}"
|
||||||
build:
|
build:
|
||||||
|
|||||||
+17
@@ -211,6 +211,23 @@ SSE 事件流。事件数据模型:
|
|||||||
| `ring_width` | `1` | 环线宽 |
|
| `ring_width` | `1` | 环线宽 |
|
||||||
| `ring_spacing` | `8` | 环间距 |
|
| `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
|
## Templates
|
||||||
|
|
||||||
### GET `/api/templates`
|
### GET `/api/templates`
|
||||||
|
|||||||
+10
-2
@@ -57,7 +57,7 @@ TemplateHome(首页)
|
|||||||
- **层级调整**:上移、下移
|
- **层级调整**:上移、下移
|
||||||
- **删除**:删除元素
|
- **删除**:删除元素
|
||||||
- 右侧面板支持修改画布宽高、背景色
|
- 右侧面板支持修改画布宽高、背景色
|
||||||
- 支持导出总图 SVG、清空画布
|
- 支持导出总图 SVG、AI(受支持元素)、清空画布
|
||||||
- **图层管理**:支持图层可见性、锁定、文件夹分组
|
- **图层管理**:支持图层可见性、锁定、文件夹分组
|
||||||
- **吸附对齐**:元素拖拽时自动吸附到附近元素边缘
|
- **吸附对齐**:元素拖拽时自动吸附到附近元素边缘
|
||||||
- **缩放**:编辑视图可缩放(不影响导出尺寸)
|
- **缩放**:编辑视图可缩放(不影响导出尺寸)
|
||||||
@@ -73,6 +73,14 @@ TemplateHome(首页)
|
|||||||
- 基础形状输出为原生 SVG 的 `<rect>`、`<ellipse>`、`<line>`
|
- 基础形状输出为原生 SVG 的 `<rect>`、`<ellipse>`、`<line>`
|
||||||
- 元素的位移和旋转写入 SVG `transform`,透明度写入 `opacity`
|
- 元素的位移和旋转写入 SVG `transform`,透明度写入 `opacity`
|
||||||
|
|
||||||
|
## AI 导出
|
||||||
|
|
||||||
|
"导出总图 AI"先按同一画布模型生成 SVG,再提交到后端 `/api/exports/ai`,由 Docker Compose 内部的 `ai-converter` 转换为 Illustrator 5 兼容 `.ai` 文件。
|
||||||
|
|
||||||
|
- 支持路径、矩形、椭圆、圆、线条、纯色填充/描边和仿射变换。
|
||||||
|
- 图片贴纸、普通文字、透明度、SVG pattern/clipPath 等不保证保真的特性会明确失败,不会静默生成错误文件。
|
||||||
|
- 词云本体由后端输出为路径,适合作为 AI 导出主场景。
|
||||||
|
|
||||||
## ZIP 导出
|
## ZIP 导出
|
||||||
|
|
||||||
支持导出含以下内容的 ZIP 包:
|
支持导出含以下内容的 ZIP 包:
|
||||||
@@ -98,5 +106,5 @@ TemplateHome(首页)
|
|||||||
- 当前没有服务端素材库、项目文件格式或协作编辑接口(Projects 接口存在但服务层级较浅)
|
- 当前没有服务端素材库、项目文件格式或协作编辑接口(Projects 接口存在但服务层级较浅)
|
||||||
- SVG 导入按用户信任文件处理;编辑器预览使用图片方式加载,不在页面中直接执行 SVG 内容
|
- SVG 导入按用户信任文件处理;编辑器预览使用图片方式加载,不在页面中直接执行 SVG 内容
|
||||||
- 当前缩放只影响编辑视图,不改变导出尺寸
|
- 当前缩放只影响编辑视图,不改变导出尺寸
|
||||||
- 当前导出目标是 SVG;没有在画布页实现 PNG/JPG 总图导出
|
- 画布页总图支持 SVG 和受支持元素的 AI;没有实现 PNG/JPG 总图导出
|
||||||
- 线距分析结果显示在元素属性面板中,辅助激光加工参数设定
|
- 线距分析结果显示在元素属性面板中,辅助激光加工参数设定
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export default function ExportPanel({
|
|||||||
const [exportW, setExportW] = useState('1920');
|
const [exportW, setExportW] = useState('1920');
|
||||||
const [exportH, setExportH] = useState('1080');
|
const [exportH, setExportH] = useState('1080');
|
||||||
const [isSavingSticker, setIsSavingSticker] = useState(false);
|
const [isSavingSticker, setIsSavingSticker] = useState(false);
|
||||||
|
const [isExportingAi, setIsExportingAi] = useState(false);
|
||||||
|
|
||||||
const [stroke, setStroke] = useState(false);
|
const [stroke, setStroke] = useState(false);
|
||||||
const [fillMode, setFillMode] = useState<FillMode>('fill');
|
const [fillMode, setFillMode] = useState<FillMode>('fill');
|
||||||
@@ -77,6 +78,27 @@ export default function ExportPanel({
|
|||||||
triggerDownload(url, 'wordcloud.svg');
|
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') => {
|
const handleSaveAsSticker = async (mode: 'add' | 'replace' = 'add') => {
|
||||||
if (isSavingSticker) return;
|
if (isSavingSticker) return;
|
||||||
if (mode === 'replace' && !replaceTarget) {
|
if (mode === 'replace' && !replaceTarget) {
|
||||||
@@ -145,6 +167,12 @@ export default function ExportPanel({
|
|||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const triggerDownloadBlob = (blob: Blob, filename: string) => {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
triggerDownload(url, filename);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
const hasResult = !!jobId;
|
const hasResult = !!jobId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -339,6 +367,16 @@ export default function ExportPanel({
|
|||||||
>
|
>
|
||||||
导出 SVG
|
导出 SVG
|
||||||
</button>
|
</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 ? (
|
{replaceTarget ? (
|
||||||
<>
|
<>
|
||||||
<div className="settings-note" style={{ marginTop: 8 }}>
|
<div className="settings-note" style={{ marginTop: 8 }}>
|
||||||
|
|||||||
@@ -470,6 +470,16 @@ export default function CanvasStudio({
|
|||||||
);
|
);
|
||||||
}, [normalizedDocument, stickerById]);
|
}, [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 exportLayerZip = async (layerIds: string[], folderIds: string[], addRegistrationMarks: boolean) => {
|
||||||
const blob = await createLayerExportZip(normalizedDocument, stickerById, layerIds, folderIds, { addRegistrationMarks });
|
const blob = await createLayerExportZip(normalizedDocument, stickerById, layerIds, folderIds, { addRegistrationMarks });
|
||||||
downloadBlob(blob, 'canvas-layers.zip');
|
downloadBlob(blob, 'canvas-layers.zip');
|
||||||
@@ -908,6 +918,7 @@ export default function CanvasStudio({
|
|||||||
stickerById={stickerById}
|
stickerById={stickerById}
|
||||||
onUpdateDocument={updateDocument}
|
onUpdateDocument={updateDocument}
|
||||||
onExportSvg={exportSvg}
|
onExportSvg={exportSvg}
|
||||||
|
onExportAi={exportAi}
|
||||||
onExportLayerZip={exportLayerZip}
|
onExportLayerZip={exportLayerZip}
|
||||||
onReset={resetDesign}
|
onReset={resetDesign}
|
||||||
/>
|
/>
|
||||||
@@ -1447,6 +1458,7 @@ function CanvasExportPanel({
|
|||||||
stickerById,
|
stickerById,
|
||||||
onUpdateDocument,
|
onUpdateDocument,
|
||||||
onExportSvg,
|
onExportSvg,
|
||||||
|
onExportAi,
|
||||||
onExportLayerZip,
|
onExportLayerZip,
|
||||||
onReset,
|
onReset,
|
||||||
}: {
|
}: {
|
||||||
@@ -1454,6 +1466,7 @@ function CanvasExportPanel({
|
|||||||
stickerById: Map<string, StickerAsset>;
|
stickerById: Map<string, StickerAsset>;
|
||||||
onUpdateDocument: (partial: Partial<CanvasDocument>) => void;
|
onUpdateDocument: (partial: Partial<CanvasDocument>) => void;
|
||||||
onExportSvg: (addRegistrationMarks: boolean) => void;
|
onExportSvg: (addRegistrationMarks: boolean) => void;
|
||||||
|
onExportAi: (addRegistrationMarks: boolean) => Promise<void>;
|
||||||
onExportLayerZip: (layerIds: string[], folderIds: string[], addRegistrationMarks: boolean) => void;
|
onExportLayerZip: (layerIds: string[], folderIds: string[], addRegistrationMarks: boolean) => void;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -1464,6 +1477,7 @@ function CanvasExportPanel({
|
|||||||
const [templateDescription, setTemplateDescription] = useState('');
|
const [templateDescription, setTemplateDescription] = useState('');
|
||||||
const [referenceFiles, setReferenceFiles] = useState<File[]>([]);
|
const [referenceFiles, setReferenceFiles] = useState<File[]>([]);
|
||||||
const [addRegistrationMarks, setAddRegistrationMarks] = useState(false);
|
const [addRegistrationMarks, setAddRegistrationMarks] = useState(false);
|
||||||
|
const [exportingAi, setExportingAi] = useState(false);
|
||||||
const [exportingPackage, setExportingPackage] = useState(false);
|
const [exportingPackage, setExportingPackage] = useState(false);
|
||||||
const layers = documentModel.layers || [];
|
const layers = documentModel.layers || [];
|
||||||
const folders = documentModel.layerFolders || [];
|
const folders = documentModel.layerFolders || [];
|
||||||
@@ -1589,6 +1603,24 @@ function CanvasExportPanel({
|
|||||||
<div className="note-text">在导出的总图和每个分层文件左上角、右下角添加对齐点</div>
|
<div className="note-text">在导出的总图和每个分层文件左上角、右下角添加对齐点</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-primary btn-block" onClick={() => onExportSvg(addRegistrationMarks)}>导出总图 SVG</button>
|
<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-divider" />
|
||||||
<div className="section-title">分层打包导出</div>
|
<div className="section-title">分层打包导出</div>
|
||||||
|
|||||||
@@ -107,6 +107,13 @@ rsync -a \
|
|||||||
--exclude '.idea/' \
|
--exclude '.idea/' \
|
||||||
"$ROOT_DIR/frontend/" "$STAGING_DIR/$PKG_NAME/frontend/"
|
"$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 ────────────────────────────────────────────────────
|
# ── docs ────────────────────────────────────────────────────
|
||||||
if [[ -d "$ROOT_DIR/docs" ]]; then
|
if [[ -d "$ROOT_DIR/docs" ]]; then
|
||||||
mkdir -p "$STAGING_DIR/$PKG_NAME/docs"
|
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/app.py" in names
|
||||||
assert "wordcloud/backend/service/line_spacing.py" in names
|
assert "wordcloud/backend/service/line_spacing.py" in names
|
||||||
assert "wordcloud/frontend/src/pages/CanvasStudio.tsx" 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(".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"
|
assert not any("/service_workspace/" in n and not n.endswith("/service_workspace") for n in names), "workspace leaked"
|
||||||
print("archive entries:", len(names))
|
print("archive entries:", len(names))
|
||||||
|
|||||||
Reference in New Issue
Block a user