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..63c1f6d1 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,43 @@ 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, content, display) in raw { + equations + .entry(idx) + .or_default() + .push(MathEquation { content, 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 +699,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 +716,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 +4578,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..789f1b47 --- /dev/null +++ b/crates/office2pdf/src/parser/omml.rs @@ -0,0 +1,910 @@ +//! 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 quick_xml::Reader; +use quick_xml::events::Event; + +/// Convert an OMML XML fragment to Typst math notation. +/// +/// The input should be the inner content of an `` element. +/// Returns the Typst math notation string (without `$` delimiters). +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(); + + // 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. +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), + b"m" => parse_matrix(reader, out), + b"eqArr" => parse_eq_array(reader, out), + 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)) => { + if e.local_name().as_ref() == end_tag { + return; + } + } + Ok(Event::Eof) => return, + Err(_) => return, + _ => {} + } + } +} + +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() +} + +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)) => 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) | Err(_) => break, + _ => {} + } + } + + let _ = std::fmt::Write::write_fmt(out, format_args!("frac({num}, {den})")); +} + +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)) => 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) | Err(_) => break, + _ => {} + } + } + + out.push_str(&base); + let _ = std::fmt::Write::write_fmt(out, format_args!("^{}", wrap_if_needed(&sup))); +} + +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)) => 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) | Err(_) => break, + _ => {} + } + } + + out.push_str(&base); + let _ = std::fmt::Write::write_fmt(out, format_args!("_{}", wrap_if_needed(&sub))); +} + +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)) => 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) | Err(_) => break, + _ => {} + } + } + + out.push_str(&base); + let _ = std::fmt::Write::write_fmt( + out, + format_args!("_{}^{}", wrap_if_needed(&sub), wrap_if_needed(&sup)), + ); +} + +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)) => 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) | 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})")); + } +} + +fn parse_radical_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) | Err(_) => break, + _ => {} + } + } + deg_hide +} + +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)) => 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) | 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}")); +} + +fn map_delimiter(chr: &str) -> &str { + match chr { + "(" | ")" | "[" | "]" | "{" | "}" | "|" => chr, + "\u{2016}" | "||" => "\u{2016}", + "\u{27E8}" | "<" => "\u{27E8}", + "\u{27E9}" | ">" => "\u{27E9}", + _ => chr, + } +} + +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)) => 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" => { + 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) | Err(_) => break, + _ => {} + } + } +} + +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)) => 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)) => match e.local_name().as_ref() { + b"t" => in_text = false, + b"r" => break, + _ => {} + }, + Ok(Event::Eof) | Err(_) => break, + _ => {} + } + } +} + +fn parse_nary(reader: &mut Reader<&[u8]>, out: &mut String) { + 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)) => 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) | Err(_) => break, + _ => {} + } + } + + let op = map_nary_operator(&chr); + out.push_str(op); + if !sub.is_empty() { + let _ = std::fmt::Write::write_fmt(out, format_args!("_{}", wrap_if_needed(&sub))); + } + if !sup.is_empty() { + let _ = std::fmt::Write::write_fmt(out, format_args!("^{}", wrap_if_needed(&sup))); + } + out.push(' '); + out.push_str(&content); +} + +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" { + 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) | Err(_) => break, + _ => {} + } + } +} + +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", + } +} + +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)) => 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) | Err(_) => break, + _ => {} + } + } + + let func_name = name.trim(); + let _ = std::fmt::Write::write_fmt(out, format_args!("{func_name} {content}")); +} + +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)) => 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) | Err(_) => break, + _ => {} + } + } + + out.push_str(&base); + let _ = std::fmt::Write::write_fmt(out, format_args!("_{}", wrap_if_needed(&lim))); +} + +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)) => 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) | Err(_) => break, + _ => {} + } + } + + out.push_str(&base); + let _ = std::fmt::Write::write_fmt(out, format_args!("^{}", wrap_if_needed(&lim))); +} + +fn parse_accent(reader: &mut Reader<&[u8]>, out: &mut String) { + let mut chr = "\u{0302}".to_string(); + let mut content = String::new(); + + loop { + match reader.read_event() { + 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) | Err(_) => break, + _ => {} + } + } + + let accent = map_accent(&chr); + let _ = std::fmt::Write::write_fmt(out, format_args!("{accent}({content})")); +} + +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" { + 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"accPr" => break, + Ok(Event::Eof) | Err(_) => break, + _ => {} + } + } +} + +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", + } +} + +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)) => 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) | 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})")); + } +} + +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" { + 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) | Err(_) => break, + _ => {} + } + } +} + +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)) => { + 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) | Err(_) => break, + _ => {} + } + } + + for (i, eq) in equations.iter().enumerate() { + if i > 0 { + out.push_str(" \\ "); + } + out.push_str(eq); + } +} + +fn wrap_if_needed(s: &str) -> String { + let trimmed = s.trim(); + if trimmed.chars().count() <= 1 { + trimmed.to_string() + } else { + format!("({trimmed})") + } +} + +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) | Err(_) => return, + _ => {} + } + } +} + +/// Scan `word/document.xml` for math equations. +/// +/// 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; + 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" { + let inner = capture_element_inner(&mut reader, b"oMathPara"); + let typst = omml_to_typst(&inner); + if !typst.is_empty() { + results.push((body_child_index, typst, true)); + } + // capture_element_inner consumed the End event, adjust depth + depth_in_body -= 1; + } else if name == b"oMath" { + let inner = capture_element_inner(&mut reader, b"oMath"); + let typst = omml_to_typst(&inner); + if !typst.is_empty() { + results.push((body_child_index, typst, false)); + } + // capture_element_inner consumed the End event, adjust depth + depth_in_body -= 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 +} + +fn capture_element_inner(reader: &mut Reader<&[u8]>, 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::*; + + #[test] + fn test_simple_fraction() { + let xml = "ab"; + assert_eq!(omml_to_typst(xml), "frac(a, b)"); + } + + #[test] + fn test_superscript() { + let xml = "x2"; + assert_eq!(omml_to_typst(xml), "x^2"); + } + + #[test] + fn test_subscript() { + let xml = "x1"; + assert_eq!(omml_to_typst(xml), "x_1"); + } + + #[test] + fn test_sub_superscript() { + let xml = "xi2"; + assert_eq!(omml_to_typst(xml), "x_i^2"); + } + + #[test] + fn test_square_root() { + let xml = r#"x"#; + assert_eq!(omml_to_typst(xml), "sqrt(x)"); + } + + #[test] + fn test_nth_root() { + let xml = r#"3x"#; + assert_eq!(omml_to_typst(xml), "root(3, x)"); + } + + #[test] + fn test_parentheses() { + let xml = r#"x+y"#; + assert_eq!(omml_to_typst(xml), "(x+y)"); + } + + #[test] + 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=1ni"#; + assert_eq!(omml_to_typst(xml), "sum_(i=1)^n i"); + } + + #[test] + 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±b2-4ac2a"#; + assert_eq!(omml_to_typst(xml), "x=frac(-b±sqrt(b^2-4ac), 2a)"); + } + + #[test] + fn test_scan_display_math() { + let xml = r#" + + + + + E=mc2 + + + + "#; + + 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#" + + + Text + + x=5 + + + "#; + + 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/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 + ); + } } 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. ---