Compare commits

..

4 Commits

Author SHA1 Message Date
xfy
d1013dfa08 refactor(mhchem): re! 宏编译失败降级为不匹配,补上 panic=abort 缺失的防御
Some checks failed
CI / check (push) Has been cancelled
CI / build (push) Has been cancelled
re! 宏原先 LazyLock::new(|| Regex::new($pat).unwrap()),在 panic="abort" 下
任何正则转录错误(如曾经的 ". __* " InvalidClass)都会直接杀进程,且 to_tex
的 catch_unwind 在 abort 模式下无法兜住。

引入 compile_or_log(pat) -> Option<Regex>:编译失败记 tracing::error 日志
并返回 None(绝不 panic)。封装为 CompiledPat(Option<Regex>) newtype,把
「坏正则降级为不匹配」的语义集中在一处:

  - captures_head(input) -> Option<Captures>:坏正则/未匹配统一 None
  - is_match(input) -> bool:坏正则/未匹配统一 false

re! 宏改为产出 &CompiledPat,调用方直接 re!("...").captures_head(...) /
.is_match(...),无需再到处写 re_ref(...)? 或手写 None 回退。状态机遇到
坏模式自然退到 "else" 兜底分支产出原样字符,不影响整体渲染。

适配点:Pat::Re、pat_match_head、match_pattern 的 re_match 闭包、
pat_enumber/state_of_aggregation/amount/formula、1/2 动作、mhchem_tables.rs
的 ce_action、3 个 static LETTERS/GREEK/ONE_GREEK_RE。

回归测试 compile_or_log_degrades_bad_regex_without_panic 断言坏正则返回 None
不 panic。clippy 干净,622 测试全过。
2026-07-23 18:16:22 +08:00
xfy
a03fbac88c fix(mhchem): 修复 find_observe_end 逐字节扫描多字节字符的 panic
find_observe_end 用 `let bytes = input.as_bytes(); let mut i = start; while i < len { ...; i += 1; }`
逐字节步进,对每个位置做 `&input[i..]` 切片与 `bytes[i] as char` 花括号判定。
化学公式若含多字节 UTF-8 字符(如中文「浓」占 3 字节),i 会落在字符内部:

  - `&input[i..]` 触发 `byte index N is not a char boundary; it is inside '浓'` panic;
  - 在 panic=abort 下直接 SIGABRT 杀进程;
  - `bytes[i] as char` 把多字节字符首字节误判为花括号,还静默损坏输出。

触发路径:rebuild_content_html 重建含中文化学式(如 \ce{浓H2SO4})的文章时崩溃。

修复:改用 `input.char_indices().skip_while(...)` 按字符边界步进,i 始终落在
char 边界上,`a` 直接是解出的 char。start 校验对齐到边界后跳过。

新增:
- 回归测试 ce_multibyte_char_in_braces_does_not_panic:覆盖触发输入。
- 保护性测试 ce_arbitrary_multibyte_does_not_panic:穷举 emoji/日韩文/组合字符/
  四字节区,覆盖花括号/方括号/\frac/$...$ 全部走 find_observe_end 的路径。

