首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >排版四小时变几十秒:我把 Markdown 一次粘进富文本编辑器

排版四小时变几十秒:我把 Markdown 一次粘进富文本编辑器

原创
作者头像
真的就是初学者
发布2026-09-09 21:25:53
发布2026-09-09 21:25:53
1340
举报

我每天在社区发一篇原创文章。稿件是 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. 在编辑器里按一下粘贴。

整套跑下来几十秒,中间不用点一次工具栏。

四、第一步:把 Markdown 转成编辑器认的 HTML

这一步是纯 Python,不装任何第三方库,标准库就够。

先说一个前提:不要想着让 HTML 跟 Markdown 一模一样。 编辑器认的标签是有限的,你转得再花哨,它不认的部分照样会被扔掉,扔的时候还会把周围的格式搞乱。所以这一步的目标不是「转换」,是「降级到编辑器能吃的样子」。

我踩出来的降级规则是这几条:

  • 二级标题 「##」 转成 「h2」,三级转 「h3」,这两个编辑器认;
  • 列表转成 「ul」 套 「li」;
  • 代码块不转 「pre」 和 「code」,转成一行一个 「p」;
  • 表格也不转 「table」,转成一行一个 「p」,单元格用逗号连起来;
  • 加粗的星号直接删掉,别转 「strong」。

后三条看着是「越转越丑」,实际是救命的。原因下一节单独讲。

代码缩进是另一个麻烦。代码块降级成段落之后,行首的空格如果写普通空格,编辑器会把连续空格折叠成一个,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>" + "&#8194;" * lead + esc.strip() + "</p>"

                           if esc.strip() else "<p>&#8194;</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 文件,用浏览器打开看一眼。这一步能省掉后面大量返工。

五、第二步:把 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")

我给自己定的校验有三条,三条全过才算粘成功:

  • 小标题数量对得上。 我稿子里有九个二级标题,读回来的 HTML 里就该有九个 h2。少一个就说明中间有段落被合并了,得查。
  • 没有 HTML 残留。 编辑器如果没解析成功,标签会当纯文本显示在页面上。所以读回的正文里不能出现 p、h2 这类尖括号标签的原文。
  • 开头和结尾的关键句都在。 比如开头第一句、结尾最后一句,还有代码块里某个独特的变量名,都在就说明整篇没被截断。

三条里任何一条不过,直接回滚:把 Markdown 转成不带任何标签的纯文本重新粘一遍,宁可没格式,也不能发一篇格式半截的出去。有回滚预案,才敢用自动化。

七、编辑器到底认哪些标签:实测清单

这一节是整篇文章我觉得最值钱的部分,因为它是粘了很多篇才试出来的,网上查不到现成的答案。

结论分三档:

能用的:

  • 二级标题 h2:正常,会变成真正的标题样式。
  • 三级标题 h3:正常。
  • 无序列表 ul 套 li:正常,项目符号对。

会被改的:

  • 有序列表 ol:数字序号会丢,变成无序列表。所以我直接把有序也转成 ul,不要指望它给你编号。

会被剥掉的(重点):

  • 代码块 pre 和 code:整个标签被扔掉,代码挤成一坨,行也没了,比不排版还难看。
  • 表格 table:单元格粘成一串,分不清行列。
  • 加粗 strong:不认,星号可能原样留在页面上。
  • 连续空格:不管你写几个,编辑器只留一个。

所以第四节那几条「降级规则」不是我偷懒,是被逼出来的。既然 pre 和 table 一定会被剥,就别给它们,直接给成一行一个段落,至少保证内容完整、顺序不乱。

还有一条实测经验:编辑器对空段落的处理很怪,空段落容易被吞掉,导致两段之间没间隔。我的办法是空段落里塞一个 「&#8194;」,看起来是空行,实际有内容,就不会被吞。

八、避坑清单

把我踩过的坑按严重程度排一遍,照着对就能少走弯路:

  • ctypes 不声明类型:报的错是「int too long to convert」,跟真正的原因八竿子打不着。涉及句柄和指针的函数,argtypes 和 restype 一行都别省。这是第一坑。
  • 代码缩进用普通空格或 &nbsp; 保不住:编辑器会折叠连续空格。必须用 &#8194; 这类不会被折叠的实体。
  • 给编辑器 pre、code、table、strong:给了也白给,还容易把周围格式带乱。直接降级成段落。
  • 粘完不验证:长文肉眼扫不过来,必须读回来数标签。小标题少一个、代码塌一段,都是发完才发现的那种事。
  • 没有回滚方案:一旦粘坏了,编辑器里的撤销不一定救得回来。提前准备好「纯文本版」的兜底,出事就重来。
  • 依赖固定屏幕坐标去点编辑器:窗口大小一变就点空。要用控件本身去定位,别用坐标。
  • 指望在线转换网站:稿子要上传到别人服务器,本地路径、脚本内容都在里面,不合适。

九、这样做下来,省了多少

最直观的变化是时间。手工排一篇四个小时起步,现在整条链路跑完几十秒,剩下的时间只需要扫一眼验证结果。

但比时间更值钱的是一致性。手工排版,每篇的效果都不一样,这篇小标题用二级、那篇手滑点了三级,代码块这次套了样式、下次忘了。机器转出来的篇篇一致,发出去的文章看着就像一个人排的。

还有个附带的收获:因为粘之前必须先看清编辑器认什么、不认什么,我反过来把稿件的写法也规范了。现在写稿的时候就按「编辑器能吃的样子」去写,少用华丽格式,多用朴素的小标题和列表。工具反过来改掉了我的坏习惯。

最后说一句心里话。我上了年纪,在家时间多,电脑水平一般,很多东西是边试边学。这套东西没有任何高深的地方,就是把「复制粘贴」这四个字拆开研究了一遍:原来复制出去的不只是字,粘贴进来的也可以是带结构的整篇内容。想明白这一层,后面就是体力活了。

要是你也在为排版发愁,别急着找插件、找网站。先花半小时把剪贴板的原理搞清楚,再用几十行 Python 把它串起来。一次写对,后面每天省四小时。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 一、先说清楚问题出在哪
  • 二、我试过的三条歪路
  • 三、正解的关键:剪贴板里不只能放文本
  • 四、第一步:把 Markdown 转成编辑器认的 HTML
  • 五、第二步:把 HTML 写进剪贴板
  • 六、第三步:粘进去,然后读回来验
  • 七、编辑器到底认哪些标签:实测清单
  • 八、避坑清单
  • 九、这样做下来,省了多少
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档