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 artifact_kind: crate::manifest::ArtifactKind::Text,
320 converter_id: HTML_STRIP_ID.to_string(),
321 converter_version: HTML_STRIP_VERSION.to_string(),
322 detected_format: detected_format.to_string(),
323 text,
324 warnings,
325 segments,
326 media: None,
327 })
328 }
329}
330
331fn collapse_whitespace(text: &str) -> String {
334 let mut out = String::with_capacity(text.len());
335 let mut in_run = false;
336 for c in text.chars() {
337 if matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0c') {
338 if !in_run {
339 out.push(' ');
340 in_run = true;
341 }
342 } else {
343 out.push(c);
344 in_run = false;
345 }
346 }
347 out
348}
349
350fn assemble(title: &str, raw: &str) -> String {
354 let mut lines: Vec<String> = Vec::new();
355 for raw_line in raw.split('\n') {
356 let mut cells: Vec<String> = raw_line
357 .split('\t')
358 .map(|cell| collapse_whitespace(cell).trim().to_string())
359 .collect();
360 while cells.last().is_some_and(String::is_empty) && cells.len() > 1 {
361 cells.pop();
362 }
363 lines.push(cells.join("\t"));
364 }
365
366 let mut out = String::new();
367 let mut body_started = false;
368 let mut blank_pending = false;
369 for line in &lines {
370 if line.is_empty() {
371 blank_pending = body_started;
375 continue;
376 }
377 if blank_pending {
378 out.push('\n');
379 blank_pending = false;
380 }
381 out.push_str(line);
382 out.push('\n');
383 body_started = true;
384 }
385 let title_line = collapse_whitespace(title);
386 let title_line = title_line.trim();
387 if title_line.is_empty() {
388 out
389 } else {
390 format!("{title_line}\n{out}")
391 }
392}
393
394pub(crate) fn decode_entities(text: &str) -> String {
397 if !text.contains('&') {
398 return text.to_string();
399 }
400 let mut out = String::with_capacity(text.len());
401 let mut rest = text;
402 while let Some(position) = rest.find('&') {
403 out.push_str(&rest[..position]);
404 rest = &rest[position..];
405 match decode_reference(rest) {
406 Some((decoded, consumed)) => {
407 out.push(decoded);
408 rest = &rest[consumed..];
409 }
410 None => {
411 out.push('&');
412 rest = &rest[1..];
413 }
414 }
415 }
416 out.push_str(rest);
417 out
418}
419
420fn decode_reference(text: &str) -> Option<(char, usize)> {
423 let semicolon = text.as_bytes().iter().take(40).position(|&b| b == b';')?;
424 let body = &text[1..semicolon];
425 if let Some(number) = body.strip_prefix('#') {
426 let value = if let Some(hex) = number.strip_prefix(['x', 'X']) {
427 u32::from_str_radix(hex, 16).ok()?
428 } else if number.is_empty() {
429 return None;
430 } else {
431 number.parse::<u32>().ok()?
432 };
433 let decoded = char::from_u32(value)?;
434 if decoded == '\0' {
435 return None;
436 }
437 return Some((decoded, semicolon + 1));
438 }
439 NAMED_ENTITIES
440 .binary_search_by(|(name, _)| (*name).cmp(body))
441 .ok()
442 .map(|index| (NAMED_ENTITIES[index].1, semicolon + 1))
443}
444
445const NAMED_ENTITIES: &[(&str, char)] = &[
454 ("AElig", '\u{c6}'),
455 ("Aacute", '\u{c1}'),
456 ("Acirc", '\u{c2}'),
457 ("Agrave", '\u{c0}'),
458 ("Aring", '\u{c5}'),
459 ("Atilde", '\u{c3}'),
460 ("Auml", '\u{c4}'),
461 ("Ccedil", '\u{c7}'),
462 ("Dagger", '\u{2021}'),
463 ("ETH", '\u{d0}'),
464 ("Eacute", '\u{c9}'),
465 ("Ecirc", '\u{ca}'),
466 ("Egrave", '\u{c8}'),
467 ("Euml", '\u{cb}'),
468 ("Iacute", '\u{cd}'),
469 ("Icirc", '\u{ce}'),
470 ("Igrave", '\u{cc}'),
471 ("Iuml", '\u{cf}'),
472 ("Ntilde", '\u{d1}'),
473 ("OElig", '\u{152}'),
474 ("Oacute", '\u{d3}'),
475 ("Ocirc", '\u{d4}'),
476 ("Ograve", '\u{d2}'),
477 ("Oslash", '\u{d8}'),
478 ("Otilde", '\u{d5}'),
479 ("Ouml", '\u{d6}'),
480 ("Prime", '\u{2033}'),
481 ("Scaron", '\u{160}'),
482 ("THORN", '\u{de}'),
483 ("Uacute", '\u{da}'),
484 ("Ucirc", '\u{db}'),
485 ("Ugrave", '\u{d9}'),
486 ("Uuml", '\u{dc}'),
487 ("Yacute", '\u{dd}'),
488 ("Yuml", '\u{178}'),
489 ("aacute", '\u{e1}'),
490 ("acirc", '\u{e2}'),
491 ("acute", '\u{b4}'),
492 ("aelig", '\u{e6}'),
493 ("agrave", '\u{e0}'),
494 ("amp", '\u{26}'),
495 ("apos", '\u{27}'),
496 ("aring", '\u{e5}'),
497 ("atilde", '\u{e3}'),
498 ("auml", '\u{e4}'),
499 ("bdquo", '\u{201e}'),
500 ("brvbar", '\u{a6}'),
501 ("bull", '\u{2022}'),
502 ("ccedil", '\u{e7}'),
503 ("cedil", '\u{b8}'),
504 ("cent", '\u{a2}'),
505 ("circ", '\u{2c6}'),
506 ("copy", '\u{a9}'),
507 ("curren", '\u{a4}'),
508 ("dagger", '\u{2020}'),
509 ("deg", '\u{b0}'),
510 ("divide", '\u{f7}'),
511 ("eacute", '\u{e9}'),
512 ("ecirc", '\u{ea}'),
513 ("egrave", '\u{e8}'),
514 ("emsp", '\u{2003}'),
515 ("ensp", '\u{2002}'),
516 ("eth", '\u{f0}'),
517 ("euml", '\u{eb}'),
518 ("euro", '\u{20ac}'),
519 ("fnof", '\u{192}'),
520 ("frac12", '\u{bd}'),
521 ("frac14", '\u{bc}'),
522 ("frac34", '\u{be}'),
523 ("frasl", '\u{2044}'),
524 ("gt", '\u{3e}'),
525 ("hellip", '\u{2026}'),
526 ("iacute", '\u{ed}'),
527 ("icirc", '\u{ee}'),
528 ("iexcl", '\u{a1}'),
529 ("igrave", '\u{ec}'),
530 ("iquest", '\u{bf}'),
531 ("iuml", '\u{ef}'),
532 ("laquo", '\u{ab}'),
533 ("ldquo", '\u{201c}'),
534 ("lrm", '\u{200e}'),
535 ("lsaquo", '\u{2039}'),
536 ("lsquo", '\u{2018}'),
537 ("lt", '\u{3c}'),
538 ("macr", '\u{af}'),
539 ("mdash", '\u{2014}'),
540 ("micro", '\u{b5}'),
541 ("middot", '\u{b7}'),
542 ("minus", '\u{2212}'),
543 ("nbsp", '\u{a0}'),
544 ("ndash", '\u{2013}'),
545 ("not", '\u{ac}'),
546 ("ntilde", '\u{f1}'),
547 ("oacute", '\u{f3}'),
548 ("ocirc", '\u{f4}'),
549 ("oelig", '\u{153}'),
550 ("ograve", '\u{f2}'),
551 ("oline", '\u{203e}'),
552 ("ordf", '\u{aa}'),
553 ("ordm", '\u{ba}'),
554 ("oslash", '\u{f8}'),
555 ("otilde", '\u{f5}'),
556 ("ouml", '\u{f6}'),
557 ("para", '\u{b6}'),
558 ("permil", '\u{2030}'),
559 ("plusmn", '\u{b1}'),
560 ("pound", '\u{a3}'),
561 ("prime", '\u{2032}'),
562 ("quot", '\u{22}'),
563 ("raquo", '\u{bb}'),
564 ("rdquo", '\u{201d}'),
565 ("reg", '\u{ae}'),
566 ("rlm", '\u{200f}'),
567 ("rsaquo", '\u{203a}'),
568 ("rsquo", '\u{2019}'),
569 ("sbquo", '\u{201a}'),
570 ("scaron", '\u{161}'),
571 ("sect", '\u{a7}'),
572 ("shy", '\u{ad}'),
573 ("sup1", '\u{b9}'),
574 ("sup2", '\u{b2}'),
575 ("sup3", '\u{b3}'),
576 ("szlig", '\u{df}'),
577 ("thinsp", '\u{2009}'),
578 ("thorn", '\u{fe}'),
579 ("tilde", '\u{2dc}'),
580 ("times", '\u{d7}'),
581 ("trade", '\u{2122}'),
582 ("uacute", '\u{fa}'),
583 ("ucirc", '\u{fb}'),
584 ("ugrave", '\u{f9}'),
585 ("uml", '\u{a8}'),
586 ("uuml", '\u{fc}'),
587 ("yacute", '\u{fd}'),
588 ("yen", '\u{a5}'),
589 ("yuml", '\u{ff}'),
590 ("zwj", '\u{200d}'),
591 ("zwnj", '\u{200c}'),
592];
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597
598 #[test]
601 fn entities_decode_and_the_raw_reference_never_ships() {
602 let (text, _) =
603 strip_html(b"<p>a & b © <tag> €9 &bogus; x</p>").unwrap();
604 assert_eq!(text, "a & b \u{a9} <tag> \u{20ac}9 &bogus; x\n");
605 assert!(!text.contains("&"));
606 let (control, _) = strip_html(b"<p>&amp;</p>").unwrap();
610 assert!(control.contains("&"));
611 }
612
613 #[test]
616 fn suppressed_element_text_never_reaches_the_capture() {
617 let html = b"<p>keep</p><script>var leak = 'scriptleak';</script>\
618 <style>.styleleak{}</style><noscript>noscriptleak</noscript>\
619 <template>templateleak</template><script>runs to eof scriptleaktwo";
620 let (text, _) = strip_html(html).unwrap();
621 assert!(text.contains("keep"));
622 for leak in [
623 "scriptleak",
624 "styleleak",
625 "noscriptleak",
626 "templateleak",
627 "scriptleaktwo",
628 ] {
629 assert!(!text.contains(leak), "{leak} leaked into {text:?}");
630 }
631 let (control, _) = strip_html(b"<p>var leak = 'scriptleak';</p>").unwrap();
634 assert!(control.contains("scriptleak"));
635 }
636
637 #[test]
640 fn implied_closures_break_lines_deterministically() {
641 let source = b"<ul><li>one<li>two<li>three</ul><p>para";
642 let (first, _) = strip_html(source).unwrap();
643 let (second, _) = strip_html(source).unwrap();
644 assert_eq!(first, second);
645 assert!(first.contains("one\ntwo\nthree"), "{first:?}");
646 assert!(first.contains("para"));
647 let (control, _) = strip_html(b"<p>one two three</p>").unwrap();
650 assert_eq!(control, "one two three\n");
651 }
652
653 #[test]
656 fn sectioning_containers_break_instead_of_concatenating() {
657 let (text, _) = strip_html(b"<article>alpha</article><section>beta</section>").unwrap();
658 assert_eq!(
659 text,
660 "alpha
661
662beta
663"
664 );
665 assert!(!text.contains("alphabeta"));
666 let (more, _) = strip_html(
667 b"<header>top</header><nav>menu</nav><main>body</main><footer>legal</footer>",
668 )
669 .unwrap();
670 assert_eq!(
671 more,
672 "top
673
674menu
675
676body
677
678legal
679"
680 );
681 }
682
683 #[test]
687 fn dense_short_blocks_fail_closed_on_the_working_budget() {
688 let page = "<p>x</p>".repeat(64);
689 let err = strip_html_with_budget(page.as_bytes(), 100).unwrap_err();
691 assert_eq!(err.code, "resource_limit");
692 assert!(err.message.contains("byte ceiling"), "{}", err.message);
693 let (text, _) = strip_html_with_budget(page.as_bytes(), 4096).unwrap();
696 assert_eq!(text, vec!["x"; 64].join("\n\n") + "\n");
697 }
698
699 #[test]
700 fn title_is_the_first_line_and_head_content_drops() {
701 let html = b"<html><head><meta charset=\"utf-8\"><title>Q3 Plan</title>\
702 <style>.x{}</style></head><body><p>body text</p></body></html>";
703 let (text, _) = strip_html(html).unwrap();
704 assert_eq!(text, "Q3 Plan\nbody text\n");
705 }
706
707 #[test]
708 fn an_unclosed_head_does_not_swallow_the_body() {
709 let (text, _) =
710 strip_html(b"<html><head><title>T</title><p>after an unclosed head").unwrap();
711 assert_eq!(text, "T\nafter an unclosed head\n");
712 }
713
714 #[test]
715 fn paragraphs_separate_with_one_blank_line() {
716 let (text, _) =
717 strip_html(b"<p>first</p>\n\n\n<p>second</p><div><div><p>third</p></div></div>")
718 .unwrap();
719 assert_eq!(text, "first\n\nsecond\n\nthird\n");
720 }
721
722 #[test]
723 fn table_cells_join_with_tabs_within_a_row() {
724 let (text, _) = strip_html(
725 b"<table><tr><td>a</td><td>b</td></tr><tr><td>c</td><td>d</td></tr></table>",
726 )
727 .unwrap();
728 assert!(text.contains("a\tb"), "{text:?}");
729 assert!(text.contains("c\td"), "{text:?}");
730 }
731
732 #[test]
733 fn whitespace_collapses_and_inline_markup_flows() {
734 let (text, _) = strip_html(b"<p>a\n lot\t of <b>bold</b>\nspace</p>").unwrap();
735 assert_eq!(text, "a lot of bold space\n");
736 }
737
738 #[test]
739 fn invalid_utf8_fails_closed() {
740 let err = strip_html(b"<p>ok \xff</p>").unwrap_err();
741 assert_eq!(err.code, "invalid_utf8");
742 }
743
744 #[test]
745 fn a_page_with_no_visible_text_is_suspiciously_empty() {
746 let err = HtmlStrip
747 .convert(b"<script>var x = 1;</script>", "html")
748 .unwrap_err();
749 assert_eq!(err.code, "empty_output");
750 }
751}