@topmindspace/tms-skills 0.1.18 → 0.2.0

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 (98) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.en.md +6 -3
  3. package/README.md +6 -3
  4. package/package.json +5 -2
  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
  44. package/topmind-cover/README.md +40 -0
  45. package/topmind-cover/SKILL.md +66 -0
  46. package/topmind-cover/package-lock.json +16 -0
  47. package/topmind-cover/package.json +16 -0
  48. package/topmind-cover/references/cover-styles.md +40 -0
  49. package/topmind-cover/scripts/audit_css.py +39 -0
  50. package/topmind-cover/scripts/audit_docs.py +58 -0
  51. package/topmind-cover/scripts/audit_skill.py +60 -0
  52. package/topmind-cover/scripts/audit_styles.py +54 -0
  53. package/topmind-cover/scripts/crop-cover.py +69 -0
  54. package/topmind-cover/scripts/negative_tests.py +71 -0
  55. package/topmind-cover/scripts/package_skill.py +188 -0
  56. package/topmind-wechat-post/README.md +49 -0
  57. package/topmind-wechat-post/SKILL.md +208 -0
  58. package/topmind-wechat-post/assets/themes/amber-review.json +35 -0
  59. package/topmind-wechat-post/assets/themes/graphite.json +35 -0
  60. package/topmind-wechat-post/assets/themes/minimal-ink.json +35 -0
  61. package/topmind-wechat-post/assets/themes/newsprint.json +35 -0
  62. package/topmind-wechat-post/assets/themes/tech-blue.json +35 -0
  63. package/topmind-wechat-post/package-lock.json +16 -0
  64. package/topmind-wechat-post/package.json +16 -0
  65. package/topmind-wechat-post/references/design.md +50 -0
  66. package/topmind-wechat-post/references/element-spec.md +162 -0
  67. package/topmind-wechat-post/references/known-pits.md +86 -0
  68. package/topmind-wechat-post/references/theme-map.md +139 -0
  69. package/topmind-wechat-post/references/typography-rules.md +62 -0
  70. package/topmind-wechat-post/references/wechat-constraints.md +106 -0
  71. package/topmind-wechat-post/references/workflow.md +102 -0
  72. package/topmind-wechat-post/references/writing-quality.md +129 -0
  73. package/topmind-wechat-post/scripts/audit_css.py +39 -0
  74. package/topmind-wechat-post/scripts/audit_docs.py +58 -0
  75. package/topmind-wechat-post/scripts/audit_skill.py +60 -0
  76. package/topmind-wechat-post/scripts/audit_styles.py +54 -0
  77. package/topmind-wechat-post/scripts/lint-wechat.py +446 -0
  78. package/topmind-wechat-post/scripts/md2wechat.py +1602 -0
  79. package/topmind-wechat-post/scripts/negative_tests.py +65 -0
  80. package/topmind-wechat-post/scripts/new-article.py +183 -0
  81. package/topmind-wechat-post/scripts/package_skill.py +188 -0
  82. package/topmind-wechat-post/scripts/push-to-topstream.py +226 -0
  83. package/topmind-wechat-post/scripts/scan_ai_flavor.py +464 -0
  84. package/topmind-wechat-post/scripts/sync-mapping.py +220 -0
  85. package/topmind-wechat-post/scripts/sync-status.py +187 -0
  86. package/topmind-x-article/README.md +40 -0
  87. package/topmind-x-article/SKILL.md +62 -0
  88. package/topmind-x-article/package-lock.json +16 -0
  89. package/topmind-x-article/package.json +16 -0
  90. package/topmind-x-article/references/publish-checklist.md +24 -0
  91. package/topmind-x-article/references/x-format.md +24 -0
  92. package/topmind-x-article/scripts/audit_css.py +39 -0
  93. package/topmind-x-article/scripts/audit_docs.py +58 -0
  94. package/topmind-x-article/scripts/audit_skill.py +60 -0
  95. package/topmind-x-article/scripts/audit_styles.py +54 -0
  96. package/topmind-x-article/scripts/md2x.py +121 -0
  97. package/topmind-x-article/scripts/negative_tests.py +74 -0
  98. package/topmind-x-article/scripts/package_skill.py +188 -0
@@ -109,7 +109,9 @@ function regionOf(pageType, slotId, opts) {
109
109
  const y = PT.chartY || 2.95;
110
110
  const bottom = (opts && opts.bottom != null) ? opts.bottom
111
111
  : ((opts && opts.withNote) ? L.contentBottomWithNote : L.contentBottom);
112
- return { x: mx + (PT.chartX || 0), y, w: cw - (PT.chartW || 0), h: Math.max(1.2, bottom - y) };
112
+ /* chartX/chartW 是历史误用:与 donut/table/hbar/exhibit 同口径,图表盒对齐版心 (mx, cw)。
113
+ 此前 `mx+chartX` / `cw-chartW` 造成 bar 图右移 0.6in,与标题/表格左边距不齐。 */
114
+ return { x: mx, y, w: cw, h: Math.max(1.2, bottom - y) };
113
115
  }
114
116
  if (slot === 'hbar') {
115
117
  const y0 = PT.hbarY0 || 2.6;
@@ -26,9 +26,12 @@
26
26
  "research": 3,
27
27
  "architecture": 0
28
28
  },
