@topmindspace/tms-skills 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ ## 0.1.7 — 2026-09-26
2
+
3
+ ### top-ppt-html · defect opt (D8/D9/D11/D13)
4
+
5
+ - **P0 D8**:`chartBottom(hasSoWhat, hasFootnote)` 不再 footnote 短路;`Math.min(soWhatY, footnoteY, contentBottomWithNote)` 与 flagY 让位同口径。双通道(`build_pptx.js` / `pptx-export.js`)对齐;flagBar 在 withNote 时底边不越过 `contentBottomWithNote`;Agenda 预留 0.25in 避免 severe 误伤。
6
+ - **P0 D9**:`annotation_band_overlap_check` 报告页内**全部**侵入者(去掉首条即 `break`)。
7
+ - **P1 D11**:有 so-what 时 `band_top = soWhatY`(6.05),捕获 (6.05, 6.40] 侵入;crush = 自带顶之上压下;注释自形状/窄 accent 条豁免保留。
8
+ - **P1 D13**:`ci_skill_gates.sh --with-pptx` 必跑 `smoke_pptx` × business-blue **与** research-mckinsey(覆盖 soWhat+footnote);graphite-dark 可选。
9
+ - **CHROME_DRIFT**:页码只认 `N / M`,不再把年份/Exhibit 编号当页脚(research-mckinsey 假阳性清除)。
10
+ - **Fast Mode / 效率**:演讲/汇报线索→Mode A;明确跳过 vs 仍须(质检红线不降);L0+L1 / `extract_snippet` 纪律不变。
11
+ - **门禁**:`test_feedback_gates` 锁定 D8/D9/D11/CHROME;research-mckinsey `--strict` **0/0**。
12
+ - 版本对齐 **0.1.7**(根 + 技能 package / SKILL metadata / README / PUBLISHING)。
13
+
14
+ ### 红线未动
15
+ 反截断、图表多样性地板、Mode A craft、runtime SHA 同版本门禁、中文「演示文稿」术语保持。
16
+
1
17
  ## 0.1.6 — 2026-09-25
2
18
 
3
19
  ### CI / Release 硬化
package/README.md CHANGED
@@ -8,9 +8,9 @@
8
8
 
9
9
  | 技能 | 版本 | 做什么 |
10
10
  |------|------|--------|
11
- | [`top-ppt-html`](./top-ppt-html/) | 0.1.6 | 优雅大气的正式场合演讲/汇报演示文稿:单文件 HTML + 版式保真可编辑 16:9 PPTX |
11
+ | [`top-ppt-html`](./top-ppt-html/) | 0.1.7 | 优雅大气的正式场合演讲/汇报演示文稿:单文件 HTML + 版式保真可编辑 16:9 PPTX |
12
12
 
