@topmindspace/tms-skills 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +9 -3
  3. package/bin/tms-skills.js +200 -160
  4. package/package.json +12 -9
  5. package/top-ppt-html/README.md +162 -254
  6. package/top-ppt-html/SKILL.md +126 -111
  7. package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.html +3926 -3926
  8. package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.model.json +167 -167
  9. package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.html +4325 -4325
  10. package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.model.json +320 -320
  11. package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.html +5527 -5527
  12. package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.model.json +913 -913
  13. package/top-ppt-html/assets/pptx-export.js +1944 -1944
  14. package/top-ppt-html/assets/style-gallery.html +589 -589
  15. package/top-ppt-html/assets/templates/architecture.html +3728 -3728
  16. package/top-ppt-html/assets/templates/engine.css +840 -840
  17. package/top-ppt-html/assets/templates/presentation.html +3738 -3738
  18. package/top-ppt-html/assets/templates/research.html +4017 -4017
  19. package/top-ppt-html/assets/templates/ui.js +520 -520
  20. package/top-ppt-html/assets/theme-overview-architecture.png +0 -0
  21. package/top-ppt-html/assets/theme-overview-research.png +0 -0
  22. package/top-ppt-html/assets/theme-overview.png +0 -0
  23. package/top-ppt-html/evals/prompts.csv +15 -15
  24. package/top-ppt-html/evals/rubric.schema.json +25 -25
  25. package/top-ppt-html/evals/run_evals.py +220 -220
  26. package/top-ppt-html/evals/trace.example.json +16 -16
  27. package/top-ppt-html/package-lock.json +186 -0
  28. package/top-ppt-html/package.json +11 -11
  29. package/top-ppt-html/references/charts-basic.md +624 -624
  30. package/top-ppt-html/references/charts-discipline.md +110 -108
  31. package/top-ppt-html/references/charts-extended.md +482 -482
  32. package/top-ppt-html/references/charts.md +37 -28
  33. package/top-ppt-html/references/components-atoms.md +624 -624
  34. package/top-ppt-html/references/components.md +30 -30
  35. package/top-ppt-html/references/content-rules.md +510 -490
  36. package/top-ppt-html/references/default-surface.md +61 -0
  37. package/top-ppt-html/references/design-system.md +478 -478
  38. package/top-ppt-html/references/failure-modes.md +227 -214
  39. package/top-ppt-html/references/high-fidelity.md +127 -127
  40. package/top-ppt-html/references/icons.md +176 -397
  41. package/top-ppt-html/references/infographics-stats.md +308 -308
  42. package/top-ppt-html/references/infographics-structure.md +226 -226
  43. package/top-ppt-html/references/infographics.md +43 -43
  44. package/top-ppt-html/references/layout-grammar.md +325 -315
  45. package/top-ppt-html/references/layouts-architecture.md +108 -108
  46. package/top-ppt-html/references/layouts-combo.md +600 -600
  47. package/top-ppt-html/references/layouts-research.md +160 -160
  48. package/top-ppt-html/references/modes.md +259 -254
  49. package/top-ppt-html/references/outline-design.md +275 -275
  50. package/top-ppt-html/references/playbook.md +284 -266
  51. package/top-ppt-html/references/pptx-export.md +227 -209
  52. package/top-ppt-html/references/presentation-craft.md +69 -0
  53. package/top-ppt-html/references/styles.md +370 -370
  54. package/top-ppt-html/references/tech-design.md +138 -138
  55. package/top-ppt-html/scripts/audit_css.py +109 -109
  56. package/top-ppt-html/scripts/audit_docs.py +176 -176
  57. package/top-ppt-html/scripts/audit_skill.py +231 -220
  58. package/top-ppt-html/scripts/audit_styles.py +351 -351
  59. package/top-ppt-html/scripts/build_examples.py +55 -2276
  60. package/top-ppt-html/scripts/build_pptx.js +2386 -2380
  61. package/top-ppt-html/scripts/capture_theme_overview.js +79 -78
  62. package/top-ppt-html/scripts/checks_html.py +166 -127
  63. package/top-ppt-html/scripts/cross_verify.py +298 -294
  64. package/top-ppt-html/scripts/env_probe.py +158 -158
  65. package/top-ppt-html/scripts/extract_model.py +210 -210
  66. package/top-ppt-html/scripts/extract_snippet.py +374 -374
  67. package/top-ppt-html/scripts/gen_channel_a.js +214 -214
  68. package/top-ppt-html/scripts/layout-constants.json +111 -18
  69. package/top-ppt-html/scripts/lib_layout_regions.js +410 -412
  70. package/top-ppt-html/scripts/measure_height.py +178 -178
  71. package/top-ppt-html/scripts/negative_tests.py +433 -307
  72. package/top-ppt-html/scripts/package_skill.py +294 -291
  73. package/top-ppt-html/scripts/prepare_images.py +341 -341
  74. package/top-ppt-html/scripts/probe_image_export.py +187 -188
  75. package/top-ppt-html/scripts/quality_gate.py +309 -301
  76. package/top-ppt-html/scripts/recommend_layout.py +384 -0
  77. package/top-ppt-html/scripts/regression.py +308 -308
  78. package/top-ppt-html/scripts/render_compare.py +275 -275
  79. package/top-ppt-html/scripts/render_from_model.py +698 -698
  80. package/top-ppt-html/scripts/scaffold_report.py +1054 -1054
  81. package/top-ppt-html/scripts/section-file-map.json +104 -104
  82. package/top-ppt-html/scripts/smoke_pptx.sh +30 -0
  83. package/top-ppt-html/scripts/sync_runtime.py +659 -662
  84. package/top-ppt-html/scripts/validate_pptx.py +1579 -1510
  85. package/top-ppt-html/scripts/validate_report.py +1755 -1456
  86. package/top-ppt-html/assets/examples/2026-09-09-architecture-spectrum.html +0 -3926
  87. package/top-ppt-html/assets/examples/2026-09-09-architecture-spectrum.model.json +0 -168
  88. package/top-ppt-html/assets/examples/2026-09-09-presentation-apple-mono.html +0 -4325
  89. package/top-ppt-html/assets/examples/2026-09-09-presentation-apple-mono.model.json +0 -321
  90. package/top-ppt-html/assets/examples/2026-09-09-presentation-brand-red.html +0 -4325
  91. package/top-ppt-html/assets/examples/2026-09-09-presentation-brand-red.model.json +0 -321
  92. package/top-ppt-html/assets/examples/2026-09-09-research-deep-teal.html +0 -5527
  93. package/top-ppt-html/assets/examples/2026-09-09-research-deep-teal.model.json +0 -914
  94. package/top-ppt-html/assets/examples/2026-09-09-research-indigo-violet.html +0 -5527
  95. package/top-ppt-html/assets/examples/2026-09-09-research-indigo-violet.model.json +0 -914
  96. package/top-ppt-html/assets/examples/2026-09-09-research-warm-sand.html +0 -5527
  97. package/top-ppt-html/assets/examples/2026-09-09-research-warm-sand.model.json +0 -914
  98. package/top-ppt-html/assets/theme-overview-presentation.png +0 -0
  99. package/top-ppt-html/references/design-system-engine.md +0 -235
  100. package/top-ppt-html/references/industry-benchmark.md +0 -105
  101. package/top-ppt-html/references/reform-plan.md +0 -252
  102. package/top-ppt-html/scripts/layout_slots.json +0 -830
@@ -1,2380 +1,2386 @@
1
- /**
2
- * TopPPT HTML· PPTX 精导(基于 PptxGenJS)
3
- *
4
- * 作用:把报告的「内容模型」(window.REPORT_MODEL,由 extract_model.py 抽取)导出为
5
- * 16:9 可编辑 PPTX(封面 / Agenda(architecture 可省)/ 章节页 / 收尾),与页面预览
6
- * (assets/pptx-export.js,同一序列化语义)消费同一份 layout-constants.json 常量单源,
7
- * 产出一致。正式 PPTX 一律走本通道(页面不导出文件),strict 0/0 才交付。
8
- *
9
- * 用法:
10
- * 1) 抽模型:python scripts/extract_model.py "报告.html"(生成 报告.model.json)
11
- * 2) 生成:node scripts/build_pptx.js 报告.pptx --model="报告.model.json"
12
- * (可选 --style=<风格> / --theme=light|dark 覆盖模型值)
13
- * 3) 质检(硬门禁):python scripts/validate_pptx.py 报告.pptx --strict --model="报告.model.json"
14
- *
15
- * 章节页型(sections[].type,字段约束单源 scripts/model-schema.json,共 29 种):
16
- * points 要点列表 + 可选右侧指标列(默认)
17
- * metrics 大指标带(4–6 个核心数字)
18
- * kpi 大数指标页(hero=[值,标签,delta?] 巨号数字 + 右列支撑指标)
19
- * table 对比表(head + rows)
20
- * timeline 路线/阶段(phases: [label, 名称, 说明, done|now|''])
21
- * steps 步骤条(steps: [[标题,说明]…],可选 groups 分组、accent 高亮步)
22
- * bar 数据图表页(chart.type 见 model-schema.json chartTypes:30 类)
23
- * donut 环形图页(原生数据图表 + 挖孔 + 中心合计 + 右侧数值图例)
24
- * heatmap 热力矩阵(rowHeads × colHeads + cells 数值,4 级色阶)
25
- * bullet 达成对比(items: [[标签,实际,目标]…],底槽 + 实际条 + 目标刻度)
26
- * pyramid 金字塔(levels: [[标题,说明]…] 自上而下逐层加宽)
27
- * image 素材图片页(image:{src|items|placeholder, layout:full|half|bleed|grid|compare|wall,
28
- * fit:cover|contain, caption};src 仅 data:/相对路径;无图走原生配图占位)
29
- * cards 卡片网格
30
- * split 左文右图(left.points + right: chart/table/image;图表字段挂在 right 上)
31
- * comparison 对比页(left/right 双面板 + 可选 verdict 结论条)
32
- * quote 引用/金句页(主题一致强调带:accent-soft 底,浅色模式不再出深色页)
33
- * diagram 分层架构(layers + legend,含层间连接;architecture 模式走全幅图页型)
34
- * exhibit Exhibit 编号图表页(research:exhibitNo + chart + soWhat + footnote)
35
- * twocol 双栏论证页(research R1)
36
- * threecol 三栏证据页(research R6)
37
- * halftable 半表半图页(research R7:table + chart 对照)
38
- * matrix 矩阵图页(research R8:rowHeads × colHeads + cells)
39
- * lane 泳道页(architecture A2:lanes + steps)
40
- * ── 复杂信息图专属页型(结构化载荷,不走 chart.type)──
41
- * sankey 桑基图页(flows:[[源,汇,值]…],分层节点 + 流带;≥16 采样点、双边界追踪)
42
- * treemap 树图页(items:[[标签,值]…],面积 ∝ 数值)
43
- * boxplot 箱线图页(groups:[[标签,min,q1,中位,q3,max]…])
44
- * network 关系网络页(nodes:[[id,名称]…] + edges:[[源,汇]…],确定性环形布局)
45
- * marimekko 马赛克图页(cols + cells + legend,列宽 × 列高双重编码)
46
- * streamgraph 流带图页(series + labels,基线居中,≥24 采样点)
47
- * 通用可选字段:soWhat(so-what 结论条)、footnote(页脚来源行)、
48
- * flags(待核实标注清单 → accent 强调色清单条,提示用户二次确认)、image(素材图片)、
49
- * exhibitNo(非 exhibit 页型也可带 Exhibit 帧头徽标,正文区自动下移让位)
50
- *
51
- * 图表双通道(charts.registry 四元组为唯一分派依据):
52
- * 原生 16 类走 pptxgenjs addChart(chart part + 内嵌 Excel,双击可编辑数据);
53
- * 形状 20 类走高保真形状还原(曲线用多段旋转矩形沿采样路径逼近,禁 preset 形状替代),
54
- * 并按 chart.dataTable 策略附数据表(notes 默认 / inline 页内表格)——数据可追溯。
55
- *
56
- * 版式纪律:所有几何常量取自 layout-constants.json(禁止在此手写坐标);
57
- * 文本块按可用高度自适应字号/行距(fitFont),杜绝溢出——这是「每页高度稳定」的保障。
58
- */
59
-
60
- /* eslint-disable */
61
- const pptxgen = require('pptxgenjs');
62
- const fs = require('fs');
63
- const path = require('path');
64
-
65
- /* ══════════ 版式常量单源(scripts/layout-constants.json · 两通道共用,禁止在此手写常量) ══════════ */
66
- const LC = JSON.parse(fs.readFileSync(path.join(__dirname, 'layout-constants.json'), 'utf-8'));
67
- const STYLE_PRESETS = LC.styles; // 9 套风格 token(light)
68
- const STYLE_PRESETS_DARK = LC.stylesDark || {}; // 9 套风格 token(dark)
69
- const STYLE_DATA_COLORS = LC.styleDataColors || {}; // 9 套风格各自的编码色板(light)
70
- const STYLE_DATA_COLORS_DARK = LC.styleDataColorsDark || {}; // 深色主题版(按 model.theme 选择)
71
- const { pw: PW, ph: PH, mx: MX } = LC.page; // 16:9,页边距
72
- const CW = PW - 2 * MX;
73
- const TS_BASE = LC.typeScale; // 排版比例尺基准(presentation pt)
74
- const MTS = LC.modeTypeScale; // 三模式独立排版比例尺
75
- const PT = LC.pageTypes; // 页型几何(含 layout / cover / closing / steps / heatmap / bullet / pyramid …)
76
- const IMG = LC.imageSpec || {}; // 图片规格与配图占位约定(双引擎同源)
77
- const LAY = PT.layout || {};
78
- const CONTENT_TOP = LAY.contentTop != null ? LAY.contentTop : 2.5;
79
- const CONTENT_BOTTOM = LAY.contentBottom != null ? LAY.contentBottom : 6.9;
80
- const CONTENT_BOTTOM_NOTE = LAY.contentBottomWithNote != null ? LAY.contentBottomWithNote : 6.4;
81
- /* 页型布局区域 IR:语义槽位 → 英寸矩形(与 layout_slots.json 同源;未识别槽位时回落 PT 常量) */
82
- const REGIONS = require('./lib_layout_regions.js');
83
-
84
- const modelArg = (process.argv.find(a => a.startsWith('--model=')) || '').split('=')[1];
85
- let styleArg = (process.argv.find(a => a.startsWith('--style=')) || '').split('=')[1];
86
- let themeArg = (process.argv.find(a => a.startsWith('--theme=')) || '').split('=')[1];
87
- let STYLE; // 在内容模型加载后确定(模型可带默认 style)
88
- let THEME = 'light'; // 亮/暗主题(--theme= 覆盖 > 模型 theme > 默认 light)
89
- let MODEL_DIR = null; // 模型文件所在目录(素材图片相对路径以此为锚)
90
-
91
- let MODE_NAME = 'presentation'; // 当前模式(加载模型后设置)
92
- /* 三模式独立排版比例尺:基准 pt → 最接近 TS_BASE 角色 → MTS[mode][role]。
93
- research 是咨询密排比例尺(正文 10.5pt),不再是简单 ×0.8。保留 0.5pt 精度。
94
- 未知模式回落 presentation 档(MTS 与 mode 取值同源于 model-schema.json 的 modes)。 */
95
- const sz = (pt) => {
96
- const mts = (MTS && (MTS[MODE_NAME] || MTS.presentation)) || null;
97
- if (!mts) return Math.max(8.5, Math.round(pt * 2) / 2);
98
- let best = null, bd = 1e9;
99
- for (const k of Object.keys(TS_BASE)) {
100
- const d = Math.abs(TS_BASE[k] - pt);
101
- if (d < bd) { bd = d; best = mts[k]; }
102
- }
103
- return Math.max(8.5, Math.round((best == null ? pt : best) * 2) / 2);
104
- };
105
- /* 编码色板:9 套风格**各有自己的** c1–c5(单源 styleDataColors / styleDataColorsDark)。
106
- 与 HTML 侧的 .f-c1~c5 语义类同源;结构色纪律不受影响——本表只用于数据系列。 */
107
- const _dcWarned = new Set();
108
- function dataColors(style) {
109
- const map = (THEME === 'dark' && Object.keys(STYLE_DATA_COLORS_DARK).length)
110
- ? STYLE_DATA_COLORS_DARK : STYLE_DATA_COLORS;
111
- const c = map[style];
112
- if ((!c || !c.length) && !_dcWarned.has(style)) {
113
- _dcWarned.add(style);
114
- /* 静默回落到单色会让多系列图表失去可区分性,但产物照样"合格"——必须出声 */
115
- console.warn(`[build_pptx] 风格 "${style}" 缺${THEME === 'dark' ? '深色' : ''}编码色板 c1–c5,` +
116
- '多系列图表将回落单色梯度(建议补 layout-constants.json 的 ' +
117
- (THEME === 'dark' ? 'styleDataColorsDark' : 'styleDataColors') + ')。');
118
- }
119
- return (c && c.length) ? c : null;
120
- }
121
- /* 单色风格的数据色板回落(多扇区/多系列页型) */
122
- function donutColors(style) {
123
- return dataColors(style) || [STYLE.accent, STYLE.faint, STYLE.body, STYLE.line];
124
- }
125
-
126
- /* ══════════ 自适应排版工具(保证内容不溢出页面 —— 每页高度稳定的 PPTX 侧保障) ══════════ */
127
- /* 文本度量单源:validate_pptx.py 的溢出估算读同一份,两侧同值才不会出现「引擎装得下、门禁判溢出」或反之 */
128
- const TM = (LC.containers && LC.containers.textMetrics) || {};
129
- const TM_LINE = TM.lineFactor != null ? TM.lineFactor : 1.45;
130
- const TM_ASCII = TM.emAsciiRatio != null ? TM.emAsciiRatio : 0.52;
131
- /* 估算文本行数:CJK 按 1em,拉丁/数字按 emAsciiRatio(保守偏大,宁可早换行) */
132
- function estLines(text, widthIn, fontPt) {
133
- const usable = Math.max(24, widthIn * 72 - 8);
134
- let w = 0, lines = 1;
135
- const s = String(text == null ? '' : text);
136
- for (let i = 0; i < s.length; i++) {
137
- if (s[i] === '\n') { lines++; w = 0; continue; }
138
- const cw = /[\x00-\xff]/.test(s[i]) ? fontPt * TM_ASCII : fontPt;
139
- if (w + cw > usable) { lines++; w = cw; } else { w += cw; }
140
- }
141
- return lines;
142
- }
143
- function estTextH(text, widthIn, fontPt, lineFactor) {
144
- return estLines(text, widthIn, fontPt) * fontPt * (lineFactor || TM_LINE) / 72;
145
- }
146
- /* 有限缩字号(v8.2):优先取最大可装字号;内容偏多时允许阶梯下探。
147
- 策略(containers.fontShrink 单源):先优化内容/组合/拆页,最后才缩字号;
148
- maxShrinkSteps 限制相对首选档的下探步数,floorPt 为模式绝对下限。
149
- 装不下时仍取下限——保证不越界;调用方应配合 validate 的内容预算与拆页建议。 */
150
- const FS_POLICY = (function () {
151
- try {
152
- const raw = fs.readFileSync(path.join(__dirname, 'layout-constants.json'), 'utf-8');
153
- const p = (JSON.parse(raw).containers || {}).fontShrink || {};
154
- return {
155
- ladder: p.ladder || [15, 14, 13.5, 13, 12.5, 12, 11.5, 11, 10.5, 10, 9.5, 9, 8.5],
156
- maxShrinkSteps: p.maxShrinkSteps != null ? p.maxShrinkSteps : 4,
157
- floorPt: p.floorPt || { presentation: 10, research: 9, architecture: 10 },
158
- warnBelow: p.warnBelow != null ? p.warnBelow : 9.5,
159
- };
160
- } catch (e) {
161
- return {
162
- ladder: [15, 14, 13.5, 13, 12.5, 12, 11.5, 11, 10.5, 10, 9.5, 9, 8.5],
163
- maxShrinkSteps: 4,
164
- floorPt: { presentation: 10, research: 9, architecture: 10 },
165
- warnBelow: 9.5,
166
- };
167
- }
168
- })();
169
- const FONT_LADDER = FS_POLICY.ladder;
170
- /* 缩字号触底记录:触底 = 内容量超出该版式承载力,是「该拆页/换组合」的信号,不得无声无息 */
171
- const SHRINK_FLOOR_HITS = [];
172
- function modeFloorPt() {
173
- const m = String((CONTENT && CONTENT.mode) || MODE_NAME || 'presentation');
174
- const f = FS_POLICY.floorPt[m];
175
- return f != null ? f : 10;
176
- }
177
- function fitFont(items, widthIn, availIn, opts) {
178
- opts = opts || {};
179
- const floor = opts.floor != null ? opts.floor : modeFloorPt();
180
- const maxSteps = opts.maxShrinkSteps != null ? opts.maxShrinkSteps : FS_POLICY.maxShrinkSteps;
181
- const raw = (opts.ladder || FONT_LADDER).filter(v => v <= (opts.max || 99) && v >= floor);
182
- const lineFactor = opts.lineFactor || TM_LINE;
183
- const pad = opts.pad || 0;
184
- const needH = (f) => {
185
- let h = 0;
186
- for (const it of items) h += estTextH(it, widthIn, f, lineFactor) + f * (opts.gapFactor || 0.5) / 72;
187
- return h + pad;
188
- };
189
- /* 首选:阶梯中最大且装得下的字号 */
190
- for (const f of raw) {
191
- if (needH(f) <= availIn) return f;
192
- }
193
- /* 全装不下:在 maxShrinkSteps 内从「首选档」下探(首选=opts.max 或阶梯首档) */
194
- const preferred = raw.length ? raw[0] : floor;
195
- const prefIdx = (opts.ladder || FONT_LADDER).indexOf(preferred);
196
- const limited = (opts.ladder || FONT_LADDER)
197
- .slice(prefIdx, prefIdx + 1 + maxSteps)
198
- .filter(v => v >= floor && v <= (opts.max || 99));
199
- for (const f of limited) {
200
- if (needH(f) <= availIn) return f;
201
- }
202
- /* 仍装不下:取 limited 最小(或 floor),宁可字号偏小也不越界;内容优化由上层负责 */
203
- const bottom = limited.length ? limited[limited.length - 1] : floor;
204
- SHRINK_FLOOR_HITS.push({ pt: bottom, sample: String(items[0] == null ? '' : items[0]).slice(0, 24) });
205
- return bottom;
206
- }
207
- /* 等分并限制下限的列宽 */
208
- function cols(total, n, gap) {
209
- const w = (total - (n - 1) * (gap || 0)) / Math.max(1, n);
210
- return { w: w, step: w + (gap || 0) };
211
- }
212
- /* 行高收敛:在 [floor, pref] 内按可用高度取值。
213
- floor 是绝对硬下限(可低于几何里的软下限),保证条目数超容量时**宁可行高更小也不越界**。 */
214
- function fitRowH(avail, n, pref, floor) {
215
- const lo = (floor == null) ? 0.22 : floor;
216
- return Math.max(lo, Math.min(pref, avail / Math.max(1, n)));
217
- }
218
-
219
- /* ══════════ 内容模型 ══════════
220
- * 从 --model=<file.json> 读取(推荐:由 extract_model.py 从 HTML 报告抽取,
221
- * 保证 PPTX 与页面同源);未提供时使用下方内嵌 CONTENT 示例。 */
222
- let CONTENT;
223
- if (modelArg) {
224
- /* 模型读取失败必须显式中断:静默回落内嵌示例会产出一份「看起来正常但内容全错」的 PPTX */
225
- let raw;
226
- try {
227
- raw = fs.readFileSync(modelArg, 'utf-8');
228
- } catch (e) {
229
- console.error(`[build_pptx] FAIL: 无法读取模型文件 ${modelArg}:${e && e.message}`);
230
- console.error(' 提示:路径是否正确?模型通常由 python scripts/extract_model.py <报告.html> 生成。');
231
- process.exit(1);
232
- }
233
- try {
234
- CONTENT = JSON.parse(raw);
235
- } catch (e) {
236
- console.error(`[build_pptx] FAIL: 模型不是合法 JSON(${modelArg}):${e && e.message}`);
237
- process.exit(1);
238
- }
239
- if (!CONTENT || typeof CONTENT !== 'object' || Array.isArray(CONTENT)) {
240
- console.error(`[build_pptx] FAIL: 模型根节点必须是对象(${modelArg})。`);
241
- process.exit(1);
242
- }
243
- MODEL_DIR = path.dirname(path.resolve(modelArg)); // 素材图片相对路径锚点
244
- if (CONTENT.style && !styleArg) styleArg = CONTENT.style; // 模型里的风格作为默认
245
- } else {
246
- CONTENT = {
247
- title: '让想法被看见',
248
- subtitle: 'TopPPT HTML 报告生成示例 · 16:9 可编辑 PPTX',
249
- meta: '示例内容 · 请用 --model= 传入真实模型',
250
- mode: 'presentation',
251
- style: 'business-blue',
252
- theme: 'light',
253
- agenda: [
254
- ['01', '问题与背景', '为什么现在要谈这件事'],
255
- ['02', '现状基线', '三组数字勾勒起点'],
256
- ['03', '方案对比', '两条路径的分野'],
257
- ['04', '落地路线', '四个季度四步走'],
258
- ],
259
- sections: [
260
- {
261
- type: 'points',
262
- eyebrow: '01 · 问题与背景',
263
- title: '把想法说清楚,比把话说满更重要',
264
- lead: '一页一个主张,一屏一个结论。',
265
- points: [
266
- ['结论前置', '每页标题就是该页最重要的那句话。'],
267
- ['证据支撑', '数据、图表、案例三选一,不堆砌。'],
268
- ['细节分层', '放不下的细节沉到图注、来源行与演讲者备注。'],
269
- ],
270
- metrics: [['1', '个核心主张/页'], ['29', '种锁定页型']],
271
- },
272
- {
273
- type: 'metrics',
274
- eyebrow: '02 · 现状基线',
275
- title: '三组数字勾勒起点',
276
- lead: '指标带页兼作节奏休止页。',
277
- metrics: [['62TB', '数据规模'], ['4,662', '入湖表'], ['31%', '使用率'], ['0%', '契约覆盖']],
278
- },
279
- {
280
- type: 'bar',
281
- eyebrow: '04 · 落地路线',
282
- title: '可信数据消费率:四个季度四步走',
283
- chart: { labels: ['2026Q3', 'Q4', '2027Q1', 'Q2'], values: [30, 45, 55, 60], max: 100, unit: '%' },
284
- note: '口径:被 BI / ChatBI / Agent 调用的可信数据集占比',
285
- },
286
- // 更多页型示例见 references/pptx-export.md 的页型表与 references/`components.md` §46 选型速查
287
- ],
288
- closing: {
289
- title: '把「治数据」变成消费飞轮的一部分',
290
- points: [
291
- ['指标中心', '统一口径,BI / ChatBI / Agent 共用一套定义'],
292
- ['数据契约', '上下游变更纳入契约,消费前有 SLA'],
293
- ['治理 Agent', '清洗 / 校验 / 血缘核验自动化'],
294
- ],
295
- },
296
- };
297
- }
298
-
299
- THEME = (themeArg === 'dark' || themeArg === 'light') ? themeArg
300
- : (CONTENT.theme === 'dark' ? 'dark' : 'light');
301
- STYLE = (THEME === 'dark' ? (STYLE_PRESETS_DARK[styleArg] || STYLE_PRESETS[styleArg])
302
- : STYLE_PRESETS[styleArg]) || STYLE_PRESETS['business-blue'];
303
- MODE_NAME = CONTENT.mode || 'presentation';
304
- if (styleArg && !STYLE_PRESETS[styleArg]) {
305
- console.warn('未知风格 "' + styleArg + '",回退 business-blue。可选:' + Object.keys(STYLE_PRESETS).join(' / '));
306
- }
307
- if (THEME === 'dark' && styleArg && !STYLE_PRESETS_DARK[styleArg]) {
308
- console.warn('风格 "' + styleArg + '" 无 dark token,回退该风格 light token(建议补 layout-constants.json stylesDark)。');
309
- }
310
-
311
- /* ══════════ 生成 ══════════ */
312
- const pptx = new pptxgen();
313
- pptx.defineLayout({ name: 'W16x9', width: PW, height: PH });
314
- pptx.layout = 'W16x9';
315
- pptx.title = CONTENT.title || 'TopPPT HTML 报告';
316
- pptx.subject = CONTENT.subtitle || '';
317
- pptx.author = 'TopPPT HTML';
318
- pptx.company = 'TopMindspace';
319
-
320
- function base() {
321
- const s = pptx.addSlide();
322
- s.background = { color: STYLE.bg };
323
- s.addShape('rect', { x: 0, y: 0, w: PW, h: PH, fill: { color: STYLE.bg }, line: { type: 'none' } });
324
- return s;
325
- }
326
- function head(s, eyebrow, title, lead) {
327
- const H = REGIONS.regionOf('head', 'head') || {
328
- x: MX, y: PT.common.headEyebrowY, w: CW, h: 0.4,
329
- titleY: PT.common.headTitleY, ruleY: PT.common.headRuleY, leadY: PT.common.leadY,
330
- };
331
- if (eyebrow) s.addText(eyebrow, { x: H.x, y: H.y, w: H.w, h: 0.4, fontFace: STYLE.font,
332
- fontSize: sz(13), bold: true, color: STYLE.accent, charSpacing: 2 });
333
- /* 主标题自适应:过长时缩一档,避免压到分隔线 */
334
- const titleH = eyebrow ? (H.ruleY - H.titleY - 0.06) : 1.6 - 0.6;
335
- const tSize = estTextH(title, CW, sz(30), 1.25) > titleH ? sz(24) : sz(30);
336
- s.addText(title, { x: H.x, y: eyebrow ? H.titleY : 0.6, w: H.w, h: Math.max(0.6, titleH),
337
- fontFace: STYLE.fontDisplay, fontSize: tSize, bold: true, color: STYLE.ink });
338
- s.addShape('rect', { x: H.x, y: eyebrow ? H.ruleY : 1.6, w: 0.9, h: 0.045, fill: { color: STYLE.accent }, line: { type: 'none' } });
339
- if (lead) s.addText(lead, { x: H.x, y: H.leadY || PT.common.leadY, w: H.w, h: 0.5, fontFace: STYLE.font,
340
- fontSize: sz(14), color: STYLE.body });
341
- }
342
- function footer(s, n, total) {
343
- s.addText(`${n} / ${total}`, { x: PW - 1.4, y: LAY.pageNumY != null ? LAY.pageNumY : PH - 0.5,
344
- w: 0.9, h: 0.3, align: 'right', fontFace: STYLE.font, fontSize: sz(10), color: STYLE.faint });
345
- }
346
- /* so-what 结论条(research 通用可选件,与 HTML .sowhat 同源) */
347
- /* 容器语义名(p:cNvPr@name):供 validate_pptx 按 containers.pad 分型扣内边距。
348
- 文本框坐标已由引擎 inset,校验器不再对无名 shape 二次扣大 pad。 */
349
- function trName(kind) { return 'tr:' + kind; }
350
- /* 双形态载荷判定:[[t,d]…] 数组 vs [{t,d,accent}…] 记录。
351
- typeof [] === 'object',绝不能用 typeof 区分——否则数组形态的 t/d 被读成 undefined 静默丢字。 */
352
- function isRec(v) { return !!v && typeof v === 'object' && !Array.isArray(v); }
353
- function soWhatBar(s, text) {
354
- const R = REGIONS.regionOf('exhibit', 'annotation') ||
355
- { x: MX, y: PT.exhibit.soWhatY, w: CW, h: 0.62 };
356
- /* 长结论条在 bar 内有限缩字号(fontShrink),优先保内容完整不越出容器 */
357
- const labelW = 1.35;
358
- const bodyW = Math.max(2.5, R.w - 0.44 - labelW);
359
- const fzB = fitFont([String(text || '')], bodyW, R.h - 0.14, { max: 12.5, gapFactor: 0.2, maxShrinkSteps: 3 });
360
- s.addShape('rect', { x: R.x, y: R.y, w: R.w, h: R.h, fill: { color: STYLE.soft }, line: { type: 'none' }, objectName: trName('soWhat') });
361
- s.addShape('rect', { x: R.x, y: R.y, w: 0.06, h: R.h, fill: { color: STYLE.accent }, line: { type: 'none' } });
362
- s.addText([
363
- { text: 'SO WHAT ', options: { fontSize: sz(11), bold: true, color: STYLE.accent, charSpacing: 1.5 } },
364
- { text: text, options: { fontSize: sz(fzB), color: STYLE.ink } },
365
- ], { x: R.x + 0.22, y: R.y + 0.06, w: R.w - 0.44, h: R.h - 0.12, fontFace: STYLE.font, valign: 'middle', objectName: trName('soWhat') });
366
- }
367
- function footnoteLine(s, text) {
368
- const R = REGIONS.regionOf('exhibit', 'footnote') ||
369
- { x: MX, y: PT.exhibit.footnoteY, w: CW - 1.2, h: 0.32 };
370
- s.addText(text, { x: R.x, y: R.y, w: R.w, h: R.h,
371
- fontFace: STYLE.font, fontSize: sz(9.5), color: STYLE.faint, objectName: trName('footnote') });
372
- }
373
- /* 待核实清单条(accent 强调色标注 · 与 HTML .flagbar 同源)
374
- * 底对齐 contentBottom,正文区在调用点按 flagH 让位。 */
375
- function flagBar(s, items, yTop) {
376
- const F = PT.flagbar || { hdH: 0.26, rowH: 0.24, maxRows: 3, gap: 0.12 };
377
- const list = (items || []).slice(0, F.maxRows).map(String);
378
- if (!list.length) return 0;
379
- const h = F.hdH + list.length * F.rowH + 0.06;
380
- const y = (yTop != null) ? yTop : (CONTENT_BOTTOM - h);
381
- s.addShape('rect', { x: MX, y, w: CW, h, fill: { color: STYLE.soft }, line: { type: 'none' }, objectName: trName('band') });
382
- s.addShape('rect', { x: MX, y, w: 0.055, h, fill: { color: STYLE.accent }, line: { type: 'none' } });
383
- s.addText('待核实 · 需二次确认', { x: MX + 0.22, y: y + 0.02, w: CW - 0.44, h: F.hdH,
384
- fontFace: STYLE.font, fontSize: sz(9.5), bold: true, color: STYLE.accent, charSpacing: 1, objectName: trName('band') });
385
- list.forEach((t, i) => {
386
- s.addText('· ' + t, { x: MX + 0.24, y: y + F.hdH + i * F.rowH, w: CW - 0.5, h: F.rowH,
387
- fontFace: STYLE.font, fontSize: sz(9.5), color: STYLE.body, valign: 'middle', objectName: trName('band') });
388
- });
389
- return h;
390
- }
391
- /* 素材图片 / 配图占位(src 仅允许 data: 内联或相对路径;fit = cover 裁切填满 / contain 完整显示)。
392
- 无图或显式 placeholder 时**不静默跳过**,改画原生占位框(圆角矩形 + 虚线 + 居中标签文本)——
393
- 占位符是原生形状而非图片,pictures 不增,且标签串与 A 通道逐字一致(cross_verify 逐页比对)。 */
394
- function imgPlaceholderLabel(layout, multi) {
395
- const label = IMG.placeholderLabel || '配图占位';
396
- if (multi) return label;
397
- const size = (IMG.recommendedSizePx || {})[layout];
398
- if (!size) return label;
399
- return label + ' · ' + (IMG.placeholderHintPrefix || '建议 ') + size + (IMG.placeholderHintSuffix || 'px');
400
- }
401
- function addImageEl(s, src, x, y, w, h, radius, opts) {
402
- opts = opts || {};
403
- const ph = !!opts.placeholder || !src;
404
- if (ph) {
405
- s.addShape('roundRect', { x, y, w, h, rectRadius: radius || 0.09,
406
- fill: { color: STYLE.surface }, line: { color: STYLE.line, width: 1, dashType: 'dash' } });
407
- s.addText(imgPlaceholderLabel(opts.layout || 'full', !!opts.multi),
408
- { x: x + 0.08, y: y + h / 2 - 0.22, w: Math.max(0.4, w - 0.16), h: 0.44,
409
- fontFace: STYLE.font, fontSize: sz(11), bold: true, color: STYLE.faint,
410
- align: 'center', valign: 'middle' });
411
- return;
412
- }
413
- const fit = String(opts.fit || IMG.fitDefault || 'cover').toLowerCase() === 'contain' ? 'contain' : 'cover';
414
- const o = { x, y, w, h, sizing: { type: fit, w, h } };
415
- if (/^data:/i.test(src)) {
416
- o.data = src;
417
- } else {
418
- const resolved = resolveImagePath(src);
419
- /* 写出阶段才会读盘——路径不存在必须在此拦截,否则 pptxgenjs write 时 ENOENT 中断交付 */
420
- let exists = false;
421
- try { exists = fs.existsSync(resolved); } catch (e) { exists = false; }
422
- if (!exists) {
423
- console.warn(`[build_pptx] 图片路径不存在,已回落占位框: ${String(src).slice(0, 60)}`);
424
- addImageEl(s, '', x, y, w, h, radius, { placeholder: true, layout: opts.layout, multi: opts.multi });
425
- return;
426
- }
427
- o.path = resolved;
428
- }
429
- if (radius) o.rounding = true;
430
- try { s.addImage(o); }
431
- catch (e) {
432
- delete o.rounding;
433
- try { s.addImage(o); }
434
- catch (e2) {
435
- /* 图片不可读(路径失效 / 数据损坏)→ 回落原生占位框,不留空洞、不阻断交付 */
436
- console.warn(`[build_pptx] 图片不可读,已回落占位框: ${src.slice(0, 60)}`);
437
- addImageEl(s, '', x, y, w, h, radius, { placeholder: true, layout: opts.layout, multi: opts.multi });
438
- }
439
- }
440
- }
441
- /* 相对路径解析:pptxgenjs 的 image.path 按 process.cwd() 解析,跨目录调用会失败。
442
- 这里以**模型文件所在目录**为锚(模型与报告同目录,报告里的相对路径即相对该目录),
443
- 再回落到 CWD——保证 `--model=some/dir/x.model.json` 时图片仍能按报告口径找到。 */
444
- function resolveImagePath(src) {
445
- const raw = String(src || '');
446
- if (!raw) return raw;
447
- if (path.isAbsolute(raw)) return raw;
448
- const cands = [];
449
- if (MODEL_DIR) cands.push(path.resolve(MODEL_DIR, raw));
450
- cands.push(path.resolve(process.cwd(), raw));
451
- for (const c of cands) {
452
- try { if (fs.existsSync(c)) return c; } catch (e) { /* ignore */ }
453
- }
454
- return cands[0] || raw;
455
- }
456
- /* 版式比例(imageSpec.ratioDefault 或 image.ratio,形如 "3:1")→ 数值宽高比。
457
- 双引擎同源:HTML 用同比例的 .media--r* 锁定类,PPTX 用本函数算高度,版式才一致。 */
458
- function imgRatio(layout, img) {
459
- const raw = String((img && img.ratio) || (IMG.ratioDefault || {})[layout] || '3:1');
460
- const m = raw.match(/^\s*(\d+(?:\.\d+)?)\s*[:/×x]\s*(\d+(?:\.\d+)?)/);
461
- if (!m) return 3;
462
- const a = parseFloat(m[1]), b = parseFloat(m[2]);
463
- return (a > 0 && b > 0) ? a / b : 3;
464
- }
465
- /* 图片版式族(与 HTML .media / .media-grid / .media-compare / .media-wall 同源):
466
- full 版心全宽|half 左图右注|bleed 通栏出血|grid 多图网格|compare 双图对比|wall Logo 墙。
467
- 每版式按 imageSpec.ratioDefault 锁定比例;空间不足时按可用高度收敛并**垂直居中**。
468
- 返回图注基准 Y。占位符走原生形状(pictures 不增)。 */
469
- function imageLayoutShapes(s, layout, img, items, points, isPh, fit, y0, y1) {
470
- const IM = PT.image || {};
471
- const gap = IM.gridGap || 0.16;
472
- const iy = y0;
473
- const ih = Math.max(1.0, y1 - iy);
474
- let capY = iy + ih;
475
- const ratio = imgRatio(layout, img);
476
- const one = (x, y, w, h, it, multi) => {
477
- addImageEl(s, (it && it.src) || '', x, y, w, h, IM.radius,
478
- { placeholder: isPh || !(it && it.src), layout, multi, fit });
479
- };
480
- if (layout === 'grid' && items.length) {
481
- const n = Math.min(items.length, IMG.maxPerPage || 6);
482
- const colsN = (IM.gridCols || {})[String(n)] || Math.min(3, n);
483
- const rowsN = Math.max(1, Math.ceil(n / colsN));
484
- const capH = IM.gridCapH || 0.28;
485
- const cellW = (CW - (colsN - 1) * gap) / colsN;
486
- const capRows = rowsN * (capH + 0.04);
487
- const cellH = Math.min(cellW / ratio, Math.max(0.5, (ih - (rowsN - 1) * gap - capRows) / rowsN));
488
- const blockH = rowsN * cellH + (rowsN - 1) * gap + capRows;
489
- const gy = iy + Math.max(0, (ih - blockH) / 2);
490
- items.slice(0, n).forEach((it, i) => {
491
- const cx = MX + (i % colsN) * (cellW + gap);
492
- const cy = gy + Math.floor(i / colsN) * (cellH + gap + capH + 0.04);
493
- const cap = (it && it.caption) || '';
494
- one(cx, cy, cellW, cellH, it, true);
495
- if (cap) s.addText(cap, { x: cx, y: cy + cellH + 0.02, w: cellW, h: capH,
496
- fontFace: STYLE.font, fontSize: sz(10), color: STYLE.faint, align: 'center' });
497
- });
498
- capY = gy + blockH;
499
- } else if (layout === 'compare' && items.length >= 2) {
500
- const cgap = IM.compareGap || 0.3;
501
- const cw2 = (CW - cgap) / 2;
502
- const capH2 = 0.34;
503
- const ih2 = Math.min(cw2 / ratio, Math.max(0.5, ih - capH2));
504
- const cy2 = iy + Math.max(0, (ih - ih2 - capH2) / 2);
505
- [0, 1].forEach((i) => {
506
- const it = items[i] || {};
507
- const cx2 = MX + i * (cw2 + cgap);
508
- one(cx2, cy2, cw2, ih2, it, true);
509
- const cap2 = it.caption || '';
510
- if (cap2) s.addText(cap2, { x: cx2, y: cy2 + ih2 + 0.02, w: cw2, h: 0.3,
511
- fontFace: STYLE.font, fontSize: sz(10), color: STYLE.faint, align: 'center' });
512
- });
513
- capY = cy2 + ih2 + capH2;
514
- } else if (layout === 'wall' && items.length) {
515
- const wgap = IM.wallGap || 0.18;
516
- const wn = Math.min(items.length, 12);
517
- const rowsW = Math.max(1, Math.min(IM.wallMaxRows || 2, Math.ceil(wn / 6)));
518
- const perRow = Math.max(1, Math.ceil(wn / rowsW));
519
- const cellW2 = (CW - (perRow - 1) * wgap) / perRow;
520
- const cellH2 = Math.min(IM.wallCellH || 0.92, cellW2 / ratio,
521
- Math.max(0.4, (ih - (rowsW - 1) * wgap) / rowsW));
522
- const blockH2 = rowsW * cellH2 + (rowsW - 1) * wgap;
523
- const wy = iy + Math.max(0, (ih - blockH2) / 2);
524
- items.slice(0, wn).forEach((it, i) => {
525
- const cx3 = MX + (i % perRow) * (cellW2 + wgap);
526
- const cy3 = wy + Math.floor(i / perRow) * (cellH2 + wgap);
527
- one(cx3, cy3, cellW2, cellH2, it, true);
528
- });
529
- capY = wy + blockH2;
530
- } else if (layout === 'half') {
531
- const iw = CW * (IM.halfW || 0.56);
532
- const ihh = Math.min(iw / ratio, ih);
533
- one(MX, iy, iw, ihh, img, false);
534
- const rxI = MX + iw + CW * (IM.halfGap || 0.06);
535
- const rwI = CW - iw - CW * (IM.halfGap || 0.06);
536
- const ptsI = points || [];
537
- const rowHI = fitRowH(ih - 0.1, ptsI.length, IM.noteRowH || 0.62, 0.3);
538
- const fzI = fitFont(ptsI.map(p => (p[0] || '') + (p[1] || '')), rwI - 0.3, ih, { max: 14, gapFactor: 0.6 });
539
- const fz = sz(fzI), fzSm = sz(fzI - 1);
540
- ptsI.forEach((pt, i) => {
541
- const lyI = iy + i * rowHI;
542
- s.addShape('rect', { x: rxI, y: lyI + rowHI / 2 - 0.06, w: 0.12, h: 0.12,
543
- fill: { color: STYLE.accent }, line: { type: 'none' } });
544
- s.addText([
545
- { text: (pt[0] || '') + ' ', options: { fontSize: fz, bold: true, color: STYLE.ink } },
546
- { text: pt[1] || '', options: { fontSize: fzSm, color: STYLE.body } },
547
- ], { x: rxI + 0.26, y: lyI, w: rwI - 0.3, h: rowHI - 0.04, fontFace: STYLE.font, valign: 'top' });
548
- });
549
- capY = iy + Math.max(ihh, ih * 0.5);
550
- } else if (layout === 'bleed') {
551
- const bh = Math.min(ih, PW / ratio);
552
- const by = iy + Math.max(0, (ih - bh) / 2);
553
- one(0, by, PW, bh, img, false);
554
- capY = by + bh;
555
- } else {
556
- const fh = Math.min(ih, CW / ratio);
557
- const fy = iy + Math.max(0, (ih - fh) / 2);
558
- one(MX, fy, CW, fh, img, false);
559
- capY = fy + fh;
560
- }
561
- return capY;
562
- }
563
- /* 数据图表上下文(供备注与自适应使用) */
564
- function chartBottom(hasSoWhat, hasFootnote) {
565
- if (hasFootnote) return PT.exhibit.footnoteY - 0.12;
566
- if (hasSoWhat) return PT.exhibit.soWhatY - 0.12;
567
- return CONTENT_BOTTOM;
568
- }
569
-
570
- /* ═══ 原生数据图表(pptxgenjs addChart → 真 chart part + 内嵌 Excel 工作簿)═══
571
- * PowerPoint / WPS 中双击图表即可"编辑数据"——带数据的图表一律走此通道,不再用形状拼图。
572
- * 几何取自 layout-constants 页型几何;单系列按数据点着色(峰值 accent、其余中性)。
573
- * chart.type: bar(默认竖柱)/ hbar(横条)/ line / dualline / area / stack / donut
574
- * chart.series(可选):[{name, values}…] 多系列(dualline / stack 用) */
575
- function chartSeries(c) {
576
- const labels = (c.labels || []).map(String);
577
- if (Array.isArray(c.series) && c.series.length) {
578
- return c.series.map((se, i) => ({
579
- name: se.name || ('系列' + (i + 1)),
580
- labels: labels,
581
- values: (se.values || []).map(Number),
582
- }));
583
- }
584
- return [{ name: '数值', labels: labels, values: (c.values || []).map(Number) }];
585
- }
586
- /* ══ 图表登记(单源 scripts/layout-constants.json · charts.registry)══
587
- * type → { pptx: 'native'|'shape', nativeType, nativeTrick, dataTable }
588
- * 原生通道:pptxgenjs addChart(真 chart part + 内嵌 Excel 工作簿,双击可编辑数据)
589
- * 形状通道:shapeChart()(高保真形状还原 + 按 dataTable 策略附数据表,保证数据可追溯) */
590
- const CHART_REG = (LC.charts && LC.charts.registry) || {};
591
- function chartReg(t) { return CHART_REG[String(t || 'bar').toLowerCase()] || CHART_REG.bar || {}; }
592
- function isNativeChart(t) { return chartReg(t).pptx === 'native'; }
593
- /* 数据表策略:图表级覆盖 > 登记表默认(appendix 收敛为页内表格,保持页数稳定) */
594
- function dataTableMode(c) {
595
- const m = (c && c.dataTable) || chartReg(c && c.type).dataTable || 'notes';
596
- return m === 'appendix' ? 'inline' : m;
597
- }
598
- function dataTableRows(c) {
599
- const labels = (c.labels || []).map(String);
600
- const unit = c.unit ? '(' + c.unit + ')' : '';
601
- if (Array.isArray(c.series) && c.series.length) {
602
- return [['项目'].concat(c.series.map((se, i) => se.name || ('系列' + (i + 1))))]
603
- .concat(labels.map((lb, i) => [lb].concat(c.series.map(se => {
604
- const v = (se.values || [])[i]; return v == null ? '' : String(v);
605
- }))));
606
- }
607
- return [['项目', '数值' + unit]]
608
- .concat(labels.map((lb, i) => { const v = (c.values || [])[i]; return [lb, v == null ? '' : String(v)]; }));
609
- }
610
- function dataTableText(c) { return dataTableRows(c).map(r => r.join(' | ')).join('\n'); }
611
-
612
- /* 原生图表基础选项(位置由调用点附加) */
613
- function chartBaseOpts(c, colors, multi, kind) {
614
- const isLine = (kind === 'line' || kind === 'area');
615
- const opts = {
616
- showValue: !isLine && !multi,
617
- dataLabelPosition: 'outEnd',
618
- dataLabelColor: STYLE.body, dataLabelFontSize: sz(11.5), dataLabelFontFace: STYLE.font,
619
- dataLabelFormatCode: c.unit ? '0"' + c.unit + '"' : 'General',
620
- catAxisLabelColor: STYLE.faint, catAxisLabelFontSize: sz(10.5), catAxisLabelFontFace: STYLE.font,
621
- valAxisHidden: true, valAxisLineShow: false, catAxisLineShow: false,
622
- catGridLine: { style: 'none' }, valGridLine: { style: 'none' },
623
- valAxisMinVal: 0,
624
- showLegend: !!multi, legendPos: 'b', legendColor: STYLE.body,
625
- legendFontSize: sz(10.5), legendFontFace: STYLE.font,
626
- showTitle: false,
627
- };
628
- if (colors) opts.chartColors = colors;
629
- if (c.max) opts.valAxisMaxVal = c.max;
630
- return opts;
631
- }
632
- /* addChart 失败即失败:禁止静默回落为柱状图(会造成「过了 strict 但图表类型假象」)。
633
- * 严格解析/第三方裁判可暴露该失败;交付通道必须诚实报错。 */
634
- function chartTry(s, ctype, data, opts, c) {
635
- try { s.addChart(ctype, data, opts); }
636
- catch (e) {
637
- console.error(`[build_pptx] FAIL: 原生图表 ${ctype} 调用失败(禁止回落): ${e && e.message}`);
638
- process.exitCode = 1;
639
- throw e;
640
- }
641
- }
642
- /* 原生数据图表(pptxgenjs 原生 10 类 + 3 类原生技巧:waterfall / gauge / pareto) */
643
- function nativeChart(s, c, x, y, w, h, dcols) {
644
- const t = String(c.type || 'bar').toLowerCase();
645
- const reg = chartReg(t);
646
- const kind = reg.nativeType || 'bar';
647
- const trick = reg.nativeTrick || '';
648
- const vals = (c.values || []).map(Number);
649
- const top = vals.length ? Math.max.apply(null, vals) : 0;
650
- const multi = Array.isArray(c.series) && c.series.length > 0;
651
- const colors = (dcols && dcols.length) ? dcols
652
- : (multi ? null : vals.map(v => (v === top ? STYLE.accent : STYLE.faint)));
653
- const pos = { x: x, y: y, w: w, h: h };
654
-
655
- /* ── 原生技巧 ── */
656
- if (trick.indexOf('stacked+hiddenBase') === 0) { waterfallChart(s, c, pos); return; }
657
- if (trick.indexOf('doughnut+firstSliceAng') === 0) { gaugeChart(s, c, pos); return; }
658
- if (trick.indexOf('multiType') === 0) { paretoChart(s, c, pos); return; }
659
-
660
- /* ── 饼 / 环 ── */
661
- if (kind === 'doughnut' || kind === 'pie') {
662
- const opts = Object.assign(chartBaseOpts(c, colors || donutColors(styleArg), false, kind), pos, {
663
- showValue: true, dataLabelPosition: 'ctr',
664
- dataLabelColor: STYLE.onAccent, showPercent: true,
665
- showLegend: true, legendPos: 'r',
666
- dataBorder: { pt: 1.5, color: STYLE.bg },
667
- });
668
- if (kind === 'doughnut') opts.holeSize = 55;
669
- chartTry(s, kind === 'pie' ? pptx.ChartType.pie : pptx.ChartType.doughnut, chartSeries(c), opts, c);
670
- return;
671
- }
672
- /* ── 散点 / 气泡(XY 型) ── */
673
- if (kind === 'scatter' || kind === 'bubble') { xyChart(s, c, pos, kind, colors); return; }
674
- /* ── 雷达 ── */
675
- if (kind === 'radar') {
676
- const opts = Object.assign(chartBaseOpts(c, colors, multi, kind), pos, {
677
- radarStyle: 'marker', valAxisHidden: false, valAxisLineShow: true, catAxisLineShow: true,
678
- valGridLine: { style: 'solid', color: STYLE.line }, showValue: false,
679
- });
680
- chartTry(s, pptx.ChartType.radar, chartSeries(c), opts, c);
681
- return;
682
- }
683
- /* ── 柱族(bar / hbar / stack / stackline)与线 / 面积 ── */
684
- const opts = Object.assign(chartBaseOpts(c, colors, multi, kind), pos);
685
- if (kind === 'bar') {
686
- opts.barDir = (t === 'hbar') ? 'bar' : 'col';
687
- opts.barGapWidthPct = (t === 'hbar') ? 45 : 55;
688
- if (t === 'stack') { opts.barGrouping = 'stacked'; opts.showValue = false; }
689
- if (t === 'stackline') { opts.barGrouping = 'percentStacked'; opts.showValue = false; }
690
- }
691
- if (kind === 'line' || kind === 'area') {
692
- opts.lineSize = 2.5; opts.lineSmooth = false;
693
- opts.lineDataSymbol = 'circle'; opts.lineDataSymbolSize = 6;
694
- }
695
- const ctype = kind === 'line' ? pptx.ChartType.line
696
- : kind === 'area' ? pptx.ChartType.area : pptx.ChartType.bar;
697
- chartTry(s, ctype, chartSeries(c), opts, c);
698
- }
699
- /* 散点(X/Y 双系列)/ 气泡({x,y,w} 单系列);labels 解析为 x,values 为 y */
700
- function xyChart(s, c, pos, kind, colors) {
701
- const labels = (c.labels || []);
702
- const vals = (c.values || []).map(Number);
703
- const pts = Array.isArray(c.points) ? c.points : null;
704
- const opts = Object.assign(chartBaseOpts(c, colors, false, kind), pos, {
705
- showValue: false, showLegend: false,
706
- valAxisHidden: false, valAxisLineShow: true, catAxisLineShow: true,
707
- catGridLine: { style: 'solid', color: STYLE.line }, valGridLine: { style: 'solid', color: STYLE.line },
708
- });
709
- try {
710
- if (kind === 'bubble') {
711
- const raw = pts ? pts : labels.map((lb, i) => [i + 1, vals[i], 1]);
712
- const data = [{ name: c.unit || '规模',
713
- values: raw.map(p => ({ x: Number(p[0]) || 0, y: Number(p[1]) || 0, w: Number(p[2]) || 1 })) }];
714
- s.addChart(pptx.ChartType.bubble, data, opts);
715
- } else {
716
- const xs = pts ? pts.map(p => Number(p[0]) || 0)
717
- : labels.map(lb => { const n = parseFloat(String(lb).replace(/[^\d.\-]/g, '')); return isNaN(n) ? 0 : n; });
718
- const ys = pts ? pts.map(p => Number(p[1]) || 0) : vals;
719
- s.addChart(pptx.ChartType.scatter, [{ name: 'X', values: xs }, { name: 'Y', values: ys }], opts);
720
- }
721
- } catch (e) {
722
- console.error(`[build_pptx] FAIL: 原生 ${kind} 调用失败(禁止回落 bar): ${e && e.message}`);
723
- process.exitCode = 1;
724
- throw e;
725
- }
726
- }
727
- /* 瀑布图(原生技巧):隐藏基底 + 增减堆叠柱,叠加累计折线作连接线 */
728
- function waterfallChart(s, c, pos) {
729
- const labels = (c.labels || []).map(String);
730
- const deltas = (c.values || []).map(Number);
731
- let run = 0;
732
- const bases = [], ups = [], downs = [], cum = [];
733
- deltas.forEach(d => {
734
- const b = d >= 0 ? run : run + d;
735
- bases.push(b); ups.push(d >= 0 ? d : 0); downs.push(d < 0 ? -d : 0);
736
- run += d; cum.push(run);
737
- });
738
- const series = [
739
- { name: '基底', labels: labels, values: bases },
740
- { name: '增量', labels: labels, values: ups },
741
- { name: '减量', labels: labels, values: downs },
742
- ];
743
- const inner = {
744
- barDir: 'col', barGrouping: 'stacked', showValue: false, showLegend: false,
745
- chartColors: [STYLE.bg, STYLE.accent, STYLE.faint],
746
- catAxisLabelColor: STYLE.faint, catAxisLabelFontSize: sz(10.5), catAxisLabelFontFace: STYLE.font,
747
- valAxisHidden: true, valAxisLineShow: false, catAxisLineShow: false,
748
- catGridLine: { style: 'none' }, valGridLine: { style: 'none' }, valAxisMinVal: 0, showTitle: false,
749
- };
750
- const outer = Object.assign({}, inner, pos);
751
- try {
752
- s.addChart([
753
- { type: pptx.ChartType.bar, data: series, options: inner },
754
- { type: pptx.ChartType.line, data: [{ name: '累计', labels: labels, values: cum }],
755
- options: { lineSize: 1.25, lineSmooth: false, lineDataSymbol: 'none', chartColors: [STYLE.body] } },
756
- ], outer);
757
- } catch (e) {
758
- /* 组合图失败:单柱降级必须可观测,禁止静默空白 */
759
- console.error(`[build_pptx] WARN: waterfall 组合图失败,降级为堆叠柱: ${e && e.message}`);
760
- try {
761
- s.addChart(pptx.ChartType.bar, series, outer);
762
- } catch (e2) {
763
- console.error(`[build_pptx] FAIL: waterfall 降级柱图仍失败: ${e2 && e2.message}`);
764
- process.exitCode = 1;
765
- throw e2;
766
- }
767
- }
768
- }
769
- /* 仪表盘(原生技巧):270° 扇形环,剩余扇区以底色隐藏 */
770
- function gaugeChart(s, c, pos) {
771
- const v = Number((c.values || [0])[0]) || 0;
772
- const max = Number(c.max) || 100;
773
- const ratio = Math.max(0, Math.min(1, v / max));
774
- const sweep = ratio * 270;
775
- /* 类别标签用数值(达成值 / 剩余值)——cross_verify 按纯数值 token 过滤,A/B 文本集合一致 */
776
- const _u = c.unit || '';
777
- try {
778
- s.addChart(pptx.ChartType.doughnut,
779
- [{ name: '达成', labels: [String(v) + _u, String(Math.max(0, max - v)) + _u], values: [sweep, 360 - sweep] }],
780
- Object.assign({}, pos, {
781
- holeSize: 68, firstSliceAng: 225,
782
- chartColors: [STYLE.accent, STYLE.surface],
783
- dataBorder: { pt: 1, color: STYLE.bg },
784
- showLegend: false, showValue: false, showPercent: false, showTitle: false,
785
- }));
786
- } catch (e) {
787
- console.error(`[build_pptx] FAIL: 原生 gauge 调用失败(禁止静默): ${e && e.message}`);
788
- process.exitCode = 1;
789
- throw e;
790
- }
791
- }
792
- /* 帕累托(原生技巧):柱(数值)+ 折线(累计占比),多类型组合 */
793
- function paretoChart(s, c, pos) {
794
- const labels = (c.labels || []).map(String);
795
- const vals = (c.values || []).map(Number);
796
- const total = vals.reduce((a, b) => a + b, 0) || 1;
797
- let run = 0;
798
- const cum = vals.map(v => { run += v; return Math.round(run / total * 1000) / 10; });
799
- const barOpts = {
800
- barDir: 'col', showValue: false, showLegend: true, legendPos: 'b',
801
- chartColors: [STYLE.accent],
802
- catAxisLabelColor: STYLE.faint, catAxisLabelFontSize: sz(10.5), catAxisLabelFontFace: STYLE.font,
803
- valAxisHidden: true, valAxisLineShow: false, catAxisLineShow: false,
804
- catGridLine: { style: 'none' }, valGridLine: { style: 'none' }, valAxisMinVal: 0, showTitle: false,
805
- };
806
- try {
807
- s.addChart([
808
- { type: pptx.ChartType.bar,
809
- data: [{ name: c.unit ? '数值(' + c.unit + ')' : '数值', labels: labels, values: vals }], options: barOpts },
810
- { type: pptx.ChartType.line, data: [{ name: '累计占比', labels: labels, values: cum }],
811
- options: { lineSize: 2, lineSmooth: false, lineDataSymbol: 'circle', lineDataSymbolSize: 6, chartColors: [STYLE.body] } },
812
- ], Object.assign({}, barOpts, pos));
813
- } catch (e) { chartTry(s, pptx.ChartType.bar, chartSeries(c), Object.assign({}, barOpts, pos), c); }
814
- }
815
- function nativeDonutChart(s, dcn, dcols) {
816
- const d = PT.donut;
817
- s.addChart(pptx.ChartType.doughnut, chartSeries(dcn), {
818
- x: d.centerX - d.r, y: d.centerY - d.r, w: d.r * 2, h: d.r * 2,
819
- holeSize: Math.round(d.holeR / d.r * 100),
820
- chartColors: dcols,
821
- showLegend: false, showValue: false, showPercent: false, showTitle: false,
822
- dataBorder: { pt: 1.5, color: STYLE.bg },
823
- });
824
- }
825
- /* ═══ 形状通道图表(非原生类型的高保真形状还原)═══
826
- * 与 A 通道(assets/pptx-export.js 的 chartShapes)语义一致:类别标签与数值都落为文本,
827
- * 保证 cross_verify 逐页文本集合一致(数值 token 由 cross_verify 过滤)。
828
- * 复杂信息图(sankey/treemap/boxplot/network/marimekko/streamgraph)请用专属页型承载。 */
829
- function shapeChart(s, c, x, y, w, h, dcols) {
830
- const t = String(c.type || 'bar').toLowerCase();
831
- if (t === 'funnel') { shapeFunnel(s, c, x, y, w, h, dcols); return; }
832
- if (t === 'progress') { shapeRows(s, c, x, y, w, h, dcols, 'progress'); return; }
833
- if (t === 'radialbar') { shapeRows(s, c, x, y, w, h, dcols, 'radialbar'); return; }
834
- if (t === 'dumbbell') { shapeRows(s, c, x, y, w, h, dcols, 'dumbbell'); return; }
835
- if (t === 'lollipop') { shapeRows(s, c, x, y, w, h, dcols, 'lollipop'); return; }
836
- if (t === 'dotplot') { shapeRows(s, c, x, y, w, h, dcols, 'dotplot'); return; }
837
- if (t === 'bulletchart') { shapeRows(s, c, x, y, w, h, dcols, 'bullet'); return; }
838
- if (t === 'gantt') { shapeGantt(s, c, x, y, w, h, dcols); return; }
839
- if (t === 'vsbar') { shapeVsbar(s, c, x, y, w, h, dcols); return; }
840
- if (t === 'sparkline') { shapeSpark(s, c, x, y, w, h, dcols); return; }
841
- if (t === 'slope') { shapeSlope(s, c, x, y, w, h, dcols); return; }
842
- if (t === 'waffle') { shapeWaffle(s, c, x, y, w, h, dcols); return; }
843
- if (t === 'rose') { shapeRose(s, c, x, y, w, h, dcols); return; }
844
- if (t === 'candlestick') { shapeCandlestick(s, c, x, y, w, h, dcols); return; }
845
- /* 已登记形状通道类型必须命中专属渲染器——命中通用回落 = 实现缺口,硬失败 */
846
- const reg = chartReg(t);
847
- if (reg && reg.pptx === 'shape') {
848
- console.error(`[build_pptx] FAIL: 形状通道类型 ${t} 已登记但缺专属渲染器,拒绝通用比例条回落`);
849
- process.exitCode = 1;
850
- throw new Error(`SHAPE_RENDERER_MISSING: ${t}`);
851
- }
852
- /* 未登记类型:仅在模型非法时走到这里(schema/校验应已拦截)——留痕便于定位 */
853
- console.warn(`[build_pptx] 未登记图表类型 ${t} 使用通用比例条(模型应先过 schema)。`);
854
- shapeProportion(s, c, x, y, w, h, dcols);
855
- }
856
- /* 形状图表通用排版量 */
857
- function shapeMetrics(c, w, h) {
858
- const labels = (c.labels || []).map(String);
859
- const values = (c.values || []).map(Number);
860
- const n = Math.max(1, labels.length);
861
- const labelW = Math.min(2.4, w * 0.24);
862
- const valW = 0.9;
863
- const vmax = c.max || Math.max.apply(null, values.concat([1]));
864
- const top = values.length ? Math.max.apply(null, values) : 0;
865
- return { labels: labels, values: values, n: n, labelW: labelW, valW: valW, vmax: vmax, top: top };
866
- }
867
- function shapeLabel(s, t, x, y, w, h, col, bold) {
868
- s.addText(t, { x: x, y: y, w: w, h: h, fontFace: STYLE.font, fontSize: sz(bold ? 11.5 : 11),
869
- bold: !!bold, color: col || STYLE.body, valign: 'middle' });
870
- }
871
- function shapeValue(s, c, v, x, y, w, h, hot) {
872
- s.addText(String(v) + (c.unit || ''), { x: x, y: y, w: w, h: h, fontFace: STYLE.font,
873
- fontSize: sz(11.5), bold: true, color: hot ? STYLE.accent : STYLE.body, align: 'right', valign: 'middle' });
874
- }
875
- /* 玫瑰图(rose):等角扇区、半径 ∝ 数值(南丁格尔玫瑰)。
876
- * 扇区用「多段旋转矩形」沿角向铺贴逼近(每扇区 ≥16 段,双边界分别取弧长),
877
- * 不使用 preset pie/arc 替代曲线语义;标签与数值走底部图例行(文本与 A 通道一致)。 */
878
- function shapeRose(s, c, x, y, w, h, dcols) {
879
- const M = shapeMetrics(c, w, h);
880
- const legendH = Math.min(0.72, Math.max(0.3, h * 0.18));
881
- const plotH = Math.max(0.6, h - legendH - 0.08);
882
- const cx = x + w / 2, cy = y + plotH / 2;
883
- const R = Math.max(0.4, Math.min(w, plotH) / 2 - 0.08);
884
- const rMin = R * 0.2;
885
- const step = 360 / M.n;
886
- const gapDeg = M.n > 1 ? 1.6 : 0;
887
- const vmax = M.vmax || 1;
888
- M.labels.forEach((lb, i) => {
889
- const v = M.values[i] || 0;
890
- const r = rMin + (R - rMin) * Math.max(0.05, Math.min(1, Math.abs(v) / vmax));
891
- const a1 = -90 + i * step + gapDeg / 2;
892
- const a2 = -90 + (i + 1) * step - gapDeg / 2;
893
- const segs = Math.max(16, Math.round((a2 - a1) / 5));
894
- const dc = dcols ? dcols[i % dcols.length] : (v === M.top ? STYLE.accent : STYLE.faint);
895
- for (let k = 0; k < segs; k++) {
896
- const t0 = (a1 + (a2 - a1) * (k / segs)) * Math.PI / 180;
897
- const t1 = (a1 + (a2 - a1) * ((k + 1) / segs)) * Math.PI / 180;
898
- const tm = (t0 + t1) / 2;
899
- const chord = Math.max(0.02, r * Math.abs(t1 - t0));
900
- s.addShape('rect', {
901
- x: cx + Math.cos(tm) * (r / 2) - chord / 2, y: cy + Math.sin(tm) * (r / 2) - r / 2,
902
- w: chord, h: r, rotate: Math.round((tm * 180 / Math.PI + 90) * 10) / 10,
903
- fill: { color: dc }, line: { type: 'none' } });
904
- }
905
- });
906
- /* 底部图例:色块 + 标签 + 数值 */
907
- const itemW = Math.max(0.9, w / M.n);
908
- M.labels.forEach((lb, i) => {
909
- const dc = dcols ? dcols[i % dcols.length] : (M.values[i] === M.top ? STYLE.accent : STYLE.faint);
910
- const lx = x + i * itemW;
911
- const ly = y + plotH + 0.08;
912
- s.addShape('rect', { x: lx, y: ly + 0.04, w: 0.11, h: 0.11, fill: { color: dc }, line: { type: 'none' } });
913
- s.addText(lb, { x: lx + 0.15, y: ly - 0.02, w: Math.max(0.3, itemW - 0.85), h: 0.24,
914
- fontFace: STYLE.font, fontSize: sz(10.5), color: STYLE.body, valign: 'middle' });
915
- shapeValue(s, c, M.values[i] || 0, lx + itemW - 0.7, ly - 0.02, 0.66, 0.24, M.values[i] === M.top);
916
- });
917
- }
918
- /* K 线(candlestick):values = [[开,高,低,收]…];实体 = 开收,须 = 高低。
919
- * 上涨用 accent、下跌用中性线色(不引入红绿第二色相);文本 = 标签 + 收盘值。 */
920
- function shapeCandlestick(s, c, x, y, w, h, dcols) {
921
- const labels = (c.labels || []).map(String);
922
- const rows = (c.values || []).filter(r => Array.isArray(r) && r.length >= 4)
923
- .map(r => r.slice(0, 4).map(Number));
924
- const n = Math.max(1, rows.length);
925
- const axisH = 0.26;
926
- const plotH = Math.max(0.5, h - axisH - 0.1);
927
- const flat = [];
928
- rows.forEach(r => r.forEach(v => { if (!isNaN(v)) flat.push(v); }));
929
- const lo = flat.length ? Math.min.apply(null, flat) : 0;
930
- const hi = flat.length ? Math.max.apply(null, flat) : 1;
931
- const span = (hi - lo) || 1;
932
- const yFor = (v) => y + 0.06 + (1 - (v - lo) / span) * plotH;
933
- const step = w / n;
934
- const bw = Math.min(0.42, step * 0.5);
935
- s.addShape('rect', { x: x, y: y + 0.06 + plotH, w: w, h: 0.015, fill: { color: STYLE.line }, line: { type: 'none' } });
936
- rows.forEach((r, i) => {
937
- const [o, hiP, loP, cl] = r;
938
- const cxp = x + step * i + step / 2;
939
- const up = cl >= o;
940
- const bodyCol = up ? STYLE.accent : STYLE.line;
941
- s.addShape('rect', { x: cxp - 0.011, y: yFor(hiP), w: 0.022, h: Math.max(0.02, yFor(loP) - yFor(hiP)),
942
- fill: { color: STYLE.faint }, line: { type: 'none' } });
943
- s.addShape('rect', { x: cxp - bw / 2, y: yFor(Math.max(o, cl)), w: bw,
944
- h: Math.max(0.04, Math.abs(yFor(cl) - yFor(o))),
945
- fill: { color: bodyCol }, line: { type: 'none' } });
946
- if (labels[i]) {
947
- s.addText(labels[i], { x: cxp - step / 2, y: y + plotH + axisH - 0.06, w: step, h: 0.24,
948
- align: 'center', fontFace: STYLE.font, fontSize: sz(10), color: STYLE.faint });
949
- }
950
- shapeValue(s, c, cl, cxp - step / 2, yFor(hiP) - 0.26, step, 0.24, up);
951
- });
952
- }
953
- /* 通用回落:标签 + 比例条 + 数值——**仅**未登记/非法类型可用。
954
- * 已登记形状类型命中此处会由 shapeChart 抛 SHAPE_RENDERER_MISSING。 */
955
- function shapeProportion(s, c, x, y, w, h, dcols) {
956
- const M = shapeMetrics(c, w, h);
957
- const rowH = Math.max(0.22, Math.min(0.52, h / M.n));
958
- const trackX = x + M.labelW + 0.08;
959
- const trackW = w - M.labelW - M.valW - 0.2;
960
- const barH = Math.max(0.12, rowH * 0.52);
961
- M.labels.forEach((lb, i) => {
962
- const v = M.values[i] || 0;
963
- const ry = y + i * rowH;
964
- const hot = v === M.top;
965
- const dc = dcols ? dcols[i % dcols.length] : null;
966
- shapeLabel(s, lb, x, ry, M.labelW - 0.06, rowH, STYLE.body);
967
- s.addShape('rect', { x: trackX, y: ry + (rowH - barH) / 2, w: trackW, h: barH,
968
- fill: { color: STYLE.surface }, line: { color: STYLE.line, width: 0.5 } });
969
- s.addShape('rect', { x: trackX, y: ry + (rowH - barH) / 2,
970
- w: Math.max(0.02, Math.abs(v) / M.vmax * trackW), h: barH,
971
- fill: { color: dc || (hot ? STYLE.accent : STYLE.faint) }, line: { type: 'none' } });
972
- shapeValue(s, c, v, trackX + trackW + 0.06, ry, M.valW, rowH, hot);
973
- });
974
- }
975
- /* 行式标记图:progress / radialbar / dumbbell / lollipop / dotplot / bullet */
976
- function shapeRows(s, c, x, y, w, h, dcols, variant) {
977
- const M = shapeMetrics(c, w, h);
978
- const rowH = Math.max(0.24, Math.min(0.6, h / M.n));
979
- const trackX = x + M.labelW + 0.08;
980
- const trackW = w - M.labelW - M.valW - 0.2;
981
- const th = Math.max(0.1, rowH * (variant === 'bullet' ? 0.42 : 0.28));
982
- M.labels.forEach((lb, i) => {
983
- const v = M.values[i] || 0;
984
- const ry = y + i * rowH;
985
- const cy = ry + rowH / 2;
986
- const hot = v === M.top;
987
- const dc = dcols ? dcols[i % dcols.length] : null;
988
- const col = dc || (hot ? STYLE.accent : STYLE.faint);
989
- shapeLabel(s, lb, x, ry, M.labelW - 0.06, rowH, STYLE.body);
990
- const frac = Math.max(0, Math.min(1, v / M.vmax));
991
- if (variant === 'progress' || variant === 'radialbar') {
992
- s.addShape('roundRect', { x: trackX, y: cy - th / 2, w: trackW, h: th,
993
- rectRadius: 0.04, fill: { color: STYLE.surface }, line: { type: 'none' } });
994
- s.addShape('roundRect', { x: trackX, y: cy - th / 2, w: Math.max(0.03, frac * trackW), h: th,
995
- rectRadius: 0.04, fill: { color: col }, line: { type: 'none' } });
996
- } else if (variant === 'dumbbell') {
997
- const base = Math.max(0, Math.min(1, (M.values[i] || 0) / M.vmax));
998
- const other = Math.max(0, Math.min(1, ((c.series && c.series[0] && c.series[0].values[i]) || M.vmax * 0.6) / M.vmax));
999
- const x1 = trackX + Math.min(base, other) * trackW, x2 = trackX + Math.max(base, other) * trackW;
1000
- s.addShape('rect', { x: x1, y: cy - 0.015, w: Math.max(0.02, x2 - x1), h: 0.03,
1001
- fill: { color: STYLE.line }, line: { type: 'none' } });
1002
- s.addShape('ellipse', { x: x1 - 0.055, y: cy - 0.055, w: 0.11, h: 0.11, fill: { color: STYLE.faint }, line: { type: 'none' } });
1003
- s.addShape('ellipse', { x: x2 - 0.055, y: cy - 0.055, w: 0.11, h: 0.11, fill: { color: col }, line: { type: 'none' } });
1004
- } else if (variant === 'lollipop' || variant === 'dotplot') {
1005
- const px = trackX + frac * trackW;
1006
- if (variant === 'lollipop') {
1007
- s.addShape('rect', { x: px - 0.012, y: cy, w: 0.024, h: (y + h) - cy - 0.06,
1008
- fill: { color: STYLE.line }, line: { type: 'none' } });
1009
- }
1010
- s.addShape('ellipse', { x: px - 0.07, y: cy - 0.07, w: 0.14, h: 0.14, fill: { color: col }, line: { type: 'none' } });
1011
- } else { /* bullet:底槽 + 实际条 + 目标刻度 */
1012
- s.addShape('rect', { x: trackX, y: cy - th / 2, w: trackW, h: th,
1013
- fill: { color: STYLE.surface }, line: { color: STYLE.line, width: 0.5 } });
1014
- s.addShape('rect', { x: trackX, y: cy - th / 2, w: Math.max(0.03, frac * trackW), h: th,
1015
- fill: { color: col }, line: { type: 'none' } });
1016
- const tgt = Math.max(0, Math.min(1, ((c.target && c.target[i]) || M.vmax * 0.8) / M.vmax));
1017
- s.addShape('rect', { x: trackX + tgt * trackW - 0.015, y: cy - th * 0.8, w: 0.03, h: th * 1.6,
1018
- fill: { color: STYLE.ink }, line: { type: 'none' } });
1019
- }
1020
- shapeValue(s, c, v, trackX + trackW + 0.06, ry, M.valW, rowH, hot);
1021
- });
1022
- }
1023
- /* 漏斗:逐层收窄的梯形行(用宽度递减的矩形近似,保留标签与数值) */
1024
- function shapeFunnel(s, c, x, y, w, h, dcols) {
1025
- const M = shapeMetrics(c, w, h);
1026
- const rowH = Math.max(0.26, Math.min(0.72, h / M.n));
1027
- M.labels.forEach((lb, i) => {
1028
- const v = M.values[i] || 0;
1029
- const ry = y + i * rowH;
1030
- const hot = v === M.top;
1031
- const dc = dcols ? dcols[i % dcols.length] : null;
1032
- const frac = Math.max(0.06, v / M.vmax);
1033
- const bw = Math.max(0.4, (w - M.valW - 0.2) * frac);
1034
- const bx = x + (w - M.valW - 0.2 - bw) / 2;
1035
- s.addShape('rect', { x: bx, y: ry + 0.02, w: bw, h: rowH - 0.06,
1036
- fill: { color: dc || (hot ? STYLE.accent : STYLE.faint) }, line: { type: 'none' } });
1037
- s.addText(lb, { x: bx, y: ry + 0.02, w: bw, h: rowH - 0.06,
1038
- fontFace: STYLE.font, fontSize: sz(11.5), bold: true, color: STYLE.onAccent, align: 'center', valign: 'middle' });
1039
- shapeValue(s, c, v, x + w - M.valW, ry, M.valW, rowH, hot);
1040
- });
1041
- }
1042
- /* 甘特 / 路线:行 = 标签 + 偏移起点 + 时长条 */
1043
- function shapeGantt(s, c, x, y, w, h, dcols) {
1044
- const M = shapeMetrics(c, w, h);
1045
- const rowH = Math.max(0.24, Math.min(0.6, h / M.n));
1046
- const trackX = x + M.labelW + 0.08;
1047
- const trackW = w - M.labelW - M.valW - 0.2;
1048
- /* 时间轴跨度 = 显式 max,或所有条目的「起点 + 时长」最大值(原按「时长最大值」
1049
- * 导致 start 偏移超出轨道、形状越界的缺陷) */
1050
- let span = Number(c.max) || 0;
1051
- if (!span) {
1052
- M.values.forEach((dur, i) => {
1053
- const st = (c.start && Number(c.start[i])) || 0;
1054
- span = Math.max(span, st + (Number(dur) || 0));
1055
- });
1056
- }
1057
- span = span || 1;
1058
- M.labels.forEach((lb, i) => {
1059
- const start = (c.start && Number(c.start[i])) || 0;
1060
- const dur = M.values[i] || 0;
1061
- const ry = y + i * rowH;
1062
- const hot = dur === M.top;
1063
- const dc = dcols ? dcols[i % dcols.length] : null;
1064
- shapeLabel(s, lb, x, ry, M.labelW - 0.06, rowH, STYLE.body);
1065
- s.addShape('rect', { x: trackX, y: ry + rowH * 0.3, w: trackW, h: rowH * 0.36,
1066
- fill: { color: STYLE.surface }, line: { type: 'none' } });
1067
- s.addShape('rect', { x: trackX + (start / span) * trackW, y: ry + rowH * 0.3,
1068
- w: Math.max(0.05, (dur / span) * trackW), h: rowH * 0.36,
1069
- fill: { color: dc || (hot ? STYLE.accent : STYLE.faint) }, line: { type: 'none' } });
1070
- shapeValue(s, c, dur, trackX + trackW + 0.06, ry, M.valW, rowH, hot);
1071
- });
1072
- }
1073
- /* 双向对比条(vsbar):以中线为轴向左/右展开(正值向右、负值向左) */
1074
- function shapeVsbar(s, c, x, y, w, h, dcols) {
1075
- const M = shapeMetrics(c, w, h);
1076
- const rowH = Math.max(0.24, Math.min(0.6, h / M.n));
1077
- const labelW = M.labelW;
1078
- const halfW = (w - labelW - M.valW - 0.2) / 2;
1079
- const midX = x + labelW + 0.08 + halfW;
1080
- const barH = Math.max(0.14, rowH * 0.5);
1081
- M.labels.forEach((lb, i) => {
1082
- const v = M.values[i] || 0;
1083
- const ry = y + i * rowH;
1084
- const cy = ry + rowH / 2;
1085
- const hot = v === M.top;
1086
- shapeLabel(s, lb, x, ry, labelW - 0.06, rowH, STYLE.body);
1087
- const bw = Math.max(0.03, Math.abs(v) / M.vmax * halfW);
1088
- const bx = v >= 0 ? midX : midX - bw;
1089
- s.addShape('rect', { x: bx, y: cy - barH / 2, w: bw, h: barH,
1090
- fill: { color: hot ? STYLE.accent : STYLE.faint }, line: { type: 'none' } });
1091
- s.addShape('rect', { x: midX - 0.008, y: ry + 0.04, w: 0.016, h: rowH - 0.08,
1092
- fill: { color: STYLE.line }, line: { type: 'none' } });
1093
- shapeValue(s, c, v, midX + halfW + 0.06, ry, M.valW, rowH, hot);
1094
- });
1095
- }
1096
- /* 迷你折线(sparkline):无轴,仅趋势线 + 端点 */
1097
- function shapeSpark(s, c, x, y, w, h, dcols) {
1098
- const M = shapeMetrics(c, w, h);
1099
- const stepX = M.n > 1 ? w / (M.n - 1) : 0;
1100
- const baseY = y + h - 0.06;
1101
- const plotH = Math.max(0.2, h - 0.18);
1102
- let px = 0, py = 0;
1103
- M.values.forEach((v, i) => {
1104
- const cx = x + i * stepX;
1105
- const cy = baseY - Math.max(0.02, (v / M.vmax) * plotH);
1106
- if (i > 0) {
1107
- s.addShape('rect', { x: Math.min(px, cx), y: (py + cy) / 2 - 0.012, w: Math.abs(cx - px), h: 0.024,
1108
- fill: { color: STYLE.accent }, line: { type: 'none' } });
1109
- }
1110
- px = cx; py = cy;
1111
- });
1112
- s.addText(String(M.values[M.values.length - 1] != null ? M.values[M.values.length - 1] : '') + (c.unit || ''),
1113
- { x: x, y: y, w: w, h: 0.3, fontFace: STYLE.font, fontSize: sz(11.5), bold: true,
1114
- color: STYLE.accent, align: 'right' });
1115
- }
1116
- /* 斜率图(slope):两点之间的连线 + 端点标签 */
1117
- function shapeSlope(s, c, x, y, w, h, dcols) {
1118
- const M = shapeMetrics(c, w, h);
1119
- const leftX = x + 0.5, rightX = x + w - 0.5;
1120
- const plotH = Math.max(0.3, h - 0.3);
1121
- const yFor = (v) => y + 0.15 + (1 - Math.max(0, Math.min(1, v / M.vmax))) * plotH;
1122
- const other = (c.series && c.series[0] && c.series[0].values) || M.values.map(v => v * 0.7);
1123
- M.labels.forEach((lb, i) => {
1124
- const y1 = yFor(M.values[i]), y2 = yFor(Number(other[i]) || 0);
1125
- s.addShape('rect', { x: leftX, y: (y1 + y2) / 2 - 0.012, w: rightX - leftX, h: 0.024,
1126
- fill: { color: STYLE.line }, line: { type: 'none' } });
1127
- s.addShape('ellipse', { x: leftX - 0.06, y: y1 - 0.06, w: 0.12, h: 0.12, fill: { color: STYLE.faint }, line: { type: 'none' } });
1128
- s.addShape('ellipse', { x: rightX - 0.06, y: y2 - 0.06, w: 0.12, h: 0.12, fill: { color: STYLE.accent }, line: { type: 'none' } });
1129
- s.addText(lb, { x: x, y: y1 - 0.14, w: 0.62, h: 0.28,
1130
- fontFace: STYLE.font, fontSize: sz(10.5), color: STYLE.body, align: 'right', valign: 'middle' });
1131
- shapeValue(s, c, M.values[i], x + 0.66, y1 - 0.14, 0.52, 0.28, true);
1132
- shapeValue(s, c, Number(other[i]) || 0, rightX + 0.1, y2 - 0.14, 0.62, 0.28, false);
1133
- });
1134
- }
1135
- /* 华夫图(waffle):10×N 点阵按占比填充 */
1136
- function shapeWaffle(s, c, x, y, w, h, dcols) {
1137
- const M = shapeMetrics(c, w, h);
1138
- const cols = 10, rows = 10;
1139
- const cell = Math.min(w / cols, h / rows);
1140
- const total = M.values.reduce((a, b) => a + b, 0) || 1;
1141
- const units = 100;
1142
- let filled = 0;
1143
- const alloc = M.values.map(v => Math.round(v / total * units));
1144
- M.labels.forEach((lb, i) => {
1145
- const dc = dcols ? dcols[i % dcols.length] : (i === 0 ? STYLE.accent : STYLE.faint);
1146
- for (let k = 0; k < alloc[i] && filled < units; k++, filled++) {
1147
- const r = Math.floor(filled / cols), cc = filled % cols;
1148
- s.addShape('roundRect', { x: x + cc * cell, y: y + r * cell, w: cell * 0.82, h: cell * 0.82,
1149
- rectRadius: 0.02, fill: { color: dc }, line: { type: 'none' } });
1150
- }
1151
- });
1152
- let lx = x;
1153
- M.labels.forEach((lb, i) => {
1154
- const dc = dcols ? dcols[i % dcols.length] : (i === 0 ? STYLE.accent : STYLE.faint);
1155
- s.addShape('ellipse', { x: lx, y: y + h + 0.06, w: 0.1, h: 0.1, fill: { color: dc }, line: { type: 'none' } });
1156
- s.addText(lb, { x: lx + 0.14, y: y + h + 0.02, w: 0.9, h: 0.24,
1157
- fontFace: STYLE.font, fontSize: sz(10.5), color: STYLE.body, valign: 'middle' });
1158
- s.addText(String(M.values[i] != null ? M.values[i] : '') + (c.unit || ''),
1159
- { x: lx + 1.06, y: y + h + 0.02, w: 0.66, h: 0.24, fontFace: STYLE.font, fontSize: sz(10.5),
1160
- bold: true, color: STYLE.body, valign: 'middle' });
1161
- lx += 1.9;
1162
- });
1163
- }
1164
- /* 图表 + 数据表组合块:inline 策略在图表下方附原生小字号表格(压缩图表高度,不越界) */
1165
- function chartBlock(s, c, x, y, w, h, dcols) {
1166
- const dm = dataTableMode(c);
1167
- let chH = h;
1168
- const hasData = (c.labels || []).length > 0;
1169
- if (dm === 'inline' && hasData) {
1170
- const rowsData = dataTableRows(c);
1171
- const n = rowsData.length;
1172
- const tH = Math.min(h * 0.44, Math.max(0.44, n * 0.22 + 0.06));
1173
- chH = Math.max(0.85, h - tH - 0.1);
1174
- const rowH = Math.max(0.15, (tH - 0.04) / Math.max(1, n));
1175
- const dense = rowH < 0.26;
1176
- const fs = sz(dense ? 9 : 10.5);
1177
- const rows = rowsData.map((r, ri) => r.map(cell => ({
1178
- text: String(cell),
1179
- options: {
1180
- fontSize: fs, fontFace: STYLE.font, valign: 'middle',
1181
- bold: ri === 0, color: ri === 0 ? STYLE.onAccent : STYLE.body,
1182
- fill: { color: ri === 0 ? STYLE.accent : STYLE.bg },
1183
- },
1184
- })));
1185
- s.addTable(rows, { x: x, y: y + chH + 0.1, w: w, rowH: rowH, autoPage: false,
1186
- border: { pt: 0.5, color: STYLE.line }, margin: dense ? 1 : 3 });
1187
- }
1188
- if (isNativeChart(c.type)) nativeChart(s, c, x, y, w, chH, dcols);
1189
- else shapeChart(s, c, x, y, w, chH, dcols);
1190
- }
1191
- /* ═══ 复杂信息图页型(形状通道:高保真形状还原 + 数据表可追溯)═══
1192
- * 复杂曲线 / 流带一律用「多段旋转矩形」沿采样路径逼近(采样点 ≥16,上下边界分别追踪),
1193
- * 不使用 preset shape(pie / arc / blockArc / chord / moon / wave)替代;
1194
- * 与 A 通道(assets/pptx-export.js)同规则、文本集合一致(cross_verify 逐页比对)。 */
1195
- const INFO_MIN_SAMPLES = 16;
1196
- function sstep(t) { return t * t * (3 - 2 * t); }
1197
- /* 沿采样路径铺贴多段旋转矩形:pts[i] = [x, yTop, yBottom](双边界分别追踪) */
1198
- function ribbonRects(s, pts, color) {
1199
- for (let i = 0; i < pts.length - 1; i++) {
1200
- const a = pts[i], b = pts[i + 1];
1201
- const ca = (a[1] + a[2]) / 2, cb = (b[1] + b[2]) / 2;
1202
- const dx = b[0] - a[0], dy = cb - ca;
1203
- const len = Math.sqrt(dx * dx + dy * dy);
1204
- const th = Math.max(0.02, ((a[2] - a[1]) + (b[2] - b[1])) / 2);
1205
- const ang = Math.atan2(dy, dx) * 180 / Math.PI;
1206
- s.addShape('rect', { x: (a[0] + b[0]) / 2 - len / 2, y: (ca + cb) / 2 - th / 2,
1207
- w: Math.max(0.02, len), h: th, rotate: Math.round(ang * 10) / 10,
1208
- fill: { color: color }, line: { type: 'none' } });
1209
- }
1210
- }
1211
- /* 信息图页型的数据表策略与行(默认 notes:数据入演讲者备注;inline:页内附原生表格) */
1212
- function infoDataTableMode(sec) {
1213
- const m = String(((sec.chart || {}).dataTable) || 'notes').toLowerCase();
1214
- return m === 'appendix' ? 'inline' : m;
1215
- }
1216
- function infoTableRows(sec) {
1217
- const t = sec.type || '';
1218
- const u = sec.unit ? '(' + sec.unit + ')' : '';
1219
- if (t === 'sankey') return [['源', '汇', '流量' + u]].concat((sec.flows || []).map(f => [String(f[0]), String(f[1]), String(f[2])]));
1220
- if (t === 'treemap') return [['项目', '数值' + u]].concat((sec.items || []).map(it => [String(it[0]), String(it[1])]));
1221
- if (t === 'boxplot') return [['分组', '最小', 'Q1', '中位', 'Q3', '最大']].concat((sec.groups || []).map(g => g.map(String)));
1222
- if (t === 'network') return [['节点', '名称']].concat((sec.nodes || []).map(n => [String(n[0]), String(n[1])]));
1223
- if (t === 'marimekko') {
1224
- const head = ['列', '总量' + u].concat((sec.legend || []).map(String));
1225
- return [head].concat((sec.cols || []).map((c, i) => [String(c[0]), String(c[1])].concat(((sec.cells || [])[i] || []).map(String))));
1226
- }
1227
- if (t === 'streamgraph') {
1228
- return [['系列'].concat((sec.labels || []).map(String))]
1229
- .concat((sec.series || []).map(se => [String(se.name || '')].concat((se.values || []).map(String))));
1230
- }
1231
- return null;
1232
- }
1233
- function infoTable(s, rows, x, y, w, h) {
1234
- const n = rows.length;
1235
- const rowH = Math.max(0.15, Math.min(0.26, h / Math.max(1, n)));
1236
- const dense = rowH < 0.24;
1237
- const fs = sz(dense ? 9 : 10.5);
1238
- const tbl = rows.map((r, ri) => r.map(cell => ({ text: String(cell), options: {
1239
- fontSize: fs, fontFace: STYLE.font, valign: 'middle', bold: ri === 0,
1240
- color: ri === 0 ? STYLE.onAccent : STYLE.body,
1241
- fill: { color: ri === 0 ? STYLE.accent : STYLE.bg } } })));
1242
- s.addTable(tbl, { x: x, y: y, w: w, rowH: rowH, autoPage: false,
1243
- border: { pt: 0.5, color: STYLE.line }, margin: dense ? 1 : 3 });
1244
- }
1245
- /* 信息图页型的公共外壳:算绘图区(inline 数据表占位则压缩绘图区) */
1246
- function infoPlot(sec, y0, y1) {
1247
- const rows = infoTableRows(sec);
1248
- const inline = (infoDataTableMode(sec) === 'inline') && rows && rows.length;
1249
- if (!inline) return { y0: y0, y1: y1, rows: rows };
1250
- const tH = Math.min((y1 - y0) * 0.4, Math.max(0.44, rows.length * 0.22 + 0.06));
1251
- return { y0: y0, y1: y1 - tH - 0.1, rows: rows, table: { y: y1 - tH, h: tH } };
1252
- }
1253
- /* 桑基图:分层节点 + 流带(流量线性编码;流带上下边界分别采样,采样点 ≥16) */
1254
- function infoSankey(s, sec, y0, y1) {
1255
- const G = PT.sankey;
1256
- const P = infoPlot(sec, y0, y1);
1257
- if (P.table) infoTable(s, P.rows, MX, P.table.y, CW, P.table.h);
1258
- const flows = (sec.flows || []).filter(f => f && f.length >= 3);
1259
- const nodes = [], idx = {};
1260
- flows.forEach(f => [f[0], f[1]].forEach(n => { if (idx[n] == null) { idx[n] = nodes.length; nodes.push(n); } }));
1261
- if (!nodes.length) return;
1262
- const level = nodes.map(() => 0);
1263
- for (let iter = 0; iter < nodes.length + 1; iter++) {
1264
- let ch = false;
1265
- flows.forEach(f => { const a = idx[f[0]], b = idx[f[1]]; if (level[b] < level[a] + 1) { level[b] = level[a] + 1; ch = true; } });
1266
- if (!ch) break;
1267
- }
1268
- const maxLv = Math.max.apply(null, level.concat([0]));
1269
- const outSum = {}, inSum = {};
1270
- flows.forEach(f => { outSum[f[0]] = (outSum[f[0]] || 0) + Number(f[2]); inSum[f[1]] = (inSum[f[1]] || 0) + Number(f[2]); });
1271
- const thr = (n) => Math.max(inSum[n] || 0, outSum[n] || 0);
1272
- const totalFlow = flows.reduce((a, f) => a + Number(f[2]), 0) || 1;
1273
- const plotH = P.y1 - P.y0;
1274
- const colW = CW / (maxLv + 1);
1275
- const geo = {};
1276
- for (let l = 0; l <= maxLv; l++) {
1277
- const lv = nodes.filter((n, i) => level[i] === l);
1278
- const sum = lv.reduce((a, n) => a + thr(n), 0) || 1;
1279
- const availH = plotH - G.nodeGap * Math.max(0, lv.length - 1);
1280
- let cy = P.y0;
1281
- lv.forEach(n => {
1282
- const h = Math.max(G.minBandH, thr(n) / sum * availH);
1283
- geo[n] = { x: MX + l * colW + colW * 0.5 - G.nodeW / 2, y: cy, h: h, lv: l };
1284
- cy += h + G.nodeGap;
1285
- });
1286
- }
1287
- const used = {};
1288
- flows.forEach(f => {
1289
- const a = geo[f[0]], b = geo[f[1]];
1290
- if (!a || !b) return;
1291
- const v = Number(f[2]) || 0;
1292
- const w = Math.max(0.03, v / totalFlow * plotH * 0.55);
1293
- used[f[0]] = (used[f[0]] || 0) + w; used[f[1]] = (used[f[1]] || 0) + w;
1294
- const yA = a.y + used[f[0]] - w / 2, yB = b.y + used[f[1]] - w / 2;
1295
- const x0 = a.x + G.nodeW, x1 = b.x;
1296
- const n = Math.max(INFO_MIN_SAMPLES, Math.round((x1 - x0) / 0.1));
1297
- const pts = [];
1298
- for (let i = 0; i <= n; i++) {
1299
- const t = i / n, e = sstep(t);
1300
- const x = x0 + (x1 - x0) * t;
1301
- const cyy = yA + (yB - yA) * e;
1302
- pts.push([x, cyy - w / 2, cyy + w / 2]);
1303
- }
1304
- ribbonRects(s, pts, STYLE.soft);
1305
- s.addText(String(v) + (sec.unit || ''), { x: (x0 + x1) / 2 - 0.5, y: (yA + yB) / 2 - 0.14, w: 1.0, h: 0.28,
1306
- align: 'center', fontFace: STYLE.font, fontSize: sz(9.5), color: STYLE.faint });
1307
- });
1308
- nodes.forEach(n => {
1309
- const g = geo[n];
1310
- s.addShape('rect', { x: g.x, y: g.y, w: G.nodeW, h: g.h, fill: { color: STYLE.accent }, line: { type: 'none' } });
1311
- const lw = Math.min(G.labelW, colW * 0.6);
1312
- const last = (g.lv === maxLv);
1313
- s.addText(n, { x: last ? (g.x - lw - 0.06) : (g.x + G.nodeW + 0.06), y: g.y, w: lw,
1314
- h: Math.max(0.24, g.h), align: last ? 'right' : 'left', valign: 'middle',
1315
- fontFace: STYLE.font, fontSize: sz(10.5), bold: true, color: STYLE.body });
1316
- });
1317
- }
1318
- /* 树图:按面积编码的矩形切分(递归二分,面积严格成比例) */
1319
- function tmSplit(items, x, y, w, h, out) {
1320
- if (!items.length) return;
1321
- if (items.length === 1) { out.push({ it: items[0], x: x, y: y, w: w, h: h }); return; }
1322
- const total = items.reduce((a, i) => a + i.v, 0) || 1;
1323
- let acc = 0, k = 0;
1324
- for (; k < items.length - 1; k++) { acc += items[k].v; if (acc >= total / 2) break; }
1325
- const first = items.slice(0, k + 1), second = items.slice(k + 1);
1326
- const r = first.reduce((a, i) => a + i.v, 0) / total;
1327
- if (w >= h) {
1328
- tmSplit(first, x, y, w * r, h, out);
1329
- tmSplit(second, x + w * r, y, w * (1 - r), h, out);
1330
- } else {
1331
- tmSplit(first, x, y, w, h * r, out);
1332
- tmSplit(second, x, y + h * r, w, h * (1 - r), out);
1333
- }
1334
- }
1335
- function infoTreemap(s, sec, y0, y1) {
1336
- const G = PT.treemap;
1337
- const P = infoPlot(sec, y0, y1);
1338
- if (P.table) infoTable(s, P.rows, MX, P.table.y, CW, P.table.h);
1339
- const items = (sec.items || []).map(it => ({ label: String(it[0]), v: Math.max(0, Number(it[1]) || 0) }))
1340
- .filter(i => i.v > 0).sort((a, b) => b.v - a.v).slice(0, G.maxLeaves);
1341
- if (!items.length) return;
1342
- const out = [];
1343
- tmSplit(items, MX, P.y0, CW, P.y1 - P.y0, out);
1344
- const maxV = items[0].v || 1;
1345
- out.forEach(o => {
1346
- const p = o.it.v / maxV;
1347
- const lvl = p >= 0.66 ? 3 : (p >= 0.33 ? 2 : 1);
1348
- const fill = [STYLE.surface, STYLE.soft, STYLE.line, STYLE.accent][lvl];
1349
- const onAcc = lvl >= 3;
1350
- s.addShape('rect', { x: o.x + G.gap / 2, y: o.y + G.gap / 2,
1351
- w: Math.max(0.05, o.w - G.gap), h: Math.max(0.05, o.h - G.gap),
1352
- fill: { color: fill }, line: { color: STYLE.bg, width: 0.75 } });
1353
- if (o.h >= G.labelMinH) {
1354
- s.addText(o.it.label, { x: o.x + 0.1, y: o.y + 0.06, w: Math.max(0.2, o.w - 0.2), h: 0.3,
1355
- fontFace: STYLE.font, fontSize: sz(10.5), bold: true, color: onAcc ? STYLE.onAccent : STYLE.ink });
1356
- s.addText(String(o.it.v) + (sec.unit || ''), { x: o.x + 0.1, y: o.y + 0.34, w: Math.max(0.2, o.w - 0.2), h: 0.28,
1357
- fontFace: STYLE.font, fontSize: sz(10), color: onAcc ? STYLE.onAccent : STYLE.body });
1358
- }
1359
- });
1360
- }
1361
- /* 箱线图:分组箱体(min/q1/median/q3/max)+ 须线端帽 */
1362
- function infoBoxplot(s, sec, y0, y1) {
1363
- const G = PT.boxplot;
1364
- const P = infoPlot(sec, y0, y1);
1365
- if (P.table) infoTable(s, P.rows, MX, P.table.y, CW, P.table.h);
1366
- const groups = (sec.groups || []).filter(g => g && g.length >= 6).slice(0, G.maxGroups);
1367
- if (!groups.length) return;
1368
- const nums = groups.map(g => g.slice(1, 6).map(Number));
1369
- const lo = Math.min.apply(null, nums.map(n => n[0]));
1370
- const hi = Math.max.apply(null, nums.map(n => n[4]));
1371
- const span = (hi - lo) || 1;
1372
- const plotH = P.y1 - P.y0 - 0.5;
1373
- const yFor = (v) => P.y0 + (1 - (v - lo) / span) * plotH;
1374
- const step = CW / groups.length;
1375
- const bw = Math.min(G.boxMaxW, step * 0.5);
1376
- s.addShape('rect', { x: MX, y: P.y0 + plotH, w: CW, h: 0.015, fill: { color: STYLE.line }, line: { type: 'none' } });
1377
- groups.forEach((g, i) => {
1378
- const mn = Number(g[1]), q1 = Number(g[2]), md = Number(g[3]), q3 = Number(g[4]), mx = Number(g[5]);
1379
- const cx = MX + step * i + step / 2;
1380
- s.addShape('rect', { x: cx - 0.012, y: yFor(mx), w: 0.024, h: Math.max(0.02, yFor(mn) - yFor(mx)),
1381
- fill: { color: STYLE.faint }, line: { type: 'none' } });
1382
- s.addShape('rect', { x: cx - G.whiskerCapW / 2, y: yFor(mx), w: G.whiskerCapW, h: 0.022, fill: { color: STYLE.faint }, line: { type: 'none' } });
1383
- s.addShape('rect', { x: cx - G.whiskerCapW / 2, y: yFor(mn) - 0.022, w: G.whiskerCapW, h: 0.022, fill: { color: STYLE.faint }, line: { type: 'none' } });
1384
- s.addShape('rect', { x: cx - bw / 2, y: yFor(q3), w: bw, h: Math.max(0.05, yFor(q1) - yFor(q3)),
1385
- fill: { color: STYLE.soft }, line: { color: STYLE.accent, width: 1 } });
1386
- s.addShape('rect', { x: cx - bw / 2, y: yFor(md) - 0.015, w: bw, h: 0.03, fill: { color: STYLE.accent }, line: { type: 'none' } });
1387
- s.addText(String(g[0]), { x: cx - step / 2, y: P.y1 - 0.42, w: step, h: 0.3, align: 'center',
1388
- fontFace: STYLE.font, fontSize: sz(10.5), color: STYLE.body });
1389
- s.addText(String(md) + (sec.unit || ''), { x: cx - step / 2, y: yFor(md) - 0.42, w: step, h: 0.26, align: 'center',
1390
- fontFace: STYLE.font, fontSize: sz(10), bold: true, color: STYLE.accent });
1391
- s.addText(String(mn) + (sec.unit || ''), { x: cx - step / 2, y: yFor(mn) + 0.03, w: step, h: 0.24, align: 'center',
1392
- fontFace: STYLE.font, fontSize: sz(9.5), color: STYLE.faint });
1393
- s.addText(String(mx) + (sec.unit || ''), { x: cx - step / 2, y: yFor(mx) - 0.28, w: step, h: 0.24, align: 'center',
1394
- fontFace: STYLE.font, fontSize: sz(9.5), color: STYLE.faint });
1395
- });
1396
- }
1397
- /* 关系网络:确定性环形布局(同环按输入顺序)+ 直连边 */
1398
- function infoNetwork(s, sec, y0, y1) {
1399
- const G = PT.network;
1400
- const P = infoPlot(sec, y0, y1);
1401
- if (P.table) infoTable(s, P.rows, MX, P.table.y, CW, P.table.h);
1402
- const nodes = (sec.nodes || []).slice(0, G.maxNodes);
1403
- if (!nodes.length) return;
1404
- const edges = (sec.edges || []).slice(0, G.maxEdges);
1405
- const n = nodes.length;
1406
- const cx = MX + CW / 2, cy = (P.y0 + P.y1) / 2;
1407
- const r = Math.min(CW / 2, (P.y1 - P.y0) / 2) - G.labelMaxW * 0.55;
1408
- const pos = {};
1409
- nodes.forEach((nd, i) => {
1410
- const a = -Math.PI / 2 + i * 2 * Math.PI / n;
1411
- pos[String(nd[0])] = { x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) };
1412
- });
1413
- edges.forEach(e => {
1414
- const a = pos[String(e[0])], b = pos[String(e[1])];
1415
- if (!a || !b) return;
1416
- const dx = b.x - a.x, dy = b.y - a.y;
1417
- const len = Math.sqrt(dx * dx + dy * dy);
1418
- const ang = Math.atan2(dy, dx) * 180 / Math.PI;
1419
- s.addShape('rect', { x: (a.x + b.x) / 2 - len / 2, y: (a.y + b.y) / 2 - 0.008, w: len, h: 0.016,
1420
- rotate: Math.round(ang * 10) / 10, fill: { color: STYLE.line }, line: { type: 'none' } });
1421
- });
1422
- nodes.forEach((nd, i) => {
1423
- const p = pos[String(nd[0])];
1424
- const deg = edges.filter(e => String(e[0]) === String(nd[0]) || String(e[1]) === String(nd[0])).length;
1425
- const rr = Math.max(G.nodeRMin, Math.min(G.nodeR, G.nodeRMin + deg * 0.03));
1426
- s.addShape('ellipse', { x: p.x - rr, y: p.y - rr, w: rr * 2, h: rr * 2,
1427
- fill: { color: deg ? STYLE.accent : STYLE.faint }, line: { color: STYLE.bg, width: 1 } });
1428
- const outward = p.x >= cx;
1429
- s.addText(String(nd[1] != null ? nd[1] : nd[0]),
1430
- { x: outward ? p.x + rr + 0.05 : p.x - rr - 0.05 - G.labelMaxW, y: p.y - 0.14, w: G.labelMaxW, h: 0.28,
1431
- align: outward ? 'left' : 'right', valign: 'middle', fontFace: STYLE.font, fontSize: sz(10), color: STYLE.body });
1432
- });
1433
- }
1434
- /* 马赛克图:列宽按列总量、列高按 100% 构成的双重编码 */
1435
- function infoMarimekko(s, sec, y0, y1) {
1436
- const G = PT.marimekko;
1437
- const P = infoPlot(sec, y0, y1);
1438
- if (P.table) infoTable(s, P.rows, MX, P.table.y, CW, P.table.h);
1439
- const cols = (sec.cols || []).slice(0, G.maxCols);
1440
- if (!cols.length) return;
1441
- const legend = (sec.legend || []).map(String);
1442
- const cells = sec.cells || [];
1443
- const total = cols.reduce((a, c) => a + (Number(c[1]) || 0), 0) || 1;
1444
- const plotH = (P.y1 - P.y0) - G.legendH - 0.1;
1445
- const availW = CW - G.colGap * Math.max(0, cols.length - 1);
1446
- const palette = [STYLE.accent, STYLE.faint, STYLE.body, STYLE.line];
1447
- let cx0 = MX;
1448
- cols.forEach((c, i) => {
1449
- const cw = Math.max(0.2, (Number(c[1]) || 0) / total * availW);
1450
- const shares = (cells[i] || []).map(Number).slice(0, G.maxSegs);
1451
- const sum = shares.reduce((a, b) => a + b, 0) || 1;
1452
- let cy0 = P.y0;
1453
- shares.forEach((v, k) => {
1454
- const sh = Math.max(0.02, v / sum * plotH);
1455
- s.addShape('rect', { x: cx0, y: cy0, w: cw, h: sh,
1456
- fill: { color: palette[k % palette.length] }, line: { color: STYLE.bg, width: 0.75 } });
1457
- s.addText(String(v) + (sec.unit || ''), { x: cx0, y: cy0 + sh / 2 - 0.13, w: cw, h: 0.26, align: 'center',
1458
- fontFace: STYLE.font, fontSize: sz(10), bold: true,
1459
- color: k === 0 ? STYLE.onAccent : STYLE.ink });
1460
- cy0 += sh;
1461
- });
1462
- s.addText(String(c[0]), { x: cx0, y: P.y0 + plotH + 0.06, w: cw, h: 0.3, align: 'center',
1463
- fontFace: STYLE.font, fontSize: sz(10.5), bold: true, color: STYLE.body });
1464
- cx0 += cw + G.colGap;
1465
- });
1466
- let lx = MX;
1467
- legend.forEach((lg, k) => {
1468
- s.addShape('rect', { x: lx, y: P.y1 - 0.2, w: 0.12, h: 0.12,
1469
- fill: { color: palette[k % palette.length] }, line: { type: 'none' } });
1470
- s.addText(lg, { x: lx + 0.16, y: P.y1 - 0.26, w: 1.5, h: 0.26,
1471
- fontFace: STYLE.font, fontSize: sz(10), color: STYLE.body, valign: 'middle' });
1472
- lx += 1.75;
1473
- });
1474
- }
1475
- /* 流带图:基线居中的堆叠平滑带(带边界上下分别采样,采样点 ≥24) */
1476
- function infoStreamgraph(s, sec, y0, y1) {
1477
- const G = PT.streamgraph;
1478
- const P = infoPlot(sec, y0, y1);
1479
- if (P.table) infoTable(s, P.rows, MX, P.table.y, CW, P.table.h);
1480
- const series = (sec.series || []).slice(0, G.maxSeries);
1481
- if (!series.length) return;
1482
- const labels = sec.labels || [];
1483
- const nPts = labels.length || ((series[0].values || []).length);
1484
- if (nPts < 2) return;
1485
- const values = series.map(se => (se.values || []).map(v => Math.max(0, Number(v) || 0)));
1486
- const totals = [];
1487
- for (let i = 0; i < nPts; i++) {
1488
- let t = 0;
1489
- values.forEach(v => { t += (v[i] || 0); });
1490
- totals.push(t);
1491
- }
1492
- const maxTot = Math.max.apply(null, totals.concat([1]));
1493
- const plotH = P.y1 - P.y0;
1494
- const cyMid = (P.y0 + P.y1) / 2;
1495
- const xAt = (i) => MX + (nPts > 1 ? i / (nPts - 1) : 0.5) * CW;
1496
- const yAt = (i) => cyMid - (totals[i] / maxTot) * plotH / 2;
1497
- const palette = [STYLE.accent, STYLE.faint, STYLE.body, STYLE.line, STYLE.soft, STYLE.surface];
1498
- const n = Math.max(24, Math.round(CW / 0.1));
1499
- let base = [];
1500
- for (let i = 0; i < nPts; i++) base.push(yAt(i));
1501
- series.forEach((se, si) => {
1502
- const upper = [];
1503
- for (let i = 0; i < nPts; i++) upper.push(base[i] + (values[si][i] || 0) / maxTot * plotH / 2);
1504
- const pts = [];
1505
- for (let k = 0; k <= n; k++) {
1506
- const t = k / n * (nPts - 1);
1507
- const i0 = Math.min(nPts - 2, Math.floor(t)), f = t - i0;
1508
- const x = MX + (k / n) * CW;
1509
- const bt = base[i0] + (base[i0 + 1] - base[i0]) * sstep(f);
1510
- const ut = upper[i0] + (upper[i0 + 1] - upper[i0]) * sstep(f);
1511
- pts.push([x, ut, bt]);
1512
- }
1513
- ribbonRects(s, pts, palette[si % palette.length]);
1514
- const lx = MX;
1515
- s.addShape('rect', { x: lx, y: P.y1 + 0.06 + si * 0.26, w: 0.12, h: 0.12,
1516
- fill: { color: palette[si % palette.length] }, line: { type: 'none' } });
1517
- s.addText(String(se.name || ('系列' + (si + 1))), { x: lx + 0.16, y: P.y1 + si * 0.26, w: 1.5, h: 0.26,
1518
- fontFace: STYLE.font, fontSize: sz(10), color: STYLE.body, valign: 'middle' });
1519
- base = upper;
1520
- });
1521
- labels.forEach((lb, i) => {
1522
- s.addText(String(lb), { x: xAt(i) - 0.4, y: P.y1 - 0.02, w: 0.8, h: 0.24, align: 'center',
1523
- fontFace: STYLE.font, fontSize: sz(9.5), color: STYLE.faint });
1524
- });
1525
- }
1526
- /* 表格:行高按可用高度自适应(research 密表 ≤16 行不再溢出) */
1527
- function addTable(s, tbl, x, y, w, availH, opts) {
1528
- opts = opts || {};
1529
- const rowsIn = tbl.rows || [];
1530
- const head = tbl.head || [];
1531
- const n = rowsIn.length + 1;
1532
- const maxRowH = opts.maxRowH != null ? opts.maxRowH : PT.table.rowH;
1533
- const minRowH = opts.minRowH != null ? opts.minRowH : PT.table.rowHMin;
1534
- const rowH = Math.max(minRowH, Math.min(maxRowH, availH / Math.max(1, n)));
1535
- /* 行多则同步收字号(research 密排允许更小) */
1536
- const dense = rowH < 0.42;
1537
- const headSz = sz(dense ? 11 : 13), bodySz = sz(dense ? 10 : 12.5);
1538
- const rows = [head.map(h => ({ text: h, options: { bold: true, color: STYLE.onAccent,
1539
- fill: { color: STYLE.accent }, fontSize: headSz, fontFace: STYLE.font, valign: 'middle' } }))]
1540
- .concat(rowsIn.map(r => r.map(c => ({ text: String(c), options: { fontSize: bodySz,
1541
- color: STYLE.body, fontFace: STYLE.font, valign: 'middle', fill: { color: STYLE.bg } } }))));
1542
- s.addTable(rows, { x, y, w, colW: tbl.colW, border: { pt: 0.75, color: STYLE.line },
1543
- rowH: rowH, autoPage: false, margin: dense ? 2 : 5 });
1544
- return n * rowH;
1545
- }
1546
-
1547
- /* 封面 */
1548
- (function cover() {
1549
- const s = base();
1550
- const C = PT.cover;
1551
- s.addShape('rect', { x: 0, y: 0, w: PW, h: PH, fill: { color: STYLE.accent }, line: { type: 'none' } });
1552
- s.addText(CONTENT.meta || '', { x: MX, y: C.metaY, w: CW, h: 0.4, fontFace: STYLE.font,
1553
- fontSize: sz(12), color: STYLE.soft });
1554
- const tSize = estTextH(CONTENT.title, CW - 1, sz(44), 1.25) > C.titleH ? sz(36) : sz(44);
1555
- s.addText(CONTENT.title, { x: MX, y: C.titleY, w: CW - 1, h: C.titleH, fontFace: STYLE.fontDisplay,
1556
- fontSize: tSize, bold: true, color: STYLE.onAccent });
1557
- s.addText(CONTENT.subtitle || '', { x: MX, y: C.subtitleY, w: CW - 1, h: C.subtitleH,
1558
- fontFace: STYLE.font, fontSize: sz(18), color: STYLE.soft });
1559
- s.addNotes([CONTENT.subtitle, CONTENT.meta].filter(Boolean).join('\n'));
1560
- })();
1561
-
1562
- /* Agenda(architecture 极简形态可省略 agenda → 不产出大纲页;>8 条自动双列;行高自适应防溢出) */
1563
- (function agenda() {
1564
- if (!Array.isArray(CONTENT.agenda) || !CONTENT.agenda.length) return;
1565
- const s = base();
1566
- head(s, 'AGENDA', '报告大纲');
1567
- const items = CONTENT.agenda;
1568
- const colN = items.length > 8 ? 2 : 1;
1569
- const perCol = Math.ceil(items.length / colN);
1570
- const availH = CONTENT_BOTTOM - CONTENT_TOP;
1571
- const rowH = Math.min(1.05, availH / perCol);
1572
- const numSize = rowH >= 0.85 ? sz(30) : (rowH >= 0.65 ? sz(22) : sz(18));
1573
- const tSize = rowH >= 0.85 ? sz(16) : sz(14);
1574
- const dSize = rowH >= 0.85 ? sz(12) : sz(11);
1575
- items.forEach((it, i) => {
1576
- const col = Math.floor(i / perCol), row = i % perCol;
1577
- const x = MX + col * (CW / colN), y = CONTENT_TOP + row * rowH;
1578
- s.addText(it[0], { x, y, w: 0.9, h: rowH - 0.05, fontFace: STYLE.font, fontSize: numSize,
1579
- bold: true, color: STYLE.accent, valign: 'top' });
1580
- s.addText([
1581
- { text: it[1] + '\n', options: { fontSize: tSize, bold: true, color: STYLE.ink } },
1582
- { text: it[2] || '', options: { fontSize: dSize, color: STYLE.body } },
1583
- ], { x: x + 1.0, y: y + 0.03, w: CW / colN - 1.15, h: rowH - 0.08, fontFace: STYLE.font,
1584
- valign: 'top', lineSpacing: tSize + 5 });
1585
- });
1586
- })();
1587
-
1588
- /* 章节页 · 按页型分发 */
1589
- CONTENT.sections.forEach((sec) => {
1590
- const type = sec.type || 'points';
1591
- const s = base();
1592
- if (type !== 'quote') head(s, sec.eyebrow, sec.title, sec.lead);
1593
-
1594
- /* 正文区起点 / 终点(含 lead 与 so-what / 来源行 / 待核实条占位) */
1595
- /* 非 exhibit 页型也可带 Exhibit 编号(帧头徽标;正文区下移让位,避免与主图重叠) */
1596
- const exBadge = (type !== 'exhibit' && sec.exhibitNo) ? String(sec.exhibitNo).toUpperCase() : null;
1597
- const bodyY0 = sec.lead ? PT.common.bodyYWithLead : CONTENT_TOP;
1598
- const bodyY = exBadge ? bodyY0 + 0.42 : bodyY0;
1599
- if (exBadge) {
1600
- s.addText(exBadge, { x: MX, y: CONTENT_TOP - 0.06, w: CW, h: 0.3, fontFace: STYLE.font,
1601
- fontSize: sz(11.5), bold: true, color: STYLE.accent, charSpacing: 2 });
1602
- }
1603
- const flagItems = Array.isArray(sec.flags) ? sec.flags.filter(Boolean) : [];
1604
- const _F = PT.flagbar || { hdH: 0.26, rowH: 0.24, maxRows: 3, gap: 0.12 };
1605
- const flagH = flagItems.length
1606
- ? (_F.hdH + Math.min(_F.maxRows, flagItems.length) * _F.rowH + 0.06) : 0;
1607
- let bodyBottom = chartBottom(!!sec.soWhat, !!sec.footnote);
1608
- let flagY = 0;
1609
- if (flagH) {
1610
- /* 待核实条底对齐 contentBottom;与来源行 / so-what 同页时上移让位(不重叠) */
1611
- flagY = CONTENT_BOTTOM - flagH;
1612
- if (sec.footnote) flagY = Math.min(flagY, PT.exhibit.footnoteY - flagH - 0.06);
1613
- if (sec.soWhat) flagY = Math.min(flagY, PT.exhibit.soWhatY - flagH - 0.06);
1614
- bodyBottom = Math.min(bodyBottom, flagY - (_F.gap || 0.12));
1615
- }
1616
-
1617
- if (type === 'metrics') {
1618
- const M = PT.metrics;
1619
- const reg = REGIONS.regionOf('metrics', 'primary') ||
1620
- { x: MX, y: M.bandY, w: CW, h: M.cardH };
1621
- const ms = sec.metrics || [];
1622
- const n = Math.max(1, ms.length);
1623
- const g = cols(reg.w, n, M.gap);
1624
- const mw = Math.min(M.maxW, g.w);
1625
- const gx = reg.x + (reg.w - n * mw - (n - 1) * M.gap) / 2;
1626
- const cardH = Math.min(reg.h, CONTENT_BOTTOM - reg.y);
1627
- ms.forEach((m, i) => {
1628
- const x = gx + i * (mw + M.gap);
1629
- s.addShape('roundRect', { x, y: reg.y, w: mw, h: cardH, rectRadius: 0.09,
1630
- fill: { color: STYLE.surface }, line: { color: STYLE.line, width: 0.75 } });
1631
- const vSize = estTextH(m[0], mw - 0.2, sz(40), 1.1) > cardH * 0.5 ? sz(30) : sz(40);
1632
- s.addText(m[0], { x, y: M.valY, w: mw, h: M.valH, align: 'center', fontFace: STYLE.fontDisplay,
1633
- fontSize: vSize, bold: true, color: STYLE.accent });
1634
- s.addText(m[1], { x: x + 0.15, y: M.capY, w: mw - 0.3, h: M.capH, align: 'center',
1635
- fontFace: STYLE.font, fontSize: sz(11.5), color: STYLE.body });
1636
- });
1637
- } else if (type === 'kpi') {
1638
- const K = PT.kpi;
1639
- const hreg = REGIONS.regionOf('kpi', 'hero') ||
1640
- { x: MX, y: K.heroY, w: CW * K.heroW, h: K.heroH };
1641
- const hro = sec.hero || [];
1642
- s.addText(String(hro[0] || ''), { x: hreg.x, y: hreg.y, w: hreg.w, h: hreg.h,
1643
- fontFace: STYLE.fontDisplay, fontSize: sz(64), bold: true, color: STYLE.accent });
1644
- s.addText(String(hro[1] || ''), { x: hreg.x + 0.05, y: hreg.y + 1.5, w: hreg.w - 0.4, h: 0.55,
1645
- fontFace: STYLE.font, fontSize: sz(15), bold: true, color: STYLE.ink });
1646
- if (hro[2]) s.addText('▲ ' + hro[2], { x: hreg.x + 0.05, y: hreg.y + 2.1, w: hreg.w - 0.4, h: 0.4,
1647
- fontFace: STYLE.font, fontSize: sz(12), bold: true, color: STYLE.accent });
1648
- const kdivX = MX + CW * K.dividerX;
1649
- s.addShape('rect', { x: kdivX, y: K.heroY + 0.15, w: 0.025, h: 3.1,
1650
- fill: { color: STYLE.line }, line: { type: 'none' } });
1651
- /* 支撑指标行:按可用高度收敛行高并截断,保证不越过 contentBottom */
1652
- const mreg = REGIONS.regionOf('kpi', 'metrics') ||
1653
- { x: kdivX + 0.35, y: K.metricY0, w: CW * (1 - K.dividerX) - 0.5, h: CONTENT_BOTTOM - K.metricY0 };
1654
- const kmets = sec.metrics || [];
1655
- const kCap = Math.max(1, Math.floor((CONTENT_BOTTOM - mreg.y) / K.metricRowH));
1656
- const kRows = Math.min(kmets.length, kCap);
1657
- const kRowH = fitRowH(CONTENT_BOTTOM - mreg.y, kRows, K.metricRowH, 0.42);
1658
- kmets.slice(0, kRows).forEach((m, i) => {
1659
- const ky = mreg.y + i * kRowH;
1660
- s.addText(m[0], { x: mreg.x, y: ky, w: mreg.w, h: Math.min(0.55, kRowH * 0.62),
1661
- fontFace: STYLE.fontDisplay, fontSize: sz(22), bold: true, color: STYLE.accent });
1662
- s.addText(m[1], { x: mreg.x, y: ky + Math.min(0.55, kRowH * 0.62), w: mreg.w,
1663
- h: Math.max(0.24, kRowH - Math.min(0.55, kRowH * 0.62)), fontFace: STYLE.font,
1664
- fontSize: sz(10.5), color: STYLE.faint });
1665
- });
1666
- } else if (type === 'comparison') {
1667
- const C2 = PT.comparison;
1668
- const lreg = REGIONS.regionOf('comparison', 'left') ||
1669
- { x: MX, y: C2.panelY, w: (CW - C2.panelGap) / 2, h: C2.panelH };
1670
- const rreg = REGIONS.regionOf('comparison', 'right') ||
1671
- { x: MX + (CW + C2.panelGap) / 2, y: C2.panelY, w: (CW - C2.panelGap) / 2, h: C2.panelH };
1672
- const cw2 = lreg.w;
1673
- const cpy = lreg.y, cph = lreg.h;
1674
- const rx2 = rreg.x;
1675
- s.addShape('roundRect', { x: lreg.x, y: cpy, w: cw2, h: cph, rectRadius: 0.09,
1676
- fill: { color: STYLE.surface }, line: { color: STYLE.line, width: 0.75 } });
1677
- s.addShape('roundRect', { x: rx2, y: cpy, w: cw2, h: cph, rectRadius: 0.09,
1678
- fill: { color: STYLE.soft }, line: { type: 'none' } });
1679
- [{ x: lreg.x, d: sec.left || {} }, { x: rx2, d: sec.right || {} }].forEach((sd) => {
1680
- const isRight = sd.x === rx2;
1681
- const pts = sd.d.points || [];
1682
- /* 行高按面板可用高度自适应 */
1683
- const avail = cph - C2.titleH - 0.34;
1684
- const rowH = fitRowH(avail, pts.length, C2.rowH, 0.34);
1685
- const fzBase = rowH < 0.5 ? 11.5 : 13; /* 基准 pt(presentation 比例尺) */
1686
- const fz = sz(fzBase), fzSm = sz(fzBase - 1); /* 各映射一次,避免二次缩放 */
1687
- s.addText(sd.d.title || '', { x: sd.x + 0.22, y: cpy + 0.18, w: cw2 - 0.44, h: C2.titleH,
1688
- fontFace: STYLE.font, fontSize: sz(15), bold: true, color: isRight ? STYLE.accent : STYLE.ink });
1689
- pts.forEach((p, pi) => {
1690
- const py = cpy + 0.18 + C2.titleH + 0.16 + pi * rowH;
1691
- s.addShape('rect', { x: sd.x + 0.24, y: py + rowH / 2 - 0.06, w: 0.12, h: 0.12,
1692
- fill: { color: isRight ? STYLE.accent : STYLE.faint }, line: { type: 'none' } });
1693
- s.addText([
1694
- { text: p[0] + ' ', options: { fontSize: fz, bold: true, color: STYLE.ink } },
1695
- { text: p[1], options: { fontSize: fzSm, color: STYLE.body } },
1696
- ], { x: sd.x + 0.5, y: py, w: cw2 - 0.75, h: rowH - 0.04, fontFace: STYLE.font, valign: 'top' });
1697
- });
1698
- });
1699
- if (sec.verdict) {
1700
- const vreg = REGIONS.regionOf('exhibit', 'annotation') ||
1701
- { x: MX, y: PT.exhibit.soWhatY, w: CW, h: 0.62 };
1702
- s.addShape('rect', { x: vreg.x, y: vreg.y, w: vreg.w, h: vreg.h,
1703
- fill: { color: STYLE.accent }, line: { type: 'none' } });
1704
- s.addText([
1705
- { text: '结论 ', options: { fontSize: sz(11), bold: true, color: STYLE.onAccent, charSpacing: 1.5 } },
1706
- { text: sec.verdict, options: { fontSize: sz(12.5), bold: true, color: STYLE.onAccent } },
1707
- ], { x: vreg.x + 0.22, y: vreg.y + 0.06, w: vreg.w - 0.44, h: vreg.h - 0.12, fontFace: STYLE.font, valign: 'middle' });
1708
- }
1709
- } else if (type === 'quote') {
1710
- const Q = PT.quote;
1711
- const qreg = REGIONS.regionOf('quote', 'primary') ||
1712
- { x: MX + 1.05, y: Q.textY, w: CW - 2.1, h: Q.textH };
1713
- /* 金句页用主题一致强调带(accent-soft 底)——浅色主题下不再出现深色页 */
1714
- s.addShape('rect', { x: 0, y: 0, w: PW, h: PH, fill: { color: STYLE.soft }, line: { type: 'none' } });
1715
- if (sec.eyebrow) s.addText(sec.eyebrow, { x: MX, y: PT.common.headEyebrowY, w: CW, h: 0.4,
1716
- fontFace: STYLE.font, fontSize: sz(13), bold: true, color: STYLE.accent, charSpacing: 2 });
1717
- s.addText(sec.title, { x: MX, y: PT.common.headTitleY, w: CW, h: 1.0,
1718
- fontFace: STYLE.fontDisplay, fontSize: sz(30), bold: true, color: STYLE.ink });
1719
- s.addShape('rect', { x: MX, y: PT.common.headRuleY, w: 0.9, h: 0.045, fill: { color: STYLE.accent }, line: { type: 'none' } });
1720
- s.addText('「', { x: MX + 0.05, y: Q.markY, w: 1.2, h: 1.1,
1721
- fontFace: STYLE.fontDisplay, fontSize: sz(60), bold: true, color: STYLE.accent });
1722
- const qSize = estTextH(sec.quote, qreg.w, sz(20), 1.4) > qreg.h ? sz(16) : sz(20);
1723
- s.addText(sec.quote, { x: qreg.x, y: qreg.y, w: qreg.w, h: qreg.h,
1724
- fontFace: STYLE.fontDisplay, fontSize: qSize, bold: true, color: STYLE.ink, valign: 'middle' });
1725
- s.addText('—— ' + sec.author, { x: qreg.x, y: Q.authorY, w: qreg.w, h: 0.45, align: 'right',
1726
- fontFace: STYLE.font, fontSize: sz(13), bold: true, color: STYLE.accent });
1727
- if (sec.context) s.addText(sec.context, { x: qreg.x, y: Q.contextY, w: qreg.w, h: 0.4, align: 'right',
1728
- fontFace: STYLE.font, fontSize: sz(11), color: STYLE.faint });
1729
- } else if (type === 'image') {
1730
- /* 素材图片页:full 版心全宽 / half 左图右注 / bleed 通栏出血 / grid 多图网格 /
1731
- compare 双图对比 / wall Logo 墙;无图(image.placeholder)走原生占位框 + 标签文本 */
1732
- const IM = PT.image;
1733
- const img = sec.image || {};
1734
- const items = Array.isArray(img.items) ? img.items.filter(Boolean) : [];
1735
- const layout = String(img.layout || (items.length > 1 ? 'grid' : 'full')).toLowerCase();
1736
- const fit = String(img.fit || IMG.fitDefault || 'cover').toLowerCase();
1737
- const isPh = !!img.placeholder;
1738
- const ireg = REGIONS.regionOf('image', 'primary', { top: bodyY, caption: !!img.caption }) ||
1739
- { x: MX, y: Math.max(IM.y, bodyY), w: CW, h: IM.h };
1740
- const iy = ireg.y;
1741
- const capH = img.caption ? 0.32 : 0;
1742
- const ih = Math.max(1.2, ireg.h);
1743
- imageLayoutShapes(s, layout, img, items, sec.points || [], isPh, fit, iy, iy + ih);
1744
- if (img.caption) s.addText(img.caption, { x: MX, y: iy + ih + 0.04, w: CW, h: capH,
1745
- fontFace: STYLE.font, fontSize: sz(10.5), color: STYLE.faint });
1746
- } else if (type === 'donut') {
1747
- const dcn = sec.chart || {};
1748
- if (dcn.labels && dcn.values) {
1749
- const d = PT.donut;
1750
- const dcols = dcn.colors || donutColors(styleArg);
1751
- nativeDonutChart(s, dcn, dcols);
1752
- const hb = d.holeR * 2;
1753
- let total = 0; dcn.values.forEach(v => { total += Math.max(0, v); });
1754
- const totalStr = (Math.round(total * 10) / 10) + (dcn.unit || '');
1755
- s.addText(totalStr, { x: d.centerX - d.holeR, y: d.centerY - 0.42, w: hb, h: 0.5, align: 'center',
1756
- fontFace: STYLE.fontDisplay, fontSize: sz(20), bold: true, color: STYLE.ink, valign: 'middle' });
1757
- s.addText(dcn.centerLabel || '合计', { x: d.centerX - d.holeR, y: d.centerY + 0.08, w: hb, h: 0.35, align: 'center',
1758
- fontFace: STYLE.font, fontSize: sz(9.5), color: STYLE.faint, valign: 'middle' });
1759
- const n = Math.max(1, dcn.labels.length);
1760
- const rowH = Math.min(d.legendRowH, 3.4 / n);
1761
- const ly0 = d.centerY - (n * rowH) / 2 + 0.1;
1762
- const sum2 = total || 1;
1763
- dcn.labels.forEach((lb, i) => {
1764
- const v = dcn.values[i] || 0;
1765
- const y = ly0 + i * rowH;
1766
- s.addShape('ellipse', { x: d.legendX, y: y + 0.08, w: 0.16, h: 0.16,
1767
- fill: { color: dcols[i % dcols.length] }, line: { type: 'none' } });
1768
- s.addText(lb, { x: d.legendX + 0.3, y, w: d.legendLabelW, h: rowH, valign: 'middle',
1769
- fontFace: STYLE.font, fontSize: sz(12), bold: true, color: STYLE.ink });
1770
- s.addText(String(v) + (dcn.unit || ''), { x: d.legendX + 3.8, y, w: d.legendValW, h: rowH, align: 'right',
1771
- valign: 'middle', fontFace: STYLE.font, fontSize: sz(12), bold: true, color: STYLE.body });
1772
- s.addText((Math.round(v / sum2 * 1000) / 10) + '%', { x: d.legendPctX, y, w: d.legendPctW, h: rowH,
1773
- align: 'right', valign: 'middle', fontFace: STYLE.font, fontSize: sz(10.5), color: STYLE.faint });
1774
- });
1775
- }
1776
- if (sec.note) s.addText(sec.note, { x: MX, y: PT.note.y, w: CW, h: PT.note.h,
1777
- fontFace: STYLE.font, fontSize: sz(11), color: STYLE.faint });
1778
- } else if (type === 'heatmap') {
1779
- /* 热力矩阵:行 × 列 + 4 级色阶(surface-1 → accent-soft → accent-soft-2 → accent) */
1780
- const H = PT.heatmap;
1781
- const hreg = REGIONS.regionOf('heatmap', 'primary', { bottom: bodyBottom }) ||
1782
- { x: MX, y: H.y, w: CW, h: bodyBottom - H.y };
1783
- const rowsH = sec.rowHeads || [], colsH = sec.colHeads || [];
1784
- const cells = sec.cells || [];
1785
- const nR = Math.max(1, rowsH.length), nC = Math.max(1, colsH.length);
1786
- const availH = hreg.h;
1787
- const rowH = fitRowH(availH - H.headH, nR, H.cellH, 0.24);
1788
- const gridW = hreg.w - H.labelW;
1789
- const cw = (gridW - (nC - 1) * H.cellGap) / nC;
1790
- const flat = cells.flat().map(Number).filter(v => !isNaN(v));
1791
- const vmax = flat.length ? Math.max.apply(null, flat) : 1;
1792
- const vmin = flat.length ? Math.min.apply(null, flat) : 0;
1793
- const levels = [STYLE.surface, STYLE.soft, STYLE.line, STYLE.accent];
1794
- colsH.forEach((ch, ci) => {
1795
- s.addText(ch, { x: hreg.x + H.labelW + ci * (cw + H.cellGap), y: hreg.y, w: cw, h: H.headH,
1796
- align: 'center', valign: 'middle', fontFace: STYLE.font, fontSize: sz(11), bold: true, color: STYLE.body });
1797
- });
1798
- const rowsUse = Math.min(nR, cells.length);
1799
- for (let ri = 0; ri < rowsUse; ri++) {
1800
- const y = hreg.y + H.headH + 0.08 + ri * (rowH + H.cellGap);
1801
- s.addText(rowsH[ri] || '', { x: hreg.x, y, w: H.labelW - 0.15, h: rowH, align: 'right', valign: 'middle',
1802
- fontFace: STYLE.font, fontSize: sz(11), bold: true, color: STYLE.body });
1803
- for (let ci = 0; ci < nC; ci++) {
1804
- const v = Number((cells[ri] || [])[ci]);
1805
- const has = !isNaN(v);
1806
- const p = (has && vmax > vmin) ? (v - vmin) / (vmax - vmin) : 0.5;
1807
- const lvl = p >= 0.86 ? 3 : (p >= 0.55 ? 2 : (p >= 0.25 ? 1 : 0));
1808
- const onAcc = lvl >= 3;
1809
- s.addShape('roundRect', { x: hreg.x + H.labelW + ci * (cw + H.cellGap), y, w: cw, h: rowH,
1810
- rectRadius: 0.05, fill: { color: levels[lvl] },
1811
- line: lvl === 0 ? { color: STYLE.line, width: 0.75 } : { type: 'none' } });
1812
- s.addText(has ? (String(v) + (sec.unit || '')) : '—',
1813
- { x: hreg.x + H.labelW + ci * (cw + H.cellGap), y, w: cw, h: rowH, align: 'center', valign: 'middle',
1814
- fontFace: STYLE.font, fontSize: sz(11.5), bold: lvl >= 2, color: onAcc ? STYLE.onAccent : STYLE.body });
1815
- }
1816
- }
1817
- const lg = sec.scaleLabel || ['低', '高'];
1818
- s.addText(lg[0] || '低', { x: hreg.x, y: hreg.y + H.headH + 0.08 + rowsUse * (rowH + H.cellGap) + 0.06,
1819
- w: 0.6, h: 0.3, fontFace: STYLE.font, fontSize: sz(9.5), color: STYLE.faint });
1820
- for (let i = 0; i < 4; i++) {
1821
- s.addShape('rect', { x: hreg.x + 0.45 + i * 0.3, y: hreg.y + H.headH + 0.08 + rowsUse * (rowH + H.cellGap) + 0.1,
1822
- w: 0.28, h: 0.16, fill: { color: levels[i] }, line: i === 0 ? { color: STYLE.line, width: 0.5 } : { type: 'none' } });
1823
- }
1824
- s.addText(lg[1] || '高', { x: hreg.x + 1.75, y: hreg.y + H.headH + 0.08 + rowsUse * (rowH + H.cellGap) + 0.06,
1825
- w: 0.6, h: 0.3, fontFace: STYLE.font, fontSize: sz(9.5), color: STYLE.faint });
1826
- } else if (type === 'bullet') {
1827
- /* 达成对比:底槽 + 实际条 + 目标刻度 + 数值(实际/目标) */
1828
- const B = PT.bullet;
1829
- const breg = REGIONS.regionOf('bullet', 'primary', { bottom: bodyBottom }) ||
1830
- { x: MX, y: B.y, w: CW, h: bodyBottom - B.y };
1831
- const items = sec.items || [];
1832
- const n = Math.max(1, items.length);
1833
- const availH = breg.h;
1834
- const rowH = fitRowH(availH, n, B.rowH, 0.26);
1835
- const trackX = breg.x + B.labelW + 0.15;
1836
- const trackW = breg.w - B.labelW - B.valW - 0.3;
1837
- const nums = items.map(it => Number(it[1])).filter(v => !isNaN(v));
1838
- const vmax = sec.max || (nums.length ? Math.max.apply(null, nums) : 1);
1839
- items.forEach((it, i) => {
1840
- const y = breg.y + i * rowH + rowH / 2 - B.barH / 2;
1841
- const v = Number(it[1]) || 0, tgt = Number(it[2]);
1842
- const hit = !isNaN(tgt) && v >= tgt;
1843
- s.addText(String(it[0]), { x: breg.x, y: breg.y + i * rowH, w: B.labelW, h: rowH, valign: 'middle',
1844
- fontFace: STYLE.font, fontSize: sz(12), bold: true, color: STYLE.ink });
1845
- s.addShape('roundRect', { x: trackX, y, w: trackW, h: B.barH, rectRadius: 0.5, fill: { color: STYLE.surface }, line: { color: STYLE.line, width: 0.75 } });
1846
- const fw = Math.max(0.02, Math.min(1, v / (vmax || 1)) * trackW);
1847
- s.addShape('roundRect', { x: trackX, y, w: fw, h: B.barH, rectRadius: 0.5,
1848
- fill: { color: hit ? STYLE.accent : STYLE.faint }, line: { type: 'none' } });
1849
- if (!isNaN(tgt)) {
1850
- const tx = trackX + Math.min(1, tgt / (vmax || 1)) * trackW;
1851
- s.addShape('rect', { x: tx - 0.01, y: y - 0.07, w: 0.02, h: B.barH + 0.14, fill: { color: STYLE.body }, line: { type: 'none' } });
1852
- }
1853
- s.addText([{ text: String(it[1]) + (sec.unit || ''), options: { fontSize: sz(12), bold: true, color: hit ? STYLE.accent : STYLE.body } },
1854
- { text: isNaN(tgt) ? '' : ' / ' + tgt + (sec.unit || ''), options: { fontSize: sz(10), color: STYLE.faint } }],
1855
- { x: trackX + trackW + 0.12, y: breg.y + i * rowH, w: B.valW, h: rowH, align: 'right', valign: 'middle', fontFace: STYLE.font });
1856
- });
1857
- } else if (type === 'pyramid') {
1858
- /* 金字塔:自上而下逐层加宽;顶层最窄,accent 层强调 */
1859
- const P = PT.pyramid;
1860
- const preg = REGIONS.regionOf('pyramid', 'primary', { bottom: bodyBottom }) ||
1861
- { x: MX, y: P.y, w: CW, h: bodyBottom - P.y };
1862
- const lv = sec.levels || [];
1863
- const n = Math.max(1, lv.length);
1864
- const availH = preg.h;
1865
- const rowH = fitRowH(availH - (n - 1) * P.gap, n, P.rowH, 0.26);
1866
- lv.forEach((l, i) => {
1867
- const frac = P.minW + (1 - P.minW) * (n === 1 ? 1 : i / (n - 1));
1868
- const w = preg.w * frac;
1869
- const x = preg.x + (preg.w - w) / 2;
1870
- const y = preg.y + i * (rowH + P.gap);
1871
- const acc = isRec(l) && l.accent;
1872
- const lt = isRec(l) ? (l.t || '') : String(l[0] || '');
1873
- const ld = isRec(l) ? (l.d || '') : String(l[1] || '');
1874
- s.addShape('roundRect', { x, y, w, h: rowH, rectRadius: 0.07,
1875
- fill: { color: acc ? STYLE.soft : STYLE.surface },
1876
- line: acc ? { type: 'none' } : { color: STYLE.line, width: 0.75 } });
1877
- s.addText(lt, { x: x + 0.22, y, w: Math.min(P.labelW, w - 0.44), h: rowH, valign: 'middle',
1878
- fontFace: STYLE.font, fontSize: sz(13.5), bold: true, color: acc ? STYLE.accent : STYLE.ink });
1879
- if (ld) s.addText(ld, { x: x + 0.22 + Math.min(P.labelW, w - 0.44), y, w: w - 0.44 - Math.min(P.labelW, w - 0.44), h: rowH,
1880
- valign: 'middle', fontFace: STYLE.font, fontSize: sz(11), color: STYLE.faint });
1881
- });
1882
- } else if (type === 'steps') {
1883
- /* 步骤条:N 步横排 + 箭头;超 maxPerRow 折行;groups 分组标签 */
1884
- const S2 = PT.steps;
1885
- const sreg = REGIONS.regionOf('steps', 'primary') ||
1886
- { x: MX, y: S2.y, w: CW, h: S2.barH };
1887
- const st = sec.steps || [];
1888
- const maxPer = S2.maxPerRow || 6;
1889
- const groups = sec.groups || [];
1890
- /* 按分组切片(groups = [[标签, 步数]…];缺省全为一段) */
1891
- const chunks = [];
1892
- if (groups.length) {
1893
- let k = 0;
1894
- groups.forEach(g => { const cnt = Number(g[1]) || 0; chunks.push({ label: g[0], items: st.slice(k, k + cnt) }); k += cnt; });
1895
- if (k < st.length) chunks.push({ label: '', items: st.slice(k) });
1896
- } else {
1897
- for (let i = 0; i < st.length; i += maxPer) chunks.push({ label: '', items: st.slice(i, i + maxPer) });
1898
- }
1899
- const rowsN = chunks.length;
1900
- const availH = bodyBottom - sreg.y;
1901
- const rowH = fitRowH(availH - (rowsN - 1) * 0.3, rowsN, sreg.h || S2.barH, 0.3);
1902
- let cy = sreg.y;
1903
- chunks.forEach((ck) => {
1904
- if (ck.label) {
1905
- s.addText(ck.label, { x: sreg.x, y: cy - 0.02, w: sreg.w, h: 0.26, fontFace: STYLE.font,
1906
- fontSize: sz(10), bold: true, color: STYLE.faint, charSpacing: 1 });
1907
- cy += 0.28;
1908
- }
1909
- const n = Math.max(1, ck.items.length);
1910
- const g = cols(sreg.w, n, S2.gap + S2.arrowW);
1911
- ck.items.forEach((it, i) => {
1912
- const acc = isRec(it) && it.accent;
1913
- const t = isRec(it) ? (it.t || '') : String(it[0] || '');
1914
- const d = isRec(it) ? (it.d || '') : String(it[1] || '');
1915
- const x = sreg.x + i * g.step;
1916
- s.addShape('roundRect', { x, y: cy, w: g.w, h: rowH, rectRadius: 0.07,
1917
- fill: { color: acc ? STYLE.soft : STYLE.surface },
1918
- line: acc ? { type: 'none' } : { color: STYLE.line, width: 0.75 } });
1919
- const pd = S2.pad, numH = S2.numH, ttlH = S2.titleH;
1920
- s.addText(String(i + 1).padStart(2, '0'), { x: x + 0.14, y: cy + pd, w: g.w - 0.28, h: numH,
1921
- fontFace: STYLE.font, fontSize: sz(10), bold: true, color: STYLE.accent });
1922
- s.addText(t, { x: x + 0.14, y: cy + pd + numH, w: g.w - 0.28, h: ttlH,
1923
- fontFace: STYLE.font, fontSize: sz(13), bold: true, color: acc ? STYLE.accent : STYLE.ink });
1924
- if (d) s.addText(d, { x: x + 0.14, y: cy + pd + numH + ttlH, w: g.w - 0.28,
1925
- h: Math.max(0.2, rowH - pd * 2 - numH - ttlH),
1926
- fontFace: STYLE.font, fontSize: sz(10), color: STYLE.faint });
1927
- if (i < n - 1) {
1928
- s.addText('→', { x: x + g.w, y: cy, w: S2.arrowW + S2.gap, h: rowH, align: 'center', valign: 'middle',
1929
- fontFace: STYLE.font, fontSize: sz(12), color: STYLE.faint });
1930
- }
1931
- });
1932
- cy += rowH + 0.3;
1933
- });
1934
- } else if (type === 'table') {
1935
- const treg = REGIONS.regionOf('table', 'primary', { bottom: bodyBottom }) ||
1936
- { x: MX, y: CONTENT_TOP, w: CW, h: bodyBottom - CONTENT_TOP };
1937
- addTable(s, sec.table, treg.x, treg.y, treg.w, treg.h);
1938
- } else if (type === 'timeline') {
1939
- const T = PT.timeline;
1940
- const treg = REGIONS.regionOf('timeline', 'primary') ||
1941
- { x: MX, y: T.labelY, w: CW, h: T.descY + T.descH - T.labelY };
1942
- const ps = sec.phases || [];
1943
- const n = Math.max(1, ps.length);
1944
- const stepW = (treg.w - 1.0) / n;
1945
- s.addShape('line', { x: treg.x + 0.2, y: T.axisY, w: treg.w - 0.4, h: 0.02, line: { color: STYLE.line, width: 1.5 } });
1946
- ps.forEach((p, i) => {
1947
- const x = treg.x + 0.2 + i * stepW;
1948
- const on = p[3] === 'done' || p[3] === 'now';
1949
- s.addShape('ellipse', { x: x - T.dotR / 2, y: T.dotY, w: T.dotR, h: T.dotR,
1950
- fill: { color: on ? STYLE.accent : STYLE.line }, line: { type: 'none' } });
1951
- s.addText(p[0], { x: x - 0.1, y: T.labelY, w: stepW - 0.2, h: 0.4, fontFace: STYLE.font,
1952
- fontSize: sz(12), bold: true, color: STYLE.accent, charSpacing: 1 });
1953
- s.addText(p[1], { x: x - 0.1, y: T.nameY, w: stepW - 0.2, h: 0.5, fontFace: STYLE.fontDisplay,
1954
- fontSize: sz(17), bold: true, color: p[3] === 'now' ? STYLE.accent : STYLE.ink });
1955
- s.addText(p[2] || '', { x: x - 0.1, y: T.descY, w: stepW - 0.25, h: T.descH, fontFace: STYLE.font,
1956
- fontSize: sz(12), color: STYLE.body, lineSpacing: sz(18) });
1957
- });
1958
- } else if (type === 'bar') {
1959
- const c = sec.chart || {};
1960
- const dcols = c.colors || dataColors(styleArg);
1961
- const bot = chartBottom(!!sec.soWhat, !!sec.footnote) - (sec.note ? 0.42 : 0.05);
1962
- const isH = c.type === 'hbar';
1963
- const reg = REGIONS.regionOf('bar', isH ? 'hbar' : 'primary', { bottom: bot }) ||
1964
- { x: MX, y: isH ? PT.bar.hbarY0 : PT.bar.chartY, w: CW, h: 3 };
1965
- if (isH) {
1966
- chartBlock(s, c, reg.x, reg.y, reg.w, reg.h, dcols);
1967
- } else {
1968
- /* 非 hbar 保持两侧留白(chartX/chartW),region 已扣减 */
1969
- chartBlock(s, c, reg.x, reg.y, reg.w, Math.max(1.5, reg.h), dcols);
1970
- }
1971
- if (sec.note) s.addText(sec.note, { x: MX, y: PT.note.y, w: CW, h: PT.note.h, fontFace: STYLE.font,
1972
- fontSize: sz(11), color: STYLE.faint });
1973
- } else if (type === 'twocol' || type === 'threecol') {
1974
- const ps = sec.paragraphs || [];
1975
- const nCol = (type === 'threecol') ? 3 : 2;
1976
- const gap = (type === 'threecol') ? PT.research.col3Gap : PT.twocol.colGap;
1977
- const treg = REGIONS.regionOf('twocol', 'primary', { top: bodyY, bottom: bodyBottom }) ||
1978
- { x: MX, y: bodyY, w: CW, h: bodyBottom - bodyY };
1979
- /* threecol 用 3 栏均分(twocol IR 的 colW 仅适用 2 栏) */
1980
- const g = cols(treg.w, nCol, gap);
1981
- const per = Math.ceil(ps.length / nCol);
1982
- const availH = treg.h;
1983
- const groups = [];
1984
- for (let i = 0; i < nCol; i++) groups.push(ps.slice(i * per, (i + 1) * per));
1985
- /* 自适应:按各栏文本估算高度选字号 */
1986
- const longest = groups.reduce((a, g2) => Math.max(a, g2.map(p => (p[1] || '').length).reduce((x, y) => x + y, 0)), 0);
1987
- const items = []; for (let i = 0; i < longest / 120; i++) items.push('x'.repeat(120));
1988
- const fz = fitFont(items.length ? items : ['x'.repeat(longest)], g.w, availH, { max: 13.5, gapFactor: 1.2 });
1989
- groups.forEach((col, ci) => {
1990
- if (!col.length) return;
1991
- const runs = col.map(p => ([
1992
- { text: p[0] + ' ', options: { fontSize: sz(fz), bold: true, color: STYLE.ink } },
1993
- { text: p[1] + '\n', options: { fontSize: sz(fz - 1), color: STYLE.body, breakLine: true } },
1994
- ])).flat();
1995
- s.addText(runs, { x: treg.x + ci * g.step, y: treg.y, w: g.w, h: availH, fontFace: STYLE.font,
1996
- valign: 'top', lineSpacing: sz(fz * 1.5), paraSpaceBefore: sz(7) });
1997
- });
1998
- } else if (type === 'halftable') {
1999
- const yH = sec.lead ? PT.common.bodyYWithLead : PT.research.denseTableY;
2000
- const lwH = CW * PT.research.halfTableW;
2001
- if (sec.table && sec.table.head) {
2002
- addTable(s, sec.table, MX, yH, lwH, bodyBottom - yH,
2003
- { maxRowH: PT.research.denseRowH + 0.1, minRowH: 0.28 });
2004
- }
2005
- const ecH = sec.chart || {};
2006
- if (ecH.labels && ecH.values) {
2007
- const rxH = MX + CW * PT.research.halfChartX;
2008
- const rwH = CW * PT.research.halfChartW;
2009
- chartBlock(s, ecH, rxH, yH, rwH, Math.max(1.2, bodyBottom - yH), (ecH.colors || dataColors(styleArg)));
2010
- }
2011
- } else if (type === 'matrix') {
2012
- const rhH = sec.rowHeads || [], chH = sec.colHeads || [];
2013
- const mY = PT.research.matrixY, mLW = PT.research.matrixLabelW;
2014
- const rowsC = sec.cells || [];
2015
- const nR = Math.max(1, rowsC.length);
2016
- const availH = bodyBottom - mY - 0.42;
2017
- const mCH = fitRowH(availH, nR, PT.research.matrixCellH, 0.3);
2018
- const mW = (CW - mLW) / Math.max(1, chH.length);
2019
- chH.forEach((ch2, ci) => {
2020
- s.addText(ch2, { x: MX + mLW + ci * mW, y: mY, w: mW, h: 0.34, align: 'center',
2021
- fontFace: STYLE.font, fontSize: sz(11), bold: true, color: STYLE.body });
2022
- });
2023
- rowsC.forEach((rowC, ri) => {
2024
- const y2 = mY + 0.42 + ri * mCH;
2025
- s.addText(rhH[ri] || '', { x: MX, y: y2 + (mCH - 0.5) / 2, w: mLW - 0.15, h: 0.5, align: 'right',
2026
- fontFace: STYLE.font, fontSize: sz(11), bold: true, color: STYLE.body });
2027
- rowC.forEach((cell, ci) => {
2028
- const acc = isRec(cell) && cell.accent;
2029
- const txt = isRec(cell) ? (cell.t || '') : String(cell);
2030
- const cx = MX + mLW + ci * mW;
2031
- s.addShape('roundRect', { x: cx + 0.04, y: y2, w: mW - 0.08, h: mCH - 0.1, rectRadius: 0.05,
2032
- fill: { color: acc ? STYLE.soft : STYLE.surface },
2033
- line: acc ? { type: 'none' } : { color: STYLE.line, width: 0.75 } });
2034
- s.addText(txt, { x: cx + 0.14, y: y2 + 0.08, w: mW - 0.28, h: mCH - 0.26,
2035
- fontFace: STYLE.font, fontSize: sz(11), valign: 'middle',
2036
- color: acc ? STYLE.accent : STYLE.body });
2037
- });
2038
- });
2039
- } else if (type === 'lane') {
2040
- const lnS = sec.lanes || [];
2041
- const n = Math.max(1, lnS.length);
2042
- const start = sec.lead ? PT.common.bodyYWithLead : PT.arch.fullStartY;
2043
- const availH = bodyBottom - start;
2044
- const lnH = fitRowH(availH - (n - 1) * PT.arch.laneGap, n, PT.arch.laneH, 0.3);
2045
- const lnGap = PT.arch.laneGap, lnHW = PT.arch.laneHeadW;
2046
- lnS.forEach((ln, li) => {
2047
- const y3 = start + li * (lnH + lnGap);
2048
- s.addShape('roundRect', { x: MX, y: y3, w: CW, h: lnH, rectRadius: 0.07,
2049
- fill: { color: STYLE.surface }, line: { color: STYLE.line, width: 0.75 } });
2050
- s.addShape('rect', { x: MX, y: y3, w: lnHW, h: lnH,
2051
- fill: { color: STYLE.surface }, line: { color: STYLE.line, width: 0.75 } });
2052
- s.addText(ln[0], { x: MX + 0.08, y: y3, w: lnHW - 0.16, h: lnH, align: 'center', valign: 'middle',
2053
- fontFace: STYLE.font, fontSize: sz(11), bold: true, color: STYLE.body });
2054
- const steps = ln[1] || [];
2055
- const stepW = Math.min(PT.arch.stepMaxW, (CW - lnHW - 0.4 - (steps.length - 1) * PT.arch.stepGap) / Math.max(1, steps.length));
2056
- steps.forEach((st, si) => {
2057
- const stAcc = isRec(st) && st.accent;
2058
- const stT = isRec(st) ? st.t : st;
2059
- const sx = MX + lnHW + 0.2 + si * (stepW + PT.arch.stepGap);
2060
- s.addShape('roundRect', { x: sx, y: y3 + 0.13, w: stepW, h: lnH - 0.26, rectRadius: 0.06,
2061
- fill: { color: stAcc ? STYLE.soft : STYLE.bg },
2062
- line: stAcc ? { type: 'none' } : { color: STYLE.line, width: 0.75 } });
2063
- s.addText(stT, { x: sx + 0.06, y: y3 + 0.13, w: stepW - 0.12, h: lnH - 0.26,
2064
- align: 'center', valign: 'middle', fontFace: STYLE.font, fontSize: sz(11),
2065
- bold: !!stAcc, color: stAcc ? STYLE.accent : STYLE.ink });
2066
- if (si < steps.length - 1) {
2067
- /* 正交箭头(线段+三角头),替代裸文字 → */
2068
- const ax = sx + stepW + 0.02, ay = y3 + lnH / 2, aw = PT.arch.stepGap - 0.04;
2069
- s.addShape('rect', { x: ax, y: ay - 0.01, w: Math.max(0.06, aw * 0.55), h: 0.02,
2070
- fill: { color: STYLE.faint }, line: { type: 'none' } });
2071
- s.addShape('triangle', { x: ax + aw * 0.55, y: ay - 0.05, w: 0.1, h: 0.1, rotate: 90,
2072
- fill: { color: STYLE.faint }, line: { type: 'none' } });
2073
- }
2074
- });
2075
- });
2076
- } else if (type === 'cards') {
2077
- const cdC = PT.cards;
2078
- const creg = REGIONS.regionOf('cards', 'primary', { bottom: bodyBottom }) ||
2079
- { x: MX, y: cdC.startY, w: CW, h: bodyBottom - cdC.startY };
2080
- const cds = sec.cards || [];
2081
- const c = Math.min(sec.columns || 3, cds.length || 1);
2082
- const r = Math.ceil((cds.length || 1) / c);
2083
- const gw = (creg.w - (c - 1) * cdC.gap) / c;
2084
- const gh = fitRowH(creg.h - (r - 1) * cdC.gap, r, cdC.maxH, 0.6);
2085
- cds.forEach((cd, i) => {
2086
- const col = i % c, row = Math.floor(i / c);
2087
- const x = creg.x + col * (gw + cdC.gap), y = creg.y + row * (gh + cdC.gap);
2088
- s.addShape('roundRect', { x, y, w: gw, h: gh, rectRadius: 0.08,
2089
- fill: { color: STYLE.surface }, line: { color: STYLE.line, width: 0.75 } });
2090
- /* 卡片头路标(PPTX 侧图标等价物:accent 小方块,与 HTML .card__ico 的 accent 底同语义)
2091
- —— HTML 内联 SVG 图标不跨通道;用原生形状保持「路标不是装饰」的可扫读性 */
2092
- const ico = 0.18;
2093
- s.addShape('roundRect', { x: x + 0.18, y: y + 0.2, w: ico, h: ico, rectRadius: 0.04,
2094
- fill: { color: STYLE.accent }, line: { type: 'none' }, objectName: trName('card') });
2095
- s.addText(cd.title, { x: x + 0.18 + ico + 0.1, y: y + 0.14, w: gw - 0.36 - ico - 0.1, h: cdC.titleH, fontFace: STYLE.font,
2096
- fontSize: sz(15), bold: true, color: STYLE.ink });
2097
- /* points 兼容:[[k,v]…] | [str…] | [{t,d}…](scaffold v9 契约 {title, points:[[k,v]]}) */
2098
- const ptLines = (cd.points || []).map(pt => {
2099
- if (Array.isArray(pt)) {
2100
- const k = pt[0] == null ? '' : String(pt[0]);
2101
- const v = pt[1] == null ? '' : String(pt[1]);
2102
- return k && v ? ('· ' + k + ' ' + v) : ('· ' + (k || v));
2103
- }
2104
- if (pt && typeof pt === 'object') {
2105
- return '· ' + String(pt.t || '') + (pt.d ? ' ' + String(pt.d) : '');
2106
- }
2107
- return '· ' + String(pt == null ? '' : pt);
2108
- });
2109
- s.addText(ptLines.map(t => ({ text: t + '\n',
2110
- options: { fontSize: sz(12), color: STYLE.body, fontFace: STYLE.font, breakLine: true } })),
2111
- { x: x + 0.18, y: y + 0.14 + cdC.titleH + 0.06, w: gw - 0.36, h: Math.max(0.4, gh - cdC.titleH - 0.34),
2112
- fontFace: STYLE.font, valign: 'top', lineSpacing: sz(12) * 1.5 });
2113
- });
2114
- } else if (type === 'split') {
2115
- /* 双区自由组合页:左区与右区各可为 要点 / 图表 / 表格 / 图片。
2116
- left.type / right.type = 'points'(默认) | 'table' | 'image' | 图表类型名(bar/line/…)。
2117
- 缺省 left=points、right=bar —— 向后兼容 v7 模型(只写 left.points 的旧模型行为不变)。 */
2118
- const SP = PT.split;
2119
- const lreg = REGIONS.regionOf('split', 'left', { bottom: bodyBottom }) ||
2120
- { x: MX, y: SP.chartY, w: CW * SP.leftW, h: bodyBottom - SP.chartY };
2121
- const rreg = REGIONS.regionOf('split', 'right', { bottom: bodyBottom }) ||
2122
- { x: MX + CW * SP.rightX, y: SP.chartY, w: CW * SP.rightW, h: bodyBottom - SP.chartY };
2123
- const lw = lreg.w, rw = rreg.w, rx = rreg.x;
2124
- const lt = (sec.left && sec.left.type) || 'points';
2125
- /* 要点区渲染(左右两区共用;v8 起 right.type='points' 也可用——修正 v7 只在 schema 声明却未实现的问题) */
2126
- const drawPoints = (el, x, w) => {
2127
- const pts = (el && el.points) || [];
2128
- const ly0 = bodyY, availP = bodyBottom - ly0;
2129
- const rowH = fitRowH(availP, pts.length, SP.rowH, 0.3);
2130
- const fzB = fitFont(pts.map(p => (p[0] || '') + (p[1] || '')), w - 0.4, availP, { max: 14, gapFactor: 0.6 });
2131
- const fz = sz(fzB), fzSm = sz(fzB - 1);
2132
- pts.forEach((pt, i) => {
2133
- const ly = ly0 + i * rowH;
2134
- s.addShape('rect', { x, y: ly + rowH / 2 - 0.06, w: 0.12, h: 0.12, fill: { color: STYLE.accent }, line: { type: 'none' } });
2135
- s.addText([
2136
- { text: pt[0] + ' ', options: { fontSize: fz, bold: true, color: STYLE.ink } },
2137
- { text: pt[1], options: { fontSize: fzSm, color: STYLE.body } },
2138
- ], { x: x + 0.28, y: ly, w: w - 0.4, h: rowH - 0.04, fontFace: STYLE.font, valign: 'top' });
2139
- });
2140
- };
2141
- if (lt === 'table') {
2142
- addTable(s, { head: sec.left.head, rows: sec.left.rows, colW: sec.left.colW },
2143
- lreg.x, SP.tableY, lw, bodyBottom - SP.tableY, { maxRowH: SP.tableRowH, minRowH: 0.3 });
2144
- } else if (lt === 'image') {
2145
- const imL = (sec.left && sec.left.image) || {};
2146
- addImageEl(s, imL.src, lreg.x, SP.chartY, lw, Math.max(1.4, bodyBottom - SP.chartY), 0.09,
2147
- { placeholder: !!imL.placeholder || !imL.src, layout: 'half', multi: false, fit: imL.fit });
2148
- if (imL.caption) s.addText(imL.caption, { x: lreg.x, y: SP.capY, w: lw, h: 0.35, align: 'center',
2149
- fontFace: STYLE.font, fontSize: sz(11), color: STYLE.faint });
2150
- } else if (lt !== 'points') {
2151
- const lc = sec.left;
2152
- chartBlock(s, lc, lreg.x, SP.chartY, lw, Math.max(1.4, bodyBottom - SP.chartY - (lc.cap ? 0.1 : 0)),
2153
- lc.colors || dataColors(styleArg));
2154
- if (lc.cap) s.addText(lc.cap, { x: lreg.x, y: SP.capY, w: lw, h: 0.35, align: 'center',
2155
- fontFace: STYLE.font, fontSize: sz(12), color: STYLE.body });
2156
- } else {
2157
- drawPoints(sec.left, lreg.x, lw);
2158
- }
2159
- const rt = (sec.right && sec.right.type) || 'bar';
2160
- if (rt === 'table') {
2161
- addTable(s, { head: sec.right.head, rows: sec.right.rows, colW: sec.right.colW },
2162
- rx, SP.tableY, rw, bodyBottom - SP.tableY, { maxRowH: SP.tableRowH, minRowH: 0.3 });
2163
- } else if (rt === 'image') {
2164
- const im = (sec.right && sec.right.image) || {};
2165
- addImageEl(s, im.src, rx, SP.chartY, rw, Math.max(1.4, bodyBottom - SP.chartY), 0.09,
2166
- { placeholder: !!im.placeholder || !im.src, layout: 'half', multi: false, fit: im.fit });
2167
- if (im.caption) s.addText(im.caption, { x: rx, y: SP.capY, w: rw, h: 0.35, align: 'center',
2168
- fontFace: STYLE.font, fontSize: sz(11), color: STYLE.faint });
2169
- } else if (rt === 'points') {
2170
- drawPoints(sec.right, rx, rw);
2171
- } else {
2172
- const bc = sec.right;
2173
- const dcols = bc.colors || dataColors(styleArg);
2174
- chartBlock(s, bc, rx, SP.chartY, rw, Math.max(1.4, bodyBottom - SP.chartY - (bc.cap ? 0.1 : 0)), dcols);
2175
- if (bc.cap) s.addText(bc.cap, { x: rx, y: SP.capY, w: rw, h: 0.35, align: 'center',
2176
- fontFace: STYLE.font, fontSize: sz(12), color: STYLE.body });
2177
- }
2178
- } else if (type === 'diagram') {
2179
- const D = PT.diagram;
2180
- const dreg = REGIONS.regionOf('diagram', 'primary', {
2181
- mode: MODE_NAME, bottom: bodyBottom,
2182
- }) || { x: MX, y: D.bodyStartY, w: CW, h: bodyBottom - D.bodyStartY,
2183
- layerBarW: D.layerBarW, layerGap: D.layerGap, maxLayerH: D.maxLayerH };
2184
- const lys = sec.layers || [];
2185
- const dStart = dreg.y;
2186
- const dEnd = dreg.y + dreg.h;
2187
- const n = Math.max(1, lys.length);
2188
- const lh = Math.max(0.5, Math.min(dreg.maxLayerH || D.maxLayerH, (dEnd - dStart) / n - D.layerGap));
2189
- lys.forEach((lay, li) => {
2190
- const y = dStart + li * (lh + D.layerGap);
2191
- const focus = lay[2] === 'focus';
2192
- s.addShape('roundRect', { x: dreg.x, y, w: D.layerBarW, h: lh, rectRadius: 0.05,
2193
- fill: { color: focus ? STYLE.accent : STYLE.surface }, line: focus ? { type: 'none' } : { color: STYLE.line, width: 0.75 } });
2194
- s.addText(lay[0], { x: dreg.x + 0.1, y: y + 0.1, w: D.layerBarW - 0.2, h: lh - 0.2, align: 'center', valign: 'middle',
2195
- fontFace: STYLE.font, fontSize: sz(12), bold: true, color: focus ? STYLE.onAccent : STYLE.body });
2196
- const nodes = lay[1] || [];
2197
- const nn = Math.max(1, nodes.length);
2198
- const availW = CW - D.layerBarW - 0.2;
2199
- const nw = Math.min(D.nodeMaxW, (availW - (nn - 1) * D.nodeGap) / nn);
2200
- nodes.forEach((nd, ni) => {
2201
- const x = MX + D.layerBarW + 0.2 + ni * (nw + D.nodeGap);
2202
- const acc = isRec(nd) && nd.accent;
2203
- const ntt = isRec(nd) ? nd.t : nd;
2204
- const nds = isRec(nd) ? (nd.d || '') : '';
2205
- s.addShape('roundRect', { x, y, w: nw, h: lh, rectRadius: 0.06,
2206
- fill: { color: acc ? STYLE.soft : STYLE.surface }, line: { color: STYLE.line, width: 0.75 } });
2207
- s.addText(ntt, { x: x + 0.12, y: y + (nds ? 0.12 : lh / 2 - 0.2), w: nw - 0.24, h: 0.38,
2208
- fontFace: STYLE.font, fontSize: sz(12.5), bold: true, color: acc ? STYLE.accent : STYLE.ink,
2209
- valign: nds ? 'top' : 'middle' });
2210
- if (nds) s.addText(nds, { x: x + 0.12, y: y + lh - 0.5, w: nw - 0.24, h: 0.4,
2211
- fontFace: STYLE.font, fontSize: sz(10), color: STYLE.faint });
2212
- });
2213
- if (li < lys.length - 1) {
2214
- const cy = y + lh + D.layerGap / 2;
2215
- const cxm = MX + D.layerBarW / 2;
2216
- /* 层间正交连接:竖线 + 箭头(替代两小块矩形) */
2217
- s.addShape('rect', { x: cxm - 0.01, y: cy - 0.1, w: 0.02, h: 0.14,
2218
- fill: { color: focus ? STYLE.accent : STYLE.line }, line: { type: 'none' } });
2219
- s.addShape('triangle', { x: cxm - 0.06, y: cy + 0.02, w: 0.12, h: 0.1, rotate: 180,
2220
- fill: { color: focus ? STYLE.accent : STYLE.line }, line: { type: 'none' } });
2221
- }
2222
- });
2223
- if (sec.legend) {
2224
- let lx = MX;
2225
- sec.legend.forEach((lg) => {
2226
- s.addText('● ' + lg, { x: lx, y: PH - 0.62, w: 3.2, h: 0.3, fontFace: STYLE.font, fontSize: sz(10), color: STYLE.faint });
2227
- lx += 2.0;
2228
- });
2229
- }
2230
- } else if (type === 'exhibit') {
2231
- const ec = sec.chart || {};
2232
- /* 主图区走布局 IR(layout_slots.json 的 primary 槽位),未命中时回落 PT.exhibit */
2233
- const reg = REGIONS.regionOf('exhibit', 'primary', { withNote: !!(sec.soWhat || sec.footnote) })
2234
- || { x: MX, y: PT.exhibit.chartY, w: CW, h: PT.exhibit.chartH };
2235
- if (sec.exhibitNo) {
2236
- s.addText(String(sec.exhibitNo).toUpperCase(), { x: MX, y: PT.exhibit.badgeY, w: CW, h: 0.35,
2237
- fontFace: STYLE.font, fontSize: sz(11.5), bold: true, color: STYLE.accent, charSpacing: 2 });
2238
- }
2239
- if (ec.labels && ec.values) {
2240
- const edcols = ec.colors || dataColors(styleArg);
2241
- const h = Math.max(1.2, chartBottom(!!sec.soWhat, !!sec.footnote) - reg.y - 0.05);
2242
- if (ec.type === 'hbar') {
2243
- chartBlock(s, ec, reg.x, reg.y, reg.w, h, edcols);
2244
- } else {
2245
- chartBlock(s, ec, reg.x + 0.4, reg.y - 0.1, reg.w - 0.8, h + 0.15, edcols);
2246
- }
2247
- }
2248
- } else if (type === 'sankey') {
2249
- infoSankey(s, sec, bodyY, bodyBottom);
2250
- } else if (type === 'treemap') {
2251
- infoTreemap(s, sec, bodyY, bodyBottom);
2252
- } else if (type === 'boxplot') {
2253
- infoBoxplot(s, sec, bodyY, bodyBottom);
2254
- } else if (type === 'network') {
2255
- infoNetwork(s, sec, bodyY, bodyBottom);
2256
- } else if (type === 'marimekko') {
2257
- infoMarimekko(s, sec, bodyY, bodyBottom);
2258
- } else if (type === 'streamgraph') {
2259
- infoStreamgraph(s, sec, bodyY, bodyBottom);
2260
- } else {
2261
- /* points(默认):要点列表 + 可选右侧指标列(文本区与指标列严格分区,不重叠) */
2262
- const P = PT.points;
2263
- const pts = sec.points || [];
2264
- const hasMetrics = !!(sec.metrics && sec.metrics.length);
2265
- /* 指标列最多 3 个,列宽按剩余空间收敛,保证文本区不被侵占(原重叠缺陷) */
2266
- const nM = hasMetrics ? Math.min(sec.metrics.length, 3) : 0;
2267
- const mw = hasMetrics ? Math.min(P.metricW, Math.max(1.6, (CW - 4.8) / nM)) : 0;
2268
- const textW = hasMetrics
2269
- ? Math.max(4.2, (PW - MX - nM * mw) - MX - 0.35)
2270
- : CW - 0.4;
2271
- const avail = bodyBottom - bodyY;
2272
- const rowH = fitRowH(avail, pts.length, P.rowH, 0.3);
2273
- const fzB = fitFont(pts.map(p => (p[0] || '') + (p[1] || '')), textW - 0.32, avail,
2274
- { max: 15, gapFactor: 0.55 });
2275
- const fz = sz(fzB), fzSm = sz(fzB - 1);
2276
- pts.forEach((p, i) => {
2277
- const y = bodyY + i * rowH;
2278
- s.addShape('rect', { x: MX, y: y + rowH / 2 - P.markSize / 2, w: P.markSize, h: P.markSize,
2279
- fill: { color: STYLE.accent }, line: { type: 'none' } });
2280
- s.addText([
2281
- { text: p[0] + ' ', options: { fontSize: fz, bold: true, color: STYLE.ink } },
2282
- { text: p[1], options: { fontSize: fzSm, color: STYLE.body } },
2283
- ], { x: MX + 0.32, y, w: textW, h: rowH - 0.04, fontFace: STYLE.font, valign: 'top' });
2284
- });
2285
- if (hasMetrics) {
2286
- const gx = PW - MX - nM * mw;
2287
- const vSize = nM >= 3 ? sz(28) : sz(34);
2288
- sec.metrics.slice(0, nM).forEach((m, i) => {
2289
- const x = gx + i * mw;
2290
- s.addText(m[0], { x, y: P.metricY, w: mw - 0.2, h: P.metricValH, fontFace: STYLE.fontDisplay,
2291
- fontSize: vSize, bold: true, color: STYLE.accent });
2292
- s.addText(m[1], { x, y: P.metricCapY, w: mw - 0.2, h: 0.9, fontFace: STYLE.font, fontSize: sz(11), color: STYLE.faint });
2293
- });
2294
- }
2295
- }
2296
- /* research 通用可选件:so-what 结论条 + 页脚来源行 + 待核实条(所有页型共用同一槽位) */
2297
- if (sec.soWhat) soWhatBar(s, sec.soWhat);
2298
- if (sec.footnote) footnoteLine(s, sec.footnote);
2299
- if (flagH) flagBar(s, flagItems, flagY);
2300
- /* 演讲者备注(口径/含义/来源沉 notes,不堆版面) */
2301
- const _notes = [];
2302
- if (sec.lead) _notes.push('导语:' + sec.lead);
2303
- if (sec.chart && (sec.chart.unit || sec.chart.max != null)) {
2304
- _notes.push('图表口径:单位 ' + (sec.chart.unit || '无') + ',量程 ' + (sec.chart.max != null ? sec.chart.max : '自动'));
2305
- }
2306
- /* 数据表:notes 策略把数据写入备注,保证图表数据可追溯(inline 策略已在页内附原生表格) */
2307
- const _chartData = sec.chart || ((sec.right && sec.right.labels && sec.right.values) ? sec.right : null);
2308
- if (_chartData && _chartData.labels && _chartData.values) {
2309
- if (dataTableMode(_chartData) === 'notes') {
2310
- _notes.push('数据表(' + (_chartData.type || 'bar') + '):\n' + dataTableText(_chartData));
2311
- }
2312
- }
2313
- /* 信息图页型:notes 策略把页型数据表写入备注,保证数据可追溯 */
2314
- const _infoRows = infoTableRows(sec);
2315
- if (_infoRows && _infoRows.length && infoDataTableMode(sec) === 'notes') {
2316
- _notes.push('数据表(' + type + '):\n' + _infoRows.map(r => r.join(' | ')).join('\n'));
2317
- }
2318
- if (sec.steps) _notes.push('步骤:' + sec.steps.map(st => (isRec(st) ? st.t : st[0]) || '').join(' → '));
2319
- if (sec.items) _notes.push('达成对比:' + sec.items.map(it => it[0] + ' ' + it[1] + (it[2] != null ? '/' + it[2] : '')).join(';'));
2320
- if (sec.levels) _notes.push('层级:' + sec.levels.map(l => (isRec(l) ? l.t : l[0]) || '').join(' → '));
2321
- if (sec.soWhat) _notes.push('So what:' + sec.soWhat);
2322
- if (flagItems.length) _notes.push('待核实(需二次确认):' + flagItems.join(';'));
2323
- if (sec.note) _notes.push('注:' + sec.note);
2324
- if (sec.footnote) _notes.push('来源:' + sec.footnote);
2325
- if (sec.image && sec.image.caption) _notes.push('图片说明:' + sec.image.caption);
2326
- if (sec.image && sec.image.placeholder) {
2327
- const _size = (IMG.recommendedSizePx || {})[String(sec.image.layout || 'full').toLowerCase()] || '';
2328
- _notes.push('配图占位(交付前建议替换):' + (sec.image.hint || ('建议 ' + _size + 'px,替换 image.src 或 image.items 即可')));
2329
- }
2330
- if (sec.image && Array.isArray(sec.image.items) && sec.image.items.length) {
2331
- _notes.push('图片清单:' + sec.image.items.map((it, i) => (i + 1) + '.' + ((it && (it.caption || it.alt)) || '(待补说明)')).join(';'));
2332
- }
2333
- if (_notes.length) s.addNotes(_notes.join('\n'));
2334
- });
2335
-
2336
- /* 收尾 */
2337
- (function closing() {
2338
- const s = base();
2339
- const C = PT.closing;
2340
- /* 收尾页用主题一致强调带(accent-soft 底 + 常规文字)——浅色主题下不再出现深色收尾页 */
2341
- s.addShape('rect', { x: 0, y: 0, w: PW, h: PH, fill: { color: STYLE.soft }, line: { type: 'none' } });
2342
- s.addShape('rect', { x: MX, y: C.eyebrowY, w: 0.9, h: 0.045, fill: { color: STYLE.accent }, line: { type: 'none' } });
2343
- s.addText('下一步', { x: MX, y: C.eyebrowY + 0.16, w: CW, h: 0.4, fontFace: STYLE.font,
2344
- fontSize: sz(13), bold: true, color: STYLE.accent, charSpacing: 2 });
2345
- s.addText(CONTENT.closing.title, { x: MX, y: C.titleY, w: CW - 1, h: C.titleH, fontFace: STYLE.fontDisplay,
2346
- fontSize: sz(30), bold: true, color: STYLE.ink });
2347
- const pts = CONTENT.closing.points || [];
2348
- const n = Math.max(1, pts.length);
2349
- const cw3 = (CW - (n - 1) * 0.3) / n;
2350
- pts.forEach((p, i) => {
2351
- const x = MX + i * (cw3 + 0.3);
2352
- s.addShape('roundRect', { x, y: C.pointsY - 0.2, w: cw3, h: C.pointTitleH + C.pointBodyH + 0.32,
2353
- rectRadius: 0.09, fill: { color: STYLE.surface }, line: { color: STYLE.line, width: 0.75 } });
2354
- s.addText(p[0], { x: x + 0.18, y: C.pointsY, w: cw3 - 0.36, h: C.pointTitleH, fontFace: STYLE.font,
2355
- fontSize: sz(17), bold: true, color: STYLE.accent });
2356
- s.addText(p[1], { x: x + 0.18, y: C.pointsY + C.pointTitleH, w: cw3 - 0.36, h: C.pointBodyH, fontFace: STYLE.font,
2357
- fontSize: sz(12.5), color: STYLE.body });
2358
- });
2359
- s.addNotes((CONTENT.closing.points || []).map(p => '· ' + p[0] + ':' + p[1]).join('\n'));
2360
- })();
2361
-
2362
- /* 页码 */
2363
- pptx._slides.forEach((s, i) => footer(s, i + 1, pptx._slides.length));
2364
-
2365
- const out = process.argv.slice(2).find(a => !a.startsWith('--')) || 'TopPPT HTML 报告.pptx';
2366
- pptx.writeFile({ fileName: out }).then(() => {
2367
- console.log('已生成: ' + out + ' (风格: ' + (STYLE_PRESETS[styleArg] ? styleArg : 'business-blue') +
2368
- ' · 主题: ' + (THEME === 'dark' ? '深色 dark' : '浅色 light') + ' · 模式: ' + MODE_NAME + ')');
2369
- if (SHRINK_FLOOR_HITS.length) {
2370
- const low = SHRINK_FLOOR_HITS.filter(h => h.pt <= (FS_POLICY.warnBelow || 9.5));
2371
- console.warn('[build_pptx] 注意:' + SHRINK_FLOOR_HITS.length + ' 处文本缩字号已触底' +
2372
- (low.length ? '(其中 ' + low.length + ' 处 ≤ ' + (FS_POLICY.warnBelow || 9.5) + 'pt)' : '') +
2373
- '——该页内容超出版式承载力。');
2374
- SHRINK_FLOOR_HITS.slice(0, 5).forEach(h =>
2375
- console.warn(' · ' + h.pt + 'pt:“' + h.sample + '…”'));
2376
- console.warn(' 处置顺序:①列表化/精炼 ②升级承载形态 ③换/扩组合版式 ④分区 ⑤拆页(不要靠继续缩字号硬塞)。');
2377
- }
2378
- console.log('下一步(质检硬门禁,本技能内置脚本;--model 启用模型往返保真检查):');
2379
- console.log(' python scripts/validate_pptx.py "' + out + '" --strict' + (modelArg ? ' --model="' + modelArg + '"' : ' --model="<报告.model.json>"'));
2380
- });
1
+ /**
2
+ * TopPPT HTML· PPTX 精导(基于 PptxGenJS)
3
+ *
4
+ * 作用:把报告的「内容模型」(window.REPORT_MODEL,由 extract_model.py 抽取)导出为
5
+ * 16:9 可编辑 PPTX(封面 / Agenda(architecture 可省)/ 章节页 / 收尾),与页面预览
6
+ * (assets/pptx-export.js,同一序列化语义)消费同一份 layout-constants.json 常量单源,
7
+ * 产出一致。正式 PPTX 一律走本通道(页面不导出文件),strict 0/0 才交付。
8
+ *
9
+ * 用法:
10
+ * 1) 抽模型:python scripts/extract_model.py "报告.html"(生成 报告.model.json)
11
+ * 2) 生成:node scripts/build_pptx.js 报告.pptx --model="报告.model.json"
12
+ * (可选 --style=<风格> / --theme=light|dark 覆盖模型值)
13
+ * 3) 质检(硬门禁):python scripts/validate_pptx.py 报告.pptx --strict --model="报告.model.json"
14
+ *
15
+ * 章节页型(sections[].type,字段约束单源 scripts/model-schema.json,共 29 种):
16
+ * points 要点列表 + 可选右侧指标列(默认)
17
+ * metrics 大指标带(4–6 个核心数字)
18
+ * kpi 大数指标页(hero=[值,标签,delta?] 巨号数字 + 右列支撑指标)
19
+ * table 对比表(head + rows)
20
+ * timeline 路线/阶段(phases: [label, 名称, 说明, done|now|''])
21
+ * steps 步骤条(steps: [[标题,说明]…],可选 groups 分组、accent 高亮步)
22
+ * bar 数据图表页(chart.type 见 model-schema.json chartTypes:30 类)
23
+ * donut 环形图页(原生数据图表 + 挖孔 + 中心合计 + 右侧数值图例)
24
+ * heatmap 热力矩阵(rowHeads × colHeads + cells 数值,4 级色阶)
25
+ * bullet 达成对比(items: [[标签,实际,目标]…],底槽 + 实际条 + 目标刻度)
26
+ * pyramid 金字塔(levels: [[标题,说明]…] 自上而下逐层加宽)
27
+ * image 素材图片页(image:{src|items|placeholder, layout:full|half|bleed|grid|compare|wall,
28
+ * fit:cover|contain, caption};src 仅 data:/相对路径;无图走原生配图占位)
29
+ * cards 卡片网格
30
+ * split 左文右图(left.points + right: chart/table/image;图表字段挂在 right 上)
31
+ * comparison 对比页(left/right 双面板 + 可选 verdict 结论条)
32
+ * quote 引用/金句页(主题一致强调带:accent-soft 底,浅色模式不再出深色页)
33
+ * diagram 分层架构(layers + legend,含层间连接;architecture 模式走全幅图页型)
34
+ * exhibit Exhibit 编号图表页(research:exhibitNo + chart + soWhat + footnote)
35
+ * twocol 双栏论证页(research R1)
36
+ * threecol 三栏证据页(research R6)
37
+ * halftable 半表半图页(research R7:table + chart 对照)
38
+ * matrix 矩阵图页(research R8:rowHeads × colHeads + cells)
39
+ * lane 泳道页(architecture A2:lanes + steps)
40
+ * ── 复杂信息图专属页型(结构化载荷,不走 chart.type)──
41
+ * sankey 桑基图页(flows:[[源,汇,值]…],分层节点 + 流带;≥16 采样点、双边界追踪)
42
+ * treemap 树图页(items:[[标签,值]…],面积 ∝ 数值)
43
+ * boxplot 箱线图页(groups:[[标签,min,q1,中位,q3,max]…])
44
+ * network 关系网络页(nodes:[[id,名称]…] + edges:[[源,汇]…],确定性环形布局)
45
+ * marimekko 马赛克图页(cols + cells + legend,列宽 × 列高双重编码)
46
+ * streamgraph 流带图页(series + labels,基线居中,≥24 采样点)
47
+ * 通用可选字段:soWhat(so-what 结论条)、footnote(页脚来源行)、
48
+ * flags(待核实标注清单 → accent 强调色清单条,提示用户二次确认)、image(素材图片)、
49
+ * exhibitNo(非 exhibit 页型也可带 Exhibit 帧头徽标,正文区自动下移让位)
50
+ *
51
+ * 图表双通道(charts.registry 四元组为唯一分派依据):
52
+ * 原生 16 类走 pptxgenjs addChart(chart part + 内嵌 Excel,双击可编辑数据);
53
+ * 形状 20 类走高保真形状还原(曲线用多段旋转矩形沿采样路径逼近,禁 preset 形状替代),
54
+ * 并按 chart.dataTable 策略附数据表(notes 默认 / inline 页内表格)——数据可追溯。
55
+ *
56
+ * 版式纪律:所有几何常量取自 layout-constants.json(禁止在此手写坐标);
57
+ * 文本块按可用高度自适应字号/行距(fitFont),杜绝溢出——这是「每页高度稳定」的保障。
58
+ */
59
+
60
+ /* eslint-disable */
61
+ const pptxgen = require('pptxgenjs');
62
+ const fs = require('fs');
63
+ const path = require('path');
64
+
65
+ /* ══════════ 版式常量单源(scripts/layout-constants.json · 两通道共用,禁止在此手写常量) ══════════ */
66
+ const LC = JSON.parse(fs.readFileSync(path.join(__dirname, 'layout-constants.json'), 'utf-8'));
67
+ const STYLE_PRESETS = LC.styles; // 9 套风格 token(light)
68
+ const STYLE_PRESETS_DARK = LC.stylesDark || {}; // 9 套风格 token(dark)
69
+ const STYLE_DATA_COLORS = LC.styleDataColors || {}; // 9 套风格各自的编码色板(light)
70
+ const STYLE_DATA_COLORS_DARK = LC.styleDataColorsDark || {}; // 深色主题版(按 model.theme 选择)
71
+ const { pw: PW, ph: PH, mx: MX } = LC.page; // 16:9,页边距
72
+ const CW = PW - 2 * MX;
73
+ const TS_BASE = LC.typeScale; // 排版比例尺基准(presentation pt)
74
+ const MTS = LC.modeTypeScale; // 三模式独立排版比例尺
75
+ const PT = LC.pageTypes; // 页型几何(含 layout / cover / closing / steps / heatmap / bullet / pyramid …)
76
+ const IMG = LC.imageSpec || {}; // 图片规格与配图占位约定(双引擎同源)
77
+ const LAY = PT.layout || {};
78
+ const CONTENT_TOP = LAY.contentTop != null ? LAY.contentTop : 2.5;
79
+ const CONTENT_BOTTOM = LAY.contentBottom != null ? LAY.contentBottom : 6.9;
80
+ const CONTENT_BOTTOM_NOTE = LAY.contentBottomWithNote != null ? LAY.contentBottomWithNote : 6.4;
81
+ /* 页型布局区域 IR:语义槽位 → 英寸矩形(与 layout_slots.json 同源;未识别槽位时回落 PT 常量) */
82
+ const REGIONS = require('./lib_layout_regions.js');
83
+
84
+ const modelArg = (process.argv.find(a => a.startsWith('--model=')) || '').split('=')[1];
85
+ let styleArg = (process.argv.find(a => a.startsWith('--style=')) || '').split('=')[1];
86
+ let themeArg = (process.argv.find(a => a.startsWith('--theme=')) || '').split('=')[1];
87
+ let STYLE; // 在内容模型加载后确定(模型可带默认 style)
88
+ let THEME = 'light'; // 亮/暗主题(--theme= 覆盖 > 模型 theme > 默认 light)
89
+ let MODEL_DIR = null; // 模型文件所在目录(素材图片相对路径以此为锚)
90
+
91
+ let MODE_NAME = 'presentation'; // 当前模式(加载模型后设置)
92
+ /* 三模式独立排版比例尺:基准 pt → 最接近 TS_BASE 角色 → MTS[mode][role]。
93
+ research 是咨询密排比例尺(正文 10.5pt),不再是简单 ×0.8。保留 0.5pt 精度。
94
+ 未知模式回落 presentation 档(MTS 与 mode 取值同源于 model-schema.json 的 modes)。 */
95
+ const sz = (pt) => {
96
+ const mts = (MTS && (MTS[MODE_NAME] || MTS.presentation)) || null;
97
+ if (!mts) return Math.max(8.5, Math.round(pt * 2) / 2);
98
+ let best = null, bd = 1e9;
99
+ for (const k of Object.keys(TS_BASE)) {
100
+ const d = Math.abs(TS_BASE[k] - pt);
101
+ if (d < bd) { bd = d; best = mts[k]; }
102
+ }
103
+ return Math.max(8.5, Math.round((best == null ? pt : best) * 2) / 2);
104
+ };
105
+ /* 编码色板:9 套风格**各有自己的** c1–c5(单源 styleDataColors / styleDataColorsDark)。
106
+ 与 HTML 侧的 .f-c1~c5 语义类同源;结构色纪律不受影响——本表只用于数据系列。 */
107
+ const _dcWarned = new Set();
108
+ function dataColors(style) {
109
+ const map = (THEME === 'dark' && Object.keys(STYLE_DATA_COLORS_DARK).length)
110
+ ? STYLE_DATA_COLORS_DARK : STYLE_DATA_COLORS;
111
+ const c = map[style];
112
+ if ((!c || !c.length) && !_dcWarned.has(style)) {
113
+ _dcWarned.add(style);
114
+ /* 静默回落到单色会让多系列图表失去可区分性,但产物照样"合格"——必须出声 */
115
+ console.warn(`[build_pptx] 风格 "${style}" 缺${THEME === 'dark' ? '深色' : ''}编码色板 c1–c5,` +
116
+ '多系列图表将回落单色梯度(建议补 layout-constants.json 的 ' +
117
+ (THEME === 'dark' ? 'styleDataColorsDark' : 'styleDataColors') + ')。');
118
+ }
119
+ return (c && c.length) ? c : null;
120
+ }
121
+ /* 单色风格的数据色板回落(多扇区/多系列页型) */
122
+ function donutColors(style) {
123
+ return dataColors(style) || [STYLE.accent, STYLE.faint, STYLE.body, STYLE.line];
124
+ }
125
+
126
+ /* ══════════ 自适应排版工具(保证内容不溢出页面 —— 每页高度稳定的 PPTX 侧保障) ══════════ */
127
+ /* 文本度量单源:validate_pptx.py 的溢出估算读同一份,两侧同值才不会出现「引擎装得下、门禁判溢出」或反之 */
128
+ const TM = (LC.containers && LC.containers.textMetrics) || {};
129
+ const TM_LINE = TM.lineFactor != null ? TM.lineFactor : 1.45;
130
+ const TM_ASCII = TM.emAsciiRatio != null ? TM.emAsciiRatio : 0.52;
131
+ /* 估算文本行数:CJK 按 1em,拉丁/数字按 emAsciiRatio(保守偏大,宁可早换行) */
132
+ function estLines(text, widthIn, fontPt) {
133
+ const usable = Math.max(24, widthIn * 72 - 8);
134
+ let w = 0, lines = 1;
135
+ const s = String(text == null ? '' : text);
136
+ for (let i = 0; i < s.length; i++) {
137
+ if (s[i] === '\n') { lines++; w = 0; continue; }
138
+ const cw = /[\x00-\xff]/.test(s[i]) ? fontPt * TM_ASCII : fontPt;
139
+ if (w + cw > usable) { lines++; w = cw; } else { w += cw; }
140
+ }
141
+ return lines;
142
+ }
143
+ function estTextH(text, widthIn, fontPt, lineFactor) {
144
+ return estLines(text, widthIn, fontPt) * fontPt * (lineFactor || TM_LINE) / 72;
145
+ }
146
+ /* 有限缩字号(v8.2):优先取最大可装字号;内容偏多时允许阶梯下探。
147
+ 策略(containers.fontShrink 单源):先优化内容/组合/拆页,最后才缩字号;
148
+ maxShrinkSteps 限制相对首选档的下探步数,floorPt 为模式绝对下限。
149
+ 装不下时仍取下限——保证不越界;调用方应配合 validate 的内容预算与拆页建议。 */
150
+ const FS_POLICY = (function () {
151
+ try {
152
+ const raw = fs.readFileSync(path.join(__dirname, 'layout-constants.json'), 'utf-8');
153
+ const p = (JSON.parse(raw).containers || {}).fontShrink || {};
154
+ return {
155
+ ladder: p.ladder || [15, 14, 13.5, 13, 12.5, 12, 11.5, 11, 10.5, 10, 9.5, 9, 8.5],
156
+ maxShrinkSteps: p.maxShrinkSteps != null ? p.maxShrinkSteps : 4,
157
+ floorPt: p.floorPt || { presentation: 10, research: 9, architecture: 10 },
158
+ warnBelow: p.warnBelow != null ? p.warnBelow : 9.5,
159
+ };
160
+ } catch (e) {
161
+ return {
162
+ ladder: [15, 14, 13.5, 13, 12.5, 12, 11.5, 11, 10.5, 10, 9.5, 9, 8.5],
163
+ maxShrinkSteps: 4,
164
+ floorPt: { presentation: 10, research: 9, architecture: 10 },
165
+ warnBelow: 9.5,
166
+ };
167
+ }
168
+ })();
169
+ const FONT_LADDER = FS_POLICY.ladder;
170
+ /* 缩字号触底记录:触底 = 内容量超出该版式承载力,是「该拆页/换组合」的信号,不得无声无息 */
171
+ const SHRINK_FLOOR_HITS = [];
172
+ function modeFloorPt() {
173
+ const m = String((CONTENT && CONTENT.mode) || MODE_NAME || 'presentation');
174
+ const f = FS_POLICY.floorPt[m];
175
+ return f != null ? f : 10;
176
+ }
177
+ function fitFont(items, widthIn, availIn, opts) {
178
+ opts = opts || {};
179
+ const floor = opts.floor != null ? opts.floor : modeFloorPt();
180
+ const maxSteps = opts.maxShrinkSteps != null ? opts.maxShrinkSteps : FS_POLICY.maxShrinkSteps;
181
+ const raw = (opts.ladder || FONT_LADDER).filter(v => v <= (opts.max || 99) && v >= floor);
182
+ const lineFactor = opts.lineFactor || TM_LINE;
183
+ const pad = opts.pad || 0;
184
+ const needH = (f) => {
185
+ let h = 0;
186
+ for (const it of items) h += estTextH(it, widthIn, f, lineFactor) + f * (opts.gapFactor || 0.5) / 72;
187
+ return h + pad;
188
+ };
189
+ /* 首选:阶梯中最大且装得下的字号 */
190
+ for (const f of raw) {
191
+ if (needH(f) <= availIn) return f;
192
+ }
193
+ /* 全装不下:在 maxShrinkSteps 内从「首选档」下探(首选=opts.max 或阶梯首档) */
194
+ const preferred = raw.length ? raw[0] : floor;
195
+ const prefIdx = (opts.ladder || FONT_LADDER).indexOf(preferred);
196
+ const limited = (opts.ladder || FONT_LADDER)
197
+ .slice(prefIdx, prefIdx + 1 + maxSteps)
198
+ .filter(v => v >= floor && v <= (opts.max || 99));
199
+ for (const f of limited) {
200
+ if (needH(f) <= availIn) return f;
201
+ }
202
+ /* 仍装不下:取 limited 最小(或 floor),宁可字号偏小也不越界;内容优化由上层负责 */
203
+ const bottom = limited.length ? limited[limited.length - 1] : floor;
204
+ SHRINK_FLOOR_HITS.push({ pt: bottom, sample: String(items[0] == null ? '' : items[0]).slice(0, 24) });
205
+ return bottom;
206
+ }
207
+ /* 等分并限制下限的列宽 */
208
+ function cols(total, n, gap) {
209
+ const w = (total - (n - 1) * (gap || 0)) / Math.max(1, n);
210
+ return { w: w, step: w + (gap || 0) };
211
+ }
212
+ /* 行高收敛:在 [floor, pref] 内按可用高度取值。
213
+ floor 是绝对硬下限(可低于几何里的软下限),保证条目数超容量时**宁可行高更小也不越界**。 */
214
+ function fitRowH(avail, n, pref, floor) {
215
+ const lo = (floor == null) ? 0.22 : floor;
216
+ return Math.max(lo, Math.min(pref, avail / Math.max(1, n)));
217
+ }
218
+
219
+ /* ══════════ 内容模型 ══════════
220
+ * 从 --model=<file.json> 读取(推荐:由 extract_model.py 从 HTML 报告抽取,
221
+ * 保证 PPTX 与页面同源);未提供时使用下方内嵌 CONTENT 示例。 */
222
+ let CONTENT;
223
+ if (modelArg) {
224
+ /* 模型读取失败必须显式中断:静默回落内嵌示例会产出一份「看起来正常但内容全错」的 PPTX */
225
+ let raw;
226
+ try {
227
+ raw = fs.readFileSync(modelArg, 'utf-8');
228
+ } catch (e) {
229
+ console.error(`[build_pptx] FAIL: 无法读取模型文件 ${modelArg}:${e && e.message}`);
230
+ console.error(' 提示:路径是否正确?模型通常由 python scripts/extract_model.py <报告.html> 生成。');
231
+ process.exit(1);
232
+ }
233
+ try {
234
+ CONTENT = JSON.parse(raw);
235
+ } catch (e) {
236
+ console.error(`[build_pptx] FAIL: 模型不是合法 JSON(${modelArg}):${e && e.message}`);
237
+ process.exit(1);
238
+ }
239
+ if (!CONTENT || typeof CONTENT !== 'object' || Array.isArray(CONTENT)) {
240
+ console.error(`[build_pptx] FAIL: 模型根节点必须是对象(${modelArg})。`);
241
+ process.exit(1);
242
+ }
243
+ MODEL_DIR = path.dirname(path.resolve(modelArg)); // 素材图片相对路径锚点
244
+ if (CONTENT.style && !styleArg) styleArg = CONTENT.style; // 模型里的风格作为默认
245
+ } else {
246
+ CONTENT = {
247
+ title: '让想法被看见',
248
+ subtitle: 'TopPPT HTML 报告生成示例 · 16:9 可编辑 PPTX',
249
+ meta: '示例内容 · 请用 --model= 传入真实模型',
250
+ mode: 'presentation',
251
+ style: 'business-blue',
252
+ theme: 'light',
253
+ agenda: [
254
+ ['01', '问题与背景', '为什么现在要谈这件事'],
255
+ ['02', '现状基线', '三组数字勾勒起点'],
256
+ ['03', '方案对比', '两条路径的分野'],
257
+ ['04', '落地路线', '四个季度四步走'],
258
+ ],
259
+ sections: [
260
+ {
261
+ type: 'points',
262
+ eyebrow: '01 · 问题与背景',
263
+ title: '把想法说清楚,比把话说满更重要',
264
+ lead: '一页一个主张,一屏一个结论。',
265
+ points: [
266
+ ['结论前置', '每页标题就是该页最重要的那句话。'],
267
+ ['证据支撑', '数据、图表、案例三选一,不堆砌。'],
268
+ ['细节分层', '放不下的细节沉到图注、来源行与演讲者备注。'],
269
+ ],
270
+ metrics: [['1', '个核心主张/页'], ['29', '种锁定页型']],
271
+ },
272
+ {
273
+ type: 'metrics',
274
+ eyebrow: '02 · 现状基线',
275
+ title: '三组数字勾勒起点',
276
+ lead: '指标带页兼作节奏休止页。',
277
+ metrics: [['62TB', '数据规模'], ['4,662', '入湖表'], ['31%', '使用率'], ['0%', '契约覆盖']],
278
+ },
279
+ {
280
+ type: 'bar',
281
+ eyebrow: '04 · 落地路线',
282
+ title: '可信数据消费率:四个季度四步走',
283
+ chart: { labels: ['2026Q3', 'Q4', '2027Q1', 'Q2'], values: [30, 45, 55, 60], max: 100, unit: '%' },
284
+ note: '口径:被 BI / ChatBI / Agent 调用的可信数据集占比',
285
+ },
286
+ // 更多页型示例见 references/pptx-export.md 的页型表与 references/`components.md` §46 选型速查
287
+ ],
288
+ closing: {
289
+ title: '把「治数据」变成消费飞轮的一部分',
290
+ points: [
291
+ ['指标中心', '统一口径,BI / ChatBI / Agent 共用一套定义'],
292
+ ['数据契约', '上下游变更纳入契约,消费前有 SLA'],
293
+ ['治理 Agent', '清洗 / 校验 / 血缘核验自动化'],
294
+ ],
295
+ },
296
+ };
297
+ }
298
+
299
+ THEME = (themeArg === 'dark' || themeArg === 'light') ? themeArg
300
+ : (CONTENT.theme === 'dark' ? 'dark' : 'light');
301
+ STYLE = (THEME === 'dark' ? (STYLE_PRESETS_DARK[styleArg] || STYLE_PRESETS[styleArg])
302
+ : STYLE_PRESETS[styleArg]) || STYLE_PRESETS['business-blue'];
303
+ MODE_NAME = CONTENT.mode || 'presentation';
304
+ if (styleArg && !STYLE_PRESETS[styleArg]) {
305
+ console.warn('未知风格 "' + styleArg + '",回退 business-blue。可选:' + Object.keys(STYLE_PRESETS).join(' / '));
306
+ }
307
+ if (THEME === 'dark' && styleArg && !STYLE_PRESETS_DARK[styleArg]) {
308
+ console.warn('风格 "' + styleArg + '" 无 dark token,回退该风格 light token(建议补 layout-constants.json stylesDark)。');
309
+ }
310
+
311
+ /* ══════════ 生成 ══════════ */
312
+ const pptx = new pptxgen();
313
+ pptx.defineLayout({ name: 'W16x9', width: PW, height: PH });
314
+ pptx.layout = 'W16x9';
315
+ pptx.title = CONTENT.title || 'TopPPT HTML 报告';
316
+ pptx.subject = CONTENT.subtitle || '';
317
+ pptx.author = 'TopPPT HTML';
318
+ pptx.company = 'TopMindspace';
319
+
320
+ function base() {
321
+ const s = pptx.addSlide();
322
+ s.background = { color: STYLE.bg };
323
+ s.addShape('rect', { x: 0, y: 0, w: PW, h: PH, fill: { color: STYLE.bg }, line: { type: 'none' } });
324
+ return s;
325
+ }
326
+ function head(s, eyebrow, title, lead) {
327
+ const H = REGIONS.regionOf('head', 'head') || {
328
+ x: MX, y: PT.common.headEyebrowY, w: CW, h: 0.4,
329
+ titleY: PT.common.headTitleY, ruleY: PT.common.headRuleY, leadY: PT.common.leadY,
330
+ };
331
+ if (eyebrow) s.addText(eyebrow, { x: H.x, y: H.y, w: H.w, h: 0.4, fontFace: STYLE.font,
332
+ fontSize: sz(13), bold: true, color: STYLE.accent, charSpacing: 2 });
333
+ /* 主标题自适应:过长时缩一档,避免压到分隔线 */
334
+ const titleH = eyebrow ? (H.ruleY - H.titleY - 0.06) : 1.6 - 0.6;
335
+ const tSize = estTextH(title, CW, sz(30), 1.25) > titleH ? sz(24) : sz(30);
336
+ s.addText(title, { x: H.x, y: eyebrow ? H.titleY : 0.6, w: H.w, h: Math.max(0.6, titleH),
337
+ fontFace: STYLE.fontDisplay, fontSize: tSize, bold: true, color: STYLE.ink });
338
+ s.addShape('rect', { x: H.x, y: eyebrow ? H.ruleY : 1.6, w: 0.9, h: 0.045, fill: { color: STYLE.accent }, line: { type: 'none' } });
339
+ if (lead) s.addText(lead, { x: H.x, y: H.leadY || PT.common.leadY, w: H.w, h: 0.5, fontFace: STYLE.font,
340
+ fontSize: sz(14), color: STYLE.body });
341
+ }
342
+ function footer(s, n, total) {
343
+ s.addText(`${n} / ${total}`, { x: PW - 1.4, y: LAY.pageNumY != null ? LAY.pageNumY : PH - 0.5,
344
+ w: 0.9, h: 0.3, align: 'right', fontFace: STYLE.font, fontSize: sz(10), color: STYLE.faint });
345
+ }
346
+ /* so-what 结论条(research 通用可选件,与 HTML .sowhat 同源) */
347
+ /* 容器语义名(p:cNvPr@name):供 validate_pptx 按 containers.pad 分型扣内边距。
348
+ 文本框坐标已由引擎 inset,校验器不再对无名 shape 二次扣大 pad。 */
349
+ function trName(kind) { return 'tr:' + kind; }
350
+ /* 双形态载荷判定:[[t,d]…] 数组 vs [{t,d,accent}…] 记录。
351
+ typeof [] === 'object',绝不能用 typeof 区分——否则数组形态的 t/d 被读成 undefined 静默丢字。 */
352
+ function isRec(v) { return !!v && typeof v === 'object' && !Array.isArray(v); }
353
+ function soWhatBar(s, text) {
354
+ const R = REGIONS.regionOf('exhibit', 'annotation') ||
355
+ { x: MX, y: PT.exhibit.soWhatY, w: CW, h: 0.62 };
356
+ /* 长结论条在 bar 内有限缩字号(fontShrink),优先保内容完整不越出容器 */
357
+ const labelW = 1.35;
358
+ const bodyW = Math.max(2.5, R.w - 0.44 - labelW);
359
+ const fzB = fitFont([String(text || '')], bodyW, R.h - 0.14, { max: 12.5, gapFactor: 0.2, maxShrinkSteps: 3 });
360
+ s.addShape('rect', { x: R.x, y: R.y, w: R.w, h: R.h, fill: { color: STYLE.soft }, line: { type: 'none' }, objectName: trName('soWhat') });
361
+ s.addShape('rect', { x: R.x, y: R.y, w: 0.06, h: R.h, fill: { color: STYLE.accent }, line: { type: 'none' } });
362
+ s.addText([
363
+ { text: 'SO WHAT ', options: { fontSize: sz(11), bold: true, color: STYLE.accent, charSpacing: 1.5 } },
364
+ { text: text, options: { fontSize: sz(fzB), color: STYLE.ink } },
365
+ ], { x: R.x + 0.22, y: R.y + 0.06, w: R.w - 0.44, h: R.h - 0.12, fontFace: STYLE.font, valign: 'middle', objectName: trName('soWhat') });
366
+ }
367
+ function footnoteLine(s, text) {
368
+ const R = REGIONS.regionOf('exhibit', 'footnote') ||
369
+ { x: MX, y: PT.exhibit.footnoteY, w: CW - 1.2, h: 0.32 };
370
+ s.addText(text, { x: R.x, y: R.y, w: R.w, h: R.h,
371
+ fontFace: STYLE.font, fontSize: sz(9.5), color: STYLE.faint, objectName: trName('footnote') });
372
+ }
373
+ /* 待核实清单条(accent 强调色标注 · 与 HTML .flagbar 同源)
374
+ * 底对齐 contentBottom,正文区在调用点按 flagH 让位。 */
375
+ function flagBar(s, items, yTop) {
376
+ const F = PT.flagbar || { hdH: 0.26, rowH: 0.24, maxRows: 3, gap: 0.12 };
377
+ const list = (items || []).slice(0, F.maxRows).map(String);
378
+ if (!list.length) return 0;
379
+ const h = F.hdH + list.length * F.rowH + 0.06;
380
+ const y = (yTop != null) ? yTop : (CONTENT_BOTTOM - h);
381
+ s.addShape('rect', { x: MX, y, w: CW, h, fill: { color: STYLE.soft }, line: { type: 'none' }, objectName: trName('band') });
382
+ s.addShape('rect', { x: MX, y, w: 0.055, h, fill: { color: STYLE.accent }, line: { type: 'none' } });
383
+ s.addText('待核实 · 需二次确认', { x: MX + 0.22, y: y + 0.02, w: CW - 0.44, h: F.hdH,
384
+ fontFace: STYLE.font, fontSize: sz(9.5), bold: true, color: STYLE.accent, charSpacing: 1, objectName: trName('band') });
385
+ list.forEach((t, i) => {
386
+ s.addText('· ' + t, { x: MX + 0.24, y: y + F.hdH + i * F.rowH, w: CW - 0.5, h: F.rowH,
387
+ fontFace: STYLE.font, fontSize: sz(9.5), color: STYLE.body, valign: 'middle', objectName: trName('band') });
388
+ });
389
+ return h;
390
+ }
391
+ /* 素材图片 / 配图占位(src 仅允许 data: 内联或相对路径;fit = cover 裁切填满 / contain 完整显示)。
392
+ 无图或显式 placeholder 时**不静默跳过**,改画原生占位框(圆角矩形 + 虚线 + 居中标签文本)——
393
+ 占位符是原生形状而非图片,pictures 不增,且标签串与 A 通道逐字一致(cross_verify 逐页比对)。 */
394
+ function imgPlaceholderLabel(layout, multi) {
395
+ const label = IMG.placeholderLabel || '配图占位';
396
+ if (multi) return label;
397
+ const size = (IMG.recommendedSizePx || {})[layout];
398
+ if (!size) return label;
399
+ return label + ' · ' + (IMG.placeholderHintPrefix || '建议 ') + size + (IMG.placeholderHintSuffix || 'px');
400
+ }
401
+ function addImageEl(s, src, x, y, w, h, radius, opts) {
402
+ opts = opts || {};
403
+ const ph = !!opts.placeholder || !src;
404
+ if (ph) {
405
+ s.addShape('roundRect', { x, y, w, h, rectRadius: radius || 0.09,
406
+ fill: { color: STYLE.surface }, line: { color: STYLE.line, width: 1, dashType: 'dash' } });
407
+ s.addText(imgPlaceholderLabel(opts.layout || 'full', !!opts.multi),
408
+ { x: x + 0.08, y: y + h / 2 - 0.22, w: Math.max(0.4, w - 0.16), h: 0.44,
409
+ fontFace: STYLE.font, fontSize: sz(11), bold: true, color: STYLE.faint,
410
+ align: 'center', valign: 'middle' });
411
+ return;
412
+ }
413
+ const fit = String(opts.fit || IMG.fitDefault || 'cover').toLowerCase() === 'contain' ? 'contain' : 'cover';
414
+ const o = { x, y, w, h, sizing: { type: fit, w, h } };
415
+ if (/^data:/i.test(src)) {
416
+ o.data = src;
417
+ } else {
418
+ const resolved = resolveImagePath(src);
419
+ /* 写出阶段才会读盘——路径不存在必须在此拦截,否则 pptxgenjs write 时 ENOENT 中断交付 */
420
+ let exists = false;
421
+ try { exists = fs.existsSync(resolved); } catch (e) { exists = false; }
422
+ if (!exists) {
423
+ console.warn(`[build_pptx] 图片路径不存在,已回落占位框: ${String(src).slice(0, 60)}`);
424
+ addImageEl(s, '', x, y, w, h, radius, { placeholder: true, layout: opts.layout, multi: opts.multi });
425
+ return;
426
+ }
427
+ o.path = resolved;
428
+ }
429
+ if (radius) o.rounding = true;
430
+ try { s.addImage(o); }
431
+ catch (e) {
432
+ delete o.rounding;
433
+ try { s.addImage(o); }
434
+ catch (e2) {
435
+ /* 图片不可读(路径失效 / 数据损坏)→ 回落原生占位框,不留空洞、不阻断交付 */
436
+ console.warn(`[build_pptx] 图片不可读,已回落占位框: ${src.slice(0, 60)}`);
437
+ addImageEl(s, '', x, y, w, h, radius, { placeholder: true, layout: opts.layout, multi: opts.multi });
438
+ }
439
+ }
440
+ }
441
+ /* 相对路径解析:pptxgenjs 的 image.path 按 process.cwd() 解析,跨目录调用会失败。
442
+ 这里以**模型文件所在目录**为锚(模型与报告同目录,报告里的相对路径即相对该目录),
443
+ 再回落到 CWD——保证 `--model=some/dir/x.model.json` 时图片仍能按报告口径找到。 */
444
+ function resolveImagePath(src) {
445
+ const raw = String(src || '');
446
+ if (!raw) return raw;
447
+ if (path.isAbsolute(raw)) return raw;
448
+ const cands = [];
449
+ if (MODEL_DIR) cands.push(path.resolve(MODEL_DIR, raw));
450
+ cands.push(path.resolve(process.cwd(), raw));
451
+ for (const c of cands) {
452
+ try { if (fs.existsSync(c)) return c; } catch (e) { /* ignore */ }
453
+ }
454
+ return cands[0] || raw;
455
+ }
456
+ /* 版式比例(imageSpec.ratioDefault 或 image.ratio,形如 "3:1")→ 数值宽高比。
457
+ 双引擎同源:HTML 用同比例的 .media--r* 锁定类,PPTX 用本函数算高度,版式才一致。 */
458
+ function imgRatio(layout, img) {
459
+ const raw = String((img && img.ratio) || (IMG.ratioDefault || {})[layout] || '3:1');
460
+ const m = raw.match(/^\s*(\d+(?:\.\d+)?)\s*[:/×x]\s*(\d+(?:\.\d+)?)/);
461
+ if (!m) return 3;
462
+ const a = parseFloat(m[1]), b = parseFloat(m[2]);
463
+ return (a > 0 && b > 0) ? a / b : 3;
464
+ }
465
+ /* 图片版式族(与 HTML .media / .media-grid / .media-compare / .media-wall 同源):
466
+ full 版心全宽|half 左图右注|bleed 通栏出血|grid 多图网格|compare 双图对比|wall Logo 墙。
467
+ 每版式按 imageSpec.ratioDefault 锁定比例;空间不足时按可用高度收敛并**垂直居中**。
468
+ 返回图注基准 Y。占位符走原生形状(pictures 不增)。 */
469
+ function imageLayoutShapes(s, layout, img, items, points, isPh, fit, y0, y1) {
470
+ const IM = PT.image || {};
471
+ const gap = IM.gridGap || 0.16;
472
+ const iy = y0;
473
+ const ih = Math.max(1.0, y1 - iy);
474
+ let capY = iy + ih;
475
+ const ratio = imgRatio(layout, img);
476
+ const one = (x, y, w, h, it, multi) => {
477
+ addImageEl(s, (it && it.src) || '', x, y, w, h, IM.radius,
478
+ { placeholder: isPh || !(it && it.src), layout, multi, fit });
479
+ };
480
+ if (layout === 'grid' && items.length) {
481
+ const n = Math.min(items.length, IMG.maxPerPage || 6);
482
+ const colsN = (IM.gridCols || {})[String(n)] || Math.min(3, n);
483
+ const rowsN = Math.max(1, Math.ceil(n / colsN));
484
+ const capH = IM.gridCapH || 0.28;
485
+ const cellW = (CW - (colsN - 1) * gap) / colsN;
486
+ const capRows = rowsN * (capH + 0.04);
487
+ const cellH = Math.min(cellW / ratio, Math.max(0.5, (ih - (rowsN - 1) * gap - capRows) / rowsN));
488
+ const blockH = rowsN * cellH + (rowsN - 1) * gap + capRows;
489
+ const gy = iy + Math.max(0, (ih - blockH) / 2);
490
+ items.slice(0, n).forEach((it, i) => {
491
+ const cx = MX + (i % colsN) * (cellW + gap);
492
+ const cy = gy + Math.floor(i / colsN) * (cellH + gap + capH + 0.04);
493
+ const cap = (it && it.caption) || '';
494
+ one(cx, cy, cellW, cellH, it, true);
495
+ if (cap) s.addText(cap, { x: cx, y: cy + cellH + 0.02, w: cellW, h: capH,
496
+ fontFace: STYLE.font, fontSize: sz(10), color: STYLE.faint, align: 'center' });
497
+ });
498
+ capY = gy + blockH;
499
+ } else if (layout === 'compare' && items.length >= 2) {
500
+ const cgap = IM.compareGap || 0.3;
501
+ const cw2 = (CW - cgap) / 2;
502
+ const capH2 = 0.34;
503
+ const ih2 = Math.min(cw2 / ratio, Math.max(0.5, ih - capH2));
504
+ const cy2 = iy + Math.max(0, (ih - ih2 - capH2) / 2);
505
+ [0, 1].forEach((i) => {
506
+ const it = items[i] || {};
507
+ const cx2 = MX + i * (cw2 + cgap);
508
+ one(cx2, cy2, cw2, ih2, it, true);
509
+ const cap2 = it.caption || '';
510
+ if (cap2) s.addText(cap2, { x: cx2, y: cy2 + ih2 + 0.02, w: cw2, h: 0.3,
511
+ fontFace: STYLE.font, fontSize: sz(10), color: STYLE.faint, align: 'center' });
512
+ });
513
+ capY = cy2 + ih2 + capH2;
514
+ } else if (layout === 'wall' && items.length) {
515
+ const wgap = IM.wallGap || 0.18;
516
+ const wn = Math.min(items.length, 12);
517
+ const rowsW = Math.max(1, Math.min(IM.wallMaxRows || 2, Math.ceil(wn / 6)));
518
+ const perRow = Math.max(1, Math.ceil(wn / rowsW));
519
+ const cellW2 = (CW - (perRow - 1) * wgap) / perRow;
520
+ const cellH2 = Math.min(IM.wallCellH || 0.92, cellW2 / ratio,
521
+ Math.max(0.4, (ih - (rowsW - 1) * wgap) / rowsW));
522
+ const blockH2 = rowsW * cellH2 + (rowsW - 1) * wgap;
523
+ const wy = iy + Math.max(0, (ih - blockH2) / 2);
524
+ items.slice(0, wn).forEach((it, i) => {
525
+ const cx3 = MX + (i % perRow) * (cellW2 + wgap);
526
+ const cy3 = wy + Math.floor(i / perRow) * (cellH2 + wgap);
527
+ one(cx3, cy3, cellW2, cellH2, it, true);
528
+ });
529
+ capY = wy + blockH2;
530
+ } else if (layout === 'half') {
531
+ const iw = CW * (IM.halfW || 0.56);
532
+ const ihh = Math.min(iw / ratio, ih);
533
+ one(MX, iy, iw, ihh, img, false);
534
+ const rxI = MX + iw + CW * (IM.halfGap || 0.06);
535
+ const rwI = CW - iw - CW * (IM.halfGap || 0.06);
536
+ const ptsI = points || [];
537
+ const rowHI = fitRowH(ih - 0.1, ptsI.length, IM.noteRowH || 0.62, 0.3);
538
+ const fzI = fitFont(ptsI.map(p => (p[0] || '') + (p[1] || '')), rwI - 0.3, ih, { max: 14, gapFactor: 0.6 });
539
+ const fz = sz(fzI), fzSm = sz(fzI - 1);
540
+ ptsI.forEach((pt, i) => {
541
+ const lyI = iy + i * rowHI;
542
+ s.addShape('rect', { x: rxI, y: lyI + rowHI / 2 - 0.06, w: 0.12, h: 0.12,
543
+ fill: { color: STYLE.accent }, line: { type: 'none' } });
544
+ s.addText([
545
+ { text: (pt[0] || '') + ' ', options: { fontSize: fz, bold: true, color: STYLE.ink } },
546
+ { text: pt[1] || '', options: { fontSize: fzSm, color: STYLE.body } },
547
+ ], { x: rxI + 0.26, y: lyI, w: rwI - 0.3, h: rowHI - 0.04, fontFace: STYLE.font, valign: 'top' });
548
+ });
549
+ capY = iy + Math.max(ihh, ih * 0.5);
550
+ } else if (layout === 'bleed') {
551
+ const bh = Math.min(ih, PW / ratio);
552
+ const by = iy + Math.max(0, (ih - bh) / 2);
553
+ one(0, by, PW, bh, img, false);
554
+ capY = by + bh;
555
+ } else {
556
+ const fh = Math.min(ih, CW / ratio);
557
+ const fy = iy + Math.max(0, (ih - fh) / 2);
558
+ one(MX, fy, CW, fh, img, false);
559
+ capY = fy + fh;
560
+ }
561
+ return capY;
562
+ }
563
+ /* 数据图表上下文(供备注与自适应使用) */
564
+ function chartBottom(hasSoWhat, hasFootnote) {
565
+ if (hasFootnote) return PT.exhibit.footnoteY - 0.12;
566
+ if (hasSoWhat) return PT.exhibit.soWhatY - 0.12;
567
+ return CONTENT_BOTTOM;
568
+ }
569
+
570
+ /* ═══ 原生数据图表(pptxgenjs addChart → 真 chart part + 内嵌 Excel 工作簿)═══
571
+ * PowerPoint / WPS 中双击图表即可"编辑数据"——带数据的图表一律走此通道,不再用形状拼图。
572
+ * 几何取自 layout-constants 页型几何;单系列按数据点着色(峰值 accent、其余中性)。
573
+ * chart.type: bar(默认竖柱)/ hbar(横条)/ line / dualline / area / stack / donut
574
+ * chart.series(可选):[{name, values}…] 多系列(dualline / stack 用) */
575
+ function chartSeries(c) {
576
+ const labels = (c.labels || []).map(String);
577
+ if (Array.isArray(c.series) && c.series.length) {
578
+ return c.series.map((se, i) => ({
579
+ name: se.name || ('系列' + (i + 1)),
580
+ labels: labels,
581
+ values: (se.values || []).map(Number),
582
+ }));
583
+ }
584
+ return [{ name: '数值', labels: labels, values: (c.values || []).map(Number) }];
585
+ }
586
+ /* ══ 图表登记(单源 scripts/layout-constants.json · charts.registry)══
587
+ * type → { pptx: 'native'|'shape', nativeType, nativeTrick, dataTable }
588
+ * 原生通道:pptxgenjs addChart(真 chart part + 内嵌 Excel 工作簿,双击可编辑数据)
589
+ * 形状通道:shapeChart()(高保真形状还原 + 按 dataTable 策略附数据表,保证数据可追溯) */
590
+ const CHART_REG = (LC.charts && LC.charts.registry) || {};
591
+ function chartReg(t) { return CHART_REG[String(t || 'bar').toLowerCase()] || CHART_REG.bar || {}; }
592
+ function isNativeChart(t) { return chartReg(t).pptx === 'native'; }
593
+ /* 数据表策略:图表级覆盖 > 登记表默认(appendix 收敛为页内表格,保持页数稳定) */
594
+ function dataTableMode(c) {
595
+ const m = (c && c.dataTable) || chartReg(c && c.type).dataTable || 'notes';
596
+ return m === 'appendix' ? 'inline' : m;
597
+ }
598
+ function dataTableRows(c) {
599
+ const labels = (c.labels || []).map(String);
600
+ const unit = c.unit ? '(' + c.unit + ')' : '';
601
+ if (Array.isArray(c.series) && c.series.length) {
602
+ return [['项目'].concat(c.series.map((se, i) => se.name || ('系列' + (i + 1))))]
603
+ .concat(labels.map((lb, i) => [lb].concat(c.series.map(se => {
604
+ const v = (se.values || [])[i]; return v == null ? '' : String(v);
605
+ }))));
606
+ }
607
+ return [['项目', '数值' + unit]]
608
+ .concat(labels.map((lb, i) => { const v = (c.values || [])[i]; return [lb, v == null ? '' : String(v)]; }));
609
+ }
610
+ function dataTableText(c) { return dataTableRows(c).map(r => r.join(' | ')).join('\n'); }
611
+
612
+ /* 原生图表基础选项(位置由调用点附加) */
613
+ function chartBaseOpts(c, colors, multi, kind) {
614
+ const isLine = (kind === 'line' || kind === 'area');
615
+ const opts = {
616
+ showValue: !isLine && !multi,
617
+ dataLabelPosition: 'outEnd',
618
+ dataLabelColor: STYLE.body, dataLabelFontSize: sz(11.5), dataLabelFontFace: STYLE.font,
619
+ dataLabelFormatCode: c.unit ? '0"' + c.unit + '"' : 'General',
620
+ catAxisLabelColor: STYLE.faint, catAxisLabelFontSize: sz(10.5), catAxisLabelFontFace: STYLE.font,
621
+ valAxisHidden: true, valAxisLineShow: false, catAxisLineShow: false,
622
+ catGridLine: { style: 'none' }, valGridLine: { style: 'none' },
623
+ valAxisMinVal: 0,
624
+ showLegend: !!multi, legendPos: 'b', legendColor: STYLE.body,
625
+ legendFontSize: sz(10.5), legendFontFace: STYLE.font,
626
+ showTitle: false,
627
+ };
628
+ if (colors) opts.chartColors = colors;
629
+ if (c.max) opts.valAxisMaxVal = c.max;
630
+ return opts;
631
+ }
632
+ /* addChart 失败即失败:禁止静默回落为柱状图(会造成「过了 strict 但图表类型假象」)。
633
+ * 严格解析/第三方裁判可暴露该失败;交付通道必须诚实报错。 */
634
+ function chartTry(s, ctype, data, opts, c) {
635
+ try { s.addChart(ctype, data, opts); }
636
+ catch (e) {
637
+ console.error(`[build_pptx] FAIL: 原生图表 ${ctype} 调用失败(禁止回落): ${e && e.message}`);
638
+ process.exitCode = 1;
639
+ throw e;
640
+ }
641
+ }
642
+ /* 原生数据图表(pptxgenjs 原生 10 类 + 3 类原生技巧:waterfall / gauge / pareto) */
643
+ function nativeChart(s, c, x, y, w, h, dcols) {
644
+ const t = String(c.type || 'bar').toLowerCase();
645
+ const reg = chartReg(t);
646
+ const kind = reg.nativeType || 'bar';
647
+ const trick = reg.nativeTrick || '';
648
+ const vals = (c.values || []).map(Number);
649
+ const top = vals.length ? Math.max.apply(null, vals) : 0;
650
+ const multi = Array.isArray(c.series) && c.series.length > 0;
651
+ const colors = (dcols && dcols.length) ? dcols
652
+ : (multi ? null : vals.map(v => (v === top ? STYLE.accent : STYLE.faint)));
653
+ const pos = { x: x, y: y, w: w, h: h };
654
+
655
+ /* ── 原生技巧 ── */
656
+ if (trick.indexOf('stacked+hiddenBase') === 0) { waterfallChart(s, c, pos); return; }
657
+ if (trick.indexOf('doughnut+firstSliceAng') === 0) { gaugeChart(s, c, pos); return; }
658
+ if (trick.indexOf('multiType') === 0) { paretoChart(s, c, pos); return; }
659
+
660
+ /* ── 饼 / 环 ── */
661
+ if (kind === 'doughnut' || kind === 'pie') {
662
+ const opts = Object.assign(chartBaseOpts(c, colors || donutColors(styleArg), false, kind), pos, {
663
+ showValue: true, dataLabelPosition: 'ctr',
664
+ dataLabelColor: STYLE.onAccent, showPercent: true,
665
+ showLegend: true, legendPos: 'r',
666
+ dataBorder: { pt: 1.5, color: STYLE.bg },
667
+ });
668
+ if (kind === 'doughnut') opts.holeSize = 55;
669
+ chartTry(s, kind === 'pie' ? pptx.ChartType.pie : pptx.ChartType.doughnut, chartSeries(c), opts, c);
670
+ return;
671
+ }
672
+ /* ── 散点 / 气泡(XY 型) ── */
673
+ if (kind === 'scatter' || kind === 'bubble') { xyChart(s, c, pos, kind, colors); return; }
674
+ /* ── 雷达 ── */
675
+ if (kind === 'radar') {
676
+ const opts = Object.assign(chartBaseOpts(c, colors, multi, kind), pos, {
677
+ radarStyle: 'marker', valAxisHidden: false, valAxisLineShow: true, catAxisLineShow: true,
678
+ valGridLine: { style: 'solid', color: STYLE.line }, showValue: false,
679
+ });
680
+ chartTry(s, pptx.ChartType.radar, chartSeries(c), opts, c);
681
+ return;
682
+ }
683
+ /* ── 柱族(bar / hbar / stack / stackline)与线 / 面积 ── */
684
+ const opts = Object.assign(chartBaseOpts(c, colors, multi, kind), pos);
685
+ if (kind === 'bar') {
686
+ opts.barDir = (t === 'hbar') ? 'bar' : 'col';
687
+ opts.barGapWidthPct = (t === 'hbar') ? 45 : 55;
688
+ if (t === 'stack') { opts.barGrouping = 'stacked'; opts.showValue = false; }
689
+ if (t === 'stackline') { opts.barGrouping = 'percentStacked'; opts.showValue = false; }
690
+ }
691
+ if (kind === 'line' || kind === 'area') {
692
+ opts.lineSize = 2.5; opts.lineSmooth = false;
693
+ opts.lineDataSymbol = 'circle'; opts.lineDataSymbolSize = 6;
694
+ }
695
+ const ctype = kind === 'line' ? pptx.ChartType.line
696
+ : kind === 'area' ? pptx.ChartType.area : pptx.ChartType.bar;
697
+ chartTry(s, ctype, chartSeries(c), opts, c);
698
+ }
699
+ /* 散点(X/Y 双系列)/ 气泡({x,y,w} 单系列);labels 解析为 x,values 为 y */
700
+ function xyChart(s, c, pos, kind, colors) {
701
+ const labels = (c.labels || []);
702
+ const vals = (c.values || []).map(Number);
703
+ const pts = Array.isArray(c.points) ? c.points : null;
704
+ const opts = Object.assign(chartBaseOpts(c, colors, false, kind), pos, {
705
+ showValue: false, showLegend: false,
706
+ valAxisHidden: false, valAxisLineShow: true, catAxisLineShow: true,
707
+ catGridLine: { style: 'solid', color: STYLE.line }, valGridLine: { style: 'solid', color: STYLE.line },
708
+ });
709
+ try {
710
+ if (kind === 'bubble') {
711
+ const raw = pts ? pts : labels.map((lb, i) => [i + 1, vals[i], 1]);
712
+ const data = [{ name: c.unit || '规模',
713
+ values: raw.map(p => ({ x: Number(p[0]) || 0, y: Number(p[1]) || 0, w: Number(p[2]) || 1 })) }];
714
+ s.addChart(pptx.ChartType.bubble, data, opts);
715
+ } else {
716
+ const xs = pts ? pts.map(p => Number(p[0]) || 0)
717
+ : labels.map(lb => { const n = parseFloat(String(lb).replace(/[^\d.\-]/g, '')); return isNaN(n) ? 0 : n; });
718
+ const ys = pts ? pts.map(p => Number(p[1]) || 0) : vals;
719
+ s.addChart(pptx.ChartType.scatter, [{ name: 'X', values: xs }, { name: 'Y', values: ys }], opts);
720
+ }
721
+ } catch (e) {
722
+ console.error(`[build_pptx] FAIL: 原生 ${kind} 调用失败(禁止回落 bar): ${e && e.message}`);
723
+ process.exitCode = 1;
724
+ throw e;
725
+ }
726
+ }
727
+ /* 瀑布图(原生技巧):隐藏基底 + 增减堆叠柱,叠加累计折线作连接线 */
728
+ function waterfallChart(s, c, pos) {
729
+ const labels = (c.labels || []).map(String);
730
+ const deltas = (c.values || []).map(Number);
731
+ let run = 0;
732
+ const bases = [], ups = [], downs = [], cum = [];
733
+ deltas.forEach(d => {
734
+ const b = d >= 0 ? run : run + d;
735
+ bases.push(b); ups.push(d >= 0 ? d : 0); downs.push(d < 0 ? -d : 0);
736
+ run += d; cum.push(run);
737
+ });
738
+ const series = [
739
+ { name: '基底', labels: labels, values: bases },
740
+ { name: '增量', labels: labels, values: ups },
741
+ { name: '减量', labels: labels, values: downs },
742
+ ];
743
+ const inner = {
744
+ barDir: 'col', barGrouping: 'stacked', showValue: false, showLegend: false,
745
+ chartColors: [STYLE.bg, STYLE.accent, STYLE.faint],
746
+ catAxisLabelColor: STYLE.faint, catAxisLabelFontSize: sz(10.5), catAxisLabelFontFace: STYLE.font,
747
+ valAxisHidden: true, valAxisLineShow: false, catAxisLineShow: false,
748
+ catGridLine: { style: 'none' }, valGridLine: { style: 'none' }, valAxisMinVal: 0, showTitle: false,
749
+ };
750
+ const outer = Object.assign({}, inner, pos);
751
+ try {
752
+ s.addChart([
753
+ { type: pptx.ChartType.bar, data: series, options: inner },
754
+ { type: pptx.ChartType.line, data: [{ name: '累计', labels: labels, values: cum }],
755
+ options: { lineSize: 1.25, lineSmooth: false, lineDataSymbol: 'none', chartColors: [STYLE.body] } },
756
+ ], outer);
757
+ } catch (e) {
758
+ /* 组合图失败:单柱降级必须可观测,禁止静默空白 */
759
+ console.error(`[build_pptx] WARN: waterfall 组合图失败,降级为堆叠柱: ${e && e.message}`);
760
+ try {
761
+ s.addChart(pptx.ChartType.bar, series, outer);
762
+ } catch (e2) {
763
+ console.error(`[build_pptx] FAIL: waterfall 降级柱图仍失败: ${e2 && e2.message}`);
764
+ process.exitCode = 1;
765
+ throw e2;
766
+ }
767
+ }
768
+ }
769
+ /* 仪表盘(原生技巧):270° 扇形环,剩余扇区以底色隐藏 */
770
+ function gaugeChart(s, c, pos) {
771
+ const v = Number((c.values || [0])[0]) || 0;
772
+ const max = Number(c.max) || 100;
773
+ const ratio = Math.max(0, Math.min(1, v / max));
774
+ const sweep = ratio * 270;
775
+ /* 类别标签用数值(达成值 / 剩余值)——cross_verify 按纯数值 token 过滤,A/B 文本集合一致 */
776
+ const _u = c.unit || '';
777
+ try {
778
+ s.addChart(pptx.ChartType.doughnut,
779
+ [{ name: '达成', labels: [String(v) + _u, String(Math.max(0, max - v)) + _u], values: [sweep, 360 - sweep] }],
780
+ Object.assign({}, pos, {
781
+ holeSize: 68, firstSliceAng: 225,
782
+ chartColors: [STYLE.accent, STYLE.surface],
783
+ dataBorder: { pt: 1, color: STYLE.bg },
784
+ showLegend: false, showValue: false, showPercent: false, showTitle: false,
785
+ }));
786
+ } catch (e) {
787
+ console.error(`[build_pptx] FAIL: 原生 gauge 调用失败(禁止静默): ${e && e.message}`);
788
+ process.exitCode = 1;
789
+ throw e;
790
+ }
791
+ }
792
+ /* 帕累托(原生技巧):柱(数值)+ 折线(累计占比),多类型组合 */
793
+ function paretoChart(s, c, pos) {
794
+ const labels = (c.labels || []).map(String);
795
+ const vals = (c.values || []).map(Number);
796
+ const total = vals.reduce((a, b) => a + b, 0) || 1;
797
+ let run = 0;
798
+ const cum = vals.map(v => { run += v; return Math.round(run / total * 1000) / 10; });
799
+ const barOpts = {
800
+ barDir: 'col', showValue: false, showLegend: true, legendPos: 'b',
801
+ chartColors: [STYLE.accent],
802
+ catAxisLabelColor: STYLE.faint, catAxisLabelFontSize: sz(10.5), catAxisLabelFontFace: STYLE.font,
803
+ valAxisHidden: true, valAxisLineShow: false, catAxisLineShow: false,
804
+ catGridLine: { style: 'none' }, valGridLine: { style: 'none' }, valAxisMinVal: 0, showTitle: false,
805
+ };
806
+ try {
807
+ s.addChart([
808
+ { type: pptx.ChartType.bar,
809
+ data: [{ name: c.unit ? '数值(' + c.unit + ')' : '数值', labels: labels, values: vals }], options: barOpts },
810
+ { type: pptx.ChartType.line, data: [{ name: '累计占比', labels: labels, values: cum }],
811
+ options: { lineSize: 2, lineSmooth: false, lineDataSymbol: 'circle', lineDataSymbolSize: 6, chartColors: [STYLE.body] } },
812
+ ], Object.assign({}, barOpts, pos));
813
+ } catch (e) { chartTry(s, pptx.ChartType.bar, chartSeries(c), Object.assign({}, barOpts, pos), c); }
814
+ }
815
+ function nativeDonutChart(s, dcn, dcols) {
816
+ const d = PT.donut;
817
+ s.addChart(pptx.ChartType.doughnut, chartSeries(dcn), {
818
+ x: d.centerX - d.r, y: d.centerY - d.r, w: d.r * 2, h: d.r * 2,
819
+ holeSize: Math.round(d.holeR / d.r * 100),
820
+ chartColors: dcols,
821
+ showLegend: false, showValue: false, showPercent: false, showTitle: false,
822
+ dataBorder: { pt: 1.5, color: STYLE.bg },
823
+ });
824
+ }
825
+ /* ═══ 形状通道图表(非原生类型的高保真形状还原)═══
826
+ * 与 A 通道(assets/pptx-export.js 的 chartShapes)语义一致:类别标签与数值都落为文本,
827
+ * 保证 cross_verify 逐页文本集合一致(数值 token 由 cross_verify 过滤)。
828
+ * 复杂信息图(sankey/treemap/boxplot/network/marimekko/streamgraph)请用专属页型承载。 */
829
+ function shapeChart(s, c, x, y, w, h, dcols) {
830
+ const t = String(c.type || 'bar').toLowerCase();
831
+ if (t === 'funnel') { shapeFunnel(s, c, x, y, w, h, dcols); return; }
832
+ if (t === 'progress') { shapeRows(s, c, x, y, w, h, dcols, 'progress'); return; }
833
+ if (t === 'radialbar') { shapeRows(s, c, x, y, w, h, dcols, 'radialbar'); return; }
834
+ if (t === 'dumbbell') { shapeRows(s, c, x, y, w, h, dcols, 'dumbbell'); return; }
835
+ if (t === 'lollipop') { shapeRows(s, c, x, y, w, h, dcols, 'lollipop'); return; }
836
+ if (t === 'dotplot') { shapeRows(s, c, x, y, w, h, dcols, 'dotplot'); return; }
837
+ if (t === 'bulletchart') { shapeRows(s, c, x, y, w, h, dcols, 'bullet'); return; }
838
+ if (t === 'gantt') { shapeGantt(s, c, x, y, w, h, dcols); return; }
839
+ if (t === 'vsbar') { shapeVsbar(s, c, x, y, w, h, dcols); return; }
840
+ if (t === 'sparkline') { shapeSpark(s, c, x, y, w, h, dcols); return; }
841
+ if (t === 'slope') { shapeSlope(s, c, x, y, w, h, dcols); return; }
842
+ if (t === 'waffle') { shapeWaffle(s, c, x, y, w, h, dcols); return; }
843
+ if (t === 'rose') { shapeRose(s, c, x, y, w, h, dcols); return; }
844
+ if (t === 'candlestick') { shapeCandlestick(s, c, x, y, w, h, dcols); return; }
845
+ /* 已登记形状通道类型必须命中专属渲染器——命中通用回落 = 实现缺口,硬失败 */
846
+ const reg = chartReg(t);
847
+ if (reg && reg.pptx === 'shape') {
848
+ console.error(`[build_pptx] FAIL: 形状通道类型 ${t} 已登记但缺专属渲染器,拒绝通用比例条回落`);
849
+ process.exitCode = 1;
850
+ throw new Error(`SHAPE_RENDERER_MISSING: ${t}`);
851
+ }
852
+ /* 未登记类型:仅在模型非法时走到这里(schema/校验应已拦截)——留痕便于定位 */
853
+ console.warn(`[build_pptx] 未登记图表类型 ${t} 使用通用比例条(模型应先过 schema)。`);
854
+ shapeProportion(s, c, x, y, w, h, dcols);
855
+ }
856
+ /* 形状图表通用排版量 */
857
+ function shapeMetrics(c, w, h) {
858
+ const labels = (c.labels || []).map(String);
859
+ const values = (c.values || []).map(Number);
860
+ const n = Math.max(1, labels.length);
861
+ const labelW = Math.min(2.4, w * 0.24);
862
+ const valW = 0.9;
863
+ const vmax = c.max || Math.max.apply(null, values.concat([1]));
864
+ const top = values.length ? Math.max.apply(null, values) : 0;
865
+ return { labels: labels, values: values, n: n, labelW: labelW, valW: valW, vmax: vmax, top: top };
866
+ }
867
+ function shapeLabel(s, t, x, y, w, h, col, bold) {
868
+ s.addText(t, { x: x, y: y, w: w, h: h, fontFace: STYLE.font, fontSize: sz(bold ? 11.5 : 11),
869
+ bold: !!bold, color: col || STYLE.body, valign: 'middle' });
870
+ }
871
+ function shapeValue(s, c, v, x, y, w, h, hot) {
872
+ s.addText(String(v) + (c.unit || ''), { x: x, y: y, w: w, h: h, fontFace: STYLE.font,
873
+ fontSize: sz(11.5), bold: true, color: hot ? STYLE.accent : STYLE.body, align: 'right', valign: 'middle' });
874
+ }
875
+ /* 玫瑰图(rose):等角扇区、半径 ∝ 数值(南丁格尔玫瑰)。
876
+ * 扇区用「多段旋转矩形」沿角向铺贴逼近(每扇区 ≥16 段,双边界分别取弧长),
877
+ * 不使用 preset pie/arc 替代曲线语义;标签与数值走底部图例行(文本与 A 通道一致)。 */
878
+ function shapeRose(s, c, x, y, w, h, dcols) {
879
+ const M = shapeMetrics(c, w, h);
880
+ const legendH = Math.min(0.72, Math.max(0.3, h * 0.18));
881
+ const plotH = Math.max(0.6, h - legendH - 0.08);
882
+ const cx = x + w / 2, cy = y + plotH / 2;
883
+ const R = Math.max(0.4, Math.min(w, plotH) / 2 - 0.08);
884
+ const rMin = R * 0.2;
885
+ const step = 360 / M.n;
886
+ const gapDeg = M.n > 1 ? 1.6 : 0;
887
+ const vmax = M.vmax || 1;
888
+ M.labels.forEach((lb, i) => {
889
+ const v = M.values[i] || 0;
890
+ const r = rMin + (R - rMin) * Math.max(0.05, Math.min(1, Math.abs(v) / vmax));
891
+ const a1 = -90 + i * step + gapDeg / 2;
892
+ const a2 = -90 + (i + 1) * step - gapDeg / 2;
893
+ const segs = Math.max(16, Math.round((a2 - a1) / 5));
894
+ const dc = dcols ? dcols[i % dcols.length] : (v === M.top ? STYLE.accent : STYLE.faint);
895
+ for (let k = 0; k < segs; k++) {
896
+ const t0 = (a1 + (a2 - a1) * (k / segs)) * Math.PI / 180;
897
+ const t1 = (a1 + (a2 - a1) * ((k + 1) / segs)) * Math.PI / 180;
898
+ const tm = (t0 + t1) / 2;
899
+ const chord = Math.max(0.02, r * Math.abs(t1 - t0));
900
+ s.addShape('rect', {
901
+ x: cx + Math.cos(tm) * (r / 2) - chord / 2, y: cy + Math.sin(tm) * (r / 2) - r / 2,
902
+ w: chord, h: r, rotate: Math.round((tm * 180 / Math.PI + 90) * 10) / 10,
903
+ fill: { color: dc }, line: { type: 'none' } });
904
+ }
905
+ });
906
+ /* 底部图例:色块 + 标签 + 数值 */
907
+ const itemW = Math.max(0.9, w / M.n);
908
+ M.labels.forEach((lb, i) => {
909
+ const dc = dcols ? dcols[i % dcols.length] : (M.values[i] === M.top ? STYLE.accent : STYLE.faint);
910
+ const lx = x + i * itemW;
911
+ const ly = y + plotH + 0.08;
912
+ s.addShape('rect', { x: lx, y: ly + 0.04, w: 0.11, h: 0.11, fill: { color: dc }, line: { type: 'none' } });
913
+ s.addText(lb, { x: lx + 0.15, y: ly - 0.02, w: Math.max(0.3, itemW - 0.85), h: 0.24,
914
+ fontFace: STYLE.font, fontSize: sz(10.5), color: STYLE.body, valign: 'middle' });
915
+ shapeValue(s, c, M.values[i] || 0, lx + itemW - 0.7, ly - 0.02, 0.66, 0.24, M.values[i] === M.top);
916
+ });
917
+ }
918
+ /* K 线(candlestick):values = [[开,高,低,收]…];实体 = 开收,须 = 高低。
919
+ * 上涨用 accent、下跌用中性线色(不引入红绿第二色相);文本 = 标签 + 收盘值。 */
920
+ function shapeCandlestick(s, c, x, y, w, h, dcols) {
921
+ const labels = (c.labels || []).map(String);
922
+ const rows = (c.values || []).filter(r => Array.isArray(r) && r.length >= 4)
923
+ .map(r => r.slice(0, 4).map(Number));
924
+ const n = Math.max(1, rows.length);
925
+ const axisH = 0.26;
926
+ const plotH = Math.max(0.5, h - axisH - 0.1);
927
+ const flat = [];
928
+ rows.forEach(r => r.forEach(v => { if (!isNaN(v)) flat.push(v); }));
929
+ const lo = flat.length ? Math.min.apply(null, flat) : 0;
930
+ const hi = flat.length ? Math.max.apply(null, flat) : 1;
931
+ const span = (hi - lo) || 1;
932
+ const yFor = (v) => y + 0.06 + (1 - (v - lo) / span) * plotH;
933
+ const step = w / n;
934
+ const bw = Math.min(0.42, step * 0.5);
935
+ s.addShape('rect', { x: x, y: y + 0.06 + plotH, w: w, h: 0.015, fill: { color: STYLE.line }, line: { type: 'none' } });
936
+ rows.forEach((r, i) => {
937
+ const [o, hiP, loP, cl] = r;
938
+ const cxp = x + step * i + step / 2;
939
+ const up = cl >= o;
940
+ const bodyCol = up ? STYLE.accent : STYLE.line;
941
+ s.addShape('rect', { x: cxp - 0.011, y: yFor(hiP), w: 0.022, h: Math.max(0.02, yFor(loP) - yFor(hiP)),
942
+ fill: { color: STYLE.faint }, line: { type: 'none' } });
943
+ s.addShape('rect', { x: cxp - bw / 2, y: yFor(Math.max(o, cl)), w: bw,
944
+ h: Math.max(0.04, Math.abs(yFor(cl) - yFor(o))),
945
+ fill: { color: bodyCol }, line: { type: 'none' } });
946
+ if (labels[i]) {
947
+ s.addText(labels[i], { x: cxp - step / 2, y: y + plotH + axisH - 0.06, w: step, h: 0.24,
948
+ align: 'center', fontFace: STYLE.font, fontSize: sz(10), color: STYLE.faint });
949
+ }
950
+ shapeValue(s, c, cl, cxp - step / 2, yFor(hiP) - 0.26, step, 0.24, up);
951
+ });
952
+ }
953
+ /* 通用回落:标签 + 比例条 + 数值——**仅**未登记/非法类型可用。
954
+ * 已登记形状类型命中此处会由 shapeChart 抛 SHAPE_RENDERER_MISSING。 */
955
+ function shapeProportion(s, c, x, y, w, h, dcols) {
956
+ const M = shapeMetrics(c, w, h);
957
+ const rowH = Math.max(0.22, Math.min(0.52, h / M.n));
958
+ const trackX = x + M.labelW + 0.08;
959
+ const trackW = w - M.labelW - M.valW - 0.2;
960
+ const barH = Math.max(0.12, rowH * 0.52);
961
+ M.labels.forEach((lb, i) => {
962
+ const v = M.values[i] || 0;
963
+ const ry = y + i * rowH;
964
+ const hot = v === M.top;
965
+ const dc = dcols ? dcols[i % dcols.length] : null;
966
+ shapeLabel(s, lb, x, ry, M.labelW - 0.06, rowH, STYLE.body);
967
+ s.addShape('rect', { x: trackX, y: ry + (rowH - barH) / 2, w: trackW, h: barH,
968
+ fill: { color: STYLE.surface }, line: { color: STYLE.line, width: 0.5 } });
969
+ s.addShape('rect', { x: trackX, y: ry + (rowH - barH) / 2,
970
+ w: Math.max(0.02, Math.abs(v) / M.vmax * trackW), h: barH,
971
+ fill: { color: dc || (hot ? STYLE.accent : STYLE.faint) }, line: { type: 'none' } });
972
+ shapeValue(s, c, v, trackX + trackW + 0.06, ry, M.valW, rowH, hot);
973
+ });
974
+ }
975
+ /* 行式标记图:progress / radialbar / dumbbell / lollipop / dotplot / bullet */
976
+ function shapeRows(s, c, x, y, w, h, dcols, variant) {
977
+ const M = shapeMetrics(c, w, h);
978
+ const rowH = Math.max(0.24, Math.min(0.6, h / M.n));
979
+ const trackX = x + M.labelW + 0.08;
980
+ const trackW = w - M.labelW - M.valW - 0.2;
981
+ const th = Math.max(0.1, rowH * (variant === 'bullet' ? 0.42 : 0.28));
982
+ M.labels.forEach((lb, i) => {
983
+ const v = M.values[i] || 0;
984
+ const ry = y + i * rowH;
985
+ const cy = ry + rowH / 2;
986
+ const hot = v === M.top;
987
+ const dc = dcols ? dcols[i % dcols.length] : null;
988
+ const col = dc || (hot ? STYLE.accent : STYLE.faint);
989
+ shapeLabel(s, lb, x, ry, M.labelW - 0.06, rowH, STYLE.body);
990
+ const frac = Math.max(0, Math.min(1, v / M.vmax));
991
+ if (variant === 'progress' || variant === 'radialbar') {
992
+ s.addShape('roundRect', { x: trackX, y: cy - th / 2, w: trackW, h: th,
993
+ rectRadius: 0.04, fill: { color: STYLE.surface }, line: { type: 'none' } });
994
+ s.addShape('roundRect', { x: trackX, y: cy - th / 2, w: Math.max(0.03, frac * trackW), h: th,
995
+ rectRadius: 0.04, fill: { color: col }, line: { type: 'none' } });
996
+ } else if (variant === 'dumbbell') {
997
+ const base = Math.max(0, Math.min(1, (M.values[i] || 0) / M.vmax));
998
+ const other = Math.max(0, Math.min(1, ((c.series && c.series[0] && c.series[0].values[i]) || M.vmax * 0.6) / M.vmax));
999
+ const x1 = trackX + Math.min(base, other) * trackW, x2 = trackX + Math.max(base, other) * trackW;
1000
+ s.addShape('rect', { x: x1, y: cy - 0.015, w: Math.max(0.02, x2 - x1), h: 0.03,
1001
+ fill: { color: STYLE.line }, line: { type: 'none' } });
1002
+ s.addShape('ellipse', { x: x1 - 0.055, y: cy - 0.055, w: 0.11, h: 0.11, fill: { color: STYLE.faint }, line: { type: 'none' } });
1003
+ s.addShape('ellipse', { x: x2 - 0.055, y: cy - 0.055, w: 0.11, h: 0.11, fill: { color: col }, line: { type: 'none' } });
1004
+ } else if (variant === 'lollipop' || variant === 'dotplot') {
1005
+ const px = trackX + frac * trackW;
1006
+ if (variant === 'lollipop') {
1007
+ s.addShape('rect', { x: px - 0.012, y: cy, w: 0.024, h: (y + h) - cy - 0.06,
1008
+ fill: { color: STYLE.line }, line: { type: 'none' } });
1009
+ }
1010
+ s.addShape('ellipse', { x: px - 0.07, y: cy - 0.07, w: 0.14, h: 0.14, fill: { color: col }, line: { type: 'none' } });
1011
+ } else { /* bullet:底槽 + 实际条 + 目标刻度 */
1012
+ s.addShape('rect', { x: trackX, y: cy - th / 2, w: trackW, h: th,
1013
+ fill: { color: STYLE.surface }, line: { color: STYLE.line, width: 0.5 } });
1014
+ s.addShape('rect', { x: trackX, y: cy - th / 2, w: Math.max(0.03, frac * trackW), h: th,
1015
+ fill: { color: col }, line: { type: 'none' } });
1016
+ const tgt = Math.max(0, Math.min(1, ((c.target && c.target[i]) || M.vmax * 0.8) / M.vmax));
1017
+ s.addShape('rect', { x: trackX + tgt * trackW - 0.015, y: cy - th * 0.8, w: 0.03, h: th * 1.6,
1018
+ fill: { color: STYLE.ink }, line: { type: 'none' } });
1019
+ }
1020
+ shapeValue(s, c, v, trackX + trackW + 0.06, ry, M.valW, rowH, hot);
1021
+ });
1022
+ }
1023
+ /* 漏斗:逐层收窄的梯形行(用宽度递减的矩形近似,保留标签与数值) */
1024
+ function shapeFunnel(s, c, x, y, w, h, dcols) {
1025
+ const M = shapeMetrics(c, w, h);
1026
+ const rowH = Math.max(0.26, Math.min(0.72, h / M.n));
1027
+ M.labels.forEach((lb, i) => {
1028
+ const v = M.values[i] || 0;
1029
+ const ry = y + i * rowH;
1030
+ const hot = v === M.top;
1031
+ const dc = dcols ? dcols[i % dcols.length] : null;
1032
+ const frac = Math.max(0.06, v / M.vmax);
1033
+ const bw = Math.max(0.4, (w - M.valW - 0.2) * frac);
1034
+ const bx = x + (w - M.valW - 0.2 - bw) / 2;
1035
+ s.addShape('rect', { x: bx, y: ry + 0.02, w: bw, h: rowH - 0.06,
1036
+ fill: { color: dc || (hot ? STYLE.accent : STYLE.faint) }, line: { type: 'none' } });
1037
+ s.addText(lb, { x: bx, y: ry + 0.02, w: bw, h: rowH - 0.06,
1038
+ fontFace: STYLE.font, fontSize: sz(11.5), bold: true, color: STYLE.onAccent, align: 'center', valign: 'middle' });
1039
+ shapeValue(s, c, v, x + w - M.valW, ry, M.valW, rowH, hot);
1040
+ });
1041
+ }
1042
+ /* 甘特 / 路线:行 = 标签 + 偏移起点 + 时长条 */
1043
+ function shapeGantt(s, c, x, y, w, h, dcols) {
1044
+ const M = shapeMetrics(c, w, h);
1045
+ const rowH = Math.max(0.24, Math.min(0.6, h / M.n));
1046
+ const trackX = x + M.labelW + 0.08;
1047
+ const trackW = w - M.labelW - M.valW - 0.2;
1048
+ /* 时间轴跨度 = 显式 max,或所有条目的「起点 + 时长」最大值(原按「时长最大值」
1049
+ * 导致 start 偏移超出轨道、形状越界的缺陷) */
1050
+ let span = Number(c.max) || 0;
1051
+ if (!span) {
1052
+ M.values.forEach((dur, i) => {
1053
+ const st = (c.start && Number(c.start[i])) || 0;
1054
+ span = Math.max(span, st + (Number(dur) || 0));
1055
+ });
1056
+ }
1057
+ span = span || 1;
1058
+ M.labels.forEach((lb, i) => {
1059
+ const start = (c.start && Number(c.start[i])) || 0;
1060
+ const dur = M.values[i] || 0;
1061
+ const ry = y + i * rowH;
1062
+ const hot = dur === M.top;
1063
+ const dc = dcols ? dcols[i % dcols.length] : null;
1064
+ shapeLabel(s, lb, x, ry, M.labelW - 0.06, rowH, STYLE.body);
1065
+ s.addShape('rect', { x: trackX, y: ry + rowH * 0.3, w: trackW, h: rowH * 0.36,
1066
+ fill: { color: STYLE.surface }, line: { type: 'none' } });
1067
+ s.addShape('rect', { x: trackX + (start / span) * trackW, y: ry + rowH * 0.3,
1068
+ w: Math.max(0.05, (dur / span) * trackW), h: rowH * 0.36,
1069
+ fill: { color: dc || (hot ? STYLE.accent : STYLE.faint) }, line: { type: 'none' } });
1070
+ shapeValue(s, c, dur, trackX + trackW + 0.06, ry, M.valW, rowH, hot);
1071
+ });
1072
+ }
1073
+ /* 双向对比条(vsbar):以中线为轴向左/右展开(正值向右、负值向左) */
1074
+ function shapeVsbar(s, c, x, y, w, h, dcols) {
1075
+ const M = shapeMetrics(c, w, h);
1076
+ const rowH = Math.max(0.24, Math.min(0.6, h / M.n));
1077
+ const labelW = M.labelW;
1078
+ const halfW = (w - labelW - M.valW - 0.2) / 2;
1079
+ const midX = x + labelW + 0.08 + halfW;
1080
+ const barH = Math.max(0.14, rowH * 0.5);
1081
+ M.labels.forEach((lb, i) => {
1082
+ const v = M.values[i] || 0;
1083
+ const ry = y + i * rowH;
1084
+ const cy = ry + rowH / 2;
1085
+ const hot = v === M.top;
1086
+ shapeLabel(s, lb, x, ry, labelW - 0.06, rowH, STYLE.body);
1087
+ const bw = Math.max(0.03, Math.abs(v) / M.vmax * halfW);
1088
+ const bx = v >= 0 ? midX : midX - bw;
1089
+ s.addShape('rect', { x: bx, y: cy - barH / 2, w: bw, h: barH,
1090
+ fill: { color: hot ? STYLE.accent : STYLE.faint }, line: { type: 'none' } });
1091
+ s.addShape('rect', { x: midX - 0.008, y: ry + 0.04, w: 0.016, h: rowH - 0.08,
1092
+ fill: { color: STYLE.line }, line: { type: 'none' } });
1093
+ shapeValue(s, c, v, midX + halfW + 0.06, ry, M.valW, rowH, hot);
1094
+ });
1095
+ }
1096
+ /* 迷你折线(sparkline):无轴,仅趋势线 + 端点 */
1097
+ function shapeSpark(s, c, x, y, w, h, dcols) {
1098
+ const M = shapeMetrics(c, w, h);
1099
+ const stepX = M.n > 1 ? w / (M.n - 1) : 0;
1100
+ const baseY = y + h - 0.06;
1101
+ const plotH = Math.max(0.2, h - 0.18);
1102
+ let px = 0, py = 0;
1103
+ M.values.forEach((v, i) => {
1104
+ const cx = x + i * stepX;
1105
+ const cy = baseY - Math.max(0.02, (v / M.vmax) * plotH);
1106
+ if (i > 0) {
1107
+ s.addShape('rect', { x: Math.min(px, cx), y: (py + cy) / 2 - 0.012, w: Math.abs(cx - px), h: 0.024,
1108
+ fill: { color: STYLE.accent }, line: { type: 'none' } });
1109
+ }
1110
+ px = cx; py = cy;
1111
+ });
1112
+ s.addText(String(M.values[M.values.length - 1] != null ? M.values[M.values.length - 1] : '') + (c.unit || ''),
1113
+ { x: x, y: y, w: w, h: 0.3, fontFace: STYLE.font, fontSize: sz(11.5), bold: true,
1114
+ color: STYLE.accent, align: 'right' });
1115
+ }
1116
+ /* 斜率图(slope):两点之间的连线 + 端点标签 */
1117
+ function shapeSlope(s, c, x, y, w, h, dcols) {
1118
+ const M = shapeMetrics(c, w, h);
1119
+ const leftX = x + 0.5, rightX = x + w - 0.5;
1120
+ const plotH = Math.max(0.3, h - 0.3);
1121
+ const yFor = (v) => y + 0.15 + (1 - Math.max(0, Math.min(1, v / M.vmax))) * plotH;
1122
+ const other = (c.series && c.series[0] && c.series[0].values) || M.values.map(v => v * 0.7);
1123
+ M.labels.forEach((lb, i) => {
1124
+ const y1 = yFor(M.values[i]), y2 = yFor(Number(other[i]) || 0);
1125
+ s.addShape('rect', { x: leftX, y: (y1 + y2) / 2 - 0.012, w: rightX - leftX, h: 0.024,
1126
+ fill: { color: STYLE.line }, line: { type: 'none' } });
1127
+ s.addShape('ellipse', { x: leftX - 0.06, y: y1 - 0.06, w: 0.12, h: 0.12, fill: { color: STYLE.faint }, line: { type: 'none' } });
1128
+ s.addShape('ellipse', { x: rightX - 0.06, y: y2 - 0.06, w: 0.12, h: 0.12, fill: { color: STYLE.accent }, line: { type: 'none' } });
1129
+ s.addText(lb, { x: x, y: y1 - 0.14, w: 0.62, h: 0.28,
1130
+ fontFace: STYLE.font, fontSize: sz(10.5), color: STYLE.body, align: 'right', valign: 'middle' });
1131
+ shapeValue(s, c, M.values[i], x + 0.66, y1 - 0.14, 0.52, 0.28, true);
1132
+ shapeValue(s, c, Number(other[i]) || 0, rightX + 0.1, y2 - 0.14, 0.62, 0.28, false);
1133
+ });
1134
+ }
1135
+ /* 华夫图(waffle):10×N 点阵按占比填充 */
1136
+ function shapeWaffle(s, c, x, y, w, h, dcols) {
1137
+ const M = shapeMetrics(c, w, h);
1138
+ const cols = 10, rows = 10;
1139
+ const cell = Math.min(w / cols, h / rows);
1140
+ const total = M.values.reduce((a, b) => a + b, 0) || 1;
1141
+ const units = 100;
1142
+ let filled = 0;
1143
+ const alloc = M.values.map(v => Math.round(v / total * units));
1144
+ M.labels.forEach((lb, i) => {
1145
+ const dc = dcols ? dcols[i % dcols.length] : (i === 0 ? STYLE.accent : STYLE.faint);
1146
+ for (let k = 0; k < alloc[i] && filled < units; k++, filled++) {
1147
+ const r = Math.floor(filled / cols), cc = filled % cols;
1148
+ s.addShape('roundRect', { x: x + cc * cell, y: y + r * cell, w: cell * 0.82, h: cell * 0.82,
1149
+ rectRadius: 0.02, fill: { color: dc }, line: { type: 'none' } });
1150
+ }
1151
+ });
1152
+ let lx = x;
1153
+ M.labels.forEach((lb, i) => {
1154
+ const dc = dcols ? dcols[i % dcols.length] : (i === 0 ? STYLE.accent : STYLE.faint);
1155
+ s.addShape('ellipse', { x: lx, y: y + h + 0.06, w: 0.1, h: 0.1, fill: { color: dc }, line: { type: 'none' } });
1156
+ s.addText(lb, { x: lx + 0.14, y: y + h + 0.02, w: 0.9, h: 0.24,
1157
+ fontFace: STYLE.font, fontSize: sz(10.5), color: STYLE.body, valign: 'middle' });
1158
+ s.addText(String(M.values[i] != null ? M.values[i] : '') + (c.unit || ''),
1159
+ { x: lx + 1.06, y: y + h + 0.02, w: 0.66, h: 0.24, fontFace: STYLE.font, fontSize: sz(10.5),
1160
+ bold: true, color: STYLE.body, valign: 'middle' });
1161
+ lx += 1.9;
1162
+ });
1163
+ }
1164
+ /* 图表 + 数据表组合块:inline 策略在图表下方附原生小字号表格(压缩图表高度,不越界) */
1165
+ function chartBlock(s, c, x, y, w, h, dcols) {
1166
+ const dm = dataTableMode(c);
1167
+ let chH = h;
1168
+ const hasData = (c.labels || []).length > 0;
1169
+ if (dm === 'inline' && hasData) {
1170
+ const rowsData = dataTableRows(c);
1171
+ const n = rowsData.length;
1172
+ const tH = Math.min(h * 0.44, Math.max(0.44, n * 0.22 + 0.06));
1173
+ chH = Math.max(0.85, h - tH - 0.1);
1174
+ const rowH = Math.max(0.15, (tH - 0.04) / Math.max(1, n));
1175
+ const dense = rowH < 0.26;
1176
+ const fs = sz(dense ? 9 : 10.5);
1177
+ const rows = rowsData.map((r, ri) => r.map(cell => ({
1178
+ text: String(cell),
1179
+ options: {
1180
+ fontSize: fs, fontFace: STYLE.font, valign: 'middle',
1181
+ bold: ri === 0, color: ri === 0 ? STYLE.onAccent : STYLE.body,
1182
+ fill: { color: ri === 0 ? STYLE.accent : STYLE.bg },
1183
+ },
1184
+ })));
1185
+ s.addTable(rows, { x: x, y: y + chH + 0.1, w: w, rowH: rowH, autoPage: false,
1186
+ border: { pt: 0.5, color: STYLE.line }, margin: dense ? 1 : 3 });
1187
+ }
1188
+ if (isNativeChart(c.type)) nativeChart(s, c, x, y, w, chH, dcols);
1189
+ else shapeChart(s, c, x, y, w, chH, dcols);
1190
+ }
1191
+ /* ═══ 复杂信息图页型(形状通道:高保真形状还原 + 数据表可追溯)═══
1192
+ * 复杂曲线 / 流带一律用「多段旋转矩形」沿采样路径逼近(采样点 ≥16,上下边界分别追踪),
1193
+ * 不使用 preset shape(pie / arc / blockArc / chord / moon / wave)替代;
1194
+ * 与 A 通道(assets/pptx-export.js)同规则、文本集合一致(cross_verify 逐页比对)。 */
1195
+ const INFO_MIN_SAMPLES = 16;
1196
+ function sstep(t) { return t * t * (3 - 2 * t); }
1197
+ /* 沿采样路径铺贴多段旋转矩形:pts[i] = [x, yTop, yBottom](双边界分别追踪) */
1198
+ function ribbonRects(s, pts, color) {
1199
+ for (let i = 0; i < pts.length - 1; i++) {
1200
+ const a = pts[i], b = pts[i + 1];
1201
+ const ca = (a[1] + a[2]) / 2, cb = (b[1] + b[2]) / 2;
1202
+ const dx = b[0] - a[0], dy = cb - ca;
1203
+ const len = Math.sqrt(dx * dx + dy * dy);
1204
+ const th = Math.max(0.02, ((a[2] - a[1]) + (b[2] - b[1])) / 2);
1205
+ const ang = Math.atan2(dy, dx) * 180 / Math.PI;
1206
+ s.addShape('rect', { x: (a[0] + b[0]) / 2 - len / 2, y: (ca + cb) / 2 - th / 2,
1207
+ w: Math.max(0.02, len), h: th, rotate: Math.round(ang * 10) / 10,
1208
+ fill: { color: color }, line: { type: 'none' } });
1209
+ }
1210
+ }
1211
+ /* 信息图页型的数据表策略与行(默认 notes:数据入演讲者备注;inline:页内附原生表格) */
1212
+ function infoDataTableMode(sec) {
1213
+ const m = String(((sec.chart || {}).dataTable) || 'notes').toLowerCase();
1214
+ return m === 'appendix' ? 'inline' : m;
1215
+ }
1216
+ function infoTableRows(sec) {
1217
+ const t = sec.type || '';
1218
+ const u = sec.unit ? '(' + sec.unit + ')' : '';
1219
+ if (t === 'sankey') return [['源', '汇', '流量' + u]].concat((sec.flows || []).map(f => [String(f[0]), String(f[1]), String(f[2])]));
1220
+ if (t === 'treemap') return [['项目', '数值' + u]].concat((sec.items || []).map(it => [String(it[0]), String(it[1])]));
1221
+ if (t === 'boxplot') return [['分组', '最小', 'Q1', '中位', 'Q3', '最大']].concat((sec.groups || []).map(g => g.map(String)));
1222
+ if (t === 'network') return [['节点', '名称']].concat((sec.nodes || []).map(n => [String(n[0]), String(n[1])]));
1223
+ if (t === 'marimekko') {
1224
+ const head = ['列', '总量' + u].concat((sec.legend || []).map(String));
1225
+ return [head].concat((sec.cols || []).map((c, i) => [String(c[0]), String(c[1])].concat(((sec.cells || [])[i] || []).map(String))));
1226
+ }
1227
+ if (t === 'streamgraph') {
1228
+ return [['系列'].concat((sec.labels || []).map(String))]
1229
+ .concat((sec.series || []).map(se => [String(se.name || '')].concat((se.values || []).map(String))));
1230
+ }
1231
+ return null;
1232
+ }
1233
+ function infoTable(s, rows, x, y, w, h) {
1234
+ const n = rows.length;
1235
+ const rowH = Math.max(0.15, Math.min(0.26, h / Math.max(1, n)));
1236
+ const dense = rowH < 0.24;
1237
+ const fs = sz(dense ? 9 : 10.5);
1238
+ const tbl = rows.map((r, ri) => r.map(cell => ({ text: String(cell), options: {
1239
+ fontSize: fs, fontFace: STYLE.font, valign: 'middle', bold: ri === 0,
1240
+ color: ri === 0 ? STYLE.onAccent : STYLE.body,
1241
+ fill: { color: ri === 0 ? STYLE.accent : STYLE.bg } } })));
1242
+ s.addTable(tbl, { x: x, y: y, w: w, rowH: rowH, autoPage: false,
1243
+ border: { pt: 0.5, color: STYLE.line }, margin: dense ? 1 : 3 });
1244
+ }
1245
+ /* 信息图页型的公共外壳:算绘图区(inline 数据表占位则压缩绘图区) */
1246
+ function infoPlot(sec, y0, y1) {
1247
+ const rows = infoTableRows(sec);
1248
+ const inline = (infoDataTableMode(sec) === 'inline') && rows && rows.length;
1249
+ if (!inline) return { y0: y0, y1: y1, rows: rows };
1250
+ const tH = Math.min((y1 - y0) * 0.4, Math.max(0.44, rows.length * 0.22 + 0.06));
1251
+ return { y0: y0, y1: y1 - tH - 0.1, rows: rows, table: { y: y1 - tH, h: tH } };
1252
+ }
1253
+ /* 桑基图:分层节点 + 流带(流量线性编码;流带上下边界分别采样,采样点 ≥16) */
1254
+ function infoSankey(s, sec, y0, y1) {
1255
+ const G = PT.sankey;
1256
+ const P = infoPlot(sec, y0, y1);
1257
+ if (P.table) infoTable(s, P.rows, MX, P.table.y, CW, P.table.h);
1258
+ const flows = (sec.flows || []).filter(f => f && f.length >= 3);
1259
+ const nodes = [], idx = {};
1260
+ flows.forEach(f => [f[0], f[1]].forEach(n => { if (idx[n] == null) { idx[n] = nodes.length; nodes.push(n); } }));
1261
+ if (!nodes.length) return;
1262
+ const level = nodes.map(() => 0);
1263
+ for (let iter = 0; iter < nodes.length + 1; iter++) {
1264
+ let ch = false;
1265
+ flows.forEach(f => { const a = idx[f[0]], b = idx[f[1]]; if (level[b] < level[a] + 1) { level[b] = level[a] + 1; ch = true; } });
1266
+ if (!ch) break;
1267
+ }
1268
+ const maxLv = Math.max.apply(null, level.concat([0]));
1269
+ const outSum = {}, inSum = {};
1270
+ flows.forEach(f => { outSum[f[0]] = (outSum[f[0]] || 0) + Number(f[2]); inSum[f[1]] = (inSum[f[1]] || 0) + Number(f[2]); });
1271
+ const thr = (n) => Math.max(inSum[n] || 0, outSum[n] || 0);
1272
+ const totalFlow = flows.reduce((a, f) => a + Number(f[2]), 0) || 1;
1273
+ const plotH = P.y1 - P.y0;
1274
+ const colW = CW / (maxLv + 1);
1275
+ const geo = {};
1276
+ for (let l = 0; l <= maxLv; l++) {
1277
+ const lv = nodes.filter((n, i) => level[i] === l);
1278
+ const sum = lv.reduce((a, n) => a + thr(n), 0) || 1;
1279
+ const availH = plotH - G.nodeGap * Math.max(0, lv.length - 1);
1280
+ let cy = P.y0;
1281
+ lv.forEach(n => {
1282
+ const h = Math.max(G.minBandH, thr(n) / sum * availH);
1283
+ geo[n] = { x: MX + l * colW + colW * 0.5 - G.nodeW / 2, y: cy, h: h, lv: l };
1284
+ cy += h + G.nodeGap;
1285
+ });
1286
+ }
1287
+ const used = {};
1288
+ flows.forEach(f => {
1289
+ const a = geo[f[0]], b = geo[f[1]];
1290
+ if (!a || !b) return;
1291
+ const v = Number(f[2]) || 0;
1292
+ const w = Math.max(0.03, v / totalFlow * plotH * 0.55);
1293
+ used[f[0]] = (used[f[0]] || 0) + w; used[f[1]] = (used[f[1]] || 0) + w;
1294
+ const yA = a.y + used[f[0]] - w / 2, yB = b.y + used[f[1]] - w / 2;
1295
+ const x0 = a.x + G.nodeW, x1 = b.x;
1296
+ const n = Math.max(INFO_MIN_SAMPLES, Math.round((x1 - x0) / 0.1));
1297
+ const pts = [];
1298
+ for (let i = 0; i <= n; i++) {
1299
+ const t = i / n, e = sstep(t);
1300
+ const x = x0 + (x1 - x0) * t;
1301
+ const cyy = yA + (yB - yA) * e;
1302
+ pts.push([x, cyy - w / 2, cyy + w / 2]);
1303
+ }
1304
+ ribbonRects(s, pts, STYLE.soft);
1305
+ s.addText(String(v) + (sec.unit || ''), { x: (x0 + x1) / 2 - 0.5, y: (yA + yB) / 2 - 0.14, w: 1.0, h: 0.28,
1306
+ align: 'center', fontFace: STYLE.font, fontSize: sz(9.5), color: STYLE.faint });
1307
+ });
1308
+ nodes.forEach(n => {
1309
+ const g = geo[n];
1310
+ s.addShape('rect', { x: g.x, y: g.y, w: G.nodeW, h: g.h, fill: { color: STYLE.accent }, line: { type: 'none' } });
1311
+ const lw = Math.min(G.labelW, colW * 0.6);
1312
+ const last = (g.lv === maxLv);
1313
+ s.addText(n, { x: last ? (g.x - lw - 0.06) : (g.x + G.nodeW + 0.06), y: g.y, w: lw,
1314
+ h: Math.max(0.24, g.h), align: last ? 'right' : 'left', valign: 'middle',
1315
+ fontFace: STYLE.font, fontSize: sz(10.5), bold: true, color: STYLE.body });
1316
+ });
1317
+ }
1318
+ /* 树图:按面积编码的矩形切分(递归二分,面积严格成比例) */
1319
+ function tmSplit(items, x, y, w, h, out) {
1320
+ if (!items.length) return;
1321
+ if (items.length === 1) { out.push({ it: items[0], x: x, y: y, w: w, h: h }); return; }
1322
+ const total = items.reduce((a, i) => a + i.v, 0) || 1;
1323
+ let acc = 0, k = 0;
1324
+ for (; k < items.length - 1; k++) { acc += items[k].v; if (acc >= total / 2) break; }
1325
+ const first = items.slice(0, k + 1), second = items.slice(k + 1);
1326
+ const r = first.reduce((a, i) => a + i.v, 0) / total;
1327
+ if (w >= h) {
1328
+ tmSplit(first, x, y, w * r, h, out);
1329
+ tmSplit(second, x + w * r, y, w * (1 - r), h, out);
1330
+ } else {
1331
+ tmSplit(first, x, y, w, h * r, out);
1332
+ tmSplit(second, x, y + h * r, w, h * (1 - r), out);
1333
+ }
1334
+ }
1335
+ function infoTreemap(s, sec, y0, y1) {
1336
+ const G = PT.treemap;
1337
+ const P = infoPlot(sec, y0, y1);
1338
+ if (P.table) infoTable(s, P.rows, MX, P.table.y, CW, P.table.h);
1339
+ const items = (sec.items || []).map(it => ({ label: String(it[0]), v: Math.max(0, Number(it[1]) || 0) }))
1340
+ .filter(i => i.v > 0).sort((a, b) => b.v - a.v).slice(0, G.maxLeaves);
1341
+ if (!items.length) return;
1342
+ const out = [];
1343
+ tmSplit(items, MX, P.y0, CW, P.y1 - P.y0, out);
1344
+ const maxV = items[0].v || 1;
1345
+ out.forEach(o => {
1346
+ const p = o.it.v / maxV;
1347
+ const lvl = p >= 0.66 ? 3 : (p >= 0.33 ? 2 : 1);
1348
+ const fill = [STYLE.surface, STYLE.soft, STYLE.line, STYLE.accent][lvl];
1349
+ const onAcc = lvl >= 3;
1350
+ s.addShape('rect', { x: o.x + G.gap / 2, y: o.y + G.gap / 2,
1351
+ w: Math.max(0.05, o.w - G.gap), h: Math.max(0.05, o.h - G.gap),
1352
+ fill: { color: fill }, line: { color: STYLE.bg, width: 0.75 } });
1353
+ if (o.h >= G.labelMinH) {
1354
+ s.addText(o.it.label, { x: o.x + 0.1, y: o.y + 0.06, w: Math.max(0.2, o.w - 0.2), h: 0.3,
1355
+ fontFace: STYLE.font, fontSize: sz(10.5), bold: true, color: onAcc ? STYLE.onAccent : STYLE.ink });
1356
+ s.addText(String(o.it.v) + (sec.unit || ''), { x: o.x + 0.1, y: o.y + 0.34, w: Math.max(0.2, o.w - 0.2), h: 0.28,
1357
+ fontFace: STYLE.font, fontSize: sz(10), color: onAcc ? STYLE.onAccent : STYLE.body });
1358
+ }
1359
+ });
1360
+ }
1361
+ /* 箱线图:分组箱体(min/q1/median/q3/max)+ 须线端帽 */
1362
+ function infoBoxplot(s, sec, y0, y1) {
1363
+ const G = PT.boxplot;
1364
+ const P = infoPlot(sec, y0, y1);
1365
+ if (P.table) infoTable(s, P.rows, MX, P.table.y, CW, P.table.h);
1366
+ const groups = (sec.groups || []).filter(g => g && g.length >= 6).slice(0, G.maxGroups);
1367
+ if (!groups.length) return;
1368
+ const nums = groups.map(g => g.slice(1, 6).map(Number));
1369
+ const lo = Math.min.apply(null, nums.map(n => n[0]));
1370
+ const hi = Math.max.apply(null, nums.map(n => n[4]));
1371
+ const span = (hi - lo) || 1;
1372
+ const plotH = P.y1 - P.y0 - 0.5;
1373
+ const yFor = (v) => P.y0 + (1 - (v - lo) / span) * plotH;
1374
+ const step = CW / groups.length;
1375
+ const bw = Math.min(G.boxMaxW, step * 0.5);
1376
+ s.addShape('rect', { x: MX, y: P.y0 + plotH, w: CW, h: 0.015, fill: { color: STYLE.line }, line: { type: 'none' } });
1377
+ groups.forEach((g, i) => {
1378
+ const mn = Number(g[1]), q1 = Number(g[2]), md = Number(g[3]), q3 = Number(g[4]), mx = Number(g[5]);
1379
+ const cx = MX + step * i + step / 2;
1380
+ s.addShape('rect', { x: cx - 0.012, y: yFor(mx), w: 0.024, h: Math.max(0.02, yFor(mn) - yFor(mx)),
1381
+ fill: { color: STYLE.faint }, line: { type: 'none' } });
1382
+ s.addShape('rect', { x: cx - G.whiskerCapW / 2, y: yFor(mx), w: G.whiskerCapW, h: 0.022, fill: { color: STYLE.faint }, line: { type: 'none' } });
1383
+ s.addShape('rect', { x: cx - G.whiskerCapW / 2, y: yFor(mn) - 0.022, w: G.whiskerCapW, h: 0.022, fill: { color: STYLE.faint }, line: { type: 'none' } });
1384
+ s.addShape('rect', { x: cx - bw / 2, y: yFor(q3), w: bw, h: Math.max(0.05, yFor(q1) - yFor(q3)),
1385
+ fill: { color: STYLE.soft }, line: { color: STYLE.accent, width: 1 } });
1386
+ s.addShape('rect', { x: cx - bw / 2, y: yFor(md) - 0.015, w: bw, h: 0.03, fill: { color: STYLE.accent }, line: { type: 'none' } });
1387
+ s.addText(String(g[0]), { x: cx - step / 2, y: P.y1 - 0.42, w: step, h: 0.3, align: 'center',
1388
+ fontFace: STYLE.font, fontSize: sz(10.5), color: STYLE.body });
1389
+ s.addText(String(md) + (sec.unit || ''), { x: cx - step / 2, y: yFor(md) - 0.42, w: step, h: 0.26, align: 'center',
1390
+ fontFace: STYLE.font, fontSize: sz(10), bold: true, color: STYLE.accent });
1391
+ s.addText(String(mn) + (sec.unit || ''), { x: cx - step / 2, y: yFor(mn) + 0.03, w: step, h: 0.24, align: 'center',
1392
+ fontFace: STYLE.font, fontSize: sz(9.5), color: STYLE.faint });
1393
+ s.addText(String(mx) + (sec.unit || ''), { x: cx - step / 2, y: yFor(mx) - 0.28, w: step, h: 0.24, align: 'center',
1394
+ fontFace: STYLE.font, fontSize: sz(9.5), color: STYLE.faint });
1395
+ });
1396
+ }
1397
+ /* 关系网络:确定性环形布局(同环按输入顺序)+ 直连边 */
1398
+ function infoNetwork(s, sec, y0, y1) {
1399
+ const G = PT.network;
1400
+ const P = infoPlot(sec, y0, y1);
1401
+ if (P.table) infoTable(s, P.rows, MX, P.table.y, CW, P.table.h);
1402
+ const nodes = (sec.nodes || []).slice(0, G.maxNodes);
1403
+ if (!nodes.length) return;
1404
+ const edges = (sec.edges || []).slice(0, G.maxEdges);
1405
+ const n = nodes.length;
1406
+ const cx = MX + CW / 2, cy = (P.y0 + P.y1) / 2;
1407
+ const r = Math.min(CW / 2, (P.y1 - P.y0) / 2) - G.labelMaxW * 0.55;
1408
+ const pos = {};
1409
+ nodes.forEach((nd, i) => {
1410
+ const a = -Math.PI / 2 + i * 2 * Math.PI / n;
1411
+ pos[String(nd[0])] = { x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) };
1412
+ });
1413
+ edges.forEach(e => {
1414
+ const a = pos[String(e[0])], b = pos[String(e[1])];
1415
+ if (!a || !b) return;
1416
+ const dx = b.x - a.x, dy = b.y - a.y;
1417
+ const len = Math.sqrt(dx * dx + dy * dy);
1418
+ const ang = Math.atan2(dy, dx) * 180 / Math.PI;
1419
+ s.addShape('rect', { x: (a.x + b.x) / 2 - len / 2, y: (a.y + b.y) / 2 - 0.008, w: len, h: 0.016,
1420
+ rotate: Math.round(ang * 10) / 10, fill: { color: STYLE.line }, line: { type: 'none' } });
1421
+ });
1422
+ nodes.forEach((nd, i) => {
1423
+ const p = pos[String(nd[0])];
1424
+ const deg = edges.filter(e => String(e[0]) === String(nd[0]) || String(e[1]) === String(nd[0])).length;
1425
+ const rr = Math.max(G.nodeRMin, Math.min(G.nodeR, G.nodeRMin + deg * 0.03));
1426
+ s.addShape('ellipse', { x: p.x - rr, y: p.y - rr, w: rr * 2, h: rr * 2,
1427
+ fill: { color: deg ? STYLE.accent : STYLE.faint }, line: { color: STYLE.bg, width: 1 } });
1428
+ const outward = p.x >= cx;
1429
+ s.addText(String(nd[1] != null ? nd[1] : nd[0]),
1430
+ { x: outward ? p.x + rr + 0.05 : p.x - rr - 0.05 - G.labelMaxW, y: p.y - 0.14, w: G.labelMaxW, h: 0.28,
1431
+ align: outward ? 'left' : 'right', valign: 'middle', fontFace: STYLE.font, fontSize: sz(10), color: STYLE.body });
1432
+ });
1433
+ }
1434
+ /* 马赛克图:列宽按列总量、列高按 100% 构成的双重编码 */
1435
+ function infoMarimekko(s, sec, y0, y1) {
1436
+ const G = PT.marimekko;
1437
+ const P = infoPlot(sec, y0, y1);
1438
+ if (P.table) infoTable(s, P.rows, MX, P.table.y, CW, P.table.h);
1439
+ const cols = (sec.cols || []).slice(0, G.maxCols);
1440
+ if (!cols.length) return;
1441
+ const legend = (sec.legend || []).map(String);
1442
+ const cells = sec.cells || [];
1443
+ const total = cols.reduce((a, c) => a + (Number(c[1]) || 0), 0) || 1;
1444
+ const plotH = (P.y1 - P.y0) - G.legendH - 0.1;
1445
+ const availW = CW - G.colGap * Math.max(0, cols.length - 1);
1446
+ const palette = [STYLE.accent, STYLE.faint, STYLE.body, STYLE.line];
1447
+ let cx0 = MX;
1448
+ cols.forEach((c, i) => {
1449
+ const cw = Math.max(0.2, (Number(c[1]) || 0) / total * availW);
1450
+ const shares = (cells[i] || []).map(Number).slice(0, G.maxSegs);
1451
+ const sum = shares.reduce((a, b) => a + b, 0) || 1;
1452
+ let cy0 = P.y0;
1453
+ shares.forEach((v, k) => {
1454
+ const sh = Math.max(0.02, v / sum * plotH);
1455
+ s.addShape('rect', { x: cx0, y: cy0, w: cw, h: sh,
1456
+ fill: { color: palette[k % palette.length] }, line: { color: STYLE.bg, width: 0.75 } });
1457
+ s.addText(String(v) + (sec.unit || ''), { x: cx0, y: cy0 + sh / 2 - 0.13, w: cw, h: 0.26, align: 'center',
1458
+ fontFace: STYLE.font, fontSize: sz(10), bold: true,
1459
+ color: k === 0 ? STYLE.onAccent : STYLE.ink });
1460
+ cy0 += sh;
1461
+ });
1462
+ s.addText(String(c[0]), { x: cx0, y: P.y0 + plotH + 0.06, w: cw, h: 0.3, align: 'center',
1463
+ fontFace: STYLE.font, fontSize: sz(10.5), bold: true, color: STYLE.body });
1464
+ cx0 += cw + G.colGap;
1465
+ });
1466
+ let lx = MX;
1467
+ legend.forEach((lg, k) => {
1468
+ s.addShape('rect', { x: lx, y: P.y1 - 0.2, w: 0.12, h: 0.12,
1469
+ fill: { color: palette[k % palette.length] }, line: { type: 'none' } });
1470
+ s.addText(lg, { x: lx + 0.16, y: P.y1 - 0.26, w: 1.5, h: 0.26,
1471
+ fontFace: STYLE.font, fontSize: sz(10), color: STYLE.body, valign: 'middle' });
1472
+ lx += 1.75;
1473
+ });
1474
+ }
1475
+ /* 流带图:基线居中的堆叠平滑带(带边界上下分别采样,采样点 ≥24) */
1476
+ function infoStreamgraph(s, sec, y0, y1) {
1477
+ const G = PT.streamgraph;
1478
+ const P = infoPlot(sec, y0, y1);
1479
+ if (P.table) infoTable(s, P.rows, MX, P.table.y, CW, P.table.h);
1480
+ const series = (sec.series || []).slice(0, G.maxSeries);
1481
+ if (!series.length) return;
1482
+ const labels = sec.labels || [];
1483
+ const nPts = labels.length || ((series[0].values || []).length);
1484
+ if (nPts < 2) return;
1485
+ const values = series.map(se => (se.values || []).map(v => Math.max(0, Number(v) || 0)));
1486
+ const totals = [];
1487
+ for (let i = 0; i < nPts; i++) {
1488
+ let t = 0;
1489
+ values.forEach(v => { t += (v[i] || 0); });
1490
+ totals.push(t);
1491
+ }
1492
+ const maxTot = Math.max.apply(null, totals.concat([1]));
1493
+ const plotH = P.y1 - P.y0;
1494
+ const cyMid = (P.y0 + P.y1) / 2;
1495
+ const xAt = (i) => MX + (nPts > 1 ? i / (nPts - 1) : 0.5) * CW;
1496
+ const yAt = (i) => cyMid - (totals[i] / maxTot) * plotH / 2;
1497
+ const palette = [STYLE.accent, STYLE.faint, STYLE.body, STYLE.line, STYLE.soft, STYLE.surface];
1498
+ const n = Math.max(24, Math.round(CW / 0.1));
1499
+ let base = [];
1500
+ for (let i = 0; i < nPts; i++) base.push(yAt(i));
1501
+ series.forEach((se, si) => {
1502
+ const upper = [];
1503
+ for (let i = 0; i < nPts; i++) upper.push(base[i] + (values[si][i] || 0) / maxTot * plotH / 2);
1504
+ const pts = [];
1505
+ for (let k = 0; k <= n; k++) {
1506
+ const t = k / n * (nPts - 1);
1507
+ const i0 = Math.min(nPts - 2, Math.floor(t)), f = t - i0;
1508
+ const x = MX + (k / n) * CW;
1509
+ const bt = base[i0] + (base[i0 + 1] - base[i0]) * sstep(f);
1510
+ const ut = upper[i0] + (upper[i0 + 1] - upper[i0]) * sstep(f);
1511
+ pts.push([x, ut, bt]);
1512
+ }
1513
+ ribbonRects(s, pts, palette[si % palette.length]);
1514
+ const lx = MX;
1515
+ s.addShape('rect', { x: lx, y: P.y1 + 0.06 + si * 0.26, w: 0.12, h: 0.12,
1516
+ fill: { color: palette[si % palette.length] }, line: { type: 'none' } });
1517
+ s.addText(String(se.name || ('系列' + (si + 1))), { x: lx + 0.16, y: P.y1 + si * 0.26, w: 1.5, h: 0.26,
1518
+ fontFace: STYLE.font, fontSize: sz(10), color: STYLE.body, valign: 'middle' });
1519
+ base = upper;
1520
+ });
1521
+ labels.forEach((lb, i) => {
1522
+ s.addText(String(lb), { x: xAt(i) - 0.4, y: P.y1 - 0.02, w: 0.8, h: 0.24, align: 'center',
1523
+ fontFace: STYLE.font, fontSize: sz(9.5), color: STYLE.faint });
1524
+ });
1525
+ }
1526
+ /* 表格:行高按可用高度自适应(research 密表 ≤16 行不再溢出) */
1527
+ function addTable(s, tbl, x, y, w, availH, opts) {
1528
+ opts = opts || {};
1529
+ const rowsIn = tbl.rows || [];
1530
+ const head = tbl.head || [];
1531
+ const n = rowsIn.length + 1;
1532
+ const maxRowH = opts.maxRowH != null ? opts.maxRowH : PT.table.rowH;
1533
+ const minRowH = opts.minRowH != null ? opts.minRowH : PT.table.rowHMin;
1534
+ const rowH = Math.max(minRowH, Math.min(maxRowH, availH / Math.max(1, n)));
1535
+ /* 行多则同步收字号(research 密排允许更小) */
1536
+ const dense = rowH < 0.42;
1537
+ const headSz = sz(dense ? 11 : 13), bodySz = sz(dense ? 10 : 12.5);
1538
+ const rows = [head.map(h => ({ text: h, options: { bold: true, color: STYLE.onAccent,
1539
+ fill: { color: STYLE.accent }, fontSize: headSz, fontFace: STYLE.font, valign: 'middle' } }))]
1540
+ .concat(rowsIn.map(r => r.map(c => ({ text: String(c), options: { fontSize: bodySz,
1541
+ color: STYLE.body, fontFace: STYLE.font, valign: 'middle', fill: { color: STYLE.bg } } }))));
1542
+ s.addTable(rows, { x, y, w, colW: tbl.colW, border: { pt: 0.75, color: STYLE.line },
1543
+ rowH: rowH, autoPage: false, margin: dense ? 2 : 5 });
1544
+ return n * rowH;
1545
+ }
1546
+
1547
+ /* 封面 */
1548
+ (function cover() {
1549
+ const s = base();
1550
+ const C = PT.cover;
1551
+ s.addShape('rect', { x: 0, y: 0, w: PW, h: PH, fill: { color: STYLE.accent }, line: { type: 'none' } });
1552
+ s.addText(CONTENT.meta || '', { x: MX, y: C.metaY, w: CW, h: 0.4, fontFace: STYLE.font,
1553
+ fontSize: sz(12), color: STYLE.soft });
1554
+ const tSize = estTextH(CONTENT.title, CW - 1, sz(44), 1.25) > C.titleH ? sz(36) : sz(44);
1555
+ s.addText(CONTENT.title, { x: MX, y: C.titleY, w: CW - 1, h: C.titleH, fontFace: STYLE.fontDisplay,
1556
+ fontSize: tSize, bold: true, color: STYLE.onAccent });
1557
+ s.addText(CONTENT.subtitle || '', { x: MX, y: C.subtitleY, w: CW - 1, h: C.subtitleH,
1558
+ fontFace: STYLE.font, fontSize: sz(18), color: STYLE.soft });
1559
+ s.addNotes([CONTENT.subtitle, CONTENT.meta].filter(Boolean).join('\n'));
1560
+ })();
1561
+
1562
+ /* Agenda(architecture 极简形态可省略 agenda → 不产出大纲页;>8 条自动双列;行高自适应防溢出) */
1563
+ (function agenda() {
1564
+ if (!Array.isArray(CONTENT.agenda) || !CONTENT.agenda.length) return;
1565
+ const s = base();
1566
+ head(s, 'AGENDA', '报告大纲');
1567
+ const items = CONTENT.agenda;
1568
+ const colN = items.length > 8 ? 2 : 1;
1569
+ const perCol = Math.ceil(items.length / colN);
1570
+ const availH = CONTENT_BOTTOM - CONTENT_TOP;
1571
+ const rowH = Math.min(1.05, availH / perCol);
1572
+ const numSize = rowH >= 0.85 ? sz(30) : (rowH >= 0.65 ? sz(22) : sz(18));
1573
+ const tSize = rowH >= 0.85 ? sz(16) : sz(14);
1574
+ const dSize = rowH >= 0.85 ? sz(12) : sz(11);
1575
+ items.forEach((it, i) => {
1576
+ const col = Math.floor(i / perCol), row = i % perCol;
1577
+ const x = MX + col * (CW / colN), y = CONTENT_TOP + row * rowH;
1578
+ s.addText(it[0], { x, y, w: 0.9, h: rowH - 0.05, fontFace: STYLE.font, fontSize: numSize,
1579
+ bold: true, color: STYLE.accent, valign: 'top' });
1580
+ s.addText([
1581
+ { text: it[1] + '\n', options: { fontSize: tSize, bold: true, color: STYLE.ink } },
1582
+ { text: it[2] || '', options: { fontSize: dSize, color: STYLE.body } },
1583
+ ], { x: x + 1.0, y: y + 0.03, w: CW / colN - 1.15, h: rowH - 0.08, fontFace: STYLE.font,
1584
+ valign: 'top', lineSpacing: tSize + 5 });
1585
+ });
1586
+ })();
1587
+
1588
+ /* 章节页 · 按页型分发 */
1589
+ CONTENT.sections.forEach((sec) => {
1590
+ const type = sec.type || 'points';
1591
+ // Batch 2: honor layoutPreset (scaffold/recommend_layout → PPTX IR alignment)
1592
+ if (!sec.layoutPreset) {
1593
+ const _p2p = (LC.layoutSystem && LC.layoutSystem.pageToPreset) || {};
1594
+ if (_p2p[type]) sec.layoutPreset = _p2p[type];
1595
+ }
1596
+ const s = base();
1597
+ if (type !== 'quote') head(s, sec.eyebrow, sec.title, sec.lead);
1598
+
1599
+ /* 正文区起点 / 终点(含 lead 与 so-what / 来源行 / 待核实条占位) */
1600
+ /* 非 exhibit 页型也可带 Exhibit 编号(帧头徽标;正文区下移让位,避免与主图重叠) */
1601
+ const exBadge = (type !== 'exhibit' && sec.exhibitNo) ? String(sec.exhibitNo).toUpperCase() : null;
1602
+ const bodyY0 = sec.lead ? PT.common.bodyYWithLead : CONTENT_TOP;
1603
+ const bodyY = exBadge ? bodyY0 + 0.42 : bodyY0;
1604
+ if (exBadge) {
1605
+ s.addText(exBadge, { x: MX, y: CONTENT_TOP - 0.06, w: CW, h: 0.3, fontFace: STYLE.font,
1606
+ fontSize: sz(11.5), bold: true, color: STYLE.accent, charSpacing: 2 });
1607
+ }
1608
+ const flagItems = Array.isArray(sec.flags) ? sec.flags.filter(Boolean) : [];
1609
+ const _F = PT.flagbar || { hdH: 0.26, rowH: 0.24, maxRows: 3, gap: 0.12 };
1610
+ const flagH = flagItems.length
1611
+ ? (_F.hdH + Math.min(_F.maxRows, flagItems.length) * _F.rowH + 0.06) : 0;
1612
+ let bodyBottom = chartBottom(!!sec.soWhat, !!sec.footnote);
1613
+ let flagY = 0;
1614
+ if (flagH) {
1615
+ /* 待核实条底对齐 contentBottom;与来源行 / so-what 同页时上移让位(不重叠) */
1616
+ flagY = CONTENT_BOTTOM - flagH;
1617
+ if (sec.footnote) flagY = Math.min(flagY, PT.exhibit.footnoteY - flagH - 0.06);
1618
+ if (sec.soWhat) flagY = Math.min(flagY, PT.exhibit.soWhatY - flagH - 0.06);
1619
+ bodyBottom = Math.min(bodyBottom, flagY - (_F.gap || 0.12));
1620
+ }
1621
+
1622
+ if (type === 'metrics') {
1623
+ const M = PT.metrics;
1624
+ const reg = REGIONS.regionOf('metrics', 'primary') ||
1625
+ { x: MX, y: M.bandY, w: CW, h: M.cardH };
1626
+ const ms = sec.metrics || [];
1627
+ const n = Math.max(1, ms.length);
1628
+ const g = cols(reg.w, n, M.gap);
1629
+ const mw = Math.min(M.maxW, g.w);
1630
+ const gx = reg.x + (reg.w - n * mw - (n - 1) * M.gap) / 2;
1631
+ const cardH = Math.min(reg.h, CONTENT_BOTTOM - reg.y);
1632
+ ms.forEach((m, i) => {
1633
+ const x = gx + i * (mw + M.gap);
1634
+ s.addShape('roundRect', { x, y: reg.y, w: mw, h: cardH, rectRadius: 0.09,
1635
+ fill: { color: STYLE.surface }, line: { color: STYLE.line, width: 0.75 } });
1636
+ const vSize = estTextH(m[0], mw - 0.2, sz(40), 1.1) > cardH * 0.5 ? sz(30) : sz(40);
1637
+ s.addText(m[0], { x, y: M.valY, w: mw, h: M.valH, align: 'center', fontFace: STYLE.fontDisplay,
1638
+ fontSize: vSize, bold: true, color: STYLE.accent });
1639
+ s.addText(m[1], { x: x + 0.15, y: M.capY, w: mw - 0.3, h: M.capH, align: 'center',
1640
+ fontFace: STYLE.font, fontSize: sz(11.5), color: STYLE.body });
1641
+ });
1642
+ } else if (type === 'kpi') {
1643
+ const K = PT.kpi;
1644
+ const hreg = REGIONS.regionOf('kpi', 'hero') ||
1645
+ { x: MX, y: K.heroY, w: CW * K.heroW, h: K.heroH };
1646
+ const hro = sec.hero || [];
1647
+ s.addText(String(hro[0] || ''), { x: hreg.x, y: hreg.y, w: hreg.w, h: hreg.h,
1648
+ fontFace: STYLE.fontDisplay, fontSize: sz(64), bold: true, color: STYLE.accent });
1649
+ s.addText(String(hro[1] || ''), { x: hreg.x + 0.05, y: hreg.y + 1.5, w: hreg.w - 0.4, h: 0.55,
1650
+ fontFace: STYLE.font, fontSize: sz(15), bold: true, color: STYLE.ink });
1651
+ if (hro[2]) s.addText('▲ ' + hro[2], { x: hreg.x + 0.05, y: hreg.y + 2.1, w: hreg.w - 0.4, h: 0.4,
1652
+ fontFace: STYLE.font, fontSize: sz(12), bold: true, color: STYLE.accent });
1653
+ const kdivX = MX + CW * K.dividerX;
1654
+ s.addShape('rect', { x: kdivX, y: K.heroY + 0.15, w: 0.025, h: 3.1,
1655
+ fill: { color: STYLE.line }, line: { type: 'none' } });
1656
+ /* 支撑指标行:按可用高度收敛行高并截断,保证不越过 contentBottom */
1657
+ const mreg = REGIONS.regionOf('kpi', 'metrics') ||
1658
+ { x: kdivX + 0.35, y: K.metricY0, w: CW * (1 - K.dividerX) - 0.5, h: CONTENT_BOTTOM - K.metricY0 };
1659
+ const kmets = sec.metrics || [];
1660
+ const kCap = Math.max(1, Math.floor((CONTENT_BOTTOM - mreg.y) / K.metricRowH));
1661
+ const kRows = Math.min(kmets.length, kCap);
1662
+ const kRowH = fitRowH(CONTENT_BOTTOM - mreg.y, kRows, K.metricRowH, 0.42);
1663
+ kmets.slice(0, kRows).forEach((m, i) => {
1664
+ const ky = mreg.y + i * kRowH;
1665
+ s.addText(m[0], { x: mreg.x, y: ky, w: mreg.w, h: Math.min(0.55, kRowH * 0.62),
1666
+ fontFace: STYLE.fontDisplay, fontSize: sz(22), bold: true, color: STYLE.accent });
1667
+ s.addText(m[1], { x: mreg.x, y: ky + Math.min(0.55, kRowH * 0.62), w: mreg.w,
1668
+ h: Math.max(0.24, kRowH - Math.min(0.55, kRowH * 0.62)), fontFace: STYLE.font,
1669
+ fontSize: sz(10.5), color: STYLE.faint });
1670
+ });
1671
+ } else if (type === 'comparison') {
1672
+ const C2 = PT.comparison;
1673
+ const lreg = REGIONS.regionOf('comparison', 'left') ||
1674
+ { x: MX, y: C2.panelY, w: (CW - C2.panelGap) / 2, h: C2.panelH };
1675
+ const rreg = REGIONS.regionOf('comparison', 'right') ||
1676
+ { x: MX + (CW + C2.panelGap) / 2, y: C2.panelY, w: (CW - C2.panelGap) / 2, h: C2.panelH };
1677
+ const cw2 = lreg.w;
1678
+ const cpy = lreg.y, cph = lreg.h;
1679
+ const rx2 = rreg.x;
1680
+ s.addShape('roundRect', { x: lreg.x, y: cpy, w: cw2, h: cph, rectRadius: 0.09,
1681
+ fill: { color: STYLE.surface }, line: { color: STYLE.line, width: 0.75 } });
1682
+ s.addShape('roundRect', { x: rx2, y: cpy, w: cw2, h: cph, rectRadius: 0.09,
1683
+ fill: { color: STYLE.soft }, line: { type: 'none' } });
1684
+ [{ x: lreg.x, d: sec.left || {} }, { x: rx2, d: sec.right || {} }].forEach((sd) => {
1685
+ const isRight = sd.x === rx2;
1686
+ const pts = sd.d.points || [];
1687
+ /* 行高按面板可用高度自适应 */
1688
+ const avail = cph - C2.titleH - 0.34;
1689
+ const rowH = fitRowH(avail, pts.length, C2.rowH, 0.34);
1690
+ const fzBase = rowH < 0.5 ? 11.5 : 13; /* 基准 pt(presentation 比例尺) */
1691
+ const fz = sz(fzBase), fzSm = sz(fzBase - 1); /* 各映射一次,避免二次缩放 */
1692
+ s.addText(sd.d.title || '', { x: sd.x + 0.22, y: cpy + 0.18, w: cw2 - 0.44, h: C2.titleH,
1693
+ fontFace: STYLE.font, fontSize: sz(15), bold: true, color: isRight ? STYLE.accent : STYLE.ink });
1694
+ pts.forEach((p, pi) => {
1695
+ const py = cpy + 0.18 + C2.titleH + 0.16 + pi * rowH;
1696
+ s.addShape('rect', { x: sd.x + 0.24, y: py + rowH / 2 - 0.06, w: 0.12, h: 0.12,
1697
+ fill: { color: isRight ? STYLE.accent : STYLE.faint }, line: { type: 'none' } });
1698
+ s.addText([
1699
+ { text: p[0] + ' ', options: { fontSize: fz, bold: true, color: STYLE.ink } },
1700
+ { text: p[1], options: { fontSize: fzSm, color: STYLE.body } },
1701
+ ], { x: sd.x + 0.5, y: py, w: cw2 - 0.75, h: rowH - 0.04, fontFace: STYLE.font, valign: 'top' });
1702
+ });
1703
+ });
1704
+ if (sec.verdict) {
1705
+ const vreg = REGIONS.regionOf('exhibit', 'annotation') ||
1706
+ { x: MX, y: PT.exhibit.soWhatY, w: CW, h: 0.62 };
1707
+ s.addShape('rect', { x: vreg.x, y: vreg.y, w: vreg.w, h: vreg.h,
1708
+ fill: { color: STYLE.accent }, line: { type: 'none' } });
1709
+ s.addText([
1710
+ { text: '结论 ', options: { fontSize: sz(11), bold: true, color: STYLE.onAccent, charSpacing: 1.5 } },
1711
+ { text: sec.verdict, options: { fontSize: sz(12.5), bold: true, color: STYLE.onAccent } },
1712
+ ], { x: vreg.x + 0.22, y: vreg.y + 0.06, w: vreg.w - 0.44, h: vreg.h - 0.12, fontFace: STYLE.font, valign: 'middle' });
1713
+ }
1714
+ } else if (type === 'quote') {
1715
+ const Q = PT.quote;
1716
+ const qreg = REGIONS.regionOf('quote', 'primary') ||
1717
+ { x: MX + 1.05, y: Q.textY, w: CW - 2.1, h: Q.textH };
1718
+ /* 金句页用主题一致强调带(accent-soft 底)——浅色主题下不再出现深色页 */
1719
+ s.addShape('rect', { x: 0, y: 0, w: PW, h: PH, fill: { color: STYLE.soft }, line: { type: 'none' } });
1720
+ if (sec.eyebrow) s.addText(sec.eyebrow, { x: MX, y: PT.common.headEyebrowY, w: CW, h: 0.4,
1721
+ fontFace: STYLE.font, fontSize: sz(13), bold: true, color: STYLE.accent, charSpacing: 2 });
1722
+ s.addText(sec.title, { x: MX, y: PT.common.headTitleY, w: CW, h: 1.0,
1723
+ fontFace: STYLE.fontDisplay, fontSize: sz(30), bold: true, color: STYLE.ink });
1724
+ s.addShape('rect', { x: MX, y: PT.common.headRuleY, w: 0.9, h: 0.045, fill: { color: STYLE.accent }, line: { type: 'none' } });
1725
+ s.addText('「', { x: MX + 0.05, y: Q.markY, w: 1.2, h: 1.1,
1726
+ fontFace: STYLE.fontDisplay, fontSize: sz(60), bold: true, color: STYLE.accent });
1727
+ const qSize = estTextH(sec.quote, qreg.w, sz(20), 1.4) > qreg.h ? sz(16) : sz(20);
1728
+ s.addText(sec.quote, { x: qreg.x, y: qreg.y, w: qreg.w, h: qreg.h,
1729
+ fontFace: STYLE.fontDisplay, fontSize: qSize, bold: true, color: STYLE.ink, valign: 'middle' });
1730
+ s.addText('—— ' + sec.author, { x: qreg.x, y: Q.authorY, w: qreg.w, h: 0.45, align: 'right',
1731
+ fontFace: STYLE.font, fontSize: sz(13), bold: true, color: STYLE.accent });
1732
+ if (sec.context) s.addText(sec.context, { x: qreg.x, y: Q.contextY, w: qreg.w, h: 0.4, align: 'right',
1733
+ fontFace: STYLE.font, fontSize: sz(11), color: STYLE.faint });
1734
+ } else if (type === 'image') {
1735
+ /* 素材图片页:full 版心全宽 / half 左图右注 / bleed 通栏出血 / grid 多图网格 /
1736
+ compare 双图对比 / wall Logo 墙;无图(image.placeholder)走原生占位框 + 标签文本 */
1737
+ const IM = PT.image;
1738
+ const img = sec.image || {};
1739
+ const items = Array.isArray(img.items) ? img.items.filter(Boolean) : [];
1740
+ const layout = String(img.layout || (items.length > 1 ? 'grid' : 'full')).toLowerCase();
1741
+ const fit = String(img.fit || IMG.fitDefault || 'cover').toLowerCase();
1742
+ const isPh = !!img.placeholder;
1743
+ const ireg = REGIONS.regionOf('image', 'primary', { top: bodyY, caption: !!img.caption }) ||
1744
+ { x: MX, y: Math.max(IM.y, bodyY), w: CW, h: IM.h };
1745
+ const iy = ireg.y;
1746
+ const capH = img.caption ? 0.32 : 0;
1747
+ const ih = Math.max(1.2, ireg.h);
1748
+ imageLayoutShapes(s, layout, img, items, sec.points || [], isPh, fit, iy, iy + ih);
1749
+ if (img.caption) s.addText(img.caption, { x: MX, y: iy + ih + 0.04, w: CW, h: capH,
1750
+ fontFace: STYLE.font, fontSize: sz(10.5), color: STYLE.faint });
1751
+ } else if (type === 'donut') {
1752
+ const dcn = sec.chart || {};
1753
+ if (dcn.labels && dcn.values) {
1754
+ const d = PT.donut;
1755
+ const dcols = dcn.colors || donutColors(styleArg);
1756
+ nativeDonutChart(s, dcn, dcols);
1757
+ const hb = d.holeR * 2;
1758
+ let total = 0; dcn.values.forEach(v => { total += Math.max(0, v); });
1759
+ const totalStr = (Math.round(total * 10) / 10) + (dcn.unit || '');
1760
+ s.addText(totalStr, { x: d.centerX - d.holeR, y: d.centerY - 0.42, w: hb, h: 0.5, align: 'center',
1761
+ fontFace: STYLE.fontDisplay, fontSize: sz(20), bold: true, color: STYLE.ink, valign: 'middle' });
1762
+ s.addText(dcn.centerLabel || '合计', { x: d.centerX - d.holeR, y: d.centerY + 0.08, w: hb, h: 0.35, align: 'center',
1763
+ fontFace: STYLE.font, fontSize: sz(9.5), color: STYLE.faint, valign: 'middle' });
1764
+ const n = Math.max(1, dcn.labels.length);
1765
+ const rowH = Math.min(d.legendRowH, 3.4 / n);
1766
+ const ly0 = d.centerY - (n * rowH) / 2 + 0.1;
1767
+ const sum2 = total || 1;
1768
+ dcn.labels.forEach((lb, i) => {
1769
+ const v = dcn.values[i] || 0;
1770
+ const y = ly0 + i * rowH;
1771
+ s.addShape('ellipse', { x: d.legendX, y: y + 0.08, w: 0.16, h: 0.16,
1772
+ fill: { color: dcols[i % dcols.length] }, line: { type: 'none' } });
1773
+ s.addText(lb, { x: d.legendX + 0.3, y, w: d.legendLabelW, h: rowH, valign: 'middle',
1774
+ fontFace: STYLE.font, fontSize: sz(12), bold: true, color: STYLE.ink });
1775
+ s.addText(String(v) + (dcn.unit || ''), { x: d.legendX + 3.8, y, w: d.legendValW, h: rowH, align: 'right',
1776
+ valign: 'middle', fontFace: STYLE.font, fontSize: sz(12), bold: true, color: STYLE.body });
1777
+ s.addText((Math.round(v / sum2 * 1000) / 10) + '%', { x: d.legendPctX, y, w: d.legendPctW, h: rowH,
1778
+ align: 'right', valign: 'middle', fontFace: STYLE.font, fontSize: sz(10.5), color: STYLE.faint });
1779
+ });
1780
+ }
1781
+ if (sec.note) s.addText(sec.note, { x: MX, y: PT.note.y, w: CW, h: PT.note.h,
1782
+ fontFace: STYLE.font, fontSize: sz(11), color: STYLE.faint });
1783
+ } else if (type === 'heatmap') {
1784
+ /* 热力矩阵:行 × 列 + 4 级色阶(surface-1 → accent-soft → accent-soft-2 → accent) */
1785
+ const H = PT.heatmap;
1786
+ const hreg = REGIONS.regionOf('heatmap', 'primary', { bottom: bodyBottom }) ||
1787
+ { x: MX, y: H.y, w: CW, h: bodyBottom - H.y };
1788
+ const rowsH = sec.rowHeads || [], colsH = sec.colHeads || [];
1789
+ const cells = sec.cells || [];
1790
+ const nR = Math.max(1, rowsH.length), nC = Math.max(1, colsH.length);
1791
+ const availH = hreg.h;
1792
+ const rowH = fitRowH(availH - H.headH, nR, H.cellH, 0.24);
1793
+ const gridW = hreg.w - H.labelW;
1794
+ const cw = (gridW - (nC - 1) * H.cellGap) / nC;
1795
+ const flat = cells.flat().map(Number).filter(v => !isNaN(v));
1796
+ const vmax = flat.length ? Math.max.apply(null, flat) : 1;
1797
+ const vmin = flat.length ? Math.min.apply(null, flat) : 0;
1798
+ const levels = [STYLE.surface, STYLE.soft, STYLE.line, STYLE.accent];
1799
+ colsH.forEach((ch, ci) => {
1800
+ s.addText(ch, { x: hreg.x + H.labelW + ci * (cw + H.cellGap), y: hreg.y, w: cw, h: H.headH,
1801
+ align: 'center', valign: 'middle', fontFace: STYLE.font, fontSize: sz(11), bold: true, color: STYLE.body });
1802
+ });
1803
+ const rowsUse = Math.min(nR, cells.length);
1804
+ for (let ri = 0; ri < rowsUse; ri++) {
1805
+ const y = hreg.y + H.headH + 0.08 + ri * (rowH + H.cellGap);
1806
+ s.addText(rowsH[ri] || '', { x: hreg.x, y, w: H.labelW - 0.15, h: rowH, align: 'right', valign: 'middle',
1807
+ fontFace: STYLE.font, fontSize: sz(11), bold: true, color: STYLE.body });
1808
+ for (let ci = 0; ci < nC; ci++) {
1809
+ const v = Number((cells[ri] || [])[ci]);
1810
+ const has = !isNaN(v);
1811
+ const p = (has && vmax > vmin) ? (v - vmin) / (vmax - vmin) : 0.5;
1812
+ const lvl = p >= 0.86 ? 3 : (p >= 0.55 ? 2 : (p >= 0.25 ? 1 : 0));
1813
+ const onAcc = lvl >= 3;
1814
+ s.addShape('roundRect', { x: hreg.x + H.labelW + ci * (cw + H.cellGap), y, w: cw, h: rowH,
1815
+ rectRadius: 0.05, fill: { color: levels[lvl] },
1816
+ line: lvl === 0 ? { color: STYLE.line, width: 0.75 } : { type: 'none' } });
1817
+ s.addText(has ? (String(v) + (sec.unit || '')) : '—',
1818
+ { x: hreg.x + H.labelW + ci * (cw + H.cellGap), y, w: cw, h: rowH, align: 'center', valign: 'middle',
1819
+ fontFace: STYLE.font, fontSize: sz(11.5), bold: lvl >= 2, color: onAcc ? STYLE.onAccent : STYLE.body });
1820
+ }
1821
+ }
1822
+ const lg = sec.scaleLabel || ['低', '高'];
1823
+ s.addText(lg[0] || '低', { x: hreg.x, y: hreg.y + H.headH + 0.08 + rowsUse * (rowH + H.cellGap) + 0.06,
1824
+ w: 0.6, h: 0.3, fontFace: STYLE.font, fontSize: sz(9.5), color: STYLE.faint });
1825
+ for (let i = 0; i < 4; i++) {
1826
+ s.addShape('rect', { x: hreg.x + 0.45 + i * 0.3, y: hreg.y + H.headH + 0.08 + rowsUse * (rowH + H.cellGap) + 0.1,
1827
+ w: 0.28, h: 0.16, fill: { color: levels[i] }, line: i === 0 ? { color: STYLE.line, width: 0.5 } : { type: 'none' } });
1828
+ }
1829
+ s.addText(lg[1] || '高', { x: hreg.x + 1.75, y: hreg.y + H.headH + 0.08 + rowsUse * (rowH + H.cellGap) + 0.06,
1830
+ w: 0.6, h: 0.3, fontFace: STYLE.font, fontSize: sz(9.5), color: STYLE.faint });
1831
+ } else if (type === 'bullet') {
1832
+ /* 达成对比:底槽 + 实际条 + 目标刻度 + 数值(实际/目标) */
1833
+ const B = PT.bullet;
1834
+ const breg = REGIONS.regionOf('bullet', 'primary', { bottom: bodyBottom }) ||
1835
+ { x: MX, y: B.y, w: CW, h: bodyBottom - B.y };
1836
+ const items = sec.items || [];
1837
+ const n = Math.max(1, items.length);
1838
+ const availH = breg.h;
1839
+ const rowH = fitRowH(availH, n, B.rowH, 0.26);
1840
+ const trackX = breg.x + B.labelW + 0.15;
1841
+ const trackW = breg.w - B.labelW - B.valW - 0.3;
1842
+ const nums = items.map(it => Number(it[1])).filter(v => !isNaN(v));
1843
+ const vmax = sec.max || (nums.length ? Math.max.apply(null, nums) : 1);
1844
+ items.forEach((it, i) => {
1845
+ const y = breg.y + i * rowH + rowH / 2 - B.barH / 2;
1846
+ const v = Number(it[1]) || 0, tgt = Number(it[2]);
1847
+ const hit = !isNaN(tgt) && v >= tgt;
1848
+ s.addText(String(it[0]), { x: breg.x, y: breg.y + i * rowH, w: B.labelW, h: rowH, valign: 'middle',
1849
+ fontFace: STYLE.font, fontSize: sz(12), bold: true, color: STYLE.ink });
1850
+ s.addShape('roundRect', { x: trackX, y, w: trackW, h: B.barH, rectRadius: 0.5, fill: { color: STYLE.surface }, line: { color: STYLE.line, width: 0.75 } });
1851
+ const fw = Math.max(0.02, Math.min(1, v / (vmax || 1)) * trackW);
1852
+ s.addShape('roundRect', { x: trackX, y, w: fw, h: B.barH, rectRadius: 0.5,
1853
+ fill: { color: hit ? STYLE.accent : STYLE.faint }, line: { type: 'none' } });
1854
+ if (!isNaN(tgt)) {
1855
+ const tx = trackX + Math.min(1, tgt / (vmax || 1)) * trackW;
1856
+ s.addShape('rect', { x: tx - 0.01, y: y - 0.07, w: 0.02, h: B.barH + 0.14, fill: { color: STYLE.body }, line: { type: 'none' } });
1857
+ }
1858
+ s.addText([{ text: String(it[1]) + (sec.unit || ''), options: { fontSize: sz(12), bold: true, color: hit ? STYLE.accent : STYLE.body } },
1859
+ { text: isNaN(tgt) ? '' : ' / ' + tgt + (sec.unit || ''), options: { fontSize: sz(10), color: STYLE.faint } }],
1860
+ { x: trackX + trackW + 0.12, y: breg.y + i * rowH, w: B.valW, h: rowH, align: 'right', valign: 'middle', fontFace: STYLE.font });
1861
+ });
1862
+ } else if (type === 'pyramid') {
1863
+ /* 金字塔:自上而下逐层加宽;顶层最窄,accent 层强调 */
1864
+ const P = PT.pyramid;
1865
+ const preg = REGIONS.regionOf('pyramid', 'primary', { bottom: bodyBottom }) ||
1866
+ { x: MX, y: P.y, w: CW, h: bodyBottom - P.y };
1867
+ const lv = sec.levels || [];
1868
+ const n = Math.max(1, lv.length);
1869
+ const availH = preg.h;
1870
+ const rowH = fitRowH(availH - (n - 1) * P.gap, n, P.rowH, 0.26);
1871
+ lv.forEach((l, i) => {
1872
+ const frac = P.minW + (1 - P.minW) * (n === 1 ? 1 : i / (n - 1));
1873
+ const w = preg.w * frac;
1874
+ const x = preg.x + (preg.w - w) / 2;
1875
+ const y = preg.y + i * (rowH + P.gap);
1876
+ const acc = isRec(l) && l.accent;
1877
+ const lt = isRec(l) ? (l.t || '') : String(l[0] || '');
1878
+ const ld = isRec(l) ? (l.d || '') : String(l[1] || '');
1879
+ s.addShape('roundRect', { x, y, w, h: rowH, rectRadius: 0.07,
1880
+ fill: { color: acc ? STYLE.soft : STYLE.surface },
1881
+ line: acc ? { type: 'none' } : { color: STYLE.line, width: 0.75 } });
1882
+ s.addText(lt, { x: x + 0.22, y, w: Math.min(P.labelW, w - 0.44), h: rowH, valign: 'middle',
1883
+ fontFace: STYLE.font, fontSize: sz(13.5), bold: true, color: acc ? STYLE.accent : STYLE.ink });
1884
+ if (ld) s.addText(ld, { x: x + 0.22 + Math.min(P.labelW, w - 0.44), y, w: w - 0.44 - Math.min(P.labelW, w - 0.44), h: rowH,
1885
+ valign: 'middle', fontFace: STYLE.font, fontSize: sz(11), color: STYLE.faint });
1886
+ });
1887
+ } else if (type === 'steps') {
1888
+ /* 步骤条:N 步横排 + 箭头;超 maxPerRow 折行;groups 分组标签 */
1889
+ const S2 = PT.steps;
1890
+ const sreg = REGIONS.regionOf('steps', 'primary') ||
1891
+ { x: MX, y: S2.y, w: CW, h: S2.barH };
1892
+ const st = sec.steps || [];
1893
+ const maxPer = S2.maxPerRow || 6;
1894
+ const groups = sec.groups || [];
1895
+ /* 按分组切片(groups = [[标签, 步数]…];缺省全为一段) */
1896
+ const chunks = [];
1897
+ if (groups.length) {
1898
+ let k = 0;
1899
+ groups.forEach(g => { const cnt = Number(g[1]) || 0; chunks.push({ label: g[0], items: st.slice(k, k + cnt) }); k += cnt; });
1900
+ if (k < st.length) chunks.push({ label: '', items: st.slice(k) });
1901
+ } else {
1902
+ for (let i = 0; i < st.length; i += maxPer) chunks.push({ label: '', items: st.slice(i, i + maxPer) });
1903
+ }
1904
+ const rowsN = chunks.length;
1905
+ const availH = bodyBottom - sreg.y;
1906
+ const rowH = fitRowH(availH - (rowsN - 1) * 0.3, rowsN, sreg.h || S2.barH, 0.3);
1907
+ let cy = sreg.y;
1908
+ chunks.forEach((ck) => {
1909
+ if (ck.label) {
1910
+ s.addText(ck.label, { x: sreg.x, y: cy - 0.02, w: sreg.w, h: 0.26, fontFace: STYLE.font,
1911
+ fontSize: sz(10), bold: true, color: STYLE.faint, charSpacing: 1 });
1912
+ cy += 0.28;
1913
+ }
1914
+ const n = Math.max(1, ck.items.length);
1915
+ const g = cols(sreg.w, n, S2.gap + S2.arrowW);
1916
+ ck.items.forEach((it, i) => {
1917
+ const acc = isRec(it) && it.accent;
1918
+ const t = isRec(it) ? (it.t || '') : String(it[0] || '');
1919
+ const d = isRec(it) ? (it.d || '') : String(it[1] || '');
1920
+ const x = sreg.x + i * g.step;
1921
+ s.addShape('roundRect', { x, y: cy, w: g.w, h: rowH, rectRadius: 0.07,
1922
+ fill: { color: acc ? STYLE.soft : STYLE.surface },
1923
+ line: acc ? { type: 'none' } : { color: STYLE.line, width: 0.75 } });
1924
+ const pd = S2.pad, numH = S2.numH, ttlH = S2.titleH;
1925
+ s.addText(String(i + 1).padStart(2, '0'), { x: x + 0.14, y: cy + pd, w: g.w - 0.28, h: numH,
1926
+ fontFace: STYLE.font, fontSize: sz(10), bold: true, color: STYLE.accent });
1927
+ s.addText(t, { x: x + 0.14, y: cy + pd + numH, w: g.w - 0.28, h: ttlH,
1928
+ fontFace: STYLE.font, fontSize: sz(13), bold: true, color: acc ? STYLE.accent : STYLE.ink });
1929
+ if (d) s.addText(d, { x: x + 0.14, y: cy + pd + numH + ttlH, w: g.w - 0.28,
1930
+ h: Math.max(0.2, rowH - pd * 2 - numH - ttlH),
1931
+ fontFace: STYLE.font, fontSize: sz(10), color: STYLE.faint });
1932
+ if (i < n - 1) {
1933
+ s.addText('→', { x: x + g.w, y: cy, w: S2.arrowW + S2.gap, h: rowH, align: 'center', valign: 'middle',
1934
+ fontFace: STYLE.font, fontSize: sz(12), color: STYLE.faint });
1935
+ }
1936
+ });
1937
+ cy += rowH + 0.3;
1938
+ });
1939
+ } else if (type === 'table') {
1940
+ const treg = REGIONS.regionOf('table', 'primary', { bottom: bodyBottom }) ||
1941
+ { x: MX, y: CONTENT_TOP, w: CW, h: bodyBottom - CONTENT_TOP };
1942
+ addTable(s, sec.table, treg.x, treg.y, treg.w, treg.h);
1943
+ } else if (type === 'timeline') {
1944
+ const T = PT.timeline;
1945
+ const treg = REGIONS.regionOf('timeline', 'primary') ||
1946
+ { x: MX, y: T.labelY, w: CW, h: T.descY + T.descH - T.labelY };
1947
+ const ps = sec.phases || [];
1948
+ const n = Math.max(1, ps.length);
1949
+ const stepW = (treg.w - 1.0) / n;
1950
+ s.addShape('line', { x: treg.x + 0.2, y: T.axisY, w: treg.w - 0.4, h: 0.02, line: { color: STYLE.line, width: 1.5 } });
1951
+ ps.forEach((p, i) => {
1952
+ const x = treg.x + 0.2 + i * stepW;
1953
+ const on = p[3] === 'done' || p[3] === 'now';
1954
+ s.addShape('ellipse', { x: x - T.dotR / 2, y: T.dotY, w: T.dotR, h: T.dotR,
1955
+ fill: { color: on ? STYLE.accent : STYLE.line }, line: { type: 'none' } });
1956
+ s.addText(p[0], { x: x - 0.1, y: T.labelY, w: stepW - 0.2, h: 0.4, fontFace: STYLE.font,
1957
+ fontSize: sz(12), bold: true, color: STYLE.accent, charSpacing: 1 });
1958
+ s.addText(p[1], { x: x - 0.1, y: T.nameY, w: stepW - 0.2, h: 0.5, fontFace: STYLE.fontDisplay,
1959
+ fontSize: sz(17), bold: true, color: p[3] === 'now' ? STYLE.accent : STYLE.ink });
1960
+ s.addText(p[2] || '', { x: x - 0.1, y: T.descY, w: stepW - 0.25, h: T.descH, fontFace: STYLE.font,
1961
+ fontSize: sz(12), color: STYLE.body, lineSpacing: sz(18) });
1962
+ });
1963
+ } else if (type === 'bar') {
1964
+ const c = sec.chart || {};
1965
+ const dcols = c.colors || dataColors(styleArg);
1966
+ const bot = chartBottom(!!sec.soWhat, !!sec.footnote) - (sec.note ? 0.42 : 0.05);
1967
+ const isH = c.type === 'hbar';
1968
+ const reg = REGIONS.regionOf('bar', isH ? 'hbar' : 'primary', { bottom: bot }) ||
1969
+ { x: MX, y: isH ? PT.bar.hbarY0 : PT.bar.chartY, w: CW, h: 3 };
1970
+ if (isH) {
1971
+ chartBlock(s, c, reg.x, reg.y, reg.w, reg.h, dcols);
1972
+ } else {
1973
+ /* 非 hbar 保持两侧留白(chartX/chartW),region 已扣减 */
1974
+ chartBlock(s, c, reg.x, reg.y, reg.w, Math.max(1.5, reg.h), dcols);
1975
+ }
1976
+ if (sec.note) s.addText(sec.note, { x: MX, y: PT.note.y, w: CW, h: PT.note.h, fontFace: STYLE.font,
1977
+ fontSize: sz(11), color: STYLE.faint });
1978
+ } else if (type === 'twocol' || type === 'threecol') {
1979
+ const ps = sec.paragraphs || [];
1980
+ const nCol = (type === 'threecol') ? 3 : 2;
1981
+ const gap = (type === 'threecol') ? PT.research.col3Gap : PT.twocol.colGap;
1982
+ const treg = REGIONS.regionOf('twocol', 'primary', { top: bodyY, bottom: bodyBottom }) ||
1983
+ { x: MX, y: bodyY, w: CW, h: bodyBottom - bodyY };
1984
+ /* threecol 用 3 栏均分(twocol IR 的 colW 仅适用 2 栏) */
1985
+ const g = cols(treg.w, nCol, gap);
1986
+ const per = Math.ceil(ps.length / nCol);
1987
+ const availH = treg.h;
1988
+ const groups = [];
1989
+ for (let i = 0; i < nCol; i++) groups.push(ps.slice(i * per, (i + 1) * per));
1990
+ /* 自适应:按各栏文本估算高度选字号 */
1991
+ const longest = groups.reduce((a, g2) => Math.max(a, g2.map(p => (p[1] || '').length).reduce((x, y) => x + y, 0)), 0);
1992
+ const items = []; for (let i = 0; i < longest / 120; i++) items.push('x'.repeat(120));
1993
+ const fz = fitFont(items.length ? items : ['x'.repeat(longest)], g.w, availH, { max: 13.5, gapFactor: 1.2 });
1994
+ groups.forEach((col, ci) => {
1995
+ if (!col.length) return;
1996
+ const runs = col.map(p => ([
1997
+ { text: p[0] + ' ', options: { fontSize: sz(fz), bold: true, color: STYLE.ink } },
1998
+ { text: p[1] + '\n', options: { fontSize: sz(fz - 1), color: STYLE.body, breakLine: true } },
1999
+ ])).flat();
2000
+ s.addText(runs, { x: treg.x + ci * g.step, y: treg.y, w: g.w, h: availH, fontFace: STYLE.font,
2001
+ valign: 'top', lineSpacing: sz(fz * 1.5), paraSpaceBefore: sz(7) });
2002
+ });
2003
+ } else if (type === 'halftable') {
2004
+ const yH = sec.lead ? PT.common.bodyYWithLead : PT.research.denseTableY;
2005
+ const lwH = CW * PT.research.halfTableW;
2006
+ if (sec.table && sec.table.head) {
2007
+ addTable(s, sec.table, MX, yH, lwH, bodyBottom - yH,
2008
+ { maxRowH: PT.research.denseRowH + 0.1, minRowH: 0.28 });
2009
+ }
2010
+ const ecH = sec.chart || {};
2011
+ if (ecH.labels && ecH.values) {
2012
+ const rxH = MX + CW * PT.research.halfChartX;
2013
+ const rwH = CW * PT.research.halfChartW;
2014
+ chartBlock(s, ecH, rxH, yH, rwH, Math.max(1.2, bodyBottom - yH), (ecH.colors || dataColors(styleArg)));
2015
+ }
2016
+ } else if (type === 'matrix') {
2017
+ const rhH = sec.rowHeads || [], chH = sec.colHeads || [];
2018
+ const mY = PT.research.matrixY, mLW = PT.research.matrixLabelW;
2019
+ const rowsC = sec.cells || [];
2020
+ const nR = Math.max(1, rowsC.length);
2021
+ const availH = bodyBottom - mY - 0.42;
2022
+ const mCH = fitRowH(availH, nR, PT.research.matrixCellH, 0.3);
2023
+ const mW = (CW - mLW) / Math.max(1, chH.length);
2024
+ chH.forEach((ch2, ci) => {
2025
+ s.addText(ch2, { x: MX + mLW + ci * mW, y: mY, w: mW, h: 0.34, align: 'center',
2026
+ fontFace: STYLE.font, fontSize: sz(11), bold: true, color: STYLE.body });
2027
+ });
2028
+ rowsC.forEach((rowC, ri) => {
2029
+ const y2 = mY + 0.42 + ri * mCH;
2030
+ s.addText(rhH[ri] || '', { x: MX, y: y2 + (mCH - 0.5) / 2, w: mLW - 0.15, h: 0.5, align: 'right',
2031
+ fontFace: STYLE.font, fontSize: sz(11), bold: true, color: STYLE.body });
2032
+ rowC.forEach((cell, ci) => {
2033
+ const acc = isRec(cell) && cell.accent;
2034
+ const txt = isRec(cell) ? (cell.t || '') : String(cell);
2035
+ const cx = MX + mLW + ci * mW;
2036
+ s.addShape('roundRect', { x: cx + 0.04, y: y2, w: mW - 0.08, h: mCH - 0.1, rectRadius: 0.05,
2037
+ fill: { color: acc ? STYLE.soft : STYLE.surface },
2038
+ line: acc ? { type: 'none' } : { color: STYLE.line, width: 0.75 } });
2039
+ s.addText(txt, { x: cx + 0.14, y: y2 + 0.08, w: mW - 0.28, h: mCH - 0.26,
2040
+ fontFace: STYLE.font, fontSize: sz(11), valign: 'middle',
2041
+ color: acc ? STYLE.accent : STYLE.body });
2042
+ });
2043
+ });
2044
+ } else if (type === 'lane') {
2045
+ const lnS = sec.lanes || [];
2046
+ const n = Math.max(1, lnS.length);
2047
+ const start = sec.lead ? PT.common.bodyYWithLead : PT.arch.fullStartY;
2048
+ const availH = bodyBottom - start;
2049
+ const lnH = fitRowH(availH - (n - 1) * PT.arch.laneGap, n, PT.arch.laneH, 0.3);
2050
+ const lnGap = PT.arch.laneGap, lnHW = PT.arch.laneHeadW;
2051
+ lnS.forEach((ln, li) => {
2052
+ const y3 = start + li * (lnH + lnGap);
2053
+ s.addShape('roundRect', { x: MX, y: y3, w: CW, h: lnH, rectRadius: 0.07,
2054
+ fill: { color: STYLE.surface }, line: { color: STYLE.line, width: 0.75 } });
2055
+ s.addShape('rect', { x: MX, y: y3, w: lnHW, h: lnH,
2056
+ fill: { color: STYLE.surface }, line: { color: STYLE.line, width: 0.75 } });
2057
+ s.addText(ln[0], { x: MX + 0.08, y: y3, w: lnHW - 0.16, h: lnH, align: 'center', valign: 'middle',
2058
+ fontFace: STYLE.font, fontSize: sz(11), bold: true, color: STYLE.body });
2059
+ const steps = ln[1] || [];
2060
+ const stepW = Math.min(PT.arch.stepMaxW, (CW - lnHW - 0.4 - (steps.length - 1) * PT.arch.stepGap) / Math.max(1, steps.length));
2061
+ steps.forEach((st, si) => {
2062
+ const stAcc = isRec(st) && st.accent;
2063
+ const stT = isRec(st) ? st.t : st;
2064
+ const sx = MX + lnHW + 0.2 + si * (stepW + PT.arch.stepGap);
2065
+ s.addShape('roundRect', { x: sx, y: y3 + 0.13, w: stepW, h: lnH - 0.26, rectRadius: 0.06,
2066
+ fill: { color: stAcc ? STYLE.soft : STYLE.bg },
2067
+ line: stAcc ? { type: 'none' } : { color: STYLE.line, width: 0.75 } });
2068
+ s.addText(stT, { x: sx + 0.06, y: y3 + 0.13, w: stepW - 0.12, h: lnH - 0.26,
2069
+ align: 'center', valign: 'middle', fontFace: STYLE.font, fontSize: sz(11),
2070
+ bold: !!stAcc, color: stAcc ? STYLE.accent : STYLE.ink });
2071
+ if (si < steps.length - 1) {
2072
+ /* 正交箭头(线段+三角头),替代裸文字 → */
2073
+ const ax = sx + stepW + 0.02, ay = y3 + lnH / 2, aw = PT.arch.stepGap - 0.04;
2074
+ s.addShape('rect', { x: ax, y: ay - 0.01, w: Math.max(0.06, aw * 0.55), h: 0.02,
2075
+ fill: { color: STYLE.faint }, line: { type: 'none' } });
2076
+ s.addShape('triangle', { x: ax + aw * 0.55, y: ay - 0.05, w: 0.1, h: 0.1, rotate: 90,
2077
+ fill: { color: STYLE.faint }, line: { type: 'none' } });
2078
+ }
2079
+ });
2080
+ });
2081
+ } else if (type === 'cards') {
2082
+ const cdC = PT.cards;
2083
+ const creg = REGIONS.regionOf('cards', 'primary', { bottom: bodyBottom }) ||
2084
+ { x: MX, y: cdC.startY, w: CW, h: bodyBottom - cdC.startY };
2085
+ const cds = sec.cards || [];
2086
+ const c = Math.min(sec.columns || 3, cds.length || 1);
2087
+ const r = Math.ceil((cds.length || 1) / c);
2088
+ const gw = (creg.w - (c - 1) * cdC.gap) / c;
2089
+ const gh = fitRowH(creg.h - (r - 1) * cdC.gap, r, cdC.maxH, 0.6);
2090
+ cds.forEach((cd, i) => {
2091
+ const col = i % c, row = Math.floor(i / c);
2092
+ const x = creg.x + col * (gw + cdC.gap), y = creg.y + row * (gh + cdC.gap);
2093
+ s.addShape('roundRect', { x, y, w: gw, h: gh, rectRadius: 0.08,
2094
+ fill: { color: STYLE.surface }, line: { color: STYLE.line, width: 0.75 } });
2095
+ /* 卡片头路标(PPTX 侧图标等价物:accent 小方块,与 HTML .card__ico 的 accent 底同语义)
2096
+ —— HTML 内联 SVG 图标不跨通道;用原生形状保持「路标不是装饰」的可扫读性 */
2097
+ const ico = 0.18;
2098
+ s.addShape('roundRect', { x: x + 0.18, y: y + 0.2, w: ico, h: ico, rectRadius: 0.04,
2099
+ fill: { color: STYLE.accent }, line: { type: 'none' }, objectName: trName('card') });
2100
+ s.addText(cd.title, { x: x + 0.18 + ico + 0.1, y: y + 0.14, w: gw - 0.36 - ico - 0.1, h: cdC.titleH, fontFace: STYLE.font,
2101
+ fontSize: sz(15), bold: true, color: STYLE.ink });
2102
+ /* points 兼容:[[k,v]…] | [str…] | [{t,d}…](scaffold v9 契约 {title, points:[[k,v]]}) */
2103
+ const ptLines = (cd.points || []).map(pt => {
2104
+ if (Array.isArray(pt)) {
2105
+ const k = pt[0] == null ? '' : String(pt[0]);
2106
+ const v = pt[1] == null ? '' : String(pt[1]);
2107
+ return k && v ? ('· ' + k + ' ' + v) : ('· ' + (k || v));
2108
+ }
2109
+ if (pt && typeof pt === 'object') {
2110
+ return '· ' + String(pt.t || '') + (pt.d ? ' ' + String(pt.d) : '');
2111
+ }
2112
+ return '· ' + String(pt == null ? '' : pt);
2113
+ });
2114
+ s.addText(ptLines.map(t => ({ text: t + '\n',
2115
+ options: { fontSize: sz(12), color: STYLE.body, fontFace: STYLE.font, breakLine: true } })),
2116
+ { x: x + 0.18, y: y + 0.14 + cdC.titleH + 0.06, w: gw - 0.36, h: Math.max(0.4, gh - cdC.titleH - 0.34),
2117
+ fontFace: STYLE.font, valign: 'top', lineSpacing: sz(12) * 1.5 });
2118
+ });
2119
+ } else if (type === 'split') {
2120
+ /* 双区自由组合页:左区与右区各可为 要点 / 图表 / 表格 / 图片。
2121
+ left.type / right.type = 'points'(默认) | 'table' | 'image' | 图表类型名(bar/line/…)。
2122
+ 缺省 left=points、right=bar —— 向后兼容 v7 模型(只写 left.points 的旧模型行为不变)。 */
2123
+ const SP = PT.split;
2124
+ const lreg = REGIONS.regionOf('split', 'left', { bottom: bodyBottom }) ||
2125
+ { x: MX, y: SP.chartY, w: CW * SP.leftW, h: bodyBottom - SP.chartY };
2126
+ const rreg = REGIONS.regionOf('split', 'right', { bottom: bodyBottom }) ||
2127
+ { x: MX + CW * SP.rightX, y: SP.chartY, w: CW * SP.rightW, h: bodyBottom - SP.chartY };
2128
+ const lw = lreg.w, rw = rreg.w, rx = rreg.x;
2129
+ const lt = (sec.left && sec.left.type) || 'points';
2130
+ /* 要点区渲染(左右两区共用;v8 起 right.type='points' 也可用——修正 v7 只在 schema 声明却未实现的问题) */
2131
+ const drawPoints = (el, x, w) => {
2132
+ const pts = (el && el.points) || [];
2133
+ const ly0 = bodyY, availP = bodyBottom - ly0;
2134
+ const rowH = fitRowH(availP, pts.length, SP.rowH, 0.3);
2135
+ const fzB = fitFont(pts.map(p => (p[0] || '') + (p[1] || '')), w - 0.4, availP, { max: 14, gapFactor: 0.6 });
2136
+ const fz = sz(fzB), fzSm = sz(fzB - 1);
2137
+ pts.forEach((pt, i) => {
2138
+ const ly = ly0 + i * rowH;
2139
+ s.addShape('rect', { x, y: ly + rowH / 2 - 0.06, w: 0.12, h: 0.12, fill: { color: STYLE.accent }, line: { type: 'none' } });
2140
+ s.addText([
2141
+ { text: pt[0] + ' ', options: { fontSize: fz, bold: true, color: STYLE.ink } },
2142
+ { text: pt[1], options: { fontSize: fzSm, color: STYLE.body } },
2143
+ ], { x: x + 0.28, y: ly, w: w - 0.4, h: rowH - 0.04, fontFace: STYLE.font, valign: 'top' });
2144
+ });
2145
+ };
2146
+ if (lt === 'table') {
2147
+ addTable(s, { head: sec.left.head, rows: sec.left.rows, colW: sec.left.colW },
2148
+ lreg.x, SP.tableY, lw, bodyBottom - SP.tableY, { maxRowH: SP.tableRowH, minRowH: 0.3 });
2149
+ } else if (lt === 'image') {
2150
+ const imL = (sec.left && sec.left.image) || {};
2151
+ addImageEl(s, imL.src, lreg.x, SP.chartY, lw, Math.max(1.4, bodyBottom - SP.chartY), 0.09,
2152
+ { placeholder: !!imL.placeholder || !imL.src, layout: 'half', multi: false, fit: imL.fit });
2153
+ if (imL.caption) s.addText(imL.caption, { x: lreg.x, y: SP.capY, w: lw, h: 0.35, align: 'center',
2154
+ fontFace: STYLE.font, fontSize: sz(11), color: STYLE.faint });
2155
+ } else if (lt !== 'points') {
2156
+ const lc = sec.left;
2157
+ chartBlock(s, lc, lreg.x, SP.chartY, lw, Math.max(1.4, bodyBottom - SP.chartY - (lc.cap ? 0.1 : 0)),
2158
+ lc.colors || dataColors(styleArg));
2159
+ if (lc.cap) s.addText(lc.cap, { x: lreg.x, y: SP.capY, w: lw, h: 0.35, align: 'center',
2160
+ fontFace: STYLE.font, fontSize: sz(12), color: STYLE.body });
2161
+ } else {
2162
+ drawPoints(sec.left, lreg.x, lw);
2163
+ }
2164
+ const rt = (sec.right && sec.right.type) || 'bar';
2165
+ if (rt === 'table') {
2166
+ addTable(s, { head: sec.right.head, rows: sec.right.rows, colW: sec.right.colW },
2167
+ rx, SP.tableY, rw, bodyBottom - SP.tableY, { maxRowH: SP.tableRowH, minRowH: 0.3 });
2168
+ } else if (rt === 'image') {
2169
+ const im = (sec.right && sec.right.image) || {};
2170
+ addImageEl(s, im.src, rx, SP.chartY, rw, Math.max(1.4, bodyBottom - SP.chartY), 0.09,
2171
+ { placeholder: !!im.placeholder || !im.src, layout: 'half', multi: false, fit: im.fit });
2172
+ if (im.caption) s.addText(im.caption, { x: rx, y: SP.capY, w: rw, h: 0.35, align: 'center',
2173
+ fontFace: STYLE.font, fontSize: sz(11), color: STYLE.faint });
2174
+ } else if (rt === 'points') {
2175
+ drawPoints(sec.right, rx, rw);
2176
+ } else {
2177
+ const bc = sec.right;
2178
+ const dcols = bc.colors || dataColors(styleArg);
2179
+ chartBlock(s, bc, rx, SP.chartY, rw, Math.max(1.4, bodyBottom - SP.chartY - (bc.cap ? 0.1 : 0)), dcols);
2180
+ if (bc.cap) s.addText(bc.cap, { x: rx, y: SP.capY, w: rw, h: 0.35, align: 'center',
2181
+ fontFace: STYLE.font, fontSize: sz(12), color: STYLE.body });
2182
+ }
2183
+ } else if (type === 'diagram') {
2184
+ const D = PT.diagram;
2185
+ const dreg = REGIONS.regionOf('diagram', 'primary', {
2186
+ mode: MODE_NAME, bottom: bodyBottom,
2187
+ }) || { x: MX, y: D.bodyStartY, w: CW, h: bodyBottom - D.bodyStartY,
2188
+ layerBarW: D.layerBarW, layerGap: D.layerGap, maxLayerH: D.maxLayerH };
2189
+ const lys = sec.layers || [];
2190
+ const dStart = dreg.y;
2191
+ const dEnd = dreg.y + dreg.h;
2192
+ const n = Math.max(1, lys.length);
2193
+ const lh = Math.max(0.5, Math.min(dreg.maxLayerH || D.maxLayerH, (dEnd - dStart) / n - D.layerGap));
2194
+ lys.forEach((lay, li) => {
2195
+ const y = dStart + li * (lh + D.layerGap);
2196
+ const focus = lay[2] === 'focus';
2197
+ s.addShape('roundRect', { x: dreg.x, y, w: D.layerBarW, h: lh, rectRadius: 0.05,
2198
+ fill: { color: focus ? STYLE.accent : STYLE.surface }, line: focus ? { type: 'none' } : { color: STYLE.line, width: 0.75 } });
2199
+ s.addText(lay[0], { x: dreg.x + 0.1, y: y + 0.1, w: D.layerBarW - 0.2, h: lh - 0.2, align: 'center', valign: 'middle',
2200
+ fontFace: STYLE.font, fontSize: sz(12), bold: true, color: focus ? STYLE.onAccent : STYLE.body });
2201
+ const nodes = lay[1] || [];
2202
+ const nn = Math.max(1, nodes.length);
2203
+ const availW = CW - D.layerBarW - 0.2;
2204
+ const nw = Math.min(D.nodeMaxW, (availW - (nn - 1) * D.nodeGap) / nn);
2205
+ nodes.forEach((nd, ni) => {
2206
+ const x = MX + D.layerBarW + 0.2 + ni * (nw + D.nodeGap);
2207
+ const acc = isRec(nd) && nd.accent;
2208
+ const ntt = isRec(nd) ? nd.t : nd;
2209
+ const nds = isRec(nd) ? (nd.d || '') : '';
2210
+ s.addShape('roundRect', { x, y, w: nw, h: lh, rectRadius: 0.06,
2211
+ fill: { color: acc ? STYLE.soft : STYLE.surface }, line: { color: STYLE.line, width: 0.75 } });
2212
+ s.addText(ntt, { x: x + 0.12, y: y + (nds ? 0.12 : lh / 2 - 0.2), w: nw - 0.24, h: 0.38,
2213
+ fontFace: STYLE.font, fontSize: sz(12.5), bold: true, color: acc ? STYLE.accent : STYLE.ink,
2214
+ valign: nds ? 'top' : 'middle' });
2215
+ if (nds) s.addText(nds, { x: x + 0.12, y: y + lh - 0.5, w: nw - 0.24, h: 0.4,
2216
+ fontFace: STYLE.font, fontSize: sz(10), color: STYLE.faint });
2217
+ });
2218
+ if (li < lys.length - 1) {
2219
+ const cy = y + lh + D.layerGap / 2;
2220
+ const cxm = MX + D.layerBarW / 2;
2221
+ /* 层间正交连接:竖线 + 箭头(替代两小块矩形) */
2222
+ s.addShape('rect', { x: cxm - 0.01, y: cy - 0.1, w: 0.02, h: 0.14,
2223
+ fill: { color: focus ? STYLE.accent : STYLE.line }, line: { type: 'none' } });
2224
+ s.addShape('triangle', { x: cxm - 0.06, y: cy + 0.02, w: 0.12, h: 0.1, rotate: 180,
2225
+ fill: { color: focus ? STYLE.accent : STYLE.line }, line: { type: 'none' } });
2226
+ }
2227
+ });
2228
+ if (sec.legend) {
2229
+ let lx = MX;
2230
+ sec.legend.forEach((lg) => {
2231
+ s.addText('● ' + lg, { x: lx, y: PH - 0.62, w: 3.2, h: 0.3, fontFace: STYLE.font, fontSize: sz(10), color: STYLE.faint });
2232
+ lx += 2.0;
2233
+ });
2234
+ }
2235
+ } else if (type === 'exhibit') {
2236
+ const ec = sec.chart || {};
2237
+ /* 主图区走布局 IR(layout_slots.json 的 primary 槽位),未命中时回落 PT.exhibit */
2238
+ const reg = REGIONS.regionOf('exhibit', 'primary', { withNote: !!(sec.soWhat || sec.footnote) })
2239
+ || { x: MX, y: PT.exhibit.chartY, w: CW, h: PT.exhibit.chartH };
2240
+ if (sec.exhibitNo) {
2241
+ s.addText(String(sec.exhibitNo).toUpperCase(), { x: MX, y: PT.exhibit.badgeY, w: CW, h: 0.35,
2242
+ fontFace: STYLE.font, fontSize: sz(11.5), bold: true, color: STYLE.accent, charSpacing: 2 });
2243
+ }
2244
+ if (ec.labels && ec.values) {
2245
+ const edcols = ec.colors || dataColors(styleArg);
2246
+ const h = Math.max(1.2, chartBottom(!!sec.soWhat, !!sec.footnote) - reg.y - 0.05);
2247
+ if (ec.type === 'hbar') {
2248
+ chartBlock(s, ec, reg.x, reg.y, reg.w, h, edcols);
2249
+ } else {
2250
+ chartBlock(s, ec, reg.x + 0.4, reg.y - 0.1, reg.w - 0.8, h + 0.15, edcols);
2251
+ }
2252
+ }
2253
+ } else if (type === 'sankey') {
2254
+ infoSankey(s, sec, bodyY, bodyBottom);
2255
+ } else if (type === 'treemap') {
2256
+ infoTreemap(s, sec, bodyY, bodyBottom);
2257
+ } else if (type === 'boxplot') {
2258
+ infoBoxplot(s, sec, bodyY, bodyBottom);
2259
+ } else if (type === 'network') {
2260
+ infoNetwork(s, sec, bodyY, bodyBottom);
2261
+ } else if (type === 'marimekko') {
2262
+ infoMarimekko(s, sec, bodyY, bodyBottom);
2263
+ } else if (type === 'streamgraph') {
2264
+ infoStreamgraph(s, sec, bodyY, bodyBottom);
2265
+ } else {
2266
+ /* points(默认):要点列表 + 可选右侧指标列(文本区与指标列严格分区,不重叠) */
2267
+ const P = PT.points;
2268
+ const pts = sec.points || [];
2269
+ const hasMetrics = !!(sec.metrics && sec.metrics.length);
2270
+ /* 指标列最多 3 个,列宽按剩余空间收敛,保证文本区不被侵占(原重叠缺陷) */
2271
+ const nM = hasMetrics ? Math.min(sec.metrics.length, 3) : 0;
2272
+ const mw = hasMetrics ? Math.min(P.metricW, Math.max(1.6, (CW - 4.8) / nM)) : 0;
2273
+ const textW = hasMetrics
2274
+ ? Math.max(4.2, (PW - MX - nM * mw) - MX - 0.35)
2275
+ : CW - 0.4;
2276
+ const avail = bodyBottom - bodyY;
2277
+ const rowH = fitRowH(avail, pts.length, P.rowH, 0.3);
2278
+ const fzB = fitFont(pts.map(p => (p[0] || '') + (p[1] || '')), textW - 0.32, avail,
2279
+ { max: 15, gapFactor: 0.55 });
2280
+ const fz = sz(fzB), fzSm = sz(fzB - 1);
2281
+ pts.forEach((p, i) => {
2282
+ const y = bodyY + i * rowH;
2283
+ s.addShape('rect', { x: MX, y: y + rowH / 2 - P.markSize / 2, w: P.markSize, h: P.markSize,
2284
+ fill: { color: STYLE.accent }, line: { type: 'none' } });
2285
+ s.addText([
2286
+ { text: p[0] + ' ', options: { fontSize: fz, bold: true, color: STYLE.ink } },
2287
+ { text: p[1], options: { fontSize: fzSm, color: STYLE.body } },
2288
+ ], { x: MX + 0.32, y, w: textW, h: rowH - 0.04, fontFace: STYLE.font, valign: 'top' });
2289
+ });
2290
+ if (hasMetrics) {
2291
+ const gx = PW - MX - nM * mw;
2292
+ const vSize = nM >= 3 ? sz(28) : sz(34);
2293
+ sec.metrics.slice(0, nM).forEach((m, i) => {
2294
+ const x = gx + i * mw;
2295
+ s.addText(m[0], { x, y: P.metricY, w: mw - 0.2, h: P.metricValH, fontFace: STYLE.fontDisplay,
2296
+ fontSize: vSize, bold: true, color: STYLE.accent });
2297
+ s.addText(m[1], { x, y: P.metricCapY, w: mw - 0.2, h: 0.9, fontFace: STYLE.font, fontSize: sz(11), color: STYLE.faint });
2298
+ });
2299
+ }
2300
+ }
2301
+ /* research 通用可选件:so-what 结论条 + 页脚来源行 + 待核实条(所有页型共用同一槽位) */
2302
+ if (sec.soWhat) soWhatBar(s, sec.soWhat);
2303
+ if (sec.footnote) footnoteLine(s, sec.footnote);
2304
+ if (flagH) flagBar(s, flagItems, flagY);
2305
+ /* 演讲者备注(口径/含义/来源沉 notes,不堆版面) */
2306
+ const _notes = [];
2307
+ if (sec.lead) _notes.push('导语:' + sec.lead);
2308
+ if (sec.chart && (sec.chart.unit || sec.chart.max != null)) {
2309
+ _notes.push('图表口径:单位 ' + (sec.chart.unit || '无') + ',量程 ' + (sec.chart.max != null ? sec.chart.max : '自动'));
2310
+ }
2311
+ /* 数据表:notes 策略把数据写入备注,保证图表数据可追溯(inline 策略已在页内附原生表格) */
2312
+ const _chartData = sec.chart || ((sec.right && sec.right.labels && sec.right.values) ? sec.right : null);
2313
+ if (_chartData && _chartData.labels && _chartData.values) {
2314
+ if (dataTableMode(_chartData) === 'notes') {
2315
+ _notes.push('数据表(' + (_chartData.type || 'bar') + '):\n' + dataTableText(_chartData));
2316
+ }
2317
+ }
2318
+ /* 信息图页型:notes 策略把页型数据表写入备注,保证数据可追溯 */
2319
+ const _infoRows = infoTableRows(sec);
2320
+ if (_infoRows && _infoRows.length && infoDataTableMode(sec) === 'notes') {
2321
+ _notes.push('数据表(' + type + '):\n' + _infoRows.map(r => r.join(' | ')).join('\n'));
2322
+ }
2323
+ if (sec.steps) _notes.push('步骤:' + sec.steps.map(st => (isRec(st) ? st.t : st[0]) || '').join(' → '));
2324
+ if (sec.items) _notes.push('达成对比:' + sec.items.map(it => it[0] + ' ' + it[1] + (it[2] != null ? '/' + it[2] : '')).join(';'));
2325
+ if (sec.levels) _notes.push('层级:' + sec.levels.map(l => (isRec(l) ? l.t : l[0]) || '').join(' → '));
2326
+ if (sec.soWhat) _notes.push('So what:' + sec.soWhat);
2327
+ if (sec.layoutPreset) _notes.push('布局骨架:' + sec.layoutPreset);
2328
+ if (flagItems.length) _notes.push('待核实(需二次确认):' + flagItems.join(';'));
2329
+ if (sec.note) _notes.push('注:' + sec.note);
2330
+ if (sec.footnote) _notes.push('来源:' + sec.footnote);
2331
+ if (sec.image && sec.image.caption) _notes.push('图片说明:' + sec.image.caption);
2332
+ if (sec.image && sec.image.placeholder) {
2333
+ const _size = (IMG.recommendedSizePx || {})[String(sec.image.layout || 'full').toLowerCase()] || '';
2334
+ _notes.push('配图占位(交付前建议替换):' + (sec.image.hint || ('建议 ' + _size + 'px,替换 image.src 或 image.items 即可')));
2335
+ }
2336
+ if (sec.image && Array.isArray(sec.image.items) && sec.image.items.length) {
2337
+ _notes.push('图片清单:' + sec.image.items.map((it, i) => (i + 1) + '.' + ((it && (it.caption || it.alt)) || '(待补说明)')).join(';'));
2338
+ }
2339
+ if (_notes.length) s.addNotes(_notes.join('\n'));
2340
+ });
2341
+
2342
+ /* 收尾 */
2343
+ (function closing() {
2344
+ const s = base();
2345
+ const C = PT.closing;
2346
+ /* 收尾页用主题一致强调带(accent-soft 底 + 常规文字)——浅色主题下不再出现深色收尾页 */
2347
+ s.addShape('rect', { x: 0, y: 0, w: PW, h: PH, fill: { color: STYLE.soft }, line: { type: 'none' } });
2348
+ s.addShape('rect', { x: MX, y: C.eyebrowY, w: 0.9, h: 0.045, fill: { color: STYLE.accent }, line: { type: 'none' } });
2349
+ s.addText('下一步', { x: MX, y: C.eyebrowY + 0.16, w: CW, h: 0.4, fontFace: STYLE.font,
2350
+ fontSize: sz(13), bold: true, color: STYLE.accent, charSpacing: 2 });
2351
+ s.addText(CONTENT.closing.title, { x: MX, y: C.titleY, w: CW - 1, h: C.titleH, fontFace: STYLE.fontDisplay,
2352
+ fontSize: sz(30), bold: true, color: STYLE.ink });
2353
+ const pts = CONTENT.closing.points || [];
2354
+ const n = Math.max(1, pts.length);
2355
+ const cw3 = (CW - (n - 1) * 0.3) / n;
2356
+ pts.forEach((p, i) => {
2357
+ const x = MX + i * (cw3 + 0.3);
2358
+ s.addShape('roundRect', { x, y: C.pointsY - 0.2, w: cw3, h: C.pointTitleH + C.pointBodyH + 0.32,
2359
+ rectRadius: 0.09, fill: { color: STYLE.surface }, line: { color: STYLE.line, width: 0.75 } });
2360
+ s.addText(p[0], { x: x + 0.18, y: C.pointsY, w: cw3 - 0.36, h: C.pointTitleH, fontFace: STYLE.font,
2361
+ fontSize: sz(17), bold: true, color: STYLE.accent });
2362
+ s.addText(p[1], { x: x + 0.18, y: C.pointsY + C.pointTitleH, w: cw3 - 0.36, h: C.pointBodyH, fontFace: STYLE.font,
2363
+ fontSize: sz(12.5), color: STYLE.body });
2364
+ });
2365
+ s.addNotes((CONTENT.closing.points || []).map(p => '· ' + p[0] + ':' + p[1]).join('\n'));
2366
+ })();
2367
+
2368
+ /* 页码 */
2369
+ pptx._slides.forEach((s, i) => footer(s, i + 1, pptx._slides.length));
2370
+
2371
+ const out = process.argv.slice(2).find(a => !a.startsWith('--')) || 'TopPPT HTML 报告.pptx';
2372
+ pptx.writeFile({ fileName: out }).then(() => {
2373
+ console.log('已生成: ' + out + ' (风格: ' + (STYLE_PRESETS[styleArg] ? styleArg : 'business-blue') +
2374
+ ' · 主题: ' + (THEME === 'dark' ? '深色 dark' : '浅色 light') + ' · 模式: ' + MODE_NAME + ')');
2375
+ if (SHRINK_FLOOR_HITS.length) {
2376
+ const low = SHRINK_FLOOR_HITS.filter(h => h.pt <= (FS_POLICY.warnBelow || 9.5));
2377
+ console.warn('[build_pptx] 注意:' + SHRINK_FLOOR_HITS.length + ' 处文本缩字号已触底' +
2378
+ (low.length ? '(其中 ' + low.length + ' 处 ≤ ' + (FS_POLICY.warnBelow || 9.5) + 'pt)' : '') +
2379
+ '——该页内容超出版式承载力。');
2380
+ SHRINK_FLOOR_HITS.slice(0, 5).forEach(h =>
2381
+ console.warn(' · ' + h.pt + 'pt:“' + h.sample + '…”'));
2382
+ console.warn(' 处置顺序:①列表化/精炼 ②升级承载形态 ③换/扩组合版式 ④分区 ⑤拆页(不要靠继续缩字号硬塞)。');
2383
+ }
2384
+ console.log('下一步(质检硬门禁,本技能内置脚本;--model 启用模型往返保真检查):');
2385
+ console.log(' python scripts/validate_pptx.py "' + out + '" --strict' + (modelArg ? ' --model="' + modelArg + '"' : ' --model="<报告.model.json>"'));
2386
+ });