@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.
- package/CHANGELOG.md +61 -0
- package/package.json +1 -1
- package/top-ppt-html/README.md +38 -130
- package/top-ppt-html/SKILL.md +34 -19
- package/top-ppt-html/assets/theme-overview-architecture.png +0 -0
- package/top-ppt-html/assets/theme-overview-research.png +0 -0
- package/top-ppt-html/assets/theme-overview.png +0 -0
- package/top-ppt-html/package.json +1 -1
- package/top-ppt-html/references/charts-discipline.md +3 -1
- package/top-ppt-html/references/charts.md +24 -15
- package/top-ppt-html/references/content-rules.md +54 -34
- package/top-ppt-html/references/default-surface.md +61 -0
- package/top-ppt-html/references/design-system.md +2 -2
- package/top-ppt-html/references/failure-modes.md +14 -1
- package/top-ppt-html/references/icons.md +56 -277
- package/top-ppt-html/references/layout-grammar.md +16 -6
- package/top-ppt-html/references/modes.md +16 -11
- package/top-ppt-html/references/playbook.md +31 -13
- package/top-ppt-html/references/pptx-export.md +20 -2
- package/top-ppt-html/references/presentation-craft.md +69 -0
- package/top-ppt-html/references/tech-design.md +5 -5
- package/top-ppt-html/scripts/audit_skill.py +13 -2
- package/top-ppt-html/scripts/build_examples.py +39 -2260
- package/top-ppt-html/scripts/build_pptx.js +6 -0
- package/top-ppt-html/scripts/checks_html.py +39 -0
- package/top-ppt-html/scripts/cross_verify.py +5 -1
- package/top-ppt-html/scripts/extract_snippet.py +2 -2
- package/top-ppt-html/scripts/layout-constants.json +111 -18
- package/top-ppt-html/scripts/lib_layout_regions.js +7 -9
- package/top-ppt-html/scripts/negative_tests.py +127 -1
- package/top-ppt-html/scripts/package_skill.py +13 -10
- package/top-ppt-html/scripts/quality_gate.py +11 -3
- package/top-ppt-html/scripts/recommend_layout.py +384 -0
- package/top-ppt-html/scripts/regression.py +1 -1
- package/top-ppt-html/scripts/smoke_pptx.sh +30 -0
- package/top-ppt-html/scripts/sync_runtime.py +22 -25
- package/top-ppt-html/scripts/validate_pptx.py +69 -0
- package/top-ppt-html/scripts/validate_report.py +309 -10
- package/top-ppt-html/assets/examples/2026-09-09-architecture-spectrum.html +0 -3926
- package/top-ppt-html/assets/examples/2026-09-09-architecture-spectrum.model.json +0 -168
- package/top-ppt-html/assets/examples/2026-09-09-presentation-apple-mono.html +0 -4325
- package/top-ppt-html/assets/examples/2026-09-09-presentation-apple-mono.model.json +0 -321
- package/top-ppt-html/assets/examples/2026-09-09-presentation-brand-red.html +0 -4325
- package/top-ppt-html/assets/examples/2026-09-09-presentation-brand-red.model.json +0 -321
- package/top-ppt-html/assets/examples/2026-09-09-research-deep-teal.html +0 -5527
- package/top-ppt-html/assets/examples/2026-09-09-research-deep-teal.model.json +0 -914
- package/top-ppt-html/assets/examples/2026-09-09-research-indigo-violet.html +0 -5527
- package/top-ppt-html/assets/examples/2026-09-09-research-indigo-violet.model.json +0 -914
- package/top-ppt-html/assets/examples/2026-09-09-research-warm-sand.html +0 -5527
- package/top-ppt-html/assets/examples/2026-09-09-research-warm-sand.model.json +0 -914
- package/top-ppt-html/references/design-system-engine.md +0 -235
- package/top-ppt-html/references/industry-benchmark.md +0 -105
- package/top-ppt-html/references/reform-plan.md +0 -252
- 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) + "
|
|
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
|
-
|
|
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 = (
|
|
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(
|
|
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)
|