md-multi-format 是我写的一个"一份 Markdown 转多个平台"的小工具:公众号、知乎、掘金/CSDN 各出一份,另外还能把文章拆成小红书的 3:4 图文卡片导出成图片。前面几篇写的都是公众号那条线(内联样式、代码高亮),这次看的是卡片拆分。
起因是重读 cards.js 时注意到,手动分页符用的是单独一行 ===,而这个写法在 Markdown 里本来就有含义。顺着这个写了个脚本把边界情况挨个试了一遍,拆分逻辑里一共翻出 5 个问题,另外还有 2 个是估算方式本身的局限,改不了,文末一起说。
下面的输出都是在仓库当前代码上用脚本跑出来的(Node 24.22.1,markdown-it 15.0.3)。
原来的拆分逻辑
src/core/cards.js 里先把 Markdown 切成块(段落、标题、代码块各算一块),再一块一块往卡片里装:
export const CARD_BREAK = /^\s*(<!--\s*card\s*-->|===)\s*$/
function blocks(md) {
// ...逐行扫描:代码块整块保留;遇到 CARD_BREAK 记一个分页标记;空行结束一个块;标题单独成块
if (CARD_BREAK.test(l)) { flush(); out.push('\u0000BREAK'); continue }
}
export function weight(b) {
if (/^\s*(```|~~~)/.test(b)) return b.split('\n').length * 22 // 代码块按行
if (/!\[[^\]]*\]\([^)]+\)/.test(b)) return 220 // 图片
if (/^#{1,2}\s/.test(b)) return 40 // 一二级标题
return b.replace(/\s/g, '').length + b.split('\n').length * 8 // 正文按字数
}
export function splitCards(markdown, { maxWeight = 260, coverFromH1 = true } = {}) {
const bs = blocks(markdown.replace(/^---\n[\s\S]*?\n---\n?/, ''))
const manual = bs.includes('\u0000BREAK')
// ...
for (const b of bs) {
if (b === '\u0000BREAK') { push(); continue }
// 第一个 # 标题做封面
if (!manual) {
const bw = weight(b)
if (/^##\s/.test(b) && cur.length) push() // 遇到 ## 换页
else if (cur.length && w + bw > maxWeight) push() // 超重换页
w += bw
}
cur.push(b)
}
}规则是:写了 === 或 <!-- card --> 就按手动分页;没写就按 ## 和重量自动分页。思路没问题,问题都在边界上。
1. === 本身是合法的 Markdown 语法
CommonMark 里,一行文字下面紧跟一行 ===,是 Setext 风格的一级标题:
第一部分
===等价于 # 第一部分。很多人从老博客、老笔记里搬过来的文章就是这么写标题的。
CARD_BREAK 只看这一行本身,不看上一行,于是这个标题被拆成了"一行普通文字 + 一个分页符":
输入:'# 封面\n\n第一部分\n===\n\n内容 A\n\n## 小节\n\n内容 B'
卡片:["第一部分", "内容 A\n\n## 小节\n\n内容 B"]
同一段在公众号输出里:<h1>封面</h1><h1>第一部分</h1><p>内容 A</p><h2>小节</h2><p>内容 B</p>同一份 Markdown,公众号那边当一级标题渲染,卡片这边当分页符。更麻烦的是,因为文章里"出现了分页符",整篇进入手动模式,后面的 ## 小节 也不再换页了。最后的效果是:第一张卡片上只有孤零零一行"第一部分",剩下的全挤在第二张。
改法是:=== 只有在上一行是空行(或者是第一行)时才算分页符,<!-- card --> 不受影响。
const isBreak = (l, prev) => CARD_BREAK.test(l) && (/<!--/.test(l) || prev === undefined || prev.trim() === '')补丁后:
卡片:["第一部分\n===\n\n内容 A", "## 小节\n\n内容 B"]这个改动有一点行为变化:以前紧贴在文字下面写 === 想分页的人,现在会得到一个一级标题。我觉得这比反过来好,因为这种写法在其他所有平台上本来就是标题,卡片这边跟着 Markdown 走才一致。README 里写的是"单独一行写 ===",补丁把注释也补成了"上一行须为空行"。
2. 写一个 ===,整篇的自动分页就全关了
manual 是全文级别的开关:只要出现一个分页标记,所有块都不再计算重量。我的本意是"这里我想手动断一下",结果变成了"这篇文章我全部手动分页"。
测试数据:封面 + 三节,每节一个 ## 加两段 150 字的正文(每段重量 158)。
不写 ===: 4 张,每张重量 [198, 158, 198, 158]
中间写一个 ===:2 张,每张重量 [356, 712]只多写了一个 ===,第二张卡片的重量就到了 712,差不多是上限 260 的 2.7 倍。组件那边会在内容高度超出卡片时提示"⚠️ 内容超出,建议用 === 手动分页",可这时用户已经写过 === 了。
改法是手动模式只关掉"遇到 ## 换页"这一条,超重换页仍然保留:手动分隔符决定"这里必须断",重量决定"放不下了也要断"。补丁后同样的输入是 6 张,每张重量 [198, 158, 198, 158, 198, 158]。
仓库里原来有一个测试"手动分隔符优先",断言 c\n\n## d 同页,也就是手动模式下 ## 不换页,这个行为我保留了,测试照样通过。
3. 分页符漏到了其他平台的输出里
卡片专用的分页符,公众号、知乎、掘金这几条输出线是不认识的:
输入:'第一张的内容\n\n===\n\n第二张\n\n<!-- card -->\n\n第三张'
公众号:<p>第一张的内容</p><p>===</p><p>第二张</p><p><!-- card --></p><p>第三张</p>
知乎: <p>第一张的内容</p><p>===</p><p>第二张</p><p><!-- card --></p><p>第三张</p>
掘金/CSDN Markdown:"第一张的内容\n\n===\n\n第二张\n\n<!-- card -->\n\n第三张\n"=== 前面有空行,所以不是标题,就是一个内容为 === 的段落。<!-- card --> 本来是 HTML 注释,想着"在别的平台上反正看不见",但这个项目的 markdown-it 配的是 html: false(不放行原始 HTML),注释被转义成了文字,直接显示在正文里。
补丁加了一个 stripCardBreaks(),用的是和拆分同一套判断(同样认得代码块,代码块里的 === 不动),在 toWechat、toZhihu、toPreview 和 cleanMarkdown 前面先过一遍。补丁后三路输出里都没有分页符了。
4. 小标题留在卡片最底下
超重换页的判断是"加上这一块会超重就换页",标题本身只有 40(三级以下标题按正文算,更轻),很容易被装进上一张的末尾,而它下面的正文去了下一张:
输入:## 准备 / 190 字 / ### 第一步 / 120 字
卡片:["## 准备\n\n<190字>\n\n### 第一步", "<120字>"]排版上这叫孤行标题,读起来很别扭。补丁让"因超重换页"时,把当前卡片末尾的标题一起带到下一张:
卡片:["## 准备\n\n<190字>", "### 第一步\n\n<120字>"]只在超重换页时带,手动分页时不带。一开始我两种情况都带,结果把上面那个"手动分隔符优先"的测试弄挂了,因为用户手动断在标题后面是有意的。
5. 列表项的续行被拆到下一张,变成了代码块
列表项里如果有空行隔开的补充说明,Markdown 的写法是缩进:
1. 第一步
补充说明:这一行缩进 4 格,属于第一步
2. 第二步blocks() 按空行切,这三行就成了三个块。如果正好在"第一步"后面超重,续行就被拆到了下一张:
卡片:["## 步骤\n\n<180字>\n\n1. 第一步", " 补充说明:这一行缩进 4 格,属于第一步\n\n2. 第二步"]
第 2 张渲染:<pre><code>补充说明:这一行缩进 4 格,属于第一步</code></pre><ol start="2"><li>第二步</li></ol>单独拿出来以后,缩进 4 格的一行就是缩进代码块了,卡片上出现一个灰底的代码框。补丁里,缩进两格以上、又不是代码围栏的块,不再和上一块拆开:
第 1 张渲染:<h2>步骤</h2><p><180字></p><ol><li><p>第一步</p><p>补充说明:这一行缩进 4 格,属于第一步</p></li></ol>
第 2 张渲染:<ol start="2"><li>第二步</li></ol>代价是第一张会超重一点。我的取舍是宁可稍微超一点,也不要把一个列表项拆成两半。
没改的两处
重量按字符数算,不分中英文。 80 个汉字重量 88,160 个英文字符(含空格)重量 143。半角字母大约只有汉字一半宽,所以英文内容的重量会被高估,卡片会比实际需要的拆得更碎。要准确得按字宽算,或者干脆渲染出来量高度,我还没量过实际像素,这里先不动。
单块放不下就是放不下。 一个 400 字的段落重量 408,一个 20 行的代码块重量 484,都超过了上限 260,但拆分只在块与块之间进行,不会把一个段落或一个代码块从中间切开。组件会给出"内容超出"的提示,这时只能自己把段落拆短,或者代码块删几行。
测试
补丁改了 cards.js、clean.js、render.js 三个文件,在 test/core.test.js 里加了 4 个用例:Setext 标题不当分页符、手动分页时超重仍续页、标题跟着正文走和缩进续行不拆、分页符不出现在其他平台输出里且代码块里的保留。原来 33 个测试,补丁后 37 个,全部通过。
回头看,这几个问题都是"写规则的时候只想着卡片"。同一份 Markdown 要同时喂给好几个渲染器,自己加的语法最好先确认它在标准 Markdown 里是不是已经有意思了;切块的时候也要记得,Markdown 里"块"的边界不只有空行,缩进和上一行都会影响后面一行的含义。
补丁目前只在我的本地副本里跑过,仓库里还是原来的代码。