@topmindspace/tms-skills 0.3.6 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/README.en.md +12 -9
  3. package/README.md +12 -9
  4. package/bin/tms-skills.js +153 -13
  5. package/package.json +1 -1
  6. package/top-ppt-html/README.en.md +2 -2
  7. package/top-ppt-html/README.md +1 -1
  8. package/top-ppt-html/SKILL.md +8 -7
  9. package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.html +58 -184
  10. package/top-ppt-html/assets/examples/2026-09-09-architecture-graphite-dark.model.json +13 -3
  11. package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.html +58 -337
  12. package/top-ppt-html/assets/examples/2026-09-09-presentation-business-blue.model.json +15 -1
  13. package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.html +58 -930
  14. package/top-ppt-html/assets/examples/2026-09-09-research-mckinsey.model.json +15 -1
  15. package/top-ppt-html/assets/examples/2026-09-26-topmind-tms-skills-showcase.html +141 -31
  16. package/top-ppt-html/assets/icons/index.json +557 -0
  17. package/top-ppt-html/assets/icons//344/270/212/344/274/240.png +0 -0
  18. package/top-ppt-html/assets/icons//344/270/212/344/274/240.svg +1 -0
  19. package/top-ppt-html/assets/icons//344/270/213/350/275/275.png +0 -0
  20. package/top-ppt-html/assets/icons//344/270/213/350/275/275.svg +1 -0
  21. package/top-ppt-html/assets/icons//344/270/213/351/231/215.png +0 -0
  22. package/top-ppt-html/assets/icons//344/270/213/351/231/215.svg +1 -0
  23. package/top-ppt-html/assets/icons//344/271/246/347/255/276.png +0 -0
  24. package/top-ppt-html/assets/icons//344/271/246/347/255/276.svg +1 -0
  25. package/top-ppt-html/assets/icons//344/272/221.png +0 -0
  26. package/top-ppt-html/assets/icons//344/272/221.svg +1 -0
  27. package/top-ppt-html/assets/icons//344/275/215/347/275/256.png +0 -0
  28. package/top-ppt-html/assets/icons//344/275/215/347/275/256.svg +1 -0
  29. package/top-ppt-html/assets/icons//344/277/241/346/201/257.png +0 -0
  30. package/top-ppt-html/assets/icons//344/277/241/346/201/257.svg +1 -0
  31. package/top-ppt-html/assets/icons//345/210/206/344/272/253.png +0 -0
  32. package/top-ppt-html/assets/icons//345/210/206/344/272/253.svg +1 -0
  33. package/top-ppt-html/assets/icons//345/213/276/351/200/211.png +0 -0
  34. package/top-ppt-html/assets/icons//345/213/276/351/200/211.svg +1 -0
  35. package/top-ppt-html/assets/icons//345/215/240/346/257/224.png +0 -0
  36. package/top-ppt-html/assets/icons//345/215/240/346/257/224.svg +1 -0
  37. package/top-ppt-html/assets/icons//345/233/242/351/230/237.png +0 -0
  38. package/top-ppt-html/assets/icons//345/233/242/351/230/237.svg +1 -0
  39. package/top-ppt-html/assets/icons//345/233/276/350/241/250.png +0 -0
  40. package/top-ppt-html/assets/icons//345/233/276/350/241/250.svg +1 -0
  41. package/top-ppt-html/assets/icons//345/242/236/351/225/277.png +0 -0
  42. package/top-ppt-html/assets/icons//345/242/236/351/225/277.svg +1 -0
  43. package/top-ppt-html/assets/icons//345/256/211/345/205/250.png +0 -0
  44. package/top-ppt-html/assets/icons//345/256/211/345/205/250.svg +1 -0
  45. package/top-ppt-html/assets/icons//345/257/271/350/257/235.png +0 -0
  46. package/top-ppt-html/assets/icons//345/257/271/350/257/235.svg +1 -0
  47. package/top-ppt-html/assets/icons//345/267/245/345/205/267.png +0 -0
  48. package/top-ppt-html/assets/icons//345/267/245/345/205/267.svg +1 -0
  49. package/top-ppt-html/assets/icons//345/276/252/347/216/257.png +0 -0
  50. package/top-ppt-html/assets/icons//345/276/252/347/216/257.svg +1 -0
  51. package/top-ppt-html/assets/icons//346/210/220/346/236/234.png +0 -0
  52. package/top-ppt-html/assets/icons//346/210/220/346/236/234.svg +1 -0
  53. package/top-ppt-html/assets/icons//346/211/213/346/234/272.png +0 -0
  54. package/top-ppt-html/assets/icons//346/211/213/346/234/272.svg +1 -0
  55. package/top-ppt-html/assets/icons//346/225/210/347/216/207.png +0 -0
  56. package/top-ppt-html/assets/icons//346/225/210/347/216/207.svg +1 -0
  57. package/top-ppt-html/assets/icons//346/225/260/346/215/256.png +0 -0
  58. package/top-ppt-html/assets/icons//346/225/260/346/215/256.svg +1 -0
  59. package/top-ppt-html/assets/icons//346/226/207/344/273/266/345/244/271.png +0 -0
  60. package/top-ppt-html/assets/icons//346/226/207/344/273/266/345/244/271.svg +1 -0
  61. package/top-ppt-html/assets/icons//346/226/207/346/241/243.png +0 -0
  62. package/top-ppt-html/assets/icons//346/226/207/346/241/243.svg +1 -0
  63. package/top-ppt-html/assets/icons//346/227/227/345/270/234.png +0 -0
  64. package/top-ppt-html/assets/icons//346/227/227/345/270/234.svg +1 -0
  65. package/top-ppt-html/assets/icons//346/227/266/351/227/264.png +0 -0
  66. package/top-ppt-html/assets/icons//346/227/266/351/227/264.svg +1 -0
  67. package/top-ppt-html/assets/icons//346/230/237/346/240/207.png +0 -0
  68. package/top-ppt-html/assets/icons//346/230/237/346/240/207.svg +1 -0
  69. package/top-ppt-html/assets/icons//346/231/272/350/203/275.png +0 -0
  70. package/top-ppt-html/assets/icons//346/231/272/350/203/275.svg +1 -0
  71. package/top-ppt-html/assets/icons//346/235/203/351/231/220.png +0 -0
  72. package/top-ppt-html/assets/icons//346/235/203/351/231/220.svg +1 -0
  73. package/top-ppt-html/assets/icons//346/243/200/346/237/245.png +0 -0
  74. package/top-ppt-html/assets/icons//346/243/200/346/237/245.svg +1 -0
  75. package/top-ppt-html/assets/icons//346/264/236/345/257/237.png +0 -0
  76. package/top-ppt-html/assets/icons//346/264/236/345/257/237.svg +1 -0
  77. package/top-ppt-html/assets/icons//346/265/201/347/250/213.png +0 -0
  78. package/top-ppt-html/assets/icons//346/265/201/347/250/213.svg +1 -0
  79. package/top-ppt-html/assets/icons//346/270/205/345/215/225.png +0 -0
  80. package/top-ppt-html/assets/icons//346/270/205/345/215/225.svg +1 -0
  81. package/top-ppt-html/assets/icons//347/201/253/347/256/255.png +0 -0
  82. package/top-ppt-html/assets/icons//347/201/253/347/256/255.svg +1 -0
  83. package/top-ppt-html/assets/icons//347/201/257/346/263/241.png +0 -0
  84. package/top-ppt-html/assets/icons//347/201/257/346/263/241.svg +1 -0
  85. package/top-ppt-html/assets/icons//347/224/250/346/210/267.png +0 -0
  86. package/top-ppt-html/assets/icons//347/224/250/346/210/267.svg +1 -0
  87. package/top-ppt-html/assets/icons//347/233/256/346/240/207.png +0 -0
  88. package/top-ppt-html/assets/icons//347/233/256/346/240/207.svg +1 -0
  89. package/top-ppt-html/assets/icons//347/256/255/345/244/264.png +0 -0
  90. package/top-ppt-html/assets/icons//347/256/255/345/244/264.svg +1 -0
  91. package/top-ppt-html/assets/icons//347/273/204/347/273/207.png +0 -0
  92. package/top-ppt-html/assets/icons//347/273/204/347/273/207.svg +1 -0
  93. package/top-ppt-html/assets/icons//347/275/227/347/233/230.png +0 -0
  94. package/top-ppt-html/assets/icons//347/275/227/347/233/230.svg +1 -0
  95. package/top-ppt-html/assets/icons//350/241/250/346/240/274.png +0 -0
  96. package/top-ppt-html/assets/icons//350/241/250/346/240/274.svg +1 -0
  97. package/top-ppt-html/assets/icons//350/256/241/345/210/222.png +0 -0
  98. package/top-ppt-html/assets/icons//350/256/241/345/210/222.svg +1 -0
  99. package/top-ppt-html/assets/icons//350/256/276/347/275/256.png +0 -0
  100. package/top-ppt-html/assets/icons//350/256/276/347/275/256.svg +1 -0
  101. package/top-ppt-html/assets/icons//350/266/213/345/212/277.png +0 -0
  102. package/top-ppt-html/assets/icons//350/266/213/345/212/277.svg +1 -0
  103. package/top-ppt-html/assets/icons//351/202/256/344/273/266.png +0 -0
  104. package/top-ppt-html/assets/icons//351/202/256/344/273/266.svg +1 -0
  105. package/top-ppt-html/assets/icons//351/207/221/351/222/261.png +0 -0
  106. package/top-ppt-html/assets/icons//351/207/221/351/222/261.svg +1 -0
  107. package/top-ppt-html/assets/icons//351/223/276/346/216/245.png +0 -0
  108. package/top-ppt-html/assets/icons//351/223/276/346/216/245.svg +1 -0
  109. package/top-ppt-html/assets/icons//351/227/252/347/224/265.png +0 -0
  110. package/top-ppt-html/assets/icons//351/227/252/347/224/265.svg +1 -0
  111. package/top-ppt-html/assets/icons//351/243/216/351/231/251.png +0 -0
  112. package/top-ppt-html/assets/icons//351/243/216/351/231/251.svg +1 -0
  113. package/top-ppt-html/assets/pptx-export.js +44 -15
  114. package/top-ppt-html/assets/style-gallery.html +2 -2
  115. package/top-ppt-html/assets/templates/architecture.html +59 -17
  116. package/top-ppt-html/assets/templates/engine.css +12 -0
  117. package/top-ppt-html/assets/templates/presentation.html +59 -17
  118. package/top-ppt-html/assets/templates/research.html +59 -17
  119. package/top-ppt-html/package-lock.json +2 -2
  120. package/top-ppt-html/package.json +2 -2
  121. package/top-ppt-html/references/icons.md +22 -19
  122. package/top-ppt-html/references/layout-variants.md +69 -0
  123. package/top-ppt-html/references/style-pack.md +40 -0
  124. package/top-ppt-html/scripts/audit_css.py +1 -1
  125. package/top-ppt-html/scripts/build_pptx.js +228 -50
  126. package/top-ppt-html/scripts/check_icons.py +119 -0
  127. package/top-ppt-html/scripts/hydrate_charts.py +60 -5
  128. package/top-ppt-html/scripts/icon_lib.js +66 -37
  129. package/top-ppt-html/scripts/layout-constants.json +92 -5
  130. package/top-ppt-html/scripts/make_icon_pack.py +172 -0
  131. package/top-ppt-html/scripts/model-schema.json +4 -1
  132. package/top-ppt-html/scripts/regression.py +3 -3
  133. package/top-ppt-html/scripts/render_compare.py +1 -1
  134. package/top-ppt-html/scripts/render_from_model.py +116 -55
  135. package/top-ppt-html/scripts/scaffold_report.py +1 -1
  136. package/top-ppt-html/scripts/sync_runtime.py +14 -14
  137. package/top-ppt-html/scripts/validate_pptx.py +1 -0
  138. package/top-ppt-html/scripts/validate_report.py +157 -0
  139. package/topmind-cover/README.en.md +4 -4
  140. package/topmind-cover/README.md +4 -4
  141. package/topmind-cover/SKILL.md +36 -16
  142. package/topmind-cover/assets/examples/README.md +13 -7
  143. package/topmind-cover/assets/examples/bg-blur-alt.png +0 -0
  144. package/topmind-cover/assets/examples/bg-blur-wechat.png +0 -0
  145. package/topmind-cover/assets/examples/bg-blur.png +0 -0
  146. package/topmind-cover/assets/examples/big-type-wechat.png +0 -0
  147. package/topmind-cover/assets/examples/big-type.png +0 -0
  148. package/topmind-cover/assets/examples/brand-launch-wechat.png +0 -0
  149. package/topmind-cover/assets/examples/brand-launch.png +0 -0
  150. package/topmind-cover/assets/examples/gan-huo-wechat.png +0 -0
  151. package/topmind-cover/assets/examples/gan-huo.png +0 -0
  152. package/topmind-cover/assets/examples/ip-fun-wechat.png +0 -0
  153. package/topmind-cover/assets/examples/ip-fun.png +0 -0
  154. package/topmind-cover/assets/examples/magazine-wechat.png +0 -0
  155. package/topmind-cover/assets/examples/magazine.png +0 -0
  156. package/topmind-cover/assets/examples/minimal-wechat.png +0 -0
  157. package/topmind-cover/assets/examples/minimal.png +0 -0
  158. package/topmind-cover/assets/examples/news-flash-wechat.png +0 -0
  159. package/topmind-cover/assets/examples/news-flash.png +0 -0
  160. package/topmind-cover/assets/examples/overview.png +0 -0
  161. package/topmind-cover/assets/examples/paper-collage-alt.png +0 -0
  162. package/topmind-cover/assets/examples/paper-collage-wechat.png +0 -0
  163. package/topmind-cover/assets/examples/paper-collage.png +0 -0
  164. package/topmind-cover/assets/examples/tutorial-steps-wechat.png +0 -0
  165. package/topmind-cover/assets/examples/tutorial-steps.png +0 -0
  166. package/topmind-cover/assets/examples/white-clean-alt-wechat.png +0 -0
  167. package/topmind-cover/assets/examples/white-clean-alt.png +0 -0
  168. package/topmind-cover/assets/examples/white-clean-wechat.png +0 -0
  169. package/topmind-cover/assets/examples/white-clean.png +0 -0
  170. package/topmind-cover/package.json +2 -2
  171. package/topmind-cover/references/cover-styles.md +210 -35
  172. package/topmind-cover/references/impact-language.md +43 -2
  173. package/topmind-cover/references/ref-white-clean-1.png +0 -0
  174. package/topmind-cover/references/ref-white-clean-2.png +0 -0
  175. package/topmind-cover/scripts/crop-cover.py +19 -13
  176. package/topmind-wechat-post/README.en.md +2 -2
  177. package/topmind-wechat-post/README.md +2 -2
  178. package/topmind-wechat-post/SKILL.md +1 -1
  179. package/topmind-wechat-post/scripts/md2wechat.py +142 -30
  180. package/topmind-wechat-post/scripts/negative_tests.py +74 -0
  181. package/topmind-x-article/README.en.md +1 -1
  182. package/topmind-x-article/README.md +1 -1
  183. package/topmind-x-article/scripts/md2x.py +25 -5
  184. package/topmind-x-article/scripts/negative_tests.py +17 -0
