From 1f9eb2853c0cf4cf4be4e37cf29521c3e557d687 Mon Sep 17 00:00:00 2001 From: lai_hong Date: Sun, 13 Sep 2026 15:55:46 +0800 Subject: [PATCH] fix(export): outline SVG text for AI export --- backend/service/app.py | 118 +++++++++++++++++++++++++++- backend/tests/test_ai_export.py | 27 +++++++ docs/API.md | 3 +- docs/CANVAS_STUDIO.md | 3 +- frontend/src/pages/CanvasStudio.tsx | 2 +- 5 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 backend/tests/test_ai_export.py diff --git a/backend/service/app.py b/backend/service/app.py index f1d109f..0b2e8f7 100644 --- a/backend/service/app.py +++ b/backend/service/app.py @@ -18,6 +18,7 @@ from pathlib import Path from typing import Optional from urllib.error import HTTPError, URLError from urllib.request import Request as UrlRequest, urlopen +from xml.etree import ElementTree as ET from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile from fastapi.middleware.cors import CORSMiddleware @@ -90,6 +91,8 @@ PRODUCT_ARCHIVES_DIR.mkdir(parents=True, exist_ok=True) # 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 +SVG_NAMESPACE = "http://www.w3.org/2000/svg" +_AI_TEXT_FONT_CACHE: tuple[object, object, dict[int, str], int] | None = None metadata_store = MetadataStore(METADATA_DIR / "app.db") manager = JobManager(metadata_store) @@ -1035,6 +1038,109 @@ class AiExportRequest(BaseModel): filename: str = "wordcloud.ai" +def _ai_text_font() -> tuple[object, object, dict[int, str], int]: + """Load the bundled CJK font once for SVG text-to-outline conversion.""" + global _AI_TEXT_FONT_CACHE + if _AI_TEXT_FONT_CACHE is not None: + return _AI_TEXT_FONT_CACHE + + from fontTools.ttLib import TTFont + + font_path = PROJECT_ROOT / "assets" / "fonts" / "STHeiti Medium.ttc" + font = TTFont(font_path, fontNumber=0) + _AI_TEXT_FONT_CACHE = ( + font, + font.getGlyphSet(), + font.getBestCmap() or {}, + int(font["head"].unitsPerEm), + ) + return _AI_TEXT_FONT_CACHE + + +def _svg_number(value: str | None, default: float = 0) -> float: + if value is None: + return default + match = re.match(r"\s*([-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?)", value) + if not match: + raise ValueError(f"无效数值: {value}") + number = float(match.group(1)) + if not number == number or number in {float("inf"), float("-inf")}: + raise ValueError(f"无效数值: {value}") + return number + + +def _svg_tag_name(element: ET.Element) -> str: + return element.tag.rsplit("}", 1)[-1] + + +def _outline_svg_text(svg_bytes: bytes) -> bytes: + """Replace application SVG ``text`` nodes with CJK-safe glyph outlines. + + AI5/PostScript has no portable Unicode text encoding. Outlining here keeps + Chinese canvas text visually stable and leaves the private converter to + process only paths and basic geometry. + """ + try: + root = ET.fromstring(svg_bytes) + except ET.ParseError as exc: + raise ValueError(f"SVG 解析失败: {exc}") from exc + + font, glyph_set, cmap, units_per_em = _ai_text_font() + from fontTools.pens.svgPathPen import SVGPathPen + + text_only_attributes = { + "x", "y", "dx", "dy", "rotate", "textLength", "lengthAdjust", + "font-family", "font-size", "font-weight", "font-style", "text-anchor", + "dominant-baseline", "alignment-baseline", + } + + def outline_children(parent: ET.Element) -> None: + for index, element in enumerate(list(parent)): + if _svg_tag_name(element) != "text": + outline_children(element) + continue + if list(element): + raise ValueError("AI 导出暂不支持包含 tspan 等子节点的文字") + + try: + font_size = _svg_number(element.get("font-size"), 16) + x = _svg_number(element.get("x"), 0) + y = _svg_number(element.get("y"), 0) + except ValueError as exc: + raise ValueError(f"AI 文字轮廓转换失败: {exc}") from exc + if font_size <= 0: + raise ValueError("AI 文字轮廓转换失败: font-size 必须大于 0") + + outer = ET.Element( + f"{{{SVG_NAMESPACE}}}g", + {key: value for key, value in element.attrib.items() if key not in text_only_attributes}, + ) + scale = font_size / units_per_em + glyph_group = ET.SubElement( + outer, + f"{{{SVG_NAMESPACE}}}g", + {"transform": f"translate({x:g} {y:g}) scale({scale:.12g} {-scale:.12g})"}, + ) + cursor = 0.0 + for character in element.text or "": + glyph_name = cmap.get(ord(character), ".notdef") + glyph = glyph_set.get(glyph_name) or glyph_set[".notdef"] + pen = SVGPathPen(glyph_set) + glyph.draw(pen) + path_data = pen.getCommands() + if path_data: + ET.SubElement( + glyph_group, + f"{{{SVG_NAMESPACE}}}path", + {"d": path_data, "transform": f"translate({cursor:g} 0)"}, + ) + cursor += float(glyph.width) + parent[index] = outer + + outline_children(root) + return ET.tostring(root, encoding="utf-8") + + def _ai_download_name(value: str) -> str: stem = Path(value).stem stem = re.sub(r"[^A-Za-z0-9._-]+", "-", stem).strip(".-")[:80] @@ -1052,11 +1158,17 @@ def export_ai(payload: AiExportRequest): if not AI_CONVERTER_URL: raise HTTPException(status_code=503, detail="AI 转换服务尚未配置") - svg_bytes = payload.svg.encode("utf-8") - if not svg_bytes: + raw_svg_bytes = payload.svg.encode("utf-8") + if not raw_svg_bytes: raise HTTPException(status_code=400, detail="SVG 内容不能为空") - if len(svg_bytes) > AI_EXPORT_MAX_BYTES: + if len(raw_svg_bytes) > AI_EXPORT_MAX_BYTES: raise HTTPException(status_code=413, detail="SVG 文件超过 25 MB 限制") + try: + svg_bytes = _outline_svg_text(raw_svg_bytes) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + if len(svg_bytes) > AI_EXPORT_MAX_BYTES: + raise HTTPException(status_code=413, detail="文字转轮廓后的 SVG 超过 25 MB 限制") request = UrlRequest( f"{AI_CONVERTER_URL}/convert", diff --git a/backend/tests/test_ai_export.py b/backend/tests/test_ai_export.py new file mode 100644 index 0000000..b6fbb31 --- /dev/null +++ b/backend/tests/test_ai_export.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import sys +from pathlib import Path +from xml.etree import ElementTree as ET + + +BACKEND_DIR = Path(__file__).resolve().parents[1] +if str(BACKEND_DIR) not in sys.path: + sys.path.insert(0, str(BACKEND_DIR)) + +from service import app as service_app # noqa: E402 + + +def test_ai_export_outlines_chinese_svg_text() -> None: + source = b''' + 你好 AI + ''' + + outlined = service_app._outline_svg_text(source) + root = ET.fromstring(outlined) + names = [element.tag.rsplit("}", 1)[-1] for element in root.iter()] + + assert "text" not in names + assert names.count("path") >= 3 + assert b'rotate(2 0 0)' in outlined + assert b'fill="#112233"' in outlined diff --git a/docs/API.md b/docs/API.md index 44d3ca5..de821d4 100644 --- a/docs/API.md +++ b/docs/API.md @@ -226,7 +226,8 @@ SSE 事件流。事件数据模型: - 最大 SVG 大小:25 MB。 - 当前支持路径、`rect`、`circle`、`ellipse`、`line`、纯色填充/描边和仿射变换。 -- 不支持图片、普通文字、SVG 图案/裁剪/透明效果时会返回 `422`,不会产生可能失真的 AI 文件。 +- 普通 SVG `text` 会先使用内置中文字体转换为轮廓路径,以保证中文在 AI 中可见;文本将不再是可编辑文字。 +- 不支持图片、SVG 图案/裁剪/透明效果时会返回 `422`,不会产生可能失真的 AI 文件。 ## Templates diff --git a/docs/CANVAS_STUDIO.md b/docs/CANVAS_STUDIO.md index cb520a3..77b7745 100644 --- a/docs/CANVAS_STUDIO.md +++ b/docs/CANVAS_STUDIO.md @@ -78,7 +78,8 @@ TemplateHome(首页) "导出总图 AI"先按同一画布模型生成 SVG,再提交到后端 `/api/exports/ai`,由 Docker Compose 内部的 `ai-converter` 转换为 Illustrator 5 兼容 `.ai` 文件。 - 支持路径、矩形、椭圆、圆、线条、纯色填充/描边和仿射变换。 -- 图片贴纸、普通文字、透明度、SVG pattern/clipPath 等不保证保真的特性会明确失败,不会静默生成错误文件。 +- 普通文字会在服务端使用内置中文字体转换为轮廓路径,避免 Illustrator 的字体缺失或中文编码问题;导出后文字不再是可编辑文本。 +- 图片贴纸、透明度、SVG pattern/clipPath 等不保证保真的特性会明确失败,不会静默生成错误文件。 - 词云本体由后端输出为路径,适合作为 AI 导出主场景。 ## ZIP 导出 diff --git a/frontend/src/pages/CanvasStudio.tsx b/frontend/src/pages/CanvasStudio.tsx index 23343d6..743b838 100644 --- a/frontend/src/pages/CanvasStudio.tsx +++ b/frontend/src/pages/CanvasStudio.tsx @@ -1620,7 +1620,7 @@ function CanvasExportPanel({ > {exportingAi ? '正在生成 AI...' : '导出总图 AI'} -
AI 导出当前支持路径和基础形状;含图片贴纸或普通文字的画布会提示不支持。
+
AI 导出会将普通文字转换为轮廓路径;图片贴纸、透明效果和复杂 SVG 效果暂不支持。
分层打包导出