1use std::cell::RefCell;
25use std::rc::Rc;
26
27use lol_html::html_content::{TextChunk, TextType};
28use lol_html::{HtmlRewriter, MemorySettings, Settings, doc_text, element, end_tag};
29
30use super::{ConvertError, Converter, MAX_OUTPUT_BYTES, MAX_PART_BYTES, Outcome, normalize_text};
31use crate::segments::Segment;
32
33pub const HTML_STRIP_ID: &str = "html-strip";
35pub const HTML_STRIP_VERSION: &str = "1.0.0";
37
38pub struct HtmlStrip;
40
41const HEAD_CONTENT: &[&str] = &[
48 "base", "basefont", "bgsound", "head", "html", "link", "meta", "noscript", "script", "style",
49 "template", "title",
50];
51
52const BLOCK_ELEMENTS: &[&str] = &[
56 "address",
57 "article",
58 "aside",
59 "blockquote",
60 "dd",
61 "div",
62 "dl",
63 "dt",
64 "figure",
65 "footer",
66 "h1",
67 "h2",
68 "h3",
69 "h4",
70 "h5",
71 "h6",
72 "header",
73 "li",
74 "main",
75 "nav",
76 "ol",
77 "p",
78 "pre",
79 "section",
80 "table",
81 "tr",
82 "ul",
83];
84
85#[derive(Default)]
86struct StripState {
87 raw: String,
91 title: String,
92 in_title: bool,
93 title_done: bool,
94 in_head: bool,
95 template_depth: u32,
96 row_has_cell: bool,
97 node_buf: String,
98 node_type: Option<TextType>,
99 charged_bytes: usize,
100 budget: usize,
101}
102
103impl StripState {
104 fn charge(&mut self, bytes: usize) -> std::result::Result<(), String> {
108 self.charged_bytes += bytes;
109 if self.charged_bytes > self.budget {
110 Err(format!(
111 "captured content exceeds the {} byte ceiling",
112 self.budget
113 ))
114 } else {
115 Ok(())
116 }
117 }
118
119 fn push_break(&mut self) -> std::result::Result<(), String> {
120 self.charge(1)?;
121 self.raw.push('\n');
122 Ok(())
123 }
124
125 fn push_tab(&mut self) -> std::result::Result<(), String> {
126 self.charge(1)?;
127 self.raw.push('\t');
128 Ok(())
129 }
130
131 fn flush_node(&mut self, fallback_type: TextType) {
132 let text = std::mem::take(&mut self.node_buf);
133 let text_type = self.node_type.take().unwrap_or(fallback_type);
134 if text.is_empty() || self.template_depth > 0 {
135 return;
136 }
137 let keep = matches!(
138 text_type,
139 TextType::Data | TextType::RCData | TextType::PlainText
140 );
141 if !keep {
142 return;
143 }
144 let decoded = if text_type.allows_html_entities() {
145 decode_entities(&text)
146 } else {
147 text
148 };
149 if self.in_title {
150 self.title.push_str(&decoded);
151 } else if !self.in_head {
152 self.raw.push_str(&collapse_whitespace(&decoded));
155 }
156 }
157}
158
159pub(crate) fn strip_html(source: &[u8]) -> Result<(String, Vec<String>), ConvertError> {
163 strip_html_with_budget(source, MAX_OUTPUT_BYTES)
164}
165
166fn strip_html_with_budget(
169 source: &[u8],
170 budget: usize,
171) -> Result<(String, Vec<String>), ConvertError> {
172 let text_source = std::str::from_utf8(source).map_err(|e| ConvertError {
173 code: "invalid_utf8",
174 message: format!("invalid UTF-8 at byte {}", e.valid_up_to()),
175 })?;
176 let mut warnings = Vec::new();
177 let text_source = match text_source.strip_prefix('\u{feff}') {
178 Some(stripped) => {
179 warnings.push("stripped leading byte order mark".to_string());
180 stripped
181 }
182 None => text_source,
183 };
184
185 let state = Rc::new(RefCell::new(StripState {
186 budget,
187 ..StripState::default()
188 }));
189 let element_state = state.clone();
190 let text_state = state.clone();
191 let settings = Settings::new()
192 .with_memory_settings(
193 MemorySettings::new().with_max_allowed_memory_usage(MAX_PART_BYTES as usize),
194 )
195 .append_element_content_handler(element!("*", move |el| {
196 let tag = el.tag_name();
197 let mut st = element_state.borrow_mut();
198 if st.in_head && !HEAD_CONTENT.contains(&tag.as_str()) {
199 st.in_head = false;
200 }
201 match tag.as_str() {
202 "head" => {
203 st.in_head = true;
204 let state = element_state.clone();
205 drop(st);
206 el.on_end_tag(end_tag!(move |_| {
207 state.borrow_mut().in_head = false;
208 Ok(())
209 }))?;
210 }
211 "title" if !st.title_done => {
212 st.in_title = true;
213 let state = element_state.clone();
214 drop(st);
215 el.on_end_tag(end_tag!(move |_| {
216 let mut st = state.borrow_mut();
217 st.in_title = false;
218 st.title_done = true;
219 Ok(())
220 }))?;
221 }
222 "template" => {
223 st.template_depth += 1;
224 let state = element_state.clone();
225 drop(st);
226 el.on_end_tag(end_tag!(move |_| {
227 let mut st = state.borrow_mut();
228 st.template_depth = st.template_depth.saturating_sub(1);
229 Ok(())
230 }))?;
231 }
232 "br" => st.push_break()?,
233 "td" | "th" => {
234 if st.row_has_cell {
235 st.push_tab()?;
236 } else {
237 st.row_has_cell = true;
238 }
239 }
240 block if BLOCK_ELEMENTS.contains(&block) => {
241 st.push_break()?;
242 if block == "tr" {
243 st.row_has_cell = false;
244 }
245 let state = element_state.clone();
246 drop(st);
247 el.on_end_tag(end_tag!(move |_| {
248 state.borrow_mut().push_break()?;
249 Ok(())
250 }))?;
251 }
252 _ => {}
253 }
254 Ok(())
255 }))
256 .append_document_content_handler(doc_text!(move |chunk: &mut TextChunk| {
257 let mut st = text_state.borrow_mut();
258 let piece = chunk.as_str();
259 st.charge(piece.len())?;
260 if st.node_type.is_none() && !piece.is_empty() {
261 st.node_type = Some(chunk.text_type());
262 }
263 st.node_buf.push_str(piece);
264 if chunk.last_in_text_node() {
265 let text_type = chunk.text_type();
266 st.flush_node(text_type);
267 }
268 Ok(())
269 }));
270
271 let mut rewriter = HtmlRewriter::new(settings, |_: &[u8]| {});
272 rewriter
273 .write(text_source.as_bytes())
274 .and_then(|()| rewriter.end())
275 .map_err(|e| match e {
276 lol_html::errors::RewritingError::MemoryLimitExceeded(_) => ConvertError {
277 code: "resource_limit",
278 message: format!("markup working memory exceeds the {MAX_PART_BYTES} byte ceiling"),
279 },
280 lol_html::errors::RewritingError::ContentHandlerError(detail) => ConvertError {
281 code: "resource_limit",
282 message: detail.to_string(),
283 },
284 other => ConvertError {
285 code: "html_parse_error",
286 message: other.to_string(),
287 },
288 })?;
289
290 let mut state = state.borrow_mut();
291 state.flush_node(TextType::Data);
292 Ok((assemble(&state.title, &state.raw), warnings))
293}
294
295impl Converter for HtmlStrip {
296 fn id(&self) -> &'static str {
297 HTML_STRIP_ID
298 }
299
300 fn version(&self) -> &'static str {
301 HTML_STRIP_VERSION
302 }
303
304 fn convert(
305 &self,
306 source: &[u8],
307 detected_format: &str,
308 ) -> std::result::Result<Outcome, ConvertError> {
309 let (stripped, warnings) = strip_html(source)?;
310 let text = normalize_text(&stripped);
311 if !source.is_empty() && text.is_empty() {
312 return Err(ConvertError {
313 code: "empty_output",
314 message: format!("source is {} bytes but holds no visible text", source.len()),
315 });
316 }
317 let segments = vec![Segment::span(0, text.len(), "document")];
318 Ok(Outcome {
319 converter_id: HTML_STRIP_ID.to_string(),
320 converter_version: HTML_STRIP_VERSION.to_string(),
321 detected_format: detected_format.to_string(),
322 text,
323 warnings,
324 segments,
325 })
326 }
327}
328
329fn collapse_whitespace(text: &str) -> String {
332 let mut out = String::with_capacity(text.len());
333 let mut in_run = false;
334 for c in text.chars() {
335 if matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0c') {
336 if !in_run {
337 out.push(' ');
338 in_run = true;
339 }
340 } else {
341 out.push(c);
342 in_run = false;
343 }
344 }
345 out
346}
347
348fn assemble(title: &str, raw: &str) -> String {
352 let mut lines: Vec<String> = Vec::new();
353 for raw_line in raw.split('\n') {
354 let mut cells: Vec<String> = raw_line
355 .split('\t')
356 .map(|cell| collapse_whitespace(cell).trim().to_string())
357 .collect();
358 while cells.last().is_some_and(String::is_empty) && cells.len() > 1 {
359 cells.pop();
360 }
361 lines.push(cells.join("\t"));
362 }
363
364 let mut out = String::new();
365 let mut body_started = false;
366 let mut blank_pending = false;
367 for line in &lines {
368 if line.is_empty() {
369 blank_pending = body_started;
373 continue;
374 }
375 if blank_pending {
376 out.push('\n');
377 blank_pending = false;
378 }
379 out.push_str(line);
380 out.push('\n');
381 body_started = true;
382 }
383 let title_line = collapse_whitespace(title);
384 let title_line = title_line.trim();
385 if title_line.is_empty() {
386 out
387 } else {
388 format!("{title_line}\n{out}")
389 }
390}
391
392pub(crate) fn decode_entities(text: &str) -> String {
395 if !text.contains('&') {
396 return text.to_string();
397 }
398 let mut out = String::with_capacity(text.len());
399 let mut rest = text;
400 while let Some(position) = rest.find('&') {
401 out.push_str(&rest[..position]);
402 rest = &rest[position..];
403 match decode_reference(rest) {
404 Some((decoded, consumed)) => {
405 out.push(decoded);
406 rest = &rest[consumed..];
407 }
408 None => {
409 out.push('&');
410 rest = &rest[1..];
411 }
412 }
413 }
414 out.push_str(rest);
415 out
416}
417
418fn decode_reference(text: &str) -> Option<(char, usize)> {
421 let semicolon = text.as_bytes().iter().take(40).position(|&b| b == b';')?;
422 let body = &text[1..semicolon];
423 if let Some(number) = body.strip_prefix('#') {
424 let value = if let Some(hex) = number.strip_prefix(['x', 'X']) {
425 u32::from_str_radix(hex, 16).ok()?
426 } else if number.is_empty() {
427 return None;
428 } else {
429 number.parse::<u32>().ok()?
430 };
431 let decoded = char::from_u32(value)?;
432 if decoded == '\0' {
433 return None;
434 }
435 return Some((decoded, semicolon + 1));
436 }
437 NAMED_ENTITIES
438 .binary_search_by(|(name, _)| (*name).cmp(body))
439 .ok()
440 .map(|index| (NAMED_ENTITIES[index].1, semicolon + 1))
441}
442
443const NAMED_ENTITIES: &[(&str, char)] = &[
452 ("AElig", '\u{c6}'),
453 ("Aacute", '\u{c1}'),
454 ("Acirc", '\u{c2}'),
455 ("Agrave", '\u{c0}'),
456 ("Aring", '\u{c5}'),
457 ("Atilde", '\u{c3}'),
458 ("Auml", '\u{c4}'),
459 ("Ccedil", '\u{c7}'),
460 ("Dagger", '\u{2021}'),
461 ("ETH", '\u{d0}'),
462 ("Eacute", '\u{c9}'),
463 ("Ecirc", '\u{ca}'),
464 ("Egrave", '\u{c8}'),
465 ("Euml", '\u{cb}'),
466 ("Iacute", '\u{cd}'),
467 ("Icirc", '\u{ce}'),
468 ("Igrave", '\u{cc}'),
469 ("Iuml", '\u{cf}'),
470 ("Ntilde", '\u{d1}'),
471 ("OElig", '\u{152}'),
472 ("Oacute", '\u{d3}'),
473 ("Ocirc", '\u{d4}'),
474 ("Ograve", '\u{d2}'),
475 ("Oslash", '\u{d8}'),
476 ("Otilde", '\u{d5}'),
477 ("Ouml", '\u{d6}'),
478 ("Prime", '\u{2033}'),
479 ("Scaron", '\u{160}'),
480 ("THORN", '\u{de}'),
481 ("Uacute", '\u{da}'),
482 ("Ucirc", '\u{db}'),
483 ("Ugrave", '\u{d9}'),
484 ("Uuml", '\u{dc}'),
485 ("Yacute", '\u{dd}'),
486 ("Yuml", '\u{178}'),
487 ("aacute", '\u{e1}'),
488 ("acirc", '\u{e2}'),
489 ("acute", '\u{b4}'),
490 ("aelig", '\u{e6}'),
491 ("agrave", '\u{e0}'),
492 ("amp", '\u{26}'),
493 ("apos", '\u{27}'),
494 ("aring", '\u{e5}'),
495 ("atilde", '\u{e3}'),
496 ("auml", '\u{e4}'),
497 ("bdquo", '\u{201e}'),
498 ("brvbar", '\u{a6}'),
499 ("bull", '\u{2022}'),
500 ("ccedil", '\u{e7}'),
501 ("cedil", '\u{b8}'),
502 ("cent", '\u{a2}'),
503 ("circ", '\u{2c6}'),
504 ("copy", '\u{a9}'),
505 ("curren", '\u{a4}'),
506 ("dagger", '\u{2020}'),
507 ("deg", '\u{b0}'),
508 ("divide", '\u{f7}'),
509 ("eacute", '\u{e9}'),
510 ("ecirc", '\u{ea}'),
511 ("egrave", '\u{e8}'),
512 ("emsp", '\u{2003}'),
513 ("ensp", '\u{2002}'),
514 ("eth", '\u{f0}'),
515 ("euml", '\u{eb}'),
516 ("euro", '\u{20ac}'),
517 ("fnof", '\u{192}'),
518 ("frac12", '\u{bd}'),
519 ("frac14", '\u{bc}'),
520 ("frac34", '\u{be}'),
521 ("frasl", '\u{2044}'),
522 ("gt", '\u{3e}'),
523 ("hellip", '\u{2026}'),
524 ("iacute", '\u{ed}'),
525 ("icirc", '\u{ee}'),
526 ("iexcl", '\u{a1}'),
527 ("igrave", '\u{ec}'),
528 ("iquest", '\u{bf}'),
529 ("iuml", '\u{ef}'),
530 ("laquo", '\u{ab}'),
531 ("ldquo", '\u{201c}'),
532 ("lrm", '\u{200e}'),
533 ("lsaquo", '\u{2039}'),
534 ("lsquo", '\u{2018}'),
535 ("lt", '\u{3c}'),
536 ("macr", '\u{af}'),
537 ("mdash", '\u{2014}'),
538 ("micro", '\u{b5}'),
539 ("middot", '\u{b7}'),
540 ("minus", '\u{2212}'),
541 ("nbsp", '\u{a0}'),
542 ("ndash", '\u{2013}'),
543 ("not", '\u{ac}'),
544 ("ntilde", '\u{f1}'),
545 ("oacute", '\u{f3}'),
546 ("ocirc", '\u{f4}'),
547 ("oelig", '\u{153}'),
548 ("ograve", '\u{f2}'),
549 ("oline", '\u{203e}'),
550 ("ordf", '\u{aa}'),
551 ("ordm", '\u{ba}'),
552 ("oslash", '\u{f8}'),
553 ("otilde", '\u{f5}'),
554 ("ouml", '\u{f6}'),
555 ("para", '\u{b6}'),
556 ("permil", '\u{2030}'),
557 ("plusmn", '\u{b1}'),
558 ("pound", '\u{a3}'),
559 ("prime", '\u{2032}'),
560 ("quot", '\u{22}'),
561 ("raquo", '\u{bb}'),
562 ("rdquo", '\u{201d}'),
563 ("reg", '\u{ae}'),
564 ("rlm", '\u{200f}'),
565 ("rsaquo", '\u{203a}'),
566 ("rsquo", '\u{2019}'),
567 ("sbquo", '\u{201a}'),
568 ("scaron", '\u{161}'),
569 ("sect", '\u{a7}'),
570 ("shy", '\u{ad}'),
571 ("sup1", '\u{b9}'),
572 ("sup2", '\u{b2}'),
573 ("sup3", '\u{b3}'),
574 ("szlig", '\u{df}'),
575 ("thinsp", '\u{2009}'),
576 ("thorn", '\u{fe}'),
577 ("tilde", '\u{2dc}'),
578 ("times", '\u{d7}'),
579 ("trade", '\u{2122}'),
580 ("uacute", '\u{fa}'),
581 ("ucirc", '\u{fb}'),
582 ("ugrave", '\u{f9}'),
583 ("uml", '\u{a8}'),
584 ("uuml", '\u{fc}'),
585 ("yacute", '\u{fd}'),
586 ("yen", '\u{a5}'),
587 ("yuml", '\u{ff}'),
588 ("zwj", '\u{200d}'),
589 ("zwnj", '\u{200c}'),
590];
591
592#[cfg(test)]
593mod tests {
594 use super::*;
595
596 #[test]
599 fn entities_decode_and_the_raw_reference_never_ships() {
600 let (text, _) =
601 strip_html(b"<p>a & b © <tag> €9 &bogus; x</p>").unwrap();
602 assert_eq!(text, "a & b \u{a9} <tag> \u{20ac}9 &bogus; x\n");
603 assert!(!text.contains("&"));
604 let (control, _) = strip_html(b"<p>&amp;</p>").unwrap();
608 assert!(control.contains("&"));
609 }
610
611 #[test]
614 fn suppressed_element_text_never_reaches_the_capture() {
615 let html = b"<p>keep</p><script>var leak = 'scriptleak';</script>\
616 <style>.styleleak{}</style><noscript>noscriptleak</noscript>\
617 <template>templateleak</template><script>runs to eof scriptleaktwo";
618 let (text, _) = strip_html(html).unwrap();
619 assert!(text.contains("keep"));
620 for leak in [
621 "scriptleak",
622 "styleleak",
623 "noscriptleak",
624 "templateleak",
625 "scriptleaktwo",
626 ] {
627 assert!(!text.contains(leak), "{leak} leaked into {text:?}");
628 }
629 let (control, _) = strip_html(b"<p>var leak = 'scriptleak';</p>").unwrap();
632 assert!(control.contains("scriptleak"));
633 }
634
635 #[test]
638 fn implied_closures_break_lines_deterministically() {
639 let source = b"<ul><li>one<li>two<li>three</ul><p>para";
640 let (first, _) = strip_html(source).unwrap();
641 let (second, _) = strip_html(source).unwrap();
642 assert_eq!(first, second);
643 assert!(first.contains("one\ntwo\nthree"), "{first:?}");
644 assert!(first.contains("para"));
645 let (control, _) = strip_html(b"<p>one two three</p>").unwrap();
648 assert_eq!(control, "one two three\n");
649 }
650
651 #[test]
654 fn sectioning_containers_break_instead_of_concatenating() {
655 let (text, _) = strip_html(b"<article>alpha</article><section>beta</section>").unwrap();
656 assert_eq!(
657 text,
658 "alpha
659
660beta
661"
662 );
663 assert!(!text.contains("alphabeta"));
664 let (more, _) = strip_html(
665 b"<header>top</header><nav>menu</nav><main>body</main><footer>legal</footer>",
666 )
667 .unwrap();
668 assert_eq!(
669 more,
670 "top
671
672menu
673
674body
675
676legal
677"
678 );
679 }
680
681 #[test]
685 fn dense_short_blocks_fail_closed_on_the_working_budget() {
686 let page = "<p>x</p>".repeat(64);
687 let err = strip_html_with_budget(page.as_bytes(), 100).unwrap_err();
689 assert_eq!(err.code, "resource_limit");
690 assert!(err.message.contains("byte ceiling"), "{}", err.message);
691 let (text, _) = strip_html_with_budget(page.as_bytes(), 4096).unwrap();
694 assert_eq!(text, vec!["x"; 64].join("\n\n") + "\n");
695 }
696
697 #[test]
698 fn title_is_the_first_line_and_head_content_drops() {
699 let html = b"<html><head><meta charset=\"utf-8\"><title>Q3 Plan</title>\
700 <style>.x{}</style></head><body><p>body text</p></body></html>";
701 let (text, _) = strip_html(html).unwrap();
702 assert_eq!(text, "Q3 Plan\nbody text\n");
703 }
704
705 #[test]
706 fn an_unclosed_head_does_not_swallow_the_body() {
707 let (text, _) =
708 strip_html(b"<html><head><title>T</title><p>after an unclosed head").unwrap();
709 assert_eq!(text, "T\nafter an unclosed head\n");
710 }
711
712 #[test]
713 fn paragraphs_separate_with_one_blank_line() {
714 let (text, _) =
715 strip_html(b"<p>first</p>\n\n\n<p>second</p><div><div><p>third</p></div></div>")
716 .unwrap();
717 assert_eq!(text, "first\n\nsecond\n\nthird\n");
718 }
719
720 #[test]
721 fn table_cells_join_with_tabs_within_a_row() {
722 let (text, _) = strip_html(
723 b"<table><tr><td>a</td><td>b</td></tr><tr><td>c</td><td>d</td></tr></table>",
724 )
725 .unwrap();
726 assert!(text.contains("a\tb"), "{text:?}");
727 assert!(text.contains("c\td"), "{text:?}");
728 }
729
730 #[test]
731 fn whitespace_collapses_and_inline_markup_flows() {
732 let (text, _) = strip_html(b"<p>a\n lot\t of <b>bold</b>\nspace</p>").unwrap();
733 assert_eq!(text, "a lot of bold space\n");
734 }
735
736 #[test]
737 fn invalid_utf8_fails_closed() {
738 let err = strip_html(b"<p>ok \xff</p>").unwrap_err();
739 assert_eq!(err.code, "invalid_utf8");
740 }
741
742 #[test]
743 fn a_page_with_no_visible_text_is_suspiciously_empty() {
744 let err = HtmlStrip
745 .convert(b"<script>var x = 1;</script>", "html")
746 .unwrap_err();
747 assert_eq!(err.code, "empty_output");
748 }
749}