@topmindspace/tms-skills 0.1.18 → 0.1.19

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 (43) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.en.md +3 -3
  3. package/README.md +3 -3
  4. package/package.json +1 -1
  5. package/top-ppt-html/README.en.md +2 -2
  6. package/top-ppt-html/README.md +1 -1
  7. package/top-ppt-html/SKILL.md +4 -4
  8. package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.html +24 -12
  9. package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.html +24 -12
  10. package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.html +24 -12
  11. package/top-ppt-html/assets/examples/2026-09-26-topmind-tms-skills-showcase.html +94 -165
  12. package/top-ppt-html/assets/examples/2026-09-26-topmind-tms-skills-showcase.model.json +32 -38
  13. package/top-ppt-html/assets/pptx-export.js +3 -2
  14. package/top-ppt-html/assets/style-gallery.html +2 -1
  15. package/top-ppt-html/assets/templates/architecture.html +38 -15
  16. package/top-ppt-html/assets/templates/engine.css +20 -9
  17. package/top-ppt-html/assets/templates/presentation.html +38 -17
  18. package/top-ppt-html/assets/templates/research.html +44 -23
  19. package/top-ppt-html/package-lock.json +2 -2
  20. package/top-ppt-html/package.json +1 -1
  21. package/top-ppt-html/references/components-atoms.md +1 -1
  22. package/top-ppt-html/references/content-rules.md +17 -11
  23. package/top-ppt-html/references/failure-modes.md +6 -4
  24. package/top-ppt-html/references/high-fidelity.md +14 -1
  25. package/top-ppt-html/references/icons.md +2 -2
  26. package/top-ppt-html/references/layout-grammar.md +3 -1
  27. package/top-ppt-html/references/layouts-research.md +1 -1
  28. package/top-ppt-html/references/pptx-export.md +2 -2
  29. package/top-ppt-html/references/tech-design.md +2 -0
  30. package/top-ppt-html/scripts/build_icon_assets.js +92 -0
  31. package/top-ppt-html/scripts/build_pptx.js +125 -29
  32. package/top-ppt-html/scripts/extract_model.py +21 -1
  33. package/top-ppt-html/scripts/icon_lib.js +53 -0
  34. package/top-ppt-html/scripts/icon_raster.js +86 -0
  35. package/top-ppt-html/scripts/layout-constants.json +4 -1
  36. package/top-ppt-html/scripts/lib_layout_regions.js +3 -1
  37. package/top-ppt-html/scripts/model-schema.json +7 -3
  38. package/top-ppt-html/scripts/package_skill.py +1 -0
  39. package/top-ppt-html/scripts/render_from_model.py +125 -24
  40. package/top-ppt-html/scripts/scaffold_report.py +47 -24
  41. package/top-ppt-html/scripts/sync_runtime.py +2 -1
  42. package/top-ppt-html/scripts/validate_pptx.py +14 -3
  43. package/top-ppt-html/scripts/validate_report.py +1 -1
@@ -6,7 +6,7 @@
6
6
 
7
7
  全部 24×24、描边(stroke 1.8,圆角线帽),`currentColor` 随主题变色。
8
8
 
9
- > **PPTX 通道**:内联 SVG 不跨通道导出。PPTX 侧卡片头用 **accent 小方块路标**;要点列表用 accent 实心方块。**不要在模型里期待图标 ID 进 PPT**。若须区分类型,用标题前缀(「数据 ·」「风险 ·」)。
9
+ > **PPTX 通道(真导出)**:图标名写入模型 `cards[].icon`(或 HTML `data-icon`),`build_pptx.js` 经 `icon_lib.js` + sharp 栅格化为 PNG 嵌入,与 HTML 同名同语义。`objectName` 前缀 `icon:`,`validate_pptx` 单独计数(`icon_pictures`),**不进** `pictures=0` 内容图门禁。缺资产时回落 accent 小方块路标。图标名取值 = 本文件高频取码键(增长/下降/数据/图表/趋势/占比/表格/效率/成果/安全/权限/检查/风险/团队/流程/计划/智能/洞察/工具/清单)。
10
10
 
11
11
  ## 使用准则(先读再用)
12
12
 
@@ -19,7 +19,7 @@
19
19
  3. **有限密度**:演示 3–8 / 研究 2–4 / 架构主图内 0;超过即噪音。
20
20
  4. **位置克制**:只走四正当位置(卡头 / 关键列表 / 指标角标 / 提示条);**Agenda 大编号、表格单元格、图表内部、正文行内**一律不用。
21
21
  5. **一致性 > 个性**:同类信息复用同一语义图标(如「风险」全篇都用警告),不要每页换隐喻。
22
- 6. **可导出意识**:PPTX 通道用 accent 小方块路标代替 SVG;不把图标 ID 写进模型字段。
22
+ 6. **可导出意识**:卡片头写 `data-icon` / 模型 `cards[].icon`,PPTX 真导出同名 PNG;缺省按标题语义自动挑选(`icon_lib.pickIconName`)。
23
23
 
24
24
  违反时:先减数量,再统一家族,最后才考虑换布局——不要靠堆图标补「空旷」。
25
25
 
@@ -329,7 +329,9 @@ fill = Body 区被内容覆盖的估算面积 / Body 区面积
329
329
  | 条目数 | 版式 | 类名 |
330
330
  |--------|------|------|
331
331
  | ≤8 | 单列 | `.agenda` |
332
- | 9–16 | **两列 / 两排** | `.agenda.agenda--2col` |
332
+ | 3–7(目标) | **单列** | `.agenda`(章节大纲,每章 1+ 页) |
333
+ | 8 | 单列上限 | `.agenda` |
334
+ | >8(异常) | **两列 / 两排** | `.agenda.agenda--2col`(安全网,非设计目标) |
333
335
  | >16 | 拆上篇/下篇或只列一级章节 | — |
334
336
 
335
337
  阈值单源:`layout-constants.json` → `contentQuality.agenda.singleMax`(默认 8)。校验:`validate_report` WARN(只认 `<ol class="…agenda--2col">`,不被 CSS 选择器误伤)。
@@ -128,7 +128,7 @@
128
128
  <span class="exhibit__t">右栏图表标题(结论式)</span>
129
129
  </div>
130
130
  <svg class="chart" data-chart="hbar" viewBox="0 0 480 190"><!-- …条形图… --></svg>
131
- <div class="exhibit__src">来源:来源名称,YYYY-MM;口径说明</div>
131
+ <div class="exhibit__src">来源:<真实来源名>,YYYY-MM;口径说明</div>
132
132
  </div>
