Skip to main content

mail_parser/decoders/
preview.rs

1/*
2 * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
3 *
4 * SPDX-License-Identifier: Apache-2.0 OR MIT
5 */
6
7//! Previews and truncation of text and HTML bodies.
8
9use std::borrow::Cow;
10
11use memchr::memchr;
12
13use super::html::html_to_text_prefix;
14
15const ELLIPSIS: &str = "...";
16const MIN_LEN_WITH_ELLIPSIS: usize = 7;
17const COMMENT_OPEN: &[u8] = b"!--";
18const COMMENT_CLOSE: &[u8] = b"--";
19const LOOKAHEAD: usize = 4;
20const PREVIEW_SLACK: usize = 64;
21
22/// At most `max_len` bytes of `text`, carriage returns removed. Longer
23/// text is cut at a character boundary and, when `max_len` is 7 or more,
24/// ends with `...` (counted in `max_len`). Borrowed when the text fits and
25/// has no carriage return.
26///
27/// ```
28/// use mail_parser::preview_text;
29///
30/// assert_eq!(preview_text("Hello,\r\nworld", 20), "Hello,\nworld");
31/// assert_eq!(preview_text("Hello, world", 8), "Hello...");
32/// ```
33pub fn preview_text(text: &str, max_len: usize) -> Cow<'_, str> {
34    match strip_cr_prefix(text, max_len.saturating_add(1)) {
35        Stripped::Complete(text) if text.len() <= max_len => text,
36        Stripped::Complete(text) | Stripped::Prefix(text) => Cow::Owned(match text {
37            Cow::Borrowed(text) => truncate_chars(text, max_len),
38            Cow::Owned(text) => truncate_owned(text, max_len),
39        }),
40    }
41}
42
43/// A text preview of an HTML document: [`crate::html_to_text`] of the
44/// document cut like [`preview_text`]. Only the part of the document that
45/// the preview needs is converted.
46pub fn preview_html(html: &str, max_len: usize) -> String {
47    preview_html_prefix(html, max_len, true).unwrap_or_default()
48}
49
50pub(crate) fn preview_html_prefix(html: &str, max_len: usize, complete: bool) -> Option<String> {
51    let mut text = String::with_capacity(max_len.saturating_add(PREVIEW_SLACK).min(html.len()));
52    if !html_to_text_prefix(html, max_len, &mut text) && !complete {
53        return None;
54    }
55    if text.len() > max_len {
56        let (keep, ellipsis) = cut(max_len);
57        text.truncate(text.floor_char_boundary(keep));
58        text.extend(ellipsis);
59    }
60    Some(text)
61}
62
63/// Truncates text for display; the same as [`preview_text`].
64pub fn truncate_text(text: &str, max_len: usize) -> Cow<'_, str> {
65    preview_text(text, max_len)
66}
67
68/// At most `max_len` bytes of an HTML document, carriage returns removed.
69/// A longer document is cut before a tag rather than inside one and, when
70/// `max_len` is 7 or more, ends with `...`. Borrowed when the document fits
71/// and has no carriage return.
72pub fn truncate_html(html: &str, max_len: usize) -> Cow<'_, str> {
73    match strip_cr_prefix(html, max_len.saturating_add(LOOKAHEAD)) {
74        Stripped::Complete(html) if html.len() <= max_len => html,
75        Stripped::Complete(html) | Stripped::Prefix(html) => {
76            Cow::Owned(truncate_markup(&html, max_len))
77        }
78    }
79}
80
81enum Stripped<'x> {
82    Complete(Cow<'x, str>),
83    Prefix(Cow<'x, str>),
84}
85
86fn strip_cr_prefix(text: &str, needed: usize) -> Stripped<'_> {
87    let window = text.as_bytes().get(..needed).unwrap_or(text.as_bytes());
88    if memchr(b'\r', window).is_none() {
89        return if text.len() < needed {
90            Stripped::Complete(Cow::Borrowed(text))
91        } else {
92            Stripped::Prefix(Cow::Borrowed(text))
93        };
94    }
95    let mut stripped = String::with_capacity(needed.min(text.len()));
96    let mut rest = text;
97    while stripped.len() < needed {
98        let missing = needed - stripped.len();
99        let window = rest.as_bytes().get(..missing).unwrap_or(rest.as_bytes());
100        match memchr(b'\r', window) {
101            Some(cr) => {
102                let (segment, tail) = rest.split_at(cr);
103                stripped.push_str(segment);
104                rest = tail.get(1..).unwrap_or_default();
105            }
106            None if rest.len() <= missing => {
107                stripped.push_str(rest);
108                return Stripped::Complete(Cow::Owned(stripped));
109            }
110            None => {
111                stripped.push_str(
112                    rest.get(..rest.ceil_char_boundary(missing))
113                        .unwrap_or_default(),
114                );
115                return Stripped::Prefix(Cow::Owned(stripped));
116            }
117        }
118    }
119    Stripped::Prefix(Cow::Owned(stripped))
120}
121
122fn cut(max_len: usize) -> (usize, Option<&'static str>) {
123    if max_len >= MIN_LEN_WITH_ELLIPSIS {
124        (max_len - ELLIPSIS.len(), Some(ELLIPSIS))
125    } else {
126        (max_len, None)
127    }
128}
129
130fn truncate_chars(text: &str, max_len: usize) -> String {
131    let (keep, ellipsis) = cut(max_len);
132    let kept = text
133        .get(..text.floor_char_boundary(keep))
134        .unwrap_or_default();
135    let mut result = String::with_capacity(kept.len() + ELLIPSIS.len());
136    result.push_str(kept);
137    result.extend(ellipsis);
138    result
139}
140
141fn truncate_owned(mut text: String, max_len: usize) -> String {
142    let (keep, ellipsis) = cut(max_len);
143    text.truncate(text.floor_char_boundary(keep));
144    text.extend(ellipsis);
145    text
146}
147
148fn truncate_markup(html: &str, max_len: usize) -> String {
149    let (keep, ellipsis) = cut(max_len);
150    let bytes = html.as_bytes();
151    let mut in_tag = false;
152    let mut in_comment = false;
153    let mut last_tag_end = 0;
154    let mut end = html.len();
155    for (pos, ch) in html.char_indices() {
156        let mut tag_boundary = 0;
157        match ch {
158            '<' if !in_tag => {
159                in_tag = true;
160                if bytes.get(pos + 1..pos + 1 + COMMENT_OPEN.len()) == Some(COMMENT_OPEN) {
161                    in_comment = true;
162                }
163                tag_boundary = pos;
164            }
165            '>' if in_tag => {
166                if !in_comment {
167                    in_tag = false;
168                    tag_boundary = pos + 1;
169                } else if pos
170                    .checked_sub(COMMENT_CLOSE.len())
171                    .and_then(|start| bytes.get(start..pos))
172                    == Some(COMMENT_CLOSE)
173                {
174                    in_comment = false;
175                    in_tag = false;
176                    tag_boundary = pos + 1;
177                }
178            }
179            _ => (),
180        }
181        if ch.len_utf8() + pos > keep {
182            end = if (in_tag || tag_boundary > 0) && last_tag_end > 0 {
183                last_tag_end
184            } else {
185                pos
186            };
187            break;
188        } else if tag_boundary > 0 {
189            last_tag_end = tag_boundary;
190        }
191    }
192    let kept = html.get(..end).unwrap_or_default();
193    let mut result = String::with_capacity(kept.len() + ELLIPSIS.len());
194    result.push_str(kept);
195    result.extend(ellipsis);
196    result
197}
198
199#[cfg(test)]
200mod tests {
201    use crate::fields::tests::{case_text, load_cases};
202    use serde_json::Value as Json;
203    use std::borrow::Cow;
204
205    fn truncations(name: &str) -> Vec<(String, usize, String)> {
206        load_cases(name)
207            .iter()
208            .map(|case| {
209                let max_len = case
210                    .get("max_len")
211                    .and_then(Json::as_u64)
212                    .and_then(|len| usize::try_from(len).ok())
213                    .unwrap_or_else(|| panic!("\"max_len\" is a length in {case}"));
214                (
215                    case_text(case, "input").to_string(),
216                    max_len,
217                    case_text(case, "expected").to_string(),
218                )
219            })
220            .collect()
221    }
222
223    #[test]
224    fn truncate_text_fixtures() {
225        let tests = truncations("decoders/truncate_text.json");
226        assert_eq!(tests.len(), 2);
227        for (input, max_len, expected) in tests {
228            assert_eq!(super::truncate_text(&input, max_len), expected, "{input:?}");
229        }
230    }
231
232    #[test]
233    fn truncate_html_fixtures() {
234        let tests = truncations("decoders/truncate_html.json");
235        assert_eq!(tests.len(), 9);
236        for (input, max_len, expected) in tests {
237            assert_eq!(super::truncate_html(&input, max_len), expected, "{input:?}");
238        }
239    }
240
241    #[test]
242    fn carriage_returns_are_stripped() {
243        assert_eq!(
244            super::preview_text("hello\r\nworld\r\n", 100),
245            "hello\nworld\n"
246        );
247        assert_eq!(super::preview_text("hello\r\nworld", 8), "hello...");
248        assert_eq!(super::preview_text("ab\r\rcd", 4), "abcd");
249        assert_eq!(super::preview_text("ab\r\rcde", 4), "abcd");
250        assert!(matches!(
251            super::preview_text("short", 10),
252            Cow::Borrowed("short")
253        ));
254        assert_eq!(
255            super::preview_html("<p>a&#13;b</p>\r\n<p>c</p>", 100),
256            "ab\n c\n"
257        );
258        assert_eq!(super::preview_html("<p>hello world</p>", 8), "hello...");
259        assert_eq!(
260            super::truncate_html("<p>\r\nhello</p>", 100),
261            "<p>\nhello</p>"
262        );
263    }
264}