refactor(ppt): 工作目录收进隐藏 .build/ + 反卡片映射 + svg_preview 兜底/gate(bump 0.34.0)

累积一批(承接 ppt生成2 验证 + 用户"缺图形/卡片阵太多/文件夹过多"反馈):

- 工作目录重构:<project_dir> 根原本把"持久源 / 交付物 / 可再生构建产物"混摊。
  新增 project_utils.build_dir/svg_final_dir/preview_dir/backup_dir 单一事实源,
  把 svg_final→.build/svg_final、preview→.build/preview、backup→.build/backup/latest
  (只留最新,不再堆时间戳)。.build 是 dotfile → /v1/files 自动隐藏 → 用户可见面
  收敛到 源(sources/images/svg_output/notes/两个 spec)+ 交付物(exports)。改动:
  finalize_svg / svg_preview(_collect)/ pptx_discovery('final'→.build/svg_final)/
  pptx_cli(backup 路径 + rmtree 清旧)+ SKILL 工作目录约定/命令。端到端实测:根目录
  只剩 exports/+svg_output/,.build/ 三子目录就位,导出/预览/backup 全正常。

- 反卡片映射(治"大段大段卡片阵"):executor-base §page_rhythm 的 dense 行去掉
  "card grid 是 baseline"的背书;加一段硬映射「先看内容关系再选图形」(系统→
  hub_spoke/分层、流程→flow、层级→树/金字塔、循环→环、互依→mind_map、对比→象限、
  ≥3数据→图表),卡片阵封顶 ~1/3 页、连画两页网格下一关系页必须上示意图,指回 page_charts。

- svg_preview 加 cairosvg 兜底:find_browser 改返回 None 不抛错;无 chromium 时回退
  cairosvg,渲前用 embed_icons 预展开 <use data-icon> 成真 path(避 INVALID_MATRIX);
  修 --screenshot 相对路径静默失败(改绝对路径 + 暴露 chromium stderr)。

- 扁平 gate 计入 circle/polyline:svg_quality_checker 图形图元加 <circle>(node/venn/
  timeline 是真图,修 21-circle roadmap 误判);文字密集 deck ≥60% 页无图形 → ERROR。

