@topmindspace/tms-skills 0.1.3 → 0.1.5

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.
Files changed (32) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +6 -6
  3. package/package.json +2 -2
  4. package/top-ppt-html/README.md +5 -5
  5. package/top-ppt-html/SKILL.md +15 -12
  6. package/top-ppt-html/agents/openai.yaml +2 -2
  7. package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.html +72 -27
  8. package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.html +72 -27
  9. package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.html +72 -27
  10. package/top-ppt-html/assets/pptx-export.js +69 -25
  11. package/top-ppt-html/assets/style-gallery.html +1 -1
  12. package/top-ppt-html/assets/templates/architecture.html +70 -25
  13. package/top-ppt-html/assets/templates/presentation.html +70 -25
  14. package/top-ppt-html/assets/templates/research.html +70 -25
  15. package/top-ppt-html/package-lock.json +2 -2
  16. package/top-ppt-html/package.json +2 -2
  17. package/top-ppt-html/references/default-surface.md +1 -1
  18. package/top-ppt-html/references/modes.md +1 -1
  19. package/top-ppt-html/references/playbook.md +9 -8
  20. package/top-ppt-html/references/pptx-export.md +1 -1
  21. package/top-ppt-html/references/presentation-craft.md +4 -3
  22. package/top-ppt-html/references/tech-design.md +1 -1
  23. package/top-ppt-html/scripts/audit_skill.py +11 -0
  24. package/top-ppt-html/scripts/build_pptx.js +24 -10
  25. package/top-ppt-html/scripts/cross_verify.py +1 -1
  26. package/top-ppt-html/scripts/negative_tests.py +65 -9
  27. package/top-ppt-html/scripts/quality_gate.py +30 -19
  28. package/top-ppt-html/scripts/render_from_model.py +1 -1
  29. package/top-ppt-html/scripts/sync_runtime.py +10 -4
  30. package/top-ppt-html/scripts/test_feedback_gates.py +180 -0
  31. package/top-ppt-html/scripts/validate_pptx.py +157 -0
  32. package/top-ppt-html/scripts/validate_report.py +31 -1
@@ -16,7 +16,7 @@
16
16
  ② 无 LLM 的 rubric 启发式(content/layout/chart/infographic/tone 五维)
17
17
  ③ --deliver:按 SKILL.md「交付说明」七要素(路径/字节数/模式/风格/篇幅/格式/校验+引用)
18
18
  生成结构化交付说明——七要素缺一即 gate FAIL,杜绝手拼遗漏
