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):
|
for i, slide in enumerate(prs.slides, 1):
|
||||||
slide_text: list[str] = []
|
slide_text: list[str] = []
|
||||||
for shape in slide.shapes:
|
for shape in slide.shapes:
|
||||||
if hasattr(shape, "text") and shape.text:
|
_collect_pptx_shape_text(shape, slide_text)
|
||||||
slide_text.append(shape.text)
|
|
||||||
if slide_text:
|
if slide_text:
|
||||||
slides.append(f"--- Slide {i} ---\n" + "\n".join(slide_text))
|
slides.append(f"--- Slide {i} ---\n" + "\n".join(slide_text))
|
||||||
return _truncate("\n\n".join(slides), _MAX_TEXT_LENGTH)
|
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}]"
|
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:
|
def _extract_text_file(path: Path) -> str:
|
||||||
"""Extract text from a plain text file."""
|
"""Extract text from a plain text file."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -209,6 +209,49 @@ class TestExtractText:
|
|||||||
# Text content may vary depending on PowerPoint layout defaults
|
# Text content may vary depending on PowerPoint layout defaults
|
||||||
assert len(result) > 0
|
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):
|
def test_extract_text_pdf_not_found(self, tmp_path: Path):
|
||||||
"""Test that missing PDF files return error string."""
|
"""Test that missing PDF files return error string."""
|
||||||
missing_pdf = tmp_path / "nonexistent.pdf"
|
missing_pdf = tmp_path / "nonexistent.pdf"
|
||||||
|
|||||||
Reference in New Issue
Block a user