From 7c793f62d9e9c9425c8c93e1589a09f16399fd49 Mon Sep 17 00:00:00 2001 From: Yonghye Kwon Date: Fri, 27 Feb 2026 12:59:09 +0900 Subject: [PATCH 1/2] feat: [US-041] - DOCX parser - OMML math equations Co-Authored-By: Claude Opus 4.6 Signed-off-by: Yonghye Kwon --- crates/office2pdf/src/ir/elements.rs | 10 + crates/office2pdf/src/parser/docx.rs | 249 ++++- crates/office2pdf/src/parser/mod.rs | 1 + crates/office2pdf/src/parser/omml.rs | 1025 +++++++++++++++++++++ crates/office2pdf/src/render/typst_gen.rs | 74 +- 5 files changed, 1353 insertions(+), 6 deletions(-) create mode 100644 crates/office2pdf/src/parser/omml.rs diff --git a/crates/office2pdf/src/ir/elements.rs b/crates/office2pdf/src/ir/elements.rs index 075e087e..022cf376 100644 --- a/crates/office2pdf/src/ir/elements.rs +++ b/crates/office2pdf/src/ir/elements.rs @@ -32,9 +32,19 @@ pub enum Block { Image(ImageData), FloatingImage(FloatingImage), List(List), + MathEquation(MathEquation), PageBreak, } +/// A math equation (from OMML or similar). +#[derive(Debug, Clone)] +pub struct MathEquation { + /// Typst math notation content (without surrounding `$` delimiters). + pub content: String, + /// Whether this is a display equation (centered, on its own line) vs inline. + pub display: bool, +} + /// How text wraps around a floating image. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WrapMode { diff --git a/crates/office2pdf/src/parser/docx.rs b/crates/office2pdf/src/parser/docx.rs index 11036d4b..2969a8b9 100644 --- a/crates/office2pdf/src/parser/docx.rs +++ b/crates/office2pdf/src/parser/docx.rs @@ -7,8 +7,8 @@ use crate::error::{ConvertError, ConvertWarning}; use crate::ir::{ Alignment, Block, BorderSide, CellBorder, Color, Document, FloatingImage, FlowPage, HFInline, HeaderFooter, HeaderFooterParagraph, ImageData, ImageFormat, LineSpacing, List, ListItem, - ListKind, Margins, Metadata, Page, PageSize, Paragraph, ParagraphStyle, Run, StyleSheet, Table, - TableCell, TableRow, TextStyle, WrapMode, + ListKind, Margins, MathEquation, Metadata, Page, PageSize, Paragraph, ParagraphStyle, Run, + StyleSheet, Table, TableCell, TableRow, TextStyle, WrapMode, }; use crate::parser::Parser; @@ -478,6 +478,45 @@ fn scan_anchor_wrap_types(xml: &str) -> Vec { results } +/// Context for OMML math equations extracted from raw document XML. +/// docx-rs does not parse `m:oMath` / `m:oMathPara` elements — they are +/// completely absent from the `ParagraphChild` enum — so we scan the raw ZIP. +struct MathContext { + /// Math equations keyed by 0-based body child index in document.xml. + /// A paragraph can contain multiple equations. + equations: HashMap>, +} + +impl MathContext { + /// Take the math equations for a given body child index (consuming them). + fn take(&mut self, index: usize) -> Vec { + self.equations.remove(&index).unwrap_or_default() + } +} + +/// Build a `MathContext` by scanning document.xml for OMML elements. +fn build_math_context(data: &[u8]) -> MathContext { + let mut equations: HashMap> = HashMap::new(); + + let Ok(mut archive) = zip::ZipArchive::new(std::io::Cursor::new(data)) else { + return MathContext { equations }; + }; + + if let Some(xml) = read_zip_text(&mut archive, "word/document.xml") { + let raw = super::omml::scan_math_equations(&xml); + for (idx, infos) in raw { + for info in infos { + equations.entry(idx).or_default().push(MathEquation { + content: info.content, + display: info.display, + }); + } + } + } + + MathContext { equations } +} + /// Build a `NoteContext` by parsing footnotes/endnotes from the raw DOCX ZIP. /// This is needed because docx-rs reader does not parse `w:footnoteReference` /// or `w:endnoteReference` elements. @@ -662,6 +701,8 @@ impl Parser for DocxParser { let notes = build_note_context(data); // Build wrap context for anchor image wrap types from raw ZIP let wraps = build_wrap_context(data); + // Build math context for OMML equations from raw ZIP + let mut math = build_math_context(data); let docx = docx_rs::read_docx(data) .map_err(|e| ConvertError::Parse(format!("Failed to parse DOCX: {e}")))?; @@ -677,14 +718,20 @@ impl Parser for DocxParser { for (idx, child) in docx.document.children.iter().enumerate() { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match child { docx_rs::DocumentChild::Paragraph(para) => { - vec![convert_paragraph_element( + let mut tagged = vec![convert_paragraph_element( para, &images, &hyperlinks, &style_map, ¬es, &wraps, - )] + )]; + // Inject math equations for this body child + let eqs = math.take(idx); + for eq in eqs { + tagged.push(TaggedElement::Plain(vec![Block::MathEquation(eq)])); + } + tagged } docx_rs::DocumentChild::Table(table) => { vec![TaggedElement::Plain(vec![Block::Table(convert_table( @@ -4533,4 +4580,198 @@ mod tests { new_zip.finish().unwrap().into_inner() } + + // ── OMML math equation tests ── + + /// Build a DOCX ZIP with a custom document.xml containing OMML math. + fn build_docx_with_math(document_xml: &str) -> Vec { + let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); + let options = zip::write::FileOptions::default(); + + // [Content_Types].xml + zip.start_file("[Content_Types].xml", options).unwrap(); + std::io::Write::write_all( + &mut zip, + br#" + + + + +"#, + ) + .unwrap(); + + // _rels/.rels + zip.start_file("_rels/.rels", options).unwrap(); + std::io::Write::write_all( + &mut zip, + br#" + + +"#, + ) + .unwrap(); + + // word/_rels/document.xml.rels + zip.start_file("word/_rels/document.xml.rels", options) + .unwrap(); + std::io::Write::write_all( + &mut zip, + br#" + +"#, + ) + .unwrap(); + + // word/document.xml + zip.start_file("word/document.xml", options).unwrap(); + std::io::Write::write_all(&mut zip, document_xml.as_bytes()).unwrap(); + + zip.finish().unwrap().into_inner() + } + + #[test] + fn test_parse_docx_with_display_math_fraction() { + let document_xml = r#" + + + + Before math + + + + + + a + b + + + + + + After math + + + +"#; + + let data = build_docx_with_math(document_xml); + let parser = DocxParser; + let (doc, _warnings) = parser.parse(&data, &ConvertOptions::default()).unwrap(); + + let page = match &doc.pages[0] { + Page::Flow(fp) => fp, + _ => panic!("Expected FlowPage"), + }; + + // Should find a MathEquation block + let math_blocks: Vec<&MathEquation> = page + .content + .iter() + .filter_map(|b| match b { + Block::MathEquation(m) => Some(m), + _ => None, + }) + .collect(); + + assert!( + !math_blocks.is_empty(), + "Expected at least one MathEquation block, found none" + ); + assert_eq!(math_blocks[0].content, "frac(a, b)"); + assert!(math_blocks[0].display); + } + + #[test] + fn test_parse_docx_with_inline_math_superscript() { + let document_xml = r#" + + + + The value of + + + x + 2 + + + is positive + + + +"#; + + let data = build_docx_with_math(document_xml); + let parser = DocxParser; + let (doc, _warnings) = parser.parse(&data, &ConvertOptions::default()).unwrap(); + + let page = match &doc.pages[0] { + Page::Flow(fp) => fp, + _ => panic!("Expected FlowPage"), + }; + + let math_blocks: Vec<&MathEquation> = page + .content + .iter() + .filter_map(|b| match b { + Block::MathEquation(m) => Some(m), + _ => None, + }) + .collect(); + + assert!( + !math_blocks.is_empty(), + "Expected at least one MathEquation block" + ); + assert_eq!(math_blocks[0].content, "x^2"); + assert!(!math_blocks[0].display); + } + + #[test] + fn test_parse_docx_with_complex_math() { + let document_xml = r#" + + + + + + E + = + m + + c + 2 + + + + + + +"#; + + let data = build_docx_with_math(document_xml); + let parser = DocxParser; + let (doc, _warnings) = parser.parse(&data, &ConvertOptions::default()).unwrap(); + + let page = match &doc.pages[0] { + Page::Flow(fp) => fp, + _ => panic!("Expected FlowPage"), + }; + + let math_blocks: Vec<&MathEquation> = page + .content + .iter() + .filter_map(|b| match b { + Block::MathEquation(m) => Some(m), + _ => None, + }) + .collect(); + + assert!(!math_blocks.is_empty()); + assert_eq!(math_blocks[0].content, "E=mc^2"); + assert!(math_blocks[0].display); + } } diff --git a/crates/office2pdf/src/parser/mod.rs b/crates/office2pdf/src/parser/mod.rs index 1a7f456e..2900eba8 100644 --- a/crates/office2pdf/src/parser/mod.rs +++ b/crates/office2pdf/src/parser/mod.rs @@ -1,4 +1,5 @@ pub mod docx; +pub(crate) mod omml; pub mod pptx; pub mod xlsx; diff --git a/crates/office2pdf/src/parser/omml.rs b/crates/office2pdf/src/parser/omml.rs new file mode 100644 index 00000000..a0403634 --- /dev/null +++ b/crates/office2pdf/src/parser/omml.rs @@ -0,0 +1,1025 @@ +//! OMML (Office Math Markup Language) to Typst math notation converter. +//! +//! Parses OMML XML elements (m:oMath, m:oMathPara) from DOCX documents +//! and converts them to Typst math notation strings. + +use std::collections::HashMap; + +use quick_xml::Reader; +use quick_xml::events::Event; + +/// A parsed math equation from the document. +pub(crate) struct MathEquationInfo { + /// Typst math notation content (without `$` delimiters). + pub(crate) content: String, + /// Whether this is display math (centered, block-level). + pub(crate) display: bool, +} + +/// Convert an OMML `` XML fragment to Typst math notation. +/// +/// The input should be the complete `...` element. +/// Returns the Typst math notation string (without `$` delimiters). +#[cfg(test)] +fn omml_to_typst(xml: &str) -> String { + let mut reader = Reader::from_str(xml); + let mut result = String::new(); + parse_omml_children(&mut reader, &mut result, b"oMath"); + result.trim().to_string() +} + +/// Recursively parse OMML children and append Typst math notation. +/// `end_tag` is the local name of the element whose End event should stop parsing. +fn parse_omml_children(reader: &mut Reader<&[u8]>, out: &mut String, end_tag: &[u8]) { + loop { + match reader.read_event() { + Ok(Event::Start(ref e)) => { + let local = e.local_name(); + let name = local.as_ref(); + match name { + b"f" => parse_fraction(reader, out), + b"sSup" => parse_superscript(reader, out), + b"sSub" => parse_subscript(reader, out), + b"sSubSup" => parse_sub_superscript(reader, out), + b"rad" => parse_radical(reader, out), + b"d" => parse_delimiter(reader, out), + b"r" => parse_math_run(reader, out), + b"nary" => parse_nary(reader, out), + b"func" => parse_function(reader, out), + b"limLow" => parse_lim_low(reader, out), + b"limUpp" => parse_lim_upp(reader, out), + b"acc" => parse_accent(reader, out), + b"bar" => parse_bar(reader, out), + // Note: b"m" would conflict with the module itself, + // matrix element local name is just "m" but with namespace prefix + b"eqArr" => parse_eq_array(reader, out), + b"oMath" => { + // Nested oMath — just process its children + parse_omml_children(reader, out, b"oMath"); + } + b"oMathPara" => { + // Display math wrapper — process children + parse_omml_children(reader, out, b"oMathPara"); + } + _ => { + // Skip unknown elements + skip_element(reader, name); + } + } + } + Ok(Event::End(ref e)) => { + if e.local_name().as_ref() == end_tag { + return; + } + } + Ok(Event::Eof) => return, + Err(_) => return, + _ => {} + } + } +} + +/// Parse a single sub-element into a string, returning the content. +fn parse_sub_element(reader: &mut Reader<&[u8]>, end_tag: &[u8]) -> String { + let mut out = String::new(); + parse_omml_children(reader, &mut out, end_tag); + out.trim().to_string() +} + +/// Parse `` fraction element: `......`. +fn parse_fraction(reader: &mut Reader<&[u8]>, out: &mut String) { + let mut num = String::new(); + let mut den = String::new(); + + loop { + match reader.read_event() { + Ok(Event::Start(ref e)) => { + let name = e.local_name(); + match name.as_ref() { + b"num" => num = parse_sub_element(reader, b"num"), + b"den" => den = parse_sub_element(reader, b"den"), + b"fPr" => skip_element(reader, b"fPr"), + _ => skip_element(reader, name.as_ref()), + } + } + Ok(Event::End(ref e)) if e.local_name().as_ref() == b"f" => break, + Ok(Event::Eof) => break, + Err(_) => break, + _ => {} + } + } + + let _ = std::fmt::Write::write_fmt(out, format_args!("frac({num}, {den})")); +} + +/// Parse `` superscript: `baseexp`. +fn parse_superscript(reader: &mut Reader<&[u8]>, out: &mut String) { + let mut base = String::new(); + let mut sup = String::new(); + + loop { + match reader.read_event() { + Ok(Event::Start(ref e)) => { + let name = e.local_name(); + match name.as_ref() { + b"e" => base = parse_sub_element(reader, b"e"), + b"sup" => sup = parse_sub_element(reader, b"sup"), + b"sSupPr" => skip_element(reader, b"sSupPr"), + _ => skip_element(reader, name.as_ref()), + } + } + Ok(Event::End(ref e)) if e.local_name().as_ref() == b"sSup" => break, + Ok(Event::Eof) => break, + Err(_) => break, + _ => {} + } + } + + out.push_str(&base); + let wrapped = wrap_if_needed(&sup); + let _ = std::fmt::Write::write_fmt(out, format_args!("^{wrapped}")); +} + +/// Parse `` subscript: `basesub`. +fn parse_subscript(reader: &mut Reader<&[u8]>, out: &mut String) { + let mut base = String::new(); + let mut sub = String::new(); + + loop { + match reader.read_event() { + Ok(Event::Start(ref e)) => { + let name = e.local_name(); + match name.as_ref() { + b"e" => base = parse_sub_element(reader, b"e"), + b"sub" => sub = parse_sub_element(reader, b"sub"), + b"sSubPr" => skip_element(reader, b"sSubPr"), + _ => skip_element(reader, name.as_ref()), + } + } + Ok(Event::End(ref e)) if e.local_name().as_ref() == b"sSub" => break, + Ok(Event::Eof) => break, + Err(_) => break, + _ => {} + } + } + + out.push_str(&base); + let wrapped = wrap_if_needed(&sub); + let _ = std::fmt::Write::write_fmt(out, format_args!("_{wrapped}")); +} + +/// Parse ``: `basesubsup`. +fn parse_sub_superscript(reader: &mut Reader<&[u8]>, out: &mut String) { + let mut base = String::new(); + let mut sub = String::new(); + let mut sup = String::new(); + + loop { + match reader.read_event() { + Ok(Event::Start(ref e)) => { + let name = e.local_name(); + match name.as_ref() { + b"e" => base = parse_sub_element(reader, b"e"), + b"sub" => sub = parse_sub_element(reader, b"sub"), + b"sup" => sup = parse_sub_element(reader, b"sup"), + b"sSubSupPr" => skip_element(reader, b"sSubSupPr"), + _ => skip_element(reader, name.as_ref()), + } + } + Ok(Event::End(ref e)) if e.local_name().as_ref() == b"sSubSup" => break, + Ok(Event::Eof) => break, + Err(_) => break, + _ => {} + } + } + + out.push_str(&base); + let w_sub = wrap_if_needed(&sub); + let w_sup = wrap_if_needed(&sup); + let _ = std::fmt::Write::write_fmt(out, format_args!("_{w_sub}^{w_sup}")); +} + +/// Parse `` radical: `degreecontent`. +fn parse_radical(reader: &mut Reader<&[u8]>, out: &mut String) { + let mut deg = String::new(); + let mut content = String::new(); + let mut deg_hide = false; + + loop { + match reader.read_event() { + Ok(Event::Start(ref e)) => { + let name = e.local_name(); + match name.as_ref() { + b"radPr" => deg_hide = parse_rad_props(reader), + b"deg" => deg = parse_sub_element(reader, b"deg"), + b"e" => content = parse_sub_element(reader, b"e"), + _ => skip_element(reader, name.as_ref()), + } + } + Ok(Event::End(ref e)) if e.local_name().as_ref() == b"rad" => break, + Ok(Event::Eof) => break, + Err(_) => break, + _ => {} + } + } + + if deg_hide || deg.is_empty() { + let _ = std::fmt::Write::write_fmt(out, format_args!("sqrt({content})")); + } else { + let _ = std::fmt::Write::write_fmt(out, format_args!("root({deg}, {content})")); + } +} + +/// Parse radical properties to check if degree is hidden (square root). +fn parse_rad_props(reader: &mut Reader<&[u8]>) -> bool { + let mut deg_hide = false; + loop { + match reader.read_event() { + Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => { + if e.local_name().as_ref() == b"degHide" { + for attr in e.attributes().flatten() { + if attr.key.local_name().as_ref() == b"val" + && let Ok(v) = attr.unescape_value() + { + deg_hide = v == "1" || v == "true" || v == "on"; + } + } + } + } + Ok(Event::End(ref e)) if e.local_name().as_ref() == b"radPr" => break, + Ok(Event::Eof) => break, + Err(_) => break, + _ => {} + } + } + deg_hide +} + +/// Parse `` delimiter: `...content`. +fn parse_delimiter(reader: &mut Reader<&[u8]>, out: &mut String) { + let mut beg_chr = "(".to_string(); + let mut end_chr = ")".to_string(); + let mut elements: Vec = Vec::new(); + + loop { + match reader.read_event() { + Ok(Event::Start(ref e)) => { + let name = e.local_name(); + match name.as_ref() { + b"dPr" => parse_delimiter_props(reader, &mut beg_chr, &mut end_chr), + b"e" => elements.push(parse_sub_element(reader, b"e")), + _ => skip_element(reader, name.as_ref()), + } + } + Ok(Event::End(ref e)) if e.local_name().as_ref() == b"d" => break, + Ok(Event::Eof) => break, + Err(_) => break, + _ => {} + } + } + + let beg = map_delimiter(&beg_chr); + let end = map_delimiter(&end_chr); + let content = elements.join(", "); + let _ = std::fmt::Write::write_fmt(out, format_args!("{beg}{content}{end}")); +} + +/// Map OMML delimiter characters to Typst math equivalents. +fn map_delimiter(chr: &str) -> &str { + match chr { + "(" | ")" | "[" | "]" | "|" => chr, + "{" => "{", + "}" => "}", + _ => chr, + } +} + +/// Parse delimiter properties for begin/end characters. +fn parse_delimiter_props(reader: &mut Reader<&[u8]>, beg: &mut String, end: &mut String) { + loop { + match reader.read_event() { + Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => { + let name = e.local_name(); + match name.as_ref() { + b"begChr" => { + if let Some(v) = get_val_attr(e) { + *beg = v; + } + } + b"endChr" => { + if let Some(v) = get_val_attr(e) { + *end = v; + } + } + _ => {} + } + } + Ok(Event::End(ref e)) if e.local_name().as_ref() == b"dPr" => break, + Ok(Event::Eof) => break, + Err(_) => break, + _ => {} + } + } +} + +/// Extract the `m:val` or `val` attribute value from an element. +fn get_val_attr(e: &quick_xml::events::BytesStart<'_>) -> Option { + for attr in e.attributes().flatten() { + if attr.key.local_name().as_ref() == b"val" + && let Ok(v) = attr.unescape_value() + { + return Some(v.to_string()); + } + } + None +} + +/// Parse `` math run: extract text from ``. +fn parse_math_run(reader: &mut Reader<&[u8]>, out: &mut String) { + loop { + match reader.read_event() { + Ok(Event::Start(ref e)) => { + let name = e.local_name(); + match name.as_ref() { + b"t" => { + // Read text content + if let Ok(Event::Text(ref text)) = reader.read_event() + && let Ok(s) = text.xml_content() + { + out.push_str(&s); + } + } + b"rPr" => skip_element(reader, b"rPr"), + _ => skip_element(reader, name.as_ref()), + } + } + Ok(Event::End(ref e)) if e.local_name().as_ref() == b"r" => break, + Ok(Event::Eof) => break, + Err(_) => break, + _ => {} + } + } +} + +/// Parse `` n-ary operator (sum, product, integral, etc.). +fn parse_nary(reader: &mut Reader<&[u8]>, out: &mut String) { + let mut chr = "\u{2211}".to_string(); // default is sum ∑ + let mut sub = String::new(); + let mut sup = String::new(); + let mut content = String::new(); + + loop { + match reader.read_event() { + Ok(Event::Start(ref e)) => { + let name = e.local_name(); + match name.as_ref() { + b"naryPr" => parse_nary_props(reader, &mut chr), + b"sub" => sub = parse_sub_element(reader, b"sub"), + b"sup" => sup = parse_sub_element(reader, b"sup"), + b"e" => content = parse_sub_element(reader, b"e"), + _ => skip_element(reader, name.as_ref()), + } + } + Ok(Event::End(ref e)) if e.local_name().as_ref() == b"nary" => break, + Ok(Event::Eof) => break, + Err(_) => break, + _ => {} + } + } + + let op = map_nary_operator(&chr); + out.push_str(op); + if !sub.is_empty() { + let w = wrap_if_needed(&sub); + let _ = std::fmt::Write::write_fmt(out, format_args!("_{w}")); + } + if !sup.is_empty() { + let w = wrap_if_needed(&sup); + let _ = std::fmt::Write::write_fmt(out, format_args!("^{w}")); + } + out.push(' '); + out.push_str(&content); +} + +/// Parse n-ary properties to extract the operator character. +fn parse_nary_props(reader: &mut Reader<&[u8]>, chr: &mut String) { + loop { + match reader.read_event() { + Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => { + if e.local_name().as_ref() == b"chr" + && let Some(v) = get_val_attr(e) + { + *chr = v; + } + } + Ok(Event::End(ref e)) if e.local_name().as_ref() == b"naryPr" => break, + Ok(Event::Eof) => break, + Err(_) => break, + _ => {} + } + } +} + +/// Map OMML n-ary operator characters to Typst math operators. +fn map_nary_operator(chr: &str) -> &str { + match chr { + "\u{2211}" => "sum", // ∑ + "\u{220f}" => "product", // ∏ + "\u{222b}" => "integral", // ∫ + "\u{222c}" => "integral.double", // ∬ + "\u{222d}" => "integral.triple", // ∭ + "\u{222e}" => "integral.cont", // ∮ + "\u{22c3}" => "union.big", // ⋃ + "\u{22c2}" => "sect.big", // ⋂ + _ => "sum", + } +} + +/// Parse `` function application: `namearg`. +fn parse_function(reader: &mut Reader<&[u8]>, out: &mut String) { + let mut name = String::new(); + let mut content = String::new(); + + loop { + match reader.read_event() { + Ok(Event::Start(ref e)) => { + let local = e.local_name(); + match local.as_ref() { + b"fName" => name = parse_sub_element(reader, b"fName"), + b"e" => content = parse_sub_element(reader, b"e"), + b"funcPr" => skip_element(reader, b"funcPr"), + _ => skip_element(reader, local.as_ref()), + } + } + Ok(Event::End(ref e)) if e.local_name().as_ref() == b"func" => break, + Ok(Event::Eof) => break, + Err(_) => break, + _ => {} + } + } + + let func_name = name.trim(); + let _ = std::fmt::Write::write_fmt(out, format_args!("{func_name} {content}")); +} + +/// Parse `` lower limit: `baselimit`. +fn parse_lim_low(reader: &mut Reader<&[u8]>, out: &mut String) { + let mut base = String::new(); + let mut lim = String::new(); + + loop { + match reader.read_event() { + Ok(Event::Start(ref e)) => { + let name = e.local_name(); + match name.as_ref() { + b"e" => base = parse_sub_element(reader, b"e"), + b"lim" => lim = parse_sub_element(reader, b"lim"), + b"limLowPr" => skip_element(reader, b"limLowPr"), + _ => skip_element(reader, name.as_ref()), + } + } + Ok(Event::End(ref e)) if e.local_name().as_ref() == b"limLow" => break, + Ok(Event::Eof) => break, + Err(_) => break, + _ => {} + } + } + + out.push_str(&base); + let w = wrap_if_needed(&lim); + let _ = std::fmt::Write::write_fmt(out, format_args!("_{w}")); +} + +/// Parse `` upper limit: `baselimit`. +fn parse_lim_upp(reader: &mut Reader<&[u8]>, out: &mut String) { + let mut base = String::new(); + let mut lim = String::new(); + + loop { + match reader.read_event() { + Ok(Event::Start(ref e)) => { + let name = e.local_name(); + match name.as_ref() { + b"e" => base = parse_sub_element(reader, b"e"), + b"lim" => lim = parse_sub_element(reader, b"lim"), + b"limUppPr" => skip_element(reader, b"limUppPr"), + _ => skip_element(reader, name.as_ref()), + } + } + Ok(Event::End(ref e)) if e.local_name().as_ref() == b"limUpp" => break, + Ok(Event::Eof) => break, + Err(_) => break, + _ => {} + } + } + + out.push_str(&base); + let w = wrap_if_needed(&lim); + let _ = std::fmt::Write::write_fmt(out, format_args!("^{w}")); +} + +/// Parse `` accent: `...content`. +fn parse_accent(reader: &mut Reader<&[u8]>, out: &mut String) { + let mut chr = "\u{0302}".to_string(); // combining circumflex accent (hat) + let mut content = String::new(); + + loop { + match reader.read_event() { + Ok(Event::Start(ref e)) => { + let name = e.local_name(); + match name.as_ref() { + b"accPr" => parse_props_with_chr(reader, b"accPr", &mut chr), + b"e" => content = parse_sub_element(reader, b"e"), + _ => skip_element(reader, name.as_ref()), + } + } + Ok(Event::End(ref e)) if e.local_name().as_ref() == b"acc" => break, + Ok(Event::Eof) => break, + Err(_) => break, + _ => {} + } + } + + let accent = map_accent(&chr); + let _ = std::fmt::Write::write_fmt(out, format_args!("{accent}({content})")); +} + +/// Parse a properties element looking for a `chr` child with a `val` attribute. +fn parse_props_with_chr(reader: &mut Reader<&[u8]>, end_tag: &[u8], chr: &mut String) { + loop { + match reader.read_event() { + Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => { + if e.local_name().as_ref() == b"chr" + && let Some(v) = get_val_attr(e) + { + *chr = v; + } + } + Ok(Event::End(ref e)) if e.local_name().as_ref() == end_tag => break, + Ok(Event::Eof) => break, + Err(_) => break, + _ => {} + } + } +} + +/// Map OMML accent characters to Typst accent functions. +fn map_accent(chr: &str) -> &str { + match chr { + "\u{0302}" | "^" => "hat", + "\u{0303}" | "~" => "tilde", + "\u{0304}" | "\u{00af}" => "macron", + "\u{0307}" | "\u{02d9}" => "dot", + "\u{0308}" | "\u{00a8}" => "dot.double", + "\u{20d7}" | "\u{2192}" => "arrow", + "\u{030c}" => "caron", + "\u{0306}" => "breve", + _ => "hat", + } +} + +/// Parse `` bar/overline: `...content`. +fn parse_bar(reader: &mut Reader<&[u8]>, out: &mut String) { + let mut content = String::new(); + let mut pos = "top".to_string(); + + loop { + match reader.read_event() { + Ok(Event::Start(ref e)) => { + let name = e.local_name(); + match name.as_ref() { + b"barPr" => parse_bar_props(reader, &mut pos), + b"e" => content = parse_sub_element(reader, b"e"), + _ => skip_element(reader, name.as_ref()), + } + } + Ok(Event::End(ref e)) if e.local_name().as_ref() == b"bar" => break, + Ok(Event::Eof) => break, + Err(_) => break, + _ => {} + } + } + + if pos == "bot" { + let _ = std::fmt::Write::write_fmt(out, format_args!("underline({content})")); + } else { + let _ = std::fmt::Write::write_fmt(out, format_args!("overline({content})")); + } +} + +/// Parse bar properties (position: top or bot). +fn parse_bar_props(reader: &mut Reader<&[u8]>, pos: &mut String) { + loop { + match reader.read_event() { + Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => { + if e.local_name().as_ref() == b"pos" + && let Some(v) = get_val_attr(e) + { + *pos = v; + } + } + Ok(Event::End(ref e)) if e.local_name().as_ref() == b"barPr" => break, + Ok(Event::Eof) => break, + Err(_) => break, + _ => {} + } + } +} + +/// Parse `` equation array (system of equations). +fn parse_eq_array(reader: &mut Reader<&[u8]>, out: &mut String) { + let mut equations: Vec = Vec::new(); + + loop { + match reader.read_event() { + Ok(Event::Start(ref e)) => { + let name = e.local_name(); + match name.as_ref() { + b"e" => equations.push(parse_sub_element(reader, b"e")), + b"eqArrPr" => skip_element(reader, b"eqArrPr"), + _ => skip_element(reader, name.as_ref()), + } + } + Ok(Event::End(ref e)) if e.local_name().as_ref() == b"eqArr" => break, + Ok(Event::Eof) => break, + Err(_) => break, + _ => {} + } + } + + for (i, eq) in equations.iter().enumerate() { + if i > 0 { + out.push_str(" \\ "); + } + out.push_str(eq); + } +} + +/// Wrap a string in parentheses if it contains multiple characters (for sub/superscripts). +fn wrap_if_needed(s: &str) -> String { + let trimmed = s.trim(); + if trimmed.chars().count() <= 1 { + trimmed.to_string() + } else { + format!("({trimmed})") + } +} + +/// Skip an element and all its children until its matching end tag. +fn skip_element(reader: &mut Reader<&[u8]>, end_tag: &[u8]) { + let mut depth = 1u32; + loop { + match reader.read_event() { + Ok(Event::Start(ref e)) => { + if e.local_name().as_ref() == end_tag { + depth += 1; + } + } + Ok(Event::End(ref e)) => { + if e.local_name().as_ref() == end_tag { + depth -= 1; + if depth == 0 { + return; + } + } + } + Ok(Event::Eof) => return, + Err(_) => return, + _ => {} + } + } +} + +/// Scan a DOCX `word/document.xml` for math equations. +/// +/// Returns a map from body child index (0-based) to list of math equations. +/// Each top-level ``, ``, or `` in `` is a "body child". +pub(crate) fn scan_math_equations(xml: &str) -> HashMap> { + let mut results: HashMap> = HashMap::new(); + let mut reader = Reader::from_str(xml); + + let mut in_body = false; + let mut body_child_index: usize = 0; + let mut depth_in_body: u32 = 0; + + loop { + match reader.read_event() { + Ok(Event::Start(ref e)) => { + let local = e.local_name(); + let name = local.as_ref(); + + if name == b"body" { + in_body = true; + depth_in_body = 0; + body_child_index = 0; + continue; + } + + if in_body { + depth_in_body += 1; + + if name == b"oMathPara" { + // Display math — parse inline + let mut math_content = String::new(); + parse_omml_children(&mut reader, &mut math_content, b"oMathPara"); + let content = math_content.trim().to_string(); + if !content.is_empty() { + results + .entry(body_child_index) + .or_default() + .push(MathEquationInfo { + content, + display: true, + }); + } + // The End event for oMathPara was consumed by parse_omml_children + depth_in_body -= 1; + } else if name == b"oMath" { + // Inline math — parse inline + let mut math_content = String::new(); + parse_omml_children(&mut reader, &mut math_content, b"oMath"); + let content = math_content.trim().to_string(); + if !content.is_empty() { + results + .entry(body_child_index) + .or_default() + .push(MathEquationInfo { + content, + display: false, + }); + } + depth_in_body -= 1; + } + } + } + Ok(Event::Empty(_)) => { + // Self-closing elements at body level (e.g. bookmarkStart/End) + if in_body && depth_in_body == 0 { + body_child_index += 1; + } + } + Ok(Event::End(ref e)) => { + let name = e.local_name(); + if name.as_ref() == b"body" { + in_body = false; + } else if in_body && depth_in_body > 0 { + depth_in_body -= 1; + if depth_in_body == 0 { + body_child_index += 1; + } + } + } + Ok(Event::Eof) => break, + Err(_) => break, + _ => {} + } + } + + results +} + +#[cfg(test)] +mod tests { + use super::*; + + // ===== OMML to Typst conversion tests ===== + + #[test] + fn test_simple_fraction() { + let xml = r#" + + a + b + + "#; + let result = omml_to_typst(xml); + assert_eq!(result, "frac(a, b)"); + } + + #[test] + fn test_superscript() { + let xml = r#" + + x + 2 + + "#; + let result = omml_to_typst(xml); + assert_eq!(result, "x^2"); + } + + #[test] + fn test_subscript() { + let xml = r#" + + x + 1 + + "#; + let result = omml_to_typst(xml); + assert_eq!(result, "x_1"); + } + + #[test] + fn test_sub_superscript() { + let xml = r#" + + x + i + 2 + + "#; + let result = omml_to_typst(xml); + assert_eq!(result, "x_i^2"); + } + + #[test] + fn test_square_root() { + let xml = r#" + + + + x + + "#; + let result = omml_to_typst(xml); + assert_eq!(result, "sqrt(x)"); + } + + #[test] + fn test_nth_root() { + let xml = r#" + + + 3 + x + + "#; + let result = omml_to_typst(xml); + assert_eq!(result, "root(3, x)"); + } + + #[test] + fn test_parentheses() { + let xml = r#" + + + + + + x+y + + "#; + let result = omml_to_typst(xml); + assert_eq!(result, "(x+y)"); + } + + #[test] + fn test_complex_equation_fraction_with_superscript() { + // a^2 / (b + c) + let xml = r#" + + + + a + 2 + + + + + + b + + + c + + + + + "#; + let result = omml_to_typst(xml); + assert_eq!(result, "frac(a^2, (b+c))"); + } + + #[test] + fn test_sum_with_limits() { + let xml = r#" + + + i=1 + n + i + + "#; + let result = omml_to_typst(xml); + assert_eq!(result, "sum_(i=1)^n i"); + } + + #[test] + fn test_plain_text_e_equals_mc2() { + let xml = r#" + E + = + m + + c + 2 + + "#; + let result = omml_to_typst(xml); + assert_eq!(result, "E=mc^2"); + } + + #[test] + fn test_quadratic_formula() { + let xml = r#" + x + = + + + -b± + + + + + + b + 2 + + -4ac + + + + + 2a + + + "#; + let result = omml_to_typst(xml); + assert_eq!(result, "x=frac(-b\u{00b1}sqrt(b^2-4ac), 2a)"); + } + + // ===== scan_math_equations tests ===== + + #[test] + fn test_scan_display_math() { + let xml = r#" + + + + Before math + + + + + + a + b + + + + + + After math + + + "#; + + let math = scan_math_equations(xml); + assert!( + math.contains_key(&1), + "Math should be in body child index 1" + ); + let eqs = &math[&1]; + assert_eq!(eqs.len(), 1); + assert!(eqs[0].display); + assert_eq!(eqs[0].content, "frac(a, b)"); + } + + #[test] + fn test_scan_inline_math() { + let xml = r#" + + + + The equation + + + x + 2 + + + is nice + + + "#; + + let math = scan_math_equations(xml); + assert!(math.contains_key(&0)); + let eqs = &math[&0]; + assert_eq!(eqs.len(), 1); + assert!(!eqs[0].display); + assert_eq!(eqs[0].content, "x^2"); + } +} diff --git a/crates/office2pdf/src/render/typst_gen.rs b/crates/office2pdf/src/render/typst_gen.rs index 09c39987..781d138b 100644 --- a/crates/office2pdf/src/render/typst_gen.rs +++ b/crates/office2pdf/src/render/typst_gen.rs @@ -5,8 +5,8 @@ use crate::error::ConvertError; use crate::ir::{ Alignment, Block, BorderSide, CellBorder, Color, Document, FixedElement, FixedElementKind, FixedPage, FloatingImage, FlowPage, HFInline, HeaderFooter, ImageData, ImageFormat, - LineSpacing, List, ListKind, Margins, Page, PageSize, Paragraph, ParagraphStyle, Run, Shape, - ShapeKind, Table, TableCell, TablePage, TextStyle, WrapMode, + LineSpacing, List, ListKind, Margins, MathEquation, Page, PageSize, Paragraph, ParagraphStyle, + Run, Shape, ShapeKind, Table, TableCell, TablePage, TextStyle, WrapMode, }; /// An image asset to be embedded in the Typst compilation. @@ -449,6 +449,22 @@ fn generate_block(out: &mut String, block: &Block, ctx: &mut GenCtx) -> Result<( Ok(()) } Block::List(list) => generate_list(out, list), + Block::MathEquation(math) => { + generate_math_equation(out, math); + Ok(()) + } + } +} + +/// Generate Typst markup for a math equation. +/// +/// Display math is rendered as `$ content $` (on its own line, centered). +/// Inline math is rendered as `$content$`. +fn generate_math_equation(out: &mut String, math: &MathEquation) { + if math.display { + let _ = writeln!(out, "$ {} $", math.content); + } else { + let _ = write!(out, "${}$", math.content); } } @@ -653,6 +669,7 @@ fn generate_cell_content( Block::Image(img) => generate_image(out, img, ctx), Block::FloatingImage(fi) => generate_floating_image(out, fi, ctx), Block::List(list) => generate_list(out, list)?, + Block::MathEquation(math) => generate_math_equation(out, math), Block::PageBreak => {} } } @@ -3695,4 +3712,57 @@ mod tests { output.source ); } + + // ── Math equation codegen tests ── + + #[test] + fn test_codegen_display_math() { + let doc = make_doc(vec![make_flow_page(vec![Block::MathEquation( + MathEquation { + content: "frac(a, b)".to_string(), + display: true, + }, + )])]); + + let output = generate_typst(&doc).unwrap(); + assert!( + output.source.contains("$ frac(a, b) $"), + "Expected display math '$ frac(a, b) $', got:\n{}", + output.source + ); + } + + #[test] + fn test_codegen_inline_math() { + let doc = make_doc(vec![make_flow_page(vec![Block::MathEquation( + MathEquation { + content: "x^2".to_string(), + display: false, + }, + )])]); + + let output = generate_typst(&doc).unwrap(); + assert!( + output.source.contains("$x^2$"), + "Expected inline math '$x^2$', got:\n{}", + output.source + ); + } + + #[test] + fn test_codegen_complex_math() { + let doc = make_doc(vec![make_flow_page(vec![Block::MathEquation( + MathEquation { + content: "sum_(i=1)^n i".to_string(), + display: true, + }, + )])]); + + let output = generate_typst(&doc).unwrap(); + assert!( + output.source.contains("$ sum_(i=1)^n i $"), + "Expected display math with sum, got:\n{}", + output.source + ); + } } From 270c47ccc35522d457833474bdd21b8196c70811 Mon Sep 17 00:00:00 2001 From: Yonghye Kwon Date: Fri, 27 Feb 2026 13:03:25 +0900 Subject: [PATCH 2/2] refactor: [US-041] polish OMML parser and update progress Fix depth tracking bug in scan_math_equations, collapse nested if blocks for clippy, and mark US-041 as complete in PRD. Signed-off-by: Yonghye Kwon Co-Authored-By: Claude Opus 4.6 Signed-off-by: Yonghye Kwon --- crates/office2pdf/src/parser/docx.rs | 12 +- crates/office2pdf/src/parser/omml.rs | 893 ++++++++++++--------------- scripts/ralph/prd.json | 2 +- scripts/ralph/progress.txt | 1 + 4 files changed, 396 insertions(+), 512 deletions(-) diff --git a/crates/office2pdf/src/parser/docx.rs b/crates/office2pdf/src/parser/docx.rs index 2969a8b9..63c1f6d1 100644 --- a/crates/office2pdf/src/parser/docx.rs +++ b/crates/office2pdf/src/parser/docx.rs @@ -504,13 +504,11 @@ fn build_math_context(data: &[u8]) -> MathContext { if let Some(xml) = read_zip_text(&mut archive, "word/document.xml") { let raw = super::omml::scan_math_equations(&xml); - for (idx, infos) in raw { - for info in infos { - equations.entry(idx).or_default().push(MathEquation { - content: info.content, - display: info.display, - }); - } + for (idx, content, display) in raw { + equations + .entry(idx) + .or_default() + .push(MathEquation { content, display }); } } diff --git a/crates/office2pdf/src/parser/omml.rs b/crates/office2pdf/src/parser/omml.rs index a0403634..789f1b47 100644 --- a/crates/office2pdf/src/parser/omml.rs +++ b/crates/office2pdf/src/parser/omml.rs @@ -3,33 +3,35 @@ //! Parses OMML XML elements (m:oMath, m:oMathPara) from DOCX documents //! and converts them to Typst math notation strings. -use std::collections::HashMap; - use quick_xml::Reader; use quick_xml::events::Event; -/// A parsed math equation from the document. -pub(crate) struct MathEquationInfo { - /// Typst math notation content (without `$` delimiters). - pub(crate) content: String, - /// Whether this is display math (centered, block-level). - pub(crate) display: bool, -} - -/// Convert an OMML `` XML fragment to Typst math notation. +/// Convert an OMML XML fragment to Typst math notation. /// -/// The input should be the complete `...` element. +/// The input should be the inner content of an `` element. /// Returns the Typst math notation string (without `$` delimiters). -#[cfg(test)] -fn omml_to_typst(xml: &str) -> String { - let mut reader = Reader::from_str(xml); +pub(crate) fn omml_to_typst(xml: &str) -> String { + let wrapped = format!( + "{xml}" + ); + let mut reader = Reader::from_str(&wrapped); let mut result = String::new(); - parse_omml_children(&mut reader, &mut result, b"oMath"); + + // Skip the wrapper start + loop { + match reader.read_event() { + Ok(Event::Start(ref e)) if e.local_name().as_ref() == b"root" => break, + Ok(Event::Eof) => return String::new(), + Err(_) => return String::new(), + _ => {} + } + } + + parse_omml_children(&mut reader, &mut result, b"root"); result.trim().to_string() } /// Recursively parse OMML children and append Typst math notation. -/// `end_tag` is the local name of the element whose End event should stop parsing. fn parse_omml_children(reader: &mut Reader<&[u8]>, out: &mut String, end_tag: &[u8]) { loop { match reader.read_event() { @@ -50,21 +52,11 @@ fn parse_omml_children(reader: &mut Reader<&[u8]>, out: &mut String, end_tag: &[ b"limUpp" => parse_lim_upp(reader, out), b"acc" => parse_accent(reader, out), b"bar" => parse_bar(reader, out), - // Note: b"m" would conflict with the module itself, - // matrix element local name is just "m" but with namespace prefix + b"m" => parse_matrix(reader, out), b"eqArr" => parse_eq_array(reader, out), - b"oMath" => { - // Nested oMath — just process its children - parse_omml_children(reader, out, b"oMath"); - } - b"oMathPara" => { - // Display math wrapper — process children - parse_omml_children(reader, out, b"oMathPara"); - } - _ => { - // Skip unknown elements - skip_element(reader, name); - } + b"oMath" => parse_omml_children(reader, out, b"oMath"), + b"oMathPara" => parse_omml_children(reader, out, b"oMathPara"), + _ => skip_element(reader, name), } } Ok(Event::End(ref e)) => { @@ -79,32 +71,26 @@ fn parse_omml_children(reader: &mut Reader<&[u8]>, out: &mut String, end_tag: &[ } } -/// Parse a single sub-element into a string, returning the content. fn parse_sub_element(reader: &mut Reader<&[u8]>, end_tag: &[u8]) -> String { let mut out = String::new(); parse_omml_children(reader, &mut out, end_tag); out.trim().to_string() } -/// Parse `` fraction element: `......`. fn parse_fraction(reader: &mut Reader<&[u8]>, out: &mut String) { let mut num = String::new(); let mut den = String::new(); loop { match reader.read_event() { - Ok(Event::Start(ref e)) => { - let name = e.local_name(); - match name.as_ref() { - b"num" => num = parse_sub_element(reader, b"num"), - b"den" => den = parse_sub_element(reader, b"den"), - b"fPr" => skip_element(reader, b"fPr"), - _ => skip_element(reader, name.as_ref()), - } - } + Ok(Event::Start(ref e)) => match e.local_name().as_ref() { + b"num" => num = parse_sub_element(reader, b"num"), + b"den" => den = parse_sub_element(reader, b"den"), + b"fPr" => skip_element(reader, b"fPr"), + other => skip_element(reader, other), + }, Ok(Event::End(ref e)) if e.local_name().as_ref() == b"f" => break, - Ok(Event::Eof) => break, - Err(_) => break, + Ok(Event::Eof) | Err(_) => break, _ => {} } } @@ -112,63 +98,50 @@ fn parse_fraction(reader: &mut Reader<&[u8]>, out: &mut String) { let _ = std::fmt::Write::write_fmt(out, format_args!("frac({num}, {den})")); } -/// Parse `` superscript: `baseexp`. fn parse_superscript(reader: &mut Reader<&[u8]>, out: &mut String) { let mut base = String::new(); let mut sup = String::new(); loop { match reader.read_event() { - Ok(Event::Start(ref e)) => { - let name = e.local_name(); - match name.as_ref() { - b"e" => base = parse_sub_element(reader, b"e"), - b"sup" => sup = parse_sub_element(reader, b"sup"), - b"sSupPr" => skip_element(reader, b"sSupPr"), - _ => skip_element(reader, name.as_ref()), - } - } + Ok(Event::Start(ref e)) => match e.local_name().as_ref() { + b"e" => base = parse_sub_element(reader, b"e"), + b"sup" => sup = parse_sub_element(reader, b"sup"), + b"sSupPr" => skip_element(reader, b"sSupPr"), + other => skip_element(reader, other), + }, Ok(Event::End(ref e)) if e.local_name().as_ref() == b"sSup" => break, - Ok(Event::Eof) => break, - Err(_) => break, + Ok(Event::Eof) | Err(_) => break, _ => {} } } out.push_str(&base); - let wrapped = wrap_if_needed(&sup); - let _ = std::fmt::Write::write_fmt(out, format_args!("^{wrapped}")); + let _ = std::fmt::Write::write_fmt(out, format_args!("^{}", wrap_if_needed(&sup))); } -/// Parse `` subscript: `basesub`. fn parse_subscript(reader: &mut Reader<&[u8]>, out: &mut String) { let mut base = String::new(); let mut sub = String::new(); loop { match reader.read_event() { - Ok(Event::Start(ref e)) => { - let name = e.local_name(); - match name.as_ref() { - b"e" => base = parse_sub_element(reader, b"e"), - b"sub" => sub = parse_sub_element(reader, b"sub"), - b"sSubPr" => skip_element(reader, b"sSubPr"), - _ => skip_element(reader, name.as_ref()), - } - } + Ok(Event::Start(ref e)) => match e.local_name().as_ref() { + b"e" => base = parse_sub_element(reader, b"e"), + b"sub" => sub = parse_sub_element(reader, b"sub"), + b"sSubPr" => skip_element(reader, b"sSubPr"), + other => skip_element(reader, other), + }, Ok(Event::End(ref e)) if e.local_name().as_ref() == b"sSub" => break, - Ok(Event::Eof) => break, - Err(_) => break, + Ok(Event::Eof) | Err(_) => break, _ => {} } } out.push_str(&base); - let wrapped = wrap_if_needed(&sub); - let _ = std::fmt::Write::write_fmt(out, format_args!("_{wrapped}")); + let _ = std::fmt::Write::write_fmt(out, format_args!("_{}", wrap_if_needed(&sub))); } -/// Parse ``: `basesubsup`. fn parse_sub_superscript(reader: &mut Reader<&[u8]>, out: &mut String) { let mut base = String::new(); let mut sub = String::new(); @@ -176,30 +149,26 @@ fn parse_sub_superscript(reader: &mut Reader<&[u8]>, out: &mut String) { loop { match reader.read_event() { - Ok(Event::Start(ref e)) => { - let name = e.local_name(); - match name.as_ref() { - b"e" => base = parse_sub_element(reader, b"e"), - b"sub" => sub = parse_sub_element(reader, b"sub"), - b"sup" => sup = parse_sub_element(reader, b"sup"), - b"sSubSupPr" => skip_element(reader, b"sSubSupPr"), - _ => skip_element(reader, name.as_ref()), - } - } + Ok(Event::Start(ref e)) => match e.local_name().as_ref() { + b"e" => base = parse_sub_element(reader, b"e"), + b"sub" => sub = parse_sub_element(reader, b"sub"), + b"sup" => sup = parse_sub_element(reader, b"sup"), + b"sSubSupPr" => skip_element(reader, b"sSubSupPr"), + other => skip_element(reader, other), + }, Ok(Event::End(ref e)) if e.local_name().as_ref() == b"sSubSup" => break, - Ok(Event::Eof) => break, - Err(_) => break, + Ok(Event::Eof) | Err(_) => break, _ => {} } } out.push_str(&base); - let w_sub = wrap_if_needed(&sub); - let w_sup = wrap_if_needed(&sup); - let _ = std::fmt::Write::write_fmt(out, format_args!("_{w_sub}^{w_sup}")); + let _ = std::fmt::Write::write_fmt( + out, + format_args!("_{}^{}", wrap_if_needed(&sub), wrap_if_needed(&sup)), + ); } -/// Parse `` radical: `degreecontent`. fn parse_radical(reader: &mut Reader<&[u8]>, out: &mut String) { let mut deg = String::new(); let mut content = String::new(); @@ -207,18 +176,14 @@ fn parse_radical(reader: &mut Reader<&[u8]>, out: &mut String) { loop { match reader.read_event() { - Ok(Event::Start(ref e)) => { - let name = e.local_name(); - match name.as_ref() { - b"radPr" => deg_hide = parse_rad_props(reader), - b"deg" => deg = parse_sub_element(reader, b"deg"), - b"e" => content = parse_sub_element(reader, b"e"), - _ => skip_element(reader, name.as_ref()), - } - } + Ok(Event::Start(ref e)) => match e.local_name().as_ref() { + b"radPr" => deg_hide = parse_radical_props(reader), + b"deg" => deg = parse_sub_element(reader, b"deg"), + b"e" => content = parse_sub_element(reader, b"e"), + other => skip_element(reader, other), + }, Ok(Event::End(ref e)) if e.local_name().as_ref() == b"rad" => break, - Ok(Event::Eof) => break, - Err(_) => break, + Ok(Event::Eof) | Err(_) => break, _ => {} } } @@ -230,8 +195,7 @@ fn parse_radical(reader: &mut Reader<&[u8]>, out: &mut String) { } } -/// Parse radical properties to check if degree is hidden (square root). -fn parse_rad_props(reader: &mut Reader<&[u8]>) -> bool { +fn parse_radical_props(reader: &mut Reader<&[u8]>) -> bool { let mut deg_hide = false; loop { match reader.read_event() { @@ -247,15 +211,13 @@ fn parse_rad_props(reader: &mut Reader<&[u8]>) -> bool { } } Ok(Event::End(ref e)) if e.local_name().as_ref() == b"radPr" => break, - Ok(Event::Eof) => break, - Err(_) => break, + Ok(Event::Eof) | Err(_) => break, _ => {} } } deg_hide } -/// Parse `` delimiter: `...content`. fn parse_delimiter(reader: &mut Reader<&[u8]>, out: &mut String) { let mut beg_chr = "(".to_string(); let mut end_chr = ")".to_string(); @@ -263,17 +225,13 @@ fn parse_delimiter(reader: &mut Reader<&[u8]>, out: &mut String) { loop { match reader.read_event() { - Ok(Event::Start(ref e)) => { - let name = e.local_name(); - match name.as_ref() { - b"dPr" => parse_delimiter_props(reader, &mut beg_chr, &mut end_chr), - b"e" => elements.push(parse_sub_element(reader, b"e")), - _ => skip_element(reader, name.as_ref()), - } - } + Ok(Event::Start(ref e)) => match e.local_name().as_ref() { + b"dPr" => parse_delimiter_props(reader, &mut beg_chr, &mut end_chr), + b"e" => elements.push(parse_sub_element(reader, b"e")), + other => skip_element(reader, other), + }, Ok(Event::End(ref e)) if e.local_name().as_ref() == b"d" => break, - Ok(Event::Eof) => break, - Err(_) => break, + Ok(Event::Eof) | Err(_) => break, _ => {} } } @@ -284,105 +242,90 @@ fn parse_delimiter(reader: &mut Reader<&[u8]>, out: &mut String) { let _ = std::fmt::Write::write_fmt(out, format_args!("{beg}{content}{end}")); } -/// Map OMML delimiter characters to Typst math equivalents. fn map_delimiter(chr: &str) -> &str { match chr { - "(" | ")" | "[" | "]" | "|" => chr, - "{" => "{", - "}" => "}", + "(" | ")" | "[" | "]" | "{" | "}" | "|" => chr, + "\u{2016}" | "||" => "\u{2016}", + "\u{27E8}" | "<" => "\u{27E8}", + "\u{27E9}" | ">" => "\u{27E9}", _ => chr, } } -/// Parse delimiter properties for begin/end characters. fn parse_delimiter_props(reader: &mut Reader<&[u8]>, beg: &mut String, end: &mut String) { loop { match reader.read_event() { - Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => { - let name = e.local_name(); - match name.as_ref() { - b"begChr" => { - if let Some(v) = get_val_attr(e) { - *beg = v; + Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => match e.local_name().as_ref() { + b"begChr" => { + for attr in e.attributes().flatten() { + if attr.key.local_name().as_ref() == b"val" + && let Ok(v) = attr.unescape_value() + { + *beg = v.to_string(); } } - b"endChr" => { - if let Some(v) = get_val_attr(e) { - *end = v; + } + b"endChr" => { + for attr in e.attributes().flatten() { + if attr.key.local_name().as_ref() == b"val" + && let Ok(v) = attr.unescape_value() + { + *end = v.to_string(); } } - _ => {} } - } + _ => {} + }, Ok(Event::End(ref e)) if e.local_name().as_ref() == b"dPr" => break, - Ok(Event::Eof) => break, - Err(_) => break, + Ok(Event::Eof) | Err(_) => break, _ => {} } } } -/// Extract the `m:val` or `val` attribute value from an element. -fn get_val_attr(e: &quick_xml::events::BytesStart<'_>) -> Option { - for attr in e.attributes().flatten() { - if attr.key.local_name().as_ref() == b"val" - && let Ok(v) = attr.unescape_value() - { - return Some(v.to_string()); - } - } - None -} - -/// Parse `` math run: extract text from ``. fn parse_math_run(reader: &mut Reader<&[u8]>, out: &mut String) { + let mut in_text = false; + loop { match reader.read_event() { - Ok(Event::Start(ref e)) => { - let name = e.local_name(); - match name.as_ref() { - b"t" => { - // Read text content - if let Ok(Event::Text(ref text)) = reader.read_event() - && let Ok(s) = text.xml_content() - { - out.push_str(&s); - } - } - b"rPr" => skip_element(reader, b"rPr"), - _ => skip_element(reader, name.as_ref()), + Ok(Event::Start(ref e)) => match e.local_name().as_ref() { + b"t" => in_text = true, + b"rPr" => skip_element(reader, b"rPr"), + _ => {} + }, + Ok(Event::Text(ref t)) if in_text => { + if let Ok(s) = t.xml_content() { + out.push_str(s.as_ref()); } } - Ok(Event::End(ref e)) if e.local_name().as_ref() == b"r" => break, - Ok(Event::Eof) => break, - Err(_) => break, + Ok(Event::End(ref e)) => match e.local_name().as_ref() { + b"t" => in_text = false, + b"r" => break, + _ => {} + }, + Ok(Event::Eof) | Err(_) => break, _ => {} } } } -/// Parse `` n-ary operator (sum, product, integral, etc.). fn parse_nary(reader: &mut Reader<&[u8]>, out: &mut String) { - let mut chr = "\u{2211}".to_string(); // default is sum ∑ + let mut chr = "\u{2211}".to_string(); let mut sub = String::new(); let mut sup = String::new(); let mut content = String::new(); loop { match reader.read_event() { - Ok(Event::Start(ref e)) => { - let name = e.local_name(); - match name.as_ref() { - b"naryPr" => parse_nary_props(reader, &mut chr), - b"sub" => sub = parse_sub_element(reader, b"sub"), - b"sup" => sup = parse_sub_element(reader, b"sup"), - b"e" => content = parse_sub_element(reader, b"e"), - _ => skip_element(reader, name.as_ref()), - } - } + Ok(Event::Start(ref e)) => match e.local_name().as_ref() { + b"naryPr" => parse_nary_props(reader, &mut chr), + b"sub" => sub = parse_sub_element(reader, b"sub"), + b"sup" => sup = parse_sub_element(reader, b"sup"), + b"e" => content = parse_sub_element(reader, b"e"), + other => skip_element(reader, other), + }, Ok(Event::End(ref e)) if e.local_name().as_ref() == b"nary" => break, - Ok(Event::Eof) => break, - Err(_) => break, + Ok(Event::Eof) | Err(_) => break, _ => {} } } @@ -390,70 +333,64 @@ fn parse_nary(reader: &mut Reader<&[u8]>, out: &mut String) { let op = map_nary_operator(&chr); out.push_str(op); if !sub.is_empty() { - let w = wrap_if_needed(&sub); - let _ = std::fmt::Write::write_fmt(out, format_args!("_{w}")); + let _ = std::fmt::Write::write_fmt(out, format_args!("_{}", wrap_if_needed(&sub))); } if !sup.is_empty() { - let w = wrap_if_needed(&sup); - let _ = std::fmt::Write::write_fmt(out, format_args!("^{w}")); + let _ = std::fmt::Write::write_fmt(out, format_args!("^{}", wrap_if_needed(&sup))); } out.push(' '); out.push_str(&content); } -/// Parse n-ary properties to extract the operator character. fn parse_nary_props(reader: &mut Reader<&[u8]>, chr: &mut String) { loop { match reader.read_event() { Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => { - if e.local_name().as_ref() == b"chr" - && let Some(v) = get_val_attr(e) - { - *chr = v; + if e.local_name().as_ref() == b"chr" { + for attr in e.attributes().flatten() { + if attr.key.local_name().as_ref() == b"val" + && let Ok(v) = attr.unescape_value() + { + *chr = v.to_string(); + } + } } } Ok(Event::End(ref e)) if e.local_name().as_ref() == b"naryPr" => break, - Ok(Event::Eof) => break, - Err(_) => break, + Ok(Event::Eof) | Err(_) => break, _ => {} } } } -/// Map OMML n-ary operator characters to Typst math operators. fn map_nary_operator(chr: &str) -> &str { match chr { - "\u{2211}" => "sum", // ∑ - "\u{220f}" => "product", // ∏ - "\u{222b}" => "integral", // ∫ - "\u{222c}" => "integral.double", // ∬ - "\u{222d}" => "integral.triple", // ∭ - "\u{222e}" => "integral.cont", // ∮ - "\u{22c3}" => "union.big", // ⋃ - "\u{22c2}" => "sect.big", // ⋂ + "\u{2211}" => "sum", + "\u{220F}" => "product", + "\u{222B}" => "integral", + "\u{222C}" => "integral.double", + "\u{222D}" => "integral.triple", + "\u{222E}" => "integral.cont", + "\u{22C3}" => "union.big", + "\u{22C2}" => "sect.big", _ => "sum", } } -/// Parse `` function application: `namearg`. fn parse_function(reader: &mut Reader<&[u8]>, out: &mut String) { let mut name = String::new(); let mut content = String::new(); loop { match reader.read_event() { - Ok(Event::Start(ref e)) => { - let local = e.local_name(); - match local.as_ref() { - b"fName" => name = parse_sub_element(reader, b"fName"), - b"e" => content = parse_sub_element(reader, b"e"), - b"funcPr" => skip_element(reader, b"funcPr"), - _ => skip_element(reader, local.as_ref()), - } - } + Ok(Event::Start(ref e)) => match e.local_name().as_ref() { + b"fName" => name = parse_sub_element(reader, b"fName"), + b"e" => content = parse_sub_element(reader, b"e"), + b"funcPr" => skip_element(reader, b"funcPr"), + other => skip_element(reader, other), + }, Ok(Event::End(ref e)) if e.local_name().as_ref() == b"func" => break, - Ok(Event::Eof) => break, - Err(_) => break, + Ok(Event::Eof) | Err(_) => break, _ => {} } } @@ -462,80 +399,63 @@ fn parse_function(reader: &mut Reader<&[u8]>, out: &mut String) { let _ = std::fmt::Write::write_fmt(out, format_args!("{func_name} {content}")); } -/// Parse `` lower limit: `baselimit`. fn parse_lim_low(reader: &mut Reader<&[u8]>, out: &mut String) { let mut base = String::new(); let mut lim = String::new(); loop { match reader.read_event() { - Ok(Event::Start(ref e)) => { - let name = e.local_name(); - match name.as_ref() { - b"e" => base = parse_sub_element(reader, b"e"), - b"lim" => lim = parse_sub_element(reader, b"lim"), - b"limLowPr" => skip_element(reader, b"limLowPr"), - _ => skip_element(reader, name.as_ref()), - } - } + Ok(Event::Start(ref e)) => match e.local_name().as_ref() { + b"e" => base = parse_sub_element(reader, b"e"), + b"lim" => lim = parse_sub_element(reader, b"lim"), + b"limLowPr" => skip_element(reader, b"limLowPr"), + other => skip_element(reader, other), + }, Ok(Event::End(ref e)) if e.local_name().as_ref() == b"limLow" => break, - Ok(Event::Eof) => break, - Err(_) => break, + Ok(Event::Eof) | Err(_) => break, _ => {} } } out.push_str(&base); - let w = wrap_if_needed(&lim); - let _ = std::fmt::Write::write_fmt(out, format_args!("_{w}")); + let _ = std::fmt::Write::write_fmt(out, format_args!("_{}", wrap_if_needed(&lim))); } -/// Parse `` upper limit: `baselimit`. fn parse_lim_upp(reader: &mut Reader<&[u8]>, out: &mut String) { let mut base = String::new(); let mut lim = String::new(); loop { match reader.read_event() { - Ok(Event::Start(ref e)) => { - let name = e.local_name(); - match name.as_ref() { - b"e" => base = parse_sub_element(reader, b"e"), - b"lim" => lim = parse_sub_element(reader, b"lim"), - b"limUppPr" => skip_element(reader, b"limUppPr"), - _ => skip_element(reader, name.as_ref()), - } - } + Ok(Event::Start(ref e)) => match e.local_name().as_ref() { + b"e" => base = parse_sub_element(reader, b"e"), + b"lim" => lim = parse_sub_element(reader, b"lim"), + b"limUppPr" => skip_element(reader, b"limUppPr"), + other => skip_element(reader, other), + }, Ok(Event::End(ref e)) if e.local_name().as_ref() == b"limUpp" => break, - Ok(Event::Eof) => break, - Err(_) => break, + Ok(Event::Eof) | Err(_) => break, _ => {} } } out.push_str(&base); - let w = wrap_if_needed(&lim); - let _ = std::fmt::Write::write_fmt(out, format_args!("^{w}")); + let _ = std::fmt::Write::write_fmt(out, format_args!("^{}", wrap_if_needed(&lim))); } -/// Parse `` accent: `...content`. fn parse_accent(reader: &mut Reader<&[u8]>, out: &mut String) { - let mut chr = "\u{0302}".to_string(); // combining circumflex accent (hat) + let mut chr = "\u{0302}".to_string(); let mut content = String::new(); loop { match reader.read_event() { - Ok(Event::Start(ref e)) => { - let name = e.local_name(); - match name.as_ref() { - b"accPr" => parse_props_with_chr(reader, b"accPr", &mut chr), - b"e" => content = parse_sub_element(reader, b"e"), - _ => skip_element(reader, name.as_ref()), - } - } + Ok(Event::Start(ref e)) => match e.local_name().as_ref() { + b"accPr" => parse_accent_props(reader, &mut chr), + b"e" => content = parse_sub_element(reader, b"e"), + other => skip_element(reader, other), + }, Ok(Event::End(ref e)) if e.local_name().as_ref() == b"acc" => break, - Ok(Event::Eof) => break, - Err(_) => break, + Ok(Event::Eof) | Err(_) => break, _ => {} } } @@ -544,58 +464,54 @@ fn parse_accent(reader: &mut Reader<&[u8]>, out: &mut String) { let _ = std::fmt::Write::write_fmt(out, format_args!("{accent}({content})")); } -/// Parse a properties element looking for a `chr` child with a `val` attribute. -fn parse_props_with_chr(reader: &mut Reader<&[u8]>, end_tag: &[u8], chr: &mut String) { +fn parse_accent_props(reader: &mut Reader<&[u8]>, chr: &mut String) { loop { match reader.read_event() { Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => { - if e.local_name().as_ref() == b"chr" - && let Some(v) = get_val_attr(e) - { - *chr = v; + if e.local_name().as_ref() == b"chr" { + for attr in e.attributes().flatten() { + if attr.key.local_name().as_ref() == b"val" + && let Ok(v) = attr.unescape_value() + { + *chr = v.to_string(); + } + } } } - Ok(Event::End(ref e)) if e.local_name().as_ref() == end_tag => break, - Ok(Event::Eof) => break, - Err(_) => break, + Ok(Event::End(ref e)) if e.local_name().as_ref() == b"accPr" => break, + Ok(Event::Eof) | Err(_) => break, _ => {} } } } -/// Map OMML accent characters to Typst accent functions. fn map_accent(chr: &str) -> &str { match chr { "\u{0302}" | "^" => "hat", "\u{0303}" | "~" => "tilde", - "\u{0304}" | "\u{00af}" => "macron", - "\u{0307}" | "\u{02d9}" => "dot", - "\u{0308}" | "\u{00a8}" => "dot.double", - "\u{20d7}" | "\u{2192}" => "arrow", - "\u{030c}" => "caron", + "\u{0304}" | "\u{00AF}" => "macron", + "\u{0307}" | "\u{02D9}" => "dot", + "\u{0308}" | "\u{00A8}" => "dot.double", + "\u{20D7}" | "\u{2192}" => "arrow", + "\u{030C}" => "caron", "\u{0306}" => "breve", _ => "hat", } } -/// Parse `` bar/overline: `...content`. fn parse_bar(reader: &mut Reader<&[u8]>, out: &mut String) { let mut content = String::new(); let mut pos = "top".to_string(); loop { match reader.read_event() { - Ok(Event::Start(ref e)) => { - let name = e.local_name(); - match name.as_ref() { - b"barPr" => parse_bar_props(reader, &mut pos), - b"e" => content = parse_sub_element(reader, b"e"), - _ => skip_element(reader, name.as_ref()), - } - } + Ok(Event::Start(ref e)) => match e.local_name().as_ref() { + b"barPr" => parse_bar_props(reader, &mut pos), + b"e" => content = parse_sub_element(reader, b"e"), + other => skip_element(reader, other), + }, Ok(Event::End(ref e)) if e.local_name().as_ref() == b"bar" => break, - Ok(Event::Eof) => break, - Err(_) => break, + Ok(Event::Eof) | Err(_) => break, _ => {} } } @@ -607,42 +523,86 @@ fn parse_bar(reader: &mut Reader<&[u8]>, out: &mut String) { } } -/// Parse bar properties (position: top or bot). fn parse_bar_props(reader: &mut Reader<&[u8]>, pos: &mut String) { loop { match reader.read_event() { Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => { - if e.local_name().as_ref() == b"pos" - && let Some(v) = get_val_attr(e) - { - *pos = v; + if e.local_name().as_ref() == b"pos" { + for attr in e.attributes().flatten() { + if attr.key.local_name().as_ref() == b"val" + && let Ok(v) = attr.unescape_value() + { + *pos = v.to_string(); + } + } } } Ok(Event::End(ref e)) if e.local_name().as_ref() == b"barPr" => break, - Ok(Event::Eof) => break, - Err(_) => break, + Ok(Event::Eof) | Err(_) => break, _ => {} } } } -/// Parse `` equation array (system of equations). -fn parse_eq_array(reader: &mut Reader<&[u8]>, out: &mut String) { - let mut equations: Vec = Vec::new(); +fn parse_matrix(reader: &mut Reader<&[u8]>, out: &mut String) { + let mut rows: Vec> = Vec::new(); + + loop { + match reader.read_event() { + Ok(Event::Start(ref e)) => match e.local_name().as_ref() { + b"mr" => rows.push(parse_matrix_row(reader)), + b"mPr" => skip_element(reader, b"mPr"), + other => skip_element(reader, other), + }, + Ok(Event::End(ref e)) if e.local_name().as_ref() == b"m" => break, + Ok(Event::Eof) | Err(_) => break, + _ => {} + } + } + + out.push_str("mat("); + for (i, row) in rows.iter().enumerate() { + if i > 0 { + out.push_str("; "); + } + out.push_str(&row.join(", ")); + } + out.push(')'); +} + +fn parse_matrix_row(reader: &mut Reader<&[u8]>) -> Vec { + let mut elements = Vec::new(); loop { match reader.read_event() { Ok(Event::Start(ref e)) => { - let name = e.local_name(); - match name.as_ref() { - b"e" => equations.push(parse_sub_element(reader, b"e")), - b"eqArrPr" => skip_element(reader, b"eqArrPr"), - _ => skip_element(reader, name.as_ref()), + if e.local_name().as_ref() == b"e" { + elements.push(parse_sub_element(reader, b"e")); + } else { + skip_element(reader, e.local_name().as_ref()); } } + Ok(Event::End(ref e)) if e.local_name().as_ref() == b"mr" => break, + Ok(Event::Eof) | Err(_) => break, + _ => {} + } + } + + elements +} + +fn parse_eq_array(reader: &mut Reader<&[u8]>, out: &mut String) { + let mut equations: Vec = Vec::new(); + + loop { + match reader.read_event() { + Ok(Event::Start(ref e)) => match e.local_name().as_ref() { + b"e" => equations.push(parse_sub_element(reader, b"e")), + b"eqArrPr" => skip_element(reader, b"eqArrPr"), + other => skip_element(reader, other), + }, Ok(Event::End(ref e)) if e.local_name().as_ref() == b"eqArr" => break, - Ok(Event::Eof) => break, - Err(_) => break, + Ok(Event::Eof) | Err(_) => break, _ => {} } } @@ -655,7 +615,6 @@ fn parse_eq_array(reader: &mut Reader<&[u8]>, out: &mut String) { } } -/// Wrap a string in parentheses if it contains multiple characters (for sub/superscripts). fn wrap_if_needed(s: &str) -> String { let trimmed = s.trim(); if trimmed.chars().count() <= 1 { @@ -665,7 +624,6 @@ fn wrap_if_needed(s: &str) -> String { } } -/// Skip an element and all its children until its matching end tag. fn skip_element(reader: &mut Reader<&[u8]>, end_tag: &[u8]) { let mut depth = 1u32; loop { @@ -683,19 +641,17 @@ fn skip_element(reader: &mut Reader<&[u8]>, end_tag: &[u8]) { } } } - Ok(Event::Eof) => return, - Err(_) => return, + Ok(Event::Eof) | Err(_) => return, _ => {} } } } -/// Scan a DOCX `word/document.xml` for math equations. +/// Scan `word/document.xml` for math equations. /// -/// Returns a map from body child index (0-based) to list of math equations. -/// Each top-level ``, ``, or `` in `` is a "body child". -pub(crate) fn scan_math_equations(xml: &str) -> HashMap> { - let mut results: HashMap> = HashMap::new(); +/// Returns `(body_child_index, typst_math, is_display)` tuples. +pub(crate) fn scan_math_equations(xml: &str) -> Vec<(usize, String, bool)> { + let mut results = Vec::new(); let mut reader = Reader::from_str(xml); let mut in_body = false; @@ -719,45 +675,24 @@ pub(crate) fn scan_math_equations(xml: &str) -> HashMap { - // Self-closing elements at body level (e.g. bookmarkStart/End) - if in_body && depth_in_body == 0 { - body_child_index += 1; - } - } Ok(Event::End(ref e)) => { let name = e.local_name(); if name.as_ref() == b"body" { @@ -778,248 +713,198 @@ pub(crate) fn scan_math_equations(xml: &str) -> HashMap, end_tag: &[u8]) -> String { + let mut depth = 1u32; + let mut content = String::new(); + + loop { + match reader.read_event() { + Ok(Event::Start(ref e)) => { + if e.local_name().as_ref() == end_tag { + depth += 1; + } + content.push('<'); + content.push_str(&String::from_utf8_lossy(e.name().as_ref())); + for attr in e.attributes().flatten() { + content.push(' '); + content.push_str(&String::from_utf8_lossy(attr.key.as_ref())); + content.push_str("=\""); + if let Ok(val) = attr.unescape_value() { + content.push_str(&val); + } + content.push('"'); + } + content.push('>'); + } + Ok(Event::Empty(ref e)) => { + content.push('<'); + content.push_str(&String::from_utf8_lossy(e.name().as_ref())); + for attr in e.attributes().flatten() { + content.push(' '); + content.push_str(&String::from_utf8_lossy(attr.key.as_ref())); + content.push_str("=\""); + if let Ok(val) = attr.unescape_value() { + content.push_str(&val); + } + content.push('"'); + } + content.push_str("/>"); + } + Ok(Event::End(ref e)) => { + if e.local_name().as_ref() == end_tag { + depth -= 1; + if depth == 0 { + return content; + } + } + content.push_str("'); + } + Ok(Event::Text(ref t)) => { + if let Ok(text) = t.xml_content() { + content.push_str(text.as_ref()); + } + } + Ok(Event::Eof) => break, + Err(_) => break, + _ => {} + } + } + + content +} + #[cfg(test)] mod tests { use super::*; - // ===== OMML to Typst conversion tests ===== - #[test] fn test_simple_fraction() { - let xml = r#" - - a - b - - "#; - let result = omml_to_typst(xml); - assert_eq!(result, "frac(a, b)"); + let xml = "ab"; + assert_eq!(omml_to_typst(xml), "frac(a, b)"); } #[test] fn test_superscript() { - let xml = r#" - - x - 2 - - "#; - let result = omml_to_typst(xml); - assert_eq!(result, "x^2"); + let xml = "x2"; + assert_eq!(omml_to_typst(xml), "x^2"); } #[test] fn test_subscript() { - let xml = r#" - - x - 1 - - "#; - let result = omml_to_typst(xml); - assert_eq!(result, "x_1"); + let xml = "x1"; + assert_eq!(omml_to_typst(xml), "x_1"); } #[test] fn test_sub_superscript() { - let xml = r#" - - x - i - 2 - - "#; - let result = omml_to_typst(xml); - assert_eq!(result, "x_i^2"); + let xml = "xi2"; + assert_eq!(omml_to_typst(xml), "x_i^2"); } #[test] fn test_square_root() { - let xml = r#" - - - - x - - "#; - let result = omml_to_typst(xml); - assert_eq!(result, "sqrt(x)"); + let xml = r#"x"#; + assert_eq!(omml_to_typst(xml), "sqrt(x)"); } #[test] fn test_nth_root() { - let xml = r#" - - - 3 - x - - "#; - let result = omml_to_typst(xml); - assert_eq!(result, "root(3, x)"); + let xml = r#"3x"#; + assert_eq!(omml_to_typst(xml), "root(3, x)"); } #[test] fn test_parentheses() { - let xml = r#" - - - - - - x+y - - "#; - let result = omml_to_typst(xml); - assert_eq!(result, "(x+y)"); + let xml = r#"x+y"#; + assert_eq!(omml_to_typst(xml), "(x+y)"); } #[test] - fn test_complex_equation_fraction_with_superscript() { - // a^2 / (b + c) - let xml = r#" - - - - a - 2 - - - - - - b - + - c - - - - - "#; - let result = omml_to_typst(xml); - assert_eq!(result, "frac(a^2, (b+c))"); + fn test_complex_equation() { + let xml = "a2b+c"; + assert_eq!(omml_to_typst(xml), "frac(a^2, (b+c))"); } #[test] fn test_sum_with_limits() { - let xml = r#" - - - i=1 - n - i - - "#; - let result = omml_to_typst(xml); - assert_eq!(result, "sum_(i=1)^n i"); + let xml = r#"i=1ni"#; + assert_eq!(omml_to_typst(xml), "sum_(i=1)^n i"); } #[test] - fn test_plain_text_e_equals_mc2() { - let xml = r#" - E - = - m - - c - 2 - - "#; - let result = omml_to_typst(xml); - assert_eq!(result, "E=mc^2"); + fn test_emc2() { + let xml = "E=mc2"; + assert_eq!(omml_to_typst(xml), "E=mc^2"); } #[test] fn test_quadratic_formula() { - let xml = r#" - x - = - - - -b± - - - - - - b - 2 - - -4ac - - - - - 2a - - - "#; - let result = omml_to_typst(xml); - assert_eq!(result, "x=frac(-b\u{00b1}sqrt(b^2-4ac), 2a)"); + let xml = r#"x=-b±b2-4ac2a"#; + assert_eq!(omml_to_typst(xml), "x=frac(-b±sqrt(b^2-4ac), 2a)"); } - // ===== scan_math_equations tests ===== - #[test] fn test_scan_display_math() { - let xml = r#" + let xml = r#" - - Before math - - - - a - b - - + E=mc2 - - After math - "#; - let math = scan_math_equations(xml); - assert!( - math.contains_key(&1), - "Math should be in body child index 1" - ); - let eqs = &math[&1]; - assert_eq!(eqs.len(), 1); - assert!(eqs[0].display); - assert_eq!(eqs[0].content, "frac(a, b)"); + let results = scan_math_equations(xml); + assert_eq!(results.len(), 1); + assert_eq!(results[0].0, 0); + assert_eq!(results[0].1, "E=mc^2"); + assert!(results[0].2); } #[test] fn test_scan_inline_math() { - let xml = r#" + let xml = r#" + Text - The equation - - - x - 2 - - - is nice + x=5 "#; - let math = scan_math_equations(xml); - assert!(math.contains_key(&0)); - let eqs = &math[&0]; - assert_eq!(eqs.len(), 1); - assert!(!eqs[0].display); - assert_eq!(eqs[0].content, "x^2"); + let results = scan_math_equations(xml); + assert_eq!(results.len(), 1); + assert_eq!(results[0].0, 1); + assert_eq!(results[0].1, "x=5"); + assert!(!results[0].2); + } + + #[test] + fn test_scan_multiple_equations() { + let xml = r#" + + + a=1 + text + b=2 + + "#; + + let results = scan_math_equations(xml); + assert_eq!(results.len(), 2); + assert_eq!(results[0].0, 0); + assert_eq!(results[0].1, "a=1"); + assert!(results[0].2); + assert_eq!(results[1].0, 2); + assert_eq!(results[1].1, "b=2"); + assert!(!results[1].2); } } diff --git a/scripts/ralph/prd.json b/scripts/ralph/prd.json index fb84b484..b8604928 100644 --- a/scripts/ralph/prd.json +++ b/scripts/ralph/prd.json @@ -35,7 +35,7 @@ "cargo clippy --workspace -- -D warnings passes" ], "priority": 2, - "passes": false, + "passes": true, "notes": "" }, { diff --git a/scripts/ralph/progress.txt b/scripts/ralph/progress.txt index ca931fac..22a7fac0 100644 --- a/scripts/ralph/progress.txt +++ b/scripts/ralph/progress.txt @@ -61,6 +61,7 @@ - **Test PPTX with layout/master**: Use `build_test_pptx_with_layout_master(cx, cy, slide_xml, layout_xml, master_xml)`. Creates full inheritance chain: slide1 → slideLayout1 → slideMaster1 with .rels files at each level. `make_slide_xml_with_bg(bg_xml, shapes)` creates slide XML with `` inside ``. - **PPTX layout/master element inheritance**: `parse_single_slide()` resolves layout and master paths via `resolve_layout_master_paths()`, then parses elements from master, layout, and slide in order (master behind → layout → slide on top). Uses `rels_path_for()` helper for .rels path construction. `parse_slide_xml()` works on any XML with `` — same function parses slide, layout, and master shapes. Test helpers: `make_layout_xml(shapes)`, `make_master_xml(shapes)`, `build_test_pptx_with_layout_master_multi_slide(cx, cy, slides, layout, master)` for multi-slide testing. - **PPTX tables**: Tables live inside `` elements (not ``). Position from `` (not ``). Table XML: ``. Grid: ``. Rows: ``. Cells: `` with optional `gridSpan` (colspan), `rowSpan`, `hMerge`/`vMerge` (continuation). Cell content: `` with same paragraph/run structure as shapes. Cell properties: `` with `` for background, `` for borders. Maps to `FixedElementKind::Table(Table)` in IR. `parse_pptx_table()` is a standalone sub-parser called from `parse_slide_xml()` when `` is found. Test helpers: `make_table_graphic_frame(x, y, cx, cy, col_widths_emu, rows_xml)`, `make_table_row(cells_text)`, `table_element(elem)`. +- **DOCX OMML math equations**: docx-rs v0.4.19 does NOT parse `m:` namespace elements — they are completely absent from `ParagraphChild`/`RunChild` enums. Use raw ZIP scanning pattern (like WrapContext/NoteContext). `MathContext` struct with `HashMap>` keyed by body child index. `build_math_context(data)` opens ZIP, reads `word/document.xml`, calls `omml::scan_math_equations(xml)`. `scan_math_equations()` tracks `depth_in_body` to count top-level body children, processes `m:oMath`/`m:oMathPara` inline via `parse_omml_children()`. IR: `Block::MathEquation(MathEquation { content: String, display: bool })`. Codegen: display → `$ content $\n`, inline → `$content$`. OMML elements handled: `m:f` (fraction→`frac()`), `m:sSup`/`m:sSub`/`m:sSubSup` (scripts→`^`/`_`), `m:rad` (radical→`sqrt()`/`root()`), `m:d` (delimiter→parens), `m:nary` (n-ary→`sum`/`product`/`integral`), `m:func`, `m:limLow`/`m:limUpp`, `m:acc`, `m:bar`, `m:eqArr`. Math run text: `m:r`→`m:t` via `xml_content()`. Test DOCX with math: `build_docx_with_math(document_xml)` creates minimal ZIP with custom `word/document.xml`. - **DOCX floating images**: `WrapContext` scans raw document.xml for `wp:anchor` elements in document order (similar pattern to `NoteContext`). `AnchorWrapInfo` stores wrap_mode (WrapMode) and behind_doc (bool). `scan_anchor_wrap_types()` parses wp:wrapSquare→Square, wp:wrapTight/wrapThrough→Tight, wp:wrapTopAndBottom→TopAndBottom, wp:wrapNone→depends on behindDoc. `extract_drawing_image()` checks `pic.position_type == Anchor` → consumes next wrap info → `Block::FloatingImage(FloatingImage)`. Position from `DrawingPosition::Offset(emu)` → `emu / 12700.0` points. Codegen: Square/Tight → `#place(float: true)`, TopAndBottom → `#block` with `#v()` spacer, Behind/InFront/None → `#place()` absolute. Test helpers: `patch_docx_wrap_type(data, old_wrap, new_wrap)` replaces wrap elements in ZIP, `patch_docx_behind_doc(data)` sets `behindDoc="1"`. Create floating test Pic: `Pic::new(&bmp).floating().offset_x(emu).offset_y(emu)` (default: wrapSquare if !allow_overlap), `.overlapping()` → wrapNone. ---