pyhtml2text 0.3.0__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/CHANGELOG.md +11 -0
  2. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/Cargo.lock +1 -1
  3. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/Cargo.toml +1 -1
  4. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/PKG-INFO +8 -4
  5. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/README.md +7 -3
  6. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/pyproject.toml +1 -1
  7. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/tests/test_basic.py +17 -9
  8. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/tests/test_markdown.py +2 -0
  9. pyhtml2text-0.3.1/vendor/README.md +48 -0
  10. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/vendor/html5ever/src/tree_builder/mod.rs +95 -118
  11. pyhtml2text-0.3.1/vendor/html5ever/src/tree_builder/open_elems.rs +501 -0
  12. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/vendor/html5ever/src/tree_builder/rules.rs +124 -101
  13. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/vendor/html5ever/src/tree_builder/tag_sets.rs +10 -0
  14. pyhtml2text-0.3.0/vendor/README.md +0 -13
  15. pyhtml2text-0.3.0/vendor/html5ever/src/tree_builder/open_elems.rs +0 -134
  16. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/.gitignore +0 -0
  17. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/LICENSE +0 -0
  18. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/python/pyhtml2text/__init__.py +0 -0
  19. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/python/pyhtml2text/__init__.pyi +0 -0
  20. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/python/pyhtml2text/py.typed +0 -0
  21. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/src/lib.rs +0 -0
  22. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/src/markdown.rs +0 -0
  23. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/tests/markdown_check.py +0 -0
  24. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/vendor/html5ever/Cargo.toml +0 -0
  25. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/vendor/html5ever/LICENSE-APACHE +0 -0
  26. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/vendor/html5ever/LICENSE-MIT +0 -0
  27. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/vendor/html5ever/README.md +0 -0
  28. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/vendor/html5ever/src/driver.rs +0 -0
  29. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/vendor/html5ever/src/encoding.rs +0 -0
  30. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/vendor/html5ever/src/lib.rs +0 -0
  31. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/vendor/html5ever/src/macros.rs +0 -0
  32. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/vendor/html5ever/src/serialize/mod.rs +0 -0
  33. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/vendor/html5ever/src/tokenizer/char_ref/mod.rs +0 -0
  34. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/vendor/html5ever/src/tokenizer/interface.rs +0 -0
  35. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/vendor/html5ever/src/tokenizer/mod.rs +0 -0
  36. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/vendor/html5ever/src/tokenizer/states.rs +0 -0
  37. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/vendor/html5ever/src/tree_builder/data.rs +0 -0
  38. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/vendor/html5ever/src/tree_builder/types.rs +0 -0
  39. {pyhtml2text-0.3.0 → pyhtml2text-0.3.1}/vendor/html5ever/src/util/str.rs +0 -0
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.1 (2026-10-19)
4
+
5
+ - The vendored html5ever now carries
6
+ [servo/html5ever#800](https://github.com/servo/html5ever/pull/800) by Josef
7
+ Guenther in place of our narrower patch. Every "in scope" check and the
8
+ other searches of the stack of open elements are now O(1), so unmatched
9
+ end tags and end tags inside SVG or MathML no longer make deeply nested
10
+ HTML quadratic: 32,000 nested `<span>` followed by as many unmatched
11
+ `</em>` went from 3.4 s to 0.02 s. Output is unchanged.
12
+ - About 0.4 µs more per call, and 0–2% on real pages.
13
+
3
14
  ## 0.3.0 (2026-10-15)
4
15
 
5
16
  - New `html2markdown(html, *, base_url=None)`: HTML to CommonMark with
@@ -249,7 +249,7 @@ dependencies = [
249
249
 
250
250
  [[package]]
251
251
  name = "pyhtml2text"
252
- version = "0.3.0"
252
+ version = "0.3.1"
253
253
  dependencies = [
254
254
  "html2text",
255
255
  "html5ever",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "pyhtml2text"
3
- version = "0.3.0"
3
+ version = "0.3.1"
4
4
  edition = "2021"
5
5
  license = "MIT"
6
6
  publish = false
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyhtml2text
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: Topic :: Text Processing :: Markup :: HTML
@@ -122,14 +122,18 @@ To plain text:
122
122
  profile-guided optimisation, which makes them about 25% faster. Other
123
123
  platforms get the same code without it.
124
124
  - Deeply nested HTML is parsed in linear time, so hostile input can't stall a
125
- worker: 40,000 nested lists take 0.14 s, against 6 s with stock html5ever.
125
+ worker: 40,000 nested lists take 0.14 s, against 6 s with stock html5ever,
126
+ and 100,000 levels of nesting followed by unmatched end tags take under
127
+ 0.1 s.
126
128
 
127
129
  ## Credits
128
130
 
129
131
  The plain-text rendering is done by rust-html2text by Chris Emerson (MIT).
130
132
  This package uses [a fork](https://github.com/jaredlynskey/rust-html2text/tree/pyhtml2text-perf)
131
- that adds `base_url` and some performance changes, and a patched copy of
132
- [html5ever](https://github.com/servo/html5ever) (MIT/Apache-2.0, see
133
+ that adds `base_url` and some performance changes, and a copy of
134
+ [html5ever](https://github.com/servo/html5ever) (MIT/Apache-2.0) with
135
+ [servo/html5ever#800](https://github.com/servo/html5ever/pull/800) by Josef
136
+ Guenther applied, which makes parsing deeply nested HTML linear (see
133
137
  `vendor/`). Markdown output is checked with
134
138
  [pulldown-cmark](https://github.com/raphlinus/pulldown-cmark) (MIT). It
135
139
  isn't affiliated with or endorsed by any of these projects, so please report
@@ -106,14 +106,18 @@ To plain text:
106
106
  profile-guided optimisation, which makes them about 25% faster. Other
107
107
  platforms get the same code without it.
108
108
  - Deeply nested HTML is parsed in linear time, so hostile input can't stall a
109
- worker: 40,000 nested lists take 0.14 s, against 6 s with stock html5ever.
109
+ worker: 40,000 nested lists take 0.14 s, against 6 s with stock html5ever,
110
+ and 100,000 levels of nesting followed by unmatched end tags take under
111
+ 0.1 s.
110
112
 
111
113
  ## Credits
112
114
 
113
115
  The plain-text rendering is done by rust-html2text by Chris Emerson (MIT).
114
116
  This package uses [a fork](https://github.com/jaredlynskey/rust-html2text/tree/pyhtml2text-perf)
115
- that adds `base_url` and some performance changes, and a patched copy of
116
- [html5ever](https://github.com/servo/html5ever) (MIT/Apache-2.0, see
117
+ that adds `base_url` and some performance changes, and a copy of
118
+ [html5ever](https://github.com/servo/html5ever) (MIT/Apache-2.0) with
119
+ [servo/html5ever#800](https://github.com/servo/html5ever/pull/800) by Josef
120
+ Guenther applied, which makes parsing deeply nested HTML linear (see
117
121
  `vendor/`). Markdown output is checked with
118
122
  [pulldown-cmark](https://github.com/raphlinus/pulldown-cmark) (MIT). It
119
123
  isn't affiliated with or endorsed by any of these projects, so please report
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "pyhtml2text"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "Fast HTML to plain text, with relative link resolution (Python bindings to rust-html2text)"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -74,16 +74,24 @@ def test_rejects_other_types():
74
74
 
75
75
  def test_deep_nesting_is_linear():
76
76
  # Parsing used to be quadratic in nesting depth (40,000 nested lists took
77
- # 6s); with the patched html5ever this takes well under a second.
77
+ # 6s); with the patched html5ever each of these takes well under a second.
78
+ # pyhtml2text 0.3.0, whose patch didn't cover them, took 33s and 50s on the
79
+ # last two.
78
80
  import platform
79
81
  import sys
80
82
  import time
81
83
 
82
- html = "<ul><li>" * 100_000 + "x"
83
- start = time.perf_counter()
84
- out = html2text(html)
85
- elapsed = time.perf_counter() - start
86
- assert out.rstrip().endswith("x")
87
- # Only time it where tests run natively; emulated CI runners are slow.
88
- if platform.machine().lower() in ("x86_64", "amd64") or sys.platform == "darwin":
89
- assert elapsed < 10, f"took {elapsed:.1f}s"
84
+ cases = [
85
+ "<ul><li>" * 100_000 + "x",
86
+ "<div>" * 100_000 + "x</p></p>",
87
+ "<span>" * 100_000 + "x" + "</em>" * 100_000,
88
+ "<svg>" + "<g>" * 100_000 + "</x>" * 100_000 + "</svg>x",
89
+ ]
90
+ for html in cases:
91
+ start = time.perf_counter()
92
+ out = html2text(html)
93
+ elapsed = time.perf_counter() - start
94
+ assert out.rstrip().endswith("x")
95
+ # Only time it where tests run natively; emulated CI runners are slow.
96
+ if platform.machine().lower() in ("x86_64", "amd64") or sys.platform == "darwin":
97
+ assert elapsed < 10, f"took {elapsed:.1f}s on {html[:20]!r}..."
@@ -171,6 +171,8 @@ def test_hostile_input_stays_linear():
171
171
  "<ul><li>" * 100_000 + "x",
172
172
  "<blockquote>" * 100_000 + "x",
173
173
  "<table><tr><td>" * 10_000 + "x",
174
+ "<span>" * 100_000 + "x" + "</em>" * 100_000,
175
+ "<svg>" + "<g>" * 100_000 + "</x>" * 100_000 + "</svg>x",
174
176
  "<table><tr><td rowspan=65534 colspan=1000>a</td><td>b</td></tr>" + "<tr></tr>" * 200_000 + "</table>",
175
177
  ]
176
178
  for html in cases:
@@ -0,0 +1,48 @@
1
+ # Vendored crates
2
+
3
+ ## html5ever 0.39.0
4
+
5
+ Copied from the crates.io release (`src/`, licences, README and manifest;
6
+ examples, tests and benchmarks removed). Dual-licensed MIT / Apache-2.0,
7
+ see `html5ever/LICENSE-*`.
8
+
9
+ Patched with [servo/html5ever#800](https://github.com/servo/html5ever/pull/800)
10
+ by Josef Guenther ("Fix quadratic parsing of deeply nested markup", commit
11
+ `67c7e7c423c5d1acab1872c630cae61e2bc45211`), which is not merged yet. The
12
+ PR is against html5ever 0.40.1; its `src/` changes apply unchanged to
13
+ 0.39.0, apart from line offsets. Like the rest of html5ever, they are
14
+ available under MIT or Apache-2.0.
15
+
16
+ Without it, tree building is quadratic in nesting depth (see
17
+ [servo/html5ever#788](https://github.com/servo/html5ever/issues/788)): most
18
+ start and end tags search the stack of open elements from the top, and on
19
+ deeply nested input each search walks the whole stack. The PR keeps the
20
+ positions of open elements by name and by the sets those searches look for,
21
+ so "in scope" checks and the other lookups are O(1). Two cases stay
22
+ quadratic: the adoption agency removing elements from the middle of the
23
+ stack, and searches of the list of active formatting elements.
24
+
25
+ This replaces an earlier, narrower patch of ours that only covered the
26
+ "`p` in button scope" check. That patch left unmatched end tags and end tags
27
+ in foreign content quadratic: at 32,000 levels, `html2text()` took 3.4 s on
28
+ nested `<span>` followed by unmatched `</em>`, and 4.5 s on SVG `<g>`
29
+ followed by unmatched `</x>`. Both now take 0.02 s or less.
30
+
31
+ Checked against stock 0.39.0 before vendoring:
32
+
33
+ - html5ever's own tests pass, including the PR's new test, and so do all
34
+ 3,515 html5lib tree-construction tests, in release and debug builds.
35
+ - The tree, quirks mode and exact parse errors are identical to stock 0.39.0
36
+ on every html5lib input (documents and fragments), 300,000 random tag-soup
37
+ records (30% of them fragments, in 23 contexts) and 28 real pages,
38
+ including the single-page HTML standard.
39
+ - pyhtml2text's output is byte-for-byte unchanged on the benchmark corpus
40
+ (`bench/bench.py --check`).
41
+
42
+ The extra bookkeeping costs about 0.4 µs per call, and 0–2% on real pages.
43
+
44
+ Once the PR is in an html5ever release that rust-html2text uses, drop this
45
+ directory and the `[patch.crates-io]` entry in `Cargo.toml`. Upgrading
46
+ rust-html2text to one that uses a newer html5ever without updating this copy
47
+ silently drops the patch (cargo only warns that it is unused), so
48
+ `test_deep_nesting_is_linear` checks that it is still in effect.
@@ -14,7 +14,7 @@ pub use crate::interface::{AppendNode, AppendText, Attribute, NodeOrText};
14
14
  pub use crate::interface::{LimitedQuirks, NoQuirks, Quirks, QuirksMode};
15
15
  pub use markup5ever::interface::tree_builder::create_element_with_flags;
16
16
 
17
- use self::open_elems::{ElemKind, OpenElems};
17
+ use self::open_elems::{OpenElems, TrackedSet};
18
18
  use self::types::*;
19
19
 
20
20
  use crate::tendril::StrTendril;
@@ -546,7 +546,8 @@ where
546
546
  }
547
547
 
548
548
  fn end(&self) {
549
- for elem in self.open_elems.borrow_mut().drain_all().rev() {
549
+ let mut open_elems = self.open_elems.borrow_mut();
550
+ while let Some(elem) = open_elems.pop() {
550
551
  self.sink.pop(&elem);
551
552
  }
552
553
  }
@@ -743,12 +744,7 @@ where
743
744
  });
744
745
  };
745
746
 
746
- let Some(fmt_elem_stack_index) = self
747
- .open_elems
748
- .borrow()
749
- .iter()
750
- .rposition(|n| self.sink.same_node(n, &fmt_elem))
751
- else {
747
+ let Some(fmt_elem_stack_index) = self.position_in_open_elems(&fmt_elem) else {
752
748
  self.sink
753
749
  .parse_error(Borrowed("Formatting element not open"));
754
750
  self.active_formatting.borrow_mut().remove(fmt_elem_index);
@@ -756,7 +752,11 @@ where
756
752
  };
757
753
 
758
754
  // 7.
759
- if !self.in_scope(default_scope, |n| self.sink.same_node(&n, &fmt_elem)) {
755
+ if !self
756
+ .open_elems
757
+ .borrow()
758
+ .in_scope(TrackedSet::DefaultScope, fmt_elem_stack_index)
759
+ {
760
760
  self.sink
761
761
  .parse_error(Borrowed("Formatting element not in scope"));
762
762
  return;
@@ -778,7 +778,7 @@ where
778
778
  .find(|&(_, open_element)| self.elem_in(open_element, special_tag))
779
779
  .map(|(i, h)| (i, h.clone()));
780
780
 
781
- let Some((furthest_block_index, furthest_block)) = maybe_furthest_block else {
781
+ let Some((mut furthest_block_index, furthest_block)) = maybe_furthest_block else {
782
782
  // 10.
783
783
  self.open_elems.borrow_mut().truncate(fmt_elem_stack_index);
784
784
  self.active_formatting.borrow_mut().remove(fmt_elem_index);
@@ -816,12 +816,14 @@ where
816
816
  self.position_in_active_formatting(&node)
817
817
  .map(|position| self.active_formatting.borrow_mut().remove(position));
818
818
  self.open_elems.borrow_mut().remove(node_index);
819
+ furthest_block_index -= 1;
819
820
  continue;
820
821
  }
821
822
 
822
823
  let Some(node_formatting_index) = self.position_in_active_formatting(&node) else {
823
824
  // 13.6.
824
825
  self.open_elems.borrow_mut().remove(node_index);
826
+ furthest_block_index -= 1;
825
827
  continue;
826
828
  };
827
829
 
@@ -841,10 +843,11 @@ where
841
843
  tag.attrs.clone(),
842
844
  tag.had_duplicate_attributes,
843
845
  );
844
- let kind = self.open_elem_kind(&new_element);
845
- self.open_elems
846
- .borrow_mut()
847
- .replace(node_index, new_element.clone(), kind);
846
+ self.open_elems.borrow_mut().replace(
847
+ node_index,
848
+ new_element.clone(),
849
+ self.sink.elem_name(&new_element).expanded(),
850
+ );
848
851
  self.active_formatting.borrow_mut()[node_formatting_index] =
849
852
  FormatEntry::Element(new_element.clone(), tag);
850
853
  node = new_element;
@@ -910,37 +913,28 @@ where
910
913
  }
911
914
 
912
915
  // 19.
913
- self.remove_from_stack(&fmt_elem);
914
- let new_furthest_block_index = self
915
- .open_elems
916
- .borrow()
917
- .iter()
918
- .position(|n| self.sink.same_node(n, &furthest_block))
919
- .expect("furthest block missing from open element stack");
920
- let kind = self.open_elem_kind(&new_element);
921
- self.open_elems
922
- .borrow_mut()
923
- .insert(new_furthest_block_index + 1, new_element, kind);
916
+ // Move the formatting element up to just above the furthest block, which moves the
917
+ // elements in between down by one, and put the new element in its place.
918
+ {
919
+ let mut open_elems = self.open_elems.borrow_mut();
920
+ open_elems.move_up(fmt_elem_stack_index, furthest_block_index);
921
+ open_elems.replace(
922
+ furthest_block_index,
923
+ new_element.clone(),
924
+ self.sink.elem_name(&new_element).expanded(),
925
+ );
926
+ }
927
+ self.sink.pop(&fmt_elem);
924
928
 
925
929
  // 20.
926
930
  }
927
931
  }
928
932
 
929
- fn open_elem_kind(&self, elem: &Handle) -> ElemKind {
930
- let name = self.sink.elem_name(elem);
931
- let name = name.expanded();
932
- if *name.ns == ns!(html) && *name.local == local_name!("p") {
933
- ElemKind::P
934
- } else if button_scope(name) {
935
- ElemKind::ButtonScopeBoundary
936
- } else {
937
- ElemKind::Other
938
- }
939
- }
940
-
941
933
  fn push(&self, elem: &Handle) {
942
- let kind = self.open_elem_kind(elem);
943
- self.open_elems.borrow_mut().push(elem.clone(), kind);
934
+ let name = self.sink.elem_name(elem);
935
+ self.open_elems
936
+ .borrow_mut()
937
+ .push(elem.clone(), name.expanded());
944
938
  }
945
939
 
946
940
  fn pop(&self) -> Handle {
@@ -954,12 +948,24 @@ where
954
948
  elem
955
949
  }
956
950
 
957
- fn remove_from_stack(&self, elem: &Handle) {
958
- let position = self
959
- .open_elems
960
- .borrow()
951
+ /// The position of `elem` in the stack of open elements, if it is open.
952
+ fn position_in_open_elems(&self, elem: &Handle) -> Option<usize> {
953
+ let name = self.sink.elem_name(elem);
954
+ let open_elems = self.open_elems.borrow();
955
+ let positions = if *name.ns() == ns!(html) {
956
+ open_elems.positions_named(name.local_name())
957
+ } else {
958
+ open_elems.positions_foreign_named(name.local_name())
959
+ };
960
+ let position = positions
961
961
  .iter()
962
- .rposition(|x| self.sink.same_node(elem, x));
962
+ .rev()
963
+ .find(|&position| self.sink.same_node(&open_elems[position], elem));
964
+ position
965
+ }
966
+
967
+ fn remove_from_stack(&self, elem: &Handle) {
968
+ let position = self.position_in_open_elems(elem);
963
969
  if let Some(position) = position {
964
970
  self.open_elems.borrow_mut().remove(position);
965
971
  self.sink.pop(elem);
@@ -969,12 +975,7 @@ where
969
975
  fn is_marker_or_open(&self, entry: &FormatEntry<Handle>) -> bool {
970
976
  match *entry {
971
977
  FormatEntry::Marker => true,
972
- FormatEntry::Element(ref node, _) => self
973
- .open_elems
974
- .borrow()
975
- .iter()
976
- .rev()
977
- .any(|n| self.sink.same_node(n, node)),
978
+ FormatEntry::Element(ref node, _) => self.position_in_open_elems(node).is_some(),
978
979
  }
979
980
  }
980
981
 
@@ -1075,48 +1076,22 @@ where
1075
1076
  /// Signal an error depending on the state of the stack of open elements at
1076
1077
  /// the end of the body.
1077
1078
  fn check_body_end(&self) {
1078
- declare_tag_set!(body_end_ok =
1079
- "dd" "dt" "li" "optgroup" "option" "p" "rp" "rt" "tbody" "td" "tfoot" "th"
1080
- "thead" "tr" "body" "html");
1081
-
1082
- for elem in self.open_elems.borrow().iter() {
1083
- let error = {
1084
- let elem_name = self.sink.elem_name(elem);
1085
- let name = elem_name.expanded();
1086
- if body_end_ok(name) {
1087
- continue;
1088
- }
1089
-
1090
- if self.opts.exact_errors {
1091
- Cow::from(format!("Unexpected open tag {name:?} at end of body"))
1092
- } else {
1093
- Cow::from("Unexpected open tag at end of body")
1094
- }
1095
- };
1096
- self.sink.parse_error(error);
1097
- // FIXME: Do we keep checking after finding one bad tag?
1098
- // The spec suggests not.
1079
+ let open_elems = self.open_elems.borrow();
1080
+ // FIXME: Do we keep checking after finding one bad tag?
1081
+ // The spec suggests not.
1082
+ let Some(position) = open_elems.positions_in(TrackedSet::NotBodyEndOk).first() else {
1099
1083
  return;
1100
- }
1101
- }
1102
-
1103
- fn in_scope<TagSet, Pred>(&self, scope: TagSet, pred: Pred) -> bool
1104
- where
1105
- TagSet: Fn(ExpandedName) -> bool,
1106
- Pred: Fn(Handle) -> bool,
1107
- {
1108
- for node in self.open_elems.borrow().iter().rev() {
1109
- if pred(node.clone()) {
1110
- return true;
1111
- }
1112
- if scope(self.sink.elem_name(node).expanded()) {
1113
- return false;
1084
+ };
1085
+ let error = {
1086
+ let elem_name = self.sink.elem_name(&open_elems[position]);
1087
+ let name = elem_name.expanded();
1088
+ if self.opts.exact_errors {
1089
+ Cow::from(format!("Unexpected open tag {name:?} at end of body"))
1090
+ } else {
1091
+ Cow::from("Unexpected open tag at end of body")
1114
1092
  }
1115
- }
1116
-
1117
- // supposed to be impossible, because <html> is always in scope
1118
-
1119
- false
1093
+ };
1094
+ self.sink.parse_error(error);
1120
1095
  }
1121
1096
 
1122
1097
  fn elem_in<TagSet>(&self, elem: &Handle, set: TagSet) -> bool
@@ -1132,21 +1107,22 @@ where
1132
1107
  }
1133
1108
 
1134
1109
  fn in_html_elem_named(&self, name: LocalName) -> bool {
1135
- self.open_elems
1136
- .borrow()
1137
- .iter()
1138
- .any(|elem| self.html_elem_named(elem, name.clone()))
1110
+ !self.open_elems.borrow().positions_named(&name).is_empty()
1139
1111
  }
1140
1112
 
1141
1113
  fn current_node_named(&self, name: LocalName) -> bool {
1142
1114
  self.html_elem_named(&self.current_node(), name)
1143
1115
  }
1144
1116
 
1145
- fn in_scope_named<TagSet>(&self, scope: TagSet, name: LocalName) -> bool
1146
- where
1147
- TagSet: Fn(ExpandedName) -> bool,
1148
- {
1149
- self.in_scope(scope, |elem| self.html_elem_named(&elem, name.clone()))
1117
+ fn in_scope_named(&self, scope: TrackedSet, name: LocalName) -> bool {
1118
+ self.open_elems.borrow().named_in_scope(scope, &name)
1119
+ }
1120
+
1121
+ fn in_scope_any_named(&self, scope: TrackedSet, names: &[LocalName]) -> bool {
1122
+ let open_elems = self.open_elems.borrow();
1123
+ names
1124
+ .iter()
1125
+ .any(|name| open_elems.named_in_scope(scope, name))
1150
1126
  }
1151
1127
 
1152
1128
  /// <https://html.spec.whatwg.org/#generate-implied-end-tags>
@@ -1234,15 +1210,8 @@ where
1234
1210
  self.expect_to_close(local_name!("p"));
1235
1211
  }
1236
1212
 
1237
- /// Whether the stack of open elements has a `p` element in button scope.
1238
- fn p_in_button_scope(&self) -> bool {
1239
- let result = self.open_elems.borrow().has_p_in_button_scope();
1240
- debug_assert_eq!(result, self.in_scope_named(button_scope, local_name!("p")));
1241
- result
1242
- }
1243
-
1244
1213
  fn close_p_element_in_button_scope(&self) {
1245
- if self.p_in_button_scope() {
1214
+ if self.in_scope_named(TrackedSet::ButtonScope, local_name!("p")) {
1246
1215
  self.close_p_element();
1247
1216
  }
1248
1217
  }
@@ -1290,7 +1259,15 @@ where
1290
1259
  // https://html.spec.whatwg.org/multipage/#reset-the-insertion-mode-appropriately
1291
1260
  fn reset_insertion_mode(&self) -> InsertionMode {
1292
1261
  let open_elems = self.open_elems.borrow();
1293
- for (i, mut node) in open_elems.iter().enumerate().rev() {
1262
+ // The steps below stop at the topmost element in this set, unless it is the first one,
1263
+ // which they look at with the context element in its place.
1264
+ let topmost = open_elems
1265
+ .positions_in(TrackedSet::ResetInsertionMode)
1266
+ .last()
1267
+ .filter(|&i| i > 0);
1268
+ let first = (!open_elems.is_empty()).then_some(0);
1269
+ for i in topmost.into_iter().chain(first) {
1270
+ let mut node = &open_elems[i];
1294
1271
  let last = i == 0usize;
1295
1272
  let context_elem = self.context_elem.borrow();
1296
1273
  if let (true, Some(ctx)) = (last, context_elem.as_ref()) {
@@ -1587,20 +1564,20 @@ where
1587
1564
  }
1588
1565
 
1589
1566
  fn process_end_tag_in_body(&self, tag: Tag) {
1590
- // Look back for a matching open element.
1591
- let mut match_idx = None;
1592
- for (i, elem) in self.open_elems.borrow().iter().enumerate().rev() {
1593
- if self.html_elem_named(elem, tag.name.clone()) {
1594
- match_idx = Some(i);
1595
- break;
1596
- }
1597
-
1598
- if self.elem_in(elem, special_tag) {
1567
+ // Look back for a matching open element, as far as the topmost special element.
1568
+ let match_idx = {
1569
+ let open_elems = self.open_elems.borrow();
1570
+ let topmost_match = open_elems.positions_named(&tag.name).last();
1571
+ if topmost_match.is_some_and(|i| open_elems.in_scope(TrackedSet::Special, i)) {
1572
+ topmost_match
1573
+ } else if !open_elems.positions_in(TrackedSet::Special).is_empty() {
1599
1574
  self.sink
1600
1575
  .parse_error(Borrowed("Found special tag while closing generic tag"));
1601
1576
  return;
1577
+ } else {
1578
+ None
1602
1579
  }
1603
- }
1580
+ };
1604
1581
 
1605
1582
  let Some(match_idx) = match_idx else {
1606
1583
  // I believe this is impossible, because the root