Skip to content
前端

Markdown 拆小红书卡片的 5 个坑:=== 本来是一级标题,分页符还漏进了公众号

md-multi-format 小红书卡片拆分的边界:=== 与 Setext 标题冲突、一个分页符关掉整篇自动分页、分页符漏进公众号知乎掘金输出、孤行标题、列表续行被拆成代码块。

涛哥2026-11-045 分钟阅读

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 切成块(段落、标题、代码块各算一块),再一块一块往卡片里装:

js
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 风格的一级标题:

markdown
第一部分
===

等价于 # 第一部分。很多人从老博客、老笔记里搬过来的文章就是这么写标题的。

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 --> 不受影响。

js
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>&lt;!-- card --&gt;</p><p>第三张</p>
知乎:  <p>第一张的内容</p><p>===</p><p>第二张</p><p>&lt;!-- card --&gt;</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 的写法是缩进:

markdown
1. 第一步

    补充说明:这一行缩进 4 格,属于第一步

2. 第二步

blocks() 按空行切,这三行就成了三个块。如果正好在"第一步"后面超重,续行就被拆到了下一张:

卡片:["## 步骤\n\n<180字>\n\n1. 第一步", "    补充说明:这一行缩进 4 格,属于第一步\n\n2. 第二步"]
第 2 张渲染:<pre><code>补充说明:这一行缩进&nbsp;4&nbsp;格,属于第一步</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 里"块"的边界不只有空行,缩进和上一行都会影响后面一行的含义。

补丁目前只在我的本地副本里跑过,仓库里还是原来的代码。

仓库:https://github.com/MsNan/md-multi-format

最后更新: