1use html2text::render::{RichAnnotation, TaggedLineElement};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum LinkStyle {
9 Osc8,
11 Inline,
13 Plain,
15}
16
17pub fn strip_controls(text: &str) -> String {
24 if !text.chars().any(is_hostile_control) {
25 return text.to_string();
26 }
27 text.chars().filter(|&c| !is_hostile_control(c)).collect()
28}
29
30fn is_hostile_control(c: char) -> bool {
31 c.is_control() && !matches!(c, '\n' | '\r' | '\t')
32}
33
34pub fn render_html(html: &str, width: usize, links: LinkStyle) -> String {
46 let lines = match html2text::config::rich()
47 .raw_mode(true)
48 .lines_from_read(html.as_bytes(), width)
49 {
50 Ok(l) => l,
51 Err(_) => return html.to_string(),
52 };
53 let mut out = String::new();
54 for line in lines {
55 for elem in line.iter() {
56 let TaggedLineElement::Str(ts) = elem else {
57 continue;
58 };
59 let mut url: Option<&str> = None;
60 let mut is_image = false;
61 for ann in &ts.tag {
62 match ann {
63 RichAnnotation::Image(_) => is_image = true,
64 RichAnnotation::Link(u) => url = Some(u.as_str()),
65 _ => {}
66 }
67 }
68 if is_image {
69 continue;
70 }
71 let text = strip_controls(&ts.s).replace('\u{00A0}', " ");
72 let url = url.map(strip_controls);
73 match (url.as_deref(), links) {
74 (Some(u), LinkStyle::Osc8) => {
75 out.push_str("\x1b]8;;");
76 out.push_str(u);
77 out.push_str("\x1b\\");
78 out.push_str(&text);
79 out.push_str("\x1b]8;;\x1b\\");
80 }
81 (Some(u), LinkStyle::Inline) => {
82 out.push_str(&text);
83 out.push_str(" (");
84 out.push_str(u);
85 out.push(')');
86 }
87 _ => out.push_str(&text),
88 }
89 }
90 out.push('\n');
91 }
92 collapse_blank_runs(&out)
93}
94
95pub fn collapse_blank_runs(text: &str) -> String {
100 let mut out = String::with_capacity(text.len());
101 let mut blank_streak = 0;
102 for line in text.lines() {
103 let cleaned: String = line
104 .chars()
105 .filter(|&c| !matches!(c, '\u{200C}' | '\u{200B}' | '\u{200A}' | '\u{034F}'))
106 .collect();
107 let cleaned = cleaned.trim_end_matches(' ');
108 if cleaned.is_empty() {
109 blank_streak += 1;
110 if blank_streak <= 2 {
111 out.push('\n');
112 }
113 } else {
114 blank_streak = 0;
115 out.push_str(cleaned);
116 out.push('\n');
117 }
118 }
119 out
120}
121
122pub fn strip_quoted_history(text: &str) -> String {
126 let lines: Vec<&str> = text.lines().collect();
127 let mut cut = lines.len();
128 for (i, line) in lines.iter().enumerate() {
129 let t = line.trim_start();
130 let is_outlook_header = t.starts_with("From:")
131 && lines[i..(i + 4).min(lines.len())].iter().any(|l| {
132 l.trim_start().starts_with("Sent:") || l.trim_start().starts_with("Subject:")
133 });
134 let is_wrote = t.starts_with("On ") && t.trim_end().ends_with("wrote:");
135 let is_quote = t.starts_with('>');
136 if is_outlook_header || is_wrote || is_quote {
137 cut = i;
138 break;
139 }
140 }
141 lines[..cut].join("\n").trim_end().to_string()
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 #[test]
149 fn strip_controls_drops_escapes_and_c1_but_keeps_line_structure() {
150 assert_eq!(
151 strip_controls("a\x1b]8;;https://evil.test\x1b\\b\n\tc\r\n\u{85}\u{7f}d"),
152 "a]8;;https://evil.test\\b\n\tc\r\nd"
153 );
154 assert_eq!(strip_controls("plain åäö"), "plain åäö");
155 }
156
157 #[test]
158 fn render_html_strips_control_characters_including_decoded_entities() {
159 let html = r#"<p>Hi ]8;;https://evil.test\see]8;;\ <a href="https://ok.test/x">link</a></p>"#;
162 for style in [LinkStyle::Plain, LinkStyle::Inline] {
163 let out = render_html(html, 80, style);
164 assert!(!out.contains('\x1b'), "{out:?}");
165 }
166 let out = render_html(html, 80, LinkStyle::Osc8);
167 assert_eq!(
168 out.matches("\x1b]8;;").count(),
169 2,
170 "only pidge's own link escapes: {out:?}"
171 );
172 assert!(out.contains("\x1b]8;;https://ok.test/x\x1b\\"), "{out:?}");
173 }
174
175 #[test]
176 fn inline_links_render_as_text_and_url() {
177 let out = render_html(
178 r#"<p>See <a href="https://x.test/a">the page</a>.</p>"#,
179 80,
180 LinkStyle::Inline,
181 );
182 assert_eq!(out.trim(), "See the page (https://x.test/a).");
183 }
184
185 #[test]
186 fn plain_links_render_text_only() {
187 let out = render_html(
188 r#"<p>See <a href="https://x.test/a">the page</a>.</p>"#,
189 80,
190 LinkStyle::Plain,
191 );
192 assert_eq!(out.trim(), "See the page.");
193 }
194
195 #[test]
196 fn osc8_links_wrap_text_in_escape() {
197 let out = render_html(r#"<a href="https://x.test/a">go</a>"#, 80, LinkStyle::Osc8);
198 assert!(out.contains("\x1b]8;;https://x.test/a\x1b\\"));
199 assert!(out.contains("go"));
200 }
201
202 #[test]
203 fn images_are_suppressed_and_blank_runs_collapse() {
204 let out = render_html(
205 "<p>a</p><img alt=\"Logo\"><br><br><br><br><p>b</p>",
206 80,
207 LinkStyle::Plain,
208 );
209 assert!(!out.contains("Logo"));
210 assert!(!out.contains("\n\n\n\n"));
211 }
212
213 #[test]
214 fn strips_outlook_style_quoted_history() {
215 let text = "Thanks, sounds good.\n\nFrom: Jane <jane@example.com>\nSent: Monday\nSubject: Re: x\n\nEarlier text";
216 assert_eq!(strip_quoted_history(text), "Thanks, sounds good.");
217 }
218
219 #[test]
220 fn strips_on_wrote_and_angle_quotes() {
221 let text = "Yes.\n\nOn Mon, Jan 1, Jane wrote:\n> old\n> older";
222 assert_eq!(strip_quoted_history(text), "Yes.");
223 let text2 = "Yes.\n> old\n> older";
224 assert_eq!(strip_quoted_history(text2), "Yes.");
225 }
226
227 #[test]
228 fn keeps_text_without_quotes() {
229 assert_eq!(strip_quoted_history("Hello\nworld"), "Hello\nworld");
230 }
231}