1use 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 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 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
58fn 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 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 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 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}