Skip to main content

docparser_office/
docx.rs

1//! DOCX 解析:ZIP + quick-xml 流式文本提取(与 Python `_docx_raw_stream` 语义对齐)。
2//!
3//! 架构:
4//!   `DocxRawBackend` -> `ParseCapability`:打开 OOXML ZIP,流式读取 `word/document.xml`,
5//!   提取段落文本与表格行,跳过结构还原(raw 模式)。
6
7use docparser_core::error::{DocError, Result};
8use docparser_core::plugin::{ParseCapability, RawDoc, RawPage};
9use quick_xml::events::Event;
10use quick_xml::Reader;
11
12use crate::zip_read_str;
13
14/// DOCX raw 后端:跳过结构还原,直接提取文本。
15pub struct DocxRawBackend;
16
17impl ParseCapability for DocxRawBackend {
18    fn id(&self) -> &str {
19        "office-raw"
20    }
21    fn formats(&self) -> &[&str] {
22        &["docx"]
23    }
24    fn parse(&self, path: &str) -> Result<RawDoc> {
25        let file = std::fs::File::open(path)
26            .map_err(|e| DocError::corrupt(format!("cannot open {path}: {e}")))?;
27        let mut zip = zip::ZipArchive::new(file)
28            .map_err(|e| DocError::corrupt(format!("ZIP open failed: {e}")))?;
29
30        let xml = zip_read_str(&mut zip, "word/document.xml")?
31            .ok_or_else(|| DocError::corrupt("missing word/document.xml"))?;
32
33        let items = docx_raw_text(&xml)?;
34
35        let page = RawPage {
36            name: None,
37            items,
38            ..Default::default()
39        };
40        Ok(RawDoc {
41            pages: vec![page],
42            ..Default::default()
43        })
44    }
45}
46
47/// 从 `word/document.xml` 的 `<w:body>` 中提取 raw 文本项。
48fn docx_raw_text(xml: &str) -> Result<Vec<docparser_core::plugin::RawTextItem>> {
49    let mut reader = Reader::from_str(xml);
50    // 不裁剪文本:空白由消费者 trim(实体两侧空格需保留,见 GeneralRef 分支)
51    let mut buf = Vec::new();
52    let mut items = Vec::new();
53
54    // 找到 <w:body>
55    loop {
56        match reader.read_event_into(&mut buf) {
57            Ok(Event::Start(ref e)) if e.name().local_name().as_ref() == crate::tags::W_BODY => {
58                break
59            }
60            Ok(Event::Eof) => return Err(DocError::corrupt("no w:body found")),
61            Err(e) => return Err(DocError::corrupt(format!("XML parse error: {e}"))),
62            _ => {}
63        }
64        buf.clear();
65    }
66    buf.clear();
67
68    // 流式处理 body 的直接子级
69    loop {
70        match reader.read_event_into(&mut buf) {
71            Ok(Event::Start(ref e)) => {
72                let name = e.name().local_name();
73                let name = name.as_ref();
74                if name == crate::tags::W_P {
75                    let t = extract_para_text(&mut reader)?;
76                    let trimmed = t.trim().to_string();
77                    if !trimmed.is_empty() {
78                        items.push(docparser_core::plugin::RawTextItem {
79                            text: trimmed,
80                            bbox: None,
81                        });
82                    }
83                } else if name == crate::tags::W_TBL {
84                    let rows = extract_table_rows(&mut reader)?;
85                    if !rows.is_empty() {
86                        let t = rows
87                            .iter()
88                            .map(|r| r.join(","))
89                            .collect::<Vec<_>>()
90                            .join("\n");
91                        items.push(docparser_core::plugin::RawTextItem {
92                            text: t,
93                            bbox: None,
94                        });
95                    }
96                } else if name == crate::tags::W_SDT {
97                    extract_sdt_items(&mut reader, &mut items)?;
98                }
99            }
100            Ok(Event::End(ref e)) => {
101                if e.name().local_name().as_ref() == crate::tags::W_BODY {
102                    break;
103                }
104            }
105            Ok(Event::Eof) => break,
106            Err(e) => return Err(DocError::corrupt(format!("XML parse error: {e}"))),
107            _ => {}
108        }
109        buf.clear();
110    }
111
112    Ok(items)
113}
114
115/// 跳过 `<w:sdt>` 内部的 `<w:sdtContent>`,提取其中的 `w:p` 段落。
116fn extract_sdt_items<R: std::io::BufRead>(
117    reader: &mut Reader<R>,
118    items: &mut Vec<docparser_core::plugin::RawTextItem>,
119) -> Result<()> {
120    let mut buf = Vec::new();
121    let mut depth = 1;
122    loop {
123        match reader.read_event_into(&mut buf) {
124            Ok(Event::Start(ref e)) => {
125                depth += 1;
126                if e.name().local_name().as_ref() == crate::tags::W_P {
127                    let t = extract_para_text(reader)?;
128                    let trimmed = t.trim().to_string();
129                    if !trimmed.is_empty() {
130                        items.push(docparser_core::plugin::RawTextItem {
131                            text: trimmed,
132                            bbox: None,
133                        });
134                    }
135                }
136            }
137            Ok(Event::End(ref _e)) => {
138                depth -= 1;
139                if depth == 0 {
140                    break;
141                }
142            }
143            Ok(Event::Eof) => break,
144            Err(e) => return Err(DocError::corrupt(format!("XML sdt parse error: {e}"))),
145            _ => {}
146        }
147        buf.clear();
148    }
149    Ok(())
150}
151
152/// 提取 `<w:p>` 段落的文本,处理 w:r/w:t/w:tab/w:br/w:cr。
153/// 调用时 reader 刚读过 `<w:p>` Start 事件,退出时 reader 处于 `</w:p>` End 事件之后。
154fn extract_para_text<R: std::io::BufRead>(reader: &mut Reader<R>) -> Result<String> {
155    let mut out = String::new();
156    let mut buf = Vec::new();
157    let mut depth = 1;
158
159    loop {
160        match reader.read_event_into(&mut buf) {
161            Ok(Event::Start(ref e)) => {
162                depth += 1;
163                match e.name().local_name().as_ref() {
164                    crate::tags::W_TAB => out.push('\t'),
165                    crate::tags::W_BR | crate::tags::W_CR => out.push('\n'),
166                    _ => {}
167                }
168            }
169            Ok(Event::End(ref _e)) => {
170                depth -= 1;
171                if depth == 0 {
172                    break;
173                }
174            }
175            Ok(Event::Empty(ref e)) => match e.name().local_name().as_ref() {
176                crate::tags::W_TAB => out.push('\t'),
177                crate::tags::W_BR | crate::tags::W_CR => out.push('\n'),
178                _ => {}
179            },
180            Ok(Event::Text(ref e)) => {
181                // quick-xml 0.38+:Text 已是解码后的字符串,实体需显式 unescape;
182                // 纯空白节点来自 XML 缩进,直接丢弃(原先靠 trim_text 处理)
183                let raw = e.xml10_content();
184                if !raw.trim().is_empty() {
185                    if let Ok(t) = quick_xml::escape::unescape(&raw) {
186                        out.push_str(&t);
187                    }
188                }
189            }
190            Ok(Event::GeneralRef(ref e)) => crate::push_general_ref(&mut out, e),
191            Ok(Event::CData(ref e)) => {
192                // CDATA 内容不含实体,直接取字符串
193                out.push_str(e.as_ref());
194            }
195            Err(e) => return Err(DocError::corrupt(format!("XML para parse error: {e}"))),
196            _ => {}
197        }
198        buf.clear();
199    }
200    Ok(out)
201}
202
203/// 提取 `<w:tbl>` 的行列结构,处理嵌套表。
204/// 调用时 reader 刚读过 `<w:tbl>` Start 事件,退出时 reader 处于 `</w:tbl>` End 事件之后。
205fn extract_table_rows<R: std::io::BufRead>(reader: &mut Reader<R>) -> Result<Vec<Vec<String>>> {
206    let mut rows: Vec<Vec<String>> = Vec::new();
207    let mut current_row: Vec<String> = Vec::new();
208    let mut current_cell = String::new();
209    let mut tbl_depth = 1;
210    let mut buf = Vec::new();
211    let mut cell_has_content = false;
212
213    loop {
214        match reader.read_event_into(&mut buf) {
215            Ok(Event::Start(ref e)) => {
216                tbl_depth += 1;
217                match e.name().local_name().as_ref() {
218                    crate::tags::W_P => {
219                        // extract_para_text 在内部消费了 </w:p>,需补偿深度
220                        let t = extract_para_text(reader)?;
221                        tbl_depth -= 1;
222                        if !t.trim().is_empty() {
223                            if cell_has_content {
224                                current_cell.push('\n');
225                            }
226                            current_cell.push_str(t.trim());
227                            cell_has_content = true;
228                        }
229                        continue;
230                    }
231                    crate::tags::W_TBL => {
232                        // 嵌套表:递归提取,其内部也消费了 </w:tbl>
233                        let nested = extract_table_rows(reader)?;
234                        tbl_depth -= 1;
235                        if !nested.is_empty() {
236                            if cell_has_content {
237                                current_cell.push('\n');
238                            }
239                            current_cell.push_str(
240                                &nested
241                                    .iter()
242                                    .map(|r| r.join(","))
243                                    .collect::<Vec<_>>()
244                                    .join("\n"),
245                            );
246                            cell_has_content = true;
247                        }
248                        continue;
249                    }
250                    _ => {}
251                }
252            }
253            Ok(Event::End(ref e)) => {
254                tbl_depth -= 1;
255                match e.name().local_name().as_ref() {
256                    crate::tags::W_TBL => {
257                        if tbl_depth == 0 {
258                            if !current_row.is_empty() {
259                                rows.push(std::mem::take(&mut current_row));
260                            }
261                            break;
262                        }
263                    }
264                    crate::tags::W_TR => {
265                        // 行结束:单元格已由 W_TC End 处理,只需保存行
266                        if !current_row.is_empty() {
267                            rows.push(std::mem::take(&mut current_row));
268                        }
269                    }
270                    crate::tags::W_TC => {
271                        // 单元格结束:保存内容
272                        if cell_has_content {
273                            current_row.push(current_cell.trim().to_string());
274                        } else {
275                            current_row.push(String::new());
276                        }
277                        current_cell.clear();
278                        cell_has_content = false;
279                    }
280                    _ => {}
281                }
282            }
283            Ok(Event::Eof) => break,
284            Err(e) => return Err(DocError::corrupt(format!("XML table parse error: {e}"))),
285            _ => {}
286        }
287        buf.clear();
288    }
289    Ok(rows)
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn test_para_simple() {
298        let xml = r#"<w:body xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
299  <w:p><w:r><w:t>Hello</w:t></w:r></w:p>
300</w:body>"#;
301        let items = docx_raw_text(xml).unwrap();
302        assert_eq!(items.len(), 1);
303        assert_eq!(items[0].text, "Hello");
304    }
305
306    #[test]
307    fn test_para_tab_br() {
308        let xml = concat!(
309            r#"<w:body xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">"#,
310            r#"  <w:p><w:r><w:t>a</w:t></w:r><w:tab/><w:r><w:t>b</w:t></w:r><w:br/><w:r><w:t>c</w:t></w:r></w:p>"#,
311            r#"</w:body>"#
312        );
313        let items = docx_raw_text(xml).unwrap();
314        assert_eq!(items.len(), 1);
315        assert_eq!(items[0].text, "a\tb\nc");
316    }
317
318    #[test]
319    fn test_simple_table() {
320        let xml = concat!(
321            r#"<w:body xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">"#,
322            r#"  <w:tbl>"#,
323            r#"    <w:tr><w:tc><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>B</w:t></w:r></w:p></w:tc></w:tr>"#,
324            r#"  </w:tbl>"#,
325            r#"</w:body>"#
326        );
327        let items = docx_raw_text(xml).unwrap();
328        assert_eq!(items.len(), 1);
329        assert_eq!(items[0].text, "A,B");
330    }
331
332    #[test]
333    fn test_nested_table() {
334        let xml = concat!(
335            r#"<w:body xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">"#,
336            r#"  <w:tbl>"#,
337            r#"    <w:tr>"#,
338            r#"      <w:tc><w:p><w:r><w:t>X</w:t></w:r></w:p></w:tc>"#,
339            r#"      <w:tc>"#,
340            r#"        <w:p><w:r><w:t>Y</w:t></w:r></w:p>"#,
341            r#"        <w:tbl>"#,
342            r#"          <w:tr><w:tc><w:p><w:r><w:t>innerA</w:t></w:r></w:p></w:tc>"#,
343            r#"               <w:tc><w:p><w:r><w:t>innerB</w:t></w:r></w:p></w:tc></w:tr>"#,
344            r#"        </w:tbl>"#,
345            r#"      </w:tc>"#,
346            r#"    </w:tr>"#,
347            r#"  </w:tbl>"#,
348            r#"</w:body>"#
349        );
350        let items = docx_raw_text(xml).unwrap();
351        assert_eq!(items.len(), 1);
352        assert!(items[0].text.contains("innerA"));
353        assert!(items[0].text.contains("innerB"));
354    }
355
356    #[test]
357    fn test_multi_para() {
358        let xml = concat!(
359            r#"<w:body xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">"#,
360            r#"  <w:p><w:r><w:t>Para1</w:t></w:r></w:p>"#,
361            r#"  <w:p><w:r><w:t>Para2</w:t></w:r></w:p>"#,
362            r#"</w:body>"#
363        );
364        let items = docx_raw_text(xml).unwrap();
365        assert_eq!(items.len(), 2);
366        assert_eq!(items[0].text, "Para1");
367        assert_eq!(items[1].text, "Para2");
368    }
369
370    /// reader 迁移 smoke:现场造一个最小 docx,走完整 ZIP → 事件流路径。
371    ///
372    /// 纯函数测试覆盖不到 `name().local_name()` 与显式 `escape::unescape`,
373    /// 这里用一个真实 zip 包证明事件解析没被改坏(含实体 `&amp;` 解码)。
374    #[test]
375    fn smoke_parse_minimal_docx_zip() {
376        use std::io::Write;
377
378        const DOCUMENT_XML: &str = concat!(
379            r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#,
380            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">"#,
381            r#"<w:body>"#,
382            r#"<w:p><w:r><w:t>Smoke 文本</w:t></w:r></w:p>"#,
383            r#"<w:p><w:r><w:t>a &amp; b</w:t></w:r></w:p>"#,
384            r#"</w:body></w:document>"#
385        );
386
387        let dir = std::env::temp_dir().join("docparser_office_docx_smoke");
388        std::fs::create_dir_all(&dir).unwrap();
389        let path = dir.join("min.docx");
390        {
391            let f = std::fs::File::create(&path).unwrap();
392            let mut zw = zip::ZipWriter::new(f);
393            let opts = zip::write::SimpleFileOptions::default();
394            zw.start_file("word/document.xml", opts).unwrap();
395            zw.write_all(DOCUMENT_XML.as_bytes()).unwrap();
396            zw.finish().unwrap();
397        }
398
399        let raw = DocxRawBackend.parse(path.to_str().unwrap()).unwrap();
400        std::fs::remove_file(&path).ok();
401
402        assert_eq!(raw.pages.len(), 1);
403        assert_eq!(raw.pages[0].items.len(), 2);
404        assert_eq!(raw.pages[0].items[0].text, "Smoke 文本");
405        assert_eq!(raw.pages[0].items[1].text, "a & b");
406    }
407}