fix(ppt): 门体系二轮硬化——逃生口收紧+导出自动质检+svg_final 嵌图修复(bump 0.36.1)

0.36.0 重跑复盘:门都触发了,但弱模型 8 秒内连按 --allow-iconless +
--allow-unreviewed 绕过,质检/渲图验收仍 0 调用,4/25 页错位漏出。修五处:

- A 验收门分层:"从没渲过/渲后又改/finalize 前渲的"= 硬问题,任何 CLI
  flag 不豁免;--allow-unreviewed 只豁免"渲过但没标 pass";运维兜底走
  ZCBOT_PPT_FORCE_EXPORT=1 环境变量(不进 --help/SKILL)
- B 拔 -s final 雷:图标门永远对 svg_output 源检测(消除 svg_final 展开
  后误报"零图标"),wrapper docstring 老示例删除
- C 导出自动质检门:svg_to_pptx 导出前内嵌复跑 quality checker 逐页硬
  错误,error 拒绝导出、无豁免参数
- E 几何质检加"文字骑卡片边缘"检测(warn 带坐标,P12/P14/P18 类命中)
- F 修 svg_final 嵌图失效:copytree 后 ../images/ 解析必落空,所有 deck
  的 svg_final 一直嵌不进外链图(验收 PNG 图片为空);resolve 加 rebase
  回 svg_output 兜底

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-03 08:58:49 +08:00
parent 3c712031d5
commit fcc158dff6
8 changed files with 229 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-07-02(ppt 渲图验收闭环 + 导出验收硬门 + 几何质检,bump 0.36.0) 最后更新:2026-07-03(ppt 门体系二轮硬化:逃生口收紧 + 导出自动质检 + svg_final 嵌图修复,bump 0.36.1)
--- ---
@ -21,6 +21,9 @@
## 已完成关键能力 ## 已完成关键能力
### 2026-07-03 / ppt 门体系二轮硬化:逃生口收紧 + 导出自动质检 + svg_final 嵌图修复(139a59c5 重跑复盘,bump 0.36.1)
0.36.0 上线后同 task 重跑(仍 deepseek-v4-flash):产物整体大幅好转,但仍有 4/25 页错位(P12 色带裁两行标题+正文跑出卡外 / P14·P18 文字骑卡片边框 / P21 手画饼图弧线劈叉)。轨迹显示**两道新门都触发了、都被模型 8 秒内用逃生口按过去**:质检+渲图验收 0 调用,`--allow-iconless` + `--allow-unreviewed` 连按直接导出——门有了,逃生口对弱模型等于"报错时该加的参数"。且 `--allow-iconless` 的"正当理由"是我们自己给的:wrapper docstring 老示例教它 `-s final`,而图标门检查的是 svg_final(data-icon 已展开)→ 误报零图标;`-s final` 还连锁出图片路径连环坑(见 F)。二轮修五处:**A 验收门分层**——"从没渲过/渲后又改/finalize 前渲的"为硬问题,**任何 CLI flag 不豁免**(渲图便宜且机器可验,没理由交付没人能看过的页);`--allow-unreviewed` 只豁免"渲过但没标 pass";运维兜底走 `ZCBOT_PPT_FORCE_EXPORT=1` 环境变量(不进 --help/SKILL)。**B 拔 `-s final` 雷**——图标门永远对 svg_output 源检测(误报根除);wrapper docstring 示例去掉 `-s final` 并注明勿用。**C 导出自动质检门**——svg_to_pptx 导出前内嵌复跑 quality checker 逐页硬错误(坏 XML/禁用特性/图片缺失/几何 error),error 拒绝导出、无豁免参数(fail-open 于 import 失败)——"忘跑/不跑质检"从此无效。**D** 验收门报错计数措辞修正。**E 几何质检加"文字骑卡片边缘"检测**(warning 带坐标:文字与可见矩形交叠面积占比 0.20.85 即骑边,P12/P14/P18 三类当场可命中;P21 饼图弧线错误静态无解,只能渲图过目)。**F 修 svg_final 嵌图失效 bug**——finalize 先 copytree 到 `.build/svg_final` 再就地嵌图,`../images/` 从 svg_final 解析必落空 → **所有 deck 的 svg_final 一直嵌不进外链图**(渲图验收 PNG 里图片也是空的);`_resolve_image_path` 加"rebase 回 svg_output 同相对路径"兜底,实测 data:URI 落位。本机全链路回归:未渲→硬拒(带 flag 也拒)/ pending→拒、flag 放 / pass→放行 / 质检 error→拒 / env 强制→放;71 charts 模板几何 0 error。已知边界:P21 类"图形画错但不重叠不越界"仍只有渲图过目能拦——"看没看"无法机器验证,治本要平台层 vision 验收(待做,同 0.35.1 备注)。
### 2026-07-02 / ppt 渲图验收闭环 + 导出验收硬门 + 几何质检(139a59c5 复盘,bump 0.36.0) ### 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 <tspan>(35%)</tspan>` 是一行不是两段),71 个 charts 模板 0 error 误报、复刻事故的 fixture 全命中。**C 管线顺序+反模式(文档)**——SKILL.md 管线改"后处理→渲图验收→导出"(验收在导出前),阶段五=finalize+全量渲图+逐页过目+标记,阶段六=拆备注+导出(验收门+图标门双硬门);反模式加"没看 PNG 就 --pass-all"和"为消警告脚本批量盲插元素不复看"。SKILL_LIST 同步。已知边界:gate 只能强制"渲过、源没改",看没看 PNG 无法机器验证(--pass-all 仍可被糊弄,但本次事故"从不渲图"的直接通路已封死)。 复盘 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 <tspan>(35%)</tspan>` 是一行不是两段),71 个 charts 模板 0 error 误报、复刻事故的 fixture 全命中。**C 管线顺序+反模式(文档)**——SKILL.md 管线改"后处理→渲图验收→导出"(验收在导出前),阶段五=finalize+全量渲图+逐页过目+标记,阶段六=拆备注+导出(验收门+图标门双硬门);反模式加"没看 PNG 就 --pass-all"和"为消警告脚本批量盲插元素不复看"。SKILL_LIST 同步。已知边界:gate 只能强制"渲过、源没改",看没看 PNG 无法机器验证(--pass-all 仍可被糊弄,但本次事故"从不渲图"的直接通路已封死)。

