@topmindspace/tms-skills 0.1.2 → 0.1.4

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 (34) hide show
  1. package/CHANGELOG.md +46 -1
  2. package/README.md +14 -4
  3. package/bin/tms-skills.js +7 -2
  4. package/package.json +2 -2
  5. package/top-ppt-html/README.md +40 -27
  6. package/top-ppt-html/SKILL.md +20 -16
  7. package/top-ppt-html/agents/openai.yaml +5 -0
  8. package/top-ppt-html/assets/pptx-export.js +69 -25
  9. package/top-ppt-html/assets/style-gallery.html +1 -1
  10. package/top-ppt-html/assets/templates/architecture.html +69 -25
  11. package/top-ppt-html/assets/templates/presentation.html +69 -25
  12. package/top-ppt-html/assets/templates/research.html +69 -25
  13. package/top-ppt-html/evals/trigger-queries.json +181 -0
  14. package/top-ppt-html/package.json +4 -3
  15. package/top-ppt-html/references/chart-decision-tree.md +42 -0
  16. package/top-ppt-html/references/default-surface.md +3 -2
  17. package/top-ppt-html/references/illustration-layout.md +39 -0
  18. package/top-ppt-html/references/modes.md +1 -1
  19. package/top-ppt-html/references/outline-design.md +24 -0
  20. package/top-ppt-html/references/page-type-matrix.md +35 -0
  21. package/top-ppt-html/references/playbook.md +28 -89
  22. package/top-ppt-html/references/pptx-export.md +1 -1
  23. package/top-ppt-html/references/presentation-craft.md +11 -2
  24. package/top-ppt-html/references/tech-design.md +6 -0
  25. package/top-ppt-html/scripts/audit_docs.py +1 -1
  26. package/top-ppt-html/scripts/audit_skill.py +43 -4
  27. package/top-ppt-html/scripts/build_pptx.js +24 -10
  28. package/top-ppt-html/scripts/check_triggers.py +153 -0
  29. package/top-ppt-html/scripts/negative_tests.py +10 -0
  30. package/top-ppt-html/scripts/package_skill.py +17 -1
  31. package/top-ppt-html/scripts/quality_gate.py +30 -19
  32. package/top-ppt-html/scripts/test_feedback_gates.py +180 -0
  33. package/top-ppt-html/scripts/validate_pptx.py +157 -0
  34. package/top-ppt-html/scripts/validate_report.py +27 -0
@@ -87,6 +87,7 @@ INCLUDE = [
87
87
  'references/*',
88
88
  'scripts/*',
89
89
  'evals/*',
90
+ 'agents/*',
90
91
  ]
91
92
 
92
93
  # 出包规则(命中即跳过;与 .gitignore 口径一致)
@@ -120,6 +121,9 @@ REQUIRED = [
120
121
  'references/layout-grammar.md',
121
122
  'references/default-surface.md',
122
123
  'references/presentation-craft.md',
124
+ 'references/illustration-layout.md',
125
+ 'references/page-type-matrix.md',
126
+ 'references/chart-decision-tree.md',
123
127
  'references/modes.md', 'references/outline-design.md', 'references/design-system.md',
124
128
  'references/styles.md', 'references/content-rules.md', 'references/components.md',
125
129
  'references/charts.md',
@@ -145,13 +149,15 @@ REQUIRED = [
145
149
  'references/infographics-stats.md', 'references/infographics-structure.md',
146
150
  'evals/prompts.csv', 'evals/run_evals.py', 'evals/rubric.schema.json',
147
151
  'evals/trace.example.json',
152
+ 'evals/trigger-queries.json',
153
+ 'scripts/check_triggers.py',
148
154
  ]
149
155
 
150
156
  # 必须达到最小数量的集合(名称含日期,故按数量校验)
151
157
  MIN_COUNTS = {
152
158
  'assets/examples/*.html': 3, # Batch3 每模式 1 份黄金样张
153
159
  'assets/examples/*.model.json': 3,
154
- 'references/*.md': 22, # craft 轮后含 default-surface + presentation-craft;≥22 防误删
160
+ 'references/*.md': 25, # + page-type-matrix + chart-decision-tree;≥25 防误删
155
161
  }
156
162
 
157
163
 
@@ -255,6 +261,16 @@ def main():
255
261
  return 1
256
262
  print(' 校验通过:必需文件齐全 · frontmatter 合规 · 无临时/缓存路径')
257
263
 
264
+ # P0-1 trigger heuristic gate(廉价、无 LLM)
265
+ trig = ROOT / 'scripts' / 'check_triggers.py'
266
+ if trig.exists():
267
+ import subprocess
268
+ r = subprocess.run([sys.executable, str(trig)], cwd=str(ROOT))
269
+ if r.returncode != 0:
270
+ print(' ✗ trigger coverage 失败(evals/trigger-queries.json)')
271
+ return 1
272
+ print(' ✓ trigger coverage 通过')
273
+
258
274
  if check_only:
259
275
  print('\n(--check 模式:仅校验,不打包)')
260
276
  return 0
@@ -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)
@@ -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
 
@@ -1215,6 +1215,33 @@ def _check_media(txt, chk, model):
1215
1215
  chk("模型配图占位 ↔ 正文 .media--ph 对应", False,
1216
1216
  f"{n_ph} 页模型声明 image.placeholder 但正文无 .media--ph", level="WARN")
1217
1217
 
1218
+ # 配图页 caption / so-what(廉价 WARN);近邻主张/导语亦可
1219
+ bands = re.split(r'(?=<section\b)', txt)
1220
+ miss_cap = []
1221
+ near_empty = []
1222
+ for i, b in enumerate(bands):
1223
+ if not re.search(r'class="[^"]*\bmedia\b|<img\b|class="[^"]*media--', b):
1224
+ continue
1225
+ if len(b) < 80:
1226
+ continue
1227
+ has_cap = bool(re.search(
1228
+ r'class="[^"]*(?:fig__cap|media__cap|media__ph|caption|exhibit__src|footnote|so-what|lead)', b)
1229
+ or re.search(r'<figcaption\b|class="[^"]*\bsoWhat\b', b))
1230
+ if not has_cap:
1231
+ miss_cap.append(f'band[{i}]')
1232
+ # 近图过空:有 media 但正文文本极少且无要点/指标(WARN)
1233
+ textish = re.sub(r'<script[\s\S]*?</script>|<style[\s\S]*?</style>|<[^>]+>', ' ', b)
1234
+ textish = re.sub(r'\s+', ' ', textish).strip()
1235
+ has_points = bool(re.search(r'class="[^"]*(?:points|bullets|kpi|metrics|card)', b))
1236
+ if len(textish) < 40 and not has_points and not re.search(r'media--ph', b):
1237
+ near_empty.append(f'band[{i}]')
1238
+ chk("配图页含 caption/图注(IMAGE_CAPTION)", not miss_cap,
1239
+ f"{len(miss_cap)} 处配图区缺 .fig__cap/.media__cap/.footnote/so-what 等图注" if miss_cap else "",
1240
+ level="WARN")
1241
+ chk("配图页近邻过空(IMAGE_NEAR_EMPTY)", not near_empty,
1242
+ f"{len(near_empty)} 处配图页几乎无注解/要点(补 caption 或要点条)" if near_empty else "",
1243
+ level="WARN")
1244
+
1218
1245
 
1219
1246
  # 失败检查项 → 失败模式 / 处置动作 / 精确取码命令。
1220
1247
  # 目的:校验失败时直接给出「改什么、按什么顺序改、去哪取代码」,