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(|| Regex::new(r"<w:t[^>]*>(.*?)</w:t>").unwrap());
18static PARA_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"</w:p>").unwrap());
19
20pub struct DocxLoader {
25 path: String,
27}
28
29impl DocxLoader {
30 pub fn new(path: impl Into<String>) -> Self {
32 Self { path: path.into() }
33 }
34
35 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 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 Self::extract_text_from_xml(&xml_content)
68 }
69
70 fn extract_text_from_xml(xml: &str) -> Result<String, LoaderError> {
72 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 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 }
87 result.push_str(m.as_str());
88 last_end = m.end();
89 }
90
91 Ok(result)
92 }
93}
94
95#[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 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}