Initial project baseline
This commit is contained in:
+71
@@ -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
|
||||
@@ -0,0 +1,84 @@
|
||||
# 更新日志
|
||||
|
||||
## 2026-06-13 — 从词云生成器升级为完整创作工作流
|
||||
|
||||
> 本次更新在原有「词云生成」核心能力之上,**完整新增了「模板中心」与「画布工作室」两大模块**。系统从一个单纯的词云生成工具,升级为支持「选模板 → 生成词云 → 拖拽排版 → 导出成品」的一站式创作平台。
|
||||
|
||||
---
|
||||
|
||||
## 🆕 新增:模板中心(TemplateHome)
|
||||
|
||||
- **模板展示页面**:新增独立的模板中心入口,可浏览、筛选、管理设计模板。
|
||||
- **模板预览弹窗**:点击模板缩略图弹出预览窗口,并支持在弹窗内**切换多张预览图**查看,而非固定单张大图。
|
||||
- **异步 SVG 预览生成**:模板弹窗中的预览图改为异步生成,避免阻塞主线程,提升打开速度。
|
||||
- **模板 CRUD 接口**:后端新增 `/api/design-templates` 相关接口,支持模板的上传、更新、删除。
|
||||
|
||||
---
|
||||
|
||||
## 🆕 新增:画布工作室(CanvasStudio)
|
||||
|
||||
- **独立画布编辑器**:新增完整的可视化画布页面,支持多图层自由排版。
|
||||
- **词云作为贴纸导入**:生成的词云可一键作为贴纸插入画布,支持拖拽移动、缩放、旋转。
|
||||
- **贴纸库面板**:新增贴纸库,可浏览、上传、删除贴纸资源。
|
||||
- **组内元素联动缩放**:当调整词云大小时,同组内的底图/背景元素会按相同比例同步缩放,保持整体构图一致。
|
||||
- **SVG 导出自动内联后端资源**:导出成品 SVG 时,自动从后端拉取贴纸、底图等资源并转为 data URL 内嵌,确保导出的文件独立可用、图片不会缺失。
|
||||
- **图层导出 ZIP**:支持按图层批量导出资源包。
|
||||
|
||||
---
|
||||
|
||||
## 🆕 新增:后端贴纸库(完全替代 localStorage)
|
||||
|
||||
- **贴纸文件全部迁移到后端**:原先贴纸存在浏览器 `localStorage`,容易触发 `QuotaExceededError` 并导致贴纸丢失;现在统一通过 `/api/assets` 接口存取,文件保存在服务器。
|
||||
- **资源类型体系**:后端 `/api/assets` 支持 `wordcloud` / `upload` / `shape` / `sticker` 等类型。
|
||||
- **元数据 + 文件分离**:贴纸的元数据存在后端 JSON,文件存在后端磁盘,前端仅保存轻量引用。
|
||||
|
||||
---
|
||||
|
||||
## 🔧 问题修复
|
||||
|
||||
| 问题 | 原因 | 修复方案 |
|
||||
|------|------|----------|
|
||||
| 词云导入画布时生成两个重复贴纸 | React StrictMode 双重触发 effect,pending 状态未提交就被二次消费 | 增加 `importingStickerRef` 引用锁,防止同一词云重复导入 |
|
||||
| 调整词云大小时底图不同步缩放 | resize handler 只更新被拖拽的元素 | 记录 `groupId` 与组内元素初始尺寸,按比例同步缩放同组伙伴 |
|
||||
| 导出的 SVG 图片错误/缺失 | `serializeDocument` 把后端 URL 当作 SVG 文本处理 | 将 `serializeDocument` 改为异步,fetch 后端资源并内联为 data URL |
|
||||
| 贴纸/词云上传报 413 | Nginx 默认 `client_max_body_size` 仅 1MB | 前端 Nginx 配置 `client_max_body_size 100M` |
|
||||
| 生成任务进度连接超时断开 | Nginx 默认 60 秒 read timeout | Nginx 配置 `proxy_read_timeout` / `proxy_send_timeout` 3600 秒,关闭 buffering |
|
||||
| Docker 部署后词云任务失败 | release 包漏掉 `wordcloud_generate_hybrid.py` | 补回脚本并在 `backend/Dockerfile` 中显式复制 |
|
||||
|
||||
---
|
||||
|
||||
## 🐳 新增:Docker Compose 一键部署
|
||||
|
||||
- **新增 `docker-compose.yml`**:编排 frontend + backend 服务,含健康检查与 5 个持久化卷。
|
||||
- **新增 `frontend/Dockerfile`**:Node 多阶段构建 → Nginx 静态服务。
|
||||
- **新增 `backend/Dockerfile`**:Python 3.10 + 编译 `EfficientWordCloud` C++ 扩展,healthcheck 已安装 `curl`。
|
||||
- **新增 `frontend/nginx.conf`**:静态资源服务、`/api` 反向代理、SSE 长连接优化、大文件上传支持。
|
||||
- **新增 `frontend/.dockerignore` 与 `backend/.dockerignore`**:避免构建时带入 `node_modules`、`.venv`、`__pycache__` 等。
|
||||
|
||||
### 便捷命令
|
||||
- **新增 `Makefile`**:
|
||||
- `make build` / `make up` / `make down`
|
||||
- `make logs` / `make logs-backend` / `make logs-frontend`
|
||||
- `make restart` / `make clean` / `make shell-backend`
|
||||
|
||||
### 部署文档
|
||||
- **新增 `DOCKER.md`**:环境要求、快速开始、数据持久化说明、端口配置、常见问题排查。
|
||||
|
||||
### Release 包
|
||||
- **新增 `release/` 目录与 `release.tar.gz`**:仅包含源代码 + Docker 部署所需文件(已排除 `node_modules`、`.venv`、编译产物、测试输出等)。
|
||||
- 包大小约 28 MB(主要为 `backend/assets/fonts/STHeiti Medium.ttc` 字体文件)。
|
||||
|
||||
---
|
||||
|
||||
## 📦 依赖
|
||||
|
||||
- 后端 `requirements.txt` 新增 `fastapi>=0.136.0`、`uvicorn[standard]>=0.32.0`、`python-multipart>=0.0.27`、`pydantic>=2.10.0`、`pillow>=10.0.0`、`numpy>=2.0.0`、`matplotlib>=3.10.0`、`pandas>=2.0.0`、`openpyxl>=3.1.0`。
|
||||
- 前端保持 React 18 + Vite 5 技术栈,新增 `xlsx` 用于 Excel 解析。
|
||||
|
||||
---
|
||||
|
||||
## 📝 其他说明
|
||||
|
||||
- **数据持久化**:Docker Compose 使用 5 个命名卷保存任务工作区、贴纸资源、项目文件、设计模板、上传字体,容器重建不会丢失用户数据。
|
||||
- **API 调用**:前端统一使用相对路径 `/api/*`,本地开发由 Vite 代理到 `localhost:8000`,生产环境由 Nginx 代理到后端容器。
|
||||
- **原有词云生成功能保留**:`TestWorkbench` 作为原始生成工作台继续可用,新增强的模板中心与画布工作室与其并行。
|
||||
@@ -0,0 +1,184 @@
|
||||
# WordCloud — Docker Compose 部署指南
|
||||
|
||||
本项目提供完整的 Docker Compose 配置,可在任意 Ubuntu / Linux 服务器上通过 `docker-compose up` 一键启动前后端服务。
|
||||
|
||||
---
|
||||
|
||||
## 1. 目录结构
|
||||
|
||||
```
|
||||
wordcloud/
|
||||
├── docker-compose.yml # 编排 frontend + backend
|
||||
├── Makefile # 常用命令封装
|
||||
├── frontend/
|
||||
│ ├── Dockerfile # Node 构建 + Nginx 服务
|
||||
│ ├── nginx.conf # 静态资源 + /api 反向代理
|
||||
│ └── .dockerignore
|
||||
└── backend/
|
||||
├── Dockerfile # Python + C++ 扩展构建
|
||||
├── requirements.txt
|
||||
└── .dockerignore
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 环境要求
|
||||
|
||||
- Docker Engine >= 20.10
|
||||
- Docker Compose >= 1.29(或 `docker compose` plugin)
|
||||
- 服务器开放端口:`3000`(前端)、`8000`(后端,可选暴露)
|
||||
|
||||
---
|
||||
|
||||
## 3. 快速开始
|
||||
|
||||
### 3.1 克隆/上传代码到 Ubuntu 服务器
|
||||
|
||||
```bash
|
||||
cd /opt
|
||||
# 方式 A:git clone
|
||||
git clone <你的仓库地址> wordcloud
|
||||
cd wordcloud
|
||||
|
||||
# 方式 B:直接上传整个项目目录后进入
|
||||
cd /path/to/wordcloud
|
||||
```
|
||||
|
||||
### 3.2 构建并启动
|
||||
|
||||
```bash
|
||||
# 使用 Makefile(推荐)
|
||||
make build
|
||||
make up
|
||||
|
||||
# 或者直接使用 docker-compose
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
首次构建会:
|
||||
1. 后端:安装 Python 依赖并编译 `EfficientWordCloud` C++ 扩展。
|
||||
2. 前端:执行 `npm ci` 与 `npm run build`,生成静态文件。
|
||||
|
||||
根据服务器性能,首次构建通常需要 3–10 分钟。
|
||||
|
||||
### 3.3 访问服务
|
||||
|
||||
- 前端页面:http://`<服务器IP>`:3000
|
||||
- 后端 API 文档:http://`<服务器IP>`:8000/docs
|
||||
- 后端健康检查:http://`<服务器IP>`:8000/api/health
|
||||
|
||||
> 前端 Nginx 已将所有 `/api/*` 请求反向代理到后端容器,因此浏览器只需访问 3000 端口。
|
||||
|
||||
---
|
||||
|
||||
## 4. 常用命令
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `make build` | 重新构建镜像 |
|
||||
| `make up` | 后台启动服务 |
|
||||
| `make down` | 停止并移除容器 |
|
||||
| `make restart` | 重启服务 |
|
||||
| `make logs` | 查看实时日志 |
|
||||
| `make logs-backend` | 只看后端日志 |
|
||||
| `make logs-frontend` | 只看前端日志 |
|
||||
| `make clean` | 停止并删除容器 + 镜像 + 卷(谨慎) |
|
||||
| `make shell-backend` | 进入后端容器调试 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 数据持久化
|
||||
|
||||
Docker Compose 已声明以下命名卷,数据会保存在 Docker 宿主机上,容器重建不会丢失:
|
||||
|
||||
| 卷名 | 容器内路径 | 用途 |
|
||||
|------|-----------|------|
|
||||
| `wordcloud_workspace` | `/app/service_workspace` | 词云任务工作目录 |
|
||||
| `wordcloud_assets` | `/app/service_assets` | 贴纸资源文件 |
|
||||
| `wordcloud_projects` | `/app/service_projects` | 保存的项目 |
|
||||
| `wordcloud_design_templates` | `/app/service_design_templates` | 设计模板 |
|
||||
| `wordcloud_fonts` | `/app/service_fonts` | 上传的字体文件 |
|
||||
|
||||
如需查看本地卷位置:
|
||||
|
||||
```bash
|
||||
docker volume inspect wordcloud_workspace
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 端口与网络
|
||||
|
||||
- `frontend` 容器监听 `3000:80`
|
||||
- `backend` 容器监听 `8000:8000`
|
||||
- 两个服务通过默认 Docker bridge 网络通信,`frontend` 的 Nginx 通过服务名 `backend:8000` 访问后端。
|
||||
|
||||
如需修改端口,编辑 `docker-compose.yml` 中的 `ports` 映射即可,例如将前端改为 `8080:80`。
|
||||
|
||||
---
|
||||
|
||||
## 7. 生产环境建议
|
||||
|
||||
1. **使用反向代理(Nginx / Caddy / Traefik)**
|
||||
- 将 `3000` 端口通过域名 + HTTPS 暴露。
|
||||
- 关闭后端 `8000` 端口的外部访问,仅保留内部通信。
|
||||
|
||||
2. **设置环境变量**
|
||||
- 后端 `UVICORN_WORKERS`:可通过环境变量增加工作进程数。
|
||||
- 如需自定义后端日志级别,可挂载 `.env` 文件。
|
||||
|
||||
3. **备份数据卷**
|
||||
- 定期备份 `wordcloud_assets`、`wordcloud_projects` 等卷,避免服务器故障丢失用户数据。
|
||||
|
||||
4. **更新部署**
|
||||
```bash
|
||||
git pull
|
||||
make build
|
||||
make restart
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 常见问题
|
||||
|
||||
### Q1: 前端页面空白或 502
|
||||
检查后端是否健康:
|
||||
```bash
|
||||
make logs-backend
|
||||
```
|
||||
确认 `/api/health` 返回 `{"status":"ok"}`。
|
||||
|
||||
### Q2: 构建 C++ 扩展失败
|
||||
确保 base 镜像能联网安装 `build-essential` 与 `g++`。如在中国大陆服务器,可配置 Docker 镜像加速。
|
||||
|
||||
### Q3: 贴纸/字体上传后丢失
|
||||
检查卷是否正确挂载:
|
||||
```bash
|
||||
docker exec -it wordcloud-backend ls -la /app/service_assets
|
||||
```
|
||||
|
||||
### Q4: 端口被占用
|
||||
修改 `docker-compose.yml` 中的端口映射,例如:
|
||||
```yaml
|
||||
ports:
|
||||
- "8080:80"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 本地开发(非 Docker)
|
||||
|
||||
如需本地开发,可分别运行:
|
||||
|
||||
```bash
|
||||
# 后端
|
||||
cd backend
|
||||
./start-dev.sh
|
||||
|
||||
# 前端
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
本地开发时前端通过 Vite 代理 `/api` 到 `http://localhost:8000`。
|
||||
@@ -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
|
||||
@@ -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 大纲或一次性变更记录作为行为依据。
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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+** |
|
||||
@@ -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 测量开销**:采用预取窗口控制并发规模。
|
||||
- **锁开销**:只在必要时加锁,读操作使用共享锁。
|
||||
- **一致性**:聚合结果按最小索引保证行为与原排序一致。
|
||||
@@ -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 (0–1).
|
||||
mode : str
|
||||
PIL image mode ('RGB', 'RGBA', …).
|
||||
use_spiral_search : bool
|
||||
Use center-out sorted search (True) or reservoir sampling (False).
|
||||
scale : float
|
||||
Scaling factor between layout computation and final rendering.
|
||||
``scale=2`` means the output image is 2× the canvas size in each
|
||||
dimension while layout is still computed at base resolution.
|
||||
Equivalent to ref's ``scale`` parameter.
|
||||
contour_width : float
|
||||
If > 0 and mask is set, draw the mask contour on the output image.
|
||||
contour_color : color
|
||||
PIL-compatible color for the mask contour (default 'black').
|
||||
margin : int
|
||||
Pixel gap between words.
|
||||
stopwords : set of str or None
|
||||
Words to exclude when processing text. Defaults to built-in STOPWORDS.
|
||||
regexp : str or None
|
||||
Override the regex used to tokenize text (default ``r"\\w[\\w']+"``).
|
||||
collocations : bool
|
||||
Whether to detect bigrams (default True).
|
||||
collocation_threshold : int
|
||||
Dunning score threshold for bigrams (default 30).
|
||||
normalize_plurals : bool
|
||||
Strip trailing 's' to merge plurals (default True).
|
||||
include_numbers : bool
|
||||
Keep numeric tokens when processing text (default False).
|
||||
min_word_length : int
|
||||
Minimum character length for a token to be kept (default 0).
|
||||
color_func : callable or None
|
||||
``color_func(word, font_size, position, orientation, font_path,
|
||||
random_state) -> color``. Overrides *colormap*.
|
||||
colormap : str or matplotlib colormap or None
|
||||
Matplotlib colormap used when *color_func* is None.
|
||||
random_state : int, Random, or None
|
||||
Seed for reproducibility.
|
||||
repeat : bool
|
||||
If True, repeat words (with decreasing weight) until *max_words* or
|
||||
*min_font_size* is reached (default False).
|
||||
relative_scaling : float (0–1)
|
||||
How much word frequency (vs rank) influences font size.
|
||||
0 = rank only, 1 = fully frequency-driven.
|
||||
When *repeat* is True, defaults to 0.
|
||||
font_step : int
|
||||
Step size when reducing font size to find a fit.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
width=400, height=200,
|
||||
mask=None,
|
||||
font_path=None,
|
||||
max_words=200,
|
||||
min_font_size=4,
|
||||
max_font_size=None,
|
||||
background_color="black",
|
||||
prefer_horizontal=0.9,
|
||||
mode="RGB",
|
||||
use_spiral_search=True,
|
||||
scale=1,
|
||||
contour_width=0,
|
||||
contour_color="black",
|
||||
margin=2,
|
||||
color_func=None,
|
||||
colormap=None,
|
||||
random_state=None,
|
||||
relative_scaling="auto",
|
||||
font_step=1,
|
||||
repeat=False,
|
||||
stopwords=None,
|
||||
regexp=None,
|
||||
collocations=True,
|
||||
collocation_threshold=30,
|
||||
normalize_plurals=True,
|
||||
include_numbers=False,
|
||||
min_word_length=0):
|
||||
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.mask = mask
|
||||
self.font_path = font_path
|
||||
self.max_words = max_words
|
||||
self.min_font_size = min_font_size
|
||||
self.max_font_size = max_font_size
|
||||
self.background_color = background_color
|
||||
self.prefer_horizontal = prefer_horizontal
|
||||
self.mode = mode
|
||||
self.use_spiral_search = use_spiral_search
|
||||
self.scale = scale
|
||||
self.contour_width = contour_width
|
||||
self.contour_color = contour_color
|
||||
self.repeat = repeat
|
||||
|
||||
# relative_scaling default mirrors ref: 0 when repeat, else 0.5
|
||||
if relative_scaling == "auto":
|
||||
self.relative_scaling = 0 if repeat else 0.5
|
||||
else:
|
||||
self.relative_scaling = relative_scaling
|
||||
self.margin = margin
|
||||
self.font_step = font_step
|
||||
self.stopwords = stopwords if stopwords is not None else STOPWORDS
|
||||
self.regexp = regexp
|
||||
self.collocations = collocations
|
||||
self.collocation_threshold = collocation_threshold
|
||||
self.normalize_plurals = normalize_plurals
|
||||
self.include_numbers = include_numbers
|
||||
self.min_word_length = min_word_length
|
||||
|
||||
# Random state
|
||||
if isinstance(random_state, int):
|
||||
self.random_state = Random(random_state)
|
||||
elif random_state is None:
|
||||
self.random_state = Random()
|
||||
else:
|
||||
self.random_state = random_state
|
||||
|
||||
# Color function
|
||||
if color_func is not None:
|
||||
self.color_func = color_func
|
||||
elif colormap is not None:
|
||||
self.color_func = colormap_color_func(colormap)
|
||||
else:
|
||||
self.color_func = random_color_func
|
||||
|
||||
self.layout_ = []
|
||||
|
||||
# Handle mask
|
||||
if self.mask is not None:
|
||||
self.width = self.mask.shape[1]
|
||||
self.height = self.mask.shape[0]
|
||||
if self.mask.dtype == bool:
|
||||
self.boolean_mask = self.mask.astype(np.uint8) * 255
|
||||
elif self.mask.ndim == 3:
|
||||
# White pixels (all channels == 255) are blocked
|
||||
self.boolean_mask = np.where(
|
||||
np.all(self.mask[:, :, :3] == 255, axis=-1), 255, 0
|
||||
).astype(np.uint8)
|
||||
else:
|
||||
self.boolean_mask = self.mask.astype(np.uint8)
|
||||
else:
|
||||
self.boolean_mask = np.zeros((self.height, self.width), dtype=np.uint8)
|
||||
|
||||
# Initialize C++ grid (>0 = occupied)
|
||||
self.grid = IntegralGrid(self.boolean_mask, self.height, self.width)
|
||||
|
||||
def generate_from_frequencies(self, frequencies, max_font_size=None):
|
||||
"""Generate word cloud from a dict of {word: frequency}.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
frequencies : dict
|
||||
max_font_size : int or None
|
||||
Override self.max_font_size for this call (used internally for
|
||||
the automatic font-size estimation).
|
||||
"""
|
||||
sorted_freq = sorted(frequencies.items(), key=lambda x: x[1], reverse=True)
|
||||
if not sorted_freq:
|
||||
raise ValueError("Need at least 1 word to generate a word cloud.")
|
||||
|
||||
sorted_freq = sorted_freq[:self.max_words]
|
||||
|
||||
# Normalize so the top word = 1.0
|
||||
max_freq = float(sorted_freq[0][1])
|
||||
sorted_freq = [(w, f / max_freq) for w, f in sorted_freq]
|
||||
|
||||
# --- repeat: pad list up to max_words with down-weighted copies ---
|
||||
if self.repeat and len(sorted_freq) < self.max_words:
|
||||
import math
|
||||
times_extend = math.ceil(self.max_words / len(sorted_freq)) - 1
|
||||
base = list(sorted_freq)
|
||||
downweight = base[-1][1]
|
||||
for i in range(times_extend):
|
||||
factor = downweight ** (i + 1)
|
||||
sorted_freq.extend([(w, f * factor) for w, f in base])
|
||||
sorted_freq = sorted_freq[:self.max_words]
|
||||
|
||||
self.words_ = dict(sorted_freq)
|
||||
|
||||
# --- auto max_font_size estimation (mirrors ref) ---
|
||||
effective_max = max_font_size if max_font_size is not None else self.max_font_size
|
||||
if effective_max is None:
|
||||
if len(sorted_freq) == 1:
|
||||
effective_max = self.height
|
||||
else:
|
||||
# Trial run with just the first 2 words to estimate a good max size.
|
||||
# We must reinitialize the grid after so it is clean for the real run.
|
||||
_repeat_bak = self.repeat
|
||||
self.repeat = False
|
||||
self.generate_from_frequencies(dict(sorted_freq[:2]),
|
||||
max_font_size=self.height)
|
||||
self.repeat = _repeat_bak
|
||||
sizes = [s for _, s, *_ in self.layout_]
|
||||
try:
|
||||
effective_max = int(2 * sizes[0] * sizes[1] / (sizes[0] + sizes[1]))
|
||||
except (IndexError, ZeroDivisionError):
|
||||
effective_max = sizes[0] if sizes else self.height
|
||||
# Reinitialize the C++ grid so the trial run does not consume space
|
||||
self.grid = IntegralGrid(self.boolean_mask, self.height, self.width)
|
||||
|
||||
rs = self.random_state
|
||||
# No PIL image needed during placement — C++ canvas handles collision.
|
||||
# The PIL image is constructed lazily in to_image().
|
||||
self.layout_ = []
|
||||
|
||||
font_size = int(effective_max)
|
||||
last_freq = 1.0
|
||||
|
||||
# E1: font object cache {size -> ImageFont}
|
||||
font_cache: dict = {}
|
||||
|
||||
def _get_font(size):
|
||||
if size not in font_cache:
|
||||
try:
|
||||
font_cache[size] = ImageFont.truetype(self.font_path, size)
|
||||
except IOError:
|
||||
font_cache[size] = ImageFont.load_default()
|
||||
return font_cache[size]
|
||||
|
||||
def _query(qh, qw):
|
||||
return self.grid.query_direct(qh, qw, rs.randint(0, 2**31))
|
||||
|
||||
# v4: Ref-like linear step-down placement with bitmap occupancy
|
||||
# After each word placement, stamp glyph bitmap into C++ canvas
|
||||
# and rebuild integral for pixel-accurate collision detection.
|
||||
# No PIL image drawn during placement — to_image() renders later.
|
||||
|
||||
# Dummy draw for textbbox measurement
|
||||
_measure_img = Image.new("L", (1, 1))
|
||||
_measure_draw = ImageDraw.Draw(_measure_img)
|
||||
|
||||
for idx, (word, freq) in enumerate(sorted_freq):
|
||||
if freq == 0:
|
||||
continue
|
||||
|
||||
# Relative-scaling font size adjustment (mirrors ref logic)
|
||||
rs_val = self.relative_scaling
|
||||
if rs_val != 0:
|
||||
font_size = int(round(
|
||||
(rs_val * (freq / float(last_freq)) + (1 - rs_val)) * font_size
|
||||
))
|
||||
|
||||
if rs.random() < self.prefer_horizontal:
|
||||
orientation = None
|
||||
else:
|
||||
orientation = Image.ROTATE_90
|
||||
|
||||
tried_other_orientation = False
|
||||
|
||||
while True:
|
||||
if font_size < self.min_font_size:
|
||||
break
|
||||
|
||||
font = _get_font(font_size)
|
||||
transposed = ImageFont.TransposedFont(font, orientation=orientation)
|
||||
bbox = _measure_draw.textbbox((0, 0), word, font=transposed)
|
||||
tw = bbox[2] - bbox[0]
|
||||
th = bbox[3] - bbox[1]
|
||||
qh = th + self.margin
|
||||
qw = tw + self.margin
|
||||
|
||||
pos = _query(qh, qw)
|
||||
if pos is not None:
|
||||
break
|
||||
|
||||
# No position found — try alternate orientation, then reduce size
|
||||
if not tried_other_orientation and self.prefer_horizontal < 1:
|
||||
orientation = Image.ROTATE_90 if orientation is None else None
|
||||
tried_other_orientation = True
|
||||
else:
|
||||
font_size -= self.font_step
|
||||
orientation = None
|
||||
tried_other_orientation = False
|
||||
|
||||
if font_size < self.min_font_size:
|
||||
# Canvas full — no more words can fit
|
||||
break
|
||||
|
||||
y, x = pos
|
||||
# Adjust position for margin (like ref: x,y += margin // 2)
|
||||
draw_x = x + self.margin // 2
|
||||
draw_y = y + self.margin // 2
|
||||
|
||||
# Get glyph bitmap and stamp into C++ canvas
|
||||
font = _get_font(font_size)
|
||||
transposed = ImageFont.TransposedFont(font, orientation=orientation)
|
||||
glyph_mask = transposed.getmask(word, mode="L")
|
||||
gw, gh = glyph_mask.size
|
||||
glyph_arr = np.frombuffer(bytes(glyph_mask), dtype=np.uint8).reshape(gh, gw)
|
||||
|
||||
# Stamp glyph into C++ canvas + rebuild integral
|
||||
self.grid.stamp_and_rebuild(glyph_arr, gh, gw, draw_y, draw_x)
|
||||
|
||||
color = self.color_func(
|
||||
word=word,
|
||||
font_size=font_size,
|
||||
position=(y, x),
|
||||
orientation=orientation,
|
||||
font_path=self.font_path,
|
||||
random_state=rs,
|
||||
)
|
||||
self.layout_.append((word, font_size, (y, x), orientation, color))
|
||||
last_freq = freq
|
||||
|
||||
return self
|
||||
|
||||
def generate(self, text):
|
||||
"""Generate word cloud from raw text (calls process_text + generate_from_frequencies)."""
|
||||
return self.generate_from_text(text)
|
||||
|
||||
def generate_from_text(self, text):
|
||||
"""Process *text* into word frequencies, then generate the word cloud."""
|
||||
words = self.process_text(text)
|
||||
self.generate_from_frequencies(words)
|
||||
return self
|
||||
|
||||
def process_text(self, text):
|
||||
"""Tokenize *text* and return ``{word: count}`` after filtering.
|
||||
|
||||
Applies regexp splitting, stopword removal, number/length filters,
|
||||
plural normalization, and optional bigram collocation detection.
|
||||
"""
|
||||
min_len = self.min_word_length
|
||||
pattern = r"\w[\w']+" if min_len <= 1 else r"\w[\w']+"
|
||||
regexp = self.regexp if self.regexp is not None else pattern
|
||||
|
||||
words = re.findall(regexp, text)
|
||||
# Strip possessive 's
|
||||
words = [w[:-2] if w.lower().endswith("'s") else w for w in words]
|
||||
if not self.include_numbers:
|
||||
words = [w for w in words if not w.isdigit()]
|
||||
if self.min_word_length:
|
||||
words = [w for w in words if len(w) >= self.min_word_length]
|
||||
|
||||
stopwords_lower = {s.lower() for s in self.stopwords}
|
||||
|
||||
if self.collocations:
|
||||
word_counts = unigrams_and_bigrams(
|
||||
words, stopwords_lower,
|
||||
normalize_plurals=self.normalize_plurals,
|
||||
collocation_threshold=self.collocation_threshold,
|
||||
)
|
||||
else:
|
||||
words = [w for w in words if w.lower() not in stopwords_lower]
|
||||
word_counts, _ = process_tokens(words, self.normalize_plurals)
|
||||
|
||||
self.words_ = word_counts
|
||||
return word_counts
|
||||
|
||||
def to_image(self):
|
||||
"""Render the layout to a PIL Image, respecting *scale* and *contour*."""
|
||||
s = self.scale
|
||||
out_w = int(self.width * s)
|
||||
out_h = int(self.height * s)
|
||||
img = Image.new(self.mode, (out_w, out_h), self.background_color)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
for word, size, (y, x), orient, color in self.layout_:
|
||||
try:
|
||||
font = ImageFont.truetype(self.font_path, int(size * s))
|
||||
except Exception:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
transposed_font = ImageFont.TransposedFont(font, orientation=orient)
|
||||
draw.text((int(x * s), int(y * s)), word, font=transposed_font, fill=color)
|
||||
|
||||
return self._draw_contour(img)
|
||||
|
||||
def _draw_contour(self, img):
|
||||
"""Draw mask contour on *img* if contour_width > 0."""
|
||||
if self.mask is None or self.contour_width == 0:
|
||||
return img
|
||||
|
||||
# Build boolean mask: True where drawing area (not blocked)
|
||||
if self.mask.ndim == 3:
|
||||
blocked = np.all(self.mask[:, :, :3] == 255, axis=-1)
|
||||
else:
|
||||
blocked = self.mask == 255
|
||||
mask_uint8 = (~blocked).astype(np.uint8) * 255
|
||||
|
||||
contour = Image.fromarray(mask_uint8)
|
||||
contour = contour.resize(img.size)
|
||||
contour = contour.filter(ImageFilter.FIND_EDGES)
|
||||
contour_arr = np.array(contour)
|
||||
|
||||
# Zero out border pixels so edges aren't drawn at image boundary
|
||||
contour_arr[[0, -1], :] = 0
|
||||
contour_arr[:, [0, -1]] = 0
|
||||
|
||||
# Gaussian blur controls perceived width (divide by 10 for sub-pixel)
|
||||
radius = self.contour_width / 10
|
||||
contour = Image.fromarray(contour_arr)
|
||||
contour = contour.filter(ImageFilter.GaussianBlur(radius=radius))
|
||||
contour_arr = np.array(contour) > 0
|
||||
contour_3d = np.dstack([contour_arr] * 3)
|
||||
|
||||
result = np.array(img.convert("RGB")) * ~contour_3d
|
||||
if self.contour_color != "black":
|
||||
color_img = Image.new("RGB", img.size, self.contour_color)
|
||||
result = result + np.array(color_img) * contour_3d
|
||||
|
||||
out = Image.fromarray(result.astype(np.uint8))
|
||||
if self.mode == "RGBA":
|
||||
out = out.convert("RGBA")
|
||||
return out
|
||||
|
||||
def to_array(self, copy=None):
|
||||
"""Return the word cloud as a numpy ndarray (H x W x channels)."""
|
||||
image = self.to_image()
|
||||
if copy is None:
|
||||
return np.asarray(image)
|
||||
try:
|
||||
return np.asarray(image, copy=copy)
|
||||
except TypeError:
|
||||
return np.asarray(image)
|
||||
|
||||
def __array__(self, copy=None):
|
||||
return self.to_array(copy=copy)
|
||||
|
||||
def to_file(self, filename):
|
||||
"""Save to *filename* and return self (for chaining)."""
|
||||
img = self.to_image()
|
||||
img.save(filename, optimize=True)
|
||||
return self
|
||||
|
||||
def recolor(self, random_state=None, color_func=None, colormap=None):
|
||||
"""Re-apply colors to the current layout without regenerating it.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
random_state : int, Random, or None
|
||||
color_func : callable or None
|
||||
colormap : str or matplotlib colormap or None
|
||||
"""
|
||||
if isinstance(random_state, int):
|
||||
random_state = Random(random_state)
|
||||
elif random_state is None:
|
||||
random_state = Random()
|
||||
|
||||
if color_func is None:
|
||||
if colormap is not None:
|
||||
color_func = colormap_color_func(colormap)
|
||||
else:
|
||||
color_func = self.color_func
|
||||
|
||||
self.layout_ = [
|
||||
(word, font_size, position, orientation,
|
||||
color_func(word=word, font_size=font_size, position=position,
|
||||
orientation=orientation, font_path=self.font_path,
|
||||
random_state=random_state))
|
||||
for word, font_size, position, orientation, _ in self.layout_
|
||||
]
|
||||
return self
|
||||
|
||||
def to_svg(self, filename=None):
|
||||
"""Export as SVG with scale, correct rotation transforms and XML escaping.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
filename : str or None
|
||||
If given, write to this file. Otherwise return the SVG string.
|
||||
"""
|
||||
from xml.sax import saxutils
|
||||
s = self.scale
|
||||
out_w = int(self.width * s)
|
||||
out_h = int(self.height * s)
|
||||
|
||||
# Derive font metadata from the actual font file
|
||||
try:
|
||||
_font_probe = ImageFont.truetype(self.font_path, 12)
|
||||
raw_family, raw_style = _font_probe.getname()
|
||||
except Exception:
|
||||
raw_family, raw_style = "sans-serif", "Regular"
|
||||
|
||||
raw_style_lower = raw_style.lower()
|
||||
font_weight = "bold" if "bold" in raw_style_lower else "normal"
|
||||
if "italic" in raw_style_lower:
|
||||
font_style = "italic"
|
||||
elif "oblique" in raw_style_lower:
|
||||
font_style = "oblique"
|
||||
else:
|
||||
font_style = "normal"
|
||||
font_family = repr(raw_family)
|
||||
|
||||
lines = [
|
||||
f'<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
|
||||
@@ -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',
|
||||
)
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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.
@@ -0,0 +1 @@
|
||||
"""Core pipeline modules for the wordcloud generator."""
|
||||
@@ -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}")
|
||||
@@ -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"]
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 文档中按当前代码整理。
|
||||
@@ -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)
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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}",
|
||||
)
|
||||
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
+107
@@ -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"
|
||||
}
|
||||
Executable
+97
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
@@ -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
@@ -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` |
|
||||
@@ -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
@@ -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`。字体不可用会直接失败。
|
||||
@@ -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)。
|
||||
- 不再新增单次变更记录文档;短期变更应合并进标准文档。
|
||||
@@ -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`
|
||||
|
||||
需要确认行为时,优先查标准文档;标准文档仍不清楚时,直接查代码。
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.DS_Store
|
||||
*.log
|
||||
.claude
|
||||
.vscode
|
||||
.idea
|
||||
@@ -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;"]
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+1836
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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">°</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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_INDEX(0-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 起,默认 1(第2列)</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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',
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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_INDEX(null 表示不传)
|
||||
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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user