#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Markdown → 微信公众号可粘贴 HTML(全内联样式,零依赖)。 排版 = 骨架(结构装饰)× 色板(主题色)两层: 骨架 工程 色条标题 + 主题色加粗 + 灰底金句卡——教程、技术、工程实践(默认) 杂志 深色大标题 + 深灰粗体 + 装饰线金句卡——叙事、访谈、生活方式 简报 紧凑节奏 + 主题色小标题——更新简报、快讯、清单 色板 靛蓝 / 松绿 / 黛紫 / 绛红(--color 可自定义 #RRGGBB) 用法: python3 md2wechat.py article.md -o article.html # 工程骨架 + 靛蓝 python3 md2wechat.py article.md --skeleton 杂志 --palette 松绿 # 指定骨架与色板 python3 md2wechat.py article.md --list-themes # 看全部组合与适用场景 python3 md2wechat.py article.md --preview # 三骨架对照页(375px 栏宽) 输出用浏览器打开,全选复制 → 粘贴进公众号编辑器。 支持的语法子集:#(跳过)/ ### / 段落 / **加粗** / `行内代码` / [链接] / ![图片](src "题注") / > 引用(含「—— 出处」识别为金句卡)/ - 与 1. 列表 / --- 分隔 / ```代码块``` / ≤3 列表格。 """ import argparse import html import re import sys PALETTES = {"靛蓝": "#1A5CFF", "松绿": "#0F7B6C", "黛紫": "#6B4FBB", "绛红": "#B03A2E"} SKELETONS = { "工程": {"alias": "eng", "fit": "教程、技术解析、工程实践"}, "杂志": {"alias": "mag", "fit": "叙事、访谈、生活方式、品牌"}, "简报": {"alias": "brief", "fit": "更新简报、快讯、清单文"}, } def esc(t): return html.escape(t, quote=False) def darken(hexc, f=0.72): hexc = hexc.lstrip("#") r, g, b = (int(hexc[i:i + 2], 16) for i in (0, 2, 4)) m = lambda v: max(0, int(v * f)) return f"#{m(r):02x}{m(g):02x}{m(b):02x}" def styles(sk, color, px): """骨架差异全部参数化在这里:返回各元素的内联样式串。""" n = int(px[:-2]) dark = darken(color) if sk == "杂志": r, g, b = color.lstrip("#")[0:2], color.lstrip("#")[2:4], color.lstrip("#")[4:6] hl = f"rgba({int(r,16)},{int(g,16)},{int(b,16)},0.12)" return { "p": f"font-size:{px};line-height:1.8;color:#333;letter-spacing:.5px;margin:0 0 22px;", "strong": f"color:#111;font-weight:700;background:{hl};", "link": f"color:{dark};text-decoration:none;border-bottom:1px solid {hl};", "h3": (f"margin:34px 0 16px;font-size:{n + 1}px;font-weight:700;color:#111;" f"border-bottom:2px solid #e8e8e8;padding-bottom:10px;line-height:1.5;"), "quote": (f"margin:26px 0;padding:4px 16px;border-left:2px solid #ccc;" f"font-size:{n - 1}px;line-height:1.8;color:#888;"), "golden": (f"margin:30px 0;padding:20px 10px;text-align:center;" f"border-top:1px solid {color};border-bottom:1px solid {color};" f"font-size:{n + 1}px;line-height:1.9;color:{dark};font-weight:500;"), "li": "margin:0 0 12px;line-height:1.8;", "li_ul": f"padding-left:1.4em;margin:0 0 22px;font-size:{px};line-height:1.8;color:#333;", } if sk == "简报": return { "p": f"font-size:{px};line-height:1.7;color:#3a3a3a;margin:0 0 16px;", "strong": f"color:{color};font-weight:600;", "link": f"color:{color};text-decoration:none;", "h3": (f"margin:26px 0 12px;font-size:{n + 1}px;font-weight:600;color:{dark};" f"line-height:1.5;"), "quote": (f"margin:20px 0;padding:2px 14px;border-left:2px solid {color};" f"font-size:{n - 1}px;line-height:1.7;color:#777;"), "golden": (f"margin:24px 0;padding:12px 8px;text-align:center;" f"font-size:{n + 1}px;line-height:1.8;color:{dark};font-weight:500;" f"letter-spacing:1px;"), "li": "margin:0 0 8px;line-height:1.7;", "li_ul": f"padding-left:1.4em;margin:0 0 16px;font-size:{px};line-height:1.7;color:#3a3a3a;", } # 工程(默认) return { "p": f"font-size:{px};line-height:1.75;color:#3f3f3f;letter-spacing:.4px;margin:0 0 20px;", "strong": f"color:{color};font-weight:600;", "link": f"color:{color};text-decoration:none;", "h3": (f"margin:30px 0 16px;font-size:{n + 1}px;font-weight:600;color:{dark};" f"border-left:4px solid {color};padding-left:10px;line-height:1.5;"), "quote": (f"margin:24px 0;padding:12px 16px;border-left:3px solid #ddd;background:#f7f8fa;" f"font-size:{n - 1}px;line-height:1.8;color:#888;"), "golden": (f"margin:28px 0;padding:20px 24px;background:#f7f8fa;text-align:center;" f"font-size:{n + 1}px;line-height:1.9;color:{dark};font-weight:500;"), "li": "margin:0 0 10px;line-height:1.75;", "li_ul": f"padding-left:1.4em;margin:0 0 20px;font-size:{px};line-height:1.75;color:#3f3f3f;", } def inline(text, st): """行内元素转换:先保护行内代码,再处理加粗/链接/图片。""" codes = [] def stash(m): codes.append(m.group(1)) return f"\x00{len(codes) - 1}\x00" t = re.sub(r"`([^`]+)`", stash, text) t = re.sub(r"!\[([^\]]*)\]\(([^)\s]+)(?:\s+\"([^\"]*)\")?\)", lambda m: (f'{esc(m.group(3))}' if m.group(3) else "")), t) t = re.sub(r"\[([^\]]+)\]\(([^)\s]+)\)", rf'\1', t) t = re.sub(r"\*\*([^*]+)\*\*", rf'\1', t) t = re.sub(r"\x00(\d+)\x00", lambda m: f'' f'{esc(codes[int(m.group(1))])}', t) return t def convert(md, sk, color, px): st = styles(sk, color, px) dark = darken(color) lines = md.split("\n") out, i, n = [], 0, len(lines) in_front = lines and lines[0].strip() == "---" front_done = not in_front def flush_list(items, ordered): tag = "ol" if ordered else "ul" bullet = re.compile(r"^\s*(?:\d+\.|-)\s+") lis = "".join( f'
  • ' + inline(bullet.sub("", it), st) + "
  • " for it in items) out.append(f'<{tag} style="{st["li_ul"]}">{lis}') while i < n: ln = lines[i] if not front_done: if i > 0 and ln.strip() == "---": front_done = True i += 1 continue s = ln.strip() if not s: i += 1 continue if s.startswith("```"): i += 1 buf = [] while i < n and not lines[i].strip().startswith("```"): buf.append(lines[i]) i += 1 i += 1 code = esc("\n".join(buf)) out.append(f'
    {code}
    ') continue if s.startswith("# ") and not s.startswith("##"): i += 1 # 主标题不进正文(填在公众号后台) continue if s.startswith("###") and not s.startswith("####"): out.append(f'

    {inline(s.lstrip("#").strip(), st)}

    ') i += 1 continue if re.match(r"^####+\s", s): out.append(f'

    {inline(s.lstrip("#").strip(), st)}

    ') i += 1 continue if re.match(r"^-{3,}$", s): out.append('
    ') i += 1 continue if s.startswith(">"): buf = [] while i < n and lines[i].strip().startswith(">"): buf.append(lines[i].strip().lstrip(">").strip()) i += 1 text = "
    ".join(inline(b, st) for b in buf if b) key = "golden" if "——" in text else "quote" out.append(f'
    {text}
    ') continue if s.startswith("|"): tbl = [] while i < n and lines[i].strip().startswith("|"): tbl.append(lines[i].strip()) i += 1 rows = [r for r in tbl if not re.match(r"^\|[\s:|-]+\|$", r)] if rows: cols = max(r.count("|") - 1 for r in rows) if cols > 3: print(f"⚠️ 表格超过 3 列({cols} 列),已跳过渲染——请先改写为列表", file=sys.stderr) else: trs = [] for ri, r in enumerate(rows): cells = [c.strip() for c in r.strip("|").split("|")] head = (f"background:#f7f8fa;font-weight:600;color:{dark};" if ri == 0 else "") tds = "".join( f'{inline(c, st)}' for c in cells) trs.append(f"{tds}") out.append('' + "".join(trs) + "
    ") continue if re.match(r"^\s*-\s+", ln): items = [] while i < n and re.match(r"^\s*-\s+", lines[i]): items.append(lines[i]) i += 1 flush_list(items, ordered=False) continue if re.match(r"^\s*\d+\.\s+", ln): items = [] while i < n and re.match(r"^\s*\d+\.\s+", lines[i]): items.append(lines[i]) i += 1 flush_list(items, ordered=True) continue out.append(f'

    {inline(s, st)}

    ') i += 1 return (f'
    ' + "".join(out) + "
    ") SAMPLE = """### 小节标题示例 正文段落:这里是**主题加粗落点**,后面是[链接示例](https://example.com)与行内代码 `npm install`。 - **标签一**:说明文字 - **标签二**:说明文字 > 值得截图转发的一句金句。 —— 出处 > 普通引用:补充说明的引文。 ![图片示例](data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='120'%3E%3Crect width='100%25' height='100%25' fill='%23eee'/%3E%3C/svg%3E "题注") --- 结尾段落,含**收束落点**。 """ def list_themes(): print("骨架 × 色板(--skeleton 骨架 --palette 色板,或 --color '#RRGGBB' 自定义)\n") for sk, meta in SKELETONS.items(): print(f" {sk}(--skeleton {sk} 或 {meta['alias']})—— {meta['fit']}") print(f" 色板:{' / '.join(f'{k} {v}' for k, v in PALETTES.items())}\n") def resolve_skeleton(name): if not name: return "工程" for sk, meta in SKELETONS.items(): if name in (sk, meta["alias"]): return sk sys.exit(f"未知骨架 {name},可选:{' / '.join(SKELETONS)}(或其英文别名)") def main(): ap = argparse.ArgumentParser(description="Markdown → 微信公众号 HTML(骨架×色板,全内联样式)") ap.add_argument("article", nargs="?") ap.add_argument("-o", "--output") ap.add_argument("--skeleton", help="骨架:工程 / 杂志 / 简报(默认 工程)") ap.add_argument("--palette", choices=list(PALETTES)) ap.add_argument("--theme", choices=list(PALETTES), help="兼容旧参数,等同 --palette") ap.add_argument("--color", help="自定义主题色 #RRGGBB,覆盖色板") ap.add_argument("--font-size", default="16px") ap.add_argument("--list-themes", action="store_true", help="列出全部骨架与色板") ap.add_argument("--preview", action="store_true", help="生成三骨架对照页(用内置样文)") args = ap.parse_args() if args.list_themes: list_themes() return sk = resolve_skeleton(args.skeleton) color = args.color or PALETTES.get(args.palette or args.theme or "靛蓝") if not re.match(r"^#[0-9A-Fa-f]{6}$", color): sys.exit(f"颜色格式应为 #RRGGBB,收到 {color}") if args.preview: blocks = [] for sk_name in SKELETONS: doc = convert(SAMPLE, sk_name, color, args.font_size) blocks.append(f'

    骨架:{sk_name} · 色板 {color}

    ' f'
    {doc}
    ') page = ("主题预览" f"{''.join(blocks)}") out = args.output or "theme-preview.html" open(out, "w", encoding="utf-8").write(page) print(f"✔ {out}(骨架对照页,375px 栏宽 ≈ 手机宽度)——浏览器打开选一个顺眼的") return if not args.article: sys.exit("请提供 markdown 文件路径(或用 --list-themes / --preview)") md = open(args.article, encoding="utf-8").read() doc = convert(md, sk, color, args.font_size) full = ("" "公众号文章" f"{doc}") out = args.output or re.sub(r"\.md$", ".html", args.article) open(out, "w", encoding="utf-8").write(full) print(f"✔ {out}(骨架 {sk} · 主题色 {color})——浏览器打开后全选复制,粘贴进公众号编辑器") if __name__ == "__main__": main()