Skip to main content

cloudiful_docling_convert/document/
input_kind.rs

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}