Skip to main content

yggdrasil/api/
mhchem.rs

1#![cfg(feature = "server")]
2
3//! mhchem 化学公式转译器:把 `\ce{...}` / `\pu{...}` 预转译为标准 LaTeX,
4//! 再交给 katex 渲染。
5//!
6//! 这是 [mhchemParser](https://github.com/mhchem/mhchemParser) 4.2.2(Apache-2.0)
7//! 的**机械移植**:状态机 + texify 输出。mhchemParser 是纯字符串→字符串转译器,
8//! `ce("H2O")` → `\mathrm{H}\sb{2}\mathrm{O}` 之类,无需嵌入 katex 内部解析树。
9//!
10//! 移植要点(TS→Rust):
11//! - 动态 `buffer` 对象 → `Buffer` 结构体(`Option` 字段 + `clear`)。
12//! - 动态 `Parsed = string | object` → `Parsed` 枚举;节点字段用 `Field`(字符串或节点向量)。
13//! - 正则模式(含 lookahead `(?=)`/`(?!)`,Rust `regex` 不支持)→ `fancy-regex`(已被
14//!   syntect 间接引入,此处显式声明)。
15//! - `findObserveGroups` 花括号配对扫描 → `find_observe_groups`。
16//! - 状态机转移表 → `build_transitions` 展开(对应 `_mhchemCreateTransitions`)。
17//! - texify 输出采用 KaTeX `contrib/mhchem` 的改编(服务端用 katex-rs,不是 MathJax):
18//!   `\rlap`/`\llap` → `\mathrlap`/`\mathllap`;`\raise`/`\lower` → `\raisebox`;
19//!   反应箭头改用可伸缩箭头 `\x…`;`\tripledash` 由 katex.rs 的宏提供。
20//!
21//! 公开 API:[`ce`](crate::api::mhchem::ce) / [`pu`](crate::api::mhchem::pu)。任意内部异常都回退为原样输出(绝不 panic,与
22//! katex.rs 的容错哲学一致)。
23//!
24//! -----------------------------------------------------------------------
25//! Copyright 2015-2023 Martin Hensel(mhchemParser 原作者)。Apache-2.0。
26//! -----------------------------------------------------------------------
27
28use fancy_regex::Regex;
29use std::collections::HashMap;
30use std::sync::LazyLock;
31
32// =========================================================================
33// 数据模型
34// =========================================================================
35
36/// 模式匹配值:单字符串或捕获组数组(正则 >1 个捕获组时为数组)。
37#[derive(Clone, Debug)]
38enum MVal {
39    S(String),
40    V(Vec<String>),
41}
42
43/// 模式匹配结果。
44#[derive(Clone, Debug)]
45struct MMatch {
46    m: MVal,
47    remainder: String,
48}
49
50/// 解析中间表示的字段值:字符串或子表达式节点向量。
51#[derive(Clone, Debug)]
52enum Field {
53    Str(String),
54    Nodes(Vec<Parsed>),
55}
56
57/// 解析中间表示:字面字符串或节点。
58#[allow(clippy::large_enum_variant)] // 机械移植:镜像 mhchemParser 上游结构,Parsed 经 Vec 进堆,非栈热路径
59#[derive(Clone, Debug)]
60enum Parsed {
61    S(String),
62    N(NodeData),
63}
64
65#[derive(Clone, Debug, Default)]
66struct NodeData {
67    type_: String,
68    p1: Option<Field>,
69    p2: Option<Field>,
70    a: Option<Field>,
71    b: Option<Field>,
72    p: Option<Field>,
73    o: Option<Field>,
74    q: Option<Field>,
75    d: Option<Field>,
76    color: Option<String>,
77    color1: Option<String>,
78    color2: Option<Field>,
79    r: Option<String>,
80    rd: Option<Field>,
81    rq: Option<Field>,
82    d_type: Option<String>,
83    kind_: Option<String>,
84}
85
86/// 状态机缓冲区(对应 TS 的动态 `buffer` 对象)。
87#[derive(Clone, Default)]
88struct Buffer {
89    a: Option<String>,
90    b: Option<String>,
91    p: Option<String>,
92    o: Option<String>,
93    q: Option<String>,
94    d: Option<String>,
95    rm: Option<String>,
96    text_: Option<String>,
97    r: Option<String>,
98    rd: Option<String>,
99    rq: Option<String>,
100    rdt: Option<String>,
101    rqt: Option<String>,
102    d_type: Option<String>,
103    sb: bool,
104    begins_with_bond: bool,
105    parenthesis_level: i32,
106}
107
108impl Buffer {
109    /// 清空内容字段。`keep` 为真时保留 `parenthesis_level` 与 `begins_with_bond`
110    /// (对应 ce 的 output:`delete buffer[p]` 跳过这两项)。
111    fn clear(&mut self, keep: bool) {
112        let (pl, bb) = if keep {
113            (self.parenthesis_level, self.begins_with_bond)
114        } else {
115            (0, false)
116        };
117        *self = Buffer {
118            parenthesis_level: pl,
119            begins_with_bond: bb,
120            ..Buffer::default()
121        };
122    }
123}
124
125// =========================================================================
126// 转移表
127// =========================================================================
128
129#[derive(Clone)]
130struct ActionRef {
131    type_: String,
132    option: Option<String>,
133}
134
135#[derive(Clone, Default)]
136struct Task {
137    actions: Vec<ActionRef>,
138    next_state: Option<String>,
139    revisit: bool,
140    to_continue: bool,
141}
142
143#[derive(Clone)]
144struct Transition {
145    pattern: String,
146    task: Task,
147}
148
149/// 原始转移条目(对应 TS 字面量,`patterns`/`states` 均为 `|` 分隔的合并名)。
150struct RawEntry {
151    patterns: &'static str,
152    states: &'static str,
153    task: Task,
154}
155
156/// 展开 `{pattern: {state: task}}` 为 `{state => [(pattern, task)]}`。
157/// 对应 `_mhchemCreateTransitions`:拆分 `|`、`'*'` 插入所有状态。
158fn build_transitions(raw: &[RawEntry]) -> HashMap<String, Vec<Transition>> {
159    let mut transitions: HashMap<String, Vec<Transition>> = HashMap::new();
160    // 1. 收集所有状态(拆分 states 的 `|`)
161    for e in raw {
162        for state in e.states.split('|') {
163            transitions.entry(state.to_string()).or_default();
164        }
165    }
166    // 2. 填充(拆分 patterns 与 states 的 `|`,`'*'` 插入所有已收集状态)
167    let all_states: Vec<String> = transitions.keys().cloned().collect();
168    for e in raw {
169        let state_list: Vec<&str> = e.states.split('|').collect();
170        for (idx, _state) in state_list.iter().enumerate() {
171            for pattern in e.patterns.split('|') {
172                let insert_states: Vec<String> = if state_list[idx] == "*" {
173                    all_states.clone()
174                } else {
175                    vec![state_list[idx].to_string()]
176                };
177                for s in insert_states {
178                    transitions.entry(s).or_default().push(Transition {
179                        pattern: pattern.to_string(),
180                        task: e.task.clone(),
181                    });
182                }
183            }
184        }
185    }
186    transitions
187}
188
189// =========================================================================
190// 模式匹配
191// =========================================================================
192
193/// 编译正则,失败时记日志并返回 `None`(绝不 panic)。
194///
195/// `re!` 宏原先的 `.unwrap()` 会在正则转录错误时 panic,让整篇含化学公式的
196/// 文章渲染失败。改为降级返回 `None`
197/// 后,坏正则只让该模式匹配失败(状态机退到 "else" 兜底分支产出原样字符),
198/// 不影响整体渲染。`all_match_patterns_compile` 测试仍会在测试期捕获回归。
199fn compile_or_log(pat: &str) -> Option<Regex> {
200    match Regex::new(pat) {
201        Ok(re) => Some(re),
202        Err(e) => {
203            tracing::error!(target: "yggdrasil::mhchem", pattern = pat, error = ?e, "mhchem 正则编译失败,该模式将降级为不匹配");
204            None
205        }
206    }
207}
208
209/// 编译产物:把「坏正则降级为不匹配」的语义封装在一处。
210///
211/// 调用方不再到处写 `re_ref(re!(...))?` 或手写 `None` 回退——直接调
212/// [`CompiledPat::captures_head`] / [`CompiledPat::is_match`],编译失败时返回
213/// 不匹配(`None` / `false`)。对应原始 `Regex` 方法的子集。
214struct CompiledPat(Option<Regex>);
215
216impl CompiledPat {
217    /// 锚定头部匹配(坏正则 → `None`)。匹配失败/编译失败统一为不匹配。
218    ///
219    /// fancy-regex 0.19 的 captures 只借用 input;`str` 指明 UTF-8 文本输入。
220    #[inline]
221    fn captures_head<'s>(&self, input: &'s str) -> Option<fancy_regex::Captures<'s, str>> {
222        self.0.as_ref()?.captures(input).ok().flatten()
223    }
224
225    /// 是否匹配(坏正则 → `false`)。
226    #[inline]
227    fn is_match(&self, input: &str) -> bool {
228        self.0
229            .as_ref()
230            .and_then(|r| r.is_match(input).ok())
231            .unwrap_or(false)
232    }
233}
234
235/// 惰性编译字面量正则,编译失败降级为 [`CompiledPat`](None)。
236macro_rules! re {
237    ($pat:literal) => {{
238        static RE: LazyLock<CompiledPat> = LazyLock::new(|| CompiledPat(compile_or_log($pat)));
239        &*RE
240    }};
241}
242
243/// 字面量或正则模式(`findObserveGroups` 的参数)。
244enum Pat {
245    Lit(&'static str),
246    Re(&'static CompiledPat),
247}
248
249/// 在 `input` 起始处匹配 `pat`,返回匹配到的文本(不含 remainder)。
250fn pat_match_head(pat: &Pat, input: &str) -> Option<String> {
251    match pat {
252        Pat::Lit(s) => {
253            if input.starts_with(s) {
254                Some((*s).to_string())
255            } else {
256                None
257            }
258        }
259        Pat::Re(cp) => cp.captures_head(input).and_then(|c| {
260            let whole = c.get(0).map(|m| m.as_str())?;
261            // 模式均锚定 ^,确认从 0 开始
262            if input.starts_with(whole) {
263                Some(whole.to_string())
264            } else {
265                None
266            }
267        }),
268    }
269}
270
271/// `findObserveGroups`:花括号感知的定界符配对扫描。
272///
273/// 移植自 TS `findObserveGroups`。`beg*_excl/incl`/`end*_incl/excl` 为定界符,
274/// `combine` 控制第二组结果是否拼接为字符串。
275#[allow(clippy::too_many_arguments)]
276fn find_observe_groups(
277    input: &str,
278    beg_excl: Pat,
279    beg_incl: Pat,
280    end_incl: Pat,
281    end_excl: Pat,
282    beg2_excl: Option<Pat>,
283    beg2_incl: Option<Pat>,
284    end2_incl: Option<Pat>,
285    end2_excl: Option<Pat>,
286    combine: bool,
287) -> Option<MMatch> {
288    // 第一组
289    let m0 = pat_match_head(&beg_excl, input)?;
290    let rest0 = &input[m0.len()..];
291    let m1 = pat_match_head(&beg_incl, rest0)?;
292    // 结束定界符:endIncl || endExcl(endIncl 为空字面量时取 endExcl)
293    let end_is_incl = !is_empty_pat(&end_incl);
294    let end_chars = if end_is_incl { &end_incl } else { &end_excl };
295    let e = find_observe_end(rest0, m1.len(), end_chars)?;
296    let body_end = if end_is_incl { e.end } else { e.begin };
297    let match1 = rest0[..body_end].to_string();
298    let after1 = &rest0[e.end..];
299    // 无第二组
300    if beg2_excl.is_none() && beg2_incl.is_none() {
301        return Some(MMatch {
302            m: MVal::S(match1),
303            remainder: after1.to_string(),
304        });
305    }
306    // 第二组(递归)
307    let g2 = find_observe_groups(
308        after1,
309        beg2_excl.unwrap_or(Pat::Lit("")),
310        beg2_incl.unwrap_or(Pat::Lit("")),
311        end2_incl.unwrap_or(Pat::Lit("")),
312        end2_excl.unwrap_or(Pat::Lit("")),
313        None,
314        None,
315        None,
316        None,
317        false,
318    )?;
319    let mval = match g2.m {
320        MVal::S(s2) => {
321            if combine {
322                MVal::S(format!("{}{}", match1, s2))
323            } else {
324                MVal::V(vec![match1, s2])
325            }
326        }
327        MVal::V(_) => MVal::V(vec![match1]), // 不应发生(第二组无二级)
328    };
329    Some(MMatch {
330        m: mval,
331        remainder: g2.remainder,
332    })
333}
334
335fn is_empty_pat(p: &Pat) -> bool {
336    matches!(p, Pat::Lit(""))
337}
338
339struct Span {
340    begin: usize,
341    end: usize,
342}
343
344/// 从 `rest0` 的 `start` 位置扫描,跟踪花括号深度,在深度 0 遇到 `end_chars` 时返回其区间。
345///
346/// 必须按**字符**而非字节步进:化学公式可含多字节 UTF-8 字符(如中文说明
347/// 文字「浓」占 3 字节)。逐字节 `i+=1` 会让 `&input[i..]` 落在字符内部,
348/// 触发 `byte index N is not a char boundary` panic,且 `bytes[i] as char`
349/// 会把多字节字符的首字节误判为花括号,
350/// 导致输出损坏。`char_indices` 保证每步都在字符边界上。
351fn find_observe_end(input: &str, start: usize, end_chars: &Pat) -> Option<Span> {
352    let mut braces = 0i32;
353    // start 是上一步 beg_incl 匹配的长度;该匹配来自 Pat::Lit 或锚定正则,
354    // 落在字符边界上。校验后用于首次切片,随后只按 char_indices 的边界前进。
355    let start = input.get(start..).map(|_| start).unwrap_or(input.len());
356    for (i, a) in input.char_indices().skip_while(|(b, _)| *b < start) {
357        // 结束定界符匹配(仅在花括号平衡时)
358        if braces == 0 {
359            if let Some(matched) = pat_match_head(end_chars, &input[i..]) {
360                return Some(Span {
361                    begin: i,
362                    end: i + matched.len(),
363                });
364            }
365        }
366        if a == '{' {
367            braces += 1;
368        } else if a == '}' {
369            if braces == 0 {
370                // ExtraCloseMissingOpen —— 原实现抛错,这里返回 None 容错
371                return None;
372            }
373            braces -= 1;
374        }
375    }
376    None
377}
378
379/// 把 fancy-regex 的捕获结果转为 [`MMatch`](锚定 `^`,whole 从 0 起)。
380fn fancy_match(re: &Regex, input: &str) -> Option<MMatch> {
381    let caps = re.captures(input).ok()??;
382    let whole = caps.get(0)?.as_str().to_string();
383    if !input.starts_with(&whole) {
384        return None;
385    }
386    let n_groups = re.captures_len();
387    let mval = if n_groups >= 2 {
388        let mut v = Vec::with_capacity(n_groups);
389        for gi in 1..=n_groups {
390            v.push(
391                caps.get(gi)
392                    .map(|m| m.as_str().to_string())
393                    .unwrap_or_default(),
394            );
395        }
396        MVal::V(v)
397    } else {
398        // n_groups == 1 或 0:match[1] || match[0](空串视作未匹配)
399        let g1 = caps
400            .get(1)
401            .map(|m| m.as_str().to_string())
402            .filter(|s| !s.is_empty());
403        MVal::S(g1.unwrap_or(whole.clone()))
404    };
405    Some(MMatch {
406        m: mval,
407        remainder: input[whole.len()..].to_string(),
408    })
409}
410
411/// 希腊字母宏集合(`letters` / `\greek` 等模式用到)。
412static 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";
413
414static LETTERS_RE: LazyLock<CompiledPat> = LazyLock::new(|| {
415    CompiledPat(compile_or_log(&format!(
416        "^(?:[a-zA-Z\u{03B1}-\u{03C9}\u{0391}-\u{03A9}?@]|(?:\\\\(?:{})(?:\\s+|\\{{\\}}|(?![a-zA-Z]))))+",
417        GREEK_NAMES
418    )))
419});
420
421static GREEK_RE: LazyLock<CompiledPat> = LazyLock::new(|| {
422    CompiledPat(compile_or_log(&format!(
423        "^\\\\(?:{})(?:\\s+|\\{{\\}}|(?![a-zA-Z]))",
424        GREEK_NAMES
425    )))
426});
427
428static ONE_GREEK_RE: LazyLock<CompiledPat> = LazyLock::new(|| {
429    CompiledPat(compile_or_log(
430        "^(?:\\$?[\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]))$"
431    ))
432});
433
434/// 按名匹配模式(对应 `_mhchemParser.patterns.match_`)。
435fn match_pattern(name: &str, input: &str) -> Option<MMatch> {
436    // ── 正则模式 ──
437    // `re!` 产物是 `&CompiledPat`:编译失败的坏正则 `.0` 为 None,
438    // 这里 `.0.as_ref()?` 降级为不匹配,状态机退到 "else" 兜底分支。
439    let re_match = |cp: &CompiledPat| cp.0.as_ref().and_then(|r| fancy_match(r, input));
440    match name {
441        "empty" => re_match(re!("^$")),
442        "else" | "else2" => re_match(re!("^.")),
443        "space" => re_match(re!("^\\s")),
444        "space A" => re_match(re!("^\\s(?=[A-Z\\\\$])")),
445        "space$" => re_match(re!("^\\s$")),
446        "a-z" => re_match(re!("^[a-z]")),
447        "x" => re_match(re!("^x")),
448        "x$" => re_match(re!("^x$")),
449        "i$" => re_match(re!("^i$")),
450        "letters" => re_match(&LETTERS_RE),
451        "\\greek" => re_match(&GREEK_RE),
452        "one lowercase latin letter $" => re_match(re!("^(?:([a-z])(?:$|[^a-zA-Z]))$")),
453        "$one lowercase latin letter$ $" => re_match(re!("^\\$(?:([a-z])(?:$|[^a-zA-Z]))\\$$")),
454        "one lowercase greek letter $" => re_match(&ONE_GREEK_RE),
455        "digits" => re_match(re!("^[0-9]+")),
456        "-9.,9" => re_match(re!("^[+\\-]?(?:[0-9]+(?:[,.][0-9]+)?|[0-9]*(?:\\.[0-9]+))")),
457        "-9.,9 no missing 0" => re_match(re!("^[+\\-]?[0-9]+(?:[.,][0-9]+)?")),
458        "(-)(9)^(-9)" => re_match(re!("^(\\+\\-|\\+\\/\\-|\\+|\\-|\\\\pm\\s?)?([0-9]+(?:[,.][0-9]+)?|[0-9]*(?:\\.[0-9]+)?)\\^([+\\-]?[0-9]+|\\{[+\\-]?[0-9]+\\})")),
459        "_{(state of aggregation)}$" => re_match(re!("^_\\{(\\([a-z]{1,3}\\))\\}")),
460        "{[(" => re_match(re!("^(?:\\\\\\{|\\[|\\()")),
461        ")]}" => re_match(re!("^(?:\\)|\\]|\\\\\\})")),
462        ", " => re_match(re!("^[,;]\\s*")),
463        "," => re_match(re!("^[,;]")),
464        "." => re_match(re!("^[.]")),
465        ". __* " => re_match(re!("^([.\u{22C5}\u{00B7}\u{2022}]|[*])\\s*")),
466        "..." => re_match(re!("^\\.\\.\\.(?=$|[^.])")),
467        "^a" => re_match(re!("^\\^([0-9]+|[^\\\\_])")),
468        "^\\x" => re_match(re!("^\\^(\\\\[a-zA-Z]+)\\s*")),
469        "^(-1)" => re_match(re!("^\\^(-?\\d+)")),
470        "'" => re_match(re!("^'")),
471        "_9" => re_match(re!("^_([+\\-]?[0-9]+|[^\\\\])")),
472        "_\\x" => re_match(re!("^_(\\\\[a-zA-Z]+)\\s*")),
473        "^_" => re_match(re!("^(?:\\^(?=_)|\\_(?=\\^)|[\\^_]$)")),
474        "{}^" => re_match(re!("^\\{\\}(?=\\^)")),
475        "{}" => re_match(re!("^\\{\\}")),
476        "=<>" => re_match(re!("^[=<>]")),
477        "#" => re_match(re!("^[#\u{2261}]")),
478        "+" => re_match(re!("^\\+")),
479        "-$" => re_match(re!("^-(?=[\\s_},;\\]/]|$|\\([a-z]+\\))")),
480        "-9" => re_match(re!("^-(?=[0-9])")),
481        "- orbital overlap" => re_match(re!("^-(?=(?:[spd]|sp)(?:$|[\\s,;\\)\\]\\}]))")),
482        "-" => re_match(re!("^-")),
483        "pm-operator" => re_match(re!("^(?:\\\\pm|\\$\\\\pm\\$|\\+-|\\+\\/-)")),
484        "operator" => re_match(re!("^(?:\\+|(?:[\\-=<>]|<<|>>|\\\\approx|\\$\\\\approx\\$)(?=\\s|$|-?[0-9]))")),
485        "arrowUpDown" => re_match(re!("^(?:v|\\(v\\)|\\^|\\(\\^\\))(?=$|[\\s,;\\)\\]\\}])")),
486        "->" => re_match(re!("^(?:<->|<-->|->|<-|<=>>|<<=>|<=>|[\u{2192}\u{27F6}\u{21CC}])")),
487        "CMT" => re_match(re!("^[CMT](?=\\[)")),
488        "1st-level escape" => re_match(re!("^(&|\\\\\\\\|\\\\hline)\\s*")),
489        "\\," => re_match(re!("^(?:\\\\[,\\ ;:])")),
490        "\\ca" => re_match(re!("^\\\\ca(?:\\s+|(?![a-zA-Z]))")),
491        "\\x" => re_match(re!("^(?:\\\\[a-zA-Z]+\\s*|\\\\[_&{}%])")),
492        "orbital" => re_match(re!("^(?:[0-9]{1,2}[spdfgh]|[0-9]{0,2}sp)(?=$|[^a-zA-Z])")),
493        "others" => re_match(re!("^[/~|]")),
494        "oxidation$" => re_match(re!("^(?:[+-][IVX]+|(?:\\\\pm|\\$\\\\pm\\$|\\+-|\\+\\/-)\\s*0)$")),
495        "d-oxidation$" => re_match(re!("^(?:[+-]?[IVX]+|(?:\\\\pm|\\$\\\\pm\\$|\\+-|\\+\\/-)\\s*0)$")),
496        "1/2$" => re_match(re!("^[+\\-]?(?:[0-9]+|\\$[a-z]\\$|[a-z])\\/[0-9]+(?:\\$[a-z]\\$|[a-z])?$")),
497        "(KV letters)," => re_match(re!("^(?:[A-Z][a-z]{0,2}|i)(?=,)")),
498        "uprightEntities" => re_match(re!("^(?:pH|pOH|pC|pK|iPr|iBu)(?=$|[^a-zA-Z])")),
499        "/" => re_match(re!("^\\s*(\\/)\\s*")),
500        "//" => re_match(re!("^\\s*(\\/\\/)\\s*")),
501        "*" => re_match(re!("^\\s*[*.]\\s*")),
502        // ── 函数模式(findObserveGroups / 自定义)──
503        "(-)(9.,9)(e)(99)" => pat_enumber(input),
504        "state of aggregation $" => pat_state_of_aggregation(input),
505        "^{(...)}" => fg(input, Pat::Lit("^{"), Pat::Lit(""), Pat::Lit(""), Pat::Lit("}"), None, None, None, None, false),
506        "^($...$)" => fg(input, Pat::Lit("^"), Pat::Lit("$"), Pat::Lit("$"), Pat::Lit(""), None, None, None, None, false),
507        "^\\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),
508        "^\\x{}" => fg(input, Pat::Lit("^"), Pat::Re(re!("^\\\\[a-zA-Z]+\\{")), Pat::Lit("}"), Pat::Lit(""), None, None, None, None, false),
509        "\\bond{(...)}" => fg(input, Pat::Lit("\\bond{"), Pat::Lit(""), Pat::Lit(""), Pat::Lit("}"), None, None, None, None, false),
510        "[(...)]" => fg(input, Pat::Lit("["), Pat::Lit(""), Pat::Lit(""), Pat::Lit("]"), None, None, None, None, false),
511        "\\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),
512        "\\x{}" => fg(input, Pat::Lit(""), Pat::Re(re!("^\\\\[a-zA-Z]+\\{")), Pat::Lit("}"), Pat::Lit(""), None, None, None, None, false),
513        "\\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),
514        "\\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),
515        "\\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),
516        "\\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),
517        "\\color{(...)}" => fg(input, Pat::Lit("\\color{"), Pat::Lit(""), Pat::Lit(""), Pat::Lit("}"), None, None, None, None, false),
518        "\\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),
519        "\\ce{(...)}" => fg(input, Pat::Lit("\\ce{"), Pat::Lit(""), Pat::Lit(""), Pat::Lit("}"), None, None, None, None, false),
520        "\\pu{(...)}" => fg(input, Pat::Lit("\\pu{"), Pat::Lit(""), Pat::Lit(""), Pat::Lit("}"), None, None, None, None, false),
521        "_{(...)}" => fg(input, Pat::Lit("_{"), Pat::Lit(""), Pat::Lit(""), Pat::Lit("}"), None, None, None, None, false),
522        "_($...$)" => fg(input, Pat::Lit("_"), Pat::Lit("$"), Pat::Lit("$"), Pat::Lit(""), None, None, None, None, false),
523        "_\\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),
524        "_\\x{}" => fg(input, Pat::Lit("_"), Pat::Re(re!("^\\\\[a-zA-Z]+\\{")), Pat::Lit("}"), Pat::Lit(""), None, None, None, None, false),
525        "{...}" => fg(input, Pat::Lit(""), Pat::Lit("{"), Pat::Lit("}"), Pat::Lit(""), None, None, None, None, false),
526        "{(...)}" => fg(input, Pat::Lit("{"), Pat::Lit(""), Pat::Lit(""), Pat::Lit("}"), None, None, None, None, false),
527        "$...$" => fg(input, Pat::Lit(""), Pat::Lit("$"), Pat::Lit("$"), Pat::Lit(""), None, None, None, None, false),
528        "${(...)}$__$(...)$" => 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)),
529        "amount" | "amount2" => pat_amount(input),
530        "formula$" => pat_formula(input),
531        _ => {
532            // 未知模式:容错返回 None(原实现抛 MhchemBugP)
533            None
534        }
535    }
536}
537
538/// `find_observe_groups` 的简写封装。
539#[allow(clippy::too_many_arguments)]
540fn fg(
541    input: &str,
542    beg_excl: Pat,
543    beg_incl: Pat,
544    end_incl: Pat,
545    end_excl: Pat,
546    beg2_excl: Option<Pat>,
547    beg2_incl: Option<Pat>,
548    end2_incl: Option<Pat>,
549    end2_excl: Option<Pat>,
550    combine: bool,
551) -> Option<MMatch> {
552    find_observe_groups(
553        input, beg_excl, beg_incl, end_incl, end_excl, beg2_excl, beg2_incl, end2_incl, end2_excl,
554        combine,
555    )
556}
557
558/// `(-)(9.,9)(e)(99)` 模式。
559fn pat_enumber(input: &str) -> Option<MMatch> {
560    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]+\\}))?");
561    let caps = re.captures_head(input)?;
562    let whole = caps.get(0)?.as_str();
563    if whole.is_empty() || !input.starts_with(whole) {
564        return None;
565    }
566    let mut v = Vec::with_capacity(6);
567    for gi in 1..=6 {
568        v.push(
569            caps.get(gi)
570                .map(|m| m.as_str().to_string())
571                .unwrap_or_default(),
572        );
573    }
574    Some(MMatch {
575        m: MVal::V(v),
576        remainder: input[whole.len()..].to_string(),
577    })
578}
579
580/// `state of aggregation $` 模式。
581fn pat_state_of_aggregation(input: &str) -> Option<MMatch> {
582    let a = fg(
583        input,
584        Pat::Lit(""),
585        Pat::Re(re!("^\\([a-z]{1,3}(?=[\\),])")),
586        Pat::Lit(""),
587        Pat::Lit(")"),
588        None,
589        None,
590        None,
591        None,
592        false,
593    )?;
594    if re!("^($|[\\s,;\\)\\]\\}])").is_match(a.remainder.as_str()) {
595        return Some(a);
596    }
597    let re2 = re!("^(?:\\((?:\\\\ca\\s?)?\\$[amothc]\\$\\))");
598    let caps = re2.captures_head(input)?;
599    let whole = caps.get(0)?.as_str().to_string();
600    Some(MMatch {
601        m: MVal::S(whole.clone()),
602        remainder: input[whole.len()..].to_string(),
603    })
604}
605
606/// `amount` 模式。
607fn pat_amount(input: &str) -> Option<MMatch> {
608    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))");
609    if let Some(caps) = re.captures_head(input) {
610        let whole = caps.get(0)?.as_str();
611        if !whole.is_empty() {
612            return Some(MMatch {
613                m: MVal::S(whole.to_string()),
614                remainder: input[whole.len()..].to_string(),
615            });
616        }
617    }
618    let a = fg(
619        input,
620        Pat::Lit(""),
621        Pat::Lit("$"),
622        Pat::Lit("$"),
623        Pat::Lit(""),
624        None,
625        None,
626        None,
627        None,
628        false,
629    )?;
630    let re2 = re!("^\\$(?:\\(?[+\\-]?(?:[0-9]*[a-z]?[+\\-])?[0-9]*[a-z](?:[+\\-][0-9]*[a-z]?)?\\)?|\\+|-)\\$$");
631    let inner = mval_str(&a.m);
632    let inner_len = inner.len();
633    if re2.is_match(&inner) {
634        return Some(MMatch {
635            m: MVal::S(inner),
636            remainder: input[inner_len..].to_string(),
637        });
638    }
639    None
640}
641
642/// `formula$` 模式。
643fn pat_formula(input: &str) -> Option<MMatch> {
644    if re!("^\\([a-z]+\\)$").is_match(input) {
645        return None;
646    }
647    let re = re!("^(?:[a-z]|(?:[0-9\\ +\\-\\,\\.\\(\\)]+[a-z])+[0-9\\ +\\-\\,\\.\\(\\)]*|(?:[a-z][0-9\\ +\\-\\,\\.\\(\\)]+)+[a-z]?)$");
648    let caps = re.captures_head(input)?;
649    let whole = caps.get(0)?.as_str().to_string();
650    Some(MMatch {
651        m: MVal::S(whole.clone()),
652        remainder: input[whole.len()..].to_string(),
653    })
654}
655
656fn mval_str(m: &MVal) -> String {
657    match m {
658        MVal::S(s) => s.clone(),
659        MVal::V(v) => v.first().cloned().unwrap_or_default(),
660    }
661}
662
663// =========================================================================
664// 状态机主循环(对应 `_mhchemParser.go`)
665// =========================================================================
666
667#[allow(clippy::large_enum_variant)] // 同上:Out 为动作返回值,瞬态使用
668enum Out {
669    None,
670    One(Parsed),
671    Many(Vec<Parsed>),
672}
673
674fn concat(out: &mut Vec<Parsed>, o: Out) {
675    match o {
676        Out::None => {}
677        Out::One(p) => out.push(p),
678        Out::Many(v) => out.extend(v),
679    }
680}
681
682/// 主解析循环。
683fn go(input: &str, machine: &str) -> Vec<Parsed> {
684    if input.is_empty() {
685        return Vec::new();
686    }
687    // 输入预处理
688    let mut input = input.replace('\n', " ");
689    input = input.replace(['\u{2212}', '\u{2013}', '\u{2014}', '\u{2010}'], "-");
690    input = input.replace('\u{2026}', "...");
691
692    let transitions = transitions_for(machine);
693    let mut state = String::from("0");
694    let mut buffer = Buffer {
695        parenthesis_level: 0,
696        ..Default::default()
697    };
698    let mut output: Vec<Parsed> = Vec::new();
699    let mut last_input: Option<String> = None;
700    let mut watchdog = 10i32;
701
702    loop {
703        if last_input.as_deref() != Some(input.as_str()) {
704            watchdog = 10;
705            last_input = Some(input.clone());
706        } else {
707            watchdog -= 1;
708        }
709        let t = transitions.get(&state).or_else(|| transitions.get("*"));
710        let t = match t {
711            Some(t) => t,
712            None => break,
713        };
714        let mut matched = false;
715        for tr in t {
716            if let Some(mres) = match_pattern(&tr.pattern, &input) {
717                matched = true;
718                // 执行动作链
719                for aref in &tr.task.actions {
720                    let o = exec_action(machine, &mut buffer, &mres.m, &aref.option, &aref.type_);
721                    concat(&mut output, o);
722                }
723                // 设置下一状态
724                if let Some(ns) = &tr.task.next_state {
725                    state = ns.clone();
726                }
727                if !input.is_empty() {
728                    if !tr.task.revisit {
729                        input = mres.remainder;
730                    }
731                    if !tr.task.to_continue {
732                        break;
733                    }
734                } else {
735                    return output;
736                }
737            }
738        }
739        if !matched {
740            break;
741        }
742        if watchdog <= 0 {
743            // 防死循环:容错返回当前输出(原实现抛 MhchemBugU)
744            break;
745        }
746    }
747    output
748}
749
750// =========================================================================
751// 动作分发
752// =========================================================================
753
754fn exec_action(
755    machine: &str,
756    buf: &mut Buffer,
757    m: &MVal,
758    opt: &Option<String>,
759    type_: &str,
760) -> Out {
761    // 优先机器局部动作,再查通用动作
762    if let Some(o) = machine_action(machine, buf, m, opt, type_) {
763        return o;
764    }
765    generic_action(buf, m, opt, type_)
766}
767
768fn generic_action(buf: &mut Buffer, m: &MVal, opt: &Option<String>, type_: &str) -> Out {
769    match type_ {
770        "a=" => {
771            append_field(&mut buf.a, m);
772            Out::None
773        }
774        "b=" => {
775            append_field(&mut buf.b, m);
776            Out::None
777        }
778        "p=" => {
779            append_field(&mut buf.p, m);
780            Out::None
781        }
782        "o=" => {
783            append_field(&mut buf.o, m);
784            Out::None
785        }
786        "o=+p1" => {
787            if let Some(a) = opt {
788                append_str(&mut buf.o, a);
789            }
790            Out::None
791        }
792        "q=" => {
793            append_field(&mut buf.q, m);
794            Out::None
795        }
796        "d=" => {
797            append_field(&mut buf.d, m);
798            Out::None
799        }
800        "rm=" => {
801            append_field(&mut buf.rm, m);
802            Out::None
803        }
804        "text=" => {
805            append_field(&mut buf.text_, m);
806            Out::None
807        }
808        "insert" => match opt {
809            Some(a) => Out::One(Parsed::N(NodeData {
810                type_: a.clone(),
811                ..Default::default()
812            })),
813            None => Out::None,
814        },
815        "insert+p1" => match opt {
816            Some(a) => Out::One(Parsed::N(NodeData {
817                type_: a.clone(),
818                p1: Some(Field::Str(mval_str(m))),
819                ..Default::default()
820            })),
821            None => Out::None,
822        },
823        "insert+p1+p2" => {
824            if let (Some(a), MVal::V(v)) = (opt, m) {
825                Out::One(Parsed::N(NodeData {
826                    type_: a.clone(),
827                    p1: Some(Field::Str(v.first().cloned().unwrap_or_default())),
828                    p2: Some(Field::Str(v.get(1).cloned().unwrap_or_default())),
829                    ..Default::default()
830                }))
831            } else {
832                Out::None
833            }
834        }
835        "copy" => mval_to_out(m),
836        "write" => match opt {
837            Some(a) => Out::One(Parsed::S(a.clone())),
838            None => Out::None,
839        },
840        "rm" => Out::One(Parsed::N(NodeData {
841            type_: "rm".into(),
842            p1: Some(Field::Str(mval_str(m))),
843            ..Default::default()
844        })),
845        "text" => Out::Many(go(&mval_str(m), "text")),
846        "tex-math" => Out::Many(go(&mval_str(m), "tex-math")),
847        "tex-math tight" => Out::Many(go(&mval_str(m), "tex-math tight")),
848        "bond" => {
849            let kind = opt
850                .clone()
851                .or_else(|| match m {
852                    MVal::S(s) => Some(s.clone()),
853                    _ => None,
854                })
855                .unwrap_or_default();
856            Out::One(Parsed::N(NodeData {
857                type_: "bond".into(),
858                kind_: Some(kind),
859                ..Default::default()
860            }))
861        }
862        "color0-output" => Out::One(Parsed::N(NodeData {
863            type_: "color0".into(),
864            color: Some(mval_str(m)),
865            ..Default::default()
866        })),
867        "ce" => Out::Many(go(&mval_str(m), "ce")),
868        "pu" => Out::Many(go(&mval_str(m), "pu")),
869        "9,9" => Out::Many(go(&mval_str(m), "9,9")),
870        "1/2" => {
871            let mut s = mval_str(m);
872            let mut ret: Vec<Parsed> = Vec::new();
873            if s.starts_with('+') || s.starts_with('-') {
874                ret.push(Parsed::S(s[..1].to_string()));
875                s = s[1..].to_string();
876            }
877            // 坏正则(编译失败)时 captures_head 返回 None,直接结束 1/2 动作
878            // (产出已累积的前缀符号)。
879            if let Some(caps) =
880                re!("^([0-9]+|\\$[a-z]\\$|[a-z])\\/([0-9]+)(\\$[a-z]\\$|[a-z])?$").captures_head(&s)
881            {
882                let mut n1 = caps
883                    .get(1)
884                    .map(|x| x.as_str().to_string())
885                    .unwrap_or_default();
886                n1 = n1.replace('$', "");
887                let n2 = caps
888                    .get(2)
889                    .map(|x| x.as_str().to_string())
890                    .unwrap_or_default();
891                ret.push(Parsed::N(NodeData {
892                    type_: "frac".into(),
893                    p1: Some(Field::Str(n1)),
894                    p2: Some(Field::Str(n2)),
895                    ..Default::default()
896                }));
897                if let Some(g3) = caps.get(3) {
898                    let mut n3 = g3.as_str().replace('$', "");
899                    ret.push(Parsed::N(NodeData {
900                        type_: "tex-math".into(),
901                        p1: Some(Field::Str(std::mem::take(&mut n3))),
902                        ..Default::default()
903                    }));
904                }
905            }
906            Out::Many(ret)
907        }
908        _ => Out::None,
909    }
910}
911
912fn append_field(field: &mut Option<String>, m: &MVal) {
913    let s = mval_str(m);
914    match field {
915        Some(existing) => existing.push_str(&s),
916        None => *field = Some(s),
917    }
918}
919
920fn append_str(field: &mut Option<String>, s: &str) {
921    match field {
922        Some(existing) => existing.push_str(s),
923        None => *field = Some(s.to_string()),
924    }
925}
926
927fn mval_to_out(m: &MVal) -> Out {
928    match m {
929        MVal::S(s) => Out::One(Parsed::S(s.clone())),
930        MVal::V(v) => Out::Many(v.iter().map(|s| Parsed::S(s.clone())).collect()),
931    }
932}
933
934// =========================================================================
935// texify 输出(对应 `_mhchemTexify`)
936// =========================================================================
937
938fn texify_go(input: &[Parsed], add_outer_braces: bool) -> String {
939    if input.is_empty() {
940        return String::new();
941    }
942    let mut res = String::new();
943    let mut cee = false;
944    for p in input {
945        match p {
946            Parsed::S(s) => res.push_str(s),
947            Parsed::N(n) => {
948                res.push_str(&texify_go2(n));
949                if n.type_ == "1st-level escape" {
950                    cee = true;
951                }
952            }
953        }
954    }
955    if add_outer_braces && !cee && !res.is_empty() {
956        res = format!("{{{}}}", res);
957    }
958    res
959}
960
961fn field_str(f: &Field) -> String {
962    match f {
963        Field::Str(s) => s.clone(),
964        Field::Nodes(v) => texify_go(v, false),
965    }
966}
967
968fn texify_go2(buf: &NodeData) -> String {
969    match buf.type_.as_str() {
970        "chemfive" => {
971            let mut res = String::new();
972            let a = buf.a.as_ref().map(field_str).unwrap_or_default();
973            let b = buf.b.as_ref().map(field_str).unwrap_or_default();
974            let p = buf.p.as_ref().map(field_str).unwrap_or_default();
975            let o = buf.o.as_ref().map(field_str).unwrap_or_default();
976            let q = buf.q.as_ref().map(field_str).unwrap_or_default();
977            let d = buf.d.as_ref().map(field_str).unwrap_or_default();
978            // a
979            if !a.is_empty() {
980                let aa = if a.starts_with('+') || a.starts_with('-') {
981                    format!("{{{}}}", a)
982                } else {
983                    a.clone()
984                };
985                res.push_str(&aa);
986                res.push_str("\\,");
987            }
988            // b and p
989            if !b.is_empty() || !p.is_empty() {
990                res.push_str("{\\vphantom{A}}");
991                res.push_str(&format!("^{{\\hphantom{{{}}}}}_{{\\hphantom{{{}}}}}", b, p));
992                res.push_str("\\mkern-1.5mu");
993                res.push_str("{\\vphantom{A}}");
994                res.push_str(&format!(
995                    "^{{\\smash[t]{{\\vphantom{{2}}}}\\mathllap{{{}}}}}",
996                    b
997                ));
998                res.push_str(&format!(
999                    "_{{\\vphantom{{2}}\\mathllap{{\\smash[t]{{{}}}}}}}",
1000                    p
1001                ));
1002            }
1003            // o
1004            if !o.is_empty() {
1005                let oo = if o.starts_with('+') || o.starts_with('-') {
1006                    format!("{{{}}}", o)
1007                } else {
1008                    o.clone()
1009                };
1010                res.push_str(&oo);
1011            }
1012            // q and d
1013            match buf.d_type.as_deref() {
1014                Some("kv") => {
1015                    if !d.is_empty() || !q.is_empty() {
1016                        res.push_str("{\\vphantom{A}}");
1017                    }
1018                    if !d.is_empty() {
1019                        res.push_str(&format!("^{{{}}}", d));
1020                    }
1021                    if !q.is_empty() {
1022                        res.push_str(&format!("_{{\\smash[t]{{{}}}}}", q));
1023                    }
1024                }
1025                Some("oxidation") => {
1026                    if !d.is_empty() {
1027                        res.push_str("{\\vphantom{A}}");
1028                        res.push_str(&format!("^{{{}}}", d));
1029                    }
1030                    if !q.is_empty() {
1031                        res.push_str("{\\vphantom{A}}");
1032                        res.push_str(&format!("_{{\\smash[t]{{{}}}}}", q));
1033                    }
1034                }
1035                _ => {
1036                    if !q.is_empty() {
1037                        res.push_str("{\\vphantom{A}}");
1038                        res.push_str(&format!("_{{\\smash[t]{{{}}}}}", q));
1039                    }
1040                    if !d.is_empty() {
1041                        res.push_str("{\\vphantom{A}}");
1042                        res.push_str(&format!("^{{{}}}", d));
1043                    }
1044                }
1045            }
1046            res
1047        }
1048        "rm" => format!(
1049            "\\mathrm{{{}}}",
1050            buf.p1.as_ref().map(field_str).unwrap_or_default()
1051        ),
1052        "text" => {
1053            let mut p1 = buf.p1.as_ref().map(field_str).unwrap_or_default();
1054            if p1.contains('^') || p1.contains('_') {
1055                p1 = p1.replace(' ', "~").replace('-', "\\text{-}");
1056                format!("\\mathrm{{{}}}", p1)
1057            } else {
1058                format!("\\text{{{}}}", p1)
1059            }
1060        }
1061        "roman numeral" => format!(
1062            "\\mathrm{{{}}}",
1063            buf.p1.as_ref().map(field_str).unwrap_or_default()
1064        ),
1065        "state of aggregation" => format!(
1066            "\\mskip2mu {}",
1067            buf.p1.as_ref().map(field_str).unwrap_or_default()
1068        ),
1069        "state of aggregation subscript" => format!(
1070            "\\mskip1mu {}",
1071            buf.p1.as_ref().map(field_str).unwrap_or_default()
1072        ),
1073        "bond" => get_bond(buf.kind_.as_deref().unwrap_or("")),
1074        "frac" => {
1075            let c = format!(
1076                "\\frac{{{}}}{{{}}}",
1077                buf.p1.as_ref().map(field_str).unwrap_or_default(),
1078                buf.p2.as_ref().map(field_str).unwrap_or_default()
1079            );
1080            format!("\\mathchoice{{\\textstyle{c}}}{{{c}}}{{{c}}}{{{c}}}")
1081        }
1082        "pu-frac" => {
1083            let d = format!(
1084                "\\frac{{{}}}{{{}}}",
1085                buf.p1.as_ref().map(field_str).unwrap_or_default(),
1086                buf.p2.as_ref().map(field_str).unwrap_or_default()
1087            );
1088            format!("\\mathchoice{{\\textstyle{d}}}{{{d}}}{{{d}}}{{{d}}}")
1089        }
1090        "tex-math" => format!("{} ", buf.p1.as_ref().map(field_str).unwrap_or_default()),
1091        "frac-ce" => format!(
1092            "\\frac{{{}}}{{{}}}",
1093            buf.p1.as_ref().map(field_str).unwrap_or_default(),
1094            buf.p2.as_ref().map(field_str).unwrap_or_default()
1095        ),
1096        "overset" => format!(
1097            "\\overset{{{}}}{{{}}}",
1098            buf.p1.as_ref().map(field_str).unwrap_or_default(),
1099            buf.p2.as_ref().map(field_str).unwrap_or_default()
1100        ),
1101        "underset" => format!(
1102            "\\underset{{{}}}{{{}}}",
1103            buf.p1.as_ref().map(field_str).unwrap_or_default(),
1104            buf.p2.as_ref().map(field_str).unwrap_or_default()
1105        ),
1106        "underbrace" => format!(
1107            "\\underbrace{{{}}}_{{{}}}",
1108            buf.p1.as_ref().map(field_str).unwrap_or_default(),
1109            buf.p2.as_ref().map(field_str).unwrap_or_default()
1110        ),
1111        "color" => format!(
1112            "{{\\color{{{}}}{{{}}}}}",
1113            buf.color1.as_deref().unwrap_or(""),
1114            buf.color2.as_ref().map(field_str).unwrap_or_default()
1115        ),
1116        "color0" => format!("\\color{{{}}}", buf.color.as_deref().unwrap_or("")),
1117        "arrow" => {
1118            let rd = buf.rd.as_ref().map(field_str).unwrap_or_default();
1119            let rq = buf.rq.as_ref().map(field_str).unwrap_or_default();
1120            let mut arrow = String::from("\\x");
1121            arrow.push_str(get_arrow(buf.r.as_deref().unwrap_or("")));
1122            if !rq.is_empty() {
1123                arrow.push_str("[{");
1124                arrow.push_str(&rq);
1125                arrow.push_str("}]");
1126            }
1127            arrow.push('{');
1128            arrow.push_str(&rd);
1129            arrow.push('}');
1130            arrow
1131        }
1132        "operator" => get_operator(buf.kind_.as_deref().unwrap_or("")),
1133        "1st-level escape" => format!("{} ", buf.p1.as_ref().map(field_str).unwrap_or_default()),
1134        "space" => " ".to_string(),
1135        "tinySkip" => "\\mkern2mu".to_string(),
1136        "entitySkip" => "~".to_string(),
1137        "pu-space-1" => "~".to_string(),
1138        "pu-space-2" => "\\mkern3mu ".to_string(),
1139        "1000 separator" => "\\mkern2mu ".to_string(),
1140        "commaDecimal" => "{,}".to_string(),
1141        "comma enumeration L" => format!(
1142            "{{{}}}\\mkern6mu ",
1143            buf.p1.as_ref().map(field_str).unwrap_or_default()
1144        ),
1145        "comma enumeration M" => format!(
1146            "{{{}}}\\mkern3mu ",
1147            buf.p1.as_ref().map(field_str).unwrap_or_default()
1148        ),
1149        "comma enumeration S" => format!(
1150            "{{{}}}\\mkern1mu ",
1151            buf.p1.as_ref().map(field_str).unwrap_or_default()
1152        ),
1153        "hyphen" => "\\text{-}".to_string(),
1154        "addition compound" => "\\,{\\cdot}\\,".to_string(),
1155        "electron dot" => "\\mkern1mu \\bullet\\mkern1mu ".to_string(),
1156        "KV x" => "{\\times}".to_string(),
1157        "prime" => "\\prime ".to_string(),
1158        "cdot" => "\\cdot ".to_string(),
1159        "tight cdot" => "\\mkern1mu{\\cdot}\\mkern1mu ".to_string(),
1160        "times" => "\\times ".to_string(),
1161        "circa" => "{\\sim}".to_string(),
1162        "^" => "uparrow".to_string(),
1163        "v" => "downarrow".to_string(),
1164        "ellipsis" => "\\ldots ".to_string(),
1165        "/" => "/".to_string(),
1166        " / " => "\\,/\\,".to_string(),
1167        _ => String::new(),
1168    }
1169}
1170
1171fn get_arrow(a: &str) -> &'static str {
1172    match a {
1173        "->" | "\u{2192}" | "\u{27F6}" => "rightarrow",
1174        "<-" => "leftarrow",
1175        "<->" => "leftrightarrow",
1176        "<-->" => "rightleftarrows",
1177        "<=>" | "\u{21CC}" => "rightleftharpoons",
1178        "<=>>" => "rightequilibrium",
1179        "<<=>" => "leftequilibrium",
1180        _ => "rightarrow",
1181    }
1182}
1183
1184fn get_bond(a: &str) -> String {
1185    match a {
1186        "-" | "1" => "{-}".to_string(),
1187        "=" | "2" => "{=}".to_string(),
1188        "#" | "3" => "{\\equiv}".to_string(),
1189        "~" => "{\\tripledash}".to_string(),
1190        "~-" => "{\\mathrlap{\\raisebox{-.1em}{$-$}}\\raisebox{.1em}{$\\tripledash$}}".to_string(),
1191        "~=" | "~--" => {
1192            "{\\mathrlap{\\raisebox{-.2em}{$-$}}\\mathrlap{\\raisebox{.2em}{$\\tripledash$}}-}"
1193                .to_string()
1194        }
1195        "-~-" => {
1196            "{\\mathrlap{\\raisebox{-.2em}{$-$}}\\mathrlap{\\raisebox{.2em}{$-$}}\\tripledash}"
1197                .to_string()
1198        }
1199        "..." => "{{\\cdot}{\\cdot}{\\cdot}}".to_string(),
1200        "...." => "{{\\cdot}{\\cdot}{\\cdot}{\\cdot}}".to_string(),
1201        "->" => "{\\rightarrow}".to_string(),
1202        "<-" => "{\\leftarrow}".to_string(),
1203        "<" => "{<}".to_string(),
1204        ">" => "{>}".to_string(),
1205        _ => format!("{{{}}}", a),
1206    }
1207}
1208
1209fn get_operator(a: &str) -> String {
1210    match a {
1211        "+" => " {}+{} ".to_string(),
1212        "-" => " {}-{} ".to_string(),
1213        "=" => " {}={} ".to_string(),
1214        "<" => " {}<{} ".to_string(),
1215        ">" => " {}>{} ".to_string(),
1216        "<<" => " {}\\ll{} ".to_string(),
1217        ">>" => " {}\\gg{} ".to_string(),
1218        "\\pm" => " {}\\pm{} ".to_string(),
1219        "\\approx" | "$\\approx$" => " {}\\approx{} ".to_string(),
1220        "v" | "(v)" => " \\downarrow{} ".to_string(),
1221        "^" | "(^)" => " \\uparrow{} ".to_string(),
1222        _ => format!(" {{{}}} ", a),
1223    }
1224}
1225
1226// =========================================================================
1227// 公开 API
1228// =========================================================================
1229
1230/// 把 `\ce{...}` 内容转译为 LaTeX。
1231///
1232/// 容错设计:
1233/// - 正则编译走 [`compile_or_log`],失败降级为不匹配(状态机退到 "else" 兜底)。
1234/// - 状态机有 watchdog 防死循环。
1235/// - `to_tex` 用 `catch_unwind` 兜底,残余 panic 时原样返回输入。
1236///
1237/// 坏公式可能产出退化输出,但不应 panic。`all_match_patterns_compile` 测试
1238/// 在测试期捕获正则转录回归。
1239pub fn ce(input: &str) -> String {
1240    to_tex(input, "ce")
1241}
1242
1243/// 把 `\pu{...}` 内容转译为 LaTeX(容错同 [`ce`])。
1244pub fn pu(input: &str) -> String {
1245    to_tex(input, "pu")
1246}
1247
1248fn to_tex(input: &str, kind: &str) -> String {
1249    // 最后一道防线:服务端 panic=unwind,残余 panic 在这里截住,
1250    // 只让这条公式退回原文,不影响整篇文章渲染。
1251    let result = std::panic::catch_unwind(|| {
1252        let parsed = go(input, kind);
1253        // D5:TEX 状态机已删除(to_tex 仅以 ce/pu 调用,kind != "tex" 恒真)。
1254        texify_go(&parsed, true)
1255    });
1256    result.unwrap_or_else(|_| input.to_string())
1257}
1258
1259// 状态机转移表 + 局部动作在 mhchem_tables.rs(同模块,含大量常量数据)。
1260include!("mhchem_tables.rs");
1261
1262#[cfg(test)]
1263mod tests {
1264    use super::*;
1265
1266    #[test]
1267    fn ce_water_produces_mathrm() {
1268        let tex = ce("H2O");
1269        assert!(tex.contains(r"\mathrm{H}"), "H 应为直立体: {tex}");
1270        assert!(tex.contains(r"\mathrm{O}"), "O 应为直立体: {tex}");
1271    }
1272
1273    #[test]
1274    fn ce_keeps_subscript_digits() {
1275        // 与 mhchem.js 的输出一致;下标数字曾在 9,9 子状态机里被吞掉。
1276        assert_eq!(
1277            ce("H2O"),
1278            r"{\mathrm{H}{\vphantom{A}}_{\smash[t]{2}}\mathrm{O}}"
1279        );
1280        assert!(ce("CO2").contains(r"_{\smash[t]{2}}"));
1281    }
1282
1283    #[test]
1284    fn ce_reaction_has_arrow() {
1285        let tex = ce("2H2 + O2 -> 2H2O");
1286        assert!(tex.contains("rightarrow"), "应含反应箭头: {tex}");
1287        assert!(tex.starts_with(r"{2\,\mathrm{H}"), "系数应保留: {tex}");
1288        assert_eq!(tex.matches(r"_{\smash[t]{2}}").count(), 3, "{tex}");
1289    }
1290
1291    #[test]
1292    fn ce_keeps_decimal_numbers() {
1293        let tex = ce("1.5 H2O");
1294        assert!(tex.contains("1.5"), "小数系数应保留: {tex}");
1295    }
1296
1297    #[test]
1298    fn ce_empty_is_empty() {
1299        assert_eq!(ce(""), "");
1300    }
1301
1302    #[test]
1303    fn ce_does_not_panic_on_garbage() {
1304        // 任意乱码不应 panic(容错回退为原样)。
1305        let _ = ce("}}}{{{]][[");
1306        let _ = ce("\\frac{");
1307        let _ = ce("<<<<>>>>");
1308    }
1309
1310    #[test]
1311    fn ce_gas_arrow_becomes_uparrow() {
1312        // 行尾 ^ 气体符号转译后应含 uparrow(消解原行尾 ^ 解析错误)。
1313        let tex = ce("CaCO3 ->[\\Delta] CaO + CO2 ^");
1314        assert!(
1315            tex.contains("uparrow") || tex.contains("rightarrow"),
1316            "气体/反应符号: {tex}"
1317        );
1318    }
1319
1320    #[test]
1321    fn pu_unit_has_mathrm() {
1322        let tex = pu("9.8 m/s^2");
1323        assert!(tex.contains(r"\mathrm"), "单位应有直立体: {tex}");
1324    }
1325
1326    #[test]
1327    fn pu_empty_is_empty() {
1328        assert_eq!(pu(""), "");
1329    }
1330
1331    #[test]
1332    fn ce_charge_superscript() {
1333        let tex = ce("SO4^2-");
1334        // 应含上标(^...)且无 panic。
1335        assert!(!tex.is_empty(), "离子应产出非空: {tex}");
1336    }
1337
1338    /// 回归测试:`". __* "` 模式的 regex 曾因转录错误把 `|` 放进了字符类
1339    /// 内部(`[.\u{22C5}\u{00B7}\u{2022}|[*]`),fancy-regex 报
1340    /// `ParseError(20, InvalidClass)`,`re!` 宏的 `.unwrap()` 触发 panic。
1341    /// 上游 mhchemParser 是 `[...]|[*]`(alternation 在类外)。
1342    /// 这些输入会强制该 LazyLock 正则编译,必须产出非空且不 panic。
1343    #[test]
1344    fn ce_dot_bullet_bond_inputs_dont_panic() {
1345        for s in [
1346            ".",
1347            "·",
1348            "•",
1349            "⋅",
1350            "*",
1351            ". ",
1352            "••",
1353            "·  ",
1354            "H·OH",
1355            "CaCO3 · H2O",
1356        ] {
1357            let tex = ce(s);
1358            assert!(!tex.is_empty(), "ce({s:?}) 不应为空");
1359        }
1360    }
1361
1362    /// 回归测试:`compile_or_log` 是 re! 宏硬化的核心——对坏正则必须降级
1363    /// 返回 `None` 而非 panic。`re!` 宏原先用 `.unwrap()`,任何未来的正则
1364    /// 转录错误(如曾经的 `". __* "` InvalidClass)都会 panic。
1365    /// 降级后坏正则只让该模式匹配失败,状态机退到 "else" 兜底分支,
1366    /// 不影响整体渲染。
1367    #[test]
1368    fn compile_or_log_degrades_bad_regex_without_panic() {
1369        // 合法正则 → Some
1370        assert!(compile_or_log("^a").is_some(), "合法正则应编译成功");
1371        // 非法正则(未闭合字符类,曾触发 ParseError(20, InvalidClass))
1372        let bad = "^([abc|[*])";
1373        let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| compile_or_log(bad)));
1374        assert!(r.is_ok(), "compile_or_log 对坏正则不应 panic");
1375        assert!(r.unwrap().is_none(), "坏正则应返回 None");
1376    }
1377
1378    /// 回归测试:`find_observe_end` 曾用逐字节 `i += 1` 步进,遇多字节
1379    /// UTF-8 字符(如中文「浓」占 3 字节)会让 `&input[i..]` 落在字符内部,
1380    /// 触发 `byte index N is not a char boundary` panic(曾在服务端仍为
1381    /// panic=abort 时让 rebuild_content_html 重建含中文化学式的文章时崩溃进程)。
1382    /// 修复改为按 `char_indices` 字符边界步进。这些输入必须不 panic。
1383    #[test]
1384    fn ce_multibyte_char_in_braces_does_not_panic() {
1385        for s in [
1386            "{浓}",
1387            "浓H2SO4",
1388            "{中文}",
1389            "H{浓}O",
1390            "\\frac{浓}{稀}",
1391            "[浓]",
1392        ] {
1393            // ce 内部的 to_tex 已 catch_unwind,外层只是防御性保留。
1394            let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| ce(s)));
1395            assert!(r.is_ok(), "ce({s:?}) PANICKED (char boundary)");
1396        }
1397    }
1398
1399    /// 保护性:任意多字节字符组合(emoji/日韩文/组合字符/四字节区)都不应
1400    /// panic。覆盖花括号/方括号/`\frac`/`$...$` 等所有走 find_observe_end 的路径。
1401    #[test]
1402    fn ce_arbitrary_multibyte_does_not_panic() {
1403        let inputs = [
1404            "🔥",
1405            "café",
1406            "naïve",
1407            "Σ",
1408            "αβγ",
1409            "ΔH",
1410            "你好世界",
1411            "안녕",
1412            "こんにちは",
1413            "{🧪}",
1414            "H₂O",
1415            "[α]",
1416            "\\frac{β}{γ}",
1417            "${日本}$",
1418            "A·B•C⋅D",
1419            "naïve H2O",
1420            "{β-Gal}",
1421            "😀😂",
1422            "\u{1F9EA}", // test tube emoji
1423        ];
1424        for s in inputs {
1425            let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| ce(s)));
1426            assert!(r.is_ok(), "ce({s:?}) PANICKED");
1427            let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| pu(s)));
1428            assert!(r.is_ok(), "pu({s:?}) PANICKED");
1429        }
1430    }
1431
1432    /// 保护性:强制 `match_pattern` 的每个分支至少运行一次,让每个 `re!`
1433    /// 定义的 regex 都被编译。任一转录错误都会在此暴露,而非等到生产环境
1434    /// 被特定输入触发。
1435    #[test]
1436    fn all_match_patterns_compile() {
1437        for name in [
1438            "empty",
1439            "else",
1440            "else2",
1441            "space",
1442            "space A",
1443            "space$",
1444            "a-z",
1445            "x",
1446            "x$",
1447            "i$",
1448            "letters",
1449            "\\greek",
1450            "one lowercase latin letter $",
1451            "$one lowercase latin letter$ $",
1452            "one lowercase greek letter $",
1453            "digits",
1454            "-9.,9",
1455            "-9.,9 no missing 0",
1456            "(-)(9)^(-9)",
1457            "_{(state of aggregation)}$",
1458            "{[(",
1459            ")]}",
1460            ", ",
1461            ",",
1462            ".",
1463            ". __* ",
1464            "...",
1465            "^a",
1466            "^\\x",
1467            "^(-1)",
1468            "'",
1469            "_9",
1470            "_\\x",
1471            "^_",
1472            "{}^",
1473            "{}",
1474            "=<>",
1475            "#",
1476            "+",
1477            "-$",
1478            "-9",
1479            "- orbital overlap",
1480            "-",
1481            "pm-operator",
1482            "operator",
1483            "arrowUpDown",
1484            "->",
1485            "CMT",
1486            "1st-level escape",
1487            "\\,",
1488            "\\ca",
1489            "\\x",
1490            "orbital",
1491            "others",
1492            "oxidation$",
1493            "d-oxidation$",
1494            "1/2$",
1495            "(KV letters),",
1496            "uprightEntities",
1497            "/",
1498            "//",
1499            "*",
1500        ] {
1501            // 返回值不重要,只要不 panic(regex 编译成功)即可。
1502            let _ = match_pattern(name, "");
1503        }
1504    }
1505}