use crate::extractors::security::{SecurityBudget, SecurityError};
use roxmltree::Node;
const ANNOTATION_ELEMENTS: &[&str] = &["annotation", "annotation-xml"];
const TEX_ANNOTATION_ENCODINGS: &[&str] = &["application/x-tex", "text/x-tex", "tex", "latex"];
const TRANSPARENT_ELEMENTS: &[&str] = &["math", "mrow", "mstyle", "mpadded", "merror"];
#[cfg_attr(alef, alef(skip))]
#[derive(Debug, Clone)]
enum MmlNode {
Verbatim(String),
Run(String),
Text(String),
Space,
Frac { num: Box<MmlNode>, den: Box<MmlNode> },
Sup { base: Box<MmlNode>, sup: Box<MmlNode> },
Sub { base: Box<MmlNode>, sub: Box<MmlNode> },
SubSup {
base: Box<MmlNode>,
sub: Box<MmlNode>,
sup: Box<MmlNode>,
},
Sqrt { body: Box<MmlNode> },
Root { body: Box<MmlNode>, index: Box<MmlNode> },
Fenced {
open: String,
close: String,
sep: String,
elements: Vec<MmlNode>,
},
Under { base: Box<MmlNode>, under: Box<MmlNode> },
Over { base: Box<MmlNode>, over: Box<MmlNode> },
UnderOver {
base: Box<MmlNode>,
under: Box<MmlNode>,
over: Box<MmlNode>,
},
Phantom { body: Box<MmlNode> },
Table { rows: Vec<Vec<MmlNode>> },
Group { children: Vec<MmlNode> },
}
pub(crate) fn convert_mathml_str_to_latex(xml: &str, budget: &mut SecurityBudget) -> Result<String, SecurityError> {
let xml = crate::utils::xml_utils::strip_doctype(xml);
let Ok(doc) = roxmltree::Document::parse(&xml) else {
return Ok(String::new());
};
let root = doc.root_element();
let math_node = if root.tag_name().name().eq_ignore_ascii_case("math") {
root
} else {
match root
.descendants()
.find(|n| n.is_element() && n.tag_name().name().eq_ignore_ascii_case("math"))
{
Some(node) => node,
None => root,
}
};
convert_mathml_node_to_latex(math_node, budget)
}
pub(crate) fn convert_mathml_node_to_latex(node: Node, budget: &mut SecurityBudget) -> Result<String, SecurityError> {
let collected = collect_node(node, budget)?;
let mut out = String::new();
render_node(&collected, &mut out);
Ok(out)
}
fn collect_children(parent: Node, budget: &mut SecurityBudget) -> Result<Vec<MmlNode>, SecurityError> {
let mut nodes = Vec::new();
for child in parent.children() {
budget.step()?;
if child.is_element() {
nodes.push(collect_node(child, budget)?);
} else if child.is_text() {
let text = child.text().unwrap_or("");
if !text.trim().is_empty() {
budget.check_entity(text)?;
budget.account_text(text.len())?;
nodes.push(MmlNode::Run(text.to_string()));
}
}
}
Ok(nodes)
}
fn collect_nth_child(parent: Node, index: usize, budget: &mut SecurityBudget) -> Result<MmlNode, SecurityError> {
match parent.children().filter(|c| c.is_element()).nth(index) {
Some(child) => collect_node(child, budget),
None => Ok(MmlNode::Group { children: Vec::new() }),
}
}
fn collect_node(node: Node, budget: &mut SecurityBudget) -> Result<MmlNode, SecurityError> {
budget.step()?;
if let Err(error) = budget.enter() {
budget.leave();
return Err(error);
}
let result = collect_node_inner(node, budget);
budget.leave();
result
}
fn tex_annotation(node: Node, budget: &mut SecurityBudget) -> Result<Option<String>, SecurityError> {
for child in node.children().filter(|c| c.is_element()) {
if !child.tag_name().name().eq_ignore_ascii_case("annotation") {
continue;
}
let Some(encoding) = child.attribute("encoding") else {
continue;
};
if !TEX_ANNOTATION_ENCODINGS
.iter()
.any(|known| known.eq_ignore_ascii_case(encoding.trim()))
{
continue;
}
let text = collect_text(child, budget)?;
let latex = strip_style_wrapper(crate::extraction::derive::strip_math_delimiters(text.trim()));
if !latex.is_empty() {
return Ok(Some(latex.to_string()));
}
}
Ok(None)
}
pub(crate) fn strip_style_wrapper(latex: &str) -> &str {
for prefix in ["{\\displaystyle", "{\\textstyle", "{\\scriptstyle"] {
let Some(rest) = latex.strip_prefix(prefix) else {
continue;
};
let Some(inner) = rest.strip_suffix('}') else {
continue;
};
let mut depth = 1i32;
for ch in inner.chars() {
match ch {
'{' => depth += 1,
'}' => depth -= 1,
_ => {}
}
if depth == 0 {
return latex;
}
}
return inner.trim();
}
latex
}
fn content_annotation(node: Node, budget: &mut SecurityBudget) -> Result<Option<String>, SecurityError> {
for child in node.children().filter(|c| c.is_element()) {
if !child.tag_name().name().eq_ignore_ascii_case("annotation-xml") {
continue;
}
let is_content = child
.attribute("encoding")
.is_some_and(|e| e.trim().eq_ignore_ascii_case("MathML-Content"));
if !is_content {
continue;
}
for inner in child.children().filter(|c| c.is_element()) {
let latex = convert_content_node(inner, budget)?;
if !latex.trim().is_empty() {
return Ok(Some(latex.trim().to_string()));
}
}
}
Ok(None)
}
const INFIX_OPERATORS: &[(&str, &str)] = &[
("plus", "+"),
("minus", "-"),
("times", " \\times "),
("divide", " \\div "),
("eq", "="),
("neq", " \\ne "),
("lt", " < "),
("gt", " > "),
("leq", " \\le "),
("geq", " \\ge "),
("equivalent", " \\equiv "),
("approx", " \\approx "),
("and", " \\land "),
("or", " \\lor "),
("implies", " \\implies "),
("in", " \\in "),
("notin", " \\notin "),
("subset", " \\subset "),
("prsubset", " \\subsetneq "),
("union", " \\cup "),
("intersect", " \\cap "),
("setdiff", " \\setminus "),
("cartesianproduct", " \\times "),
("compose", " \\circ "),
];
const FUNCTION_OPERATORS: &[(&str, &str)] = &[
("sin", "\\sin"),
("cos", "\\cos"),
("tan", "\\tan"),
("sec", "\\sec"),
("csc", "\\csc"),
("cot", "\\cot"),
("arcsin", "\\arcsin"),
("arccos", "\\arccos"),
("arctan", "\\arctan"),
("sinh", "\\sinh"),
("cosh", "\\cosh"),
("tanh", "\\tanh"),
("exp", "\\exp"),
("ln", "\\ln"),
("log", "\\log"),
("det", "\\det"),
("gcd", "\\gcd"),
("max", "\\max"),
("min", "\\min"),
];
fn convert_apply(node: Node, budget: &mut SecurityBudget) -> Result<String, SecurityError> {
budget.step()?;
let mut children = node.children().filter(|c| c.is_element());
let Some(operator) = children.next() else {
return Ok(String::new());
};
let name = operator.tag_name().name().to_ascii_lowercase();
let mut operands: Vec<Node> = Vec::new();
let (mut bvar, mut lower, mut upper, mut degree) = (None, None, None, None);
for child in children {
match child.tag_name().name().to_ascii_lowercase().as_str() {
"bvar" => bvar = Some(child),
"lowlimit" | "condition" => lower = Some(child),
"uplimit" => upper = Some(child),
"degree" => degree = Some(child),
_ => operands.push(child),
}
}
let rendered: Vec<String> = operands
.iter()
.map(|c| convert_content_node(*c, budget))
.collect::<Result<_, _>>()?;
if let Some((_, latex)) = INFIX_OPERATORS.iter().find(|(op, _)| *op == name) {
if name == "minus" && rendered.len() == 1 {
return Ok(format!("-{}", rendered[0]));
}
return Ok(rendered.join(latex));
}
if let Some((_, latex)) = FUNCTION_OPERATORS.iter().find(|(op, _)| *op == name) {
return Ok(format!("{latex}\\left({}\\right)", rendered.join(", ")));
}
match name.as_str() {
"power" if rendered.len() == 2 => Ok(format!("{}^{{{}}}", rendered[0], rendered[1])),
"root" => {
let index = apply_qualifier(degree, budget)?;
let radicand = rendered.first().cloned().unwrap_or_default();
if index.is_empty() || index == "2" {
Ok(format!("\\sqrt{{{radicand}}}"))
} else {
Ok(format!("\\sqrt[{index}]{{{radicand}}}"))
}
}
"abs" => Ok(format!("\\left|{}\\right|", rendered.join(", "))),
"floor" => Ok(format!("\\lfloor {}\\rfloor", rendered.join(", "))),
"ceiling" => Ok(format!("\\lceil {}\\rceil", rendered.join(", "))),
"factorial" => Ok(format!("{}!", rendered.join(""))),
"sum" | "product" | "int" => convert_apply_bigop(&name, &rendered, bvar, lower, upper, budget),
"diff" => {
let var = apply_qualifier(bvar, budget)?;
let body = rendered.join("");
if var.is_empty() {
Ok(format!("\\frac{{d}}{{dx}}{body}"))
} else {
Ok(format!("\\frac{{d}}{{d{var}}}{body}"))
}
}
_ => Ok(format!(
"\\operatorname{{{}}}\\left({}\\right)",
name,
rendered.join(", ")
)),
}
}
fn apply_qualifier(qualifier: Option<Node>, budget: &mut SecurityBudget) -> Result<String, SecurityError> {
match qualifier {
Some(n) => {
let parts: Vec<String> = n
.children()
.filter(|c| c.is_element())
.map(|c| convert_content_node(c, budget))
.collect::<Result<_, _>>()?;
Ok(parts.join(""))
}
None => Ok(String::new()),
}
}
fn convert_apply_bigop(
name: &str,
rendered: &[String],
bvar: Option<Node>,
lower: Option<Node>,
upper: Option<Node>,
budget: &mut SecurityBudget,
) -> Result<String, SecurityError> {
let command = match name {
"sum" => "\\sum",
"product" => "\\prod",
_ => "\\int",
};
let var = apply_qualifier(bvar, budget)?;
let from = apply_qualifier(lower, budget)?;
let to = apply_qualifier(upper, budget)?;
let mut out = String::from(command);
if !from.is_empty() {
let start = if var.is_empty() { from } else { format!("{var}={from}") };
out.push_str(&format!("_{{{start}}}"));
} else if !var.is_empty() {
out.push_str(&format!("_{{{var}}}"));
}
if !to.is_empty() {
out.push_str(&format!("^{{{to}}}"));
}
out.push(' ');
out.push_str(&rendered.join(""));
if name == "int" && !var.is_empty() {
out.push_str(&format!("\\,d{var}"));
}
Ok(out)
}
fn convert_content_node(node: Node, budget: &mut SecurityBudget) -> Result<String, SecurityError> {
budget.step()?;
match node.tag_name().name().to_ascii_lowercase().as_str() {
"apply" => convert_apply(node, budget),
"ci" | "cn" | "csymbol" => {
let text = collect_text(node, budget)?;
let mut out = String::new();
crate::extraction::math_symbols::render_run_text(text.trim(), &mut out);
Ok(out)
}
"matrix" | "vector" => {
let rows: Vec<String> = node
.children()
.filter(|c| c.is_element())
.map(|row| {
let cells: Vec<String> = row
.children()
.filter(|c| c.is_element())
.map(|c| convert_content_node(c, budget))
.collect::<Result<_, _>>()?;
Ok(if cells.is_empty() {
convert_content_node(row, budget)?
} else {
cells.join(" & ")
})
})
.collect::<Result<_, SecurityError>>()?;
Ok(format!("\\begin{{pmatrix}}{}\\end{{pmatrix}}", rows.join(" \\\\ ")))
}
"piecewise" => {
let mut rows: Vec<String> = Vec::new();
for piece in node.children().filter(|c| c.is_element()) {
let parts: Vec<String> = piece
.children()
.filter(|c| c.is_element())
.map(|c| convert_content_node(c, budget))
.collect::<Result<_, _>>()?;
rows.push(match piece.tag_name().name().to_ascii_lowercase().as_str() {
"otherwise" => format!("{} & \\text{{otherwise}}", parts.join("")),
_ => parts.join(" & \\text{if }"),
});
}
Ok(format!("\\begin{{cases}}{}\\end{{cases}}", rows.join(" \\\\ ")))
}
"list" | "set" => {
let items: Vec<String> = node
.children()
.filter(|c| c.is_element())
.map(|c| convert_content_node(c, budget))
.collect::<Result<_, _>>()?;
let inner = items.join(", ");
Ok(if node.tag_name().name().eq_ignore_ascii_case("set") {
format!("\\{{{inner}\\}}")
} else {
format!("\\left({inner}\\right)")
})
}
"" => Ok(String::new()),
other => match other {
"pi" => Ok("\\pi".to_string()),
"exponentiale" => Ok("e".to_string()),
"imaginaryi" => Ok("i".to_string()),
"infinity" => Ok("\\infty".to_string()),
"true" => Ok("\\text{true}".to_string()),
"false" => Ok("\\text{false}".to_string()),
"emptyset" => Ok("\\emptyset".to_string()),
_ => {
let text = collect_text(node, budget)?;
Ok(text.trim().to_string())
}
},
}
}
fn collect_node_inner(node: Node, budget: &mut SecurityBudget) -> Result<MmlNode, SecurityError> {
let tag = node.tag_name().name();
if ANNOTATION_ELEMENTS.iter().any(|&s| s.eq_ignore_ascii_case(tag)) {
return Ok(MmlNode::Group { children: Vec::new() });
}
let tag_lower = tag.to_ascii_lowercase();
match tag_lower.as_str() {
"mi" | "mn" | "ms" | "mo" => Ok(MmlNode::Run(collect_text(node, budget)?)),
"mtext" => Ok(MmlNode::Text(collect_text(node, budget)?)),
"mspace" => Ok(MmlNode::Space),
"apply" | "piecewise" | "matrix" | "vector" | "set" | "list" | "ci" | "cn" | "csymbol" => {
Ok(MmlNode::Verbatim(convert_content_node(node, budget)?))
}
"semantics" => collect_semantics_node(node, budget),
t if TRANSPARENT_ELEMENTS.contains(&t) => Ok(MmlNode::Group {
children: collect_children(node, budget)?,
}),
"mfrac" | "msup" | "msub" | "msubsup" | "msqrt" | "mroot" | "mfenced" | "munder" | "mover" | "munderover"
| "mphantom" | "mtable" => collect_scripted_node(&tag_lower, node, budget),
_ => Ok(MmlNode::Group {
children: collect_children(node, budget)?,
}),
}
}
fn collect_semantics_node(node: Node, budget: &mut SecurityBudget) -> Result<MmlNode, SecurityError> {
if let Some(tex) = tex_annotation(node, budget)? {
return Ok(MmlNode::Verbatim(tex));
}
let children = node
.children()
.filter(|c| {
c.is_element()
&& !ANNOTATION_ELEMENTS
.iter()
.any(|&s| s.eq_ignore_ascii_case(c.tag_name().name()))
})
.map(|c| collect_node(c, budget))
.collect::<Result<Vec<_>, _>>()?;
if render_nodes(&children).trim().is_empty()
&& let Some(latex) = content_annotation(node, budget)?
{
return Ok(MmlNode::Verbatim(latex));
}
Ok(MmlNode::Group { children })
}
fn collect_scripted_node(tag: &str, node: Node, budget: &mut SecurityBudget) -> Result<MmlNode, SecurityError> {
match tag {
"mfrac" => Ok(MmlNode::Frac {
num: Box::new(collect_nth_child(node, 0, budget)?),
den: Box::new(collect_nth_child(node, 1, budget)?),
}),
"msup" => Ok(MmlNode::Sup {
base: Box::new(collect_nth_child(node, 0, budget)?),
sup: Box::new(collect_nth_child(node, 1, budget)?),
}),
"msub" => Ok(MmlNode::Sub {
base: Box::new(collect_nth_child(node, 0, budget)?),
sub: Box::new(collect_nth_child(node, 1, budget)?),
}),
"msubsup" => Ok(MmlNode::SubSup {
base: Box::new(collect_nth_child(node, 0, budget)?),
sub: Box::new(collect_nth_child(node, 1, budget)?),
sup: Box::new(collect_nth_child(node, 2, budget)?),
}),
"msqrt" => Ok(MmlNode::Sqrt {
body: Box::new(MmlNode::Group {
children: collect_children(node, budget)?,
}),
}),
"mroot" => Ok(MmlNode::Root {
body: Box::new(collect_nth_child(node, 0, budget)?),
index: Box::new(collect_nth_child(node, 1, budget)?),
}),
"mfenced" => collect_fenced(node, budget),
"munder" => Ok(MmlNode::Under {
base: Box::new(collect_nth_child(node, 0, budget)?),
under: Box::new(collect_nth_child(node, 1, budget)?),
}),
"mover" => Ok(MmlNode::Over {
base: Box::new(collect_nth_child(node, 0, budget)?),
over: Box::new(collect_nth_child(node, 1, budget)?),
}),
"munderover" => Ok(MmlNode::UnderOver {
base: Box::new(collect_nth_child(node, 0, budget)?),
under: Box::new(collect_nth_child(node, 1, budget)?),
over: Box::new(collect_nth_child(node, 2, budget)?),
}),
"mphantom" => Ok(MmlNode::Phantom {
body: Box::new(MmlNode::Group {
children: collect_children(node, budget)?,
}),
}),
"mtable" => collect_table(node, budget),
_ => Ok(MmlNode::Group {
children: collect_children(node, budget)?,
}),
}
}
fn collect_text(node: Node, budget: &mut SecurityBudget) -> Result<String, SecurityError> {
let mut text = String::new();
for child in node.children().filter(|c| c.is_text()) {
if let Some(t) = child.text() {
budget.check_entity(t)?;
budget.account_text(t.len())?;
text.extend(t.chars().filter(|c| !is_private_use(*c)));
}
}
Ok(text)
}
fn is_private_use(c: char) -> bool {
matches!(c, '\u{E000}'..='\u{F8FF}' | '\u{F0000}'..='\u{FFFFD}' | '\u{100000}'..='\u{10FFFD}')
}
fn collect_fenced(node: Node, budget: &mut SecurityBudget) -> Result<MmlNode, SecurityError> {
let strip = |value: &str| -> String { value.chars().filter(|c| !is_private_use(*c)).collect() };
let open = node.attribute("open").map(strip).unwrap_or_else(|| "(".to_string());
let close = node.attribute("close").map(strip).unwrap_or_else(|| ")".to_string());
let sep = node
.attribute("separators")
.map(strip)
.and_then(|s| s.chars().next())
.unwrap_or(',')
.to_string();
let elements = node
.children()
.filter(|c| c.is_element())
.map(|c| collect_node(c, budget))
.collect::<Result<Vec<_>, _>>()?;
Ok(MmlNode::Fenced {
open,
close,
sep,
elements,
})
}
fn collect_table(node: Node, budget: &mut SecurityBudget) -> Result<MmlNode, SecurityError> {
let mut rows = Vec::new();
for row in node
.children()
.filter(|c| c.is_element() && c.tag_name().name().eq_ignore_ascii_case("mtr"))
{
budget.step()?;
let cells = row
.children()
.filter(|c| c.is_element() && c.tag_name().name().eq_ignore_ascii_case("mtd"))
.map(|c| {
Ok(MmlNode::Group {
children: collect_children(c, budget)?,
})
})
.collect::<Result<Vec<_>, SecurityError>>()?;
rows.push(cells);
}
Ok(MmlNode::Table { rows })
}
mod render;
use render::{render_node, render_nodes};
#[cfg(test)]
mod tests;