@topmindspace/tms-skills 0.1.1 → 0.1.2

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 (54) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/package.json +1 -1
  3. package/top-ppt-html/README.md +38 -130
  4. package/top-ppt-html/SKILL.md +34 -19
  5. package/top-ppt-html/assets/theme-overview-architecture.png +0 -0
  6. package/top-ppt-html/assets/theme-overview-research.png +0 -0
  7. package/top-ppt-html/assets/theme-overview.png +0 -0
  8. package/top-ppt-html/package.json +1 -1
  9. package/top-ppt-html/references/charts-discipline.md +3 -1
  10. package/top-ppt-html/references/charts.md +24 -15
  11. package/top-ppt-html/references/content-rules.md +54 -34
  12. package/top-ppt-html/references/default-surface.md +61 -0
  13. package/top-ppt-html/references/design-system.md +2 -2
  14. package/top-ppt-html/references/failure-modes.md +14 -1
  15. package/top-ppt-html/references/icons.md +56 -277
  16. package/top-ppt-html/references/layout-grammar.md +16 -6
  17. package/top-ppt-html/references/modes.md +16 -11
  18. package/top-ppt-html/references/playbook.md +31 -13
  19. package/top-ppt-html/references/pptx-export.md +20 -2
  20. package/top-ppt-html/references/presentation-craft.md +69 -0
  21. package/top-ppt-html/references/tech-design.md +5 -5
  22. package/top-ppt-html/scripts/audit_skill.py +13 -2
  23. package/top-ppt-html/scripts/build_examples.py +39 -2260
  24. package/top-ppt-html/scripts/build_pptx.js +6 -0
  25. package/top-ppt-html/scripts/checks_html.py +39 -0
  26. package/top-ppt-html/scripts/cross_verify.py +5 -1
  27. package/top-ppt-html/scripts/extract_snippet.py +2 -2
  28. package/top-ppt-html/scripts/layout-constants.json +111 -18
  29. package/top-ppt-html/scripts/lib_layout_regions.js +7 -9
  30. package/top-ppt-html/scripts/negative_tests.py +127 -1
  31. package/top-ppt-html/scripts/package_skill.py +13 -10
  32. package/top-ppt-html/scripts/quality_gate.py +11 -3
  33. package/top-ppt-html/scripts/recommend_layout.py +384 -0
  34. package/top-ppt-html/scripts/regression.py +1 -1
  35. package/top-ppt-html/scripts/smoke_pptx.sh +30 -0
  36. package/top-ppt-html/scripts/sync_runtime.py +22 -25
  37. package/top-ppt-html/scripts/validate_pptx.py +69 -0
  38. package/top-ppt-html/scripts/validate_report.py +309 -10
  39. package/top-ppt-html/assets/examples/2026-09-09-architecture-spectrum.html +0 -3926
  40. package/top-ppt-html/assets/examples/2026-09-09-architecture-spectrum.model.json +0 -168
  41. package/top-ppt-html/assets/examples/2026-09-09-presentation-apple-mono.html +0 -4325
  42. package/top-ppt-html/assets/examples/2026-09-09-presentation-apple-mono.model.json +0 -321
  43. package/top-ppt-html/assets/examples/2026-09-09-presentation-brand-red.html +0 -4325
  44. package/top-ppt-html/assets/examples/2026-09-09-presentation-brand-red.model.json +0 -321
  45. package/top-ppt-html/assets/examples/2026-09-09-research-deep-teal.html +0 -5527
  46. package/top-ppt-html/assets/examples/2026-09-09-research-deep-teal.model.json +0 -914
  47. package/top-ppt-html/assets/examples/2026-09-09-research-indigo-violet.html +0 -5527
  48. package/top-ppt-html/assets/examples/2026-09-09-research-indigo-violet.model.json +0 -914
  49. package/top-ppt-html/assets/examples/2026-09-09-research-warm-sand.html +0 -5527
  50. package/top-ppt-html/assets/examples/2026-09-09-research-warm-sand.model.json +0 -914
  51. package/top-ppt-html/references/design-system-engine.md +0 -235
  52. package/top-ppt-html/references/industry-benchmark.md +0 -105
  53. package/top-ppt-html/references/reform-plan.md +0 -252
  54. package/top-ppt-html/scripts/layout_slots.json +0 -830