29
+ "agendaMax": 7,
30
+ "agendaMaxWithRefs": 8,
31
+ "agendaHint": "agenda 条目 = 章(篇/section),3–7 条;每章可含多页(sections[] 按页建模)。禁止把逐页标题当大纲。sections[].eyebrow 的 `NN ·` 前缀 = 章键,须与某条 agenda 对应;跳转锚点 = 该章第一页。含「参考资料」条目时 +1 仍 ≤8。",
29
32
  "sectionsMin": 1,
30
33
  "sectionsRecommended": 3,
31
- "agendaComfortMax": 16
34
+ "agendaComfortMax": 8
32
35
  },
33
36
  "commonSectionFields": [
34
37
  "eyebrow",
@@ -276,7 +279,7 @@
276
279
  ]
277
280
  },
278
281
  "cards": {
279
- "label": "卡片网格页(cards 元素须为 {title, points:[[k,v]…]},不接受元组)",
282
+ "label": "卡片网格页(cards 元素须为 {title, points:[[k,v]…], icon?},不接受元组)",
280
283
  "modes": [
281
284
  "presentation",
282
285
  "research"
@@ -286,7 +289,8 @@
286
289
  ],
287
290
  "optional": [
288
291
  "columns"
289
- ]
292
+ ],
293
+ "cardItemHint": "cards[] 元素:{title:str, points:[[k,v]…]|[{t,d}…]|[str…], icon?:str}。icon = 语义图标名(icon_lib.js 键:增长/下降/数据/图表/趋势/占比/表格/效率/成果/安全/权限/检查/风险/团队/流程/计划/智能/洞察/工具/清单),HTML 与 PPTX 同名真导出;缺省按标题语义自动挑选。"
290
294
  },
