const LBRACE: char = '\u{1}';
const RBRACE: char = '\u{2}';
const MAX_BRACE_DEPTH: usize = 64;
pub fn normalize_delimiters(input: &str) -> String {
let chars: Vec<char> = input.chars().collect();
let n = chars.len();
let mut out = String::with_capacity(input.len());
let mut i = 0;
while i < n {
if chars[i] == '`' {
let fence = char_run(&chars, i, '`');
let body_start = i + fence;
match find_char_run(&chars, body_start, fence, '`') {
Some(close_end) => {
out.extend(&chars[i..close_end]);
i = close_end;
}
None if fence >= 3 => {
out.extend(&chars[i..]);
i = n;
}
None => {
out.extend(&chars[i..body_start]);
i = body_start;
}
}
continue;
}
if chars[i] == '~' {
let run = char_run(&chars, i, '~');
if run >= 3 {
let body_start = i + run;
let end = find_tilde_fence_close(&chars, body_start).unwrap_or(n);
out.extend(&chars[i..end]);
i = end;
continue;
}
}
if chars[i] == '\\' && i + 1 < n {
match chars[i + 1] {
'(' => {
if let Some(close) = find_pair(&chars, i + 2, '\\', ')') {
out.push('$');
out.extend(&chars[i + 2..close]);
out.push('$');
i = close + 2;
continue;
}
}
'[' => {
if let Some(close) = find_pair(&chars, i + 2, '\\', ']') {
out.push_str("$$");
out.extend(&chars[i + 2..close]);
out.push_str("$$");
i = close + 2;
continue;
}
}
_ => {}
}
}
out.push(chars[i]);
i += 1;
}
out
}
pub(super) fn char_run(chars: &[char], start: usize, ch: char) -> usize {
let mut k = start;
while k < chars.len() && chars[k] == ch {
k += 1;
}
k - start
}
pub(super) fn find_char_run(chars: &[char], from: usize, len: usize, ch: char) -> Option<usize> {
let mut j = from;
while j < chars.len() {
if chars[j] == ch {
let run = char_run(chars, j, ch);
if run == len {
return Some(j + run);
}
j += run;
} else {
j += 1;
}
}
None
}
pub(super) fn find_tilde_fence_close(chars: &[char], from: usize) -> Option<usize> {
let mut j = from;
while j < chars.len() {
if chars[j] == '~' {
let run = char_run(chars, j, '~');
if run >= 3 {
return Some(j + run);
}
j += run;
} else {
j += 1;
}
}
None
}
pub(super) fn find_pair(chars: &[char], from: usize, a: char, b: char) -> Option<usize> {
let mut i = from;
while i + 1 < chars.len() {
if chars[i] == a && chars[i + 1] == b {
return Some(i);
}
i += 1;
}
None
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum MathMode {
Inline,
Display,
}
pub fn latex_to_unicode(input: &str) -> String {
latex_to_unicode_mode(input, MathMode::Inline)
}
pub fn latex_to_unicode_display(input: &str) -> String {
latex_to_unicode_mode(input, MathMode::Display)
}
fn latex_to_unicode_mode(input: &str, mode: MathMode) -> String {
let protected = input.replace("\\{", "\u{1}").replace("\\}", "\u{2}");
let without_env = strip_environments(&protected, mode);
let with_braces = apply_brace_commands(&without_env);
let with_commands = replace_commands(&with_braces);
let with_scripts = replace_scripts(&with_commands);
let stripped: String = with_scripts
.chars()
.filter(|&c| c != '{' && c != '}')
.map(|c| match c {
LBRACE => '{',
RBRACE => '}',
other => other,
})
.collect();
let stripped = match mode {
MathMode::Inline => stripped.replace('\n', " "),
MathMode::Display => stripped,
};
stripped
.split('\n')
.map(|l| collapse_spaces(l.trim()))
.collect::<Vec<_>>()
.join("\n")
.trim()
.to_string()
}
fn collapse_spaces(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut prev_space = false;
for ch in s.chars() {
if ch == ' ' {
if !prev_space {
out.push(' ');
}
prev_space = true;
} else {
out.push(ch);
prev_space = false;
}
}
out
}
pub(super) fn strip_environments(input: &str, mode: MathMode) -> String {
let chars: Vec<char> = input.chars().collect();
let n = chars.len();
let sep = match mode {
MathMode::Display => "\n",
MathMode::Inline => "; ",
};
let mut out = String::with_capacity(input.len());
let mut i = 0;
while i < n {
let c = chars[i];
if c == '\n' || c == '\r' {
out.push(' ');
i += 1;
continue;
}
if c == '&' {
i += 1;
continue;
}
if c == '\\' {
if i + 1 < n && chars[i + 1] == '\\' {
i += 2;
if i < n
&& chars[i] == '['
&& let Some(rel) = chars[i + 1..].iter().position(|&ch| ch == ']')
{
i += 1 + rel + 1;
}
while i < n && chars[i].is_whitespace() {
i += 1;
}
while out.ends_with(char::is_whitespace) {
out.pop();
}
out.push_str(sep);
continue;
}
if i + 1 < n && chars[i + 1] == '&' {
out.push('&');
i += 2;
continue;
}
let start = i + 1;
let mut j = start;
while j < n && chars[j].is_ascii_alphabetic() {
j += 1;
}
let name: String = chars[start..j].iter().collect();
match name.as_str() {
"begin" => {
let mut k = j;
let mut env = String::new();
if k < n
&& chars[k] == '{'
&& let Some((g, after)) = read_group(&chars, k)
{
env = g;
k = after;
}
if matches!(env.trim(), "array" | "tabular" | "tabularx")
&& k < n
&& chars[k] == '{'
&& let Some((_g, after)) = read_group(&chars, k)
{
k = after;
}
i = k;
}
"end" => {
let mut k = j;
if k < n
&& chars[k] == '{'
&& let Some((_g, after)) = read_group(&chars, k)
{
k = after;
}
i = k;
}
"hline" | "midrule" | "toprule" | "bottomrule" | "notag" | "nonumber" => {
i = j;
}
"label" | "cline" | "tag" | "ref" | "eqref" => {
let mut k = j;
if k < n
&& chars[k] == '{'
&& let Some((_g, after)) = read_group(&chars, k)
{
k = after;
}
i = k;
}
"" => {
out.push('\\');
if start < n {
out.push(chars[start]);
i = start + 1;
} else {
i = start;
}
}
_ => {
out.push('\\');
out.push_str(&name);
i = j;
}
}
continue;
}
out.push(c);
i += 1;
}
out
}
pub(super) fn apply_brace_commands(input: &str) -> String {
apply_brace_commands_depth(input, 0)
}
fn brace_recurse(s: &str, depth: usize) -> String {
if depth >= MAX_BRACE_DEPTH {
s.to_string()
} else {
apply_brace_commands_depth(s, depth + 1)
}
}
fn apply_brace_commands_depth(input: &str, depth: usize) -> String {
let chars: Vec<char> = input.chars().collect();
let n = chars.len();
let mut out = String::with_capacity(input.len());
let mut i = 0;
while i < n {
if chars[i] != '\\' {
out.push(chars[i]);
i += 1;
continue;
}
let start = i + 1;
let mut j = start;
while j < n && chars[j].is_ascii_alphabetic() {
j += 1;
}
let name: String = chars[start..j].iter().collect();
if name == "sqrt" {
let (root, after_opt) = read_optional(&chars, j);
if after_opt < n
&& chars[after_opt] == '{'
&& let Some((a, after_a)) = read_group(&chars, after_opt)
{
out.push_str(&sqrt_render(root.as_deref(), &brace_recurse(&a, depth)));
i = after_a;
continue;
}
}
if !name.is_empty() && j < n && chars[j] == '{' {
if is_frac_command(&name)
&& let Some((a, after_a)) = read_group(&chars, j)
&& after_a < n
&& chars[after_a] == '{'
&& let Some((b, after_b)) = read_group(&chars, after_a)
{
out.push_str(&brace_recurse(&a, depth));
out.push('/');
out.push_str(&brace_recurse(&b, depth));
i = after_b;
continue;
}
if name == "binom"
&& let Some((a, after_a)) = read_group(&chars, j)
&& after_a < n
&& chars[after_a] == '{'
&& let Some((b, after_b)) = read_group(&chars, after_a)
{
out.push('C');
out.push('(');
out.push_str(&brace_recurse(&a, depth));
out.push_str(", ");
out.push_str(&brace_recurse(&b, depth));
out.push(')');
i = after_b;
continue;
}
if name == "pmod"
&& let Some((a, after_a)) = read_group(&chars, j)
{
out.push_str("(mod ");
out.push_str(&brace_recurse(&a, depth));
out.push(')');
i = after_a;
continue;
}
if is_stack_command(&name)
&& let Some((_a, after_a)) = read_group(&chars, j)
&& after_a < n
&& chars[after_a] == '{'
&& let Some((b, after_b)) = read_group(&chars, after_a)
{
out.push_str(&brace_recurse(&b, depth));
i = after_b;
continue;
}
if matches!(name.as_str(), "mathbb" | "mathcal" | "mathfrak")
&& let Some((a, after_a)) = read_group(&chars, j)
{
out.push_str(&blackboard_or_content(&name, &a, depth));
i = after_a;
continue;
}
if is_text_command(&name)
&& let Some((a, after_a)) = read_group(&chars, j)
{
out.push_str(&brace_recurse(&a, depth));
i = after_a;
continue;
}
out.push('\\');
out.push_str(&name);
let mut k = j;
while k < n && chars[k] == '{' {
let Some((g, after)) = read_group(&chars, k) else {
break;
};
out.push(LBRACE);
out.push_str(&brace_recurse(&g, depth));
out.push(RBRACE);
k = after;
}
i = k;
continue;
}
out.push('\\');
i += 1;
}
out
}
fn is_frac_command(name: &str) -> bool {
matches!(name, "frac" | "dfrac" | "tfrac" | "cfrac")
}
fn is_stack_command(name: &str) -> bool {
matches!(name, "overset" | "underset" | "stackrel")
}
fn read_optional(chars: &[char], from: usize) -> (Option<String>, usize) {
if from < chars.len()
&& chars[from] == '['
&& let Some(rel) = chars[from + 1..].iter().position(|&c| c == ']')
{
let inner: String = chars[from + 1..from + 1 + rel].iter().collect();
return (Some(inner), from + 1 + rel + 1);
}
(None, from)
}
fn sqrt_render(root: Option<&str>, inner: &str) -> String {
let prefix = match root {
None | Some("2") => "√".to_string(),
Some("3") => "∛".to_string(),
Some("4") => "∜".to_string(),
Some(r) => {
let rc: Vec<char> = r.chars().collect();
match map_script_chars(&rc, true) {
Some(sup) => format!("{sup}√"),
None => format!("√[{r}]"),
}
}
};
format!("{prefix}({inner})")
}
fn blackboard_or_content(name: &str, content: &str, depth: usize) -> String {
let chars: Vec<char> = content.chars().collect();
if chars.len() == 1 {
let mapped = match name {
"mathbb" => double_struck(chars[0]),
"mathcal" => script_letter(chars[0]),
"mathfrak" => fraktur_letter(chars[0]),
_ => None,
};
if let Some(g) = mapped {
return g.to_string();
}
}
brace_recurse(content, depth)
}
fn double_struck(c: char) -> Option<char> {
Some(match c {
'C' => 'ℂ',
'H' => 'ℍ',
'N' => 'ℕ',
'P' => 'ℙ',
'Q' => 'ℚ',
'R' => 'ℝ',
'Z' => 'ℤ',
_ => return None,
})
}
fn script_letter(c: char) -> Option<char> {
Some(match c {
'B' => 'ℬ',
'E' => 'ℰ',
'F' => 'ℱ',
'H' => 'ℋ',
'I' => 'ℐ',
'L' => 'ℒ',
'M' => 'ℳ',
'R' => 'ℛ',
'e' => 'ℯ',
'g' => 'ℊ',
'o' => 'ℴ',
_ => return None,
})
}
fn fraktur_letter(c: char) -> Option<char> {
Some(match c {
'C' => 'ℭ',
'H' => 'ℌ',
'I' => 'ℑ',
'R' => 'ℜ',
'Z' => 'ℨ',
_ => return None,
})
}
pub(super) fn read_group(chars: &[char], open: usize) -> Option<(String, usize)> {
let mut depth = 0usize;
let mut buf = String::new();
let mut i = open;
while i < chars.len() {
match chars[i] {
'{' => {
if depth > 0 {
buf.push('{');
}
depth += 1;
}
'}' => {
depth -= 1;
if depth == 0 {
return Some((buf, i + 1));
}
buf.push('}');
}
c => buf.push(c),
}
i += 1;
}
None
}
pub(super) fn is_text_command(name: &str) -> bool {
matches!(
name,
"text"
| "textbf"
| "textit"
| "textrm"
| "textnormal"
| "textsf"
| "textsl"
| "texttt"
| "textup"
| "mbox"
| "emph"
| "mathrm"
| "mathbf"
| "mathit"
| "mathbb"
| "mathcal"
| "mathfrak"
| "mathsf"
| "mathtt"
| "operatorname"
| "boldsymbol"
| "overline"
| "underline"
| "overbrace"
| "underbrace"
| "overrightarrow"
| "overleftarrow"
| "hat"
| "widehat"
| "bar"
| "vec"
| "tilde"
| "widetilde"
| "dot"
| "ddot"
| "mathring"
| "breve"
| "acute"
| "grave"
| "check"
)
}
pub(super) fn is_function_name(name: &str) -> bool {
matches!(
name,
"log"
| "ln"
| "lg"
| "exp"
| "sin"
| "cos"
| "tan"
| "cot"
| "sec"
| "csc"
| "sinh"
| "cosh"
| "tanh"
| "coth"
| "arcsin"
| "arccos"
| "arctan"
| "lim"
| "limsup"
| "liminf"
| "sup"
| "inf"
| "min"
| "max"
| "gcd"
| "det"
| "deg"
| "dim"
| "ker"
| "hom"
| "arg"
| "Pr"
| "mod"
)
}
pub(super) fn replace_commands(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let bytes = input.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'\\' {
let ch = input[i..].chars().next().unwrap();
out.push(ch);
i += ch.len_utf8();
continue;
}
let start = i + 1;
let mut j = start;
while j < bytes.len() && bytes[j].is_ascii_alphabetic() {
j += 1;
}
if j == start {
if start < bytes.len() {
let next = input[start..].chars().next().unwrap();
match next {
',' | ';' | ':' | ' ' => out.push(' '),
'!' => {}
other => out.push(other),
}
i = start + next.len_utf8();
} else {
out.push('\\');
i += 1;
}
continue;
}
let name = &input[start..j];
if let Some(sym) = command_symbol(name) {
out.push_str(sym);
i = j;
if (name == "left" || name == "right") && i < bytes.len() && bytes[i] == b'.' {
i += 1;
}
} else if is_function_name(name) {
out.push_str(name);
i = j;
} else {
out.push('\\');
out.push_str(name);
i = j;
}
}
out
}
pub(super) fn command_symbol(name: &str) -> Option<&'static str> {
let s = match name {
"alpha" => "α",
"beta" => "β",
"gamma" => "γ",
"delta" => "δ",
"epsilon" | "varepsilon" => "ε",
"zeta" => "ζ",
"eta" => "η",
"theta" => "θ",
"iota" => "ι",
"kappa" => "κ",
"lambda" => "λ",
"mu" => "μ",
"nu" => "ν",
"xi" => "ξ",
"pi" => "π",
"rho" => "ρ",
"sigma" => "σ",
"tau" => "τ",
"upsilon" => "υ",
"phi" | "varphi" => "φ",
"chi" => "χ",
"psi" => "ψ",
"omega" => "ω",
"Gamma" => "Γ",
"Delta" => "Δ",
"Theta" => "Θ",
"Lambda" => "Λ",
"Xi" => "Ξ",
"Pi" => "Π",
"Sigma" => "Σ",
"Phi" => "Φ",
"Psi" => "Ψ",
"Omega" => "Ω",
"rightarrow" | "to" => "→",
"leftarrow" | "gets" => "←",
"leftrightarrow" => "↔",
"longrightarrow" => "⟶",
"longleftarrow" => "⟵",
"Rightarrow" | "implies" => "⇒",
"Longrightarrow" => "⟹",
"Leftarrow" => "⇐",
"Leftrightarrow" | "iff" => "⇔",
"uparrow" => "↑",
"downarrow" => "↓",
"mapsto" => "↦",
"leq" | "le" => "≤",
"geq" | "ge" => "≥",
"neq" | "ne" => "≠",
"ll" => "≪",
"gg" => "≫",
"approx" => "≈",
"equiv" => "≡",
"sim" => "∼",
"times" => "×",
"div" => "÷",
"pm" => "±",
"mp" => "∓",
"cdot" => "·",
"ast" => "∗",
"star" => "⋆",
"bullet" => "•",
"circ" => "∘",
"infty" => "∞",
"partial" => "∂",
"nabla" => "∇",
"sum" => "∑",
"prod" => "∏",
"int" => "∫",
"oint" => "∮",
"sqrt" => "√",
"propto" => "∝",
"in" => "∈",
"notin" => "∉",
"subset" => "⊂",
"subseteq" => "⊆",
"supset" => "⊃",
"supseteq" => "⊇",
"cup" => "∪",
"cap" => "∩",
"emptyset" | "varnothing" => "∅",
"forall" => "∀",
"exists" => "∃",
"nexists" => "∄",
"neg" | "lnot" => "¬",
"land" | "wedge" => "∧",
"lor" | "vee" => "∨",
"oplus" => "⊕",
"otimes" => "⊗",
"perp" => "⊥",
"top" => "⊤",
"bot" => "⊥",
"because" => "∵",
"therefore" => "∴",
"angle" => "∠",
"triangle" => "△",
"degree" => "°",
"prime" => "′",
"hbar" => "ℏ",
"ell" => "ℓ",
"Re" => "ℜ",
"Im" => "ℑ",
"aleph" => "ℵ",
"wp" => "℘",
"ldots" | "dots" => "…",
"cdots" => "⋯",
"vdots" => "⋮",
"ddots" => "⋱",
"langle" => "⟨",
"rangle" => "⟩",
"lfloor" => "⌊",
"rfloor" => "⌋",
"lceil" => "⌈",
"rceil" => "⌉",
"mid" => "|",
"parallel" | "Vert" => "‖",
"setminus" | "smallsetminus" => "∖",
"backslash" => "\\",
"cong" => "≅",
"simeq" => "≃",
"asymp" => "≍",
"doteq" => "≐",
"vdash" => "⊢",
"dashv" => "⊣",
"models" | "vDash" => "⊨",
"triangleq" => "≜",
"coloneqq" | "coloneq" => "≔",
"subsetneq" => "⊊",
"supsetneq" => "⊋",
"nsubseteq" => "⊈",
"nsupseteq" => "⊉",
"sqsubseteq" => "⊑",
"sqsupseteq" => "⊒",
"ni" | "owns" => "∋",
"Longleftarrow" => "⟸",
"longleftrightarrow" => "⟷",
"Longleftrightarrow" => "⟺",
"hookrightarrow" => "↪",
"hookleftarrow" => "↩",
"twoheadrightarrow" => "↠",
"rightsquigarrow" | "leadsto" => "⇝",
"nearrow" => "↗",
"searrow" => "↘",
"nwarrow" => "↖",
"swarrow" => "↙",
"vartheta" => "ϑ",
"varsigma" => "ς",
"varrho" => "ϱ",
"varkappa" => "ϰ",
"varpi" => "ϖ",
"sqcap" => "⊓",
"sqcup" => "⊔",
"uplus" => "⊎",
"bigcup" => "⋃",
"bigcap" => "⋂",
"coprod" => "∐",
"iint" => "∬",
"iiint" => "∭",
"odot" => "⊙",
"ominus" => "⊖",
"oslash" => "⊘",
"dagger" => "†",
"ddagger" => "‡",
"diamond" => "⋄",
"Box" | "square" => "□",
"blacksquare" => "■",
"triangleleft" => "◁",
"triangleright" => "▷",
"checkmark" => "✓",
"quad" => " ",
"qquad" => " ",
"bmod" => "mod",
"left" | "right" | "big" | "Big" | "bigg" | "Bigg" | "bigl" | "bigr" | "Bigl" | "Bigr"
| "biggl" | "biggr" => "",
_ => return None,
};
Some(s)
}
pub(super) fn replace_scripts(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let chars: Vec<char> = input.chars().collect();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == '^' || c == '_' {
let sup = c == '^';
if let Some((mapped, consumed)) = take_script(&chars[i + 1..], sup) {
out.push_str(&mapped);
i += 1 + consumed;
continue;
}
}
out.push(c);
i += 1;
}
out
}
pub(super) fn take_script(rest: &[char], sup: bool) -> Option<(String, usize)> {
if rest.is_empty() {
return None;
}
if rest[0] == '{' {
let close = rest.iter().position(|&c| c == '}')?;
let inner = &rest[1..close];
match map_script_chars(inner, sup) {
Some(mapped) => Some((mapped, close + 1)), None => {
let marker = if sup { '^' } else { '_' };
let inner_str: String = inner.iter().collect();
Some((format!("{marker}({inner_str})"), close + 1))
}
}
} else {
let mapped = map_script_chars(&rest[0..1], sup)?;
Some((mapped, 1))
}
}
pub(super) fn map_script_chars(chars: &[char], sup: bool) -> Option<String> {
if chars.is_empty() {
return None;
}
let mut out = String::with_capacity(chars.len());
for &c in chars {
let mapped = if sup { superscript(c) } else { subscript(c) }?;
out.push(mapped);
}
Some(out)
}
pub(super) fn superscript(c: char) -> Option<char> {
Some(match c {
'0' => '⁰',
'1' => '¹',
'2' => '²',
'3' => '³',
'4' => '⁴',
'5' => '⁵',
'6' => '⁶',
'7' => '⁷',
'8' => '⁸',
'9' => '⁹',
'+' => '⁺',
'-' => '⁻',
'=' => '⁼',
'(' => '⁽',
')' => '⁾',
'a' => 'ᵃ',
'b' => 'ᵇ',
'c' => 'ᶜ',
'd' => 'ᵈ',
'e' => 'ᵉ',
'f' => 'ᶠ',
'g' => 'ᵍ',
'h' => 'ʰ',
'i' => 'ⁱ',
'j' => 'ʲ',
'k' => 'ᵏ',
'l' => 'ˡ',
'm' => 'ᵐ',
'n' => 'ⁿ',
'o' => 'ᵒ',
'p' => 'ᵖ',
'r' => 'ʳ',
's' => 'ˢ',
't' => 'ᵗ',
'u' => 'ᵘ',
'v' => 'ᵛ',
'w' => 'ʷ',
'x' => 'ˣ',
'y' => 'ʸ',
'z' => 'ᶻ',
'A' => 'ᴬ',
'B' => 'ᴮ',
'D' => 'ᴰ',
'E' => 'ᴱ',
'G' => 'ᴳ',
'H' => 'ᴴ',
'I' => 'ᴵ',
'J' => 'ᴶ',
'K' => 'ᴷ',
'L' => 'ᴸ',
'M' => 'ᴹ',
'N' => 'ᴺ',
'O' => 'ᴼ',
'P' => 'ᴾ',
'R' => 'ᴿ',
'T' => 'ᵀ',
'U' => 'ᵁ',
'V' => 'ⱽ',
'W' => 'ᵂ',
_ => return None,
})
}
pub(super) fn subscript(c: char) -> Option<char> {
Some(match c {
'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' => 'ₓ',
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::super::testkit::*;
use super::*;
#[test]
fn greek_and_arrows() {
assert_eq!(latex_to_unicode(r"\alpha + \beta"), "α + β");
assert_eq!(latex_to_unicode(r"A \rightarrow B"), "A → B");
assert_eq!(latex_to_unicode(r"x \leq y \times z"), "x ≤ y × z");
assert_eq!(latex_to_unicode(r"\Omega \neq \emptyset"), "Ω ≠ ∅");
}
#[test]
fn command_preserves_following_whitespace() {
assert_eq!(latex_to_unicode(r"\pi r^2"), "π r²");
assert_eq!(latex_to_unicode(r"\alpha+\beta"), "α+β");
}
#[test]
fn unknown_command_is_left_intact() {
assert_eq!(latex_to_unicode(r"\foobar x"), r"\foobar x");
}
#[test]
fn escaped_backslash_and_brace() {
assert_eq!(latex_to_unicode(r"a \\ b"), "a; b");
assert_eq!(latex_to_unicode(r"\{x\}"), "{x}");
}
#[test]
fn superscripts_and_subscripts() {
assert_eq!(latex_to_unicode("x^2"), "x²");
assert_eq!(latex_to_unicode("H_2O"), "H₂O");
assert_eq!(latex_to_unicode("e^{-1}"), "e⁻¹");
assert_eq!(latex_to_unicode("a_{12}"), "a₁₂");
}
#[test]
fn unmappable_script_keeps_group_as_parens() {
assert_eq!(latex_to_unicode("x^q"), "x^q");
assert_eq!(latex_to_unicode("x^{q+}"), "x^(q+)");
}
#[test]
fn letter_scripts_map_to_unicode() {
assert_eq!(latex_to_unicode("x_i"), "xᵢ");
assert_eq!(latex_to_unicode("a_n"), "aₙ");
assert_eq!(latex_to_unicode("x^T"), "xᵀ");
assert_eq!(latex_to_unicode("x^{ab}"), "xᵃᵇ");
assert_eq!(latex_to_unicode(r"\sum_{i=1}^{n}"), "∑ᵢ₌₁ⁿ");
}
#[test]
fn mathbb_double_struck() {
assert_eq!(latex_to_unicode(r"x \in \mathbb{R}"), "x ∈ ℝ");
assert_eq!(latex_to_unicode(r"\mathbb{Z}"), "ℤ");
assert_eq!(latex_to_unicode(r"\mathcal{L}"), "ℒ");
assert_eq!(latex_to_unicode(r"\mathfrak{g}"), "g"); assert_eq!(latex_to_unicode(r"\mathbb{XY}"), "XY"); }
#[test]
fn plain_text_unchanged() {
assert_eq!(
latex_to_unicode("обычный текст 2 + 2"),
"обычный текст 2 + 2"
);
}
#[test]
fn frac_sqrt_and_text_wrappers() {
assert_eq!(latex_to_unicode(r"\frac{a}{b}"), "a/b");
assert_eq!(latex_to_unicode(r"\frac{\alpha}{2}"), "α/2");
assert_eq!(latex_to_unicode(r"\sqrt{x+1}"), "√(x+1)");
assert_eq!(latex_to_unicode(r"\text{скорость} = v"), "скорость = v");
assert_eq!(latex_to_unicode(r"\mathbb{R}"), "ℝ");
}
#[test]
fn spacing_commands_become_space() {
assert_eq!(latex_to_unicode(r"a\,b"), "a b");
assert_eq!(latex_to_unicode(r"a\;b"), "a b");
assert_eq!(latex_to_unicode(r"a\!b"), "ab");
}
#[test]
fn extended_symbols() {
assert_eq!(latex_to_unicode(r"a \therefore b"), "a ∴ b");
assert_eq!(latex_to_unicode(r"x \longrightarrow y"), "x ⟶ y");
assert_eq!(latex_to_unicode(r"p \ll q"), "p ≪ q");
}
#[test]
fn function_names_render_as_words() {
assert_eq!(latex_to_unicode(r"O(n \log n)"), "O(n log n)");
assert_eq!(latex_to_unicode(r"\sin x + \cos x"), "sin x + cos x");
assert_eq!(latex_to_unicode(r"\lim f"), "lim f");
assert_eq!(latex_to_unicode(r"\ln(x) \exp(y)"), "ln(x) exp(y)");
}
#[test]
fn bracket_size_modifiers_stripped() {
assert_eq!(latex_to_unicode(r"\left( x \right)"), "( x )");
assert_eq!(latex_to_unicode(r"\bigl[ a \bigr]"), "[ a ]");
}
#[test]
fn accents_show_content() {
assert_eq!(latex_to_unicode(r"\vec{v}"), "v");
assert_eq!(latex_to_unicode(r"\overline{AB}"), "AB");
assert_eq!(latex_to_unicode(r"\hat{x} + \bar{y}"), "x + y");
}
#[test]
fn pmod_and_bmod() {
assert_eq!(latex_to_unicode(r"a \bmod n"), "a mod n");
assert_eq!(latex_to_unicode(r"x \pmod{7}"), "x (mod 7)");
}
#[test]
fn normalize_paren_and_bracket_delimiters() {
assert_eq!(normalize_delimiters(r"итог \(x^2\) тут"), "итог $x^2$ тут");
assert_eq!(normalize_delimiters(r"\[a+b\]"), "$$a+b$$");
}
#[test]
fn normalize_skips_code_spans() {
assert_eq!(normalize_delimiters(r"`\(x\)`"), r"`\(x\)`");
assert_eq!(
normalize_delimiters("```\n\\(x\\)\n```"),
"```\n\\(x\\)\n```"
);
}
#[test]
fn render_converts_math_and_strips_dollars() {
let collected = rendered_text(r"Формула: $x^2 + \alpha$");
assert!(collected.contains("x²"));
assert!(collected.contains('α'));
assert!(!collected.contains('$'));
}
#[test]
fn render_leaves_bare_commands_outside_math() {
let collected = rendered_text(r"стрелка \rightarrow без формулы");
assert!(collected.contains(r"\rightarrow"));
}
#[test]
fn render_paren_delimiters_become_math() {
let collected = rendered_text(r"путь \(\alpha \to \beta\) готов");
assert!(collected.contains("α → β"));
assert!(!collected.contains('$'));
}
#[test]
fn unknown_brace_command_keeps_braces() {
assert_eq!(latex_to_unicode(r"\boxed{x+1}"), r"\boxed{x+1}");
assert_eq!(latex_to_unicode(r"\boxed{\alpha}"), r"\boxed{α}");
assert_eq!(latex_to_unicode(r"\op{a}{b}"), r"\op{a}{b}");
}
#[test]
fn frac_family_aliases() {
assert_eq!(latex_to_unicode(r"\dfrac{a}{b}"), "a/b");
assert_eq!(latex_to_unicode(r"\tfrac{1}{2}"), "1/2");
assert_eq!(latex_to_unicode(r"\cfrac{x}{y}"), "x/y");
}
#[test]
fn binom_coefficient() {
assert_eq!(latex_to_unicode(r"\binom{n}{k}"), "C(n, k)");
assert_eq!(latex_to_unicode(r"\binom{\alpha}{2}"), "C(α, 2)");
}
#[test]
fn sqrt_with_index() {
assert_eq!(latex_to_unicode(r"\sqrt[3]{x}"), "∛(x)");
assert_eq!(latex_to_unicode(r"\sqrt[4]{y}"), "∜(y)");
assert_eq!(latex_to_unicode(r"\sqrt[n]{x}"), "ⁿ√(x)");
assert_eq!(latex_to_unicode(r"\sqrt[q]{x}"), "√[q](x)");
assert_eq!(latex_to_unicode(r"\sqrt{x}"), "√(x)");
}
#[test]
fn left_right_dot_delimiter_eaten() {
assert_eq!(latex_to_unicode(r"\left. x \right."), "x");
assert_eq!(latex_to_unicode(r"\left( x \right)"), "( x )");
}
#[test]
fn overset_underset_take_base() {
assert_eq!(latex_to_unicode(r"\overset{def}{=}"), "=");
assert_eq!(latex_to_unicode(r"\underset{n}{\min}"), "min");
assert_eq!(latex_to_unicode(r"\stackrel{?}{=}"), "=");
}
#[test]
fn more_text_wrappers() {
assert_eq!(latex_to_unicode(r"\texttt{code}"), "code");
assert_eq!(latex_to_unicode(r"\emph{важно}"), "важно");
assert_eq!(latex_to_unicode(r"\underbrace{a+b}"), "a+b");
}
#[test]
fn extended_symbol_table() {
assert_eq!(latex_to_unicode(r"\langle x \rangle"), "⟨ x ⟩");
assert_eq!(latex_to_unicode(r"\lfloor x \rfloor"), "⌊ x ⌋");
assert_eq!(latex_to_unicode(r"a \cong b"), "a ≅ b");
assert_eq!(latex_to_unicode(r"\Gamma \vdash x"), "Γ ⊢ x");
assert_eq!(latex_to_unicode(r"A \setminus B"), "A ∖ B");
assert_eq!(latex_to_unicode(r"x \hookrightarrow y"), "x ↪ y");
assert_eq!(latex_to_unicode(r"\vartheta + \varpi"), "ϑ + ϖ");
assert_eq!(latex_to_unicode(r"\bigcup_i A"), "⋃ᵢ A");
}
#[test]
fn backslash_command_is_literal() {
assert_eq!(latex_to_unicode(r"a \backslash b"), r"a \ b");
}
#[test]
fn normalize_skips_tilde_fence() {
assert_eq!(
normalize_delimiters("~~~\n\\(x\\)\n~~~"),
"~~~\n\\(x\\)\n~~~"
);
assert_eq!(normalize_delimiters(r"~~s~~ \(y\)"), r"~~s~~ $y$");
}
#[test]
fn normalize_unclosed_short_backtick_continues() {
assert_eq!(normalize_delimiters(r"a ` b \(x\)"), r"a ` b $x$");
assert_eq!(normalize_delimiters("```\n\\(x\\)"), "```\n\\(x\\)");
}
#[test]
fn deep_nesting_does_not_overflow_stack() {
let deep = "\\sqrt{".repeat(5000) + "x" + &"}".repeat(5000);
let _ = latex_to_unicode(&deep);
}
#[test]
fn display_environment_lays_out_rows() {
let r = latex_to_unicode_display(r"\begin{aligned} x &= y \\ z &= w \end{aligned}");
assert_eq!(r, "x = y\nz = w");
}
#[test]
fn display_cases_and_matrix() {
assert_eq!(
latex_to_unicode_display(r"\begin{cases} a & x>0 \\ b & x<0 \end{cases}"),
"a x>0\nb x<0"
);
assert_eq!(
latex_to_unicode_display(r"\begin{pmatrix} 1 & 2 \\ 3 & 4 \end{pmatrix}"),
"1 2\n3 4"
);
}
#[test]
fn inline_double_backslash_is_semicolon() {
assert_eq!(latex_to_unicode(r"a \\ b"), "a; b");
assert_eq!(
latex_to_unicode(r"\begin{aligned} x &= 1 \\ y &= 2 \end{aligned}"),
"x = 1; y = 2"
);
}
#[test]
fn environment_helpers_stripped() {
assert_eq!(
latex_to_unicode_display(r"a = b \label{eq:1} \\[4pt] c = d"),
"a = b\nc = d"
);
assert_eq!(latex_to_unicode_display(r"x \hline y"), "x y");
assert_eq!(latex_to_unicode(r"P \& Q"), "P & Q");
}
#[test]
fn display_math_multiline_source_without_break() {
assert_eq!(latex_to_unicode_display("x = y +\nz"), "x = y + z");
}
}