Skip to content

Commit 875d1cf

Browse files
committed
fix: nest DOCX sub-lists that Word stores as a separate numbering definition
Word can express a nested list either as a deeper w:ilvl within the parent's w:numId, or as a new w:numId at w:ilvl 0 that is set apart only by its indentation. Both render identically in Word, but mammoth derives nesting from w:ilvl alone, so the second form was flattened into the parent list and its items were renumbered as siblings. Extend the existing pre_process_docx step to resolve each level's effective indentation from numbering.xml and walk the document tracking the open list levels, so nesting implied by indentation is restored before mammoth reads the file. Within one w:numId the declared w:ilvl stays authoritative, since some numbering definitions give several levels the same indentation. Indentation only ever adds nesting that the declared levels missed and never removes nesting a document states outright, which leaves documents that already convert correctly untouched. Remapped paragraphs are pointed at a generated w:abstractNum carrying their original w:numFmt, so a bulleted sub-list is not silently converted into a numbered one, and depth is capped at the last level mammoth's default style map defines. Fixes #2323
1 parent 9dc0d65 commit 875d1cf

3 files changed

Lines changed: 507 additions & 0 deletions

File tree

packages/markitdown/src/markitdown/converter_utils/docx/pre_process.py

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,309 @@ def _pre_process_math(content: bytes) -> bytes:
115115
return str(soup).encode()
116116

117117