133
133
  </div>
134
134
 
@@ -170,10 +170,10 @@ python scripts/validate_pptx.py "报告.pptx" --strict --model="报告.model.jso
170
170
  2. **数据表随行**:`chart.dataTable` 三态——`notes`(默认:数据表以「项目 | 数值」文本写入演讲者备注,零版面占用、可复制回 Excel)、`inline`(图表下方附原生小字号表格,压缩图表高度不越界)、`off`(仅 sparkline 等装饰微图)。**形状通道图表的 `dataTable` 不得为 off**(`MODEL_CHART_DATATABLE` 硬拦)——保证非原生图表的数据同样可核对。
171
171
  3. **演讲者备注**:`lead` / `soWhat` / `note` / `footnote` / 图表口径 / 数据表自动写入页备注(notesSlide)——细节沉到备注区不堆版面,讲稿口径随文件走;封面/收尾页同样有备注。
172
172
  4. **文档元数据**:`pptx.title/subject/author/company` 从模型填充——文件属性完整,检索与归档友好。
173
- 5. **默认零图片全原生(声明式放行 + 配图占位)**:默认 `pictures=0`(表格为原生 table、文字为原生文本框,全部可就地编辑);**仅当模型显式声明 `section.image` / `split.left.image` / `split.right.image` 时才导出图片**,且图片数不得超过声明数(`PICTURES_NOT_DECLARED` 硬拦)——图片源只允许 data: 内联或相对路径(外链报 `IMAGE_SRC_EXTERNAL`)。**无素材时用 `image.placeholder` 出配图占位**:原生圆角矩形 + 虚线 + 居中标签(可编辑、`pictures` 不增),交付前替换 `src` 即可;版式/比例/图注位置已由 `imageSpec` 锁死。
173
+ 5. **默认零内容图全原生(声明式放行 + 配图占位)**:默认 `pictures=0`(表格为原生 table、文字为原生文本框,全部可就地编辑);**仅当模型显式声明 `section.image` / `split.left.image` / `split.right.image` 时才导出内容图片**,且内容图数不得超过声明数(`PICTURES_NOT_DECLARED` 硬拦)——图片源只允许 data: 内联或相对路径(外链报 `IMAGE_SRC_EXTERNAL`)。**语义图标(`objectName: icon:*`)单独计数 `icon_pictures`,不进 `pictures` 门禁**(见第 8 条)。**无素材时用 `image.placeholder` 出配图占位**:原生圆角矩形 + 虚线 + 居中标签(可编辑、`pictures` 不增),交付前替换 `src` 即可;版式/比例/图注位置已由 `imageSpec` 锁死。
174
174
  6. **自适应排版(有限缩字号)**:每个文本块按可用高度在字号阶梯里选"装得下"的最大值;内容偏多时按 `containers.fontShrink` **有限下探**(最多 4 档,floor:presentation/architecture 10pt、research 9pt)。优先顺序:列表化/精炼 → 换/扩组合或拆页 → 最后才缩字号。表格行高按可用高度计算,列表/泳道/矩阵/卡片行高随容器收敛——保证不越界、不重叠;`fitFont` 与 `estTextH` 在 `build_pptx.js`(交付)与 `pptx-export.js`(预览)中同算法,两通道排版一致。
175
175
  7. **版面安全边界**:所有页型几何来自 `layout-constants.json` 的 `pageTypes`;内容安全下界 `layout.contentBottom`(有 so-what/来源行时收紧为 `contentBottomWithNote`)。校验器对越界(`SHAPE_OUTSIDE_SLIDE`)、负坐标、非正尺寸、文本溢出估算、字号下限、未声明图片数一律拦截。
176
- 8. **图标路标(PPTX 通道)**:HTML 内联 SVG 图标不跨通道。PPTX 卡片头渲染 **accent 小方块路标**(与 `.card__ico` accent 底同语义),要点列表用 accent 实心方块标记——保持「路标不是装饰」的可扫读性,且全为原生形状可编辑。模型不携带 icon ID。
176
+ 8. **图标真导出(PPTX 通道)**:卡片头图标名写入模型 `cards[].icon`(或 HTML `data-icon`),`build_pptx.js` 经 `scripts/icon_lib.js` + sharp 栅格化为 PNG 嵌入(`scripts/icon-assets.json` 预生成缓存,20 图标 × 各风格 accent 色)。`objectName` 前缀 `icon:`,`validate_pptx` 单独计数 `icon_pictures`,**不进** `pictures=0` 内容图门禁。无 sharp/资产时回落 accent 小方块路标(可扫读)。图标名 = `icon_lib.js` 键(增长/下降/数据/图表/趋势/占比/表格/效率/成果/安全/权限/检查/风险/团队/流程/计划/智能/洞察/工具/清单);缺省按标题语义自动挑选。要点列表项目符号仍用 accent 实心方块(列表行首图标属 `.ul--ico`,下一档接同源 PNG)。
177
177
 
178
178
  > A 通道预览引擎(`pptx-export.js`)保持形状渲染作浏览器缩略:**版式与文本所见即所得**;带数据的原生图表在交付 PPTX 中为真 chart part(双击可编辑数据),预览侧为形状近似——**图表形态以精导产物为准**。`cross_verify` 对图表文本做归一化比对(类别标签并入、纯数值 token 过滤),双通道语义一致。
179
179
 
@@ -31,6 +31,8 @@
31
31
  | `scripts/layout-constants.json` | 页面几何 `page` / **12 列网格 `grid`** / **语义字阶 `typography` C0–T14** / 三模式比例尺 `typeScale`+`modeTypeScale` / 页型几何 `pageTypes`+`pageTypeGeometry` / **9 风格 token `styles`+`stylesDark`+`styleAccents`** / **编码色板 `styleDataColors`+`styleDataColorsDark`(9 套 × 亮暗 × c1–c5)** / **图表登记四元组 `charts.registry`** / 图表最小尺寸 `charts.minSize` / **图表多样性预算 `charts.variety`** / **容器内边距 `containers`** / **锚点容差 `anchorTolerance`** / **图片准入门 `imageAdmission`** / **图片规格 `imageSpec`**(版式 `layouts` / 比例 `ratioDefault`+`ratioCssClass` / 建议尺寸 / 占位标签 / 体积上限) / **深度模式 `deepMode`** / 校验预算 `checkBudgets` / 强调带 `emphasis` / 待核实 `annotations` / 去AI味词 `aiFlavor` / **内容级质量 `contentQuality`**(版式节奏 / so-what 实质 / research 标题判断) | `build_pptx.js`(require)、`pptx-export.js`(注入常量块)、三模板、`style-gallery.html`、`validate_report.py`、`validate_pptx.py`、`audit_styles.py`、`audit_skill.py`、`prepare_images.py`、`evals/run_evals.py` |
