docling 2.2.2

DocumentConverter and format backends for docling.rs (a Rust port of docling).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
//! Input format enumeration and detection.
//!
//! Mirrors `docling.datamodel.base_models.InputFormat` and its
//! `FormatToExtensions` map.

/// A document format supported by docling.rs backends.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum InputFormat {
    Docx,
    Pptx,
    Html,
    Image,
    Pdf,
    Asciidoc,
    Md,
    Csv,
    Xlsx,
    /// Word 97–2004 binary (`.doc`) — parsed natively (CFB + MS-DOC), no
    /// external converter (docling shells out to LibreOffice for these).
    Doc,
    /// Excel 97–2004 binary (`.xls`, BIFF8) — parsed natively via calamine.
    Xls,
    /// PowerPoint 97–2003 binary (`.ppt`) — parsed natively (CFB + MS-PPT).
    Ppt,
    Odt,
    Ods,
    Odp,
    XmlUspto,
    XmlJats,
    XmlXbrl,
    XmlDoclang,
    /// Raw DocTags markup (`.doctags`/`.dt`) — the token stream docling's
    /// VLMs emit, parsed by `docling_core::doctags` (#152).
    DocTags,
    /// A DocLang OPC archive (`.dclx`, the format `--to dclx` writes).
    Dclx,
    MetsGbs,
    JsonDocling,
    Audio,
    /// Video containers (`.mp4`/`.avi`/`.mov`/`.mkv`/`.webm`) — Phase 1 of
    /// issue #138 transcribes the audio track through the ASR pipeline
    /// (mirrors docling's `InputFormat.VIDEO`, v2.114).
    Video,
    Vtt,
    /// EBCDIC mainframe data + JSON copybook layout (#252, docling 2.118).
    Ebcdic,
    Latex,
    Email,
    Epub,
    /// MIME HTML archive (`.mhtml`/`.mht`) — docling's `InputFormat.MHTML`
    /// (docling#4184), unwrapped and converted as HTML.
    Mhtml,
    /// Rich Text Format (`.rtf`) — a docling.rs extension (#209); docling
    /// converts RTF only by shelling out to LibreOffice.
    Rtf,
    /// Microsoft Visio (`.vsdx`, `.vsdm`) — a docling.rs extension (#214);
    /// docling has no Visio reader. Pages become sections, shape text flows
    /// in reading order, connectors become a relations table.
    Visio,
    /// SVG (`.svg`) — a docling.rs extension (#212); docling does not accept
    /// SVG input. Mirrors the pdf / pdf-text split: the ML build rasterizes
    /// (resvg) and rides the image pipeline; without ML — or under `--no-ocr` / `--text-layer-only`
    /// — `<text>` elements are extracted directly into flat paragraphs.
    Svg,
    /// Apple Pages (`.pages`) — a conformance format (#318, #383): mirrors
    /// docling's `IWorkPagesDocumentBackend` for both the 2013+ IWA package
    /// and the iWork '09 `index.xml` generation.
    Pages,
    /// Apple Numbers (`.numbers`), same IWA machinery as [`Self::Pages`].
    Numbers,
    /// Apple Keynote (`.key`), same IWA machinery as [`Self::Pages`].
    Keynote,
    /// AbiWord (`.abw`/`.zabw`/`.awt`) — a docling.rs extension (#216);
    /// AWML XML (gzip-wrapped for `.zabw`), parsed natively.
    Abiword,
    /// WordPerfect 5.x / 6.x+ documents (`.wpd`, `.wp`, `.wp5`, `.wp6`,
    /// `.wpt`) — a docling.rs extension (#216); the `ÿWPC` byte stream is
    /// parsed natively (docling reaches WordPerfect only via LibreOffice's
    /// libwpd), text-level with bold/italic/underline runs.
    WordPerfect,
    /// Microsoft Works word-processor documents (`.wps`) — a docling.rs
    /// extension (#216); Works 2.x DOS / 3 / 4 (`WPS4`) and Works 2000 / 6–9
    /// (`WPS8`, OLE `CONTENTS`) parsed natively after libwps' readers.
    Works,
    /// dBase table (`.dbf`) — a docling.rs extension (#216); the field
    /// descriptors become the header row, records the data rows.
    Dbf,
    /// Data Interchange Format (`.dif`) — a docling.rs extension (#216);
    /// sheet snapshot, split into data regions like an ODS sheet.
    Dif,
    /// SYLK (`.slk`/`.sylk`) — a docling.rs extension (#216); same
    /// sheet-region conversion as DIF.
    Sylk,
    /// Quattro Pro spreadsheets (`.wq1`/`.wq2` DOS, `.wb1`–`.wb3` Windows,
    /// `.qpw` 9–X9) — a docling.rs extension (#216) parsed natively after
    /// libwps' readers.
    QuattroPro,
    /// Lotus 1-2-3 / Symphony / MS Works spreadsheets (`.wk1`–`.wk4`,
    /// `.wks`, `.wrk`, `.123`) — a docling.rs extension (#216); the DOS-era
    /// record streams, content-sniffed on the BOF record and split into data
    /// regions like an ODS sheet.
    Lotus,
    /// StarOffice 5 binaries (`.sdw`/`.sda`/`.sdd`/`.vor`) — a docling.rs
    /// extension (#215); CFB containers parsed natively (text-level
    /// extraction). The document kind comes from the stream inside, so a
    /// `.vor` template of any application dispatches by content. StarCalc
    /// (`.sdc`) is a follow-up.
    StarOffice5,
    /// DjVu scanned documents (`.djvu`/`.djv`) — a docling.rs extension (#434);
    /// docling has no DjVu reader. Decoded in pure Rust (`djvu-rs`): the
    /// hidden per-page text layer by default, rasterize + OCR for scan-only
    /// pages when the ML pipeline is built.
    Djvu,
}

