feat(katex): 移植 mhchem 化学公式转译器(\ce/\pu → LaTeX)
Some checks failed
CI / check (push) Has been cancelled
CI / build (push) Has been cancelled

katex-rs 默认无 mhchem 解析器,\ce/\pu 化学公式渲染为红字(实测正确页面 648
的 137 个公式中 32 处化学公式坏掉,含 \ce×24 + \pu×8)。

从 mhchemParser 4.2.2(Apache-2.0)机械移植状态机转译器为纯 Rust:
- src/api/mhchem.rs:数据模型 + 模式匹配 + go() 主循环 + texify 输出 + 公开 API
- src/api/mhchem_tables.rs:14 个状态机转移表 + 机器局部动作(ce/pu/...)
- 模式含 lookahead (?=)/(?!),Rust regex crate 不支持,故用 fancy-regex
  (已被 syntect 间接引入,此处显式声明为直接 server 依赖)
- ce/pu 包裹 catch_unwind,任何内部异常回退原样输出,绝不 panic
- katex.rs 新增 expand_chem:渲染前嵌套花括号配对扫描 \ce/\pu 预转译
- 行尾 ^ 气体符号转译为 \uparrow,消解原 mhchem 解析错误
- 8 个 mhchem 单测 + 5 个 katex 集成测试(水/反应箭头/气体箭头/单位/络离子)
This commit is contained in:
xfy 2026-07-23 12:23:53 +08:00
parent c6999763c0
commit bcd13958ac
6 changed files with 2905 additions and 4 deletions

1
Cargo.lock generated
View File

