1use ratex_parser::ParseNode;
7use ratex_parser::parse_node::Mode;
8
9use crate::math::MathError;
10
11const MAX_WALK: usize = 96;
13
14pub 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 scr: Option<&'static str>,
34 sty: Option<&'static str>,
36 normal: bool,
38}
39
40struct Writer {
41 out: String,
42 sz: u32,
43 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
51fn 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
57fn 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("&"),
64 '<' => o.push_str("<"),
65 '>' => o.push_str(">"),
66 '"' => o.push_str("""),
67 '\r' => o.push_str(" "),
68 c if is_xml_char(c) => o.push(c),
69 _ => {}
70 }
71 }
72 o
73}
74
75fn 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
99fn 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 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 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 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 " " | "\\ " | "~" | "\\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 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 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 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 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 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 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
474fn 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 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><</m:t>") && bare("a \\& b").contains("<m:t>&</m:t>"));
542 }
543
544 #[test]
545 fn an_underbrace_with_both_scripts_keeps_its_exponent() {
546 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 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 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 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 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 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 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 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 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}