Skip to main content

lc_rag/loaders/
docx.rs

1//! DOCX document loader
2//!
3//! Loads document content from .docx files (plain-text extraction).
4//! No external crate required: uses ZIP decompression + XML parsing to extract text.
5
6use std::collections::HashMap;
7use std::io::Read;
8
9use async_trait::async_trait;
10use regex::Regex;
11use std::sync::LazyLock;
12
13use super::{DocumentLoader, LoaderError};
14use lc_vector_stores::Document;
15
16/// M60: compile regexes once using LazyLock instead of on every call
17static WT_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<w:t[^>]*>(.*?)</w:t>").unwrap());
18static PARA_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"</w:p>").unwrap());
19
20/// DOCX document loader
21///
22/// Loads documents from a .docx file path, extracting the body text.
23/// A DOCX file is a ZIP package; the body lives in `word/document.xml`.
24pub struct DocxLoader {
25    /// File path
26    path: String,
27}
28
29impl DocxLoader {
30    /// Creates a loader from a file path
31    pub fn new(path: impl Into<String>) -> Self {
32        Self { path: path.into() }
33    }
34
35    /// Extracts text from DOCX bytes
36    ///
37    /// A DOCX file is a ZIP package; the body lives in `word/document.xml`,
38    /// with the text inside `<w:t>` tags.
39    fn extract_text_from_bytes(data: &[u8]) -> Result<String, LoaderError> {
40        let reader = std::io::Cursor::new(data);
41        let mut archive = zip::ZipArchive::new(reader)
42            .map_err(|e| LoaderError::Other(format!("DOCX is not a valid ZIP: {}", e)))?;
43
44        // Read word/document.xml
45        let mut xml_content = String::new();
46        let mut found = false;
47        for i in 0..archive.len() {
48            let mut file = archive
49                .by_index(i)
50                .map_err(|e| LoaderError::Other(format!("failed to read ZIP entry: {}", e)))?;
51            if file.name() == "word/document.xml" {
52                file.read_to_string(&mut xml_content).map_err(|e| {
53                    LoaderError::Other(format!("failed to read document.xml: {}", e))
54                })?;
55                found = true;
56                break;
57            }
58        }
59
60        if !found {
61            return Err(LoaderError::Other(
62                "word/document.xml not found in DOCX".to_string(),
63            ));
64        }
65
66        // Extract the <w:t> tag content
67        Self::extract_text_from_xml(&xml_content)
68    }
69
70    /// Extracts text from document.xml
71    fn extract_text_from_xml(xml: &str) -> Result<String, LoaderError> {
72        // M60: use pre-compiled regexes from LazyLock
73        let mut result = String::new();
74        let mut last_end = 0;
75
76        for cap in WT_REGEX.captures_iter(xml) {
77            let Some(m) = cap.get(1) else {
78                continue;
79            };
80            // Check whether a </w:p> (new paragraph) precedes this <w:t>
81            let before = &xml[last_end..m.start()];
82            if PARA_REGEX.is_match(before) && !result.is_empty() {
83                result.push('\n');
84            } else if !result.is_empty() {
85                // Consecutive text within the same paragraph
86            }
87            result.push_str(m.as_str());
88            last_end = m.end();
89        }
90
91        Ok(result)
92    }
93}
94
95// The zip crate is in dev-dependencies; normally we would handle it via features,
96// but for simplicity it is added directly as a dependency.
97// Note: only std::io + zip are used here as the minimal dependency set.
98
99#[async_trait]
100impl DocumentLoader for DocxLoader {
101    async fn load(&self) -> Result<Vec<Document>, LoaderError> {
102        let data = tokio::task::spawn_blocking({
103            let path = self.path.clone();
104            move || std::fs::read(&path)
105        })
106        .await
107        .map_err(|e| LoaderError::Other(format!("failed to read file: {}", e)))?
108        .map_err(LoaderError::IoError)?;
109
110        let text = Self::extract_text_from_bytes(&data)?;
111
112        let mut metadata = HashMap::new();
113        metadata.insert("format".to_string(), "docx".to_string().into());
114        metadata.insert("source".to_string(), self.path.clone().into());
115
116        Ok(vec![Document {
117            content: text,
118            metadata,
119            id: None,
120        }])
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn test_extract_text_from_xml() {
130        let xml = r#"<?xml version="1.0"?>
131        <w:document>
132            <w:body>
133                <w:p><w:r><w:t>Hello</w:t></w:r><w:r><w:t> World</w:t></w:r></w:p>
134                <w:p><w:r><w:t>Second paragraph</w:t></w:r></w:p>
135            </w:body>
136        </w:document>"#;
137        let text = DocxLoader::extract_text_from_xml(xml).unwrap();
138        assert!(text.contains("Hello World"));
139        assert!(text.contains("Second paragraph"));
140    }
141
142    #[test]
143    fn test_extract_text_from_xml_empty() {
144        let xml = r#"<?xml version="1.0"?><w:document><w:body></w:body></w:document>"#;
145        let text = DocxLoader::extract_text_from_xml(xml).unwrap();
146        assert!(text.is_empty());
147    }
148
149    #[test]
150    fn test_extract_text_from_xml_with_xml_space() {
151        // w:t may carry an xml:space="preserve" attribute
152        let xml = r#"<w:p><w:r><w:t xml:space="preserve">  spaced  </w:t></w:r></w:p>"#;
153        let text = DocxLoader::extract_text_from_xml(xml).unwrap();
154        assert_eq!(text, "  spaced  ");
155    }
156
157    #[test]
158    fn test_new() {
159        let loader = DocxLoader::new("/path/to/file.docx");
160        assert_eq!(loader.path, "/path/to/file.docx");
161    }
162}