32
32
  | `scripts/model-schema.json` | 29 种页型的字段与必填约束 / **图表类型白名单 `chartTypes`(30 类:16 原生 + 14 形状)** / **数据表策略 `chartDataTable`** / 通用可选字段 `commonSectionFields` | `extract_model.py`(本地校验)、`pptx-export.js`(浏览器端 `validateModel`) |
33
33
  | `layout-constants.json` → `layoutSlots` | **页型布局 IR(29 页型全量)**:语义槽位(head/primary/secondary/annotation + required)——双通道按同一槽位语义落位 | `sync_runtime.py`(完整性校验);`lib_layout_regions.js`(槽位→英寸矩形);`extract_snippet.py --page-type` |
34
+ | `scripts/icon_lib.js` | **语义图标库单源**(20 名 → 24×24 stroke SVG;与 `icons.md` 高频取码 / `render_from_model.ICONS` 同键) | `build_icon_assets.js`(栅格化)、`build_pptx.js`(真导出 PNG)、`icon_raster.js` |
35
+ | `scripts/icon-assets.json` | **图标 PNG 资产缓存**(`build_icon_assets.js` 生成 · sharp 栅格化 · 20 图标 × 各风格 accent 色;gitignore,可再生成) | `build_pptx.js`(同步读取;缺失时 execFileSync 自动补齐) |
34
36
  | 交付一键 | `quality_gate.py` | `validate_report --strict` + 可选 `validate_pptx` + `evals --score` + **rubric 启发式五维**(content/layout/chart/infographic/tone) |
35
37
 
36
38
  **纪律**:改常量只改 JSON,改 schema 只改 schema JSON,然后跑 `python scripts/sync_runtime.py`(注入 + 完整性校验 + 哈希摘要)。**禁止手改** `pptx-export.js` / 三模板 / `style-gallery.html` 的标记块。
@@ -0,0 +1,92 @@
1
+ /**
2
+ * TopPPT HTML · 图标资产预生成(SVG → PNG base64)
3
+ * 产出 scripts/icon-assets.json:{ "icons": { "<name>": { "<#color>": "data:image/png;base64,..." } } }
4
+ * build_pptx.js 同步读取本文件;缺失时用 execFileSync 调本脚本补齐。
5
+ * 由 sync_runtime.py / 首次 build 调用。依赖 sharp(MIMO_NODE_MODULES 或技能目录 node_modules)。
6
+ *
7
+ * 用法: node scripts/build_icon_assets.js [--out=scripts/icon-assets.json]
8
+ */
9
+ 'use strict';
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const { ICONS, ORDER, iconSvg } = require('./icon_lib.js');
14
+
15
+ function loadSharp() {
16
+ const roots = [];
17
+ if (process.env.MIMO_NODE_MODULES) roots.push(process.env.MIMO_NODE_MODULES);
18
+ roots.push(path.join(__dirname, '..', 'node_modules'));
19
+ roots.push(path.join(__dirname, 'node_modules'));
20
+ for (const r of roots) {
21
+ try { return require(path.join(r, 'sharp')); } catch (_) { /* next */ }
22
+ }
23
+ try { return require('sharp'); } catch (_) { return null; }
24
+ }
25
+
26
+ /** 需要预生成的描边色:9 套风格 light/dark 的 accent + 一个兜底深灰 */
27
+ function collectColors() {
28
+ const colors = new Set(['#333333']);
29
+ const add = (a) => {
30
+ if (typeof a !== 'string') return;
31
+ let v = a.trim();
32
+ if (!v) return;
33
+ if (/^[0-9a-fA-F]{6}$/.test(v)) v = '#' + v;
34
+ if (/^[0-9a-fA-F]{3}$/.test(v)) v = '#' + v;
35
+ if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v)) colors.add(v.toLowerCase());
36
+ };
37
+ try {
38
+ const lc = JSON.parse(fs.readFileSync(path.join(__dirname, 'layout-constants.json'), 'utf-8'));
39
+ const grab = (obj) => {
40
+ if (!obj || typeof obj !== 'object') return;
41
+ for (const k of Object.keys(obj)) {
42
+ const v = obj[k];
43
+ if (v && typeof v === 'object') {
44
+ add(v.accent);
45
+ add(v.accentText || v['accent-text']);
46
+ }
47
+ }
48
+ };
49
+ grab(lc.styles);
50
+ grab(lc.stylesDark);
51
+ } catch (_) { /* 用兜底色 */ }
52
+ return [...colors];
53
+ }
54
+
55
+ async function main() {
56
+ const sharp = loadSharp();
57
+ if (!sharp) {
58
+ console.error('[build_icon_assets] FAIL: 未找到 sharp。请在技能目录 npm install,或设置 NODE_PATH/MIMO_NODE_MODULES。');
59
+ process.exit(1);
60
+ }
61
+ const outArg = (process.argv.find(a => a.startsWith('--out=')) || '').split('=')[1];
62
+ const outPath = outArg ? path.resolve(outArg) : path.join(__dirname, 'icon-assets.json');
63
+ const colors = collectColors();
64
+ const PX = 64;
65
+ const icons = {};
66
+ for (const name of ORDER) {
67
+ icons[name] = {};
68
+ for (const color of colors) {
69
+ const svg = iconSvg(name, color);
70
+ const buf = await sharp(Buffer.from(svg), { density: 300 })
71
+ .resize(PX, PX)
72
+ .png()
73
+ .toBuffer();
74
+ icons[name][color] = 'data:image/png;base64,' + buf.toString('base64');
75
+ }
76
+ }
77
+ const payload = {
78
+ version: 1,
79
+ px: PX,
80
+ generated: new Date().toISOString(),
81
+ colors,
82
+ icons,
83
+ };
84
+ fs.writeFileSync(outPath, JSON.stringify(payload), 'utf-8');
85
+ const n = ORDER.length * colors.length;
86
+ console.log(`[build_icon_assets] OK: ${ORDER.length} icons × ${colors.length} colors = ${n} PNGs → ${outPath}`);
87
+ }
88
+
89
+ main().catch((e) => {
90
+ console.error('[build_icon_assets] FAIL:', e && e.message);
91
+ process.exit(1);
92
+ });
@@ -81,6 +81,55 @@ const CONTENT_BOTTOM_NOTE = LAY.contentBottomWithNote != null ? LAY.contentBotto
81
81
  /* 页型布局区域 IR:语义槽位 → 英寸矩形(与 layout_slots.json 同源;未识别槽位时回落 PT 常量) */
