const SUPERSCRIPTS: &[(char, char)] = &[
('0', '⁰'),
('1', '¹'),
('2', '²'),
('3', '³'),
('4', '⁴'),
('5', '⁵'),
('6', '⁶'),
('7', '⁷'),
('8', '⁸'),
('9', '⁹'),
('+', '⁺'),
('-', '⁻'),
('=', '⁼'),
('(', '⁽'),
(')', '⁾'),
('n', 'ⁿ'),
('i', 'ⁱ'),
('a', 'ᵃ'),
('b', 'ᵇ'),
('c', 'ᶜ'),
('d', 'ᵈ'),
('e', 'ᵉ'),
('f', 'ᶠ'),
('g', 'ᵍ'),
('h', 'ʰ'),
('j', 'ʲ'),
('k', 'ᵏ'),
('l', 'ˡ'),
('m', 'ᵐ'),
('o', 'ᵒ'),
('p', 'ᵖ'),
('r', 'ʳ'),
('s', 'ˢ'),
('t', 'ᵗ'),
('u', 'ᵘ'),
('v', 'ᵛ'),
('w', 'ʷ'),
('x', 'ˣ'),
('y', 'ʸ'),
('z', 'ᶻ'),
];
const SUBSCRIPTS: &[(char, char)] = &[
('0', '₀'),
('1', '₁'),
('2', '₂'),
('3', '₃'),
('4', '₄'),
('5', '₅'),
('6', '₆'),
('7', '₇'),
('8', '₈'),
('9', '₉'),
('+', '₊'),
('-', '₋'),
('=', '₌'),
('(', '₍'),
(')', '₎'),
('a', 'ₐ'),
('e', 'ₑ'),
('h', 'ₕ'),
('i', 'ᵢ'),
('j', 'ⱼ'),
('k', 'ₖ'),
('l', 'ₗ'),
('m', 'ₘ'),
('n', 'ₙ'),
('o', 'ₒ'),
('p', 'ₚ'),
('r', 'ᵣ'),
('s', 'ₛ'),
('t', 'ₜ'),
('u', 'ᵤ'),
('v', 'ᵥ'),
('x', 'ₓ'),
];
const COMMANDS: &[(&str, &str)] = &[
("alpha", "α"),
("beta", "β"),
("gamma", "γ"),
("delta", "δ"),
("epsilon", "ε"),
("varepsilon", "ε"),
("zeta", "ζ"),
("eta", "η"),
("theta", "θ"),
("vartheta", "ϑ"),
("iota", "ι"),
("kappa", "κ"),
("lambda", "λ"),
("mu", "μ"),
("nu", "ν"),
("xi", "ξ"),
("pi", "π"),
("varpi", "ϖ"),
("rho", "ρ"),
("varrho", "ϱ"),
("sigma", "σ"),
("varsigma", "ς"),
("tau", "τ"),
("upsilon", "υ"),
("phi", "φ"),
("varphi", "ϕ"),
("chi", "χ"),
("psi", "ψ"),
("omega", "ω"),
("Gamma", "Γ"),
("Delta", "Δ"),
("Theta", "Θ"),
("Lambda", "Λ"),
("Xi", "Ξ"),
("Pi", "Π"),
("Sigma", "Σ"),
("Upsilon", "Υ"),
("Phi", "Φ"),
("Psi", "Ψ"),
("Omega", "Ω"),
("times", "×"),
("div", "÷"),
("pm", "±"),
("mp", "∓"),
("cdot", "·"),
("leq", "≤"),
("le", "≤"),
("geq", "≥"),
("ge", "≥"),
("neq", "≠"),
("ne", "≠"),
("approx", "≈"),
("equiv", "≡"),
("propto", "∝"),
("infty", "∞"),
("partial", "∂"),
("nabla", "∇"),
("sum", "∑"),
("prod", "∏"),
("int", "∫"),
("oint", "∮"),
("sqrt", "√"),
("forall", "∀"),
("exists", "∃"),
("neg", "¬"),
("in", "∈"),
("notin", "∉"),
("subset", "⊂"),
("subseteq", "⊆"),
("supset", "⊃"),
("cup", "∪"),
("cap", "∩"),
("emptyset", "∅"),
("rightarrow", "→"),
("to", "→"),
("leftarrow", "←"),
("Rightarrow", "⇒"),
("Leftarrow", "⇐"),
("leftrightarrow", "↔"),
("Leftrightarrow", "⇔"),
("mapsto", "↦"),
("circ", "∘"),
("ast", "∗"),
("star", "⋆"),
("bullet", "•"),
("ldots", "…"),
("cdots", "⋯"),
("degree", "°"),
("dagger", "†"),
("ddagger", "‡"),
];
fn script_char(table: &[(char, char)], c: char) -> Option<char> {
table.iter().find(|(k, _)| *k == c).map(|(_, v)| *v)
}
fn convert_math(latex: &str) -> String {
let chars: Vec<char> = latex.chars().collect();
let mut out = String::with_capacity(latex.len());
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == '\\' {
let start = i + 1;
let mut end = start;
while end < chars.len() && chars[end].is_ascii_alphabetic() {
end += 1;
}
if end > start {
let name: String = chars[start..end].iter().collect();
if let Some((_, repl)) = COMMANDS.iter().find(|(k, _)| *k == name) {
out.push_str(repl);
i = end;
continue;
}
} else if end < chars.len() {
out.push(chars[end]);
i = end + 1;
continue;
}
out.push('\\');
i += 1;
} else if c == '^' || c == '_' {
let table = if c == '^' { SUPERSCRIPTS } else { SUBSCRIPTS };
if let Some((body, next)) = read_group(&chars, i + 1) {
let resolved = convert_math(&body);
let any_script = resolved.chars().any(|ch| script_char(table, ch).is_some());
if any_script || body.contains('\\') {
for ch in resolved.chars() {
out.push(script_char(table, ch).unwrap_or(ch));
}
} else {
out.push(c);
out.push_str(&resolved);
}
i = next;
} else {
out.push(c);
i += 1;
}
} else {
out.push(c);
i += 1;
}
}
out
}
fn read_group(chars: &[char], at: usize) -> Option<(String, usize)> {
let c = *chars.get(at)?;
if c == '{' {
let mut depth = 1;
let mut end = at + 1;
let mut body = String::new();
while end < chars.len() {
let ch = chars[end];
if ch == '{' {
depth += 1;
body.push(ch);
} else if ch == '}' {
depth -= 1;
if depth == 0 {
return Some((body, end + 1));
}
body.push(ch);
} else {
body.push(ch);
}
end += 1;
}
Some((body, end)) } else if c == '\\' {
let mut end = at + 1;
while end < chars.len() && chars[end].is_ascii_alphabetic() {
end += 1;
}
let body: String = chars[at..end].iter().collect();
Some((body, end))
} else {
Some((c.to_string(), at + 1))
}
}
pub(crate) fn latex_to_unicode_owned(input: &str) -> String {
if !input.contains('$') {
return input.to_string();
}
let chars: Vec<char> = input.chars().collect();
let mut out = String::with_capacity(input.len());
let mut i = 0;
let n = chars.len();
while i < n {
let run = if chars[i] == '$' && i + 1 < n && chars[i + 1] == '$' {
find_delim(&chars, i + 2, "$$").map(|end| (i + 2, end, 2))
} else if chars[i] == '$' {
find_delim(&chars, i + 1, "$")
.filter(|end| {
let body: String = chars[i + 1..*end].iter().collect();
is_mathy(&body)
})
.map(|end| (i + 1, end, 1))
} else {
None
};
if let Some((body_start, end, closer_len)) = run {
let body: String = chars[body_start..end].iter().collect();
out.push_str(&convert_math(&body));
i = end + closer_len;
continue;
}
out.push(chars[i]);
i += 1;
}
out
}
fn is_mathy(body: &str) -> bool {
body.contains('\\') || body.contains('^') || body.contains('_')
}
fn find_delim(chars: &[char], from: usize, delim: &str) -> Option<usize> {
let dc: Vec<char> = delim.chars().collect();
let mut i = from;
while i < chars.len() {
if chars[i] == '\\' && i + 1 < chars.len() {
i += 2;
continue;
}
if chars[i] == dc[0] {
if dc.len() == 1 {
return Some(i);
}
if i + 1 < chars.len() && chars[i + 1] == dc[1] {
return Some(i);
}
}
i += 1;
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_math_returns_input_unchanged() {
assert_eq!(latex_to_unicode_owned("hello world"), "hello world");
assert_eq!(
latex_to_unicode_owned("cost is $5 and $10"),
"cost is $5 and $10"
);
}
#[test]
fn lone_unbalanced_dollar_left_untouched() {
assert_eq!(latex_to_unicode_owned("price: $5"), "price: $5");
assert_eq!(latex_to_unicode_owned("a $ b"), "a $ b");
}
#[test]
fn inline_greek_and_operators() {
assert_eq!(latex_to_unicode_owned(r"$\alpha + \beta$"), "α + β");
assert_eq!(latex_to_unicode_owned(r"$\sum \times \infty$"), "∑ × ∞");
}
#[test]
fn superscripts_and_subscripts() {
assert_eq!(latex_to_unicode_owned(r"$x^2 + y^2$"), "x² + y²");
assert_eq!(latex_to_unicode_owned(r"$a_{ij}$"), "aᵢⱼ");
assert_eq!(latex_to_unicode_owned(r"$2^n$"), "2ⁿ");
}
#[test]
fn superscript_without_unicode_form_falls_back() {
assert_eq!(latex_to_unicode_owned(r"$x^q$"), "x^q");
}
#[test]
fn display_math_block() {
assert_eq!(
latex_to_unicode_owned(r"$$\int_0^\infty e^{-x} dx$$"),
"∫₀∞ e⁻ˣ dx"
);
}
#[test]
fn mixed_text_and_math() {
assert_eq!(
latex_to_unicode_owned(r"Euler: $e^{i\pi} + 1 = 0$ holds."),
"Euler: eⁱπ + 1 = 0 holds."
);
}
#[test]
fn escaped_dollar_inside_math() {
assert_eq!(latex_to_unicode_owned(r"cost $\$5$"), "cost $5");
}
#[test]
fn unknown_command_emitted_verbatim() {
assert_eq!(latex_to_unicode_owned(r"$\foobar x$"), r"\foobar x");
}
}