1#![warn(clippy::pedantic)]
2#![allow(clippy::module_name_repetitions)]
3
4use std::io::Write;
5
6use quick_xml::Writer;
7use quick_xml::events::BytesText;
8use typst::foundations::Content;
9use typst_library::foundations::{SequenceElem, SymbolElem};
10use typst_library::math::{
11 AccentElem, AlignPointElem, AttachElem, CasesElem, EquationElem, FracElem, LrElem, MatElem,
12 OpElem, OverbraceElem, OverbracketElem, OverlineElem, OverparenElem, OvershellElem, RootElem,
13 UnderbraceElem, UnderbracketElem, UnderlineElem, UnderparenElem, UndershellElem, VecElem,
14};
15use typst_library::text::{LinebreakElem, SpaceElem, TextElem};
16
17#[must_use]
24pub fn equation_to_omml(content: &Content) -> String {
25 let eq = content
26 .to_packed::<EquationElem>()
27 .expect("content must be an EquationElem");
28
29 let is_block = *eq.block.as_option().as_ref().unwrap_or(&false);
31 let body = &eq.body;
32
33 let mut buf = Vec::new();
34 let mut writer = Writer::new_with_indent(&mut buf, b' ', 2);
35
36 if is_block {
37 writer
38 .create_element("m:oMathPara")
39 .write_inner_content(|w| {
40 write_omath(w, body)?;
41 Ok(())
42 })
43 .expect("XML write failed");
44 } else {
45 write_omath(&mut writer, body).expect("XML write failed");
46 }
47
48 String::from_utf8(buf).expect("valid UTF-8")
49}
50
51fn write_omath<W: Write>(writer: &mut Writer<W>, body: &Content) -> std::io::Result<()> {
52 writer.create_element("m:oMath").write_inner_content(|w| {
53 if is_aligned_equation(body) {
55 convert_eq_array(w, body)?;
56 } else {
57 convert_content(w, body)?;
58 }
59 Ok(())
60 })?;
61 Ok(())
62}
63
64fn is_aligned_equation(content: &Content) -> bool {
69 if let Some(seq) = content.to_packed::<SequenceElem>() {
70 let has_align = seq
71 .children
72 .iter()
73 .any(|c| c.to_packed::<AlignPointElem>().is_some());
74 let has_linebreak = seq
75 .children
76 .iter()
77 .any(|c| c.to_packed::<LinebreakElem>().is_some());
78 has_align && has_linebreak
79 } else {
80 false
81 }
82}
83
84fn convert_eq_array<W: Write>(writer: &mut Writer<W>, body: &Content) -> std::io::Result<()> {
91 let seq = body
92 .to_packed::<SequenceElem>()
93 .expect("aligned equation body must be a SequenceElem");
94
95 let mut rows: Vec<Vec<&Content>> = vec![Vec::new()];
97 for child in &seq.children {
98 if child.to_packed::<LinebreakElem>().is_some() {
99 rows.push(Vec::new());
100 } else {
101 rows.last_mut().unwrap().push(child);
102 }
103 }
104
105 while rows.last().is_some_and(Vec::is_empty) {
107 rows.pop();
108 }
109
110 writer
111 .create_element("m:eqArr")
112 .write_inner_content(|arr| {
113 for row in &rows {
114 arr.create_element("m:e").write_inner_content(|e| {
115 for child in row {
116 if child.to_packed::<AlignPointElem>().is_some() {
117 write_math_run(e, "\u{0026}")?;
119 } else {
120 convert_content(e, child)?;
121 }
122 }
123 Ok(())
124 })?;
125 }
126 Ok(())
127 })?;
128 Ok(())
129}
130
131fn convert_content<W: Write>(writer: &mut Writer<W>, content: &Content) -> std::io::Result<()> {
132 if let Some(seq) = content.to_packed::<SequenceElem>() {
134 for child in &seq.children {
135 convert_content(writer, child)?;
136 }
137 } else if let Some(attach) = content.to_packed::<AttachElem>() {
138 convert_attach(writer, attach)?;
139 } else if let Some(frac) = content.to_packed::<FracElem>() {
140 convert_frac(writer, frac)?;
141 } else if let Some(lr) = content.to_packed::<LrElem>() {
142 convert_lr(writer, lr)?;
143 } else if let Some(root) = content.to_packed::<RootElem>() {
144 convert_root(writer, root)?;
145 } else if let Some(mat) = content.to_packed::<MatElem>() {
146 convert_mat(writer, mat)?;
147 } else if let Some(vec_elem) = content.to_packed::<VecElem>() {
148 convert_vec(writer, vec_elem)?;
149 } else if let Some(accent) = content.to_packed::<AccentElem>() {
150 convert_accent(writer, accent)?;
151 } else if let Some(overline) = content.to_packed::<OverlineElem>() {
152 convert_bar(writer, &overline.body, "top")?;
153 } else if let Some(underline) = content.to_packed::<UnderlineElem>() {
154 convert_bar(writer, &underline.body, "bot")?;
155 } else if let Some(op) = content.to_packed::<OpElem>() {
156 convert_op(writer, op)?;
157 } else if let Some(cases) = content.to_packed::<CasesElem>() {
158 convert_cases(writer, cases)?;
159 } else if let Some(ob) = content.to_packed::<OverbraceElem>() {
160 let ann = ob.annotation.as_option().as_ref().and_then(|v| v.as_ref());
161 convert_groupchr(writer, &ob.body, ann, "\u{23DE}", "top")?;
162 } else if let Some(ub) = content.to_packed::<UnderbraceElem>() {
163 let ann = ub.annotation.as_option().as_ref().and_then(|v| v.as_ref());
164 convert_groupchr(writer, &ub.body, ann, "\u{23DF}", "bot")?;
165 } else if let Some(ob) = content.to_packed::<OverbracketElem>() {
166 let ann = ob.annotation.as_option().as_ref().and_then(|v| v.as_ref());
167 convert_groupchr(writer, &ob.body, ann, "\u{23B4}", "top")?;
168 } else if let Some(ub) = content.to_packed::<UnderbracketElem>() {
169 let ann = ub.annotation.as_option().as_ref().and_then(|v| v.as_ref());
170 convert_groupchr(writer, &ub.body, ann, "\u{23B5}", "bot")?;
171 } else if let Some(op) = content.to_packed::<OverparenElem>() {
172 let ann = op.annotation.as_option().as_ref().and_then(|v| v.as_ref());
173 convert_groupchr(writer, &op.body, ann, "\u{23DC}", "top")?;
174 } else if let Some(up) = content.to_packed::<UnderparenElem>() {
175 let ann = up.annotation.as_option().as_ref().and_then(|v| v.as_ref());
176 convert_groupchr(writer, &up.body, ann, "\u{23DD}", "bot")?;
177 } else if let Some(os) = content.to_packed::<OvershellElem>() {
178 let ann = os.annotation.as_option().as_ref().and_then(|v| v.as_ref());
179 convert_groupchr(writer, &os.body, ann, "\u{23E0}", "top")?;
180 } else if let Some(us) = content.to_packed::<UndershellElem>() {
181 let ann = us.annotation.as_option().as_ref().and_then(|v| v.as_ref());
182 convert_groupchr(writer, &us.body, ann, "\u{23E1}", "bot")?;
183 } else if content.to_packed::<AlignPointElem>().is_some() {
184 } else if let Some(sym) = content.to_packed::<SymbolElem>() {
187 write_math_run(writer, &sym.text)?;
188 } else if let Some(text) = content.to_packed::<TextElem>() {
189 write_math_run(writer, &text.text)?;
190 } else if content.to_packed::<SpaceElem>().is_some() {
191 } else {
193 }
195 Ok(())
196}
197
198fn convert_attach<W: Write>(writer: &mut Writer<W>, attach: &AttachElem) -> std::io::Result<()> {
199 let base = &attach.base;
200 let sup = attach.t.as_option().as_ref().and_then(|v| v.as_ref());
202 let sub = attach.b.as_option().as_ref().and_then(|v| v.as_ref());
203
204 if is_nary_base(base) {
206 return convert_nary(writer, base, sub, sup);
207 }
208
209 match (sub, sup) {
210 (Some(below), Some(above)) => {
211 writer
213 .create_element("m:sSubSup")
214 .write_inner_content(|w| {
215 w.create_element("m:e").write_inner_content(|e| {
216 convert_content(e, base)?;
217 Ok(())
218 })?;
219 w.create_element("m:sub").write_inner_content(|s| {
220 convert_content(s, below)?;
221 Ok(())
222 })?;
223 w.create_element("m:sup").write_inner_content(|s| {
224 convert_content(s, above)?;
225 Ok(())
226 })?;
227 Ok(())
228 })?;
229 }
230 (None, Some(above)) => {
231 writer.create_element("m:sSup").write_inner_content(|w| {
233 w.create_element("m:e").write_inner_content(|e| {
234 convert_content(e, base)?;
235 Ok(())
236 })?;
237 w.create_element("m:sup").write_inner_content(|s| {
238 convert_content(s, above)?;
239 Ok(())
240 })?;
241 Ok(())
242 })?;
243 }
244 (Some(below), None) => {
245 writer.create_element("m:sSub").write_inner_content(|w| {
247 w.create_element("m:e").write_inner_content(|e| {
248 convert_content(e, base)?;
249 Ok(())
250 })?;
251 w.create_element("m:sub").write_inner_content(|s| {
252 convert_content(s, below)?;
253 Ok(())
254 })?;
255 Ok(())
256 })?;
257 }
258 (None, None) => {
259 convert_content(writer, base)?;
261 }
262 }
263 Ok(())
264}
265
266fn is_nary_base(content: &Content) -> bool {
268 if let Some(sym) = content.to_packed::<SymbolElem>() {
269 let text = sym.text.as_str();
270 matches!(
271 text,
272 "\u{2211}" | "\u{220F}" | "\u{222B}" | "\u{222C}" | "\u{222D}" | "\u{222E}" | "\u{2210}" | "\u{22C0}" | "\u{22C1}" | "\u{22C2}" | "\u{22C3}" )
284 } else {
285 false
286 }
287}
288
289fn convert_nary<W: Write>(
291 writer: &mut Writer<W>,
292 base: &Content,
293 sub: Option<&Content>,
294 sup: Option<&Content>,
295) -> std::io::Result<()> {
296 let chr = if let Some(sym) = base.to_packed::<SymbolElem>() {
297 sym.text.to_string()
298 } else {
299 "\u{2211}".to_string()
300 };
301
302 writer.create_element("m:nary").write_inner_content(|w| {
303 w.create_element("m:naryPr").write_inner_content(|pr| {
304 pr.create_element("m:chr")
305 .with_attribute(("m:val", chr.as_str()))
306 .write_empty()?;
307 if sub.is_none() {
308 pr.create_element("m:subHide")
309 .with_attribute(("m:val", "1"))
310 .write_empty()?;
311 }
312 if sup.is_none() {
313 pr.create_element("m:supHide")
314 .with_attribute(("m:val", "1"))
315 .write_empty()?;
316 }
317 Ok(())
318 })?;
319 w.create_element("m:sub").write_inner_content(|s| {
320 if let Some(sub_content) = sub {
321 convert_content(s, sub_content)?;
322 }
323 Ok(())
324 })?;
325 w.create_element("m:sup").write_inner_content(|s| {
326 if let Some(sup_content) = sup {
327 convert_content(s, sup_content)?;
328 }
329 Ok(())
330 })?;
331 w.create_element("m:e").write_inner_content(|_| Ok(()))?;
334 Ok(())
335 })?;
336 Ok(())
337}
338
339fn convert_frac<W: Write>(writer: &mut Writer<W>, frac: &FracElem) -> std::io::Result<()> {
340 writer.create_element("m:f").write_inner_content(|w| {
341 w.create_element("m:num").write_inner_content(|n| {
342 convert_content(n, &frac.num)?;
343 Ok(())
344 })?;
345 w.create_element("m:den").write_inner_content(|d| {
346 convert_content(d, &frac.denom)?;
347 Ok(())
348 })?;
349 Ok(())
350 })?;
351 Ok(())
352}
353
354fn convert_lr<W: Write>(writer: &mut Writer<W>, lr: &LrElem) -> std::io::Result<()> {
355 let body = &lr.body;
356
357 let (open, close, inner) = extract_delimiters(body);
359
360 writer.create_element("m:d").write_inner_content(|w| {
361 w.create_element("m:dPr").write_inner_content(|pr| {
362 pr.create_element("m:begChr")
363 .with_attribute(("m:val", open.as_str()))
364 .write_empty()?;
365 pr.create_element("m:endChr")
366 .with_attribute(("m:val", close.as_str()))
367 .write_empty()?;
368 Ok(())
369 })?;
370 w.create_element("m:e").write_inner_content(|e| {
371 for item in &inner {
372 convert_content(e, item)?;
373 }
374 Ok(())
375 })?;
376 Ok(())
377 })?;
378 Ok(())
379}
380
381fn extract_delimiters(body: &Content) -> (String, String, Vec<&Content>) {
383 let mut open = "(".to_string();
384 let mut close = ")".to_string();
385 let mut inner = Vec::new();
386
387 if let Some(seq) = body.to_packed::<SequenceElem>() {
388 let children = &seq.children;
389 if children.is_empty() {
390 return (open, close, inner);
391 }
392
393 if let Some(sym) = children[0].to_packed::<SymbolElem>() {
395 open = sym.text.to_string();
396 }
397
398 if children.len() > 1
400 && let Some(sym) = children[children.len() - 1].to_packed::<SymbolElem>()
401 {
402 close = sym.text.to_string();
403 }
404
405 if children.len() > 2 {
407 for child in &children[1..children.len() - 1] {
408 inner.push(child);
409 }
410 }
411 } else {
412 inner.push(body);
414 }
415
416 (open, close, inner)
417}
418
419fn convert_root<W: Write>(writer: &mut Writer<W>, root: &RootElem) -> std::io::Result<()> {
420 let index = root.index.as_option().as_ref().and_then(|v| v.as_ref());
422
423 writer.create_element("m:rad").write_inner_content(|w| {
424 w.create_element("m:radPr").write_inner_content(|pr| {
425 if index.is_none() {
426 pr.create_element("m:degHide")
427 .with_attribute(("m:val", "1"))
428 .write_empty()?;
429 }
430 Ok(())
431 })?;
432 w.create_element("m:deg").write_inner_content(|d| {
433 if let Some(idx) = index {
434 convert_content(d, idx)?;
435 }
436 Ok(())
437 })?;
438 w.create_element("m:e").write_inner_content(|e| {
439 convert_content(e, &root.radicand)?;
440 Ok(())
441 })?;
442 Ok(())
443 })?;
444 Ok(())
445}
446
447fn convert_mat<W: Write>(writer: &mut Writer<W>, mat: &MatElem) -> std::io::Result<()> {
450 let (open, close) = if let Some(delim) = mat.delim.as_option().as_ref() {
452 (
453 delim.open().map_or_else(String::new, |c| c.to_string()),
454 delim.close().map_or_else(String::new, |c| c.to_string()),
455 )
456 } else {
457 ("(".to_string(), ")".to_string())
458 };
459
460 writer.create_element("m:d").write_inner_content(|w| {
461 w.create_element("m:dPr").write_inner_content(|pr| {
462 pr.create_element("m:begChr")
463 .with_attribute(("m:val", open.as_str()))
464 .write_empty()?;
465 pr.create_element("m:endChr")
466 .with_attribute(("m:val", close.as_str()))
467 .write_empty()?;
468 Ok(())
469 })?;
470 w.create_element("m:e").write_inner_content(|e| {
471 e.create_element("m:m").write_inner_content(|m| {
472 for row in &mat.rows {
473 m.create_element("m:mr").write_inner_content(|mr| {
474 for cell in row {
475 mr.create_element("m:e").write_inner_content(|ce| {
476 convert_content(ce, cell)?;
477 Ok(())
478 })?;
479 }
480 Ok(())
481 })?;
482 }
483 Ok(())
484 })?;
485 Ok(())
486 })?;
487 Ok(())
488 })?;
489 Ok(())
490}
491
492fn convert_vec<W: Write>(writer: &mut Writer<W>, vec_elem: &VecElem) -> std::io::Result<()> {
494 let (open, close) = if let Some(delim) = vec_elem.delim.as_option().as_ref() {
496 (
497 delim.open().map_or_else(String::new, |c| c.to_string()),
498 delim.close().map_or_else(String::new, |c| c.to_string()),
499 )
500 } else {
501 ("(".to_string(), ")".to_string())
502 };
503
504 writer.create_element("m:d").write_inner_content(|w| {
505 w.create_element("m:dPr").write_inner_content(|pr| {
506 pr.create_element("m:begChr")
507 .with_attribute(("m:val", open.as_str()))
508 .write_empty()?;
509 pr.create_element("m:endChr")
510 .with_attribute(("m:val", close.as_str()))
511 .write_empty()?;
512 Ok(())
513 })?;
514 w.create_element("m:e").write_inner_content(|e| {
515 e.create_element("m:m").write_inner_content(|m| {
516 for child in &vec_elem.children {
517 m.create_element("m:mr").write_inner_content(|mr| {
518 mr.create_element("m:e").write_inner_content(|ce| {
519 convert_content(ce, child)?;
520 Ok(())
521 })?;
522 Ok(())
523 })?;
524 }
525 Ok(())
526 })?;
527 Ok(())
528 })?;
529 Ok(())
530 })?;
531 Ok(())
532}
533
534fn convert_accent<W: Write>(writer: &mut Writer<W>, accent: &AccentElem) -> std::io::Result<()> {
536 let chr = accent_to_omml_char(accent.accent.0);
539
540 writer.create_element("m:acc").write_inner_content(|w| {
541 w.create_element("m:accPr").write_inner_content(|pr| {
542 pr.create_element("m:chr")
543 .with_attribute(("m:val", chr))
544 .write_empty()?;
545 Ok(())
546 })?;
547 w.create_element("m:e").write_inner_content(|e| {
548 convert_content(e, &accent.base)?;
549 Ok(())
550 })?;
551 Ok(())
552 })?;
553 Ok(())
554}
555
556fn accent_to_omml_char(c: char) -> &'static str {
562 match c {
563 '\u{0303}' => "\u{0303}", '\u{20D7}' => "\u{20D7}", '\u{0307}' => "\u{0307}", '\u{0308}' => "\u{0308}", '\u{0300}' => "\u{0300}", '\u{0301}' => "\u{0301}", '\u{0304}' => "\u{0304}", '\u{0305}' => "\u{0305}", '\u{0306}' => "\u{0306}", '\u{030A}' => "\u{030A}", '\u{030C}' => "\u{030C}", '\u{20DB}' => "\u{20DB}", '\u{20DC}' => "\u{20DC}", '\u{030B}' => "\u{030B}", '\u{20D6}' => "\u{20D6}", '\u{20E1}' => "\u{20E1}", '\u{20D0}' => "\u{20D0}", '\u{20D1}' => "\u{20D1}", _ => "\u{0302}",
583 }
584}
585
586fn convert_bar<W: Write>(writer: &mut Writer<W>, body: &Content, pos: &str) -> std::io::Result<()> {
588 writer.create_element("m:bar").write_inner_content(|w| {
589 w.create_element("m:barPr").write_inner_content(|pr| {
590 pr.create_element("m:pos")
591 .with_attribute(("m:val", pos))
592 .write_empty()?;
593 Ok(())
594 })?;
595 w.create_element("m:e").write_inner_content(|e| {
596 convert_content(e, body)?;
597 Ok(())
598 })?;
599 Ok(())
600 })?;
601 Ok(())
602}
603
604fn convert_op<W: Write>(writer: &mut Writer<W>, op: &OpElem) -> std::io::Result<()> {
608 let op_text = extract_text_content(&op.text);
611
612 writer.create_element("m:func").write_inner_content(|w| {
613 w.create_element("m:fName").write_inner_content(|fname| {
614 fname.create_element("m:r").write_inner_content(|r| {
615 r.create_element("m:rPr").write_inner_content(|rpr| {
616 rpr.create_element("m:sty")
617 .with_attribute(("m:val", "p"))
618 .write_empty()?;
619 Ok(())
620 })?;
621 r.create_element("m:t")
622 .write_text_content(BytesText::new(&op_text))?;
623 Ok(())
624 })?;
625 Ok(())
626 })?;
627 w.create_element("m:e").write_inner_content(|_| Ok(()))?;
630 Ok(())
631 })?;
632 Ok(())
633}
634
635fn extract_text_content(content: &Content) -> String {
637 if let Some(text) = content.to_packed::<TextElem>() {
638 return text.text.to_string();
639 }
640 if let Some(sym) = content.to_packed::<SymbolElem>() {
641 return sym.text.to_string();
642 }
643 if let Some(seq) = content.to_packed::<SequenceElem>() {
644 let mut result = String::new();
645 for child in &seq.children {
646 result.push_str(&extract_text_content(child));
647 }
648 return result;
649 }
650 String::new()
651}
652
653fn convert_cases<W: Write>(writer: &mut Writer<W>, cases: &CasesElem) -> std::io::Result<()> {
655 let is_reverse = *cases.reverse.as_option().as_ref().unwrap_or(&false);
656
657 let (delim_open, delim_close) = if let Some(delim) = cases.delim.as_option().as_ref() {
659 (
660 delim.open().map_or_else(String::new, |c| c.to_string()),
661 delim.close().map_or_else(String::new, |c| c.to_string()),
662 )
663 } else {
664 ("{".to_string(), "}".to_string())
665 };
666
667 let (open_str, close_str) = if is_reverse {
668 (delim_close, delim_open)
669 } else {
670 (delim_open, delim_close)
671 };
672
673 let effective_close = if is_reverse { close_str.as_str() } else { "" };
675 let effective_open = if is_reverse { "" } else { open_str.as_str() };
676
677 writer.create_element("m:d").write_inner_content(|w| {
678 w.create_element("m:dPr").write_inner_content(|pr| {
679 pr.create_element("m:begChr")
680 .with_attribute(("m:val", effective_open))
681 .write_empty()?;
682 pr.create_element("m:endChr")
683 .with_attribute(("m:val", effective_close))
684 .write_empty()?;
685 Ok(())
686 })?;
687 w.create_element("m:e").write_inner_content(|e| {
688 e.create_element("m:eqArr").write_inner_content(|arr| {
689 for child in &cases.children {
690 arr.create_element("m:e").write_inner_content(|ce| {
691 convert_content(ce, child)?;
692 Ok(())
693 })?;
694 }
695 Ok(())
696 })?;
697 Ok(())
698 })?;
699 Ok(())
700 })?;
701 Ok(())
702}
703
704fn convert_groupchr<W: Write>(
711 writer: &mut Writer<W>,
712 body: &Content,
713 annotation: Option<&Content>,
714 chr: &str,
715 pos: &str,
716) -> std::io::Result<()> {
717 let write_group = |w: &mut Writer<W>| -> std::io::Result<()> {
719 w.create_element("m:groupChr").write_inner_content(|gc| {
720 gc.create_element("m:groupChrPr")
721 .write_inner_content(|pr| {
722 pr.create_element("m:chr")
723 .with_attribute(("m:val", chr))
724 .write_empty()?;
725 pr.create_element("m:pos")
726 .with_attribute(("m:val", pos))
727 .write_empty()?;
728 pr.create_element("m:vertJc")
730 .with_attribute(("m:val", pos))
731 .write_empty()?;
732 Ok(())
733 })?;
734 gc.create_element("m:e").write_inner_content(|e| {
735 convert_content(e, body)?;
736 Ok(())
737 })?;
738 Ok(())
739 })?;
740 Ok(())
741 };
742
743 if let Some(ann) = annotation {
744 if pos == "bot" {
746 writer.create_element("m:limLow").write_inner_content(|w| {
747 w.create_element("m:e").write_inner_content(|e| {
748 write_group(e)?;
749 Ok(())
750 })?;
751 w.create_element("m:lim").write_inner_content(|lim| {
752 convert_content(lim, ann)?;
753 Ok(())
754 })?;
755 Ok(())
756 })?;
757 } else {
758 writer.create_element("m:limUpp").write_inner_content(|w| {
759 w.create_element("m:e").write_inner_content(|e| {
760 write_group(e)?;
761 Ok(())
762 })?;
763 w.create_element("m:lim").write_inner_content(|lim| {
764 convert_content(lim, ann)?;
765 Ok(())
766 })?;
767 Ok(())
768 })?;
769 }
770 } else {
771 write_group(writer)?;
772 }
773 Ok(())
774}
775
776fn write_math_run<W: Write>(writer: &mut Writer<W>, text: &str) -> std::io::Result<()> {
777 writer.create_element("m:r").write_inner_content(|w| {
778 w.create_element("m:t")
779 .write_text_content(BytesText::new(text))?;
780 Ok(())
781 })?;
782 Ok(())
783}
784
785#[cfg(test)]
786mod tests {
787 use super::*;
788
789 #[test]
790 fn test_write_math_run() {
791 let mut buf = Vec::new();
792 let mut writer = Writer::new_with_indent(&mut buf, b' ', 2);
793 write_math_run(&mut writer, "x").unwrap();
794 let result = String::from_utf8(buf).unwrap();
795 assert!(result.contains("<m:r>"));
796 assert!(result.contains("<m:t>x</m:t>"));
797 assert!(result.contains("</m:r>"));
798 }
799
800 #[test]
801 fn test_write_math_run_empty_string() {
802 let mut buf = Vec::new();
803 let mut writer = Writer::new_with_indent(&mut buf, b' ', 2);
804 write_math_run(&mut writer, "").unwrap();
805 let result = String::from_utf8(buf).unwrap();
806 assert!(result.contains("<m:r>"), "should still produce m:r element");
807 assert!(
808 result.contains("<m:t></m:t>") || result.contains("<m:t/>"),
809 "should produce empty m:t element, got: {result}"
810 );
811 }
812
813 #[test]
814 fn test_write_math_run_unicode() {
815 let mut buf = Vec::new();
816 let mut writer = Writer::new_with_indent(&mut buf, b' ', 2);
817 write_math_run(&mut writer, "\u{03B1}").unwrap(); let result = String::from_utf8(buf).unwrap();
819 assert!(
820 result.contains("<m:t>\u{03B1}</m:t>"),
821 "should contain Unicode alpha character, got: {result}"
822 );
823 }
824
825 #[test]
826 fn test_write_math_run_xml_special_chars() {
827 let mut buf = Vec::new();
828 let mut writer = Writer::new_with_indent(&mut buf, b' ', 2);
829 write_math_run(&mut writer, "&<>").unwrap();
830 let result = String::from_utf8(buf).unwrap();
831 assert!(
832 !result.contains("<m:t>&<></m:t>"),
833 "XML special chars should be escaped, got: {result}"
834 );
835 assert!(
836 result.contains("&"),
837 "ampersand should be escaped to &, got: {result}"
838 );
839 assert!(
840 result.contains("<"),
841 "less-than should be escaped to <, got: {result}"
842 );
843 assert!(
844 result.contains(">"),
845 "greater-than should be escaped to >, got: {result}"
846 );
847 }
848}