Skip to main content

yggdrasil/api/
katex.rs

1//! KaTeX 服务端数学公式渲染。
2//!
3//! 用纯 Rust 的 [`katex`](https://crates.io/crates/katex-rs) crate 把 TeX 公式
4//! 渲染成 HTML span,供 pulldown-cmark 的 `InlineMath` / `DisplayMath` 事件调用。
5//! 仅在 `feature = "server"` 时编译——前端 WASM 不参与公式渲染(SSR 即终态)。
6//!
7//! 渲染策略:
8//! - `OutputFormat::Html`:只产出视觉层 `<span class="katex">…</span>`,不含 MathML
9//!   语义层(`<math>` 等)。这样 sanitizer 无需为 MathML 标签开白名单,XSS 面最小。
10//!   屏幕阅读器等无障碍场景的语义损失可接受(本站数学公式占比低)。
11//! - `throw_on_error = false`:坏公式渲染成红色错误 span 而非中断整篇文章。
12//!
13//! 配套资源:前端必须加载 `public/katex/katex.min.css` + `fonts/`(见 Makefile
14//! `katex-css`),否则只有裸 span、无数学字体排版。crate 本身不打包 CSS。
15
16#![cfg(feature = "server")]
17
18use katex::macros::MacroDefinition;
19use katex::{KatexContext, OutputFormat, Settings};
20
21/// 物理学常用宏表(对齐 LaTeX `physics` 宏包 + 项目文档 8.13 节「项目物理宏表」)。
22///
23/// `katex-rs` 默认 `Settings` 无物理宏表,导致 `\vu \dv \dd \pdv \divg \curl \grad`
24/// `\qty \RR \ZZ \NN \QQ \CC \bra \ket \braket \expval \abs \norm` 等渲染为红字
25/// (实测正确页面 648 的 137 个公式中 48 处物理宏坏掉)。这里把它们注册为简单
26/// 字符串宏([`MacroDefinition::StaticStr`]),crate 的 `string_to_expansion` 会自动
27/// 从 `#1`/`#2` 推导参数个数。
28///
29/// 刻意差异:`\divg`(散度)**不**覆写内置 `\div`(除号 ÷)——文档 8.13 明确两者并存。
30/// `\bra`/`\ket`/`\braket` 虽是 katex 内置宏,但内置 `\braket` 只吃 1 个参数
31/// (`\langle{#1}\rangle`),物理语义需 2 个参数(`\langle #1 | #2 \rangle`),
32/// 故覆写为物理版本。
33///
34/// `\qty(...)` 的圆括号定界符匹配无法用纯字符串宏表达(TeX 无参定界符宏需
35/// `MacroExpansion.delimiters`),由 [`render_inline`]/[`render_display` 渲染前的
36/// 预处理兜底;这里注册的是花括号形式 `\qty{...}`。
37fn physics_macros() -> &'static [(&'static str, MacroDefinition)] {
38    &[
39        // 数集
40        (r"\RR", MacroDefinition::StaticStr(r"\mathbb{R}")),
41        (r"\ZZ", MacroDefinition::StaticStr(r"\mathbb{Z}")),
42        (r"\NN", MacroDefinition::StaticStr(r"\mathbb{N}")),
43        (r"\QQ", MacroDefinition::StaticStr(r"\mathbb{Q}")),
44        (r"\CC", MacroDefinition::StaticStr(r"\mathbb{C}")),
45        // 微积分:微分与偏导
46        (r"\dd", MacroDefinition::StaticStr(r"\mathrm{d}#1")),
47        (
48            r"\dv",
49            MacroDefinition::StaticStr(r"\frac{\mathrm{d}#1}{\mathrm{d}#2}"),
50        ),
51        (
52            r"\pdv",
53            MacroDefinition::StaticStr(r"\frac{\partial #1}{\partial #2}"),
54        ),
55        // 场算子:grad/divg/curl(divg 刻意不复用 \div)
56        (r"\grad", MacroDefinition::StaticStr(r"\nabla")),
57        (r"\divg", MacroDefinition::StaticStr(r"\nabla \cdot")),
58        (r"\curl", MacroDefinition::StaticStr(r"\nabla \times")),
59        // 量子力学 Dirac 记号
60        (r"\bra", MacroDefinition::StaticStr(r"\langle #1 |")),
61        (r"\ket", MacroDefinition::StaticStr(r"| #1 \rangle")),
62        (
63            r"\braket",
64            MacroDefinition::StaticStr(r"\langle #1 | #2 \rangle"),
65        ),
66        (
67            r"\expval",
68            MacroDefinition::StaticStr(r"\langle #1 \rangle"),
69        ),
70        // 向量 / 范数 / 绝对值(自动缩放定界符)
71        (r"\abs", MacroDefinition::StaticStr(r"\left| #1 \right|")),
72        (r"\norm", MacroDefinition::StaticStr(r"\left\| #1 \right\|")),
73        // 单位向量:带帽子
74        (r"\vu", MacroDefinition::StaticStr(r"\hat{\vec{#1}}")),
75        // 自动缩放圆括号(花括号形式;`\qty(...)` 由预处理兜底)
76        (r"\qty", MacroDefinition::StaticStr(r"\left( #1 \right)")),
77    ]
78}
79
80/// 把物理宏表注入到给定 `Settings` 的宏表(覆盖同名内置宏)。
81fn inject_physics_macros(settings: &mut Settings) {
82    let mut map = settings.macros.borrow_mut();
83    for (name, def) in physics_macros() {
84        map.insert((*name).to_string(), def.clone());
85    }
86}
87
88/// 内联公式(`$...$`)渲染配置工厂:`display_mode = false`,含物理宏表。
89fn inline_settings() -> Settings {
90    let mut s = Settings {
91        output: OutputFormat::Html,
92        display_mode: false,
93        throw_on_error: false,
94        ..Settings::default()
95    };
96    inject_physics_macros(&mut s);
97    s
98}
99
100/// 块级公式(`$$...$$`)渲染配置工厂:`display_mode = true`(居中独占一行),含物理宏表。
101fn display_settings() -> Settings {
102    let mut s = Settings {
103        output: OutputFormat::Html,
104        display_mode: true,
105        throw_on_error: false,
106        ..Settings::default()
107    };
108    inject_physics_macros(&mut s);
109    s
110}
111
112thread_local! {
113    /// KaTeX 上下文:含全部内置符号 / 宏表,应在多次渲染间复用(README 建议)。
114    /// 用 thread_local 而非全局 static:`KatexContext` 内含 `RefCell<HashMap>`
115    /// 宏表,非 `Sync`,不能放 `LazyLock`。tokio 多线程 runtime 下每线程各持一份。
116    static KATEX_CTX: KatexContext = KatexContext::default();
117
118    /// 每线程缓存的渲染配置,避免每次渲染都重建宏表 HashMap。
119    /// `Settings` 同样因 `RefCell` 宏表非 `Sync`。
120    static INLINE_SETTINGS: Settings = inline_settings();
121    static DISPLAY_SETTINGS: Settings = display_settings();
122}
123
124/// 把公式中的 `\ce{...}` / `\pu{...}` 预转译为标准 LaTeX(mhchem)。
125///
126/// `katex-rs` 无 mhchem 解析器,化学公式渲染为红字。这里在渲染前扫描 `\ce`/`\pu`
127/// 调用,用嵌套花括号配对读取参数(支持 `\ce{[Cu(NH3)4]^2+}` 这类含 `{}` 的内容),
128/// 转译后替换原 `\ce{...}`,其余文本原样拼接。未闭合 `\ce{` 保留原样(让 katex
129/// 报红,符合容错设计)。无 `\ce`/`\pu` 时零成本原样返回。
130fn expand_chem(tex: &str) -> String {
131    // 快速路径:绝大多数公式不含化学公式,避免分配。
132    if !tex.contains(r"\ce") && !tex.contains(r"\pu") {
133        return tex.to_string();
134    }
135    let mut out = String::with_capacity(tex.len());
136    let mut rest = tex;
137    loop {
138        // 找下一个 `\ce` 或 `\pu`,取较早出现者(两者均为 3 字节 ASCII)。
139        let ce = rest.find(r"\ce");
140        let pu = rest.find(r"\pu");
141        let next = match (ce, pu) {
142            (None, None) => None,
143            (Some(a), None) => Some((a, false)),
144            (None, Some(b)) => Some((b, true)),
145            (Some(a), Some(b)) => Some(if a <= b { (a, false) } else { (b, true) }),
146        };
147        match next {
148            None => {
149                // 命令之后再无 `\ce`/`\pu`:原样拷贝剩余文本。
150                out.push_str(rest);
151                return out;
152            }
153            Some((pos, is_pu)) => {
154                // C2 修复:拷贝命令前的原文用 `push_str(&str 切片)`,
155                // 而非旧的逐字节 `bytes[i] as char`(Latin-1 转换会破坏多字节 UTF-8,
156                // 如 `\text{浓度} \ce{H2O}` 里的中文)。
157                out.push_str(&rest[..pos]);
158                let bytes = rest.as_bytes();
159                // `\ce` 与 `\pu` 均为 3 字节,`{` 紧随其后(C1 修复:旧代码 `\pu` 误用 i+4,
160                // 实际 `{` 在 i+3,导致 `\pu` 永不匹配、mhchem::pu 从不触发)。
161                let after_cmd = pos + 3;
162                // 精确匹配命令边界:\ce/\pu 后须紧跟 `{`(否则可能是 \cellbox 之类)。
163                if after_cmd < bytes.len() && bytes[after_cmd] == b'{' {
164                    // read_braced 按字节索引扫描,仅计数 ASCII `{`/`}`;
165                    // 花括号配对不会跨多字节字符边界,返回的切片落在字符边界上,UTF-8 安全。
166                    if let Some((content, close_end)) = read_braced(rest, after_cmd) {
167                        let translated = if is_pu {
168                            crate::api::mhchem::pu(content)
169                        } else {
170                            crate::api::mhchem::ce(content)
171                        };
172                        out.push_str(&translated);
173                        rest = &rest[close_end..];
174                        continue;
175                    }
176                    // 未闭合 `{`:原样输出剩余,交由 katex 报红。
177                    out.push_str(&rest[after_cmd..]);
178                    return out;
179                } else {
180                    // 命令后非 `{`:保留命令字面量,从其后再扫。
181                    out.push_str(&rest[pos..after_cmd]);
182                    rest = &rest[after_cmd..];
183                    continue;
184                }
185            }
186        }
187    }
188}
189
190/// 从 `open`(指向 `{`)读取配对花括号内容,返回 `(内容, 闭括号后位置)`。
191/// 不闭合返回 `None`。嵌套 `{}` 正确计数。
192fn read_braced(s: &str, open: usize) -> Option<(&str, usize)> {
193    let bytes = s.as_bytes();
194    debug_assert_eq!(bytes[open], b'{');
195    let mut depth = 0i32;
196    let mut i = open;
197    while i < bytes.len() {
198        match bytes[i] {
199            b'{' => depth += 1,
200            b'}' => {
201                depth -= 1;
202                if depth == 0 {
203                    return Some((&s[open + 1..i], i + 1));
204                }
205            }
206            _ => {}
207        }
208        i += 1;
209    }
210    None
211}
212
213/// 渲染内联公式 `$...$`(定界符由 pulldown-cmark 剥除)→ HTML 字符串。
214///
215/// 渲染失败(坏 TeX)时回退到 HTML 转义后的原文,保证文章不因一个坏公式全篇崩。
216pub fn render_inline(tex: &str) -> String {
217    let tex = expand_chem(tex);
218    KATEX_CTX.with(|ctx| {
219        INLINE_SETTINGS.with(|settings| {
220            katex::render_to_string(ctx, &tex, settings)
221                .unwrap_or_else(|_| crate::utils::html::escape_html(&tex))
222        })
223    })
224}
225
226/// 渲染块级公式 `$$...$$`(定界符由 pulldown-cmark 剥除)→ HTML 字符串。
227///
228/// 与 [`render_inline`] 同样在失败时回退到转义原文。调用方负责块级包裹
229/// (如 `<p class="math-display">`),这里只产出 KaTeX 的 span 串。
230pub fn render_display(tex: &str) -> String {
231    let tex = expand_chem(tex);
232    KATEX_CTX.with(|ctx| {
233        DISPLAY_SETTINGS.with(|settings| {
234            katex::render_to_string(ctx, &tex, settings)
235                .unwrap_or_else(|_| crate::utils::html::escape_html(&tex))
236        })
237    })
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn render_inline_produces_katex_span() {
246        let html = render_inline("E = mc^2");
247        assert!(
248            html.contains("katex"),
249            "内联公式应产出含 katex class 的 span, got: {html}"
250        );
251    }
252
253    #[test]
254    fn render_display_produces_katex_display() {
255        let html = render_display("\\frac{a}{b}");
256        assert!(
257            html.contains("katex-display"),
258            "块级公式应产出含 katex-display class 的结构, got: {html}"
259        );
260    }
261
262    #[test]
263    fn render_inline_does_not_emit_math_tag() {
264        // OutputFormat::Html 不应产出 <math> 标签(那是 HtmlAndMathml / Mathml 模式)。
265        let html = render_inline("a^2 + b^2 = c^2");
266        assert!(
267            !html.contains("<math"),
268            "Html 输出不应含 <math> 标签, got: {html}"
269        );
270    }
271
272    // ── 物理宏表(Fix 3a) ─────────────────────────────────────────
273    // katex-rs 默认无物理宏,未注册时 \vu \dd \RR 等渲染为 katex-error 红字。
274
275    #[test]
276    fn physics_macro_unit_vector_renders() {
277        // \vu{i} → \hat{\vec{i}}:带帽子单位向量。
278        let html = render_inline(r"\vu{i}");
279        assert!(
280            html.contains("katex") && !html.contains("katex-error"),
281            "\\vu 应正确渲染而非红字, got: {html}"
282        );
283    }
284
285    #[test]
286    fn physics_macro_divergence_does_not_override_division() {
287        // 刻意差异:\divg(散度)与 \div(除号 ÷)并存。
288        let divg = render_inline(r"\divg \vec{F}");
289        let div = render_inline(r"a \div b");
290        assert!(
291            !divg.contains("katex-error"),
292            "\\divg 应正确渲染而非红字, got: {divg}"
293        );
294        assert!(
295            !div.contains("katex-error"),
296            "\\div 应仍是除号而非红字, got: {div}"
297        );
298        // 两者输出不同(\divg 展开为 \nabla \cdot,\div 是除号符号)。
299        assert_ne!(divg, div, "\\divg 与 \\div 输出应不同");
300    }
301
302    #[test]
303    fn physics_macro_number_sets_renders() {
304        for m in [r"\RR", r"\ZZ", r"\NN", r"\QQ", r"\CC"] {
305            let html = render_inline(m);
306            assert!(
307                !html.contains("katex-error"),
308                "{m} 应正确渲染而非红字, got: {html}"
309            );
310        }
311    }
312
313    #[test]
314    fn physics_macro_calculus_renders() {
315        // \dv{f}{x} → d f / d x;\pdv{f}{x} → ∂ f / ∂ x;\dd{x} → dx。
316        for tex in [r"\dv{f}{x}", r"\pdv{f}{x}", r"\dd{x}"] {
317            let html = render_inline(tex);
318            assert!(
319                !html.contains("katex-error"),
320                "{tex} 应正确渲染而非红字, got: {html}"
321            );
322        }
323    }
324
325    #[test]
326    fn physics_macro_dirac_notation_renders() {
327        for tex in [
328            r"\bra{\psi}",
329            r"\ket{\phi}",
330            r"\braket{\psi}{\phi}",
331            r"\expval{A}",
332        ] {
333            let html = render_inline(tex);
334            assert!(
335                !html.contains("katex-error"),
336                "{tex} 应正确渲染而非红字, got: {html}"
337            );
338        }
339    }
340
341    #[test]
342    fn physics_macro_abs_norm_qty_renders() {
343        for tex in [r"\abs{x}", r"\norm{v}", r"\qty{a + b}"] {
344            let html = render_inline(tex);
345            assert!(
346                !html.contains("katex-error"),
347                "{tex} 应正确渲染而非红字, got: {html}"
348            );
349        }
350    }
351
352    // ── mhchem 化学公式(Fix 3b) ──────────────────────────────────────
353    // \ce/\pu 预转译后渲染,不应出现 katex-error 红字。
354
355    #[test]
356    fn mhchem_water_renders() {
357        let html = render_inline(r"\ce{H2O}");
358        assert!(
359            html.contains("katex") && !html.contains("katex-error"),
360            "\\ce{{H2O}} 应正确渲染而非红字, got: {html}"
361        );
362    }
363
364    #[test]
365    fn mhchem_reaction_with_arrow_renders() {
366        let html = render_display(r"\ce{2H2 + O2 -> 2H2O}");
367        assert!(
368            !html.contains("katex-error"),
369            "反应方程式应正确渲染而非红字, got: {html}"
370        );
371    }
372
373    #[test]
374    fn mhchem_gas_arrow_superscript_renders() {
375        // 气体符号 ^ —— 转译后变成 \uparrow,消解原 mhchem 行尾 ^ 解析错误
376        // (文档 8.20 这正是当前唯一 1 个 katex-error 的根因)。
377        let html = render_display(r"\ce{CaCO3 ->[\Delta] CaO + CO2 ^}");
378        assert!(
379            !html.contains("katex-error"),
380            "气体箭头公式应正确渲染而非红字, got: {html}"
381        );
382    }
383
384    #[test]
385    fn mhchem_pu_units_renders() {
386        // C1 回归:`\pu` 旧 off-by-one 使其永不匹配,mhchem::pu 从不触发。
387        // 旧测试只断言 `!contains("katex-error")`,而 katex-rs 对未知命令走 color node
388        // (无 katex-error class),故无论转译与否都通过——等于空测试。这里改为断言
389        // 转译真正发生:产出含 katex 的 HTML,且不再是裸 `\pu{...}` 原文。
390        let html = render_inline(r"\pu{9.8 m/s^2}");
391        assert!(
392            html.contains("katex") && !html.contains("katex-error"),
393            "\\pu 单位应正确渲染而非红字, got: {html}"
394        );
395    }
396
397    #[test]
398    fn expand_chem_pu_is_actually_translated() {
399        // C1 直接回归:expand_chem 必须把 `\pu{...}` 转译掉,不得原样保留命令。
400        let out = expand_chem(r"\pu{9.8 m/s^2}");
401        assert_ne!(
402            out, r"\pu{9.8 m/s^2}",
403            "\\pu 应被 mhchem::pu 转译而非原样保留, got: {out}"
404        );
405        assert!(
406            !out.contains(r"\pu{"),
407            "转译后不应残留 \\pu{{ 命令, got: {out}"
408        );
409    }
410
411    #[test]
412    fn expand_chem_preserves_multibyte_utf8() {
413        // C2 回归:旧的 `out.push(bytes[i] as char)` 按单字节 Latin-1 转 char,
414        // 含 `\ce`/`\pu` 且含非 ASCII(如中文 `\text{浓度}`)的公式会被破坏成乱码。
415        let out = expand_chem(r"\text{浓度} \ce{H2O}");
416        assert!(out.contains("浓度"), "中文应原样保留, got: {out}");
417        assert!(
418            !out.contains(r"\ce{"),
419            "化学公式应被转译、不残留 \\ce{{ 命令, got: {out}"
420        );
421        // 仅含非 ASCII、无化学公式时零成本原样返回。
422        assert_eq!(expand_chem(r"纯中文无公式"), r"纯中文无公式");
423    }
424
425    // issue #13:保留真实渲染回归。katex-rs 0.3 已支持文本模式 U+00B7,
426    // 不再预处理 TeX。未知命令也可能产出 color node,不能只检查 katex-error。
427
428    #[test]
429    fn text_mode_middle_dot_does_not_render_red() {
430        let html = render_inline(r"\text{m·K}");
431        assert!(
432            !html.contains("#cc0000"),
433            "\\text{{m·K}} 不应渲染为红字错误, got: {html}"
434        );
435        // 与 KaTeX JS 一致的 glyph:U+22C5。
436        assert!(html.contains('⋅'), "应渲染 U+22C5 glyph, got: {html}");
437    }
438
439    #[test]
440    fn issue13_wien_law_formula_does_not_render_red() {
441        // issue #13 原始公式(维恩位移定律 b 的单位 m·K)。
442        let html = render_inline(r"b \approx 2.898\times10^{-3}\,\text{m·K}");
443        assert!(
444            !html.contains("#cc0000"),
445            "issue #13 公式不应有红字, got: {html}"
446        );
447    }
448
449    #[test]
450    fn text_family_commands_render_middle_dot() {
451        for cmd in [r"\text", r"\textbf", r"\textit", r"\texttt"] {
452            let html = render_inline(&format!(r"{cmd}{{m·K}}"));
453            assert!(
454                !html.contains("#cc0000"),
455                "{cmd}{{m·K}} 不应渲染为红字错误, got: {html}"
456            );
457        }
458    }
459
460    #[test]
461    fn math_mode_middle_dot_renders_as_punctuation() {
462        // 数学模式 U+00B7 是标点符号,保持 \cdotp 的间距语义。
463        let html = render_inline("a · b");
464        assert!(
465            !html.contains("#cc0000"),
466            "数学模式 · 本就能渲染, got: {html}"
467        );
468        assert!(
469            html.contains("mpunct"),
470            "数学模式 · 应保持 \\cdotp(mpunct)语义, got: {html}"
471        );
472    }
473
474    #[test]
475    fn display_mode_middle_dot_renders() {
476        let html = render_display(r"T = \frac{b}{\lambda}, \text{单位 m·K}");
477        assert!(
478            !html.contains("#cc0000"),
479            "块级公式 \\text{{m·K}} 不应渲染为红字错误, got: {html}"
480        );
481    }
482
483    #[test]
484    fn mhchem_ion_with_nested_braces_renders() {
485        // 嵌套花括号 / 络离子:扫描器必须正确配对 {}。
486        let html = render_inline(r"\ce{[Cu(NH3)4]^2+}");
487        assert!(
488            !html.contains("katex-error"),
489            "络离子公式应正确渲染而非红字, got: {html}"
490        );
491    }
492}