View File

@ -186,7 +186,7 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` +
- **19 种视觉风格 + 5 种叙事骨架**:editorial / swiss-minimal / glassmorphism / dark-tech / data-journalism… × pyramid / narrative / instructional / showcase / briefing —— 去 AI 味的关键 - **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 自动内嵌) - **模板库**:layouts(版式)/ decks(整套:中汽研/招商银行/重庆大学等)/ brands(品牌)/ charts(71 个图表信息图)/ icons(5 套共 1.1w+ 图标,finalize 自动内嵌)
- **逐页节奏纪律**:论断式标题、page_rhythm(anchor/dense/breathing,breathing 页禁卡片墙)、内容→版式映射、图文版式 72 式 - **逐页节奏纪律**:论断式标题、page_rhythm(anchor/dense/breathing,breathing 页禁卡片墙)、内容→版式映射、图文版式 72 式
- **SVG 质检** `svg_quality_checker.py`:禁用特性 / viewBox / spec_lock 漂移 / 配色越界 / **几何检测**(文本·图标包围盒估算,拦大字压说明、图标压字、行溢出画布)(error 必改,回写 SVG) - **SVG 质检** `svg_quality_checker.py`:禁用特性 / viewBox / spec_lock 漂移 / 配色越界 / **几何检测**(文本·图标包围盒估算,拦大字压说明、图标压字、行溢出画布、文字骑卡片边缘)(error 必改,回写 SVG;**导出边界自动复跑同套硬错误,error 拒绝导出、无豁免参数**)
- **渲图验收闭环** `svg_preview.py` + `accept_pages.py`:无头 Chrome 全量渲 PNG 肉眼/vision 验版面,逐页标 pass/fail 落 `.build/acceptance.json`;**导出 gate 只认"渲过 + 看过标 pass + 渲后源未改(sha1)"**,跳验收/盲改混不过去;`update_spec.py` 一键改色/字体传播到所有 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 素材摄取 - AI 配图走 imagegen skill;markitdown 素材摄取

View File

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

View File

@ -146,6 +146,7 @@ references/visual-styles/<locked-style>.md # 锁定的视觉风格
- `warning`(低分辨率图 / 非 PPT 安全字体等):能顺手改就改,否则知会后放行。**例外:`Geometry:` 开头的文字重叠 warning 不许无视** —— 它给了精确坐标,是"大字压说明 / 同行文字互侵"的高嫌疑点(估宽无法区分擦边与压字,所以只报 warn),阶段五渲图时**必须对着该页该坐标专门看**,压了就返工。 - `warning`(低分辨率图 / 非 PPT 安全字体等):能顺手改就改,否则知会后放行。**例外:`Geometry:` 开头的文字重叠 warning 不许无视** —— 它给了精确坐标,是"大字压说明 / 同行文字互侵"的高嫌疑点(估宽无法区分擦边与压字,所以只报 warn),阶段五渲图时**必须对着该页该坐标专门看**,压了就返工。
- 跑 `svg_output/`(不要在 finalize 后跑 —— finalize 改写 SVG 会掩盖源级违规)。 - 跑 `svg_output/`(不要在 finalize 后跑 —— finalize 改写 SVG 会掩盖源级违规)。
- ⚠️ **别用 `| head` / `| tail` 截断质检输出**:管道会把脚本的非零退出码换成 `head` 的 0(门形同虚设),`head` 还会截掉打在**最后**的 deck 级门结论(如零图标 `[ERROR]`)。原样跑,读完整输出、认它的退出码。 - ⚠️ **别用 `| head` / `| tail` 截断质检输出**:管道会把脚本的非零退出码换成 `head` 的 0(门形同虚设),`head` 还会截掉打在**最后**的 deck 级门结论(如零图标 `[ERROR]`)。原样跑,读完整输出、认它的退出码。
- 跳过本阶段没有意义:导出边界会**自动复跑同一套逐页硬错误检查**(见阶段六质检门),error 到那里一样拒绝导出 —— 在这里主动跑并连警告一起读,能更早返工。
## 阶段五:后处理 + 渲图验收(强制门)—— 全量,不抽查 ## 阶段五:后处理 + 渲图验收(强制门)—— 全量,不抽查
@ -175,8 +176,10 @@ references/visual-styles/<locked-style>.md # 锁定的视觉风格
.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/)+ .build/backup/latest/svg_output/(源快照,只留最新) # 产物:exports/<slug>_<ts>.pptx(原生,读 svg_output/)+ .build/backup/latest/svg_output/(源快照,只留最新)
``` ```
- 🚧 **导出边界验收门(硬)**:spec_lock 存在时,**每页都必须 verdict=pass 且渲图后源未改动**,否则导出 `[ERROR]` 退非零、不产出 pptx(`| head` 绕不过)。被拒就回阶段五补验收/走返工回路;`--allow-unreviewed` 只留给"确实不需要视觉验收"的极端场景,**不是跳过验收的捷径**。 - 🚧 **导出边界质检门(硬,无豁免参数)**:导出前自动复跑阶段四质检的逐页硬错误(禁用特性 / 坏 XML / 图片文件缺失 / 图标压字·出画布几何错误等),**有 error 直接拒绝导出**。没有任何 `--allow-*` 能绕过 —— 这些是真缺陷,回 svg_output 修完再来。
- 🚧 **导出边界图标门(硬)**:spec_lock 锁了 `icons.library` + 非空 `inventory` 但全 deck 零 `<use data-icon>` → 同样 `[ERROR]` 退非零。正确做法是回阶段三给内容页补图标重跑;只有 lock 确实过期 / 有意做无图标 deck 才加 `--allow-iconless` 放行。 - 🚧 **导出边界验收门(硬)**:spec_lock 存在时,**每页都必须渲过图(svg_preview)、且渲图后源未再改动、且 verdict=pass**。分两层:**"从没渲过 / 渲后又改 / finalize 前渲的"没有任何 CLI 逃生口**(渲图很便宜,没有理由交付一页没人看过的东西);`--allow-unreviewed` 只豁免"渲过但还没标 pass"这一层,**不是跳过验收的捷径**。被拒就回阶段五补验收/走返工回路。
- 🚧 **导出边界图标门(硬)**:spec_lock 锁了 `icons.library` + 非空 `inventory``svg_output/` 全 deck 零 `<use data-icon>` → 同样 `[ERROR]` 退非零(检测永远对 svg_output 源,与 `-s` 无关)。正确做法是回阶段三给内容页补图标重跑;只有 lock 确实过期 / 有意做无图标 deck 才加 `--allow-iconless` 放行。
- ❌ **别加 `-s final`**:native 导出默认读 `svg_output/`(转换器自己处理图标占位与 `../images/` 相对路径),`-s final` 只会引出图片路径错位这类连锁问题;真实事故里模型为绕它把 svg_output 源里的 href 改坏了。
- 🛑 **导出唯一入口 = 官方 `svg_to_pptx.py`,严禁自写导出器**:它**默认产出原生可编辑 DrawingML**(形状/文本/渐变都能在 PowerPoint 里选中改),是**纯 Python、不依赖任何外部渲染器**(cairosvg / inkscape / rsvg-convert 一个都不需要)。所以**"某某渲染器没装"永远不是理由**——别 `pip install cairosvg` 也别手搓"SVG→PNG→整页贴图"的 `export_pptx.py`。自搓光栅导出器 = 整份变成一叠不可编辑的贴图(每页一张整页 PNG、零原生文本),**skill 核心价值直接归零、判废**。官方脚本跑不动就读它的报错按流程修 / 反馈,不要另起平行管线。 - 🛑 **导出唯一入口 = 官方 `svg_to_pptx.py`,严禁自写导出器**:它**默认产出原生可编辑 DrawingML**(形状/文本/渐变都能在 PowerPoint 里选中改),是**纯 Python、不依赖任何外部渲染器**(cairosvg / inkscape / rsvg-convert 一个都不需要)。所以**"某某渲染器没装"永远不是理由**——别 `pip install cairosvg` 也别手搓"SVG→PNG→整页贴图"的 `export_pptx.py`。自搓光栅导出器 = 整份变成一叠不可编辑的贴图(每页一张整页 PNG、零原生文本),**skill 核心价值直接归零、判废**。官方脚本跑不动就读它的报错按流程修 / 反馈,不要另起平行管线。
- ❌ 别用 `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。

