@topmindspace/tms-skills 0.1.9 → 0.1.10

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.
@@ -4,7 +4,7 @@
4
4
  "theme": "light",
5
5
  "title": "TopMind × tms-skills",
6
6
  "subtitle": "让 idea 飞 · 把好想法做成可上台的演示文稿",
7
- "meta": "产品介绍 · 2026-09-26",
7
+ "meta": "产品介绍 · 2026-09-26 · v0.1.10",
8
8
  "agenda": [
9
9
  [
10
10
  "01",
@@ -18,7 +18,7 @@
18
18
  ],
19
19
  [
20
20
  "03",
21
- "top-ppt-html 特色",
21
+ "top-ppt-html",
22
22
  "HTML + 保真 PPTX"
23
23
  ],
24
24
  [
@@ -28,16 +28,26 @@
28
28
  ],
29
29
  [
30
30
  "05",
31
+ "图表工艺",
32
+ "多样 · 可编辑 · 门禁"
33
+ ],
34
+ [
35
+ "06",
31
36
  "风格与主题",
32
37
  "九风格 × 亮暗"
33
38
  ],
34
39
  [
35
- "06",
40
+ "07",
36
41
  "质量门禁",
37
42
  "strict 0/0 交付"
38
43
  ],
39
44
  [
40
- "07",
45
+ "08",
46
+ "Header 工具栏",
47
+ "演示即文档"
48
+ ],
49
+ [
50
+ "09",
41
51
  "Fast Mode",
42
52
  "少问一句 · 工艺不降"
43
53
  ]
@@ -136,7 +146,7 @@
136
146
  },
137
147
  "right": {
138
148
  "type": "bar",
139
- "cap": "交付关注点(示意权重)",
149
+ "cap": "工艺关注点(产品强调 · 示意权重)",
140
150
  "labels": [
141
151
  "版式",
142
152
  "排版",
@@ -204,10 +214,71 @@
204
214
  ],
205
215
  "footnote": "意图线索:路演/汇报/演讲 → A;调研/对标 → B;拓扑/方案图 → C"
206
216
  },