82
82
  const REGIONS = require('./lib_layout_regions.js');
83
83
 
84
+ /* ══ 语义图标真导出(SVG→PNG · scripts/icon-assets.json 预生成缓存)══
85
+ * HTML .card__ico / .ul--ico 的 SVG 图标在此栅格化为 PNG 嵌入,不再用 accent 方块替代。
86
+ * 图标图 objectName 统一 `icon:` 前缀 —— validate_pptx 单独计数,不进 pictures 内容图门禁。 */
87
+ const ICON_LIB = require('./icon_lib.js');
88
+ let ICON_ASSETS = { icons: {}, px: 64 };
89
+ try {
90
+ ICON_ASSETS = JSON.parse(fs.readFileSync(path.join(__dirname, 'icon-assets.json'), 'utf-8'));
91
+ } catch (e) {
92
+ try {
93
+ const { execFileSync } = require('child_process');
94
+ execFileSync(process.execPath, [path.join(__dirname, 'build_icon_assets.js')],
95
+ { env: process.env, stdio: 'pipe' });
96
+ ICON_ASSETS = JSON.parse(fs.readFileSync(path.join(__dirname, 'icon-assets.json'), 'utf-8'));
97
+ } catch (e2) {
98
+ console.warn('[build_pptx] 图标资产缺失且生成失败,卡片头回落 accent 方块:' + (e2 && e2.message));
99
+ }
100
+ }
101
+ function _normColor(c) {
102
+ if (typeof c !== 'string') return '#333333';
103
+ let v = c.trim().toLowerCase();
104
+ if (/^[0-9a-f]{6}$/.test(v)) v = '#' + v;
105
+ if (/^[0-9a-f]{3}$/.test(v)) v = '#' + v;
106
+ return v;
107
+ }
108
+ /** 取图标 PNG data URI;无缓存时按名挑色兜底(accent → 首色 → null) */
109
+ function iconPng(name, colorHex) {
110
+ const n = name && ICON_ASSETS.icons[name] ? name : ICON_LIB.pickIconName(name, 0);
111
+ const bag = ICON_ASSETS.icons[n];
112
+ if (!bag) return null;
113
+ const want = _normColor(colorHex);
114
+ if (bag[want]) return bag[want];
115
+ const keys = Object.keys(bag);
116
+ return bag[keys[0]] || null;
117
+ }
118
+ /** 在指定盒内嵌图标(真导出);无图标资产时回落 accent 小方块(可扫读路标) */
119
+ function addIcon(s, name, x, y, size, colorHex) {
120
+ const uri = iconPng(name, colorHex || (STYLE && STYLE.accent) || '#1a56a8');
121
+ if (uri) {
122
+ try {
123
+ s.addImage({ data: uri, x, y, w: size, h: size, objectName: 'icon:' + (name || 'icon') });
124
+ return true;
125
+ } catch (e) { /* 回落方块 */ }
126
+ }
127
+ s.addShape('roundRect', { x, y, w: size, h: size, rectRadius: 0.04,
128
+ fill: { color: (STYLE && STYLE.accent) || '1A73E8' }, line: { type: 'none' },
129
+ objectName: 'icon:' + (name || 'mark') });
130
+ return false;
131
+ }
132
+
84
133
  const modelArg = (process.argv.find(a => a.startsWith('--model=')) || '').split('=')[1];
85
134
  let styleArg = (process.argv.find(a => a.startsWith('--style=')) || '').split('=')[1];
86
135
  let themeArg = (process.argv.find(a => a.startsWith('--theme=')) || '').split('=')[1];
@@ -345,7 +394,7 @@ function head(s, eyebrow, title, lead) {
345
394
  const titleH = eyebrow ? (H.ruleY - H.titleY - 0.06) : 1.6 - 0.6;
346
395
  const tSize = estTextH(title, CW, sz(30), 1.25) > titleH ? sz(24) : sz(30);
347
396
  s.addText(title, { x: H.x, y: eyebrow ? H.titleY : 0.6, w: H.w, h: Math.max(0.6, titleH),
348
- fontFace: STYLE.fontDisplay, fontSize: tSize, bold: true, color: STYLE.ink });
397
+ fontFace: STYLE.fontDisplay, fontSize: tSize, bold: true, color: STYLE.ink, fit: 'shrink' });
349
398
  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' } });
350
399
  if (lead) s.addText(lead, { x: H.x, y: H.leadY || PT.common.leadY, w: H.w, h: 0.5, fontFace: STYLE.font,
351
400
  fontSize: sz(14), color: STYLE.body });
