@topmindspace/tms-skills 0.3.6 → 0.4.1

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 (184) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/README.en.md +12 -9
  3. package/README.md +12 -9
  4. package/bin/tms-skills.js +153 -13
  5. package/package.json +1 -1
  6. package/top-ppt-html/README.en.md +2 -2
  7. package/top-ppt-html/README.md +1 -1
  8. package/top-ppt-html/SKILL.md +8 -7
  9. package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.html +58 -184
  10. package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.model.json +13 -3
  11. package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.html +58 -337
  12. package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.model.json +15 -1
  13. package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.html +58 -930
  14. package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.model.json +15 -1
  15. package/top-ppt-html/assets/examples/2026-09-26-topmind-tms-skills-showcase.html +141 -31
  16. package/top-ppt-html/assets/icons/index.json +557 -0
  17. package/top-ppt-html/assets/icons//344/270/212/344/274/240.png +0 -0
  18. package/top-ppt-html/assets/icons//344/270/212/344/274/240.svg +1 -0
  19. package/top-ppt-html/assets/icons//344/270/213/350/275/275.png +0 -0
  20. package/top-ppt-html/assets/icons//344/270/213/350/275/275.svg +1 -0
  21. package/top-ppt-html/assets/icons//344/270/213/351/231/215.png +0 -0
  22. package/top-ppt-html/assets/icons//344/270/213/351/231/215.svg +1 -0
  23. package/top-ppt-html/assets/icons//344/271/246/347/255/276.png +0 -0
  24. package/top-ppt-html/assets/icons//344/271/246/347/255/276.svg +1 -0
  25. package/top-ppt-html/assets/icons//344/272/221.png +0 -0
  26. package/top-ppt-html/assets/icons//344/272/221.svg +1 -0
  27. package/top-ppt-html/assets/icons//344/275/215/347/275/256.png +0 -0
  28. package/top-ppt-html/assets/icons//344/275/215/347/275/256.svg +1 -0
  29. package/top-ppt-html/assets/icons//344/277/241/346/201/257.png +0 -0
  30. package/top-ppt-html/assets/icons//344/277/241/346/201/257.svg +1 -0
  31. package/top-ppt-html/assets/icons//345/210/206/344/272/253.png +0 -0
  32. package/top-ppt-html/assets/icons//345/210/206/344/272/253.svg +1 -0
  33. package/top-ppt-html/assets/icons//345/213/276/351/200/211.png +0 -0
  34. package/top-ppt-html/assets/icons//345/213/276/351/200/211.svg +1 -0
  35. package/top-ppt-html/assets/icons//345/215/240/346/257/224.png +0 -0
  36. package/top-ppt-html/assets/icons//345/215/240/346/257/224.svg +1 -0
  37. package/top-ppt-html/assets/icons//345/233/242/351/230/237.png +0 -0
  38. package/top-ppt-html/assets/icons//345/233/242/351/230/237.svg +1 -0
  39. package/top-ppt-html/assets/icons//345/233/276/350/241/250.png +0 -0
  40. package/top-ppt-html/assets/icons//345/233/276/350/241/250.svg +1 -0
  41. package/top-ppt-html/assets/icons//345/242/236/351/225/277.png +0 -0
  42. package/top-ppt-html/assets/icons//345/242/236/351/225/277.svg +1 -0
  43. package/top-ppt-html/assets/icons//345/256/211/345/205/250.png +0 -0
  44. package/top-ppt-html/assets/icons//345/256/211/345/205/250.svg +1 -0
  45. package/top-ppt-html/assets/icons//345/257/271/350/257/235.png +0 -0
  46. package/top-ppt-html/assets/icons//345/257/271/350/257/235.svg +1 -0
  47. package/top-ppt-html/assets/icons//345/267/245/345/205/267.png +0 -0
  48. package/top-ppt-html/assets/icons//345/267/245/345/205/267.svg +1 -0
  49. package/top-ppt-html/assets/icons//345/276/252/347/216/257.png +0 -0
  50. package/top-ppt-html/assets/icons//345/276/252/347/216/257.svg +1 -0
  51. package/top-ppt-html/assets/icons//346/210/220/346/236/234.png +0 -0
  52. package/top-ppt-html/assets/icons//346/210/220/346/236/234.svg +1 -0
  53. package/top-ppt-html/assets/icons//346/211/213/346/234/272.png +0 -0
  54. package/top-ppt-html/assets/icons//346/211/213/346/234/272.svg +1 -0
  55. package/top-ppt-html/assets/icons//346/225/210/347/216/207.png +0 -0
  56. package/top-ppt-html/assets/icons//346/225/210/347/216/207.svg +1 -0
  57. package/top-ppt-html/assets/icons//346/225/260/346/215/256.png +0 -0
  58. package/top-ppt-html/assets/icons//346/225/260/346/215/256.svg +1 -0
  59. package/top-ppt-html/assets/icons//346/226/207/344/273/266/345/244/271.png +0 -0
  60. package/top-ppt-html/assets/icons//346/226/207/344/273/266/345/244/271.svg +1 -0
  61. package/top-ppt-html/assets/icons//346/226/207/346/241/243.png +0 -0
  62. package/top-ppt-html/assets/icons//346/226/207/346/241/243.svg +1 -0
  63. package/top-ppt-html/assets/icons//346/227/227/345/270/234.png +0 -0
  64. package/top-ppt-html/assets/icons//346/227/227/345/270/234.svg +1 -0
  65. package/top-ppt-html/assets/icons//346/227/266/351/227/264.png +0 -0
  66. package/top-ppt-html/assets/icons//346/227/266/351/227/264.svg +1 -0
  67. package/top-ppt-html/assets/icons//346/230/237/346/240/207.png +0 -0
  68. package/top-ppt-html/assets/icons//346/230/237/346/240/207.svg +1 -0
  69. package/top-ppt-html/assets/icons//346/231/272/350/203/275.png +0 -0
  70. package/top-ppt-html/assets/icons//346/231/272/350/203/275.svg +1 -0
  71. package/top-ppt-html/assets/icons//346/235/203/351/231/220.png +0 -0
  72. package/top-ppt-html/assets/icons//346/235/203/351/231/220.svg +1 -0
  73. package/top-ppt-html/assets/icons//346/243/200/346/237/245.png +0 -0
  74. package/top-ppt-html/assets/icons//346/243/200/346/237/245.svg +1 -0
  75. package/top-ppt-html/assets/icons//346/264/236/345/257/237.png +0 -0
  76. package/top-ppt-html/assets/icons//346/264/236/345/257/237.svg +1 -0
  77. package/top-ppt-html/assets/icons//346/265/201/347/250/213.png +0 -0
  78. package/top-ppt-html/assets/icons//346/265/201/347/250/213.svg +1 -0
  79. package/top-ppt-html/assets/icons//346/270/205/345/215/225.png +0 -0
  80. package/top-ppt-html/assets/icons//346/270/205/345/215/225.svg +1 -0
  81. package/top-ppt-html/assets/icons//347/201/253/347/256/255.png +0 -0
  82. package/top-ppt-html/assets/icons//347/201/253/347/256/255.svg +1 -0
  83. package/top-ppt-html/assets/icons//347/201/257/346/263/241.png +0 -0
  84. package/top-ppt-html/assets/icons//347/201/257/346/263/241.svg +1 -0
  85. package/top-ppt-html/assets/icons//347/224/250/346/210/267.png +0 -0
  86. package/top-ppt-html/assets/icons//347/224/250/346/210/267.svg +1 -0
  87. package/top-ppt-html/assets/icons//347/233/256/346/240/207.png +0 -0
  88. package/top-ppt-html/assets/icons//347/233/256/346/240/207.svg +1 -0
  89. package/top-ppt-html/assets/icons//347/256/255/345/244/264.png +0 -0
  90. package/top-ppt-html/assets/icons//347/256/255/345/244/264.svg +1 -0
  91. package/top-ppt-html/assets/icons//347/273/204/347/273/207.png +0 -0
  92. package/top-ppt-html/assets/icons//347/273/204/347/273/207.svg +1 -0
  93. package/top-ppt-html/assets/icons//347/275/227/347/233/230.png +0 -0
  94. package/top-ppt-html/assets/icons//347/275/227/347/233/230.svg +1 -0
  95. package/top-ppt-html/assets/icons//350/241/250/346/240/274.png +0 -0
  96. package/top-ppt-html/assets/icons//350/241/250/346/240/274.svg +1 -0
  97. package/top-ppt-html/assets/icons//350/256/241/345/210/222.png +0 -0
  98. package/top-ppt-html/assets/icons//350/256/241/345/210/222.svg +1 -0
  99. package/top-ppt-html/assets/icons//350/256/276/347/275/256.png +0 -0
  100. package/top-ppt-html/assets/icons//350/256/276/347/275/256.svg +1 -0
  101. package/top-ppt-html/assets/icons//350/266/213/345/212/277.png +0 -0
  102. package/top-ppt-html/assets/icons//350/266/213/345/212/277.svg +1 -0
  103. package/top-ppt-html/assets/icons//351/202/256/344/273/266.png +0 -0
  104. package/top-ppt-html/assets/icons//351/202/256/344/273/266.svg +1 -0
  105. package/top-ppt-html/assets/icons//351/207/221/351/222/261.png +0 -0
  106. package/top-ppt-html/assets/icons//351/207/221/351/222/261.svg +1 -0
  107. package/top-ppt-html/assets/icons//351/223/276/346/216/245.png +0 -0
  108. package/top-ppt-html/assets/icons//351/223/276/346/216/245.svg +1 -0
  109. package/top-ppt-html/assets/icons//351/227/252/347/224/265.png +0 -0
  110. package/top-ppt-html/assets/icons//351/227/252/347/224/265.svg +1 -0
  111. package/top-ppt-html/assets/icons//351/243/216/351/231/251.png +0 -0
  112. package/top-ppt-html/assets/icons//351/243/216/351/231/251.svg +1 -0
  113. package/top-ppt-html/assets/pptx-export.js +44 -15
  114. package/top-ppt-html/assets/style-gallery.html +2 -2
  115. package/top-ppt-html/assets/templates/architecture.html +59 -17
  116. package/top-ppt-html/assets/templates/engine.css +12 -0
  117. package/top-ppt-html/assets/templates/presentation.html +59 -17
  118. package/top-ppt-html/assets/templates/research.html +59 -17
  119. package/top-ppt-html/package-lock.json +2 -2
  120. package/top-ppt-html/package.json +2 -2
  121. package/top-ppt-html/references/icons.md +22 -19
  122. package/top-ppt-html/references/layout-variants.md +69 -0
  123. package/top-ppt-html/references/style-pack.md +40 -0
  124. package/top-ppt-html/scripts/audit_css.py +1 -1
  125. package/top-ppt-html/scripts/build_pptx.js +228 -50
  126. package/top-ppt-html/scripts/check_icons.py +119 -0
  127. package/top-ppt-html/scripts/hydrate_charts.py +60 -5
  128. package/top-ppt-html/scripts/icon_lib.js +66 -37
  129. package/top-ppt-html/scripts/layout-constants.json +92 -5
  130. package/top-ppt-html/scripts/make_icon_pack.py +172 -0
  131. package/top-ppt-html/scripts/model-schema.json +4 -1
  132. package/top-ppt-html/scripts/regression.py +3 -3
  133. package/top-ppt-html/scripts/render_compare.py +1 -1
  134. package/top-ppt-html/scripts/render_from_model.py +116 -55
  135. package/top-ppt-html/scripts/scaffold_report.py +1 -1
  136. package/top-ppt-html/scripts/sync_runtime.py +14 -14
  137. package/top-ppt-html/scripts/validate_pptx.py +1 -0
  138. package/top-ppt-html/scripts/validate_report.py +157 -0
  139. package/topmind-cover/README.en.md +4 -4
  140. package/topmind-cover/README.md +4 -4
  141. package/topmind-cover/SKILL.md +36 -16
  142. package/topmind-cover/assets/examples/README.md +13 -7
  143. package/topmind-cover/assets/examples/bg-blur-alt.png +0 -0
  144. package/topmind-cover/assets/examples/bg-blur-wechat.png +0 -0
  145. package/topmind-cover/assets/examples/bg-blur.png +0 -0
  146. package/topmind-cover/assets/examples/big-type-wechat.png +0 -0
  147. package/topmind-cover/assets/examples/big-type.png +0 -0
  148. package/topmind-cover/assets/examples/brand-launch-wechat.png +0 -0
  149. package/topmind-cover/assets/examples/brand-launch.png +0 -0
  150. package/topmind-cover/assets/examples/gan-huo-wechat.png +0 -0
  151. package/topmind-cover/assets/examples/gan-huo.png +0 -0
  152. package/topmind-cover/assets/examples/ip-fun-wechat.png +0 -0
  153. package/topmind-cover/assets/examples/ip-fun.png +0 -0
  154. package/topmind-cover/assets/examples/magazine-wechat.png +0 -0
  155. package/topmind-cover/assets/examples/magazine.png +0 -0
  156. package/topmind-cover/assets/examples/minimal-wechat.png +0 -0
  157. package/topmind-cover/assets/examples/minimal.png +0 -0
  158. package/topmind-cover/assets/examples/news-flash-wechat.png +0 -0
  159. package/topmind-cover/assets/examples/news-flash.png +0 -0
  160. package/topmind-cover/assets/examples/overview.png +0 -0
  161. package/topmind-cover/assets/examples/paper-collage-alt.png +0 -0
  162. package/topmind-cover/assets/examples/paper-collage-wechat.png +0 -0
  163. package/topmind-cover/assets/examples/paper-collage.png +0 -0
  164. package/topmind-cover/assets/examples/tutorial-steps-wechat.png +0 -0
  165. package/topmind-cover/assets/examples/tutorial-steps.png +0 -0
  166. package/topmind-cover/assets/examples/white-clean-alt-wechat.png +0 -0
  167. package/topmind-cover/assets/examples/white-clean-alt.png +0 -0
  168. package/topmind-cover/assets/examples/white-clean-wechat.png +0 -0
  169. package/topmind-cover/assets/examples/white-clean.png +0 -0
  170. package/topmind-cover/package.json +2 -2
  171. package/topmind-cover/references/cover-styles.md +210 -35
  172. package/topmind-cover/references/impact-language.md +43 -2
  173. package/topmind-cover/references/ref-white-clean-1.png +0 -0
  174. package/topmind-cover/references/ref-white-clean-2.png +0 -0
  175. package/topmind-cover/scripts/crop-cover.py +19 -13
  176. package/topmind-wechat-post/README.en.md +2 -2
  177. package/topmind-wechat-post/README.md +2 -2
  178. package/topmind-wechat-post/SKILL.md +1 -1
  179. package/topmind-wechat-post/scripts/md2wechat.py +142 -30
  180. package/topmind-wechat-post/scripts/negative_tests.py +74 -0
  181. package/topmind-x-article/README.en.md +1 -1
  182. package/topmind-x-article/README.md +1 -1
  183. package/topmind-x-article/scripts/md2x.py +25 -5
  184. package/topmind-x-article/scripts/negative_tests.py +17 -0
