use std::sync::OnceLock;
fn is_escaped(bytes: &[u8], idx: usize) -> bool {
let mut slashes = 0;
let mut cursor = idx;
while cursor > 0 && bytes[cursor - 1] == b'\\' {
slashes += 1;
cursor -= 1;
}
slashes % 2 == 1
}
fn find_math_start(text: &str) -> Option<usize> {
let b = text.as_bytes();
for (idx, &byte) in b.iter().enumerate() {
if byte == b'$' && !is_escaped(b, idx) {
return Some(idx);
}
if byte == b'\\'
&& !is_escaped(b, idx)
&& idx + 1 < b.len()
&& (b[idx + 1] == b'(' || b[idx + 1] == b'[')
{
return Some(idx);
}
}
None
}
fn find_math_end(text: &str) -> Option<(usize, bool)> {
let b = text.as_bytes();
if b.starts_with(b"$$") {
for i in 2..b.len().saturating_sub(1) {
if b[i] == b'{' {
continue;
}
if b[i..].starts_with(b"$$") && !is_escaped(b, i) {
return Some((i, true));
}
}
} else if b.starts_with(b"$") && !b.starts_with(b"$$") {
for (j, &byte) in b[1..].iter().enumerate() {
if byte == b'{' {
continue;
}
let i = j + 1;
if byte == b'$'
&& !is_escaped(b, i)
&& !b
.get(i.wrapping_sub(1))
.is_some_and(u8::is_ascii_whitespace)
&& !b.get(i + 1).is_some_and(u8::is_ascii_digit)
{
return Some((i, false));
}
}
} else if b.starts_with(b"\\[") {
for i in 2..b.len().saturating_sub(1) {
if b[i] == b'{' {
continue;
}
if b[i..].starts_with(b"\\]") && !is_escaped(b, i) {
return Some((i, true));
}
}
} else if b.starts_with(b"\\(") {
for i in 2..b.len().saturating_sub(1) {
if b[i] == b'{' {
continue;
}
if b[i..].starts_with(b"\\)") && !is_escaped(b, i) {
return Some((i, false));
}
}
}
None
}
fn math_delim_offset(text: &str) -> usize {
let b = text.as_bytes();
if b.starts_with(b"$$") || b.starts_with(b"\\[") || b.starts_with(b"\\(") {
2
} else {
1
}
}
fn render_math_segment(text: &str) -> String {
let mut result = String::new();
let mut i = 0;
while i < text.len() {
let remaining = &text[i..];
if let Some((end, _is_display)) = find_math_end(remaining) {
let offset = math_delim_offset(remaining);
let inner = &remaining[offset..end];
result.push_str(&render_latex_to_string(inner));
let close_len: usize = if remaining[end..].starts_with("\\]")
|| remaining[end..].starts_with("$$")
|| remaining[end..].starts_with("\\)")
{
2
} else if remaining.as_bytes().get(end..end + 1) == Some(b"$") {
1
} else {
0
};
i += end + close_len;
} else {
let skip = find_math_start(remaining).unwrap_or(remaining.len());
if skip == 0 {
result.push(remaining.chars().next().unwrap_or('$'));
i += remaining.chars().next().map(|c| c.len_utf8()).unwrap_or(1);
} else {
result.push_str(&remaining[..skip]);
i += skip;
if skip == remaining.len() {
break;
}
}
}
}
result
}
pub fn render_latex_in_text(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut cursor = 0;
while cursor < text.len() {
let Some(tick_offset) = text[cursor..].find('`') else {
result.push_str(&render_math_segment(&text[cursor..]));
break;
};
let tick_start = cursor + tick_offset;
result.push_str(&render_math_segment(&text[cursor..tick_start]));
let tick_count = text[tick_start..]
.bytes()
.take_while(|byte| *byte == b'`')
.count();
let delimiter = "`".repeat(tick_count);
let content_start = tick_start + tick_count;
if let Some(close_offset) = text[content_start..].find(&delimiter) {
let code_end = content_start + close_offset + tick_count;
result.push_str(&text[tick_start..code_end]);
cursor = code_end;
} else {
result.push_str(&text[tick_start..]);
break;
}
}
result
}
fn render_styled_symbol(
command: &str,
chars: &mut std::iter::Peekable<std::str::Chars>,
out: &mut String,
) {
let argument = read_braced(chars);
let rendered = match (command, argument.as_str()) {
("mathbb", "R") => Some("ℝ"),
("mathbb", "C") => Some("ℂ"),
("mathbb", "N") => Some("ℕ"),
("mathbb", "Q") => Some("ℚ"),
("mathbb", "Z") => Some("ℤ"),
("mathbb", "P") => Some("ℙ"),
("mathbb", "H") => Some("ℍ"),
("mathbb", "F") => Some("𝔽"),
("mathcal", "L") => Some("ℒ"),
("mathcal", "H") => Some("ℋ"),
("mathcal", "R") => Some("ℛ"),
_ => None,
};
if let Some(symbol) = rendered {
out.push_str(symbol);
} else {
out.push('\\');
out.push_str(command);
out.push('{');
out.push_str(&argument);
out.push('}');
}
}
fn render_latex_to_string(latex: &str) -> String {
let mut out = String::new();
let mut chars = latex.trim().chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\\' => {
let mut cmd = String::new();
while let Some(&c) = chars.peek() {
if c.is_ascii_alphabetic() {
cmd.push(c);
chars.next();
} else {
break;
}
}
if cmd.is_empty() {
if let Some(&c) = chars.peek() {
match c {
'{' | '}' | '$' | '%' | '#' | '&' | '_' | ' ' => {
chars.next();
}
_ => {
out.push(c);
chars.next();
}
}
}
continue;
}
match cmd.as_str() {
"mathbb" | "mathcal" => render_styled_symbol(&cmd, &mut chars, &mut out),
"frac" => {
let n = render_latex_to_string(&read_braced(&mut chars));
let d = render_latex_to_string(&read_braced(&mut chars));
out.push_str(&format!("({}/{})", n, d));
}
"sqrt" => {
let _n = read_optional_sqrt_root(&mut chars);
let r = render_latex_to_string(&read_braced(&mut chars));
out.push_str(&format!("\u{221a}({})", r));
}
"sum" => {
out.push('\u{2211}');
}
"prod" => {
out.push('\u{220f}');
}
"int" => {
out.push('\u{222b}');
}
"iint" => {
out.push('\u{222c}');
}
"iiint" => {
out.push('\u{222d}');
}
"oint" => {
out.push('\u{222e}');
}
"lim" => {
out.push_str("lim");
}
"sin" | "cos" | "tan" | "cot" | "sec" | "csc" | "log" | "ln" | "lg" | "exp"
| "det" | "dim" | "ker" | "hom" | "max" | "min" | "sup" | "inf" | "arg"
| "deg" | "mod" | "gcd" | "lcm" => out.push_str(&cmd),
"to" | "rightarrow" => out.push('\u{2192}'),
"leftarrow" => out.push('\u{2190}'),
"Rightarrow" => out.push('\u{21d2}'),
"Leftarrow" => out.push('\u{21d0}'),
"Leftrightarrow" | "iff" => out.push('\u{21d4}'),
"mapsto" => out.push('\u{21a6}'),
"longrightarrow" => out.push('\u{27f6}'),
"Longrightarrow" => out.push('\u{27f9}'),
"uparrow" => out.push('\u{2191}'),
"downarrow" => out.push('\u{2193}'),
"Uparrow" => out.push('\u{21d1}'),
"Downarrow" => out.push('\u{21d3}'),
_ => {
if let Some(u) = SYMBOLS.get_or_init(build_symbols).get(cmd.as_str()) {
out.push_str(u);
} else {
out.push('\\');
out.push_str(&cmd);
if chars.peek() == Some(&'{') {
out.push('{');
out.push_str(&read_braced(&mut chars));
out.push('}');
}
}
}
}
}
'_' => {
let sub = read_optional_arg(&mut chars);
append_subscript(&render_latex_to_string(&sub), &mut out);
}
'^' => {
let sup = read_optional_arg(&mut chars);
append_superscript(&render_latex_to_string(&sup), &mut out);
}
'{' | '}' => {}
' ' => {
if !out.ends_with(' ') {
out.push(' ');
}
}
'\n' => {
if !out.ends_with(' ') {
out.push(' ');
}
}
_ => out.push(ch),
}
}
out.trim().to_string()
}
fn read_braced(chars: &mut std::iter::Peekable<std::str::Chars>) -> String {
let mut s = String::new();
let mut depth: u32 = 0;
if chars.next_if_eq(&'{').is_some() {
depth = 1;
}
while let Some(&c) = chars.peek() {
match c {
'{' => {
depth += 1;
s.push(c);
chars.next();
}
'}' => {
depth = depth.saturating_sub(1);
chars.next();
if depth == 0 {
break;
}
s.push('}');
}
_ => {
s.push(c);
chars.next();
}
}
}
s
}
fn read_optional_arg(chars: &mut std::iter::Peekable<std::str::Chars>) -> String {
if chars.peek() == Some(&'{') {
read_braced(chars)
} else {
let mut s = String::new();
while let Some(&c) = chars.peek() {
if c.is_alphanumeric() || c == '+' || c == '-' {
s.push(c);
chars.next();
} else {
break;
}
}
s
}
}
fn read_optional_sqrt_root(chars: &mut std::iter::Peekable<std::str::Chars>) -> String {
if chars.peek() == Some(&'[') {
chars.next();
let mut s = String::new();
while let Some(&c) = chars.peek() {
if c == ']' {
chars.next();
break;
}
s.push(c);
chars.next();
}
s
} else {
String::new()
}
}
fn append_superscript(s: &str, out: &mut String) {
for c in s.chars() {
out.push(match c {
'0' => '\u{2070}',
'1' => '\u{00b9}',
'2' => '\u{00b2}',
'3' => '\u{00b3}',
'4' => '\u{2074}',
'5' => '\u{2075}',
'6' => '\u{2076}',
'7' => '\u{2077}',
'8' => '\u{2078}',
'9' => '\u{2079}',
'+' => '\u{207a}',
'-' => '\u{207b}',
'=' => '\u{207c}',
'n' => '\u{207f}',
'i' => '\u{2071}',
_ => c,
});
}
}
fn append_subscript(s: &str, out: &mut String) {
for c in s.chars() {
out.push(match c {
'0' => '\u{2080}',
'1' => '\u{2081}',
'2' => '\u{2082}',
'3' => '\u{2083}',
'4' => '\u{2084}',
'5' => '\u{2085}',
'6' => '\u{2086}',
'7' => '\u{2087}',
'8' => '\u{2088}',
'9' => '\u{2089}',
'+' => '\u{208a}',
'-' => '\u{208b}',
'=' => '\u{208c}',
'a' => '\u{2090}',
'e' => '\u{2091}',
'h' => '\u{2095}',
'i' => '\u{1d62}',
'k' => '\u{2096}',
'l' => '\u{2097}',
'm' => '\u{2098}',
'n' => '\u{2099}',
'o' => '\u{2092}',
'p' => '\u{209a}',
'r' => '\u{1d63}',
's' => '\u{209b}',
't' => '\u{209c}',
'u' => '\u{1d64}',
'v' => '\u{1d65}',
'x' => '\u{2093}',
_ => c,
});
}
}
type SymbolMap = std::collections::HashMap<&'static str, &'static str>;
fn build_symbols() -> SymbolMap {
let mut m = SymbolMap::new();
for (k, v) in [
("alpha", "\u{03b1}"),
("beta", "\u{03b2}"),
("gamma", "\u{03b3}"),
("delta", "\u{03b4}"),
("epsilon", "\u{03b5}"),
("zeta", "\u{03b6}"),
("eta", "\u{03b7}"),
("theta", "\u{03b8}"),
("iota", "\u{03b9}"),
("kappa", "\u{03ba}"),
("lambda", "\u{03bb}"),
("mu", "\u{03bc}"),
("nu", "\u{03bd}"),
("xi", "\u{03be}"),
("pi", "\u{03c0}"),
("rho", "\u{03c1}"),
("sigma", "\u{03c3}"),
("tau", "\u{03c4}"),
("upsilon", "\u{03c5}"),
("phi", "\u{03c6}"),
("chi", "\u{03c7}"),
("psi", "\u{03c8}"),
("omega", "\u{03c9}"),
("varepsilon", "\u{03b5}"),
("vartheta", "\u{03d1}"),
("varphi", "\u{03c6}"),
] {
m.insert(k, v);
}
for (k, v) in [
("Gamma", "\u{0393}"),
("Delta", "\u{0394}"),
("Theta", "\u{0398}"),
("Lambda", "\u{039b}"),
("Xi", "\u{039e}"),
("Pi", "\u{03a0}"),
("Sigma", "\u{03a3}"),
("Upsilon", "\u{03a5}"),
("Phi", "\u{03a6}"),
("Psi", "\u{03a8}"),
("Omega", "\u{03a9}"),
] {
m.insert(k, v);
}
for (k, v) in [
("infty", "\u{221e}"),
("partial", "\u{2202}"),
("nabla", "\u{2207}"),
("ell", "\u{2113}"),
("hbar", "\u{210f}"),
("Im", "\u{2111}"),
("Re", "\u{211c}"),
("emptyset", "\u{2205}"),
("aleph", "\u{2135}"),
("angle", "\u{2220}"),
("perp", "\u{22a5}"),
("parallel", "\u{2225}"),
("prime", "\u{2032}"),
("surd", "\u{221a}"),
("top", "\u{22a4}"),
] {
m.insert(k, v);
}
for (k, v) in [
("in", "\u{2208}"),
("notin", "\u{2209}"),
("ni", "\u{220b}"),
("subset", "\u{2282}"),
("supset", "\u{2283}"),
("subseteq", "\u{2286}"),
("supseteq", "\u{2287}"),
("cup", "\u{222a}"),
("cap", "\u{2229}"),
("vee", "\u{2228}"),
("wedge", "\u{2227}"),
("oplus", "\u{2295}"),
("otimes", "\u{2297}"),
("odot", "\u{2299}"),
("forall", "\u{2200}"),
("exists", "\u{2203}"),
("neg", "\u{00ac}"),
("sim", "\u{223c}"),
("simeq", "\u{2243}"),
("cong", "\u{2245}"),
("approx", "\u{2248}"),
("neq", "\u{2260}"),
("ne", "\u{2260}"),
("equiv", "\u{2261}"),
("le", "\u{2264}"),
("ge", "\u{2265}"),
("leq", "\u{2264}"),
("geq", "\u{2265}"),
("ll", "\u{226a}"),
("gg", "\u{226b}"),
("times", "\u{00d7}"),
("div", "\u{00f7}"),
("pm", "\u{00b1}"),
("mp", "\u{2213}"),
("cdot", "\u{00b7}"),
("ast", "\u{2217}"),
("circ", "\u{2218}"),
("bullet", "\u{2022}"),
("cdots", "\u{2026}"),
("ldots", "\u{2026}"),
("vdots", "\u{22ee}"),
("ddots", "\u{22f1}"),
] {
m.insert(k, v);
}
m
}
static SYMBOLS: OnceLock<SymbolMap> = OnceLock::new();
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_superscript() {
assert_eq!(render_latex_to_string("x^2"), "x\u{00b2}");
}
#[test]
fn test_subscript() {
assert_eq!(render_latex_to_string("x_1"), "x\u{2081}");
}
#[test]
fn test_blackboard() {
assert_eq!(render_latex_to_string(r"\mathbb{R}"), "\u{211d}");
}
#[test]
fn test_infty() {
assert_eq!(render_latex_to_string(r"\infty"), "\u{221e}");
}
#[test]
fn test_inline_dollar() {
let r = render_latex_in_text(r"text $x^2$ more");
assert_eq!(r, "text x\u{00b2} more");
}
#[test]
fn test_display_bracket() {
let r = render_latex_in_text(r"text \[x^2\] more");
assert_eq!(r, "text x\u{00b2} more");
}
#[test]
fn preserves_currency() {
assert_eq!(render_latex_in_text("cost $5 and $10"), "cost $5 and $10");
}
#[test]
fn preserves_markdown_code() {
assert_eq!(render_latex_in_text("`$x^2$` and $y^2$"), "`$x^2$` and y²");
assert_eq!(
render_latex_in_text("```sh\necho $HOME\n```"),
"```sh\necho $HOME\n```"
);
}
#[test]
fn preserves_escaped_dollars_and_unknown_commands() {
assert_eq!(
render_latex_in_text(r"cost \$5 and $\operatorname{foo}$"),
r"cost \$5 and \operatorname{foo}"
);
}
}