From bcd13958aca4491a9ab638fc23b4cc844b145cf8 Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 23 Jul 2026 12:23:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(katex):=20=E7=A7=BB=E6=A4=8D=20mhchem=20?= =?UTF-8?q?=E5=8C=96=E5=AD=A6=E5=85=AC=E5=BC=8F=E8=BD=AC=E8=AF=91=E5=99=A8?= =?UTF-8?q?(\ce/\pu=20=E2=86=92=20LaTeX)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit katex-rs 默认无 mhchem 解析器,\ce/\pu 化学公式渲染为红字(实测正确页面 648 的 137 个公式中 32 处化学公式坏掉,含 \ce×24 + \pu×8)。 从 mhchemParser 4.2.2(Apache-2.0)机械移植状态机转译器为纯 Rust: - src/api/mhchem.rs:数据模型 + 模式匹配 + go() 主循环 + texify 输出 + 公开 API - src/api/mhchem_tables.rs:14 个状态机转移表 + 机器局部动作(ce/pu/...) - 模式含 lookahead (?=)/(?!),Rust regex crate 不支持,故用 fancy-regex (已被 syntect 间接引入,此处显式声明为直接 server 依赖) - ce/pu 包裹 catch_unwind,任何内部异常回退原样输出,绝不 panic - katex.rs 新增 expand_chem:渲染前嵌套花括号配对扫描 \ce/\pu 预转译 - 行尾 ^ 气体符号转译为 \uparrow,消解原 mhchem 解析错误 - 8 个 mhchem 单测 + 5 个 katex 集成测试(水/反应箭头/气体箭头/单位/络离子) --- Cargo.lock | 1 + Cargo.toml | 5 + src/api/katex.rs | 135 +++- src/api/mhchem.rs | 1218 ++++++++++++++++++++++++++++++ src/api/mhchem_tables.rs | 1547 ++++++++++++++++++++++++++++++++++++++ src/api/mod.rs | 3 + 6 files changed, 2905 insertions(+), 4 deletions(-) create mode 100755 src/api/mhchem.rs create mode 100644 src/api/mhchem_tables.rs diff --git a/Cargo.lock b/Cargo.lock index b27a1af..01ef9b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5602,6 +5602,7 @@ dependencies = [ "deadpool-postgres", "dioxus", "dotenvy", + "fancy-regex", "futures", "governor", "hex", diff --git a/Cargo.toml b/Cargo.toml index bb83d7e..d6f7f8a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,10 @@ argon2 = { version = "0.5", optional = true } uuid = { version = "1", features = ["v4"], optional = true } chrono = { version = "0.4", features = ["serde"] } regex = { version = "1.12", optional = true } +# mhchem 化学公式转译器(src/api/mhchem.rs)需要 lookahead 正则, +# Rust `regex` crate 不支持 `(?=...)`/`(?!...)`,故用 `fancy-regex`。 +# 该 crate 已被 syntect 间接引入(0.16.2,见 Cargo.lock),此处显式声明为直接依赖。 +fancy-regex = { version = "0.16", optional = true } pulldown-cmark = { version = "0.13", optional = true } dotenvy = { version = "0.15", optional = true } tracing = { version = "0.1", optional = true, features = ["release_max_level_info"] } @@ -98,6 +102,7 @@ server = [ "dep:argon2", "dep:uuid", "dep:regex", + "dep:fancy-regex", "dep:pulldown-cmark", "dep:rand", "dep:http", diff --git a/src/api/katex.rs b/src/api/katex.rs index cf5a4d0..0cd44f5 100644 --- a/src/api/katex.rs +++ b/src/api/katex.rs @@ -119,14 +119,89 @@ thread_local! { static DISPLAY_SETTINGS: Settings = display_settings(); } +/// 把公式中的 `\ce{...}` / `\pu{...}` 预转译为标准 LaTeX(mhchem)。 +/// +/// `katex-rs` 无 mhchem 解析器,化学公式渲染为红字。这里在渲染前扫描 `\ce`/`\pu` +/// 调用,用嵌套花括号配对读取参数(支持 `\ce{[Cu(NH3)4]^2+}` 这类含 `{}` 的内容), +/// 转译后替换原 `\ce{...}`,其余文本原样拼接。未闭合 `\ce{` 保留原样(让 katex +/// 报红,符合容错设计)。无 `\ce`/`\pu` 时零成本原样返回。 +fn expand_chem(tex: &str) -> String { + // 快速路径:绝大多数公式不含化学公式,避免分配。 + if !tex.contains(r"\ce") && !tex.contains(r"\pu") { + return tex.to_string(); + } + let bytes = tex.as_bytes(); + let mut out = String::with_capacity(tex.len()); + let mut i = 0; + while i < bytes.len() { + // 匹配 `\ce{` 或 `\pu{` + if bytes[i] == b'\\' && i + 3 < bytes.len() { + let (is_ce, is_pu) = (bytes[i + 1] == b'c' && bytes[i + 2] == b'e', bytes[i + 1] == b'p' && bytes[i + 2] == b'u'); + let brace_at = if is_ce { + Some(i + 3) + } else if is_pu { + Some(i + 4) + } else { + None + }; + if let Some(bi) = brace_at { + // 精确匹配命令边界:\ce/\pu 后须紧跟 `{`(否则可能是 \cellbox 之类) + if bi < bytes.len() && bytes[bi] == b'{' { + // 读配对花括号内容(处理嵌套) + if let Some((content, close_end)) = read_braced(tex, bi) { + let translated = if is_ce { + crate::api::mhchem::ce(content) + } else { + crate::api::mhchem::pu(content) + }; + out.push_str(&translated); + i = close_end; + continue; + } + // 未闭合 `{`:原样输出剩余,交由 katex 报红 + out.push_str(&tex[i..]); + return out; + } + } + } + out.push(bytes[i] as char); + i += 1; + } + out +} + +/// 从 `open`(指向 `{`)读取配对花括号内容,返回 `(内容, 闭括号后位置)`。 +/// 不闭合返回 `None`。嵌套 `{}` 正确计数。 +fn read_braced(s: &str, open: usize) -> Option<(&str, usize)> { + let bytes = s.as_bytes(); + debug_assert_eq!(bytes[open], b'{'); + let mut depth = 0i32; + let mut i = open; + while i < bytes.len() { + match bytes[i] { + b'{' => depth += 1, + b'}' => { + depth -= 1; + if depth == 0 { + return Some((&s[open + 1..i], i + 1)); + } + } + _ => {} + } + i += 1; + } + None +} + /// 渲染内联公式 `$...$`(定界符由 pulldown-cmark 剥除)→ HTML 字符串。 /// /// 渲染失败(坏 TeX)时回退到 HTML 转义后的原文,保证文章不因一个坏公式全篇崩。 pub fn render_inline(tex: &str) -> String { + let tex = expand_chem(tex); KATEX_CTX.with(|ctx| { INLINE_SETTINGS.with(|settings| { - katex::render_to_string(ctx, tex, settings) - .unwrap_or_else(|_| crate::utils::html::escape_html(tex)) + katex::render_to_string(ctx, &tex, settings) + .unwrap_or_else(|_| crate::utils::html::escape_html(&tex)) }) }) } @@ -136,10 +211,11 @@ pub fn render_inline(tex: &str) -> String { /// 与 [`render_inline`] 同样在失败时回退到转义原文。调用方负责块级包裹 /// (如 `

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