@topmindspace/tms-skills 0.1.4 → 0.1.6

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.
@@ -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
+ # 失败时打印短摘要(错误码/页码/消息),完整 JSON 落盘到 TMPDIR。
5
6
  set -euo pipefail
6
7
  ROOT="$(cd "$(dirname "$0")/.." && pwd)"
7
8
  cd "$ROOT"
@@ -15,6 +16,7 @@ OUT_DIR="${TMPDIR:-/tmp}/top-ppt-html-smoke"
15
16
  mkdir -p "$OUT_DIR"
16
17
  MODEL="$OUT_DIR/${STEM}.model.json"
17
18
  PPTX="$OUT_DIR/${STEM}.pptx"
19
+ REPORT="$OUT_DIR/${STEM}.validate.json"
18
20
 
19
21
  echo "== smoke_pptx =="
20
22
  echo "HTML $HTML"
@@ -26,5 +28,42 @@ export NODE_PATH="${ROOT}/node_modules${NODE_PATH:+:$NODE_PATH}"
26
28
  node scripts/build_pptx.js "$PPTX" --model="$MODEL"
27
29
  echo "PPTX $PPTX ($(wc -c < "$PPTX") bytes)"
28
30
 
29
- python3 scripts/validate_pptx.py "$PPTX" --strict --model="$MODEL"
30
- echo "smoke_pptx: OK"
31
+ set +e
32
+ python3 scripts/validate_pptx.py "$PPTX" --strict --model="$MODEL" \
33
+ --json-out="$REPORT" >"$OUT_DIR/${STEM}.validate.stdout.json"
34
+ rc=$?
35
+ set -e
36
+
37
+ if [[ "$rc" -ne 0 ]]; then
38
+ echo "smoke_pptx: VALIDATE FAIL (exit $rc)" >&2
39
+ python3 - "$REPORT" "$rc" <<'PY'
40
+ import json, sys
41
+ path, rc = sys.argv[1], sys.argv[2]
42
+ try:
43
+ d = json.loads(open(path, encoding="utf-8").read())
44
+ except Exception as e:
45
+ print(f" (could not read report {path}: {e})", file=sys.stderr)
46
+ sys.exit(int(rc))
47
+ errs = d.get("errors") or []
48
+ warns = d.get("warnings") or []
49
+ print(f" summary: errors={len(errs)} warnings={len(warns)} slides={d.get('summary',{}).get('slide_count')}", file=sys.stderr)
50
+ seen = set()
51
+ for e in errs + warns:
52
+ key = (e.get("code"), e.get("slide"), e.get("message"))
53
+ if key in seen:
54
+ continue
55
+ seen.add(key)
56
+ slide = e.get("slide")
57
+ slide_s = f"slide={slide} " if slide is not None else ""
58
+ msg = (e.get("message") or "")[:160]
59
+ print(f" [{e.get('code')}] {slide_s}{msg}", file=sys.stderr)
60
+ if len(seen) >= 40:
61
+ print(f" … truncated ({len(errs)+len(warns)} total findings)", file=sys.stderr)
62
+ break
63
+ print(f" full JSON: {path}", file=sys.stderr)
64
+ sys.exit(int(rc))
65
+ PY
66
+ fi
67
+
68
+ echo "smoke_pptx: OK (0 errors / 0 warnings)"
69
+ exit 0
@@ -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
@@ -102,6 +102,95 @@ def test_annotation_band_overlap() -> None:
102
102
  )
103
103
 
104
104
 
