diff --git a/mammoth/docx/numbering_xml.py b/mammoth/docx/numbering_xml.py index 0bd1952..8f21f76 100644 --- a/mammoth/docx/numbering_xml.py +++ b/mammoth/docx/numbering_xml.py @@ -101,6 +101,17 @@ def __init__(self, abstract_nums, nums, styles): self._styles = styles def find_level(self, num_id, level): + return self._find_level(num_id, level, visited=set()) + + def _find_level(self, num_id, level, visited): + # A w:numStyleLink can point (directly or transitively) back to a + # numbering definition that has already been visited, forming a cycle. + # Track the num IDs seen while resolving this chain so a malformed + # document cannot cause unbounded recursion. + if num_id in visited: + return None + visited.add(num_id) + num = self._nums.get(num_id) if num is None: return None @@ -112,7 +123,7 @@ def find_level(self, num_id, level): return self._to_numbering_level(abstract_num.levels.get(level)) else: style = self._styles.find_numbering_style_by_id(abstract_num.num_style_link) - return self.find_level(style.num_id, level) + return self._find_level(style.num_id, level, visited) def find_level_by_paragraph_style_id(self, style_id): return self._levels_by_paragraph_style_id.get(style_id) diff --git a/tests/docx/numbering_xml_tests.py b/tests/docx/numbering_xml_tests.py index 6cc779f..8fb98e4 100644 --- a/tests/docx/numbering_xml_tests.py +++ b/tests/docx/numbering_xml_tests.py @@ -176,6 +176,26 @@ def test_numbering_level_can_be_found_by_paragraph_style_id(): assert_equal(None, numbering.find_level_by_paragraph_style_id("Paragraph")) + +def test_find_level_returns_none_when_num_style_link_forms_a_cycle(): + # num 201 -> abstractNum 101 -> numStyleLink "List1" -> num 201 -> ... + # Without cycle detection this recurses until the interpreter stack is + # exhausted; find_level should instead return None. + numbering = _read_numbering_xml_element( + xml_element("w:numbering", {}, [ + xml_element("w:abstractNum", {"w:abstractNumId": "101"}, [ + xml_element("w:numStyleLink", {"w:val": "List1"}), + ]), + xml_element("w:num", {"w:numId": "201"}, [ + xml_element("w:abstractNumId", {"w:val": "101"}), + ]), + ]), + styles=Styles.create(numbering_styles={ + "List1": NumberingStyle(style_id="List1", num_id="201"), + }), + ) + assert_equal(None, numbering.find_level("201", "0")) + def _read_numbering_xml_element(element, styles=None): if styles is None: styles = Styles.EMPTY