fix(utils): recurse into PPTX groups and tables when extracting text (#3250)
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user