@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
@@ -31,7 +31,10 @@ def main() -> None:
31
31
 
32
32
  src = Path(args.src)
33
33
  out_dir = Path(args.out_dir)
34
- out_dir.mkdir(parents=True, exist_ok=True)
34
+ try:
35
+ out_dir.mkdir(parents=True, exist_ok=True)
36
+ except OSError as e:
37
+ sys.exit(f"错误:无法创建输出目录 {out_dir}({e})")
35
38
 
36
39
  if not src.is_file():
37
40
  sys.exit(f"错误:找不到输入图片 {src}")
@@ -50,18 +53,21 @@ def main() -> None:
50
53
  x0, y0 = (w - cw) // 2, (h - ch) // 2
51
54
  base = img.crop((x0, y0, x0 + cw, y0 + ch))
52
55
 
53
- for name, (tw, th) in TARGETS.items():
54
- ratio = tw / th
55
- bw, bh = base.size
56
- if bw / bh > ratio:
57
- cw2, ch2 = int(bh * ratio), bh
58
- else:
59
- cw2, ch2 = bw, int(bw / ratio)
60
- x1, y1 = (bw - cw2) // 2, (bh - ch2) // 2
61
- out = base.crop((x1, y1, x1 + cw2, y1 + ch2)).resize((tw, th), Image.LANCZOS)
62
- dest = out_dir / name
63
- out.save(dest)
64
- print(f"OK {dest} ({tw}x{th})")
56
+ try:
57
+ for name, (tw, th) in TARGETS.items():
58
+ ratio = tw / th
59
+ bw, bh = base.size
60
+ if bw / bh > ratio:
61
+ cw2, ch2 = int(bh * ratio), bh
62
+ else:
63
+ cw2, ch2 = bw, int(bw / ratio)
64
+ x1, y1 = (bw - cw2) // 2, (bh - ch2) // 2
65
+ out = base.crop((x1, y1, x1 + cw2, y1 + ch2)).resize((tw, th), Image.LANCZOS)
66
+ dest = out_dir / name
67
+ out.save(dest)
68
+ print(f"OK {dest} ({tw}x{th})")
69
+ except OSError as e:
70
+ sys.exit(f"错误:写入封面图失败({e})")
65
71
 
66
72
 
67
73
  if __name__ == "__main__":
@@ -4,7 +4,7 @@
4
4
 
5
5
  Full lifecycle for WeChat articles: delivery package, review & rewrite, three quality gates, status sync, WeChat inline typography, and publish checklist.
6
6
 
7
- - Version: **v0.1.0** (same tag as `@topmindspace/tms-skills@0.3.6`)
7
+ - Version: **v0.1.0** (same tag as `@topmindspace/tms-skills@0.4.1`)
8
8
 
9
9
  ## Install
10
10
 