@@ -358,6 +407,26 @@ function footer(s, n, total) {
358
407
  /* 容器语义名(p:cNvPr@name):供 validate_pptx 按 containers.pad 分型扣内边距。
359
408
  文本框坐标已由引擎 inset,校验器不再对无名 shape 二次扣大 pad。 */
360
409
  function trName(kind) { return 'tr:' + kind; }
410
+ /* 发射前矩形相交断言:组合布局(图表+数据表/图例+标签)在 addShape/addText 前自检,
411
+ 发现叠印立刻 console.warn 并计入 OVERLAP_PREEMIT(validate_pptx 的 ELEMENT_OVERLAP 是事后裁判)。 */
412
+ function rectsOverlap(a, b, slack) {
413
+ const s = slack != null ? slack : 0.02;
414
+ return (a.x < b.x + b.w - s && b.x < a.x + a.w - s &&
415
+ a.y < b.y + b.h - s && b.y < a.y + a.h - s);
416
+ }
417
+ const OVERLAP_PREEMIT = [];
418
+ function assertNoOverlap(tag, a, b) {
419
+ if (rectsOverlap(a, b)) {
420
+ OVERLAP_PREEMIT.push({ tag, a, b });
421
+ if (typeof console !== 'undefined') {
422
+ console.warn(`[build_pptx] PREEMIT_OVERLAP ${tag}: ` +
423
+ `(${a.x.toFixed(2)},${a.y.toFixed(2)},${a.w.toFixed(2)}×${a.h.toFixed(2)}) ` +
424
+ `∩ (${b.x.toFixed(2)},${b.y.toFixed(2)},${b.w.toFixed(2)}×${b.h.toFixed(2)})`);
425
+ }
426
+ return false;
427
+ }
428
+ return true;
429
+ }
361
430
  /* 双形态载荷判定:[[t,d]…] 数组 vs [{t,d,accent}…] 记录。
362
431
  typeof [] === 'object',绝不能用 typeof 区分——否则数组形态的 t/d 被读成 undefined 静默丢字。 */
363
432
  function isRec(v) { return !!v && typeof v === 'object' && !Array.isArray(v); }
@@ -372,14 +441,14 @@ function soWhatBar(s, text) {
372
441
  s.addText(String(text || ''), {
373
442
  x: R.x + padX, y: R.y + padY, w: R.w - padX * 2, h: R.h - padY * 2,
374
443
  fontFace: STYLE.font, fontSize: sz(fzB), bold: true, color: STYLE.ink, valign: 'middle',
375
- objectName: trName('soWhat'),
444
+ objectName: trName('soWhat'), fit: 'shrink',
376
445
  });
377
446
  }
378
447
  function footnoteLine(s, text) {
379
448
  const R = REGIONS.regionOf('exhibit', 'footnote') ||
380
449
  { x: MX, y: PT.exhibit.footnoteY, w: CW - 1.2, h: 0.32 };
381
450
  s.addText(text, { x: R.x, y: R.y, w: R.w, h: R.h,
382
- fontFace: STYLE.font, fontSize: sz(9.5), color: STYLE.faint, objectName: trName('footnote') });
451
+ fontFace: STYLE.font, fontSize: sz(9.5), color: STYLE.faint, objectName: trName('footnote'), fit: 'shrink' });
383
452
  }
384
453
  /* R2:sec.note 与 sec.footnote 共用注释带末行(footnoteY=6.72)。
385
454
  note.y=6.55 会压进 so-what 带——同页二者合并为一行。 */
@@ -1163,11 +1232,14 @@ function shapeSlope(s, c, x, y, w, h, dcols) {
1163
1232
  shapeValue(s, c, Number(other[i]) || 0, rightX + 0.1, y2 - 0.14, 0.62, 0.28, false);
1164
1233
  });
1165
1234
  }
1166
- /* 华夫图(waffle):10×N 点阵按占比填充 */
1235
+ /* 华夫图(waffle):10×N 点阵按占比填充;图例含在图高 h 内(几何铁律②,防与 inline 数据表叠印) */
1167
1236
  function shapeWaffle(s, c, x, y, w, h, dcols) {
1168
1237
  const M = shapeMetrics(c, w, h);
1169
1238
  const cols = 10, rows = 10;
1170
- const cell = Math.min(w / cols, h / rows);
1239
+ /* 图例带收在 h 内:网格只用 h-legendH,图例贴在网格下方 */
1240
+ const legendH = 0.30;
1241
+ const plotH = Math.max(0.6, h - legendH);
1242
+ const cell = Math.min(w / cols, plotH / rows);
1171
1243
  const total = M.values.reduce((a, b) => a + b, 0) || 1;
1172
1244
  const units = 100;
1173
1245
  let filled = 0;
@@ -1181,13 +1253,14 @@ function shapeWaffle(s, c, x, y, w, h, dcols) {
1181
1253
  }
1182
1254
  });
1183
1255
  let lx = x;
1256
+ const legendY = y + Math.min(plotH, rows * cell) + 0.02;
1184
1257
  M.labels.forEach((lb, i) => {
1185
1258
  const dc = dcols ? dcols[i % dcols.length] : (i === 0 ? STYLE.accent : STYLE.faint);
1186
- s.addShape('ellipse', { x: lx, y: y + h + 0.06, w: 0.1, h: 0.1, fill: { color: dc }, line: { type: 'none' } });
1187
- s.addText(lb, { x: lx + 0.14, y: y + h + 0.02, w: 0.9, h: 0.24,
1259
+ s.addShape('ellipse', { x: lx, y: legendY + 0.04, w: 0.1, h: 0.1, fill: { color: dc }, line: { type: 'none' } });
1260
+ s.addText(lb, { x: lx + 0.14, y: legendY, w: 0.9, h: 0.24,
1188
1261
  fontFace: STYLE.font, fontSize: sz(10.5), color: STYLE.body, valign: 'middle' });
1189
1262
  s.addText(String(M.values[i] != null ? M.values[i] : '') + (c.unit || ''),
1190
- { x: lx + 1.06, y: y + h + 0.02, w: 0.66, h: 0.24, fontFace: STYLE.font, fontSize: sz(10.5),
1263
+ { x: lx + 1.06, y: legendY, w: 0.66, h: 0.24, fontFace: STYLE.font, fontSize: sz(10.5),
1191
1264
  bold: true, color: STYLE.body, valign: 'middle' });
1192
1265
  lx += 1.9;
1193
1266
  });
@@ -1213,8 +1286,11 @@ function chartBlock(s, c, x, y, w, h, dcols) {
1213
1286
  fill: { color: ri === 0 ? STYLE.accent : STYLE.bg },
1214
1287
  },
1215
1288
  })));
1216
- s.addTable(rows, { x: x, y: y + chH + 0.1, w: w, rowH: rowH, autoPage: false,
1217
- border: { pt: 0.5, color: STYLE.line }, margin: dense ? 1 : 3 });
1289
+ const tblBox = { x: x, y: y + chH + 0.1, w: w, h: tH };
1290
+ const chartBox = { x: x, y: y, w: w, h: chH };
1291
+ assertNoOverlap('chartBlock:chart∩inlineTable', chartBox, tblBox);
1292
+ s.addTable(rows, { x: tblBox.x, y: tblBox.y, w: tblBox.w, h: tblBox.h, rowH: rowH, autoPage: false,
1293
+ border: { pt: 0.5, color: STYLE.line }, margin: dense ? 1 : 3, fit: 'shrink', valign: 'middle' });
1218
1294
  }
1219
1295
  if (isNativeChart(c.type)) nativeChart(s, c, x, y, w, chH, dcols);
1220
1296
  else shapeChart(s, c, x, y, w, chH, dcols);
