@topmindspace/tms-skills 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/bin/tms-skills.js +160 -0
- package/package.json +48 -0
- package/top-ppt-html/README.md +254 -0
- package/top-ppt-html/SKILL.md +111 -0
- package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.html +3926 -0
- package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.model.json +168 -0
- package/top-ppt-html/assets/examples/2026-09-09-architecture-spectrum.html +3926 -0
- package/top-ppt-html/assets/examples/2026-09-09-architecture-spectrum.model.json +168 -0
- package/top-ppt-html/assets/examples/2026-09-09-presentation-apple-mono.html +4325 -0
- package/top-ppt-html/assets/examples/2026-09-09-presentation-apple-mono.model.json +321 -0
- package/top-ppt-html/assets/examples/2026-09-09-presentation-brand-red.html +4325 -0
- package/top-ppt-html/assets/examples/2026-09-09-presentation-brand-red.model.json +321 -0
- package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.html +4325 -0
- package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.model.json +321 -0
- package/top-ppt-html/assets/examples/2026-09-09-research-deep-teal.html +5527 -0
- package/top-ppt-html/assets/examples/2026-09-09-research-deep-teal.model.json +914 -0
- package/top-ppt-html/assets/examples/2026-09-09-research-indigo-violet.html +5527 -0
- package/top-ppt-html/assets/examples/2026-09-09-research-indigo-violet.model.json +914 -0
- package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.html +5527 -0
- package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.model.json +914 -0
- package/top-ppt-html/assets/examples/2026-09-09-research-warm-sand.html +5527 -0
- package/top-ppt-html/assets/examples/2026-09-09-research-warm-sand.model.json +914 -0
- package/top-ppt-html/assets/pptx-export.js +1944 -0
- package/top-ppt-html/assets/style-gallery.html +589 -0
- package/top-ppt-html/assets/templates/architecture.html +3728 -0
- package/top-ppt-html/assets/templates/engine.css +840 -0
- package/top-ppt-html/assets/templates/presentation.html +3738 -0
- package/top-ppt-html/assets/templates/research.html +4017 -0
- package/top-ppt-html/assets/templates/ui.js +520 -0
- package/top-ppt-html/assets/theme-overview-architecture.png +0 -0
- package/top-ppt-html/assets/theme-overview-presentation.png +0 -0
- package/top-ppt-html/assets/theme-overview-research.png +0 -0
- package/top-ppt-html/assets/theme-overview.png +0 -0
- package/top-ppt-html/evals/prompts.csv +15 -0
- package/top-ppt-html/evals/rubric.schema.json +25 -0
- package/top-ppt-html/evals/run_evals.py +220 -0
- package/top-ppt-html/evals/trace.example.json +16 -0
- package/top-ppt-html/package.json +35 -0
- package/top-ppt-html/references/charts-basic.md +624 -0
- package/top-ppt-html/references/charts-discipline.md +108 -0
- package/top-ppt-html/references/charts-extended.md +482 -0
- package/top-ppt-html/references/charts.md +28 -0
- package/top-ppt-html/references/components-atoms.md +624 -0
- package/top-ppt-html/references/components.md +30 -0
- package/top-ppt-html/references/content-rules.md +490 -0
- package/top-ppt-html/references/design-system-engine.md +235 -0
- package/top-ppt-html/references/design-system.md +478 -0
- package/top-ppt-html/references/failure-modes.md +214 -0
- package/top-ppt-html/references/high-fidelity.md +127 -0
- package/top-ppt-html/references/icons.md +397 -0
- package/top-ppt-html/references/industry-benchmark.md +105 -0
- package/top-ppt-html/references/infographics-stats.md +308 -0
- package/top-ppt-html/references/infographics-structure.md +226 -0
- package/top-ppt-html/references/infographics.md +43 -0
- package/top-ppt-html/references/layout-grammar.md +315 -0
- package/top-ppt-html/references/layouts-architecture.md +108 -0
- package/top-ppt-html/references/layouts-combo.md +600 -0
- package/top-ppt-html/references/layouts-research.md +160 -0
- package/top-ppt-html/references/modes.md +254 -0
- package/top-ppt-html/references/outline-design.md +275 -0
- package/top-ppt-html/references/playbook.md +266 -0
- package/top-ppt-html/references/pptx-export.md +209 -0
- package/top-ppt-html/references/reform-plan.md +252 -0
- package/top-ppt-html/references/styles.md +370 -0
- package/top-ppt-html/references/tech-design.md +138 -0
- package/top-ppt-html/scripts/audit_css.py +109 -0
- package/top-ppt-html/scripts/audit_docs.py +176 -0
- package/top-ppt-html/scripts/audit_skill.py +220 -0
- package/top-ppt-html/scripts/audit_styles.py +351 -0
- package/top-ppt-html/scripts/build_examples.py +2276 -0
- package/top-ppt-html/scripts/build_pptx.js +2380 -0
- package/top-ppt-html/scripts/capture_theme_overview.js +78 -0
- package/top-ppt-html/scripts/checks_html.py +127 -0
- package/top-ppt-html/scripts/cross_verify.py +294 -0
- package/top-ppt-html/scripts/env_probe.py +158 -0
- package/top-ppt-html/scripts/extract_model.py +210 -0
- package/top-ppt-html/scripts/extract_snippet.py +374 -0
- package/top-ppt-html/scripts/gen_channel_a.js +214 -0
- package/top-ppt-html/scripts/layout-constants.json +3377 -0
- package/top-ppt-html/scripts/layout_slots.json +830 -0
- package/top-ppt-html/scripts/lib_layout_regions.js +412 -0
- package/top-ppt-html/scripts/measure_height.py +178 -0
- package/top-ppt-html/scripts/model-schema.json +547 -0
- package/top-ppt-html/scripts/negative_tests.py +307 -0
- package/top-ppt-html/scripts/package_skill.py +291 -0
- package/top-ppt-html/scripts/prepare_images.py +341 -0
- package/top-ppt-html/scripts/probe_image_export.py +188 -0
- package/top-ppt-html/scripts/quality_gate.py +301 -0
- package/top-ppt-html/scripts/regression.py +308 -0
- package/top-ppt-html/scripts/render_compare.py +275 -0
- package/top-ppt-html/scripts/render_from_model.py +698 -0
- package/top-ppt-html/scripts/scaffold_report.py +1054 -0
- package/top-ppt-html/scripts/section-file-map.json +105 -0
- package/top-ppt-html/scripts/sync_runtime.py +662 -0
- package/top-ppt-html/scripts/validate_pptx.py +1510 -0
- package/top-ppt-html/scripts/validate_report.py +1456 -0
|
@@ -0,0 +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('&', '&').replace('<', '<').replace('>', '>')
|
|
46
|
+
.replace('"', '"'))
|
|
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())
|