diff --git a/docling/backend/msword_backend.py b/docling/backend/msword_backend.py index c0324f6fb1..fd122dcfd4 100644 --- a/docling/backend/msword_backend.py +++ b/docling/backend/msword_backend.py @@ -1274,9 +1274,13 @@ def _in_textbox(elem): # This must come before the image branches: they are computed with # descendant XPaths, so a control holding a picture anywhere inside # would match there and its paragraphs would never be walked. - elif tag_name == "sdt": - sdt_content = element.find( - "./w:sdtContent", namespaces=_OOXML_NAMESPACES + # Custom XML markup wraps paragraphs and tables the same way, with no + # content element of its own in between. + elif tag_name in ("sdt", "customXml"): + sdt_content = ( + element.find("./w:sdtContent", namespaces=_OOXML_NAMESPACES) + if tag_name == "sdt" + else element ) # Recursively walk the SDT content to catch textboxes, tables, and nested structures added_elements.extend(self._walk_optional_container(sdt_content, doc)) @@ -2272,7 +2276,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 314fa1c6f3..f4ddb021c0 100644 --- a/tests/test_backend_msword.py +++ b/tests/test_backend_msword.py @@ -24,7 +24,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 @@ -910,6 +910,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")