217
+ {
218
+ "type": "donut",
219
+ "layoutPreset": "P3",
220
+ "eyebrow": "05 · 图表工艺",
221
+ "title": "交付面三件套:演示 HTML · 可编辑 PPTX · 门禁说明",
222
+ "chart": {
223
+ "labels": [
224
+ "可翻页 HTML",
225
+ "保真 PPTX",
226
+ "质检交付说明"
227
+ ],
228
+ "values": [
229
+ 40,
230
+ 35,
231
+ 25
232
+ ],
233
+ "unit": "%",
234
+ "centerLabel": "同源模型",
235
+ "dataTable": "notes"
236
+ },
237
+ "note": "口径:产品交付构成示意——三者同源 REPORT_MODEL,缺一则不算完整上台交付。",
238
+ "soWhat": "双通道不是二选一:HTML 上台演示,PPTX 可继续编辑,门禁证明可交付。"
239
+ },
240
+ {
241
+ "type": "bar",
242
+ "layoutPreset": "P7",
243
+ "eyebrow": "05 · 图表工艺",
244
+ "title": "0.1.x 能力累进:门禁与样张随版本加厚",
245
+ "chart": {
246
+ "type": "line",
247
+ "labels": [
248
+ "0.1.6",
249
+ "0.1.7",
250
+ "0.1.8",
251
+ "0.1.9",
252
+ "0.1.10"
253
+ ],
254
+ "values": [
255
+ 6,
256
+ 8,
257
+ 10,
258
+ 11,
259
+ 12
260
+ ],
261
+ "unit": "项",
262
+ "max": 14,
263
+ "dataTable": "notes"
264
+ },
265
+ "points": [
266
+ [
267
+ "真实版本线",
268
+ "对应 CHANGELOG 已合并能力,非虚构增速"
269
+ ],
270
+ [
271
+ "本版加厚",
272
+ "多图样张 · Header 工具栏文档 · 双语 README"
273
+ ]
274
+ ],
275
+ "note": "口径:累计能力项为发布说明中的能力主题计数(示意刻度),用于展示版本节奏。",
276
+ "soWhat": "质量不是口号——版本线把门禁、样张与文档一起推进。"
277
+ },
207
278
  {
208
279
  "type": "metrics",
209
280
  "layoutPreset": "P2",
210
- "eyebrow": "05 · 风格主题",
281
+ "eyebrow": "06 · 风格主题",
211
282
  "title": "九风格开场即可见,亮暗主题一键贯穿",
212
283
  "metrics": [
213
284
  [
@@ -232,7 +303,7 @@
232
303
  {
233
304
  "type": "comparison",
234
305
  "layoutPreset": "P4",
235
- "eyebrow": "06 · 质量门禁",
306
+ "eyebrow": "07 · 质量门禁",
236
307
  "title": "散装出稿止步,strict 0/0 才算交付",
237
308
  "left": {
238
309
  "title": "散装路径 · 风险",
@@ -270,10 +341,137 @@
270
341
  },
271
342
  "verdict": "反截断、图表多样性、runtime SHA——门禁是交付的一部分。"
272
343
  },
344
+ {
345
+ "type": "bar",
346
+ "layoutPreset": "P6",
347
+ "eyebrow": "07 · 质量门禁",
348
+ "title": "从想法到上台:工艺链路如何把主张垫实",
349
+ "chart": {
350
+ "type": "waterfall",
351
+ "labels": [
352
+ "意图",
353
+ "模式",
354
+ "模型",
355
+ "图表",
356
+ "门禁",
357
+ "上台"
358
+ ],
359
+ "values": [
360
+ 20,
361
+ 15,
362
+ 25,
363
+ 15,
364
+ 15,
365
+ 90
366
+ ],
367
+ "unit": "pt",
368
+ "dataTable": "notes"
369
+ },
370
+ "points": [
371
+ [
372
+ "意图定模式",
373
+ "路演→A · 调研→B · 拓扑→C"
374
+ ],
375
+ [
376
+ "门禁收口",
377
+ "strict 0/0 前不算交付"
378
+ ]
379
+ ],
380
+ "note": "口径:链路贡献为工艺阶段示意分,用于说明流程而非外部 KPI。",
381
+ "soWhat": "上台交付 = 模式契约 + 模型单写 + 多样图表 + 门禁收口。"
382
+ },
383
+ {
384
+ "type": "split",
385
+ "layoutPreset": "P1",
386
+ "eyebrow": "05 · 图表工艺",
387
+ "title": "核图优先:排名对比用横向条,一眼分出轻重",
388
+ "left": {
389
+ "points": [
390
+ [
391
+ "产品已交付能力",
392
+ "五项均为当前线真实能力,不是预测"
393
+ ],
394
+ [
395
+ "图表选型",
396
+ "谁大谁小 → hbar;趋势 → line;占比 → donut"
397
+ ]
398
+ ]
399
+ },
400
+ "right": {
401
+ "type": "hbar",
402
+ "cap": "能力就绪(当前发布线)",
403
+ "labels": [
404
+ "strict 0/0",
405
+ "三模式契约",
406
+ "九风格皮肤",
407
+ "双通道交付",
408
+ "Fast Mode"
409
+ ],
410
+ "values": [
411
+ 100,
412
+ 100,
413
+ 100,
414
+ 100,
415
+ 100
416
+ ],
417
+ "max": 100,
418
+ "unit": "%",
419
+ "dataTable": "notes"
420
+ },
421
+ "footnote": "口径:能力就绪度 = 当前发布线已具备并写入文档/门禁的能力(均为 100%)。",
422
+ "soWhat": "说服力来自可核对的能力清单与图表多样性,而非单张柱图凑数。"
423
+ },
424
+ {
425
+ "type": "table",
426
+ "layoutPreset": "P8",
427
+ "eyebrow": "08 · Header 工具栏",
428
+ "title": "打开 HTML 即演示:顶栏快捷操作一览",
429
+ "table": {
430
+ "head": [
431
+ "控件",
432
+ "快捷键",
433
+ "行为"
434
+ ],
435
+ "rows": [
436
+ [
437
+ "亮暗主题",
438
+ "T",
439
+ "浅色外发 / 深色大屏;按文件记忆;同步 REPORT_MODEL.theme"
440
+ ],
441
+ [
442
+ "风格选择",
443
+ "9 套",
444
+ "九风格实时切换(纯视觉);交付前 data-style = REPORT_MODEL.style"
445
+ ],
446
+ [
447
+ "预览 PPTX",
448
+ "P",
449
+ "页序列所见即所得;可复制提示词回对话走精导"
450
+ ],
451
+ [
452
+ "PPT 生成指引",
453
+ "H",
454
+ "双通道说明与环境依赖;与 ? 帮助同效"
455
+ ],
456
+ [
457
+ "全屏",
458
+ "F",
459
+ "沉浸演示(requestFullscreen);图标随进入/退出切换"
460
+ ],
461
+ [
462
+ "收起工具栏",
463
+ "B",
464
+ "折叠为品牌标+展开迷你条;锚点自适应并按文件记忆"
465
+ ]
466
+ ]
467
+ },
468
+ "footnote": "另:方向键翻页 · Esc 关闭模态 · 风格/主题变更后须重跑校验",
469
+ "soWhat": "工具栏不是装饰——演示、预览、换肤与交付指引同一顶栏完成。"
470
+ },
273
471
  {
274
472
  "type": "timeline",
275
473
  "layoutPreset": "P6",
276
- "eyebrow": "07 · Fast Mode",
474
+ "eyebrow": "09 · Fast Mode",
277
475
  "title": "Fast Mode:跳过问询,不跳过工艺",
278
476
  "phases": [
279
477
  [
@@ -304,7 +502,7 @@
304
502
  ],
305
503
  [
306
504
  "看样张",
307
- "assets/examples · style-gallery.html"
505
+ "assets/examples · style-gallery.html · 顶栏 T/P/H/F/B"
308
506
  ],
309
507
  [
310
508
  "上台交付",
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "top-ppt-html",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "top-ppt-html",
9
- "version": "0.1.9",
9
+ "version": "0.1.10",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "pptxgenjs": "^4.0.0"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "top-ppt-html",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "private": true,
5
5
  "description": "TopPPT HTML:优雅大气的正式场合演讲/汇报演示文稿。单文件 HTML + 版式保真可编辑 PPTX;布局骨架 P1–P12;模型单写;strict 0/0 交付。",
6
6
  "license": "MIT",
@@ -0,0 +1,341 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Fill placeholder chart SVGs from REPORT_MODEL (core types for showcase).
4
+
5
+ Used after render_from_model.py — follows extract_snippet chart patterns
6
+ (bar / hbar / line / area / donut / waterfall / stack). Not a public CLI.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ MODEL_RE = re.compile(r'window\.REPORT_MODEL = (\{[\s\S]*?\});')
16
+ PLACEHOLDER_SVG_RE = re.compile(
17
+ r'<svg class="chart" data-chart="([^"]+)" viewBox="0 0 560 220">\s*'
18
+ r'<!--[\s\S]*?-->\s*'
19
+ r'<text[^>]*>[^<]*见模型数据</text>\s*'
20
+ r'</svg>',
21
+ re.M,
22
+ )
23
+
24
+
25
+ def esc(s) -> str:
26
+ return (str(s)
27
+ .replace('&', '&amp;')
28
+ .replace('<', '&lt;')
29
+ .replace('>', '&gt;')
30
+ .replace('"', '&quot;'))
31
+
32
+
33
+ def svg_bar(labels, values, max_v=None, unit='') -> str:
34
+ max_v = float(max_v or max(values) * 1.15 or 1)
35
+ n = len(labels)
36
+ gap, left, right, base, top = 24, 60, 540, 170, 30
37
+ usable = right - left
38
+ bw = max(28, (usable - gap * (n - 1)) / n * 0.55)
39
+ step = usable / n
40
+ parts = [f'<line x1="46" y1="{base}" x2="540" y2="{base}" class="s-bds" stroke-width="1"/>']
41
+ for i, (lb, v) in enumerate(zip(labels, values)):
42
+ v = float(v)
43
+ h = (v / max_v) * (base - top)
44
+ x = left + i * step + (step - bw) / 2
45
+ y = base - h
46
+ cls = 'f-acc' if i >= n - 2 else 'f-s3'
47
+ cx = x + bw / 2
48
+ parts.append(
49
+ f'<rect data-anim="grow" x="{x:.1f}" y="{y:.1f}" width="{bw:.1f}" '
50
+ f'height="{h:.1f}" rx="6" class="{cls}"><title>{esc(lb)} {v:g}{esc(unit)}</title></rect>'
51
+ )
52
+ parts.append(
53
+ f'<text data-anim="fade" x="{cx:.1f}" y="{base + 22}" text-anchor="middle" '
54
+ f'class="f-txt3" font-size="11">{esc(lb)}</text>'
55
+ )
56
+ parts.append(
57
+ f'<text data-anim="fade" x="{cx:.1f}" y="{y - 8:.1f}" text-anchor="middle" '
58
+ f'class="f-txt2" font-size="12" font-weight="600">{v:g}{esc(unit)}</text>'
59
+ )
60
+ return ('<svg class="chart" data-chart="bar" viewBox="0 0 560 220">\n '
61
+ + '\n '.join(parts) + '\n </svg>')
62
+
63
+
64
+ def svg_hbar(labels, values, max_v=None, unit='') -> str:
65
+ max_v = float(max_v or max(values) or 1)
66
+ n = len(labels)
67
+ row_h = min(36, 180 / max(n, 1))
68
+ y0 = 16
69
+ slot_x, slot_w = 130, 340
70
+ parts = []
71
+ for i, (lb, v) in enumerate(zip(labels, values)):
72
+ v = float(v)
73
+ y = y0 + i * row_h
74
+ w = (v / max_v) * slot_w
75
+ parts.append(f'<text x="0" y="{y + 14:.1f}" class="f-txt2" font-size="12">{esc(lb)}</text>')
76
+ parts.append(
77
+ f'<rect x="{slot_x}" y="{y + 4:.1f}" width="{slot_w}" height="15" rx="7.5" class="f-s2"/>'
78
+ )
79
+ parts.append(
80
+ f'<rect data-anim="grow" x="{slot_x}" y="{y + 4:.1f}" width="{w:.1f}" height="15" '
81
+ f'rx="7.5" class="f-acc"><title>{esc(lb)} {v:g}{esc(unit)}</title></rect>'
82
+ )
83
+ parts.append(
84
+ f'<text x="{slot_x + slot_w + 12}" y="{y + 16:.1f}" class="f-txt" '
85
+ f'font-size="12" font-weight="600">{v:g}{esc(unit)}</text>'
86
+ )
87
+ return ('<svg class="chart" data-chart="hbar" viewBox="0 0 560 220">\n '
88
+ + '\n '.join(parts) + '\n </svg>')
89
+
90
+
91
+ def svg_line(labels, values, max_v=None, unit='', area=False) -> str:
92
+ max_v = float(max_v or max(values) * 1.2 or 1)
93
+ n = len(labels)
94
+ left, right, base, top = 60, 520, 180, 40
95
+ xs = [left + i * (right - left) / max(n - 1, 1) for i in range(n)]
96
+ ys = [base - (float(v) / max_v) * (base - top) for v in values]
97
+ pts = ' '.join(f'{x:.1f},{y:.1f}' for x, y in zip(xs, ys))
98
+ path = 'L'.join(f'{x:.1f},{y:.1f}' for x, y in zip(xs, ys))
99
+ path = 'M' + path[1:] if path.startswith('L') else 'M' + path
100
+ # fix: build properly
101
+ path = 'M' + ' L'.join(f'{x:.1f},{y:.1f}' for x, y in zip(xs, ys))
102
+ ct = 'area' if area else 'line'
103
+ parts = [
104
+ f'<line x1="46" y1="{base}" x2="540" y2="{base}" class="s-bds" stroke-width="1"/>',
105
+ f'<line x1="46" y1="130" x2="540" y2="130" class="s-bds" stroke-width="1" stroke-dasharray="3 5"/>',
106
+ f'<line x1="46" y1="80" x2="540" y2="80" class="s-bds" stroke-width="1" stroke-dasharray="3 5"/>',
107
+ ]
108
+ if area:
109
+ area_path = path + f' L{xs[-1]:.1f},{base} L{xs[0]:.1f},{base} Z'
110
+ parts.append(f'<path d="{area_path}" class="f-acc" fill-opacity=".12"/>')
111
+ parts.append(
112
+ f'<path data-draw d="{path}" class="f-none s-acc" stroke-width="2.5" '
113
+ f'stroke-linecap="round" stroke-linejoin="round"/>'
114
+ )
115
+ for i, (x, y, lb, v) in enumerate(zip(xs, ys, labels, values)):
116
+ parts.append(
117
+ f'<circle cx="{x:.1f}" cy="{y:.1f}" r="4" class="f-acc">'
118
+ f'<title>{esc(lb)} · {float(v):g}{esc(unit)}</title></circle>'
119
+ )
120
+ parts.append(
121
+ f'<text x="{x:.1f}" y="{base + 20}" text-anchor="middle" '
122
+ f'class="f-txt3" font-size="11">{esc(lb)}</text>'
123
+ )
124
+ return (f'<svg class="chart" data-chart="{ct}" viewBox="0 0 560 220">\n '
125
+ + '\n '.join(parts) + '\n </svg>')
126
+
127
+
128
+ def svg_donut(labels, values, center_label='合计', unit='') -> str:
129
+ total = sum(float(v) for v in values) or 1
130
+ # multi-segment donut: layered sweeps (simplified visual with chips)
131
+ # Use concentric accent arc for first + legend chips for all (presentation craft)
132
+ r, cx, cy, circ = 54, 70, 70, 339.3
133
+ parts = [
134
+ f'<circle cx="{cx}" cy="{cy}" r="{r}" class="f-none s-bds" stroke-width="15"/>'
135
+ ]
136
+ # Draw up to 3 sweeps stacked by offset — for clarity use primary accent for largest
137
+ ordered = sorted(zip(labels, values), key=lambda x: -float(x[1]))
138
+ offset = 0.0
139
+ colors = ['s-acc', 's-txt2', 's-txt3', 's-bds']
140
+ for i, (lb, v) in enumerate(ordered):
141
+ p = float(v) / total
142
+ dash = circ * p
143
+ gap = circ - dash
144
+ rot = -90 + offset * 360
145
+ cls = colors[i % len(colors)]
146
+ parts.append(
147
+ f'<circle data-sweep data-circ="{circ}" data-p="{p:.4f}" cx="{cx}" cy="{cy}" r="{r}" '
148
+ f'class="f-none {cls}" stroke-width="15" stroke-dasharray="{dash:.1f} {gap:.1f}" '
149
+ f'stroke-linecap="butt" transform="rotate({rot:.1f} {cx} {cy})">'
150
+ f'<title>{esc(lb)} {float(v):g}{esc(unit)}</title></circle>'
151
+ )
152
+ offset += p
153
+ top = ordered[0]
154
+ pct = float(top[1]) / total * 100
155
+ parts.append(
156
+ f'<text x="{cx}" y="68" text-anchor="middle" class="f-txt" font-size="22" '
157
+ f'font-weight="600" data-count="{pct:.0f}" data-suffix="%">{pct:.0f}%</text>'
158
+ )
159
+ parts.append(
160
+ f'<text x="{cx}" y="88" text-anchor="middle" class="f-txt3" font-size="11">'
161
+ f'{esc(center_label)}</text>'
162
+ )
163
+ # viewBox larger with legend beside — keep 560x220 and center the donut
164
+ inner = '\n '.join(parts)
165
+ return (
166
+ f'<svg class="chart" data-chart="donut" viewBox="0 0 560 220">\n'
167
+ f' <g transform="translate(210,40)">\n {inner}\n </g>\n'
168
+ f' </svg>'
169
+ )
170
+
171
+
172
+ def svg_waterfall(labels, values, unit='') -> str:
173
+ # values: start, deltas..., end (end should equal start+sum(deltas))
174
+ n = len(labels)
175
+ if n < 2:
176
+ return svg_bar(labels, values, unit=unit)
177
+ start = float(values[0])
178
+ end = float(values[-1])
179
+ mids = [float(v) for v in values[1:-1]]
180
+ # running level after each step
181
+ levels = [start]
182
+ cur = start
183
+ for d in mids:
184
+ cur += d
185
+ levels.append(cur)
186
+ levels.append(end)
187
+ max_v = max(levels) * 1.15 or 1
188
+ min_v = min(0, min(levels))
189
+ span = max_v - min_v or 1
190
+ left, right, base, top = 50, 540, 190, 30
191
+ usable = right - left
192
+ bw = min(70, usable / n * 0.55)
193
+ step = usable / n
194
+
195
+ def y_of(val):
196
+ return base - ((val - min_v) / span) * (base - top)
197
+
198
+ parts = [f'<line x1="40" y1="{base}" x2="540" y2="{base}" class="s-bds" stroke-width="1"/>']
199
+ # start bar
200
+ y1, y0 = y_of(start), base
201
+ h = abs(y0 - y1)
202
+ x = left + (step - bw) / 2
203
+ parts.append(
204
+ f'<rect data-anim="grow" x="{x:.1f}" y="{min(y0,y1):.1f}" width="{bw:.1f}" '
205
+ f'height="{h:.1f}" rx="5" class="f-s3"/>'
206
+ )
207
+ parts.append(
208
+ f'<text x="{x + bw/2:.1f}" y="{min(y0,y1) - 8:.1f}" text-anchor="middle" '
209
+ f'class="f-txt2" font-size="12" font-weight="600">{start:g}{esc(unit)}</text>'
210
+ )
211
+ parts.append(
212
+ f'<text x="{x + bw/2:.1f}" y="{base + 16}" text-anchor="middle" '
213
+ f'class="f-txt3" font-size="11">{esc(labels[0])}</text>'
214
+ )
215
+ prev = start
216
+ for i, d in enumerate(mids):
217
+ idx = i + 1
218
+ nxt = prev + d
219
+ ya, yb = y_of(prev), y_of(nxt)
220
+ x = left + idx * step + (step - bw) / 2
221
+ top_y, bot_y = min(ya, yb), max(ya, yb)
222
+ cls = 'f-acc' if d >= 0 else 'f-txt3'
223
+ sign = f'+{d:g}' if d >= 0 else f'{d:g}'
224
+ parts.append(
225
+ f'<rect data-anim="grow" x="{x:.1f}" y="{top_y:.1f}" width="{bw:.1f}" '
226
+ f'height="{max(bot_y - top_y, 2):.1f}" rx="5" class="{cls}"/>'
227
+ )
228
+ parts.append(
229
+ f'<text x="{x + bw/2:.1f}" y="{top_y - 8:.1f}" text-anchor="middle" '
230
+ f'class="{cls}" font-size="12" font-weight="600">{sign}{esc(unit)}</text>'
231
+ )
232
+ parts.append(
233
+ f'<text x="{x + bw/2:.1f}" y="{base + 16}" text-anchor="middle" '
234
+ f'class="f-txt3" font-size="11">{esc(labels[idx])}</text>'
235
+ )
236
+ # connector
237
+ if i < len(mids):
238
+ parts.append(
239
+ f'<line x1="{x - (step - bw)/2:.1f}" y1="{ya:.1f}" '
240
+ f'x2="{x:.1f}" y2="{ya:.1f}" class="s-bds" stroke-width="1" stroke-dasharray="3 3"/>'
241
+ )
242
+ prev = nxt
243
+ # end bar
244
+ x = left + (n - 1) * step + (step - bw) / 2
245
+ ye = y_of(end)
246
+ parts.append(
247
+ f'<rect data-anim="grow" x="{x:.1f}" y="{ye:.1f}" width="{bw:.1f}" '
248
+ f'height="{base - ye:.1f}" rx="5" class="f-acc"/>'
249
+ )
250
+ parts.append(
251
+ f'<text x="{x + bw/2:.1f}" y="{ye - 8:.1f}" text-anchor="middle" '
252
+ f'class="f-txt2" font-size="12" font-weight="600">{end:g}{esc(unit)}</text>'
253
+ )
254
+ parts.append(
255
+ f'<text x="{x + bw/2:.1f}" y="{base + 16}" text-anchor="middle" '
256
+ f'class="f-txt3" font-size="11">{esc(labels[-1])}</text>'
257
+ )
258
+ return ('<svg class="chart" data-chart="waterfall" viewBox="0 0 560 220">\n '
259
+ + '\n '.join(parts) + '\n </svg>')
260
+
261
+
262
+ def build_svg(chart: dict) -> str:
263
+ ct = (chart.get('type') or 'bar').lower()
264
+ labels = chart.get('labels') or []
265
+ values = chart.get('values') or []
266
+ unit = chart.get('unit') or ''
267
+ max_v = chart.get('max')
268
+ if ct == 'hbar':
269
+ return svg_hbar(labels, values, max_v, unit)
270
+ if ct == 'line':
271
+ return svg_line(labels, values, max_v, unit, area=False)
272
+ if ct == 'area':
273
+ return svg_line(labels, values, max_v, unit, area=True)
274
+ if ct == 'donut' or ct == 'pie':
275
+ return svg_donut(labels, values, chart.get('centerLabel') or '合计', unit)
276
+ if ct == 'waterfall':
277
+ return svg_waterfall(labels, values, unit)
278
+ return svg_bar(labels, values, max_v, unit)
279
+
280
+
281
+ def iter_chart_payloads(model: dict):
282
+ """Yield chart dicts in document order (sections + split sides)."""
283
+ for sec in model.get('sections') or []:
284
+ st = (sec.get('type') or '').lower()
285
+ if st in ('bar', 'donut', 'exhibit', 'halftable') and sec.get('chart'):
286
+ ch = dict(sec['chart'])
287
+ if st == 'donut':
288
+ ch.setdefault('type', 'donut')
289
+ elif not ch.get('type'):
290
+ ch['type'] = 'bar'
291
+ yield ch
292
+ for side in ('left', 'right'):
293
+ el = sec.get(side)
294
+ if isinstance(el, dict) and el.get('labels') and el.get('values') is not None:
295
+ ch = {
296
+ 'type': el.get('type') or 'bar',
297
+ 'labels': el.get('labels'),
298
+ 'values': el.get('values'),
299
+ 'unit': el.get('unit') or '',
300
+ 'max': el.get('max'),
301
+ 'centerLabel': el.get('centerLabel'),
302
+ }
303
+ yield ch
304
+
305
+
306
+ def hydrate(html: str, model: dict) -> str:
307
+ payloads = list(iter_chart_payloads(model))
308
+ idx = 0
309
+
310
+ def repl(m):
311
+ nonlocal idx
312
+ if idx >= len(payloads):
313
+ return m.group(0)
314
+ svg = build_svg(payloads[idx])
315
+ idx += 1
316
+ return svg
317
+
318
+ new_html, n = PLACEHOLDER_SVG_RE.subn(repl, html)
319
+ return new_html, n, idx, len(payloads)
320
+
321
+
322
+ def main() -> int:
323
+ if len(sys.argv) < 2:
324
+ print('usage: _hydrate_charts.py <report.html>')
325
+ return 2
326
+ path = Path(sys.argv[1])
327
+ html = path.read_text(encoding='utf-8')
328
+ m = MODEL_RE.search(html)
329
+ if not m:
330
+ print('FAIL: no REPORT_MODEL')
331
+ return 1
332
+ model = json.loads(m.group(1))
333
+ new_html, n_ph, used, total = hydrate(html, model)
334
+ path.write_text(new_html, encoding='utf-8')
335
+ left = new_html.count('见模型数据')
336
+ print(f'hydrated placeholders={n_ph} used_payloads={used}/{total} remaining_placeholder={left}')
337
+ return 0 if left == 0 else 1
338
+
339
+
340
+ if __name__ == '__main__':
341
+ raise SystemExit(main())