diff --git a/markdownify/__init__.py b/markdownify/__init__.py index 28cdaf6..c4667f1 100644 --- a/markdownify/__init__.py +++ b/markdownify/__init__.py @@ -225,17 +225,30 @@ def convert(self, html): def convert_soup(self, soup): return self.process_tag(soup, parent_tags=set()) - def process_element(self, node, parent_tags=None): + def process_element(self, node, parent_tags=None, _visited=None): if isinstance(node, NavigableString): return self.process_text(node, parent_tags=parent_tags) else: - return self.process_tag(node, parent_tags=parent_tags) + return self.process_tag(node, parent_tags=parent_tags, _visited=_visited) - def process_tag(self, node, parent_tags=None): + def process_tag(self, node, parent_tags=None, _visited=None): # For the top-level element, initialize the parent context with an empty set. if parent_tags is None: parent_tags = set() + # Guard against cyclic trees. A well-formed BeautifulSoup tree is acyclic, + # but some HTML producers (e.g. certain PDF-to-HTML pipelines) can yield a + # graph where a descendant references an ancestor, which would otherwise + # send process_tag/process_element into unbounded mutual recursion and a + # RecursionError. Track the ids of the tags on the current descent path and + # stop if one repeats. + if _visited is None: + _visited = set() + node_id = id(node) + if node_id in _visited: + return '' + _visited = _visited | {node_id} + # Collect child elements to process, ignoring whitespace-only text elements # adjacent to the inner/outer boundaries of block elements. should_remove_inside = should_remove_whitespace_inside(node) @@ -285,7 +298,7 @@ def _can_ignore(el): # Convert the children elements into a list of result strings. child_strings = [ - self.process_element(el, parent_tags=parent_tags_for_children) + self.process_element(el, parent_tags=parent_tags_for_children, _visited=_visited) for el in children_to_convert ] diff --git a/tests/test_advanced.py b/tests/test_advanced.py index 6123d8c..0c4b2f8 100644 --- a/tests/test_advanced.py +++ b/tests/test_advanced.py @@ -37,3 +37,27 @@ def test_code_with_tricky_content(): def test_special_tags(): assert md('') == '' assert md('') == 'foobar' + + +def test_cyclic_tree_does_not_recurse(): + """A cyclic BeautifulSoup tree (a descendant referencing an ancestor, as + some PDF-to-HTML pipelines can produce) must not send process_tag / + process_element into unbounded recursion. Regression test for #256.""" + import sys + from bs4 import BeautifulSoup + from markdownify import MarkdownConverter + + soup = BeautifulSoup('

hello

', 'html.parser') + div = soup.find('div') + p = soup.find('p') + # Introduce a cycle: p now contains div, which already contains p. + p.contents.append(div) + + original_limit = sys.getrecursionlimit() + sys.setrecursionlimit(300) + try: + # Must complete without raising RecursionError. + result = MarkdownConverter().convert_soup(soup) + finally: + sys.setrecursionlimit(original_limit) + assert 'hello' in result