@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,698 +1,698 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- """TopPPT HTML · 模型驱动生成 HTML(消灭双写)
4
-
5
- 用法:
6
- python scripts/render_from_model.py report.model.json --template research.html --out report.html
7
- python scripts/render_from_model.py report.html --inplace # 用内嵌 REPORT_MODEL 重渲染正文区
8
- python scripts/render_from_model.py report.model.json --body-only > body.html
9
-
10
- 定位:
11
- REPORT_MODEL 是内容唯一事实源。本脚本按锁定版式(layout-grammar P1–P12 / scaffold 同源类名)
12
- 从模型字段渲染 HTML 正文——智能体只填模型,不再手写正文与模型两份。
13
- 引擎标记块(__TOPPPT_*__)原样保留;正文区由模型重建。
14
-
15
- 纪律:
16
- · 模型字符串字段必须是纯文本(引用写 [n]);本脚本负责转义
17
- · 版式类名与 scaffold_report / components 锁定版式一致,禁止临场发明
18
- · 渲染后必须跑 validate_report --strict
19
- """
20
- from __future__ import annotations
21
-
22
- import argparse
23
- import json
24
- import re
25
- import sys
26
- from pathlib import Path
27
-
28
- try:
29
- sys.stdout.reconfigure(encoding='utf-8', errors='replace')
30
- except Exception:
31
- pass
32
-
33
- ROOT = Path(__file__).resolve().parent.parent
34
- TPL = ROOT / 'assets' / 'templates'
35
- LC = json.loads((ROOT / 'scripts' / 'layout-constants.json').read_text(encoding='utf-8'))
36
- PAGE_TO_PRESET = ((LC.get('layoutSystem') or {}).get('pageToPreset') or {})
37
-
38
- CONTENT_RE = re.compile(
39
- r'(<!-- __TOPPPT_CONTENT_START__ -->)[\s\S]*?(<!-- __TOPPPT_CONTENT_END__ -->)')
40
- MODEL_RE = re.compile(r'window\.REPORT_MODEL = \{[\s\S]*?\};')
41
-
42
-
43
- def esc(s) -> str:
44
- return (str(s or '')
45
- .replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
46
- .replace('"', '&quot;'))
47
-
48
-
49
- def cite(text: str) -> str:
50
- """模型里 [n] → HTML 上标引用(模型保持纯文本)。"""
51
- return re.sub(r'\[(\d+)\]',
52
- r'<a class="cite" href="#ref-\1">[\1]</a>',
53
- esc(text))
54
-
55
-
56
- def kp(k, v) -> str:
57
- k, v = esc(k), cite(v) if isinstance(v, str) else esc(v)
58
- return f'<li><strong>{k}。</strong>{v}</li>' if k else f'<li>{v}</li>'
59
-
60
-
61
- def open_sec(i: int, ptype: str, extra: str = '') -> str:
62
- skel = PAGE_TO_PRESET.get(ptype, 'P4')
63
- return (f'<section class="band" id="s{i}" data-skel="{skel}" '
64
- f'data-page-type="{ptype}"{extra}>')
65
-
66
-
67
- def shead(sec: dict) -> str:
68
- lead = sec.get('lead') or ''
69
- lead_html = f'\n <p class="t-lead shead__desc">{cite(lead)}</p>' if lead else ''
70
- return (f' <div class="shead rv">\n'
71
- f' <div class="t-eyebrow">{esc(sec.get("eyebrow") or "")}</div>\n'
72
- f' <h2 class="t-h1 shead__title">{esc(sec.get("title") or "")}</h2>'
73
- f'{lead_html}\n </div>')
74
-
75
-
76
- def sowhat(sec: dict) -> str:
77
- t = sec.get('soWhat')
78
- if not t:
79
- return ''
80
- return (f' <div class="sowhat rv"><span class="sowhat__k">So what</span>'
81
- f'<span class="sowhat__v">{cite(t)}</span></div>\n')
82
-
83
-
84
- def footnote(sec: dict) -> str:
85
- t = sec.get('footnote')
86
- if not t:
87
- return ''
88
- return f' <div class="footnote">{cite(t)}</div>\n'
89
-
90
-
91
- def flags_block(sec: dict) -> str:
92
- fl = sec.get('flags') or []
93
- if not fl:
94
- return ''
95
- items = '\n'.join(f' <li>{cite(x)}</li>' for x in fl)
96
- return (f' <div class="flagbar rv"><div class="flagbar__hd">待核实</div>\n'
97
- f' <ul>\n{items}\n </ul>\n </div>\n')
98
-
99
-
100
- def wrap(i: int, ptype: str, body: str, sec: dict, extra: str = '') -> str:
101
- return (f'{open_sec(i, ptype, extra)}\n <div class="wrap">\n'
102
- f'{shead(sec)}\n{body}{sowhat(sec)}{footnote(sec)}{flags_block(sec)}'
103
- f' </div>\n</section>')
104
-
105
-
106
- def pts_list(points) -> str:
107
- if not points:
108
- return '<ul class="ul"><li>(待填)</li></ul>'
109
- lis = []
110
- for p in points:
111
- if isinstance(p, (list, tuple)) and len(p) >= 2:
112
- lis.append(kp(p[0], p[1]))
113
- elif isinstance(p, dict):
114
- lis.append(kp(p.get('k') or p.get('t') or '', p.get('v') or p.get('d') or ''))
115
- else:
116
- lis.append(f'<li>{cite(p)}</li>')
117
- return '<ul class="ul">' + ''.join(lis) + '</ul>'
118
-
119
-
120
- def chart_svg(chart: dict, idx: int, force_type: str | None = None) -> str:
121
- ct = esc(force_type or (chart or {}).get('type') or 'bar')
122
- return (f' <svg class="chart" data-chart="{ct}" viewBox="0 0 560 220">\n'
123
- f' <!-- 数据以 REPORT_MODEL.chart 为准;复杂图形用 extract_snippet --chart {ct} -->\n'
124
- f' <text x="280" y="110" text-anchor="middle" class="f-txt3" font-size="12">'
125
- f'{ct} · 见模型数据</text>\n'
126
- f' </svg>\n')
127
-
128
-
129
- # ── 页型渲染器(字段 → 锁定版式 HTML)──────────────────────────────────────────
130
- def r_points(i, sec):
131
- pts = sec.get('points') or []
132
- half = (len(pts) + 1) // 2 or 1
133
- col1, col2 = pts[:half], pts[half:]
134
- def card(title, items):
135
- return (f' <div class="card"><div class="card__hd"><h3 class="t-h3">{esc(title)}</h3></div>\n'
136
- f' {pts_list(items)}</div>')
137
- body = (' <div class="grid g-2 rv a-start">\n'
138
- + card('要点一', col1 or pts[:1]) + '\n'
139
- + card('要点二', col2 or [['', '(第二组要点)']]) + '\n'
140
- + ' </div>\n')
141
- if sec.get('metrics'):
142
- body += metrics_row(sec['metrics'])
143
- return wrap(i, 'points', body, sec)
144
-
145
-
146
- def metrics_row(metrics) -> str:
147
- cells = []
148
- for m in (metrics or [])[:6]:
149
- if isinstance(m, (list, tuple)):
150
- val, key = m[0], m[1] if len(m) > 1 else ''
151
- note = m[2] if len(m) > 2 else ''
152
- else:
153
- val, key, note = m.get('v') or m.get('value') or '', m.get('k') or '', m.get('n') or ''
154
- cells.append(
155
- f' <div class="metric"><div class="metric__v t-metric">{esc(val)}</div>'
156
- f'<div class="metric__k">{esc(key)}</div>'
157
- f'<div class="metric__n">{cite(note)}</div></div>')
158
- return f' <div class="grid g-4 rv a-start">\n' + '\n'.join(cells) + '\n </div>\n'
159
-
160
-
161
- def r_metrics(i, sec):
162
- return wrap(i, 'metrics', metrics_row(sec.get('metrics')), sec, ' band--top')
163
-
164
-
165
- def r_kpi(i, sec):
166
- hero = sec.get('hero') or []
167
- val = hero[0] if hero else ''
168
- lab = hero[1] if len(hero) > 1 else ''
169
- delta = hero[2] if len(hero) > 2 else ''
170
- body = (
171
- ' <div class="grid g-hero rv a-c">\n'
172
- f' <div class="stack gap-3"><div class="t-metric" style="color:var(--accent)">{esc(val)}</div>'
173
- f'<div class="t-h3">{esc(lab)}</div>'
174
- + (f'<div class="t-sm" style="color:var(--accent);font-weight:600">{esc(delta)}</div>' if delta else '')
175
- + '</div>\n'
176
- + ' <div class="grid g-2">\n'
177
- + pts_list(sec.get('points') or [['支撑', '一句话。']])
178
- + '\n </div>\n </div>\n')
179
- return wrap(i, 'kpi', body, sec)
180
-
181
-
182
- def r_table(i, sec):
183
- tbl = sec.get('table') or {}
184
- head = tbl.get('head') or []
185
- rows = tbl.get('rows') or []
186
- th = ''.join(f'<th>{esc(h)}</th>' for h in head)
187
- trs = []
188
- for row in rows:
189
- tds = ''.join(f'<td>{cite(c)}</td>' for c in row)
190
- trs.append(f'<tr>{tds}</tr>')
191
- body = (' <div class="tbl-wrap rv"><table><thead><tr>' + th +
192
- '</tr></thead><tbody>' + ''.join(trs) + '</tbody></table></div>\n')
193
- extra = ' band--flow' if len(rows) > 10 else ''
194
- return wrap(i, 'table', body, sec, extra)
195
-
196
-
197
- def r_bar(i, sec):
198
- ch = sec.get('chart') or {}
199
- body = (
200
- ' <div class="grid g-side rv a-start">\n'
201
- f' <div class="fig"><div class="fig__cap">{esc(sec.get("title") or "图表")}</div>\n'
202
- f'{chart_svg(ch, i)} </div>\n'
203
- ' <div class="stack gap-4"><h3 class="t-h3">怎么读这张图</h3>\n'
204
- f' {pts_list(sec.get("points") or [["结论一", "一句话。"], ["结论二", "一句话。"]])}\n'
205
- ' </div>\n'
206
- ' </div>\n')
207
- return wrap(i, 'bar', body, sec)
208
-
209
-
210
- def r_donut(i, sec):
211
- ch = dict(sec.get('chart') or {})
212
- ch.setdefault('type', 'donut')
213
- labels, values = ch.get('labels') or [], ch.get('values') or []
214
- legend = []
215
- total = sum(float(v or 0) for v in values) or 1
216
- for lb, v in zip(labels, values):
217
- pct = float(v or 0) / total * 100
218
- legend.append(f' <li><strong>{esc(lb)}。</strong>{esc(v)}({pct:.0f}%)</li>')
219
- body = (
220
- ' <div class="grid g-side rv a-start">\n'
221
- f' <div class="fig" style="text-align:center"><div class="fig__cap">构成占比</div>\n'
222
- f'{chart_svg(ch, i, "donut")} </div>\n'
223
- ' <div class="stack gap-4"><h3 class="t-h3">数值图例(禁止叠在弧上)</h3>\n'
224
- ' <ul class="ul">' + ''.join(legend) + '</ul>\n'
225
- ' </div>\n </div>\n')
226
- return wrap(i, 'donut', body, sec)
227
-
228
-
229
- def r_exhibit(i, sec):
230
- ch = dict(sec.get('chart') or {})
231
- st = (sec.get('type') or '').lower()
232
- if st in ('donut', 'bar', 'halftable') and not ch.get('type'):
233
- ch['type'] = 'donut' if st == 'donut' else 'bar'
234
- no = sec.get('exhibitNo') or i
235
- body = (
236
- ' <div class="grid g-side rv a-start">\n'
237
- ' <div class="exhibit rv">\n'
238
- f' <div class="exhibit__hd"><span class="exhibit__no">Exhibit {esc(no)}</span>'
239
- f'<span class="exhibit__t">{esc(sec.get("title") or "")}</span></div>\n'
240
- f'{chart_svg(ch, i)}'
241
- f' <div class="exhibit__src">{cite(sec.get("footnote") or "来源:待补")}</div>\n'
242
- ' </div>\n'
243
- f' {pts_list(sec.get("points") or [["结论", "一句话。"]])}\n'
244
- ' </div>\n')
245
- sec = dict(sec)
246
- sec.pop('footnote', None) # 已进来源行
247
- return wrap(i, 'exhibit', body, sec)
248
-
249
-
250
- def r_twocol(i, sec):
251
- paras = sec.get('paragraphs') or []
252
- cols = []
253
- for p in paras:
254
- if isinstance(p, (list, tuple)) and len(p) >= 2:
255
- cols.append(f' <p class="t-body"><strong>{esc(p[0])}。</strong>{cite(p[1])}</p>')
256
- else:
257
- cols.append(f' <p class="t-body">{cite(p)}</p>')
258
- body = ' <div class="cols-2 rv">\n' + '\n'.join(cols) + '\n </div>\n'
259
- return wrap(i, 'twocol', body, sec)
260
-
261
-
262
- def r_threecol(i, sec):
263
- paras = sec.get('paragraphs') or []
264
- cols = []
265
- for p in paras[:6]:
266
- if isinstance(p, (list, tuple)) and len(p) >= 2:
267
- cols.append(f' <div class="card"><h3 class="t-h3">{esc(p[0])}</h3>'
268
- f'<p class="t-body">{cite(p[1])}</p></div>')
269
- else:
270
- cols.append(f' <div class="card"><p class="t-body">{cite(p)}</p></div>')
271
- # cols-3 供校验「模型页型 ↔ 版式组件」对齐(TYPE_FEATURE.threecol)
272
- body = (' <div class="cols-3 grid g-3 rv a-start">\n' + '\n'.join(cols) + '\n </div>\n')
273
- return wrap(i, 'threecol', body, sec)
274
-
275
-
276
- def r_cards(i, sec):
277
- cards = sec.get('cards') or []
278
- n = int(sec.get('columns') or min(3, max(1, len(cards))))
279
- cls = {1: 'g-2', 2: 'g-2', 3: 'g-3', 4: 'g-4'}.get(n, 'g-3')
280
- blocks = []
281
- for cd in cards:
282
- title = cd.get('title') if isinstance(cd, dict) else (cd[0] if cd else '')
283
- points = cd.get('points') if isinstance(cd, dict) else ([['', cd[1]]] if isinstance(cd, (list, tuple)) and len(cd) > 1 else [])
284
- blocks.append(
285
- f' <div class="card"><div class="card__hd"><h3 class="t-h3">{esc(title)}</h3></div>\n'
286
- f' {pts_list(points)}</div>')
287
- body = f' <div class="grid {cls} rv a-start">\n' + '\n'.join(blocks) + '\n </div>\n'
288
- return wrap(i, 'cards', body, sec)
289
-
290
-
291
- def r_split(i, sec):
292
- def zone(el, side):
293
- if not el:
294
- return ' <div></div>'
295
- t = (el.get('type') or ('points' if el.get('points') else 'bar'))
296
- if t == 'table':
297
- return (' <div class="tbl-wrap"><table><thead><tr>' +
298
- ''.join(f'<th>{esc(h)}</th>' for h in (el.get('head') or [])) +
299
- '</tr></thead><tbody>' +
300
- ''.join('<tr>' + ''.join(f'<td>{cite(c)}</td>' for c in row) + '</tr>'
301
- for row in (el.get('rows') or [])) +
302
- '</tbody></table></div>')
303
- if t == 'image':
304
- im = el.get('image') or {}
305
- cap = im.get('caption') or ''
306
- return (' <figure class="media media--r4-3 media--ph">'
307
- '<div class="media__ph"><b>配图占位</b><span>建议 1200×900px</span></div></figure>'
308
- + (f'\n <div class="media__cap--below">{esc(cap)}</div>' if cap else ''))
309
- if t == 'points' or el.get('points') and t not in ('bar', 'line', 'donut', 'hbar'):
310
- return f' <div class="stack gap-4">{pts_list(el.get("points"))}</div>'
311
- return (f' <div class="fig"><div class="fig__cap">{esc(el.get("cap") or "图表")}</div>\n'
312
- f'{chart_svg(el, i)} </div>')
313
- body = (' <div class="grid g-side rv a-start">\n'
314
- f'{zone(sec.get("left"), "L")}\n{zone(sec.get("right"), "R")}\n'
315
- ' </div>\n')
316
- return wrap(i, 'split', body, sec)
317
-
318
-
319
- def r_comparison(i, sec):
320
- def panel(d, accent=False):
321
- style = ' style="background:var(--accent-soft);border-color:transparent"' if accent else ''
322
- tcol = ' style="color:var(--accent)"' if accent else ''
323
- return (f' <div class="card"{style}><h3 class="t-h3"{tcol}>{esc((d or {}).get("title") or "")}</h3>\n'
324
- f' {pts_list((d or {}).get("points"))}</div>')
325
- body = (' <div class="grid g-half g-2 rv a-start">\n'
326
- + panel(sec.get('left')) + '\n' + panel(sec.get('right'), True) + '\n'
327
- + ' </div>\n')
328
- if sec.get('verdict'):
329
- body += (f' <div class="sowhat rv"><span class="sowhat__k">结论</span>'
330
- f'<span class="sowhat__v">{cite(sec["verdict"])}</span></div>\n')
331
- sec = dict(sec)
332
- sec.pop('verdict', None)
333
- return wrap(i, 'comparison', body, sec)
334
-
335
-
336
- def r_quote(i, sec):
337
- body = (
338
- ' <div class="shead shead--center rv">\n'
339
- f' <div style="font-size:clamp(40px,4.6vh,56px);font-weight:700;color:var(--accent)">「</div>\n'
340
- f' <p class="t-lead" style="max-width:860px;margin-inline:auto;font-weight:600">{cite(sec.get("quote") or "")}</p>\n'
341
- f' <p class="t-sm" style="color:var(--accent-text)">—— {esc(sec.get("author") or "")}'
342
- + (f' · {esc(sec.get("context"))}' if sec.get('context') else '')
343
- + '</p>\n </div>\n')
344
- return wrap(i, 'quote', body, sec, ' band--accent band--fit')
345
-
346
-
347
- def r_image(i, sec):
348
- im = sec.get('image') or {}
349
- layout = (im.get('layout') or 'full').lower()
350
- ratio = {'half': 'r4-3', 'bleed': 'r21-9', 'grid': 'r4-3', 'compare': 'r4-3',
351
- 'wall': 'r1-1', 'full': 'r3-1'}.get(layout, 'r3-1')
352
- cap = im.get('caption') or ''
353
- media = (f' <figure class="media media--{ratio} media--ph rv">'
354
- f'<div class="media__ph"><b>配图占位</b><span>{esc(im.get("hint") or "建议尺寸见 imageSpec")}</span></div>'
355
- f'</figure>\n')
356
- if cap:
357
- media += f' <p class="media__src">{cite(cap)}</p>\n'
358
- body = media
359
- if layout == 'half' and sec.get('points'):
360
- body = (' <div class="grid g-side rv a-start">\n' + media +
361
- f' <div class="stack gap-4">{pts_list(sec.get("points"))}</div>\n </div>\n')
362
- return wrap(i, 'image', body, sec)
363
-
364
-
365
- def r_diagram(i, sec):
366
- layers = sec.get('layers') or []
367
- rows = []
368
- for li, lay in enumerate(layers):
369
- name = lay[0] if lay else ''
370
- nodes = lay[1] if len(lay) > 1 else []
371
- focus = len(lay) > 2 and lay[2] == 'focus'
372
- nds = []
373
- for nd in nodes[:8]:
374
- if isinstance(nd, dict):
375
- nds.append(f' <div class="arch__node"><div class="arch__nt">{esc(nd.get("t") or "")}</div>'
376
- f'<div class="arch__nd">{esc(nd.get("d") or "")}</div></div>')
377
- else:
378
- nds.append(f' <div class="arch__node"><div class="arch__nt">{esc(nd)}</div></div>')
379
- rows.append(
380
- f' <div class="arch__layer{" arch__layer--focus" if focus else ""}">\n'
381
- f' <div class="arch__lname">{esc(name)}</div>\n'
382
- f' <div class="arch__nodes">\n' + '\n'.join(nds) + '\n </div>\n </div>')
383
- if li < len(layers) - 1:
384
- rows.append(' <div class="arch__conn" aria-hidden="true">'
385
- '<svg width="18" height="22" viewBox="0 0 18 22" fill="none" stroke="currentColor" '
386
- 'stroke-width="2"><path d="M9 2v14"/><path d="M4 12l5 6 5-6"/></svg></div>')
387
- legend = ''.join(f'<span class="chip">{esc(x)}</span>' for x in (sec.get('legend') or []))
388
- body = (' <div class="arch rv">\n' + '\n'.join(rows) + '\n </div>\n'
389
- + (f' <div class="arch__legend">{legend}</div>\n' if legend else ''))
390
- return wrap(i, 'diagram', body, sec, ' band--fit')
391
-
392
-
393
- def r_lane(i, sec):
394
- lanes = sec.get('lanes') or []
395
- rows = []
396
- for ln in lanes:
397
- hd = ln[0] if ln else ''
398
- steps = ln[1] if len(ln) > 1 else []
399
- parts = []
400
- for si, st in enumerate(steps):
401
- if isinstance(st, dict):
402
- cls = 'lane__step lane__step--a' if st.get('accent') else 'lane__step'
403
- parts.append(f'<span class="{cls}">{esc(st.get("t") or "")}</span>')
404
- else:
405
- parts.append(f'<span class="lane__step">{esc(st)}</span>')
406
- if si < len(steps) - 1:
407
- parts.append('<span class="lane__arr" aria-hidden="true"></span>')
408
- rows.append(f' <div class="lane"><div class="lane__hd">{esc(hd)}</div>'
409
- f'<div class="lane__body">' + ''.join(parts) + '</div></div>')
410
- body = ' <div class="stack rv gap-3">\n' + '\n'.join(rows) + '\n </div>\n'
411
- return wrap(i, 'lane', body, sec, ' band--fit')
412
-
413
-
414
- def r_timeline(i, sec):
415
- items = []
416
- for ph in (sec.get('phases') or []):
417
- if isinstance(ph, (list, tuple)):
418
- lab, name, desc = (list(ph) + ['', '', ''])[:3]
419
- state = ph[3] if len(ph) > 3 else ''
420
- else:
421
- lab, name, desc, state = ph.get('label', ''), ph.get('name', ''), ph.get('d', ''), ph.get('s', '')
422
- cls = 'tl__i'
423
- if state == 'done':
424
- cls += ' tl__i--done'
425
- elif state == 'now':
426
- cls += ' tl__i--now'
427
- items.append(
428
- f' <div class="{cls}"><div class="tl__d"></div>'
429
- f'<div class="tl__l">{esc(lab)}</div><div class="tl__t">{esc(name)}</div>'
430
- f'<p class="t-body">{cite(desc)}</p></div>')
431
- body = ' <div class="fig rv"><div class="tl">\n' + '\n'.join(items) + '\n </div></div>\n'
432
- return wrap(i, 'timeline', body, sec)
433
-
434
-
435
- def r_steps(i, sec):
436
- items = []
437
- for si, st in enumerate((sec.get('steps') or [])[:6]):
438
- if isinstance(st, (list, tuple)):
439
- t, d = (list(st) + ['', ''])[:2]
440
- acc = False
441
- else:
442
- t, d, acc = st.get('t', ''), st.get('d', ''), bool(st.get('accent'))
443
- cls = 'step step--a' if acc else 'step'
444
- items.append(f' <div class="{cls}"><div class="step__n">{si + 1:02d}</div>'
445
- f'<div class="step__t">{esc(t)}</div><div class="step__d">{cite(d)}</div></div>')
446
- body = ' <div class="steps rv">\n' + '\n'.join(items) + '\n </div>\n'
447
- return wrap(i, 'steps', body, sec)
448
-
449
-
450
- def r_heatmap(i, sec):
451
- rows, cols, cells = sec.get('rowHeads') or [], sec.get('colHeads') or [], sec.get('cells') or []
452
- body = f' <div class="heat rv" style="--heat-cols:{max(1, len(cols))}"><div class="heat__grid">\n'
453
- body += ' <div></div>' + ''.join(f'<div class="heat__h">{esc(c)}</div>' for c in cols) + '\n'
454
- for ri, rh in enumerate(rows):
455
- body += f' <div class="heat__rh">{esc(rh)}</div>'
456
- row = cells[ri] if ri < len(cells) else []
457
- for ci in range(len(cols)):
458
- v = row[ci] if ci < len(row) else ''
459
- body += f'<div class="heat__c">{esc(v)}</div>'
460
- body += '\n'
461
- body += ' </div></div>\n'
462
- return wrap(i, 'heatmap', body, sec)
463
-
464
-
465
- def r_bullet(i, sec):
466
- items = sec.get('items') or []
467
- rows = []
468
- for it in items:
469
- if isinstance(it, (list, tuple)):
470
- k, a, t = (list(it) + ['', 0, 0])[:3]
471
- else:
472
- k, a, t = it.get('k', ''), it.get('a', 0), it.get('t', 0)
473
- mx = float(sec.get('max') or 100) or 100
474
- fw = max(0, min(100, float(a or 0) / mx * 100))
475
- tw = max(0, min(100, float(t or 0) / mx * 100))
476
- rows.append(
477
- f' <div class="bul__row"><div class="bul__k">{esc(k)}</div>'
478
- f'<div class="bul__track"><div class="bul__fill" style="width:{fw}%"></div>'
479
- f'<div class="bul__tgt" style="left:{tw}%"></div></div>'
480
- f'<div class="bul__v"><b>{esc(a)}</b> / {esc(t)}</div></div>')
481
- body = ' <div class="bul rv">\n' + '\n'.join(rows) + '\n </div>\n'
482
- return wrap(i, 'bullet', body, sec)
483
-
484
-
485
- def r_pyramid(i, sec):
486
- levels = sec.get('levels') or []
487
- n = max(1, len(levels))
488
- rows = []
489
- for i2, lv in enumerate(levels):
490
- if isinstance(lv, (list, tuple)):
491
- t, d = (list(lv) + ['', ''])[:2]
492
- else:
493
- t, d = lv.get('t', ''), lv.get('d', '')
494
- w = 40 + int(60 * (i2 + 1) / n)
495
- rows.append(f' <div class="pyr__lvl" style="--w:{w}%">'
496
- f'<div class="pyr__t">{esc(t)}</div><div class="pyr__d">{cite(d)}</div></div>')
497
- body = ' <div class="pyr rv">\n' + '\n'.join(rows) + '\n </div>\n'
498
- return wrap(i, 'pyramid', body, sec)
499
-
500
-
501
- def r_halftable(i, sec):
502
- """半表半图:g-half 左表右图(校验 TYPE_FEATURE.halftable)。"""
503
- tbl = sec.get('table') or {}
504
- ch = sec.get('chart') or {}
505
- head = tbl.get('head') or []
506
- rows = tbl.get('rows') or []
507
- th = ''.join(f'<th>{esc(h)}</th>' for h in head)
508
- trs = ''.join('<tr>' + ''.join(f'<td>{cite(c)}</td>' for c in row) + '</tr>' for row in rows)
509
- body = (
510
- ' <div class="grid g-half g-2 rv a-start">\n'
511
- f' <div class="tbl-wrap"><table><thead><tr>{th}</tr></thead><tbody>{trs}</tbody></table></div>\n'
512
- f' <div class="fig"><div class="fig__cap">{esc(ch.get("cap") or "互证图")}</div>\n'
513
- f'{chart_svg(ch, i)} </div>\n'
514
- ' </div>\n')
515
- return wrap(i, 'halftable', body, sec)
516
-
517
-
518
- def r_matrix(i, sec):
519
- rows, cols, cells = sec.get('rowHeads') or [], sec.get('colHeads') or [], sec.get('cells') or []
520
- body = f' <div class="matrix heat rv" style="--heat-cols:{max(1, len(cols))}"><div class="heat__grid">\n'
521
- body += ' <div></div>' + ''.join(f'<div class="heat__h">{esc(c)}</div>' for c in cols) + '\n'
522
- for ri, rh in enumerate(rows):
523
- body += f' <div class="heat__rh">{esc(rh)}</div>'
524
- row = cells[ri] if ri < len(cells) else []
525
- for ci in range(len(cols)):
526
- v = row[ci] if ci < len(row) else ''
527
- if isinstance(v, dict):
528
- v = v.get('t') or v.get('v') or ''
529
- body += f'<div class="heat__c">{esc(v)}</div>'
530
- body += '\n'
531
- body += ' </div></div>\n'
532
- return wrap(i, 'matrix', body, sec)
533
-
534
-
535
- def r_info(i, sec):
536
- t = sec.get('type') or 'sankey'
537
- body = (f' <div class="fig rv"><div class="fig__cap">{esc(t)} 信息图</div>\n'
538
- f' <svg class="chart" data-chart="{esc(t)}" viewBox="0 0 900 400">\n'
539
- f' <text x="450" y="200" text-anchor="middle" class="f-txt3">{esc(t)} · 按 infographics 规格绘制</text>\n'
540
- f' </svg>\n </div>\n')
541
- return wrap(i, t, body, sec)
542
-
543
-
544
- RENDERERS = {
545
- 'points': r_points, 'metrics': r_metrics, 'kpi': r_kpi, 'table': r_table,
546
- 'bar': r_bar, 'donut': r_donut, 'exhibit': r_exhibit, 'twocol': r_twocol,
547
- 'threecol': r_threecol, 'cards': r_cards, 'split': r_split, 'comparison': r_comparison,
548
- 'quote': r_quote, 'image': r_image, 'diagram': r_diagram, 'lane': r_lane,
549
- 'timeline': r_timeline, 'steps': r_steps, 'heatmap': r_heatmap, 'bullet': r_bullet,
550
- 'pyramid': r_pyramid, 'halftable': r_halftable, 'matrix': r_matrix,
551
- 'sankey': r_info, 'treemap': r_info, 'boxplot': r_info, 'network': r_info,
552
- 'marimekko': r_info, 'streamgraph': r_info,
553
- }
554
-
555
-
556
- def render_body(model: dict) -> str:
557
- parts = []
558
- # 封面
559
- parts.append(f'''<!-- 封面 -->
560
- <section class="band band--fit" id="cover">
561
- <div class="wrap">
562
- <div class="shead rv">
563
- <div class="t-eyebrow">{esc(model.get("meta") or "")}</div>
564
- <h1 class="t-display shead__title">{esc(model.get("title") or "")}</h1>
565
- <p class="t-lead shead__desc">{esc(model.get("subtitle") or "")}</p>
566
- </div>
567
- </div>
568
- </section>''')
569
- agenda = model.get('agenda') or []
570
- secs = [s for s in (model.get('sections') or []) if isinstance(s, dict)]
571
- # Agenda 与 sections 对齐:模型 agenda 缺失/条数不符时以 sections 为准(并回写模型)
572
- if len(agenda) != len(secs):
573
- model['agenda'] = [[f'{i:02d}', s.get('title') or '', s.get('eyebrow') or '']
574
- for i, s in enumerate(secs, 1)]
575
- agenda = model['agenda']
576
- if agenda or not (model.get('mode') == 'architecture' and len(secs) <= 4):
577
- lis = []
578
- for i, s in enumerate(secs, 1):
579
- ag = agenda[i - 1] if i - 1 < len(agenda) else [f'{i:02d}', s.get('title') or '', s.get('eyebrow') or '']
580
- num = ag[0] if isinstance(ag, (list, tuple)) and ag else f'{i:02d}'
581
- title = ag[1] if isinstance(ag, (list, tuple)) and len(ag) > 1 else (s.get('title') or '')
582
- desc = ag[2] if isinstance(ag, (list, tuple)) and len(ag) > 2 else (s.get('eyebrow') or '')
583
- lis.append(
584
- f' <li class="agenda__i"><a class="agenda__a" href="#s{i}">'
585
- f'<span class="agenda__n">{esc(num)}</span>'
586
- f'<span><span class="agenda__t">{esc(title)}</span>'
587
- f'<div class="agenda__d">{esc(desc)}</div></span></a></li>')
588
- parts.append(f'''<section class="band band--top" id="agenda">
589
- <div class="wrap">
590
- <div class="shead rv"><div class="t-eyebrow">目录</div>
591
- <h2 class="t-h1 shead__title">报告大纲</h2></div>
592
- <ol class="agenda rv">{''.join(lis)}</ol>
593
- </div>
594
- </section>''')
595
- ex_n = 0
596
- for i, sec in enumerate(secs, 1):
597
- t = (sec.get('type') or 'points').lower()
598
- is_research_exhibit = (model.get('mode') == 'research'
599
- and t in ('exhibit', 'bar', 'halftable', 'donut'))
600
- if is_research_exhibit:
601
- ex_n += 1
602
- sec = dict(sec)
603
- sec['exhibitNo'] = ex_n
604
- fn = RENDERERS.get(t, r_points)
605
- if is_research_exhibit:
606
- fn = r_exhibit
607
- try:
608
- parts.append(fn(i, sec))
609
- except Exception as e:
610
- parts.append(wrap(i, t, f' <p class="t-body">渲染失败 {esc(e)}</p>', sec))
611
- closing = model.get('closing') or {}
612
- cpts = closing.get('points') or []
613
- body = ' <div class="grid g-3 rv a-start">\n' + pts_list(cpts) + '\n </div>\n'
614
- parts.append(f'''<section class="band band--accent" id="closing">
615
- <div class="wrap">
616
- <div class="shead rv"><div class="t-eyebrow">下一步</div>
617
- <h2 class="t-h1 shead__title">{esc(closing.get("title") or "收尾")}</h2></div>
618
- {body} </div>
619
- </section>''')
620
- # 参考资料
621
- refs = []
622
- all_text = json.dumps(model, ensure_ascii=False)
623
- for n in sorted({int(x) for x in re.findall(r'\[(\d+)\]', all_text)}):
624
- refs.append(f' <li id="ref-{n}">[{n}] 来源名称,时间;口径。</li>')
625
- parts.append(f'''<section class="band band--flow" id="refs">
626
- <div class="wrap">
627
- <div class="shead rv"><div class="t-eyebrow">附录</div>
628
- <h2 class="t-h1 shead__title">参考资料</h2></div>
629
- <ol class="refs">{''.join(refs) or '<li>(无外部引用)</li>'}</ol>
630
- </div>
631
- </section>''')
632
- return '\n\n'.join(parts)
633
-
634
-
635
- def load_model(path: Path) -> dict:
636
- txt = path.read_text(encoding='utf-8')
637
- if path.suffix.lower() == '.json':
638
- return json.loads(txt)
639
- m = re.search(r'window\.REPORT_MODEL\s*=\s*(\{[\s\S]*?\})\s*;', txt)
640
- if not m:
641
- raise SystemExit('未找到 window.REPORT_MODEL')
642
- return json.loads(m.group(1))
643
-
644
-
645
- def main() -> int:
646
- ap = argparse.ArgumentParser(description='从 REPORT_MODEL 渲染 HTML 正文(模型驱动生成)')
647
- ap.add_argument('src', help='model.json 或含 REPORT_MODEL 的 .html')
648
- ap.add_argument('--template', help='模式模板名 presentation|research|architecture 或路径')
649
- ap.add_argument('--out', help='输出 HTML')
650
- ap.add_argument('--inplace', action='store_true', help='就地替换 src 的 CONTENT 区')
651
- ap.add_argument('--body-only', action='store_true', help='只打印正文片段')
652
- args = ap.parse_args()
653
-
654
- src = Path(args.src)
655
- model = load_model(src)
656
- body = render_body(model)
657
-
658
- if args.body_only:
659
- sys.stdout.write(body)
660
- return 0
661
-
662
- if args.inplace:
663
- t = src.read_text(encoding='utf-8')
664
- if not CONTENT_RE.search(t):
665
- print('错误:--inplace 需要 __TOPPPT_CONTENT__ 标记', file=sys.stderr)
666
- return 2
667
- t = CONTENT_RE.sub(
668
- r'\1\n' + body.replace('\\', '\\\\') + r'\n\2', t, count=1)
669
- # 同步模型(确保与渲染源一致)
670
- t = MODEL_RE.sub('window.REPORT_MODEL = ' +
671
- json.dumps(model, ensure_ascii=False, indent=2) + ';', t, count=1)
672
- src.write_text(t, encoding='utf-8')
673
- print(f'已按模型重渲染正文:{src}({len(body)} 字符)')
674
- print(f' 下一步: python scripts/validate_report.py "{src}" --strict')
675
- return 0
676
-
677
- mode = model.get('mode') or 'presentation'
678
- tpl = args.template or mode
679
- tpl_path = Path(tpl) if Path(tpl).exists() else TPL / f'{tpl}.html'
680
- if not tpl_path.exists():
681
- print(f'错误:模板不存在 {tpl_path}', file=sys.stderr)
682
- return 2
683
- t = tpl_path.read_text(encoding='utf-8')
684
- if not CONTENT_RE.search(t):
685
- print('错误:模板缺少 CONTENT 标记', file=sys.stderr)
686
- return 2
687
- t = CONTENT_RE.sub(r'\1\n' + body.replace('\\', '\\\\') + r'\n\2', t, count=1)
688
- t = MODEL_RE.sub('window.REPORT_MODEL = ' +
689
- json.dumps(model, ensure_ascii=False, indent=2) + ';', t, count=1)
690
- out = Path(args.out or (src.with_suffix('.html')))
691
- out.write_text(t, encoding='utf-8')
692
- print(f'已从模型生成:{out} sections={len(model.get("sections") or [])}')
693
- print(f' 下一步: python scripts/validate_report.py "{out}" --strict')
694
- return 0
695
-
696
-
697
- if __name__ == '__main__':
698
- sys.exit(main())
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """TopPPT HTML · 模型驱动生成 HTML(消灭双写)
4
+
5
+ 用法:
6
+ python scripts/render_from_model.py report.model.json --template research.html --out report.html
7
+ python scripts/render_from_model.py report.html --inplace # 用内嵌 REPORT_MODEL 重渲染正文区
8
+ python scripts/render_from_model.py report.model.json --body-only > body.html
9
+
10
+ 定位:
11
+ REPORT_MODEL 是内容唯一事实源。本脚本按锁定版式(layout-grammar P1–P12 / scaffold 同源类名)
12
+ 从模型字段渲染 HTML 正文——智能体只填模型,不再手写正文与模型两份。
13
+ 引擎标记块(__TOPPPT_*__)原样保留;正文区由模型重建。
14
+
15
+ 纪律:
16
+ · 模型字符串字段必须是纯文本(引用写 [n]);本脚本负责转义
17
+ · 版式类名与 scaffold_report / components 锁定版式一致,禁止临场发明
18
+ · 渲染后必须跑 validate_report --strict
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import json
24
+ import re
25
+ import sys
26
+ from pathlib import Path
27
+
28
+ try:
29
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
30
+ except Exception:
31
+ pass
32
+
33
+ ROOT = Path(__file__).resolve().parent.parent
34
+ TPL = ROOT / 'assets' / 'templates'
35
+ LC = json.loads((ROOT / 'scripts' / 'layout-constants.json').read_text(encoding='utf-8'))
36
+ PAGE_TO_PRESET = ((LC.get('layoutSystem') or {}).get('pageToPreset') or {})
37
+
38
+ CONTENT_RE = re.compile(
39
+ r'(<!-- __TOPPPT_CONTENT_START__ -->)[\s\S]*?(<!-- __TOPPPT_CONTENT_END__ -->)')
40
+ MODEL_RE = re.compile(r'window\.REPORT_MODEL = \{[\s\S]*?\};')
41
+
42
+
43
+ def esc(s) -> str:
44
+ return (str(s or '')
45
+ .replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
46
+ .replace('"', '&quot;'))
47
+
48
+
49
+ def cite(text: str) -> str:
50
+ """模型里 [n] → HTML 上标引用(模型保持纯文本)。"""
51
+ return re.sub(r'\[(\d+)\]',
52
+ r'<a class="cite" href="#ref-\1">[\1]</a>',
53
+ esc(text))
54
+
55
+
56
+ def kp(k, v) -> str:
57
+ k, v = esc(k), cite(v) if isinstance(v, str) else esc(v)
58
+ return f'<li><strong>{k}。</strong>{v}</li>' if k else f'<li>{v}</li>'
59
+
60
+
61
+ def open_sec(i: int, ptype: str, extra: str = '') -> str:
62
+ skel = PAGE_TO_PRESET.get(ptype, 'P4')
63
+ return (f'<section class="band" id="s{i}" data-skel="{skel}" '
64
+ f'data-page-type="{ptype}"{extra}>')
65
+
66
+
67
+ def shead(sec: dict) -> str:
68
+ lead = sec.get('lead') or ''
69
+ lead_html = f'\n <p class="t-lead shead__desc">{cite(lead)}</p>' if lead else ''
70
+ return (f' <div class="shead rv">\n'
71
+ f' <div class="t-eyebrow">{esc(sec.get("eyebrow") or "")}</div>\n'
72
+ f' <h2 class="t-h1 shead__title">{esc(sec.get("title") or "")}</h2>'
73
+ f'{lead_html}\n </div>')
74
+
75
+
76
+ def sowhat(sec: dict) -> str:
77
+ t = sec.get('soWhat')
78
+ if not t:
79
+ return ''
80
+ return (f' <div class="sowhat rv"><span class="sowhat__k">So what</span>'
81
+ f'<span class="sowhat__v">{cite(t)}</span></div>\n')
82
+
83
+
84
+ def footnote(sec: dict) -> str:
85
+ t = sec.get('footnote')
86
+ if not t:
87
+ return ''
88
+ return f' <div class="footnote">{cite(t)}</div>\n'
89
+
90
+
91
+ def flags_block(sec: dict) -> str:
92
+ fl = sec.get('flags') or []
93
+ if not fl:
94
+ return ''
95
+ items = '\n'.join(f' <li>{cite(x)}</li>' for x in fl)
96
+ return (f' <div class="flagbar rv"><div class="flagbar__hd">待核实</div>\n'
97
+ f' <ul>\n{items}\n </ul>\n </div>\n')
98
+
99
+
100
+ def wrap(i: int, ptype: str, body: str, sec: dict, extra: str = '') -> str:
101
+ return (f'{open_sec(i, ptype, extra)}\n <div class="wrap">\n'
102
+ f'{shead(sec)}\n{body}{sowhat(sec)}{footnote(sec)}{flags_block(sec)}'
103
+ f' </div>\n</section>')
104
+
105
+
106
+ def pts_list(points) -> str:
107
+ if not points:
108
+ return '<ul class="ul"><li>(待填)</li></ul>'
109
+ lis = []
110
+ for p in points:
111
+ if isinstance(p, (list, tuple)) and len(p) >= 2:
112
+ lis.append(kp(p[0], p[1]))
113
+ elif isinstance(p, dict):
114
+ lis.append(kp(p.get('k') or p.get('t') or '', p.get('v') or p.get('d') or ''))
115
+ else:
116
+ lis.append(f'<li>{cite(p)}</li>')
117
+ return '<ul class="ul">' + ''.join(lis) + '</ul>'
118
+
119
+
120
+ def chart_svg(chart: dict, idx: int, force_type: str | None = None) -> str:
121
+ ct = esc(force_type or (chart or {}).get('type') or 'bar')
122
+ return (f' <svg class="chart" data-chart="{ct}" viewBox="0 0 560 220">\n'
123
+ f' <!-- 数据以 REPORT_MODEL.chart 为准;复杂图形用 extract_snippet --chart {ct} -->\n'
124
+ f' <text x="280" y="110" text-anchor="middle" class="f-txt3" font-size="12">'
125
+ f'{ct} · 见模型数据</text>\n'
126
+ f' </svg>\n')
127
+
128
+
129
+ # ── 页型渲染器(字段 → 锁定版式 HTML)──────────────────────────────────────────
130
+ def r_points(i, sec):
131
+ pts = sec.get('points') or []
132
+ half = (len(pts) + 1) // 2 or 1
133
+ col1, col2 = pts[:half], pts[half:]
134
+ def card(title, items):
135
+ return (f' <div class="card"><div class="card__hd"><h3 class="t-h3">{esc(title)}</h3></div>\n'
136
+ f' {pts_list(items)}</div>')
137
+ body = (' <div class="grid g-2 rv a-start">\n'
138
+ + card('要点一', col1 or pts[:1]) + '\n'
139
+ + card('要点二', col2 or [['', '(第二组要点)']]) + '\n'
140
+ + ' </div>\n')
141
+ if sec.get('metrics'):
142
+ body += metrics_row(sec['metrics'])
143
+ return wrap(i, 'points', body, sec)
144
+
145
+
146
+ def metrics_row(metrics) -> str:
147
+ cells = []
148
+ for m in (metrics or [])[:6]:
149
+ if isinstance(m, (list, tuple)):
150
+ val, key = m[0], m[1] if len(m) > 1 else ''
151
+ note = m[2] if len(m) > 2 else ''
152
+ else:
153
+ val, key, note = m.get('v') or m.get('value') or '', m.get('k') or '', m.get('n') or ''
154
+ cells.append(
155
+ f' <div class="metric"><div class="metric__v t-metric">{esc(val)}</div>'
156
+ f'<div class="metric__k">{esc(key)}</div>'
157
+ f'<div class="metric__n">{cite(note)}</div></div>')
158
+ return f' <div class="grid g-4 rv a-start">\n' + '\n'.join(cells) + '\n </div>\n'
159
+
160
+
161
+ def r_metrics(i, sec):
162
+ return wrap(i, 'metrics', metrics_row(sec.get('metrics')), sec, ' band--top')
163
+
164
+
165
+ def r_kpi(i, sec):
166
+ hero = sec.get('hero') or []
167
+ val = hero[0] if hero else ''
168
+ lab = hero[1] if len(hero) > 1 else ''
169
+ delta = hero[2] if len(hero) > 2 else ''
170
+ body = (
171
+ ' <div class="grid g-hero rv a-c">\n'
172
+ f' <div class="stack gap-3"><div class="t-metric" style="color:var(--accent)">{esc(val)}</div>'
173
+ f'<div class="t-h3">{esc(lab)}</div>'
174
+ + (f'<div class="t-sm" style="color:var(--accent);font-weight:600">{esc(delta)}</div>' if delta else '')
175
+ + '</div>\n'
176
+ + ' <div class="grid g-2">\n'
177
+ + pts_list(sec.get('points') or [['支撑', '一句话。']])
178
+ + '\n </div>\n </div>\n')
179
+ return wrap(i, 'kpi', body, sec)
180
+
181
+
182
+ def r_table(i, sec):
183
+ tbl = sec.get('table') or {}
184
+ head = tbl.get('head') or []
185
+ rows = tbl.get('rows') or []
186
+ th = ''.join(f'<th>{esc(h)}</th>' for h in head)
187
+ trs = []
188
+ for row in rows:
189
+ tds = ''.join(f'<td>{cite(c)}</td>' for c in row)
190
+ trs.append(f'<tr>{tds}</tr>')
191
+ body = (' <div class="tbl-wrap rv"><table><thead><tr>' + th +
192
+ '</tr></thead><tbody>' + ''.join(trs) + '</tbody></table></div>\n')
193
+ extra = ' band--flow' if len(rows) > 10 else ''
194
+ return wrap(i, 'table', body, sec, extra)
195
+
196
+
197
+ def r_bar(i, sec):
198
+ ch = sec.get('chart') or {}
199
+ body = (
200
+ ' <div class="grid g-side rv a-start">\n'
201
+ f' <div class="fig"><div class="fig__cap">{esc(sec.get("title") or "图表")}</div>\n'
202
+ f'{chart_svg(ch, i)} </div>\n'
203
+ ' <div class="stack gap-4"><h3 class="t-h3">怎么读这张图</h3>\n'
204
+ f' {pts_list(sec.get("points") or [["结论一", "一句话。"], ["结论二", "一句话。"]])}\n'
205
+ ' </div>\n'
206
+ ' </div>\n')
207
+ return wrap(i, 'bar', body, sec)
208
+
209
+
210
+ def r_donut(i, sec):
211
+ ch = dict(sec.get('chart') or {})
212
+ ch.setdefault('type', 'donut')
213
+ labels, values = ch.get('labels') or [], ch.get('values') or []
214
+ legend = []
215
+ total = sum(float(v or 0) for v in values) or 1
216
+ for lb, v in zip(labels, values):
217
+ pct = float(v or 0) / total * 100
218
+ legend.append(f' <li><strong>{esc(lb)}。</strong>{esc(v)}({pct:.0f}%)</li>')
219
+ body = (
220
+ ' <div class="grid g-side rv a-start">\n'
221
+ f' <div class="fig" style="text-align:center"><div class="fig__cap">构成占比</div>\n'
222
+ f'{chart_svg(ch, i, "donut")} </div>\n'
223
+ ' <div class="stack gap-4"><h3 class="t-h3">数值图例(禁止叠在弧上)</h3>\n'
224
+ ' <ul class="ul">' + ''.join(legend) + '</ul>\n'
225
+ ' </div>\n </div>\n')
226
+ return wrap(i, 'donut', body, sec)
227
+
228
+
229
+ def r_exhibit(i, sec):
230
+ ch = dict(sec.get('chart') or {})
231
+ st = (sec.get('type') or '').lower()
232
+ if st in ('donut', 'bar', 'halftable') and not ch.get('type'):
233
+ ch['type'] = 'donut' if st == 'donut' else 'bar'
234
+ no = sec.get('exhibitNo') or i
235
+ body = (
236
+ ' <div class="grid g-side rv a-start">\n'
237
+ ' <div class="exhibit rv">\n'
238
+ f' <div class="exhibit__hd"><span class="exhibit__no">Exhibit {esc(no)}</span>'
239
+ f'<span class="exhibit__t">{esc(sec.get("title") or "")}</span></div>\n'
240
+ f'{chart_svg(ch, i)}'
241
+ f' <div class="exhibit__src">{cite(sec.get("footnote") or "来源:待补")}</div>\n'
242
+ ' </div>\n'
243
+ f' {pts_list(sec.get("points") or [["结论", "一句话。"]])}\n'
244
+ ' </div>\n')
245
+ sec = dict(sec)
246
+ sec.pop('footnote', None) # 已进来源行
247
+ return wrap(i, 'exhibit', body, sec)
248
+
249
+
250
+ def r_twocol(i, sec):
251
+ paras = sec.get('paragraphs') or []
252
+ cols = []
253
+ for p in paras:
254
+ if isinstance(p, (list, tuple)) and len(p) >= 2:
255
+ cols.append(f' <p class="t-body"><strong>{esc(p[0])}。</strong>{cite(p[1])}</p>')
256
+ else:
257
+ cols.append(f' <p class="t-body">{cite(p)}</p>')
258
+ body = ' <div class="cols-2 rv">\n' + '\n'.join(cols) + '\n </div>\n'
259
+ return wrap(i, 'twocol', body, sec)
260
+
261
+
262
+ def r_threecol(i, sec):
263
+ paras = sec.get('paragraphs') or []
264
+ cols = []
265
+ for p in paras[:6]:
266
+ if isinstance(p, (list, tuple)) and len(p) >= 2:
267
+ cols.append(f' <div class="card"><h3 class="t-h3">{esc(p[0])}</h3>'
268
+ f'<p class="t-body">{cite(p[1])}</p></div>')
269
+ else:
270
+ cols.append(f' <div class="card"><p class="t-body">{cite(p)}</p></div>')
271
+ # cols-3 供校验「模型页型 ↔ 版式组件」对齐(TYPE_FEATURE.threecol)
272
+ body = (' <div class="cols-3 grid g-3 rv a-start">\n' + '\n'.join(cols) + '\n </div>\n')
273
+ return wrap(i, 'threecol', body, sec)
274
+
275
+
276
+ def r_cards(i, sec):
277
+ cards = sec.get('cards') or []
278
+ n = int(sec.get('columns') or min(3, max(1, len(cards))))
279
+ cls = {1: 'g-2', 2: 'g-2', 3: 'g-3', 4: 'g-4'}.get(n, 'g-3')
280
+ blocks = []
281
+ for cd in cards:
282
+ title = cd.get('title') if isinstance(cd, dict) else (cd[0] if cd else '')
283
+ points = cd.get('points') if isinstance(cd, dict) else ([['', cd[1]]] if isinstance(cd, (list, tuple)) and len(cd) > 1 else [])
284
+ blocks.append(
285
+ f' <div class="card"><div class="card__hd"><h3 class="t-h3">{esc(title)}</h3></div>\n'
286
+ f' {pts_list(points)}</div>')
287
+ body = f' <div class="grid {cls} rv a-start">\n' + '\n'.join(blocks) + '\n </div>\n'
288
+ return wrap(i, 'cards', body, sec)
289
+
290
+
291
+ def r_split(i, sec):
292
+ def zone(el, side):
293
+ if not el:
294
+ return ' <div></div>'
295
+ t = (el.get('type') or ('points' if el.get('points') else 'bar'))
296
+ if t == 'table':
297
+ return (' <div class="tbl-wrap"><table><thead><tr>' +
298
+ ''.join(f'<th>{esc(h)}</th>' for h in (el.get('head') or [])) +
299
+ '</tr></thead><tbody>' +
300
+ ''.join('<tr>' + ''.join(f'<td>{cite(c)}</td>' for c in row) + '</tr>'
301
+ for row in (el.get('rows') or [])) +
302
+ '</tbody></table></div>')
303
+ if t == 'image':
304
+ im = el.get('image') or {}
305
+ cap = im.get('caption') or ''
306
+ return (' <figure class="media media--r4-3 media--ph">'
307
+ '<div class="media__ph"><b>配图占位</b><span>建议 1200×900px</span></div></figure>'
308
+ + (f'\n <div class="media__cap--below">{esc(cap)}</div>' if cap else ''))
309
+ if t == 'points' or el.get('points') and t not in ('bar', 'line', 'donut', 'hbar'):
310
+ return f' <div class="stack gap-4">{pts_list(el.get("points"))}</div>'
311
+ return (f' <div class="fig"><div class="fig__cap">{esc(el.get("cap") or "图表")}</div>\n'
312
+ f'{chart_svg(el, i)} </div>')
313
+ body = (' <div class="grid g-side rv a-start">\n'
314
+ f'{zone(sec.get("left"), "L")}\n{zone(sec.get("right"), "R")}\n'
315
+ ' </div>\n')
316
+ return wrap(i, 'split', body, sec)
317
+
318
+
319
+ def r_comparison(i, sec):
320
+ def panel(d, accent=False):
321
+ style = ' style="background:var(--accent-soft);border-color:transparent"' if accent else ''
322
+ tcol = ' style="color:var(--accent)"' if accent else ''
323
+ return (f' <div class="card"{style}><h3 class="t-h3"{tcol}>{esc((d or {}).get("title") or "")}</h3>\n'
324
+ f' {pts_list((d or {}).get("points"))}</div>')
325
+ body = (' <div class="grid g-half g-2 rv a-start">\n'
326
+ + panel(sec.get('left')) + '\n' + panel(sec.get('right'), True) + '\n'
327
+ + ' </div>\n')
328
+ if sec.get('verdict'):
329
+ body += (f' <div class="sowhat rv"><span class="sowhat__k">结论</span>'
330
+ f'<span class="sowhat__v">{cite(sec["verdict"])}</span></div>\n')
331
+ sec = dict(sec)
332
+ sec.pop('verdict', None)
333
+ return wrap(i, 'comparison', body, sec)
334
+
335
+
336
+ def r_quote(i, sec):
337
+ body = (
338
+ ' <div class="shead shead--center rv">\n'
339
+ f' <div style="font-size:clamp(40px,4.6vh,56px);font-weight:700;color:var(--accent)">「</div>\n'
340
+ f' <p class="t-lead" style="max-width:860px;margin-inline:auto;font-weight:600">{cite(sec.get("quote") or "")}</p>\n'
341
+ f' <p class="t-sm" style="color:var(--accent-text)">—— {esc(sec.get("author") or "")}'
342
+ + (f' · {esc(sec.get("context"))}' if sec.get('context') else '')
343
+ + '</p>\n </div>\n')
344
+ return wrap(i, 'quote', body, sec, ' band--accent band--fit')
345
+
346
+
347
+ def r_image(i, sec):
348
+ im = sec.get('image') or {}
349
+ layout = (im.get('layout') or 'full').lower()
350
+ ratio = {'half': 'r4-3', 'bleed': 'r21-9', 'grid': 'r4-3', 'compare': 'r4-3',
351
+ 'wall': 'r1-1', 'full': 'r3-1'}.get(layout, 'r3-1')
352
+ cap = im.get('caption') or ''
353
+ media = (f' <figure class="media media--{ratio} media--ph rv">'
354
+ f'<div class="media__ph"><b>配图占位</b><span>{esc(im.get("hint") or "建议尺寸见 imageSpec")}</span></div>'
355
+ f'</figure>\n')
356
+ if cap:
357
+ media += f' <p class="media__src">{cite(cap)}</p>\n'
358
+ body = media
359
+ if layout == 'half' and sec.get('points'):
360
+ body = (' <div class="grid g-side rv a-start">\n' + media +
361
+ f' <div class="stack gap-4">{pts_list(sec.get("points"))}</div>\n </div>\n')
362
+ return wrap(i, 'image', body, sec)
363
+
364
+
365
+ def r_diagram(i, sec):
366
+ layers = sec.get('layers') or []
367
+ rows = []
368
+ for li, lay in enumerate(layers):
369
+ name = lay[0] if lay else ''
370
+ nodes = lay[1] if len(lay) > 1 else []
371
+ focus = len(lay) > 2 and lay[2] == 'focus'
372
+ nds = []
373
+ for nd in nodes[:8]:
374
+ if isinstance(nd, dict):
375
+ nds.append(f' <div class="arch__node"><div class="arch__nt">{esc(nd.get("t") or "")}</div>'
376
+ f'<div class="arch__nd">{esc(nd.get("d") or "")}</div></div>')
377
+ else:
378
+ nds.append(f' <div class="arch__node"><div class="arch__nt">{esc(nd)}</div></div>')
379
+ rows.append(
380
+ f' <div class="arch__layer{" arch__layer--focus" if focus else ""}">\n'
381
+ f' <div class="arch__lname">{esc(name)}</div>\n'
382
+ f' <div class="arch__nodes">\n' + '\n'.join(nds) + '\n </div>\n </div>')
383
+ if li < len(layers) - 1:
384
+ rows.append(' <div class="arch__conn" aria-hidden="true">'
385
+ '<svg width="18" height="22" viewBox="0 0 18 22" fill="none" stroke="currentColor" '
386
+ 'stroke-width="2"><path d="M9 2v14"/><path d="M4 12l5 6 5-6"/></svg></div>')
387
+ legend = ''.join(f'<span class="chip">{esc(x)}</span>' for x in (sec.get('legend') or []))
388
+ body = (' <div class="arch rv">\n' + '\n'.join(rows) + '\n </div>\n'
389
+ + (f' <div class="arch__legend">{legend}</div>\n' if legend else ''))
390
+ return wrap(i, 'diagram', body, sec, ' band--fit')
391
+
392
+
393
+ def r_lane(i, sec):
394
+ lanes = sec.get('lanes') or []
395
+ rows = []
396
+ for ln in lanes:
397
+ hd = ln[0] if ln else ''
398
+ steps = ln[1] if len(ln) > 1 else []
399
+ parts = []
400
+ for si, st in enumerate(steps):
401
+ if isinstance(st, dict):
402
+ cls = 'lane__step lane__step--a' if st.get('accent') else 'lane__step'
403
+ parts.append(f'<span class="{cls}">{esc(st.get("t") or "")}</span>')
404
+ else:
405
+ parts.append(f'<span class="lane__step">{esc(st)}</span>')
406
+ if si < len(steps) - 1:
407
+ parts.append('<span class="lane__arr" aria-hidden="true"></span>')
408
+ rows.append(f' <div class="lane"><div class="lane__hd">{esc(hd)}</div>'
409
+ f'<div class="lane__body">' + ''.join(parts) + '</div></div>')
410
+ body = ' <div class="stack rv gap-3">\n' + '\n'.join(rows) + '\n </div>\n'
411
+ return wrap(i, 'lane', body, sec, ' band--fit')
412
+
413
+
414
+ def r_timeline(i, sec):
415
+ items = []
416
+ for ph in (sec.get('phases') or []):
417
+ if isinstance(ph, (list, tuple)):
418
+ lab, name, desc = (list(ph) + ['', '', ''])[:3]
419
+ state = ph[3] if len(ph) > 3 else ''
420
+ else:
421
+ lab, name, desc, state = ph.get('label', ''), ph.get('name', ''), ph.get('d', ''), ph.get('s', '')
422
+ cls = 'tl__i'
423
+ if state == 'done':
424
+ cls += ' tl__i--done'
425
+ elif state == 'now':
426
+ cls += ' tl__i--now'
427
+ items.append(
428
+ f' <div class="{cls}"><div class="tl__d"></div>'
429
+ f'<div class="tl__l">{esc(lab)}</div><div class="tl__t">{esc(name)}</div>'
430
+ f'<p class="t-body">{cite(desc)}</p></div>')
431
+ body = ' <div class="fig rv"><div class="tl">\n' + '\n'.join(items) + '\n </div></div>\n'
432
+ return wrap(i, 'timeline', body, sec)
433
+
434
+
435
+ def r_steps(i, sec):
436
+ items = []
437
+ for si, st in enumerate((sec.get('steps') or [])[:6]):
438
+ if isinstance(st, (list, tuple)):
439
+ t, d = (list(st) + ['', ''])[:2]
440
+ acc = False
441
+ else:
442
+ t, d, acc = st.get('t', ''), st.get('d', ''), bool(st.get('accent'))
443
+ cls = 'step step--a' if acc else 'step'
444
+ items.append(f' <div class="{cls}"><div class="step__n">{si + 1:02d}</div>'
445
+ f'<div class="step__t">{esc(t)}</div><div class="step__d">{cite(d)}</div></div>')
446
+ body = ' <div class="steps rv">\n' + '\n'.join(items) + '\n </div>\n'
447
+ return wrap(i, 'steps', body, sec)
448
+
449
+
450
+ def r_heatmap(i, sec):
451
+ rows, cols, cells = sec.get('rowHeads') or [], sec.get('colHeads') or [], sec.get('cells') or []
452
+ body = f' <div class="heat rv" style="--heat-cols:{max(1, len(cols))}"><div class="heat__grid">\n'
453
+ body += ' <div></div>' + ''.join(f'<div class="heat__h">{esc(c)}</div>' for c in cols) + '\n'
454
+ for ri, rh in enumerate(rows):
455
+ body += f' <div class="heat__rh">{esc(rh)}</div>'
456
+ row = cells[ri] if ri < len(cells) else []
457
+ for ci in range(len(cols)):
458
+ v = row[ci] if ci < len(row) else ''
459
+ body += f'<div class="heat__c">{esc(v)}</div>'
460
+ body += '\n'
461
+ body += ' </div></div>\n'
462
+ return wrap(i, 'heatmap', body, sec)
463
+
464
+
465
+ def r_bullet(i, sec):
466
+ items = sec.get('items') or []
467
+ rows = []
468
+ for it in items:
469
+ if isinstance(it, (list, tuple)):
470
+ k, a, t = (list(it) + ['', 0, 0])[:3]
471
+ else:
472
+ k, a, t = it.get('k', ''), it.get('a', 0), it.get('t', 0)
473
+ mx = float(sec.get('max') or 100) or 100
474
+ fw = max(0, min(100, float(a or 0) / mx * 100))
475
+ tw = max(0, min(100, float(t or 0) / mx * 100))
476
+ rows.append(
477
+ f' <div class="bul__row"><div class="bul__k">{esc(k)}</div>'
478
+ f'<div class="bul__track"><div class="bul__fill" style="width:{fw}%"></div>'
479
+ f'<div class="bul__tgt" style="left:{tw}%"></div></div>'
480
+ f'<div class="bul__v"><b>{esc(a)}</b> / {esc(t)}</div></div>')
481
+ body = ' <div class="bul rv">\n' + '\n'.join(rows) + '\n </div>\n'
482
+ return wrap(i, 'bullet', body, sec)
483
+
484
+
485
+ def r_pyramid(i, sec):
486
+ levels = sec.get('levels') or []
487
+ n = max(1, len(levels))
488
+ rows = []
489
+ for i2, lv in enumerate(levels):
490
+ if isinstance(lv, (list, tuple)):
491
+ t, d = (list(lv) + ['', ''])[:2]
492
+ else:
493
+ t, d = lv.get('t', ''), lv.get('d', '')
494
+ w = 40 + int(60 * (i2 + 1) / n)
495
+ rows.append(f' <div class="pyr__lvl" style="--w:{w}%">'
496
+ f'<div class="pyr__t">{esc(t)}</div><div class="pyr__d">{cite(d)}</div></div>')
497
+ body = ' <div class="pyr rv">\n' + '\n'.join(rows) + '\n </div>\n'
498
+ return wrap(i, 'pyramid', body, sec)
499
+
500
+
501
+ def r_halftable(i, sec):
502
+ """半表半图:g-half 左表右图(校验 TYPE_FEATURE.halftable)。"""
503
+ tbl = sec.get('table') or {}
504
+ ch = sec.get('chart') or {}
505
+ head = tbl.get('head') or []
506
+ rows = tbl.get('rows') or []
507
+ th = ''.join(f'<th>{esc(h)}</th>' for h in head)
508
+ trs = ''.join('<tr>' + ''.join(f'<td>{cite(c)}</td>' for c in row) + '</tr>' for row in rows)
509
+ body = (
510
+ ' <div class="grid g-half g-2 rv a-start">\n'
511
+ f' <div class="tbl-wrap"><table><thead><tr>{th}</tr></thead><tbody>{trs}</tbody></table></div>\n'
512
+ f' <div class="fig"><div class="fig__cap">{esc(ch.get("cap") or "互证图")}</div>\n'
513
+ f'{chart_svg(ch, i)} </div>\n'
514
+ ' </div>\n')
515
+ return wrap(i, 'halftable', body, sec)
516
+
517
+
518
+ def r_matrix(i, sec):
519
+ rows, cols, cells = sec.get('rowHeads') or [], sec.get('colHeads') or [], sec.get('cells') or []
520
+ body = f' <div class="matrix heat rv" style="--heat-cols:{max(1, len(cols))}"><div class="heat__grid">\n'
521
+ body += ' <div></div>' + ''.join(f'<div class="heat__h">{esc(c)}</div>' for c in cols) + '\n'
522
+ for ri, rh in enumerate(rows):
523
+ body += f' <div class="heat__rh">{esc(rh)}</div>'
524
+ row = cells[ri] if ri < len(cells) else []
525
+ for ci in range(len(cols)):
526
+ v = row[ci] if ci < len(row) else ''
527
+ if isinstance(v, dict):
528
+ v = v.get('t') or v.get('v') or ''
529
+ body += f'<div class="heat__c">{esc(v)}</div>'
530
+ body += '\n'
531
+ body += ' </div></div>\n'
532
+ return wrap(i, 'matrix', body, sec)
533
+
534
+
535
+ def r_info(i, sec):
536
+ t = sec.get('type') or 'sankey'
537
+ body = (f' <div class="fig rv"><div class="fig__cap">{esc(t)} 信息图</div>\n'
538
+ f' <svg class="chart" data-chart="{esc(t)}" viewBox="0 0 900 400">\n'
539
+ f' <text x="450" y="200" text-anchor="middle" class="f-txt3">{esc(t)} · 按 infographics 规格绘制</text>\n'
540
+ f' </svg>\n </div>\n')
541
+ return wrap(i, t, body, sec)
542
+
543
+
544
+ RENDERERS = {
545
+ 'points': r_points, 'metrics': r_metrics, 'kpi': r_kpi, 'table': r_table,
546
+ 'bar': r_bar, 'donut': r_donut, 'exhibit': r_exhibit, 'twocol': r_twocol,
547
+ 'threecol': r_threecol, 'cards': r_cards, 'split': r_split, 'comparison': r_comparison,
548
+ 'quote': r_quote, 'image': r_image, 'diagram': r_diagram, 'lane': r_lane,
549
+ 'timeline': r_timeline, 'steps': r_steps, 'heatmap': r_heatmap, 'bullet': r_bullet,
550
+ 'pyramid': r_pyramid, 'halftable': r_halftable, 'matrix': r_matrix,
551
+ 'sankey': r_info, 'treemap': r_info, 'boxplot': r_info, 'network': r_info,
552
+ 'marimekko': r_info, 'streamgraph': r_info,
553
+ }
554
+
555
+
556
+ def render_body(model: dict) -> str:
557
+ parts = []
558
+ # 封面
559
+ parts.append(f'''<!-- 封面 -->
560
+ <section class="band band--fit" id="cover">
561
+ <div class="wrap">
562
+ <div class="shead rv">
563
+ <div class="t-eyebrow">{esc(model.get("meta") or "")}</div>
564
+ <h1 class="t-display shead__title">{esc(model.get("title") or "")}</h1>
565
+ <p class="t-lead shead__desc">{esc(model.get("subtitle") or "")}</p>
566
+ </div>
567
+ </div>
568
+ </section>''')
569
+ agenda = model.get('agenda') or []
570
+ secs = [s for s in (model.get('sections') or []) if isinstance(s, dict)]
571
+ # Agenda 与 sections 对齐:模型 agenda 缺失/条数不符时以 sections 为准(并回写模型)
572
+ if len(agenda) != len(secs):
573
+ model['agenda'] = [[f'{i:02d}', s.get('title') or '', s.get('eyebrow') or '']
574
+ for i, s in enumerate(secs, 1)]
575
+ agenda = model['agenda']
576
+ if agenda or not (model.get('mode') == 'architecture' and len(secs) <= 4):
577
+ lis = []
578
+ for i, s in enumerate(secs, 1):
579
+ ag = agenda[i - 1] if i - 1 < len(agenda) else [f'{i:02d}', s.get('title') or '', s.get('eyebrow') or '']
580
+ num = ag[0] if isinstance(ag, (list, tuple)) and ag else f'{i:02d}'
581
+ title = ag[1] if isinstance(ag, (list, tuple)) and len(ag) > 1 else (s.get('title') or '')
582
+ desc = ag[2] if isinstance(ag, (list, tuple)) and len(ag) > 2 else (s.get('eyebrow') or '')
583
+ lis.append(
584
+ f' <li class="agenda__i"><a class="agenda__a" href="#s{i}">'
585
+ f'<span class="agenda__n">{esc(num)}</span>'
586
+ f'<span><span class="agenda__t">{esc(title)}</span>'
587
+ f'<div class="agenda__d">{esc(desc)}</div></span></a></li>')
588
+ parts.append(f'''<section class="band band--top" id="agenda">
589
+ <div class="wrap">
590
+ <div class="shead rv"><div class="t-eyebrow">目录</div>
591
+ <h2 class="t-h1 shead__title">报告大纲</h2></div>
592
+ <ol class="agenda rv">{''.join(lis)}</ol>
593
+ </div>
594
+ </section>''')
595
+ ex_n = 0
596
+ for i, sec in enumerate(secs, 1):
597
+ t = (sec.get('type') or 'points').lower()
598
+ is_research_exhibit = (model.get('mode') == 'research'
599
+ and t in ('exhibit', 'bar', 'halftable', 'donut'))
600
+ if is_research_exhibit:
601
+ ex_n += 1
602
+ sec = dict(sec)
603
+ sec['exhibitNo'] = ex_n
604
+ fn = RENDERERS.get(t, r_points)
605
+ if is_research_exhibit:
606
+ fn = r_exhibit
607
+ try:
608
+ parts.append(fn(i, sec))
609
+ except Exception as e:
610
+ parts.append(wrap(i, t, f' <p class="t-body">渲染失败 {esc(e)}</p>', sec))
611
+ closing = model.get('closing') or {}
612
+ cpts = closing.get('points') or []
613
+ body = ' <div class="grid g-3 rv a-start">\n' + pts_list(cpts) + '\n </div>\n'
614
+ parts.append(f'''<section class="band band--accent" id="closing">
615
+ <div class="wrap">
616
+ <div class="shead rv"><div class="t-eyebrow">下一步</div>
617
+ <h2 class="t-h1 shead__title">{esc(closing.get("title") or "收尾")}</h2></div>
618
+ {body} </div>
619
+ </section>''')
620
+ # 参考资料
621
+ refs = []
622
+ all_text = json.dumps(model, ensure_ascii=False)
623
+ for n in sorted({int(x) for x in re.findall(r'\[(\d+)\]', all_text)}):
624
+ refs.append(f' <li id="ref-{n}">[{n}] 来源名称,时间;口径。</li>')
625
+ parts.append(f'''<section class="band band--flow" id="refs">
626
+ <div class="wrap">
627
+ <div class="shead rv"><div class="t-eyebrow">附录</div>
628
+ <h2 class="t-h1 shead__title">参考资料</h2></div>
629
+ <ol class="refs">{''.join(refs) or '<li>(无外部引用)</li>'}</ol>
630
+ </div>
631
+ </section>''')
632
+ return '\n\n'.join(parts)
633
+
634
+
635
+ def load_model(path: Path) -> dict:
636
+ txt = path.read_text(encoding='utf-8')
637
+ if path.suffix.lower() == '.json':
638
+ return json.loads(txt)
639
+ m = re.search(r'window\.REPORT_MODEL\s*=\s*(\{[\s\S]*?\})\s*;', txt)
640
+ if not m:
641
+ raise SystemExit('未找到 window.REPORT_MODEL')
642
+ return json.loads(m.group(1))
643
+
644
+
645
+ def main() -> int:
646
+ ap = argparse.ArgumentParser(description='从 REPORT_MODEL 渲染 HTML 正文(模型驱动生成)')
647
+ ap.add_argument('src', help='model.json 或含 REPORT_MODEL 的 .html')
648
+ ap.add_argument('--template', help='模式模板名 presentation|research|architecture 或路径')
649
+ ap.add_argument('--out', help='输出 HTML')
650
+ ap.add_argument('--inplace', action='store_true', help='就地替换 src 的 CONTENT 区')
651
+ ap.add_argument('--body-only', action='store_true', help='只打印正文片段')
652
+ args = ap.parse_args()
653
+
654
+ src = Path(args.src)
655
+ model = load_model(src)
656
+ body = render_body(model)
657
+
658
+ if args.body_only:
659
+ sys.stdout.write(body)
660
+ return 0
661
+
662
+ if args.inplace:
663
+ t = src.read_text(encoding='utf-8')
664
+ if not CONTENT_RE.search(t):
665
+ print('错误:--inplace 需要 __TOPPPT_CONTENT__ 标记', file=sys.stderr)
666
+ return 2
667
+ t = CONTENT_RE.sub(
668
+ r'\1\n' + body.replace('\\', '\\\\') + r'\n\2', t, count=1)
669
+ # 同步模型(确保与渲染源一致)
670
+ t = MODEL_RE.sub('window.REPORT_MODEL = ' +
671
+ json.dumps(model, ensure_ascii=False, indent=2) + ';', t, count=1)
672
+ src.write_text(t, encoding='utf-8')
673
+ print(f'已按模型重渲染正文:{src}({len(body)} 字符)')
674
+ print(f' 下一步: python scripts/validate_report.py "{src}" --strict')
675
+ return 0
676
+
677
+ mode = model.get('mode') or 'presentation'
678
+ tpl = args.template or mode
679
+ tpl_path = Path(tpl) if Path(tpl).exists() else TPL / f'{tpl}.html'
680
+ if not tpl_path.exists():
681
+ print(f'错误:模板不存在 {tpl_path}', file=sys.stderr)
682
+ return 2
683
+ t = tpl_path.read_text(encoding='utf-8')
684
+ if not CONTENT_RE.search(t):
685
+ print('错误:模板缺少 CONTENT 标记', file=sys.stderr)
686
+ return 2
687
+ t = CONTENT_RE.sub(r'\1\n' + body.replace('\\', '\\\\') + r'\n\2', t, count=1)
688
+ t = MODEL_RE.sub('window.REPORT_MODEL = ' +
689
+ json.dumps(model, ensure_ascii=False, indent=2) + ';', t, count=1)
690
+ out = Path(args.out or (src.with_suffix('.html')))
691
+ out.write_text(t, encoding='utf-8')
692
+ print(f'已从模型生成:{out} sections={len(model.get("sections") or [])}')
693
+ print(f' 下一步: python scripts/validate_report.py "{out}" --strict')
694
+ return 0
695
+
696
+
697
+ if __name__ == '__main__':
698
+ sys.exit(main())