feat(ppt): 反纯文字页+图表落地硬门(7aa49195 二代陶瓷 deck 复盘,bump 0.38.0)
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 <noreply@anthropic.com>
This commit is contained in:
parent
d30f6089bb
commit
346930449a
|
|
@ -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)
|
### 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` 收尾同步改。
|
用户报: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` 收尾同步改。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||||
# 改版本只动这一行。
|
# 改版本只动这一行。
|
||||||
__version__ = "0.37.2"
|
__version__ = "0.38.0"
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ description: 生成 PowerPoint 演示文稿 (.pptx) 文件。✅ 触发:用户
|
||||||
|
|
||||||
**逐页大纲**(写进 design_spec.md §IX,也是 spec_lock 的 page_rhythm/page_layouts 依据):**论断式标题 + 每页标节奏**(`anchor`/`dense`/`breathing`)。三条硬纪律(大纲阶段定死):
|
**逐页大纲**(写进 design_spec.md §IX,也是 spec_lock 的 page_rhythm/page_layouts 依据):**论断式标题 + 每页标节奏**(`anchor`/`dense`/`breathing`)。三条硬纪律(大纲阶段定死):
|
||||||
- **论断标题**:写结论不写主题("渗透率破 60%" 不是 "行业背景");
|
- **论断标题**:写结论不写主题("渗透率破 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(见阶段四)。
|
- **内容→版式映射(必须落到 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 便宜)。
|
大纲连同 a–h **一起给用户预览,⛔ BLOCKING 等确认整份结构**后再进阶段二(改文字比改 slide 便宜)。
|
||||||
|
|
@ -142,7 +142,7 @@ 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 漂移 / **图标压在文字上、文字基线超出画布**(Geometry 检测,几何精确)/ **兄弟卡片错位 2–12px、偏离 layout_grid 网格**(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_output/`(不要在 finalize 后跑 —— finalize 改写 SVG 会掩盖源级违规)。
|
- 跑 `svg_output/`(不要在 finalize 后跑 —— finalize 改写 SVG 会掩盖源级违规)。
|
||||||
- ⚠️ **别用 `| head` / `| tail` 截断质检输出**:管道会把脚本的非零退出码换成 `head` 的 0(门形同虚设),`head` 还会截掉打在**最后**的 deck 级门结论(如零图标 `[ERROR]`)。原样跑,读完整输出、认它的退出码。
|
- ⚠️ **别用 `| head` / `| tail` 截断质检输出**:管道会把脚本的非零退出码换成 `head` 的 0(门形同虚设),`head` 还会截掉打在**最后**的 deck 级门结论(如零图标 `[ERROR]`)。原样跑,读完整输出、认它的退出码。
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- **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.
|
- **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.
|
- **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
|
- **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
|
- **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).
|
- **Generation rhythm**: lock global design context first, then generate pages sequentially in one continuous context. No batched groups (e.g., 5 at a time).
|
||||||
|
|
|
||||||
|
|
@ -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.
|
> - 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).
|
> - **`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 `<path>`/`<polygon>`/`<polyline>`/`<image>` 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.
|
> - Downstream enforcement: `svg_quality_checker.py` **hard-fails** any deck (≥6 text-heavy pages) whose Executor output has zero `<path>`/`<polygon>`/`<polyline>`/`<image>` 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.
|
> - **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.**
|
> **Reading is mandatory; the catalog is a starting point, not a copy target.**
|
||||||
|
|
|
||||||
|
|
@ -1602,10 +1602,12 @@ class SVGQualityChecker:
|
||||||
x0 = x
|
x0 = x
|
||||||
joined = ''.join(t for t, _ in runs)
|
joined = ''.join(t for t, _ in runs)
|
||||||
label = joined if len(joined) <= 12 else joined[:12] + '…'
|
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 {
|
return {
|
||||||
'x0': x0 + tx, 'y0': y - 0.76 * fs + ty,
|
'x0': x0 + tx, 'y0': y - 0.76 * fs + ty,
|
||||||
'x1': x0 + w + tx, 'y1': y + 0.22 * 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,
|
'baseline': y + ty, 'anchor_x': x + tx,
|
||||||
'exact_left': anchor not in ('middle', 'end'),
|
'exact_left': anchor not in ('middle', 'end'),
|
||||||
}
|
}
|
||||||
|
|
@ -1785,14 +1787,24 @@ class SVGQualityChecker:
|
||||||
min_area = min((a['x1'] - a['x0']) * (a['y1'] - a['y0']),
|
min_area = min((a['x1'] - a['x0']) * (a['y1'] - a['y0']),
|
||||||
(b['x1'] - b['x0']) * (b['y1'] - b['y0']))
|
(b['x1'] - b['x0']) * (b['y1'] - b['y0']))
|
||||||
ratio = (iw * ih) / min_area if min_area > 0 else 0
|
ratio = (iw * ih) / min_area if min_area > 0 else 0
|
||||||
# Text-text overlaps cap at WARNING: the width estimate can't
|
# Text-text overlaps normally cap at WARNING: the width
|
||||||
# tell a crash from a deliberate graze (quadrant captions,
|
# estimate can't tell a crash from a deliberate graze
|
||||||
# word clouds, tightly-kerned numeral+suffix pairs all overlap
|
# (quadrant captions, word clouds, tightly-kerned
|
||||||
# estimated boxes legitimately). The warning carries exact
|
# numeral+suffix pairs all overlap estimated boxes
|
||||||
# coordinates so the render-acceptance pass knows which spot
|
# legitimately). The warning carries exact coordinates so the
|
||||||
# to eyeball; icon-on-text and off-canvas below stay errors
|
# render-acceptance pass knows which spot to eyeball.
|
||||||
# because their geometry is exact.
|
# EXCEPTION — CJK-on-CJK deep overlap is an ERROR: ideograph
|
||||||
if ratio >= 0.15 or same_line:
|
# 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(
|
warnings.append(
|
||||||
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 "
|
||||||
|
|
@ -1907,25 +1919,34 @@ class SVGQualityChecker:
|
||||||
return abs(a - b) <= max(floor, rel * max(a, b))
|
return abs(a - b) <= max(floor, rel * max(a, b))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _cluster_values(values, tol):
|
def _clusters(values, tol):
|
||||||
"""Cluster sorted scalars; returns list of cluster-center floats."""
|
"""Cluster sorted scalars; returns list of member lists."""
|
||||||
centers = []
|
groups = []
|
||||||
for v in sorted(values):
|
for v in sorted(values):
|
||||||
if centers and v - centers[-1][-1] <= tol:
|
if groups and v - groups[-1][-1] <= tol:
|
||||||
centers[-1].append(v)
|
groups[-1].append(v)
|
||||||
else:
|
else:
|
||||||
centers.append([v])
|
groups.append([v])
|
||||||
return [sum(c) / len(c) for c in centers]
|
return groups
|
||||||
|
|
||||||
def _layout_fingerprint(self, cards, icons):
|
@classmethod
|
||||||
"""Classify the page's dominant grid archetype, or None.
|
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
|
icon-grid: >=4 icons arranged in a >=2x2 grid (the icon+title+text
|
||||||
card pattern — usually no visible card rect, so icons carry the
|
card pattern — usually no visible card rect, so icons carry the
|
||||||
structure). card-grid: >=4 similar-sized card rects in a grid, or a
|
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
|
>=4-row single-column stack (full-width list rows). text-columns:
|
||||||
dominant grid (covers, chapters, diagrams, timelines) get None and
|
bare text lists in >=2 columns with no cards and no figure — the
|
||||||
never count toward monotony.
|
"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:
|
if len(icons) >= 4:
|
||||||
rows = self._cluster_values([i['y0'] for i in icons], self._CLUSTER_TOL)
|
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
|
and (min(len(rows), len(cols)) >= 2
|
||||||
or (len(cols) == 1 and len(rows) >= 4)):
|
or (len(cols) == 1 and len(rows) >= 4)):
|
||||||
return ('card-grid', len(rows), len(cols))
|
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
|
return None
|
||||||
|
|
||||||
def _check_alignment(self, content: str, svg_path: Path, result: Dict) -> 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}")
|
f"{dev:.0f}px off the locked footer_y={footer_y:.0f}")
|
||||||
break
|
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 <rect>).
|
||||||
|
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
|
# 4. Deck aggregation: primary left edge (margin-drift fallback when no
|
||||||
# layout_grid is locked) + layout-archetype fingerprint (monotony).
|
# layout_grid is locked) + layout-archetype fingerprint (monotony).
|
||||||
# Only numbered deck pages (NN_*.svg) participate — a directory of
|
# 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]
|
edge_candidates = [x for x in edge_candidates if 0 < x < 0.25 * canvas_w]
|
||||||
if len(texts) >= 4 and edge_candidates:
|
if len(texts) >= 4 and edge_candidates:
|
||||||
self._page_left_edges[svg_path.name] = min(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:
|
if fp:
|
||||||
self._page_fingerprints[svg_path.name] = fp
|
self._page_fingerprints[svg_path.name] = fp
|
||||||
|
|
||||||
|
|
@ -2191,7 +2259,8 @@ class SVGQualityChecker:
|
||||||
by_family[fp[0]].append(name)
|
by_family[fp[0]].append(name)
|
||||||
fp, members = max(by_fp.items(), key=lambda kv: len(kv[1]))
|
fp, members = max(by_fp.items(), key=lambda kv: len(kv[1]))
|
||||||
n = len(members)
|
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 — "
|
_fix = ("Rework all but 1-2 of them into a different visual form — "
|
||||||
"timeline / layered architecture / quadrant / process flow / "
|
"timeline / layered architecture / quadrant / process flow / "
|
||||||
"hub-spoke / chart (templates/charts/) — per the content->layout "
|
"hub-spoke / chart (templates/charts/) — per the content->layout "
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
## layout_grid
|
## layout_grid
|
||||||
- margin_x: 60
|
- margin_x: 60
|
||||||
- content_top: 150
|
- content_top: 150
|
||||||
|
- content_bottom: 650
|
||||||
- footer_y: 688
|
- footer_y: 688
|
||||||
- gutter: 24
|
- 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.
|
> - `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_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.
|
> - `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.
|
> - `gutter` — gap between side-by-side sibling cards in a row/grid. One value deck-wide.
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -235,6 +235,100 @@ class DeckAggregationTests(unittest.TestCase):
|
||||||
_, out = self._run_deck(pages)
|
_, out = self._run_deck(pages)
|
||||||
self.assertNotIn("Layout monotony", out)
|
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 = ('<path d="M 100 250 L 1100 250" stroke="#C00000" fill="none"/>'
|
||||||
|
'<circle cx="300" cy="250" r="6" fill="#C00000"/>'
|
||||||
|
'<circle cx="700" cy="250" r="6" fill="#C00000"/>')
|
||||||
|
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 = ('<path d="M 100 400 L 400 300 L 700 350" stroke="#C00000" fill="none"/>'
|
||||||
|
'<circle cx="400" cy="300" r="5" fill="#C00000"/>'
|
||||||
|
'<circle cx="700" cy="350" r="5" fill="#C00000"/>')
|
||||||
|
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",
|
||||||
|
'<text x="400" y="500" font-size="22" fill="#C00000">中国陶瓷碳指数之都</text>'
|
||||||
|
'<text x="405" y="503" font-size="14" fill="#666666">不仅是千年瓷都更是权威发布地</text>')
|
||||||
|
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",
|
||||||
|
'<text x="400" y="500" font-size="22" fill="#C00000">Total Revenue 2027</text>'
|
||||||
|
'<text x="405" y="503" font-size="14" fill="#666666">annual growth rate</text>')
|
||||||
|
r = SVGQualityChecker().check_file(str(page))
|
||||||
|
self.assertFalse(any("CJK" in e for e in r["errors"]))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue