我每天在社区发一篇原创文章。稿件是 Markdown 写好的,小标题、代码块、表格、列表一应俱全,在本地看得很清楚。可一粘进社区的富文本编辑器,全变了样:井号还在,星号还在,表格竖线整排挂在那儿,代码块挤成一坨。
然后就得手工收拾。选中一行,点工具栏的「标题二」,再选下一行,再点一次。遇到代码块,得先点「代码块」,再把内容贴进去。表格更麻烦,一个一个格子填。一篇文章七八个小标题、四五段代码、两张表格,从头排到尾,四个小时打不住。
排完一次我就下定决心:这活不能这么干。
富文本编辑器不是不认识排版,它是不认识 Markdown。
Markdown 那套井号、星号、竖线,是给人看源码用的记号。编辑器要的是 HTML 标签:「h2」、「ul」、「li」、「p」。你把一堆井号粘进去,编辑器只当它是普通文字,原样显示。所以你看到的「格式全丢」,本质上是两套标记语言没对上。
那是不是把 Markdown 转成 HTML 再粘就行?对,方向是对的,但坑在后面——编辑器不会照单全收你的 HTML。这一点我踩了很久才摸清楚。
第一条:老实点工具栏。 能用,就是慢。四个小时一篇,还容易漏。更要命的是排到后面手一抖选错范围,前面的全乱了。这条路我走了一个多星期,实在受不了。
第二条:转成纯文本再粘。 把 Markdown 里的井号、星号、竖线全删掉,粘进去是干净文字,格式一个没有。等于没排,只是不难看了。这种发出去,读者看长文很吃力,也不像样。白干。
第三条:找在线转换网站。 网上有那种「Markdown 转 HTML」的在线工具,粘进去点一下,出来带标签的 HTML。问题是:一是要联网,二是出来的 HTML 里塞满了内联样式和编辑器不认的标签,粘进去照样变形,三是稿子要上传到别人服务器上——我的稿子里有本地路径、有脚本,往外网传不合适,这条直接否掉。
三条路都不通,那就只剩自己动手这一条:本地把 Markdown 转成编辑器认的 HTML,直接写进剪贴板的 HTML 通道,一次粘进去。
这一步是整件事的转折点,得先想明白。
我一直以为剪贴板就是放文字的地方。不是的。你在网页上复制一段带格式的文字,粘到 Word 里格式还在——这说明剪贴板里存的不只是纯文本,还有一份带格式的版本。
Windows 上,这份带格式的版本叫 CF_HTML。它是一个有固定格式头的 HTML 片段。程序往剪贴板写数据的时候,可以同时写好几个「通道」:纯文本通道、HTML 通道、图片通道。粘贴的时候,目标程序挑自己能用的那个通道读。富文本编辑器看到有 HTML 通道,就会读它,并按里面的标签渲染。
所以思路就清楚了:
1. 本地把 Markdown 转成 HTML; 2. 按 CF_HTML 的规矩给它套个头; 3. 用 Python 调 Windows 接口写进剪贴板; 4. 在编辑器里按一下粘贴。
整套跑下来几十秒,中间不用点一次工具栏。
这一步是纯 Python,不装任何第三方库,标准库就够。
先说一个前提:不要想着让 HTML 跟 Markdown 一模一样。 编辑器认的标签是有限的,你转得再花哨,它不认的部分照样会被扔掉,扔的时候还会把周围的格式搞乱。所以这一步的目标不是「转换」,是「降级到编辑器能吃的样子」。
我踩出来的降级规则是这几条:
后三条看着是「越转越丑」,实际是救命的。原因下一节单独讲。
代码缩进是另一个麻烦。代码块降级成段落之后,行首的空格如果写普通空格,编辑器会把连续空格折叠成一个,Python 的缩进层级全没了,代码没法看。解决办法是用 HTML 实体 「 」,它是半个字宽的空位,不会被折叠。
这里不要用 「 」。我一开始用的是它,粘进去缩进照样丢,后来换成 「 」 才保住。
下面是转换的主体,去掉了首尾的标题行和标签行,其余按行处理:
import re, html
FENCE = chr(96) * 3 # 三个反引号,写成变量避免字面量
def inline(t):
t = html.escape(t)
t = re.sub(r"\*\*(.+?)\*\*", r"\1", t) # 加粗星号直接去掉
return t
def to_html(md_text):
lines = md_text.split("\n")
if lines and lines[0].startswith("# "):
lines = lines[1:]
while lines and lines[-1].strip() in ("", "#WorkBuddy#"):
lines.pop()
out, i, n = [], 0, len(lines)
while i < n:
s = lines[i].strip()
if s.startswith(FENCE): # 代码块降级
i += 1
buf = []
while i < n and not lines[i].strip().startswith(FENCE):
buf.append(lines[i]); i += 1
i += 1
for ln in buf:
esc = html.escape(ln)
lead = len(esc) - len(esc.lstrip(" "))
out.append("<p>" + " " * lead + esc.strip() + "</p>"
if esc.strip() else "<p> </p>")
continue
if s.startswith("### "): # 三级小标题
out.append("<h3>" + html.escape(s[4:].strip()) + "</h3>")
i += 1
continue
if s.startswith("## "): # 二级小标题
out.append("<h2>" + html.escape(s[3:].strip()) + "</h2>")
i += 1
continue
if s.startswith("|") and s.endswith("|"): # 表格降级
while i < n and lines[i].strip().startswith("|"):
row = lines[i].strip()
if re.match(r"^[\|\s\-]+$", row) and "-" in row:
i += 1; continue
cells = [c.strip() for c in row[1:-1].split("|")]
out.append("<p>" + inline(",".join(cells)) + "</p>")
i += 1
continue
if re.match(r"^[-*]\s+", s): # 列表
items = []
while i < n and re.match(r"^[-*]\s+", lines[i].strip()):
items.append(inline(re.sub(r"^[-*]\s+", "", lines[i].strip())))
i += 1
out.append("<ul>" + "".join("<li>" + x + "</li>" for x in items) + "</ul>")
continue
if s == "":
i += 1
continue
para = []
while i < n and lines[i].strip() != "" and not lines[i].strip().startswith(("## ", FENCE, "|")) \
and not re.match(r"^[-*]\s+", lines[i].strip()):
para.append(lines[i].strip()); i += 1
if para:
out.append("<p>" + inline(" ".join(para)) + "</p>")
return "\n".join(out)
转完之后先别急着粘,先把结果存成一个本地 html 文件,用浏览器打开看一眼。这一步能省掉后面大量返工。
CF_HTML 不是一个裸的 HTML 片段,它前面必须有一段格式头,告诉读的程序:整段 HTML 从第几个字节开始、到第几个字节结束,正文片段又是从哪到哪。
头长这样:
Version:0.9
StartHTML:0000000000
EndHTML:0000000000
StartFragment:0000000000
EndFragment:0000000000
四个数字都是字节偏移,而且是十位对齐的。看着唬人,其实就是先拿一串占位符算出头部长度,再往下推。
import ctypes
def set_cf_html(fragment):
CF = ctypes.windll.user32.RegisterClipboardFormatW("HTML Format")
HEAD = ("Version:0.9\r\nStartHTML:%08d\r\nEndHTML:%08d\r\n"
"StartFragment:%08d\r\nEndFragment:%08d\r\n")
plen = len(HEAD % (0, 0, 0, 0))
pre = "<html><body>\r\n<!--StartFragment-->"
suf = "<!--EndFragment-->\r\n</body></html>"
fb = fragment.encode("utf-8")
sh = plen
eh = plen + len(pre.encode()) + len(fb) + len(suf.encode())
sf = plen + len(pre.encode())
ef = sf + len(fb)
data = (HEAD % (sh, eh, sf, ef)).encode() + pre.encode() + fb + suf.encode()
u, k = ctypes.windll.user32, ctypes.windll.kernel32
u.OpenClipboard.argtypes = [ctypes.c_void_p]
u.EmptyClipboard.restype = ctypes.c_int
k.GlobalAlloc.argtypes = [ctypes.c_uint, ctypes.c_size_t]
k.GlobalAlloc.restype = ctypes.c_void_p
k.GlobalLock.argtypes = [ctypes.c_void_p]
k.GlobalLock.restype = ctypes.c_void_p
k.GlobalUnlock.argtypes = [ctypes.c_void_p]
u.SetClipboardData.argtypes = [ctypes.c_uint, ctypes.c_void_p]
u.SetClipboardData.restype = ctypes.c_void_p
u.OpenClipboard(None); u.EmptyClipboard()
h = k.GlobalAlloc(0x0002, len(data) + 2)
p = k.GlobalLock(h); ctypes.memmove(p, data, len(data)); k.GlobalUnlock(h)
u.SetClipboardData(CF, h); u.CloseClipboard()
return len(data)
这段代码里有一个坑,卡了我小半天,单独拎出来说。
那几行 argtypes 和 restype 的声明,一行都不能少。
ctypes 调 Windows 接口的时候,如果你不告诉它参数和返回值是什么类型,它默认按 32 位整数处理。在 64 位系统上,内存句柄是个很大的数,超出 32 位整数的范围,直接报一句 「int too long to convert」,然后就没了,报错信息完全不指向真正的原因。
我一开始以为是数据太大,改小了试;又以为是编码问题,换了好几种。折腾半天才反应过来是类型声明的事。补上 「GlobalAlloc」 的返回类型声明成 「c_void_p」、「GlobalLock」 的参数和返回都声明成指针,一次就通了。
用 ctypes 调系统接口,凡是跟句柄、指针沾边的函数,先把类型声明写全。 这是通用教训,不光这一处。
写入剪贴板之后,剩下的是在编辑器里点一下。这一步我是用脚本代劳的,也可以手动:切到编辑器,正文区域点一下,按两次全选、一次粘贴,完事。
但真正重要的不是粘,是粘完之后读回来验一次。
这是个我后来才养成的习惯。早先我粘完扫一眼觉得「还行」就发,结果有两次翻车:一次是小标题只认了一半,后半篇全是普通段落;一次是代码缩进整段塌了。
肉眼扫不可靠,长文扫不过来。机器数才可靠。 做法很简单:粘完之后,在编辑器里全选、复制,再用脚本把剪贴板里的 HTML 读回来,数标签。
def read_cf_html():
u, k = ctypes.windll.user32, ctypes.windll.kernel32
CF = u.RegisterClipboardFormatW("HTML Format")
u.GetClipboardData.argtypes = [ctypes.c_uint]
u.GetClipboardData.restype = ctypes.c_void_p
k.GlobalLock.argtypes = [ctypes.c_void_p]
k.GlobalLock.restype = ctypes.c_void_p
k.GlobalSize.argtypes = [ctypes.c_void_p]
k.GlobalSize.restype = ctypes.c_size_t
if not u.OpenClipboard(None):
return ""
try:
h = u.GetClipboardData(CF)
if not h:
return ""
p = k.GlobalLock(h)
raw = ctypes.string_at(p, k.GlobalSize(h))
k.GlobalUnlock(h)
finally:
u.CloseClipboard()
return raw.decode("utf-8", errors="ignore")
我给自己定的校验有三条,三条全过才算粘成功:
三条里任何一条不过,直接回滚:把 Markdown 转成不带任何标签的纯文本重新粘一遍,宁可没格式,也不能发一篇格式半截的出去。有回滚预案,才敢用自动化。
这一节是整篇文章我觉得最值钱的部分,因为它是粘了很多篇才试出来的,网上查不到现成的答案。
结论分三档:
能用的:
会被改的:
会被剥掉的(重点):
所以第四节那几条「降级规则」不是我偷懒,是被逼出来的。既然 pre 和 table 一定会被剥,就别给它们,直接给成一行一个段落,至少保证内容完整、顺序不乱。
还有一条实测经验:编辑器对空段落的处理很怪,空段落容易被吞掉,导致两段之间没间隔。我的办法是空段落里塞一个 「 」,看起来是空行,实际有内容,就不会被吞。
把我踩过的坑按严重程度排一遍,照着对就能少走弯路:
最直观的变化是时间。手工排一篇四个小时起步,现在整条链路跑完几十秒,剩下的时间只需要扫一眼验证结果。
但比时间更值钱的是一致性。手工排版,每篇的效果都不一样,这篇小标题用二级、那篇手滑点了三级,代码块这次套了样式、下次忘了。机器转出来的篇篇一致,发出去的文章看着就像一个人排的。
还有个附带的收获:因为粘之前必须先看清编辑器认什么、不认什么,我反过来把稿件的写法也规范了。现在写稿的时候就按「编辑器能吃的样子」去写,少用华丽格式,多用朴素的小标题和列表。工具反过来改掉了我的坏习惯。
最后说一句心里话。我上了年纪,在家时间多,电脑水平一般,很多东西是边试边学。这套东西没有任何高深的地方,就是把「复制粘贴」这四个字拆开研究了一遍:原来复制出去的不只是字,粘贴进来的也可以是带结构的整篇内容。想明白这一层,后面就是体力活了。
要是你也在为排版发愁,别急着找插件、找网站。先花半小时把剪贴板的原理搞清楚,再用几十行 Python 把它串起来。一次写对,后面每天省四小时。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。