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