@@ -1472,7 +1548,11 @@ function infoMarimekko(s, sec, y0, y1) {
1472
1548
  const legend = (sec.legend || []).map(String);
1473
1549
  const cells = sec.cells || [];
1474
1550
  const total = cols.reduce((a, c) => a + (Number(c[1]) || 0), 0) || 1;
1475
- const plotH = (P.y1 - P.y0) - G.legendH - 0.1;
1551
+ /* 图例收在 P.y1 底带;列标签在图区下沿之上——二者留 0.12in 间隙,禁叠印(几何铁律②) */
1552
+ const legendH = Math.max(0.28, G.legendH || 0.32);
1553
+ const plotH = Math.max(0.5, (P.y1 - P.y0) - legendH - 0.40);
1554
+ const labelY = P.y0 + plotH + 0.04;
1555
+ const legendY = P.y1 - 0.22;
1476
1556
  const availW = CW - G.colGap * Math.max(0, cols.length - 1);
1477
1557
  const palette = [STYLE.accent, STYLE.faint, STYLE.body, STYLE.line];
1478
1558
  let cx0 = MX;
@@ -1486,20 +1566,20 @@ function infoMarimekko(s, sec, y0, y1) {
1486
1566
  s.addShape('rect', { x: cx0, y: cy0, w: cw, h: sh,
1487
1567
  fill: { color: palette[k % palette.length] }, line: { color: STYLE.bg, width: 0.75 } });
1488
1568
  s.addText(String(v) + (sec.unit || ''), { x: cx0, y: cy0 + sh / 2 - 0.13, w: cw, h: 0.26, align: 'center',
1489
- fontFace: STYLE.font, fontSize: sz(10), bold: true,
1569
+ fontFace: STYLE.font, fontSize: sz(10), bold: true, fit: 'shrink',
1490
1570
  color: k === 0 ? STYLE.onAccent : STYLE.ink });
1491
1571
  cy0 += sh;
1492
1572
  });
1493
- s.addText(String(c[0]), { x: cx0, y: P.y0 + plotH + 0.06, w: cw, h: 0.3, align: 'center',
1494
- fontFace: STYLE.font, fontSize: sz(10.5), bold: true, color: STYLE.body });
1573
+ s.addText(String(c[0]), { x: cx0, y: labelY, w: cw, h: 0.28, align: 'center',
1574
+ fontFace: STYLE.font, fontSize: sz(10.5), bold: true, color: STYLE.body, fit: 'shrink' });
1495
1575
  cx0 += cw + G.colGap;
1496
1576
  });
1497
1577
  let lx = MX;
1498
1578
  legend.forEach((lg, k) => {
1499
- s.addShape('rect', { x: lx, y: P.y1 - 0.2, w: 0.12, h: 0.12,
1579
+ s.addShape('rect', { x: lx, y: legendY + 0.06, w: 0.12, h: 0.12,
1500
1580
  fill: { color: palette[k % palette.length] }, line: { type: 'none' } });
1501
- s.addText(lg, { x: lx + 0.16, y: P.y1 - 0.26, w: 1.5, h: 0.26,
1502
- fontFace: STYLE.font, fontSize: sz(10), color: STYLE.body, valign: 'middle' });
1581
+ s.addText(lg, { x: lx + 0.16, y: legendY, w: 1.5, h: 0.24,
1582
+ fontFace: STYLE.font, fontSize: sz(10), color: STYLE.body, valign: 'middle', fit: 'shrink' });
1503
1583
  lx += 1.75;
1504
1584
  });
1505
1585
  }
@@ -1557,7 +1637,7 @@ function infoStreamgraph(s, sec, y0, y1) {
1557
1637
  fontFace: STYLE.font, fontSize: sz(9.5), color: STYLE.faint });
1558
1638
  });
1559
1639
  }
1560
- /* 表格:行高按可用高度自适应(research 密表 ≤16 行不再溢出) */
1640
+ /* 表格:行高按可用高度自适应;行数×minRowH 超高时压到 hardFloor 并告警,禁止溢出注释带 */
1561
1641
  function addTable(s, tbl, x, y, w, availH, opts) {
1562
1642
  opts = opts || {};
1563
1643
  const rowsIn = tbl.rows || [];
@@ -1565,17 +1645,32 @@ function addTable(s, tbl, x, y, w, availH, opts) {
1565
1645
  const n = rowsIn.length + 1;
1566
1646
  const maxRowH = opts.maxRowH != null ? opts.maxRowH : PT.table.rowH;
1567
1647
  const minRowH = opts.minRowH != null ? opts.minRowH : PT.table.rowHMin;
1568
- const rowH = Math.max(minRowH, Math.min(maxRowH, availH / Math.max(1, n)));
1569
- /* 行多则同步收字号(research 密排允许更小) */
1648
+ const headRowH = opts.headRowH != null ? opts.headRowH : (PT.table.headRowH || minRowH);
1649
+ /* 行数超过容量时优先压行高到 hardFloor,仍放不下则整体缩放到 availH(禁溢出) */
1650
+ const hardFloor = Math.min(minRowH, 0.22);
1651
+ const ideal = availH / Math.max(1, n);
1652
+ let rowH = Math.min(maxRowH, Math.max(hardFloor, ideal));
1653
+ if (n * rowH > availH + 0.01) {
1654
+ rowH = availH / Math.max(1, n);
1655
+ if (typeof console !== 'undefined') {
1656
+ console.warn(`addTable: ${n} 行×${rowH.toFixed(3)}in 仍超 availH=${availH.toFixed(2)},已压到可用高度`);
1657
+ }
1658
+ }
1659
+ /* 行多则同步收字号(research 密排允许更小;句子单元格仍走语义字号门禁) */
1570
1660
  const dense = rowH < 0.42;
1571
1661
  const headSz = sz(dense ? 11 : 13), bodySz = sz(dense ? 10 : 12.5);
1662
+ const hRowH = Math.min(headRowH, Math.max(hardFloor, rowH * 1.15));
1572
1663
  const rows = [head.map(h => ({ text: h, options: { bold: true, color: STYLE.onAccent,
1573
1664
  fill: { color: STYLE.accent }, fontSize: headSz, fontFace: STYLE.font, valign: 'middle' } }))]
1574
1665
  .concat(rowsIn.map(r => r.map(c => ({ text: String(c), options: { fontSize: bodySz,
1575
1666
  color: STYLE.body, fontFace: STYLE.font, valign: 'middle', fill: { color: STYLE.bg } } }))));
1667
+ /* rowH 数组:表头略高、正文均分剩余(pptxgenjs 支持逐行 rowH) */
1668
+ const rowHeights = [hRowH].concat(rowsIn.map(() => rowH));
1576
1669
  s.addTable(rows, { x, y, w, colW: tbl.colW, border: { pt: 0.75, color: STYLE.line },
1577
- rowH: rowH, autoPage: false, margin: dense ? 2 : 5 });
1578
- return n * rowH;
1670
+ rowH: rowHeights, autoPage: false, margin: dense ? 2 : 5,
1671
+ /* 文本框自动收缩:PowerPoint 字体度量 ≠ 浏览器,宁可缩字号也不溢出 */
1672
+ fit: 'shrink', valign: 'middle' });
1673
+ return hRowH + rowsIn.length * rowH;
1579
1674
  }
