Akshay66777's picture
AgentScope Gradio chat app — ZeroGPU-ready
9792ea7 verified
Raw
History Blame Contribute Delete
12.5 kB
# -*- coding: utf-8 -*-
"""PowerPoint (.pptx) file parser.
Walks the deck slide-by-slide and emits one :class:`Section` per
contiguous content block — adjacent text runs (and, by default,
tables) are merged into a single text section; embedded images are
emitted as their own :class:`DataBlock` sections. Each section's
metadata carries the slide index (starting at 1) for later citation.
Mirrors the knob set of the v1 ``PowerPointReader``:
``include_image``, ``separate_table``, ``table_format``,
``slide_prefix``, ``slide_suffix``. Chunking is **not** done here —
long text stays intact inside a section and is split downstream by a
:class:`~agentscope.rag.ChunkerBase`.
"""
import base64
import io
from typing import Any, Literal
from ..._logging import logger
from ...message import Base64Source, DataBlock, TextBlock
from .._document import Section
from ._base import ParserBase
from ._utils import (
_guess_image_media_type,
_table_to_json,
_table_to_markdown,
)
def _extract_table_rows(table: Any) -> list[list[str]]:
"""Read a python-pptx table into a 2-D ``list[list[str]]``.
Args:
table (`Any`):
The python-pptx ``Table`` object.
Returns:
`list[list[str]]`:
One inner list per row; per-cell line breaks are
normalised to ``\\n``.
"""
rows: list[list[str]] = []
for row in table.rows:
cells: list[str] = []
for cell in row.cells:
text = cell.text.strip()
text = text.replace("\r\n", "\n").replace("\r", "\n")
cells.append(text)
rows.append(cells)
return rows
def _extract_image_bytes(shape: Any) -> bytes | None:
"""Return the embedded image bytes for a picture shape, or ``None``.
Args:
shape (`Any`):
A python-pptx shape. Non-picture shapes return ``None``.
Returns:
`bytes | None`:
The raw image bytes, or ``None`` when ``shape`` is not a
picture / the bytes are unreadable.
"""
try:
from pptx.enum.shapes import MSO_SHAPE_TYPE
picture_type = MSO_SHAPE_TYPE.PICTURE
except ImportError:
# MSO_SHAPE_TYPE.PICTURE numeric value used as the fallback
# so the parser still works against pptx builds where the
# enum import path has moved.
picture_type = 13
if shape.shape_type != picture_type:
return None
try:
return shape.image.blob
except Exception as e: # pylint: disable=broad-except
logger.warning("Failed to extract image from PPT shape: %s", e)
return None
class PPTParser(ParserBase):
"""Parser for PowerPoint ``.pptx`` files.
Slide order is preserved. Within a slide, shapes are visited in
document order and grouped into a minimum number of sections:
- **Text** shapes and **table** shapes contribute to one running
text section. When ``separate_table=True`` a table closes the
running section and starts a new one of its own.
- **Picture** shapes emit a standalone :class:`Section` whose
``content`` is a :class:`DataBlock` holding the base64-encoded
image bytes.
``slide_prefix`` / ``slide_suffix`` are wrapped around each
slide's text content (the prefix is prepended to the slide's
first text section, the suffix is appended to its last text
section). Use ``None`` on either to disable wrapping.
"""
supported_media_types: list[str] = [
"application/vnd.openxmlformats-officedocument.presentationml"
".presentation",
]
@classmethod
def supported_extensions(cls) -> list[str]:
"""Return ``[".pptx"]`` — the only format ``python-pptx``
reads."""
return [".pptx"]
def __init__(
self,
include_image: bool = True,
separate_table: bool = False,
table_format: Literal["markdown", "json"] = "markdown",
slide_prefix: str | None = "<slide index={index}>",
slide_suffix: str | None = "</slide>",
) -> None:
"""Initialize the PowerPoint parser.
Args:
include_image (`bool`, defaults to ``True``):
When ``True``, picture shapes are emitted as
:class:`DataBlock` sections. Set to ``False`` to keep
a text-only index.
separate_table (`bool`, defaults to ``False``):
When ``True``, each table becomes its own text
section, never merged with surrounding text.
table_format (`Literal["markdown", "json"]`, defaults to
``"markdown"``):
How to render tables. ``"markdown"`` uses pipe-table
syntax; ``"json"`` emits a JSON array prefixed with a
``<system-info>`` marker — choose JSON when cells
contain newlines that would corrupt Markdown layout.
slide_prefix (`str | None`, defaults to
``"<slide index={index}>"``):
Prepended to the first text section of each slide.
Supports the ``{index}`` placeholder (starting at 1). Use
``None`` to disable.
slide_suffix (`str | None`, defaults to ``"</slide>"``):
Appended to the last text section of each slide. Use
``None`` to disable.
Raises:
`ValueError`: If ``table_format`` is not one of
``"markdown"`` / ``"json"``.
"""
if table_format not in ("markdown", "json"):
raise ValueError(
"The table_format must be one of 'markdown' or 'json', "
f"got {table_format!r}.",
)
self.include_image = include_image
self.separate_table = separate_table
self.table_format = table_format
self.slide_prefix = slide_prefix
self.slide_suffix = slide_suffix
async def parse(
self,
file: bytes | str,
filename: str,
) -> list[Section]:
"""Parse a PPTX file into a list of :class:`Section` objects.
Args:
file (`bytes | str`):
Either the raw PPTX bytes, or a filesystem path to
the PPTX file.
filename (`str`):
The source filename, copied into each Section's
:attr:`Section.source`.
Returns:
`list[Section]`:
Sections in deck order. Text sections carry
``{"slide": <starting at 1>}``; image sections add
``{"media_type": "image/..."}``.
Raises:
`FileNotFoundError`: If ``file`` is a ``str`` pointing to
a path that does not exist.
`ImportError`: If :mod:`python-pptx` is not installed.
`ValueError`: If the bytes cannot be parsed.
"""
if isinstance(file, str):
with open(file, "rb") as fp:
file = fp.read()
try:
from pptx import Presentation
except ImportError as e:
raise ImportError(
"Please install python-pptx to use the PowerPoint "
"parser. You can install it by "
"`pip install python-pptx` (or "
"`pip install agentscope[rag]`).",
) from e
try:
prs = Presentation(io.BytesIO(file))
except Exception as e: # pylint: disable=broad-except
raise ValueError(
f"Failed to parse {filename!r} as PPTX: {e}",
) from e
sections: list[Section] = []
for slide_idx, slide in enumerate(prs.slides):
sections.extend(
self._parse_slide(slide, slide_idx, filename),
)
return sections
# ------------------------------------------------------------------
# Slide-level parsing
# ------------------------------------------------------------------
def _parse_slide(
self,
slide: Any,
slide_idx: int,
filename: str,
) -> list[Section]:
"""Walk one slide and return its ordered sections."""
slide_no = slide_idx + 1
prefix = (
self.slide_prefix.format(index=slide_no)
if self.slide_prefix is not None
else ""
)
slide_sections: list[Section] = []
# ``text_buffer`` accumulates the running text section; it is
# flushed whenever an image arrives, the slide ends, or
# ``separate_table`` forces a break around a table shape.
text_buffer: list[str] = []
def flush_text() -> None:
if not text_buffer:
return
slide_sections.append(
Section(
content=TextBlock(text="\n".join(text_buffer)),
source=filename,
metadata={"slide": slide_no},
),
)
text_buffer.clear()
# Slide prefix lives at the very top of the first text section.
if prefix:
text_buffer.append(prefix)
for shape in slide.shapes:
# 1. Pictures — flush running text, emit a DataBlock section.
if self.include_image:
image_bytes = _extract_image_bytes(shape)
if image_bytes is not None:
flush_text()
media_type = _guess_image_media_type(image_bytes)
data = base64.b64encode(image_bytes).decode("utf-8")
slide_sections.append(
Section(
content=DataBlock(
source=Base64Source(
media_type=media_type,
data=data,
),
name=filename,
),
source=filename,
metadata={
"slide": slide_no,
"media_type": media_type,
},
),
)
continue
# 2. Tables — render to text; optionally flush around them.
if getattr(shape, "has_table", False):
try:
rows = _extract_table_rows(shape.table)
except Exception as e: # pylint: disable=broad-except
logger.warning(
"Failed to extract table from slide %d: %s",
slide_no,
e,
)
continue
rendered = (
_table_to_markdown(rows)
if self.table_format == "markdown"
else _table_to_json(rows)
)
if not rendered:
continue
if self.separate_table:
flush_text()
slide_sections.append(
Section(
content=TextBlock(text=rendered),
source=filename,
metadata={"slide": slide_no},
),
)
else:
text_buffer.append(rendered)
continue
# 3. Text frames.
if getattr(shape, "has_text_frame", False):
try:
parts = [
para.text.strip()
for para in shape.text_frame.paragraphs
if para.text.strip()
]
except Exception as e: # pylint: disable=broad-except
logger.warning(
"Failed to extract text from shape in slide %d: %s",
slide_no,
e,
)
continue
if parts:
text_buffer.append("\n".join(parts))
# Suffix goes onto the last text section of this slide. If the
# slide ends on an image (text_buffer empty, no prior text
# section in this slide), create a small text-only carrier so
# the suffix is preserved.
if self.slide_suffix is not None:
text_buffer.append(self.slide_suffix)
flush_text()
return slide_sections