use crate::limits::Limits;
pub const EPUB_MIMETYPE: &[u8] = b"application/epub+zip";
pub const CONTAINER_MEMBER: &[u8] = b"META-INF/container.xml";
pub const CONTAINER_NS: &[u8] = b"urn:oasis:names:tc:opendocument:xmlns:container";
pub const OPF_MEDIA_TYPE: &[u8] = b"application/oebps-package+xml";
#[cfg(feature = "package")]
const CONTENT_TYPES_MEMBER: &[u8] = b"[Content_Types].xml";
#[cfg(feature = "package")]
const PACKAGE_RELS_MEMBER: &[u8] = b"_rels/.rels";
#[cfg(feature = "package")]
const OFFICE_DOCUMENT_FRAGMENT: &[u8] = b"officeDocument";
#[cfg(feature = "odt")]
const ODT_MANIFEST_MEMBER: &[u8] = b"META-INF/manifest.xml";
#[cfg(feature = "odt")]
const ODT_TEXT_FRAGMENT: &[u8] = b"application/vnd.oasis.opendocument.text";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DocumentFormat {
Pdf,
Docx,
Epub,
Odt,
Opaque,
}
impl DocumentFormat {
pub const fn name(self) -> &'static str {
match self {
DocumentFormat::Pdf => "pdf",
DocumentFormat::Docx => "docx",
DocumentFormat::Epub => "epub",
DocumentFormat::Odt => "odt",
DocumentFormat::Opaque => "opaque",
}
}
pub const fn adapter(self) -> &'static str {
match self {
DocumentFormat::Pdf => "pdf",
DocumentFormat::Docx => "docx",
DocumentFormat::Epub => "epub",
DocumentFormat::Odt => "odt",
DocumentFormat::Opaque => "opaque",
}
}
pub const fn compiled(self) -> bool {
match self {
DocumentFormat::Pdf | DocumentFormat::Opaque => true,
DocumentFormat::Docx => cfg!(feature = "docx"),
DocumentFormat::Epub => cfg!(feature = "epub"),
DocumentFormat::Odt => cfg!(feature = "odt"),
}
}
pub fn provenance_prefix(self) -> String {
format!("format={};", self.name())
}
pub fn from_provenance(provenance: &str) -> Option<DocumentFormat> {
let rest = provenance.strip_prefix("format=")?;
let name = rest.split(';').next()?;
match name {
"pdf" => Some(DocumentFormat::Pdf),
"docx" => Some(DocumentFormat::Docx),
"epub" => Some(DocumentFormat::Epub),
"odt" => Some(DocumentFormat::Odt),
"opaque" => Some(DocumentFormat::Opaque),
_ => None,
}
}
}
pub fn detect_document_format(source: &[u8], limits: Limits) -> DocumentFormat {
if crate::adapter::pdf::detect(source, limits) {
return DocumentFormat::Pdf;
}
#[cfg(feature = "package")]
{
if let Some(format) = detect_zip_family(source, limits) {
return format;
}
}
DocumentFormat::Opaque
}
#[cfg(feature = "package")]
pub fn is_zip(source: &[u8], limits: Limits) -> bool {
crate::adapter::package::scan(source, limits).is_ok()
}
#[cfg(feature = "package")]
fn detect_zip_family(source: &[u8], limits: Limits) -> Option<DocumentFormat> {
let physical = crate::adapter::package::scan(source, limits).ok()?;
let mimetype = member_decoded(&physical, source, EPUB_MIMETYPE_MEMBER, limits);
let mimetype_ok = mimetype.as_deref() == Some(EPUB_MIMETYPE);
let container = member_decoded(&physical, source, CONTAINER_MEMBER, limits);
let container_ok = container
.as_deref()
.is_some_and(|c| contains(c, CONTAINER_NS) && contains(c, OPF_MEDIA_TYPE));
let is_epub = mimetype_ok || container_ok;
let content_types = member_decoded(&physical, source, CONTENT_TYPES_MEMBER, limits);
let rels = member_decoded(&physical, source, PACKAGE_RELS_MEMBER, limits);
let is_docx = content_types.is_some()
&& rels
.as_deref()
.is_some_and(|r| contains(r, OFFICE_DOCUMENT_FRAGMENT));
#[cfg(feature = "odt")]
let is_odt = mimetype
.as_deref()
.is_some_and(|m| contains(m, ODT_TEXT_FRAGMENT))
|| member_decoded(&physical, source, ODT_MANIFEST_MEMBER, limits)
.as_deref()
.is_some_and(|m| contains(m, ODT_TEXT_FRAGMENT));
#[cfg(not(feature = "odt"))]
let is_odt = false;
let matches = [is_docx, is_epub, is_odt].iter().filter(|b| **b).count();
match matches {
1 if is_docx => Some(DocumentFormat::Docx),
1 if is_epub => Some(DocumentFormat::Epub),
1 if is_odt => Some(DocumentFormat::Odt),
_ => None,
}
}
#[cfg(feature = "package")]
const EPUB_MIMETYPE_MEMBER: &[u8] = b"mimetype";
#[cfg(feature = "package")]
fn member_decoded(
physical: &crate::adapter::package::ZipPhysical,
source: &[u8],
name: &[u8],
limits: Limits,
) -> Option<Vec<u8>> {
const FLAG_ENCRYPTED: u16 = 0x0001;
let member = physical.members.iter().find(|m| m.name == name)?;
if member.flags & FLAG_ENCRYPTED != 0 || member.uncompressed_size > limits.max_xml_part_bytes {
return None;
}
let off = usize::try_from(member.data.0).ok()?;
let len = usize::try_from(member.data.1).ok()?;
let raw = source.get(off..off.checked_add(len)?)?;
match member.method {
0 => Some(raw.to_vec()),
8 => crate::field::derive::inflate_raw_deflate(raw, member.uncompressed_size, limits).ok(),
_ => None,
}
}
#[cfg(feature = "package")]
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
!needle.is_empty()
&& needle.len() <= haystack.len()
&& haystack.windows(needle.len()).any(|w| w == needle)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names_and_provenance_roundtrip() {
for f in [
DocumentFormat::Pdf,
DocumentFormat::Docx,
DocumentFormat::Epub,
DocumentFormat::Opaque,
] {
let token = format!("{}field:package;members=1", f.provenance_prefix());
assert_eq!(DocumentFormat::from_provenance(&token), Some(f));
}
assert_eq!(DocumentFormat::from_provenance("field:ingest-b"), None);
assert_eq!(DocumentFormat::from_provenance("format=exotic;x"), None);
}
#[test]
fn plain_bytes_are_opaque() {
assert_eq!(
detect_document_format(b"not a document", Limits::DEFAULT),
DocumentFormat::Opaque
);
}
#[test]
fn a_corpus_pdf_is_detected() {
let (_, pdf) = crate::adapter::pdf::sample_pdfs()
.into_iter()
.next()
.expect("the PDF corpus is non-empty");
assert_eq!(
detect_document_format(&pdf, Limits::DEFAULT),
DocumentFormat::Pdf
);
}
}