19
- 交付时一键跑完,避免「只过了结构、内容质量靠自觉」。
19
+ 交付时一键跑完;互不依赖的子进程门禁(HTML / PPTX / evals)并行执行以缩短墙钟。
20
20
  """
21
21
  from __future__ import annotations
22
22
 
@@ -26,6 +26,7 @@ import re
26
26
  import subprocess
27
27
  import sys
28
28
  import time
29
+ from concurrent.futures import ThreadPoolExecutor, as_completed
29
30
  from pathlib import Path
30
31
 
31
32
  try:
@@ -242,33 +243,43 @@ def main() -> int:
242
243
  print(f'交付质量门禁 · {html.name}')
243
244
  print('-' * 60)
244
245
 
245
- # ① HTML strict(Mode A / presentation 自动附带 --layout-qa)
246
+ # ①–③ 互不依赖子进程并行(HTML strict / PPTX strict / evals)
246
247
  head_snip = html.read_text(encoding='utf-8')[:2500]
247
248
  mode_m = re.search(r'data-mode="([^"]+)"', head_snip)
248
249
  mode = mode_m.group(1) if mode_m else 'presentation'
250
+ jobs: list[tuple[str, list[str], str | None]] = []
251
+ # (name, cmd, fixed_note) — fixed_note 非空则覆盖尾部摘要
249
252
  vcmd = [sys.executable, str(ROOT / 'scripts' / 'validate_report.py'), str(html), '--strict']
250
253
  if mode == 'presentation':
251
254
  vcmd.append('--layout-qa')
252
- rc, out, el = run(vcmd)
253
- gate_name = 'validate_report --strict' + (' --layout-qa' if mode == 'presentation' else '')
254
- tail = [ln for ln in out.splitlines() if ln.startswith('PASS ') or ln.startswith('结论')]
255
- gate(gate_name, rc == 0, tail[-1] if tail else f'exit {rc}', el)
256
-
257
- # ② PPTX strict(可选)
255
+ gate_html = 'validate_report --strict' + (' --layout-qa' if mode == 'presentation' else '')
256
+ jobs.append((gate_html, vcmd, None))
258
257
  if args.pptx:
259
- cmd = [sys.executable, str(ROOT / 'scripts' / 'validate_pptx.py'), args.pptx, '--strict']
258
+ pcmd = [sys.executable, str(ROOT / 'scripts' / 'validate_pptx.py'), args.pptx, '--strict']
260
259
  if args.model:
261
- cmd += ['--model=' + args.model]
262
- rc, out, el = run(cmd)
263
- tail = [ln for ln in out.splitlines() if ln.startswith('PASS ') or ln.startswith('结论')]
264
- gate('validate_pptx --strict', rc == 0, tail[-1] if tail else f'exit {rc}', el)
265
-
266
- # ③ evals 确定性
267
- cmd = [sys.executable, str(ROOT / 'evals' / 'run_evals.py'), '--score', str(html)]
260
+ pcmd += ['--model=' + args.model]
261
+ jobs.append(('validate_pptx --strict', pcmd, None))
262
+ ecmd = [sys.executable, str(ROOT / 'evals' / 'run_evals.py'), '--score', str(html)]
268
263
  if args.trace:
269
- cmd += ['--trace', args.trace]
270
- rc, out, el = run(cmd)
271
- gate('evals --score', rc == 0, '确定性 + 效率检查', el)
264
+ ecmd += ['--trace', args.trace]
265
+ jobs.append(('evals --score', ecmd, '确定性 + 效率检查'))
266
+
267
+ results: dict[str, tuple[int, str, float, str | None]] = {}
268
+ with ThreadPoolExecutor(max_workers=max(1, len(jobs))) as pool:
269
+ futs = {pool.submit(run, cmd): (name, note) for name, cmd, note in jobs}
270
+ for fut in as_completed(futs):
271
+ name, note = futs[fut]
272
+ rc, out, el = fut.result()
273
+ results[name] = (rc, out, el, note)
274
+
275
+ # 按 jobs 声明顺序输出(稳定可读;墙钟已并行)
276
+ for name, _cmd, _note in jobs:
277
+ rc, out, el, note = results[name]
278
+ if note is not None:
279
+ gate(name, rc == 0, note, el)
280
+ else:
281
+ tail = [ln for ln in out.splitlines() if ln.startswith('PASS ') or ln.startswith('结论')]
282
+ gate(name, rc == 0, tail[-1] if tail else f'exit {rc}', el)
272
283
 
273
284
  # ④ rubric 启发式
274
285
  rubric = heuristic_rubric(html)
@@ -220,7 +220,7 @@ def r_donut(i, sec):
220
220
  ' <div class="grid g-side rv a-start">\n'
221
221
  f' <div class="fig" style="text-align:center"><div class="fig__cap">构成占比</div>\n'
222
222
  f'{chart_svg(ch, i, "donut")} </div>\n'
223
- ' <div class="stack gap-4"><h3 class="t-h3">数值图例(禁止叠在弧上)</h3>\n'
223
+ ' <div class="stack gap-4"><h3 class="t-h3">构成明细</h3>\n'
224
224
  ' <ul class="ul">' + ''.join(legend) + '</ul>\n'
225
225
  ' </div>\n </div>\n')
226
226
  return wrap(i, 'donut', body, sec)
@@ -59,6 +59,7 @@ UI = TPL_DIR / 'ui.js'
59
59
  TEMPLATES = [TPL_DIR / 'presentation.html',
60
60
  TPL_DIR / 'research.html',
61
61
  TPL_DIR / 'architecture.html']
62
+ EXAMPLES = sorted((ROOT / 'assets' / 'examples').glob('*.html')) if (ROOT / 'assets' / 'examples').is_dir() else []
62
63
  BJ = ROOT / 'scripts' / 'build_pptx.js'
63
64
  EM = ROOT / 'scripts' / 'extract_model.py'
64
65
 
@@ -336,14 +337,19 @@ def main() -> int:
336
337
  ui_repl = ('/* __TOPPPT_UI_START__ */\n'
337
338
  '/* ══ TopPPT HTML 公共 UI 脚本(assets/templates/ui.js 的内联副本 · 由 scripts/sync_runtime.py 注入,禁止手改) ══ */\n'
338
339
  + ui_js + '\n/* __TOPPPT_UI_END__ */')
340
+ # 运行时同版本戳:对注入源 assets/pptx-export.js(rstrip 后)取 sha256[:16],
341
+ # 写入模板内联块,供 validate_report 比对防「模板未 sync」漂移。
342
+ runtime_sha = hashlib.sha256(runtime.encode('utf-8')).hexdigest()[:16]
339
343
  runtime_repl = ('/* __TOPPPT_RUNTIME_START__ */\n'
340
344
  '/* ══ PPTX 导出运行时(assets/pptx-export.js 的内联副本 · 由 scripts/sync_runtime.py 注入,禁止手改) ══ */\n'
345
+ f'/* __TOPPPT_RUNTIME_SHA__:{runtime_sha} */\n'
341
346
  + runtime + '\n/* __TOPPPT_RUNTIME_END__ */')
342
347
 
343
- # ③ 注入三份模式模板
344
- for tpl in TEMPLATES:
348
+ # ③ 注入三份模式模板 + 黄金样张(样张也内联运行时,须同戳)
349
+ inject_targets = list(TEMPLATES) + list(EXAMPLES)
350
+ for tpl in inject_targets:
345
351
  if not tpl.exists():
346
- print(f'警告: 模板不存在 {tpl}')
352
+ print(f'警告: 模板/样张不存在 {tpl}')
347
353
  ok = False
348
354
  continue
349
355
  ok = inject(tpl, ENGINE_PAT, engine_repl, '__TOPPPT_ENGINE__') and ok
@@ -649,7 +655,7 @@ process.exit(bad ? 1 : 0);
649
655
  print(f' model-schema.json sha256[:16] = {ms_digest} · 页型 {n_schema_pt} 种')
650
656
  print(f' 风格 token {n_styles} 套 · 三模式独立比例尺 {len([k for k in lc["modeTypeScale"] if not k.startswith("$")])} 套 · 页型几何 {n_pt} 组')
651
657
  print(f' 图表登记 {n_charts} 种(原生 {n_native} / 形状 {n_shape}) · 语义字阶 {n_typo} 级 · 12 列网格 {lc["grid"]["columns"]} 列 · 布局 IR {n_slots} 页型')
652
- print(' 已刷新: assets/pptx-export.js(常量块 + schema 块) · assets/style-gallery.html(常量块) · templates/{presentation,research,architecture}.html(引擎/UI/运行时内联副本)')
658
+ print(' 已刷新: assets/pptx-export.js(常量块 + schema 块) · assets/style-gallery.html(常量块) · templates/{presentation,research,architecture}.html + assets/examples/*.html(引擎/UI/运行时内联副本,含 __TOPPPT_RUNTIME_SHA__)')
653
659
  print(f' 双端单源引用校验: {"PASS" if ok else "WARN(build_pptx 应 require layout-constants.json;extract_model 应读 model-schema.json;页型四件套与图表登记须完整)"}')
654
660
  print(f' 说明: 页型几何 {n_pt} 组(多页型共享几何组) · schema 页型 {n_schema_pt} 种')
655
661
  return 0
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Regression gates for usage-feedback defects (validator + static engine checks)."""
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ import sys
8
+ from pathlib import Path
9
+ from xml.etree import ElementTree as ET
10
+
11
+ ROOT = Path(__file__).resolve().parent
12
+ sys.path.insert(0, str(ROOT))
13
+ import validate_pptx as V # noqa: E402
14
+
15
+ EMU = 914400
16
+ NS = V.NS
17
+ fails: list[str] = []
18
+
19
+
20
+ def ok(name: str, cond: bool, detail: str = "") -> None:
21
+ status = "PASS" if cond else "FAIL"
22
+ print(f" [{status}] {name}" + (f" — {detail}" if detail and not cond else ""))
23
+ if not cond:
24
+ fails.append(name)
25
+
26
+
27
+ def test_shape_bounds_reads_p_xfrm() -> None:
28
+ xml = (
29
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
30
+ f'<p:graphicFrame xmlns:a="{NS["a"]}" xmlns:p="{NS["p"]}">'
31
+ "<p:xfrm>"
32
+ f'<a:off x="{int(0.6 * EMU)}" y="{int(2.5 * EMU)}"/>'
33
+ f'<a:ext cx="{int(8.5 * EMU)}" cy="{int(3.5 * EMU)}"/>'
34
+ "</p:xfrm>"
35
+ "</p:graphicFrame>"
36
+ )
37
+ el = ET.fromstring(xml)
38
+ box = V.shape_bounds(el)
39
+ ok("1 shape_bounds reads p:xfrm", box is not None and box[2] > 0, f"got {box}")
40
+
41
+
42
+ def test_shape_bounds_still_reads_a_xfrm() -> None:
43
+ xml = (
44
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
45
+ f'<p:sp xmlns:a="{NS["a"]}" xmlns:p="{NS["p"]}">'
46
+ "<p:spPr><a:xfrm>"
47
+ f'<a:off x="{EMU}" y="{EMU}"/>'
48
+ f'<a:ext cx="{2 * EMU}" cy="{EMU}"/>'
49
+ "</a:xfrm></p:spPr></p:sp>"
50
+ )
51
+ el = ET.fromstring(xml)
52
+ box = V.shape_bounds(el)
53
+ ok("1b shape_bounds reads a:xfrm", box is not None and box[0] == EMU)
54
+
55
+
56
+ def _text_shape(paras, x=0.6, y=2.5, w=4.0, h=0.6):
57
+ parts = []
58
+ for text, pt in paras:
59
+ parts.append(
60
+ f'<a:p><a:r><a:rPr sz="{int(pt * 100)}"/><a:t>{text}</a:t></a:r></a:p>'
61
+ )
62
+ xml = (
63
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
64
+ f'<p:sp xmlns:a="{NS["a"]}" xmlns:p="{NS["p"]}">'
65
+ "<p:spPr><a:xfrm>"
66
+ f'<a:off x="{int(x * EMU)}" y="{int(y * EMU)}"/>'
67
+ f'<a:ext cx="{int(w * EMU)}" cy="{int(h * EMU)}"/>'
68
+ "</a:xfrm></p:spPr>"
69
+ f'<p:txBody>{"".join(parts)}</p:txBody></p:sp>'
70
+ )
71
+ return ET.fromstring(xml)
72
+
73
+
74
+ def test_text_overflow_vertical() -> None:
75
+ paras = [(f"这是第{i}段足够长的中文内容用来累计行高", 12.0) for i in range(8)]
76
+ shape = _text_shape(paras, h=0.55)
77
+ issues = V.text_overflow_vertical_check(shape, 1)
78
+ codes = [i["code"] for i in issues]
79
+ ok(
80
+ "2 TEXT_OVERFLOW_VERTICAL fires on multi-segment cumulative",
81
+ "TEXT_OVERFLOW_VERTICAL" in codes,
82
+ f"codes={codes}",
83
+ )
84
+
85
+
86
+ def test_annotation_band_overlap() -> None:
87
+ xml = (
88
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
89
+ f'<p:sp xmlns:a="{NS["a"]}" xmlns:p="{NS["p"]}">'
90
+ "<p:spPr><a:xfrm>"
91
+ f'<a:off x="{int(0.6 * EMU)}" y="{int(5.5 * EMU)}"/>'
92
+ f'<a:ext cx="{int(4 * EMU)}" cy="{int(1.4 * EMU)}"/>'
93
+ "</a:xfrm></p:spPr></p:sp>"
94
+ )
95
+ el = ET.fromstring(xml)
96
+ issues = V.annotation_band_overlap_check([el], 1, int(7.5 * EMU))
97
+ codes = [i["code"] for i in issues]
98
+ ok(
99
+ "3 ANNOTATION_BAND_OVERLAP fires when content crushes band",
100
+ "ANNOTATION_BAND_OVERLAP" in codes,
101
+ f"codes={codes}",
102
+ )
103
+
104
+
105
+ def test_font_size_not_snapped() -> None:
106
+ xml = (
107
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
108
+ f'<p:sld xmlns:a="{NS["a"]}" xmlns:p="{NS["p"]}">'
109
+ "<p:cSld><p:spTree><p:sp><p:txBody>"
110
+ '<a:p><a:r><a:rPr sz="1130"/><a:t>OffLadderSampleTextHere</a:t></a:r></a:p>'
111
+ "</p:txBody></p:sp></p:spTree></p:cSld></p:sld>"
112
+ )
113
+ root = ET.fromstring(xml)
114
+ issues = V.font_size_snap_check(root, 1)
115
+ codes = [i["code"] for i in issues]
116
+ ok(
117
+ "4 FONT_SIZE_NOT_SNAPPED fires on off-ladder size",
118
+ "FONT_SIZE_NOT_SNAPPED" in codes,
119
+ f"codes={codes}",
120
+ )
121
+
122
+
123
+ def test_engine_static() -> None:
124
+ export = (ROOT.parent / "assets" / "pptx-export.js").read_text(encoding="utf-8")
125
+ build = (ROOT.parent / "scripts" / "build_pptx.js").read_text(encoding="utf-8")
126
+
127
+ m = re.search(r"function vbarShapes\([\s\S]*?\n\}", export)
128
+ body = m.group(0) if m else ""
129
+ ok("B vbarShapes handles ec.series", "ec.series" in body and "series.forEach" in body)
130
+
131
+ ok(
132
+ "C A-channel streamgraph reserves legend inside plot",
133
+ "Reserve legend INSIDE" in export and "plotBottom" in export,
134
+ )
135
+ ok(
136
+ "C B-channel infoStreamgraph reserves legend inside plot",
137
+ "Reserve legend INSIDE" in build and "plotBottom" in build,
138
+ )
139
+
140
+ ok(
141
+ "D A-channel caption uses capYImg from imageLayoutShapes",
142
+ "capYImg" in export
143
+ and re.search(r"capYImg\s*=\s*imageLayoutShapes", export) is not None,
144
+ )
145
+ ok(
146
+ "D B-channel caption uses capYImg from imageLayoutShapes",
147
+ "capYImg" in build
148
+ and re.search(r"capYImg\s*=\s*imageLayoutShapes", build) is not None,
149
+ )
150
+
151
+ cards = re.search(
152
+ r"else if \(type === 'cards'\) \{[\s\S]*?else if \(type === 'split'\)",
153
+ export,
154
+ )
155
+ cards_body = cards.group(0) if cards else ""
156
+ ok(
157
+ "A A-channel cards uses sz: N object props (not bare sz())",
158
+ "sz: 12" in cards_body
159
+ and not re.search(r"(?<![\w.])sz\s*\(\s*12\s*\)", cards_body),
160
+ )
161
+ ok(
162
+ "A modeSize applied in txSp serialization",
163
+ "function modeSize" in export and "modeSize(" in export,
164
+ )
165
+
166
+
167
+ def main() -> int:
168
+ print("feedback-gates regression")
169
+ test_shape_bounds_reads_p_xfrm()
170
+ test_shape_bounds_still_reads_a_xfrm()
171
+ test_text_overflow_vertical()
172
+ test_annotation_band_overlap()
173
+ test_font_size_not_snapped()
174
+ test_engine_static()
175
+ print(f"\n{len(fails)} failed" if fails else "\nAll feedback gates PASS")
176
+ return 1 if fails else 0
177
+
178
+
179
+ if __name__ == "__main__":
180
+ sys.exit(main())
@@ -108,6 +108,9 @@ STRICT_FAILURE_CODES = {
108
108
  "UNBALANCED_EMPTY_SPACE",
109
109
  "UNJUSTIFIED_LARGE_IMAGE",
110
110
  "TEXT_OVERFLOW_ESTIMATE",
111
+ "TEXT_OVERFLOW_VERTICAL",
112
+ "ANNOTATION_BAND_OVERLAP",
113
+ "FONT_SIZE_NOT_SNAPPED",
111
114
  "TEXT_INCOMPLETE",
112
115
  "CHART_SKEW_INVALID",
113
116
  "CHART_OVERSIZE",
@@ -207,9 +210,17 @@ def slide_chart_text(archive: zipfile.ZipFile, slide_name: str) -> str:
207
210
 
208
211
 
209
212
  def shape_bounds(element: ET.Element) -> tuple[int, int, int, int] | None:
213
+ """Read shape geometry from DrawingML ``a:xfrm`` **or** PresentationML ``p:xfrm``.
214
+
215
+ Native charts live in ``p:graphicFrame``, which carries ``p:xfrm`` (not ``a:xfrm``).
216
+ Skipping ``p:xfrm`` made chart overflow / overlap gates blind.
217
+ """
210
218
  xfrm = element.find(".//a:xfrm", NS)
219
+ if xfrm is None:
220
+ xfrm = element.find(".//p:xfrm", NS)
211
221
  if xfrm is None:
212
222
  return None
223
+ # p:xfrm and a:xfrm both nest a:off / a:ext
213
224
  offset = xfrm.find("a:off", NS)
214
225
  extent = xfrm.find("a:ext", NS)
215
226
  if offset is None or extent is None:
@@ -298,6 +309,147 @@ def text_overflow_check(shape: ET.Element, slide_no: int) -> list[dict[str, Any]
298
309
  return out
299
310
 
300
311
 
312
+ def text_overflow_vertical_check(shape: ET.Element, slide_no: int) -> list[dict[str, Any]]:
313
+ """Multi-segment cumulative vertical overflow (TEXT_OVERFLOW_VERTICAL).
314
+
315
+ Per-paragraph area checks miss the case where each line fits individually but
316
+ the *sum* of estimated line heights exceeds the text-frame height.
317
+ """
318
+ out: list[dict[str, Any]] = []
319
+ box = shape_bounds(shape)
320
+ if box is None:
321
+ return out
322
+ _, _, cx, cy = box
323
+ w_in, h_in = cx / 914400.0, cy / 914400.0
324
+ if w_in <= 0 or h_in <= 0:
325
+ return out
326
+ cfg = _containers_cfg()
327
+ lf = float(cfg.get("lineFactor") or 1.35)
328
+ total_h = 0.0
329
+ segments = 0
330
+ for para in shape.findall(".//a:p", NS):
331
+ line = "".join((t.text or "") for t in para.findall(".//a:t", NS))
332
+ if not line.strip():
333
+ continue
334
+ sizes = font_sizes_pt(para)
335
+ fz = min(sizes) if sizes else 12.0
336
+ est_w = est_text_width_in(line, fz)
337
+ lines_needed = max(1, int((est_w / max(w_in, 0.01)) + 0.999))
338
+ total_h += lines_needed * (fz / 72.0) * lf
339
+ segments += 1
340
+ # Single-segment cases already covered by TEXT_OVERFLOW_ESTIMATE; this gate
341
+ # targets multi-para cumulative overflow (tolerance mirrors horizontal gate).
342
+ if segments >= 2 and total_h > h_in * TEXT_OVERFLOW_TOLERANCE:
343
+ out.append(issue(
344
+ "TEXT_OVERFLOW_VERTICAL",
345
+ f"多段文本累计高度估算 {total_h:.2f}in > 文本框 {h_in:.2f}in"
346
+ f"({segments} 段,容差 {TEXT_OVERFLOW_TOLERANCE:.0%})——"
347
+ "请拆段/缩字号阶梯/换页,禁止静默截断。",
348
+ slide=slide_no,
349
+ ))
350
+ return out
351
+
352
+
353
+ def annotation_band_overlap_check(
354
+ elements: list[ET.Element],
355
+ slide_no: int,
356
+ slide_height_emu: int,
357
+ ) -> list[dict[str, Any]]:
358
+ """Detect primary content crushing into the annotation band.
359
+
360
+ Annotation band ≈ [contentBottomWithNote, contentBottom] (default 6.4–6.9in).
361
+ Shapes that start in the body and extend into the band (e.g. streamgraph
362
+ legends stacked below the plot) fire ANNOTATION_BAND_OVERLAP.
363
+ """
364
+ out: list[dict[str, Any]] = []
365
+ try:
366
+ lc_path = Path(__file__).resolve().parent / "layout-constants.json"
367
+ lc = json.loads(lc_path.read_text(encoding="utf-8"))
368
+ lay = ((lc.get("pageTypes") or {}).get("layout") or {})
369
+ band_top_in = float(lay.get("contentBottomWithNote") or 6.4)
370
+ band_bot_in = float(lay.get("contentBottom") or 6.9)
371
+ except (OSError, json.JSONDecodeError, TypeError, ValueError):
372
+ band_top_in, band_bot_in = 6.4, 6.9
373
+ band_top = int(band_top_in * 914400)
374
+ band_bot = int(band_bot_in * 914400)
375
+ # Ignore footer/page-number chrome near the very bottom
376
+ pager_floor = int(min(band_bot_in + 0.05, slide_height_emu / 914400.0 - 0.05) * 914400)
377
+ min_overlap = int(0.08 * 914400) # 0.08in
378
+ for el in elements:
379
+ box = shape_bounds(el)
380
+ if box is None:
381
+ continue
382
+ x, y, cx, cy = box
383
+ if cx <= 0 or cy <= 0:
384
+ continue
385
+ bottom = y + cy
386
+ # Legitimate annotation/note content sits entirely inside the band
387
+ if y >= band_top - int(0.02 * 914400):
388
+ continue
389
+ # Page chrome (pager) — tiny shapes near bottom edge
390
+ if y >= pager_floor:
391
+ continue
392
+ overlap = min(bottom, band_bot) - max(y, band_top)
393
+ if overlap >= min_overlap and bottom > band_top:
394
+ out.append(issue(
395
+ "ANNOTATION_BAND_OVERLAP",
396
+ f"主内容侵入注释带(元素底边 {bottom/914400:.2f}in 越过注释带顶 "
397
+ f"{band_top_in:.2f}in,重叠 {overlap/914400:.2f}in)——"
398
+ "图例/系列请收入主图区或压缩系列数,禁止压进 so-what/来源行。",
399
+ slide=slide_no,
400
+ ))
401
+ # One finding per slide is enough to gate
402
+ break
403
+ return out
404
+
405
+
406
+ def font_size_snap_check(root: ET.Element, slide_no: int) -> list[dict[str, Any]]:
407
+ """Font sizes must sit on the fontShrink ladder (0.5pt snap grid).
408
+
409
+ fitFont already selects from the ladder; this gate catches callers that
410
+ arithmetic-shift sizes (e.g. fz-1) or hardcode off-ladder values without
411
+ re-snapping through the ladder / modeSize path.
412
+ """
413
+ out: list[dict[str, Any]] = []
414
+ try:
415
+ lc_path = Path(__file__).resolve().parent / "layout-constants.json"
416
+ lc = json.loads(lc_path.read_text(encoding="utf-8"))
417
+ ladder = ((lc.get("containers") or {}).get("fontShrink") or {}).get("ladder") or []
418
+ allowed = {round(float(v), 2) for v in ladder}
419
+ except (OSError, json.JSONDecodeError, TypeError, ValueError):
420
+ allowed = {15, 14, 13.5, 13, 12.5, 12, 11.5, 11, 10.5, 10, 9.5, 9, 8.5}
421
+ # Also allow common display sizes used by cover/hero (not on shrink ladder)
422
+ allowed |= {44, 36, 30, 28, 24, 22, 20, 18, 16, 8.0, 7.5}
423
+ alien: list[float] = []
424
+ for run in root.findall(".//a:r", NS):
425
+ text = "".join(t.text or "" for t in run.findall("a:t", NS))
426
+ if not text.strip():
427
+ continue
428
+ pr = run.find("a:rPr", NS)
429
+ raw = pr.get("sz") if pr is not None else None
430
+ if raw is None:
431
+ continue
432
+ try:
433
+ pt = int(raw) / 100.0
434
+ except (TypeError, ValueError):
435
+ continue
436
+ if round(pt, 2) not in allowed and abs(pt * 2 - round(pt * 2)) > 0.01:
437
+ alien.append(pt)
438
+ elif round(pt, 2) not in allowed:
439
+ # On 0.5 grid but not on declared ladder / display set — still flag
440
+ # when far from any allowed value (>0.26pt)
441
+ if min(abs(pt - a) for a in allowed) > 0.26:
442
+ alien.append(pt)
443
+ if alien:
444
+ out.append(issue(
445
+ "FONT_SIZE_NOT_SNAPPED",
446
+ f"页内出现未对齐字号阶梯的字号 {sorted(set(round(v,2) for v in alien))[:8]}——"
447
+ "fitFont/modeSize 须回落到 containers.fontShrink.ladder(或封面展示档)。",
448
+ slide=slide_no,
449
+ ))
450
+ return out
451
+
452
+
301
453
  @lru_cache(maxsize=1)
302
454
  def _containers_cfg() -> dict[str, Any]:
303
455
  """容器内边距与锚点容差(单源 scripts/layout-constants.json 的 containers / anchorTolerance)。"""
@@ -628,8 +780,13 @@ def inspect_slide(
628
780
 
629
781
  for shape in shapes:
630
782
  warnings.extend(text_overflow_check(shape, slide_number))
783
+ warnings.extend(text_overflow_vertical_check(shape, slide_number))
631
784
  warnings.extend(container_overflow_check(shape, slide_number))
632
785
 
786
+ warnings.extend(annotation_band_overlap_check(
787
+ [*shapes, *pictures, *graphic_frames], slide_number, height))
788
+ warnings.extend(font_size_snap_check(root, slide_number))
789
+
633
790
  for table in tables:
634
791
  warnings.extend(table_checks(table, slide_number))
635
792
 
@@ -28,6 +28,8 @@ research / architecture 仍须显式传 --layout-qa(不强制)。
28
28
  import sys
29
29
  import re
30
30
  import json
31
+ import hashlib
32
+ from typing import Optional
31
33
  from html.parser import HTMLParser
32
34
  from pathlib import Path
33
35
 
@@ -433,8 +435,22 @@ def _check_type_features(txt, chk, model):
433
435
  f"{len(missing)} 处: {missing[:4]}" if missing else "", level="WARN")
434
436
 
435
437
 
438
+ RUNTIME_SHA_RE = re.compile(r'/\* __TOPPPT_RUNTIME_SHA__:([0-9a-f]{16}) \*/')
439
+
440
+
441
+ def _pptx_export_source_sha() -> Optional[str]:
442
+ """当前技能包 assets/pptx-export.js 的 sha256[:16](与 sync_runtime 注入口径一致)。"""
443
+ pe = Path(__file__).resolve().parent.parent / 'assets' / 'pptx-export.js'
444
+ try:
445
+ body = pe.read_text(encoding='utf-8').rstrip('\n')
446
+ except OSError:
447
+ return None
448
+ return hashlib.sha256(body.encode('utf-8')).hexdigest()[:16]
449
+
450
+
436
451
  def _check_pptx_export(txt, chk):
437
- """PPTX 预览配套:有预览按钮就必须有内容模型 + 预览运行时;页面不得残留导出按钮。"""
452
+ """PPTX 预览配套:有预览按钮就必须有内容模型 + 预览运行时;页面不得残留导出按钮。
453
+ 另:内联运行时必须带 __TOPPPT_RUNTIME_SHA__ 且与当前 pptx-export.js 同源。"""
438
454
  has_btn = 'id="pptPreviewBtn"' in txt
439
455
  has_model = 'REPORT_MODEL' in txt
440
456
  has_runtime = ('g.TopPptHtml = api' in txt) and ('function slidesXml' in txt)
@@ -445,6 +461,20 @@ def _check_pptx_export(txt, chk):
445
461
  else:
446
462
  chk("含 PPTX 预览按钮与内容模型(建议保留)",
447
463
  has_model and has_runtime, "未集成预览按钮/模型", level="WARN")
464
+ # 同版本戳:有运行时块时强制核对(缺戳或漂移 = FAIL)
465
+ if has_runtime or '/* __TOPPPT_RUNTIME_START__ */' in txt:
466
+ m = RUNTIME_SHA_RE.search(txt)
467
+ expected = _pptx_export_source_sha()
468
+ if not m:
469
+ chk("运行时同版本戳(__TOPPPT_RUNTIME_SHA__)", False,
470
+ "内联运行时缺戳;请跑 scripts/sync_runtime.py")
471
+ elif expected is None:
472
+ chk("运行时同版本戳(__TOPPPT_RUNTIME_SHA__)", False,
473
+ "无法读取 assets/pptx-export.js 计算源哈希")
474
+ else:
475
+ got = m.group(1)
476
+ chk("运行时同版本戳(__TOPPPT_RUNTIME_SHA__)", got == expected,
477
+ f"stamp={got} ≠ source={expected}(模板未 sync 或源已改)")
448
478
  chk("页面无 PPTX 导出按钮(仅预览 + 提示词)",
449
479
  'id="pptxBtn"' not in txt and 'id="pptDownload"' not in txt,
450
480
  "残留导出按钮 pptxBtn/pptDownload", level="WARN")