Compare commits

..

No commits in common. "d1013dfa081e03fd65ede32b31aa4739e1fcd94c" and "089fe88a7542f2bcff693143bfb22429c80d0f5a" have entirely different histories.

2 changed files with 45 additions and 181 deletions

View File

@ -8,14 +8,14 @@
//! `ce("H2O")` → `\mathrm{H}\sb{2}\mathrm{O}` 之类,无需嵌入 katex 内部解析树。
//!
//! 移植要点TS→Rust
//! - 动态 `buffer` 对象 → `Buffer` 结构体(`Option` 字段 + `clear`)。
//! - 动态 `Parsed = string | object` → `Parsed` 枚举;节点字段用 `Field`(字符串或节点向量)。
//! - 动态 `buffer` 对象 → [`Buffer`] 结构体(`Option` 字段 + `clear`)。
//! - 动态 `Parsed = string | object` → [`Parsed`] 枚举;节点字段用 [`Field`](字符串或节点向量)。
//! - 正则模式(含 lookahead `(?=)`/`(?!)`Rust `regex` 不支持)→ `fancy-regex`(已被
//! syntect 间接引入,此处显式声明)。
//! - `findObserveGroups` 花括号配对扫描 → `find_observe_groups`。
//! - 状态机转移表 → `build_transitions` 展开(对应 `_mhchemCreateTransitions`)。
//! - `findObserveGroups` 花括号配对扫描 → [`find_observe_groups`]
//! - 状态机转移表 → [`build_transitions`] 展开(对应 `_mhchemCreateTransitions`)。
//!
//! 公开 API[`ce`](crate::api::mhchem::ce) / [`pu`](crate::api::mhchem::pu)。任意内部异常都回退为原样输出(绝不 panic
//! 公开 API[`ce`] / [`pu`]。任意内部异常都回退为原样输出(绝不 panic
//! katex.rs 的容错哲学一致)。
//!
//! -----------------------------------------------------------------------
@ -187,55 +187,9 @@ fn build_transitions(raw: &[RawEntry]) -> HashMap<String, Vec<Transition>> {
// 模式匹配
// =========================================================================
/// 编译正则,失败时记日志并返回 `None`(绝不 panic
///
/// `re!` 宏的 `.unwrap()` 在 `panic = "abort"` 下会直接杀进程——任何未来的
/// 正则转录错误都会让整篇含化学公式的文章渲染崩溃。改为降级返回 `None`
/// 后,坏正则只让该模式匹配失败(状态机退到 "else" 兜底分支产出原样字符),
/// 不影响整体渲染。`all_match_patterns_compile` 测试仍会在测试期捕获回归。
fn compile_or_log(pat: &str) -> Option<Regex> {
match Regex::new(pat) {
Ok(re) => Some(re),
Err(e) => {
tracing::error!(target: "yggdrasil::mhchem", pattern = pat, error = ?e, "mhchem 正则编译失败,该模式将降级为不匹配");
None
}
}
}
/// 编译产物:把「坏正则降级为不匹配」的语义封装在一处。
///
/// 调用方不再到处写 `re_ref(re!(...))?` 或手写 `None` 回退——直接调
/// [`CompiledPat::captures_head`] / [`CompiledPat::is_match`],编译失败时返回
/// 不匹配(`None` / `false`)。对应原始 `Regex` 方法的子集。
struct CompiledPat(Option<Regex>);
impl CompiledPat {
/// 锚定头部匹配(坏正则 → `None`)。匹配失败/编译失败统一为不匹配。
///
/// fancy-regex 的 `Captures` 同时借用 regex 与 input故签名把两者绑到同一
/// 生命周期 `'s`。调用处 `self` 来自 `&*RE``'static`),所以实际约束是 input
/// 须在 captures 使用期间存活——与原始 `re.captures(input)` 一致。
#[inline]
fn captures_head<'s>(&'s self, input: &'s str) -> Option<fancy_regex::Captures<'s>> {
self.0.as_ref()?.captures(input).ok().flatten()
}
/// 是否匹配(坏正则 → `false`)。
#[inline]
fn is_match(&self, input: &str) -> bool {
self.0
.as_ref()
.and_then(|r| r.is_match(input).ok())
.unwrap_or(false)
}
}
/// 惰性编译字面量正则,编译失败降级为 [`CompiledPat`]None
macro_rules! re {
($pat:literal) => {{
static RE: LazyLock<CompiledPat> =
LazyLock::new(|| CompiledPat(compile_or_log($pat)));
static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new($pat).unwrap());
&*RE
}};
}
@ -243,7 +197,7 @@ macro_rules! re {
/// 字面量或正则模式(`findObserveGroups` 的参数)。
enum Pat {
Lit(&'static str),
Re(&'static CompiledPat),
Re(&'static Regex),
}
/// 在 `input` 起始处匹配 `pat`,返回匹配到的文本(不含 remainder
@ -256,7 +210,7 @@ fn pat_match_head(pat: &Pat, input: &str) -> Option<String> {
None
}
}
Pat::Re(cp) => cp.captures_head(input).and_then(|c| {
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) {
@ -342,18 +296,11 @@ struct Span {
}
/// 从 `rest0` 的 `start` 位置扫描,跟踪花括号深度,在深度 0 遇到 `end_chars` 时返回其区间。
///
/// 必须按**字符**而非字节步进:化学公式可含多字节 UTF-8 字符(如中文说明
/// 文字「浓」占 3 字节)。逐字节 `i+=1` 会让 `&input[i..]` 落在字符内部,
/// 触发 `byte index N is not a char boundary` panic在 panic=abort 下直接
/// 杀进程),且 `bytes[i] as char` 会把多字节字符的首字节误判为花括号,
/// 导致输出损坏。`char_indices` 保证每步都在字符边界上。
fn find_observe_end(input: &str, start: usize, end_chars: &Pat) -> Option<Span> {
let bytes = input.as_bytes();
let mut i = start;
let mut braces = 0i32;
// start 是上一步 beg_incl 匹配的长度;该匹配来自 Pat::Lit 或锚定正则,
// 落在字符边界上。校验后用于首次切片,随后只按 char_indices 的边界前进。
let start = input.get(start..).map(|_| start).unwrap_or(input.len());
for (i, a) in input.char_indices().skip_while(|(b, _)| *b < start) {
while i < input.len() {
// 结束定界符匹配(仅在花括号平衡时)
if braces == 0 {
if let Some(matched) = pat_match_head(end_chars, &input[i..]) {
@ -363,6 +310,7 @@ fn find_observe_end(input: &str, start: usize, end_chars: &Pat) -> Option<Span>
});
}
}
let a = bytes[i] as char;
if a == '{' {
braces += 1;
} else if a == '}' {
@ -372,6 +320,7 @@ fn find_observe_end(input: &str, start: usize, end_chars: &Pat) -> Option<Span>
}
braces -= 1;
}
i += 1;
}
None
}
@ -411,32 +360,33 @@ fn fancy_match(re: &Regex, input: &str) -> Option<MMatch> {
/// 希腊字母宏集合(`letters` / `\greek` 等模式用到)。
static GREEK_NAMES: &str = "alpha|beta|gamma|delta|epsilon|zeta|eta|theta|iota|kappa|lambda|mu|nu|xi|omicron|pi|rho|sigma|tau|upsilon|phi|chi|psi|omega|Gamma|Delta|Theta|Lambda|Xi|Pi|Sigma|Upsilon|Phi|Psi|Omega";
static LETTERS_RE: LazyLock<CompiledPat> = LazyLock::new(|| {
CompiledPat(compile_or_log(&format!(
static LETTERS_RE: LazyLock<Regex> = 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<CompiledPat> = LazyLock::new(|| {
CompiledPat(compile_or_log(&format!(
static GREEK_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!(
"^\\\\(?:{})(?:\\s+|\\{{\\}}|(?![a-zA-Z]))",
GREEK_NAMES
)))
))
.unwrap()
});
static ONE_GREEK_RE: LazyLock<CompiledPat> = LazyLock::new(|| {
CompiledPat(compile_or_log(
static ONE_GREEK_RE: LazyLock<Regex> = 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<MMatch> {
// ── 正则模式 ──
// `re!` 产物是 `&CompiledPat`:编译失败的坏正则 `.0` 为 None
// 这里 `.0.as_ref()?` 降级为不匹配,状态机退到 "else" 兜底分支。
let re_match = |cp: &CompiledPat| cp.0.as_ref().and_then(|r| fancy_match(r, input));
let re_match = |re: &Regex| fancy_match(re, input);
match name {
"empty" => re_match(re!("^$")),
"else" | "else2" => re_match(re!("^.")),
@ -462,7 +412,7 @@ fn match_pattern(name: &str, input: &str) -> Option<MMatch> {
", " => re_match(re!("^[,;]\\s*")),
"," => re_match(re!("^[,;]")),
"." => re_match(re!("^[.]")),
". __* " => re_match(re!("^([.\u{22C5}\u{00B7}\u{2022}]|[*])\\s*")),
". __* " => 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*")),
@ -558,7 +508,7 @@ fn fg(
/// `(-)(9.,9)(e)(99)` 模式。
fn pat_enumber(input: &str) -> Option<MMatch> {
let re = re!("^(\\+\\-|\\+\\/\\-|\\+|\\-|\\\\pm\\s?)?([0-9]+(?:[,.][0-9]+)?|[0-9]*(?:\\.[0-9]+))?(\\((?:[0-9]+(?:[,.][0-9]+)?|[0-9]*(?:\\.[0-9]+))\\))?(?:(?:([eE])|\\s*(\\*|x|\\\\times|\u{00D7})\\s*10\\^)([+\\-]?[0-9]+|\\{[+\\-]?[0-9]+\\}))?");
let caps = re.captures_head(input)?;
let caps = re.captures(input).ok()??;
let whole = caps.get(0)?.as_str();
if whole.is_empty() || !input.starts_with(whole) {
return None;
@ -591,11 +541,14 @@ fn pat_state_of_aggregation(input: &str) -> Option<MMatch> {
None,
false,
)?;
if re!("^($|[\\s,;\\)\\]\\}])").is_match(a.remainder.as_str()) {
if re!("^($|[\\s,;\\)\\]\\}])")
.is_match(a.remainder.as_str())
.ok()?
{
return Some(a);
}
let re2 = re!("^(?:\\((?:\\\\ca\\s?)?\\$[amothc]\\$\\))");
let caps = re2.captures_head(input)?;
let caps = re2.captures(input).ok()??;
let whole = caps.get(0)?.as_str().to_string();
Some(MMatch {
m: MVal::S(whole.clone()),
@ -606,7 +559,7 @@ fn pat_state_of_aggregation(input: &str) -> Option<MMatch> {
/// `amount` 模式。
fn pat_amount(input: &str) -> Option<MMatch> {
let re = re!("^(?:(?:(?:\\([+\\-]?[0-9]+\\/[0-9]+\\)|[+\\-]?(?:[0-9]+|\\$[a-z]\\$|[a-z])\\/[0-9]+|[+\\-]?[0-9]+[.,][0-9]+|[+\\-]?\\.[0-9]+|[+\\-]?[0-9]+)(?:[a-z](?=\\s*[A-Z]))?)|[+\\-]?[a-z](?=\\s*[A-Z])|\\+(?!\\s))");
if let Some(caps) = re.captures_head(input) {
if let Some(caps) = re.captures(input).ok().flatten() {
let whole = caps.get(0)?.as_str();
if !whole.is_empty() {
return Some(MMatch {
@ -630,7 +583,7 @@ fn pat_amount(input: &str) -> Option<MMatch> {
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) {
if re2.is_match(&inner).ok()? {
return Some(MMatch {
m: MVal::S(inner),
remainder: input[inner_len..].to_string(),
@ -641,11 +594,11 @@ fn pat_amount(input: &str) -> Option<MMatch> {
/// `formula$` 模式。
fn pat_formula(input: &str) -> Option<MMatch> {
if re!("^\\([a-z]+\\)$").is_match(input) {
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_head(input)?;
let caps = re.captures(input).ok()??;
let whole = caps.get(0)?.as_str().to_string();
Some(MMatch {
m: MVal::S(whole.clone()),
@ -874,9 +827,11 @@ fn generic_action(buf: &mut Buffer, m: &MVal, opt: &Option<String>, type_: &str)
ret.push(Parsed::S(s[..1].to_string()));
s = s[1..].to_string();
}
// 坏正则(编译失败)时 captures_head 返回 None直接结束 1/2 动作
// (产出已累积的前缀符号)。
if let Some(caps) = re!("^([0-9]+|\\$[a-z]\\$|[a-z])\\/([0-9]+)(\\$[a-z]\\$|[a-z])?$").captures_head(&s) {
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())
@ -1242,27 +1197,17 @@ fn get_operator(a: &str) -> String {
// 公开 API
// =========================================================================
/// 把 `\ce{...}` 内容转译为 LaTeX。
///
/// 容错设计(不依赖 `catch_unwind`,因 release 的 `panic = "abort"` 下它无效):
/// - 正则编译走 [`compile_or_log`],失败降级为不匹配(状态机退到 "else" 兜底)。
/// - 状态机有 watchdog 防死循环。
/// - `to_tex` 仍包 `catch_unwind`,仅作 dev/test 护栏。
///
/// 坏公式可能产出退化输出,但不应 panic。[`all_match_patterns_compile`] 测试
/// 在测试期捕获正则转录回归。
/// 把 `\ce{...}` 内容转译为 LaTeX。任何内部异常回退为原样输出。
pub fn ce(input: &str) -> String {
to_tex(input, "ce")
}
/// 把 `\pu{...}` 内容转译为 LaTeX(容错同 [`ce`]
/// 把 `\pu{...}` 内容转译为 LaTeX。任何内部异常回退为原样输出。
pub fn pu(input: &str) -> String {
to_tex(input, "pu")
}
fn to_tex(input: &str, kind: &str) -> String {
// 仅在 panic=unwinddev/test时有效release 的 panic=abort 下此处的
// catch_unwind 无法阻止进程终止。保留它是为了本地开发时坏公式不炸测试。
let result = std::panic::catch_unwind(|| {
let parsed = go(input, kind);
texify_go(&parsed, kind != "tex")
@ -1330,85 +1275,4 @@ mod tests {
// 应含上标(^...)且无 panic。
assert!(!tex.is_empty(), "离子应产出非空: {tex}");
}
/// 回归测试:`". __* "` 模式的 regex 曾因转录错误把 `|` 放进了字符类
/// 内部(`[.\u{22C5}\u{00B7}\u{2022}|[*]`fancy-regex 报
/// `ParseError(20, InvalidClass)``re!` 宏的 `.unwrap()` 触发 panic。
/// 上游 mhchemParser 是 `[...]|[*]`alternation 在类外)。
/// 这些输入会强制该 LazyLock 正则编译,必须产出非空且不 panic。
#[test]
fn ce_dot_bullet_bond_inputs_dont_panic() {
for s in [".", "·", "", "", "*", ". ", "••", "· ", "H·OH", "CaCO3 · H2O"] {
let tex = ce(s);
assert!(!tex.is_empty(), "ce({s:?}) 不应为空");
}
}
/// 回归测试:`compile_or_log` 是 re! 宏硬化的核心——对坏正则必须降级
/// 返回 `None` 而非 panic。`re!` 宏原先用 `.unwrap()`,在 panic=abort 下
/// 任何未来的正则转录错误(如曾经的 `". __* "` InvalidClass都会直接
/// 杀进程。降级后坏正则只让该模式匹配失败,状态机退到 "else" 兜底分支,
/// 不影响整体渲染。
#[test]
fn compile_or_log_degrades_bad_regex_without_panic() {
// 合法正则 → Some
assert!(compile_or_log("^a").is_some(), "合法正则应编译成功");
// 非法正则(未闭合字符类,曾触发 ParseError(20, InvalidClass)
let bad = "^([abc|[*])";
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| compile_or_log(bad)));
assert!(r.is_ok(), "compile_or_log 对坏正则不应 panic");
assert!(r.unwrap().is_none(), "坏正则应返回 None");
}
/// 回归测试:`find_observe_end` 曾用逐字节 `i += 1` 步进,遇多字节
/// UTF-8 字符(如中文「浓」占 3 字节)会让 `&input[i..]` 落在字符内部,
/// 触发 `byte index N is not a char boundary` panic。在 panic=abort 下
/// 直接杀进程(曾导致 rebuild_content_html 重建含中文化学式的文章时崩溃)。
/// 修复改为按 `char_indices` 字符边界步进。这些输入必须不 panic。
#[test]
fn ce_multibyte_char_in_braces_does_not_panic() {
for s in ["{浓}", "浓H2SO4", "{中文}", "H{浓}O", "\\frac{浓}{稀}", "[浓]"] {
// catch_unwind 仅 dev/test 护栏release panic=abort 下由本修复保证。
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| ce(s)));
assert!(r.is_ok(), "ce({s:?}) PANICKED (char boundary)");
}
}
/// 保护性任意多字节字符组合emoji/日韩文/组合字符/四字节区)都不应
/// panic。覆盖花括号/方括号/`\frac`/`$...$` 等所有走 find_observe_end 的路径。
#[test]
fn ce_arbitrary_multibyte_does_not_panic() {
let inputs = [
"🔥", "café", "naïve", "Σ", "αβγ", "ΔH", "你好世界", "안녕", "こんにちは",
"{🧪}", "H₂O", "[α]", "\\frac{β}{γ}", "${日本}$", "A·B•C⋅D",
"naïve H2O", "{β-Gal}", "😀😂", "\u{1F9EA}", // test tube emoji
];
for s in inputs {
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| ce(s)));
assert!(r.is_ok(), "ce({s:?}) PANICKED");
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| pu(s)));
assert!(r.is_ok(), "pu({s:?}) PANICKED");
}
}
/// 保护性:强制 `match_pattern` 的每个分支至少运行一次,让每个 `re!`
/// 定义的 regex 都被编译。任一转录错误都会在此暴露,而非等到生产环境
/// 被特定输入触发后 panic=abort 整个进程。
#[test]
fn all_match_patterns_compile() {
for name in [
"empty", "else", "else2", "space", "space A", "space$", "a-z", "x", "x$", "i$",
"letters", "\\greek", "one lowercase latin letter $",
"$one lowercase latin letter$ $", "one lowercase greek letter $", "digits",
"-9.,9", "-9.,9 no missing 0", "(-)(9)^(-9)", "_{(state of aggregation)}$",
"{[(", ")]}", ", ", ",", ".", ". __* ", "...", "^a", "^\\x", "^(-1)", "'", "_9",
"_\\x", "^_", "{}^", "{}", "=<>", "#", "+", "-$", "-9", "- orbital overlap", "-",
"pm-operator", "operator", "arrowUpDown", "->", "CMT", "1st-level escape", "\\,",
"\\ca", "\\x", "orbital", "others", "oxidation$", "d-oxidation$", "1/2$",
"(KV letters),", "uprightEntities", "/", "//", "*",
] {
// 返回值不重要,只要不 panicregex 编译成功)即可。
let _ = match_pattern(name, "");
}
}
}

View File

@ -924,7 +924,7 @@ fn ce_action(buf: &mut Buffer, m: &MVal, opt: &Option<String>, type_: &str) -> O
let d_is_int = buf
.d
.as_ref()
.map(|d| re!("^[1-9][0-9]*$").is_match(d))
.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();