use super::{MATH_NS, find_children, math_child, math_run_text, math_val, parse_on_off};
pub(super) fn speak(omath: roxmltree::Node) -> String {
let mut words = Vec::new();
speak_into(omath, &mut words);
words.join(" ")
}
fn speak_into(node: roxmltree::Node, out: &mut Vec<String>) {
for c in node
.children()
.filter(|n| n.tag_name().namespace() == Some(MATH_NS))
{
let part = |name: &str, out: &mut Vec<String>| {
if let Some(n) = math_child(c, name) {
speak_into(n, out);
}
};
let said = |word: &str, name: &str, out: &mut Vec<String>| {
let filled = math_child(c, name).filter(|n| n.descendants().any(|t| t.is_text()));
if let Some(n) = filled {
out.push(word.into());
speak_into(n, out);
}
filled.is_some()
};
match c.tag_name().name() {
"r" => speak_run(c, out),
"f" => {
part("num", out);
out.push("over".into());
part("den", out);
}
"rad" => {
let hidden = math_child(c, "radPr")
.and_then(|p| math_child(p, "degHide"))
.is_some_and(|h| h.attribute((MATH_NS, "val")).is_none_or(parse_on_off));
if hidden || !said("root", "deg", out) {
out.push("square root of".into());
} else {
out.push("of".into());
}
part("e", out);
}
"sSub" | "sSup" | "sSubSup" => {
part("e", out);
said("sub", "sub", out);
said("to the", "sup", out);
}
"d" => {
let beg = math_val(c, "dPr", "begChr").unwrap_or("(");
let end = math_val(c, "dPr", "endChr").unwrap_or(")");
out.extend(delimiter(beg, "open"));
for (i, e) in find_children(c, "e", MATH_NS).enumerate() {
if i > 0 {
out.push("comma".into());
}
speak_into(e, out);
}
out.extend(delimiter(end, "close"));
}
"nary" => {
let op = match math_val(c, "naryPr", "chr").unwrap_or("∫") {
"∑" => "sum",
"∏" => "product",
"∮" => "contour integral",
"∫" => "integral",
other => other,
};
out.push(op.into());
said("from", "sub", out);
said("to", "sup", out);
out.push("of".into());
part("e", out);
}
name if name.ends_with("Pr") => {}
_ => speak_into(c, out),
}
}
}
fn delimiter(chr: &str, side: &str) -> Option<String> {
let name = match chr {
"" => return None,
"(" | ")" => "paren",
"[" | "]" => "bracket",
"{" | "}" => "brace",
"|" => return Some("vertical bar".into()),
other => return Some(other.into()),
};
Some(format!("{side} {name}"))
}
fn speak_run(r: roxmltree::Node, out: &mut Vec<String>) {
let text = math_run_text(r);
let sty = math_val(r, "rPr", "sty");
if sty == Some("p")
|| math_child(r, "rPr")
.and_then(|p| math_child(p, "nor"))
.is_some()
{
out.extend(text.split_whitespace().map(str::to_string));
return;
}
let bold = matches!(sty, Some("b" | "bi"));
let mut number = String::new();
for ch in text.chars() {
if ch.is_ascii_digit() && !bold {
number.push(ch);
continue;
}
if !number.is_empty() {
out.push(std::mem::take(&mut number));
}
if ch.is_whitespace() {
continue;
}
let (word, prefix) = if let Some(w) = symbol_word(ch) {
(w.to_string(), None)
} else if let Some(w) = letter_word(ch) {
let style = if sty == Some("bi") {
"bold italic"
} else {
"bold"
};
(w, bold.then_some(style))
} else {
(
ch.to_string(),
(bold && ch.is_ascii_digit()).then_some("bold"),
)
};
out.extend(prefix.map(str::to_string));
out.push(word);
}
if !number.is_empty() {
out.push(number);
}
}
fn letter_word(ch: char) -> Option<String> {
const GREEK: [&str; 25] = [
"alpha",
"beta",
"gamma",
"delta",
"epsilon",
"zeta",
"eta",
"theta",
"iota",
"kappa",
"lambda",
"mu",
"nu",
"xi",
"omicron",
"pi",
"rho",
"final sigma",
"sigma",
"tau",
"upsilon",
"phi",
"chi",
"psi",
"omega",
];
let (name, cap) = match ch {
'a'..='z' => (ch.to_string(), false),
'A'..='Z' => (ch.to_string(), true),
'α'..='ω' => (GREEK[ch as usize - 'α' as usize].to_string(), false),
'Α'..='Ω' if ch != '\u{3A2}' => (GREEK[ch as usize - 'Α' as usize].to_string(), true),
'ϑ' => ("script theta".into(), false),
'ϕ' => ("phi".into(), false),
'ϵ' => ("epsilon".into(), false),
_ => return None,
};
Some(if cap { format!("cap {name}") } else { name })
}
fn symbol_word(ch: char) -> Option<&'static str> {
Some(match ch {
'=' => "equals",
'+' => "plus",
'-' | '−' => "minus",
'±' => "plus or minus",
'∓' => "minus or plus",
'×' => "times",
'·' | '⋅' | '∙' => "dot",
'÷' => "divided by",
'/' => "slash",
'<' => "less than",
'>' => "greater than",
'≤' => "less than or equal to",
'≥' => "greater than or equal to",
'≪' => "much less than",
'≫' => "much greater than",
'≠' => "not equal to",
'≈' => "almost equal to",
'≡' => "identical to",
'∝' => "proportional to",
'∞' => "infinity",
'∆' => "increment",
'∂' => "partial",
'∇' => "nabla",
'→' => "right arrow",
'√' => "square root",
'%' => "percent",
'°' => "degrees",
'′' => "prime",
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn spoken(omml: &str) -> String {
let xml = format!(
r#"<m:oMath xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">{omml}</m:oMath>"#
);
let doc = roxmltree::Document::parse(&xml).unwrap();
speak(doc.root_element())
}
#[test]
fn speaks_like_word() {
assert_eq!(
spoken(
"<m:r><m:t>T=2π</m:t></m:r><m:rad><m:deg><m:r><m:t>2</m:t></m:r></m:deg>\
<m:e><m:f><m:num><m:r><m:t>l</m:t></m:r></m:num>\
<m:den><m:r><m:t>g</m:t></m:r></m:den></m:f></m:e></m:rad>"
),
"cap T equals 2 pi root 2 of l over g"
);
assert_eq!(
spoken(
"<m:r><m:t>ϑ≪</m:t></m:r><m:f><m:num><m:r><m:t>π</m:t></m:r></m:num>\
<m:den><m:r><m:t>2</m:t></m:r></m:den></m:f>"
),
"script theta much less than pi over 2"
);
let bi = r#"<m:rPr><m:sty m:val="bi"/></m:rPr>"#;
let b = r#"<m:rPr><m:sty m:val="b"/></m:rPr>"#;
assert_eq!(
spoken(&format!("<m:r>{bi}<m:t>l±∆l</m:t></m:r>")),
"bold italic l plus or minus increment bold italic l"
);
let t10 = format!(
"<m:sSub><m:e><m:r>{bi}<m:t>T</m:t></m:r></m:e><m:sub><m:r>{bi}<m:t>10</m:t></m:r></m:sub></m:sSub>"
);
assert_eq!(
spoken(&format!(
"{t10}<m:r>{bi}<m:t>±</m:t></m:r><m:r>{b}<m:t>Δ</m:t></m:r>{t10}"
)),
"bold italic cap T sub bold 1 bold 0 plus or minus bold cap delta \
bold italic cap T sub bold 1 bold 0"
);
assert_eq!(
spoken(&format!(
"<m:sSubSup><m:e><m:r>{bi}<m:t>T</m:t></m:r></m:e><m:sub><m:r>{bi}<m:t>1</m:t></m:r></m:sub>\
<m:sup><m:r>{bi}<m:t>2</m:t></m:r></m:sup></m:sSubSup>"
)),
"bold italic cap T sub bold 1 to the bold 2"
);
assert_eq!(spoken("<m:r><m:t>l </m:t></m:r>"), "l");
assert_eq!(spoken("<m:r><m:t> </m:t></m:r>"), "");
}
}