@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,1054 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""TopPPT HTML · 报告骨架生成器(零依赖 · 替代"整份复制模板")
|
|
4
|
+
|
|
5
|
+
用法:
|
|
6
|
+
python scripts/scaffold_report.py --mode research --style mckinsey --theme light \
|
|
7
|
+
--title "报告标题" --subtitle "副标题" --sections 8 --out 2026-09-15-主题.html
|
|
8
|
+
python scripts/scaffold_report.py --mode presentation --plan plan.json --out deck.html
|
|
9
|
+
python scripts/scaffold_report.py --mode architecture --list-types # 看可用页型
|
|
10
|
+
|
|
11
|
+
为什么存在它(第一性原理):
|
|
12
|
+
模式模板内联了引擎/UI/运行时,单份 ≈ 230KB。让智能体"复制模板"在实践中常变成"读模板"
|
|
13
|
+
→ 单次生成白烧 6 万 token 且极易丢标记块。**确定性的事交给脚本**:脚本读模板、锁风格与主题、
|
|
14
|
+
生成封面/Agenda/内容页/收尾/参考资料骨架,并**预生成 window.REPORT_MODEL**。
|
|
15
|
+
智能体此后只需做两件不确定的事:**填内容** + **按规划卡调版式组合**。
|
|
16
|
+
|
|
17
|
+
产出(单文件、零外链、可翻页、亮暗双主题):
|
|
18
|
+
· 全部 __TOPPPT_*__ 标记块原样保留(引擎/UI/运行时/常量/schema 注入位不被破坏)
|
|
19
|
+
· 封面 + Agenda(architecture 内容页 ≤4 时省略)+ N 个内容页 + 收尾 + 参考资料 + 页脚
|
|
20
|
+
· 每页按其页型给出**正确的锁定版式类名骨架**(§46 / §46b / §36d / §37–§38b / §71–§77)
|
|
21
|
+
· window.REPORT_MODEL 骨架与正文一一对应(严格 JSON,可被 extract_model.py 直接抽取)
|
|
22
|
+
|
|
23
|
+
plan.json 格式(可选;不给则按模式默认序列):
|
|
24
|
+
[{"type": "exhibit", "eyebrow": "01 · 现状诊断", "title": "结论句(行动标题)"}, ...]
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import argparse
|
|
29
|
+
import json
|
|
30
|
+
import re
|
|
31
|
+
import sys
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
36
|
+
except Exception:
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
40
|
+
TPL = ROOT / 'assets' / 'templates'
|
|
41
|
+
LC = json.loads((ROOT / 'scripts' / 'layout-constants.json').read_text(encoding='utf-8'))
|
|
42
|
+
|
|
43
|
+
CONTENT_RE = re.compile(r'<!-- __TOPPPT_CONTENT_START__ -->[\s\S]*?<!-- __TOPPPT_CONTENT_END__ -->')
|
|
44
|
+
MODEL_RE = re.compile(r'window\.REPORT_MODEL = \{[\s\S]*?\};')
|
|
45
|
+
NAV_RE = re.compile(r'<nav class="nav">[\s\S]*?</nav>')
|
|
46
|
+
BRAND_RE = re.compile(r'(<div class="brand__title">)[^<]*(</div>\s*<div class="brand__sub">)[^<]*(</div>)')
|
|
47
|
+
|
|
48
|
+
TPL_DEFAULT_STYLE = {'presentation': 'business-blue', 'research': 'mckinsey', 'architecture': 'graphite-dark'}
|
|
49
|
+
TPL_TITLE_TAG = {'presentation': '报告标题', 'research': '研究报告标题', 'architecture': '架构标题'}
|
|
50
|
+
TPL_BRAND = {
|
|
51
|
+
'presentation': ('主标题', '副标题 / 专题名'),
|
|
52
|
+
'research': ('报告标题', '研究报告 · 专题名'),
|
|
53
|
+
'architecture': ('架构标题', '方案架构 · 专题名'),
|
|
54
|
+
}
|
|
55
|
+
DEFAULT_PLAN = {
|
|
56
|
+
'presentation': ['points', 'bar', 'metrics', 'split', 'comparison', 'timeline', 'quote', 'steps'],
|
|
57
|
+
# research:图表页与论证页交替 + 结构/分布类页型穿插(同时满足图表多样性门禁与节奏规则)
|
|
58
|
+
'research': ['exhibit', 'twocol', 'halftable', 'metrics', 'threecol', 'table',
|
|
59
|
+
'matrix', 'exhibit', 'boxplot', 'twocol'],
|
|
60
|
+
'architecture': ['diagram', 'lane', 'diagram'],
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# 黄金节奏包:整篇页型序列预设(治「全篇 points/bar 公式化」;--preset 取用)
|
|
64
|
+
GOLDEN_PLANS = {
|
|
65
|
+
'pitch': {
|
|
66
|
+
'mode': 'presentation',
|
|
67
|
+
'title': '路演/发布节奏',
|
|
68
|
+
'types': ['metrics', 'bar', 'split', 'comparison', 'steps', 'timeline', 'kpi', 'points'],
|
|
69
|
+
},
|
|
70
|
+
'ops-review': {
|
|
71
|
+
'mode': 'presentation',
|
|
72
|
+
'title': '经营复盘节奏',
|
|
73
|
+
'types': ['kpi', 'metrics', 'bar', 'table', 'split', 'comparison', 'points', 'quote'],
|
|
74
|
+
},
|
|
75
|
+
'consulting': {
|
|
76
|
+
'mode': 'research',
|
|
77
|
+
'title': '咨询密排节奏',
|
|
78
|
+
'types': ['exhibit', 'twocol', 'halftable', 'metrics', 'threecol', 'matrix',
|
|
79
|
+
'exhibit', 'boxplot', 'twocol', 'table'],
|
|
80
|
+
},
|
|
81
|
+
'diagnostic': {
|
|
82
|
+
'mode': 'research',
|
|
83
|
+
'title': '诊断分析节奏',
|
|
84
|
+
'types': ['metrics', 'exhibit', 'twocol', 'halftable', 'exhibit', 'matrix',
|
|
85
|
+
'table', 'threecol', 'boxplot', 'twocol'],
|
|
86
|
+
},
|
|
87
|
+
'layered-arch': {
|
|
88
|
+
'mode': 'architecture',
|
|
89
|
+
'title': '分层架构节奏',
|
|
90
|
+
'types': ['diagram', 'lane', 'diagram'],
|
|
91
|
+
},
|
|
92
|
+
'flow-arch': {
|
|
93
|
+
'mode': 'architecture',
|
|
94
|
+
'title': '流程/数据流节奏',
|
|
95
|
+
'types': ['lane', 'diagram', 'steps'],
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
# 页型 → 布局骨架(layout-grammar P1–P12);生成时写入 data-skel,校验与填内容共用
|
|
99
|
+
PAGE_TO_PRESET = {
|
|
100
|
+
'points': 'P4', 'cards': 'P5', 'metrics': 'P2', 'kpi': 'P3', 'table': 'P8',
|
|
101
|
+
'bar': 'P8', 'donut': 'P3', 'exhibit': 'P8', 'twocol': 'P4', 'threecol': 'P5',
|
|
102
|
+
'halftable': 'P8', 'matrix': 'P7', 'heatmap': 'P7', 'bullet': 'P8', 'pyramid': 'P3',
|
|
103
|
+
'timeline': 'P2', 'steps': 'P2', 'comparison': 'P4', 'split': 'P1', 'quote': 'P3',
|
|
104
|
+
'image': 'P1', 'diagram': 'P9', 'lane': 'P9',
|
|
105
|
+
'sankey': 'P9', 'treemap': 'P9', 'boxplot': 'P9', 'network': 'P9',
|
|
106
|
+
'marimekko': 'P9', 'streamgraph': 'P9',
|
|
107
|
+
}
|
|
108
|
+
# 默认生成面优先核心图表(P1 收敛);高级类型按需 --chart 指定
|
|
109
|
+
CHART_CORE = ['bar', 'hbar', 'line', 'donut', 'progress', 'area', 'stack', 'dualline']
|
|
110
|
+
CHART_CYCLE = CHART_CORE # 轮换默认序列(骨架示范「图要选对」)
|
|
111
|
+
# 图表骨架尺寸:唯一事实源 = layout-constants.json charts.scaffold(v8.3 起禁止本文件硬编码尺寸;
|
|
112
|
+
# sync_runtime.py 校验 scaffold 默认值 ≥ charts.minSize 同口径阈值)
|
|
113
|
+
_SCAFFOLD = (LC.get('charts') or {}).get('scaffold') or {}
|
|
114
|
+
if not _SCAFFOLD.get('viewBoxHeight') or not _SCAFFOLD.get('infoTypes'):
|
|
115
|
+
# 启动自检:单源缺键时立刻失败,防「静默退回默认值 180」的窗口期
|
|
116
|
+
print('错误: layout-constants.json 缺 charts.scaffold.viewBoxHeight/infoTypes'
|
|
117
|
+
'(骨架尺寸单源不完整;补单源后先跑 sync_runtime.py)', file=sys.stderr)
|
|
118
|
+
sys.exit(2)
|
|
119
|
+
CHART_VIEWBOX = dict(_SCAFFOLD.get('viewBoxHeight') or {})
|
|
120
|
+
CHART_PX = dict(_SCAFFOLD.get('pxWidth') or {})
|
|
121
|
+
INFO_TYPES = {k: tuple(v) for k, v in (_SCAFFOLD.get('infoTypes') or {}).items()}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ── 小工具 ────────────────────────────────────────────────────────────────────
|
|
125
|
+
def esc(s: str) -> str:
|
|
126
|
+
return (s or '').replace('&', '&').replace('<', '<').replace('>', '>')
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def chart_svg(ct: str, idx: int) -> str:
|
|
130
|
+
"""最小合法图表骨架:data-chart 已登记 + viewBox 达标 + 指向代码节。"""
|
|
131
|
+
vb_h = CHART_VIEWBOX.get(ct, 180)
|
|
132
|
+
w = CHART_PX.get(ct)
|
|
133
|
+
style = f' style="width:{w}px;margin-inline:auto"' if w else ''
|
|
134
|
+
return (f' <svg class="chart" data-chart="{ct}" viewBox="0 0 560 {vb_h}"{style}>\n'
|
|
135
|
+
f' <!-- TODO 图表代码:charts.md(按 data-chart 类型取对应节);数据须与 REPORT_MODEL 一致 -->\n'
|
|
136
|
+
f' <text x="280" y="{vb_h // 2}" text-anchor="middle" class="f-txt3" font-size="12">'
|
|
137
|
+
f'{ct} 图表占位 · 替换为真实图形</text>\n </svg>')
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def shead(eyebrow: str, title: str, lead: str = '') -> str:
|
|
141
|
+
lead_html = f'\n <p class="t-lead shead__desc">{esc(lead)}</p>' if lead else ''
|
|
142
|
+
return (f' <div class="shead rv">\n'
|
|
143
|
+
f' <div class="t-eyebrow">{esc(eyebrow)}</div>\n'
|
|
144
|
+
f' <h2 class="t-h1 shead__title">{esc(title)}</h2>{lead_html}\n </div>')
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def icon() -> str:
|
|
148
|
+
return ('<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" '
|
|
149
|
+
'stroke-linecap="round" stroke-linejoin="round"><path d="M22 7l-8.5 8.5-5-5L2 17"/>'
|
|
150
|
+
'<path d="M16 7h6v6"/></svg>')
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ── 页型骨架(HTML 版式 ↔ PPTX 页型同源)──────────────────────────────────────
|
|
154
|
+
def open_section(i, ptype, extra=''):
|
|
155
|
+
"""内容页 section 开标签 + 布局骨架标记(layout-grammar data-skel)。"""
|
|
156
|
+
skel = PAGE_TO_PRESET.get(ptype, 'P4')
|
|
157
|
+
return f'<section class="band" id="s{i}" data-skel="{skel}" data-page-type="{ptype}"{extra}>'
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def pg_points(i, sec):
|
|
161
|
+
html = f'''{open_section(i, 'points')}
|
|
162
|
+
<div class="wrap">
|
|
163
|
+
{shead(sec['eyebrow'], sec['title'], sec.get('lead', ''))}
|
|
164
|
+
<div class="grid g-2 rv a-start">
|
|
165
|
+
<div class="card">
|
|
166
|
+
<div class="card__hd"><div class="card__ico">{icon()}</div><h3 class="t-h3">要点一</h3></div>
|
|
167
|
+
<ul class="ul"><li><strong>结论。</strong>一句话证据。</li><li><strong>结论。</strong>一句话证据。</li></ul>
|
|
168
|
+
</div>
|
|
169
|
+
<div class="card">
|
|
170
|
+
<div class="card__hd"><div class="card__ico">{icon()}</div><h3 class="t-h3">要点二</h3></div>
|
|
171
|
+
<ul class="ul"><li><strong>结论。</strong>一句话证据。</li><li><strong>结论。</strong>一句话证据。</li></ul>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
</section>'''
|
|
176
|
+
return html, {'type': 'points', 'points': [['要点一', '一句话证据。'], ['要点二', '一句话证据。']]}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def pg_cards(i, sec):
|
|
180
|
+
html = f'''<section class="band" id="s{i}">
|
|
181
|
+
<div class="wrap">
|
|
182
|
+
{shead(sec['eyebrow'], sec['title'], sec.get('lead', ''))}
|
|
183
|
+
<div class="grid g-3 rv">
|
|
184
|
+
<div class="card"><div class="card__hd"><div class="card__ico">{icon()}</div><h3 class="t-h3">卡片一</h3></div>
|
|
185
|
+
<p class="t-body">一句话说明。</p></div>
|
|
186
|
+
<div class="card"><div class="card__hd"><div class="card__ico">{icon()}</div><h3 class="t-h3">卡片二</h3></div>
|
|
187
|
+
<p class="t-body">一句话说明。</p></div>
|
|
188
|
+
<div class="card"><div class="card__hd"><div class="card__ico">{icon()}</div><h3 class="t-h3">卡片三</h3></div>
|
|
189
|
+
<p class="t-body">一句话说明。</p></div>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
</section>'''
|
|
193
|
+
# cards 契约:双引擎期望 {title, points[]}(见 build_pptx.js / pptx-export.js);
|
|
194
|
+
# 元组 [[k,v]] 会通过 schema 的 cards:array 却渲染空卡(v8.3 缺陷,v9 修复)。
|
|
195
|
+
return html, {'type': 'cards', 'columns': 3,
|
|
196
|
+
'cards': [
|
|
197
|
+
{'title': '卡片一', 'points': [['要点', '一句话说明。']]},
|
|
198
|
+
{'title': '卡片二', 'points': [['要点', '一句话说明。']]},
|
|
199
|
+
{'title': '卡片三', 'points': [['要点', '一句话说明。']]}]}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def pg_metrics(i, sec):
|
|
203
|
+
cards = ''.join(f'''
|
|
204
|
+
<div class="metric"><div class="metric__v t-metric">{n}<small>%</small></div>
|
|
205
|
+
<div class="metric__k">指标{n}</div><div class="metric__n">一行注解</div></div>'''
|
|
206
|
+
for n in (61, 18, 42, 76))
|
|
207
|
+
html = f'''<section class="band band--top" id="s{i}">
|
|
208
|
+
<div class="wrap">
|
|
209
|
+
{shead(sec['eyebrow'], sec['title'], sec.get('lead', ''))}
|
|
210
|
+
<div class="grid g-4 rv">{cards}
|
|
211
|
+
</div>
|
|
212
|
+
<div class="row row-wrap rv" style="margin-top:var(--sp-5)">
|
|
213
|
+
<div class="t-sm" style="color:var(--text-3)">口径:统计周期与样本说明<a class="cite" href="#ref-1">[1]</a></div>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
</section>'''
|
|
217
|
+
return html, {'type': 'metrics', 'metrics': [['61%', '指标1', '一行注解'], ['18%', '指标2', '一行注解'],
|
|
218
|
+
['42%', '指标3', '一行注解'], ['76%', '指标4', '一行注解']]}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def pg_kpi(i, sec):
|
|
222
|
+
html = f'''<section class="band" id="s{i}">
|
|
223
|
+
<div class="wrap">
|
|
224
|
+
{shead(sec['eyebrow'], sec['title'], sec.get('lead', ''))}
|
|
225
|
+
<div class="grid g-side rv" style="align-items:center">
|
|
226
|
+
<div class="stack gap-3">
|
|
227
|
+
<div class="t-metric" style="color:var(--accent);font-size:clamp(56px,7vh,76px)">61%</div>
|
|
228
|
+
<div class="t-h3">核心指标名</div>
|
|
229
|
+
<div class="t-sm" style="color:var(--accent);font-weight:600">▲ +6pt vs 上期</div>
|
|
230
|
+
</div>
|
|
231
|
+
<div class="grid g-3">
|
|
232
|
+
<div class="metric"><div class="metric__v t-metric">18<small>%</small></div>
|
|
233
|
+
<div class="metric__k">支撑指标</div><div class="metric__n">一行注解</div></div>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
</section>'''
|
|
238
|
+
return html, {'type': 'kpi', 'hero': ['61%', '核心指标名', '▲ +6pt vs 上期'],
|
|
239
|
+
'metrics': [['18%', '支撑指标', '一行注解']]}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def pg_table(i, sec):
|
|
243
|
+
rows = ''.join(f'\n <tr><td class="k">对象 {n}</td><td class="num">{n * 7}%</td>'
|
|
244
|
+
f'<td><strong>结论</strong></td></tr>' for n in (1, 2, 3, 4))
|
|
245
|
+
html = f'''<section class="band" id="s{i}">
|
|
246
|
+
<div class="wrap">
|
|
247
|
+
{shead(sec['eyebrow'], sec['title'], sec.get('lead', ''))}
|
|
248
|
+
<div class="tbl-wrap rv">
|
|
249
|
+
<table>
|
|
250
|
+
<thead><tr><th style="width:26%">维度</th><th style="width:24%">数值</th><th>结论</th></tr></thead>
|
|
251
|
+
<tbody>{rows}
|
|
252
|
+
</tbody>
|
|
253
|
+
</table>
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
</section>'''
|
|
257
|
+
return html, {'type': 'table',
|
|
258
|
+
'table': {'head': ['维度', '数值', '结论'],
|
|
259
|
+
'rows': [[f'对象 {n}', f'{n * 7}%', '结论'] for n in (1, 2, 3, 4)]}}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
SIDE_NOTES = ''' <div class="stack gap-4">
|
|
263
|
+
<h3 class="t-h3">怎么读这张图</h3>
|
|
264
|
+
<ul class="ul">
|
|
265
|
+
<li><strong>结论一。</strong>一句话(≤45 字)。</li>
|
|
266
|
+
<li><strong>结论二。</strong>一句话(≤45 字)。</li>
|
|
267
|
+
<li><strong>结论三。</strong>一句话(≤45 字)。</li>
|
|
268
|
+
</ul>
|
|
269
|
+
</div>'''
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def pg_chart(i, sec):
|
|
273
|
+
"""P8 主区+注解列:主图 + 侧栏要点 + so-what(禁「一张图+标题」独页)。"""
|
|
274
|
+
ct = sec.get('chart', 'hbar')
|
|
275
|
+
html = f'''{open_section(i, 'bar')}
|
|
276
|
+
<div class="wrap">
|
|
277
|
+
{shead(sec['eyebrow'], sec['title'], sec.get('lead', ''))}
|
|
278
|
+
<div class="grid g-side rv a-start">
|
|
279
|
+
<div class="fig">
|
|
280
|
+
<div class="fig__cap">图表标题(写结论)</div>
|
|
281
|
+
{chart_svg(ct, i)}
|
|
282
|
+
</div>
|
|
283
|
+
{SIDE_NOTES}
|
|
284
|
+
</div>
|
|
285
|
+
<div class="sowhat rv"><span class="sowhat__k">So what</span>
|
|
286
|
+
<span class="sowhat__v">一行含义(≤60 字)。</span></div>
|
|
287
|
+
</div>
|
|
288
|
+
</section>'''
|
|
289
|
+
return html, {'type': 'bar', 'chart': {'type': ct, 'labels': ['A', 'B', 'C'], 'values': [42, 61, 35],
|
|
290
|
+
'dataTable': 'notes'},
|
|
291
|
+
'soWhat': '一行含义(≤60 字)。'}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def pg_donut(i, sec):
|
|
295
|
+
"""P3/V3:简单占比配侧栏图例说明,禁止弧上叠标签。"""
|
|
296
|
+
html = f'''{open_section(i, 'donut')}
|
|
297
|
+
<div class="wrap">
|
|
298
|
+
{shead(sec['eyebrow'], sec['title'], sec.get('lead', ''))}
|
|
299
|
+
<div class="grid g-side rv a-start">
|
|
300
|
+
<div class="fig rv" style="text-align:center">
|
|
301
|
+
<div class="fig__cap">构成占比</div>
|
|
302
|
+
{chart_svg('donut', i)}
|
|
303
|
+
</div>
|
|
304
|
+
{SIDE_NOTES}
|
|
305
|
+
</div>
|
|
306
|
+
<div class="sowhat rv"><span class="sowhat__k">So what</span>
|
|
307
|
+
<span class="sowhat__v">一行含义(≤60 字)。</span></div>
|
|
308
|
+
</div>
|
|
309
|
+
</section>'''
|
|
310
|
+
return html, {'type': 'donut', 'chart': {'labels': ['A', 'B', 'C'], 'values': [45, 35, 20],
|
|
311
|
+
'dataTable': 'notes'},
|
|
312
|
+
'soWhat': '一行含义(≤60 字)。'}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def pg_exhibit(i, sec):
|
|
316
|
+
ct = sec.get('chart', 'bar')
|
|
317
|
+
html = f'''{open_section(i, 'exhibit')}
|
|
318
|
+
<div class="wrap">
|
|
319
|
+
{shead(sec['eyebrow'], sec['title'], sec.get('lead', ''))}
|
|
320
|
+
<div class="grid g-side rv a-start">
|
|
321
|
+
<div class="exhibit rv">
|
|
322
|
+
<div class="exhibit__hd"><span class="exhibit__no">Exhibit {sec.get('exhibitNo', 1)}</span>
|
|
323
|
+
<span class="exhibit__t">图表标题(结论式)</span></div>
|
|
324
|
+
{chart_svg(ct, i)}
|
|
325
|
+
<div class="exhibit__src">来源:来源名称,YYYY-MM;口径说明<a class="cite" href="#ref-1">[1]</a></div>
|
|
326
|
+
</div>
|
|
327
|
+
{SIDE_NOTES}
|
|
328
|
+
</div>
|
|
329
|
+
<div class="sowhat rv">
|
|
330
|
+
<span class="sowhat__k">So what</span>
|
|
331
|
+
<span class="sowhat__v">一行含义或建议(≤60 字)。</span>
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
</section>'''
|
|
335
|
+
return html, {'type': 'exhibit', 'exhibitNo': sec.get('exhibitNo', 1),
|
|
336
|
+
'chart': {'type': ct, 'labels': ['A', 'B', 'C'], 'values': [42, 61, 35],
|
|
337
|
+
'dataTable': 'notes'},
|
|
338
|
+
'soWhat': '一行含义或建议(≤60 字)。',
|
|
339
|
+
'footnote': '来源:来源名称,YYYY-MM;口径说明'}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def pg_twocol(i, sec):
|
|
343
|
+
html = f'''<section class="band" id="s{i}">
|
|
344
|
+
<div class="wrap">
|
|
345
|
+
{shead(sec['eyebrow'], sec['title'], sec.get('lead', ''))}
|
|
346
|
+
<div class="cols-2 rv">
|
|
347
|
+
<p class="t-body"><strong>发现。</strong>直接陈述数据与事实,≤200 字。</p>
|
|
348
|
+
<p class="t-body"><strong>证据。</strong>引用口径与对照数据,外部数据带 [n]。</p>
|
|
349
|
+
<p class="t-body"><strong>含义。</strong>这一发现意味着什么、下一步动作。</p>
|
|
350
|
+
</div>
|
|
351
|
+
<div class="footnote">注:口径与样本说明一行。</div>
|
|
352
|
+
</div>
|
|
353
|
+
</section>'''
|
|
354
|
+
return html, {'type': 'twocol', 'paragraphs': [['发现', '直接陈述数据与事实,≤200 字。'],
|
|
355
|
+
['证据', '引用口径与对照数据。'],
|
|
356
|
+
['含义', '这一发现意味着什么、下一步动作。']]}
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def pg_threecol(i, sec):
|
|
360
|
+
html = f'''<section class="band" id="s{i}">
|
|
361
|
+
<div class="wrap">
|
|
362
|
+
{shead(sec['eyebrow'], sec['title'], sec.get('lead', ''))}
|
|
363
|
+
<div class="cols-3 rv">
|
|
364
|
+
<p class="t-body"><strong>论点一。</strong>第一栏论述,每栏 ≤150 字。</p>
|
|
365
|
+
<p class="t-body"><strong>论点二。</strong>第二栏论述,与第一栏并列推进。</p>
|
|
366
|
+
<p class="t-body"><strong>论点三。</strong>第三栏论述,收束到本页结论。</p>
|
|
367
|
+
</div>
|
|
368
|
+
<div class="footnote">注:三方口径与样本说明一行。</div>
|
|
369
|
+
</div>
|
|
370
|
+
</section>'''
|
|
371
|
+
return html, {'type': 'threecol', 'paragraphs': [['论点一', '第一栏论述,每栏 ≤150 字。'],
|
|
372
|
+
['论点二', '第二栏论述,与第一栏并列推进。'],
|
|
373
|
+
['论点三', '第三栏论述,收束到本页结论。']]}
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def pg_halftable(i, sec):
|
|
377
|
+
ct = sec.get('chart', 'hbar')
|
|
378
|
+
rows = ''.join(f'\n <tr><td class="k">对象 {n}</td><td class="num">{n * 7}%</td>'
|
|
379
|
+
f'<td><strong>+{n}pt</strong></td></tr>' for n in (1, 2, 3))
|
|
380
|
+
html = f'''<section class="band" id="s{i}">
|
|
381
|
+
<div class="wrap">
|
|
382
|
+
{shead(sec['eyebrow'], sec['title'], sec.get('lead', ''))}
|
|
383
|
+
<div class="grid g-half rv" style="align-items:start">
|
|
384
|
+
<div class="tbl-wrap">
|
|
385
|
+
<table>
|
|
386
|
+
<thead><tr><th style="width:30%">对象</th><th>数值</th><th>变化</th></tr></thead>
|
|
387
|
+
<tbody>{rows}
|
|
388
|
+
</tbody>
|
|
389
|
+
</table>
|
|
390
|
+
</div>
|
|
391
|
+
<div class="exhibit">
|
|
392
|
+
<div class="exhibit__hd"><span class="exhibit__no">Exhibit {sec.get('exhibitNo', 1)}</span>
|
|
393
|
+
<span class="exhibit__t">右栏图表标题(结论式)</span></div>
|
|
394
|
+
{chart_svg(ct, i)}
|
|
395
|
+
<div class="exhibit__src">来源:来源名称,YYYY-MM;口径说明<a class="cite" href="#ref-1">[1]</a></div>
|
|
396
|
+
</div>
|
|
397
|
+
</div>
|
|
398
|
+
</div>
|
|
399
|
+
</section>'''
|
|
400
|
+
return html, {'type': 'halftable', 'exhibitNo': sec.get('exhibitNo', 1),
|
|
401
|
+
'table': {'head': ['对象', '数值', '变化'],
|
|
402
|
+
'rows': [[f'对象 {n}', f'{n * 7}%', f'+{n}pt'] for n in (1, 2, 3)]},
|
|
403
|
+
'chart': {'type': ct, 'labels': ['A', 'B', 'C'], 'values': [42, 61, 35],
|
|
404
|
+
'dataTable': 'notes'}}
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def pg_matrix(i, sec):
|
|
408
|
+
html = f'''<section class="band" id="s{i}">
|
|
409
|
+
<div class="wrap">
|
|
410
|
+
{shead(sec['eyebrow'], sec['title'], sec.get('lead', ''))}
|
|
411
|
+
<div class="matrix rv">
|
|
412
|
+
<div class="matrix__grid" style="--mx-cols:3">
|
|
413
|
+
<div></div>
|
|
414
|
+
<div class="matrix__h">列一 · 低</div><div class="matrix__h">列二 · 中</div><div class="matrix__h">列三 · 高</div>
|
|
415
|
+
<div class="matrix__rh">行一 · 高</div>
|
|
416
|
+
<div class="matrix__c matrix__c--a"><b>单元</b>说明</div>
|
|
417
|
+
<div class="matrix__c"><b>单元</b>说明</div>
|
|
418
|
+
<div class="matrix__c"><b>单元</b>说明</div>
|
|
419
|
+
<div class="matrix__rh">行二 · 中</div>
|
|
420
|
+
<div class="matrix__c"><b>单元</b>说明</div>
|
|
421
|
+
<div class="matrix__c"><b>单元</b>说明</div>
|
|
422
|
+
<div class="matrix__c matrix__c--a"><b>单元</b>说明</div>
|
|
423
|
+
</div>
|
|
424
|
+
<div class="matrix__note">图例:强调单元 = 优先投入项。</div>
|
|
425
|
+
</div>
|
|
426
|
+
</div>
|
|
427
|
+
</section>'''
|
|
428
|
+
return html, {'type': 'matrix', 'rowHeads': ['行一 · 高', '行二 · 中'],
|
|
429
|
+
'colHeads': ['列一 · 低', '列二 · 中', '列三 · 高'],
|
|
430
|
+
'cells': [['单元 说明', '单元 说明', '单元 说明'],
|
|
431
|
+
['单元 说明', '单元 说明', {'t': '单元 说明', 'accent': True}]]}
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def pg_heatmap(i, sec):
|
|
435
|
+
html = f'''<section class="band" id="s{i}">
|
|
436
|
+
<div class="wrap">
|
|
437
|
+
{shead(sec['eyebrow'], sec['title'], sec.get('lead', ''))}
|
|
438
|
+
<div class="heat rv">
|
|
439
|
+
<div class="heat__grid" style="--heat-cols:4;--heat-label:132px">
|
|
440
|
+
<div></div>
|
|
441
|
+
<div class="heat__h">列一</div><div class="heat__h">列二</div><div class="heat__h">列三</div><div class="heat__h">列四</div>
|
|
442
|
+
<div class="heat__rh">行一</div>
|
|
443
|
+
<div class="heat__c heat__c--3">78<small>高</small></div>
|
|
444
|
+
<div class="heat__c heat__c--1">46<small>中</small></div>
|
|
445
|
+
<div class="heat__c heat__c--2">63<small>中高</small></div>
|
|
446
|
+
<div class="heat__c">38<small>低</small></div>
|
|
447
|
+
</div>
|
|
448
|
+
<div class="heat__legend"><span>就绪度</span>
|
|
449
|
+
<span class="heat__sw" style="background:var(--surface-1)"></span>
|
|
450
|
+
<span class="heat__sw" style="background:var(--accent-soft)"></span>
|
|
451
|
+
<span class="heat__sw" style="background:var(--accent-soft-2)"></span>
|
|
452
|
+
<span class="heat__sw" style="background:var(--accent)"></span>
|
|
453
|
+
<span>低 → 高(单位:分)</span></div>
|
|
454
|
+
</div>
|
|
455
|
+
<div class="footnote">口径:评分来源与样本说明。</div>
|
|
456
|
+
</div>
|
|
457
|
+
</section>'''
|
|
458
|
+
return html, {'type': 'heatmap', 'rowHeads': ['行一'], 'colHeads': ['列一', '列二', '列三', '列四'],
|
|
459
|
+
'cells': [[78, 46, 63, 38]], 'unit': '分',
|
|
460
|
+
'scaleLabel': ['低', '中', '中高', '高']}
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def pg_bullet(i, sec):
|
|
464
|
+
html = f'''<section class="band" id="s{i}">
|
|
465
|
+
<div class="wrap">
|
|
466
|
+
{shead(sec['eyebrow'], sec['title'], sec.get('lead', ''))}
|
|
467
|
+
<div class="bul rv" style="--bul-label:minmax(0,2.4fr)">
|
|
468
|
+
<div class="bul__row"><div class="bul__k">场景覆盖</div>
|
|
469
|
+
<div class="bul__track"><div class="bul__fill" style="width:61%"></div>
|
|
470
|
+
<div class="bul__tgt" style="left:80%"></div></div>
|
|
471
|
+
<div class="bul__v"><b>61%</b> / 80%</div></div>
|
|
472
|
+
<div class="bul__row bul__row--warn"><div class="bul__k">治理就绪</div>
|
|
473
|
+
<div class="bul__track"><div class="bul__fill" style="width:34%"></div>
|
|
474
|
+
<div class="bul__tgt" style="left:75%"></div></div>
|
|
475
|
+
<div class="bul__v"><b>34%</b> / 75%</div></div>
|
|
476
|
+
</div>
|
|
477
|
+
</div>
|
|
478
|
+
</section>'''
|
|
479
|
+
return html, {'type': 'bullet', 'unit': '%', 'max': 100,
|
|
480
|
+
'items': [['场景覆盖', 61, 80], ['治理就绪', 34, 75]]}
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def pg_pyramid(i, sec):
|
|
484
|
+
html = f'''<section class="band" id="s{i}">
|
|
485
|
+
<div class="wrap">
|
|
486
|
+
{shead(sec['eyebrow'], sec['title'], sec.get('lead', ''))}
|
|
487
|
+
<div class="pyr rv">
|
|
488
|
+
<div class="pyr__lvl" style="--w:42%"><div class="pyr__t">顶层</div><div class="pyr__d">说明 ≤24 字</div></div>
|
|
489
|
+
<div class="pyr__lvl pyr__lvl--a" style="--w:72%"><div class="pyr__t">中层</div><div class="pyr__d">说明 ≤24 字</div></div>
|
|
490
|
+
<div class="pyr__lvl" style="--w:100%"><div class="pyr__t">底层</div><div class="pyr__d">说明 ≤24 字</div></div>
|
|
491
|
+
</div>
|
|
492
|
+
</div>
|
|
493
|
+
</section>'''
|
|
494
|
+
return html, {'type': 'pyramid', 'levels': [['顶层', '说明'], ['中层', '说明'], ['底层', '说明']]}
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def pg_timeline(i, sec):
|
|
498
|
+
html = f'''<section class="band" id="s{i}">
|
|
499
|
+
<div class="wrap">
|
|
500
|
+
{shead(sec['eyebrow'], sec['title'], sec.get('lead', ''))}
|
|
501
|
+
<div class="fig rv" style="padding:clamp(24px,2.4vw,36px)">
|
|
502
|
+
<div class="tl">
|
|
503
|
+
<div class="tl__i tl__i--done"><div class="tl__d"></div>
|
|
504
|
+
<div class="tl__l">2026 Q3</div><div class="tl__t">阶段一</div>
|
|
505
|
+
<p class="t-body">≤2 行说明。</p></div>
|
|
506
|
+
<div class="tl__i tl__i--now"><div class="tl__d"></div>
|
|
507
|
+
<div class="tl__l">2026 Q4</div><div class="tl__t">阶段二</div>
|
|
508
|
+
<p class="t-body">≤2 行说明。</p></div>
|
|
509
|
+
<div class="tl__i"><div class="tl__d"></div>
|
|
510
|
+
<div class="tl__l">2027 Q1</div><div class="tl__t">阶段三</div>
|
|
511
|
+
<p class="t-body">≤2 行说明。</p></div>
|
|
512
|
+
</div>
|
|
513
|
+
</div>
|
|
514
|
+
</div>
|
|
515
|
+
</section>'''
|
|
516
|
+
return html, {'type': 'timeline', 'phases': [['2026 Q3', '阶段一', '≤2 行说明。'],
|
|
517
|
+
['2026 Q4', '阶段二', '≤2 行说明。'],
|
|
518
|
+
['2027 Q1', '阶段三', '≤2 行说明。']]}
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def pg_steps(i, sec):
|
|
522
|
+
html = f'''{open_section(i, 'steps')}
|
|
523
|
+
<div class="wrap">
|
|
524
|
+
{shead(sec['eyebrow'], sec['title'], sec.get('lead', ''))}
|
|
525
|
+
<div class="steps__phase rv">第一阶段 · 打基础</div>
|
|
526
|
+
<div class="steps rv">
|
|
527
|
+
<div class="step"><div class="step__n">01</div><div class="step__t">步骤一</div>
|
|
528
|
+
<div class="step__d">动作说明 ≤30 字</div></div>
|
|
529
|
+
<div class="step step--a"><div class="step__n">02</div><div class="step__t">步骤二</div>
|
|
530
|
+
<div class="step__d">关键步高亮(≤2 处)</div></div>
|
|
531
|
+
<div class="step"><div class="step__n">03</div><div class="step__t">步骤三</div>
|
|
532
|
+
<div class="step__d">动作说明 ≤30 字</div></div>
|
|
533
|
+
</div>
|
|
534
|
+
<div class="flow-rail rv">
|
|
535
|
+
<div class="flow-box flow-box--end">开始</div>
|
|
536
|
+
<span class="lane__arr" aria-hidden="true"></span>
|
|
537
|
+
<div class="flow-dia"><svg viewBox="0 0 72 72" aria-hidden="true"><polygon points="36,4 68,36 36,68 4,36" class="f-none s-acc" stroke-width="2"/></svg><span>判断</span></div>
|
|
538
|
+
<span class="lane__arr" aria-hidden="true"></span>
|
|
539
|
+
<div class="flow-box flow-box--a">关键步</div>
|
|
540
|
+
<span class="lane__arr" aria-hidden="true"></span>
|
|
541
|
+
<div class="flow-box flow-box--ex">异常</div>
|
|
542
|
+
</div>
|
|
543
|
+
</div>
|
|
544
|
+
</section>'''
|
|
545
|
+
return html, {'type': 'steps', 'steps': [['步骤一', '动作说明'], ['步骤二', '关键步'], ['步骤三', '动作说明']]}
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def pg_comparison(i, sec):
|
|
549
|
+
html = f'''<section class="band" id="s{i}">
|
|
550
|
+
<div class="wrap">
|
|
551
|
+
{shead(sec['eyebrow'], sec['title'], sec.get('lead', ''))}
|
|
552
|
+
<div class="grid g-half rv">
|
|
553
|
+
<div class="card"><h3 class="t-h3">现状</h3>
|
|
554
|
+
<ul class="ul"><li><strong>要点。</strong>一句话。</li><li><strong>要点。</strong>一句话。</li></ul></div>
|
|
555
|
+
<div class="card" style="background:var(--accent-soft);border-color:transparent">
|
|
556
|
+
<h3 class="t-h3" style="color:var(--accent)">目标</h3>
|
|
557
|
+
<ul class="ul"><li><strong>要点。</strong>一句话。</li><li><strong>要点。</strong>一句话。</li></ul></div>
|
|
558
|
+
</div>
|
|
559
|
+
<div class="sowhat rv"><span class="sowhat__k">结论</span>
|
|
560
|
+
<span class="sowhat__v">一行结论(≤60 字)。</span></div>
|
|
561
|
+
</div>
|
|
562
|
+
</section>'''
|
|
563
|
+
return html, {'type': 'comparison',
|
|
564
|
+
'left': {'title': '现状', 'points': [['要点', '一句话。'], ['要点', '一句话。']]},
|
|
565
|
+
'right': {'title': '目标', 'points': [['要点', '一句话。'], ['要点', '一句话。']]},
|
|
566
|
+
'verdict': '一行结论(≤60 字)。'}
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def pg_split(i, sec):
|
|
570
|
+
"""双区组合页:默认「左文右图」;规划卡写 left_type='chart' 时「左图右文」(走 split 左区图表路径)。"""
|
|
571
|
+
ct = sec.get('chart', 'bar')
|
|
572
|
+
left_is_chart = (sec.get('left_type') == 'chart')
|
|
573
|
+
fig = f''' <div class="fig">
|
|
574
|
+
<div class="fig__cap">图表标题</div>
|
|
575
|
+
{chart_svg(ct, i)}
|
|
576
|
+
</div>'''
|
|
577
|
+
pts = ''' <div class="stack gap-5">
|
|
578
|
+
<h3 class="t-h3">怎么读这张图</h3>
|
|
579
|
+
<ul class="ul">
|
|
580
|
+
<li><strong>结论一。</strong>一句话。</li>
|
|
581
|
+
<li><strong>结论二。</strong>一句话。</li>
|
|
582
|
+
<li><strong>结论三。</strong>一句话。</li>
|
|
583
|
+
</ul>
|
|
584
|
+
<div class="note"><i class="note__ico">i</i><div><p class="t-body">可选强调。</p></div></div>
|
|
585
|
+
</div>'''
|
|
586
|
+
left, right = (fig, pts) if left_is_chart else (pts, fig)
|
|
587
|
+
html = f'''<section class="band" id="s{i}">
|
|
588
|
+
<div class="wrap">
|
|
589
|
+
{shead(sec['eyebrow'], sec['title'], sec.get('lead', ''))}
|
|
590
|
+
<div class="grid g-side rv" style="align-items:start">
|
|
591
|
+
{left}
|
|
592
|
+
{right}
|
|
593
|
+
</div>
|
|
594
|
+
</div>
|
|
595
|
+
</section>'''
|
|
596
|
+
m_pts = {'points': [['结论一', '一句话。'], ['结论二', '一句话。'], ['结论三', '一句话。']]}
|
|
597
|
+
m_chart = {'type': ct, 'cap': '图表标题', 'labels': ['A', 'B', 'C'], 'values': [42, 61, 35],
|
|
598
|
+
'dataTable': 'notes'}
|
|
599
|
+
return html, {'type': 'split',
|
|
600
|
+
'left': m_chart if left_is_chart else m_pts,
|
|
601
|
+
'right': m_pts if left_is_chart else m_chart}
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def pg_quote(i, sec):
|
|
605
|
+
html = f'''<section class="band band--accent--solid band--fit" id="s{i}">
|
|
606
|
+
<div class="wrap">
|
|
607
|
+
<div class="shead shead--center rv">
|
|
608
|
+
<div class="t-eyebrow">{esc(sec['eyebrow'])}</div>
|
|
609
|
+
<h2 class="t-h1 shead__title">{esc(sec['title'])}</h2>
|
|
610
|
+
<p class="t-lead" style="max-width:860px;margin-inline:auto;font-weight:600">一句金句(≤40 字)。</p>
|
|
611
|
+
<p class="t-sm" style="margin-top:var(--sp-4)">—— 出处 / 样本</p>
|
|
612
|
+
</div>
|
|
613
|
+
</div>
|
|
614
|
+
</section>'''
|
|
615
|
+
return html, {'type': 'quote', 'quote': '一句金句(≤40 字)。', 'author': '出处 / 样本'}
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def pg_image(i, sec):
|
|
619
|
+
html = f'''<section class="band" id="s{i}">
|
|
620
|
+
<div class="wrap">
|
|
621
|
+
{shead(sec['eyebrow'], sec['title'], sec.get('lead', ''))}
|
|
622
|
+
<figure class="media media--r3-1 media--ph rv">
|
|
623
|
+
<div class="media__ph">
|
|
624
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/>
|
|
625
|
+
<circle cx="9" cy="10" r="1.6"/><path d="m3 18 5-5 4 3.4 3-2.6 6 5.2"/></svg>
|
|
626
|
+
<b>配图占位</b><span>建议 2400×800px · 3:1 · 替换 image.src 即可</span>
|
|
627
|
+
</div>
|
|
628
|
+
</figure>
|
|
629
|
+
<p class="media__src">图 1:图注(占位;交付前替换为真实素材)</p>
|
|
630
|
+
</div>
|
|
631
|
+
</section>'''
|
|
632
|
+
return html, {'type': 'image', 'image': {'placeholder': True, 'layout': 'full',
|
|
633
|
+
'caption': '图 1:图注', 'hint': '建议 2400×800px'}}
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def pg_diagram(i, sec):
|
|
637
|
+
conn = ('<div class="arch__conn" aria-hidden="true">'
|
|
638
|
+
'<svg width="18" height="22" viewBox="0 0 18 22" fill="none" stroke="currentColor" '
|
|
639
|
+
'stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'
|
|
640
|
+
'<path d="M9 2v14"/><path d="M4 12l5 6 5-6"/></svg></div>')
|
|
641
|
+
html = f'''{open_section(i, 'diagram', ' band--fit')}
|
|
642
|
+
<div class="wrap">
|
|
643
|
+
{shead(sec['eyebrow'], sec['title'], sec.get('lead', ''))}
|
|
644
|
+
<div class="arch rv">
|
|
645
|
+
<div class="arch__layer arch__layer--focus">
|
|
646
|
+
<div class="arch__lname">应用层</div>
|
|
647
|
+
<div class="arch__nodes">
|
|
648
|
+
<div class="arch__node arch__node--accent"><div class="arch__nt">节点一</div><div class="arch__nd">一行注</div></div>
|
|
649
|
+
<div class="arch__node"><div class="arch__nt">节点二</div><div class="arch__nd">一行注</div></div>
|
|
650
|
+
</div>
|
|
651
|
+
</div>
|
|
652
|
+
{conn}
|
|
653
|
+
<div class="arch__layer">
|
|
654
|
+
<div class="arch__lname">服务层</div>
|
|
655
|
+
<div class="arch__nodes">
|
|
656
|
+
<div class="arch__node"><div class="arch__nt">节点三</div><div class="arch__nd">一行注</div></div>
|
|
657
|
+
</div>
|
|
658
|
+
</div>
|
|
659
|
+
</div>
|
|
660
|
+
<div class="arch__legend">
|
|
661
|
+
<span class="chip chip--accent">当前建设焦点</span><span class="chip">已有能力</span>
|
|
662
|
+
</div>
|
|
663
|
+
</div>
|
|
664
|
+
</section>'''
|
|
665
|
+
return html, {'type': 'diagram', 'layers': [['应用层', [['节点一', '一行注'], ['节点二', '一行注']]],
|
|
666
|
+
['服务层', [['节点三', '一行注']]]],
|
|
667
|
+
'legend': ['当前建设焦点', '已有能力']}
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def pg_lane(i, sec):
|
|
671
|
+
"""泳道:CSS 正交箭头(.lane__arr 线段+箭头),禁裸文字 →。"""
|
|
672
|
+
def arr(last=False):
|
|
673
|
+
return '' if last else '<span class="lane__arr" aria-hidden="true"></span>'
|
|
674
|
+
html = f'''{open_section(i, 'lane', ' band--fit')}
|
|
675
|
+
<div class="wrap">
|
|
676
|
+
{shead(sec['eyebrow'], sec['title'], sec.get('lead', ''))}
|
|
677
|
+
<div class="stack rv gap-3">
|
|
678
|
+
<div class="lane"><div class="lane__hd">业务域</div>
|
|
679
|
+
<div class="lane__body">
|
|
680
|
+
<span class="lane__step">提需求</span>{arr()}
|
|
681
|
+
<span class="lane__step">确认口径</span>{arr()}
|
|
682
|
+
<span class="lane__step lane__step--a">Agent 取数</span>{arr()}
|
|
683
|
+
<span class="lane__step">验收结果</span>{arr(True)}
|
|
684
|
+
</div>
|
|
685
|
+
</div>
|
|
686
|
+
<div class="lane"><div class="lane__hd">数据平台</div>
|
|
687
|
+
<div class="lane__body">
|
|
688
|
+
<span class="lane__step">契约校验</span>{arr()}
|
|
689
|
+
<span class="lane__step">指标服务</span>{arr()}
|
|
690
|
+
<span class="lane__step">审计留痕</span>{arr(True)}
|
|
691
|
+
</div>
|
|
692
|
+
</div>
|
|
693
|
+
</div>
|
|
694
|
+
<div class="arch__legend">
|
|
695
|
+
<span class="chip chip--accent">关键步骤</span><span class="chip">常规步骤</span>
|
|
696
|
+
</div>
|
|
697
|
+
</div>
|
|
698
|
+
</section>'''
|
|
699
|
+
return html, {'type': 'lane', 'lanes': [['业务域', ['提需求', '确认口径', {'t': 'Agent 取数', 'accent': True}, '验收结果']],
|
|
700
|
+
['数据平台', ['契约校验', '指标服务', '审计留痕']]]}
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def pg_info(i, sec):
|
|
704
|
+
t = sec['type']
|
|
705
|
+
w, h = INFO_TYPES[t]
|
|
706
|
+
cap = {'sankey': '流向与流量(带宽 ∝ 流量)', 'treemap': '层级构成(面积 ∝ 数值)',
|
|
707
|
+
'boxplot': '分组分布(箱体 = Q1–Q3,中线 = 中位,须 = 极值)',
|
|
708
|
+
'network': '节点-边拓扑(节点半径 ∝ 连接度)',
|
|
709
|
+
'marimekko': '双重编码(列宽 ∝ 规模,列高 = 构成占比)',
|
|
710
|
+
'streamgraph': '构成演变(基线居中 · 带宽 ∝ 规模)'}[t]
|
|
711
|
+
# 信息图页承载在 .fig 里(不是 .exhibit 框)→ 来源行走 .footnote,避免与 Exhibit 来源行统计混淆
|
|
712
|
+
src = (' <div class="footnote">来源:来源名称,YYYY-MM;口径说明'
|
|
713
|
+
'<a class="cite" href="#ref-1">[1]</a></div>\n')
|
|
714
|
+
html = f'''<section class="band" id="s{i}">
|
|
715
|
+
<div class="wrap">
|
|
716
|
+
{shead(sec['eyebrow'], sec['title'], sec.get('lead', ''))}
|
|
717
|
+
<div class="fig rv">
|
|
718
|
+
<div class="fig__cap">{cap}</div>
|
|
719
|
+
<svg class="chart" data-chart="{t}" viewBox="0 0 {w} {h}">
|
|
720
|
+
<!-- TODO 信息图写法:infographics.md §71–§77(统计图形)/ §78–§81(结构图形) -->
|
|
721
|
+
<text x="{w // 2}" y="{h // 2}" text-anchor="middle" class="f-txt3" font-size="13">{t} 信息图占位</text>
|
|
722
|
+
</svg>
|
|
723
|
+
</div>
|
|
724
|
+
{src} <div class="sowhat rv"><span class="sowhat__k">So what</span>
|
|
725
|
+
<span class="sowhat__v">一行含义(≤60 字)。</span></div>
|
|
726
|
+
</div>
|
|
727
|
+
</section>'''
|
|
728
|
+
model = {'type': t}
|
|
729
|
+
if t == 'sankey':
|
|
730
|
+
model['flows'] = [['A', 'B', 100], ['B', 'C', 60], ['B', '流失', 40]]
|
|
731
|
+
elif t == 'treemap':
|
|
732
|
+
model['items'] = [['A', 38], ['B', 27], ['C', 21], ['D', 14]]
|
|
733
|
+
elif t == 'boxplot':
|
|
734
|
+
model['groups'] = [['A 线', 12, 28, 35, 48, 62], ['B 线', 18, 30, 34, 40, 46]]
|
|
735
|
+
elif t == 'network':
|
|
736
|
+
model['nodes'] = [['n1', '核心'], ['n2', '订单'], ['n3', '结算']]
|
|
737
|
+
model['edges'] = [['n1', 'n2'], ['n1', 'n3'], ['n2', 'n3']]
|
|
738
|
+
elif t == 'marimekko':
|
|
739
|
+
model['cols'] = [['2024', 130], ['2025', 165]]
|
|
740
|
+
model['cells'] = [[52, 48], [61, 39]]
|
|
741
|
+
model['legend'] = ['线上', '线下']
|
|
742
|
+
elif t == 'streamgraph':
|
|
743
|
+
model['labels'] = ['2024', '2025']
|
|
744
|
+
model['series'] = [{'name': '订阅', 'values': [52, 68]}, {'name': '服务', 'values': [34, 30]}]
|
|
745
|
+
model['chart'] = {'dataTable': 'notes'}
|
|
746
|
+
model['soWhat'] = '一行含义(≤60 字)。'
|
|
747
|
+
return html, model
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
BUILDERS = {
|
|
751
|
+
'points': pg_points, 'cards': pg_cards, 'metrics': pg_metrics, 'kpi': pg_kpi, 'table': pg_table,
|
|
752
|
+
'bar': pg_chart, 'donut': pg_donut, 'exhibit': pg_exhibit, 'twocol': pg_twocol,
|
|
753
|
+
'threecol': pg_threecol, 'halftable': pg_halftable, 'matrix': pg_matrix, 'heatmap': pg_heatmap,
|
|
754
|
+
'bullet': pg_bullet, 'pyramid': pg_pyramid, 'timeline': pg_timeline, 'steps': pg_steps,
|
|
755
|
+
'comparison': pg_comparison, 'split': pg_split, 'quote': pg_quote, 'image': pg_image,
|
|
756
|
+
'diagram': pg_diagram, 'lane': pg_lane,
|
|
757
|
+
'sankey': pg_info, 'treemap': pg_info, 'boxplot': pg_info, 'network': pg_info,
|
|
758
|
+
'marimekko': pg_info, 'streamgraph': pg_info,
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
SOWHAT_BLOCK = ''' <div class="sowhat rv">
|
|
763
|
+
<span class="sowhat__k">So what</span>
|
|
764
|
+
<span class="sowhat__v">这一页的含义或下一步建议(≤60 字)。</span>
|
|
765
|
+
</div>
|
|
766
|
+
'''
|
|
767
|
+
HAS_NOTE = re.compile(r'class="(?:sowhat|note)\b')
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def inject_sowhat(h: str) -> str:
|
|
771
|
+
"""research 节奏兜底:连续 2 页无结论条时补一条(校验器要求 ≤3 页无 .sowhat/.note)。"""
|
|
772
|
+
i = h.rfind(' </div>\n</section>')
|
|
773
|
+
return h if i < 0 else h[:i] + SOWHAT_BLOCK + h[i:]
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
# ── 页面骨架:封面 / Agenda / 收尾 / 参考资料 / 页脚 ────────────────────────────
|
|
777
|
+
def cover(mode, title, subtitle, meta):
|
|
778
|
+
return f'''<!-- 封面 -->
|
|
779
|
+
<section class="band band--fit" id="cover">
|
|
780
|
+
<div class="wrap">
|
|
781
|
+
<div class="shead rv">
|
|
782
|
+
<div class="t-eyebrow">{esc(meta)}</div>
|
|
783
|
+
<h1 class="t-display shead__title">{esc(title)}</h1>
|
|
784
|
+
<p class="t-lead shead__desc">{esc(subtitle)}</p>
|
|
785
|
+
</div>
|
|
786
|
+
</div>
|
|
787
|
+
</section>'''
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def agenda(mode, items):
|
|
791
|
+
lis = '\n'.join(
|
|
792
|
+
f''' <li class="agenda__i"><a class="agenda__a" href="#{sid}">
|
|
793
|
+
<span class="agenda__n">{n:02d}</span>
|
|
794
|
+
<span><span class="agenda__t">{esc(t)}</span><div class="agenda__d">{esc(d)}</div></span>
|
|
795
|
+
<span class="agenda__go"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
796
|
+
stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
|
|
797
|
+
</a></li>''' for n, (sid, t, d) in enumerate(items, 1))
|
|
798
|
+
cls = 'agenda agenda--2col' if len(items) > 8 else 'agenda'
|
|
799
|
+
return f'''<!-- Agenda(第二页) -->
|
|
800
|
+
<section class="band band--tint" id="agenda">
|
|
801
|
+
<div class="wrap">
|
|
802
|
+
<div class="shead rv">
|
|
803
|
+
<div class="t-eyebrow">Agenda</div>
|
|
804
|
+
<h2 class="t-h1 shead__title">报告大纲</h2>
|
|
805
|
+
<p class="t-lead shead__desc">点击任意条目跳转。</p>
|
|
806
|
+
</div>
|
|
807
|
+
<ol class="{cls} rv">
|
|
808
|
+
{lis}
|
|
809
|
+
</ol>
|
|
810
|
+
</div>
|
|
811
|
+
</section>'''
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def closing(mode, title, points):
|
|
815
|
+
cards = '\n'.join(f''' <div class="card"><h3 class="t-h3" style="margin-bottom:var(--sp-2)">{esc(k)}</h3>
|
|
816
|
+
<p class="t-body">{esc(v)}</p></div>''' for k, v in points)
|
|
817
|
+
return f'''<!-- 收尾(主题一致强调带:不用 band--deep) -->
|
|
818
|
+
<section class="band band--accent band--fit" id="next">
|
|
819
|
+
<div class="wrap">
|
|
820
|
+
<div class="shead shead--center rv">
|
|
821
|
+
<div class="t-eyebrow">下一步</div>
|
|
822
|
+
<h2 class="t-h1 shead__title">{esc(title)}</h2>
|
|
823
|
+
<p class="t-lead shead__desc">收尾页必须回应封面提出的问题。</p>
|
|
824
|
+
</div>
|
|
825
|
+
<div class="grid g-3 rv">
|
|
826
|
+
{cards}
|
|
827
|
+
</div>
|
|
828
|
+
</div>
|
|
829
|
+
</section>'''
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def refs(mode, items):
|
|
833
|
+
"""参考资料条目与正文 `[n]` 上标一一对应(校验器检查双向对齐)。"""
|
|
834
|
+
if items:
|
|
835
|
+
body = (' <ul class="ul ul--num rv">\n' + '\n'.join(
|
|
836
|
+
f''' <li id="ref-{n}">{esc(s)} <span class="t-xs" style="color:var(--text-3)">{esc(t)}</span></li>'''
|
|
837
|
+
for n, (s, t) in enumerate(items, 1)) + '\n </ul>')
|
|
838
|
+
else:
|
|
839
|
+
body = (' <p class="t-body rv">本报告暂无外部引用;内部材料的来源、时间与口径见各页来源行与'
|
|
840
|
+
' <code>.footnote</code>。补入外部数据时同步加 <code>[n]</code> 上标与本节条目。</p>')
|
|
841
|
+
return f'''<!-- 参考资料 -->
|
|
842
|
+
<section class="band band--tint" id="refs">
|
|
843
|
+
<div class="wrap">
|
|
844
|
+
<div class="shead rv">
|
|
845
|
+
<div class="t-eyebrow">参考资料</div>
|
|
846
|
+
<h2 class="t-h1 shead__title">参考资料:来源、时间与统计口径</h2>
|
|
847
|
+
</div>
|
|
848
|
+
{body}
|
|
849
|
+
</div>
|
|
850
|
+
</section>'''
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def footer(title, subtitle, meta):
|
|
854
|
+
return f'''<footer class="foot">
|
|
855
|
+
<div class="wrap foot__in">
|
|
856
|
+
<div class="brand">
|
|
857
|
+
<div class="brand__mark">R</div>
|
|
858
|
+
<div class="brand__txt">
|
|
859
|
+
<div class="brand__title">{esc(title)}</div>
|
|
860
|
+
<div class="brand__sub">{esc(subtitle)} · {esc(meta)}</div>
|
|
861
|
+
</div>
|
|
862
|
+
</div>
|
|
863
|
+
<div class="t-xs">密级:内部</div>
|
|
864
|
+
</div>
|
|
865
|
+
</footer>'''
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
# ── 主流程 ────────────────────────────────────────────────────────────────────
|
|
869
|
+
def build_plan(mode: str, n: int, plan: list | None, preset: str | None = None) -> list[dict]:
|
|
870
|
+
if plan:
|
|
871
|
+
out = []
|
|
872
|
+
for k, it in enumerate(plan):
|
|
873
|
+
s = dict(it)
|
|
874
|
+
s.setdefault('type', 'points')
|
|
875
|
+
s.setdefault('eyebrow', f'{k + 1:02d} · 章节')
|
|
876
|
+
s.setdefault('title', f'第 {k + 1} 页行动标题(结论句)')
|
|
877
|
+
out.append(s)
|
|
878
|
+
return out
|
|
879
|
+
seq = DEFAULT_PLAN[mode]
|
|
880
|
+
if preset:
|
|
881
|
+
gp = GOLDEN_PLANS.get(preset)
|
|
882
|
+
if not gp:
|
|
883
|
+
print(f'警告:未知节奏包 {preset!r},回落默认序列')
|
|
884
|
+
else:
|
|
885
|
+
if gp['mode'] != mode:
|
|
886
|
+
print(f'警告:节奏包 {preset} 属于 {gp["mode"]},与 --mode {mode} 不一致;'
|
|
887
|
+
f'将自动切换 mode={gp["mode"]}')
|
|
888
|
+
seq = gp['types']
|
|
889
|
+
mode = gp['mode']
|
|
890
|
+
out = []
|
|
891
|
+
for k in range(n):
|
|
892
|
+
t = seq[k % len(seq)]
|
|
893
|
+
out.append({'type': t, 'eyebrow': f'{k + 1:02d} · 章节', 'title': f'第 {k + 1} 页行动标题(结论句)'})
|
|
894
|
+
return out
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def main() -> int:
|
|
898
|
+
ap = argparse.ArgumentParser(description='TopPPT HTML 报告骨架生成器')
|
|
899
|
+
ap.add_argument('--mode', default='presentation', choices=['presentation', 'research', 'architecture'])
|
|
900
|
+
ap.add_argument('--style', default=None, help='9 套风格之一(默认取模板出厂风格)')
|
|
901
|
+
ap.add_argument('--theme', default=None, choices=['light', 'dark'],
|
|
902
|
+
help='默认 light;石墨深灰(graphite-dark)默认 dark(深色优先风格)')
|
|
903
|
+
ap.add_argument('--title', default='报告标题')
|
|
904
|
+
ap.add_argument('--subtitle', default='副标题 / 范围口径')
|
|
905
|
+
ap.add_argument('--meta', default='2026-01-01 · 内部')
|
|
906
|
+
ap.add_argument('--sections', type=int, default=None, help='内容页数(不给则按模式默认)')
|
|
907
|
+
ap.add_argument('--plan', help='页型规划 JSON 文件:[{"type","eyebrow","title"}…]')
|
|
908
|
+
ap.add_argument('--preset', help='黄金节奏包名:pitch/ops-review/consulting/diagnostic/layered-arch/flow-arch')
|
|
909
|
+
ap.add_argument('--out', help='输出 HTML 路径')
|
|
910
|
+
ap.add_argument('--list-types', action='store_true', help='列出可用页型与节奏包后退出')
|
|
911
|
+
args = ap.parse_args()
|
|
912
|
+
|
|
913
|
+
if args.list_types:
|
|
914
|
+
print('可用页型(' + str(len(BUILDERS)) + '):')
|
|
915
|
+
print(' ' + ' · '.join(sorted(BUILDERS)))
|
|
916
|
+
print('\n默认序列:')
|
|
917
|
+
for m, seq in DEFAULT_PLAN.items():
|
|
918
|
+
print(f' {m}: ' + ' → '.join(seq))
|
|
919
|
+
print('\n黄金节奏包(--preset):')
|
|
920
|
+
for name, gp in GOLDEN_PLANS.items():
|
|
921
|
+
print(f' {name:14} [{gp["mode"]}] {gp["title"]}: ' + ' → '.join(gp['types']))
|
|
922
|
+
return 0
|
|
923
|
+
|
|
924
|
+
if not args.out:
|
|
925
|
+
print('错误:需要 --out <输出.html>')
|
|
926
|
+
return 2
|
|
927
|
+
|
|
928
|
+
mode = args.mode
|
|
929
|
+
if args.preset and args.preset in GOLDEN_PLANS and GOLDEN_PLANS[args.preset]['mode'] != mode:
|
|
930
|
+
mode = GOLDEN_PLANS[args.preset]['mode']
|
|
931
|
+
print(f'提示:按节奏包 {args.preset} 切换 mode={mode}')
|
|
932
|
+
tpl_path = TPL / f'{mode}.html'
|
|
933
|
+
if not tpl_path.exists():
|
|
934
|
+
print(f'错误:模板不存在 {tpl_path}')
|
|
935
|
+
return 2
|
|
936
|
+
t = tpl_path.read_text(encoding='utf-8')
|
|
937
|
+
|
|
938
|
+
style = args.style or TPL_DEFAULT_STYLE[mode]
|
|
939
|
+
theme = args.theme or ('dark' if style == 'graphite-dark' else 'light')
|
|
940
|
+
n = args.sections if args.sections else {'presentation': 8, 'research': 10, 'architecture': 3}[mode]
|
|
941
|
+
plan = None
|
|
942
|
+
if args.plan:
|
|
943
|
+
plan = json.loads(Path(args.plan).read_text(encoding='utf-8'))
|
|
944
|
+
n = len(plan)
|
|
945
|
+
pages = build_plan(mode, n, plan, preset=args.preset)
|
|
946
|
+
|
|
947
|
+
# ① 风格 / 主题 / 标题 / 品牌
|
|
948
|
+
t = t.replace(f'data-style="{TPL_DEFAULT_STYLE[mode]}"', f'data-style="{style}"', 1)
|
|
949
|
+
t = re.sub(r'(<html[^>]*data-theme=")[^"]*(")', r'\g<1>' + theme + r'\g<2>', t, count=1)
|
|
950
|
+
assert f'data-mode="{mode}" data-style="{style}" data-theme="{theme}"' in t, \
|
|
951
|
+
'主题/风格注入失败(模板 html 标签与预期不一致)'
|
|
952
|
+
t = t.replace(f'<title>{TPL_TITLE_TAG[mode]}</title>', f'<title>{esc(args.title)}</title>', 1)
|
|
953
|
+
old_t, old_s = TPL_BRAND[mode]
|
|
954
|
+
assert BRAND_RE.search(t), '模板缺少品牌标题结构'
|
|
955
|
+
t = BRAND_RE.sub(lambda m: m.group(1) + esc(args.title) + m.group(2) + esc(args.subtitle) + m.group(3),
|
|
956
|
+
t, count=1)
|
|
957
|
+
|
|
958
|
+
# ② 正文区
|
|
959
|
+
html_parts, model_secs, agenda_items = [], [], []
|
|
960
|
+
exhibit_no = 1
|
|
961
|
+
chart_i = 0
|
|
962
|
+
since_note = 0
|
|
963
|
+
for k, p in enumerate(pages, 1):
|
|
964
|
+
p.setdefault('type', 'points')
|
|
965
|
+
b = BUILDERS.get(p['type'])
|
|
966
|
+
if b is None:
|
|
967
|
+
print(f'警告:未知页型 {p["type"]!r},回落到 points')
|
|
968
|
+
b = pg_points
|
|
969
|
+
if p['type'] in ('bar', 'exhibit', 'halftable', 'split'):
|
|
970
|
+
p.setdefault('chart', CHART_CYCLE[chart_i % len(CHART_CYCLE)])
|
|
971
|
+
chart_i += 1
|
|
972
|
+
if p['type'] in ('exhibit', 'halftable'):
|
|
973
|
+
p['exhibitNo'] = exhibit_no
|
|
974
|
+
exhibit_no += 1
|
|
975
|
+
h, m = b(k, p)
|
|
976
|
+
# 布局骨架标记(layout-grammar):无则补 data-skel / data-page-type
|
|
977
|
+
skel = PAGE_TO_PRESET.get(p['type'], 'P4')
|
|
978
|
+
if 'data-skel=' not in h:
|
|
979
|
+
h = re.sub(
|
|
980
|
+
r'<section class="band([^"]*)" id="s' + str(k) + r'"',
|
|
981
|
+
f'<section class="band\\1" id="s{k}" data-skel="{skel}" data-page-type="{p["type"]}"',
|
|
982
|
+
h, count=1)
|
|
983
|
+
m['layoutPreset'] = skel
|
|
984
|
+
has_note = bool(HAS_NOTE.search(h))
|
|
985
|
+
if not has_note and since_note >= 2: # research 节奏:≤2 页无结论条
|
|
986
|
+
h = inject_sowhat(h)
|
|
987
|
+
has_note = True
|
|
988
|
+
since_note = 0 if has_note else since_note + 1
|
|
989
|
+
html_parts.append(h)
|
|
990
|
+
m.update({'eyebrow': p['eyebrow'], 'title': p['title']})
|
|
991
|
+
if p.get('lead'):
|
|
992
|
+
m['lead'] = p['lead']
|
|
993
|
+
model_secs.append(m)
|
|
994
|
+
agenda_items.append((f's{k}', p['title'], p.get('eyebrow', '')))
|
|
995
|
+
|
|
996
|
+
show_agenda = not (mode == 'architecture' and len(pages) <= 4)
|
|
997
|
+
closing_points = [['动作一', '一句话说明。'], ['动作二', '一句话说明。'], ['动作三', '一句话说明。']]
|
|
998
|
+
|
|
999
|
+
body = [cover(mode, args.title, args.subtitle, args.meta)]
|
|
1000
|
+
if show_agenda:
|
|
1001
|
+
body.append(agenda(mode, agenda_items))
|
|
1002
|
+
body += html_parts
|
|
1003
|
+
body.append(closing(mode, '收尾主张(回应封面问题)', closing_points))
|
|
1004
|
+
head = '\n\n'.join(body)
|
|
1005
|
+
|
|
1006
|
+
# 参考资料条目由正文实际 `[n]` 上标反推(校验器检查双向对齐)
|
|
1007
|
+
ref_ids = sorted(set(re.findall(r'class="cite" href="#(ref-\d)"', head)))
|
|
1008
|
+
ref_items = [[f'来源 {i}(替换为真实来源名称)', '2026-01;口径说明'] for i in range(1, len(ref_ids) + 1)]
|
|
1009
|
+
content = head + '\n\n' + refs(mode, ref_items) + '\n\n' + footer(args.title, args.subtitle, args.meta)
|
|
1010
|
+
|
|
1011
|
+
assert CONTENT_RE.search(t), f'{tpl_path.name} 缺少 __TOPPPT_CONTENT__ 标记'
|
|
1012
|
+
t = CONTENT_RE.sub('<!-- __TOPPPT_CONTENT_START__ -->\n' + content +
|
|
1013
|
+
'\n<!-- __TOPPPT_CONTENT_END__ -->', t, count=1)
|
|
1014
|
+
|
|
1015
|
+
# ③ 顶栏导航与 Agenda 锚点一致
|
|
1016
|
+
nav_links = '\n'.join(f' <a href="#s{i}">{esc(p["title"][:10])}</a>'
|
|
1017
|
+
for i, p in enumerate(pages[:3], 1))
|
|
1018
|
+
assert NAV_RE.search(t), '模板缺少顶栏导航'
|
|
1019
|
+
t = NAV_RE.sub('<nav class="nav">\n' + nav_links + '\n <a href="#refs">参考资料</a>\n </nav>',
|
|
1020
|
+
t, count=1)
|
|
1021
|
+
|
|
1022
|
+
# ④ REPORT_MODEL 骨架(与正文一一对应 · 严格 JSON)
|
|
1023
|
+
model = {
|
|
1024
|
+
'mode': mode, 'style': style, 'theme': theme,
|
|
1025
|
+
'title': args.title, 'subtitle': args.subtitle, 'meta': args.meta,
|
|
1026
|
+
'agenda': [[f'{i:02d}', p['title'], p.get('eyebrow', '')] for i, p in enumerate(pages, 1)]
|
|
1027
|
+
if show_agenda else [],
|
|
1028
|
+
'sections': model_secs,
|
|
1029
|
+
'closing': {'title': '收尾主张(回应封面问题)',
|
|
1030
|
+
'points': [list(x) for x in closing_points]},
|
|
1031
|
+
}
|
|
1032
|
+
assert MODEL_RE.search(t), f'{tpl_path.name} 缺少 REPORT_MODEL 块'
|
|
1033
|
+
t = MODEL_RE.sub('window.REPORT_MODEL = ' + json.dumps(model, ensure_ascii=False, indent=2) + ';', t, count=1)
|
|
1034
|
+
assert f'"mode": "{mode}"' in t and f'data-mode="{mode}"' in t, 'mode 注入不一致'
|
|
1035
|
+
assert 'g.TopPptHtml = api' in t and 'function slidesXml' in t, '预览运行时缺失(标记块被破坏)'
|
|
1036
|
+
|
|
1037
|
+
out = Path(args.out)
|
|
1038
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
1039
|
+
out.write_text(t, encoding='utf-8')
|
|
1040
|
+
print(f'已生成骨架:{out} {len(t)} 字符')
|
|
1041
|
+
print(f' 模式 {mode} · 风格 {style} · 主题 {theme} · 内容页 {len(pages)}'
|
|
1042
|
+
f'({"含" if show_agenda else "省略"} Agenda'
|
|
1043
|
+
+ (f' · 节奏包 {args.preset}' if args.preset else '') + ')')
|
|
1044
|
+
print(' 页型序列:' + ' → '.join(p['type'] for p in pages))
|
|
1045
|
+
print(' 布局骨架:' + ' → '.join(PAGE_TO_PRESET.get(p['type'], 'P4') for p in pages))
|
|
1046
|
+
print('下一步:')
|
|
1047
|
+
print(' 1) 按 references/layout-grammar.md 选/核对骨架 P__,再按 playbook §三/§四 填内容(主件+从件+注释层)')
|
|
1048
|
+
print(' 2) 同步更新 window.REPORT_MODEL(字段见 scripts/model-schema.json;模型字段纯文本,引用写 [n])')
|
|
1049
|
+
print(f' 3) python scripts/validate_report.py "{out}" --strict → 0/0 才交付')
|
|
1050
|
+
return 0
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
if __name__ == '__main__':
|
|
1054
|
+
sys.exit(main())
|