Skip to content

Commit 55c12b8

Browse files
committed
Handle cyclic w references
Track the num IDs visited during a single find_level lookup and return None when a num ID repeats, so a cyclic w:numStyleLink chain is treated as having no resolvable level instead of recursing without bound. Adds a regression test covering a two-entry cycle.
1 parent 1cac245 commit 55c12b8

2 files changed

Lines changed: 32 additions & 1 deletion

File tree

mammoth/docx/numbering_xml.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,17 @@ def __init__(self, abstract_nums, nums, styles):
101101
self._styles = styles
102102

103103
def find_level(self, num_id, level):
104+
return self._find_level(num_id, level, visited=set())
105+
106+
def _find_level(self, num_id, level, visited):
107+
# A w:numStyleLink can point (directly or transitively) back to a
108+
# numbering definition that has already been visited, forming a cycle.
109+
# Track the num IDs seen while resolving this chain so a malformed
110+
# document cannot cause unbounded recursion.
111+
if num_id in visited:
112+
return None
113+
visited.add(num_id)
114+
104115
num = self._nums.get(num_id)
105116
if num is None:
106117
return None
@@ -112,7 +123,7 @@ def find_level(self, num_id, level):
112123
return self._to_numbering_level(abstract_num.levels.get(level))
113124
else:
114125
style = self._styles.find_numbering_style_by_id(abstract_num.num_style_link)
115-
return self.find_level(style.num_id, level)
126+
return self._find_level(style.num_id, level, visited)
116127

117128
def find_level_by_paragraph_style_id(self, style_id):
118129
return self._levels_by_paragraph_style_id.get(style_id)

tests/docx/numbering_xml_tests.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,26 @@ def test_numbering_level_can_be_found_by_paragraph_style_id():
176176
assert_equal(None, numbering.find_level_by_paragraph_style_id("Paragraph"))
177177

178178

179+
180+
def test_find_level_returns_none_when_num_style_link_forms_a_cycle():
181+
# num 201 -> abstractNum 101 -> numStyleLink "List1" -> num 201 -> ...
182+
# Without cycle detection this recurses until the interpreter stack is
183+
# exhausted; find_level should instead return None.
184+
numbering = _read_numbering_xml_element(
185+
xml_element("w:numbering", {}, [
186+
xml_element("w:abstractNum", {"w:abstractNumId": "101"}, [
187+
xml_element("w:numStyleLink", {"w:val": "List1"}),
188+
]),
189+
xml_element("w:num", {"w:numId": "201"}, [
190+
xml_element("w:abstractNumId", {"w:val": "101"}),
191+
]),
192+
]),
193+
styles=Styles.create(numbering_styles={
194+
"List1": NumberingStyle(style_id="List1", num_id="201"),
195+
}),
196+
)
197+
assert_equal(None, numbering.find_level("201", "0"))
198+
179199
def _read_numbering_xml_element(element, styles=None):
180200
if styles is None:
181201
styles = Styles.EMPTY

0 commit comments

Comments
 (0)