@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
@@ -1408,7 +1408,35 @@
1408
1408
  "preferContentFirst": true,
1409
1409
  "warnBelow": 9.5
1410
1410
  },
1411
- "continuousTextRule": "语义连续的一句话 / so-what 主句 / 结论句 / 表格单元格正文不得拆成多个独立文本框(避免异常空格、断句、基线漂移);局部加粗高亮必须用同一文本框内富文本。"
1411
+ "continuousTextRule": "语义连续的一句话 / so-what 主句 / 结论句 / 表格单元格正文不得拆成多个独立文本框(避免异常空格、断句、基线漂移);局部加粗高亮必须用同一文本框内富文本。",
1412
+ "adaptiveText": {
1413
+ "$comment": "弹性文本单源(0.2.0):长标题/长列表/长表格单元格的降级规则。build_pptx.js 只读本表(AT),禁止在调用处散落 magic number。tier 语义:字符数按 CJK=1、拉丁/数字≈0.5 折算(与 estLines 同口径)。",
1414
+ "title": {
1415
+ "base": 30,
1416
+ "step1": 24,
1417
+ "longChars": 28,
1418
+ "longMax": 24,
1419
+ "xlongChars": 44,
1420
+ "xlongMax": 20
1421
+ },
1422
+ "list": {
1423
+ "denseItems": 6,
1424
+ "gapFactor": 0.5,
1425
+ "denseGapFactor": 0.25,
1426
+ "lineFactor": 1.45,
1427
+ "denseLineFactor": 1.3,
1428
+ "max": 14,
1429
+ "denseMax": 12
1430
+ },
1431
+ "table": {
1432
+ "cellMax": 12.5,
1433
+ "cellFloor": 9,
1434
+ "headMax": 13,
1435
+ "longCellChars": 24,
1436
+ "rowFloorIn": 0.22,
1437
+ "cellPadIn": 0.07
1438
+ }
1439
+ }
1412
1440
  },
1413
1441
  "anchorTolerance": {
1414
1442
  "$comment": "空间锚点容差(深度模式)。centerPct = 中心点偏差上限(占参考框局部宽高比例);keyPx = 关键锚点(图标/标签/箭头端点)偏差上限(px,按 1280×720 参考图计);exactPx = 用户要求 1:1 时的上限。",
@@ -1964,7 +1992,7 @@
1964
1992
  }
1965
1993
  },
1966
1994
  "qualityGates": {
1967
- "$comment": "v9+ 硬门禁。tagLeakPatterns / titleOnly / underfill / roundtripMin / chartSkew / chartOversize 同前。antiTruncation:截断迹象与溢出未拆页;chromeDriftIn:跨页 chrome y 容差(英寸)。",
1995
+ "$comment": "v9+ 硬门禁。tagLeakPatterns / titleOnly / underfill / roundtripMin / chartSkew / chartOversize 同前。antiTruncation:截断迹象与溢出未拆页;chromeDriftIn:跨页 chrome y 容差(英寸)。 contentCoverage:模型文本→渲染文本覆盖率下限(validate_report 只 WARN)。",
1968
1996
  "tagLeakPatterns": [
1969
1997
  "<a ",
1970
1998
  "</a>",
@@ -2033,7 +2061,8 @@
2033
2061
  "minCards": 3,
2034
2062
  "shortPlainChars": 12,
2035
2063
  "maxShortRatio": 0.5
2036
- }
2064
+ },
2065
+ "contentCoverage": 0.95
2037
2066
  },
2038
2067
  "imageAdmission": {
2039
2068
  "$comment": "图片资产准入门。forbidden = 禁止图片化的区域(主信息层与简单图表必须原生重建);allowed = 可保留为图片的复杂视觉资产;maxPageAreaPct = 单页图片覆盖面积上限(超过须逐项说明);fullSlideRiskPct = 按整页背景风险处理阈值(内容页默认失败);placeholderIsNative = 配图占位由原生形状渲染(不是图片,不触发 pictures 门禁)。",
@@ -3525,5 +3554,63 @@
3525
3554
  ]
3526
3555
  }
3527
3556
  }
