Files

312 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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'<img src="{esc(m.group(2))}" style="max-width:100%;display:block;margin:16px auto;'
f'border-radius:6px;"'
+ (f'<figcaption style="text-align:center;font-size:13px;color:#999;'
f'margin:8px 0 20px;">{esc(m.group(3))}</figcaption>' if m.group(3) else "")),
t)
t = re.sub(r"\[([^\]]+)\]\(([^)\s]+)\)",
rf'<a href="\2" style="{st["link"]}">\1</a>', t)
t = re.sub(r"\*\*([^*]+)\*\*", rf'<strong style="{st["strong"]}">\1</strong>', t)
t = re.sub(r"\x00(\d+)\x00",
lambda m: f'<code style="background:#f2f3f5;color:#c7254e;border-radius:3px;'
f'padding:2px 5px;font-size:14px;font-family:Menlo,Consolas,monospace;">'
f'{esc(codes[int(m.group(1))])}</code>', 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'<li style="{st["li"]}">' + inline(bullet.sub("", it), st) + "</li>"
for it in items)
out.append(f'<{tag} style="{st["li_ul"]}">{lis}</{tag}>')
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'<pre style="background:#f6f8fa;border-radius:8px;padding:14px 16px;'
f'overflow-x:auto;font-size:13.5px;line-height:1.6;margin:0 0 20px;'
f'font-family:Menlo,Consolas,monospace;color:#24292e;">{code}</pre>')
continue
if s.startswith("# ") and not s.startswith("##"):
i += 1 # 主标题不进正文(填在公众号后台)
continue
if s.startswith("###") and not s.startswith("####"):
out.append(f'<h3 style="{st["h3"]}">{inline(s.lstrip("#").strip(), st)}</h3>')
i += 1
continue
if re.match(r"^####+\s", s):
out.append(f'<p style="margin:24px 0 12px;font-weight:600;color:{dark};'
f'font-size:{px};">{inline(s.lstrip("#").strip(), st)}</p>')
i += 1
continue
if re.match(r"^-{3,}$", s):
out.append('<hr style="border:none;border-top:1px solid #eaeaea;margin:28px 0;"/>')
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 = "<br/>".join(inline(b, st) for b in buf if b)
key = "golden" if "——" in text else "quote"
out.append(f'<blockquote style="{st[key]}">{text}</blockquote>')
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'<td style="border:1px solid #eaeaea;padding:8px 10px;'
f'font-size:{int(px[:-2]) - 1}px;{head}">{inline(c, st)}</td>'
for c in cells)
trs.append(f"<tr>{tds}</tr>")
out.append('<table style="border-collapse:collapse;margin:0 auto 20px;">'
+ "".join(trs) + "</table>")
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'<p style="{st["p"]}">{inline(s, st)}</p>')
i += 1
return (f'<section style="max-width:578px;margin:0 auto;font-family:'
f'-apple-system,BlinkMacSystemFont,\'PingFang SC\',\'Hiragino Sans GB\','
f'\'Microsoft YaHei\',sans-serif;">' + "".join(out) + "</section>")
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'<h2 style="text-align:center;font-size:15px;color:#999;'
f'margin:40px 0 12px;">骨架:{sk_name} · 色板 {color}</h2>'
f'<div style="max-width:375px;margin:0 auto;border:1px solid #ddd;'
f'padding:16px 14px;">{doc}</div>')
page = ("<!DOCTYPE html><html><head><meta charset='utf-8'><title>主题预览</title></head>"
f"<body>{''.join(blocks)}</body></html>")
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 = ("<!DOCTYPE html><html><head><meta charset='utf-8'>"
"<title>公众号文章</title></head>"
f"<body style='background:#fff;'>{doc}</body></html>")
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()