@@ -159,15 +159,22 @@ function _normColor(c) {
159
159
  if (/^[0-9a-f]{3}$/.test(v)) v = '#' + v;
160
160
  return v;
161
161
  }
162
- /** 取图标 PNG data URI;无缓存时按名挑色兜底(accent → 首色 → null) */
162
+ /** 取图标 PNG data URI;无缓存时按名挑色兜底(accent → 首色 → 包内 512px PNG → null) */
163
163
  function iconPng(name, colorHex) {
164
164
  const n = name && ICON_ASSETS.icons[name] ? name : ICON_LIB.pickIconName(name, 0);
165
165
  const bag = ICON_ASSETS.icons[n];
166
- if (!bag) return null;
167
- const want = _normColor(colorHex);
168
- if (bag[want]) return bag[want];
169
- const keys = Object.keys(bag);
170
- return bag[keys[0]] || null;
166
+ if (bag) {
167
+ const want = _normColor(colorHex);
168
+ if (bag[want]) return bag[want];
169
+ const keys = Object.keys(bag);
170
+ if (keys.length) return bag[keys[0]];
171
+ }
172
+ // 无 sharp / 无预生成资产时的可靠回落:包内 512px 预渲染 PNG(python-pptx/pptxgenjs 均可直接嵌入)
173
+ try {
174
+ const p = ICON_LIB.iconPngPath(n);
175
+ if (p) return 'data:image/png;base64,' + fs.readFileSync(p).toString('base64');
176
+ } catch (e) { /* 继续回落 accent 方块 */ }
177
+ return null;
171
178
  }
