1use 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
16static 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
23pub struct DocxLoader {
28 path: String,
30}
31
32impl DocxLoader {
33 pub fn new(path: impl Into<String>) -> Self {
35 Self { path: path.into() }
36 }
37
38 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 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 Self::extract_text_from_xml(&xml_content)
71 }
72
73 fn extract_text_from_xml(xml: &str) -> Result<String, LoaderError> {
75 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 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 }
90 result.push_str(m.as_str());
91 last_end = m.end();
92 }
93
94 Ok(result)
95 }
96}
97
98#[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 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}