105
+ def test_annotation_band_ignores_full_bleed_bg() -> None:
106
+ """Full-slide background must not false-positive as band crush."""
107
+ xml = (
108
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
109
+ f'<p:sp xmlns:a="{NS["a"]}" xmlns:p="{NS["p"]}">'
110
+ "<p:spPr><a:xfrm>"
111
+ f'<a:off x="0" y="0"/>'
112
+ f'<a:ext cx="{int(13.333 * EMU)}" cy="{int(7.5 * EMU)}"/>'
113
+ "</a:xfrm></p:spPr></p:sp>"
114
+ )
115
+ el = ET.fromstring(xml)
116
+ issues = V.annotation_band_overlap_check(
117
+ [el], 1, int(7.5 * EMU), int(13.333 * EMU),
118
+ )
119
+ codes = [i["code"] for i in issues]
120
+ ok(
121
+ "3b full-bleed background does not fire ANNOTATION_BAND_OVERLAP",
122
+ "ANNOTATION_BAND_OVERLAP" not in codes,
123
+ f"codes={codes}",
124
+ )
125
+
126
+
127
+ def test_annotation_band_allows_content_without_note() -> None:
128
+ """Without so-what/source, content may use up to contentBottom (~6.9)."""
129
+ xml = (
130
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
131
+ f'<p:sp xmlns:a="{NS["a"]}" xmlns:p="{NS["p"]}">'
132
+ "<p:spPr><a:xfrm>"
133
+ f'<a:off x="{int(0.6 * EMU)}" y="{int(5.5 * EMU)}"/>'
134
+ f'<a:ext cx="{int(4 * EMU)}" cy="{int(1.0 * EMU)}"/>'
135
+ "</a:xfrm></p:spPr>"
136
+ "<p:txBody><a:p><a:r><a:t>AgendaCard</a:t></a:r></a:p></p:txBody>"
137
+ "</p:sp>"
138
+ )
139
+ el = ET.fromstring(xml)
140
+ issues = V.annotation_band_overlap_check([el], 1, int(7.5 * EMU))
141
+ codes = [i["code"] for i in issues]
142
+ ok(
143
+ "3c content to 6.50 without annotation does not fire",
144
+ "ANNOTATION_BAND_OVERLAP" not in codes,
145
+ f"codes={codes}",
146
+ )
147
+
148
+
149
+ def test_annotation_band_fires_when_note_present() -> None:
150
+ """With so-what bar present, body overlapping the withNote band must fire."""
151
+ def sp(y, h, w, text):
152
+ return ET.fromstring(
153
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
154
+ f'<p:sp xmlns:a="{NS["a"]}" xmlns:p="{NS["p"]}">'
155
+ "<p:spPr><a:xfrm>"
156
+ f'<a:off x="{int(0.6 * EMU)}" y="{int(y * EMU)}"/>'
157
+ f'<a:ext cx="{int(w * EMU)}" cy="{int(h * EMU)}"/>'
158
+ "</a:xfrm></p:spPr>"
159
+ f"<p:txBody><a:p><a:r><a:t>{text}</a:t></a:r></a:p></p:txBody>"
160
+ "</p:sp>"
161
+ )
162
+ els = [
163
+ sp(5.5, 1.0, 4.0, "LegendSeries"),
164
+ sp(6.05, 0.55, 12.0, "结论 平台化是唯一路径"),
165
+ ]
166
+ issues = V.annotation_band_overlap_check(els, 1, int(7.5 * EMU), int(13.333 * EMU))
167
+ codes = [i["code"] for i in issues]
168
+ ok(
169
+ "3d body crush with so-what present fires ANNOTATION_BAND_OVERLAP",
170
+ "ANNOTATION_BAND_OVERLAP" in codes,
171
+ f"codes={codes}",
172
+ )
173
+
174
+
175
+ def test_font_size_h2_17_allowed() -> None:
176
+ """modeTypeScale h2=17 must be on the allowed set (cover/display whitelist)."""
177
+ xml = (
178
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
179
+ f'<p:sld xmlns:a="{NS["a"]}" xmlns:p="{NS["p"]}">'
180
+ "<p:cSld><p:spTree><p:sp><p:txBody>"
181
+ '<a:p><a:r><a:rPr sz="1700"/><a:t>AgendaSectionTitle</a:t></a:r></a:p>'
182
+ "</p:txBody></p:sp></p:spTree></p:cSld></p:sld>"
183
+ )
184
+ root = ET.fromstring(xml)
185
+ issues = V.font_size_snap_check(root, 1)
186
+ codes = [i["code"] for i in issues]
187
+ ok(
188
+ "4b FONT_SIZE_NOT_SNAPPED allows intentional 17pt h2",
189
+ "FONT_SIZE_NOT_SNAPPED" not in codes,
190
+ f"codes={codes}",
191
+ )
192
+
193
+
105
194
  def test_font_size_not_snapped() -> None:
