From bde71bd99351cb591a8a00f94b58dc36919cd9b5 Mon Sep 17 00:00:00 2001 From: Lubaba Khalid <129605617+LubabaKhalid@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:40:15 +0500 Subject: [PATCH 1/4] fix(docx): resolve linked style numbering Signed-off-by: Lubaba Khalid <129605617+LubabaKhalid@users.noreply.github.com> --- docling/backend/msword_backend.py | 240 +++++++++++++++++++++++------ tests/test_backend_msword.py | 93 ++++++++++- tests/test_backend_msword_lists.py | 69 +++++++++ 3 files changed, 351 insertions(+), 51 deletions(-) diff --git a/docling/backend/msword_backend.py b/docling/backend/msword_backend.py index 827f0e0e9a..04e862e347 100644 --- a/docling/backend/msword_backend.py +++ b/docling/backend/msword_backend.py @@ -1420,46 +1420,88 @@ def _style_numbering( return numId, ilvl def _get_level_element(self, numid: int, ilvl: int) -> BaseOxmlElement | None: - """Find the level element from the numbering XML for a given numId and ilvl.""" + """Find a numbering level, following linked numbering styles.""" try: numbering_part = None + styles_part = None for part in self.docx_obj.part.package.parts: if "numbering" in part.partname: numbering_part = part - break + elif part.partname.endswith("/word/styles.xml"): + styles_part = part if numbering_part is None: return None numbering_root = numbering_part.element namespaces = _OOXML_NAMESPACES - num_element = numbering_root.find( f".//w:num[@w:numId='{numid}']", namespaces=namespaces ) if num_element is None: return None - abstract_num_id_elem = num_element.find( - ".//w:abstractNumId", namespaces=namespaces - ) - if abstract_num_id_elem is None: - return None + visited_abstract_ids: set[str] = set() + for _ in range(self._MAX_STYLE_INHERITANCE_DEPTH): + abstract_id_elem = num_element.find( + "w:abstractNumId", namespaces=namespaces + ) + if abstract_id_elem is None: + return None - abstract_num_id = abstract_num_id_elem.get(self.XML_KEY) - if abstract_num_id is None: - return None + abstract_id = abstract_id_elem.get(self.XML_KEY) + if abstract_id is None or abstract_id in visited_abstract_ids: + return None + visited_abstract_ids.add(abstract_id) - abstract_num_element = numbering_root.find( - f".//w:abstractNum[@w:abstractNumId='{abstract_num_id}']", - namespaces=namespaces, - ) - if abstract_num_element is None: - return None + abstract_element = numbering_root.find( + f".//w:abstractNum[@w:abstractNumId='{abstract_id}']", + namespaces=namespaces, + ) + if abstract_element is None: + return None - return abstract_num_element.find( - f".//w:lvl[@w:ilvl='{ilvl}']", namespaces=namespaces - ) + level_element = abstract_element.find( + f"w:lvl[@w:ilvl='{ilvl}']", namespaces=namespaces + ) + if level_element is not None: + return level_element + + style_link = abstract_element.find( + "w:numStyleLink", namespaces=namespaces + ) + if style_link is None or styles_part is None: + return None + + style_id = style_link.get(self.XML_KEY) + if not style_id: + return None + + style_element = styles_part.element.find( + f".//w:style[@w:styleId='{style_id}']", + namespaces=namespaces, + ) + if style_element is None: + return None + + style_num_id = style_element.find( + "w:pPr/w:numPr/w:numId", namespaces=namespaces + ) + if style_num_id is None: + return None + + linked_num_id = style_num_id.get(self.XML_KEY) + if not linked_num_id: + return None + + num_element = numbering_root.find( + f".//w:num[@w:numId='{linked_num_id}']", + namespaces=namespaces, + ) + if num_element is None: + return None + + return None except Exception as e: _log.debug(f"Error finding level element: {e}") return None @@ -1616,6 +1658,73 @@ def _replace(match, _fmts=fmt_by_lvl): ) return ".".join(parts) + "." + def _get_caption_numbering_marker( + self, numid: int, ilevel: int, p_style_id: str + ) -> str | None: + """Resolve custom caption numbering that should be part of paragraph text.""" + if p_style_id in ["Title", "Heading", "Code"] or p_style_id.lower().startswith( + ("list", "normal") + ): + return None + + lvl_element = self._get_level_element(numid, ilevel) + lvl_text_element = ( + lvl_element.find("w:lvlText", namespaces=_OOXML_NAMESPACES) + if lvl_element is not None + else None + ) + lvl_text = ( + lvl_text_element.get(self.XML_KEY) if lvl_text_element is not None else None + ) + if not lvl_text: + return None + + referenced_levels = [ + int(match) - 1 for match in re.findall(r"%(\d+)", lvl_text) + ] + literal_prefix = re.sub(r"%(\d+)", "", lvl_text) + if not ( + any(level < ilevel for level in referenced_levels) + and any(char.isalpha() for char in literal_prefix) + ): + return None + + self._get_list_counter(numid, ilevel) + return self._build_enum_marker(numid, ilevel) + + def _apply_caption_numbering( + self, paragraph_elements, numid, ilevel, p_style_id + ) -> bool: + """Add custom caption numbering to its text instead of creating a list.""" + if numid is None or ilevel is None: + return False + + marker = self._get_caption_numbering_marker(numid, ilevel, p_style_id) + if not marker: + return False + + for index, (run_text, formatting, hyperlink) in enumerate(paragraph_elements): + if run_text and run_text.strip(): + paragraph_elements[index] = ( + f"{marker} {run_text}", + formatting, + hyperlink, + ) + break + + return True + + @staticmethod + def _should_handle_as_list( + numid: int | None, ilevel: int | None, p_style_id: str + ) -> bool: + """Return whether a paragraph should be processed as a structural list.""" + return ( + numid is not None + and ilevel is not None + and p_style_id not in ["Title", "Heading", "Code"] + ) + def _has_visible_numbering_format(self, numId: int, ilvl: int) -> bool: """Return True when numbering.xml defines a visible marker for numId/ilvl.""" try: @@ -2692,12 +2801,12 @@ def _handle_text_elements( if numid == 0: numid = None + if self._apply_caption_numbering(paragraph_elements, numid, ilevel, p_style_id): + numid = None + ilevel = None + # Handle lists - if ( - numid is not None - and ilevel is not None - and p_style_id not in ["Title", "Heading", "Code"] - ): + if self._should_handle_as_list(numid, ilevel, p_style_id): # Check if this is actually a numbered list by examining the numFmt is_numbered = self._has_visible_numbering_format(numid, ilevel) left_indent = self._get_list_left_indent(paragraph, numid, ilevel) @@ -2783,7 +2892,7 @@ def _handle_text_elements( # next list item opens fresh under this heading. self.level_at_new_list = None is_numbered_style = self._is_numbered_heading(paragraph) - h1 = self._add_heading(doc, p_level, text, is_numbered_style) + h1 = self._add_heading(doc, p_level, text, is_numbered_style, numid, ilevel) elem_ref.extend(h1) elif len(equations) > 0: @@ -2880,31 +2989,14 @@ def _handle_text_elements( else: self._end_list_on_body_text(text) - level = self._get_level() - parent = self._create_or_reuse_parent( - doc=doc, - prev_parent=self.parents.get(level - 1), - paragraph_elements=paragraph_elements, - ) - for text, format, hyperlink in paragraph_elements: - # Clean checkbox symbols from text if this is a checkbox item - clean_text = ( - self._clean_checkbox_symbols(text) if checkbox_label else text - ) - - # Skip empty text items - if skip_empty_text and (not clean_text or not clean_text.strip()): - continue - - text_item = doc.add_text( - label=checkbox_label if checkbox_label else DocItemLabel.TEXT, - parent=parent, - text=clean_text, - formatting=format, - hyperlink=hyperlink, - content_layer=self.content_layer, + elem_ref.extend( + self._add_body_text_items( + doc=doc, + paragraph_elements=paragraph_elements, + checkbox_label=checkbox_label, + skip_empty_text=skip_empty_text, ) - elem_ref.append(text_item.get_ref()) + ) self._update_history(p_style_id, p_level, numid, ilevel) @@ -2916,12 +3008,47 @@ def _handle_text_elements( return elem_ref + def _add_body_text_items( + self, + doc: DoclingDocument, + paragraph_elements: list, + checkbox_label, + skip_empty_text: bool, + ) -> list[RefItem]: + """Add ordinary paragraph text items under the current parent.""" + level = self._get_level() + parent = self._create_or_reuse_parent( + doc=doc, + prev_parent=self.parents.get(level - 1), + paragraph_elements=paragraph_elements, + ) + elem_ref: list[RefItem] = [] + + for text, formatting, hyperlink in paragraph_elements: + clean_text = self._clean_checkbox_symbols(text) if checkbox_label else text + if skip_empty_text and (not clean_text or not clean_text.strip()): + continue + + text_item = doc.add_text( + label=checkbox_label if checkbox_label else DocItemLabel.TEXT, + parent=parent, + text=clean_text, + formatting=formatting, + hyperlink=hyperlink, + content_layer=self.content_layer, + ) + elem_ref.append(text_item.get_ref()) + + return elem_ref + def _add_heading( self, doc: DoclingDocument, curr_level: int | None, text: str, is_numbered_style: bool = False, + numid: int | None = None, + ilevel: int | None = None, ) -> list[RefItem]: elem_ref: list[RefItem] = [] level = self._get_level() @@ -2978,6 +3105,19 @@ def _add_heading( text = f"{self.numbered_headers[previous_level]}.{text}" previous_level -= 1 + # Make the heading counter available to numbering templates that + # reference this same numId, such as "Table %1.%7". Preserve deeper + # list counters: a heading between list items must not restart them. + if numid is not None: + heading_ilevel = ilevel if ilevel is not None else 0 + self.list_counters[(numid, heading_ilevel)] = self.numbered_headers[ + add_level + ] + # A heading may seed a counter used by a later list template + # with the same numId (e.g. "Tabell %1.%7"). Don't let the + # first list item reset that shared numbering sequence. + self.started_numids.add(numid) + hd = doc.add_heading( parent=self.parents[parent_level], text=text, diff --git a/tests/test_backend_msword.py b/tests/test_backend_msword.py index 0b0aea4014..b6eee70287 100644 --- a/tests/test_backend_msword.py +++ b/tests/test_backend_msword.py @@ -1188,6 +1188,97 @@ def test_list_counter_and_enum_marker(docx_paths): assert backend.list_counters[(2, 0)] == 1 # unaffected +def test_numbered_heading_syncs_list_counter(docx_paths, monkeypatch): + """Heading numbering should be available to custom numbering templates.""" + docx_path = docx_paths[0] + in_doc = InputDocument( + path_or_stream=docx_path, + format=InputFormat.DOCX, + backend=MsWordDocumentBackend, + ) + backend = in_doc._backend + + doc = DoclingDocument(name="heading-counter-test") + backend._add_heading( + doc, + curr_level=1, + text="Chapter", + is_numbered_style=True, + numid=200, + ilevel=0, + ) + + assert backend.numbered_headers[1] == 1 + assert backend.list_counters[(200, 0)] == 1 + + backend._add_heading( + doc, + curr_level=1, + text="Next chapter", + is_numbered_style=True, + numid=200, + ilevel=0, + ) + + assert backend.numbered_headers[1] == 2 + assert backend.list_counters[(200, 0)] == 2 + + # A table-title numbering template can refer to the chapter counter + # through %1 while using level 6 for its own counter through %7. + level = OxmlElement("w:lvl") + level.set(qn("w:ilvl"), "6") + + num_fmt = OxmlElement("w:numFmt") + num_fmt.set(qn("w:val"), "decimal") + level.append(num_fmt) + + lvl_text = OxmlElement("w:lvlText") + lvl_text.set(qn("w:val"), "Tabell %1.%7") + level.append(lvl_text) + + monkeypatch.setattr( + backend, + "_get_level_element", + lambda numid, ilvl: level, + ) + + backend._get_list_counter(200, 6) + assert backend._build_enum_marker(200, 6) == "Tabell 2.1" + + +def test_custom_caption_numbering_is_added_to_paragraph_text(docx_paths, monkeypatch): + """Custom caption numbers should be paragraph text, not list markers.""" + in_doc = InputDocument( + path_or_stream=docx_paths[0], + format=InputFormat.DOCX, + backend=MsWordDocumentBackend, + ) + backend = in_doc._backend + + level = OxmlElement("w:lvl") + lvl_text = OxmlElement("w:lvlText") + lvl_text.set(qn("w:val"), "Tabell %1.%7") + level.append(lvl_text) + + monkeypatch.setattr(backend, "_get_level_element", lambda numid, ilevel: level) + monkeypatch.setattr(backend, "_get_list_counter", lambda numid, ilevel: 1) + monkeypatch.setattr( + backend, "_build_enum_marker", lambda numid, ilevel: "Tabell 14.1" + ) + + paragraph_elements = [("Saker som kan rettsmekles i tingrettene", None, None)] + + assert backend._apply_caption_numbering( + paragraph_elements, numid=200, ilevel=6, p_style_id="Caption" + ) + assert paragraph_elements[0][0] == ( + "Tabell 14.1 Saker som kan rettsmekles i tingrettene" + ) + + # The caller clears numid/ilevel after applying the caption marker. + assert not backend._should_handle_as_list(None, None, "Caption") + + def test_custom_numbering_format_markers(tmp_path): """Test that lvlText templates like 'Proposal %1:' produce correct markers. @@ -1329,7 +1420,7 @@ def __init__(self, element, docx_obj): ) monkeypatch.setattr(backend, "_get_numId_and_ilvl", lambda paragraph: (None, None)) - def fake_add_heading(doc, level, text, is_numbered_style): + def fake_add_heading(doc, level, text, is_numbered_style, numid=None, ilevel=None): captured["heading"] = (level, text, is_numbered_style) return [] diff --git a/tests/test_backend_msword_lists.py b/tests/test_backend_msword_lists.py index 29d7ec2ba1..d467cb5c3c 100644 --- a/tests/test_backend_msword_lists.py +++ b/tests/test_backend_msword_lists.py @@ -911,3 +911,72 @@ def test_list_nesting_follows_word_indentation_precedence(tmp_path): assert items["back"].parent == outer assert items["by style"].parent.resolve(doc).parent == outer assert items["by paragraph"].parent.resolve(doc).parent == outer + + +def test_get_level_element_follows_num_style_link(tmp_path): + """Resolve levels through a numbering definition's w:numStyleLink.""" + doc = Document() + numbering = doc.part.numbering_part.element + + # The paragraph numbering points to an abstract definition that contains + # numStyleLink rather than a direct level. + linked_abstract = OxmlElement("w:abstractNum") + linked_abstract.set(qn("w:abstractNumId"), "300") + style_link = OxmlElement("w:numStyleLink") + style_link.set(qn("w:val"), "LinkedNumberingStyle") + linked_abstract.append(style_link) + numbering.append(linked_abstract) + + # The linked paragraph style points to a second numbering definition. + linked_style = doc.styles.add_style("LinkedNumberingStyle", WD_STYLE_TYPE.LIST) + ppr = linked_style.element.get_or_add_pPr() + numpr = OxmlElement("w:numPr") + num_id_elem = OxmlElement("w:numId") + num_id_elem.set(qn("w:val"), "401") + numpr.append(num_id_elem) + ppr.append(numpr) + + target_abstract = OxmlElement("w:abstractNum") + target_abstract.set(qn("w:abstractNumId"), "400") + level = OxmlElement("w:lvl") + level.set(qn("w:ilvl"), "0") + + start = OxmlElement("w:start") + start.set(qn("w:val"), "1") + level.append(start) + + num_fmt = OxmlElement("w:numFmt") + num_fmt.set(qn("w:val"), "decimal") + level.append(num_fmt) + + lvl_text = OxmlElement("w:lvlText") + lvl_text.set(qn("w:val"), "%1") + level.append(lvl_text) + + target_abstract.append(level) + numbering.append(target_abstract) + + for num_id, abstract_id in (("301", "300"), ("401", "400")): + num = OxmlElement("w:num") + num.set(qn("w:numId"), num_id) + abstract_ref = OxmlElement("w:abstractNumId") + abstract_ref.set(qn("w:val"), abstract_id) + num.append(abstract_ref) + numbering.append(num) + + docx_path = tmp_path / "num_style_link.docx" + doc.save(str(docx_path)) + + in_doc = InputDocument( + path_or_stream=docx_path, + format=InputFormat.DOCX, + backend=MsWordDocumentBackend, + filename=docx_path.name, + ) + backend = MsWordDocumentBackend(in_doc=in_doc, path_or_stream=docx_path) + + resolved = backend._get_level_element(301, 0) + + assert resolved is not None + assert resolved.get(qn("w:ilvl")) == "0" + assert resolved.find(qn("w:lvlText")).get(qn("w:val")) == "%1" From 70ca33fa88c4fb5b0086ca992aa13b6f18fcdb36 Mon Sep 17 00:00:00 2001 From: Lubaba Khalid <129605617+LubabaKhalid@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:01:16 +0500 Subject: [PATCH 2/4] fix(docx): reset chapter-dependent caption counters --- docling/backend/msword_backend.py | 28 ++++++++++++++++++++++++++++ tests/test_backend_msword.py | 23 ++++++++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/docling/backend/msword_backend.py b/docling/backend/msword_backend.py index 04e862e347..f551e8eb17 100644 --- a/docling/backend/msword_backend.py +++ b/docling/backend/msword_backend.py @@ -3113,6 +3113,34 @@ def _add_heading( self.list_counters[(numid, heading_ilevel)] = self.numbered_headers[ add_level ] + + # Restart deeper counters whose numbering templates depend on + # this heading level (e.g. "Tabell %1.%7"). Unrelated list + # counters sharing the same numId should retain their state. + heading_placeholder = re.compile( + rf"%{heading_ilevel + 1}(?!\d)" + ) + for (counter_numid, counter_ilevel) in list(self.list_counters): + if counter_numid != numid or counter_ilevel <= heading_ilevel: + continue + counter_level = self._get_level_element( + counter_numid, counter_ilevel + ) + lvl_text_element = ( + counter_level.find( + "w:lvlText", namespaces=_OOXML_NAMESPACES + ) + if counter_level is not None + else None + ) + lvl_text = ( + lvl_text_element.get(self.XML_KEY) + if lvl_text_element is not None + else None + ) + if lvl_text and heading_placeholder.search(lvl_text): + self.list_counters[(counter_numid, counter_ilevel)] = 0 + # A heading may seed a counter used by a later list template # with the same numId (e.g. "Tabell %1.%7"). Don't let the # first list item reset that shared numbering sequence. diff --git a/tests/test_backend_msword.py b/tests/test_backend_msword.py index b6eee70287..8e65186415 100644 --- a/tests/test_backend_msword.py +++ b/tests/test_backend_msword.py @@ -1236,15 +1236,36 @@ def test_numbered_heading_syncs_list_counter(docx_paths, monkeypatch): lvl_text.set(qn("w:val"), "Tabell %1.%7") level.append(lvl_text) + unrelated_level = OxmlElement("w:lvl") + unrelated_lvl_text = OxmlElement("w:lvlText") + unrelated_lvl_text.set(qn("w:val"), "%8") + unrelated_level.append(unrelated_lvl_text) + monkeypatch.setattr( backend, "_get_level_element", - lambda numid, ilvl: level, + lambda numid, ilvl: unrelated_level if ilvl == 7 else level, ) backend._get_list_counter(200, 6) assert backend._build_enum_marker(200, 6) == "Tabell 2.1" + # A new chapter should restart dependent caption counters while preserving + # unrelated deeper counters sharing the same numId. + backend.list_counters[(200, 7)] = 4 + backend._add_heading( + doc, + curr_level=1, + text="Third chapter", + is_numbered_style=True, + numid=200, + ilevel=0, + ) + assert backend.list_counters[(200, 7)] == 4 + + backend._get_list_counter(200, 6) + assert backend._build_enum_marker(200, 6) == "Tabell 3.1" + def test_custom_caption_numbering_is_added_to_paragraph_text(docx_paths, monkeypatch): """Custom caption numbers should be paragraph text, not list markers.""" From 23914154a8dbc6f2073e807582367bd5880636d1 Mon Sep 17 00:00:00 2001 From: Lubaba Khalid <129605617+LubabaKhalid@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:04:02 +0500 Subject: [PATCH 3/4] style: format caption counter reset logic Signed-off-by: Lubaba Khalid <129605617+LubabaKhalid@users.noreply.github.com> --- docling/backend/msword_backend.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/docling/backend/msword_backend.py b/docling/backend/msword_backend.py index f551e8eb17..07ae6151b1 100644 --- a/docling/backend/msword_backend.py +++ b/docling/backend/msword_backend.py @@ -3117,19 +3117,15 @@ def _add_heading( # Restart deeper counters whose numbering templates depend on # this heading level (e.g. "Tabell %1.%7"). Unrelated list # counters sharing the same numId should retain their state. - heading_placeholder = re.compile( - rf"%{heading_ilevel + 1}(?!\d)" - ) - for (counter_numid, counter_ilevel) in list(self.list_counters): + heading_placeholder = re.compile(rf"%{heading_ilevel + 1}(?!\d)") + for counter_numid, counter_ilevel in list(self.list_counters): if counter_numid != numid or counter_ilevel <= heading_ilevel: continue counter_level = self._get_level_element( counter_numid, counter_ilevel ) lvl_text_element = ( - counter_level.find( - "w:lvlText", namespaces=_OOXML_NAMESPACES - ) + counter_level.find("w:lvlText", namespaces=_OOXML_NAMESPACES) if counter_level is not None else None ) From 6c125d73fa2640b4a5e889893dbb0315c0fd9cf0 Mon Sep 17 00:00:00 2001 From: Lubaba Khalid <129605617+LubabaKhalid@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:06:12 +0500 Subject: [PATCH 4/4] DCO Remediation Commit for Lubaba Khalid <129605617+LubabaKhalid@users.noreply.github.com> I, Lubaba Khalid <129605617+LubabaKhalid@users.noreply.github.com>, hereby add my Signed-off-by to this commit: 70ca33fa88c4fb5b0086ca992aa13b6f18fcdb36 Signed-off-by: Lubaba Khalid <129605617+LubabaKhalid@users.noreply.github.com>