@@ -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))
@@ -113,7 +113,7 @@ CHART_CYCLE = CHART_CORE # 轮换默认序列(骨架示范「图要选对」
113
113
  _SCAFFOLD = (LC.get('charts') or {}).get('scaffold') or {}
114
114
  if not _SCAFFOLD.get('viewBoxHeight') or not _SCAFFOLD.get('infoTypes'):
115
115
  # 启动自检:单源缺键时立刻失败,防「静默退回默认值 180」的窗口期
116
- print('错误: layout-constants.json 缺 charts.scaffold.viewBoxHeight/infoTypes'
116
+ print('错误:layout-constants.json 缺 charts.scaffold.viewBoxHeight/infoTypes'
117
117
  '(骨架尺寸单源不完整;补单源后先跑 sync_runtime.py)', file=sys.stderr)
118
118
  sys.exit(2)
119
119
  CHART_VIEWBOX = dict(_SCAFFOLD.get('viewBoxHeight') or {})
@@ -301,10 +301,10 @@ def inject(path: Path, pat: re.Pattern, repl: str, label: str) -> bool:
301
301
  try:
302
302
  src = path.read_text(encoding='utf-8')
303
303
  except OSError as e:
304
- print(f'错误: 无法读取 {path.name}: {e}')
304
+ print(f'错误: 无法读取 {path.name}: {e}')
305
305
  return False
306
306
  if not pat.search(src):
307
- print(f'错误: {path.name} 缺少 {label} 标记块')
307
+ print(f'错误: {path.name} 缺少 {label} 标记块')
308
308
  return False
309
309
  path.write_text(pat.sub(lambda m: repl, src, count=1), encoding='utf-8')
310
310
  return True
@@ -312,32 +312,32 @@ def inject(path: Path, pat: re.Pattern, repl: str, label: str) -> bool:
312
312
 
313
313
  def main() -> int:
314
314
  if not LC.exists():
315
- print(f'错误: 找不到 {LC}')
315
+ print(f'错误: 找不到 {LC}')
316
316
  return 2
317
317
  try:
318
318
  lc = json.loads(LC.read_text(encoding='utf-8'))
319
319
  except UnicodeDecodeError as e:
320
- print(f'错误: {LC.name} 不是合法 UTF-8: {e}')
320
+ print(f'错误: {LC.name} 不是合法 UTF-8: {e}')
321
321
  return 2
322
322
  except json.JSONDecodeError as e:
323
- print(f'错误: {LC.name} 不是合法 JSON: {e}')
323
+ print(f'错误: {LC.name} 不是合法 JSON: {e}')
324
324
  return 2
325
325
  except OSError as e:
326
- print(f'错误: 无法读取 {LC}: {e}')
326
+ print(f'错误: 无法读取 {LC}: {e}')
327
327
  return 2
328
328
  if not MS.exists():
329
- print(f'错误: 找不到 {MS}(DSL schema 单源)')
329
+ print(f'错误: 找不到 {MS}(DSL schema 单源)')
330
330
  return 2
331
331
  try:
332
332
  ms = json.loads(MS.read_text(encoding='utf-8'))
333
333
  except UnicodeDecodeError as e:
334
- print(f'错误: {MS.name} 不是合法 UTF-8: {e}')
334
+ print(f'错误: {MS.name} 不是合法 UTF-8: {e}')
335
335
  return 2
336
336
  except json.JSONDecodeError as e:
337
- print(f'错误: {MS.name} 不是合法 JSON: {e}')
337
+ print(f'错误: {MS.name} 不是合法 JSON: {e}')
338
338
  return 2
339
339
  except OSError as e:
340
- print(f'错误: 无法读取 {MS}: {e}')
340
+ print(f'错误: 无法读取 {MS}: {e}')
341
341
  return 2
342
342
  ok = True
343
343
 
@@ -345,13 +345,13 @@ def main() -> int:
345
345
  try:
346
346
  src = PE.read_text(encoding='utf-8')
347
347
  except OSError as e:
348
- print(f'错误: 无法读取 {PE}: {e}')
348
+ print(f'错误: 无法读取 {PE}: {e}')
349
349
  return 1
350
350
  if not CONST_PAT.search(src):
351
- print('错误: assets/pptx-export.js 缺少 __TOPPPT_CONSTANTS__ 标记块')
351
+ print('错误: assets/pptx-export.js 缺少 __TOPPPT_CONSTANTS__ 标记块')
352
352
  return 1
353
353
  if not SCHEMA_PAT.search(src):
354
- print('错误: assets/pptx-export.js 缺少 __TOPPPT_SCHEMA__ 标记块')
354
+ print('错误: assets/pptx-export.js 缺少 __TOPPPT_SCHEMA__ 标记块')
355
355
  return 1
356
356
  block = build_const_block(lc)
357
357
  PE.write_text(CONST_PAT.sub(lambda m: block, src), encoding='utf-8')
@@ -370,7 +370,7 @@ def main() -> int:
370
370
 
371
371
  # ② 公共片段源
372
372
  if not ENGINE.exists() or not UI.exists():
373
- print(f'错误: 缺少公共片段 {ENGINE} / {UI}')
373
+ print(f'错误: 缺少公共片段 {ENGINE} / {UI}')
374
374
  return 1
375
375
  engine_css = ENGINE.read_text(encoding='utf-8').rstrip('\n')
376
376
  ui_js = UI.read_text(encoding='utf-8').rstrip('\n')
@@ -138,6 +138,7 @@ SLIDE_RE = re.compile(r"ppt/slides/slide(\d+)\.xml$")
138
138
  _PROBE_SKIP_KEYS = {
139
139
  "colors", "color", "icon", "href", "id", "type", "exhibitNo", "max", "unit",
140
140
  "hint", "src", "alt", "fit", "layout", "placeholder", "ratio", "dataTable",
141
+ "variant", # 版式变体注册值(bento-grid/timeline/2-col-feature)不渲染为文本
141
142
  }
142
143
 
143
144
 
@@ -4,6 +4,7 @@
4
4
  TopPPT HTML· HTML 报告质量校验(交付闭环)
5
5
  用法:
6
6
  python validate_report.py <报告.html> [--strict] [--layout-qa] [--json]
7
+ python validate_report.py --help
7
8
 
8
9
  输出每项 PASS/FAIL,结尾给汇总与结论;任一 FAIL 时退出码为 1(--strict 时 WARN 也计失败)。
9
10
  --json 以 JSON 输出全部检查结果(供脚本/流水线读取)。
@@ -30,6 +31,8 @@ import re
30
31
  import json
31
32
  import base64
32
33
  import hashlib
34
+ import html as _html
35
+ import unicodedata
33
36
  from typing import Optional
34
37
  from html.parser import HTMLParser
35
38
  from pathlib import Path
@@ -370,6 +373,155 @@ def _check_bands(txt, chk, mode="presentation"):
370
373
  f"{bad_grids} 处未对齐" if bad_grids else "", level="WARN")
