@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
@@ -0,0 +1,1602 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """md2wechat v2 — Markdown -> WeChat MP inline-style HTML.
4
+
5
+ Zero dependency (stdlib only). WeChat's rich text editor strips <style> blocks
6
+ and class attributes, so every style in the output is inlined.
7
+
8
+ Supported beyond CommonMark:
9
+ - code syntax highlighting (inline span colors, no <style>)
10
+ - ::: container blocks (note/tip/warn/danger/pull/dialogue/stat)
11
+ - [!badge] inline tags, ==highlight==
12
+ - footnote link mode (external links -> end-of-article index)
13
+ - CJK "pangu" spacing between Han and Latin/digits
14
+ - four heading-2 style variants
15
+
16
+ Usage:
17
+ python3 md2wechat.py --input 稿.md --out-dir 交付包 --slug foo \
18
+ --asset-root /path/to/repo [--theme theme.json] [--link-mode footnote] \
19
+ [--embed-images]
20
+
21
+ Outputs:
22
+ <out-dir>/<slug>-公众号版.html full page, copy body section into WeChat
23
+ <out-dir>/images/* local image assets
24
+ <out-dir>/diagrams/* extracted mermaid sources (+ png if rendered)
25
+ <out-dir>/图片上传清单.md manual upload checklist
26
+
27
+ --embed-images: 把正文 <img> 的本地路径换成 base64 data URI。
28
+ 公众号后台拿到相对路径或本地/内网地址时无法取图,粘贴过去图片会整段消失;
29
+ base64 内嵌后图片字节随剪贴板一起过去,粘贴即带图。images/ 仍照常导出,
30
+ 作为「后台显示占位符时手动补传」的兜底。
31
+ """
32
+
33
+ import argparse
34
+ import base64
35
+ import html
36
+ import json
37
+ import os
38
+ import re
39
+ import shutil
40
+ import subprocess
41
+ import sys
42
+
43
+ # ---------------------------------------------------------------- theme
44
+
45
+ DEFAULT_THEME = {
46
+ "id": "minimal-ink",
47
+ "name": "极简黑白灰",
48
+ "genre": ["观点", "深度分析", "随笔"],
49
+ "accent": "#A6524A",
50
+ # accent 的浅化与淡化变体:下划线与浅底高亮都必须与主色同源,
51
+ # 否则主题一换就会露出上一套主题的色(历史 bug:tech-blue 继承砖红 mark_bg)。
52
+ "accent_light": "#E3BAB3",
53
+ "accent_soft": "#FAF0EE",
54
+ "text": "#333333",
55
+ "text_strong": "#1A1A1A",
56
+ "text_muted": "#888888",
57
+ "text_light": "#999999",
58
+ "surface": "#F7F7F7",
59
+ "border": "#E8E8E8",
60
+ "code_bg": "#2C2C2C",
61
+ "code_text": "#E8E8E8",
62
+ "c_note": "#C9C9C9",
63
+ "c_tip": "#5B8C5A",
64
+ "c_warn": "#C08A3E",
65
+ "c_danger": "#B5544C",
66
+ "mark_bg": "#FAF0EE",
67
+ "radius": "4px",
68
+ "font_size": "16px",
69
+ "line_height": "1.75",
70
+ "para_gap": "22px",
71
+ # 公众号只认 left / center / right,justify 会被判为非标准值
72
+ "align": "left",
73
+ "h2_style": "number",
74
+ "toc": True,
75
+ "toc_max": 5,
76
+ "toc_min_h2": 4,
77
+ "toc_min_chars": 2000,
78
+ "table_max_cols": 3,
79
+ "code_scheme": "mono",
80
+ "img_border": "#F0F0F0",
81
+ "font_stack": (
82
+ "-apple-system,BlinkMacSystemFont,'Helvetica Neue',"
83
+ "'PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif"
84
+ ),
85
+ "mono_stack": "Menlo,Monaco,Consolas,'Courier New',monospace",
86
+ }
87
+
88
+ # Material Palenight derived, desaturated to survive on #2C2C2C
89
+ HIGHLIGHT = {
90
+ "comment": "#6B7A8F",
91
+ "string": "#C3E88D",
92
+ "number": "#F78C6C",
93
+ "keyword": "#C792EA",
94
+ "builtin": "#FFCB6B",
95
+ "func": "#82AAFF",
96
+ "variable": "#FFCB6B",
97
+ "op": "#89DDFF",
98
+ "key": "#82AAFF",
99
+ "punct": "#89DDFF",
100
+ "flag": "#FFCB6B",
101
+ "tag": "#F07178",
102
+ "attr": "#FFCB6B",
103
+ "add": "#C3E88D",
104
+ "del": "#E58B7F",
105
+ "meta": "#6B7A8F",
106
+ }
107
+
108
+ # ordered rules: earlier patterns win
109
+ LANG_RULES = {
110
+ "python": [
111
+ ("comment", r"#[^\n]*"),
112
+ ("string", r'"""[\s\S]*?"""|\'\'\'[\s\S]*?\'\'\'|"(?:\\.|[^"\\\n])*"|\'(?:\\.|[^\'\\\n])*\''),
113
+ ("keyword", r"\b(?:def|class|if|elif|else|for|while|return|import|from|as|try|except|finally|with|lambda|yield|pass|break|continue|in|is|not|and|or|None|True|False|self|async|await|raise|assert|global|del|match|case)\b"),
114
+ ("builtin", r"\b(?:print|len|range|int|str|float|list|dict|set|tuple|open|enumerate|zip|map|filter|sum|min|max|abs|sorted|type|isinstance|super|getattr|setattr|hasattr)\b"),
115
+ ("func", r"\b[A-Za-z_]\w*(?=\s*\()"),
116
+ ("number", r"\b\d+\.?\d*\b"),
117
+ ("op", r"[=+\-*/%<>!&|^~]+"),
118
+ ],
119
+ "js": [
120
+ ("comment", r"//[^\n]*|/\*[\s\S]*?\*/"),
121
+ ("string", r"`(?:\\.|[^`\\])*`|\"(?:\\.|[^\"\\\n])*\"|'(?:\\.|[^'\\\n])*'"),
122
+ ("keyword", r"\b(?:const|let|var|function|return|if|else|for|while|class|extends|new|await|async|import|export|from|default|try|catch|finally|throw|typeof|instanceof|this|super|null|undefined|true|false|switch|case|break|continue|delete|in|of|yield|static|get|set)\b"),
123
+ ("builtin", r"\b(?:console|JSON|Math|Object|Array|String|Number|Boolean|Promise|window|document|require|module|exports|process)\b"),
124
+ ("func", r"\b[A-Za-z_$]\w*(?=\s*\()"),
125
+ ("number", r"\b\d+\.?\d*\b"),
126
+ ("op", r"[=+\-*/%<>!&|^~?:]+"),
127
+ ],
128
+ "json": [
129
+ ("key", r'"(?:\\.|[^"\\])*"(?=\s*:)'),
130
+ ("string", r'"(?:\\.|[^"\\])*"'),
131
+ ("keyword", r"\b(?:true|false|null)\b"),
132
+ ("number", r"-?\b\d+\.?\d*(?:[eE][+-]?\d+)?\b"),
133
+ ("punct", r"[{}\[\],:]"),
134
+ ],
135
+ "bash": [
136
+ ("comment", r"#[^\n]*"),
137
+ ("string", r'"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\''),
138
+ ("variable", r"\$\w+|\$\{[^}]*\}"),
139
+ ("keyword", r"\b(?:if|then|else|fi|for|in|do|done|while|case|esac|function|return|export|local|source)\b"),
140
+ ("builtin", r"\b(?:npm|npx|node|python3?|pip3?|git|docker|curl|wget|vercel|mmdc|openssl|mkdir|cp|mv|rm|ls|cat|grep|sed|awk|trash|open|echo|cd|sudo|chmod|kill|ps|top)\b"),
141
+ ("flag", r"(?<=\s)-{1,2}[\w-]+"),
142
+ ("op", r"[|&;<>]+"),
143
+ ],
144
+ "yaml": [
145
+ ("comment", r"#[^\n]*"),
146
+ ("key", r"(?m)^\s*[\w.\-/]+(?=\s*:)"),
147
+ ("string", r'"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\''),
148
+ ("keyword", r"\b(?:true|false|null|yes|no|on|off)\b"),
149
+ ("number", r"\b\d+\.?\d*\b"),
150
+ ("punct", r"[-:]"),
151
+ ],
152
+ "sql": [
153
+ ("comment", r"--[^\n]*"),
154
+ ("string", r"'(?:\\.|[^'\\])*'"),
155
+ ("keyword", r"\b(?:SELECT|FROM|WHERE|INSERT|INTO|VALUES|UPDATE|SET|DELETE|CREATE|TABLE|ALTER|DROP|JOIN|LEFT|RIGHT|INNER|OUTER|ON|GROUP|BY|ORDER|LIMIT|OFFSET|AS|AND|OR|NOT|NULL|IS|IN|LIKE|COUNT|SUM|AVG|MAX|MIN|DISTINCT|INDEX|PRIMARY|KEY|DEFAULT|CASCADE|WITH|UNION|HAVING)\b"),
156
+ ("number", r"\b\d+\.?\d*\b"),
157
+ ("op", r"[=<>!*/+\-]+"),
158
+ ],
159
+ "diff": [
160
+ ("meta", r"(?m)^@@[^\n]*|^---[^\n]*|^\+\+\+[^\n]*"),
161
+ ("add", r"(?m)^\+[^\n]*"),
162
+ ("del", r"(?m)^-[^\n]*"),
163
+ ],
164
+ "html": [
165
+ ("comment", r"<!--[\s\S]*?-->"),
166
+ ("string", r'"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\''),
167
+ ("tag", r"</?[A-Za-z][\w:.-]*|/?>"),
168
+ ("attr", r"[\w:.-]+(?=\s*=)"),
169
+ ("punct", r"[=]"),
170
+ ],
171
+ "css": [
172
+ ("comment", r"/\*[\s\S]*?\*/"),
173
+ ("keyword", r"@[\w-]+"),
174
+ ("string", r'"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\''),
175
+ ("key", r"[\w-]+(?=\s*:)"),
176
+ ("number", r"-?\b\d+\.?\d*(?:px|em|rem|%|vh|vw|s|ms)?\b|#[0-9a-fA-F]{3,8}"),
177
+ ("punct", r"[{}\[\],;:]"),
178
+ ],
179
+ }
180
+
181
+ LANG_ALIASES = {
182
+ "javascript": "js", "jsx": "js", "typescript": "js", "ts": "js", "tsx": "js",
183
+ "py": "python", "sh": "bash", "shell": "bash", "zsh": "bash", "console": "bash",
184
+ "yml": "yaml", "xml": "html", "vue": "html", "scss": "css", "less": "css",
185
+ "http": "json", "diff": "diff", "patch": "diff",
186
+ }
187
+
188
+
189
+ def build_code_scheme(theme):
190
+ """按主题生成代码高亮色板。
191
+
192
+ 默认 mono:只用「主色 + 明暗层级」,不引入第二套色彩语言。
193
+ 历史问题:代码块固定用 Material Palenight 的 16 色(天蓝/蓝/绿/橙/紫),
194
+ 与砖红主题毫无关系,一篇文章里出现两套色板,读者会觉得是另一个号。
195
+ """
196
+ if theme.get("code_scheme") == "palenight":
197
+ return dict(HIGHLIGHT)
198
+ base = theme.get("code_text", "#E8E8E8")
199
+ bg = theme.get("code_bg", "#2C2C2C")
200
+ acc = mix_hex(theme.get("accent", "#A6524A"), "#FFFFFF", 0.34)
201
+ dim = mix_hex(base, bg, 0.46)
202
+ mid = mix_hex(base, bg, 0.20)
203
+ return {
204
+ "comment": dim, "meta": dim,
205
+ "string": mid, "number": mid,
206
+ "keyword": acc, "tag": acc, "del": acc,
207
+ "builtin": base, "func": base, "variable": base,
208
+ "op": mid, "key": mid, "punct": mid, "flag": mid,
209
+ "attr": mid, "add": mid,
210
+ }
211
+
212
+
213
+ def highlight(code, lang, scheme=None):
214
+ """Inline-span syntax highlighting (no <style>, no class)."""
215
+ key = LANG_ALIASES.get(lang, lang)
216
+ rules = LANG_RULES.get(key)
217
+ if not rules:
218
+ return html.escape(code, quote=False)
219
+ palette = scheme or CTX.get("hl_scheme") or HIGHLIGHT
220
+ pattern = re.compile("|".join("(?P<%s>%s)" % (name, pat) for name, pat in rules), re.S)
221
+ out = []
222
+ pos = 0
223
+ for m in pattern.finditer(code):
224
+ if m.start() > pos:
225
+ out.append(html.escape(code[pos:m.start()], quote=False))
226
+ color = palette.get(m.lastgroup)
227
+ text = html.escape(m.group(), quote=False)
228
+ out.append('<span style="color:%s;">%s</span>' % (color, text) if color else text)
229
+ pos = m.end()
230
+ out.append(html.escape(code[pos:], quote=False))
231
+ return "".join(out)
232
+
233
+
234
+ THEME_DIR = os.path.normpath(os.path.join(
235
+ os.path.dirname(os.path.abspath(__file__)), "..", "assets", "themes"))
236
+
237
+
238
+ def list_themes():
239
+ """列出 assets/themes/ 下所有可用主题(按题材分主题的清单来源)。"""
240
+ out = []
241
+ if not os.path.isdir(THEME_DIR):
242
+ return out
243
+ for fn in sorted(os.listdir(THEME_DIR)):
244
+ if not fn.endswith(".json"):
245
+ continue
246
+ path = os.path.join(THEME_DIR, fn)
247
+ try:
248
+ with open(path, encoding="utf-8") as fh:
249
+ d = json.load(fh)
250
+ except Exception:
251
+ continue
252
+ out.append({
253
+ "file": path, "stem": fn[:-5],
254
+ "id": d.get("id", fn[:-5]), "name": d.get("name", fn[:-5]),
255
+ "genre": d.get("genre", []), "accent": d.get("accent", ""),
256
+ "h2_style": d.get("h2_style", ""),
257
+ })
258
+ return out
259
+
260
+
261
+ def resolve_theme(spec):
262
+ """把 --theme 的值解析成主题文件路径。
263
+
264
+ 支持三种写法,降低「按题材换主题」的使用门槛:
265
+ 1. 空串 -> 用 DEFAULT_THEME(不读文件)
266
+ 2. 主题名 / 标识 -> 在 assets/themes/ 里按 id、文件名、中文名匹配
267
+ 3. 文件路径 -> 直接读
268
+ 另有 "genre:题材" 语法按题材自动选主题(方案 B 的入口)。
269
+ """
270
+ if not spec:
271
+ return None
272
+ if os.path.exists(spec):
273
+ return spec
274
+ themes = list_themes()
275
+ if spec.startswith("genre:"):
276
+ want = spec.split(":", 1)[1].strip()
277
+ for t in themes:
278
+ if want in t["genre"]:
279
+ return t["file"]
280
+ raise SystemExit("没有主题声明题材「%s」。各主题题材见 references/theme-map.md" % want)
281
+ low = spec.strip().lower()
282
+ for t in themes:
283
+ if low in (t["id"].lower(), t["stem"].lower(), t["name"].lower()):
284
+ return t["file"]
285
+ raise SystemExit("未知主题:%s\n可用主题:%s" % (
286
+ spec, "、".join("%s(%s)" % (t["name"], t["id"]) for t in themes) or "(无)"))
287
+
288
+
289
+ def load_theme(spec):
290
+ base = dict(DEFAULT_THEME)
291
+ path = resolve_theme(spec)
292
+ if not path:
293
+ return base
294
+ with open(path, encoding="utf-8") as fh:
295
+ data = json.load(fh)
296
+ accent = data.get("accent", base["accent"])
297
+ # 主色派生字段必须按「本主题」的主色重算,不能继承默认主题的值:
298
+ # 默认主题的 accent_light / mark_bg 是砖红系,继承过来会让蓝色或灰色主题里
299
+ # 冒出砖红下划线和砖红高亮底——这正是 tech-blue 等主题当年从没被启用的原因。
300
+ data.setdefault("accent_light", mix_hex(accent, "#FFFFFF", 0.62))
301
+ data.setdefault("accent_soft", mix_hex(accent, "#FFFFFF", 0.92))
302
+ data.setdefault("mark_bg", data["accent_soft"])
303
+ theme = dict(base)
304
+ theme.update(data)
305
+ theme.setdefault("id", os.path.basename(path)[:-5])
306
+ return theme
307
+
308
+
309
+ def mix_hex(a, b, ratio):
310
+ """把 a 往 b 混 ratio 比例(0=全 a,1=全 b)。主题色派生用,避免手填出错。"""
311
+ a = a.lstrip("#")
312
+ b = b.lstrip("#")
313
+ if len(a) != 6 or len(b) != 6:
314
+ return "#" + a
315
+ out = []
316
+ for i in (0, 2, 4):
317
+ va, vb = int(a[i:i + 2], 16), int(b[i:i + 2], 16)
318
+ out.append("%02X" % int(round(va + (vb - va) * ratio)))
319
+ return "#" + "".join(out)
320
+
321
+
322
+ # ---------------------------------------------------------------- inline
323
+
324
+ # shared render state, set once per convert() run
325
+ CTX = {
326
+ "theme": DEFAULT_THEME,
327
+ "link_mode": "footnote",
328
+ "footnotes": [],
329
+ "use_pangu": True,
330
+ }
331
+
332
+ CODE_TOKEN = "\x00C%d\x00"
333
+ HAN = r"\u4e00-\u9fff\u3400-\u4dbf"
334
+ HAN_RE = re.compile("[%s]" % HAN)
335
+ PANGU_A = re.compile(r"([%s])([A-Za-z0-9])" % HAN)
336
+ PANGU_B = re.compile(r"([A-Za-z0-9])([%s])" % HAN)
337
+
338
+
339
+ def pangu(text):
340
+ text = PANGU_A.sub(r"\1 \2", text)
341
+ text = PANGU_B.sub(r"\1 \2", text)
342
+ return text
343
+
344
+
345
+ def esc(text):
346
+ return html.escape(text, quote=False)
347
+
348
+
349
+ def render_inline(raw):
350
+ """Escape + markdown inline -> HTML with inline styles.
351
+
352
+ Reads shared state from CTX so links/footnotes resolve across all
353
+ block types (paragraphs, tables, lists, containers) uniformly.
354
+ """
355
+ theme = CTX["theme"]
356
+ link_mode = CTX["link_mode"]
357
+ footnotes = CTX["footnotes"]
358
+ use_pangu = CTX["use_pangu"]
359
+ spans = []
360
+
361
+ def stash(code):
362
+ spans.append(code)
363
+ return CODE_TOKEN % (len(spans) - 1)
364
+
365
+ text = esc(raw)
366
+ text = re.sub(r"`([^`]+)`", lambda m: stash(m.group(1)), text)
367
+
368
+ if use_pangu:
369
+ text = pangu(text)
370
+
371
+ strong = 'font-weight:600;color:%s;' % theme["text_strong"]
372
+ em = 'font-style:normal;color:%s;' % theme["text_muted"]
373
+
374
+ # images
375
+ def img_repl(m):
376
+ alt, src = m.group(1), m.group(2)
377
+ caption = ' alt="%s"' % alt if alt else ""
378
+ return ('<img src="%s"%s style="max-width:100%%;height:auto;'
379
+ 'border-radius:3px;vertical-align:middle;">') % (src, caption)
380
+
381
+ text = re.sub(r"!\[([^\]]*)\]\(([^)\s]+)\)", img_repl, text)
382
+
383
+ # links
384
+ def link_repl(m):
385
+ label, url = m.group(1), m.group(2)
386
+ if link_mode == "footnote" and footnotes is not None:
387
+ if url not in [f["url"] for f in footnotes]:
388
+ footnotes.append({"url": url, "label": label})
389
+ idx = [f["url"] for f in footnotes].index(url) + 1
390
+ return ('%s<sup style="font-size:11px;color:%s;'
391
+ 'padding-left:1px;">[%d]</sup>'
392
+ % (label, theme["accent"], idx))
393
+ if link_mode == "inline":
394
+ return ('%s<span style="font-size:13px;color:%s;">(%s)</span>'
395
+ % (label, theme["text_light"], url))
396
+ return ('<span style="font-size:13px;color:%s;word-break:break-all;">'
397
+ '%s(%s)</span>' % (theme["text_light"], label, url))
398
+
399
+ text = re.sub(r"\[([^\]]+)\]\(([^)\s]+)\)", link_repl, text)
400
+
401
+ # badges: [!文本]
402
+ # 不用 display:inline-block:公众号对非常规 display 值支持不稳定,
403
+ # 徽章是纯行内元素,padding 足够撑开视觉。
404
+ text = re.sub(
405
+ r"\[!([^\]]+)\]",
406
+ r'<span style="font-size:11px;line-height:1.6;'
407
+ r'color:' + theme["accent"] + r';background:' + theme["accent_soft"] + r';'
408
+ r'padding:1px 6px;border-radius:2px;margin:0 2px;'
409
+ r'font-weight:600;">\1</span>',
410
+ text,
411
+ )
412
+
413
+ # ==高亮== before bold so inner markup still works
414
+ text = re.sub(
415
+ r"==([^=]+)==",
416
+ r'<strong style="font-weight:600;color:' + theme["text_strong"] + r';'
417
+ r'background:' + theme["accent_soft"] + r';padding:1px 3px;'
418
+ r'border-radius:2px;">\1</strong>',
419
+ text,
420
+ )
421
+
422
+ # ++下划线++ / <u>下划线</u>:标记层(正文关键词标记),出现频率最高、
423
+ # 权重介于加粗与高亮之间。esc() 已把 <u> 转义,故这里匹配实体。
424
+ underline = ('<span style="border-bottom:2px solid %s;font-weight:600;'
425
+ 'color:%s;">\\1</span>'
426
+ % (theme["accent_light"], theme["text_strong"]))
427
+ text = re.sub(r"\+\+([^+]+)\+\+", underline, text)
428
+ text = re.sub(r"&lt;u&gt;(.+?)&lt;/u&gt;", underline, text)
429
+
430
+ # ~~荧光笔~~:半高亮。用纯色浅底,不用 linear-gradient
431
+ # (渐变在公众号会被静默丢弃,退化成无底)。
432
+ text = re.sub(
433
+ r"~~([^~]+)~~",
434
+ r'<span style="background:%s;font-weight:600;color:%s;">\1</span>'
435
+ % (theme["accent_soft"], theme["text_strong"]),
436
+ text,
437
+ )
438
+
439
+ text = re.sub(r"\*\*([^*]+)\*\*", r'<strong style="%s">\1</strong>' % strong, text)
440
+ text = re.sub(r"(?<![*\w])\*([^*\n]+)\*(?!\*)", r'<em style="%s">\1</em>' % em, text)
441
+
442
+ for idx, code in enumerate(spans):
443
+ text = text.replace(
444
+ CODE_TOKEN % idx,
445
+ '<code style="background:%s;color:%s;padding:2px 5px;'
446
+ 'border-radius:3px;font-size:14px;font-family:%s;word-break:break-all;">%s</code>'
447
+ % (theme["surface"], theme["accent"], theme["mono_stack"], code),
448
+ )
449
+ return text
450
+
451
+
452
+ # ---------------------------------------------------------------- blocks
453
+
454
+ HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$")
455
+ FENCE_RE = re.compile(r"^```(\w*)\s*$")
456
+ HR_RE = re.compile(r"^(-{3,}|\*{3,}|_{3,})\s*$")
457
+ IMG_ONLY_RE = re.compile(r"^!\[([^\]]*)\]\(([^)\s]+)\)\s*$")
458
+ CAPTION_RE = re.compile(r"^\*(.+)\*\s*$")
459
+ TABLE_SEP_RE = re.compile(r"^\|?[\s:|-]+\|[\s:|-]*$")
460
+ CONTAINER_RE = re.compile(r"^:::+\s*(\w+)?\s*(.*)$")
461
+
462
+ CN_NUM = "一二三四五六七八九十"
463
+
464
+ # 标题里手写的中文/阿拉伯序号。序号应由排版层生成(可换样式、可加引用),
465
+ # 写进标题文本后就变成了不可编程的字符串。这里统一剥离后重新编号。
466
+ # 注意:\d{1,2}[、..](?!\d) 带负向断言,避免把「3.4× 成本」这类
467
+ # 以数字开头的标题误剥成「4× 成本」。
468
+ HAND_NUM_RE = re.compile(
469
+ r"^(?:第\s*[" + CN_NUM + r"]{1,3}\s*[、..]?\s*"
470
+ r"|[" + CN_NUM + r"]{1,3}\s*[、..]\s*"
471
+ r"|\d{1,2}\s*[、..](?!\d)\s*"
472
+ r"|0\d\s+)"
473
+ )
474
+
475
+ # 末章若是收束类(总结/结语/小结…),编号改用 ∞ 而不是顺延的数字——
476
+ # 数字编号暗示「还有下一节」,收束章用数字会和读者预期打架。
477
+ # 只在 h2_style 含 number 时生效(其它样式本来就不输出编号)。
478
+ SUMMARY_H2_RE = re.compile(
479
+ r"^(?:总结|结语|小结|尾声|后记|写在最后|写在后面|收尾|结尾|余论|结语与展望)"
480
+ )
481
+
482
+
483
+ def split_row(line):
484
+ return [c.strip() for c in line.strip().strip("|").split("|")]
485
+
486
+
487
+ def is_list_item(line):
488
+ return bool(re.match(r"^\s*([-*+]|\d+[.)])\s+", line))
489
+
490
+
491
+ def list_meta(line):
492
+ m = re.match(r"^(\s*)([-*+]|\d+[.)])\s+(.*)$", line)
493
+ indent = len(m.group(1).replace("\t", " ")) // 2
494
+ return indent, m.group(2), m.group(3)
495
+
496
+
497
+ def para_style(theme):
498
+ return ("margin:0 0 %s;line-height:%s;letter-spacing:0.5px;"
499
+ "text-align:%s;word-break:break-word;"
500
+ % (theme["para_gap"], theme["line_height"], theme["align"]))
501
+
502
+
503
+ def render_heading(level, text, theme, index=None):
504
+ accent = theme["accent"]
505
+ if level == 1:
506
+ # 正文大标题不用 <h1>:微信对 h1 有「标题」语义映射,粘贴后字号/样式易被
507
+ # 后台覆盖。改用 <p> + 内联样式,等价且稳(20px/700/居中)。
508
+ return ('<p style="font-size:20px;font-weight:700;color:%s;'
509
+ 'text-align:center;margin:0 0 18px;line-height:1.5;'
510
+ 'letter-spacing:0.5px;">%s</p>'
511
+ % (theme["text_strong"], render_inline(HAND_NUM_RE.sub("", text).strip() or text)))
512
+
513
+ if level == 2:
514
+ style = theme.get("h2_style", "number")
515
+ # 先剥离手写序号,再按主题样式决定是否由排版层补编号
516
+ plain = HAND_NUM_RE.sub("", text).strip() or text.strip()
517
+ t = render_inline(plain)
518
+ num = ""
519
+ if "number" in style and index:
520
+ # index 既可以是序号(int,渲染成 01/02),也可以是 ∞ 这类标记(str)
521
+ label = ("%02d" % index) if isinstance(index, int) else str(index)
522
+ num = ('<span style="color:%s;font-weight:700;margin-right:8px;'
523
+ 'font-family:%s;">%s</span>'
524
+ % (accent, theme["mono_stack"], label))
525
+
526
+ if style == "center":
527
+ return ('<h2 style="font-size:18px;font-weight:700;color:%s;'
528
+ 'text-align:center;margin:36px 0 18px;padding:12px 0;'
529
+ 'line-height:1.5;border-top:1px solid %s;'
530
+ 'border-bottom:1px solid %s;letter-spacing:1px;">%s%s</h2>'
531
+ % (theme["text_strong"], theme["border"], theme["border"],
532
+ num, t))
533
+ if style == "underline":
534
+ return ('<h2 style="font-size:18px;font-weight:700;color:%s;'
535
+ 'margin:34px 0 16px;line-height:1.5;display:inline-block;'
536
+ 'border-bottom:2px solid %s;padding-bottom:4px;">%s%s</h2>'
537
+ % (theme["text_strong"], accent, num, t))
538
+ if style == "plain":
539
+ return ('<h2 style="font-size:18px;font-weight:700;color:%s;'
540
+ 'margin:34px 0 16px;line-height:1.5;'
541
+ 'letter-spacing:0.5px;">%s%s</h2>'
542
+ % (theme["text_strong"], num, t))
543
+ # number / bar:都走左竖条;number 额外带 01/02 编号
544
+ return ('<h2 style="font-size:18px;font-weight:700;color:%s;'
545
+ 'margin:34px 0 16px;padding-left:11px;line-height:1.5;'
546
+ 'border-left:4px solid %s;">%s%s</h2>'
547
+ % (theme["text_strong"], accent, num, t))
548
+
549
+ if level == 3:
550
+ t = render_inline(HAND_NUM_RE.sub("", text).strip() or text)
551
+ return ('<h3 style="font-size:16px;font-weight:600;color:%s;'
552
+ 'margin:26px 0 12px;line-height:1.5;">%s</h3>'
553
+ % (theme["text_strong"], t))
554
+ t = render_inline(text)
555
+ return ('<h4 style="font-size:15px;font-weight:600;color:%s;'
556
+ 'margin:22px 0 10px;line-height:1.5;">%s</h4>' % (theme["text"], t))
557
+
558
+
559
+ def render_code(code, lang, theme):
560
+ body = highlight(code.rstrip("\n"), (lang or "").lower())
561
+ # 语言标签用 <section> 不用 <div>:公众号新版编辑器底层是 ProseMirror,
562
+ # 白名单里没有 div,div 包裹的块连同背景/内边距会被整段吞掉。
563
+ label = ('<section style="font-size:11px;color:%s;padding:0 0 8px;'
564
+ 'letter-spacing:1px;font-weight:600;font-family:%s;">%s</section>'
565
+ % (theme["accent"], theme["mono_stack"], esc(lang.upper()))
566
+ if lang else "")
567
+ # 不用 <pre>:公众号端 pre 不自动换行,窄屏会横向溢出。
568
+ # 改用 section + pre-wrap + break-all,长行自动折行;缩进与换行由代码自身
569
+ # 提供,HTML 源码里不在该 section 内插入任何格式化换行。
570
+ return ('<section style="margin:0 0 %s;background:%s;border-radius:%s;'
571
+ 'padding:16px;">%s<section style="margin:0;'
572
+ 'white-space:pre-wrap;word-break:break-all;color:%s;font-size:13.5px;'
573
+ 'line-height:1.65;font-family:%s;">%s</section></section>'
574
+ % (theme["para_gap"], theme["code_bg"], theme["radius"], label,
575
+ theme["code_text"], theme["mono_stack"], body))
576
+
577
+
578
+ def render_quote(lines, theme):
579
+ texts = []
580
+ buf = []
581
+ for line in lines:
582
+ if not line.strip():
583
+ if buf:
584
+ texts.append(render_inline(" ".join(buf)))
585
+ buf = []
586
+ continue
587
+ buf.append(line.strip())
588
+ if buf:
589
+ texts.append(render_inline(" ".join(buf)))
590
+ parts = []
591
+ for k, x in enumerate(texts):
592
+ gap = "0" if k == len(texts) - 1 else "10px"
593
+ parts.append('<p style="margin:0 0 %s;line-height:1.75;text-align:%s;'
594
+ 'word-break:break-word;">%s</p>'
595
+ % (gap, theme["align"] or "left", x))
596
+ # 用 <section> 而非 <blockquote>:公众号对 blockquote 有原生「引用」样式,
597
+ # 可能覆盖我们设置的颜色与边框。section + border-left 样式完全自控。
598
+ return ('<section style="margin:0 0 %s;padding:14px 16px;'
599
+ 'background:%s;border-left:3px solid %s;border-radius:%s;'
600
+ 'color:%s;font-size:15px;">%s</section>'
601
+ % (theme["para_gap"], theme["surface"], theme["accent"],
602
+ theme["radius"], theme["text"], "".join(parts)))
603
+
604
+
605
+ def render_paragraph(text, theme, tight=False, align=None):
606
+ style = para_style(theme)
607
+ if tight:
608
+ style = style.replace("margin:0 0 %s;" % theme["para_gap"], "margin:0 0 10px;")
609
+ if align:
610
+ style += "text-align:%s;" % align
611
+ return '<p style="%s">%s</p>' % (style, render_inline(text))
612
+
613
+
614
+ def render_list(items, theme, ordered, depth=0):
615
+ tag = "ol" if ordered else "ul"
616
+ style = ("margin:0 0 20px;padding-left:%dpx;line-height:%s;"
617
+ % (26 if depth == 0 else 22, theme["line_height"]))
618
+ if ordered:
619
+ style += "list-style-type:decimal;"
620
+ else:
621
+ style += "list-style-type:disc;color:%s;" % theme["accent"]
622
+ out = ["<%s style=\"%s\">" % (tag, style)]
623
+ for item in items:
624
+ line = '<li style="margin-bottom:9px;color:%s;">' % theme["text"]
625
+ line += '<span style="color:%s;">%s</span>' % (theme["text"], item["text"])
626
+ if item["children"]:
627
+ line += render_list(item["children"], theme,
628
+ bool(item["children_ordered"]), depth + 1)
629
+ line += "</li>"
630
+ out.append(line)
631
+ out.append("</%s>" % tag)
632
+ return "".join(out)
633
+
634
+
635
+ def build_list(lines, theme, start=0):
636
+ items = []
637
+ root = {"indent": -1, "children": items, "ordered": False, "children_ordered": None}
638
+ stack = [root]
639
+ i = start
640
+ while i < len(lines):
641
+ line = lines[i]
642
+ if not line.strip():
643
+ if i + 1 < len(lines) and is_list_item(lines[i + 1]):
644
+ i += 1
645
+ continue
646
+ break
647
+ if not is_list_item(line):
648
+ if line.startswith(" ") or line.startswith("\t"):
649
+ if items:
650
+ items[-1]["text"] += " " + line.strip()
651
+ i += 1
652
+ continue
653
+ break
654
+ indent, marker, content = list_meta(line)
655
+ node = {
656
+ "text": render_inline(content),
657
+ "children": [],
658
+ "children_ordered": None,
659
+ "indent": indent,
660
+ "ordered": not marker.startswith(("-", "*", "+")),
661
+ }
662
+ while stack and stack[-1]["indent"] >= indent:
663
+ stack.pop()
664
+ if not stack:
665
+ stack = [root]
666
+ parent = stack[-1]
667
+ if parent["children_ordered"] is None:
668
+ parent["children_ordered"] = node["ordered"]
669
+ parent["children"].append(node)
670
+ stack.append(node)
671
+ i += 1
672
+
673
+ ordered = bool(re.match(r"^\s*(\d+[.)])\s+", lines[start]))
674
+ return render_list(items, theme, ordered), i
675
+
676
+
677
+ def render_table(rows, theme):
678
+ """表格分流渲染。
679
+
680
+ 列数 <= table_max_cols:保留 <table>,但不加 table-layout:fixed,
681
+ 让列宽按内容自适应(fixed 会把各列均分,5 列在 677px 正文宽里每列仅 135px,
682
+ 中文被压成竖条)。
683
+ 列数更多:转卡片化,每行一张卡(首列作标题,其余「列名 + 值」成对),
684
+ 彻底摆脱横向溢出。公众号端列数 > 4 必然溢出,这是唯一稳的解法。
685
+
686
+ 另外:<thead>/<tbody>/<colgroup> 在公众号里不被支持,一律不输出。
687
+ """
688
+ cols = max(len(r) for r in rows)
689
+ if cols > int(theme.get("table_max_cols", 3)):
690
+ return render_table_cards(rows[0], rows[1:], theme)
691
+
692
+ head = rows[0]
693
+ hcells = "".join(
694
+ '<th style="background:%s;border:1px solid %s;padding:9px 10px;'
695
+ 'text-align:left;font-weight:600;color:%s;font-size:14px;'
696
+ 'line-height:1.55;word-break:break-word;">%s</th>'
697
+ % (theme["surface"], theme["border"], theme["text_strong"],
698
+ render_inline(c))
699
+ for c in head
700
+ )
701
+ body_rows = "".join(
702
+ "<tr>%s</tr>" % "".join(
703
+ '<td style="border:1px solid %s;padding:9px 10px;color:%s;'
704
+ 'font-size:14px;line-height:1.6;word-break:break-word;">%s</td>'
705
+ % (theme["border"], theme["text"], render_inline(c))
706
+ for c in row
707
+ )
708
+ for row in rows[1:]
709
+ )
710
+ return ('<section style="margin:0 0 %s;">'
711
+ '<table style="border-collapse:collapse;width:100%%;font-size:14px;">'
712
+ '<tr>%s</tr>%s</table></section>'
713
+ % (theme["para_gap"], hcells, body_rows))
714
+
715
+
716
+ def render_table_cards(head, body, theme):
717
+ cards = []
718
+ for row in body:
719
+ if not row:
720
+ continue
721
+ title = render_inline(row[0]) if row else ""
722
+ metas = []
723
+ for k in range(1, len(row)):
724
+ name = head[k] if k < len(head) else ""
725
+ metas.append(
726
+ '<p style="margin:0 0 6px;line-height:1.6;">'
727
+ '<span style="font-size:12.5px;color:%s;">%s</span>'
728
+ '<span style="font-size:14px;font-weight:600;color:%s;'
729
+ 'margin-left:8px;">%s</span></p>'
730
+ % (theme["text_light"], render_inline(name),
731
+ theme["text_strong"], render_inline(row[k])))
732
+ cards.append(
733
+ '<section style="margin:0 0 12px;padding:13px 15px;background:%s;'
734
+ 'border-left:3px solid %s;border-radius:%s;">'
735
+ '<p style="margin:0 0 9px;font-size:15px;font-weight:600;color:%s;'
736
+ 'line-height:1.5;word-break:break-word;">%s</p>%s</section>'
737
+ % (theme["surface"], theme["accent"], theme["radius"],
738
+ theme["text_strong"], title, "".join(metas)))
739
+ return ('<section style="margin:0 0 %s;">%s</section>'
740
+ % (theme["para_gap"], "".join(cards)))
741
+
742
+
743
+ def render_hr(theme):
744
+ # 宽度用百分比:公众号端 px 固定值在不同屏宽下表现不一致
745
+ return ('<p style="text-align:center;margin:32px 0;">'
746
+ '<span style="display:inline-block;width:12%%;height:2px;'
747
+ 'background:%s;"></span></p>' % theme["accent"])
748
+
749
+
750
+ def render_figure(src, caption, theme, alt=""):
751
+ cap = ""
752
+ if caption:
753
+ cap = ('<p style="font-size:13px;color:%s;text-align:center;'
754
+ 'margin:9px 0 %s;line-height:1.6;">%s</p>'
755
+ % (theme["text_light"], theme["para_gap"], render_inline(caption)))
756
+ border = ("border:1px solid %s;" % theme["img_border"]
757
+ if theme.get("img_border") else "")
758
+ altattr = ' alt="%s"' % esc(alt) if alt else ""
759
+ # 图片一律 max-width:100%,绝不用 width:100%:正文容器约 677px,
760
+ # width:100% 会把 400px 的小截图强行拉满变糊,竖图更糟。
761
+ # max-width 让大图缩到容器宽、小图保持原尺寸并居中。
762
+ # 不用 <figure>:微信粘贴时可能剥离包裹导致图注错位,改 section + p。
763
+ return ('<section style="margin:0 0 %s;">'
764
+ '<p style="margin:0;text-align:center;">'
765
+ '<img src="%s"%s style="max-width:100%%;height:auto;display:block;'
766
+ '%sborder-radius:%s;margin:0 auto;"></p>%s</section>'
767
+ % ("9px" if caption else theme["para_gap"], src, altattr, border,
768
+ theme["radius"], cap))
769
+
770
+
771
+ # ---------------------------------------------------------------- containers
772
+
773
+ CONTAINER_KINDS = {
774
+ "note": "note", "info": "note", "memo": "note",
775
+ "tip": "tip", "success": "tip", "ok": "tip",
776
+ "warn": "warn", "warning": "warn",
777
+ "danger": "danger", "error": "danger",
778
+ "pull": "pull", "quote": "pull", "golden": "pull",
779
+ "dialogue": "dialogue", "chat": "dialogue", "talk": "dialogue",
780
+ "stat": "stat", "data": "stat", "metric": "stat",
781
+ }
782
+
783
+ DEFAULT_LABELS = {
784
+ "note": "说明", "tip": "提示", "warn": "注意", "danger": "警告",
785
+ }
786
+
787
+
788
+ def render_container(kind, label, inner_lines, theme):
789
+ kind = CONTAINER_KINDS.get(kind, "note")
790
+ text = "\n".join(inner_lines)
791
+
792
+ if kind == "pull":
793
+ # 金句块用 <section> 不用 <div>(div 会被公众号整段吞掉)
794
+ return ('<section style="margin:28px 0;padding:18px 10px;'
795
+ 'border-top:1px solid %s;border-bottom:1px solid %s;">'
796
+ '<p style="margin:0;font-size:17px;line-height:1.85;color:%s;'
797
+ 'text-align:center;font-weight:600;letter-spacing:0.5px;'
798
+ 'word-break:break-word;">%s</p></section>'
799
+ % (theme["border"], theme["border"], theme["text_strong"],
800
+ render_inline(text.strip())))
801
+
802
+ if kind == "dialogue":
803
+ rows = []
804
+ for line in inner_lines:
805
+ if not line.strip():
806
+ continue
807
+ m = re.match(r"^\s*(.{1,12}?)\s*[::]\s*(.+)$", line)
808
+ if m:
809
+ name, say = m.group(1), m.group(2)
810
+ rows.append(
811
+ '<p style="margin:0 0 6px;font-size:13px;color:%s;">%s</p>'
812
+ '<p style="margin:0 0 14px;padding:11px 14px;background:%s;'
813
+ 'border-radius:%s;font-size:15px;line-height:1.72;'
814
+ 'color:%s;">%s</p>'
815
+ % (theme["text_light"], render_inline(name),
816
+ theme["surface"], theme["radius"], theme["text"],
817
+ render_inline(say)))
818
+ else:
819
+ rows.append('<p style="margin:0 0 14px;padding:11px 14px;'
820
+ 'background:%s;border-radius:%s;font-size:15px;'
821
+ 'line-height:1.72;color:%s;">%s</p>'
822
+ % (theme["surface"], theme["radius"], theme["text"],
823
+ render_inline(line.strip())))
824
+ return '<section style="margin:0 0 %s;">%s</section>' % (
825
+ theme["para_gap"], "".join(rows))
826
+
827
+ if kind == "stat":
828
+ rows = []
829
+ for line in inner_lines:
830
+ if not line.strip() or "|" not in line:
831
+ continue
832
+ value, desc = line.split("|", 1)
833
+ rows.append(
834
+ '<p style="margin:0 0 14px;line-height:1.3;">'
835
+ '<span style="font-size:26px;font-weight:700;color:%s;'
836
+ 'margin-right:10px;">%s</span>'
837
+ '<span style="font-size:13px;color:%s;">%s</span></p>'
838
+ % (theme["accent"], esc(value.strip()),
839
+ theme["text_light"], esc(desc.strip())))
840
+ return '<section style="margin:0 0 %s;padding:16px 18px;' \
841
+ 'background:%s;border-radius:%s;">%s</section>' % (
842
+ theme["para_gap"], theme["surface"], theme["radius"],
843
+ "".join(rows))
844
+
845
+ color = {"note": theme["c_note"], "tip": theme["c_tip"],
846
+ "warn": theme["c_warn"], "danger": theme["c_danger"]}[kind]
847
+ label_html = ""
848
+ if label:
849
+ # 标签用 <section> 不用 <div>
850
+ label_html = ('<section style="font-size:12px;font-weight:600;color:%s;'
851
+ 'letter-spacing:1px;margin-bottom:6px;">%s</section>'
852
+ % (color, esc(label)))
853
+ body = []
854
+ buf = []
855
+ for line in inner_lines:
856
+ if not line.strip():
857
+ if buf:
858
+ body.append(render_paragraph(" ".join(buf), theme, tight=True))
859
+ buf = []
860
+ continue
861
+ buf.append(line.strip())
862
+ if buf:
863
+ body.append(render_paragraph(" ".join(buf), theme, tight=True))
864
+ return ('<section style="margin:0 0 %s;padding:14px 16px;background:%s;'
865
+ 'border-left:3px solid %s;border-radius:%s;">%s%s</section>'
866
+ % (theme["para_gap"], theme["surface"], color, theme["radius"],
867
+ label_html, "".join(body)))
868
+
869
+
870
+ # ---------------------------------------------------------------- images
871
+
872
+ def resolve_image(src, md_path, out_dir, assets, asset_root):
873
+ if re.match(r"^https?://", src):
874
+ assets.append({"src": src, "local": None, "remote": True})
875
+ return src
876
+ abs_path = os.path.normpath(os.path.join(os.path.dirname(md_path), src))
877
+ if not os.path.exists(abs_path) and asset_root:
878
+ rel = re.sub(r"^(\.\./)+", "", src)
879
+ candidate = os.path.normpath(os.path.join(asset_root, rel))
880
+ if os.path.exists(candidate):
881
+ abs_path = candidate
882
+ if not os.path.exists(abs_path):
883
+ return src
884
+ img_dir = os.path.join(out_dir, "images")
885
+ os.makedirs(img_dir, exist_ok=True)
886
+ base = os.path.basename(abs_path)
887
+ target = os.path.join(img_dir, base)
888
+ n = 1
889
+ while os.path.exists(target) and not os.path.samefile(abs_path, target):
890
+ stem, ext = os.path.splitext(base)
891
+ target = os.path.join(img_dir, "%s-%d%s" % (stem, n, ext))
892
+ n += 1
893
+ if not os.path.exists(target):
894
+ shutil.copy2(abs_path, target)
895
+ rel = os.path.relpath(target, out_dir)
896
+ assets.append({"src": src, "local": rel, "remote": False,
897
+ "basename": os.path.basename(target),
898
+ "size": os.path.getsize(abs_path)})
899
+ return rel
900
+
901
+
902
+ def handle_mermaid(code, out_dir, diagrams, theme, render):
903
+ os.makedirs(os.path.join(out_dir, "diagrams"), exist_ok=True)
904
+ idx = len(diagrams) + 1
905
+ mmd = os.path.join(out_dir, "diagrams", "diagram-%02d.mmd" % idx)
906
+ with open(mmd, "w", encoding="utf-8") as fh:
907
+ fh.write(code)
908
+ png = os.path.join(out_dir, "diagrams", "diagram-%02d.png" % idx)
909
+ rel_png = os.path.relpath(png, out_dir)
910
+ ok = False
911
+ if render and shutil.which("mmdc"):
912
+ try:
913
+ subprocess.run(
914
+ ["mmdc", "-i", mmd, "-o", png, "-b", "white", "-w", "1080"],
915
+ check=True, capture_output=True, timeout=180,
916
+ )
917
+ ok = os.path.exists(png)
918
+ except Exception:
919
+ ok = False
920
+ diagrams.append({"index": idx, "mmd": os.path.relpath(mmd, out_dir),
921
+ "png": rel_png if ok else None})
922
+ if ok:
923
+ return render_figure(rel_png, None, theme)
924
+ note = ("[此处为流程图 %02d,公众号不支持 mermaid。请渲染 diagrams/%s 为图片后手动插入]"
925
+ % (idx, os.path.basename(mmd)))
926
+ return ('<section style="margin:0 0 %s;padding:14px 16px;'
927
+ 'background:%s;border-left:3px solid %s;color:%s;'
928
+ 'font-size:14px;line-height:1.7;border-radius:%s;">%s</section>'
929
+ % (theme["para_gap"], theme["surface"], theme["accent"],
930
+ theme["text_muted"], theme["radius"], esc(note)))
931
+
932
+
933
+ # ---------------------------------------------------------------- parser
934
+
935
+ def strip_frontmatter(lines):
936
+ if lines and lines[0].strip() == "---":
937
+ for idx in range(1, len(lines)):
938
+ if lines[idx].strip() == "---":
939
+ return lines[idx + 1:]
940
+ return lines
941
+
942
+
943
+ def convert_blocks(lines, md_path, out_dir, theme, ctx):
944
+ out = []
945
+ i = 0
946
+ n = len(lines)
947
+ h2_index = 0
948
+ h2_titles = []
949
+ toc_at = None # 导读落点:首个正文段落之后
950
+
951
+ # 预扫描:末章是否为收束类,决定它的编号用数字还是 ∞
952
+ h2_texts = [HAND_NUM_RE.sub("", l.strip()[3:]).strip() or l.strip()[3:].strip()
953
+ for l in lines if l.strip().startswith("## ")]
954
+ h2_total = len(h2_texts)
955
+ last_is_summary = bool(h2_total) and bool(SUMMARY_H2_RE.match(h2_texts[-1]))
956
+
957
+ while i < n:
958
+ line = lines[i]
959
+ stripped = line.strip()
960
+
961
+ if not stripped:
962
+ i += 1
963
+ continue
964
+
965
+ m = FENCE_RE.match(stripped)
966
+ if m:
967
+ lang = m.group(1)
968
+ i += 1
969
+ buf = []
970
+ while i < n and not lines[i].strip().startswith("```"):
971
+ buf.append(lines[i])
972
+ i += 1
973
+ i += 1
974
+ code = "\n".join(buf)
975
+ if lang.lower() == "mermaid":
976
+ out.append(handle_mermaid(code, out_dir, ctx["diagrams"], theme,
977
+ ctx["render_mermaid"]))
978
+ else:
979
+ out.append(render_code(code, lang, theme))
980
+ continue
981
+
982
+ if HR_RE.match(stripped) and not is_list_item(line):
983
+ out.append(render_hr(theme))
984
+ i += 1
985
+ continue
986
+
987
+ m = HEADING_RE.match(stripped)
988
+ if m:
989
+ level = len(m.group(1))
990
+ marker = h2_index
991
+ if level == 2:
992
+ h2_index += 1
993
+ h2_titles.append(HAND_NUM_RE.sub("", m.group(2)).strip()
994
+ or m.group(2).strip())
995
+ marker = ("∞" if (last_is_summary and h2_index == h2_total)
996
+ else h2_index)
997
+ out.append(render_heading(level, m.group(2).strip(), theme, marker))
998
+ i += 1
999
+ continue
1000
+
1001
+ m = CONTAINER_RE.match(stripped)
1002
+ if m:
1003
+ kind = (m.group(1) or "note").lower()
1004
+ label = m.group(2).strip()
1005
+ i += 1
1006
+ buf = []
1007
+ while i < n and not lines[i].strip().startswith(":::"):
1008
+ buf.append(lines[i])
1009
+ i += 1
1010
+ if kind in ("toc", "目录", "导读"):
1011
+ out.append(TOC_TOKEN)
1012
+ i += 1
1013
+ continue
1014
+ if kind in ("sign", "signature", "签名"):
1015
+ out.append(SIGN_TOKEN)
1016
+ i += 1
1017
+ continue
1018
+ i += 1
1019
+ out.append(render_container(kind, label or DEFAULT_LABELS.get(
1020
+ CONTAINER_KINDS.get(kind, "note"), ""), buf, theme))
1021
+ continue
1022
+
1023
+ if stripped.startswith("|") and i + 1 < n and TABLE_SEP_RE.match(lines[i + 1].strip()):
1024
+ rows = [split_row(stripped)]
1025
+ i += 2
1026
+ while i < n and lines[i].strip().startswith("|"):
1027
+ rows.append(split_row(lines[i].strip()))
1028
+ i += 1
1029
+ out.append(render_table(rows, theme))
1030
+ continue
1031
+
1032
+ if stripped.startswith(">"):
1033
+ buf = []
1034
+ while i < n and lines[i].strip().startswith(">"):
1035
+ buf.append(lines[i].strip().lstrip(">").strip())
1036
+ i += 1
1037
+ out.append(render_quote(buf, theme))
1038
+ continue
1039
+
1040
+ if is_list_item(line):
1041
+ block_html, i = build_list(lines, theme, i)
1042
+ out.append(block_html)
1043
+ continue
1044
+
1045
+ m = IMG_ONLY_RE.match(stripped)
1046
+ if m:
1047
+ caption = None
1048
+ if i + 1 < n:
1049
+ cm = CAPTION_RE.match(lines[i + 1].strip())
1050
+ if cm:
1051
+ caption = cm.group(1)
1052
+ i += 1
1053
+ src = resolve_image(m.group(2), md_path, out_dir,
1054
+ ctx["assets"], ctx["asset_root"])
1055
+ out.append(render_figure(src, caption, theme, alt=m.group(1)))
1056
+ i += 1
1057
+ continue
1058
+
1059
+ buf = [stripped]
1060
+ i += 1
1061
+ while i < n:
1062
+ cur = lines[i].strip()
1063
+ if (not cur or HEADING_RE.match(cur) or HR_RE.match(cur)
1064
+ or CONTAINER_RE.match(cur) or cur.startswith(">")
1065
+ or cur.startswith("|") or is_list_item(lines[i])
1066
+ or IMG_ONLY_RE.match(cur) or FENCE_RE.match(cur)):
1067
+ break
1068
+ buf.append(cur)
1069
+ i += 1
1070
+ out.append(render_paragraph(" ".join(buf), theme))
1071
+ if toc_at is None:
1072
+ toc_at = len(out)
1073
+
1074
+ # 自动导读:显式 ::: toc 优先。触发条件是「章节够多」或「正文够长」任一成立——
1075
+ # 只按字数会漏掉「1152 字 / 6 节」这类章节密集的短稿,只按章节数会漏掉
1076
+ # 「3 节 / 3000 字」这类长段落稿。
1077
+ if not any(x == TOC_TOKEN for x in out) and ctx.get("toc"):
1078
+ n_h2 = len(h2_titles)
1079
+ n_han = ctx.get("han_chars", 0)
1080
+ by_h2 = n_h2 >= int(theme.get("toc_min_h2", 4))
1081
+ by_len = n_h2 >= 3 and n_han >= int(theme.get("toc_min_chars", 2000))
1082
+ if by_h2 or by_len:
1083
+ out.insert(toc_at if toc_at is not None else 0, TOC_TOKEN)
1084
+
1085
+ html = "".join(out)
1086
+ html = html.replace(TOC_TOKEN, render_toc(h2_titles, theme))
1087
+ return html
1088
+
1089
+
1090
+ def convert(md_text, md_path, out_dir, theme, link_mode="footnote",
1091
+ render_mermaid=False, asset_root=None, use_pangu=True,
1092
+ toc=True, sign_mode="auto", author="", author_bio=""):
1093
+ lines = strip_frontmatter(md_text.split("\n"))
1094
+ han_chars = sum(len(HAN_RE.findall(l)) for l in lines)
1095
+ ctx = {"assets": [], "diagrams": [], "footnotes": [],
1096
+ "render_mermaid": render_mermaid, "asset_root": asset_root,
1097
+ "toc": bool(toc), "han_chars": han_chars,
1098
+ "sign_mode": sign_mode, "author": author, "author_bio": author_bio}
1099
+ CTX["theme"] = theme
1100
+ CTX["link_mode"] = link_mode
1101
+ CTX["footnotes"] = ctx["footnotes"]
1102
+ CTX["use_pangu"] = use_pangu
1103
+ CTX["hl_scheme"] = build_code_scheme(theme)
1104
+
1105
+ body = convert_blocks(lines, md_path, out_dir, theme, ctx)
1106
+
1107
+ # 尾部签名区。auto:稿件末尾已自带签名/CTA 就不重复生成(避免出现两处签名)。
1108
+ sign_html = render_signature(theme, author, author_bio)
1109
+ if SIGN_TOKEN in body:
1110
+ body = body.replace(SIGN_TOKEN, sign_html)
1111
+ elif sign_mode == "on":
1112
+ body += sign_html
1113
+ elif sign_mode == "auto" and not md_tail_has_signature(lines):
1114
+ body += sign_html
1115
+
1116
+ if link_mode == "footnote" and ctx["footnotes"]:
1117
+ body += render_footnotes(ctx["footnotes"], theme)
1118
+ return body, ctx["assets"], ctx["diagrams"], ctx["footnotes"]
1119
+
1120
+
1121
+ def render_footnotes(footnotes, theme):
1122
+ items = "".join(
1123
+ '<p style="margin:0 0 8px;font-size:13px;line-height:1.7;color:%s;'
1124
+ 'word-break:break-all;">[%d] %s — %s</p>'
1125
+ % (theme["text_muted"], idx, esc(f["label"]), esc(f["url"]))
1126
+ for idx, f in enumerate(footnotes, 1)
1127
+ )
1128
+ # 标题用 <section> 不用 <div>
1129
+ return ('<section style="margin:34px 0 %s;padding:16px 18px;'
1130
+ 'background:%s;border-radius:%s;">'
1131
+ '<section style="font-size:13px;font-weight:600;color:%s;'
1132
+ 'letter-spacing:1px;margin-bottom:10px;">参考链接</section>%s</section>'
1133
+ % (theme["para_gap"], theme["surface"], theme["radius"],
1134
+ theme["text_strong"], items))
1135
+
1136
+
1137
+ # ---------------------------------------------------------------- toc / signature
1138
+
1139
+ SIGN_RE = re.compile(
1140
+ r"点赞|在看|三连|点个关注|欢迎关注|关注「|扫码|分享给|我是[^。,,]{0,20}[,,]")
1141
+
1142
+ # 流式解析时还不知道全文有哪些 H2,先用占位符标记落点,收尾时统一替换
1143
+ TOC_TOKEN = "\x01TOC\x01"
1144
+ SIGN_TOKEN = "\x01SIGN\x01"
1145
+
1146
+
1147
+ def md_tail_has_signature(lines, window=8):
1148
+ """稿件末尾是否已自带签名/CTA。自带则不再追加,避免出现两处签名区。"""
1149
+ tail = [l.strip() for l in lines if l.strip()][-window:]
1150
+ return any(SIGN_RE.search(l) for l in tail)
1151
+
1152
+
1153
+ def render_toc(titles, theme):
1154
+ """前言导读。展示精选看点而非全量目录:超出 toc_max 时截断并注明总节数,
1155
+ 不假装全文只有这几节。"""
1156
+ if not titles:
1157
+ return ""
1158
+ cap = int(theme.get("toc_max", 5))
1159
+ shown = titles[:cap]
1160
+ # 导读的标记必须跟正文标题一致:
1161
+ # - 标题带编号(h2_style 含 number)→ 导读也用编号;末章收束类用 ∞,避免「导读 04 / 正文 ∞」
1162
+ # - 标题不带编号(center/underline/plain)→ 导读只用中性圆点,不要凭空造出 01/02
1163
+ numbered = "number" in theme.get("h2_style", "number")
1164
+ last_summary = bool(titles) and bool(SUMMARY_H2_RE.match(titles[-1]))
1165
+
1166
+ def label(k):
1167
+ if not numbered:
1168
+ return "·"
1169
+ return "∞" if (last_summary and k == len(titles)) else "%02d" % k
1170
+
1171
+ rows = "".join(
1172
+ '<p style="margin:0 0 7px;font-size:14px;line-height:1.6;color:%s;'
1173
+ 'word-break:break-word;">'
1174
+ '<span style="color:%s;font-weight:700;margin-right:8px;'
1175
+ 'font-family:%s;">%s</span>%s</p>'
1176
+ % (theme["text"], theme["accent"], theme["mono_stack"], label(k),
1177
+ render_inline(t))
1178
+ for k, t in enumerate(shown, 1))
1179
+ more = ""
1180
+ if len(titles) > len(shown):
1181
+ more = ('<p style="margin:9px 0 0;font-size:12.5px;color:%s;">'
1182
+ '……共 %d 节</p>' % (theme["text_light"], len(titles)))
1183
+ return ('<section style="margin:0 0 26px;padding:15px 17px;background:%s;'
1184
+ 'border-left:3px solid %s;border-radius:%s;">'
1185
+ '<p style="margin:0 0 10px;font-size:12.5px;font-weight:600;'
1186
+ 'color:%s;letter-spacing:1px;">本文看点</p>%s%s</section>'
1187
+ % (theme["surface"], theme["accent"], theme["radius"],
1188
+ theme["accent"], rows, more))
1189
+
1190
+
1191
+ def render_signature(theme, author="", bio=""):
1192
+ """尾部签名区。默认用占位署名,交付时提示用户替换,不替用户编造人名。"""
1193
+ name = esc(author.strip() or "{{作者名}}")
1194
+ blurb = esc(bio.strip() or "{{一句话简介}}")
1195
+ return ('<section style="margin:34px 0 0;padding:16px 18px;background:%s;'
1196
+ 'border-top:2px solid %s;border-radius:%s;">'
1197
+ '<p style="margin:0 0 8px;font-size:14px;line-height:1.75;'
1198
+ 'color:%s;word-break:break-word;">我是 <span style="font-weight:600;'
1199
+ 'color:%s;">%s</span>,%s</p>'
1200
+ '<p style="margin:0;font-size:14px;line-height:1.75;color:%s;'
1201
+ 'word-break:break-word;">如果你觉得今天这篇有收获,欢迎'
1202
+ '<span style="font-weight:600;color:%s;">点赞、在看、转发</span>'
1203
+ '三连,我们下篇见。</p></section>'
1204
+ % (theme["surface"], theme["accent"], theme["radius"],
1205
+ theme["text"], theme["text_strong"], name, blurb,
1206
+ theme["text"], theme["accent"]))
1207
+
1208
+
1209
+ # ---------------------------------------------------------------- embed
1210
+
1211
+ MIME_BY_EXT = {
1212
+ ".png": "image/png",
1213
+ ".jpg": "image/jpeg",
1214
+ ".jpeg": "image/jpeg",
1215
+ ".gif": "image/gif",
1216
+ ".webp": "image/webp",
1217
+ ".svg": "image/svg+xml",
1218
+ }
1219
+
1220
+
1221
+ def embed_images_inline(body, out_dir):
1222
+ """把正文里指向本地文件的 <img src> 换成 base64 data URI。
1223
+
1224
+ 公众号后台会丢相对路径与本地地址:粘贴过去的 <img> 没有可抓取的 URL,
1225
+ 整张图直接消失(连占位符都没有)。内嵌成 data URI 后,图片字节跟着
1226
+ 剪贴板一起过去。远程 http(s) 图与已经是 data: 的图不动。
1227
+ 返回 (新正文, 统计)。
1228
+ """
1229
+ stats = {"count": 0, "bytes": 0, "skipped": []}
1230
+
1231
+ def repl(m):
1232
+ src = m.group(1)
1233
+ if re.match(r"^(https?:|data:|//)", src):
1234
+ return m.group(0)
1235
+ path = os.path.normpath(os.path.join(out_dir, src))
1236
+ mime = MIME_BY_EXT.get(os.path.splitext(path)[1].lower())
1237
+ if not mime or not os.path.exists(path):
1238
+ stats["skipped"].append(src)
1239
+ return m.group(0)
1240
+ with open(path, "rb") as fh:
1241
+ raw = fh.read()
1242
+ stats["count"] += 1
1243
+ stats["bytes"] += len(raw)
1244
+ return 'src="data:%s;base64,%s"' % (
1245
+ mime, base64.b64encode(raw).decode("ascii"))
1246
+
1247
+ return re.sub(r'src="([^"]+)"', repl, body), stats
1248
+
1249
+
1250
+ # ---------------------------------------------------------------- page
1251
+
1252
+ PAGE_TPL = """<!DOCTYPE html>
1253
+ <html lang="zh-CN">
1254
+ <head>
1255
+ <meta charset="utf-8">
1256
+ <meta name="viewport" content="width=device-width,initial-scale=1">
1257
+ <title>{title}</title>
1258
+ <style>
1259
+ /* 仅本地预览用:公众号会剥离 style 标签,正文内联样式保持百分比宽度 */
1260
+ body>div,body>article{{max-width:677px!important;margin-left:auto!important;
1261
+ margin-right:auto!important;box-sizing:border-box;}}
1262
+ </style>
1263
+ </head>
1264
+ <body style="margin:0;padding:24px 12px 64px;background:#EDEDED;font-family:{font_stack};">
1265
+
1266
+ <div style="max-width:100%;margin:0 auto 18px;padding:13px 18px;background:#FFF8E6;
1267
+ border:1px solid #F0D9A8;border-radius:8px;font-size:13px;line-height:1.7;color:#7A5C1E;">
1268
+ {notice}
1269
+ </div>
1270
+
1271
+ <div style="max-width:100%;margin:0 auto 14px;display:flex;align-items:center;gap:10px;
1272
+ flex-wrap:wrap;">
1273
+ <button onclick="copyBody()" style="padding:9px 20px;font-size:14px;color:#fff;
1274
+ background:{accent};border:none;border-radius:6px;cursor:pointer;">复制正文</button>
1275
+ <span id="wx-tip" style="font-size:13px;color:#4A7A3A;"></span>
1276
+ <span style="margin-left:auto;font-size:12px;color:#888;">{title}</span>
1277
+ </div>
1278
+
1279
+ <div style="max-width:100%;margin:0 auto;padding:0 0 8px;font-size:12px;
1280
+ color:#999;text-align:center;">↓ 正文开始 ↓</div>
1281
+
1282
+ <article id="wx-body" style="max-width:100%;margin:0 auto;padding:26px 20px 32px;
1283
+ background:#FFFFFF;border:1px dashed #C9C9C9;border-radius:8px;
1284
+ font-size:{font_size};color:{text};line-height:{line_height};
1285
+ font-family:{font_stack};">
1286
+ {body}
1287
+ </article>
1288
+
1289
+ <script>
1290
+ function copyBody() {{
1291
+ var node = document.getElementById('wx-body');
1292
+ var sel = window.getSelection();
1293
+ var range = document.createRange();
1294
+ range.selectNodeContents(node);
1295
+ sel.removeAllRanges();
1296
+ sel.addRange(range);
1297
+ try {{
1298
+ document.execCommand('copy');
1299
+ document.getElementById('wx-tip').textContent = '已复制,去公众号后台粘贴';
1300
+ }} catch (e) {{
1301
+ document.getElementById('wx-tip').textContent = '复制失败,请手动全选';
1302
+ }}
1303
+ sel.removeAllRanges();
1304
+ }}
1305
+ </script>
1306
+ </body>
1307
+ </html>
1308
+ """
1309
+
1310
+
1311
+ def build_notice(embedded, n_images):
1312
+ """页顶「发布前须知」文案。内嵌模式与手动上传模式的说明不同。"""
1313
+ code = ('<code style="background:#F5F5F5;padding:1px 4px;border-radius:3px;">%s</code>')
1314
+ head = ("<strong>发布前须知</strong>:上方为操作提示,<strong>不要一起复制</strong>。"
1315
+ "请点「复制正文」或全选下方虚线框内容,粘贴到公众号后台编辑器。")
1316
+ if embedded:
1317
+ tail = ("<br>本页 %d 张图已 base64 内嵌,粘贴时会跟着一起过去,正常情况下"
1318
+ "无需再手动插图。若后台某张图显示为占位符,按 %s 的顺序,"
1319
+ "从 %s 手动补传这一张。"
1320
+ % (n_images, code % "图片上传清单.md", code % "images/"))
1321
+ else:
1322
+ tail = ("<br>图片需手动上传:本页图片仅供本地预览 —— 按 %s 的顺序"
1323
+ "逐张插入素材库。" % (code % "图片上传清单.md"))
1324
+ return head + tail
1325
+
1326
+
1327
+ def audit_inline(body):
1328
+ """正文的平台合规自检,返回 [(级别, 说明)],空列表即通过。
1329
+
1330
+ 级别含义:
1331
+ ERROR —— 公众号会静默丢弃该样式或整段吞掉该标签,产物必然与预览不一致
1332
+ WARN —— 转换后仍可读,但存在降级风险或移动端体验问题
1333
+
1334
+ 红线依据见 references/wechat-constraints.md,本函数是该文档的可执行版本。
1335
+ """
1336
+ issues = []
1337
+
1338
+ def add(level, msg):
1339
+ issues.append((level, msg))
1340
+
1341
+ # ---- 标签级:会被剥离或不被支持
1342
+ TAGS = [
1343
+ ("<div", "ERROR", "<div> 标签(新版编辑器白名单无 div,包裹的块连同背景/内边距会被整段吞掉,是「粘贴后样式全丢」的头号元凶。改 <section>)"),
1344
+ ("<figure", "ERROR", "<figure> 标签(粘贴时可能剥离包裹导致图注错位。用 <section>+<p>)"),
1345
+ ("<figcaption", "ERROR", "<figcaption> 标签(同上,图注改用独立 <p>)"),
1346
+ ("<pre", "ERROR", "<pre> 标签(公众号端不自动换行,窄屏横向溢出。用 <section>+pre-wrap)"),
1347
+ ("<thead", "ERROR", "<thead> 不被支持(去掉分组标签,表头行直接作为首个 <tr>)"),
1348
+ ("<tbody", "ERROR", "<tbody> 不被支持(同上)"),
1349
+ ("<colgroup", "ERROR", "<colgroup> 不被支持"),
1350
+ ("<h1", "ERROR", "<h1> 标签(微信按标题语义覆盖字号,正文大标题用 <p> + 内联样式)"),
1351
+ ("<iframe", "ERROR", "<iframe> 会被剥离(视频只能用公众号自带组件)"),
1352
+ ("<video", "ERROR", "<video> 会被剥离"),
1353
+ ("<script", "ERROR", "<script> 会被剥离"),
1354
+ ("<style", "ERROR", "<style> 会被剥离,样式必须内联"),
1355
+ ]
1356
+ for tag, lv, msg in TAGS:
1357
+ if tag in body:
1358
+ add(lv, msg)
1359
+
1360
+ # ---- 属性/值级
1361
+ for m in re.finditer(r'style="([^"]*)"', body):
1362
+ decls = [d.strip() for d in m.group(1).split(";") if d.strip()]
1363
+ for decl in decls:
1364
+ prop, _, val = decl.partition(":")
1365
+ prop, val = prop.strip().lower(), val.strip()
1366
+ low = val.lower()
1367
+ if prop == "text-align" and val not in ("left", "center", "right"):
1368
+ add("ERROR", "text-align:%s 非标准值(只允许 left/center/right)" % val)
1369
+ elif prop in ("width", "max-width", "min-width") and "px" in low:
1370
+ add("ERROR", "%s:%s 固定像素宽度(不同屏宽下表现不一致,改百分比)" % (prop, val))
1371
+ elif prop == "white-space" and low in ("pre", "nowrap"):
1372
+ add("ERROR", "white-space:%s 不换行(窄屏溢出;折行用 pre-wrap)" % val)
1373
+ elif prop in ("position", "float", "transform", "opacity"):
1374
+ add("ERROR", "%s:%s 定位/透明属性(公众号会吞掉,元素错位)" % (prop, val))
1375
+ elif prop == "display" and low in ("flex", "grid"):
1376
+ add("ERROR", "display:%s(布局属性被吞,内容堆叠。用 <section>+padding 模拟)" % val)
1377
+ elif prop.startswith("-"):
1378
+ add("ERROR", "%s 私有属性(公众号不支持)" % prop)
1379
+ elif prop == "table-layout" and low == "fixed":
1380
+ add("WARN", "table-layout:fixed(各列宽度均分,小屏下中文被压成竖条;除非逐列显式设宽,否则去掉)")
1381
+ elif prop == "display" and low == "inline-block":
1382
+ add("WARN", "display:inline-block(非常规 display 值,公众号支持不稳定)")
1383
+ elif prop == "border-radius":
1384
+ add("WARN", "border-radius(圆角可能被丢弃,降级为直角;属可接受的优雅降级,勿依赖圆角做视觉区分)")
1385
+
1386
+ for pat, lv, msg in [
1387
+ (r"linear-gradient", "ERROR", "linear-gradient(渐变被静默丢弃,退化成无背景。改纯色)"),
1388
+ (r"box-shadow", "ERROR", "box-shadow(阴影被丢弃)"),
1389
+ (r"rgba\(|hsla\(", "ERROR", "rgba()/hsla() 颜色(透明度不被支持,改纯色十六进制)"),
1390
+ (r"var\(--", "ERROR", "CSS 变量(不支持,值必须写死)"),
1391
+ (r"@media|@keyframes", "ERROR", "@media/@keyframes(媒体查询与动画不被支持)"),
1392
+ (r"\sclass=", "ERROR", "class 属性(公众号会剥离,样式必须内联)"),
1393
+ (r"\sid=", "WARN", "id 属性(正文里无意义,可能被剥离)"),
1394
+ (r"<img[^>]*width=\"", "WARN", "<img> 上的 width 属性(公众号不保证保留,宽度用内联 style)"),
1395
+ ]:
1396
+ if re.search(pat, body, re.I):
1397
+ add(lv, msg)
1398
+
1399
+ # ---- 图片宽度:小图被拉伸是高频问题
1400
+ for m in re.finditer(r'<img[^>]*style="([^"]*)"', body):
1401
+ st = m.group(1)
1402
+ if re.search(r"(?<!max-)(?<!min-)\bwidth:\s*100%", st) and "max-width" not in st:
1403
+ add("WARN", "图片用 width:100%(小图会被强行拉满变糊,改 max-width:100%;height:auto)")
1404
+ break
1405
+
1406
+ # 去重(保留首次出现的级别)
1407
+ seen, out = {}, []
1408
+ for lv, msg in issues:
1409
+ if msg not in seen:
1410
+ seen[msg] = lv
1411
+ out.append((lv, msg))
1412
+ # ERROR 在前
1413
+ out.sort(key=lambda x: 0 if x[0] == "ERROR" else 1)
1414
+ return out
1415
+
1416
+
1417
+ def write_manifest(out_dir, slug, title, assets, diagrams, theme, footnotes,
1418
+ embedded=False):
1419
+ lines = ["# 图片上传清单 · %s" % title, ""]
1420
+ if embedded:
1421
+ lines.append("> 本页图片已 base64 内嵌,正常情况下粘贴到后台即带图。"
1422
+ "若有图显示为占位符,再按此顺序逐张上传到素材库替换。")
1423
+ else:
1424
+ lines.append("> 公众号不会抓取本地图片,请在后台编辑器按此顺序逐张上传到素材库并插入正文。")
1425
+ lines.append("")
1426
+ if assets:
1427
+ lines.append("## 正文图片(按出现顺序)")
1428
+ lines.append("")
1429
+ lines.append("| 序号 | 文件名 | 体积 | 原 Markdown 引用 |")
1430
+ lines.append("|---|---|---|---|")
1431
+ for idx, a in enumerate(assets, 1):
1432
+ size = ""
1433
+ if a.get("size"):
1434
+ kb = a["size"] / 1024
1435
+ size = "%.0f KB" % kb if kb < 1024 else "%.1f MB" % (kb / 1024)
1436
+ if a.get("remote"):
1437
+ lines.append("| %d | %s(远程) | %s | `%s` |" % (idx, a["src"], size, a["src"]))
1438
+ else:
1439
+ lines.append("| %d | `%s` | %s | `%s` |"
1440
+ % (idx, a.get("basename", ""), size, a["src"]))
1441
+ lines.append("")
1442
+ lines.append("图片目录:`images/`(公众号单图建议 < 2 MB,宽度 1080 px 左右)")
1443
+ lines.append("")
1444
+ else:
1445
+ lines.append("## 正文图片")
1446
+ lines.append("")
1447
+ lines.append("_无_")
1448
+ lines.append("")
1449
+ lines.append("## 图表(mermaid)")
1450
+ lines.append("")
1451
+ if diagrams:
1452
+ lines.append("| 序号 | 源文件 | 已渲染 PNG |")
1453
+ lines.append("|---|---|---|")
1454
+ for d in diagrams:
1455
+ lines.append("| %d | `%s` | %s |" % (d["index"], d["mmd"],
1456
+ ("`%s`" % d["png"]) if d["png"] else "未渲染"))
1457
+ lines.append("")
1458
+ lines.append("渲染:`mmdc -i diagrams/xx.mmd -o diagrams/xx.png -b white -w 1080`")
1459
+ else:
1460
+ lines.append("_无_")
1461
+ if footnotes:
1462
+ lines.append("")
1463
+ lines.append("## 参考链接(正文上标序号对应)")
1464
+ lines.append("")
1465
+ for idx, f in enumerate(footnotes, 1):
1466
+ lines.append("%d. %s — %s" % (idx, f["label"], f["url"]))
1467
+ lines.append("")
1468
+ lines.append("---")
1469
+ lines.append("")
1470
+ lines.append("主题:%s · 点缀色 `%s` · 标题样式 `%s` · 代码色板 `%s`"
1471
+ % (theme["name"], theme["accent"],
1472
+ theme.get("h2_style", "number"),
1473
+ theme.get("code_scheme", "mono")))
1474
+ path = os.path.join(out_dir, "图片上传清单.md")
1475
+ with open(path, "w", encoding="utf-8") as fh:
1476
+ fh.write("\n".join(lines))
1477
+ return path
1478
+
1479
+
1480
+ # ---------------------------------------------------------------- main
1481
+
1482
+ def main():
1483
+ ap = argparse.ArgumentParser()
1484
+ # 不设 required=True:--list-themes 需要能在不带输入文件时单独运行。
1485
+ # 必填校验放在下面手动做。
1486
+ ap.add_argument("--input", default="")
1487
+ ap.add_argument("--out-dir", default="")
1488
+ ap.add_argument("--slug", default="")
1489
+ ap.add_argument("--title", default="")
1490
+ ap.add_argument("--theme", default="",
1491
+ help="主题:可传标识(tech-blue)、中文名(科技蓝)、JSON 路径,"
1492
+ "或 genre:题材 按题材自动选(如 genre:评测)。留空用默认主题")
1493
+ ap.add_argument("--list-themes", action="store_true", help="列出所有可用主题后退出")
1494
+ ap.add_argument("--link-mode", default="footnote",
1495
+ choices=["footnote", "note", "inline"])
1496
+ ap.add_argument("--asset-root", default="",
1497
+ help="源仓库根目录,用于解析稿中 ../assets/ 形式的图片路径")
1498
+ ap.add_argument("--no-pangu", action="store_true", help="关闭中英文自动加空格")
1499
+ ap.add_argument("--render-mermaid", action="store_true")
1500
+ ap.add_argument("--embed-images", action="store_true",
1501
+ help="把正文图片转成 base64 内嵌,粘贴到公众号时带图(推荐)")
1502
+ ap.add_argument("--no-toc", action="store_true",
1503
+ help="不自动插入前言导读(默认 H2 >=4 个,或 H2 >=3 且正文 >=2000 中文字时插入)")
1504
+ ap.add_argument("--signature", default="auto", choices=["auto", "on", "off"],
1505
+ help="尾部签名区:auto=稿件末尾已有签名则不重复生成(默认)")
1506
+ ap.add_argument("--author", default="", help="签名区署名;留空写 {{作者名}} 占位")
1507
+ ap.add_argument("--author-bio", default="", help="签名区一句话简介")
1508
+ args = ap.parse_args()
1509
+
1510
+ if args.list_themes:
1511
+ for t in list_themes():
1512
+ print("%-14s %-10s %-9s %s" % (t["id"], t["name"], t["accent"],
1513
+ "、".join(t["genre"]) or "(未声明题材)"))
1514
+ return
1515
+
1516
+ missing = [n for n, v in (("--input", args.input), ("--out-dir", args.out_dir),
1517
+ ("--slug", args.slug)) if not v]
1518
+ if missing:
1519
+ sys.exit("缺少必填参数:%s" % "、".join(missing))
1520
+
1521
+ if not os.path.isfile(args.input):
1522
+ sys.exit("错误:找不到输入文件 %s" % args.input)
1523
+
1524
+ theme = load_theme(args.theme)
1525
+ with open(args.input, encoding="utf-8") as fh:
1526
+ md_text = fh.read()
1527
+
1528
+ os.makedirs(args.out_dir, exist_ok=True)
1529
+ body, assets, diagrams, footnotes = convert(
1530
+ md_text, os.path.abspath(args.input), args.out_dir, theme,
1531
+ args.link_mode, args.render_mermaid, args.asset_root or None,
1532
+ not args.no_pangu,
1533
+ toc=not args.no_toc, sign_mode=args.signature,
1534
+ author=args.author, author_bio=args.author_bio,
1535
+ )
1536
+
1537
+ title = args.title
1538
+ if not title and md_text.startswith("---"):
1539
+ end = md_text.find("\n---", 3)
1540
+ fm = md_text[3:end] if end > 0 else ""
1541
+ m = re.search(r"^title:\s*(.+)$", fm, re.M)
1542
+ if m:
1543
+ title = m.group(1).strip().strip("\"'")
1544
+ if not title:
1545
+ for line in md_text.split("\n"):
1546
+ if line.startswith("# "):
1547
+ title = line[2:].strip()
1548
+ break
1549
+ title = title or args.slug
1550
+
1551
+ embed_stats = None
1552
+ if args.embed_images:
1553
+ body, embed_stats = embed_images_inline(body, args.out_dir)
1554
+
1555
+ page = PAGE_TPL.format(
1556
+ title=esc(title), body=body, accent=theme["accent"],
1557
+ text=theme["text"], font_size=theme["font_size"],
1558
+ line_height=theme["line_height"], font_stack=theme["font_stack"],
1559
+ notice=build_notice(embed_stats is not None,
1560
+ embed_stats["count"] if embed_stats else len(assets)),
1561
+ )
1562
+ html_path = os.path.join(args.out_dir, "%s-公众号版.html" % args.slug)
1563
+ with open(html_path, "w", encoding="utf-8") as fh:
1564
+ fh.write(page)
1565
+
1566
+ manifest = write_manifest(args.out_dir, args.slug, title, assets, diagrams,
1567
+ theme, footnotes, embed_stats is not None)
1568
+
1569
+ plain = re.sub(r"<[^>]+>", "", body)
1570
+ print("HTML : %s" % html_path)
1571
+ audit = audit_inline(body)
1572
+ errors = [m for lv, m in audit if lv == "ERROR"]
1573
+ warns = [m for lv, m in audit if lv == "WARN"]
1574
+ if errors or warns:
1575
+ print("合规自检 : %s(ERROR %d / WARN %d)"
1576
+ % ("✗ 未通过" if errors else "△ 通过但有提示", len(errors), len(warns)))
1577
+ for m in errors:
1578
+ print(" [ERROR] %s" % m)
1579
+ for m in warns[:8]:
1580
+ print(" [WARN ] %s" % m)
1581
+ if len(warns) > 8:
1582
+ print(" …… 另有 %d 条 WARN" % (len(warns) - 8))
1583
+ else:
1584
+ print("合规自检 : ✓ 无告警(标签/属性/宽度/对齐 全部通过)")
1585
+ print("清单 : %s" % manifest)
1586
+ if embed_stats:
1587
+ print("图片内嵌 : ✓ %d 张 / %.0f KB(base64 后约 %.1f MB)"
1588
+ % (embed_stats["count"], embed_stats["bytes"] / 1024,
1589
+ embed_stats["bytes"] * 4 / 3 / 1024 / 1024))
1590
+ if embed_stats["skipped"]:
1591
+ print(" 未内嵌:%s" % "、".join(embed_stats["skipped"]))
1592
+ print("主题 : %s(%s) / 标题样式 %s / 代码色板 %s"
1593
+ % (theme["name"], theme.get("id", "-"), theme.get("h2_style", "number"),
1594
+ theme.get("code_scheme", "mono")))
1595
+ print("图片 : %d 张(%d 远程)" % (len(assets), sum(1 for a in assets if a.get("remote"))))
1596
+ print("图表 : %d 个 mermaid" % len(diagrams))
1597
+ print("脚注 : %d 条" % len(footnotes))
1598
+ print("正文字数 : %d" % len(plain))
1599
+
1600
+
1601
+ if __name__ == "__main__":
1602
+ sys.exit(main())