1use crate::limits::Limits;
30
31pub const EPUB_MIMETYPE: &[u8] = b"application/epub+zip";
33pub const CONTAINER_MEMBER: &[u8] = b"META-INF/container.xml";
35pub const CONTAINER_NS: &[u8] = b"urn:oasis:names:tc:opendocument:xmlns:container";
37pub const OPF_MEDIA_TYPE: &[u8] = b"application/oebps-package+xml";
39#[cfg(feature = "package")]
41const CONTENT_TYPES_MEMBER: &[u8] = b"[Content_Types].xml";
42#[cfg(feature = "package")]
44const PACKAGE_RELS_MEMBER: &[u8] = b"_rels/.rels";
45#[cfg(feature = "package")]
47const OFFICE_DOCUMENT_FRAGMENT: &[u8] = b"officeDocument";
48#[cfg(feature = "odt")]
50const ODT_MANIFEST_MEMBER: &[u8] = b"META-INF/manifest.xml";
51#[cfg(feature = "odt")]
53const ODT_TEXT_FRAGMENT: &[u8] = b"application/vnd.oasis.opendocument.text";
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum DocumentFormat {
58 Pdf,
60 Docx,
62 Epub,
64 Odt,
66 Opaque,
68}
69
70impl DocumentFormat {
71 pub const fn name(self) -> &'static str {
73 match self {
74 DocumentFormat::Pdf => "pdf",
75 DocumentFormat::Docx => "docx",
76 DocumentFormat::Epub => "epub",
77 DocumentFormat::Odt => "odt",
78 DocumentFormat::Opaque => "opaque",
79 }
80 }
81
82 pub const fn adapter(self) -> &'static str {
84 match self {
85 DocumentFormat::Pdf => "pdf",
86 DocumentFormat::Docx => "docx",
87 DocumentFormat::Epub => "epub",
88 DocumentFormat::Odt => "odt",
89 DocumentFormat::Opaque => "opaque",
90 }
91 }
92
93 pub const fn compiled(self) -> bool {
95 match self {
96 DocumentFormat::Pdf | DocumentFormat::Opaque => true,
97 DocumentFormat::Docx => cfg!(feature = "docx"),
98 DocumentFormat::Epub => cfg!(feature = "epub"),
99 DocumentFormat::Odt => cfg!(feature = "odt"),
100 }
101 }
102
103 pub fn provenance_prefix(self) -> String {
105 format!("format={};", self.name())
106 }
107
108 pub fn from_provenance(provenance: &str) -> Option<DocumentFormat> {
114 let rest = provenance.strip_prefix("format=")?;
115 let name = rest.split(';').next()?;
116 match name {
117 "pdf" => Some(DocumentFormat::Pdf),
118 "docx" => Some(DocumentFormat::Docx),
119 "epub" => Some(DocumentFormat::Epub),
120 "odt" => Some(DocumentFormat::Odt),
121 "opaque" => Some(DocumentFormat::Opaque),
122 _ => None,
123 }
124 }
125}
126
127pub fn detect_document_format(source: &[u8], limits: Limits) -> DocumentFormat {
132 if crate::adapter::pdf::detect(source, limits) {
133 return DocumentFormat::Pdf;
134 }
135 #[cfg(feature = "package")]
136 {
137 if let Some(format) = detect_zip_family(source, limits) {
138 return format;
139 }
140 }
141 DocumentFormat::Opaque
142}
143
144#[cfg(feature = "package")]
150pub fn is_zip(source: &[u8], limits: Limits) -> bool {
151 crate::adapter::package::scan(source, limits).is_ok()
152}
153
154#[cfg(feature = "package")]
155fn detect_zip_family(source: &[u8], limits: Limits) -> Option<DocumentFormat> {
156 let physical = crate::adapter::package::scan(source, limits).ok()?;
157
158 let mimetype = member_decoded(&physical, source, EPUB_MIMETYPE_MEMBER, limits);
161 let mimetype_ok = mimetype.as_deref() == Some(EPUB_MIMETYPE);
162 let container = member_decoded(&physical, source, CONTAINER_MEMBER, limits);
163 let container_ok = container
164 .as_deref()
165 .is_some_and(|c| contains(c, CONTAINER_NS) && contains(c, OPF_MEDIA_TYPE));
166 let is_epub = mimetype_ok || container_ok;
167
168 let content_types = member_decoded(&physical, source, CONTENT_TYPES_MEMBER, limits);
171 let rels = member_decoded(&physical, source, PACKAGE_RELS_MEMBER, limits);
172 let is_docx = content_types.is_some()
173 && rels
174 .as_deref()
175 .is_some_and(|r| contains(r, OFFICE_DOCUMENT_FRAGMENT));
176
177 #[cfg(feature = "odt")]
180 let is_odt = mimetype
181 .as_deref()
182 .is_some_and(|m| contains(m, ODT_TEXT_FRAGMENT))
183 || member_decoded(&physical, source, ODT_MANIFEST_MEMBER, limits)
184 .as_deref()
185 .is_some_and(|m| contains(m, ODT_TEXT_FRAGMENT));
186 #[cfg(not(feature = "odt"))]
187 let is_odt = false;
188
189 let matches = [is_docx, is_epub, is_odt].iter().filter(|b| **b).count();
191 match matches {
192 1 if is_docx => Some(DocumentFormat::Docx),
193 1 if is_epub => Some(DocumentFormat::Epub),
194 1 if is_odt => Some(DocumentFormat::Odt),
195 _ => None,
196 }
197}
198
199#[cfg(feature = "package")]
200const EPUB_MIMETYPE_MEMBER: &[u8] = b"mimetype";
201
202#[cfg(feature = "package")]
205fn member_decoded(
206 physical: &crate::adapter::package::ZipPhysical,
207 source: &[u8],
208 name: &[u8],
209 limits: Limits,
210) -> Option<Vec<u8>> {
211 const FLAG_ENCRYPTED: u16 = 0x0001;
212 let member = physical.members.iter().find(|m| m.name == name)?;
213 if member.flags & FLAG_ENCRYPTED != 0 || member.uncompressed_size > limits.max_xml_part_bytes {
214 return None;
215 }
216 let off = usize::try_from(member.data.0).ok()?;
217 let len = usize::try_from(member.data.1).ok()?;
218 let raw = source.get(off..off.checked_add(len)?)?;
219 match member.method {
220 0 => Some(raw.to_vec()),
221 8 => crate::field::derive::inflate_raw_deflate(raw, member.uncompressed_size, limits).ok(),
222 _ => None,
223 }
224}
225
226#[cfg(feature = "package")]
228fn contains(haystack: &[u8], needle: &[u8]) -> bool {
229 !needle.is_empty()
230 && needle.len() <= haystack.len()
231 && haystack.windows(needle.len()).any(|w| w == needle)
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 #[test]
239 fn names_and_provenance_roundtrip() {
240 for f in [
241 DocumentFormat::Pdf,
242 DocumentFormat::Docx,
243 DocumentFormat::Epub,
244 DocumentFormat::Opaque,
245 ] {
246 let token = format!("{}field:package;members=1", f.provenance_prefix());
247 assert_eq!(DocumentFormat::from_provenance(&token), Some(f));
248 }
249 assert_eq!(DocumentFormat::from_provenance("field:ingest-b"), None);
250 assert_eq!(DocumentFormat::from_provenance("format=exotic;x"), None);
251 }
252
253 #[test]
254 fn plain_bytes_are_opaque() {
255 assert_eq!(
256 detect_document_format(b"not a document", Limits::DEFAULT),
257 DocumentFormat::Opaque
258 );
259 }
260
261 #[test]
262 fn a_corpus_pdf_is_detected() {
263 let (_, pdf) = crate::adapter::pdf::sample_pdfs()
264 .into_iter()
265 .next()
266 .expect("the PDF corpus is non-empty");
267 assert_eq!(
268 detect_document_format(&pdf, Limits::DEFAULT),
269 DocumentFormat::Pdf
270 );
271 }
272}