Files
wordcraft/crates/docx/src/read/math.rs
T
dec24458cf #1273: equations from Word files keep all their content (#2613)
- Math straight in the body or a cell (m:oMathPara/m:oMath without w:p,
  allowed by EG_BlockLevelElts) gets its own paragraph instead of vanishing.
- mc:AlternateContent Requires="m" picks the equation, not its fallback.
- Inside an equation, runs in w:hyperlink, w:fldSimple, w:dir, w:bdo and
  mc:AlternateContent are kept (EG_PContentMath); depth stays bounded.
- w:sym, w:noBreakHyphen and Symbol-font text in math runs become Unicode.
- Layout: n-ary operators, delimiters, accents and group characters the math
  font lacks use a fallback font instead of a missing-glyph box.
- Coverage test reads every ECMA-376 §22.1 element and checks no argument's
  text is dropped; tests for body-level math, Requires="m", symbols, hostile
  nesting and the font fallback. Parity docs and progress log updated.


Claude-Session: https://claude.ai/code/session_01P8veLveuUsJJm6UEcQPEuW

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:45:01 -04:00

497 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Office Math (OMML, ECMA-376 Part 1 §22.1) → [`wordcraft_doc::math`] tree.
use wordcraft_doc::math::{
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, MathProps, ScriptKind, merge_runs,
};
use wordcraft_doc::props::Rgb;
use wordcraft_doc::symbol_fonts;
use crate::xml::El;
/// An `m:oMath` element as an equation (structure plus its source XML).
pub fn read_omath(e: &El, jc: MathJc) -> Math {
Math { nodes: merge_runs(arg(e, 0)), omml: e.to_xml(), jc, linear: false }
}
/// A colour every math run shares, taken off the runs: it is the equation's own colour, as
/// [`write_equation`](crate::write::math::write_equation) spreads it (#1467). The source OMML
/// is kept: the writer replaces it only when the colour changes.
pub fn lift_color(math: &mut Math) -> Option<Rgb> {
let (mut shared, mut uniform) = (None, true);
wordcraft_doc::math_edit::each_run_mut(&mut math.nodes, &mut |r| match shared {
None => shared = Some(r.color),
Some(c) => uniform &= c == r.color,
});
let c = shared.flatten().filter(|_| uniform)?;
wordcraft_doc::math_edit::each_run_mut(&mut math.nodes, &mut |r| r.color = None);
Some(c)
}
/// `m:oMathParaPr/m:jc`.
pub fn para_jc(para: &El) -> MathJc {
match para.child("m:oMathParaPr").and_then(|p| p.child("m:jc")).and_then(|j| j.attr("m:val")) {
Some("left") => MathJc::Left,
Some("right") => MathJc::Right,
Some("center") => MathJc::Center,
_ => MathJc::CenterGroup,
}
}
/// The settings part's `m:mathPr`: how display equations wrap.
pub fn read_math_pr(e: &El) -> MathProps {
let mut m = MathProps::default();
let pr = Some(e);
m.brk_bin = match val(pr, "m:brkBin") {
Some("after") => BrkBin::After,
Some("repeat") => BrkBin::Repeat,
_ => BrkBin::Before,
};
m.brk_bin_sub = match val(pr, "m:brkBinSub") {
Some("-+") => BrkBinSub::MinusPlus,
Some("+-") => BrkBinSub::PlusMinus,
_ => BrkBinSub::MinusMinus,
};
if let Some(v) = e.child("m:wrapIndent").and_then(|c| crate::units::tw(c, "m:val")).filter(|v| v.is_finite() && *v >= 0.0) {
m.wrap_indent = v.min(1584.0);
}
m.wrap_right = prop(pr, "m:wrapRight", false);
m
}
/// OMML on/off property: absent `m:val` = on.
fn on(e: &El) -> bool {
match e.attr("m:val") {
None => true,
Some(v) => !matches!(v.trim(), "0" | "false" | "off" | "False" | "FALSE"),
}
}
/// An on/off property `name` in the properties element `pr` (absent = `default`).
fn prop(pr: Option<&El>, name: &str, default: bool) -> bool {
pr.and_then(|p| p.child(name)).map(on).unwrap_or(default)
}
/// A character property (`m:chr`, `m:begChr`…): `None` when absent, `Some(None)` when empty.
fn chr(pr: Option<&El>, name: &str) -> Option<Option<char>> {
let c = pr?.child(name)?;
match c.attr("m:val") {
Some(v) => Some(v.chars().next()),
None => Some(None),
}
}
fn val<'a>(pr: Option<&'a El>, name: &str) -> Option<&'a str> {
pr.and_then(|p| p.child(name)).and_then(|c| c.attr("m:val"))
}
/// The argument held by child `name` of `e`.
fn sub_arg(e: &El, name: &str, depth: usize) -> Arg {
e.child(name).map(|c| arg(c, depth + 1)).unwrap_or_default()
}
/// Read the math content of an element (`m:oMath`, `m:e`, `m:num`…).
fn arg(e: &El, depth: usize) -> Arg {
let mut out = Vec::new();
if depth > MAX_DEPTH {
return out;
}
for k in e.els() {
node(k, depth, &mut out);
}
out
}
fn node(k: &El, depth: usize, out: &mut Arg) {
let d = depth + 1;
match k.name.as_str() {
"m:r" => {
if let Some(r) = run(k) {
out.push(MNode::Run(r));
}
}
// Word puts ordinary runs in math zones for normal text in some files.
"w:r" => {
let text = run_text(k);
if !text.is_empty() {
let mut r = MRun { text, nor: true, sty: Some(MSty::Plain), ..Default::default() };
wrpr(k.child("w:rPr"), &mut r);
out.push(MNode::Run(r));
}
}
"m:f" => {
let pr = k.child("m:fPr");
let kind = match val(pr, "m:type") {
Some("skw") => FracKind::Skewed,
Some("lin") => FracKind::Linear,
Some("noBar") => FracKind::NoBar,
_ => FracKind::Bar,
};
out.push(MNode::Frac { kind, num: sub_arg(k, "m:num", d), den: sub_arg(k, "m:den", d) });
}
"m:sSup" => out.push(MNode::Script { kind: ScriptKind::Sup, base: sub_arg(k, "m:e", d), sub: Vec::new(), sup: sub_arg(k, "m:sup", d) }),
"m:sSub" => out.push(MNode::Script { kind: ScriptKind::Sub, base: sub_arg(k, "m:e", d), sub: sub_arg(k, "m:sub", d), sup: Vec::new() }),
"m:sSubSup" => {
out.push(MNode::Script { kind: ScriptKind::SubSup, base: sub_arg(k, "m:e", d), sub: sub_arg(k, "m:sub", d), sup: sub_arg(k, "m:sup", d) })
}
"m:sPre" => {
out.push(MNode::Script { kind: ScriptKind::Pre, base: sub_arg(k, "m:e", d), sub: sub_arg(k, "m:sub", d), sup: sub_arg(k, "m:sup", d) })
}
"m:rad" => {
let pr = k.child("m:radPr");
out.push(MNode::Rad { deg: sub_arg(k, "m:deg", d), deg_hide: prop(pr, "m:degHide", false), e: sub_arg(k, "m:e", d) });
}
"m:nary" => {
let pr = k.child("m:naryPr");
let chr = chr(pr, "m:chr").map(|c| c.unwrap_or('∫')).unwrap_or('∫');
let lim_loc = match val(pr, "m:limLoc") {
Some("undOvr") => Some(LimLoc::UndOvr),
Some("subSup") => Some(LimLoc::SubSup),
_ => None,
};
out.push(MNode::Nary {
chr,
lim_loc,
grow: prop(pr, "m:grow", false),
sub_hide: prop(pr, "m:subHide", false),
sup_hide: prop(pr, "m:supHide", false),
sub: sub_arg(k, "m:sub", d),
sup: sub_arg(k, "m:sup", d),
e: sub_arg(k, "m:e", d),
});
}
"m:d" => {
let pr = k.child("m:dPr");
let beg = chr(pr, "m:begChr").unwrap_or(Some('('));
let end = chr(pr, "m:endChr").unwrap_or(Some(')'));
let sep = chr(pr, "m:sepChr").unwrap_or(Some('|'));
let elems: Vec<Arg> = k.children("m:e").map(|e| arg(e, d)).collect();
out.push(MNode::Delim {
beg,
end,
sep,
grow: prop(pr, "m:grow", true),
shp_match: val(pr, "m:shp") == Some("match"),
elems: if elems.is_empty() { vec![Vec::new()] } else { elems },
});
}
"m:func" => out.push(MNode::Func { name: sub_arg(k, "m:fName", d), e: sub_arg(k, "m:e", d) }),
"m:limLow" => out.push(MNode::Lim { upper: false, e: sub_arg(k, "m:e", d), lim: sub_arg(k, "m:lim", d) }),
"m:limUpp" => out.push(MNode::Lim { upper: true, e: sub_arg(k, "m:e", d), lim: sub_arg(k, "m:lim", d) }),
"m:acc" => {
let c = chr(k.child("m:accPr"), "m:chr").map(|c| c.unwrap_or('\u{302}')).unwrap_or('\u{302}');
out.push(MNode::Acc { chr: c, e: sub_arg(k, "m:e", d) });
}
"m:bar" => out.push(MNode::Bar { top: val(k.child("m:barPr"), "m:pos") == Some("top"), e: sub_arg(k, "m:e", d) }),
"m:borderBox" => {
let pr = k.child("m:borderBoxPr");
out.push(MNode::BorderBox {
hide: [prop(pr, "m:hideTop", false), prop(pr, "m:hideBot", false), prop(pr, "m:hideLeft", false), prop(pr, "m:hideRight", false)],
strike: [
prop(pr, "m:strikeH", false),
prop(pr, "m:strikeV", false),
prop(pr, "m:strikeBLTR", false),
prop(pr, "m:strikeTLBR", false),
],
e: sub_arg(k, "m:e", d),
});
}
"m:box" => out.push(MNode::Boxed { e: sub_arg(k, "m:e", d) }),
"m:groupChr" => {
let pr = k.child("m:groupChrPr");
let c = chr(pr, "m:chr").map(|c| c.unwrap_or('\u{23DF}')).unwrap_or('\u{23DF}');
out.push(MNode::GroupChr { chr: c, top: val(pr, "m:pos") == Some("top"), e: sub_arg(k, "m:e", d) });
}
"m:eqArr" => out.push(MNode::EqArr { rows: k.children("m:e").map(|e| arg(e, d)).collect() }),
"m:m" => {
let mut col_jc = Vec::new();
if let Some(mcs) = k.child("m:mPr").and_then(|p| p.child("m:mcs")) {
for mc in mcs.children("m:mc") {
let pr = mc.child("m:mcPr");
let jc = match val(pr, "m:mcJc") {
Some("left") => ColJc::Left,
Some("right") => ColJc::Right,
_ => ColJc::Center,
};
let count = val(pr, "m:count").and_then(|c| c.trim().parse::<usize>().ok()).unwrap_or(1).clamp(1, 64);
for _ in 0..count {
if col_jc.len() < 64 {
col_jc.push(jc);
}
}
}
}
let rows = k.children("m:mr").take(1000).map(|r| r.children("m:e").take(64).map(|e| arg(e, d)).collect()).collect();
out.push(MNode::Matrix { rows, col_jc });
}
"m:phant" => {
let pr = k.child("m:phantPr");
out.push(MNode::Phant {
show: prop(pr, "m:show", true),
zero_wid: prop(pr, "m:zeroWid", false),
zero_asc: prop(pr, "m:zeroAsc", false),
zero_desc: prop(pr, "m:zeroDesc", false),
e: sub_arg(k, "m:e", d),
});
}
// Tracked insertions show; deletions don't. Hyperlinks, simple fields and direction
// wrappers (EG_PContentMath, §22.1.2.77) keep their runs (#1273).
"w:ins" | "w:moveTo" | "w:smartTag" | "w:customXml" | "w:hyperlink" | "w:fldSimple" | "w:dir" | "w:bdo" | "m:oMath" if depth <= MAX_DEPTH => {
for c in k.els() {
node(c, d, out);
}
}
// Markup compatibility: the math choice (we read math), else the fallback.
"mc:AlternateContent" if depth <= MAX_DEPTH => {
if let Some(c) = k.child("mc:Choice").or_else(|| k.child("mc:Fallback")) {
for c in c.els() {
node(c, d, out);
}
}
}
"w:sdt" => {
if let Some(c) = k.child("w:sdtContent") {
for c in c.els() {
node(c, d, out);
}
}
}
_ => {}
}
}
/// `m:t` (and `w:t`) text of a run, with symbol characters (`w:sym`) and special hyphens.
/// Text in a symbol font (Symbol, Wingdings…) becomes its Unicode equivalent: the math font has
/// no glyphs at those private-use codes (#1273).
fn run_text(r: &El) -> String {
let font = r.child("w:rPr").and_then(|p| p.child("w:rFonts")).and_then(|f| f.attr("w:ascii").or_else(|| f.attr("w:hAnsi"))).unwrap_or("");
let mut s = String::new();
for c in r.els() {
match c.name.as_str() {
"m:t" | "w:t" => s.push_str(&symbol_fonts::map_text(font, &c.text())),
"w:tab" => s.push('\t'),
"w:noBreakHyphen" => s.push('\u{2011}'),
"w:sym" => {
let ch = c.attr("w:char").and_then(|h| u32::from_str_radix(h.trim(), 16).ok()).and_then(char::from_u32).filter(|c| *c as u32 >= 0x20);
if let Some(ch) = ch {
let f = c.attr("w:font").unwrap_or(font);
s.push(symbol_fonts::symbol_char(f, ch).unwrap_or(ch));
}
}
_ => {}
}
}
s
}
fn run(r: &El) -> Option<MRun> {
let text = run_text(r);
if text.is_empty() {
return None;
}
let mut out = MRun { text, ..Default::default() };
if let Some(pr) = r.child("m:rPr") {
out.sty = match pr.child("m:sty").and_then(|s| s.attr("m:val")) {
Some("p") => Some(MSty::Plain),
Some("b") => Some(MSty::Bold),
Some("i") => Some(MSty::Italic),
Some("bi") => Some(MSty::BoldItalic),
_ => None,
};
out.scr = match pr.child("m:scr").and_then(|s| s.attr("m:val")) {
Some("script") => MScr::Script,
Some("fraktur") => MScr::Fraktur,
Some("double-struck") => MScr::DoubleStruck,
Some("sans-serif") => MScr::SansSerif,
Some("monospace") => MScr::Monospace,
_ => MScr::Roman,
};
out.nor = pr.child("m:nor").is_some_and(on);
out.lit = pr.child("m:lit").is_some_and(on);
if let Some(b) = pr.child("m:brk") {
let aln = b.attr("m:alnAt").and_then(|v| v.trim().parse::<u32>().ok()).unwrap_or(0);
out.brk = Some(aln.min(255) as u8);
}
}
wrpr(r.child("w:rPr"), &mut out);
Some(out)
}
/// The run formatting a math run takes from `w:rPr`: size, colour, normal-text font and weight.
fn wrpr(pr: Option<&El>, out: &mut MRun) {
let Some(pr) = pr else { return };
if let Some(sz) = pr.child("w:sz").and_then(|s| s.attr("w:val")).and_then(|v| v.trim().parse::<f32>().ok()) {
let pt = sz / 2.0;
if pt.is_finite() && (1.0..=1638.0).contains(&pt) {
out.size = Some(pt);
}
}
if let Some(c) = pr.child("w:color").and_then(|c| c.attr("w:val")).and_then(Rgb::parse) {
out.color = Some(c);
}
if out.nor {
// Symbol-font text was mapped to Unicode: it takes the text font.
if let Some(f) =
pr.child("w:rFonts").and_then(|f| f.attr("w:ascii").or_else(|| f.attr("w:hAnsi"))).filter(|f| !symbol_fonts::is_symbol_font(f))
{
out.font = Some(f.to_string());
}
let bold = pr.child("w:b").is_some_and(crate::units::on_off);
let italic = pr.child("w:i").is_some_and(crate::units::on_off);
if bold || italic {
out.sty = Some(match (bold, italic) {
(true, true) => MSty::BoldItalic,
(true, false) => MSty::Bold,
_ => MSty::Italic,
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn omath(inner: &str) -> El {
let x = format!(
r#"<m:oMath xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">{inner}</m:oMath>"#
);
crate::xml::parse(x.as_bytes()).unwrap()
}
#[test]
fn reads_fraction_radical_and_scripts() {
let e = omath(
r#"<m:r><m:t>x=</m:t></m:r><m:f><m:num><m:r><m:t>-b±</m:t></m:r><m:rad><m:radPr><m:degHide m:val="1"/></m:radPr><m:deg/><m:e><m:sSup><m:e><m:r><m:t>b</m:t></m:r></m:e><m:sup><m:r><m:t>2</m:t></m:r></m:sup></m:sSup><m:r><m:t>-4ac</m:t></m:r></m:e></m:rad></m:num><m:den><m:r><m:t>2a</m:t></m:r></m:den></m:f>"#,
);
let m = read_omath(&e, MathJc::CenterGroup);
assert_eq!(m.nodes.len(), 2);
let MNode::Frac { num, den, kind } = &m.nodes[1] else { panic!("{:#?}", m.nodes) };
assert_eq!(*kind, FracKind::Bar);
assert_eq!(den, &vec![MNode::Run(MRun::new("2a"))]);
let MNode::Rad { deg_hide, e, .. } = &num[1] else { panic!() };
assert!(*deg_hide);
assert!(matches!(e[0], MNode::Script { kind: ScriptKind::Sup, .. }));
assert_eq!(wordcraft_doc::math::to_linear(&m.nodes), "x=(-b±√(b^2-4ac))/2a");
assert!(m.omml.starts_with("<m:oMath>"));
}
#[test]
fn reads_nary_delims_matrix_and_defaults() {
let e = omath(
r#"<m:nary><m:naryPr><m:chr m:val="∑"/><m:limLoc m:val="undOvr"/></m:naryPr><m:sub><m:r><m:t>i</m:t></m:r></m:sub><m:sup/><m:e><m:r><m:t>i</m:t></m:r></m:e></m:nary><m:d><m:dPr><m:begChr m:val="["/><m:endChr m:val=""/></m:dPr><m:e><m:r><m:t>a</m:t></m:r></m:e><m:e><m:r><m:t>b</m:t></m:r></m:e></m:d><m:nary><m:e/></m:nary><m:m><m:mr><m:e/><m:e/></m:mr></m:m>"#,
);
let m = read_omath(&e, MathJc::CenterGroup);
let MNode::Nary { chr, lim_loc, .. } = &m.nodes[0] else { panic!() };
assert_eq!((*chr, *lim_loc), ('∑', Some(LimLoc::UndOvr)));
let MNode::Delim { beg, end, sep, elems, .. } = &m.nodes[1] else { panic!() };
assert_eq!((*beg, *end, *sep, elems.len()), (Some('['), None, Some('|'), 2));
assert!(matches!(m.nodes[2], MNode::Nary { chr: '∫', .. }));
assert!(matches!(&m.nodes[3], MNode::Matrix { rows, .. } if rows.len() == 1 && rows[0].len() == 2));
}
/// Every run's text in the tree, in reading order.
fn texts(m: &mut Math) -> Vec<String> {
let mut out = Vec::new();
wordcraft_doc::math_edit::each_run_mut(&mut m.nodes, &mut |r| out.push(r.text.clone()));
out
}
fn t(s: &str) -> String {
format!("<m:r><m:t>{s}</m:t></m:r>")
}
/// #1273: one equation using every Office Math element of ECMA-376 §22.1 (each argument
/// holding a distinct marker) keeps every argument's text: nothing is silently dropped.
#[test]
fn every_omml_element_keeps_its_content() {
let parts = [
format!("<m:acc><m:accPr><m:chr m:val=\"̃\"/></m:accPr><m:e>{}</m:e></m:acc>", t("A1")),
format!("<m:bar><m:barPr><m:pos m:val=\"top\"/></m:barPr><m:e>{}</m:e></m:bar>", t("B1")),
format!("<m:box><m:boxPr><m:opEmu m:val=\"1\"/></m:boxPr><m:e>{}</m:e></m:box>", t("C1")),
format!("<m:borderBox><m:borderBoxPr><m:strikeH/></m:borderBoxPr><m:e>{}</m:e></m:borderBox>", t("D1")),
format!("<m:d><m:dPr><m:begChr m:val=\"[\"/></m:dPr><m:e>{}</m:e><m:e>{}</m:e></m:d>", t("E1"), t("E2")),
format!("<m:eqArr><m:e>{}</m:e><m:e>{}</m:e></m:eqArr>", t("F1"), t("F2")),
format!("<m:f><m:fPr><m:type m:val=\"skw\"/></m:fPr><m:num>{}</m:num><m:den>{}</m:den></m:f>", t("G1"), t("G2")),
format!("<m:func><m:fName>{}</m:fName><m:e>{}</m:e></m:func>", t("H1"), t("H2")),
format!("<m:groupChr><m:groupChrPr><m:chr m:val=\"⏞\"/><m:pos m:val=\"top\"/></m:groupChrPr><m:e>{}</m:e></m:groupChr>", t("I1")),
format!("<m:limLow><m:e>{}</m:e><m:lim>{}</m:lim></m:limLow>", t("J1"), t("J2")),
format!("<m:limUpp><m:e>{}</m:e><m:lim>{}</m:lim></m:limUpp>", t("K1"), t("K2")),
format!("<m:m><m:mr><m:e>{}</m:e><m:e>{}</m:e></m:mr><m:mr><m:e>{}</m:e><m:e/></m:mr></m:m>", t("L1"), t("L2"), t("L3")),
format!(
"<m:nary><m:naryPr><m:chr m:val=\"∑\"/></m:naryPr><m:sub>{}</m:sub><m:sup>{}</m:sup><m:e>{}</m:e></m:nary>",
t("M1"),
t("M2"),
t("M3")
),
format!("<m:phant><m:phantPr><m:show m:val=\"0\"/></m:phantPr><m:e>{}</m:e></m:phant>", t("N1")),
format!("<m:rad><m:deg>{}</m:deg><m:e>{}</m:e></m:rad>", t("O1"), t("O2")),
format!("<m:sPre><m:sub>{}</m:sub><m:sup>{}</m:sup><m:e>{}</m:e></m:sPre>", t("P1"), t("P2"), t("P3")),
format!("<m:sSub><m:e>{}</m:e><m:sub>{}</m:sub></m:sSub>", t("Q1"), t("Q2")),
format!("<m:sSubSup><m:e>{}</m:e><m:sub>{}</m:sub><m:sup>{}</m:sup></m:sSubSup>", t("R1"), t("R2"), t("R3")),
format!("<m:sSup><m:e>{}</m:e><m:sup>{}</m:sup></m:sSup>", t("S1"), t("S2")),
// Run-level wrappers allowed in math (EG_PContentMath): hyperlinks, simple fields,
// tracked insertions, content controls, markup compatibility.
format!("<w:hyperlink w:anchor=\"x\">{}</w:hyperlink>", t("T1")),
format!("<w:fldSimple w:instr=\"QUOTE\">{}</w:fldSimple>", t("T2")),
format!("<w:ins w:id=\"1\" w:author=\"a\">{}</w:ins>", t("T3")),
format!("<w:sdt><w:sdtContent>{}</w:sdtContent></w:sdt>", t("T4")),
format!(
"<mc:AlternateContent><mc:Choice Requires=\"m\">{}</mc:Choice><mc:Fallback><w:r><w:t>picture</w:t></w:r></mc:Fallback></mc:AlternateContent>",
t("T5")
),
"<w:r><w:t>T6</w:t></w:r>".to_string(),
];
let x = format!(
r#"<m:oMath xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006">{}</m:oMath>"#,
parts.concat()
);
let e = crate::xml::parse(x.as_bytes()).unwrap();
let mut m = read_omath(&e, MathJc::CenterGroup);
let got = texts(&mut m).concat();
let markers = [
"A1", "B1", "C1", "D1", "E1", "E2", "F1", "F2", "G1", "G2", "H1", "H2", "I1", "J1", "J2", "K1", "K2", "L1", "L2", "L3", "M1", "M2", "M3",
"N1", "O1", "O2", "P1", "P2", "P3", "Q1", "Q2", "R1", "R2", "R3", "S1", "S2", "T1", "T2", "T3", "T4", "T5", "T6",
];
for k in markers {
assert!(got.contains(k), "{k} dropped from {got}");
}
assert!(!got.contains("picture"), "the math choice wins over the fallback: {got}");
}
/// #1273: deleted text stays out; symbol characters and symbol-font text become Unicode.
#[test]
fn symbols_and_deletions_in_math() {
let e = omath(
r#"<m:r><m:t>x</m:t></m:r><w:del w:id="1" w:author="a"><m:r><m:t>gone</m:t></m:r></w:del><m:r><w:sym w:font="Symbol" w:char="F061"/></m:r><m:r><w:rPr><w:rFonts w:ascii="Symbol" w:hAnsi="Symbol"/></w:rPr><m:t>&#xF062;</m:t><w:noBreakHyphen/></m:r><w:r><w:rPr><w:rFonts w:ascii="Symbol"/></w:rPr><w:t>&#xF067;</w:t></w:r>"#,
);
let mut m = read_omath(&e, MathJc::CenterGroup);
let got = texts(&mut m).concat();
assert_eq!(got, "xαβ\u{2011}γ");
let mut font = None;
wordcraft_doc::math_edit::each_run_mut(&mut m.nodes, &mut |r| {
if r.nor {
font = Some(r.font.clone());
}
});
assert_eq!(font, Some(None), "mapped symbol text takes the text font");
}
/// Hostile nesting through the wrappers is bounded (no stack overflow, no panic).
#[test]
fn deeply_nested_wrappers_are_bounded() {
// Deeper than the math limit, within the XML parser's own depth limit.
let n = 120;
let inner = format!("{}{}{}", "<w:hyperlink>".repeat(n), t("deep"), "</w:hyperlink>".repeat(n));
let e = omath(&inner);
let _ = read_omath(&e, MathJc::CenterGroup);
let inner = format!(
"{}{}{}",
"<mc:AlternateContent xmlns:mc=\"http://schemas.openxmlformats.org/markup-compatibility/2006\"><mc:Choice Requires=\"m\">".repeat(n),
t("deep"),
"</mc:Choice></mc:AlternateContent>".repeat(n)
);
let e = omath(&inner);
let _ = read_omath(&e, MathJc::CenterGroup);
}
}