1#![deny(
29 clippy::unwrap_used,
30 clippy::expect_used,
31 clippy::indexing_slicing,
32 clippy::panic,
33 clippy::unreachable
34)]
35
36use std::collections::HashMap;
37
38use crate::pdftext::MAX_CHARS;
39
40pub const MAX_HTML_BYTES: u64 = 16 * 1024 * 1024;
42
43pub const MAX_DEPTH: usize = 256;
45
46const MAX_ATTR_CHARS: usize = 1024;
48
49const MAX_NAME_CHARS: usize = 32;
51
52pub fn is_html(filename: &str) -> bool {
54 filename.rsplit_once('.').is_some_and(|(stem, ext)| {
55 !stem.is_empty() && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
56 })
57}
58
59pub fn decode(bytes: &[u8], meta: bool) -> String {
70 if let Some((encoding, bom)) = encoding_rs::Encoding::for_bom(bytes) {
71 let body = bytes.get(bom..).unwrap_or_default();
72 return encoding.decode_without_bom_handling(body).0.into_owned();
73 }
74 if meta {
75 if let Some(encoding) = declared_charset(bytes) {
76 return encoding.decode_without_bom_handling(bytes).0.into_owned();
77 }
78 }
79 match std::str::from_utf8(bytes) {
80 Ok(text) => text.to_string(),
81 Err(e) if e.error_len().is_none() => String::from_utf8_lossy(bytes).into_owned(),
83 Err(_) if mostly_utf8(bytes) => String::from_utf8_lossy(bytes).into_owned(),
84 Err(_) => encoding_rs::WINDOWS_1252
85 .decode_without_bom_handling(bytes)
86 .0
87 .into_owned(),
88 }
89}
90
91fn mostly_utf8(bytes: &[u8]) -> bool {
95 let mut multibyte = 0usize;
96 let mut invalid = 0usize;
97 for chunk in bytes.utf8_chunks() {
98 multibyte += chunk.valid().chars().filter(|c| !c.is_ascii()).count();
99 invalid += usize::from(!chunk.invalid().is_empty());
100 }
101 multibyte > 0 && invalid <= multibyte
102}
103
104fn declared_charset(bytes: &[u8]) -> Option<&'static encoding_rs::Encoding> {
108 let head = bytes.get(..bytes.len().min(1024)).unwrap_or_default();
109 let lower: Vec<u8> = head.iter().map(u8::to_ascii_lowercase).collect();
110 let mut from = 0;
111 while let Some(at) = find(lower.get(from..)?, b"<meta") {
112 let start = from + at + 5;
113 let rest = lower.get(start..)?;
114 let end = rest.iter().position(|b| *b == b'>').unwrap_or(rest.len());
115 let tag = rest.get(..end)?;
116 if let Some(c) = find(tag, b"charset") {
117 let after = tag.get(c + 7..)?;
118 let after = trim_ascii_start(after);
119 if let Some(after) = after.strip_prefix(b"=") {
120 let after = trim_ascii_start(after);
121 let after = after
122 .strip_prefix(b"\"")
123 .or_else(|| after.strip_prefix(b"'"))
124 .unwrap_or(after);
125 let label: Vec<u8> = after
126 .iter()
127 .copied()
128 .take_while(|b| {
129 b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b':' | b'.')
130 })
131 .take(40)
132 .collect();
133 if let Some(encoding) = encoding_rs::Encoding::for_label(&label) {
134 return Some(
135 if encoding == encoding_rs::UTF_16LE || encoding == encoding_rs::UTF_16BE {
136 encoding_rs::UTF_8
137 } else if encoding == encoding_rs::X_USER_DEFINED {
138 encoding_rs::WINDOWS_1252
139 } else {
140 encoding
141 },
142 );
143 }
144 }
145 }
146 from = start;
147 }
148 None
149}
150
151fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
152 if needle.is_empty() {
153 return Some(0);
154 }
155 hay.windows(needle.len()).position(|w| w == needle)
156}
157
158fn trim_ascii_start(b: &[u8]) -> &[u8] {
159 let n = b.iter().take_while(|c| c.is_ascii_whitespace()).count();
160 b.get(n..).unwrap_or_default()
161}
162
163fn looks_binary(bytes: &[u8]) -> bool {
166 let head = bytes.get(..bytes.len().min(4096)).unwrap_or_default();
167 if head.is_empty() || encoding_rs::Encoding::for_bom(head).is_some() {
168 return false;
169 }
170 let control = head
171 .iter()
172 .filter(|b| **b < 0x20 && !matches!(b, b'\t' | b'\n' | b'\r' | 0x0C))
173 .count();
174 control >= 16 && control * 10 > head.len()
175}
176
177const ENTITIES: &[(&str, &str)] = &[
184 ("amp", "&"),
185 ("lt", "<"),
186 ("gt", ">"),
187 ("quot", "\""),
188 ("apos", "'"),
189 ("nbsp", " "),
190 ("ensp", " "),
191 ("emsp", " "),
192 ("thinsp", " "),
193 ("shy", ""),
194 ("zwnj", ""),
195 ("zwj", ""),
196 ("deg", "°"),
197 ("plusmn", "±"),
198 ("micro", "µ"),
199 ("times", "×"),
200 ("divide", "÷"),
201 ("middot", "·"),
202 ("minus", "−"),
203 ("sup1", "¹"),
204 ("sup2", "²"),
205 ("sup3", "³"),
206 ("frac12", "½"),
207 ("frac14", "¼"),
208 ("frac34", "¾"),
209 ("permil", "‰"),
210 ("percnt", "%"),
211 ("le", "≤"),
212 ("ge", "≥"),
213 ("ne", "≠"),
214 ("asymp", "≈"),
215 ("approx", "≈"),
216 ("infin", "∞"),
217 ("prime", "′"),
218 ("Prime", "″"),
219 ("radic", "√"),
220 ("sum", "∑"),
221 ("part", "∂"),
222 ("nabla", "∇"),
223 ("int", "∫"),
224 ("larr", "←"),
225 ("rarr", "→"),
226 ("uarr", "↑"),
227 ("darr", "↓"),
228 ("harr", "↔"),
229 ("copy", "©"),
230 ("reg", "®"),
231 ("trade", "™"),
232 ("sect", "§"),
233 ("para", "¶"),
234 ("hellip", "…"),
235 ("ndash", "–"),
236 ("mdash", "—"),
237 ("lsquo", "‘"),
238 ("rsquo", "’"),
239 ("sbquo", "‚"),
240 ("ldquo", "“"),
241 ("rdquo", "”"),
242 ("bdquo", "„"),
243 ("laquo", "«"),
244 ("raquo", "»"),
245 ("bull", "•"),
246 ("euro", "€"),
247 ("pound", "£"),
248 ("yen", "¥"),
249 ("cent", "¢"),
250 ("iexcl", "¡"),
251 ("iquest", "¿"),
252 ("ordm", "º"),
253 ("ordf", "ª"),
254 ("Auml", "Ä"),
255 ("Ouml", "Ö"),
256 ("Uuml", "Ü"),
257 ("auml", "ä"),
258 ("ouml", "ö"),
259 ("uuml", "ü"),
260 ("szlig", "ß"),
261 ("Aring", "Å"),
262 ("aring", "å"),
263 ("AElig", "Æ"),
264 ("aelig", "æ"),
265 ("Oslash", "Ø"),
266 ("oslash", "ø"),
267 ("Ccedil", "Ç"),
268 ("ccedil", "ç"),
269 ("Ntilde", "Ñ"),
270 ("ntilde", "ñ"),
271 ("Eacute", "É"),
272 ("eacute", "é"),
273 ("Egrave", "È"),
274 ("egrave", "è"),
275 ("Ecirc", "Ê"),
276 ("ecirc", "ê"),
277 ("Aacute", "Á"),
278 ("aacute", "á"),
279 ("Agrave", "À"),
280 ("agrave", "à"),
281 ("Acirc", "Â"),
282 ("acirc", "â"),
283 ("Iacute", "Í"),
284 ("iacute", "í"),
285 ("Oacute", "Ó"),
286 ("oacute", "ó"),
287 ("Ocirc", "Ô"),
288 ("ocirc", "ô"),
289 ("Uacute", "Ú"),
290 ("uacute", "ú"),
291 ("Alpha", "Α"),
292 ("Beta", "Β"),
293 ("Gamma", "Γ"),
294 ("Delta", "Δ"),
295 ("Theta", "Θ"),
296 ("Lambda", "Λ"),
297 ("Pi", "Π"),
298 ("Sigma", "Σ"),
299 ("Phi", "Φ"),
300 ("Psi", "Ψ"),
301 ("Omega", "Ω"),
302 ("alpha", "α"),
303 ("beta", "β"),
304 ("gamma", "γ"),
305 ("delta", "δ"),
306 ("epsilon", "ε"),
307 ("zeta", "ζ"),
308 ("eta", "η"),
309 ("theta", "θ"),
310 ("kappa", "κ"),
311 ("lambda", "λ"),
312 ("mu", "μ"),
313 ("nu", "ν"),
314 ("xi", "ξ"),
315 ("pi", "π"),
316 ("rho", "ρ"),
317 ("sigma", "σ"),
318 ("tau", "τ"),
319 ("phi", "φ"),
320 ("chi", "χ"),
321 ("psi", "ψ"),
322 ("omega", "ω"),
323];
324
325const WITHOUT_SEMICOLON: &[&str] = &["amp", "lt", "gt", "quot", "nbsp", "deg", "copy", "reg"];
327
328fn entity(rest: &str) -> Option<(String, usize)> {
331 let body = rest.strip_prefix('&')?;
332 if let Some(num) = body.strip_prefix('#') {
333 let (hex, digits_at) = match num.strip_prefix(['x', 'X']) {
334 Some(_) => (true, 1),
335 None => (false, 0),
336 };
337 let digits: String = num
338 .get(digits_at..)?
339 .chars()
340 .take_while(|c| {
341 if hex {
342 c.is_ascii_hexdigit()
343 } else {
344 c.is_ascii_digit()
345 }
346 })
347 .take(16)
348 .collect();
349 if digits.is_empty() {
350 return None;
351 }
352 let mut used = 2 + digits_at + digits.len();
353 if num.get(digits_at + digits.len()..)?.starts_with(';') {
354 used += 1;
355 }
356 let code = u32::from_str_radix(&digits, if hex { 16 } else { 10 }).unwrap_or(u32::MAX);
357 return Some((numeric_char(code).to_string(), used));
358 }
359 let name: String = body
360 .chars()
361 .take_while(char::is_ascii_alphanumeric)
362 .take(MAX_NAME_CHARS)
363 .collect();
364 if name.is_empty() {
365 return None;
366 }
367 let semicolon = body.get(name.len()..)?.starts_with(';');
368 if !semicolon && !WITHOUT_SEMICOLON.contains(&name.as_str()) {
369 return None;
370 }
371 let (_, text) = ENTITIES.iter().find(|(n, _)| *n == name)?;
372 Some((text.to_string(), 1 + name.len() + usize::from(semicolon)))
373}
374
375fn numeric_char(code: u32) -> char {
379 if (0x80..=0x9F).contains(&code) {
380 let byte = [u8::try_from(code).unwrap_or(b'?')];
381 return encoding_rs::WINDOWS_1252
382 .decode_without_bom_handling(&byte)
383 .0
384 .chars()
385 .next()
386 .unwrap_or('\u{FFFD}');
387 }
388 match char::from_u32(code) {
389 Some('\0') | None => '\u{FFFD}',
390 Some(c) => c,
391 }
392}
393
394fn decode_entities(text: &str) -> String {
396 let mut out = String::with_capacity(text.len());
397 let mut rest = text;
398 while let Some(at) = rest.find('&') {
399 out.push_str(rest.get(..at).unwrap_or_default());
400 let tail = rest.get(at..).unwrap_or_default();
401 match entity(tail) {
402 Some((decoded, used)) => {
403 out.push_str(&decoded);
404 rest = tail.get(used..).unwrap_or_default();
405 }
406 None => {
407 out.push('&');
408 rest = tail.get(1..).unwrap_or_default();
409 }
410 }
411 }
412 out.push_str(rest);
413 out
414}
415
416fn skips_content(name: &str) -> bool {
422 matches!(
423 name,
424 "noscript" | "template" | "svg" | "canvas" | "math" | "object" | "select" | "datalist"
425 )
426}
427
428fn is_raw_skipped(name: &str) -> bool {
431 matches!(
432 name,
433 "script" | "style" | "iframe" | "noembed" | "noframes" | "xmp"
434 )
435}
436
437fn is_void(name: &str) -> bool {
439 matches!(
440 name,
441 "area"
442 | "base"
443 | "br"
444 | "col"
445 | "embed"
446 | "hr"
447 | "img"
448 | "input"
449 | "link"
450 | "meta"
451 | "param"
452 | "source"
453 | "track"
454 | "wbr"
455 | "keygen"
456 | "image"
457 )
458}
459
460fn is_block(name: &str) -> bool {
462 matches!(
463 name,
464 "p" | "div"
465 | "h1"
466 | "h2"
467 | "h3"
468 | "h4"
469 | "h5"
470 | "h6"
471 | "ul"
472 | "ol"
473 | "li"
474 | "dl"
475 | "dt"
476 | "dd"
477 | "pre"
478 | "listing"
479 | "blockquote"
480 | "section"
481 | "article"
482 | "header"
483 | "footer"
484 | "nav"
485 | "aside"
486 | "main"
487 | "figure"
488 | "figcaption"
489 | "address"
490 | "form"
491 | "fieldset"
492 | "legend"
493 | "details"
494 | "summary"
495 | "center"
496 | "caption"
497 | "body"
498 | "html"
499 | "title"
500 | "textarea"
501 | "hr"
502 | "br"
503 )
504}
505
506fn closes_itself(name: &str) -> bool {
508 matches!(
509 name,
510 "p" | "li" | "dt" | "dd" | "option" | "td" | "th" | "tr"
511 )
512}
513
514struct Open {
516 name: String,
517 silent: bool,
519 pre: bool,
520}
521
522#[derive(Clone, Copy, PartialEq, Eq)]
524enum Ctx {
525 Table { cells: usize },
526 Cell,
527}
528
529struct Tag {
531 name: String,
532 end: bool,
533 self_closing: bool,
534 hidden: bool,
535 alt: Option<String>,
536}
537
538struct Out {
540 text: String,
541 chars: usize,
542 pending_space: bool,
543}
544
545impl Out {
546 fn full(&self) -> bool {
547 self.chars > MAX_CHARS
548 }
549
550 fn push_char(&mut self, c: char) {
551 self.text.push(c);
552 self.chars += 1;
553 }
554
555 fn text(&mut self, s: &str, pre: bool) {
558 for c in s.chars() {
559 if self.full() {
560 return;
561 }
562 let c = if c == '\u{A0}' { ' ' } else { c };
563 if pre {
564 if c == '\r' {
565 continue;
566 }
567 self.pending_space = false;
568 self.push_char(c);
569 } else if c.is_whitespace() {
570 self.pending_space = true;
571 } else {
572 if self.pending_space && !self.text.is_empty() && !self.text.ends_with(['\n', ' '])
573 {
574 self.push_char(' ');
575 }
576 self.pending_space = false;
577 self.push_char(c);
578 }
579 }
580 }
581
582 fn trim_end_spaces(&mut self) {
583 while self.text.ends_with(' ') {
584 self.text.pop();
585 self.chars = self.chars.saturating_sub(1);
586 }
587 }
588
589 fn newline(&mut self) {
590 self.pending_space = false;
591 self.trim_end_spaces();
592 if !self.text.is_empty() && !self.text.ends_with('\n') {
593 self.push_char('\n');
594 }
595 }
596
597 fn blank_line(&mut self) {
598 self.newline();
599 if !self.text.is_empty() && !self.text.ends_with("\n\n") {
600 self.push_char('\n');
601 }
602 }
603
604 fn space(&mut self) {
605 self.pending_space = true;
606 }
607
608 fn line_has_text(&self) -> bool {
610 !self.text.is_empty() && !self.text.ends_with('\n')
611 }
612
613 fn separator(&mut self) {
616 self.trim_end_spaces();
617 self.pending_space = false;
618 let sep = if self.line_has_text() { " | " } else { "| " };
619 for c in sep.chars() {
620 self.push_char(c);
621 }
622 }
623}
624
625struct Reader {
627 out: Out,
628 stack: Vec<Open>,
629 open_by_name: HashMap<String, usize>,
632 silent: usize,
635 silent_to_end: bool,
639 pre: usize,
640 tables: Vec<Ctx>,
641}
642
643impl Reader {
644 fn new() -> Self {
645 Reader {
646 out: Out {
647 text: String::new(),
648 chars: 0,
649 pending_space: false,
650 },
651 stack: Vec::new(),
652 open_by_name: HashMap::new(),
653 silent: 0,
654 silent_to_end: false,
655 pre: 0,
656 tables: vec![Ctx::Table { cells: 0 }],
657 }
658 }
659
660 fn is_silent(&self) -> bool {
661 self.silent > 0 || self.silent_to_end
662 }
663
664 fn in_cell(&self) -> bool {
665 self.tables.last() == Some(&Ctx::Cell)
666 }
667
668 fn close_same(&mut self, name: &str) {
671 if closes_itself(name) && self.stack.last().is_some_and(|o| o.name == name) {
672 self.pop_to(name);
673 }
674 }
675
676 fn push(&mut self, name: &str, silent: bool) {
677 self.close_same(name);
678 if self.stack.len() >= MAX_DEPTH {
679 if silent || self.silent > 0 {
681 self.silent_to_end = true;
682 }
683 return;
684 }
685 let pre = matches!(name, "pre" | "listing" | "textarea");
686 self.silent += usize::from(silent);
687 self.pre += usize::from(pre);
688 *self.open_by_name.entry(name.to_string()).or_insert(0) += 1;
689 self.stack.push(Open {
690 name: name.to_string(),
691 silent,
692 pre,
693 });
694 }
695
696 fn pop_to(&mut self, name: &str) {
701 if self.open_by_name.get(name).copied().unwrap_or(0) == 0 {
702 return;
703 }
704 if self.silent > 0 {
705 let inside = self
706 .stack
707 .iter()
708 .rev()
709 .take(self.silent)
710 .any(|o| o.name == name);
711 if !inside {
712 return;
713 }
714 }
715 while let Some(open) = self.stack.pop() {
716 self.silent = self.silent.saturating_sub(usize::from(open.silent));
717 self.pre = self.pre.saturating_sub(usize::from(open.pre));
718 if let Some(n) = self.open_by_name.get_mut(&open.name) {
719 *n = n.saturating_sub(1);
720 }
721 if open.name == name {
722 break;
723 }
724 }
725 }
726
727 fn close_cell(&mut self) {
728 if self.in_cell() {
729 self.tables.pop();
730 }
731 }
732
733 fn start_cell(&mut self) {
737 self.close_cell();
738 if let Some(Ctx::Table { cells }) = self.tables.last_mut() {
739 if *cells > 0 {
740 self.out.separator();
741 } else {
742 self.out.space();
743 }
744 *cells += 1;
745 }
746 if self.tables.len() < MAX_DEPTH {
747 self.tables.push(Ctx::Cell);
748 }
749 }
750
751 fn start_row(&mut self) {
752 self.close_cell();
753 if let Some(Ctx::Table { cells }) = self.tables.last_mut() {
754 *cells = 0;
755 }
756 self.out.newline();
757 }
758
759 fn tag(&mut self, tag: &Tag) {
760 let name = tag.name.as_str();
761 if self.is_silent() {
762 if self.silent_to_end {
763 return;
764 }
765 if tag.end {
767 self.pop_to(name);
768 return;
769 }
770 if is_void(name) || tag.self_closing {
771 return;
772 }
773 self.close_same(name);
776 if self.is_silent() {
777 self.push(name, true);
778 return;
779 }
780 }
781 if tag.end {
782 match name {
783 "td" | "th" => self.close_cell(),
784 "tr" => {
785 self.close_cell();
786 self.out.newline();
787 }
788 "table" => {
789 while let Some(ctx) = self.tables.pop() {
790 if self.tables.is_empty() {
791 self.tables.push(ctx);
793 break;
794 }
795 if matches!(ctx, Ctx::Table { .. }) {
796 break;
797 }
798 }
799 self.out.blank_line();
800 }
801 _ => {}
802 }
803 self.pop_to(name);
804 if is_block(name) && name != "table" {
805 self.block_break(name);
806 }
807 return;
808 }
809 match name {
811 "td" | "th" => self.start_cell(),
812 "tr" => self.start_row(),
813 "table" => {
814 self.out.blank_line();
815 if self.tables.len() < MAX_DEPTH {
816 self.tables.push(Ctx::Table { cells: 0 });
817 }
818 }
819 _ if is_block(name) => self.block_break(name),
820 _ => {}
821 }
822 if name == "img" && !tag.hidden {
823 if let Some(alt) = &tag.alt {
824 let pre = self.pre > 0;
825 self.out.space();
826 self.out.text(&decode_entities(alt), pre);
827 self.out.space();
828 }
829 }
830 if !is_void(name) && !tag.self_closing {
831 self.push(name, tag.hidden || skips_content(name));
832 }
833 }
834
835 fn block_break(&mut self, name: &str) {
838 if self.in_cell() {
839 self.out.space();
840 } else if matches!(name, "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "pre") {
841 self.out.blank_line();
842 } else {
843 self.out.newline();
844 }
845 }
846
847 fn text(&mut self, s: &str) {
848 if !self.is_silent() && !s.is_empty() {
849 let pre = self.pre > 0;
850 self.out.text(&decode_entities(s), pre);
851 }
852 }
853}
854
855fn read_tag(s: &str) -> Option<(Tag, usize)> {
858 let mut chars = s.char_indices().peekable();
859 let end = matches!(chars.peek(), Some((_, '/')));
860 if end {
861 chars.next();
862 }
863 if !chars.peek().is_some_and(|(_, c)| c.is_ascii_alphabetic()) {
864 return None;
865 }
866 let mut name = String::new();
867 while let Some((_, c)) = chars.peek().copied() {
868 if c.is_whitespace() || c == '/' || c == '>' {
869 break;
870 }
871 if name.len() < MAX_NAME_CHARS {
872 name.push(c.to_ascii_lowercase());
873 }
874 chars.next();
875 }
876 let mut tag = Tag {
877 name,
878 end,
879 self_closing: false,
880 hidden: false,
881 alt: None,
882 };
883 loop {
885 while chars.peek().is_some_and(|(_, c)| c.is_whitespace()) {
886 chars.next();
887 }
888 let Some((at, c)) = chars.next() else {
889 return Some((tag, s.len()));
890 };
891 match c {
892 '>' => return Some((tag, at + 1)),
893 '/' => {
894 if matches!(chars.peek(), Some((_, '>'))) {
895 tag.self_closing = true;
896 }
897 continue;
898 }
899 _ => {}
900 }
901 let mut attr = String::new();
902 attr.push(c.to_ascii_lowercase());
903 while let Some((_, c)) = chars.peek().copied() {
904 if c.is_whitespace() || c == '=' || c == '>' || c == '/' {
905 break;
906 }
907 if attr.len() < MAX_NAME_CHARS {
908 attr.push(c.to_ascii_lowercase());
909 }
910 chars.next();
911 }
912 while chars.peek().is_some_and(|(_, c)| c.is_whitespace()) {
913 chars.next();
914 }
915 let keep = matches!(attr.as_str(), "alt" | "style" | "aria-hidden");
916 let mut value = String::new();
917 if matches!(chars.peek(), Some((_, '='))) {
918 chars.next();
919 while chars.peek().is_some_and(|(_, c)| c.is_whitespace()) {
920 chars.next();
921 }
922 let quote = match chars.peek() {
923 Some((_, q @ ('"' | '\''))) => {
924 let q = *q;
925 chars.next();
926 Some(q)
927 }
928 _ => None,
929 };
930 while let Some((_, c)) = chars.peek().copied() {
932 let done = match quote {
933 Some(q) => c == q,
934 None => c.is_whitespace() || c == '>',
935 };
936 if done {
937 if quote.is_some() {
938 chars.next();
939 }
940 break;
941 }
942 if keep && value.len() < MAX_ATTR_CHARS {
943 value.push(c);
944 }
945 chars.next();
946 }
947 }
948 match attr.as_str() {
949 "hidden" => tag.hidden = true,
950 "aria-hidden" if value.trim().eq_ignore_ascii_case("true") => tag.hidden = true,
951 "style" => {
952 let style: String = value
953 .chars()
954 .filter(|c| !c.is_whitespace())
955 .collect::<String>()
956 .to_ascii_lowercase();
957 if style.contains("display:none") || style.contains("visibility:hidden") {
958 tag.hidden = true;
959 }
960 }
961 "alt" => tag.alt = Some(value),
962 _ => {}
963 }
964 }
965}
966
967fn find_end_tag(s: &str, name: &str) -> Option<usize> {
969 let needle: Vec<u8> = format!("</{name}").into_bytes();
970 s.as_bytes()
971 .windows(needle.len())
972 .position(|w| w.eq_ignore_ascii_case(&needle))
973}
974
975pub fn to_text(html: &str) -> String {
977 let mut r = Reader::new();
978 let mut rest = html;
979 while !rest.is_empty() && !r.out.full() {
980 let Some(lt) = rest.find('<') else {
981 r.text(rest);
982 break;
983 };
984 r.text(rest.get(..lt).unwrap_or_default());
985 rest = rest.get(lt..).unwrap_or_default();
986 let after = rest.get(1..).unwrap_or_default();
987 if let Some(comment) = after.strip_prefix("!--") {
988 rest = match comment.find("-->") {
989 Some(end) => comment.get(end + 3..).unwrap_or_default(),
990 None => "",
991 };
992 continue;
993 }
994 if after.starts_with(['!', '?']) {
995 rest = match after.find('>') {
997 Some(end) => after.get(end + 1..).unwrap_or_default(),
998 None => "",
999 };
1000 continue;
1001 }
1002 let Some((tag, used)) = read_tag(after) else {
1003 r.text("<");
1004 rest = after;
1005 continue;
1006 };
1007 rest = after.get(used..).unwrap_or_default();
1008 if tag.end || tag.self_closing {
1009 r.tag(&tag);
1010 continue;
1011 }
1012 if is_raw_skipped(&tag.name) {
1013 rest = match find_end_tag(rest, &tag.name) {
1014 Some(end) => rest.get(end..).unwrap_or_default(),
1015 None => "",
1016 };
1017 continue;
1018 }
1019 if matches!(tag.name.as_str(), "title" | "textarea") {
1020 let end = find_end_tag(rest, &tag.name).unwrap_or(rest.len());
1022 let content = rest.get(..end).unwrap_or_default();
1023 rest = rest.get(end..).unwrap_or_default();
1024 r.tag(&tag);
1025 r.text(content);
1026 continue;
1027 }
1028 r.tag(&tag);
1029 }
1030 r.out.text
1031}
1032
1033pub fn extract(bytes: &[u8]) -> anyhow::Result<String> {
1037 if looks_binary(bytes) {
1038 return Ok(String::new());
1039 }
1040 std::panic::catch_unwind(|| crate::pdftext::tidy(&to_text(&decode(bytes, true))))
1041 .map_err(|_| anyhow::anyhow!("the HTML file could not be read"))
1042}
1043
1044pub fn run_job(
1049 input: impl std::io::Read,
1050 mut output: impl std::io::Write,
1051 max_input_bytes: u64,
1052) -> anyhow::Result<usize> {
1053 use std::io::Read as _;
1054 let mut bytes = Vec::new();
1055 input.take(max_input_bytes).read_to_end(&mut bytes)?;
1056 let text = extract(&bytes)?;
1057 output.write_all(text.as_bytes())?;
1058 output.flush()?;
1059 Ok(text.chars().count())
1060}
1061
1062pub async fn extract_file(file: tokio::fs::File) -> String {
1065 crate::pdftext::extract_isolated(file, crate::pdftext::TextFormat::Html).await
1066}
1067
1068#[cfg(test)]
1069#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
1070mod tests {
1071 use super::*;
1072
1073 fn text(html: &str) -> String {
1074 extract(html.as_bytes()).unwrap()
1075 }
1076
1077 #[test]
1078 fn table_rows_become_lines_with_separators() {
1079 let t = text(
1080 "<html><head><title>Bracket static</title></head><body>\
1081 <h1>Results</h1><table>\n<tr><th>Result</th><th>Value</th><th>Unit</th></tr>\n\
1082 <tr><td>Maximum von Mises stress</td><td>152.4</td><td>MPa</td></tr>\n\
1083 <tr><td><p>Max. displacement</p></td><td>-1.2e-3</td><td>m</td></tr>\
1084 <tr><td>Min. safety factor<td>+2.35<td>–</tr></table></body></html>",
1085 );
1086 assert!(t.contains("Result | Value | Unit"), "{t}");
1087 assert!(
1088 t.contains("\nMaximum von Mises stress | 152.4 | MPa\n"),
1089 "{t}"
1090 );
1091 assert!(t.contains("Max. displacement | -1.2e-3 | m"), "{t}");
1092 assert!(t.contains("Min. safety factor | +2.35 | –"), "{t}");
1093 assert!(t.starts_with("Bracket static"), "the title is text: {t}");
1094 }
1095
1096 #[test]
1097 fn empty_cells_keep_their_column() {
1098 assert_eq!(
1099 text("<table><tr><td>a</td><td></td><td>b</td></tr></table>"),
1100 "a | | b"
1101 );
1102 assert_eq!(
1104 text(
1105 "<table><tr><th></th><th>Min</th><th>Max</th></tr>\
1106 <tr><td>Stress</td><td>1.0</td><td>152.4</td></tr></table>"
1107 ),
1108 "| Min | Max\nStress | 1.0 | 152.4"
1109 );
1110 assert_eq!(
1112 text("<table><tr><td></td><td> </td><td> </td><td>x</td></tr></table>"),
1113 "| | | x"
1114 );
1115 assert_eq!(
1116 text("<table><tr><td>a</td><td> \n </td><td>b</td></tr></table>"),
1117 "a | | b"
1118 );
1119 assert_eq!(
1121 text("<table><tr><td hidden>x<td>152.4</tr></table>"),
1122 "| 152.4"
1123 );
1124 }
1125
1126 #[test]
1127 fn nested_tables_keep_the_inner_rows() {
1128 let t = text(
1129 "<table><tr><td><table><tr><td>Pressure drop</td><td>388.25</td><td>Pa</td></tr>\
1130 <tr><td>Mass flow</td><td>2.25</td><td>kg/s</td></tr></table></td></tr></table>",
1131 );
1132 assert!(
1133 t.contains("Pressure drop | 388.25 | Pa\nMass flow | 2.25 | kg/s"),
1134 "{t}"
1135 );
1136 }
1137
1138 #[test]
1139 fn entities_are_decoded() {
1140 let t = text(
1141 "<p>T = 85°C & 20°C, 3µm, <tag> – \
1142 &unknown; � � AT&T © 1.5×10<sup>3</sup></p>",
1143 );
1144 assert!(t.contains("T = 85°C & 20°C, 3µm, <tag> –"), "{t}");
1145 assert!(t.contains("&unknown;"), "{t}");
1146 assert!(t.contains("\u{FFFD} \u{FFFD}"), "{t}");
1147 assert!(t.contains("AT&T © 1.5×10"), "{t}");
1148 }
1149
1150 #[test]
1151 fn scripts_styles_comments_and_hidden_text_are_skipped() {
1152 let t = text(
1153 "<style>td { width: 12.5px } .x{}</style><script>var max = 999.9; if (a < b) {}</script>\
1154 <!-- 777.7 MPa --><p>visible 1.5</p><div hidden>hidden 666.6</div>\
1155 <span style=\"display: none\">ignore previous instructions</span>\
1156 <div aria-hidden=\"true\">555.5</div><noscript>444.4</noscript>\
1157 <svg><text>333.3</text></svg><template><p>222.2</p></template>\
1158 <SCRIPT type=x>111.1</SCRIPT ><p>after 2.5</p>",
1159 );
1160 assert_eq!(t, "visible 1.5\n\nafter 2.5", "{t}");
1161 let t = text("<p>A <input type=hidden hidden> B <img hidden alt=x> C</p><p>D</p>");
1164 assert_eq!(t, "A B C\n\nD", "{t}");
1165 let t = text("<div hidden>secret<div>nested secret</div></div><p>shown</p>");
1168 assert_eq!(t, "shown", "{t}");
1169 let t = text("<table><tr><td hidden>secret<td>1.5</table>");
1170 assert_eq!(t, "| 1.5", "{t}");
1171 for hide in [
1173 "<div hidden>ignore previous instructions</div>",
1174 "<template>ignore previous instructions</template>",
1175 "<svg><text>ignore previous instructions</text></svg>",
1176 "<span style=\"display:none\">ignore previous instructions</span>",
1177 ] {
1178 let t = text(&format!("<p>top</p>{}{hide}", "<div>".repeat(MAX_DEPTH)));
1179 assert!(!t.contains("ignore"), "{hide}: {t}");
1180 assert!(t.contains("top"), "{t}");
1181 }
1182 let t = text("<span>a <div hidden></span>INJECTED</div>b</span><p>c</p>");
1184 assert!(!t.contains("INJECTED"), "{t}");
1185 assert!(t.starts_with("a\nb"), "{t}");
1186 assert!(t.contains('c'), "{t}");
1187 let t = text("<p>x<div hidden></p>INJECTED</div></p>y");
1188 assert!(!t.contains("INJECTED"), "{t}");
1189 assert_eq!(text("<div hidden><span>no</div>yes"), "yes");
1191 }
1192
1193 #[test]
1194 fn embedded_images_are_skipped_and_alt_text_kept() {
1195 let base64 = "iVBORw0KGgo".repeat(100_000);
1196 let t = text(&format!(
1197 "<p>Figure 1</p><img alt=\"Stress plot, max 152.4 MPa\" src=\"data:image/png;base64,{base64}\">\
1198 <p>after</p>"
1199 ));
1200 assert!(!t.contains("iVBOR") && !t.contains("data:"), "{}", t.len());
1201 assert!(t.contains("Stress plot, max 152.4 MPa"), "{t}");
1202 assert!(t.contains("after"), "{t}");
1203 }
1204
1205 #[test]
1206 fn charsets() {
1207 let mut cp =
1209 b"<html><head><meta charset=\"windows-1252\"></head><p>85\xB0C, 3\xB5m, \x96</p>"
1210 .to_vec();
1211 assert_eq!(text_of(&cp), "85°C, 3µm, –");
1212 cp = b"<meta http-equiv=\"Content-Type\" content=\"text/html; charset=ISO-8859-1\"><p>\xB0</p>"
1214 .to_vec();
1215 assert_eq!(text_of(&cp), "°");
1216 let mut bom = vec![0xEF, 0xBB, 0xBF];
1218 bom.extend_from_slice("<p>σ = 12 MPa</p>".as_bytes());
1219 assert_eq!(text_of(&bom), "σ = 12 MPa");
1220 let mut utf16 = vec![0xFF, 0xFE];
1222 for u in "<p>Δp 4.5</p>".encode_utf16() {
1223 utf16.extend_from_slice(&u.to_le_bytes());
1224 }
1225 assert_eq!(text_of(&utf16), "Δp 4.5");
1226 assert_eq!(text_of("<p>Düse</p>".as_bytes()), "Düse");
1228 assert_eq!(text_of(b"<p>D\xFCse</p>"), "Düse");
1229 assert_eq!(
1230 text_of(b"<meta charset=klingon><p>D\xFCse</p>"),
1231 "Düse",
1232 "unknown label"
1233 );
1234 assert_eq!(text_of("<meta charset=utf-16><p>µ</p>".as_bytes()), "µ");
1236 assert_eq!(
1239 text_of(b"<meta charset=windows-1252><p>\xC3\xBC</p>"),
1240 "ü",
1241 "UTF-8 bytes in a page declared Windows-1252"
1242 );
1243 assert_eq!(
1244 text_of(b"<meta charset='x-user-defined'><p>\xC3\xBC</p>"),
1245 "ü"
1246 );
1247 let mut long16 = vec![0xFF, 0xFE];
1250 for u in "<p>Pressure drop 4.5 bar at the nozzle inlet</p>".encode_utf16() {
1251 long16.extend_from_slice(&u.to_le_bytes());
1252 }
1253 assert_eq!(
1254 text_of(&long16),
1255 "Pressure drop 4.5 bar at the nozzle inlet"
1256 );
1257 assert_eq!(
1259 text_of(b"<!DOCTYPE html><meta name=x><META CHARSET=windows-1252><p>\xC3\xBC</p>"),
1260 "ü"
1261 );
1262 assert_eq!(
1263 text_of(b"<meta charset=\"utf-16\"><p>\xB0</p>"),
1264 "\u{FFFD}",
1265 "UTF-16 declared means UTF-8, not a guess"
1266 );
1267 let mut cut = "<p>Düse µ".as_bytes().to_vec();
1269 cut.pop();
1270 assert!(text_of(&cut).starts_with("Düse"));
1271 }
1272
1273 fn text_of(bytes: &[u8]) -> String {
1274 extract(bytes).unwrap()
1275 }
1276
1277 #[test]
1278 fn plain_decoding_has_no_meta_sniffing() {
1279 assert_eq!(
1280 decode(b"<meta charset=windows-1252>\xC3\xBC", false),
1281 "<meta charset=windows-1252>ü"
1282 );
1283 assert_eq!(decode(b"a\xFCb", false), "aüb");
1284 assert_eq!(
1287 decode(b"Temperature 20 \xC2", false),
1288 "Temperature 20 \u{FFFD}"
1289 );
1290 assert_eq!(decode(b"\xC2\xB5 a\xFC b\xFC c\xFC", false), "µ aü bü cü");
1293 assert_eq!(decode(b"\xC2\xB5 \xC2\xB0 a\xFC", false), "µ ° a\u{FFFD}");
1294 assert_eq!(decode(b"a\xFC b\xE4", false), "aü bä");
1296 }
1297
1298 #[test]
1299 fn pre_keeps_its_lines_and_breaks_end_lines() {
1300 let t = text("<pre>Node Sxx\n 12 1.5e+02\n</pre><p>a<br>b</p><ul><li>x<li>y</ul>");
1301 assert!(t.contains("Node Sxx\n 12 1.5e+02"), "{t}");
1302 assert!(t.contains("a\nb"), "{t}");
1303 assert!(t.contains("x\ny"), "{t}");
1304 }
1305
1306 #[test]
1307 fn hostile_input_is_bounded() {
1308 let deep = format!(
1310 "{}deep 1.5{}",
1311 "<div>".repeat(100_000),
1312 "</div>".repeat(100_000)
1313 );
1314 assert_eq!(text(&deep), "deep 1.5");
1315 let _ = text(&format!("{}<div hidden>x</div>", "<b>".repeat(1000)));
1317 let cells = format!("<table><tr>{}</table>", "<td>1".repeat(100_000));
1319 let t = text(&cells);
1320 assert!(t.chars().count() <= MAX_CHARS, "{}", t.len());
1321 assert!(t.starts_with("1 | 1 | 1"), "{}", &t[..20]);
1322 let _ = text(&"<table><tr><td>".repeat(50_000));
1324 for tail in [
1326 "<td",
1327 "<p class=\"x",
1328 "<!-- open",
1329 "",
1330 "&",
1331 "<",
1332 "</",
1333 "<!",
1334 "<a b='",
1335 ] {
1336 let t = text(&format!("<p>ok 2.5</p>{tail}"));
1337 assert!(t.starts_with("ok 2.5"), "{tail}: {t}");
1338 }
1339 let attr = format!(
1341 "<p title=\"{}\" style=\"{}\">x</p>",
1342 "a".repeat(5_000_000),
1343 "b".repeat(100_000)
1344 );
1345 assert_eq!(text(&attr), "x");
1346 assert_eq!(text("<p>a\0b\u{7}c</p>"), "a b c");
1348 let long = format!("<p>{}</p>", "word ".repeat(MAX_CHARS));
1350 assert_eq!(text(&long).chars().count(), MAX_CHARS);
1351 let _ = text(&"</div></span></td></tr></table>".repeat(50_000));
1353 }
1354
1355 #[test]
1356 fn empty_and_binary_files_have_no_text() {
1357 assert_eq!(text(""), "");
1358 assert_eq!(
1359 text("<html><head><style>p{}</style></head><body></body></html>"),
1360 ""
1361 );
1362 let mut binary = vec![0u8; 3000];
1363 binary.extend_from_slice(b"<p>text</p>");
1364 assert_eq!(text_of(&binary), "");
1365 let random: Vec<u8> = (0..20_000u32)
1366 .map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8)
1367 .collect();
1368 let _ = extract(&random);
1369 }
1370
1371 fn fixture(name: &str) -> String {
1373 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1374 .join("tests/fixtures/reports")
1375 .join(name);
1376 extract(&std::fs::read(path).unwrap()).unwrap()
1377 }
1378
1379 #[test]
1380 fn the_fixture_reports_read_as_rows() {
1381 let t = fixture("results-table.html");
1382 for line in [
1383 "Bracket BR-100 – static structural report",
1384 "Solver: generic FEA solver 12.1, linear static, load case LC1 nominal.",
1385 "Result | Minimum | Maximum | Unit",
1386 "Equivalent (von Mises) stress | 0.0213 | 152.4 | MPa",
1387 "Total deformation | 0 | 1.2e-3 | m",
1388 "Safety factor | 2.35 | 15 |",
1389 "Temperature | -40.5 | +85.0 | °C",
1390 "Figure 1: Equivalent stress contour",
1391 ] {
1392 assert!(t.lines().any(|l| l == line), "{line:?} in\n{t}");
1393 }
1394 assert!(!t.contains("report_files") && !t.contains("<"), "{t}");
1395
1396 let t = fixture("nested-tables.html");
1397 assert!(t.contains("Report | Value | Unit\nPressure drop | 388.25 | Pa\nMass flow inlet | 2.25 | kg/s\nMax. velocity | 14.7 | m/s"), "{t}");
1398 assert!(t.contains("Duct DF-7 Steady RANS, k-omega SST"), "{t}");
1399
1400 let t = fixture("entities.html");
1401 for line in [
1402 "Max. junction temperature | 98.6 °C",
1403 "Thermal resistance R<sub>th</sub> | 0.42 K/W",
1404 "Heat flux | 1.5×104 W/m²",
1405 "Tolerance | ±0.5 µm",
1406 "Fins & base | AlMg3 – anodised",
1407 ] {
1408 assert!(t.lines().any(|l| l == line), "{line:?} in\n{t}");
1409 }
1410
1411 let t = fixture("styled-embedded-image.html");
1412 assert!(t.contains("Mode shape 1"), "{t}");
1413 assert!(
1414 t.contains("Mode | Frequency | Unit\n1 | 42.17 | Hz\n2 | 118.9 | Hz"),
1415 "{t}"
1416 );
1417 assert!(
1418 !t.contains("px") && !t.contains("AAAA") && t.len() < 400,
1419 "{t}"
1420 );
1421
1422 let t = fixture("windows-1252.html");
1423 assert!(t.starts_with("Prüfstand PS-1"), "{t}");
1424 for line in [
1425 "Max. Temperatur | 85.5 | °C",
1426 "Spannung – Maximum | 210.0 | N/mm²",
1427 "Rauheit | 3.2 | µm",
1428 ] {
1429 assert!(t.lines().any(|l| l == line), "{line:?} in\n{t}");
1430 }
1431
1432 let t = fixture("utf8-bom.html");
1433 assert!(t.starts_with("Shaft SH-4 fatigue"), "{t:?}");
1434 assert!(
1435 t.contains("Max. σ_a | 96.3 | MPa\nDamage | 3.1e-2 | –"),
1436 "{t}"
1437 );
1438
1439 let t = fixture("scripts-comments.html");
1440 assert_eq!(
1441 t, "Valve body VB-9\n\nMax. stress | 201.7 | MPa\n\nEnd of report.",
1442 "no script, comment or hidden text"
1443 );
1444 }
1445
1446 #[test]
1447 fn literal_angle_brackets_and_names() {
1448 assert_eq!(text("<p>a < b and 1<2 & x</p>"), "a < b and 1<2 & x");
1449 assert!(
1450 is_html("Report.HTML") && is_html("r.htm") && !is_html(".html") && !is_html("html")
1451 );
1452 }
1453
1454 #[test]
1455 fn the_child_job_cuts_big_input() {
1456 let html = format!("<p>first 1.5</p>{}", "<p>more</p>".repeat(1000));
1457 let mut out = Vec::new();
1458 run_job(html.as_bytes(), &mut out, 16).unwrap();
1459 assert_eq!(String::from_utf8(out).unwrap(), "first 1.5");
1460 }
1461
1462 #[tokio::test]
1463 async fn stored_html_is_read_in_process_during_tests() {
1464 let dir = std::env::temp_dir().join(format!("tc-html-{}", uuid::Uuid::new_v4()));
1465 std::fs::create_dir_all(&dir).unwrap();
1466 let path = dir.join("report.html");
1467 std::fs::write(
1468 &path,
1469 "<table><tr><td>Mass flow</td><td>2.25</td></tr></table>",
1470 )
1471 .unwrap();
1472 assert_eq!(
1473 extract_file(tokio::fs::File::open(&path).await.unwrap()).await,
1474 "Mass flow | 2.25"
1475 );
1476 std::fs::remove_dir_all(&dir).ok();
1477 }
1478
1479 #[test]
1482 fn binary_needs_16_control_bytes_and_ten_percent() {
1483 let with_controls = |n: usize, len: usize| {
1484 let mut b = vec![0x01; n];
1485 b.resize(len, b'a');
1486 b
1487 };
1488 assert!(looks_binary(&with_controls(16, 100)));
1489 assert!(!looks_binary(&with_controls(15, 100)), "fewer than 16");
1490 assert!(!looks_binary(&with_controls(16, 160)), "exactly 10 %");
1491 assert!(looks_binary(&with_controls(16, 159)));
1492 assert!(
1493 !looks_binary(&[b'\t', b'\n', b'\r', 0x0C].repeat(20)),
1494 "white space"
1495 );
1496 assert!(!looks_binary(b""));
1497 let mut bom = vec![0xFF, 0xFE];
1498 bom.extend(std::iter::repeat_n(0u8, 100));
1499 assert!(!looks_binary(&bom), "a BOM means text");
1500 }
1501
1502 #[test]
1505 fn raw_layout() {
1506 let cases = [
1507 ("<p>a</p><p>b</p>", "a\n\nb\n\n"),
1509 (
1511 "<table><tr><th></th><th>Min</th><th>Max</th></tr>\
1512 <tr><td>p</td><td>1</td><td>2</td></tr></table><p>end</p>",
1513 "| Min | Max\np | 1 | 2\n\nend\n\n",
1514 ),
1515 ("<table><tr><td>a</td><div>b</div></tr></table>", "a\nb\n\n"),
1517 ("<table><tr><td>a</td></tr>b</table>", "a\nb\n\n"),
1519 (
1521 "<table><tr><td>a<table><tr><td>b</td></tr></table>c</td><td>d</td></tr></table>",
1522 "a\n\nb\n\nc | d\n\n",
1523 ),
1524 ("<div>a</div>b", "a\nb"),
1526 ("<pre>a b</pre>c d", "a b\n\nc d"),
1528 (
1530 "<table><tr><td>a<table><tr><td>b1</td><td>b2</td></tr></table></td></tr></table>",
1531 "a\n\nb1 | b2\n\n",
1532 ),
1533 ("<table><tr><td hidden>x<br><td>1.5</table>", "| 1.5\n\n"),
1535 ("<pre><img alt='a b'></pre>", "a b\n\n"),
1537 ("<p><img alt='a b'></p>", "a b\n\n"),
1538 ];
1539 for (html, raw) in cases {
1540 assert_eq!(to_text(html), raw, "{html}");
1541 }
1542 let html = format!(
1545 "<p>shown</p><div hidden>{}secret{}leak</div><p>after</p>",
1546 "<div>".repeat(MAX_DEPTH + 40),
1547 "</div>".repeat(MAX_DEPTH + 10),
1548 );
1549 let t = to_text(&html);
1550 assert!(
1551 t.contains("shown") && !t.contains("leak") && !t.contains("after"),
1552 "{t}"
1553 );
1554 }
1555
1556 #[test]
1558 fn tag_attributes() {
1559 assert_eq!(text("<p>a <span hidden/> b</p>"), "a b");
1561 assert_eq!(text("<p><img alt=Nozzle></p>"), "Nozzle");
1563 assert_eq!(text("<div aria-hidden=\"false\">shown</div>"), "shown");
1565 assert_eq!(text("<div aria-hidden=\" TRUE \">x</div><p>y</p>"), "y");
1566 let alt = "x".repeat(MAX_ATTR_CHARS + 500);
1568 let t = text(&format!("<p><img alt=\"{alt}\"></p>"));
1569 assert_eq!(t.chars().count(), MAX_ATTR_CHARS, "{}", t.len());
1570 }
1571
1572 #[test]
1574 fn the_text_is_capped() {
1575 let html = "<p>abcde</p>".repeat(60_000);
1577 let raw = to_text(&html).chars().count();
1579 assert_eq!(raw, MAX_CHARS + 3);
1581 let n = text(&html).chars().count();
1582 assert_eq!(n, MAX_CHARS);
1583 }
1584
1585 #[test]
1588 fn limits() {
1589 assert_eq!(MAX_HTML_BYTES, 16 * 1024 * 1024);
1590 assert_eq!(MAX_DEPTH, 256);
1591 }
1592}