注:多字节字符的状态机转译输出质量(如 ce("浓H2SO4") 的字符重复)是独立的
既有问题,mhchemParser 本为 ASCII 化学符号设计;本提交只修 panic,不扩大范围。
2026-07-23 18:00:41 +08:00
xfy
feaf76bcd9 fix(mhchem): 修复 ". __* " 正则转录错误导致的 panic=abort 崩溃
mhchem 转译器中 ". __* " 模式的 regex 被误抄成
  ^([.\u{22C5}\u{00B7}\u{2022}|[*])\s*
即把 alternation 的 `|` 放进了字符类内部。fancy-regex 把 `[...|[*]`
当作单个畸形字符类,报 ParseError(20, InvalidClass);`re!` 宏的
`.unwrap()` 因此 panic。

上游 mhchemParser 4.2.2 是 `[...]|[*]`(alternation 在类外),修正为
  ^([.\u{22C5}\u{00B7}\u{2022}]|[*])\s*

触发路径:用户提交含 `\ce{·}`/`\ce{•}`/`\ce{.}` 等化学式的文章 →
render_markdown_enhanced → katex::render_* → expand_chem → mhchem::ce,
首次编译该 LazyLock 正则即 panic。

关键放大因素:本项目 release profile 为 panic="abort",`to_tex` 内的
`std::panic::catch_unwind` 在此设置下无法捕获 panic,进程直接 SIGABRT。
修正了 `ce`/`pu` 的文档注释,不再谎称"绝不 panic"。

新增:
- 回归测试 ce_dot_bullet_bond_inputs_dont_panic:覆盖触发输入。
- 保护性测试 all_match_patterns_compile:强制编译 match_pattern 全部分支,
  任何同类转录错误在测试期而非生产期暴露。
2026-07-23 17:49:20 +08:00
xfy
3e9a3aa70d docs(mhchem): 修复模块级文档中的 broken intra-doc links
cargo doc 在 --document-private-items 下报 7 个 unresolved link 警告,
全部源自 src/api/mhchem.rs 的模块级 //! 文档:

- 公开 API ce/pu 用简写 [`ce`] 无法解析,改用全路径链接
  [`ce`](crate::api::mhchem::ce) / [`pu`](crate::api::mhchem::pu)。
- 私有项 Buffer/Parsed/Field/find_observe_groups/build_transitions 即便
  写全路径也无法被 intra-doc link 解析器识别(rustdoc 对私有项的限制),
  改回普通反引号代码,不再生成链接。这些是模块内部实现细节,本就无需
  跨项跳转。

修复后 make doc 零警告。
2026-07-23 17:48:06 +08:00
2 changed files with 181 additions and 45 deletions

View File

@ -8,14 +8,14 @@
//! `ce("H2O")` → `\mathrm{H}\sb{2}\mathrm{O}` 之类,无需嵌入 katex 内部解析树。 //! `ce("H2O")` → `\mathrm{H}\sb{2}\mathrm{O}` 之类,无需嵌入 katex 内部解析树。
//! //!
//! 移植要点TS→Rust //! 移植要点TS→Rust
//! - 动态 `buffer` 对象 → [`Buffer`] 结构体(`Option` 字段 + `clear`)。 //! - 动态 `buffer` 对象 → `Buffer` 结构体(`Option` 字段 + `clear`)。
//! - 动态 `Parsed = string | object` → [`Parsed`] 枚举;节点字段用 [`Field`](字符串或节点向量)。 //! - 动态 `Parsed = string | object` → `Parsed` 枚举;节点字段用 `Field`(字符串或节点向量)。
//! - 正则模式(含 lookahead `(?=)`/`(?!)`Rust `regex` 不支持)→ `fancy-regex`(已被 //! - 正则模式(含 lookahead `(?=)`/`(?!)`Rust `regex` 不支持)→ `fancy-regex`(已被
//! syntect 间接引入,此处显式声明)。 //! syntect 间接引入,此处显式声明)。
//! - `findObserveGroups` 花括号配对扫描 → [`find_observe_groups`] //! - `findObserveGroups` 花括号配对扫描 → `find_observe_groups`。
//! - 状态机转移表 → [`build_transitions`] 展开(对应 `_mhchemCreateTransitions`)。 //! - 状态机转移表 → `build_transitions` 展开(对应 `_mhchemCreateTransitions`)。
//! //!
//! 公开 API[`ce`] / [`pu`]。任意内部异常都回退为原样输出(绝不 panic //! 公开 API[`ce`](crate::api::mhchem::ce) / [`pu`](crate::api::mhchem::pu)。任意内部异常都回退为原样输出(绝不 panic
//! katex.rs 的容错哲学一致)。 //! katex.rs 的容错哲学一致)。
//! //!
//! ----------------------------------------------------------------------- //! -----------------------------------------------------------------------
@ -187,9 +187,55 @@ 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 { macro_rules! re {
($pat:literal) => {{ ($pat:literal) => {{
static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new($pat).unwrap()); static RE: LazyLock<CompiledPat> =
LazyLock::new(|| CompiledPat(compile_or_log($pat)));
&*RE &*RE
}}; }};
} }
@ -197,7 +243,7 @@ macro_rules! re {
/// 字面量或正则模式(`findObserveGroups` 的参数)。 /// 字面量或正则模式(`findObserveGroups` 的参数)。
enum Pat { enum Pat {
Lit(&'static str), Lit(&'static str),
Re(&'static Regex), Re(&'static CompiledPat),
} }
/// 在 `input` 起始处匹配 `pat`,返回匹配到的文本(不含 remainder /// 在 `input` 起始处匹配 `pat`,返回匹配到的文本(不含 remainder
@ -210,7 +256,7 @@ fn pat_match_head(pat: &Pat, input: &str) -> Option<String> {
None None
} }
} }
Pat::Re(re) => re.captures(input).ok().flatten().and_then(|c| { Pat::Re(cp) => cp.captures_head(input).and_then(|c| {
let whole = c.get(0).map(|m| m.as_str())?; let whole = c.get(0).map(|m| m.as_str())?;
// 模式均锚定 ^,确认从 0 开始 // 模式均锚定 ^,确认从 0 开始
if input.starts_with(whole) { if input.starts_with(whole) {
@ -296,11 +342,18 @@ struct Span {
} }
/// 从 `rest0` 的 `start` 位置扫描,跟踪花括号深度,在深度 0 遇到 `end_chars` 时返回其区间。 /// 从 `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> { 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; let mut braces = 0i32;
while i < input.len() { // 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) {
// 结束定界符匹配(仅在花括号平衡时) // 结束定界符匹配(仅在花括号平衡时)
if braces == 0 { if braces == 0 {
if let Some(matched) = pat_match_head(end_chars, &input[i..]) { if let Some(matched) = pat_match_head(end_chars, &input[i..]) {
@ -310,7 +363,6 @@ fn find_observe_end(input: &str, start: usize, end_chars: &Pat) -> Option<Span>
}); });
} }
} }
let a = bytes[i] as char;
if a == '{' { if a == '{' {
braces += 1; braces += 1;
} else if a == '}' { } else if a == '}' {
@ -320,7 +372,6 @@ fn find_observe_end(input: &str, start: usize, end_chars: &Pat) -> Option<Span>
} }
braces -= 1; braces -= 1;
} }
i += 1;
} }
None None
} }
@ -360,33 +411,32 @@ fn fancy_match(re: &Regex, input: &str) -> Option<MMatch> {
/// 希腊字母宏集合(`letters` / `\greek` 等模式用到)。 /// 希腊字母宏集合(`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 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<Regex> = LazyLock::new(|| { static LETTERS_RE: LazyLock<CompiledPat> = LazyLock::new(|| {
Regex::new(&format!( CompiledPat(compile_or_log(&format!(
"^(?:[a-zA-Z\u{03B1}-\u{03C9}\u{0391}-\u{03A9}?@]|(?:\\\\(?:{})(?:\\s+|\\{{\\}}|(?![a-zA-Z]))))+", "^(?:[a-zA-Z\u{03B1}-\u{03C9}\u{0391}-\u{03A9}?@]|(?:\\\\(?:{})(?:\\s+|\\{{\\}}|(?![a-zA-Z]))))+",
GREEK_NAMES GREEK_NAMES
)) )))
.unwrap()
}); });
static GREEK_RE: LazyLock<Regex> = LazyLock::new(|| { static GREEK_RE: LazyLock<CompiledPat> = LazyLock::new(|| {
Regex::new(&format!( CompiledPat(compile_or_log(&format!(
"^\\\\(?:{})(?:\\s+|\\{{\\}}|(?![a-zA-Z]))", "^\\\\(?:{})(?:\\s+|\\{{\\}}|(?![a-zA-Z]))",
GREEK_NAMES GREEK_NAMES
)) )))
.unwrap()
}); });
static ONE_GREEK_RE: LazyLock<Regex> = LazyLock::new(|| { static ONE_GREEK_RE: LazyLock<CompiledPat> = LazyLock::new(|| {
Regex::new( CompiledPat(compile_or_log(
"^(?:\\$?[\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]))$" "^(?:\\$?[\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_`)。 /// 按名匹配模式(对应 `_mhchemParser.patterns.match_`)。
fn match_pattern(name: &str, input: &str) -> Option<MMatch> { fn match_pattern(name: &str, input: &str) -> Option<MMatch> {
// ── 正则模式 ── // ── 正则模式 ──
let re_match = |re: &Regex| fancy_match(re, input); // `re!` 产物是 `&CompiledPat`:编译失败的坏正则 `.0` 为 None
// 这里 `.0.as_ref()?` 降级为不匹配,状态机退到 "else" 兜底分支。
let re_match = |cp: &CompiledPat| cp.0.as_ref().and_then(|r| fancy_match(r, input));
match name { match name {
"empty" => re_match(re!("^$")), "empty" => re_match(re!("^$")),
"else" | "else2" => re_match(re!("^.")), "else" | "else2" => re_match(re!("^.")),
@ -412,7 +462,7 @@ fn match_pattern(name: &str, input: &str) -> Option<MMatch> {
", " => re_match(re!("^[,;]\\s*")), ", " => re_match(re!("^[,;]\\s*")),
"," => re_match(re!("^[,;]")), "," => re_match(re!("^[,;]")),
"." => 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!("^\\.\\.\\.(?=$|[^.])")), "..." => re_match(re!("^\\.\\.\\.(?=$|[^.])")),
"^a" => re_match(re!("^\\^([0-9]+|[^\\\\_])")), "^a" => re_match(re!("^\\^([0-9]+|[^\\\\_])")),
"^\\x" => re_match(re!("^\\^(\\\\[a-zA-Z]+)\\s*")), "^\\x" => re_match(re!("^\\^(\\\\[a-zA-Z]+)\\s*")),
@ -508,7 +558,7 @@ fn fg(
/// `(-)(9.,9)(e)(99)` 模式。 /// `(-)(9.,9)(e)(99)` 模式。
fn pat_enumber(input: &str) -> Option<MMatch> { 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 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 caps = re.captures_head(input)?;
let whole = caps.get(0)?.as_str(); let whole = caps.get(0)?.as_str();
if whole.is_empty() || !input.starts_with(whole) { if whole.is_empty() || !input.starts_with(whole) {
return None; return None;
@ -541,14 +591,11 @@ fn pat_state_of_aggregation(input: &str) -> Option<MMatch> {
None, None,
false, false,
)?; )?;
if re!("^($|[\\s,;\\)\\]\\}])") if re!("^($|[\\s,;\\)\\]\\}])").is_match(a.remainder.as_str()) {
.is_match(a.remainder.as_str())
.ok()?
{
return Some(a); return Some(a);
} }
let re2 = re!("^(?:\\((?:\\\\ca\\s?)?\\$[amothc]\\$\\))"); let re2 = re!("^(?:\\((?:\\\\ca\\s?)?\\$[amothc]\\$\\))");
let caps = re2.captures(input).ok()??; let caps = re2.captures_head(input)?;
let whole = caps.get(0)?.as_str().to_string(); let whole = caps.get(0)?.as_str().to_string();
Some(MMatch { Some(MMatch {
m: MVal::S(whole.clone()), m: MVal::S(whole.clone()),
@ -559,7 +606,7 @@ fn pat_state_of_aggregation(input: &str) -> Option<MMatch> {
/// `amount` 模式。 /// `amount` 模式。
fn pat_amount(input: &str) -> Option<MMatch> { 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))"); 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() { if let Some(caps) = re.captures_head(input) {
let whole = caps.get(0)?.as_str(); let whole = caps.get(0)?.as_str();
if !whole.is_empty() { if !whole.is_empty() {
return Some(MMatch { return Some(MMatch {
@ -583,7 +630,7 @@ fn pat_amount(input: &str) -> Option<MMatch> {
let re2 = re!("^\\$(?:\\(?[+\\-]?(?:[0-9]*[a-z]?[+\\-])?[0-9]*[a-z](?:[+\\-][0-9]*[a-z]?)?\\)?|\\+|-)\\$$"); let re2 = re!("^\\$(?:\\(?[+\\-]?(?:[0-9]*[a-z]?[+\\-])?[0-9]*[a-z](?:[+\\-][0-9]*[a-z]?)?\\)?|\\+|-)\\$$");
let inner = mval_str(&a.m); let inner = mval_str(&a.m);
let inner_len = inner.len(); let inner_len = inner.len();
if re2.is_match(&inner).ok()? { if re2.is_match(&inner) {
return Some(MMatch { return Some(MMatch {
m: MVal::S(inner), m: MVal::S(inner),
remainder: input[inner_len..].to_string(), remainder: input[inner_len..].to_string(),
@ -594,11 +641,11 @@ fn pat_amount(input: &str) -> Option<MMatch> {
/// `formula$` 模式。 /// `formula$` 模式。
fn pat_formula(input: &str) -> Option<MMatch> { fn pat_formula(input: &str) -> Option<MMatch> {
if re!("^\\([a-z]+\\)$").is_match(input).ok()? { if re!("^\\([a-z]+\\)$").is_match(input) {
return None; return None;
} }
let re = re!("^(?:[a-z]|(?:[0-9\\ +\\-\\,\\.\\(\\)]+[a-z])+[0-9\\ +\\-\\,\\.\\(\\)]*|(?:[a-z][0-9\\ +\\-\\,\\.\\(\\)]+)+[a-z]?)$"); let re = re!("^(?:[a-z]|(?:[0-9\\ +\\-\\,\\.\\(\\)]+[a-z])+[0-9\\ +\\-\\,\\.\\(\\)]*|(?:[a-z][0-9\\ +\\-\\,\\.\\(\\)]+)+[a-z]?)$");
let caps = re.captures(input).ok()??; let caps = re.captures_head(input)?;
let whole = caps.get(0)?.as_str().to_string(); let whole = caps.get(0)?.as_str().to_string();
Some(MMatch { Some(MMatch {
m: MVal::S(whole.clone()), m: MVal::S(whole.clone()),
@ -827,11 +874,9 @@ fn generic_action(buf: &mut Buffer, m: &MVal, opt: &Option<String>, type_: &str)
ret.push(Parsed::S(s[..1].to_string())); ret.push(Parsed::S(s[..1].to_string()));
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_head 返回 None直接结束 1/2 动作
.captures(&s) // (产出已累积的前缀符号)。
.ok() if let Some(caps) = re!("^([0-9]+|\\$[a-z]\\$|[a-z])\\/([0-9]+)(\\$[a-z]\\$|[a-z])?$").captures_head(&s) {
.flatten()
{
let mut n1 = caps let mut n1 = caps
.get(1) .get(1)
.map(|x| x.as_str().to_string()) .map(|x| x.as_str().to_string())
@ -1197,17 +1242,27 @@ fn get_operator(a: &str) -> String {
// 公开 API // 公开 API
// ========================================================================= // =========================================================================
/// 把 `\ce{...}` 内容转译为 LaTeX。任何内部异常回退为原样输出。 /// 把 `\ce{...}` 内容转译为 LaTeX。
///
/// 容错设计(不依赖 `catch_unwind`,因 release 的 `panic = "abort"` 下它无效):
/// - 正则编译走 [`compile_or_log`],失败降级为不匹配(状态机退到 "else" 兜底)。
/// - 状态机有 watchdog 防死循环。
/// - `to_tex` 仍包 `catch_unwind`,仅作 dev/test 护栏。
///
/// 坏公式可能产出退化输出,但不应 panic。[`all_match_patterns_compile`] 测试
/// 在测试期捕获正则转录回归。
pub fn ce(input: &str) -> String { pub fn ce(input: &str) -> String {
to_tex(input, "ce") to_tex(input, "ce")
} }
/// 把 `\pu{...}` 内容转译为 LaTeX。任何内部异常回退为原样输出。 /// 把 `\pu{...}` 内容转译为 LaTeX(容错同 [`ce`]
pub fn pu(input: &str) -> String { pub fn pu(input: &str) -> String {
to_tex(input, "pu") to_tex(input, "pu")
} }
fn to_tex(input: &str, kind: &str) -> String { fn to_tex(input: &str, kind: &str) -> String {
// 仅在 panic=unwinddev/test时有效release 的 panic=abort 下此处的
// catch_unwind 无法阻止进程终止。保留它是为了本地开发时坏公式不炸测试。
let result = std::panic::catch_unwind(|| { let result = std::panic::catch_unwind(|| {
let parsed = go(input, kind); let parsed = go(input, kind);
texify_go(&parsed, kind != "tex") texify_go(&parsed, kind != "tex")
@ -1275,4 +1330,85 @@ mod tests {
// 应含上标(^...)且无 panic。 // 应含上标(^...)且无 panic。
assert!(!tex.is_empty(), "离子应产出非空: {tex}"); 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 let d_is_int = buf
.d .d
.as_ref() .as_ref()
.map(|d| re!("^[1-9][0-9]*$").is_match(d).unwrap_or(false)) .map(|d| re!("^[1-9][0-9]*$").is_match(d))
.unwrap_or(false); .unwrap_or(false);
if d_is_int { if d_is_int {
let tmp = buf.d.take(); let tmp = buf.d.take();