架构结论(svg 目录):svg_output(可编辑源)与 svg_final(自包含编译产物)是两态、不能
合并成一个文件,但只暴露一个——现 svg_output 可见、svg_final 进 .build。终态(下一议题)
干掉持久化 svg_final、finalize 内存化 + web 按需预览,牵涉 web 层,本次未做。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-01 11:12:57 +08:00
parent 13835a315a
commit 5bde2445a0
10 changed files with 216 additions and 59 deletions

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-06-30(ppt skill 加商务红品牌预设 + 配图默认主动提议 + bump 0.33.5) 最后更新:2026-07-01(ppt skill 工作目录重构:中间物收进隐藏 .build/ + 反卡片映射 + svg_preview 兜底/gate + bump 0.34.0)
--- ---
@ -21,6 +21,18 @@
## 已完成关键能力 ## 已完成关键能力
### 2026-07-01 / ppt skill 工作目录重构:中间物收进隐藏 .build/(bump 0.34.0)
用户反馈"中间产物/文件夹过多"。架构判断:`<project_dir>` 根把三类混摊了——持久源(sources/images/svg_output/notes/两个 spec)、交付物(exports)、**可再生构建产物(svg_final/preview/backup)**;第三类是 build artifact,不该和源平级。修:新增 `project_utils.build_dir/svg_final_dir/preview_dir/backup_dir` 单一事实源,把 svg_final→`.build/svg_final`、preview→`.build/preview`、backup→`.build/backup/latest`(**只留最新**,不再堆时间戳)。`.build` 是 dotfile → `/v1/files` 自动隐藏 → 用户可见面从 ~11 降到"源+交付物"。改动:finalize_svg / svg_preview(_collect)/ pptx_discovery(`final`→`.build/svg_final`)/ pptx_cli(backup 路径 + rmtree 清旧)+ SKILL 工作目录约定/命令。端到端实测:根目录只剩 exports/+svg_output/,`.build/` 三子目录就位,导出/预览/backup 全正常。
> 关于"svg现在能 web 预览、要不要收敛成一个 svg 目录":架构上 svg_output(可编辑源:占位符+相对引用)与 svg_final(自包含编译产物:图标展开+图片 base64)是**两态**、不能合并成一个文件(可编辑 vs 浏览器忠实渲染冲突);但只该暴露一个——svg_output 可见、svg_final 进 .build。终态(下一议题):干掉持久化 svg_final,finalize 纯内存化 + web 忠实预览走"按需 finalize 再 serve",磁盘就一个 svg 目录。本次先做隐藏,未做内存化(牵涉 web 层)。
### 2026-07-01 / ppt skill 验证 ppt生成2 后修复:svg_preview cairosvg 兜底 + gate 计入 circle + 反卡片映射(bump 0.33.x→并入 0.34.0)
DB 取证验证「ppt生成2」(用户重跑,商务红+图标):图标 31 个(前 0)、商务红 #C00000、封面 imagegen 配图、扁平 gate 在跑 —— **代码类修复随 bind-mount 全部生效**。但视觉验收卡住:轨迹显示沙箱 `which chromium/cairosvg/rsvg` 全空、`svg_preview.py` 没被调用、模型自己 `pip install cairosvg` 渲 raw svg_output → **6/13 图标页 INVALID_MATRIX 失败**(cairosvg 不认 href-less `<use data-icon>`)。根因:**服务器沙箱镜像旧、没带 chromium 层**(镜像非 bind-mount,`deploy/update.sh` 第 4 步 rebuild 才更新;需服务器执行)。据此两处代码修复(用户选定):
- **svg_preview.py 加 cairosvg 兜底**:`find_browser()` 改返回 None 不抛错;无 chromium 时回退 cairosvg,且渲前**用 finalize 的 embed_icons 把 `<use data-icon>` 预展开成真 `<path>`**(避开 INVALID_MATRIX);顺带修上一版遗留的 `--screenshot` 绝对路径 + 保留 chromium 优先(保真更高)。browser happy-path 实测完好。
- **扁平 gate 计入 circle/polyline**:`svg_quality_checker` 图形图元加 `<circle>`(node/venn/bubble/timeline 是真图,之前把 21-circle roadmap 误判"无图形");并收紧——文字密集 deck **≥60% 页无图形 → ERROR**(不止"全 deck 0 图形"),4060% → INFO。实测:ceramic 式(46%)→INFO exit0、多数扁平(75%)→ERROR、极端→ERROR、全 circle→clean。
> 部署:视觉验收/PDF/mermaid 的根仍是镜像 —— 服务器跑 `sudo deploy/update.sh`(不加 --skip-build)rebuild `zcbot-sandbox`(Dockerfile 已含 chromium),存量 per-user 容器待 ensure() 用新镜像重建(必要时手动 docker rm 该用户旧容器)。
同批加 **执行层反卡片映射**(治"大段大段卡片阵"):验证 ppt生成2 发现 SVG 注释自写 "3x2 Card Grid"/"3x3 Grid"——执行模型对"N 个并列项"默认摊成卡片网格。executor-base §page_rhythm:`dense` 行去掉"card grid 是 baseline"的背书;加一段硬映射「先看内容**关系**再选图形」(系统→hub_spoke/分层、流程→flow、层级→树/金字塔、循环→环、互依→mind_map、对比→象限、≥3数据→图表),**卡片阵封顶 ~1/3 页**、连画两页网格下一关系页必须上示意图,并指回 page_charts(strategist 分配了模板就画那个别塌回卡片)。诚实边界:这是执行模型设计本能天花板,prompt 抬下限但不保证每张示意图都漂亮。
### 2026-06-30 / ppt skill 加商务红品牌预设 + 配图默认主动提议(bump 0.33.5) ### 2026-06-30 / ppt skill 加商务红品牌预设 + 配图默认主动提议(bump 0.33.5)
用户两个需求:(1) 加一款红色主题;(2) 用户没给图时在需要处主动配图。 用户两个需求:(1) 加一款红色主题;(2) 用户没给图时在需要处主动配图。
- **商务红品牌预设**:新增 `templates/brands/business-red/design_spec.md`(同 anthropic 格式:#C00000 全色表 + primary-deep/gold/info/positive/alert/surface/border/muted 派生色 + 宋体标题/黑体正文字体栈 + 实心图标偏好 + 政企口吻;无 logo,注明用文字 wordmark / 可后补)+ `brands_index.json` 加条目。**红色承载在 brand 而非 visual-style**(visual-style 不带色)。同时把**商务红设为 strategist §e 默认配色候选**:中文政企/集团/科研商务汇报默认列入 ≥3 候选(红金 #BF9B5F / 红蓝 #2B4C7E 二选一点缀,纯红只压标题/关键数据)。SKILL §默认主题 + 八条对齐 h 行同步指向。 - **商务红品牌预设**:新增 `templates/brands/business-red/design_spec.md`(同 anthropic 格式:#C00000 全色表 + primary-deep/gold/info/positive/alert/surface/border/muted 派生色 + 宋体标题/黑体正文字体栈 + 实心图标偏好 + 政企口吻;无 logo,注明用文字 wordmark / 可后补)+ `brands_index.json` 加条目。**红色承载在 brand 而非 visual-style**(visual-style 不带色)。同时把**商务红设为 strategist §e 默认配色候选**:中文政企/集团/科研商务汇报默认列入 ≥3 候选(红金 #BF9B5F / 红蓝 #2B4C7E 二选一点缀,纯红只压标题/关键数据)。SKILL §默认主题 + 八条对齐 h 行同步指向。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.33.5" __version__ = "0.34.0"

View File

@ -17,7 +17,7 @@ description: 生成 PowerPoint 演示文稿 (.pptx) 文件。✅ 触发:用户
**脚本**(host 上用 `.venv/Scripts/python.exe <skill_dir>/scripts/xxx.py ...` 跑;`<skill_dir>` = 本 skill 绝对路径): **脚本**(host 上用 `.venv/Scripts/python.exe <skill_dir>/scripts/xxx.py ...` 跑;`<skill_dir>` = 本 skill 绝对路径):
- `svg_quality_checker.py` —— **SVG 结构质检**(禁用特性 / viewBox / spec_lock 漂移 / 配色越界等)。引擎,自包含 - `svg_quality_checker.py` —— **SVG 结构质检**(禁用特性 / viewBox / spec_lock 漂移 / 配色越界等)。引擎,自包含
- `finalize_svg.py` —— **SVG 后处理**(图标内嵌 / 配图裁切内嵌 / tspan 展平 / 圆角矩形转 path)→ 产出 `svg_final/` - `finalize_svg.py` —— **SVG 后处理**(图标内嵌 / 配图裁切内嵌 / tspan 展平 / 圆角矩形转 path)→ 产出 `.build/svg_final/`(隐藏、可再生)
- `svg_to_pptx.py` —— **SVG → 原生 PPTX**(逐元素译 DrawingML;默认嵌演讲者备注 + Office 兼容 PNG 兜底) - `svg_to_pptx.py` —— **SVG → 原生 PPTX**(逐元素译 DrawingML;默认嵌演讲者备注 + Office 兼容 PNG 兜底)
- `total_md_split.py` —— 把 `notes/total.md` 拆成逐页备注(导出前跑) - `total_md_split.py` —— 把 `notes/total.md` 拆成逐页备注(导出前跑)
- `update_spec.py` —— 改 `spec_lock.md` 的颜色/字体后,**一键传播到所有已生成 SVG**(改稿用) - `update_spec.py` —— 改 `spec_lock.md` 的颜色/字体后,**一键传播到所有已生成 SVG**(改稿用)
@ -52,16 +52,17 @@ description: 生成 PowerPoint 演示文稿 (.pptx) 文件。✅ 触发:用户
├── images/ # 配图(imagegen 生成 / 用户提供 / 公式 PNG);SVG 里用 ../images/ 引用 ├── images/ # 配图(imagegen 生成 / 用户提供 / 公式 PNG);SVG 里用 ../images/ 引用
├── templates/ # 仅当用户给了模板路径才有(模板 SVG + 其 design_spec) ├── templates/ # 仅当用户给了模板路径才有(模板 SVG + 其 design_spec)
├── icons/ # 可选:项目本地图标(没有则 finalize 回退到 skill 的 templates/icons/) ├── icons/ # 可选:项目本地图标(没有则 finalize 回退到 skill 的 templates/icons/)
├── svg_output/*.svg # ★ executor 逐页手写的 SVG(视觉真相、改稿对象) ├── svg_output/*.svg # ★ executor 逐页手写的 SVG(视觉真相、改稿对象)—— 唯一可见的 svg 目录
├── svg_final/ # finalize 产出(图标/配图已内嵌,供预览)
├── notes/total.md # 演讲者备注(逐页),total_md_split 拆分后导出嵌入 ├── notes/total.md # 演讲者备注(逐页),total_md_split 拆分后导出嵌入
├── preview/ # svg_preview 渲的验收 PNG
├── exports/<slug>_<ts>.pptx # ★ 最终产物(原生 DrawingML,可编辑) ├── exports/<slug>_<ts>.pptx # ★ 最终产物(原生 DrawingML,可编辑)
├── backup/<ts>/svg_output/ # SVG 源快照(可不跑模型重新导出) ├── REVISIONS.md # 修订日志(见 §修订日志)
└── REVISIONS.md # 修订日志(见 §修订日志) └── .build/ # 可再生构建产物(dotfile 隐藏、随时可删;用户文件列表看不到)
├── svg_final/ # finalize 产出(图标/配图已内嵌,自包含;供 legacy 导出 + 忠实预览)
├── preview/ # svg_preview 渲的验收 PNG
└── backup/latest/svg_output/ # SVG 源快照(只留最新一份,可不跑模型重新导出)
``` ```
**所有产物写 `<project_dir>` 下**,不写 cwd / `skills/` / repo 根。 **所有产物写 `<project_dir>` 下**,不写 cwd / `skills/` / repo 根。**可见面 = 源 + 交付物**(sources/images/svg_output/notes/exports + 两个 spec + REVISIONS);派生的中间物(svg_final/preview/backup)一律进 `.build/`,由脚本自动落位,**不要手动在根目录建 svg_final/preview/backup**。
## 默认主题 — 自由设计(content-driven) ## 默认主题 — 自由设计(content-driven)
@ -151,7 +152,7 @@ references/visual-styles/<locked-style>.md # 锁定的视觉风格
.venv/Scripts/python.exe <skill_dir>/scripts/finalize_svg.py <project_dir> .venv/Scripts/python.exe <skill_dir>/scripts/finalize_svg.py <project_dir>
# 5.3 导出原生 PPTX(默认嵌备注 + Office 兼容 PNG 兜底) # 5.3 导出原生 PPTX(默认嵌备注 + Office 兼容 PNG 兜底)
.venv/Scripts/python.exe <skill_dir>/scripts/svg_to_pptx.py <project_dir> .venv/Scripts/python.exe <skill_dir>/scripts/svg_to_pptx.py <project_dir>
# 产物:exports/<slug>_<ts>.pptx(原生,读 svg_output/)+ backup/<ts>/svg_output/(源快照) # 产物:exports/<slug>_<ts>.pptx(原生,读 svg_output/)+ .build/backup/latest/svg_output/(源快照,只留最新)
``` ```
- ❌ 别用 `cp` 代替 finalize_svg(它做了多步关键处理);❌ 别加 `--only` / 强制 `-s output` - ❌ 别用 `cp` 代替 finalize_svg(它做了多步关键处理);❌ 别加 `--only` / 强制 `-s output`
- 动画可选:`-t fade`(翻页,默认)/ `-a auto`(逐元素入场,**默认 none**,用户要才开)。全表见 animations.md。 - 动画可选:`-t fade`(翻页,默认)/ `-a auto`(逐元素入场,**默认 none**,用户要才开)。全表见 animations.md。
@ -160,9 +161,9 @@ references/visual-styles/<locked-style>.md # 锁定的视觉风格
## 阶段六:验收(渲图肉眼/vision 看) ## 阶段六:验收(渲图肉眼/vision 看)
``` ```
.venv/Scripts/python.exe <skill_dir>/scripts/svg_preview.py <project_dir> --pages 1,3,5 -o <project_dir>/preview .venv/Scripts/python.exe <skill_dir>/scripts/svg_preview.py <project_dir> --pages 1,3,5
``` ```
`read` 渲出的 PNG 亲眼过:封面、一个内容页、一个 breathing 页 —— 看标题层级、卡片过挤/过空、文字是否都正常、节奏是否单调、配图位置。不通过的回阶段三改对应页 SVG 重跑。 PNG 默认落 `.build/preview/`(隐藏);优先渲 `.build/svg_final/`(图标/配图已内嵌,最忠实),没有则渲 `svg_output/`(无 chromium 时走 cairosvg 兜底、会就地展开图标)。`read` 渲出的 PNG 亲眼过:封面、一个内容页、一个 breathing 页 —— 看标题层级、卡片过挤/过空、文字是否都正常、节奏是否单调、配图位置。不通过的回阶段三改对应页 SVG 重跑。
> svg_preview 渲的是 SVG(视觉真相,与导出的 pptx 1:1),比渲最终 pptx 更早更准暴露观感问题。需要校验"SVG→DrawingML 转换是否保真",再开导出的 pptx 在 PowerPoint 里看。 > svg_preview 渲的是 SVG(视觉真相,与导出的 pptx 1:1),比渲最终 pptx 更早更准暴露观感问题。需要校验"SVG→DrawingML 转换是否保真",再开导出的 pptx 在 PowerPoint 里看。

View File

@ -133,11 +133,22 @@ Before drawing each page, look up its entry in `page_rhythm` (key format `P<NN>`
| Tag | Layout discipline | | Tag | Layout discipline |
|-----|-------------------| |-----|-------------------|
| `anchor` | Structural page (cover / chapter / TOC / ending). With a template, follow the matching template verbatim. In free design (no template), realize the page's §IX intent — for the cover deliver its `Cover impact` and for a closing page its `Closing impact` (the committed hook / takeaway + composition), never a default centered title + subtitle or a generic "Thank you" sign-off. | | `anchor` | Structural page (cover / chapter / TOC / ending). With a template, follow the matching template verbatim. In free design (no template), realize the page's §IX intent — for the cover deliver its `Cover impact` and for a closing page its `Closing impact` (the committed hook / takeaway + composition), never a default centered title + subtitle or a generic "Thank you" sign-off. |
| `dense` | Information-heavy. Card grids, multi-column layouts, KPI dashboards, tables, and charts are all permitted. This is the baseline behavior. | | `dense` | Information-heavy. Card grids, multi-column layouts, KPI dashboards, tables, and charts are all permitted — but a card grid is **not** the automatic default (see the anti-monotony rule below); pick the structure that fits the content's relationship. |
| `breathing` | Low-density impact page. Avoid **multi-card grid layouts** — do not organize content as multiple parallel rounded containers (3-card row, 4-card KPI grid, 2×2 matrix rendered as cards). Use naked text blocks, dividers, whitespace, or full-bleed imagery as the content structure. Single rounded visual elements (hero image corners, callouts, tags, one emphasis block) are fine — the rule is about grid structure, not about the `rx` attribute. Proportions follow information weight (not a preset ratio). Typical forms: hero quote, single large number with one-line interpretation, full-bleed image with floating caption, section transition. | | `breathing` | Low-density impact page. Avoid **multi-card grid layouts** — do not organize content as multiple parallel rounded containers (3-card row, 4-card KPI grid, 2×2 matrix rendered as cards). Use naked text blocks, dividers, whitespace, or full-bleed imagery as the content structure. Single rounded visual elements (hero image corners, callouts, tags, one emphasis block) are fine — the rule is about grid structure, not about the `rx` attribute. Proportions follow information weight (not a preset ratio). Typical forms: hero quote, single large number with one-line interpretation, full-bleed image with floating caption, section transition. |
> Without rhythm variation, every page defaults to card grids (the "AI-generated" look). `page_rhythm` is the only narrative lever that survives context compression. > Without rhythm variation, every page defaults to card grids (the "AI-generated" look). `page_rhythm` is the only narrative lever that survives context compression.
> 🚧 **Anti-monotony — map the content's RELATIONSHIP before defaulting to a card grid.** "N parallel items → an N×M grid of text cards" is the single biggest source of the AI-deck look. Before drawing cards, ask what the items *are to each other* and pick the structure that shows it:
> - **A system of interconnected parts** (六大体系 / 平台架构 / 能力地图) → hub-and-spoke, layered architecture, or module composition (`charts/hub_spoke`, `layered_architecture`, `module_composition`) — the connections ARE the message; a flat grid throws them away.
> - **A process / sequence / 历程** → flow with connectors or numbered steps (`charts/process_flow`, `numbered_steps`, `snake_flow`) or a timeline — arrows carry the "then".
> - **A hierarchy / breakdown** → tree or pyramid (`charts/top_down_tree`, `pyramid_chart`).
> - **A cycle / 闭环** → concentric or segmented wheel (`charts/concentric_circles`, `segmented_wheel`).
> - **Interdependent themes** → mind-map / network (`charts/mind_map`, `hub_spoke`).
> - **Comparison** → columns / quadrant / matrix (`charts/comparison_columns`, `matrix_2x2`, `quadrant_text_bullets`), not two stacks of cards.
> - **≥3 data points** → an actual chart (bar / line / donut …), never text cards of numbers.
>
> A plain card grid is the right answer ONLY for genuinely independent, unordered items with no relationship to show — and even then, vary card size by weight, add a connecting spine, or give each one icon. **Cap: at most ~1/3 of a deck's content pages may be plain card grids.** If you've just drawn two card-grid pages, the next relational page MUST be a diagram, not a third grid. Pull a `charts/` template's **geometry** as the starting structure (re-skin per §1 — structure not skin) so you are adapting a real diagram, not inventing connectors from scratch. This is why `spec_lock.md page_charts` matters: when Strategist assigned a template for this page, build THAT, don't collapse it back into cards.
**Missing `page_rhythm` section** → emit `warning: spec_lock.md missing page_rhythm — defaulting all pages to dense` once, fall back to `dense` for all pages. **Missing `page_rhythm` section** → emit `warning: spec_lock.md missing page_rhythm — defaulting all pages to dense` once, fall back to `dense` for all pages.
**Tag not found for current page** → emit `warning: spec_lock.md page_rhythm tag not found for P<NN> — falling back to dense` once per deck (aggregate; do not repeat per page), fall back to `dense`. Do not invent a tag. **Tag not found for current page** → emit `warning: spec_lock.md page_rhythm tag not found for P<NN> — falling back to dense` once per deck (aggregate; do not repeat per page), fall back to `dense`. Do not invent a tag.

View File

@ -133,8 +133,9 @@ def finalize_project(
compress: Compress images before embedding compress: Compress images before embedding
max_dimension: Downscale images exceeding this dimension max_dimension: Downscale images exceeding this dimension
""" """
from project_utils import svg_final_dir
svg_output = project_dir / 'svg_output' svg_output = project_dir / 'svg_output'
svg_final = project_dir / 'svg_final' svg_final = svg_final_dir(project_dir) # <project>/.build/svg_final (hidden, regenerable)
# Project-first: embed from the deck's own icons/ (synced library icons + # Project-first: embed from the deck's own icons/ (synced library icons +
# any custom icons), falling back to the global library per-icon. # any custom icons), falling back to the global library per-icon.
global_icons_dir = Path(__file__).parent.parent / 'templates' / 'icons' global_icons_dir = Path(__file__).parent.parent / 'templates' / 'icons'

View File

@ -76,6 +76,39 @@ CANVAS_FORMAT_ALIASES = {
'小红书': 'xiaohongshu', '小红书': 'xiaohongshu',
} }
# ── Regenerable build artifacts live under a single hidden dir ────────────────
# svg_output/ is the editable source of truth (stays visible at project root,
# sibling to images/ for its `../images/` refs). Everything derived from it —
# the self-contained svg_final/, preview PNGs, and source snapshots — is a build
# artifact: regenerable and safe to delete. Sweeping them under a dotfile dir
# keeps the visible project down to source + deliverable, and the /v1/files API
# hides dotfiles so the user never sees the clutter. See SKILL.md 工作目录约定.
#
# NOTE: the svg_to_pptx package uses the literal ".build/svg_final" /
# ".build/backup" to stay import-decoupled from this module — keep them in sync
# with BUILD_DIR_NAME here (it is not expected to ever change).
BUILD_DIR_NAME = ".build"
def build_dir(project_path) -> Path:
"""`<project>/.build` — hidden root for regenerable artifacts."""
return Path(project_path) / BUILD_DIR_NAME
def svg_final_dir(project_path) -> Path:
"""finalize output (self-contained SVGs: icons + images embedded)."""
return build_dir(project_path) / "svg_final"
def preview_dir(project_path) -> Path:
"""svg_preview PNGs for visual acceptance."""
return build_dir(project_path) / "preview"
def backup_dir(project_path) -> Path:
"""svg_output snapshot for re-export. Latest only (no timestamp pile-up)."""
return build_dir(project_path) / "backup" / "latest"
def normalize_canvas_format(format_key: str) -> str: def normalize_canvas_format(format_key: str) -> str:
"""Normalize canvas format key name (supports common aliases).""" """Normalize canvas format key name (supports common aliases)."""

View File

@ -50,7 +50,13 @@ _WHICH_NAMES = (
) )
def find_browser() -> str: def find_browser() -> str | None:
"""Return a chromium/chrome/edge executable path, or None if none found.
Returns None (not raise) so the caller can fall back to the cairosvg
renderer when no browser exists (e.g. a sandbox image built without the
chromium layer).
"""
# Explicit override first (matches rendering/pdf.py's CHROMIUM/CHROME env). # Explicit override first (matches rendering/pdf.py's CHROMIUM/CHROME env).
env = os.environ.get("CHROMIUM") or os.environ.get("CHROME") env = os.environ.get("CHROMIUM") or os.environ.get("CHROME")
if env and (shutil.which(env) or Path(env).exists()): if env and (shutil.which(env) or Path(env).exists()):
@ -62,11 +68,7 @@ def find_browser() -> str:
p = shutil.which(name) p = shutil.which(name)
if p: if p:
return p return p
raise SystemExit( return None
"[fatal] 未找到 Chrome / Edge / Chromium,无法渲染 SVG 预览。"
"沙箱镜像应自带 /usr/bin/chromium;本机可装 Chrome,或设 CHROMIUM 环境变量,"
"或用浏览器手动打开 svg_output/*.svg 验收。"
)
_VIEWBOX_RE = re.compile(r'viewBox\s*=\s*["\']\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*["\']') _VIEWBOX_RE = re.compile(r'viewBox\s*=\s*["\']\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*["\']')
@ -145,19 +147,21 @@ def render(browser: str, svg_path: Path, out_png: Path, scale: float = 2.0) -> b
def _collect(target: Path) -> tuple[list[Path], Path]: def _collect(target: Path) -> tuple[list[Path], Path]:
"""返回 (svg 文件列表, 默认输出目录)。""" """返回 (svg 文件列表, 默认输出目录)。"""
from project_utils import svg_final_dir, preview_dir
if target.is_file() and target.suffix.lower() == ".svg": if target.is_file() and target.suffix.lower() == ".svg":
return [target], target.parent / "preview" return [target], preview_dir(target.parent)
# 目录:优先 svg_final(finalize 后图标/配图已内嵌,渲出来最忠实); # 目录:优先 .build/svg_final(finalize 后图标/配图已内嵌,渲出来最忠实);
# 没 svg_final 就退而渲 svg_output(生成中验收,此时图标仍是占位符不显示) # 没有就退而渲 svg_output(生成中验收 —— cairosvg 兜底会就地展开图标,chromium
if (target / "svg_final").is_dir() and any((target / "svg_final").glob("*.svg")): # 直接渲则图标仍是占位符不显示)。
svg_dir = target / "svg_final" sf = svg_final_dir(target)
if sf.is_dir() and any(sf.glob("*.svg")):
svg_dir = sf
elif (target / "svg_output").is_dir(): elif (target / "svg_output").is_dir():
svg_dir = target / "svg_output" svg_dir = target / "svg_output"
else: else:
svg_dir = target svg_dir = target
files = sorted(svg_dir.glob("*.svg")) files = sorted(svg_dir.glob("*.svg"))
default_out = target / "preview" return files, preview_dir(target)
return files, default_out
def _select(files: list[Path], pages: str | None) -> list[Path]: def _select(files: list[Path], pages: str | None) -> list[Path]:
@ -171,6 +175,57 @@ def _select(files: list[Path], pages: str | None) -> list[Path]:
return [files[i] for i in idxs if 0 <= i < len(files)] return [files[i] for i in idxs if 0 <= i < len(files)]
def _expand_icons_for_cairo(svg_text: str) -> str:
"""Expand `<use data-icon>` placeholders to real `<g><path>` in-memory.
cairosvg does not understand the `data-icon` placeholder and errors on the
href-less `<use>` (observed: CAIRO_STATUS_INVALID_MATRIX on every icon-bearing
page). Reuse finalize's exact regex embedder so the fallback rasterizes the
same icons the native exporter draws. No-op when the SVG carries no
placeholders (e.g. already-finalized svg_final/).
"""
if 'data-icon' not in svg_text:
return svg_text
try:
from svg_finalize.embed_icons import process_svg_file, DEFAULT_ICONS_DIR
except Exception:
return svg_text # can't expand — let cairosvg try as-is
try:
import contextlib
import io as _io
with tempfile.TemporaryDirectory(prefix="svgprev-ico-") as tmp:
f = Path(tmp) / "x.svg"
f.write_text(svg_text, encoding="utf-8")
# Silence the embedder's per-file "[OK] x.svg (N icons)" chatter —
# the temp name is meaningless in preview output.
with contextlib.redirect_stdout(_io.StringIO()):
process_svg_file(f, DEFAULT_ICONS_DIR, dry_run=False, verbose=False,
fallback_dir=DEFAULT_ICONS_DIR)
return f.read_text(encoding="utf-8")
except Exception:
return svg_text
def render_cairosvg(cairosvg, svg_path: Path, out_png: Path, scale: float = 2.0) -> bool:
"""Rasterize one SVG via cairosvg (browser-free fallback). Returns True on success.
Icons are pre-expanded so cairosvg doesn't choke on `<use data-icon>`.
Fidelity is slightly below chromium (no JS, limited filter support), but it
lets visual acceptance run in a sandbox image built without chromium.
"""
svg_text = _expand_icons_for_cairo(
svg_path.read_text(encoding="utf-8", errors="ignore"))
out_png = out_png.resolve()
out_png.parent.mkdir(parents=True, exist_ok=True)
try:
cairosvg.svg2png(bytestring=svg_text.encode("utf-8"),
write_to=str(out_png), scale=scale)
except Exception as e:
print(f" [warn] cairosvg 渲染失败 {svg_path.name}: {e}")
return False
return out_png.exists() and out_png.stat().st_size > 0
def main() -> None: def main() -> None:
ap = argparse.ArgumentParser(description="把 SVG 页渲成 PNG 供肉眼/vision 验收") ap = argparse.ArgumentParser(description="把 SVG 页渲成 PNG 供肉眼/vision 验收")
ap.add_argument("target", type=Path, help="project_dir / svg 目录 / 单个 .svg 文件") ap.add_argument("target", type=Path, help="project_dir / svg 目录 / 单个 .svg 文件")
@ -187,12 +242,33 @@ def main() -> None:
raise SystemExit(f"[fatal] --pages {args.pages} 没选中任何页(共 {len(_collect(args.target)[0])} 页)") raise SystemExit(f"[fatal] --pages {args.pages} 没选中任何页(共 {len(_collect(args.target)[0])} 页)")
out_dir = args.out or default_out out_dir = args.out or default_out
# Renderer: prefer a real browser (chromium — highest fidelity, matches the
# native pptx). Fall back to cairosvg when no browser exists (sandbox image
# built without the chromium layer) so visual acceptance still runs.
browser = find_browser() browser = find_browser()
print(f"[svg_preview] browser={browser}") cairo = None
if browser:
print(f"[svg_preview] browser={browser}")
else:
try:
import cairosvg
cairo = cairosvg
print("[svg_preview] 未找到 chromium → 回退 cairosvg(图标已预展开;"
"保真度略低于 chromium。装 chromium 或设 CHROMIUM 可用浏览器渲染)")
except Exception:
raise SystemExit(
"[fatal] 未找到 Chrome / Edge / Chromium,也无 cairosvg 兜底。"
"沙箱镜像应自带 /usr/bin/chromium(rebuild sandbox 镜像),"
"或 `pip install cairosvg`,或设 CHROMIUM 环境变量。")
done = [] done = []
for svg in files: for svg in files:
png = out_dir / (svg.stem + ".png") png = out_dir / (svg.stem + ".png")
render(browser, svg, png, scale=args.scale) if browser:
render(browser, svg, png, scale=args.scale)
else:
render_cairosvg(cairo, svg, png, scale=args.scale)
if png.exists(): if png.exists():
done.append(png) done.append(png)
print(f" [ok] {svg.name} -> {png}") print(f" [ok] {svg.name} -> {png}")

View File

@ -1473,15 +1473,19 @@ class SVGQualityChecker:
def _check_graphic_richness(self, content: str, result: Dict) -> None: def _check_graphic_richness(self, content: str, result: Dict) -> None:
"""Tally graphic primitives per page for the deck-level flat-deck gate. """Tally graphic primitives per page for the deck-level flat-deck gate.
Counts <path>/<polyline>/<polygon>/<image> the elements that actually Counts <path>/<polyline>/<polygon>/<circle>/<image> the elements that
draw a diagram, chart, figure, or photo. <rect> and <line> are excluded actually draw a diagram, chart, figure, or photo. <circle> is included
on purpose: they are layout cards, backgrounds, and dividers, and a deck because node / bubble / venn / timeline diagrams are built from circles
built entirely from them is exactly the "text on rectangles" look this (excluding it false-flagged a 21-circle roadmap as "no figure"). <rect>
catches. Per-page nudges stay soft; the hard gate is deck-wide. and <line> stay excluded: they are layout cards, backgrounds, and
dividers, and a deck built only from them is the "text on rectangles"
look this catches. Icons don't count here — they have their own gate.
Per-page nudges stay soft; the hard gate is deck-wide.
""" """
g = (len(re.findall(r'<path\b', content)) g = (len(re.findall(r'<path\b', content))
+ len(re.findall(r'<polyline\b', content)) + len(re.findall(r'<polyline\b', content))
+ len(re.findall(r'<polygon\b', content)) + len(re.findall(r'<polygon\b', content))
+ len(re.findall(r'<circle\b', content))
+ len(re.findall(r'<image\b', content))) + len(re.findall(r'<image\b', content)))
result.setdefault('info', {})['graphic_count'] = g result.setdefault('info', {})['graphic_count'] = g
self._deck_page_count += 1 self._deck_page_count += 1
@ -1493,29 +1497,41 @@ class SVGQualityChecker:
def _print_graphic_summary(self): def _print_graphic_summary(self):
"""Deck-level flat-deck gate. """Deck-level flat-deck gate.
A content-rich deck (>=6 pages, text-heavy) with zero path/polygon/ Two hard-error bars for a text-heavy content deck (>=6 pages):
polyline/image deck-wide is the wall-of-text-boxes pathology hard - ZERO figure primitives deck-wide the wall-of-text-boxes pathology.
error so it can't ship. Below that bar, surface a soft note when most - >=60% of pages carry no figure mostly-flat (a few token diagrams
pages carry no figure. Short or sparse decks (<6 pages) are exempt to don't rescue a deck that is otherwise text + boxes + icons).
avoid false-failing minimalist / teaser decks. Between 40% and 60% is a soft INFO nudge. Short decks (<6 pages) are
exempt to avoid false-failing minimalist / teaser decks. Figure =
path/polygon/polyline/circle/image (see _check_graphic_richness).
""" """
pages = self._deck_page_count pages = self._deck_page_count
if pages < 6: if pages < 6:
return return
avg_text = self._deck_text_total / pages avg_text = self._deck_text_total / pages
no_g = len(self._pages_no_graphic)
frac = no_g / pages
_fix = ("Map content shape -> a visual: comparison->columns/quadrant, "
"timeline->process, share->donut, trend->line, ≥3 data points->chart "
"(adapt a templates/charts/ template or draw it), add diagrams/imagery, "
"then re-run.")
if self._deck_graphic_total == 0 and avg_text >= 10: if self._deck_graphic_total == 0 and avg_text >= 10:
self.summary['errors'] += 1 self.summary['errors'] += 1
print(f"\n[ERROR] Visual richness: {pages} text-heavy pages but ZERO " print(f"\n[ERROR] Visual richness: {pages} text-heavy pages but ZERO "
"diagram/figure primitives (<path>/<polygon>/<polyline>/<image>) " "diagram/figure primitives (<path>/<polygon>/<polyline>/<circle>/"
"deck-wide — the deck is text on rectangles.") "<image>) deck-wide — the deck is text on rectangles.")
print(" Map content shape -> a visual: comparison->columns/quadrant, " print(" " + _fix)
"timeline->process, share->donut, trend->line, ≥3 data points->chart " elif frac >= 0.6 and avg_text >= 10:
"(adapt a templates/charts/ template or draw it), add diagrams/imagery, " self.summary['errors'] += 1
"then re-run.") print(f"\n[ERROR] Visual richness: {no_g}/{pages} pages carry no diagram/"
elif len(self._pages_no_graphic) >= max(6, int(pages * 0.7)): "figure (<path>/<polygon>/<polyline>/<circle>/<image>) — the deck is "
print(f"\n[INFO] Visual richness: {len(self._pages_no_graphic)}/{pages} pages " "mostly text + boxes; a few token diagrams don't cover a data/analysis "
"have no diagram/figure (<path>/<polygon>/<polyline>/<image>) — " "deck.")
"verify dense content pages aren't just text + boxes.") print(" " + _fix)
elif frac >= 0.4:
print(f"\n[INFO] Visual richness: {no_g}/{pages} pages have no diagram/figure "
"(<path>/<polygon>/<polyline>/<circle>/<image>) — verify dense content "
"pages aren't just text + boxes.")
def print_summary(self): def print_summary(self):
"""Print check summary""" """Print check summary"""

View File

@ -125,7 +125,7 @@ def main(argv: list[str] | None = None) -> int:
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=f''' epilog=f'''
Examples: Examples:
%(prog)s examples/ppt169_demo -s final # Default: native pptx -> exports/, svg_output -> backup/<ts>/ %(prog)s examples/ppt169_demo -s final # Default: native pptx -> exports/, svg_output -> .build/backup/latest/
%(prog)s examples/ppt169_demo --svg-snapshot # Also emit SVG-rendered snapshot pptx alongside native in exports/ %(prog)s examples/ppt169_demo --svg-snapshot # Also emit SVG-rendered snapshot pptx alongside native in exports/
%(prog)s examples/ppt169_demo --only legacy # Only SVG image version (skips native) %(prog)s examples/ppt169_demo --only legacy # Only SVG image version (skips native)
%(prog)s examples/ppt169_demo -o out.pptx # Explicit path (no backup/) %(prog)s examples/ppt169_demo -o out.pptx # Explicit path (no backup/)
@ -223,7 +223,7 @@ Recorded narration:
help='Also emit the SVG-rendered snapshot pptx alongside the native pptx in exports/ ' help='Also emit the SVG-rendered snapshot pptx alongside the native pptx in exports/ '
'(named <project>_<ts>_svg.pptx). Off by default — the native pptx is the ' '(named <project>_<ts>_svg.pptx). Off by default — the native pptx is the '
'canonical output; live preview already provides the SVG visual reference. ' 'canonical output; live preview already provides the SVG visual reference. '
'Note: the svg_output/ source snapshot is always written to backup/<ts>/ ' 'Note: the svg_output/ source snapshot is always written to .build/backup/latest/ '
'regardless of this flag.') 'regardless of this flag.')
def non_negative_float(value: str) -> float: def non_negative_float(value: str) -> float:
@ -369,11 +369,12 @@ Recorded narration:
exports_dir = project_path / "exports" exports_dir = project_path / "exports"
exports_dir.mkdir(parents=True, exist_ok=True) exports_dir.mkdir(parents=True, exist_ok=True)
native_path = exports_dir / f"{project_name}_{timestamp}.pptx" native_path = exports_dir / f"{project_name}_{timestamp}.pptx"
# svg_output/ snapshot always goes under backup/<ts>/ in default-flow # svg_output/ snapshot goes under the hidden .build/backup/latest/ in
# mode (no -o). --svg-snapshot only controls the optional legacy # default-flow mode (no -o). Latest-only — no timestamp pile-up; the
# SVG-rendered pptx, which now sits alongside the native pptx in # persistent svg_output/ is the real source, this is just a re-export
# exports/ rather than nested inside backup/. # convenience copy. Literal ".build" keeps this package decoupled from
backup_dir = project_path / "backup" / timestamp # project_utils; keep in sync with BUILD_DIR_NAME.
backup_dir = project_path / ".build" / "backup" / "latest"
if gen_legacy: if gen_legacy:
legacy_path = exports_dir / f"{project_name}_{timestamp}_svg.pptx" legacy_path = exports_dir / f"{project_name}_{timestamp}_svg.pptx"
@ -656,13 +657,16 @@ Recorded narration:
# svg_output/ snapshot — runs once per export in default-flow mode, # svg_output/ snapshot — runs once per export in default-flow mode,
# decoupled from --svg-snapshot. Preserves the AI-generated SVG sources # decoupled from --svg-snapshot. Preserves the AI-generated SVG sources
# under backup/<ts>/svg_output/ for later inspection / re-export. # under .build/backup/latest/svg_output/ for later inspection / re-export.
# Latest-only: wipe any prior snapshot so backups don't pile up.
if success and backup_dir is not None: if success and backup_dir is not None:
svg_output_src = project_path / "svg_output" svg_output_src = project_path / "svg_output"
if svg_output_src.is_dir(): if svg_output_src.is_dir():
backup_dir.mkdir(parents=True, exist_ok=True)
svg_output_dst = backup_dir / "svg_output" svg_output_dst = backup_dir / "svg_output"
try: try:
if backup_dir.exists():
shutil.rmtree(backup_dir)
backup_dir.mkdir(parents=True, exist_ok=True)
shutil.copytree(svg_output_src, svg_output_dst) shutil.copytree(svg_output_src, svg_output_dst)
if verbose: if verbose:
print(f" svg_output backup: {svg_output_dst}") print(f" svg_output backup: {svg_output_dst}")

View File

@ -22,9 +22,12 @@ def find_svg_files(
Returns: Returns:
(list_of_svg_files, actual_directory_name) tuple. (list_of_svg_files, actual_directory_name) tuple.
""" """
# 'final' lives under the hidden .build/ (regenerable finalize output).
# Literal keeps this package import-decoupled; keep in sync with
# project_utils.BUILD_DIR_NAME (".build").
dir_map = { dir_map = {
'output': 'svg_output', 'output': 'svg_output',
'final': 'svg_final', 'final': '.build/svg_final',
} }
dir_name = dir_map.get(source, source) dir_name = dir_map.get(source, source)