Rust in Everything
基于 pulldown-cmark 0.13.5

原价$5,现价$3 变成了公式

主线位置:行内主线。第一遍扫到 $ 时只看它前后各一个字节,记成可开、可闭的 MaybeMath;第二遍按花括号层级配对,配上的一段变成 Event::InlineMath(数学与智能标点)。

flowchart TB
  d1["价$5 的 $"] --> o["后面是 5 能开"]
  d2["价$3 的 $"] --> c["前面是 价 能闭"]
  o --> pair["配成一对"]
  c --> pair
  pair --> m["InlineMath 5,现价"]
  d3["空格加 $"] --> nc["前面是空白 不能闭"]

实验在 exp-pulldown-cmark/pita-lab,bin p10_dollar(release),输出存在 out-p10.txt;main 的对照在 exp-pulldown-cmark/pita-mainprobe(origin/main,29d6cfa),输出 out-p10-main.txt。M2 Max、macOS 27、rustc 1.99.0,pulldown-cmark 0.13.5,选项 ENABLE_MATH。

现象

为了在少数页面里写公式,全站打开了 ENABLE_MATH。几种写价格的方式(每段先列事件,略去段落的 Start / End,再列 push_html 的输出):

--- $5 / $10
  0..1 Text(Borrowed("$"))
  1..5 Text(Borrowed("5 / "))
  5..6 Text(Borrowed("$"))
  6..8 Text(Borrowed("10"))
  <p>$5 / $10</p>
--- $5/$10
  0..4 InlineMath(Borrowed("5/"))
  4..6 Text(Borrowed("10"))
  <p><span class="math math-inline">5/</span>10</p>
--- 原价$5,现价$3
  0..6 Text(Borrowed("原价"))
  6..18 InlineMath(Borrowed("5,现价"))
  18..19 Text(Borrowed("3"))
  <p>原价<span class="math math-inline">5,现价</span>3</p>
--- 原价 $5,现价 $3
  0..7 Text(Borrowed("原价 "))
  7..8 Text(Borrowed("$"))
  8..19 Text(Borrowed("5,现价 "))
  19..20 Text(Borrowed("$"))
  20..21 Text(Borrowed("3"))
  <p>原价 $5,现价 $3</p>
--- 原价\$5,现价\$3
  0..6 Text(Borrowed("原价"))
  7..18 Text(Borrowed("$5,现价"))
  19..21 Text(Borrowed("$3"))
  <p>原价$5,现价$3</p>
--- 公式 $x^2$ 与 $y$
  0..7 Text(Borrowed("公式 "))
  7..12 InlineMath(Borrowed("x^2"))
  12..17 Text(Borrowed(" 与 "))
  17..20 InlineMath(Borrowed("y"))
  <p>公式 <span class="math math-inline">x^2</span> 与 <span class="math math-inline">y</span></p>

原价$5,现价$3 里,5,现价 成了一段公式,页面上的 KaTeX 之类会把它排成斜体数学;$5/$10 也被吃掉一半。带空格的 $5 / $10 和 原价 $5,现价 $3 没事。

原因

