From 41bf21d41e42f14285741637a0ed6ebccf64e89f Mon Sep 17 00:00:00 2001 From: L4XB Date: Thu, 24 Sep 2026 22:32:54 +0200 Subject: [PATCH] fix(docx): read moved text, bidirectional spans and block-level custom XML The Word backend already reads runs inside smart tags, custom XML, tracked insertions and simple fields, and the paragraphs of block-level content controls. It dropped three more wrappers of ordinary content: - w:moveTo, the new position of text moved with track changes on, so moved text disappeared from the output altogether (the old position, w:moveFrom, is correctly left out); - w:dir and w:bdo, bidirectional embeddings and overrides around runs; - block-level w:customXml around body paragraphs and tables. The run recursion now includes moveTo, dir and bdo, and block-level custom XML is walked like the content of a block-level content control. Signed-off-by: L4XB --- docling/backend/msword_backend.py | 15 ++++++++- tests/test_backend_msword.py | 56 ++++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/docling/backend/msword_backend.py b/docling/backend/msword_backend.py index bfa7fcc9ea..79f85d3670 100644 --- a/docling/backend/msword_backend.py +++ b/docling/backend/msword_backend.py @@ -985,6 +985,11 @@ def _in_textbox(elem): # Recursively walk the SDT content to catch textboxes, tables, and nested structures _, te = self._walk_linear(sdt_content, doc) added_elements.extend(te) + # Custom XML markup wraps paragraphs and tables the same way, with no + # content element of its own in between. + elif tag_name == "customXml": + _, te = self._walk_linear(element, doc) + added_elements.extend(te) # Check for Image elif drawing_blip: pics = self._handle_pictures(drawing_blip, doc) @@ -1759,7 +1764,15 @@ def _iter_paragraph_content( def _get_children_recursive(node): for child in node: tag_name = etree.QName(child).localname - if tag_name in {"smartTag", "customXml", "ins", "fldSimple"}: + if tag_name in { + "smartTag", + "customXml", + "ins", + "moveTo", + "fldSimple", + "dir", + "bdo", + }: yield from _get_children_recursive(child) else: yield child diff --git a/tests/test_backend_msword.py b/tests/test_backend_msword.py index ad5c24c403..fa4dffe0df 100644 --- a/tests/test_backend_msword.py +++ b/tests/test_backend_msword.py @@ -23,7 +23,7 @@ from docx import Document from docx.enum.style import WD_STYLE_TYPE from docx.opc.constants import RELATIONSHIP_TYPE as RT -from docx.oxml import OxmlElement +from docx.oxml import OxmlElement, parse_xml from docx.oxml.ns import qn from docx.shared import Inches from lxml import etree @@ -870,6 +870,60 @@ def _append_citation(paragraph, text: str): assert "(Standalone citation)" in markdown +_W_NS = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"' + + +def _w_run(text: str) -> str: + return f'{text}' + + +def _append_to_body(document, xml: str) -> None: + body = document.element.body + body.insert(len(body) - 1, parse_xml(xml)) + + +def test_runs_in_moves_and_bidirectional_spans_are_read(tmp_path): + """Text moved with track changes on, and text in w:dir or w:bdo, belongs to its paragraph.""" + revision = 'w:author="Reviewer" w:date="2024-01-01T00:00:00Z"' + d = Document() + for content in ( + f'{_w_run("Moved away")}' + f'{_w_run("Moved here")}', + _w_run("Before ") + f'{_w_run("embedded")}', + _w_run("Before ") + f'{_w_run("overridden")}', + ): + _append_to_body(d, f"{content}") + + doc = _convert_built(d, tmp_path) + + assert [item.text for item in doc.texts] == [ + "Moved here", + "Before embedded", + "Before overridden", + ] + + +def test_paragraphs_and_tables_in_block_custom_xml_are_read(tmp_path): + """Custom XML markup around body paragraphs and tables does not hide them.""" + d = Document() + d.add_paragraph("Before") + _append_to_body( + d, + f'' + f"{_w_run('In custom XML')}" + "" + f"{_w_run('Name')}" + f"{_w_run('Value')}" + "", + ) + d.add_paragraph("After") + + doc = _convert_built(d, tmp_path) + + assert [item.text for item in doc.texts] == ["Before", "In custom XML", "After"] + assert [cell.text for cell in doc.tables[0].data.table_cells] == ["Name", "Value"] + + def test_block_sdt_tables_are_extracted(): """Test tables wrapped in block-level SDT content controls.""" docx_path = Path("./tests/data/docx/sources/docx_rich_tables_01.docx")