diff --git a/PROGRESS.md b/PROGRESS.md index 866700f..e0cefa8 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -21,6 +21,9 @@ ## 已完成关键能力 +### 2026-07-03 / ppt 对齐网格锁 + 错位/单调质检(d1285247 陶瓷 deck 复盘,bump 0.37.0) +对 d1285247 产物(25 页陶瓷方案 PPTX)逐页几何量测 + PowerPoint COM 渲图目视复盘,三类缺陷:①跨页左基线漂移(0.656–0.75in 七个值)+ 并排块顶差 2–12px 的"想对齐没对齐"(S8/S19/S23);②5 页同为"图标+标题+三行字"卡网格,零流程箭头/零分层图形,单调;③标题语义不兑现("五层架构"画成五条等宽横条、"矩阵"画成卡片格)。根因:executor 手写绝对坐标但 spec_lock 无网格常量可依;质检只查重叠/越界不查对齐;"节奏不雷同"只约束相邻页。修四层:**A spec_lock 新增 `layout_grid` 锁段**(margin_x/content_top/footer_y/gutter,strategist 派生、executor 每页吸附、checker 强制;design_spec_reference §V 同步);**B executor-base §3 网格对齐纪律**(并排卡片同 top 同高等 gutter、打破网格 ≥16px 干净打破、同行文字 ≥0.3em 禁贴字);**C svg_quality_checker 新增 check 14**——兄弟卡片近失对齐(精确几何,2–12px error;底对齐/中心对齐/绘图区内数据柱三类豁免,71 charts 模板回归误报清零)、layout_grid 偏离 2–15px error、行内 gap 不等 warning、无锁存量项目跨页左缘聚类漂移 warning、版式指纹单调门(≥3 页同指纹 warn、≥4 或过半 error;仅对 NN_ 编号 deck 页聚合,模板库静默);**D 策略纪律升级**——同一版式原型整本 ≤2 次 + 标题语义必须被图形兑现(SKILL.md 大纲纪律 + strategist visual-floor GATE)。顺手修 comparison_columns 模板胶囊 5px 错位。新增 tests/test_svg_alignment_check.py 21 项,全量 153 过。已知边界:页面平衡类(底部大空白/重心偏移,S18/S22)误报风险高未进 checker,只进阶段五验收 checklist 眼看;错位 error 会被导出边界自动质检门连带拦截,存量项目重导出若报新 error 属预期(真缺陷)。 + ### 2026-07-03 / 进度条自愈:回放层强制单调完成(d1285247 复盘,bump 0.36.2) 用户报 task d1285247(ppt生成3)进度条反常:后面步(质检/导出)打绿勾、前面步(摄取素材/配图)却卡红圈"…",顶部"4/6"。诊断脚本 `scripts/diag_progress_d1285247.py` 拉出 `task_progress` 调用序列定位**非渲染 bug**——`progress.js` 忠实回放了模型发的调用:模型每次推进是"标下一步 completed + 再下一步 in_progress"的跳步,**每次都漏给上一次留在 in_progress 的那步补 completed**(s1、s3 被漏),回放到最后就是 `s1=in_progress,s2=completed,s3=in_progress,s4/s5/s6=completed`。根因是模型用工具收尾不稳,纯提示拦不住(与门体系教训同构)。修在**回放层加确定性单调不变量**:`enforceMonotonicProgress`——checklist 线性推进,只要某步 completed,其之前所有步自动视为 completed;`applyProgressAction` 的 set_plan / update_step 两条出口都过一遍,漏发自愈。前端单测加 3 条(含复刻 d1285247 跳步序列 → 6/6)。已知边界:假设步骤线性顺序(现有所有 skill 成立);若将来出现真·并行/乱序 checklist 会被抹平。 diff --git a/core/__init__.py b/core/__init__.py index b698ffa..d7c1864 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.36.2" +__version__ = "0.37.0" diff --git a/skills/ppt/SKILL.md b/skills/ppt/SKILL.md index ad85cad..ae9ee09 100644 --- a/skills/ppt/SKILL.md +++ b/skills/ppt/SKILL.md @@ -100,14 +100,14 @@ description: 生成 PowerPoint 演示文稿 (.pptx) 文件。✅ 触发:用户 **逐页大纲**(写进 design_spec.md §IX,也是 spec_lock 的 page_rhythm/page_layouts 依据):**论断式标题 + 每页标节奏**(`anchor`/`dense`/`breathing`)。三条硬纪律(大纲阶段定死): - **论断标题**:写结论不写主题("渗透率破 60%" 不是 "行业背景"); -- **节奏不雷同**:相邻内容页不同版式;narrative 真正停顿处插 `breathing`(单概念/金句/大图,**禁多卡网格**);不要为凑节奏造填充页; -- **内容→版式映射(必须落到 spec,不能整本留空)**:历程→时间轴、循环→闭环、2-4 数字→KPI、并列→网格、单震撼数字→breathing 大字、≥3 数据点→图表(charts/ 模板或自绘);对比→象限/分栏、流程→process_flow、占比→donut、架构→分层、关系→hub_spoke。每个能结构化的内容页都要在 spec_lock 的 `page_charts`/`page_layouts` 落一个视觉处理 —— **内容 deck 不许 page_charts + page_layouts 同时空着**(=啥图都没分配,执行层必堆文字方块)。视觉下限见 strategist.md「GATE — visual floor」;质检会硬卡"全是文字方块"的扁平 deck(见阶段四)。 +- **节奏不雷同(整本 ≤2 次)**:相邻内容页不同版式,且**同一版式原型全 deck 最多 2 页**(图标卡网格 / 全宽横条列表尤其 —— 5 页"2×3 图标卡"哪怕文案不同也读作同一张片重复,真实翻车过);第 3 页起换形态(时间轴/分层/象限/流程/hub-spoke/图表)。narrative 真正停顿处插 `breathing`(单概念/金句/大图,**禁多卡网格**);不要为凑节奏造填充页; +- **内容→版式映射(必须落到 spec,不能整本留空)**:历程→时间轴、循环→闭环、2-4 数字→KPI、并列→网格、单震撼数字→breathing 大字、≥3 数据点→图表(charts/ 模板或自绘);对比→象限/分栏、流程→process_flow、占比→donut、架构→分层、关系→hub_spoke。**标题语义必须被图形兑现**:标题写"架构"就画层块堆叠(不是等宽横条列表)、写"矩阵"就画真象限(不是卡片网格)、写"流程/层级"就有方向/层次 —— "五层架构"画成五条一样的横条是典型名不副实。每个能结构化的内容页都要在 spec_lock 的 `page_charts`/`page_layouts` 落一个视觉处理 —— **内容 deck 不许 page_charts + page_layouts 同时空着**(=啥图都没分配,执行层必堆文字方块)。视觉下限见 strategist.md「GATE — visual floor」;质检会硬卡"全是文字方块"的扁平 deck(见阶段四)。 大纲连同 a–h **一起给用户预览,⛔ BLOCKING 等确认整份结构**后再进阶段二(改文字比改 slide 便宜)。 **确认后产出两份引擎契约**(按骨架填,**只填实际用到的行**): - `/design_spec.md` —— 人读叙事(I–XI 节,见 design_spec_reference.md) -- `/spec_lock.md` —— 机读执行锁(canvas/mode/visual_style/colors/typography/icons/images/page_rhythm/page_layouts/page_charts/forbidden,见 spec_lock_reference.md)。**executor 每页重读它**,是长 deck 抗漂移的命门。 +- `/spec_lock.md` —— 机读执行锁(canvas/**layout_grid**/mode/visual_style/colors/typography/icons/images/page_rhythm/page_layouts/page_charts/forbidden,见 spec_lock_reference.md)。**executor 每页重读它**,是长 deck 抗漂移的命门。`layout_grid`(margin_x/content_top/footer_y/gutter)是跨页对齐的锚 —— 手写绝对坐标没有锁定基线必漂,质检会硬卡偏离网格 2–15px 的"想对齐没对齐"。 > 公式策略 mixed/render-all 且有公式 → 写 `images/formula_manifest.json` 后渲染(ppt-master 的 latex_render 未搬;zcbot 可用现有公式渲染或转图后按 `images` 行登记)。 @@ -130,7 +130,7 @@ references/visual-styles/.md # 锁定的视觉风格 **纪律(来自 SKILL 全局 + executor-base,务必遵守)**: 1. **逐页串行手写,不批量、不脚本生成**:每页由当前主 agent 在同一上下文里手写 SVG;**禁止写循环脚本批量产 SVG**(跨页视觉一致性靠逐页带上游上下文,生成器做不到),也不要 5 页一组。 -2. **每页前重读 `spec_lock.md`**:颜色/字体/图标/图片只能来自它;查本页 `page_rhythm`/`page_layouts`/`page_charts`。抗上下文压缩漂移。 +2. **每页前重读 `spec_lock.md`**:颜色/字体/图标/图片只能来自它;查本页 `page_rhythm`/`page_layouts`/`page_charts`;坐标吸附 `layout_grid`(左缘=margin_x、正文顶=content_top、并排卡片同 top 同高等 gutter,打破网格要 ≥16px 干净地打破,不许差几 px 的"差不多" —— 对齐纪律详见 executor-base §3)。抗上下文压缩漂移。 3. **模板供结构不供皮**(非 mirror):继承几何/标签位置/编码逻辑,**重新上 visual_style + spec_lock.colors 的皮**;字号按 spec_lock 角色锁定值,不继承模板占位字号。 4. **图标(锁了就必须用,非可选装饰)**:spec_lock 有 `icons.library` + 非空 `inventory` 时,**每个内容页必须放 1–3 个 inventory 内的图标**(KPI/列表/流程/对比/特性网格版式尤其要,常一卡一图标)——自由设计没有模板可继承图标,只能逐页手写 `` 才有图标。封面/纯排版分节页/单数字·金句 breathing 页/尾页可不放。写法:``,name 必须在 inventory 内、文件在 `templates/icons//`。**质检会硬卡**:锁了 inventory 但全 deck 0 图标 → error 退非零(见阶段四)。 5. **配图**:``,croppable 用 `preserveAspectRatio="xMidYMid slice"`,`| no-crop` 行用 `meet`;意图与版式见 image-layout-*。 @@ -142,7 +142,7 @@ references/visual-styles/.md # 锁定的视觉风格 ``` .venv/Scripts/python.exe /scripts/svg_quality_checker.py ``` -- **任何 `error`(禁用特性 / viewBox 不符 / spec_lock 漂移 / **图标压在文字上、文字基线超出画布**(Geometry 检测,几何精确)/ **锁了图标 inventory 却全 deck 0 图标** / **内容 deck 全是文字方块(≥6 页且零 ``/``/``/``)** 等)必须改:回阶段三重写该页再跑**,不放过。 +- **任何 `error`(禁用特性 / viewBox 不符 / spec_lock 漂移 / **图标压在文字上、文字基线超出画布**(Geometry 检测,几何精确)/ **兄弟卡片错位 2–12px、偏离 layout_grid 网格**(Alignment 检测,几何精确)/ **锁了图标 inventory 却全 deck 0 图标** / **内容 deck 全是文字方块(≥6 页且零 ``/``/``/``)** / **≥4 页同版式指纹(单调门)** 等)必须改:回阶段三重写该页再跑**,不放过。 - `warning`(低分辨率图 / 非 PPT 安全字体等):能顺手改就改,否则知会后放行。**例外:`Geometry:` 开头的文字重叠 warning 不许无视** —— 它给了精确坐标,是"大字压说明 / 同行文字互侵"的高嫌疑点(估宽无法区分擦边与压字,所以只报 warn),阶段五渲图时**必须对着该页该坐标专门看**,压了就返工。 - 跑 `svg_output/`(不要在 finalize 后跑 —— finalize 改写 SVG 会掩盖源级违规)。 - ⚠️ **别用 `| head` / `| tail` 截断质检输出**:管道会把脚本的非零退出码换成 `head` 的 0(门形同虚设),`head` 还会截掉打在**最后**的 deck 级门结论(如零图标 `[ERROR]`)。原样跑,读完整输出、认它的退出码。 @@ -161,7 +161,7 @@ references/visual-styles/.md # 锁定的视觉风格 # (有问题的页:--fail <页名> --reason "…";只标部分页:--pass <页名…>;看状态:--status) ``` - **默认渲整本,不带 `--pages`**。抽查 3 页只能覆盖 3 页,错位/文字溢出/元素重叠恰恰藏在没看的那些页里 —— 逐页手写绝对坐标,每页都可能翻车,所以**每页都要过目**。(页数多时可分批渲,但目标是 100% 覆盖,不是采样。) -- `read` / `look_at_image` **逐页**亲眼过:标题层级、卡片过挤/过空、**文字是否溢出卡片/被裁**、**元素是否重叠错位**、图标在不在(位置对不对)、节奏是否单调、配图位置。**看完才许标 pass** —— `--pass-all` 是"每页都看过且都合格"的宣告,不是跳过看的快捷键。 +- `read` / `look_at_image` **逐页**亲眼过:标题层级、卡片过挤/过空、**文字是否溢出卡片/被裁**、**元素是否重叠错位**、**并排元素顶/底是否对齐、与上一页对比左缘/内容顶线是否一致**(跨页一致性只有连续翻看才看得出)、图标在不在(位置对不对)、节奏是否单调(连续几页同为卡片墙就该返工换形态)、配图位置。**看完才许标 pass** —— `--pass-all` 是"每页都看过且都合格"的宣告,不是跳过看的快捷键。 - 🚧 **差评即阻断 + 返工回路**:任一页有排版/溢出/重叠/半成品问题(哪怕只是封面)→ **改那一页 svg_output 的 SVG → 重跑 finalize → `svg_preview.py --pages ` 重渲该页 → 复看 → 再标 pass**。机制会强制这个回路:标 pass 和导出 gate 都校验"渲图之后源文件没再改过"(sha1),改了不重渲重看,gate 过不去。不许"看了一页差评、跳去看下一页好评就收尾"——那正是错位交付的来路。 - ❌ **禁止盲改**:修错位/补图标不许写脚本批量 regex 插元素、改完不看渲染结果(真实事故来源:质检提示缺图标后 regex 批量盲插,图标全压在文字上交付)。每处修改都要走上面的返工回路落到"复看"。 diff --git a/skills/ppt/references/executor-base.md b/skills/ppt/references/executor-base.md index 11b0bf3..e157d1c 100644 --- a/skills/ppt/references/executor-base.md +++ b/skills/ppt/references/executor-base.md @@ -177,6 +177,11 @@ Before drawing each page, look up its entry in `page_charts` to decide which cha - **Proximity**: group related elements with tight spacing; separate unrelated groups - **Spec adherence**: follow color, layout, canvas format, and typography in the spec +- **Grid & alignment discipline (HARD — checker-enforced)**: hand-written absolute coordinates drift; the fix is snapping, not eyeballing. + - **Snap to `layout_grid`**: header title, content blocks, and footer left edges sit exactly at `spec_lock.layout_grid.margin_x`; body content starts at `content_top`; footer baseline at `footer_y`. Never write a "close enough" coordinate (63 when the grid says 60) — the quality checker errors on 2–15px deviations. Breaking the grid on purpose (full-bleed, asymmetric hero) means clearing it by ≥16px, not by a few px. + - **Sibling cards align exactly**: cards in one row share the same `y` and `height`; cards in one column share the same `x`; gaps in a row all equal `layout_grid.gutter`. Compute one set of constants per grid (`x = margin_x + i * (card_w + gutter)`) instead of placing each card by feel. Deliberate stagger (masonry) offsets by ≥16px. + - **Two-column layouts resolve the bottom edge**: either both columns end at the same y, or the shorter column is deliberately closed (vertical centering, a filler visual, a closing rule) — never one column dangling far above the other with dead whitespace. + - **No glued glyphs on one line**: adjacent inline elements (arrow + number, numeral + unit, badge + label) keep ≥0.3em horizontal gap. An arrow touching a digit ("→02") reads as a typo. - **Template structure**: if templates exist, inherit the visual framework - **Main-agent ownership**: SVG generation must run in the main agent (not sub-agents) — pages share upstream context for cross-page visual continuity - **Generation rhythm**: lock global design context first, then generate pages sequentially in one continuous context. No batched groups (e.g., 5 at a time). diff --git a/skills/ppt/references/strategist.md b/skills/ppt/references/strategist.md index 492280c..8b08a59 100644 --- a/skills/ppt/references/strategist.md +++ b/skills/ppt/references/strategist.md @@ -650,6 +650,8 @@ The most common Strategist failure mode is missing the structural half — treat > - Every **content page whose shape matches a catalog Pick clause** MUST get a visual treatment — a `page_charts` entry (chart / infographic template), a `page_layouts` structural template, or an explicit §VII custom-diagram plan (`no-template-match` with the figure described). Text-in-boxes is the fallback only for pages that genuinely carry no structurable shape, and you must be able to name why. > - **`spec_lock.md` MUST NOT ship a content deck with `page_charts` empty AND `page_layouts` empty/free-design AND no §VII custom-diagram rows.** That combination means no visual was assigned anywhere — re-scan §IX and map each content page's shape to a figure (comparison→columns/quadrant, process/历程→timeline/process_flow, cycle/循环→concentric/segmented_wheel, share→donut/pie, trend→line/area, ranking→bar, architecture→layered_architecture, relations→hub_spoke/mind_map). > - Downstream enforcement: `svg_quality_checker.py` **hard-fails** any deck (≥6 text-heavy pages) whose Executor output has zero ``/``/``/`` deck-wide. Leaving the visual plan empty here guarantees that failure later — assign the figures now, at the spec stage, where it is cheapest to change. +> - **Archetype cap (anti-monotony)**: the same layout archetype — especially the icon+title+text card grid and the full-width stacked row list — may carry at most **2 pages per deck**, adjacent or not. Five "2×3 icon grid" pages read as the same slide repeated even when the copy differs (real shipped failure). When §IX assigns a third page the same shape, rework it into a different visual form via the mapping above. Downstream: the checker warns at 3 same-fingerprint pages and errors at 4. +> - **Title semantics must be honored by the layout**: a page titled 架构/分层 gets stacked layers (NOT equal-width row bars); 矩阵 gets a real 2×2 quadrant (NOT a card grid); 流程 gets a directional flow; 层级 gets a pyramid/tree. Shipping a "五层架构" page drawn as five identical list rows is the canonical semantic mismatch — the title promises a figure the page never draws. > **Reading is mandatory; the catalog is a starting point, not a copy target.** > - Fully read `templates/charts/charts_index.json` **before drafting the Eight Confirmations** — the read happens up front, not when you sit down to write Section VII. The file contains `meta` + `charts..summary` only; each `summary` is a selection rule (`"Pick for … Skip if …"`), not a description. There is **no category, quickLookup, or keyword index** — selection is done by semantically matching each page's content shape against all 71 summaries in one pass. @@ -808,7 +810,7 @@ This is what makes the axis meaningful: a `presentation` deck and a `text` deck 1. Read reference template: `templates/design_spec_reference.md` 2. Generate complete spec from scratch based on analysis 3. Save to: `projects/.../design_spec.md` -4. **Generate execution lock**: read `templates/spec_lock_reference.md` and produce `projects/.../spec_lock.md` — a distilled, machine-readable short form of the color / typography / icon / image / **page_rhythm** / **page_layouts** / **page_charts** decisions above. This file is what the Executor re-reads before every page (see [executor-base.md](executor-base.md) §2.1). The values in `spec_lock.md` MUST exactly match the decisions recorded in `design_spec.md`; if they ever diverge, `spec_lock.md` wins and `design_spec.md` should be treated as historical narrative. +4. **Generate execution lock**: read `templates/spec_lock_reference.md` and produce `projects/.../spec_lock.md` — a distilled, machine-readable short form of the color / typography / icon / image / **layout_grid** / **page_rhythm** / **page_layouts** / **page_charts** decisions above. This file is what the Executor re-reads before every page (see [executor-base.md](executor-base.md) §2.1). The values in `spec_lock.md` MUST exactly match the decisions recorded in `design_spec.md`; if they ever diverge, `spec_lock.md` wins and `design_spec.md` should be treated as historical narrative. - **page_rhythm is mandatory**: Based on the page list in §IX Content Outline, assign each page one of `anchor` / `dense` / `breathing` (see `spec_lock_reference.md` for the full vocabulary). This is what breaks the uniform "every page is a card grid" feel — without it the Executor defaults all pages to `dense`. - **Rhythm follows narrative, not quota**: `breathing` pages mark natural pauses — chapter transitions, standalone emphasis (hero quote / big number), SCQA bridges. Dense decks may legitimately be all `dense`. **Do NOT invent filler pages** ("Thank you", empty dividers) to pad rhythm — every `breathing` page must say something independent. Delivery purpose biases the overall lean (`presentation` toward more `anchor` / `breathing`, `text` toward `dense`; see §6.1) — a bias, never a quota. - **Cover impact is mandatory**: Page `P01` is the deck's first visual contract, not a generic title slide. In `design_spec.md §IX`, add a `Cover impact` line for `P01` that names one concrete hook and one concrete composition strategy. Use the source's strongest available signal: a provocative core claim, object / scene metaphor, hero number, founder / product / audience moment, or a distilled conflict. Pair it with one concrete composition strategy — such as `full-bleed image + floating title`, `typographic poster`, `hero object`, `data hook`, `editorial scene`, `high-contrast abstract geometry`, or a fresh composition the deck's subject suggests (these are starting points, not the allowed set). If no external or AI image is available, still specify a native-SVG visual hook; do not fall back to "title + subtitle + decorative background". (Beautify / template-fill keep the source cover verbatim — this rule does not apply on those preservation paths.) diff --git a/skills/ppt/scripts/svg_quality_checker.py b/skills/ppt/scripts/svg_quality_checker.py index e88be2e..f7ff637 100644 --- a/skills/ppt/scripts/svg_quality_checker.py +++ b/skills/ppt/scripts/svg_quality_checker.py @@ -243,6 +243,12 @@ class SVGQualityChecker: self._deck_graphic_total = 0 # path+polyline+polygon+image across deck self._deck_text_total = 0 # across deck (density signal) self._pages_no_graphic: List[str] = [] # pages with zero graphic primitives + # Alignment / grid / monotony aggregation (check 14). Cross-page margin + # drift and repeated layout archetypes are only visible deck-wide, so + # per-page passes record into these and print_summary aggregates. + self._grid_locked = False # any spec_lock carried layout_grid + self._page_left_edges: Dict[str, float] = {} # page -> primary content left edge + self._page_fingerprints: Dict[str, tuple] = {} # page -> layout archetype fingerprint def check_file(self, svg_file: str, expected_format: str = None) -> Dict: """ @@ -338,6 +344,12 @@ class SVGQualityChecker: if not self.template_mode: self._check_geometry(content, result) + # 14. Alignment lint: sibling-card near-miss misalignment, + # layout_grid lock enforcement, uneven row gaps, plus + # deck-level margin-drift / layout-monotony aggregation. + if not self.template_mode: + self._check_alignment(content, svg_path, result) + # Determine pass/fail result['passed'] = len(result['errors']) == 0 @@ -1079,6 +1091,10 @@ class SVGQualityChecker: return 'viewBox issues' elif 'foreignObject' in error_msg: return 'foreignObject' + elif error_msg.startswith('Alignment:'): + return 'Alignment/grid' + elif error_msg.startswith('Geometry:'): + return 'Geometry' elif 'font' in error_msg.lower(): return 'Font issues' else: @@ -1848,6 +1864,359 @@ class SVGQualityChecker: f"Geometry: ... and {len(bucket) - len(shown)} more " f"similar issue(s) on this page") + # ── Alignment / grid / monotony lint (check 14) ────────────────────── + # The shipped failures no overlap check sees: sibling cards a few px out + # of line ("meant to align, didn't"), content blocks drifting off the + # deck's margin line page by page, and the same card/icon-grid archetype + # repeated until the deck reads monotone. Rect / icon coordinates are + # exact (no width estimation), so near-miss offsets are reliable: a + # 2-12px offset between row-mates is virtually never design intent — + # deliberate stagger clears 16px — which is why those land error-tier, + # unlike the estimated text boxes above. + + _ALIGN_TOL = 2.0 # <= : aligned (authoring rounding slack) + _ALIGN_ERR = 12.0 # (tol, err] : hard misalignment → error + _ALIGN_INTENT = 16.0 # (err, intent): borderline → warning; >= intent: deliberate + _CARD_MIN_W = 60.0 # smaller rects are chips / accent bars, not cards + _CARD_MIN_H = 36.0 + _CLUSTER_TOL = 14.0 # row/col clustering tolerance (fingerprints, gaps) + _PAGE_NUM_RE = re.compile(r'^(\d{1,3})[_\-]') + + def _page_rhythm(self, svg_path: Path, lock) -> str: + """Return this page's page_rhythm tag ('' when unknown).""" + rhythm = (lock or {}).get('page_rhythm') or {} + m = self._PAGE_NUM_RE.match(svg_path.name) + if not m: + return '' + return (rhythm.get(f'P{int(m.group(1)):02d}') or '').strip().lower() + + def _cards_from_rects(self, rects, canvas_w, canvas_h): + """Card-sized visible rects, excluding full-bleed backgrounds.""" + cards = [] + for r in rects: + w, h = r['x1'] - r['x0'], r['y1'] - r['y0'] + if w < self._CARD_MIN_W or h < self._CARD_MIN_H: + continue + if w * h >= 0.85 * canvas_w * canvas_h: + continue + cards.append(r) + return cards + + @staticmethod + def _similar_size(a, b, rel=0.2, floor=8.0): + return abs(a - b) <= max(floor, rel * max(a, b)) + + @staticmethod + def _cluster_values(values, tol): + """Cluster sorted scalars; returns list of cluster-center floats.""" + centers = [] + for v in sorted(values): + if centers and v - centers[-1][-1] <= tol: + centers[-1].append(v) + else: + centers.append([v]) + return [sum(c) / len(c) for c in centers] + + def _layout_fingerprint(self, cards, icons): + """Classify the page's dominant grid archetype, or None. + + icon-grid: >=4 icons arranged in a >=2x2 grid (the icon+title+text + card pattern — usually no visible card rect, so icons carry the + structure). card-grid: >=4 similar-sized card rects in a grid, or a + >=4-row single-column stack (full-width list rows). Pages without a + dominant grid (covers, chapters, diagrams, timelines) get None and + never count toward monotony. + """ + if len(icons) >= 4: + rows = self._cluster_values([i['y0'] for i in icons], self._CLUSTER_TOL) + cols = self._cluster_values([i['x0'] for i in icons], self._CLUSTER_TOL) + if len(rows) >= 2 and len(cols) >= 2 \ + and len(rows) * len(cols) <= len(icons) + 2: + return ('icon-grid', len(rows), len(cols)) + # Consider only the largest similar-size card family on the page. + if len(cards) >= 4: + fam = max( + ([c for c in cards + if self._similar_size(c['x1'] - c['x0'], k['x1'] - k['x0']) + and self._similar_size(c['y1'] - c['y0'], k['y1'] - k['y0'])] + for k in cards), + key=len, + ) + if len(fam) >= 4: + rows = self._cluster_values([c['y0'] for c in fam], self._CLUSTER_TOL) + cols = self._cluster_values([c['x0'] for c in fam], self._CLUSTER_TOL) + if len(rows) * len(cols) <= len(fam) + 2 \ + and (min(len(rows), len(cols)) >= 2 + or (len(cols) == 1 and len(rows) >= 4)): + return ('card-grid', len(rows), len(cols)) + return None + + def _check_alignment(self, content: str, svg_path: Path, result: Dict) -> None: + """Sibling alignment + layout_grid enforcement + deck aggregation.""" + try: + root = ET.fromstring(content) + except ET.ParseError: + return + 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, rects = self._collect_geometry(root) + cards = self._cards_from_rects(rects, canvas_w, canvas_h) + + # Chart plot areas (the mandatory §3.1 marker): rects inside them are + # data marks (bars / boxes) whose offsets encode values, not layout — + # exclude them from every alignment check. + plot_areas = [ + tuple(map(float, m.groups())) + for m in re.finditer( + r'chart-plot-area:\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)', + content) + ] + if plot_areas: + cards = [ + c for c in cards + if not any(px0 <= (c['x0'] + c['x1']) / 2 <= px1 + and py0 <= (c['y0'] + c['y1']) / 2 <= py1 + for px0, py0, px1, py1 in plot_areas) + ] + + errors: List[str] = [] + warnings: List[str] = [] + + def near_equal(u, v, tol=None): + return abs(u - v) <= (self._ALIGN_TOL if tol is None else tol) + + def cross_match(u, v): + # Cross-axis dimension near-equality: true sibling cards share the + # dimension perpendicular to their run (heights in a row, widths in + # a column). Data bars / featured-emphasis cards differ more. + return abs(u - v) <= max(6.0, 0.04 * max(u, v)) + + # 1. Sibling near-miss misalignment (exact geometry → error tier). + # A pair only errs when NO alignment scheme fits: leading edges, + # centers, and trailing edges all disagree. Shared centers (tree + # nodes on an axis, symmetric emphasis growth) or shared trailing + # edges (baseline-anchored elements) mean the offset is a scheme, + # not an accident. + for i in range(len(cards)): + for j in range(i + 1, len(cards)): + a, b = cards[i], cards[j] + aw, ah = a['x1'] - a['x0'], a['y1'] - a['y0'] + bw, bh = b['x1'] - b['x0'], b['y1'] - b['y0'] + v_overlap = min(a['y1'], b['y1']) - max(a['y0'], b['y0']) + h_overlap = min(a['x1'], b['x1']) - max(a['x0'], b['x0']) + # Row-mates: share a horizontal band, horizontally disjoint. + if (cross_match(ah, bh) and v_overlap >= 0.6 * min(ah, bh) + and h_overlap <= self._ALIGN_TOL): + dy = abs(a['y0'] - b['y0']) + aligned_otherwise = ( + near_equal(a['y1'], b['y1']) + or near_equal((a['y0'] + a['y1']) / 2, + (b['y0'] + b['y1']) / 2)) + if dy <= self._ALIGN_TOL or aligned_otherwise: + if dy <= self._ALIGN_TOL and self._similar_size(aw, bw): + dh = abs(ah - bh) + if self._ALIGN_TOL < dh: + warnings.append( + f"row-mate cards at x={a['x0']:.0f} and " + f"x={b['x0']:.0f} share a top but differ " + f"{dh:.0f}px in height — equalize or make the " + f"difference deliberate (>=16px)") + elif dy <= self._ALIGN_ERR: + errors.append( + f"row-mate cards at x={a['x0']:.0f} and x={b['x0']:.0f} have " + f"tops {dy:.0f}px apart (y={a['y0']:.0f} vs {b['y0']:.0f}) — " + f"meant to align; snap to one y") + elif dy < self._ALIGN_INTENT: + warnings.append( + f"row-mate cards at x={a['x0']:.0f} and x={b['x0']:.0f} have " + f"tops {dy:.0f}px apart — deliberate stagger should clear " + f"{self._ALIGN_INTENT:.0f}px") + # Column-mates: share a vertical band, vertically disjoint. + elif (cross_match(aw, bw) and h_overlap >= 0.6 * min(aw, bw) + and v_overlap <= self._ALIGN_TOL): + dx = abs(a['x0'] - b['x0']) + aligned_otherwise = ( + near_equal(a['x1'], b['x1']) + or near_equal((a['x0'] + a['x1']) / 2, + (b['x0'] + b['x1']) / 2)) + if dx <= self._ALIGN_TOL or aligned_otherwise: + pass + elif dx <= self._ALIGN_ERR: + errors.append( + f"column-mate cards at y={a['y0']:.0f} and y={b['y0']:.0f} have " + f"left edges {dx:.0f}px apart (x={a['x0']:.0f} vs {b['x0']:.0f}) " + f"— meant to align; snap to one x") + elif dx < self._ALIGN_INTENT: + warnings.append( + f"column-mate cards at y={a['y0']:.0f} and y={b['y0']:.0f} have " + f"left edges {dx:.0f}px apart — deliberate indent should clear " + f"{self._ALIGN_INTENT:.0f}px") + + # 2. Uneven gaps in a >=3-card row. Only flag near-equal-but-not-equal + # spreads; a 2+1 grouping (gap spread comparable to the gap itself) + # is design intent, not drift. + top_groups: Dict[float, list] = {} + for c in cards: + for k in top_groups: + if abs(c['y0'] - k) <= self._CLUSTER_TOL: + top_groups[k].append(c) + break + else: + top_groups[c['y0']] = [c] + for row in top_groups.values(): + if len(row) < 3: + continue + row.sort(key=lambda r: r['x0']) + gaps = [row[k + 1]['x0'] - row[k]['x1'] for k in range(len(row) - 1)] + if any(g < -self._ALIGN_TOL for g in gaps): + continue # overlapping/nested — not a simple row + spread = max(gaps) - min(gaps) + if 4.0 < spread and spread < 0.35 * max(gaps): + warnings.append( + f"{len(row)}-card row at y={row[0]['y0']:.0f} has uneven gaps " + f"({', '.join(f'{g:.0f}' for g in gaps)}px) — equalize to one gutter") + + # 3. layout_grid lock enforcement (spec-declared baselines → error on + # near-miss deviation; clean break >=16px is allowed by contract). + lock = self._get_spec_lock(svg_path) + grid = (lock or {}).get('layout_grid') or {} + margin_x = self._f(grid.get('margin_x')) + content_top = self._f(grid.get('content_top')) + footer_y = self._f(grid.get('footer_y')) + rhythm = self._page_rhythm(svg_path, lock) + structural = rhythm == 'anchor' + if margin_x is not None: + self._grid_locked = True + if margin_x is not None and not structural: + seen = set() + for el, x0 in ([(f"card at y={c['y0']:.0f}", c['x0']) for c in cards] + + [(f"text \"{t['label']}\"", t['x0']) + for t in texts if t['exact_left']]): + dev = abs(x0 - margin_x) + key = round(x0) + if self._ALIGN_TOL < dev < self._ALIGN_INTENT and key not in seen: + seen.add(key) + errors.append( + f"{el} sits at x={x0:.0f}, {dev:.0f}px off the locked " + f"margin_x={margin_x:.0f} — snap to the grid or clear it by >=16px") + if content_top is not None and not structural: + seen = set() + for el, y0 in ([(f"card at x={c['x0']:.0f}", c['y0']) for c in cards] + + [(f"icon {i['label']}", i['y0']) for i in icons]): + dev = abs(y0 - content_top) + key = round(y0) + if self._ALIGN_TOL < dev < self._ALIGN_INTENT and key not in seen: + seen.add(key) + errors.append( + f"{el} starts at y={y0:.0f}, {dev:.0f}px off the locked " + f"content_top={content_top:.0f} — snap to the grid or clear it " + f"by >=16px") + if footer_y is not None and not structural: + for t in texts: + dev = abs(t['baseline'] - footer_y) + if self._ALIGN_TOL < dev < self._ALIGN_INTENT: + errors.append( + f"text \"{t['label']}\" baseline y={t['baseline']:.0f} is " + f"{dev:.0f}px off the locked footer_y={footer_y:.0f}") + break + + # 4. Deck aggregation: primary left edge (margin-drift fallback when no + # layout_grid is locked) + layout-archetype fingerprint (monotony). + # Only numbered deck pages (NN_*.svg) participate — a directory of + # standalone template/chart SVGs is not a deck, and aggregating + # across unrelated files produces meaningless drift/monotony noise. + if not structural and self._PAGE_NUM_RE.match(svg_path.name): + edge_candidates = ([c['x0'] for c in cards] + + [t['x0'] for t in texts if t['exact_left']]) + edge_candidates = [x for x in edge_candidates if 0 < x < 0.25 * canvas_w] + if len(texts) >= 4 and edge_candidates: + self._page_left_edges[svg_path.name] = min(edge_candidates) + fp = self._layout_fingerprint(cards, icons) + if fp: + self._page_fingerprints[svg_path.name] = fp + + for bucket, dest in ((errors, result['errors']), (warnings, result['warnings'])): + shown = bucket[:self._GEOM_MAX_REPORTS] + dest.extend(f"Alignment: {m}" for m in shown) + if len(bucket) > len(shown): + dest.append( + f"Alignment: ... and {len(bucket) - len(shown)} more " + f"similar issue(s) on this page") + + def _print_alignment_summary(self): + """Deck-level margin-drift fallback + layout-monotony gate. + + Margin drift: without a layout_grid lock there is no declared baseline, + so cluster each content page's primary left edge — several distinct + values within 16px of each other is the drift signature ("meant to be + one margin line"), warning-tier only (legacy decks must not hard-fail). + Monotony: >=3 content pages sharing one grid fingerprint → warning; + >=4 or over half the deck → error (the user-visible "every page is + the same card wall" pathology). Same-family (any dims) repetition is + an advisory nudge. Short decks (<6 pages) exempt, like the flat gate. + """ + pages = self._deck_page_count + # Margin-drift fallback (only when no layout_grid was declared). + if not self._grid_locked and len(self._page_left_edges) >= 4: + centers = self._cluster_values( + self._page_left_edges.values(), self._ALIGN_TOL) + drifting = [ + (a, b) for i, a in enumerate(centers) for b in centers[i + 1:] + if self._ALIGN_TOL < b - a < self._ALIGN_INTENT + ] + if drifting: + self.summary['warnings'] += 1 + vals = sorted({round(v) for pair in drifting for v in pair}) + pages_by_edge = ', '.join( + f"{name}@{edge:.0f}" for name, edge + in sorted(self._page_left_edges.items()) + if any(abs(edge - v) <= self._ALIGN_TOL for v in vals)) + print(f"\n[WARN] Margin drift: content left edges cluster at " + f"{vals}px across pages — meant to be one margin line. " + f"Lock layout_grid in spec_lock.md and snap pages to it.") + print(f" ({pages_by_edge})") + # Layout monotony gate. + if pages < 6 or not self._page_fingerprints: + return + by_fp: Dict[tuple, list] = defaultdict(list) + by_family: Dict[str, list] = defaultdict(list) + for name, fp in self._page_fingerprints.items(): + by_fp[fp].append(name) + by_family[fp[0]].append(name) + fp, members = max(by_fp.items(), key=lambda kv: len(kv[1])) + n = len(members) + label = f"{fp[1]}x{fp[2]} {fp[0]}" + _fix = ("Rework all but 1-2 of them into a different visual form — " + "timeline / layered architecture / quadrant / process flow / " + "hub-spoke / chart (templates/charts/) — per the content->layout " + "mapping in strategist.md; also check spec_lock page_layouts.") + if n >= 4 or (n >= 3 and n > 0.5 * pages): + self.summary['errors'] += 1 + print(f"\n[ERROR] Layout monotony: {n} pages share the same {label} " + f"archetype ({', '.join(sorted(members))}) — the deck reads as " + f"the same card wall repeated.") + print(" " + _fix) + elif n >= 3: + self.summary['warnings'] += 1 + print(f"\n[WARN] Layout monotony: {n} pages share the same {label} " + f"archetype ({', '.join(sorted(members))}) — consider reworking " + f"at least {n - 2} of them.") + print(" " + _fix) + else: + fam, fam_members = max(by_family.items(), key=lambda kv: len(kv[1])) + if len(fam_members) >= 4: + self.summary['warnings'] += 1 + print(f"\n[WARN] Layout monotony: {len(fam_members)} pages are all " + f"{fam} layouts ({', '.join(sorted(fam_members))}) — vary the " + f"visual form even if the grid dims differ.") + print(" " + _fix) + def _print_graphic_summary(self): """Deck-level flat-deck gate. @@ -1921,6 +2290,9 @@ class SVGQualityChecker: # Deck-level flat-deck gate (text-on-rectangles, no diagrams/figures). self._print_graphic_summary() + # Deck-level margin-drift fallback + layout-monotony gate. + self._print_alignment_summary() + # Fix suggestions if self.summary['errors'] > 0 or self.summary['warnings'] > 0: print(f"\n[TIP] Common fixes:") diff --git a/skills/ppt/templates/charts/comparison_columns.svg b/skills/ppt/templates/charts/comparison_columns.svg index 801b908..5cd100b 100644 --- a/skills/ppt/templates/charts/comparison_columns.svg +++ b/skills/ppt/templates/charts/comparison_columns.svg @@ -90,8 +90,8 @@ Custom Workflows Dedicated Account Manager SLA Guarantee - - + Upgrade Now diff --git a/skills/ppt/templates/design_spec_reference.md b/skills/ppt/templates/design_spec_reference.md index a360fc3..fff7fba 100644 --- a/skills/ppt/templates/design_spec_reference.md +++ b/skills/ppt/templates/design_spec_reference.md @@ -165,6 +165,7 @@ Two views on the same font decisions — fill both, keep them consistent: - **Header area**: [Height and content description] - **Content area**: [Height and content description] - **Footer area**: [Height and content description] +- **Layout grid**: [margin_x / content_top / footer_y / gutter in canvas px — the deck-wide alignment constants. Copy these verbatim into `spec_lock.md ## layout_grid`; the Executor snaps every content page to them and the quality checker errors on near-miss deviations (2–15px off). Hand-written coordinates drift across pages without this anchor.] ### Layout Pattern Library (combine or break as content demands) diff --git a/skills/ppt/templates/spec_lock_reference.md b/skills/ppt/templates/spec_lock_reference.md index 05e3790..decd374 100644 --- a/skills/ppt/templates/spec_lock_reference.md +++ b/skills/ppt/templates/spec_lock_reference.md @@ -12,7 +12,22 @@ > Strategist: fill viewBox and format for the chosen canvas. Common values: `0 0 1280 720` (PPT 16:9), `0 0 1024 768` (PPT 4:3), `0 0 1242 1660` (Xiaohongshu), `0 0 1080 1080` (WeChat Moments), `0 0 1080 1920` (Story). -## mode +## layout_grid +- margin_x: 60 +- content_top: 150 +- footer_y: 688 +- gutter: 24 + +> Deck-wide layout constants, in canvas px. Executor snaps every content page to these; `svg_quality_checker.py` hard-checks them (a content block whose left edge lands 2–15px off `margin_x` is an error — "meant to align, didn't"). This section exists because hand-written absolute coordinates drift across pages without a locked baseline — the #1 source of cross-page misalignment. +> +> - `margin_x` — left/right content margin. Header title, content blocks, and footer on content pages all share this left edge. +> - `content_top` — top edge of the body content zone on content pages (below the header band). Structural pages (cover / chapter / TOC / ending) are exempt. +> - `footer_y` — footer baseline. Omit if the deck has no footer. +> - `gutter` — gap between side-by-side sibling cards in a row/grid. One value deck-wide. +> +> Strategist derives values from canvas + visual_style (defaults above suit 1280×720; scale proportionally for other canvases — e.g. 4:3 `margin_x: 50`). A page may deliberately break the grid (full-bleed image, asymmetric breathing page) — breaking means clearing it by ≥16px or going full-bleed, never a few-px "almost". +> +> **Missing section** → legacy deck; checker falls back to cross-page clustering (warning-tier only). New decks MUST fill it. - mode: pyramid > Strategist: the deck's narrative skeleton, locked at confirmation `d` Layer 1. One of `pyramid` / `narrative` / `instructional` / `showcase` / `briefing` — see [`references/modes/_index.md`](../references/modes/_index.md). Executor reads only the locked mode's file. Deck-wide. Or the literal `custom` for a bespoke direction no preset captures (a special cadence, a multi-mode fusion, a particular posture) — user-requested or Strategist-recommended (user confirms, like every lock). Then add a sibling `- mode_behavior:` paragraph (how the argument advances, title voice, page rhythm, register) that the Executor follows in place of a preset file. One deck locks one value; don't default to `custom` when a preset fits. diff --git a/tests/test_svg_alignment_check.py b/tests/test_svg_alignment_check.py new file mode 100644 index 0000000..0797bd4 --- /dev/null +++ b/tests/test_svg_alignment_check.py @@ -0,0 +1,240 @@ +"""svg_quality_checker 对齐/网格/单调检查(check 14)的 focused tests。 + +合成 SVG 三类病灶:兄弟卡片差几 px 不齐、内容块偏离 layout_grid 锁、 +同一卡网格原型重复 —— 对应 d1285247 陶瓷 deck 复盘出的真实缺陷。 +纯几何逻辑,不依赖渲染器。 +""" +import contextlib +import io +import sys +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +SCRIPTS = Path(__file__).parent.parent / "skills" / "ppt" / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +from svg_quality_checker import SVGQualityChecker # noqa: E402 + + +def _svg(body: str) -> str: + return ('' + body + '') + + +def _card(x, y, w=200, h=120): + return (f'') + + +def _icon(x, y, name="home"): + return (f'') + + +def _text(x, y, s="标签"): + return f'{s}' + + +def _write_page(project: Path, name: str, body: str) -> Path: + out = project / "svg_output" + out.mkdir(parents=True, exist_ok=True) + p = out / name + p.write_text(_svg(body), encoding="utf-8") + return p + + +def _alignment_errors(result): + return [e for e in result["errors"] if e.startswith("Alignment:")] + + +def _alignment_warnings(result): + return [w for w in result["warnings"] if w.startswith("Alignment:")] + + +class SiblingAlignmentTests(unittest.TestCase): + def _check(self, body: str): + with TemporaryDirectory() as tmp: + page = _write_page(Path(tmp), "03_content.svg", body) + return SVGQualityChecker().check_file(str(page)) + + def test_row_mates_offset_6px_is_error(self): + r = self._check(_card(100, 200) + _card(340, 206)) + errs = _alignment_errors(r) + self.assertTrue(any("row-mate" in e for e in errs), errs) + + def test_row_mates_exact_align_passes(self): + r = self._check(_card(100, 200) + _card(340, 200)) + self.assertEqual(_alignment_errors(r), []) + self.assertEqual(_alignment_warnings(r), []) + + def test_deliberate_stagger_24px_passes(self): + r = self._check(_card(100, 200) + _card(340, 224)) + self.assertEqual(_alignment_errors(r), []) + self.assertEqual(_alignment_warnings(r), []) + + def test_column_mates_offset_5px_is_error(self): + r = self._check(_card(100, 200) + _card(105, 360)) + errs = _alignment_errors(r) + self.assertTrue(any("column-mate" in e for e in errs), errs) + + def test_row_mates_height_mismatch_warns(self): + r = self._check(_card(100, 200, h=120) + _card(340, 200, h=125)) + warns = _alignment_warnings(r) + self.assertTrue(any("height" in w for w in warns), warns) + + def test_center_aligned_pair_exempt(self): + # 树节点宽度不同但中心同轴(640):左缘差 10px 是居中方案,不是事故 + r = self._check(_card(540, 100, w=200, h=90) + _card(550, 300, w=180, h=90)) + self.assertEqual(_alignment_errors(r), []) + + def test_baseline_anchored_pair_exempt(self): + # 底对齐、顶差 5px(数据柱形态):存在对齐方案,不报 + r = self._check(_card(100, 200, h=120) + _card(340, 205, h=115)) + self.assertEqual(_alignment_errors(r), []) + + def test_plot_area_bars_excluded(self): + # 绘图区标记内的"柱子"错位不报(值编码,非版式) + body = ('' + + _card(100, 200) + _card(340, 206)) + r = self._check(body) + self.assertEqual(_alignment_errors(r), []) + + def test_uneven_row_gaps_warn(self): + # gaps 30 / 36 / 30 —— 近等不等,该报;2+1 分组的大差距不报 + body = (_card(60, 300) + _card(290, 300) + + _card(526, 300) + _card(756, 300)) + r = self._check(body) + warns = _alignment_warnings(r) + self.assertTrue(any("uneven gaps" in w for w in warns), warns) + + def test_grouped_row_large_gap_spread_passes(self): + # gaps 30 / 30 / 120 —— 明显是分组设计,不报 + body = (_card(60, 300) + _card(290, 300) + + _card(520, 300) + _card(840, 300)) + r = self._check(body) + self.assertFalse( + any("uneven gaps" in w for w in _alignment_warnings(r))) + + +class LayoutGridLockTests(unittest.TestCase): + def _check(self, body: str, lock: str, name="03_content.svg"): + with TemporaryDirectory() as tmp: + project = Path(tmp) + (project / "spec_lock.md").write_text(lock, encoding="utf-8") + page = _write_page(project, name, body) + return SVGQualityChecker().check_file(str(page)) + + LOCK = "## layout_grid\n- margin_x: 60\n- content_top: 150\n" + + def test_card_6px_off_margin_is_error(self): + r = self._check(_card(66, 150), self.LOCK) + errs = _alignment_errors(r) + self.assertTrue(any("margin_x" in e for e in errs), errs) + + def test_card_on_grid_passes(self): + r = self._check(_card(60, 150), self.LOCK) + self.assertEqual(_alignment_errors(r), []) + + def test_clean_break_over_16px_passes(self): + r = self._check(_card(100, 200), self.LOCK) + self.assertEqual(_alignment_errors(r), []) + + def test_icon_8px_off_content_top_is_error(self): + r = self._check(_icon(60, 158), self.LOCK) + errs = _alignment_errors(r) + self.assertTrue(any("content_top" in e for e in errs), errs) + + def test_anchor_rhythm_page_exempt(self): + lock = self.LOCK + "\n## page_rhythm\n- P01: anchor\n" + r = self._check(_card(66, 150), lock, name="01_cover.svg") + self.assertEqual(_alignment_errors(r), []) + + +class DeckAggregationTests(unittest.TestCase): + def _run_deck(self, pages: dict, lock: str = None): + """pages: {filename: body}; 返回 (checker, print_summary 输出)。""" + with TemporaryDirectory() as tmp: + project = Path(tmp) + if lock is not None: + (project / "spec_lock.md").write_text(lock, encoding="utf-8") + checker = SVGQualityChecker() + for name, body in sorted(pages.items()): + page = _write_page(project, name, body) + checker.check_file(str(page)) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + checker.print_summary() + return checker, buf.getvalue() + + @staticmethod + def _content_page(margin: float) -> str: + return "".join(_text(margin, 100 + 40 * i, f"第{i}行") for i in range(4)) + + def test_margin_drift_across_pages_warns(self): + pages = { + "01_a.svg": self._content_page(60), + "02_b.svg": self._content_page(63), + "03_c.svg": self._content_page(66), + "04_d.svg": self._content_page(60), + } + _, out = self._run_deck(pages) + self.assertIn("Margin drift", out) + + def test_consistent_margin_no_drift_warning(self): + pages = {f"{i:02d}_p.svg": self._content_page(60) for i in range(1, 5)} + _, out = self._run_deck(pages) + self.assertNotIn("Margin drift", out) + + def test_locked_grid_disables_drift_fallback(self): + pages = { + "01_a.svg": self._content_page(60), + "02_b.svg": self._content_page(63), + "03_c.svg": self._content_page(66), + "04_d.svg": self._content_page(60), + } + # margin 值刻意与页面无关:fallback 该关闭,页级 error 才是出口 + _, out = self._run_deck(pages, lock="## layout_grid\n- margin_x: 60\n") + self.assertNotIn("Margin drift", out) + + @staticmethod + def _icon_grid_page() -> str: + body = "" + for r in range(2): + for c in range(3): + body += _icon(100 + 420 * c, 200 + 240 * r) + return body + + @staticmethod + def _diagram_page(seed: int) -> str: + return (f'' + _text(60, 100)) + + def test_four_same_icon_grids_is_error(self): + pages = {f"{i:02d}_g.svg": self._icon_grid_page() for i in range(1, 5)} + pages["05_d.svg"] = self._diagram_page(1) + pages["06_e.svg"] = self._diagram_page(2) + checker, out = self._run_deck(pages) + self.assertIn("[ERROR] Layout monotony", out) + self.assertGreaterEqual(checker.summary["errors"], 1) + + def test_three_same_icon_grids_warns(self): + pages = {f"{i:02d}_g.svg": self._icon_grid_page() for i in range(1, 4)} + pages["04_d.svg"] = self._diagram_page(1) + pages["05_e.svg"] = self._diagram_page(2) + pages["06_f.svg"] = self._diagram_page(3) + pages["07_h.svg"] = self._diagram_page(4) + _, out = self._run_deck(pages) + self.assertIn("[WARN] Layout monotony", out) + + def test_varied_deck_no_monotony(self): + pages = {"01_g.svg": self._icon_grid_page(), + "02_g.svg": self._icon_grid_page()} + for i in range(3, 8): + pages[f"{i:02d}_d.svg"] = self._diagram_page(i) + _, out = self._run_deck(pages) + self.assertNotIn("Layout monotony", out) + + +if __name__ == "__main__": + unittest.main()