View File

@ -116,9 +116,20 @@ def _resolve_image_path(href: str, svg_dir: Path) -> Path | None:
return None return None
if os.path.isabs(decoded): if os.path.isabs(decoded):
candidate = Path(decoded) candidate = Path(decoded)
else: return candidate if candidate.exists() else None
candidate = (svg_dir / decoded).resolve() candidate = (svg_dir / decoded).resolve()
return candidate if candidate.exists() else None if candidate.exists():
return candidate
# svg_final is a copytree of svg_output living two levels deeper
# (<project>/.build/svg_final), but authored hrefs like ../images/x.png
# are relative to svg_output — from the copy they land on the
# nonexistent .build/images and every external image silently stayed
# un-embedded. Rebase the same relative path onto svg_output.
if svg_dir.name == 'svg_final' and svg_dir.parent.name == '.build':
authored = (svg_dir.parent.parent / 'svg_output' / decoded).resolve()
if authored.exists():
return authored
return None
def _load_pil_image(img_path: Path) -> 'PILImage' | None: def _load_pil_image(img_path: Path) -> 'PILImage' | None:

View File

@ -1541,18 +1541,20 @@ class SVGQualityChecker:
except (TypeError, ValueError): except (TypeError, ValueError):
return default return default
def _collect_geometry(self, root) -> Tuple[list, list]: def _collect_geometry(self, root) -> Tuple[list, list, list]:
"""Walk the tree collecting estimated text boxes and exact icon boxes. """Walk the tree collecting text boxes, icon boxes and visible rects.
Only translate() transforms are followed; any other transform makes Only translate() transforms are followed; any other transform makes
coordinates unknowable without a full matrix engine, so that subtree coordinates unknowable without a full matrix engine, so that subtree
is skipped (better silent than wrong). Boxes are dicts: is skipped (better silent than wrong). Boxes are dicts:
{x0, y0, x1, y1, fs, label, exact_left} exact_left marks a {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 start-anchored text whose left edge is exact (only the right edge is
estimated). estimated). Rects (cards / bands, 12px on both axes, visible fill or
stroke) feed the text-straddles-edge check.
""" """
texts: list = [] texts: list = []
icons: list = [] icons: list = []
rects: list = []
translate_re = re.compile( translate_re = re.compile(
r'^\s*translate\(\s*(-?[\d.]+)(?:[\s,]+(-?[\d.]+))?\s*\)\s*$') r'^\s*translate\(\s*(-?[\d.]+)(?:[\s,]+(-?[\d.]+))?\s*\)\s*$')
skip_tags = {'defs', 'clipPath', 'marker', 'symbol', 'pattern', skip_tags = {'defs', 'clipPath', 'marker', 'symbol', 'pattern',
@ -1667,6 +1669,19 @@ class SVGQualityChecker:
'label': el.get('data-icon'), 'label': el.get('data-icon'),
}) })
return return
if tag == 'rect':
x, y = self._f(el.get('x'), 0.0), self._f(el.get('y'), 0.0)
w, h = self._f(el.get('width')), self._f(el.get('height'))
visible = (el.get('fill') or '').strip().lower() != 'none' \
or el.get('stroke') not in (None, 'none')
# ≥12px both axes: hairline rules / accent bars / top bands are
# legitimate text neighbors, only card/band-sized boxes matter.
if visible and w and h and w >= 12 and h >= 12:
rects.append({
'x0': x + tx, 'y0': y + ty,
'x1': x + w + tx, 'y1': y + h + ty,
})
return
inh_fs = self._f(el.get('font-size'), inh_fs) inh_fs = self._f(el.get('font-size'), inh_fs)
inh_anchor = el.get('text-anchor') or inh_anchor inh_anchor = el.get('text-anchor') or inh_anchor
inh_op = effective_opacity(el, inh_op) inh_op = effective_opacity(el, inh_op)
@ -1676,7 +1691,7 @@ class SVGQualityChecker:
walk(c, tx, ty, inh_fs, inh_anchor, inh_op) walk(c, tx, ty, inh_fs, inh_anchor, inh_op)
walk(root, 0.0, 0.0, None, 'start', 1.0) walk(root, 0.0, 0.0, None, 'start', 1.0)
return texts, icons return texts, icons, rects
@staticmethod @staticmethod
def _box_intersection(a: Dict, b: Dict) -> Tuple[float, float]: def _box_intersection(a: Dict, b: Dict) -> Tuple[float, float]:
@ -1698,7 +1713,7 @@ class SVGQualityChecker:
return return
canvas_w, canvas_h = float(parts[2]), float(parts[3]) canvas_w, canvas_h = float(parts[2]), float(parts[3])
texts, icons = self._collect_geometry(root) texts, icons, rects = self._collect_geometry(root)
errors: List[str] = [] errors: List[str] = []
warnings: List[str] = [] warnings: List[str] = []
@ -1802,6 +1817,29 @@ class SVGQualityChecker:
f"icons {a['label']} and {b['label']} overlap at " f"icons {a['label']} and {b['label']} overlap at "
f"({max(a['x0'], b['x0']):.0f},{max(a['y0'], b['y0']):.0f})") f"({max(a['x0'], b['x0']):.0f},{max(a['y0'], b['y0']):.0f})")
# 5. Text straddling a card/band edge (warning). A run that is partly
# inside and partly outside a visible rect is either clipped by the
# band (header text taller than its band) or poking out of its card
# (mis-centered captions) — both shipped in a real deck. Ratio
# bounds leave room for the width estimate; a deliberate badge
# overlapping a card edge is the FP case, hence warning-tier.
for t in texts:
t_area = (t['x1'] - t['x0']) * (t['y1'] - t['y0'])
if t_area <= 0:
continue
for r in rects:
iw, ih = self._box_intersection(t, r)
if iw <= 0 or ih <= 0:
continue
ratio = (iw * ih) / t_area
if 0.2 <= ratio <= 0.85:
warnings.append(
f"text \"{t['label']}\" straddles a card/band edge at "
f"({r['x0']:.0f},{r['y0']:.0f})-({r['x1']:.0f},{r['y1']:.0f}) "
f"(~{ratio * 100:.0f}% inside) — likely clipped by the band "
f"or poking out of the card; eyeball at render acceptance")
break # one report per text run is enough
for bucket, dest in ((errors, result['errors']), (warnings, result['warnings'])): for bucket, dest in ((errors, result['errors']), (warnings, result['warnings'])):
shown = bucket[:self._GEOM_MAX_REPORTS] shown = bucket[:self._GEOM_MAX_REPORTS]
dest.extend(f"Geometry: {m}" for m in shown) dest.extend(f"Geometry: {m}" for m in shown)

