#!/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 栏宽)
输出用浏览器打开,全选复制 → 粘贴进公众号编辑器。
支持的语法子集:#(跳过)/ ### / 段落 / **加粗** / `行内代码` / [链接] /  /
> 引用(含「—— 出处」识别为金句卡)/ - 与 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'
{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"^-{3,}$", s): out.append('{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(s, st)}
') i += 1 return (f'