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 相关接口。