3557
+ },
3558
+ "layoutVariants": {
3559
+ "$comment": "版式变体注册表(0.2.0 新增):复用既有页型/组件的组合版式,不新增 DSL 页型。variant 写在 section.variant;HTML 侧 render_from_model 按 base 页型渲染 + 变体类名;PPTX 侧 build_pptx 同名映射。新增变体:在此登记 + references/layout-variants.md 写骨架文档;禁止为变体另起渲染器。",
3560
+ "version": "0.1",
3561
+ "variants": {
3562
+ "bento-grid": {
3563
+ "name": "Bento 拼贴卡",
3564
+ "base": "cards",
3565
+ "html": ".grid.g-bento(复用 .card;首卡 .card--lead 跨 2 列)",
3566
+ "components": [
3567
+ "card",
3568
+ "card__ico",
3569
+ "pts_list"
3570
+ ],
3571
+ "sectionAttr": "data-variant=\"bento-grid\"",
3572
+ "when": "4–6 张卡、其中 1 张是主结论/总览时;信息密度高于等权卡片",
3573
+ "budget": {
3574
+ "maxCards": 6,
3575
+ "recommendCards": "4–5",
3576
+ "leadMaxPoints": 4,
3577
+ "otherMaxPoints": 3
3578
+ },
3579
+ "pptx": "cards 分支:首卡跨 2 列,其余顺排(build_pptx.js variant 分支)"
3580
+ },
3581
+ "timeline": {
3582
+ "name": "图标时间线",
3583
+ "base": "timeline",
3584
+ "html": "复用 .tl;phase 字典加 icon 键 → 节点图标 .tl__ico[data-icon]",
3585
+ "components": [
3586
+ "tl",
3587
+ "icon:<name>"
3588
+ ],
3589
+ "sectionAttr": "data-variant=\"timeline\"",
3590
+ "when": "阶段有明确类型语义(里程碑/风险/交付)且图标能加速扫读时;无语义用基础 timeline",
3591
+ "budget": {
3592
+ "maxPhases": 6,
3593
+ "iconPerPhase": 1,
3594
+ "descMaxChars": 60
3595
+ },
3596
+ "pptx": "timeline 分支:节点圆点旁贴 icon: 图标(build_pptx.js phaseKV 第 5 元)"
3597
+ },
3598
+ "2-col-feature": {
3599
+ "name": "双栏特性",
3600
+ "base": "twocol",
3601
+ "html": ".cols-2;左栏 .col--feature(复用 .card 语义:加粗标题 + accent 左线)",
3602
+ "components": [
3603
+ "cols-2",
3604
+ "card"
3605
+ ],
3606
+ "sectionAttr": "data-variant=\"2-col-feature\"",
3607
+ "when": "左栏是核心主张/特性、右栏是支撑细节时;两栏对等论证用基础 twocol",
3608
+ "budget": {
3609
+ "maxParas": 6,
3610
+ "featureMaxParas": 2
3611
+ },
3612
+ "pptx": "twocol 分支:首栏加 accent 左线 + 标题放大(build_pptx.js variant 分支)"
3613
+ }
3614
+ }
3528
3615
  }
