Initial project baseline

This commit is contained in:
2026-07-04 02:40:45 +08:00
commit d5d8caef2f
86 changed files with 15590 additions and 0 deletions
+71
View File
@@ -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
+84
View File
@@ -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 双重触发 effectpending 状态未提交就被二次消费 | 增加 `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` 作为原始生成工作台继续可用,新增强的模板中心与画布工作室与其并行。
+184
View File
@@ -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
# 方式 Agit 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`。
+40
View File
@@ -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
+13
View File
@@ -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 大纲或一次性变更记录作为行为依据。
+23
View File
@@ -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
+65
View File
@@ -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
+36
View File
@@ -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"]
+66
View File
@@ -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+** |
+84
View File
@@ -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 测量开销**:采用预取窗口控制并发规模。
- **锁开销**:只在必要时加锁,读操作使用共享锁。
- **一致性**:聚合结果按最小索引保证行为与原排序一致。
+61
View File
@@ -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 默认字体,但测量与渲染效果可能不同。
@@ -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
@@ -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))
@@ -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 <Python.h>
#include <vector>
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <cstdint>
#include <utility>
#include <mutex>
#include <shared_mutex>
#include <thread>
#include <future>
#include <atomic>
#include <random>
#include <functional>
#include <numeric>
// ==========================================
// Thread Pool (avoid per-query thread creation)
// ==========================================
class ThreadPool {
std::vector<std::thread> workers;
std::vector<std::function<void()>> tasks;
std::mutex mtx;
std::condition_variable cv;
std::atomic<bool> stop{false};
public:
ThreadPool(unsigned int n) {
for (unsigned int i = 0; i < n; ++i) {
workers.emplace_back([this] {
while (true) {
std::function<void()> task;
{
std::unique_lock<std::mutex> 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<class F>
std::future<typename std::invoke_result<F>::type> submit(F&& f) {
using R = typename std::invoke_result<F>::type;
auto p = std::make_shared<std::promise<R>>();
auto fut = p->get_future();
{
std::lock_guard<std::mutex> lock(mtx);
if constexpr (std::is_void_v<R>) {
tasks.push_back([p, f=std::forward<F>(f)]() mutable {
try { f(); p->set_value(); }
catch (...) { p->set_exception(std::current_exception()); }
});
} else {
tasks.push_back([p, f=std::forward<F>(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<std::pair<int, int>> valid_coords;
// Lazy update buffers
std::vector<int32_t> diff;
std::vector<Rect> 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<int, int>& a, const std::pair<int, int>& 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<std::shared_mutex> 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<size_t> 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<size_t> 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<std::pair<int, int>> 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<int32_t> delta_prev_row(width, 0);
std::vector<int32_t> delta_row(width, 0);
std::vector<uint32_t> 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<uint8_t> 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<int,int> 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<int>(0, max_row)(rng);
int x = std::uniform_int_distribution<int>(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<int>(0, max_row)(rng);
int x = std::uniform_int_distribution<int>(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<uint64_t>(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<uint64_t> chunk_counts(nt, 0);
std::vector<int> 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<std::future<uint64_t>> 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<uint64_t>(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<DirectResult> batch_query(const std::vector<std::tuple<int,int,uint32_t>>& queries) {
std::vector<DirectResult> 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<bool, std::pair<int, int>> find_spot_parallel(int box_h, int box_w, int step) {
{
std::shared_lock<std::shared_mutex> lock(mutex_);
if (dirty_count > 0 && dirty_count >= rebuild_interval) {
lock.unlock();
std::unique_lock<std::shared_mutex> 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<std::future<SearchResult>> 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<std::tuple<int,int,uint32_t>> 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;
}
@@ -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
@@ -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}
@@ -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 (01).
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 (01)
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'<svg width="{out_w}" height="{out_h}" xmlns="http://www.w3.org/2000/svg">',
f'<style>text{{font-family:{font_family};font-weight:{font_weight};'
f'font-style:{font_style};}}</style>',
]
if self.background_color is not None:
lines.append(
f'<rect width="100%" height="100%" style="fill:{self.background_color}"/>'
)
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'<text transform="{transform}" font-size="{scaled_size}" '
f'style="fill:{color}">{saxutils.escape(word)}</text>'
)
lines.append("</svg>")
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
+28
View File
@@ -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',
)
+21
View File
@@ -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.
+21
View File
@@ -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`
+21
View File
@@ -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`
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
"""Core pipeline modules for the wordcloud generator."""
+396
View File
@@ -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}")
+15
View File
@@ -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"]
+25
View File
@@ -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]
+560
View File
@@ -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'<svg width="{self.width}" height="{self.height}" viewBox="0 0 {self.width} {self.height}" '
f'xmlns="http://www.w3.org/2000/svg">\n'
)
f.write(f'<rect width="100%" height="100%" fill="{background}"/>\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'<path d="{path}" transform="translate({tx:.3f} {ty:.3f}) scale(1 -1)" fill="{color}"/>\n')
f.write("</svg>\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'<svg width="{self.width}" height="{self.height}" viewBox="0 0 {self.width} {self.height}" '
f'xmlns="http://www.w3.org/2000/svg">\n'
)
f.write(f'<rect width="100%" height="100%" fill="none"/>\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'<path d="{path}" transform="translate({tx:.3f} {ty:.3f}) scale(1 -1)" '
f'fill="none" stroke="{stroke_color}" stroke-width="{stroke_width}" '
f'stroke-linejoin="round" stroke-linecap="round"/>\n'
)
f.write("</svg>\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'<svg width="{self.width}" height="{self.height}" viewBox="0 0 {self.width} {self.height}" '
f'xmlns="http://www.w3.org/2000/svg">\n'
)
f.write(f'<rect width="100%" height="100%" fill="none"/>\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'<circle cx="{cx}" cy="{cy}" r="{dot_radius}" '
f'fill="{dot_color}" stroke="none"/>\n'
)
dot_count += 1
f.write("</svg>\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'<svg width="{self.width}" height="{self.height}" viewBox="0 0 {self.width} {self.height}" '
f'xmlns="http://www.w3.org/2000/svg">\n'
)
f.write(f'<rect width="100%" height="100%" fill="none"/>\n')
if fill_mode == "dot":
# 点阵模式:用 SVG pattern 平铺圆点 + clipPath 裁剪到文字形状
f.write('<defs>\n')
f.write(f' <pattern id="dot-pat" x="0" y="0" width="{dot_spacing}" height="{dot_spacing}" patternUnits="userSpaceOnUse">\n')
half = dot_spacing / 2
f.write(f' <circle cx="{half}" cy="{half}" r="{dot_radius}" fill="{color}"/>\n')
f.write(' </pattern>\n')
self._write_text_clip(f, text_paths)
f.write('</defs>\n')
f.write(f'<rect width="{self.width}" height="{self.height}" fill="url(#dot-pat)" clip-path="url(#text-clip)"/>\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'<path d="{" ".join(path_parts)}" fill="none" stroke="{color}" stroke-width="{sw}" stroke-linecap="round"/>\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'<path d="{" ".join(circle_parts)}" fill="none" stroke="{color}" stroke-width="{sw}"/>\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'<path d="{path}" transform="translate({tx:.3f} {ty:.3f}) scale(1 -1)" fill="{fill_attr}" {stroke_attr}/>\n')
f.write("</svg>\n")
@staticmethod
def _write_text_clip(f, text_paths):
"""将文字路径写入 <clipPath id="text-clip">(调用方负责 <defs> 开闭)。"""
f.write(' <clipPath id="text-clip">\n')
for path, tx, ty in text_paths:
f.write(f' <path d="{path}" transform="translate({tx:.3f} {ty:.3f}) scale(1 -1)"/>\n')
f.write(' </clipPath>\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)
+145
View File
@@ -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
+14
View File
@@ -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")
+586
View File
@@ -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)
+47
View File
@@ -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
+101
View File
@@ -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
+10
View File
@@ -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`
@@ -0,0 +1,9 @@
# 已废弃:性能与市场对比
本文档是历史分析材料,不再作为当前项目能力、性能或路线图依据。
当前代码行为请以标准文档和源码为准:
- [../../docs/README.md](../../docs/README.md)
- [../../docs/PROJECT_STANDARD.md](../../docs/PROJECT_STANDARD.md)
- [../../docs/ALGORITHM.md](../../docs/ALGORITHM.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 文档中按当前代码整理。
+11
View File
@@ -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)
+9
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+119
View File
@@ -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)
+43
View File
@@ -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)
+165
View File
@@ -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}",
)
+139
View File
@@ -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
+27
View File
@@ -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",
)
@@ -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"
}
+97
View File
@@ -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"
+12
View File
@@ -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()
+42
View File
@@ -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:
+135
View File
@@ -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"`
- 以轮为单位生成任务
- 同一轮使用统一字号或统一权重映射
- 某一轮放不下时,整轮降低字号重试
- 失败词统一进入下一档补位队列
- 大字号限制按姓名或轮次计数
+300
View File
@@ -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` |
+67
View File
@@ -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`
- 背景输出为一个覆盖全画布的 `<rect>`
- 贴纸输出为 `<image>`SVG 贴纸会以内联 `data:image/svg+xml` 的形式嵌入。
- 文字输出为 `<text>`
- 基础形状输出为原生 SVG 的 `<rect>``<ellipse>``<line>`
- 元素的位移和旋转写入 SVG `transform`,透明度写入 `opacity`
## 当前边界
- 贴纸库和画布文档只保存在当前浏览器本地,不会跨浏览器或跨设备同步。
- 当前没有服务端素材库、项目文件格式或协作编辑接口。
- SVG 导入按用户信任文件处理;编辑器预览使用图片方式加载,不在页面中直接执行 SVG 内容。
- 当前缩放只影响编辑视图,不改变导出尺寸。
- 当前导出目标是 SVG;没有在画布页实现 PNG/JPG 总图导出。
+104
View File
@@ -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`。字体不可用会直接失败。
+146
View File
@@ -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)。
- 不再新增单次变更记录文档;短期变更应合并进标准文档。
+32
View File
@@ -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`
需要确认行为时,优先查标准文档;标准文档仍不清楚时,直接查代码。
+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
.git
.DS_Store
*.log
.claude
.vscode
.idea
+25
View File
@@ -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;"]
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>词云生成工具</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>☁️</text></svg>" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+36
View File
@@ -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;
}
}
+1836
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -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"
}
}
+72
View File
@@ -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<AppPage>('home');
const [themeMode] = useState<ThemeMode>(getStoredTheme);
const [systemTheme] = useState<'light' | 'dark'>(getSystemTheme);
const [initialDocument, setInitialDocument] = useState<CanvasDocument | null>(null);
const [pendingWordcloudSticker, setPendingWordcloudSticker] = useState<WordcloudStickerPayload | null>(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 (
<TestWorkbench
onOpenCanvas={() => setPage('canvas')}
onImportWordcloudSticker={(payload) => {
setPendingWordcloudSticker(payload);
setPage('canvas');
}}
/>
);
}
if (page === 'canvas') {
return (
<CanvasStudio
onOpenHome={() => setPage('home')}
onOpenWordcloud={() => setPage('wordcloud')}
initialDocument={initialDocument}
onConsumeInitialDocument={() => setInitialDocument(null)}
pendingWordcloudSticker={pendingWordcloudSticker}
onConsumeWordcloudSticker={() => setPendingWordcloudSticker(null)}
/>
);
}
return (
<TemplateHome
onCreateBlank={() => {
setInitialDocument(createDefaultDocument());
setPage('canvas');
}}
onUseTemplate={(template) => {
setInitialDocument(template.document);
setPage('canvas');
}}
onOpenCanvas={() => setPage('canvas')}
onOpenWordcloud={() => setPage('wordcloud')}
/>
);
}
+107
View File
@@ -0,0 +1,107 @@
import { JobParams } from '../types';
interface AdvancedPanelProps {
params: JobParams;
onParamsChange: (partial: Partial<JobParams>) => void;
}
export default function AdvancedPanel({ params, onParamsChange }: AdvancedPanelProps) {
return (
<>
<div className="panel-header">
<div className="panel-title"></div>
</div>
<div className="panel-body">
{/* SEED */}
<div className="form-group">
<label className="form-label">SEED</label>
<input
className="form-input"
type="number"
min={0}
value={params.seed ?? ''}
placeholder="留空=不固定种子"
onChange={e => {
const v = e.target.value.trim();
onParamsChange({ seed: v === '' ? null : parseInt(v) });
}}
/>
<span className="text-xs text-muted"></span>
</div>
<div className="section-divider" />
{/* FONT_COLOR */}
<div className="form-group">
<label className="form-label"></label>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="color"
value={params.fontColor || '#000000'}
onChange={e => onParamsChange({ fontColor: e.target.value })}
style={{ width: 36, height: 28, border: 'none', cursor: 'pointer' }}
/>
<input
className="form-input"
type="text"
value={params.fontColor || '#000000'}
placeholder="#000000"
onChange={e => onParamsChange({ fontColor: e.target.value })}
style={{ flex: 1 }}
/>
</div>
<span className="text-xs text-muted">使</span>
</div>
<div className="section-divider" />
{/* N_REPETITIONS */}
<div className="form-group">
<label className="form-label"></label>
<input
className="form-input"
type="number"
min={1}
max={20}
value={params.nRepetitions}
onChange={e => {
const v = parseInt(e.target.value);
onParamsChange({ nRepetitions: isNaN(v) || v < 1 ? 1 : Math.min(v, 20) });
}}
/>
<span className="text-xs text-muted">
10 5~10使 1
</span>
</div>
<div className="section-divider" />
{/* STROKE_WEIGHTS */}
<div className="form-group">
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
checked={params.strokeWeights}
onChange={e => onParamsChange({ strokeWeights: e.target.checked })}
/>
</label>
<span className="text-xs text-muted">
"鑫""一"
</span>
</div>
<div className="section-divider" />
<p className="text-xs text-muted" style={{ lineHeight: 1.6 }}>
<code style={{ fontSize: 10, background: 'var(--bg)', padding: '0 3px', borderRadius: 2 }}>
config.json
</code>
README 4.8
</p>
</div>
</>
);
}
+87
View File
@@ -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<string | null>(null);
const wrapperRef = useRef<HTMLDivElement>(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 (
<div className="canvas-inner" ref={wrapperRef}>
{isEmpty ? (
<div className="canvas-placeholder">
<div className="canvas-placeholder-icon"><IconCloudy /></div>
<div className="canvas-placeholder-text"></div>
</div>
) : viewMode === '3d' ? (
<div className="view-3d-container">
<img src={displayUrl} alt="wordcloud 3D" className="view-3d-image" />
</div>
) : (
<div className="canvas-image-wrapper">
<img
src={displayUrl}
alt="wordcloud"
className="canvas-image"
style={{ transform: `scale(${zoom})` }}
/>
{highlightLocation && jobResult && (
<HighlightBox location={highlightLocation} zoom={zoom} />
)}
</div>
)}
</div>
);
}
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 (
<div
className="highlight-box"
style={{
left: x * zoom,
top: y * zoom,
width: width * zoom,
height: height * zoom,
}}
/>
);
}
+110
View File
@@ -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 (
<>
<div className="panel-header">
<div className="panel-title"></div>
</div>
<div className="panel-body" style={{ padding: '10px 10px 0' }}>
{/* Filter bar */}
<div className="flex-row" style={{ gap: 4, marginBottom: 8 }}>
<span className="text-xs text-muted" style={{ flexShrink: 0 }}></span>
<input
className="form-input"
style={{ flex: '0 0 56px', padding: '4px 6px', fontSize: 11 }}
placeholder="列"
value={filterCol}
onChange={e => setFilterCol(e.target.value)}
/>
<input
className="form-input"
style={{ flex: 1, padding: '4px 6px', fontSize: 11 }}
placeholder="值"
value={filterVal}
onChange={e => setFilterVal(e.target.value)}
/>
</div>
{/* Table */}
<div className="data-table-wrapper" style={{ flex: 1, minHeight: 0, marginBottom: 10 }}>
<div className="data-table-header">
<div className="data-table-head-cell"></div>
<div className="data-table-head-cell"></div>
<div className="data-table-head-cell"></div>
</div>
<div className="data-table-body">
{filtered.length === 0 ? (
<div className="table-empty">
<span style={{ fontSize: 24 }}><IconFolder /></span>
<span>{entries.length === 0 ? '请先导入名单' : '无匹配结果'}</span>
</div>
) : (
filtered.map((entry, idx) => (
<div className="data-table-row" key={idx}>
<div className="data-table-cell">
<input
value={entry.group}
onChange={e => updateEntry(idx, 'group', e.target.value)}
/>
</div>
<div className="data-table-cell">
<input
value={entry.name}
onChange={e => updateEntry(idx, 'name', e.target.value)}
/>
</div>
<div className="data-table-cell">
<input
type="number"
min={1}
value={entry.weight}
onChange={e => updateEntry(idx, 'weight', parseInt(e.target.value) || 1)}
/>
</div>
</div>
))
)}
</div>
</div>
</div>
</>
);
}
+382
View File
@@ -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<Format>('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<FillMode>('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 (
<>
<div className="panel-header">
<div className="panel-title"></div>
</div>
<div className="panel-body">
{/* ── SVG 导出 ── */}
<div className="export-col-title"> SVG</div>
{/* 1. 描边 */}
<div className="form-group">
<label className="radio-label" style={{ cursor: 'pointer' }}>
<input
type="checkbox"
checked={stroke}
onChange={e => setStroke(e.target.checked)}
style={{ marginRight: 6 }}
/>
</label>
</div>
{/* 2. 填充模式 */}
<div className="form-group">
<div className="radio-group" style={{ flexDirection: 'column', gap: 2 }}>
<label className="radio-label">
<input
type="radio"
name="fill-mode"
checked={fillMode === 'fill'}
onChange={() => setFillMode('fill')}
/>
fill
</label>
<label className="radio-label">
<input
type="radio"
name="fill-mode"
checked={fillMode === 'dot'}
onChange={() => setFillMode('dot')}
/>
dot
</label>
<label className="radio-label">
<input
type="radio"
name="fill-mode"
checked={fillMode === 'line'}
onChange={() => setFillMode('line')}
/>
线line
</label>
<label className="radio-label">
<input
type="radio"
name="fill-mode"
checked={fillMode === 'ring'}
onChange={() => setFillMode('ring')}
/>
ring
</label>
</div>
</div>
{/* 点阵参数 */}
{fillMode === 'dot' && (
<div className="form-group" style={{ paddingLeft: 20 }}>
<div className="flex-row" style={{ gap: 4 }}>
<span className="text-xs text-muted" style={{ flexShrink: 0 }}></span>
<input
className="form-input"
type="number"
value={dotSpacing}
onChange={e => setDotSpacing(parseInt(e.target.value) || 10)}
min={2}
max={100}
style={{ width: 48, padding: '4px 6px', fontSize: 11 }}
/>
<span className="text-xs text-muted">px</span>
<span className="text-xs text-muted" style={{ flexShrink: 0, marginLeft: 6 }}></span>
<input
className="form-input"
type="number"
value={dotRadius}
onChange={e => setDotRadius(parseInt(e.target.value) || 2)}
min={1}
max={20}
style={{ width: 48, padding: '4px 6px', fontSize: 11 }}
/>
<span className="text-xs text-muted">px</span>
</div>
</div>
)}
{/* 线条参数 */}
{fillMode === 'line' && (
<div className="form-group" style={{ paddingLeft: 20 }}>
<div className="flex-row" style={{ gap: 4 }}>
<span className="text-xs text-muted" style={{ flexShrink: 0 }}></span>
<input
className="form-input"
type="number"
value={lineSpacing}
onChange={e => setLineSpacing(parseInt(e.target.value) || 6)}
min={2}
max={100}
style={{ width: 48, padding: '4px 6px', fontSize: 11 }}
/>
<span className="text-xs text-muted">px</span>
<span className="text-xs text-muted" style={{ flexShrink: 0, marginLeft: 6 }}></span>
<input
className="form-input"
type="number"
value={lineWidth}
onChange={e => setLineWidth(parseFloat(e.target.value) || 1)}
min={0.5}
max={10}
step={0.5}
style={{ width: 48, padding: '4px 6px', fontSize: 11 }}
/>
<span className="text-xs text-muted">px</span>
</div>
<div className="flex-row" style={{ gap: 4, marginTop: 4 }}>
<span className="text-xs text-muted" style={{ flexShrink: 0 }}></span>
<input
className="form-input"
type="number"
value={lineAngle}
onChange={e => setLineAngle(parseInt(e.target.value) || 0)}
min={0}
max={359}
style={{ width: 48, padding: '4px 6px', fontSize: 11 }}
/>
<span className="text-xs text-muted">&deg;</span>
</div>
</div>
)}
{/* 空心圆参数 */}
{fillMode === 'ring' && (
<div className="form-group" style={{ paddingLeft: 20 }}>
<div className="flex-row" style={{ gap: 4 }}>
<span className="text-xs text-muted" style={{ flexShrink: 0 }}></span>
<input
className="form-input"
type="number"
value={ringRadius}
onChange={e => setRingRadius(parseInt(e.target.value) || 3)}
min={1}
max={20}
style={{ width: 48, padding: '4px 6px', fontSize: 11 }}
/>
<span className="text-xs text-muted">px</span>
<span className="text-xs text-muted" style={{ flexShrink: 0, marginLeft: 6 }}></span>
<input
className="form-input"
type="number"
value={ringWidth}
onChange={e => setRingWidth(parseFloat(e.target.value) || 1)}
min={0.5}
max={10}
step={0.5}
style={{ width: 48, padding: '4px 6px', fontSize: 11 }}
/>
<span className="text-xs text-muted">px</span>
</div>
<div className="flex-row" style={{ gap: 4, marginTop: 4 }}>
<span className="text-xs text-muted" style={{ flexShrink: 0 }}></span>
<input
className="form-input"
type="number"
value={ringSpacing}
onChange={e => setRingSpacing(parseInt(e.target.value) || 8)}
min={2}
max={100}
style={{ width: 48, padding: '4px 6px', fontSize: 11 }}
/>
<span className="text-xs text-muted">px</span>
</div>
</div>
)}
<button
className="btn btn-secondary btn-sm btn-block"
disabled={!hasResult}
onClick={handleExportSvg}
>
SVG
</button>
<button
className="btn btn-primary btn-sm btn-block"
disabled={!hasResult || isSavingSticker}
onClick={handleSaveAsSticker}
>
{isSavingSticker ? '保存中' : '作为贴纸导入画布'}
</button>
<div className="section-divider" />
{/* ── 位图导出 ── */}
<div className="export-split">
<div className="export-col">
<div className="export-col-title"></div>
<div className="radio-group" style={{ flexDirection: 'column', gap: 4 }}>
{(['png', 'jpg'] as Format[]).map(f => (
<label key={f} className="radio-label">
<input
type="radio"
name="bmp-format"
checked={bmpFormat === f}
onChange={() => setBmpFormat(f)}
/>
{f}
</label>
))}
</div>
<div className="form-group">
<div className="flex-row" style={{ gap: 4 }}>
<span className="text-xs text-muted" style={{ flexShrink: 0 }}>=</span>
<input
className="form-input"
type="number"
value={exportW}
onChange={e => setExportW(e.target.value)}
min={1}
max={10000}
style={{ width: '100%', padding: '4px 6px', fontSize: 11 }}
/>
<span className="text-xs text-muted">px</span>
</div>
<div className="flex-row" style={{ gap: 4 }}>
<span className="text-xs text-muted" style={{ flexShrink: 0 }}>=</span>
<input
className="form-input"
type="number"
value={exportH}
onChange={e => setExportH(e.target.value)}
min={1}
max={10000}
style={{ width: '100%', padding: '4px 6px', fontSize: 11 }}
/>
<span className="text-xs text-muted">px</span>
</div>
</div>
<div className="note-text"></div>
<button
className="btn btn-secondary btn-sm btn-block"
disabled={!hasResult}
onClick={handleExportBitmap}
>
{bmpFormat}
</button>
</div>
</div>
{!hasResult && (
<p className="text-xs text-muted" style={{ textAlign: 'center', marginTop: 8 }}>
</p>
)}
</div>
</>
);
}
+79
View File
@@ -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<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragging(false);
const dropped = e.dataTransfer.files[0];
if (!dropped) return;
validateAndSet(dropped);
};
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
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 (
<div className="form-group">
<div
className={`upload-zone${dragging ? ' drag-over' : ''}`}
onDragOver={e => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
>
<input
ref={inputRef}
type="file"
accept={accept}
onChange={handleChange}
onClick={e => e.stopPropagation()}
/>
<div className="upload-zone-text">
<span className="upload-zone-icon"><IconFolder /></span>
<div>{label}</div>
<div style={{ fontSize: 10, marginTop: 2 }}>{acceptHint}</div>
</div>
</div>
{(hasFile || error) && (
<div className={`status-bar ${statusClass}`}>
<span className="status-bar-filename">
{error ? error : file?.name}
</span>
<span className="status-bar-label">
{error ? '导入失败' : '导入成功'}
</span>
</div>
)}
</div>
);
}
+105
View File
@@ -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<NameLocation[]>([]);
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 (
<>
<div className="panel-header">
<div className="panel-title"></div>
</div>
<div className="panel-body">
<div className="form-group">
<label className="form-label" style={{ fontWeight: 600, fontSize: 12 }}></label>
<input
className="form-input"
placeholder="输入名字,如张三"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleFind()}
disabled={!jobId}
/>
</div>
<div className="btn-group">
<button
className="btn btn-primary btn-sm"
onClick={handleFind}
disabled={!jobId || !query.trim() || loading}
>
{loading ? <><span className="spinner" style={{ width: 10, height: 10 }} /></> : '查找'}
</button>
<button
className="btn btn-secondary btn-sm"
onClick={handleNext}
disabled={!hasNext}
>
</button>
</div>
{searched && (
<div className="find-result-text">
{results.length > 0
? `结果:共找到 ${results.length} 个结果,点击"下一个"浏览不同位置`
: `未找到"${query}",请检查名字是否正确`
}
</div>
)}
{!jobId && (
<p className="text-xs text-muted"></p>
)}
</div>
</>
);
}
+260
View File
@@ -0,0 +1,260 @@
import React from 'react';
function Icon({ children }: { children: React.ReactNode }) {
return (
<svg
viewBox="0 0 16 16"
width="1em"
height="1em"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
style={{ display: 'inline-block', verticalAlign: 'middle' }}
>
{children}
</svg>
);
}
export function IconImport() {
return (
<Icon>
<path d="M8 2v8M4 8l4 4 4-4M2 14h12" />
</Icon>
);
}
export function IconExport() {
return (
<Icon>
<path d="M8 2v8M4 6l4-4 4 4M2 14h12" />
</Icon>
);
}
export function IconEdit() {
return (
<Icon>
<path d="M11 2l3 3-9 9H2v-3l9-9z" />
</Icon>
);
}
export function IconFind() {
return (
<Icon>
<circle cx="7" cy="7" r="5" />
<path d="M15 15l-4-4" />
</Icon>
);
}
export function IconSettings() {
return (
<Icon>
<circle cx="8" cy="8" r="3" />
<path d="M1 8h3M12 8h3M8 1v3M8 12v3" />
</Icon>
);
}
export function IconCloud() {
return (
<Icon>
<path d="M6 10a4 4 0 0 1-.5-7.9 4 4 0 0 1 7.9.5A3.5 3.5 0 0 1 14.5 10H6z" />
</Icon>
);
}
export function IconGrid() {
return (
<Icon>
<rect x="1" y="1" width="6" height="6" rx="1" />
<rect x="9" y="1" width="6" height="6" rx="1" />
<rect x="1" y="9" width="6" height="6" rx="1" />
<rect x="9" y="9" width="6" height="6" rx="1" />
</Icon>
);
}
export function IconLayers() {
return (
<Icon>
<path d="M8 1l7 4-7 4-7-4 7-4z" />
<path d="M1 8l7 4 7-4" />
<path d="M1 12l7 4 7-4" />
</Icon>
);
}
export function IconSticker() {
return (
<Icon>
<rect x="2" y="2" width="12" height="12" rx="2" />
<path d="M4 13l2-2 2 2 3-3" />
</Icon>
);
}
export function IconText() {
return (
<Icon>
<path d="M4 1h8M8 1v14m-3 0h6" />
</Icon>
);
}
export function IconShape() {
return (
<Icon>
<rect x="2" y="2" width="12" height="12" rx="2" />
</Icon>
);
}
export function IconCanvas() {
return (
<Icon>
<rect x="1" y="1" width="14" height="14" rx="2" />
<path d="M4 4h8M4 8h8M4 12h8" />
</Icon>
);
}
export function IconEyeOpen() {
return (
<Icon>
<path d="M1 8s3-5 7-5 7 5 7 5-3 5-7 5S1 8 1 8z" />
<circle cx="8" cy="8" r="2" />
</Icon>
);
}
export function IconEyeClosed() {
return (
<Icon>
<path d="M1 8s3-5 7-5 7 5 7 5-3 5-7 5S1 8 1 8z" />
<path d="M4 4l8 8" />
</Icon>
);
}
export function IconLock() {
return (
<Icon>
<rect x="4" y="8" width="8" height="7" rx="1" />
<path d="M4 8V6a4 4 0 0 1 8 0v2" />
</Icon>
);
}
export function IconUnlock() {
return (
<Icon>
<rect x="4" y="8" width="8" height="7" rx="1" />
<path d="M4 8V6a4 4 0 0 1 8 0v2" />
<path d="M4 8V6a4 4 0 0 1 8 0v2" />
<path d="M4 4l8 8" />
</Icon>
);
}
export function IconArrowUp() {
return (
<Icon>
<path d="M8 3v10M4 7l4-4 4 4" />
</Icon>
);
}
export function IconArrowDown() {
return (
<Icon>
<path d="M8 13V3M4 9l4 4 4-4" />
</Icon>
);
}
export function IconTrash() {
return (
<Icon>
<path d="M3 4h10M5 4v9a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2V4M6 4V2h4v2" />
</Icon>
);
}
export function IconPlus() {
return (
<Icon>
<path d="M8 1v14M1 8h14" />
</Icon>
);
}
export function IconFolder() {
return (
<Icon>
<path d="M2 3h4l2 2h7a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z" />
</Icon>
);
}
export function IconClose() {
return (
<Icon>
<path d="M4 4l8 8M12 4l-8 8" />
</Icon>
);
}
export function IconCheckmark() {
return (
<Icon>
<path d="M3 9l3 3 7-7" />
</Icon>
);
}
export function IconCross() {
return (
<Icon>
<path d="M4 4l8 8M12 4l-8 8" />
</Icon>
);
}
export function IconGear() {
return (
<Icon>
<circle cx="8" cy="8" r="3" />
<path d="M1 8h3M12 8h3M8 1v3M8 12v3" />
</Icon>
);
}
export function IconCloudy() {
return (
<Icon>
<path d="M6 10a4 4 0 0 1-.5-7.9 4 4 0 0 1 7.9.5A3.5 3.5 0 0 1 14.5 10H6z" />
</Icon>
);
}
export function IconRefresh() {
return (
<Icon>
<path d="M14 8a6 6 0 0 1-6 6 6 6 0 0 1-6-6 6 6 0 0 1 6-6v0" />
<path d="M10 2h4v4" />
</Icon>
);
}
export function IconDownload() {
return (
<Icon>
<path d="M8 1v8M4 9l4 4 4-4M2 14h12" />
</Icon>
);
}
+201
View File
@@ -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<JobParams>) => 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<HTMLInputElement>(null);
const handleFontFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
onFontUpload(file);
e.target.value = '';
}
};
return (
<>
<div className="panel-header">
<div className="panel-title"></div>
</div>
<div className="panel-body">
{/* 底图导入 */}
<div>
<div className="section-title">mask_image</div>
<FileUploader
label="拖动或点击上传底图"
accept=".svg,.jpg,.jpeg,.png,image/svg+xml,image/jpeg,image/png"
acceptHint="支持 svg · jpg · png"
file={maskFile}
onFileChange={onMaskChange}
/>
</div>
<div className="section-divider" />
{/* 字体选择 */}
<div>
<div className="section-title"></div>
<div className="form-group">
<select
className="form-input"
value={selectedFontId}
onChange={e => onFontSelect(e.target.value)}
style={{ width: '100%' }}
>
{fonts.map(f => (
<option key={f.font_id} value={f.font_id}>
{f.name}{f.font_id === '__default__' ? '' : ` (${(f.file_size / 1024).toFixed(0)}KB)`}
</option>
))}
</select>
</div>
<div className="flex-row" style={{ gap: 6, marginTop: 4 }}>
<button
className="btn-generate"
style={{ flex: 1, fontSize: 11, padding: '5px 0' }}
onClick={() => fontInputRef.current?.click()}
>
</button>
{selectedFontId !== '__default__' && (
<button
className="btn-generate"
style={{
flex: '0 0 auto',
fontSize: 11,
padding: '5px 10px',
background: 'var(--danger)',
color: 'var(--on-danger)',
}}
onClick={() => onFontDelete(selectedFontId)}
>
</button>
)}
</div>
<input
ref={fontInputRef}
type="file"
accept=".ttf,.ttc,.otf"
style={{ display: 'none' }}
onChange={handleFontFileChange}
/>
<span className="text-xs text-muted" style={{ marginTop: 4, display: 'block' }}>
ttf · ttc · otf
</span>
</div>
<div className="section-divider" />
{/* 名单导入 */}
<div>
<div className="section-title">name_list</div>
<FileUploader
label="拖动或点击上传名单"
accept=".xlsx"
acceptHint="仅支持 .xlsx"
file={namesFile}
onFileChange={onNamesChange}
/>
</div>
<div className="section-divider" />
{/* 表格配置 */}
<div>
<div className="section-title"></div>
{/* 名字列索引 DATA_COL_INDEX0-based */}
<div className="form-group">
<label className="form-label">DATA_COL_INDEX</label>
<div className="flex-row">
<input
className="form-input"
type="number"
min={0}
style={{ width: 64, flexShrink: 0 }}
value={params.dataColIndex}
onChange={e => onParamsChange({ dataColIndex: parseInt(e.target.value) || 0 })}
placeholder="1"
/>
<span className="text-xs text-muted"> 0 12</span>
</div>
</div>
{/* 表头行号(前端预览用,不传后端) */}
<div className="form-group" style={{ marginTop: 8 }}>
<label className="form-label"></label>
<div className="flex-row">
<span className="text-xs text-muted"></span>
<input
className="form-input"
type="number"
min={1}
style={{ width: 56, flexShrink: 0 }}
value={params.headerRow}
onChange={e => onParamsChange({ headerRow: parseInt(e.target.value) || 1 })}
placeholder="1"
/>
<span className="text-xs text-muted"></span>
</div>
</div>
{/* 权重列索引 WEIGHT_COL_INDEX(可选,0-based */}
<div className="form-group" style={{ marginTop: 8 }}>
<label className="form-label"></label>
<div className="flex-row">
<input
className="form-input"
type="number"
min={0}
style={{ width: 64, flexShrink: 0 }}
value={params.weightColIndex ?? ''}
placeholder="留空=自动权重"
onChange={e => {
const v = e.target.value.trim();
onParamsChange({ weightColIndex: v === '' ? null : parseInt(v) });
}}
/>
<span className="text-xs text-muted">使</span>
</div>
</div>
</div>
<div className="section-divider" />
{/* 模板下载 */}
<a
href={TEMPLATE_URL}
className="text-link"
onClick={e => e.preventDefault()}
download
>
<IconDownload />
</a>
</div>
</>
);
}
+48
View File
@@ -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 (
<div className="progress-panel" style={isFailed ? { borderColor: 'var(--danger)' } : {}}>
<div className="progress-title">
{isFailed ? <><IconCross /> </> : isDone ? <><IconCheckmark /> </> : <><IconGear /> </>}
</div>
<div className="progress-stage" style={isFailed ? { color: 'var(--danger)' } : {}}>
{progress.stage}
</div>
{!isFailed && (
<div className="progress-bar-track">
<div
className="progress-bar-fill"
style={{
width: `${Math.max(0, Math.min(100, progress.percent))}%`,
background: isDone ? 'var(--success)' : 'var(--accent)',
}}
/>
</div>
)}
<div
className="progress-message"
style={{
color: isFailed ? 'var(--danger)' : 'var(--text-muted)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
lineHeight: 1.5,
marginTop: isFailed ? 8 : 0,
}}
>
{progress.message}
</div>
</div>
);
}
+33
View File
@@ -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 (
<div className="view-controls">
<button className="view-btn" title="缩小" onClick={onZoomOut}></button>
<button className="view-btn" title="重置缩放" onClick={onZoomReset} style={{ fontSize: 10, width: 'auto', padding: '0 4px' }}>
{Math.round(zoom * 100)}%
</button>
<button className="view-btn" title="放大" onClick={onZoomIn}>+</button>
<div className="view-divider" />
<button
className={`view-btn${viewMode === '2d' ? ' active' : ''}`}
title="2D 视图"
onClick={() => viewMode !== '2d' && onToggleView()}
>2D</button>
<button
className={`view-btn${viewMode === '3d' ? ' active' : ''}`}
title="3D 视图"
onClick={() => viewMode !== '3d' && onToggleView()}
>3D</button>
</div>
);
}
+74
View File
@@ -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<HTMLDivElement>(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 };
}
+138
View File
@@ -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<string>();
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);
}
+156
View File
@@ -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<string, string> {
try {
const raw = localStorage.getItem(STICKER_TINTS_KEY);
return raw ? JSON.parse(raw) : {};
} catch {
return {};
}
}
function saveTints(tints: Record<string, string>) {
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<BackendAsset[]> {
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<BackendAsset> {
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<void> {
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<StickerAsset[]> {
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<StickerAsset, 'id' | 'createdAt'>,
): Promise<StickerAsset> {
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<void> {
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;
}
+147
View File
@@ -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<string> {
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<string> {
// Legacy inline content (still supported for imported files / tests)
if (asset.type === 'svg' && asset.source.trim().startsWith('<svg')) {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(asset.source)}`;
}
if (asset.source.startsWith('data:')) {
return asset.source;
}
// Backend URL: fetch and inline so the exported SVG is self-contained
return fetchBlobAsDataUrl(asset.source);
}
export async function serializeDocument(
documentModel: CanvasDocument,
stickerById: Map<string, StickerAsset>,
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 = [
`<svg xmlns="http://www.w3.org/2000/svg" width="${widthMm}mm" height="${heightMm}mm" viewBox="0 0 ${doc.width} ${doc.height}">`,
];
if (options.includeBackground !== false) {
parts.push(`<rect width="100%" height="100%" fill="${escapeXml(doc.background)}"/>`);
}
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(`<image href="${escapeXml(href)}" x="0" y="0" width="${element.width}" height="${element.height}" preserveAspectRatio="xMidYMid meet" opacity="${opacity}" transform="${transform}"${filter}/>`);
continue;
}
if (element.type === 'text') {
parts.push(
`<text x="0" y="${element.fontSize}" fill="${escapeXml(element.fill)}" font-size="${element.fontSize}" font-family="${escapeXml(element.fontFamily)}" font-weight="${escapeXml(element.fontWeight)}" opacity="${opacity}" transform="${transform}">${escapeXml(element.text)}</text>`,
);
continue;
}
if (element.type === 'rect') {
parts.push(`<rect x="0" y="0" width="${element.width}" height="${element.height}" fill="${escapeXml(element.fill)}" stroke="${escapeXml(element.stroke)}" stroke-width="${element.strokeWidth}" opacity="${opacity}" transform="${transform}"/>`);
continue;
}
if (element.type === 'ellipse') {
parts.push(`<ellipse cx="${element.width / 2}" cy="${element.height / 2}" rx="${element.width / 2}" ry="${element.height / 2}" fill="${escapeXml(element.fill)}" stroke="${escapeXml(element.stroke)}" stroke-width="${element.strokeWidth}" opacity="${opacity}" transform="${transform}"/>`);
continue;
}
parts.push(`<line x1="0" y1="${element.height / 2}" x2="${element.width}" y2="${element.height / 2}" stroke="${escapeXml(element.stroke)}" stroke-width="${element.strokeWidth}" stroke-linecap="round" opacity="${opacity}" transform="${transform}"/>`);
}
parts.push('</svg>');
return parts.join('\n');
}
export async function createLayerExportZip(
documentModel: CanvasDocument,
stickerById: Map<string, StickerAsset>,
selectedLayerIds: string[],
selectedFolderIds: string[],
) {
const doc = normalizeDocument(documentModel);
const files: { name: string; content: string }[] = [];
const used = new Map<string, number>();
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<string, number>) {
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
+105
View File
@@ -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<CanvasTemplate[]> {
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<CanvasTemplate> {
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<CanvasTemplate> {
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<BackendAsset[]> {
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<BackendAsset> {
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);
}
+124
View File
@@ -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;
}
+10
View File
@@ -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(
<StrictMode>
<App />
</StrictMode>
);
File diff suppressed because it is too large Load Diff
+316
View File
@@ -0,0 +1,316 @@
import { useEffect, useMemo, useState } from 'react';
import { BackendAsset, CanvasTemplate } from '../types';
import { formatMm, normalizeDocument } from '../lib/canvasDocument';
import {
assetUrl,
deleteCanvasTemplate,
listAssets,
listDesignTemplates,
templateCoverId,
templateId,
templateReferenceIds,
templateUpdatedAt,
} from '../lib/templateLibrary';
import { serializeDocument } from '../lib/svgExport';
import { loadStickerLibrary } from '../lib/stickerLibrary';
import {
IconGrid,
IconCloud,
IconRefresh,
IconCanvas,
} from '../components/Icons';
interface TemplateHomeProps {
onCreateBlank: () => void;
onUseTemplate: (template: CanvasTemplate) => void;
onOpenCanvas: () => void;
onOpenWordcloud: () => void;
}
export default function TemplateHome({
onCreateBlank,
onUseTemplate,
onOpenCanvas,
onOpenWordcloud,
}: TemplateHomeProps) {
const [templates, setTemplates] = useState<CanvasTemplate[]>([]);
const [assets, setAssets] = useState<BackendAsset[]>([]);
const [selected, setSelected] = useState<CanvasTemplate | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [stickerById, setStickerById] = useState(() => new Map<string, import('../types').StickerAsset>());
useEffect(() => {
loadStickerLibrary().then(items => {
const map = new Map<string, import('../types').StickerAsset>();
items.forEach(asset => map.set(asset.id, asset));
setStickerById(map);
});
}, [templates]);
const assetById = useMemo(() => {
const map = new Map<string, BackendAsset>();
assets.forEach(asset => map.set(asset.asset_id, asset));
return map;
}, [assets]);
const refresh = async () => {
setLoading(true);
setError('');
try {
const [templateItems, assetItems] = await Promise.all([
listDesignTemplates(),
listAssets(),
]);
setTemplates(templateItems);
setAssets(assetItems);
} catch (err) {
setError(err instanceof Error ? err.message : '读取模板失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
refresh();
}, []);
const removeSelected = async () => {
if (!selected) return;
await deleteCanvasTemplate(templateId(selected));
setSelected(null);
refresh();
};
return (
<div className="template-home">
<nav className="navbar">
<div className="navbar-brand">
<div className="navbar-brand-icon"><IconGrid /></div>
<span className="navbar-brand-name"></span>
</div>
<div className="navbar-actions">
<button className="nav-btn active" onClick={refresh}>
<span className="nav-btn-icon"><IconRefresh /></span>
<span className="nav-btn-label"></span>
</button>
<button className="nav-btn" onClick={onOpenCanvas}>
<span className="nav-btn-icon"><IconCanvas /></span>
<span className="nav-btn-label"></span>
</button>
<button className="nav-btn" onClick={onOpenWordcloud}>
<span className="nav-btn-icon"><IconCloud /></span>
<span className="nav-btn-label"></span>
</button>
</div>
<div className="navbar-end">
<button className="btn btn-primary btn-sm" onClick={onCreateBlank}></button>
</div>
</nav>
<main className="template-home-main">
{loading && <div className="table-empty"></div>}
{error && <div className="table-empty">{error}</div>}
{!loading && templates.length === 0 && (
<div className="template-empty">
<div className="template-empty-title"></div>
<button className="btn btn-primary" onClick={onCreateBlank}></button>
</div>
)}
<div className="template-masonry">
{templates.map(template => (
<TemplateMasonryCard
key={templateId(template)}
template={template}
cover={assetById.get(templateCoverId(template))}
stickerById={stickerById}
onClick={() => setSelected(template)}
/>
))}
</div>
</main>
{selected && (
<TemplateModal
template={selected}
assetById={assetById}
stickerById={stickerById}
onUse={() => onUseTemplate(selected)}
onClose={() => setSelected(null)}
onDelete={removeSelected}
/>
)}
</div>
);
}
function TemplateModal({
template,
assetById,
stickerById,
onUse,
onClose,
onDelete,
}: {
template: CanvasTemplate;
assetById: Map<string, BackendAsset>;
stickerById: Map<string, import('../types').StickerAsset>;
onUse: () => void;
onClose: () => void;
onDelete: () => void;
}) {
// Build slide list: cover first, then reference images
const slides = useMemo(() => {
const coverId = templateCoverId(template);
const cover = assetById.get(coverId);
const refs = templateReferenceIds(template)
.filter(id => id !== coverId)
.map(id => assetById.get(id))
.filter((a): a is BackendAsset => !!a);
return cover ? [cover, ...refs] : refs;
}, [template, assetById]);
const [idx, setIdx] = useState(0);
const safeIdx = Math.min(idx, Math.max(0, slides.length - 1));
const prev = () => setIdx(i => Math.max(0, i - 1));
const next = () => setIdx(i => Math.min(slides.length - 1, i + 1));
const [largeUrl, setLargeUrl] = useState<string>(slides.length > 0 ? assetUrl(slides[safeIdx]) : '');
useEffect(() => {
if (slides.length > 0) {
setLargeUrl(assetUrl(slides[safeIdx]));
return;
}
let cancelled = false;
serializeDocument(normalizeDocument(template.document), stickerById, { includeBackground: true }).then(svg => {
if (!cancelled) setLargeUrl(`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`);
});
return () => { cancelled = true; };
}, [slides, safeIdx, template, stickerById]);
return (
<div className="template-modal-backdrop" onClick={onClose}>
<div className="template-modal" onClick={e => e.stopPropagation()}>
{/* Large preview with prev/next arrows */}
<div className="template-preview large" style={{ position: 'relative' }}>
<img src={largeUrl} alt={template.name} />
{slides.length > 1 && (
<>
<button
className="slide-arrow slide-arrow-prev"
onClick={prev}
disabled={safeIdx === 0}
></button>
<button
className="slide-arrow slide-arrow-next"
onClick={next}
disabled={safeIdx === slides.length - 1}
></button>
</>
)}
</div>
<div className="template-modal-body">
<div className="template-title large">{template.name}</div>
{template.description && <p className="template-description">{template.description}</p>}
<div className="template-meta modal-meta">
<span>{formatMm(template.document.width)} x {formatMm(template.document.height)} mm</span>
<span>{template.document.elements.length} </span>
<span>{formatDate(templateUpdatedAt(template))}</span>
</div>
{/* Thumbnail strip */}
{slides.length > 1 && (
<div className="reference-strip">
{slides.map((asset, i) => (
<img
key={asset.asset_id}
src={assetUrl(asset)}
alt={asset.name}
className={i === safeIdx ? 'active' : ''}
onClick={() => setIdx(i)}
style={{ cursor: 'pointer', outline: i === safeIdx ? '2px solid var(--color-primary, #6c63ff)' : 'none', borderRadius: 4 }}
/>
))}
</div>
)}
<div className="btn-group">
<button className="btn btn-primary" onClick={onUse}>使</button>
<button className="btn btn-secondary" onClick={onClose}></button>
<button className="btn btn-danger" onClick={onDelete}></button>
</div>
</div>
</div>
</div>
);
}
function TemplateMasonryCard({
template,
cover,
stickerById,
onClick,
}: {
template: CanvasTemplate;
cover?: BackendAsset;
stickerById: Map<string, import('../types').StickerAsset>;
onClick: () => void;
}) {
return (
<button className="template-masonry-card" onClick={onClick}>
<TemplatePreview template={template} cover={cover} stickerById={stickerById} />
<div className="template-masonry-info">
<div className="template-title">{template.name}</div>
{template.description && <div className="template-description">{template.description}</div>}
<div className="template-meta">
<span>{formatMm(template.document.width)} x {formatMm(template.document.height)} mm</span>
<span>{formatDate(templateUpdatedAt(template))}</span>
</div>
</div>
</button>
);
}
function TemplatePreview({
template,
cover,
stickerById,
large = false,
}: {
template: CanvasTemplate;
cover?: BackendAsset;
stickerById: Map<string, import('../types').StickerAsset>;
large?: boolean;
}) {
const [previewUrl, setPreviewUrl] = useState<string>(cover ? assetUrl(cover) : '');
useEffect(() => {
if (cover) {
setPreviewUrl(assetUrl(cover));
return;
}
let cancelled = false;
serializeDocument(normalizeDocument(template.document), stickerById, { includeBackground: true }).then(svg => {
if (!cancelled) setPreviewUrl(`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`);
});
return () => { cancelled = true; };
}, [cover, stickerById, template]);
return (
<div className={`template-preview${large ? ' large' : ''}`}>
<img src={previewUrl} alt={template.name} />
</div>
);
}
function formatDate(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '未知时间';
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
+586
View File
@@ -0,0 +1,586 @@
import { useState, useCallback, useRef, useEffect, useLayoutEffect } from 'react';
import * as XLSX from 'xlsx';
import {
NameEntry,
JobParams,
JobResult,
SSEProgress,
PanelType,
NameLocation,
Font,
WordcloudMaskSource,
WordcloudStickerPayload,
} from '../types';
import ImportPanel from '../components/ImportPanel';
import ExportPanel from '../components/ExportPanel';
import EditPanel from '../components/EditPanel';
import FindPanel from '../components/FindPanel';
import AdvancedPanel from '../components/AdvancedPanel';
import ProgressPanel from '../components/ProgressPanel';
import CanvasArea from '../components/CanvasArea';
import ViewControls from '../components/ViewControls';
import { useResizablePanel } from '../hooks/useResizablePanel';
import {
IconImport,
IconExport,
IconEdit,
IconFind,
IconSettings,
IconCloud,
} from '../components/Icons';
const API_BASE = '';
const DEFAULT_PARAMS: JobParams = {
seed: 42,
dataColIndex: 1, // 0-based,默认第2列(B列)
headerRow: 1, // 1-based,默认第1行为表头,数据从第2行开始
weightColIndex: null, // 不指定权重列,由 ENABLE_STROKE_WEIGHTS 决定是否启用笔画权重
fontColor: '#000000', // 字体颜色,默认黑色
nRepetitions: 1, // 词语重复填充次数,词语较少时可增大以提升填充率
strokeWeights: true, // 根据笔画复杂度调整权重
};
type NavItem = {
id: NonNullable<PanelType>;
label: string;
icon: React.ReactNode;
};
type ThemeMode = 'light' | 'dark' | 'system';
const NAV_ITEMS: NavItem[] = [
{ id: 'import', label: '导入', icon: <IconImport /> },
{ id: 'export', label: '导出', icon: <IconExport /> },
{ id: 'edit', label: '修改', icon: <IconEdit /> },
{ id: 'find', label: '查找', icon: <IconFind /> },
{ id: 'advanced', label: '高级', icon: <IconSettings /> },
];
const THEME_OPTIONS: { id: ThemeMode; label: string }[] = [
{ id: 'light', label: '浅色' },
{ id: 'dark', label: '深色' },
{ id: 'system', label: '系统' },
];
interface TestWorkbenchProps {
onOpenCanvas?: () => void;
onImportWordcloudSticker?: (payload: WordcloudStickerPayload) => void;
}
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 TestWorkbench({ onOpenCanvas, onImportWordcloudSticker }: TestWorkbenchProps) {
const { width: panelWidth, handleRef } = useResizablePanel('wb-panel-width', 240, 180, 400, 'left');
const [maskFile, setMaskFile] = useState<File | null>(null);
const [maskSubmitFile, setMaskSubmitFile] = useState<File | null>(null);
const [maskSource, setMaskSource] = useState<WordcloudMaskSource | null>(null);
const [namesFile, setNamesFile] = useState<File | null>(null);
const [nameEntries, setNameEntries] = useState<NameEntry[]>([]);
const [params, setParams] = useState<JobParams>(DEFAULT_PARAMS);
const [jobId, setJobId] = useState<string | null>(null);
const [jobResult, setJobResult] = useState<JobResult | null>(null);
const [progress, setProgress] = useState<SSEProgress | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [activePanel, setActivePanel] = useState<PanelType>('import');
const [viewMode, setViewMode] = useState<'2d' | '3d'>('2d');
const [zoom, setZoom] = useState(1);
const [highlightLocation, setHighlightLocation] = useState<NameLocation | null>(null);
const [fonts, setFonts] = useState<Font[]>([]);
const [selectedFontId, setSelectedFontId] = useState<string>('__default__');
const [themeMode, setThemeMode] = useState<ThemeMode>(getStoredTheme);
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(getSystemTheme);
const sseRef = useRef<EventSource | null>(null);
useLayoutEffect(() => {
const resolvedTheme = themeMode === 'system' ? systemTheme : themeMode;
document.documentElement.dataset.theme = resolvedTheme;
document.documentElement.dataset.themeMode = themeMode;
document.documentElement.style.colorScheme = resolvedTheme;
window.localStorage.setItem('wordcloud-theme', themeMode);
}, [themeMode, systemTheme]);
useEffect(() => {
const media = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (event: MediaQueryListEvent) => {
setSystemTheme(event.matches ? 'dark' : 'light');
};
setSystemTheme(media.matches ? 'dark' : 'light');
media.addEventListener('change', handleChange);
return () => media.removeEventListener('change', handleChange);
}, []);
// ─── 字体列表加载 ─────────────────────────────────────────────────────────
const fetchFonts = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/api/fonts`);
if (res.ok) {
const data: Font[] = await res.json();
setFonts(data);
}
} catch (e) { console.error('fetchFonts error:', e); }
}, []);
useEffect(() => { fetchFonts(); }, [fetchFonts]);
const handleFontUpload = useCallback(async (file: File) => {
const fd = new FormData();
fd.append('file', file);
fd.append('name', file.name.replace(/\.(ttf|ttc|otf)$/i, ''));
try {
const res = await fetch(`${API_BASE}/api/fonts`, { method: 'POST', body: fd });
if (res.ok) {
const font: Font = await res.json();
setFonts(prev => [font, ...prev]);
setSelectedFontId(font.font_id);
}
} catch (e) { console.error('font upload error:', e); }
}, []);
const handleFontDelete = useCallback(async (fontId: string) => {
try {
await fetch(`${API_BASE}/api/fonts/${fontId}`, { method: 'DELETE' });
setFonts(prev => prev.filter(f => f.font_id !== fontId));
setSelectedFontId('__default__');
} catch (e) { console.error('font delete error:', e); }
}, []);
// ─── Excel 本地预览解析 ───────────────────────────────────────────────────
// 仅用于"修改"面板展示;实际生成时后端直接读文件,以后端解析为准。
const parseExcel = useCallback(async (
file: File,
dataColIndex: number, // 0-based
headerRow: number, // 1-based,数据从 headerRow 之后一行开始
) => {
try {
const buf = await file.arrayBuffer();
const wb = XLSX.read(buf, { type: 'array' });
const ws = wb.Sheets[wb.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json<unknown[]>(ws, { header: 1 });
// 数据从 headerRow 行(0-based = headerRow)开始
const startIdx = Math.max(0, headerRow);
const colIdx = dataColIndex;
const entries: NameEntry[] = (rows as unknown[][])
.slice(startIdx)
.filter(row => row[colIdx] !== undefined && String(row[colIdx]).trim() !== '')
.map(row => ({
group: String(row[0] ?? ''),
name: String(row[colIdx] ?? ''),
weight: parseInt(String(row[colIdx + 1] ?? '1')) || 1,
}));
setNameEntries(entries);
} catch (err) {
console.error('Excel parse error:', err);
setNameEntries([]);
}
}, []);
const handleMaskChange = useCallback(async (file: File | null) => {
setMaskFile(file);
setMaskSubmitFile(null);
setMaskSource(null);
if (!file) return;
try {
const isSvg = file.type === 'image/svg+xml' || /\.svg$/i.test(file.name);
if (isSvg) {
const svg = await file.text();
const png = await svgToPngFile(svg, file.name);
setMaskSubmitFile(png);
setMaskSource({
name: file.name.replace(/\.svg$/i, ''),
type: 'svg',
source: svg,
});
} else {
const dataUrl = await fileToDataUrl(file);
setMaskSubmitFile(file);
setMaskSource({
name: file.name.replace(/\.(png|jpe?g)$/i, ''),
type: 'image',
source: dataUrl,
});
}
} catch (error) {
console.error(error);
alert(error instanceof Error ? error.message : '底图处理失败');
setMaskFile(null);
setMaskSubmitFile(null);
setMaskSource(null);
}
}, []);
const handleNamesChange = useCallback(async (file: File | null) => {
setNamesFile(file);
if (!file) { setNameEntries([]); return; }
await parseExcel(file, params.dataColIndex, params.headerRow);
}, [params.dataColIndex, params.headerRow, parseExcel]);
// 参数变更时如果文件已存在则重新预览解析
const handleParamsChange = useCallback((partial: Partial<JobParams>) => {
setParams(prev => {
const next = { ...prev, ...partial };
if (
namesFile &&
(partial.dataColIndex !== undefined || partial.headerRow !== undefined)
) {
parseExcel(namesFile, next.dataColIndex, next.headerRow);
}
return next;
});
}, [namesFile, parseExcel]);
const handleNavClick = (panel: PanelType) => {
setActivePanel(prev => prev === panel ? null : panel);
};
// ─── 生成任务提交 ─────────────────────────────────────────────────────────
const handleGenerate = async () => {
if (isGenerating) return;
if (!namesFile) {
alert('请先导入名单(.xlsx');
return;
}
setIsGenerating(true);
setProgress({ stage: '准备中', percent: 0, message: '正在提交任务...' });
setJobResult(null);
setHighlightLocation(null);
try {
// ── 组装 params JSON(对应后端 config 别名键)──────────────────────
// 参见 README 4.8.3 / 4.8.10
const paramsObj: Record<string, unknown> = {
MODE: maskSubmitFile ? 'IMAGE' : 'TEXT',
DATA_COL_INDEX: params.dataColIndex,
};
if (params.seed !== null) {
paramsObj.SEED = params.seed; // 全局随机种子
}
if (params.weightColIndex !== null) {
paramsObj.WEIGHT_COL_INDEX = params.weightColIndex; // 0-based 权重列
}
if (params.fontColor) {
paramsObj.FONT_COLOR = params.fontColor;
}
if (params.nRepetitions > 1) {
paramsObj.N_REPETITIONS = params.nRepetitions; // 词语重复填充次数
}
if (!params.strokeWeights) {
paramsObj.ENABLE_STROKE_WEIGHTS = false; // 关闭笔画权重
}
// ── FormData(字段名严格按 README 6.5)────────────────────────────
// name_list : 必填 xlsx
// mask_image : IMAGE 模式必填
// font_file : 可选自定义字体
// params : JSON 字符串
const formData = new FormData();
formData.append('name_list', namesFile);
if (maskSubmitFile) {
formData.append('mask_image', maskSubmitFile);
}
if (selectedFontId) {
formData.append('font_id', selectedFontId);
}
formData.append('params', JSON.stringify(paramsObj));
const res = await fetch(`${API_BASE}/api/jobs`, { method: 'POST', body: formData });
if (!res.ok) {
const errText = await res.text().catch(() => '');
throw new Error(`提交失败 (${res.status}): ${errText}`);
}
const data = await res.json();
// README 6.5 响应包含 job_id
const id: string = data.job_id ?? data.id;
if (!id) throw new Error('后端未返回 job_id,请检查接口响应');
setJobId(id);
// ── SSE 监听进度 ───────────────────────────────────────────────────
// GET /api/jobs/{job_id}/events
sseRef.current?.close();
const sse = new EventSource(`${API_BASE}/api/jobs/${id}/events`);
sseRef.current = sse;
sse.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
setProgress({
stage: msg.stage ?? '',
percent: msg.progress_percent ?? 0,
message: msg.message ?? '',
});
if (msg.progress_percent >= 100 || msg.stage === 'completed' || msg.stage === 'failed') {
sse.close();
fetchResult(id);
}
} catch { /* ignore SSE parse errors */ }
};
sse.onerror = () => {
sse.close();
fetchResult(id);
};
} catch (err) {
console.error(err);
const msg = err instanceof Error ? err.message : '未知错误';
setProgress({ stage: '错误', percent: 0, message: msg });
setIsGenerating(false);
}
};
// ─── 获取结果 ─────────────────────────────────────────────────────────────
// GET /api/jobs/{job_id}/result
const fetchResult = async (id: string) => {
try {
const res = await fetch(`${API_BASE}/api/jobs/${id}/result`);
if (!res.ok) throw new Error(`获取结果失败 (${res.status})`);
const data: JobResult = await res.json();
if (data.status === 'failed') {
// 尝试从 /detail 拿更详细的错误信息
let detail = '';
try {
const dr = await fetch(`${API_BASE}/api/jobs/${id}/detail`);
if (dr.ok) {
const dd = await dr.json();
detail = dd.error ?? dd.message ?? '';
}
} catch { /* ignore */ }
setProgress({
stage: '生成失败',
percent: 0,
message: `任务失败${detail ? '' + detail : ''}。可用 docker logs 查看后端堆栈。`,
});
setIsGenerating(false);
return;
}
// 图片 URL:优先用 result 里的字段,降级到 files/png 路由
// README 6.5 列出了 /files/{kind}kind 对应输出文件类型
const imageUrl = data.image_url || `/api/jobs/${id}/files/png`;
const svgUrl = data.svg_url || `/api/jobs/${id}/files/svg`;
setJobResult({ ...data, image_url: imageUrl, svg_url: svgUrl });
setProgress({ stage: '完成', percent: 100, message: '词云生成完成!' });
setTimeout(() => setProgress(null), 3000);
} catch (err) {
const msg = err instanceof Error ? err.message : '未知错误';
setProgress({ stage: '错误', percent: 0, message: msg });
} finally {
setIsGenerating(false);
}
};
const handleLocate = (loc: NameLocation) => {
setHighlightLocation(loc);
setViewMode('2d');
setTimeout(() => setHighlightLocation(null), 3000);
};
const panelOpen = activePanel !== null;
return (
<div className="app-layout">
{/* ===== NAVBAR ===== */}
<nav className="navbar">
<div className="navbar-brand">
<div className="navbar-brand-icon"><IconCloud /></div>
<span className="navbar-brand-name"></span>
</div>
<div className="navbar-actions">
{NAV_ITEMS.map(item => (
<button
key={item.id}
className={`nav-btn${activePanel === item.id ? ' active' : ''}`}
onClick={() => handleNavClick(item.id as PanelType)}
>
<span className="nav-btn-icon">{item.icon}</span>
<span className="nav-btn-label">{item.label}</span>
</button>
))}
</div>
<div className="navbar-end">
{onOpenCanvas && (
<button className="btn btn-secondary btn-sm" onClick={onOpenCanvas}></button>
)}
<div className="theme-switch" aria-label="主题模式">
{THEME_OPTIONS.map(option => (
<button
key={option.id}
type="button"
className={`theme-btn${themeMode === option.id ? ' active' : ''}`}
title={
option.id === 'system'
? `跟随系统(当前${systemTheme === 'dark' ? '深色' : '浅色'}`
: `${option.label}模式`
}
aria-pressed={themeMode === option.id}
onClick={() => setThemeMode(option.id)}
>
{option.label}
</button>
))}
</div>
</div>
</nav>
{/* ===== MAIN ===== */}
<div className="main-content">
{/* ===== SIDE PANEL ===== */}
<aside className={`side-panel${panelOpen ? '' : ' collapsed'}`} style={panelOpen ? { width: panelWidth } : undefined}>
<div className="side-panel-inner">
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
{activePanel === 'import' && (
<ImportPanel
maskFile={maskFile}
namesFile={namesFile}
params={params}
fonts={fonts}
selectedFontId={selectedFontId}
onMaskChange={handleMaskChange}
onNamesChange={handleNamesChange}
onParamsChange={handleParamsChange}
onFontUpload={handleFontUpload}
onFontDelete={handleFontDelete}
onFontSelect={setSelectedFontId}
/>
)}
{activePanel === 'export' && (
<ExportPanel
jobId={jobId}
apiBase={API_BASE}
svgUrl={jobResult?.svg_url}
imageUrl={jobResult?.image_url}
onOpenCanvas={onOpenCanvas}
maskSource={maskSource}
onImportWordcloudSticker={onImportWordcloudSticker}
/>
)}
{activePanel === 'edit' && (
<EditPanel
entries={nameEntries}
onEntriesChange={setNameEntries}
/>
)}
{activePanel === 'find' && (
<FindPanel
jobId={jobId}
apiBase={API_BASE}
onLocate={handleLocate}
/>
)}
{activePanel === 'advanced' && (
<AdvancedPanel
params={params}
onParamsChange={handleParamsChange}
/>
)}
</div>
{/* 生成按钮固定在面板底部 */}
<div className="panel-footer">
<button
className="btn-generate"
onClick={handleGenerate}
disabled={isGenerating}
>
{isGenerating
? <><span className="spinner" /></>
: '生成'}
</button>
</div>
</div>
<div className="panel-resize-handle" ref={handleRef} />
</aside>
{/* ===== CANVAS ===== */}
<main className="canvas-area">
<CanvasArea
maskFile={maskFile}
jobResult={jobResult}
apiBase={API_BASE}
viewMode={viewMode}
zoom={zoom}
highlightLocation={highlightLocation}
/>
<ProgressPanel progress={progress} visible={isGenerating || !!progress} />
<ViewControls
zoom={zoom}
viewMode={viewMode}
onZoomIn={() => setZoom(z => Math.min(5, +(z + 0.1).toFixed(1)))}
onZoomOut={() => setZoom(z => Math.max(0.1, +(z - 0.1).toFixed(1)))}
onZoomReset={() => setZoom(1)}
onToggleView={() => setViewMode(v => v === '2d' ? '3d' : '2d')}
/>
</main>
</div>
</div>
);
}
function fileToDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(new Error('读取底图失败'));
reader.readAsDataURL(file);
});
}
function svgToPngFile(svg: string, originalName: string) {
return new Promise<File>((resolve, reject) => {
const blob = new Blob([svg], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
const width = img.naturalWidth || parseSvgNumber(svg, 'width') || 1024;
const height = img.naturalHeight || parseSvgNumber(svg, 'height') || 1024;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
URL.revokeObjectURL(url);
reject(new Error('无法创建 SVG 转换画布'));
return;
}
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob(result => {
URL.revokeObjectURL(url);
if (!result) {
reject(new Error('SVG 转 PNG 失败'));
return;
}
resolve(new File([result], originalName.replace(/\.svg$/i, '.png'), { type: 'image/png' }));
}, 'image/png');
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('SVG 底图无法解析'));
};
img.src = url;
});
}
function parseSvgNumber(svg: string, attr: 'width' | 'height') {
const match = svg.match(new RegExp(`${attr}=["']([0-9.]+)`));
return match ? Math.max(1, Math.round(parseFloat(match[1]))) : 0;
}
File diff suppressed because it is too large Load Diff
+193
View File
@@ -0,0 +1,193 @@
export interface NameEntry {
group: string;
name: string;
weight: number;
}
// 前端持有的参数状态
// dataColIndex: 0-based,对应后端 DATA_COL_INDEX
// headerRow: 1-based,表头所在行,数据从 headerRow+1 行开始(前端预览用)
// seed: 对应后端 SEED
// weightColIndex: 0-based,对应后端 WEIGHT_COL_INDEXnull 表示不传)
export interface JobParams {
seed: number | null;
dataColIndex: number; // 0-based,名字列索引
headerRow: number; // 1-based,表头行号;数据从下一行开始
weightColIndex: number | null; // 0-based,权重列索引;null=不传
fontColor: string; // 字体颜色,默认 #000000
nRepetitions: number; // 词语重复填充次数,默认 1;词语较少时可增大以提升填充率
strokeWeights: boolean; // 是否根据笔画复杂度调整权重,默认 true
}
// 后端 /api/jobs/{id}/result 返回结构(根据 README 6.5 + 输出说明推断)
// 文件通过 /api/jobs/{id}/files/{kind} 访问,kind: png | svg | db | metrics
export interface JobResult {
job_id: string;
status: string;
image_url: string;
svg_url: string;
svg_stroke_url: string;
db_url: string;
metrics_url: string;
}
export interface NameLocation {
id?: number;
name: string;
x: number;
y: number;
font_size?: number;
color?: string;
orientation?: 'horizontal' | 'vertical';
box_x?: number;
box_y?: number;
box_width?: number;
box_height?: number;
// 前端兼容旧字段
width?: number;
height?: number;
count?: number;
}
export interface SSEProgress {
stage: string;
percent: number;
message: string;
}
export type PanelType = 'import' | 'export' | 'edit' | 'find' | 'advanced' | null;
export interface ExportConfig {
type: 'bitmap' | 'vector';
format: 'jpg' | 'png';
width: number;
height: number;
}
export interface FindResult {
locations: NameLocation[];
currentIndex: number;
}
export interface Font {
font_id: string;
name: string;
filename: string;
file_size: number;
created_at: string;
}
export type StickerAssetType = 'svg' | 'image';
export interface StickerAsset {
id: string;
name: string;
type: StickerAssetType;
source: string;
createdAt: string;
tint?: string;
}
export type CanvasElementType = 'sticker' | 'text' | 'rect' | 'ellipse' | 'line';
export interface CanvasElementBase {
id: string;
type: CanvasElementType;
layerId?: string;
groupId?: string;
x: number;
y: number;
width: number;
height: number;
rotation: number;
opacity: number;
}
export interface StickerCanvasElement extends CanvasElementBase {
type: 'sticker';
assetId: string;
}
export interface TextCanvasElement extends CanvasElementBase {
type: 'text';
text: string;
fill: string;
fontSize: number;
fontFamily: string;
fontWeight: string;
}
export interface ShapeCanvasElement extends CanvasElementBase {
type: 'rect' | 'ellipse' | 'line';
fill: string;
stroke: string;
strokeWidth: number;
}
export type CanvasElement = StickerCanvasElement | TextCanvasElement | ShapeCanvasElement;
export interface CanvasLayer {
id: string;
name: string;
visible: boolean;
locked: boolean;
folderId?: string;
}
export interface CanvasLayerFolder {
id: string;
name: string;
layerIds: string[];
collapsed?: boolean;
}
export interface CanvasDocument {
width: number;
height: number;
background: string;
elements: CanvasElement[];
layers?: CanvasLayer[];
layerFolders?: CanvasLayerFolder[];
}
export interface CanvasTemplate {
id?: string;
template_id?: string;
name: string;
description: string;
document: CanvasDocument;
referenceAssetIds?: string[];
reference_asset_ids?: string[];
coverAssetId?: string;
cover_asset_id?: string;
createdAt?: string;
updatedAt?: string;
created_at?: string;
updated_at?: string;
}
export interface BackendAsset {
asset_id: string;
name: string;
type: string;
mime_type: string;
width: number;
height: number;
file_size: number;
file_url: string;
job_id?: string;
created_at: string;
}
export interface WordcloudMaskSource {
name: string;
type: 'svg' | 'image';
source: string;
}
export interface WordcloudStickerPayload {
svg: string;
mask?: WordcloudMaskSource;
width?: number;
height?: number;
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
}
}
}
});
Executable
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
BACKEND_DIR="$ROOT_DIR/backend"
FRONTEND_DIR="$ROOT_DIR/frontend"
cleanup() {
echo ""
echo "[INFO] 正在停止所有服务..."
if [[ -n "${BACKEND_PID:-}" ]]; then
kill "$BACKEND_PID" 2>/dev/null || true
wait "$BACKEND_PID" 2>/dev/null || true
fi
if [[ -n "${FRONTEND_PID:-}" ]]; then
kill "$FRONTEND_PID" 2>/dev/null || true
wait "$FRONTEND_PID" 2>/dev/null || true
fi
echo "[OK] 已停止"
exit 0
}
trap cleanup INT TERM
# 启动后端
echo "[INFO] 启动后端服务..."
cd "$BACKEND_DIR"
./start-dev.sh &
BACKEND_PID=$!
echo "[OK] 后端 PID: $BACKEND_PID"
# 等待后端启动
sleep 2
# 启动前端
echo "[INFO] 启动前端服务..."
cd "$FRONTEND_DIR"
npm run dev &
FRONTEND_PID=$!
echo "[OK] 前端 PID: $FRONTEND_PID"
echo ""
echo "========================================="
echo " 服务已启动"
echo " 后端: http://localhost:8000"
echo " 前端: http://localhost:3000"
echo " 按 Ctrl+C 停止所有服务"
echo "========================================="
wait