mail_parser/decoders/html/
mod.rs1mod entities;
10mod meta;
11mod text;
12
13use std::char::REPLACEMENT_CHARACTER;
14
15use memchr::{memchr_iter, memchr2, memchr3};
16
17pub use meta::strip_charset_meta;
18pub(crate) use text::html_to_text_prefix;
19
20const HTML_PREFIX: &str = "<html><body>";
21const HTML_SUFFIX: &str = "</body></html>";
22const LINE_BREAK: &str = "<br/>";
23
24pub fn html_to_text(input: &str) -> String {
28 text::html_to_text(input)
29}
30
31pub fn add_html_token(result: &mut String, token: &[u8], add_space: bool) {
35 let token = String::from_utf8_lossy(token);
36 if add_space {
37 result.push(' ');
38 }
39 match decode_entity(&token) {
40 Some(ch) => result.push(ch),
41 None => result.push_str(&token),
42 }
43}
44
45pub fn text_to_html(input: &str) -> String {
49 let bytes = input.as_bytes();
50 let line_breaks = memchr_iter(b'\n', bytes).count();
51 let mut result = String::with_capacity(
52 bytes.len() + line_breaks * (LINE_BREAK.len() - 1) + HTML_PREFIX.len() + HTML_SUFFIX.len(),
53 );
54 result.push_str(HTML_PREFIX);
55 let find_common = |from: usize| find(bytes, from, |rest| memchr3(b'\n', b'\r', b'<', rest));
56 let find_rare = |from: usize| find(bytes, from, |rest| memchr2(b'>', b'&', rest));
57 let mut next_common = find_common(0);
58 let mut next_rare = find_rare(0);
59 let mut literal_start = 0;
60 while let Some(special) = earliest(next_common, next_rare) {
61 result.push_str(input.get(literal_start..special).unwrap_or_default());
62 result.push_str(match bytes.get(special) {
63 Some(b'\n') => LINE_BREAK,
64 Some(b'<') => "<",
65 Some(b'>') => ">",
66 Some(b'&') => "&",
67 _ => "",
68 });
69 literal_start = special + 1;
70 if next_common == Some(special) {
71 next_common = find_common(literal_start);
72 }
73 if next_rare == Some(special) {
74 next_rare = find_rare(literal_start);
75 }
76 }
77 result.push_str(input.get(literal_start..).unwrap_or_default());
78 result.push_str(HTML_SUFFIX);
79 result
80}
81
82fn find(bytes: &[u8], from: usize, search: impl Fn(&[u8]) -> Option<usize>) -> Option<usize> {
83 bytes
84 .get(from..)
85 .and_then(search)
86 .map(|offset| from + offset)
87}
88
89fn earliest(first: Option<usize>, second: Option<usize>) -> Option<usize> {
90 match (first, second) {
91 (Some(first), Some(second)) => Some(first.min(second)),
92 (first, second) => first.or(second),
93 }
94}
95
96fn decode_entity(token: &str) -> Option<char> {
97 let entity = token.strip_prefix('&')?.strip_suffix(';')?;
98 let code = match entity.strip_prefix('#') {
99 Some(number) => match number.strip_prefix(['x', 'X']) {
100 Some(hex) => u32::from_str_radix(hex, 16).ok()?,
101 None => number.parse::<u32>().ok()?,
102 },
103 None => entities::lookup(entity.as_bytes()).copied()?,
104 };
105 Some(char::from_u32(code).unwrap_or(REPLACEMENT_CHARACTER))
106}
107
108#[cfg(test)]
109mod tests {
110 use super::{add_html_token, html_to_text, strip_charset_meta, text_to_html};
111 use crate::fields::tests::load_text_pairs;
112 use std::borrow::Cow;
113
114 #[test]
115 fn text_to_html_fixtures() {
116 let tests = load_text_pairs("decoders/text_to_html.json");
117 assert_eq!(tests.len(), 3);
118 for (input, expected) in tests {
119 assert_eq!(text_to_html(&input), expected, "{input:?}");
120 }
121 }
122
123 #[test]
124 fn html_to_text_fixtures() {
125 let tests = load_text_pairs("decoders/html_to_text.json");
126 assert_eq!(tests.len(), 24);
127 for (input, expected) in tests {
128 assert_eq!(html_to_text(&input), expected, "{input:?}");
129 }
130 }
131
132 #[test]
133 fn html_entity_fixtures() {
134 let tests = load_text_pairs("decoders/html_entities.json");
135 assert_eq!(tests.len(), 9);
136 for (input, expected) in tests {
137 let mut result = String::with_capacity(input.len());
138 add_html_token(&mut result, input.as_bytes(), false);
139 assert_eq!(result, expected, "{input:?}");
140 }
141 }
142
143 #[test]
144 fn charset_meta_fixtures() {
145 let tests = load_text_pairs("decoders/charset_meta.json");
146 assert_eq!(tests.len(), 7);
147 for (input, expected) in tests {
148 let stripped = strip_charset_meta(&input);
149 assert_eq!(
150 matches!(stripped, Cow::Borrowed(_)),
151 input == expected,
152 "{input:?}"
153 );
154 assert_eq!(stripped, expected, "{input:?}");
155 }
156 }
157}