@topmindspace/tms-skills 0.3.9 → 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 (161) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.en.md +11 -8
  3. package/README.md +11 -8
  4. package/bin/tms-skills.js +144 -12
  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 +16 -171
  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 +16 -324
  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 +16 -917
  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 +99 -18
  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 +2 -2
  114. package/top-ppt-html/assets/style-gallery.html +1 -1
  115. package/top-ppt-html/assets/templates/architecture.html +15 -3
  116. package/top-ppt-html/assets/templates/engine.css +12 -0
  117. package/top-ppt-html/assets/templates/presentation.html +15 -3
  118. package/top-ppt-html/assets/templates/research.html +15 -3
  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/build_pptx.js +93 -26
  125. package/top-ppt-html/scripts/check_icons.py +119 -0
  126. package/top-ppt-html/scripts/hydrate_charts.py +60 -5
  127. package/top-ppt-html/scripts/icon_lib.js +66 -37
  128. package/top-ppt-html/scripts/layout-constants.json +91 -4
  129. package/top-ppt-html/scripts/make_icon_pack.py +172 -0
  130. package/top-ppt-html/scripts/model-schema.json +4 -1
  131. package/top-ppt-html/scripts/render_from_model.py +116 -55
  132. package/top-ppt-html/scripts/validate_pptx.py +1 -0
  133. package/top-ppt-html/scripts/validate_report.py +153 -0
  134. package/topmind-cover/README.en.md +4 -4
  135. package/topmind-cover/README.md +4 -4
  136. package/topmind-cover/SKILL.md +36 -18
  137. package/topmind-cover/assets/examples/README.md +13 -7
  138. package/topmind-cover/assets/examples/bg-blur-alt.png +0 -0
  139. package/topmind-cover/assets/examples/bg-blur-wechat.png +0 -0
  140. package/topmind-cover/assets/examples/bg-blur.png +0 -0
  141. package/topmind-cover/assets/examples/overview.png +0 -0
  142. package/topmind-cover/assets/examples/paper-collage-alt.png +0 -0
  143. package/topmind-cover/assets/examples/paper-collage-wechat.png +0 -0
  144. package/topmind-cover/assets/examples/paper-collage.png +0 -0
  145. package/topmind-cover/assets/examples/white-clean-alt-wechat.png +0 -0
  146. package/topmind-cover/assets/examples/white-clean-alt.png +0 -0
  147. package/topmind-cover/assets/examples/white-clean-wechat.png +0 -0
  148. package/topmind-cover/assets/examples/white-clean.png +0 -0
  149. package/topmind-cover/package.json +2 -2
  150. package/topmind-cover/references/cover-styles.md +146 -14
  151. package/topmind-cover/references/impact-language.md +9 -6
  152. package/topmind-cover/references/ref-white-clean-1.png +0 -0
  153. package/topmind-cover/references/ref-white-clean-2.png +0 -0
  154. package/topmind-wechat-post/README.en.md +1 -1
  155. package/topmind-wechat-post/README.md +1 -1
  156. package/topmind-wechat-post/scripts/md2wechat.py +130 -27
  157. package/topmind-wechat-post/scripts/negative_tests.py +74 -0
  158. package/topmind-x-article/README.en.md +1 -1
  159. package/topmind-x-article/README.md +1 -1
  160. package/topmind-x-article/scripts/md2x.py +18 -3
  161. 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() {
@@ -470,9 +501,10 @@ function head(s, eyebrow, title, lead) {
470
501
  };
471
502
  if (eyebrow) s.addText(eyebrow, { x: H.x, y: H.y, w: H.w, h: 0.4, fontFace: STYLE.font,
472
503
  fontSize: sz(13), bold: true, color: STYLE.accent, charSpacing: 2 });
473
- /* 主标题自适应:过长时缩一档,避免压到分隔线 */
504
+ /* 主标题自适应(adaptiveText.title 单源):先按字符数分档定上限,再按高度验算降一档,避免压到分隔线 */
474
505
  const titleH = eyebrow ? (H.ruleY - H.titleY - 0.06) : 1.6 - 0.6;
475
- 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);
476
508
  s.addText(title, { x: H.x, y: eyebrow ? H.titleY : 0.6, w: H.w, h: Math.max(0.6, titleH),
477
509
  fontFace: STYLE.fontDisplay, fontSize: tSize, bold: true, color: STYLE.ink, fit: 'shrink' });