3529
- }
3616
+ }
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """TopPPT HTML · 内置图标包生成器(单源)
4
+
5
+ 产出 assets/icons/:
6
+ - <name>.svg 24×24 手绘线性图标(stroke=currentColor, 1.8, 圆角),HTML 内联用
7
+ - <name>.png 512px 预渲染(cairosvg),PPTX 通道用(python-pptx 不认 SVG,见报告)
8
+ - index.json 关键词→图标映射 + 默认图标表
9
+
10
+ 用法:
11
+ python scripts/make_icon_pack.py # 写 SVG + index.json
12
+ python scripts/make_icon_pack.py --png # 另渲染 512px PNG(需 cairosvg)
13
+
14
+ 图标全部手绘原创(24 网格),不复制第三方图标库。
15
+ 其中 19 个沿用既有 icon_lib.js 的成熟路径(字节一致,现有渲染不变);
16
+ 流程 采用 references/icons.md 文档版(git-branch 式,与文档对齐)。
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ ROOT = Path(__file__).resolve().parent.parent
25
+ OUT = ROOT / 'assets' / 'icons'
26
+
27
+ SVG_OPEN = ('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" '
28
+ 'stroke="currentColor" stroke-width="1.8" stroke-linecap="round" '
29
+ 'stroke-linejoin="round">')
30
+
31
+ # name -> (inner_svg, label, keywords)
32
+ ICONS: dict[str, tuple[str, str, list[str]]] = {
33
+ # ── 既有 19(与 icon_lib.js 现行字节一致) ──
34
+ '增长': ('<path d="M22 7l-8.5 8.5-5-5L2 17"/><path d="M16 7h6v6"/>',
35
+ '增长/上升', ['增长', '上升', '提升', '增加', 'up']),
36
+ '下降': ('<path d="M22 17l-8.5-8.5-5 5L2 7"/><path d="M16 17h6v-6"/>',
37
+ '下降/回落', ['下降', '回落', '减少', '降低', 'down']),
38
+ '数据': ('<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"/>',
39
+ '数据库/底座', ['数据', '底座', 'database']),
40
+ '图表': ('<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"/>',
41
+ '柱状图', ['图表', '柱状', 'bar']),
42
+ '趋势': ('<path d="M3 3v18h18"/><path d="M7 14l4-4 3 3 5-6"/><path d="M15 7h4v4"/>',
43
+ '趋势/折线', ['趋势', '折线', 'line']),
44
+ '占比': ('<path d="M21.2 15.9A10 10 0 1 1 8 2.8"/><path d="M22 12A10 10 0 0 0 12 2v10z"/>',
45
+ '环形/占比', ['占比', '环形', '份额', 'donut']),
46
+ '表格': ('<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M3 15h18M9 3v18M15 3v18"/>',
47
+ '表格/网格', ['表格', '矩阵', 'table']),
48
+ '效率': ('<path d="M12 15l3.5-5.5"/><path d="M20.2 15a8.5 8.5 0 1 0-16.4 0"/>',
49
+ '仪表盘/效率', ['效率', '仪表盘', '性能', 'gauge']),
50
+ '成果': ('<circle cx="12" cy="8" r="6"/><path d="M15.5 13 17 22l-5-3-5 3 1.5-9"/>',
51
+ '奖章/成果', ['成果', '奖章', '奖项', 'medal']),
52
+ '安全': ('<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/>',
53
+ '盾牌/安全', ['安全', '盾牌', 'shield']),
54
+ '权限': ('<rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
55
+ '锁/权限', ['权限', '锁', 'lock']),
56
+ '检查': ('<circle cx="12" cy="12" r="10"/><path d="M8 12.5l2.5 2.5L16 9.5"/>',
57
+ '检查/对勾', ['检查', '对勾', '校验', 'check']),
58
+ '风险': ('<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"/>',
59
+ '警告/风险', ['风险', '警告', 'warning']),
60
+ '团队': ('<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"/>',
61
+ '用户/团队', ['团队', '人力', 'team']),
62
+ '计划': ('<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
63
+ '日历/计划', ['计划', '日历', '日程', 'calendar']),
64
+ '智能': ('<rect x="4" y="4" width="16" height="16" rx="2"/><path d="M9 9h6v6H9zM9 1v3M15 1v3M9 20v3M15 20v3M1 9h3M1 15h3M20 9h3M20 15h3"/>',
65
+ '芯片/AI', ['智能', '芯片', 'AI', 'chip']),
66
+ '洞察': ('<circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>',
67
+ '搜索/洞察', ['洞察', '搜索', '分析', 'search']),
68
+ '工具': ('<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"/>',
69
+ '扳手/工具', ['工具', '扳手', 'tool']),
70
+ '清单': ('<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/>',
71
+ '清单/条目', ['清单', '条目', 'checklist']),
72
+ # ── 流程:采用 references/icons.md 文档版(git-branch 式) ──
73
+ '流程': ('<circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="8" r="3"/><path d="M6 9v6M18 11a9 9 0 0 1-9 4"/>',
74
+ '分支/流程', ['流程', '分支', 'pipeline']),
75
+ # ── 新增 28(手绘原创) ──
76
+ '箭头': ('<path d="M4 12h15"/><path d="M13 6l6 6-6 6"/>',
77
+ '右箭头', ['箭头', '指向', 'arrow']),
78
+ '下载': ('<path d="M12 4v11"/><path d="M7 10.5l5 5 5-5"/><path d="M4 16v4h16v-4"/>',
79
+ '下载', ['下载', 'download']),
80
+ '上传': ('<path d="M12 20V9"/><path d="M7 13.5l5-5 5 5"/><path d="M4 16v4h16v-4"/>',
81
+ '上传', ['上传', 'upload']),
82
+ '分享': ('<circle cx="6" cy="12" r="2.5"/><circle cx="17.5" cy="5.5" r="2.5"/><circle cx="17.5" cy="18.5" r="2.5"/><path d="M8.3 10.9l6.9-4.2M8.3 13.1l6.9 4.2"/>',
83
+ '分享', ['分享', 'share']),
84
+ '循环': ('<path d="M20.5 15a8.5 8.5 0 1 1-2-8.9L21 8.5"/><path d="M21 3.5v5h-5"/>',
85
+ '循环/刷新', ['循环', '刷新', '迭代', '飞轮', 'refresh']),
86
+ '目标': ('<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><path d="M12 12h.01"/>',
87
+ '同心圆靶心', ['目标', 'target']),
88
+ '闪电': ('<path d="M13 2 3 14h7l-1 8 10-12h-7l1-8z"/>',
89
+ '闪电/高效', ['闪电', '高效', '快速', '快捷']),
90
+ '勾选': ('<path d="M4 12.5l5 5L20 6.5"/>',
91
+ '对勾', ['勾选', '完成', 'done']),
92
+ '用户': ('<circle cx="12" cy="8" r="4"/><path d="M4 21c0-3.5 3.6-5.5 8-5.5s8 2 8 5.5"/>',
93
+ '单人/用户', ['用户', '客户', 'user']),
94
+ '组织': ('<rect x="9" y="2" width="6" height="6" rx="1"/><rect x="2" y="16" width="6" height="6" rx="1"/><rect x="16" y="16" width="6" height="6" rx="1"/><path d="M12 8v4M5 12h14M5 12v4M19 12v4"/>',
95
+ '组织架构/层级', ['组织', '层级', '架构', 'org']),
96
+ '灯泡': ('<path d="M12 3a6 6 0 0 0-3.4 11c.7.5 1.4 1.3 1.4 2.5h4c0-1.2.7-2 1.4-2.5A6 6 0 0 0 12 3z"/><path d="M10 19.5h4"/><path d="M10.8 22h2.4"/>',
97
+ '灯泡/创意', ['灯泡', '创意', '想法', 'idea']),
98
+ '文档': ('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"/><path d="M14 2v6h6"/><path d="M9 13h6M9 17h6"/>',
99
+ '文档/报告', ['文档', '报告', '文件', 'doc']),
100
+ '云': ('<path d="M7 18.5a4 4 0 0 1-.4-7.98A5.5 5.5 0 0 1 17.3 11 3.9 3.9 0 0 1 17 18.5H7z"/>',
101
+ '云/平台', ['云', '平台', 'cloud']),
102
+ '邮件': ('<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M2.5 7.5 12 13l9.5-5.5"/>',
103
+ '信封/邮件', ['邮件', '邮箱', 'mail']),
104
+ '手机': ('<rect x="7" y="2" width="10" height="20" rx="2.5"/><path d="M11 18.5h2"/>',
105
+ '手机', ['手机', '移动', 'phone']),
106
+ '对话': ('<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 16v4l4.5-4"/>',
107
+ '对话框', ['对话', '沟通', 'chat']),
108
+ '位置': ('<path d="M12 21.5s-7-5.3-7-11a7 7 0 0 1 14 0c0 5.7-7 11-7 11z"/><circle cx="12" cy="10.5" r="2.5"/>',
109
+ '定位/地点', ['位置', '地点', 'pin']),
110
+ '时间': ('<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3.5 2"/>',
111
+ '时钟/时间', ['时间', '时钟', 'clock']),
112
+ '金钱': ('<rect x="2" y="6" width="20" height="12" rx="2"/><circle cx="12" cy="12" r="2.8"/><path d="M5.5 12h.01M18.5 12h.01"/>',
113
+ '纸币/财务', ['金钱', '成本', '财务', 'money']),
114
+ '链接': ('<path d="M10 14a4.5 4.5 0 0 0 6.4.4l2.8-2.8a4.5 4.5 0 0 0-6.4-6.4l-1.6 1.6"/><path d="M14 10a4.5 4.5 0 0 0-6.4-.4l-2.8 2.8a4.5 4.5 0 0 0 6.4 6.4l1.6-1.6"/>',
115
+ '链条/链接', ['链接', 'link']),
116
+ '设置': ('<circle cx="12" cy="12" r="3.2"/><path d="M12 2.5v2.8M12 18.7v2.8M2.5 12h2.8M18.7 12h2.8M5.2 5.2l2 2M16.8 16.8l2 2M18.8 5.2l-2 2M7.2 16.8l-2 2"/>',
117
+ '齿轮/设置', ['设置', '齿轮', '配置', 'setting']),
118
+ '信息': ('<circle cx="12" cy="12" r="9"/><path d="M12 11v5"/><path d="M12 7.5h.01"/>',
119
+ '信息/提示', ['信息', '提示', 'info']),
120
+ '星标': ('<path d="m12 2.8 2.8 5.8 6.4.9-4.6 4.5 1.1 6.3-5.7-3-5.7 3 1.1-6.3L2.8 9.5l6.4-.9z"/>',
121
+ '五角星', ['星标', '收藏', 'star']),
122
+ '书签': ('<path d="M6.5 3h11V21l-5.5-3.8L6.5 21z"/>',
123
+ '书签', ['书签', 'bookmark']),
124
+ '文件夹': ('<path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V18a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
125
+ '文件夹/资产', ['文件夹', '资产', 'folder']),
126
+ '火箭': ('<path d="M12 2.5c2.6 2.2 4 5.6 4 9.2l-4 2.8-4-2.8c0-3.6 1.4-7 4-9.2z"/><circle cx="12" cy="9" r="1.6"/><path d="M8.3 12.5 5 16.5l3.6-.6M15.7 12.5l3.3 4-3.6-.6"/><path d="M10.8 16.8c0 1.6.5 2.6 1.2 3.7.7-1.1 1.2-2.1 1.2-3.7"/>',
127
+ '火箭/发射', ['火箭', '发射', 'rocket']),
128
+ '旗帜': ('<path d="M5 21.5V4"/><path d="M5 4.5h12.5l-2.8 3.8 2.8 3.7H5"/>',
129
+ '旗帜/里程碑', ['旗帜', '里程碑', 'flag']),
130
+ '罗盘': ('<circle cx="12" cy="12" r="9"/><path d="m15.5 8.5-2.2 4.8-4.8 2.2 2.2-4.8z"/>',
131
+ '罗盘/方向', ['罗盘', '方向', '导航', 'compass']),
132
+ }
133
+
134
+ DEFAULTS = ['信息', '检查', '数据', '图表', '目标', '团队', '工具']
135
+
136
+
137
+ def main() -> int:
138
+ OUT.mkdir(parents=True, exist_ok=True)
139
+ assert len(ICONS) == 48, f'图标数应为 48,实际 {len(ICONS)}'
140
+ assert all(d in ICONS for d in DEFAULTS), '默认图标必须在包内'
141
+ index = {'version': '0.2.0', 'count': len(ICONS),
142
+ 'defaults': DEFAULTS, 'icons': {}}
143
+ for name, (inner, label, keywords) in ICONS.items():
144
+ svg = SVG_OPEN + inner + '</svg>'
145
+ (OUT / f'{name}.svg').write_text(svg, encoding='utf-8')
146
+ index['icons'][name] = {
147
+ 'file': f'{name}.svg',
148
+ 'png': f'{name}.png',
149
+ 'label': label,
150
+ 'keywords': keywords,
151
+ 'default': name in DEFAULTS,
152
+ }
153
+ (OUT / 'index.json').write_text(
154
+ json.dumps(index, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
155
+ print(f'[make_icon_pack] OK: {len(ICONS)} svg + index.json → {OUT}')
156
+ if '--png' in sys.argv:
157
+ try:
158
+ import cairosvg
159
+ except ImportError:
160
+ print('[make_icon_pack] FAIL: 需 cairosvg(pip install cairosvg)才能渲染 PNG')
161
+ return 1
162
+ for name, (inner, _label, _kw) in ICONS.items():
163
+ svg = SVG_OPEN.replace('currentColor', '#1a56a8') + inner + '</svg>'
164
+ cairosvg.svg2png(bytestring=svg.encode('utf-8'),
165
+ write_to=str(OUT / f'{name}.png'),
166
+ output_width=512, output_height=512)
167
+ print(f'[make_icon_pack] OK: {len(ICONS)} png(512px) → {OUT}')
168
+ return 0
169
+
170
+
171
+ if __name__ == '__main__':
172
+ raise SystemExit(main())
@@ -39,10 +39,13 @@
39
39
  "lead",
40
40
  "soWhat",
41
41
  "footnote",
42
+ "note",
42
43
  "flags",
43
44
  "image",
44
45
  "exhibitNo",
45
- "layoutPreset"
46
+ "layoutPreset",
47
+ "variant",
48
+ "anchor"
46
49
  ],
47
50
  "mutualExclusionHint": "verdict 与 soWhat 互斥:二者共用 annotation 结论条槽位,同页双填必叠印。comparison 页填 verdict 时不要再填 soWhat(渲染器已自动二选一,但模型侧应只给一个)。",
48
51
  "flagsHint": "flags = 待核实/待二次修改条目字符串数组(如 '2027 增速 xx%(口径未定)');渲染为 accent 强调色标注行,提示用户核对。",
@@ -95,9 +95,18 @@ def kp(k, v) -> str:
95
95
  return f'<li><strong>{k}。</strong>{v}</li>' if k else f'<li>{v}</li>'
96
96
 
97
97
 
98
- def open_sec(i: int, ptype: str, extra: str = '') -> str:
98
+ def sec_anchor(i: int, sec: dict) -> str:
99
+ """页锚点 id:模型 anchor 优先,否则 s{i}(agenda 链接与 open_sec 同源,禁各写一份)。"""
100
+ a = str((sec or {}).get('anchor') or '').strip()
101
+ if re.fullmatch(r'[A-Za-z][A-Za-z0-9_-]*', a):
102
+ return a
103
+ return f's{i}'
104
+
105
+
106
+ def open_sec(i: int, ptype: str, extra: str = '', sec: dict = None) -> str:
99
107
  skel = PAGE_TO_PRESET.get(ptype, 'P4')
100
- return (f'<section class="band" id="s{i}" data-skel="{skel}" '
108
+ sid = sec_anchor(i, sec)
109
+ return (f'<section class="band" id="{sid}" data-skel="{skel}" '
101
110
  f'data-page-type="{ptype}"{extra}>')
102
111
 
103
112
 
@@ -118,10 +127,17 @@ def sowhat(sec: dict) -> str:
118
127
 
119
128
 
120
129
  def footnote(sec: dict) -> str:
130
+ # footnote 与 note 共用来源行(与 build_pptx 注释带同口径:两者都不丢)
121
131
  t = sec.get('footnote')
122
- if not t:
132
+ n = sec.get('note')
133
+ parts = []
134
+ if t:
135
+ parts.append(cite(t))
136
+ if n and n != t:
137
+ parts.append(f'注:{cite(n)}')
138
+ if not parts:
123
139
  return ''
124
- return f' <div class="footnote">{cite(t)}</div>\n'
140
+ return f' <div class="footnote">{" ".join(parts)}</div>\n'
125
141
 
126
142
 
127
143
  def flags_block(sec: dict) -> str:
@@ -134,55 +150,49 @@ def flags_block(sec: dict) -> str:
134
150
 
135
151
 
136
152
  def wrap(i: int, ptype: str, body: str, sec: dict, extra: str = '') -> str:
137
- return (f'{open_sec(i, ptype, extra)}\n <div class="wrap">\n'
153
+ return (f'{open_sec(i, ptype, extra, sec)}\n <div class="wrap">\n'
138
154
  f'{shead(sec)}\n{body}{sowhat(sec)}{footnote(sec)}{flags_block(sec)}'
139
155
  f' </div>\n</section>')
140
156
 
141
157
 
142
- # ── 语义图标库(高频取码 · 与 references/icons.md 同源;PPTX 侧由 accent 方块路标对应)──
143
- _SVG_OPEN = ('<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" '
144
- 'stroke-linecap="round" stroke-linejoin="round">')
145
- ICONS: dict[str, str] = {
146
- '增长': _SVG_OPEN + '<path d="M22 7l-8.5 8.5-5-5L2 17"/><path d="M16 7h6v6"/></svg>',
147
- '下降': _SVG_OPEN + '<path d="M22 17l-8.5-8.5-5 5L2 7"/><path d="M16 17h6v-6"/></svg>',
148
- '数据': _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>',
149
- '图表': _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>',
150
- '趋势': _SVG_OPEN + '<path d="M3 3v18h18"/><path d="M7 14l4-4 3 3 5-6"/><path d="M15 7h4v4"/></svg>',
151
- '占比': _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>',
152
- '表格': _SVG_OPEN + '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M3 15h18M9 3v18M15 3v18"/></svg>',
153
- '效率': _SVG_OPEN + '<path d="M12 15l3.5-5.5"/><path d="M20.2 15a8.5 8.5 0 1 0-16.4 0"/></svg>',
154
- '成果': _SVG_OPEN + '<circle cx="12" cy="8" r="6"/><path d="M15.5 13 17 22l-5-3-5 3 1.5-9"/></svg>',
155
- '安全': _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>',
156
- '权限': _SVG_OPEN + '<rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>',
157
- '检查': _SVG_OPEN + '<circle cx="12" cy="12" r="10"/><path d="M8 12.5l2.5 2.5L16 9.5"/></svg>',
158
- '风险': _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>',
159
- '团队': _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>',
160
- '流程': _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>',
161
- '计划': _SVG_OPEN + '<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>',
162
- '智能': _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>',
163
- '洞察': _SVG_OPEN + '<circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>',
164
- '工具': _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>',
165
- '清单': _SVG_OPEN + '<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>',
166
- }
158
+ # ── 语义图标库(单源:assets/icons/ + index.json;与 icon_lib.js / PPTX 真导出同源)──
159
+ _ICON_DIR = Path(__file__).resolve().parent.parent / 'assets' / 'icons'
160
+ _ICON_INDEX: dict = {}
161
+ try:
162
+ _ICON_INDEX = json.loads((_ICON_DIR / 'index.json').read_text(encoding='utf-8'))
163
+ except OSError:
164
+ _ICON_INDEX = {}
165
+
166
+
167
+ def _load_icons() -> 'dict[str, str]':
168
+ out: dict[str, str] = {}
169
+ for name in (_ICON_INDEX.get('icons') or {}):
170
+ try:
171
+ out[name] = (_ICON_DIR / f'{name}.svg').read_text(encoding='utf-8').strip()
172
+ except OSError:
173
+ continue
174
+ return out
175
+
176
+
177
+ ICONS: dict[str, str] = _load_icons()
167
178
  _ICONS_ORDER = list(ICONS.keys())
179
+ _ICON_DEFAULTS = [n for n in (_ICON_INDEX.get('defaults') or []) if n in ICONS] or _ICONS_ORDER
168
180
 
169
181
 
170
182
  def pick_icon(title: str = '', idx: int = 0) -> str:
171
- """按标题语义挑图标;未命中则按序轮换(保持同屏同家族、不堆砌)。"""
172
- t = str(title or '')
173
- for key, svg in ICONS.items():
174
- if key in t:
175
- return svg
176
- return ICONS[_ICONS_ORDER[idx % len(_ICONS_ORDER)]]
183
+ """按标题语义挑图标 SVG;未命中则在默认表内轮换(选不出用默认,不盲目轮换全量)。"""
184
+ return ICONS[pick_icon_name(title, idx)]
177
185
 
178
186
 
179
187
  def pick_icon_name(title: str = '', idx: int = 0) -> str:
180
- """图标名(与 icon_lib.js / PPTX 真导出同名)。"""
188
+ """图标名(与 icon_lib.js / PPTX 真导出同名;关键词来自 index.json)。"""
181
189
  t = str(title or '')
182
- for key in ICONS:
183
- if key in t:
190
+ meta = _ICON_INDEX.get('icons') or {}
191
+ for key in _ICONS_ORDER:
192
+ kws = (meta.get(key) or {}).get('keywords') or [key]
193
+ if any(kw and kw in t for kw in kws):
184
194
  return key
185
- return _ICONS_ORDER[idx % len(_ICONS_ORDER)]
195
+ return _ICON_DEFAULTS[idx % len(_ICON_DEFAULTS)]
186
196
 
187
197
 
188
198
  def card_head(title: str, idx: int = 0, icon_name: str | None = None) -> str:
@@ -253,16 +263,23 @@ def r_metrics(i, sec):
253
263
 
254
264
 
255
265
  def r_kpi(i, sec):
266
+ # 大数指标页:hero 大数字 + 支撑指标行(metrics,可选)+ 要点;三者缺一不丢其余
256
267
  hero = sec.get('hero') or []
257
268
  val = hero[0] if hero else ''
258
269
  lab = hero[1] if len(hero) > 1 else ''
259
270
  delta = hero[2] if len(hero) > 2 else ''
271
+ left = ''
272
+ if val or lab:
273
+ left = (
274
+ ' <div class="stack gap-3"><div class="t-metric" style="color:var(--accent)">'
275
+ f'{esc(val)}</div>'
276
+ f'<div class="t-h3">{esc(lab)}</div>'
277
+ + (f'<div class="t-sm" style="color:var(--accent);font-weight:600">{esc(delta)}</div>' if delta else '')
278
+ + '</div>\n')
279
+ mets = metrics_row(sec.get('metrics')) if sec.get('metrics') else ''
260
280
  body = (
261
281
  ' <div class="grid g-hero rv a-c">\n'
262
- f' <div class="stack gap-3"><div class="t-metric" style="color:var(--accent)">{esc(val)}</div>'
263
- f'<div class="t-h3">{esc(lab)}</div>'
264
- + (f'<div class="t-sm" style="color:var(--accent);font-weight:600">{esc(delta)}</div>' if delta else '')
265
- + '</div>\n'
282
+ + left + mets
266
283
  + ' <div class="grid g-2">\n'
267
284
  + pts_list(sec.get('points') or [['支撑', '一句话。']])
268
285
  + '\n </div>\n </div>\n')
@@ -339,14 +356,20 @@ def r_exhibit(i, sec):
339
356
 
340
357
  def r_twocol(i, sec):
341
358
  paras = sec.get('paragraphs') or []
359
+ variant = sec.get('variant') or ''
342
360
  cols = []
343
- for p in paras:
361
+ for pi, p in enumerate(paras):
344
362
  if isinstance(p, (list, tuple)) and len(p) >= 2:
345
363
  cols.append(f' <p class="t-body"><strong>{esc(p[0])}。</strong>{cite(p[1])}</p>')
346
364
  else:
347
365
  cols.append(f' <p class="t-body">{cite(p)}</p>')
366
+ # 变体 2-col-feature:首栏为特性栏(复用 .card 语义:加粗标题 + accent 左线)
367
+ extra = ''
368
+ if variant == '2-col-feature' and cols:
369
+ cols[0] = cols[0].replace('class="t-body"', 'class="t-body col--feature"', 1)
370
+ extra = ' data-variant="2-col-feature"'
348
371
  body = ' <div class="cols-2 rv">\n' + '\n'.join(cols) + '\n </div>\n'
349
- return wrap(i, 'twocol', body, sec)
372
+ return wrap(i, 'twocol', body, sec, extra)
350
373
 
351
374
 
352
375
  def r_threecol(i, sec):
@@ -365,11 +388,16 @@ def r_threecol(i, sec):
365
388
 
366
389
  def r_cards(i, sec):
367
390
  cards = sec.get('cards') or []
391
+ variant = sec.get('variant') or ''
368
392
  n = int(sec.get('columns') or min(3, max(1, len(cards))))
369
393
  cls = {1: 'g-2', 2: 'g-2', 3: 'g-3', 4: 'g-4'}.get(n, 'g-3')
370
394
  # 纯卡片栅格强制等高(g-2--equal / g-3--equal),与 CSS / layout-grammar「同行卡片 stretch」一致
371
395
  if cls in ('g-2', 'g-3', 'g-4'):
372
396
  cls = cls + '--equal'
397
+ extra = ''
398
+ if variant == 'bento-grid':
399
+ # 变体 bento-grid:首卡跨 2 列(.card--lead),复用 .card 组件
400
+ cls, extra = 'g-bento', ' data-variant="bento-grid"'
373
401
  blocks = []
374
402
  for ci, cd in enumerate(cards):
375
403
  if isinstance(cd, dict):
@@ -380,11 +408,12 @@ def r_cards(i, sec):
380
408
  title, points, icon_name = cd[0], [['', cd[1]]], None
381
409
  else:
382
410
  title, points, icon_name = (cd or ''), [], None
411
+ lead_cls = ' card--lead' if (variant == 'bento-grid' and ci == 0) else ''
383
412
  blocks.append(
384
- f' <div class="card">{card_head(str(title), ci, icon_name)}\n'
413
+ f' <div class="card{lead_cls}">{card_head(str(title), ci, icon_name)}\n'
385
414
  f' {pts_list(points)}</div>')
386
415
  body = f' <div class="grid {cls} rv">\n' + '\n'.join(blocks) + '\n </div>\n'
387
- return wrap(i, 'cards', body, sec)
416
+ return wrap(i, 'cards', body, sec, extra)
388
417
 
389
418
 
390
419
  def r_split(i, sec):
@@ -519,6 +548,9 @@ def r_image(i, sec):
519
548
  else:
520
549
  grid_cls = f'media-grid media-grid--{min(4, max(2, n))}'
521
550
  media = f' <div class="{grid_cls} rv">\n' + '\n'.join(cells) + '\n </div>\n'
551
+ # 组 caption(图编号):多图版式同样渲染,与 PPTX img.caption 同口径(此前仅单图分支渲染)
552
+ if cap:
553
+ media += f' <p class="media__src">{cite(cap)}</p>\n'
522
554
  else:
523
555
  src = im.get('src') or ''
524
556
  ph = bool(im.get('placeholder')) or not src
@@ -589,23 +621,32 @@ def r_lane(i, sec):
589
621
 
590
622
  def r_timeline(i, sec):
591
623
  items = []
624
+ variant = sec.get('variant') or ''
592
625
  for ph in (sec.get('phases') or []):
593
626
  if isinstance(ph, (list, tuple)):
594
627
  lab, name, desc = (list(ph) + ['', '', ''])[:3]
595
628
  state = ph[3] if len(ph) > 3 else ''
629
+ icon_name = ph[4] if len(ph) > 4 else ''
596
630
  else:
597
631
  lab, name, desc, state = ph.get('label', ''), ph.get('name', ''), ph.get('d', ''), ph.get('s', '')
632
+ icon_name = ph.get('icon', '')
598
633
  cls = 'tl__i'
599
634
  if state == 'done':
600
635
  cls += ' tl__i--done'
601
636
  elif state == 'now':
602
637
  cls += ' tl__i--now'
638
+ # 变体 timeline:节点图标(icon:<name>,无图标回落圆点)
639
+ dot = '<div class="tl__d"></div>'
640
+ if variant == 'timeline' and icon_name and icon_name in ICONS:
641
+ dot = (f'<div class="tl__ico" data-icon="{esc(icon_name)}">'
642
+ f'{ICONS[icon_name]}</div>')
603
643
  items.append(
604
- f' <div class="{cls}"><div class="tl__d"></div>'
644
+ f' <div class="{cls}">{dot}'
605
645
  f'<div class="tl__l">{esc(lab)}</div><div class="tl__t">{esc(name)}</div>'
606
646
  f'<p class="t-body">{cite(desc)}</p></div>')
607
647
  body = ' <div class="fig rv"><div class="tl">\n' + '\n'.join(items) + '\n </div></div>\n'
608
- return wrap(i, 'timeline', body, sec)
648
+ extra = ' data-variant="timeline"' if variant == 'timeline' else ''
649
+ return wrap(i, 'timeline', body, sec, extra)
609
650
 
610
651
 
611
652
  def r_steps(i, sec):
@@ -675,18 +716,34 @@ def r_pyramid(i, sec):
675
716
 
676
717
 
677
718
  def r_halftable(i, sec):
678
- """半表半图:g-half 左表右图(校验 TYPE_FEATURE.halftable)。"""
719
+ """半表半图:g-half 左表右图(校验 TYPE_FEATURE.halftable)。
720
+ research 模式带 exhibitNo 时:右图按 Exhibit 规范加编号徽标与来源行,表侧不丢
721
+ (此前 research 统一走 r_exhibit 会静默丢弃 table——内容覆盖率门禁捕获)。"""
679
722
  tbl = sec.get('table') or {}
680
723
  ch = sec.get('chart') or {}
681
724
  head = tbl.get('head') or []
682
725
  rows = tbl.get('rows') or []
683
726
  th = ''.join(f'<th>{esc(h)}</th>' for h in head)
684
727
  trs = ''.join('<tr>' + ''.join(f'<td>{cite(c)}</td>' for c in row) + '</tr>' for row in rows)
728
+ no = sec.get('exhibitNo')
729
+ if no:
730
+ fig = (
731
+ ' <div class="fig"><div class="exhibit rv">\n'
732
+ f' <div class="exhibit__hd"><span class="exhibit__no">Exhibit {esc(no)}</span>'
733
+ f'<span class="exhibit__t">{esc(sec.get("title") or "")}</span></div>\n'
734
+ f'{chart_svg(ch, i)}'
735
+ f' <div class="exhibit__src">{cite(sec.get("footnote") or "来源:待补")}</div>\n'
736
+ ' </div></div>\n')
737
+ sec = dict(sec)
738
+ sec.pop('footnote', None) # 已进来源行,避免 wrap 再渲染一次
739
+ else:
740
+ fig = (
741
+ f' <div class="fig"><div class="fig__cap">{esc(ch.get("cap") or "互证图")}</div>\n'
742
+ f'{chart_svg(ch, i)} </div>\n')
685
743
  body = (
686
744
  ' <div class="grid g-half g-2 rv a-start">\n'
687
745
  f' <div class="tbl-wrap"><table><thead><tr>{th}</tr></thead><tbody>{trs}</tbody></table></div>\n'
688
- f' <div class="fig"><div class="fig__cap">{esc(ch.get("cap") or "互证图")}</div>\n'
689
- f'{chart_svg(ch, i)} </div>\n'
746
+ f'{fig}'
690
747
  ' </div>\n')
691
748
  return wrap(i, 'halftable', body, sec)
692
749
 
@@ -800,8 +857,10 @@ def render_body(model: dict) -> str:
800
857
  title = ag[1] if isinstance(ag, (list, tuple)) and len(ag) > 1 else ''
801
858
  desc = ag[2] if isinstance(ag, (list, tuple)) and len(ag) > 2 else ''
802
859
  href_i = chapter_first.get(str(num).zfill(2)) or chapter_first.get(str(num)) or gi
860
+ # 锚点与 open_sec 同源:该页有 anchor 则链到命名锚点
861
+ href_id = sec_anchor(href_i, secs[href_i - 1] if 0 < href_i <= len(secs) else {})
803
862
  lis.append(
804
- f' <li class="agenda__i"><a class="agenda__a" href="#s{href_i}">'
863
+ f' <li class="agenda__i"><a class="agenda__a" href="#{href_id}">'
805
864
  f'<span class="agenda__n">{esc(num)}</span>'
806
865
  f'<span><span class="agenda__t">{esc(title)}</span>'
807
866
  f'<div class="agenda__d">{esc(desc)}</div></span></a></li>')
@@ -822,7 +881,9 @@ def render_body(model: dict) -> str:
822
881
  sec = dict(sec)
823
882
  sec['exhibitNo'] = ex_n
824
883
  fn = RENDERERS.get(t, r_points)
825
- if is_research_exhibit:
884
+ if is_research_exhibit and t != 'halftable':
885
+ # halftable 在 research 下仍走 r_halftable(表+图),仅借 exhibitNo 加 Exhibit 徽标;
886
+ # 统一走 r_exhibit 会静默丢弃 table(内容覆盖率门禁捕获,0.2.0 修)。
826
887
  fn = r_exhibit
827
888
  try:
828
889
  parts.append(fn(i, sec))