1580
1675
 
1581
1676
  /* 封面 */
@@ -2034,7 +2129,6 @@ CONTENT.sections.forEach((sec) => {
2034
2129
  { x: MX, y: isH ? PT.bar.hbarY0 : PT.bar.chartY, w: CW, h: 3 };
2035
2130
  const pts = (sec.points || []).filter(Boolean);
2036
2131
  let cx = reg.x, cw = reg.w;
2037
- /* 有侧栏要点时让出右栏,保证 HTML g-side / 怎么读这张图 与 PPTX 同源可核对 */
2038
2132
  if (pts.length && !isH) {
2039
2133
  const sideW = Math.min(3.6, Math.max(2.6, reg.w * 0.30));
2040
2134
  cw = Math.max(3.2, reg.w - sideW - 0.28);
@@ -2174,13 +2268,12 @@ CONTENT.sections.forEach((sec) => {
2174
2268
  const x = creg.x + col * (gw + cdC.gap), y = creg.y + row * (gh + cdC.gap);
2175
2269
  s.addShape('roundRect', { x, y, w: gw, h: gh, rectRadius: 0.08,
2176
2270
  fill: { color: STYLE.surface }, line: { color: STYLE.line, width: 0.75 } });
2177
- /* 卡片头路标(PPTX 侧图标等价物:accent 小方块,与 HTML .card__ico 的 accent 底同语义)
2178
- —— HTML 内联 SVG 图标不跨通道;用原生形状保持「路标不是装饰」的可扫读性 */
2271
+ /* 卡片头路标:真导出语义图标(SVG→PNG),与 HTML .card__ico 同源;无资产时回落 accent 方块 */
2179
2272
  const ico = 0.18;
2180
- s.addShape('roundRect', { x: x + 0.18, y: y + 0.2, w: ico, h: ico, rectRadius: 0.04,
2181
- fill: { color: STYLE.accent }, line: { type: 'none' }, objectName: trName('card') });
2273
+ const icoName = cd.icon || ICON_LIB.pickIconName(cd.title || '', i);
2274
+ addIcon(s, icoName, x + 0.18, y + 0.2, ico, STYLE.accent);
2182
2275
  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,
2183
- fontSize: sz(15), bold: true, color: STYLE.ink });
2276
+ fontSize: sz(15), bold: true, color: STYLE.ink, fit: 'shrink' });
2184
2277
  /* points 兼容:[[k,v]…] | [str…] | [{t,d}…](scaffold v9 契约 {title, points:[[k,v]]}) */
2185
2278
  const ptLines = (cd.points || []).map(pt => {
2186
2279
  if (Array.isArray(pt)) {
@@ -2196,7 +2289,7 @@ CONTENT.sections.forEach((sec) => {
2196
2289
  s.addText(ptLines.map(t => ({ text: t + '\n',
2197
2290
  options: { fontSize: sz(12), color: STYLE.body, fontFace: STYLE.font, breakLine: true } })),
2198
2291
  { 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),
2199
- fontFace: STYLE.font, valign: 'top', lineSpacing: sz(12) * 1.5 });
2292
+ fontFace: STYLE.font, valign: 'top', lineSpacing: sz(12) * 1.5, fit: 'shrink' });
2200
2293
  });
2201
2294
  } else if (type === 'split') {
2202
2295
  /* 双区自由组合页:左区与右区各可为 要点 / 图表 / 表格 / 图片。
@@ -2491,4 +2584,7 @@ pptx.writeFile({ fileName: out }).then(() => {
2491
2584
  }
2492
2585
  console.log('下一步(质检硬门禁,本技能内置脚本;--model 启用模型往返保真检查):');
2493
2586
  console.log(' python scripts/validate_pptx.py "' + out + '" --strict' + (modelArg ? ' --model="' + modelArg + '"' : ' --model="<报告.model.json>"'));
2587
+ if (OVERLAP_PREEMIT.length) {
2588
+ console.warn('[build_pptx] PREEMIT_OVERLAP 共 ' + OVERLAP_PREEMIT.length + ' 处(发射前几何自检,详见上方 warn)——请先修区域分配再交付。');
2589
+ }
2494
2590
  });
@@ -158,7 +158,27 @@ def main():
158
158
  print(f"[净化] 剥离 {stripped['count']} 处模型字段中的 HTML 标签: {stripped['samples']}")
159
159
  print(" 注意:引用在模型里写 [n] 纯文本;HTML 正文才用 <a class=\"cite\">。禁止把标签写进模型。")
160
160
 
161
- # 风格/模式/主题兜底:模型未写时取页面 data-style / data-mode / data-theme
161
+ # 图标名回填:模型 cards[].icon 缺失时,从 HTML .card__ico[data-icon] 同步
162
+ # (保证「HTML 真图标 ↔ PPTX 真导出」同名;旧模型/手改 HTML 也能对齐)
163
+ def _backfill_icons(model, html):
164
+ n = 0
165
+ html_icons = re.findall(r'data-icon="([^"]+)"', html)
166
+ for sec in model.get('sections') or []:
167
+ if not isinstance(sec, dict):
168
+ continue
169
+ cards = sec.get('cards')
170
+ if not isinstance(cards, list):
171
+ continue
172
+ for ci, cd in enumerate(cards):
173
+ if isinstance(cd, dict) and not cd.get('icon') and html_icons:
174
+ # 按序对齐 data-icon(render_from_model 与卡片同序输出)
175
+ if ci < len(html_icons):
176
+ cd['icon'] = html_icons[ci]
177
+ n += 1
178
+ return n
179
+ n_icons = _backfill_icons(model, txt)
180
+ if n_icons:
181
+ print(f"[图标] 从 HTML data-icon 回填 {n_icons} 个卡片图标名(PPTX 将真导出同名图标)")
162
182
  if not model.get('style'):
163
183
  sm = re.search(r'<html[^>]*data-style="([^"]+)"', txt)
164
184
  if sm:
@@ -0,0 +1,53 @@
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 校验三处图标键一致。
6
+ */
7
+ 'use strict';
8
+
9
+ 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
+
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 同策略) */
38
+ function pickIconName(title, idx) {
39
+ const t = String(title || '');
40
+ for (const key of ORDER) {
41
+ if (t.includes(key)) return key;
42
+ }
43
+ return ORDER[(idx || 0) % ORDER.length];
44
+ }
45
+
46
+ /** 渲染为带指定描边色的 SVG 字符串 */
47
+ function iconSvg(name, color) {
48
+ const raw = ICONS[name] || ICONS[ORDER[0]];
49
+ const c = color || '#1a56a8';
50
+ return raw.replace('stroke="currentColor"', `stroke="${c}"`);
51
+ }
52
+
53
+ module.exports = { ICONS, ORDER, pickIconName, iconSvg, SVG_OPEN };
@@ -0,0 +1,86 @@
1
+ /**
2
+ * TopPPT HTML · 图标栅格化(SVG → PNG data URI)
3
+ * 依赖 sharp(MIMO_NODE_MODULES / 技能目录 npm install)。
4
+ * build_pptx.js 在嵌入前调用;同一 name+color+size 结果缓存。
5
+ * 图标图 objectName 统一 `icon:` 前缀,validate_pptx 单独计数(不进 pictures 内容图门禁)。
6
+ */
7
+ 'use strict';
8
+
9
+ const path = require('path');
10
+ const { iconSvg, pickIconName, ICONS, ORDER } = require('./icon_lib.js');
11
+
12
+ let sharpLib = null;
13
+ function loadSharp() {
14
+ if (sharpLib !== null) return sharpLib;
15
+ try {
16
+ sharpLib = require('sharp');
17
+ } catch (e) {
18
+ try {
19
+ const roots = [];
20
+ if (process.env.MIMO_NODE_MODULES) roots.push(process.env.MIMO_NODE_MODULES);
21
+ roots.push(path.join(__dirname, '..', 'node_modules'));
22
+ roots.push(path.join(__dirname, 'node_modules'));
23
+ for (const r of roots) {
24
+ try {
25
+ sharpLib = require(path.join(r, 'sharp'));
26
+ break;
27
+ } catch (_) { /* next */ }
28
+ }
29
+ } catch (_) { /* fallthrough */ }
30
+ }
31
+ if (!sharpLib) sharpLib = false;
32
+ return sharpLib;
33
+ }
34
+
35
+ const _cache = new Map();
36
+
37
+ /**
38
+ * 生成图标 PNG data URI。
39
+ * @param {string} name 图标名(icon_lib.ICONS 键)
40
+ * @param {string} color 描边色 #rrggbb
41
+ * @param {number} px 输出边长(默认 64 = 2× 于 24pt@96dpi 的 0.18in 图标)
42
+ * @returns {Promise<string|null>} data:image/png;base64,... 或 null(无 sharp 时回落)
43
+ */
44
+ async function iconDataUri(name, color, px) {
45
+ const sharp = loadSharp();
46
+ if (!sharp) return null;
47
+ const size = Math.max(16, Math.min(256, px || 64));
48
+ const key = `${name}|${color}|${size}`;
49
+ if (_cache.has(key)) return _cache.get(key);
50
+ const svg = iconSvg(name, color);
51
+ try {
52
+ const buf = await sharp(Buffer.from(svg), { density: 300 })
53
+ .resize(size, size)
54
+ .png()
55
+ .toBuffer();
56
+ const uri = 'data:image/png;base64,' + buf.toString('base64');
57
+ _cache.set(key, uri);
58
+ return uri;
59
+ } catch (e) {
60
+ if (typeof console !== 'undefined') {
61
+ console.warn(`iconDataUri(${name}) 栅格化失败: ${e.message}`);
62
+ }
63
+ _cache.set(key, null);
64
+ return null;
65
+ }
66
+ }
67
+
68
+ /** 同步取缓存(须先 await 预热);无缓存返回 null */
69
+ function iconDataUriCached(name, color, px) {
70
+ return _cache.get(`${name}|${color}|${px || 64}`) || null;
71
+ }
72
+
73
+ /** 预热常用图标(卡片头 accent 色) */
74
+ async function prewarm(color, px) {
75
+ const out = {};
76
+ for (const n of ORDER) {
77
+ out[n] = await iconDataUri(n, color, px);
78
+ }
79
+ return out;
80
+ }
81
+
82
+ function hasSharp() {
83
+ return !!loadSharp();
84
+ }
85
+
86
+ module.exports = { iconDataUri, iconDataUriCached, prewarm, hasSharp, pickIconName, ICONS, ORDER };
@@ -1346,7 +1346,10 @@
1346
1346
  "judgmentPattern": "\\d|[二两三四五六七八九十半]|一(?!览|体|致|直|些|般|系列|同|起|方面)|是(?!否)|应(?!用|急|对|届)|需(?!求)|将(?!来|近)|增|降|优先|瓶颈|关键|高于|低于|快于|慢于|第一|唯一|从.{1,12}到|翻倍|过半|不足|领先|落后|卡在|分化|出现|成为|仍|并非|必须|才能|才是|最大|最浅|最深|马当先|明显|尚在|已(?!知)|提升|贡献|占比|领跑|先行|滞后|分野|显著|差异|穿越|兑现|放量|[::—–]"
1347
1347
  },
1348
1348
  "agenda": {
1349
- "$comment": "大纲条目数阈值:≤singleMax 单列;>singleMax 须 .agenda--2col(两列/两排);>splitMax 拆上篇/下篇或只列一级章节。titleMaxChars = 单条标题字数上限(超长截断+全称沉 notes)。scaffold / validate_report 消费。",
1349
+ "$comment": "大纲 = 章节大纲(3–7 章),不是逐页标题罗列。chapterMin/Max = 章数预算;含「参考资料」条目 +1 仍 ≤8。singleMax 单列上限;>singleMax 才 .agenda--2col(异常安全网)。splitMax 仅作 PPTX 自动分页兜底,不是设计目标。titleMaxChars = 单条标题字数上限。scaffold / validate_report 消费。",
1350
+ "chapterMin": 3,
1351
+ "chapterMax": 7,
1352
+ "chapterMaxWithRefs": 8,
1350
1353
  "singleMax": 8,
1351
1354
  "splitMax": 16,
1352
1355
  "titleMaxChars": 36,