From 60ab580f8b1f2c20cffa650b707948ff9792c6fc Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:57:38 +0800 Subject: [PATCH] fix(documents): preserve DOCX table content --- nanobot/utils/document.py | 30 ++++++++++++++++++++++++++---- tests/test_context_documents.py | 20 ++++++++++++++++++++ tests/test_document_parsing.py | 23 +++++++++++++++++++++++ 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/nanobot/utils/document.py b/nanobot/utils/document.py index fec6ed4b..5570a1ee 100644 --- a/nanobot/utils/document.py +++ b/nanobot/utils/document.py @@ -210,6 +210,8 @@ def _extract_docx(path: Path) -> str: """Extract text from DOCX using python-docx.""" try: from docx import Document as DocxDocument + from docx.table import Table + from docx.text.paragraph import Paragraph except ImportError: return "[error: python-docx not installed]" try: @@ -217,10 +219,30 @@ def _extract_docx(path: Path) -> str: return error doc = DocxDocument(path) collector = _TextCollector(_MAX_TEXT_LENGTH) - for paragraph in doc.paragraphs: - text = paragraph.text.strip() - if text and not collector.add(text, separator="\n\n"): - break + for block in doc.iter_inner_content(): + if isinstance(block, Paragraph): + text = block.text.strip() + if text and not collector.add(text, separator="\n\n"): + break + continue + if not isinstance(block, Table): + continue + first_row = True + for row in block.rows: + cells: list[str] = [] + seen_cells: set[int] = set() + for cell in row.cells: + cell_id = id(cell._tc) + if cell_id in seen_cells: + continue + seen_cells.add(cell_id) + cells.append(" ".join(cell.text.split())) + if not any(cells): + continue + separator = "\n\n" if first_row else "\n" + first_row = False + if not collector.add("\t".join(cells), separator=separator): + return collector.render() return collector.render() except Exception as e: logger.exception("Failed to extract DOCX {}", path) diff --git a/tests/test_context_documents.py b/tests/test_context_documents.py index 7d9ac908..b90abb4d 100644 --- a/tests/test_context_documents.py +++ b/tests/test_context_documents.py @@ -93,6 +93,26 @@ def test_drain_pending_path_preserves_document_text(tmp_path: Path) -> None: assert "summarize" in result +def test_drain_pending_path_preserves_docx_table_text(tmp_path: Path) -> None: + """Uploaded Word forms must retain content stored in table cells.""" + from docx import Document + + doc = Document() + table = doc.add_table(rows=2, cols=2) + table.cell(0, 0).text = "Applicant" + table.cell(0, 1).text = "Ada Lovelace" + table.cell(1, 0).text = "Research area" + table.cell(1, 1).text = "Analytical engines" + docx_path = tmp_path / "application.docx" + doc.save(docx_path) + + content, image_only = extract_documents("summarize", [str(docx_path)]) + + assert image_only == [] + assert "Applicant\tAda Lovelace" in content + assert "Research area\tAnalytical engines" in content + + def test_drain_pending_path_without_extract_loses_document(tmp_path: Path) -> None: """Demonstrates the BUG: if _drain_pending calls _build_user_content directly without extract_documents, document content is lost.""" diff --git a/tests/test_document_parsing.py b/tests/test_document_parsing.py index be4e996f..5657e784 100644 --- a/tests/test_document_parsing.py +++ b/tests/test_document_parsing.py @@ -174,6 +174,29 @@ class TestExtractText: assert "This is paragraph one." in result assert "This is paragraph two." in result + def test_extract_text_docx_preserves_paragraph_and_table_order(self, tmp_path: Path): + """DOCX forms commonly keep nearly all meaningful content in tables.""" + from docx import Document + + docx_file = tmp_path / "form.docx" + doc = Document() + doc.add_paragraph("Applicant details") + table = doc.add_table(rows=2, cols=2) + table.cell(0, 0).text = "Name" + table.cell(0, 1).text = "Ada Lovelace" + table.cell(1, 0).text = "Project" + table.cell(1, 1).text = "Analytical Engine" + doc.add_paragraph("End of form") + doc.save(docx_file) + + result = extract_text(docx_file) + + assert result is not None + assert "Name\tAda Lovelace" in result + assert "Project\tAnalytical Engine" in result + assert result.index("Applicant details") < result.index("Name\tAda Lovelace") + assert result.index("Analytical Engine") < result.index("End of form") + def test_extract_text_docx_empty(self, tmp_path: Path): """Test extracting text from an empty .docx file.""" from docx import Document