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",