Compare commits
2
Commits
cc5c3f9751
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31e7053bb0 | ||
|
|
1f9eb2853c |
+115
-3
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -226,7 +226,8 @@ SSE 事件流。事件数据模型:
|
||||
|
||||
- 最大 SVG 大小:25 MB。
|
||||
- 当前支持路径、`rect`、`circle`、`ellipse`、`line`、纯色填充/描边和仿射变换。
|
||||
- 不支持图片、普通文字、SVG 图案/裁剪/透明效果时会返回 `422`,不会产生可能失真的 AI 文件。
|
||||
- 普通 SVG `text` 会先使用内置中文字体转换为轮廓路径,以保证中文在 AI 中可见;文本将不再是可编辑文字。
|
||||
- 不支持图片、SVG 图案/裁剪/透明效果时会返回 `422`,不会产生可能失真的 AI 文件。
|
||||
|
||||
## Templates
|
||||
|
||||
|
||||
@@ -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 导出
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user