feat(ppt): 渲图验收闭环+导出验收硬门+几何质检(139a59c5 错位复盘,bump 0.36.0)
复盘 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 <noreply@anthropic.com>
This commit is contained in:
parent
d79c28de06
commit
3c712031d5
|
|
@ -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-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 <tspan>(35%)</tspan>` 是一行不是两段),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)
|
### 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 交付/预览路径自动检测整页贴图(本次未做)。
|
复盘同一 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 交付/预览路径自动检测整页贴图(本次未做)。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# zcbot Skill 清单
|
# zcbot Skill 清单
|
||||||
|
|
||||||
服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材)
|
服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材)
|
||||||
最后更新:2026-06-29(ppt skill 重构为 SVG-first,移植自 ppt-master)
|
最后更新:2026-07-02(ppt skill 加渲图验收闭环 + 导出验收硬门 + 几何质检)
|
||||||
Skill 总数:17
|
Skill 总数:17
|
||||||
|
|
||||||
zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` + 配套 templates / scripts / Python helper),模型在识别用户意图后挂载对应 skill,按其内置的阶段化流程产出可交付物。本文档面向**使用方 / 协作方**,按"做什么、什么时候用、什么时候别用、典型产物"组织。
|
zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` + 配套 templates / scripts / Python helper),模型在识别用户意图后挂载对应 skill,按其内置的阶段化流程产出可交付物。本文档面向**使用方 / 协作方**,按"做什么、什么时候用、什么时候别用、典型产物"组织。
|
||||||
|
|
@ -170,7 +170,7 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` +
|
||||||
### ppt
|
### ppt
|
||||||
**生成可编辑 PowerPoint 演示文稿 (.pptx)。SVG-first 路线。**
|
**生成可编辑 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
|
- ✅ 用户明确点名 PPT / 幻灯片 / 演示文稿 / .pptx / slide / deck
|
||||||
|
|
@ -186,8 +186,8 @@ 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)
|
||||||
- **渲图验收** `svg_preview.py`:无头 Chrome 把 SVG 渲成 PNG 肉眼/vision 验版面;`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 素材摄取
|
||||||
|
|
||||||
**典型产物**:`exports/<topic>_<ts>.pptx`(原生可编辑)+ `svg_output/*.svg`(逐页设计源,改稿对象)+ `design_spec.md`/`spec_lock.md`。
|
**典型产物**:`exports/<topic>_<ts>.pptx`(原生可编辑)+ `svg_output/*.svg`(逐页设计源,改稿对象)+ `design_spec.md`/`spec_lock.md`。
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||||
# 改版本只动这一行。
|
# 改版本只动这一行。
|
||||||
__version__ = "0.35.1"
|
__version__ = "0.36.0"
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,11 @@ description: 生成 PowerPoint 演示文稿 (.pptx) 文件。✅ 触发:用户
|
||||||
|
|
||||||
把材料变成**可演示、可编辑**的 .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`。
|
> **为什么是 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 兜底)
|
- `svg_to_pptx.py` —— **SVG → 原生 PPTX**(逐元素译 DrawingML;默认嵌演讲者备注 + Office 兼容 PNG 兜底)
|
||||||
- `total_md_split.py` —— 把 `notes/total.md` 拆成逐页备注(导出前跑)
|
- `total_md_split.py` —— 把 `notes/total.md` 拆成逐页备注(导出前跑)
|
||||||
- `update_spec.py` —— 改 `spec_lock.md` 的颜色/字体后,**一键传播到所有已生成 SVG**(改稿用)
|
- `update_spec.py` —— 改 `spec_lock.md` 的颜色/字体后,**一键传播到所有已生成 SVG**(改稿用)
|
||||||
- `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,不直接调
|
- `project_utils.py` / `error_helper.py` —— 引擎辅助(canvas 校验 / 友好报错),被上面脚本 import,不直接调
|
||||||
|
|
||||||
**设计知识(references/,先读相关的,不默写)**:
|
**设计知识(references/,先读相关的,不默写)**:
|
||||||
|
|
@ -59,6 +60,7 @@ description: 生成 PowerPoint 演示文稿 (.pptx) 文件。✅ 触发:用户
|
||||||
└── .build/ # 可再生构建产物(dotfile 隐藏、随时可删;用户文件列表看不到)
|
└── .build/ # 可再生构建产物(dotfile 隐藏、随时可删;用户文件列表看不到)
|
||||||
├── svg_final/ # finalize 产出(图标/配图已内嵌,自包含;供 legacy 导出 + 忠实预览)
|
├── svg_final/ # finalize 产出(图标/配图已内嵌,自包含;供 legacy 导出 + 忠实预览)
|
||||||
├── preview/ # svg_preview 渲的验收 PNG
|
├── preview/ # svg_preview 渲的验收 PNG
|
||||||
|
├── acceptance.json # 渲图验收记录(每页源 sha1 + verdict;导出 gate 依据)
|
||||||
└── backup/latest/svg_output/ # SVG 源快照(只留最新一份,可不跑模型重新导出)
|
└── backup/latest/svg_output/ # SVG 源快照(只留最新一份,可不跑模型重新导出)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -140,40 +142,45 @@ references/visual-styles/<locked-style>.md # 锁定的视觉风格
|
||||||
```
|
```
|
||||||
.venv/Scripts/python.exe <skill_dir>/scripts/svg_quality_checker.py <project_dir>
|
.venv/Scripts/python.exe <skill_dir>/scripts/svg_quality_checker.py <project_dir>
|
||||||
```
|
```
|
||||||
- **任何 `error`(禁用特性 / viewBox 不符 / spec_lock 漂移 / **锁了图标 inventory 却全 deck 0 图标** / **内容 deck 全是文字方块(≥6 页且零 `<path>`/`<polygon>`/`<polyline>`/`<image>`)** 等)必须改:回阶段三重写该页再跑**,不放过。
|
- **任何 `error`(禁用特性 / viewBox 不符 / spec_lock 漂移 / **图标压在文字上、文字基线超出画布**(Geometry 检测,几何精确)/ **锁了图标 inventory 却全 deck 0 图标** / **内容 deck 全是文字方块(≥6 页且零 `<path>`/`<polygon>`/`<polyline>`/`<image>`)** 等)必须改:回阶段三重写该页再跑**,不放过。
|
||||||
- `warning`(低分辨率图 / 非 PPT 安全字体等):能顺手改就改,否则知会后放行。
|
- `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]`)。原样跑,读完整输出、认它的退出码。
|
||||||
|
|
||||||
## 阶段五:后处理 + 导出
|
## 阶段五:后处理 + 渲图验收(强制门)—— 全量,不抽查
|
||||||
|
|
||||||
⚠️ 三步**一步步来**,别合并成一条命令:
|
⚠️ 三步**一步步来**,别合并成一条命令:
|
||||||
```
|
```
|
||||||
# 5.1 拆备注
|
# 5.1 SVG 后处理(图标/配图内嵌 / 文本展平 / 圆角转 path)
|
||||||
.venv/Scripts/python.exe <skill_dir>/scripts/total_md_split.py <project_dir>
|
|
||||||
# 5.2 SVG 后处理(图标/配图内嵌 / 文本展平 / 圆角转 path)
|
|
||||||
.venv/Scripts/python.exe <skill_dir>/scripts/finalize_svg.py <project_dir>
|
.venv/Scripts/python.exe <skill_dir>/scripts/finalize_svg.py <project_dir>
|
||||||
# 5.3 导出原生 PPTX(默认嵌备注 + Office 兼容 PNG 兜底)
|
# 5.2 全量渲图(渲 .build/svg_final,同步登记 .build/acceptance.json 验收记录)
|
||||||
|
.venv/Scripts/python.exe <skill_dir>/scripts/svg_preview.py <project_dir>
|
||||||
|
# 5.3 read/look_at_image 逐页过目后,标记验收结论
|
||||||
|
.venv/Scripts/python.exe <skill_dir>/scripts/accept_pages.py <project_dir> --pass-all
|
||||||
|
# (有问题的页:--fail <页名> --reason "…";只标部分页:--pass <页名…>;看状态:--status)
|
||||||
|
```
|
||||||
|
- **默认渲整本,不带 `--pages`**。抽查 3 页只能覆盖 3 页,错位/文字溢出/元素重叠恰恰藏在没看的那些页里 —— 逐页手写绝对坐标,每页都可能翻车,所以**每页都要过目**。(页数多时可分批渲,但目标是 100% 覆盖,不是采样。)
|
||||||
|
- `read` / `look_at_image` **逐页**亲眼过:标题层级、卡片过挤/过空、**文字是否溢出卡片/被裁**、**元素是否重叠错位**、图标在不在(位置对不对)、节奏是否单调、配图位置。**看完才许标 pass** —— `--pass-all` 是"每页都看过且都合格"的宣告,不是跳过看的快捷键。
|
||||||
|
- 🚧 **差评即阻断 + 返工回路**:任一页有排版/溢出/重叠/半成品问题(哪怕只是封面)→ **改那一页 svg_output 的 SVG → 重跑 finalize → `svg_preview.py <project_dir> --pages <N>` 重渲该页 → 复看 → 再标 pass**。机制会强制这个回路:标 pass 和导出 gate 都校验"渲图之后源文件没再改过"(sha1),改了不重渲重看,gate 过不去。不许"看了一页差评、跳去看下一页好评就收尾"——那正是错位交付的来路。
|
||||||
|
- ❌ **禁止盲改**:修错位/补图标不许写脚本批量 regex 插元素、改完不看渲染结果(真实事故来源:质检提示缺图标后 regex 批量盲插,图标全压在文字上交付)。每处修改都要走上面的返工回路落到"复看"。
|
||||||
|
|
||||||
|
> svg_preview 渲的是 SVG(视觉真相,与导出的 pptx 1:1),比渲最终 pptx 更早更准暴露观感问题。需要校验"SVG→DrawingML 转换是否保真",再开导出的 pptx 在 PowerPoint 里看。
|
||||||
|
|
||||||
|
## 阶段六:导出
|
||||||
|
|
||||||
|
```
|
||||||
|
# 6.1 拆备注
|
||||||
|
.venv/Scripts/python.exe <skill_dir>/scripts/total_md_split.py <project_dir>
|
||||||
|
# 6.2 导出原生 PPTX(默认嵌备注 + Office 兼容 PNG 兜底)
|
||||||
.venv/Scripts/python.exe <skill_dir>/scripts/svg_to_pptx.py <project_dir>
|
.venv/Scripts/python.exe <skill_dir>/scripts/svg_to_pptx.py <project_dir>
|
||||||
# 产物:exports/<slug>_<ts>.pptx(原生,读 svg_output/)+ .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` 只留给"确实不需要视觉验收"的极端场景,**不是跳过验收的捷径**。
|
||||||
|
- 🚧 **导出边界图标门(硬)**:spec_lock 锁了 `icons.library` + 非空 `inventory` 但全 deck 零 `<use data-icon>` → 同样 `[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 核心价值直接归零、判废**。官方脚本跑不动就读它的报错按流程修 / 反馈,不要另起平行管线。
|
- 🛑 **导出唯一入口 = 官方 `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 零 `<use data-icon>` → 导出**直接 `[ERROR]` 退非零、不产出 pptx**(这是最后一道,`| head` 绕不过)。正确做法是回阶段三给内容页补图标重跑;只有 lock 确实过期 / 有意做无图标 deck 才加 `--allow-iconless` 放行。
|
|
||||||
- ❌ 别用 `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。
|
||||||
- 改稿:只改 `spec_lock.md` 的颜色/字体 → `update_spec.py <project_dir>` 传播到所有 SVG;改版式/内容 → 重写对应页 SVG 再跑 5.2–5.3,**不要直接 edit 成品 .pptx**。
|
- 改稿:只改 `spec_lock.md` 的颜色/字体 → `update_spec.py <project_dir>` 传播到所有 SVG(所有页源都变了 → **重跑阶段五全量重渲重标**,顺手把全本再过一遍眼);改版式/内容 → 重写对应页 SVG 再走阶段五返工回路 + 6.2,**不要直接 edit 成品 .pptx**。
|
||||||
|
|
||||||
## 阶段六:验收(渲图肉眼/vision 看)—— 全量,不抽查
|
|
||||||
|
|
||||||
```
|
|
||||||
.venv/Scripts/python.exe <skill_dir>/scripts/svg_preview.py <project_dir>
|
|
||||||
```
|
|
||||||
- **默认渲整本,不带 `--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 里看。
|
|
||||||
|
|
||||||
完成后:用 `update_spec` / 重写页迭代;用户确认**实质改动**后追加一行到 `REVISIONS.md`。
|
完成后:用 `update_spec` / 重写页迭代;用户确认**实质改动**后追加一行到 `REVISIONS.md`。
|
||||||
|
|
||||||
|
|
@ -206,7 +213,8 @@ references/visual-styles/<locked-style>.md # 锁定的视觉风格
|
||||||
- **breathing 页堆多卡网格**(违节奏,显 AI 味)
|
- **breathing 页堆多卡网格**(违节奏,显 AI 味)
|
||||||
- 模板照搬不重上皮(直接用模板默认渐变/阴影/字号)
|
- 模板照搬不重上皮(直接用模板默认渐变/阴影/字号)
|
||||||
- 质检没过就交付 / 直接 edit 成品 .pptx 改稿
|
- 质检没过就交付 / 直接 edit 成品 .pptx 改稿
|
||||||
- **只渲/只看几页就收尾**(错位藏在没看的页里);**看到差评却不返工**(封面 vision 说"半成品/挤左侧"还继续导出交付)
|
- **只渲/只看几页就收尾**(错位藏在没看的页里);**看到差评却不返工**(封面 vision 说"半成品/挤左侧"还继续导出交付);**没看 PNG 就 `accept_pages --pass-all`**(把验收门当橡皮图章 —— gate 只能强制"渲过、源没改",看没看只有你自己知道,糊弄的结果就是错位 deck 交到用户手上)
|
||||||
|
- **质检/渲图后为消警告写脚本批量盲插元素**(regex 批量加图标、改坐标,改完不复看渲染 —— 真实事故:25 页 deck 图标全压在文字上交付)
|
||||||
- **用 `| head` 截断质检或导出输出**(吞非零退出码 + 截掉最后的门结论,门形同虚设)
|
- **用 `| head` 截断质检或导出输出**(吞非零退出码 + 截掉最后的门结论,门形同虚设)
|
||||||
- 起名 `output.pptx` —— 按主题命名
|
- 起名 `output.pptx` —— 按主题命名
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 <project_dir> --status # 看各页验收状态
|
||||||
|
python accept_pages.py <project_dir> --pass P01_cover P02_toc
|
||||||
|
python accept_pages.py <project_dir> --pass-all # 全部标 pass
|
||||||
|
python accept_pages.py <project_dir> --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())
|
||||||
|
|
@ -12,13 +12,21 @@ SVG-first 管线里 SVG 就是视觉真相(导出的 pptx 与之 1:1),所以验
|
||||||
约定:优先渲 <project_dir>/svg_output;没有则退而渲 <project_dir> 本身。
|
约定:优先渲 <project_dir>/svg_output;没有则退而渲 <project_dir> 本身。
|
||||||
依赖:本机装了 Chrome 或 Edge(无需 pip 包)。两者都没有则报错退出。
|
依赖:本机装了 Chrome 或 Edge(无需 pip 包)。两者都没有则报错退出。
|
||||||
产物默认 2x 超采样,够清晰看版面。
|
产物默认 2x 超采样,够清晰看版面。
|
||||||
|
|
||||||
|
渲 project 目录时同步维护 `.build/acceptance.json` 验收记录(每页:源 sha1 +
|
||||||
|
渲染时间 + verdict)。看完 PNG 后用 accept_pages.py 标 pass/fail;svg_to_pptx
|
||||||
|
的导出 gate 要求每页 verdict=pass 且源文件此后未改动 —— "从没渲过就交付"和
|
||||||
|
"改了页不复看"都会被导出边界挡下。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
try: # zcbot: Windows GBK 控制台兼容,避免 emoji/© 等触发 UnicodeEncodeError
|
try: # zcbot: Windows GBK 控制台兼容,避免 emoji/© 等触发 UnicodeEncodeError
|
||||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||||
sys.stderr.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
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _collect(target: Path) -> tuple[list[Path], Path]:
|
def _collect(target: Path) -> tuple[list[Path], Path, Path | None, str]:
|
||||||
"""返回 (svg 文件列表, 默认输出目录)。"""
|
"""返回 (svg 文件列表, 默认输出目录, project_root 或 None, 渲染来源标签)。
|
||||||
|
|
||||||
|
project_root 非 None 时(target 是标准 project 目录),渲染结束后会把每页
|
||||||
|
写进 `.build/acceptance.json` 验收记录 —— 导出 gate 依赖它。
|
||||||
|
"""
|
||||||
from project_utils import svg_final_dir, preview_dir
|
from project_utils import svg_final_dir, preview_dir
|
||||||
if target.is_file() and target.suffix.lower() == ".svg":
|
if target.is_file() and target.suffix.lower() == ".svg":
|
||||||
return [target], preview_dir(target.parent)
|
return [target], preview_dir(target.parent), None, "file"
|
||||||
# 目录:优先 .build/svg_final(finalize 后图标/配图已内嵌,渲出来最忠实);
|
# 目录:优先 .build/svg_final(finalize 后图标/配图已内嵌,渲出来最忠实);
|
||||||
# 没有就退而渲 svg_output(生成中验收 —— cairosvg 兜底会就地展开图标,chromium
|
# 没有就退而渲 svg_output(生成中验收 —— cairosvg 兜底会就地展开图标,chromium
|
||||||
# 直接渲则图标仍是占位符不显示)。
|
# 直接渲则图标仍是占位符不显示)。
|
||||||
sf = svg_final_dir(target)
|
sf = svg_final_dir(target)
|
||||||
if sf.is_dir() and any(sf.glob("*.svg")):
|
if sf.is_dir() and any(sf.glob("*.svg")):
|
||||||
svg_dir = sf
|
svg_dir, source = sf, "svg_final"
|
||||||
elif (target / "svg_output").is_dir():
|
elif (target / "svg_output").is_dir():
|
||||||
svg_dir = target / "svg_output"
|
svg_dir, source = target / "svg_output", "svg_output"
|
||||||
else:
|
else:
|
||||||
svg_dir = target
|
svg_dir, source = target, "dir"
|
||||||
files = sorted(svg_dir.glob("*.svg"))
|
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]:
|
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")
|
ap.add_argument("--scale", type=float, default=2.0, help="超采样倍数,默认 2")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
files, default_out = _collect(args.target)
|
files, default_out, project_root, source = _collect(args.target)
|
||||||
if not files:
|
if not files:
|
||||||
raise SystemExit(f"[fatal] 没找到 SVG:{args.target}")
|
raise SystemExit(f"[fatal] 没找到 SVG:{args.target}")
|
||||||
|
all_count = len(files)
|
||||||
files = _select(files, args.pages)
|
files = _select(files, args.pages)
|
||||||
if not files:
|
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
|
out_dir = args.out or default_out
|
||||||
|
|
||||||
|
|
@ -262,7 +354,7 @@ def main() -> None:
|
||||||
"沙箱镜像应自带 /usr/bin/chromium(rebuild sandbox 镜像),"
|
"沙箱镜像应自带 /usr/bin/chromium(rebuild sandbox 镜像),"
|
||||||
"或 `pip install cairosvg`,或设 CHROMIUM 环境变量。")
|
"或 `pip install cairosvg`,或设 CHROMIUM 环境变量。")
|
||||||
|
|
||||||
done = []
|
done: list[tuple[Path, Path]] = []
|
||||||
for svg in files:
|
for svg in files:
|
||||||
png = out_dir / (svg.stem + ".png")
|
png = out_dir / (svg.stem + ".png")
|
||||||
if browser:
|
if browser:
|
||||||
|
|
@ -270,11 +362,13 @@ def main() -> None:
|
||||||
else:
|
else:
|
||||||
render_cairosvg(cairo, svg, png, scale=args.scale)
|
render_cairosvg(cairo, svg, png, scale=args.scale)
|
||||||
if png.exists():
|
if png.exists():
|
||||||
done.append(png)
|
done.append((svg, png))
|
||||||
print(f" [ok] {svg.name} -> {png}")
|
print(f" [ok] {svg.name} -> {png}")
|
||||||
else:
|
else:
|
||||||
print(f" [FAIL] {svg.name} 未生成 PNG")
|
print(f" [FAIL] {svg.name} 未生成 PNG")
|
||||||
print(f"[svg_preview] {len(done)}/{len(files)} 页渲好,输出目录:{out_dir}")
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -331,6 +331,13 @@ class SVGQualityChecker:
|
||||||
if not self.template_mode:
|
if not self.template_mode:
|
||||||
self._check_graphic_richness(content, result)
|
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
|
# Determine pass/fail
|
||||||
result['passed'] = len(result['errors']) == 0
|
result['passed'] = len(result['errors']) == 0
|
||||||
|
|
||||||
|
|
@ -1494,6 +1501,315 @@ class SVGQualityChecker:
|
||||||
if g == 0:
|
if g == 0:
|
||||||
self._pages_no_graphic.append(result.get('file', '?'))
|
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 <text> with styled inline tspans
|
||||||
|
(e.g. <text text-anchor="end">$4.2B <tspan>(35%)</tspan></text>
|
||||||
|
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):
|
def _print_graphic_summary(self):
|
||||||
"""Deck-level flat-deck gate.
|
"""Deck-level flat-deck gate.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,71 @@ 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]:
|
||||||
|
"""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:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
"""CLI entry point for the SVG to PPTX conversion tool."""
|
"""CLI entry point for the SVG to PPTX conversion tool."""
|
||||||
transition_choices = (
|
transition_choices = (
|
||||||
|
|
@ -206,6 +271,12 @@ Recorded narration:
|
||||||
'would render flat / icon-less). Use only for a stale lock or an '
|
'would render flat / icon-less). Use only for a stale lock or an '
|
||||||
'intentionally icon-less deck.')
|
'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 = parser.add_mutually_exclusive_group()
|
||||||
mode_group.add_argument('--only', type=str, choices=['native', 'legacy'], default=None,
|
mode_group.add_argument('--only', type=str, choices=['native', 'legacy'], default=None,
|
||||||
help='Only generate one version: native (editable shapes) or legacy (SVG image)')
|
help='Only generate one version: native (editable shapes) or legacy (SVG image)')
|
||||||
|
|
@ -380,6 +451,40 @@ Recorded narration:
|
||||||
)
|
)
|
||||||
return 1
|
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 <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).\n"
|
||||||
|
" Escape hatch (NOT for skipping review): --allow-unreviewed.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
backup_dir: Path | None = None
|
backup_dir: Path | None = None
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue