@topmindspace/tms-skills 2.0.1 → 2.0.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 (98) hide show
  1. package/CHANGELOG.md +139 -134
  2. package/bin/tms-skills.js +160 -160
  3. package/package.json +48 -48
  4. package/top-ppt-html/README.md +254 -254
  5. package/top-ppt-html/SKILL.md +111 -111
  6. package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.html +3873 -3926
  7. package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.model.json +167 -167
  8. package/top-ppt-html/assets/examples/2026-09-09-architecture-spectrum.html +3873 -3926
  9. package/top-ppt-html/assets/examples/2026-09-09-architecture-spectrum.model.json +167 -167
  10. package/top-ppt-html/assets/examples/2026-09-09-presentation-apple-mono.html +4272 -4325
  11. package/top-ppt-html/assets/examples/2026-09-09-presentation-apple-mono.model.json +320 -320
  12. package/top-ppt-html/assets/examples/2026-09-09-presentation-brand-red.html +4272 -4325
  13. package/top-ppt-html/assets/examples/2026-09-09-presentation-brand-red.model.json +320 -320
  14. package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.html +4272 -4325
  15. package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.model.json +320 -320
  16. package/top-ppt-html/assets/examples/2026-09-09-research-deep-teal.html +5474 -5527
  17. package/top-ppt-html/assets/examples/2026-09-09-research-deep-teal.model.json +913 -913
  18. package/top-ppt-html/assets/examples/2026-09-09-research-indigo-violet.html +5474 -5527
  19. package/top-ppt-html/assets/examples/2026-09-09-research-indigo-violet.model.json +913 -913
  20. package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.html +5474 -5527
  21. package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.model.json +913 -913
  22. package/top-ppt-html/assets/examples/2026-09-09-research-warm-sand.html +5474 -5527
  23. package/top-ppt-html/assets/examples/2026-09-09-research-warm-sand.model.json +913 -913
  24. package/top-ppt-html/assets/pptx-export.js +1944 -1944
  25. package/top-ppt-html/assets/style-gallery.html +559 -589
  26. package/top-ppt-html/assets/templates/architecture.html +3675 -3728
  27. package/top-ppt-html/assets/templates/engine.css +787 -840
  28. package/top-ppt-html/assets/templates/presentation.html +3685 -3738
  29. package/top-ppt-html/assets/templates/research.html +3964 -4017
  30. package/top-ppt-html/assets/templates/ui.js +520 -520
  31. package/top-ppt-html/assets/theme-overview-architecture.png +0 -0
  32. package/top-ppt-html/assets/theme-overview-presentation.png +0 -0
  33. package/top-ppt-html/assets/theme-overview-research.png +0 -0
  34. package/top-ppt-html/assets/theme-overview.png +0 -0
  35. package/top-ppt-html/evals/prompts.csv +15 -15
  36. package/top-ppt-html/evals/rubric.schema.json +25 -25
  37. package/top-ppt-html/evals/run_evals.py +220 -220
  38. package/top-ppt-html/evals/trace.example.json +16 -16
  39. package/top-ppt-html/package-lock.json +175 -0
  40. package/top-ppt-html/package.json +30 -35
  41. package/top-ppt-html/references/charts-basic.md +624 -624
  42. package/top-ppt-html/references/charts-discipline.md +108 -108
  43. package/top-ppt-html/references/charts-extended.md +482 -482
  44. package/top-ppt-html/references/charts.md +28 -28
  45. package/top-ppt-html/references/components-atoms.md +624 -624
  46. package/top-ppt-html/references/components.md +30 -30
  47. package/top-ppt-html/references/content-rules.md +490 -490
  48. package/top-ppt-html/references/design-system-engine.md +235 -235
  49. package/top-ppt-html/references/design-system.md +471 -478
  50. package/top-ppt-html/references/failure-modes.md +214 -214
  51. package/top-ppt-html/references/high-fidelity.md +127 -127
  52. package/top-ppt-html/references/icons.md +397 -397
  53. package/top-ppt-html/references/industry-benchmark.md +105 -105
  54. package/top-ppt-html/references/infographics-stats.md +308 -308
  55. package/top-ppt-html/references/infographics-structure.md +226 -226
  56. package/top-ppt-html/references/infographics.md +43 -43
  57. package/top-ppt-html/references/layout-grammar.md +315 -315
  58. package/top-ppt-html/references/layouts-architecture.md +108 -108
  59. package/top-ppt-html/references/layouts-combo.md +600 -600
  60. package/top-ppt-html/references/layouts-research.md +160 -160
  61. package/top-ppt-html/references/modes.md +254 -254
  62. package/top-ppt-html/references/outline-design.md +275 -275
  63. package/top-ppt-html/references/playbook.md +266 -266
  64. package/top-ppt-html/references/pptx-export.md +209 -209
  65. package/top-ppt-html/references/reform-plan.md +252 -252
  66. package/top-ppt-html/references/styles.md +336 -370
  67. package/top-ppt-html/references/tech-design.md +138 -138
  68. package/top-ppt-html/scripts/audit_css.py +109 -109
  69. package/top-ppt-html/scripts/audit_docs.py +176 -176
  70. package/top-ppt-html/scripts/audit_skill.py +220 -220
  71. package/top-ppt-html/scripts/audit_styles.py +293 -351
  72. package/top-ppt-html/scripts/build_examples.py +2276 -2276
  73. package/top-ppt-html/scripts/build_pptx.js +2380 -2380
  74. package/top-ppt-html/scripts/capture_theme_overview.js +78 -78
  75. package/top-ppt-html/scripts/checks_html.py +127 -127
  76. package/top-ppt-html/scripts/cross_verify.py +294 -294
  77. package/top-ppt-html/scripts/env_probe.py +158 -158
  78. package/top-ppt-html/scripts/extract_model.py +210 -210
  79. package/top-ppt-html/scripts/extract_snippet.py +374 -374
  80. package/top-ppt-html/scripts/gen_channel_a.js +214 -214
  81. package/top-ppt-html/scripts/layout-constants.json +3309 -3377
  82. package/top-ppt-html/scripts/layout_slots.json +830 -830
  83. package/top-ppt-html/scripts/lib_layout_regions.js +412 -412
  84. package/top-ppt-html/scripts/measure_height.py +178 -178
  85. package/top-ppt-html/scripts/model-schema.json +547 -547
  86. package/top-ppt-html/scripts/negative_tests.py +307 -307
  87. package/top-ppt-html/scripts/package_skill.py +291 -291
  88. package/top-ppt-html/scripts/prepare_images.py +341 -341
  89. package/top-ppt-html/scripts/probe_image_export.py +188 -188
  90. package/top-ppt-html/scripts/quality_gate.py +301 -301
  91. package/top-ppt-html/scripts/regression.py +307 -308
  92. package/top-ppt-html/scripts/render_compare.py +275 -275
  93. package/top-ppt-html/scripts/render_from_model.py +698 -698
  94. package/top-ppt-html/scripts/scaffold_report.py +1054 -1054
  95. package/top-ppt-html/scripts/section-file-map.json +104 -104
  96. package/top-ppt-html/scripts/sync_runtime.py +662 -662
  97. package/top-ppt-html/scripts/validate_pptx.py +1510 -1510
  98. package/top-ppt-html/scripts/validate_report.py +1456 -1456
@@ -1,1456 +1,1456 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- """
4
- TopPPT HTML· HTML 报告质量校验(交付闭环)
5
- 用法:
6
- python validate_report.py <报告.html> [--strict] [--json]
7
-
8
- 输出每项 PASS/FAIL,结尾给汇总与结论;任一 FAIL 时退出码为 1(--strict 时 WARN 也计失败)。
9
- --json 以 JSON 输出全部检查结果(供脚本/流水线读取)。
10
- 生成流程:生成 → 跑本脚本 → 修复 FAIL → 再跑,直至全部 PASS 才交付。
11
-
12
- 阈值全部来自单源 scripts/layout-constants.json(checkBudgets / charts / styleAccents / aiFlavor),
13
- 本脚本不再内置任何风格色值或预算数字——改阈值只改 JSON。
14
-
15
- 检查面:
16
- 结构闭合 / 主题风格 / 页面高度模型 / 宽屏翻页 / Agenda(architecture 极简形态可省略) / 粗体 /
17
- 零外链 / 引用与锚点闭环 / 单一强调色 / 内联写死色 / 内容密度 / 单页文字与组件预算 /
18
- 表格行数上限 / 页高溢出估算 / 去AI味 / 图表存在与动效 / 图表 data-chart 登记 / 图表最小尺寸 /
19
- 模式版式特征(含新页型 steps/heatmap/bullet/pyramid/image 与 research/architecture 专属) /
20
- 模型一致性(REPORT_MODEL ↔ 正文) / Exhibit 编号连续 / research so-what 与来源行 /
21
- 强调带约束(band--deep 反相页 ≤ 上限且不作末页) / 待核实标注(.tbd ↔ .tbd-legend/.flagbar) /
22
- 素材图片与配图占位(零外链 + alt + 内联体积 + 版式/比例锁定类对应 + 占位可见标签 + 模型对应) /
23
- PPTX 预览配套(预览按钮 + 模型 + 运行时,页面无导出按钮) /
24
- 内容级质量(版式节奏连用上限 / so-what 实质与空洞套话 / research 标题含数字或判断词)
25
- """
26
- import sys
27
- import re
28
- import json
29
- from html.parser import HTMLParser
30
- from pathlib import Path
31
-
32
- # Windows GBK 控制台兜底:强制 UTF-8 输出(含 ↔ 等符号)
33
- try:
34
- sys.stdout.reconfigure(encoding='utf-8', errors='replace')
35
- sys.stderr.reconfigure(encoding='utf-8', errors='replace')
36
- except Exception:
37
- pass
38
-
39
- sys.path.insert(0, str(Path(__file__).resolve().parent))
40
- import checks_html # noqa: E402 承载正则 / 图表通道 / 多样性 / 组合版式单源
41
-
42
- VOID = {'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta',
43
- 'param', 'source', 'track', 'wbr'}
44
-
45
- # ── 单源阈值(scripts/layout-constants.json)────────────────────────────────
46
- _DEFAULTS = {
47
- 'checkBudgets': {
48
- 'presentation': dict(char=1500, unit=8, empty=80, fit=1000, wrap=1400, trow=8),
49
- 'research': dict(char=3200, unit=12, empty=120, fit=2200, wrap=1240, trow=16),
50
- 'architecture': dict(char=600, unit=3, empty=60, fit=600, wrap=1600, trow=6),
51
- },
52
- 'charts': {'types': [], 'minSize': {}},
53
- 'styleAccents': {},
54
- 'aiFlavor': {'words': []},
55
- 'imageSpec': {},
56
- 'contentQuality': {},
57
- }
58
-
59
-
60
- def load_constants():
61
- path = Path(__file__).resolve().parent / 'layout-constants.json'
62
- try:
63
- lc = json.loads(path.read_text(encoding='utf-8'))
64
- except (OSError, json.JSONDecodeError) as exc:
65
- print(f'警告: 无法读取单源 {path}({exc}),使用内置默认阈值。')
66
- return _DEFAULTS
67
- for key, fallback in _DEFAULTS.items():
68
- lc.setdefault(key, fallback)
69
- return lc
70
-
71
-
72
- LC = load_constants()
73
- MODE_BUDGETS = LC['checkBudgets']
74
- CHART_TYPES = set(LC['charts'].get('types') or [])
75
- CHART_MIN = LC['charts'].get('minSize') or {}
76
- # 图表多样性预算(治「登记 36 种、实际只用 bar/line/donut」)
77
- CHART_VARIETY = LC['charts'].get('variety') or {}
78
- AI_FLAVOR = LC['aiFlavor'].get('words') or []
79
- STYLE_ACCENTS = {k: {str(h).lower() for h in v}
80
- for k, v in (LC['styleAccents'] or {}).items()
81
- if not k.startswith('$') and isinstance(v, list)}
82
- ALL_ACCENTS = set().union(*STYLE_ACCENTS.values()) if STYLE_ACCENTS else set()
83
- # 图表登记四元组(charts.registry)与信息图页型(数据可追溯策略核对)
84
- CHART_REG = {k: v for k, v in (LC['charts'].get('registry') or {}).items()
85
- if not str(k).startswith('$')}
86
- INFO_PAGE_TYPES = {'sankey', 'treemap', 'boxplot', 'network', 'marimekko', 'streamgraph'}
87
- # 素材图片规格与配图占位约定(单源 layout-constants.json imageSpec)
88
- IMAGE_SPEC = LC.get('imageSpec') or {}
89
- IMAGE_LAYOUTS = set(IMAGE_SPEC.get('layouts') or ['full', 'half', 'bleed', 'grid', 'compare', 'wall'])
90
- IMAGE_MULTI_LAYOUTS = set(IMAGE_SPEC.get('multiLayouts') or ['grid', 'compare', 'wall'])
91
- IMAGE_FIT = set(IMAGE_SPEC.get('fitEnum') or ['cover', 'contain'])
92
- IMAGE_MAX_PER_PAGE = int(IMAGE_SPEC.get('maxPerPage') or 6)
93
- IMAGE_MAX_INLINE = int(IMAGE_SPEC.get('maxInlineBytes') or 1572864)
94
- IMAGE_MAX_TOTAL = int(IMAGE_SPEC.get('maxTotalInlineBytes') or 8388608)
95
-
96
- AGENDA_SINGLE_MAX = 8 # 超过则建议 agenda--2col
97
- # 页高估算参数(单源 layout-constants.pageHeightEstimate)
98
- _HE = LC.get('pageHeightEstimate') or {}
99
- SCREEN_BUDGET_PX = int(_HE.get('screenBudgetPx') or 1000)
100
- WRAP_INSET_PX = int(_HE.get('wrapInsetPx') or 112)
101
- SHEAD_PX = int(_HE.get('sheadPx') or 190)
102
- UNIT_PX = int(_HE.get('unitPx') or 170)
103
- LINE_FACTOR = float(_HE.get('lineFactor') or 1.65)
104
- STRUCT_PAGE_IDS = ('refs', 'appendix')
105
-
106
-
107
- class Struct(HTMLParser):
108
- def __init__(self):
109
- super().__init__()
110
- self.stack, self.err, self.mis = [], [], []
111
-
112
- def handle_starttag(self, tag, attrs):
113
- if tag not in VOID:
114
- self.stack.append(tag)
115
-
116
- def handle_endtag(self, tag):
117
- if tag in VOID:
118
- return
119
- if self.stack and self.stack[-1] == tag:
120
- self.stack.pop()
121
- else:
122
- try:
123
- i = len(self.stack) - 1 - self.stack[::-1].index(tag)
124
- self.mis.extend(self.stack[i:])
125
- self.stack = self.stack[:i]
126
- except ValueError:
127
- self.err.append(tag)
128
-
129
-
130
- def _plain(html):
131
- return re.sub(r'<[^>]+>', ' ', html)
132
-
133
-
134
- def _bands(txt):
135
- parts = re.split(r'(?=<section class="band)', txt)
136
- return [p for p in parts if p.startswith('<section class="band')]
137
-
138
-
139
- def _band_units(b):
140
- """承载组件数:卡/指标/图/表 + 列表组/结构/步骤/媒体/矩阵(组合版式与 underfill 同口径)。"""
141
- return (len(re.findall(r'class="(?:card|metric)[\s"]', b)) +
142
- len(re.findall(r'class="fig[\s"]', b)) +
143
- len(re.findall(r'class="tbl-wrap', b)) +
144
- len(re.findall(r'class="(?:arch|lane|steps|heat|bul|pyr|media|matrix|exhibit)[\s"]', b)) +
145
- len(re.findall(r'class="ul[\s"]', b)) +
146
- len(re.findall(r'class="cols-[23]', b)))
147
-
148
-
149
- def _band_heavy_units(b):
150
- """页高估算用「重承载行数」:卡/指标按栅格列数折行;图/表/结构各计 1 行级。"""
151
- cards = len(re.findall(r'class="(?:card|metric)[\s"]', b))
152
- figs = len(re.findall(r'class="fig[\s"]', b))
153
- tables = len(re.findall(r'class="tbl-wrap', b))
154
- structs = len(re.findall(r'class="(?:arch|lane|media|heat|exhibit)[\s"]', b))
155
- if re.search(r'class="[^"]*\bg-4\b', b):
156
- cols = 4
157
- elif re.search(r'class="[^"]*\bg-3\b', b):
158
- cols = 3
159
- elif re.search(r'class="[^"]*\b(?:g-2|g-side|g-hero|g-half)\b', b):
160
- cols = 2
161
- else:
162
- cols = 1
163
- card_rows = (cards + cols - 1) // cols if cards else 0
164
- return card_rows + figs + tables + structs
165
-
166
-
167
- def _check_charts(txt, chk):
168
- """图表:存在性 / data-chart 登记 / 动效 / 最小尺寸。"""
169
- svgs = re.findall(r'<svg\b[^>]*>', txt)
170
- chart_tags = [t for t in svgs if 'class="chart' in t or "class='chart" in t or 'data-chart' in t]
171
- chk("含内联 SVG 图表/图形", len(svgs) >= 1, f"{len(svgs)} 个", level="WARN")
172
- if not chart_tags:
173
- return
174
-
175
- untyped = [t for t in chart_tags if 'data-chart' not in t]
176
- chk("图表 svg 均有 data-chart 类型标记", not untyped,
177
- f"{len(untyped)} 个未标记", level="WARN")
178
-
179
- # data-chart 类型须在登记表内(layout-constants.json charts.types)
180
- unknown = []
181
- if CHART_TYPES:
182
- for t in chart_tags:
183
- m = re.search(r'data-chart="([^"]+)"', t)
184
- if m and m.group(1) not in CHART_TYPES:
185
- unknown.append(m.group(1))
186
- chk("图表类型在登记表内(layout-constants.json charts.types)", not unknown,
187
- f"未登记: {sorted(set(unknown))}", level="WARN")
188
-
189
- has_io = 'IntersectionObserver' in txt
190
- has_anim = 'data-anim' in txt or 'data-draw' in txt or 'data-sweep' in txt or 'data-count' in txt
191
- chk("图表动效(IntersectionObserver + data-anim/draw/sweep/count)",
192
- has_io and has_anim,
193
- "" if (has_io and has_anim) else f"IO={'有' if has_io else '无'} anim={'有' if has_anim else '无'}",
194
- level="WARN")
195
-
196
- too_small, small_note = [], []
197
- for t in chart_tags:
198
- m = re.search(r'data-chart="([^"]+)"', t)
199
- ctype = m.group(1) if m else None
200
- rule = CHART_MIN.get(ctype, {}) if ctype else {}
201
- wm = re.search(r'style="[^"]*?width:\s*(\d+(?:\.\d+)?)px', t)
202
- vb = re.search(r'viewBox="[\d.\-]+ [\d.\-]+ ([\d.]+) ([\d.]+)"', t)
203
- if wm and rule.get('pxWidthMin'):
204
- w = float(wm.group(1))
205
- if w < rule['pxWidthMin']:
206
- too_small.append(f"{ctype} 宽{w:.0f}px<{rule['pxWidthMin']}px")
207
- if vb and rule.get('vbHeightMin'):
208
- h = float(vb.group(2))
209
- if h < rule['vbHeightMin']:
210
- small_note.append(f"{ctype} viewBox高{h:.0f}<{rule['vbHeightMin']}")
211
- chk("图表最小尺寸·硬下限(环形/雷达/仪表盘显示宽)", not too_small,
212
- "; ".join(too_small) if too_small else "")
213
- chk("图表最小尺寸·建议(通宽图 viewBox 高)", not small_note,
214
- "; ".join(small_note) if small_note else "", level="WARN")
215
-
216
-
217
- CARRIERS = checks_html.CARRIERS # 单源 scripts/checks_html.py
218
-
219
-
220
- def _check_chart_variety(txt, chk, mode):
221
- """图表与版式多样性(阈值单源 charts.variety;判定逻辑 checks_html)。"""
222
- v = CHART_VARIETY
223
- if not v:
224
- return
225
- per_page = checks_html.chart_types_per_page(txt)
226
- used = [t for ts in per_page for t in ts]
227
- if not used:
228
- return
229
- distinct = sorted(set(used))
230
- n_chart_pages = len([1 for ts in per_page if ts])
231
-
232
- floor = checks_html.variety_floor(mode, n_chart_pages, v)
233
- chk("图表多样性(全篇不同 data-chart 类型数)", len(distinct) >= floor,
234
- f"{len(distinct)} 种 / 下限 {floor}({n_chart_pages} 个图表页): {distinct}")
235
-
236
- if v.get('noRepeatAdjacent'):
237
- adj = checks_html.adjacent_same_type(per_page)
238
- chk("图表不连续同型(相邻图表页不得同 data-chart)", not adj,
239
- "; ".join(adj[:3]) if adj else "", level="WARN")
240
-
241
- ratio = checks_html.composite_required(mode, v)
242
- if ratio > 0:
243
- multi, n_content = checks_html.composite_pages(txt)
244
- need = max(1, int(n_content * ratio))
245
- chk("组合版式(内容页含 ≥2 种承载类型的比例)", multi >= need,
246
- f"{multi}/{n_content} 页(下限 {need})——单件页是例外不是默认",
247
- level="WARN")
248
-
249
-
250
- def _check_page_model(txt, chk, mode):
251
- """页面高度模型(每页高度稳定):所有 .band 至少一屏高,长结构页显式 --flow 退出。"""
252
- has_model = ('--band-min' in txt) or ('calc(100svh - var(--bar-h))' in txt) or \
253
- ('min-height:calc(100vh - var(--bar-h))' in txt)
254
- chk("页面高度模型(--band-min / 一屏最小高度)", has_model,
255
- "缺少每页一屏的 min-height 机制(见 design-system.md §1c)")
256
- bands = _bands(txt)
257
- if not bands:
258
- return
259
- # 固定页高页(非 --flow)应占多数;长结构页才允许 flow
260
- flow_pages = []
261
- for i, b in enumerate(bands, 1):
262
- if 'band--flow' in b[:220]:
263
- flow_pages.append(i)
264
- bad_flow = []
265
- for i in flow_pages:
266
- b = bands[i - 1]
267
- if not any(f'id="{pid}"' in b for pid in STRUCT_PAGE_IDS):
268
- bad_flow.append(f"第{i}页")
269
- chk("band--flow 仅用于长结构页(参考资料/附录)", not bad_flow,
270
- f"{bad_flow} 使用 --flow 但非结构页(应改用默认一屏页)" if bad_flow else "", level="WARN")
271
-
272
-
273
- def _check_bands(txt, chk, mode="presentation"):
274
- """单页预算与布局:文字量/组件数(防塞爆)· 过空页 · 页高溢出估算 · 混排对齐。"""
275
- B = MODE_BUDGETS.get(mode, MODE_BUDGETS['presentation'])
276
- bands = _bands(txt)
277
- over_chars, over_units, too_empty, fit_overload = [], [], [], []
278
- for i, b in enumerate(bands, 1):
279
- if 'id="refs"' in b or 'id="appendix"' in b:
280
- continue
281
- b_clean = re.sub(r'<script\b[\s\S]*?</script>', ' ', b)
282
- b_clean = re.sub(r'<style\b[\s\S]*?</style>', ' ', b_clean)
283
- plain_len = len(_plain(b_clean).strip())
284
- units = _band_units(b)
285
- if plain_len > B["char"]:
286
- over_chars.append(f"第{i}页 {plain_len}字")
287
- if units > B["unit"]:
288
- over_units.append(f"第{i}页 {units}个")
289
- head_tag = b[:220]
290
- is_fit = 'band--fit' in head_tag
291
- is_deep = 'band--deep' in head_tag
292
- if is_fit and plain_len > B["fit"]:
293
- fit_overload.append(f"第{i}页 {plain_len}字")
294
- if (not is_fit and not is_deep and units == 0 and plain_len < B["empty"]):
295
- too_empty.append(f"第{i}页 {plain_len}字")
296
- chk(f"单页文字量 ≤ {B['char']} 字({mode} 模式 · 防溢出)", not over_chars,
297
- "; ".join(over_chars) if over_chars else "", level="WARN")
298
- chk(f"单页并列单元 ≤ {B['unit']} 个({mode} 模式 · 防塞爆)", not over_units,
299
- "; ".join(over_units) if over_units else "", level="WARN")
300
- # v9:过空页升为 FAIL(截图级「只有标题」缺陷不得假过)
301
- chk(f"无过空页({mode} 模式 · 普通页 ≥{B['empty']} 字或有组件;防大面积留白)", not too_empty,
302
- "; ".join(too_empty) if too_empty else "")
303
- chk(f"满屏居中页 band--fit 内容 ≤ {B['fit']} 字({mode} 模式 · 防垂直溢出)", not fit_overload,
304
- "; ".join(fit_overload) if fit_overload else "", level="WARN")
305
-
306
- # 页高溢出静态估算:字数×行高 + 组件固定高 vs 一屏预算(参数单源 pageHeightEstimate / checkBudgets)。
307
- # 固定页高页(无 --flow)超预算即 FAIL——这是"每页高度稳定"的核心硬门禁。
308
- fs_px = int(B.get('bodyPx') or 17)
309
- wrap_px = int(B.get('wrap') or 1400) - WRAP_INSET_PX
310
- cpl = max(10, int(wrap_px / fs_px))
311
- est_over = []
312
- for i, b in enumerate(bands, 1):
313
- if 'id="refs"' in b or 'id="appendix"' in b:
314
- continue
315
- if 'band--flow' in b[:220]:
316
- continue
317
- b_clean = re.sub(r'<script\b[\s\S]*?</script>', ' ', b)
318
- b_clean = re.sub(r'<style\b[\s\S]*?</style>', ' ', b_clean)
319
- plen = len(_plain(b_clean).strip())
320
- u = _band_heavy_units(b)
321
- est_h = plen / cpl * fs_px * LINE_FACTOR + u * UNIT_PX + SHEAD_PX
322
- # 静态估算容差 8%(真值以浏览器为准;轻微超出仍按 FAIL 会误伤密排页)
323
- if est_h > SCREEN_BUDGET_PX * 1.08:
324
- est_over.append(f"第{i}页≈{est_h:.0f}px")
325
- chk(f"页高溢出估算(字×行高+组件 ≤ ~{SCREEN_BUDGET_PX}px/屏 · {mode})", not est_over,
326
- ("; ".join(est_over) + "(按「列表化/精炼→压缩→多列→拆页」处理,或给长结构页加 band--flow)")
327
- if est_over else "")
328
-
329
- bad_grids = 0
330
- for m in re.finditer(r'<div class="grid[^"]*"([^>]*)>', txt):
331
- attrs = m.group(1)
332
- sec_end = txt.find('</section>', m.end())
333
- ahead = txt[m.end(): sec_end if sec_end != -1 else m.end() + 4000]
334
- if 'class="fig' in ahead and 'class="card' in ahead:
335
- aligned = ('align-items' in attrs or
336
- re.search(r'\ba-(c|start|end)\b', m.group(0)))
337
- if not aligned:
338
- bad_grids += 1
339
- chk("混排栅格(图+卡)显式对齐(a-start/a-c 或 align-items)", bad_grids == 0,
340
- f"{bad_grids} 处未对齐" if bad_grids else "", level="WARN")
341
-
342
-
343
- def _check_icons(txt, chk):
344
- """图标使用:长报告不应全文无图标(要点卡/关键列表至少一处)。"""
345
- if len(_bands(txt)) < 6:
346
- return
347
- has_icon = ('ul--ico' in txt or 'metric__ico' in txt or
348
- re.search(r'card__ico"[^>]*>\s*<svg', txt) or
349
- re.search(r'card__ico">\s*<svg', txt))
350
- chk("长报告(≥6页)要点卡/关键列表有图标", bool(has_icon),
351
- "全文未用图标(见 icons.md 使用准则)", level="WARN")
352
-
353
-
354
- def _check_table_rows(txt, chk, mode="presentation"):
355
- """表格行数上限(research 密表 ≤16 / presentation ≤8 / architecture ≤6;宁拆勿挤)。"""
356
- B = MODE_BUDGETS.get(mode, MODE_BUDGETS['presentation'])
357
- max_rows = B['trow']
358
- over = []
359
- for i, m in enumerate(re.finditer(r'<tbody>([\s\S]*?)</tbody>', txt), 1):
360
- n = len(re.findall(r'<tr', m.group(1)))
361
- if n > max_rows:
362
- over.append(f"表{i} {n}行")
363
- chk(f"表格行数 ≤ {max_rows} 行({mode} 模式 · 宁拆勿挤)", not over,
364
- "; ".join(over) if over else "", level="WARN")
365
-
366
-
367
- def _check_mode_layouts(txt, chk, mode):
368
- """模式版式特征:三模式独立体系,各自应有标志性组件。"""
369
- if mode == 'research':
370
- has = ('class="cols-2' in txt or 'class="exhibit' in txt or
371
- 'class="cols-3' in txt or 'class="matrix' in txt)
372
- chk("research 版式特征(cols-2/cols-3/exhibit/matrix 至少一处)", has,
373
- "未见研究模式标志性组件", level="WARN")
374
- elif mode == 'architecture':
375
- has = ('class="arch' in txt or 'class="lane' in txt)
376
- chk("architecture 版式特征(arch/lane 至少一处)", has,
377
- "未见架构模式标志性组件", level="WARN")
378
-
379
-
380
- # 页型 → 正文标志性版式特征(正则;模型-正文同源抽查,见 `components.md` §46/§46b)
381
- TYPE_FEATURE = {
382
- 'steps': r'class="[^"]*\bsteps\b',
383
- 'heatmap': r'class="[^"]*\bheat\b',
384
- 'bullet': r'class="[^"]*\bbul\b',
385
- 'pyramid': r'class="[^"]*\bpyr\b',
386
- 'image': r'class="[^"]*\bmedia\b|<img\b',
387
- 'matrix': r'class="[^"]*\bmatrix\b',
388
- 'exhibit': r'class="[^"]*\bexhibit\b',
389
- 'twocol': r'class="[^"]*\bcols-2\b',
390
- 'threecol': r'class="[^"]*\bcols-3\b',
391
- 'halftable': r'class="[^"]*\bg-half\b',
392
- 'split': r'class="[^"]*\bg-side\b',
393
- 'diagram': r'class="[^"]*\barch\b',
394
- 'lane': r'class="[^"]*\blane\b',
395
- 'timeline': r'class="[^"]*\btl\b',
396
- 'comparison': r'class="[^"]*\bg-half\b',
397
- 'quote': r'band--accent|band--deep',
398
- 'donut': r'data-chart="donut"',
399
- # 复杂信息图页型:正文以内联 SVG 承载(data-chart 登记与 charts.registry 同源)
400
- 'sankey': r'data-chart="sankey"',
401
- 'treemap': r'data-chart="treemap"',
402
- 'boxplot': r'data-chart="boxplot"',
403
- 'network': r'data-chart="network"',
404
- 'marimekko': r'data-chart="marimekko"',
405
- 'streamgraph': r'data-chart="streamgraph"',
406
- }
407
-
408
-
409
- def _check_type_features(txt, chk, model):
410
- """模型声明的页型应在正文找到对应版式组件(防"模型填了页型但正文用旧版式")。"""
411
- if not model:
412
- return
413
- missing = []
414
- for sec in (model.get('sections') or []):
415
- t = (sec.get('type') or 'points')
416
- feat = TYPE_FEATURE.get(t)
417
- if feat and not re.search(feat, txt):
418
- missing.append(t)
419
- chk("模型页型 ↔ 正文版式组件对应", not missing,
420
- f"{len(missing)} 处: {missing[:4]}" if missing else "", level="WARN")
421
-
422
-
423
- def _check_pptx_export(txt, chk):
424
- """PPTX 预览配套:有预览按钮就必须有内容模型 + 预览运行时;页面不得残留导出按钮。"""
425
- has_btn = 'id="pptPreviewBtn"' in txt
426
- has_model = 'REPORT_MODEL' in txt
427
- has_runtime = ('g.TopPptHtml = api' in txt) and ('function slidesXml' in txt)
428
- if has_btn:
429
- chk("PPTX 预览按钮配套(REPORT_MODEL + 预览运行时)",
430
- has_model and has_runtime,
431
- f"model={'有' if has_model else '无'} runtime={'有' if has_runtime else '无'}")
432
- else:
433
- chk("含 PPTX 预览按钮与内容模型(建议保留)",
434
- has_model and has_runtime, "未集成预览按钮/模型", level="WARN")
435
- chk("页面无 PPTX 导出按钮(仅预览 + 提示词)",
436
- 'id="pptxBtn"' not in txt and 'id="pptDownload"' not in txt,
437
- "残留导出按钮 pptxBtn/pptDownload", level="WARN")
438
-
439
-
440
- def _extract_model(txt):
441
- m = re.search(r'window\.REPORT_MODEL\s*=\s*', txt)
442
- if not m:
443
- return None, "未找到 window.REPORT_MODEL(PPTX 双通道导出将不可用)"
444
- try:
445
- model, _end = json.JSONDecoder().raw_decode(txt[m.end():])
446
- return model, None
447
- except json.JSONDecodeError as e:
448
- return None, f"REPORT_MODEL 不是合法 JSON: {e}(必须双引号/无尾逗号/无注释)"
449
-
450
-
451
- def _chart_channel(chart_type) -> str:
452
- """图表类型 → 交付通道(native / shape),与 charts.registry 同源(checks_html)。"""
453
- return checks_html.chart_channel(chart_type, CHART_REG)
454
-
455
-
456
- def _chart_datatable_mode(chart: dict) -> str:
457
- """数据表策略:图表级 > 登记表默认(appendix 收敛为 inline)。"""
458
- return checks_html.chart_datatable_mode(chart, CHART_REG)
459
-
460
-
461
- def _check_chart_datatable(chk, model) -> None:
462
- """数据可追溯:非原生(形状通道)图表与信息图页型的 dataTable 不得为 off。
463
-
464
- 与 validate_pptx.py 的 MODEL_CHART_DATATABLE 同源,但提前到 HTML 阶段暴露——
465
- 避免生成完 PPTX 才被拦(数据表策略是模型层决定,HTML 阶段即可判定)。
466
- """
467
- bad: list[str] = []
468
- for sec in (model.get('sections') or []):
469
- if not isinstance(sec, dict):
470
- continue
471
- st = sec.get('type') or ''
472
- if st in INFO_PAGE_TYPES:
473
- mode = str(((sec.get('chart') or {}).get('dataTable')) or 'notes').lower()
474
- if mode == 'off':
475
- bad.append(f'{st} 页信息图 dataTable=off')
476
- continue
477
- charts = []
478
- if st in ('bar', 'donut', 'exhibit', 'halftable'):
479
- c = sec.get('chart') or {}
480
- if isinstance(c, dict) and c.get('labels') and c.get('values'):
481
- charts.append(c)
482
- elif st == 'split':
483
- # 双区组合页:左/右两侧各可能是图表(left 缺省 points、right 缺省 bar)
484
- for side, dflt in (('left', 'points'), ('right', 'bar')):
485
- pane = sec.get(side) or {}
486
- if not isinstance(pane, dict):
487
- continue
488
- if (pane.get('type') or dflt) in ('table', 'image', 'points'):
489
- continue
490
- if pane.get('labels') and pane.get('values'):
491
- charts.append(pane)
492
- for chart in charts:
493
- if _chart_channel(chart.get('type')) == 'shape' and _chart_datatable_mode(chart) == 'off':
494
- bad.append(f"{st} 页 {chart.get('type')} 图表 dataTable=off")
495
- chk("图表数据表策略(非原生图表不得 off,保证数据可追溯)", not bad,
496
- ';'.join(bad[:3]) if bad else "", level="WARN")
497
-
498
-
499
- def _check_model_consistency(txt, chk, mode, style):
500
- """模型-正文一致性:JSON 合法 / mode·style·theme 一致 / 章节标题抽查 / agenda / 页数。"""
501
- model, err = _extract_model(txt)
502
- if err:
503
- chk("REPORT_MODEL 存在且为合法 JSON", False, err)
504
- return None
505
- chk("REPORT_MODEL 存在且为合法 JSON", True)
506
- mmode = model.get('mode')
507
- chk("REPORT_MODEL.mode 与 data-mode 一致", mmode == mode,
508
- f"model={mmode!r} 页面={mode!r}")
509
- mstyle = model.get('style')
510
- chk("REPORT_MODEL.style 与 data-style 一致", mstyle == style,
511
- f"model={mstyle!r} 页面={style!r}", level="WARN")
512
- mtheme = model.get('theme') or 'light'
513
- html_theme_m = re.search(r'<html[^>]*data-theme="([^"]+)"', txt)
514
- html_theme = html_theme_m.group(1) if html_theme_m else 'light'
515
- chk("REPORT_MODEL.theme 与 data-theme 一致(未声明默认 light)",
516
- mtheme in ('light', 'dark') and mtheme == html_theme,
517
- f"model={mtheme!r} 页面={html_theme!r}", level="WARN")
518
- secs = model.get('sections') or []
519
- chk("REPORT_MODEL.sections 非空(每章一页模型)", len(secs) >= 1,
520
- f"{len(secs)} 页", level="WARN")
521
- _check_chart_datatable(chk, model)
522
- plain = _plain(txt)
523
- missing = [str(s.get('title'))[:14] for s in secs
524
- if not s.get('title') or str(s.get('title')) not in plain]
525
- chk("模型章节标题均出现于正文(模型-正文同源)", not missing,
526
- f"{len(missing)} 个缺失: {missing[:3]}" if missing else "", level="WARN")
527
- n_html = txt.count('class="agenda__a"')
528
- n_model = len(model.get('agenda') or [])
529
- if n_html and n_model:
530
- chk("模型 agenda 条数与正文一致", n_html == n_model,
531
- f"model={n_model} 正文={n_html}", level="WARN")
532
- n_bands = len(_bands(txt))
533
- lo, hi = len(secs) + 3, len(secs) + 7
534
- chk("页数粗匹配(正文 band ≈ sections + 结构页)", lo <= n_bands <= hi,
535
- f"正文 {n_bands} 页 vs 模型 {len(secs)}+{lo - len(secs)}~{hi - len(secs)}", level="WARN")
536
- return model
537
-
538
-
539
- def _check_anchors(txt, chk):
540
- """锚点闭环:Agenda / 导航 / 正文内部链接指向的 id 必须存在,且 id 全篇唯一。
541
- (重复 id 会让锚点跳到错误的页,且 HTML 规范不允许——硬拦。)"""
542
- # 只看正文:内联运行时/UI 脚本里有 `id="' + nid() + '"` 这类拼接,不能算重复 id
543
- body = re.sub(r'<script\b[\s\S]*?</script>', '', txt, flags=re.I)
544
- all_ids = re.findall(r'\sid="([^"]+)"', body)
545
- ids = set(all_ids)
546
- dup = sorted({i for i in all_ids if all_ids.count(i) > 1})
547
- chk("id 全篇唯一(无重复 id)", not dup,
548
- f"重复 id {dup[:5]}(锚点会跳到错误的页)" if dup else "")
549
- bad = []
550
- for href in set(re.findall(r'href="#([^"]+)"', body)):
551
- if href and href not in ids:
552
- bad.append('#' + href)
553
- chk("内部锚点闭环(agenda/nav/正文链接均有对应 id)", not bad,
554
- f"悬空锚点 {sorted(bad)[:5]}" if bad else "")
555
-
556
-
557
- def _check_exhibits(txt, chk, mode):
558
- """Exhibit 编号体系:每个 .exhibit 框都有编号,且全篇连续 1..N 无跳号无重复。"""
559
- nos = [int(n) for n in re.findall(
560
- r'class="exhibit__no"[^>]*>\s*Exhibit\s*(\d+)', txt, re.I)]
561
- n_frame = len(re.findall(r'class="exhibit[\s"]', txt))
562
- if not nos:
563
- if mode == 'research':
564
- chk("Exhibit 编号体系(research 深报告建议建立)", False,
565
- "全文无 Exhibit 编号图表框", level="WARN")
566
- return
567
- # 只校验已编号者的连续性会漏掉「漏标编号的 exhibit 框」——先核框数再核编号。
568
- chk(f"Exhibit 框均已编号({n_frame} 框)", n_frame == len(nos),
569
- f"{n_frame} 个 .exhibit 框但只有 {len(nos)} 个 .exhibit__no 编号" if n_frame != len(nos) else "")
570
- expected = list(range(1, len(nos) + 1))
571
- chk(f"Exhibit 编号连续(1..{len(nos)} 无跳号无重复)", sorted(nos) == expected,
572
- f"读到 {nos}")
573
-
574
-
575
- def _check_research_extras(txt, chk, mode):
576
- """research 模式:so-what 连续性(>3 页无结论条)与 Exhibit 来源行。"""
577
- if mode != 'research':
578
- return
579
- bands = _bands(txt)
580
- streak = max_streak = 0
581
- for i, b in enumerate(bands, 1):
582
- if i <= 2 or 'id="agenda"' in b or 'id="refs"' in b:
583
- continue
584
- if 'class="sowhat' in b or 'class="note' in b:
585
- streak = 0
586
- else:
587
- streak += 1
588
- max_streak = max(max_streak, streak)
589
- chk("so-what 连续性(连续 ≤3 页无结论条)", max_streak <= 3,
590
- f"连续 {max_streak} 页无 .sowhat/.note" if max_streak > 3 else "", level="WARN")
591
- n_ex = len(re.findall(r'class="exhibit[\s"]', txt))
592
- n_src = txt.count('class="exhibit__src"')
593
- chk(f"Exhibit 图表框均有来源行({n_ex} 框)", n_ex == n_src,
594
- f"{n_ex - n_src} 个缺 .exhibit__src" if n_ex != n_src else "", level="WARN")
595
-
596
-
597
- def _check_emphasis(txt, chk):
598
- """强调带约束:band--deep 是反相强调页(浅色主题下渲染为深色)——
599
- 全文受限使用,且不得作末页,否则浅色模式末尾会出现深色页(用户核心诉求)。"""
600
- em = LC.get('emphasis') or {}
601
- max_deep = em.get('deepMaxPages', 1)
602
- forbid_last = em.get('deepForbiddenLast', True)
603
- bands = _bands(txt)
604
- deep_idx = [i for i, b in enumerate(bands, 1) if 'band--deep' in b[:220]]
605
- chk(f"反相强调页 band--deep ≤ {max_deep} 页(浅色模式下渲染为深色)",
606
- len(deep_idx) <= max_deep,
607
- f"{len(deep_idx)} 页: {deep_idx}(收尾/金句页请用 band--accent)" if len(deep_idx) > max_deep else "",
608
- level="WARN")
609
- if forbid_last and bands:
610
- chk("末页非反相深色页(band--deep)",
611
- 'band--deep' not in bands[-1][:220],
612
- "末页为 band--deep:浅色模式下会以深色收尾(改用 band--accent)", level="WARN")
613
-
614
-
615
- def _layout_sig(band: str) -> str | None:
616
- """从 band HTML 提取版式签名(优先级从具体到宽泛)。
617
- 图表类型并入签名:exhibit+sankey 与 exhibit+waterfall 视为不同版式。"""
618
- pats = (
619
- ('g-hero-full', r'g-hero-full'),
620
- ('g-mosaic', r'g-mosaic'),
621
- ('g-bento', r'g-bento'),
622
- ('g-aside', r'g-aside'),
623
- ('g-quad', r'g-quad'),
624
- ('g-211', r'g-211'),
625
- ('g-121', r'g-121'),
626
- ('g-side', r'g-side'),
627
- ('rows-2', r'rows-2'),
628
- ('rows-3', r'rows-3'),
629
- ('g-2', r'\bg-2\b'),
630
- ('g-3', r'\bg-3\b'),
631
- ('g-4', r'\bg-4\b'),
632
- ('g-5', r'\bg-5\b'),
633
- ('g-6', r'\bg-6\b'),
634
- ('cols-3', r'cols-3'),
635
- ('cols-2', r'cols-2'),
636
- ('exhibit', r'class="exhibit'),
637
- ('table', r'class="tbl-wrap'),
638
- ('metrics', r'class="metric'),
639
- ('arch', r'class="arch'),
640
- ('lane', r'class="lane'),
641
- ('matrix', r'class="matrix'),
642
- ('heat', r'class="heat'),
643
- ('bul', r'class="bul'),
644
- ('pyr', r'class="pyr'),
645
- ('steps', r'class="steps'),
646
- ('timeline', r'class="tl[\s"]'),
647
- ('media', r'class="media'),
648
- ('stagger', r'stagger'),
649
- ('quote', r'band--accent'),
650
- ('card', r'class="card[\s"]'),
651
- )
652
- base = None
653
- for name, pat in pats:
654
- if re.search(pat, band):
655
- base = name
656
- break
657
- if base is None:
658
- return None
659
- charts = re.findall(r'data-chart="([^"]+)"', band)
660
- if charts:
661
- # 去重保序,最多 2 类,避免顺序噪声
662
- seen, uniq = set(), []
663
- for c in charts:
664
- if c not in seen:
665
- seen.add(c)
666
- uniq.append(c)
667
- return f"{base}:{'+'.join(uniq[:2])}"
668
- return base
669
-
670
-
671
- def _check_content_quality(txt, chk, mode, model):
672
- """内容级质量:版式节奏 / so-what 非空洞 / research 行动标题含判断。"""
673
- cq = LC.get('contentQuality') or {}
674
- bands = _bands(txt)
675
- skip = set((cq.get('rhythm') or {}).get('skipIds') or
676
- ['cover', 'agenda', 'refs', 'appendix', 'next'])
677
-
678
- # ① 版式节奏:同一签名不得连用超过上限
679
- max_streak = int((cq.get('rhythm') or {}).get('maxSameLayoutStreak') or 2)
680
- sigs: list[tuple[int, str]] = []
681
- for i, b in enumerate(bands, 1):
682
- head = b[:220]
683
- if any(f'id="{sid}"' in head for sid in skip):
684
- continue
685
- sig = _layout_sig(b)
686
- if sig:
687
- sigs.append((i, sig))
688
- streak_hits: list[str] = []
689
- run_sig, run_start, run_len = None, 0, 0
690
- for i, sig in sigs:
691
- if sig == run_sig:
692
- run_len += 1
693
- else:
694
- if run_sig and run_len > max_streak:
695
- streak_hits.append(f'{run_sig}×{run_len}(起第{run_start}页)')
696
- run_sig, run_start, run_len = sig, i, 1
697
- if run_sig and run_len > max_streak:
698
- streak_hits.append(f'{run_sig}×{run_len}(起第{run_start}页)')
699
- chk(f"版式节奏(同一版式签名连续 ≤{max_streak} 页)", not streak_hits,
700
- "; ".join(streak_hits[:3]) if streak_hits else "", level="WARN")
701
-
702
- # ② so-what 禁空洞套话 + 最短长度(HTML 与模型双侧)
703
- sw = cq.get('sowhat') or {}
704
- min_chars = int(sw.get('minChars') or 12)
705
- forbidden = list(sw.get('forbidden') or [])
706
- texts: list[tuple[str, str]] = []
707
- for m in re.finditer(r'class="sowhat__v"[^>]*>(.*?)</', txt, re.S):
708
- body = re.sub(r'<[^>]+>', '', m.group(1) or '').strip()
709
- if body:
710
- texts.append(('html', body))
711
- if model:
712
- for idx, sec in enumerate(model.get('sections') or [], 1):
713
- v = sec.get('soWhat')
714
- if isinstance(v, str) and v.strip():
715
- texts.append((f'model#{idx}', v.strip()))
716
- short, hollow = [], []
717
- for src, body in texts:
718
- if len(body) < min_chars:
719
- short.append(f'{src}:{body[:24]}')
720
- if any(f in body for f in forbidden):
721
- hollow.append(f'{src}:{body[:24]}')
722
- if texts:
723
- chk(f"so-what 实质(长度 ≥{min_chars} 字且无空洞套话)", not short and not hollow,
724
- "; ".join((short + hollow)[:4]) if (short or hollow) else "", level="WARN")
725
-
726
- # ③ research 行动标题须含数字或判断词
727
- if mode == 'research':
728
- rt = cq.get('researchTitle') or {}
729
- if rt.get('requireDigitOrJudgment', True):
730
- jpat = rt.get('judgmentPattern') or r'[\d]|是|应|需|将'
731
- struct = ('报告大纲', '大纲', '参考资料', '数据来源', '下一步', '结论',
732
- '全文核心', 'agenda', '附录', '收尾')
733
- h1s = re.findall(r'<h2 class="t-h1 shead__title"[^>]*>(.*?)</h2>', txt)
734
- titles = [re.sub(r'<[^>]+>', '', h).strip() for h in h1s]
735
- titles = [t for t in titles if len(t) >= 12 and
736
- not t.lower().startswith(struct) and not any(s in t for s in struct)]
737
- no_judge = [t[:28] for t in titles if not re.search(jpat, t)]
738
- chk("research 行动标题含数字或判断词(标题即结论)", not no_judge,
739
- f"缺少判断信号: {no_judge[:3]}" if no_judge else "", level="WARN")
740
-
741
-
742
- def _check_v9_hard_gates(txt, chk, model):
743
- """v9 硬门禁:标签泄漏 / 标题空页 / 极偏占比 / 简单图过大(治截图级缺陷)。"""
744
- import html as _html
745
- QG = LC.get('qualityGates') or {}
746
- patterns = QG.get('tagLeakPatterns') or [
747
- '<a ', '</a>', 'class="cite"', 'href=', '<strong', '</strong>'
748
- ]
749
- # ① HTML 标签泄漏进可见文本
750
- # 先抓「转义后当字面量显示」的(&lt;a class=…&gt;):源码里就有实体
751
- leak_hits = []
752
- for i, b in enumerate(_bands(txt), 1):
753
- b2 = re.sub(r'<script\b[\s\S]*?</script>', ' ', b)
754
- b2 = re.sub(r'<style\b[\s\S]*?</style>', ' ', b2)
755
- # A. 转义标签字面量(用户截图形态:正文里直接可见 <a class="cite"…>)
756
- if re.search(r'&lt;/?[a-zA-Z][^&]{0,60}&gt;', b2):
757
- m = re.search(r'&lt;/?[a-zA-Z][^&]{0,60}&gt;', b2)
758
- leak_hits.append(f"第{i}页含转义标签字面量 `{m.group(0)[:40]}`")
759
- continue
760
- # B. 反转义后剥真标签,残留源码片段
761
- unesc = _html.unescape(b2)
762
- plain = _plain(unesc)
763
- for p in patterns:
764
- if p in plain:
765
- leak_hits.append(f"第{i}页可见文本含 `{p}`")
766
- break
767
- # C. REPORT_MODEL 字段内夹带 HTML 标签(在 <script> 里,正文剥离会漏掉)
768
- model_tag_hits = []
769
- mm = re.search(r'window\.REPORT_MODEL\s*=\s*(\{[\s\S]*?\})\s*;', txt)
770
- if mm:
771
- try:
772
- import json as _json
773
- mobj = _json.loads(mm.group(1))
774
-
775
- def _scan(o, path=''):
776
- if isinstance(o, dict):
777
- for k, v in o.items():
778
- _scan(v, f'{path}.{k}' if path else k)
779
- elif isinstance(o, list):
780
- for i, v in enumerate(o):
781
- _scan(v, f'{path}[{i}]')
782
- elif isinstance(o, str) and re.search(r'</?[a-zA-Z][^>]*>', o):
783
- model_tag_hits.append(path or '(root)')
784
- _scan(mobj)
785
- except Exception:
786
- pass
787
- if model_tag_hits:
788
- leak_hits.append(f"REPORT_MODEL 字段夹带标签: {model_tag_hits[:3]}")
789
- chk("HTML_TAG_IN_TEXT 可见文本无 HTML 标签源码泄漏", not leak_hits,
790
- "; ".join(leak_hits[:5]) if leak_hits else "")
791
-
792
- # ② 标题空页 / 承载不足
793
- title_only, underfill = [], []
794
- to_min = int((QG.get('titleOnly') or {}).get('minBodyChars') or 40)
795
- uf = QG.get('underfill') or {}
796
- uf_min = int(uf.get('minCarriers') or 2)
797
- exempt = set(uf.get('exemptIds') or ['quote', 'cover', 'refs', 'appendix', 'next'])
798
- for i, b in enumerate(_bands(txt), 1):
799
- head = b[:240]
800
- bid_m = re.search(r'id="([^"]+)"', head)
801
- bid = bid_m.group(1) if bid_m else ''
802
- if bid in exempt or any(f'id="{e}"' in head for e in exempt):
803
- continue
804
- if 'band--deep' in head or 'band--accent' in head:
805
- continue
806
- b2 = re.sub(r'<script\b[\s\S]*?</script>', ' ', b)
807
- b2 = re.sub(r'<style\b[\s\S]*?</style>', ' ', b2)
808
- # 去掉 shead(eyebrow+标题+导语)后的正文
809
- body = re.sub(r'<div class="shead[\s\S]*?</div>\s*</div>', ' ', b2, count=1)
810
- body = re.sub(r'<div class="shead[\s\S]*?</h2>\s*</div>', ' ', b2, count=1)
811
- plain_body = _plain(body).strip()
812
- # 再去掉与标题重复的大标题串
813
- title_m = re.search(r'shead__title[^>]*>(.*?)</', b2)
814
- if title_m:
815
- plain_body = plain_body.replace(_plain(title_m.group(1)).strip(), '', 1).strip()
816
- carriers = _band_units(b)
817
- if len(plain_body) < to_min and carriers == 0:
818
- title_only.append(f"第{i}页正文{len(plain_body)}字")
819
- elif carriers < uf_min and len(plain_body) < to_min * 2:
820
- underfill.append(f"第{i}页承载{carriers}")
821
- chk(f"TITLE_ONLY_PAGE 非空页(去页头后正文 ≥{to_min} 字)", not title_only,
822
- "; ".join(title_only[:5]) if title_only else "")
823
- chk(f"UNDERFILL_PAGE 内容页承载 ≥{uf_min}(金句/章节幕豁免)", not underfill,
824
- "; ".join(underfill[:5]) if underfill else "")
825
-
826
- # ③ 极偏占比禁 donut/pie(治 0.5% vs 99.5% 环图叠字不可读)
827
- skew = QG.get('chartSkew') or {}
828
- skew_types = set(skew.get('types') or ['donut', 'pie', 'multidonut'])
829
- min_pct = float(skew.get('minSectorPct') or 5)
830
- max_ratio = float(skew.get('maxMinRatio') or 20)
831
- skew_hits = []
832
- if model and isinstance(model.get('sections'), list):
833
- for si, sec in enumerate(model['sections'], 1):
834
- if not isinstance(sec, dict):
835
- continue
836
- ch = sec.get('chart') or {}
837
- ctype = str(ch.get('type') or sec.get('type') or '').lower()
838
- if sec.get('type') == 'donut':
839
- ctype = 'donut'
840
- if ctype not in skew_types:
841
- continue
842
- vals = [float(v) for v in (ch.get('values') or []) if v is not None]
843
- vals = [v for v in vals if v >= 0]
844
- if len(vals) < 2:
845
- continue
846
- total = sum(vals) or 1
847
- pcts = [v / total * 100 for v in vals]
848
- mn, mx = min(pcts), max(pcts)
849
- ratio = (mx / mn) if mn > 0 else 999
850
- if mn < min_pct or ratio > max_ratio:
851
- skew_hits.append(
852
- f"第{si}章 {ctype} 最小扇区 {mn:.1f}%(比 {ratio:.0f}:1)应改 KPI/进度/对比条")
853
- chk(f"CHART_SKEW_INVALID 占比图最小扇区 ≥{min_pct:.0f}% 且 max/min ≤{max_ratio:.0f}",
854
- not skew_hits, "; ".join(skew_hits[:4]) if skew_hits else "")
855
-
856
- # ④ 简单图过大(类别≤2 或 数据点≤3 却近乎独占内容区)
857
- ov = QG.get('chartOversize') or {}
858
- simple_cats = int(ov.get('simpleMaxCats') or 2)
859
- simple_pts = int(ov.get('simpleMaxPts') or 3)
860
- max_pct_area = float(ov.get('maxContentAreaPct') or 55)
861
- oversize = []
862
- for i, b in enumerate(_bands(txt), 1):
863
- for m in re.finditer(
864
- r'<svg\b[^>]*data-chart="([^"]+)"[^>]*style="([^"]*)"[^>]*>',
865
- b):
866
- ctype, style = m.group(1), m.group(2)
867
- # 解析 width / height(px 或 %)
868
- wm = re.search(r'width:\s*(\d+(?:\.\d+)?)px', style)
869
- hm = re.search(r'height:\s*(\d+(?:\.\d+)?)px', style)
870
- vw = re.search(r'viewBox="0 0 (\d+(?:\.\d+)?) (\d+(?:\.\d+)?)"', m.group(0))
871
- # 从 svg 块粗数数据点/类别
872
- svg_end = b.find('</svg>', m.end())
873
- block = b[m.start():svg_end if svg_end > 0 else m.end() + 800]
874
- n_labels = len(re.findall(r'<text\b', block))
875
- n_rects = len(re.findall(r'<rect\b', block))
876
- n_circles = len(re.findall(r'<circle\b', block))
877
- n_pts = max(n_labels, n_rects, n_circles)
878
- is_simple = n_labels <= simple_cats or n_pts <= simple_pts
879
- if not is_simple:
880
- continue
881
- # 简单图接近通宽且高度大 → 视为「简单大图」
882
- h_px = float(hm.group(1)) if hm else (float(vw.group(2)) if vw else 0)
883
- if h_px >= 320 or (not hm and not vw and 'width:100%' in style and 'height' not in style):
884
- # 无显式高度的通宽简单图
885
- if h_px >= 320 or ('width:100%' in style and n_pts <= simple_pts):
886
- oversize.append(f"第{i}页 {ctype} 简单图过大({n_pts} 点/标记)")
887
- chk(f"CHART_OVERSIZE 简单图(≤{simple_cats} 类或 ≤{simple_pts} 点)勿独占版面(上限 {max_pct_area:.0f}%)",
888
- not oversize, "; ".join(oversize[:4]) if oversize else "")
889
-
890
-
891
- def _check_layout_grammar(txt, chk, mode):
892
- """布局语法门禁:骨架类 / 单一重心 / 混排对齐 / 间距 token / 图标尺寸 / 标签防换行。"""
893
- LS = LC.get('layoutSystem') or {}
894
- presets = LS.get('presets') or {}
895
- elem = LS.get('elements') or {}
896
- mf = LS.get('multiFocus') or {}
897
- align_cfg = LS.get('align') or {}
898
- fill_t = (LS.get('fillTarget') or {}).get(mode) or [62, 78]
899
- icon_sizes = set(elem.get('icon', {}).get('sizesPx') or [16, 18, 20, 24])
900
- chart_w_pct = float(mf.get('chartWidthPct') or 55)
901
- metric_fz = float(mf.get('metricFontPx') or 40)
902
-
903
- # 收集预设骨架对应 class
904
- skeleton_classes = set()
905
- for p in (presets or {}).values():
906
- for c in (p.get('html') or []):
907
- skeleton_classes.add(c)
908
- # 附加常见栅格类(grammar 白名单)
909
- skeleton_classes.update({
910
- 'g-2', 'g-3', 'g-4', 'g-5', 'g-6', 'g-hero', 'g-hero--rev', 'g-side', 'g-side--rev',
911
- 'g-31', 'g-13', 'g-41', 'g-14', 'g-211', 'g-121', 'g-quad', 'g-aside', 'g-bento',
912
- 'g-hero-full', 'g-mosaic', 'rows-2', 'rows-3', 'stagger', 'media-grid', 'media-compare',
913
- })
914
-
915
- no_skel, multi_focus, align_miss, off_token = [], [], [], []
916
- icon_bad, label_collapse = [], []
917
- fill_low, fill_high = [], []
918
- mixed_need = bool(align_cfg.get('requireOnMixed', True))
919
- align_tokens = set(align_cfg.get('mixedGridClasses') or ['a-start', 'a-c', 'align-items'])
920
-
921
- for i, b in enumerate(_bands(txt), 1):
922
- head = b[:240]
923
- if any(f'id="{x}' in head for x in ('refs', 'appendix', 'cover', 'agenda', 'quote')):
924
- continue
925
- if 'band--flow' in head:
926
- continue
927
- # 轻量合法页:金句/强调带/大数压场(fill 目标放宽)
928
- is_light = ('band--fit' in head or 'band--accent' in head or
929
- 'band--deep' in head or 'quote' in head)
930
-
931
- # ① 骨架类(或 data-skel 标记)
932
- has_skel = ('data-skel="' in b) or any(
933
- re.search(rf'class="[^"]*\b{re.escape(c)}\b', b) for c in skeleton_classes)
934
- # 手写 inline grid 且无预设类
935
- inline_grid = re.findall(r'style="[^"]*display:\s*grid[^"]*"', b)
936
- if not has_skel and inline_grid:
937
- no_skel.append(f"第{i}页 inline grid 无 P1–P12 骨架类")
938
-
939
- # ② 单一视觉重心(粗启发式)
940
- n_big = 0
941
- for m in re.finditer(r'<svg\b[^>]*>', b):
942
- tag = m.group(0)
943
- wm = re.search(r'width:\s*(\d+(?:\.\d+)?)px', tag)
944
- wrap = int((re.search(r'--wrap:\s*(\d+)', txt) or [0, 1400])[1] or 1400) if False else 1400
945
- if wm and float(wm.group(1)) / wrap * 100 >= chart_w_pct:
946
- n_big += 1
947
- if re.search(r'width:\s*100%', tag) and 'data-chart' in tag:
948
- # 通宽图记 1 大件
949
- n_big += 1
950
- for m in re.finditer(r'font-size:\s*clamp\(\s*(\d+)px', b):
951
- if float(m.group(1)) >= metric_fz:
952
- n_big += 1
953
- break
954
- if re.search(r'class="[^"]*media--(?:full|bleed)', b):
955
- n_big += 1
956
- if n_big >= 2:
957
- multi_focus.append(f"第{i}页大件×{n_big}")
958
-
959
- # ③ 混排对齐(同时有图/媒体与卡/列表)
960
- mixed = (re.search(r'class="[^"]*(?:fig|media|chart)', b) and
961
- re.search(r'class="[^"]*(?:card|ul|metric)', b))
962
- if mixed and mixed_need:
963
- if not any(t in b for t in align_tokens):
964
- align_miss.append(f"第{i}页图卡混排缺 a-start/a-c")
965
-
966
- # ④ 间距写死(margin/padding/gap 非 token / 非 clamp;≤8px 微调白名单)
967
- for m in re.finditer(r'(?:margin|padding|gap)(?:-[a-z]+)?\s*:\s*([^;"]+)', b):
968
- val = m.group(1).strip()
969
- if not val or val.startswith('var(') or val.startswith('clamp(') or val.startswith('0'):
970
- continue
971
- if re.fullmatch(r'auto|inherit|initial|unset|normal', val):
972
- continue
973
- px_vals = [float(x) for x in re.findall(r'(\d+(?:\.\d+)?)px', val)]
974
- if px_vals and all(v <= 8 for v in px_vals) and 'clamp' not in val and 'var(' not in val:
975
- continue # 4–8px 光学微调允许
976
- if px_vals and 'clamp' not in val and 'var(' not in val:
977
- off_token.append(f"第{i}页 `{val[:24]}`")
978
-
979
- # ⑤ 图标尺寸
980
- for m in re.finditer(
981
- r'<svg\b[^>]*(?:class="[^"]*(?:ico|icon)[^"]*"|metric__ico|card__ico)[^>]*>',
982
- b, re.I):
983
- tag = m.group(0)
984
- sm = re.search(r'width:\s*["\']?(\d+)', tag) or re.search(r'width="(\d+)"', tag)
985
- if sm and int(sm.group(1)) not in icon_sizes:
986
- icon_bad.append(f"第{i}页 {sm.group(1)}px")
987
-
988
- # ⑥ 图表标签防换行(短盒 + 长 text)
989
- for m in re.finditer(r'<text[^>]*width="(\d+)"[^>]*>([^<]{8,})</text>', b):
990
- if int(m.group(1)) < 48:
991
- label_collapse.append(f"第{i}页标签盒宽{m.group(1)}")
992
-
993
- # ⑦ 结构图禁裸文字箭头(F13)
994
- if re.search(r'class="[^"]*lane__arr[^"]*"[^>]*>\s*→', b) or re.search(
995
- r'lane__arr">→', b):
996
- label_collapse.append(f"第{i}页泳道裸文字→(改 .lane__arr)")
997
-
998
- # ⑧ 填充率粗估(去空白字符 + 组件;轻量页不判过空)
999
- b2 = re.sub(r'<script\b[\s\S]*?</script>', ' ', b)
1000
- b2 = re.sub(r'<style\b[\s\S]*?</style>', ' ', b2)
1001
- plen = len(re.sub(r'\s+', '', _plain(b2)))
1002
- units = _band_units(b)
1003
- budget = MODE_BUDGETS.get(mode, MODE_BUDGETS['presentation'])
1004
- est = (plen / max(1, budget['fit']) * 55) + (units / max(1, budget['unit']) * 45)
1005
- est = max(0, min(100, est))
1006
- lo_t = fill_t[0] * 0.55
1007
- hi_t = min(95, fill_t[1] * 1.15)
1008
- if (not is_light) and est < lo_t and units == 0 and plen < budget['empty']:
1009
- fill_low.append(f"第{i}页≈{est:.0f}%")
1010
- elif est > hi_t and not is_light:
1011
- fill_high.append(f"第{i}页≈{est:.0f}%")
1012
-
1013
- chk(f"LAYOUT_NO_SKELETON 内容页使用 P1–P12 骨架类(layout-grammar)", not no_skel,
1014
- "; ".join(no_skel[:4]) if no_skel else "")
1015
- chk(f"LAYOUT_MULTI_FOCUS 一屏一视觉重心(大件 ≤{mf.get('maxPrimary', 1)})", not multi_focus,
1016
- "; ".join(multi_focus[:4]) if multi_focus else "")
1017
- chk("LAYOUT_ALIGN_DRIFT 图卡混排显式对齐(a-start / a-c)", not align_miss,
1018
- "; ".join(align_miss[:4]) if align_miss else "")
1019
- chk("LAYOUT_SPACING_OFF_TOKEN 间距走 --sp-*/gap/clamp(禁游离 px)", not off_token,
1020
- "; ".join(off_token[:4]) if off_token else "", level="WARN")
1021
- chk("LAYOUT_ICON_SIZE 图标 ∈ {16,18,20,24}px", not icon_bad,
1022
- "; ".join(icon_bad[:4]) if icon_bad else "", level="WARN")
1023
- chk("LAYOUT_LABEL_COLLIDE 图表标签盒足够宽 / 结构图无裸文字箭头", not label_collapse,
1024
- "; ".join(label_collapse[:4]) if label_collapse else "")
1025
- chk(f"LAYOUT_FILL 填充率目标 {fill_t[0]}–{fill_t[1]}%({mode})",
1026
- not fill_low and not fill_high,
1027
- ("过空: " + "; ".join(fill_low[:3]) if fill_low else "") +
1028
- ((" 过满: " + "; ".join(fill_high[:3])) if fill_high else ""))
1029
-
1030
-
1031
- def _check_annotations(txt, chk, model):
1032
- """待核实标注:.tbd 内联标色须配 .tbd-legend / .flagbar 说明;单页数量上限。"""
1033
- an = LC.get('annotations') or {}
1034
- max_per_page = an.get('flagMaxPerPage', 12)
1035
- require_legend = an.get('requireLegend', True)
1036
- tbd_re = re.compile(r'class="tbd(?:\s|")')
1037
- no_legend, over = [], []
1038
- for i, b in enumerate(_bands(txt), 1):
1039
- n = len(tbd_re.findall(b))
1040
- if n and require_legend and ('tbd-legend' not in b and 'flagbar' not in b):
1041
- no_legend.append(f"第{i}页")
1042
- if n > max_per_page:
1043
- over.append(f"第{i}页 {n}处")
1044
- chk("待核实标注 .tbd 均配 .tbd-legend/.flagbar 说明", not no_legend,
1045
- f"{no_legend} 有标色项但无说明(用户不知为何标色)" if no_legend else "", level="WARN")
1046
- chk(f"单页待核实标注 ≤ {max_per_page} 处(过密则转 .flagbar 清单)", not over,
1047
- "; ".join(over) if over else "", level="WARN")
1048
- if model:
1049
- mflags = [s for s in (model.get('sections') or []) if s.get('flags')]
1050
- if mflags and 'flagbar' not in txt and 'tbd-legend' not in txt:
1051
- chk("模型 flags ↔ 正文待核实条对应", False,
1052
- f"{len(mflags)} 页模型含 flags,正文无 .flagbar/.tbd-legend", level="WARN")
1053
-
1054
-
1055
- def _img_holders(sec):
1056
- """页内可能承载素材图片的容器(image 页型 / split 右栏)。"""
1057
- out = []
1058
- if isinstance(sec.get('image'), dict):
1059
- out.append(sec['image'])
1060
- right = sec.get('right')
1061
- if isinstance(right, dict) and isinstance(right.get('image'), dict):
1062
- out.append(right['image'])
1063
- return out
1064
-
1065
-
1066
- def _img_srcs(img):
1067
- """图片对象的全部真实 src(image.src + image.items[].src),排除占位符。"""
1068
- if img.get('placeholder'):
1069
- return []
1070
- srcs = []
1071
- if isinstance(img.get('src'), str) and img['src'].strip():
1072
- srcs.append(img['src'])
1073
- for it in (img.get('items') or []):
1074
- if isinstance(it, dict) and isinstance(it.get('src'), str) and it['src'].strip():
1075
- srcs.append(it['src'])
1076
- elif isinstance(it, str) and it.strip():
1077
- srcs.append(it)
1078
- return srcs
1079
-
1080
-
1081
- def _check_media(txt, chk, model):
1082
- """素材图片与配图占位:零外链铁律(<img> 只允许 data: 内联或相对路径);
1083
- alt 可访问性;配图占位必须有可见标签;模型 image 三选一(src/items/placeholder)
1084
- 与正文版式、数量、裁切策略一一对应。"""
1085
- imgs = re.findall(r'<img\b[^>]*>', txt)
1086
- if imgs:
1087
- bad = [t for t in imgs if re.search(r'src\s*=\s*["\']\s*(?:https?:)?//', t)]
1088
- chk("图片源无外链(data: 内联或相对路径,零外链铁律)", not bad,
1089
- f"{len(bad)} 处外链图片" if bad else "")
1090
- noalt = [t for t in imgs if 'alt=' not in t]
1091
- chk("图片均带 alt(可访问性)", not noalt,
1092
- f"{len(noalt)} 处缺 alt" if noalt else "", level="WARN")
1093
- data_imgs = [t for t in imgs if re.search(r'src\s*=\s*["\']\s*data:', t)]
1094
- inline_bytes = sum(len(t) for t in data_imgs)
1095
- chk("单图 data: 内联体积在上限内", all(len(t) <= IMAGE_MAX_INLINE for t in data_imgs),
1096
- f"最大 {max((len(t) for t in data_imgs), default=0) // 1024}KB > 上限 {IMAGE_MAX_INLINE // 1024}KB",
1097
- level="WARN")
1098
- chk("报告内联图片总量在上限内", inline_bytes <= IMAGE_MAX_TOTAL,
1099
- f"{inline_bytes // 1024}KB > 上限 {IMAGE_MAX_TOTAL // 1024}KB(改用相对路径)",
1100
- level="WARN")
1101
- ph_blocks = re.findall(r'class="[^"]*\bmedia--ph\b[^"]*"', txt)
1102
- if ph_blocks:
1103
- chk("配图占位含可见标签(.media__ph)", 'media__ph' in txt,
1104
- f"{len(ph_blocks)} 处 .media--ph 缺 .media__ph 标签(空占位 = 不合格)")
1105
- if not model:
1106
- return
1107
- holders = [(i, img) for i, s in enumerate(model.get('sections') or [])
1108
- if isinstance(s, dict) for img in _img_holders(s)]
1109
- if not holders:
1110
- return
1111
- bad_src, bad_layout, bad_fit, too_many = [], [], [], []
1112
- n_declared = 0
1113
- n_ph = 0
1114
- for idx, img in holders:
1115
- srcs = _img_srcs(img)
1116
- n_declared += len(srcs)
1117
- if img.get('placeholder'):
1118
- n_ph += 1
1119
- for s in srcs:
1120
- if re.match(r'\s*(?:https?:)?//', str(s)):
1121
- bad_src.append(f"sections[{idx}]")
1122
- layout = str(img.get('layout') or ('grid' if len(img.get('items') or []) > 1 else 'full')).lower()
1123
- if layout not in IMAGE_LAYOUTS:
1124
- bad_layout.append(f"sections[{idx}]:{layout}")
1125
- if img.get('fit') and str(img['fit']).lower() not in IMAGE_FIT:
1126
- bad_fit.append(f"sections[{idx}]:{img['fit']}")
1127
- items = img.get('items') or []
1128
- if len(items) > IMAGE_MAX_PER_PAGE:
1129
- too_many.append(f"sections[{idx}]={len(items)}")
1130
- if layout in IMAGE_MULTI_LAYOUTS and not img.get('placeholder') and len(items) < 2:
1131
- too_many.append(f"sections[{idx}] {layout} 需 ≥2 张图(当前 {len(items)})")
1132
- chk("模型 image.src 非外链(data:/相对路径)", not bad_src,
1133
- f"{len(bad_src)} 处外链 src" if bad_src else "")
1134
- chk("模型 image.layout 合法(imageSpec.layouts)", not bad_layout,
1135
- f"非法版式 {sorted(set(bad_layout))}" if bad_layout else "")
1136
- chk("模型 image.fit 合法(cover/contain)", not bad_fit,
1137
- f"非法裁切 {sorted(set(bad_fit))}" if bad_fit else "")
1138
- chk("单页图片数/多图版式图数合规(imageSpec.maxPerPage · 多图版式 ≥2 张)", not too_many,
1139
- f"{too_many}" if too_many else "", level="WARN")
1140
- layout_class = {'grid': 'media-grid', 'compare': 'media-compare', 'wall': 'media-wall',
1141
- 'bleed': 'media--bleed', 'ph': 'media--ph'}
1142
- ratio_css = IMAGE_SPEC.get('ratioCssClass') or {}
1143
- miss_cls = []
1144
- for idx, img in holders:
1145
- lay = str(img.get('layout') or ('grid' if len(img.get('items') or []) > 1 else 'full')).lower()
1146
- cls = layout_class.get(lay)
1147
- if cls and cls not in txt:
1148
- miss_cls.append(f"sections[{idx}]:{lay}→.{cls}")
1149
- # 比例锁定类:HTML 与 PPTX 用同一份 imageSpec.ratioCssClass,缺了就会出现"比例走样"
1150
- rcls = ratio_css.get(lay)
1151
- if rcls and ('.' + rcls) not in txt:
1152
- miss_cls.append(f"sections[{idx}]:{lay}→.{rcls}(比例未锁定)")
1153
- if img.get('placeholder') and 'media--ph' not in txt:
1154
- miss_cls.append(f"sections[{idx}]:placeholder→.media--ph")
1155
- chk("模型图片版式 ↔ 正文版式类/比例锁定类对应", not miss_cls,
1156
- f"缺 {sorted(set(miss_cls))}" if miss_cls else "", level="WARN")
1157
- if n_declared and len(imgs) < n_declared:
1158
- chk("模型 image ↔ 正文图片数量对应", False,
1159
- f"模型声明 {n_declared} 张但正文仅 {len(imgs)} 个 <img>", level="WARN")
1160
- if n_ph and not ph_blocks:
1161
- chk("模型配图占位 ↔ 正文 .media--ph 对应", False,
1162
- f"{n_ph} 页模型声明 image.placeholder 但正文无 .media--ph", level="WARN")
1163
-
1164
-
1165
- # 失败检查项 → 失败模式 / 处置动作 / 精确取码命令。
1166
- # 目的:校验失败时直接给出「改什么、按什么顺序改、去哪取代码」,
1167
- # 使智能体不必整读 references/failure-modes.md(17KB)就能收敛。
1168
- # 关键词按检查项名称匹配;新增检查项时同步在此登记,否则只回落通用处置顺序。
1169
- FIX_GUIDE = [
1170
- (("页高", "溢出", "满屏", "文字预算"), "F4 容器溢出",
1171
- "① 列表化/精炼 ② 升级承载形态 ③ 换/扩组合版式 ④ 分区 ⑤ 拆页 ⑥ 最后才有限缩字号",
1172
- "--task content-rules"),
1173
- (("过空", "内容不足", "密度"), "F1 内容不足 / F3 密度塌陷",
1174
- "补证据与含义(数字+口径+so-what),不要用装饰或放大字号填空",
1175
- "--task content-rules"),
1176
- (("图表", "多样性", "登记"), "F6 图表降级 / F12 图表单一",
1177
- "换图表类型拉开多样性;连续两页不得同型;类型须在 charts.registry 登记",
1178
- "--task chart-pick"),
1179
- (("最小尺寸",), "图表尺寸不足",
1180
- "把 svg 的 viewBox 高/显示宽度提到 charts.minSize 之上(尺寸问题改尺寸,不要靠换图型回避)",
1181
- "--task chart-pick"),
1182
- (("Exhibit",), "研究模式证据编号",
1183
- "每个 .exhibit 框都要有 .exhibit__no(Exhibit N,全篇连续)与 .exhibit__src 来源行",
1184
- "--task research-evidence"),
1185
- (("引用", "参考资料", "锚点"), "引用闭环",
1186
- "正文 [n] 上标与文末条目双向对齐且编号从 1 连续;ref-link 带 target/rel",
1187
- "--file components-atoms.md --section 7"),
1188
- (("待核实", "tbd"), "待核实标注",
1189
- "每处 .tbd 必须配 .tbd-legend 或 .flagbar 说明口径,只标色不解释即不合格",
1190
- "--file components-atoms.md --section 11b"),
1191
- (("强调", "风格", "主题", "配色"), "F8 主题/风格漂移",
1192
- "风格是皮肤不是解药:末页禁 band--deep,收尾用 band--accent;单一强调色",
1193
- "--file components-atoms.md --section 11"),
1194
- (("so-what", "结论", "标题"), "F15 空洞结论",
1195
- "research 主标题须是结论句(≥12 字含数字或判断词);so-what 禁套话",
1196
- "--task content-rules"),
1197
- (("版式", "节奏", "组合"), "F11 单件页默认",
1198
- "默认一页=主件+从件+注释;同一版式不连用超 2 页,密度 L/M/H 交替",
1199
- "--task presentation-combo"),
1200
- (("图片", "占位", "media"), "F9 配图走样 / F10 空占位",
1201
- "无素材用 image.placeholder + .media--ph 锁版式;路径以模型目录为锚;禁外链",
1202
- "--task image-layout"),
1203
- (("AI", "去AI味"), "文风",
1204
- "改写命中的高危词;大段文字转列表",
1205
- "--task content-rules"),
1206
- ]
1207
-
1208
-
1209
- def _print_fix_guide(results, strict, width):
1210
- """校验未通过时输出定向修复指引(只列命中的失败模式,不做全量倾倒)。"""
1211
- failed = [(lv, nm, nt) for lv, nm, ok, nt in results
1212
- if not ok and (lv == "FAIL" or strict)]
1213
- if not failed:
1214
- return
1215
- seen, guides = set(), []
1216
- for _, name, _ in failed:
1217
- nl = name.lower()
1218
- # 最长关键词优先(而非首个命中即 break):避免「图表最小尺寸」被泛化的
1219
- #「图表」关键词抢走、误路由到「换图表类型」
1220
- best = None
1221
- for keys, mode, action, cmd in FIX_GUIDE:
1222
- hit = max((len(k) for k in keys if k.lower() in nl), default=0)
1223
- if hit and (best is None or hit > best[0]):
1224
- best = (hit, mode, action, cmd)
1225
- if best and best[1] not in seen:
1226
- seen.add(best[1])
1227
- guides.append((best[1], best[2], best[3]))
1228
- print("-" * width)
1229
- print("修复指引(按下列顺序改;跳步直接缩字号/砍内容会把问题推给下一环):")
1230
- if guides:
1231
- for i, (mode, action, cmd) in enumerate(guides, 1):
1232
- print(f" {i}. [{mode}] {action}")
1233
- print(f" 取码: python scripts/extract_snippet.py {cmd}")
1234
- else:
1235
- print(" 未匹配到已登记的失败模式,按通用顺序处置:")
1236
- print(" ① 补内容 ② 优化形态(列表化/精炼)③ 换承载形态 "
1237
- "④ 调容器/网格 ⑤ 有限缩字号 ⑥ 最后拆页")
1238
- print(" 完整失败模式库与错误解释纠正表: references/failure-modes.md")
1239
-
1240
-
1241
- def main():
1242
- if len(sys.argv) < 2:
1243
- print(__doc__)
1244
- return 2
1245
- path = Path(sys.argv[1])
1246
- strict = '--strict' in sys.argv
1247
- as_json = '--json' in sys.argv
1248
- if not path.exists():
1249
- print(f"文件不存在: {path}")
1250
- return 2
1251
- txt = path.read_text(encoding='utf-8')
1252
-
1253
- v = Struct()
1254
- v.feed(txt)
1255
-
1256
- results = []
1257
-
1258
- def chk(name, ok, note="", level="FAIL"):
1259
- results.append((level, name, bool(ok), note))
1260
-
1261
- # ── 结构 ──
1262
- chk("HTMLParser 结构 0 未闭合", not v.stack, f"剩余 {v.stack}" if v.stack else "")
1263
- chk("HTMLParser 0 失配/游离", not v.mis and not v.err,
1264
- f"mis={v.mis} err={v.err}" if (v.mis or v.err) else "")
1265
-
1266
- # ── 主题与风格 ──
1267
- chk("data-style 已指定风格", 'data-style="' in txt)
1268
- chk('data-theme="light" + dark 变量块',
1269
- 'data-theme="light"' in txt and '[data-theme="dark"]' in txt)
1270
- chk("主题切换按钮 + localStorage + prefers-color-scheme",
1271
- 'id="themeBtn"' in txt and 'localStorage' in txt and 'prefers-color-scheme' in txt)
1272
-
1273
- # ── 模式 ──
1274
- mode_m = re.search(r'<html[^>]*data-mode="([^"]+)"', txt)
1275
- mode = mode_m.group(1) if mode_m else None
1276
- chk("data-mode 已声明模式(presentation/research/architecture)",
1277
- mode in MODE_BUDGETS,
1278
- f"读到 {mode!r}(未声明按 presentation 处理)" if mode not in MODE_BUDGETS else "",
1279
- level="WARN" if mode is None else "FAIL")
1280
- mode = mode if mode in MODE_BUDGETS else 'presentation'
1281
-
1282
- # ── 宽屏与页面高度模型 ──
1283
- B0 = MODE_BUDGETS[mode]
1284
- _w = B0['wrap']
1285
- chk(f"版心 --wrap: {_w}px({mode} 模式)",
1286
- f'--wrap:{_w}px' in txt or f'--wrap: {_w}px' in txt)
1287
- _check_page_model(txt, chk, mode)
1288
- chk("scroll-snap 翻页停靠", 'scroll-snap-type' in txt)
1289
-
1290
- # ── 翻页与大纲 ──
1291
- chk("翻页 JS(方向键 + scrollIntoView)",
1292
- 'scrollIntoView' in txt and 'ArrowRight' in txt)
1293
- chk("页码指示 pager", 'pager__dot' in txt and 'id="pagerDots"' in txt)
1294
- has_agenda = 'id="agenda"' in txt
1295
- if mode == 'architecture':
1296
- # architecture 极简形态:内容页 ≤4 时省略 Agenda 合法(封面/收尾/参考资料不计内容页)
1297
- struct_ids = ('agenda', 'next', 'refs', 'appendix')
1298
- bands_a = _bands(txt)
1299
- content_n = 0
1300
- for i, b in enumerate(bands_a):
1301
- if i == 0:
1302
- continue
1303
- if any(f'id="{sid}"' in b[:220] for sid in struct_ids):
1304
- continue
1305
- content_n += 1
1306
- if content_n > 4:
1307
- chk("存在 Agenda 大纲页(architecture 内容页 >4 时建议补)", has_agenda,
1308
- f"内容页 {content_n} 页无 Agenda", level="WARN")
1309
- else:
1310
- chk("存在 Agenda 大纲页", has_agenda)
1311
- if has_agenda:
1312
- band_secs = [m.start() for m in re.finditer(r'<section class="band', txt)]
1313
- agenda_pos = txt.find('id="agenda"')
1314
- second_band_pos = band_secs[1] if len(band_secs) > 1 else -1
1315
- ok_second = (second_band_pos != -1 and
1316
- band_secs[0] < agenda_pos < (band_secs[2] if len(band_secs) > 2 else len(txt)))
1317
- chk("Agenda 位于第二页", ok_second,
1318
- f"共{len(band_secs)}页 agenda@{agenda_pos} 第2页@{second_band_pos}")
1319
- n_items = txt.count('class="agenda__a"')
1320
- chk("Agenda 条目可点击跳转 (≥4)", n_items >= 4, f"{n_items} 条")
1321
- if n_items > AGENDA_SINGLE_MAX:
1322
- chk(f"Agenda >{AGENDA_SINGLE_MAX} 条时已用 agenda--2col 双列",
1323
- 'agenda--2col' in txt, f"{n_items} 条未双列", level="WARN")
1324
-
1325
- # ── 主标题粗体 ──
1326
- chk("主标题粗体 --fw-title/--fw-display",
1327
- ('--fw-title' in txt and 'font-weight:var(--fw-title)' in txt) or
1328
- re.search(r'\.t-h1\{[^}]*font-weight:\s*(600|700)', txt))
1329
-
1330
- # ── 零外链 ──
1331
- ext = re.findall(r'<(?:link|script)[^>]+(?:href|src)=["\']https?://[^"\']+', txt)
1332
- chk("无 <link>/<script src> 外链", not ext, f"{len(ext)} 处" if ext else "")
1333
- img_ext = re.findall(r'<img[^>]+src=["\']https?://', txt)
1334
- chk("无外链图片", not img_ext, f"{len(img_ext)} 处" if img_ext else "")
1335
-
1336
- # ── 引用与锚点闭环 ──
1337
- # 编号须匹配多位数:`ref-\d` 会让 ref-10 及以后的条目在两侧同时落空,
1338
- # 使双向对齐检查在 ≥10 条引用时静默通过(假阴性)。
1339
- cites = re.findall(r'class="cite" href="#(ref-\d+)"', txt)
1340
- refs = re.findall(r'id="(ref-\d+)"', txt)
1341
- if cites or refs:
1342
- chk("引用标记 ↔ 参考资料条目 双向对齐", set(cites) == set(refs),
1343
- f"引用{sorted(set(cites))} 条目{sorted(set(refs))}")
1344
- nums = sorted(int(r.split('-')[1]) for r in set(refs))
1345
- chk(f"参考资料编号连续(1..{len(nums)} 无跳号)", nums == list(range(1, len(nums) + 1)),
1346
- f"读到 {nums}" if nums != list(range(1, len(nums) + 1)) else "")
1347
- else:
1348
- chk("引用标记 ↔ 参考资料条目 双向对齐", True, "(无外部引用)", level="WARN")
1349
- bad_links = re.findall(r'<a class="ref-link"(?![^>]*target="_blank")[^>]*>', txt) + \
1350
- re.findall(r'<a class="ref-link"(?![^>]*rel="noopener")[^>]*>', txt)
1351
- chk('.ref-link 均带 target="_blank" rel="noopener"', not bad_links,
1352
- f"{len(bad_links)} 处" if bad_links else "")
1353
- _check_anchors(txt, chk)
1354
-
1355
- # ── 单一强调色(色值表来自 layout-constants.json styleAccents 单源) ──
1356
- style_m = re.search(r'<html[^>]*data-style="([^"]+)"', txt)
1357
- style = style_m.group(1) if style_m else 'business-blue'
1358
- body_txt = txt[txt.find('</style>'):] if '</style>' in txt else txt
1359
- body_txt = re.sub(r'<script\b[\s\S]*?</script>', ' ', body_txt)
1360
- if STYLE_ACCENTS:
1361
- own = STYLE_ACCENTS.get(style, set())
1362
- others = ALL_ACCENTS - own
1363
- bad_hues = [h for h in others if h in body_txt.lower()]
1364
- if style == 'spectrum':
1365
- chk("彩色模块边界(spectrum:数据色限 c1–c5,无其它风格强调色)", not bad_hues,
1366
- f"残留 {bad_hues}" if bad_hues else "")
1367
- else:
1368
- chk(f"单一强调色({style},正文无第二色相)", not bad_hues,
1369
- f"残留 {bad_hues}" if bad_hues else "")
1370
-
1371
- hard_hex = re.findall(r'style="[^"]*(?:color|background|border-color|fill|stroke)\s*:\s*#',
1372
- body_txt)
1373
- chk("正文无内联写死 hex 色(用 var()/语义类)", not hard_hex,
1374
- f"{len(hard_hex)} 处" if hard_hex else "", level="WARN")
1375
-
1376
- # ── 内容与密度 ──
1377
- paras = re.findall(r'<p class="t-body"[^>]*>(.*?)</p>', txt, re.S)
1378
- long_paras = [x for x in paras if len(re.sub(r'<[^>]+>', '', x).strip()) > 110]
1379
- chk("卡片内无超 110 字纯段落(应转列表)", not long_paras,
1380
- f"{len(long_paras)} 段" if long_paras else "")
1381
- _check_bands(txt, chk, mode)
1382
- _check_table_rows(txt, chk, mode)
1383
- _check_mode_layouts(txt, chk, mode)
1384
- if mode != 'architecture':
1385
- _check_icons(txt, chk)
1386
- _check_pptx_export(txt, chk)
1387
- model = _check_model_consistency(txt, chk, mode, style)
1388
- _check_type_features(txt, chk, model)
1389
- _check_exhibits(txt, chk, mode)
1390
- _check_research_extras(txt, chk, mode)
1391
- _check_emphasis(txt, chk)
1392
- _check_annotations(txt, chk, model)
1393
- _check_media(txt, chk, model)
1394
-
1395
- if mode == 'research':
1396
- STRUCT = ('报告大纲', '大纲', '参考资料', '数据来源', '下一步', '结论', '全文核心', 'agenda', '附录')
1397
- h1s = re.findall(r'<h2 class="t-h1 shead__title"[^>]*>(.*?)</h2>', txt)
1398
- short = [re.sub(r'<[^>]+>', '', h).strip() for h in h1s]
1399
- short = [h for h in short if 0 < len(h) < 12 and not h.lower().startswith(STRUCT)]
1400
- chk("research 行动标题(章节主标题 ≥12 字,标题即结论)", not short,
1401
- f"过短: {short[:3]}" if short else "", level="WARN")
1402
-
1403
- _check_content_quality(txt, chk, mode, model)
1404
- _check_v9_hard_gates(txt, chk, model)
1405
- _check_layout_grammar(txt, chk, mode)
1406
-
1407
- # ── 去AI味(词表来自单源) ──
1408
- body_plain = _plain(body_txt)
1409
- hits = [w for w in AI_FLAVOR if re.search(w, body_plain)]
1410
- chk("去AI味(无高危 AI 腔词汇)", not hits, f"命中 {hits}" if hits else "", level="WARN")
1411
-
1412
- # ── 图表 ──
1413
- _check_charts(txt, chk)
1414
- _check_chart_variety(txt, chk, mode)
1415
-
1416
- # ── 汇总 ──
1417
- n_fail = sum(1 for lv, _, ok, _ in results if not ok and lv == "FAIL")
1418
- n_warn = sum(1 for lv, _, ok, _ in results if not ok and lv == "WARN")
1419
- n_pass = sum(1 for _, _, ok, _ in results if ok)
1420
- verdict = "通过,可交付" if n_fail == 0 and (not strict or n_warn == 0) else \
1421
- ("有警告" if n_fail == 0 else "不通过,需修复后重跑")
1422
- exit_code = 1 if (n_fail > 0 or (strict and n_warn > 0)) else 0
1423
-
1424
- if as_json:
1425
- print(json.dumps({
1426
- "file": str(path),
1427
- "strict": strict,
1428
- "pass": n_pass, "warn": n_warn, "fail": n_fail,
1429
- "verdict": verdict,
1430
- "checks": [{"level": lv, "name": nm, "ok": ok, "note": nt}
1431
- for lv, nm, ok, nt in results],
1432
- }, ensure_ascii=False, indent=2))
1433
- return exit_code
1434
-
1435
- W = 68
1436
- print("=" * W)
1437
- print(f"TopPPT HTML· 质量校验 {LC.get('version', '')} {'[STRICT]' if strict else ''}")
1438
- print(f"文件: {path.name} ({len(txt.encode('utf-8'))/1024:.1f} KB)")
1439
- print("=" * W)
1440
- for level, name, ok, note in results:
1441
- tag = "PASS" if ok else ("WARN" if level == "WARN" else "FAIL")
1442
- line = f"[{tag}] {name}"
1443
- if note and not ok:
1444
- line += f" -> {note}"
1445
- print(line)
1446
- print("-" * W)
1447
- print(f"PASS {n_pass} WARN {n_warn} FAIL {n_fail}")
1448
- print(f"结论: {verdict}")
1449
- if exit_code != 0:
1450
- _print_fix_guide(results, strict, W)
1451
- print("=" * W)
1452
- return exit_code
1453
-
1454
-
1455
- if __name__ == '__main__':
1456
- sys.exit(main())
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ TopPPT HTML· HTML 报告质量校验(交付闭环)
5
+ 用法:
6
+ python validate_report.py <报告.html> [--strict] [--json]
7
+
8
+ 输出每项 PASS/FAIL,结尾给汇总与结论;任一 FAIL 时退出码为 1(--strict 时 WARN 也计失败)。
9
+ --json 以 JSON 输出全部检查结果(供脚本/流水线读取)。
10
+ 生成流程:生成 → 跑本脚本 → 修复 FAIL → 再跑,直至全部 PASS 才交付。
11
+
12
+ 阈值全部来自单源 scripts/layout-constants.json(checkBudgets / charts / styleAccents / aiFlavor),
13
+ 本脚本不再内置任何风格色值或预算数字——改阈值只改 JSON。
14
+
15
+ 检查面:
16
+ 结构闭合 / 主题风格 / 页面高度模型 / 宽屏翻页 / Agenda(architecture 极简形态可省略) / 粗体 /
17
+ 零外链 / 引用与锚点闭环 / 单一强调色 / 内联写死色 / 内容密度 / 单页文字与组件预算 /
18
+ 表格行数上限 / 页高溢出估算 / 去AI味 / 图表存在与动效 / 图表 data-chart 登记 / 图表最小尺寸 /
19
+ 模式版式特征(含新页型 steps/heatmap/bullet/pyramid/image 与 research/architecture 专属) /
20
+ 模型一致性(REPORT_MODEL ↔ 正文) / Exhibit 编号连续 / research so-what 与来源行 /
21
+ 强调带约束(band--deep 反相页 ≤ 上限且不作末页) / 待核实标注(.tbd ↔ .tbd-legend/.flagbar) /
22
+ 素材图片与配图占位(零外链 + alt + 内联体积 + 版式/比例锁定类对应 + 占位可见标签 + 模型对应) /
23
+ PPTX 预览配套(预览按钮 + 模型 + 运行时,页面无导出按钮) /
24
+ 内容级质量(版式节奏连用上限 / so-what 实质与空洞套话 / research 标题含数字或判断词)
25
+ """
26
+ import sys
27
+ import re
28
+ import json
29
+ from html.parser import HTMLParser
30
+ from pathlib import Path
31
+
32
+ # Windows GBK 控制台兜底:强制 UTF-8 输出(含 ↔ 等符号)
33
+ try:
34
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
35
+ sys.stderr.reconfigure(encoding='utf-8', errors='replace')
36
+ except Exception:
37
+ pass
38
+
39
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
40
+ import checks_html # noqa: E402 承载正则 / 图表通道 / 多样性 / 组合版式单源
41
+
42
+ VOID = {'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta',
43
+ 'param', 'source', 'track', 'wbr'}
44
+
45
+ # ── 单源阈值(scripts/layout-constants.json)────────────────────────────────
46
+ _DEFAULTS = {
47
+ 'checkBudgets': {
48
+ 'presentation': dict(char=1500, unit=8, empty=80, fit=1000, wrap=1400, trow=8),
49
+ 'research': dict(char=3200, unit=12, empty=120, fit=2200, wrap=1240, trow=16),
50
+ 'architecture': dict(char=600, unit=3, empty=60, fit=600, wrap=1600, trow=6),
51
+ },
52
+ 'charts': {'types': [], 'minSize': {}},
53
+ 'styleAccents': {},
54
+ 'aiFlavor': {'words': []},
55
+ 'imageSpec': {},
56
+ 'contentQuality': {},
57
+ }
58
+
59
+
60
+ def load_constants():
61
+ path = Path(__file__).resolve().parent / 'layout-constants.json'
62
+ try:
63
+ lc = json.loads(path.read_text(encoding='utf-8'))
64
+ except (OSError, json.JSONDecodeError) as exc:
65
+ print(f'警告: 无法读取单源 {path}({exc}),使用内置默认阈值。')
66
+ return _DEFAULTS
67
+ for key, fallback in _DEFAULTS.items():
68
+ lc.setdefault(key, fallback)
69
+ return lc
70
+
71
+
72
+ LC = load_constants()
73
+ MODE_BUDGETS = LC['checkBudgets']
74
+ CHART_TYPES = set(LC['charts'].get('types') or [])
75
+ CHART_MIN = LC['charts'].get('minSize') or {}
76
+ # 图表多样性预算(治「登记 36 种、实际只用 bar/line/donut」)
77
+ CHART_VARIETY = LC['charts'].get('variety') or {}
78
+ AI_FLAVOR = LC['aiFlavor'].get('words') or []
79
+ STYLE_ACCENTS = {k: {str(h).lower() for h in v}
80
+ for k, v in (LC['styleAccents'] or {}).items()
81
+ if not k.startswith('$') and isinstance(v, list)}
82
+ ALL_ACCENTS = set().union(*STYLE_ACCENTS.values()) if STYLE_ACCENTS else set()
83
+ # 图表登记四元组(charts.registry)与信息图页型(数据可追溯策略核对)
84
+ CHART_REG = {k: v for k, v in (LC['charts'].get('registry') or {}).items()
85
+ if not str(k).startswith('$')}
86
+ INFO_PAGE_TYPES = {'sankey', 'treemap', 'boxplot', 'network', 'marimekko', 'streamgraph'}
87
+ # 素材图片规格与配图占位约定(单源 layout-constants.json imageSpec)
88
+ IMAGE_SPEC = LC.get('imageSpec') or {}
89
+ IMAGE_LAYOUTS = set(IMAGE_SPEC.get('layouts') or ['full', 'half', 'bleed', 'grid', 'compare', 'wall'])
90
+ IMAGE_MULTI_LAYOUTS = set(IMAGE_SPEC.get('multiLayouts') or ['grid', 'compare', 'wall'])
91
+ IMAGE_FIT = set(IMAGE_SPEC.get('fitEnum') or ['cover', 'contain'])
92
+ IMAGE_MAX_PER_PAGE = int(IMAGE_SPEC.get('maxPerPage') or 6)
93
+ IMAGE_MAX_INLINE = int(IMAGE_SPEC.get('maxInlineBytes') or 1572864)
94
+ IMAGE_MAX_TOTAL = int(IMAGE_SPEC.get('maxTotalInlineBytes') or 8388608)
95
+
96
+ AGENDA_SINGLE_MAX = 8 # 超过则建议 agenda--2col
97
+ # 页高估算参数(单源 layout-constants.pageHeightEstimate)
98
+ _HE = LC.get('pageHeightEstimate') or {}
99
+ SCREEN_BUDGET_PX = int(_HE.get('screenBudgetPx') or 1000)
100
+ WRAP_INSET_PX = int(_HE.get('wrapInsetPx') or 112)
101
+ SHEAD_PX = int(_HE.get('sheadPx') or 190)
102
+ UNIT_PX = int(_HE.get('unitPx') or 170)
103
+ LINE_FACTOR = float(_HE.get('lineFactor') or 1.65)
104
+ STRUCT_PAGE_IDS = ('refs', 'appendix')
105
+
106
+
107
+ class Struct(HTMLParser):
108
+ def __init__(self):
109
+ super().__init__()
110
+ self.stack, self.err, self.mis = [], [], []
111
+
112
+ def handle_starttag(self, tag, attrs):
113
+ if tag not in VOID:
114
+ self.stack.append(tag)
115
+
116
+ def handle_endtag(self, tag):
117
+ if tag in VOID:
118
+ return
119
+ if self.stack and self.stack[-1] == tag:
120
+ self.stack.pop()
121
+ else:
122
+ try:
123
+ i = len(self.stack) - 1 - self.stack[::-1].index(tag)
124
+ self.mis.extend(self.stack[i:])
125
+ self.stack = self.stack[:i]
126
+ except ValueError:
127
+ self.err.append(tag)
128
+
129
+
130
+ def _plain(html):
131
+ return re.sub(r'<[^>]+>', ' ', html)
132
+
133
+
134
+ def _bands(txt):
135
+ parts = re.split(r'(?=<section class="band)', txt)
136
+ return [p for p in parts if p.startswith('<section class="band')]
137
+
138
+
139
+ def _band_units(b):
140
+ """承载组件数:卡/指标/图/表 + 列表组/结构/步骤/媒体/矩阵(组合版式与 underfill 同口径)。"""
141
+ return (len(re.findall(r'class="(?:card|metric)[\s"]', b)) +
142
+ len(re.findall(r'class="fig[\s"]', b)) +
143
+ len(re.findall(r'class="tbl-wrap', b)) +
144
+ len(re.findall(r'class="(?:arch|lane|steps|heat|bul|pyr|media|matrix|exhibit)[\s"]', b)) +
145
+ len(re.findall(r'class="ul[\s"]', b)) +
146
+ len(re.findall(r'class="cols-[23]', b)))
147
+
148
+
149
+ def _band_heavy_units(b):
150
+ """页高估算用「重承载行数」:卡/指标按栅格列数折行;图/表/结构各计 1 行级。"""
151
+ cards = len(re.findall(r'class="(?:card|metric)[\s"]', b))
152
+ figs = len(re.findall(r'class="fig[\s"]', b))
153
+ tables = len(re.findall(r'class="tbl-wrap', b))
154
+ structs = len(re.findall(r'class="(?:arch|lane|media|heat|exhibit)[\s"]', b))
155
+ if re.search(r'class="[^"]*\bg-4\b', b):
156
+ cols = 4
157
+ elif re.search(r'class="[^"]*\bg-3\b', b):
158
+ cols = 3
159
+ elif re.search(r'class="[^"]*\b(?:g-2|g-side|g-hero|g-half)\b', b):
160
+ cols = 2
161
+ else:
162
+ cols = 1
163
+ card_rows = (cards + cols - 1) // cols if cards else 0
164
+ return card_rows + figs + tables + structs
165
+
166
+
167
+ def _check_charts(txt, chk):
168
+ """图表:存在性 / data-chart 登记 / 动效 / 最小尺寸。"""
169
+ svgs = re.findall(r'<svg\b[^>]*>', txt)
170
+ chart_tags = [t for t in svgs if 'class="chart' in t or "class='chart" in t or 'data-chart' in t]
171
+ chk("含内联 SVG 图表/图形", len(svgs) >= 1, f"{len(svgs)} 个", level="WARN")
172
+ if not chart_tags:
173
+ return
174
+
175
+ untyped = [t for t in chart_tags if 'data-chart' not in t]
176
+ chk("图表 svg 均有 data-chart 类型标记", not untyped,
177
+ f"{len(untyped)} 个未标记", level="WARN")
178
+
179
+ # data-chart 类型须在登记表内(layout-constants.json charts.types)
180
+ unknown = []
181
+ if CHART_TYPES:
182
+ for t in chart_tags:
183
+ m = re.search(r'data-chart="([^"]+)"', t)
184
+ if m and m.group(1) not in CHART_TYPES:
185
+ unknown.append(m.group(1))
186
+ chk("图表类型在登记表内(layout-constants.json charts.types)", not unknown,
187
+ f"未登记: {sorted(set(unknown))}", level="WARN")
188
+
189
+ has_io = 'IntersectionObserver' in txt
190
+ has_anim = 'data-anim' in txt or 'data-draw' in txt or 'data-sweep' in txt or 'data-count' in txt
191
+ chk("图表动效(IntersectionObserver + data-anim/draw/sweep/count)",
192
+ has_io and has_anim,
193
+ "" if (has_io and has_anim) else f"IO={'有' if has_io else '无'} anim={'有' if has_anim else '无'}",
194
+ level="WARN")
195
+
196
+ too_small, small_note = [], []
197
+ for t in chart_tags:
198
+ m = re.search(r'data-chart="([^"]+)"', t)
199
+ ctype = m.group(1) if m else None
200
+ rule = CHART_MIN.get(ctype, {}) if ctype else {}
201
+ wm = re.search(r'style="[^"]*?width:\s*(\d+(?:\.\d+)?)px', t)
202
+ vb = re.search(r'viewBox="[\d.\-]+ [\d.\-]+ ([\d.]+) ([\d.]+)"', t)
203
+ if wm and rule.get('pxWidthMin'):
204
+ w = float(wm.group(1))
205
+ if w < rule['pxWidthMin']:
206
+ too_small.append(f"{ctype} 宽{w:.0f}px<{rule['pxWidthMin']}px")
207
+ if vb and rule.get('vbHeightMin'):
208
+ h = float(vb.group(2))
209
+ if h < rule['vbHeightMin']:
210
+ small_note.append(f"{ctype} viewBox高{h:.0f}<{rule['vbHeightMin']}")
211
+ chk("图表最小尺寸·硬下限(环形/雷达/仪表盘显示宽)", not too_small,
212
+ "; ".join(too_small) if too_small else "")
213
+ chk("图表最小尺寸·建议(通宽图 viewBox 高)", not small_note,
214
+ "; ".join(small_note) if small_note else "", level="WARN")
215
+
216
+
217
+ CARRIERS = checks_html.CARRIERS # 单源 scripts/checks_html.py
218
+
219
+
220
+ def _check_chart_variety(txt, chk, mode):
221
+ """图表与版式多样性(阈值单源 charts.variety;判定逻辑 checks_html)。"""
222
+ v = CHART_VARIETY
223
+ if not v:
224
+ return
225
+ per_page = checks_html.chart_types_per_page(txt)
226
+ used = [t for ts in per_page for t in ts]
227
+ if not used:
228
+ return
229
+ distinct = sorted(set(used))
230
+ n_chart_pages = len([1 for ts in per_page if ts])
231
+
232
+ floor = checks_html.variety_floor(mode, n_chart_pages, v)
233
+ chk("图表多样性(全篇不同 data-chart 类型数)", len(distinct) >= floor,
234
+ f"{len(distinct)} 种 / 下限 {floor}({n_chart_pages} 个图表页): {distinct}")
235
+
236
+ if v.get('noRepeatAdjacent'):
237
+ adj = checks_html.adjacent_same_type(per_page)
238
+ chk("图表不连续同型(相邻图表页不得同 data-chart)", not adj,
239
+ "; ".join(adj[:3]) if adj else "", level="WARN")
240
+
241
+ ratio = checks_html.composite_required(mode, v)
242
+ if ratio > 0:
243
+ multi, n_content = checks_html.composite_pages(txt)
244
+ need = max(1, int(n_content * ratio))
245
+ chk("组合版式(内容页含 ≥2 种承载类型的比例)", multi >= need,
246
+ f"{multi}/{n_content} 页(下限 {need})——单件页是例外不是默认",
247
+ level="WARN")
248
+
249
+
250
+ def _check_page_model(txt, chk, mode):
251
+ """页面高度模型(每页高度稳定):所有 .band 至少一屏高,长结构页显式 --flow 退出。"""
252
+ has_model = ('--band-min' in txt) or ('calc(100svh - var(--bar-h))' in txt) or \
253
+ ('min-height:calc(100vh - var(--bar-h))' in txt)
254
+ chk("页面高度模型(--band-min / 一屏最小高度)", has_model,
255
+ "缺少每页一屏的 min-height 机制(见 design-system.md §1c)")
256
+ bands = _bands(txt)
257
+ if not bands:
258
+ return
259
+ # 固定页高页(非 --flow)应占多数;长结构页才允许 flow
260
+ flow_pages = []
261
+ for i, b in enumerate(bands, 1):
262
+ if 'band--flow' in b[:220]:
263
+ flow_pages.append(i)
264
+ bad_flow = []
265
+ for i in flow_pages:
266
+ b = bands[i - 1]
267
+ if not any(f'id="{pid}"' in b for pid in STRUCT_PAGE_IDS):
268
+ bad_flow.append(f"第{i}页")
269
+ chk("band--flow 仅用于长结构页(参考资料/附录)", not bad_flow,
270
+ f"{bad_flow} 使用 --flow 但非结构页(应改用默认一屏页)" if bad_flow else "", level="WARN")
271
+
272
+
273
+ def _check_bands(txt, chk, mode="presentation"):
274
+ """单页预算与布局:文字量/组件数(防塞爆)· 过空页 · 页高溢出估算 · 混排对齐。"""
275
+ B = MODE_BUDGETS.get(mode, MODE_BUDGETS['presentation'])
276
+ bands = _bands(txt)
277
+ over_chars, over_units, too_empty, fit_overload = [], [], [], []
278
+ for i, b in enumerate(bands, 1):
279
+ if 'id="refs"' in b or 'id="appendix"' in b:
280
+ continue
281
+ b_clean = re.sub(r'<script\b[\s\S]*?</script>', ' ', b)
282
+ b_clean = re.sub(r'<style\b[\s\S]*?</style>', ' ', b_clean)
283
+ plain_len = len(_plain(b_clean).strip())
284
+ units = _band_units(b)
285
+ if plain_len > B["char"]:
286
+ over_chars.append(f"第{i}页 {plain_len}字")
287
+ if units > B["unit"]:
288
+ over_units.append(f"第{i}页 {units}个")
289
+ head_tag = b[:220]
290
+ is_fit = 'band--fit' in head_tag
291
+ is_deep = 'band--deep' in head_tag
292
+ if is_fit and plain_len > B["fit"]:
293
+ fit_overload.append(f"第{i}页 {plain_len}字")
294
+ if (not is_fit and not is_deep and units == 0 and plain_len < B["empty"]):
295
+ too_empty.append(f"第{i}页 {plain_len}字")
296
+ chk(f"单页文字量 ≤ {B['char']} 字({mode} 模式 · 防溢出)", not over_chars,
297
+ "; ".join(over_chars) if over_chars else "", level="WARN")
298
+ chk(f"单页并列单元 ≤ {B['unit']} 个({mode} 模式 · 防塞爆)", not over_units,
299
+ "; ".join(over_units) if over_units else "", level="WARN")
300
+ # v9:过空页升为 FAIL(截图级「只有标题」缺陷不得假过)
301
+ chk(f"无过空页({mode} 模式 · 普通页 ≥{B['empty']} 字或有组件;防大面积留白)", not too_empty,
302
+ "; ".join(too_empty) if too_empty else "")
303
+ chk(f"满屏居中页 band--fit 内容 ≤ {B['fit']} 字({mode} 模式 · 防垂直溢出)", not fit_overload,
304
+ "; ".join(fit_overload) if fit_overload else "", level="WARN")
305
+
306
+ # 页高溢出静态估算:字数×行高 + 组件固定高 vs 一屏预算(参数单源 pageHeightEstimate / checkBudgets)。
307
+ # 固定页高页(无 --flow)超预算即 FAIL——这是"每页高度稳定"的核心硬门禁。
308
+ fs_px = int(B.get('bodyPx') or 17)
309
+ wrap_px = int(B.get('wrap') or 1400) - WRAP_INSET_PX
310
+ cpl = max(10, int(wrap_px / fs_px))
311
+ est_over = []
312
+ for i, b in enumerate(bands, 1):
313
+ if 'id="refs"' in b or 'id="appendix"' in b:
314
+ continue
315
+ if 'band--flow' in b[:220]:
316
+ continue
317
+ b_clean = re.sub(r'<script\b[\s\S]*?</script>', ' ', b)
318
+ b_clean = re.sub(r'<style\b[\s\S]*?</style>', ' ', b_clean)
319
+ plen = len(_plain(b_clean).strip())
320
+ u = _band_heavy_units(b)
321
+ est_h = plen / cpl * fs_px * LINE_FACTOR + u * UNIT_PX + SHEAD_PX
322
+ # 静态估算容差 8%(真值以浏览器为准;轻微超出仍按 FAIL 会误伤密排页)
323
+ if est_h > SCREEN_BUDGET_PX * 1.08:
324
+ est_over.append(f"第{i}页≈{est_h:.0f}px")
325
+ chk(f"页高溢出估算(字×行高+组件 ≤ ~{SCREEN_BUDGET_PX}px/屏 · {mode})", not est_over,
326
+ ("; ".join(est_over) + "(按「列表化/精炼→压缩→多列→拆页」处理,或给长结构页加 band--flow)")
327
+ if est_over else "")
328
+
329
+ bad_grids = 0
330
+ for m in re.finditer(r'<div class="grid[^"]*"([^>]*)>', txt):
331
+ attrs = m.group(1)
332
+ sec_end = txt.find('</section>', m.end())
333
+ ahead = txt[m.end(): sec_end if sec_end != -1 else m.end() + 4000]
334
+ if 'class="fig' in ahead and 'class="card' in ahead:
335
+ aligned = ('align-items' in attrs or
336
+ re.search(r'\ba-(c|start|end)\b', m.group(0)))
337
+ if not aligned:
338
+ bad_grids += 1
339
+ chk("混排栅格(图+卡)显式对齐(a-start/a-c 或 align-items)", bad_grids == 0,
340
+ f"{bad_grids} 处未对齐" if bad_grids else "", level="WARN")
341
+
342
+
343
+ def _check_icons(txt, chk):
344
+ """图标使用:长报告不应全文无图标(要点卡/关键列表至少一处)。"""
345
+ if len(_bands(txt)) < 6:
346
+ return
347
+ has_icon = ('ul--ico' in txt or 'metric__ico' in txt or
348
+ re.search(r'card__ico"[^>]*>\s*<svg', txt) or
349
+ re.search(r'card__ico">\s*<svg', txt))
350
+ chk("长报告(≥6页)要点卡/关键列表有图标", bool(has_icon),
351
+ "全文未用图标(见 icons.md 使用准则)", level="WARN")
352
+
353
+
354
+ def _check_table_rows(txt, chk, mode="presentation"):
355
+ """表格行数上限(research 密表 ≤16 / presentation ≤8 / architecture ≤6;宁拆勿挤)。"""
356
+ B = MODE_BUDGETS.get(mode, MODE_BUDGETS['presentation'])
357
+ max_rows = B['trow']
358
+ over = []
359
+ for i, m in enumerate(re.finditer(r'<tbody>([\s\S]*?)</tbody>', txt), 1):
360
+ n = len(re.findall(r'<tr', m.group(1)))
361
+ if n > max_rows:
362
+ over.append(f"表{i} {n}行")
363
+ chk(f"表格行数 ≤ {max_rows} 行({mode} 模式 · 宁拆勿挤)", not over,
364
+ "; ".join(over) if over else "", level="WARN")
365
+
366
+
367
+ def _check_mode_layouts(txt, chk, mode):
368
+ """模式版式特征:三模式独立体系,各自应有标志性组件。"""
369
+ if mode == 'research':
370
+ has = ('class="cols-2' in txt or 'class="exhibit' in txt or
371
+ 'class="cols-3' in txt or 'class="matrix' in txt)
372
+ chk("research 版式特征(cols-2/cols-3/exhibit/matrix 至少一处)", has,
373
+ "未见研究模式标志性组件", level="WARN")
374
+ elif mode == 'architecture':
375
+ has = ('class="arch' in txt or 'class="lane' in txt)
376
+ chk("architecture 版式特征(arch/lane 至少一处)", has,
377
+ "未见架构模式标志性组件", level="WARN")
378
+
379
+
380
+ # 页型 → 正文标志性版式特征(正则;模型-正文同源抽查,见 `components.md` §46/§46b)
381
+ TYPE_FEATURE = {
382
+ 'steps': r'class="[^"]*\bsteps\b',
383
+ 'heatmap': r'class="[^"]*\bheat\b',
384
+ 'bullet': r'class="[^"]*\bbul\b',
385
+ 'pyramid': r'class="[^"]*\bpyr\b',
386
+ 'image': r'class="[^"]*\bmedia\b|<img\b',
387
+ 'matrix': r'class="[^"]*\bmatrix\b',
388
+ 'exhibit': r'class="[^"]*\bexhibit\b',
389
+ 'twocol': r'class="[^"]*\bcols-2\b',
390
+ 'threecol': r'class="[^"]*\bcols-3\b',
391
+ 'halftable': r'class="[^"]*\bg-half\b',
392
+ 'split': r'class="[^"]*\bg-side\b',
393
+ 'diagram': r'class="[^"]*\barch\b',
394
+ 'lane': r'class="[^"]*\blane\b',
395
+ 'timeline': r'class="[^"]*\btl\b',
396
+ 'comparison': r'class="[^"]*\bg-half\b',
397
+ 'quote': r'band--accent|band--deep',
398
+ 'donut': r'data-chart="donut"',
399
+ # 复杂信息图页型:正文以内联 SVG 承载(data-chart 登记与 charts.registry 同源)
400
+ 'sankey': r'data-chart="sankey"',
401
+ 'treemap': r'data-chart="treemap"',
402
+ 'boxplot': r'data-chart="boxplot"',
403
+ 'network': r'data-chart="network"',
404
+ 'marimekko': r'data-chart="marimekko"',
405
+ 'streamgraph': r'data-chart="streamgraph"',
406
+ }
407
+
408
+
409
+ def _check_type_features(txt, chk, model):
410
+ """模型声明的页型应在正文找到对应版式组件(防"模型填了页型但正文用旧版式")。"""
411
+ if not model:
412
+ return
413
+ missing = []
414
+ for sec in (model.get('sections') or []):
415
+ t = (sec.get('type') or 'points')
416
+ feat = TYPE_FEATURE.get(t)
417
+ if feat and not re.search(feat, txt):
418
+ missing.append(t)
419
+ chk("模型页型 ↔ 正文版式组件对应", not missing,
420
+ f"{len(missing)} 处: {missing[:4]}" if missing else "", level="WARN")
421
+
422
+
423
+ def _check_pptx_export(txt, chk):
424
+ """PPTX 预览配套:有预览按钮就必须有内容模型 + 预览运行时;页面不得残留导出按钮。"""
425
+ has_btn = 'id="pptPreviewBtn"' in txt
426
+ has_model = 'REPORT_MODEL' in txt
427
+ has_runtime = ('g.TopPptHtml = api' in txt) and ('function slidesXml' in txt)
428
+ if has_btn:
429
+ chk("PPTX 预览按钮配套(REPORT_MODEL + 预览运行时)",
430
+ has_model and has_runtime,
431
+ f"model={'有' if has_model else '无'} runtime={'有' if has_runtime else '无'}")
432
+ else:
433
+ chk("含 PPTX 预览按钮与内容模型(建议保留)",
434
+ has_model and has_runtime, "未集成预览按钮/模型", level="WARN")
435
+ chk("页面无 PPTX 导出按钮(仅预览 + 提示词)",
436
+ 'id="pptxBtn"' not in txt and 'id="pptDownload"' not in txt,
437
+ "残留导出按钮 pptxBtn/pptDownload", level="WARN")
438
+
439
+
440
+ def _extract_model(txt):
441
+ m = re.search(r'window\.REPORT_MODEL\s*=\s*', txt)
442
+ if not m:
443
+ return None, "未找到 window.REPORT_MODEL(PPTX 双通道导出将不可用)"
444
+ try:
445
+ model, _end = json.JSONDecoder().raw_decode(txt[m.end():])
446
+ return model, None
447
+ except json.JSONDecodeError as e:
448
+ return None, f"REPORT_MODEL 不是合法 JSON: {e}(必须双引号/无尾逗号/无注释)"
449
+
450
+
451
+ def _chart_channel(chart_type) -> str:
452
+ """图表类型 → 交付通道(native / shape),与 charts.registry 同源(checks_html)。"""
453
+ return checks_html.chart_channel(chart_type, CHART_REG)
454
+
455
+
456
+ def _chart_datatable_mode(chart: dict) -> str:
457
+ """数据表策略:图表级 > 登记表默认(appendix 收敛为 inline)。"""
458
+ return checks_html.chart_datatable_mode(chart, CHART_REG)
459
+
460
+
461
+ def _check_chart_datatable(chk, model) -> None:
462
+ """数据可追溯:非原生(形状通道)图表与信息图页型的 dataTable 不得为 off。
463
+
464
+ 与 validate_pptx.py 的 MODEL_CHART_DATATABLE 同源,但提前到 HTML 阶段暴露——
465
+ 避免生成完 PPTX 才被拦(数据表策略是模型层决定,HTML 阶段即可判定)。
466
+ """
467
+ bad: list[str] = []
468
+ for sec in (model.get('sections') or []):
469
+ if not isinstance(sec, dict):
470
+ continue
471
+ st = sec.get('type') or ''
472
+ if st in INFO_PAGE_TYPES:
473
+ mode = str(((sec.get('chart') or {}).get('dataTable')) or 'notes').lower()
474
+ if mode == 'off':
475
+ bad.append(f'{st} 页信息图 dataTable=off')
476
+ continue
477
+ charts = []
478
+ if st in ('bar', 'donut', 'exhibit', 'halftable'):
479
+ c = sec.get('chart') or {}
480
+ if isinstance(c, dict) and c.get('labels') and c.get('values'):
481
+ charts.append(c)
482
+ elif st == 'split':
483
+ # 双区组合页:左/右两侧各可能是图表(left 缺省 points、right 缺省 bar)
484
+ for side, dflt in (('left', 'points'), ('right', 'bar')):
485
+ pane = sec.get(side) or {}
486
+ if not isinstance(pane, dict):
487
+ continue
488
+ if (pane.get('type') or dflt) in ('table', 'image', 'points'):
489
+ continue
490
+ if pane.get('labels') and pane.get('values'):
491
+ charts.append(pane)
492
+ for chart in charts:
493
+ if _chart_channel(chart.get('type')) == 'shape' and _chart_datatable_mode(chart) == 'off':
494
+ bad.append(f"{st} 页 {chart.get('type')} 图表 dataTable=off")
495
+ chk("图表数据表策略(非原生图表不得 off,保证数据可追溯)", not bad,
496
+ ';'.join(bad[:3]) if bad else "", level="WARN")
497
+
498
+
499
+ def _check_model_consistency(txt, chk, mode, style):
500
+ """模型-正文一致性:JSON 合法 / mode·style·theme 一致 / 章节标题抽查 / agenda / 页数。"""
501
+ model, err = _extract_model(txt)
502
+ if err:
503
+ chk("REPORT_MODEL 存在且为合法 JSON", False, err)
504
+ return None
505
+ chk("REPORT_MODEL 存在且为合法 JSON", True)
506
+ mmode = model.get('mode')
507
+ chk("REPORT_MODEL.mode 与 data-mode 一致", mmode == mode,
508
+ f"model={mmode!r} 页面={mode!r}")
509
+ mstyle = model.get('style')
510
+ chk("REPORT_MODEL.style 与 data-style 一致", mstyle == style,
511
+ f"model={mstyle!r} 页面={style!r}", level="WARN")
512
+ mtheme = model.get('theme') or 'light'
513
+ html_theme_m = re.search(r'<html[^>]*data-theme="([^"]+)"', txt)
514
+ html_theme = html_theme_m.group(1) if html_theme_m else 'light'
515
+ chk("REPORT_MODEL.theme 与 data-theme 一致(未声明默认 light)",
516
+ mtheme in ('light', 'dark') and mtheme == html_theme,
517
+ f"model={mtheme!r} 页面={html_theme!r}", level="WARN")
518
+ secs = model.get('sections') or []
519
+ chk("REPORT_MODEL.sections 非空(每章一页模型)", len(secs) >= 1,
520
+ f"{len(secs)} 页", level="WARN")
521
+ _check_chart_datatable(chk, model)
522
+ plain = _plain(txt)
523
+ missing = [str(s.get('title'))[:14] for s in secs
524
+ if not s.get('title') or str(s.get('title')) not in plain]
525
+ chk("模型章节标题均出现于正文(模型-正文同源)", not missing,
526
+ f"{len(missing)} 个缺失: {missing[:3]}" if missing else "", level="WARN")
527
+ n_html = txt.count('class="agenda__a"')
528
+ n_model = len(model.get('agenda') or [])
529
+ if n_html and n_model:
530
+ chk("模型 agenda 条数与正文一致", n_html == n_model,
531
+ f"model={n_model} 正文={n_html}", level="WARN")
532
+ n_bands = len(_bands(txt))
533
+ lo, hi = len(secs) + 3, len(secs) + 7
534
+ chk("页数粗匹配(正文 band ≈ sections + 结构页)", lo <= n_bands <= hi,
535
+ f"正文 {n_bands} 页 vs 模型 {len(secs)}+{lo - len(secs)}~{hi - len(secs)}", level="WARN")
536
+ return model
537
+
538
+
539
+ def _check_anchors(txt, chk):
540
+ """锚点闭环:Agenda / 导航 / 正文内部链接指向的 id 必须存在,且 id 全篇唯一。
541
+ (重复 id 会让锚点跳到错误的页,且 HTML 规范不允许——硬拦。)"""
542
+ # 只看正文:内联运行时/UI 脚本里有 `id="' + nid() + '"` 这类拼接,不能算重复 id
543
+ body = re.sub(r'<script\b[\s\S]*?</script>', '', txt, flags=re.I)
544
+ all_ids = re.findall(r'\sid="([^"]+)"', body)
545
+ ids = set(all_ids)
546
+ dup = sorted({i for i in all_ids if all_ids.count(i) > 1})
547
+ chk("id 全篇唯一(无重复 id)", not dup,
548
+ f"重复 id {dup[:5]}(锚点会跳到错误的页)" if dup else "")
549
+ bad = []
550
+ for href in set(re.findall(r'href="#([^"]+)"', body)):
551
+ if href and href not in ids:
552
+ bad.append('#' + href)
553
+ chk("内部锚点闭环(agenda/nav/正文链接均有对应 id)", not bad,
554
+ f"悬空锚点 {sorted(bad)[:5]}" if bad else "")
555
+
556
+
557
+ def _check_exhibits(txt, chk, mode):
558
+ """Exhibit 编号体系:每个 .exhibit 框都有编号,且全篇连续 1..N 无跳号无重复。"""
559
+ nos = [int(n) for n in re.findall(
560
+ r'class="exhibit__no"[^>]*>\s*Exhibit\s*(\d+)', txt, re.I)]
561
+ n_frame = len(re.findall(r'class="exhibit[\s"]', txt))
562
+ if not nos:
563
+ if mode == 'research':
564
+ chk("Exhibit 编号体系(research 深报告建议建立)", False,
565
+ "全文无 Exhibit 编号图表框", level="WARN")
566
+ return
567
+ # 只校验已编号者的连续性会漏掉「漏标编号的 exhibit 框」——先核框数再核编号。
568
+ chk(f"Exhibit 框均已编号({n_frame} 框)", n_frame == len(nos),
569
+ f"{n_frame} 个 .exhibit 框但只有 {len(nos)} 个 .exhibit__no 编号" if n_frame != len(nos) else "")
570
+ expected = list(range(1, len(nos) + 1))
571
+ chk(f"Exhibit 编号连续(1..{len(nos)} 无跳号无重复)", sorted(nos) == expected,
572
+ f"读到 {nos}")
573
+
574
+
575
+ def _check_research_extras(txt, chk, mode):
576
+ """research 模式:so-what 连续性(>3 页无结论条)与 Exhibit 来源行。"""
577
+ if mode != 'research':
578
+ return
579
+ bands = _bands(txt)
580
+ streak = max_streak = 0
581
+ for i, b in enumerate(bands, 1):
582
+ if i <= 2 or 'id="agenda"' in b or 'id="refs"' in b:
583
+ continue
584
+ if 'class="sowhat' in b or 'class="note' in b:
585
+ streak = 0
586
+ else:
587
+ streak += 1
588
+ max_streak = max(max_streak, streak)
589
+ chk("so-what 连续性(连续 ≤3 页无结论条)", max_streak <= 3,
590
+ f"连续 {max_streak} 页无 .sowhat/.note" if max_streak > 3 else "", level="WARN")
591
+ n_ex = len(re.findall(r'class="exhibit[\s"]', txt))
592
+ n_src = txt.count('class="exhibit__src"')
593
+ chk(f"Exhibit 图表框均有来源行({n_ex} 框)", n_ex == n_src,
594
+ f"{n_ex - n_src} 个缺 .exhibit__src" if n_ex != n_src else "", level="WARN")
595
+
596
+
597
+ def _check_emphasis(txt, chk):
598
+ """强调带约束:band--deep 是反相强调页(浅色主题下渲染为深色)——
599
+ 全文受限使用,且不得作末页,否则浅色模式末尾会出现深色页(用户核心诉求)。"""
600
+ em = LC.get('emphasis') or {}
601
+ max_deep = em.get('deepMaxPages', 1)
602
+ forbid_last = em.get('deepForbiddenLast', True)
603
+ bands = _bands(txt)
604
+ deep_idx = [i for i, b in enumerate(bands, 1) if 'band--deep' in b[:220]]
605
+ chk(f"反相强调页 band--deep ≤ {max_deep} 页(浅色模式下渲染为深色)",
606
+ len(deep_idx) <= max_deep,
607
+ f"{len(deep_idx)} 页: {deep_idx}(收尾/金句页请用 band--accent)" if len(deep_idx) > max_deep else "",
608
+ level="WARN")
609
+ if forbid_last and bands:
610
+ chk("末页非反相深色页(band--deep)",
611
+ 'band--deep' not in bands[-1][:220],
612
+ "末页为 band--deep:浅色模式下会以深色收尾(改用 band--accent)", level="WARN")
613
+
614
+
615
+ def _layout_sig(band: str) -> str | None:
616
+ """从 band HTML 提取版式签名(优先级从具体到宽泛)。
617
+ 图表类型并入签名:exhibit+sankey 与 exhibit+waterfall 视为不同版式。"""
618
+ pats = (
619
+ ('g-hero-full', r'g-hero-full'),
620
+ ('g-mosaic', r'g-mosaic'),
621
+ ('g-bento', r'g-bento'),
622
+ ('g-aside', r'g-aside'),
623
+ ('g-quad', r'g-quad'),
624
+ ('g-211', r'g-211'),
625
+ ('g-121', r'g-121'),
626
+ ('g-side', r'g-side'),
627
+ ('rows-2', r'rows-2'),
628
+ ('rows-3', r'rows-3'),
629
+ ('g-2', r'\bg-2\b'),
630
+ ('g-3', r'\bg-3\b'),
631
+ ('g-4', r'\bg-4\b'),
632
+ ('g-5', r'\bg-5\b'),
633
+ ('g-6', r'\bg-6\b'),
634
+ ('cols-3', r'cols-3'),
635
+ ('cols-2', r'cols-2'),
636
+ ('exhibit', r'class="exhibit'),
637
+ ('table', r'class="tbl-wrap'),
638
+ ('metrics', r'class="metric'),
639
+ ('arch', r'class="arch'),
640
+ ('lane', r'class="lane'),
641
+ ('matrix', r'class="matrix'),
642
+ ('heat', r'class="heat'),
643
+ ('bul', r'class="bul'),
644
+ ('pyr', r'class="pyr'),
645
+ ('steps', r'class="steps'),
646
+ ('timeline', r'class="tl[\s"]'),
647
+ ('media', r'class="media'),
648
+ ('stagger', r'stagger'),
649
+ ('quote', r'band--accent'),
650
+ ('card', r'class="card[\s"]'),
651
+ )
652
+ base = None
653
+ for name, pat in pats:
654
+ if re.search(pat, band):
655
+ base = name
656
+ break
657
+ if base is None:
658
+ return None
659
+ charts = re.findall(r'data-chart="([^"]+)"', band)
660
+ if charts:
661
+ # 去重保序,最多 2 类,避免顺序噪声
662
+ seen, uniq = set(), []
663
+ for c in charts:
664
+ if c not in seen:
665
+ seen.add(c)
666
+ uniq.append(c)
667
+ return f"{base}:{'+'.join(uniq[:2])}"
668
+ return base
669
+
670
+
671
+ def _check_content_quality(txt, chk, mode, model):
672
+ """内容级质量:版式节奏 / so-what 非空洞 / research 行动标题含判断。"""
673
+ cq = LC.get('contentQuality') or {}
674
+ bands = _bands(txt)
675
+ skip = set((cq.get('rhythm') or {}).get('skipIds') or
676
+ ['cover', 'agenda', 'refs', 'appendix', 'next'])
677
+
678
+ # ① 版式节奏:同一签名不得连用超过上限
679
+ max_streak = int((cq.get('rhythm') or {}).get('maxSameLayoutStreak') or 2)
680
+ sigs: list[tuple[int, str]] = []
681
+ for i, b in enumerate(bands, 1):
682
+ head = b[:220]
683
+ if any(f'id="{sid}"' in head for sid in skip):
684
+ continue
685
+ sig = _layout_sig(b)
686
+ if sig:
687
+ sigs.append((i, sig))
688
+ streak_hits: list[str] = []
689
+ run_sig, run_start, run_len = None, 0, 0
690
+ for i, sig in sigs:
691
+ if sig == run_sig:
692
+ run_len += 1
693
+ else:
694
+ if run_sig and run_len > max_streak:
695
+ streak_hits.append(f'{run_sig}×{run_len}(起第{run_start}页)')
696
+ run_sig, run_start, run_len = sig, i, 1
697
+ if run_sig and run_len > max_streak:
698
+ streak_hits.append(f'{run_sig}×{run_len}(起第{run_start}页)')
699
+ chk(f"版式节奏(同一版式签名连续 ≤{max_streak} 页)", not streak_hits,
700
+ "; ".join(streak_hits[:3]) if streak_hits else "", level="WARN")
701
+
702
+ # ② so-what 禁空洞套话 + 最短长度(HTML 与模型双侧)
703
+ sw = cq.get('sowhat') or {}
704
+ min_chars = int(sw.get('minChars') or 12)
705
+ forbidden = list(sw.get('forbidden') or [])
706
+ texts: list[tuple[str, str]] = []
707
+ for m in re.finditer(r'class="sowhat__v"[^>]*>(.*?)</', txt, re.S):
708
+ body = re.sub(r'<[^>]+>', '', m.group(1) or '').strip()
709
+ if body:
710
+ texts.append(('html', body))
711
+ if model:
712
+ for idx, sec in enumerate(model.get('sections') or [], 1):
713
+ v = sec.get('soWhat')
714
+ if isinstance(v, str) and v.strip():
715
+ texts.append((f'model#{idx}', v.strip()))
716
+ short, hollow = [], []
717
+ for src, body in texts:
718
+ if len(body) < min_chars:
719
+ short.append(f'{src}:{body[:24]}')
720
+ if any(f in body for f in forbidden):
721
+ hollow.append(f'{src}:{body[:24]}')
722
+ if texts:
723
+ chk(f"so-what 实质(长度 ≥{min_chars} 字且无空洞套话)", not short and not hollow,
724
+ "; ".join((short + hollow)[:4]) if (short or hollow) else "", level="WARN")
725
+
726
+ # ③ research 行动标题须含数字或判断词
727
+ if mode == 'research':
728
+ rt = cq.get('researchTitle') or {}
729
+ if rt.get('requireDigitOrJudgment', True):
730
+ jpat = rt.get('judgmentPattern') or r'[\d]|是|应|需|将'
731
+ struct = ('报告大纲', '大纲', '参考资料', '数据来源', '下一步', '结论',
732
+ '全文核心', 'agenda', '附录', '收尾')
733
+ h1s = re.findall(r'<h2 class="t-h1 shead__title"[^>]*>(.*?)</h2>', txt)
734
+ titles = [re.sub(r'<[^>]+>', '', h).strip() for h in h1s]
735
+ titles = [t for t in titles if len(t) >= 12 and
736
+ not t.lower().startswith(struct) and not any(s in t for s in struct)]
737
+ no_judge = [t[:28] for t in titles if not re.search(jpat, t)]
738
+ chk("research 行动标题含数字或判断词(标题即结论)", not no_judge,
739
+ f"缺少判断信号: {no_judge[:3]}" if no_judge else "", level="WARN")
740
+
741
+
742
+ def _check_v9_hard_gates(txt, chk, model):
743
+ """v9 硬门禁:标签泄漏 / 标题空页 / 极偏占比 / 简单图过大(治截图级缺陷)。"""
744
+ import html as _html
745
+ QG = LC.get('qualityGates') or {}
746
+ patterns = QG.get('tagLeakPatterns') or [
747
+ '<a ', '</a>', 'class="cite"', 'href=', '<strong', '</strong>'
748
+ ]
749
+ # ① HTML 标签泄漏进可见文本
750
+ # 先抓「转义后当字面量显示」的(&lt;a class=…&gt;):源码里就有实体
751
+ leak_hits = []
752
+ for i, b in enumerate(_bands(txt), 1):
753
+ b2 = re.sub(r'<script\b[\s\S]*?</script>', ' ', b)
754
+ b2 = re.sub(r'<style\b[\s\S]*?</style>', ' ', b2)
755
+ # A. 转义标签字面量(用户截图形态:正文里直接可见 <a class="cite"…>)
756
+ if re.search(r'&lt;/?[a-zA-Z][^&]{0,60}&gt;', b2):
757
+ m = re.search(r'&lt;/?[a-zA-Z][^&]{0,60}&gt;', b2)
758
+ leak_hits.append(f"第{i}页含转义标签字面量 `{m.group(0)[:40]}`")
759
+ continue
760
+ # B. 反转义后剥真标签,残留源码片段
761
+ unesc = _html.unescape(b2)
762
+ plain = _plain(unesc)
763
+ for p in patterns:
764
+ if p in plain:
765
+ leak_hits.append(f"第{i}页可见文本含 `{p}`")
766
+ break
767
+ # C. REPORT_MODEL 字段内夹带 HTML 标签(在 <script> 里,正文剥离会漏掉)
768
+ model_tag_hits = []
769
+ mm = re.search(r'window\.REPORT_MODEL\s*=\s*(\{[\s\S]*?\})\s*;', txt)
770
+ if mm:
771
+ try:
772
+ import json as _json
773
+ mobj = _json.loads(mm.group(1))
774
+
775
+ def _scan(o, path=''):
776
+ if isinstance(o, dict):
777
+ for k, v in o.items():
778
+ _scan(v, f'{path}.{k}' if path else k)
779
+ elif isinstance(o, list):
780
+ for i, v in enumerate(o):
781
+ _scan(v, f'{path}[{i}]')
782
+ elif isinstance(o, str) and re.search(r'</?[a-zA-Z][^>]*>', o):
783
+ model_tag_hits.append(path or '(root)')
784
+ _scan(mobj)
785
+ except Exception:
786
+ pass
787
+ if model_tag_hits:
788
+ leak_hits.append(f"REPORT_MODEL 字段夹带标签: {model_tag_hits[:3]}")
789
+ chk("HTML_TAG_IN_TEXT 可见文本无 HTML 标签源码泄漏", not leak_hits,
790
+ "; ".join(leak_hits[:5]) if leak_hits else "")
791
+
792
+ # ② 标题空页 / 承载不足
793
+ title_only, underfill = [], []
794
+ to_min = int((QG.get('titleOnly') or {}).get('minBodyChars') or 40)
795
+ uf = QG.get('underfill') or {}
796
+ uf_min = int(uf.get('minCarriers') or 2)
797
+ exempt = set(uf.get('exemptIds') or ['quote', 'cover', 'refs', 'appendix', 'next'])
798
+ for i, b in enumerate(_bands(txt), 1):
799
+ head = b[:240]
800
+ bid_m = re.search(r'id="([^"]+)"', head)
801
+ bid = bid_m.group(1) if bid_m else ''
802
+ if bid in exempt or any(f'id="{e}"' in head for e in exempt):
803
+ continue
804
+ if 'band--deep' in head or 'band--accent' in head:
805
+ continue
806
+ b2 = re.sub(r'<script\b[\s\S]*?</script>', ' ', b)
807
+ b2 = re.sub(r'<style\b[\s\S]*?</style>', ' ', b2)
808
+ # 去掉 shead(eyebrow+标题+导语)后的正文
809
+ body = re.sub(r'<div class="shead[\s\S]*?</div>\s*</div>', ' ', b2, count=1)
810
+ body = re.sub(r'<div class="shead[\s\S]*?</h2>\s*</div>', ' ', b2, count=1)
811
+ plain_body = _plain(body).strip()
812
+ # 再去掉与标题重复的大标题串
813
+ title_m = re.search(r'shead__title[^>]*>(.*?)</', b2)
814
+ if title_m:
815
+ plain_body = plain_body.replace(_plain(title_m.group(1)).strip(), '', 1).strip()
816
+ carriers = _band_units(b)
817
+ if len(plain_body) < to_min and carriers == 0:
818
+ title_only.append(f"第{i}页正文{len(plain_body)}字")
819
+ elif carriers < uf_min and len(plain_body) < to_min * 2:
820
+ underfill.append(f"第{i}页承载{carriers}")
821
+ chk(f"TITLE_ONLY_PAGE 非空页(去页头后正文 ≥{to_min} 字)", not title_only,
822
+ "; ".join(title_only[:5]) if title_only else "")
823
+ chk(f"UNDERFILL_PAGE 内容页承载 ≥{uf_min}(金句/章节幕豁免)", not underfill,
824
+ "; ".join(underfill[:5]) if underfill else "")
825
+
826
+ # ③ 极偏占比禁 donut/pie(治 0.5% vs 99.5% 环图叠字不可读)
827
+ skew = QG.get('chartSkew') or {}
828
+ skew_types = set(skew.get('types') or ['donut', 'pie', 'multidonut'])
829
+ min_pct = float(skew.get('minSectorPct') or 5)
830
+ max_ratio = float(skew.get('maxMinRatio') or 20)
831
+ skew_hits = []
832
+ if model and isinstance(model.get('sections'), list):
833
+ for si, sec in enumerate(model['sections'], 1):
834
+ if not isinstance(sec, dict):
835
+ continue
836
+ ch = sec.get('chart') or {}
837
+ ctype = str(ch.get('type') or sec.get('type') or '').lower()
838
+ if sec.get('type') == 'donut':
839
+ ctype = 'donut'
840
+ if ctype not in skew_types:
841
+ continue
842
+ vals = [float(v) for v in (ch.get('values') or []) if v is not None]
843
+ vals = [v for v in vals if v >= 0]
844
+ if len(vals) < 2:
845
+ continue
846
+ total = sum(vals) or 1
847
+ pcts = [v / total * 100 for v in vals]
848
+ mn, mx = min(pcts), max(pcts)
849
+ ratio = (mx / mn) if mn > 0 else 999
850
+ if mn < min_pct or ratio > max_ratio:
851
+ skew_hits.append(
852
+ f"第{si}章 {ctype} 最小扇区 {mn:.1f}%(比 {ratio:.0f}:1)应改 KPI/进度/对比条")
853
+ chk(f"CHART_SKEW_INVALID 占比图最小扇区 ≥{min_pct:.0f}% 且 max/min ≤{max_ratio:.0f}",
854
+ not skew_hits, "; ".join(skew_hits[:4]) if skew_hits else "")
855
+
856
+ # ④ 简单图过大(类别≤2 或 数据点≤3 却近乎独占内容区)
857
+ ov = QG.get('chartOversize') or {}
858
+ simple_cats = int(ov.get('simpleMaxCats') or 2)
859
+ simple_pts = int(ov.get('simpleMaxPts') or 3)
860
+ max_pct_area = float(ov.get('maxContentAreaPct') or 55)
861
+ oversize = []
862
+ for i, b in enumerate(_bands(txt), 1):
863
+ for m in re.finditer(
864
+ r'<svg\b[^>]*data-chart="([^"]+)"[^>]*style="([^"]*)"[^>]*>',
865
+ b):
866
+ ctype, style = m.group(1), m.group(2)
867
+ # 解析 width / height(px 或 %)
868
+ wm = re.search(r'width:\s*(\d+(?:\.\d+)?)px', style)
869
+ hm = re.search(r'height:\s*(\d+(?:\.\d+)?)px', style)
870
+ vw = re.search(r'viewBox="0 0 (\d+(?:\.\d+)?) (\d+(?:\.\d+)?)"', m.group(0))
871
+ # 从 svg 块粗数数据点/类别
872
+ svg_end = b.find('</svg>', m.end())
873
+ block = b[m.start():svg_end if svg_end > 0 else m.end() + 800]
874
+ n_labels = len(re.findall(r'<text\b', block))
875
+ n_rects = len(re.findall(r'<rect\b', block))
876
+ n_circles = len(re.findall(r'<circle\b', block))
877
+ n_pts = max(n_labels, n_rects, n_circles)
878
+ is_simple = n_labels <= simple_cats or n_pts <= simple_pts
879
+ if not is_simple:
880
+ continue
881
+ # 简单图接近通宽且高度大 → 视为「简单大图」
882
+ h_px = float(hm.group(1)) if hm else (float(vw.group(2)) if vw else 0)
883
+ if h_px >= 320 or (not hm and not vw and 'width:100%' in style and 'height' not in style):
884
+ # 无显式高度的通宽简单图
885
+ if h_px >= 320 or ('width:100%' in style and n_pts <= simple_pts):
886
+ oversize.append(f"第{i}页 {ctype} 简单图过大({n_pts} 点/标记)")
887
+ chk(f"CHART_OVERSIZE 简单图(≤{simple_cats} 类或 ≤{simple_pts} 点)勿独占版面(上限 {max_pct_area:.0f}%)",
888
+ not oversize, "; ".join(oversize[:4]) if oversize else "")
889
+
890
+
891
+ def _check_layout_grammar(txt, chk, mode):
892
+ """布局语法门禁:骨架类 / 单一重心 / 混排对齐 / 间距 token / 图标尺寸 / 标签防换行。"""
893
+ LS = LC.get('layoutSystem') or {}
894
+ presets = LS.get('presets') or {}
895
+ elem = LS.get('elements') or {}
896
+ mf = LS.get('multiFocus') or {}
897
+ align_cfg = LS.get('align') or {}
898
+ fill_t = (LS.get('fillTarget') or {}).get(mode) or [62, 78]
899
+ icon_sizes = set(elem.get('icon', {}).get('sizesPx') or [16, 18, 20, 24])
900
+ chart_w_pct = float(mf.get('chartWidthPct') or 55)
901
+ metric_fz = float(mf.get('metricFontPx') or 40)
902
+
903
+ # 收集预设骨架对应 class
904
+ skeleton_classes = set()
905
+ for p in (presets or {}).values():
906
+ for c in (p.get('html') or []):
907
+ skeleton_classes.add(c)
908
+ # 附加常见栅格类(grammar 白名单)
909
+ skeleton_classes.update({
910
+ 'g-2', 'g-3', 'g-4', 'g-5', 'g-6', 'g-hero', 'g-hero--rev', 'g-side', 'g-side--rev',
911
+ 'g-31', 'g-13', 'g-41', 'g-14', 'g-211', 'g-121', 'g-quad', 'g-aside', 'g-bento',
912
+ 'g-hero-full', 'g-mosaic', 'rows-2', 'rows-3', 'stagger', 'media-grid', 'media-compare',
913
+ })
914
+
915
+ no_skel, multi_focus, align_miss, off_token = [], [], [], []
916
+ icon_bad, label_collapse = [], []
917
+ fill_low, fill_high = [], []
918
+ mixed_need = bool(align_cfg.get('requireOnMixed', True))
919
+ align_tokens = set(align_cfg.get('mixedGridClasses') or ['a-start', 'a-c', 'align-items'])
920
+
921
+ for i, b in enumerate(_bands(txt), 1):
922
+ head = b[:240]
923
+ if any(f'id="{x}' in head for x in ('refs', 'appendix', 'cover', 'agenda', 'quote')):
924
+ continue
925
+ if 'band--flow' in head:
926
+ continue
927
+ # 轻量合法页:金句/强调带/大数压场(fill 目标放宽)
928
+ is_light = ('band--fit' in head or 'band--accent' in head or
929
+ 'band--deep' in head or 'quote' in head)
930
+
931
+ # ① 骨架类(或 data-skel 标记)
932
+ has_skel = ('data-skel="' in b) or any(
933
+ re.search(rf'class="[^"]*\b{re.escape(c)}\b', b) for c in skeleton_classes)
934
+ # 手写 inline grid 且无预设类
935
+ inline_grid = re.findall(r'style="[^"]*display:\s*grid[^"]*"', b)
936
+ if not has_skel and inline_grid:
937
+ no_skel.append(f"第{i}页 inline grid 无 P1–P12 骨架类")
938
+
939
+ # ② 单一视觉重心(粗启发式)
940
+ n_big = 0
941
+ for m in re.finditer(r'<svg\b[^>]*>', b):
942
+ tag = m.group(0)
943
+ wm = re.search(r'width:\s*(\d+(?:\.\d+)?)px', tag)
944
+ wrap = int((re.search(r'--wrap:\s*(\d+)', txt) or [0, 1400])[1] or 1400) if False else 1400
945
+ if wm and float(wm.group(1)) / wrap * 100 >= chart_w_pct:
946
+ n_big += 1
947
+ if re.search(r'width:\s*100%', tag) and 'data-chart' in tag:
948
+ # 通宽图记 1 大件
949
+ n_big += 1
950
+ for m in re.finditer(r'font-size:\s*clamp\(\s*(\d+)px', b):
951
+ if float(m.group(1)) >= metric_fz:
952
+ n_big += 1
953
+ break
954
+ if re.search(r'class="[^"]*media--(?:full|bleed)', b):
955
+ n_big += 1
956
+ if n_big >= 2:
957
+ multi_focus.append(f"第{i}页大件×{n_big}")
958
+
959
+ # ③ 混排对齐(同时有图/媒体与卡/列表)
960
+ mixed = (re.search(r'class="[^"]*(?:fig|media|chart)', b) and
961
+ re.search(r'class="[^"]*(?:card|ul|metric)', b))
962
+ if mixed and mixed_need:
963
+ if not any(t in b for t in align_tokens):
964
+ align_miss.append(f"第{i}页图卡混排缺 a-start/a-c")
965
+
966
+ # ④ 间距写死(margin/padding/gap 非 token / 非 clamp;≤8px 微调白名单)
967
+ for m in re.finditer(r'(?:margin|padding|gap)(?:-[a-z]+)?\s*:\s*([^;"]+)', b):
968
+ val = m.group(1).strip()
969
+ if not val or val.startswith('var(') or val.startswith('clamp(') or val.startswith('0'):
970
+ continue
971
+ if re.fullmatch(r'auto|inherit|initial|unset|normal', val):
972
+ continue
973
+ px_vals = [float(x) for x in re.findall(r'(\d+(?:\.\d+)?)px', val)]
974
+ if px_vals and all(v <= 8 for v in px_vals) and 'clamp' not in val and 'var(' not in val:
975
+ continue # 4–8px 光学微调允许
976
+ if px_vals and 'clamp' not in val and 'var(' not in val:
977
+ off_token.append(f"第{i}页 `{val[:24]}`")
978
+
979
+ # ⑤ 图标尺寸
980
+ for m in re.finditer(
981
+ r'<svg\b[^>]*(?:class="[^"]*(?:ico|icon)[^"]*"|metric__ico|card__ico)[^>]*>',
982
+ b, re.I):
983
+ tag = m.group(0)
984
+ sm = re.search(r'width:\s*["\']?(\d+)', tag) or re.search(r'width="(\d+)"', tag)
985
+ if sm and int(sm.group(1)) not in icon_sizes:
986
+ icon_bad.append(f"第{i}页 {sm.group(1)}px")
987
+
988
+ # ⑥ 图表标签防换行(短盒 + 长 text)
989
+ for m in re.finditer(r'<text[^>]*width="(\d+)"[^>]*>([^<]{8,})</text>', b):
990
+ if int(m.group(1)) < 48:
991
+ label_collapse.append(f"第{i}页标签盒宽{m.group(1)}")
992
+
993
+ # ⑦ 结构图禁裸文字箭头(F13)
994
+ if re.search(r'class="[^"]*lane__arr[^"]*"[^>]*>\s*→', b) or re.search(
995
+ r'lane__arr">→', b):
996
+ label_collapse.append(f"第{i}页泳道裸文字→(改 .lane__arr)")
997
+
998
+ # ⑧ 填充率粗估(去空白字符 + 组件;轻量页不判过空)
999
+ b2 = re.sub(r'<script\b[\s\S]*?</script>', ' ', b)
1000
+ b2 = re.sub(r'<style\b[\s\S]*?</style>', ' ', b2)
1001
+ plen = len(re.sub(r'\s+', '', _plain(b2)))
1002
+ units = _band_units(b)
1003
+ budget = MODE_BUDGETS.get(mode, MODE_BUDGETS['presentation'])
1004
+ est = (plen / max(1, budget['fit']) * 55) + (units / max(1, budget['unit']) * 45)
1005
+ est = max(0, min(100, est))
1006
+ lo_t = fill_t[0] * 0.55
1007
+ hi_t = min(95, fill_t[1] * 1.15)
1008
+ if (not is_light) and est < lo_t and units == 0 and plen < budget['empty']:
1009
+ fill_low.append(f"第{i}页≈{est:.0f}%")
1010
+ elif est > hi_t and not is_light:
1011
+ fill_high.append(f"第{i}页≈{est:.0f}%")
1012
+
1013
+ chk(f"LAYOUT_NO_SKELETON 内容页使用 P1–P12 骨架类(layout-grammar)", not no_skel,
1014
+ "; ".join(no_skel[:4]) if no_skel else "")
1015
+ chk(f"LAYOUT_MULTI_FOCUS 一屏一视觉重心(大件 ≤{mf.get('maxPrimary', 1)})", not multi_focus,
1016
+ "; ".join(multi_focus[:4]) if multi_focus else "")
1017
+ chk("LAYOUT_ALIGN_DRIFT 图卡混排显式对齐(a-start / a-c)", not align_miss,
1018
+ "; ".join(align_miss[:4]) if align_miss else "")
1019
+ chk("LAYOUT_SPACING_OFF_TOKEN 间距走 --sp-*/gap/clamp(禁游离 px)", not off_token,
1020
+ "; ".join(off_token[:4]) if off_token else "", level="WARN")
1021
+ chk("LAYOUT_ICON_SIZE 图标 ∈ {16,18,20,24}px", not icon_bad,
1022
+ "; ".join(icon_bad[:4]) if icon_bad else "", level="WARN")
1023
+ chk("LAYOUT_LABEL_COLLIDE 图表标签盒足够宽 / 结构图无裸文字箭头", not label_collapse,
1024
+ "; ".join(label_collapse[:4]) if label_collapse else "")
1025
+ chk(f"LAYOUT_FILL 填充率目标 {fill_t[0]}–{fill_t[1]}%({mode})",
1026
+ not fill_low and not fill_high,
1027
+ ("过空: " + "; ".join(fill_low[:3]) if fill_low else "") +
1028
+ ((" 过满: " + "; ".join(fill_high[:3])) if fill_high else ""))
1029
+
1030
+
1031
+ def _check_annotations(txt, chk, model):
1032
+ """待核实标注:.tbd 内联标色须配 .tbd-legend / .flagbar 说明;单页数量上限。"""
1033
+ an = LC.get('annotations') or {}
1034
+ max_per_page = an.get('flagMaxPerPage', 12)
1035
+ require_legend = an.get('requireLegend', True)
1036
+ tbd_re = re.compile(r'class="tbd(?:\s|")')
1037
+ no_legend, over = [], []
1038
+ for i, b in enumerate(_bands(txt), 1):
1039
+ n = len(tbd_re.findall(b))
1040
+ if n and require_legend and ('tbd-legend' not in b and 'flagbar' not in b):
1041
+ no_legend.append(f"第{i}页")
1042
+ if n > max_per_page:
1043
+ over.append(f"第{i}页 {n}处")
1044
+ chk("待核实标注 .tbd 均配 .tbd-legend/.flagbar 说明", not no_legend,
1045
+ f"{no_legend} 有标色项但无说明(用户不知为何标色)" if no_legend else "", level="WARN")
1046
+ chk(f"单页待核实标注 ≤ {max_per_page} 处(过密则转 .flagbar 清单)", not over,
1047
+ "; ".join(over) if over else "", level="WARN")
1048
+ if model:
1049
+ mflags = [s for s in (model.get('sections') or []) if s.get('flags')]
1050
+ if mflags and 'flagbar' not in txt and 'tbd-legend' not in txt:
1051
+ chk("模型 flags ↔ 正文待核实条对应", False,
1052
+ f"{len(mflags)} 页模型含 flags,正文无 .flagbar/.tbd-legend", level="WARN")
1053
+
1054
+
1055
+ def _img_holders(sec):
1056
+ """页内可能承载素材图片的容器(image 页型 / split 右栏)。"""
1057
+ out = []
1058
+ if isinstance(sec.get('image'), dict):
1059
+ out.append(sec['image'])
1060
+ right = sec.get('right')
1061
+ if isinstance(right, dict) and isinstance(right.get('image'), dict):
1062
+ out.append(right['image'])
1063
+ return out
1064
+
1065
+
1066
+ def _img_srcs(img):
1067
+ """图片对象的全部真实 src(image.src + image.items[].src),排除占位符。"""
1068
+ if img.get('placeholder'):
1069
+ return []
1070
+ srcs = []
1071
+ if isinstance(img.get('src'), str) and img['src'].strip():
1072
+ srcs.append(img['src'])
1073
+ for it in (img.get('items') or []):
1074
+ if isinstance(it, dict) and isinstance(it.get('src'), str) and it['src'].strip():
1075
+ srcs.append(it['src'])
1076
+ elif isinstance(it, str) and it.strip():
1077
+ srcs.append(it)
1078
+ return srcs
1079
+
1080
+
1081
+ def _check_media(txt, chk, model):
1082
+ """素材图片与配图占位:零外链铁律(<img> 只允许 data: 内联或相对路径);
1083
+ alt 可访问性;配图占位必须有可见标签;模型 image 三选一(src/items/placeholder)
1084
+ 与正文版式、数量、裁切策略一一对应。"""
1085
+ imgs = re.findall(r'<img\b[^>]*>', txt)
1086
+ if imgs:
1087
+ bad = [t for t in imgs if re.search(r'src\s*=\s*["\']\s*(?:https?:)?//', t)]
1088
+ chk("图片源无外链(data: 内联或相对路径,零外链铁律)", not bad,
1089
+ f"{len(bad)} 处外链图片" if bad else "")
1090
+ noalt = [t for t in imgs if 'alt=' not in t]
1091
+ chk("图片均带 alt(可访问性)", not noalt,
1092
+ f"{len(noalt)} 处缺 alt" if noalt else "", level="WARN")
1093
+ data_imgs = [t for t in imgs if re.search(r'src\s*=\s*["\']\s*data:', t)]
1094
+ inline_bytes = sum(len(t) for t in data_imgs)
1095
+ chk("单图 data: 内联体积在上限内", all(len(t) <= IMAGE_MAX_INLINE for t in data_imgs),
1096
+ f"最大 {max((len(t) for t in data_imgs), default=0) // 1024}KB > 上限 {IMAGE_MAX_INLINE // 1024}KB",
1097
+ level="WARN")
1098
+ chk("报告内联图片总量在上限内", inline_bytes <= IMAGE_MAX_TOTAL,
1099
+ f"{inline_bytes // 1024}KB > 上限 {IMAGE_MAX_TOTAL // 1024}KB(改用相对路径)",
1100
+ level="WARN")
1101
+ ph_blocks = re.findall(r'class="[^"]*\bmedia--ph\b[^"]*"', txt)
1102
+ if ph_blocks:
1103
+ chk("配图占位含可见标签(.media__ph)", 'media__ph' in txt,
1104
+ f"{len(ph_blocks)} 处 .media--ph 缺 .media__ph 标签(空占位 = 不合格)")
1105
+ if not model:
1106
+ return
1107
+ holders = [(i, img) for i, s in enumerate(model.get('sections') or [])
1108
+ if isinstance(s, dict) for img in _img_holders(s)]
1109
+ if not holders:
1110
+ return
1111
+ bad_src, bad_layout, bad_fit, too_many = [], [], [], []
1112
+ n_declared = 0
1113
+ n_ph = 0
1114
+ for idx, img in holders:
1115
+ srcs = _img_srcs(img)
1116
+ n_declared += len(srcs)
1117
+ if img.get('placeholder'):
1118
+ n_ph += 1
1119
+ for s in srcs:
1120
+ if re.match(r'\s*(?:https?:)?//', str(s)):
1121
+ bad_src.append(f"sections[{idx}]")
1122
+ layout = str(img.get('layout') or ('grid' if len(img.get('items') or []) > 1 else 'full')).lower()
1123
+ if layout not in IMAGE_LAYOUTS:
1124
+ bad_layout.append(f"sections[{idx}]:{layout}")
1125
+ if img.get('fit') and str(img['fit']).lower() not in IMAGE_FIT:
1126
+ bad_fit.append(f"sections[{idx}]:{img['fit']}")
1127
+ items = img.get('items') or []
1128
+ if len(items) > IMAGE_MAX_PER_PAGE:
1129
+ too_many.append(f"sections[{idx}]={len(items)}")
1130
+ if layout in IMAGE_MULTI_LAYOUTS and not img.get('placeholder') and len(items) < 2:
1131
+ too_many.append(f"sections[{idx}] {layout} 需 ≥2 张图(当前 {len(items)})")
1132
+ chk("模型 image.src 非外链(data:/相对路径)", not bad_src,
1133
+ f"{len(bad_src)} 处外链 src" if bad_src else "")
1134
+ chk("模型 image.layout 合法(imageSpec.layouts)", not bad_layout,
1135
+ f"非法版式 {sorted(set(bad_layout))}" if bad_layout else "")
1136
+ chk("模型 image.fit 合法(cover/contain)", not bad_fit,
1137
+ f"非法裁切 {sorted(set(bad_fit))}" if bad_fit else "")
1138
+ chk("单页图片数/多图版式图数合规(imageSpec.maxPerPage · 多图版式 ≥2 张)", not too_many,
1139
+ f"{too_many}" if too_many else "", level="WARN")
1140
+ layout_class = {'grid': 'media-grid', 'compare': 'media-compare', 'wall': 'media-wall',
1141
+ 'bleed': 'media--bleed', 'ph': 'media--ph'}
1142
+ ratio_css = IMAGE_SPEC.get('ratioCssClass') or {}
1143
+ miss_cls = []
1144
+ for idx, img in holders:
1145
+ lay = str(img.get('layout') or ('grid' if len(img.get('items') or []) > 1 else 'full')).lower()
1146
+ cls = layout_class.get(lay)
1147
+ if cls and cls not in txt:
1148
+ miss_cls.append(f"sections[{idx}]:{lay}→.{cls}")
1149
+ # 比例锁定类:HTML 与 PPTX 用同一份 imageSpec.ratioCssClass,缺了就会出现"比例走样"
1150
+ rcls = ratio_css.get(lay)
1151
+ if rcls and ('.' + rcls) not in txt:
1152
+ miss_cls.append(f"sections[{idx}]:{lay}→.{rcls}(比例未锁定)")
1153
+ if img.get('placeholder') and 'media--ph' not in txt:
1154
+ miss_cls.append(f"sections[{idx}]:placeholder→.media--ph")
1155
+ chk("模型图片版式 ↔ 正文版式类/比例锁定类对应", not miss_cls,
1156
+ f"缺 {sorted(set(miss_cls))}" if miss_cls else "", level="WARN")
1157
+ if n_declared and len(imgs) < n_declared:
1158
+ chk("模型 image ↔ 正文图片数量对应", False,
1159
+ f"模型声明 {n_declared} 张但正文仅 {len(imgs)} 个 <img>", level="WARN")
1160
+ if n_ph and not ph_blocks:
1161
+ chk("模型配图占位 ↔ 正文 .media--ph 对应", False,
1162
+ f"{n_ph} 页模型声明 image.placeholder 但正文无 .media--ph", level="WARN")
1163
+
1164
+
1165
+ # 失败检查项 → 失败模式 / 处置动作 / 精确取码命令。
1166
+ # 目的:校验失败时直接给出「改什么、按什么顺序改、去哪取代码」,
1167
+ # 使智能体不必整读 references/failure-modes.md(17KB)就能收敛。
1168
+ # 关键词按检查项名称匹配;新增检查项时同步在此登记,否则只回落通用处置顺序。
1169
+ FIX_GUIDE = [
1170
+ (("页高", "溢出", "满屏", "文字预算"), "F4 容器溢出",
1171
+ "① 列表化/精炼 ② 升级承载形态 ③ 换/扩组合版式 ④ 分区 ⑤ 拆页 ⑥ 最后才有限缩字号",
1172
+ "--task content-rules"),
1173
+ (("过空", "内容不足", "密度"), "F1 内容不足 / F3 密度塌陷",
1174
+ "补证据与含义(数字+口径+so-what),不要用装饰或放大字号填空",
1175
+ "--task content-rules"),
1176
+ (("图表", "多样性", "登记"), "F6 图表降级 / F12 图表单一",
1177
+ "换图表类型拉开多样性;连续两页不得同型;类型须在 charts.registry 登记",
1178
+ "--task chart-pick"),
1179
+ (("最小尺寸",), "图表尺寸不足",
1180
+ "把 svg 的 viewBox 高/显示宽度提到 charts.minSize 之上(尺寸问题改尺寸,不要靠换图型回避)",
1181
+ "--task chart-pick"),
1182
+ (("Exhibit",), "研究模式证据编号",
1183
+ "每个 .exhibit 框都要有 .exhibit__no(Exhibit N,全篇连续)与 .exhibit__src 来源行",
1184
+ "--task research-evidence"),
1185
+ (("引用", "参考资料", "锚点"), "引用闭环",
1186
+ "正文 [n] 上标与文末条目双向对齐且编号从 1 连续;ref-link 带 target/rel",
1187
+ "--file components-atoms.md --section 7"),
1188
+ (("待核实", "tbd"), "待核实标注",
1189
+ "每处 .tbd 必须配 .tbd-legend 或 .flagbar 说明口径,只标色不解释即不合格",
1190
+ "--file components-atoms.md --section 11b"),
1191
+ (("强调", "风格", "主题", "配色"), "F8 主题/风格漂移",
1192
+ "风格是皮肤不是解药:末页禁 band--deep,收尾用 band--accent;单一强调色",
1193
+ "--file components-atoms.md --section 11"),
1194
+ (("so-what", "结论", "标题"), "F15 空洞结论",
1195
+ "research 主标题须是结论句(≥12 字含数字或判断词);so-what 禁套话",
1196
+ "--task content-rules"),
1197
+ (("版式", "节奏", "组合"), "F11 单件页默认",
1198
+ "默认一页=主件+从件+注释;同一版式不连用超 2 页,密度 L/M/H 交替",
1199
+ "--task presentation-combo"),
1200
+ (("图片", "占位", "media"), "F9 配图走样 / F10 空占位",
1201
+ "无素材用 image.placeholder + .media--ph 锁版式;路径以模型目录为锚;禁外链",
1202
+ "--task image-layout"),
1203
+ (("AI", "去AI味"), "文风",
1204
+ "改写命中的高危词;大段文字转列表",
1205
+ "--task content-rules"),
1206
+ ]
1207
+
1208
+
1209
+ def _print_fix_guide(results, strict, width):
1210
+ """校验未通过时输出定向修复指引(只列命中的失败模式,不做全量倾倒)。"""
1211
+ failed = [(lv, nm, nt) for lv, nm, ok, nt in results
1212
+ if not ok and (lv == "FAIL" or strict)]
1213
+ if not failed:
1214
+ return
1215
+ seen, guides = set(), []
1216
+ for _, name, _ in failed:
1217
+ nl = name.lower()
1218
+ # 最长关键词优先(而非首个命中即 break):避免「图表最小尺寸」被泛化的
1219
+ #「图表」关键词抢走、误路由到「换图表类型」
1220
+ best = None
1221
+ for keys, mode, action, cmd in FIX_GUIDE:
1222
+ hit = max((len(k) for k in keys if k.lower() in nl), default=0)
1223
+ if hit and (best is None or hit > best[0]):
1224
+ best = (hit, mode, action, cmd)
1225
+ if best and best[1] not in seen:
1226
+ seen.add(best[1])
1227
+ guides.append((best[1], best[2], best[3]))
1228
+ print("-" * width)
1229
+ print("修复指引(按下列顺序改;跳步直接缩字号/砍内容会把问题推给下一环):")
1230
+ if guides:
1231
+ for i, (mode, action, cmd) in enumerate(guides, 1):
1232
+ print(f" {i}. [{mode}] {action}")
1233
+ print(f" 取码: python scripts/extract_snippet.py {cmd}")
1234
+ else:
1235
+ print(" 未匹配到已登记的失败模式,按通用顺序处置:")
1236
+ print(" ① 补内容 ② 优化形态(列表化/精炼)③ 换承载形态 "
1237
+ "④ 调容器/网格 ⑤ 有限缩字号 ⑥ 最后拆页")
1238
+ print(" 完整失败模式库与错误解释纠正表: references/failure-modes.md")
1239
+
1240
+
1241
+ def main():
1242
+ if len(sys.argv) < 2:
1243
+ print(__doc__)
1244
+ return 2
1245
+ path = Path(sys.argv[1])
1246
+ strict = '--strict' in sys.argv
1247
+ as_json = '--json' in sys.argv
1248
+ if not path.exists():
1249
+ print(f"文件不存在: {path}")
1250
+ return 2
1251
+ txt = path.read_text(encoding='utf-8')
1252
+
1253
+ v = Struct()
1254
+ v.feed(txt)
1255
+
1256
+ results = []
1257
+
1258
+ def chk(name, ok, note="", level="FAIL"):
1259
+ results.append((level, name, bool(ok), note))
1260
+
1261
+ # ── 结构 ──
1262
+ chk("HTMLParser 结构 0 未闭合", not v.stack, f"剩余 {v.stack}" if v.stack else "")
1263
+ chk("HTMLParser 0 失配/游离", not v.mis and not v.err,
1264
+ f"mis={v.mis} err={v.err}" if (v.mis or v.err) else "")
1265
+
1266
+ # ── 主题与风格 ──
1267
+ chk("data-style 已指定风格", 'data-style="' in txt)
1268
+ chk('data-theme="light" + dark 变量块',
1269
+ 'data-theme="light"' in txt and '[data-theme="dark"]' in txt)
1270
+ chk("主题切换按钮 + localStorage + prefers-color-scheme",
1271
+ 'id="themeBtn"' in txt and 'localStorage' in txt and 'prefers-color-scheme' in txt)
1272
+
1273
+ # ── 模式 ──
1274
+ mode_m = re.search(r'<html[^>]*data-mode="([^"]+)"', txt)
1275
+ mode = mode_m.group(1) if mode_m else None
1276
+ chk("data-mode 已声明模式(presentation/research/architecture)",
1277
+ mode in MODE_BUDGETS,
1278
+ f"读到 {mode!r}(未声明按 presentation 处理)" if mode not in MODE_BUDGETS else "",
1279
+ level="WARN" if mode is None else "FAIL")
1280
+ mode = mode if mode in MODE_BUDGETS else 'presentation'
1281
+
1282
+ # ── 宽屏与页面高度模型 ──
1283
+ B0 = MODE_BUDGETS[mode]
1284
+ _w = B0['wrap']
1285
+ chk(f"版心 --wrap: {_w}px({mode} 模式)",
1286
+ f'--wrap:{_w}px' in txt or f'--wrap: {_w}px' in txt)
1287
+ _check_page_model(txt, chk, mode)
1288
+ chk("scroll-snap 翻页停靠", 'scroll-snap-type' in txt)
1289
+
1290
+ # ── 翻页与大纲 ──
1291
+ chk("翻页 JS(方向键 + scrollIntoView)",
1292
+ 'scrollIntoView' in txt and 'ArrowRight' in txt)
1293
+ chk("页码指示 pager", 'pager__dot' in txt and 'id="pagerDots"' in txt)
1294
+ has_agenda = 'id="agenda"' in txt
1295
+ if mode == 'architecture':
1296
+ # architecture 极简形态:内容页 ≤4 时省略 Agenda 合法(封面/收尾/参考资料不计内容页)
1297
+ struct_ids = ('agenda', 'next', 'refs', 'appendix')
1298
+ bands_a = _bands(txt)
1299
+ content_n = 0
1300
+ for i, b in enumerate(bands_a):
1301
+ if i == 0:
1302
+ continue
1303
+ if any(f'id="{sid}"' in b[:220] for sid in struct_ids):
1304
+ continue
1305
+ content_n += 1
1306
+ if content_n > 4:
1307
+ chk("存在 Agenda 大纲页(architecture 内容页 >4 时建议补)", has_agenda,
1308
+ f"内容页 {content_n} 页无 Agenda", level="WARN")
1309
+ else:
1310
+ chk("存在 Agenda 大纲页", has_agenda)
1311
+ if has_agenda:
1312
+ band_secs = [m.start() for m in re.finditer(r'<section class="band', txt)]
1313
+ agenda_pos = txt.find('id="agenda"')
1314
+ second_band_pos = band_secs[1] if len(band_secs) > 1 else -1
1315
+ ok_second = (second_band_pos != -1 and
1316
+ band_secs[0] < agenda_pos < (band_secs[2] if len(band_secs) > 2 else len(txt)))
1317
+ chk("Agenda 位于第二页", ok_second,
1318
+ f"共{len(band_secs)}页 agenda@{agenda_pos} 第2页@{second_band_pos}")
1319
+ n_items = txt.count('class="agenda__a"')
1320
+ chk("Agenda 条目可点击跳转 (≥4)", n_items >= 4, f"{n_items} 条")
1321
+ if n_items > AGENDA_SINGLE_MAX:
1322
+ chk(f"Agenda >{AGENDA_SINGLE_MAX} 条时已用 agenda--2col 双列",
1323
+ 'agenda--2col' in txt, f"{n_items} 条未双列", level="WARN")
1324
+
1325
+ # ── 主标题粗体 ──
1326
+ chk("主标题粗体 --fw-title/--fw-display",
1327
+ ('--fw-title' in txt and 'font-weight:var(--fw-title)' in txt) or
1328
+ re.search(r'\.t-h1\{[^}]*font-weight:\s*(600|700)', txt))
1329
+
1330
+ # ── 零外链 ──
1331
+ ext = re.findall(r'<(?:link|script)[^>]+(?:href|src)=["\']https?://[^"\']+', txt)
1332
+ chk("无 <link>/<script src> 外链", not ext, f"{len(ext)} 处" if ext else "")
1333
+ img_ext = re.findall(r'<img[^>]+src=["\']https?://', txt)
1334
+ chk("无外链图片", not img_ext, f"{len(img_ext)} 处" if img_ext else "")
1335
+
1336
+ # ── 引用与锚点闭环 ──
1337
+ # 编号须匹配多位数:`ref-\d` 会让 ref-10 及以后的条目在两侧同时落空,
1338
+ # 使双向对齐检查在 ≥10 条引用时静默通过(假阴性)。
1339
+ cites = re.findall(r'class="cite" href="#(ref-\d+)"', txt)
1340
+ refs = re.findall(r'id="(ref-\d+)"', txt)
1341
+ if cites or refs:
1342
+ chk("引用标记 ↔ 参考资料条目 双向对齐", set(cites) == set(refs),
1343
+ f"引用{sorted(set(cites))} 条目{sorted(set(refs))}")
1344
+ nums = sorted(int(r.split('-')[1]) for r in set(refs))
1345
+ chk(f"参考资料编号连续(1..{len(nums)} 无跳号)", nums == list(range(1, len(nums) + 1)),
1346
+ f"读到 {nums}" if nums != list(range(1, len(nums) + 1)) else "")
1347
+ else:
1348
+ chk("引用标记 ↔ 参考资料条目 双向对齐", True, "(无外部引用)", level="WARN")
1349
+ bad_links = re.findall(r'<a class="ref-link"(?![^>]*target="_blank")[^>]*>', txt) + \
1350
+ re.findall(r'<a class="ref-link"(?![^>]*rel="noopener")[^>]*>', txt)
1351
+ chk('.ref-link 均带 target="_blank" rel="noopener"', not bad_links,
1352
+ f"{len(bad_links)} 处" if bad_links else "")
1353
+ _check_anchors(txt, chk)
1354
+
1355
+ # ── 单一强调色(色值表来自 layout-constants.json styleAccents 单源) ──
1356
+ style_m = re.search(r'<html[^>]*data-style="([^"]+)"', txt)
1357
+ style = style_m.group(1) if style_m else 'business-blue'
1358
+ body_txt = txt[txt.find('</style>'):] if '</style>' in txt else txt
1359
+ body_txt = re.sub(r'<script\b[\s\S]*?</script>', ' ', body_txt)
1360
+ if STYLE_ACCENTS:
1361
+ own = STYLE_ACCENTS.get(style, set())
1362
+ others = ALL_ACCENTS - own
1363
+ bad_hues = [h for h in others if h in body_txt.lower()]
1364
+ if style == 'spectrum':
1365
+ chk("彩色模块边界(spectrum:数据色限 c1–c5,无其它风格强调色)", not bad_hues,
1366
+ f"残留 {bad_hues}" if bad_hues else "")
1367
+ else:
1368
+ chk(f"单一强调色({style},正文无第二色相)", not bad_hues,
1369
+ f"残留 {bad_hues}" if bad_hues else "")
1370
+
1371
+ hard_hex = re.findall(r'style="[^"]*(?:color|background|border-color|fill|stroke)\s*:\s*#',
1372
+ body_txt)
1373
+ chk("正文无内联写死 hex 色(用 var()/语义类)", not hard_hex,
1374
+ f"{len(hard_hex)} 处" if hard_hex else "", level="WARN")
1375
+
1376
+ # ── 内容与密度 ──
1377
+ paras = re.findall(r'<p class="t-body"[^>]*>(.*?)</p>', txt, re.S)
1378
+ long_paras = [x for x in paras if len(re.sub(r'<[^>]+>', '', x).strip()) > 110]
1379
+ chk("卡片内无超 110 字纯段落(应转列表)", not long_paras,
1380
+ f"{len(long_paras)} 段" if long_paras else "")
1381
+ _check_bands(txt, chk, mode)
1382
+ _check_table_rows(txt, chk, mode)
1383
+ _check_mode_layouts(txt, chk, mode)
1384
+ if mode != 'architecture':
1385
+ _check_icons(txt, chk)
1386
+ _check_pptx_export(txt, chk)
1387
+ model = _check_model_consistency(txt, chk, mode, style)
1388
+ _check_type_features(txt, chk, model)
1389
+ _check_exhibits(txt, chk, mode)
1390
+ _check_research_extras(txt, chk, mode)
1391
+ _check_emphasis(txt, chk)
1392
+ _check_annotations(txt, chk, model)
1393
+ _check_media(txt, chk, model)
1394
+
1395
+ if mode == 'research':
1396
+ STRUCT = ('报告大纲', '大纲', '参考资料', '数据来源', '下一步', '结论', '全文核心', 'agenda', '附录')
1397
+ h1s = re.findall(r'<h2 class="t-h1 shead__title"[^>]*>(.*?)</h2>', txt)
1398
+ short = [re.sub(r'<[^>]+>', '', h).strip() for h in h1s]
1399
+ short = [h for h in short if 0 < len(h) < 12 and not h.lower().startswith(STRUCT)]
1400
+ chk("research 行动标题(章节主标题 ≥12 字,标题即结论)", not short,
1401
+ f"过短: {short[:3]}" if short else "", level="WARN")
1402
+
1403
+ _check_content_quality(txt, chk, mode, model)
1404
+ _check_v9_hard_gates(txt, chk, model)
1405
+ _check_layout_grammar(txt, chk, mode)
1406
+
1407
+ # ── 去AI味(词表来自单源) ──
1408
+ body_plain = _plain(body_txt)
1409
+ hits = [w for w in AI_FLAVOR if re.search(w, body_plain)]
1410
+ chk("去AI味(无高危 AI 腔词汇)", not hits, f"命中 {hits}" if hits else "", level="WARN")
1411
+
1412
+ # ── 图表 ──
1413
+ _check_charts(txt, chk)
1414
+ _check_chart_variety(txt, chk, mode)
1415
+
1416
+ # ── 汇总 ──
1417
+ n_fail = sum(1 for lv, _, ok, _ in results if not ok and lv == "FAIL")
1418
+ n_warn = sum(1 for lv, _, ok, _ in results if not ok and lv == "WARN")
1419
+ n_pass = sum(1 for _, _, ok, _ in results if ok)
1420
+ verdict = "通过,可交付" if n_fail == 0 and (not strict or n_warn == 0) else \
1421
+ ("有警告" if n_fail == 0 else "不通过,需修复后重跑")
1422
+ exit_code = 1 if (n_fail > 0 or (strict and n_warn > 0)) else 0
1423
+
1424
+ if as_json:
1425
+ print(json.dumps({
1426
+ "file": str(path),
1427
+ "strict": strict,
1428
+ "pass": n_pass, "warn": n_warn, "fail": n_fail,
1429
+ "verdict": verdict,
1430
+ "checks": [{"level": lv, "name": nm, "ok": ok, "note": nt}
1431
+ for lv, nm, ok, nt in results],
1432
+ }, ensure_ascii=False, indent=2))
1433
+ return exit_code
1434
+
1435
+ W = 68
1436
+ print("=" * W)
1437
+ print(f"TopPPT HTML· 质量校验 {LC.get('version', '')} {'[STRICT]' if strict else ''}")
1438
+ print(f"文件: {path.name} ({len(txt.encode('utf-8'))/1024:.1f} KB)")
1439
+ print("=" * W)
1440
+ for level, name, ok, note in results:
1441
+ tag = "PASS" if ok else ("WARN" if level == "WARN" else "FAIL")
1442
+ line = f"[{tag}] {name}"
1443
+ if note and not ok:
1444
+ line += f" -> {note}"
1445
+ print(line)
1446
+ print("-" * W)
1447
+ print(f"PASS {n_pass} WARN {n_warn} FAIL {n_fail}")
1448
+ print(f"结论: {verdict}")
1449
+ if exit_code != 0:
1450
+ _print_fix_guide(results, strict, W)
1451
+ print("=" * W)
1452
+ return exit_code
1453
+
1454
+
1455
+ if __name__ == '__main__':
1456
+ sys.exit(main())