1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
//! Extracts plain text from binary document formats (PDF/DOCX) for
//! indexing into the knowledge base (RAG, the `/rag add` command, stage B1b —
//! `docs/history/rag-sources-retrieval.md`). Pure functions over bytes: dispatching by
//! extension and the extraction itself is the `app` layer's responsibility (`orchestrator/rag.rs`);
//! here only the format parsing via external crates (no cross-layer FSD imports).
//!
//! A "best effort" philosophy: an extraction failure returns `Err` (the indexing loop
//! catches the file's error, logs a `warn`, and bumps the error counter — it doesn't panic).
//! A scanned/image-only PDF may yield empty/whitespace-only text — that's acceptable.
use std::io::{Cursor, Read};
use anyhow::Context;
use quick_xml::Reader;
use quick_xml::events::Event;
/// Extracts readable text from a DOCX (Office Open XML). A DOCX is a ZIP archive with
/// deflate compression; the document's text lives in `word/document.xml`. We parse it via
/// `quick-xml`'s streaming reader, collecting the content of `<w:t>` elements (by
/// **local** name, ignoring the `w:` prefix); a paragraph's end `</w:p>` → a line
/// break, `<w:tab/>` → a tab, `<w:br/>` → a line break. Entities (`&`
/// etc.) are decoded. Trailing whitespace is trimmed.
///
/// A missing `word/document.xml`, a corrupt ZIP, or invalid XML → `Err` with
/// context (the caller skips such a file).
pub fn extract_docx(bytes: &[u8]) -> anyhow::Result<String> {
let mut zip =
zip::ZipArchive::new(Cursor::new(bytes)).context("DOCX: failed to open the ZIP archive")?;
let mut xml = Vec::new();
zip.by_name("word/document.xml")
.context("DOCX: the archive has no word/document.xml")?
.read_to_end(&mut xml)
.context("DOCX: failed to read word/document.xml")?;
let mut reader = Reader::from_reader(xml.as_slice());
let mut buf = Vec::new();
let mut out = String::new();
// Text in a DOCX lives only inside `<w:t>`; outside it — insignificant whitespace between
// tags. We collect content only while `<w:t>` is open.
let mut in_text = false;
loop {
match reader
.read_event_into(&mut buf)
.context("DOCX: XML parse error")?
{
// Matched by **local** name (without the `w:` prefix). Since
// quick-xml 0.40 a name is a `str`, not bytes — the reader decodes.
Event::Start(e) => {
if e.local_name().as_ref() == "t" {
in_text = true;
}
}
Event::End(e) => match e.local_name().as_ref() {
"t" => in_text = false,
"p" => out.push('\n'), // end of a paragraph
_ => {}
},
Event::Empty(e) => match e.local_name().as_ref() {
"tab" => out.push('\t'),
"br" => out.push('\n'),
_ => {}
},
Event::Text(e) if in_text => {
// Already decoded; `xml10_content` is the text with XML 1.0 line-end
// normalization applied (escapes arrive as separate GeneralRef
// events, see below — none are left in here).
out.push_str(&e.xml10_content());
}
// quick-xml emits entity references (`&`, `&`) as a separate
// event — `BytesRef` holds the name without `&`/`;`. We reconstruct and
// unescape it (`unescape` understands both named and numeric references).
Event::GeneralRef(e) if in_text => {
let entity = format!("&{};", e.xml10_content());
let text = quick_xml::escape::unescape(&entity)
.context("DOCX: failed to unescape an entity")?;
out.push_str(&text);
}
Event::Eof => break,
_ => {}
}
buf.clear();
}
Ok(out.trim_end().to_string())
}
/// Extracts text from a PDF (the `pdf-extract` crate, "best effort" quality).
/// A scanned/image-only PDF may return empty/whitespace-only text — that's
/// acceptable (RAG simply won't index anything). A corrupt PDF → `Err` with context.
pub fn extract_pdf(bytes: &[u8]) -> anyhow::Result<String> {
pdf_extract::extract_text_from_mem(bytes)
.map_err(|e| anyhow::anyhow!("PDF: failed to extract text: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
/// Builds a minimal valid DOCX in memory: a ZIP with `word/document.xml`
/// containing `paragraphs` paragraphs (each — `<w:p><w:r><w:t>…</w:t></w:r></w:p>`).
fn build_docx(paragraphs: &[&str]) -> Vec<u8> {
let mut body = String::from(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">\
<w:body>",
);
for p in paragraphs {
body.push_str("<w:p><w:r><w:t xml:space=\"preserve\">");
body.push_str(p);
body.push_str("</w:t></w:r></w:p>");
}
body.push_str("</w:body></w:document>");
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let opts: zip::write::FileOptions<'_, ()> =
zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
zip.start_file("word/document.xml", opts).unwrap();
zip.write_all(body.as_bytes()).unwrap();
zip.finish().unwrap().into_inner()
}
#[test]
fn extract_docx_returns_paragraphs_newline_separated_with_unescaped_entities() {
// Cyrillic + the entity `&` (should unescape to `&`) + brackets in the text.
let docx = build_docx(&["Первый абзац & продолжение", "Второй абзац документа"]);
let text = extract_docx(&docx).unwrap();
// Both paragraphs are present, the entity is unescaped.
assert!(
text.contains("Первый абзац & продолжение"),
"the entity & should unescape to &: {text:?}"
);
assert!(
text.contains("Второй абзац документа"),
"the second paragraph should be present: {text:?}"
);
// Paragraphs are separated by a line break.
assert_eq!(
text, "Первый абзац & продолжение\nВторой абзац документа",
"paragraphs are separated by \\n, the trailing line break is trimmed: {text:?}"
);
// XML tags don't leak into the output.
assert!(!text.contains("<w:"), "tags shouldn't leak: {text:?}");
assert!(!text.contains("w:t"), "tags shouldn't leak: {text:?}");
}
#[test]
fn extract_docx_handles_tab_and_break() {
// A manual build with <w:tab/> and <w:br/> inside a run.
let body = "<?xml version=\"1.0\"?>\
<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">\
<w:body><w:p><w:r><w:t>до</w:t><w:tab/><w:t>после</w:t><w:br/><w:t>строка</w:t>\
</w:r></w:p></w:body></w:document>";
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let opts: zip::write::FileOptions<'_, ()> = zip::write::FileOptions::default();
zip.start_file("word/document.xml", opts).unwrap();
zip.write_all(body.as_bytes()).unwrap();
let docx = zip.finish().unwrap().into_inner();
let text = extract_docx(&docx).unwrap();
assert_eq!(text, "до\tпосле\nстрока");
}
#[test]
fn extract_docx_missing_document_xml_errors() {
// A valid ZIP, but without word/document.xml → an error (not a panic).
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let opts: zip::write::FileOptions<'_, ()> = zip::write::FileOptions::default();
zip.start_file("other.txt", opts).unwrap();
zip.write_all(b"nope").unwrap();
let bytes = zip.finish().unwrap().into_inner();
assert!(extract_docx(&bytes).is_err());
}
#[test]
fn extract_docx_non_zip_bytes_error() {
// Not a ZIP at all → an error (not a panic).
assert!(extract_docx(b"this is not a zip archive at all").is_err());
assert!(extract_docx(b"").is_err());
}
/// A minimal valid PDF with the text "Hello World" (a correct xref,
/// the Helvetica font, a content stream `BT … Tj ET`). Verified against `pdf-extract`.
const HELLO_PDF: &[u8] = include_bytes!("../../tests/fixtures/hello.pdf");
#[test]
fn extract_pdf_extracts_text_from_fixture() {
let text = extract_pdf(HELLO_PDF).unwrap();
assert!(
text.contains("Hello World"),
"the text \"Hello World\" should be extracted from the PDF fixture: {text:?}"
);
}
#[test]
fn extract_pdf_non_pdf_bytes_error() {
// Not a PDF → an error (not a panic).
assert!(extract_pdf(b"definitely not a pdf document").is_err());
}
}