docparser-core 0.1.0

docparser 独立解析核心:IR / 统一错误 / 能力 trait / Registry / Router / Policy(零 native 依赖)
Documentation
//! 路由:按 magic + 扩展名判定格式(与 Python `engine.Router` 语义对齐)。

use crate::error::{DocError, Result};

const OLE_MAGIC: &[u8] = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1";
const ZIP_MAGIC: &[u8] = b"PK\x03\x04";
const PDF_MAGIC: &[u8] = b"%PDF";

const OOXML: &[&str] = &["docx", "xlsx", "pptx"];
const OLE_EXT: &[&str] = &["doc", "ppt", "xls"];
const TEXT_EXT: &[&str] = &["txt", "md", "markdown", "csv", "html", "htm"];

fn head_of(path: &str, n: usize) -> Result<Vec<u8>> {
    std::fs::read(path)
        .map(|v| v[..v.len().min(n)].to_vec())
        .map_err(|e| DocError::corrupt(format!("cannot read {path}: {e}")))
}

/// 语义(继承 KB 现网经验):
///   * OOXML 若文件头是 OLE 容器 → 加密件,归 encrypted;
///   * legacy(doc/ppt/xls)期望 OLE,不是 OLE → unsupported(伪装);
///   * 其余按头/magic 兜底。
pub fn sniff(path: &str) -> Result<&'static str> {
    let ext = path
        .rsplit('.')
        .next()
        .map(|e| e.to_ascii_lowercase())
        .unwrap_or_default();
    let h = head_of(path, 8)?;

    if h.starts_with(OLE_MAGIC) {
        if OOXML.contains(&ext.as_str()) {
            return Err(DocError::encrypted(format!("{ext} 为加密容器(OLE)")));
        }
        if OLE_EXT.contains(&ext.as_str()) {
            return Ok("ole");
        }
        return Err(DocError::unsupported(format!(
            "OLE 容器但扩展名不支持: {ext}"
        )));
    }
    if h.starts_with(ZIP_MAGIC) {
        // EPUB 也是 ZIP 容器:结构(container.xml/opf/spine)由 epub 后端进一步校验
        if ext == "epub" {
            return Ok("epub");
        }
        // 细判 ooxml 需要读包内清单,这里仅按扩展名近似(由解析后端进一步校验)
        if OOXML.contains(&ext.as_str()) {
            return Ok(match ext.as_str() {
                "docx" => "docx",
                "xlsx" => "xlsx",
                _ => "pptx",
            });
        }
        return Err(DocError::corrupt("ZIP 容器但缺少 OOXML 关键部件"));
    }
    if h.starts_with(PDF_MAGIC) {
        return Ok("pdf");
    }
    if OLE_EXT.contains(&ext.as_str()) {
        return Err(DocError::unsupported(format!(
            "{ext} 文件头不是 OLE,疑似伪装"
        )));
    }
    if TEXT_EXT.contains(&ext.as_str()) {
        let kind: &'static str = match ext.as_str() {
            "txt" => "txt",
            "md" | "markdown" => "md",
            "csv" => "csv",
            "html" | "htm" => "html",
            _ => "txt",
        };
        return Ok(kind);
    }
    Ok("txt") // 兜底当纯文本
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;

    #[test]
    fn sniff_pdf_and_ole() {
        let d = std::env::temp_dir();
        let pdf = d.join("docparser_router_test.pdf");
        let mut f = std::fs::File::create(&pdf).unwrap();
        f.write_all(b"%PDF-1.7\n%%EOF").unwrap();
        drop(f);
        assert_eq!(sniff(pdf.to_str().unwrap()).unwrap(), "pdf");
        std::fs::remove_file(&pdf).ok();

        // magic 优先:.doc 但头是 %PDF → 按 pdf 路由(与 Python Router 一致)
        let renamed = d.join("renamed_pdf.doc");
        let mut f = std::fs::File::create(&renamed).unwrap();
        f.write_all(b"%PDF-1.7\n%%EOF").unwrap();
        drop(f);
        assert_eq!(sniff(renamed.to_str().unwrap()).unwrap(), "pdf");
        std::fs::remove_file(&renamed).ok();

        // 伪装 doc:非 OLE / ZIP / PDF 头 → unsupported
        let fake = d.join("fake.doc");
        let mut f = std::fs::File::create(&fake).unwrap();
        f.write_all(b"NOTANOFFICEFILE").unwrap();
        drop(f);
        let err = sniff(fake.to_str().unwrap()).unwrap_err();
        assert_eq!(err.code.as_str(), "unsupported");
        std::fs::remove_file(&fake).ok();
    }

    #[test]
    fn ooxml_ole_container_is_encrypted() {
        let d = std::env::temp_dir();
        let fx = d.join("enc.docx");
        let mut f = std::fs::File::create(&fx).unwrap();
        f.write_all(OLE_MAGIC).unwrap();
        drop(f);
        let err = sniff(fx.to_str().unwrap()).unwrap_err();
        assert_eq!(err.code.as_str(), "encrypted");
        std::fs::remove_file(&fx).ok();
    }
}