const MARK: &str = "XXXXXXX";
const TEXT_COMMANDS: &[&str] = &["operatorname", "mathrm", "text", "mathbf"];
pub fn to_latex(text: &str) -> String {
render_safe(&normalize(&remove_chinese_text_wrapping(text)))
}
fn render_safe(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(i) = rest.find("\\text") {
let (head, tail) = rest.split_at(i);
out.push_str(head);
let word = tail[1..]
.find(|c: char| !c.is_ascii_alphabetic())
.map_or(tail.len(), |n| n + 1);
out.push_str(match &tail[..word] {
"\\textlangle" => "\\langle",
"\\textrangle" => "\\rangle",
other => other,
});
rest = &tail[word..];
}
out.push_str(rest);
out
}
fn is_space(c: char) -> bool {
c.is_whitespace() || ('\u{1c}'..='\u{1f}').contains(&c)
}
fn is_word(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
fn is_letter(c: char) -> bool {
c.is_ascii_alphabetic()
}
fn is_noletter(c: char) -> bool {
!is_word(c) || c == '_' || c == '^' || c.is_numeric()
}
fn starts_with(c: &[char], i: usize, s: &str) -> bool {
(i..).zip(s.chars()).all(|(k, x)| c.get(k) == Some(&x))
}
fn remove_chinese_text_wrapping(s: &str) -> String {
let c: Vec<char> = s.chars().collect();
let mut out = String::with_capacity(s.len());
let mut i = 0;
while i < c.len() {
if let Some((inner, end)) = chinese_text_at(&c, i) {
out.extend(&c[inner..end - 1]);
i = end;
} else {
out.push(c[i]);
i += 1;
}
}
out.replace('"', "")
}
fn chinese_text_at(c: &[char], i: usize) -> Option<(usize, usize)> {
if !starts_with(c, i, "\\text") {
return None;
}
let mut j = i + "\\text".len();
while c.get(j).is_some_and(|&x| is_space(x)) {
j += 1;
}
if c.get(j) != Some(&'{') {
return None;
}
let start = j + 1;
let close = start + c[start..].iter().position(|&x| x == '{' || x == '}')?;
let cjk = |x: &char| ('\u{4e00}'..='\u{9fff}').contains(x);
(c[close] == '}' && c[start..close].iter().any(cjk)).then_some((start, close + 1))
}
fn normalize(text: &str) -> String {
let mut s = text.to_owned();
for group in text_groups(text) {
for m in protected_candidates(&group) {
if !TEXT_COMMANDS.contains(&&m[1..]) {
s = s.replace(&m, &format!("{m}{MARK}"));
s = s.replace(' ', "");
}
}
}
let mut cur: Vec<char> = s.chars().collect();
loop {
let next = squeeze(&cur, is_noletter, is_noletter, true);
let next = squeeze(&next, is_noletter, is_letter, true);
let next = squeeze(&next, is_letter, is_noletter, false);
if next == cur {
break;
}
cur = next;
}
cur.into_iter().collect::<String>().replace(MARK, " ")
}
fn text_groups(text: &str) -> Vec<String> {
let c: Vec<char> = text.chars().collect();
let mut out = Vec::new();
let mut i = 0;
while i < c.len() {
match text_group_at(&c, i) {
Some(end) => {
out.push(c[i..end].iter().collect());
i = end;
}
None => i += 1,
}
}
out
}
fn text_group_at(c: &[char], i: usize) -> Option<usize> {
if c.get(i) != Some(&'\\') {
return None;
}
for cmd in TEXT_COMMANDS {
if !starts_with(c, i + 1, cmd) {
continue;
}
let after = i + 1 + cmd.len();
for take_space in [true, false] {
if take_space && !c.get(after).is_some_and(|&x| is_space(x)) {
continue;
}
let p = after + usize::from(take_space);
for take_star in [true, false] {
if take_star && c.get(p) != Some(&'*') {
continue;
}
let q = p + usize::from(take_star);
if c.get(q) != Some(&' ') || c.get(q + 1) != Some(&'{') {
continue;
}
let body = q + 2;
if let Some(k) = c[body..].iter().position(|&x| x == '}' || x == '\n')
&& c[body + k] == '}'
{
return Some(body + k + 1);
}
}
}
}
None
}
fn protected_candidates(group: &str) -> Vec<String> {
let c: Vec<char> = group.chars().collect();
let mut out = Vec::new();
let mut i = 0;
while i < c.len() {
if c[i] == '\\' {
let mut j = i + 1;
while c.get(j).is_some_and(|&x| is_letter(x)) {
j += 1;
}
if j > i + 1 && c.get(j).is_some_and(|&x| is_space(x)) {
match c.get(j + 1) {
Some(&x) if is_word(x) => {
out.push(c[i..j].iter().collect());
i = j + 1;
continue;
}
Some('}') => {
i = j + 1;
continue;
}
_ => {}
}
}
}
i += 1;
}
out
}
fn squeeze(
c: &[char],
first: impl Fn(char) -> bool,
second: impl Fn(char) -> bool,
guard: bool,
) -> Vec<char> {
let mut out = Vec::with_capacity(c.len());
let mut i = 0;
while i < c.len() {
let blocked = guard && c[i] == '\\' && c.get(i + 1) == Some(&' ');
if !blocked && first(c[i]) {
let mut k = 1;
let mut hit = None;
while c.get(i + k).is_some_and(|&x| is_space(x)) {
if c.get(i + k + 1).is_some_and(|&x| second(x)) {
hit = Some(k);
break;
}
k += 1;
}
if let Some(k) = hit {
out.push(c[i]);
out.push(c[i + k + 1]);
i += k + 2;
continue;
}
}
out.push(c[i]);
i += 1;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn replaces_symbols_katex_rejects() {
assert_eq!(
render_safe(r"\textlangle x \textrangle + \textbf{a} \textlangles"),
r"\langle x \rangle + \textbf{a} \textlangles"
);
}
#[test]
fn matches_reference_post_process() {
let cases = [
(
r"\zeta _ { 0 } ( \nu ) = - \frac { \nu \varrho ^ { - 2 \nu } } { \pi } \int _ { \mu } ^ { \infty } d \omega \int _ { C _ { + } } d z \frac { 2 z ^ { 2 } } { ( z ^ { 2 } + \omega ^ { 2 } ) ^ { \nu + 1 } } \breve { \Psi } ( \omega ; z ) e ^ { i \epsilon z } \quad ,",
r"\zeta_{0}(\nu)=-\frac{\nu\varrho^{-2\nu}}{\pi}\int_{\mu}^{\infty}d\omega\int_{C_{+}}d z\frac{2z^{2}}{(z^{2}+\omega^{2})^{\nu+1}}\breve{\Psi}(\omega;z)e^{i\epsilon z}\quad,",
),
(
r"\mathrm { d } x + \mathrm {d} y",
r"\mathrm{d}x+\mathrm{d}y",
),
(r"\mathrm { \alpha b } + c", r"\mathrm{\alpha b}+c"),
(
r"\text { if \quad x } , \operatorname* { a r g }",
r"\text{if\quad x},\operatorname*{arg}",
),
(r"a \ b + \ c", r"a\ b+\ c"),
("x = y", "x=y"),
("\\text{中文 abc} + \\text {\"q\"}", r"中文 abc+\text{q}"),
("a\n+ b", "a+b"),
(
r"\mathbf { \alpha x } \mathbf { \alpha y }",
r"\mathbf{\alpha x}\mathbf{\alpha y}",
),
("1 2 3 a b c", "123a b c"),
(r"_ ^ { } \, \;", r"_^{}\,\;"),
(r"\left( a \right)", r"\left(a\right)"),
(
r"\mathrm { \alpha \beta } \text { \gamma }",
r"\mathrm{\alpha\beta}\text{\gamma}",
),
("\\text { a } \\text {\nb }", r"\text{a}\text{b}"),
];
for (input, want) in cases {
assert_eq!(to_latex(input), want, "输入 {input:?}");
}
}
}