Skip to main content

ljos_cli/mailbox/
html.rs

1//! HTML reduced to the text a mail client would show.
2//!
3//! Not a full parser: tags, comments and raw-text elements are skipped,
4//! entities decoded, and block elements become line breaks. Attribute
5//! values are entity-decoded before use, and CSS has its comments
6//! removed and its escapes decoded outside strings. As in HTML, `/>`
7//! closes only a void element or one inside `<svg>` or `<math>`; any
8//! other element stays open.
9//!
10//! Text the reducer can tell is concealed is dropped: inside an element
11//! with the `hidden` attribute, a class or id rule that conceals, or a
12//! style that sets `display:none`, `visibility:hidden` or `collapse`,
13//! `content-visibility:hidden`, `mso-hide:all`, a width or height of
14//! 1px or less, an offset or indent that is negative or 1000px or more,
15//! `clip`, `clip-path`, or any transform; under an opacity (a number or
16//! a percentage, multiplied down the tree) of 0.05 or less or a font
17//! size (resolved down the tree) of 1px or less; or drawn in a color
18//! whose alpha is 0.05 or less, or within 16 of its background in every
19//! channel (black text on white when nothing sets them). A value of one
20//! of those properties the reducer cannot read, or any `var()`, counts
21//! as concealing.
22//!
23//! Every `<style>` block, wherever it sits, is read before the body, and
24//! any block or concealment marks the part as hiding content. Rules
25//! inside `@media`, `@supports` and other block at-rules are read as if
26//! they apply. When the HTML does what the reducer cannot follow
27//! (`@import`, a concealing rule on any selector other than a class or
28//! id, a rule nested in a style rule or blocks nested more than 8 deep,
29//! a stylesheet color within 16 of a background set anywhere or, when
30//! its rule sets no background, of white, a stylesheet background within
31//! 16 of a color set anywhere or, when its rule sets no color, of black,
32//! a linked stylesheet, or a `style`, `class`, `id` or `hidden`
33//! attribute holding a reference the reducer cannot decode), the text is
34//! withheld rather than guessed. A link keeps its text and gains its
35//! target as plain text, so a mismatched link is visible.
36
37/// The visible text and what the HTML hid.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct Visible {
40    pub text: String,
41    /// A style block or concealment was present, so the part hides, or
42    /// may hide, text from a reader.
43    pub hidden_content: bool,
44    /// The reducer could not tell what is hidden; `text` must not be
45    /// shown.
46    pub withheld: bool,
47}
48
49const VOID: &[&str] = &[
50    "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track",
51    "wbr",
52];
53const RAW: &[&str] = &[
54    "script", "style", "title", "template", "noscript", "textarea",
55];
56const BLOCK: &[&str] = &[
57    "p",
58    "div",
59    "br",
60    "tr",
61    "li",
62    "ul",
63    "ol",
64    "table",
65    "blockquote",
66    "hr",
67    "h1",
68    "h2",
69    "h3",
70    "h4",
71    "h5",
72    "h6",
73    "pre",
74    "section",
75    "article",
76    "header",
77    "footer",
78];
79
80struct Open {
81    name: String,
82    hidden: bool,
83    href: Option<String>,
84    paint: Paint,
85}
86
87/// How the text inside an element is drawn, from it and its ancestors.
88#[derive(Clone)]
89struct Paint {
90    fg: Color,
91    bg: Color,
92    opacity: f32,
93    font_px: f32,
94}
95
96impl Default for Paint {
97    fn default() -> Self {
98        Self {
99            fg: BLACK,
100            bg: WHITE,
101            opacity: 1.0,
102            font_px: 16.0,
103        }
104    }
105}
106
107/// What one element's declarations and color attributes set; `None`
108/// for each part left to the parent.
109struct Own {
110    fg: Option<Color>,
111    bg: Option<Color>,
112    opacity: Option<f32>,
113    font_px: Option<f32>,
114}
115
116#[derive(Clone, Debug, PartialEq)]
117enum Color {
118    Rgb([u8; 3], f32),
119    /// A name the reducer has no value for, compared by name.
120    Named(String),
121}
122
123const BLACK: Color = Color::Rgb([0, 0, 0], 1.0);
124const WHITE: Color = Color::Rgb([255, 255, 255], 1.0);
125
126#[derive(Default)]
127struct HiddenRules {
128    classes: Vec<String>,
129    ids: Vec<String>,
130    colors: Vec<Color>,
131    backgrounds: Vec<Color>,
132    styled: bool,
133    withheld: bool,
134}
135
136/// The most of a part the reducer reads. A tag with no `>` is retried
137/// from the next byte, so a part of unterminated tags costs the square
138/// of its length; past the cap the rest is unread and reported hidden.
139const HTML_CAP: usize = 1024 * 1024;
140
141#[must_use]
142pub fn visible_text(html: &str) -> Visible {
143    let mut out = String::new();
144    let mut dropped = false;
145    let html = if html.len() > HTML_CAP {
146        dropped = true;
147        let mut end = HTML_CAP;
148        while !html.is_char_boundary(end) {
149            end -= 1;
150        }
151        &html[..end]
152    } else {
153        html
154    };
155    let mut concealed = false;
156    let mut stack: Vec<Open> = Vec::new();
157    let mut rules = HiddenRules::of(html);
158    let (mut seen_fg, mut seen_bg) = (Vec::new(), Vec::new());
159    let mut rest = html;
160    while !rest.is_empty() {
161        let Some(lt) = rest.find('<') else {
162            text(rest, &stack, &mut out, &mut dropped);
163            break;
164        };
165        text(&rest[..lt], &stack, &mut out, &mut dropped);
166        rest = &rest[lt..];
167        if let Some(after) = rest.strip_prefix("<!--") {
168            // `<!-->` and `<!--->` are whole comments in HTML5; reading on
169            // to the next `-->` would swallow the rest of the part.
170            rest = if let Some(tail) = after.strip_prefix('>') {
171                tail
172            } else if let Some(tail) = after.strip_prefix("->") {
173                tail
174            } else {
175                after.find("-->").map_or("", |end| &after[end + 3..])
176            };
177            continue;
178        }
179        let Some((tag, after)) = parse_tag(rest) else {
180            text("<", &stack, &mut out, &mut dropped);
181            rest = &rest[1..];
182            continue;
183        };
184        rest = after;
185        if tag.odd && !tag.closing {
186            rules.withheld = true;
187        }
188        if tag.closing {
189            if let Some(at) = stack.iter().rposition(|o| o.name == tag.name) {
190                for open in stack.drain(at..).rev() {
191                    if let Some(href) = open.href.filter(|_| !open.hidden) {
192                        out.push_str(&format!(" <{href}>"));
193                    }
194                }
195            }
196            if BLOCK.contains(&tag.name.as_str()) {
197                out.push('\n');
198            }
199            continue;
200        }
201        if BLOCK.contains(&tag.name.as_str()) {
202            out.push('\n');
203        }
204        if RAW.contains(&tag.name.as_str()) {
205            let close = format!("</{}", tag.name);
206            rest = &rest[find_ascii_ci(rest, &close).unwrap_or(rest.len())..];
207            continue;
208        }
209        if tag.name == "link"
210            && tag
211                .attr("rel")
212                .is_some_and(|r| r.to_ascii_lowercase().contains("stylesheet"))
213        {
214            rules.withheld = true;
215        }
216        let foreign = ["svg", "math"].contains(&tag.name.as_str())
217            || stack.iter().any(|o| o.name == "svg" || o.name == "math");
218        if VOID.contains(&tag.name.as_str()) || (tag.self_closing && foreign) {
219            continue;
220        }
221        let style = declarations(&css_text(tag.attr("style").unwrap_or("")));
222        let parent = stack
223            .last()
224            .map_or_else(Paint::default, |o| o.paint.clone());
225        let set = own(&style, Some(&tag), parent.font_px);
226        if let Some(set) = &set {
227            seen_fg.extend(set.fg.clone());
228            seen_bg.extend(set.bg.clone());
229        }
230        let paint = set.as_ref().map(|set| Paint {
231            fg: set.fg.clone().unwrap_or_else(|| parent.fg.clone()),
232            bg: set.bg.clone().unwrap_or_else(|| parent.bg.clone()),
233            opacity: parent.opacity * set.opacity.unwrap_or(1.0),
234            font_px: set.font_px.unwrap_or(parent.font_px),
235        });
236        let hidden = tag.attr("hidden").is_some()
237            || hides(&style)
238            || paint
239                .as_ref()
240                .is_none_or(|p| p.opacity <= 0.05 || p.font_px <= 1.0)
241            || tag
242                .attr("class")
243                .is_some_and(|c| c.split_whitespace().any(|c| rules.has_class(c)))
244            || tag.attr("id").is_some_and(|id| rules.has_id(id));
245        let href = (tag.name == "a")
246            .then(|| tag.attr("href").map(|h| h.trim().to_string()))
247            .flatten()
248            .filter(|h| !h.is_empty());
249        concealed |= hidden;
250        stack.push(Open {
251            name: tag.name,
252            hidden,
253            href,
254            paint: paint.unwrap_or(parent),
255        });
256    }
257    let clash = rules.colors.iter().any(|c| {
258        rules
259            .backgrounds
260            .iter()
261            .chain(&seen_bg)
262            .any(|b| close(c, b))
263    }) || rules
264        .backgrounds
265        .iter()
266        .any(|b| seen_fg.iter().any(|c| close(c, b)));
267    let withheld = rules.withheld || clash;
268    Visible {
269        text: tidy(&out),
270        hidden_content: dropped || concealed || rules.styled || withheld,
271        withheld,
272    }
273}
274
275fn text(raw: &str, stack: &[Open], out: &mut String, dropped: &mut bool) {
276    if raw.is_empty() {
277        return;
278    }
279    let decoded = decode(raw);
280    if stack.iter().any(|o| o.hidden) || stack.last().is_some_and(|o| invisible(&o.paint)) {
281        *dropped |= !decoded.trim().is_empty();
282        return;
283    }
284    out.push_str(&decoded);
285}
286
287/// CSS as a client reads it: comments removed and escapes such as `\3a`
288/// decoded, both outside strings.
289fn css_text(css: &str) -> String {
290    let mut out = String::with_capacity(css.len());
291    let mut chars = css.chars().peekable();
292    let mut quote = None;
293    while let Some(c) = chars.next() {
294        match (quote, c) {
295            (Some(q), _) => {
296                out.push(c);
297                if c == '\\' {
298                    out.extend(chars.next());
299                } else if c == q || matches!(c, '\n' | '\r' | '\x0c') {
300                    quote = None;
301                }
302            }
303            (None, '"' | '\'') => {
304                quote = Some(c);
305                out.push(c);
306            }
307            (None, '/') if chars.peek() == Some(&'*') => {
308                chars.next();
309                let mut star = false;
310                for c in chars.by_ref() {
311                    if star && c == '/' {
312                        break;
313                    }
314                    star = c == '*';
315                }
316            }
317            (None, '\\') => {
318                let mut hex = String::new();
319                while hex.len() < 6 && chars.peek().is_some_and(char::is_ascii_hexdigit) {
320                    hex.extend(chars.next());
321                }
322                if hex.is_empty() {
323                    out.push(chars.next().unwrap_or('\u{FFFD}'));
324                } else {
325                    chars.next_if(|c| c.is_whitespace());
326                    let code = u32::from_str_radix(&hex, 16).ok().filter(|&c| c != 0);
327                    out.push(code.and_then(char::from_u32).unwrap_or('\u{FFFD}'));
328                }
329            }
330            (None, _) => out.push(c),
331        }
332    }
333    out
334}
335
336/// `property: value` pairs in lower case, runs of whitespace as one
337/// space, `!important` removed.
338fn declarations(css: &str) -> Vec<(String, String)> {
339    css.split(';')
340        .filter_map(|d| d.split_once(':'))
341        .map(|(k, v)| {
342            let mut v = v.split_whitespace().collect::<Vec<_>>().join(" ");
343            if let Some(bang) = v.find('!') {
344                v.truncate(bang);
345            }
346            (k.trim().to_ascii_lowercase(), v.trim().to_ascii_lowercase())
347        })
348        .collect()
349}
350
351fn value<'a>(decls: &'a [(String, String)], name: &str) -> Option<&'a str> {
352    decls
353        .iter()
354        .rev()
355        .find(|(k, _)| k == name)
356        .map(|(_, v)| v.as_str())
357}
358
359/// A CSS number and the unit after it: `(-12.0, "em")` for `-12em`,
360/// `(0.001, "%")` for `1e-3%`.
361fn number(v: &str) -> Option<(f32, &str)> {
362    let mut end = v
363        .find(|c: char| !(c.is_ascii_digit() || matches!(c, '.' | '+' | '-')))
364        .unwrap_or(v.len());
365    if let Some(exp) = v[end..].strip_prefix(['e', 'E']) {
366        let digits = exp.trim_start_matches(['+', '-']);
367        let run = digits
368            .find(|c: char| !c.is_ascii_digit())
369            .unwrap_or(digits.len());
370        if run > 0 {
371            end += 1 + exp.len() - digits.len() + run;
372        }
373    }
374    let n: f32 = v[..end].parse().ok().filter(|n: &f32| n.is_finite())?;
375    Some((n, &v[end..]))
376}
377
378/// A length in px, `em` against `em` px and `%` against `percent` px.
379fn px(v: &str, em: f32, percent: f32) -> Option<f32> {
380    let (n, unit) = number(v)?;
381    let scale = match unit {
382        "" | "px" => 1.0,
383        "pt" => 4.0 / 3.0,
384        "pc" => 16.0,
385        "in" => 96.0,
386        "cm" => 96.0 / 2.54,
387        "mm" => 96.0 / 25.4,
388        "q" => 96.0 / 101.6,
389        "em" => em,
390        "rem" => 16.0,
391        "ex" | "ch" => em / 2.0,
392        "%" => percent,
393        "vw" | "vh" | "vmin" | "vmax" => 3.2,
394        _ => return None,
395    };
396    Some(n * scale)
397}
398
399/// A keyword that leaves the value to the parent or the default.
400fn inherits(v: &str) -> bool {
401    matches!(
402        v,
403        "inherit" | "initial" | "unset" | "revert" | "revert-layer" | "currentcolor"
404    )
405}
406
407/// Declarations that hide the element's text whatever its parent is.
408fn hides(decls: &[(String, String)]) -> bool {
409    decls.iter().any(|(k, v)| {
410        let keyword = inherits(v)
411            || matches!(
412                v.as_str(),
413                "auto" | "none" | "normal" | "fit-content" | "max-content" | "min-content"
414            );
415        let len = || px(v, 16.0, 6.0);
416        v.contains("var(")
417            || match k.as_str() {
418                "display" => v == "none",
419                "visibility" => v == "hidden" || v == "collapse",
420                "content-visibility" => v == "hidden",
421                "width" | "height" | "max-width" | "max-height" => {
422                    !keyword && len().is_none_or(|n| n <= 1.0)
423                }
424                "left" | "top" | "right" | "bottom" | "text-indent" | "margin-left"
425                | "margin-top" => !keyword && len().is_none_or(|n| !(0.0..1000.0).contains(&n)),
426                "clip" => v.starts_with("rect("),
427                "clip-path" => v != "none",
428                "transform" | "translate" | "scale" | "rotate" => v != "none",
429                "mso-hide" => v == "all",
430                _ => false,
431            }
432    })
433}
434
435/// What `decls` and, for an element, its color attributes set. `None`
436/// when a color, opacity or font size cannot be read.
437fn own(decls: &[(String, String)], tag: Option<&Tag>, parent_font: f32) -> Option<Own> {
438    let attr =
439        |element: &str, name: &str| tag.filter(|t| t.name == element).and_then(|t| t.attr(name));
440    let fg = value(decls, "-webkit-text-fill-color")
441        .filter(|v| !inherits(v))
442        .or_else(|| value(decls, "color"))
443        .or_else(|| attr("font", "color"))
444        .or_else(|| attr("body", "text"))
445        .filter(|v| !inherits(v));
446    let fg = match fg {
447        Some(v) => Some(color(v)?),
448        None => None,
449    };
450    let bg = decls
451        .iter()
452        .rev()
453        .find(|(k, _)| k == "background" || k == "background-color");
454    let bg = match bg {
455        Some((_, v)) => background(v)?,
456        None => tag.and_then(|t| t.attr("bgcolor")).and_then(color),
457    };
458    let opacity = match value(decls, "opacity").filter(|v| !inherits(v)) {
459        Some(v) => Some(match number(v)? {
460            (n, "") => n,
461            (n, "%") => n / 100.0,
462            _ => return None,
463        }),
464        None => None,
465    };
466    let size = value(decls, "font-size").or_else(|| value(decls, "font").and_then(font_size));
467    let font_px = match size {
468        Some(v) => Some(font_px(v, parent_font)?),
469        None => None,
470    };
471    Some(Own {
472        fg,
473        bg: bg.filter(|c| alpha(c) > 0.05),
474        opacity,
475        font_px,
476    })
477}
478
479/// The color a `background` value paints: `Some(None)` for none the
480/// reducer can see (an image, a gradient, `none`), `None` when a part
481/// cannot be read.
482fn background(v: &str) -> Option<Option<Color>> {
483    const WORDS: &str = "none repeat no-repeat repeat-x repeat-y space round scroll fixed local \
484        center top bottom left right cover contain auto border-box padding-box content-box text";
485    let mut paint = None;
486    for part in parts(v) {
487        if part.starts_with("url(")
488            || part.contains("gradient(")
489            || part.contains('/')
490            || number(part).is_some()
491            || WORDS.split(' ').any(|w| w == part)
492            || (inherits(part) && part != "currentcolor")
493        {
494            continue;
495        }
496        paint = Some(color(part).filter(|_| part != "currentcolor")?);
497    }
498    Some(paint)
499}
500
501/// The space-separated parts of a value, a function call kept whole.
502fn parts(v: &str) -> Vec<&str> {
503    let (mut out, mut depth, mut start) = (Vec::new(), 0usize, 0);
504    for (i, c) in v.char_indices() {
505        match c {
506            '(' => depth += 1,
507            ')' => depth = depth.saturating_sub(1),
508            ' ' if depth == 0 => {
509                out.extend((i > start).then(|| &v[start..i]));
510                start = i + 1;
511            }
512            _ => {}
513        }
514    }
515    out.extend((start < v.len()).then(|| &v[start..]));
516    out
517}
518
519/// The size in a `font` shorthand: the first part, before any `/`, that
520/// is a keyword size or a length with a unit or zero.
521fn font_size(v: &str) -> Option<&str> {
522    parts(v).into_iter().find_map(|part| {
523        let size = part.split('/').next().unwrap_or("");
524        let length = number(size).is_some_and(|(n, unit)| !unit.is_empty() || n == 0.0);
525        let keyword = number(size).is_none() && font_px(size, 16.0).is_some();
526        (length || keyword).then_some(size)
527    })
528}
529
530/// A `font-size` in px against the parent's.
531fn font_px(v: &str, parent: f32) -> Option<f32> {
532    Some(match v {
533        "xx-small" => 9.0,
534        "x-small" => 10.0,
535        "small" => 13.0,
536        "medium" => 16.0,
537        "large" => 18.0,
538        "x-large" => 24.0,
539        "xx-large" => 32.0,
540        "xxx-large" => 48.0,
541        "smaller" => parent / 1.2,
542        "larger" => parent * 1.2,
543        _ if inherits(v) || v == "math" => parent,
544        _ => px(v, parent, parent / 100.0)?,
545    })
546}
547
548fn alpha(c: &Color) -> f32 {
549    match c {
550        Color::Rgb(_, a) => *a,
551        Color::Named(_) => 1.0,
552    }
553}
554
555fn close(a: &Color, b: &Color) -> bool {
556    match (a, b) {
557        (Color::Rgb(x, _), Color::Rgb(y, _)) => x.iter().zip(y).all(|(p, q)| p.abs_diff(*q) <= 16),
558        (Color::Named(x), Color::Named(y)) => x == y,
559        _ => false,
560    }
561}
562
563/// Text in `paint` cannot be seen.
564fn invisible(paint: &Paint) -> bool {
565    alpha(&paint.fg) <= 0.05 || close(&paint.fg, &paint.bg)
566}
567
568/// A CSS color: hex with or without `#` and with an optional alpha,
569/// `rgb()` or `rgba()` with commas or spaces and an optional alpha,
570/// `transparent`, or a name. `None` for a function the reducer does not
571/// read, such as `hsl()` or `var()`.
572fn color(v: &str) -> Option<Color> {
573    let v = v.trim().to_ascii_lowercase();
574    let bare = matches!(v.len(), 3 | 6) && v.bytes().all(|b| b.is_ascii_hexdigit());
575    if let Some(h) = v.strip_prefix('#').or(bare.then_some(v.as_str())) {
576        let d: Vec<u8> = h
577            .chars()
578            .map(|c| c.to_digit(16).and_then(|d| u8::try_from(d).ok()))
579            .collect::<Option<_>>()?;
580        let ([r, g, b], a) = match d.as_slice() {
581            [r, g, b] => ([r * 17, g * 17, b * 17], 255),
582            [r, g, b, a] => ([r * 17, g * 17, b * 17], a * 17),
583            [r1, r2, g1, g2, b1, b2] => ([r1 * 16 + r2, g1 * 16 + g2, b1 * 16 + b2], 255),
584            [r1, r2, g1, g2, b1, b2, a1, a2] => {
585                ([r1 * 16 + r2, g1 * 16 + g2, b1 * 16 + b2], a1 * 16 + a2)
586            }
587            _ => return None,
588        };
589        return Some(Color::Rgb([r, g, b], f32::from(a) / 255.0));
590    }
591    if let Some(args) = v
592        .strip_prefix("rgba(")
593        .or_else(|| v.strip_prefix("rgb("))
594        .and_then(|a| a.strip_suffix(')'))
595    {
596        let fraction = |p: &str| match number(p)? {
597            (n, "") => Some(n),
598            (n, "%") => Some(n / 100.0),
599            _ => None,
600        };
601        let channel = |p: &str| match number(p)? {
602            (n, "") => Some(n.clamp(0.0, 255.0) as u8),
603            (n, "%") => Some((n * 2.55).clamp(0.0, 255.0) as u8),
604            _ => None,
605        };
606        let parts: Vec<&str> = args
607            .split(|c: char| c == ',' || c == '/' || c.is_whitespace())
608            .filter(|p| !p.is_empty())
609            .collect();
610        let (rgb, a) = match parts.as_slice() {
611            [r, g, b] => ([*r, *g, *b], 1.0),
612            [r, g, b, a] => ([*r, *g, *b], fraction(a)?),
613            _ => return None,
614        };
615        let [r, g, b] = rgb.map(channel);
616        return Some(Color::Rgb([r?, g?, b?], a.clamp(0.0, 1.0)));
617    }
618    if v.contains('(') {
619        return None;
620    }
621    let rgb = match v.as_str() {
622        "transparent" => return Some(Color::Rgb([0, 0, 0], 0.0)),
623        "white" | "canvas" | "window" => [255, 255, 255],
624        "black" | "canvastext" | "windowtext" => [0, 0, 0],
625        "snow" => [255, 250, 250],
626        "ghostwhite" => [248, 248, 255],
627        "whitesmoke" => [245, 245, 245],
628        "ivory" => [255, 255, 240],
629        "azure" => [240, 255, 255],
630        "mintcream" => [245, 255, 250],
631        "floralwhite" => [255, 250, 240],
632        "honeydew" => [240, 255, 240],
633        "aliceblue" => [240, 248, 255],
634        "lavenderblush" => [255, 240, 245],
635        "seashell" => [255, 245, 238],
636        _ => return Some(Color::Named(v)),
637    };
638    Some(Color::Rgb(rgb, 1.0))
639}
640
641impl HiddenRules {
642    /// Every `<style>` block in `html`, wherever it sits: a client
643    /// applies a sheet to the whole document.
644    fn of(html: &str) -> Self {
645        let mut rules = Self::default();
646        let mut rest = html;
647        while let Some(at) = find_ascii_ci(rest, "<style") {
648            rest = &rest[at..];
649            match parse_tag(rest) {
650                Some((tag, after)) if tag.name == "style" && !tag.closing => {
651                    let end = find_ascii_ci(after, "</style").unwrap_or(after.len());
652                    rules.read(&after[..end]);
653                    rest = &after[end..];
654                }
655                _ => rest = &rest[1..],
656            }
657        }
658        rules
659    }
660
661    /// Class and id selectors whose rule hides, and the colors and
662    /// backgrounds, from one `<style>` block. What cannot be followed
663    /// sets `withheld`.
664    fn read(&mut self, css: &str) {
665        self.styled = true;
666        self.rules(&css_text(&css.to_ascii_lowercase()), 0);
667    }
668
669    /// The rules in `css`. Those inside `@media`, `@supports` or any
670    /// other block at-rule are read as if they apply; `@import`, or
671    /// blocks nested more than 8 deep, withhold.
672    fn rules(&mut self, mut css: &str, depth: usize) {
673        if depth > 8 {
674            self.withheld = true;
675            return;
676        }
677        while let Some((prelude, block, rest)) = next_rule(css) {
678            css = rest;
679            let mut prelude = prelude.trim();
680            while let Some(p) = prelude
681                .strip_prefix("<!--")
682                .or_else(|| prelude.strip_prefix("-->"))
683            {
684                prelude = p.trim_start();
685            }
686            match (prelude.strip_prefix('@'), block) {
687                (Some(at), _) if at.starts_with("import") => self.withheld = true,
688                (Some(_), Some(block)) => self.rules(block, depth + 1),
689                (None, Some(block)) => self.rule(prelude, block),
690                (_, None) => {}
691            }
692        }
693    }
694
695    /// One style rule. A rule nested inside it is not followed.
696    fn rule(&mut self, selectors: &str, body: &str) {
697        if unquoted(body).any(|(_, c)| c == '{') {
698            self.withheld = true;
699            return;
700        }
701        let decls = declarations(body);
702        let set = own(&decls, None, 16.0);
703        if let Some(set) = &set {
704            if let Some(fg) = &set.fg {
705                self.withheld |= set.bg.is_none() && close(fg, &WHITE);
706                self.colors.push(fg.clone());
707            }
708            if let Some(bg) = &set.bg {
709                self.withheld |= set.fg.is_none() && close(bg, &BLACK);
710                self.backgrounds.push(bg.clone());
711            }
712        }
713        let conceals = hides(&decls)
714            || set.is_none_or(|set| {
715                set.opacity.is_some_and(|n| n <= 0.05)
716                    || set.font_px.is_some_and(|n| n <= 1.0)
717                    || set.fg.is_some_and(|c| alpha(&c) <= 0.05)
718            });
719        if !conceals {
720            return;
721        }
722        for selector in selectors.split(',') {
723            let last = selector
724                .trim()
725                .rsplit(|c: char| c.is_whitespace() || ">+~".contains(c))
726                .next()
727                .unwrap_or("");
728            match (last.rsplit_once('.'), last.rsplit_once('#')) {
729                (Some((_, class)), _) if simple(class) => self.classes.push(class.to_string()),
730                (None, Some((_, id))) if simple(id) => self.ids.push(id.to_string()),
731                _ => self.withheld = true,
732            }
733        }
734    }
735
736    fn has_class(&self, class: &str) -> bool {
737        self.classes.iter().any(|c| c.eq_ignore_ascii_case(class))
738    }
739
740    fn has_id(&self, id: &str) -> bool {
741        self.ids.iter().any(|i| i.eq_ignore_ascii_case(id))
742    }
743}
744
745/// The first rule or statement in `css`: its prelude, its block if it
746/// has one, and the CSS after it. A block the sheet leaves open runs to
747/// the end.
748fn next_rule(css: &str) -> Option<(&str, Option<&str>, &str)> {
749    if css.trim().is_empty() {
750        return None;
751    }
752    let (mut depth, mut open) = (0usize, 0);
753    for (i, c) in unquoted(css) {
754        match c {
755            ';' if depth == 0 => return Some((&css[..i], None, &css[i + 1..])),
756            '{' => {
757                if depth == 0 {
758                    open = i;
759                }
760                depth += 1;
761            }
762            '}' if depth > 0 => {
763                depth -= 1;
764                if depth == 0 {
765                    return Some((&css[..open], Some(&css[open + 1..i]), &css[i + 1..]));
766                }
767            }
768            _ => {}
769        }
770    }
771    Some(if depth > 0 {
772        (&css[..open], Some(&css[open + 1..]), "")
773    } else {
774        (css, None, "")
775    })
776}
777
778/// The characters of `css` outside strings, with their offsets. A
779/// string ends at its quote or at a line break.
780fn unquoted(css: &str) -> impl Iterator<Item = (usize, char)> + '_ {
781    let (mut quote, mut escaped) = (None, false);
782    css.char_indices().filter(move |&(_, c)| {
783        let Some(q) = quote else {
784            quote = matches!(c, '"' | '\'').then_some(c);
785            return quote.is_none();
786        };
787        if escaped {
788            escaped = false;
789        } else if c == '\\' {
790            escaped = true;
791        } else if c == q || matches!(c, '\n' | '\r' | '\x0c') {
792            quote = None;
793        }
794        false
795    })
796}
797
798fn simple(name: &str) -> bool {
799    !name.is_empty()
800        && name
801            .chars()
802            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
803}
804
805struct Tag {
806    name: String,
807    closing: bool,
808    self_closing: bool,
809    /// `style`, `class`, `id` or `hidden` held a reference `decode` does
810    /// not know, so a client may read it otherwise.
811    odd: bool,
812    /// Values entity-decoded.
813    attrs: Vec<(String, String)>,
814}
815
816impl Tag {
817    fn attr(&self, name: &str) -> Option<&str> {
818        self.attrs
819            .iter()
820            .find(|(k, _)| k == name)
821            .map(|(_, v)| v.as_str())
822    }
823}
824
825/// One tag at the start of `s`, and the text after it. `None` when `s`
826/// does not start a tag, so the `<` is text.
827fn parse_tag(s: &str) -> Option<(Tag, &str)> {
828    let mut chars = s[1..].char_indices().peekable();
829    let mut closing = false;
830    if let Some((_, '/')) = chars.peek() {
831        closing = true;
832        chars.next();
833    }
834    if let Some((_, c)) = chars.peek() {
835        if *c == '!' || *c == '?' {
836            let end = s.find('>')?;
837            return Some((
838                Tag {
839                    name: String::new(),
840                    closing: true,
841                    self_closing: true,
842                    odd: false,
843                    attrs: Vec::new(),
844                },
845                &s[end + 1..],
846            ));
847        }
848    }
849    let mut name = String::new();
850    while let Some((_, c)) = chars.peek() {
851        if c.is_ascii_alphanumeric() {
852            name.push(c.to_ascii_lowercase());
853            chars.next();
854        } else {
855            break;
856        }
857    }
858    if name.is_empty() {
859        return None;
860    }
861    let mut attrs = Vec::new();
862    let (mut self_closing, mut odd) = (false, false);
863    loop {
864        while chars.peek().is_some_and(|(_, c)| c.is_whitespace()) {
865            chars.next();
866        }
867        let (at, c) = chars.next()?;
868        match c {
869            '>' => {
870                let tag = Tag {
871                    name,
872                    closing,
873                    self_closing,
874                    odd,
875                    attrs,
876                };
877                return Some((tag, &s[1 + at + 1..]));
878            }
879            '/' => self_closing = matches!(chars.peek(), Some((_, '>'))),
880            _ => {
881                let mut key = c.to_ascii_lowercase().to_string();
882                while let Some((_, c)) = chars.peek() {
883                    if c.is_whitespace() || *c == '=' || *c == '>' || *c == '/' {
884                        break;
885                    }
886                    key.push(c.to_ascii_lowercase());
887                    chars.next();
888                }
889                while chars.peek().is_some_and(|(_, c)| c.is_whitespace()) {
890                    chars.next();
891                }
892                let mut value = String::new();
893                if let Some((_, '=')) = chars.peek() {
894                    chars.next();
895                    while chars.peek().is_some_and(|(_, c)| c.is_whitespace()) {
896                        chars.next();
897                    }
898                    match chars.peek().map(|(_, c)| *c) {
899                        Some(q @ ('"' | '\'')) => {
900                            chars.next();
901                            for (_, c) in chars.by_ref() {
902                                if c == q {
903                                    break;
904                                }
905                                value.push(c);
906                            }
907                        }
908                        _ => {
909                            while let Some((_, c)) = chars.peek() {
910                                if c.is_whitespace() || *c == '>' {
911                                    break;
912                                }
913                                value.push(*c);
914                                chars.next();
915                            }
916                        }
917                    }
918                }
919                let (value, unknown) = decode_all(&value);
920                odd |= unknown && matches!(key.as_str(), "style" | "class" | "id" | "hidden");
921                attrs.push((key, value));
922            }
923        }
924    }
925}
926
927fn find_ascii_ci(hay: &str, needle: &str) -> Option<usize> {
928    hay.as_bytes()
929        .windows(needle.len())
930        .position(|w| w.eq_ignore_ascii_case(needle.as_bytes()))
931}
932
933/// The common named entities and every numeric one.
934fn decode(s: &str) -> String {
935    decode_all(s).0
936}
937
938/// `s` decoded, and whether an `&` that starts some other reference was
939/// left as it is.
940fn decode_all(s: &str) -> (String, bool) {
941    let mut out = String::with_capacity(s.len());
942    let mut unknown = false;
943    let mut rest = s;
944    while let Some(amp) = rest.find('&') {
945        out.push_str(&rest[..amp]);
946        rest = &rest[amp..];
947        let reference = rest[1..].starts_with(|c: char| c.is_ascii_alphanumeric() || c == '#');
948        // The window ends on a character boundary: a multibyte character
949        // across byte 12 must not panic the reducer.
950        let mut window = rest.len().min(12);
951        while !rest.is_char_boundary(window) {
952            window -= 1;
953        }
954        let Some(semi) = rest[..window].find(';') else {
955            unknown |= reference;
956            out.push('&');
957            rest = &rest[1..];
958            continue;
959        };
960        let name = &rest[1..semi];
961        let ch = match name {
962            "amp" => Some('&'),
963            "lt" => Some('<'),
964            "gt" => Some('>'),
965            "quot" => Some('"'),
966            "apos" => Some('\''),
967            "nbsp" => Some(' '),
968            _ => name
969                .strip_prefix("#x")
970                .or_else(|| name.strip_prefix("#X"))
971                .map(|hex| u32::from_str_radix(hex, 16))
972                .or_else(|| name.strip_prefix('#').map(str::parse::<u32>))
973                .and_then(Result::ok)
974                .and_then(char::from_u32),
975        };
976        match ch {
977            Some(c) => {
978                out.push(c);
979                rest = &rest[semi + 1..];
980            }
981            None => {
982                unknown |= reference;
983                out.push('&');
984                rest = &rest[1..];
985            }
986        }
987    }
988    out.push_str(rest);
989    (out, unknown)
990}
991
992/// Runs of spaces become one, lines are trimmed, and no more than one
993/// blank line is kept.
994fn tidy(s: &str) -> String {
995    let mut lines: Vec<String> = Vec::new();
996    for line in s.lines() {
997        let line = line.split_whitespace().collect::<Vec<_>>().join(" ");
998        if line.is_empty() && lines.last().is_none_or(String::is_empty) {
999            continue;
1000        }
1001        lines.push(line);
1002    }
1003    while lines.last().is_some_and(String::is_empty) {
1004        lines.pop();
1005    }
1006    lines.join("\n")
1007}
1008
1009#[cfg(test)]
1010mod tests {
1011    use super::*;
1012
1013    #[test]
1014    fn hidden_text_is_dropped_and_reported() {
1015        let html = "<style>.x { display : none } #y{visibility:hidden}</style>\
1016            <p>Shown <span style='DISPLAY: none'>inline</span>\
1017            <b class='a x'>class</b><i id=y>id</i><u hidden>attr</u> end</p>";
1018        let got = visible_text(html);
1019        assert_eq!(got.text, "Shown end");
1020        assert!(got.hidden_content && !got.withheld);
1021    }
1022
1023    #[test]
1024    fn plain_html_drops_nothing_and_shows_link_targets() {
1025        let got = visible_text(
1026            "<head><title>t</title></head><div>A &amp; B</div><!-- c -->\
1027             <a href=\"https://example.test/x?a=1&amp;b=2\">here</a><script>evil()</script>",
1028        );
1029        assert_eq!(got.text, "A & B\nhere <https://example.test/x?a=1&b=2>");
1030        assert!(!got.hidden_content && !got.withheld);
1031    }
1032
1033    #[test]
1034    fn inline_concealment_beyond_display_none_is_dropped() {
1035        for style in [
1036            "font-size:0",
1037            "font-size: 0px",
1038            "font-size:1px",
1039            "opacity:0",
1040            "opacity: 0.01",
1041            "color:#fff;background:#FFFFFF",
1042            "color:white; background-color: rgb(255, 255, 255)",
1043            "color: transparent",
1044            "position:absolute;left:-9999px",
1045            "text-indent:-9999px",
1046            "clip:rect(0,0,0,0)",
1047            "clip-path: inset(50%)",
1048            "max-height:0;overflow:hidden",
1049            "mso-hide:all",
1050            "visibility: collapse",
1051        ] {
1052            let html = format!("<p>Shown <span style=\"{style}\">secret</span> end</p>");
1053            let got = visible_text(&html);
1054            assert_eq!(got.text, "Shown end", "{style}");
1055            assert!(got.hidden_content && !got.withheld, "{style}");
1056        }
1057        let inherited = visible_text(
1058            "<td bgcolor=\"#000\" style=\"color:#fff\">Shown <font color=black>secret</font> end</td>",
1059        );
1060        assert_eq!(inherited.text, "Shown end");
1061    }
1062
1063    #[test]
1064    fn the_concealment_the_review_found_is_dropped() {
1065        for span in [
1066            "<span style=\"display&#58;none\">",
1067            "<span style=\"display&#x3a;none\">",
1068            "<span/style=\"display:none\">",
1069            "<span style=\"display:none\"/>",
1070            "<span style=\"display:/**/none\">",
1071            "<span style=\"display:n\\6f ne\">",
1072            "<span style=\"opacity:1%\">",
1073            "<span style=\"opacity:1e-3\">",
1074            "<span style=\"font-size:0.01em\">",
1075            "<span style=\"font:0/0 a\">",
1076            "<span style=\"color:rgba(255,255,255,.0)\">",
1077            "<span style=\"color:#fff0\">",
1078            "<span style=\"color:#fefefe\">",
1079            "<span style=\"-webkit-text-fill-color:transparent\">",
1080            "<span style=\"transform:translateX(-9999px)\">",
1081            "<span style=\"position:absolute;left:9999px\">",
1082            "<span style=\"display:var(--h)\">",
1083            "<span style=\"color:hsl(0,0%,100%)\">",
1084            "<span style=\"opacity:.2\"><span style=\"opacity:.2\">",
1085            "<span style=\"font-size:10%\"><span style=\"font-size:10%\">",
1086        ] {
1087            let got = visible_text(&format!("<p>Shown {span}SECRET</span></span> end</p>"));
1088            assert_eq!(got.text, "Shown end", "{span}");
1089            assert!(got.hidden_content && !got.withheld, "{span}");
1090        }
1091        let got = visible_text("<p>Shown<div/hidden>SECRET</div>end</p>");
1092        assert!(
1093            !got.text.contains("SECRET") && got.hidden_content,
1094            "{got:?}"
1095        );
1096    }
1097
1098    #[test]
1099    fn styling_that_hides_nothing_keeps_its_text() {
1100        for span in [
1101            "<span style=\"color:#333;opacity:.5;font-size:.8em;transform:none\">",
1102            "<span style=\"background:url(x.png) #06c no-repeat;color:#fff\">",
1103            "<span style=\"left:-0px;width:auto\">",
1104        ] {
1105            let got = visible_text(&format!("<p>Shown {span}text</span> end</p>"));
1106            assert_eq!(got.text, "Shown text end", "{span}");
1107            assert!(!got.hidden_content, "{span}");
1108        }
1109        let svg = visible_text("<svg><rect style=\"display:none\"/></svg><p>Shown<br/>end</p>");
1110        assert_eq!(svg.text, "Shown\nend");
1111    }
1112
1113    #[test]
1114    fn what_the_reducer_cannot_decode_or_place_is_withheld() {
1115        for html in [
1116            "<p><span style=\"display&colon;none\">SECRET</span></p>",
1117            "<p><span class=\"&#x78\">SECRET</span></p>",
1118            "<style>.t{color:#fff;background:#000}</style>\
1119             <div style=\"background:#fff\"><p class=t>SECRET</p></div>",
1120            "<style>.b{background:#fff;color:#000}</style>\
1121             <div class=b><p style=\"color:#fff\">SECRET</p></div>",
1122            "<style>.t{color:#fff}</style><p class=t>SECRET</p>",
1123            "<p class=t>SECRET</p><style>.x{color:#000}</style><td bgcolor=black>",
1124        ] {
1125            let got = visible_text(html);
1126            assert!(got.hidden_content && got.withheld, "{html}");
1127        }
1128        let late = visible_text("<p class=x>SECRET</p><p>Shown</p><style>.x{display:none}</style>");
1129        assert_eq!(late.text, "Shown");
1130    }
1131
1132    #[test]
1133    fn a_style_block_marks_the_part_and_what_cannot_be_followed_is_withheld() {
1134        let plain = visible_text("<style>.a { color: red }</style><p>Hello</p>");
1135        assert_eq!(plain.text, "Hello");
1136        assert!(plain.hidden_content && !plain.withheld);
1137        for css in [
1138            "p { display: none }",
1139            "div > p { font-size: 0 }",
1140            "* { opacity: 0 }",
1141            "a:hover, .x { display: none }",
1142            "@media screen { div > p { display: none } }",
1143            "@keyframes k { to { opacity: 0 } }",
1144            ".a { .x { display: none } }",
1145            "@import url(https://example.test/x.css);",
1146            "@media screen { @IMPORT 'x.css'; }",
1147            ".a { color: #fff } body { background: white }",
1148        ] {
1149            let got = visible_text(&format!("<style>{css}</style><p>Hello</p><p>secret</p>"));
1150            assert!(got.hidden_content && got.withheld, "{css}");
1151        }
1152        let linked = visible_text("<link rel=stylesheet href=https://example.test/x.css><p>Hi</p>");
1153        assert!(linked.withheld);
1154        let deep = format!(
1155            "<style>{}.x{{display:none}}</style>",
1156            "@media screen{".repeat(9)
1157        );
1158        assert!(visible_text(&deep).withheld);
1159    }
1160
1161    #[test]
1162    fn at_rules_apply_as_if_they_match() {
1163        for css in [
1164            "@media screen { .a { color: red } }",
1165            ".a::after { content: \"a@b\" }",
1166            "/* @import url(x.css); */ .a { font-family: '@import' }",
1167            "@charset \"utf-8\"; @font-face { font-family: f; src: url(f.woff) }",
1168        ] {
1169            let got = visible_text(&format!("<style>{css}</style><p class=a>Hello</p>"));
1170            assert_eq!(got.text, "Hello", "{css}");
1171            assert!(got.hidden_content && !got.withheld, "{css}");
1172        }
1173        let got = visible_text(
1174            "<style><!-- @media (max-width: 600px) { .x { display: none } }\
1175             @supports (display: grid) { @media print { #y { font-size: 0 } } } -->\
1176             .z { content: \"a\n}.w{display:/**/none}</style>\
1177             <p>Shown</p><p class=x>SECRET</p><p id=y>SECRET</p><p class=w>SECRET</p>",
1178        );
1179        assert_eq!(got.text, "Shown");
1180        assert!(got.hidden_content && !got.withheld, "{got:?}");
1181    }
1182
1183    #[test]
1184    fn a_stray_angle_bracket_is_text() {
1185        assert_eq!(visible_text("1 < 2 and 3 > 2").text, "1 < 2 and 3 > 2");
1186    }
1187}