feat(ppt): 验收改纯代码几何质检,撤除渲图 vision 验收闭环(bump 0.50.0)
真实会话复盘:沙箱 chromium "找到但渲染崩"无回退,硬验收门+死路逼模型 pip install cairosvg 手写渲染;look_at_image 逐页 26-42s 纯烧 token。 - pptx_cli.py:删导出渲图验收门(_acceptance_problems)+ --allow-unreviewed; 几何硬错误质检门(check13/14 越界/压字/错位)原样保留并继续在导出边界复跑 - 删 accept_pages.py 及 acceptance.json 登记机制 - svg_preview.py 保留为手动工具:chromium 渲染失败按页自动回退 cairosvg (原来只在"完全没浏览器"时才回退,找到但崩=死路) - SKILL.md:阶段五简化为 finalize 单步;渲图预览零提及(提示面不给渲染 入口);强调导出唯一入口 svg_to_pptx;Geometry warning 改"回源核对坐标" - DESIGN §8.10 记录取舍推翻(0.36.0 渲图验收闭环);PROGRESS/SKILL_LIST 同步 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
e9e549b606
commit
09db2c9c93
|
|
@ -309,6 +309,10 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
|
||||||
|
|
||||||
**根因**(同类事故两次):①文档层禁令拦不住绕开官方管线的产物——管线内的门只在模型用了官方脚本时生效;②否定式禁令若自带完整违规配方(精确命令/文件名/分步做法),压力下反成模型的执行菜谱(粉红大象:否定词丢了,菜谱留下);③官方脚本报错文案里的 `pip install` 对 agent 就是一条会被照办的指令。**三层治理**:平台层 `core/pptx_guard.py`(loop 在 shell/run_python 后对本步新 .pptx 机检"整页贴图"特征,tool 结果注入 ERROR 当场逼返工;判定刻意保守——≥2 页、≥80% 页整版位图、全 deck 零文本/表格/图表才判废,解析失败一律放行)——**检产物不检命令**,换什么工具造都绕不开;skill 撰写纪律——**禁令只点名不给配方**,正面写"唯一入口 + 报错就修/如实上报";脚本文案纪律——**打印给 agent 的报错不得含安装/替代管线指令**,缺依赖=环境问题,说明并上报。本条推翻 0.35.1"平台层自动检测暂缓"的拍板(复发即证据)。
|
**根因**(同类事故两次):①文档层禁令拦不住绕开官方管线的产物——管线内的门只在模型用了官方脚本时生效;②否定式禁令若自带完整违规配方(精确命令/文件名/分步做法),压力下反成模型的执行菜谱(粉红大象:否定词丢了,菜谱留下);③官方脚本报错文案里的 `pip install` 对 agent 就是一条会被照办的指令。**三层治理**:平台层 `core/pptx_guard.py`(loop 在 shell/run_python 后对本步新 .pptx 机检"整页贴图"特征,tool 结果注入 ERROR 当场逼返工;判定刻意保守——≥2 页、≥80% 页整版位图、全 deck 零文本/表格/图表才判废,解析失败一律放行)——**检产物不检命令**,换什么工具造都绕不开;skill 撰写纪律——**禁令只点名不给配方**,正面写"唯一入口 + 报错就修/如实上报";脚本文案纪律——**打印给 agent 的报错不得含安装/替代管线指令**,缺依赖=环境问题,说明并上报。本条推翻 0.35.1"平台层自动检测暂缓"的拍板(复发即证据)。
|
||||||
|
|
||||||
|
### 8.10 ppt 验收:vision 渲图过目 → 纯代码几何质检(2026-07-07)
|
||||||
|
|
||||||
|
**推翻 0.36.0 的渲图验收闭环**(svg_preview 渲 PNG → look_at_image 逐页过目 → accept_pages 标 pass → 导出 gate 校验 sha1)。三条根因:①**成本**——逐页 vision 26-42s/页、每 deck 8-25 次调用,烧 token 大头;②**环境脆弱**——沙箱 chromium "找到了但渲染崩"没有回退路径,硬门+死路逼模型即兴发挥(pip install cairosvg、手写渲染循环,正是 8.9 要防的行为);③**验收环节的"看"本身无法机检**——gate 只能强制"渲过",看没看/看得准不准全凭模型自觉,0.36.0 就写明了这个边界。**替代**:质检器 check 13/14 的精确几何检测(越界/压字/错位/网格漂移/图表退化)已覆盖"正确性"面,且在导出边界自动复跑;**主动放弃美学验收**(配色观感/页面空挤,单调门除外)——底线正确性机检可保,观感交给用户反馈迭代。svg_preview 保留为手动工具(chromium 失败自动回退 cairosvg)但 **SKILL 提示面零提及**——不给渲染入口模型就不会主动渲(同 8.9"禁令不带配方"逻辑,正面只写"导出唯一入口 svg_to_pptx");accept_pages/acceptance.json/--allow-unreviewed 整套删除。**回退信号**:若"几何全对但观感翻车"成为用户改稿主因,再考虑便宜的整本拼图单次 vision 抽检,而非逐页。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 附录:DeepSeek V4 关键事实(2026-04-24)
|
## 附录:DeepSeek V4 关键事实(2026-04-24)
|
||||||
|
|
|
||||||
|
|
@ -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-07(撤除企微欢迎推送机制,聊天优先布局下不需要,bump 0.46.1)
|
最后更新:2026-07-07(ppt 验收改纯代码几何质检,撤除渲图 vision 验收闭环,bump 0.50.0)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
|
|
||||||
### 2026-07
|
### 2026-07
|
||||||
|
|
||||||
|
- **07-07 / 0.50.0**:ppt 验收改纯代码几何质检,撤除 0.36.0 渲图 vision 验收闭环(DESIGN §8.10)——真实会话复盘:沙箱 chromium "找到了但渲染崩"无回退,硬门+死路逼模型 pip install cairosvg 手写渲染,look_at_image 逐页 26-42s 纯烧 token。删 accept_pages.py / acceptance.json 登记 / 导出验收门 / `--allow-unreviewed`;正确性由质检器 check13/14 几何检测保底(越界/压字/错位,导出边界自动复跑);svg_preview.py 保留为手动工具并修回退(chromium 渲染失败按页自动落 cairosvg),但 SKILL/SKILL_LIST **零提及渲图**(提示面不给渲染入口,模型不再主动渲);SKILL.md 阶段五简化为 finalize 单步 + 强调导出唯一入口 svg_to_pptx,Geometry warning 改"回源核对坐标"。放弃逐页美学验收,观感靠用户反馈迭代。
|
||||||
- **07-07 / 0.49.0**:语音输入流式化(边说边出字,消掉松手后停顿)——用户报"录完停顿几秒才出字",根因:整段模式按讯飞文档 40ms/帧节奏回放音频 = 按录音时长等比例白等(16.8s 音频实测等 4.6s)。改双通道:① **流式主通道** `WS /v1/asr/stream`——录音开始即连(首消息 JWT 鉴权,不走 query 防进 access log),前端增量降采样(carry 缓冲跨片连续)实时上行,服务端 `XfyunStream` 转发讯飞(dwa=wpgs)并**在服务端合并动态修正**(sn/pgs/rg),回推 partial 全文,录音面板实时字幕,松手只等最终帧——实测 16.8s 音频 **end→final 179ms**(61 个增量帧,文本与整段一致);引擎提前收尾(静音 10s/60s 上限)推 final 前端自动结束。② **整段 POST 兜底**(WS 连不上/中途挂/超时),帧间隔实测调优 40ms→8ms(4.6s→1.75s,文本一致,已到讯飞处理地板)。nginx 示例加 `location = /v1/asr/stream` WS Upgrade 段(不配不坏,退兜底);踩坑:讯飞控制台 IP 白名单(握手 403 "Your IP address is not allowed"),进 RUN 故障兜底。
|
- **07-07 / 0.49.0**:语音输入流式化(边说边出字,消掉松手后停顿)——用户报"录完停顿几秒才出字",根因:整段模式按讯飞文档 40ms/帧节奏回放音频 = 按录音时长等比例白等(16.8s 音频实测等 4.6s)。改双通道:① **流式主通道** `WS /v1/asr/stream`——录音开始即连(首消息 JWT 鉴权,不走 query 防进 access log),前端增量降采样(carry 缓冲跨片连续)实时上行,服务端 `XfyunStream` 转发讯飞(dwa=wpgs)并**在服务端合并动态修正**(sn/pgs/rg),回推 partial 全文,录音面板实时字幕,松手只等最终帧——实测 16.8s 音频 **end→final 179ms**(61 个增量帧,文本与整段一致);引擎提前收尾(静音 10s/60s 上限)推 final 前端自动结束。② **整段 POST 兜底**(WS 连不上/中途挂/超时),帧间隔实测调优 40ms→8ms(4.6s→1.75s,文本一致,已到讯飞处理地板)。nginx 示例加 `location = /v1/asr/stream` WS Upgrade 段(不配不坏,退兜底);踩坑:讯飞控制台 IP 白名单(握手 403 "Your IP address is not allowed"),进 RUN 故障兜底。
|
||||||
- **07-07 / 0.48.1**:语音手势两端统一(用户提出 PC 也要能按住说话)——不再按 pointerType 分流,改按**按下时长**判别,PC 鼠标/手机触屏同一心智模型:按住 ≥300ms = 按住说话(松开即转写,上滑取消);快速点一下 <300ms = 转成连续录音免持(说完 Enter / 再点结束,Esc 取消;鼠标按住 60s 会累且易失手,长口述场景必须保留免持)。实现要点:press 发起的录音松手后合成 click 全压掉(防"松开转写完又开新录音"/"短按刚转免持被自己 click 停掉"),"点按钮停止连续录音"的 press 不压(`_pressActive` 区分);getUserMedia 完成前松手按同一时长规则收尾;键盘激活按钮(无 pointer 事件)兜底走 click 起录。
|
- **07-07 / 0.48.1**:语音手势两端统一(用户提出 PC 也要能按住说话)——不再按 pointerType 分流,改按**按下时长**判别,PC 鼠标/手机触屏同一心智模型:按住 ≥300ms = 按住说话(松开即转写,上滑取消);快速点一下 <300ms = 转成连续录音免持(说完 Enter / 再点结束,Esc 取消;鼠标按住 60s 会累且易失手,长口述场景必须保留免持)。实现要点:press 发起的录音松手后合成 click 全压掉(防"松开转写完又开新录音"/"短按刚转免持被自己 click 停掉"),"点按钮停止连续录音"的 press 不压(`_pressActive` 区分);getUserMedia 完成前松手按同一时长规则收尾;键盘激活按钮(无 pointer 事件)兜底走 click 起录。
|
||||||
- **07-07 / 0.48.0**:语音输入手势升级(纯前端,后端零改动)——业界对齐调研后两端分治:① **触屏按住说话**(微信/DeepSeek 式):`pointerdown`(pointerType≠mouse)按住录音、松开转写、上滑 60px 进入"松开取消"(面板变红),pointer capture 保证滑出按钮仍收事件,touch 后合成 click 用时间窗压掉;② **桌面点击开停 + Enter/Esc 收尾**:说完按 Enter 结束转写、Esc 取消(capture 拦截防落回 textarea 触发发送)。**明确不做桌面 VAD 静音自动停**:口述需求"边想边说"停顿 3~5s 是常态,自动截断比多按一下更伤,业界听写类(ChatGPT/Google Docs/Win+H)也全是手动收尾,自动停只属于 Siri 式短指令场景。③ 录音期 `#voice-rec-panel` 替换 textarea:实时波浪(采集 chunk 顺手算 RMS,1/8 抽样,零额外开销)+ 计时 + 手势提示;iOS Safari 手势内 `ctx.resume()`;授权弹窗期间松手 → 启动完成立即收尾。
|
- **07-07 / 0.48.0**:语音输入手势升级(纯前端,后端零改动)——业界对齐调研后两端分治:① **触屏按住说话**(微信/DeepSeek 式):`pointerdown`(pointerType≠mouse)按住录音、松开转写、上滑 60px 进入"松开取消"(面板变红),pointer capture 保证滑出按钮仍收事件,touch 后合成 click 用时间窗压掉;② **桌面点击开停 + Enter/Esc 收尾**:说完按 Enter 结束转写、Esc 取消(capture 拦截防落回 textarea 触发发送)。**明确不做桌面 VAD 静音自动停**:口述需求"边想边说"停顿 3~5s 是常态,自动截断比多按一下更伤,业界听写类(ChatGPT/Google Docs/Win+H)也全是手动收尾,自动停只属于 Siri 式短指令场景。③ 录音期 `#voice-rec-panel` 替换 textarea:实时波浪(采集 chunk 顺手算 RMS,1/8 抽样,零额外开销)+ 计时 + 手势提示;iOS Safari 手势内 `ctx.resume()`;授权弹窗期间松手 → 启动完成立即收尾。
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# zcbot Skill 清单
|
# zcbot Skill 清单
|
||||||
|
|
||||||
服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材)
|
服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材)
|
||||||
最后更新:2026-07-06(ppt skill 定向 GLM-5.2 模型 —— frontmatter `model:` 机制,load 时自动切换)
|
最后更新:2026-07-07(ppt 验收改纯代码几何质检,撤除渲图 vision 验收环节)
|
||||||
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**(导出边界硬门:每页都要渲图过目、标记 pass 且此后源未改动,否则拒绝产出 pptx)。核心是 AI 把每页当**矢量设计稿手写成 SVG**(设计自由度=浏览器级),再由纯 Python 转换器逐元素译成**原生 DrawingML**(形状/文本/渐变都能在 PowerPoint 里选中改)——告别 python-pptx 固定版式件的单调与 AI 味。
|
把材料(汇报草稿 / 项目方案 / 调研报告)变成可演示、**可编辑**的 .pptx。流程:**素材摄取 → 八条对齐 + 逐页大纲(spec)→ [配图] → 逐页手写 SVG → SVG 质检 → 后处理 → 导出 PPTX**(质检是代码几何检测,越界/压字/错位全部机检;导出边界自动复跑同套硬错误,有 error 拒绝产出 pptx)。核心是 AI 把每页当**矢量设计稿手写成 SVG**(设计自由度=浏览器级),再由纯 Python 转换器逐元素译成**原生 DrawingML**(形状/文本/渐变都能在 PowerPoint 里选中改)——告别 python-pptx 固定版式件的单调与 AI 味。
|
||||||
|
|
||||||
**触发**:
|
**触发**:
|
||||||
- ✅ 用户明确点名 PPT / 幻灯片 / 演示文稿 / .pptx / slide / deck
|
- ✅ 用户明确点名 PPT / 幻灯片 / 演示文稿 / .pptx / slide / deck
|
||||||
|
|
@ -189,7 +189,7 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` +
|
||||||
- **模板库**: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;**导出边界自动复跑同套硬错误,error 拒绝导出、无豁免参数**)
|
- **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
|
- **一键改稿** `update_spec.py`:改 `spec_lock.md` 的颜色/字体后传播到所有已生成 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.49.0"
|
__version__ = "0.50.0"
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,11 @@ model: glm.pro52
|
||||||
|
|
||||||
把材料变成**可演示、可编辑**的 .pptx。
|
把材料变成**可演示、可编辑**的 .pptx。
|
||||||
|
|
||||||
**核心管线**:`素材 → 策略(spec)→ [配图] → 执行(逐页手写 SVG)→ SVG 质检 → 后处理 → 渲图验收 → 导出 PPTX`(验收在导出**之前**;导出边界有硬门,没验收过的 deck 拒绝产出 pptx)
|
**核心管线**:`素材 → 策略(spec)→ [配图] → 执行(逐页手写 SVG)→ SVG 质检 → 后处理 → 导出 PPTX`(质检是代码几何检测 —— 越界/压字/错位/网格漂移全部机检;导出边界自动复跑同一套硬错误检查,有 error 拒绝产出 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 / 质检 / 导出」等关键阶段;不要把每页内部写入都当进度步骤。
|
||||||
|
|
||||||
## 资源
|
## 资源
|
||||||
|
|
||||||
|
|
@ -22,8 +22,6 @@ model: glm.pro52
|
||||||
- `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);渲 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,13 +57,11 @@ model: glm.pro52
|
||||||
├── exports/<slug>_<ts>.pptx # ★ 最终产物(原生 DrawingML,可编辑)
|
├── exports/<slug>_<ts>.pptx # ★ 最终产物(原生 DrawingML,可编辑)
|
||||||
├── REVISIONS.md # 修订日志(见 §修订日志)
|
├── REVISIONS.md # 修订日志(见 §修订日志)
|
||||||
└── .build/ # 可再生构建产物(dotfile 隐藏、随时可删;用户文件列表看不到)
|
└── .build/ # 可再生构建产物(dotfile 隐藏、随时可删;用户文件列表看不到)
|
||||||
├── svg_final/ # finalize 产出(图标/配图已内嵌,自包含;供 legacy 导出 + 忠实预览)
|
├── svg_final/ # finalize 产出(图标/配图已内嵌,自包含;供 legacy 导出)
|
||||||
├── preview/ # svg_preview 渲的验收 PNG
|
|
||||||
├── acceptance.json # 渲图验收记录(每页源 sha1 + verdict;导出 gate 依据)
|
|
||||||
└── backup/latest/svg_output/ # SVG 源快照(只留最新一份,可不跑模型重新导出)
|
└── backup/latest/svg_output/ # SVG 源快照(只留最新一份,可不跑模型重新导出)
|
||||||
```
|
```
|
||||||
|
|
||||||
**所有产物写 `<project_dir>` 下**,不写 cwd / `skills/` / repo 根。**可见面 = 源 + 交付物**(sources/images/svg_output/notes/exports + 两个 spec + REVISIONS);派生的中间物(svg_final/preview/backup)一律进 `.build/`,由脚本自动落位,**不要手动在根目录建 svg_final/preview/backup**。
|
**所有产物写 `<project_dir>` 下**,不写 cwd / `skills/` / repo 根。**可见面 = 源 + 交付物**(sources/images/svg_output/notes/exports + 两个 spec + REVISIONS);派生的中间物(svg_final/backup)一律进 `.build/`,由脚本自动落位,**不要手动在根目录建 svg_final/backup**。
|
||||||
|
|
||||||
## 默认主题 — 自由设计(content-driven)
|
## 默认主题 — 自由设计(content-driven)
|
||||||
|
|
||||||
|
|
@ -145,29 +141,19 @@ 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 漂移 / **图标压在文字上、文字基线超出画布、CJK 文字互相叠压**(Geometry 检测,几何精确)/ **兄弟卡片错位 2–12px、偏离 layout_grid 网格、正文越过 content_bottom 侵入页脚区、spec 指派了 page_charts 该页却零图形(图表被退化成文字)**(Alignment 检测,几何精确)/ **锁了图标 inventory 却全 deck 0 图标** / **内容 deck 全是文字方块(≥6 页且零 `<path>`/`<polygon>`/`<polyline>`/`<image>`)** / **≥4 页同版式指纹(单调门,含两栏裸文字列表)** 等)必须改:回阶段三重写该页再跑**,不放过。
|
- **任何 `error`(禁用特性 / viewBox 不符 / spec_lock 漂移 / **图标压在文字上、文字基线超出画布、CJK 文字互相叠压**(Geometry 检测,几何精确)/ **兄弟卡片错位 2–12px、偏离 layout_grid 网格、正文越过 content_bottom 侵入页脚区、spec 指派了 page_charts 该页却零图形(图表被退化成文字)**(Alignment 检测,几何精确)/ **锁了图标 inventory 却全 deck 0 图标** / **内容 deck 全是文字方块(≥6 页且零 `<path>`/`<polygon>`/`<polyline>`/`<image>`)** / **≥4 页同版式指纹(单调门,含两栏裸文字列表)** 等)必须改:回阶段三重写该页再跑**,不放过。
|
||||||
- `warning`(低分辨率图 / 非 PPT 安全字体等):能顺手改就改,否则知会后放行。**例外:`Geometry:` 开头的文字重叠 warning 不许无视** —— 它给了精确坐标,是"大字压说明 / 同行文字互侵"的高嫌疑点(估宽无法区分擦边与压字,所以只报 warn),阶段五渲图时**必须对着该页该坐标专门看**,压了就返工。
|
- `warning`(低分辨率图 / 非 PPT 安全字体等):能顺手改就改,否则知会后放行。**例外:`Geometry:` 开头的文字重叠 warning 不许无视** —— 它给了精确坐标,是"大字压说明 / 同行文字互侵"的高嫌疑点(估宽无法区分擦边与压字,所以只报 warn)。**必须回源 SVG 对着该坐标核对**:算一下两段文字的实际占位(字号 × 字数 vs 间距),确实互侵就改坐标/缩字号/换行,擦边误报则知会后放行。
|
||||||
- 跑 `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 到那里一样拒绝导出 —— 在这里主动跑并连警告一起读,能更早返工。
|
- 跳过本阶段没有意义:导出边界会**自动复跑同一套逐页硬错误检查**(见阶段六质检门),error 到那里一样拒绝导出 —— 在这里主动跑并连警告一起读,能更早返工。
|
||||||
|
|
||||||
## 阶段五:后处理 + 渲图验收(强制门)—— 全量,不抽查
|
## 阶段五:后处理(finalize)
|
||||||
|
|
||||||
⚠️ 三步**一步步来**,别合并成一条命令:
|
|
||||||
```
|
```
|
||||||
# 5.1 SVG 后处理(图标/配图内嵌 / 文本展平 / 圆角转 path)
|
# 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.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% 覆盖,不是采样。)
|
- 🚧 **改稿返工回路**:质检(阶段四 / 导出边界)报某页 error → **改那一页 svg_output 的 SVG → 重跑质检确认清零 → 重跑 finalize**。不许"看到 error 却只改别的页就往下走"。
|
||||||
- `read` / `look_at_image` **逐页**亲眼过:标题层级、卡片过挤/过空、**文字是否溢出卡片/被裁**、**元素是否重叠错位**、**并排元素顶/底是否对齐、与上一页对比左缘/内容顶线是否一致**(跨页一致性只有连续翻看才看得出)、图标在不在(位置对不对)、节奏是否单调(连续几页同为卡片墙就该返工换形态)、配图位置。**看完才许标 pass** —— `--pass-all` 是"每页都看过且都合格"的宣告,不是跳过看的快捷键。
|
- ❌ **禁止盲改**:修错位/补图标不许写脚本批量 regex 插元素(真实事故来源:质检提示缺图标后 regex 批量盲插,图标全压在文字上交付)。逐页手改,改完重跑质检 —— Geometry/Alignment 检测会抓住压字和错位。
|
||||||
- 🚧 **差评即阻断 + 返工回路**:任一页有排版/溢出/重叠/半成品问题(哪怕只是封面)→ **改那一页 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 里看。
|
|
||||||
|
|
||||||
## 阶段六:导出
|
## 阶段六:导出
|
||||||
|
|
||||||
|
|
@ -178,14 +164,13 @@ 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/(源快照,只留最新)
|
||||||
```
|
```
|
||||||
- 🚧 **导出边界质检门(硬,无豁免参数)**:导出前自动复跑阶段四质检的逐页硬错误(禁用特性 / 坏 XML / 图片文件缺失 / 图标压字·出画布几何错误等),**有 error 直接拒绝导出**。没有任何 `--allow-*` 能绕过 —— 这些是真缺陷,回 svg_output 修完再来。
|
- 🚧 **导出边界质检门(硬,无豁免参数)**:导出前自动复跑阶段四质检的逐页硬错误(禁用特性 / 坏 XML / 图片文件缺失 / 图标压字·出画布几何错误 / 错位·越界等),**有 error 直接拒绝导出**。没有任何 `--allow-*` 能绕过 —— 这些是真缺陷,回 svg_output 修完再来。
|
||||||
- 🚧 **导出边界验收门(硬)**: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` 放行。
|
- 🚧 **导出边界图标门(硬)**: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 改坏了。
|
- ❌ **别加 `-s final`**:native 导出默认读 `svg_output/`(转换器自己处理图标占位与 `../images/` 相对路径),`-s final` 只会引出图片路径错位这类连锁问题;真实事故里模型为绕它把 svg_output 源里的 href 改坏了。
|
||||||
- 🛑 **导出唯一入口 = 官方 `svg_to_pptx.py`**:它默认产出**原生可编辑 DrawingML**(形状/文本/渐变都能在 PowerPoint 里选中改),纯 Python、不依赖任何外部渲染器,任何环境都跑得起来——**不存在"依赖缺失所以要换个导出办法"的情形**。它报错时:读报错原文 → 按指引回对应阶段修 → 重跑;修不动就把报错原文如实报给用户并停下。交付物的每一页都必须保有原生文本/形状;**平台会对产出的 .pptx 自动机检,无原生文本的 deck 会被判废打回**,别的产出路径不会更快,只会白做。
|
- 🛑 **导出唯一入口 = 官方 `svg_to_pptx.py`**:它默认产出**原生可编辑 DrawingML**(形状/文本/渐变都能在 PowerPoint 里选中改),纯 Python、不依赖任何外部渲染器,任何环境都跑得起来——**不存在"依赖缺失所以要换个导出办法"的情形**。它报错时:读报错原文 → 按指引回对应阶段修 → 重跑;修不动就把报错原文如实报给用户并停下。交付物的每一页都必须保有原生文本/形状;**平台会对产出的 .pptx 自动机检,无原生文本的 deck 会被判废打回**,别的产出路径不会更快,只会白做。
|
||||||
- ❌ 别用 `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 再走阶段五返工回路 + 6.2,**不要直接 edit 成品 .pptx**。
|
- 改稿:只改 `spec_lock.md` 的颜色/字体 → `update_spec.py <project_dir>` 传播到所有 SVG(所有页源都变了 → **重跑阶段四质检 + finalize**);改版式/内容 → 重写对应页 SVG 再走质检 + finalize + 6.2,**不要直接 edit 成品 .pptx**。
|
||||||
|
|
||||||
完成后:用 `update_spec` / 重写页迭代;用户确认**实质改动**后追加一行到 `REVISIONS.md`。
|
完成后:用 `update_spec` / 重写页迭代;用户确认**实质改动**后追加一行到 `REVISIONS.md`。
|
||||||
|
|
||||||
|
|
@ -210,7 +195,7 @@ references/visual-styles/<locked-style>.md # 锁定的视觉风格
|
||||||
- 用户没给材料就硬编内容(没材料只给主题 → 先补素材/反问,别凭空发挥)
|
- 用户没给材料就硬编内容(没材料只给主题 → 先补素材/反问,别凭空发挥)
|
||||||
- 八条没对齐、没产出 spec_lock 就开始写 SVG
|
- 八条没对齐、没产出 spec_lock 就开始写 SVG
|
||||||
- **写脚本批量生成 SVG**(破坏跨页一致性,禁;逐页手写)
|
- **写脚本批量生成 SVG**(破坏跨页一致性,禁;逐页手写)
|
||||||
- **不经官方 `svg_to_pptx.py` 产出 .pptx**(无论用什么办法)—— 导出/后处理/质检/验收**只走 §16 资源里那几个官方脚本**,缺一步就补一步,别另起平行流程;官方导出器纯 Python、任何环境可跑,它报错就修或如实上报。平台对成品 .pptx 有机检,非原生产物直接判废
|
- **不经官方 `svg_to_pptx.py` 产出 .pptx**(无论用什么办法)—— 导出/后处理/质检**只走 §16 资源里那几个官方脚本**,缺一步就补一步,别另起平行流程;官方导出器纯 Python、任何环境可跑,它报错就修或如实上报。平台对成品 .pptx 有机检,非原生产物直接判废
|
||||||
- **执行时不每页重读 spec_lock**(长 deck 必漂色/漂字号)
|
- **执行时不每页重读 spec_lock**(长 deck 必漂色/漂字号)
|
||||||
- **同 deck 混用多个图标库** / 用 inventory 外的图标名
|
- **同 deck 混用多个图标库** / 用 inventory 外的图标名
|
||||||
- 用了 `<style>`/`class`/`<mask>`/`<symbol>+<use>`/`@font-face`/`rgba()`/HTML 命名实体 等 **shared-standards 禁用特性**(导出会丢元素或报错)
|
- 用了 `<style>`/`class`/`<mask>`/`<symbol>+<use>`/`@font-face`/`rgba()`/HTML 命名实体 等 **shared-standards 禁用特性**(导出会丢元素或报错)
|
||||||
|
|
@ -218,8 +203,8 @@ references/visual-styles/<locked-style>.md # 锁定的视觉风格
|
||||||
- **breathing 页堆多卡网格**(违节奏,显 AI 味)
|
- **breathing 页堆多卡网格**(违节奏,显 AI 味)
|
||||||
- 模板照搬不重上皮(直接用模板默认渐变/阴影/字号)
|
- 模板照搬不重上皮(直接用模板默认渐变/阴影/字号)
|
||||||
- 质检没过就交付 / 直接 edit 成品 .pptx 改稿
|
- 质检没过就交付 / 直接 edit 成品 .pptx 改稿
|
||||||
- **只渲/只看几页就收尾**(错位藏在没看的页里);**看到差评却不返工**(封面 vision 说"半成品/挤左侧"还继续导出交付);**没看 PNG 就 `accept_pages --pass-all`**(把验收门当橡皮图章 —— gate 只能强制"渲过、源没改",看没看只有你自己知道,糊弄的结果就是错位 deck 交到用户手上)
|
- **质检报 error 却不修就往下走**(改别的页糊弄 / 试图绕导出门 —— 导出边界复跑同一套检查,绕不过去,只会白做)
|
||||||
- **质检/渲图后为消警告写脚本批量盲插元素**(regex 批量加图标、改坐标,改完不复看渲染 —— 真实事故:25 页 deck 图标全压在文字上交付)
|
- **质检后为消警告/错误写脚本批量盲插元素**(regex 批量加图标、改坐标 —— 真实事故:25 页 deck 图标全压在文字上交付。逐页手改,改完重跑质检)
|
||||||
- **用 `| head` 截断质检或导出输出**(吞非零退出码 + 截掉最后的门结论,门形同虚设)
|
- **用 `| head` 截断质检或导出输出**(吞非零退出码 + 截掉最后的门结论,门形同虚设)
|
||||||
- 起名 `output.pptx` —— 按主题命名
|
- 起名 `output.pptx` —— 按主题命名
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
"""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())
|
|
||||||
|
|
@ -101,7 +101,7 @@ def svg_final_dir(project_path) -> Path:
|
||||||
|
|
||||||
|
|
||||||
def preview_dir(project_path) -> Path:
|
def preview_dir(project_path) -> Path:
|
||||||
"""svg_preview PNGs for visual acceptance."""
|
"""svg_preview PNGs (optional visual preview)."""
|
||||||
return build_dir(project_path) / "preview"
|
return build_dir(project_path) / "preview"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,29 @@
|
||||||
"""svg_preview.py: 用无头 Chrome/Edge 把 SVG 页渲成 PNG,供交付前肉眼/vision 验收。
|
"""svg_preview.py: 把 SVG 页渲成 PNG 的可选预览工具(chromium 优先,cairosvg 兜底)。
|
||||||
|
|
||||||
SVG-first 管线里 SVG 就是视觉真相(导出的 pptx 与之 1:1),所以验收直接渲 SVG 最忠实 ——
|
SVG-first 管线里 SVG 就是视觉真相(导出的 pptx 与之 1:1),想直观看一眼版面时
|
||||||
比渲最终 pptx 更早、更准地暴露"标题层级 / 卡片过挤过空 / 文字掉色 / 节奏单调"这类观感问题。
|
渲 SVG 最忠实 —— 比渲最终 pptx 更早、更准地暴露观感问题。
|
||||||
|
|
||||||
|
注意:本脚本是**可选工具**,不再是交付管线的强制环节。页面正确性(越界 /
|
||||||
|
压字 / 错位 / 网格漂移)由 svg_quality_checker.py 的几何检测保证,并在导出
|
||||||
|
边界自动复跑 —— 交付不依赖渲图,渲图只服务"人想看效果"。
|
||||||
|
|
||||||
用法:
|
用法:
|
||||||
python svg_preview.py <project_dir> # 渲 <project_dir>/svg_output 全部页
|
python svg_preview.py <project_dir> # 渲 <project_dir> 全部页
|
||||||
python svg_preview.py <project_dir> --pages 1,3,5 # 只渲第 1/3/5 页(按文件排序)
|
python svg_preview.py <project_dir> --pages 1,3,5 # 只渲第 1/3/5 页(按文件排序)
|
||||||
python svg_preview.py <project_dir> -o <out_dir> # 指定 PNG 输出目录(默认 <project_dir>/preview)
|
python svg_preview.py <project_dir> -o <out_dir> # 指定 PNG 输出目录(默认 .build/preview)
|
||||||
python svg_preview.py <svg_dir_or_file> # 直接渲某个目录/单文件
|
python svg_preview.py <svg_dir_or_file> # 直接渲某个目录/单文件
|
||||||
|
|
||||||
约定:优先渲 <project_dir>/svg_output;没有则退而渲 <project_dir> 本身。
|
约定:优先渲 <project_dir>/.build/svg_final(图标/配图已内嵌);没有则渲
|
||||||
依赖:本机装了 Chrome 或 Edge(无需 pip 包)。两者都没有则报错退出。
|
svg_output(图标占位符会由 cairosvg 路径就地展开)。
|
||||||
产物默认 2x 超采样,够清晰看版面。
|
渲染器:找得到 Chrome/Edge/Chromium 就用无头浏览器(保真度最高);找不到、
|
||||||
|
或浏览器对某页渲染失败,自动回退 cairosvg(装了的话)。产物默认 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")
|
||||||
|
|
@ -79,6 +76,15 @@ def find_browser() -> str | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_cairosvg():
|
||||||
|
"""Return the cairosvg module, or None if not importable."""
|
||||||
|
try:
|
||||||
|
import cairosvg
|
||||||
|
return cairosvg
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
_VIEWBOX_RE = re.compile(r'viewBox\s*=\s*["\']\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*["\']')
|
_VIEWBOX_RE = re.compile(r'viewBox\s*=\s*["\']\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*["\']')
|
||||||
_WH_RE = re.compile(r'\b(width|height)\s*=\s*["\']\s*([\d.]+)')
|
_WH_RE = re.compile(r'\b(width|height)\s*=\s*["\']\s*([\d.]+)')
|
||||||
|
|
||||||
|
|
@ -148,111 +154,27 @@ def render(browser: str, svg_path: Path, out_png: Path, scale: float = 2.0) -> b
|
||||||
if out_png.exists() and out_png.stat().st_size > 0:
|
if out_png.exists() and out_png.stat().st_size > 0:
|
||||||
return True
|
return True
|
||||||
tail = (proc.stderr or b"").decode("utf-8", "replace").strip()[-400:]
|
tail = (proc.stderr or b"").decode("utf-8", "replace").strip()[-400:]
|
||||||
print(f" [warn] 渲染失败 {svg_path.name}(rc={proc.returncode})"
|
print(f" [warn] chromium 渲染失败 {svg_path.name}(rc={proc.returncode})"
|
||||||
+ (f": {tail}" if tail else ""))
|
+ (f": {tail}" if tail else ""))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _collect(target: Path) -> tuple[list[Path], Path, Path | None, str]:
|
def _collect(target: Path) -> tuple[list[Path], Path]:
|
||||||
"""返回 (svg 文件列表, 默认输出目录, project_root 或 None, 渲染来源标签)。
|
"""返回 (svg 文件列表, 默认输出目录)。"""
|
||||||
|
|
||||||
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), None, "file"
|
return [target], preview_dir(target.parent)
|
||||||
# 目录:优先 .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, source = sf, "svg_final"
|
svg_dir = sf
|
||||||
elif (target / "svg_output").is_dir():
|
elif (target / "svg_output").is_dir():
|
||||||
svg_dir, source = target / "svg_output", "svg_output"
|
svg_dir = target / "svg_output"
|
||||||
else:
|
else:
|
||||||
svg_dir, source = target, "dir"
|
svg_dir = target
|
||||||
files = sorted(svg_dir.glob("*.svg"))
|
return sorted(svg_dir.glob("*.svg")), 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]:
|
||||||
|
|
@ -302,7 +224,8 @@ def render_cairosvg(cairosvg, svg_path: Path, out_png: Path, scale: float = 2.0)
|
||||||
|
|
||||||
Icons are pre-expanded so cairosvg doesn't choke on `<use data-icon>`.
|
Icons are pre-expanded so cairosvg doesn't choke on `<use data-icon>`.
|
||||||
Fidelity is slightly below chromium (no JS, limited filter support), but it
|
Fidelity is slightly below chromium (no JS, limited filter support), but it
|
||||||
lets visual acceptance run in a sandbox image built without chromium.
|
lets preview run in a sandbox image built without chromium — or one whose
|
||||||
|
chromium crashes at runtime.
|
||||||
"""
|
"""
|
||||||
svg_text = _expand_icons_for_cairo(
|
svg_text = _expand_icons_for_cairo(
|
||||||
svg_path.read_text(encoding="utf-8", errors="ignore"))
|
svg_path.read_text(encoding="utf-8", errors="ignore"))
|
||||||
|
|
@ -318,14 +241,14 @@ def render_cairosvg(cairosvg, svg_path: Path, out_png: Path, scale: float = 2.0)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
ap = argparse.ArgumentParser(description="把 SVG 页渲成 PNG 供肉眼/vision 验收")
|
ap = argparse.ArgumentParser(description="把 SVG 页渲成 PNG(可选预览工具)")
|
||||||
ap.add_argument("target", type=Path, help="project_dir / svg 目录 / 单个 .svg 文件")
|
ap.add_argument("target", type=Path, help="project_dir / svg 目录 / 单个 .svg 文件")
|
||||||
ap.add_argument("--pages", default=None, help="只渲指定页,如 1,3,5(按文件排序)")
|
ap.add_argument("--pages", default=None, help="只渲指定页,如 1,3,5(按文件排序)")
|
||||||
ap.add_argument("-o", "--out", type=Path, default=None, help="PNG 输出目录")
|
ap.add_argument("-o", "--out", type=Path, default=None, help="PNG 输出目录")
|
||||||
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, project_root, source = _collect(args.target)
|
files, default_out = _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)
|
all_count = len(files)
|
||||||
|
|
@ -336,40 +259,43 @@ def main() -> None:
|
||||||
out_dir = args.out or default_out
|
out_dir = args.out or default_out
|
||||||
|
|
||||||
# Renderer: prefer a real browser (chromium — highest fidelity, matches the
|
# Renderer: prefer a real browser (chromium — highest fidelity, matches the
|
||||||
# native pptx). Fall back to cairosvg when no browser exists (sandbox image
|
# native pptx); cairosvg covers both "no browser at all" and "browser found
|
||||||
# built without the chromium layer) so visual acceptance still runs.
|
# but crashes at render time" (seen with the sandbox chromium) — a found
|
||||||
|
# browser that can't render must not be a dead end.
|
||||||
browser = find_browser()
|
browser = find_browser()
|
||||||
cairo = None
|
cairo = find_cairosvg()
|
||||||
if browser:
|
if browser:
|
||||||
print(f"[svg_preview] browser={browser}")
|
print(f"[svg_preview] browser={browser}"
|
||||||
|
+ ("" if cairo else " (无 cairosvg 兜底)"))
|
||||||
|
elif cairo:
|
||||||
|
print("[svg_preview] 未找到 chromium → 用 cairosvg 渲染(图标已预展开;"
|
||||||
|
"保真度略低于 chromium。装 chromium 或设 CHROMIUM 可用浏览器渲染)")
|
||||||
else:
|
else:
|
||||||
try:
|
raise SystemExit(
|
||||||
import cairosvg
|
"[fatal] 未找到 Chrome / Edge / Chromium,也无 cairosvg。这是环境/镜像"
|
||||||
cairo = cairosvg
|
"问题:沙箱镜像应自带 /usr/bin/chromium(需 rebuild sandbox 镜像),"
|
||||||
print("[svg_preview] 未找到 chromium → 回退 cairosvg(图标已预展开;"
|
"本机可设 CHROMIUM 环境变量指向浏览器。渲图只是可选预览,不阻塞"
|
||||||
"保真度略低于 chromium。装 chromium 或设 CHROMIUM 可用浏览器渲染)")
|
"质检与导出 —— 请把本报错如实告知用户,不要在会话内安装渲染依赖。")
|
||||||
except Exception:
|
|
||||||
raise SystemExit(
|
|
||||||
"[fatal] 未找到 Chrome / Edge / Chromium,也无 cairosvg 兜底。"
|
|
||||||
"这是环境/镜像问题:沙箱镜像应自带 /usr/bin/chromium(需 rebuild "
|
|
||||||
"sandbox 镜像),本机可设 CHROMIUM 环境变量指向浏览器。"
|
|
||||||
"请把本报错如实告知用户,不要在会话内安装渲染依赖或另寻渲染方式。")
|
|
||||||
|
|
||||||
done: list[tuple[Path, Path]] = []
|
ok = 0
|
||||||
|
fell_back = 0
|
||||||
for svg in files:
|
for svg in files:
|
||||||
png = out_dir / (svg.stem + ".png")
|
png = out_dir / (svg.stem + ".png")
|
||||||
if browser:
|
done = render(browser, svg, png, scale=args.scale) if browser else False
|
||||||
render(browser, svg, png, scale=args.scale)
|
if not done and cairo:
|
||||||
else:
|
if browser:
|
||||||
render_cairosvg(cairo, svg, png, scale=args.scale)
|
fell_back += 1
|
||||||
if png.exists():
|
done = render_cairosvg(cairo, svg, png, scale=args.scale)
|
||||||
done.append((svg, png))
|
if done:
|
||||||
|
ok += 1
|
||||||
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}")
|
if fell_back:
|
||||||
if project_root is not None and done:
|
print(f"[svg_preview] {fell_back} 页浏览器渲染失败,已回退 cairosvg")
|
||||||
record_renders(project_root, source, done)
|
print(f"[svg_preview] {ok}/{len(files)} 页渲好,输出目录:{out_dir}")
|
||||||
|
if ok < len(files):
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -1792,7 +1792,7 @@ class SVGQualityChecker:
|
||||||
# (quadrant captions, word clouds, tightly-kerned
|
# (quadrant captions, word clouds, tightly-kerned
|
||||||
# numeral+suffix pairs all overlap estimated boxes
|
# numeral+suffix pairs all overlap estimated boxes
|
||||||
# legitimately). The warning carries exact coordinates so the
|
# legitimately). The warning carries exact coordinates so the
|
||||||
# render-acceptance pass knows which spot to eyeball.
|
# author knows which spot to double-check in the source.
|
||||||
# EXCEPTION — CJK-on-CJK deep overlap is an ERROR: ideograph
|
# EXCEPTION — CJK-on-CJK deep overlap is an ERROR: ideograph
|
||||||
# advance is a fixed 1.0em, so for runs that are mostly CJK
|
# advance is a fixed 1.0em, so for runs that are mostly CJK
|
||||||
# the estimate is near-exact and a >=50% mutual overlap is a
|
# the estimate is near-exact and a >=50% mutual overlap is a
|
||||||
|
|
@ -1809,7 +1809,7 @@ class SVGQualityChecker:
|
||||||
f"text \"{a['label']}\" and \"{b['label']}\" overlap "
|
f"text \"{a['label']}\" and \"{b['label']}\" overlap "
|
||||||
f"(~{ratio * 100:.0f}% of the smaller run, around "
|
f"(~{ratio * 100:.0f}% of the smaller run, around "
|
||||||
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})) "
|
||||||
f"— eyeball this spot at render acceptance")
|
f"— double-check this spot in the source SVG")
|
||||||
|
|
||||||
# 3. Icon-on-text collisions. Icon geometry is exact; the text estimate
|
# 3. Icon-on-text collisions. Icon geometry is exact; the text estimate
|
||||||
# only inflates the right edge, so a large covered fraction of the
|
# only inflates the right edge, so a large covered fraction of the
|
||||||
|
|
@ -1865,7 +1865,7 @@ class SVGQualityChecker:
|
||||||
f"text \"{t['label']}\" straddles a card/band edge at "
|
f"text \"{t['label']}\" straddles a card/band edge at "
|
||||||
f"({r['x0']:.0f},{r['y0']:.0f})-({r['x1']:.0f},{r['y1']:.0f}) "
|
f"({r['x0']:.0f},{r['y0']:.0f})-({r['x1']:.0f},{r['y1']:.0f}) "
|
||||||
f"(~{ratio * 100:.0f}% inside) — likely clipped by the band "
|
f"(~{ratio * 100:.0f}% inside) — likely clipped by the band "
|
||||||
f"or poking out of the card; eyeball at render acceptance")
|
f"or poking out of the card; double-check the coordinates")
|
||||||
break # one report per text run is enough
|
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'])):
|
||||||
|
|
|
||||||
|
|
@ -156,81 +156,6 @@ def _quality_errors(project_path: Path) -> list[str]:
|
||||||
return []
|
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).
|
|
||||||
|
|
||||||
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 ``(hard, waivable)`` problem lists; both empty means the gate
|
|
||||||
passes or does not apply (no spec_lock.md — bare/ad-hoc conversions stay
|
|
||||||
unblocked). ``hard`` problems (never rendered / record unreadable / source
|
|
||||||
edited after render / pre-finalize render) block even under
|
|
||||||
``--allow-unreviewed`` — rendering is cheap and machine-checkable, so
|
|
||||||
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
|
|
||||||
and numeral-on-caption collisions because the acceptance stage was skipped
|
|
||||||
outright; the re-run then skipped rendering again by reaching straight for
|
|
||||||
--allow-unreviewed eight seconds after the gate fired. 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
|
|
||||||
hard: list[str] = []
|
|
||||||
waivable: list[str] = []
|
|
||||||
for svg in svg_files:
|
|
||||||
stem = svg.stem
|
|
||||||
entry = pages.get(stem)
|
|
||||||
if not isinstance(entry, dict):
|
|
||||||
hard.append(f"{stem}: never rendered / reviewed")
|
|
||||||
continue
|
|
||||||
if entry.get('rendered_from') == 'svg_output':
|
|
||||||
hard.append(
|
|
||||||
f"{stem}: rendered pre-finalize (icons/images not embedded, "
|
|
||||||
f"the PNG shows the wrong page) — 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'):
|
|
||||||
hard.append(f"{stem}: source edited AFTER the last render — "
|
|
||||||
f"re-render and re-review this page")
|
|
||||||
continue
|
|
||||||
verdict = entry.get('verdict')
|
|
||||||
if verdict != 'pass':
|
|
||||||
waivable.append(f"{stem}: verdict is '{verdict or 'pending'}', not 'pass'")
|
|
||||||
return hard, waivable
|
|
||||||
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 = (
|
||||||
|
|
@ -333,12 +258,6 @@ 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)')
|
||||||
|
|
@ -534,68 +453,11 @@ Recorded narration:
|
||||||
" Fix the listed pages in svg_output/ (run svg_quality_checker.py "
|
" Fix the listed pages in svg_output/ (run svg_quality_checker.py "
|
||||||
"<project_dir>\n"
|
"<project_dir>\n"
|
||||||
" for the full report incl. warnings), then re-run finalize + "
|
" for the full report incl. warnings), then re-run finalize + "
|
||||||
"preview + export.",
|
"export.",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
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. Hard problems (never rendered / stale
|
|
||||||
# render) block even under --allow-unreviewed; only un-passed verdicts
|
|
||||||
# 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:
|
|
||||||
print(
|
|
||||||
f"[WARN] {len(waivable_problems)} page(s) rendered but not marked "
|
|
||||||
"pass — exporting anyway (--allow-unreviewed).",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
print(
|
|
||||||
f"[ERROR] {len(waivable_problems)} page(s) rendered but not yet "
|
|
||||||
"reviewed/passed — refusing to export:",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
for p in waivable_problems[:20]:
|
|
||||||
print(f" - {p}", file=sys.stderr)
|
|
||||||
if len(waivable_problems) > 20:
|
|
||||||
print(f" ... and {len(waivable_problems) - 20} more",
|
|
||||||
file=sys.stderr)
|
|
||||||
print(
|
|
||||||
" Look at every PNG under .build/preview/, then mark verdicts "
|
|
||||||
"with\n"
|
|
||||||
" accept_pages.py --pass ... (or --pass-all after a full "
|
|
||||||
"review).",
|
|
||||||
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