yggdrasil/src/api/mhchem.rs
xfy bcd13958ac
Some checks failed
CI / check (push) Has been cancelled
CI / build (push) Has been cancelled
feat(katex): 移植 mhchem 化学公式转译器(\ce/\pu → LaTeX)
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 集成测试(水/反应箭头/气体箭头/单位/络离子)
2026-07-23 12:23:53 +08:00

1219 lines
45 KiB
Rust
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#![cfg(feature = "server")]
//! mhchem 化学公式转译器:把 `\ce{...}` / `\pu{...}` 预转译为标准 LaTeX
//! 再交给 katex 渲染。
//!
//! 这是 [mhchemParser](https://github.com/mhchem/mhchemParser) 4.2.2Apache-2.0
//! 的**机械移植**:状态机 + texify 输出。mhchemParser 是纯字符串→字符串转译器,
//! `ce("H2O")` → `\mathrm{H}\sb{2}\mathrm{O}` 之类,无需嵌入 katex 内部解析树。
//!
//! 移植要点TS→Rust
//! - 动态 `buffer` 对象 → [`Buffer`] 结构体(`Option` 字段 + `clear`)。
//! - 动态 `Parsed = string | object` → [`Parsed`] 枚举;节点字段用 [`Field`](字符串或节点向量)。
//! - 正则模式(含 lookahead `(?=)`/`(?!)`Rust `regex` 不支持)→ `fancy-regex`(已被
//! syntect 间接引入,此处显式声明)。
//! - `findObserveGroups` 花括号配对扫描 → [`find_observe_groups`]。
//! - 状态机转移表 → [`build_transitions`] 展开(对应 `_mhchemCreateTransitions`)。
//!
//! 公开 API[`ce`] / [`pu`]。任意内部异常都回退为原样输出(绝不 panic
//! katex.rs 的容错哲学一致)。
//!
//! -----------------------------------------------------------------------
//! Copyright 2015-2023 Martin HenselmhchemParser 原作者。Apache-2.0。
//! -----------------------------------------------------------------------
use fancy_regex::Regex;
use std::collections::HashMap;
use std::sync::LazyLock;
// =========================================================================
// 数据模型
// =========================================================================
/// 模式匹配值:单字符串或捕获组数组(正则 >1 个捕获组时为数组)。
#[derive(Clone, Debug)]
enum MVal {
S(String),
V(Vec<String>),
}
/// 模式匹配结果。
#[derive(Clone, Debug)]
struct MMatch {
m: MVal,
remainder: String,
}
/// 解析中间表示的字段值:字符串或子表达式节点向量。
#[derive(Clone, Debug)]
enum Field {
Str(String),
Nodes(Vec<Parsed>),
}
impl Field {
fn empty(&self) -> bool {
match self {
Field::Str(s) => s.is_empty(),
Field::Nodes(v) => v.is_empty(),
}
}
}
/// 解析中间表示:字面字符串或节点。
#[derive(Clone, Debug)]
enum Parsed {
S(String),
N(NodeData),
}
#[derive(Clone, Debug, Default)]
struct NodeData {
type_: String,
p1: Option<Field>,
p2: Option<Field>,
a: Option<Field>,
b: Option<Field>,
p: Option<Field>,
o: Option<Field>,
q: Option<Field>,
d: Option<Field>,
color: Option<String>,
color1: Option<String>,
color2: Option<Field>,
r: Option<String>,
rd: Option<Field>,
rq: Option<Field>,
d_type: Option<String>,
kind_: Option<String>,
}
/// 状态机缓冲区(对应 TS 的动态 `buffer` 对象)。
#[derive(Clone, Default)]
struct Buffer {
a: Option<String>,
b: Option<String>,
p: Option<String>,
o: Option<String>,
q: Option<String>,
d: Option<String>,
rm: Option<String>,
text_: Option<String>,
r: Option<String>,
rd: Option<String>,
rq: Option<String>,
rdt: Option<String>,
rqt: Option<String>,
d_type: Option<String>,
sb: bool,
begins_with_bond: bool,
parenthesis_level: i32,
}
impl Buffer {
/// 清空内容字段。`keep` 为真时保留 `parenthesis_level` 与 `begins_with_bond`
/// (对应 ce 的 output`delete buffer[p]` 跳过这两项)。
fn clear(&mut self, keep: bool) {
let (pl, bb) = if keep {
(self.parenthesis_level, self.begins_with_bond)
} else {
(0, false)
};
*self = Buffer {
parenthesis_level: pl,
begins_with_bond: bb,
..Buffer::default()
};
}
}
// =========================================================================
// 转移表
// =========================================================================
#[derive(Clone)]
struct ActionRef {
type_: String,
option: Option<String>,
}
#[derive(Clone, Default)]
struct Task {
actions: Vec<ActionRef>,
next_state: Option<String>,
revisit: bool,
to_continue: bool,
}
#[derive(Clone)]
struct Transition {
pattern: String,
task: Task,
}
/// 原始转移条目(对应 TS 字面量,`patterns`/`states` 均为 `|` 分隔的合并名)。
struct RawEntry {
patterns: &'static str,
states: &'static str,
task: Task,
}
/// 展开 `{pattern: {state: task}}` 为 `{state => [(pattern, task)]}`。
/// 对应 `_mhchemCreateTransitions`:拆分 `|`、`'*'` 插入所有状态。
fn build_transitions(raw: &[RawEntry]) -> HashMap<String, Vec<Transition>> {
let mut transitions: HashMap<String, Vec<Transition>> = HashMap::new();
// 1. 收集所有状态(拆分 states 的 `|`
for e in raw {
for state in e.states.split('|') {
transitions.entry(state.to_string()).or_default();
}
}
// 2. 填充(拆分 patterns 与 states 的 `|``'*'` 插入所有已收集状态)
let all_states: Vec<String> = transitions.keys().cloned().collect();
for e in raw {
let state_list: Vec<&str> = e.states.split('|').collect();
for (idx, _state) in state_list.iter().enumerate() {
for pattern in e.patterns.split('|') {
let insert_states: Vec<String> = if state_list[idx] == "*" {
all_states.clone()
} else {
vec![state_list[idx].to_string()]
};
for s in insert_states {
transitions.entry(s).or_default().push(Transition {
pattern: pattern.to_string(),
task: e.task.clone(),
});
}
}
}
}
transitions
}
// =========================================================================
// 模式匹配
// =========================================================================
macro_rules! re {
($pat:literal) => {{
static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new($pat).unwrap());
&*RE
}};
}
/// 字面量或正则模式(`findObserveGroups` 的参数)。
enum Pat {
Lit(&'static str),
Re(&'static Regex),
}
/// 在 `input` 起始处匹配 `pat`,返回匹配到的文本(不含 remainder
fn pat_match_head(pat: &Pat, input: &str) -> Option<String> {
match pat {
Pat::Lit(s) => {
if input.starts_with(s) {
Some((*s).to_string())
} else {
None
}
}
Pat::Re(re) => re.captures(input).ok().flatten().and_then(|c| {
let whole = c.get(0).map(|m| m.as_str())?;
// 模式均锚定 ^,确认从 0 开始
if input.starts_with(whole) {
Some(whole.to_string())
} else {
None
}
}),
}
}
/// `findObserveGroups`:花括号感知的定界符配对扫描。
///
/// 移植自 TS `findObserveGroups`。`beg*_excl/incl`/`end*_incl/excl` 为定界符,
/// `combine` 控制第二组结果是否拼接为字符串。
#[allow(clippy::too_many_arguments)]
fn find_observe_groups(
input: &str,
beg_excl: Pat,
beg_incl: Pat,
end_incl: Pat,
end_excl: Pat,
beg2_excl: Option<Pat>,
beg2_incl: Option<Pat>,
end2_incl: Option<Pat>,
end2_excl: Option<Pat>,
combine: bool,
) -> Option<MMatch> {
// 第一组
let m0 = pat_match_head(&beg_excl, input)?;
let rest0 = &input[m0.len()..];
let m1 = pat_match_head(&beg_incl, rest0)?;
// 结束定界符endIncl || endExclendIncl 为空字面量时取 endExcl
let end_is_incl = !is_empty_pat(&end_incl);
let end_chars = if end_is_incl { &end_incl } else { &end_excl };
let e = find_observe_end(rest0, m1.len(), end_chars)?;
let body_end = if end_is_incl { e.end } else { e.begin };
let match1 = rest0[..body_end].to_string();
let after1 = &rest0[e.end..];
// 无第二组
if beg2_excl.is_none() && beg2_incl.is_none() {
return Some(MMatch {
m: MVal::S(match1),
remainder: after1.to_string(),
});
}
// 第二组(递归)
let g2 = find_observe_groups(
after1,
beg2_excl.unwrap_or(Pat::Lit("")),
beg2_incl.unwrap_or(Pat::Lit("")),
end2_incl.unwrap_or(Pat::Lit("")),
end2_excl.unwrap_or(Pat::Lit("")),
None,
None,
None,
None,
false,
)?;
let mval = match g2.m {
MVal::S(s2) => {
if combine {
MVal::S(format!("{}{}", match1, s2))
} else {
MVal::V(vec![match1, s2])
}
}
MVal::V(_) => MVal::V(vec![match1]), // 不应发生(第二组无二级)
};
Some(MMatch {
m: mval,
remainder: g2.remainder,
})
}
fn is_empty_pat(p: &Pat) -> bool {
matches!(p, Pat::Lit("") )
}
struct Span {
begin: usize,
end: usize,
}
/// 从 `rest0` 的 `start` 位置扫描,跟踪花括号深度,在深度 0 遇到 `end_chars` 时返回其区间。
fn find_observe_end(input: &str, start: usize, end_chars: &Pat) -> Option<Span> {
let bytes = input.as_bytes();
let mut i = start;
let mut braces = 0i32;
while i < input.len() {
// 结束定界符匹配(仅在花括号平衡时)
if braces == 0 {
if let Some(matched) = pat_match_head(end_chars, &input[i..]) {
return Some(Span {
begin: i,
end: i + matched.len(),
});
}
}
let a = bytes[i] as char;
if a == '{' {
braces += 1;
} else if a == '}' {
if braces == 0 {
// ExtraCloseMissingOpen —— 原实现抛错,这里返回 None 容错
return None;
}
braces -= 1;
}
i += 1;
}
None
}
/// 把 fancy-regex 的捕获结果转为 [`MMatch`](锚定 `^`whole 从 0 起)。
fn fancy_match(re: &Regex, input: &str) -> Option<MMatch> {
let caps = re.captures(input).ok()??;
let whole = caps.get(0)?.as_str().to_string();
if !input.starts_with(&whole) {
return None;
}
let n_groups = re.captures_len();
let mval = if n_groups >= 2 {
let mut v = Vec::with_capacity(n_groups);
for gi in 1..=n_groups {
v.push(caps.get(gi).map(|m| m.as_str().to_string()).unwrap_or_default());
}
MVal::V(v)
} else {
// n_groups == 1 或 0match[1] || match[0](空串视作未匹配)
let g1 = caps.get(1).map(|m| m.as_str().to_string()).filter(|s| !s.is_empty());
MVal::S(g1.unwrap_or(whole.clone()))
};
Some(MMatch {
m: mval,
remainder: input[whole.len()..].to_string(),
})
}
/// 希腊字母宏集合(`letters` / `\greek` 等模式用到)。
static GREEK_NAMES: &str = "alpha|beta|gamma|delta|epsilon|zeta|eta|theta|iota|kappa|lambda|mu|nu|xi|omicron|pi|rho|sigma|tau|upsilon|phi|chi|psi|omega|Gamma|Delta|Theta|Lambda|Xi|Pi|Sigma|Upsilon|Phi|Psi|Omega";
static LETTERS_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!(
"^(?:[a-zA-Z\u{03B1}-\u{03C9}\u{0391}-\u{03A9}?@]|(?:\\\\(?:{})(?:\\s+|\\{{\\}}|(?![a-zA-Z]))))+",
GREEK_NAMES
))
.unwrap()
});
static GREEK_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!(
"^\\\\(?:{})(?:\\s+|\\{{\\}}|(?![a-zA-Z]))",
GREEK_NAMES
))
.unwrap()
});
static ONE_GREEK_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
"^(?:\\$?[\u{03B1}-\u{03C9}]\\$?|\\$?\\\\(?:alpha|beta|gamma|delta|epsilon|zeta|eta|theta|iota|kappa|lambda|mu|nu|xi|omicron|pi|rho|sigma|tau|upsilon|phi|chi|psi|omega)\\s*\\$?)(?:\\s+|\\{{\\}}|(?![a-zA-Z]))$"
)
.unwrap()
});
/// 按名匹配模式(对应 `_mhchemParser.patterns.match_`)。
fn match_pattern(name: &str, input: &str) -> Option<MMatch> {
// ── 正则模式 ──
let re_match = |re: &Regex| fancy_match(re, input);
match name {
"empty" => re_match(re!("^$")),
"else" | "else2" => re_match(re!("^.")),
"space" => re_match(re!("^\\s")),
"space A" => re_match(re!("^\\s(?=[A-Z\\\\$])")),
"space$" => re_match(re!("^\\s$")),
"a-z" => re_match(re!("^[a-z]")),
"x" => re_match(re!("^x")),
"x$" => re_match(re!("^x$")),
"i$" => re_match(re!("^i$")),
"letters" => re_match(&*LETTERS_RE),
"\\greek" => re_match(&*GREEK_RE),
"one lowercase latin letter $" => re_match(re!("^(?:([a-z])(?:$|[^a-zA-Z]))$")),
"$one lowercase latin letter$ $" => re_match(re!("^\\$(?:([a-z])(?:$|[^a-zA-Z]))\\$$")),
"one lowercase greek letter $" => re_match(&*ONE_GREEK_RE),
"digits" => re_match(re!("^[0-9]+")),
"-9.,9" => re_match(re!("^[+\\-]?(?:[0-9]+(?:[,.][0-9]+)?|[0-9]*(?:\\.[0-9]+))")),
"-9.,9 no missing 0" => re_match(re!("^[+\\-]?[0-9]+(?:[.,][0-9]+)?")),
"(-)(9)^(-9)" => re_match(re!("^(\\+\\-|\\+\\/\\-|\\+|\\-|\\\\pm\\s?)?([0-9]+(?:[,.][0-9]+)?|[0-9]*(?:\\.[0-9]+)?)\\^([+\\-]?[0-9]+|\\{[+\\-]?[0-9]+\\})")),
"_{(state of aggregation)}$" => re_match(re!("^_\\{(\\([a-z]{1,3}\\))\\}")),
"{[(" => re_match(re!("^(?:\\\\\\{|\\[|\\()")),
")]}" => re_match(re!("^(?:\\)|\\]|\\\\\\})")),
", " => re_match(re!("^[,;]\\s*")),
"," => re_match(re!("^[,;]")),
"." => re_match(re!("^[.]")),
". __* " => re_match(re!("^([.\u{22C5}\u{00B7}\u{2022}|[*])\\s*")),
"..." => re_match(re!("^\\.\\.\\.(?=$|[^.])")),
"^a" => re_match(re!("^\\^([0-9]+|[^\\\\_])")),
"^\\x" => re_match(re!("^\\^(\\\\[a-zA-Z]+)\\s*")),
"^(-1)" => re_match(re!("^\\^(-?\\d+)")),
"'" => re_match(re!("^'")),
"_9" => re_match(re!("^_([+\\-]?[0-9]+|[^\\\\])")),
"_\\x" => re_match(re!("^_(\\\\[a-zA-Z]+)\\s*")),
"^_" => re_match(re!("^(?:\\^(?=_)|\\_(?=\\^)|[\\^_]$)")),
"{}^" => re_match(re!("^\\{\\}(?=\\^)")),
"{}" => re_match(re!("^\\{\\}")),
"=<>" => re_match(re!("^[=<>]")),
"#" => re_match(re!("^[#\u{2261}]")),
"+" => re_match(re!("^\\+")),
"-$" => re_match(re!("^-(?=[\\s_},;\\]/]|$|\\([a-z]+\\))")),
"-9" => re_match(re!("^-(?=[0-9])")),
"- orbital overlap" => re_match(re!("^-(?=(?:[spd]|sp)(?:$|[\\s,;\\)\\]\\}]))")),
"-" => re_match(re!("^-")),
"pm-operator" => re_match(re!("^(?:\\\\pm|\\$\\\\pm\\$|\\+-|\\+\\/-)")),
"operator" => re_match(re!("^(?:\\+|(?:[\\-=<>]|<<|>>|\\\\approx|\\$\\\\approx\\$)(?=\\s|$|-?[0-9]))")),
"arrowUpDown" => re_match(re!("^(?:v|\\(v\\)|\\^|\\(\\^\\))(?=$|[\\s,;\\)\\]\\}])")),
"->" => re_match(re!("^(?:<->|<-->|->|<-|<=>>|<<=>|<=>|[\u{2192}\u{27F6}\u{21CC}])")),
"CMT" => re_match(re!("^[CMT](?=\\[)")),
"1st-level escape" => re_match(re!("^(&|\\\\\\\\|\\\\hline)\\s*")),
"\\," => re_match(re!("^(?:\\\\[,\\ ;:])")),
"\\ca" => re_match(re!("^\\\\ca(?:\\s+|(?![a-zA-Z]))")),
"\\x" => re_match(re!("^(?:\\\\[a-zA-Z]+\\s*|\\\\[_&{}%])")),
"orbital" => re_match(re!("^(?:[0-9]{1,2}[spdfgh]|[0-9]{0,2}sp)(?=$|[^a-zA-Z])")),
"others" => re_match(re!("^[/~|]")),
"oxidation$" => re_match(re!("^(?:[+-][IVX]+|(?:\\\\pm|\\$\\\\pm\\$|\\+-|\\+\\/-)\\s*0)$")),
"d-oxidation$" => re_match(re!("^(?:[+-]?[IVX]+|(?:\\\\pm|\\$\\\\pm\\$|\\+-|\\+\\/-)\\s*0)$")),
"1/2$" => re_match(re!("^[+\\-]?(?:[0-9]+|\\$[a-z]\\$|[a-z])\\/[0-9]+(?:\\$[a-z]\\$|[a-z])?$")),
"(KV letters)," => re_match(re!("^(?:[A-Z][a-z]{0,2}|i)(?=,)")),
"uprightEntities" => re_match(re!("^(?:pH|pOH|pC|pK|iPr|iBu)(?=$|[^a-zA-Z])")),
"/" => re_match(re!("^\\s*(\\/)\\s*")),
"//" => re_match(re!("^\\s*(\\/\\/)\\s*")),
"*" => re_match(re!("^\\s*[*.]\\s*")),
// ── 函数模式findObserveGroups / 自定义)──
"(-)(9.,9)(e)(99)" => pat_enumber(input),
"state of aggregation $" => pat_state_of_aggregation(input),
"^{(...)}" => fg(input, Pat::Lit("^{"), Pat::Lit(""), Pat::Lit(""), Pat::Lit("}"), None, None, None, None, false),
"^($...$)" => fg(input, Pat::Lit("^"), Pat::Lit("$"), Pat::Lit("$"), Pat::Lit(""), None, None, None, None, false),
"^\\x{}{}" => fg(input, Pat::Lit("^"), Pat::Re(re!("^\\\\[a-zA-Z]+\\{")), Pat::Lit("}"), Pat::Lit(""), Some(Pat::Lit("")), Some(Pat::Lit("{")), Some(Pat::Lit("}")), Some(Pat::Lit("")), true),
"^\\x{}" => fg(input, Pat::Lit("^"), Pat::Re(re!("^\\\\[a-zA-Z]+\\{")), Pat::Lit("}"), Pat::Lit(""), None, None, None, None, false),
"\\bond{(...)}" => fg(input, Pat::Lit("\\bond{"), Pat::Lit(""), Pat::Lit(""), Pat::Lit("}"), None, None, None, None, false),
"[(...)]" => fg(input, Pat::Lit("["), Pat::Lit(""), Pat::Lit(""), Pat::Lit("]"), None, None, None, None, false),
"\\x{}{}" => fg(input, Pat::Lit(""), Pat::Re(re!("^\\\\[a-zA-Z]+\\{")), Pat::Lit("}"), Pat::Lit(""), Some(Pat::Lit("")), Some(Pat::Lit("{")), Some(Pat::Lit("}")), Some(Pat::Lit("")), true),
"\\x{}" => fg(input, Pat::Lit(""), Pat::Re(re!("^\\\\[a-zA-Z]+\\{")), Pat::Lit("}"), Pat::Lit(""), None, None, None, None, false),
"\\frac{(...)}" => fg(input, Pat::Lit("\\frac{"), Pat::Lit(""), Pat::Lit(""), Pat::Lit("}"), Some(Pat::Lit("{")), Some(Pat::Lit("")), Some(Pat::Lit("")), Some(Pat::Lit("}")), false),
"\\overset{(...)}" => fg(input, Pat::Lit("\\overset{"), Pat::Lit(""), Pat::Lit(""), Pat::Lit("}"), Some(Pat::Lit("{")), Some(Pat::Lit("")), Some(Pat::Lit("")), Some(Pat::Lit("}")), false),
"\\underset{(...)}" => fg(input, Pat::Lit("\\underset{"), Pat::Lit(""), Pat::Lit(""), Pat::Lit("}"), Some(Pat::Lit("{")), Some(Pat::Lit("")), Some(Pat::Lit("")), Some(Pat::Lit("}")), false),
"\\underbrace{(...)}" => fg(input, Pat::Lit("\\underbrace{"), Pat::Lit(""), Pat::Lit(""), Pat::Lit("}"), Some(Pat::Lit("_")), Some(Pat::Lit("{")), Some(Pat::Lit("")), Some(Pat::Lit("}")), false),
"\\color{(...)}" => fg(input, Pat::Lit("\\color{"), Pat::Lit(""), Pat::Lit(""), Pat::Lit("}"), None, None, None, None, false),
"\\color{(...)}{(...)}" => fg(input, Pat::Lit("\\color{"), Pat::Lit(""), Pat::Lit(""), Pat::Lit("}"), Some(Pat::Lit("{")), Some(Pat::Lit("")), Some(Pat::Lit("")), Some(Pat::Lit("}")), false),
"\\ce{(...)}" => fg(input, Pat::Lit("\\ce{"), Pat::Lit(""), Pat::Lit(""), Pat::Lit("}"), None, None, None, None, false),
"\\pu{(...)}" => fg(input, Pat::Lit("\\pu{"), Pat::Lit(""), Pat::Lit(""), Pat::Lit("}"), None, None, None, None, false),
"_{(...)}" => fg(input, Pat::Lit("_{"), Pat::Lit(""), Pat::Lit(""), Pat::Lit("}"), None, None, None, None, false),
"_($...$)" => fg(input, Pat::Lit("_"), Pat::Lit("$"), Pat::Lit("$"), Pat::Lit(""), None, None, None, None, false),
"_\\x{}{}" => fg(input, Pat::Lit("_"), Pat::Re(re!("^\\\\[a-zA-Z]+\\{")), Pat::Lit("}"), Pat::Lit(""), Some(Pat::Lit("")), Some(Pat::Lit("{")), Some(Pat::Lit("}")), Some(Pat::Lit("")), true),
"_\\x{}" => fg(input, Pat::Lit("_"), Pat::Re(re!("^\\\\[a-zA-Z]+\\{")), Pat::Lit("}"), Pat::Lit(""), None, None, None, None, false),
"{...}" => fg(input, Pat::Lit(""), Pat::Lit("{"), Pat::Lit("}"), Pat::Lit(""), None, None, None, None, false),
"{(...)}" => fg(input, Pat::Lit("{"), Pat::Lit(""), Pat::Lit(""), Pat::Lit("}"), None, None, None, None, false),
"$...$" => fg(input, Pat::Lit(""), Pat::Lit("$"), Pat::Lit("$"), Pat::Lit(""), None, None, None, None, false),
"${(...)}$__$(...)$" => fg(input, Pat::Lit("${"), Pat::Lit(""), Pat::Lit(""), Pat::Lit("}$"), None, None, None, None, false).or_else(|| fg(input, Pat::Lit("$"), Pat::Lit(""), Pat::Lit(""), Pat::Lit("$"), None, None, None, None, false)),
"amount" | "amount2" => pat_amount(input),
"formula$" => pat_formula(input),
_ => {
// 未知模式:容错返回 None原实现抛 MhchemBugP
None
}
}
}
/// `find_observe_groups` 的简写封装。
#[allow(clippy::too_many_arguments)]
fn fg(
input: &str,
beg_excl: Pat,
beg_incl: Pat,
end_incl: Pat,
end_excl: Pat,
beg2_excl: Option<Pat>,
beg2_incl: Option<Pat>,
end2_incl: Option<Pat>,
end2_excl: Option<Pat>,
combine: bool,
) -> Option<MMatch> {
find_observe_groups(
input, beg_excl, beg_incl, end_incl, end_excl, beg2_excl, beg2_incl, end2_incl, end2_excl,
combine,
)
}
/// `(-)(9.,9)(e)(99)` 模式。
fn pat_enumber(input: &str) -> Option<MMatch> {
let re = re!("^(\\+\\-|\\+\\/\\-|\\+|\\-|\\\\pm\\s?)?([0-9]+(?:[,.][0-9]+)?|[0-9]*(?:\\.[0-9]+))?(\\((?:[0-9]+(?:[,.][0-9]+)?|[0-9]*(?:\\.[0-9]+))\\))?(?:(?:([eE])|\\s*(\\*|x|\\\\times|\u{00D7})\\s*10\\^)([+\\-]?[0-9]+|\\{[+\\-]?[0-9]+\\}))?");
let caps = re.captures(input).ok()??;
let whole = caps.get(0)?.as_str();
if whole.is_empty() || !input.starts_with(whole) {
return None;
}
let mut v = Vec::with_capacity(6);
for gi in 1..=6 {
v.push(caps.get(gi).map(|m| m.as_str().to_string()).unwrap_or_default());
}
Some(MMatch {
m: MVal::V(v),
remainder: input[whole.len()..].to_string(),
})
}
/// `state of aggregation $` 模式。
fn pat_state_of_aggregation(input: &str) -> Option<MMatch> {
let a = fg(
input,
Pat::Lit(""),
Pat::Re(re!("^\\([a-z]{1,3}(?=[\\),])")),
Pat::Lit(""),
Pat::Lit(")"),
None, None, None, None, false,
)?;
if re!("^($|[\\s,;\\)\\]\\}])").is_match(a.remainder.as_str()).ok()? {
return Some(a);
}
let re2 = re!("^(?:\\((?:\\\\ca\\s?)?\\$[amothc]\\$\\))");
let caps = re2.captures(input).ok()??;
let whole = caps.get(0)?.as_str().to_string();
Some(MMatch {
m: MVal::S(whole.clone()),
remainder: input[whole.len()..].to_string(),
})
}
/// `amount` 模式。
fn pat_amount(input: &str) -> Option<MMatch> {
let re = re!("^(?:(?:(?:\\([+\\-]?[0-9]+\\/[0-9]+\\)|[+\\-]?(?:[0-9]+|\\$[a-z]\\$|[a-z])\\/[0-9]+|[+\\-]?[0-9]+[.,][0-9]+|[+\\-]?\\.[0-9]+|[+\\-]?[0-9]+)(?:[a-z](?=\\s*[A-Z]))?)|[+\\-]?[a-z](?=\\s*[A-Z])|\\+(?!\\s))");
if let Some(caps) = re.captures(input).ok().flatten() {
let whole = caps.get(0)?.as_str();
if !whole.is_empty() {
return Some(MMatch {
m: MVal::S(whole.to_string()),
remainder: input[whole.len()..].to_string(),
});
}
}
let a = fg(input, Pat::Lit(""), Pat::Lit("$"), Pat::Lit("$"), Pat::Lit(""), None, None, None, None, false)?;
let re2 = re!("^\\$(?:\\(?[+\\-]?(?:[0-9]*[a-z]?[+\\-])?[0-9]*[a-z](?:[+\\-][0-9]*[a-z]?)?\\)?|\\+|-)\\$$");
let inner = mval_str(&a.m);
let inner_len = inner.len();
if re2.is_match(&inner).ok()? {
return Some(MMatch {
m: MVal::S(inner),
remainder: input[inner_len..].to_string(),
});
}
None
}
/// `formula$` 模式。
fn pat_formula(input: &str) -> Option<MMatch> {
if re!("^\\([a-z]+\\)$").is_match(input).ok()? {
return None;
}
let re = re!("^(?:[a-z]|(?:[0-9\\ +\\-\\,\\.\\(\\)]+[a-z])+[0-9\\ +\\-\\,\\.\\(\\)]*|(?:[a-z][0-9\\ +\\-\\,\\.\\(\\)]+)+[a-z]?)$");
let caps = re.captures(input).ok()??;
let whole = caps.get(0)?.as_str().to_string();
Some(MMatch {
m: MVal::S(whole.clone()),
remainder: input[whole.len()..].to_string(),
})
}
fn mval_str(m: &MVal) -> String {
match m {
MVal::S(s) => s.clone(),
MVal::V(v) => v.first().cloned().unwrap_or_default(),
}
}
// =========================================================================
// 状态机主循环(对应 `_mhchemParser.go`
// =========================================================================
enum Out {
None,
One(Parsed),
Many(Vec<Parsed>),
}
fn concat(out: &mut Vec<Parsed>, o: Out) {
match o {
Out::None => {}
Out::One(p) => out.push(p),
Out::Many(v) => out.extend(v),
}
}
/// 主解析循环。
fn go(input: &str, machine: &str) -> Vec<Parsed> {
if input.is_empty() {
return Vec::new();
}
// 输入预处理
let mut input = input.replace('\n', " ");
input = input.replace(['\u{2212}', '\u{2013}', '\u{2014}', '\u{2010}'], "-");
input = input.replace('\u{2026}', "...");
let transitions = transitions_for(machine);
let mut state = String::from("0");
let mut buffer = Buffer::default();
buffer.parenthesis_level = 0;
let mut output: Vec<Parsed> = Vec::new();
let mut last_input: Option<String> = None;
let mut watchdog = 10i32;
loop {
if last_input.as_deref() != Some(input.as_str()) {
watchdog = 10;
last_input = Some(input.clone());
} else {
watchdog -= 1;
}
let t = transitions.get(&state).or_else(|| transitions.get("*"));
let t = match t {
Some(t) => t,
None => break,
};
let mut matched = false;
for tr in t {
if let Some(mres) = match_pattern(&tr.pattern, &input) {
matched = true;
// 执行动作链
for aref in &tr.task.actions {
let o = exec_action(machine, &mut buffer, &mres.m, &aref.option, &aref.type_);
concat(&mut output, o);
}
// 设置下一状态
if let Some(ns) = &tr.task.next_state {
state = ns.clone();
}
if !input.is_empty() {
if !tr.task.revisit {
input = mres.remainder;
}
if !tr.task.to_continue {
break;
}
} else {
return output;
}
}
}
if !matched {
break;
}
if watchdog <= 0 {
// 防死循环:容错返回当前输出(原实现抛 MhchemBugU
break;
}
}
output
}
// =========================================================================
// 动作分发
// =========================================================================
fn exec_action(
machine: &str,
buf: &mut Buffer,
m: &MVal,
opt: &Option<String>,
type_: &str,
) -> Out {
// 优先机器局部动作,再查通用动作
if let Some(o) = machine_action(machine, buf, m, opt, type_) {
return o;
}
generic_action(buf, m, opt, type_)
}
fn generic_action(buf: &mut Buffer, m: &MVal, opt: &Option<String>, type_: &str) -> Out {
match type_ {
"a=" => {
append_field(&mut buf.a, m);
Out::None
}
"b=" => {
append_field(&mut buf.b, m);
Out::None
}
"p=" => {
append_field(&mut buf.p, m);
Out::None
}
"o=" => {
append_field(&mut buf.o, m);
Out::None
}
"o=+p1" => {
if let Some(a) = opt {
append_str(&mut buf.o, a);
}
Out::None
}
"q=" => {
append_field(&mut buf.q, m);
Out::None
}
"d=" => {
append_field(&mut buf.d, m);
Out::None
}
"rm=" => {
append_field(&mut buf.rm, m);
Out::None
}
"text=" => {
append_field(&mut buf.text_, m);
Out::None
}
"insert" => match opt {
Some(a) => Out::One(Parsed::N(NodeData {
type_: a.clone(),
..Default::default()
})),
None => Out::None,
},
"insert+p1" => match opt {
Some(a) => Out::One(Parsed::N(NodeData {
type_: a.clone(),
p1: Some(Field::Str(mval_str(m))),
..Default::default()
})),
None => Out::None,
},
"insert+p1+p2" => {
if let (Some(a), MVal::V(v)) = (opt, m) {
Out::One(Parsed::N(NodeData {
type_: a.clone(),
p1: Some(Field::Str(v.get(0).cloned().unwrap_or_default())),
p2: Some(Field::Str(v.get(1).cloned().unwrap_or_default())),
..Default::default()
}))
} else {
Out::None
}
}
"copy" => mval_to_out(m),
"write" => match opt {
Some(a) => Out::One(Parsed::S(a.clone())),
None => Out::None,
},
"rm" => Out::One(Parsed::N(NodeData {
type_: "rm".into(),
p1: Some(Field::Str(mval_str(m))),
..Default::default()
})),
"text" => Out::Many(go(&mval_str(m), "text")),
"tex-math" => Out::Many(go(&mval_str(m), "tex-math")),
"tex-math tight" => Out::Many(go(&mval_str(m), "tex-math tight")),
"bond" => {
let kind = opt
.clone()
.or_else(|| match m {
MVal::S(s) => Some(s.clone()),
_ => None,
})
.unwrap_or_default();
Out::One(Parsed::N(NodeData {
type_: "bond".into(),
kind_: Some(kind),
..Default::default()
}))
}
"color0-output" => Out::One(Parsed::N(NodeData {
type_: "color0".into(),
color: Some(mval_str(m)),
..Default::default()
})),
"ce" => Out::Many(go(&mval_str(m), "ce")),
"pu" => Out::Many(go(&mval_str(m), "pu")),
"9,9" => Out::Many(go(&mval_str(m), "9,9")),
"1/2" => {
let mut s = mval_str(m);
let mut ret: Vec<Parsed> = Vec::new();
if s.starts_with('+') || s.starts_with('-') {
ret.push(Parsed::S(s[..1].to_string()));
s = s[1..].to_string();
}
if let Some(caps) = re!("^([0-9]+|\\$[a-z]\\$|[a-z])\\/([0-9]+)(\\$[a-z]\\$|[a-z])?$")
.captures(&s)
.ok()
.flatten()
{
let mut n1 = caps.get(1).map(|x| x.as_str().to_string()).unwrap_or_default();
n1 = n1.replace('$', "");
let n2 = caps.get(2).map(|x| x.as_str().to_string()).unwrap_or_default();
ret.push(Parsed::N(NodeData {
type_: "frac".into(),
p1: Some(Field::Str(n1)),
p2: Some(Field::Str(n2)),
..Default::default()
}));
if let Some(g3) = caps.get(3) {
let mut n3 = g3.as_str().replace('$', "");
ret.push(Parsed::N(NodeData {
type_: "tex-math".into(),
p1: Some(Field::Str(std::mem::take(&mut n3))),
..Default::default()
}));
}
}
Out::Many(ret)
}
_ => Out::None,
}
}
fn append_field(field: &mut Option<String>, m: &MVal) {
let s = mval_str(m);
match field {
Some(existing) => existing.push_str(&s),
None => *field = Some(s),
}
}
fn append_str(field: &mut Option<String>, s: &str) {
match field {
Some(existing) => existing.push_str(s),
None => *field = Some(s.to_string()),
}
}
fn mval_to_out(m: &MVal) -> Out {
match m {
MVal::S(s) => Out::One(Parsed::S(s.clone())),
MVal::V(v) => Out::Many(v.iter().map(|s| Parsed::S(s.clone())).collect()),
}
}
// =========================================================================
// texify 输出(对应 `_mhchemTexify`
// =========================================================================
fn texify_go(input: &[Parsed], add_outer_braces: bool) -> String {
if input.is_empty() {
return String::new();
}
let mut res = String::new();
let mut cee = false;
for p in input {
match p {
Parsed::S(s) => res.push_str(s),
Parsed::N(n) => {
res.push_str(&texify_go2(n));
if n.type_ == "1st-level escape" {
cee = true;
}
}
}
}
if add_outer_braces && !cee && !res.is_empty() {
res = format!("{{{}}}", res);
}
res
}
fn go_inner(f: &Field) -> String {
match f {
Field::Str(s) => s.clone(),
Field::Nodes(v) => texify_go(v, false),
}
}
fn field_str(f: &Field) -> String {
match f {
Field::Str(s) => s.clone(),
Field::Nodes(v) => texify_go(v, false),
}
}
fn texify_go2(buf: &NodeData) -> String {
match buf.type_.as_str() {
"chemfive" => {
let mut res = String::new();
let a = buf.a.as_ref().map(field_str).unwrap_or_default();
let b = buf.b.as_ref().map(field_str).unwrap_or_default();
let p = buf.p.as_ref().map(field_str).unwrap_or_default();
let o = buf.o.as_ref().map(field_str).unwrap_or_default();
let q = buf.q.as_ref().map(field_str).unwrap_or_default();
let d = buf.d.as_ref().map(field_str).unwrap_or_default();
// a
if !a.is_empty() {
let aa = if a.starts_with('+') || a.starts_with('-') {
format!("{{{}}}", a)
} else {
a.clone()
};
res.push_str(&aa);
res.push_str("\\,");
}
// b and p
if !b.is_empty() || !p.is_empty() {
res.push_str("{\\vphantom{A}}");
res.push_str(&format!("^{{\\hphantom{{{}}}}}_{{\\hphantom{{{}}}}}", b, p));
res.push_str("\\mkern-1.5mu");
res.push_str("{\\vphantom{A}}");
res.push_str(&format!("^{{\\smash[t]{{\\vphantom{{2}}}}\\llap{{{}}}}}", b));
res.push_str(&format!("_{{\\vphantom{{2}}\\llap{{\\smash[t]{{{}}}}}}}", p));
}
// o
if !o.is_empty() {
let oo = if o.starts_with('+') || o.starts_with('-') {
format!("{{{}}}", o)
} else {
o.clone()
};
res.push_str(&oo);
}
// q and d
match buf.d_type.as_deref() {
Some("kv") => {
if !d.is_empty() || !q.is_empty() {
res.push_str("{\\vphantom{A}}");
}
if !d.is_empty() {
res.push_str(&format!("^{{{}}}", d));
}
if !q.is_empty() {
res.push_str(&format!("_{{\\smash[t]{{{}}}}}", q));
}
}
Some("oxidation") => {
if !d.is_empty() {
res.push_str("{\\vphantom{A}}");
res.push_str(&format!("^{{{}}}", d));
}
if !q.is_empty() {
res.push_str("{\\vphantom{A}}");
res.push_str(&format!("_{{\\smash[t]{{{}}}}}", q));
}
}
_ => {
if !q.is_empty() {
res.push_str("{\\vphantom{A}}");
res.push_str(&format!("_{{\\smash[t]{{{}}}}}", q));
}
if !d.is_empty() {
res.push_str("{\\vphantom{A}}");
res.push_str(&format!("^{{{}}}", d));
}
}
}
res
}
"rm" => format!("\\mathrm{{{}}}", buf.p1.as_ref().map(field_str).unwrap_or_default()),
"text" => {
let mut p1 = buf.p1.as_ref().map(field_str).unwrap_or_default();
if p1.contains('^') || p1.contains('_') {
p1 = p1.replace(' ', "~").replace('-', "\\text{-}");
format!("\\mathrm{{{}}}", p1)
} else {
format!("\\text{{{}}}", p1)
}
}
"roman numeral" => format!("\\mathrm{{{}}}", buf.p1.as_ref().map(field_str).unwrap_or_default()),
"state of aggregation" => format!("\\mskip2mu {}", buf.p1.as_ref().map(go_inner).unwrap_or_default()),
"state of aggregation subscript" => format!("\\mskip1mu {}", buf.p1.as_ref().map(go_inner).unwrap_or_default()),
"bond" => get_bond(buf.kind_.as_deref().unwrap_or("")),
"frac" => {
let c = format!(
"\\frac{{{}}}{{{}}}",
buf.p1.as_ref().map(field_str).unwrap_or_default(),
buf.p2.as_ref().map(field_str).unwrap_or_default()
);
format!("\\mathchoice{{\\textstyle{c}}}{{{c}}}{{{c}}}{{{c}}}")
}
"pu-frac" => {
let d = format!(
"\\frac{{{}}}{{{}}}",
buf.p1.as_ref().map(go_inner).unwrap_or_default(),
buf.p2.as_ref().map(go_inner).unwrap_or_default()
);
format!("\\mathchoice{{\\textstyle{d}}}{{{d}}}{{{d}}}{{{d}}}")
}
"tex-math" => format!("{} ", buf.p1.as_ref().map(field_str).unwrap_or_default()),
"frac-ce" => format!(
"\\frac{{{}}}{{{}}}",
buf.p1.as_ref().map(go_inner).unwrap_or_default(),
buf.p2.as_ref().map(go_inner).unwrap_or_default()
),
"overset" => format!(
"\\overset{{{}}}{{{}}}",
buf.p1.as_ref().map(go_inner).unwrap_or_default(),
buf.p2.as_ref().map(go_inner).unwrap_or_default()
),
"underset" => format!(
"\\underset{{{}}}{{{}}}",
buf.p1.as_ref().map(go_inner).unwrap_or_default(),
buf.p2.as_ref().map(go_inner).unwrap_or_default()
),
"underbrace" => format!(
"\\underbrace{{{}}}_{{{}}}",
buf.p1.as_ref().map(go_inner).unwrap_or_default(),
buf.p2.as_ref().map(go_inner).unwrap_or_default()
),
"color" => format!(
"{{\\color{{{}}}{{{}}}}}",
buf.color1.as_deref().unwrap_or(""),
buf.color2.as_ref().map(go_inner).unwrap_or_default()
),
"color0" => format!("\\color{{{}}}", buf.color.as_deref().unwrap_or("")),
"arrow" => {
let rd = buf.rd.as_ref().map(go_inner).unwrap_or_default();
let rq = buf.rq.as_ref().map(go_inner).unwrap_or_default();
let r = buf.r.as_deref().unwrap_or("");
let mut arrow = get_arrow(r).to_string();
if !rd.is_empty() || !rq.is_empty() {
if matches!(r, "<=>" | "<=>>" | "<<=>" | "<-->") {
arrow = format!("\\long{}", arrow);
if !rd.is_empty() {
arrow = format!("\\overset{{{}}}{{{}}}", rd, arrow);
}
if !rq.is_empty() {
arrow = if r == "<-->" {
format!("\\underset{{\\lower2mu{{{}}}}}{{{}}}", rq, arrow)
} else {
format!("\\underset{{\\lower6mu{{{}}}}}{{{}}}", rq, arrow)
};
}
arrow = format!(" {{}}\\mathrel{{{}}}{{}} ", arrow);
} else {
if !rq.is_empty() {
arrow.push_str(&format!("[{{{}}}]", rq));
}
arrow.push_str(&format!("{{{}}}", rd));
arrow = format!(" {{}}\\mathrel{{\\x{}}}{{}} ", arrow);
}
} else {
arrow = format!(" {{}}\\mathrel{{\\long{}}}{{}} ", arrow);
}
arrow
}
"operator" => get_operator(buf.kind_.as_deref().unwrap_or("")),
"1st-level escape" => format!("{} ", buf.p1.as_ref().map(field_str).unwrap_or_default()),
"space" => " ".to_string(),
"tinySkip" => "\\mkern2mu".to_string(),
"entitySkip" => "~".to_string(),
"pu-space-1" => "~".to_string(),
"pu-space-2" => "\\mkern3mu ".to_string(),
"1000 separator" => "\\mkern2mu ".to_string(),
"commaDecimal" => "{,}".to_string(),
"comma enumeration L" => format!("{{{}}}\\mkern6mu ", buf.p1.as_ref().map(field_str).unwrap_or_default()),
"comma enumeration M" => format!("{{{}}}\\mkern3mu ", buf.p1.as_ref().map(field_str).unwrap_or_default()),
"comma enumeration S" => format!("{{{}}}\\mkern1mu ", buf.p1.as_ref().map(field_str).unwrap_or_default()),
"hyphen" => "\\text{-}".to_string(),
"addition compound" => "\\,{\\cdot}\\,".to_string(),
"electron dot" => "\\mkern1mu \\bullet\\mkern1mu ".to_string(),
"KV x" => "{\\times}".to_string(),
"prime" => "\\prime ".to_string(),
"cdot" => "\\cdot ".to_string(),
"tight cdot" => "\\mkern1mu{\\cdot}\\mkern1mu ".to_string(),
"times" => "\\times ".to_string(),
"circa" => "{\\sim}".to_string(),
"^" => "uparrow".to_string(),
"v" => "downarrow".to_string(),
"ellipsis" => "\\ldots ".to_string(),
"/" => "/".to_string(),
" / " => "\\,/\\,".to_string(),
_ => String::new(),
}
}
fn get_arrow(a: &str) -> &'static str {
match a {
"->" | "\u{2192}" | "\u{27F6}" => "rightarrow",
"<-" => "leftarrow",
"<->" => "leftrightarrow",
"<-->" => "leftrightarrows",
"<=>" | "\u{21CC}" => "rightleftharpoons",
"<=>>" => "Rightleftharpoons",
"<<=>" => "Leftrightharpoons",
_ => "rightarrow",
}
}
fn get_bond(a: &str) -> String {
match a {
"-" | "1" => "{-}".to_string(),
"=" | "2" => "{=}".to_string(),
"#" | "3" => "{\\equiv}".to_string(),
"~" => "{\\tripledash}".to_string(),
"~-" => "{\\rlap{\\lower.1em{-}}\\raise.1em{\\tripledash}}".to_string(),
"~=" | "~--" => "{\\rlap{\\lower.2em{-}}\\rlap{\\raise.2em{\\tripledash}}-}".to_string(),
"-~-" => "{\\rlap{\\lower.2em{-}}\\rlap{\\raise.2em{-}}\\tripledash}".to_string(),
"..." => "{{\\cdot}{\\cdot}{\\cdot}}".to_string(),
"...." => "{{\\cdot}{\\cdot}{\\cdot}{\\cdot}}".to_string(),
"->" => "{\\rightarrow}".to_string(),
"<-" => "{\\leftarrow}".to_string(),
"<" => "{<}".to_string(),
">" => "{>}".to_string(),
_ => format!("{{{}}}", a),
}
}
fn get_operator(a: &str) -> String {
match a {
"+" => " {}+{} ".to_string(),
"-" => " {}-{} ".to_string(),
"=" => " {}={} ".to_string(),
"<" => " {}<{} ".to_string(),
">" => " {}>{} ".to_string(),
"<<" => " {}\\ll{} ".to_string(),
">>" => " {}\\gg{} ".to_string(),
"\\pm" => " {}\\pm{} ".to_string(),
"\\approx" | "$\\approx$" => " {}\\approx{} ".to_string(),
"v" | "(v)" => " \\downarrow{} ".to_string(),
"^" | "(^)" => " \\uparrow{} ".to_string(),
_ => format!(" {{{}}} ", a),
}
}
// =========================================================================
// 公开 API
// =========================================================================
/// 把 `\ce{...}` 内容转译为 LaTeX。任何内部异常回退为原样输出。
pub fn ce(input: &str) -> String {
to_tex(input, "ce")
}
/// 把 `\pu{...}` 内容转译为 LaTeX。任何内部异常回退为原样输出。
pub fn pu(input: &str) -> String {
to_tex(input, "pu")
}
fn to_tex(input: &str, kind: &str) -> String {
let result = std::panic::catch_unwind(|| {
let parsed = go(input, kind);
texify_go(&parsed, kind != "tex")
});
result.unwrap_or_else(|_| input.to_string())
}
// 状态机转移表 + 局部动作在 mhchem_tables.rs同模块含大量常量数据
include!("mhchem_tables.rs");
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ce_water_produces_mathrm() {
let tex = ce("H2O");
assert!(tex.contains(r"\mathrm{H}"), "H 应为直立体: {tex}");
assert!(tex.contains(r"\mathrm{O}"), "O 应为直立体: {tex}");
}
#[test]
fn ce_reaction_has_arrow() {
let tex = ce("2H2 + O2 -> 2H2O");
assert!(tex.contains("rightarrow"), "应含反应箭头: {tex}");
}
#[test]
fn ce_empty_is_empty() {
assert_eq!(ce(""), "");
}
#[test]
fn ce_does_not_panic_on_garbage() {
// 任意乱码不应 panic容错回退为原样
let _ = ce("}}}{{{]][[");
let _ = ce("\\frac{");
let _ = ce("<<<<>>>>");
}
#[test]
fn ce_gas_arrow_becomes_uparrow() {
// 行尾 ^ 气体符号转译后应含 uparrow消解原行尾 ^ 解析错误)。
let tex = ce("CaCO3 ->[\\Delta] CaO + CO2 ^");
assert!(tex.contains("uparrow") || tex.contains("rightarrow"), "气体/反应符号: {tex}");
}
#[test]
fn pu_unit_has_mathrm() {
let tex = pu("9.8 m/s^2");
assert!(tex.contains(r"\mathrm"), "单位应有直立体: {tex}");
}
#[test]
fn pu_empty_is_empty() {
assert_eq!(pu(""), "");
}
#[test]
fn ce_charge_superscript() {
let tex = ce("SO4^2-");
// 应含上标(^...)且无 panic。
assert!(!tex.is_empty(), "离子应产出非空: {tex}");
}
}