371
374
 
372
375
 
376
+ def _icon_pack_names():
377
+ """图标包合法名集合(assets/icons/index.json);读不到时返回 None(跳过存在性检查)。"""
378
+ try:
379
+ idx = json.loads((Path(__file__).resolve().parent.parent / 'assets' / 'icons'
380
+ / 'index.json').read_text(encoding='utf-8'))
381
+ return set((idx.get('icons') or {}).keys())
382
+ except OSError:
383
+ return None
384
+
385
+
386
+ def _check_icon_refs(txt, chk):
387
+ """图标引用存在性:data-icon / icon: 标记引用了包外图标 → WARN(不 FAIL)。"""
388
+ names = _icon_pack_names()
389
+ if names is None:
390
+ return
391
+ # 只扫渲染正文:去掉 script/style/注释与嵌入模型 JSON,避免代码与文档示例误报
392
+ body = re.sub(r'<script[\s\S]*?</script>', '', txt, flags=re.I)
393
+ body = re.sub(r'<style[\s\S]*?</style>', '', body, flags=re.I)
394
+ body = re.sub(r'<!--[\s\S]*?-->', '', body)
395
+ body = re.sub(r'window\.REPORT_MODEL\s*=\s*\{[\s\S]*?\n\};', '', body)
396
+ refs = set(re.findall(r'data-icon="([^"]+)"', body))
397
+ refs |= set(re.findall(r'icon:([\u4e00-\u9fa5A-Za-z0-9_-]+)', body))
398
+ # 排除 hydrate 占位与 CSS 类名噪声:只看真实引用上下文
399
+ bad = sorted(r for r in refs if r not in names and not r.startswith('{{'))
400
+ chk("图标引用存在(assets/icons 包内)", not bad,
401
+ f"未知图标: {', '.join(bad[:6])}{'…' if len(bad) > 6 else ''}({len(bad)} 个)" if bad else "",
402
+ level="WARN")
403
+
404
+
405
+ # ── 内容覆盖率(0.2.0):版式不得以丢文字为代价美化 ──
406
+ # 口径:REPORT_MODEL 可见文本串(去重、归一化)有多少字符能在渲染后 HTML 可见文本中找到;
407
+ # <95% → WARN(只报告,不 FAIL;长串按 8 字窗口部分计分,避免数字重排/截断误杀)。
408
+ _COV_SKIP_KEYS = {
409
+ # 元字段(不渲染为正文)
410
+ "style", "theme", "mode", "type", "variant", "icon", "color", "colors",
411
+ "src", "href", "id", "layout", "datatable", "dataTable", "font", "fontface",
412
+ "exhibitno", "exhibitNo", "columns", "max", "min", "unit", "ratio", "fit",
413
+ "align", "width", "height", "placeholder", "alt", "target", "rel", "dir",
414
+ "lang", "charset", "defer", "async", "media",
415
+ "hint", # 搭建期给智能体的指令(如 image.hint),不是读者正文
416
+ }
417
+ _COV_URL_RE = re.compile(r'^(https?://|www\.|data:|#)', re.I)
418
+ _COV_COLOR_RE = re.compile(r'^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$')
419
+ # 纯小写 ASCII 标识符(无空格/CJK/大写):渲染器语义 flag(如 "focus"),不是正文
420
+ _COV_FLAG_RE = re.compile(r'^[a-z][a-z0-9_-]*$')
421
+ # 信息图页型(r_info 故意留"按 infographics 规格绘制"占位、PPTX 走形状通道):
422
+ # 其 chart 数据子树不计入覆盖率(数据在模型与 PPTX 中完整,仅 HTML 待定制信息图)
423
+ _COV_INFO_TYPES = {'sankey', 'treemap', 'boxplot', 'network', 'marimekko', 'streamgraph'}
424
+ _COV_INFO_DATA_KEYS = {'chart', 'labels', 'values', 'groups', 'nodes', 'links', 'edges', 'data',
425
+ 'series', 'dataset', 'cols', 'rows', 'cells'}
426
+ _COV_MIN_RATIO = 0.95 # 覆盖率下限(layout-constants qualityGates.contentCoverage 可覆写)
427
+
428
+
429
+ def _norm_cov(s: str) -> str:
430
+ s = unicodedata.normalize('NFKC', s)
431
+ s = re.sub(r'(?<=\d),(?=\d)', '', s) # 千分位 1,000 → 1000(中文逗号保留)
432
+ s = re.sub(r'\s+', '', s)
433
+ # 标点不计入:内容在、标点异(: vs 无、() vs 无)不算丢失;比的是信息不是排印
434
+ s = ''.join(c for c in s if not unicodedata.category(c).startswith('P'))
435
+ return s
436
+
437
+
438
+ def _model_cov_strings(model) -> list:
439
+ """从 REPORT_MODEL 递归抽取候选正文串(去重、保序)。"""
440
+ out, seen = [], set()
441
+
442
+ def walk(node, skip_sub=False):
443
+ if isinstance(node, dict):
444
+ # 信息图页型:chart 数据子树是故意占位(r_info),不计入
445
+ info_data = isinstance(node.get('type'), str) and node.get('type').lower() in _COV_INFO_TYPES
446
+ for k, v in node.items():
447
+ kl = str(k).lower()
448
+ if kl in _COV_SKIP_KEYS or str(k).startswith('data-'):
449
+ continue
450
+ if info_data and kl in _COV_INFO_DATA_KEYS:
451
+ continue
452
+ walk(v)
453
+ elif isinstance(node, (list, tuple)):
454
+ for v in node:
455
+ walk(v)
456
+ elif isinstance(node, str):
457
+ t = node.strip()
458
+ if len(t) < 4 or _COV_URL_RE.match(t) or _COV_COLOR_RE.match(t):
459
+ return
460
+ if _COV_FLAG_RE.match(t):
461
+ return # 渲染器语义 flag(如 focus),非正文
462
+ if t not in seen:
463
+ seen.add(t)
464
+ out.append(t)
465
+
466
+ walk(model)
467
+ return out
468
+
469
+
470
+ def _visible_cov_text(txt: str) -> str:
471
+ """渲染后可见文本:剔 script(含 REPORT_MODEL JSON 本体)/style/注释/标签后归一化。"""
472
+ t = re.sub(r'<script[\s\S]*?</script>', '', txt, flags=re.I)
473
+ t = re.sub(r'<style[\s\S]*?</style>', '', t, flags=re.I)
474
+ t = re.sub(r'<!--[\s\S]*?-->', '', t)
475
+ t = re.sub(r'<[^>]+>', '', t)
476
+ return _norm_cov(_html.unescape(t))
477
+
478
+
479
+ def _cov_credit(cand: str, rendered: str) -> float:
480
+ """候选串的字符计分:整串命中按全长;否则按 8 字窗口命中率折算。"""
481
+ n = _norm_cov(cand)
482
+ if not n:
483
+ return 0.0
484
+ if n in rendered:
485
+ return float(len(n))
486
+ if len(n) <= 12:
487
+ return 0.0
488
+ win, hits, total = 8, 0, 0
489
+ for i in range(0, len(n), win):
490
+ total += 1
491
+ if n[i:i + win] in rendered:
492
+ hits += 1
493
+ return len(n) * hits / total if total else 0.0
494
+
495
+
496
+ def _check_content_coverage(txt, chk, model) -> None:
497
+ """模型文本 → 渲染文本覆盖率(只 WARN)。"""
498
+ if not isinstance(model, dict):
499
+ return
500
+ try:
501
+ min_ratio = float((load_constants().get("qualityGates") or {}).get("contentCoverage")
502
+ or _COV_MIN_RATIO)
503
+ except Exception:
504
+ min_ratio = _COV_MIN_RATIO
505
+ cands = _model_cov_strings(model)
506
+ if not cands:
507
+ return
508
+ rendered = _visible_cov_text(txt)
509
+ total = sum(len(_norm_cov(c)) for c in cands)
510
+ if not total:
511
+ return
512
+ miss = []
513
+ got = 0.0
514
+ for c in cands:
515
+ cr = _cov_credit(c, rendered)
516
+ got += cr
517
+ if cr < len(_norm_cov(c)) * 0.5:
518
+ miss.append(c[:24])
519
+ ratio = got / total
520
+ chk(f"内容覆盖率(模型文本→渲染文本 ≥{min_ratio:.0%})", ratio >= min_ratio,
521
+ f"覆盖率 {ratio:.1%}({len(cands)} 串),疑似丢失: {';'.join(miss[:3])}" if ratio < min_ratio else "",
522
+ level="WARN")
523
+
524
+
373
525
  def _check_icons(txt, chk):
