Skip to main content

easydoc_writer/
math_omml.rs

1//! 数学公式 OMML 注入。
2//!
3//! docx-rs 不支持 Office Math(OMML);本模块在 document.xml 后处理阶段
4//! 把渲染时生成的占位标记替换为原生 `<m:oMath>` 元素。
5//! LaTeX → OMML 转换使用自研 [`easydoc_math::latex_to_omml`](严格错误通道:
6//! 无法无损转换时返回 `Err`,此处回退保留 `$latex$` 原文,内容零丢失)。
7//!
8//! 对应 Java: 无直接对应(Apache POI 的 `XWPFOMath` 系列)
9
10use easydoc_math::latex_to_omml;
11
12/// OMML 命名空间,需声明在 document.xml 根元素上,否则 Word 不识别公式。
13const OMML_NS: &str = "xmlns:m=\"http://schemas.openxmlformats.org/officeDocument/2006/math\"";
14
15/// 把 document.xml 中的 Math 占位标记替换为原生 `<m:oMath>` 元素。
16///
17/// # 参数
18///
19/// - `document_xml`: docx-rs 生成的 document.xml 内容。
20/// - `math`: [`super::content_renderer::take_rendered_math`] 返回的
21///   `(标记, latex, display)` 列表。
22///
23/// # 返回
24///
25/// 替换完成后的 document.xml。每个占位段落(`<w:p>` 内含标记 run)被
26/// 替换为包含 `<m:oMath>`(行内)或 `<m:oMathPara>`(块级、居中)的段落;
27/// 根元素补充 `xmlns:m` 声明。无法转换的公式保留 LaTeX 文本
28/// (`$$...$$` / `$...$` 形式),保证内容不丢失。
29///
30/// # 错误
31///
32/// 无(转换失败时回退为 LaTeX 文本,由 [`latex_to_omml::convert`] 的
33/// 严格错误通道保证不静默丢内容)。
34#[must_use]
35pub fn postprocess_math_xml(document_xml: &str, math: &[(String, String, bool)]) -> String {
36    let mut xml = ensure_omml_namespace(document_xml);
37    for (marker, latex, display) in math {
38        let replacement = match latex_to_omml::convert(latex) {
39            Ok(omml) if !omml.trim().is_empty() && !is_empty_omath(&omml) => {
40                build_omath_paragraph(&omml, *display)
41            }
42            // 转换失败(不支持的命令/不配对等)或产物为空:回退为 LaTeX 文本段落。
43            _ => build_latex_fallback_paragraph(latex, *display),
44        };
45        // marker 在 docx-rs 生成的 `<w:t>` 文本内;需找到其所属的整个
46        // `<w:p>` 段落并整体替换(否则 OMML 会嵌进 `<w:t>` 造成非法 XML)。
47        if let Some((para_start, para_end)) = find_containing_paragraph(&xml, marker) {
48            xml.replace_range(para_start..para_end, &replacement);
49        } else {
50            xml = xml.replace(marker, &replacement);
51        }
52    }
53    xml
54}
55
56/// 确保 document.xml 根元素声明 `xmlns:m`,缺则补入。
57///
58/// docx-rs 生成的根元素为 `<w:document …>`;在第一个 `<w:document` 起始标签
59/// 的 `>` 之前插入命名空间属性(已存在则跳过)。
60fn ensure_omml_namespace(document_xml: &str) -> String {
61    if document_xml.contains("xmlns:m=") {
62        return document_xml.to_owned();
63    }
64    let Some(start) = document_xml.find("<w:document") else {
65        return document_xml.to_owned();
66    };
67    let Some(tag_end) = document_xml[start..].find('>') else {
68        return document_xml.to_owned();
69    };
70    let abs_end = start + tag_end;
71    let mut xml = String::with_capacity(document_xml.len() + OMML_NS.len() + 1);
72    xml.push_str(&document_xml[..abs_end]);
73    xml.push(' ');
74    xml.push_str(OMML_NS);
75    xml.push_str(&document_xml[abs_end..]);
76    xml
77}
78
79/// 判断产物是否为空的 `<m:oMath></m:oMath>`。
80fn is_empty_omath(omml: &str) -> bool {
81    let inner = omml
82        .trim()
83        .strip_prefix("<m:oMath>")
84        .and_then(|s| s.strip_suffix("</m:oMath>"))
85        .unwrap_or(omml.trim());
86    inner.trim().is_empty()
87}
88
89/// 构建包含 `<m:oMath>` 的段落 XML。
90///
91/// 块级公式(display)包 `<m:oMathPara>` 并居中,行内公式直接包 `<m:oMath>`。
92fn build_omath_paragraph(omml: &str, display: bool) -> String {
93    if display {
94        format!(
95            "<w:p><m:oMathPara><m:oMathParaPr><m:jc m:val=\"center\"/></m:oMathParaPr>\
96             {omml}</m:oMathPara></w:p>"
97        )
98    } else {
99        format!("<w:p>{omml}</w:p>")
100    }
101}
102
103/// 转换失败的兜底:保留 `$latex$` / `$$latex$$` 原文(等宽字体),内容不丢失。
104fn build_latex_fallback_paragraph(latex: &str, display: bool) -> String {
105    let latex_text = if display {
106        format!("$${latex}$$")
107    } else {
108        format!("${latex}$")
109    };
110    format!(
111        "<w:p><w:r><w:rPr><w:rFonts w:ascii=\"Courier New\"/></w:rPr>\
112         <w:t xml:space=\"preserve\">{latex_text}</w:t></w:r></w:p>"
113    )
114}
115
116/// 找到包含 `marker` 的 `<w:p>...</w:p>` 段落范围。
117///
118/// 向前找 `<w:p>`(后跟 `>` 或空格,排除 `<w:pPr` 等前缀标签),
119/// 向后找 `</w:p>`。找不到时返回 `None`。
120fn find_containing_paragraph(xml: &str, marker: &str) -> Option<(usize, usize)> {
121    let marker_pos = xml.find(marker)?;
122    // 向前找段落开始:最近的 `<w:p>` 后跟 `>`/空格/`/`
123    let before = &xml[..marker_pos];
124    let mut para_start = None;
125    let mut search = 0;
126    while let Some(rel) = before[search..].find("<w:p") {
127        let abs = search + rel;
128        let after = before[abs + 4..].chars().next();
129        if matches!(after, Some('>' | ' ' | '/')) {
130            para_start = Some(abs);
131        }
132        search = abs + 4;
133    }
134    let para_start = para_start?;
135    // 向后找段落结束
136    let after_marker = &xml[marker_pos..];
137    let close_rel = after_marker.find("</w:p>")?;
138    let para_end = marker_pos + close_rel + "</w:p>".len();
139    Some((para_start, para_end))
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn inline_math_injects_omath() {
148        let xml = "<w:document><w:body><w:p><w:r><w:t>@@EASYDOC_MATH_0@@</w:t></w:r></w:p></w:body></w:document>";
149        let math = vec![("@@EASYDOC_MATH_0@@".to_string(), "x^2".to_string(), false)];
150        let out = postprocess_math_xml(xml, &math);
151        assert!(out.contains("<m:oMath>"), "{out}");
152        assert!(out.contains("<m:sSup>"), "{out}");
153        assert!(out.contains("xmlns:m="), "应注入命名空间:{out}");
154        assert!(!out.contains("@@EASYDOC_MATH"), "{out}");
155    }
156
157    #[test]
158    fn display_math_wraps_omath_para() {
159        let xml =
160            "<w:document><w:body><w:p><w:r><w:t>@@M@@</w:t></w:r></w:p></w:body></w:document>";
161        let math = vec![("@@M@@".to_string(), r"\frac{a}{b}".to_string(), true)];
162        let out = postprocess_math_xml(xml, &math);
163        assert!(out.contains("<m:oMathPara>"), "{out}");
164        assert!(out.contains("<m:jc m:val=\"center\"/>"), "{out}");
165        assert!(out.contains("<m:oMath>"), "{out}");
166    }
167
168    #[test]
169    fn unsupported_latex_falls_back_to_source() {
170        let xml =
171            "<w:document><w:body><w:p><w:r><w:t>@@M@@</w:t></w:r></w:p></w:body></w:document>";
172        let math = vec![("@@M@@".to_string(), r"\cancel{x}".to_string(), true)];
173        let out = postprocess_math_xml(xml, &math);
174        assert!(out.contains(r"$$\cancel{x}$$"), "应保留 LaTeX 原文:{out}");
175        assert!(!out.contains("<m:oMath>"), "{out}");
176    }
177
178    #[test]
179    fn empty_omath_falls_back() {
180        let xml =
181            "<w:document><w:body><w:p><w:r><w:t>@@M@@</w:t></w:r></w:p></w:body></w:document>";
182        let math = vec![("@@M@@".to_string(), " ".to_string(), false)];
183        let out = postprocess_math_xml(xml, &math);
184        assert!(out.contains("$ $"), "{out}");
185    }
186
187    #[test]
188    fn namespace_not_duplicated() {
189        let xml = "<w:document xmlns:m=\"http://schemas.openxmlformats.org/officeDocument/2006/math\"><w:body></w:body></w:document>";
190        let out = ensure_omml_namespace(xml);
191        assert_eq!(out.matches("xmlns:m=").count(), 1, "{out}");
192    }
193
194    #[test]
195    fn namespace_injected_into_root_tag() {
196        let xml = "<w:document xmlns:w=\"http://x\"><w:body></w:body></w:document>";
197        let out = ensure_omml_namespace(xml);
198        assert!(
199            out.starts_with("<w:document xmlns:w=\"http://x\" xmlns:m="),
200            "{out}"
201        );
202    }
203}