13
- 安装器包 [`@topmindspace/tms-skills`](https://www.npmjs.com/package/@topmindspace/tms-skills) 亦为 **0.1.6**(整仓发版:安装器与当前技能同 tag;未来多技能仍可各自演进,默认随仓库 release)。
13
+ 安装器包 [`@topmindspace/tms-skills`](https://www.npmjs.com/package/@topmindspace/tms-skills) 亦为 **0.1.7**(整仓发版:安装器与当前技能同 tag;未来多技能仍可各自演进,默认随仓库 release)。
14
14
 
15
15
  更多技能以仓库根目录下的技能目录加入(每个目录含 `SKILL.md`)。
16
16
 
@@ -30,7 +30,7 @@
30
30
  npx @topmindspace/tms-skills list
31
31
  npx @topmindspace/tms-skills install top-ppt-html
32
32
  npx @topmindspace/tms-skills install top-ppt-html --to ./.claude/skills
33
- npx @topmindspace/tms-skills@0.1.6 install top-ppt-html # 钉版本
33
+ npx @topmindspace/tms-skills@0.1.7 install top-ppt-html # 钉版本
34
34
  ```
35
35
 
36
36
  ### GitHub 直装
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topmindspace/tms-skills",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "TopMindspace 智能体技能 monorepo — 通过 npm / GitHub npx 安装技能(top-ppt-html:优雅大气正式场合演讲/汇报演示文稿等)。",
5
5
  "license": "MIT",
6
6
  "author": "TopMindspace",
@@ -3,7 +3,7 @@
3
3
  **让 idea 飞,好想法被看见。** 面向**演讲与正式场合**的高品质演示文稿技能:把主张、调研、分析、架构方案,生成为**优雅、美观、大气**的可翻页 HTML + **版式保真可编辑 PPTX**。核心工艺是版式、排版、色彩与内容组织——克制图标/图表配文,而非 gadget 堆砌或咨询 dump 默认。
4
4
 
5
5
  - 技能标识:`top-ppt-html`(安装/打包目录名与此一致);品牌名:**TopPPT HTML**
6
- - 版本:**v0.1.6**(与 `@topmindspace/tms-skills@0.1.6` 同 tag)
6
+ - 版本:**v0.1.7**(与 `@topmindspace/tms-skills@0.1.7` 同 tag)
7
7
  - **智能体入口**:`SKILL.md` → `references/playbook.md`(L1)→ L2 按需
8
8
  - **人类维护者**:本 README(安装 / 命令 / 目录);勿把本文件当生成规范
9
9
 
@@ -4,7 +4,7 @@ description: "Use when 用户要做报告、演示、汇报、PPT、slides、dec
4
4
  license: MIT
5
5
  compatibility: "Python 3 stdlib for HTML generation; Node >=18 + pptxgenjs for PPTX; optional playwright for browser regression / theme captures."
6
6
  metadata:
7
- version: "0.1.6"
7
+ version: "0.1.7"
8
8
  author: TopMindspace
9
9
  ---
10
10
  # TopPPT HTML
@@ -25,17 +25,17 @@ metadata:
25
25
 
26
26
  ## Fast Mode · 快速模式(用户显式 opt-in)
27
27
 
28
- 触发词任一即进入(**跳过 Gate 0 与六项问询**):`快速模式` / `fast` / `直接生成` / `一键出稿` / `fast mode` / `少问一句`。
28
+ 触发词即进入(**跳过 Gate 0 与六项**):`快速模式` / `fast` / `直接生成` / `一键出稿` / `fast mode` / `少问一句`。
29
29
 
30
30
  | 参数 | 默认 | 推断 |
31
31
  |------|------|------|
32
- | mode | **B** | **路演/汇报/发布/演讲/demo/融资→A**;架构/拓扑→C;未点明→B |
32
+ | mode | **B** | **路演/汇报/发布/演讲/demo/融资/宣讲→A**;架构/拓扑→C;未点明→B |
33
33
  | style | B→mckinsey · A→business-blue · C→graphite-dark | |
34
34
  | theme | light(graphite→dark) | |
35
35
  | 篇幅 | A=10 / B=12 / C=6 | |
36
- | format | **html only**(默认;**仅当用户要 PPT / 交付含 PPTX 才开** B 通道) | |
36
+ | format | **html only**(**仅当用户要 PPT/PPTX 才开** B 通道) | |
37
37
 
38
- 流程:一行宣布「Fast 选用:…」→ **最小大纲**(`outline-design.md`「轻量最小集」)→ playbook §二轻量链(骨架→模型单写→回填→strict→`quality_gate --deliver`;**PPTX 显式 opt-in**)。路径轻量;末可附 `assets/style-gallery.html`。开场声明「已跳过参考图」。**标准模式**仍强制 Gate 0 + 六项。
38
+ **跳过** Gate 0/六项/完整大纲。**仍须** 最小大纲→模型单写→strict 0/0→`quality_gate --deliver`(反截断、图表多样性、Mode A craft 不降)。读面 L0+L1(A/Fast 可加 L1.5);代码一律 `extract_snippet`。一行宣布「Fast 选用:…」→ playbook §二轻量链;声明已跳过参考图。**标准模式**仍强制 Gate 0 + 六项。
39
39
 
40
40
  ## 唯一入口流程
41
41
 
@@ -121,7 +121,7 @@ metadata:
121
121
 
122
122
  ## 版本口径
123
123
 
124
- 包 semver(现 0.1.6)≠ 布局 schema 线(layout-constants / model-schema 的 version,现 `0.1`);patch 不抬 schema。
124
+ 包 semver(现 0.1.7)≠ 布局 schema 线(layout-constants / model-schema 的 version,现 `0.1`);patch 不抬 schema。
125
125
 
126
126
  ## 环境依赖
127
127
 
@@ -1976,7 +1976,7 @@ window.REPORT_MODEL = {
1976
1976
 
1977
1977
  /* __TOPPPT_RUNTIME_START__ */
1978
1978
  /* ══ PPTX 导出运行时(assets/pptx-export.js 的内联副本 · 由 scripts/sync_runtime.py 注入,禁止手改) ══ */
1979
- /* __TOPPPT_RUNTIME_SHA__:68c55206c33160af */
1979
+ /* __TOPPPT_RUNTIME_SHA__:77ca318e7a510e01 */
1980
1980
  /* ═══════════════════════════════════════════════════════════════════════════
1981
1981
  * TopPPT HTML· 浏览器端 PPTX 预览运行时
1982
1982
  * 零依赖、随报告单文件分发;OOXML 序列化器(仅供预览,页面不导出文件)。
@@ -2231,6 +2231,7 @@ function dataColors(style, theme) {
2231
2231
  var LAY = PT.layout || {};
2232
2232
  var CONTENT_TOP = (LAY.contentTop != null) ? LAY.contentTop : 2.5;
2233
2233
  var CONTENT_BOTTOM = (LAY.contentBottom != null) ? LAY.contentBottom : 6.9;
2234
+ var CONTENT_BOTTOM_NOTE = (LAY.contentBottomWithNote != null) ? LAY.contentBottomWithNote : 6.4;
2234
2235
 
2235
2236
  /* ── 自适应排版工具(与 build_pptx.js 同算法,保证两通道字号/换行一致) ── */
2236
2237
  function estLines(text, widthIn, fontPt) {
@@ -2305,9 +2306,12 @@ function fitRowH(avail, n, pref, floor) {
2305
2306
  return Math.max(lo, Math.min(pref, avail / Math.max(1, n)));
2306
2307
  }
2307
2308
  function chartBottom(hasSoWhat, hasFootnote) {
2308
- if (hasFootnote) return PT.exhibit.footnoteY - 0.12;
2309
- if (hasSoWhat) return PT.exhibit.soWhatY - 0.12;
2310
- return CONTENT_BOTTOM;
2309
+ /* D8: tightest of soWhat/footnote/contentBottomWithNote — no short-circuit */
2310
+ var bot = CONTENT_BOTTOM;
2311
+ if (hasSoWhat) bot = Math.min(bot, PT.exhibit.soWhatY - 0.12);
2312
+ if (hasFootnote) bot = Math.min(bot, PT.exhibit.footnoteY - 0.12);
2313
+ if (hasSoWhat || hasFootnote) bot = Math.min(bot, CONTENT_BOTTOM_NOTE);
2314
+ return bot;
2311
2315
  }
2312
2316
 
2313
2317
  /* ── XML 工具 ── */
@@ -3195,7 +3199,7 @@ function slidesOf(model, S) {
3195
3199
  head(ag, 'AGENDA', '报告大纲');
3196
3200
  var colN = items.length > 8 ? 2 : 1;
3197
3201
  var perCol = Math.ceil(items.length / colN) || 1;
3198
- var rowH = Math.min(1.05, (CONTENT_BOTTOM - CONTENT_TOP) / perCol);
3202
+ var rowH = Math.min(1.05, (CONTENT_BOTTOM - CONTENT_TOP - 0.25) / Math.max(1, perCol));
3199
3203
  var numSz = rowH >= 0.85 ? 30 : (rowH >= 0.65 ? 22 : 18);
3200
3204
  var tSz = rowH >= 0.85 ? 16 : 14, dSz = rowH >= 0.85 ? 12 : 11;
3201
3205
  items.forEach(function (it, i) {
@@ -3232,6 +3236,7 @@ function slidesOf(model, S) {
3232
3236
  flagY = CONTENT_BOTTOM - flagH;
3233
3237
  if (sec.footnote) flagY = Math.min(flagY, PT.exhibit.footnoteY - flagH - 0.06);
3234
3238
  if (sec.soWhat) flagY = Math.min(flagY, PT.exhibit.soWhatY - flagH - 0.06);
3239
+ if (sec.footnote || sec.soWhat) flagY = Math.min(flagY, CONTENT_BOTTOM_NOTE - flagH);
3235
3240
  bodyBottom = Math.min(bodyBottom, flagY - (_F.gap || 0.12));
3236
3241
  }
3237
3242
 
@@ -2375,7 +2375,7 @@ window.REPORT_MODEL = {
2375
2375
 
2376
2376
  /* __TOPPPT_RUNTIME_START__ */
2377
2377
  /* ══ PPTX 导出运行时(assets/pptx-export.js 的内联副本 · 由 scripts/sync_runtime.py 注入,禁止手改) ══ */
2378
- /* __TOPPPT_RUNTIME_SHA__:68c55206c33160af */
2378
+ /* __TOPPPT_RUNTIME_SHA__:77ca318e7a510e01 */
2379
2379
  /* ═══════════════════════════════════════════════════════════════════════════
2380
2380
  * TopPPT HTML· 浏览器端 PPTX 预览运行时
2381
2381
  * 零依赖、随报告单文件分发;OOXML 序列化器(仅供预览,页面不导出文件)。
@@ -2630,6 +2630,7 @@ function dataColors(style, theme) {
2630
2630
  var LAY = PT.layout || {};
2631
2631
  var CONTENT_TOP = (LAY.contentTop != null) ? LAY.contentTop : 2.5;
2632
2632
  var CONTENT_BOTTOM = (LAY.contentBottom != null) ? LAY.contentBottom : 6.9;
2633
+ var CONTENT_BOTTOM_NOTE = (LAY.contentBottomWithNote != null) ? LAY.contentBottomWithNote : 6.4;
2633
2634
 
2634
2635
  /* ── 自适应排版工具(与 build_pptx.js 同算法,保证两通道字号/换行一致) ── */
2635
2636
  function estLines(text, widthIn, fontPt) {
@@ -2704,9 +2705,12 @@ function fitRowH(avail, n, pref, floor) {
2704
2705
  return Math.max(lo, Math.min(pref, avail / Math.max(1, n)));
2705
2706
  }
2706
2707
  function chartBottom(hasSoWhat, hasFootnote) {
2707
- if (hasFootnote) return PT.exhibit.footnoteY - 0.12;
2708
- if (hasSoWhat) return PT.exhibit.soWhatY - 0.12;
2709
- return CONTENT_BOTTOM;
2708
+ /* D8: tightest of soWhat/footnote/contentBottomWithNote — no short-circuit */
2709
+ var bot = CONTENT_BOTTOM;
2710
+ if (hasSoWhat) bot = Math.min(bot, PT.exhibit.soWhatY - 0.12);
2711
+ if (hasFootnote) bot = Math.min(bot, PT.exhibit.footnoteY - 0.12);
2712
+ if (hasSoWhat || hasFootnote) bot = Math.min(bot, CONTENT_BOTTOM_NOTE);
2713
+ return bot;
2710
2714
  }
2711
2715
 
2712
2716
  /* ── XML 工具 ── */
@@ -3594,7 +3598,7 @@ function slidesOf(model, S) {
3594
3598
  head(ag, 'AGENDA', '报告大纲');
3595
3599
  var colN = items.length > 8 ? 2 : 1;
3596
3600
  var perCol = Math.ceil(items.length / colN) || 1;
3597
- var rowH = Math.min(1.05, (CONTENT_BOTTOM - CONTENT_TOP) / perCol);
3601
+ var rowH = Math.min(1.05, (CONTENT_BOTTOM - CONTENT_TOP - 0.25) / Math.max(1, perCol));
3598
3602
  var numSz = rowH >= 0.85 ? 30 : (rowH >= 0.65 ? 22 : 18);
3599
3603
  var tSz = rowH >= 0.85 ? 16 : 14, dSz = rowH >= 0.85 ? 12 : 11;
3600
3604
  items.forEach(function (it, i) {
@@ -3631,6 +3635,7 @@ function slidesOf(model, S) {
3631
3635
  flagY = CONTENT_BOTTOM - flagH;
3632
3636
  if (sec.footnote) flagY = Math.min(flagY, PT.exhibit.footnoteY - flagH - 0.06);
3633
3637
  if (sec.soWhat) flagY = Math.min(flagY, PT.exhibit.soWhatY - flagH - 0.06);
3638
+ if (sec.footnote || sec.soWhat) flagY = Math.min(flagY, CONTENT_BOTTOM_NOTE - flagH);
3634
3639
  bodyBottom = Math.min(bodyBottom, flagY - (_F.gap || 0.12));
3635
3640
  }
3636
3641
 
@@ -3577,7 +3577,7 @@ window.REPORT_MODEL = {
3577
3577
 
3578
3578
  /* __TOPPPT_RUNTIME_START__ */
3579
3579
  /* ══ PPTX 导出运行时(assets/pptx-export.js 的内联副本 · 由 scripts/sync_runtime.py 注入,禁止手改) ══ */
3580
- /* __TOPPPT_RUNTIME_SHA__:68c55206c33160af */
3580
+ /* __TOPPPT_RUNTIME_SHA__:77ca318e7a510e01 */
3581
3581
  /* ═══════════════════════════════════════════════════════════════════════════
3582
3582
  * TopPPT HTML· 浏览器端 PPTX 预览运行时
3583
3583
  * 零依赖、随报告单文件分发;OOXML 序列化器(仅供预览,页面不导出文件)。
@@ -3832,6 +3832,7 @@ function dataColors(style, theme) {
3832
3832
  var LAY = PT.layout || {};
3833
3833
  var CONTENT_TOP = (LAY.contentTop != null) ? LAY.contentTop : 2.5;
3834
3834
  var CONTENT_BOTTOM = (LAY.contentBottom != null) ? LAY.contentBottom : 6.9;
3835
+ var CONTENT_BOTTOM_NOTE = (LAY.contentBottomWithNote != null) ? LAY.contentBottomWithNote : 6.4;
3835
3836
 
3836
3837
  /* ── 自适应排版工具(与 build_pptx.js 同算法,保证两通道字号/换行一致) ── */
3837
3838
  function estLines(text, widthIn, fontPt) {
@@ -3906,9 +3907,12 @@ function fitRowH(avail, n, pref, floor) {
3906
3907
  return Math.max(lo, Math.min(pref, avail / Math.max(1, n)));
3907
3908
  }
3908
3909
  function chartBottom(hasSoWhat, hasFootnote) {
3909
- if (hasFootnote) return PT.exhibit.footnoteY - 0.12;
3910
- if (hasSoWhat) return PT.exhibit.soWhatY - 0.12;
3911
- return CONTENT_BOTTOM;
3910
+ /* D8: tightest of soWhat/footnote/contentBottomWithNote — no short-circuit */
3911
+ var bot = CONTENT_BOTTOM;
3912
+ if (hasSoWhat) bot = Math.min(bot, PT.exhibit.soWhatY - 0.12);
3913
+ if (hasFootnote) bot = Math.min(bot, PT.exhibit.footnoteY - 0.12);
3914
+ if (hasSoWhat || hasFootnote) bot = Math.min(bot, CONTENT_BOTTOM_NOTE);
3915
+ return bot;
3912
3916
  }
3913
3917
 
3914
3918
  /* ── XML 工具 ── */
@@ -4796,7 +4800,7 @@ function slidesOf(model, S) {
4796
4800
  head(ag, 'AGENDA', '报告大纲');
4797
4801
  var colN = items.length > 8 ? 2 : 1;
4798
4802
  var perCol = Math.ceil(items.length / colN) || 1;
4799
- var rowH = Math.min(1.05, (CONTENT_BOTTOM - CONTENT_TOP) / perCol);
4803
+ var rowH = Math.min(1.05, (CONTENT_BOTTOM - CONTENT_TOP - 0.25) / Math.max(1, perCol));
4800
4804
  var numSz = rowH >= 0.85 ? 30 : (rowH >= 0.65 ? 22 : 18);
4801
4805
  var tSz = rowH >= 0.85 ? 16 : 14, dSz = rowH >= 0.85 ? 12 : 11;
4802
4806
  items.forEach(function (it, i) {
@@ -4833,6 +4837,7 @@ function slidesOf(model, S) {
4833
4837
  flagY = CONTENT_BOTTOM - flagH;
4834
4838
  if (sec.footnote) flagY = Math.min(flagY, PT.exhibit.footnoteY - flagH - 0.06);
4835
4839
  if (sec.soWhat) flagY = Math.min(flagY, PT.exhibit.soWhatY - flagH - 0.06);
4840
+ if (sec.footnote || sec.soWhat) flagY = Math.min(flagY, CONTENT_BOTTOM_NOTE - flagH);
4836
4841
  bodyBottom = Math.min(bodyBottom, flagY - (_F.gap || 0.12));
4837
4842
  }
4838
4843
 
@@ -252,6 +252,7 @@ function dataColors(style, theme) {
252
252
  var LAY = PT.layout || {};
253
253
  var CONTENT_TOP = (LAY.contentTop != null) ? LAY.contentTop : 2.5;
254
254
  var CONTENT_BOTTOM = (LAY.contentBottom != null) ? LAY.contentBottom : 6.9;
255
+ var CONTENT_BOTTOM_NOTE = (LAY.contentBottomWithNote != null) ? LAY.contentBottomWithNote : 6.4;
255
256
 
256
257
  /* ── 自适应排版工具(与 build_pptx.js 同算法,保证两通道字号/换行一致) ── */
257
258
  function estLines(text, widthIn, fontPt) {
@@ -326,9 +327,12 @@ function fitRowH(avail, n, pref, floor) {
326
327
  return Math.max(lo, Math.min(pref, avail / Math.max(1, n)));
327
328
  }
328
329
  function chartBottom(hasSoWhat, hasFootnote) {
329
- if (hasFootnote) return PT.exhibit.footnoteY - 0.12;
330
- if (hasSoWhat) return PT.exhibit.soWhatY - 0.12;
331
- return CONTENT_BOTTOM;
330
+ /* D8: tightest of soWhat/footnote/contentBottomWithNote — no short-circuit */
331
+ var bot = CONTENT_BOTTOM;
332
+ if (hasSoWhat) bot = Math.min(bot, PT.exhibit.soWhatY - 0.12);
333
+ if (hasFootnote) bot = Math.min(bot, PT.exhibit.footnoteY - 0.12);
334
+ if (hasSoWhat || hasFootnote) bot = Math.min(bot, CONTENT_BOTTOM_NOTE);
335
+ return bot;
332
336
  }
333
337
 
334
338
  /* ── XML 工具 ── */
@@ -1216,7 +1220,7 @@ function slidesOf(model, S) {
1216
1220
  head(ag, 'AGENDA', '报告大纲');
1217
1221
  var colN = items.length > 8 ? 2 : 1;
1218
1222
  var perCol = Math.ceil(items.length / colN) || 1;
1219
- var rowH = Math.min(1.05, (CONTENT_BOTTOM - CONTENT_TOP) / perCol);
1223
+ var rowH = Math.min(1.05, (CONTENT_BOTTOM - CONTENT_TOP - 0.25) / Math.max(1, perCol));
1220
1224
  var numSz = rowH >= 0.85 ? 30 : (rowH >= 0.65 ? 22 : 18);
1221
1225
  var tSz = rowH >= 0.85 ? 16 : 14, dSz = rowH >= 0.85 ? 12 : 11;
1222
1226
  items.forEach(function (it, i) {
@@ -1253,6 +1257,7 @@ function slidesOf(model, S) {
1253
1257
  flagY = CONTENT_BOTTOM - flagH;
1254
1258
  if (sec.footnote) flagY = Math.min(flagY, PT.exhibit.footnoteY - flagH - 0.06);
1255
1259
  if (sec.soWhat) flagY = Math.min(flagY, PT.exhibit.soWhatY - flagH - 0.06);
1260
+ if (sec.footnote || sec.soWhat) flagY = Math.min(flagY, CONTENT_BOTTOM_NOTE - flagH);
1256
1261
  bodyBottom = Math.min(bodyBottom, flagY - (_F.gap || 0.12));
1257
1262
  }
1258
1263
 
@@ -1778,7 +1778,7 @@ window.REPORT_MODEL = {
1778
1778
 
1779
1779
  /* __TOPPPT_RUNTIME_START__ */
1780
1780
  /* ══ PPTX 导出运行时(assets/pptx-export.js 的内联副本 · 由 scripts/sync_runtime.py 注入,禁止手改) ══ */
1781
- /* __TOPPPT_RUNTIME_SHA__:68c55206c33160af */
1781
+ /* __TOPPPT_RUNTIME_SHA__:77ca318e7a510e01 */
1782
1782
  /* ═══════════════════════════════════════════════════════════════════════════
1783
1783
  * TopPPT HTML· 浏览器端 PPTX 预览运行时
1784
1784
  * 零依赖、随报告单文件分发;OOXML 序列化器(仅供预览,页面不导出文件)。
@@ -2033,6 +2033,7 @@ function dataColors(style, theme) {
2033
2033
  var LAY = PT.layout || {};
2034
2034
  var CONTENT_TOP = (LAY.contentTop != null) ? LAY.contentTop : 2.5;
2035
2035
  var CONTENT_BOTTOM = (LAY.contentBottom != null) ? LAY.contentBottom : 6.9;
2036
+ var CONTENT_BOTTOM_NOTE = (LAY.contentBottomWithNote != null) ? LAY.contentBottomWithNote : 6.4;
2036
2037
 
2037
2038
  /* ── 自适应排版工具(与 build_pptx.js 同算法,保证两通道字号/换行一致) ── */
2038
2039
  function estLines(text, widthIn, fontPt) {
@@ -2107,9 +2108,12 @@ function fitRowH(avail, n, pref, floor) {
2107
2108
  return Math.max(lo, Math.min(pref, avail / Math.max(1, n)));
2108
2109
  }
2109
2110
  function chartBottom(hasSoWhat, hasFootnote) {
2110
- if (hasFootnote) return PT.exhibit.footnoteY - 0.12;
2111
- if (hasSoWhat) return PT.exhibit.soWhatY - 0.12;
2112
- return CONTENT_BOTTOM;
2111
+ /* D8: tightest of soWhat/footnote/contentBottomWithNote — no short-circuit */
2112
+ var bot = CONTENT_BOTTOM;
2113
+ if (hasSoWhat) bot = Math.min(bot, PT.exhibit.soWhatY - 0.12);
2114
+ if (hasFootnote) bot = Math.min(bot, PT.exhibit.footnoteY - 0.12);
2115
+ if (hasSoWhat || hasFootnote) bot = Math.min(bot, CONTENT_BOTTOM_NOTE);
2116
+ return bot;
2113
2117
  }
2114
2118
 
2115
2119
  /* ── XML 工具 ── */
@@ -2997,7 +3001,7 @@ function slidesOf(model, S) {
2997
3001
  head(ag, 'AGENDA', '报告大纲');
2998
3002
  var colN = items.length > 8 ? 2 : 1;
2999
3003
  var perCol = Math.ceil(items.length / colN) || 1;
3000
- var rowH = Math.min(1.05, (CONTENT_BOTTOM - CONTENT_TOP) / perCol);
3004
+ var rowH = Math.min(1.05, (CONTENT_BOTTOM - CONTENT_TOP - 0.25) / Math.max(1, perCol));
3001
3005
  var numSz = rowH >= 0.85 ? 30 : (rowH >= 0.65 ? 22 : 18);
3002
3006
  var tSz = rowH >= 0.85 ? 16 : 14, dSz = rowH >= 0.85 ? 12 : 11;
3003
3007
  items.forEach(function (it, i) {
@@ -3034,6 +3038,7 @@ function slidesOf(model, S) {
3034
3038
  flagY = CONTENT_BOTTOM - flagH;
3035
3039
  if (sec.footnote) flagY = Math.min(flagY, PT.exhibit.footnoteY - flagH - 0.06);
3036
3040
  if (sec.soWhat) flagY = Math.min(flagY, PT.exhibit.soWhatY - flagH - 0.06);
3041
+ if (sec.footnote || sec.soWhat) flagY = Math.min(flagY, CONTENT_BOTTOM_NOTE - flagH);
3037
3042
  bodyBottom = Math.min(bodyBottom, flagY - (_F.gap || 0.12));
3038
3043
  }
3039
3044
 
@@ -1788,7 +1788,7 @@ window.REPORT_MODEL = {
1788
1788
 
1789
1789
  /* __TOPPPT_RUNTIME_START__ */
1790
1790
  /* ══ PPTX 导出运行时(assets/pptx-export.js 的内联副本 · 由 scripts/sync_runtime.py 注入,禁止手改) ══ */
1791
- /* __TOPPPT_RUNTIME_SHA__:68c55206c33160af */
1791
+ /* __TOPPPT_RUNTIME_SHA__:77ca318e7a510e01 */
1792
1792
  /* ═══════════════════════════════════════════════════════════════════════════
1793
1793
  * TopPPT HTML· 浏览器端 PPTX 预览运行时
1794
1794
  * 零依赖、随报告单文件分发;OOXML 序列化器(仅供预览,页面不导出文件)。
@@ -2043,6 +2043,7 @@ function dataColors(style, theme) {
2043
2043
  var LAY = PT.layout || {};
2044
2044
  var CONTENT_TOP = (LAY.contentTop != null) ? LAY.contentTop : 2.5;
2045
2045
  var CONTENT_BOTTOM = (LAY.contentBottom != null) ? LAY.contentBottom : 6.9;
2046
+ var CONTENT_BOTTOM_NOTE = (LAY.contentBottomWithNote != null) ? LAY.contentBottomWithNote : 6.4;
2046
2047
 
2047
2048
  /* ── 自适应排版工具(与 build_pptx.js 同算法,保证两通道字号/换行一致) ── */
2048
2049
  function estLines(text, widthIn, fontPt) {
@@ -2117,9 +2118,12 @@ function fitRowH(avail, n, pref, floor) {
2117
2118
  return Math.max(lo, Math.min(pref, avail / Math.max(1, n)));
2118
2119
  }
2119
2120
  function chartBottom(hasSoWhat, hasFootnote) {
2120
- if (hasFootnote) return PT.exhibit.footnoteY - 0.12;
2121
- if (hasSoWhat) return PT.exhibit.soWhatY - 0.12;
2122
- return CONTENT_BOTTOM;
2121
+ /* D8: tightest of soWhat/footnote/contentBottomWithNote — no short-circuit */
2122
+ var bot = CONTENT_BOTTOM;
2123
+ if (hasSoWhat) bot = Math.min(bot, PT.exhibit.soWhatY - 0.12);
2124
+ if (hasFootnote) bot = Math.min(bot, PT.exhibit.footnoteY - 0.12);
2125
+ if (hasSoWhat || hasFootnote) bot = Math.min(bot, CONTENT_BOTTOM_NOTE);
2126
+ return bot;
2123
2127
  }
2124
2128
 
2125
2129
  /* ── XML 工具 ── */
@@ -3007,7 +3011,7 @@ function slidesOf(model, S) {
3007
3011
  head(ag, 'AGENDA', '报告大纲');
3008
3012
  var colN = items.length > 8 ? 2 : 1;
3009
3013
  var perCol = Math.ceil(items.length / colN) || 1;
3010
- var rowH = Math.min(1.05, (CONTENT_BOTTOM - CONTENT_TOP) / perCol);
3014
+ var rowH = Math.min(1.05, (CONTENT_BOTTOM - CONTENT_TOP - 0.25) / Math.max(1, perCol));
3011
3015
  var numSz = rowH >= 0.85 ? 30 : (rowH >= 0.65 ? 22 : 18);
3012
3016
  var tSz = rowH >= 0.85 ? 16 : 14, dSz = rowH >= 0.85 ? 12 : 11;
3013
3017
  items.forEach(function (it, i) {
@@ -3044,6 +3048,7 @@ function slidesOf(model, S) {
3044
3048
  flagY = CONTENT_BOTTOM - flagH;
3045
3049
  if (sec.footnote) flagY = Math.min(flagY, PT.exhibit.footnoteY - flagH - 0.06);
3046
3050
  if (sec.soWhat) flagY = Math.min(flagY, PT.exhibit.soWhatY - flagH - 0.06);
3051
+ if (sec.footnote || sec.soWhat) flagY = Math.min(flagY, CONTENT_BOTTOM_NOTE - flagH);
3047
3052
  bodyBottom = Math.min(bodyBottom, flagY - (_F.gap || 0.12));
3048
3053
  }
3049
3054
 
@@ -2067,7 +2067,7 @@ window.REPORT_MODEL = {
2067
2067
 
2068
2068
  /* __TOPPPT_RUNTIME_START__ */
2069
2069
  /* ══ PPTX 导出运行时(assets/pptx-export.js 的内联副本 · 由 scripts/sync_runtime.py 注入,禁止手改) ══ */
2070
- /* __TOPPPT_RUNTIME_SHA__:68c55206c33160af */
2070
+ /* __TOPPPT_RUNTIME_SHA__:77ca318e7a510e01 */
2071
2071
  /* ═══════════════════════════════════════════════════════════════════════════
2072
2072
  * TopPPT HTML· 浏览器端 PPTX 预览运行时
2073
2073
  * 零依赖、随报告单文件分发;OOXML 序列化器(仅供预览,页面不导出文件)。
@@ -2322,6 +2322,7 @@ function dataColors(style, theme) {
2322
2322
  var LAY = PT.layout || {};
2323
2323
  var CONTENT_TOP = (LAY.contentTop != null) ? LAY.contentTop : 2.5;
2324
2324
  var CONTENT_BOTTOM = (LAY.contentBottom != null) ? LAY.contentBottom : 6.9;
2325
+ var CONTENT_BOTTOM_NOTE = (LAY.contentBottomWithNote != null) ? LAY.contentBottomWithNote : 6.4;
2325
2326
 
2326
2327
  /* ── 自适应排版工具(与 build_pptx.js 同算法,保证两通道字号/换行一致) ── */
2327
2328
  function estLines(text, widthIn, fontPt) {
@@ -2396,9 +2397,12 @@ function fitRowH(avail, n, pref, floor) {
2396
2397
  return Math.max(lo, Math.min(pref, avail / Math.max(1, n)));
2397
2398
  }
2398
2399
  function chartBottom(hasSoWhat, hasFootnote) {
2399
- if (hasFootnote) return PT.exhibit.footnoteY - 0.12;
2400
- if (hasSoWhat) return PT.exhibit.soWhatY - 0.12;
2401
- return CONTENT_BOTTOM;
2400
+ /* D8: tightest of soWhat/footnote/contentBottomWithNote — no short-circuit */
2401
+ var bot = CONTENT_BOTTOM;
2402
+ if (hasSoWhat) bot = Math.min(bot, PT.exhibit.soWhatY - 0.12);
2403
+ if (hasFootnote) bot = Math.min(bot, PT.exhibit.footnoteY - 0.12);
2404
+ if (hasSoWhat || hasFootnote) bot = Math.min(bot, CONTENT_BOTTOM_NOTE);
2405
+ return bot;
2402
2406
  }
2403
2407
 
2404
2408
  /* ── XML 工具 ── */
@@ -3286,7 +3290,7 @@ function slidesOf(model, S) {
3286
3290
  head(ag, 'AGENDA', '报告大纲');
3287
3291
  var colN = items.length > 8 ? 2 : 1;
3288
3292
  var perCol = Math.ceil(items.length / colN) || 1;
3289
- var rowH = Math.min(1.05, (CONTENT_BOTTOM - CONTENT_TOP) / perCol);
3293
+ var rowH = Math.min(1.05, (CONTENT_BOTTOM - CONTENT_TOP - 0.25) / Math.max(1, perCol));
3290
3294
  var numSz = rowH >= 0.85 ? 30 : (rowH >= 0.65 ? 22 : 18);
3291
3295
  var tSz = rowH >= 0.85 ? 16 : 14, dSz = rowH >= 0.85 ? 12 : 11;
3292
3296
  items.forEach(function (it, i) {
@@ -3323,6 +3327,7 @@ function slidesOf(model, S) {
3323
3327
  flagY = CONTENT_BOTTOM - flagH;
3324
3328
  if (sec.footnote) flagY = Math.min(flagY, PT.exhibit.footnoteY - flagH - 0.06);
3325
3329
  if (sec.soWhat) flagY = Math.min(flagY, PT.exhibit.soWhatY - flagH - 0.06);
3330
+ if (sec.footnote || sec.soWhat) flagY = Math.min(flagY, CONTENT_BOTTOM_NOTE - flagH);
3326
3331
  bodyBottom = Math.min(bodyBottom, flagY - (_F.gap || 0.12));
3327
3332
  }
3328
3333
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "top-ppt-html",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "top-ppt-html",
9
- "version": "0.1.6",
9
+ "version": "0.1.7",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "pptxgenjs": "^4.0.0"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "top-ppt-html",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "private": true,
5
5
  "description": "TopPPT HTML:优雅大气的正式场合演讲/汇报演示文稿。单文件 HTML + 版式保真可编辑 PPTX;布局骨架 P1–P12;模型单写;strict 0/0 交付。",
6
6
  "license": "MIT",
@@ -46,7 +46,7 @@
46
46
  Gate 0 参考图 → 六项问询(1 轮)→ 最小大纲(outline-design「轻量最小集」)→ 1 张规划卡 → scaffold_report.py
47
47
  → **只填 REPORT_MODEL** → render_from_model.py --inplace → validate_report.py --strict → 交付
48
48
  ```
49
- (Fast Mode 跳过 Gate 0/六项,仍须最小大纲;默认 **HTML-only**,PPTX 显式 opt-in 走 B 通道;**禁止**手改 HTML 正文与模型双写。)
49
+ (Fast Mode 跳过 Gate 0/六项,仍须最小大纲+strict;默认 **HTML-only**,PPTX 显式 opt-in 走 B 通道;路演/汇报/演讲线索→Mode A;**禁止**手改 HTML 正文与模型双写。)
50
50
 
51
51
  **完整路径**(任一命中)——研究 ≥20 页 / 演示 ≥12 页 / 材料量大且杂 / 含未敲定关键判断 / 用户要看框架。
52
52
  ```
@@ -573,9 +573,13 @@ function imageLayoutShapes(s, layout, img, items, points, isPh, fit, y0, y1) {
573
573
  }
574
574
  /* 数据图表上下文(供备注与自适应使用) */
575
575
  function chartBottom(hasSoWhat, hasFootnote) {
576
- if (hasFootnote) return PT.exhibit.footnoteY - 0.12;
577
- if (hasSoWhat) return PT.exhibit.soWhatY - 0.12;
578
- return CONTENT_BOTTOM;
576
+ /* 取最紧下界:so-what / footnote / contentBottomWithNote 并存时不得短路(D8)。
577
+ * 与 ~1630 flagY 的 Math.min 让位同口径;withNote 时对齐 regionOf 的 contentBottomWithNote。 */
578
+ let bot = CONTENT_BOTTOM;
579
+ if (hasSoWhat) bot = Math.min(bot, PT.exhibit.soWhatY - 0.12);
580
+ if (hasFootnote) bot = Math.min(bot, PT.exhibit.footnoteY - 0.12);
581
+ if (hasSoWhat || hasFootnote) bot = Math.min(bot, CONTENT_BOTTOM_NOTE);
582
+ return bot;
579
583
  }
580
584
 
581
585
  /* ═══ 原生数据图表(pptxgenjs addChart → 真 chart part + 内嵌 Excel 工作簿)═══
@@ -1581,8 +1585,9 @@ function addTable(s, tbl, x, y, w, availH, opts) {
1581
1585
  const items = CONTENT.agenda;
1582
1586
  const colN = items.length > 8 ? 2 : 1;
1583
1587
  const perCol = Math.ceil(items.length / colN);
1584
- const availH = CONTENT_BOTTOM - CONTENT_TOP;
1585
- const rowH = Math.min(1.05, availH / perCol);
1588
+ /* 预留 0.25in,避免末行底边落入 severe-overlap 安全网(无注释页亦勿压进 withNote 带过深) */
1589
+ const availH = CONTENT_BOTTOM - CONTENT_TOP - 0.25;
1590
+ const rowH = Math.min(1.05, availH / Math.max(1, perCol));
1586
1591
  const numSize = rowH >= 0.85 ? sz(30) : (rowH >= 0.65 ? sz(22) : sz(18));
1587
1592
  const tSize = rowH >= 0.85 ? sz(16) : sz(14);
1588
1593
  const dSize = rowH >= 0.85 ? sz(12) : sz(11);
@@ -1630,6 +1635,8 @@ CONTENT.sections.forEach((sec) => {
1630
1635
  flagY = CONTENT_BOTTOM - flagH;
1631
1636
  if (sec.footnote) flagY = Math.min(flagY, PT.exhibit.footnoteY - flagH - 0.06);
1632
1637
  if (sec.soWhat) flagY = Math.min(flagY, PT.exhibit.soWhatY - flagH - 0.06);
1638
+ /* withNote 时 flag 底不得越过 contentBottomWithNote(与 chartBottom / regionOf 同口径) */
1639
+ if (sec.footnote || sec.soWhat) flagY = Math.min(flagY, CONTENT_BOTTOM_NOTE - flagH);
1633
1640
  bodyBottom = Math.min(bodyBottom, flagY - (_F.gap || 0.12));
1634
1641
  }
1635
1642
 
@@ -2,6 +2,7 @@
2
2
  # TopPPT HTML · PPTX 轻量冒烟(Batch 2)
3
3
  # extract_model → build_pptx → validate_pptx --strict --model=
4
4
  # 用法: bash scripts/smoke_pptx.sh [example.html]
5
+ # CI(ci_skill_gates --with-pptx)必跑 business-blue + research-mckinsey(D13;后者覆盖 soWhat+footnote / D8)
5
6
  # 失败时打印短摘要(错误码/页码/消息),完整 JSON 落盘到 TMPDIR。
6
7
  set -euo pipefail
7
8
  ROOT="$(cd "$(dirname "$0")/.." && pwd)"
@@ -209,6 +209,98 @@ def test_font_size_not_snapped() -> None:
209
209
  )
210
210
 
211
211
 
212
+ def test_annotation_band_reports_all_invaders() -> None:
213
+ """D9: do not break after the first ANNOTATION_BAND_OVERLAP on a slide."""
214
+ def sp(y, h, w, text):
215
+ return ET.fromstring(
216
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
217
+ f'<p:sp xmlns:a="{NS["a"]}" xmlns:p="{NS["p"]}">'
218
+ "<p:spPr><a:xfrm>"
219
+ f'<a:off x="{int(0.6 * EMU)}" y="{int(y * EMU)}"/>'
220
+ f'<a:ext cx="{int(w * EMU)}" cy="{int(h * EMU)}"/>'
221
+ "</a:xfrm></p:spPr>"
222
+ f"<p:txBody><a:p><a:r><a:t>{text}</a:t></a:r></a:p></p:txBody>"
223
+ "</p:sp>"
224
+ )
225
+ els = [
226
+ sp(5.0, 1.2, 4.0, "PlotSeriesA"),
227
+ sp(5.2, 1.1, 4.0, "PlotSeriesB"),
228
+ sp(6.05, 0.55, 12.0, "SO WHAT locked path"),
229
+ ]
230
+ issues = V.annotation_band_overlap_check(els, 1, int(7.5 * EMU), int(13.333 * EMU))
231
+ codes = [i["code"] for i in issues]
232
+ ok(
233
+ "3e D9 reports all invaders (not just first)",
234
+ codes.count("ANNOTATION_BAND_OVERLAP") >= 2,
235
+ f"codes={codes}",
236
+ )
237
+
238
+
239
+ def test_annotation_band_so_what_band_top() -> None:
240
+ """D11: with so-what present, invasions in (6.05, 6.40] must fire."""
241
+ def sp(y, h, w, text):
242
+ return ET.fromstring(
243
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
244
+ f'<p:sp xmlns:a="{NS["a"]}" xmlns:p="{NS["p"]}">'
245
+ "<p:spPr><a:xfrm>"
246
+ f'<a:off x="{int(0.6 * EMU)}" y="{int(y * EMU)}"/>'
247
+ f'<a:ext cx="{int(w * EMU)}" cy="{int(h * EMU)}"/>'
248
+ "</a:xfrm></p:spPr>"
249
+ f"<p:txBody><a:p><a:r><a:t>{text}</a:t></a:r></a:p></p:txBody>"
250
+ "</p:sp>"
251
+ )
252
+ els = [
253
+ # Body ends at 6.30 — inside (soWhatY=6.05, contentBottomWithNote=6.40]
254
+ sp(5.0, 1.3, 4.0, "LegendCrushMid"),
255
+ sp(6.05, 0.55, 12.0, "SO WHAT platform first"),
256
+ ]
257
+ issues = V.annotation_band_overlap_check(els, 1, int(7.5 * EMU), int(13.333 * EMU))
258
+ codes = [i["code"] for i in issues]
259
+ msgs = " ".join(i.get("message") or "" for i in issues)
260
+ ok(
261
+ "3f D11 band_top=soWhatY catches invasion in (6.05, 6.40]",
262
+ "ANNOTATION_BAND_OVERLAP" in codes and "6.05" in msgs,
263
+ f"codes={codes} msgs={msgs[:120]}",
264
+ )
265
+
266
+
267
+ def test_chrome_footer_ignores_bare_integers() -> None:
268
+ """CHROME_DRIFT FP: bare year/exhibit numbers must not count as pager."""
269
+ def sp(y, text):
270
+ return ET.fromstring(
271
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
272
+ f'<p:sp xmlns:a="{NS["a"]}" xmlns:p="{NS["p"]}">'
273
+ "<p:spPr><a:xfrm>"
274
+ f'<a:off x="{int(0.6 * EMU)}" y="{int(y * EMU)}"/>'
275
+ f'<a:ext cx="{int(1.0 * EMU)}" cy="{int(0.3 * EMU)}"/>'
276
+ "</a:xfrm></p:spPr>"
277
+ f'<p:txBody><a:p><a:r><a:rPr sz="1100"/><a:t>{text}</a:t></a:r></a:p></p:txBody>'
278
+ "</p:sp>"
279
+ )
280
+ shapes = [sp(6.14, "2024"), sp(6.14, "06"), sp(7.0, "3 / 14")]
281
+ y = V.chrome_footer_y_in(shapes, int(7.5 * EMU))
282
+ ok(
283
+ "5 chrome_footer_y_in uses N / M only (ignores bare ints)",
284
+ y is not None and abs(y - 7.0) < 0.05,
285
+ f"got {y}",
286
+ )
287
+
288
+
289
+ def test_chart_bottom_no_short_circuit() -> None:
290
+ """D8: chartBottom must Math.min soWhat/footnote — no footnote-first return."""
291
+ build = (ROOT.parent / "scripts" / "build_pptx.js").read_text(encoding="utf-8")
292
+ m = re.search(r"function chartBottom\([\s\S]*?\n\}", build)
293
+ body = m.group(0) if m else ""
294
+ ok(
295
+ "D8 chartBottom uses Math.min (no footnote short-circuit)",
296
+ "Math.min" in body
297
+ and "hasFootnote) return" not in body
298
+ and "CONTENT_BOTTOM_NOTE" in body,
299
+ f"body={body[:200]!r}",
300
+ )
301
+
302
+
303
+
212
304
  def test_engine_static() -> None:
213
305
  export = (ROOT.parent / "assets" / "pptx-export.js").read_text(encoding="utf-8")
214
306
  build = (ROOT.parent / "scripts" / "build_pptx.js").read_text(encoding="utf-8")
@@ -262,6 +354,10 @@ def main() -> int:
262
354
  test_annotation_band_ignores_full_bleed_bg()
263
355
  test_annotation_band_allows_content_without_note()
264
356
  test_annotation_band_fires_when_note_present()
357
+ test_annotation_band_reports_all_invaders()
358
+ test_annotation_band_so_what_band_top()
359
+ test_chrome_footer_ignores_bare_integers()
360
+ test_chart_bottom_no_short_circuit()
265
361
  test_font_size_h2_17_allowed()
266
362
  test_font_size_not_snapped()
267
363
  test_engine_static()
@@ -362,18 +362,20 @@ def annotation_band_overlap_check(
362
362
  soWhatY ≈ 6.05 · contentBottomWithNote ≈ 6.4 · footnote/note ≈ 6.55 ·
363
363
  contentBottom ≈ 6.9 · pager ≈ 7.0
364
364
 
365
- Semantics:
365
+ Semantics (0.1.7 · D9/D11):
366
366
  - Full-bleed backgrounds, full-height accent strips, and pager/footer
367
367
  chrome are never "content crushing the band".
368
368
  - so-what / 口径 / 来源 bars that live in the annotation zone are the
369
369
  band itself — not invaders.
370
- - When the slide actually uses an annotation (so-what/source), body
371
- content that starts above the band and overlaps
372
- [contentBottomWithNote, contentBottom] fires ANNOTATION_BAND_OVERLAP
373
- (streamgraph legend / plot crush cases).
370
+ - Crush = starts ABOVE band_top and overlaps down into the band.
371
+ Shapes whose top is already at/below band_top are in-band chrome.
372
+ - When so-what is present, band_top = soWhatY so invasions in
373
+ (soWhatY, contentBottomWithNote] are reported (D11). Footnote/source
374
+ only keeps band_top = contentBottomWithNote.
374
375
  - When no annotation is present, main content may use up to
375
376
  contentBottom; only a severe invasion (≥0.35in into the nominal
376
377
  withNote band) still fires as a safety net.
378
+ - Report ALL invaders per slide (D9) — do not break after the first.
377
379
  """
378
380
  out: list[dict[str, Any]] = []
379
381
  try:
@@ -382,14 +384,16 @@ def annotation_band_overlap_check(
382
384
  lay = ((lc.get("pageTypes") or {}).get("layout") or {})
383
385
  exhibit = ((lc.get("pageTypes") or {}).get("exhibit") or {})
384
386
  note = ((lc.get("pageTypes") or {}).get("note") or {})
385
- band_top_in = float(lay.get("contentBottomWithNote") or 6.4)
387
+ nominal_band_top_in = float(lay.get("contentBottomWithNote") or 6.4)
386
388
  band_bot_in = float(lay.get("contentBottom") or 6.9)
387
389
  so_what_y_in = float(exhibit.get("soWhatY") or 6.05)
388
390
  note_y_in = float(note.get("y") or exhibit.get("footnoteY") or 6.55)
389
391
  except (OSError, json.JSONDecodeError, TypeError, ValueError):
390
- band_top_in, band_bot_in, so_what_y_in, note_y_in = 6.4, 6.9, 6.05, 6.55
392
+ nominal_band_top_in, band_bot_in, so_what_y_in, note_y_in = 6.4, 6.9, 6.05, 6.55
391
393
 
394
+ band_top_in = nominal_band_top_in
392
395
  band_top = int(band_top_in * 914400)
396
+ nominal_band_top = int(nominal_band_top_in * 914400)
393
397
  band_bot = int(band_bot_in * 914400)
394
398
  so_what_y = int(so_what_y_in * 914400)
395
399
  note_y = int(note_y_in * 914400)
@@ -397,12 +401,14 @@ def annotation_band_overlap_check(
397
401
  slide_h = int(slide_height_emu)
398
402
  slide_area = max(slide_w * slide_h, 1)
399
403
  pager_floor = int(min(band_bot_in + 0.05, slide_h / 914400.0 - 0.05) * 914400)
400
- min_overlap = int(0.10 * 914400) # 0.10in — ignore sub-tenth rounding kiss
401
- severe_overlap = int(0.35 * 914400) # safety net when no annotation detected
404
+ min_overlap = int(0.10 * 914400)
405
+ severe_overlap = int(0.35 * 914400)
402
406
  full_bleed_area = 0.85
403
407
  full_height_frac = 0.90
408
+ # Label-like prefixes only — body text containing「来源」must not count (agenda FP)
404
409
  ann_pat = re.compile(
405
- r"(结论|SO\s*WHAT|口径|来源|数据来源|Source\b|Note\b|footnote)",
410
+ r"(?:^|\n)\s*(结论|SO\s*WHAT|口径\s*[::]?|来源\s*[::]|数据来源\s*[::]|"
411
+ r"Source\s*[::]|Note\s*[::]|footnote|注\s*[::])",
406
412
  re.IGNORECASE,
407
413
  )
408
414
 
@@ -420,23 +426,31 @@ def annotation_band_overlap_check(
420
426
  return True
421
427
  return False
422
428
 
429
+ def _is_bar_like(cy: int, cx: int) -> bool:
430
+ return cy <= int(0.75 * 914400) and cx >= int(0.45 * slide_w)
431
+
432
+ def _is_note_like(y: int, cy: int, text: str) -> bool:
433
+ return y >= note_y - int(0.05 * 914400) and cy <= int(0.55 * 914400) and bool(text)
434
+
435
+ def _at_so_what_slot(y: int) -> bool:
436
+ # Engine locks so-what to soWhatY; agenda trailing rows sit lower (~+0.12)
437
+ return abs(y - so_what_y) <= int(0.08 * 914400)
438
+
423
439
  def _is_annotation_self(y: int, cy: int, cx: int, text: str) -> bool:
424
440
  """so-what / footnote / source row living in the annotation zone."""
425
- if y >= so_what_y - int(0.08 * 914400) and (y + cy) <= band_bot + int(0.2 * 914400):
426
- if ann_pat.search(text):
441
+ if _at_so_what_slot(y) and cy <= int(0.85 * 914400):
442
+ # Fill rect, accent strip, or SO WHAT text — all live in the slot
443
+ if ann_pat.search(text) or _is_bar_like(cy, cx) or cx <= int(0.15 * 914400):
427
444
  return True
428
- # Wide short bar in so-what band (bar fill behind 结论 text)
429
- if cy <= int(0.75 * 914400) and cx >= int(0.45 * slide_w):
430
- return True
431
- if y >= note_y - int(0.05 * 914400) and cy <= int(0.55 * 914400):
445
+ if _is_note_like(y, cy, text):
432
446
  return True
433
- # Entirely inside the nominal withNote band
434
- if y >= band_top - int(0.02 * 914400):
447
+ # Entirely inside the nominal withNote band (6.40 口径)
448
+ if y >= nominal_band_top - int(0.02 * 914400):
435
449
  return True
436
450
  return False
437
451
 
438
- # Pass 1: does this slide reserve an annotation?
439
452
  has_annotation = False
453
+ has_so_what = False
440
454
  for el in elements:
441
455
  box = shape_bounds(el)
442
456
  if box is None:
@@ -446,19 +460,24 @@ def annotation_band_overlap_check(
446
460
  continue
447
461
  if _is_chrome_or_bg(y, cy, cx):
448
462
  continue
449
- if _is_annotation_self(y, cy, cx, _text(el)):
450
- # Only count as "has annotation" when it looks like so-what/source,
451
- # not merely any shape whose top is inside the band.
452
- txt = _text(el)
453
- if (
454
- ann_pat.search(txt)
455
- or (y >= so_what_y - int(0.08 * 914400) and cy <= int(0.75 * 914400) and cx >= int(0.45 * slide_w))
456
- or (y >= note_y - int(0.05 * 914400) and cy <= int(0.55 * 914400) and txt)
457
- ):
458
- has_annotation = True
459
- break
460
-
461
- # Pass 2: body invaders
463
+ txt = _text(el)
464
+ note_like = _is_note_like(y, cy, txt)
465
+ labeled = bool(ann_pat.search(txt))
466
+ bar = _is_bar_like(cy, cx)
467
+ # Fill rect sits on soWhatY (±0.02); labeled SO WHAT text may sit slightly below
468
+ on_fill = abs(y - so_what_y) <= int(0.02 * 914400) and bar
469
+ labeled_slot = labeled and _at_so_what_slot(y) and cy <= int(0.85 * 914400)
470
+ if note_like:
471
+ has_annotation = True
472
+ elif labeled_slot or on_fill:
473
+ has_annotation = True
474
+ has_so_what = True
475
+
476
+ if has_so_what:
477
+ band_top_in = min(nominal_band_top_in, so_what_y_in)
478
+ band_top = int(band_top_in * 914400)
479
+
480
+ # Pass 2: body invaders that START ABOVE the band and crush down (D9: all of them)
462
481
  for el in elements:
463
482
  box = shape_bounds(el)
464
483
  if box is None:
@@ -471,10 +490,14 @@ def annotation_band_overlap_check(
471
490
  continue
472
491
  if _is_annotation_self(y, cy, cx, _text(el)):
473
492
  continue
474
- overlap = min(bottom, band_bot) - max(y, band_top)
475
- if overlap < min_overlap or bottom <= band_top:
493
+ # Crush-from-above only: top must sit above band_top
494
+ if y >= band_top - int(0.02 * 914400):
495
+ continue
496
+ if bottom <= band_top:
497
+ continue
498
+ overlap = min(bottom, band_bot) - band_top
499
+ if overlap < min_overlap:
476
500
  continue
477
- # Enforce when annotation present, or when invasion is severe anyway
478
501
  if has_annotation or overlap >= severe_overlap:
479
502
  out.append(issue(
480
503
  "ANNOTATION_BAND_OVERLAP",
@@ -483,7 +506,6 @@ def annotation_band_overlap_check(
483
506
  "图例/系列请收入主图区或压缩系列数,禁止压进 so-what/来源行。",
484
507
  slide=slide_no,
485
508
  ))
486
- break
487
509
  return out
488
510
 
489
511
 
@@ -795,7 +817,8 @@ def chrome_footer_y_in(shapes: list[ET.Element], height: int) -> float | None:
795
817
  bottom_frac = float(chrome[1] if isinstance(chrome, list) and len(chrome) > 1 else 24) / 100.0
796
818
  threshold = int(height * (1.0 - bottom_frac))
797
819
  ys: list[float] = []
798
- pager = re.compile(r"^\s*\d+\s*/\s*\d+\s*$|^\s*\d+\s*$")
820
+ # Engine footer is always "N / M"; bare integers are chart/exhibit labels (CHROME_DRIFT FP)
821
+ pager = re.compile(r"^\s*\d+\s*/\s*\d+\s*$")
799
822
  for shape in shapes:
800
823
  text = (text_content(shape) or "").strip()
801
824
  box = shape_bounds(shape)