impl InputFormat {
    /// Stable string identifier, matching the Python enum values.
    pub fn as_str(self) -> &'static str {
        match self {
            InputFormat::Docx => "docx",
            InputFormat::Pptx => "pptx",
            InputFormat::Html => "html",
            InputFormat::Image => "image",
            InputFormat::Pdf => "pdf",
            InputFormat::Asciidoc => "asciidoc",
            InputFormat::Md => "md",
            InputFormat::Csv => "csv",
            InputFormat::Xlsx => "xlsx",
            InputFormat::Doc => "doc",
            InputFormat::Xls => "xls",
            InputFormat::Ppt => "ppt",
            InputFormat::Odt => "odt",
            InputFormat::Ods => "ods",
            InputFormat::Odp => "odp",
            InputFormat::XmlUspto => "xml_uspto",
            InputFormat::XmlJats => "xml_jats",
            InputFormat::XmlXbrl => "xml_xbrl",
            InputFormat::XmlDoclang => "xml_doclang",
            InputFormat::DocTags => "doctags",
            InputFormat::Dclx => "dclx",
            InputFormat::MetsGbs => "mets_gbs",
            InputFormat::JsonDocling => "json_docling",
            InputFormat::Audio => "audio",
            InputFormat::Video => "video",
            InputFormat::Vtt => "vtt",
            InputFormat::Ebcdic => "ebc",
            InputFormat::Latex => "latex",
            InputFormat::Email => "email",
            InputFormat::Epub => "epub",
            InputFormat::Mhtml => "mhtml",
            InputFormat::Rtf => "rtf",
            InputFormat::Visio => "visio",
            InputFormat::Svg => "svg",
            InputFormat::Pages => "pages",
            InputFormat::Numbers => "numbers",
            InputFormat::Keynote => "key",
            InputFormat::Abiword => "abiword",
            InputFormat::WordPerfect => "wordperfect",
            InputFormat::Works => "works",
            InputFormat::Dbf => "dbf",
            InputFormat::Dif => "dif",
            InputFormat::Sylk => "sylk",
            InputFormat::Lotus => "lotus",
            InputFormat::QuattroPro => "quattro",
            InputFormat::StarOffice5 => "staroffice5",
            InputFormat::Djvu => "djvu",
        }
    }

    /// The inverse of [`as_str`](Self::as_str): the format for one of its
    /// stable identifiers (`"pdf"`, `"xml_jats"`, …; case-insensitive), as the
    /// bindings receive it. `None` for anything else.
    pub fn from_id(id: &str) -> Option<Self> {
        Some(match id.trim().to_ascii_lowercase().as_str() {
            "docx" => InputFormat::Docx,
            "pptx" => InputFormat::Pptx,
            "html" => InputFormat::Html,
            "image" => InputFormat::Image,
            "pdf" => InputFormat::Pdf,
            "asciidoc" => InputFormat::Asciidoc,
            "md" => InputFormat::Md,
            "csv" => InputFormat::Csv,
            "xlsx" => InputFormat::Xlsx,
            "doc" => InputFormat::Doc,
            "xls" => InputFormat::Xls,
            "ppt" => InputFormat::Ppt,
            "odt" => InputFormat::Odt,
            "ods" => InputFormat::Ods,
            "odp" => InputFormat::Odp,
            "xml_uspto" => InputFormat::XmlUspto,
            "xml_jats" => InputFormat::XmlJats,
            "xml_xbrl" => InputFormat::XmlXbrl,
            "xml_doclang" => InputFormat::XmlDoclang,
            "doctags" => InputFormat::DocTags,
            "dclx" => InputFormat::Dclx,
            "mets_gbs" => InputFormat::MetsGbs,
            "json_docling" => InputFormat::JsonDocling,
            "audio" => InputFormat::Audio,
            "video" => InputFormat::Video,
            "vtt" => InputFormat::Vtt,
            "ebc" => InputFormat::Ebcdic,
            "latex" => InputFormat::Latex,
            "email" => InputFormat::Email,
            "epub" => InputFormat::Epub,
            "mhtml" => InputFormat::Mhtml,
            "rtf" => InputFormat::Rtf,
            "visio" => InputFormat::Visio,
            "svg" => InputFormat::Svg,
            "pages" => InputFormat::Pages,
            "numbers" => InputFormat::Numbers,
            "key" => InputFormat::Keynote,
            "abiword" => InputFormat::Abiword,
            "wordperfect" => InputFormat::WordPerfect,
            "works" => InputFormat::Works,
            "dbf" => InputFormat::Dbf,
            "dif" => InputFormat::Dif,
            "sylk" => InputFormat::Sylk,
            "lotus" => InputFormat::Lotus,
            "quattro" => InputFormat::QuattroPro,
            "staroffice5" => InputFormat::StarOffice5,
            "djvu" => InputFormat::Djvu,
            _ => return None,
        })
    }

    /// Best-effort format detection from a media type (`type/subtype`,
    /// parameters and case ignored) — what an email attachment or an HTTP
    /// response declares when its name has no usable extension (#561). The
    /// generic `application/octet-stream` is `None`: it says nothing.
    pub fn from_mime(mime: &str) -> Option<Self> {
        let essence = mime
            .split(';')
            .next()
            .unwrap_or("")
            .trim()
            .to_ascii_lowercase();
        Some(match essence.as_str() {
            "application/pdf" | "application/x-pdf" => InputFormat::Pdf,
            "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
            | "application/vnd.openxmlformats-officedocument.wordprocessingml.template"
            | "application/vnd.ms-word.document.macroenabled.12" => InputFormat::Docx,
            "application/vnd.openxmlformats-officedocument.presentationml.presentation"
            | "application/vnd.openxmlformats-officedocument.presentationml.slideshow"
            | "application/vnd.openxmlformats-officedocument.presentationml.template"
            | "application/vnd.ms-powerpoint.presentation.macroenabled.12" => InputFormat::Pptx,
            "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
            | "application/vnd.openxmlformats-officedocument.spreadsheetml.template"
            | "application/vnd.ms-excel.sheet.macroenabled.12"
            | "application/vnd.ms-excel.sheet.binary.macroenabled.12" => InputFormat::Xlsx,
            "application/msword" => InputFormat::Doc,
            "application/vnd.ms-excel" => InputFormat::Xls,
            "application/vnd.ms-powerpoint" => InputFormat::Ppt,
            "application/vnd.oasis.opendocument.text" => InputFormat::Odt,
            "application/vnd.oasis.opendocument.spreadsheet" => InputFormat::Ods,
            "application/vnd.oasis.opendocument.presentation" => InputFormat::Odp,
            "application/rtf" | "text/rtf" => InputFormat::Rtf,
            "application/epub+zip" => InputFormat::Epub,
            "text/html" | "application/xhtml+xml" => InputFormat::Html,
            "text/markdown" | "text/x-markdown" | "text/plain" => InputFormat::Md,
            "text/csv" | "text/tab-separated-values" => InputFormat::Csv,
            "text/x-tex" | "application/x-tex" | "application/x-latex" => InputFormat::Latex,
            "text/vtt" => InputFormat::Vtt,
            "application/json" => InputFormat::JsonDocling,
            "message/rfc822" | "application/vnd.ms-outlook" => InputFormat::Email,
            "multipart/related" | "message/rfc822-headers" => return None,
            "image/png" | "image/jpeg" | "image/tiff" | "image/bmp" | "image/webp"
            | "image/gif" | "image/heic" | "image/heif" => InputFormat::Image,
            "image/svg+xml" => InputFormat::Svg,
            "audio/mpeg" | "audio/mp3" | "audio/wav" | "audio/x-wav" | "audio/wave"
            | "audio/mp4" | "audio/m4a" | "audio/x-m4a" | "audio/aac" | "audio/ogg"
            | "audio/flac" => InputFormat::Audio,
            "video/mp4" | "video/quicktime" | "video/x-msvideo" | "video/x-matroska"
            | "video/webm" | "video/mpeg" => InputFormat::Video,
            _ => return None,
        })
    }

    /// Best-effort format detection from a file extension (case-insensitive).
    ///
    /// Ambiguous extensions (notably bare `xml`) resolve to a single default
    /// here; the converter's content sniffing does the real disambiguation.
    /// The mapping is the [`EXTENSIONS`] table — the one list
    /// [`supported_extensions`](Self::supported_extensions) prints too (#603).
    pub fn from_extension(ext: &str) -> Option<Self> {
        let ext = ext.to_ascii_lowercase();
        EXTENSIONS
            .iter()
            .find(|(known, _)| *known == ext)
            .map(|&(_, format)| format)
    }

    /// The file extensions that route to this format (lowercase, no dot), in
    /// [`EXTENSIONS`] order.
    pub fn extensions(self) -> impl Iterator<Item = &'static str> {
        EXTENSIONS
            .iter()
            .filter(move |(_, format)| *format == self)
            .map(|&(ext, _)| ext)
    }

    /// Whether this build converts the format. Every format stays
    /// *detectable* in every build, but a few need a cargo feature to
    /// convert: PDF the `pdf` ML pipeline or the `pdf-text` text-layer path,
    /// images and METS/GBS scan packages the `pdf` pipeline, audio and video
    /// the `asr` pipeline — without it the converter answers "not compiled
    /// in". Missing *runtime* assets (models, ffmpeg) are not a build
    /// property and do not count here: those formats degrade or fail at
    /// conversion time with a message that names what to fetch.
    pub fn is_compiled_in(self) -> bool {
        match self {
            InputFormat::Pdf => cfg!(any(feature = "pdf", feature = "pdf-text")),
            InputFormat::Image | InputFormat::MetsGbs => cfg!(feature = "pdf"),
            InputFormat::Audio | InputFormat::Video => cfg!(feature = "asr"),
            _ => true,
        }
    }

    /// Every input file extension this build converts — sorted, unique,
    /// lowercase, no dot: what `docling-rs --list-input-formats` prints
    /// (#603, Pandoc's `--list-input-formats`), so a wrapper can ask the
    /// binary instead of hard-coding a list that drifts from it. Formats the
    /// build lacks ([`is_compiled_in`](Self::is_compiled_in)) are left out,
    /// and so are two extensions that only route: `.heic`/`.heif` without the
    /// `heif` feature (libheif — the image pipeline cannot decode them) and
    /// StarCalc's `.sdc`, which reaches the StarOffice backend for its
    /// targeted "save as .ods" error, never a document.
    pub fn supported_extensions() -> Vec<&'static str> {
        let mut out: Vec<&'static str> = EXTENSIONS
            .iter()
            .filter(|(ext, format)| {
                format.is_compiled_in()
                    && *ext != "sdc"
                    && (cfg!(feature = "heif") || !matches!(*ext, "heic" | "heif"))
            })
            .map(|&(ext, _)| ext)
            .collect();
        out.sort_unstable();
        out.dedup();
        out
    }
}