106
195
  xml = (
107
196
  '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
@@ -170,6 +259,10 @@ def main() -> int:
170
259
  test_shape_bounds_still_reads_a_xfrm()
171
260
  test_text_overflow_vertical()
172
261
  test_annotation_band_overlap()
262
+ test_annotation_band_ignores_full_bleed_bg()
263
+ test_annotation_band_allows_content_without_note()
264
+ test_annotation_band_fires_when_note_present()
265
+ test_font_size_h2_17_allowed()
173
266
  test_font_size_not_snapped()
174
267
  test_engine_static()
175
268
  print(f"\n{len(fails)} failed" if fails else "\nAll feedback gates PASS")
@@ -354,27 +354,111 @@ def annotation_band_overlap_check(
354
354
  elements: list[ET.Element],
355
355
  slide_no: int,
356
356
  slide_height_emu: int,
357
+ slide_width_emu: int | None = None,
357
358
  ) -> list[dict[str, Any]]:
358
359
  """Detect primary content crushing into the annotation band.
359
360
 
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.
361
+ Geometry (layout-constants pageTypes.layout / exhibit / note):
362
+ soWhatY ≈ 6.05 · contentBottomWithNote ≈ 6.4 · footnote/note ≈ 6.55 ·
363
+ contentBottom ≈ 6.9 · pager ≈ 7.0
364
+
365
+ Semantics:
366
+ - Full-bleed backgrounds, full-height accent strips, and pager/footer
367
+ chrome are never "content crushing the band".
368
+ - so-what / 口径 / 来源 bars that live in the annotation zone are the
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).
374
+ - When no annotation is present, main content may use up to
375
+ contentBottom; only a severe invasion (≥0.35in into the nominal
376
+ withNote band) still fires as a safety net.
363
377
  """
364
378
  out: list[dict[str, Any]] = []
365
379
  try:
366
380
  lc_path = Path(__file__).resolve().parent / "layout-constants.json"
367
381
  lc = json.loads(lc_path.read_text(encoding="utf-8"))
368
382
  lay = ((lc.get("pageTypes") or {}).get("layout") or {})
383
+ exhibit = ((lc.get("pageTypes") or {}).get("exhibit") or {})
384
+ note = ((lc.get("pageTypes") or {}).get("note") or {})
369
385
  band_top_in = float(lay.get("contentBottomWithNote") or 6.4)
370
386
  band_bot_in = float(lay.get("contentBottom") or 6.9)
387
+ so_what_y_in = float(exhibit.get("soWhatY") or 6.05)
388
+ note_y_in = float(note.get("y") or exhibit.get("footnoteY") or 6.55)
371
389
  except (OSError, json.JSONDecodeError, TypeError, ValueError):
372
- band_top_in, band_bot_in = 6.4, 6.9
390
+ band_top_in, band_bot_in, so_what_y_in, note_y_in = 6.4, 6.9, 6.05, 6.55
391
+
373
392
  band_top = int(band_top_in * 914400)
374
393
  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
394
+ so_what_y = int(so_what_y_in * 914400)
395
+ note_y = int(note_y_in * 914400)
396
+ slide_w = int(slide_width_emu) if slide_width_emu else int(13.333333 * 914400)
397
+ slide_h = int(slide_height_emu)
398
+ slide_area = max(slide_w * slide_h, 1)
399
+ 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
402
+ full_bleed_area = 0.85
403
+ full_height_frac = 0.90
404
+ ann_pat = re.compile(
405
+ r"(结论|SO\s*WHAT|口径|来源|数据来源|Source\b|Note\b|footnote)",
406
+ re.IGNORECASE,
407
+ )
408
+
409
+ def _text(el: ET.Element) -> str:
410
+ return (text_content(el) or "").strip()
411
+
412
+ def _is_chrome_or_bg(y: int, cy: int, cx: int) -> bool:
413
+ if (cx * cy) / slide_area >= full_bleed_area and y <= int(0.05 * 914400):
414
+ return True
415
+ if cy >= int(full_height_frac * slide_h) and y <= int(0.05 * 914400):
416
+ return True
417
+ if y >= band_bot - int(0.02 * 914400):
418
+ return True
419
+ if y >= pager_floor:
420
+ return True
421
+ return False
422
+
423
+ def _is_annotation_self(y: int, cy: int, cx: int, text: str) -> bool:
424
+ """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):
427
+ 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):
432
+ return True
433
+ # Entirely inside the nominal withNote band
434
+ if y >= band_top - int(0.02 * 914400):
435
+ return True
436
+ return False
437
+
438
+ # Pass 1: does this slide reserve an annotation?
439
+ has_annotation = False
440
+ for el in elements:
441
+ box = shape_bounds(el)
442
+ if box is None:
443
+ continue
444
+ _x, y, cx, cy = box
445
+ if cx <= 0 or cy <= 0:
446
+ continue
447
+ if _is_chrome_or_bg(y, cy, cx):
448
+ 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
378
462
  for el in elements:
379
463
  box = shape_bounds(el)
380
464
  if box is None:
@@ -383,14 +467,15 @@ def annotation_band_overlap_check(
383
467
  if cx <= 0 or cy <= 0:
384
468
  continue
385
469
  bottom = y + cy
386
- # Legitimate annotation/note content sits entirely inside the band
387
- if y >= band_top - int(0.02 * 914400):
470
+ if _is_chrome_or_bg(y, cy, cx):
388
471
  continue
389
- # Page chrome (pager) — tiny shapes near bottom edge
390
- if y >= pager_floor:
472
+ if _is_annotation_self(y, cy, cx, _text(el)):
391
473
  continue
392
474
  overlap = min(bottom, band_bot) - max(y, band_top)
393
- if overlap >= min_overlap and bottom > band_top:
475
+ if overlap < min_overlap or bottom <= band_top:
476
+ continue
477
+ # Enforce when annotation present, or when invasion is severe anyway
478
+ if has_annotation or overlap >= severe_overlap:
394
479
  out.append(issue(
395
480
  "ANNOTATION_BAND_OVERLAP",
396
481
  f"主内容侵入注释带(元素底边 {bottom/914400:.2f}in 越过注释带顶 "
@@ -398,17 +483,21 @@ def annotation_band_overlap_check(
398
483
  "图例/系列请收入主图区或压缩系列数,禁止压进 so-what/来源行。",
399
484
  slide=slide_no,
400
485
  ))
401
- # One finding per slide is enough to gate
402
486
  break
403
487
  return out
404
488
 
405
489
 
490
+
406
491
  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).
492
+ """Font sizes must sit on the declared type scale / fontShrink ladder.
408
493
 
409
494
  fitFont already selects from the ladder; this gate catches callers that
410
495
  arithmetic-shift sizes (e.g. fz-1) or hardcode off-ladder values without
411
496
  re-snapping through the ladder / modeSize path.
497
+
498
+ Allowed set = containers.fontShrink.ladder ∪ typeScale ∪ all modeTypeScale
499
+ roles ∪ a small cover/hero display whitelist. Intentional h2 (17pt) and
500
+ other mode roles must pass; off-ladder 11.3pt etc. must still fail.
412
501
  """
413
502
  out: list[dict[str, Any]] = []
414
503
  try:
@@ -416,10 +505,25 @@ def font_size_snap_check(root: ET.Element, slide_no: int) -> list[dict[str, Any]
416
505
  lc = json.loads(lc_path.read_text(encoding="utf-8"))
417
506
  ladder = ((lc.get("containers") or {}).get("fontShrink") or {}).get("ladder") or []
418
507
  allowed = {round(float(v), 2) for v in ladder}
508
+ scale_sources: list[Any] = [lc.get("typeScale") or {}]
509
+ mts = lc.get("modeTypeScale") or {}
510
+ if isinstance(mts, dict):
511
+ scale_sources.extend(v for v in mts.values() if isinstance(v, dict))
512
+ for src in scale_sources:
513
+ if not isinstance(src, dict):
514
+ continue
515
+ for key, val in src.items():
516
+ if str(key).startswith("$"):
517
+ continue
518
+ try:
519
+ allowed.add(round(float(val), 2))
520
+ except (TypeError, ValueError):
521
+ continue
419
522
  except (OSError, json.JSONDecodeError, TypeError, ValueError):
420
523
  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}
524
+ allowed |= {44, 36, 30, 22, 19, 18, 17, 14, 13, 11, 10, 8.5}
525
+ # Cover/hero intermediate display sizes (not every mode lists every step)
526
+ allowed |= {28, 24, 22, 20, 16, 8.0, 7.5}
423
527
  alien: list[float] = []
424
528
  for run in root.findall(".//a:r", NS):
425
529
  text = "".join(t.text or "" for t in run.findall("a:t", NS))
@@ -784,7 +888,7 @@ def inspect_slide(
784
888
  warnings.extend(container_overflow_check(shape, slide_number))
785
889
 
786
890
  warnings.extend(annotation_band_overlap_check(
787
- [*shapes, *pictures, *graphic_frames], slide_number, height))
891
+ [*shapes, *pictures, *graphic_frames], slide_number, height, width))
788
892
  warnings.extend(font_size_snap_check(root, slide_number))
789
893
 
790
894
  for table in tables:
@@ -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")