@@ -3,10 +3,12 @@
3
3
  """
4
4
  TopPPT HTML· HTML 报告质量校验(交付闭环)
5
5
  用法:
6
- python validate_report.py <报告.html> [--strict] [--json]
6
+ python validate_report.py <报告.html> [--strict] [--layout-qa] [--json]
7
7
 
8
8
  输出每项 PASS/FAIL,结尾给汇总与结论;任一 FAIL 时退出码为 1(--strict 时 WARN 也计失败)。
9
9
  --json 以 JSON 输出全部检查结果(供脚本/流水线读取)。
10
+ Mode A / presentation + --strict:自动启用 --layout-qa(V 契约/截断/溢出未拆页/半空卡/对齐);
11
+ research / architecture 仍须显式传 --layout-qa(不强制)。
10
12
  生成流程:生成 → 跑本脚本 → 修复 FAIL → 再跑,直至全部 PASS 才交付。
11
13
 
12
14
  阈值全部来自单源 scripts/layout-constants.json(checkBudgets / charts / styleAccents / aiFlavor),
@@ -218,7 +220,12 @@ CARRIERS = checks_html.CARRIERS # 单源 scripts/checks_html.py
218
220
 
219
221
 
220
222
  def _check_chart_variety(txt, chk, mode):
221
- """图表与版式多样性(阈值单源 charts.variety;判定逻辑 checks_html)。"""
223
+ """图表与版式多样性(阈值单源 charts.variety;判定逻辑 checks_html)。
224
+
225
+ 多样性仍是特性:全部 data-chart(含 advanced)计入下限——用 advanced 不罚。
226
+ preferCoreFirst:核图多样性不足却靠 advanced 撑场时 WARN(勿凑下限)。
227
+ 禁反模式由 layout-qa / sizeByComplexity / CHART_SKEW 另检。
228
+ """
222
229
  v = CHART_VARIETY
223
230
  if not v:
224
231
  return
@@ -230,8 +237,14 @@ def _check_chart_variety(txt, chk, mode):
230
237
  n_chart_pages = len([1 for ts in per_page if ts])
231
238
 
232
239
  floor = checks_html.variety_floor(mode, n_chart_pages, v)
240
+ # advanced 与核图一并计入 distinct(不因用 advanced 受罚)
233
241
  chk("图表多样性(全篇不同 data-chart 类型数)", len(distinct) >= floor,
234
242
  f"{len(distinct)} 种 / 下限 {floor}({n_chart_pages} 个图表页): {distinct}")
243
+ # preferCoreFirst WARN 仅 A/B:C 架构以结构/甘特等 advanced 为主属正常,不噪音
244
+ if mode in ('presentation', 'research'):
245
+ pref = checks_html.variety_core_preference(used, v)
246
+ if pref:
247
+ chk("图表多样性·核图优先(preferCoreFirst)", False, pref, level="WARN")
235
248
 
236
249
  if v.get('noRepeatAdjacent'):
237
250
  adj = checks_html.adjacent_same_type(per_page)
@@ -323,7 +336,7 @@ def _check_bands(txt, chk, mode="presentation"):
323
336
  if est_h > SCREEN_BUDGET_PX * 1.08:
324
337
  est_over.append(f"第{i}页≈{est_h:.0f}px")
325
338
  chk(f"页高溢出估算(字×行高+组件 ≤ ~{SCREEN_BUDGET_PX}px/屏 · {mode})", not est_over,
326
- ("; ".join(est_over) + "(按「列表化/精炼→压缩→多列→拆页」处理,或给长结构页加 band--flow)")
339
+ ("; ".join(est_over) + "(按「重构承载→拆页/分章→换形态→有限 fontShrink」;禁静默截断;或长结构页加 band--flow)")
327
340
  if est_over else "")
328
341
 
329
342
  bad_grids = 0
@@ -888,6 +901,45 @@ def _check_v9_hard_gates(txt, chk, model):
888
901
  not oversize, "; ".join(oversize[:4]) if oversize else "")
889
902
 
890
903
 
904
+
905
+ def _class_tokens(tag_or_html: str) -> set[str]:
906
+ """Extract HTML class tokens from class="..." attributes."""
907
+ out: set[str] = set()
908
+ for m in re.finditer(r'class="([^"]*)"', tag_or_html):
909
+ out.update(m.group(1).split())
910
+ return out
911
+
912
+
913
+ def _mixed_grid_needs_align(band: str) -> bool:
914
+ """True when a grid/g-* region pairs media with cards/lists (token-exact; no t-metric / data-chart false hits)."""
915
+ def has_media(classes: set[str], chunk: str) -> bool:
916
+ if classes & {'fig', 'media', 'chart'}:
917
+ return True
918
+ if any(c.startswith('media') or c.startswith('fig') for c in classes):
919
+ return True
920
+ return 'data-chart=' in chunk
921
+
922
+ def has_cards(classes: set[str]) -> bool:
923
+ if classes & {'card', 'ul', 'metric'}:
924
+ return True
925
+ return any(
926
+ c.startswith('ul--') or c.startswith('card') or c.startswith('metric__')
927
+ for c in classes)
928
+
929
+ # Find each grid opening and inspect following chunk
930
+ for m in re.finditer(r'<div class="([^"]*)"', band):
931
+ classes_on_grid = set(m.group(1).split())
932
+ if not (classes_on_grid & {'grid'} or any(re.fullmatch(r'g-\d+', c) or c.startswith('g-') for c in classes_on_grid)):
933
+ # allow g-hero / g-side / g-2 etc
934
+ if not any(c == 'grid' or c.startswith('g-') for c in classes_on_grid):
935
+ continue
936
+ chunk = band[m.start(): m.start() + 4500]
937
+ classes = _class_tokens(chunk)
938
+ if has_media(classes, chunk) and has_cards(classes):
939
+ return True
940
+ return False
941
+
942
+
891
943
  def _check_layout_grammar(txt, chk, mode):
892
944
  """布局语法门禁:骨架类 / 单一重心 / 混排对齐 / 间距 token / 图标尺寸 / 标签防换行。"""
893
945
  LS = LC.get('layoutSystem') or {}
@@ -920,7 +972,10 @@ def _check_layout_grammar(txt, chk, mode):
920
972
 
921
973
  for i, b in enumerate(_bands(txt), 1):
922
974
  head = b[:240]
923
- if any(f'id="{x}' in head for x in ('refs', 'appendix', 'cover', 'agenda', 'quote')):
975
+ # intentional whitespace:封面/章节幕/金句/收尾等休止页不参与 FILL 门禁
976
+ if any(f'id="{x}' in head for x in (
977
+ 'refs', 'appendix', 'cover', 'agenda', 'quote',
978
+ 'closing', 'section', 'chapter', 'next')):
924
979
  continue
925
980
  if 'band--flow' in head:
926
981
  continue
@@ -956,11 +1011,10 @@ def _check_layout_grammar(txt, chk, mode):
956
1011
  if n_big >= 2:
957
1012
  multi_focus.append(f"第{i}页大件×{n_big}")
958
1013
 
959
- # ③ 混排对齐(同时有图/媒体与卡/列表)
960
- mixed = (re.search(r'class="[^"]*(?:fig|media|chart)', b) and
961
- re.search(r'class="[^"]*(?:card|ul|metric)', b))
1014
+ # ③ 混排对齐(grid 内同时有图/媒体与卡/列表;token 精确)
1015
+ mixed = _mixed_grid_needs_align(b)
962
1016
  if mixed and mixed_need:
963
- if not any(t in b for t in align_tokens):
1017
+ if not any(re.search(r'\b' + re.escape(tok) + r'\b', b) for tok in align_tokens):
964
1018
  align_miss.append(f"第{i}页图卡混排缺 a-start/a-c")
965
1019
 
966
1020
  # ④ 间距写死(margin/padding/gap 非 token / 非 clamp;≤8px 微调白名单)
@@ -1233,11 +1287,227 @@ def _print_fix_guide(results, strict, width):
1233
1287
  print(f" 取码: python scripts/extract_snippet.py {cmd}")
1234
1288
  else:
1235
1289
  print(" 未匹配到已登记的失败模式,按通用顺序处置:")
1236
- print(" ① 补内容 ② 优化形态(列表化/精炼)③ 换承载形态 "
1237
- "④ 调容器/网格 ⑤ 有限缩字号 ⑥ 最后拆页")
1290
+ print(" ① 重构承载(列表/卡/表/图)② 拆页/分章 ③ 换布局形态 "
1291
+ "④ 有限缩字号 ⑤ 禁止静默截断/砍 so-what")
1238
1292
  print(" 完整失败模式库与错误解释纠正表: references/failure-modes.md")
1239
1293
 
1240
1294
 
1295
+
1296
+ def _check_layout_qa(txt, chk, mode, model):
1297
+ """布局 QA(--layout-qa;presentation+--strict 自动开):
1298
+ V 契约 / 极偏环图 / 骨架连用 / 缺 data-skel / 简单全幅 /
1299
+ 截断迹象 / 溢出未拆页 / 半空卡 / 列对齐节奏。
1300
+ """
1301
+ bands = _bands(txt)
1302
+ skip_ids = {'cover', 'agenda', 'refs', 'appendix', 'next', 'quote'}
1303
+ content = []
1304
+ for i, b in enumerate(bands, 1):
1305
+ head = b[:240]
1306
+ if any(f'id="{sid}"' in head for sid in skip_ids):
1307
+ continue
1308
+ if 'band--deep' in head or 'band--accent' in head:
1309
+ # 金句/强调带允许无骨架
1310
+ if 'data-skel=' not in b and not re.search(r'class="[^"]*\bP\d+\b', b):
1311
+ continue
1312
+ content.append((i, b, head))
1313
+
1314
+ # ① 缺 data-skel:若全文已出现 data-skel(scaffold/render 管线),则内容页必须都有
1315
+ has_any_skel = 'data-skel="' in txt
1316
+ missing = []
1317
+ if has_any_skel:
1318
+ for i, b, head in content:
1319
+ if 'data-skel="' not in b and not re.search(r'class="[^"]*\bP\d+\b', b):
1320
+ missing.append(f"第{i}页")
1321
+ chk("LAYOUT_QA_MISSING_SKEL 内容页须带 data-skel(管线产物)",
1322
+ not missing,
1323
+ ("缺骨架: " + "; ".join(missing[:5])) if missing else
1324
+ ("(全文无 data-skel,跳过——手写示例豁免;scaffold/render 会写入)" if not has_any_skel else ""))
1325
+
1326
+ # ② 连续 ≥3 页同一 data-skel(或同一 layout 签名)
1327
+ skels = []
1328
+ for i, b, head in content:
1329
+ m = re.search(r'data-skel="(P\d+)"', b)
1330
+ if m:
1331
+ skels.append((i, m.group(1)))
1332
+ else:
1333
+ sig = _layout_sig(b)
1334
+ if sig:
1335
+ skels.append((i, f"sig:{sig}"))
1336
+ streak_hits = []
1337
+ run_v, run_s, run_n = None, 0, 0
1338
+ for i, v in skels:
1339
+ if v == run_v:
1340
+ run_n += 1
1341
+ else:
1342
+ if run_v and run_n >= 3:
1343
+ streak_hits.append(f"{run_v}×{run_n}(起第{run_s}页)")
1344
+ run_v, run_s, run_n = v, i, 1
1345
+ if run_v and run_n >= 3:
1346
+ streak_hits.append(f"{run_v}×{run_n}(起第{run_s}页)")
1347
+ chk("LAYOUT_QA_SKEL_STREAK 同一 data-skel/版式签名连续 <3 页",
1348
+ not streak_hits, "; ".join(streak_hits[:3]) if streak_hits else "")
1349
+
1350
+ # ③ 极偏仍用 donut/pie(模型侧;与 CHART_SKEW 互补,专打 layout-qa 关键字)
1351
+ skew_hits = []
1352
+ sections = (model or {}).get('sections') or []
1353
+ for si, sec in enumerate(sections, 1):
1354
+ if not isinstance(sec, dict):
1355
+ continue
1356
+ ch = sec.get('chart') if isinstance(sec.get('chart'), dict) else {}
1357
+ ctype = str(ch.get('type') or (sec.get('type') if sec.get('type') in
1358
+ ('donut', 'pie', 'multidonut') else '') or '').lower()
1359
+ if sec.get('type') == 'donut':
1360
+ ctype = 'donut'
1361
+ if ctype not in ('donut', 'pie', 'multidonut'):
1362
+ continue
1363
+ vals = [float(v) for v in (ch.get('values') or []) if isinstance(v, (int, float))]
1364
+ vals = [v for v in vals if v >= 0]
1365
+ if len(vals) < 2:
1366
+ continue
1367
+ total = sum(vals) or 1.0
1368
+ pcts = [v / total * 100 for v in vals]
1369
+ mn, mx = min(pcts), max(pcts)
1370
+ ratio = (mx / mn) if mn > 0 else 999
1371
+ if mn < 5.0 or ratio > 20:
1372
+ skew_hits.append(f"sections[{si}] {ctype} 最小{mn:.1f}% 比{ratio:.0f}:1 → 改 V3/KPI")
1373
+ chk("LAYOUT_QA_SKEW_DONUT 极偏占比禁用 donut/pie(改 V3 KPI)",
1374
+ not skew_hits, "; ".join(skew_hits[:3]) if skew_hits else "")
1375
+
1376
+ # ④ 演示模式:简单图(≤2 类)却近全幅(svg height≥320 或 width:100% 无注解带)
1377
+ bleed_hits = []
1378
+ if mode == 'presentation':
1379
+ for i, b, head in content:
1380
+ for m in re.finditer(
1381
+ r'<svg\b[^>]*data-chart="([^"]+)"[^>]*style="([^"]*)"[^>]*>', b):
1382
+ ctype, style = m.group(1), m.group(2)
1383
+ svg_end = b.find('</svg>', m.end())
1384
+ block = b[m.start():svg_end if svg_end > 0 else m.end() + 600]
1385
+ n_lab = len(re.findall(r'<text\b', block))
1386
+ n_pts = max(n_lab, len(re.findall(r'<rect\b', block)),
1387
+ len(re.findall(r'<circle\b', block)))
1388
+ hm = re.search(r'height:\s*(\d+(?:\.\d+)?)px', style)
1389
+ h_px = float(hm.group(1)) if hm else 0
1390
+ simple = n_lab <= 2 or n_pts <= 3
1391
+ full = h_px >= 320 or ('width:100%' in style and h_px >= 240)
1392
+ has_anno = bool(re.search(r'class="[^"]*(?:sowhat|anno|points|callout)', b))
1393
+ if simple and full and not has_anno:
1394
+ bleed_hits.append(f"第{i}页 {ctype} 简单全幅无注解")
1395
+ chk("LAYOUT_QA_SIMPLE_FULLBLEED 演示页简单图禁全幅无注解(用 V1–V4)",
1396
+ not bleed_hits, "; ".join(bleed_hits[:3]) if bleed_hits else "")
1397
+
1398
+ # ⑤ V 契约:演示页 data-v / data-skel 与内容复杂度粗检
1399
+ v_hits = []
1400
+ if mode == 'presentation' and sections:
1401
+ for si, sec in enumerate(sections, 1):
1402
+ if not isinstance(sec, dict):
1403
+ continue
1404
+ pt = str(sec.get('type') or '')
1405
+ if pt in ('cover', 'agenda', 'closing', 'quote'):
1406
+ continue
1407
+ ch = sec.get('chart') if isinstance(sec.get('chart'), dict) else {}
1408
+ vals = ch.get('values') or []
1409
+ n = len(vals) if vals else len(sec.get('points') or sec.get('items') or [])
1410
+ skel = str(sec.get('layoutPreset') or '')
1411
+ # 极偏却仍 donut
1412
+ if pt == 'donut' or ch.get('type') in ('donut', 'pie'):
1413
+ if vals:
1414
+ nums = [float(v) for v in vals if isinstance(v, (int, float)) and v >= 0]
1415
+ if len(nums) >= 2:
1416
+ total = sum(nums) or 1
1417
+ pcts = [v / total * 100 for v in nums]
1418
+ if min(pcts) < 5:
1419
+ v_hits.append(f"sections[{si}] 极偏仍 {pt or ch.get('type')}(应 V3/kpi)")
1420
+ # 简单 1–2 点却声明 V1 全幅主视觉(layoutPreset P1 + 简单)
1421
+ if skel == 'P1' and n <= 2 and (pt in ('bar', 'donut') or ch.get('type')):
1422
+ v_hits.append(f"sections[{si}] P1+简单{n}点(应 V3/P3 或加注解)")
1423
+ chk("LAYOUT_QA_V_CONTRACT 演示 V1–V4 与复杂度匹配",
1424
+ not v_hits, "; ".join(v_hits[:3]) if v_hits else "")
1425
+
1426
+ # ⑥ 截断迹象:正文/列表项以省略号截断充数(antiTruncation)
1427
+ at = (LC.get('qualityGates') or {}).get('antiTruncation') or {}
1428
+ trunc_hits = []
1429
+ if at.get('forbidEllipsisTruncate', True):
1430
+ patterns = list(at.get('ellipsisPatterns') or ['…', '...', '……'])
1431
+ for i, b, head in content:
1432
+ plain_parts = re.findall(r'<(?:li|p)[^>]*>([\s\S]*?)</(?:li|p)>', b)
1433
+ plain_parts += re.findall(
1434
+ r'class="[^"]*(?:card__b|sowhat__v|point)[^"]*"[^>]*>([\s\S]*?)</',
1435
+ b)
1436
+ for raw in plain_parts:
1437
+ plain = _plain(raw).strip()
1438
+ if len(plain) < 8:
1439
+ continue
1440
+ for pat in patterns:
1441
+ if plain.endswith(pat) or plain.endswith(pat + '。'):
1442
+ # 排除「等…」短收口
1443
+ if re.search(r'等[…\.]{1,3}$', plain) and len(plain) <= 16:
1444
+ continue
1445
+ trunc_hits.append(f"第{i}页「{plain[:18]}」")
1446
+ break
1447
+ chk("LAYOUT_QA_TRUNCATION 禁静默截断(列表/卡/结论勿以省略号砍义)",
1448
+ not trunc_hits, "; ".join(trunc_hits[:4]) if trunc_hits else "")
1449
+
1450
+ # ⑦ 溢出却无拆页策略:页高估算超预算,且无 band--flow / 续页标记 / 多部分 title
1451
+ overflow_hits = []
1452
+ if at.get('overflowNoSplitFail', True):
1453
+ B = MODE_BUDGETS.get(mode, MODE_BUDGETS['presentation'])
1454
+ fs_px = int(B.get('bodyPx') or 17)
1455
+ wrap_px = int(B.get('wrap') or 1400) - WRAP_INSET_PX
1456
+ cpl = max(10, int(wrap_px / fs_px))
1457
+ for i, b, head in content:
1458
+ if 'band--flow' in head:
1459
+ continue
1460
+ # 已有拆页/续页信号则豁免
1461
+ if re.search(r'(续|续表|01[ab]|02[ab]|part\s*[12]|跟进|详见下页)', b, re.I):
1462
+ continue
1463
+ b_clean = re.sub(r'<script\b[\s\S]*?</script>', ' ', b)
1464
+ b_clean = re.sub(r'<style\b[\s\S]*?</style>', ' ', b_clean)
1465
+ plen = len(_plain(b_clean).strip())
1466
+ u = _band_heavy_units(b)
1467
+ est_h = plen / cpl * fs_px * LINE_FACTOR + u * UNIT_PX + SHEAD_PX
1468
+ if est_h > SCREEN_BUDGET_PX * 1.08:
1469
+ overflow_hits.append(f"第{i}页≈{est_h:.0f}px 无拆页/换形态信号")
1470
+ chk("LAYOUT_QA_OVERFLOW_NO_SPLIT 溢出须拆页/换形态(禁硬塞)",
1471
+ not overflow_hits, "; ".join(overflow_hits[:3]) if overflow_hits else "")
1472
+
1473
+ # ⑧ 半空卡 vs 塞爆:同页多卡时过半卡正文过短
1474
+ he = (LC.get('qualityGates') or {}).get('halfEmpty') or {}
1475
+ half_hits = []
1476
+ min_cards = int(he.get('minCards') or 3)
1477
+ short_n = int(he.get('shortPlainChars') or 12)
1478
+ max_ratio = float(he.get('maxShortRatio') or 0.5)
1479
+ for i, b, head in content:
1480
+ cards = re.findall(r'class="[^"]*\bcard\b[^"]*"[^>]*>([\s\S]*?)(?=<div class="[^"]*\bcard\b|</section>|$)', b)
1481
+ if len(cards) < min_cards:
1482
+ # also count .card blocks via simpler split
1483
+ cards = re.split(r'class="[^"]*\bcard\b', b)[1:]
1484
+ if len(cards) < min_cards:
1485
+ continue
1486
+ shorts = 0
1487
+ for c in cards:
1488
+ plen = len(_plain(c[:800]).strip())
1489
+ if plen < short_n:
1490
+ shorts += 1
1491
+ if shorts / max(len(cards), 1) > max_ratio and shorts >= 2:
1492
+ half_hits.append(f"第{i}页半空卡 {shorts}/{len(cards)}")
1493
+ chk("LAYOUT_QA_HALF_EMPTY 半空卡过多(与塞爆同样不合格)",
1494
+ not half_hits, "; ".join(half_hits[:3]) if half_hits else "",
1495
+ level="WARN")
1496
+
1497
+ # ⑨ 列对齐节奏:同页多个 grid 混排却完全无对齐 token(加强 LAYOUT_ALIGN)
1498
+ align_cfg = (LC.get('layoutSystem') or {}).get('align') or {}
1499
+ rhythm_hits = []
1500
+ if align_cfg.get('requireColumnRhythm', True):
1501
+ tokens = set(align_cfg.get('mixedGridClasses') or ['a-start', 'a-c', 'a-end'])
1502
+ for i, b, head in content:
1503
+ mixed = _mixed_grid_needs_align(b)
1504
+ has_align = any(re.search(r'\b' + re.escape(tok) + r'\b', b) for tok in tokens)
1505
+ if mixed and not has_align:
1506
+ rhythm_hits.append(f"第{i}页混排缺对齐类")
1507
+ chk("LAYOUT_QA_ALIGN_RHYTHM 混排列节奏/共享对齐类",
1508
+ not rhythm_hits, "; ".join(rhythm_hits[:3]) if rhythm_hits else "")
1509
+
1510
+
1241
1511
  def main():
1242
1512
  if len(sys.argv) < 2:
1243
1513
  print(__doc__)
@@ -1245,6 +1515,9 @@ def main():
1245
1515
  path = Path(sys.argv[1])
1246
1516
  strict = '--strict' in sys.argv
1247
1517
  as_json = '--json' in sys.argv
1518
+ layout_qa = '--layout-qa' in sys.argv
1519
+ # Mode A / presentation:--strict 隐含 --layout-qa(正式演示交付不漏 V 契约)
1520
+ # research/architecture 不自动开启,避免污染密排/架构路径
1248
1521
  if not path.exists():
1249
1522
  print(f"文件不存在: {path}")
1250
1523
  return 2
@@ -1278,6 +1551,8 @@ def main():
1278
1551
  f"读到 {mode!r}(未声明按 presentation 处理)" if mode not in MODE_BUDGETS else "",
1279
1552
  level="WARN" if mode is None else "FAIL")
1280
1553
  mode = mode if mode in MODE_BUDGETS else 'presentation'
1554
+ if strict and mode == 'presentation' and not layout_qa:
1555
+ layout_qa = True # presentation + --strict → 自动 layout-qa
1281
1556
 
1282
1557
  # ── 宽屏与页面高度模型 ──
1283
1558
  B0 = MODE_BUDGETS[mode]
@@ -1400,9 +1675,33 @@ def main():
1400
1675
  chk("research 行动标题(章节主标题 ≥12 字,标题即结论)", not short,
1401
1676
  f"过短: {short[:3]}" if short else "", level="WARN")
1402
1677
 
1678
+ if mode == 'presentation':
1679
+ # Mode A:主张/行动句标题(action title);纯话题标签 WARN(硬 FAIL 过脆)
1680
+ STRUCT_A = ('报告大纲', '大纲', '议程', 'Agenda', '参考资料', '下一步', '结论',
1681
+ '封面', '目录', '附录', '谢谢', 'Thank', 'Q&A', '问答')
1682
+ TOPIC_ONLY = (
1683
+ '现状分析', '市场格局', '风险与挑战', '背景介绍', '项目概述', '总结',
1684
+ '概览', '概述', '简介', '背景', '方案', '规划', '进展', '回顾',
1685
+ '分析', '对比', '数据', '附录', '下一步计划', '内容', '主题',
1686
+ )
1687
+ h1s_a = re.findall(r'<h2 class="t-h1 shead__title"[^>]*>(.*?)</h2>', txt)
1688
+ titles_a = [re.sub(r'<[^>]+>', '', h).strip() for h in h1s_a]
1689
+ topic_hits = []
1690
+ for h in titles_a:
1691
+ if not h or any(h.startswith(s) or h == s for s in STRUCT_A):
1692
+ continue
1693
+ # 纯话题:命中话题词表,或极短且无判断/数字/动词痕迹
1694
+ if h in TOPIC_ONLY or (len(h) <= 6 and not re.search(
1695
+ r'\d|是|应|须|将|已|要|可|能|达|超|降|升|破|卡|成|未|无|有', h)):
1696
+ topic_hits.append(h)
1697
+ chk("presentation 主张/行动标题(禁纯话题标签)", not topic_hits,
1698
+ f"话题式: {topic_hits[:4]}" if topic_hits else "", level="WARN")
1699
+
1403
1700
  _check_content_quality(txt, chk, mode, model)
1404
1701
  _check_v9_hard_gates(txt, chk, model)
1405
1702
  _check_layout_grammar(txt, chk, mode)
1703
+ if layout_qa:
1704
+ _check_layout_qa(txt, chk, mode, model)
1406
1705
 
1407
1706
  # ── 去AI味(词表来自单源) ──
1408
1707
  body_plain = _plain(body_txt)