From c27b4d07c40bd422726bba22db22060418e19570 Mon Sep 17 00:00:00 2001 From: 04cb <0x04cb@gmail.com> Date: Sat, 18 Apr 2026 11:09:29 +0800 Subject: [PATCH] fix(utils): recurse into PPTX groups and tables when extracting text (#3250) --- nanobot/utils/document.py | 28 ++++++++++++++++++++-- tests/test_document_parsing.py | 43 ++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/nanobot/utils/document.py b/nanobot/utils/document.py index a27b0e7a..89ffcca9 100644 --- a/nanobot/utils/document.py +++ b/nanobot/utils/document.py @@ -159,8 +159,7 @@ def _extract_pptx(path: Path) -> str: for i, slide in enumerate(prs.slides, 1): slide_text: list[str] = [] for shape in slide.shapes: - if hasattr(shape, "text") and shape.text: - slide_text.append(shape.text) + _collect_pptx_shape_text(shape, slide_text) if slide_text: slides.append(f"--- Slide {i} ---\n" + "\n".join(slide_text)) return _truncate("\n\n".join(slides), _MAX_TEXT_LENGTH) @@ -169,6 +168,31 @@ def _extract_pptx(path: Path) -> str: return f"[error: failed to extract PPTX: {e!s}]" +def _collect_pptx_shape_text(shape, out: list[str]) -> None: + """Collect text from a PPTX shape, recursing into groups and tables. + + Groups have ``has_text_frame=False`` and must be walked via ``.shapes``; + tables are GraphicFrame objects whose cell text lives under ``.table``. + """ + sub_shapes = getattr(shape, "shapes", None) + if sub_shapes is not None: + for sub in sub_shapes: + _collect_pptx_shape_text(sub, out) + return + + if getattr(shape, "has_table", False): + for row in shape.table.rows: + cells = [cell.text.strip() for cell in row.cells] + line = "\t".join(cell for cell in cells if cell) + if line: + out.append(line) + return + + text = getattr(shape, "text", "") + if text: + out.append(text) + + def _extract_text_file(path: Path) -> str: """Extract text from a plain text file.""" try: diff --git a/tests/test_document_parsing.py b/tests/test_document_parsing.py index 07387372..9b424998 100644 --- a/tests/test_document_parsing.py +++ b/tests/test_document_parsing.py @@ -209,6 +209,49 @@ class TestExtractText: # Text content may vary depending on PowerPoint layout defaults assert len(result) > 0 + def test_extract_text_pptx_table(self, tmp_path: Path): + """Table cells should be extracted, not silently dropped.""" + from pptx import Presentation + from pptx.util import Inches + + pptx_file = tmp_path / "table.pptx" + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[5]) + table = slide.shapes.add_table( + 2, 2, Inches(1), Inches(1), Inches(4), Inches(1) + ).table + table.cell(0, 0).text = "Header A" + table.cell(0, 1).text = "Header B" + table.cell(1, 0).text = "Alice" + table.cell(1, 1).text = "Bob" + prs.save(pptx_file) + + result = extract_text(pptx_file) + assert result is not None + assert "Header A" in result + assert "Header B" in result + assert "Alice" in result + assert "Bob" in result + + def test_extract_text_pptx_grouped_shapes(self, tmp_path: Path): + """Text inside grouped shapes must be extracted recursively.""" + from pptx import Presentation + from pptx.util import Inches + + pptx_file = tmp_path / "grouped.pptx" + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[5]) + group = slide.shapes.add_group_shape() + inner = group.shapes.add_textbox( + Inches(1), Inches(1), Inches(3), Inches(1) + ) + inner.text_frame.text = "Inside group" + prs.save(pptx_file) + + result = extract_text(pptx_file) + assert result is not None + assert "Inside group" in result + def test_extract_text_pdf_not_found(self, tmp_path: Path): """Test that missing PDF files return error string.""" missing_pdf = tmp_path / "nonexistent.pdf"