Repository: docling-project/docling

Issue:
Docling doesn't parse lists in docx correctly
### Bug

Docling incorrectly calculates the list numbers in word documents.

### Steps to reproduce

1. Ingest word document (attached, it's a publicly available document)
2. Convert to any format, markdown or native JSON
3. See the response (attached below)

### Docling version
Docling version: 2.42.2
Docling Core version: 2.43.1
Docling IBM Models version: 3.9.0
Docling Parse version: 4.0.4
Python: cpython-312 (3.12.10)
Platform: macOS-15.6-arm64-arm-64bit

### Python version
Python 3.12.10

**Actual document** 

<img width="1247" height="891" alt="Image" src="https://github.com/user-attachments/assets/89acf773-17c7-4fd6-844a-f08735b9ec9c" />

**Docling Response**

```md

### 6.1 Introduction

### 6.2 Purpose

This policy defines the technical controls and security configurations users and Information Technology (IT) administrators are required to implement in order to ensure the integrity and availability of the data environment at Brendon Health Company, hereinafter, referred to as the Practice. It serves as a central policy document with which all employees and contractors must be familiar, and defines actions and prohibitions that all users must follow.  The policy provides IT managers within the Practice with policies and guidelines concerning the acceptable use of Practice technology equipment, e-mail, Internet connections, voice-mail, facsimile, future technology resources and information processing.

The policy requirements and restrictions defined in this document shall apply to network infrastructures, databases, external media, encryption, hardcopy reports, films, slides, models, wireless, telecommunication, conversations, and any other methods used to convey knowledge and ideas across all hardware, software, and data transmission mechanisms.  This policy must be adhered to by all Practice employees or temporary workers at all locations and by contractors working with the Practice as subcontractors.
```

**Document Source: (it's a public document)**
[info_security_policy_template_v1_0.docx](https://github.com/user-attachments/files/21833396/info_security_policy_template_v1_0.docx)


Discussion:
<!-- Greeting -->
Hi @NikhilVerma! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the docling team.

<!-- Answer -->
Docling currently treats all lists in Word documents as unordered (bulleted) because the code in `msword_backend.py` always sets the `is_numbered` flag to `False` and does not inspect the actual numbering format from the Word file's numbering definitions. To fix this, the code should, for each paragraph with a `numId`, look up the corresponding numbering definition in the DOCX file and set `is_numbered=True` if `w:numFmt` is `'decimal'`, `'roman'`, etc., and `False` if `'bullet'`. This change would ensure that numbered lists in Word documents are correctly rendered as ordered lists in Markdown or JSON outputs. The code is already well-positioned for such a patch, since it has access to `numId` and the document object model needed for this lookup.  
[See source code details](https://github.com/docling-project/docling/blob/c3a7d1d999508ac46700291caab5ffa083dcdc86/docling/backend/msword_backend.py)


<!-- Dosu Comment Footer -->
*To reply, just mention [@dosu](https://go.dosu.dev/dosubot).*

---
<sup>How did I do? [Good](https://app.dosu.dev/response-feedback/1a1a849a-2087-4afe-8cf0-785c760e4db4?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/1a1a849a-2087-4afe-8cf0-785c760e4db4?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/1a1a849a-2087-4afe-8cf0-785c760e4db4?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/1a1a849a-2087-4afe-8cf0-785c760e4db4?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/1a1a849a-2087-4afe-8cf0-785c760e4db4?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/1a1a849a-2087-4afe-8cf0-785c760e4db4?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/1a1a849a-2087-4afe-8cf0-785c760e4db4?feedback_type=other)</sup>&nbsp;&nbsp;[![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/097760a8-135e-4789-8234-90c8837d7f1c/ask?utm_source=github)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/docling-project/docling/issues/2090)
@dosu Yep, can we make it happen?
<!-- Answer -->
Yes, this fix is definitely possible. The core issue is that Docling's DOCX parser (msword_backend.py) doesn't currently check the numbering format (w:numFmt) for each list in the Word file, so all lists are treated as bulleted. To implement the fix, update msword_backend.py so that for each paragraph with a numId, it looks up the corresponding numbering definition in the DOCX XML and sets is_numbered=True if w:numFmt is "decimal", "roman", etc., and False if "bullet". The code already has access to numId and the document object model, so this change is straightforward and self-contained. There are no existing PRs addressing this, so you can proceed without risk of duplication. Testing should focus on verifying that both ordered and unordered lists in DOCX files are correctly reflected in the output formats. 

If you need pointers to the relevant code sections or want a step-by-step on where to add the logic, let me know!


<!-- Dosu Comment Footer -->
*To reply, just mention [@dosu](https://go.dosu.dev/dosubot).*

---
<sup>How did I do? [Good](https://app.dosu.dev/response-feedback/1633fc10-9818-4369-b07c-674c51ab82e4?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/1633fc10-9818-4369-b07c-674c51ab82e4?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/1633fc10-9818-4369-b07c-674c51ab82e4?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/1633fc10-9818-4369-b07c-674c51ab82e4?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/1633fc10-9818-4369-b07c-674c51ab82e4?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/1633fc10-9818-4369-b07c-674c51ab82e4?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/1633fc10-9818-4369-b07c-674c51ab82e4?feedback_type=other)</sup>&nbsp;&nbsp;[![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/097760a8-135e-4789-8234-90c8837d7f1c/ask?utm_source=github)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/docling-project/docling/issues/2090)



Patch:
diff --git a/docling/backend/msword_backend.py b/docling/backend/msword_backend.py
index 45c53a984..c29abb776 100644
--- a/docling/backend/msword_backend.py
+++ b/docling/backend/msword_backend.py
@@ -67,6 +67,8 @@ def __init__(
 
         self.level = 0
         self.listIter = 0
+        # Track list counters per numId and ilvl
+        self.list_counters: dict[tuple[int, int], int] = {}
 
         self.history: dict[str, Any] = {
             "names": [None],
@@ -315,6 +317,108 @@ def _get_numId_and_ilvl(
 
         return None, None  # If the paragraph is not part of a list
 
+    def _get_list_counter(self, numid: int, ilvl: int) -> int:
+        """Get and increment the counter for a specific numId and ilvl combination."""
+        key = (numid, ilvl)
+        if key not in self.list_counters:
+            self.list_counters[key] = 0
+        self.list_counters[key] += 1
+        return self.list_counters[key]
+
+    def _reset_list_counters_for_new_sequence(self, numid: int):
+        """Reset counters when starting a new numbering sequence."""
+        # Reset all counters for this numid
+        keys_to_reset = [key for key in self.list_counters.keys() if key[0] == numid]
+        for key in keys_to_reset:
+            self.list_counters[key] = 0
+
+    def _is_numbered_list(self, docx_obj: DocxDocument, numId: int, ilvl: int) -> bool:
+        """Check if a list is numbered based on its numFmt value."""
+        try:
+            # Access the numbering part of the document
+            if not hasattr(docx_obj, "part") or not hasattr(docx_obj.part, "package"):
+                return False
+
+            numbering_part = None
+            # Find the numbering part
+            for part in docx_obj.part.package.parts:
+                if "numbering" in part.partname:
+                    numbering_part = part
+                    break
+
+            if numbering_part is None:
+                return False
+
+            # Parse the numbering XML
+            numbering_root = numbering_part.element
+            namespaces = {
+                "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
+            }
+
+            # Find the numbering definition with the given numId
+            num_xpath = f".//w:num[@w:numId='{numId}']"
+            num_element = numbering_root.find(num_xpath, namespaces=namespaces)
+
+            if num_element is None:
+                return False
+
+            # Get the abstractNumId from the num element
+            abstract_num_id_elem = num_element.find(
+                ".//w:abstractNumId", namespaces=namespaces
+            )
+            if abstract_num_id_elem is None:
+                return False
+
+            abstract_num_id = abstract_num_id_elem.get(
+                "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val"
+            )
+            if abstract_num_id is None:
+                return False
+
+            # Find the abstract numbering definition
+            abstract_num_xpath = (
+                f".//w:abstractNum[@w:abstractNumId='{abstract_num_id}']"
+            )
+            abstract_num_element = numbering_root.find(
+                abstract_num_xpath, namespaces=namespaces
+            )
+
+            if abstract_num_element is None:
+                return False
+
+            # Find the level definition for the given ilvl
+            lvl_xpath = f".//w:lvl[@w:ilvl='{ilvl}']"
+            lvl_element = abstract_num_element.find(lvl_xpath, namespaces=namespaces)
+
+            if lvl_element is None:
+                return False
+
+            # Get the numFmt element
+            num_fmt_element = lvl_element.find(".//w:numFmt", namespaces=namespaces)
+            if num_fmt_element is None:
+                return False
+
+            num_fmt = num_fmt_element.get(
+                "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val"
+            )
+
+            # Numbered formats include: decimal, lowerRoman, upperRoman, lowerLetter, upperLetter
+            # Bullet formats include: bullet
+            numbered_formats = {
+                "decimal",
+                "lowerRoman",
+                "upperRoman",
+                "lowerLetter",
+                "upperLetter",
+                "decimalZero",
+            }
+
+            return num_fmt in numbered_formats
+
+        except Exception as e:
+            _log.debug(f"Error determining if list is numbered: {e}")
+            return False
+
     def _get_heading_and_level(self, style_label: str) -> tuple[str, Optional[int]]:
         parts = self._split_text_and_number(style_label)
 
@@ -713,8 +817,6 @@ def _handle_text_elements(  # noqa: C901
         # Common styles for bullet and numbered lists.
         # "List Bullet", "List Number", "List Paragraph"
         # Identify whether list is a numbered list or not
-        # is_numbered = "List Bullet" not in paragraph.style.name
-        is_numbered = False
         p_style_id, p_level = self._get_label_and_level(paragraph)
         numid, ilevel = self._get_numId_and_ilvl(paragraph)
 
@@ -727,6 +829,9 @@ def _handle_text_elements(  # noqa: C901
             and ilevel is not None
             and p_style_id not in ["Title", "Heading"]
         ):
+            # Check if this is actually a numbered list by examining the numFmt
+            is_numbered = self._is_numbered_list(docx_obj, numid, ilevel)
+
             self._add_list_item(
                 doc=doc,
                 numid=numid,
@@ -983,15 +1088,19 @@ def _add_list_item(
         if self._prev_numid() is None:  # Open new list
             self.level_at_new_list = level
 
+            # Reset counters for the new numbering sequence
+            self._reset_list_counters_for_new_sequence(numid)
+
             self.parents[level] = doc.add_list_group(
                 name="list", parent=self.parents[level - 1]
             )
 
             # Set marker and enumerated arguments if this is an enumeration element.
-            self.listIter += 1
             if is_numbered:
-                enum_marker = str(self.listIter) + "."
-                is_numbered = True
+                counter = self._get_list_counter(numid, ilevel)
+                enum_marker = str(counter) + "."
+            else:
+                enum_marker = ""
             self._add_formatted_list_item(
                 doc, elements, enum_marker, is_numbered, level
             )
@@ -1005,16 +1114,16 @@ def _add_list_item(
                 self.level_at_new_list + prev_indent + 1,
                 self.level_at_new_list + ilevel + 1,
             ):
-                self.listIter = 0
                 self.parents[i] = doc.add_list_group(
                     name="list", parent=self.parents[i - 1]
                 )
 
             # TODO: Set marker and enumerated arguments if this is an enumeration element.
-            self.listIter += 1
             if is_numbered:
-                enum_marker = str(self.listIter) + "."
-                is_numbered = True
+                counter = self._get_list_counter(numid, ilevel)
+                enum_marker = str(counter) + "."
+            else:
+                enum_marker = ""
             self._add_formatted_list_item(
                 doc,
                 elements,
@@ -1033,10 +1142,11 @@ def _add_list_item(
                     self.parents[k] = None
 
             # TODO: Set marker and enumerated arguments if this is an enumeration element.
-            self.listIter += 1
             if is_numbered:
-                enum_marker = str(self.listIter) + "."
-                is_numbered = True
+                counter = self._get_list_counter(numid, ilevel)
+                enum_marker = str(counter) + "."
+            else:
+                enum_marker = ""
             self._add_formatted_list_item(
                 doc,
                 elements,
@@ -1044,14 +1154,14 @@ def _add_list_item(
                 is_numbered,
                 self.level_at_new_list + ilevel,
             )
-            self.listIter = 0
 
         elif self._prev_numid() == numid or prev_indent == ilevel:
             # TODO: Set marker and enumerated arguments if this is an enumeration element.
-            self.listIter += 1
             if is_numbered:
-                enum_marker = str(self.listIter) + "."
-                is_numbered = True
+                counter = self._get_list_counter(numid, ilevel)
+                enum_marker = str(counter) + "."
+            else:
+                enum_marker = ""
             self._add_formatted_list_item(
                 doc, elements, enum_marker, is_numbered, level - 1
             )


Test patch:
diff --git a/tests/data/groundtruth/docling_v2/unit_test_lists.docx.md b/tests/data/groundtruth/docling_v2/unit_test_lists.docx.md
index b254cb161..35e3b8da8 100644
--- a/tests/data/groundtruth/docling_v2/unit_test_lists.docx.md
+++ b/tests/data/groundtruth/docling_v2/unit_test_lists.docx.md
@@ -12,9 +12,9 @@ Paragraph 2.1.2
 
 #### Test 2:
 
-- List item a
-- List item b
-- List item c
+1. List item a
+2. List item b
+3. List item c
 
 #### Test 3:
 
diff --git a/tests/data/groundtruth/docling_v2/word_sample.docx.md b/tests/data/groundtruth/docling_v2/word_sample.docx.md
index 9c5a96e09..b9d65ca1a 100644
--- a/tests/data/groundtruth/docling_v2/word_sample.docx.md
+++ b/tests/data/groundtruth/docling_v2/word_sample.docx.md
@@ -18,9 +18,9 @@ To get started with swimming, first lay down in a water and try not to drown:
 
 Also, don’t forget:
 
-- Wear sunglasses
-- Don’t forget to drink water
-- Use sun cream
+1. Wear sunglasses
+2. Don’t forget to drink water
+3. Use sun cream
 
 Hmm, what else…
 
@@ -40,6 +40,6 @@ Here are some interesting things a respectful duck could eat:
 
 And let’s add another list in the end:
 
-- Leaves
-- Berries
-- Grain
\ No newline at end of file
+1. Leaves
+2. Berries
+3. Grain
\ No newline at end of file