478
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' } });
@@ -515,7 +547,7 @@ function isRec(v) { return !!v && typeof v === 'object' && !Array.isArray(v); }
515
547
  pointKV: [k,v] | {k|t, v|d} | str(对齐 pts_list)
516
548
  metricKV: [v,k] | {v|value, k}(对齐 metrics_row)
517
549
  bulletKV: [k,a,t] | {k,a,t}(对齐 r_bullet;缺省保持 undefined 以便调用方判空)
518
- 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 节点图标)
519
551
  paraKV: [title,text] | str(对齐 r_twocol;str 为无加粗标题的正文段) */
520
552
  function pointKV(p) {
521
553
  if (Array.isArray(p)) return [String(p[0] == null ? '' : p[0]), String(p[1] == null ? '' : p[1])];
@@ -533,9 +565,10 @@ function bulletKV(it) {
533
565
  return [String(it == null ? '' : it), undefined, undefined];
534
566
  }
535
567
  function phaseKV(ph) {
536
- if (Array.isArray(ph)) return [ph[0] || '', ph[1] || '', ph[2] || '', ph[3] || ''];
537
- if (isRec(ph)) return [ph.label || '', ph.name || '', ph.d || '', ph.s || ''];
538
- 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), '', '', '', ''];
539
572
  }
