Rust in Everything
基于 pulldown-cmark 0.13.5

**结论。**正文 原样显示星号

主线位置:行内主线。第一遍为每一串 * 算好「能开」「能闭」,第二趟用分隔符栈配对(强调与分隔符栈)。闭合的那串在第一遍就被判成「不能闭」,第二趟无从配起。

flowchart TB
  s["结论。 星星 正文"] --> prev["前一字符是标点"]
  prev --> next{"后一字符"}
  next -->|"空白或标点"| close["能闭 强调成立"]
  next -->|"汉字"| no["不能闭"]
  no --> lit["星号原样输出"]

实验在 exp-pulldown-cmark/pita-lab,bin p09_cjk(release),输出存在 out-p09.txt;对照的 comrak 0.59、markdown-rs 1.0 都用默认设置。main 的对照在 exp-pulldown-cmark/pita-mainprobe(origin/main,29d6cfa),输出 out-p09-main.txt。M2 Max、macOS 27、rustc 1.99.0,pulldown-cmark 0.13.5,Options::empty()。

现象

中文里常见的写法是把句末标点也加粗。三个库对七种写法的输出:

--- **结论。**正文
pulldown: <p>**结论。**正文</p>
comrak:   <p>**结论。**正文</p>
mdrs:     <p>**结论。**正文</p>
--- **“重点”**正文
pulldown: <p>**“重点”**正文</p>
comrak:   <p>**“重点”**正文</p>
mdrs:     <p>**“重点”**正文</p>
--- **结论**。正文
pulldown: <p><strong>结论</strong>。正文</p>
comrak:   <p><strong>结论</strong>。正文</p>
mdrs:     <p><strong>结论</strong>。正文</p>
--- **结论。** 正文
pulldown: <p><strong>结论。</strong> 正文</p>
comrak:   <p><strong>结论。</strong> 正文</p>
mdrs:     <p><strong>结论。</strong> 正文</p>
--- **结论:**正文
pulldown: <p>**结论:**正文</p>
comrak:   <p>**结论:**正文</p>
mdrs:     <p>**结论:**正文</p>
--- 前文**结论**正文
pulldown: <p>前文<strong>结论</strong>正文</p>
comrak:   <p>前文<strong>结论</strong>正文</p>
mdrs:     <p>前文<strong>结论</strong>正文</p>
--- **Rust**是
pulldown: <p><strong>Rust</strong>是</p>
comrak:   <p><strong>Rust</strong>是</p>
mdrs:     <p><strong>Rust</strong>是</p>

凡是闭合的 ** 前面是全角标点(句号、冒号、右引号)、后面紧跟汉字的,都没有加粗,四个星号原样出现在页面上。标点挪到外面,或者在 ** 后面加一个空格,就正常了。三个库结果完全一致。

原因

这是 CommonMark 的规则,不是哪个实现的错。闭合用的分隔符串必须是「右侧分隔符串」(right-flanking delimiter run):前面不是空白,并且要么前面不是标点,要么后面是空白或标点。。**正 的 ** 前面是标点 。,后面是 正,两个条件都不满足。

pulldown-cmark 里这条规则在 delim_run_can_close 的最后几行:

    let delim = suffix.bytes().next().unwrap();
    // `*` and `~~` can be intraword, `_` and `~` cannot
    if (delim == b'*' || (delim == b'~' && run_len > 1)) && !is_punctuation(prev_char) {
        return true;
    }
    if delim == b'~' && prev_char == '~' {
        return true;
    }

    next_char.is_whitespace() || is_punctuation(next_char)
}

is_punctuation 查的是 Unicode 的 P、S 类(puncttable.rs),全角的 。、:、” 都在里面;正 不是空白也不是标点,函数返回 false。开头那串 ** 后面是 结,能开,于是压栈;直到段落结束也等不到能闭的,pop_all 把它退回成文字。第一遍的树和配对过程见强调与分隔符栈。

这条规则假定词与词之间有空白。中文不用空格分词,句末又常有全角标点,「标点紧挨着星号、星号紧挨着下一个字」就成了常见写法。

main 上已经合入了 PR #1059 的 ENABLE_CJK_FRIENDLY_EMPHASIS(1 << 18),文档注释说它实现「CommonMark CJK-friendly emphasis heuristics」,放宽中日韩字符旁边的左右侧判定。用 main 跑:

**结论。**正文  empty        <p>**结论。**正文</p>
**结论。**正文  CJK_FRIENDLY <p><strong>结论。</strong>正文</p>
**“重点”**正文  empty        <p>**“重点”**正文</p>
**“重点”**正文  CJK_FRIENDLY <p><strong>“重点”</strong>正文</p>
**结论:**正文  empty        <p>**结论:**正文</p>
**结论:**正文  CJK_FRIENDLY <p><strong>结论:</strong>正文</p>

这一位还没有进任何 tag,0.13.5 里没有。

修法

0.13.5 上只能改写法:

  • 标点放到星号外面:**结论**。正文。
  • 星号后面加空格:**结论。** 正文。中文排版里多出一个空格,不一定能接受。
  • 引号放到星号外面:“**重点**”正文 三个库都输出 “<strong>重点</strong>”正文,**“重点”**正文 不行。
  • 用行内 HTML <strong>结论。</strong>正文 也能绕过,但渲染用户输入时如果把原始 HTML 降成了文字(push_html 原样输出 <script>),这条路就不通。

等 ENABLE_CJK_FRIENDLY_EMPHASIS 进了正式版本,打开它是最省事的;在那之前,不要为了它改依赖 git 上的 main:main 在为下一个大版本做改动,和 0.13 分支已经分开了 262 个提交。

怎么检查

渲染之后扫一遍文本事件里残留的 **。先合并文字,否则 ** 可能被切在两个 Text 里;代码块里的不算,行内代码是 Event::Code,本来就不在 Text 里:

fn leftover_stars(doc: &str) {
  let mut in_code = false;
  let events = TextMergeWithOffset::new(Parser::new(doc).into_offset_iter());
  for (ev, r) in events {
    match ev {
      Event::Start(Tag::CodeBlock(_)) => in_code = true,
      Event::End(TagEnd::CodeBlock) => in_code = false,
      Event::Text(t) if !in_code && t.contains("**") => {
        let line = doc[..r.start].matches('\n').count() + 1;
        println!("line {line}: {t:?}");
      }
      _ => {}
    }
  }
}

对一份含标题、两处这种写法和一个代码块(里面有字符串 "**")的文档:

line 3: Boxed("**结论。**正文")
line 9: Boxed("第二段**注意:**这里")

代码块里的 "**" 没有被报出来。把这个检查放进内容仓库的 CI,比上线后肉眼找可靠;文档本来就要显示星号的地方(比如讲 Markdown 语法),用反斜杠转义或放进代码里。

小结

  • 闭合的 ** 前面是全角标点、后面紧跟汉字时,按 CommonMark 规则不能闭,强调不成立,星号原样输出;三个库一致。
  • 0.13.5 里只能改写法:标点挪到星号外,或在星号后加空格;引号要整体放在星号外面。
  • main 上的 ENABLE_CJK_FRIENDLY_EMPHASIS(#1059)解决这一类写法,还没有发布。
  • 用合并后的文本事件扫残留的 **,放进内容的 CI。

💬 关联讨论

发起讨论
加载中...