1#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct Visible {
40 pub text: String,
41 pub hidden_content: bool,
44 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#[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
107struct 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 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
136const 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 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
287fn 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
336fn 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
359fn 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
378fn 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
399fn inherits(v: &str) -> bool {
401 matches!(
402 v,
403 "inherit" | "initial" | "unset" | "revert" | "revert-layer" | "currentcolor"
404 )
405}
406
407fn 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
435fn 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
479fn 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
501fn 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
519fn 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
530fn 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
563fn invisible(paint: &Paint) -> bool {
565 alpha(&paint.fg) <= 0.05 || close(&paint.fg, &paint.bg)
566}
567
568fn 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 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 fn read(&mut self, css: &str) {
665 self.styled = true;
666 self.rules(&css_text(&css.to_ascii_lowercase()), 0);
667 }
668
669 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 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
745fn 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
778fn 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 odd: bool,
812 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
825fn 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
933fn decode(s: &str) -> String {
935 decode_all(s).0
936}
937
938fn 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 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
992fn 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 & B</div><!-- c -->\
1027 <a href=\"https://example.test/x?a=1&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:none\">",
1067 "<span style=\"display: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:none\">SECRET</span></p>",
1117 "<p><span class=\"x\">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}