1use std::path::Path;
2use std::str::FromStr;
3
4use serde::{Deserialize, Serialize};
5
6use crate::error::{PdfConvertError, Result};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum InputKind {
11 Pdf,
12 Docx,
13 Pptx,
14 Html,
15 Asciidoc,
16 Markdown,
17 Csv,
18 Xlsx,
19 Odt,
20 Ods,
21 Odp,
22 Epub,
23 Email,
24 Image,
25 XmlUspto,
26 XmlJats,
27 XmlXbrl,
28 XmlDoclang,
29 MetsGbs,
30 JsonDocling,
31 Latex,
32 Text,
33}
34
35#[derive(Debug, Clone, Copy)]
36struct InputKindSpec {
37 kind: InputKind,
38 extensions: &'static [&'static str],
39 media_types: &'static [&'static str],
40 default_extension: &'static str,
41 default_media_type: &'static str,
42 from_formats_value: &'static str,
43 parse_aliases: &'static [&'static str],
44 reading_label: &'static str,
45 auto_detect: bool,
46 generic_convert_options: bool,
47 supports_vlm: bool,
48}
49
50const INPUT_KIND_SPECS: &[InputKindSpec] = &[
51 InputKindSpec {
52 kind: InputKind::Pdf,
53 extensions: &["pdf"],
54 media_types: &["application/pdf"],
55 default_extension: "pdf",
56 default_media_type: "application/pdf",
57 from_formats_value: "pdf",
58 parse_aliases: &["pdf"],
59 reading_label: "Reading PDF...",
60 auto_detect: true,
61 generic_convert_options: false,
62 supports_vlm: true,
63 },
64 InputKindSpec {
65 kind: InputKind::Docx,
66 extensions: &["docx"],
67 media_types: &["application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
68 default_extension: "docx",
69 default_media_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
70 from_formats_value: "docx",
71 parse_aliases: &["docx"],
72 reading_label: "Reading DOCX...",
73 auto_detect: true,
74 generic_convert_options: true,
75 supports_vlm: true,
76 },
77 InputKindSpec {
78 kind: InputKind::Pptx,
79 extensions: &["pptx"],
80 media_types: &["application/vnd.openxmlformats-officedocument.presentationml.presentation"],
81 default_extension: "pptx",
82 default_media_type: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
83 from_formats_value: "pptx",
84 parse_aliases: &["pptx"],
85 reading_label: "Reading PPTX...",
86 auto_detect: true,
87 generic_convert_options: true,
88 supports_vlm: false,
89 },
90 InputKindSpec {
91 kind: InputKind::Html,
92 extensions: &["html", "htm", "xhtml"],
93 media_types: &["text/html", "application/xhtml+xml"],
94 default_extension: "html",
95 default_media_type: "text/html",
96 from_formats_value: "html",
97 parse_aliases: &["html"],
98 reading_label: "Reading HTML...",
99 auto_detect: true,
100 generic_convert_options: true,
101 supports_vlm: false,
102 },
103 InputKindSpec {
104 kind: InputKind::Asciidoc,
105 extensions: &["adoc", "asciidoc", "asc"],
106 media_types: &["text/asciidoc", "text/x-asciidoc"],
107 default_extension: "adoc",
108 default_media_type: "text/asciidoc",
109 from_formats_value: "asciidoc",
110 parse_aliases: &["asciidoc", "adoc"],
111 reading_label: "Reading AsciiDoc...",
112 auto_detect: true,
113 generic_convert_options: true,
114 supports_vlm: false,
115 },
116 InputKindSpec {
117 kind: InputKind::Markdown,
118 extensions: &["md", "markdown"],
119 media_types: &["text/markdown", "text/x-markdown"],
120 default_extension: "md",
121 default_media_type: "text/markdown",
122 from_formats_value: "md",
123 parse_aliases: &["markdown", "md"],
124 reading_label: "Reading Markdown...",
125 auto_detect: true,
126 generic_convert_options: true,
127 supports_vlm: true,
128 },
129 InputKindSpec {
130 kind: InputKind::Csv,
131 extensions: &["csv"],
132 media_types: &["text/csv", "application/csv"],
133 default_extension: "csv",
134 default_media_type: "text/csv",
135 from_formats_value: "csv",
136 parse_aliases: &["csv"],
137 reading_label: "Reading CSV...",
138 auto_detect: true,
139 generic_convert_options: true,
140 supports_vlm: false,
141 },
142 InputKindSpec {
143 kind: InputKind::Xlsx,
144 extensions: &["xlsx"],
145 media_types: &["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
146 default_extension: "xlsx",
147 default_media_type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
148 from_formats_value: "xlsx",
149 parse_aliases: &["xlsx"],
150 reading_label: "Reading XLSX...",
151 auto_detect: true,
152 generic_convert_options: true,
153 supports_vlm: false,
154 },
155 InputKindSpec {
156 kind: InputKind::Odt,
157 extensions: &["odt"],
158 media_types: &["application/vnd.oasis.opendocument.text"],
159 default_extension: "odt",
160 default_media_type: "application/vnd.oasis.opendocument.text",
161 from_formats_value: "odt",
162 parse_aliases: &["odt"],
163 reading_label: "Reading ODT...",
164 auto_detect: true,
165 generic_convert_options: true,
166 supports_vlm: false,
167 },
168 InputKindSpec {
169 kind: InputKind::Ods,
170 extensions: &["ods"],
171 media_types: &["application/vnd.oasis.opendocument.spreadsheet"],
172 default_extension: "ods",
173 default_media_type: "application/vnd.oasis.opendocument.spreadsheet",
174 from_formats_value: "ods",
175 parse_aliases: &["ods"],
176 reading_label: "Reading ODS...",
177 auto_detect: true,
178 generic_convert_options: true,
179 supports_vlm: false,
180 },
181 InputKindSpec {
182 kind: InputKind::Odp,
183 extensions: &["odp"],
184 media_types: &["application/vnd.oasis.opendocument.presentation"],
185 default_extension: "odp",
186 default_media_type: "application/vnd.oasis.opendocument.presentation",
187 from_formats_value: "odp",
188 parse_aliases: &["odp"],
189 reading_label: "Reading ODP...",
190 auto_detect: true,
191 generic_convert_options: true,
192 supports_vlm: false,
193 },
194 InputKindSpec {
195 kind: InputKind::Epub,
196 extensions: &["epub"],
197 media_types: &["application/epub+zip"],
198 default_extension: "epub",
199 default_media_type: "application/epub+zip",
200 from_formats_value: "epub",
201 parse_aliases: &["epub"],
202 reading_label: "Reading EPUB...",
203 auto_detect: true,
204 generic_convert_options: true,
205 supports_vlm: false,
206 },
207 InputKindSpec {
208 kind: InputKind::Email,
209 extensions: &["eml", "msg"],
210 media_types: &["message/rfc822", "application/vnd.ms-outlook"],
211 default_extension: "eml",
212 default_media_type: "message/rfc822",
213 from_formats_value: "email",
214 parse_aliases: &["email", "eml", "msg"],
215 reading_label: "Reading email...",
216 auto_detect: true,
217 generic_convert_options: true,
218 supports_vlm: false,
219 },
220 InputKindSpec {
221 kind: InputKind::Image,
222 extensions: &[
223 "png", "jpg", "jpeg", "gif", "bmp", "tif", "tiff", "webp", "svg",
224 ],
225 media_types: &[
226 "image/png",
227 "image/jpeg",
228 "image/gif",
229 "image/bmp",
230 "image/tiff",
231 "image/webp",
232 "image/svg+xml",
233 ],
234 default_extension: "png",
235 default_media_type: "image/png",
236 from_formats_value: "image",
237 parse_aliases: &["image"],
238 reading_label: "Reading image...",
239 auto_detect: true,
240 generic_convert_options: true,
241 supports_vlm: true,
242 },
243 InputKindSpec {
244 kind: InputKind::XmlUspto,
245 extensions: &["xml"],
246 media_types: &["application/xml", "text/xml"],
247 default_extension: "xml",
248 default_media_type: "application/xml",
249 from_formats_value: "xml_uspto",
250 parse_aliases: &["xml_uspto"],
251 reading_label: "Reading XML USPTO...",
252 auto_detect: false,
253 generic_convert_options: true,
254 supports_vlm: false,
255 },
256 InputKindSpec {
257 kind: InputKind::XmlJats,
258 extensions: &["xml"],
259 media_types: &["application/xml", "text/xml"],
260 default_extension: "xml",
261 default_media_type: "application/xml",
262 from_formats_value: "xml_jats",
263 parse_aliases: &["xml_jats"],
264 reading_label: "Reading XML JATS...",
265 auto_detect: false,
266 generic_convert_options: true,
267 supports_vlm: false,
268 },
269 InputKindSpec {
270 kind: InputKind::XmlXbrl,
271 extensions: &["xml"],
272 media_types: &["application/xml", "text/xml"],
273 default_extension: "xml",
274 default_media_type: "application/xml",
275 from_formats_value: "xml_xbrl",
276 parse_aliases: &["xml_xbrl"],
277 reading_label: "Reading XML XBRL...",
278 auto_detect: false,
279 generic_convert_options: true,
280 supports_vlm: false,
281 },
282 InputKindSpec {
283 kind: InputKind::XmlDoclang,
284 extensions: &["xml"],
285 media_types: &["application/xml", "text/xml"],
286 default_extension: "xml",
287 default_media_type: "application/xml",
288 from_formats_value: "xml_doclang",
289 parse_aliases: &["xml_doclang"],
290 reading_label: "Reading XML Docling...",
291 auto_detect: false,
292 generic_convert_options: true,
293 supports_vlm: false,
294 },
295 InputKindSpec {
296 kind: InputKind::MetsGbs,
297 extensions: &["xml"],
298 media_types: &["application/xml", "text/xml"],
299 default_extension: "xml",
300 default_media_type: "application/xml",
301 from_formats_value: "mets_gbs",
302 parse_aliases: &["mets_gbs"],
303 reading_label: "Reading METS GBS...",
304 auto_detect: false,
305 generic_convert_options: true,
306 supports_vlm: false,
307 },
308 InputKindSpec {
309 kind: InputKind::JsonDocling,
310 extensions: &["json"],
311 media_types: &["application/json", "text/json"],
312 default_extension: "json",
313 default_media_type: "application/json",
314 from_formats_value: "json_docling",
315 parse_aliases: &["json_docling"],
316 reading_label: "Reading JSON Docling...",
317 auto_detect: false,
318 generic_convert_options: true,
319 supports_vlm: false,
320 },
321 InputKindSpec {
322 kind: InputKind::Latex,
323 extensions: &["tex"],
324 media_types: &[
325 "application/x-tex",
326 "application/x-latex",
327 "text/x-tex",
328 "text/x-latex",
329 ],
330 default_extension: "tex",
331 default_media_type: "application/x-tex",
332 from_formats_value: "latex",
333 parse_aliases: &["latex", "tex"],
334 reading_label: "Reading LaTeX...",
335 auto_detect: true,
336 generic_convert_options: true,
337 supports_vlm: false,
338 },
339 InputKindSpec {
340 kind: InputKind::Text,
341 extensions: &["txt"],
342 media_types: &["text/plain"],
343 default_extension: "txt",
344 default_media_type: "text/plain",
345 from_formats_value: "text",
346 parse_aliases: &["text", "txt"],
347 reading_label: "Reading text file...",
348 auto_detect: true,
349 generic_convert_options: false,
350 supports_vlm: false,
351 },
352];
353
354impl InputKind {
355 pub fn from_path(path: &Path) -> Option<Self> {
356 let file_name = path.file_name().and_then(|name| name.to_str())?;
357 Self::from_filename_and_media_type(file_name, None)
358 }
359
360 pub fn from_filename_and_media_type(filename: &str, media_type: Option<&str>) -> Option<Self> {
361 let ext = normalized_extension(filename);
362 let media_type = normalized_media_type(media_type);
363
364 if Self::requires_explicit_override(filename, media_type.as_deref()) {
365 return None;
366 }
367
368 detect_auto_kind(ext.as_deref(), media_type.as_deref())
369 }
370
371 pub fn requires_explicit_override(filename: &str, media_type: Option<&str>) -> bool {
372 let ext = normalized_extension(filename);
373 let media_type = normalized_media_type(media_type);
374
375 matches!(ext.as_deref(), Some("xml") | Some("json"))
376 || matches!(
377 media_type.as_deref(),
378 Some("application/xml")
379 | Some("text/xml")
380 | Some("application/json")
381 | Some("text/json")
382 )
383 }
384
385 pub fn media_type(self) -> &'static str {
386 self.spec().default_media_type
387 }
388
389 pub fn canonical_media_type(self, filename: &str, media_type: Option<&str>) -> &'static str {
390 let ext = normalized_extension(filename);
391 let media_type = normalized_media_type(media_type);
392
393 match self {
394 Self::Html => match (ext.as_deref(), media_type.as_deref()) {
395 (Some("xhtml"), _) | (_, Some("application/xhtml+xml")) => "application/xhtml+xml",
396 _ => self.media_type(),
397 },
398 Self::Email => match (ext.as_deref(), media_type.as_deref()) {
399 (Some("msg"), _) | (_, Some("application/vnd.ms-outlook")) => {
400 "application/vnd.ms-outlook"
401 }
402 _ => self.media_type(),
403 },
404 Self::Image => image_media_type(ext.as_deref(), media_type.as_deref()),
405 _ => self.media_type(),
406 }
407 }
408
409 pub fn default_extension(self) -> &'static str {
410 self.spec().default_extension
411 }
412
413 pub fn default_extension_for_media_type(self, media_type: Option<&str>) -> &'static str {
414 let media_type = normalized_media_type(media_type);
415
416 match self {
417 Self::Html => match media_type.as_deref() {
418 Some("application/xhtml+xml") => "xhtml",
419 _ => self.default_extension(),
420 },
421 Self::Email => match media_type.as_deref() {
422 Some("application/vnd.ms-outlook") => "msg",
423 _ => self.default_extension(),
424 },
425 Self::Image => image_extension(media_type.as_deref()),
426 _ => self.default_extension(),
427 }
428 }
429
430 pub fn reading_label(self) -> &'static str {
431 self.spec().reading_label
432 }
433
434 pub fn from_formats_value(self) -> &'static str {
435 self.spec().from_formats_value
436 }
437
438 pub fn uses_generic_convert_options(self) -> bool {
439 self.spec().generic_convert_options
440 }
441
442 pub fn supports_vlm(self) -> bool {
443 self.spec().supports_vlm
444 }
445
446 fn spec(self) -> &'static InputKindSpec {
447 INPUT_KIND_SPECS
448 .iter()
449 .find(|spec| spec.kind == self)
450 .expect("missing InputKindSpec")
451 }
452}
453
454impl std::fmt::Display for InputKind {
455 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
456 f.write_str(self.from_formats_value())
457 }
458}
459
460impl FromStr for InputKind {
461 type Err = PdfConvertError;
462
463 fn from_str(value: &str) -> Result<Self> {
464 let normalized = value.trim().to_ascii_lowercase();
465
466 INPUT_KIND_SPECS
467 .iter()
468 .find(|spec| spec.parse_aliases.contains(&normalized.as_str()))
469 .map(|spec| spec.kind)
470 .ok_or_else(|| {
471 PdfConvertError::validation_error(
472 "input_format",
473 format!("unsupported input format: {}", normalized),
474 )
475 })
476 }
477}
478
479fn detect_auto_kind(extension: Option<&str>, media_type: Option<&str>) -> Option<InputKind> {
480 INPUT_KIND_SPECS
481 .iter()
482 .filter(|spec| spec.auto_detect)
483 .find(|spec| {
484 extension.is_some_and(|ext| spec.extensions.contains(&ext))
485 || media_type.is_some_and(|mime| spec.media_types.contains(&mime))
486 })
487 .map(|spec| spec.kind)
488}
489
490fn image_media_type(extension: Option<&str>, media_type: Option<&str>) -> &'static str {
491 match (extension, media_type) {
492 (Some("jpg" | "jpeg"), _) | (_, Some("image/jpeg")) => "image/jpeg",
493 (Some("gif"), _) | (_, Some("image/gif")) => "image/gif",
494 (Some("bmp"), _) | (_, Some("image/bmp")) => "image/bmp",
495 (Some("tif" | "tiff"), _) | (_, Some("image/tiff")) => "image/tiff",
496 (Some("webp"), _) | (_, Some("image/webp")) => "image/webp",
497 (Some("svg"), _) | (_, Some("image/svg+xml")) => "image/svg+xml",
498 _ => "image/png",
499 }
500}
501
502fn image_extension(media_type: Option<&str>) -> &'static str {
503 match media_type {
504 Some("image/jpeg") => "jpg",
505 Some("image/gif") => "gif",
506 Some("image/bmp") => "bmp",
507 Some("image/tiff") => "tiff",
508 Some("image/webp") => "webp",
509 Some("image/svg+xml") => "svg",
510 _ => "png",
511 }
512}
513
514fn normalized_extension(filename: &str) -> Option<String> {
515 Path::new(filename)
516 .extension()
517 .and_then(|ext| ext.to_str())
518 .map(|ext| ext.to_ascii_lowercase())
519}
520
521fn normalized_media_type(media_type: Option<&str>) -> Option<String> {
522 media_type.map(|value| {
523 value
524 .split(';')
525 .next()
526 .unwrap_or(value)
527 .trim()
528 .to_ascii_lowercase()
529 })
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535
536 #[test]
537 fn metadata_methods_match_specs() {
538 for spec in INPUT_KIND_SPECS {
539 assert_eq!(spec.kind.default_extension(), spec.default_extension);
540 assert_eq!(spec.kind.media_type(), spec.default_media_type);
541 assert_eq!(spec.kind.from_formats_value(), spec.from_formats_value);
542 assert_eq!(spec.kind.reading_label(), spec.reading_label);
543 assert_eq!(
544 spec.kind.uses_generic_convert_options(),
545 spec.generic_convert_options
546 );
547 assert_eq!(spec.kind.supports_vlm(), spec.supports_vlm);
548 }
549 }
550
551 #[test]
552 fn detects_first_wave_and_latex_input_kinds_from_extension() {
553 for spec in INPUT_KIND_SPECS.iter().filter(|spec| spec.auto_detect) {
554 for extension in spec.extensions {
555 let filename = format!("sample.{}", extension);
556 assert_eq!(
557 InputKind::from_path(Path::new(&filename)),
558 Some(spec.kind),
559 "failed to detect extension '{}'",
560 extension
561 );
562 }
563 }
564 }
565
566 #[test]
567 fn generic_xml_and_json_do_not_auto_map() {
568 assert_eq!(InputKind::from_path(Path::new("a.xml")), None);
569 assert_eq!(InputKind::from_path(Path::new("a.json")), None);
570 assert_eq!(
571 InputKind::from_filename_and_media_type("downloaded", Some("application/xml")),
572 None
573 );
574 assert_eq!(
575 InputKind::from_filename_and_media_type("downloaded", Some("application/json")),
576 None
577 );
578 }
579
580 #[test]
581 fn detects_supported_input_kinds_from_mime_type() {
582 for spec in INPUT_KIND_SPECS.iter().filter(|spec| spec.auto_detect) {
583 for media_type in spec.media_types {
584 assert_eq!(
585 InputKind::from_filename_and_media_type("upload", Some(media_type)),
586 Some(spec.kind),
587 "failed to detect media type '{}'",
588 media_type
589 );
590 }
591 }
592 }
593
594 #[test]
595 fn reports_override_only_sources() {
596 assert!(InputKind::requires_explicit_override("paper.xml", None));
597 assert!(InputKind::requires_explicit_override(
598 "paper",
599 Some("application/xml")
600 ));
601 assert!(InputKind::requires_explicit_override("paper.json", None));
602 assert!(InputKind::requires_explicit_override(
603 "paper",
604 Some("application/json")
605 ));
606 assert!(!InputKind::requires_explicit_override("paper.tex", None));
607 }
608
609 #[test]
610 fn derives_second_wave_defaults() {
611 assert_eq!(InputKind::XmlJats.default_extension(), "xml");
612 assert_eq!(InputKind::JsonDocling.default_extension(), "json");
613 assert_eq!(InputKind::Latex.default_extension(), "tex");
614 assert_eq!(InputKind::XmlUspto.from_formats_value(), "xml_uspto");
615 assert_eq!(InputKind::JsonDocling.from_formats_value(), "json_docling");
616 assert_eq!(InputKind::Latex.from_formats_value(), "latex");
617 }
618
619 #[test]
620 fn parses_input_format_strings() {
621 assert_eq!("xml_jats".parse::<InputKind>().unwrap(), InputKind::XmlJats);
622 assert_eq!(
623 "json_docling".parse::<InputKind>().unwrap(),
624 InputKind::JsonDocling
625 );
626 assert_eq!("latex".parse::<InputKind>().unwrap(), InputKind::Latex);
627 assert_eq!("msg".parse::<InputKind>().unwrap(), InputKind::Email);
628 assert!("xml".parse::<InputKind>().is_err());
629 }
630
631 #[test]
632 fn preserves_specific_media_types() {
633 assert_eq!(
634 InputKind::Image.canonical_media_type("cover.jpg", None),
635 "image/jpeg"
636 );
637 assert_eq!(
638 InputKind::Email.canonical_media_type("message.msg", None),
639 "application/vnd.ms-outlook"
640 );
641 assert_eq!(
642 InputKind::Html.canonical_media_type("page.xhtml", None),
643 "application/xhtml+xml"
644 );
645 }
646}