commit d5d8caef2ffe4b67ed5ce4a4b0a7b8cde2116fba Author: obroccolio Date: Sat Jul 4 02:40:45 2026 +0800 Initial project baseline diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a25fd25 --- /dev/null +++ b/.gitignore @@ -0,0 +1,71 @@ +# ── macOS / editors ───────────────────────────── +.DS_Store +._* +.idea/ +.claude/ + +# ── Python ───────────────────────────────────── +__pycache__/ +*.py[cod] +*.pyc.* +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.venv/ +.matplotlib/ +.runtime/ + +# ── C++ build artifacts ──────────────────────── +*.so +*.dylib +*.dll +*.a +*.o +*.obj +backend/EfficientWordCloud/build/ +backend/EfficientWordCloud/dist/ +backend/EfficientWordCloud/efficient_wordcloud.egg-info/ +backend/EfficientWordCloud/efficient_wordcloud/ewc_core*.so + +# ── Frontend ─────────────────────────────────── +frontend/node_modules/ +frontend/dist/ + +# ── Runtime / service data ───────────────────── +backend/service_workspace/ +backend/service_assets/ +backend/service_projects/ +backend/service_fonts/ +backend/output/ +backend/output_cli_test/ +backend/ref/ +backend/.runtime/ + +# ── Logs ─────────────────────────────────────── +*.log + +# ── Generated artifacts ──────────────────────── +release/ +*.db +metrics.json +*.tar +*.tar.gz +*.tgz +*.png +*.svg +*.jpg +*.jpeg +*.webp + +# ── Data files (not code) ────────────────────── +*.pdf +*.zip +*.xlsx +*.xls +*.csv +*.tsv +*.docx + +# ── Old / local notes ────────────────────────── +EWC_REF_FEATURE_PARITY_PLAN.md +GIT_PUSH_指南.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..338d17c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,84 @@ +# 更新日志 + +## 2026-06-13 — 从词云生成器升级为完整创作工作流 + +> 本次更新在原有「词云生成」核心能力之上,**完整新增了「模板中心」与「画布工作室」两大模块**。系统从一个单纯的词云生成工具,升级为支持「选模板 → 生成词云 → 拖拽排版 → 导出成品」的一站式创作平台。 + +--- + +## 🆕 新增:模板中心(TemplateHome) + +- **模板展示页面**:新增独立的模板中心入口,可浏览、筛选、管理设计模板。 +- **模板预览弹窗**:点击模板缩略图弹出预览窗口,并支持在弹窗内**切换多张预览图**查看,而非固定单张大图。 +- **异步 SVG 预览生成**:模板弹窗中的预览图改为异步生成,避免阻塞主线程,提升打开速度。 +- **模板 CRUD 接口**:后端新增 `/api/design-templates` 相关接口,支持模板的上传、更新、删除。 + +--- + +## 🆕 新增:画布工作室(CanvasStudio) + +- **独立画布编辑器**:新增完整的可视化画布页面,支持多图层自由排版。 +- **词云作为贴纸导入**:生成的词云可一键作为贴纸插入画布,支持拖拽移动、缩放、旋转。 +- **贴纸库面板**:新增贴纸库,可浏览、上传、删除贴纸资源。 +- **组内元素联动缩放**:当调整词云大小时,同组内的底图/背景元素会按相同比例同步缩放,保持整体构图一致。 +- **SVG 导出自动内联后端资源**:导出成品 SVG 时,自动从后端拉取贴纸、底图等资源并转为 data URL 内嵌,确保导出的文件独立可用、图片不会缺失。 +- **图层导出 ZIP**:支持按图层批量导出资源包。 + +--- + +## 🆕 新增:后端贴纸库(完全替代 localStorage) + +- **贴纸文件全部迁移到后端**:原先贴纸存在浏览器 `localStorage`,容易触发 `QuotaExceededError` 并导致贴纸丢失;现在统一通过 `/api/assets` 接口存取,文件保存在服务器。 +- **资源类型体系**:后端 `/api/assets` 支持 `wordcloud` / `upload` / `shape` / `sticker` 等类型。 +- **元数据 + 文件分离**:贴纸的元数据存在后端 JSON,文件存在后端磁盘,前端仅保存轻量引用。 + +--- + +## 🔧 问题修复 + +| 问题 | 原因 | 修复方案 | +|------|------|----------| +| 词云导入画布时生成两个重复贴纸 | React StrictMode 双重触发 effect,pending 状态未提交就被二次消费 | 增加 `importingStickerRef` 引用锁,防止同一词云重复导入 | +| 调整词云大小时底图不同步缩放 | resize handler 只更新被拖拽的元素 | 记录 `groupId` 与组内元素初始尺寸,按比例同步缩放同组伙伴 | +| 导出的 SVG 图片错误/缺失 | `serializeDocument` 把后端 URL 当作 SVG 文本处理 | 将 `serializeDocument` 改为异步,fetch 后端资源并内联为 data URL | +| 贴纸/词云上传报 413 | Nginx 默认 `client_max_body_size` 仅 1MB | 前端 Nginx 配置 `client_max_body_size 100M` | +| 生成任务进度连接超时断开 | Nginx 默认 60 秒 read timeout | Nginx 配置 `proxy_read_timeout` / `proxy_send_timeout` 3600 秒,关闭 buffering | +| Docker 部署后词云任务失败 | release 包漏掉 `wordcloud_generate_hybrid.py` | 补回脚本并在 `backend/Dockerfile` 中显式复制 | + +--- + +## 🐳 新增:Docker Compose 一键部署 + +- **新增 `docker-compose.yml`**:编排 frontend + backend 服务,含健康检查与 5 个持久化卷。 +- **新增 `frontend/Dockerfile`**:Node 多阶段构建 → Nginx 静态服务。 +- **新增 `backend/Dockerfile`**:Python 3.10 + 编译 `EfficientWordCloud` C++ 扩展,healthcheck 已安装 `curl`。 +- **新增 `frontend/nginx.conf`**:静态资源服务、`/api` 反向代理、SSE 长连接优化、大文件上传支持。 +- **新增 `frontend/.dockerignore` 与 `backend/.dockerignore`**:避免构建时带入 `node_modules`、`.venv`、`__pycache__` 等。 + +### 便捷命令 +- **新增 `Makefile`**: + - `make build` / `make up` / `make down` + - `make logs` / `make logs-backend` / `make logs-frontend` + - `make restart` / `make clean` / `make shell-backend` + +### 部署文档 +- **新增 `DOCKER.md`**:环境要求、快速开始、数据持久化说明、端口配置、常见问题排查。 + +### Release 包 +- **新增 `release/` 目录与 `release.tar.gz`**:仅包含源代码 + Docker 部署所需文件(已排除 `node_modules`、`.venv`、编译产物、测试输出等)。 +- 包大小约 28 MB(主要为 `backend/assets/fonts/STHeiti Medium.ttc` 字体文件)。 + +--- + +## 📦 依赖 + +- 后端 `requirements.txt` 新增 `fastapi>=0.136.0`、`uvicorn[standard]>=0.32.0`、`python-multipart>=0.0.27`、`pydantic>=2.10.0`、`pillow>=10.0.0`、`numpy>=2.0.0`、`matplotlib>=3.10.0`、`pandas>=2.0.0`、`openpyxl>=3.1.0`。 +- 前端保持 React 18 + Vite 5 技术栈,新增 `xlsx` 用于 Excel 解析。 + +--- + +## 📝 其他说明 + +- **数据持久化**:Docker Compose 使用 5 个命名卷保存任务工作区、贴纸资源、项目文件、设计模板、上传字体,容器重建不会丢失用户数据。 +- **API 调用**:前端统一使用相对路径 `/api/*`,本地开发由 Vite 代理到 `localhost:8000`,生产环境由 Nginx 代理到后端容器。 +- **原有词云生成功能保留**:`TestWorkbench` 作为原始生成工作台继续可用,新增强的模板中心与画布工作室与其并行。 diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..1457291 --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,184 @@ +# WordCloud — Docker Compose 部署指南 + +本项目提供完整的 Docker Compose 配置,可在任意 Ubuntu / Linux 服务器上通过 `docker-compose up` 一键启动前后端服务。 + +--- + +## 1. 目录结构 + +``` +wordcloud/ +├── docker-compose.yml # 编排 frontend + backend +├── Makefile # 常用命令封装 +├── frontend/ +│ ├── Dockerfile # Node 构建 + Nginx 服务 +│ ├── nginx.conf # 静态资源 + /api 反向代理 +│ └── .dockerignore +└── backend/ + ├── Dockerfile # Python + C++ 扩展构建 + ├── requirements.txt + └── .dockerignore +``` + +--- + +## 2. 环境要求 + +- Docker Engine >= 20.10 +- Docker Compose >= 1.29(或 `docker compose` plugin) +- 服务器开放端口:`3000`(前端)、`8000`(后端,可选暴露) + +--- + +## 3. 快速开始 + +### 3.1 克隆/上传代码到 Ubuntu 服务器 + +```bash +cd /opt +# 方式 A:git clone +git clone <你的仓库地址> wordcloud +cd wordcloud + +# 方式 B:直接上传整个项目目录后进入 +cd /path/to/wordcloud +``` + +### 3.2 构建并启动 + +```bash +# 使用 Makefile(推荐) +make build +make up + +# 或者直接使用 docker-compose +docker-compose up -d --build +``` + +首次构建会: +1. 后端:安装 Python 依赖并编译 `EfficientWordCloud` C++ 扩展。 +2. 前端:执行 `npm ci` 与 `npm run build`,生成静态文件。 + +根据服务器性能,首次构建通常需要 3–10 分钟。 + +### 3.3 访问服务 + +- 前端页面:http://`<服务器IP>`:3000 +- 后端 API 文档:http://`<服务器IP>`:8000/docs +- 后端健康检查:http://`<服务器IP>`:8000/api/health + +> 前端 Nginx 已将所有 `/api/*` 请求反向代理到后端容器,因此浏览器只需访问 3000 端口。 + +--- + +## 4. 常用命令 + +| 命令 | 说明 | +|------|------| +| `make build` | 重新构建镜像 | +| `make up` | 后台启动服务 | +| `make down` | 停止并移除容器 | +| `make restart` | 重启服务 | +| `make logs` | 查看实时日志 | +| `make logs-backend` | 只看后端日志 | +| `make logs-frontend` | 只看前端日志 | +| `make clean` | 停止并删除容器 + 镜像 + 卷(谨慎) | +| `make shell-backend` | 进入后端容器调试 | + +--- + +## 5. 数据持久化 + +Docker Compose 已声明以下命名卷,数据会保存在 Docker 宿主机上,容器重建不会丢失: + +| 卷名 | 容器内路径 | 用途 | +|------|-----------|------| +| `wordcloud_workspace` | `/app/service_workspace` | 词云任务工作目录 | +| `wordcloud_assets` | `/app/service_assets` | 贴纸资源文件 | +| `wordcloud_projects` | `/app/service_projects` | 保存的项目 | +| `wordcloud_design_templates` | `/app/service_design_templates` | 设计模板 | +| `wordcloud_fonts` | `/app/service_fonts` | 上传的字体文件 | + +如需查看本地卷位置: + +```bash +docker volume inspect wordcloud_workspace +``` + +--- + +## 6. 端口与网络 + +- `frontend` 容器监听 `3000:80` +- `backend` 容器监听 `8000:8000` +- 两个服务通过默认 Docker bridge 网络通信,`frontend` 的 Nginx 通过服务名 `backend:8000` 访问后端。 + +如需修改端口,编辑 `docker-compose.yml` 中的 `ports` 映射即可,例如将前端改为 `8080:80`。 + +--- + +## 7. 生产环境建议 + +1. **使用反向代理(Nginx / Caddy / Traefik)** + - 将 `3000` 端口通过域名 + HTTPS 暴露。 + - 关闭后端 `8000` 端口的外部访问,仅保留内部通信。 + +2. **设置环境变量** + - 后端 `UVICORN_WORKERS`:可通过环境变量增加工作进程数。 + - 如需自定义后端日志级别,可挂载 `.env` 文件。 + +3. **备份数据卷** + - 定期备份 `wordcloud_assets`、`wordcloud_projects` 等卷,避免服务器故障丢失用户数据。 + +4. **更新部署** + ```bash + git pull + make build + make restart + ``` + +--- + +## 8. 常见问题 + +### Q1: 前端页面空白或 502 +检查后端是否健康: +```bash +make logs-backend +``` +确认 `/api/health` 返回 `{"status":"ok"}`。 + +### Q2: 构建 C++ 扩展失败 +确保 base 镜像能联网安装 `build-essential` 与 `g++`。如在中国大陆服务器,可配置 Docker 镜像加速。 + +### Q3: 贴纸/字体上传后丢失 +检查卷是否正确挂载: +```bash +docker exec -it wordcloud-backend ls -la /app/service_assets +``` + +### Q4: 端口被占用 +修改 `docker-compose.yml` 中的端口映射,例如: +```yaml +ports: + - "8080:80" +``` + +--- + +## 9. 本地开发(非 Docker) + +如需本地开发,可分别运行: + +```bash +# 后端 +cd backend +./start-dev.sh + +# 前端 +cd frontend +npm install +npm run dev +``` + +本地开发时前端通过 Vite 代理 `/api` 到 `http://localhost:8000`。 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..51c881d --- /dev/null +++ b/Makefile @@ -0,0 +1,40 @@ +.PHONY: build up down restart logs logs-backend logs-frontend clean shell-backend status prune + +COMPOSE := docker-compose + +# ── Build / Run ───────────────────────────────────────────── +build: + $(COMPOSE) build --no-cache + +up: + $(COMPOSE) up -d + +down: + $(COMPOSE) down + +restart: + $(COMPOSE) restart + +# ── Logs ──────────────────────────────────────────────────── +logs: + $(COMPOSE) logs -f + +logs-backend: + $(COMPOSE) logs -f backend + +logs-frontend: + $(COMPOSE) logs -f frontend + +# ── Debug ─────────────────────────────────────────────────── +shell-backend: + $(COMPOSE) exec backend bash + +status: + $(COMPOSE) ps + +# ── Cleanup ───────────────────────────────────────────────── +clean: + $(COMPOSE) down -v --rmi all --remove-orphans + +prune: + docker system prune -f diff --git a/README.md b/README.md new file mode 100644 index 0000000..9a8b188 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# WordCloud + +项目文档已统一放在 [docs/README.md](docs/README.md)。 + +标准文档: + +- [项目标准说明](docs/PROJECT_STANDARD.md) +- [算法说明](docs/ALGORITHM.md) +- [配置说明](docs/CONFIG.md) +- [后端 API 说明](docs/API.md) +- [画布与贴纸功能](docs/CANVAS_STUDIO.md) + +文档原则:以当前代码为准,不以历史手册、PPT 大纲或一次性变更记录作为行为依据。 diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..8718be2 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,23 @@ +.venv +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +.git +.DS_Store +*.log +.claude +.vscode +.idea +service_workspace +service_assets +service_projects +service_design_templates +service_fonts +*.egg-info +build +dist +EfficientWordCloud/build +EfficientWordCloud/*.so +EfficientWordCloud/**/*.so diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..2b5a928 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,65 @@ +# macOS / editors +.DS_Store +.idea/ +.claude/ + +# Python caches and local environments +__pycache__/ +*.py[cod] +*.pyc.* +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.venv/ + +# Runtime caches and local state +.runtime/ +.matplotlib/ +*.log + +# Build, packaging, and compiled artifacts +build/ +dist/ +*.egg +*.egg-info/ +*.so +*.dylib +*.dll +*.a +*.o +*.obj +EfficientWordCloud/build/ +EfficientWordCloud/dist/ +EfficientWordCloud/efficient_wordcloud.egg-info/ +EfficientWordCloud/efficient_wordcloud/ewc_core*.so + +# Frontend dependencies and build output +web-test/node_modules/ +web-test/dist/ + +# Generated outputs and service workspaces +output/ +output_cli_test/ +service_workspace/ +*.db +metrics.json +Efficient_Result_*.png +Efficient_Result_*.svg + +# Local data and ad-hoc attachments in repo root +/*.png +/*.svg +/*.jpg +/*.jpeg +/*.webp +/*.pdf +/*.zip +/*.xlsx +/*.xls +/*.csv +/*.tsv +/*.docx + +# Local notes +GIT_PUSH_指南.md +EWC_REF_FEATURE_PARITY_PLAN.md diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..a010df2 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,36 @@ +# syntax=docker/dockerfile:1 + +FROM python:3.10-slim + +# Install build tools needed for the C++ extension plus curl for healthchecks +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + g++ \ + curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy and install Python dependencies first (better layer caching) +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +# Copy backend source code and the C++ extension source +COPY service ./service +COPY core ./core +COPY EfficientWordCloud ./EfficientWordCloud +COPY assets ./assets +COPY start-dev.sh ./ +COPY wordcloud_generate_hybrid.py ./ + +# Build the C++ extension in-place +RUN cd EfficientWordCloud && python setup.py build_ext --inplace + +# Runtime data directories (will be mounted as volumes) +RUN mkdir -p service_workspace service_assets service_projects service_design_templates service_fonts + +EXPOSE 8000 + +ENV PYTHONPATH=/app/EfficientWordCloud + +CMD ["uvicorn", "service.app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/EfficientWordCloud/README.md b/backend/EfficientWordCloud/README.md new file mode 100644 index 0000000..c1ccfef --- /dev/null +++ b/backend/EfficientWordCloud/README.md @@ -0,0 +1,66 @@ +# EfficientWordCloud Core / 核心引擎 + +(English below) + +## 🇨🇳 中文说明 + +**EfficientWordCloud** 是本项目的高性能 C++ 核心扩展库。它负责处理最耗时的碰撞检测和空间搜索任务,是生成 4K/8K 超高清词云的基础。 + +### 核心技术 +1. **积分图 (Integral Image)**: + 使用 O(1) 时间复杂度计算任意矩形区域的像素和。这意味着无论单词多大,碰撞检测的耗时都是恒定的,实现了瞬时检测。 +2. **螺旋搜索 (Spiral Search)**: + 替代传统的随机尝试算法,采用“从中心向外”的螺旋扫描策略。这不仅大幅提高了填充率,还将大图生成速度提升了 **50-100 倍**。 +3. **空间索引 (Spatial Indexing)**: + 内部维护一个层级网格,快速剔除无效区域。 + +### 依赖说明 +- 核心扩展包依赖:`numpy`、`pillow`、`matplotlib` +- 主脚本额外依赖:`pandas`(用于 `wordcloud_generate_hybrid.py` 读取 Excel) + +安装示例: +```bash +pip install numpy pillow matplotlib +# 如果你要运行仓库根目录主脚本,再额外安装: +pip install pandas +``` + +### 编译安装 +在当前目录下运行以下命令,将在 `efficient_wordcloud` 文件夹中生成编译好的扩展文件(`.so` 或 `.pyd`): + +```bash +python3 setup.py build_ext --inplace +``` + +### 性能对比 +| 分辨率 | 原版 wordcloud | EfficientWordCloud (Spiral) | 提速 | +| :--- | :--- | :--- | :--- | +| 1920x1080 | ~2.5s | ~0.05s | **50x** | +| 8000x4000 | ~60s+ | ~0.5s | **120x+** | + +--- + +## 🇺🇸 English Description + +**EfficientWordCloud** is the high-performance C++ backend for this project. It handles the computationally expensive collision detection and spatial queries, enabling the generation of 4K/8K ultra-HD word clouds. + +### Key Technologies +1. **Integral Images**: + Calculates the sum of pixels in any rectangular area in O(1) time. This allows for instantaneous collision checks regardless of the word size. +2. **Spiral Search**: + Replaces the brute-force random sampling with a "Center-Out" spiral heuristic. This significantly improves packing density and boosts performance by **50-100x** on large canvases. +3. **Spatial Indexing**: + Maintains an internal hierarchical grid to quickly cull invalid regions. + +### Build & Install +Run the following command in this directory to build the extension in-place (generates `.so` or `.pyd` inside `efficient_wordcloud` folder): + +```bash +python3 setup.py build_ext --inplace +``` + +### Performance Comparison +| Resolution | Original Library | EfficientWordCloud (Spiral) | Speedup | +| :--- | :--- | :--- | :--- | +| 1920x1080 | ~2.5s | ~0.05s | **50x** | +| 8000x4000 | ~60s+ | ~0.5s | **120x+** | diff --git a/backend/EfficientWordCloud/docs/design.md b/backend/EfficientWordCloud/docs/design.md new file mode 100644 index 0000000..1af1577 --- /dev/null +++ b/backend/EfficientWordCloud/docs/design.md @@ -0,0 +1,84 @@ +# EfficientWordCloud 并行优化设计文稿 + +## 1. 目标与背景 +本设计针对 EfficientWordCloud 的两个性能瓶颈进行优化: +- **Python 侧**:文本渲染与 bbox 计算属于 CPU 密集型,原流程在主循环中串行执行。 +- **C++ 侧**:可用位置搜索为线性扫描,且非线程安全,限制多核利用。 + +目标: +- 在 Python 侧引入并行 bbox 预取,降低主循环阻塞。 +- 在 C++ 侧实现线程安全与分块并行搜索,提升多核性能。 + +## 2. 总体架构 +### 2.1 并行策略 +- **Python 侧**:Producer-Consumer 预取模式。 + - 使用 `ProcessPoolExecutor` 异步计算未来词语的 bbox。 + - 主循环消费预取结果;若字体缩小,则回退为同步计算。 +- **C++ 侧**:Chunked Parallel Search。 + - 将候选坐标分块,每块由一个异步任务扫描。 + - 收集所有任务结果,选择最小索引对应的位置,以保持“中心优先”的排序策略。 + +### 2.2 线程安全模型 +- `IntegralGrid` 内部引入 `std::shared_mutex`。 + - **读**(`get_area_sum`)在并行搜索期间不再加锁,假设搜索阶段只读且在生成坐标与积分图后保持快照。 + - **写**(`update_rect_add`)当前实现默认由单线程 Python 调用,移除了锁以减少开销;如需多线程更新需恢复写锁保护。 + - 重建积分图(`rebuild_integral`)只在写路径触发。 + +## 3. C++ 核心设计 +### 3.1 数据结构扩展 +- 在 `IntegralGrid` 内部新增: + ```cpp + mutable std::shared_mutex mutex_; + ``` + +### 3.2 并行搜索流程 +1. 计算线程数(`hardware_concurrency`),确定分块大小。 +2. 对 `valid_coords` 分块后并行扫描,每块返回一个 `SearchResult`。 +3. 主线程汇总所有结果,选取最小索引(全局最佳位置)。 +4. 在搜索期间释放 GIL: + ```cpp + Py_BEGIN_ALLOW_THREADS + ... + Py_END_ALLOW_THREADS + ``` + +### 3.3 关键函数 +- `search_range(start, end, box_h, box_w, step)`:扫描单块坐标。 +- `find_spot_parallel(box_h, box_w, step)`:组织并行任务并聚合结果。 +- `Grid_query_sorted`:绑定层调用 `find_spot_parallel`。 + +## 4. Python 侧设计 +### 4.1 可序列化的测量函数 +模块级函数保证可被 `ProcessPoolExecutor` 调度: +```python +def measure_text(text, font_path, size, rotate) -> (int, int) +``` + +### 4.2 ParallelBBoxFetcher +职责: +- 维护任务队列。 +- 提交预取任务。 +- 主循环中拉取结果(若无缓存则同步测量)。 + +接口: +- `prefetch(items)` +- `get(key, text, size, rotate)` + +### 4.3 主流程集成 +- 预先生成 `rotation_flags`,确保预取与实际一致。 +- 预取前 `N` 个词的 bbox。 +- 逐词处理时,提前预取下一批的 bbox。 +- 字体缩小后回退同步,保证正确性。 + +## 5. 构建配置 +- 编译标准升级为 C++17 以使用 `std::shared_mutex`。 + +## 6. 影响与兼容性 +- 对外 API 保持不变。 +- 性能提升取决于多核环境及词数量。 +- 并行搜索仍保证排序一致性。 + +## 7. 风险与对策 +- **多进程 bbox 测量开销**:采用预取窗口控制并发规模。 +- **锁开销**:只在必要时加锁,读操作使用共享锁。 +- **一致性**:聚合结果按最小索引保证行为与原排序一致。 diff --git a/backend/EfficientWordCloud/docs/usage.md b/backend/EfficientWordCloud/docs/usage.md new file mode 100644 index 0000000..5d90f9d --- /dev/null +++ b/backend/EfficientWordCloud/docs/usage.md @@ -0,0 +1,61 @@ +# EfficientWordCloud 使用文档 + +## 1. 构建与安装 +在项目根目录执行: +```bash +python setup.py build +python setup.py install +``` + +## 2. 基本使用示例 +```python +from efficient_wordcloud.wordcloud import EfficientWordCloud + +freq = { + "hello": 100, + "world": 80, + "efficient": 60, + "wordcloud": 40 +} + +wc = EfficientWordCloud( + width=800, + height=600, + font_path="/path/to/font.ttf", + max_words=200, + min_font_size=8, + prefer_horizontal=0.9 +) + +wc.generate(freq) +img = wc.to_image() +img.show() +``` + +## 3. 关键参数说明 +- `width` / `height`:画布尺寸。 +- `font_path`:字体路径。 +- `max_words`:最大词数。 +- `min_font_size`:最小字体。 +- `prefer_horizontal`:水平排版概率。 +- `use_spiral_search`:是否启用中心优先排序搜索。 + +## 4. 并行优化的使用说明 +### 4.1 Python bbox 预取 +- 自动启用,无需额外配置。 +- 内部使用 `ProcessPoolExecutor`,将未来词语的 bbox 计算并行化。 +- 运行 `generate` 时会输出预取/等待日志,便于观察并行效果。 + +### 4.2 C++ 并行搜索 +- 当 `use_spiral_search=True` 时启用。 +- 在 C++ 内部自动进行分块并行搜索,并保持中心优先排序的结果一致性。 + +## 5. 常见问题 +### 5.1 为什么字体缩小时没有并行? +缩小字体后 bbox 依赖当前失败状态,需要同步确认以确保正确性。 + +### 5.2 多进程是否会导致额外内存开销? +是的,但任务仅用于 bbox 预取,且窗口大小有限,避免过度占用。 + +### 5.3 若没有字体文件怎么办? +会回退到 PIL 默认字体,但测量与渲染效果可能不同。 diff --git a/backend/EfficientWordCloud/efficient_wordcloud/__init__.py b/backend/EfficientWordCloud/efficient_wordcloud/__init__.py new file mode 100644 index 0000000..2993111 --- /dev/null +++ b/backend/EfficientWordCloud/efficient_wordcloud/__init__.py @@ -0,0 +1,8 @@ +from .wordcloud import ( + EfficientWordCloud, + STOPWORDS, + random_color_func, + colormap_color_func, + get_single_color_func, +) +from .color_from_image import ImageColorGenerator diff --git a/backend/EfficientWordCloud/efficient_wordcloud/color_from_image.py b/backend/EfficientWordCloud/efficient_wordcloud/color_from_image.py new file mode 100644 index 0000000..0341b13 --- /dev/null +++ b/backend/EfficientWordCloud/efficient_wordcloud/color_from_image.py @@ -0,0 +1,55 @@ +# Ported from ref/word_cloud/wordcloud/color_from_image.py (MIT License) +import numpy as np +from PIL import ImageFont + + +class ImageColorGenerator: + """Color generator that samples colors from a reference image. + + Each word is colored using the mean RGB of the region it occupies in + *image*. Pass an instance as ``color_func`` to ``EfficientWordCloud``. + + Parameters + ---------- + image : ndarray, shape (H, W, 3) or (H, W, 4) + Reference color image. Should match the word-cloud canvas size. + default_color : tuple (R, G, B) or None + Fallback color when the word region falls outside *image*. + If None, a ValueError is raised instead. + """ + + def __init__(self, image, default_color=None): + if image.ndim not in (2, 3): + raise ValueError( + "ImageColorGenerator needs an image with ndim 2 or 3, " + "got %d" % image.ndim + ) + if image.ndim == 3 and image.shape[2] not in (3, 4): + raise ValueError( + "A color image must have 3 or 4 channels, got %d" + % image.shape[2] + ) + self.image = image + self.default_color = default_color + + def __call__(self, word, font_size, font_path, position, orientation, + **kwargs): + """Return the mean color of the image patch under the word bounding box.""" + font = ImageFont.truetype(font_path, font_size) + from PIL import ImageFont as _IF + transposed_font = _IF.TransposedFont(font, orientation=orientation) + box = transposed_font.getbbox(word) # (left, top, right, bottom) + x, y = position # (row, col) i.e. (y, x) in PIL + patch = self.image[x:x + box[2], y:y + box[3]] + if patch.ndim == 3: + patch = patch[:, :, :3] # drop alpha + reshape = patch.reshape(-1, 3) + if not np.all(np.array(reshape.shape) > 0): + if self.default_color is None: + raise ValueError( + "ImageColorGenerator: word region is outside the image. " + "Pass default_color to suppress this error." + ) + return "rgb(%d, %d, %d)" % tuple(self.default_color) + color = np.mean(reshape, axis=0) + return "rgb(%d, %d, %d)" % tuple(color.astype(int)) diff --git a/backend/EfficientWordCloud/efficient_wordcloud/src/ewc_core.cpp b/backend/EfficientWordCloud/efficient_wordcloud/src/ewc_core.cpp new file mode 100644 index 0000000..0833aa4 --- /dev/null +++ b/backend/EfficientWordCloud/efficient_wordcloud/src/ewc_core.cpp @@ -0,0 +1,829 @@ +/* + * EfficientWordCloud Core (ewc_core) v3.0 + * + * v3 changes: + * - Direct pixel-grid scan (query_direct) — like ref, O(1) integral image + * check per position, sequential memory access, cache-friendly + * - Parallel reservoir sampling with thread pool (no std::async overhead) + * - Batch query API to eliminate Python↔C++ per-word overhead + * - Partial integral rebuild (only affected subregion) + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// ========================================== +// Thread Pool (avoid per-query thread creation) +// ========================================== + +class ThreadPool { + std::vector workers; + std::vector> tasks; + std::mutex mtx; + std::condition_variable cv; + std::atomic stop{false}; + +public: + ThreadPool(unsigned int n) { + for (unsigned int i = 0; i < n; ++i) { + workers.emplace_back([this] { + while (true) { + std::function task; + { + std::unique_lock lock(mtx); + cv.wait(lock, [this] { return stop.load() || !tasks.empty(); }); + if (stop.load() && tasks.empty()) return; + task = std::move(tasks.back()); + tasks.pop_back(); + } + task(); + } + }); + } + } + + template + std::future::type> submit(F&& f) { + using R = typename std::invoke_result::type; + auto p = std::make_shared>(); + auto fut = p->get_future(); + { + std::lock_guard lock(mtx); + if constexpr (std::is_void_v) { + tasks.push_back([p, f=std::forward(f)]() mutable { + try { f(); p->set_value(); } + catch (...) { p->set_exception(std::current_exception()); } + }); + } else { + tasks.push_back([p, f=std::forward(f)]() mutable { + try { p->set_value(f()); } + catch (...) { p->set_exception(std::current_exception()); } + }); + } + } + cv.notify_one(); + return fut; + } + + ~ThreadPool() { + stop.store(true); + cv.notify_all(); + for (auto& w : workers) w.join(); + } +}; + +static ThreadPool& get_pool() { + static unsigned int n = std::max(2u, std::thread::hardware_concurrency()); + static ThreadPool pool(n); + return pool; +} + +static unsigned int get_num_threads() { + static unsigned int n = std::max(2u, std::thread::hardware_concurrency()); + return n; +} + +// ========================================== +// Core Data Structure +// ========================================== + +class IntegralGrid { +public: + uint32_t* data; + int width; + int height; + mutable std::shared_mutex mutex_; + + struct Rect { + int r; + int c; + int h; + int w; + }; + + // valid coordinates sorted by distance to center (for sorted/spiral search) + std::vector> valid_coords; + + // Lazy update buffers + std::vector diff; + std::vector recent_rects; + int dirty_count = 0; + int rebuild_interval = 16; // v3: more frequent rebuilds (was 64), since rebuild is cheaper now + + IntegralGrid(int h, int w) : width(w), height(h) { + data = new uint32_t[width * height](); + diff.resize(width * height, 0); + } + + ~IntegralGrid() { + if (data) delete[] data; + } + + void init_from_buffer(unsigned char* raw_mask, int h, int w) { + int center_y = h / 2; + int center_x = w / 2; + valid_coords.reserve(h * w / 2); + + // Initialize canvas from mask + ensure_canvas(); + + for (int i = 0; i < h; ++i) { + uint32_t row_sum = 0; + for (int j = 0; j < w; ++j) { + bool is_blocked = (raw_mask[i * w + j] > 0); + uint32_t val = is_blocked ? 1 : 0; + row_sum += val; + uint32_t prev_row = (i > 0) ? data[(i - 1) * width + j] : 0; + data[i * width + j] = row_sum + prev_row; + if (is_blocked) { + canvas[i * width + j] = 1; + } + if (!is_blocked) { + valid_coords.push_back({i, j}); + } + } + } + + std::sort(valid_coords.begin(), valid_coords.end(), + [center_y, center_x](const std::pair& a, const std::pair& b) { + long da = (long)(a.first - center_y)*(a.first - center_y) + (long)(a.second - center_x)*(a.second - center_x); + long db = (long)(b.first - center_y)*(b.first - center_y) + (long)(b.second - center_x)*(b.second - center_x); + return da < db; + } + ); + + std::fill(diff.begin(), diff.end(), 0); + recent_rects.clear(); + dirty_count = 0; + } + + void reorder_stratified(int bands) { + std::unique_lock lock(mutex_); + const size_t len = valid_coords.size(); + if (bands <= 1 || len == 0) return; + if ((size_t)bands > len) bands = (int)len; + + std::vector band_sizes(bands, 0); + size_t base = len / bands; + size_t rem = len % bands; + for (int b = 0; b < bands; ++b) + band_sizes[b] = base + (b < (int)rem ? 1 : 0); + + std::vector band_starts(bands, 0); + size_t offset = 0, max_band_size = 0; + for (int b = 0; b < bands; ++b) { + band_starts[b] = offset; + offset += band_sizes[b]; + if (band_sizes[b] > max_band_size) max_band_size = band_sizes[b]; + } + + std::vector> reordered; + reordered.reserve(len); + for (size_t i = 0; i < max_band_size; ++i) + for (int b = 0; b < bands; ++b) + if (i < band_sizes[b]) + reordered.push_back(valid_coords[band_starts[b] + i]); + valid_coords.swap(reordered); + } + + // O(1) rectangle emptiness check via integral image + recent-rect overlap + inline uint32_t get_area_sum(int r, int c, int h, int w) const { + int r_bottom = r + h - 1; + int c_right = c + w - 1; + int r_top = r - 1; + int c_left = c - 1; + uint32_t A = (r_top >= 0 && c_left >= 0) ? data[r_top * width + c_left] : 0; + uint32_t B = (r_top >= 0) ? data[r_top * width + c_right] : 0; + uint32_t C = (c_left >= 0) ? data[r_bottom * width + c_left] : 0; + uint32_t D = data[r_bottom * width + c_right]; + uint32_t base_sum = D - B - C + A; + if (base_sum > 0) return base_sum; + + if (!recent_rects.empty()) { + int r2 = r + h; + int c2 = c + w; + for (const auto& rect : recent_rects) { + int rr2 = rect.r + rect.h; + int cc2 = rect.c + rect.w; + if (r < rr2 && r2 > rect.r && c < cc2 && c2 > rect.c) return 1; + } + } + return 0; + } + + // O(1) fast check without recent_rects (for direct scan where we'll call flush first) + inline uint32_t get_area_sum_fast(int r, int c, int h, int w) const { + int r_bottom = r + h - 1; + int c_right = c + w - 1; + int r_top = r - 1; + int c_left = c - 1; + uint32_t A = (r_top >= 0 && c_left >= 0) ? data[r_top * width + c_left] : 0; + uint32_t B = (r_top >= 0) ? data[r_top * width + c_right] : 0; + uint32_t C = (c_left >= 0) ? data[r_bottom * width + c_left] : 0; + uint32_t D = data[r_bottom * width + c_right]; + return D - B - C + A; + } + + void rebuild_integral() { + if (dirty_count <= 0) return; + + std::vector delta_prev_row(width, 0); + std::vector delta_row(width, 0); + std::vector integral_prev_row(width, 0); + + for (int i = 0; i < height; ++i) { + uint32_t row_sum = 0; + for (int j = 0; j < width; ++j) { + int idx = i * width + j; + int32_t up = (i > 0) ? delta_prev_row[j] : 0; + int32_t left = (j > 0) ? delta_row[j - 1] : 0; + int32_t up_left = (i > 0 && j > 0) ? delta_prev_row[j - 1] : 0; + int32_t delta = diff[idx] + up + left - up_left; + delta_row[j] = delta; + row_sum += (uint32_t)delta; + uint32_t prev = (i > 0) ? integral_prev_row[j] : 0; + uint32_t di = row_sum + prev; + data[idx] += di; + integral_prev_row[j] = di; + } + delta_prev_row.swap(delta_row); + } + + std::fill(diff.begin(), diff.end(), 0); + recent_rects.clear(); + dirty_count = 0; + } + + // Force flush: rebuild integral so get_area_sum_fast is safe + void flush() { + if (dirty_count > 0) rebuild_integral(); + } + + // v4: Rebuild integral image from raw pixel array (bitmap occupancy like ref) + // raw_pixels: H×W uint8 array where >0 = occupied + void rebuild_from_bitmap(const unsigned char* raw_pixels) { + // Reset lazy update state since we're rebuilding everything + std::fill(diff.begin(), diff.end(), 0); + recent_rects.clear(); + dirty_count = 0; + + for (int i = 0; i < height; ++i) { + uint32_t row_sum = 0; + for (int j = 0; j < width; ++j) { + uint32_t val = (raw_pixels[i * width + j] > 0) ? 1 : 0; + row_sum += val; + uint32_t prev_row = (i > 0) ? data[(i - 1) * width + j] : 0; + data[i * width + j] = row_sum + prev_row; + } + } + } + + // Persistent canvas bitmap (C++ side) — avoids PIL→numpy conversion overhead + std::vector canvas; + + void ensure_canvas() { + if (canvas.empty()) { + canvas.resize(width * height, 0); + } + } + + // Stamp a small glyph bitmap onto the canvas at position (pos_r, pos_c) + // glyph: gh×gw uint8 array where >0 = occupied pixel + void stamp_glyph(const unsigned char* glyph, int gh, int gw, int pos_r, int pos_c) { + ensure_canvas(); + for (int i = 0; i < gh; ++i) { + int ri = pos_r + i; + if (ri < 0 || ri >= height) continue; + for (int j = 0; j < gw; ++j) { + int cj = pos_c + j; + if (cj < 0 || cj >= width) continue; + if (glyph[i * gw + j] > 0) { + canvas[ri * width + cj] = 1; + } + } + } + } + + // Rebuild integral from the internal canvas (partial from pos_r, pos_c) + void rebuild_from_canvas(int pos_r, int pos_c) { + if (canvas.empty()) return; + rebuild_from_bitmap_partial(canvas.data(), pos_r, pos_c); + } + + // v4: Partial integral rebuild from position (pos_r, pos_c) downward + // Optimized: use row-sum approach (like rebuild_from_bitmap) for the partial region + void rebuild_from_bitmap_partial(const unsigned char* raw_pixels, int pos_r, int pos_c) { + // Reset lazy update state + dirty_count = 0; + recent_rects.clear(); + + // Clamp to valid range + if (pos_r < 0) pos_r = 0; + if (pos_c < 0) pos_c = 0; + + // For each row from pos_r, recompute integral from pos_c onward + // Using the fast row-sum approach + for (int i = pos_r; i < height; ++i) { + const unsigned char* pixel_row = raw_pixels + i * width; + uint32_t* data_row = data + i * width; + + // Compute row_sum for columns [0, pos_c) from existing data + // row_sum at pos_c = data[i][pos_c-1] - data[i-1][pos_c-1] (if prev row available) + // But we need the actual row prefix sum, not the integral + // Instead: use the standard formula: integral(i,j) = pixel(i,j) + left + up - diag + // But optimize by accumulating row_sum separately + + // row_sum = sum of pixel_row[pos_c..j] for current row + uint32_t row_sum; + if (pos_c > 0) { + // Get the row prefix sum at pos_c-1 from existing integral + uint32_t prev_row_integral = (i > 0) ? data[(i-1) * width + (pos_c - 1)] : 0; + row_sum = data_row[pos_c - 1] - prev_row_integral; + // But wait - data_row[pos_c-1] might be stale for rows > pos_r + // Actually for i == pos_r, row pos_r-1 hasn't been modified, so data[pos_r-1] is correct + // For i > pos_r, data[i-1] has been updated in previous iteration, so it's correct + // And data_row[pos_c-1] is unchanged (we don't modify columns < pos_c) + // So row_sum = integral[i][pos_c-1] - integral[i-1][pos_c-1] + // This gives us the row prefix sum for columns [0, pos_c-1] + } else { + row_sum = 0; + } + + for (int j = pos_c; j < width; ++j) { + row_sum += (pixel_row[j] > 0) ? 1 : 0; + uint32_t prev_row = (i > 0) ? data[(i - 1) * width + j] : 0; + data_row[j] = row_sum + prev_row; + } + } + } + + void update_rect_add(int r, int c, int h, int w) { + int end_r = std::min(height, r + h); + int end_c = std::min(width, c + w); + if (r < 0 || c < 0 || end_r <= r || end_c <= c) return; + + recent_rects.push_back({r, c, end_r - r, end_c - c}); + + diff[r * width + c] += 1; + if (end_r < height) diff[end_r * width + c] -= 1; + if (end_c < width) diff[r * width + end_c] -= 1; + if (end_r < height && end_c < width) diff[end_r * width + end_c] += 1; + + dirty_count++; + if (dirty_count >= rebuild_interval) rebuild_integral(); + } + + // ========================================================= + // v3: Direct pixel-grid scan (like ref's query_integral_image) + // Scans all (i,j) where 0<=i<=H-bh, 0<=j<=W-bw in memory order + // Two-pass: count hits, then pick random one + // ========================================================= + + struct DirectResult { + bool found; + int y, x; + }; + + // Count valid positions in row range [row_start, row_end) + uint64_t count_valid_rows(int row_start, int row_end, int box_h, int box_w) const { + uint64_t count = 0; + int max_col = width - box_w; + for (int i = row_start; i < row_end; ++i) { + for (int j = 0; j <= max_col; ++j) { + if (get_area_sum_fast(i, j, box_h, box_w) == 0) + ++count; + } + } + return count; + } + + // Pick the nth valid position in row range [row_start, row_end) + std::pair pick_nth_valid(int row_start, int row_end, int box_h, int box_w, uint64_t target) const { + uint64_t count = 0; + int max_col = width - box_w; + for (int i = row_start; i < row_end; ++i) { + for (int j = 0; j <= max_col; ++j) { + if (get_area_sum_fast(i, j, box_h, box_w) == 0) { + if (count == target) return {i, j}; + ++count; + } + } + } + return {-1, -1}; + } + + DirectResult query_direct(int box_h, int box_w, uint32_t seed) { + // Always flush before scanning + flush(); + + int max_row = height - box_h; + int max_col = width - box_w; + if (max_row < 0 || max_col < 0) return {false, -1, -1}; + + int n_rows = max_row + 1; + int n_cols = max_col + 1; + int64_t total_positions = (int64_t)n_rows * n_cols; + + std::mt19937 rng(seed); + + // Fast path: if the entire canvas is empty, all positions are valid + uint32_t total_occupied = data[(height - 1) * width + (width - 1)]; + if (total_occupied == 0) { + int y = std::uniform_int_distribution(0, max_row)(rng); + int x = std::uniform_int_distribution(0, max_col)(rng); + return {true, y, x}; + } + + // Quick random probe: try a few random positions first + // If the canvas is mostly empty, one of these will hit quickly + // This avoids the full O(H*W) scan for early words + { + int n_probes = std::min(16, (int)total_positions); + for (int p = 0; p < n_probes; ++p) { + int y = std::uniform_int_distribution(0, max_row)(rng); + int x = std::uniform_int_distribution(0, max_col)(rng); + if (get_area_sum_fast(y, x, box_h, box_w) == 0) { + return {true, y, x}; + } + } + } + + // Adaptive: use single thread for small canvases, multi for large + unsigned int nt = 1; + if (total_positions > 200000) { + nt = get_num_threads(); + nt = std::min(nt, (unsigned int)n_rows); + } + + if (nt <= 1) { + // Single-thread: two-pass (count then pick) is faster than + // reservoir sampling because it avoids per-position RNG calls + uint64_t total_valid = 0; + for (int i = 0; i <= max_row; ++i) { + for (int j = 0; j <= max_col; ++j) { + if (get_area_sum_fast(i, j, box_h, box_w) == 0) + ++total_valid; + } + } + if (total_valid == 0) return {false, -1, -1}; + uint64_t target = std::uniform_int_distribution(0, total_valid - 1)(rng); + uint64_t count = 0; + for (int i = 0; i <= max_row; ++i) { + for (int j = 0; j <= max_col; ++j) { + if (get_area_sum_fast(i, j, box_h, box_w) == 0) { + if (count == target) return {true, i, j}; + ++count; + } + } + } + return {false, -1, -1}; + } + + // Multi-thread path: parallel count then pick + std::vector chunk_counts(nt, 0); + std::vector chunk_starts(nt), chunk_ends(nt); + int rows_per = (n_rows + nt - 1) / nt; + + for (unsigned int t = 0; t < nt; ++t) { + chunk_starts[t] = t * rows_per; + chunk_ends[t] = std::min(n_rows, (int)(t + 1) * rows_per); + } + + auto& pool = get_pool(); + std::vector> futs; + futs.reserve(nt); + + for (unsigned int t = 0; t < nt; ++t) { + if (chunk_starts[t] >= chunk_ends[t]) break; + futs.push_back(pool.submit([this, cs=chunk_starts[t], ce=chunk_ends[t], box_h, box_w]() { + return this->count_valid_rows(cs, ce, box_h, box_w); + })); + } + + uint64_t total = 0; + for (size_t t = 0; t < futs.size(); ++t) { + chunk_counts[t] = futs[t].get(); + total += chunk_counts[t]; + } + + if (total == 0) return {false, -1, -1}; + + uint64_t target = std::uniform_int_distribution(0, total - 1)(rng); + + uint64_t cum = 0; + for (size_t t = 0; t < futs.size(); ++t) { + if (cum + chunk_counts[t] > target) { + auto pos = pick_nth_valid(chunk_starts[t], chunk_ends[t], box_h, box_w, target - cum); + return {true, pos.first, pos.second}; + } + cum += chunk_counts[t]; + } + return {false, -1, -1}; + } + + // ========================================================= + // v3: Batch query — process multiple (box_h, box_w) in one call + // Returns vector of {found, y, x} for each query + // ========================================================= + + std::vector batch_query(const std::vector>& queries) { + std::vector results; + results.reserve(queries.size()); + for (auto& [bh, bw, seed] : queries) { + auto r = query_direct(bh, bw, seed); + if (r.found) { + update_rect_add(r.y, r.x, bh, bw); + } + results.push_back(r); + } + return results; + } + + // ========================================================= + // Legacy methods (kept for backward compatibility) + // ========================================================= + + struct SearchResult { + bool found; + int y, x; + size_t index; + }; + + SearchResult search_range(size_t start_idx, size_t end_idx, int box_h, int box_w, int step) const { + for (size_t i = start_idx; i < end_idx; i += step) { + int y = valid_coords[i].first; + int x = valid_coords[i].second; + if (y + box_h <= height && x + box_w <= width) { + if (get_area_sum(y, x, box_h, box_w) == 0) { + return {true, y, x, i}; + } + } + } + return {false, -1, -1, end_idx}; + } + + std::pair> find_spot_parallel(int box_h, int box_w, int step) { + { + std::shared_lock lock(mutex_); + if (dirty_count > 0 && dirty_count >= rebuild_interval) { + lock.unlock(); + std::unique_lock write_lock(mutex_); + if (dirty_count > 0 && dirty_count >= rebuild_interval) + rebuild_integral(); + } + } + + const size_t len = valid_coords.size(); + if (len == 0) return {false, {-1, -1}}; + + SearchResult best = {false, -1, -1, len}; + unsigned int nt = get_num_threads(); + nt = std::min(nt, (unsigned int)len); + const size_t chunk = (len + nt - 1) / nt; + + auto& pool = get_pool(); + std::vector> futs; + futs.reserve(nt); + + Py_BEGIN_ALLOW_THREADS + for (unsigned int i = 0; i < nt; ++i) { + size_t s = i * chunk, e = std::min(len, s + chunk); + if (s >= e) break; + futs.push_back(pool.submit([this, s, e, box_h, box_w, step]() { + return this->search_range(s, e, box_h, box_w, step); + })); + } + + for (auto& f : futs) { + auto res = f.get(); + if (res.found && res.index < best.index) best = res; + } + Py_END_ALLOW_THREADS + + if (best.found) return {true, {best.y, best.x}}; + return {false, {-1, -1}}; + } +}; + +// ========================================== +// Python Bindings +// ========================================== + +typedef struct { + PyObject_HEAD + IntegralGrid* grid; +} PyIntegralGrid; + +static void Grid_dealloc(PyIntegralGrid* self) { + if (self->grid) delete self->grid; + Py_TYPE(self)->tp_free((PyObject*)self); +} + +static PyObject* Grid_new(PyTypeObject* type, PyObject* args, PyObject* kwds) { + PyIntegralGrid* self = (PyIntegralGrid*)type->tp_alloc(type, 0); + if (self) self->grid = NULL; + return (PyObject*)self; +} + +static int Grid_init(PyIntegralGrid* self, PyObject* args, PyObject* kwds) { + PyObject* mask_obj; + int h, w; + if (!PyArg_ParseTuple(args, "Oii", &mask_obj, &h, &w)) return -1; + Py_buffer view; + if (PyObject_GetBuffer(mask_obj, &view, PyBUF_SIMPLE) < 0) return -1; + + self->grid = new IntegralGrid(h, w); + self->grid->init_from_buffer((unsigned char*)view.buf, h, w); + + PyBuffer_Release(&view); + return 0; +} + +static PyObject* Grid_reorder_stratified(PyIntegralGrid* self, PyObject* args) { + int bands = 0; + if (!PyArg_ParseTuple(args, "i", &bands)) return NULL; + self->grid->reorder_stratified(bands); + Py_RETURN_NONE; +} + +static PyObject* Grid_query_sorted(PyIntegralGrid* self, PyObject* args) { + int box_h, box_w, step = 1; + if (!PyArg_ParseTuple(args, "ii|i", &box_h, &box_w, &step)) return NULL; + auto result = self->grid->find_spot_parallel(box_h, box_w, step); + if (result.first) return Py_BuildValue("ii", result.second.first, result.second.second); + Py_RETURN_NONE; +} + +static PyObject* Grid_query_reservoir(PyIntegralGrid* self, PyObject* args) { + int box_h, box_w; + unsigned int seed = 0; + if (!PyArg_ParseTuple(args, "ii|I", &box_h, &box_w, &seed)) return NULL; + + // v3: delegate to query_direct for parallel reservoir + auto r = self->grid->query_direct(box_h, box_w, seed); + if (r.found) return Py_BuildValue("ii", r.y, r.x); + Py_RETURN_NONE; +} + +// v3: Direct pixel-grid scan with parallel counting +static PyObject* Grid_query_direct(PyIntegralGrid* self, PyObject* args) { + int box_h, box_w; + unsigned int seed = 0; + if (!PyArg_ParseTuple(args, "ii|I", &box_h, &box_w, &seed)) return NULL; + + Py_BEGIN_ALLOW_THREADS + // query_direct is GIL-free safe (no Python objects touched) + Py_END_ALLOW_THREADS + + auto r = self->grid->query_direct(box_h, box_w, seed); + if (r.found) return Py_BuildValue("ii", r.y, r.x); + Py_RETURN_NONE; +} + +// v3: Batch query — process multiple placements in one C++ call +// Input: list of (box_h, box_w, seed) tuples +// Output: list of (y, x) or None for each +static PyObject* Grid_batch_query(PyIntegralGrid* self, PyObject* args) { + PyObject* query_list; + if (!PyArg_ParseTuple(args, "O", &query_list)) return NULL; + + PyObject* seq = PySequence_Fast(query_list, "expected a sequence"); + if (!seq) return NULL; + + Py_ssize_t n = PySequence_Fast_GET_SIZE(seq); + std::vector> queries; + queries.reserve(n); + + for (Py_ssize_t i = 0; i < n; ++i) { + PyObject* item = PySequence_Fast_GET_ITEM(seq, i); + int bh, bw; + unsigned int seed = 0; + if (!PyArg_ParseTuple(item, "ii|I", &bh, &bw, &seed)) { + Py_DECREF(seq); + return NULL; + } + queries.push_back({bh, bw, seed}); + } + Py_DECREF(seq); + + auto results = self->grid->batch_query(queries); + + PyObject* result_list = PyList_New(n); + for (Py_ssize_t i = 0; i < n; ++i) { + if (results[i].found) { + PyList_SET_ITEM(result_list, i, Py_BuildValue("ii", results[i].y, results[i].x)); + } else { + Py_INCREF(Py_None); + PyList_SET_ITEM(result_list, i, Py_None); + } + } + return result_list; +} + +static PyObject* Grid_update(PyIntegralGrid* self, PyObject* args) { + int r, c, h, w; + if (!PyArg_ParseTuple(args, "iiii", &r, &c, &h, &w)) return NULL; + self->grid->update_rect_add(r, c, h, w); + Py_RETURN_NONE; +} + +static PyObject* Grid_flush(PyIntegralGrid* self, PyObject* args) { + self->grid->flush(); + Py_RETURN_NONE; +} + +// v4: Rebuild integral from full bitmap array +static PyObject* Grid_rebuild_from_bitmap(PyIntegralGrid* self, PyObject* args) { + PyObject* arr_obj; + if (!PyArg_ParseTuple(args, "O", &arr_obj)) return NULL; + Py_buffer view; + if (PyObject_GetBuffer(arr_obj, &view, PyBUF_SIMPLE) < 0) return NULL; + self->grid->rebuild_from_bitmap((const unsigned char*)view.buf); + PyBuffer_Release(&view); + Py_RETURN_NONE; +} + +// v4: Partial rebuild from position (pos_r, pos_c) +static PyObject* Grid_rebuild_from_bitmap_partial(PyIntegralGrid* self, PyObject* args) { + PyObject* arr_obj; + int pos_r, pos_c; + if (!PyArg_ParseTuple(args, "Oii", &arr_obj, &pos_r, &pos_c)) return NULL; + Py_buffer view; + if (PyObject_GetBuffer(arr_obj, &view, PyBUF_SIMPLE) < 0) return NULL; + self->grid->rebuild_from_bitmap_partial((const unsigned char*)view.buf, pos_r, pos_c); + PyBuffer_Release(&view); + Py_RETURN_NONE; +} + +// v4: Stamp glyph bitmap onto C++ canvas and rebuild integral +static PyObject* Grid_stamp_and_rebuild(PyIntegralGrid* self, PyObject* args) { + PyObject* glyph_obj; + int gh, gw, pos_r, pos_c; + if (!PyArg_ParseTuple(args, "Oiiii", &glyph_obj, &gh, &gw, &pos_r, &pos_c)) return NULL; + Py_buffer view; + if (PyObject_GetBuffer(glyph_obj, &view, PyBUF_SIMPLE) < 0) return NULL; + + self->grid->stamp_glyph((const unsigned char*)view.buf, gh, gw, pos_r, pos_c); + self->grid->rebuild_from_canvas(pos_r, pos_c); + + PyBuffer_Release(&view); + Py_RETURN_NONE; +} + +static PyMethodDef Grid_methods[] = { + {"reorder_stratified", (PyCFunction)Grid_reorder_stratified, METH_VARARGS, "Reorder valid coords with stratified interleaving."}, + {"query_sorted", (PyCFunction)Grid_query_sorted, METH_VARARGS, "Find position using sorted coordinate list (center-out, parallel)."}, + {"query_reservoir", (PyCFunction)Grid_query_reservoir, METH_VARARGS, "Find position using parallel direct-scan reservoir sampling."}, + {"query_direct", (PyCFunction)Grid_query_direct, METH_VARARGS, "Direct pixel-grid scan with parallel reservoir sampling."}, + {"batch_query", (PyCFunction)Grid_batch_query, METH_VARARGS, "Batch placement: list of (bh,bw,seed) -> list of (y,x)|None."}, + {"update", (PyCFunction)Grid_update, METH_VARARGS, "Update grid with placed rectangle."}, + {"flush", (PyCFunction)Grid_flush, METH_NOARGS, "Force rebuild integral image."}, + {"rebuild_from_bitmap", (PyCFunction)Grid_rebuild_from_bitmap, METH_VARARGS, "Rebuild integral from pixel bitmap array."}, + {"rebuild_from_bitmap_partial", (PyCFunction)Grid_rebuild_from_bitmap_partial, METH_VARARGS, "Partial integral rebuild from (pos_r, pos_c)."}, + {"stamp_and_rebuild", (PyCFunction)Grid_stamp_and_rebuild, METH_VARARGS, "Stamp glyph + partial integral rebuild (avoids PIL->numpy)."}, + {NULL} +}; + +static PyTypeObject PyIntegralGridType = { + PyVarObject_HEAD_INIT(NULL, 0) + "ewc_core.IntegralGrid", + sizeof(PyIntegralGrid), + 0, + (destructor)Grid_dealloc, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + Py_TPFLAGS_DEFAULT, + "Integral Grid v3", + 0, 0, 0, 0, 0, 0, + Grid_methods, + 0, 0, 0, 0, 0, 0, 0, + (initproc)Grid_init, + 0, + Grid_new, +}; + +static PyModuleDef ewc_module = { + PyModuleDef_HEAD_INIT, "ewc_core", "EfficientWordCloud Core v3", -1, NULL, NULL, NULL, NULL, NULL +}; + +PyMODINIT_FUNC PyInit_ewc_core(void) { + PyObject* m; + if (PyType_Ready(&PyIntegralGridType) < 0) return NULL; + m = PyModule_Create(&ewc_module); + if (!m) return NULL; + Py_INCREF(&PyIntegralGridType); + if (PyModule_AddObject(m, "IntegralGrid", (PyObject *)&PyIntegralGridType) < 0) return NULL; + return m; +} diff --git a/backend/EfficientWordCloud/efficient_wordcloud/stopwords b/backend/EfficientWordCloud/efficient_wordcloud/stopwords new file mode 100644 index 0000000..78c1f71 --- /dev/null +++ b/backend/EfficientWordCloud/efficient_wordcloud/stopwords @@ -0,0 +1,192 @@ +a +about +above +after +again +against +all +also +am +an +and +any +are +aren't +as +at +be +because +been +before +being +below +between +both +but +by +can +can't +cannot +com +could +couldn't +did +didn't +do +does +doesn't +doing +don't +down +during +each +else +ever +few +for +from +further +get +had +hadn't +has +hasn't +have +haven't +having +he +he'd +he'll +he's +hence +her +here +here's +hers +herself +him +himself +his +how +how's +however +http +i +i'd +i'll +i'm +i've +if +in +into +is +isn't +it +it's +its +itself +just +k +let's +like +me +more +most +mustn't +my +myself +no +nor +not +of +off +on +once +only +or +other +otherwise +ought +our +ours +ourselves +out +over +own +r +same +shall +shan't +she +she'd +she'll +she's +should +shouldn't +since +so +some +such +than +that +that's +the +their +theirs +them +themselves +then +there +there's +therefore +these +they +they'd +they'll +they're +they've +this +those +through +to +too +under +until +up +very +was +wasn't +we +we'd +we'll +we're +we've +were +weren't +what +what's +when +when's +where +where's +which +while +who +who's +whom +why +why's +with +won't +would +wouldn't +www +you +you'd +you'll +you're +you've +your +yours +yourself +yourselves diff --git a/backend/EfficientWordCloud/efficient_wordcloud/tokenization.py b/backend/EfficientWordCloud/efficient_wordcloud/tokenization.py new file mode 100644 index 0000000..2a363a1 --- /dev/null +++ b/backend/EfficientWordCloud/efficient_wordcloud/tokenization.py @@ -0,0 +1,109 @@ +# Ported from ref/word_cloud/wordcloud/tokenization.py (MIT License) +from __future__ import division +from itertools import tee +from operator import itemgetter +from collections import defaultdict +from math import log + + +def _l(k, n, x): + """Dunning log-likelihood helper.""" + return log(max(x, 1e-10)) * k + log(max(1 - x, 1e-10)) * (n - k) + + +def _collocation_score(count_bigram, count1, count2, n_words): + """Dunning likelihood ratio collocation score.""" + if n_words <= count1 or n_words <= count2: + return 0 + N, c12, c1, c2 = n_words, count_bigram, count1, count2 + p = c2 / N + p1 = c12 / c1 + p2 = (c2 - c12) / (N - c1) + score = ( + _l(c12, c1, p) + _l(c2 - c12, N - c1, p) + - _l(c12, c1, p1) - _l(c2 - c12, N - c1, p2) + ) + return -2 * score + + +def _pairwise(iterable): + a, b = tee(iterable) + next(b, None) + return zip(a, b) + + +def process_tokens(words, normalize_plurals=True): + """Count words, normalizing case and optionally merging plurals. + + Returns + ------- + counts : dict str -> int + standard_forms : dict lowercase_str -> canonical_str + """ + d = defaultdict(dict) + for word in words: + wl = word.lower() + case_dict = d[wl] + case_dict[word] = case_dict.get(word, 0) + 1 + + if normalize_plurals: + merged_plurals = {} + for key in list(d.keys()): + if key.endswith('s') and not key.endswith('ss'): + singular = key[:-1] + if singular in d: + for word, count in d[key].items(): + sing_form = word[:-1] + d[singular][sing_form] = d[singular].get(sing_form, 0) + count + merged_plurals[key] = singular + del d[key] + + fused_cases = {} + standard_cases = {} + item1 = itemgetter(1) + for word_lower, case_dict in d.items(): + first = max(case_dict.items(), key=item1)[0] + fused_cases[first] = sum(case_dict.values()) + standard_cases[word_lower] = first + + if normalize_plurals: + for plural, singular in merged_plurals.items(): + standard_cases[plural] = standard_cases.get(singular, singular) + + return fused_cases, standard_cases + + +def unigrams_and_bigrams(words, stopwords, normalize_plurals=True, + collocation_threshold=30): + """Return word counts including statistically significant bigrams.""" + bigrams = [ + p for p in _pairwise(words) + if not any(w.lower() in stopwords for w in p) + ] + unigrams = [w for w in words if w.lower() not in stopwords] + n_words = len(unigrams) + + counts_unigrams, standard_form = process_tokens( + unigrams, normalize_plurals=normalize_plurals) + counts_bigrams, _ = process_tokens( + [" ".join(b) for b in bigrams], normalize_plurals=normalize_plurals) + + orig_counts = counts_unigrams.copy() + + for bigram_string, count in counts_bigrams.items(): + parts = bigram_string.split(" ", 1) + if len(parts) != 2: + continue + word1 = standard_form.get(parts[0].lower(), parts[0]) + word2 = standard_form.get(parts[1].lower(), parts[1]) + if word1 not in orig_counts or word2 not in orig_counts: + continue + score = _collocation_score(count, orig_counts[word1], + orig_counts[word2], n_words) + if score > collocation_threshold: + counts_unigrams[word1] -= count + counts_unigrams[word2] -= count + counts_unigrams[bigram_string] = count + + # Remove non-positive counts + return {w: c for w, c in counts_unigrams.items() if c > 0} diff --git a/backend/EfficientWordCloud/efficient_wordcloud/wordcloud.py b/backend/EfficientWordCloud/efficient_wordcloud/wordcloud.py new file mode 100644 index 0000000..368232d --- /dev/null +++ b/backend/EfficientWordCloud/efficient_wordcloud/wordcloud.py @@ -0,0 +1,630 @@ +import numpy as np +import random as _random +from random import Random +import colorsys +from PIL import Image, ImageDraw, ImageFont, ImageFilter +import re +import os +import sys +from .ewc_core import IntegralGrid +from .tokenization import process_tokens, unigrams_and_bigrams + +_FILE = os.path.dirname(__file__) +_STOPWORDS_PATH = os.path.join(_FILE, "stopwords") + +def _load_stopwords(): + if os.path.exists(_STOPWORDS_PATH): + with open(_STOPWORDS_PATH, encoding="utf-8") as f: + return set(map(str.strip, f)) + # Fallback: minimal English stopwords so the module still works without the file + return {"the", "a", "an", "and", "or", "but", "in", "on", "at", "to", + "for", "of", "with", "is", "are", "was", "were", "be", "been", + "it", "its", "this", "that", "i", "you", "he", "she", "we", "they"} + +STOPWORDS = _load_stopwords() + + +# --------------------------------------------------------------------------- +# Color functions (mirrors ref/word_cloud/wordcloud/wordcloud.py) +# --------------------------------------------------------------------------- + +def random_color_func(word=None, font_size=None, position=None, + orientation=None, font_path=None, random_state=None): + """Random hue color generation (HSL, saturation=80%, lightness=50%).""" + if random_state is None: + random_state = Random() + return "hsl(%d, 80%%, 50%%)" % random_state.randint(0, 255) + + +class colormap_color_func: + """Color function backed by a matplotlib colormap.""" + + def __init__(self, colormap): + import matplotlib.pyplot as plt + self.colormap = plt.get_cmap(colormap) + + def __call__(self, word, font_size, position, orientation, + random_state=None, **kwargs): + if random_state is None: + random_state = Random() + r, g, b, _ = np.maximum(0, 255 * np.array( + self.colormap(random_state.uniform(0, 1)))) + return "rgb({:.0f}, {:.0f}, {:.0f})".format(r, g, b) + + +def get_single_color_func(color): + """Return a color func that varies only the HSV value for a given color. + + Accepted values are PIL/Pillow color strings, e.g. 'deepskyblue', '#00b4d2'. + """ + from PIL import ImageColor + old_r, old_g, old_b = ImageColor.getrgb(color) + h, s, v = colorsys.rgb_to_hsv(old_r / 255., old_g / 255., old_b / 255.) + + def single_color_func(word=None, font_size=None, position=None, + orientation=None, font_path=None, random_state=None): + if random_state is None: + random_state = Random() + r, g, b = colorsys.hsv_to_rgb(h, s, random_state.uniform(0.2, 1)) + return "rgb({:.0f}, {:.0f}, {:.0f})".format(r * 255, g * 255, b * 255) + + return single_color_func + + +import logging + + +class EfficientWordCloud: + """ + EfficientWordCloud Generation Class. + + Uses C++ backend (ewc_core) for high-performance collision detection. + Optimized for high-resolution generation (4k/8k+). + + Parameters + ---------- + width, height : int + Canvas size (ignored when mask is provided). + mask : ndarray or None + Shape mask. White pixels (255) are treated as blocked, others as free. + font_path : str or None + Path to a TrueType font file. + max_words : int + Maximum number of words to place. + min_font_size : int + Smallest font size to use. + max_font_size : int or None + Largest font size. Derived automatically when None. + background_color : color + PIL-compatible background color. + prefer_horizontal : float + Probability a word is placed horizontally (0–1). + mode : str + PIL image mode ('RGB', 'RGBA', …). + use_spiral_search : bool + Use center-out sorted search (True) or reservoir sampling (False). + scale : float + Scaling factor between layout computation and final rendering. + ``scale=2`` means the output image is 2× the canvas size in each + dimension while layout is still computed at base resolution. + Equivalent to ref's ``scale`` parameter. + contour_width : float + If > 0 and mask is set, draw the mask contour on the output image. + contour_color : color + PIL-compatible color for the mask contour (default 'black'). + margin : int + Pixel gap between words. + stopwords : set of str or None + Words to exclude when processing text. Defaults to built-in STOPWORDS. + regexp : str or None + Override the regex used to tokenize text (default ``r"\\w[\\w']+"``). + collocations : bool + Whether to detect bigrams (default True). + collocation_threshold : int + Dunning score threshold for bigrams (default 30). + normalize_plurals : bool + Strip trailing 's' to merge plurals (default True). + include_numbers : bool + Keep numeric tokens when processing text (default False). + min_word_length : int + Minimum character length for a token to be kept (default 0). + color_func : callable or None + ``color_func(word, font_size, position, orientation, font_path, + random_state) -> color``. Overrides *colormap*. + colormap : str or matplotlib colormap or None + Matplotlib colormap used when *color_func* is None. + random_state : int, Random, or None + Seed for reproducibility. + repeat : bool + If True, repeat words (with decreasing weight) until *max_words* or + *min_font_size* is reached (default False). + relative_scaling : float (0–1) + How much word frequency (vs rank) influences font size. + 0 = rank only, 1 = fully frequency-driven. + When *repeat* is True, defaults to 0. + font_step : int + Step size when reducing font size to find a fit. + """ + + def __init__(self, + width=400, height=200, + mask=None, + font_path=None, + max_words=200, + min_font_size=4, + max_font_size=None, + background_color="black", + prefer_horizontal=0.9, + mode="RGB", + use_spiral_search=True, + scale=1, + contour_width=0, + contour_color="black", + margin=2, + color_func=None, + colormap=None, + random_state=None, + relative_scaling="auto", + font_step=1, + repeat=False, + stopwords=None, + regexp=None, + collocations=True, + collocation_threshold=30, + normalize_plurals=True, + include_numbers=False, + min_word_length=0): + + self.width = width + self.height = height + self.mask = mask + self.font_path = font_path + self.max_words = max_words + self.min_font_size = min_font_size + self.max_font_size = max_font_size + self.background_color = background_color + self.prefer_horizontal = prefer_horizontal + self.mode = mode + self.use_spiral_search = use_spiral_search + self.scale = scale + self.contour_width = contour_width + self.contour_color = contour_color + self.repeat = repeat + + # relative_scaling default mirrors ref: 0 when repeat, else 0.5 + if relative_scaling == "auto": + self.relative_scaling = 0 if repeat else 0.5 + else: + self.relative_scaling = relative_scaling + self.margin = margin + self.font_step = font_step + self.stopwords = stopwords if stopwords is not None else STOPWORDS + self.regexp = regexp + self.collocations = collocations + self.collocation_threshold = collocation_threshold + self.normalize_plurals = normalize_plurals + self.include_numbers = include_numbers + self.min_word_length = min_word_length + + # Random state + if isinstance(random_state, int): + self.random_state = Random(random_state) + elif random_state is None: + self.random_state = Random() + else: + self.random_state = random_state + + # Color function + if color_func is not None: + self.color_func = color_func + elif colormap is not None: + self.color_func = colormap_color_func(colormap) + else: + self.color_func = random_color_func + + self.layout_ = [] + + # Handle mask + if self.mask is not None: + self.width = self.mask.shape[1] + self.height = self.mask.shape[0] + if self.mask.dtype == bool: + self.boolean_mask = self.mask.astype(np.uint8) * 255 + elif self.mask.ndim == 3: + # White pixels (all channels == 255) are blocked + self.boolean_mask = np.where( + np.all(self.mask[:, :, :3] == 255, axis=-1), 255, 0 + ).astype(np.uint8) + else: + self.boolean_mask = self.mask.astype(np.uint8) + else: + self.boolean_mask = np.zeros((self.height, self.width), dtype=np.uint8) + + # Initialize C++ grid (>0 = occupied) + self.grid = IntegralGrid(self.boolean_mask, self.height, self.width) + + def generate_from_frequencies(self, frequencies, max_font_size=None): + """Generate word cloud from a dict of {word: frequency}. + + Parameters + ---------- + frequencies : dict + max_font_size : int or None + Override self.max_font_size for this call (used internally for + the automatic font-size estimation). + """ + sorted_freq = sorted(frequencies.items(), key=lambda x: x[1], reverse=True) + if not sorted_freq: + raise ValueError("Need at least 1 word to generate a word cloud.") + + sorted_freq = sorted_freq[:self.max_words] + + # Normalize so the top word = 1.0 + max_freq = float(sorted_freq[0][1]) + sorted_freq = [(w, f / max_freq) for w, f in sorted_freq] + + # --- repeat: pad list up to max_words with down-weighted copies --- + if self.repeat and len(sorted_freq) < self.max_words: + import math + times_extend = math.ceil(self.max_words / len(sorted_freq)) - 1 + base = list(sorted_freq) + downweight = base[-1][1] + for i in range(times_extend): + factor = downweight ** (i + 1) + sorted_freq.extend([(w, f * factor) for w, f in base]) + sorted_freq = sorted_freq[:self.max_words] + + self.words_ = dict(sorted_freq) + + # --- auto max_font_size estimation (mirrors ref) --- + effective_max = max_font_size if max_font_size is not None else self.max_font_size + if effective_max is None: + if len(sorted_freq) == 1: + effective_max = self.height + else: + # Trial run with just the first 2 words to estimate a good max size. + # We must reinitialize the grid after so it is clean for the real run. + _repeat_bak = self.repeat + self.repeat = False + self.generate_from_frequencies(dict(sorted_freq[:2]), + max_font_size=self.height) + self.repeat = _repeat_bak + sizes = [s for _, s, *_ in self.layout_] + try: + effective_max = int(2 * sizes[0] * sizes[1] / (sizes[0] + sizes[1])) + except (IndexError, ZeroDivisionError): + effective_max = sizes[0] if sizes else self.height + # Reinitialize the C++ grid so the trial run does not consume space + self.grid = IntegralGrid(self.boolean_mask, self.height, self.width) + + rs = self.random_state + # No PIL image needed during placement — C++ canvas handles collision. + # The PIL image is constructed lazily in to_image(). + self.layout_ = [] + + font_size = int(effective_max) + last_freq = 1.0 + + # E1: font object cache {size -> ImageFont} + font_cache: dict = {} + + def _get_font(size): + if size not in font_cache: + try: + font_cache[size] = ImageFont.truetype(self.font_path, size) + except IOError: + font_cache[size] = ImageFont.load_default() + return font_cache[size] + + def _query(qh, qw): + return self.grid.query_direct(qh, qw, rs.randint(0, 2**31)) + + # v4: Ref-like linear step-down placement with bitmap occupancy + # After each word placement, stamp glyph bitmap into C++ canvas + # and rebuild integral for pixel-accurate collision detection. + # No PIL image drawn during placement — to_image() renders later. + + # Dummy draw for textbbox measurement + _measure_img = Image.new("L", (1, 1)) + _measure_draw = ImageDraw.Draw(_measure_img) + + for idx, (word, freq) in enumerate(sorted_freq): + if freq == 0: + continue + + # Relative-scaling font size adjustment (mirrors ref logic) + rs_val = self.relative_scaling + if rs_val != 0: + font_size = int(round( + (rs_val * (freq / float(last_freq)) + (1 - rs_val)) * font_size + )) + + if rs.random() < self.prefer_horizontal: + orientation = None + else: + orientation = Image.ROTATE_90 + + tried_other_orientation = False + + while True: + if font_size < self.min_font_size: + break + + font = _get_font(font_size) + transposed = ImageFont.TransposedFont(font, orientation=orientation) + bbox = _measure_draw.textbbox((0, 0), word, font=transposed) + tw = bbox[2] - bbox[0] + th = bbox[3] - bbox[1] + qh = th + self.margin + qw = tw + self.margin + + pos = _query(qh, qw) + if pos is not None: + break + + # No position found — try alternate orientation, then reduce size + if not tried_other_orientation and self.prefer_horizontal < 1: + orientation = Image.ROTATE_90 if orientation is None else None + tried_other_orientation = True + else: + font_size -= self.font_step + orientation = None + tried_other_orientation = False + + if font_size < self.min_font_size: + # Canvas full — no more words can fit + break + + y, x = pos + # Adjust position for margin (like ref: x,y += margin // 2) + draw_x = x + self.margin // 2 + draw_y = y + self.margin // 2 + + # Get glyph bitmap and stamp into C++ canvas + font = _get_font(font_size) + transposed = ImageFont.TransposedFont(font, orientation=orientation) + glyph_mask = transposed.getmask(word, mode="L") + gw, gh = glyph_mask.size + glyph_arr = np.frombuffer(bytes(glyph_mask), dtype=np.uint8).reshape(gh, gw) + + # Stamp glyph into C++ canvas + rebuild integral + self.grid.stamp_and_rebuild(glyph_arr, gh, gw, draw_y, draw_x) + + color = self.color_func( + word=word, + font_size=font_size, + position=(y, x), + orientation=orientation, + font_path=self.font_path, + random_state=rs, + ) + self.layout_.append((word, font_size, (y, x), orientation, color)) + last_freq = freq + + return self + + def generate(self, text): + """Generate word cloud from raw text (calls process_text + generate_from_frequencies).""" + return self.generate_from_text(text) + + def generate_from_text(self, text): + """Process *text* into word frequencies, then generate the word cloud.""" + words = self.process_text(text) + self.generate_from_frequencies(words) + return self + + def process_text(self, text): + """Tokenize *text* and return ``{word: count}`` after filtering. + + Applies regexp splitting, stopword removal, number/length filters, + plural normalization, and optional bigram collocation detection. + """ + min_len = self.min_word_length + pattern = r"\w[\w']+" if min_len <= 1 else r"\w[\w']+" + regexp = self.regexp if self.regexp is not None else pattern + + words = re.findall(regexp, text) + # Strip possessive 's + words = [w[:-2] if w.lower().endswith("'s") else w for w in words] + if not self.include_numbers: + words = [w for w in words if not w.isdigit()] + if self.min_word_length: + words = [w for w in words if len(w) >= self.min_word_length] + + stopwords_lower = {s.lower() for s in self.stopwords} + + if self.collocations: + word_counts = unigrams_and_bigrams( + words, stopwords_lower, + normalize_plurals=self.normalize_plurals, + collocation_threshold=self.collocation_threshold, + ) + else: + words = [w for w in words if w.lower() not in stopwords_lower] + word_counts, _ = process_tokens(words, self.normalize_plurals) + + self.words_ = word_counts + return word_counts + + def to_image(self): + """Render the layout to a PIL Image, respecting *scale* and *contour*.""" + s = self.scale + out_w = int(self.width * s) + out_h = int(self.height * s) + img = Image.new(self.mode, (out_w, out_h), self.background_color) + draw = ImageDraw.Draw(img) + + for word, size, (y, x), orient, color in self.layout_: + try: + font = ImageFont.truetype(self.font_path, int(size * s)) + except Exception: + font = ImageFont.load_default() + + transposed_font = ImageFont.TransposedFont(font, orientation=orient) + draw.text((int(x * s), int(y * s)), word, font=transposed_font, fill=color) + + return self._draw_contour(img) + + def _draw_contour(self, img): + """Draw mask contour on *img* if contour_width > 0.""" + if self.mask is None or self.contour_width == 0: + return img + + # Build boolean mask: True where drawing area (not blocked) + if self.mask.ndim == 3: + blocked = np.all(self.mask[:, :, :3] == 255, axis=-1) + else: + blocked = self.mask == 255 + mask_uint8 = (~blocked).astype(np.uint8) * 255 + + contour = Image.fromarray(mask_uint8) + contour = contour.resize(img.size) + contour = contour.filter(ImageFilter.FIND_EDGES) + contour_arr = np.array(contour) + + # Zero out border pixels so edges aren't drawn at image boundary + contour_arr[[0, -1], :] = 0 + contour_arr[:, [0, -1]] = 0 + + # Gaussian blur controls perceived width (divide by 10 for sub-pixel) + radius = self.contour_width / 10 + contour = Image.fromarray(contour_arr) + contour = contour.filter(ImageFilter.GaussianBlur(radius=radius)) + contour_arr = np.array(contour) > 0 + contour_3d = np.dstack([contour_arr] * 3) + + result = np.array(img.convert("RGB")) * ~contour_3d + if self.contour_color != "black": + color_img = Image.new("RGB", img.size, self.contour_color) + result = result + np.array(color_img) * contour_3d + + out = Image.fromarray(result.astype(np.uint8)) + if self.mode == "RGBA": + out = out.convert("RGBA") + return out + + def to_array(self, copy=None): + """Return the word cloud as a numpy ndarray (H x W x channels).""" + image = self.to_image() + if copy is None: + return np.asarray(image) + try: + return np.asarray(image, copy=copy) + except TypeError: + return np.asarray(image) + + def __array__(self, copy=None): + return self.to_array(copy=copy) + + def to_file(self, filename): + """Save to *filename* and return self (for chaining).""" + img = self.to_image() + img.save(filename, optimize=True) + return self + + def recolor(self, random_state=None, color_func=None, colormap=None): + """Re-apply colors to the current layout without regenerating it. + + Parameters + ---------- + random_state : int, Random, or None + color_func : callable or None + colormap : str or matplotlib colormap or None + """ + if isinstance(random_state, int): + random_state = Random(random_state) + elif random_state is None: + random_state = Random() + + if color_func is None: + if colormap is not None: + color_func = colormap_color_func(colormap) + else: + color_func = self.color_func + + self.layout_ = [ + (word, font_size, position, orientation, + color_func(word=word, font_size=font_size, position=position, + orientation=orientation, font_path=self.font_path, + random_state=random_state)) + for word, font_size, position, orientation, _ in self.layout_ + ] + return self + + def to_svg(self, filename=None): + """Export as SVG with scale, correct rotation transforms and XML escaping. + + Parameters + ---------- + filename : str or None + If given, write to this file. Otherwise return the SVG string. + """ + from xml.sax import saxutils + s = self.scale + out_w = int(self.width * s) + out_h = int(self.height * s) + + # Derive font metadata from the actual font file + try: + _font_probe = ImageFont.truetype(self.font_path, 12) + raw_family, raw_style = _font_probe.getname() + except Exception: + raw_family, raw_style = "sans-serif", "Regular" + + raw_style_lower = raw_style.lower() + font_weight = "bold" if "bold" in raw_style_lower else "normal" + if "italic" in raw_style_lower: + font_style = "italic" + elif "oblique" in raw_style_lower: + font_style = "oblique" + else: + font_style = "normal" + font_family = repr(raw_family) + + lines = [ + f'', + f'', + ] + if self.background_color is not None: + lines.append( + f'' + ) + + for word, size, (y, x), orient, color in self.layout_: + scaled_size = int(size * s) + try: + font = ImageFont.truetype(self.font_path, scaled_size) + except Exception: + font = ImageFont.load_default() + + (size_x, size_y), (offset_x, offset_y) = font.font.getsize(word) + ascent, _ = font.getmetrics() + min_x = -offset_x + max_x = size_x - offset_x + max_y = ascent - offset_y + + sx = int(x * s) + sy = int(y * s) + + if orient == Image.ROTATE_90: + tx = sx + max_y + ty = sy + max_x - min_x + transform = f"translate({tx},{ty}) rotate(-90)" + else: + tx = sx + min_x + ty = sy + max_y + transform = f"translate({tx},{ty})" + + lines.append( + f'{saxutils.escape(word)}' + ) + + lines.append("") + svg_str = "\n".join(lines) + + if filename is not None: + with open(filename, "w", encoding="utf-8") as f: + f.write(svg_str) + return svg_str diff --git a/backend/EfficientWordCloud/setup.py b/backend/EfficientWordCloud/setup.py new file mode 100644 index 0000000..00c1bf8 --- /dev/null +++ b/backend/EfficientWordCloud/setup.py @@ -0,0 +1,28 @@ +from setuptools import setup, Extension, find_packages + +# Define C++ extension +ewc_core = Extension( + 'efficient_wordcloud.ewc_core', + sources=['efficient_wordcloud/src/ewc_core.cpp'], + extra_compile_args=['-O3', '-std=c++17'], + language='c++', +) + +setup( + name='efficient_wordcloud', + version='1.0.0', + description='A high-performance Word Cloud generator using Spiral Search and Integral Images.', + author='Antigravity', + packages=find_packages(), + ext_modules=[ewc_core], + install_requires=[ + 'numpy>=1.19.0', + 'pillow>=8.0.0', + 'matplotlib>=3.3.0' + ], + extras_require={ + # Main script (`wordcloud_generate_hybrid.py`) reads Excel via pandas. + 'script': ['pandas>=1.3.0'], + }, + python_requires='>=3.7', +) diff --git a/backend/LICENSE b/backend/LICENSE new file mode 100644 index 0000000..81e2d59 --- /dev/null +++ b/backend/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 EfficientWordCloud contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..a41ebd4 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,21 @@ +# EfficientWordCloud Backend + +Backend documentation has been consolidated under the project-level `docs/` directory. + +Start here: + +- [../docs/README.md](../docs/README.md) +- [../docs/PROJECT_STANDARD.md](../docs/PROJECT_STANDARD.md) +- [../docs/ALGORITHM.md](../docs/ALGORITHM.md) +- [../docs/CONFIG.md](../docs/CONFIG.md) +- [../docs/API.md](../docs/API.md) + +The source of truth is the current code: + +- `backend/core/config.py` +- `backend/core/pipeline.py` +- `backend/core/layout.py` +- `backend/core/weights.py` +- `backend/service/app.py` +- `backend/service/schemas.py` +- `backend/EfficientWordCloud/efficient_wordcloud/src/ewc_core.cpp` diff --git a/backend/README_zh.md b/backend/README_zh.md new file mode 100644 index 0000000..6df9439 --- /dev/null +++ b/backend/README_zh.md @@ -0,0 +1,21 @@ +# EfficientWordCloud 后端说明 + +后端文档已统一迁移到项目根目录的 `docs/`。 + +请从这里开始: + +- [../docs/README.md](../docs/README.md) +- [../docs/PROJECT_STANDARD.md](../docs/PROJECT_STANDARD.md) +- [../docs/ALGORITHM.md](../docs/ALGORITHM.md) +- [../docs/CONFIG.md](../docs/CONFIG.md) +- [../docs/API.md](../docs/API.md) + +源码事实来源: + +- `backend/core/config.py` +- `backend/core/pipeline.py` +- `backend/core/layout.py` +- `backend/core/weights.py` +- `backend/service/app.py` +- `backend/service/schemas.py` +- `backend/EfficientWordCloud/efficient_wordcloud/src/ewc_core.cpp` diff --git a/backend/assets/fonts/STHeiti Medium.ttc b/backend/assets/fonts/STHeiti Medium.ttc new file mode 100644 index 0000000..24df082 Binary files /dev/null and b/backend/assets/fonts/STHeiti Medium.ttc differ diff --git a/backend/core/__init__.py b/backend/core/__init__.py new file mode 100644 index 0000000..b870977 --- /dev/null +++ b/backend/core/__init__.py @@ -0,0 +1 @@ +"""Core pipeline modules for the wordcloud generator.""" diff --git a/backend/core/config.py b/backend/core/config.py new file mode 100644 index 0000000..2b9f5e0 --- /dev/null +++ b/backend/core/config.py @@ -0,0 +1,396 @@ +import argparse +import json +import logging +import os +import random +import sys +from pathlib import Path + +import numpy as np +from PIL import ImageFont + +from . import paths + +BASE_DIR = paths.BASE_DIR +RUNTIME_DIR = paths.RUNTIME_DIR +ASSETS_DIR = paths.ASSETS_DIR +FONTS_DIR = paths.FONTS_DIR +PROJECT_DEFAULT_FONT = paths.PROJECT_DEFAULT_FONT + +# 配置日志:只写入文件,不干扰控制台输出 +logging.basicConfig( + filename=str(RUNTIME_DIR / "ewc_concurrency.log"), + level=logging.DEBUG, + format='%(asctime)s - %(levelname)s - %(message)s', + filemode='w' +) + +# ==================== 0. 配置区(默认值) ==================== +MODE = "IMAGE" + +# --- Image Mode --- +MASK_IMAGE_PATH = "7887.png" +IMAGE_CANVAS_MODE = "WIDTH" +EXPAND_FOR_SPIRAL = True # 放大画布使螺旋填充覆盖边角 +EXPAND_RATIO = 2.5 # 更大倍率确保覆盖边缘 +FILL_CORNERS = False +CORNER_FILL_RATIO = 0.15 + +# --- Text Mode --- +MASK_TEXT = "A" +MASK_FONT_PATH = str(PROJECT_DEFAULT_FONT) +MASK_FONT_SIZE = 3000 + +# --- 自动画幅与清晰度 --- +AUTO_EXPAND_CANVAS = True +BASE_HD_WIDTH = 8000 +BASE_HD_HEIGHT = 4000 +MIN_READABLE_HEIGHT_PX = 25 +WORK_SCALE = 0.25 + +# --- 阴阳刻 --- +FILL_ON = "BLACK" + +# --- 数据与字体 --- +EXCEL_PATH = "四个方向汇总录取名单.xlsx" +DATA_COL_INDEX = 1 +WEIGHT_COL_INDEX = None +WEIGHT_COL_NAME = None +REMOVE_DUPLICATES = False +ENABLE_STROKE_WEIGHTS = True + +WC_FONT_PATH = str(PROJECT_DEFAULT_FONT) +FONT_FALLBACK_PATHS = ( + "/System/Library/Fonts/STHeiti Medium.ttc", + "/System/Library/Fonts/Hiragino Sans GB.ttc", + "/Library/Fonts/Arial Unicode.ttf", +) + +# --- 填充策略 --- +N_REPETITIONS = 1 +TARGET_FILL_RATIO = 0.0 # 关闭填充率检测 +SIZE_RATIO = 2.0 +PACKING_EFFICIENCY = 0.85 + +# --- 分层采样(边缘覆盖) --- +ENABLE_STRATIFIED_SAMPLING = True +STRATIFIED_BANDS = 3 # Mix Center, Middle, and Edge + +# --- 填充率补偿(低填充时略增字号) --- +GROW_FONT_ON_LOW_FILL = False # 关闭 +GROW_FONT_STEP = 1.05 + +# --- 填充率检测 --- +MIN_ACCEPT_FILL_RATIO = 0.75 +FILL_RETRY_RELAX_LARGE_CAP = True +FILL_RETRY_MAX_ROUNDS = 3 +FILL_RETRY_MAX_SCALE = 1.5 + +# --- 智能字号搜索 --- +REQUIRE_ALL_WORDS = True +MIN_FONT_SIZE = int(MIN_READABLE_HEIGHT_PX * WORK_SCALE) +USER_MIN_FONT_SIZE = None +USER_MAX_FONT_SIZE = None +MIN_FONT_FLOOR = 2 +FONT_SCALE_MIN = 0.5 +FONT_SCALE_MAX = 1.2 +SCALE_SEARCH_STEPS = 7 +SCALE_SEARCH_ROUNDS = 5 +SCALE_DECAY = 0.85 +SCALE_FLOOR = 0.25 +AUTO_SHRINK_ROUNDS = 4 +LOG_WEIGHT_RATIO = 0.72 +RANK_WEIGHT_RATIO = 0.28 + +# --- 大字号智能降级 --- +# 开启后,如果填不满,会自动尝试减少大字号的数量,给小词腾空间 +ENABLE_SMART_LARGE_FONT_REDUCTION = True +LIMIT_LARGE_FONTS = True +LARGE_FONT_LIMIT_RATIO = 0.2 # 初始允许 20% 的词是大字 +LARGE_FONT_THRESHOLD_RATIO = 0.8 # 超过最大字号 80% 算大字 +LARGE_FONT_CAP_RATIO = 0.6 # 被限制时,缩小到阈值的 60% + +# --- 点阵补偿 --- +ENABLE_DOT_MATRIX = False +DOT_SPACING = 15 +DOT_RADIUS = 0 +DOT_SAFETY_BUFFER = 12 + +# --- 画布重试 --- +CANVAS_RETRY_MAX_ROUNDS = 1 +CANVAS_RETRY_GROWTH = 1.12 + +# --- 配色 --- +DARK_COLOR_PALETTE = ( + "#102A43", + "#1F4E5F", + "#206A5D", + "#7B341E", + "#5D1F45", +) +LIGHT_COLOR_PALETTE = ( + "#EAF2FF", + "#CDECF6", + "#CFF7E6", + "#FFD8C2", + "#F6D1EB", +) +FONT_COLOR = "#000000" # 统一字体颜色,None 则使用调色板 + +# --- 输出 --- +MAX_ATTEMPTS = 5 +OUTPUT_DIR = "." +OUTPUT_PREFIX = "" +OUTPUT_PNG = "Efficient_Result_HD_AutoResize.png" +OUTPUT_SVG = "Efficient_Result_HD_AutoResize.svg" +DB_PATH = "wordcloud_hd.db" +METRICS_FILE = "metrics.json" +SAVE_DEBUG_IMAGES = True +DEBUG_OUTPUT_DIR = "output" + +# --- 可复现性 --- +SEED = None +LAYOUT_ORDER_MODE_SORTED = "SORTED" +LAYOUT_ORDER_MODE_INTERLEAVED_RANDOM = "INTERLEAVED_RANDOM" +VALID_LAYOUT_ORDER_MODES = ( + LAYOUT_ORDER_MODE_SORTED, + LAYOUT_ORDER_MODE_INTERLEAVED_RANDOM, +) +LAYOUT_ORDER_MODE = LAYOUT_ORDER_MODE_SORTED +LAYOUT_SEED = None + +KNOWN_CONFIG_KEYS = { + 'MODE', 'MASK_IMAGE_PATH', 'IMAGE_CANVAS_MODE', 'EXPAND_FOR_SPIRAL', 'EXPAND_RATIO', 'FILL_CORNERS', + 'CORNER_FILL_RATIO', 'MASK_TEXT', 'MASK_FONT_PATH', 'MASK_FONT_SIZE', 'AUTO_EXPAND_CANVAS', + 'BASE_HD_WIDTH', 'BASE_HD_HEIGHT', 'MIN_READABLE_HEIGHT_PX', 'WORK_SCALE', 'FILL_ON', 'EXCEL_PATH', + 'DATA_COL_INDEX', 'WEIGHT_COL_INDEX', 'WEIGHT_COL_NAME', 'REMOVE_DUPLICATES', 'ENABLE_STROKE_WEIGHTS', + 'WC_FONT_PATH', + 'FONT_FALLBACK_PATHS', + 'N_REPETITIONS', 'TARGET_FILL_RATIO', 'SIZE_RATIO', 'PACKING_EFFICIENCY', 'ENABLE_STRATIFIED_SAMPLING', + 'STRATIFIED_BANDS', 'GROW_FONT_ON_LOW_FILL', 'GROW_FONT_STEP', 'MIN_ACCEPT_FILL_RATIO', + 'FILL_RETRY_RELAX_LARGE_CAP', 'FILL_RETRY_MAX_ROUNDS', 'FILL_RETRY_MAX_SCALE', 'REQUIRE_ALL_WORDS', + 'MIN_FONT_SIZE', 'USER_MIN_FONT_SIZE', 'USER_MAX_FONT_SIZE', 'MIN_FONT_FLOOR', 'FONT_SCALE_MIN', + 'FONT_SCALE_MAX', 'SCALE_SEARCH_STEPS', 'SCALE_SEARCH_ROUNDS', 'SCALE_DECAY', 'SCALE_FLOOR', + 'LOG_WEIGHT_RATIO', 'RANK_WEIGHT_RATIO', + 'AUTO_SHRINK_ROUNDS', 'ENABLE_SMART_LARGE_FONT_REDUCTION', 'LIMIT_LARGE_FONTS', + 'LARGE_FONT_LIMIT_RATIO', 'LARGE_FONT_THRESHOLD_RATIO', 'LARGE_FONT_CAP_RATIO', 'ENABLE_DOT_MATRIX', + 'DOT_SPACING', 'DOT_RADIUS', 'DOT_SAFETY_BUFFER', 'CANVAS_RETRY_MAX_ROUNDS', 'CANVAS_RETRY_GROWTH', + 'DARK_COLOR_PALETTE', 'LIGHT_COLOR_PALETTE', 'FONT_COLOR', 'MAX_ATTEMPTS', 'OUTPUT_DIR', 'OUTPUT_PREFIX', + 'OUTPUT_PNG', 'OUTPUT_SVG', 'DB_PATH', 'METRICS_FILE', 'SAVE_DEBUG_IMAGES', 'DEBUG_OUTPUT_DIR', 'SEED', + 'LAYOUT_ORDER_MODE', 'LAYOUT_SEED' +} + +CONFIG_ALIASES = { + 'seed': 'SEED', + 'layout_order_mode': 'LAYOUT_ORDER_MODE', + 'layout_seed': 'LAYOUT_SEED', + 'excel_path': 'EXCEL_PATH', + 'mask_image_path': 'MASK_IMAGE_PATH', + 'output_dir': 'OUTPUT_DIR', + 'output_prefix': 'OUTPUT_PREFIX', + 'mode': 'MODE', + 'work_scale': 'WORK_SCALE', + 'weight_col_index': 'WEIGHT_COL_INDEX', + 'weight_col_name': 'WEIGHT_COL_NAME', + 'min_font_size': 'USER_MIN_FONT_SIZE', + 'max_font_size': 'USER_MAX_FONT_SIZE', + 'font_color': 'FONT_COLOR', + 'stroke_weights': 'ENABLE_STROKE_WEIGHTS', +} + +CRITICAL_TYPE_CHECKS = { + 'MODE': str, + 'WORK_SCALE': (int, float), + 'DATA_COL_INDEX': int, + 'WEIGHT_COL_INDEX': (int, type(None)), + 'WEIGHT_COL_NAME': (str, type(None)), + 'FONT_FALLBACK_PATHS': (list, tuple), + 'USER_MIN_FONT_SIZE': (int, float, type(None)), + 'USER_MAX_FONT_SIZE': (int, float, type(None)), + 'MAX_ATTEMPTS': int, + 'SAVE_DEBUG_IMAGES': bool, + 'REMOVE_DUPLICATES': bool, + 'ENABLE_STROKE_WEIGHTS': bool, + 'CANVAS_RETRY_MAX_ROUNDS': int, + 'CANVAS_RETRY_GROWTH': (int, float), + 'LOG_WEIGHT_RATIO': (int, float), + 'RANK_WEIGHT_RATIO': (int, float), + 'SEED': (int, type(None)), + 'LAYOUT_ORDER_MODE': str, + 'LAYOUT_SEED': (int, type(None)), +} + +DEFAULT_CONFIG = {k: v for k, v in globals().items() if k in KNOWN_CONFIG_KEYS} + + +def parse_args(): + parser = argparse.ArgumentParser(description="Efficient WordCloud generator") + parser.add_argument("--config", type=str, help="JSON 配置文件路径") + parser.add_argument("--seed", type=int, help="随机种子(可复现)") + parser.add_argument("--layout-order-mode", type=lambda s: s.upper(), choices=VALID_LAYOUT_ORDER_MODES, help="布局顺序模式") + parser.add_argument("--layout-seed", type=int, help="布局顺序随机种子") + parser.add_argument("--excel-path", type=str, help="Excel 输入路径") + parser.add_argument("--mask-image-path", type=str, help="掩膜图片路径(IMAGE 模式)") + parser.add_argument("--output-dir", type=str, help="输出目录") + parser.add_argument("--output-prefix", type=str, help="输出文件前缀") + parser.add_argument("--mode", type=str, choices=["TEXT", "IMAGE"], help="掩膜模式") + parser.add_argument("--work-scale", type=float, help="运算缩放比例") + parser.add_argument("--weight-col-index", type=int, help="Excel 权重列索引") + parser.add_argument("--weight-col-name", type=str, help="Excel 权重列名(优先于索引)") + parser.add_argument("--min-font-size", type=float, help="覆盖最小字号") + parser.add_argument("--max-font-size", type=float, help="覆盖最大字号") + return parser.parse_args() + + +def _warn(msg): + print(f"[WARN] {msg}") + + +def _resolve_path(path_str): + p = Path(path_str) + if p.is_absolute(): + return p + return BASE_DIR / p + + +def _with_prefix(filename, prefix): + if not prefix: + return filename + return f"{prefix}_{filename}" + + +def apply_json_config(config_path): + cfg_path = _resolve_path(config_path) + if not cfg_path.exists(): + print(f"错误: 配置文件不存在: {cfg_path}") + sys.exit(1) + + try: + with cfg_path.open("r", encoding="utf-8") as f: + data = json.load(f) + except Exception as e: + print(f"错误: 读取配置文件失败: {e}") + sys.exit(1) + + if not isinstance(data, dict): + print("错误: 配置文件顶层必须是 JSON 对象") + sys.exit(1) + + normalized_data = {} + for key, value in data.items(): + key_upper = CONFIG_ALIASES.get(key, key) + normalized_data[key_upper] = value + + for key in normalized_data.keys(): + if key not in KNOWN_CONFIG_KEYS: + _warn(f"未知配置键: {key}") + + for key, expected in CRITICAL_TYPE_CHECKS.items(): + if key in normalized_data and not isinstance(normalized_data[key], expected): + print(f"错误: 配置键 {key} 类型错误,期望 {expected},实际 {type(normalized_data[key])}") + sys.exit(1) + + for key, value in normalized_data.items(): + if key in KNOWN_CONFIG_KEYS: + globals()[key] = value + + +def apply_cli_overrides(args): + mapping = { + 'seed': 'SEED', + 'layout_order_mode': 'LAYOUT_ORDER_MODE', + 'layout_seed': 'LAYOUT_SEED', + 'excel_path': 'EXCEL_PATH', + 'mask_image_path': 'MASK_IMAGE_PATH', + 'output_dir': 'OUTPUT_DIR', + 'output_prefix': 'OUTPUT_PREFIX', + 'mode': 'MODE', + 'work_scale': 'WORK_SCALE', + 'weight_col_index': 'WEIGHT_COL_INDEX', + 'weight_col_name': 'WEIGHT_COL_NAME', + 'min_font_size': 'USER_MIN_FONT_SIZE', + 'max_font_size': 'USER_MAX_FONT_SIZE', + } + for arg_key, cfg_key in mapping.items(): + value = getattr(args, arg_key) + if value is not None: + globals()[cfg_key] = value + + +def _resolve_font_path(configured_path, fallback_paths, *, role): + candidates = [_resolve_path(configured_path), *[Path(path) for path in fallback_paths]] + errors = [] + + for index, candidate in enumerate(candidates): + if not candidate.exists(): + errors.append(f"{candidate}: missing") + continue + try: + ImageFont.truetype(str(candidate), 32) + if index == 0: + print(f"[Font] {role} 使用项目字体: {candidate}") + else: + _warn(f"{role} 字体未命中项目内资源,回退到系统字体: {candidate}") + return str(candidate) + except OSError as exc: + errors.append(f"{candidate}: {exc}") + + print(f"错误: {role} 字体初始化失败。候选路径: {errors}") + sys.exit(1) + + +def finalize_runtime_config(): + global EXCEL_PATH, MASK_IMAGE_PATH, MASK_FONT_PATH, WC_FONT_PATH + global OUTPUT_DIR, OUTPUT_PNG, OUTPUT_SVG, DB_PATH, METRICS_FILE, DEBUG_OUTPUT_DIR, MIN_FONT_SIZE + global LAYOUT_ORDER_MODE, LAYOUT_SEED + + # 运行时派生字段 + MIN_FONT_SIZE = int(MIN_READABLE_HEIGHT_PX * WORK_SCALE) + + if LAYOUT_SEED is None: + LAYOUT_SEED = SEED + + LAYOUT_ORDER_MODE = str(LAYOUT_ORDER_MODE).upper() + if LAYOUT_ORDER_MODE not in VALID_LAYOUT_ORDER_MODES: + print(f"错误: 不支持的 LAYOUT_ORDER_MODE: {LAYOUT_ORDER_MODE}") + sys.exit(1) + + EXCEL_PATH = str(_resolve_path(EXCEL_PATH)) + MASK_IMAGE_PATH = str(_resolve_path(MASK_IMAGE_PATH)) + + MASK_FONT_PATH = _resolve_font_path(MASK_FONT_PATH, FONT_FALLBACK_PATHS, role="mask") + WC_FONT_PATH = _resolve_font_path(WC_FONT_PATH, FONT_FALLBACK_PATHS, role="layout") + + OUTPUT_DIR = str(_resolve_path(OUTPUT_DIR)) + Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True) + + OUTPUT_PNG = str(Path(OUTPUT_DIR) / _with_prefix(Path(OUTPUT_PNG).name, OUTPUT_PREFIX)) + OUTPUT_SVG = str(Path(OUTPUT_DIR) / _with_prefix(Path(OUTPUT_SVG).name, OUTPUT_PREFIX)) + DB_PATH = str(Path(OUTPUT_DIR) / _with_prefix(Path(DB_PATH).name, OUTPUT_PREFIX)) + METRICS_FILE = str(Path(OUTPUT_DIR) / _with_prefix(Path(METRICS_FILE).name, OUTPUT_PREFIX)) + DEBUG_OUTPUT_DIR = str(Path(OUTPUT_DIR) / Path(DEBUG_OUTPUT_DIR).name) + + +def set_random_seed(): + if SEED is None: + return + np.random.seed(SEED) + random.seed(SEED) + print(f"[Seed] 使用固定随机种子: {SEED}") + + +def get_output_background(): + return "black" if FILL_ON == "WHITE" else "white" + + +def get_output_palette(): + return LIGHT_COLOR_PALETTE if FILL_ON == "WHITE" else DARK_COLOR_PALETTE + + +def write_metrics(metrics): + try: + with Path(METRICS_FILE).open("w", encoding="utf-8") as f: + json.dump(metrics, f, ensure_ascii=False, indent=2) + print(f"已保存: {METRICS_FILE}") + except Exception as e: + _warn(f"写入 metrics 失败(不影响主产物): {e}") diff --git a/backend/core/ewc.py b/backend/core/ewc.py new file mode 100644 index 0000000..cb0415c --- /dev/null +++ b/backend/core/ewc.py @@ -0,0 +1,15 @@ +import sys + +from .paths import BASE_DIR + +lib_path = str(BASE_DIR / "EfficientWordCloud") +if lib_path not in sys.path: + sys.path.insert(0, lib_path) + +try: + from efficient_wordcloud import EfficientWordCloud +except ImportError: + print("错误: 找不到 EfficientWordCloud 库。请确保已编译并安装该库。") + sys.exit(1) + +__all__ = ["EfficientWordCloud"] diff --git a/backend/core/fonts.py b/backend/core/fonts.py new file mode 100644 index 0000000..40ebbe4 --- /dev/null +++ b/backend/core/fonts.py @@ -0,0 +1,25 @@ +from matplotlib.font_manager import FontProperties +from PIL import ImageFont + +from . import config + +_global_font_cache = {} +_font_properties_cache = {} + + +def get_cached_font(font_path, size): + key = (font_path, size) + if key not in _global_font_cache: + try: + _global_font_cache[key] = ImageFont.truetype(font_path, size) + except IOError as e: + config._warn(f"字体加载失败,回退默认字体: path={font_path}, size={size}, error={e}") + _global_font_cache[key] = ImageFont.load_default() + return _global_font_cache[key] + + +def get_font_properties(font_path, size): + key = (font_path, size) + if key not in _font_properties_cache: + _font_properties_cache[key] = FontProperties(fname=font_path, size=size) + return _font_properties_cache[key] diff --git a/backend/core/layout.py b/backend/core/layout.py new file mode 100644 index 0000000..0fa40e4 --- /dev/null +++ b/backend/core/layout.py @@ -0,0 +1,560 @@ +import math +import random + +import numpy as np +from PIL import Image, ImageDraw, ImageFont +from matplotlib.path import Path as MplPath +from matplotlib.textpath import TextPath +from matplotlib.transforms import Affine2D + +from . import config +from .ewc import EfficientWordCloud +from .fonts import get_cached_font, get_font_properties + + +def normalize_relative_scores(values): + if not values: + return [] + v_min = min(values) + v_max = max(values) + if math.isclose(v_min, v_max): + return [1.0 for _ in values] + scale = v_max - v_min + return [(value - v_min) / scale for value in values] + + +def build_log_rank_scores(freq_list, *, per_word=False): + if not freq_list: + return [] + + if per_word: + word_weights = {} + for word, freq in freq_list: + f = max(float(freq), 1e-6) + if word not in word_weights or f > word_weights[word]: + word_weights[word] = f + unique_weights = sorted(set(word_weights.values()), reverse=True) + if len(unique_weights) <= 1: + word_scores = {w: 1.0 for w in word_weights} + else: + log_vals = [math.log1p(w) for w in unique_weights] + normed = normalize_relative_scores(log_vals) + weight_to_score = dict(zip(unique_weights, normed)) + word_scores = {w: weight_to_score[weight] for w, weight in word_weights.items()} + return [word_scores.get(w, 1.0) for w, _ in freq_list] + + safe_freqs = [max(float(freq), 1e-6) for _word, freq in freq_list] + log_scores = normalize_relative_scores([math.log1p(freq) for freq in safe_freqs]) + rank_scores = [1.0 - (idx / max(1, len(freq_list) - 1)) for idx in range(len(freq_list))] + + total_ratio = config.LOG_WEIGHT_RATIO + config.RANK_WEIGHT_RATIO + if total_ratio <= 0: + return log_scores + + log_ratio = config.LOG_WEIGHT_RATIO / total_ratio + rank_ratio = config.RANK_WEIGHT_RATIO / total_ratio + return [ + max(0.0, min(1.0, log_score * log_ratio + rank_score * rank_ratio)) + for log_score, rank_score in zip(log_scores, rank_scores) + ] + + +def pick_palette_color(relative_score): + if config.FONT_COLOR: + return config.FONT_COLOR + palette = config.LIGHT_COLOR_PALETTE if config.FILL_ON == "WHITE" else config.DARK_COLOR_PALETTE + if not palette: + return "#111111" + idx = min(len(palette) - 1, max(0, int(round((1.0 - relative_score) * (len(palette) - 1))))) + return palette[idx] + + +def _build_layout_sequence(sorted_freq, max_words, layout_order_mode, layout_seed): + if max_words <= 0 or not sorted_freq: + return [] + + expanded_freq = list(sorted_freq) + if len(expanded_freq) < max_words: + base_words = expanded_freq[:] + if not base_words: + return [] + while len(expanded_freq) < max_words: + for item in base_words: + if len(expanded_freq) >= max_words: + break + expanded_freq.append(item) + + expanded_freq = expanded_freq[:max_words] + if layout_order_mode == config.LAYOUT_ORDER_MODE_SORTED or len(expanded_freq) <= 1: + return expanded_freq + + band_count = min(3, len(expanded_freq)) + band_size = math.ceil(len(expanded_freq) / band_count) + bands = [] + rng = random.Random(layout_seed) + for band_idx in range(band_count): + start = band_idx * band_size + end = min(len(expanded_freq), start + band_size) + band = expanded_freq[start:end] + rng.shuffle(band) + if band: + bands.append(band) + + interleave_pattern = [0, 1, 0, 2] + band_positions = [0] * len(bands) + sequence = [] + + while len(sequence) < len(expanded_freq): + appended = False + for pattern_idx in interleave_pattern: + if pattern_idx >= len(bands): + continue + pos = band_positions[pattern_idx] + if pos >= len(bands[pattern_idx]): + continue + sequence.append(bands[pattern_idx][pos]) + band_positions[pattern_idx] += 1 + appended = True + if len(sequence) >= len(expanded_freq): + break + if appended: + continue + for band_idx, band in enumerate(bands): + pos = band_positions[band_idx] + if pos < len(band): + sequence.append(band[pos]) + band_positions[band_idx] += 1 + appended = True + if len(sequence) >= len(expanded_freq): + break + if not appended: + break + + return sequence + + +class OptimizedEfficientWordCloud(EfficientWordCloud): + def __init__(self, *args, large_font_ratio=config.LARGE_FONT_LIMIT_RATIO, size_scale=1.0, **kwargs): + super().__init__(*args, **kwargs) + self.large_font_ratio = large_font_ratio + self.size_scale = size_scale + + def generate_from_frequencies(self, frequencies): + if isinstance(frequencies, dict): + freq_list = list(frequencies.items()) + elif isinstance(frequencies, list): + freq_list = frequencies + else: + raise ValueError("frequencies 必须是字典或 (word, freq) 列表") + + sorted_freq = sorted(freq_list, key=lambda x: x[1], reverse=True) + layout_sequence = _build_layout_sequence( + sorted_freq, + self.max_words, + config.LAYOUT_ORDER_MODE, + config.LAYOUT_SEED, + ) + + if not layout_sequence: + return self + + self.layout_ = [] + per_word_scores = build_log_rank_scores(freq_list, per_word=True) + word_to_score = {} + for (w, _f), s in zip(freq_list, per_word_scores): + if w not in word_to_score or s > word_to_score[w]: + word_to_score[w] = s + score_by_index = [word_to_score.get(w, 1.0) for w, _ in layout_sequence] + effective_max_font = max(self.min_font_size + 1, int(self.max_font_size * self.size_scale)) + large_threshold = int(effective_max_font * config.LARGE_FONT_THRESHOLD_RATIO) if config.LIMIT_LARGE_FONTS else None + large_limit = int(self.max_words * self.large_font_ratio) if config.LIMIT_LARGE_FONTS else None + large_count = 0 + + rotation_flags = [np.random.random() > self.prefer_horizontal for _ in layout_sequence] + + # Dummy draw for textbbox measurement (no actual PIL image needed during placement) + _measure_img = Image.new("L", (1, 1)) + _measure_draw = ImageDraw.Draw(_measure_img) + + base_span = max(1, self.max_font_size - self.min_font_size) + target_font_sizes = [] + for score in score_by_index: + raw_size = self.min_font_size + base_span * score + f_size = max(config.MIN_FONT_FLOOR, int(round(raw_size * self.size_scale))) + target_font_sizes.append(f_size) + + gap_fill_list = [] # 收集未成功放置的词,用于第二轮填充 + + for idx, (word, _freq) in enumerate(layout_sequence): + font_size = target_font_sizes[idx] + if config.LIMIT_LARGE_FONTS and large_threshold is not None and large_limit is not None: + if font_size >= large_threshold and large_count >= large_limit: + font_size = max(self.min_font_size, int(large_threshold * config.LARGE_FONT_CAP_RATIO)) + + current_size = font_size + min_attempt_size = max(self.min_font_size, int(current_size * 0.4)) + placed = False + + while current_size >= min_attempt_size: + orientation = None + rotate = rotation_flags[idx] + if rotate: + orientation = Image.ROTATE_90 + + font = get_cached_font(self.font_path, current_size) + if orientation: + transposed = ImageFont.TransposedFont(font, orientation=orientation) + else: + transposed = font + bbox = _measure_draw.textbbox((0, 0), word, font=transposed) + w_text = bbox[2] - bbox[0] + h_text = bbox[3] - bbox[1] + + query_w = w_text + self.margin + query_h = h_text + self.margin + + pos = self.grid.query_direct(query_h, query_w, np.random.randint(0, 2**31)) + + if pos is not None: + y, x = pos + draw_y = y + self.margin // 2 + draw_x = x + self.margin // 2 + + # Stamp glyph bitmap into C++ canvas for pixel-accurate collision + font = get_cached_font(self.font_path, current_size) + if orientation: + transposed = ImageFont.TransposedFont(font, orientation=orientation) + else: + transposed = font + glyph_mask = transposed.getmask(word, mode="L") + gw, gh = glyph_mask.size + glyph_arr = np.frombuffer(bytes(glyph_mask), dtype=np.uint8).reshape(gh, gw) + self.grid.stamp_and_rebuild(glyph_arr, gh, gw, draw_y, draw_x) + + color = pick_palette_color(score_by_index[idx]) + self.layout_.append((word, current_size, (draw_y, draw_x), orientation, color)) + + if config.LIMIT_LARGE_FONTS and large_threshold is not None and current_size >= large_threshold: + large_count += 1 + placed = True + break + + current_size -= 2 + + if not placed: + gap_fill_list.append((word, score_by_index[idx])) + + # ── Gap-filling pass: 用更小的字号填充剩余空隙 ────────────── + if gap_fill_list: + gap_font_size = max(config.MIN_FONT_FLOOR, int(self.min_font_size * 0.8)) + if gap_font_size >= config.MIN_FONT_FLOOR: + placed_gap = 0 + for word, score in gap_fill_list: + font = get_cached_font(self.font_path, gap_font_size) + bbox = _measure_draw.textbbox((0, 0), word, font=font) + w_text = bbox[2] - bbox[0] + h_text = bbox[3] - bbox[1] + query_w = w_text + self.margin + query_h = h_text + self.margin + + pos = self.grid.query_direct(query_h, query_w, np.random.randint(0, 2**31)) + if pos is not None: + y, x = pos + draw_y = y + self.margin // 2 + draw_x = x + self.margin // 2 + + glyph_mask = font.getmask(word, mode="L") + gw, gh = glyph_mask.size + glyph_arr = np.frombuffer(bytes(glyph_mask), dtype=np.uint8).reshape(gh, gw) + self.grid.stamp_and_rebuild(glyph_arr, gh, gw, draw_y, draw_x) + + color = pick_palette_color(score) + self.layout_.append((word, gap_font_size, (draw_y, draw_x), None, color)) + placed_gap += 1 + + if placed_gap > 0: + config._warn(f"Gap-filling: 用小字号 {gap_font_size} 额外放置了 {placed_gap}/{len(gap_fill_list)} 个词") + + return self + + def to_image(self): + img = Image.new(self.mode, (self.width, self.height), self.background_color) + draw = ImageDraw.Draw(img) + for word, size, (y, x), orient, color in self.layout_: + font = get_cached_font(self.font_path, size) + if orient: + font = ImageFont.TransposedFont(font, orientation=orient) + draw.text((x, y), word, font=font, fill=color) + return img + + def to_svg(self, filename): + background = self.background_color + with open(filename, "w", encoding="utf-8") as f: + f.write( + f'\n' + ) + f.write(f'\n') + + for word, size, (y, x), orient, color in self.layout_: + try: + path, tx, ty, _ = build_svg_text_path(word, size, x, y, self.font_path, orient) + except Exception as exc: + config._warn(f"SVG path 导出失败,跳过词条: {word}, error={exc}") + continue + f.write(f'\n') + + f.write("\n") + + def to_svg_stroke(self, filename, stroke_color="#000000", stroke_width=1.0): + """生成描边版 SVG,适合激光雕刻机使用(描边路径,无填充)。""" + with open(filename, "w", encoding="utf-8") as f: + f.write( + f'\n' + ) + f.write(f'\n') + + for word, size, (y, x), orient, _color in self.layout_: + try: + path, tx, ty, _ = build_svg_text_path(word, size, x, y, self.font_path, orient) + except Exception as exc: + config._warn(f"SVG stroke path 导出失败,跳过词条: {word}, error={exc}") + continue + f.write( + f'\n' + ) + + f.write("\n") + + def to_svg_dotfill(self, filename, dot_spacing=10, dot_radius=2, dot_color="#000000"): + """生成点阵填充 SVG:文字区域用密排小圆点填充,适合激光雕刻逐点打标。""" + from .render import render_layout_occupancy + + occ = render_layout_occupancy(self.layout_, (self.height, self.width), self.font_path) + occ_arr = np.array(occ) + + with open(filename, "w", encoding="utf-8") as f: + f.write( + f'\n' + ) + f.write(f'\n') + + half = dot_spacing / 2 + dot_count = 0 + h, w = occ_arr.shape + for gy in range(0, h, dot_spacing): + for gx in range(0, w, dot_spacing): + cy = min(gy + int(half), h - 1) + cx = min(gx + int(half), w - 1) + if occ_arr[cy, cx]: + f.write( + f'\n' + ) + dot_count += 1 + + f.write("\n") + return dot_count + + def to_svg_custom(self, filename, fill_mode="fill", do_stroke=False, + dot_spacing=10, dot_radius=2, color="#000000", + line_spacing=6, line_width=1, line_angle=0, + ring_radius=3, ring_width=1, ring_spacing=8): + """统一 SVG 导出:fill_mode=fill|dot|line|ring,可叠加描边。""" + # 预先构建所有文字路径(fill / dot 模式共用) + text_paths = [] + for word, size, (y, x), orient, _color in self.layout_: + try: + path, tx, ty, _ = build_svg_text_path(word, size, x, y, self.font_path, orient) + text_paths.append((path, tx, ty)) + except Exception as exc: + config._warn(f"SVG path 导出失败,跳过: {word}, error={exc}") + + with open(filename, "w", encoding="utf-8") as f: + f.write( + f'\n' + ) + f.write(f'\n') + + if fill_mode == "dot": + # 点阵模式:用 SVG pattern 平铺圆点 + clipPath 裁剪到文字形状 + f.write('\n') + f.write(f' \n') + half = dot_spacing / 2 + f.write(f' \n') + f.write(' \n') + self._write_text_clip(f, text_paths) + f.write('\n') + f.write(f'\n') + + elif fill_mode == "line": + # 线条填充:用 matplotlib Path 渲染占用蒙版(与 SVG 完全对齐) + import math as _m + occ = render_path_occupancy(self.layout_, (self.height, self.width), self.font_path) + angle = line_angle % 360 + rad = _m.radians(angle) + cos_a, sin_a = _m.cos(rad), _m.sin(rad) + h, w = occ.shape + step = 1 # 逐像素采样,保证线段连续 + # 垂直方向的总范围(确保覆盖整个画布) + perp_max = abs(h * cos_a) + abs(w * sin_a) + n_lines = max(1, int(perp_max / line_spacing) + 1) + sw = f'{line_width:g}' + path_parts = [] + for i in range(n_lines): + d0 = (i - n_lines // 2) * line_spacing + sx = -d0 * sin_a + sy = d0 * cos_a + n_steps = int(perp_max) + 1 + run_start = None + for s in range(n_steps + 1): + px = sx + s * step * cos_a + py = sy + s * step * sin_a + ix, iy = int(round(px)), int(round(py)) + inside = (0 <= iy < h and 0 <= ix < w and occ[iy, ix]) + if inside: + if run_start is None: + run_start = (px, py) + else: + if run_start is not None: + ex = px - step * cos_a + ey = py - step * sin_a + path_parts.append(f'M{run_start[0]:.1f} {run_start[1]:.1f}L{ex:.1f} {ey:.1f}') + run_start = None + if run_start is not None: + ex = sx + n_steps * step * cos_a + ey = sy + n_steps * step * sin_a + path_parts.append(f'M{run_start[0]:.1f} {run_start[1]:.1f}L{ex:.1f} {ey:.1f}') + if path_parts: + f.write(f'\n') + + elif fill_mode == "ring": + # 空心圆点填充:闭合路径,激光机可描一圈 + occ = render_path_occupancy(self.layout_, (self.height, self.width), self.font_path) + h, w = occ.shape + r = ring_radius + sw = f'{ring_width:g}' + circle_parts = [] + for gy in range(r, h - r, ring_spacing): + for gx in range(r, w - r, ring_spacing): + if not occ[gy, gx]: + continue + lx = gx - r + rx = gx + r + circle_parts.append( + f'M{lx} {gy}A{r} {r} 0 1 0 {rx} {gy}A{r} {r} 0 1 0 {lx} {gy}Z' + ) + if circle_parts: + f.write(f'\n') + + # 只有 fill 模式和显式描边时才输出 matplotlib 文字路径 + # ring/line 模式用 PIL occupancy mask 生成填充,不需要文字轮廓 + if fill_mode == "fill" or do_stroke: + for path, tx, ty in text_paths: + fill_attr = color if fill_mode == "fill" else "none" + stroke_attr = f'stroke="{color}" stroke-width="1" stroke-linejoin="round" stroke-linecap="round"' if do_stroke else "" + f.write(f'\n') + + f.write("\n") + + @staticmethod + def _write_text_clip(f, text_paths): + """将文字路径写入 (调用方负责 开闭)。""" + f.write(' \n') + for path, tx, ty in text_paths: + f.write(f' \n') + f.write(' \n') + + +def build_svg_text_path(word, size, x, y, font_path, orient): + path = TextPath((0, 0), word, prop=get_font_properties(font_path, size), size=size) + if orient: + path = path.transformed(Affine2D().rotate_deg(-90)) + bbox = path.get_extents() + tx = x - bbox.xmin + ty = y + bbox.ymax + # 返回变换后的 Path(已定位到画布坐标)以及 SVG 用的偏移量 + transformed = path.transformed(Affine2D().scale(1, -1).translate(tx, ty)) + return mpl_path_to_svg_d(path), tx, ty, transformed + + +def mpl_path_to_svg_d(path): + parts = [] + for vertices, code in path.iter_segments(): + if code == MplPath.MOVETO: + x, y = vertices + parts.append(f"M{x:.3f} {y:.3f}") + elif code == MplPath.LINETO: + x, y = vertices + parts.append(f"L{x:.3f} {y:.3f}") + elif code == MplPath.CURVE3: + x1, y1, x2, y2 = vertices + parts.append(f"Q{x1:.3f} {y1:.3f} {x2:.3f} {y2:.3f}") + elif code == MplPath.CURVE4: + x1, y1, x2, y2, x3, y3 = vertices + parts.append( + f"C{x1:.3f} {y1:.3f} {x2:.3f} {y2:.3f} {x3:.3f} {y3:.3f}" + ) + elif code == MplPath.CLOSEPOLY: + parts.append("Z") + return " ".join(parts) + + +def render_path_occupancy(layout_data, canvas_shape, font_path): + """渲染文字占用蒙版:字形笔画=1,字内空洞(如口)=0,外部=0。 + + 使用 PIL 渲染文字蒙版(与画布坐标完全对齐)+ 边界泛洪填充来区分外部区域与字内空洞。 + layout_data: [(word, size, (y, x), orient, color), ...] 同 self.layout_ + """ + from collections import deque + + h, w = canvas_shape + if not layout_data: + return np.zeros((h, w), dtype=np.uint8) + + # 用 PIL 渲染文字蒙版(坐标系与 to_image() 完全一致) + mask = Image.new("L", (w, h), 0) + draw = ImageDraw.Draw(mask) + for word, size, (y, x), orient, _color in layout_data: + font = get_cached_font(font_path, size) + if orient: + font = ImageFont.TransposedFont(font, orientation=orient) + draw.text((x, y), word, font=font, fill=255) + + occ_raw = (np.array(mask) > 127).astype(np.uint8) + + # 泛洪填充:从边框出发标记所有与外部连通的白色区域 + # 口 等闭合字符的内部空洞不会与边框连通,因此正确保留为空 + outside = np.zeros_like(occ_raw, dtype=np.uint8) + q = deque() + for x in range(w): + if occ_raw[0, x]: + q.append((0, x)) + outside[0, x] = 1 + if occ_raw[h - 1, x]: + q.append((h - 1, x)) + outside[h - 1, x] = 1 + for y in range(1, h - 1): + if occ_raw[y, 0]: + q.append((y, 0)) + outside[y, 0] = 1 + if occ_raw[y, w - 1]: + q.append((y, w - 1)) + outside[y, w - 1] = 1 + + while q: + cy, cx = q.popleft() + for dy, dx in ((-1, 0), (1, 0), (0, -1), (0, 1)): + ny, nx = cy + dy, cx + dx + if 0 <= ny < h and 0 <= nx < w and occ_raw[ny, nx] and not outside[ny, nx]: + outside[ny, nx] = 1 + q.append((ny, nx)) + + # 最终蒙版:文字笔画=1,外部和字内空洞=0 + return (occ_raw & (~outside).astype(np.uint8)).astype(np.uint8) + diff --git a/backend/core/mask.py b/backend/core/mask.py new file mode 100644 index 0000000..475443e --- /dev/null +++ b/backend/core/mask.py @@ -0,0 +1,145 @@ +import os +from pathlib import Path + +import numpy as np +from PIL import Image, ImageDraw + +from . import config +from .fonts import get_cached_font + + +def analyze_mask(mask): + free = mask == 0 + free_area = int(np.sum(free)) + total_area = int(mask.size) + free_ratio = (free_area / total_area) if total_area else 0.0 + + rows = np.where(np.any(free, axis=1))[0] + cols = np.where(np.any(free, axis=0))[0] + bbox_fill_ratio = free_ratio + bbox = None + if rows.size and cols.size: + y0, y1 = int(rows[0]), int(rows[-1]) + x0, x1 = int(cols[0]), int(cols[-1]) + bbox = (x0, y0, x1, y1) + bbox_area = max(1, (x1 - x0 + 1) * (y1 - y0 + 1)) + bbox_fill_ratio = free_area / bbox_area + + return { + "free_area": free_area, + "free_ratio": free_ratio, + "bbox": bbox, + "bbox_fill_ratio": bbox_fill_ratio, + } + + +def normalize_mask_for_fill(mask): + if config.FILL_ON == "WHITE": + return np.where(mask > 128, 0, 255).astype(np.uint8) + return np.where(mask > 128, 255, 0).astype(np.uint8) + + +def calculate_dynamic_dimensions(base_w, base_h, num_words, avg_len=3, mask_stats=None): + if not config.AUTO_EXPAND_CANVAS: + return base_w, base_h + + effective_fill = config.TARGET_FILL_RATIO if config.TARGET_FILL_RATIO > 0 else max(config.MIN_ACCEPT_FILL_RATIO, 0.82) + mask_fill_ratio = 0.5 + if mask_stats is not None: + mask_fill_ratio = max(0.05, mask_stats["free_ratio"]) + + area_per_word = (config.MIN_READABLE_HEIGHT_PX ** 2) * max(1.0, avg_len) * 1.2 + required_fillable_area = (num_words * area_per_word * max(1, config.N_REPETITIONS)) / max(effective_fill, 0.1) + required_canvas_area = required_fillable_area / mask_fill_ratio + current_area = base_w * base_h + if required_canvas_area > current_area: + scale_factor = (required_canvas_area / current_area) ** 0.5 + new_w = int(base_w * scale_factor) + new_h = int(base_h * scale_factor) + new_w = ((new_w // 100) + 1) * 100 + new_h = ((new_h // 100) + 1) * 100 + print(f"[Auto-Size] 扩展画布: {base_w}x{base_h} -> {new_w}x{new_h}") + return new_w, new_h + return base_w, base_h + + +def prepare_mask(target_w, target_h): + if config.MODE == "TEXT": + img_mask_gen = Image.new("L", (target_w, target_h), 255) + draw_mask = ImageDraw.Draw(img_mask_gen) + font_size = min(config.MASK_FONT_SIZE, int(target_h * 0.75)) + font_mask = get_cached_font(config.MASK_FONT_PATH, font_size) + bbox = draw_mask.textbbox((0, 0), config.MASK_TEXT, font=font_mask) + text_w = bbox[2] - bbox[0] + text_h = bbox[3] - bbox[1] + x_pos = (target_w - text_w) // 2 + y_pos = (target_h - text_h) // 2 + draw_mask.text((x_pos, y_pos), config.MASK_TEXT, fill=0, font=font_mask) + mask_hd = np.array(img_mask_gen) + mask_hd = normalize_mask_for_fill(mask_hd) + return mask_hd, (target_w, target_h), None + if config.MODE == "IMAGE": + if not os.path.exists(config.MASK_IMAGE_PATH): + raise FileNotFoundError(f"找不到掩膜文件 {config.MASK_IMAGE_PATH}") + img_raw = Image.open(config.MASK_IMAGE_PATH) + if img_raw.mode in ('RGBA', 'LA') or (img_raw.mode == 'P' and 'transparency' in img_raw.info): + img_bg = Image.new('RGB', img_raw.size, (255, 255, 255)) + if img_raw.mode == 'P': + img_raw = img_raw.convert('RGBA') + img_bg.paste(img_raw, mask=img_raw.split()[-1]) + img_src = img_bg.convert('L') + else: + img_src = img_raw.convert("L") + src_w, src_h = img_src.size + if config.IMAGE_CANVAS_MODE == "AUTO" or (target_w is None and target_h is None): + final_w, final_h = src_w, src_h + elif config.IMAGE_CANVAS_MODE == "WIDTH": + final_w = target_w + final_h = int(round(final_w * src_h / src_w)) + elif config.IMAGE_CANVAS_MODE == "HEIGHT": + final_h = target_h + final_w = int(round(final_h * src_w / src_h)) + else: + final_w, final_h = target_w, target_h + if (final_w, final_h) != (src_w, src_h): + print(f"正在重采样掩膜: {src_w}x{src_h} -> {final_w}x{final_h} (LANCZOS)") + img_src = img_src.resize((final_w, final_h), Image.Resampling.LANCZOS) + threshold = 200 + img_src = img_src.point(lambda p: 255 if p > threshold else 0) + + # 自动填充边角区域为可填充(黑色) + if config.FILL_CORNERS: + arr = np.array(img_src) + corner_h = int(final_h * config.CORNER_FILL_RATIO) + corner_w = int(final_w * config.CORNER_FILL_RATIO) + # 四个角落区域设为黑色(可填充) + arr[:corner_h, :corner_w] = 0 # 左上 + arr[:corner_h, -corner_w:] = 0 # 右上 + arr[-corner_h:, :corner_w] = 0 # 左下 + arr[-corner_h:, -corner_w:] = 0 # 右下 + img_src = Image.fromarray(arr) + print(f"[边角填充] 四角区域 {corner_w}x{corner_h} 已设为可填充") + + if config.SAVE_DEBUG_IMAGES: + debug_dir = config.DEBUG_OUTPUT_DIR + os.makedirs(debug_dir, exist_ok=True) + img_src.save(str(Path(debug_dir) / "mask_src.png")) + mask_hd = np.array(img_src) + mask_hd = normalize_mask_for_fill(mask_hd) + + return mask_hd, (final_w, final_h), None + + raise ValueError(f"未知 MODE: {config.MODE}") + + +def apply_safe_padding(mask, padding_px=4, padding_ratio=0.003, max_padding=20): + h, w = mask.shape + padding = max(padding_px, int(min(h, w) * padding_ratio)) + padding = min(padding, max_padding) + if padding <= 0: + return mask + mask[:padding, :] = 255 + mask[-padding:, :] = 255 + mask[:, :padding] = 255 + mask[:, -padding:] = 255 + return mask diff --git a/backend/core/paths.py b/backend/core/paths.py new file mode 100644 index 0000000..8d583e6 --- /dev/null +++ b/backend/core/paths.py @@ -0,0 +1,14 @@ +from pathlib import Path +import os + +BASE_DIR = Path(__file__).resolve().parents[1] +RUNTIME_DIR = BASE_DIR / ".runtime" +MPL_CONFIG_DIR = RUNTIME_DIR / "matplotlib" +RUNTIME_DIR.mkdir(parents=True, exist_ok=True) +MPL_CONFIG_DIR.mkdir(parents=True, exist_ok=True) + +os.environ.setdefault("MPLCONFIGDIR", str(MPL_CONFIG_DIR)) + +ASSETS_DIR = BASE_DIR / "assets" +FONTS_DIR = ASSETS_DIR / "fonts" +PROJECT_DEFAULT_FONT = Path("assets/fonts/STHeiti Medium.ttc") diff --git a/backend/core/pipeline.py b/backend/core/pipeline.py new file mode 100644 index 0000000..5d51eae --- /dev/null +++ b/backend/core/pipeline.py @@ -0,0 +1,586 @@ +import logging +import os +import sqlite3 +import sys +import time +from pathlib import Path + +import numpy as np +from PIL import Image, ImageDraw, ImageFont +import pandas as pd + +from . import config +from .fonts import get_cached_font +from .layout import OptimizedEfficientWordCloud +from .mask import analyze_mask, apply_safe_padding, calculate_dynamic_dimensions, prepare_mask +from .render import apply_dot_matrix, compute_fill_ratio_fast +from .weights import calculate_font_by_area_model, extract_weights_from_df, get_stroke_complexity_batch + +log = logging.getLogger("core.pipeline") + + +def run_generation_pass(names, frequencies_data, name_weights_map, mask_hd, real_hd_w, real_hd_h): + log.info("[run_generation_pass] 开始") + log.info(" 输入: %d 词 | HD尺寸: %dx%d", len(names), real_hd_w, real_hd_h) + + w_small = max(1, int(real_hd_w * config.WORK_SCALE)) + h_small = max(1, int(real_hd_h * config.WORK_SCALE)) + img_small = Image.fromarray(mask_hd).resize((w_small, h_small), Image.NEAREST) + mask_small = np.array(img_small) + apply_safe_padding(mask_small) + + log.info(" 运算网格: %dx%d (WORK_SCALE=%.4f)", w_small, h_small, config.WORK_SCALE) + log.info(" mask_small 统计: 总像素=%d, 空闲像素=%d, 空闲率=%.4f", + mask_small.size, int(np.sum(mask_small == 0)), + int(np.sum(mask_small == 0)) / mask_small.size if mask_small.size else 0) + + if config.SAVE_DEBUG_IMAGES: + debug_dir = Path(config.DEBUG_OUTPUT_DIR) + debug_dir.mkdir(parents=True, exist_ok=True) + Image.fromarray(mask_hd).save(str(debug_dir / "mask_hd.png")) + Image.fromarray(mask_small).save(str(debug_dir / "mask_small.png")) + + print(f"最终输出: {real_hd_w}x{real_hd_h} | 运算网格: {w_small}x{h_small}") + + current_min_font = max(config.MIN_FONT_FLOOR, int(config.MIN_READABLE_HEIGHT_PX * config.WORK_SCALE)) + total_target = len(names) * config.N_REPETITIONS + current_packing_eff = config.PACKING_EFFICIENCY + grow_step = config.GROW_FONT_STEP if config.GROW_FONT_ON_LOW_FILL else 1.0 + final_wc = None + final_scale = 1.0 + base_min_font = current_min_font + base_max_font = current_min_font + 1 + + def compute_font_bounds(packing_eff): + min_font, max_font = calculate_font_by_area_model( + mask_small, names, name_weights_map, config.TARGET_FILL_RATIO, config.SIZE_RATIO, packing_eff, config.N_REPETITIONS + ) + min_font = max(current_min_font, min_font) + + if config.USER_MIN_FONT_SIZE is not None: + user_min = int(config.USER_MIN_FONT_SIZE) + if user_min < config.MIN_FONT_FLOOR: + config._warn(f"USER_MIN_FONT_SIZE={config.USER_MIN_FONT_SIZE} 过小,提升到 {config.MIN_FONT_FLOOR}") + user_min = config.MIN_FONT_FLOOR + min_font = user_min + + if config.USER_MAX_FONT_SIZE is not None: + user_max = int(config.USER_MAX_FONT_SIZE) + if user_max < config.MIN_FONT_FLOOR: + config._warn(f"USER_MAX_FONT_SIZE={config.USER_MAX_FONT_SIZE} 过小,提升到 {config.MIN_FONT_FLOOR}") + user_max = config.MIN_FONT_FLOOR + max_font = user_max + + if max_font <= min_font: + config._warn(f"字号区间无效: min={min_font}, max={max_font},自动修正 max=min+1") + max_font = min_font + 1 + + return min_font, max_font + + def try_place(min_font, max_font, large_ratio=config.LARGE_FONT_LIMIT_RATIO, size_scale=1.0): + min_font = max(config.MIN_FONT_FLOOR, int(min_font)) + max_font = max(min_font + 1, int(max_font)) + wc = OptimizedEfficientWordCloud( + width=w_small, + height=h_small, + mask=mask_small, + font_path=config.WC_FONT_PATH, + max_words=total_target, + min_font_size=min_font, + max_font_size=max_font, + background_color=config.get_output_background(), + use_spiral_search=True, + large_font_ratio=large_ratio, + size_scale=size_scale, + ) + if config.ENABLE_STRATIFIED_SAMPLING: + wc.grid.reorder_stratified(config.STRATIFIED_BANDS) + wc.generate_from_frequencies(frequencies_data) + return wc, len(wc.layout_) + + print(f"--- 5. 启动生成 (目标: {total_target} 词) ---") + log.info("--- 5. 启动生成 ---") + log.info(" 目标词数: %d (names=%d * N_REPETITIONS=%d)", total_target, len(names), config.N_REPETITIONS) + log.info(" 当前最小字号: %d, 效率: %.2f", current_min_font, current_packing_eff) + for attempt in range(1, config.MAX_ATTEMPTS + 1): + base_min_font, base_max_font = compute_font_bounds(current_packing_eff) + print(f"尝试 #{attempt}: 基准字号 [{base_min_font}, {base_max_font}], 效率: {current_packing_eff:.2f}") + log.info("[尝试 #%d] 字号区间: [%d, %d], 效率: %.2f, 大字率: %.2f", + attempt, base_min_font, base_max_font, current_packing_eff, config.LARGE_FONT_LIMIT_RATIO) + + best_wc = None + best_count = 0 + best_scale = config.FONT_SCALE_MIN + best_success_wc = None + best_success_scale = None + current_large_ratio = config.LARGE_FONT_LIMIT_RATIO + low_scale = max(config.SCALE_FLOOR, config.FONT_SCALE_MIN) + high_scale = max(low_scale + 0.01, config.FONT_SCALE_MAX) + + for _ in range(max(1, config.SCALE_SEARCH_ROUNDS)): + mid_scale = ((low_scale + high_scale) / 2) * grow_step + wc, placed_count = try_place(base_min_font, base_max_font, current_large_ratio, mid_scale) + print(f" 尺度 {mid_scale:.3f} (字号 {base_min_font}-{base_max_font}) -> 成功: {placed_count}/{total_target}") + log.info(" 尺度 %.3f -> 放置 %d/%d", mid_scale, placed_count, total_target) + + if placed_count > best_count: + best_wc = wc + best_count = placed_count + best_scale = mid_scale + + if config.REQUIRE_ALL_WORDS: + if placed_count >= total_target: + best_success_wc = wc + best_success_scale = mid_scale + low_scale = max(low_scale, mid_scale / max(grow_step, 1e-6)) + else: + high_scale = min(high_scale, mid_scale / max(grow_step, 1e-6)) + else: + if placed_count >= best_count: + low_scale = max(low_scale, mid_scale / max(grow_step, 1e-6)) + else: + high_scale = min(high_scale, mid_scale / max(grow_step, 1e-6)) + + if abs(high_scale - low_scale) < 0.02: + break + + if best_success_wc is not None: + final_wc = best_success_wc + final_scale = best_success_scale if best_success_scale is not None else best_scale + break + + if best_wc is not None: + shrink_min = current_min_font + for _ in range(config.AUTO_SHRINK_ROUNDS): + shrink_min = max(config.MIN_FONT_FLOOR, int(shrink_min * 0.8)) + if shrink_min >= base_min_font: + continue + + print(f" [降级:缩小字号] {shrink_min}...") + wc, placed_count = try_place(shrink_min, base_max_font, current_large_ratio, best_scale) + if placed_count > best_count: + best_wc = wc + best_count = placed_count + best_scale = best_scale + if config.REQUIRE_ALL_WORDS and placed_count >= total_target: + final_wc = wc + final_scale = best_scale + break + if final_wc is not None: + break + + if config.ENABLE_SMART_LARGE_FONT_REDUCTION: + print(" [降级:牺牲大字] 仍然放不下,尝试减少大字数量...") + strict_large_ratio = 0.05 + retry_min = shrink_min if 'shrink_min' in locals() else current_min_font + wc, placed_count = try_place(retry_min, base_max_font, strict_large_ratio, best_scale) + print(f" [严格模式] 大字率 {strict_large_ratio} -> 成功: {placed_count}/{total_target}") + + if placed_count > best_count: + best_wc = wc + best_count = placed_count + if config.REQUIRE_ALL_WORDS and placed_count >= total_target: + final_wc = wc + final_scale = best_scale + break + + if attempt == config.MAX_ATTEMPTS: + final_wc = best_wc + final_scale = best_scale + break + + shrink_ratio = (best_count / total_target) if best_count else 0.5 + current_packing_eff *= min(0.95, max(0.5, shrink_ratio)) + + if final_wc is None: + return { + "wc": None, + "fill_ratio": 0.0, + "occ_fast": None, + "w_small": w_small, + "h_small": h_small, + "base_min_font": base_min_font, + "base_max_font": base_max_font, + "mask_small": mask_small, + "size_scale": final_scale, + } + + fill_ratio, occ_fast = compute_fill_ratio_fast(final_wc.layout_, mask_small, config.WC_FONT_PATH) + print(f"填充率: {fill_ratio:.3f}") + log.info("[填充率] 初始填充率: %.4f (最低要求: %.4f)", fill_ratio, config.MIN_ACCEPT_FILL_RATIO) + log.info(" layout_ 词数: %d", len(final_wc.layout_)) + if config.SAVE_DEBUG_IMAGES and occ_fast is not None: + debug_dir = Path(config.DEBUG_OUTPUT_DIR) + debug_dir.mkdir(parents=True, exist_ok=True) + Image.fromarray((occ_fast * 255).astype(np.uint8)).save(str(debug_dir / "occ_fast.png")) + + if fill_ratio < config.MIN_ACCEPT_FILL_RATIO: + print(f"[填充率不足] {fill_ratio:.3f} < {config.MIN_ACCEPT_FILL_RATIO:.2f},启动二分放大字号重试...") + low_scale = max(final_scale, 1.0) + high_scale = max(low_scale, config.FILL_RETRY_MAX_SCALE) + retry_round = 0 + best_wc = final_wc + best_fill = fill_ratio + + while retry_round < config.FILL_RETRY_MAX_ROUNDS: + mid_scale = (low_scale + high_scale) / 2 + retry_large_ratio = 1.0 if config.FILL_RETRY_RELAX_LARGE_CAP else config.LARGE_FONT_LIMIT_RATIO + + wc, _placed_count = try_place(base_min_font, base_max_font, retry_large_ratio, mid_scale) + new_fill, occ_fast = compute_fill_ratio_fast(wc.layout_, mask_small, config.WC_FONT_PATH) + print(f" [二分重试#{retry_round + 1}] scale={mid_scale:.3f} 填充率={new_fill:.3f}") + + if new_fill > best_fill: + best_fill = new_fill + best_wc = wc + + if new_fill >= config.MIN_ACCEPT_FILL_RATIO: + final_wc = wc + final_scale = mid_scale + fill_ratio = new_fill + if config.SAVE_DEBUG_IMAGES and occ_fast is not None: + debug_dir = Path(config.DEBUG_OUTPUT_DIR) + debug_dir.mkdir(parents=True, exist_ok=True) + Image.fromarray((occ_fast * 255).astype(np.uint8)).save( + str(debug_dir / f"occ_fast_retry_{retry_round + 1}.png") + ) + break + + if new_fill > fill_ratio: + low_scale = mid_scale + else: + high_scale = mid_scale + + retry_round += 1 + + if fill_ratio < config.MIN_ACCEPT_FILL_RATIO: + final_wc = best_wc + fill_ratio = best_fill + + print(f"最终填充率: {fill_ratio:.3f}") + return { + "wc": final_wc, + "fill_ratio": fill_ratio, + "occ_fast": occ_fast, + "w_small": w_small, + "h_small": h_small, + "base_min_font": base_min_font, + "base_max_font": base_max_font, + "mask_small": mask_small, + "size_scale": final_scale, + } + + +def main(): + t_start = time.time() + print("--- 1. 读取数据 ---") + log.info("=" * 60) + log.info("[Pipeline] main() 开始") + log.info(" EXCEL_PATH = %s", config.EXCEL_PATH) + log.info(" DATA_COL = %d", config.DATA_COL_INDEX) + log.info(" MODE = %s", config.MODE) + log.info(" FILL_ON = %s", config.FILL_ON) + log.info(" WORK_SCALE = %.4f", config.WORK_SCALE) + log.info(" SEED = %s", config.SEED) + + names = [] + df = None + if os.path.exists(config.EXCEL_PATH): + try: + df = pd.read_excel(config.EXCEL_PATH) + raw_names = df.iloc[:, config.DATA_COL_INDEX].dropna().astype(str) + if config.REMOVE_DUPLICATES: + names = raw_names.unique().tolist() + print(f"模式: 去重 | 数量: {len(names)}") + else: + names = raw_names.tolist() + print(f"模式: 保留重复 | 数量: {len(names)}") + except Exception as e: + print(f"读取 Excel 失败: {e}") + log.error("读取 Excel 失败: %s", e) + sys.exit(1) + else: + count = 12000 + print(f"未找到Excel,使用测试数据: {count}条") + log.info("未找到 Excel,使用测试数据: %d 条", count) + names = [f"测试_{i % 100}" for i in range(count)] + + input_count = len(names) + log.info("[阶段1] 读取完成: input_count=%d, 去重=%s", input_count, config.REMOVE_DUPLICATES) + if names: + sample = names[:min(10, len(names))] + log.info(" 前10个名字: %s", sample) + + print("--- 2. 智能画幅计算 ---") + log.info("--- 阶段2: 智能画幅计算 ---") + avg_len = sum(len(n) for n in names) / len(names) if names else 3 + log.info(" 平均名字长度: %.2f 字符", avg_len) + log.info(" BASE_HD: %dx%d", config.BASE_HD_WIDTH, config.BASE_HD_HEIGHT) + + probe_mask_hd, (probe_w, probe_h), _ = prepare_mask(config.BASE_HD_WIDTH, config.BASE_HD_HEIGHT) + probe_stats = analyze_mask(probe_mask_hd) + log.info(" Probe mask: %dx%d, free_ratio=%.4f, bbox_fill_ratio=%.4f", + probe_w, probe_h, probe_stats['free_ratio'], probe_stats['bbox_fill_ratio']) + if probe_stats.get('bbox'): + log.info(" Probe bbox: %s", probe_stats['bbox']) + + print(f"[Mask Probe] 可填充比例={probe_stats['free_ratio']:.3f}") + hd_w, hd_h = calculate_dynamic_dimensions(probe_w, probe_h, len(names), avg_len, probe_stats) + log.info(" 动态画幅计算结果: %dx%d", hd_w, hd_h) + + print("--- 3. 生成掩膜 (High Quality & Edge Fix) ---") + log.info("--- 阶段3: 生成掩膜 ---") + mask_hd, (real_hd_w, real_hd_h), _ = prepare_mask(hd_w, hd_h) + mask_stats = analyze_mask(mask_hd) + log.info(" mask_hd: %dx%d", real_hd_w, real_hd_h) + log.info(" free_area=%d, free_ratio=%.6f", mask_stats['free_area'], mask_stats['free_ratio']) + log.info(" bbox_fill_ratio=%.6f", mask_stats['bbox_fill_ratio']) + if mask_stats.get('bbox'): + log.info(" bbox=%s", mask_stats['bbox']) + print(f"[Mask Final] 可填充比例={mask_stats['free_ratio']:.3f}") + + print("--- 4. 计算权重 ---") + log.info("--- 阶段4: 计算权重 ---") + t_weights = time.time() + if config.ENABLE_STROKE_WEIGHTS: + stroke_weights_map = get_stroke_complexity_batch(names, config.WC_FONT_PATH) + log.info(" 笔画权重计算完成: %d 个词, 耗时=%.3fs", len(stroke_weights_map), time.time() - t_weights) + else: + stroke_weights_map = {} + print("笔画权重已关闭") + log.info(" 笔画权重已关闭") + + # 打印权重分布统计 + if stroke_weights_map: + w_vals = list(stroke_weights_map.values()) + log.info(" 笔画权重分布: min=%.1f, max=%.1f, avg=%.1f, median=%.1f", + min(w_vals), max(w_vals), sum(w_vals)/len(w_vals), + sorted(w_vals)[len(w_vals)//2]) + sample_items = list(stroke_weights_map.items())[:5] + log.info(" 笔画权重样本: %s", sample_items) + + excel_weights_map = extract_weights_from_df(df, names) if df is not None else {} + if excel_weights_map: + print(f"Excel 权重生效: {len(excel_weights_map)} 个词") + log.info(" Excel 权重生效: %d 个词", len(excel_weights_map)) + ew_vals = list(excel_weights_map.values()) + log.info(" Excel 权重分布: min=%.1f, max=%.1f, avg=%.1f", + min(ew_vals), max(ew_vals), sum(ew_vals)/len(ew_vals)) + elif config.WEIGHT_COL_NAME is not None or config.WEIGHT_COL_INDEX is not None: + fallback = "笔画权重" if config.ENABLE_STROKE_WEIGHTS else "均等权重" + print(f"Excel 权重不可用,已回退{fallback}") + log.info(" Excel 权重不可用,已回退%s", fallback) + + name_weights_map = dict(stroke_weights_map) + name_weights_map.update(excel_weights_map) + frequencies_data = name_weights_map if config.REMOVE_DUPLICATES else [(name, name_weights_map.get(name, 10)) for name in names] + + canvas_retry_round = 0 + generation_result = None + t_gen = time.time() + while True: + log.info("[画布] 第%d轮生成 pass, 当前画布: %dx%d", canvas_retry_round + 1, real_hd_w, real_hd_h) + generation_result = run_generation_pass( + names, + frequencies_data, + name_weights_map, + mask_hd, + real_hd_w, + real_hd_h, + ) + + if generation_result["wc"] is None: + if canvas_retry_round >= config.CANVAS_RETRY_MAX_ROUNDS: + print("生成失败:未找到合适布局") + log.error("生成失败:未找到合适布局 (已重试 %d 轮)", canvas_retry_round) + sys.exit(1) + log.warning(" 本轮生成失败 (wc=None), 将重试") + elif generation_result["fill_ratio"] >= config.MIN_ACCEPT_FILL_RATIO or canvas_retry_round >= config.CANVAS_RETRY_MAX_ROUNDS: + log.info(" 生成成功! fill_ratio=%.4f (要求>=%.4f), 重试轮次=%d", + generation_result['fill_ratio'], config.MIN_ACCEPT_FILL_RATIO, canvas_retry_round) + break + + canvas_retry_round += 1 + next_w = int(real_hd_w * config.CANVAS_RETRY_GROWTH) + next_h = int(real_hd_h * config.CANVAS_RETRY_GROWTH) + print(f"[画布重试#{canvas_retry_round}] {real_hd_w}x{real_hd_h} -> {next_w}x{next_h}") + log.info("[画布重试#%d] %dx%d -> %dx%d (growth=%.2f)", + canvas_retry_round, real_hd_w, real_hd_h, next_w, next_h, config.CANVAS_RETRY_GROWTH) + mask_hd, (real_hd_w, real_hd_h), _ = prepare_mask(next_w, next_h) + mask_stats = analyze_mask(mask_hd) + + final_wc = generation_result["wc"] + fill_ratio = generation_result["fill_ratio"] + w_small = generation_result["w_small"] + h_small = generation_result["h_small"] + log.info("[阶段5完成] 生成耗时=%.2fs, fill_ratio=%.4f, size_scale=%.4f", + time.time() - t_gen, fill_ratio, generation_result["size_scale"]) + + print("--- 6. 高清渲染 ---") + log.info("--- 阶段6: 高清渲染 ---") + t_render = time.time() + hd_layout = [] + for text, size, (y, x), orient, color in final_wc.layout_: + hd_size = int(size / config.WORK_SCALE) + hd_y = int(y / config.WORK_SCALE) + hd_x = int(x / config.WORK_SCALE) + hd_layout.append((text, hd_size, (hd_y, hd_x), orient, color)) + + log.info(" HD layout 词数: %d", len(hd_layout)) + log.info(" HD 画布: %dx%d", real_hd_w, real_hd_h) + if hd_layout: + sample = hd_layout[:3] + for s in sample: + log.info(" 样本: text='%s', size=%d, pos=(%d,%d), orient=%s, color=%s", + s[0], s[1], s[2][1], s[2][0], s[3], s[4]) + + final_wc.layout_ = hd_layout + final_wc.width = real_hd_w + final_wc.height = real_hd_h + + base_img = final_wc.to_image().convert("RGB") + if config.ENABLE_DOT_MATRIX: + base_img = apply_dot_matrix(base_img, mask_hd) + + base_img.save(config.OUTPUT_PNG) + print(f"已保存: {config.OUTPUT_PNG}") + log.info(" PNG 已保存: %s (%.2f MB)", config.OUTPUT_PNG, + Path(config.OUTPUT_PNG).stat().st_size / 1024 / 1024 if Path(config.OUTPUT_PNG).exists() else 0) + final_wc.to_svg(config.OUTPUT_SVG) + print(f"已保存: {config.OUTPUT_SVG}") + log.info(" SVG 已保存: %s (%.2f MB)", config.OUTPUT_SVG, + Path(config.OUTPUT_SVG).stat().st_size / 1024 / 1024 if Path(config.OUTPUT_SVG).exists() else 0) + + # 描边版 SVG(激光雕刻用) + stroke_svg = str(Path(config.OUTPUT_SVG).with_name( + Path(config.OUTPUT_SVG).stem + "_stroke" + Path(config.OUTPUT_SVG).suffix + )) + final_wc.to_svg_stroke(stroke_svg) + print(f"已保存: {stroke_svg}") + log.info(" SVG(stroke) 已保存: %s (%.2f MB)", stroke_svg, + Path(stroke_svg).stat().st_size / 1024 / 1024 if Path(stroke_svg).exists() else 0) + log.info(" 渲染耗时: %.2fs", time.time() - t_render) + + log.info("--- 阶段7: 写入数据库 ---") + t_db = time.time() + try: + conn = sqlite3.connect(config.DB_PATH) + cursor = conn.cursor() + cursor.execute("DROP TABLE IF EXISTS word_locations") + cursor.execute(""" + CREATE TABLE word_locations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + x INTEGER, + y INTEGER, + font_size INTEGER, + color TEXT, + orientation TEXT, + box_x INTEGER, + box_y INTEGER, + box_width INTEGER, + box_height INTEGER + ) + """) + bbox_canvas = Image.new("L", (1, 1), 0) + bbox_draw = ImageDraw.Draw(bbox_canvas) + db_data = [] + for name, font_size, (y, x), orient, color in final_wc.layout_: + font = get_cached_font(config.WC_FONT_PATH, max(1, int(font_size))) + orientation = "vertical" if orient else "horizontal" + if orient: + font = ImageFont.TransposedFont(font, orientation=orient) + bbox = bbox_draw.textbbox((x, y), name, font=font) + db_data.append( + ( + name, + x, + y, + font_size, + color, + orientation, + bbox[0], + bbox[1], + bbox[2] - bbox[0], + bbox[3] - bbox[1], + ) + ) + cursor.executemany( + """ + INSERT INTO word_locations + (name, x, y, font_size, color, orientation, box_x, box_y, box_width, box_height) + VALUES (?,?,?,?,?,?,?,?,?,?) + """, + db_data, + ) + conn.commit() + conn.close() + log.info(" DB 写入完成: %s, %d 行, 耗时=%.3fs", config.DB_PATH, len(db_data), time.time() - t_db) + except sqlite3.Error as e: + print(f"DB Error: {e}") + log.error(" DB 写入失败: %s", e) + sys.exit(1) + + elapsed = time.time() - t_start + placed_count = len(final_wc.layout_) + metrics = { + "seed": config.SEED, + "layout_order_mode": config.LAYOUT_ORDER_MODE, + "layout_seed": config.LAYOUT_SEED, + "input_count": input_count, + "placed_count": placed_count, + "fill_ratio": fill_ratio, + "elapsed_seconds": round(elapsed, 4), + "font_info": { + "layout_font_path": config.WC_FONT_PATH, + "mask_font_path": config.MASK_FONT_PATH, + "palette": list(config.get_output_palette()), + "background": config.get_output_background(), + }, + "mask_info": { + "free_ratio": round(mask_stats["free_ratio"], 6), + "bbox_fill_ratio": round(mask_stats["bbox_fill_ratio"], 6), + "canvas_retry_rounds": canvas_retry_round, + }, + "canvas_info": { + "hd_width": real_hd_w, + "hd_height": real_hd_h, + "work_width": w_small, + "work_height": h_small, + "work_scale": config.WORK_SCALE, + }, + "output_paths": { + "png": config.OUTPUT_PNG, + "svg": config.OUTPUT_SVG, + "db": config.DB_PATH, + "metrics": config.METRICS_FILE, + "debug_dir": config.DEBUG_OUTPUT_DIR, + }, + "config_snapshot": { + "mode": config.MODE, + "excel_path": config.EXCEL_PATH, + "mask_image_path": config.MASK_IMAGE_PATH, + "output_dir": config.OUTPUT_DIR, + "output_prefix": config.OUTPUT_PREFIX, + "min_font_size": config.MIN_FONT_SIZE, + "max_attempts": config.MAX_ATTEMPTS, + "fill_on": config.FILL_ON, + "min_accept_fill_ratio": config.MIN_ACCEPT_FILL_RATIO, + "require_all_words": config.REQUIRE_ALL_WORDS, + "layout_order_mode": config.LAYOUT_ORDER_MODE, + "layout_seed": config.LAYOUT_SEED, + } + } + config.write_metrics(metrics) + + print(f"\n✅ 完成! 总耗时: {elapsed:.2f}s") + log.info("=" * 60) + log.info("[Pipeline] 全流程完成!") + log.info(" 总耗时: %.2fs", elapsed) + log.info(" 输入: %d 词 -> 放置: %d 词", input_count, placed_count) + log.info(" 填充率: %.4f", fill_ratio) + log.info(" 画布: %dx%d (运算: %dx%d)", real_hd_w, real_hd_h, w_small, h_small) + log.info(" 输出: PNG=%s", config.OUTPUT_PNG) + log.info(" 输出: SVG=%s", config.OUTPUT_SVG) + log.info(" 输出: DB=%s", config.DB_PATH) + log.info("=" * 60) diff --git a/backend/core/render.py b/backend/core/render.py new file mode 100644 index 0000000..1dd4d8a --- /dev/null +++ b/backend/core/render.py @@ -0,0 +1,47 @@ +import numpy as np +from PIL import Image, ImageDraw, ImageFilter, ImageFont + +from . import config +from .fonts import get_cached_font + + +def render_layout_occupancy(layout, mask_shape, font_path): + h, w = mask_shape + canvas = Image.new("L", (w, h), 0) + draw = ImageDraw.Draw(canvas) + for word, size, (y, x), orient, _color in layout: + font = get_cached_font(font_path, size) + if orient: + font = ImageFont.TransposedFont(font, orientation=orient) + draw.text((x, y), word, font=font, fill=255) + return (np.array(canvas) > 0).astype(np.uint8) + + +def compute_fill_ratio_fast(layout, mask, font_path): + if not layout: + return 0.0, None + occ = render_layout_occupancy(layout, mask.shape, font_path) + free_area = np.sum(mask == 0) + if free_area == 0: + return 0.0, occ + filled_area = np.sum((mask == 0) & (occ == 1)) + return filled_area / free_area, occ + + +def apply_dot_matrix(base_img, mask_hd): + text_mask = base_img.convert("L").point(lambda x: 0 if x < 200 else 255) + filter_size = max(3, (config.DOT_SAFETY_BUFFER // 2) * 2 + 1) + safe_zone_mask = text_mask.filter(ImageFilter.MinFilter(size=filter_size)) + safe_zone_array = np.array(safe_zone_mask) + unfilled_zone = (mask_hd == 0) & (safe_zone_array > 200) + draw = ImageDraw.Draw(base_img) + h, w = mask_hd.shape + dot_color = "white" if config.FILL_ON == "WHITE" else "black" + for y in range(0, h, config.DOT_SPACING): + for x in range(0, w, config.DOT_SPACING): + if unfilled_zone[y, x]: + if config.DOT_RADIUS > 0: + draw.ellipse([x - config.DOT_RADIUS, y - config.DOT_RADIUS, x + config.DOT_RADIUS, y + config.DOT_RADIUS], fill=dot_color) + else: + draw.point((x, y), fill=dot_color) + return base_img diff --git a/backend/core/weights.py b/backend/core/weights.py new file mode 100644 index 0000000..e75d9e1 --- /dev/null +++ b/backend/core/weights.py @@ -0,0 +1,101 @@ +import math + +import numpy as np +import pandas as pd +from PIL import Image, ImageDraw + +from . import config +from .fonts import get_cached_font +from .layout import normalize_relative_scores + + +def get_stroke_complexity_batch(names, font_p, test_size=64): + font = get_cached_font(font_p, test_size) + img = Image.new("L", (test_size, test_size), 255) + draw = ImageDraw.Draw(img) + char_complexity_cache = {} + weights = {} + all_chars = set("".join(names)) + for char in all_chars: + draw.rectangle([0, 0, test_size, test_size], fill=255) + draw.text((0, 0), char, font=font, fill=0) + char_complexity_cache[char] = np.sum(np.array(img) < 200) + + unique_names = set(names) + for name in unique_names: + if not name: + weights[name] = 10 + continue + complexities = [char_complexity_cache.get(c, 10) for c in name] + weights[name] = max(complexities) + return weights + + +def extract_weights_from_df(df, names): + series = None + + if config.WEIGHT_COL_NAME is not None: + if config.WEIGHT_COL_NAME in df.columns: + series = df[config.WEIGHT_COL_NAME] + else: + config._warn(f"权重列名不存在: {config.WEIGHT_COL_NAME},尝试使用权重列索引") + if series is None and config.WEIGHT_COL_INDEX is not None: + if 0 <= config.WEIGHT_COL_INDEX < len(df.columns): + series = df.iloc[:, config.WEIGHT_COL_INDEX] + else: + config._warn(f"权重列索引越界: {config.WEIGHT_COL_INDEX},将回退到笔画权重") + + if series is None: + return {} + + name_series = df.iloc[:, config.DATA_COL_INDEX] + numeric = pd.to_numeric(series, errors='coerce') + pairs = pd.DataFrame({"name": name_series, "weight": numeric}) + pairs = pairs[pairs["name"].notna()] + pairs["name"] = pairs["name"].astype(str) + pairs = pairs[pairs["weight"].notna() & (pairs["weight"] > 0)] + + if pairs.empty: + config._warn("Excel 权重列没有可用正数,全部回退到笔画权重") + return {} + + if config.REMOVE_DUPLICATES: + grouped = pairs.groupby("name", as_index=False)["weight"].max() + return dict(zip(grouped["name"], grouped["weight"])) + + valid_name_set = set(names) + pairs = pairs[pairs["name"].isin(valid_name_set)] + if pairs.empty: + config._warn("Excel 权重与名称列未形成有效映射,全部回退到笔画权重") + return {} + + grouped = pairs.groupby("name", as_index=False)["weight"].max() + return dict(zip(grouped["name"], grouped["weight"])) + + +def calculate_font_by_area_model(mask, names, weights_map, fill_ratio, size_ratio, packing_efficiency, n_rep): + free_area = int(np.sum(mask == 0)) + if free_area <= 0: + free_area = int(mask.size) + + effective_fill = fill_ratio if fill_ratio > 0 else max(config.MIN_ACCEPT_FILL_RATIO, 0.82) + target_area = free_area * effective_fill * packing_efficiency + + weights = [max(float(weights_map.get(name, 10)), 1.0) for name in names] + if not weights: + return max(config.MIN_FONT_SIZE, 10), max(config.MIN_FONT_SIZE + 4, 20) + + log_scores = normalize_relative_scores([math.log1p(weight) for weight in weights]) + char_mass = 0.0 + for name, score in zip(names, log_scores): + length = max(1, len(name)) + char_mass += length * (0.9 + 0.9 * score) + + char_mass *= max(1, n_rep) + if char_mass <= 0: + return max(config.MIN_FONT_SIZE, 10), max(config.MIN_FONT_SIZE + 4, 20) + + nominal_size = math.sqrt(target_area / char_mass) + min_f = max(config.MIN_FONT_SIZE, int(nominal_size * 0.72)) + max_f = max(min_f + 1, int(min_f * max(1.4, size_ratio))) + return min_f, max_f diff --git a/backend/docs/backend-api-spec.md b/backend/docs/backend-api-spec.md new file mode 100644 index 0000000..e0eff3b --- /dev/null +++ b/backend/docs/backend-api-spec.md @@ -0,0 +1,10 @@ +# 已废弃:后端 API 旧规格 + +本文档已被 `docs/API.md` 取代。 + +请阅读: + +- [../../docs/API.md](../../docs/API.md) +- [../../docs/README.md](../../docs/README.md) + +API 事实来源是 `backend/service/app.py` 和 `backend/service/schemas.py`。 diff --git a/backend/docs/performance_market_comparison_2026-04.md b/backend/docs/performance_market_comparison_2026-04.md new file mode 100644 index 0000000..29cf9ba --- /dev/null +++ b/backend/docs/performance_market_comparison_2026-04.md @@ -0,0 +1,9 @@ +# 已废弃:性能与市场对比 + +本文档是历史分析材料,不再作为当前项目能力、性能或路线图依据。 + +当前代码行为请以标准文档和源码为准: + +- [../../docs/README.md](../../docs/README.md) +- [../../docs/PROJECT_STANDARD.md](../../docs/PROJECT_STANDARD.md) +- [../../docs/ALGORITHM.md](../../docs/ALGORITHM.md) diff --git a/backend/docs/workbench-backend-api-spec.md b/backend/docs/workbench-backend-api-spec.md new file mode 100644 index 0000000..d16e330 --- /dev/null +++ b/backend/docs/workbench-backend-api-spec.md @@ -0,0 +1,10 @@ +# 已废弃:工作台后端 API 旧规格 + +本文档已被 `docs/API.md` 取代。 + +请阅读: + +- [../../docs/API.md](../../docs/API.md) +- [../../docs/PROJECT_STANDARD.md](../../docs/PROJECT_STANDARD.md) + +当前工作台相关接口包括 Jobs、Templates、Assets、Projects 和 Fonts,均在标准 API 文档中按当前代码整理。 diff --git a/backend/docs/项目完整使用手册.md b/backend/docs/项目完整使用手册.md new file mode 100644 index 0000000..ef0353e --- /dev/null +++ b/backend/docs/项目完整使用手册.md @@ -0,0 +1,11 @@ +# 已废弃:项目完整使用手册旧版 + +本文档已被标准文档拆分取代。 + +请阅读: + +- [../../docs/README.md](../../docs/README.md) +- [../../docs/PROJECT_STANDARD.md](../../docs/PROJECT_STANDARD.md) +- [../../docs/ALGORITHM.md](../../docs/ALGORITHM.md) +- [../../docs/CONFIG.md](../../docs/CONFIG.md) +- [../../docs/API.md](../../docs/API.md) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..f0e7c1f --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,9 @@ +fastapi>=0.136.0 +uvicorn[standard]>=0.32.0 +python-multipart>=0.0.27 +pydantic>=2.10.0 +pillow>=10.0.0 +numpy>=2.0.0 +matplotlib>=3.10.0 +pandas>=2.0.0 +openpyxl>=3.1.0 diff --git a/backend/service/__init__.py b/backend/service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/service/app.py b/backend/service/app.py new file mode 100644 index 0000000..5196fb7 --- /dev/null +++ b/backend/service/app.py @@ -0,0 +1,1208 @@ +from __future__ import annotations + +import io +import json +import logging +import os +import re +import shutil +import sqlite3 +import threading +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 .log_config import get_logger +from .runner import JobRunner +from .schemas import ( + Asset, + DesignTemplate, + Font, + JobCreateResponse, + JobDetail, + JobLocationSearchResult, + JobResult, + JobStatus, + 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) + + +@app.post("/api/assets/from-job/{job_id}", response_model=Asset) +async def import_asset_from_job( + job_id: str, + name: str = Form(""), +) -> Asset: + # 尝试从内存获取产物路径;若服务已重启则从磁盘回退 + 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: + svg_path = Path(svg_path_str) + else: + # 服务重启后内存丢失,直接从工作区目录查找 + fallback_dir = storage.base_dir / job_id / "output" + if fallback_dir.exists(): + for candidate in fallback_dir.glob("*.svg"): + svg_path = candidate + break + + 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" + shutil.copy2(svg_path, dest) + + content = dest.read_bytes() + width, height = _parse_svg_viewbox(dest) + + display_name = name or f"词云_{job_id[:8]}" + meta = { + "asset_id": asset_id, + "name": display_name, + "type": "wordcloud", + "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.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) diff --git a/backend/service/job_manager.py b/backend/service/job_manager.py new file mode 100644 index 0000000..c0f95e5 --- /dev/null +++ b/backend/service/job_manager.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import queue +import threading +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path + +from .schemas import JobDetail, JobEvent, JobStatus + + +@dataclass +class JobState: + status: JobStatus + events: list[JobEvent] = field(default_factory=list) + subscribers: list[queue.Queue] = field(default_factory=list) + + +class JobManager: + def __init__(self) -> None: + self._lock = threading.Lock() + self._jobs: dict[str, JobState] = {} + + def create_job(self) -> str: + job_id = uuid.uuid4().hex + now = datetime.now(timezone.utc) + status = JobStatus( + job_id=job_id, + status="queued", + stage="queued", + progress_percent=0, + message="任务已创建", + created_at=now, + updated_at=now, + artifacts={}, + error="", + ) + with self._lock: + self._jobs[job_id] = JobState(status=status) + return job_id + + def exists(self, job_id: str) -> bool: + with self._lock: + return job_id in self._jobs + + def get_status(self, job_id: str) -> JobStatus: + with self._lock: + return self._jobs[job_id].status + + def get_detail(self, job_id: str) -> JobDetail: + with self._lock: + state = self._jobs[job_id] + return JobDetail(status=state.status, recent_events=state.events[-100:]) + + def set_artifacts(self, job_id: str, artifacts: dict[str, str]) -> None: + with self._lock: + status = self._jobs[job_id].status + status.artifacts = artifacts + status.updated_at = datetime.now(timezone.utc) + + def set_status( + self, + job_id: str, + *, + status: str, + stage: str, + progress_percent: int, + message: str, + error: str | None = None, + ) -> None: + with self._lock: + s = self._jobs[job_id].status + s.status = status + s.stage = stage + s.progress_percent = progress_percent + s.message = message + s.updated_at = datetime.now(timezone.utc) + if error is not None: + s.error = error + + def add_event(self, job_id: str, *, kind: str, stage: str, progress_percent: int, message: str) -> None: + event = JobEvent( + type=kind, + stage=stage, + progress_percent=progress_percent, + message=message, + timestamp=datetime.now(timezone.utc), + ) + with self._lock: + state = self._jobs[job_id] + state.events.append(event) + state.status.stage = stage + state.status.progress_percent = progress_percent + state.status.message = message + state.status.updated_at = event.timestamp + for sub in state.subscribers: + sub.put(event) + + def subscribe(self, job_id: str) -> queue.Queue: + q: queue.Queue = queue.Queue() + with self._lock: + self._jobs[job_id].subscribers.append(q) + return q + + def unsubscribe(self, job_id: str, q: queue.Queue) -> None: + with self._lock: + subs = self._jobs[job_id].subscribers + if q in subs: + subs.remove(q) + + @staticmethod + def resolve_artifact_path(status: JobStatus, kind: str) -> Path: + if kind not in status.artifacts: + raise KeyError(kind) + p = status.artifacts[kind] + if not p: + raise FileNotFoundError(kind) + return Path(p) diff --git a/backend/service/log_config.py b/backend/service/log_config.py new file mode 100644 index 0000000..fd6396b --- /dev/null +++ b/backend/service/log_config.py @@ -0,0 +1,43 @@ +""" +后端日志配置模块。 + +提供统一的日志格式和两个 handler: + - console: 输出到控制台(uvicorn 可见) + - file: 输出到 backend/.runtime/service.log +""" +import logging +import sys +from pathlib import Path + +_LOG_DIR = Path(__file__).resolve().parent.parent / ".runtime" +_LOG_DIR.mkdir(parents=True, exist_ok=True) +_LOG_FILE = _LOG_DIR / "service.log" + +_FORMAT = "%(asctime)s | %(levelname)-7s | %(name)s | %(message)s" +_DATE_FORMAT = "%Y-%m-%d %H:%M:%S" + + +def setup_logging(level: int = logging.DEBUG) -> None: + """配置全局日志,可重复调用(幂等)。""" + root = logging.getLogger() + if root.handlers: + return # 已配置过 + + root.setLevel(level) + + console = logging.StreamHandler(sys.stdout) + console.setLevel(logging.INFO) + console.setFormatter(logging.Formatter(_FORMAT, datefmt=_DATE_FORMAT)) + + file_handler = logging.FileHandler(str(_LOG_FILE), mode="a", encoding="utf-8") + file_handler.setLevel(logging.DEBUG) + file_handler.setFormatter(logging.Formatter(_FORMAT, datefmt=_DATE_FORMAT)) + + root.addHandler(console) + root.addHandler(file_handler) + + +def get_logger(name: str) -> logging.Logger: + """获取命名 logger,自动触发 setup。""" + setup_logging() + return logging.getLogger(name) diff --git a/backend/service/runner.py b/backend/service/runner.py new file mode 100644 index 0000000..a7a833d --- /dev/null +++ b/backend/service/runner.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +import json +import logging +import os +import subprocess +import sys +import time +from pathlib import Path + +from .job_manager import JobManager +from .log_config import get_logger +from .schemas import JobPaths + +log = get_logger("service.runner") + + +STAGE_RULES: list[tuple[str, str, int]] = [ + ("--- 1. 读取数据 ---", "reading_data", 10), + ("--- 2. 智能画幅计算 ---", "sizing_canvas", 20), + ("--- 3. 生成掩膜", "building_mask", 35), + ("--- 4. 计算权重 ---", "computing_weights", 45), + ("--- 5. 启动生成", "placing_words", 65), + ("--- 6. 高清渲染 ---", "rendering", 85), + ("已保存:", "writing_outputs", 92), + ("✅ 完成", "writing_outputs", 99), +] + + +class JobRunner: + def __init__(self, project_root: Path, manager: JobManager) -> None: + self.project_root = project_root + self.manager = manager + self.script_path = self.project_root / "wordcloud_generate_hybrid.py" + + def _parse_stage(self, line: str, current_stage: str, current_progress: int) -> tuple[str, int]: + for token, stage, progress in STAGE_RULES: + if token in line: + return stage, progress + + if "尝试 #" in line or "尺度" in line or "二分重试" in line: + progress = max(current_progress, 70) + return "placing_words", min(progress + 1, 84) + + return current_stage, current_progress + + def run(self, job_id: str, paths: JobPaths, config: dict) -> None: + t_start = time.time() + log.info("=" * 50) + log.info("[Runner] 任务启动 job_id=%s", job_id) + log.info(" config_path = %s", paths.config_path) + log.info(" output_dir = %s", paths.output_dir) + log.info(" 子进程 python = %s", sys.executable) + self.manager.set_status(job_id, status="running", stage="starting", progress_percent=1, message="任务启动") + + with paths.config_path.open("w", encoding="utf-8") as f: + json.dump(config, f, ensure_ascii=False, indent=2) + log.info(" 配置文件已写入") + + cmd = [ + sys.executable, + str(self.script_path), + "--config", + str(paths.config_path), + ] + + env = os.environ.copy() + process = subprocess.Popen( + cmd, + cwd=str(self.project_root), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + env=env, + ) + + stage = "starting" + progress = 1 + + assert process.stdout is not None + for raw in process.stdout: + line = raw.rstrip("\n") + log.info("[Pipeline] %s", line) + stage, progress = self._parse_stage(line, stage, progress) + self.manager.add_event( + job_id, + kind="log", + stage=stage, + progress_percent=progress, + message=line, + ) + + ret = process.wait() + elapsed = time.time() - t_start + log.info("[Runner] 子进程退出 code=%d 耗时=%.2fs", ret, elapsed) + + png = next(paths.output_dir.glob("*.png"), None) + svg = next(paths.output_dir.glob("*[!_stroke].svg"), None) + svg_stroke = next(paths.output_dir.glob("*_stroke.svg"), None) + db = next(paths.output_dir.glob("*.db"), None) + metrics = next(paths.output_dir.glob("*metrics*.json"), None) + + log.info("[Runner] 产物扫描:") + log.info(" png = %s", png) + log.info(" svg = %s", svg) + log.info(" svg_stroke = %s", svg_stroke) + log.info(" db = %s", db) + log.info(" metrics = %s", metrics) + + artifacts = { + "png": str(png) if png else "", + "svg": str(svg) if svg else "", + "svg_stroke": str(svg_stroke) if svg_stroke else "", + "db": str(db) if db else "", + "metrics": str(metrics) if metrics else "", + } + self.manager.set_artifacts(job_id, artifacts) + + if ret == 0 and not png: + log.error("[Runner] 任务失败:退出码=0 但未找到输出图片") + self.manager.add_event( + job_id, + kind="status", + stage="failed", + progress_percent=100, + message="任务失败:未找到输出图片", + ) + self.manager.set_status( + job_id, + status="failed", + stage="failed", + progress_percent=100, + message="任务失败", + error="missing png artifact", + ) + return + + if ret == 0: + log.info("[Runner] ✅ 任务完成 job_id=%s 总耗时=%.2fs", job_id, elapsed) + self.manager.add_event( + job_id, + kind="status", + stage="completed", + progress_percent=100, + message="任务完成", + ) + self.manager.set_status(job_id, status="success", stage="completed", progress_percent=100, message="任务完成") + else: + log.error("[Runner] ❌ 任务失败 job_id=%s exit_code=%d 耗时=%.2fs", job_id, ret, elapsed) + self.manager.add_event( + job_id, + kind="status", + stage="failed", + progress_percent=100, + message=f"任务失败,退出码: {ret}", + ) + self.manager.set_status( + job_id, + status="failed", + stage="failed", + progress_percent=100, + message="任务失败", + error=f"script exited with code {ret}", + ) diff --git a/backend/service/schemas.py b/backend/service/schemas.py new file mode 100644 index 0000000..081afba --- /dev/null +++ b/backend/service/schemas.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +from typing import Literal + +from pydantic import BaseModel, Field + + +class JobCreateResponse(BaseModel): + job_id: str + + +class JobEvent(BaseModel): + type: Literal["log", "status"] + stage: str + progress_percent: int = Field(ge=0, le=100) + message: str + timestamp: datetime + + +class JobStatus(BaseModel): + job_id: str + status: Literal["queued", "running", "success", "failed"] + stage: str + progress_percent: int = Field(ge=0, le=100) + message: str + created_at: datetime + updated_at: datetime + artifacts: dict[str, str] + error: str = "" + + +class JobDetail(BaseModel): + status: JobStatus + recent_events: list[JobEvent] + + +class JobResult(BaseModel): + job_id: str + status: Literal["queued", "running", "success", "failed"] + image_url: str = "" + svg_url: str = "" + svg_stroke_url: str = "" + db_url: str = "" + metrics_url: str = "" + + +class WordLocation(BaseModel): + id: int + name: str + x: int + y: int + font_size: int + color: str = "" + orientation: Literal["horizontal", "vertical"] = "horizontal" + box_x: int + box_y: int + box_width: int + box_height: int + + +class JobLocationSearchResult(BaseModel): + job_id: str + query: str = "" + total: int = 0 + canvas_width: int = 0 + canvas_height: int = 0 + matches: list[WordLocation] = Field(default_factory=list) + + +class JobPaths(BaseModel): + root: Path + input_dir: Path + output_dir: Path + mask_path: Path + excel_path: Path + config_path: Path + + +class Template(BaseModel): + id: str + name: str + width: int + height: int + aspect_ratio: str + description: str = "" + + +class Asset(BaseModel): + asset_id: str + name: str + type: str + mime_type: str + width: int + height: int + file_size: int + file_url: str + job_id: str = "" + created_at: datetime + + +class DesignTemplate(BaseModel): + template_id: str + name: str + description: str = "" + document: dict + reference_asset_ids: list[str] = Field(default_factory=list) + cover_asset_id: str = "" + created_at: datetime + updated_at: datetime + + +class Project(BaseModel): + project_id: str + name: str + template_id: str + background_color: str + stickers: list[dict] = Field(default_factory=list) + created_at: datetime + updated_at: datetime + + +class ProjectSummary(BaseModel): + project_id: str + name: str + template_id: str + background_color: str + sticker_count: int = 0 + created_at: datetime + updated_at: datetime + + +class Font(BaseModel): + font_id: str + name: str + filename: str + file_size: int + created_at: datetime diff --git a/backend/service/storage.py b/backend/service/storage.py new file mode 100644 index 0000000..f1b66a6 --- /dev/null +++ b/backend/service/storage.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from pathlib import Path + +from .schemas import JobPaths + + +class Storage: + def __init__(self, base_dir: Path) -> None: + self.base_dir = base_dir + self.base_dir.mkdir(parents=True, exist_ok=True) + + def prepare_job_dirs(self, job_id: str) -> JobPaths: + root = self.base_dir / job_id + input_dir = root / "input" + output_dir = root / "output" + input_dir.mkdir(parents=True, exist_ok=True) + output_dir.mkdir(parents=True, exist_ok=True) + + return JobPaths( + root=root, + input_dir=input_dir, + output_dir=output_dir, + mask_path=input_dir / "mask.png", + excel_path=input_dir / "names.xlsx", + config_path=root / "config.json", + ) diff --git a/backend/service_design_templates/tm/tmpl_b62e127361f64206800a6a99c6a99163/template.json b/backend/service_design_templates/tm/tmpl_b62e127361f64206800a6a99c6a99163/template.json new file mode 100644 index 0000000..81e480f --- /dev/null +++ b/backend/service_design_templates/tm/tmpl_b62e127361f64206800a6a99c6a99163/template.json @@ -0,0 +1,107 @@ +{ + "template_id": "tmpl_b62e127361f64206800a6a99c6a99163", + "name": "TEST", + "description": "很好的一个设计,但是我现在要写一堆东西来让这个地方有东西看,因为这是一个测试。但是因为这是一个测试,所以这又没有什么很多好东西写,所以我现在讲了一堆东西", + "document": { + "width": 1600, + "height": 1000, + "background": "#ffffff", + "layers": [ + { + "id": "layer-default", + "name": "图层 1", + "visible": true, + "locked": false + }, + { + "id": "faeaa741-7cc5-436a-a4e3-b6cb94ea9b46", + "name": "原图遮罩", + "visible": true, + "locked": false, + "folderId": "1f67e5e5-158a-4f8e-89a9-1b2009bddc36" + }, + { + "id": "b9344479-bff1-4594-ac7c-f9c634319d10", + "name": "词云", + "visible": true, + "locked": false, + "folderId": "1f67e5e5-158a-4f8e-89a9-1b2009bddc36" + }, + { + "id": "66d70aba-bd74-49ed-8e26-c18dd84e4bb4", + "name": "图层 4", + "visible": true, + "locked": false + } + ], + "layerFolders": [ + { + "id": "1f67e5e5-158a-4f8e-89a9-1b2009bddc36", + "name": "词云文件夹 14:57", + "layerIds": [ + "faeaa741-7cc5-436a-a4e3-b6cb94ea9b46", + "b9344479-bff1-4594-ac7c-f9c634319d10" + ], + "collapsed": false + } + ], + "elements": [ + { + "id": "ff520dac-f26c-441a-a1c2-288d613372a2", + "type": "sticker", + "assetId": "asset_e12411c482094771afaacf6a2c144ec1", + "layerId": "faeaa741-7cc5-436a-a4e3-b6cb94ea9b46", + "groupId": "7a1cc966-615a-43b7-a6f8-6e57ab1ca60d", + "x": 527, + "y": 116, + "width": 874, + "height": 686, + "rotation": 0, + "opacity": 0.34 + }, + { + "id": "260edba6-4345-41a1-93cf-8cf743e9f370", + "type": "sticker", + "assetId": "asset_b8969d115c8d4e66bd259947b24b2ecd", + "layerId": "b9344479-bff1-4594-ac7c-f9c634319d10", + "groupId": "7a1cc966-615a-43b7-a6f8-6e57ab1ca60d", + "x": 527, + "y": 116, + "width": 874, + "height": 686, + "rotation": 0, + "opacity": 1 + }, + { + "id": "adc6f2dd-1531-4a7d-b43d-7541de40ca82", + "type": "sticker", + "assetId": "asset_d4d2b921dc544ee485161b843e70a061", + "layerId": "layer-default", + "x": 102, + "y": 112, + "width": 420, + "height": 280, + "rotation": 0, + "opacity": 1 + }, + { + "id": "bebd4b1e-1851-4fb9-a27b-caa09d37c22a", + "type": "sticker", + "assetId": "asset_3a667459a53d464a9f7b0860a52e3e45", + "layerId": "66d70aba-bd74-49ed-8e26-c18dd84e4bb4", + "x": 123, + "y": 522, + "width": 420, + "height": 280, + "rotation": 0, + "opacity": 1 + } + ] + }, + "reference_asset_ids": [ + "asset_5ae393dd419244ecbf0b222b87a969e3" + ], + "cover_asset_id": "asset_5ae393dd419244ecbf0b222b87a969e3", + "created_at": "2026-06-13T06:59:45.363774+00:00", + "updated_at": "2026-06-13T06:59:45.363774+00:00" +} \ No newline at end of file diff --git a/backend/start-dev.sh b/backend/start-dev.sh new file mode 100755 index 0000000..12b69cd --- /dev/null +++ b/backend/start-dev.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")" && pwd)" +VENV_DIR="$ROOT_DIR/.venv" +BACKEND_PORT="${BACKEND_PORT:-8000}" + +# ── find python ────────────────────────────────────────── +PYTHON="" +for cmd in python3.13 python3.12 python3.11 python3.10 python3.9 python3; do + if command -v "$cmd" >/dev/null 2>&1; then + PYTHON="$cmd" + break + fi +done +[[ -n "$PYTHON" ]] || { echo "[ERROR] 找不到 python3"; exit 1; } + +# verify version >= 3.9 +if ! "$PYTHON" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3,9) else 1)'; then + echo "[ERROR] 需要 Python 3.9+"; exit 1 +fi + +# ── deps check & install ───────────────────────────────── +DEPS_OK=0 +if "$PYTHON" -c " +import importlib.util, sys +mods = ('fastapi','uvicorn','pandas','PIL','numpy','matplotlib','pydantic','openpyxl') +for m in mods: + if importlib.util.find_spec(m) is None: + sys.exit(1) +" 2>/dev/null; then + DEPS_OK=1 +fi + +if [[ "$DEPS_OK" == "0" ]]; then + # try venv first + if [[ -f "$VENV_DIR/bin/python" ]] && "$VENV_DIR/bin/python" --version >/dev/null 2>&1; then + PYTHON="$VENV_DIR/bin/python" + if "$PYTHON" -c " +import importlib.util, sys +for m in ('fastapi','uvicorn','pandas','PIL','numpy','matplotlib','pydantic','openpyxl'): + if importlib.util.find_spec(m) is None: sys.exit(1) +" 2>/dev/null; then + DEPS_OK=1 + fi + fi +fi + +if [[ "$DEPS_OK" == "0" ]]; then + echo "[INFO] 安装依赖 (首次或缺失) ..." + # create venv if needed + if [[ ! -f "$VENV_DIR/bin/python" ]]; then + "$PYTHON" -m venv "$VENV_DIR" + fi + PYTHON="$VENV_DIR/bin/python" + "$PYTHON" -m pip install -q --upgrade pip + "$PYTHON" -m pip install -q fastapi uvicorn python-multipart pydantic pandas openpyxl pillow numpy matplotlib +fi + +# ── C++ extension ──────────────────────────────────────── +EXT=$($PYTHON -c 'import importlib.machinery; print(importlib.machinery.EXTENSION_SUFFIXES[0])') +EWC_SO="$ROOT_DIR/EfficientWordCloud/efficient_wordcloud/ewc_core${EXT}" +EWC_CPP="$ROOT_DIR/EfficientWordCloud/efficient_wordcloud/src/ewc_core.cpp" + +if [[ ! -f "$EWC_SO" ]] || [[ "$EWC_CPP" -nt "$EWC_SO" ]]; then + echo "[INFO] 编译 ewc_core ..." + (cd "$ROOT_DIR/EfficientWordCloud" && "$PYTHON" setup.py build_ext --inplace >/dev/null) + echo "[OK] 编译完成" +else + echo "[OK] ewc_core 就绪" +fi + +# ── ensure runtime dirs ────────────────────────────────── +mkdir -p "$ROOT_DIR/service_workspace" "$ROOT_DIR/service_assets" "$ROOT_DIR/service_projects" + +# ── port: auto-kill occupant ───────────────────────────── +if ss -tln 2>/dev/null | grep -q ":$BACKEND_PORT "; then + echo "[INFO] 端口 $BACKEND_PORT 被占用,正在释放 ..." + fuser -k "$BACKEND_PORT/tcp" >/dev/null 2>&1 || true + sleep 1 +fi + +# ── start ──────────────────────────────────────────────── +echo "[INFO] 启动后端 http://0.0.0.0:${BACKEND_PORT}" + +PYTHONPATH="$ROOT_DIR/EfficientWordCloud" \ + "$PYTHON" -m uvicorn service.app:app \ + --app-dir "$ROOT_DIR" \ + --host 0.0.0.0 --port "$BACKEND_PORT" \ + --reload & +PID=$! + +echo "[OK] PID: $PID" +echo "[OK] API: http://0.0.0.0:${BACKEND_PORT}/docs" + +trap 'echo; echo "[INFO] 停止 ..."; kill $PID 2>/dev/null || true; wait $PID 2>/dev/null || true; echo "[OK] 已停止"; exit 0' INT TERM +wait "$PID" diff --git a/backend/wordcloud_generate_hybrid.py b/backend/wordcloud_generate_hybrid.py new file mode 100644 index 0000000..92fd964 --- /dev/null +++ b/backend/wordcloud_generate_hybrid.py @@ -0,0 +1,12 @@ +from core import config +from core.pipeline import main + + +if __name__ == "__main__": + args = config.parse_args() + if args.config: + config.apply_json_config(args.config) + config.apply_cli_overrides(args) + config.finalize_runtime_config() + config.set_random_seed() + main() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..afdfe8d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +version: "3.9" + +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: wordcloud-backend + ports: + - "8000:8000" + volumes: + - wordcloud_workspace:/app/service_workspace + - wordcloud_assets:/app/service_assets + - wordcloud_projects:/app/service_projects + - wordcloud_design_templates:/app/service_design_templates + - wordcloud_fonts:/app/service_fonts + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: wordcloud-frontend + ports: + - "3000:80" + depends_on: + backend: + condition: service_healthy + restart: unless-stopped + +volumes: + wordcloud_workspace: + wordcloud_assets: + wordcloud_projects: + wordcloud_design_templates: + wordcloud_fonts: diff --git a/docs/ALGORITHM.md b/docs/ALGORITHM.md new file mode 100644 index 0000000..88721c0 --- /dev/null +++ b/docs/ALGORITHM.md @@ -0,0 +1,135 @@ +# 生成算法说明 + +本文档描述当前代码实际算法。核心代码位于 `backend/core/pipeline.py`、`backend/core/layout.py`、`backend/core/weights.py`、`backend/EfficientWordCloud/efficient_wordcloud/src/ewc_core.cpp`。 + +## 总流程 + +1. 读取 Excel 名单:`pipeline.main()` +2. 计算自动画幅:`mask.calculate_dynamic_dimensions()` +3. 生成并归一化掩膜:`mask.prepare_mask()` +4. 计算权重:`weights.extract_weights_from_df()` 和 `weights.get_stroke_complexity_batch()` +5. 估算字号范围:`weights.calculate_font_by_area_model()` +6. 小画布布局:`layout.OptimizedEfficientWordCloud.generate_from_frequencies()` +7. C++ 找可放位置:`IntegralGrid.query_direct()` +8. C++ 写入字形占用:`IntegralGrid.stamp_and_rebuild()` +9. 计算填充率并必要时重试放大 +10. 将小画布 layout 放大到高清画布并输出 PNG/SVG/DB/metrics + +## 名单和重复填充 + +`N_REPETITIONS` 决定目标词数: + +```text +total_target = len(names) * N_REPETITIONS +``` + +布局序列由 `_build_layout_sequence()` 生成。当前行为是按原名单循环追加: + +```text +[A, B, C], N_REPETITIONS=4 +=> [A, B, C, A, B, C, A, B, C, A, B, C] +``` + +所以当前队列顺序是“先填一轮名单,再填下一轮”,不是先放完同一个名字所有副本。 + +需要注意:队列顺序公平不等于最终字号完全一致。放置阶段如果某个词以目标字号找不到位置,会单独降字号继续尝试。因此后几轮词语通常比前几轮小。 + +## 权重逻辑 + +### Excel 权重 + +`WEIGHT_COL_NAME` 优先于 `WEIGHT_COL_INDEX`。有效权重必须是可转数字且大于 0。 + +`REMOVE_DUPLICATES = True` 时,同名权重取最大值。`REMOVE_DUPLICATES = False` 时,仍会按名字聚合权重映射,所以同名不同权重不会保留为不同权重实例。 + +### 笔画权重 + +`ENABLE_STROKE_WEIGHTS = True` 时,系统渲染每个字符到 64x64 灰度图,用像素占用量估算复杂度。一个名字的笔画权重取其中最复杂字符的值。 + +`ENABLE_STROKE_WEIGHTS = False` 时跳过笔画权重。若没有 Excel 权重,所有名字权重默认为 `10`。 + +## 字号范围估算 + +`calculate_font_by_area_model()` 使用可填充面积、目标填充率、packing efficiency、重复次数和名字长度估算 `min_font` / `max_font`。 + +公式思想: + +- 可填区域越大,字号越大 +- 名字越多、重复次数越高,字号越小 +- 字符越多,总占用质量越高,字号越小 +- 权重越高,在 `log1p(weight)` 归一化后获得更高面积质量 + +最终 `max_font` 基于 `min_font * SIZE_RATIO` 计算。 + +## 字号打分 + +当前 `build_log_rank_scores(..., per_word=True)` 会按姓名权重计算固定分数,然后映射到展开后的重复序列。 + +这意味着: + +- 同名副本的目标分数相同 +- 同名副本的初始目标字号相同 +- 权重相同时,所有姓名初始目标字号相同 + +但最终放置字号仍可能变小,因为放置失败时会逐词降字号。 + +## 放置策略 + +每个词的放置流程: + +1. 根据目标分数得到目标字号 +2. 随机决定横排或竖排 +3. 用 PIL 测量文字包围盒 +4. 调用 C++ `query_direct(query_h, query_w, seed)` 找位置 +5. 如果找不到,字号减 2 后重试,最低到目标字号的 40% 或 `min_font_size` +6. 放置成功后,取真实字形 bitmap 并调用 `stamp_and_rebuild()` +7. 主循环放不下的词进入 gap filling,用更小字号再尝试一次 + +## C++ 积分图搜索 + +C++ `IntegralGrid` 维护两个核心结构: + +- `canvas`:真实占用像素,`1` 表示已占用或掩膜阻挡 +- `data`:`canvas` 的积分图,用于 O(1) 判断矩形区域是否为空 + +`query_direct()` 的行为: + +1. 如果画布完全空,随机返回一个位置 +2. 先随机探测最多 16 个位置 +3. 如果未命中,扫描所有可能位置 +4. 对每个候选位置用积分图判断包围盒是否为空 +5. 从所有可放位置中随机选一个 + +`stamp_and_rebuild()` 的行为: + +1. 把真实字形像素写入 C++ `canvas` +2. 从字形左上角开始局部重建积分图 + +当前碰撞检测是“矩形找位置 + 字形像素落图”。找位置阶段要求文字包围盒矩形完全空;实际占用阶段只写入字形像素。 + +## 填充率重试 + +一次布局完成后,`compute_fill_ratio_fast()` 重新渲染 layout 并计算填充率。 + +如果填充率低于 `MIN_ACCEPT_FILL_RATIO`,管线会尝试二分放大 `size_scale`,并可通过 `FILL_RETRY_RELAX_LARGE_CAP` 放宽大字号限制。 + +如果仍无法达到目标,会保留填充率最好的 layout。 + +## 已知算法限制 + +- 当前没有真正的“整轮统一降字号”机制。重复填充虽然按轮展开,但每个词可以独立降字号。 +- `LIMIT_LARGE_FONTS` 是全局计数,不区分姓名和轮次。 +- `LAYOUT_ORDER_MODE_INTERLEAVED_RANDOM` 只改变展开序列的顺序,不改变 C++ 的空间采样策略。 +- `ENABLE_STRATIFIED_SAMPLING` 调用的 `reorder_stratified()` 对 `query_direct()` 主路径无效。 +- C++ `batch_query()` 会用矩形 `update_rect_add()` 更新,不走真实字形 `stamp_and_rebuild()`;当前 Python 主路径没有使用它。 + +## 后续公平重复填充建议 + +如果目标是“每一轮名单整体公平变小”,建议新增独立模式,而不是继续微调当前逐词降字号: + +- `REPEAT_FILL_MODE = "ROUND_ROBIN_FAIR"` +- 以轮为单位生成任务 +- 同一轮使用统一字号或统一权重映射 +- 某一轮放不下时,整轮降低字号重试 +- 失败词统一进入下一档补位队列 +- 大字号限制按姓名或轮次计数 diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..b0fbce6 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,300 @@ +# 后端 API 说明 + +本文档按 `backend/service/app.py` 和 `backend/service/schemas.py` 当前代码整理。 + +## 基础约定 + +- 默认后端地址:`http://localhost:8000` +- 请求体中上传文件使用 `multipart/form-data` +- `params` 字段是 JSON 字符串,顶层必须是对象 +- 任务状态存在内存中,服务重启后状态会丢失 + +## Jobs + +### GET `/api/health` + +返回: + +```json +{"ok": true} +``` + +### GET `/api/jobs` + +返回内存中的任务状态列表,最新任务在前。 + +### POST `/api/jobs` + +创建词云任务。 + +Form 字段: + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `name_list` | 是 | `.xlsx` 名单文件 | +| `mask_image` | IMAGE 模式必填 | `.png` / `.jpg` / `.jpeg` 掩膜 | +| `font_file` | 否 | 临时上传字体,支持后端 `_FONT_EXTENSIONS` 中的格式 | +| `font_id` | 否 | 使用已上传字体 | +| `params` | 否 | JSON 字符串,合并到任务配置 | + +字体格式当前支持 `.ttf`、`.ttc`、`.otf`。 + +`params` 示例: + +```json +{ + "MODE": "IMAGE", + "DATA_COL_INDEX": 1, + "SEED": 42, + "N_REPETITIONS": 20, + "ENABLE_STROKE_WEIGHTS": false, + "FONT_COLOR": "#000000" +} +``` + +响应: + +```json +{"job_id": "..." } +``` + +### GET `/api/jobs/{job_id}` + +返回 `JobStatus`: + +```json +{ + "job_id": "...", + "status": "queued|running|success|failed", + "stage": "...", + "progress_percent": 0, + "message": "...", + "created_at": "...", + "updated_at": "...", + "artifacts": {}, + "error": "" +} +``` + +### GET `/api/jobs/{job_id}/detail` + +返回状态和最近事件: + +```json +{ + "status": {}, + "recent_events": [] +} +``` + +### GET `/api/jobs/{job_id}/events` + +SSE 事件流。事件数据模型: + +```json +{ + "type": "log|status", + "stage": "placing_words", + "progress_percent": 65, + "message": "...", + "timestamp": "..." +} +``` + +### GET `/api/jobs/{job_id}/result` + +返回可下载产物 URL: + +```json +{ + "job_id": "...", + "status": "success", + "image_url": "/api/jobs/{job_id}/files/png", + "svg_url": "/api/jobs/{job_id}/files/svg", + "svg_stroke_url": "/api/jobs/{job_id}/files/svg_stroke", + "db_url": "/api/jobs/{job_id}/files/db", + "metrics_url": "/api/jobs/{job_id}/files/metrics" +} +``` + +### GET `/api/jobs/{job_id}/files/{kind}` + +下载产物。`kind` 支持: + +- `png` +- `svg` +- `svg_stroke` +- `db` +- `metrics` + +### GET `/api/jobs/{job_id}/locations` + +查询词语位置。查询参数: + +| 参数 | 说明 | +| --- | --- | +| `name` | 可选;为空返回全部,非空精确匹配 | + +返回: + +```json +{ + "job_id": "...", + "query": "", + "total": 1, + "canvas_width": 8000, + "canvas_height": 4000, + "matches": [ + { + "id": 1, + "name": "张三", + "x": 100, + "y": 200, + "font_size": 64, + "color": "#000000", + "orientation": "horizontal", + "box_x": 100, + "box_y": 200, + "box_width": 120, + "box_height": 50 + } + ] +} +``` + +### GET `/api/jobs/{job_id}/occupancy_mask` + +返回 PNG,显示每个已放置词语的 bounding box。 + +### GET `/api/jobs/{job_id}/custom.svg` + +按已生成 DB 重新导出 SVG。 + +查询参数: + +| 参数 | 默认 | 说明 | +| --- | --- | --- | +| `fill` | `fill` | `fill` / `dot` / `line` / `ring` | +| `stroke` | `0` | 是否描边 | +| `spacing` | `10` | 点阵间距 | +| `radius` | `2` | 点阵半径 | +| `color` | `#000000` | 输出颜色 | +| `line_spacing` | `6` | 线填充间距 | +| `line_width` | `1` | 线宽 | +| `line_angle` | `0` | 线角度 | +| `ring_radius` | `3` | 环半径 | +| `ring_width` | `1` | 环线宽 | +| `ring_spacing` | `8` | 环间距 | + +## Templates + +### GET `/api/templates` + +返回后端硬编码模板列表: + +- `poster_1x2` +- `poster_4x5` +- `poster_1x1` +- `poster_3x4` +- `poster_16x9` + +## Assets + +### POST `/api/assets` + +上传素材。Form 字段: + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `file` | 是 | 素材文件 | +| `name` | 否 | 名称 | +| `type` | 否 | 默认 `upload` | + +### POST `/api/assets/from-job/{job_id}` + +从任务产物导入素材。Form 字段: + +| 字段 | 默认 | 说明 | +| --- | --- | --- | +| `kind` | `png` | 产物类型 | +| `name` | 空 | 素材名称 | +| `type` | `wordcloud` | 素材类型 | + +### GET `/api/assets` + +列出素材。支持查询参数: + +- `type` +- `job_id` + +### GET `/api/assets/{asset_id}` + +获取素材元数据。 + +### GET `/api/assets/{asset_id}/download` + +下载素材文件。 + +### DELETE `/api/assets/{asset_id}` + +删除素材。 + +## Projects + +### POST `/api/projects` + +Form 字段: + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `name` | 是 | 工程名 | +| `template_id` | 是 | 模板 ID | +| `background_color` | 是 | `#RRGGBB` | +| `stickers` | 否 | JSON 数组字符串 | + +### GET `/api/projects` + +返回工程摘要列表。 + +### GET `/api/projects/{project_id}` + +返回工程完整数据。 + +### PATCH `/api/projects/{project_id}` + +按传入 Form 字段部分更新工程。 + +### DELETE `/api/projects/{project_id}` + +删除工程。 + +## Fonts + +### GET `/api/fonts` + +返回字体列表,包含默认字体项。 + +### POST `/api/fonts` + +上传字体。Form 字段: + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `file` | 是 | 字体文件 | +| `name` | 否 | 字体名 | + +### DELETE `/api/fonts/{font_id}` + +删除已上传字体。默认字体不能删除。 + +## 常见错误 + +| 场景 | 状态码 | detail | +| --- | --- | --- | +| `params` 不是合法 JSON | 400 | `params must be valid JSON` | +| `params` 不是对象 | 400 | `params must be JSON object` | +| `name_list` 非 `.xlsx` | 400 | `name_list must be xlsx` | +| IMAGE 模式缺少掩膜 | 400 | `mask_image is required when MODE=IMAGE` | +| job 不存在 | 404 | `job not found` | +| 产物未就绪 | 404 | `artifact not ready` | +| 文件类型未知 | 404 | `unknown artifact kind` | diff --git a/docs/CANVAS_STUDIO.md b/docs/CANVAS_STUDIO.md new file mode 100644 index 0000000..33f8c2d --- /dev/null +++ b/docs/CANVAS_STUDIO.md @@ -0,0 +1,67 @@ +# 画布与贴纸功能 + +本文档描述当前前端代码中已经实现的画布设计功能。事实来源是 `frontend/src/App.tsx`、`frontend/src/pages/CanvasStudio.tsx`、`frontend/src/components/ExportPanel.tsx`、`frontend/src/lib/stickerLibrary.ts` 和 `frontend/src/types.ts`。 + +## 页面关系 + +- 应用默认进入画布设计页。 +- 画布页顶部的“添加词云”会切换到原有词云生成页。 +- 词云生成页顶部的“返回画布”会回到画布设计页。 +- 词云生成页的导出面板保留下载 SVG/位图功能,并新增“作为贴纸导入贴纸库”。 + +## 贴纸库 + +贴纸库是前端本地能力,不依赖后端接口: + +- 存储位置:`localStorage` 的 `wordcloud-sticker-library`。 +- 数据类型:`StickerAsset`,当前支持 `svg` 和 `image` 两类,已实现入口主要使用 `svg`。 +- 用户导入 SVG:画布页左侧“贴纸”面板读取 `.svg` 文件文本,写入贴纸库,并立即插入画布。 +- 词云作为贴纸:导出面板按当前 SVG 导出参数请求 `/api/jobs/{job_id}/custom.svg`,读取返回的 SVG 文本后写入贴纸库。 +- 贴纸删除只删除本地贴纸库记录,不会删除已经导出的总图文件。 + +## 画布模型 + +画布文档保存在 `localStorage` 的 `wordcloud-canvas-document`。 + +当前模型字段: + +- `width`:画布宽度,默认 `1600`。 +- `height`:画布高度,默认 `1000`。 +- `background`:画布背景色,默认 `#ffffff`。 +- `elements`:画布元素数组。 + +当前元素类型: + +- `sticker`:引用贴纸库中的 SVG 或图片。 +- `text`:普通文字,支持内容、颜色、字号、字体、字重、位置、尺寸、旋转、透明度。 +- `rect`:矩形,支持填充、描边、描边宽度、位置、尺寸、旋转、透明度。 +- `ellipse`:椭圆,支持填充、描边、描边宽度、位置、尺寸、旋转、透明度。 +- `line`:线条,支持描边、描边宽度、位置、尺寸、旋转、透明度。 + +## 编辑行为 + +- 点击贴纸库中的贴纸会把该贴纸插入画布中央区域。 +- 画布元素可拖拽移动。 +- 选中元素后可通过右下角手柄调整大小。 +- 右侧属性面板可以精确编辑位置、尺寸、旋转、透明度和元素特有属性。 +- 右侧属性面板提供上移、下移和删除。 +- 画布面板支持修改画布宽高、背景色、导出 SVG、清空画布。 + +## SVG 导出 + +“导出总图 SVG”由前端序列化当前画布模型完成: + +- 导出文件名:`canvas-design.svg`。 +- 背景输出为一个覆盖全画布的 ``。 +- 贴纸输出为 ``,SVG 贴纸会以内联 `data:image/svg+xml` 的形式嵌入。 +- 文字输出为 ``。 +- 基础形状输出为原生 SVG 的 ``、``、``。 +- 元素的位移和旋转写入 SVG `transform`,透明度写入 `opacity`。 + +## 当前边界 + +- 贴纸库和画布文档只保存在当前浏览器本地,不会跨浏览器或跨设备同步。 +- 当前没有服务端素材库、项目文件格式或协作编辑接口。 +- SVG 导入按用户信任文件处理;编辑器预览使用图片方式加载,不在页面中直接执行 SVG 内容。 +- 当前缩放只影响编辑视图,不改变导出尺寸。 +- 当前导出目标是 SVG;没有在画布页实现 PNG/JPG 总图导出。 diff --git a/docs/CONFIG.md b/docs/CONFIG.md new file mode 100644 index 0000000..d120e07 --- /dev/null +++ b/docs/CONFIG.md @@ -0,0 +1,104 @@ +# 配置说明 + +本文档只覆盖当前代码中实际可用的配置。完整键集合以 `backend/core/config.py` 的 `KNOWN_CONFIG_KEYS` 为准。 + +## 配置来源和优先级 + +CLI 入口 `backend/wordcloud_generate_hybrid.py` 的顺序: + +1. 加载 `backend/core/config.py` 默认值 +2. 如果传 `--config`,调用 `apply_json_config()` +3. 调用 `apply_cli_overrides()` +4. 调用 `finalize_runtime_config()` 派生路径、字体、输出路径 +5. 调用 `set_random_seed()` + +服务模式下,`POST /api/jobs` 会生成任务配置并写入: + +```text +backend/service_workspace/{job_id}/config.json +``` + +之后由子进程通过 `--config` 读取。 + +## 前端参数映射 + +当前前端 `TestWorkbench.tsx` 提交的关键字段: + +| 前端字段 | 后端配置 | +| --- | --- | +| `dataColIndex` | `DATA_COL_INDEX` | +| `seed` | `SEED` | +| `weightColIndex` | `WEIGHT_COL_INDEX` | +| `fontColor` | `FONT_COLOR` | +| `nRepetitions` | `N_REPETITIONS` | +| `strokeWeights=false` | `ENABLE_STROKE_WEIGHTS=false` | + +前端只在重复次数大于 1 时传 `N_REPETITIONS`,只在关闭笔画权重时传 `ENABLE_STROKE_WEIGHTS=false`。 + +## 常用配置 + +| 键 | 默认值 | 说明 | +| --- | --- | --- | +| `MODE` | `IMAGE` | 掩膜模式,`IMAGE` 或 `TEXT` | +| `MASK_IMAGE_PATH` | `7887.png` | IMAGE 模式掩膜路径,服务模式会覆盖为上传文件路径 | +| `IMAGE_CANVAS_MODE` | `WIDTH` | 图片掩膜缩放模式 | +| `FILL_ON` | `BLACK` | `BLACK` 表示黑色可填,`WHITE` 表示白色可填 | +| `EXCEL_PATH` | `四个方向汇总录取名单.xlsx` | Excel 路径,服务模式会覆盖为上传文件路径 | +| `DATA_COL_INDEX` | `1` | 名单列,0-based | +| `WEIGHT_COL_INDEX` | `None` | 权重列,0-based | +| `WEIGHT_COL_NAME` | `None` | 权重列名,优先于列索引 | +| `REMOVE_DUPLICATES` | `False` | 是否对名单去重 | +| `ENABLE_STROKE_WEIGHTS` | `True` | 是否在无 Excel 权重时使用笔画复杂度权重 | +| `N_REPETITIONS` | `1` | 名单重复倍率 | +| `SIZE_RATIO` | `2.0` | `max_font` 相对 `min_font` 的比例 | +| `PACKING_EFFICIENCY` | `0.85` | 面积模型中的打包效率 | +| `MIN_ACCEPT_FILL_RATIO` | `0.75` | 填充率重试阈值 | +| `REQUIRE_ALL_WORDS` | `True` | 搜索阶段是否优先要求达到目标词数 | +| `USER_MIN_FONT_SIZE` | `None` | 用户覆盖最小字号 | +| `USER_MAX_FONT_SIZE` | `None` | 用户覆盖最大字号 | +| `FONT_SCALE_MIN` | `0.5` | 二分搜索缩放下限 | +| `FONT_SCALE_MAX` | `1.2` | 二分搜索缩放上限 | +| `LIMIT_LARGE_FONTS` | `True` | 是否限制大字号数量 | +| `LARGE_FONT_LIMIT_RATIO` | `0.2` | 大字号数量上限占比 | +| `LARGE_FONT_THRESHOLD_RATIO` | `0.8` | 超过有效最大字号该比例视为大字号 | +| `LARGE_FONT_CAP_RATIO` | `0.6` | 超过大字号限制后的降级比例 | +| `ENABLE_DOT_MATRIX` | `False` | 是否用点阵补偿空白区域 | +| `CANVAS_RETRY_MAX_ROUNDS` | `1` | 画布扩大重试轮数 | +| `FONT_COLOR` | `#000000` | 固定字体颜色;为空时使用调色板 | +| `SEED` | `None` | 随机种子 | +| `LAYOUT_ORDER_MODE` | `SORTED` | 展开序列排序模式 | +| `LAYOUT_SEED` | `None` | 布局顺序种子,默认继承 `SEED` | + +## JSON 别名 + +`apply_json_config()` 支持部分小写别名: + +| 别名 | 正式配置 | +| --- | --- | +| `seed` | `SEED` | +| `layout_order_mode` | `LAYOUT_ORDER_MODE` | +| `layout_seed` | `LAYOUT_SEED` | +| `excel_path` | `EXCEL_PATH` | +| `mask_image_path` | `MASK_IMAGE_PATH` | +| `output_dir` | `OUTPUT_DIR` | +| `output_prefix` | `OUTPUT_PREFIX` | +| `mode` | `MODE` | +| `work_scale` | `WORK_SCALE` | +| `weight_col_index` | `WEIGHT_COL_INDEX` | +| `weight_col_name` | `WEIGHT_COL_NAME` | +| `min_font_size` | `USER_MIN_FONT_SIZE` | +| `max_font_size` | `USER_MAX_FONT_SIZE` | +| `font_color` | `FONT_COLOR` | +| `stroke_weights` | `ENABLE_STROKE_WEIGHTS` | + +CLI 覆盖只支持 `parse_args()` 中定义的参数,不支持 `font_color` 或 `stroke_weights` CLI 参数。 + +## 类型校验 + +`CRITICAL_TYPE_CHECKS` 中的配置类型错误会直接退出。未知配置键只告警,不会失败。 + +## 路径规则 + +相对路径会以 `backend` 目录作为基准解析。输出路径会在 `finalize_runtime_config()` 中创建。 + +字体会先尝试项目字体,再尝试 `FONT_FALLBACK_PATHS`。字体不可用会直接失败。 diff --git a/docs/PROJECT_STANDARD.md b/docs/PROJECT_STANDARD.md new file mode 100644 index 0000000..5ed4bdd --- /dev/null +++ b/docs/PROJECT_STANDARD.md @@ -0,0 +1,146 @@ +# 项目标准说明 + +本文档按当前代码整理,覆盖项目边界、运行方式、输入输出和维护约定。最后核对代码时间:2026-06-09。 + +## 项目目标 + +本项目生成基于名单和掩膜的词云图。后端负责读取 Excel 名单、处理掩膜、计算权重、布局、渲染和导出;前端提供参数面板、任务提交、结果查看、查找和导出入口。 + +当前项目不是通用设计平台。`Projects`、`Assets`、`Templates` 接口存在,但主要服务于当前工作台原型和素材管理,不代表完整生产级工程系统。 + +## 目录结构 + +| 路径 | 职责 | +| --- | --- | +| `backend/wordcloud_generate_hybrid.py` | CLI 入口,加载配置后调用生成管线 | +| `backend/core/config.py` | 默认配置、JSON 配置合并、CLI 覆盖、路径和字体解析 | +| `backend/core/pipeline.py` | 生成主流程:读数据、画布、掩膜、权重、布局、渲染、DB、metrics | +| `backend/core/layout.py` | Python 布局调度、字号打分、逐词放置、SVG 导出 | +| `backend/core/weights.py` | 笔画复杂度权重、Excel 权重、面积字号模型 | +| `backend/core/mask.py` | 掩膜归一化、自动画布、边界安全 padding | +| `backend/core/render.py` | 填充率计算和点阵补偿 | +| `backend/EfficientWordCloud/` | C++ 扩展及其 Python 包装 | +| `backend/service/` | FastAPI 服务、任务管理、文件存储 | +| `frontend/src/` | React 工作台 | +| `docs/` | 标准文档入口 | + +## 运行方式 + +### 一键前后端联调 + +在项目根目录运行: + +```bash +./start-all.sh +``` + +它会: + +- 启动 `backend/start-dev.sh` +- 启动前端 `npm run dev` +- 默认后端端口为 `8000` +- 前端 Vite 端口为 `3000` + +### 单独启动后端 + +```bash +cd backend +./start-dev.sh +``` + +`start-dev.sh` 会检查 Python 依赖、必要时创建 `.venv`,并在 C++ 源码更新后重新编译 `ewc_core`。 + +### 单独启动前端 + +```bash +cd frontend +npm run dev +``` + +前端通过 Vite 代理访问后端。代理配置见 `frontend/vite.config.ts`。 + +### CLI 生成 + +```bash +cd backend +python wordcloud_generate_hybrid.py --config /path/to/config.json +``` + +CLI 配置优先级: + +1. `backend/core/config.py` 默认值 +2. JSON 配置文件 +3. CLI 参数覆盖 + +## 输入要求 + +### Excel 名单 + +服务接口只接受 `.xlsx`。默认名单列为 `DATA_COL_INDEX = 1`,也就是第 2 列,索引从 0 开始。 + +当 `REMOVE_DUPLICATES = False` 时,Excel 中重复姓名会保留。当前前端默认保留重复。 + +### 权重 + +权重来源按优先级合并: + +1. Excel 权重列:`WEIGHT_COL_NAME` 优先于 `WEIGHT_COL_INDEX` +2. 笔画复杂度权重:受 `ENABLE_STROKE_WEIGHTS` 控制 +3. 默认权重:没有权重时使用 `10` + +关闭 `ENABLE_STROKE_WEIGHTS` 且不传 Excel 权重列时,所有姓名进入均等权重。 + +### 掩膜 + +`MODE = IMAGE` 时必须提供 PNG/JPG/JPEG 掩膜。后端会将图片转灰度并以阈值 `200` 二值化。 + +`FILL_ON = BLACK` 时,黑色区域可填充;`FILL_ON = WHITE` 时,白色区域可填充。 + +## 输出产物 + +每次服务任务会创建: + +```text +backend/service_workspace/{job_id}/ + input/ + mask.png + names.xlsx + output/ + Efficient_Result_HD_AutoResize.png + Efficient_Result_HD_AutoResize.svg + Efficient_Result_HD_AutoResize_stroke.svg + wordcloud_hd.db + metrics.json + debug/ + mask_src.png + mask_hd.png + mask_small.png + occ_fast.png + config.json +``` + +产物说明: + +| 文件 | 含义 | +| --- | --- | +| PNG | 最终位图结果 | +| SVG | 填充路径 SVG | +| `_stroke.svg` | 描边 SVG,适合继续加工 | +| SQLite DB | `word_locations` 表,记录词语位置、字号、颜色、方向、包围盒 | +| metrics | 运行指标、画布尺寸、填充率、配置快照 | +| debug | 调试图,受 `SAVE_DEBUG_IMAGES` 控制 | + +## 当前限制 + +- 重复填充当前按名单轮次展开,但单个词在放置失败时会独立降字号;这会导致后几轮整体字号小于前几轮。 +- `reorder_stratified()` 当前对主路径 `query_direct()` 没有实际影响,因为 `query_direct()` 不使用 `valid_coords`。 +- C++ `Grid_query_direct` 的 GIL 释放包装没有包住实际扫描调用,性能并发上还有优化空间。 +- API 中的 Jobs 存储在进程内存,服务重启后历史任务状态会丢失;文件仍保留在 `service_workspace`。 +- 旧文档中的市场分析、路线图和性能宣传不作为当前能力承诺。 + +## 维护约定 + +- 修改算法行为时,同步更新 [ALGORITHM.md](ALGORITHM.md)。 +- 新增或删除配置项时,同步更新 [CONFIG.md](CONFIG.md)。 +- 改 HTTP 接口或响应模型时,同步更新 [API.md](API.md)。 +- 不再新增单次变更记录文档;短期变更应合并进标准文档。 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..174ffc0 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,32 @@ +# WordCloud 项目文档入口 + +本文档目录是当前项目的标准文档入口。除非某个历史文档被明确标注为“标准文档”,否则以这里列出的文档为准。 + +## 文档准则 + +- 以代码为准。文档只描述当前代码实际行为,不提前承诺未实现能力。 +- 以 `backend/core/config.py`、`backend/core/pipeline.py`、`backend/core/layout.py`、`backend/EfficientWordCloud/efficient_wordcloud/src/ewc_core.cpp` 为算法事实来源。 +- 以 `backend/service/app.py`、`backend/service/schemas.py` 为 HTTP API 事实来源。 +- 变更记录只记录历史,不作为使用说明。 + +## 标准文档 + +| 文档 | 用途 | +| --- | --- | +| [PROJECT_STANDARD.md](PROJECT_STANDARD.md) | 项目结构、运行方式、输入输出、工程约定 | +| [ALGORITHM.md](ALGORITHM.md) | 词云生成算法、重复填充、权重、C++ 碰撞搜索 | +| [CONFIG.md](CONFIG.md) | 配置项、优先级、前端参数到后端配置的映射 | +| [API.md](API.md) | FastAPI 接口、请求格式、响应结构、产物下载 | +| [CANVAS_STUDIO.md](CANVAS_STUDIO.md) | 画布设计、贴纸库、词云作为贴纸、总图 SVG 导出 | + +## 非标准/历史文档 + +以下文档可能包含历史规划、阶段性设想或已经过期的实现描述,不再作为行为依据: + +- `docs/PPT-EfficientWordCloud-详细大纲-v1.0.md` +- `docs/stroke-weights-optional.md` +- `backend/docs/*` +- `backend/README.md` +- `backend/README_zh.md` + +需要确认行为时,优先查标准文档;标准文档仍不清楚时,直接查代码。 diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..e9511e0 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,8 @@ +node_modules +dist +.git +.DS_Store +*.log +.claude +.vscode +.idea diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..619b766 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,25 @@ +# syntax=docker/dockerfile:1 + +# ── Stage 1: Build React app ─────────────────────────────────────────────── +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci + +COPY . . +RUN npm run build + +# ── Stage 2: Serve with Nginx ────────────────────────────────────────────── +FROM nginx:alpine + +# Copy custom nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Copy built static files +COPY --from=builder /app/dist /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..cfdd053 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + 词云生成工具 + + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..c04f9e1 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,36 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # allow large asset uploads (wordcloud SVG/PNG can be several MB) + client_max_body_size 100M; + + # gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml; + + # Proxy all /api requests to the backend service + location /api/ { + proxy_pass http://backend:8000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # SSE / long-running endpoints + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + + # Serve static files, fallback to index.html for SPA routes + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..7746018 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1836 @@ +{ + "name": "wordcloud-tool", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wordcloud-tool", + "version": "0.1.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.6.2", + "vite": "^5.4.8" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.29", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.29.tgz", + "integrity": "sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", + "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.362", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.362.tgz", + "integrity": "sha512-PUY2DrLvkjkUuWqq+KPL2iWshrJsZOcIojzRQ7eXFacc9dWga7MGMJAa15VbiejSZB1PAXaRLAiKgruHP8LB1w==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1d132d8 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "wordcloud-tool", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.6.2", + "vite": "^5.4.8" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..413429c --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,72 @@ +import { useLayoutEffect, useState } from 'react'; +import CanvasStudio from './pages/CanvasStudio'; +import TemplateHome from './pages/TemplateHome'; +import TestWorkbench from './pages/TestWorkbench'; +import { CanvasDocument, WordcloudStickerPayload } from './types'; +import { createDefaultDocument } from './lib/canvasDocument'; + +type AppPage = 'home' | 'canvas' | 'wordcloud'; +type ThemeMode = 'light' | 'dark' | 'system'; + +const getStoredTheme = (): ThemeMode => { + const stored = window.localStorage.getItem('wordcloud-theme'); + return stored === 'light' || stored === 'dark' || stored === 'system' ? stored : 'system'; +}; + +const getSystemTheme = () => + window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + +export default function App() { + const [page, setPage] = useState('home'); + const [themeMode] = useState(getStoredTheme); + const [systemTheme] = useState<'light' | 'dark'>(getSystemTheme); + const [initialDocument, setInitialDocument] = useState(null); + const [pendingWordcloudSticker, setPendingWordcloudSticker] = useState(null); + + useLayoutEffect(() => { + const resolvedTheme = themeMode === 'system' ? systemTheme : themeMode; + document.documentElement.dataset.theme = resolvedTheme; + document.documentElement.dataset.themeMode = themeMode; + document.documentElement.style.colorScheme = resolvedTheme; + }, [themeMode, systemTheme]); + + if (page === 'wordcloud') { + return ( + setPage('canvas')} + onImportWordcloudSticker={(payload) => { + setPendingWordcloudSticker(payload); + setPage('canvas'); + }} + /> + ); + } + + if (page === 'canvas') { + return ( + setPage('home')} + onOpenWordcloud={() => setPage('wordcloud')} + initialDocument={initialDocument} + onConsumeInitialDocument={() => setInitialDocument(null)} + pendingWordcloudSticker={pendingWordcloudSticker} + onConsumeWordcloudSticker={() => setPendingWordcloudSticker(null)} + /> + ); + } + + return ( + { + setInitialDocument(createDefaultDocument()); + setPage('canvas'); + }} + onUseTemplate={(template) => { + setInitialDocument(template.document); + setPage('canvas'); + }} + onOpenCanvas={() => setPage('canvas')} + onOpenWordcloud={() => setPage('wordcloud')} + /> + ); +} diff --git a/frontend/src/components/AdvancedPanel.tsx b/frontend/src/components/AdvancedPanel.tsx new file mode 100644 index 0000000..fdcf0fa --- /dev/null +++ b/frontend/src/components/AdvancedPanel.tsx @@ -0,0 +1,107 @@ +import { JobParams } from '../types'; + +interface AdvancedPanelProps { + params: JobParams; + onParamsChange: (partial: Partial) => void; +} + +export default function AdvancedPanel({ params, onParamsChange }: AdvancedPanelProps) { + return ( + <> +
+
高级参数
+
+
+ + {/* SEED */} +
+ + { + const v = e.target.value.trim(); + onParamsChange({ seed: v === '' ? null : parseInt(v) }); + }} + /> + 相同种子可完整复现布局结果 +
+ +
+ + {/* FONT_COLOR */} +
+ +
+ onParamsChange({ fontColor: e.target.value })} + style={{ width: 36, height: 28, border: 'none', cursor: 'pointer' }} + /> + onParamsChange({ fontColor: e.target.value })} + style={{ flex: 1 }} + /> +
+ 默认黑色,留空则使用调色板渐变 +
+ +
+ + {/* N_REPETITIONS */} +
+ + { + const v = parseInt(e.target.value); + onParamsChange({ nRepetitions: isNaN(v) || v < 1 ? 1 : Math.min(v, 20) }); + }} + /> + + 词语较少时(如仅 10 个),可增大此值(如 5~10)使每个词语重复出现多次,提升填充率与观感。默认 1(不重复)。 + +
+ +
+ + {/* STROKE_WEIGHTS */} +
+ + + 开启后,笔画复杂的字(如"鑫")会分配更大字号,笔画简单的字(如"一")字号较小。关闭则所有词语按均等权重分配字号。 + +
+ +
+ +

