use std::io::{Cursor, Read};
use anyhow::Context;
use quick_xml::Reader;
use quick_xml::events::Event;
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();
let mut in_text = false;
loop {
match reader
.read_event_into(&mut buf)
.context("DOCX: XML parse error")?
{
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'), _ => {}
},
Event::Empty(e) => match e.local_name().as_ref() {
"tab" => out.push('\t'),
"br" => out.push('\n'),
_ => {}
},
Event::Text(e) if in_text => {
out.push_str(&e.xml10_content());
}
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())
}
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;
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() {
let docx = build_docx(&["Первый абзац & продолжение", "Второй абзац документа"]);
let text = extract_docx(&docx).unwrap();
assert!(
text.contains("Первый абзац & продолжение"),
"the entity & should unescape to &: {text:?}"
);
assert!(
text.contains("Второй абзац документа"),
"the second paragraph should be present: {text:?}"
);
assert_eq!(
text, "Первый абзац & продолжение\nВторой абзац документа",
"paragraphs are separated by \\n, the trailing line break is trimmed: {text:?}"
);
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() {
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() {
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() {
assert!(extract_docx(b"this is not a zip archive at all").is_err());
assert!(extract_docx(b"").is_err());
}
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() {
assert!(extract_pdf(b"definitely not a pdf document").is_err());
}
}