yggdrasil/src/api/mhchem.rs
xfy d1013dfa08
Some checks failed
CI / check (push) Has been cancelled
CI / build (push) Has been cancelled
refactor(mhchem): re! 宏编译失败降级为不匹配,补上 panic=abort 缺失的防御
re! 宏原先 LazyLock::new(|| Regex::new($pat).unwrap()),在 panic="abort" 下
任何正则转录错误(如曾经的 ". __* " InvalidClass)都会直接杀进程,且 to_tex
的 catch_unwind 在 abort 模式下无法兜住。

引入 compile_or_log(pat) -> Option<Regex>:编译失败记 tracing::error 日志
并返回 None(绝不 panic)。封装为 CompiledPat(Option<Regex>) newtype,把
「坏正则降级为不匹配」的语义集中在一处:

  - captures_head(input) -> Option<Captures>:坏正则/未匹配统一 None
  - is_match(input) -> bool:坏正则/未匹配统一 false

re! 宏改为产出 &CompiledPat,调用方直接 re!("...").captures_head(...) /
.is_match(...),无需再到处写 re_ref(...)? 或手写 None 回退。状态机遇到
坏模式自然退到 "else" 兜底分支产出原样字符,不影响整体渲染。

适配点:Pat::Re、pat_match_head、match_pattern 的 re_match 闭包、
pat_enumber/state_of_aggregation/amount/formula、1/2 动作、mhchem_tables.rs
的 ce_action、3 个 static LETTERS/GREEK/ONE_GREEK_RE。

回归测试 compile_or_log_degrades_bad_regex_without_panic 断言坏正则返回 None
不 panic。clippy 干净,622 测试全过。
2026-07-23 18:16:22 +08:00

