**结论。**正文 原样显示星号
主线位置:行内主线。第一遍为每一串
*算好「能开」「能闭」,第二趟用分隔符栈配对(强调与分隔符栈)。闭合的那串在第一遍就被判成「不能闭」,第二趟无从配起。
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。