172
179
  /** 在指定盒内嵌图标(真导出);无图标资产时回落 accent 小方块(可扫读路标) */
173
180
  function addIcon(s, name, x, y, size, colorHex) {
@@ -275,6 +282,30 @@ const FS_POLICY = (function () {
275
282
  }
276
283
  })();
277
284
  const FONT_LADDER = FS_POLICY.ladder;
285
+ /* 弹性文本单源(containers.adaptiveText · 0.2.0):长标题/长列表/长表格单元格的降级规则。
286
+ 调用处只读 AT_* 与下列 helper,禁止手写字号/行距/阈值 magic number。 */
287
+ const AT = (LC.containers && LC.containers.adaptiveText) || {};
288
+ const AT_TITLE = AT.title || {}, AT_LIST = AT.list || {}, AT_TABLE = AT.table || {};
289
+ /* 标题字号上限:按字符数(CJK=1,拉丁/数字≈0.5,与 estLines 同口径)分档 */
290
+ function titleMaxFor(text) {
291
+ const s = String(text == null ? '' : text);
292
+ let units = 0;
293
+ for (const ch of s) units += /[\x00-\xff]/.test(ch) ? 0.5 : 1;
294
+ if (units >= (AT_TITLE.xlongChars != null ? AT_TITLE.xlongChars : 44)) return AT_TITLE.xlongMax || 20;
295
+ if (units >= (AT_TITLE.longChars != null ? AT_TITLE.longChars : 28)) return AT_TITLE.longMax || 24;
296
+ return AT_TITLE.base || 30;
297
+ }
298
+ /* 列表密集度:条目数达标 → 收紧字号上限/段间距/行距 */
299
+ function listFitOpts(n) {
300
+ const dense = n >= (AT_LIST.denseItems != null ? AT_LIST.denseItems : 6);
301
+ return {
302
+ dense,
303
+ max: dense ? (AT_LIST.denseMax || 12) : (AT_LIST.max || 14),
304
+ gapFactor: dense ? (AT_LIST.denseGapFactor != null ? AT_LIST.denseGapFactor : 0.25)
305
+ : (AT_LIST.gapFactor != null ? AT_LIST.gapFactor : 0.5),
306
+ lineFactor: dense ? (AT_LIST.denseLineFactor || 1.3) : (AT_LIST.lineFactor || 1.45),
307
+ };
308
+ }
278
309
  /* 缩字号触底记录:触底 = 内容量超出该版式承载力,是「该拆页/换组合」的信号,不得无声无息 */
279
310
  const SHRINK_FLOOR_HITS = [];
