Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 39 additions & 8 deletions docling/backend/msword_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
)

Expand Down Expand Up @@ -2105,13 +2108,17 @@ 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)
else:
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":
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -2583,6 +2601,8 @@ def _handle_equations_in_text(self, element, text):
else:
# Collect text from non-math children (e.g. <w:r> 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):
Expand All @@ -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):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
)
Expand Down
76 changes: 76 additions & 0 deletions tests/test_backend_msword.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <w:body>, 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"<w:body>",
b"<w:body><!-- Created by docx4j 8.2.8 --><?custom-pi key='val'?>",
),
(b"<w:p>", b"<w:p><!-- comment at start of paragraph -->"),
(b"<w:r>", b"<w:r><!-- comment inside run -->"),
(b"<w:tr>", b"<w:tr><!-- comment inside table row -->"),
(b"<w:tc>", b"<w:tc><!-- comment inside table cell -->"),
],
)

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)",
]
Loading