@@ -60,7 +60,7 @@ Output `demo/demo-公众号版.html` (open in a browser → click "复制正文"
60
60
 
61
61
  ```html
62
62
  <!-- h2 auto-numbered; closing chapters like "总结" get ∞ -->
63
- <h2 ...><span ...>01</span>Chapter One</h2>
63
+ <h2 ...><span ...>1</span>Chapter One</h2>
64
64
  <!-- images inlined as base64, pasted along with the text -->
65
65
  <img src="data:image/png;base64,iVBORw0KGgo..." ...>
66
66
  ```
@@ -4,7 +4,7 @@
4
4
 
5
5
  公众号文章全生命周期:交付包、审校改写、质量三关、状态同步、微信内联排版与发布清单。
6
6
 
7
- - 版本:**v0.1.0**(与 `@topmindspace/tms-skills@0.3.6` 同 tag)
7
+ - 版本:**v0.1.0**(与 `@topmindspace/tms-skills@0.4.1` 同 tag)
8
8
 
9
9
  ## 安装
10
10
 
@@ -59,7 +59,7 @@ python3 scripts/md2wechat.py --input demo.md --out-dir demo --slug demo --embed-
59
59
 
60
60
  ```html
61
61
  <!-- 二级标题自动编号;"总结"类末章编号为 ∞ -->
62
- <h2 ...><span ...>01</span>第一章</h2>
62
+ <h2 ...><span ...>1</span>第一章</h2>
63
63
  <!-- 图片 base64 内嵌,粘贴即带图 -->
64
64
  <img src="data:image/png;base64,iVBORw0KGgo..." ...>
65
65
  ```
@@ -50,7 +50,7 @@ updated: 2026-09-29
50
50
  | `scripts/scan_ai_flavor.py` | 中文去 AI 味扫描(与 `qu-aiwei-zh` 同源) |
51
51
 
52
52
  ```bash
53
- # 路径解析:CLI --base/--workspace → env TOPMIND_WECHAT_BASE / TOPMIND_WORKSPACE / TOPSTREAM_ROOT → 惯例
53
+ # 路径解析:CLI --base → env TOPMIND_WECHAT_BASE / TOPMIND_WORKSPACE / TOPSTREAM_ROOT → 惯例
54
54
  export TOPMIND_WORKSPACE=/path/to/workspace # 推荐
55
55
  python3 scripts/new-article.py --slug demo --title "标题" --direction reverse
56
56
  python3 scripts/lint-wechat.py --input <包>/公众号稿.md --fix
@@ -143,7 +143,7 @@ LANG_RULES = {
143
143
  ],
144
144
  "yaml": [
145
145
  ("comment", r"#[^\n]*"),
146
- ("key", r"(?m)^\s*[\w.\-/]+(?=\s*:)"),
146
+ ("key", r"^\s*[\w.\-/]+(?=\s*:)"),
147
147
  ("string", r'"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\''),
148
148
  ("keyword", r"\b(?:true|false|null|yes|no|on|off)\b"),
149
149
  ("number", r"\b\d+\.?\d*\b"),
@@ -157,9 +157,9 @@ LANG_RULES = {
157
157
  ("op", r"[=<>!*/+\-]+"),
158
158
  ],
159
159
  "diff": [
160
- ("meta", r"(?m)^@@[^\n]*|^---[^\n]*|^\+\+\+[^\n]*"),
161
- ("add", r"(?m)^\+[^\n]*"),
162
- ("del", r"(?m)^-[^\n]*"),
160
+ ("meta", r"^@@[^\n]*|^---[^\n]*|^\+\+\+[^\n]*"),
161
+ ("add", r"^\+[^\n]*"),
162
+ ("del", r"^-[^\n]*"),
163
163
  ],
164
164
  "html": [
165
165
  ("comment", r"<!--[\s\S]*?-->"),
@@ -219,7 +219,10 @@ def highlight(code, lang, scheme=None):
219
219
  palette = scheme or CTX.get("hl_scheme") or HIGHLIGHT
220
220
  pattern = _HL_PATTERN_CACHE.get(key)
221
221
  if pattern is None:
222
- pattern = re.compile("|".join("(?P<%s>%s)" % (name, pat) for name, pat in rules), re.S)
222
+ # (?m) 不能写进单个分支:拼成 alternation 后它就不在表达式开头了,
223
+ # Python 会报 "global flags not at the start"(历史 crash:```yaml / ```diff
224
+ # 代码块直接 Traceback)。MULTILINE 在编译期统一加,分支里只留 ^。
225
+ pattern = re.compile("|".join("(?P<%s>%s)" % (name, pat) for name, pat in rules), re.S | re.M)
223
226
  _HL_PATTERN_CACHE[key] = pattern
224
227
  out = []
225
228
  pos = 0
@@ -294,8 +297,12 @@ def load_theme(spec):
294
297
  path = resolve_theme(spec)
295
298
  if not path:
296
299
  return base
297
- with open(path, encoding="utf-8") as fh:
298
- data = json.load(fh)
300
+ try:
301
+ with open(path, encoding="utf-8") as fh:
302
+ data = json.load(fh)
303
+ except (OSError, ValueError) as e:
304
+ # ValueError 覆盖 json.JSONDecodeError:坏掉的主题文件给干净报错,不抛 Traceback
305
+ sys.exit("错误:主题文件无法解析 %s(%s)" % (path, e))
299
306
  accent = data.get("accent", base["accent"])
300
307
  # 主色派生字段必须按「本主题」的主色重算,不能继承默认主题的值:
301
308
  # 默认主题的 accent_light / mark_bg 是砖红系,继承过来会让蓝色或灰色主题里
@@ -341,8 +348,67 @@ PANGU_B = re.compile(r"([A-Za-z0-9])([%s])" % HAN)
341
348
  # render_inline() 的行内模式:预编译在模块级,避免每行重复走 re 缓存查找
342
349
  # (666k 行级调用 × 10 个模式时,re.sub 的字符串模式查找是可观开销)。
343
350
  INLINE_CODE_RE = re.compile(r"`([^`]+)`")
344
- INLINE_IMG_RE = re.compile(r"!\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
345
- INLINE_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)\s]+)\)")
351
+
352
+
353
+ def _parse_paren(s, j):
354
+ """s[j] == '(' 时找与之匹配的 ')'(括号可嵌套:URL 里允许出现平衡括号,如维基链接)。
355
+
356
+ 返回 (括号内文本, 右括号之后的位置);括号不平衡返回 None。
357
+ """
358
+ depth = 0
359
+ k, n = j, len(s)
360
+ while k < n:
361
+ c = s[k]
362
+ if c == "(":
363
+ depth += 1
364
+ elif c == ")":
365
+ depth -= 1
366
+ if depth == 0:
367
+ return s[j + 1:k], k + 1
368
+ k += 1
369
+ return None
370
+
371
+
372
+ # `(url)` / `(url "title")` / `(url 'title')` / `(url (title))` → url(title 丢弃)。
373
+ _URL_TITLE_RE = re.compile(r'^(\S+?)(?:\s+(?:"[^"]*"|\'[^\']*\'|\([^()]*\)))?\s*$')
374
+
375
+
376
+ def _split_url_title(inner):
377
+ """把链接/图片括号内的 `url "title"` 拆出 url(title 丢弃)。
378
+
379
+ 裸 url 不得含空白:含裸空白时返回 ""(调用方保留原文,
380
+ 与旧正则 `[^)\\s]+` 的行为一致,不静默吞掉)。
381
+ """
382
+ m = _URL_TITLE_RE.match(inner.strip())
383
+ return m.group(1) if m else ""
384
+
385
+
386
+ def _scan_md_links(text, img, repl):
387
+ """扫描替换行内图片/链接,url 括号支持嵌套、可带 "title"。
388
+
389
+ img=True 扫 ![alt](src),False 扫 [label](url);repl(文本, url) -> 替换串。
390
+ 括号不平衡、url 为空、url 含裸空白时保留原文(旧正则同样认不出这些写法,
391
+ 保留原文比截断 URL 更安全——历史 bug:`![a](img(1).png)` 被截成 `img(1`,
392
+ 文件明明存在却被记成缺失;`[维基](…/猫_(动物))` 的 URL 被截断、正文多个 `)`)。
393
+ """
394
+ open_tok = "![" if img else "["
395
+ skip = len(open_tok)
396
+ res = []
397
+ i, n = 0, len(text)
398
+ while i < n:
399
+ if text.startswith(open_tok, i):
400
+ k = text.find("]", i + skip)
401
+ if k != -1 and k + 1 < n and text[k + 1] == "(":
402
+ parsed = _parse_paren(text, k + 1)
403
+ if parsed:
404
+ url = _split_url_title(parsed[0])
405
+ if url:
406
+ res.append(repl(text[i + skip:k], url))
407
+ i = parsed[1]
408
+ continue
409
+ res.append(text[i])
410
+ i += 1
411
+ return "".join(res)
346
412
  INLINE_BADGE_RE = re.compile(r"\[!([^\]]+)\]")
