use std::fs;
use std::io::Read;
use std::path::Path;
use quick_xml::Reader;
use quick_xml::events::Event;
const MAX_FILE_BYTES: u64 = 20 * 1024 * 1024;
const TEXT_EXTS: &[&str] = &[
"txt", "md", "markdown", "log", "csv", "tsv", "json", "toml", "yaml", "yml", "ini", "cfg",
"conf", "xml", "html", "htm", "sql", "sh", "ps1", "bat", "rs", "py", "go", "java", "js", "ts",
"jsx", "tsx", "c", "h", "cpp", "hpp", "cs", "rb", "php", "lua", "vue",
];
const OFFICE_EXTS: &[&str] = &["docx", "xlsx", "pptx"];
pub(crate) fn is_extractable(path: &Path) -> bool {
let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
return false;
};
let ext = ext.to_ascii_lowercase();
ext == "pdf" || TEXT_EXTS.contains(&ext.as_str()) || OFFICE_EXTS.contains(&ext.as_str())
}
pub fn extract_text(path: &Path) -> Option<String> {
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
let meta = fs::metadata(path).ok()?;
if meta.len() > MAX_FILE_BYTES {
return None;
}
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match ext.as_str() {
"pdf" => extract_pdf(path),
"docx" => extract_docx(path),
"xlsx" => extract_xlsx(path),
"pptx" => extract_pptx(path),
e if TEXT_EXTS.contains(&e) => read_text_smart(path),
_ => None,
}))
.ok()
.flatten()
}
fn extract_pdf(path: &Path) -> Option<String> {
pdf_extract::extract_text(path).ok()
}
fn read_zip_entry(path: &Path, entry_name: &str) -> Option<Vec<u8>> {
let file = fs::File::open(path).ok()?;
let mut archive = zip::ZipArchive::new(file).ok()?;
let mut entry = archive.by_name(entry_name).ok()?;
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes).ok()?;
Some(bytes)
}
fn extract_docx(path: &Path) -> Option<String> {
let xml = read_zip_entry(path, "word/document.xml")?;
let text = extract_tagged_text(&xml, b"t", Some(b"p"));
let text = text.trim();
(!text.is_empty()).then(|| text.to_string())
}
fn extract_xlsx(path: &Path) -> Option<String> {
let file = fs::File::open(path).ok()?;
let mut archive = zip::ZipArchive::new(file).ok()?;
let mut text = String::new();
if let Ok(mut entry) = archive.by_name("xl/sharedStrings.xml") {
let mut bytes = Vec::new();
if entry.read_to_end(&mut bytes).is_ok() {
drop(entry);
text.push_str(&extract_tagged_text(&bytes, b"t", None));
text.push('\n');
}
}
let sheet_names: Vec<String> = archive
.file_names()
.filter(|n| n.starts_with("xl/worksheets/sheet") && n.ends_with(".xml"))
.map(|n| n.to_string())
.collect();
for name in sheet_names {
let Ok(mut entry) = archive.by_name(&name) else {
continue;
};
let mut bytes = Vec::new();
if entry.read_to_end(&mut bytes).is_err() {
continue;
}
drop(entry);
text.push_str(&extract_tagged_text(&bytes, b"t", None));
text.push('\n');
}
let text = text.trim();
(!text.is_empty()).then(|| text.to_string())
}
fn extract_pptx(path: &Path) -> Option<String> {
let file = fs::File::open(path).ok()?;
let mut archive = zip::ZipArchive::new(file).ok()?;
let mut slide_names: Vec<String> = archive
.file_names()
.filter(|n| n.starts_with("ppt/slides/slide") && n.ends_with(".xml"))
.map(|n| n.to_string())
.collect();
slide_names.sort();
let mut text = String::new();
for name in slide_names {
let Ok(mut entry) = archive.by_name(&name) else {
continue;
};
let mut bytes = Vec::new();
if entry.read_to_end(&mut bytes).is_err() {
continue;
}
drop(entry);
text.push_str(&extract_tagged_text(&bytes, b"t", Some(b"p")));
text.push('\n');
}
let text = text.trim();
(!text.is_empty()).then(|| text.to_string())
}
fn extract_tagged_text(xml: &[u8], text_tag: &[u8], para_tag: Option<&[u8]>) -> String {
let mut reader = Reader::from_reader(xml);
reader.config_mut().trim_text(false);
let mut out = String::new();
let mut in_text = false;
loop {
match reader.read_event() {
Ok(Event::Eof) => break,
Ok(Event::Start(e)) => {
if e.local_name().as_ref() == text_tag {
in_text = true;
}
}
Ok(Event::Text(t)) => {
if in_text
&& let Ok(decoded) = t.decode()
&& let Ok(unescaped) = quick_xml::escape::unescape(&decoded)
{
out.push_str(&unescaped);
}
}
Ok(Event::End(e)) => {
let name = e.local_name();
if name.as_ref() == text_tag {
in_text = false;
} else if para_tag.is_some_and(|p| name.as_ref() == p) {
out.push('\n');
}
}
Ok(Event::Empty(e)) => {
if para_tag.is_some_and(|p| e.local_name().as_ref() == p) {
out.push('\n');
}
}
Ok(_) => {}
Err(_) => break,
}
}
out
}
fn read_text_smart(path: &Path) -> Option<String> {
let bytes = fs::read(path).ok()?;
if bytes.is_empty() {
return None;
}
let mut detector = chardetng::EncodingDetector::new(chardetng::Iso2022JpDetection::Allow);
detector.feed(&bytes, true);
let encoding = detector.guess(None, chardetng::Utf8Detection::Allow);
let (text, _, had_errors) = encoding.decode(&bytes);
if had_errors && encoding != encoding_rs::UTF_8 {
return Some(String::from_utf8_lossy(&bytes).into_owned());
}
Some(text.into_owned())
}
#[cfg(test)]
mod tests {
use std::io::Write;
use zip::write::SimpleFileOptions;
use super::*;
fn write_zip(path: &Path, entries: &[(&str, &str)]) {
let file = fs::File::create(path).unwrap();
let mut zip = zip::ZipWriter::new(file);
let options = SimpleFileOptions::default();
for (name, content) in entries {
zip.start_file(*name, options).unwrap();
zip.write_all(content.as_bytes()).unwrap();
}
zip.finish().unwrap();
}
#[test]
fn extracts_docx_paragraph_text() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sample.docx");
let document_xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>
<w:p><w:r><w:t>季度对账哨兵词</w:t></w:r></w:p>
<w:p><w:r><w:t>quarterly-sentinel</w:t></w:r></w:p>
</w:body>
</w:document>"#;
write_zip(&path, &[("word/document.xml", document_xml)]);
let text = extract_text(&path).expect("docx 应能抽出文本");
assert!(text.contains("季度对账哨兵词"));
assert!(text.contains("quarterly-sentinel"));
}
#[test]
fn extracts_xlsx_shared_and_inline_strings() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sample.xlsx");
let shared_strings = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="1" uniqueCount="1">
<si><t>季度对账哨兵词</t></si>
</sst>"#;
let sheet1 = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheetData>
<row r="1">
<c r="A1" t="s"><v>0</v></c>
<c r="B1" t="inlineStr"><is><t>quarterly-sentinel</t></is></c>
</row>
</sheetData>
</worksheet>"#;
write_zip(
&path,
&[
("xl/sharedStrings.xml", shared_strings),
("xl/worksheets/sheet1.xml", sheet1),
],
);
let text = extract_text(&path).expect("xlsx 应能抽出文本");
assert!(text.contains("季度对账哨兵词"));
assert!(text.contains("quarterly-sentinel"));
}
#[test]
fn extracts_pptx_slide_text() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sample.pptx");
let slide1 = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:spTree>
<p:sp><p:txBody><a:p><a:r><a:t>季度对账哨兵词</a:t></a:r></a:p></p:txBody></p:sp>
<p:sp><p:txBody><a:p><a:r><a:t>quarterly-sentinel</a:t></a:r></a:p></p:txBody></p:sp>
</p:spTree>
</p:cSld>
</p:sld>"#;
write_zip(&path, &[("ppt/slides/slide1.xml", slide1)]);
let text = extract_text(&path).expect("pptx 应能抽出文本");
assert!(text.contains("季度对账哨兵词"));
assert!(text.contains("quarterly-sentinel"));
}
#[test]
fn corrupted_docx_is_skipped_without_panic() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("broken.docx");
fs::write(&path, b"this is not a zip file at all").unwrap();
assert!(extract_text(&path).is_none());
}
#[test]
fn gbk_encoded_chinese_txt_is_decoded_to_utf8() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("gbk_sample.txt");
let content = "这是一段用于编码探测的中文测试文本,\
包含足够多的汉字以便字符集识别器把它判定为国标编码而不是别的编码,\
季度对账哨兵词也在这里出现。";
let (gbk_bytes, _, unmappable) = encoding_rs::GBK.encode(content);
assert!(!unmappable, "测试文本应能完整编码成 GBK");
fs::write(&path, &gbk_bytes).unwrap();
let text = read_text_smart(&path).expect("GBK 文件应能解码出文本");
assert!(text.contains("季度对账哨兵词"), "应还原出原始中文: {text}");
assert!(text.contains("国标编码"));
}
#[test]
fn utf8_bom_file_is_decoded_without_leading_bom_char() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bom_sample.txt");
let mut bytes = vec![0xEF, 0xBB, 0xBF];
bytes.extend_from_slice("带 BOM 的内容 marker-bom".as_bytes());
fs::write(&path, &bytes).unwrap();
let text = read_text_smart(&path).expect("带 BOM 的 UTF-8 文件应能解码");
assert!(text.contains("marker-bom"));
assert!(text.contains("带 BOM 的内容"));
assert!(!text.starts_with('\u{FEFF}'), "BOM 应被剥掉,不进正文");
}
#[test]
fn empty_text_file_returns_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("empty_sample.txt");
fs::write(&path, b"").unwrap();
assert!(read_text_smart(&path).is_none());
}
#[test]
fn file_over_size_limit_is_skipped() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("oversized_sample.txt");
let f = fs::File::create(&path).unwrap();
f.set_len(MAX_FILE_BYTES + 1).unwrap();
drop(f);
assert!(extract_text(&path).is_none(), "超过体积上限的文件应被跳过");
}
#[test]
fn misdetected_encoding_falls_back_to_lossy_utf8() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("misdetect_sample.txt");
let (gbk, _, _) = encoding_rs::GBK
.encode("编码探测有损回退分支覆盖用例需要一段足够长的国标中文来稳定命中 GBK 判定");
let mut buf = b"FALLBACK_MARKER_".to_vec();
buf.extend_from_slice(&gbk);
buf.extend_from_slice(&[0x81, 0x20]);
fs::write(&path, &buf).unwrap();
let text = read_text_smart(&path).expect("回退分支也要返回 Some");
assert!(text.contains("FALLBACK_MARKER_"));
assert!(
!text.contains("国标中文"),
"若走了正常 GBK 解码就会出现真中文,说明没命中回退分支: {text}"
);
}
#[test]
fn docx_missing_document_entry_returns_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("no_document.docx");
write_zip(&path, &[("docProps/core.xml", "<coreProperties/>")]);
assert!(extract_text(&path).is_none());
}
#[test]
fn docx_with_truncated_xml_keeps_text_before_corruption() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("truncated.docx");
let document_xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>partial-before-corruption</w:t></w:r></w:p></w:bogusEnd>"#;
write_zip(&path, &[("word/document.xml", document_xml)]);
let text = extract_text(&path).expect("损坏前已抽到的文本应保留");
assert!(text.contains("partial-before-corruption"));
}
#[test]
fn whitespace_only_docx_returns_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("blank.docx");
let document_xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t> </w:t></w:r></w:p></w:body></w:document>"#;
write_zip(&path, &[("word/document.xml", document_xml)]);
assert!(extract_text(&path).is_none());
}
#[test]
fn xlsx_skips_unreadable_sheet_and_processes_the_rest() {
use zip::CompressionMethod;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("partial_sheets.xlsx");
{
let file = fs::File::create(&path).unwrap();
let mut zip = zip::ZipWriter::new(file);
let stored = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
zip.start_file("xl/worksheets/sheet1.xml", stored).unwrap();
zip.write_all(
br#"<worksheet><sheetData><row><c t="inlineStr"><is><t>CORRUPTME_SHEET1_UNIQUE</t></is></c></row></sheetData></worksheet>"#,
)
.unwrap();
zip.start_file("xl/worksheets/sheet2.xml", SimpleFileOptions::default())
.unwrap();
zip.write_all(
br#"<worksheet><sheetData><row><c t="inlineStr"><is><t>survivor-sheet2-text</t></is></c></row></sheetData></worksheet>"#,
)
.unwrap();
zip.finish().unwrap();
}
let mut raw = fs::read(&path).unwrap();
let needle = b"CORRUPTME_SHEET1_UNIQUE";
let pos = raw
.windows(needle.len())
.position(|w| w == needle)
.expect("应能在 zip 明文里找到 sheet1 的标记");
raw[pos] ^= 0xFF;
fs::write(&path, &raw).unwrap();
let text = extract_text(&path).expect("坏掉一个 sheet 后其它 sheet 仍应产出文本");
assert!(
text.contains("survivor-sheet2-text"),
"健康 sheet 的文本应被抽出"
);
assert!(
!text.contains("CORRUPTME_SHEET1_UNIQUE"),
"坏掉的 sheet 内容不应出现在结果里"
);
}
}