diff --git a/docling/backend/msword_backend.py b/docling/backend/msword_backend.py index 827f0e0e9a..783e3a40cd 100644 --- a/docling/backend/msword_backend.py +++ b/docling/backend/msword_backend.py @@ -1131,6 +1131,8 @@ def _walk_linear( ) -> tuple[DoclingDocument, list[RefItem]]: added_elements = [] for element in body: + if not isinstance(element.tag, str): + continue tag_name = etree.QName(element).localname # Check for Inline Images (blip elements) _raw_drawing_blip = self.blip_xpath_expr(element) @@ -1143,7 +1145,8 @@ def _walk_linear( # (they will be properly extracted by _handle_textbox_content instead) def _in_textbox(elem): return any( - etree.QName(anc).localname in ["txbxContent", "textbox"] + isinstance(anc.tag, str) + and etree.QName(anc).localname in ["txbxContent", "textbox"] for anc in elem.iterancestors() ) @@ -2105,6 +2108,8 @@ def _iter_paragraph_content( def _get_children_recursive(node): for child in node: + if not isinstance(child.tag, str): + continue tag_name = etree.QName(child).localname if tag_name in {"smartTag", "customXml", "ins", "fldSimple"}: yield from _get_children_recursive(child) @@ -2112,6 +2117,8 @@ def _get_children_recursive(node): yield child for child in _get_children_recursive(paragraph._p): + if not isinstance(child.tag, str): + continue tag_name = etree.QName(child).localname if tag_name == "sdt": @@ -2309,7 +2316,9 @@ def _get_paragraph_position(self, paragraph_element): parent = paragraph_element.getparent() # Get all paragraph siblings paragraphs = [ - p for p in parent.getchildren() if etree.QName(p).localname == "p" + p + for p in parent.getchildren() + if isinstance(p.tag, str) and etree.QName(p).localname == "p" ] # Find index of current paragraph within its siblings try: @@ -2433,6 +2442,9 @@ def _collect_textbox_paragraphs( if element in processed_paragraphs: continue + if not isinstance(element.tag, str): + continue + tag_name = etree.QName(element).localname processed_paragraphs.add(element) @@ -2445,7 +2457,9 @@ def _collect_textbox_paragraphs( # Find the containing textbox or shape element container_id = None for ancestor in element.iterancestors(): - if any(ns in ancestor.tag for ns in ["textbox", "shape", "txbx"]): + if isinstance(ancestor.tag, str) and any( + ns in ancestor.tag for ns in ["textbox", "shape", "txbx"] + ): container_id = ancestor break @@ -2564,13 +2578,17 @@ def _handle_equations_in_text(self, element, text): direct_omaths = [ child for child in element - if "oMath" in child.tag and "oMathPara" not in child.tag + if isinstance(child.tag, str) + and "oMath" in child.tag + and "oMathPara" not in child.tag ] if direct_omaths: # Iterate direct children to preserve sibling order and avoid # processing nested oMath descendants of an already-converted node. for child in element: + if not isinstance(child.tag, str): + continue if "oMath" in child.tag and "oMathPara" not in child.tag: latex_equation = str(oMath2Latex(child)).strip() if len(latex_equation) > 0: @@ -2583,6 +2601,8 @@ def _handle_equations_in_text(self, element, text): else: # Collect text from non-math children (e.g. runs) for t_elem in child.iter(): + if not isinstance(t_elem.tag, str): + continue t_tag = etree.QName(t_elem).localname if t_tag == "t" and "math" not in t_elem.tag: if isinstance(t_elem.text, str): @@ -2592,6 +2612,8 @@ def _handle_equations_in_text(self, element, text): # Original deep-iteration fallback for nested oMath (e.g. # inside oMathPara or other wrapper elements). for subt in element.iter(): + if not isinstance(subt.tag, str): + continue tag_name = etree.QName(subt).localname if tag_name == "t" and "math" not in subt.tag: if isinstance(subt.text, str): @@ -3459,6 +3481,8 @@ def _row_cells(row_element: BaseOxmlElement) -> list[BaseOxmlElement]: """ cells: list[BaseOxmlElement] = [] for child in row_element: + if not isinstance(child.tag, str): + continue tag_name = etree.QName(child).localname if tag_name == "tc": cells.append(child) @@ -3613,6 +3637,8 @@ def _has_blip(self, element: BaseOxmlElement) -> bool: """ for item in element: + if not isinstance(item.tag, str): + continue if self.blip_xpath_expr(item): return True if item.findall(".//w:drawing", namespaces=_OOXML_NAMESPACES): @@ -3650,6 +3676,8 @@ def _is_rich_table_cell(self, cell: _Cell) -> bool: # no other content allowed_tags = {"p", "tcPr"} # paragraph or table-cell properties for child in tc: + if not isinstance(child.tag, str): + continue tag = child.tag.split("}")[-1] if tag not in allowed_tags: return True @@ -3794,10 +3822,11 @@ def _convert_elements_via_docx( parent_elem = elements while parent_elem is not None: - tag = etree.QName(parent_elem).localname - if tag in element_tag: - new_r._r.append(deepcopy(parent_elem)) - break + if isinstance(parent_elem.tag, str): + tag = etree.QName(parent_elem).localname + if tag in element_tag: + new_r._r.append(deepcopy(parent_elem)) + break parent_elem = parent_elem.getparent() if parent_elem is None: @@ -4019,6 +4048,8 @@ def _classify_chart(self, chart_root: Any) -> PictureClassificationLabel: plot_area = chart_root.find(".//c:plotArea", namespaces=_OOXML_NAMESPACES) if plot_area is not None: for child in plot_area: + if not isinstance(child.tag, str): + continue label = _CHART_TAGNAME_TO_CLASSIFICATION.get( etree.QName(child).localname ) diff --git a/tests/test_backend_msword.py b/tests/test_backend_msword.py index 0b0aea4014..0d7fa6229b 100644 --- a/tests/test_backend_msword.py +++ b/tests/test_backend_msword.py @@ -2226,3 +2226,79 @@ def test_footnotes_and_endnotes_are_not_dropped(): assert len(footnote_items) == 2, ( "expected exactly one real footnote and one real endnote, no placeholders" ) + + +def _inject_into_document_xml( + doc: Document, transformations: list[tuple[bytes, bytes]] +): + import zipfile + from io import BytesIO + + buf = BytesIO() + doc.save(buf) + buf.seek(0) + with zipfile.ZipFile(buf) as src: + entries = {name: src.read(name) for name in src.namelist()} + doc_xml = entries["word/document.xml"] + for old, new in transformations: + doc_xml = doc_xml.replace(old, new, 1) + entries["word/document.xml"] = doc_xml + out = BytesIO() + with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as dst: + for name, data in entries.items(): + dst.writestr(name, data) + out.seek(0) + return out + + +def test_docx_xml_comments_and_processing_instructions(): + """XML comments and processing instructions must not crash or drop table text. + + Regression test for #4583: docx4j and other generators insert XML comments + or processing instructions in document.xml (e.g. right after , in + paragraphs, runs, table rows, and cells). Because lxml comments and PIs do + not have string tags, QName or tag attribute operations failed with + ValueError or TypeError, aborting conversion or dropping table cells. + """ + document = Document() + document.add_paragraph("Intro paragraph before table.") + table = document.add_table(rows=2, cols=2) + table.cell(0, 0).text = "Cell (0,0)" + table.cell(0, 1).text = "Cell (0,1)" + table.cell(1, 0).text = "Cell (1,0)" + table.cell(1, 1).text = "Cell (1,1)" + document.add_paragraph("Outro paragraph after table.") + + stream_buf = _inject_into_document_xml( + document, + [ + ( + b"", + b"", + ), + (b"", b""), + (b"", b""), + (b"", b""), + (b"", b""), + ], + ) + + stream = DocumentStream(name="comments_test.docx", stream=stream_buf) + converter = DocumentConverter(allowed_formats=[InputFormat.DOCX]) + result = converter.convert(stream, raises_on_error=True) + + text = result.document.export_to_markdown() + assert "Intro paragraph before table." in text + assert "Outro paragraph after table." in text + assert "Cell (0,0)" in text + assert "Cell (0,1)" in text + assert "Cell (1,0)" in text + assert "Cell (1,1)" in text + assert len(result.document.tables) == 1 + table_cells_text = [c.text for c in result.document.tables[0].data.table_cells] + assert table_cells_text == [ + "Cell (0,0)", + "Cell (0,1)", + "Cell (1,0)", + "Cell (1,1)", + ]