347
413
  INLINE_HL_RE = re.compile(r"==([^=]+)==")
348
414
  INLINE_UNDER_RE = re.compile(r"\+\+([^+]+)\+\+")
@@ -390,9 +456,8 @@ def render_inline(raw):
390
456
  strong = 'font-weight:600;color:%s;' % theme["text_strong"]
391
457
  em = 'font-style:normal;color:%s;' % theme["text_muted"]
392
458
 
393
- # images(行内图与独立成行图共用同一正则语义)
394
- def img_repl(m):
395
- alt, src = m.group(1), m.group(2)
459
+ # images(行内图与独立成行图共用同一括号语义)
460
+ def img_repl(alt, src):
396
461
  # 行内图片同样走图片管线:本地文件解析 → 复制到 images/ → 登记清单,
397
462
  # 否则 --embed-images 找不到文件、清单漏项、计数为 0(历史 bug:
398
463
  # 段落里的 ![alt](x.png) 被原样保留相对路径,粘贴到公众号后整图消失)。
@@ -403,11 +468,13 @@ def render_inline(raw):
403
468
  return ('<img src="%s"%s style="max-width:100%%;height:auto;'
404
469
  'border-radius:3px;vertical-align:middle;">') % (src, caption)
405
470
 
406
- text = INLINE_IMG_RE.sub(img_repl, text)
471
+ text = _scan_md_links(text, True, img_repl)
407
472
 
