@@ -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+
118421def 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