1use 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}