Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 0.25.2

### Fixes

- **Extract tables nested in DOCX text boxes**: `partition_docx()` now finds tables stored inside `w:txbxContent`, as produced when Pages exports a spreadsheet table to Word. Equivalent `mc:Choice` and `mc:Fallback` representations are treated as alternatives so the visible table is emitted once rather than duplicated; ordinary shape text remains ignored as before.

## 0.25.1

### Fixes
Expand Down
42 changes: 42 additions & 0 deletions test_unstructured/partition/test_docx.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@
import pathlib
import re
import tempfile
from copy import deepcopy
from typing import Any, Iterator

import docx
import pytest
from docx.document import Document
from docx.text.paragraph import Paragraph
from lxml import etree
from pytest_mock import MockFixture

from test_unstructured.unit_utils import (
Expand Down Expand Up @@ -149,6 +151,46 @@ def test_partition_docx_processes_table():
assert elements[0].metadata.filename == "fake_table.docx"


def test_partition_docx_processes_table_in_textbox_once():
"""Tables in equivalent AlternateContent branches produce one Table element."""
document = docx.Document()
table = document.add_table(rows=2, cols=2)
table.cell(0, 0).text = "Name"
table.cell(0, 1).text = "Role"
table.cell(1, 0).text = "Alice"
table.cell(1, 1).text = "Engineer"

table_element = table._tbl
table_element.getparent().remove(table_element)

mc_namespace = "http://schemas.openxmlformats.org/markup-compatibility/2006"
w_namespace = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
wps_namespace = "http://schemas.microsoft.com/office/word/2010/wordprocessingShape"
alternate_content = etree.Element(
f"{{{mc_namespace}}}AlternateContent",
nsmap={"mc": mc_namespace, "w": w_namespace, "wps": wps_namespace},
)
choice = etree.SubElement(alternate_content, f"{{{mc_namespace}}}Choice")
choice.set("Requires", "wps")
fallback = etree.SubElement(alternate_content, f"{{{mc_namespace}}}Fallback")
for branch in (choice, fallback):
textbox_content = etree.SubElement(branch, f"{{{w_namespace}}}txbxContent")
textbox_content.append(deepcopy(table_element))

document.add_paragraph().add_run()._r.append(alternate_content)
file = io.BytesIO()
document.save(file)
file.seek(0)

elements = partition_docx(file=file, infer_table_structure=True)

assert [type(element) for element in elements] == [Table]
assert elements[0].text == "Name Role Alice Engineer"
assert elements[0].metadata.text_as_html == (
"<table><tr><td>Name</td><td>Role</td></tr><tr><td>Alice</td><td>Engineer</td></tr></table>"
)


def test_partition_docx_grabs_header_and_footer():
elements = partition_docx(example_doc_path("handbook-1p.docx"))

Expand Down
2 changes: 1 addition & 1 deletion unstructured/__version__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.25.1" # pragma: no cover
__version__ = "0.25.2" # pragma: no cover
43 changes: 42 additions & 1 deletion unstructured/partition/docx.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,10 @@
DETECTION_ORIGIN: str = "docx"
# -- CT_* stands for "complex-type", an XML element type in docx parlance --
BlockElement: TypeAlias = "CT_P | CT_Tbl"
BlockItem: TypeAlias = "Paragraph | DocxTable"

_MC_NAMESPACE = "http://schemas.openxmlformats.org/markup-compatibility/2006"
_MC_CHOICE_TAG = f"{{{_MC_NAMESPACE}}}Choice"
_MC_FALLBACK_TAG = f"{{{_MC_NAMESPACE}}}Fallback"


def register_picture_partitioner(picture_partitioner: PicturePartitionerT) -> None:
Expand Down Expand Up @@ -432,6 +435,39 @@ def _iter_sectionless_document_elements(self) -> Iterator[Element]:
elif isinstance(block_item, DocxTable): # pyright: ignore[reportUnnecessaryIsInstance]
yield from self._iter_table_element(block_item)

@staticmethod
def _is_active_alternate_content_branch(block: BlockElement) -> bool:
"""True when `block` is in the selected branch of any enclosing AlternateContent.

Pages exports text boxes in both `mc:Choice` and `mc:Fallback`, representing the same
visible content twice. Prefer the first choice and use the fallback only when no choice is
present so those equivalent representations do not produce duplicate elements.
"""
for ancestor in block.iterancestors():
if ancestor.tag == _MC_CHOICE_TAG:
alternate_content = ancestor.getparent()
first_choice = next(
Comment thread
yaodong-shen marked this conversation as resolved.
Outdated
(child for child in alternate_content if child.tag == _MC_CHOICE_TAG),
None,
)
if ancestor is not first_choice:
return False
elif ancestor.tag == _MC_FALLBACK_TAG:
alternate_content = ancestor.getparent()
if any(child.tag == _MC_CHOICE_TAG for child in alternate_content):
return False

return True

def _iter_textbox_tables(self, paragraph: Paragraph) -> Iterator[DocxTable]:
"""Generate tables nested in text boxes in `paragraph`."""
tables: list[CT_Tbl] = paragraph._p.xpath(".//w:txbxContent/w:tbl")

for table in tables:
if not self._is_active_alternate_content_branch(table):
continue
yield DocxTable(table, paragraph)

def _classify_paragraph_to_element(self, paragraph: Paragraph) -> Iterator[Element]:
"""Generate zero-or-one document element for `paragraph`.

Expand Down Expand Up @@ -640,6 +676,11 @@ def iter_paragraph_items(paragraph: Paragraph) -> Iterator[Paragraph | RenderedP
else:
yield from self._opts.increment_page_number()

# -- Text-box tables are nested in a paragraph's drawing XML and are not returned by
# -- python-docx's body/section iterators. Shape text remains intentionally ignored.
for table in self._iter_textbox_tables(paragraph):
yield from self._iter_table_element(table)

def _iter_paragraph_emphasis(self, paragraph: Paragraph) -> Iterator[dict[str, str]]:
"""Generate e.g. {"text": "MUST", "tag": "b"} for each emphasis in `paragraph`."""
for run in paragraph.runs:
Expand Down