Compare commits

..
2 Commits
Author SHA1 Message Date
broccoli 31e7053bb0 Add product wordcloud board links
Build, Push and Deploy / build (push) Successful in 9s
Build, Push and Deploy / deploy (push) Successful in 24s
2026-09-14 15:32:31 +08:00
lai_hong 1f9eb2853c 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
2026-09-13 15:55:46 +08:00
11 changed files with 258 additions and 9 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
View File
@@ -56,6 +56,8 @@ services:
build:
context: ./frontend
dockerfile: Dockerfile
args:
VITE_WORDCLOUD_BOARD_BASE_URL: ${VITE_WORDCLOUD_BOARD_BASE_URL:-http://114.55.99.6:47880}
container_name: wordcloud-frontend
ports:
- "3000:80"
+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 导出
+2
View File
@@ -9,6 +9,8 @@ COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
ARG VITE_WORDCLOUD_BOARD_BASE_URL=http://114.55.99.6:47880
ENV VITE_WORDCLOUD_BOARD_BASE_URL=$VITE_WORDCLOUD_BOARD_BASE_URL
RUN npm run build
# ── Stage 2: Serve with Nginx ──────────────────────────────────────────────
+19
View File
@@ -0,0 +1,19 @@
const DEFAULT_WORDCLOUD_BOARD_BASE_URL = 'http://114.55.99.6:47880';
export type WordCloudBoardRoute = 'cloud' | 'screen' | 'control';
function trimTrailingSlashes(value: string): string {
return value.replace(/\/+$/, '');
}
export const WORDCLOUD_BOARD_BASE_URL = trimTrailingSlashes(
import.meta.env.VITE_WORDCLOUD_BOARD_BASE_URL?.trim()
|| DEFAULT_WORDCLOUD_BOARD_BASE_URL,
);
export function wordCloudBoardUrl(
route: WordCloudBoardRoute,
productId: string,
): string {
return `${WORDCLOUD_BOARD_BASE_URL}/${route}/${encodeURIComponent(productId)}`;
}
+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>
+42
View File
@@ -3,6 +3,7 @@ import type { FormEvent } from 'react';
import AppSettingsWindow, { type ThemeMode } from '../components/AppSettingsWindow';
import { apiUrl, ensureOk } from '../lib/api';
import { IconArchive, IconGrid, IconHelp, IconLock, IconRefresh, IconTrash } from '../components/Icons';
import { wordCloudBoardUrl, type WordCloudBoardRoute } from '../lib/wordcloudBoard';
import {
deleteProduct,
getProduct,
@@ -21,6 +22,12 @@ interface ProductArchivePageProps {
const TOKEN_KEY = 'wordcloud-orders-token';
const wordCloudBoardActions: Array<{ label: string; route: WordCloudBoardRoute }> = [
{ label: '个人查找', route: 'cloud' },
{ label: '现场大屏', route: 'screen' },
{ label: '手机控制', route: 'control' },
];
function statusClass(status: ProductStatus): string {
if (status === 'active') return 'product-status-archived';
if (status === 'pending_cleanup' || status === 'failed_cleanup') return 'product-status-pending-cleanup';
@@ -57,6 +64,12 @@ function formatDateTime(value: string): string {
});
}
function hasWordCloudArchive(detail: ProductDetail): boolean {
return detail.versions.some(version =>
version.wordcloud_archives.some(archive => archive.source_job_id.trim().length > 0),
);
}
function AuthenticatedImage({
token,
imageUrl,
@@ -257,6 +270,9 @@ export default function ProductArchivePage({
: null;
const previewUrl = previewImage?.image_url || '';
const latestVersion = detail?.versions[detail.versions.length - 1] || null;
const canOpenWordCloudBoard = detail !== null
&& selected?.status === 'active'
&& hasWordCloudArchive(detail);
return (
<div className="orders-page product-archive-page">
@@ -426,6 +442,32 @@ export default function ProductArchivePage({
</div>
</div>
<section className="product-archive-board-links" aria-labelledby="wordcloud-board-title">
<h3 id="wordcloud-board-title"></h3>
<div className="product-archive-board-actions">
{wordCloudBoardActions.map(action => (
canOpenWordCloudBoard ? (
<a
className="btn btn-secondary btn-sm"
href={wordCloudBoardUrl(action.route, detail.product_id)}
key={action.route}
target="_blank"
rel="noopener noreferrer"
>
{action.label}
</a>
) : (
<button className="btn btn-secondary btn-sm" disabled key={action.route} type="button">
{action.label}
</button>
)
))}
</div>
{!canOpenWordCloudBoard && (
<p className="product-archive-board-note"></p>
)}
</section>
<div className="product-archive-timeline">
<h3>线</h3>
{detail.versions.length === 0 && <p></p>}
+24
View File
@@ -3777,6 +3777,30 @@ body.resizing .studio-panel {
color: var(--text-primary);
font-size: 15px;
}
.product-archive-board-links {
padding: 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-panel-alt);
}
.product-archive-board-links h3 {
margin: 0 0 10px;
color: var(--text-primary);
font-size: 13px;
}
.product-archive-board-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.product-archive-board-actions .btn {
text-decoration: none;
}
.product-archive-board-note {
margin: 8px 0 0;
color: var(--text-muted);
font-size: 11px;
}
.product-archive-timeline {
padding: 12px;
border: 1px solid var(--border);
+22 -3
View File
@@ -56,8 +56,8 @@ test('new product creation archives with the created product id instead of a dra
});
test('app routes to product archives and list keeps IDs out of primary cells', async () => {
const app = await readFile('frontend/src/App.tsx', 'utf8');
const page = await readFile('frontend/src/pages/ProductArchivePage.tsx', 'utf8');
const app = await readFile(new URL('../src/App.tsx', import.meta.url), 'utf8');
const page = await readFile(new URL('../src/pages/ProductArchivePage.tsx', import.meta.url), 'utf8');
assert.match(app, /'products'/);
assert.match(page, /产品档案/);
assert.match(page, /产品名称/);
@@ -65,9 +65,28 @@ test('app routes to product archives and list keeps IDs out of primary cells', a
});
test('product archive page exposes restore and soft delete actions', async () => {
const page = await readFile('frontend/src/pages/ProductArchivePage.tsx', 'utf8');
const page = await readFile(new URL('../src/pages/ProductArchivePage.tsx', import.meta.url), 'utf8');
assert.match(page, /恢复产品/);
assert.match(page, /软删除/);
assert.match(page, /系统信息/);
assert.match(page, /待清理 · 将于/);
});
test('product archive links use Product IDs and a configurable board origin', async () => {
const links = await readFile(new URL('../src/lib/wordcloudBoard.ts', import.meta.url), 'utf8');
const page = await readFile(new URL('../src/pages/ProductArchivePage.tsx', import.meta.url), 'utf8');
assert.match(links, /VITE_WORDCLOUD_BOARD_BASE_URL/);
assert.match(links, /http:\/\/114\.55\.99\.6:47880/);
assert.match(links, /encodeURIComponent\(productId\)/);
assert.match(links, /'cloud' \| 'screen' \| 'control'/);
assert.match(page, /product-archive-board-links/);
assert.match(page, /个人查找/);
assert.match(page, /现场大屏/);
assert.match(page, /手机控制/);
assert.match(page, /canOpenWordCloudBoard/);
assert.match(page, /disabled/);
assert.match(page, /target="_blank"/);
assert.match(page, /rel="noopener noreferrer"/);
assert.match(page, /detail\.product_id/);
});