408
473
  # links
409
- def link_repl(m):
410
- label, url = m.group(1), m.group(2)
474
+ def link_repl(label, url):
475
+ # 入口处已 esc():先还原,插入点与脚注登记处再按需转义。
476
+ # 否则脚注列表里 URL 的 & 会被 esc 两次、显示成 &amp;(历史 bug)。
477
+ label, url = html.unescape(label), html.unescape(url)
411
478
  if link_mode == "footnote" and footnotes is not None:
412
479
  # url→序号字典:原写法每次链接都扫全表两次(O(n²)),长文链接多时明显变慢
413
480
  fidx = CTX.get("footnote_idx")
@@ -423,14 +490,14 @@ def render_inline(raw):
423
490
  idx = [f["url"] for f in footnotes].index(url) + 1
424
491
  return ('%s<sup style="font-size:11px;color:%s;'
425
492
  'padding-left:1px;">[%d]</sup>'
426
- % (label, theme["accent"], idx))
493
+ % (esc(label), theme["accent"], idx))
427
494
  if link_mode == "inline":
428
495
  return ('%s<span style="font-size:13px;color:%s;">(%s)</span>'
429
- % (label, theme["text_light"], url))
496
+ % (esc(label), theme["text_light"], esc(url)))
430
497
  return ('<span style="font-size:13px;color:%s;word-break:break-all;">'
431
- '%s(%s)</span>' % (theme["text_light"], label, url))
498
+ '%s(%s)</span>' % (theme["text_light"], esc(label), esc(url)))
432
499
 
433
- text = INLINE_LINK_RE.sub(link_repl, text)
500
+ text = _scan_md_links(text, False, link_repl)
434
501
 
435
502
  # badges: [!文本]
436
503
  # 不用 display:inline-block:公众号对非常规 display 值支持不稳定,
@@ -483,9 +550,33 @@ def render_inline(raw):
483
550
  # ---------------------------------------------------------------- blocks
484
551
 
485
552
  HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$")
486
- FENCE_RE = re.compile(r"^```(\w*)\s*$")
553
+ # 围栏反引号数 >= 3:```` 开栏只能由 >= 4 反引号闭合(CommonMark),
554
+ # 否则 markdown 示例代码里的内层 ``` 会把外层栏提前"闭合",造成静默错排。
555
+ FENCE_RE = re.compile(r"^(`{3,})(\w*)\s*$")
487
556
  HR_RE = re.compile(r"^(-{3,}|\*{3,}|_{3,})\s*$")
488
- IMG_ONLY_RE = re.compile(r"^!\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)\s*$")
557
+
558
+
559
+ def match_img_only(line):
560
+ """独立成行图片:![alt](src) 独占一行(src 括号可嵌套、可带 title)。
561
+
562
+ 返回 (alt, src),不是独立成行图片返回 None。
563
+ 与行内图片共用同一括号语义(_parse_paren + _split_url_title)。
564
+ """
565
+ if not line.startswith("!["):
566
+ return None
567
+ k = line.find("]", 2)
568
+ if k == -1:
569
+ return None
570
+ j = k + 1
571
+ if j >= len(line) or line[j] != "(":
572
+ return None
573
+ parsed = _parse_paren(line, j)
574
+ if not parsed:
575
+ return None
576
+ src = _split_url_title(parsed[0])
577
+ if not src or line[parsed[1]:].strip():
578
+ return None
579
+ return line[2:k], src
489
580
  CAPTION_RE = re.compile(r"^\*(.+)\*\s*$")