/// The output formats a conversion can be written as — the `--to` values of
/// the CLI and the `to` option of docling-serve, in the order the help text
/// lists them (`markdown` is accepted as an alias of `md` on both). One list,
/// so the validation, the error messages and `--list-output-formats` (#603)
/// cannot drift apart.
pub const OUTPUT_FORMATS: &[&str] = &[
    "md", "json", "html", "text", "dclx", "chunks", "images", "latex", "pandoc",
];

/// File extension → format, the table behind
/// [`InputFormat::from_extension`] (lowercase, no dot; each extension once).
/// Extension sets follow docling's `FormatToExtensions` where upstream has
/// the format; the rest are docling.rs extensions, issue numbers inline.
const EXTENSIONS: &[(&str, InputFormat)] = {
    use InputFormat::*;
    &[
        ("docx", Docx),
        ("dotx", Docx),
        ("docm", Docx),
        ("dotm", Docx),
        ("pptx", Pptx),
        ("potx", Pptx),
        ("ppsx", Pptx),
        ("pptm", Pptx),
        ("potm", Pptx),
        ("ppsm", Pptx),
        ("pdf", Pdf),
        // `markdown`: docling#4306.
        ("md", Md),
        ("markdown", Md),
        ("txt", Md),
        ("text", Md),
        ("qmd", Md),
        ("rmd", Md),
        ("html", Html),
        ("htm", Html),
        ("xhtml", Html),
        ("xml", XmlJats),
        ("nxml", XmlJats),
        ("dclg", XmlDoclang),
        ("doctags", DocTags),
        ("dt", DocTags),
        ("dclx", Dclx),
        // `.gif` decodes through the same content-sniffing `image` path as
        // the rest (first frame of an animation), issue #208. `.heic`/`.heif`
        // decode behind the opt-in `heif` cargo feature (#211).
        ("jpg", Image),
        ("jpeg", Image),
        ("png", Image),
        ("tif", Image),
        ("tiff", Image),
        ("bmp", Image),
        ("webp", Image),
        ("gif", Image),
        ("heic", Image),
        ("heif", Image),
        ("adoc", Asciidoc),
        ("asciidoc", Asciidoc),
        ("asc", Asciidoc),
        // `.tsv` rides the CSV backend, whose delimiter sniffing already
        // prefers the tab when it dominates the first line (#208).
        ("csv", Csv),
        ("tsv", Csv),
        // `.xlsb` (binary Excel 2007+) parses through the same calamine
        // engine as xlsx — the backend detects the binary workbook part and
        // switches readers, issue #210. Excel templates (docling#4178, 2.126):
        // the same OOXML package under the template content type.
        ("xlsx", Xlsx),
        ("xlsm", Xlsx),
        ("xlsb", Xlsx),
        ("xltx", Xlsx),
        ("xltm", Xlsx),
        // Legacy binary Office (Word/Excel/PowerPoint 97–2003), issue #127.
        // Extension sets mirror docling's FormatToExtensions.
        ("doc", Doc),
        ("dot", Doc),
        ("xls", Xls),
        ("xlt", Xls),
        ("ppt", Ppt),
        ("pot", Ppt),
        ("pps", Ppt),
        // StarOffice / OpenOffice 1.x XML and flat ODF (#215, docling.rs
        // extensions): the shared ODF backend parses the older namespace
        // vocabulary through a local-name mapping layer, and the flat
        // variants are the same XML uncompressed in a single file. Templates
        // (`.stw`/`.sti`/`.stc`) and the Writer master document (`.sxg`) ride
        // the same parsers as their document counterparts.
        ("odt", Odt),
        ("ott", Odt),
        ("sxw", Odt),
        ("stw", Odt),
        ("sxg", Odt),
        ("fodt", Odt),
        ("ods", Ods),
        ("ots", Ods),
        ("sxc", Ods),
        ("stc", Ods),
        ("fods", Ods),
        ("odp", Odp),
        ("otp", Odp),
        ("sxi", Odp),
        ("sti", Odp),
        ("fodp", Odp),
        ("json", JsonDocling),
        // `.mpga` *is* MPEG audio (an mp3 stream) — symphonia probes the
        // codec from the bytes, the extension is just the alias (#208).
        ("wav", Audio),
        ("mp3", Audio),
        ("mpga", Audio),
        ("m4a", Audio),
        ("aac", Audio),
        ("ogg", Audio),
        ("flac", Audio),
        // Upstream's FormatToExtensions[VIDEO] (docling v2.114, #3768): the
        // audio track transcribes through the same ASR path. `.mpeg`/`.mpg`
        // (#208): MPEG-PS has no symphonia demuxer, so both the audio track
        // and the sampled frames come from the ffmpeg fallback; an audio-only
        // `.mpeg` still decodes in-process (the probe is content-based) and
        // converts to its transcript.
        ("mp4", Video),
        ("avi", Video),
        ("mov", Video),
        ("mkv", Video),
        ("webm", Video),
        ("mpeg", Video),
        ("mpg", Video),
        ("vtt", Vtt),
        ("tex", Latex),
        ("latex", Latex),
        ("eml", Email),
        // Outlook .msg (#251): a CFB container of MAPI streams; the email
        // backend sniffs the magic and projects it onto RFC 822.
        ("msg", Email),
        ("ebc", Ebcdic),
        ("ebcdic", Ebcdic),
        ("epub", Epub),
        ("mhtml", Mhtml),
        ("mht", Mhtml),
        ("rtf", Rtf),
        ("vsdx", Visio),
        ("vsdm", Visio),
        ("svg", Svg),
        // Apple iWork (#213).
        ("pages", Pages),
        ("numbers", Numbers),
        ("key", Keynote),
        // AbiWord (#216): AWML XML; .zabw is the same file gzip-wrapped, .awt
        // the template flavor.
        ("abw", Abiword),
        ("zabw", Abiword),
        ("awt", Abiword),
        // WordPerfect (#216): `.wp` was the DOS-era default (WP 5.x), `.wpd`
        // the Windows one; `.wpt` is the template flavor. The backend reads
        // the version from the prefix header, not the extension.
        ("wpd", WordPerfect),
        ("wp", WordPerfect),
        ("wp5", WordPerfect),
        ("wp6", WordPerfect),
        ("wpt", WordPerfect),
        // Microsoft Works word processor (#216): the backend tells the
        // generations apart by the stream (raw 2.x header, OLE MN0 for 3/4,
        // OLE CONTENTS for 2000+); .wks/.wdb are the spreadsheet and database
        // and route elsewhere.
        ("wps", Works),
        // Legacy spreadsheet-interchange relics (#216): all three parse
        // natively and content-sniff inside one backend.
        ("dbf", Dbf),
        ("dif", Dif),
        ("slk", Sylk),
        ("sylk", Sylk),
        // The Lotus family (#216): .wks is ambiguous (1-2-3 rel 1A and MS
        // Works v3 both used it) — the backend sniffs the BOF.
        ("wk1", Lotus),
        ("wk2", Lotus),
        ("wk3", Lotus),
        ("wk4", Lotus),
        ("wks", Lotus),
        ("wrk", Lotus),
        ("123", Lotus),
        // Quattro Pro (#216): the cell records differ per generation, so the
        // family has its own reader (the backend sniffs the BOF / OLE stream,
        // not the extension).
        ("wq1", QuattroPro),
        ("wq2", QuattroPro),
        ("wb1", QuattroPro),
        ("wb2", QuattroPro),
        ("wb3", QuattroPro),
        ("qpw", QuattroPro),
        // MS Works 6–9 spreadsheet (#216): a BIFF8 `Workbook` stream in an OLE
        // container — Excel 97's own layout under another extension, so the
        // XLS reader takes it.
        ("xlr", Xls),
        // StarOffice 5 binaries (#215): .vor templates dispatch by the CFB
        // stream inside (writer/draw/impress share the container). .sdc
        // routes here too so StarCalc gets its targeted "save as .ods" error
        // instead of an unknown-extension one.
        ("sdw", StarOffice5),
        ("sda", StarOffice5),
        ("sdd", StarOffice5),
        ("sdc", StarOffice5),
        ("vor", StarOffice5),
        // DjVu (#434): docling.rs extension, pure-Rust decode via `djvu-rs`.
        ("djvu", Djvu),
        ("djv", Djvu),
        // METS/Google Books scan packages ship as `*.tar.gz`.
        ("gz", MetsGbs),
        ("targz", MetsGbs),
    ]
};

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn audio_and_video_extensions_split_like_upstream() {
        // docling v2.114 FormatToExtensions: AUDIO and VIDEO are disjoint
        // (docling.rs adds the MPEG aliases on top, #208).
        for ext in ["wav", "mp3", "mpga", "m4a", "aac", "ogg", "flac"] {
            assert_eq!(InputFormat::from_extension(ext), Some(InputFormat::Audio));
        }
        for ext in ["mp4", "avi", "mov", "mkv", "webm", "MKV", "mpeg", "mpg"] {
            assert_eq!(InputFormat::from_extension(ext), Some(InputFormat::Video));
        }
        assert_eq!(InputFormat::Video.as_str(), "video");
    }

    #[test]
    fn extension_aliases_route_to_existing_backends() {
        // #208/#210: aliases whose decoding machinery predated the mapping.
        assert_eq!(InputFormat::from_extension("tsv"), Some(InputFormat::Csv));
        assert_eq!(InputFormat::from_extension("gif"), Some(InputFormat::Image));
        assert_eq!(InputFormat::from_extension("xlsb"), Some(InputFormat::Xlsx));
    }

    /// #603: the table is the lookup's single source — each extension once,
    /// stored lowercase, found again case-insensitively.
    #[test]
    fn extension_table_round_trips() {
        let mut seen = std::collections::HashSet::new();
        for &(ext, format) in EXTENSIONS {
            assert!(seen.insert(ext), "duplicate extension {ext}");
            assert_eq!(ext, ext.to_ascii_lowercase(), "{ext} must be lowercase");
            assert!(!ext.starts_with('.'), "{ext} must carry no dot");
            assert_eq!(InputFormat::from_extension(ext), Some(format));
            assert_eq!(
                InputFormat::from_extension(&ext.to_ascii_uppercase()),
                Some(format)
            );
            assert!(format.extensions().any(|e| e == ext));
        }
        assert_eq!(InputFormat::from_extension("unknown"), None);
        assert_eq!(InputFormat::from_extension(""), None);
    }

    /// #603: `--list-input-formats` — sorted, unique, and exactly the
    /// extensions this build converts.
    #[test]
    fn supported_extensions_follow_the_build() {
        let list = InputFormat::supported_extensions();
        let mut sorted = list.clone();
        sorted.sort_unstable();
        sorted.dedup();
        assert_eq!(list, sorted, "sorted and unique");
        for ext in [
            "docx", "rtf", "html", "xlsx", "pptx", "md", "epub", "eml", "msg",
        ] {
            assert!(list.contains(&ext), "{ext} converts in every build");
        }
        for &ext in &list {
            let format = InputFormat::from_extension(ext).expect("listed means routed");
            assert!(format.is_compiled_in(), "{ext} listed but not compiled in");
        }
        assert_eq!(
            list.contains(&"pdf"),
            cfg!(any(feature = "pdf", feature = "pdf-text"))
        );
        assert_eq!(list.contains(&"png"), cfg!(feature = "pdf"));
        assert_eq!(list.contains(&"mp3"), cfg!(feature = "asr"));
        assert_eq!(list.contains(&"mp4"), cfg!(feature = "asr"));
        assert_eq!(list.contains(&"heic"), cfg!(feature = "heif"));
        // Routed for its error message only (StarCalc is not converted).
        assert!(!list.contains(&"sdc"));
    }

    #[test]
    fn output_formats_are_unique() {
        let mut seen = std::collections::HashSet::new();
        assert!(OUTPUT_FORMATS.iter().all(|f| seen.insert(*f)));
        assert!(OUTPUT_FORMATS.contains(&"md") && OUTPUT_FORMATS.contains(&"pandoc"));
    }
}