Skip to main content

ravenrs_sheet/math/
omml.rs

1//! LaTeX to OMML, the open Office Math Markup of ECMA-376 Part 1 §22.1, for the common subset:
2//! runs, fractions and binomials, roots, scripts, big operators with limits, functions and
3//! limits, delimiters, matrices, accents, bars, braces and font styles. Anything else is an
4//! error, and the caller saves the equation as text only.
5
6use ratex_parser::ParseNode;
7use ratex_parser::parse_node::Mode;
8
9use crate::math::MathError;
10
11/// Deepest tree walked (RaTeX already refuses input nested deeper than 32).
12const MAX_WALK: usize = 96;
13
14/// `latex` as an `<m:oMath>` element (prefix `m` for the OMML namespace and `a` for DrawingML,
15/// declared by the caller), with runs at `size_pt` points in the `ink` colour (none: the
16/// paragraph's own).
17pub fn to_omml(latex: &str, size_pt: f32, ink: Option<[u8; 3]>) -> Result<String, MathError> {
18    crate::math::check_caps(latex)?;
19    crate::math::guarded(|| {
20        let nodes = crate::math::parse(latex)?;
21        let sz = if size_pt.is_finite() { (size_pt * 100.0).round().clamp(100.0, 400_000.0) as u32 } else { 1100 };
22        let fill = ink.map(|[r, g, b]| format!("<a:solidFill><a:srgbClr val=\"{r:02X}{g:02X}{b:02X}\"/></a:solidFill>")).unwrap_or_default();
23        let mut w = Writer { out: String::from("<m:oMath>"), sz, fill };
24        w.seq(&nodes, &Style::default(), 0)?;
25        w.out.push_str("</m:oMath>");
26        Ok(w.out)
27    })
28}
29
30#[derive(Clone, Default)]
31struct Style {
32    /// `m:scr` (script) value: double-struck, script, fraktur, sans-serif, monospace.
33    scr: Option<&'static str>,
34    /// `m:sty` value: p (plain), b (bold), i, bi.
35    sty: Option<&'static str>,
36    /// Ordinary text (`\text`): `m:nor`.
37    normal: bool,
38}
39
40struct Writer {
41    out: String,
42    sz: u32,
43    /// The DrawingML fill of every run (`<a:solidFill>…</a:solidFill>`), or empty.
44    fill: String,
45}
46
47fn unsupported(what: &str) -> MathError {
48    MathError::new(format!("{what} can't be saved as an Office equation."), None)
49}
50
51/// Whether XML 1.0 can carry `c`: the same set the xlsx crate's escapes keep, so the OMML is
52/// well formed wherever the drawing part embeds it.
53fn is_xml_char(c: char) -> bool {
54    matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}')
55}
56
57/// `s` escaped for element text; characters XML cannot carry (C0 controls other than tab, newline
58/// and carriage return, and U+FFFE and U+FFFF) are dropped, as the xlsx crate's `esc` drops them.
59fn esc(s: &str) -> String {
60    let mut o = String::with_capacity(s.len());
61    for c in s.chars() {
62        match c {
63            '&' => o.push_str("&amp;"),
64            '<' => o.push_str("&lt;"),
65            '>' => o.push_str("&gt;"),
66            '"' => o.push_str("&quot;"),
67            '\r' => o.push_str("&#13;"),
68            c if is_xml_char(c) => o.push(c),
69            _ => {}
70        }
71    }
72    o
73}
74
75/// The character a symbol name (`\alpha`, `(`, `\langle`) stands for.
76fn symbol(name: &str, mode: Mode) -> Option<String> {
77    let m = if mode == Mode::Text { ratex_font::symbols::Mode::Text } else { ratex_font::symbols::Mode::Math };
78    if let Some(c) = ratex_font::symbols::get_symbol(name, m).and_then(|s| s.codepoint) {
79        return Some(c.to_string());
80    }
81    if !name.starts_with('\\') {
82        return Some(name.to_string());
83    }
84    match name {
85        "\\{" | "\\lbrace" => Some("{".into()),
86        "\\}" | "\\rbrace" => Some("}".into()),
87        "\\|" | "\\Vert" => Some("‖".into()),
88        "\\vert" => Some("|".into()),
89        "\\langle" => Some("⟨".into()),
90        "\\rangle" => Some("⟩".into()),
91        "\\lfloor" => Some("⌊".into()),
92        "\\rfloor" => Some("⌋".into()),
93        "\\lceil" => Some("⌈".into()),
94        "\\rceil" => Some("⌉".into()),
95        _ => None,
96    }
97}
98
99/// A delimiter's character; `.` is no delimiter.
100fn delim(d: &str) -> Result<String, MathError> {
101    if d == "." {
102        return Ok(String::new());
103    }
104    if d.len() == 1 && d.is_ascii() {
105        return Ok(d.to_string());
106    }
107    symbol(d, Mode::Math).ok_or_else(|| unsupported(&format!("The delimiter {d}")))
108}
109
110fn is_big_op(n: &ParseNode) -> Option<String> {
111    match n {
112        ParseNode::Op { symbol: true, name: Some(name), .. } => symbol(name, Mode::Math),
113        _ => None,
114    }
115}
116
117fn is_rel(n: &ParseNode) -> bool {
118    matches!(n, ParseNode::Atom { family: ratex_parser::parse_node::AtomFamily::Rel, .. })
119}
120
121impl Writer {
122    fn run(&mut self, text: &str, st: &Style) {
123        if text.is_empty() {
124            return;
125        }
126        self.out.push_str("<m:r>");
127        if st.scr.is_some() || st.sty.is_some() || st.normal {
128            self.out.push_str("<m:rPr>");
129            if st.normal {
130                self.out.push_str("<m:nor/>");
131            }
132            if let Some(s) = st.scr {
133                self.out.push_str(&format!("<m:scr m:val=\"{s}\"/>"));
134            }
135            if let Some(s) = st.sty {
136                self.out.push_str(&format!("<m:sty m:val=\"{s}\"/>"));
137            }
138            self.out.push_str("</m:rPr>");
139        }
140        // Word trims a run's text unless it is marked, so a run that is or ends in whitespace
141        // (the space after `\text{if }`) says so.
142        let space = if text.starts_with(char::is_whitespace) || text.ends_with(char::is_whitespace) { " xml:space=\"preserve\"" } else { "" };
143        self.out.push_str(&format!(
144            "<a:rPr lang=\"en-US\" sz=\"{}\">{}<a:latin typeface=\"Cambria Math\"/></a:rPr><m:t{}>{}</m:t></m:r>",
145            self.sz,
146            self.fill,
147            space,
148            esc(text)
149        ));
150    }
151
152    /// `<tag>…</tag>` around the nodes.
153    fn wrap(&mut self, tag: &str, nodes: &[ParseNode], st: &Style, depth: usize) -> Result<(), MathError> {
154        self.out.push_str(&format!("<{tag}>"));
155        self.seq(nodes, st, depth)?;
156        self.out.push_str(&format!("</{tag}>"));
157        Ok(())
158    }
159
160    fn wrap1(&mut self, tag: &str, node: Option<&ParseNode>, st: &Style, depth: usize) -> Result<(), MathError> {
161        match node {
162            Some(n) => self.wrap(tag, std::slice::from_ref(n), st, depth),
163            None => {
164                self.out.push_str(&format!("<{tag}/>"));
165                Ok(())
166            }
167        }
168    }
169
170    fn seq(&mut self, nodes: &[ParseNode], st: &Style, depth: usize) -> Result<(), MathError> {
171        if depth > MAX_WALK {
172            return Err(unsupported("An equation this deep"));
173        }
174        let mut i = 0;
175        while let Some(n) = nodes.get(i) {
176            i += 1;
177            // A big operator takes what follows, up to the next relation, as its operand.
178            let (op, sub, sup) = match n {
179                ParseNode::SupSub { base: Some(b), sub, sup, .. } => (is_big_op(b), sub.as_deref(), sup.as_deref()),
180                other => (is_big_op(other), None, None),
181            };
182            if let Some(chr) = op {
183                let end = nodes.get(i..).and_then(|rest| rest.iter().position(is_rel)).map_or(nodes.len(), |p| i + p);
184                let limits = match n {
185                    ParseNode::SupSub { base: Some(b), .. } => matches!(**b, ParseNode::Op { limits: true, .. }),
186                    _ => false,
187                };
188                self.out.push_str(&format!(
189                    "<m:nary><m:naryPr><m:chr m:val=\"{}\"/><m:limLoc m:val=\"{}\"/>{}{}</m:naryPr>",
190                    esc(&chr),
191                    if limits { "undOvr" } else { "subSup" },
192                    if sub.is_none() { "<m:subHide m:val=\"1\"/>" } else { "" },
193                    if sup.is_none() { "<m:supHide m:val=\"1\"/>" } else { "" },
194                ));
195                self.wrap1("m:sub", sub, st, depth + 1)?;
196                self.wrap1("m:sup", sup, st, depth + 1)?;
197                self.wrap("m:e", nodes.get(i..end).unwrap_or_default(), st, depth + 1)?;
198                self.out.push_str("</m:nary>");
199                i = end;
200                continue;
201            }
202            self.node(n, st, depth + 1)?;
203        }
204        Ok(())
205    }
206
207    fn node(&mut self, n: &ParseNode, st: &Style, depth: usize) -> Result<(), MathError> {
208        if depth > MAX_WALK {
209            return Err(unsupported("An equation this deep"));
210        }
211        match n {
212            ParseNode::Atom { text, mode, .. }
213            | ParseNode::MathOrd { text, mode, .. }
214            | ParseNode::TextOrd { text, mode, .. }
215            | ParseNode::OpToken { text, mode, .. } => {
216                let t = symbol(text, *mode).ok_or_else(|| unsupported(text))?;
217                self.run(&t, st);
218            }
219            ParseNode::SpacingNode { text, .. } => {
220                let t = match text.as_str() {
221                    // A literal space in text mode (`\text{if } x`) is a spacing node of " ".
222                    " " | "\\ " | "~" | "\\space" | "\\nobreakspace" => " ",
223                    "\\quad" => "\u{2003}",
224                    "\\qquad" => "\u{2003}\u{2003}",
225                    "\\," | "\\thinspace" => "\u{2009}",
226                    "\\:" | "\\>" | "\\medspace" => "\u{205F}",
227                    "\\;" | "\\thickspace" => "\u{2004}",
228                    _ => "",
229                };
230                self.run(t, st);
231            }
232            ParseNode::Kern { .. } => {}
233            ParseNode::OrdGroup { body, .. }
234            | ParseNode::Styling { body, .. }
235            | ParseNode::Sizing { body, .. }
236            | ParseNode::MClass { body, .. }
237            | ParseNode::Href { body, .. }
238            | ParseNode::HBox { body, .. }
239            | ParseNode::Pmb { body, .. } => self.seq(body, st, depth)?,
240            ParseNode::Color { body, .. } => self.seq(body, st, depth)?,
241            ParseNode::Text { body, font, .. } => {
242                let mut s = Style { normal: true, ..st.clone() };
243                if let Some(f) = font {
244                    s.sty = match f.as_str() {
245                        "\\textbf" => Some("b"),
246                        "\\textit" => Some("i"),
247                        _ => s.sty,
248                    };
249                }
250                self.seq(body, &s, depth)?;
251            }
252            ParseNode::Font { font, body, .. } => {
253                let mut s = st.clone();
254                match font.trim_start_matches('\\') {
255                    "mathbb" | "Bbb" => s.scr = Some("double-struck"),
256                    "mathcal" | "mathscr" => s.scr = Some("script"),
257                    "mathfrak" | "frak" => s.scr = Some("fraktur"),
258                    "mathsf" | "textsf" => s.scr = Some("sans-serif"),
259                    "mathtt" | "texttt" => s.scr = Some("monospace"),
260                    "mathbf" | "bf" | "textbf" => s.sty = Some("b"),
261                    "boldsymbol" | "bm" => s.sty = Some("bi"),
262                    "mathrm" | "rm" | "textrm" | "mathnormal" => s.sty = Some("p"),
263                    "mathit" | "it" | "textit" => s.sty = Some("i"),
264                    other => return Err(unsupported(&format!("\\{other}"))),
265                }
266                self.node(body, &s, depth + 1)?;
267            }
268            ParseNode::SupSub { base, sup, sub, .. } => {
269                // A function with limits under it (\lim_{x\to 0}, \max_i).
270                if let Some(b) = base.as_deref()
271                    && let ParseNode::Op { limits: true, symbol: false, .. } | ParseNode::OperatorName { limits: true, .. } = b
272                    && sup.is_none()
273                {
274                    self.out.push_str("<m:limLow><m:e>");
275                    self.node(b, st, depth + 1)?;
276                    self.out.push_str("</m:e>");
277                    self.wrap1("m:lim", sub.as_deref(), st, depth + 1)?;
278                    self.out.push_str("</m:limLow>");
279                    return Ok(());
280                }
281                // A brace with a label over or under it (`\overbrace{x}^{n}`, `\underbrace{x}_{n}`):
282                // the label is the limit on the brace's side. A script on the other side
283                // (`\underbrace{x}_{n}^{2}`) is a script around the whole, `m:sSup` or `m:sSub`,
284                // so that it reaches Office Math too; without a label it goes around the bare
285                // brace, with no empty limit.
286                if let Some(b @ ParseNode::HorizBrace { is_over, .. }) = base.as_deref() {
287                    let (lim_tag, label, (script_tag, script_arg), script) = if *is_over {
288                        ("m:limUpp", sup.as_deref(), ("m:sSub", "m:sub"), sub.as_deref())
289                    } else {
290                        ("m:limLow", sub.as_deref(), ("m:sSup", "m:sup"), sup.as_deref())
291                    };
292                    // Each wrapper around the brace is a level of its own.
293                    let inner = if script.is_some() { depth + 1 } else { depth };
294                    if script.is_some() {
295                        self.out.push_str(&format!("<{script_tag}><m:e>"));
296                    }
297                    if label.is_some() {
298                        self.out.push_str(&format!("<{lim_tag}><m:e>"));
299                        self.node(b, st, inner + 1)?;
300                        self.out.push_str("</m:e>");
301                        self.wrap1("m:lim", label, st, inner + 1)?;
302                        self.out.push_str(&format!("</{lim_tag}>"));
303                    } else {
304                        self.node(b, st, inner)?;
305                    }
306                    if script.is_some() {
307                        self.out.push_str("</m:e>");
308                        self.wrap1(script_arg, script, st, depth + 1)?;
309                        self.out.push_str(&format!("</{script_tag}>"));
310                    }
311                    return Ok(());
312                }
313                let tag = match (sub.is_some(), sup.is_some()) {
314                    (true, true) => "m:sSubSup",
315                    (true, false) => "m:sSub",
316                    _ => "m:sSup",
317                };
318                self.out.push_str(&format!("<{tag}>"));
319                self.wrap1("m:e", base.as_deref(), st, depth + 1)?;
320                if sub.is_some() {
321                    self.wrap1("m:sub", sub.as_deref(), st, depth + 1)?;
322                }
323                if sup.is_some() || sub.is_none() {
324                    self.wrap1("m:sup", sup.as_deref(), st, depth + 1)?;
325                }
326                self.out.push_str(&format!("</{tag}>"));
327            }
328            ParseNode::GenFrac { numer, denom, has_bar_line, left_delim, right_delim, .. } => {
329                let delims = left_delim.is_some() || right_delim.is_some();
330                if delims {
331                    self.out.push_str(&format!(
332                        "<m:d><m:dPr><m:begChr m:val=\"{}\"/><m:endChr m:val=\"{}\"/></m:dPr><m:e>",
333                        esc(&delim(left_delim.as_deref().unwrap_or("."))?),
334                        esc(&delim(right_delim.as_deref().unwrap_or("."))?)
335                    ));
336                }
337                self.out.push_str("<m:f>");
338                if !has_bar_line {
339                    self.out.push_str("<m:fPr><m:type m:val=\"noBar\"/></m:fPr>");
340                }
341                self.wrap1("m:num", Some(numer), st, depth + 1)?;
342                self.wrap1("m:den", Some(denom), st, depth + 1)?;
343                self.out.push_str("</m:f>");
344                if delims {
345                    self.out.push_str("</m:e></m:d>");
346                }
347            }
348            ParseNode::Sqrt { body, index, .. } => {
349                self.out.push_str("<m:rad>");
350                match index {
351                    Some(ix) => {
352                        self.wrap1("m:deg", Some(ix), st, depth + 1)?;
353                    }
354                    None => self.out.push_str("<m:radPr><m:degHide m:val=\"1\"/></m:radPr><m:deg/>"),
355                }
356                self.wrap1("m:e", Some(body), st, depth + 1)?;
357                self.out.push_str("</m:rad>");
358            }
359            ParseNode::Op { name: Some(name), symbol: false, .. } => {
360                let t = name.trim_start_matches('\\');
361                self.run(t, &Style { sty: Some("p"), ..st.clone() });
362            }
363            ParseNode::Op { name: Some(name), .. } => {
364                let t = symbol(name, Mode::Math).ok_or_else(|| unsupported(name))?;
365                self.run(&t, st);
366            }
367            ParseNode::Op { body: Some(body), .. } => self.seq(body, st, depth)?,
368            ParseNode::OperatorName { body, .. } => self.seq(body, &Style { sty: Some("p"), ..st.clone() }, depth)?,
369            ParseNode::LeftRight { body, left, right, .. } => {
370                self.out.push_str(&format!(
371                    "<m:d><m:dPr><m:begChr m:val=\"{}\"/><m:endChr m:val=\"{}\"/></m:dPr>",
372                    esc(&delim(left)?),
373                    esc(&delim(right)?)
374                ));
375                // \middle splits the contents into separated elements.
376                let mut parts: Vec<&[ParseNode]> = Vec::new();
377                let mut start = 0;
378                for (k, b) in body.iter().enumerate() {
379                    if matches!(b, ParseNode::Middle { .. }) {
380                        parts.push(body.get(start..k).unwrap_or_default());
381                        start = k + 1;
382                    }
383                }
384                parts.push(body.get(start..).unwrap_or_default());
385                if parts.len() > 1 {
386                    let sep =
387                        body.iter().find_map(|b| if let ParseNode::Middle { delim: d, .. } = b { delim(d).ok() } else { None }).unwrap_or_default();
388                    // Rewrite the dPr with the separator: drop what was pushed and start again.
389                    let cut = self.out.rfind("<m:d>").unwrap_or(self.out.len());
390                    self.out.truncate(cut);
391                    self.out.push_str(&format!(
392                        "<m:d><m:dPr><m:begChr m:val=\"{}\"/><m:sepChr m:val=\"{}\"/><m:endChr m:val=\"{}\"/></m:dPr>",
393                        esc(&delim(left)?),
394                        esc(&sep),
395                        esc(&delim(right)?)
396                    ));
397                }
398                for p in parts {
399                    self.wrap("m:e", p, st, depth + 1)?;
400                }
401                self.out.push_str("</m:d>");
402            }
403            ParseNode::DelimSizing { delim: d, .. } => {
404                let t = delim(d)?;
405                self.run(&t, st);
406            }
407            ParseNode::Array { body, is_cd: None | Some(false), .. } => {
408                let cols = body.iter().map(Vec::len).max().unwrap_or(0).max(1);
409                self.out.push_str(&format!(
410                    "<m:m><m:mPr><m:mcs><m:mc><m:mcPr><m:count m:val=\"{cols}\"/><m:mcJc m:val=\"center\"/></m:mcPr></m:mc></m:mcs></m:mPr>"
411                ));
412                // Every row the parser gives is written: RaTeX itself drops the empty last row a
413                // trailing `\\` leaves, and an empty row anywhere else is the author's blank line.
414                for row in body {
415                    self.out.push_str("<m:mr>");
416                    for k in 0..cols {
417                        self.wrap1("m:e", row.get(k), st, depth + 1)?;
418                    }
419                    self.out.push_str("</m:mr>");
420                }
421                self.out.push_str("</m:m>");
422            }
423            ParseNode::Accent { label, base, .. } => {
424                let chr = match label.as_str() {
425                    "\\hat" | "\\widehat" => "\u{0302}",
426                    "\\check" | "\\widecheck" => "\u{030C}",
427                    "\\tilde" | "\\widetilde" => "\u{0303}",
428                    "\\acute" => "\u{0301}",
429                    "\\grave" => "\u{0300}",
430                    "\\dot" => "\u{0307}",
431                    "\\ddot" => "\u{0308}",
432                    "\\breve" => "\u{0306}",
433                    "\\bar" => "\u{0305}",
434                    "\\vec" | "\\overrightarrow" => "\u{20D7}",
435                    "\\overleftarrow" => "\u{20D6}",
436                    "\\mathring" => "\u{030A}",
437                    other => return Err(unsupported(other)),
438                };
439                self.out.push_str(&format!("<m:acc><m:accPr><m:chr m:val=\"{chr}\"/></m:accPr>"));
440                self.wrap1("m:e", Some(base), st, depth + 1)?;
441                self.out.push_str("</m:acc>");
442            }
443            ParseNode::Overline { body, .. } | ParseNode::Underline { body, .. } => {
444                let pos = if matches!(n, ParseNode::Overline { .. }) { "top" } else { "bot" };
445                self.out.push_str(&format!("<m:bar><m:barPr><m:pos m:val=\"{pos}\"/></m:barPr>"));
446                self.wrap1("m:e", Some(body), st, depth + 1)?;
447                self.out.push_str("</m:bar>");
448            }
449            ParseNode::HorizBrace { is_over, base, .. } => {
450                let (chr, pos, vert) = if *is_over { ("\u{23DE}", "top", "bot") } else { ("\u{23DF}", "bot", "top") };
451                self.out.push_str(&format!(
452                    "<m:groupChr><m:groupChrPr><m:chr m:val=\"{chr}\"/><m:pos m:val=\"{pos}\"/><m:vertJc m:val=\"{vert}\"/></m:groupChrPr>"
453                ));
454                self.wrap1("m:e", Some(base), st, depth + 1)?;
455                self.out.push_str("</m:groupChr>");
456            }
457            ParseNode::Enclose { label, body, .. } if label == "\\boxed" || label == "\\fbox" => {
458                self.out.push_str("<m:borderBox>");
459                self.wrap1("m:e", Some(body), st, depth + 1)?;
460                self.out.push_str("</m:borderBox>");
461            }
462            ParseNode::Phantom { body, .. } => {
463                self.out.push_str("<m:phant><m:phantPr><m:show m:val=\"0\"/></m:phantPr>");
464                self.wrap("m:e", body, st, depth + 1)?;
465                self.out.push_str("</m:phant>");
466            }
467            ParseNode::Internal { .. } | ParseNode::NoNumber { .. } | ParseNode::ColorToken { .. } => {}
468            other => return Err(unsupported(&format!("\"{}\"", node_name(other)))),
469        }
470        Ok(())
471    }
472}
473
474/// A short name for an unsupported construct, for the error.
475fn node_name(n: &ParseNode) -> String {
476    let dbg = format!("{n:?}");
477    dbg.split([' ', '{', '(']).next().unwrap_or("This construct").to_string()
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483
484    fn om(s: &str) -> String {
485        to_omml(s, 11.0, None).unwrap()
486    }
487
488    /// The markup without the per-run DrawingML properties, to keep assertions readable.
489    fn bare(s: &str) -> String {
490        let mut o = om(s);
491        while let Some(a) = o.find("<a:rPr") {
492            let Some(b) = o[a..].find("</a:rPr>") else { break };
493            o.replace_range(a..a + b + "</a:rPr>".len(), "");
494        }
495        o
496    }
497
498    #[test]
499    fn runs_fractions_and_roots() {
500        assert_eq!(bare("x+1"), "<m:oMath><m:r><m:t>x</m:t></m:r><m:r><m:t>+</m:t></m:r><m:r><m:t>1</m:t></m:r></m:oMath>");
501        assert!(om("x").contains("sz=\"1100\""));
502        assert_eq!(
503            bare("\\frac{a}{b}"),
504            "<m:oMath><m:f><m:num><m:r><m:t>a</m:t></m:r></m:num><m:den><m:r><m:t>b</m:t></m:r></m:den></m:f></m:oMath>"
505        );
506        assert!(
507            bare("\\binom{n}{k}")
508                .contains("<m:d><m:dPr><m:begChr m:val=\"(\"/><m:endChr m:val=\")\"/></m:dPr><m:e><m:f><m:fPr><m:type m:val=\"noBar\"/>")
509        );
510        assert!(bare("\\sqrt{x}").starts_with("<m:oMath><m:rad><m:radPr><m:degHide m:val=\"1\"/></m:radPr><m:deg/><m:e>"));
511        assert!(bare("\\sqrt[3]{x}").contains("<m:deg><m:r><m:t>3</m:t></m:r></m:deg>"));
512        assert!(bare("\\alpha \\le \\infty").contains("<m:t>α</m:t></m:r><m:r><m:t>≤</m:t></m:r><m:r><m:t>∞</m:t>"));
513    }
514
515    #[test]
516    fn scripts_operators_and_limits() {
517        assert_eq!(bare("x^2"), "<m:oMath><m:sSup><m:e><m:r><m:t>x</m:t></m:r></m:e><m:sup><m:r><m:t>2</m:t></m:r></m:sup></m:sSup></m:oMath>");
518        assert!(bare("a_{i}^{2}").starts_with("<m:oMath><m:sSubSup><m:e>"));
519        let s = bare("\\sum_{k=0}^{n} k = m");
520        assert!(s.starts_with("<m:oMath><m:nary><m:naryPr><m:chr m:val=\"∑\"/><m:limLoc m:val=\"undOvr\"/></m:naryPr><m:sub>"), "{s}");
521        assert!(s.contains("<m:e><m:r><m:t>k</m:t></m:r></m:e></m:nary><m:r><m:t>=</m:t>"), "the operand stops at the relation: {s}");
522        assert!(bare("\\int x").contains("<m:subHide m:val=\"1\"/><m:supHide m:val=\"1\"/>"));
523        let l = bare("\\lim_{n\\to\\infty} a");
524        assert!(l.starts_with("<m:oMath><m:limLow><m:e><m:r><m:rPr><m:sty m:val=\"p\"/></m:rPr><m:t>lim</m:t>"), "{l}");
525        assert!(bare("\\sin x").contains("<m:sty m:val=\"p\"/></m:rPr><m:t>sin</m:t>"));
526    }
527
528    #[test]
529    fn delimiters_matrices_accents_and_fonts() {
530        let p = bare("\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}");
531        assert!(p.contains("<m:d><m:dPr><m:begChr m:val=\"(\"/><m:endChr m:val=\")\"/></m:dPr><m:e><m:m>"), "{p}");
532        assert_eq!(p.matches("<m:mr>").count(), 2);
533        assert_eq!(p.matches("<m:e>").count(), 5);
534        assert!(bare("\\left\\{ x \\right.").contains("<m:begChr m:val=\"{\"/><m:endChr m:val=\"\"/>"));
535        assert!(bare("\\left\\langle a \\middle| b \\right\\rangle").contains("<m:sepChr m:val=\"|\"/>"));
536        assert!(bare("\\hat{x}").contains("<m:acc><m:accPr><m:chr m:val=\"\u{0302}\"/>"));
537        assert!(bare("\\overline{z}").contains("<m:bar><m:barPr><m:pos m:val=\"top\"/>"));
538        assert!(bare("\\mathbb{R}").contains("<m:rPr><m:scr m:val=\"double-struck\"/></m:rPr>"));
539        assert!(bare("\\text{if } x").contains("<m:rPr><m:nor/></m:rPr>"));
540        assert!(bare("\\overbrace{a+b}^{n}").contains("<m:limUpp><m:e><m:groupChr>"));
541        assert!(bare("a < b \\& c").contains("<m:t>&lt;</m:t>") && bare("a \\& b").contains("<m:t>&amp;</m:t>"));
542    }
543
544    #[test]
545    fn an_underbrace_with_both_scripts_keeps_its_exponent() {
546        // The label under the brace is its limit; the exponent on the other side is a
547        // superscript around the whole, so that an application reading the Office Math sees it
548        // (the source in `descr` hid its loss in a GridCraft-to-GridCraft round trip).
549        let brace = |chr: &str, pos: &str, vert: &str| {
550            format!(
551                "<m:groupChr><m:groupChrPr><m:chr m:val=\"{chr}\"/><m:pos m:val=\"{pos}\"/><m:vertJc m:val=\"{vert}\"/></m:groupChrPr><m:e><m:r><m:t>x</m:t></m:r></m:e></m:groupChr>"
552            )
553        };
554        let under = brace("\u{23DF}", "bot", "top");
555        let over = brace("\u{23DE}", "top", "bot");
556        assert_eq!(
557            bare("\\underbrace{x}_{n}^{2}"),
558            format!(
559                "<m:oMath><m:sSup><m:e><m:limLow><m:e>{under}</m:e><m:lim><m:r><m:t>n</m:t></m:r></m:lim></m:limLow></m:e><m:sup><m:r><m:t>2</m:t></m:r></m:sup></m:sSup></m:oMath>"
560            )
561        );
562        // The mirror: the label over the brace, the subscript around the whole.
563        assert_eq!(
564            bare("\\overbrace{x}^{n}_{m}"),
565            format!(
566                "<m:oMath><m:sSub><m:e><m:limUpp><m:e>{over}</m:e><m:lim><m:r><m:t>n</m:t></m:r></m:lim></m:limUpp></m:e><m:sub><m:r><m:t>m</m:t></m:r></m:sub></m:sSub></m:oMath>"
567            )
568        );
569        // Nested braces keep every script, one wrapper per level.
570        let nested = bare("\\underbrace{\\underbrace{x}_{a}^{b}}_{c}^{d}");
571        assert_eq!(nested.matches("<m:limLow>").count(), 2, "{nested}");
572        assert_eq!(nested.matches("<m:sup>").count(), 2, "{nested}");
573        for t in ["a", "b", "c", "d"] {
574            assert!(nested.contains(&format!("<m:t>{t}</m:t>")), "{nested}");
575        }
576    }
577
578    #[test]
579    fn an_underbrace_without_a_label_keeps_its_superscript() {
580        // No label: the script goes around the bare brace, and there is no empty limit.
581        let under = bare("\\underbrace{x}^{2}");
582        assert!(under.starts_with("<m:oMath><m:sSup><m:e><m:groupChr>"), "{under}");
583        assert!(under.ends_with("</m:groupChr></m:e><m:sup><m:r><m:t>2</m:t></m:r></m:sup></m:sSup></m:oMath>"), "{under}");
584        assert!(!under.contains("<m:lim"), "{under}");
585        let over = bare("\\overbrace{x}_{m}");
586        assert!(over.starts_with("<m:oMath><m:sSub><m:e><m:groupChr>"), "{over}");
587        assert!(over.ends_with("</m:groupChr></m:e><m:sub><m:r><m:t>m</m:t></m:r></m:sub></m:sSub></m:oMath>"), "{over}");
588        assert!(!over.contains("<m:lim"), "{over}");
589        // A brace with neither script, and one with its label alone, are as they were.
590        let plain = bare("\\underbrace{x}");
591        assert!(plain.starts_with("<m:oMath><m:groupChr>") && !plain.contains("<m:sSup>") && !plain.contains("<m:lim"), "{plain}");
592        let labelled = bare("\\underbrace{x}_{n}");
593        assert!(labelled.starts_with("<m:oMath><m:limLow><m:e><m:groupChr>") && !labelled.contains("<m:sSup>"), "{labelled}");
594    }
595
596    #[test]
597    fn the_unsupported_subset_is_an_error() {
598        for s in ["\\begin{CD} A @>>> B \\end{CD}", "\\rule{1em}{1em}", "\\xrightarrow{f}", "\\verb|x|"] {
599            assert!(to_omml(s, 11.0, None).is_err(), "{s}");
600        }
601        assert!(to_omml("\\frac{1}{", 11.0, None).is_err());
602        assert!(to_omml(&"x".repeat(5000), 11.0, None).is_err());
603        assert!(om("x").starts_with("<m:oMath>"));
604        assert!(to_omml("x", f32::NAN, None).unwrap().contains("sz=\"1100\""));
605    }
606
607    #[test]
608    fn a_literal_space_in_text_mode_is_kept() {
609        // RaTeX gives a text-mode space as a spacing node whose text is " "; it is a run of its
610        // own, marked `xml:space="preserve"` so that Word keeps it, and `\text{if } x` does not
611        // come out as `ifx`.
612        let o = bare("\\text{a b}");
613        assert!(o.contains("<m:t xml:space=\"preserve\"> </m:t>"), "{o}");
614        let o = bare("\\text{if } x");
615        assert!(o.contains("<m:t xml:space=\"preserve\"> </m:t></m:r><m:r><m:t>x</m:t>"), "{o}");
616        assert!(!bare("x+1").contains("xml:space"), "runs without surrounding whitespace are not marked");
617    }
618
619    #[test]
620    fn runs_carry_the_ink_colour_when_one_is_given() {
621        let o = to_omml("x+1", 11.0, Some([0x1F, 0x4E, 0x79])).unwrap();
622        let run =
623            "<a:rPr lang=\"en-US\" sz=\"1100\"><a:solidFill><a:srgbClr val=\"1F4E79\"/></a:solidFill><a:latin typeface=\"Cambria Math\"/></a:rPr>";
624        assert_eq!(o.matches(run).count(), 3, "{o}");
625        assert!(!om("x+1").contains("<a:solidFill>"), "no ink: the paragraph's colour applies");
626    }
627
628    #[test]
629    fn characters_xml_cannot_carry_are_dropped_from_runs() {
630        // U+FFFE and U+FFFF are not XML characters, nor are C0 controls other than tab, newline
631        // and carriage return; the xlsx escapes drop them and this writer does the same, so the
632        // OMML it hands the drawing part is well formed. U+FDD0 is a Unicode noncharacter but an
633        // XML character, kept as the xlsx escapes keep it.
634        assert_eq!(esc("a\u{FFFE}b\u{FFFF}c\u{1}d\u{B}e\u{7F}f"), "abcde\u{7F}f");
635        assert_eq!(esc("a\tb\nc\rd\u{FDD0}e"), "a\tb\nc&#13;d\u{FDD0}e");
636        for (src, bad) in [("x\u{FFFE}y", '\u{FFFE}'), ("x\u{FFFF}y", '\u{FFFF}'), ("\\text{a\u{FFFF}b}", '\u{FFFF}')] {
637            if let Ok(o) = to_omml(src, 11.0, None) {
638                assert!(!o.contains(bad), "{src:?}: {o}");
639            }
640        }
641    }
642
643    #[test]
644    fn only_a_trailing_empty_row_is_dropped() {
645        // RaTeX drops the empty last row a trailing `\\` leaves (its `environments.rs`), and the
646        // writer keeps every row it is given, so a blank row in the middle, written as `\\ \\` or
647        // as `{}`, reaches Office as the sheet shows it. A RaTeX release that stopped dropping the
648        // trailing row would show up here as a third row.
649        assert_eq!(bare("\\begin{matrix} a \\\\ b \\\\ \\end{matrix}").matches("<m:mr>").count(), 2);
650        assert_eq!(bare("\\begin{cases} x \\\\ \\\\ y \\end{cases}").matches("<m:mr>").count(), 3);
651        assert_eq!(bare("\\begin{matrix} a \\\\ {} \\\\ b \\end{matrix}").matches("<m:mr>").count(), 3);
652        assert_eq!(bare("\\begin{matrix} \\\\ \\end{matrix}").matches("<m:mr>").count(), 1, "one blank row is still a row");
653    }
654
655    #[test]
656    fn every_gallery_example_converts() {
657        for s in [
658            "x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}",
659            "a^2 + b^2 = c^2",
660            "(x + a)^n = \\sum_{k=0}^{n} \\binom{n}{k} x^k a^{n-k}",
661            "A = \\pi r^2",
662            "e^{i\\pi} + 1 = 0",
663            "\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}",
664            "f(x) = \\begin{cases} x & x \\ge 0 \\\\ -x & x < 0 \\end{cases}",
665        ] {
666            assert!(to_omml(s, 11.0, None).is_ok(), "{s}: {:?}", to_omml(s, 11.0, None));
667        }
668    }
669}