Skip to main content

document_svg/document/
mathml.rs

1//! Bounded MathML 3 presentation previews.
2//!
3//! This adapter converts common MathML presentation trees into deterministic
4//! plain-text formula rows. It intentionally ignores annotations, embedded XML,
5//! image glyphs and all active or external content.
6
7use std::collections::BTreeMap;
8use std::path::Path;
9
10use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
11use crate::document::html::{HtmlBlock, render_blocks_to_pages};
12use crate::error::{Error, Result};
13use crate::geospatial::xml_tree::{XmlElement, XmlLimits, parse_xml_tree};
14use crate::table::{TableAlign, TableData};
15
16const MAX_MATHML_BYTES: u64 = 32 * 1024 * 1024;
17const MAX_MATHML_XML_EVENTS: usize = 500_000;
18const MAX_MATHML_XML_NODES: usize = 300_000;
19const MAX_MATHML_XML_DEPTH: usize = 96;
20const MAX_MATHML_TEXT_BYTES: usize = 24 * 1024 * 1024;
21const MAX_MATHML_ROWS: usize = 20_000;
22const MAX_MATHML_DISPLAY_BYTES: usize = 2_048;
23
24const MATHML_NAMESPACE: &str = "http://www.w3.org/1998/Math/MathML";
25
26pub(crate) fn looks_like_prefix(bytes: &[u8]) -> bool {
27    crate::geospatial::xml_tree::looks_like_root(bytes, b"math", None)
28        && String::from_utf8_lossy(bytes)
29            .to_ascii_lowercase()
30            .contains("www.w3.org/1998/math/mathml")
31}
32
33struct MathmlPageSink<'a> {
34    inner: &'a mut dyn PageConsumer,
35    warnings: &'a [String],
36}
37impl PageConsumer for MathmlPageSink<'_> {
38    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
39        page.source_format = "mathml".into();
40        if page.title.is_empty() {
41            page.title = "MathML formula".into();
42        }
43        page.description = "MathML presentation markup is rendered as a bounded inert formula summary; annotations and external resources are not evaluated".into();
44        for warning in self.warnings {
45            page.warn(warning.clone());
46        }
47        self.inner.consume(page)
48    }
49}
50
51#[derive(Default)]
52struct Summary {
53    formula: String,
54    tokens: usize,
55    structures: BTreeMap<String, usize>,
56    annotations: usize,
57    rows: Vec<Vec<String>>,
58}
59
60pub(crate) fn convert(
61    path: &Path,
62    options: &ConvertOptions,
63    sink: &mut dyn PageConsumer,
64) -> Result<Vec<String>> {
65    let bytes = read_limited_file(
66        path,
67        options.max_input_bytes.min(MAX_MATHML_BYTES),
68        "MathML input",
69    )?;
70    let root = parse_xml_tree(
71        &bytes,
72        &XmlLimits {
73            max_events: options.max_xml_events.min(MAX_MATHML_XML_EVENTS),
74            max_nodes: MAX_MATHML_XML_NODES,
75            max_depth: MAX_MATHML_XML_DEPTH,
76            max_text_bytes: MAX_MATHML_TEXT_BYTES,
77        },
78        "MathML",
79    )?;
80    if !root.name.eq_ignore_ascii_case("math") {
81        return Err(Error::InvalidInput("MathML XML root must math".into()));
82    }
83    if root
84        .namespace
85        .as_deref()
86        .is_none_or(|namespace| namespace != MATHML_NAMESPACE)
87    {
88        return Err(Error::InvalidInput(
89            "MathML namespace is missing or unsupported".into(),
90        ));
91    }
92    let mut summary = Summary::default();
93    walk_stats(&root, &mut summary);
94    summary.formula = render_expr(&root);
95    if summary.formula.is_empty() || summary.tokens == 0 {
96        return Err(Error::InvalidInput(
97            "MathML contains no presentation tokens".into(),
98        ));
99    }
100    push_row(
101        &mut summary.rows,
102        "Formula",
103        &summary.formula,
104        "presentation tree",
105    )?;
106    for (kind, count) in &summary.structures {
107        push_row(&mut summary.rows, kind, &count.to_string(), "element count")?;
108    }
109    let metadata = format!(
110        "Tokens: {}\nAnnotations skipped: {}\nStructures: {}\nFormula: {}",
111        summary.tokens,
112        summary.annotations,
113        summary.structures.values().sum::<usize>(),
114        display_or_dash(&summary.formula)
115    );
116    let blocks = vec![
117        HtmlBlock::Heading {
118            level: 1,
119            text: "MathML formula".into(),
120        },
121        HtmlBlock::Paragraph { text: metadata },
122        HtmlBlock::Table(TableData {
123            headers: vec!["Kind".into(), "Value".into(), "Detail".into()],
124            rows: summary.rows,
125            alignments: vec![TableAlign::Left; 3],
126            raw_source: String::new(),
127        }),
128    ];
129    let warnings = vec![
130        "MathML presentation tokens and common fraction/script/root/fence/table structures are shown; annotation, annotation-xml, mglyph/image, URL, semantic metadata and arbitrary extension payloads are omitted".into(),
131        "MathML XML traversal and rows are bounded; DTD/entities, scripts, URL dereferencing, external styles/resources, content evaluation and symbolic algebra never run".into(),
132    ];
133    let mut page_sink = MathmlPageSink {
134        inner: sink,
135        warnings: &warnings,
136    };
137    render_blocks_to_pages(&blocks, &mut page_sink, options)?;
138    Ok(warnings)
139}
140
141fn walk_stats(element: &XmlElement, summary: &mut Summary) {
142    if matches!(
143        element.name.as_str(),
144        "mi" | "mn" | "mo" | "mtext" | "ms" | "mspace"
145    ) {
146        summary.tokens = summary.tokens.saturating_add(1);
147    }
148    if matches!(element.name.as_str(), "annotation" | "annotation-xml") {
149        summary.annotations = summary.annotations.saturating_add(1);
150    }
151    if is_structure(&element.name) {
152        *summary.structures.entry(element.name.clone()).or_default() += 1;
153    }
154    for child in &element.children {
155        walk_stats(child, summary);
156    }
157}
158
159fn is_structure(name: &str) -> bool {
160    matches!(
161        name,
162        "mfrac"
163            | "msqrt"
164            | "mroot"
165            | "msub"
166            | "msup"
167            | "msubsup"
168            | "munder"
169            | "mover"
170            | "munderover"
171            | "mfenced"
172            | "mtable"
173            | "mtr"
174            | "mtd"
175            | "mrow"
176    )
177}
178
179fn render_expr(element: &XmlElement) -> String {
180    match element.name.as_str() {
181        "annotation" | "annotation-xml" | "mglyph" => String::new(),
182        "mi" | "mn" | "mo" | "mtext" | "ms" => safe_text(&text_content(element)),
183        "mspace" => " ".into(),
184        "mfrac" => binary_expr(element, "/"),
185        "msup" => script_expr(element, "^"),
186        "msub" => script_expr(element, "_"),
187        "msubsup" => {
188            let parts = child_exprs(element);
189            if parts.len() >= 3 {
190                format!(
191                    "{}_{}{}",
192                    parts[0],
193                    subscript(&parts[1]),
194                    superscript(&parts[2])
195                )
196            } else {
197                join_exprs(element)
198            }
199        }
200        "msqrt" => format!("sqrt({})", join_exprs(element)),
201        "mroot" => {
202            let parts = child_exprs(element);
203            if parts.len() >= 2 {
204                format!("root({}, {})", parts[0], parts[1])
205            } else {
206                join_exprs(element)
207            }
208        }
209        "mfenced" => {
210            let open = element.attribute("open").unwrap_or("(");
211            let close = element.attribute("close").unwrap_or(")");
212            let sep = element.attribute("separators").unwrap_or(",");
213            format!("{open}{}{close}", join_exprs_with_separator(element, sep))
214        }
215        "mtable" => element
216            .children
217            .iter()
218            .filter(|child| child.name == "mtr" || child.name == "mlabeledtr")
219            .map(render_expr)
220            .collect::<Vec<_>>()
221            .join("; "),
222        "mtr" => element
223            .children
224            .iter()
225            .filter(|child| child.name == "mtd" || child.name == "mlabeledtr")
226            .map(render_expr)
227            .collect::<Vec<_>>()
228            .join(" | "),
229        "mtd" => join_exprs(element),
230        "semantics" => element
231            .children
232            .iter()
233            .find(|child| child.name != "annotation" && child.name != "annotation-xml")
234            .map(render_expr)
235            .unwrap_or_default(),
236        "mrow" | "math" | "mstyle" | "merror" | "mpadded" | "mphantom" | "menclose" | "munder"
237        | "mover" | "munderover" => join_exprs(element),
238        _ => join_exprs(element),
239    }
240}
241
242fn child_exprs(element: &XmlElement) -> Vec<String> {
243    element
244        .children
245        .iter()
246        .filter_map(|child| {
247            let value = render_expr(child);
248            (!value.is_empty()).then_some(value)
249        })
250        .collect()
251}
252fn join_exprs(element: &XmlElement) -> String {
253    join_exprs_with_separator(element, " ")
254}
255fn join_exprs_with_separator(element: &XmlElement, separator: &str) -> String {
256    child_exprs(element).join(separator)
257}
258fn binary_expr(element: &XmlElement, operator: &str) -> String {
259    let parts = child_exprs(element);
260    if parts.len() >= 2 {
261        format!("({}){}({})", parts[0], operator, parts[1])
262    } else {
263        join_exprs(element)
264    }
265}
266fn script_expr(element: &XmlElement, operator: &str) -> String {
267    let parts = child_exprs(element);
268    if parts.len() >= 2 {
269        format!("{}{}({})", parts[0], operator, parts[1])
270    } else {
271        join_exprs(element)
272    }
273}
274fn subscript(value: &str) -> String {
275    format!("({value})")
276}
277fn superscript(value: &str) -> String {
278    format!("^({value})")
279}
280
281fn text_content(element: &XmlElement) -> String {
282    let mut parts = Vec::new();
283    if !element.text.trim().is_empty() {
284        parts.push(element.text.trim().to_owned());
285    }
286    for child in &element.children {
287        let value = text_content(child);
288        if !value.is_empty() {
289            parts.push(value);
290        }
291    }
292    parts.join(" ")
293}
294fn safe_text(value: &str) -> String {
295    if value.contains("://") {
296        "[URL omitted]".into()
297    } else {
298        truncate(value.trim())
299    }
300}
301fn push_row(rows: &mut Vec<Vec<String>>, kind: &str, value: &str, detail: &str) -> Result<()> {
302    if rows.len() >= MAX_MATHML_ROWS {
303        return Err(Error::LimitExceeded(format!(
304            "MathML rows exceed {MAX_MATHML_ROWS}"
305        )));
306    }
307    rows.push(vec![truncate(kind), truncate(value), truncate(detail)]);
308    Ok(())
309}
310fn display_or_dash(value: &str) -> String {
311    if value.is_empty() {
312        "-".into()
313    } else {
314        truncate(value)
315    }
316}
317fn truncate(value: &str) -> String {
318    if value.len() <= MAX_MATHML_DISPLAY_BYTES {
319        return value.to_owned();
320    }
321    let mut end = MAX_MATHML_DISPLAY_BYTES;
322    while !value.is_char_boundary(end) {
323        end -= 1;
324    }
325    format!("{}…", &value[..end])
326}
327
328#[cfg(test)]
329mod tests {
330    use super::looks_like_prefix;
331    #[test]
332    fn recognizes_mathml_namespace() {
333        assert!(looks_like_prefix(
334            br#"<math xmlns="http://www.w3.org/1998/Math/MathML"><mi>x</mi></math>"#
335        ));
336    }
337    #[test]
338    fn rejects_generic_math() {
339        assert!(!looks_like_prefix(br#"<math><mi>x</mi></math>"#));
340    }
341}