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