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 std::borrow::Cow;
19
20use katex::macros::MacroDefinition;
21use katex::{KatexContext, OutputFormat, Settings};
22
23/// 物理学常用宏表(对齐 LaTeX `physics` 宏包 + 项目文档 8.13 节「项目物理宏表」)。
24///
25/// `katex-rs` 默认 `Settings` 无物理宏表,导致 `\vu \dv \dd \pdv \divg \curl \grad`
26/// `\qty \RR \ZZ \NN \QQ \CC \bra \ket \braket \expval \abs \norm` 等渲染为红字
27/// (实测正确页面 648 的 137 个公式中 48 处物理宏坏掉)。这里把它们注册为简单
28/// 字符串宏([`MacroDefinition::StaticStr`]),crate 的 `string_to_expansion` 会自动
29/// 从 `#1`/`#2` 推导参数个数。
30///
31/// 刻意差异:`\divg`(散度)**不**覆写内置 `\div`(除号 ÷)——文档 8.13 明确两者并存。
32/// `\bra`/`\ket`/`\braket` 虽是 katex 内置宏,但内置 `\braket` 只吃 1 个参数
33/// (`\langle{#1}\rangle`),物理语义需 2 个参数(`\langle #1 | #2 \rangle`),
34/// 故覆写为物理版本。
35///
36/// `\qty(...)` / `\qty[...]` 的定界符配对无法用纯字符串宏表达,由渲染前的
37/// [`expand_qty`] 预转译;这里注册的是花括号形式 `\qty{...}`。
38fn physics_macros() -> &'static [(&'static str, MacroDefinition)] {
39    &[
40        // 数集
41        (r"\RR", MacroDefinition::StaticStr(r"\mathbb{R}")),
42        (r"\ZZ", MacroDefinition::StaticStr(r"\mathbb{Z}")),
43        (r"\NN", MacroDefinition::StaticStr(r"\mathbb{N}")),
44        (r"\QQ", MacroDefinition::StaticStr(r"\mathbb{Q}")),
45        (r"\CC", MacroDefinition::StaticStr(r"\mathbb{C}")),
46        // 微积分:微分与偏导。`\dd` 不带参数:physics 的花括号参数是可选的,
47        // `\frac{\dd}{\dd{t}}` 里的裸 `\dd` 若声明 `#1` 会吞掉 `}` 让整个公式报错;
48        // `\dd{x}` 照样展开成 `\mathrm{d}{x}`。
49        (r"\dd", MacroDefinition::StaticStr(r"\mathrm{d}")),
50        (
51            r"\dv",
52            MacroDefinition::StaticStr(r"\frac{\mathrm{d}#1}{\mathrm{d}#2}"),
53        ),
54        (
55            r"\pdv",
56            MacroDefinition::StaticStr(r"\frac{\partial #1}{\partial #2}"),
57        ),
58        // 场算子:grad/divg/curl(divg 刻意不复用 \div)
59        (r"\grad", MacroDefinition::StaticStr(r"\nabla")),
60        (r"\divg", MacroDefinition::StaticStr(r"\nabla \cdot")),
61        (r"\curl", MacroDefinition::StaticStr(r"\nabla \times")),
62        // 量子力学 Dirac 记号
63        (r"\bra", MacroDefinition::StaticStr(r"\langle #1 |")),
64        (r"\ket", MacroDefinition::StaticStr(r"| #1 \rangle")),
65        (
66            r"\braket",
67            MacroDefinition::StaticStr(r"\langle #1 | #2 \rangle"),
68        ),
69        (
70            r"\expval",
71            MacroDefinition::StaticStr(r"\langle #1 \rangle"),
72        ),
73        // 向量 / 范数 / 绝对值(自动缩放定界符)
74        (r"\abs", MacroDefinition::StaticStr(r"\left| #1 \right|")),
75        (r"\norm", MacroDefinition::StaticStr(r"\left\| #1 \right\|")),
76        // 单位向量:带帽子
77        (r"\vu", MacroDefinition::StaticStr(r"\hat{\vec{#1}}")),
78        // 自动缩放圆括号(花括号形式;`\qty(...)` 由 `expand_qty` 预转译)
79        (r"\qty", MacroDefinition::StaticStr(r"\left( #1 \right)")),
80    ]
81}
82
83/// mhchem 键(`\bond{~}` 等)引用的宏,定义取自 KaTeX `contrib/mhchem`。
84fn mhchem_macros() -> &'static [(&'static str, MacroDefinition)] {
85    &[(
86        r"\tripledash",
87        MacroDefinition::StaticStr(
88            r"{\vphantom{-}\raisebox{2.56mu}{$\mkern2mu\tiny\text{-}\mkern1mu\text{-}\mkern1mu\text{-}\mkern2mu$}}",
89        ),
90    )]
91}
92
93/// 把物理宏表与 mhchem 辅助宏注入到给定 `Settings` 的宏表(覆盖同名内置宏)。
94fn inject_macros(settings: &mut Settings) {
95    let mut map = settings.macros.borrow_mut();
96    for (name, def) in physics_macros().iter().chain(mhchem_macros()) {
97        map.insert((*name).to_string(), def.clone());
98    }
99}
100
101/// 内联公式(`$...$`)渲染配置工厂:`display_mode = false`,含物理宏表。
102fn inline_settings() -> Settings {
103    let mut s = Settings {
104        output: OutputFormat::Html,
105        display_mode: false,
106        throw_on_error: false,
107        ..Settings::default()
108    };
109    inject_macros(&mut s);
110    s
111}
112
113/// 块级公式(`$$...$$`)渲染配置工厂:`display_mode = true`(居中独占一行),含物理宏表。
114fn display_settings() -> Settings {
115    let mut s = Settings {
116        output: OutputFormat::Html,
117        display_mode: true,
118        throw_on_error: false,
119        ..Settings::default()
120    };
121    inject_macros(&mut s);
122    s
123}
124
125thread_local! {
126    /// KaTeX 上下文:含全部内置符号 / 宏表,应在多次渲染间复用(README 建议)。
127    /// 用 thread_local 而非全局 static:`KatexContext` 内含 `RefCell<HashMap>`
128    /// 宏表,非 `Sync`,不能放 `LazyLock`。tokio 多线程 runtime 下每线程各持一份。
129    static KATEX_CTX: KatexContext = KatexContext::default();
130
131    /// 每线程缓存的渲染配置,避免每次渲染都重建宏表 HashMap。
132    /// `Settings` 同样因 `RefCell` 宏表非 `Sync`。
133    static INLINE_SETTINGS: Settings = inline_settings();
134    static DISPLAY_SETTINGS: Settings = display_settings();
135}
136
137/// 把公式中的 `\ce{...}` / `\pu{...}` 预转译为标准 LaTeX(mhchem)。
138///
139/// `katex-rs` 无 mhchem 解析器,化学公式渲染为红字。这里在渲染前扫描 `\ce`/`\pu`
140/// 调用,用嵌套花括号配对读取参数(支持 `\ce{[Cu(NH3)4]^2+}` 这类含 `{}` 的内容),
141/// 转译后替换原 `\ce{...}`,其余文本原样拼接。未闭合 `\ce{` 保留原样(让 katex
142/// 报红,符合容错设计)。无 `\ce`/`\pu` 时零成本原样返回。
143fn expand_chem(tex: &str) -> String {
144    // 快速路径:绝大多数公式不含化学公式,避免分配。
145    if !tex.contains(r"\ce") && !tex.contains(r"\pu") {
146        return tex.to_string();
147    }
148    let mut out = String::with_capacity(tex.len());
149    let mut rest = tex;
150    loop {
151        // 找下一个 `\ce` 或 `\pu`,取较早出现者(两者均为 3 字节 ASCII)。
152        let ce = rest.find(r"\ce");
153        let pu = rest.find(r"\pu");
154        let next = match (ce, pu) {
155            (None, None) => None,
156            (Some(a), None) => Some((a, false)),
157            (None, Some(b)) => Some((b, true)),
158            (Some(a), Some(b)) => Some(if a <= b { (a, false) } else { (b, true) }),
159        };
160        match next {
161            None => {
162                // 命令之后再无 `\ce`/`\pu`:原样拷贝剩余文本。
163                out.push_str(rest);
164                return out;
165            }
166            Some((pos, is_pu)) => {
167                // C2 修复:拷贝命令前的原文用 `push_str(&str 切片)`,
168                // 而非旧的逐字节 `bytes[i] as char`(Latin-1 转换会破坏多字节 UTF-8,
169                // 如 `\text{浓度} \ce{H2O}` 里的中文)。
170                out.push_str(&rest[..pos]);
171                let bytes = rest.as_bytes();
172                // `\ce` 与 `\pu` 均为 3 字节,`{` 紧随其后(C1 修复:旧代码 `\pu` 误用 i+4,
173                // 实际 `{` 在 i+3,导致 `\pu` 永不匹配、mhchem::pu 从不触发)。
174                let after_cmd = pos + 3;
175                // 精确匹配命令边界:\ce/\pu 后须紧跟 `{`(否则可能是 \cellbox 之类)。
176                if after_cmd < bytes.len() && bytes[after_cmd] == b'{' {
177                    // read_braced 按字节索引扫描,仅计数 ASCII `{`/`}`;
178                    // 花括号配对不会跨多字节字符边界,返回的切片落在字符边界上,UTF-8 安全。
179                    if let Some((content, close_end)) = read_braced(rest, after_cmd) {
180                        let translated = if is_pu {
181                            crate::api::mhchem::pu(content)
182                        } else {
183                            crate::api::mhchem::ce(content)
184                        };
185                        out.push_str(&translated);
186                        rest = &rest[close_end..];
187                        continue;
188                    }
189                    // 未闭合 `{`:原样输出剩余,交由 katex 报红。
190                    out.push_str(&rest[after_cmd..]);
191                    return out;
192                } else {
193                    // 命令后非 `{`:保留命令字面量,从其后再扫。
194                    out.push_str(&rest[pos..after_cmd]);
195                    rest = &rest[after_cmd..];
196                    continue;
197                }
198            }
199        }
200    }
201}
202
203/// 把 physics 宏包的定界符形式 `\qty(...)` / `\qty[...]` 预转译为
204/// `\left(...\right)` / `\left[...\right]`。
205///
206/// 宏表里的 `\qty` 只能吃花括号参数:`\qty(\dfrac{a}{b})` 会把 `(` 当成 `#1`,
207/// 渲染成「(()a/b)」且不报红字。这里按 TeX 记号扫描:控制序列整体跳过(`\\qty`
208/// 不算、`\(` 不计数),只在同一花括号层级内配对同种括号,嵌套的 `\qty(` 各自替换。
209/// 括号不闭合的保留原文(与 katex 报错容错一致)。`\qty{...}` 仍走宏表。
210fn expand_qty(tex: &str) -> Cow<'_, str> {
211    if !tex.contains(r"\qty") {
212        return Cow::Borrowed(tex);
213    }
214    let bytes = tex.as_bytes();
215    // (起, 止, 替换文本):开头替换 `\qty(`(含中间空白),结尾替换配对的 `)`。
216    let mut edits: Vec<(usize, usize, &'static str)> = Vec::new();
217    let mut i = 0;
218    while i < bytes.len() {
219        if bytes[i] != b'\\' {
220            i += 1;
221            continue;
222        }
223        let name_start = i + 1;
224        let name_end = control_sequence_end(bytes, name_start);
225        if &bytes[name_start..name_end] == b"qty" {
226            let mut open = name_end;
227            while open < bytes.len() && bytes[open].is_ascii_whitespace() {
228                open += 1;
229            }
230            let pair = match bytes.get(open) {
231                Some(b'(') => Some((b'(', b')', r"\left(", r"\right)")),
232                Some(b'[') => Some((b'[', b']', r"\left[", r"\right]")),
233                _ => None,
234            };
235            if let Some((open_ch, close_ch, left, right)) = pair {
236                if let Some(close) = find_matching_delimiter(bytes, open, open_ch, close_ch) {
237                    edits.push((i, open + 1, left));
238                    edits.push((close, close + 1, right));
239                }
240            }
241        }
242        // 从控制序列之后继续扫描,括号内容里的嵌套 `\qty(` 也会被找到。
243        i = name_end;
244    }
245    if edits.is_empty() {
246        return Cow::Borrowed(tex);
247    }
248    edits.sort_unstable_by_key(|&(start, _, _)| start);
249    let mut out = String::with_capacity(tex.len() + edits.len() * 4);
250    let mut copied = 0;
251    for (start, end, replacement) in edits {
252        // 替换边界都落在 ASCII 字节上,切片不会截断多字节 UTF-8。
253        out.push_str(&tex[copied..start]);
254        out.push_str(replacement);
255        copied = end;
256    }
257    out.push_str(&tex[copied..]);
258    Cow::Owned(out)
259}
260
261/// 控制序列名的结束位置:`start` 指向 `\` 之后。字母串是控制词,否则是单字符控制符。
262fn control_sequence_end(bytes: &[u8], start: usize) -> usize {
263    match bytes.get(start) {
264        Some(b) if b.is_ascii_alphabetic() => {
265            let mut end = start;
266            while end < bytes.len() && bytes[end].is_ascii_alphabetic() {
267                end += 1;
268            }
269            end
270        }
271        // 非 ASCII 控制符只跳过首字节;续字节 ≥ 0x80,不会被当成定界符。
272        Some(_) => start + 1,
273        None => start,
274    }
275}
276
277/// 从 `open`(指向开括号)找同一花括号层级内配对的闭括号位置;先遇到外层 `}` 或到结尾返回 `None`。
278fn find_matching_delimiter(bytes: &[u8], open: usize, open_ch: u8, close_ch: u8) -> Option<usize> {
279    let mut depth = 0usize;
280    let mut braces = 0usize;
281    let mut i = open;
282    while i < bytes.len() {
283        match bytes[i] {
284            b'\\' => {
285                i = control_sequence_end(bytes, i + 1);
286                continue;
287            }
288            b'{' => braces += 1,
289            b'}' => braces = braces.checked_sub(1)?,
290            c if braces == 0 && c == open_ch => depth += 1,
291            c if braces == 0 && c == close_ch => {
292                depth -= 1;
293                if depth == 0 {
294                    return Some(i);
295                }
296            }
297            _ => {}
298        }
299        i += 1;
300    }
301    None
302}
303
304/// 从 `open`(指向 `{`)读取配对花括号内容,返回 `(内容, 闭括号后位置)`。
305/// 不闭合返回 `None`。嵌套 `{}` 正确计数。
306fn read_braced(s: &str, open: usize) -> Option<(&str, usize)> {
307    let bytes = s.as_bytes();
308    debug_assert_eq!(bytes[open], b'{');
309    let mut depth = 0i32;
310    let mut i = open;
311    while i < bytes.len() {
312        match bytes[i] {
313            b'{' => depth += 1,
314            b'}' => {
315                depth -= 1;
316                if depth == 0 {
317                    return Some((&s[open + 1..i], i + 1));
318                }
319            }
320            _ => {}
321        }
322        i += 1;
323    }
324    None
325}
326
327/// 渲染内联公式 `$...$`(定界符由 pulldown-cmark 剥除)→ HTML 字符串。
328///
329/// 渲染失败(坏 TeX)时回退到 HTML 转义后的原文,保证文章不因一个坏公式全篇崩。
330pub fn render_inline(tex: &str) -> String {
331    let tex = expand_chem(&expand_qty(tex));
332    KATEX_CTX.with(|ctx| {
333        INLINE_SETTINGS.with(|settings| {
334            katex::render_to_string(ctx, &tex, settings)
335                .map(|html| sort_svg_attributes(&html))
336                .unwrap_or_else(|_| crate::utils::html::escape_html(&tex))
337        })
338    })
339}
340
341/// 渲染块级公式 `$$...$$`(定界符由 pulldown-cmark 剥除)→ HTML 字符串。
342///
343/// 与 [`render_inline`] 同样在失败时回退到转义原文。调用方负责块级包裹
344/// (如 `<p class="math-display">`),这里只产出 KaTeX 的 span 串。
345pub fn render_display(tex: &str) -> String {
346    let tex = expand_chem(&expand_qty(tex));
347    KATEX_CTX.with(|ctx| {
348        DISPLAY_SETTINGS.with(|settings| {
349            katex::render_to_string(ctx, &tex, settings)
350                .map(|html| sort_svg_attributes(&html))
351                .unwrap_or_else(|_| crate::utils::html::escape_html(&tex))
352        })
353    })
354}
355
356/// 把 `<svg>` / `<path>` 开标签的属性按名称排序。
357///
358/// `katex-rs` 用 `HashMap` 存 SVG 属性,同一公式两次渲染的属性顺序不同(实测同一
359/// 输入 10 次中 9 次不同)。顺序不影响显示,但会让「同一正文渲染结果逐字相同」的
360/// 前提失效——富文本往返守卫(`posts::roundtrip`)靠比较渲染 HTML 判断有损,
361/// 含根号 / 矩阵竖线等 SVG 的正文因此永远被误判为有损。KaTeX 产出的这两种标签的
362/// 属性值是不含 `>` 与 `"` 的双引号字符串,可以直接扫描。
363fn sort_svg_attributes(html: &str) -> String {
364    let mut out = String::with_capacity(html.len());
365    let mut rest = html;
366    while let Some(start) = ["<svg ", "<path "]
367        .iter()
368        .filter_map(|tag| rest.find(tag))
369        .min()
370    {
371        let Some(end) = rest[start..].find('>').map(|i| start + i) else {
372            break;
373        };
374        let tag_end = rest[start + 1..].find(' ').map_or(end, |i| start + 1 + i);
375        let (self_closing, attrs_end) = if rest[..end].ends_with('/') {
376            (true, end - 1)
377        } else {
378            (false, end)
379        };
380        let mut attrs: Vec<&str> = Vec::new();
381        let mut cursor = tag_end;
382        let attr_src = &rest[..attrs_end];
383        while let Some(eq) = attr_src[cursor..].find("=\"").map(|i| cursor + i) {
384            let value_start = eq + 2;
385            let Some(value_end) = attr_src[value_start..].find('"').map(|i| value_start + i) else {
386                break;
387            };
388            attrs.push(attr_src[cursor..=value_end].trim_start());
389            cursor = value_end + 1;
390        }
391        attrs.sort_unstable();
392        out.push_str(&rest[..tag_end]);
393        for attr in attrs {
394            out.push(' ');
395            out.push_str(attr);
396        }
397        if self_closing {
398            out.push('/');
399        }
400        out.push('>');
401        rest = &rest[end + 1..];
402    }
403    out.push_str(rest);
404    out
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    #[test]
412    fn render_inline_produces_katex_span() {
413        let html = render_inline("E = mc^2");
414        assert!(
415            html.contains("katex"),
416            "内联公式应产出含 katex class 的 span, got: {html}"
417        );
418    }
419
420    #[test]
421    fn render_display_produces_katex_display() {
422        let html = render_display("\\frac{a}{b}");
423        assert!(
424            html.contains("katex-display"),
425            "块级公式应产出含 katex-display class 的结构, got: {html}"
426        );
427    }
428
429    #[test]
430    fn render_inline_does_not_emit_math_tag() {
431        // OutputFormat::Html 不应产出 <math> 标签(那是 HtmlAndMathml / Mathml 模式)。
432        let html = render_inline("a^2 + b^2 = c^2");
433        assert!(
434            !html.contains("<math"),
435            "Html 输出不应含 <math> 标签, got: {html}"
436        );
437    }
438
439    // ── 物理宏表(Fix 3a) ─────────────────────────────────────────
440    // katex-rs 默认无物理宏,未注册时 \vu \dd \RR 等渲染为 katex-error 红字。
441
442    #[test]
443    fn physics_macro_unit_vector_renders() {
444        // \vu{i} → \hat{\vec{i}}:带帽子单位向量。
445        let html = render_inline(r"\vu{i}");
446        assert!(
447            html.contains("katex") && !html.contains("katex-error"),
448            "\\vu 应正确渲染而非红字, got: {html}"
449        );
450    }
451
452    #[test]
453    fn physics_macro_divergence_does_not_override_division() {
454        // 刻意差异:\divg(散度)与 \div(除号 ÷)并存。
455        let divg = render_inline(r"\divg \vec{F}");
456        let div = render_inline(r"a \div b");
457        assert!(
458            !divg.contains("katex-error"),
459            "\\divg 应正确渲染而非红字, got: {divg}"
460        );
461        assert!(
462            !div.contains("katex-error"),
463            "\\div 应仍是除号而非红字, got: {div}"
464        );
465        // 两者输出不同(\divg 展开为 \nabla \cdot,\div 是除号符号)。
466        assert_ne!(divg, div, "\\divg 与 \\div 输出应不同");
467    }
468
469    #[test]
470    fn physics_macro_number_sets_renders() {
471        for m in [r"\RR", r"\ZZ", r"\NN", r"\QQ", r"\CC"] {
472            let html = render_inline(m);
473            assert!(
474                !html.contains("katex-error"),
475                "{m} 应正确渲染而非红字, got: {html}"
476            );
477        }
478    }
479
480    #[test]
481    fn physics_macro_calculus_renders() {
482        // \dv{f}{x} → d f / d x;\pdv{f}{x} → ∂ f / ∂ x;\dd{x} → dx。
483        for tex in [r"\dv{f}{x}", r"\pdv{f}{x}", r"\dd{x}"] {
484            let html = render_inline(tex);
485            assert!(
486                !html.contains("katex-error"),
487                "{tex} 应正确渲染而非红字, got: {html}"
488            );
489        }
490    }
491
492    #[test]
493    fn physics_macro_bare_dd_renders() {
494        // 文章 48 的麦克斯韦方程组(积分形式)与欧拉-拉格朗日方程:`\frac{\dd}{\dd{t}}`
495        // 里的裸 `\dd` 曾吞掉 `}`,整块公式退化成红色原文。
496        for tex in [
497            r"\frac{\dd}{\dd{t}}",
498            r"\begin{aligned} \oint_{\partial S} \vec{E} \cdot \dd{\vec{l}} &= -\frac{\dd}{\dd{t}} \int_S \vec{B} \cdot \dd{\vec{A}} \end{aligned}",
499            r"\frac{\dd}{\dd{t}} \pdv{\mathcal{L}}{\dot{q}_i} - \pdv{\mathcal{L}}{q_i} = 0",
500            r"\int f(x) \dd x",
501        ] {
502            let html = render_display(tex);
503            assert!(
504                !html.contains("#cc0000"),
505                "{tex} 应正确渲染而非红字, got: {html}"
506            );
507        }
508        // 带参数时与原先的 `\mathrm{d}#1` 等价:直立 d 后紧跟参数。
509        let html = render_inline(r"\dd{x}");
510        assert!(html.contains(">d<") && html.contains(">x<"), "got: {html}");
511    }
512
513    #[test]
514    fn physics_macro_dirac_notation_renders() {
515        for tex in [
516            r"\bra{\psi}",
517            r"\ket{\phi}",
518            r"\braket{\psi}{\phi}",
519            r"\expval{A}",
520        ] {
521            let html = render_inline(tex);
522            assert!(
523                !html.contains("katex-error"),
524                "{tex} 应正确渲染而非红字, got: {html}"
525            );
526        }
527    }
528
529    #[test]
530    fn physics_macro_abs_norm_qty_renders() {
531        for tex in [r"\abs{x}", r"\norm{v}", r"\qty{a + b}"] {
532            let html = render_inline(tex);
533            assert!(
534                !html.contains("katex-error"),
535                "{tex} 应正确渲染而非红字, got: {html}"
536            );
537        }
538    }
539
540    #[test]
541    fn expand_qty_rewrites_delimiter_forms() {
542        for (input, expected) in [
543            (r"\qty(\dfrac{a}{b})", r"\left(\dfrac{a}{b}\right)"),
544            (r"\qty[a, b]", r"\left[a, b\right]"),
545            (r"\qty (x)", r"\left(x\right)"),
546            // 内层普通括号参与配对,不会提前收尾。
547            (r"\qty(f(x) + g(x))^2", r"\left(f(x) + g(x)\right)^2"),
548            (r"\qty(f\qty(x))", r"\left(f\left(x\right)\right)"),
549            // 花括号内的括号、`\{` `\)` 这类控制符都不计数。
550            (r"\qty(\text{)} x)", r"\left(\text{)} x\right)"),
551            (r"\qty(\{x\} \))", r"\left(\{x\} \)\right)"),
552            (r"\qty(\text{浓度})", r"\left(\text{浓度}\right)"),
553            (r"a \qty(b) c \qty[d]", r"a \left(b\right) c \left[d\right]"),
554        ] {
555            assert_eq!(expand_qty(input), expected, "input: {input}");
556        }
557    }
558
559    #[test]
560    fn expand_qty_leaves_other_forms_untouched() {
561        for input in [
562            r"\qty{a + b}",
563            r"\qtyx(a)",
564            r"a \\qty(b)",
565            // 不闭合,或外层花括号先关闭:保留原文。
566            r"\qty(a",
567            r"{\qty(a} )",
568            r"x^2",
569        ] {
570            assert!(
571                matches!(expand_qty(input), Cow::Borrowed(s) if s == input),
572                "{input} 应原样保留"
573            );
574        }
575    }
576
577    #[test]
578    fn physics_macro_qty_parentheses_render_as_left_right() {
579        // 文章 48 的 `$\qty(\dfrac{a}{b})$` 曾渲染成「(()a/b)」:宏表把 `(` 当成参数。
580        assert_eq!(
581            render_inline(r"\qty(\dfrac{a}{b})"),
582            render_inline(r"\left(\dfrac{a}{b}\right)")
583        );
584        assert_eq!(
585            render_display(r"\qty[\frac{1}{2}]"),
586            render_display(r"\left[\frac{1}{2}\right]")
587        );
588    }
589
590    // ── mhchem 化学公式(Fix 3b) ──────────────────────────────────────
591    // \ce/\pu 预转译后渲染,不应出现 katex-error 红字。
592
593    #[test]
594    fn mhchem_water_renders() {
595        let html = render_inline(r"\ce{H2O}");
596        assert!(
597            html.contains("katex") && !html.contains("katex-error"),
598            "\\ce{{H2O}} 应正确渲染而非红字, got: {html}"
599        );
600    }
601
602    #[test]
603    fn mhchem_reaction_with_arrow_renders() {
604        let html = render_display(r"\ce{2H2 + O2 -> 2H2O}");
605        assert!(
606            !html.contains("katex-error"),
607            "反应方程式应正确渲染而非红字, got: {html}"
608        );
609    }
610
611    #[test]
612    fn mhchem_gas_arrow_superscript_renders() {
613        // 气体符号 ^ —— 转译后变成 \uparrow,消解原 mhchem 行尾 ^ 解析错误
614        // (文档 8.20 这正是当前唯一 1 个 katex-error 的根因)。
615        let html = render_display(r"\ce{CaCO3 ->[\Delta] CaO + CO2 ^}");
616        assert!(
617            !html.contains("katex-error"),
618            "气体箭头公式应正确渲染而非红字, got: {html}"
619        );
620    }
621
622    #[test]
623    fn mhchem_pu_units_renders() {
624        // C1 回归:`\pu` 旧 off-by-one 使其永不匹配,mhchem::pu 从不触发。
625        // 旧测试只断言 `!contains("katex-error")`,而 katex-rs 对未知命令走 color node
626        // (无 katex-error class),故无论转译与否都通过——等于空测试。这里改为断言
627        // 转译真正发生:产出含 katex 的 HTML,且不再是裸 `\pu{...}` 原文。
628        let html = render_inline(r"\pu{9.8 m/s^2}");
629        assert!(
630            html.contains("katex") && !html.contains("katex-error"),
631            "\\pu 单位应正确渲染而非红字, got: {html}"
632        );
633    }
634
635    #[test]
636    fn expand_chem_pu_is_actually_translated() {
637        // C1 直接回归:expand_chem 必须把 `\pu{...}` 转译掉,不得原样保留命令。
638        let out = expand_chem(r"\pu{9.8 m/s^2}");
639        assert_ne!(
640            out, r"\pu{9.8 m/s^2}",
641            "\\pu 应被 mhchem::pu 转译而非原样保留, got: {out}"
642        );
643        assert!(
644            !out.contains(r"\pu{"),
645            "转译后不应残留 \\pu{{ 命令, got: {out}"
646        );
647    }
648
649    #[test]
650    fn expand_chem_preserves_multibyte_utf8() {
651        // C2 回归:旧的 `out.push(bytes[i] as char)` 按单字节 Latin-1 转 char,
652        // 含 `\ce`/`\pu` 且含非 ASCII(如中文 `\text{浓度}`)的公式会被破坏成乱码。
653        let out = expand_chem(r"\text{浓度} \ce{H2O}");
654        assert!(out.contains("浓度"), "中文应原样保留, got: {out}");
655        assert!(
656            !out.contains(r"\ce{"),
657            "化学公式应被转译、不残留 \\ce{{ 命令, got: {out}"
658        );
659        // 仅含非 ASCII、无化学公式时零成本原样返回。
660        assert_eq!(expand_chem(r"纯中文无公式"), r"纯中文无公式");
661    }
662
663    // issue #13:保留真实渲染回归。katex-rs 0.3 已支持文本模式 U+00B7,
664    // 不再预处理 TeX。未知命令也可能产出 color node,不能只检查 katex-error。
665
666    #[test]
667    fn text_mode_middle_dot_does_not_render_red() {
668        let html = render_inline(r"\text{m·K}");
669        assert!(
670            !html.contains("#cc0000"),
671            "\\text{{m·K}} 不应渲染为红字错误, got: {html}"
672        );
673        // 与 KaTeX JS 一致的 glyph:U+22C5。
674        assert!(html.contains('⋅'), "应渲染 U+22C5 glyph, got: {html}");
675    }
676
677    #[test]
678    fn issue13_wien_law_formula_does_not_render_red() {
679        // issue #13 原始公式(维恩位移定律 b 的单位 m·K)。
680        let html = render_inline(r"b \approx 2.898\times10^{-3}\,\text{m·K}");
681        assert!(
682            !html.contains("#cc0000"),
683            "issue #13 公式不应有红字, got: {html}"
684        );
685    }
686
687    #[test]
688    fn text_family_commands_render_middle_dot() {
689        for cmd in [r"\text", r"\textbf", r"\textit", r"\texttt"] {
690            let html = render_inline(&format!(r"{cmd}{{m·K}}"));
691            assert!(
692                !html.contains("#cc0000"),
693                "{cmd}{{m·K}} 不应渲染为红字错误, got: {html}"
694            );
695        }
696    }
697
698    #[test]
699    fn math_mode_middle_dot_renders_as_punctuation() {
700        // 数学模式 U+00B7 是标点符号,保持 \cdotp 的间距语义。
701        let html = render_inline("a · b");
702        assert!(
703            !html.contains("#cc0000"),
704            "数学模式 · 本就能渲染, got: {html}"
705        );
706        assert!(
707            html.contains("mpunct"),
708            "数学模式 · 应保持 \\cdotp(mpunct)语义, got: {html}"
709        );
710    }
711
712    #[test]
713    fn display_mode_middle_dot_renders() {
714        let html = render_display(r"T = \frac{b}{\lambda}, \text{单位 m·K}");
715        assert!(
716            !html.contains("#cc0000"),
717            "块级公式 \\text{{m·K}} 不应渲染为红字错误, got: {html}"
718        );
719    }
720
721    #[test]
722    fn mhchem_ion_with_nested_braces_renders() {
723        // 嵌套花括号 / 络离子:扫描器必须正确配对 {}。
724        let html = render_inline(r"\ce{[Cu(NH3)4]^2+}");
725        assert!(
726            !html.contains("katex-error"),
727            "络离子公式应正确渲染而非红字, got: {html}"
728        );
729    }
730
731    #[test]
732    fn mhchem_arrows_and_bonds_do_not_render_red() {
733        // MathJax 专有命令(\longrightleftharpoons、\raise、\tripledash 等)在 katex-rs
734        // 下会变成红色 color node(不带 katex-error),必须单独断言 #cc0000。
735        for input in [
736            r"\ce{CO2 + H2O <=> H2CO3}",
737            r"\ce{A <--> B}",
738            r"\ce{A <=>> B}",
739            r"\ce{A <<=> B}",
740            r"\ce{A <-> B}",
741            r"\ce{A <- B}",
742            r"\ce{A ->[H2O][\Delta] B}",
743            r"\ce{A <=>[k_1][k_2] B}",
744            r"\ce{A\bond{~}B}",
745            r"\ce{A\bond{~-}B}",
746            r"\ce{A\bond{~=}B}",
747            r"\ce{A\bond{-~-}B}",
748            r"\ce{^{227}_{90}Th}",
749        ] {
750            let html = render_display(input);
751            assert!(
752                !html.contains("#cc0000"),
753                "{input} 不应渲染为红字, got: {html}"
754            );
755        }
756    }
757
758    #[test]
759    fn svg_formulas_render_deterministically() {
760        // 根号带 <svg>/<path>;katex-rs 的 HashMap 属性顺序曾让同一输入每次渲染都不同。
761        let first = render_display(r"\sqrt{\pi} + \sqrt{\frac{a}{b}}");
762        assert!(first.contains("<svg"), "应含 svg, got: {first}");
763        for _ in 0..20 {
764            assert_eq!(first, render_display(r"\sqrt{\pi} + \sqrt{\frac{a}{b}}"));
765        }
766    }
767
768    #[test]
769    fn sort_svg_attributes_orders_names_and_keeps_rest() {
770        let html = r#"<span class="a"><svg xmlns="x" width="1em" height="2em"><path d="M0 0 L1 1"/></svg></span>"#;
771        assert_eq!(
772            sort_svg_attributes(html),
773            r#"<span class="a"><svg height="2em" width="1em" xmlns="x"><path d="M0 0 L1 1"/></svg></span>"#
774        );
775    }
776}