from __future__ import annotations
import io
import json
import logging
import os
import re
import shutil
import sqlite3
import threading
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, File, Form, HTTPException, Query, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, StreamingResponse
from PIL import Image, ImageDraw, ImageFont
from core import config as wc_config
from core.fonts import get_cached_font
from .job_manager import JobManager
from .line_spacing import analyze_svg_line_spacing_file
from .log_config import get_logger
from .runner import JobRunner
from .schemas import (
Asset,
DesignTemplate,
Font,
JobCreateResponse,
JobDetail,
JobLocationSearchResult,
JobResult,
JobStatus,
LineSpacingAnalysisRequest,
LineSpacingAnalysisSummary,
Project,
ProjectSummary,
Template,
WordLocation,
)
from .storage import Storage
PROJECT_ROOT = Path(__file__).resolve().parents[1]
WORKSPACE_DIR = PROJECT_ROOT / "service_workspace"
log = get_logger("service.app")
log.info("=" * 60)
log.info("服务启动 | PROJECT_ROOT=%s", PROJECT_ROOT)
log.info("=" * 60)
# ── new directories ──────────────────────────────────────────
ASSETS_DIR = PROJECT_ROOT / "service_assets"
PROJECTS_DIR = PROJECT_ROOT / "service_projects"
FONTS_DIR = PROJECT_ROOT / "service_fonts"
DESIGN_TEMPLATES_DIR = PROJECT_ROOT / "service_design_templates"
ASSETS_DIR.mkdir(parents=True, exist_ok=True)
PROJECTS_DIR.mkdir(parents=True, exist_ok=True)
FONTS_DIR.mkdir(parents=True, exist_ok=True)
DESIGN_TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
manager = JobManager()
storage = Storage(WORKSPACE_DIR)
runner = JobRunner(PROJECT_ROOT, manager)
app = FastAPI(title="WordCloud Test Service", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ═══════════════════════════════════════════════════════════
# 1. 模板(硬编码)
# ═══════════════════════════════════════════════════════════
_TEMPLATES: list[Template] = [
Template(id="poster_1x2", name="竖版手机海报", width=1080, height=2160, aspect_ratio="1:2", description="适合手机海报、宣传页"),
Template(id="poster_4x5", name="社交媒体图", width=1080, height=1350, aspect_ratio="4:5", description="小红书、Instagram 风格"),
Template(id="poster_1x1", name="方形封面", width=1080, height=1080, aspect_ratio="1:1", description="朋友圈封面、头像"),
Template(id="poster_3x4", name="竖版广告", width=1080, height=1440, aspect_ratio="3:4", description="通用竖版海报"),
Template(id="poster_16x9", name="横版电商", width=1920, height=1080, aspect_ratio="16:9", description="横版横幅、电商头图"),
]
# ═══════════════════════════════════════════════════════════
# 2. Helpers
# ═══════════════════════════════════════════════════════════
def _safe_hex_color(value: str) -> bool:
return bool(re.fullmatch(r"#[0-9A-Fa-f]{6}", value))
def _read_asset_meta(asset_dir: Path) -> dict:
meta_path = asset_dir / "meta.json"
if not meta_path.exists():
return {}
return json.loads(meta_path.read_text(encoding="utf-8"))
def _write_asset_meta(asset_dir: Path, data: dict) -> None:
meta_path = asset_dir / "meta.json"
meta_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def _asset_dir(asset_id: str) -> Path:
return ASSETS_DIR / asset_id[:2] / asset_id
def _project_dir(project_id: str) -> Path:
return PROJECTS_DIR / project_id[:2] / project_id
def _design_template_dir(template_id: str) -> Path:
return DESIGN_TEMPLATES_DIR / template_id[:2] / template_id
def _list_dirs(p: Path) -> list[Path]:
"""递归列出所有目录下包含有效子目录的 path."""
result: list[Path] = []
if not p.exists():
return result
for item in p.iterdir():
if item.is_dir():
if (item / "meta.json").exists() or (item / "project.json").exists() or (item / "template.json").exists():
result.append(item)
else:
result.extend(_list_dirs(item))
return result
# ═══════════════════════════════════════════════════════════
# 3. Health & Jobs(原有接口,精简保留)
# ═══════════════════════════════════════════════════════════
@app.get("/api/health")
def health() -> dict:
return {"ok": True}
@app.get("/api/jobs", response_model=list[JobStatus])
def list_jobs() -> list[JobStatus]:
with manager._lock:
return list(reversed([state.status for state in manager._jobs.values()]))
@app.post("/api/jobs", response_model=JobCreateResponse)
async def create_job(
mask_image: Optional[UploadFile] = File(None),
name_list: UploadFile = File(...),
font_file: Optional[UploadFile] = File(None),
font_id: str = Form(""),
params: str = Form("{}"),
) -> JobCreateResponse:
log.info("─" * 50)
log.info("[API] POST /api/jobs 收到新任务请求")
log.info(" name_list.filename = %s", name_list.filename)
log.info(" mask_image.filename = %s", mask_image.filename if mask_image else "无")
log.info(" font_file.filename = %s", font_file.filename if font_file else "无")
log.info(" font_id = %s", font_id or "(未指定)")
log.info(" params (raw) = %s", params)
if not name_list.filename:
raise HTTPException(status_code=400, detail="name_list is required")
ext_xlsx = Path(name_list.filename).suffix.lower()
if ext_xlsx not in {".xlsx"}:
raise HTTPException(status_code=400, detail="name_list must be xlsx")
try:
user_params = json.loads(params)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="params must be valid JSON")
if not isinstance(user_params, dict):
raise HTTPException(status_code=400, detail="params must be JSON object")
log.info(" 解析后 params = %s", json.dumps(user_params, ensure_ascii=False))
mode = str(user_params.get("MODE", "IMAGE")).upper()
if mode not in {"IMAGE", "TEXT"}:
raise HTTPException(status_code=400, detail="MODE must be IMAGE or TEXT")
if mode == "IMAGE":
if not mask_image or not mask_image.filename:
raise HTTPException(status_code=400, detail="mask_image is required when MODE=IMAGE")
ext = Path(mask_image.filename).suffix.lower()
if ext not in {".png", ".jpg", ".jpeg"}:
raise HTTPException(status_code=400, detail="mask_image must be png/jpg/jpeg")
elif mask_image and mask_image.filename:
ext = Path(mask_image.filename).suffix.lower()
if ext not in {".png", ".jpg", ".jpeg"}:
raise HTTPException(status_code=400, detail="mask_image must be png/jpg/jpeg")
job_id = manager.create_job()
paths = storage.prepare_job_dirs(job_id)
log.info("[Job] 创建任务 job_id=%s", job_id)
log.info(" 工作目录 = %s", paths.output_dir)
if mask_image and mask_image.filename:
mask_bytes = await mask_image.read()
paths.mask_path.write_bytes(mask_bytes)
log.info(" 掩膜已保存: %s (%d bytes)", paths.mask_path, len(mask_bytes))
xlsx_bytes = await name_list.read()
paths.excel_path.write_bytes(xlsx_bytes)
log.info(" Excel 已保存: %s (%d bytes)", paths.excel_path, len(xlsx_bytes))
# 保存自定义字体(优先 font_id,其次 font_file 上传)
font_path = ""
if font_id:
try:
font_path = str(_resolve_font_file(font_id))
log.info(" 使用已保存字体: font_id=%s -> %s", font_id, font_path)
except FileNotFoundError:
log.warning(" font_id=%s 不存在,回退默认", font_id)
elif font_file and font_file.filename:
font_ext = Path(font_file.filename).suffix.lower()
if font_ext in _FONT_EXTENSIONS:
font_dest = paths.input_dir / f"custom_font{font_ext}"
font_bytes = await font_file.read()
font_dest.write_bytes(font_bytes)
font_path = str(font_dest)
log.info(" 临时字体已保存: %s (%d bytes)", font_dest, len(font_bytes))
else:
log.warning(" 不支持的字体格式: %s,忽略", font_ext)
config = {
"MODE": mode,
"EXCEL_PATH": str(paths.excel_path),
"OUTPUT_DIR": str(paths.output_dir),
"SAVE_DEBUG_IMAGES": True,
"DEBUG_OUTPUT_DIR": str(paths.output_dir / "debug"),
}
if mask_image and mask_image.filename:
config["MASK_IMAGE_PATH"] = str(paths.mask_path)
if font_path:
config["WC_FONT_PATH"] = font_path
config["MASK_FONT_PATH"] = font_path
config.update(user_params)
log.info(" 最终配置 = %s", json.dumps(config, ensure_ascii=False, indent=2))
def _run_job_safe() -> None:
try:
runner.run(job_id, paths, config)
except Exception as exc:
logging.exception("job runner crashed", extra={"job_id": job_id})
manager.add_event(
job_id,
kind="status",
stage="failed",
progress_percent=100,
message=f"任务异常终止: {exc}",
)
manager.set_status(
job_id,
status="failed",
stage="failed",
progress_percent=100,
message="任务失败",
error=str(exc),
)
t = threading.Thread(target=_run_job_safe, daemon=True)
t.start()
log.info("[Job] 后台线程已启动 job_id=%s thread=%s", job_id, t.name)
return JobCreateResponse(job_id=job_id)
@app.get("/api/jobs/{job_id}", response_model=JobStatus)
def get_job(job_id: str) -> JobStatus:
if not manager.exists(job_id):
raise HTTPException(status_code=404, detail="job not found")
return manager.get_status(job_id)
@app.get("/api/jobs/{job_id}/detail", response_model=JobDetail)
def get_job_detail(job_id: str) -> JobDetail:
if not manager.exists(job_id):
raise HTTPException(status_code=404, detail="job not found")
return manager.get_detail(job_id)
@app.get("/api/jobs/{job_id}/events")
def stream_events(job_id: str):
if not manager.exists(job_id):
raise HTTPException(status_code=404, detail="job not found")
q = manager.subscribe(job_id)
def gen():
try:
while True:
event = q.get()
payload = json.dumps(event.model_dump(), ensure_ascii=False, default=str)
yield f"data: {payload}\n\n"
if event.stage in {"completed", "failed"}:
break
finally:
manager.unsubscribe(job_id, q)
return StreamingResponse(gen(), media_type="text/event-stream")
@app.get("/api/jobs/{job_id}/result", response_model=JobResult)
def get_result(job_id: str) -> JobResult:
if not manager.exists(job_id):
raise HTTPException(status_code=404, detail="job not found")
status = manager.get_status(job_id)
def u(kind: str) -> str:
p = status.artifacts.get(kind, "")
if not p:
return ""
return f"/api/jobs/{job_id}/files/{kind}"
return JobResult(
job_id=job_id,
status=status.status,
image_url=u("png"),
svg_url=u("svg"),
svg_stroke_url=u("svg_stroke"),
db_url=u("db"),
metrics_url=u("metrics"),
)
def _normalize_orientation(value: object) -> str:
if value is None:
return "horizontal"
text = str(value).strip().lower()
if text in {"vertical", "1", "90", "rotate_90", "rotate90"}:
return "vertical"
return "horizontal"
def _load_job_config(job_id: str) -> dict:
config_path = storage.base_dir / job_id / "config.json"
if not config_path.exists():
return {}
try:
return json.loads(config_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return {}
def _resolve_canvas_size(status: JobStatus) -> tuple[int, int]:
metrics_path_str = status.artifacts.get("metrics", "")
if metrics_path_str:
try:
metrics_path = Path(metrics_path_str)
metrics = json.loads(metrics_path.read_text(encoding="utf-8"))
canvas = metrics.get("canvas_info", {})
width = int(canvas.get("hd_width", 0))
height = int(canvas.get("hd_height", 0))
if width > 0 and height > 0:
return width, height
except (json.JSONDecodeError, OSError, TypeError, ValueError):
pass
png_path_str = status.artifacts.get("png", "")
if png_path_str:
try:
with Image.open(Path(png_path_str)) as img:
return img.size
except OSError:
pass
return 0, 0
def _resolve_font_path(raw_path: str) -> str:
path = Path(raw_path)
if path.is_absolute():
return str(path)
return str((PROJECT_ROOT / path).resolve())
def _compute_text_box(
name: str,
x: int,
y: int,
font_size: int,
orientation: str,
font_path: str,
) -> tuple[int, int, int, int]:
font = get_cached_font(font_path, max(1, int(font_size)))
if orientation == "vertical":
font = ImageFont.TransposedFont(font, orientation=Image.ROTATE_90)
canvas = Image.new("L", (1, 1), 0)
draw = ImageDraw.Draw(canvas)
bbox = draw.textbbox((x, y), name, font=font)
return bbox[0], bbox[1], bbox[2] - bbox[0], bbox[3] - bbox[1]
@app.get("/api/jobs/{job_id}/locations", response_model=JobLocationSearchResult)
def search_locations(job_id: str, name: str = Query("", description="需要查找的名字")) -> JobLocationSearchResult:
if not manager.exists(job_id):
raise HTTPException(status_code=404, detail="job not found")
status = manager.get_status(job_id)
db_path_str = status.artifacts.get("db", "")
if not db_path_str:
raise HTTPException(status_code=404, detail="db artifact not ready")
db_path = Path(db_path_str)
if not db_path.exists():
raise HTTPException(status_code=404, detail="db artifact missing on disk")
query_name = name.strip()
canvas_width, canvas_height = _resolve_canvas_size(status)
job_config = _load_job_config(job_id)
font_path = _resolve_font_path(str(job_config.get("WC_FONT_PATH") or wc_config.WC_FONT_PATH))
try:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
columns = {row["name"] for row in conn.execute("PRAGMA table_info(word_locations)").fetchall()}
select_columns = ["id", "name", "x", "y", "font_size", "color"]
optional_columns = ["orientation", "box_x", "box_y", "box_width", "box_height"]
for column in optional_columns:
if column in columns:
select_columns.append(column)
sql = f"SELECT {', '.join(select_columns)} FROM word_locations"
params: list[object] = []
if query_name:
sql += " WHERE name = ?"
params.append(query_name)
sql += " ORDER BY id ASC"
rows = conn.execute(sql, params).fetchall()
matches: list[WordLocation] = []
has_boxes = {"box_x", "box_y", "box_width", "box_height"}.issubset(columns)
for row in rows:
orientation = _normalize_orientation(row["orientation"] if "orientation" in row.keys() else None)
if has_boxes:
box_x = int(row["box_x"])
box_y = int(row["box_y"])
box_width = int(row["box_width"])
box_height = int(row["box_height"])
else:
box_x, box_y, box_width, box_height = _compute_text_box(
name=str(row["name"]),
x=int(row["x"]),
y=int(row["y"]),
font_size=int(row["font_size"]),
orientation=orientation,
font_path=font_path,
)
matches.append(
WordLocation(
id=int(row["id"]),
name=str(row["name"]),
x=int(row["x"]),
y=int(row["y"]),
font_size=int(row["font_size"]),
color=str(row["color"] or ""),
orientation=orientation,
box_x=box_x,
box_y=box_y,
box_width=box_width,
box_height=box_height,
)
)
except sqlite3.Error as exc:
raise HTTPException(status_code=500, detail=f"failed to read db: {exc}") from exc
finally:
if "conn" in locals():
conn.close()
return JobLocationSearchResult(
job_id=job_id,
query=query_name,
total=len(matches),
canvas_width=canvas_width,
canvas_height=canvas_height,
matches=matches,
)
@app.get("/api/jobs/{job_id}/occupancy_mask")
def get_occupancy_mask(job_id: str):
"""生成并返回占位遮罩图:每个已放置词语的 bounding box 以实色方块表示。"""
if not manager.exists(job_id):
raise HTTPException(status_code=404, detail="job not found")
status = manager.get_status(job_id)
db_path_str = status.artifacts.get("db", "")
if not db_path_str:
raise HTTPException(status_code=404, detail="db artifact not ready")
db_path = Path(db_path_str)
if not db_path.exists():
raise HTTPException(status_code=404, detail="db artifact missing on disk")
canvas_width, canvas_height = _resolve_canvas_size(status)
if canvas_width <= 0 or canvas_height <= 0:
raise HTTPException(status_code=500, detail="cannot determine canvas size")
try:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
columns = {row["name"] for row in conn.execute("PRAGMA table_info(word_locations)").fetchall()}
has_boxes = {"box_x", "box_y", "box_width", "box_height"}.issubset(columns)
if has_boxes:
rows = conn.execute(
"SELECT box_x, box_y, box_width, box_height FROM word_locations ORDER BY id ASC"
).fetchall()
else:
rows = conn.execute(
"SELECT x, y, font_size, orientation FROM word_locations ORDER BY id ASC"
).fetchall()
except sqlite3.Error as exc:
raise HTTPException(status_code=500, detail=f"failed to read db: {exc}") from exc
finally:
if "conn" in locals():
conn.close()
img = Image.new("RGB", (canvas_width, canvas_height), color=(255, 255, 255))
draw = ImageDraw.Draw(img)
job_config = _load_job_config(job_id)
font_path = _resolve_font_path(str(job_config.get("WC_FONT_PATH") or wc_config.WC_FONT_PATH))
for row in rows:
if has_boxes:
bx = int(row["box_x"])
by = int(row["box_y"])
bw = int(row["box_width"])
bh = int(row["box_height"])
else:
orientation = _normalize_orientation(row["orientation"] if "orientation" in row.keys() else None)
bx, by, bw, bh = _compute_text_box(
name="",
x=int(row["x"]),
y=int(row["y"]),
font_size=int(row["font_size"]),
orientation=orientation,
font_path=font_path,
)
if bw > 0 and bh > 0:
draw.rectangle([bx, by, bx + bw - 1, by + bh - 1], fill=(30, 30, 30))
buf = io.BytesIO()
img.save(buf, format="PNG")
buf.seek(0)
return StreamingResponse(buf, media_type="image/png")
@app.get("/api/jobs/{job_id}/custom.svg")
def get_custom_svg(
job_id: str,
fill: str = Query("fill", description="填充模式: fill / dot / line / ring"),
stroke: int = Query(0, description="是否描边: 0 / 1"),
spacing: int = Query(10, ge=2, le=100, description="点阵间距(fill=dot 时生效)"),
radius: int = Query(2, ge=1, le=20, description="点阵半径(fill=dot 时生效)"),
color: str = Query("#000000", description="颜色"),
line_spacing: int = Query(6, ge=2, le=100, description="线间距(fill=line 时生效)"),
line_width: float = Query(1, ge=0.5, le=10, description="线粗细(fill=line 时生效)"),
line_angle: int = Query(0, ge=0, le=359, description="线角度(fill=line 时生效,0=水平)"),
ring_radius: int = Query(3, ge=1, le=20, description="空心圆半径(fill=ring 时生效)"),
ring_width: float = Query(1, ge=0.5, le=10, description="空心圆线粗(fill=ring 时生效)"),
ring_spacing: int = Query(8, ge=2, le=100, description="空心圆间距(fill=ring 时生效)"),
):
"""统一 SVG 导出:可组合描边 + fill/dot/line/ring 填充。"""
from core.layout import OptimizedEfficientWordCloud
from core import config as wc_config
if not manager.exists(job_id):
raise HTTPException(status_code=404, detail="job not found")
status = manager.get_status(job_id)
db_path_str = status.artifacts.get("db", "")
if not db_path_str:
raise HTTPException(status_code=404, detail="db artifact not ready")
canvas_width, canvas_height = _resolve_canvas_size(status)
if canvas_width <= 0 or canvas_height <= 0:
raise HTTPException(status_code=500, detail="cannot determine canvas size")
job_config = _load_job_config(job_id)
font_path = _resolve_font_path(str(job_config.get("WC_FONT_PATH") or wc_config.WC_FONT_PATH))
try:
conn = sqlite3.connect(db_path_str)
conn.row_factory = sqlite3.Row
rows = conn.execute("SELECT name, x, y, font_size, color, orientation FROM word_locations ORDER BY id ASC").fetchall()
except sqlite3.Error as exc:
raise HTTPException(status_code=500, detail=f"failed to read db: {exc}") from exc
finally:
if "conn" in locals():
conn.close()
layout = []
for row in rows:
orient = _normalize_orientation(row["orientation"] if "orientation" in row.keys() else None)
orientation_flag = Image.ROTATE_90 if orient == "vertical" else None
layout.append((
str(row["name"]),
int(row["font_size"]),
(int(row["y"]), int(row["x"])),
orientation_flag,
str(row["color"] or "#000000"),
))
wc = OptimizedEfficientWordCloud.__new__(OptimizedEfficientWordCloud)
wc.width = canvas_width
wc.height = canvas_height
wc.layout_ = layout
wc.font_path = font_path
wc.background_color = "white"
wc.mode = "RGB"
tag = f"{fill}{'_stroke' if stroke else ''}"
if fill == "dot":
tag += f"_s{spacing}_r{radius}"
elif fill == "line":
tag += f"_ls{line_spacing}_lw{line_width}_la{line_angle}"
elif fill == "ring":
tag += f"_r{ring_radius}_w{ring_width}_s{ring_spacing}"
tmp_path = WORKSPACE_DIR / job_id / "output" / f"custom_{tag}.svg"
tmp_path.parent.mkdir(parents=True, exist_ok=True)
wc.to_svg_custom(
str(tmp_path),
fill_mode=fill,
do_stroke=bool(stroke),
dot_spacing=spacing,
dot_radius=radius,
color=color,
line_spacing=line_spacing,
line_width=line_width,
line_angle=line_angle,
ring_radius=ring_radius,
ring_width=ring_width,
ring_spacing=ring_spacing,
)
return FileResponse(tmp_path, media_type="image/svg+xml", filename=f"wordcloud_{tag}.svg")
@app.get("/api/jobs/{job_id}/files/{kind}")
def get_file(job_id: str, kind: str):
if not manager.exists(job_id):
raise HTTPException(status_code=404, detail="job not found")
status = manager.get_status(job_id)
try:
path = manager.resolve_artifact_path(status, kind)
except KeyError:
raise HTTPException(status_code=404, detail="unknown artifact kind")
except FileNotFoundError:
raise HTTPException(status_code=404, detail="artifact not ready")
if not path.exists():
raise HTTPException(status_code=404, detail="artifact missing on disk")
media = {
"png": "image/png",
"svg": "image/svg+xml",
"db": "application/octet-stream",
"metrics": "application/json",
}.get(kind, "application/octet-stream")
return FileResponse(path, media_type=media, filename=path.name)
# ═══════════════════════════════════════════════════════════
# 4. 模板接口
# ═══════════════════════════════════════════════════════════
@app.get("/api/templates", response_model=list[Template])
def list_templates() -> list[Template]:
return _TEMPLATES
def _read_design_template(template_dir: Path) -> dict:
path = template_dir / "template.json"
if not path.exists():
return {}
return json.loads(path.read_text(encoding="utf-8"))
def _write_design_template(template_dir: Path, data: dict) -> None:
template_dir.mkdir(parents=True, exist_ok=True)
(template_dir / "template.json").write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def _parse_json_array(value: str, field_name: str) -> list:
try:
parsed = json.loads(value or "[]")
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail=f"{field_name} must be valid JSON array")
if not isinstance(parsed, list):
raise HTTPException(status_code=400, detail=f"{field_name} must be a JSON array")
return parsed
@app.get("/api/design-templates", response_model=list[DesignTemplate])
def list_design_templates() -> list[DesignTemplate]:
items: list[DesignTemplate] = []
for d in _list_dirs(DESIGN_TEMPLATES_DIR):
try:
data = _read_design_template(d)
if data:
items.append(DesignTemplate(**data))
except Exception:
continue
return sorted(items, key=lambda item: item.updated_at, reverse=True)
@app.post("/api/design-templates", response_model=DesignTemplate)
async def create_design_template(
name: str = Form(...),
description: str = Form(""),
document: str = Form(...),
reference_asset_ids: str = Form("[]"),
cover_asset_id: str = Form(""),
) -> DesignTemplate:
display_name = name.strip()
if not display_name:
raise HTTPException(status_code=400, detail="name is required")
try:
document_data = json.loads(document)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="document must be valid JSON")
if not isinstance(document_data, dict):
raise HTTPException(status_code=400, detail="document must be a JSON object")
refs = [str(item) for item in _parse_json_array(reference_asset_ids, "reference_asset_ids")]
now = datetime.now(timezone.utc).isoformat()
template_id = f"tmpl_{uuid.uuid4().hex}"
data = {
"template_id": template_id,
"name": display_name[:120],
"description": description.strip(),
"document": document_data,
"reference_asset_ids": refs,
"cover_asset_id": cover_asset_id.strip() or (refs[0] if refs else ""),
"created_at": now,
"updated_at": now,
}
_write_design_template(_design_template_dir(template_id), data)
return DesignTemplate(**data)
@app.get("/api/design-templates/{template_id}", response_model=DesignTemplate)
def get_design_template(template_id: str) -> DesignTemplate:
data = _read_design_template(_design_template_dir(template_id))
if not data:
raise HTTPException(status_code=404, detail="design template not found")
return DesignTemplate(**data)
@app.patch("/api/design-templates/{template_id}", response_model=DesignTemplate)
async def update_design_template(
template_id: str,
name: str = Form(""),
description: str = Form(""),
document: str = Form(""),
reference_asset_ids: str = Form(""),
cover_asset_id: str = Form(""),
) -> DesignTemplate:
template_dir = _design_template_dir(template_id)
data = _read_design_template(template_dir)
if not data:
raise HTTPException(status_code=404, detail="design template not found")
if name:
data["name"] = name.strip()[:120]
if description != "":
data["description"] = description.strip()
if document:
try:
document_data = json.loads(document)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="document must be valid JSON")
if not isinstance(document_data, dict):
raise HTTPException(status_code=400, detail="document must be a JSON object")
data["document"] = document_data
if reference_asset_ids:
refs = [str(item) for item in _parse_json_array(reference_asset_ids, "reference_asset_ids")]
data["reference_asset_ids"] = refs
if not data.get("cover_asset_id") and refs:
data["cover_asset_id"] = refs[0]
if cover_asset_id:
data["cover_asset_id"] = cover_asset_id.strip()
data["updated_at"] = datetime.now(timezone.utc).isoformat()
_write_design_template(template_dir, data)
return DesignTemplate(**data)
@app.delete("/api/design-templates/{template_id}", status_code=204)
def delete_design_template(template_id: str) -> None:
template_dir = _design_template_dir(template_id)
if not (template_dir / "template.json").exists():
raise HTTPException(status_code=404, detail="design template not found")
shutil.rmtree(template_dir, ignore_errors=True)
# ═══════════════════════════════════════════════════════════
# 5. 素材接口
# ═══════════════════════════════════════════════════════════
def _parse_svg_viewbox(svg_path: Path) -> tuple[int, int]:
"""尝试从 SVG 文件头提取 width/height 属性。"""
try:
text = svg_path.read_text(encoding="utf-8", errors="ignore")
m = re.search(r'width="(\d+)"', text)
w = int(m.group(1)) if m else 0
m = re.search(r'height="(\d+)"', text)
h = int(m.group(1)) if m else 0
return w, h
except Exception:
return 0, 0
def _asset_extension_for_mime(mime_type: str) -> str:
if mime_type == "image/svg+xml":
return ".svg"
if mime_type == "image/jpeg":
return ".jpg"
return ".png"
@app.post("/api/assets", response_model=Asset)
async def upload_asset(
file: UploadFile = File(...),
name: str = Form(""),
type: str = Form("upload"),
) -> Asset:
if not file.filename:
raise HTTPException(status_code=400, detail="file is required")
ext = Path(file.filename).suffix.lower()
if ext not in {".svg", ".png", ".jpg", ".jpeg"}:
raise HTTPException(status_code=400, detail="unsupported file type, expected .svg/.png/.jpg/.jpeg")
mime = "image/svg+xml" if ext == ".svg" else "image/jpeg" if ext in {".jpg", ".jpeg"} else "image/png"
asset_id = f"asset_{uuid.uuid4().hex}"
asset_path = _asset_dir(asset_id)
asset_path.mkdir(parents=True, exist_ok=True)
stored_ext = ".jpg" if ext == ".jpeg" else ext
dest = asset_path / f"asset{stored_ext}"
content = await file.read()
dest.write_bytes(content)
width, height = 0, 0
if ext == ".svg":
width, height = _parse_svg_viewbox(dest)
elif ext in {".png", ".jpg", ".jpeg"}:
with Image.open(dest) as img:
width, height = img.size
meta = {
"asset_id": asset_id,
"name": name or file.filename,
"type": type,
"mime_type": mime,
"width": width,
"height": height,
"file_size": len(content),
"file_url": f"/api/assets/{asset_id}/download",
"job_id": "",
"created_at": datetime.now(timezone.utc).isoformat(),
}
_write_asset_meta(asset_path, meta)
return Asset(**meta)
def _svg_with_transparent_background(svg_bytes: bytes) -> bytes:
"""Convert full-canvas opaque backgrounds to transparent for sticker use.
Job pipeline SVG (`to_svg`) embeds a white full-rect so standalone preview
looks solid. Canvas stickers must stay transparent so the mask layer shows
through. Only the full-canvas background rect is touched.
"""
try:
text = svg_bytes.decode("utf-8")
except UnicodeDecodeError:
text = svg_bytes.decode("utf-8", errors="ignore")
patterns = [
#
(
r'(]*\bwidth=["\']100%["\'][^>]*\bheight=["\']100%["\'][^>]*\bfill=["\'])(?:white|#fff(?:fff)?|rgb\(\s*255\s*,\s*255\s*,\s*255\s*\))(["\'][^>]*?/?>)',
r'\1none\2',
),
# attribute order: fill before width/height
(
r'(]*\bfill=["\'])(?:white|#fff(?:fff)?|rgb\(\s*255\s*,\s*255\s*,\s*255\s*\))(["\'][^>]*\bwidth=["\']100%["\'][^>]*\bheight=["\']100%["\'][^>]*?/?>)',
r'\1none\2',
),
]
for pattern, repl in patterns:
updated, n = re.subn(pattern, repl, text, count=1, flags=re.IGNORECASE)
if n:
return updated.encode("utf-8")
return svg_bytes.encode("utf-8") if isinstance(svg_bytes, str) else svg_bytes
def _find_job_svg(job_id: str) -> Path | None:
"""Locate the main (non-stroke) SVG for a job.
Prefer in-memory artifact path when valid; always fall back to the job
workspace on disk so empty/stale memory state cannot hide existing files.
"""
svg_path: Path | None = None
if manager.exists(job_id):
status = manager.get_status(job_id)
svg_path_str = status.artifacts.get("svg", "")
if svg_path_str:
candidate = Path(svg_path_str)
if candidate.exists():
svg_path = candidate
if svg_path is not None:
return svg_path
fallback_dir = storage.base_dir / job_id / "output"
if not fallback_dir.exists():
return None
candidates = sorted(fallback_dir.glob("*.svg"))
for candidate in candidates:
if candidate.name.endswith("_stroke.svg"):
continue
return candidate
return candidates[0] if candidates else None
@app.post("/api/assets/from-job/{job_id}", response_model=Asset)
async def import_asset_from_job(
job_id: str,
name: str = Form(""),
type: str = Form("wordcloud"),
) -> Asset:
svg_path = _find_job_svg(job_id)
if svg_path is None or not svg_path.exists():
raise HTTPException(status_code=404, detail="svg not found")
asset_id = f"asset_{uuid.uuid4().hex}"
asset_path = _asset_dir(asset_id)
asset_path.mkdir(parents=True, exist_ok=True)
dest = asset_path / "asset.svg"
raw = svg_path.read_bytes()
# 画布贴纸需要透明底;任务主 SVG 默认带白底 rect
content = _svg_with_transparent_background(raw)
dest.write_bytes(content)
width, height = _parse_svg_viewbox(dest)
asset_type = (type or "wordcloud").strip() or "wordcloud"
display_name = name or f"词云_{job_id[:8]}"
meta = {
"asset_id": asset_id,
"name": display_name,
"type": asset_type,
"mime_type": "image/svg+xml",
"width": width,
"height": height,
"file_size": len(content),
"file_url": f"/api/assets/{asset_id}/download",
"job_id": job_id,
"created_at": datetime.now(timezone.utc).isoformat(),
}
_write_asset_meta(asset_path, meta)
return Asset(**meta)
@app.get("/api/assets", response_model=list[Asset])
def list_assets(
type: str = Query("", description="过滤类型:wordcloud / upload / shape"),
) -> list[Asset]:
items: list[Asset] = []
for d in _list_dirs(ASSETS_DIR):
meta = _read_asset_meta(d)
if not meta:
continue
if type and meta.get("type") != type:
continue
items.append(Asset(**meta))
return sorted(items, key=lambda a: a.created_at, reverse=True)
@app.get("/api/assets/{asset_id}", response_model=Asset)
def get_asset(asset_id: str) -> Asset:
d = _asset_dir(asset_id)
meta = _read_asset_meta(d)
if not meta:
raise HTTPException(status_code=404, detail="asset not found")
return Asset(**meta)
@app.get("/api/assets/{asset_id}/download")
def download_asset(asset_id: str):
d = _asset_dir(asset_id)
meta = _read_asset_meta(d)
if not meta:
raise HTTPException(status_code=404, detail="asset not found")
ext = _asset_extension_for_mime(meta["mime_type"])
path = d / f"asset{ext}"
if not path.exists():
raise HTTPException(status_code=404, detail="asset file missing on disk")
media = meta.get("mime_type", "application/octet-stream")
return FileResponse(path, media_type=media, filename=f"{meta['name']}{ext}")
@app.post("/api/assets/{asset_id}/line-spacing", response_model=LineSpacingAnalysisSummary)
def analyze_asset_line_spacing(
asset_id: str,
request: LineSpacingAnalysisRequest,
) -> LineSpacingAnalysisSummary:
d = _asset_dir(asset_id)
meta = _read_asset_meta(d)
if not meta:
raise HTTPException(status_code=404, detail="asset not found")
if meta.get("mime_type") != "image/svg+xml":
raise HTTPException(status_code=400, detail="line spacing analysis only supports SVG assets")
path = d / "asset.svg"
if not path.exists():
raise HTTPException(status_code=404, detail="asset file missing on disk")
started = time.perf_counter()
try:
result = analyze_svg_line_spacing_file(
path,
percentile=request.percentile,
element_width=request.elementWidth,
element_height=request.elementHeight,
sample_step=request.sampleStep,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
elapsed = time.perf_counter() - started
log.info(
"[API] line-spacing asset=%s percentile=%s curves=%s segments=%s elapsed=%.2fs",
asset_id,
result.percentile,
result.curveCount,
result.segmentCount,
elapsed,
)
return LineSpacingAnalysisSummary(**result.as_dict())
@app.delete("/api/assets/{asset_id}", status_code=204)
def delete_asset(asset_id: str) -> None:
d = _asset_dir(asset_id)
meta = _read_asset_meta(d)
if not meta:
raise HTTPException(status_code=404, detail="asset not found")
shutil.rmtree(d, ignore_errors=True)
# ═══════════════════════════════════════════════════════════
# 6. 工程接口
# ═══════════════════════════════════════════════════════════
@app.post("/api/projects", response_model=Project)
async def create_project(
name: str = Form(...),
template_id: str = Form(...),
background_color: str = Form(...),
stickers: str = Form("[]"),
) -> Project:
if not name or len(name) > 120:
raise HTTPException(status_code=400, detail="name is required and must be <= 120 chars")
if not _safe_hex_color(background_color):
raise HTTPException(status_code=400, detail="background_color must be valid hex like #ffffff")
if template_id not in {t.id for t in _TEMPLATES}:
raise HTTPException(status_code=400, detail=f"template not found: {template_id}")
try:
stickers_data: list[dict] = json.loads(stickers)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="stickers must be valid JSON array")
project_id = f"proj_{uuid.uuid4().hex}"
pdir = _project_dir(project_id)
pdir.mkdir(parents=True, exist_ok=True)
now = datetime.now(timezone.utc).isoformat()
data = {
"project_id": project_id,
"name": name,
"template_id": template_id,
"background_color": background_color,
"stickers": stickers_data,
"created_at": now,
"updated_at": now,
}
(pdir / "project.json").write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
return Project(**data)
@app.get("/api/projects", response_model=list[ProjectSummary])
def list_projects() -> list[ProjectSummary]:
items: list[ProjectSummary] = []
for d in _list_dirs(PROJECTS_DIR):
try:
data = json.loads((d / "project.json").read_text(encoding="utf-8"))
items.append(ProjectSummary(
project_id=data["project_id"],
name=data["name"],
template_id=data["template_id"],
background_color=data["background_color"],
sticker_count=len(data.get("stickers", [])),
created_at=data["created_at"],
updated_at=data["updated_at"],
))
except Exception:
continue
return sorted(items, key=lambda p: p.created_at, reverse=True)
@app.get("/api/projects/{project_id}", response_model=Project)
def get_project(project_id: str) -> Project:
pdir = _project_dir(project_id)
path = pdir / "project.json"
if not path.exists():
raise HTTPException(status_code=404, detail="project not found")
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
raise HTTPException(status_code=500, detail="failed to read project")
return Project(**data)
@app.patch("/api/projects/{project_id}", response_model=Project)
async def update_project(
project_id: str,
name: str = Form(""),
template_id: str = Form(""),
background_color: str = Form(""),
stickers: str = Form(""),
) -> Project:
pdir = _project_dir(project_id)
path = pdir / "project.json"
if not path.exists():
raise HTTPException(status_code=404, detail="project not found")
try:
data: dict = json.loads(path.read_text(encoding="utf-8"))
except Exception:
raise HTTPException(status_code=500, detail="failed to read project")
if name:
data["name"] = name[:120]
if template_id:
if template_id not in {t.id for t in _TEMPLATES}:
raise HTTPException(status_code=400, detail=f"template not found: {template_id}")
data["template_id"] = template_id
if background_color:
if not _safe_hex_color(background_color):
raise HTTPException(status_code=400, detail="background_color must be valid hex like #ffffff")
data["background_color"] = background_color
if stickers:
try:
data["stickers"] = json.loads(stickers)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="stickers must be valid JSON array")
data["updated_at"] = datetime.now(timezone.utc).isoformat()
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
return Project(**data)
@app.delete("/api/projects/{project_id}", status_code=204)
def delete_project(project_id: str) -> None:
pdir = _project_dir(project_id)
path = pdir / "project.json"
if not path.exists():
raise HTTPException(status_code=404, detail="project not found")
shutil.rmtree(pdir, ignore_errors=True)
# ═══════════════════════════════════════════════════════════
# 7. 字体管理接口
# ═══════════════════════════════════════════════════════════
_FONT_EXTENSIONS = {".ttf", ".ttc", ".otf"}
def _default_font_entry() -> Font:
"""返回内置默认字体(STHeiti)。"""
default_path = PROJECT_ROOT / "assets" / "fonts" / "STHeiti Medium.ttc"
return Font(
font_id="__default__",
name="STHeiti Medium(默认)",
filename="STHeiti Medium.ttc",
file_size=default_path.stat().st_size if default_path.exists() else 0,
created_at=datetime.fromtimestamp(0, tz=timezone.utc),
)
def _font_meta_path(font_dir: Path) -> Path:
return font_dir / "meta.json"
def _read_font_meta(font_dir: Path) -> dict | None:
meta_path = _font_meta_path(font_dir)
if not meta_path.exists():
return None
return json.loads(meta_path.read_text(encoding="utf-8"))
def _resolve_font_file(font_id: str) -> Path:
"""返回字体文件的绝对路径。"""
if font_id == "__default__":
return PROJECT_ROOT / "assets" / "fonts" / "STHeiti Medium.ttc"
font_dir = FONTS_DIR / font_id[:2] / font_id
meta = _read_font_meta(font_dir)
if not meta:
raise FileNotFoundError(font_id)
return font_dir / meta["filename"]
@app.get("/api/fonts", response_model=list[Font])
def list_fonts() -> list[Font]:
"""列出所有可用字体(含内置默认)。"""
result: list[Font] = [_default_font_entry()]
for font_dir in _list_dirs(FONTS_DIR):
meta = _read_font_meta(font_dir)
if meta:
result.append(Font(**meta))
return sorted(result, key=lambda f: f.created_at, reverse=True)
@app.post("/api/fonts", response_model=Font)
async def upload_font(
file: UploadFile = File(...),
name: str = Form(""),
) -> Font:
"""上传新字体,永久保存。"""
if not file.filename:
raise HTTPException(status_code=400, detail="file is required")
ext = Path(file.filename).suffix.lower()
if ext not in _FONT_EXTENSIONS:
raise HTTPException(status_code=400, detail=f"unsupported font type: {ext}, expected ttf/ttc/otf")
font_id = f"font_{uuid.uuid4().hex}"
font_dir = FONTS_DIR / font_id[:2] / font_id
font_dir.mkdir(parents=True, exist_ok=True)
dest = font_dir / f"font{ext}"
content = await file.read()
dest.write_bytes(content)
display_name = name or Path(file.filename).stem
meta = {
"font_id": font_id,
"name": display_name,
"filename": f"font{ext}",
"file_size": len(content),
"created_at": datetime.now(timezone.utc).isoformat(),
}
_font_meta_path(font_dir).write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
log.info("[Font] 上传字体: %s (%s, %d bytes)", display_name, font_id, len(content))
return Font(**meta)
@app.delete("/api/fonts/{font_id}", status_code=204)
def delete_font(font_id: str) -> None:
"""删除已上传字体(内置默认字体不可删除)。"""
if font_id == "__default__":
raise HTTPException(status_code=400, detail="cannot delete default font")
font_dir = FONTS_DIR / font_id[:2] / font_id
if not font_dir.exists():
raise HTTPException(status_code=404, detail="font not found")
shutil.rmtree(font_dir, ignore_errors=True)
log.info("[Font] 删除字体: %s", font_id)