490
581
  TABLE_SEP_RE = re.compile(r"^\|?[\s:|-]+\|[\s:|-]*$")
491
582
  CONTAINER_RE = re.compile(r"^:::+\s*(\w+)?\s*(.*)$")
@@ -920,7 +1011,10 @@ def resolve_image(src, md_path, out_dir, assets, asset_root):
920
1011
  "missing": True})
921
1012
  return src
922
1013
  img_dir = os.path.join(out_dir, "images")
923
- os.makedirs(img_dir, exist_ok=True)
1014
+ try:
1015
+ os.makedirs(img_dir, exist_ok=True)
1016
+ except OSError as e:
1017
+ sys.exit("错误:无法创建图片目录 %s(%s)" % (img_dir, e))
924
1018
  base = os.path.basename(abs_path)
925
1019
  target = os.path.join(img_dir, base)
926
1020
  n = 1
@@ -929,7 +1023,10 @@ def resolve_image(src, md_path, out_dir, assets, asset_root):
929
1023
  target = os.path.join(img_dir, "%s-%d%s" % (stem, n, ext))
930
1024
  n += 1
931
1025
  if not os.path.exists(target):
932
- shutil.copy2(abs_path, target)
1026
+ try:
1027
+ shutil.copy2(abs_path, target)
1028
+ except OSError as e:
1029
+ sys.exit("错误:复制图片失败 %s → %s(%s)" % (abs_path, target, e))
933
1030
  rel = os.path.relpath(target, out_dir)