1415 lines
54 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`](crate::api::mhchem::ce) / [`pu`](crate::api::mhchem::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>),
}
/// 解析中间表示:字面字符串或节点。
#[allow(clippy::large_enum_variant)] // 机械移植:镜像 mhchemParser 上游结构Parsed 经 Vec 进堆,非栈热路径
#[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
}
// =========================================================================
// 模式匹配
// =========================================================================
/// 编译正则,失败时记日志并返回 `None`(绝不 panic
///
/// `re!` 宏的 `.unwrap()` 在 `panic = "abort"` 下会直接杀进程——任何未来的
/// 正则转录错误都会让整篇含化学公式的文章渲染崩溃。改为降级返回 `None`
/// 后,坏正则只让该模式匹配失败(状态机退到 "else" 兜底分支产出原样字符),
/// 不影响整体渲染。`all_match_patterns_compile` 测试仍会在测试期捕获回归。
fn compile_or_log(pat: &str) -> Option<Regex> {
match Regex::new(pat) {
Ok(re) => Some(re),
Err(e) => {
tracing::error!(target: "yggdrasil::mhchem", pattern = pat, error = ?e, "mhchem 正则编译失败,该模式将降级为不匹配");
None
}
}
}
/// 编译产物:把「坏正则降级为不匹配」的语义封装在一处。
///
/// 调用方不再到处写 `re_ref(re!(...))?` 或手写 `None` 回退——直接调
/// [`CompiledPat::captures_head`] / [`CompiledPat::is_match`],编译失败时返回
/// 不匹配(`None` / `false`)。对应原始 `Regex` 方法的子集。
struct CompiledPat(Option<Regex>);
impl CompiledPat {
/// 锚定头部匹配(坏正则 → `None`)。匹配失败/编译失败统一为不匹配。
///
/// fancy-regex 的 `Captures` 同时借用 regex 与 input故签名把两者绑到同一
/// 生命周期 `'s`。调用处 `self` 来自 `&*RE``'static`),所以实际约束是 input
/// 须在 captures 使用期间存活——与原始 `re.captures(input)` 一致。
#[inline]
fn captures_head<'s>(&'s self, input: &'s str) -> Option<fancy_regex::Captures<'s>> {
self.0.as_ref()?.captures(input).ok().flatten()
}
/// 是否匹配(坏正则 → `false`)。
#[inline]
fn is_match(&self, input: &str) -> bool {
self.0
.as_ref()
.and_then(|r| r.is_match(input).ok())
.unwrap_or(false)
}
}
/// 惰性编译字面量正则,编译失败降级为 [`CompiledPat`]None
macro_rules! re {
($pat:literal) => {{
static RE: LazyLock<CompiledPat> =
LazyLock::new(|| CompiledPat(compile_or_log($pat)));
&*RE
}};
}
/// 字面量或正则模式(`findObserveGroups` 的参数)。
enum Pat {
Lit(&'static str),
Re(&'static CompiledPat),
}
/// 在 `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(cp) => cp.captures_head(input).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` 时返回其区间。
///
/// 必须按**字符**而非字节步进:化学公式可含多字节 UTF-8 字符(如中文说明
/// 文字「浓」占 3 字节)。逐字节 `i+=1` 会让 `&input[i..]` 落在字符内部,
/// 触发 `byte index N is not a char boundary` panic在 panic=abort 下直接
/// 杀进程),且 `bytes[i] as char` 会把多字节字符的首字节误判为花括号,
/// 导致输出损坏。`char_indices` 保证每步都在字符边界上。
fn find_observe_end(input: &str, start: usize, end_chars: &Pat) -> Option<Span> {
let mut braces = 0i32;
// start 是上一步 beg_incl 匹配的长度;该匹配来自 Pat::Lit 或锚定正则,
// 落在字符边界上。校验后用于首次切片,随后只按 char_indices 的边界前进。
let start = input.get(start..).map(|_| start).unwrap_or(input.len());
for (i, a) in input.char_indices().skip_while(|(b, _)| *b < start) {
// 结束定界符匹配(仅在花括号平衡时)
if braces == 0 {
if let Some(matched) = pat_match_head(end_chars, &input[i..]) {
return Some(Span {
begin: i,
end: i + matched.len(),
});
}
}
if a == '{' {
braces += 1;
} else if a == '}' {
if braces == 0 {
// ExtraCloseMissingOpen —— 原实现抛错,这里返回 None 容错
return None;
}
braces -= 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<CompiledPat> = LazyLock::new(|| {
CompiledPat(compile_or_log(&format!(
"^(?:[a-zA-Z\u{03B1}-\u{03C9}\u{0391}-\u{03A9}?@]|(?:\\\\(?:{})(?:\\s+|\\{{\\}}|(?![a-zA-Z]))))+",
GREEK_NAMES
)))
});
static GREEK_RE: LazyLock<CompiledPat> = LazyLock::new(|| {
CompiledPat(compile_or_log(&format!(
"^\\\\(?:{})(?:\\s+|\\{{\\}}|(?![a-zA-Z]))",
GREEK_NAMES
)))
});
static ONE_GREEK_RE: LazyLock<CompiledPat> = LazyLock::new(|| {
CompiledPat(compile_or_log(
"^(?:\\$?[\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]))$"
))
});
/// 按名匹配模式(对应 `_mhchemParser.patterns.match_`)。
fn match_pattern(name: &str, input: &str) -> Option<MMatch> {
// ── 正则模式 ──
// `re!` 产物是 `&CompiledPat`:编译失败的坏正则 `.0` 为 None
// 这里 `.0.as_ref()?` 降级为不匹配,状态机退到 "else" 兜底分支。
let re_match = |cp: &CompiledPat| cp.0.as_ref().and_then(|r| fancy_match(r, 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_head(input)?;
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()) {
return Some(a);
}
let re2 = re!("^(?:\\((?:\\\\ca\\s?)?\\$[amothc]\\$\\))");
let caps = re2.captures_head(input)?;
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_head(input) {
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) {
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) {
return None;
}
let re = re!("^(?:[a-z]|(?:[0-9\\ +\\-\\,\\.\\(\\)]+[a-z])+[0-9\\ +\\-\\,\\.\\(\\)]*|(?:[a-z][0-9\\ +\\-\\,\\.\\(\\)]+)+[a-z]?)$");
let caps = re.captures_head(input)?;
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`
// =========================================================================
#[allow(clippy::large_enum_variant)] // 同上Out 为动作返回值,瞬态使用
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 {
parenthesis_level: 0,
..Default::default()
};
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.first().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();
}
// 坏正则(编译失败)时 captures_head 返回 None直接结束 1/2 动作
// (产出已累积的前缀符号)。
if let Some(caps) = re!("^([0-9]+|\\$[a-z]\\$|[a-z])\\/([0-9]+)(\\$[a-z]\\$|[a-z])?$").captures_head(&s) {
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。
///
/// 容错设计(不依赖 `catch_unwind`,因 release 的 `panic = "abort"` 下它无效):
/// - 正则编译走 [`compile_or_log`],失败降级为不匹配(状态机退到 "else" 兜底)。
/// - 状态机有 watchdog 防死循环。
/// - `to_tex` 仍包 `catch_unwind`,仅作 dev/test 护栏。
///
/// 坏公式可能产出退化输出,但不应 panic。[`all_match_patterns_compile`] 测试
/// 在测试期捕获正则转录回归。
pub fn ce(input: &str) -> String {
to_tex(input, "ce")
}
/// 把 `\pu{...}` 内容转译为 LaTeX容错同 [`ce`])。
pub fn pu(input: &str) -> String {
to_tex(input, "pu")
}
fn to_tex(input: &str, kind: &str) -> String {
// 仅在 panic=unwinddev/test时有效release 的 panic=abort 下此处的
// catch_unwind 无法阻止进程终止。保留它是为了本地开发时坏公式不炸测试。
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}");
}
/// 回归测试:`". __* "` 模式的 regex 曾因转录错误把 `|` 放进了字符类
/// 内部(`[.\u{22C5}\u{00B7}\u{2022}|[*]`fancy-regex 报
/// `ParseError(20, InvalidClass)``re!` 宏的 `.unwrap()` 触发 panic。
/// 上游 mhchemParser 是 `[...]|[*]`alternation 在类外)。
/// 这些输入会强制该 LazyLock 正则编译,必须产出非空且不 panic。
#[test]
fn ce_dot_bullet_bond_inputs_dont_panic() {
for s in [".", "·", "", "", "*", ". ", "••", "· ", "H·OH", "CaCO3 · H2O"] {
let tex = ce(s);
assert!(!tex.is_empty(), "ce({s:?}) 不应为空");
}
}
/// 回归测试:`compile_or_log` 是 re! 宏硬化的核心——对坏正则必须降级
/// 返回 `None` 而非 panic。`re!` 宏原先用 `.unwrap()`,在 panic=abort 下
/// 任何未来的正则转录错误(如曾经的 `". __* "` InvalidClass都会直接
/// 杀进程。降级后坏正则只让该模式匹配失败,状态机退到 "else" 兜底分支,
/// 不影响整体渲染。
#[test]
fn compile_or_log_degrades_bad_regex_without_panic() {
// 合法正则 → Some
assert!(compile_or_log("^a").is_some(), "合法正则应编译成功");
// 非法正则(未闭合字符类,曾触发 ParseError(20, InvalidClass)
let bad = "^([abc|[*])";
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| compile_or_log(bad)));
assert!(r.is_ok(), "compile_or_log 对坏正则不应 panic");
assert!(r.unwrap().is_none(), "坏正则应返回 None");
}
/// 回归测试:`find_observe_end` 曾用逐字节 `i += 1` 步进,遇多字节
/// UTF-8 字符(如中文「浓」占 3 字节)会让 `&input[i..]` 落在字符内部,
/// 触发 `byte index N is not a char boundary` panic。在 panic=abort 下
/// 直接杀进程(曾导致 rebuild_content_html 重建含中文化学式的文章时崩溃)。
/// 修复改为按 `char_indices` 字符边界步进。这些输入必须不 panic。
#[test]
fn ce_multibyte_char_in_braces_does_not_panic() {
for s in ["{浓}", "浓H2SO4", "{中文}", "H{浓}O", "\\frac{浓}{稀}", "[浓]"] {
// catch_unwind 仅 dev/test 护栏release panic=abort 下由本修复保证。
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| ce(s)));
assert!(r.is_ok(), "ce({s:?}) PANICKED (char boundary)");
}
}
/// 保护性任意多字节字符组合emoji/日韩文/组合字符/四字节区)都不应
/// panic。覆盖花括号/方括号/`\frac`/`$...$` 等所有走 find_observe_end 的路径。
#[test]
fn ce_arbitrary_multibyte_does_not_panic() {
let inputs = [
"🔥", "café", "naïve", "Σ", "αβγ", "ΔH", "你好世界", "안녕", "こんにちは",
"{🧪}", "H₂O", "[α]", "\\frac{β}{γ}", "${日本}$", "A·B•C⋅D",
"naïve H2O", "{β-Gal}", "😀😂", "\u{1F9EA}", // test tube emoji
];
for s in inputs {
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| ce(s)));
assert!(r.is_ok(), "ce({s:?}) PANICKED");
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| pu(s)));
assert!(r.is_ok(), "pu({s:?}) PANICKED");
}
}
/// 保护性:强制 `match_pattern` 的每个分支至少运行一次,让每个 `re!`
/// 定义的 regex 都被编译。任一转录错误都会在此暴露,而非等到生产环境
/// 被特定输入触发后 panic=abort 整个进程。
#[test]
fn all_match_patterns_compile() {
for name in [
"empty", "else", "else2", "space", "space A", "space$", "a-z", "x", "x$", "i$",
"letters", "\\greek", "one lowercase latin letter $",
"$one lowercase latin letter$ $", "one lowercase greek letter $", "digits",
"-9.,9", "-9.,9 no missing 0", "(-)(9)^(-9)", "_{(state of aggregation)}$",
"{[(", ")]}", ", ", ",", ".", ". __* ", "...", "^a", "^\\x", "^(-1)", "'", "_9",
"_\\x", "^_", "{}^", "{}", "=<>", "#", "+", "-$", "-9", "- orbital overlap", "-",
"pm-operator", "operator", "arrowUpDown", "->", "CMT", "1st-level escape", "\\,",
"\\ca", "\\x", "orbital", "others", "oxidation$", "d-oxidation$", "1/2$",
"(KV letters),", "uprightEntities", "/", "//", "*",
] {
// 返回值不重要,只要不 panicregex 编译成功)即可。
let _ = match_pattern(name, "");
}
}
}