From 5bde2445a0abe57335e5e3845e00e895c251298d Mon Sep 17 00:00:00 2001 From: caoqianming Date: Wed, 1 Jul 2026 11:12:57 +0800 Subject: [PATCH] =?UTF-8?q?refactor(ppt):=20=E5=B7=A5=E4=BD=9C=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E6=94=B6=E8=BF=9B=E9=9A=90=E8=97=8F=20.build/=20+=20?= =?UTF-8?q?=E5=8F=8D=E5=8D=A1=E7=89=87=E6=98=A0=E5=B0=84=20+=20svg=5Fprevi?= =?UTF-8?q?ew=20=E5=85=9C=E5=BA=95/gate(bump=200.34.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 累积一批(承接 ppt生成2 验证 + 用户"缺图形/卡片阵太多/文件夹过多"反馈): - 工作目录重构: 根原本把"持久源 / 交付物 / 可再生构建产物"混摊。 新增 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 预展开 成真 path(避 INVALID_MATRIX); 修 --screenshot 相对路径静默失败(改绝对路径 + 暴露 chromium stderr)。 - 扁平 gate 计入 circle/polyline:svg_quality_checker 图形图元加 (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) --- PROGRESS.md | 14 ++- core/__init__.py | 2 +- skills/ppt/SKILL.md | 21 ++-- skills/ppt/references/executor-base.md | 13 ++- skills/ppt/scripts/finalize_svg.py | 3 +- skills/ppt/scripts/project_utils.py | 33 ++++++ skills/ppt/scripts/svg_preview.py | 106 +++++++++++++++--- skills/ppt/scripts/svg_quality_checker.py | 56 +++++---- skills/ppt/scripts/svg_to_pptx/pptx_cli.py | 22 ++-- .../ppt/scripts/svg_to_pptx/pptx_discovery.py | 5 +- 10 files changed, 216 insertions(+), 59 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index fae9845..a16ada1 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `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) +用户反馈"中间产物/文件夹过多"。架构判断:`` 根把三类混摊了——持久源(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 ``)。根因:**服务器沙箱镜像旧、没带 chromium 层**(镜像非 bind-mount,`deploy/update.sh` 第 4 步 rebuild 才更新;需服务器执行)。据此两处代码修复(用户选定): +- **svg_preview.py 加 cairosvg 兜底**:`find_browser()` 改返回 None 不抛错;无 chromium 时回退 cairosvg,且渲前**用 finalize 的 embed_icons 把 `` 预展开成真 ``**(避开 INVALID_MATRIX);顺带修上一版遗留的 `--screenshot` 绝对路径 + 保留 chromium 优先(保真更高)。browser happy-path 实测完好。 +- **扁平 gate 计入 circle/polyline**:`svg_quality_checker` 图形图元加 ``(node/venn/bubble/timeline 是真图,之前把 21-circle roadmap 误判"无图形");并收紧——文字密集 deck **≥60% 页无图形 → ERROR**(不止"全 deck 0 图形"),40–60% → 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) 用户两个需求:(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 行同步指向。 diff --git a/core/__init__.py b/core/__init__.py index 999392e..e78ca17 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.33.5" +__version__ = "0.34.0" diff --git a/skills/ppt/SKILL.md b/skills/ppt/SKILL.md index c68bf77..442f676 100644 --- a/skills/ppt/SKILL.md +++ b/skills/ppt/SKILL.md @@ -17,7 +17,7 @@ description: 生成 PowerPoint 演示文稿 (.pptx) 文件。✅ 触发:用户 **脚本**(host 上用 `.venv/Scripts/python.exe /scripts/xxx.py ...` 跑;`` = 本 skill 绝对路径): - `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 兜底) - `total_md_split.py` —— 把 `notes/total.md` 拆成逐页备注(导出前跑) - `update_spec.py` —— 改 `spec_lock.md` 的颜色/字体后,**一键传播到所有已生成 SVG**(改稿用) @@ -52,16 +52,17 @@ description: 生成 PowerPoint 演示文稿 (.pptx) 文件。✅ 触发:用户 ├── images/ # 配图(imagegen 生成 / 用户提供 / 公式 PNG);SVG 里用 ../images/ 引用 ├── templates/ # 仅当用户给了模板路径才有(模板 SVG + 其 design_spec) ├── icons/ # 可选:项目本地图标(没有则 finalize 回退到 skill 的 templates/icons/) -├── svg_output/*.svg # ★ executor 逐页手写的 SVG(视觉真相、改稿对象) -├── svg_final/ # finalize 产出(图标/配图已内嵌,供预览) +├── svg_output/*.svg # ★ executor 逐页手写的 SVG(视觉真相、改稿对象)—— 唯一可见的 svg 目录 ├── notes/total.md # 演讲者备注(逐页),total_md_split 拆分后导出嵌入 -├── preview/ # svg_preview 渲的验收 PNG ├── exports/_.pptx # ★ 最终产物(原生 DrawingML,可编辑) -├── backup//svg_output/ # SVG 源快照(可不跑模型重新导出) -└── REVISIONS.md # 修订日志(见 §修订日志) +├── REVISIONS.md # 修订日志(见 §修订日志) +└── .build/ # 可再生构建产物(dotfile 隐藏、随时可删;用户文件列表看不到) + ├── svg_final/ # finalize 产出(图标/配图已内嵌,自包含;供 legacy 导出 + 忠实预览) + ├── preview/ # svg_preview 渲的验收 PNG + └── backup/latest/svg_output/ # SVG 源快照(只留最新一份,可不跑模型重新导出) ``` -**所有产物写 `` 下**,不写 cwd / `skills/` / repo 根。 +**所有产物写 `` 下**,不写 cwd / `skills/` / repo 根。**可见面 = 源 + 交付物**(sources/images/svg_output/notes/exports + 两个 spec + REVISIONS);派生的中间物(svg_final/preview/backup)一律进 `.build/`,由脚本自动落位,**不要手动在根目录建 svg_final/preview/backup**。 ## 默认主题 — 自由设计(content-driven) @@ -151,7 +152,7 @@ references/visual-styles/.md # 锁定的视觉风格 .venv/Scripts/python.exe /scripts/finalize_svg.py # 5.3 导出原生 PPTX(默认嵌备注 + Office 兼容 PNG 兜底) .venv/Scripts/python.exe /scripts/svg_to_pptx.py -# 产物:exports/_.pptx(原生,读 svg_output/)+ backup//svg_output/(源快照) +# 产物:exports/_.pptx(原生,读 svg_output/)+ .build/backup/latest/svg_output/(源快照,只留最新) ``` - ❌ 别用 `cp` 代替 finalize_svg(它做了多步关键处理);❌ 别加 `--only` / 强制 `-s output`。 - 动画可选:`-t fade`(翻页,默认)/ `-a auto`(逐元素入场,**默认 none**,用户要才开)。全表见 animations.md。 @@ -160,9 +161,9 @@ references/visual-styles/.md # 锁定的视觉风格 ## 阶段六:验收(渲图肉眼/vision 看) ``` -.venv/Scripts/python.exe /scripts/svg_preview.py --pages 1,3,5 -o /preview +.venv/Scripts/python.exe /scripts/svg_preview.py --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 里看。 diff --git a/skills/ppt/references/executor-base.md b/skills/ppt/references/executor-base.md index 8dc2e19..11b0bf3 100644 --- a/skills/ppt/references/executor-base.md +++ b/skills/ppt/references/executor-base.md @@ -133,11 +133,22 @@ Before drawing each page, look up its entry in `page_rhythm` (key format `P` | 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. | -| `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. | > 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. **Tag not found for current page** → emit `warning: spec_lock.md page_rhythm tag not found for P — falling back to dense` once per deck (aggregate; do not repeat per page), fall back to `dense`. Do not invent a tag. diff --git a/skills/ppt/scripts/finalize_svg.py b/skills/ppt/scripts/finalize_svg.py index cf7ef20..4d16286 100644 --- a/skills/ppt/scripts/finalize_svg.py +++ b/skills/ppt/scripts/finalize_svg.py @@ -133,8 +133,9 @@ def finalize_project( compress: Compress images before embedding max_dimension: Downscale images exceeding this dimension """ + from project_utils import svg_final_dir svg_output = project_dir / 'svg_output' - svg_final = project_dir / 'svg_final' + svg_final = svg_final_dir(project_dir) # /.build/svg_final (hidden, regenerable) # Project-first: embed from the deck's own icons/ (synced library icons + # any custom icons), falling back to the global library per-icon. global_icons_dir = Path(__file__).parent.parent / 'templates' / 'icons' diff --git a/skills/ppt/scripts/project_utils.py b/skills/ppt/scripts/project_utils.py index f85d747..25f23d4 100644 --- a/skills/ppt/scripts/project_utils.py +++ b/skills/ppt/scripts/project_utils.py @@ -76,6 +76,39 @@ CANVAS_FORMAT_ALIASES = { '小红书': '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: + """`/.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: """Normalize canvas format key name (supports common aliases).""" diff --git a/skills/ppt/scripts/svg_preview.py b/skills/ppt/scripts/svg_preview.py index daca6bf..8cd98be 100644 --- a/skills/ppt/scripts/svg_preview.py +++ b/skills/ppt/scripts/svg_preview.py @@ -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). env = os.environ.get("CHROMIUM") or os.environ.get("CHROME") if env and (shutil.which(env) or Path(env).exists()): @@ -62,11 +68,7 @@ def find_browser() -> str: p = shutil.which(name) if p: return p - raise SystemExit( - "[fatal] 未找到 Chrome / Edge / Chromium,无法渲染 SVG 预览。" - "沙箱镜像应自带 /usr/bin/chromium;本机可装 Chrome,或设 CHROMIUM 环境变量," - "或用浏览器手动打开 svg_output/*.svg 验收。" - ) + return None _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]: """返回 (svg 文件列表, 默认输出目录)。""" + from project_utils import svg_final_dir, preview_dir if target.is_file() and target.suffix.lower() == ".svg": - return [target], target.parent / "preview" - # 目录:优先 svg_final(finalize 后图标/配图已内嵌,渲出来最忠实); - # 没 svg_final 就退而渲 svg_output(生成中验收,此时图标仍是占位符不显示) - if (target / "svg_final").is_dir() and any((target / "svg_final").glob("*.svg")): - svg_dir = target / "svg_final" + return [target], preview_dir(target.parent) + # 目录:优先 .build/svg_final(finalize 后图标/配图已内嵌,渲出来最忠实); + # 没有就退而渲 svg_output(生成中验收 —— cairosvg 兜底会就地展开图标,chromium + # 直接渲则图标仍是占位符不显示)。 + sf = svg_final_dir(target) + if sf.is_dir() and any(sf.glob("*.svg")): + svg_dir = sf elif (target / "svg_output").is_dir(): svg_dir = target / "svg_output" else: svg_dir = target files = sorted(svg_dir.glob("*.svg")) - default_out = target / "preview" - return files, default_out + return files, preview_dir(target) 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)] +def _expand_icons_for_cairo(svg_text: str) -> str: + """Expand `` placeholders to real `` in-memory. + + cairosvg does not understand the `data-icon` placeholder and errors on the + href-less `` (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 ``. + 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: ap = argparse.ArgumentParser(description="把 SVG 页渲成 PNG 供肉眼/vision 验收") 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])} 页)") 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() - 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 = [] for svg in files: 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(): done.append(png) print(f" [ok] {svg.name} -> {png}") diff --git a/skills/ppt/scripts/svg_quality_checker.py b/skills/ppt/scripts/svg_quality_checker.py index f769674..448ed94 100644 --- a/skills/ppt/scripts/svg_quality_checker.py +++ b/skills/ppt/scripts/svg_quality_checker.py @@ -1473,15 +1473,19 @@ class SVGQualityChecker: def _check_graphic_richness(self, content: str, result: Dict) -> None: """Tally graphic primitives per page for the deck-level flat-deck gate. - Counts /// — the elements that actually - draw a diagram, chart, figure, or photo. and are excluded - on purpose: they are layout cards, backgrounds, and dividers, and a deck - built entirely from them is exactly the "text on rectangles" look this - catches. Per-page nudges stay soft; the hard gate is deck-wide. + Counts //// — the elements that + actually draw a diagram, chart, figure, or photo. is included + because node / bubble / venn / timeline diagrams are built from circles + (excluding it false-flagged a 21-circle roadmap as "no figure"). + and 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'=6 pages, text-heavy) with zero path/polygon/ - polyline/image deck-wide is the wall-of-text-boxes pathology — hard - error so it can't ship. Below that bar, surface a soft note when most - pages carry no figure. Short or sparse decks (<6 pages) are exempt to - avoid false-failing minimalist / teaser decks. + Two hard-error bars for a text-heavy content deck (>=6 pages): + - ZERO figure primitives deck-wide → the wall-of-text-boxes pathology. + - >=60% of pages carry no figure → mostly-flat (a few token diagrams + don't rescue a deck that is otherwise text + boxes + icons). + 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 if pages < 6: return 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: self.summary['errors'] += 1 print(f"\n[ERROR] Visual richness: {pages} text-heavy pages but ZERO " - "diagram/figure primitives (///) " - "deck-wide — the deck is text on rectangles.") - print(" 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.") - elif len(self._pages_no_graphic) >= max(6, int(pages * 0.7)): - print(f"\n[INFO] Visual richness: {len(self._pages_no_graphic)}/{pages} pages " - "have no diagram/figure (///) — " - "verify dense content pages aren't just text + boxes.") + "diagram/figure primitives (////" + ") deck-wide — the deck is text on rectangles.") + print(" " + _fix) + elif frac >= 0.6 and avg_text >= 10: + self.summary['errors'] += 1 + print(f"\n[ERROR] Visual richness: {no_g}/{pages} pages carry no diagram/" + "figure (////) — the deck is " + "mostly text + boxes; a few token diagrams don't cover a data/analysis " + "deck.") + print(" " + _fix) + elif frac >= 0.4: + print(f"\n[INFO] Visual richness: {no_g}/{pages} pages have no diagram/figure " + "(////) — verify dense content " + "pages aren't just text + boxes.") def print_summary(self): """Print check summary""" diff --git a/skills/ppt/scripts/svg_to_pptx/pptx_cli.py b/skills/ppt/scripts/svg_to_pptx/pptx_cli.py index b779a4b..0f7e066 100644 --- a/skills/ppt/scripts/svg_to_pptx/pptx_cli.py +++ b/skills/ppt/scripts/svg_to_pptx/pptx_cli.py @@ -125,7 +125,7 @@ def main(argv: list[str] | None = None) -> int: formatter_class=argparse.RawDescriptionHelpFormatter, epilog=f''' Examples: - %(prog)s examples/ppt169_demo -s final # Default: native pptx -> exports/, svg_output -> backup// + %(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 --only legacy # Only SVG image version (skips native) %(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/ ' '(named __svg.pptx). Off by default — the native pptx is the ' 'canonical output; live preview already provides the SVG visual reference. ' - 'Note: the svg_output/ source snapshot is always written to backup// ' + 'Note: the svg_output/ source snapshot is always written to .build/backup/latest/ ' 'regardless of this flag.') def non_negative_float(value: str) -> float: @@ -369,11 +369,12 @@ Recorded narration: exports_dir = project_path / "exports" exports_dir.mkdir(parents=True, exist_ok=True) native_path = exports_dir / f"{project_name}_{timestamp}.pptx" - # svg_output/ snapshot always goes under backup// in default-flow - # mode (no -o). --svg-snapshot only controls the optional legacy - # SVG-rendered pptx, which now sits alongside the native pptx in - # exports/ rather than nested inside backup/. - backup_dir = project_path / "backup" / timestamp + # svg_output/ snapshot goes under the hidden .build/backup/latest/ in + # default-flow mode (no -o). Latest-only — no timestamp pile-up; the + # persistent svg_output/ is the real source, this is just a re-export + # convenience copy. Literal ".build" keeps this package decoupled from + # project_utils; keep in sync with BUILD_DIR_NAME. + backup_dir = project_path / ".build" / "backup" / "latest" if gen_legacy: 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, # decoupled from --svg-snapshot. Preserves the AI-generated SVG sources - # under backup//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: svg_output_src = project_path / "svg_output" if svg_output_src.is_dir(): - backup_dir.mkdir(parents=True, exist_ok=True) svg_output_dst = backup_dir / "svg_output" 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) if verbose: print(f" svg_output backup: {svg_output_dst}") diff --git a/skills/ppt/scripts/svg_to_pptx/pptx_discovery.py b/skills/ppt/scripts/svg_to_pptx/pptx_discovery.py index abd4f5c..20f238e 100644 --- a/skills/ppt/scripts/svg_to_pptx/pptx_discovery.py +++ b/skills/ppt/scripts/svg_to_pptx/pptx_discovery.py @@ -22,9 +22,12 @@ def find_svg_files( Returns: (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 = { 'output': 'svg_output', - 'final': 'svg_final', + 'final': '.build/svg_final', } dir_name = dir_map.get(source, source)