540
573
  function paraKV(p) {
541
574
  if (Array.isArray(p)) return [p[0] || '', p[1] || ''];
@@ -1836,7 +1869,7 @@ function addTable(s, tbl, x, y, w, availH, opts) {
1836
1869
  const minRowH = opts.minRowH != null ? opts.minRowH : PT.table.rowHMin;
1837
1870
  const headRowH = opts.headRowH != null ? opts.headRowH : (PT.table.headRowH || minRowH);
1838
1871
  /* 行数超过容量时优先压行高到 hardFloor,仍放不下则整体缩放到 availH(禁溢出) */
1839
- const hardFloor = Math.min(minRowH, 0.22);
1872
+ const hardFloor = Math.min(minRowH, AT_TABLE.rowFloorIn != null ? AT_TABLE.rowFloorIn : 0.22);
1840
1873
  const ideal = availH / Math.max(1, n);
1841
1874
  let rowH = Math.min(maxRowH, Math.max(hardFloor, ideal));
1842
1875
  if (n * rowH > availH + 0.01) {
@@ -1847,7 +1880,8 @@ function addTable(s, tbl, x, y, w, availH, opts) {
1847
1880
  }
1848
1881
  /* 行多则同步收字号(research 密排允许更小;句子单元格仍走语义字号门禁) */
1849
1882
  const dense = rowH < 0.42;
1850
- 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));
1851
1885
  const hRowH = Math.min(headRowH, Math.max(hardFloor, rowH * 1.15));
1852
1886
  /* 内容高度预估:逐格用 estTextH 估算,超出行高则全表收字号 ——
1853
1887
  禁依赖 fit:'shrink' 或渲染引擎自动撑高(PowerPoint/LibreOffice 会按内容增高行,压住脚注) */
@@ -1855,9 +1889,11 @@ function addTable(s, tbl, x, y, w, availH, opts) {
1855
1889
  const effColW = (Array.isArray(tbl.colW) && tbl.colW.length >= ncolsT) ? tbl.colW.slice(0, ncolsT)
1856
1890
  : Array.from({ length: ncolsT }, () => w / ncolsT);
1857
1891
  const cellPadIn = (dense ? 2 : 5) / 72;
1892
+ /* 长表格单元格适配(adaptiveText.table 单源):逐格估算,超出行高则全表收字号;
1893
+ floor 取模式下限与表格下限之大者——表格不得突破模式可读性底线 */
1858
1894
  const fitCellSz = (text, cw, availHh, maxSz) => fitFont([String(text == null ? '' : text)],
1859
1895
  Math.max(0.5, cw - cellPadIn * 2), Math.max(0.2, availHh - cellPadIn * 2),
1860
- { max: maxSz, maxShrinkSteps: 6 });
1896
+ { max: maxSz, maxShrinkSteps: 6, floor: Math.max(modeFloorPt(), AT_TABLE.cellFloor || 0) });
1861
1897
  let bodySzF = bodySz, headSzF = headSz;
1862
1898
  rowsIn.forEach(r => r.forEach((c, j) => {
1863
1899
  const f = fitCellSz(c, effColW[j] != null ? effColW[j] : effColW[0], rowH, bodySz);
@@ -2408,8 +2444,14 @@ CONTENT.sections.forEach((sec) => {
2408
2444
  ps.forEach((p, i) => {
2409
2445
  const x = treg.x + 0.2 + i * stepW;
2410
2446
  const on = p[3] === 'done' || p[3] === 'now';
2411
- s.addShape('ellipse', { x: x - T.dotR / 2, y: T.dotY, w: T.dotR, h: T.dotR,
2412
- 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
+ }
2413
2455
  s.addText(p[0], { x: x - 0.1, y: T.labelY, w: stepW - 0.2, h: 0.4, fontFace: STYLE.font,
2414
2456
  fontSize: sz(12), bold: true, color: STYLE.accent, charSpacing: 1 });
2415
2457
  s.addText(p[1], { x: x - 0.1, y: T.nameY, w: stepW - 0.2, h: 0.5, fontFace: STYLE.fontDisplay,
@@ -2465,11 +2507,25 @@ CONTENT.sections.forEach((sec) => {
2465
2507
  const fz = fitFont(items.length ? items : ['x'.repeat(longest)], g.w, availH, { max: 13.5, gapFactor: 1.2 });
2466
2508
  groups.forEach((col, ci) => {
2467
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
+ }
2468
2524
  const runs = col.map(p => ([
2469
- { 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 } },
2470
2526
  { text: p[1] + '\n', options: { fontSize: sz(fz - 1), color: STYLE.body, breakLine: true } },
2471
2527
  ])).flat();
2472
- 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,
2473
2529
  valign: 'top', lineSpacing: sz(fz * 1.5), paraSpaceBefore: sz(7) });
2474
2530
  });
2475
2531
  } else if (type === 'halftable') {
@@ -2563,8 +2619,10 @@ CONTENT.sections.forEach((sec) => {
2563
2619
  : (Array.isArray(cd)
2564
2620
  ? { title: cd[0] || '', points: cd.length > 1 ? [['', cd[1]]] : [] }
2565
2621
  : { title: String(cd == null ? '' : cd), points: [] }));
2566
- const c = Math.min(sec.columns || 3, cds.length || 1);
2567
- 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);
2568
2626
  const gw = (creg.w - (c - 1) * cdC.gap) / c;
2569
2627
  const gh = fitRowH(creg.h - (r - 1) * cdC.gap, r, cdC.maxH, 0.6);
2570
2628
  /* points 文本归一化(scaffold v9 契约 {title, points:[[k,v]]} 兼容三种形态) */
@@ -2589,21 +2647,28 @@ CONTENT.sections.forEach((sec) => {
2589
2647
  { max: 12, lineFactor: 1.5, gapFactor: 0 }))) : 12;
2590
2648
  const ptsFz = sz(ptsFzB);
2591
2649
  cds.forEach((cd, i) => {
2592
- 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;
2593
2658
  const x = creg.x + col * (gw + cdC.gap), y = creg.y + row * (gh + cdC.gap);
2594
- 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,
2595
2660
  fill: { color: STYLE.surface }, line: { color: STYLE.line, width: 0.75 } });
2596
2661
  /* 卡片头路标:真导出语义图标(SVG→PNG),与 HTML .card__ico 同源;无资产时回落 accent 方块 */
2597
2662
  const ico = 0.18;
2598
2663
  const icoName = cd.icon || ICON_LIB.pickIconName(cd.title || '', i);
2599
2664
  addIcon(s, icoName, x + 0.18, y + 0.2, ico, STYLE.accent);
2600
- 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,
2601
2666
  fontSize: sz(15), bold: true, color: STYLE.ink, fit: 'shrink' });
2602
2667
  /* points 兼容:[[k,v]…] | [str…] | [{t,d}…](scaffold v9 契约 {title, points:[[k,v]]}) */
2603
2668
  const ptLines = (cd.points || []).map(cardPtText);
2604
2669
  s.addText(ptLines.map(t => ({ text: t + '\n',
2605
2670
  options: { fontSize: ptsFz, color: STYLE.body, fontFace: STYLE.font, breakLine: true } })),
2606
- { 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,
2607
2672
  fontFace: STYLE.font, valign: 'top', lineSpacing: ptsFz * 1.5 });
2608
2673
  });
2609
2674
  } else if (type === 'split') {
@@ -2792,8 +2857,10 @@ CONTENT.sections.forEach((sec) => {
2792
2857
  : CW - 0.4;
2793
2858
  const avail = bodyBottom - bodyY;
2794
2859
  const rowH = fitRowH(avail, pts.length, P.rowH, 0.3);
2860
+ /* 长列表收紧(adaptiveText.list 单源):条目多时降字号上限/段间距/行距,仍装不下由 fitFont 阶梯下探 */
2861
+ const _lo = listFitOpts(pts.length);
2795
2862
  const fzB = fitFont(pts.map(p => (p[0] || '') + (p[1] || '')), textW - 0.32, avail,
2796
- { max: 15, gapFactor: 0.55 });
2863
+ { max: Math.min(15, _lo.max), gapFactor: _lo.gapFactor, lineFactor: _lo.lineFactor });
2797
2864
  const fz = sz(fzB), fzSm = sz(snapFont(fzB - 1));
2798
2865
  pts.forEach((p, i) => {
2799
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())
@@ -13,6 +13,46 @@ from pathlib import Path
13
13
 
14
14
  from render_from_model import extract_model_span
15
15
 
16
+ _ICON_DIR = Path(__file__).resolve().parent.parent / 'assets' / 'icons'
17
+ _ICON_CACHE: dict[str, str] = {}
18
+
19
+
20
+ def _icon_svg(name: str) -> str | None:
21
+ """取图标包 SVG(内联用);缺图标返回 None(调用方保留原标记,由 validate WARN)。"""
22
+ if name in _ICON_CACHE:
23
+ return _ICON_CACHE[name]
24
+ p = _ICON_DIR / f'{name}.svg'
25
+ try:
26
+ svg = p.read_text(encoding='utf-8').strip()
27
+ except OSError:
28
+ return None
29
+ _ICON_CACHE[name] = svg
30
+ return svg
31
+
32
+
33
+ # 手写 HTML 模板里的图标占位:<i data-icon="增长"></i> → 内联 SVG(currentColor 随主题变色)
34
+ # 未知图标名保留原标记(validate_report 报 WARN,不静默吞掉)
35
+ ICON_MARK_RE = re.compile(r'<i\s+data-icon="([^"]+)"\s*(?:/>|>\s*</i\s*>)')
36
+
37
+
38
+ def hydrate_icons(html: str) -> tuple[str, int, list[str]]:
39
+ """内联 icon 标记。返回 (new_html, 成功替换数, 未知图标名列表)。"""
40
+ unknown: list[str] = []
41
+ replaced = [0]
42
+
43
+ def repl(m):
44
+ name = m.group(1)
45
+ svg = _icon_svg(name)
46
+ if svg is None:
47
+ if name not in unknown:
48
+ unknown.append(name)
49
+ return m.group(0)
50
+ replaced[0] += 1 # 只计真正替换的;未知名保留原标记,不计入
51
+ return svg
52
+
53
+ new_html, _ = ICON_MARK_RE.subn(repl, html)
54
+ return new_html, replaced[0], unknown
55
+
16
56
  PLACEHOLDER_SVG_RE = re.compile(
17
57
  r'<svg class="chart" data-chart="([^"]+)" viewBox="0 0 560 220">\s*'
18
58
  r'<!--[\s\S]*?-->\s*'
@@ -336,20 +376,26 @@ def iter_chart_payloads(model: dict):
336
376
 
337
377
 
338
378
  def hydrate(html: str, model: dict) -> str:
379
+ """占位 SVG → 真实图表 SVG。
380
+ 弹性(0.2.0):单图数据非法只留占位并记入 errors,不中断整份报告其余图的水合。"""
339
381
  payloads = list(iter_chart_payloads(model))
340
382
  idx = 0
383
+ errors: list = []
341
384
 
342
385
  def repl(m):
343
386
  nonlocal idx
344
387
  if idx >= len(payloads):
345
388
  return m.group(0)
346
389
  ch, ctx = payloads[idx]
347
- svg = build_svg(ch, ctx)
348
390
  idx += 1
349
- return svg
391
+ try:
392
+ return build_svg(ch, ctx)
393
+ except ChartDataError as e:
394
+ errors.append(str(e))
395
+ return m.group(0)
350
396
 
351
397
  new_html, n = PLACEHOLDER_SVG_RE.subn(repl, html)
352
- return new_html, n, idx, len(payloads)
398
+ return new_html, n, idx, len(payloads), errors
353
399
 
354
400
 
355
401
  def main() -> int:
@@ -382,13 +428,22 @@ def main() -> int:
382
428
  print(f'FAIL: REPORT_MODEL 根节点必须是对象(得到 {type(model).__name__})')
383
429
  return 1
384
430
  try:
385
- new_html, n_ph, used, total = hydrate(html, model)
431
+ new_html, n_ph, used, total, chart_errors = hydrate(html, model)
386
432
  except ChartDataError as e:
433
+ # 防御:hydrate 内部已按图捕获,此处仅兜底批量抽取阶段的非法数据
387
434
  print(f'FAIL: 图表数据非法——{e}')
388
435
  return 1
436
+ for e in chart_errors:
437
+ print(f'FAIL: 图表数据非法——{e}(该图保留占位,其余图已水合)')
438
+ rc = 1 if chart_errors else 0
439
+ new_html, n_icon, unknown_icons = hydrate_icons(new_html)
440
+ if unknown_icons:
441
+ print(f'WARN: 未知图标名(已保留原标记): {", ".join(unknown_icons)}')
389
442
  path.write_text(new_html, encoding='utf-8')
390
443
  left = new_html.count('见模型数据')
391
- print(f'hydrated placeholders={n_ph} used_payloads={used}/{total} remaining_placeholder={left}')
444
+ print(f'hydrated placeholders={n_ph} icons={n_icon} used_payloads={used}/{total} remaining_placeholder={left}')
445
+ if rc:
446
+ return rc # 有图表数据非法:文件已落盘(坏图留占位),但以非零码报告
392
447
  return 0 if left == 0 else 1
393
448
 
394
449
 
@@ -1,53 +1,82 @@
1
1
  /**
2
- * TopPPT HTML · 语义图标库(SVG 内容 · 24×24 · stroke 1.8)
3
- * 单源:与 references/icons.md / render_from_model.py ICONS 同语义。
4
- * build_pptx.js 用 sharp 栅格化为 PNG 后嵌入(真导出,不再用 accent 方块替代)。
5
- * sync_runtime.py 校验三处图标键一致。
2
+ * TopPPT HTML · 语义图标库(48 · 24×24 · stroke 1.8 圆角线帽 · currentColor)
3
+ * 单源:assets/icons/<name>.svg + assets/icons/index.json(关键词映射 + 默认表)。
4
+ * 由 scripts/make_icon_pack.py 生成。HTML 内联 / build_pptx.js PNG 真导出共用。
5
+ * 图标图 objectName 统一 `icon:` 前缀,validate_pptx 单独计数,不进 pictures 内容图门禁。
6
6
  */
7
7
  'use strict';
8
8
 
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ const ICON_DIR = path.join(__dirname, '..', 'assets', 'icons');
9
13
  const SVG_OPEN = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">';
10
14
 
11
- /** name → SVG 完整标记(currentColor 由调用方替换为目标色) */
12
- const ICONS = {
13
- '增长': SVG_OPEN + '<path d="M22 7l-8.5 8.5-5-5L2 17"/><path d="M16 7h6v6"/></svg>',
14
- '下降': SVG_OPEN + '<path d="M22 17l-8.5-8.5-5 5L2 7"/><path d="M16 17h6v-6"/></svg>',
15
- '数据': SVG_OPEN + '<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5"/><path d="M3 12c0 1.66 4.03 3 9 3s9-1.34 9-3"/></svg>',
16
- '图表': SVG_OPEN + '<path d="M3 3v18h18"/><rect x="7" y="12" width="3" height="6" rx="1"/><rect x="12" y="8" width="3" height="10" rx="1"/><rect x="17" y="4" width="3" height="14" rx="1"/></svg>',
17
- '趋势': SVG_OPEN + '<path d="M3 3v18h18"/><path d="M7 14l4-4 3 3 5-6"/><path d="M15 7h4v4"/></svg>',
18
- '占比': SVG_OPEN + '<path d="M21.2 15.9A10 10 0 1 1 8 2.8"/><path d="M22 12A10 10 0 0 0 12 2v10z"/></svg>',
19
- '表格': SVG_OPEN + '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M3 15h18M9 3v18M15 3v18"/></svg>',
20
- '效率': SVG_OPEN + '<path d="M12 15l3.5-5.5"/><path d="M20.2 15a8.5 8.5 0 1 0-16.4 0"/></svg>',
21
- '成果': SVG_OPEN + '<circle cx="12" cy="8" r="6"/><path d="M15.5 13 17 22l-5-3-5 3 1.5-9"/></svg>',
22
- '安全': SVG_OPEN + '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/></svg>',
23
- '权限': SVG_OPEN + '<rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>',
24
- '检查': SVG_OPEN + '<circle cx="12" cy="12" r="10"/><path d="M8 12.5l2.5 2.5L16 9.5"/></svg>',
25
- '风险': SVG_OPEN + '<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><path d="M12 9v4M12 17h.01"/></svg>',
26
- '团队': SVG_OPEN + '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>',
27
- '流程': SVG_OPEN + '<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/></svg>',
28
- '计划': SVG_OPEN + '<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>',
29
- '智能': SVG_OPEN + '<rect x="4" y="4" width="16" height="16" rx="2"/><path d="M9 9h6v6H9zM9 1v3M15 1v3M9 20v3M15 20v3M1 9h3M1 15h3M20 9h3M20 15h3"/></svg>',
30
- '洞察': SVG_OPEN + '<circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>',
31
- '工具': SVG_OPEN + '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>',
32
- '清单': SVG_OPEN + '<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>',
33
- };
34
-
35
- const ORDER = Object.keys(ICONS);
36
-
37
- /** 按标题语义挑图标名;未命中按序轮换(与 render_from_model.pick_icon 同策略) */
15
+ let ICONS = {};
16
+ let ORDER = [];
17
+ let INDEX = { defaults: [], icons: {} };
18
+
19
+ function _load() {
20
+ try {
21
+ INDEX = JSON.parse(fs.readFileSync(path.join(ICON_DIR, 'index.json'), 'utf-8'));
22
+ } catch (e) {
23
+ INDEX = { defaults: [], icons: {} };
24
+ }
25
+ const names = Object.keys(INDEX.icons || {});
26
+ for (const name of names) {
27
+ try {
28
+ ICONS[name] = fs.readFileSync(path.join(ICON_DIR, `${name}.svg`), 'utf-8').trim();
29
+ } catch (e) { /* 缺文件则跳过,validate 会 WARN */ }
30
+ }
31
+ ORDER = Object.keys(ICONS);
32
+ if (!ORDER.length) {
33
+ // 极端回落:包不可读时给一个空壳,避免调用方崩溃
34
+ ICONS = { '信息': SVG_OPEN + '<circle cx="12" cy="12" r="9"/><path d="M12 11v5"/><path d="M12 7.5h.01"/></svg>' };
35
+ ORDER = ['信息'];
36
+ }
37
+ }
38
+ _load();
39
+
40
+ /** 默认图标表(index.json defaults;选不出语义图标时轮换) */
41
+ function defaultNames() {
42
+ const ds = (INDEX.defaults || []).filter((n) => ICONS[n]);
43
+ return ds.length ? ds : ORDER;
44
+ }
45
+
46
+ /**
47
+ * 按标题语义挑图标名:关键词命中 → 默认表轮换(不盲目轮换全量 48)。
48
+ * 与 render_from_model.pick_icon_name 同策略(同读 index.json)。
49
+ */
38
50
  function pickIconName(title, idx) {
39
51
  const t = String(title || '');
40
- for (const key of ORDER) {
41
- if (t.includes(key)) return key;
52
+ const meta = INDEX.icons || {};
53
+ for (const name of ORDER) {
54
+ const kws = (meta[name] && meta[name].keywords) || [name];
55
+ for (const kw of kws) {
56
+ if (kw && t.includes(kw)) return name;
57
+ }
42
58
  }
43
- return ORDER[(idx || 0) % ORDER.length];
59
+ const ds = defaultNames();
60
+ return ds[(idx || 0) % ds.length];
44
61
  }
45
62
 
46
- /** 渲染为带指定描边色的 SVG 字符串 */
63
+ /** 渲染为带指定描边色的 SVG 字符串(currentColor → 目标色) */
47
64
  function iconSvg(name, color) {
48
- const raw = ICONS[name] || ICONS[ORDER[0]];
65
+ const raw = ICONS[name] || ICONS[defaultNames()[0]] || ICONS[ORDER[0]];
49
66
  const c = color || '#1a56a8';
50
67
  return raw.replace('stroke="currentColor"', `stroke="${c}"`);
51
68
  }
52
69
 
53
- module.exports = { ICONS, ORDER, pickIconName, iconSvg, SVG_OPEN };
70
+ /** 预渲染 PNG(512px)文件路径;无 sharp 环境下 PPTX 通道的可靠回落 */
71
+ function iconPngPath(name) {
72
+ const n = ICONS[name] ? name : pickIconName(name, 0);
73
+ const p = path.join(ICON_DIR, `${n}.png`);
74
+ try {
75
+ fs.accessSync(p, fs.constants.R_OK);
76
+ return p;
77
+ } catch (e) {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ module.exports = { ICONS, ORDER, INDEX, pickIconName, iconSvg, iconPngPath, SVG_OPEN, defaultNames };