280
311
  function modeFloorPt() {
@@ -335,6 +366,13 @@ function fitRowH(avail, n, pref, floor) {
335
366
  return Math.max(lo, Math.min(pref, avail / Math.max(1, n)));
336
367
  }
337
368
 
369
+ /* Exhibit 徽标文本:纯编号补 "Exhibit " 前缀(HTML 为 `Exhibit {no}` + CSS uppercase,视觉同 "EXHIBIT 1");
370
+ 调用方已带前缀的不重复 */
371
+ function exhibitBadge(no) {
372
+ const t = String(no == null ? '' : no).trim();
373
+ if (!t) return t;
374
+ return /^exhibit[\s\-:]*/i.test(t) ? t.toUpperCase() : ('EXHIBIT ' + t.toUpperCase());
375
+ }
338
376
  /* ══════════ 内容模型 ══════════
339
377
  * 从 --model=<file.json> 读取(推荐:由 extract_model.py 从 HTML 报告抽取,
340
378
  * 保证 PPTX 与页面同源);未提供时使用下方内嵌 CONTENT 示例。 */
@@ -463,9 +501,10 @@ function head(s, eyebrow, title, lead) {
463
501
  };
464
502
  if (eyebrow) s.addText(eyebrow, { x: H.x, y: H.y, w: H.w, h: 0.4, fontFace: STYLE.font,
465
503
  fontSize: sz(13), bold: true, color: STYLE.accent, charSpacing: 2 });
466
- /* 主标题自适应:过长时缩一档,避免压到分隔线 */
504
+ /* 主标题自适应(adaptiveText.title 单源):先按字符数分档定上限,再按高度验算降一档,避免压到分隔线 */
467
505
  const titleH = eyebrow ? (H.ruleY - H.titleY - 0.06) : 1.6 - 0.6;
468
- const tSize = estTextH(title, CW, sz(30), 1.25) > titleH ? sz(24) : sz(30);
506
+ const tMax = titleMaxFor(title);
507
+ const tSize = sz(estTextH(title, CW, sz(tMax), 1.25) > titleH ? Math.min(tMax, AT_TITLE.step1 || 24) : tMax);
469
508
  s.addText(title, { x: H.x, y: eyebrow ? H.titleY : 0.6, w: H.w, h: Math.max(0.6, titleH),
470
509
  fontFace: STYLE.fontDisplay, fontSize: tSize, bold: true, color: STYLE.ink, fit: 'shrink' });
471
510
  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' } });
@@ -508,7 +547,7 @@ function isRec(v) { return !!v && typeof v === 'object' && !Array.isArray(v); }
508
547
  pointKV: [k,v] | {k|t, v|d} | str(对齐 pts_list)
509
548
  metricKV: [v,k] | {v|value, k}(对齐 metrics_row)
510
549
  bulletKV: [k,a,t] | {k,a,t}(对齐 r_bullet;缺省保持 undefined 以便调用方判空)
511
- phaseKV: [label,name,desc,state] | {label,name,d,s}(对齐 r_timeline)
550
+ phaseKV: [label,name,desc,state,icon?] | {label,name,d,s,icon?}(对齐 r_timeline;icon 为变体 timeline 节点图标)
512
551
  paraKV: [title,text] | str(对齐 r_twocol;str 为无加粗标题的正文段) */
513
552
  function pointKV(p) {
514
553
  if (Array.isArray(p)) return [String(p[0] == null ? '' : p[0]), String(p[1] == null ? '' : p[1])];
@@ -526,9 +565,10 @@ function bulletKV(it) {
526
565
  return [String(it == null ? '' : it), undefined, undefined];
527
566
  }
528
567
  function phaseKV(ph) {
529
- if (Array.isArray(ph)) return [ph[0] || '', ph[1] || '', ph[2] || '', ph[3] || ''];
530
- if (isRec(ph)) return [ph.label || '', ph.name || '', ph.d || '', ph.s || ''];
531
- return [String(ph == null ? '' : ph), '', '', ''];
568
+ // 第 5 元 = icon(变体 timeline 的节点图标;缺省 '',渲染回落圆点)
569
+ if (Array.isArray(ph)) return [ph[0] || '', ph[1] || '', ph[2] || '', ph[3] || '', ph[4] || ''];
570
+ if (isRec(ph)) return [ph.label || '', ph.name || '', ph.d || '', ph.s || '', ph.icon || ''];
571
+ return [String(ph == null ? '' : ph), '', '', '', ''];
532
572
  }
533
573
  function paraKV(p) {
534
574
  if (Array.isArray(p)) return [p[0] || '', p[1] || ''];
@@ -835,13 +875,30 @@ function dataTableRows(c) {
835
875
  function dataTableText(c) { return dataTableRows(c).map(r => r.join(' | ')).join('\n'); }
836
876
 
837
877
  /* 原生图表基础选项(位置由调用点附加) */
878
+ /* 数据标签小数位:按模型数值实际小数位(上限 2 位)生成 format code。
879
+ 原 '0"单位"' 会把 1.2 显示成 1(像素级数值失真);无单位且无小数时保持 General。 */
880
+ function chartDecimals(c) {
881
+ let d = 0;
882
+ const scan = (v) => {
883
+ if (typeof v === 'number' && isFinite(v)) {
884
+ const s = String(v), i = s.indexOf('.');
885
+ if (i >= 0) d = Math.max(d, Math.min(2, s.length - i - 1));
886
+ }
887
+ };
888
+ (c.values || []).forEach(scan);
889
+ (c.series || []).forEach(se => (se.values || []).forEach(scan));
890
+ (c.points || []).forEach(p => { if (Array.isArray(p)) p.forEach(scan); else scan(p); });
891
+ (c.start || []).forEach(scan); (c.target || []).forEach(scan);
892
+ return d;
893
+ }
838
894
  function chartBaseOpts(c, colors, multi, kind) {
839
895
  const isLine = (kind === 'line' || kind === 'area');
896
+ const _dec = chartDecimals(c), _ds = _dec ? '.' + '0'.repeat(_dec) : '';
840
897
  const opts = {
841
898
  showValue: !isLine && !multi,
842
899
  dataLabelPosition: 'outEnd',
843
900
  dataLabelColor: STYLE.body, dataLabelFontSize: sz(11.5), dataLabelFontFace: STYLE.font,
844
- dataLabelFormatCode: c.unit ? '0"' + c.unit + '"' : 'General',
901
+ dataLabelFormatCode: c.unit ? '0' + _ds + '"' + c.unit + '"' : (_dec ? '0' + _ds : 'General'),
845
902
  catAxisLabelColor: STYLE.faint, catAxisLabelFontSize: sz(10.5), catAxisLabelFontFace: STYLE.font,
846
903
  valAxisHidden: true, valAxisLineShow: false, catAxisLineShow: false,
847
904
  catGridLine: { style: 'none' }, valGridLine: { style: 'none' },
@@ -919,7 +976,19 @@ function nativeChart(s, c, x, y, w, h, dcols) {
919
976
  }
920
977
  const ctype = kind === 'line' ? pptx.ChartType.line
921
978
  : kind === 'area' ? pptx.ChartType.area : pptx.ChartType.bar;
922
- chartTry(s, ctype, chartSeries(c), opts, c);
979
+ let cdata = chartSeries(c);
980
+ if (t === 'hbar') {
981
+ /* 条形图(barDir=bar)类目轴自下而上绘制:反转数据使首项居顶,
982
+ 与 HTML svg-hbar 及 A 通道 hbarRows 同序(标签-数值配对不变) */
983
+ cdata = cdata.map(se => ({ name: se.name,
984
+ labels: (se.labels || []).slice().reverse(), values: (se.values || []).slice().reverse() }));
985
+ /* 单系列时颜色按数据点分配:颜色跟随数据走,与 A 通道 dcols[j%5] 同归属 */
986
+ if (!multi && colors && colors.length) {
987
+ const _n = Math.max(1, (cdata[0].values || []).length);
988
+ opts.chartColors = (cdata[0].values || []).map((_, i) => colors[(_n - 1 - i) % colors.length]);
989
+ }
990
+ }
991
+ chartTry(s, ctype, cdata, opts, c);
923
992
  }
924
993
  /* 散点(X/Y 双系列)/ 气泡({x,y,w} 单系列);labels 解析为 x,values 为 y */
925
994
  function xyChart(s, c, pos, kind, colors) {
@@ -1440,10 +1509,14 @@ function ribbonRects(s, pts, color) {
1440
1509
  const ca = (a[1] + a[2]) / 2, cb = (b[1] + b[2]) / 2;
1441
1510
  const dx = b[0] - a[0], dy = cb - ca;
1442
1511
  const len = Math.sqrt(dx * dx + dy * dy);
1443
- const th = Math.max(0.02, ((a[2] - a[1]) + (b[2] - b[1])) / 2);
1512
+ /* 厚度取绝对值:sankey 按 [x,上,下]、streamgraph 按 [x,下,上] 传参(y 向下),
1513
+ 符号不应影响厚度(原 (a[2]-a[1]) 在 streamgraph 为负 → th 恒 0.02,流带变细线) */
1514
+ const th = Math.max(0.02, Math.abs(((a[1] - a[2]) + (b[1] - b[2])) / 2));
1444
1515
  const ang = Math.atan2(dy, dx) * 180 / Math.PI;
1445
- s.addShape('rect', { x: (a[0] + b[0]) / 2 - len / 2, y: (ca + cb) / 2 - th / 2,
1446
- w: Math.max(0.02, len), h: th, rotate: Math.round(ang * 10) / 10,
1516
+ /* 相邻 quad 沿走向微叠 0.02in:消除拼接处的抗锯齿发丝缝(streamgraph/sankey 像素级瑕疵) */
1517
+ const ov = 0.02;
1518
+ s.addShape('rect', { x: (a[0] + b[0]) / 2 - len / 2 - ov, y: (ca + cb) / 2 - th / 2,
1519
+ w: Math.max(0.02, len) + ov * 2, h: th, rotate: Math.round(ang * 10) / 10,
1447
1520
  fill: { color: color }, line: { type: 'none' } });
1448
1521
  }
1449
1522
  }
@@ -1461,7 +1534,8 @@ function infoTableRows(sec) {
1461
1534
  if (t === 'network') return [['节点', '名称']].concat((sec.nodes || []).map(n => [String(n[0]), String(n[1])]));
1462
1535
  if (t === 'marimekko') {
1463
1536
  const head = ['列', '总量' + u].concat((sec.legend || []).map(String));
1464
- return [head].concat((sec.cols || []).map((c, i) => [String(c[0]), String(c[1])].concat(((sec.cells || [])[i] || []).map(String))));
1537
+ return [head].concat((sec.cols || []).map((c, i) => [String(c[0]), String(c[1])]
1538
+ .concat(marimekkoSegs((sec.cells || [])[i], 99).map(sg => sg.label != null ? sg.label + ' ' + sg.value : String(sg.value)))));
1465
1539
  }
1466
1540
  if (t === 'streamgraph') {
1467
1541
  return [['系列'].concat((sec.labels || []).map(String))]
@@ -1627,7 +1701,11 @@ function infoBoxplot(s, sec, y0, y1) {
1627
1701
  fontFace: STYLE.font, fontSize: sz(10.5), color: STYLE.body });
1628
1702
  s.addText(String(md) + (sec.unit || ''), { x: cx - step / 2, y: yFor(md) - 0.42, w: step, h: 0.26, align: 'center',
1629
1703
  fontFace: STYLE.font, fontSize: sz(10), bold: true, color: STYLE.accent });
1630
- s.addText(String(mn) + (sec.unit || ''), { x: cx - step / 2, y: yFor(mn) + 0.03, w: step, h: 0.24, align: 'center',
1704
+ /* min 标签默认在须线帽下方;mn 贴轴时会被组标签带(P.y1-0.42 起)压住 →
1705
+ 翻到帽上方,同时避开箱体下沿(几何铁律②:禁叠印) */
1706
+ let mnY = yFor(mn) + 0.03;
1707
+ if (mnY + 0.24 > P.y1 - 0.42) mnY = Math.min(yFor(mn) - 0.28, yFor(q1) - 0.30);
1708
+ s.addText(String(mn) + (sec.unit || ''), { x: cx - step / 2, y: mnY, w: step, h: 0.24, align: 'center',
1631
1709
  fontFace: STYLE.font, fontSize: sz(9.5), color: STYLE.faint });
1632
1710
  s.addText(String(mx) + (sec.unit || ''), { x: cx - step / 2, y: yFor(mx) - 0.28, w: step, h: 0.24, align: 'center',
1633
1711
  fontFace: STYLE.font, fontSize: sz(9.5), color: STYLE.faint });
@@ -1670,6 +1748,15 @@ function infoNetwork(s, sec, y0, y1) {
1670
1748
  align: outward ? 'left' : 'right', valign: 'middle', fontFace: STYLE.font, fontSize: sz(10), color: STYLE.body });
1671
1749
  });
1672
1750
  }
1751
+ /* marimekko 单元格归一化:裸数值 [70,25,5] 或 [标签,数值] 对 [["<50ms",70],…]
1752
+ (与 cols/flows/groups 的 [标签,值] 惯例一致);非数值 → 0,零值段过滤 */
1753
+ function marimekkoSegs(arr, maxSegs) {
1754
+ return ((arr || []).slice(0, maxSegs == null ? 99 : maxSegs)).map(el => {
1755
+ const pair = Array.isArray(el);
1756
+ const v = Number(pair ? el[1] : el);
1757
+ return { label: pair ? String(el[0]) : null, value: isFinite(v) ? v : 0 };
1758
+ }).filter(sg => sg.value > 0);
1759
+ }
1673
1760
  /* 马赛克图:列宽按列总量、列高按 100% 构成的双重编码 */
1674
1761
  function infoMarimekko(s, sec, y0, y1) {
1675
1762
  const G = PT.marimekko;
@@ -1690,14 +1777,17 @@ function infoMarimekko(s, sec, y0, y1) {
1690
1777
  let cx0 = MX;
1691
1778
  cols.forEach((c, i) => {
1692
1779
  const cw = Math.max(0.2, (Number(c[1]) || 0) / total * availW);
1693
- const shares = (cells[i] || []).map(Number).slice(0, G.maxSegs);
1694
- const sum = shares.reduce((a, b) => a + b, 0) || 1;
1780
+ const segs = marimekkoSegs(cells[i], G.maxSegs);
1781
+ const sum = segs.reduce((a, sg) => a + sg.value, 0) || 1;
1695
1782
  let cy0 = P.y0;
1696
- shares.forEach((v, k) => {
1783
+ segs.forEach((sg, k) => {
1784
+ const v = sg.value;
1697
1785
  const sh = Math.max(0.02, v / sum * plotH);
1698
1786
  s.addShape('rect', { x: cx0, y: cy0, w: cw, h: sh,
1699
1787
  fill: { color: palette[k % palette.length] }, line: { color: STYLE.bg, width: 0.75 } });
1700
- s.addText(String(v) + (sec.unit || ''), { x: cx0, y: cy0 + sh / 2 - 0.13, w: cw, h: 0.26, align: 'center',
1788
+ /* 有标签用标签("<50ms"),裸数值沿用旧行为(值 + 单位) */
1789
+ const segLabel = sg.label != null ? sg.label : (String(v) + (sec.unit || ''));
1790
+ s.addText(segLabel, { x: cx0, y: cy0 + sh / 2 - 0.13, w: cw, h: 0.26, align: 'center',
1701
1791
  fontFace: STYLE.font, fontSize: sz(10), bold: true, fit: 'shrink',
1702
1792
  color: k === 0 ? STYLE.onAccent : STYLE.ink });
1703
1793
  cy0 += sh;
@@ -1779,7 +1869,7 @@ function addTable(s, tbl, x, y, w, availH, opts) {
1779
1869
  const minRowH = opts.minRowH != null ? opts.minRowH : PT.table.rowHMin;
1780
1870
  const headRowH = opts.headRowH != null ? opts.headRowH : (PT.table.headRowH || minRowH);
1781
1871
  /* 行数超过容量时优先压行高到 hardFloor,仍放不下则整体缩放到 availH(禁溢出) */
1782
- const hardFloor = Math.min(minRowH, 0.22);
1872
+ const hardFloor = Math.min(minRowH, AT_TABLE.rowFloorIn != null ? AT_TABLE.rowFloorIn : 0.22);
1783
1873
  const ideal = availH / Math.max(1, n);
1784
1874
  let rowH = Math.min(maxRowH, Math.max(hardFloor, ideal));
1785
1875
  if (n * rowH > availH + 0.01) {
@@ -1790,11 +1880,46 @@ function addTable(s, tbl, x, y, w, availH, opts) {
1790
1880
  }
1791
1881
  /* 行多则同步收字号(research 密排允许更小;句子单元格仍走语义字号门禁) */
1792
1882
  const dense = rowH < 0.42;
1793
- const headSz = sz(dense ? 11 : 13), bodySz = sz(dense ? 10 : 12.5);
1883
+ const headSz = sz(dense ? 11 : Math.min(13, AT_TABLE.headMax || 12));
1884
+ const bodySz = sz(dense ? 10 : Math.min(12.5, AT_TABLE.cellMax || 13));
1794
1885
  const hRowH = Math.min(headRowH, Math.max(hardFloor, rowH * 1.15));
1886
+ /* 内容高度预估:逐格用 estTextH 估算,超出行高则全表收字号 ——
1887
+ 禁依赖 fit:'shrink' 或渲染引擎自动撑高(PowerPoint/LibreOffice 会按内容增高行,压住脚注) */
1888
+ const ncolsT = Math.max(head.length, 1, ...rowsIn.map(r => r.length));
1889
+ const effColW = (Array.isArray(tbl.colW) && tbl.colW.length >= ncolsT) ? tbl.colW.slice(0, ncolsT)
1890
+ : Array.from({ length: ncolsT }, () => w / ncolsT);
1891
+ const cellPadIn = (dense ? 2 : 5) / 72;
1892
+ /* 长表格单元格适配(adaptiveText.table 单源):逐格估算,超出行高则全表收字号;
1893
+ floor 取模式下限与表格下限之大者——表格不得突破模式可读性底线 */
1894
+ const fitCellSz = (text, cw, availHh, maxSz) => fitFont([String(text == null ? '' : text)],
1895
+ Math.max(0.5, cw - cellPadIn * 2), Math.max(0.2, availHh - cellPadIn * 2),
1896
+ { max: maxSz, maxShrinkSteps: 6, floor: Math.max(modeFloorPt(), AT_TABLE.cellFloor || 0) });
1897
+ let bodySzF = bodySz, headSzF = headSz;
1898
+ rowsIn.forEach(r => r.forEach((c, j) => {
1899
+ const f = fitCellSz(c, effColW[j] != null ? effColW[j] : effColW[0], rowH, bodySz);
1900
+ if (f < bodySzF) bodySzF = f;
1901
+ }));
1902
+ head.forEach((h, j) => {
1903
+ const f = fitCellSz(h, effColW[j] != null ? effColW[j] : effColW[0], hRowH, headSz);
1904
+ if (f < headSzF) headSzF = f;
1905
+ });
1906
+ /* 字号触底仍装不下 → 显式告警(内容超承载,建议拆页/精简;行高不再自动撑高) */
1907
+ const stillOver = (text, cw, availHh, f) =>
1908
+ estTextH(String(text == null ? '' : text), Math.max(0.5, cw - cellPadIn * 2), f, TM_LINE) >
1909
+ Math.max(0.2, availHh - cellPadIn * 2);
1910
+ let overCells = 0;
1911
+ rowsIn.forEach(r => r.forEach((c, j) => {
1912
+ if (stillOver(c, effColW[j] != null ? effColW[j] : effColW[0], rowH, bodySzF)) overCells++;
1913
+ }));
1914
+ head.forEach((h, j) => {
1915
+ if (stillOver(h, effColW[j] != null ? effColW[j] : effColW[0], hRowH, headSzF)) overCells++;
1916
+ });
1917
+ if (overCells > 0 && typeof console !== 'undefined')
1918
+ console.warn(`addTable: ${overCells} 个单元格在字号下限(正文 ${bodySzF}pt/表头 ${headSzF}pt)仍超出分配行高 —— ` +
1919
+ '内容超承载,建议拆页/精简文案(行高已锁定,防压脚注)');
1795
1920
  const rows = [head.map(h => ({ text: h, options: { bold: true, color: STYLE.onAccent,
1796
- fill: { color: STYLE.accent }, fontSize: headSz, fontFace: STYLE.font, valign: 'middle' } }))]
1797
- .concat(rowsIn.map(r => r.map(c => ({ text: String(c), options: { fontSize: bodySz,
1921
+ fill: { color: STYLE.accent }, fontSize: headSzF, fontFace: STYLE.font, valign: 'middle' } }))]
1922
+ .concat(rowsIn.map(r => r.map(c => ({ text: String(c), options: { fontSize: bodySzF,
1798
1923
  color: STYLE.body, fontFace: STYLE.font, valign: 'middle', fill: { color: STYLE.bg } } }))));
1799
1924
  /* rowH 数组:表头略高、正文均分剩余(pptxgenjs 支持逐行 rowH) */
1800
1925
  const rowHeights = [hRowH].concat(rowsIn.map(() => rowH));
@@ -1885,7 +2010,7 @@ CONTENT.sections.forEach((sec) => {
1885
2010
 
1886
2011
  /* 正文区起点 / 终点(含 lead 与 so-what / 来源行 / 待核实条占位) */
1887
2012
  /* 非 exhibit 页型也可带 Exhibit 编号(帧头徽标;正文区下移让位,避免与主图重叠) */
1888
- const exBadge = (type !== 'exhibit' && sec.exhibitNo) ? String(sec.exhibitNo).toUpperCase() : null;
2013
+ const exBadge = (type !== 'exhibit' && sec.exhibitNo) ? exhibitBadge(sec.exhibitNo) : null;
1889
2014
  const bodyY0 = sec.lead ? PT.common.bodyYWithLead : CONTENT_TOP;
1890
2015
  const bodyY = exBadge ? bodyY0 + 0.42 : bodyY0;
1891
2016
  if (exBadge) {
@@ -1896,7 +2021,7 @@ CONTENT.sections.forEach((sec) => {
1896
2021
  const _F = PT.flagbar || { hdH: 0.26, rowH: 0.24, maxRows: 3, gap: 0.12 };
1897
2022
  const flagH = flagItems.length
1898
2023
  ? (_F.hdH + Math.min(_F.maxRows, flagItems.length) * _F.rowH + 0.06) : 0;
1899
- let bodyBottom = chartBottom(!!sec.soWhat, !!sec.footnote);
2024
+ let bodyBottom = chartBottom(!!sec.soWhat, !!(sec.footnote || sec.note));
1900
2025
  let flagY = 0;
1901
2026
  if (flagH) {
1902
2027
  /* 待核实条底对齐 contentBottom;与来源行 / so-what 同页时上移让位(不重叠) */
@@ -1943,7 +2068,7 @@ CONTENT.sections.forEach((sec) => {
1943
2068
  /* R6:竖分隔线高度取原设计值与注释带感知下界的较小者——写死 3.1 会在
1944
2069
  so-what/来源行并存时越过注释带顶(ANNOTATION_BAND_OVERLAP),而无注释时
1945
2070
  不得比原设计更长(回归:smoke business-blue fixture slide 7)。 */
1946
- const kpiBottom = chartBottom(!!sec.soWhat, !!sec.footnote) - 0.05;
2071
+ const kpiBottom = chartBottom(!!sec.soWhat, !!(sec.footnote || sec.note)) - 0.05;
1947
2072
  s.addShape('rect', { x: kdivX, y: K.heroY + 0.15, w: 0.025,
1948
2073
  h: Math.min(3.1, Math.max(0.6, kpiBottom - (K.heroY + 0.15))),
1949
2074
  fill: { color: STYLE.line }, line: { type: 'none' } });
@@ -1952,9 +2077,15 @@ CONTENT.sections.forEach((sec) => {
1952
2077
  { x: kdivX + 0.35, y: K.metricY0, w: CW * (1 - K.dividerX) - 0.5, h: CONTENT_BOTTOM - K.metricY0 };
1953
2078
  const kAvail = Math.max(0.8, kpiBottom - mreg.y);
1954
2079
  const kmets = sec.metrics || [];
1955
- const kCap = Math.max(1, Math.floor(kAvail / K.metricRowH));
2080
+ /* 容量按最小允许行高判断,行高按全部指标自适应压缩 ——
2081
+ 原 kCap 用默认行高先截断,导致第 N+1 个指标被静默丢弃(禁静默丢数据) */
2082
+ const K_MINROWH = 0.42;
2083
+ const kCap = Math.max(1, Math.floor(kAvail / K_MINROWH));
1956
2084
  const kRows = Math.min(kmets.length, kCap);
1957
- const kRowH = fitRowH(kAvail, kRows, K.metricRowH, 0.42);
2085
+ if (kRows < kmets.length)
2086
+ console.warn(`[build_pptx] 警告:KPI 页「${sec.title || ''}」支撑指标 ${kmets.length} 个超出容量(${kAvail.toFixed(2)}in),` +
2087
+ `仅渲染前 ${kRows} 个 —— 建议拆页`);
2088
+ const kRowH = fitRowH(kAvail, kRows, K.metricRowH, K_MINROWH);
1958
2089
  kmets.slice(0, kRows).map(metricKV).forEach((m, i) => {
1959
2090
  const ky = mreg.y + i * kRowH;
1960
2091
  s.addText(m[0], { x: mreg.x, y: ky, w: mreg.w, h: Math.min(0.55, kRowH * 0.62),
@@ -1963,11 +2094,13 @@ CONTENT.sections.forEach((sec) => {
1963
2094
  h: Math.max(0.24, kRowH - Math.min(0.55, kRowH * 0.62)), fontFace: STYLE.font,
1964
2095
  fontSize: sz(10.5), color: STYLE.faint });
1965
2096
  });
1966
- /* 要点:指标行下方续排,k/v 双色(与 bar 侧栏同口径);无位则静默舍去 */
2097
+ /* 要点:指标行下方续排,k/v 双色(与 bar 侧栏同口径);容量不足时显式告警(禁静默丢数据) */
1967
2098
  const kpts = (sec.points || []).filter(Boolean);
1968
2099
  const kpRowH = 0.5;
1969
2100
  const kpY0 = mreg.y + kRows * kRowH + 0.12;
1970
2101
  const kpCap = Math.max(0, Math.floor((kpiBottom - kpY0) / kpRowH));
2102
+ if (kpts.length > kpCap)
2103
+ console.warn(`[build_pptx] 警告:KPI 页「${sec.title || ''}」要点 ${kpts.length} 条超出容量,仅渲染前 ${kpCap} 条 —— 建议拆页`);
1971
2104
  kpts.slice(0, kpCap).forEach((p, i) => {
1972
2105
  const kv = pointKV(p), kk = kv[0], vv = kv[1];
1973
2106
  const ky = kpY0 + i * kpRowH;
@@ -2225,10 +2358,24 @@ CONTENT.sections.forEach((sec) => {
2225
2358
  s.addShape('roundRect', { x, y, w, h: rowH, rectRadius: 0.07,
2226
2359
  fill: { color: acc ? STYLE.soft : STYLE.surface },
2227
2360
  line: acc ? { type: 'none' } : { color: STYLE.line, width: 0.75 } });
2228
- s.addText(lt, { x: x + 0.22, y, w: Math.min(P.labelW, w - 0.44), h: rowH, valign: 'middle',
2229
- fontFace: STYLE.font, fontSize: sz(13.5), bold: true, color: acc ? STYLE.accent : STYLE.ink });
2230
- 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,
2231
- valign: 'middle', fontFace: STYLE.font, fontSize: sz(11), color: STYLE.faint });
2361
+ /* 标题列宽按层内宽比例分配(labelFrac≈0.3);顶层窄时固定 3.2in 会把说明压成竖列叠印 →
2362
+ 改为标题上、说明下的单框混排(与 HTML .pyr__t/.pyr__d 同语义) */
2363
+ const lwP = Math.min(P.labelW, (w - 0.44) * (P.labelFrac == null ? 0.32 : P.labelFrac));
2364
+ const descW = w - 0.44 - lwP;
2365
+ /* 窄层判定:说明列 < 1.2in,或标题在标题列内放不下单行(会中部换行)→
2366
+ 标题上、说明下单框混排(与 HTML 窄屏 .pyr__lvl 单列同语义) */
2367
+ const narrowP = descW < 1.2 || (ld && estLines(lt, lwP, 13.5) > 1) || (!ld && estLines(lt, w - 0.44, 13.5) > 1);
2368
+ if (narrowP) {
2369
+ s.addText(lt, { x: x + 0.22, y: y + 0.03, w: w - 0.44, h: 0.30, valign: 'middle',
2370
+ fontFace: STYLE.font, fontSize: sz(12.5), bold: true, color: acc ? STYLE.accent : STYLE.ink, fit: 'shrink' });
2371
+ s.addText(ld, { x: x + 0.22, y: y + 0.33, w: w - 0.44, h: Math.max(0.2, rowH - 0.36), valign: 'middle',
2372
+ fontFace: STYLE.font, fontSize: sz(10.5), color: STYLE.faint, fit: 'shrink' });
2373
+ } else {
2374
+ s.addText(lt, { x: x + 0.22, y, w: lwP, h: rowH, valign: 'middle',
2375
+ fontFace: STYLE.font, fontSize: sz(13.5), bold: true, color: acc ? STYLE.accent : STYLE.ink, fit: 'shrink' });
2376
+ if (ld) s.addText(ld, { x: x + 0.22 + lwP, y, w: descW, h: rowH,
2377
+ valign: 'middle', fontFace: STYLE.font, fontSize: sz(11), color: STYLE.faint, fit: 'shrink' });
2378
+ }
2232
2379
  });
2233
2380
  } else if (type === 'steps') {
2234
2381
  /* 步骤条:N 步横排 + 箭头;超 maxPerRow 折行;groups 分组标签 */
@@ -2297,8 +2444,14 @@ CONTENT.sections.forEach((sec) => {
2297
2444
  ps.forEach((p, i) => {
2298
2445
  const x = treg.x + 0.2 + i * stepW;
2299
2446
  const on = p[3] === 'done' || p[3] === 'now';
2300
- s.addShape('ellipse', { x: x - T.dotR / 2, y: T.dotY, w: T.dotR, h: T.dotR,
2301
- fill: { color: on ? STYLE.accent : STYLE.line }, line: { type: 'none' } });
2447
+ // 变体 timeline:节点图标(icon:<name>);无图标回落圆点
2448
+ const wantIcon = (sec.variant === 'timeline') && p[4];
2449
+ if (wantIcon) {
2450
+ addIcon(s, p[4], x - 0.13, T.dotY - 0.06, 0.26, on ? STYLE.accent : STYLE.body);
2451
+ } else {
2452
+ s.addShape('ellipse', { x: x - T.dotR / 2, y: T.dotY, w: T.dotR, h: T.dotR,
2453
+ fill: { color: on ? STYLE.accent : STYLE.line }, line: { type: 'none' } });
2454
+ }
2302
2455
  s.addText(p[0], { x: x - 0.1, y: T.labelY, w: stepW - 0.2, h: 0.4, fontFace: STYLE.font,
2303
2456
  fontSize: sz(12), bold: true, color: STYLE.accent, charSpacing: 1 });
2304
2457
  s.addText(p[1], { x: x - 0.1, y: T.nameY, w: stepW - 0.2, h: 0.5, fontFace: STYLE.fontDisplay,
@@ -2354,11 +2507,25 @@ CONTENT.sections.forEach((sec) => {
2354
2507
  const fz = fitFont(items.length ? items : ['x'.repeat(longest)], g.w, availH, { max: 13.5, gapFactor: 1.2 });
2355
2508
  groups.forEach((col, ci) => {
2356
2509
  if (!col.length) return;
2510
+ const colX = treg.x + ci * g.step;
2511
+ // 变体 2-col-feature:首栏特性栏(accent 左线 + 标题放大一档)
2512
+ const isFeature = (sec.variant === '2-col-feature') && ci === 0 && nCol === 2;
2513
+ const fx = isFeature ? colX + 0.14 : colX;
2514
+ const fw = isFeature ? g.w - 0.14 : g.w;
2515
+ if (isFeature) {
2516
+ // 特性栏 accent 左线:高度按首段实际文本估算(不用整栏 availH,避免无墨水区压进注释带触发 severe 门禁)
2517
+ const p0 = col[0] || ['', ''];
2518
+ const barH = Math.min(availH,
2519
+ estTextH(p0[0] + ' ', fw, sz(fz + 1), 1.5) +
2520
+ estTextH(p0[1] || '', fw, sz(fz - 1), 1.5) + sz(7) / 72 + 0.06);
2521
+ s.addShape('rect', { x: colX, y: treg.y, w: 0.045, h: Math.max(0.2, barH),
2522
+ fill: { color: STYLE.accent }, line: { type: 'none' }, objectName: 'feature:bar' });
2523
+ }
2357
2524
  const runs = col.map(p => ([
2358
- { text: p[0] + ' ', options: { fontSize: sz(fz), bold: true, color: STYLE.ink } },
2525
+ { text: p[0] + ' ', options: { fontSize: sz(isFeature ? fz + 1 : fz), bold: true, color: STYLE.ink } },
2359
2526
  { text: p[1] + '\n', options: { fontSize: sz(fz - 1), color: STYLE.body, breakLine: true } },
2360
2527
  ])).flat();
2361
- s.addText(runs, { x: treg.x + ci * g.step, y: treg.y, w: g.w, h: availH, fontFace: STYLE.font,
2528
+ s.addText(runs, { x: fx, y: treg.y, w: fw, h: availH, fontFace: STYLE.font,
2362
2529
  valign: 'top', lineSpacing: sz(fz * 1.5), paraSpaceBefore: sz(7) });
2363
2530
  });
2364
2531
  } else if (type === 'halftable') {
@@ -2452,8 +2619,10 @@ CONTENT.sections.forEach((sec) => {
2452
2619
  : (Array.isArray(cd)
2453
2620
  ? { title: cd[0] || '', points: cd.length > 1 ? [['', cd[1]]] : [] }
2454
2621
  : { title: String(cd == null ? '' : cd), points: [] }));
2455
- const c = Math.min(sec.columns || 3, cds.length || 1);
2456
- const r = Math.ceil((cds.length || 1) / c);
2622
+ const isBento = (sec.variant === 'bento-grid') && cds.length >= 3;
2623
+ const c = isBento ? 3 : Math.min(sec.columns || 3, cds.length || 1);
2624
+ // bento-grid:首卡占 2 个槽位(跨 2 列),其余顺排;槽位数 = 卡数 + 1
2625
+ const r = isBento ? Math.ceil((cds.length + 1) / c) : Math.ceil((cds.length || 1) / c);
2457
2626
  const gw = (creg.w - (c - 1) * cdC.gap) / c;
2458
2627
  const gh = fitRowH(creg.h - (r - 1) * cdC.gap, r, cdC.maxH, 0.6);
2459
2628
  /* points 文本归一化(scaffold v9 契约 {title, points:[[k,v]]} 兼容三种形态) */
@@ -2478,21 +2647,28 @@ CONTENT.sections.forEach((sec) => {
2478
2647
  { max: 12, lineFactor: 1.5, gapFactor: 0 }))) : 12;
2479
2648
  const ptsFz = sz(ptsFzB);
2480
2649
  cds.forEach((cd, i) => {
2481
- const col = i % c, row = Math.floor(i / c);
2650
+ let col = i % c, row = Math.floor(i / c), span = 1;
2651
+ if (isBento) {
2652
+ // 首卡跨 2 列:槽位 = i===0 ? [0,1] : i+1
2653
+ const slot = i === 0 ? 0 : i + 1;
2654
+ if (i === 0) span = 2;
2655
+ col = slot % c; row = Math.floor(slot / c);
2656
+ }
2657
+ const cw = span * gw + (span - 1) * cdC.gap;
2482
2658
  const x = creg.x + col * (gw + cdC.gap), y = creg.y + row * (gh + cdC.gap);
2483
- s.addShape('roundRect', { x, y, w: gw, h: gh, rectRadius: 0.08,
2659
+ s.addShape('roundRect', { x, y, w: cw, h: gh, rectRadius: 0.08,
2484
2660
  fill: { color: STYLE.surface }, line: { color: STYLE.line, width: 0.75 } });
2485
2661
  /* 卡片头路标:真导出语义图标(SVG→PNG),与 HTML .card__ico 同源;无资产时回落 accent 方块 */
2486
2662
  const ico = 0.18;
2487
2663
  const icoName = cd.icon || ICON_LIB.pickIconName(cd.title || '', i);
2488
2664
  addIcon(s, icoName, x + 0.18, y + 0.2, ico, STYLE.accent);
2489
- 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,
2665
+ s.addText(cd.title || '', { x: x + 0.18 + ico + 0.1, y: y + 0.14, w: cw - 0.36 - ico - 0.1, h: cdC.titleH, fontFace: STYLE.font,
2490
2666
  fontSize: sz(15), bold: true, color: STYLE.ink, fit: 'shrink' });
2491
2667
  /* points 兼容:[[k,v]…] | [str…] | [{t,d}…](scaffold v9 契约 {title, points:[[k,v]]}) */
2492
2668
  const ptLines = (cd.points || []).map(cardPtText);
2493
2669
  s.addText(ptLines.map(t => ({ text: t + '\n',
2494
2670
  options: { fontSize: ptsFz, color: STYLE.body, fontFace: STYLE.font, breakLine: true } })),
2495
- { x: x + 0.18, y: y + 0.14 + cdC.titleH + 0.06, w: gw - 0.36, h: ptsBoxH,
2671
+ { x: x + 0.18, y: y + 0.14 + cdC.titleH + 0.06, w: cw - 0.36, h: ptsBoxH,
2496
2672
  fontFace: STYLE.font, valign: 'top', lineSpacing: ptsFz * 1.5 });
2497
2673
  });
2498
2674
  } else if (type === 'split') {
@@ -2644,12 +2820,12 @@ CONTENT.sections.forEach((sec) => {
2644
2820
  const reg = REGIONS.regionOf('exhibit', 'primary', { withNote: !!(sec.soWhat || sec.footnote) })
2645
2821
  || { x: MX, y: PT.exhibit.chartY, w: CW, h: PT.exhibit.chartH };
2646
2822
  if (sec.exhibitNo) {
2647
- s.addText(String(sec.exhibitNo).toUpperCase(), { x: MX, y: PT.exhibit.badgeY, w: CW, h: 0.35,
2823
+ s.addText(exhibitBadge(sec.exhibitNo), { x: MX, y: PT.exhibit.badgeY, w: CW, h: 0.35,
2648
2824
  fontFace: STYLE.font, fontSize: sz(11.5), bold: true, color: STYLE.accent, charSpacing: 2 });
2649
2825
  }
2650
2826
  if (ec.labels && ec.values) {
2651
2827
  const edcols = ec.colors || dataColors(styleArg);
2652
- const h = Math.max(1.2, chartBottom(!!sec.soWhat, !!sec.footnote) - reg.y - 0.05);
2828
+ const h = Math.max(1.2, chartBottom(!!sec.soWhat, !!(sec.footnote || sec.note)) - reg.y - 0.05);
2653
2829
  if (ec.type === 'hbar') {
2654
2830
  chartBlock(s, ec, reg.x, reg.y, reg.w, h, edcols);
2655
2831
  } else {
@@ -2681,8 +2857,10 @@ CONTENT.sections.forEach((sec) => {
2681
2857
  : CW - 0.4;
2682
2858
  const avail = bodyBottom - bodyY;
2683
2859
  const rowH = fitRowH(avail, pts.length, P.rowH, 0.3);
2860
+ /* 长列表收紧(adaptiveText.list 单源):条目多时降字号上限/段间距/行距,仍装不下由 fitFont 阶梯下探 */
2861
+ const _lo = listFitOpts(pts.length);
2684
2862
  const fzB = fitFont(pts.map(p => (p[0] || '') + (p[1] || '')), textW - 0.32, avail,
2685
- { max: 15, gapFactor: 0.55 });
2863
+ { max: Math.min(15, _lo.max), gapFactor: _lo.gapFactor, lineFactor: _lo.lineFactor });
2686
2864
  const fz = sz(fzB), fzSm = sz(snapFont(fzB - 1));
2687
2865
  pts.forEach((p, i) => {
2688
2866
  const y = bodyY + i * rowH;
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """TopPPT HTML · 图标包校验(assets/icons/)
4
+
5
+ 检查:
6
+ 1. 每个 *.svg 合法 XML;根为 svg;viewBox 恰为 "0 0 24 24"
7
+ 2. 描边风格统一:fill="none"、stroke="currentColor"、stroke-width="1.8"、
8
+ stroke-linecap/linejoin="round"(同家族铁律)
9
+ 3. 只含允许元素(path/circle/rect/ellipse/line/polyline/polygon),无 <text>/<image>,
10
+ 无 fill="#..." 彩色填充(禁三方彩色标),无外链
11
+ 4. index.json 一致性:icons 键 ↔ <name>.svg 文件一一对应;defaults 5~8 个且都在包内;
12
+ keywords 非空且含自身名
13
+ 5. 每个图标有同名 512px PNG(PPTX 通道预渲染)
14
+
15
+ 用法: python scripts/check_icons.py [--strict]
16
+ 退出码:0=通过;1=有 FAIL;2=仅 WARN(--strict 下也按 1 处理)
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import sys
22
+ import xml.etree.ElementTree as ET
23
+ from pathlib import Path
24
+
25
+ ROOT = Path(__file__).resolve().parent.parent
26
+ ICON_DIR = ROOT / 'assets' / 'icons'
27
+ ALLOWED = {'path', 'circle', 'rect', 'ellipse', 'line', 'polyline', 'polygon'}
28
+
29
+
30
+ def main() -> int:
31
+ strict = '--strict' in sys.argv
32
+ fails: list[str] = []
33
+ warns: list[str] = []
34
+
35
+ idx_path = ICON_DIR / 'index.json'
36
+ if not idx_path.exists():
37
+ print('FAIL: assets/icons/index.json 缺失')
38
+ return 1
39
+ index = json.loads(idx_path.read_text(encoding='utf-8'))
40
+ icons_meta = index.get('icons') or {}
41
+ if index.get('count') != len(icons_meta):
42
+ fails.append(f"index.json count={index.get('count')} 与实际条目 {len(icons_meta)} 不一致")
43
+
44
+ svg_names = {p.stem for p in ICON_DIR.glob('*.svg')}
45
+ meta_names = set(icons_meta)
46
+ for n in sorted(meta_names - svg_names):
47
+ fails.append(f'index.json 有条目但缺文件: {n}.svg')
48
+ for n in sorted(svg_names - meta_names):
49
+ warns.append(f'有 SVG 文件但 index.json 未登记: {n}.svg')
50
+
51
+ defaults = index.get('defaults') or []
52
+ if not 5 <= len(defaults) <= 8:
53
+ fails.append(f'defaults 应 5~8 个,实际 {len(defaults)} 个')
54
+ for d in defaults:
55
+ if d not in icons_meta:
56
+ fails.append(f'default 图标不在包内: {d}')
57
+
58
+ for name, meta in icons_meta.items():
59
+ kws = meta.get('keywords') or []
60
+ if not kws:
61
+ warns.append(f'{name}: keywords 为空')
62
+ elif name not in kws:
63
+ warns.append(f'{name}: keywords 未含自身名')
64
+ png = ICON_DIR / f'{name}.png'
65
+ if not png.exists():
66
+ fails.append(f'缺预渲染 PNG: {name}.png')
67
+ elif png.stat().st_size < 1024:
68
+ warns.append(f'{name}.png 疑似过小({png.stat().st_size}B)')
69
+
70
+ for name in sorted(svg_names):
71
+ p = ICON_DIR / f'{name}.svg'
72
+ try:
73
+ root = ET.fromstring(p.read_text(encoding='utf-8'))
74
+ except ET.ParseError as e:
75
+ fails.append(f'{name}.svg XML 非法: {e}')
76
+ continue
77
+ tag = root.tag.split('}')[-1]
78
+ if tag != 'svg':
79
+ fails.append(f'{name}.svg 根元素不是 svg: {tag}')
80
+ continue
81
+ if root.get('viewBox') != '0 0 24 24':
82
+ fails.append(f"{name}.svg viewBox={root.get('viewBox')!r}(必须 '0 0 24 24')")
83
+ style = {
84
+ 'fill': root.get('fill'), 'stroke': root.get('stroke'),
85
+ 'stroke-width': root.get('stroke-width'),
86
+ 'stroke-linecap': root.get('stroke-linecap'),
87
+ 'stroke-linejoin': root.get('stroke-linejoin'),
88
+ }
89
+ want = {'fill': 'none', 'stroke': 'currentColor', 'stroke-width': '1.8',
90
+ 'stroke-linecap': 'round', 'stroke-linejoin': 'round'}
91
+ if style != want:
92
+ fails.append(f'{name}.svg 风格属性偏离家族规范: {style}')
93
+ for el in root.iter():
94
+ t = el.tag.split('}')[-1]
95
+ if t == 'svg':
96
+ continue
97
+ if t not in ALLOWED:
98
+ fails.append(f'{name}.svg 含非法元素 <{t}>(禁 text/image/彩色填充)')
99
+ for k, v in el.attrib.items():
100
+ if k == 'fill' and v not in ('none',):
101
+ fails.append(f'{name}.svg <{t}> 有彩色填充 fill={v}')
102
+ if isinstance(v, str) and v.startswith('http'):
103
+ fails.append(f'{name}.svg 含外链: {v[:60]}')
104
+
105
+ for w in warns:
106
+ print(f'WARN: {w}')
107
+ for f in fails:
108
+ print(f'FAIL: {f}')
109
+ print(f'[check_icons] {len(svg_names)} svg / {len(icons_meta)} index / '
110
+ f'{len(defaults)} defaults: {"OK" if not fails else "FAIL"}')
111
+ if fails:
112
+ return 1
113
+ if warns and strict:
114
+ return 1
115
+ return 0
116
+
117
+
118
+ if __name__ == '__main__':
119
+ raise SystemExit(main())