View File

@ -2,7 +2,12 @@
"""PPT Master - SVG to PPTX Tool (thin wrapper). """PPT Master - SVG to PPTX Tool (thin wrapper).
Delegates to the svg_to_pptx package. Kept for CLI backward compatibility: Delegates to the svg_to_pptx package. Kept for CLI backward compatibility:
python3 scripts/svg_to_pptx.py <project_path> -s final python3 scripts/svg_to_pptx.py <project_path>
Do NOT pass `-s final` by default: native export reads svg_output/ (the
authored source; icons/preserveAspectRatio handled by the converter). A real
run that copied `-s final` from an old docstring here cascaded into a false
icon-gate alarm and hand-patched image paths in the sources.
""" """
import sys import sys

View File

@ -77,6 +77,13 @@ def _deck_locks_icons_but_authors_none(project_path: Path, svg_files: list[Path]
boundary it is the LAST line of defense: the quality gate can be reordered boundary it is the LAST line of defense: the quality gate can be reordered
before export or have its non-zero exit swallowed by ``| head``, whereas a before export or have its non-zero exit swallowed by ``| head``, whereas a
refusal to write the pptx cannot be piped away. refusal to write the pptx cannot be piped away.
Detection always reads ``svg_output/`` (the authored source of truth)
when it exists, regardless of which directory the export consumes:
finalize EXPANDS ``<use data-icon>`` placeholders in svg_final, so
checking an ``-s final`` file set produced a false "zero icons" alarm
which in a real run handed the model a legitimate-looking reason to pass
``--allow-iconless`` on a deck whose icons were perfectly fine.
""" """
try: try:
import re import re
@ -94,8 +101,10 @@ def _deck_locks_icons_but_authors_none(project_path: Path, svg_files: list[Path]
_empty = ('', 'none', '(none)', '-', 'n/a') _empty = ('', 'none', '(none)', '-', 'n/a')
if library in _empty or inventory in _empty: if library in _empty or inventory in _empty:
return False return False
source_dir = project_path / 'svg_output'
sources = sorted(source_dir.glob('*.svg')) if source_dir.is_dir() else svg_files
total = 0 total = 0
for p in svg_files: for p in sources:
try: try:
total += len(re.findall(r'<use\b[^>]*\bdata-icon\s*=', p.read_text(encoding='utf-8'))) total += len(re.findall(r'<use\b[^>]*\bdata-icon\s*=', p.read_text(encoding='utf-8')))
except Exception: except Exception:
@ -105,7 +114,50 @@ def _deck_locks_icons_but_authors_none(project_path: Path, svg_files: list[Path]
return False return False
def _acceptance_problems(project_path: Path, svg_files: list[Path]) -> list[str]: def _quality_errors(project_path: Path) -> list[str]:
"""Export-boundary structural quality gate.
Runs svg_quality_checker's per-file checks on ``svg_output/`` right before
export and returns every hard error (forbidden features, malformed XML,
missing image files, icon-on-text / off-canvas geometry, spec_lock
violations). The stage-4 quality check is documented as mandatory, but a
real 25-page run simply never invoked it embedding the same checks at
the export boundary makes "forgot / skipped the checker" impossible.
Only applies to spec_lock'd projects with an svg_output/ (same scope as
the other gates). Fails open on import/internal errors the checker
lives one directory above this package and is optional at runtime; a
broken checker must not break exports. Deck-level aggregates (icon
totals, visual richness) are NOT re-derived here; icons already have
their own export gate and richness is advisory.
"""
try:
if not (project_path / 'spec_lock.md').exists():
return []
source_dir = project_path / 'svg_output'
if not source_dir.is_dir():
return []
try:
import sys as _sys
_scripts_dir = str(Path(__file__).resolve().parent.parent)
if _scripts_dir not in _sys.path:
_sys.path.insert(0, _scripts_dir)
from svg_quality_checker import SVGQualityChecker
except Exception:
return []
checker = SVGQualityChecker()
problems: list[str] = []
for svg in sorted(source_dir.glob('*.svg')):
result = checker.check_file(str(svg))
for err in result.get('errors', []):
problems.append(f"{svg.stem}: {err}")
return problems
except Exception:
return []
def _acceptance_problems(project_path: Path,
svg_files: list[Path]) -> tuple[list[str], list[str]]:
"""Export-boundary visual-acceptance gate (companion to the icon gate). """Export-boundary visual-acceptance gate (companion to the icon gate).
A spec_lock'd deck must have every exported page visually accepted: A spec_lock'd deck must have every exported page visually accepted:
@ -113,61 +165,70 @@ def _acceptance_problems(project_path: Path, svg_files: list[Path]) -> list[str]
``.build/acceptance.json``), eyeballed, and marked ``pass`` via ``.build/acceptance.json``), eyeballed, and marked ``pass`` via
accept_pages.py with the svg_output source unchanged since that render. 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 Returns ``(hard, waivable)`` problem lists; both empty means the gate
does not apply (no spec_lock.md bare/ad-hoc conversions stay unblocked). passes or does not apply (no spec_lock.md bare/ad-hoc conversions stay
Unexpected internal errors fail open (return []): the gate must never unblocked). ``hard`` problems (never rendered / record unreadable / source
itself break the export path. A missing or unparseable acceptance record edited after render / pre-finalize render) block even under
is NOT an internal error it is exactly the "never rendered, never ``--allow-unreviewed`` rendering is cheap and machine-checkable, so
looked" failure this gate exists to stop. there is no legitimate reason to export a page nobody could have seen.
``waivable`` problems (verdict not yet pass) yield to the flag. The only
bypass for hard problems is the ZCBOT_PPT_FORCE_EXPORT=1 environment
variable (operator emergency hatch, deliberately absent from --help).
Unexpected internal errors fail open: 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 Motivation: a real delivery shipped 25 hand-written pages with icon-on-text
and numeral-on-caption collisions because the acceptance stage was skipped and numeral-on-caption collisions because the acceptance stage was skipped
outright the SKILL doc demanded full-render review, but nothing enforced outright; the re-run then skipped rendering again by reaching straight for
it. Like the icon gate above, a refusal to write the pptx cannot be piped --allow-unreviewed eight seconds after the gate fired. Like the icon gate
away with ``| head``. above, a refusal to write the pptx cannot be piped away with ``| head``.
""" """
try: try:
if not (project_path / 'spec_lock.md').exists(): if not (project_path / 'spec_lock.md').exists():
return [] return [], []
acc_path = project_path / '.build' / 'acceptance.json' acc_path = project_path / '.build' / 'acceptance.json'
if not acc_path.exists(): if not acc_path.exists():
return ["no acceptance record (.build/acceptance.json) — the deck was " return (["no acceptance record (.build/acceptance.json) — the deck was "
"never rendered for review (svg_preview.py never ran)"] "never rendered for review (svg_preview.py never ran)"], [])
try: try:
data = json.loads(acc_path.read_text(encoding='utf-8')) data = json.loads(acc_path.read_text(encoding='utf-8'))
pages = data.get('pages') if isinstance(data, dict) else None pages = data.get('pages') if isinstance(data, dict) else None
if not isinstance(pages, dict): if not isinstance(pages, dict):
raise ValueError('missing "pages" object') raise ValueError('missing "pages" object')
except (json.JSONDecodeError, ValueError, OSError) as exc: except (json.JSONDecodeError, ValueError, OSError) as exc:
return [f"acceptance record unreadable ({exc}) — re-run svg_preview.py"] return ([f"acceptance record unreadable ({exc}) — re-run svg_preview.py"], [])
import hashlib import hashlib
problems: list[str] = [] hard: list[str] = []
waivable: list[str] = []
for svg in svg_files: for svg in svg_files:
stem = svg.stem stem = svg.stem
entry = pages.get(stem) entry = pages.get(stem)
if not isinstance(entry, dict): if not isinstance(entry, dict):
problems.append(f"{stem}: never rendered / reviewed") hard.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 continue
if entry.get('rendered_from') == 'svg_output': if entry.get('rendered_from') == 'svg_output':
problems.append( hard.append(
f"{stem}: accepted from a pre-finalize render (icons/images " f"{stem}: rendered pre-finalize (icons/images not embedded, "
f"not embedded) — re-run finalize_svg + svg_preview") f"the PNG shows the wrong page) — re-run finalize_svg + svg_preview")
continue continue
source = project_path / 'svg_output' / f'{stem}.svg' source = project_path / 'svg_output' / f'{stem}.svg'
if not source.exists(): if not source.exists():
source = svg source = svg
sha = hashlib.sha1(source.read_bytes()).hexdigest() sha = hashlib.sha1(source.read_bytes()).hexdigest()
if sha != entry.get('source_sha1'): if sha != entry.get('source_sha1'):
problems.append(f"{stem}: source edited AFTER the accepted render — " hard.append(f"{stem}: source edited AFTER the last render — "
f"re-render and re-review this page") f"re-render and re-review this page")
return problems continue
verdict = entry.get('verdict')
if verdict != 'pass':
waivable.append(f"{stem}: verdict is '{verdict or 'pending'}', not 'pass'")
return hard, waivable
except Exception: except Exception:
return [] return [], []
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
@ -451,36 +512,85 @@ Recorded narration:
) )
return 1 return 1
import os as _os
force_export = _os.environ.get('ZCBOT_PPT_FORCE_EXPORT') == '1'
# Export-boundary structural quality gate: the stage-4 checker's per-file
# hard errors, re-run here so "never ran the checker" cannot ship a deck.
# No CLI flag waives this — these are real defects to fix, not judgments.
quality_problems = [] if force_export else _quality_errors(project_path)
if quality_problems:
print(
f"[ERROR] quality check failed with {len(quality_problems)} error(s) — "
"refusing to export:",
file=sys.stderr,
)
for p in quality_problems[:20]:
print(f" - {p}", file=sys.stderr)
if len(quality_problems) > 20:
print(f" ... and {len(quality_problems) - 20} more", file=sys.stderr)
print(
" Fix the listed pages in svg_output/ (run svg_quality_checker.py "
"<project_dir>\n"
" for the full report incl. warnings), then re-run finalize + "
"preview + export.",
file=sys.stderr,
)
return 1
# Export-boundary visual-acceptance gate: every page must have been # Export-boundary visual-acceptance gate: every page must have been
# rendered (svg_preview.py), eyeballed, and marked pass (accept_pages.py) # rendered (svg_preview.py), eyeballed, and marked pass (accept_pages.py)
# with its source unchanged since. See _acceptance_problems for rationale. # with its source unchanged since. Hard problems (never rendered / stale
acceptance_problems = _acceptance_problems(project_path, ref_files) # render) block even under --allow-unreviewed; only un-passed verdicts
if acceptance_problems: # yield to the flag. See _acceptance_problems for rationale.
hard_problems, waivable_problems = (
([], []) if force_export else _acceptance_problems(project_path, ref_files))
if hard_problems:
print(
f"[ERROR] {len(hard_problems)} blocking acceptance problem(s) — pages "
"never rendered for review, or changed since their last render. "
"Refusing to export; --allow-unreviewed does NOT cover this: rendering "
"is cheap and machine-checkable, there is no reason to ship a page "
"nobody could have seen.",
file=sys.stderr,
)
for p in hard_problems[:20]:
print(f" - {p}", file=sys.stderr)
if len(hard_problems) > 20:
print(f" ... and {len(hard_problems) - 20} more", file=sys.stderr)
print(
" Fix: run finalize_svg.py, then svg_preview.py <project_dir> "
"(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).",
file=sys.stderr,
)
return 1
if waivable_problems:
if args.allow_unreviewed: if args.allow_unreviewed:
print( print(
f"[WARN] visual acceptance incomplete on {len(acceptance_problems)} " f"[WARN] {len(waivable_problems)} page(s) rendered but not marked "
"page(s) — exporting anyway (--allow-unreviewed).", "pass — exporting anyway (--allow-unreviewed).",
file=sys.stderr, file=sys.stderr,
) )
else: else:
print( print(
"[ERROR] visual acceptance incomplete — refusing to export a deck " f"[ERROR] {len(waivable_problems)} page(s) rendered but not yet "
"whose pages were never rendered and reviewed:", "reviewed/passed — refusing to export:",
file=sys.stderr, file=sys.stderr,
) )
for p in acceptance_problems[:20]: for p in waivable_problems[:20]:
print(f" - {p}", file=sys.stderr) print(f" - {p}", file=sys.stderr)
if len(acceptance_problems) > 20: if len(waivable_problems) > 20:
print(f" ... and {len(acceptance_problems) - 20} more", print(f" ... and {len(waivable_problems) - 20} more",
file=sys.stderr) file=sys.stderr)
print( print(
" Fix: run finalize_svg.py, then svg_preview.py <project_dir> " " Look at every PNG under .build/preview/, then mark verdicts "
"(full deck),\n" "with\n"
" look at every PNG under .build/preview/, fix bad pages and " " accept_pages.py --pass ... (or --pass-all after a full "
"re-render,\n" "review).",
" then mark verdicts with accept_pages.py --pass ... "
"(or --pass-all).\n"
" Escape hatch (NOT for skipping review): --allow-unreviewed.",
file=sys.stderr, file=sys.stderr,
) )
return 1 return 1