118+
W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
119+
120+
# mammoth's default style map only defines list nesting up to five levels (see
121+
# "p:ordered-list(5)" in mammoth.options). A paragraph promoted past the last
122+
# mapped level matches no rule at all and drops out of the list entirely, so
123+
# nesting is capped at the deepest level mammoth can still represent.
124+
MAX_LIST_LEVEL = 4
125+
126+
# Word's default indentation step between consecutive list levels, in twips.
127+
# Used only when a level definition carries no explicit indentation.
128+
DEFAULT_LEVEL_INDENT = 720
129+
130+
131+
def _read_indent(element: Tag | None) -> int | None:
132+
"""
133+
Reads the left indentation (in twips) from the "w:ind" child of an element.
134+
135+
Args:
136+
element (Tag | None): The element whose "w:ind" child should be read.
137+
138+
Returns:
139+
int | None: The left indentation, or None if it is absent or malformed.
140+
"""
141+
if element is None:
142+
return None
143+
ind = element.find("ind", recursive=False)
144+
if ind is None:
145+
return None
146+
# "w:start" is the ISO/strict equivalent of the transitional "w:left".
147+
for attribute in ("w:left", "w:start"):
148+
value = ind.get(attribute)
149+
if value is not None:
150+
try:
151+
return int(value)
152+
except ValueError:
153+
pass
154+
return None
155+
156+
157+
def _read_numbering_definitions(numbering_soup: BeautifulSoup) -> dict:
158+
"""
159+
Resolves every "w:num" in numbering.xml to its per-level indentation and format.
160+
161+
Args:
162+
numbering_soup (BeautifulSoup): The parsed numbering.xml.
163+
164+
Returns:
165+
dict: Maps num_id -> level_index -> {"indent": int | None, "fmt": str | None}.
166+
"""
167+
abstract_nums = {}
168+
for abstract_num in numbering_soup.find_all("abstractNum"):
169+
levels = {}
170+
for lvl in abstract_num.find_all("lvl"):
171+
num_fmt = lvl.find("numFmt")
172+
levels[lvl.get("w:ilvl")] = {
173+
"indent": _read_indent(lvl.find("pPr")),
174+
"fmt": num_fmt.get("w:val") if num_fmt is not None else None,
175+
}
176+
abstract_nums[abstract_num.get("w:abstractNumId")] = levels
177+
178+
nums = {}
179+
for num in numbering_soup.find_all("num"):
180+
abstract_num_id = num.find("abstractNumId")
181+
if abstract_num_id is None:
182+
continue
183+
levels = dict(abstract_nums.get(abstract_num_id.get("w:val"), {}))
184+
# A "w:lvlOverride" replaces the inherited definition for a single level.
185+
for override in num.find_all("lvlOverride"):
186+
lvl = override.find("lvl")
187+
if lvl is None:
188+
continue
189+
num_fmt = lvl.find("numFmt")
190+
level_index = lvl.get("w:ilvl", override.get("w:ilvl"))
191+
levels[level_index] = {
192+
"indent": _read_indent(lvl.find("pPr")),
193+
"fmt": num_fmt.get("w:val") if num_fmt is not None else None,
194+
}
195+
nums[num.get("w:numId")] = levels
196+
return nums
197+
198+
199+
def _iter_list_paragraphs(document_soup: BeautifulSoup):
200+
"""
201+
Yields each paragraph of a document alongside its numbering reference.
202+
203+
Args:
204+
document_soup (BeautifulSoup): The parsed document.xml.
205+
206+
Yields:
207+
tuple: (paragraph, ilvl_tag, num_id_tag). The tags are None for any
208+
paragraph that does not carry direct numbering.
209+
"""
210+
for paragraph in document_soup.find_all("p"):
211+
p_pr = paragraph.find("pPr", recursive=False)
212+
num_pr = p_pr.find("numPr", recursive=False) if p_pr is not None else None
213+
if num_pr is None:
214+
yield paragraph, None, None
215+
continue
216+
ilvl_tag = num_pr.find("ilvl", recursive=False)
217+
num_id_tag = num_pr.find("numId", recursive=False)
218+
# A "w:numId" of 0 explicitly removes numbering from the paragraph.
219+
if num_id_tag is None or num_id_tag.get("w:val") == "0":
220+
yield paragraph, None, None
221+
continue
222+
yield paragraph, ilvl_tag, num_id_tag
223+
224+
225+
def _is_parent_of(candidate: dict, item: dict) -> bool:
226+
"""
227+
Determines whether an open list level is an ancestor of the current item.
228+
229+
Within a single "w:numId" the declared "w:ilvl" is authoritative, because
230+
Word uses it directly and levels of one list are directly comparable.
231+
Across different "w:numId" values the levels are unrelated, so only the
232+
rendered indentation can establish which list is nested inside the other.
233+
234+
Args:
235+
candidate (dict): An open level from the stack.
236+
item (dict): The list paragraph being placed.
237+
238+
Returns:
239+
bool: True if candidate is strictly shallower than item.
240+
"""
241+
if candidate["num_id"] == item["num_id"]:
242+
return candidate["ilvl"] < item["ilvl"]
243+
return candidate["indent"] < item["indent"]
244+
245+
246+
def _resolve_list_depths(document_soup: BeautifulSoup, numbering: dict) -> list:
247+
"""
248+
Computes the true nesting depth of every numbered paragraph in a document.
249+
250+
Word represents a nested list in either of two ways: as a deeper "w:ilvl"
251+
within the parent's "w:numId", or as an entirely new "w:numId" at
252+
"w:ilvl" 0 that is simply indented further. Both render identically, but
253+
mammoth derives nesting from "w:ilvl" alone and so flattens the second
254+
form. Walking the document while tracking the open levels recovers the
255+
nesting that indentation implies.
256+
257+
Args:
258+
document_soup (BeautifulSoup): The parsed document.xml.
259+
numbering (dict): Numbering definitions from _read_numbering_definitions.
260+
261+
Returns:
262+
list: One (paragraph, ilvl_tag, num_id_tag, depth) tuple per paragraph
263+
whose depth differs from its declared level.
264+
"""
265+
remappings = []
266+
stack: list[dict] = []
267+
268+
for paragraph, ilvl_tag, num_id_tag in _iter_list_paragraphs(document_soup):
269+
if num_id_tag is None:
270+
# Body text interrupts the surrounding list, exactly as it does for
271+
# mammoth, so no level stays open across it.
272+
stack.clear()
273+
continue
274+
275+
num_id = num_id_tag.get("w:val")
276+
# A missing "w:ilvl" means the first level.
277+
raw_ilvl = ilvl_tag.get("w:val") if ilvl_tag is not None else "0"
278+
try:
279+
ilvl = int(raw_ilvl)
280+
except (TypeError, ValueError):
281+
ilvl = 0
282+
283+
level = numbering.get(num_id, {}).get(str(ilvl), {})
284+
indent = level.get("indent")
285+
if indent is None:
286+
indent = ilvl * DEFAULT_LEVEL_INDENT
287+
# Indentation applied directly to the paragraph overrides the level's.
288+
paragraph_indent = _read_indent(paragraph.find("pPr", recursive=False))
289+
if paragraph_indent is not None:
290+
indent = paragraph_indent
291+
292+
item = {"num_id": num_id, "ilvl": ilvl, "indent": indent}
293+
while stack and not _is_parent_of(stack[-1], item):
294+
stack.pop()
295+
296+
implied_depth = stack[-1]["depth"] + 1 if stack else 0
297+
# Indentation is only ever used to reveal nesting the declared levels
298+
# missed, never to remove nesting a document states outright. This
299+
# keeps documents that mammoth already handles correctly untouched.
300+
depth = min(max(implied_depth, ilvl), MAX_LIST_LEVEL)
301+
302+
item["depth"] = depth
303+
stack.append(item)
304+
305+
if depth != ilvl:
306+
remappings.append((paragraph, ilvl_tag, num_id_tag, depth))
307+
308+
return remappings
309+
310+
311+
def _apply_list_depths(
312+
document_soup: BeautifulSoup, numbering_soup: BeautifulSoup, remappings: list
313+
) -> None:
314+
"""
315+
Rewrites paragraphs whose nesting depth was mis-declared, in place.
316+
317+
Simply raising "w:ilvl" would make the paragraph resolve against whatever
318+
unrelated level its numbering happens to define at that index, which can
319+
silently flip an ordered list to a bulleted one. Instead each remapped
320+
(num_id, ilvl, depth) combination gets a minimal generated definition that
321+
places the original format at the required depth.
322+
323+
Args:
324+
document_soup (BeautifulSoup): The parsed document.xml.
325+
numbering_soup (BeautifulSoup): The parsed numbering.xml.
326+
remappings (list): Output of _resolve_list_depths.
327+
"""
328+
numbering_root = numbering_soup.find("numbering")
329+
if numbering_root is None:
330+
return
331+
332+
existing_num_ids = {
333+
int(num.get("w:numId"))
334+
for num in numbering_soup.find_all("num")
335+
if (num.get("w:numId") or "").isdigit()
336+
}
337+
existing_abstract_ids = {
338+
int(abstract_num.get("w:abstractNumId"))
339+
for abstract_num in numbering_soup.find_all("abstractNum")
340+
if (abstract_num.get("w:abstractNumId") or "").isdigit()
341+
}
342+
next_num_id = max(existing_num_ids, default=0) + 1
343+
next_abstract_id = max(existing_abstract_ids, default=0) + 1
344+
345+
numbering = _read_numbering_definitions(numbering_soup)
346+
generated: dict = {}
347+
348+
for paragraph, ilvl_tag, num_id_tag, depth in remappings:
349+
num_id = num_id_tag.get("w:val")
350+
ilvl = ilvl_tag.get("w:val") if ilvl_tag is not None else "0"
351+
key = (num_id, ilvl, depth)
352+
353+
if key not in generated:
354+
num_fmt = numbering.get(num_id, {}).get(ilvl, {}).get("fmt")
355+
356+
abstract_num = numbering_soup.new_tag(
357+
"abstractNum", namespace=W_NS, nsprefix="w"
358+
)
359+
abstract_num["w:abstractNumId"] = str(next_abstract_id)
360+
lvl = numbering_soup.new_tag("lvl", namespace=W_NS, nsprefix="w")
361+
lvl["w:ilvl"] = str(depth)
362+
if num_fmt is not None:
363+
num_fmt_tag = numbering_soup.new_tag(
364+
"numFmt", namespace=W_NS, nsprefix="w"
365+
)
366+
num_fmt_tag["w:val"] = num_fmt
367+
lvl.append(num_fmt_tag)
368+
abstract_num.append(lvl)
369+
370+
num = numbering_soup.new_tag("num", namespace=W_NS, nsprefix="w")
371+
num["w:numId"] = str(next_num_id)
372+
abstract_num_id = numbering_soup.new_tag(
373+
"abstractNumId", namespace=W_NS, nsprefix="w"
374+
)
375+
abstract_num_id["w:val"] = str(next_abstract_id)
376+
num.append(abstract_num_id)
377+
378+
# "w:abstractNum" elements must precede "w:num" elements.
379+
first_num = numbering_root.find("num", recursive=False)
380+
if first_num is not None:
381+
first_num.insert_before(abstract_num)
382+
else:
383+
numbering_root.append(abstract_num)
384+
numbering_root.append(num)
385+
386+
generated[key] = str(next_num_id)
387+
next_abstract_id += 1
388+
next_num_id += 1
389+
390+
num_id_tag["w:val"] = generated[key]
391+
if ilvl_tag is None:
392+
num_pr = num_id_tag.parent
393+
ilvl_tag = document_soup.new_tag("ilvl", namespace=W_NS, nsprefix="w")
394+
num_pr.insert(0, ilvl_tag)
395+
ilvl_tag["w:val"] = str(depth)
396+
397+
398+
def _pre_process_lists(document_content: bytes, numbering_content: bytes) -> tuple:
399+
"""
400+
Restores list nesting that is expressed through indentation rather than levels.
401+
402+
Args:
403+
document_content (bytes): The XML content of word/document.xml.
404+
numbering_content (bytes): The XML content of word/numbering.xml.
405+
406+
Returns:
407+
tuple: The processed (document_content, numbering_content) as bytes.
408+
"""
409+
document_soup = BeautifulSoup(document_content.decode(), features="xml")
410+
numbering_soup = BeautifulSoup(numbering_content.decode(), features="xml")
411+
412+
numbering = _read_numbering_definitions(numbering_soup)
413+
remappings = _resolve_list_depths(document_soup, numbering)
414+
if not remappings:
415+
return document_content, numbering_content
416+
417+
_apply_list_depths(document_soup, numbering_soup, remappings)
418+
return str(document_soup).encode(), str(numbering_soup).encode()
419+
420+
118421
def pre_process_docx(input_docx: BinaryIO) -> BinaryIO:
119422
"""
120423
Pre-processes a DOCX file with provided steps.
@@ -138,6 +441,21 @@ def pre_process_docx(input_docx: BinaryIO) -> BinaryIO:
138441
]
139442
with zipfile.ZipFile(input_docx, mode="r") as zip_input:
140443
files = {name: zip_input.read(name) for name in zip_input.namelist()}
444+
445+
# List nesting spans document.xml and numbering.xml, so both are
446+
# rewritten together rather than one file at a time below.
447+
if "word/document.xml" in files and "word/numbering.xml" in files:
448+
try:
449+
(
450+
files["word/document.xml"],
451+
files["word/numbering.xml"],
452+
) = _pre_process_lists(
453+
files["word/document.xml"], files["word/numbering.xml"]
454+
)
455+
except Exception:
456+
# If there is an error in processing the content, keep the original content
457+
pass
458+
141459
with zipfile.ZipFile(output_docx, mode="w") as zip_output:
142460
zip_output.comment = zip_input.comment
143461
for name, content in files.items():

0 commit comments

Comments
 (0)