934
1031
  assets.append({"src": src, "local": rel, "remote": False,
935
1032
  "basename": os.path.basename(target),
@@ -1002,10 +1099,20 @@ def convert_blocks(lines, md_path, out_dir, theme, ctx):
1002
1099
 
1003
1100
  m = FENCE_RE.match(stripped)
1004
1101
  if m:
1005
- lang = m.group(1)
1102
+ open_len = len(m.group(1))
1103
+ lang = m.group(2)
1006
1104
  i += 1
1007
1105
  buf = []
1008
- while i < n and not lines[i].strip().startswith("```"):
1106
+ while i < n:
1107
+ s2 = lines[i].strip()
1108
+ k = 0
1109
+ while k < len(s2) and s2[k] == "`":
1110
+ k += 1
1111
+ # 闭合栏:反引号数 >= 开栏数且后面无其它字符(闭合栏可比开栏长)。
1112
+ # 带信息串的 ```python 不是闭合栏,是栏内代码——旧写法按
1113
+ # startswith("```") 判闭合,会把栏提前关掉。
1114
+ if k >= open_len and not s2[k:].strip():
1115
+ break
1009
1116
  buf.append(lines[i])
1010
1117
  i += 1
1011
1118
  i += 1
@@ -1080,7 +1187,7 @@ def convert_blocks(lines, md_path, out_dir, theme, ctx):
1080
1187
  out.append(block_html)
1081
1188
  continue
1082
1189
 
1083
- m = IMG_ONLY_RE.match(stripped)
1190
+ m = match_img_only(stripped)
1084
1191
  if m:
1085
1192
  caption = None
1086
1193
  if i + 1 < n:
@@ -1088,9 +1195,9 @@ def convert_blocks(lines, md_path, out_dir, theme, ctx):
1088
1195
  if cm:
1089
1196
  caption = cm.group(1)
1090
1197
  i += 1
1091
- src = resolve_image(m.group(2), md_path, out_dir,
1198
+ src = resolve_image(m[1], md_path, out_dir,
1092
1199
  ctx["assets"], ctx["asset_root"])
1093
- out.append(render_figure(src, caption, theme, alt=m.group(1)))
1200
+ out.append(render_figure(src, caption, theme, alt=m[0]))
1094
1201
  i += 1
1095
1202
  continue
1096
1203
 
@@ -1101,7 +1208,7 @@ def convert_blocks(lines, md_path, out_dir, theme, ctx):
1101
1208
  if (not cur or HEADING_RE.match(cur) or HR_RE.match(cur)
1102
1209
  or CONTAINER_RE.match(cur) or cur.startswith(">")
1103
1210
  or cur.startswith("|") or is_list_item(lines[i])
1104
- or IMG_ONLY_RE.match(cur) or FENCE_RE.match(cur)):
1211
+ or match_img_only(cur) or FENCE_RE.match(cur)):
1105
1212
  break
1106
1213
  buf.append(cur)
1107
1214
  i += 1
@@ -1597,6 +1704,8 @@ def main():
1597
1704
  if missing:
1598
1705
  sys.exit("缺少必填参数:%s" % "、".join(missing))
1599
1706
 
1707
+ if os.path.isdir(args.input):
1708
+ sys.exit("错误:输入是目录不是文件 %s" % args.input)
1600
1709
  if not os.path.isfile(args.input):
1601
1710
  sys.exit("错误:找不到输入文件 %s" % args.input)
1602
1711
 
@@ -1609,7 +1718,10 @@ def main():
1609
1718
  except OSError as e:
1610
1719
  sys.exit("错误:无法读取输入文件 %s(%s)" % (args.input, e))
1611
1720
 
1612
- os.makedirs(args.out_dir, exist_ok=True)
1721
+ try:
1722
+ os.makedirs(args.out_dir, exist_ok=True)
1723
+ except OSError as e:
1724
+ sys.exit("错误:无法创建输出目录 %s(%s)" % (args.out_dir, e))
1613
1725
  body, assets, diagrams, footnotes = convert(
1614
1726
  md_text, os.path.abspath(args.input), args.out_dir, theme,
1615
1727
  args.link_mode, args.render_mermaid, args.asset_root or None,
@@ -87,6 +87,80 @@ def main() -> None:
87
87
  check("md2wechat 分隔线无 inline-block 告警",
88
88
  "inline-block" not in r.stdout, r.stdout[:200])
89
89
 
90
+ # 9. 括号 URL 不被截断(链接与图片;历史 bug:`![a](img(1).png)` 被截成
91
+ # `img(1` 导致真实文件记成缺失;`[维基](…/猫_(动物))` 的 URL 被截断、
92
+ # 正文多个悬挂 `)`;`[t](url "title")` 被原样保留)
93
+ (td / "img(1).png").write_bytes(_b64.b64decode(png))
94
+ (td / "paren.md").write_text(
95
+ "# t\n\n![括号图](img(1).png)\n\n"
96
+ "[维基](https://zh.wikipedia.org/wiki/猫_(动物)) 与 "
97
+ "[带title](https://example.com/a \"标题文字\")。\n",
98
+ encoding="utf-8")
99
+ r = run("md2wechat.py", ["--input", str(td / "paren.md"),
100
+ "--out-dir", str(td / "o9"), "--slug", "t9",
101
+ "--no-toc", "--signature", "off"])
102
+ html = (td / "o9" / "t9-公众号版.html").read_text(encoding="utf-8")
103
+ check("md2wechat 括号图片不截断",
104
+ 'src="images/img(1).png"' in html and (td / "o9" / "images" / "img(1).png").exists(),
105
+ r.stdout[:200])
106
+ check("md2wechat 括号链接不截断",
107
+ "猫_(动物)" in html and ")</sup>" not in html,
108
+ r.stdout[:200])
109
+ check("md2wechat title 链接被转换",
110
+ "https://example.com/a" in html and '[带title](' not in html,
111
+ r.stdout[:200])
112
+
113
+ # 10. 脚注 URL 的 & 不双重转义(历史 bug:显示成 &amp;)
114
+ (td / "amp.md").write_text(
115
+ "# t\n\n[和号](https://x.com/?a=1&b=2)\n", encoding="utf-8")
116
+ r = run("md2wechat.py", ["--input", str(td / "amp.md"),
117
+ "--out-dir", str(td / "o10"), "--slug", "t10",
118
+ "--no-toc", "--signature", "off"])
119
+ html = (td / "o10" / "t10-公众号版.html").read_text(encoding="utf-8")
120
+ check("md2wechat 脚注 URL 不双重转义",
121
+ "a=1&amp;b=2" in html and "&amp;amp;" not in html)
122
+
123
+ # 11. 坏主题 JSON 干净报错(历史 crash:json Traceback)
124
+ (td / "bad-theme.json").write_text("{bad json", encoding="utf-8")
125
+ (td / "t11.md").write_text("# t\n\n正文。\n", encoding="utf-8")
126
+ r = run("md2wechat.py", ["--input", str(td / "t11.md"),
127
+ "--out-dir", str(td / "o11"), "--slug", "t11",
128
+ "--theme", str(td / "bad-theme.json")])
129
+ check("md2wechat 坏主题 JSON 干净报错",
130
+ r.returncode != 0 and "Traceback" not in r.stderr, r.stderr[:100])
131
+
132
+ # 12. 目录当输入:文案明确(与 md2x 一致),非零干净退出
133
+ r = run("md2wechat.py", ["--input", str(td),
134
+ "--out-dir", str(td / "o12"), "--slug", "t12"])
135
+ check("md2wechat 目录输入文案正确",
136
+ r.returncode != 0 and "输入是目录不是文件" in r.stderr
137
+ and "Traceback" not in r.stderr, r.stderr[:100])
138
+
139
+ # 13. yaml/diff 代码块不高亮 crash(历史 crash:(?m) 拼进 alternation
140
+ # 报 "global flags not at the start",任何 ```yaml 块都 Traceback)
141
+ (td / "hl.md").write_text(
142
+ "# t\n\n```yaml\nkey: value\n```\n\n```diff\n+add\n-del\n```\n",
143
+ encoding="utf-8")
144
+ r = run("md2wechat.py", ["--input", str(td / "hl.md"),
145
+ "--out-dir", str(td / "o13"), "--slug", "t13"])
146
+ html = (td / "o13" / "t13-公众号版.html").read_text(encoding="utf-8")
147
+ check("md2wechat yaml/diff 代码块不崩溃",
148
+ r.returncode == 0 and "Traceback" not in r.stderr
149
+ and "key" in html, r.stderr[:100])
150
+
151
+ # 14. 嵌套围栏:```` 开栏不被内层 ``` 提前闭合
152
+ (td / "nest.md").write_text(
153
+ "# t\n\n````markdown\n外层\n```python\nprint(1)\n```\n结束\n````\n",
154
+ encoding="utf-8")
155
+ r = run("md2wechat.py", ["--input", str(td / "nest.md"),
156
+ "--out-dir", str(td / "o14"), "--slug", "t14",
157
+ "--no-toc", "--signature", "off"])
158
+ html = (td / "o14" / "t14-公众号版.html").read_text(encoding="utf-8")
159
+ body = html.split('<article id="wx-body"')[1]
160
+ check("md2wechat 嵌套围栏不提前闭合",
161
+ r.returncode == 0 and "````markdown" not in body
162
+ and body.count("MARKDOWN</section>") == 1, r.stderr[:100])
163
+
90
164
  if fails:
91
165
  print(f"\n[negative_tests] {len(fails)} 项失败")
92
166
  sys.exit(1)
@@ -4,7 +4,7 @@
4
4
 
5
5
  Turn a Markdown draft into a "copy → paste → publish" X long-form (Article) package.
6
6
 
7
- - Version: **v0.1.0** (same tag as `@topmindspace/tms-skills@0.3.6`)
7
+ - Version: **v0.1.0** (same tag as `@topmindspace/tms-skills@0.4.1`)
8
8
 
9
9
  ## Install
10
10
 
@@ -4,7 +4,7 @@
4
4
 
5
5
  把 Markdown 原稿变成"复制 → 粘贴 → 发"的 X 长文(Article)发布包。
6
6
 
7
- - 版本:**v0.1.0**(与 `@topmindspace/tms-skills@0.3.6` 同 tag)
7
+ - 版本:**v0.1.0**(与 `@topmindspace/tms-skills@0.4.1` 同 tag)
8
8
 
9
9
  ## 安装
10
10
 
@@ -193,6 +193,7 @@ def convert(md: str):
193
193
  defs = {}
194
194
  out_lines = []
195
195
  in_code = False
196
+ fence_len = 0
196
197
 
197
198
  md, esc_store = _protect_escapes(md)
198
199
  md = _strip_frontmatter(md)
@@ -209,9 +210,23 @@ def convert(md: str):
209
210
  for raw in body:
210
211
  line = raw.rstrip()
211
212
 
212
- # 代码块:先处理围栏,块内行原样缩进(分割线规则不进代码块)
213
- if line.strip().startswith("```"):
214
- in_code = not in_code
213
+ # 代码块:先处理围栏,块内行原样缩进(分割线规则不进代码块)。
214
+ # 围栏反引号数 >= 3:```` 开栏只能由 >= 4 反引号闭合(CommonMark),
215
+ # 否则 markdown 示例里的内层 ``` 会把外层栏提前"闭合",造成静默错排。
216
+ stripped = line.strip()
217
+ if stripped.startswith("```"):
218
+ k = 0
219
+ while k < len(stripped) and stripped[k] == "`":
220
+ k += 1
221
+ if not in_code:
222
+ in_code = True
223
+ fence_len = k
224
+ elif k >= fence_len and not stripped[k:].strip():
225
+ in_code = False
226
+ fence_len = 0
227
+ else:
228
+ # 栏内误写的短栏 / 带信息串的栏是代码内容,不是闭合栏
229
+ out_lines.append(" " + line)
215
230
  continue
216
231
  if in_code:
217
232
  out_lines.append(" " + line)
@@ -281,6 +296,8 @@ def main() -> None:
281
296
 
282
297
  inp = Path(args.input)
283
298
  if not inp.is_file():
299
+ if inp.is_dir():
300
+ sys.exit(f"错误:输入是目录不是文件 {inp}")
284
301
  sys.exit(f"错误:找不到输入文件 {inp}")
285
302
  try:
286
303
  md = inp.read_text(encoding="utf-8-sig")
@@ -291,8 +308,11 @@ def main() -> None:
291
308
  text, images = convert(md)
292
309
 
293
310
  out = Path(args.out)
294
- out.parent.mkdir(parents=True, exist_ok=True)
295
- out.write_text(text, encoding="utf-8")
311
+ try:
312
+ out.parent.mkdir(parents=True, exist_ok=True)
313
+ out.write_text(text, encoding="utf-8")
314
+ except OSError as e:
315
+ sys.exit(f"错误:无法写入输出文件 {out}({e})")
296
316
 
297
317
  chars = len(text.replace("\n", ""))
298
318
  print(f"OK {out}")
@@ -64,6 +64,23 @@ def main() -> None:
64
64
  out = (td / "o6.txt").read_text(encoding="utf-8") if (td / "o6.txt").exists() else ""
65
65
  check("正常输入含配图清单", r.returncode == 0 and "[图1]" in out)
66
66
 
67
+ # 7. 嵌套围栏:```` 开栏不被内层 ``` 提前闭合(内层栏是代码内容)
68
+ (td / "nest.md").write_text(
69
+ "# t\n\n````markdown\n外层\n```python\nprint(1)\n```\n结束\n````\n",
70
+ encoding="utf-8")
71
+ r = run([str(td / "nest.md"), "--out", str(td / "o7.txt")])
72
+ out = (td / "o7.txt").read_text(encoding="utf-8") if (td / "o7.txt").exists() else ""
73
+ check("嵌套围栏不提前闭合",
74
+ r.returncode == 0 and " ```python" in out and "print(1)" in out)
75
+
76
+ # 8. 括号 URL 链接不断裂(与 md2wechat 对齐的守卫)
77
+ (td / "paren.md").write_text(
78
+ "# t\n\n[维基](https://zh.wikipedia.org/wiki/猫_(动物))\n", encoding="utf-8")
79
+ r = run([str(td / "paren.md"), "--out", str(td / "o8.txt")])
80
+ out = (td / "o8.txt").read_text(encoding="utf-8") if (td / "o8.txt").exists() else ""
81
+ check("括号 URL 不截断",
82
+ r.returncode == 0 and "猫_(动物)" in out)
83
+
67
84
  if fails:
68
85
  print(f"\n[negative_tests] {len(fails)} 项失败")
69
86
  sys.exit(1)