Skip to main content

typort_math/
lib.rs

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/// Convert a Typst `EquationElem` Content into an OMML XML string.
18///
19/// Returns the complete `<m:oMath>` (inline) or `<m:oMathPara><m:oMath>` (block) element.
20///
21/// # Panics
22/// Panics if the content is not an `EquationElem`.
23#[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    // block field: Settable<bool>, as_option() -> &Option<bool>
30    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        // Check if the body is a multi-line aligned equation (has AlignPointElem + LinebreakElem)
54        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
64/// Check if a content tree represents a multi-line aligned equation.
65///
66/// Returns `true` when the top-level sequence contains both `AlignPointElem`
67/// (alignment `&`) and `LinebreakElem` (line break `\`).
68fn 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
84/// Convert a multi-line aligned equation to `m:eqArr`.
85///
86/// The body is split at `LinebreakElem` boundaries into rows. Each row
87/// becomes an `<m:e>` inside the equation array. Within each row,
88/// `AlignPointElem` is emitted as an ampersand `&` character which OMML
89/// uses as an alignment tab stop inside `eqArr`.
90fn 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    // Split children at LinebreakElem boundaries
96    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    // Remove trailing empty rows (can happen if there's a trailing linebreak)
106    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                            // Emit ampersand as OMML alignment tab inside eqArr
118                            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    // Check what type the content is and dispatch accordingly
133    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        // Alignment points inside equation arrays are handled by the parent;
185        // standalone occurrences are skipped (no OMML equivalent).
186    } 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        // OMML handles inter-element spacing automatically — don't emit explicit spaces
192    } else {
193        // For unknown elements, skip silently (styled wrappers, etc.)
194    }
195    Ok(())
196}
197
198fn convert_attach<W: Write>(writer: &mut Writer<W>, attach: &AttachElem) -> std::io::Result<()> {
199    let base = &attach.base;
200    // t and b are Settable<Option<Content>>, as_option() -> &Option<Option<Content>>
201    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    // Check if this is a nary operator (sum, integral, product, etc.)
205    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            // Both sub and super: m:sSubSup
212            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            // Superscript only: m:sSup
232            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            // Subscript only: m:sSub
246            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            // No scripts, just render the base
260            convert_content(writer, base)?;
261        }
262    }
263    Ok(())
264}
265
266/// Check if the base of an `AttachElem` is a nary operator (sum, product, integral, etc.)
267fn 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}" // summation
273                | "\u{220F}" // product
274                | "\u{222B}" // integral
275                | "\u{222C}" // double integral
276                | "\u{222D}" // triple integral
277                | "\u{222E}" // contour integral
278                | "\u{2210}" // coproduct
279                | "\u{22C0}" // big wedge
280                | "\u{22C1}" // big vee
281                | "\u{22C2}" // big intersection
282                | "\u{22C3}" // big union
283        )
284    } else {
285        false
286    }
287}
288
289/// Convert a nary (big operator) with sub/superscripts to m:nary
290fn 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        // The nary body in OMML wraps subsequent content; in Typst,
332        // the following content is separate in the sequence. Leave body empty.
333        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    // Extract delimiters and inner content from the body sequence
358    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
381/// Extract open/close delimiters and inner content from a `LrElem` body.
382fn 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        // First child is the opening delimiter
394        if let Some(sym) = children[0].to_packed::<SymbolElem>() {
395            open = sym.text.to_string();
396        }
397
398        // Last child is the closing delimiter
399        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        // Everything in between is the inner content
406        if children.len() > 2 {
407            for child in &children[1..children.len() - 1] {
408                inner.push(child);
409            }
410        }
411    } else {
412        // Non-sequence body, just use as inner content
413        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    // index is Settable<Option<Content>>, as_option() -> &Option<Option<Content>>
421    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
447/// Convert a `MatElem` (matrix) to `m:m` with `m:mr` rows and `m:e` cells.
448/// The matrix is wrapped in `m:d` delimiters matching the Typst delimiter pair.
449fn convert_mat<W: Write>(writer: &mut Writer<W>, mat: &MatElem) -> std::io::Result<()> {
450    // MatElem default delimiter is PAREN: ( )
451    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
492/// Convert a `VecElem` (column vector) to `m:d` wrapping `m:m` with one column.
493fn convert_vec<W: Write>(writer: &mut Writer<W>, vec_elem: &VecElem) -> std::io::Result<()> {
494    // VecElem default delimiter is PAREN: ( )
495    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
534/// Convert an `AccentElem` to `m:acc` with the appropriate combining character.
535fn convert_accent<W: Write>(writer: &mut Writer<W>, accent: &AccentElem) -> std::io::Result<()> {
536    // Map the Typst Accent to the OMML combining character.
537    // OMML expects the combining Unicode character for the accent.
538    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
556/// Map a Typst accent character to the OMML accent character string.
557///
558/// Typst normalizes accents to their combining Unicode form. OMML also
559/// expects combining characters, so in most cases the character is used
560/// directly. We handle the common cases explicitly to ensure correctness.
561fn accent_to_omml_char(c: char) -> &'static str {
562    match c {
563        '\u{0303}' => "\u{0303}", // tilde
564        '\u{20D7}' => "\u{20D7}", // combining right arrow above (vec)
565        '\u{0307}' => "\u{0307}", // dot above
566        '\u{0308}' => "\u{0308}", // diaeresis / double dot
567        '\u{0300}' => "\u{0300}", // grave
568        '\u{0301}' => "\u{0301}", // acute
569        '\u{0304}' => "\u{0304}", // macron
570        '\u{0305}' => "\u{0305}", // overline / dash
571        '\u{0306}' => "\u{0306}", // breve
572        '\u{030A}' => "\u{030A}", // ring above
573        '\u{030C}' => "\u{030C}", // caron / háček
574        '\u{20DB}' => "\u{20DB}", // triple dot
575        '\u{20DC}' => "\u{20DC}", // quad dot
576        '\u{030B}' => "\u{030B}", // double acute
577        '\u{20D6}' => "\u{20D6}", // left arrow
578        '\u{20E1}' => "\u{20E1}", // left-right arrow
579        '\u{20D0}' => "\u{20D0}", // left harpoon
580        '\u{20D1}' => "\u{20D1}", // right harpoon
581        // Default: use combining circumflex (U+0302) as fallback — also covers hat
582        _ => "\u{0302}",
583    }
584}
585
586/// Convert `OverlineElem`/`UnderlineElem` to `m:bar` with position top/bot.
587fn 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
604/// Convert an `OpElem` (named math operator like sin, cos, lim) to `m:func`.
605///
606/// The function name is rendered in plain (upright) style via `m:sty m:val="p"`.
607fn convert_op<W: Write>(writer: &mut Writer<W>, op: &OpElem) -> std::io::Result<()> {
608    // Extract the operator text from the OpElem's text field.
609    // OpElem.text is a Content that wraps a TextElem with the operator name.
610    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        // OpElem in Typst is standalone — the argument is external in the
628        // Content tree (attached via AttachElem or adjacent). Emit empty body.
629        w.create_element("m:e").write_inner_content(|_| Ok(()))?;
630        Ok(())
631    })?;
632    Ok(())
633}
634
635/// Recursively extract plain text from a Content tree.
636fn 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
653/// Convert a `CasesElem` to `m:d` (left brace) wrapping `m:eqArr`.
654fn 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    // CasesElem default delimiter is BRACE: { }
658    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    // For standard (non-reverse) cases, suppress the closing delimiter
674    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
704/// Convert overbrace/underbrace/overbracket/underbracket/overparen/underparen/
705/// overshell/undershell to `m:groupChr`.
706///
707/// If there is an annotation, we wrap it with the group character using
708/// `m:limLow` (for "bot" position) or `m:limUpp` (for "top" position) to
709/// place the annotation below/above the group character structure.
710fn 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    // The groupChr element itself
718    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                    // vertJc controls where the character sits relative to the base
729                    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        // Wrap in m:limLow (bottom annotation) or m:limUpp (top annotation)
745        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(); // alpha
818        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("&amp;"),
837            "ampersand should be escaped to &amp;, got: {result}"
838        );
839        assert!(
840            result.contains("&lt;"),
841            "less-than should be escaped to &lt;, got: {result}"
842        );
843        assert!(
844            result.contains("&gt;"),
845            "greater-than should be escaped to &gt;, got: {result}"
846        );
847    }
848}