From 346930449afd7aea1183e2c6cc399017e78b9ea1 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Fri, 3 Jul 2026 13:34:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(ppt):=20=E5=8F=8D=E7=BA=AF=E6=96=87?= =?UTF-8?q?=E5=AD=97=E9=A1=B5+=E5=9B=BE=E8=A1=A8=E8=90=BD=E5=9C=B0?= =?UTF-8?q?=E7=A1=AC=E9=97=A8(7aa49195=20=E4=BA=8C=E4=BB=A3=E9=99=B6?= =?UTF-8?q?=E7=93=B7=20deck=20=E5=A4=8D=E7=9B=98,bump=200.38.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.37 网格锁生效后复评仍存两盲区:两栏裸文字页 x4(指纹看不见)、 全本零数据图表;另有内容被页脚裁掉、CJK 文字叠压两硬缺陷。修五处: - 指纹加 text-columns 原型(0 卡片+<=3 图标+<=2 图形基元+左对齐文本 聚 >=2 列),裸文字页进单调门,4 页同指纹 error - spec 指派图表落空检测:page_charts 指派了图表但该页 <3 图形基元 且 <4 卡片 -> error;executor 硬规则"不许把指派图表降级为文字" - CJK 叠压升级:两 run 均 >=70% CJK 且互叠 >=50% -> error (表意字宽 1.0em 估宽近精确,其余情形保持 warning) - layout_grid 加可选 content_bottom,正文 baseline 越过 -> error; executor 加"写页前垂直空间预算"纪律 - 策略层数据图表下限:素材含 >=3 组可比数值 -> 全本至少 1-2 页 真数据图表,零图表需在 spec 写理由 测试 +9(30 项)全过,全量 162 过;charts/decks 模板回归零新增噪音。 Co-Authored-By: Claude Fable 5 --- PROGRESS.md | 3 + core/__init__.py | 2 +- skills/ppt/SKILL.md | 4 +- skills/ppt/references/executor-base.md | 2 + skills/ppt/references/strategist.md | 3 +- skills/ppt/scripts/svg_quality_checker.py | 115 ++++++++++++++++---- skills/ppt/templates/spec_lock_reference.md | 2 + tests/test_svg_alignment_check.py | 94 ++++++++++++++++ 8 files changed, 198 insertions(+), 27 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 2aa70e6..305f092 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -21,6 +21,9 @@ ## 已完成关键能力 +### 2026-07-03 / ppt 反纯文字页+图表落地硬门(7aa49195 二代陶瓷 deck 复盘,bump 0.38.0) +0.37 网格锁上线后同题重做(task 7aa49195),对齐/标题/节奏大幅好转,但用户复评两点成立:①**两栏裸文字页 ×4**(S8/S9/S16/S21 同为"图标小标题+下划线+文字堆 ×2 栏"零图形)——该形态无卡片、仅 2 图标,0.37 的 icon-grid/card-grid 指纹完全看不见,单调门盲区;②**全本零数据图表**(素材全是数字:100万→500万条/能耗降10-20%/碳排26%),"历程"类内容也退化成文字列表。另有两硬缺陷:S18 第 5 条描述被页脚裁掉(内容超出内容区)、S19 红色大字直接叠压灰色说明文字。修:**A 指纹加 text-columns 原型**(0 卡片+≤3 图标+≤2 图形基元+左对齐文本聚 ≥2 列)堵盲区,4 页同指纹→error;**B spec 指派图表落空检测**——spec_lock page_charts 指派了图表但该页 <3 图形基元且 <4 卡片→error("图表被退化成文字"),配 executor 硬规则"不许把指派图表降级为文字/大字 KPI";**C CJK 叠压升级 error**——两 run 均 ≥70% CJK(表意字宽 1.0em 估宽近精确)且互叠 ≥50%→error(其余情形保持 warning+渲图过目);**D layout_grid 加可选 content_bottom**——非页脚文本 baseline 越过它→error(S18 类),executor 加"写页前垂直空间预算"纪律;**E 策略层数据图表下限**——素材含 ≥3 组可比数值→全本至少 1-2 页真数据图表,零图表需在 spec 写理由;两栏裸文字列表计入"原型 ≤2 次"上限。测试 +9(30 项)全过,全量 162 过;71 charts 模板 + 中汽研 deck 模板回归零新增噪音。已知边界:S19 类叠压若文字带 rotate/scale transform 仍不可测(子树跳过);数据图表下限是策略纪律,机器只能验"指派了没画",验不了"该指派没指派"。 + ### 2026-07-03 / web 直播流式文字按轮次分段(修工具刷屏时文字被推出视口,bump 0.37.2) 用户报:web 端一次 run 里工具调用多时,助手文字流式输出「一直在上方」被工具卡越推越高滚出视口,看不到。根因:直播态把整次 run(含几十轮 LLM)全塞进**一张 assistant 卡**——文字全累进顶部单块 `.body`(`ctx.acc` 反复重渲),工具 `tool_call`/`tool_result` 全 `appendChild` 到其下方;而历史态(DB reload)是**每轮 LLM 一条独立 assistant 消息**、天然按轮次穿插。两态结构不一致就是病根。修(方案 A,只动 `chat.js` live-run 路径,历史渲染不动):文字按轮次分段——`ensureTextSeg`/`closeTextSeg` 维护「当前打开的文字段」,每个可见工具/选项卡(非隐形 `task_progress`)先 `closeTextSeg` 关掉当前段(空占位段直接移除避免留「思考中」孤块、有内容段定稿去光标+高亮),之后的新文字在卡片底部另起新段。效果=`文字(轮1)→工具→结果→文字(轮2)→…`,流式文字始终在底部可见,且与历史结构一致(run 结束 reload 无跳变)。rAF 节流改为闭包捕获 seg,防工具关段后错渲。删掉 `ctx.body`/`ctx.pending` 单块模型,改 `ctx.curSeg={el,acc,pending}`;`createLiveAssistantCard`/`renderLiveRunIfVisible`/`sendMessage`/`fetchSse` 收尾同步改。 diff --git a/core/__init__.py b/core/__init__.py index f6b1171..c3fa783 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.37.2" +__version__ = "0.38.0" diff --git a/skills/ppt/SKILL.md b/skills/ppt/SKILL.md index ae9ee09..21ff6f4 100644 --- a/skills/ppt/SKILL.md +++ b/skills/ppt/SKILL.md @@ -100,7 +100,7 @@ description: 生成 PowerPoint 演示文稿 (.pptx) 文件。✅ 触发:用户 **逐页大纲**(写进 design_spec.md §IX,也是 spec_lock 的 page_rhythm/page_layouts 依据):**论断式标题 + 每页标节奏**(`anchor`/`dense`/`breathing`)。三条硬纪律(大纲阶段定死): - **论断标题**:写结论不写主题("渗透率破 60%" 不是 "行业背景"); -- **节奏不雷同(整本 ≤2 次)**:相邻内容页不同版式,且**同一版式原型全 deck 最多 2 页**(图标卡网格 / 全宽横条列表尤其 —— 5 页"2×3 图标卡"哪怕文案不同也读作同一张片重复,真实翻车过);第 3 页起换形态(时间轴/分层/象限/流程/hub-spoke/图表)。narrative 真正停顿处插 `breathing`(单概念/金句/大图,**禁多卡网格**);不要为凑节奏造填充页; +- **节奏不雷同(整本 ≤2 次)**:相邻内容页不同版式,且**同一版式原型全 deck 最多 2 页**(图标卡网格 / 全宽横条列表 / **两栏裸文字列表**(图标小标题+下划线+文字堆 ×2、零图形 —— 一次真实交付里出现了 4 页)尤其;5 页"2×3 图标卡"哪怕文案不同也读作同一张片重复,真实翻车过);第 3 页起换形态(时间轴/分层/象限/流程/hub-spoke/图表)。narrative 真正停顿处插 `breathing`(单概念/金句/大图,**禁多卡网格**);不要为凑节奏造填充页;素材含 ≥3 组可比数值(规模/占比/趋势/阶段目标)→ **全本至少 1-2 页真数据图表**(bar/line/donut/进度条),大字 KPI 是强调不算图表,零数据图表要在 spec 写明理由; - **内容→版式映射(必须落到 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 便宜)。 @@ -142,7 +142,7 @@ references/visual-styles/.md # 锁定的视觉风格 ``` .venv/Scripts/python.exe /scripts/svg_quality_checker.py ``` -- **任何 `error`(禁用特性 / viewBox 不符 / spec_lock 漂移 / **图标压在文字上、文字基线超出画布**(Geometry 检测,几何精确)/ **兄弟卡片错位 2–12px、偏离 layout_grid 网格**(Alignment 检测,几何精确)/ **锁了图标 inventory 却全 deck 0 图标** / **内容 deck 全是文字方块(≥6 页且零 ``/``/``/``)** / **≥4 页同版式指纹(单调门)** 等)必须改:回阶段三重写该页再跑**,不放过。 +- **任何 `error`(禁用特性 / viewBox 不符 / spec_lock 漂移 / **图标压在文字上、文字基线超出画布、CJK 文字互相叠压**(Geometry 检测,几何精确)/ **兄弟卡片错位 2–12px、偏离 layout_grid 网格、正文越过 content_bottom 侵入页脚区、spec 指派了 page_charts 该页却零图形(图表被退化成文字)**(Alignment 检测,几何精确)/ **锁了图标 inventory 却全 deck 0 图标** / **内容 deck 全是文字方块(≥6 页且零 ``/``/``/``)** / **≥4 页同版式指纹(单调门,含两栏裸文字列表)** 等)必须改:回阶段三重写该页再跑**,不放过。 - `warning`(低分辨率图 / 非 PPT 安全字体等):能顺手改就改,否则知会后放行。**例外:`Geometry:` 开头的文字重叠 warning 不许无视** —— 它给了精确坐标,是"大字压说明 / 同行文字互侵"的高嫌疑点(估宽无法区分擦边与压字,所以只报 warn),阶段五渲图时**必须对着该页该坐标专门看**,压了就返工。 - 跑 `svg_output/`(不要在 finalize 后跑 —— finalize 改写 SVG 会掩盖源级违规)。 - ⚠️ **别用 `| head` / `| tail` 截断质检输出**:管道会把脚本的非零退出码换成 `head` 的 0(门形同虚设),`head` 还会截掉打在**最后**的 deck 级门结论(如零图标 `[ERROR]`)。原样跑,读完整输出、认它的退出码。 diff --git a/skills/ppt/references/executor-base.md b/skills/ppt/references/executor-base.md index e157d1c..cf294c1 100644 --- a/skills/ppt/references/executor-base.md +++ b/skills/ppt/references/executor-base.md @@ -182,6 +182,8 @@ Before drawing each page, look up its entry in `page_charts` to decide which cha - **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. + - **Budget vertical space BEFORE writing the page**: items × row height must fit within `content_top`..`content_bottom`. If N items don't fit, cut items, tighten copy, or change layout — never compress row gaps until the last item's description lands in the footer zone (shipped failure: a 5-item list whose 5th description was clipped by the footer). The checker errors on any text baseline past `content_bottom`. + - **Never degrade an assigned chart to text**: when `spec_lock.page_charts` assigns this page a chart, the page MUST draw a real figure (adapt the named `templates/charts/` template). Rendering the numbers as a text list or big KPI type instead of the assigned chart is a checker-level error — quantitative evidence belongs in a figure, and the Strategist already decided this page gets one. - **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 8b08a59..c112b33 100644 --- a/skills/ppt/references/strategist.md +++ b/skills/ppt/references/strategist.md @@ -650,7 +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. +> - **Archetype cap (anti-monotony)**: the same layout archetype — especially the icon+title+text card grid, the full-width stacked row list, and the **two-column bare text list** (icon header + underline + text stack ×2, zero figures — a shipped deck carried FOUR of these) — 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. +> - **Data-chart floor**: when the source material carries ≥3 comparable quantities (scale numbers, shares, trends, staged targets — e.g. 100万条→500万条, 能耗降10-20%, 碳排占26%), the deck MUST include at least **1-2 real data charts** (bar / line / donut / progress — from `templates/charts/` or custom §VII). A quantitative deck that ships zero data charts wastes its strongest evidence as prose (real shipped failure); if you deliberately assign none, write the reason into design_spec §VII. Numbers rendered as big KPI type count as emphasis, not as a chart. > - **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.** diff --git a/skills/ppt/scripts/svg_quality_checker.py b/skills/ppt/scripts/svg_quality_checker.py index f7ff637..6bc5493 100644 --- a/skills/ppt/scripts/svg_quality_checker.py +++ b/skills/ppt/scripts/svg_quality_checker.py @@ -1602,10 +1602,12 @@ class SVGQualityChecker: x0 = x joined = ''.join(t for t, _ in runs) label = joined if len(joined) <= 12 else joined[:12] + '…' + cjk = (sum(1 for ch in joined if self._est_char_w(ch) == 1.0) + / len(joined) if joined else 0.0) return { 'x0': x0 + tx, 'y0': y - 0.76 * fs + ty, 'x1': x0 + w + tx, 'y1': y + 0.22 * fs + ty, - 'fs': fs, 'label': label, + 'fs': fs, 'label': label, 'cjk': cjk, 'baseline': y + ty, 'anchor_x': x + tx, 'exact_left': anchor not in ('middle', 'end'), } @@ -1785,14 +1787,24 @@ class SVGQualityChecker: min_area = min((a['x1'] - a['x0']) * (a['y1'] - a['y0']), (b['x1'] - b['x0']) * (b['y1'] - b['y0'])) ratio = (iw * ih) / min_area if min_area > 0 else 0 - # Text-text overlaps cap at WARNING: the width estimate can't - # tell a crash from a deliberate graze (quadrant captions, - # word clouds, tightly-kerned numeral+suffix pairs all overlap - # estimated boxes legitimately). The warning carries exact - # coordinates so the render-acceptance pass knows which spot - # to eyeball; icon-on-text and off-canvas below stay errors - # because their geometry is exact. - if ratio >= 0.15 or same_line: + # Text-text overlaps normally cap at WARNING: the width + # estimate can't tell a crash from a deliberate graze + # (quadrant captions, word clouds, tightly-kerned + # numeral+suffix pairs all overlap estimated boxes + # legitimately). The warning carries exact coordinates so the + # render-acceptance pass knows which spot to eyeball. + # EXCEPTION — CJK-on-CJK deep overlap is an ERROR: ideograph + # 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 + # real crash, not noise (a shipped deck had a red display + # line sitting straight on top of its gray caption). + if ratio >= 0.5 and a['cjk'] >= 0.7 and b['cjk'] >= 0.7: + errors.append( + f"CJK text \"{a['label']}\" and \"{b['label']}\" are stacked " + f"on top of each other (~{ratio * 100:.0f}% overlap at " + f"({max(a['x0'], b['x0']):.0f},{max(a['y0'], b['y0']):.0f})) " + f"— CJK width estimation is near-exact; separate them") + elif ratio >= 0.15 or same_line: warnings.append( f"text \"{a['label']}\" and \"{b['label']}\" overlap " f"(~{ratio * 100:.0f}% of the smaller run, around " @@ -1907,25 +1919,34 @@ class SVGQualityChecker: 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 = [] + def _clusters(values, tol): + """Cluster sorted scalars; returns list of member lists.""" + groups = [] for v in sorted(values): - if centers and v - centers[-1][-1] <= tol: - centers[-1].append(v) + if groups and v - groups[-1][-1] <= tol: + groups[-1].append(v) else: - centers.append([v]) - return [sum(c) / len(c) for c in centers] + groups.append([v]) + return groups - def _layout_fingerprint(self, cards, icons): - """Classify the page's dominant grid archetype, or None. + @classmethod + def _cluster_values(cls, values, tol): + """Cluster sorted scalars; returns list of cluster-center floats.""" + return [sum(c) / len(c) for c in cls._clusters(values, tol)] + + def _layout_fingerprint(self, cards, icons, texts, graphic_count): + """Classify the page's dominant layout 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. + >=4-row single-column stack (full-width list rows). text-columns: + bare text lists in >=2 columns with no cards and no figure — the + "icon header + underline + text list" two-column page that reads as + wall-of-text (invisible to the two grid archetypes above; a shipped + deck carried FOUR of these). Pages without a dominant archetype + (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) @@ -1949,6 +1970,14 @@ class SVGQualityChecker: and (min(len(rows), len(cols)) >= 2 or (len(cols) == 1 and len(rows) >= 4)): return ('card-grid', len(rows), len(cols)) + # Text-columns: no cards, no figure, few icons — the structure is + # bare left-anchored text stacked in columns. + if len(cards) <= 1 and len(icons) <= 3 and graphic_count <= 2: + xs = [t['x0'] for t in texts if t.get('exact_left')] + col_sizes = [len(g) for g in self._clusters(xs, self._CLUSTER_TOL) + if len(g) >= 3] + if len(col_sizes) >= 2 and sum(col_sizes) >= 8: + return ('text-columns', len(col_sizes), 0) return None def _check_alignment(self, content: str, svg_path: Path, result: Dict) -> None: @@ -2126,6 +2155,45 @@ class SVGQualityChecker: f"{dev:.0f}px off the locked footer_y={footer_y:.0f}") break + # 3b. Content spilling into the footer zone. With content_bottom + # locked, any non-footer text whose baseline lands between + # content_bottom and the canvas edge is content that didn't fit + # (a shipped deck had a list item's description clipped by the + # footer). Footer runs themselves (baseline ~ footer_y) and page + # numbers are exempt. + content_bottom = self._f(grid.get('content_bottom')) + if content_bottom is not None and not structural: + for t in texts: + bl = t['baseline'] + if bl <= content_bottom + self._ALIGN_TOL or bl > canvas_h + 1: + continue # in-bounds, or already an off-canvas error + if footer_y is not None and abs(bl - footer_y) <= 8: + continue # the footer line itself + errors.append( + f"text \"{t['label']}\" baseline y={bl:.0f} spills past the locked " + f"content_bottom={content_bottom:.0f} into the footer zone — the " + f"content doesn't fit; cut items or rework the layout") + break + + # 3c. Spec-assigned figure enforcement: spec_lock's page_charts says + # this page carries a chart, but the SVG draws no figure — the + # executor degraded an assigned visualization into text (shipped + # failure: a whole deck of quantitative content with zero data + # charts). Figure evidence: >=3 drawing primitives, or >=4 + # card-size rects (bar charts are mostly ). + graphic_count = len(re.findall( + r'<(?:path|polygon|polyline|circle|image)\b', content)) + m_num = self._PAGE_NUM_RE.match(svg_path.name) + if m_num and lock: + charts_map = lock.get('page_charts') or {} + assigned = (charts_map.get(f'P{int(m_num.group(1)):02d}') or '').strip() + if assigned and graphic_count < 3 and len(cards) < 4: + errors.append( + f"spec_lock page_charts assigns '{assigned}' to this page but the " + f"SVG draws no figure ({graphic_count} drawing primitives, " + f"{len(cards)} cards) — the assigned chart was degraded to text; " + f"draw it (adapt templates/charts/{assigned}.svg)") + # 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 @@ -2137,7 +2205,7 @@ class SVGQualityChecker: 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) + fp = self._layout_fingerprint(cards, icons, texts, graphic_count) if fp: self._page_fingerprints[svg_path.name] = fp @@ -2191,7 +2259,8 @@ class SVGQualityChecker: 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]}" + label = (f"{fp[1]}-column bare-text-list" if fp[0] == 'text-columns' + else 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 " diff --git a/skills/ppt/templates/spec_lock_reference.md b/skills/ppt/templates/spec_lock_reference.md index decd374..8fafce1 100644 --- a/skills/ppt/templates/spec_lock_reference.md +++ b/skills/ppt/templates/spec_lock_reference.md @@ -15,6 +15,7 @@ ## layout_grid - margin_x: 60 - content_top: 150 +- content_bottom: 650 - footer_y: 688 - gutter: 24 @@ -22,6 +23,7 @@ > > - `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. +> - `content_bottom` — bottom edge of the body content zone. Any non-footer text baseline below it is content that didn't fit (checker errors — a shipped deck had a list item's description clipped by the footer). The Executor budgets vertical space against this before writing a page: items × row height must fit content_top..content_bottom, else cut items or change layout. > - `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. > diff --git a/tests/test_svg_alignment_check.py b/tests/test_svg_alignment_check.py index 0797bd4..9d331e2 100644 --- a/tests/test_svg_alignment_check.py +++ b/tests/test_svg_alignment_check.py @@ -235,6 +235,100 @@ class DeckAggregationTests(unittest.TestCase): _, out = self._run_deck(pages) self.assertNotIn("Layout monotony", out) + @staticmethod + def _text_columns_page() -> str: + # "图标小标题+文字列表 ×2 栏"零图形页(d1285247 二代产物 S8/S9/S16/S21 形态) + body = "" + for col_x in (66, 690): + for i in range(5): + body += _text(col_x, 300 + 40 * i, f"要点第{i}行文字") + return body + + def test_four_bare_text_column_pages_is_error(self): + pages = {f"{i:02d}_t.svg": self._text_columns_page() for i in range(1, 5)} + pages["05_d.svg"] = self._diagram_page(1) + pages["06_e.svg"] = self._diagram_page(2) + _, out = self._run_deck(pages) + self.assertIn("[ERROR] Layout monotony", out) + self.assertIn("bare-text-list", out) + + def test_text_columns_with_diagram_not_fingerprinted(self): + # 同样的文字栏但页上有真图形(≥3 基元,如时间轴线+节点)→ 不算裸文字页 + timeline = ('' + '' + '') + pages = {f"{i:02d}_t.svg": self._text_columns_page() + timeline + for i in range(1, 5)} + pages["05_d.svg"] = self._diagram_page(5) + pages["06_e.svg"] = self._diagram_page(6) + _, out = self._run_deck(pages) + self.assertNotIn("bare-text-list", out) + + +class SpecContractTests(unittest.TestCase): + """spec 指派图表落空 + content_bottom 越界 + CJK 叠压升级。""" + + def _check(self, body: str, lock: str, name="02_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)) + + def test_assigned_chart_degraded_to_text_is_error(self): + lock = "## page_charts\n- P02: bar_chart\n" + body = "".join(_text(66, 200 + 40 * i, f"目标{i}") for i in range(6)) + r = self._check(body, lock) + errs = _alignment_errors(r) + self.assertTrue(any("page_charts" in e and "bar_chart" in e for e in errs), + errs) + + def test_assigned_chart_with_figure_passes(self): + lock = "## page_charts\n- P02: line_chart\n" + body = ('' + '' + '') + r = self._check(body, lock) + self.assertFalse(any("page_charts" in e for e in _alignment_errors(r))) + + def test_unassigned_text_page_no_chart_error(self): + lock = "## page_charts\n- P05: bar_chart\n" + body = "".join(_text(66, 200 + 40 * i, f"要点{i}") for i in range(6)) + r = self._check(body, lock) + self.assertFalse(any("page_charts" in e for e in _alignment_errors(r))) + + def test_content_spills_past_content_bottom_is_error(self): + lock = "## layout_grid\n- content_bottom: 650\n- footer_y: 686\n" + body = _text(66, 676, "被裁掉的描述文字") + _text(66, 686, "页脚") + r = self._check(body, lock) + errs = _alignment_errors(r) + self.assertTrue(any("content_bottom" in e for e in errs), errs) + + def test_footer_text_near_footer_y_exempt(self): + lock = "## layout_grid\n- content_bottom: 650\n- footer_y: 686\n" + body = _text(66, 686, "页脚文字") + _text(1150, 690, "12 / 25") + r = self._check(body, lock) + self.assertFalse(any("content_bottom" in e for e in _alignment_errors(r))) + + def test_cjk_deep_overlap_is_error(self): + with TemporaryDirectory() as tmp: + page = _write_page( + Path(tmp), "03_content.svg", + '中国陶瓷碳指数之都' + '不仅是千年瓷都更是权威发布地') + r = SVGQualityChecker().check_file(str(page)) + self.assertTrue(any("Geometry:" in e and "CJK" in e for e in r["errors"]), + r["errors"]) + + def test_latin_overlap_stays_warning(self): + with TemporaryDirectory() as tmp: + page = _write_page( + Path(tmp), "03_content.svg", + 'Total Revenue 2027' + 'annual growth rate') + r = SVGQualityChecker().check_file(str(page)) + self.assertFalse(any("CJK" in e for e in r["errors"])) + if __name__ == "__main__": unittest.main()