From 3c712031d53f3abd06f77e32bbd905dc9c8f22b9 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Thu, 2 Jul 2026 13:37:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(ppt):=20=E6=B8=B2=E5=9B=BE=E9=AA=8C?= =?UTF-8?q?=E6=94=B6=E9=97=AD=E7=8E=AF+=E5=AF=BC=E5=87=BA=E9=AA=8C?= =?UTF-8?q?=E6=94=B6=E7=A1=AC=E9=97=A8+=E5=87=A0=E4=BD=95=E8=B4=A8?= =?UTF-8?q?=E6=A3=80(139a59c5=20=E9=94=99=E4=BD=8D=E5=A4=8D=E7=9B=98,bump?= =?UTF-8?q?=200.36.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复盘 25 页 deck 错位交付:阶段六全量渲图验收被整个跳过(svg_preview 0 调用, 进度步骤只跑了 echo),图标 regex 盲插压字、大字压说明、目录溢出页底全部漏出。 文档要求过但无机制强制,三层补齐: - A 机制:svg_preview 渲图登记 .build/acceptance.json(源 sha1+verdict); 新增 accept_pages.py 标 pass/fail(校验渲过+源未改);svg_to_pptx 导出 边界加验收硬门(每页 pass 且 sha1 未变,--allow-unreviewed 逃生) - B 提前拦截:svg_quality_checker 新增几何检测(估宽包围盒):图标压字/ 基线出画布=ERROR,文字重叠=WARN 带坐标(密排设计误伤权衡,判断交渲图 验收);tspan 按视觉行归组续排,71 charts 模板 0 error 误报 - C 文档:SKILL.md 管线改"后处理→渲图验收→导出",反模式加"没看 PNG 就 --pass-all""为消警告批量盲插元素";SKILL_LIST 同步 Co-Authored-By: Claude Fable 5 --- PROGRESS.md | 5 +- SKILL_LIST.md | 8 +- core/__init__.py | 2 +- skills/ppt/SKILL.md | 58 ++-- skills/ppt/scripts/accept_pages.py | 144 ++++++++++ skills/ppt/scripts/svg_preview.py | 116 +++++++- skills/ppt/scripts/svg_quality_checker.py | 316 +++++++++++++++++++++ skills/ppt/scripts/svg_to_pptx/pptx_cli.py | 105 +++++++ 8 files changed, 712 insertions(+), 42 deletions(-) create mode 100644 skills/ppt/scripts/accept_pages.py diff --git a/PROGRESS.md b/PROGRESS.md index 85ed77c..47d3522 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-07-01(加快捷指令:触发词→完整指令,入口层确定性展开 + bump 0.35.0) +最后更新:2026-07-02(ppt 渲图验收闭环 + 导出验收硬门 + 几何质检,bump 0.36.0) --- @@ -21,6 +21,9 @@ ## 已完成关键能力 +### 2026-07-02 / ppt 渲图验收闭环 + 导出验收硬门 + 几何质检(139a59c5 复盘,bump 0.36.0) +复盘 task 139a59c5(deepseek-v4-flash,25 页陶瓷节点方案):用户实报"很多地方错位"。本机 PowerPoint COM 渲全部 25 页定位三类错位:①图标压字/游离(P4/P5/P8/P10/P16/P24——质检报"缺图标"后模型写 `add_icons.py` **regex 批量盲插坐标**,插完没看);②大字号数字压说明文字(P5 万亿/26%);③目录溢出页底(P2)。**根因:SKILL 阶段六"全量渲图验收"被整个跳过**——进度步骤标 completed 但唯一动作是 `echo 交付清单`,`svg_preview` 全程 0 调用;文档要求了但无机制强制(与 0.35.1 教训同构:纯文档约束拦不住弱模型)。改动三层:**A 验收闭环+导出硬门(机制)**——`svg_preview.py` 渲 project 时登记 `.build/acceptance.json`(每页 svg_output 源 sha1 + rendered_from + verdict;svg_output 比 svg_final 新的页拒登记);新增 `accept_pages.py`(`--pass/--pass-all/--fail --reason/--status`,标 pass 前校验"渲过 + PNG 在 + 渲后源没改");`svg_to_pptx` 导出边界加验收门(spec_lock 存在时每页须 verdict=pass 且源 sha1 未变,finalize 前渲的也拒;`--allow-unreviewed` 逃生口)——"从没渲过就交付"和"改页不复看"在导出边界被确定性挡下,单页返工回路(`--pages N` 重渲 merge 记录)已本机全链路验证。**B 几何质检(提前拦截)**——`svg_quality_checker` 新增 check 13:按字符估宽(CJK≈1em/Latin≈0.5-0.7em)+ translate 累加构包围盒;**图标压字、基线出画布=ERROR**(几何精确),**文字-文字重叠一律 WARN 带精确坐标**(估宽分不清擦边与压字,词云/象限图等密排设计会误伤,判断权交渲图验收;SKILL 阶段四明确 Geometry warn 渲图时必须对着坐标看);tspan 按"视觉行"归组续排(`$4.2B (35%)` 是一行不是两段),71 个 charts 模板 0 error 误报、复刻事故的 fixture 全命中。**C 管线顺序+反模式(文档)**——SKILL.md 管线改"后处理→渲图验收→导出"(验收在导出前),阶段五=finalize+全量渲图+逐页过目+标记,阶段六=拆备注+导出(验收门+图标门双硬门);反模式加"没看 PNG 就 --pass-all"和"为消警告脚本批量盲插元素不复看"。SKILL_LIST 同步。已知边界:gate 只能强制"渲过、源没改",看没看 PNG 无法机器验证(--pass-all 仍可被糊弄,但本次事故"从不渲图"的直接通路已封死)。 + ### 2026-07-02 / ppt skill 补「禁自搓导出器」硬约束(966041e5 复盘,bump 0.35.1) 复盘同一 task 后续产物 `陶瓷资源节点建设方案 (3).pptx`(deepseek-v4-flash 跑):python-pptx 拆开验证 **25 页每页只有 1 张 1280×720 整页 PNG 贴图、零原生文本/形状**——skill「原生可编辑 DrawingML」的核心卖点全废。根因:模型**整条绕开官方管线**——DB 轨迹里 `svg_quality_checker / finalize_svg / svg_to_pptx / svg_preview / total_md_split` 官方脚本**调用次数全是 0**,取而代之自己 `pip install cairosvg` + 手搓 `export_pptx.py` 调 16 次,把每页 SVG 渲成 PNG 整页贴进幻灯片。连锁三个用户实报缺陷:①「很多方格子」= 跳过 finalize_svg,图标占位空心 rect 没内嵌;②「生成的图没放进去」= cairosvg 加载不了 `href="../images/*"` 外链(实测 file://+xlink 都渲空白),AI 配图全丢、事后靠 base64 补;③文字溢出出血被裁(P04/P05/P09)+ 标题 font-weight 因属性写坏(`serif" font-weight="bold"` 引号错位)丢加粗。**关键教训**:上一条(0.34.7)硬化的是官方工具**内部**的门(退出码/图标门/验收全量),但只在模型**用了**官方工具时才生效;本次证明模型可完全另起平行管线,内部门无从触发。改动(经用户拍板**只走文档层**、平台层自动检测暂缓):SKILL.md 阶段五加「🛑 导出唯一入口=官方 `svg_to_pptx.py`,默认原生可编辑、纯 Python 无需任何外部渲染器,'渲染器没装'永不是自搓借口」;反模式加「绕开官方管线自搓 SVG→PPTX 导出器 → 一叠不可编辑贴图、价值作废」。**注:仅改 skill 文档,不改线上跑法/官方脚本行为。** 已知残留风险:纯文档约束对'完全无视 skill'的弱模型拦截力有限,真正治本需平台层在 pptx 交付/预览路径自动检测整页贴图(本次未做)。 diff --git a/SKILL_LIST.md b/SKILL_LIST.md index 593cea0..fca0832 100644 --- a/SKILL_LIST.md +++ b/SKILL_LIST.md @@ -1,7 +1,7 @@ # zcbot Skill 清单 服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材) -最后更新:2026-06-29(ppt skill 重构为 SVG-first,移植自 ppt-master) +最后更新:2026-07-02(ppt skill 加渲图验收闭环 + 导出验收硬门 + 几何质检) Skill 总数:17 zcbot 的"skill"是一份可加载的工作流脚本(`skills//SKILL.md` + 配套 templates / scripts / Python helper),模型在识别用户意图后挂载对应 skill,按其内置的阶段化流程产出可交付物。本文档面向**使用方 / 协作方**,按"做什么、什么时候用、什么时候别用、典型产物"组织。 @@ -170,7 +170,7 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills//SKILL.md` + ### ppt **生成可编辑 PowerPoint 演示文稿 (.pptx)。SVG-first 路线。** -把材料(汇报草稿 / 项目方案 / 调研报告)变成可演示、**可编辑**的 .pptx。流程:**素材摄取 → 八条对齐 + 逐页大纲(spec)→ [配图] → 逐页手写 SVG → SVG 质检 → 后处理 → 导出 PPTX → 渲图验收**。核心是 AI 把每页当**矢量设计稿手写成 SVG**(设计自由度=浏览器级),再由纯 Python 转换器逐元素译成**原生 DrawingML**(形状/文本/渐变都能在 PowerPoint 里选中改)——告别 python-pptx 固定版式件的单调与 AI 味。 +把材料(汇报草稿 / 项目方案 / 调研报告)变成可演示、**可编辑**的 .pptx。流程:**素材摄取 → 八条对齐 + 逐页大纲(spec)→ [配图] → 逐页手写 SVG → SVG 质检 → 后处理 → 全量渲图验收 → 导出 PPTX**(导出边界硬门:每页都要渲图过目、标记 pass 且此后源未改动,否则拒绝产出 pptx)。核心是 AI 把每页当**矢量设计稿手写成 SVG**(设计自由度=浏览器级),再由纯 Python 转换器逐元素译成**原生 DrawingML**(形状/文本/渐变都能在 PowerPoint 里选中改)——告别 python-pptx 固定版式件的单调与 AI 味。 **触发**: - ✅ 用户明确点名 PPT / 幻灯片 / 演示文稿 / .pptx / slide / deck @@ -186,8 +186,8 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills//SKILL.md` + - **19 种视觉风格 + 5 种叙事骨架**:editorial / swiss-minimal / glassmorphism / dark-tech / data-journalism… × pyramid / narrative / instructional / showcase / briefing —— 去 AI 味的关键 - **模板库**:layouts(版式)/ decks(整套:中汽研/招商银行/重庆大学等)/ brands(品牌)/ charts(71 个图表信息图)/ icons(5 套共 1.1w+ 图标,finalize 自动内嵌) - **逐页节奏纪律**:论断式标题、page_rhythm(anchor/dense/breathing,breathing 页禁卡片墙)、内容→版式映射、图文版式 72 式 -- **SVG 质检** `svg_quality_checker.py`:禁用特性 / viewBox / spec_lock 漂移 / 配色越界(error 必改,回写 SVG) -- **渲图验收** `svg_preview.py`:无头 Chrome 把 SVG 渲成 PNG 肉眼/vision 验版面;`update_spec.py` 一键改色/字体传播到所有 SVG +- **SVG 质检** `svg_quality_checker.py`:禁用特性 / viewBox / spec_lock 漂移 / 配色越界 / **几何检测**(文本·图标包围盒估算,拦大字压说明、图标压字、行溢出画布)(error 必改,回写 SVG) +- **渲图验收闭环** `svg_preview.py` + `accept_pages.py`:无头 Chrome 全量渲 PNG 肉眼/vision 验版面,逐页标 pass/fail 落 `.build/acceptance.json`;**导出 gate 只认"渲过 + 看过标 pass + 渲后源未改(sha1)"**,跳验收/盲改混不过去;`update_spec.py` 一键改色/字体传播到所有 SVG - AI 配图走 imagegen skill;markitdown 素材摄取 **典型产物**:`exports/_.pptx`(原生可编辑)+ `svg_output/*.svg`(逐页设计源,改稿对象)+ `design_spec.md`/`spec_lock.md`。 diff --git a/core/__init__.py b/core/__init__.py index 13da7e7..7b00afb 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.35.1" +__version__ = "0.36.0" diff --git a/skills/ppt/SKILL.md b/skills/ppt/SKILL.md index b5f4837..c6c78b7 100644 --- a/skills/ppt/SKILL.md +++ b/skills/ppt/SKILL.md @@ -7,11 +7,11 @@ description: 生成 PowerPoint 演示文稿 (.pptx) 文件。✅ 触发:用户 把材料变成**可演示、可编辑**的 .pptx。 -**核心管线**:`素材 → 策略(spec)→ [配图] → 执行(逐页手写 SVG)→ SVG 质检 → 后处理 → 导出 PPTX → 渲图验收` +**核心管线**:`素材 → 策略(spec)→ [配图] → 执行(逐页手写 SVG)→ SVG 质检 → 后处理 → 渲图验收 → 导出 PPTX`(验收在导出**之前**;导出边界有硬门,没验收过的 deck 拒绝产出 pptx) > **为什么是 SVG**:不再用 python-pptx 拼固定版式件(那是版面单调/AI 味的天花板)。AI 把每页当**矢量设计稿手写成 SVG**(设计自由度 = 浏览器级),再由纯 Python 转换器逐元素译成**原生可编辑的 DrawingML**(形状/文本/渐变都能在 PowerPoint 里选中改)。SVG 与 DrawingML 是同一套"绝对坐标 2D 矢量"世界观的两种方言,转换是翻译而非格式硬凑。详见 `references/shared-standards.md`。 -> 进度展示:多页 deck 用 `task_progress` 标记「摄取素材 / 八条对齐 + 逐页大纲 / [配图] / 逐页 SVG / 质检 / 导出 + 验收」等关键阶段;不要把每页内部写入都当进度步骤。 +> 进度展示:多页 deck 用 `task_progress` 标记「摄取素材 / 八条对齐 + 逐页大纲 / [配图] / 逐页 SVG / 质检 / 渲图验收 / 导出」等关键阶段;不要把每页内部写入都当进度步骤。 ## 资源 @@ -21,7 +21,8 @@ description: 生成 PowerPoint 演示文稿 (.pptx) 文件。✅ 触发:用户 - `svg_to_pptx.py` —— **SVG → 原生 PPTX**(逐元素译 DrawingML;默认嵌演讲者备注 + Office 兼容 PNG 兜底) - `total_md_split.py` —— 把 `notes/total.md` 拆成逐页备注(导出前跑) - `update_spec.py` —— 改 `spec_lock.md` 的颜色/字体后,**一键传播到所有已生成 SVG**(改稿用) -- `svg_preview.py` —— **无头 Chrome 把 SVG 渲成 PNG** 供肉眼/vision 验收(SVG 是视觉真相;**替代**了浏览器 live preview) +- `svg_preview.py` —— **无头 Chrome 把 SVG 渲成 PNG** 供肉眼/vision 验收(SVG 是视觉真相;**替代**了浏览器 live preview);渲 project 目录时同步登记 `.build/acceptance.json` 验收记录(每页源 sha1 + verdict) +- `accept_pages.py` —— 看完 PNG 后**标记每页验收结论**(`--pass`/`--pass-all`/`--fail --reason`);标 pass 要求"渲过图 + 渲后源没改",导出 gate 只认 pass 页 - `project_utils.py` / `error_helper.py` —— 引擎辅助(canvas 校验 / 友好报错),被上面脚本 import,不直接调 **设计知识(references/,先读相关的,不默写)**: @@ -59,6 +60,7 @@ description: 生成 PowerPoint 演示文稿 (.pptx) 文件。✅ 触发:用户 └── .build/ # 可再生构建产物(dotfile 隐藏、随时可删;用户文件列表看不到) ├── svg_final/ # finalize 产出(图标/配图已内嵌,自包含;供 legacy 导出 + 忠实预览) ├── preview/ # svg_preview 渲的验收 PNG + ├── acceptance.json # 渲图验收记录(每页源 sha1 + verdict;导出 gate 依据) └── backup/latest/svg_output/ # SVG 源快照(只留最新一份,可不跑模型重新导出) ``` @@ -140,40 +142,45 @@ references/visual-styles/.md # 锁定的视觉风格 ``` .venv/Scripts/python.exe /scripts/svg_quality_checker.py ``` -- **任何 `error`(禁用特性 / viewBox 不符 / spec_lock 漂移 / **锁了图标 inventory 却全 deck 0 图标** / **内容 deck 全是文字方块(≥6 页且零 ``/``/``/``)** 等)必须改:回阶段三重写该页再跑**,不放过。 -- `warning`(低分辨率图 / 非 PPT 安全字体等):能顺手改就改,否则知会后放行。 +- **任何 `error`(禁用特性 / viewBox 不符 / spec_lock 漂移 / **图标压在文字上、文字基线超出画布**(Geometry 检测,几何精确)/ **锁了图标 inventory 却全 deck 0 图标** / **内容 deck 全是文字方块(≥6 页且零 ``/``/``/``)** 等)必须改:回阶段三重写该页再跑**,不放过。 +- `warning`(低分辨率图 / 非 PPT 安全字体等):能顺手改就改,否则知会后放行。**例外:`Geometry:` 开头的文字重叠 warning 不许无视** —— 它给了精确坐标,是"大字压说明 / 同行文字互侵"的高嫌疑点(估宽无法区分擦边与压字,所以只报 warn),阶段五渲图时**必须对着该页该坐标专门看**,压了就返工。 - 跑 `svg_output/`(不要在 finalize 后跑 —— finalize 改写 SVG 会掩盖源级违规)。 - ⚠️ **别用 `| head` / `| tail` 截断质检输出**:管道会把脚本的非零退出码换成 `head` 的 0(门形同虚设),`head` 还会截掉打在**最后**的 deck 级门结论(如零图标 `[ERROR]`)。原样跑,读完整输出、认它的退出码。 -## 阶段五:后处理 + 导出 +## 阶段五:后处理 + 渲图验收(强制门)—— 全量,不抽查 ⚠️ 三步**一步步来**,别合并成一条命令: ``` -# 5.1 拆备注 -.venv/Scripts/python.exe /scripts/total_md_split.py -# 5.2 SVG 后处理(图标/配图内嵌 / 文本展平 / 圆角转 path) +# 5.1 SVG 后处理(图标/配图内嵌 / 文本展平 / 圆角转 path) .venv/Scripts/python.exe /scripts/finalize_svg.py -# 5.3 导出原生 PPTX(默认嵌备注 + Office 兼容 PNG 兜底) +# 5.2 全量渲图(渲 .build/svg_final,同步登记 .build/acceptance.json 验收记录) +.venv/Scripts/python.exe /scripts/svg_preview.py +# 5.3 read/look_at_image 逐页过目后,标记验收结论 +.venv/Scripts/python.exe /scripts/accept_pages.py --pass-all +# (有问题的页:--fail <页名> --reason "…";只标部分页:--pass <页名…>;看状态:--status) +``` +- **默认渲整本,不带 `--pages`**。抽查 3 页只能覆盖 3 页,错位/文字溢出/元素重叠恰恰藏在没看的那些页里 —— 逐页手写绝对坐标,每页都可能翻车,所以**每页都要过目**。(页数多时可分批渲,但目标是 100% 覆盖,不是采样。) +- `read` / `look_at_image` **逐页**亲眼过:标题层级、卡片过挤/过空、**文字是否溢出卡片/被裁**、**元素是否重叠错位**、图标在不在(位置对不对)、节奏是否单调、配图位置。**看完才许标 pass** —— `--pass-all` 是"每页都看过且都合格"的宣告,不是跳过看的快捷键。 +- 🚧 **差评即阻断 + 返工回路**:任一页有排版/溢出/重叠/半成品问题(哪怕只是封面)→ **改那一页 svg_output 的 SVG → 重跑 finalize → `svg_preview.py --pages ` 重渲该页 → 复看 → 再标 pass**。机制会强制这个回路:标 pass 和导出 gate 都校验"渲图之后源文件没再改过"(sha1),改了不重渲重看,gate 过不去。不许"看了一页差评、跳去看下一页好评就收尾"——那正是错位交付的来路。 +- ❌ **禁止盲改**:修错位/补图标不许写脚本批量 regex 插元素、改完不看渲染结果(真实事故来源:质检提示缺图标后 regex 批量盲插,图标全压在文字上交付)。每处修改都要走上面的返工回路落到"复看"。 + +> svg_preview 渲的是 SVG(视觉真相,与导出的 pptx 1:1),比渲最终 pptx 更早更准暴露观感问题。需要校验"SVG→DrawingML 转换是否保真",再开导出的 pptx 在 PowerPoint 里看。 + +## 阶段六:导出 + +``` +# 6.1 拆备注 +.venv/Scripts/python.exe /scripts/total_md_split.py +# 6.2 导出原生 PPTX(默认嵌备注 + Office 兼容 PNG 兜底) .venv/Scripts/python.exe /scripts/svg_to_pptx.py # 产物:exports/_.pptx(原生,读 svg_output/)+ .build/backup/latest/svg_output/(源快照,只留最新) ``` +- 🚧 **导出边界验收门(硬)**:spec_lock 存在时,**每页都必须 verdict=pass 且渲图后源未改动**,否则导出 `[ERROR]` 退非零、不产出 pptx(`| head` 绕不过)。被拒就回阶段五补验收/走返工回路;`--allow-unreviewed` 只留给"确实不需要视觉验收"的极端场景,**不是跳过验收的捷径**。 +- 🚧 **导出边界图标门(硬)**:spec_lock 锁了 `icons.library` + 非空 `inventory` 但全 deck 零 `` → 同样 `[ERROR]` 退非零。正确做法是回阶段三给内容页补图标重跑;只有 lock 确实过期 / 有意做无图标 deck 才加 `--allow-iconless` 放行。 - 🛑 **导出唯一入口 = 官方 `svg_to_pptx.py`,严禁自写导出器**:它**默认产出原生可编辑 DrawingML**(形状/文本/渐变都能在 PowerPoint 里选中改),是**纯 Python、不依赖任何外部渲染器**(cairosvg / inkscape / rsvg-convert 一个都不需要)。所以**"某某渲染器没装"永远不是理由**——别 `pip install cairosvg` 也别手搓"SVG→PNG→整页贴图"的 `export_pptx.py`。自搓光栅导出器 = 整份变成一叠不可编辑的贴图(每页一张整页 PNG、零原生文本),**skill 核心价值直接归零、判废**。官方脚本跑不动就读它的报错按流程修 / 反馈,不要另起平行管线。 -- 🚧 **导出边界图标门(硬)**:spec_lock 锁了 `icons.library` + 非空 `inventory` 但全 deck 零 `` → 导出**直接 `[ERROR]` 退非零、不产出 pptx**(这是最后一道,`| head` 绕不过)。正确做法是回阶段三给内容页补图标重跑;只有 lock 确实过期 / 有意做无图标 deck 才加 `--allow-iconless` 放行。 - ❌ 别用 `cp` 代替 finalize_svg(它做了多步关键处理);❌ 别加 `--only` / 强制 `-s output`。 - 动画可选:`-t fade`(翻页,默认)/ `-a auto`(逐元素入场,**默认 none**,用户要才开)。全表见 animations.md。 -- 改稿:只改 `spec_lock.md` 的颜色/字体 → `update_spec.py ` 传播到所有 SVG;改版式/内容 → 重写对应页 SVG 再跑 5.2–5.3,**不要直接 edit 成品 .pptx**。 - -## 阶段六:验收(渲图肉眼/vision 看)—— 全量,不抽查 - -``` -.venv/Scripts/python.exe /scripts/svg_preview.py -``` -- **默认渲整本,不带 `--pages`**。抽查 3 页只能覆盖 3 页,错位/文字溢出/元素重叠恰恰藏在没看的那些页里 —— 逐页手写绝对坐标,每页都可能翻车,所以**每页都要过目**。(页数多时可分批渲,但目标是 100% 覆盖,不是采样。) -- PNG 默认落 `.build/preview/`(隐藏);优先渲 `.build/svg_final/`(图标/配图已内嵌,最忠实),没有则渲 `svg_output/`(无 chromium 时走 cairosvg 兜底、会就地展开图标)。 -- `read` / `look_at_image` **逐页**亲眼过:标题层级、卡片过挤/过空、**文字是否溢出卡片/被裁**、**元素是否重叠错位**、图标在不在、节奏是否单调、配图位置。 -- 🚧 **差评即阻断**:任一页被判出排版/溢出/重叠/半成品问题(哪怕只是封面)→ **回阶段三改那一页 SVG、重渲、复看,直到通过才算验收完**。不许"看了一页差评、跳去看下一页好评就收尾"——那正是错位交付的来路。 - -> svg_preview 渲的是 SVG(视觉真相,与导出的 pptx 1:1),比渲最终 pptx 更早更准暴露观感问题。需要校验"SVG→DrawingML 转换是否保真",再开导出的 pptx 在 PowerPoint 里看。 +- 改稿:只改 `spec_lock.md` 的颜色/字体 → `update_spec.py ` 传播到所有 SVG(所有页源都变了 → **重跑阶段五全量重渲重标**,顺手把全本再过一遍眼);改版式/内容 → 重写对应页 SVG 再走阶段五返工回路 + 6.2,**不要直接 edit 成品 .pptx**。 完成后:用 `update_spec` / 重写页迭代;用户确认**实质改动**后追加一行到 `REVISIONS.md`。 @@ -206,7 +213,8 @@ references/visual-styles/.md # 锁定的视觉风格 - **breathing 页堆多卡网格**(违节奏,显 AI 味) - 模板照搬不重上皮(直接用模板默认渐变/阴影/字号) - 质检没过就交付 / 直接 edit 成品 .pptx 改稿 -- **只渲/只看几页就收尾**(错位藏在没看的页里);**看到差评却不返工**(封面 vision 说"半成品/挤左侧"还继续导出交付) +- **只渲/只看几页就收尾**(错位藏在没看的页里);**看到差评却不返工**(封面 vision 说"半成品/挤左侧"还继续导出交付);**没看 PNG 就 `accept_pages --pass-all`**(把验收门当橡皮图章 —— gate 只能强制"渲过、源没改",看没看只有你自己知道,糊弄的结果就是错位 deck 交到用户手上) +- **质检/渲图后为消警告写脚本批量盲插元素**(regex 批量加图标、改坐标,改完不复看渲染 —— 真实事故:25 页 deck 图标全压在文字上交付) - **用 `| head` 截断质检或导出输出**(吞非零退出码 + 截掉最后的门结论,门形同虚设) - 起名 `output.pptx` —— 按主题命名 diff --git a/skills/ppt/scripts/accept_pages.py b/skills/ppt/scripts/accept_pages.py new file mode 100644 index 0000000..3d1cd11 --- /dev/null +++ b/skills/ppt/scripts/accept_pages.py @@ -0,0 +1,144 @@ +"""accept_pages.py: 渲图验收的 verdict 记录器(与 svg_preview.py / 导出 gate 配套)。 + +流程:svg_preview.py 渲图并登记 `.build/acceptance.json` → 逐页 read/look_at_image +过目 → 用本脚本把看过且合格的页标 pass(有问题的标 fail 并写原因)→ 全部 pass +后 svg_to_pptx.py 才肯导出。 + +用法: + python accept_pages.py --status # 看各页验收状态 + python accept_pages.py --pass P01_cover P02_toc + python accept_pages.py --pass-all # 全部标 pass + python accept_pages.py --fail P05_kpi --reason "大字压说明文字" + +标 pass 的前提(逐页校验,不满足的页拒标并退非零): +- 该页渲过图(acceptance.json 有记录、PNG 还在); +- 渲图之后 svg_output 源没再改过(sha1 一致)—— 改了就得重跑 finalize + + svg_preview 再来,盲改混不过 gate。 +""" +from __future__ import annotations + +import argparse +import hashlib +import sys +try: # zcbot: Windows GBK 控制台兼容 + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") +except Exception: + pass +from datetime import datetime +from pathlib import Path + +from svg_preview import acceptance_path, load_acceptance, save_acceptance + + +def _sha1(path: Path) -> str: + return hashlib.sha1(path.read_bytes()).hexdigest() + + +def _page_problems(project_root: Path, stem: str, entry: dict) -> list[str]: + """标 pass 前的逐页前提校验;返回问题列表(空 = 可标)。""" + problems = [] + png = Path(entry.get("png") or "") + if not png.is_absolute(): + png = project_root / png + if not entry.get("png") or not png.exists(): + problems.append("渲染 PNG 不存在 —— 先跑 svg_preview.py") + src = project_root / "svg_output" / f"{stem}.svg" + if not src.exists(): + src = Path(entry.get("source") or "") + if str(src) and not src.is_absolute(): + src = project_root / src + if not src.exists(): + problems.append("找不到源 SVG") + elif _sha1(src) != entry.get("source_sha1"): + problems.append("渲图后源 SVG 又改过 —— 重跑 finalize_svg + svg_preview 再标") + return problems + + +def main() -> int: + ap = argparse.ArgumentParser(description="标记渲图验收 verdict(配合导出 gate)") + ap.add_argument("project_dir", type=Path) + g = ap.add_mutually_exclusive_group(required=True) + g.add_argument("--status", action="store_true", help="打印各页验收状态") + g.add_argument("--pass", dest="mark_pass", nargs="+", metavar="PAGE", + help="把指定页(文件名去 .svg)标为 pass") + g.add_argument("--pass-all", action="store_true", + help="把所有已渲页标为 pass(仍逐页校验 sha/PNG)") + g.add_argument("--fail", dest="mark_fail", nargs="+", metavar="PAGE", + help="把指定页标为 fail(配 --reason)") + ap.add_argument("--reason", default="", help="fail 的原因,写进记录") + args = ap.parse_args() + + root = args.project_dir + if not root.is_dir(): + print(f"[fatal] 不是目录:{root}") + return 1 + data = load_acceptance(root) + pages: dict = data["pages"] + svg_dir = root / "svg_output" + all_stems = sorted(p.stem for p in svg_dir.glob("*.svg")) if svg_dir.is_dir() else [] + unrendered = [s for s in all_stems if s not in pages] + + if args.status: + if not pages and not all_stems: + print(f"[status] 无验收记录也无 svg_output:{acceptance_path(root)}") + return 1 + counts = {"pass": 0, "fail": 0, "pending": 0} + for stem in sorted(pages): + e = pages[stem] + v = e.get("verdict", "pending") + counts[v if v in counts else "pending"] += 1 + extra = "" + if v == "pass" and _page_problems(root, stem, e): + extra = " <- 源已改动/PNG 丢失,导出 gate 会拒(需重渲重标)" + reason = f" ({e.get('reason')})" if e.get("reason") else "" + print(f" {v:8s} {stem}{reason}{extra}") + for stem in unrendered: + print(f" {'-':8s} {stem} <- 从未渲过") + print(f"[status] pass {counts['pass']} / fail {counts['fail']} / " + f"pending {counts['pending']} / 未渲 {len(unrendered)}") + return 0 + + now = datetime.now().isoformat(timespec="seconds") + + if args.mark_fail: + missing = [s for s in args.mark_fail if s not in pages] + if missing: + print(f"[fatal] 无渲染记录的页:{', '.join(missing)}(先 svg_preview)") + return 1 + for stem in args.mark_fail: + pages[stem].update(verdict="fail", verdict_at=now, + reason=args.reason or "unspecified") + print(f" [fail] {stem}" + (f":{args.reason}" if args.reason else "")) + save_acceptance(root, data) + return 0 + + targets = sorted(pages) if args.pass_all else args.mark_pass + missing = [s for s in targets if s not in pages] + if missing: + print(f"[fatal] 无渲染记录的页:{', '.join(missing)}(先 svg_preview," + "页名 = SVG 文件名去 .svg)") + return 1 + refused = 0 + for stem in targets: + problems = _page_problems(root, stem, pages[stem]) + if problems: + refused += 1 + print(f" [refused] {stem}:{'; '.join(problems)}") + continue + pages[stem].update(verdict="pass", verdict_at=now) + pages[stem].pop("reason", None) + print(f" [pass] {stem}") + save_acceptance(root, data) + if unrendered and args.pass_all: + print(f" [warn] {len(unrendered)} 页从未渲过、不在记录里:" + f"{', '.join(unrendered[:8])}{' …' if len(unrendered) > 8 else ''}") + if refused: + print(f"[accept_pages] {refused} 页被拒 —— 重跑 finalize_svg + svg_preview " + "后再标。") + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/ppt/scripts/svg_preview.py b/skills/ppt/scripts/svg_preview.py index 8cd98be..5ff31a1 100644 --- a/skills/ppt/scripts/svg_preview.py +++ b/skills/ppt/scripts/svg_preview.py @@ -12,13 +12,21 @@ SVG-first 管线里 SVG 就是视觉真相(导出的 pptx 与之 1:1),所以验 约定:优先渲 /svg_output;没有则退而渲 本身。 依赖:本机装了 Chrome 或 Edge(无需 pip 包)。两者都没有则报错退出。 产物默认 2x 超采样,够清晰看版面。 + +渲 project 目录时同步维护 `.build/acceptance.json` 验收记录(每页:源 sha1 + +渲染时间 + verdict)。看完 PNG 后用 accept_pages.py 标 pass/fail;svg_to_pptx +的导出 gate 要求每页 verdict=pass 且源文件此后未改动 —— "从没渲过就交付"和 +"改了页不复看"都会被导出边界挡下。 """ from __future__ import annotations import argparse +import hashlib +import json import re import subprocess import sys +from datetime import datetime try: # zcbot: Windows GBK 控制台兼容,避免 emoji/© 等触发 UnicodeEncodeError sys.stdout.reconfigure(encoding="utf-8", errors="replace") sys.stderr.reconfigure(encoding="utf-8", errors="replace") @@ -145,23 +153,106 @@ def render(browser: str, svg_path: Path, out_png: Path, scale: float = 2.0) -> b return False -def _collect(target: Path) -> tuple[list[Path], Path]: - """返回 (svg 文件列表, 默认输出目录)。""" +def _collect(target: Path) -> tuple[list[Path], Path, Path | None, str]: + """返回 (svg 文件列表, 默认输出目录, project_root 或 None, 渲染来源标签)。 + + project_root 非 None 时(target 是标准 project 目录),渲染结束后会把每页 + 写进 `.build/acceptance.json` 验收记录 —— 导出 gate 依赖它。 + """ from project_utils import svg_final_dir, preview_dir if target.is_file() and target.suffix.lower() == ".svg": - return [target], preview_dir(target.parent) + return [target], preview_dir(target.parent), None, "file" # 目录:优先 .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 + svg_dir, source = sf, "svg_final" elif (target / "svg_output").is_dir(): - svg_dir = target / "svg_output" + svg_dir, source = target / "svg_output", "svg_output" else: - svg_dir = target + svg_dir, source = target, "dir" files = sorted(svg_dir.glob("*.svg")) - return files, preview_dir(target) + root = target if source in ("svg_final", "svg_output") else None + return files, preview_dir(target), root, source + + +def _sha1(path: Path) -> str: + return hashlib.sha1(path.read_bytes()).hexdigest() + + +def acceptance_path(project_root: Path) -> Path: + return project_root / ".build" / "acceptance.json" + + +def load_acceptance(project_root: Path) -> dict: + p = acceptance_path(project_root) + if p.exists(): + try: + data = json.loads(p.read_text(encoding="utf-8")) + if isinstance(data, dict) and isinstance(data.get("pages"), dict): + return data + except (json.JSONDecodeError, OSError): + pass # 损坏就重建 —— 记录本身可再生 + return {"version": 1, "pages": {}} + + +def save_acceptance(project_root: Path, data: dict) -> Path: + p = acceptance_path(project_root) + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8") + return p + + +def record_renders(project_root: Path, source: str, + rendered: list[tuple[Path, Path]]) -> None: + """把本轮渲好的页写进验收记录(merge,verdict 归 pending)。 + + 每条记录锚定 svg_output 源文件的 sha1:accept_pages 标 pass、导出 gate 放行 + 都要求源文件此后未再改动 —— 改一页就必须重渲重看那一页,盲改混不过去。 + 源比渲染对象新(改了 svg_output 没重跑 finalize)的页跳过不记,并提示。 + """ + data = load_acceptance(project_root) + pages = data["pages"] + now = datetime.now().isoformat(timespec="seconds") + stale: list[str] = [] + + def rel(p: Path) -> str: + # 存相对项目根的路径,项目目录整个挪走记录仍有效;跨盘等场景退绝对路径 + try: + return str(p.resolve().relative_to(project_root.resolve())) + except ValueError: + return str(p.resolve()) + + for svg, png in rendered: + src = project_root / "svg_output" / svg.name + if not src.exists(): + src = svg + if source == "svg_final" and src.stat().st_mtime > svg.stat().st_mtime + 1: + stale.append(svg.stem) + continue + sha = _sha1(src) + old = pages.get(svg.stem) or {} + keep_verdict = old.get("verdict") if old.get("source_sha1") == sha else None + pages[svg.stem] = { + "png": rel(png), + "source": rel(src), + "source_sha1": sha, + "rendered_from": source, + "rendered_at": now, + "verdict": keep_verdict or "pending", + **({"verdict_at": old["verdict_at"]} + if keep_verdict and old.get("verdict_at") else {}), + } + p = save_acceptance(project_root, data) + if stale: + print(f" [warn] {len(stale)} 页的 svg_output 比 .build/svg_final 新," + f"渲的是旧版、未记入验收:{', '.join(stale[:8])}" + f"{' …' if len(stale) > 8 else ''} —— 先重跑 finalize_svg 再渲。") + pending = sum(1 for v in pages.values() if v.get("verdict") != "pass") + print(f"[svg_preview] 验收记录已更新:{p}(pending {pending} 页)") + print(" 下一步:read/look_at_image 逐页过目,再用 accept_pages.py 标记 " + "--pass <页名…> / --pass-all;导出 gate 只认 pass 且源未改动的页。") def _select(files: list[Path], pages: str | None) -> list[Path]: @@ -234,12 +325,13 @@ def main() -> None: ap.add_argument("--scale", type=float, default=2.0, help="超采样倍数,默认 2") args = ap.parse_args() - files, default_out = _collect(args.target) + files, default_out, project_root, source = _collect(args.target) if not files: raise SystemExit(f"[fatal] 没找到 SVG:{args.target}") + all_count = len(files) files = _select(files, args.pages) if not files: - raise SystemExit(f"[fatal] --pages {args.pages} 没选中任何页(共 {len(_collect(args.target)[0])} 页)") + raise SystemExit(f"[fatal] --pages {args.pages} 没选中任何页(共 {all_count} 页)") out_dir = args.out or default_out @@ -262,7 +354,7 @@ def main() -> None: "沙箱镜像应自带 /usr/bin/chromium(rebuild sandbox 镜像)," "或 `pip install cairosvg`,或设 CHROMIUM 环境变量。") - done = [] + done: list[tuple[Path, Path]] = [] for svg in files: png = out_dir / (svg.stem + ".png") if browser: @@ -270,11 +362,13 @@ def main() -> None: else: render_cairosvg(cairo, svg, png, scale=args.scale) if png.exists(): - done.append(png) + done.append((svg, png)) print(f" [ok] {svg.name} -> {png}") else: print(f" [FAIL] {svg.name} 未生成 PNG") print(f"[svg_preview] {len(done)}/{len(files)} 页渲好,输出目录:{out_dir}") + if project_root is not None and done: + record_renders(project_root, source, done) if __name__ == "__main__": diff --git a/skills/ppt/scripts/svg_quality_checker.py b/skills/ppt/scripts/svg_quality_checker.py index 448ed94..41bec98 100644 --- a/skills/ppt/scripts/svg_quality_checker.py +++ b/skills/ppt/scripts/svg_quality_checker.py @@ -331,6 +331,13 @@ class SVGQualityChecker: if not self.template_mode: self._check_graphic_richness(content, result) + # 13. Geometry lint: estimated text/icon bounding boxes → + # text-on-text / icon-on-text overlap + off-canvas elements. + # Templates carry {{PLACEHOLDER}} text whose rendered width + # is unrepresentative, so skip in template mode. + if not self.template_mode: + self._check_geometry(content, result) + # Determine pass/fail result['passed'] = len(result['errors']) == 0 @@ -1494,6 +1501,315 @@ class SVGQualityChecker: if g == 0: self._pages_no_graphic.append(result.get('file', '?')) + # ── Geometry lint (check 13) ───────────────────────────────────────── + # Hand-written absolute coordinates fail in ways no string-level check + # sees: a display-size numeral overrunning its label, an icon patched in + # on top of a title, a TOC row past the canvas bottom. Text width can't + # be measured without rendering, but a per-char estimate (CJK ≈ 1em, + # Latin ≈ 0.5–0.7em) is accurate enough to flag hard collisions. + # Thresholds are deliberately loose — a near-total overlap is an error, + # a partial one only a warning — so estimation noise doesn't hard-fail + # legitimate tight layouts. + + _GEOM_MAX_REPORTS = 6 # per file, per category — avoid drowning the report + + @staticmethod + def _est_char_w(ch: str) -> float: + """Approximate advance width of one char, in em (× font-size).""" + o = ord(ch) + if (0x2E80 <= o <= 0x9FFF or 0xF900 <= o <= 0xFAFF + or 0xFF00 <= o <= 0xFF60 or 0x3000 <= o <= 0x303F): + return 1.0 # CJK ideographs, kana, fullwidth forms, CJK punct + if ch == ' ': + return 0.30 + if ch.isdigit(): + return 0.60 + if ch.isupper(): + return 0.72 + if o < 0x2E80: + return 0.52 # latin lowercase / halfwidth punctuation + return 0.70 + + @classmethod + def _est_text_w(cls, s: str, fs: float) -> float: + return sum(cls._est_char_w(c) for c in s) * fs + + @staticmethod + def _f(value, default=None): + try: + return float(str(value).strip()) + except (TypeError, ValueError): + return default + + def _collect_geometry(self, root) -> Tuple[list, list]: + """Walk the tree collecting estimated text boxes and exact icon boxes. + + Only translate() transforms are followed; any other transform makes + coordinates unknowable without a full matrix engine, so that subtree + is skipped (better silent than wrong). Boxes are dicts: + {x0, y0, x1, y1, fs, label, exact_left} — exact_left marks a + start-anchored text whose left edge is exact (only the right edge is + estimated). + """ + texts: list = [] + icons: list = [] + translate_re = re.compile( + r'^\s*translate\(\s*(-?[\d.]+)(?:[\s,]+(-?[\d.]+))?\s*\)\s*$') + skip_tags = {'defs', 'clipPath', 'marker', 'symbol', 'pattern', + 'mask', 'linearGradient', 'radialGradient', 'filter'} + + def local(tag): + return tag.split('}')[-1] + + def effective_opacity(el, inherited: float) -> float: + op = self._f(el.get('opacity'), 1.0) + fop = self._f(el.get('fill-opacity'), 1.0) + return inherited * min(op if op is not None else 1.0, + fop if fop is not None else 1.0) + + def line_box(runs: list, x: float, y: float, anchor: str, + tx: float, ty: float): + """One box per visual line. runs = [(text, fs), ...] flowed + left-to-right; the anchor positions the line's TOTAL width, which + is how SVG actually lays out a with styled inline tspans + (e.g. $4.2B (35%) + renders as one right-aligned line, not two stacked runs).""" + w = sum(self._est_text_w(t, f) for t, f in runs) + fs = max(f for _, f in runs) + if anchor == 'middle': + x0 = x - w / 2 + elif anchor == 'end': + x0 = x - w + else: + x0 = x + joined = ''.join(t for t, _ in runs) + label = joined if len(joined) <= 12 else joined[:12] + '…' + return { + 'x0': x0 + tx, 'y0': y - 0.76 * fs + ty, + 'x1': x0 + w + tx, 'y1': y + 0.22 * fs + ty, + 'fs': fs, 'label': label, + 'baseline': y + ty, 'anchor_x': x + tx, + 'exact_left': anchor not in ('middle', 'end'), + } + + def collect_text(el, tx, ty, inh_fs, inh_anchor, inh_op): + fs = self._f(el.get('font-size'), inh_fs) or 16.0 + anchor = el.get('text-anchor') or inh_anchor + if effective_opacity(el, inh_op) < 0.35 or el.get('fill') == 'none': + return + x = self._f(el.get('x')) + y = self._f(el.get('y')) + if x is None or y is None: + return + # Group content into visual lines: a tspan with explicit x/y or a + # non-zero dy starts a new line; anything else (leading text, + # styled inline tspans, tspan tails) flows onto the current line. + cur_x, cur_y = x, y + cur_runs: list = [] + + def flush(): + nonlocal cur_runs + if any(t for t, _ in cur_runs): + texts.append(line_box(cur_runs, cur_x, cur_y, anchor, tx, ty)) + cur_runs = [] + + own = (el.text or '').strip() + if own: + cur_runs.append((own, fs)) + for ts in el: + if local(ts.tag) != 'tspan': + continue + tfs = self._f(ts.get('font-size'), fs) or fs + tsx = self._f(ts.get('x')) + tsy = self._f(ts.get('y')) + dy_raw = (ts.get('dy') or '').strip() + if dy_raw.endswith('em'): + dy = (self._f(dy_raw[:-2], 0.0) or 0.0) * tfs + else: + dy = self._f(dy_raw, 0.0) or 0.0 + if tsx is not None or tsy is not None or dy: + flush() + if tsx is not None: + cur_x = tsx + if tsy is not None: + cur_y = tsy + else: + cur_y += dy + t = ''.join(ts.itertext()).strip() + if t: + cur_runs.append((t, tfs)) + tail = (ts.tail or '').strip() + if tail: + cur_runs.append((tail, fs)) + flush() + + def walk(el, tx, ty, inh_fs, inh_anchor, inh_op): + tag = local(el.tag) + if tag in skip_tags: + return + tr = el.get('transform') + if tr: + m = translate_re.match(tr) + if not m: + return # rotate/scale/matrix — coords unknown, skip subtree + tx += float(m.group(1)) + ty += float(m.group(2) or 0) + if tag == 'text': + collect_text(el, tx, ty, inh_fs, inh_anchor, inh_op) + return + if tag == 'use' and el.get('data-icon'): + x, y = self._f(el.get('x')), self._f(el.get('y')) + w, h = self._f(el.get('width')), self._f(el.get('height')) + if None not in (x, y, w, h): + icons.append({ + 'x0': x + tx, 'y0': y + ty, + 'x1': x + w + tx, 'y1': y + h + ty, + 'label': el.get('data-icon'), + }) + return + inh_fs = self._f(el.get('font-size'), inh_fs) + inh_anchor = el.get('text-anchor') or inh_anchor + inh_op = effective_opacity(el, inh_op) + if inh_op < 0.35: + return + for c in el: + walk(c, tx, ty, inh_fs, inh_anchor, inh_op) + + walk(root, 0.0, 0.0, None, 'start', 1.0) + return texts, icons + + @staticmethod + def _box_intersection(a: Dict, b: Dict) -> Tuple[float, float]: + iw = min(a['x1'], b['x1']) - max(a['x0'], b['x0']) + ih = min(a['y1'], b['y1']) - max(a['y0'], b['y0']) + return max(iw, 0.0), max(ih, 0.0) + + def _check_geometry(self, content: str, result: Dict) -> None: + """Detect text/icon overlaps and off-canvas elements (estimated boxes).""" + try: + root = ET.fromstring(content) + except ET.ParseError: + return # already reported by the well-formedness check + vb = re.search(r'viewBox="([^"]+)"', content) + if not vb: + return + parts = vb.group(1).split() + if len(parts) != 4: + return + canvas_w, canvas_h = float(parts[2]), float(parts[3]) + + texts, icons = self._collect_geometry(root) + + errors: List[str] = [] + warnings: List[str] = [] + + # 1. Off-canvas: baseline / anchor coordinates are exact → error; + # right-edge overflow relies on the width estimate → warning. + for t in texts: + if t['baseline'] > canvas_h + 1: + errors.append( + f"text \"{t['label']}\" baseline y={t['baseline']:.0f} is below " + f"the canvas (height {canvas_h:.0f}) — it will be clipped") + elif t['exact_left'] and t['anchor_x'] > canvas_w + 1: + errors.append( + f"text \"{t['label']}\" starts at x={t['anchor_x']:.0f}, beyond " + f"the canvas (width {canvas_w:.0f})") + elif t['x1'] > canvas_w + 0.6 * t['fs']: + warnings.append( + f"text \"{t['label']}\" likely overflows the right canvas edge " + f"(estimated right {t['x1']:.0f} > {canvas_w:.0f})") + elif t['x0'] < -0.6 * t['fs']: + warnings.append( + f"text \"{t['label']}\" likely overflows the left canvas edge " + f"(estimated left {t['x0']:.0f} < 0)") + for ic in icons: + if ic['x0'] >= canvas_w or ic['y0'] >= canvas_h or ic['x1'] <= 0 or ic['y1'] <= 0: + errors.append( + f"icon {ic['label']} at ({ic['x0']:.0f},{ic['y0']:.0f}) is entirely " + f"outside the canvas") + elif (ic['x0'] < -2 or ic['y0'] < -2 + or ic['x1'] > canvas_w + 2 or ic['y1'] > canvas_h + 2): + warnings.append( + f"icon {ic['label']} extends beyond the canvas edge " + f"({ic['x0']:.0f},{ic['y0']:.0f})-({ic['x1']:.0f},{ic['y1']:.0f})") + + # 2. Text-on-text collisions. Adjacent lines at normal line-height never + # intersect (box height ≈ 0.98×fs, line gap ≥ 1.15×fs), so any real + # intersection means two runs share the same space. + for i in range(len(texts)): + for j in range(i + 1, len(texts)): + a, b = texts[i], texts[j] + iw, ih = self._box_intersection(a, b) + if iw <= 0 or ih <= 0: + continue + min_fs = min(a['fs'], b['fs']) + min_h = min(a['y1'] - a['y0'], b['y1'] - b['y0']) + # Same-baseline runs are horizontally sequenced by design — + # any real horizontal overlap means the left run's width was + # under-budgeted (the classic big-numeral-plus-caption bug), + # regardless of how small the overlap area ratio is. + same_line = abs(a['baseline'] - b['baseline']) < 0.5 * min_fs + if iw < 0.6 * min_fs or ih < 0.45 * min_h: + continue # graze from estimation noise — ignore + min_area = min((a['x1'] - a['x0']) * (a['y1'] - a['y0']), + (b['x1'] - b['x0']) * (b['y1'] - b['y0'])) + ratio = (iw * ih) / min_area if min_area > 0 else 0 + # Text-text overlaps cap at WARNING: the width estimate can't + # tell a crash from a deliberate graze (quadrant captions, + # word clouds, tightly-kerned numeral+suffix pairs all overlap + # estimated boxes legitimately). The warning carries exact + # coordinates so the render-acceptance pass knows which spot + # to eyeball; icon-on-text and off-canvas below stay errors + # because their geometry is exact. + if ratio >= 0.15 or same_line: + warnings.append( + f"text \"{a['label']}\" and \"{b['label']}\" overlap " + f"(~{ratio * 100:.0f}% of the smaller run, around " + f"({max(a['x0'], b['x0']):.0f},{max(a['y0'], b['y0']):.0f})) " + f"— eyeball this spot at render acceptance") + + # 3. Icon-on-text collisions. Icon geometry is exact; the text estimate + # only inflates the right edge, so a large covered fraction of the + # icon is a reliable signal. + for ic in icons: + icon_area = (ic['x1'] - ic['x0']) * (ic['y1'] - ic['y0']) + if icon_area <= 0: + continue + for t in texts: + iw, ih = self._box_intersection(ic, t) + if iw <= 0 or ih <= 0: + continue + ratio = (iw * ih) / icon_area + msg = (f"icon {ic['label']} overlaps text \"{t['label']}\" " + f"(~{ratio * 100:.0f}% of the icon covered, at " + f"({ic['x0']:.0f},{ic['y0']:.0f}))") + if ratio >= 0.55: + errors.append(msg) + elif ratio >= 0.25: + warnings.append(msg) + + # 4. Icon-on-icon collisions (both exact) — always at least a warning. + for i in range(len(icons)): + for j in range(i + 1, len(icons)): + a, b = icons[i], icons[j] + iw, ih = self._box_intersection(a, b) + if iw <= 0 or ih <= 0: + continue + min_area = min((a['x1'] - a['x0']) * (a['y1'] - a['y0']), + (b['x1'] - b['x0']) * (b['y1'] - b['y0'])) + if min_area > 0 and (iw * ih) / min_area >= 0.3: + warnings.append( + f"icons {a['label']} and {b['label']} overlap at " + f"({max(a['x0'], b['x0']):.0f},{max(a['y0'], b['y0']):.0f})") + + for bucket, dest in ((errors, result['errors']), (warnings, result['warnings'])): + shown = bucket[:self._GEOM_MAX_REPORTS] + dest.extend(f"Geometry: {m}" for m in shown) + if len(bucket) > len(shown): + dest.append( + f"Geometry: ... and {len(bucket) - len(shown)} more " + f"similar issue(s) on this page") + def _print_graphic_summary(self): """Deck-level flat-deck gate. diff --git a/skills/ppt/scripts/svg_to_pptx/pptx_cli.py b/skills/ppt/scripts/svg_to_pptx/pptx_cli.py index 0e1bfc0..928fd93 100644 --- a/skills/ppt/scripts/svg_to_pptx/pptx_cli.py +++ b/skills/ppt/scripts/svg_to_pptx/pptx_cli.py @@ -105,6 +105,71 @@ def _deck_locks_icons_but_authors_none(project_path: Path, svg_files: list[Path] return False +def _acceptance_problems(project_path: Path, svg_files: list[Path]) -> list[str]: + """Export-boundary visual-acceptance gate (companion to the icon gate). + + A spec_lock'd deck must have every exported page visually accepted: + rendered by svg_preview.py (which records source sha1 + render time in + ``.build/acceptance.json``), eyeballed, and marked ``pass`` via + accept_pages.py — with the svg_output source unchanged since that render. + + Returns a list of human-readable problems; empty means the gate passes or + does not apply (no spec_lock.md — bare/ad-hoc conversions stay unblocked). + Unexpected internal errors fail open (return []): the gate must never + itself break the export path. A missing or unparseable acceptance record + is NOT an internal error — it is exactly the "never rendered, never + looked" failure this gate exists to stop. + + Motivation: a real delivery shipped 25 hand-written pages with icon-on-text + and numeral-on-caption collisions because the acceptance stage was skipped + outright — the SKILL doc demanded full-render review, but nothing enforced + it. Like the icon gate above, a refusal to write the pptx cannot be piped + away with ``| head``. + """ + try: + if not (project_path / 'spec_lock.md').exists(): + return [] + acc_path = project_path / '.build' / 'acceptance.json' + if not acc_path.exists(): + return ["no acceptance record (.build/acceptance.json) — the deck was " + "never rendered for review (svg_preview.py never ran)"] + try: + data = json.loads(acc_path.read_text(encoding='utf-8')) + pages = data.get('pages') if isinstance(data, dict) else None + if not isinstance(pages, dict): + raise ValueError('missing "pages" object') + except (json.JSONDecodeError, ValueError, OSError) as exc: + return [f"acceptance record unreadable ({exc}) — re-run svg_preview.py"] + + import hashlib + problems: list[str] = [] + for svg in svg_files: + stem = svg.stem + entry = pages.get(stem) + if not isinstance(entry, dict): + problems.append(f"{stem}: never rendered / reviewed") + continue + verdict = entry.get('verdict') + if verdict != 'pass': + problems.append(f"{stem}: verdict is '{verdict or 'pending'}', not 'pass'") + continue + if entry.get('rendered_from') == 'svg_output': + problems.append( + f"{stem}: accepted from a pre-finalize render (icons/images " + f"not embedded) — re-run finalize_svg + svg_preview") + continue + source = project_path / 'svg_output' / f'{stem}.svg' + if not source.exists(): + source = svg + sha = hashlib.sha1(source.read_bytes()).hexdigest() + if sha != entry.get('source_sha1'): + problems.append(f"{stem}: source edited AFTER the accepted render — " + f"re-render and re-review this page") + return problems + except Exception: + return [] + + def main(argv: list[str] | None = None) -> int: """CLI entry point for the SVG to PPTX conversion tool.""" transition_choices = ( @@ -206,6 +271,12 @@ Recorded narration: 'would render flat / icon-less). Use only for a stale lock or an ' 'intentionally icon-less deck.') + parser.add_argument('--allow-unreviewed', action='store_true', default=False, + help='Allow export even when pages lack a passed visual acceptance ' + '(svg_preview.py render + accept_pages.py --pass with unchanged ' + 'sources). Default: refuse — unreviewed hand-written coordinates ' + 'are how misaligned decks ship.') + mode_group = parser.add_mutually_exclusive_group() mode_group.add_argument('--only', type=str, choices=['native', 'legacy'], default=None, help='Only generate one version: native (editable shapes) or legacy (SVG image)') @@ -380,6 +451,40 @@ Recorded narration: ) return 1 + # Export-boundary visual-acceptance gate: every page must have been + # rendered (svg_preview.py), eyeballed, and marked pass (accept_pages.py) + # with its source unchanged since. See _acceptance_problems for rationale. + acceptance_problems = _acceptance_problems(project_path, ref_files) + if acceptance_problems: + if args.allow_unreviewed: + print( + f"[WARN] visual acceptance incomplete on {len(acceptance_problems)} " + "page(s) — exporting anyway (--allow-unreviewed).", + file=sys.stderr, + ) + else: + print( + "[ERROR] visual acceptance incomplete — refusing to export a deck " + "whose pages were never rendered and reviewed:", + file=sys.stderr, + ) + for p in acceptance_problems[:20]: + print(f" - {p}", file=sys.stderr) + if len(acceptance_problems) > 20: + print(f" ... and {len(acceptance_problems) - 20} more", + file=sys.stderr) + print( + " Fix: run finalize_svg.py, then svg_preview.py " + "(full deck),\n" + " look at every PNG under .build/preview/, fix bad pages and " + "re-render,\n" + " then mark verdicts with accept_pages.py --pass ... " + "(or --pass-all).\n" + " Escape hatch (NOT for skipping review): --allow-unreviewed.", + file=sys.stderr, + ) + return 1 + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") backup_dir: Path | None = None