291
295
  "split": {
292
296
  "label": "双区组合页(left / right 各可为 要点|图表|表格|图片:type = points(默认)| table | image | 图表类型名;图表/表格字段直接挂在对应侧)",
@@ -142,6 +142,7 @@ REQUIRED = [
142
142
  'scripts/render_compare.py', 'scripts/capture_theme_overview.js', 'scripts/package_skill.py',
143
143
  'scripts/layout-constants.json', 'scripts/model-schema.json',
144
144
  'scripts/lib_layout_regions.js', 'scripts/extract_snippet.py', 'scripts/quality_gate.py',
145
+ 'scripts/icon_lib.js', 'scripts/icon_raster.js', 'scripts/build_icon_assets.js',
145
146
  'scripts/checks_html.py', 'scripts/section-file-map.json',
146
147
  'scripts/measure_height.py', 'scripts/audit_css.py', 'scripts/negative_tests.py',
147
148
  'references/components-atoms.md', 'references/layouts-research.md',
@@ -140,9 +140,20 @@ def pick_icon(title: str = '', idx: int = 0) -> str:
140
140
  return ICONS[_ICONS_ORDER[idx % len(_ICONS_ORDER)]]
141
141
 
142
142
 
143
- def card_head(title: str, idx: int = 0) -> str:
144
- """卡片头:图标路标 + 标题(与 scaffold 的 .card__hd + .card__ico 同源)。"""
145
- return (f'<div class="card__hd"><div class="card__ico">{pick_icon(title, idx)}</div>'
143
+ def pick_icon_name(title: str = '', idx: int = 0) -> str:
144
+ """图标名(与 icon_lib.js / PPTX 真导出同名)。"""
145
+ t = str(title or '')
146
+ for key in ICONS:
147
+ if key in t:
148
+ return key
149
+ return _ICONS_ORDER[idx % len(_ICONS_ORDER)]
150
+
151
+
152
+ def card_head(title: str, idx: int = 0, icon_name: str | None = None) -> str:
153
+ """卡片头:图标路标 + 标题(与 scaffold 的 .card__hd + .card__ico 同源)。
154
+ icon_name 写入 data-icon,供 extract_model / build_pptx 真导出同名图标。"""
155
+ name = icon_name or pick_icon_name(title, idx)
156
+ return (f'<div class="card__hd"><div class="card__ico" data-icon="{esc(name)}">{pick_icon(title, idx)}</div>'
146
157
  f'<h3 class="t-h3">{esc(title)}</h3></div>')
147
158
 
148
159
 
@@ -320,14 +331,23 @@ def r_cards(i, sec):
320
331
  cards = sec.get('cards') or []
321
332
  n = int(sec.get('columns') or min(3, max(1, len(cards))))
322
333
  cls = {1: 'g-2', 2: 'g-2', 3: 'g-3', 4: 'g-4'}.get(n, 'g-3')
334
+ # 纯卡片栅格强制等高(g-2--equal / g-3--equal),与 CSS / layout-grammar「同行卡片 stretch」一致
335
+ if cls in ('g-2', 'g-3', 'g-4'):
336
+ cls = cls + '--equal'
323
337
  blocks = []
324
338
  for ci, cd in enumerate(cards):
325
- title = cd.get('title') if isinstance(cd, dict) else (cd[0] if cd else '')
326
- points = cd.get('points') if isinstance(cd, dict) else ([['', cd[1]]] if isinstance(cd, (list, tuple)) and len(cd) > 1 else [])
339
+ if isinstance(cd, dict):
340
+ title = cd.get('title')
341
+ points = cd.get('points') or []
342
+ icon_name = cd.get('icon')
343
+ elif isinstance(cd, (list, tuple)) and len(cd) > 1:
344
+ title, points, icon_name = cd[0], [['', cd[1]]], None
345
+ else:
346
+ title, points, icon_name = (cd or ''), [], None
327
347
  blocks.append(
328
- f' <div class="card">{card_head(str(title), ci)}\n'
348
+ f' <div class="card">{card_head(str(title), ci, icon_name)}\n'
329
349
  f' {pts_list(points)}</div>')
330
- body = f' <div class="grid {cls} rv a-start">\n' + '\n'.join(blocks) + '\n </div>\n'
350
+ body = f' <div class="grid {cls} rv">\n' + '\n'.join(blocks) + '\n </div>\n'
331
351
  return wrap(i, 'cards', body, sec)
332
352
 
333
353
 
@@ -682,20 +702,64 @@ def render_body(model: dict) -> str:
682
702
  </section>''')
683
703
  agenda = model.get('agenda') or []
684
704
  secs = [s for s in (model.get('sections') or []) if isinstance(s, dict)]
685
- # Agenda 与 sections 对齐:模型 agenda 缺失/条数不符时以 sections 为准(并回写模型)
686
- if len(agenda) != len(secs):
687
- model['agenda'] = [[f'{i:02d}', s.get('title') or '', s.get('eyebrow') or '']
688
- for i, s in enumerate(secs, 1)]
705
+
706
+ def _chapter_key(sec, idx):
707
+ """从 eyebrow 的 `NN ·` 前缀或 chapter 字段取章键;无则退回页码。"""
708
+ ch = sec.get('chapter')
709
+ if ch not in (None, ''):
710
+ return str(ch)
711
+ eb = (sec.get('eyebrow') or '').strip()
712
+ m = re.match(r'^(\d{1,2})\s*[·・\-—]', eb)
713
+ if m:
714
+ return m.group(1).zfill(2)
715
+ return f'{idx:02d}'
716
+
717
+ def _chapter_title(sec):
718
+ """章标题 = eyebrow 去掉 `NN ·` 前缀;无则退回页标题。"""
719
+ eb = (sec.get('eyebrow') or '').strip()
720
+ m = re.match(r'^\d{1,2}\s*[·・\-—]\s*(.+)$', eb)
721
+ if m and m.group(1).strip():
722
+ return m.group(1).strip()
723
+ return sec.get('title') or ''
724
+
725
+ # Agenda = 章节大纲(3–7 章),不是逐页标题罗列。
726
+ # 模型给了合法 agenda(≤8 条)就原样用;否则按 eyebrow 章前缀归并,而不是按页重建。
727
+ MAX_CH = 8
728
+ if not agenda or len(agenda) > MAX_CH or len(agenda) == len(secs) and len(secs) > MAX_CH:
729
+ chapters = {}
730
+ order = []
731
+ for i, s in enumerate(secs, 1):
732
+ key = _chapter_key(s, i)
733
+ if key not in chapters:
734
+ chapters[key] = {'num': key, 'title': _chapter_title(s), 'desc': (s.get('eyebrow') or ''),
735
+ 'first': i, 'pages': 0}
736
+ order.append(key)
737
+ chapters[key]['pages'] += 1
738
+ model['agenda'] = [[c['num'], c['title'], c['desc']] for c in (chapters[k] for k in order)]
689
739
  agenda = model['agenda']
740
+ chapter_first = {c['num']: c['first'] for c in (chapters[k] for k in order)}
741
+ else:
742
+ # 模型 agenda 已是章级:锚点落到该章第一页(按 eyebrow 对齐)
743
+ chapter_first = {}
744
+ for i, s in enumerate(secs, 1):
745
+ key = _chapter_key(s, i)
746
+ chapter_first.setdefault(key, i)
747
+ # 同时用序号兜底
748
+ chapter_first.setdefault(f'{i:02d}', i)
749
+ for gi, ag in enumerate(agenda, 1):
750
+ if isinstance(ag, (list, tuple)) and ag:
751
+ num = str(ag[0]).zfill(2) if str(ag[0]).isdigit() else str(ag[0])
752
+ chapter_first.setdefault(num, gi)
753
+
690
754
  if agenda or not (model.get('mode') == 'architecture' and len(secs) <= 4):
691
755
  lis = []
692
- for i, s in enumerate(secs, 1):
693
- ag = agenda[i - 1] if i - 1 < len(agenda) else [f'{i:02d}', s.get('title') or '', s.get('eyebrow') or '']
694
- num = ag[0] if isinstance(ag, (list, tuple)) and ag else f'{i:02d}'
695
- title = ag[1] if isinstance(ag, (list, tuple)) and len(ag) > 1 else (s.get('title') or '')
696
- desc = ag[2] if isinstance(ag, (list, tuple)) and len(ag) > 2 else (s.get('eyebrow') or '')
756
+ for gi, ag in enumerate(agenda, 1):
757
+ num = ag[0] if isinstance(ag, (list, tuple)) and ag else f'{gi:02d}'
758
+ title = ag[1] if isinstance(ag, (list, tuple)) and len(ag) > 1 else ''
759
+ desc = ag[2] if isinstance(ag, (list, tuple)) and len(ag) > 2 else ''
760
+ href_i = chapter_first.get(str(num).zfill(2)) or chapter_first.get(str(num)) or gi
697
761
  lis.append(
698
- f' <li class="agenda__i"><a class="agenda__a" href="#s{i}">'
762
+ f' <li class="agenda__i"><a class="agenda__a" href="#s{href_i}">'
699
763
  f'<span class="agenda__n">{esc(num)}</span>'
700
764
  f'<span><span class="agenda__t">{esc(title)}</span>'
701
765
  f'<div class="agenda__d">{esc(desc)}</div></span></a></li>')
@@ -731,18 +795,45 @@ def render_body(model: dict) -> str:
731
795
  <h2 class="t-h1 shead__title">{esc(closing.get("title") or "收尾")}</h2></div>
732
796
  {body} </div>
733
797
  </section>''')
734
- # 参考资料
735
- refs = []
736
- all_text = json.dumps(model, ensure_ascii=False)
737
- for n in sorted({int(x) for x in re.findall(r'\[(\d+)\]', all_text)}):
738
- refs.append(f' <li id="ref-{n}">[{n}] 来源名称,时间;口径。</li>')
739
- parts.append(f'''<section class="band band--flow" id="refs">
798
+ # 参考资料:只列模型给的真实来源;无真实来源整节省略(禁止占位条目)
799
+ refs_src = model.get('refs') or model.get('references') or []
800
+ real_refs = []
801
+ for r in refs_src:
802
+ if isinstance(r, dict):
803
+ name = (r.get('name') or r.get('title') or '').strip()
804
+ time_ = (r.get('time') or r.get('date') or '').strip()
805
+ caliber = (r.get('caliber') or r.get('note') or '').strip()
806
+ url = (r.get('url') or r.get('link') or '').strip()
807
+ elif isinstance(r, (list, tuple)) and r:
808
+ name = str(r[0]).strip() if r[0] else ''
809
+ time_ = str(r[1]).strip() if len(r) > 1 and r[1] else ''
810
+ caliber = str(r[2]).strip() if len(r) > 2 and r[2] else ''
811
+ url = str(r[3]).strip() if len(r) > 3 and r[3] else ''
812
+ else:
813
+ name = str(r or '').strip()
814
+ time_ = caliber = url = ''
815
+ # 拦占位串:宁缺毋假
816
+ if not name or any(p in name for p in ('来源名称', '替换为真实来源', '来源 N', '某行业报告')):
817
+ continue
818
+ if time_ and any(p in time_ for p in ('YYYY-MM', 'YYYY', '2026-01;口径')):
819
+ time_ = ''
820
+ real_refs.append((name, time_, caliber, url))
821
+ if real_refs:
822
+ lis = []
823
+ for n, (name, time_, caliber, url) in enumerate(real_refs, 1):
824
+ meta = ','.join(x for x in (time_, caliber) if x)
825
+ label = esc(name)
826
+ if url and url.startswith(('http://', 'https://', './', '../')) and 'example.com' not in url:
827
+ label = f'<a class="ref-link" href="{esc(url)}" target="_blank" rel="noopener">{esc(name)}</a>'
828
+ lis.append(f' <li id="ref-{n}">[{n}] {label}' + (f',{esc(meta)}' if meta else '') + '。</li>')
829
+ parts.append(f'''<section class="band band--flow" id="refs">
740
830
  <div class="wrap">
741
831
  <div class="shead rv"><div class="t-eyebrow">附录</div>
742
832
  <h2 class="t-h1 shead__title">参考资料</h2></div>
743
- <ol class="refs">{''.join(refs) or '<li>(无外部引用)</li>'}</ol>
833
+ <ol class="refs">{''.join(lis)}</ol>
744
834
  </div>
745
835
  </section>''')
836
+ # 无真实来源 → 不渲染 #refs 整节(也不写占位)
746
837
  return '\n\n'.join(parts)
747
838
 
748
839
 
@@ -756,6 +847,14 @@ def load_model(path: Path) -> dict:
756
847
  return json.loads(m.group(1))
757
848
 
758
849
 
850
+ def _strip_refs_nav(html: str, body: str) -> str:
851
+ """无真实来源(正文不含 #refs 节)时,摘掉导航/收尾的「参考资料」入口,避免悬空锚点。"""
852
+ if 'id="refs"' in body:
853
+ return html
854
+ html = re.sub(r'\s*<a[^>]*href="#refs"[^>]*>[\s\S]*?</a>', '', html)
855
+ return html
856
+
857
+
759
858
  def main() -> int:
760
859
  ap = argparse.ArgumentParser(description='从 REPORT_MODEL 渲染 HTML 正文(模型驱动生成)')
761
860
  ap.add_argument('src', help='model.json 或含 REPORT_MODEL 的 .html')
@@ -783,6 +882,7 @@ def main() -> int:
783
882
  # 同步模型(确保与渲染源一致)
784
883
  t = MODEL_RE.sub('window.REPORT_MODEL = ' +
785
884
  json.dumps(model, ensure_ascii=False, indent=2) + ';', t, count=1)
885
+ t = _strip_refs_nav(t, body)
786
886
  src.write_text(t, encoding='utf-8')
787
887
  print(f'已按模型重渲染正文:{src}({len(body)} 字符)')
788
888
  print(f' 下一步: python scripts/validate_report.py "{src}" --strict')
@@ -801,6 +901,7 @@ def main() -> int:
801
901
  t = CONTENT_RE.sub(r'\1\n' + body.replace('\\', '\\\\') + r'\n\2', t, count=1)
802
902
  t = MODEL_RE.sub('window.REPORT_MODEL = ' +
803
903
  json.dumps(model, ensure_ascii=False, indent=2) + ';', t, count=1)
904
+ t = _strip_refs_nav(t, body)
804
905
  out = Path(args.out or (src.with_suffix('.html')))
805
906
  out.write_text(t, encoding='utf-8')
806
907
  print(f'已从模型生成:{out} sections={len(model.get("sections") or [])}')
@@ -320,7 +320,7 @@ def pg_exhibit(i, sec):
320
320
  <div class="exhibit__hd"><span class="exhibit__no">Exhibit {sec.get('exhibitNo', 1)}</span>
321
321
  <span class="exhibit__t">图表标题(结论式)</span></div>
322
322
  {chart_svg(ct, i)}
323
- <div class="exhibit__src">来源:来源名称,YYYY-MM;口径说明<a class="cite" href="#ref-1">[1]</a></div>
323
+ <div class="exhibit__src">来源:<真实来源名>,YYYY-MM;口径说明<a class="cite" href="#ref-1">[1]</a></div>
324
324
  </div>
325
325
  {SIDE_NOTES}
326
326
  </div>
@@ -333,7 +333,7 @@ def pg_exhibit(i, sec):
333
333
  'chart': {'type': ct, 'labels': ['A', 'B', 'C'], 'values': [42, 61, 35],
334
334
  'dataTable': 'notes'},
335
335
  'soWhat': '一行含义或建议(≤60 字)。',
336
- 'footnote': '来源:来源名称,YYYY-MM;口径说明'}
336
+ 'footnote': '来源:<真实来源名>,YYYY-MM;口径说明'}
337
337
 
338
338
 
339
339
  def pg_twocol(i, sec):
@@ -389,7 +389,7 @@ def pg_halftable(i, sec):
389
389
  <div class="exhibit__hd"><span class="exhibit__no">Exhibit {sec.get('exhibitNo', 1)}</span>
390
390
  <span class="exhibit__t">右栏图表标题(结论式)</span></div>
391
391
  {chart_svg(ct, i)}
392
- <div class="exhibit__src">来源:来源名称,YYYY-MM;口径说明<a class="cite" href="#ref-1">[1]</a></div>
392
+ <div class="exhibit__src">来源:<真实来源名>,YYYY-MM;口径说明<a class="cite" href="#ref-1">[1]</a></div>
393
393
  </div>
394
394
  </div>
395
395
  </div>
@@ -705,7 +705,7 @@ def pg_info(i, sec):
705
705
  'marimekko': '双重编码(列宽 ∝ 规模,列高 = 构成占比)',
706
706
  'streamgraph': '构成演变(基线居中 · 带宽 ∝ 规模)'}[t]
707
707
  # 信息图页承载在 .fig 里(不是 .exhibit 框)→ 来源行走 .footnote,避免与 Exhibit 来源行统计混淆
708
- src = (' <div class="footnote">来源:来源名称,YYYY-MM;口径说明'
708
+ src = (' <div class="footnote">来源:<真实来源名>,YYYY-MM;口径说明'
709
709
  '<a class="cite" href="#ref-1">[1]</a></div>\n')
710
710
  html = f'''<section class="band" id="s{i}">
711
711
  <div class="wrap">
@@ -824,15 +824,15 @@ def closing(mode, title, points):
824
824
 
825
825
 
826
826
  def refs(mode, items):
827
- """参考资料条目与正文 `[n]` 上标一一对应(校验器检查双向对齐)。"""
828
- if items:
829
- body = (' <ul class="ul ul--num rv">\n' + '\n'.join(
830
- f''' <li id="ref-{n}">{esc(s)} <span class="t-xs" style="color:var(--text-3)">{esc(t)}</span></li>'''
831
- for n, (s, t) in enumerate(items, 1)) + '\n </ul>')
832
- else:
833
- body = (' <p class="t-body rv">本报告暂无外部引用;内部材料的来源、时间与口径见各页来源行与'
834
- ' <code>.footnote</code>。补入外部数据时同步加 <code>[n]</code> 上标与本节条目。</p>')
835
- return f'''<!-- 参考资料 -->
827
+ """参考资料:只列真实来源条目;无真实来源时整节省略(禁止占位条目误导读者)。
828
+ items 元素 = (来源名, 时间;口径) 且来源名必须是真实机构/报告/文档名。"""
829
+ if not items:
830
+ # 宁缺毋假:scaffold 不生成假「参考资料」页;正文无 [n] 时也不需要该节
831
+ return ''
832
+ body = (' <ul class="ul ul--num rv">\n' + '\n'.join(
833
+ f''' <li id="ref-{n}">{esc(s)} <span class="t-xs" style="color:var(--text-3)">{esc(t)}</span></li>'''
834
+ for n, (s, t) in enumerate(items, 1)) + '\n </ul>')
835
+ return f'''<!-- 参考资料(只列真实来源;无真实来源请整节删除并去掉正文 [n] 与导航入口) -->
836
836
  <section class="band band--tint" id="refs">
837
837
  <div class="wrap">
838
838
  <div class="shead rv">
@@ -985,7 +985,25 @@ def main() -> int:
985
985
  if p.get('lead'):
986
986
  m['lead'] = p['lead']
987
987
  model_secs.append(m)
988
- agenda_items.append((f's{k}', p['title'], p.get('eyebrow', '')))
988
+
989
+ # Agenda = 章节大纲(3–7 章),不是逐页标题罗列:按 eyebrow 的 `NN ·` 前缀归并
990
+ def _chap_key(eb, idx):
991
+ mm = re.match(r'^(\d{1,2})\s*[·・\-—]', (eb or '').strip())
992
+ return mm.group(1).zfill(2) if mm else f'{idx:02d}'
993
+
994
+ def _chap_title(eb, title):
995
+ mm = re.match(r'^\d{1,2}\s*[·・\-—]\s*(.+)$', (eb or '').strip())
996
+ return mm.group(1).strip() if mm and mm.group(1).strip() else (title or '')
997
+
998
+ chapters, ch_order = {}, []
999
+ for i, p in enumerate(pages, 1):
1000
+ key = _chap_key(p.get('eyebrow', ''), i)
1001
+ if key not in chapters:
1002
+ chapters[key] = {'num': key, 'title': _chap_title(p.get('eyebrow', ''), p['title']),
1003
+ 'desc': p.get('eyebrow', ''), 'first': i}
1004
+ ch_order.append(key)
1005
+ agenda_items = [(f's{chapters[k]["first"]}', chapters[k]['title'], chapters[k]['desc'])
1006
+ for k in ch_order]
989
1007
 
990
1008
  show_agenda = not (mode == 'architecture' and len(pages) <= 4)
991
1009
  closing_points = [['动作一', '一句话说明。'], ['动作二', '一句话说明。'], ['动作三', '一句话说明。']]
@@ -997,28 +1015,33 @@ def main() -> int:
997
1015
  body.append(closing(mode, '收尾主张(回应封面问题)', closing_points))
998
1016
  head = '\n\n'.join(body)
999
1017
 
1000
- # 参考资料条目由正文实际 `[n]` 上标反推(校验器检查双向对齐)
1018
+ # 参考资料:只在有真实来源条目时生成;scaffold 不再写「来源 N(替换为真实来源名称)」占位
1019
+ # 交付前由内容作者补 model.refs = [[来源名, 时间;口径]…](真实机构/报告/文档名),再跑 render_from_model
1001
1020
  ref_ids = sorted(set(re.findall(r'class="cite" href="#(ref-\d)"', head)))
1002
- ref_items = [[f'来源 {i}(替换为真实来源名称)', '2026-01;口径说明'] for i in range(1, len(ref_ids) + 1)]
1003
- content = head + '\n\n' + refs(mode, ref_items) + '\n\n' + footer(args.title, args.subtitle, args.meta)
1021
+ ref_items = [] # 真实来源由内容作者填入;空 = 不生成参考资料节(宁缺毋假)
1022
+ content = head
1023
+ if ref_items:
1024
+ content = head + '\n\n' + refs(mode, ref_items)
1025
+ content = content + '\n\n' + footer(args.title, args.subtitle, args.meta)
1004
1026
 
1005
1027
  assert CONTENT_RE.search(t), f'{tpl_path.name} 缺少 __TOPPPT_CONTENT__ 标记'
1006
1028
  t = CONTENT_RE.sub('<!-- __TOPPPT_CONTENT_START__ -->\n' + content +
1007
1029
  '\n<!-- __TOPPPT_CONTENT_END__ -->', t, count=1)
1008
1030
 
1009
- # ③ 顶栏导航与 Agenda 锚点一致
1010
- nav_links = '\n'.join(f' <a href="#s{i}">{esc(p["title"][:10])}</a>'
1011
- for i, p in enumerate(pages[:3], 1))
1031
+ # ③ 顶栏导航与 Agenda 锚点一致(章级,不是逐页标题)
1032
+ nav_links = '\n'.join(
1033
+ f' <a href="#s{chapters[k]["first"]}">{esc(chapters[k]["title"][:10])}</a>'
1034
+ for k in ch_order[:5])
1012
1035
  assert NAV_RE.search(t), '模板缺少顶栏导航'
1013
- t = NAV_RE.sub('<nav class="nav">\n' + nav_links + '\n <a href="#refs">参考资料</a>\n </nav>',
1014
- t, count=1)
1036
+ nav_extra = '\n <a href="#refs">参考资料</a>' if ref_items else ''
1037
+ t = NAV_RE.sub(f'<nav class="nav">\n{nav_links}{nav_extra}\n </nav>', t, count=1)
1015
1038
 
1016
1039
  # ④ REPORT_MODEL 骨架(与正文一一对应 · 严格 JSON)
1017
1040
  model = {
1018
1041
  'mode': mode, 'style': style, 'theme': theme,
1019
1042
  'title': args.title, 'subtitle': args.subtitle, 'meta': args.meta,
1020
- 'agenda': [[f'{i:02d}', p['title'], p.get('eyebrow', '')] for i, p in enumerate(pages, 1)]
1021
- if show_agenda else [],
1043
+ 'agenda': [[chapters[k]['num'], chapters[k]['title'], chapters[k]['desc']]
1044
+ for k in ch_order] if show_agenda else [],
1022
1045
  'sections': model_secs,
1023
1046
  'closing': {'title': '收尾主张(回应封面问题)',
1024
1047
  'points': [list(x) for x in closing_points]},
@@ -164,7 +164,8 @@ function regionOf(pageType, slotId, opts) {
164
164
  if (type === 'bar' && slot === 'primary') {
165
165
  var by = P.chartY || 2.95;
166
166
  var bb = opts.bottom != null ? opts.bottom : bottomDefault;
167
- return { x: MX + (P.chartX || 0), y: by, w: CW - (P.chartW || 0), h: Math.max(1.2, bb - by) };
167
+ /* chartX/chartW 历史误用已纠正:与 donut/table/hbar/exhibit 同口径,图表盒对齐版心 (MX, CW) */
168
+ return { x: MX, y: by, w: CW, h: Math.max(1.2, bb - by) };
168
169
  }
169
170
  if (type === 'bar' && slot === 'hbar') {
170
171
  var y0 = P.hbarY0 || 2.6;
@@ -1021,6 +1021,14 @@ def inspect_slide(
1021
1021
  picture_area_ratios: list[float] = []
1022
1022
  margin_emu = int(MIN_EDGE_MARGIN_IN * 914400)
1023
1023
 
1024
+ # 图标语义图(objectName `icon:` 前缀)单独计数:不进 pictures 内容图门禁
1025
+ def _is_icon_pic(el) -> bool:
1026
+ nv = el.find('.//p:cNvPr', NS)
1027
+ name = (nv.get('name') if nv is not None else '') or ''
1028
+ return name.startswith('icon:')
1029
+ icon_pictures = [p for p in pictures if _is_icon_pic(p)]
1030
+ content_pictures = [p for p in pictures if not _is_icon_pic(p)]
1031
+
1024
1032
  for element in all_elements:
1025
1033
  box = shape_bounds(element)
1026
1034
  if box is None:
@@ -1193,7 +1201,8 @@ def inspect_slide(
1193
1201
  "slide": slide_number,
1194
1202
  "native_text_shapes": native_text_shapes,
1195
1203
  "native_graphic_shapes": len(shapes) + len(graphic_frames),
1196
- "pictures": len(pictures),
1204
+ "pictures": len(content_pictures),
1205
+ "icon_pictures": len(icon_pictures),
1197
1206
  "charts": len(charts),
1198
1207
  "tables": len(tables),
1199
1208
  "element_count": len(all_elements),
@@ -1557,6 +1566,7 @@ def empty_report(path: Path) -> dict[str, Any]:
1557
1566
  "native_text_shapes": 0,
1558
1567
  "native_graphic_shapes": 0,
1559
1568
  "pictures": 0,
1569
+ "icon_pictures": 0,
1560
1570
  "charts": 0,
1561
1571
  "tables": 0,
1562
1572
  },
@@ -1720,7 +1730,7 @@ def validate_pptx(
1720
1730
  slide_chart_texts.append(slide_chart_text(archive, slide_name))
1721
1731
 
1722
1732
  report["summary"]["slide_count"] = len(slide_names)
1723
- for field in ("native_text_shapes", "native_graphic_shapes", "pictures", "charts", "tables"):
1733
+ for field in ("native_text_shapes", "native_graphic_shapes", "pictures", "icon_pictures", "charts", "tables"):
1724
1734
  report["summary"][field] = sum(slide[field] for slide in report["slides"])
1725
1735
 
1726
1736
  # P1-6 chrome 一致性:页脚/页码 y 相对中位数漂移
@@ -1730,7 +1740,8 @@ def validate_pptx(
1730
1740
  # 仅当模型显式声明 section.image / split.right.image 时才允许对应数量的图片落地。
1731
1741
  # 配图占位(image.placeholder)走原生形状,不计入图片数;版式/裁切/多图数量另做硬门禁。
1732
1742
  declared_images = count_model_images(model) if model_path is not None else 0
1733
- n_pics = report["summary"]["pictures"]
1743
+ n_pics = report["summary"]["pictures"] # 内容图(已扣除 icon: 语义图标图)
1744
+ n_icons = int(report["summary"].get("icon_pictures", 0) or 0)
1734
1745
  img_issues: list[tuple[str, str]] = []
1735
1746
  if model_path is not None and model is not None:
1736
1747
  img_issues = model_image_issues(model, _image_spec(),
@@ -1693,7 +1693,7 @@ def main():
1693
1693
  ag_cfg = (LC.get('contentQuality') or {}).get('agenda') or {}
1694
1694
  split_max = int(ag_cfg.get('splitMax') or 16)
1695
1695
  title_max = int(ag_cfg.get('titleMaxChars') or 36)
1696
- chk(f"Agenda 条数 ≤{split_max}(超出请拆上/下篇或章节级大纲)",
1696
+ chk(f"Agenda 条数 ≤{split_max}(大纲=章,3–7 条;超出说明在列页,请归并回章)",
1697
1697
  n_items <= split_max,
1698
1698
  f"{n_items} 条超上限——PPTX 已自动分页,HTML 会撑出一屏,建议改章节级大纲 6–8 条")
1699
1699
  long_titles = re.findall(r'class="agenda__t"[^>]*>([^<]{%d,})' % (title_max + 1), txt)
@@ -0,0 +1,40 @@
1
+ # topmind-cover · 封面配图生成
2
+
3
+ 为 X 长文和公众号文章生成封面图的可复用技能。目标:**震撼、醒目、主题突出**。
4
+
5
+ ## 安装
6
+
7
+ ```bash
8
+ npx @topmindspace/tms-skills install topmind-cover
9
+ ```
10
+
11
+ ## 用法
12
+
13
+ 1. 输入文章标题 + 3 个主题关键词 + 平台(x / wechat / both)
14
+ 2. 按 `references/cover-styles.md` 选风格模板组 prompt(标题字逐字写进 prompt)
15
+ 3. 用 agent 的图片生成能力出图(横构图 16:9,四周留白 8%)
16
+ 4. 肉眼检查:标题字逐字正确、主体完整、一眼能说出主题
17
+ 5. 裁剪落盘:
18
+
19
+ ```bash
20
+ python3 scripts/crop-cover.py <主图> --slug <slug> --out-dir <包>/images/
21
+ # 产出 00-封面.png (1200×675) + 00-封面-公众号.png (900×383,中央裁剪)
22
+ ```
23
+
24
+ ## 尺寸
25
+
26
+ | 平台 | 尺寸 | 比例 |
27
+ |------|------|------|
28
+ | X Article 封面 | 1200×675 | 16:9(主图) |
29
+ | 公众号封面大图 | 900×383 | 2.35:1(中央裁) |
30
+
31
+ ## 设计铁律
32
+
33
+ 一图一主题;主体占画面 40%+;大标题 ≤10 字高对比;忌元素堆砌、小字密排、多主体打架。
34
+
35
+ ## 开发
36
+
37
+ ```bash
38
+ python3 scripts/package_skill.py --check
39
+ python3 scripts/negative_tests.py
40
+ ```
@@ -0,0 +1,66 @@
1
+ ---
2
+ name: topmind-cover
3
+ version: 1.0.0
4
+ description: "文章封面配图生成:X 长文与公众号共用。震撼、醒目、主题突出;平台尺寸规范、风格模板、命名落盘、成图检查一次配齐。Use when 封面、封面图、头图、配图、cover。Do NOT use for 正文插图、PPT/报告封面。"
5
+ action_category: write
6
+ triggers:
7
+ - 封面
8
+ - 封面图
9
+ - 头图
10
+ - 配图
11
+ - cover
12
+ triggers_cn:
13
+ - 公众号封面
14
+ - X 封面
15
+ - 文章封面
16
+ author: Muse
17
+ license: MIT
18
+ updated: 2026-09-29
19
+ ---
20
+
21
+ # topmind-cover · 封面配图生成
22
+
23
+ 为 X 长文和公众号文章生成封面图的可复用技能。目标:**震撼、醒目、主题突出**。
24
+
25
+ ```
26
+ 标题/主题 → 风格模板 → 生成 → 检查 → 裁剪双尺寸 → 落盘命名
27
+ ```
28
+
29
+ ## 尺寸表
30
+
31
+ | 平台 | 尺寸 | 比例 | 备注 |
32
+ |------|------|------|------|
33
+ | X Article 封面 | 1200×675 | 16:9 | 主尺寸,先按这个生成 |
34
+ | 公众号封面大图 | 900×383 | 2.35:1 | 从 16:9 中央裁剪 |
35
+
36
+ 生成 prompt 里写明"横构图 16:9";公众号版用 `scripts/crop-cover.py` 从主图中央裁出,不重新生成(保系列一致)。
37
+
38
+ ## 工作流
39
+
40
+ 1. **输入**:文章标题、3 个主题关键词、平台(`x` / `wechat` / `both`,默认 both)、风格(默认按题材自动选,见 `references/cover-styles.md`)。
41
+ 2. **组 prompt**:`[风格模板] + 主体描述 + 大标题字(≤10 字,直接用文章标题或其压缩版)+ 横构图16:9 + 四周留白8%`。标题字必须在 prompt 里逐字写明,防 AI 自造错别字。
42
+ 3. **生成**:`media.generate_image`,`name` 用 `<slug>-cover`。
43
+ 4. **检查**(必做,肉眼看一遍):
44
+ - 标题字逐字正确,无错别字、无多字少字
45
+ - 主体完整不被裁切,画面不杂乱
46
+ - 一眼能说出主题;说不出就重生成
47
+ 5. **裁剪落盘**:
48
+ ```bash
49
+ python3 scripts/crop-cover.py <主图> --slug <slug> --out-dir <包>/images/
50
+ ```
51
+ 产出 `00-封面.png`(1200×675,X/公众号共用主文件)与 `00-封面-公众号.png`(900×383)。
52
+ 公众号包规约:正文引用 `images/00-封面.png`,`图片上传清单.md` 登记两版。
53
+ 6. **系列感**:同一系列文章固定色系 + 版式语言,prompt 复用同一风格模板。
54
+
55
+ ## 设计铁律
56
+
57
+ - 一图一主题;主体占画面 40% 以上,居中或三分法
58
+ - 大标题 ≤10 字,高对比(暗底亮字 / 亮底深字)
59
+ - 四周 8% 安全边距,标题不贴边
60
+ - 忌:元素堆砌、小字密排、多主体打架、标题字错误
61
+
62
+ ## When NOT to use
63
+
64
+ - 正文插图:主体是文字说明,按正文配图流程走
65
+ - 报告/PPT 封面:走 `top-ppt-html`
66
+ - 只想找现成图:走 `image_search` 技能
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "topmind-cover",
3
+ "version": "0.1.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "topmind-cover",
9
+ "version": "0.1.0",
10
+ "license": "MIT",
11
+ "engines": {
12
+ "node": ">=18"
13
+ }
14
+ }
15
+ }
16
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "topmind-cover",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "文章封面配图生成(X 长文 / 公众号共用):震撼醒目主题突出,尺寸规范 + 风格模板 + 裁剪落盘。",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/topmindspace/tms-skills.git",
10
+ "directory": "topmind-cover"
11
+ },
12
+ "type": "commonjs",
13
+ "engines": {
14
+ "node": ">=18"
15
+ }
16
+ }