374
526
  """图标使用:长报告不应全文无图标;含 ≥2 张卡的内容页应有 .card__ico(WARN)。"""
375
527
  if len(_bands(txt)) < 6:
@@ -1630,6 +1782,9 @@ def main():
1630
1782
  if len(sys.argv) < 2:
1631
1783
  print(__doc__)
1632
1784
  return 2
1785
+ if sys.argv[1] in ('-h', '--help'):
1786
+ print(__doc__)
1787
+ return 0
1633
1788
  path = Path(sys.argv[1])
1634
1789
  strict = '--strict' in sys.argv
1635
1790
  as_json = '--json' in sys.argv
@@ -1797,8 +1952,10 @@ def main():
1797
1952
  _check_mode_layouts(txt, chk, mode)
1798
1953
  if mode != 'architecture':
1799
1954
  _check_icons(txt, chk)
1955
+ _check_icon_refs(txt, chk)
1800
1956
  _check_pptx_export(txt, chk)
1801
1957
  model = _check_model_consistency(txt, chk, mode, style)
1958
+ _check_content_coverage(txt, chk, model)
1802
1959
  _check_type_features(txt, chk, model)
1803
1960
  _check_exhibits(txt, chk, mode)
1804
1961
  _check_research_extras(txt, chk, mode)
