Skip to main content

pidge_core/
render.rs

1//! Text rendering shared by the CLI and the MCP server: HTML → text and
2//! quoted-history stripping. Pure functions, no terminal assumptions.
3
4use html2text::render::{RichAnnotation, TaggedLineElement};
5
6/// How `<a href>` spans are rendered.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum LinkStyle {
9    /// OSC 8 hyperlink escapes around the link text (terminals).
10    Osc8,
11    /// `text (url)`, for plain-text consumers such as an AI harness.
12    Inline,
13    /// Link text only.
14    Plain,
15}
16
17/// Removes control characters from third-party text before it can reach a
18/// terminal: everything `char::is_control` matches (C0, DEL, C1) except
19/// `\n`, `\r` and `\t`. An e-mail can otherwise carry `ESC ] 8 ; ;` to
20/// forge a hyperlink, move the cursor over earlier lines, retitle the
21/// window, or write the clipboard. Applied where Graph data becomes
22/// `pidge-core` types, and again on rendered HTML (entities decode late).
23pub 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
34/// Render an HTML body to text.
35///
36/// - Uses html2text's `raw_mode` which traverses HTML `<table>` elements as a
37///   sequence of paragraphs (every cell becomes its own row, no column layout,
38///   no ASCII borders). Marketing emails are almost entirely layout tables; this
39///   keeps the reading flow.
40/// - Suppresses `<img>` alt-text entirely (no `[[Logo]]` noise from email
41///   tracking pixels and logo images).
42/// - Folds NBSP to a plain space so NBSP-padded table cells collapse like
43///   ordinary blank runs.
44/// - Collapses runs of more than two blank lines down to two.
45pub 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
95/// Collapse runs of 3+ blank lines down to 2, and strip tracking-pixel
96/// padding characters that marketing emails use to distort preview-pane
97/// summaries: zero-width non-joiner (U+200C), zero-width space (U+200B),
98/// hair space (U+200A), and combining grapheme joiner (U+034F).
99pub 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
122/// Cut a plain-text body at the first sign of quoted history: an Outlook
123/// header block (`From:` followed within three lines by `Sent:`/`Subject:`),
124/// an `On … wrote:` line, or the first run of `>`-quoted lines.
125pub 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        // `&#27;` decodes to ESC only after parsing, so stripping at the
160        // Graph boundary would miss it.
161        let html = r#"<p>Hi &#27;]8;;https://evil.test&#27;\see&#27;]8;;&#27;\ <a href="https://ok.test/&#27;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}