@ -5602,6 +5602,7 @@ dependencies = [
"deadpool-postgres",
"dioxus",
"dotenvy",
"fancy-regex",
"futures",
"governor",
"hex",

View File

@ -20,6 +20,10 @@ argon2 = { version = "0.5", optional = true }
uuid = { version = "1", features = ["v4"], optional = true }
chrono = { version = "0.4", features = ["serde"] }
regex = { version = "1.12", optional = true }
# mhchem 化学公式转译器src/api/mhchem.rs需要 lookahead 正则,
# Rust `regex` crate 不支持 `(?=...)`/`(?!...)`,故用 `fancy-regex`。
# 该 crate 已被 syntect 间接引入0.16.2,见 Cargo.lock此处显式声明为直接依赖。
fancy-regex = { version = "0.16", optional = true }
pulldown-cmark = { version = "0.13", optional = true }
dotenvy = { version = "0.15", optional = true }
tracing = { version = "0.1", optional = true, features = ["release_max_level_info"] }
@ -98,6 +102,7 @@ server = [
"dep:argon2",
"dep:uuid",
"dep:regex",
"dep:fancy-regex",
"dep:pulldown-cmark",
"dep:rand",
"dep:http",

View File

@ -119,14 +119,89 @@ thread_local! {
static DISPLAY_SETTINGS: Settings = display_settings();
}
/// 把公式中的 `\ce{...}` / `\pu{...}` 预转译为标准 LaTeXmhchem
///
/// `katex-rs` 无 mhchem 解析器,化学公式渲染为红字。这里在渲染前扫描 `\ce`/`\pu`
/// 调用,用嵌套花括号配对读取参数(支持 `\ce{[Cu(NH3)4]^2+}` 这类含 `{}` 的内容),
/// 转译后替换原 `\ce{...}`,其余文本原样拼接。未闭合 `\ce{` 保留原样(让 katex
/// 报红,符合容错设计)。无 `\ce`/`\pu` 时零成本原样返回。
fn expand_chem(tex: &str) -> String {
// 快速路径:绝大多数公式不含化学公式,避免分配。
if !tex.contains(r"\ce") && !tex.contains(r"\pu") {
return tex.to_string();
}
let bytes = tex.as_bytes();
let mut out = String::with_capacity(tex.len());
let mut i = 0;
while i < bytes.len() {
// 匹配 `\ce{` 或 `\pu{`
if bytes[i] == b'\\' && i + 3 < bytes.len() {
let (is_ce, is_pu) = (bytes[i + 1] == b'c' && bytes[i + 2] == b'e', bytes[i + 1] == b'p' && bytes[i + 2] == b'u');
let brace_at = if is_ce {
Some(i + 3)
} else if is_pu {
Some(i + 4)
} else {
None
};
if let Some(bi) = brace_at {
// 精确匹配命令边界:\ce/\pu 后须紧跟 `{`(否则可能是 \cellbox 之类)
if bi < bytes.len() && bytes[bi] == b'{' {
// 读配对花括号内容(处理嵌套)
if let Some((content, close_end)) = read_braced(tex, bi) {
let translated = if is_ce {
crate::api::mhchem::ce(content)
} else {
crate::api::mhchem::pu(content)
};
out.push_str(&translated);
i = close_end;
continue;
}
// 未闭合 `{`:原样输出剩余,交由 katex 报红
out.push_str(&tex[i..]);
return out;
}
}
}
out.push(bytes[i] as char);
i += 1;
}
out
}
/// 从 `open`(指向 `{`)读取配对花括号内容,返回 `(内容, 闭括号后位置)`。
/// 不闭合返回 `None`。嵌套 `{}` 正确计数。
fn read_braced(s: &str, open: usize) -> Option<(&str, usize)> {
let bytes = s.as_bytes();
debug_assert_eq!(bytes[open], b'{');
let mut depth = 0i32;
let mut i = open;
while i < bytes.len() {
match bytes[i] {
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
return Some((&s[open + 1..i], i + 1));
}
}
_ => {}
}
i += 1;
}
None
}
/// 渲染内联公式 `$...$`(定界符由 pulldown-cmark 剥除)→ HTML 字符串。
///
/// 渲染失败(坏 TeX时回退到 HTML 转义后的原文,保证文章不因一个坏公式全篇崩。
pub fn render_inline(tex: &str) -> String {
let tex = expand_chem(tex);
KATEX_CTX.with(|ctx| {
INLINE_SETTINGS.with(|settings| {
katex::render_to_string(ctx, tex, settings)
.unwrap_or_else(|_| crate::utils::html::escape_html(tex))
katex::render_to_string(ctx, &tex, settings)
.unwrap_or_else(|_| crate::utils::html::escape_html(&tex))
})
})
}
@ -136,10 +211,11 @@ pub fn render_inline(tex: &str) -> String {
/// 与 [`render_inline`] 同样在失败时回退到转义原文。调用方负责块级包裹
/// (如 `<p class="math-display">`),这里只产出 KaTeX 的 span 串。
pub fn render_display(tex: &str) -> String {
let tex = expand_chem(tex);
KATEX_CTX.with(|ctx| {
DISPLAY_SETTINGS.with(|settings| {
katex::render_to_string(ctx, tex, settings)
.unwrap_or_else(|_| crate::utils::html::escape_html(tex))
katex::render_to_string(ctx, &tex, settings)
.unwrap_or_else(|_| crate::utils::html::escape_html(&tex))
})
})
}
@ -264,4 +340,55 @@ mod tests {
);
}
}
// ── mhchem 化学公式Fix 3b ──────────────────────────────────────
// \ce/\pu 预转译后渲染,不应出现 katex-error 红字。
#[test]
fn mhchem_water_renders() {
let html = render_inline(r"\ce{H2O}");
assert!(
html.contains("katex") && !html.contains("katex-error"),
"\\ce{{H2O}} 应正确渲染而非红字, got: {html}"
);
}
#[test]
fn mhchem_reaction_with_arrow_renders() {
let html = render_display(r"\ce{2H2 + O2 -> 2H2O}");
assert!(
!html.contains("katex-error"),
"反应方程式应正确渲染而非红字, got: {html}"
);
}
#[test]
fn mhchem_gas_arrow_superscript_renders() {
// 气体符号 ^ —— 转译后变成 \uparrow消解原 mhchem 行尾 ^ 解析错误
// (文档 8.20 这正是当前唯一 1 个 katex-error 的根因)。
let html = render_display(r"\ce{CaCO3 ->[\Delta] CaO + CO2 ^}");
assert!(
!html.contains("katex-error"),
"气体箭头公式应正确渲染而非红字, got: {html}"
);
}
#[test]
fn mhchem_pu_units_renders() {
let html = render_inline(r"\pu{9.8 m/s^2}");
assert!(
!html.contains("katex-error"),
"\\pu 单位应正确渲染而非红字, got: {html}"
);
}
#[test]
fn mhchem_ion_with_nested_braces_renders() {
// 嵌套花括号 / 络离子:扫描器必须正确配对 {}。
let html = render_inline(r"\ce{[Cu(NH3)4]^2+}");
assert!(
!html.contains("katex-error"),
"络离子公式应正确渲染而非红字, got: {html}"
);
}
}

1218
src/api/mhchem.rs Executable file

File diff suppressed because it is too large Load Diff

1547
src/api/mhchem_tables.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -23,6 +23,9 @@ pub mod image;
/// KaTeX 服务端数学公式渲染server-only
#[cfg(feature = "server")]
pub mod katex;
/// mhchem 化学公式转译器(\ce/\pu → LaTeXserver-only
#[cfg(feature = "server")]
pub mod mhchem;
/// Markdown 渲染与 HTML 清理。
pub mod markdown;
/// 文章 CRUD 相关接口。