Skip to main content

docling/backend/
mhtml.rs

1//! MHTML (`.mhtml`/`.mht`) backend — a **docling.rs extension**; docling has
2//! no MHTML backend to port.
3//!
4//! An MHTML archive is a MIME message ([RFC 2557], which `mail-parser`
5//! conforms to): a `multipart/related` structure whose root part is the saved
6//! page's `text/html`, with its resources (images, CSS, fonts) as sibling
7//! parts addressed by `Content-Location` (the resource's original URL) or
8//! `Content-ID` (referenced from the HTML as `cid:...`). This backend extracts
9//! the root HTML and hands it to the HTML backend for full Markdown
10//! extraction; `<img src>` references are resolved against the archive's own
11//! parts and embedded by default — unlike standalone HTML/EPUB image fetching
12//! (gated behind `fetch_images`), resolving here reads no filesystem/network,
13//! just the same MIME bytes already parsed, so there is no separate opt-in
14//! (matching how DOCX/PPTX embed their blobs by default).
15//!
16//! [RFC 2557]: https://datatracker.ietf.org/doc/html/rfc2557
17
18use std::collections::HashMap;
19
20use mail_parser::{MessageParser, MimeHeaders};
21
22use crate::backend::images::build_picture;
23use crate::backend::{convert_html, maybe_prerender_html, DeclarativeBackend, MapImageResolver};
24use crate::error::ConversionError;
25use crate::source::SourceDocument;
26use docling_core::{DoclingDocument, PictureImage};
27
28#[derive(Default)]
29pub struct MhtmlBackend {
30    /// Pre-render the extracted page HTML in a headless browser first (mirrors
31    /// [`crate::DocumentConverter::use_web_browser`]).
32    pub use_web_browser: bool,
33}
34
35impl DeclarativeBackend for MhtmlBackend {
36    fn convert(&self, source: &SourceDocument) -> Result<DoclingDocument, ConversionError> {
37        let msg = MessageParser::default()
38            .parse(&source.bytes)
39            .ok_or_else(|| ConversionError::Parse("mhtml: could not parse MIME message".into()))?;
40
41        // The saved page: the first (and normally only) `text/html` part. A
42        // resource-only archive with no HTML root yields an empty document,
43        // same as other backends' graceful handling of unusable input.
44        let Some(html) = msg.html_bodies().next().and_then(|p| p.text_contents()) else {
45            return Ok(DoclingDocument::new(&source.name));
46        };
47
48        let html = maybe_prerender_html(html, self.use_web_browser)?;
49        let images = collect_images(&msg);
50        Ok(convert_html(
51            &source.name,
52            &html,
53            &MapImageResolver::new(images),
54        ))
55    }
56}
57
58/// Every image sub-part, keyed by however the root HTML addresses it: its
59/// original URL (`Content-Location`, matching a rewritten `<img src>` verbatim)
60/// and/or its `cid:<Content-ID>` form.
61fn collect_images(msg: &mail_parser::Message) -> HashMap<String, PictureImage> {
62    let mut images = HashMap::new();
63    for part in &msg.parts {
64        if part.is_multipart() || part.is_message() {
65            continue;
66        }
67        let Some(ct) = part.content_type() else {
68            continue;
69        };
70        let Some(subtype) = ct.subtype() else {
71            continue;
72        };
73        if !ct.ctype().eq_ignore_ascii_case("image") {
74            continue;
75        }
76        let mimetype = format!("{}/{}", ct.ctype(), subtype);
77        let Some(pic) = build_picture(mimetype, part.contents().to_vec()) else {
78            // Vector/unsupported-by-`image` formats (e.g. `image/svg+xml`)
79            // have no decodable raster dimensions; leave the reference
80            // unresolved rather than embed a dimensionless image.
81            continue;
82        };
83        if let Some(loc) = part.content_location() {
84            images.insert(loc.to_string(), pic.clone());
85        }
86        if let Some(id) = part.content_id() {
87            images.insert(format!("cid:{}", id.trim_matches(['<', '>'])), pic);
88        }
89    }
90    images
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crate::format::InputFormat;
97    use docling_core::Node;
98
99    const RED_PNG: &[u8] = &[
100        0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
101        0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90,
102        0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8,
103        0xcf, 0xc0, 0x00, 0x00, 0x00, 0x03, 0x00, 0x01, 0x6e, 0x2c, 0xdc, 0x33, 0x00, 0x00, 0x00,
104        0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
105    ];
106
107    fn mhtml(html_part: &str, extra_parts: &str) -> Vec<u8> {
108        format!(
109            "MIME-Version: 1.0\r\n\
110             Content-Type: multipart/related; boundary=\"B\"\r\n\r\n\
111             --B\r\nContent-Type: text/html\r\nContent-Location: https://example.com/\r\n\r\n\
112             {html_part}\r\n\
113             {extra_parts}--B--\r\n"
114        )
115        .into_bytes()
116    }
117
118    #[test]
119    fn extracts_heading_and_paragraph_from_root_html() {
120        let bytes = mhtml(
121            "<html><body><h1>Title</h1><p>Body text.</p></body></html>",
122            "",
123        );
124        let src = SourceDocument::from_bytes("p", InputFormat::Mhtml, bytes);
125        let md = MhtmlBackend::default()
126            .convert(&src)
127            .unwrap()
128            .export_to_markdown();
129        assert_eq!(md.trim(), "# Title\n\nBody text.");
130    }
131
132    #[test]
133    fn resolves_image_by_content_location() {
134        let png_b64 = docling_core::base64::encode(RED_PNG);
135        let extra = format!(
136            "--B\r\nContent-Type: image/png\r\nContent-Location: https://example.com/pic.png\r\n\
137             Content-Transfer-Encoding: base64\r\n\r\n{png_b64}\r\n"
138        );
139        let bytes = mhtml(
140            r#"<html><body><img src="https://example.com/pic.png"></body></html>"#,
141            &extra,
142        );
143        let src = SourceDocument::from_bytes("p", InputFormat::Mhtml, bytes);
144        let doc = MhtmlBackend::default().convert(&src).unwrap();
145        let img = doc.nodes.iter().find_map(|n| match n {
146            Node::Picture { image, .. } => image.as_ref(),
147            _ => None,
148        });
149        let img = img.expect("image resolved from the archive");
150        assert_eq!(img.mimetype, "image/png");
151        assert_eq!((img.width, img.height), (1, 1));
152        assert_eq!(img.data, RED_PNG);
153    }
154
155    #[test]
156    fn resolves_image_by_content_id_cid_reference() {
157        let png_b64 = docling_core::base64::encode(RED_PNG);
158        let extra = format!(
159            "--B\r\nContent-Type: image/png\r\nContent-ID: <img1@mhtml.blink>\r\n\
160             Content-Transfer-Encoding: base64\r\n\r\n{png_b64}\r\n"
161        );
162        let bytes = mhtml(
163            r#"<html><body><img src="cid:img1@mhtml.blink"></body></html>"#,
164            &extra,
165        );
166        let src = SourceDocument::from_bytes("p", InputFormat::Mhtml, bytes);
167        let doc = MhtmlBackend::default().convert(&src).unwrap();
168        let embedded = doc
169            .nodes
170            .iter()
171            .any(|n| matches!(n, Node::Picture { image: Some(_), .. }));
172        assert!(embedded, "cid: reference resolved");
173    }
174
175    #[test]
176    fn plain_text_only_falls_back_via_mail_parsers_html_conversion() {
177        // mail-parser synthesizes an HTML alternative from a lone text/plain
178        // part, so even a resource-only archive still yields readable text.
179        let bytes = b"MIME-Version: 1.0\r\nContent-Type: text/plain\r\n\r\nplain text\r\n".to_vec();
180        let src = SourceDocument::from_bytes("p", InputFormat::Mhtml, bytes);
181        let md = MhtmlBackend::default()
182            .convert(&src)
183            .unwrap()
184            .export_to_markdown();
185        assert_eq!(md.trim(), "plain text");
186    }
187
188    #[test]
189    fn unparseable_bytes_yield_empty_document() {
190        // Not a MIME message at all (no headers, no blank-line separator): the
191        // parser still returns a message (mail-parser is liberal), but with no
192        // html/text body to extract.
193        let src =
194            SourceDocument::from_bytes("p", InputFormat::Mhtml, b"not a mime message".to_vec());
195        let doc = MhtmlBackend::default().convert(&src).unwrap();
196        assert!(doc.nodes.is_empty());
197    }
198}