use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum Format {
Html,
Pdf,
Docx,
Doc,
Xlsx,
Xls,
Ods,
Pptx,
Csv,
PlainText,
Markdown,
Unknown,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct DocumentHint<'a> {
pub content_type: Option<&'a str>,
pub file_name: Option<&'a str>,
}
impl<'a> DocumentHint<'a> {
pub fn content_type(content_type: &'a str) -> Self {
Self {
content_type: Some(content_type),
file_name: None,
}
}
pub fn file_name(file_name: &'a str) -> Self {
Self {
content_type: None,
file_name: Some(file_name),
}
}
}
const OLE2_MAGIC: &[u8] = b"\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1";
const ODS_MIMETYPE: &[u8] = b"mimetypeapplication/vnd.oasis.opendocument.spreadsheet";
impl Format {
pub fn as_str(&self) -> &'static str {
match self {
Format::Html => "html",
Format::Pdf => "pdf",
Format::Docx => "docx",
Format::Doc => "doc",
Format::Xlsx => "xlsx",
Format::Xls => "xls",
Format::Ods => "ods",
Format::Pptx => "pptx",
Format::Csv => "csv",
Format::PlainText => "text",
Format::Markdown => "markdown",
Format::Unknown => "unknown",
}
}
pub fn from_content_type(content_type: &str) -> Self {
let ct = content_type.to_lowercase();
let ct = ct.split(';').next().unwrap_or("").trim();
if ct.contains("text/html") || ct.contains("application/xhtml") {
Format::Html
} else if ct.contains("application/pdf") {
Format::Pdf
} else if ct.contains("wordprocessingml") || ct.contains("ms-word.document") {
Format::Docx
} else if ct.contains("msword") {
Format::Doc
} else if ct.contains("spreadsheetml") || ct.contains("ms-excel.sheet") {
Format::Xlsx
} else if ct.contains("ms-excel") {
Format::Xls
} else if ct.contains("opendocument.spreadsheet") {
Format::Ods
} else if ct.contains("presentationml") || ct.contains("ms-powerpoint") {
Format::Pptx
} else if ct.contains("text/csv") {
Format::Csv
} else if ct.contains("text/plain") {
Format::PlainText
} else if ct.contains("text/markdown") {
Format::Markdown
} else {
Format::Unknown
}
}
pub fn from_extension(ext: &str) -> Self {
match ext.to_lowercase().as_str() {
"html" | "htm" | "xhtml" => Format::Html,
"pdf" => Format::Pdf,
"docx" | "docm" | "dotx" | "dotm" => Format::Docx,
"doc" | "dot" => Format::Doc,
"xlsx" | "xlsm" | "xltx" | "xltm" => Format::Xlsx,
"xls" | "xlt" => Format::Xls,
"ods" => Format::Ods,
"pptx" | "pptm" | "ppt" => Format::Pptx,
"csv" | "tsv" => Format::Csv,
"txt" => Format::PlainText,
"md" | "markdown" => Format::Markdown,
_ => Format::Unknown,
}
}
pub fn from_file_name(name: &str) -> Self {
let path = name.split(['?', '#']).next().unwrap_or("");
let base = path.rsplit(['/', '\\']).next().unwrap_or("");
match base.rsplit_once('.') {
Some((stem, ext)) if !stem.is_empty() => Self::from_extension(ext),
_ => Format::Unknown,
}
}
pub fn sniff(bytes: &[u8]) -> Self {
if bytes.starts_with(b"PK\x03\x04") || bytes.starts_with(b"PK\x05\x06") {
return sniff_zip(bytes);
}
if bytes.starts_with(OLE2_MAGIC) {
return sniff_ole2(bytes);
}
if contains(&bytes[..bytes.len().min(1024)], b"%PDF-") {
return Format::Pdf;
}
if looks_like_html(bytes) {
return Format::Html;
}
Format::Unknown
}
pub fn detect(bytes: &[u8], hint: DocumentHint<'_>) -> Self {
if let Some(ct) = hint.content_type {
let f = Self::from_content_type(ct);
if f != Format::Unknown {
return f;
}
}
if let Some(name) = hint.file_name {
let f = Self::from_file_name(name);
if f != Format::Unknown {
return f;
}
}
Self::sniff(bytes)
}
}
impl std::fmt::Display for Format {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
!needle.is_empty() && haystack.windows(needle.len()).any(|w| w == needle)
}
fn sniff_zip(bytes: &[u8]) -> Format {
if contains(&bytes[..bytes.len().min(256)], ODS_MIMETYPE) {
Format::Ods
} else if contains(bytes, b"word/document") {
Format::Docx
} else if contains(bytes, b"xl/workbook.") {
Format::Xlsx
} else if contains(bytes, b"ppt/presentation.") {
Format::Pptx
} else if contains(bytes, ODS_MIMETYPE) {
Format::Ods
} else {
Format::Unknown
}
}
fn sniff_ole2(bytes: &[u8]) -> Format {
let utf16 = |s: &str| -> Vec<u8> { s.encode_utf16().flat_map(u16::to_le_bytes).collect() };
if contains(bytes, &utf16("WordDocument")) {
Format::Doc
} else if contains(bytes, &utf16("Workbook")) || contains(bytes, &utf16("Book")) {
Format::Xls
} else {
Format::Unknown
}
}
fn looks_like_html(bytes: &[u8]) -> bool {
let head = &bytes[..bytes.len().min(512)];
let head = head.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(head);
let start = head
.iter()
.position(|b| !b.is_ascii_whitespace())
.unwrap_or(head.len());
let head = head[start..].to_ascii_lowercase();
[&b"<!doctype html"[..], b"<html", b"<head", b"<body"]
.iter()
.any(|p| head.starts_with(p))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_content_type_detection() {
assert_eq!(
Format::from_content_type("text/html; charset=utf-8"),
Format::Html
);
assert_eq!(Format::from_content_type("application/pdf"), Format::Pdf);
assert_eq!(Format::from_content_type("text/plain"), Format::PlainText);
}
#[test]
fn test_extension_detection() {
assert_eq!(Format::from_extension("html"), Format::Html);
assert_eq!(Format::from_extension("PDF"), Format::Pdf);
assert_eq!(Format::from_extension("docx"), Format::Docx);
}
#[test]
fn test_file_name_detection() {
assert_eq!(Format::from_file_name("a/b/c.xlsx"), Format::Xlsx);
assert_eq!(Format::from_file_name("https://x/y.ods?z=1"), Format::Ods);
assert_eq!(Format::from_file_name(".docx"), Format::Unknown);
assert_eq!(Format::from_file_name("noext"), Format::Unknown);
assert_eq!(Format::from_file_name("dir.d/file"), Format::Unknown);
}
}