@@ -4,15 +4,15 @@
4
4
 
5
5
  A reusable skill for generating cover art for X long-form posts and WeChat articles. Goal: **striking, eye-catching, theme-focused**.
6
6
 
7
- - Version: **v0.1.0** (same tag as `@topmindspace/tms-skills@0.3.6`)
7
+ - Version: **v0.2.0** (same tag as `@topmindspace/tms-skills@0.4.1`)
8
8
 
9
9
  ## Style gallery
10
10
 
11
11
  <p align="center">
12
- <img src="https://github.com/topmindspace/tms-skills/raw/main/topmind-cover/assets/examples/overview.png" alt="topmind-cover · 8 cover-style overview" width="960" />
12
+ <img src="https://github.com/topmindspace/tms-skills/raw/main/topmind-cover/assets/examples/overview.png" alt="topmind-cover · 11 cover-style overview" width="960" />
13
13
  </p>
14
14
 
15
- 8 styles (viral dry-goods / giant-type manifesto / brand launch / tutorial steps / fun IP / news flash / minimalist / editorial magazine): use cases, palette stories, abstract design principles, and Chinese+English prompt recipes in [`references/cover-styles.md`](./references/cover-styles.md); full-size per-style shots in [`assets/examples/`](./assets/examples/).
15
+ 11 styles (clean white / background blur / paper collage / news flash / giant-type manifesto / tutorial steps / minimalist / editorial magazine / viral dry-goods / brand launch / fun IP, light-first ordering): use cases, palette stories, abstract design principles, and Chinese+English prompt recipes in [`references/cover-styles.md`](./references/cover-styles.md); full-size per-style shots in [`assets/examples/`](./assets/examples/).
16
16
 
17
17
  ## Install
18
18
 
@@ -22,7 +22,7 @@ npx @topmindspace/tms-skills install topmind-cover
22
22
 
23
23
  ## Usage
24
24
 
25
- 0. **Pick a style**: choose 1 of 8 styles from `references/cover-styles.md` by topic → check the example image to confirm the visual language.
25
+ 0. **Pick a style**: choose 1 of 11 styles from `references/cover-styles.md` by topic (light-first) → check the example image to confirm the visual language.
26
26
  1. **Refine the title**: lock the title copy first (usually ≤ 10 chars) using that style's title rules; rewrite until it lands — no drawing yet.
27
27
  2. **Composition brief**: one-sentence brief (theme / audience / mood / visual metaphor / palette direction) before composing the prompt.
28
28
  3. Input: article title + 3 theme keywords + platform (x / wechat / both, default both).