第一遍 parse_line 的 $ 分支(第 958 行):

                b'$' => {
                    let byte_suffix = &bytes[ix..];
                    let can_open = !byte_suffix[1..]
                        .first()
                        .copied()
                        .map_or(true, is_ascii_whitespace);
                    let can_close = ix > start
                        && !bytes[..ix]
                            .last()
                            .copied()
                            .map_or(true, is_ascii_whitespace);

后面不是 ASCII 空白就能开,前面不是 ASCII 空白就能闭,不看是不是数字、是不是汉字。英文习惯在 $ 前面留空格,第二个 $ 前面是空白,闭不上,所以 $5 / $10 平安无事;中文不留空格,现价$3 的 $ 前面是 价,能闭,和 原价$5 的 $ 配成一对。$5/$10 的第二个 $ 前面是 /,同理。规则写在 specs/math.txt,它说语法参照 commonmark-hs 的 math 扩展;配对和花括号上下文见数学与智能标点。

main 上已经改了。提交 3d67fea「math: do not allow closer followed by digit」在 can_close 上加了一个条件 !bytes.get(ix + 1).copied().map_or(false, is_ascii_numeric),提交说明第一行说这是 commonmark-hs(specs/math.txt 参照的那个实现)里同一项改动的移植,接着写:

With this change, closing `$` cannot be followed by a decimal digit.
For example, `$1$` is fine, but `$1$2` is not.

它随 PR #1098 合入 main,没有进任何 tag。用 main 跑:

$5/$10  <p>$5/$10</p>
原价$5,现价$3  <p>原价$5,现价$3</p>
运费$8元,满$99包邮  <p>运费$8元,满$99包邮</p>
公式 $x^2$ 与 $y$  <p>公式 <span class="math math-inline">x^2</span> 与 <span class="math math-inline">y</span></p>

「闭合的 $ 后面不能紧跟数字」恰好覆盖了「$ 写在数字前面」的价格写法。0.13.5 里没有这条。

修法

  • 内容里转义:写成 原价\$5,现价\$3,输出就是原文。适合自己能改的内容。

  • 只在需要的地方开 ENABLE_MATH:公式只出现在少数文档里,就按文档(比如 frontmatter 里的一个开关)决定开不开,不要全站打开。

  • 渲染时把像正文的公式还原:用户写的内容没法要求转义,就在事件流上判断。公式里出现汉字或全角标点,几乎一定是两个价格之间的正文,把它还原成带 $ 的文字:

    fn looks_like_prose(m: &str) -> bool {
      m.chars().any(|c| ('\u{3000}'..='\u{9fff}').contains(&c) || ('\u{ff00}'..='\u{ffef}').contains(&c))
    }
    
    // Put inline math that looks like prose back as the text it came from.
    fn unmath<'a>(events: impl Iterator<Item = Event<'a>>) -> impl Iterator<Item = Event<'a>> {
      events.map(|ev| match ev {
        Event::InlineMath(m) if looks_like_prose(&m) => Event::Text(format!("${m}$").into()),
        ev => ev,
      })
    }
    
    <p>原价$5,现价$3</p>
    <p>运费$8元,满$99包邮</p>
    <p>公式 <span class="math math-inline">x^2</span> 与 <span class="math math-inline">y</span></p>
    

    InlineMath 只来自单个 $,内容是原样交出的,所以 $ 加内容加 $ 就是源文。代价是公式里本来就写汉字的($\text{速度}$)也会被还原;这种公式要求作者用 $$ 或者转义。$5/$10 这样内容里没有汉字的,这个判断抓不到,要靠内容转义。

怎么检查

对内容扫一遍 InlineMath,把含汉字或全角标点的列出来:

for (ev, r) in Parser::new_ext(doc, Options::ENABLE_MATH).into_offset_iter() {
  if let Event::InlineMath(m) = ev {
    if looks_like_prose(&m) {
      println!("{r:?} InlineMath({m:?})");
    }
  }
}

对 原价$5,现价$3。、公式 $x^2$ 照常。、运费$8元,满$99包邮 三段:

6..18 InlineMath(Borrowed("5,现价"))
54..66 InlineMath(Borrowed("8元,满"))

正常的公式不会被报出来。另外在内容里搜「$ 紧跟数字」的写法(正则 \$[0-9]),数一下有多少,决定是转义还是关掉 ENABLE_MATH。

小结

  • ENABLE_MATH 下,$ 后面不是空白就能开、前面不是空白就能闭;中文不加空格,原价$5,现价$3 中间的文字成了 InlineMath,$5/$10 也会;$5 / $10 不会。
  • main 上的 #1098 规定闭合的 $ 后面不能紧跟数字,修好了这类价格,还没有发布。
  • 修法:内容里写 \$;只在需要公式的文档开 ENABLE_MATH;渲染用户内容时把含汉字的 InlineMath 还原成文字。

💬 关联讨论

发起讨论
加载中...