#![warn(clippy::pedantic)]
#![allow(clippy::module_name_repetitions)]
use std::io::Write;
use quick_xml::Writer;
use quick_xml::events::BytesText;
use typst::foundations::Content;
use typst_library::foundations::{SequenceElem, SymbolElem};
use typst_library::math::{
AccentElem, AlignPointElem, AttachElem, CasesElem, EquationElem, FracElem, LrElem, MatElem,
OpElem, OverbraceElem, OverbracketElem, OverlineElem, OverparenElem, OvershellElem, RootElem,
UnderbraceElem, UnderbracketElem, UnderlineElem, UnderparenElem, UndershellElem, VecElem,
};
use typst_library::text::{LinebreakElem, SpaceElem, TextElem};
#[must_use]
pub fn equation_to_omml(content: &Content) -> String {
let eq = content
.to_packed::<EquationElem>()
.expect("content must be an EquationElem");
let is_block = *eq.block.as_option().as_ref().unwrap_or(&false);
let body = &eq.body;
let mut buf = Vec::new();
let mut writer = Writer::new_with_indent(&mut buf, b' ', 2);
if is_block {
writer
.create_element("m:oMathPara")
.write_inner_content(|w| {
write_omath(w, body)?;
Ok(())
})
.expect("XML write failed");
} else {
write_omath(&mut writer, body).expect("XML write failed");
}
String::from_utf8(buf).expect("valid UTF-8")
}
fn write_omath<W: Write>(writer: &mut Writer<W>, body: &Content) -> std::io::Result<()> {
writer.create_element("m:oMath").write_inner_content(|w| {
if is_aligned_equation(body) {
convert_eq_array(w, body)?;
} else {
convert_content(w, body)?;
}
Ok(())
})?;
Ok(())
}
fn is_aligned_equation(content: &Content) -> bool {
if let Some(seq) = content.to_packed::<SequenceElem>() {
let has_align = seq
.children
.iter()
.any(|c| c.to_packed::<AlignPointElem>().is_some());
let has_linebreak = seq
.children
.iter()
.any(|c| c.to_packed::<LinebreakElem>().is_some());
has_align && has_linebreak
} else {
false
}
}
fn convert_eq_array<W: Write>(writer: &mut Writer<W>, body: &Content) -> std::io::Result<()> {
let seq = body
.to_packed::<SequenceElem>()
.expect("aligned equation body must be a SequenceElem");
let mut rows: Vec<Vec<&Content>> = vec![Vec::new()];
for child in &seq.children {
if child.to_packed::<LinebreakElem>().is_some() {
rows.push(Vec::new());
} else {
rows.last_mut().unwrap().push(child);
}
}
while rows.last().is_some_and(Vec::is_empty) {
rows.pop();
}
writer
.create_element("m:eqArr")
.write_inner_content(|arr| {
for row in &rows {
arr.create_element("m:e").write_inner_content(|e| {
for child in row {
if child.to_packed::<AlignPointElem>().is_some() {
write_math_run(e, "\u{0026}")?;
} else {
convert_content(e, child)?;
}
}
Ok(())
})?;
}
Ok(())
})?;
Ok(())
}
fn convert_content<W: Write>(writer: &mut Writer<W>, content: &Content) -> std::io::Result<()> {
if let Some(seq) = content.to_packed::<SequenceElem>() {
for child in &seq.children {
convert_content(writer, child)?;
}
} else if let Some(attach) = content.to_packed::<AttachElem>() {
convert_attach(writer, attach)?;
} else if let Some(frac) = content.to_packed::<FracElem>() {
convert_frac(writer, frac)?;
} else if let Some(lr) = content.to_packed::<LrElem>() {
convert_lr(writer, lr)?;
} else if let Some(root) = content.to_packed::<RootElem>() {
convert_root(writer, root)?;
} else if let Some(mat) = content.to_packed::<MatElem>() {
convert_mat(writer, mat)?;
} else if let Some(vec_elem) = content.to_packed::<VecElem>() {
convert_vec(writer, vec_elem)?;
} else if let Some(accent) = content.to_packed::<AccentElem>() {
convert_accent(writer, accent)?;
} else if let Some(overline) = content.to_packed::<OverlineElem>() {
convert_bar(writer, &overline.body, "top")?;
} else if let Some(underline) = content.to_packed::<UnderlineElem>() {
convert_bar(writer, &underline.body, "bot")?;
} else if let Some(op) = content.to_packed::<OpElem>() {
convert_op(writer, op)?;
} else if let Some(cases) = content.to_packed::<CasesElem>() {
convert_cases(writer, cases)?;
} else if let Some(ob) = content.to_packed::<OverbraceElem>() {
let ann = ob.annotation.as_option().as_ref().and_then(|v| v.as_ref());
convert_groupchr(writer, &ob.body, ann, "\u{23DE}", "top")?;
} else if let Some(ub) = content.to_packed::<UnderbraceElem>() {
let ann = ub.annotation.as_option().as_ref().and_then(|v| v.as_ref());
convert_groupchr(writer, &ub.body, ann, "\u{23DF}", "bot")?;
} else if let Some(ob) = content.to_packed::<OverbracketElem>() {
let ann = ob.annotation.as_option().as_ref().and_then(|v| v.as_ref());
convert_groupchr(writer, &ob.body, ann, "\u{23B4}", "top")?;
} else if let Some(ub) = content.to_packed::<UnderbracketElem>() {
let ann = ub.annotation.as_option().as_ref().and_then(|v| v.as_ref());
convert_groupchr(writer, &ub.body, ann, "\u{23B5}", "bot")?;
} else if let Some(op) = content.to_packed::<OverparenElem>() {
let ann = op.annotation.as_option().as_ref().and_then(|v| v.as_ref());
convert_groupchr(writer, &op.body, ann, "\u{23DC}", "top")?;
} else if let Some(up) = content.to_packed::<UnderparenElem>() {
let ann = up.annotation.as_option().as_ref().and_then(|v| v.as_ref());
convert_groupchr(writer, &up.body, ann, "\u{23DD}", "bot")?;
} else if let Some(os) = content.to_packed::<OvershellElem>() {
let ann = os.annotation.as_option().as_ref().and_then(|v| v.as_ref());
convert_groupchr(writer, &os.body, ann, "\u{23E0}", "top")?;
} else if let Some(us) = content.to_packed::<UndershellElem>() {
let ann = us.annotation.as_option().as_ref().and_then(|v| v.as_ref());
convert_groupchr(writer, &us.body, ann, "\u{23E1}", "bot")?;
} else if content.to_packed::<AlignPointElem>().is_some() {
} else if let Some(sym) = content.to_packed::<SymbolElem>() {
write_math_run(writer, &sym.text)?;
} else if let Some(text) = content.to_packed::<TextElem>() {
write_math_run(writer, &text.text)?;
} else if content.to_packed::<SpaceElem>().is_some() {
} else {
}
Ok(())
}
fn convert_attach<W: Write>(writer: &mut Writer<W>, attach: &AttachElem) -> std::io::Result<()> {
let base = &attach.base;
let sup = attach.t.as_option().as_ref().and_then(|v| v.as_ref());
let sub = attach.b.as_option().as_ref().and_then(|v| v.as_ref());
if is_nary_base(base) {
return convert_nary(writer, base, sub, sup);
}
match (sub, sup) {
(Some(below), Some(above)) => {
writer
.create_element("m:sSubSup")
.write_inner_content(|w| {
w.create_element("m:e").write_inner_content(|e| {
convert_content(e, base)?;
Ok(())
})?;
w.create_element("m:sub").write_inner_content(|s| {
convert_content(s, below)?;
Ok(())
})?;
w.create_element("m:sup").write_inner_content(|s| {
convert_content(s, above)?;
Ok(())
})?;
Ok(())
})?;
}
(None, Some(above)) => {
writer.create_element("m:sSup").write_inner_content(|w| {
w.create_element("m:e").write_inner_content(|e| {
convert_content(e, base)?;
Ok(())
})?;
w.create_element("m:sup").write_inner_content(|s| {
convert_content(s, above)?;
Ok(())
})?;
Ok(())
})?;
}
(Some(below), None) => {
writer.create_element("m:sSub").write_inner_content(|w| {
w.create_element("m:e").write_inner_content(|e| {
convert_content(e, base)?;
Ok(())
})?;
w.create_element("m:sub").write_inner_content(|s| {
convert_content(s, below)?;
Ok(())
})?;
Ok(())
})?;
}
(None, None) => {
convert_content(writer, base)?;
}
}
Ok(())
}
fn is_nary_base(content: &Content) -> bool {
if let Some(sym) = content.to_packed::<SymbolElem>() {
let text = sym.text.as_str();
matches!(
text,
"\u{2211}" | "\u{220F}" | "\u{222B}" | "\u{222C}" | "\u{222D}" | "\u{222E}" | "\u{2210}" | "\u{22C0}" | "\u{22C1}" | "\u{22C2}" | "\u{22C3}" )
} else {
false
}
}
fn convert_nary<W: Write>(
writer: &mut Writer<W>,
base: &Content,
sub: Option<&Content>,
sup: Option<&Content>,
) -> std::io::Result<()> {
let chr = if let Some(sym) = base.to_packed::<SymbolElem>() {
sym.text.to_string()
} else {
"\u{2211}".to_string()
};
writer.create_element("m:nary").write_inner_content(|w| {
w.create_element("m:naryPr").write_inner_content(|pr| {
pr.create_element("m:chr")
.with_attribute(("m:val", chr.as_str()))
.write_empty()?;
if sub.is_none() {
pr.create_element("m:subHide")
.with_attribute(("m:val", "1"))
.write_empty()?;
}
if sup.is_none() {
pr.create_element("m:supHide")
.with_attribute(("m:val", "1"))
.write_empty()?;
}
Ok(())
})?;
w.create_element("m:sub").write_inner_content(|s| {
if let Some(sub_content) = sub {
convert_content(s, sub_content)?;
}
Ok(())
})?;
w.create_element("m:sup").write_inner_content(|s| {
if let Some(sup_content) = sup {
convert_content(s, sup_content)?;
}
Ok(())
})?;
w.create_element("m:e").write_inner_content(|_| Ok(()))?;
Ok(())
})?;
Ok(())
}
fn convert_frac<W: Write>(writer: &mut Writer<W>, frac: &FracElem) -> std::io::Result<()> {
writer.create_element("m:f").write_inner_content(|w| {
w.create_element("m:num").write_inner_content(|n| {
convert_content(n, &frac.num)?;
Ok(())
})?;
w.create_element("m:den").write_inner_content(|d| {
convert_content(d, &frac.denom)?;
Ok(())
})?;
Ok(())
})?;
Ok(())
}
fn convert_lr<W: Write>(writer: &mut Writer<W>, lr: &LrElem) -> std::io::Result<()> {
let body = &lr.body;
let (open, close, inner) = extract_delimiters(body);
writer.create_element("m:d").write_inner_content(|w| {
w.create_element("m:dPr").write_inner_content(|pr| {
pr.create_element("m:begChr")
.with_attribute(("m:val", open.as_str()))
.write_empty()?;
pr.create_element("m:endChr")
.with_attribute(("m:val", close.as_str()))
.write_empty()?;
Ok(())
})?;
w.create_element("m:e").write_inner_content(|e| {
for item in &inner {
convert_content(e, item)?;
}
Ok(())
})?;
Ok(())
})?;
Ok(())
}
fn extract_delimiters(body: &Content) -> (String, String, Vec<&Content>) {
let mut open = "(".to_string();
let mut close = ")".to_string();
let mut inner = Vec::new();
if let Some(seq) = body.to_packed::<SequenceElem>() {
let children = &seq.children;
if children.is_empty() {
return (open, close, inner);
}
if let Some(sym) = children[0].to_packed::<SymbolElem>() {
open = sym.text.to_string();
}
if children.len() > 1
&& let Some(sym) = children[children.len() - 1].to_packed::<SymbolElem>()
{
close = sym.text.to_string();
}
if children.len() > 2 {
for child in &children[1..children.len() - 1] {
inner.push(child);
}
}
} else {
inner.push(body);
}
(open, close, inner)
}
fn convert_root<W: Write>(writer: &mut Writer<W>, root: &RootElem) -> std::io::Result<()> {
let index = root.index.as_option().as_ref().and_then(|v| v.as_ref());
writer.create_element("m:rad").write_inner_content(|w| {
w.create_element("m:radPr").write_inner_content(|pr| {
if index.is_none() {
pr.create_element("m:degHide")
.with_attribute(("m:val", "1"))
.write_empty()?;
}
Ok(())
})?;
w.create_element("m:deg").write_inner_content(|d| {
if let Some(idx) = index {
convert_content(d, idx)?;
}
Ok(())
})?;
w.create_element("m:e").write_inner_content(|e| {
convert_content(e, &root.radicand)?;
Ok(())
})?;
Ok(())
})?;
Ok(())
}
fn convert_mat<W: Write>(writer: &mut Writer<W>, mat: &MatElem) -> std::io::Result<()> {
let (open, close) = if let Some(delim) = mat.delim.as_option().as_ref() {
(
delim.open().map_or_else(String::new, |c| c.to_string()),
delim.close().map_or_else(String::new, |c| c.to_string()),
)
} else {
("(".to_string(), ")".to_string())
};
writer.create_element("m:d").write_inner_content(|w| {
w.create_element("m:dPr").write_inner_content(|pr| {
pr.create_element("m:begChr")
.with_attribute(("m:val", open.as_str()))
.write_empty()?;
pr.create_element("m:endChr")
.with_attribute(("m:val", close.as_str()))
.write_empty()?;
Ok(())
})?;
w.create_element("m:e").write_inner_content(|e| {
e.create_element("m:m").write_inner_content(|m| {
for row in &mat.rows {
m.create_element("m:mr").write_inner_content(|mr| {
for cell in row {
mr.create_element("m:e").write_inner_content(|ce| {
convert_content(ce, cell)?;
Ok(())
})?;
}
Ok(())
})?;
}
Ok(())
})?;
Ok(())
})?;
Ok(())
})?;
Ok(())
}
fn convert_vec<W: Write>(writer: &mut Writer<W>, vec_elem: &VecElem) -> std::io::Result<()> {
let (open, close) = if let Some(delim) = vec_elem.delim.as_option().as_ref() {
(
delim.open().map_or_else(String::new, |c| c.to_string()),
delim.close().map_or_else(String::new, |c| c.to_string()),
)
} else {
("(".to_string(), ")".to_string())
};
writer.create_element("m:d").write_inner_content(|w| {
w.create_element("m:dPr").write_inner_content(|pr| {
pr.create_element("m:begChr")
.with_attribute(("m:val", open.as_str()))
.write_empty()?;
pr.create_element("m:endChr")
.with_attribute(("m:val", close.as_str()))
.write_empty()?;
Ok(())
})?;
w.create_element("m:e").write_inner_content(|e| {
e.create_element("m:m").write_inner_content(|m| {
for child in &vec_elem.children {
m.create_element("m:mr").write_inner_content(|mr| {
mr.create_element("m:e").write_inner_content(|ce| {
convert_content(ce, child)?;
Ok(())
})?;
Ok(())
})?;
}
Ok(())
})?;
Ok(())
})?;
Ok(())
})?;
Ok(())
}
fn convert_accent<W: Write>(writer: &mut Writer<W>, accent: &AccentElem) -> std::io::Result<()> {
let chr = accent_to_omml_char(accent.accent.0);
writer.create_element("m:acc").write_inner_content(|w| {
w.create_element("m:accPr").write_inner_content(|pr| {
pr.create_element("m:chr")
.with_attribute(("m:val", chr))
.write_empty()?;
Ok(())
})?;
w.create_element("m:e").write_inner_content(|e| {
convert_content(e, &accent.base)?;
Ok(())
})?;
Ok(())
})?;
Ok(())
}
fn accent_to_omml_char(c: char) -> &'static str {
match c {
'\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}",
}
}
fn convert_bar<W: Write>(writer: &mut Writer<W>, body: &Content, pos: &str) -> std::io::Result<()> {
writer.create_element("m:bar").write_inner_content(|w| {
w.create_element("m:barPr").write_inner_content(|pr| {
pr.create_element("m:pos")
.with_attribute(("m:val", pos))
.write_empty()?;
Ok(())
})?;
w.create_element("m:e").write_inner_content(|e| {
convert_content(e, body)?;
Ok(())
})?;
Ok(())
})?;
Ok(())
}
fn convert_op<W: Write>(writer: &mut Writer<W>, op: &OpElem) -> std::io::Result<()> {
let op_text = extract_text_content(&op.text);
writer.create_element("m:func").write_inner_content(|w| {
w.create_element("m:fName").write_inner_content(|fname| {
fname.create_element("m:r").write_inner_content(|r| {
r.create_element("m:rPr").write_inner_content(|rpr| {
rpr.create_element("m:sty")
.with_attribute(("m:val", "p"))
.write_empty()?;
Ok(())
})?;
r.create_element("m:t")
.write_text_content(BytesText::new(&op_text))?;
Ok(())
})?;
Ok(())
})?;
w.create_element("m:e").write_inner_content(|_| Ok(()))?;
Ok(())
})?;
Ok(())
}
fn extract_text_content(content: &Content) -> String {
if let Some(text) = content.to_packed::<TextElem>() {
return text.text.to_string();
}
if let Some(sym) = content.to_packed::<SymbolElem>() {
return sym.text.to_string();
}
if let Some(seq) = content.to_packed::<SequenceElem>() {
let mut result = String::new();
for child in &seq.children {
result.push_str(&extract_text_content(child));
}
return result;
}
String::new()
}
fn convert_cases<W: Write>(writer: &mut Writer<W>, cases: &CasesElem) -> std::io::Result<()> {
let is_reverse = *cases.reverse.as_option().as_ref().unwrap_or(&false);
let (delim_open, delim_close) = if let Some(delim) = cases.delim.as_option().as_ref() {
(
delim.open().map_or_else(String::new, |c| c.to_string()),
delim.close().map_or_else(String::new, |c| c.to_string()),
)
} else {
("{".to_string(), "}".to_string())
};
let (open_str, close_str) = if is_reverse {
(delim_close, delim_open)
} else {
(delim_open, delim_close)
};
let effective_close = if is_reverse { close_str.as_str() } else { "" };
let effective_open = if is_reverse { "" } else { open_str.as_str() };
writer.create_element("m:d").write_inner_content(|w| {
w.create_element("m:dPr").write_inner_content(|pr| {
pr.create_element("m:begChr")
.with_attribute(("m:val", effective_open))
.write_empty()?;
pr.create_element("m:endChr")
.with_attribute(("m:val", effective_close))
.write_empty()?;
Ok(())
})?;
w.create_element("m:e").write_inner_content(|e| {
e.create_element("m:eqArr").write_inner_content(|arr| {
for child in &cases.children {
arr.create_element("m:e").write_inner_content(|ce| {
convert_content(ce, child)?;
Ok(())
})?;
}
Ok(())
})?;
Ok(())
})?;
Ok(())
})?;
Ok(())
}
fn convert_groupchr<W: Write>(
writer: &mut Writer<W>,
body: &Content,
annotation: Option<&Content>,
chr: &str,
pos: &str,
) -> std::io::Result<()> {
let write_group = |w: &mut Writer<W>| -> std::io::Result<()> {
w.create_element("m:groupChr").write_inner_content(|gc| {
gc.create_element("m:groupChrPr")
.write_inner_content(|pr| {
pr.create_element("m:chr")
.with_attribute(("m:val", chr))
.write_empty()?;
pr.create_element("m:pos")
.with_attribute(("m:val", pos))
.write_empty()?;
pr.create_element("m:vertJc")
.with_attribute(("m:val", pos))
.write_empty()?;
Ok(())
})?;
gc.create_element("m:e").write_inner_content(|e| {
convert_content(e, body)?;
Ok(())
})?;
Ok(())
})?;
Ok(())
};
if let Some(ann) = annotation {
if pos == "bot" {
writer.create_element("m:limLow").write_inner_content(|w| {
w.create_element("m:e").write_inner_content(|e| {
write_group(e)?;
Ok(())
})?;
w.create_element("m:lim").write_inner_content(|lim| {
convert_content(lim, ann)?;
Ok(())
})?;
Ok(())
})?;
} else {
writer.create_element("m:limUpp").write_inner_content(|w| {
w.create_element("m:e").write_inner_content(|e| {
write_group(e)?;
Ok(())
})?;
w.create_element("m:lim").write_inner_content(|lim| {
convert_content(lim, ann)?;
Ok(())
})?;
Ok(())
})?;
}
} else {
write_group(writer)?;
}
Ok(())
}
fn write_math_run<W: Write>(writer: &mut Writer<W>, text: &str) -> std::io::Result<()> {
writer.create_element("m:r").write_inner_content(|w| {
w.create_element("m:t")
.write_text_content(BytesText::new(text))?;
Ok(())
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_write_math_run() {
let mut buf = Vec::new();
let mut writer = Writer::new_with_indent(&mut buf, b' ', 2);
write_math_run(&mut writer, "x").unwrap();
let result = String::from_utf8(buf).unwrap();
assert!(result.contains("<m:r>"));
assert!(result.contains("<m:t>x</m:t>"));
assert!(result.contains("</m:r>"));
}
#[test]
fn test_write_math_run_empty_string() {
let mut buf = Vec::new();
let mut writer = Writer::new_with_indent(&mut buf, b' ', 2);
write_math_run(&mut writer, "").unwrap();
let result = String::from_utf8(buf).unwrap();
assert!(result.contains("<m:r>"), "should still produce m:r element");
assert!(
result.contains("<m:t></m:t>") || result.contains("<m:t/>"),
"should produce empty m:t element, got: {result}"
);
}
#[test]
fn test_write_math_run_unicode() {
let mut buf = Vec::new();
let mut writer = Writer::new_with_indent(&mut buf, b' ', 2);
write_math_run(&mut writer, "\u{03B1}").unwrap(); let result = String::from_utf8(buf).unwrap();
assert!(
result.contains("<m:t>\u{03B1}</m:t>"),
"should contain Unicode alpha character, got: {result}"
);
}
#[test]
fn test_write_math_run_xml_special_chars() {
let mut buf = Vec::new();
let mut writer = Writer::new_with_indent(&mut buf, b' ', 2);
write_math_run(&mut writer, "&<>").unwrap();
let result = String::from_utf8(buf).unwrap();
assert!(
!result.contains("<m:t>&<></m:t>"),
"XML special chars should be escaped, got: {result}"
);
assert!(
result.contains("&"),
"ampersand should be escaped to &, got: {result}"
);
assert!(
result.contains("<"),
"less-than should be escaped to <, got: {result}"
);
assert!(
result.contains(">"),
"greater-than should be escaped to >, got: {result}"
);
}
}