Skip to main content

docparser_office/
lib.rs

1//! docparser Office 后端:DOCX / XLSX / PPTX 解析(`zip` + `quick-xml` 流式)。
2//!
3//! 与 Python `docparser/plugins/ooxml.py` 语义对齐:相同 IR/错误码/空白口径,
4//! 但使用 `quick-xml` 流式解析(移除 `expat_serialized` 全局锁)。
5//! 注意:解析是单线程流式的,未做 sheet/slide 级并行(原 `rayon` 依赖实际上未使用,已移除)。
6//!
7//! 架构(继承 PDF 后端的注册模式):
8//!   * `docx::DocxRawBackend` / `xlsx::XlsxRawBackend` / `pptx::PptxRawBackend` →
9//!     `ParseCapability`(raw 模式快速文本提取)
10//!   * `OfficePlugin` → `FormatPlugin`(RawDoc → IR Document 组装)
11//!   * `register_defaults()` → 供 `core-python` 和 `cli` 调用
12
13use std::sync::Arc;
14
15// `DocError`/`Result` 只被 docx/xlsx 共用的 zip 读取辅助用到;只剩 pptx 时会成为未用导入。
16#[cfg(any(feature = "docx", feature = "xlsx"))]
17use docparser_core::error::{DocError, Result};
18use docparser_core::plugin::{FormatPlugin, RawDoc};
19use docparser_core::registry::Registry;
20
21#[cfg(feature = "docx")]
22pub mod docx;
23#[cfg(feature = "pptx")]
24pub mod pptx;
25#[cfg(feature = "xlsx")]
26pub mod xlsx;
27
28/// 本 crate 实际编译进来的格式列表(随 feature 变化)。
29pub const FORMATS: &[&str] = &[
30    #[cfg(feature = "docx")]
31    "docx",
32    #[cfg(feature = "xlsx")]
33    "xlsx",
34    #[cfg(feature = "pptx")]
35    "pptx",
36];
37
38/// OOXML 通用 namespace URI(quick-xml 0.42 起统一为 `&str`)。
39pub mod ns {
40    pub const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
41    pub const A: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
42    pub const P: &str = "http://schemas.openxmlformats.org/presentationml/2006/main";
43    pub const C: &str = "http://schemas.openxmlformats.org/drawingml/2006/chart";
44    pub const R: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
45    pub const SHEET: &str = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
46    pub const REL: &str = "http://schemas.openxmlformats.org/package/2006/relationships";
47    pub const VML: &str = "urn:schemas-microsoft-com:vml";
48    pub const WP: &str = "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing";
49}
50
51/// OOXML 元素**本地名**(去命名空间前缀)。
52///
53/// quick-xml 0.42 的 `Name::local_name()` 不返回 `w:`/`a:`/`p:` 前缀,因此这里只存本地名,
54/// 比较一律用 `e.name().local_name().as_ref()`。副作用:`w:t` 与 `a:t` 都是 `"t"`,
55/// 同名不同前缀的元素在本地名比较下不可区分——Office 的正文里不存在这种歧义元素。
56pub mod tags {
57    // docx
58    pub const W_P: &str = "p";
59    pub const W_TBL: &str = "tbl";
60    pub const W_TR: &str = "tr";
61    pub const W_TC: &str = "tc";
62    pub const W_T: &str = "t";
63    pub const W_R: &str = "r";
64    pub const W_TAB: &str = "tab";
65    pub const W_BR: &str = "br";
66    pub const W_CR: &str = "cr";
67    pub const W_SDT: &str = "sdt";
68    pub const W_SDTC: &str = "sdtContent";
69    pub const W_PPR: &str = "pPr";
70    pub const W_BODY: &str = "body";
71    pub const W_TXBX: &str = "txbxContent";
72    // xlsx
73    pub const SHEET_DATA: &str = "sheetData";
74    pub const ROW: &str = "row";
75    pub const C: &str = "c";
76    pub const V: &str = "v";
77    pub const IS: &str = "is";
78    pub const T: &str = "t";
79    pub const SI: &str = "si";
80    pub const SST: &str = "sst";
81    pub const NUM_FMT: &str = "numFmt";
82    pub const CELL_XFS: &str = "cellXfs";
83    pub const XF: &str = "xf";
84    // pptx
85    pub const P_SP: &str = "sp";
86    pub const P_GRPSP: &str = "grpSp";
87    pub const P_GFRAME: &str = "graphicFrame";
88    pub const P_CXNSP: &str = "cxnSp";
89    pub const P_SPTREE: &str = "spTree";
90    pub const P_TXBODY: &str = "txBody";
91    pub const P_PH: &str = "ph";
92    pub const P_SLD: &str = "sld";
93    pub const P_SLDID: &str = "sldId";
94    pub const A_T: &str = "t";
95    pub const A_BR: &str = "br";
96    pub const A_TBL: &str = "tbl";
97    pub const A_TR: &str = "tr";
98    pub const A_TC: &str = "tc";
99    pub const A_BLIP: &str = "blip";
100    pub const A_OFF: &str = "off";
101    pub const A_POS: &str = "pos";
102    // chart
103    pub const C_TX: &str = "tx";
104    pub const C_V: &str = "v";
105}
106
107/// Office 插件:RawDoc → IR Document 组装(与 Python `plugins/ooxml.py` 的 FormatPlugin 语义对齐)。
108pub struct OfficePlugin;
109
110impl FormatPlugin for OfficePlugin {
111    fn id(&self) -> &str {
112        "office"
113    }
114    fn formats(&self) -> &[&str] {
115        FORMATS
116    }
117    fn default_parse_chain(&self) -> Vec<&'static str> {
118        vec!["office-raw"] // 当前只有 raw 后端;后续可增加 semantic 后端
119    }
120    fn default_process_chain(&self) -> Vec<&'static str> {
121        vec![]
122    }
123    fn assemble(&self, raw: &RawDoc) -> docparser_core::model::Document {
124        // 直接使用 raw 中的 pages,不做额外结构还原(raw 模式输出已在插件中组装好 text/tables)
125        let pages: Vec<docparser_core::model::Page> = raw
126            .pages
127            .iter()
128            .map(|rp| {
129                let blocks: Vec<docparser_core::model::Block> = rp
130                    .items
131                    .iter()
132                    .filter(|it| !it.text.trim().is_empty())
133                    .map(|it| docparser_core::model::Block {
134                        kind: docparser_core::model::BlockKind::Paragraph,
135                        content: it.text.clone(),
136                        ..Default::default()
137                    })
138                    .collect();
139                let text = rp
140                    .items
141                    .iter()
142                    .map(|it| it.text.as_str())
143                    .collect::<Vec<_>>()
144                    .join("\n");
145                docparser_core::model::Page {
146                    name: rp.name.clone(),
147                    blocks,
148                    text,
149                    ..Default::default()
150                }
151            })
152            .collect();
153        docparser_core::model::Document {
154            pages,
155            ..Default::default()
156        }
157    }
158}
159
160/// 注册 Office 后端(docx/xlsx/pptx raw 文本提取)与插件。
161///
162/// 只注册编译进来的格式(见 [`FORMATS`]);被 feature 裁掉的格式不会出现在注册表里,
163/// `engine` 遇到该扩展名时按 `unsupported` 返回。
164pub fn register_defaults(reg: &mut Registry) {
165    // 注册后端的顺序:docx → xlsx → pptx,由 engine 解析链依次尝试
166    #[cfg(feature = "docx")]
167    reg.register_parse(Arc::new(docx::DocxRawBackend), None)
168        .ok();
169    #[cfg(feature = "xlsx")]
170    reg.register_parse(Arc::new(xlsx::XlsxRawBackend), None)
171        .ok();
172    #[cfg(feature = "pptx")]
173    reg.register_parse(Arc::new(pptx::PptxRawBackend), None)
174        .ok();
175    reg.register_plugin(Arc::new(OfficePlugin));
176}
177
178/// quick-xml 0.42 把 `&amp;` / `&#65;` 这类引用拆成独立的 `Event::GeneralRef`,
179/// 不再是 Text 的一部分;这里按 0.37 的语义就地解码,未知实体丢弃(与 unescape 失败一致)。
180#[cfg(any(feature = "docx", feature = "xlsx", feature = "pptx"))]
181pub(crate) fn push_general_ref(buf: &mut String, e: &quick_xml::events::BytesRef<'_>) {
182    match e.resolve_char_ref() {
183        Ok(Some(ch)) => buf.push(ch),
184        _ => {
185            // 命名实体:借 unescape 解 `&name;`(预定义实体表在 quick-xml 内部)
186            let entity = format!("&{};", e.xml10_content());
187            if let Ok(t) = quick_xml::escape::unescape(&entity) {
188                buf.push_str(&t);
189            }
190        }
191    }
192}
193
194/// 从 ZIP 中读取字符串条目(找不到返回 None)。
195#[cfg(any(feature = "docx", feature = "xlsx"))]
196pub(crate) fn zip_read_str(
197    zip: &mut zip::ZipArchive<std::fs::File>,
198    name: &str,
199) -> Result<Option<String>> {
200    match zip.by_name(name) {
201        Ok(mut f) => {
202            let mut buf = Vec::new();
203            std::io::Read::read_to_end(&mut f, &mut buf)
204                .map_err(|e| DocError::corrupt(format!("读取 {name} 失败: {e}")))?;
205            Ok(Some(String::from_utf8(buf).map_err(|e| {
206                DocError::corrupt(format!("{name} 不是 UTF-8: {e}"))
207            })?))
208        }
209        Err(zip::result::ZipError::FileNotFound) => Ok(None),
210        Err(e) => Err(DocError::corrupt(format!("ZIP 读取 {name} 失败: {e}"))),
211    }
212}
213
214/// 解析 rels 文件(如 `word/_rels/document.xml.rels`),返回 {id → target} 映射。
215#[cfg(feature = "pptx")]
216pub(crate) fn parse_rels(xml: &str) -> std::collections::HashMap<String, String> {
217    use quick_xml::events::Event;
218    use quick_xml::Reader;
219
220    let mut map = std::collections::HashMap::new();
221    let mut reader = Reader::from_str(xml);
222    let mut buf = Vec::new();
223    loop {
224        match reader.read_event_into(&mut buf) {
225            Ok(Event::Empty(ref e)) | Ok(Event::Start(ref e)) => {
226                // 只读属性,文本裁剪与实体解析在此无关
227                if e.name().local_name().as_ref() == "Relationship" {
228                    let id = e
229                        .attributes()
230                        .filter_map(|a| a.ok())
231                        .find(|a| a.key.as_ref() == "Id")
232                        .map(|a| a.value.to_string());
233                    let target = e
234                        .attributes()
235                        .filter_map(|a| a.ok())
236                        .find(|a| a.key.as_ref() == "Target")
237                        .map(|a| a.value.to_string());
238                    if let (Some(id), Some(target)) = (id, target) {
239                        map.insert(id, target);
240                    }
241                }
242            }
243            Ok(Event::Eof) => break,
244            Err(e) => {
245                // 损坏的 rels 不致命,记录并返回已有结果
246                log::warn!("parse_rels 失败: {e}");
247                break;
248            }
249            _ => {}
250        }
251        buf.clear();
252    }
253    map
254}
255
256#[cfg(test)]
257mod tests {
258    use super::FORMATS;
259
260    /// `FORMATS` 必须与编译进来的 feature 严格一致(裁剪后不能"报"不存在的格式)。
261    #[test]
262    fn formats_follow_features() {
263        let mut want: Vec<&str> = Vec::new();
264        if cfg!(feature = "docx") {
265            want.push("docx");
266        }
267        if cfg!(feature = "xlsx") {
268            want.push("xlsx");
269        }
270        if cfg!(feature = "pptx") {
271            want.push("pptx");
272        }
273        assert_eq!(FORMATS, want.as_slice());
274    }
275}