fix(export): outline SVG text for AI export
Build, Push and Deploy / build (push) Successful in 12s
Build, Push and Deploy / deploy (push) Successful in 25s

This commit is contained in:
lai_hong
2026-09-13 15:55:46 +08:00
parent cc5c3f9751
commit 1f9eb2853c
5 changed files with 147 additions and 6 deletions
+115 -3
View File
@@ -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",
+27
View File
@@ -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'''<svg xmlns="http://www.w3.org/2000/svg" width="300" height="100">
<text x="12" y="56" font-size="42" fill="#112233" transform="rotate(2 0 0)">你好 AI</text>
</svg>'''
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
+2 -1
View File
@@ -226,7 +226,8 @@ SSE 事件流。事件数据模型:
- 最大 SVG 大小:25 MB。
- 当前支持路径、`rect``circle``ellipse``line`、纯色填充/描边和仿射变换。
- 不支持图片、普通文字、SVG 图案/裁剪/透明效果时会返回 `422`,不会产生可能失真的 AI 文件
- 普通 SVG `text` 会先使用内置中文字体转换为轮廓路径,以保证中文在 AI 中可见;文本将不再是可编辑文字
- 不支持图片、SVG 图案/裁剪/透明效果时会返回 `422`,不会产生可能失真的 AI 文件。
## Templates
+2 -1
View File
@@ -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 导出
+1 -1
View File
@@ -1620,7 +1620,7 @@ function CanvasExportPanel({
>
{exportingAi ? '正在生成 AI...' : '导出总图 AI'}
</button>
<div className="note-text">AI </div>
<div className="note-text">AI SVG </div>
<div className="section-divider" />
<div className="section-title"></div>