use std::io::Read;
use std::path::Path;
pub const DOC_EXTS: &[&str] = &["pdf", "docx", "pptx", "html", "htm", "md", "markdown", "txt", "text"];
pub fn is_doc_ext(ext: &str) -> bool {
DOC_EXTS.contains(&ext)
}
pub fn extract_text(path: &Path) -> std::io::Result<Option<String>> {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();
let text = match ext.as_str() {
"txt" | "text" | "md" | "markdown" => Some(std::fs::read_to_string(path)?),
"html" | "htm" => Some(strip_html(&std::fs::read_to_string(path)?)),
"docx" => Some(ooxml_text(path, "word/document.xml", &[])?),
"pptx" => Some(ooxml_text(path, "", &["ppt/slides/slide"])?),
"pdf" => extract_pdf(path),
_ => None,
};
Ok(text.map(|t| t.trim().to_string()).filter(|t| !t.is_empty()))
}
fn extract_pdf(path: &Path) -> Option<String> {
if let Ok(out) = std::process::Command::new("pdftotext").arg("-q").arg(path).arg("-").output() {
if out.status.success() {
let t = String::from_utf8_lossy(&out.stdout).to_string();
if !t.trim().is_empty() {
return Some(t);
}
}
}
let owned = path.to_path_buf();
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let r = std::panic::catch_unwind(move || pdf_extract::extract_text(&owned).ok()).ok().flatten();
std::panic::set_hook(prev);
if let Some(t) = &r {
if !t.trim().is_empty() {
return r;
}
}
#[cfg(feature = "ocr")]
{
return crate::ocr::ocr_pdf(path);
}
#[cfg(not(feature = "ocr"))]
r
}
fn ooxml_text(path: &Path, entry: &str, prefixes: &[&str]) -> std::io::Result<String> {
let file = std::fs::File::open(path)?;
let mut zip = zip::ZipArchive::new(file).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let mut names: Vec<String> = (0..zip.len())
.filter_map(|i| zip.by_index(i).ok().map(|f| f.name().to_string()))
.filter(|n| (!entry.is_empty() && n == entry) || prefixes.iter().any(|p| n.starts_with(p) && n.ends_with(".xml")))
.collect();
names.sort();
let mut out = String::new();
for name in names {
let mut xml = String::new();
if let Ok(mut f) = zip.by_name(&name) {
if f.read_to_string(&mut xml).is_ok() {
out.push_str(&xml_text(&xml));
out.push('\n');
}
}
}
Ok(out)
}
fn xml_text(xml: &str) -> String {
use quick_xml::events::Event;
use quick_xml::reader::Reader;
let mut reader = Reader::from_str(xml);
let mut out = String::new();
let mut in_t = false;
let local = |name: &[u8]| -> Vec<u8> { name.rsplit(|&b| b == b':').next().unwrap_or(name).to_vec() };
loop {
match reader.read_event() {
Ok(Event::Start(e)) if local(e.name().as_ref()) == b"t" => in_t = true,
Ok(Event::End(e)) => match local(e.name().as_ref()).as_slice() {
b"t" => in_t = false,
b"p" => out.push('\n'),
_ => {}
},
Ok(Event::Text(t)) if in_t => {
if let Ok(s) = t.unescape() {
out.push_str(&s);
}
}
Ok(Event::Eof) | Err(_) => break,
_ => {}
}
}
out
}
pub fn strip_html(html: &str) -> String {
let mut s = html.to_string();
for tag in ["script", "style"] {
loop {
let lower = s.to_lowercase();
let open = format!("<{tag}");
let close = format!("</{tag}>");
match (lower.find(&open), lower.find(&close)) {
(Some(a), Some(b)) if b > a => s.replace_range(a..b + close.len(), " "),
_ => break,
}
}
}
let mut out = String::with_capacity(s.len());
let mut in_tag = false;
for ch in s.chars() {
match ch {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => out.push(ch),
_ => {}
}
}
let out = out
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace(" ", " ");
out.split_whitespace().collect::<Vec<_>>().join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn html_stripped() {
let h = "<html><head><style>a{color:red}</style></head><body><h1>Title</h1><p>Hello & welcome</p><script>x=1</script></body></html>";
let t = strip_html(h);
assert!(t.contains("Title"));
assert!(t.contains("Hello & welcome"));
assert!(!t.contains("color"));
assert!(!t.contains("x=1"));
}
#[test]
fn xml_runs_extracted() {
let x = r#"<w:document><w:body><w:p><w:r><w:t>Hello</w:t></w:r><w:r><w:t xml:space="preserve"> world</w:t></w:r></w:p><w:p><w:r><w:t>Second</w:t></w:r></w:p></w:body></w:document>"#;
let t = super::xml_text(x);
assert!(t.contains("Hello world"));
assert!(t.contains("Second"));
}
}