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_bad_tex_does_not_panic() {
264        // throw_on_error=false:坏 TeX 不应 panic、不应返回 Err。
265        // KaTeX 可能渲染成红色错误 span,也可能把未知宏当字面文本处理。
266        // 关键契约:返回非空字符串、不中断调用方。
267        let html = render_inline("\\thisisnotarealmacroxyz{");
268        assert!(!html.is_empty(), "坏公式应返回非空 HTML, got empty");
269    }
270
271    #[test]
272    fn render_inline_does_not_emit_math_tag() {
273        // OutputFormat::Html 不应产出 <math> 标签(那是 HtmlAndMathml / Mathml 模式)。
274        let html = render_inline("a^2 + b^2 = c^2");
275        assert!(
276            !html.contains("<math"),
277            "Html 输出不应含 <math> 标签, got: {html}"
278        );
279    }
280
281    // ── 物理宏表(Fix 3a) ─────────────────────────────────────────
282    // katex-rs 默认无物理宏,未注册时 \vu \dd \RR 等渲染为 katex-error 红字。
283
284    #[test]
285    fn physics_macro_unit_vector_renders() {
286        // \vu{i} → \hat{\vec{i}}:带帽子单位向量。
287        let html = render_inline(r"\vu{i}");
288        assert!(
289            html.contains("katex") && !html.contains("katex-error"),
290            "\\vu 应正确渲染而非红字, got: {html}"
291        );
292    }
293
294    #[test]
295    fn physics_macro_divergence_does_not_override_division() {
296        // 刻意差异:\divg(散度)与 \div(除号 ÷)并存。
297        let divg = render_inline(r"\divg \vec{F}");
298        let div = render_inline(r"a \div b");
299        assert!(
300            !divg.contains("katex-error"),
301            "\\divg 应正确渲染而非红字, got: {divg}"
302        );
303        assert!(
304            !div.contains("katex-error"),
305            "\\div 应仍是除号而非红字, got: {div}"
306        );
307        // 两者输出不同(\divg 展开为 \nabla \cdot,\div 是除号符号)。
308        assert_ne!(divg, div, "\\divg 与 \\div 输出应不同");
309    }
310
311    #[test]
312    fn physics_macro_number_sets_renders() {
313        for m in [r"\RR", r"\ZZ", r"\NN", r"\QQ", r"\CC"] {
314            let html = render_inline(m);
315            assert!(
316                !html.contains("katex-error"),
317                "{m} 应正确渲染而非红字, got: {html}"
318            );
319        }
320    }
321
322    #[test]
323    fn physics_macro_calculus_renders() {
324        // \dv{f}{x} → d f / d x;\pdv{f}{x} → ∂ f / ∂ x;\dd{x} → dx。
325        for tex in [r"\dv{f}{x}", r"\pdv{f}{x}", r"\dd{x}"] {
326            let html = render_inline(tex);
327            assert!(
328                !html.contains("katex-error"),
329                "{tex} 应正确渲染而非红字, got: {html}"
330            );
331        }
332    }
333
334    #[test]
335    fn physics_macro_dirac_notation_renders() {
336        for tex in [
337            r"\bra{\psi}",
338            r"\ket{\phi}",
339            r"\braket{\psi}{\phi}",
340            r"\expval{A}",
341        ] {
342            let html = render_inline(tex);
343            assert!(
344                !html.contains("katex-error"),
345                "{tex} 应正确渲染而非红字, got: {html}"
346            );
347        }
348    }
349
350    #[test]
351    fn physics_macro_abs_norm_qty_renders() {
352        for tex in [r"\abs{x}", r"\norm{v}", r"\qty{a + b}"] {
353            let html = render_inline(tex);
354            assert!(
355                !html.contains("katex-error"),
356                "{tex} 应正确渲染而非红字, got: {html}"
357            );
358        }
359    }
360
361    // ── mhchem 化学公式(Fix 3b) ──────────────────────────────────────
362    // \ce/\pu 预转译后渲染,不应出现 katex-error 红字。
363
364    #[test]
365    fn mhchem_water_renders() {
366        let html = render_inline(r"\ce{H2O}");
367        assert!(
368            html.contains("katex") && !html.contains("katex-error"),
369            "\\ce{{H2O}} 应正确渲染而非红字, got: {html}"
370        );
371    }
372
373    #[test]
374    fn mhchem_reaction_with_arrow_renders() {
375        let html = render_display(r"\ce{2H2 + O2 -> 2H2O}");
376        assert!(
377            !html.contains("katex-error"),
378            "反应方程式应正确渲染而非红字, got: {html}"
379        );
380    }
381
382    #[test]
383    fn mhchem_gas_arrow_superscript_renders() {
384        // 气体符号 ^ —— 转译后变成 \uparrow,消解原 mhchem 行尾 ^ 解析错误
385        // (文档 8.20 这正是当前唯一 1 个 katex-error 的根因)。
386        let html = render_display(r"\ce{CaCO3 ->[\Delta] CaO + CO2 ^}");
387        assert!(
388            !html.contains("katex-error"),
389            "气体箭头公式应正确渲染而非红字, got: {html}"
390        );
391    }
392
393    #[test]
394    fn mhchem_pu_units_renders() {
395        // C1 回归:`\pu` 旧 off-by-one 使其永不匹配,mhchem::pu 从不触发。
396        // 旧测试只断言 `!contains("katex-error")`,而 katex-rs 对未知命令走 color node
397        // (无 katex-error class),故无论转译与否都通过——等于空测试。这里改为断言
398        // 转译真正发生:产出含 katex 的 HTML,且不再是裸 `\pu{...}` 原文。
399        let html = render_inline(r"\pu{9.8 m/s^2}");
400        assert!(
401            html.contains("katex") && !html.contains("katex-error"),
402            "\\pu 单位应正确渲染而非红字, got: {html}"
403        );
404    }
405
406    #[test]
407    fn expand_chem_pu_is_actually_translated() {
408        // C1 直接回归:expand_chem 必须把 `\pu{...}` 转译掉,不得原样保留命令。
409        let out = expand_chem(r"\pu{9.8 m/s^2}");
410        assert_ne!(
411            out, r"\pu{9.8 m/s^2}",
412            "\\pu 应被 mhchem::pu 转译而非原样保留, got: {out}"
413        );
414        assert!(
415            !out.contains(r"\pu{"),
416            "转译后不应残留 \\pu{{ 命令, got: {out}"
417        );
418    }
419
420    #[test]
421    fn expand_chem_preserves_multibyte_utf8() {
422        // C2 回归:旧的 `out.push(bytes[i] as char)` 按单字节 Latin-1 转 char,
423        // 含 `\ce`/`\pu` 且含非 ASCII(如中文 `\text{浓度}`)的公式会被破坏成乱码。
424        let out = expand_chem(r"\text{浓度} \ce{H2O}");
425        assert!(out.contains("浓度"), "中文应原样保留, got: {out}");
426        assert!(
427            !out.contains(r"\ce{"),
428            "化学公式应被转译、不残留 \\ce{{ 命令, got: {out}"
429        );
430        // 仅含非 ASCII、无化学公式时零成本原样返回。
431        assert_eq!(expand_chem(r"纯中文无公式"), r"纯中文无公式");
432    }
433
434    #[test]
435    fn mhchem_ion_with_nested_braces_renders() {
436        // 嵌套花括号 / 络离子:扫描器必须正确配对 {}。
437        let html = render_inline(r"\ce{[Cu(NH3)4]^2+}");
438        assert!(
439            !html.contains("katex-error"),
440            "络离子公式应正确渲染而非红字, got: {html}"
441        );
442    }
443}