+ 更多高级参数(字号范围、填充策略、画布尺寸等)可通过后端 + + config.json + + 配置,详见 README 4.8 节。 +

+
+ + ); +} diff --git a/frontend/src/components/CanvasArea.tsx b/frontend/src/components/CanvasArea.tsx new file mode 100644 index 0000000..6526240 --- /dev/null +++ b/frontend/src/components/CanvasArea.tsx @@ -0,0 +1,87 @@ +import { useRef, useEffect, useState } from 'react'; +import { NameLocation, JobResult } from '../types'; +import { IconCloudy } from './Icons'; + +interface CanvasAreaProps { + maskFile: File | null; + jobResult: JobResult | null; + apiBase: string; + viewMode: '2d' | '3d'; + zoom: number; + highlightLocation: NameLocation | null; +} + +export default function CanvasArea({ + maskFile, jobResult, apiBase, viewMode, zoom, highlightLocation +}: CanvasAreaProps) { + const [maskPreviewUrl, setMaskPreviewUrl] = useState(null); + const wrapperRef = useRef(null); + + useEffect(() => { + if (!maskFile) { setMaskPreviewUrl(null); return; } + const url = URL.createObjectURL(maskFile); + setMaskPreviewUrl(url); + return () => URL.revokeObjectURL(url); + }, [maskFile]); + + // 图片 URL 处理: + // - 若已是完整 http URL 直接使用 + // - 若是 /api/... 路径则拼接 apiBase + // - 否则回退到 /api/jobs/{id}/files/png + const resolveImageUrl = (): string | null => { + if (!jobResult) return maskPreviewUrl; + const raw = jobResult.image_url; + if (!raw || !jobResult.job_id) return maskPreviewUrl; + if (raw.startsWith('http')) return raw; + return `${apiBase}${raw}`; + }; + + const displayUrl = resolveImageUrl(); + const isEmpty = !displayUrl; + + return ( +
+ {isEmpty ? ( +
+
+
导入底图或名单后可预览词云
+
+ ) : viewMode === '3d' ? ( +
+ wordcloud 3D +
+ ) : ( +
+ wordcloud + {highlightLocation && jobResult && ( + + )} +
+ )} +
+ ); +} + +function HighlightBox({ location, zoom }: { location: NameLocation; zoom: number }) { + const x = location.box_x ?? location.x; + const y = location.box_y ?? location.y; + const width = location.box_width ?? location.width ?? location.font_size ?? 24; + const height = location.box_height ?? location.height ?? location.font_size ?? 24; + + return ( +
+ ); +} diff --git a/frontend/src/components/EditPanel.tsx b/frontend/src/components/EditPanel.tsx new file mode 100644 index 0000000..a338df9 --- /dev/null +++ b/frontend/src/components/EditPanel.tsx @@ -0,0 +1,110 @@ +import { useState, useMemo } from 'react'; +import { NameEntry } from '../types'; +import { IconFolder } from './Icons'; + +interface EditPanelProps { + entries: NameEntry[]; + onEntriesChange: (entries: NameEntry[]) => void; +} + +export default function EditPanel({ entries, onEntriesChange }: EditPanelProps) { + const [filterCol, setFilterCol] = useState(''); + const [filterVal, setFilterVal] = useState(''); + + const filtered = useMemo(() => { + if (!filterVal.trim()) return entries; + const val = filterVal.trim().toLowerCase(); + const col = filterCol.trim().toLowerCase(); + return entries.filter(e => { + if (!col || col === '1' || col === '编号' || col === '组') { + if (e.group.toLowerCase().includes(val)) return true; + } + if (!col || col === '2' || col === '名字' || col === '姓名') { + if (e.name.toLowerCase().includes(val)) return true; + } + if (!col || col === '3' || col === '权重') { + if (String(e.weight).includes(val)) return true; + } + return false; + }); + }, [entries, filterCol, filterVal]); + + const updateEntry = (idx: number, field: keyof NameEntry, value: string | number) => { + const realEntry = filtered[idx]; + const realIdx = entries.findIndex(e => e === realEntry); + if (realIdx < 0) return; + const updated = [...entries]; + updated[realIdx] = { ...updated[realIdx], [field]: value }; + onEntriesChange(updated); + }; + + return ( + <> +
+
修改名单
+
+
+ {/* Filter bar */} +
+ 查找: + setFilterCol(e.target.value)} + /> + setFilterVal(e.target.value)} + /> +
+ + {/* Table */} +
+
+
编号
+
名字
+
权重
+
+
+ {filtered.length === 0 ? ( +
+ + {entries.length === 0 ? '请先导入名单' : '无匹配结果'} +
+ ) : ( + filtered.map((entry, idx) => ( +
+
+ updateEntry(idx, 'group', e.target.value)} + /> +
+
+ updateEntry(idx, 'name', e.target.value)} + /> +
+
+ updateEntry(idx, 'weight', parseInt(e.target.value) || 1)} + /> +
+
+ )) + )} +
+
+
+ + ); +} diff --git a/frontend/src/components/ExportPanel.tsx b/frontend/src/components/ExportPanel.tsx new file mode 100644 index 0000000..0e6d41b --- /dev/null +++ b/frontend/src/components/ExportPanel.tsx @@ -0,0 +1,382 @@ +import { useState } from 'react'; +import { WordcloudMaskSource, WordcloudStickerPayload } from '../types'; + +interface ExportPanelProps { + jobId: string | null; + apiBase: string; + svgUrl?: string; + imageUrl?: string; + onOpenCanvas?: () => void; + maskSource?: WordcloudMaskSource | null; + onImportWordcloudSticker?: (payload: WordcloudStickerPayload) => void; +} + +type Format = 'jpg' | 'png'; +type FillMode = 'fill' | 'dot' | 'line' | 'ring'; + +export default function ExportPanel({ + jobId, + apiBase, + svgUrl, + imageUrl, + onOpenCanvas, + maskSource, + onImportWordcloudSticker, +}: ExportPanelProps) { + const [bmpFormat, setBmpFormat] = useState('png'); + const [exportW, setExportW] = useState('1920'); + const [exportH, setExportH] = useState('1080'); + const [isSavingSticker, setIsSavingSticker] = useState(false); + + const [stroke, setStroke] = useState(false); + const [fillMode, setFillMode] = useState('fill'); + const [dotSpacing, setDotSpacing] = useState(10); + const [dotRadius, setDotRadius] = useState(2); + const [lineSpacing, setLineSpacing] = useState(6); + const [lineWidth, setLineWidth] = useState(1); + const [lineAngle, setLineAngle] = useState(0); + const [ringRadius, setRingRadius] = useState(3); + const [ringWidth, setRingWidth] = useState(1); + const [ringSpacing, setRingSpacing] = useState(8); + + const resolveUrl = (field: string | undefined, kind: string) => { + if (field) return field.startsWith('http') ? field : `${apiBase}${field}`; + if (jobId) return `${apiBase}/api/jobs/${jobId}/files/${kind}`; + return null; + }; + + const buildCustomSvgUrl = () => { + if (!jobId) return null; + const params = new URLSearchParams(); + params.set('fill', fillMode); + params.set('stroke', stroke ? '1' : '0'); + if (fillMode === 'dot') { + params.set('spacing', String(dotSpacing)); + params.set('radius', String(dotRadius)); + } + if (fillMode === 'line') { + params.set('line_spacing', String(lineSpacing)); + params.set('line_width', String(lineWidth)); + params.set('line_angle', String(lineAngle)); + } + if (fillMode === 'ring') { + params.set('ring_radius', String(ringRadius)); + params.set('ring_width', String(ringWidth)); + params.set('ring_spacing', String(ringSpacing)); + } + return `${apiBase}/api/jobs/${jobId}/custom.svg?${params}`; + }; + + const handleExportSvg = () => { + const url = buildCustomSvgUrl(); + if (!url) return; + triggerDownload(url, 'wordcloud.svg'); + }; + + const handleSaveAsSticker = async () => { + const url = buildCustomSvgUrl() || resolveUrl(svgUrl, 'svg'); + if (!url || isSavingSticker) return; + + setIsSavingSticker(true); + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`读取 SVG 失败 (${res.status})`); + const svg = await res.text(); + if (onImportWordcloudSticker) { + onImportWordcloudSticker({ svg, mask: maskSource || undefined }); + } else { + onOpenCanvas?.(); + } + } catch (error) { + const message = error instanceof Error ? error.message : '保存失败'; + alert(message); + } finally { + setIsSavingSticker(false); + } + }; + + const handleExportBitmap = () => { + const url = resolveUrl(imageUrl, 'png'); + if (!url) return; + triggerDownload(url, `wordcloud.${bmpFormat}`); + }; + + const triggerDownload = (url: string, filename: string) => { + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + }; + + const hasResult = !!jobId; + + return ( + <> +
+
导出
+
+
+ + {/* ── SVG 导出 ── */} +
矢量 SVG
+ + {/* 1. 描边 */} +
+ +
+ + {/* 2. 填充模式 */} +
+
+ + + + +
+
+ + {/* 点阵参数 */} + {fillMode === 'dot' && ( +
+
+ 间距 + setDotSpacing(parseInt(e.target.value) || 10)} + min={2} + max={100} + style={{ width: 48, padding: '4px 6px', fontSize: 11 }} + /> + px + 半径 + setDotRadius(parseInt(e.target.value) || 2)} + min={1} + max={20} + style={{ width: 48, padding: '4px 6px', fontSize: 11 }} + /> + px +
+
+ )} + + {/* 线条参数 */} + {fillMode === 'line' && ( +
+
+ 间距 + setLineSpacing(parseInt(e.target.value) || 6)} + min={2} + max={100} + style={{ width: 48, padding: '4px 6px', fontSize: 11 }} + /> + px + 粗细 + setLineWidth(parseFloat(e.target.value) || 1)} + min={0.5} + max={10} + step={0.5} + style={{ width: 48, padding: '4px 6px', fontSize: 11 }} + /> + px +
+
+ 角度 + setLineAngle(parseInt(e.target.value) || 0)} + min={0} + max={359} + style={{ width: 48, padding: '4px 6px', fontSize: 11 }} + /> + ° +
+
+ )} + + {/* 空心圆参数 */} + {fillMode === 'ring' && ( +
+
+ 半径 + setRingRadius(parseInt(e.target.value) || 3)} + min={1} + max={20} + style={{ width: 48, padding: '4px 6px', fontSize: 11 }} + /> + px + 粗细 + setRingWidth(parseFloat(e.target.value) || 1)} + min={0.5} + max={10} + step={0.5} + style={{ width: 48, padding: '4px 6px', fontSize: 11 }} + /> + px +
+
+ 间距 + setRingSpacing(parseInt(e.target.value) || 8)} + min={2} + max={100} + style={{ width: 48, padding: '4px 6px', fontSize: 11 }} + /> + px +
+
+ )} + + + + +
+ + {/* ── 位图导出 ── */} +
+
+
位图
+
+ {(['png', 'jpg'] as Format[]).map(f => ( + + ))} +
+ +
+
+ 长= + setExportW(e.target.value)} + min={1} + max={10000} + style={{ width: '100%', padding: '4px 6px', fontSize: 11 }} + /> + px +
+
+ 宽= + setExportH(e.target.value)} + min={1} + max={10000} + style={{ width: '100%', padding: '4px 6px', fontSize: 11 }} + /> + px +
+
+ +
注:为等比放大取最小值
+ + +
+
+ + {!hasResult && ( +

+ 请先生成词云后再导出 +

+ )} +
+ + ); +} diff --git a/frontend/src/components/FileUploader.tsx b/frontend/src/components/FileUploader.tsx new file mode 100644 index 0000000..bdf8902 --- /dev/null +++ b/frontend/src/components/FileUploader.tsx @@ -0,0 +1,79 @@ +import { useRef, useState, DragEvent, ChangeEvent } from 'react'; +import { IconFolder } from './Icons'; + +interface FileUploaderProps { + label: string; + accept: string; + acceptHint: string; + file: File | null; + onFileChange: (file: File | null) => void; +} + +export default function FileUploader({ label, accept, acceptHint, file, onFileChange }: FileUploaderProps) { + const inputRef = useRef(null); + const [dragging, setDragging] = useState(false); + const [error, setError] = useState(null); + + const handleDrop = (e: DragEvent) => { + e.preventDefault(); + setDragging(false); + const dropped = e.dataTransfer.files[0]; + if (!dropped) return; + validateAndSet(dropped); + }; + + const handleChange = (e: ChangeEvent) => { + const selected = e.target.files?.[0]; + if (!selected) return; + validateAndSet(selected); + }; + + const validateAndSet = (f: File) => { + const ext = '.' + f.name.split('.').pop()?.toLowerCase(); + const acceptList = accept.split(',').map(a => a.trim().toLowerCase()); + if (!acceptList.includes(ext) && !acceptList.includes(f.type)) { + setError(`不支持的文件格式:${ext}`); + return; + } + setError(null); + onFileChange(f); + }; + + const hasFile = file !== null; + const statusClass = error ? 'error' : hasFile ? 'success' : ''; + + return ( +
+
{ e.preventDefault(); setDragging(true); }} + onDragLeave={() => setDragging(false)} + onDrop={handleDrop} + onClick={() => inputRef.current?.click()} + > + e.stopPropagation()} + /> +
+ +
{label}
+
{acceptHint}
+
+
+ {(hasFile || error) && ( +
+ + {error ? error : file?.name} + + + {error ? '导入失败' : '导入成功'} + +
+ )} +
+ ); +} diff --git a/frontend/src/components/FindPanel.tsx b/frontend/src/components/FindPanel.tsx new file mode 100644 index 0000000..588b218 --- /dev/null +++ b/frontend/src/components/FindPanel.tsx @@ -0,0 +1,105 @@ +import { useState } from 'react'; +import { NameLocation } from '../types'; + +interface FindPanelProps { + jobId: string | null; + apiBase: string; + onLocate: (location: NameLocation) => void; +} + +export default function FindPanel({ jobId, apiBase, onLocate }: FindPanelProps) { + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [currentIdx, setCurrentIdx] = useState(-1); + const [loading, setLoading] = useState(false); + const [searched, setSearched] = useState(false); + + const handleFind = async () => { + if (!jobId || !query.trim()) return; + setLoading(true); + setSearched(false); + try { + const url = `${apiBase}/api/jobs/${jobId}/locations?name=${encodeURIComponent(query.trim())}`; + const res = await fetch(url); + if (!res.ok) throw new Error('请求失败'); + const data = await res.json(); + const matches: NameLocation[] = (data.matches ?? []).map((m: any) => ({ + ...m, + x: m.box_x ?? m.x, + y: m.box_y ?? m.y, + width: m.box_width ?? m.width ?? 0, + height: m.box_height ?? m.height ?? 0, + })); + setResults(matches); + setCurrentIdx(matches.length > 0 ? 0 : -1); + setSearched(true); + if (matches.length > 0) onLocate(matches[0]); + } catch { + setResults([]); + setCurrentIdx(-1); + setSearched(true); + } finally { + setLoading(false); + } + }; + + const handleNext = () => { + if (results.length === 0) return; + const next = (currentIdx + 1) % results.length; + setCurrentIdx(next); + onLocate(results[next]); + }; + + const hasNext = results.length > 1; + + return ( + <> +
+
查找
+
+
+
+ + setQuery(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleFind()} + disabled={!jobId} + /> +
+ +
+ + +
+ + {searched && ( +
+ {results.length > 0 + ? `结果:共找到 ${results.length} 个结果,点击"下一个"浏览不同位置` + : `未找到"${query}",请检查名字是否正确` + } +
+ )} + + {!jobId && ( +

请先生成词云后再查找

+ )} +
+ + ); +} diff --git a/frontend/src/components/Icons.tsx b/frontend/src/components/Icons.tsx new file mode 100644 index 0000000..c824587 --- /dev/null +++ b/frontend/src/components/Icons.tsx @@ -0,0 +1,260 @@ +import React from 'react'; + +function Icon({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +export function IconImport() { + return ( + + + + ); +} + +export function IconExport() { + return ( + + + + ); +} + +export function IconEdit() { + return ( + + + + ); +} + +export function IconFind() { + return ( + + + + + ); +} + +export function IconSettings() { + return ( + + + + + ); +} + +export function IconCloud() { + return ( + + + + ); +} + +export function IconGrid() { + return ( + + + + + + + ); +} + +export function IconLayers() { + return ( + + + + + + ); +} + +export function IconSticker() { + return ( + + + + + ); +} + +export function IconText() { + return ( + + + + ); +} + +export function IconShape() { + return ( + + + + ); +} + +export function IconCanvas() { + return ( + + + + + ); +} + +export function IconEyeOpen() { + return ( + + + + + ); +} + +export function IconEyeClosed() { + return ( + + + + + ); +} + +export function IconLock() { + return ( + + + + + ); +} + +export function IconUnlock() { + return ( + + + + + + + ); +} + +export function IconArrowUp() { + return ( + + + + ); +} + +export function IconArrowDown() { + return ( + + + + ); +} + +export function IconTrash() { + return ( + + + + ); +} + +export function IconPlus() { + return ( + + + + ); +} + +export function IconFolder() { + return ( + + + + ); +} + +export function IconClose() { + return ( + + + + ); +} + +export function IconCheckmark() { + return ( + + + + ); +} + +export function IconCross() { + return ( + + + + ); +} + +export function IconGear() { + return ( + + + + + ); +} + +export function IconCloudy() { + return ( + + + + ); +} + +export function IconRefresh() { + return ( + + + + + ); +} + +export function IconDownload() { + return ( + + + + ); +} diff --git a/frontend/src/components/ImportPanel.tsx b/frontend/src/components/ImportPanel.tsx new file mode 100644 index 0000000..51d0e9b --- /dev/null +++ b/frontend/src/components/ImportPanel.tsx @@ -0,0 +1,201 @@ +import { useRef } from 'react'; +import FileUploader from './FileUploader'; +import { IconDownload } from './Icons'; +import { JobParams, Font } from '../types'; + +interface ImportPanelProps { + maskFile: File | null; + namesFile: File | null; + params: JobParams; + fonts: Font[]; + selectedFontId: string; + onMaskChange: (file: File | null) => void; + onNamesChange: (file: File | null) => void; + onParamsChange: (partial: Partial) => void; + onFontUpload: (file: File) => void; + onFontDelete: (fontId: string) => void; + onFontSelect: (fontId: string) => void; +} + +const TEMPLATE_URL = '#'; + +export default function ImportPanel({ + maskFile, namesFile, params, fonts, selectedFontId, + onMaskChange, onNamesChange, onParamsChange, + onFontUpload, onFontDelete, onFontSelect, +}: ImportPanelProps) { + const fontInputRef = useRef(null); + + const handleFontFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + onFontUpload(file); + e.target.value = ''; + } + }; + + return ( + <> +
+
导入
+
+
+ + {/* 底图导入 */} +
+
底图(mask_image)
+ +
+ +
+ + {/* 字体选择 */} +
+
字体
+
+ +
+
+ + {selectedFontId !== '__default__' && ( + + )} +
+ + + 支持 ttf · ttc · otf,上传后永久保留 + +
+ +
+ + {/* 名单导入 */} +
+
名单表格(name_list)
+ +
+ +
+ + {/* 表格配置 */} +
+
表格配置
+ + {/* 名字列索引 DATA_COL_INDEX(0-based) */} +
+ +
+ onParamsChange({ dataColIndex: parseInt(e.target.value) || 0 })} + placeholder="1" + /> + 从 0 起,默认 1(第2列) +
+
+ + {/* 表头行号(前端预览用,不传后端) */} +
+ +
+ + onParamsChange({ headerRow: parseInt(e.target.value) || 1 })} + placeholder="1" + /> + 行为表头,数据从下一行读取 +
+
+ + {/* 权重列索引 WEIGHT_COL_INDEX(可选,0-based) */} +
+ +
+ { + const v = e.target.value.trim(); + onParamsChange({ weightColIndex: v === '' ? null : parseInt(v) }); + }} + /> + 留空时由高级设置决定是否使用笔画权重 +
+
+
+ + + + ); +} diff --git a/frontend/src/components/ProgressPanel.tsx b/frontend/src/components/ProgressPanel.tsx new file mode 100644 index 0000000..82c5ba2 --- /dev/null +++ b/frontend/src/components/ProgressPanel.tsx @@ -0,0 +1,48 @@ +import { SSEProgress } from '../types'; +import { IconCross, IconCheckmark, IconGear } from './Icons'; + +interface ProgressPanelProps { + progress: SSEProgress | null; + visible: boolean; +} + +export default function ProgressPanel({ progress, visible }: ProgressPanelProps) { + if (!visible || !progress) return null; + + const isFailed = progress.stage === '生成失败' || progress.stage === '错误'; + const isDone = progress.stage === '完成'; + + return ( +
+
+ {isFailed ? <> 生成失败 : isDone ? <> 生成完成 : <> 词云生成中…} +
+
+ {progress.stage} +
+ {!isFailed && ( +
+
+
+ )} +
+ {progress.message} +
+
+ ); +} diff --git a/frontend/src/components/ViewControls.tsx b/frontend/src/components/ViewControls.tsx new file mode 100644 index 0000000..ae3d54e --- /dev/null +++ b/frontend/src/components/ViewControls.tsx @@ -0,0 +1,33 @@ +interface ViewControlsProps { + zoom: number; + viewMode: '2d' | '3d'; + onZoomIn: () => void; + onZoomOut: () => void; + onZoomReset: () => void; + onToggleView: () => void; +} + +export default function ViewControls({ + zoom, viewMode, onZoomIn, onZoomOut, onZoomReset, onToggleView +}: ViewControlsProps) { + return ( +
+ + + +
+ + +
+ ); +} diff --git a/frontend/src/hooks/useResizablePanel.ts b/frontend/src/hooks/useResizablePanel.ts new file mode 100644 index 0000000..b392b2f --- /dev/null +++ b/frontend/src/hooks/useResizablePanel.ts @@ -0,0 +1,74 @@ +import { useState, useRef, useEffect, useCallback } from 'react'; + +export function useResizablePanel( + storageKey: string, + defaultWidth: number, + minWidth: number, + maxWidth: number, + direction: 'left' | 'right' +) { + const getInitialWidth = () => { + try { + const stored = localStorage.getItem(storageKey); + if (stored) { + const parsed = parseInt(stored, 10); + if (!Number.isNaN(parsed)) { + return Math.min(Math.max(parsed, minWidth), maxWidth); + } + } + } catch { /* ignore storage errors */ } + return defaultWidth; + }; + + const [width, setWidth] = useState(getInitialWidth); + const [isDragging, setIsDragging] = useState(false); + const widthRef = useRef(width); + const handleRef = useRef(null); + const startXRef = useRef(0); + const startWidthRef = useRef(defaultWidth); + + useEffect(() => { + widthRef.current = width; + }, [width]); + + const handlePointerMove = useCallback((event: PointerEvent) => { + const delta = direction === 'left' + ? event.clientX - startXRef.current + : startXRef.current - event.clientX; + let nextWidth = startWidthRef.current + delta; + nextWidth = Math.max(minWidth, Math.min(nextWidth, maxWidth)); + setWidth(nextWidth); + }, [direction, minWidth, maxWidth]); + + const handlePointerUp = useCallback(() => { + setIsDragging(false); + document.body.classList.remove('resizing'); + document.removeEventListener('pointermove', handlePointerMove); + document.removeEventListener('pointerup', handlePointerUp); + try { + localStorage.setItem(storageKey, String(widthRef.current)); + } catch { /* ignore storage errors */ } + }, [handlePointerMove, storageKey]); + + useEffect(() => { + const handle = handleRef.current; + if (!handle) return; + + const onPointerDown = (event: PointerEvent) => { + event.preventDefault(); + startXRef.current = event.clientX; + startWidthRef.current = widthRef.current; + setIsDragging(true); + document.body.classList.add('resizing'); + document.addEventListener('pointermove', handlePointerMove); + document.addEventListener('pointerup', handlePointerUp); + }; + + handle.addEventListener('pointerdown', onPointerDown); + return () => { + handle.removeEventListener('pointerdown', onPointerDown); + }; + }, [handlePointerMove, handlePointerUp]); + + return { width, isDragging, handleRef }; +} diff --git a/frontend/src/lib/canvasDocument.ts b/frontend/src/lib/canvasDocument.ts new file mode 100644 index 0000000..c56aa4d --- /dev/null +++ b/frontend/src/lib/canvasDocument.ts @@ -0,0 +1,138 @@ +import { + CanvasDocument, + CanvasElement, + CanvasLayer, + CanvasLayerFolder, +} from '../types'; + +export const DPI = 96; +export const MM_PER_INCH = 25.4; +export const DEFAULT_LAYER_ID = 'layer-default'; + +export function mmToPx(mm: number) { + return Math.max(1, Math.round((mm / MM_PER_INCH) * DPI)); +} + +export function pxToMm(px: number) { + return (px / DPI) * MM_PER_INCH; +} + +export function formatMm(px: number) { + return pxToMm(px).toFixed(1); +} + +export function makeId(prefix: string) { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return crypto.randomUUID(); + } + return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +export function createDefaultDocument(): CanvasDocument { + return { + width: 1600, + height: 1000, + background: '#ffffff', + layers: [createDefaultLayer()], + layerFolders: [], + elements: [], + }; +} + +export function createDefaultLayer(): CanvasLayer { + return { + id: DEFAULT_LAYER_ID, + name: '图层 1', + visible: true, + locked: false, + }; +} + +export function normalizeDocument(input: CanvasDocument): CanvasDocument { + const baseLayer = createDefaultLayer(); + const rawLayers = Array.isArray(input.layers) && input.layers.length > 0 ? input.layers : [baseLayer]; + const seen = new Set(); + const layers: CanvasLayer[] = rawLayers + .filter(layer => layer && typeof layer.id === 'string' && layer.id) + .map((layer, index) => { + const id = seen.has(layer.id) ? `${layer.id}-${index}` : layer.id; + seen.add(id); + return { + id, + name: layer.name || `图层 ${index + 1}`, + visible: layer.visible !== false, + locked: layer.locked === true, + folderId: layer.folderId || (layer as CanvasLayer & { groupId?: string }).groupId || undefined, + }; + }); + + if (layers.length === 0) layers.push(baseLayer); + if (!layers.some(layer => layer.id === DEFAULT_LAYER_ID)) { + layers.unshift(baseLayer); + } + + const layerIds = new Set(layers.map(layer => layer.id)); + const fallbackLayerId = layers[0]?.id || DEFAULT_LAYER_ID; + const elements: CanvasElement[] = (Array.isArray(input.elements) ? input.elements : []).map(element => ({ + ...element, + layerId: element.layerId && layerIds.has(element.layerId) ? element.layerId : fallbackLayerId, + groupId: typeof element.groupId === 'string' && element.groupId.trim() ? element.groupId : undefined, + })); + + const layerFolders = normalizeFolders( + input.layerFolders || (input as CanvasDocument & { layerGroups?: CanvasLayerFolder[] }).layerGroups || [], + layers, + ); + const folderIds = new Set(layerFolders.map(folder => folder.id)); + const nextLayers = layers.map(layer => { + const { groupId: _legacyGroupId, ...cleanLayer } = layer as CanvasLayer & { groupId?: string }; + return { + ...cleanLayer, + folderId: layer.folderId && folderIds.has(layer.folderId) ? layer.folderId : undefined, + }; + }); + + return { + width: Number.isFinite(input.width) ? input.width : 1600, + height: Number.isFinite(input.height) ? input.height : 1000, + background: input.background || '#ffffff', + layers: nextLayers, + layerFolders, + elements, + }; +} + +function normalizeFolders(folders: CanvasLayerFolder[], layers: CanvasLayer[]) { + const layerIds = new Set(layers.map(layer => layer.id)); + return folders + .filter(folder => folder && typeof folder.id === 'string' && folder.id) + .map(folder => ({ + id: folder.id, + name: folder.name || '未命名文件夹', + layerIds: folder.layerIds.filter(layerId => layerIds.has(layerId)), + collapsed: folder.collapsed === true, + })); +} + +export function layerIsVisible(documentModel: CanvasDocument, layerId?: string) { + const normalized = normalizeDocument(documentModel); + const layer = normalized.layers?.find(item => item.id === layerId); + return !layer || layer.visible !== false; +} + +export function layerIsLocked(documentModel: CanvasDocument, layerId?: string) { + const normalized = normalizeDocument(documentModel); + const layer = normalized.layers?.find(item => item.id === layerId); + return layer?.locked === true; +} + +export function getLayerFolder(documentModel: CanvasDocument, layerId?: string) { + const normalized = normalizeDocument(documentModel); + const layer = normalized.layers?.find(item => item.id === layerId); + if (!layer?.folderId) return null; + return normalized.layerFolders?.find(folder => folder.id === layer.folderId) || null; +} + +export function cloneDocument(documentModel: CanvasDocument): CanvasDocument { + return normalizeDocument(JSON.parse(JSON.stringify(documentModel)) as CanvasDocument); +} diff --git a/frontend/src/lib/stickerLibrary.ts b/frontend/src/lib/stickerLibrary.ts new file mode 100644 index 0000000..cb6bab6 --- /dev/null +++ b/frontend/src/lib/stickerLibrary.ts @@ -0,0 +1,156 @@ +import { StickerAsset } from '../types'; + +// --------------------------------------------------------------------------- +// Backend-based sticker library +// All sticker file content is stored on the backend via /api/assets. +// Only tiny non-content metadata (tint) is kept in localStorage. +// --------------------------------------------------------------------------- + +const STICKER_TINTS_KEY = 'wordcloud-sticker-tints'; +const STICKER_LIBRARY_EVENT = 'wordcloud-sticker-library-changed'; + +export const stickerLibraryEventName = STICKER_LIBRARY_EVENT; + +// ── tint metadata helpers ────────────────────────────────────────────────── + +function loadTints(): Record { + try { + const raw = localStorage.getItem(STICKER_TINTS_KEY); + return raw ? JSON.parse(raw) : {}; + } catch { + return {}; + } +} + +function saveTints(tints: Record) { + localStorage.setItem(STICKER_TINTS_KEY, JSON.stringify(tints)); +} + +function setTint(assetId: string, tint: string | undefined) { + const tints = loadTints(); + if (tint) { + tints[assetId] = tint; + } else { + delete tints[assetId]; + } + saveTints(tints); +} + +function removeTint(assetId: string) { + const tints = loadTints(); + delete tints[assetId]; + saveTints(tints); +} + +// ── content helpers ──────────────────────────────────────────────────────── + +function svgToBlob(svg: string): Blob { + return new Blob([svg], { type: 'image/svg+xml' }); +} + +function dataUrlToBlob(dataUrl: string): Blob { + const [header, b64] = dataUrl.split(','); + const mime = header.match(/:(.*?);/)?.[1] ?? 'application/octet-stream'; + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return new Blob([bytes], { type: mime }); +} + +function extForType(type: 'svg' | 'image', source: string): string { + if (type === 'svg') return '.svg'; + if (source.startsWith('data:image/png')) return '.png'; + if (source.startsWith('data:image/jpeg') || source.startsWith('data:image/jpg')) return '.jpg'; + return '.png'; +} + +// ── API helpers ──────────────────────────────────────────────────────────── + +interface BackendAsset { + asset_id: string; + name: string; + type: string; + mime_type: string; + file_url: string; + created_at: string; +} + +async function apiListAssets(): Promise { + const res = await fetch('/api/assets?type=sticker'); + if (!res.ok) throw new Error(`list assets failed: ${res.status}`); + return res.json(); +} + +async function apiUploadAsset( + blob: Blob, + filename: string, + name: string, +): Promise { + const form = new FormData(); + form.append('file', blob, filename); + form.append('name', name); + form.append('type', 'sticker'); + const res = await fetch('/api/assets', { method: 'POST', body: form }); + if (!res.ok) throw new Error(`upload asset failed: ${res.status}`); + return res.json(); +} + +async function apiDeleteAsset(assetId: string): Promise { + const res = await fetch(`/api/assets/${assetId}`, { method: 'DELETE' }); + if (!res.ok && res.status !== 404) throw new Error(`delete asset failed: ${res.status}`); +} + +// ── Public API ───────────────────────────────────────────────────────────── + +export async function loadStickerLibrary(): Promise { + const [assets, tints] = await Promise.all([ + apiListAssets(), + Promise.resolve(loadTints()), + ]); + return assets.map((a): StickerAsset => ({ + id: a.asset_id, + name: a.name, + type: a.mime_type === 'image/svg+xml' ? 'svg' : 'image', + source: a.file_url, + createdAt: a.created_at, + tint: tints[a.asset_id] as StickerAsset['tint'], + })); +} + +export async function addStickerAsset( + input: Omit, +): Promise { + const ext = extForType(input.type, input.source); + const blob = + input.type === 'svg' + ? svgToBlob(input.source) + : dataUrlToBlob(input.source); + const filename = `sticker${ext}`; + const asset = await apiUploadAsset(blob, filename, input.name); + if (input.tint) setTint(asset.asset_id, input.tint); + const sticker: StickerAsset = { + id: asset.asset_id, + name: asset.name, + type: input.type, + source: asset.file_url, + createdAt: asset.created_at, + tint: input.tint, + }; + window.dispatchEvent(new CustomEvent(STICKER_LIBRARY_EVENT)); + return sticker; +} + +export async function deleteStickerAsset(id: string): Promise { + await apiDeleteAsset(id); + removeTint(id); + window.dispatchEvent(new CustomEvent(STICKER_LIBRARY_EVENT)); +} + +export function svgToDataUrl(svg: string) { + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; +} + +export function assetToDataUrl(asset: StickerAsset) { + // source is now a backend URL; return it directly + return asset.source; +} diff --git a/frontend/src/lib/svgExport.ts b/frontend/src/lib/svgExport.ts new file mode 100644 index 0000000..c7c1731 --- /dev/null +++ b/frontend/src/lib/svgExport.ts @@ -0,0 +1,147 @@ +import { CanvasDocument, StickerAsset } from '../types'; +import { normalizeDocument, pxToMm } from './canvasDocument'; +import { createZip } from './zip'; + +export interface SerializeOptions { + layerIds?: string[]; + includeBackground?: boolean; +} + +async function fetchBlobAsDataUrl(url: string): Promise { + const res = await fetch(url); + if (!res.ok) throw new Error(`fetch failed: ${url} (${res.status})`); + const blob = await res.blob(); + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); +} + +async function resolveStickerHref(asset: StickerAsset): Promise { + // Legacy inline content (still supported for imported files / tests) + if (asset.type === 'svg' && asset.source.trim().startsWith(', + options: SerializeOptions = {}, +) { + const doc = normalizeDocument(documentModel); + const layerFilter = options.layerIds ? new Set(options.layerIds) : null; + const visibleLayers = new Set((doc.layers || []).filter(layer => layer.visible !== false).map(layer => layer.id)); + const widthMm = pxToMm(doc.width).toFixed(1); + const heightMm = pxToMm(doc.height).toFixed(1); + const parts = [ + ``, + ]; + + if (options.includeBackground !== false) { + parts.push(``); + } + + for (const element of doc.elements) { + const layerId = element.layerId || doc.layers?.[0]?.id; + if (layerFilter && (!layerId || !layerFilter.has(layerId))) continue; + if (!layerFilter && layerId && !visibleLayers.has(layerId)) continue; + + const transform = `translate(${element.x} ${element.y}) rotate(${element.rotation} ${element.width / 2} ${element.height / 2})`; + const opacity = Number.isFinite(element.opacity) ? element.opacity : 1; + + if (element.type === 'sticker') { + const asset = stickerById.get(element.assetId); + if (!asset) continue; + const href = await resolveStickerHref(asset); + const filter = asset.tint === 'gray' ? ' style="filter: grayscale(1)"' : ''; + parts.push(``); + continue; + } + + if (element.type === 'text') { + parts.push( + `${escapeXml(element.text)}`, + ); + continue; + } + + if (element.type === 'rect') { + parts.push(``); + continue; + } + + if (element.type === 'ellipse') { + parts.push(``); + continue; + } + + parts.push(``); + } + + parts.push(''); + return parts.join('\n'); +} + +export async function createLayerExportZip( + documentModel: CanvasDocument, + stickerById: Map, + selectedLayerIds: string[], + selectedFolderIds: string[], +) { + const doc = normalizeDocument(documentModel); + const files: { name: string; content: string }[] = []; + const used = new Map(); + + for (const layerId of selectedLayerIds) { + const layer = doc.layers?.find(item => item.id === layerId); + if (!layer) continue; + files.push({ + name: uniqueSvgName(layer.name, used), + content: await serializeDocument(doc, stickerById, { layerIds: [layer.id], includeBackground: false }), + }); + } + + for (const folderId of selectedFolderIds) { + const folder = doc.layerFolders?.find(item => item.id === folderId); + if (!folder) continue; + files.push({ + name: uniqueSvgName(folder.name, used), + content: await serializeDocument(doc, stickerById, { layerIds: folder.layerIds, includeBackground: false }), + }); + } + + files.push({ + name: uniqueSvgName('总效果', used), + content: await serializeDocument(doc, stickerById, { includeBackground: true }), + }); + + return createZip(files); +} + +function uniqueSvgName(name: string, used: Map) { + const base = sanitizeFileName(name || '未命名'); + const count = used.get(base) || 0; + used.set(base, count + 1); + return `${base}${count > 0 ? `-${count + 1}` : ''}.svg`; +} + +function sanitizeFileName(value: string) { + return value.replace(/[\\/:*?"<>|]/g, '-').replace(/\s+/g, ' ').trim().slice(0, 80) || '未命名'; +} + +export function escapeXml(value: string) { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/frontend/src/lib/templateLibrary.ts b/frontend/src/lib/templateLibrary.ts new file mode 100644 index 0000000..c83e5ca --- /dev/null +++ b/frontend/src/lib/templateLibrary.ts @@ -0,0 +1,105 @@ +import { BackendAsset, CanvasDocument, CanvasTemplate } from '../types'; +import { cloneDocument, normalizeDocument } from './canvasDocument'; + +const API_BASE = ''; + +export function templateId(template: CanvasTemplate) { + return template.template_id || template.id || ''; +} + +export function templateCreatedAt(template: CanvasTemplate) { + return template.created_at || template.createdAt || ''; +} + +export function templateUpdatedAt(template: CanvasTemplate) { + return template.updated_at || template.updatedAt || ''; +} + +export function templateReferenceIds(template: CanvasTemplate) { + return template.reference_asset_ids || template.referenceAssetIds || []; +} + +export function templateCoverId(template: CanvasTemplate) { + return template.cover_asset_id || template.coverAssetId || templateReferenceIds(template)[0] || ''; +} + +export async function listDesignTemplates(): Promise { + const res = await fetch(`${API_BASE}/api/design-templates`); + if (!res.ok) throw new Error(`读取模板库失败 (${res.status})`); + const items = (await res.json()) as CanvasTemplate[]; + return items.map(item => ({ ...item, document: normalizeDocument(item.document) })); +} + +export async function createCanvasTemplate(input: { + name: string; + description: string; + document: CanvasDocument; + referenceAssetIds?: string[]; + coverAssetId?: string; +}): Promise { + const fd = new FormData(); + fd.append('name', input.name.trim() || '未命名模板'); + fd.append('description', input.description.trim()); + fd.append('document', JSON.stringify(normalizeDocument(input.document))); + fd.append('reference_asset_ids', JSON.stringify(input.referenceAssetIds || [])); + fd.append('cover_asset_id', input.coverAssetId || input.referenceAssetIds?.[0] || ''); + const res = await fetch(`${API_BASE}/api/design-templates`, { method: 'POST', body: fd }); + if (!res.ok) throw new Error(`保存模板失败 (${res.status})`); + const template = (await res.json()) as CanvasTemplate; + return { ...template, document: normalizeDocument(template.document) }; +} + +export async function updateCanvasTemplate( + id: string, + partial: { + name?: string; + description?: string; + document?: CanvasDocument; + referenceAssetIds?: string[]; + coverAssetId?: string; + }, +): Promise { + const fd = new FormData(); + if (partial.name !== undefined) fd.append('name', partial.name.trim() || '未命名模板'); + if (partial.description !== undefined) fd.append('description', partial.description.trim()); + if (partial.document) fd.append('document', JSON.stringify(normalizeDocument(partial.document))); + if (partial.referenceAssetIds) fd.append('reference_asset_ids', JSON.stringify(partial.referenceAssetIds)); + if (partial.coverAssetId !== undefined) fd.append('cover_asset_id', partial.coverAssetId); + const res = await fetch(`${API_BASE}/api/design-templates/${id}`, { method: 'PATCH', body: fd }); + if (!res.ok) throw new Error(`更新模板失败 (${res.status})`); + const template = (await res.json()) as CanvasTemplate; + return { ...template, document: normalizeDocument(template.document) }; +} + +export async function deleteCanvasTemplate(id: string) { + const res = await fetch(`${API_BASE}/api/design-templates/${id}`, { method: 'DELETE' }); + if (!res.ok) throw new Error(`删除模板失败 (${res.status})`); +} + +export async function listAssets(type = ''): Promise { + const query = type ? `?type=${encodeURIComponent(type)}` : ''; + const res = await fetch(`${API_BASE}/api/assets${query}`); + if (!res.ok) throw new Error(`读取素材失败 (${res.status})`); + return (await res.json()) as BackendAsset[]; +} + +export async function uploadAsset(file: File, type = 'reference'): Promise { + const fd = new FormData(); + fd.append('file', file); + fd.append('name', file.name.replace(/\.(svg|png|jpe?g)$/i, '')); + fd.append('type', type); + const res = await fetch(`${API_BASE}/api/assets`, { method: 'POST', body: fd }); + if (!res.ok) throw new Error(`上传参考图失败 (${res.status})`); + return (await res.json()) as BackendAsset; +} + +export function assetUrl(assetOrPath?: BackendAsset | string) { + if (!assetOrPath) return ''; + const raw = typeof assetOrPath === 'string' ? assetOrPath : assetOrPath.file_url; + if (!raw) return ''; + return raw.startsWith('http') ? raw : `${API_BASE}${raw}`; +} + +export function duplicateDocument(document: CanvasDocument): CanvasDocument { + return cloneDocument(document); +} diff --git a/frontend/src/lib/zip.ts b/frontend/src/lib/zip.ts new file mode 100644 index 0000000..6affc45 --- /dev/null +++ b/frontend/src/lib/zip.ts @@ -0,0 +1,124 @@ +export interface ZipFileInput { + name: string; + content: string | Uint8Array; +} + +const encoder = new TextEncoder(); + +export function createZip(files: ZipFileInput[]): Blob { + const localParts: Uint8Array[] = []; + const centralParts: Uint8Array[] = []; + let offset = 0; + + files.forEach(file => { + const nameBytes = encoder.encode(file.name); + const data = typeof file.content === 'string' ? encoder.encode(file.content) : file.content; + const crc = crc32(data); + const local = concatBytes([ + u32(0x04034b50), + u16(20), + u16(0), + u16(0), + u16(0), + u16(0), + u32(crc), + u32(data.length), + u32(data.length), + u16(nameBytes.length), + u16(0), + nameBytes, + data, + ]); + localParts.push(local); + + const central = concatBytes([ + u32(0x02014b50), + u16(20), + u16(20), + u16(0), + u16(0), + u16(0), + u16(0), + u32(crc), + u32(data.length), + u32(data.length), + u16(nameBytes.length), + u16(0), + u16(0), + u16(0), + u16(0), + u32(0), + u32(offset), + nameBytes, + ]); + centralParts.push(central); + offset += local.length; + }); + + const centralOffset = offset; + const centralSize = centralParts.reduce((sum, part) => sum + part.length, 0); + const end = concatBytes([ + u32(0x06054b50), + u16(0), + u16(0), + u16(files.length), + u16(files.length), + u32(centralSize), + u32(centralOffset), + u16(0), + ]); + + const bytes = concatBytes([...localParts, ...centralParts, end]); + const buffer = new ArrayBuffer(bytes.length); + new Uint8Array(buffer).set(bytes); + return new Blob([buffer], { type: 'application/zip' }); +} + +function u16(value: number) { + const out = new Uint8Array(2); + const view = new DataView(out.buffer); + view.setUint16(0, value, true); + return out; +} + +function u32(value: number) { + const out = new Uint8Array(4); + const view = new DataView(out.buffer); + view.setUint32(0, value >>> 0, true); + return out; +} + +function concatBytes(parts: Uint8Array[]) { + const total = parts.reduce((sum, part) => sum + part.length, 0); + const out = new Uint8Array(total); + let cursor = 0; + parts.forEach(part => { + out.set(part, cursor); + cursor += part.length; + }); + return out; +} + +let crcTable: Uint32Array | null = null; + +function crc32(data: Uint8Array) { + const table = crcTable || buildCrcTable(); + let crc = 0xffffffff; + for (let i = 0; i < data.length; i += 1) { + crc = table[(crc ^ data[i]) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function buildCrcTable() { + const table = new Uint32Array(256); + for (let i = 0; i < 256; i += 1) { + let c = i; + for (let k = 0; k < 8; k += 1) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + table[i] = c >>> 0; + } + crcTable = table; + return table; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..a3fb933 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import './styles.css'; +import App from './App'; + +createRoot(document.getElementById('root')!).render( + + + +); diff --git a/frontend/src/pages/CanvasStudio.tsx b/frontend/src/pages/CanvasStudio.tsx new file mode 100644 index 0000000..98109bb --- /dev/null +++ b/frontend/src/pages/CanvasStudio.tsx @@ -0,0 +1,1263 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react'; +import { + CanvasDocument, + CanvasElement, + CanvasLayer, + CanvasLayerFolder, + ShapeCanvasElement, + StickerAsset, + WordcloudStickerPayload, +} from '../types'; +import { + addStickerAsset, + assetToDataUrl, + deleteStickerAsset, + loadStickerLibrary, + stickerLibraryEventName, +} from '../lib/stickerLibrary'; +import { + createDefaultDocument, + formatMm, + layerIsLocked, + makeId, + mmToPx, + normalizeDocument, + pxToMm, +} from '../lib/canvasDocument'; +import { createCanvasTemplate, duplicateDocument, uploadAsset } from '../lib/templateLibrary'; +import { createLayerExportZip, serializeDocument } from '../lib/svgExport'; +import { + IconGrid, + IconLayers, + IconSticker, + IconText, + IconShape, + IconExport, + IconEyeOpen, + IconEyeClosed, + IconLock, + IconUnlock, + IconArrowUp, + IconArrowDown, + IconTrash, + IconPlus, +} from '../components/Icons'; +import { useResizablePanel } from '../hooks/useResizablePanel'; + +interface CanvasStudioProps { + onOpenHome: () => void; + onOpenWordcloud: () => void; + initialDocument?: CanvasDocument | null; + onConsumeInitialDocument?: () => void; + pendingWordcloudSticker?: WordcloudStickerPayload | null; + onConsumeWordcloudSticker?: () => void; +} + +type ToolMode = 'layers' | 'sticker' | 'text' | 'shape' | 'export'; +type DragState = + | { + mode: 'move'; + id: string; + startX: number; + startY: number; + baseElements: { id: string; x: number; y: number }[]; + } + | { mode: 'resize'; id: string; groupId?: string; startX: number; startY: number; baseWidth: number; baseHeight: number; baseElements: { id: string; x: number; y: number; width: number; height: number }[] }; + +const DESIGN_KEY = 'wordcloud-canvas-document'; + +export default function CanvasStudio({ + onOpenHome, + onOpenWordcloud, + initialDocument, + onConsumeInitialDocument, + pendingWordcloudSticker, + onConsumeWordcloudSticker, +}: CanvasStudioProps) { + const [documentModel, setDocumentModel] = useState(() => loadDocument()); + const [stickers, setStickers] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [activeTool, setActiveTool] = useState('layers'); + const [activeLayerId, setActiveLayerId] = useState(() => documentModel.layers?.[0]?.id || 'layer-default'); + const [zoom, setZoom] = useState(0.55); + const [dragState, setDragState] = useState(null); + const stageRef = useRef(null); + const importingStickerRef = useRef(false); + + const leftPanel = useResizablePanel('cs-left-panel-width', 280, 180, 400, 'left'); + const rightPanel = useResizablePanel('cs-right-panel-width', 280, 180, 400, 'right'); + const normalizedDocument = useMemo(() => normalizeDocument(documentModel), [documentModel]); + const layers = normalizedDocument.layers || []; + const folders = normalizedDocument.layerFolders || []; + + const selectedElement = useMemo( + () => normalizedDocument.elements.find(item => item.id === selectedId) ?? null, + [normalizedDocument.elements, selectedId], + ); + + const stickerById = useMemo(() => { + const map = new Map(); + stickers.forEach(item => map.set(item.id, item)); + return map; + }, [stickers]); + + useEffect(() => { + const normalized = normalizeDocument(documentModel); + if (JSON.stringify(normalized) !== JSON.stringify(documentModel)) { + setDocumentModel(normalized); + } + }, []); + + useEffect(() => { + if (!initialDocument) return; + const next = normalizeDocument(duplicateDocument(initialDocument)); + setDocumentModel(next); + setSelectedId(null); + setActiveLayerId(next.layers?.[0]?.id || 'layer-default'); + onConsumeInitialDocument?.(); + }, [initialDocument]); + + useEffect(() => { + window.localStorage.setItem(DESIGN_KEY, JSON.stringify(normalizedDocument)); + }, [normalizedDocument]); + + useEffect(() => { + loadStickerLibrary().then(setStickers); + }, []); + + useEffect(() => { + const reload = () => loadStickerLibrary().then(setStickers); + window.addEventListener(stickerLibraryEventName, reload); + return () => { + window.removeEventListener(stickerLibraryEventName, reload); + }; + }, []); + + useEffect(() => { + if (layers.some(layer => layer.id === activeLayerId)) return; + setActiveLayerId(layers[0]?.id || 'layer-default'); + }, [activeLayerId, layers]); + + useEffect(() => { + if (!pendingWordcloudSticker) { + importingStickerRef.current = false; + return; + } + if (importingStickerRef.current) return; + importingStickerRef.current = true; + importWordcloudSticker(pendingWordcloudSticker); + onConsumeWordcloudSticker?.(); + }, [pendingWordcloudSticker]); + + useEffect(() => { + if (!dragState) return; + + const handleMove = (event: PointerEvent) => { + const dx = (event.clientX - dragState.startX) / zoom; + const dy = (event.clientY - dragState.startY) / zoom; + + setDocumentModel(prev => ({ + ...prev, + elements: prev.elements.map(item => { + if (dragState.mode === 'move') { + const base = dragState.baseElements.find(baseItem => baseItem.id === item.id); + if (!base) return item; + return { ...item, x: Math.round(base.x + dx), y: Math.round(base.y + dy) }; + } + // resize the dragged element + if (item.id === dragState.id) { + return { + ...item, + width: Math.max(10, Math.round(dragState.baseWidth + dx)), + height: Math.max(10, Math.round(dragState.baseHeight + dy)), + }; + } + // resize same-group companions proportionally + if (dragState.groupId && item.groupId === dragState.groupId) { + const base = dragState.baseElements.find(b => b.id === item.id); + if (base) { + const scaleW = Math.max(10, Math.round(dragState.baseWidth + dx)) / Math.max(1, dragState.baseWidth); + const scaleH = Math.max(10, Math.round(dragState.baseHeight + dy)) / Math.max(1, dragState.baseHeight); + return { + ...item, + width: Math.max(10, Math.round(base.width * scaleW)), + height: Math.max(10, Math.round(base.height * scaleH)), + }; + } + } + return item; + }), + })); + }; + + const handleUp = () => setDragState(null); + window.addEventListener('pointermove', handleMove); + window.addEventListener('pointerup', handleUp); + return () => { + window.removeEventListener('pointermove', handleMove); + window.removeEventListener('pointerup', handleUp); + }; + }, [dragState, zoom]); + + const updateDocument = (partial: Partial) => { + setDocumentModel(prev => normalizeDocument({ ...prev, ...partial })); + }; + + const updateSelected = (partial: Partial) => { + if (!selectedId || (selectedElement && layerIsLocked(normalizedDocument, selectedElement.layerId))) return; + setDocumentModel(prev => ({ + ...prev, + elements: prev.elements.map(item => + item.id === selectedId ? ({ ...item, ...partial } as CanvasElement) : item, + ), + })); + }; + + const addElement = (element: CanvasElement) => { + setDocumentModel(prev => ({ ...prev, elements: [...prev.elements, element] })); + setSelectedId(element.id); + }; + + const addStickerToCanvas = (asset: StickerAsset, options: Partial = {}) => { + const width = options.width || 420; + const height = options.height || 280; + addElement({ + id: makeId('element'), + type: 'sticker', + assetId: asset.id, + layerId: options.layerId || activeLayerId, + x: options.x ?? Math.round((normalizedDocument.width - width) / 2), + y: options.y ?? Math.round((normalizedDocument.height - height) / 2), + width, + height, + rotation: options.rotation ?? 0, + opacity: options.opacity ?? 1, + }); + }; + + const addText = () => { + addElement({ + id: makeId('element'), + type: 'text', + layerId: activeLayerId, + text: '双击编辑文字', + x: Math.round(normalizedDocument.width / 2 - 140), + y: Math.round(normalizedDocument.height / 2 - 40), + width: 280, + height: 80, + rotation: 0, + opacity: 1, + fill: '#1a1814', + fontSize: 48, + fontFamily: 'Arial, sans-serif', + fontWeight: '600', + }); + }; + + const addShape = (type: ShapeCanvasElement['type']) => { + addElement({ + id: makeId('element'), + type, + layerId: activeLayerId, + x: Math.round(normalizedDocument.width / 2 - 90), + y: Math.round(normalizedDocument.height / 2 - 60), + width: type === 'line' ? 240 : 180, + height: type === 'line' ? 32 : 120, + rotation: 0, + opacity: 1, + fill: type === 'line' ? 'transparent' : '#f0ebe3', + stroke: '#6b4f2e', + strokeWidth: 4, + }); + }; + + const deleteSelected = () => { + if (!selectedId || (selectedElement && layerIsLocked(normalizedDocument, selectedElement.layerId))) return; + setDocumentModel(prev => ({ + ...prev, + elements: prev.elements.filter(item => item.id !== selectedId), + })); + setSelectedId(null); + }; + + const bringForward = () => { + if (!selectedId) return; + setDocumentModel(prev => { + const index = prev.elements.findIndex(item => item.id === selectedId); + if (index < 0 || index === prev.elements.length - 1) return prev; + const next = [...prev.elements]; + const [item] = next.splice(index, 1); + next.splice(index + 1, 0, item); + return { ...prev, elements: next }; + }); + }; + + const sendBackward = () => { + if (!selectedId) return; + setDocumentModel(prev => { + const index = prev.elements.findIndex(item => item.id === selectedId); + if (index <= 0) return prev; + const next = [...prev.elements]; + const [item] = next.splice(index, 1); + next.splice(index - 1, 0, item); + return { ...prev, elements: next }; + }); + }; + + const handleSvgImport = async (file: File | null) => { + if (!file) return; + const text = await file.text(); + const asset = await addStickerAsset({ + name: file.name.replace(/\.svg$/i, ''), + type: 'svg', + source: text, + }); + setStickers(await loadStickerLibrary()); + addStickerToCanvas(asset); + }; + + const handlePointerDown = (event: ReactPointerEvent, element: CanvasElement) => { + event.stopPropagation(); + if (layerIsLocked(normalizedDocument, element.layerId)) return; + setSelectedId(element.id); + const baseElements = normalizedDocument.elements + .filter(item => { + if (item.id === element.id) return true; + if (!element.groupId || item.groupId !== element.groupId) return false; + return !layerIsLocked(normalizedDocument, item.layerId); + }) + .map(item => ({ id: item.id, x: item.x, y: item.y })); + setDragState({ + mode: 'move', + id: element.id, + startX: event.clientX, + startY: event.clientY, + baseElements, + }); + }; + + const handleResizePointerDown = (event: ReactPointerEvent, element: CanvasElement) => { + event.stopPropagation(); + if (layerIsLocked(normalizedDocument, element.layerId)) return; + setSelectedId(element.id); + const groupId = (element as { groupId?: string }).groupId; + const allElements = normalizedDocument.elements; + const groupBaseElements = groupId + ? allElements + .filter(el => (el as { groupId?: string }).groupId === groupId) + .map(el => ({ id: el.id, x: el.x, y: el.y, width: el.width, height: el.height })) + : []; + setDragState({ + mode: 'resize', + id: element.id, + groupId, + startX: event.clientX, + startY: event.clientY, + baseWidth: element.width, + baseHeight: element.height, + baseElements: groupBaseElements, + }); + }; + + const exportSvg = useCallback(async () => { + const svg = await serializeDocument(normalizedDocument, stickerById); + downloadBlob( + new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }), + 'canvas-design.svg', + ); + }, [normalizedDocument, stickerById]); + + const exportLayerZip = async (layerIds: string[], folderIds: string[]) => { + const blob = await createLayerExportZip(normalizedDocument, stickerById, layerIds, folderIds); + downloadBlob(blob, 'canvas-layers.zip'); + }; + + const importWordcloudSticker = async (payload: WordcloudStickerPayload) => { + const folderId = makeId('folder'); + const elementGroupId = makeId('element-group'); + const maskLayerId = makeId('layer'); + const cloudLayerId = makeId('layer'); + const width = payload.width || Math.min(720, normalizedDocument.width * 0.55); + const height = payload.height || Math.round(width * 0.62); + const x = Math.round((normalizedDocument.width - width) / 2); + const y = Math.round((normalizedDocument.height - height) / 2); + + let cloudAsset: StickerAsset; + let maskAsset: StickerAsset | null = null; + try { + cloudAsset = await addStickerAsset({ + name: `词云 ${new Date().toLocaleString('zh-CN')}`, + type: 'svg', + source: payload.svg, + }); + if (payload.mask) { + maskAsset = await addStickerAsset({ + name: `遮罩 ${payload.mask.name}`, + type: payload.mask.type === 'svg' ? 'svg' : 'image', + source: payload.mask.source, + tint: 'gray', + }); + } + } catch (e) { + alert(e instanceof Error ? e.message : '贴纸保存失败'); + return; + } + + const nextLayers: CanvasLayer[] = [ + ...(normalizedDocument.layers || []), + ...(maskAsset ? [{ id: maskLayerId, name: '原图遮罩', visible: true, locked: false, folderId }] : []), + { id: cloudLayerId, name: '词云', visible: true, locked: false, folderId }, + ]; + const folder: CanvasLayerFolder = { + id: folderId, + name: `词云文件夹 ${new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`, + layerIds: maskAsset ? [maskLayerId, cloudLayerId] : [cloudLayerId], + }; + + setStickers(await loadStickerLibrary()); + setDocumentModel(prev => ({ + ...prev, + layers: nextLayers, + layerFolders: [...(normalizedDocument.layerFolders || []), folder], + elements: [ + ...prev.elements, + ...(maskAsset + ? [{ + id: makeId('element'), + type: 'sticker' as const, + assetId: maskAsset.id, + layerId: maskLayerId, + groupId: elementGroupId, + x, + y, + width, + height, + rotation: 0, + opacity: 0.34, + }] + : []), + { + id: makeId('element'), + type: 'sticker', + assetId: cloudAsset.id, + layerId: cloudLayerId, + groupId: elementGroupId, + x, + y, + width, + height, + rotation: 0, + opacity: 1, + }, + ], + })); + setActiveTool('layers'); + setActiveLayerId(cloudLayerId); + }; + + const resetDesign = () => { + setDocumentModel(createDefaultDocument()); + setSelectedId(null); + }; + + return ( +
+ + +
+ + +
setSelectedId(null)}> +
+
+ {formatMm(normalizedDocument.width)} x {formatMm(normalizedDocument.height)} mm +
+
+ + {Math.round(zoom * 100)}% + +
+
+ +
+
+ {normalizedDocument.elements + .filter(element => layers.find(layer => layer.id === element.layerId)?.visible !== false) + .map(element => ( + updateSelected({ text } as Partial)} + /> + ))} +
+
+
+ + +
+
+ ); +} + +function LayersPanel({ + documentModel, + activeLayerId, + onActiveLayerChange, + onChange, +}: { + documentModel: CanvasDocument; + activeLayerId: string; + onActiveLayerChange: (id: string) => void; + onChange: (documentModel: CanvasDocument) => void; +}) { + const layers = documentModel.layers || []; + const folders = documentModel.layerFolders || []; + + const updateLayers = (nextLayers: CanvasLayer[], nextFolders = folders) => { + onChange(normalizeDocument({ ...documentModel, layers: nextLayers, layerFolders: nextFolders })); + }; + + const addLayer = () => { + const layer: CanvasLayer = { + id: makeId('layer'), + name: `图层 ${layers.length + 1}`, + visible: true, + locked: false, + }; + updateLayers([...layers, layer]); + onActiveLayerChange(layer.id); + }; + + const addFolder = () => { + const folder: CanvasLayerFolder = { + id: makeId('folder'), + name: `文件夹 ${folders.length + 1}`, + layerIds: [], + }; + updateLayers(layers, [...folders, folder]); + }; + + const updateLayer = (id: string, partial: Partial) => { + updateLayers(layers.map(layer => (layer.id === id ? { ...layer, ...partial } : layer))); + }; + + const moveLayer = (id: string, direction: -1 | 1) => { + const index = layers.findIndex(layer => layer.id === id); + const nextIndex = index + direction; + if (index < 0 || nextIndex < 0 || nextIndex >= layers.length) return; + const next = [...layers]; + const [item] = next.splice(index, 1); + next.splice(nextIndex, 0, item); + updateLayers(next); + }; + + const deleteLayer = (id: string) => { + if (layers.length <= 1) return; + const fallback = layers.find(layer => layer.id !== id)?.id || layers[0].id; + const nextLayers = layers.filter(layer => layer.id !== id); + const nextFolders = folders.map(folder => ({ ...folder, layerIds: folder.layerIds.filter(layerId => layerId !== id) })); + onChange(normalizeDocument({ + ...documentModel, + layers: nextLayers, + layerFolders: nextFolders, + elements: documentModel.elements.map(element => element.layerId === id ? { ...element, layerId: fallback } : element), + })); + if (activeLayerId === id) onActiveLayerChange(fallback); + }; + + const updateFolder = (id: string, partial: Partial) => { + updateLayers(layers, folders.map(folder => (folder.id === id ? { ...folder, ...partial } : folder))); + }; + + const deleteFolder = (id: string) => { + updateLayers( + layers.map(layer => layer.folderId === id ? { ...layer, folderId: undefined } : layer), + folders.filter(folder => folder.id !== id), + ); + }; + + const setLayerFolder = (layerId: string, folderId: string) => { + const nextLayers = layers.map(layer => layer.id === layerId ? { ...layer, folderId: folderId || undefined } : layer); + const nextFolders = folders.map(folder => ({ + ...folder, + layerIds: folder.id === folderId + ? Array.from(new Set([...folder.layerIds, layerId])) + : folder.layerIds.filter(id => id !== layerId), + })); + updateLayers(nextLayers, nextFolders); + }; + + return ( + <> +
+ + +
+ +
+
+ {folders.map(folder => ( +
+ updateFolder(folder.id, { name: event.target.value })} + /> + {folder.layerIds.length} 层 + +
+ ))} + {layers.slice().reverse().map(layer => ( +
+
+ + + onActiveLayerChange(layer.id)} + onChange={event => updateLayer(layer.id, { name: event.target.value })} + /> +
+
+ +
+ + + +
+
+
+ ))} +
+ + ); +} + +function StickerToolPanel({ + stickers, + onImportSvg, + onAddSticker, + onDeleteSticker, +}: { + stickers: StickerAsset[]; + onImportSvg: (file: File | null) => void; + onAddSticker: (asset: StickerAsset) => void; + onDeleteSticker: (id: string) => void; +}) { + return ( + <> +
+ { + onImportSvg(event.target.files?.[0] ?? null); + event.currentTarget.value = ''; + }} + /> + +
导入 SVG 贴纸
+
+ +
+
+ {stickers.length === 0 ? ( +
贴纸库为空
+ ) : stickers.map(asset => ( +
+ +
+ {asset.name} + +
+
+ ))} +
+ + ); +} + +function CanvasExportPanel({ + documentModel, + stickerById, + onUpdateDocument, + onExportSvg, + onExportLayerZip, + onReset, +}: { + documentModel: CanvasDocument; + stickerById: Map; + onUpdateDocument: (partial: Partial) => void; + onExportSvg: () => void; + onExportLayerZip: (layerIds: string[], folderIds: string[]) => void; + onReset: () => void; +}) { + const [selectedLayerIds, setSelectedLayerIds] = useState([]); + const [selectedFolderIds, setSelectedFolderIds] = useState([]); + const [savingTemplate, setSavingTemplate] = useState(false); + const [templateName, setTemplateName] = useState(''); + const [templateDescription, setTemplateDescription] = useState(''); + const [referenceFiles, setReferenceFiles] = useState([]); + const layers = documentModel.layers || []; + const folders = documentModel.layerFolders || []; + + const saveTemplate = async () => { + if (savingTemplate) return; + setSavingTemplate(true); + try { + const refs = []; + for (const file of referenceFiles) { + refs.push(await uploadAsset(file, 'reference')); + } + await createCanvasTemplate({ + name: templateName, + description: templateDescription, + document: documentModel, + referenceAssetIds: refs.map(asset => asset.asset_id), + coverAssetId: refs[0]?.asset_id, + }); + setTemplateName(''); + setTemplateDescription(''); + setReferenceFiles([]); + alert('模板已保存到首页'); + } catch (error) { + alert(error instanceof Error ? error.message : '模板保存失败'); + } finally { + setSavingTemplate(false); + } + }; + + return ( + <> +
+
+ + onUpdateDocument({ width: mmToPx(parseFloat(event.target.value) || 10) })} + /> + mm +
+
+ + onUpdateDocument({ height: mmToPx(parseFloat(event.target.value) || 10) })} + /> + mm +
+
+
+ + onUpdateDocument({ background: event.target.value })} + /> +
+ + +
+
分层打包导出
+
+ {layers.map(layer => ( + + ))} + {folders.map(folder => ( + + ))} +
+ + +
+
保存到模板库
+
+ + setTemplateName(event.target.value)} /> +
+
+ +