1
0
Fork 0

feat(inbox): .pptx extraction — speaker notes, off-slide box, alt-text (stage 2e)

The pptx payload hides where an audience watching the slides does not look. The
extractor surfaces all three regions into the concept text so the stage-2 scan
catches the injection:

- speaker notes (slide.notes_slide.notes_text_frame.text);
- off-slide (off-canvas) text boxes (shape.text_frame.text, position-agnostic);
- image/shape alt-text (cNvPr@descr, read off the XML — python-pptx 1.0.2 has no
  stable public accessor across shape types).

Detach-proof: same visible slide without notes ADMITs. python-pptx imported
lazily (dev/showcase-scoped, not a core dep). Tests 305 -> 310.
This commit is contained in:
Kjell Tore Guttormsen 2026-07-06 11:16:59 +02:00
commit d8a95e465f
2 changed files with 113 additions and 0 deletions

View file

@ -150,6 +150,38 @@ def _extract_docx_text(fs_path) -> str:
return "\n".join(parts)
def _shape_alt_text(shape) -> str:
"""Read a shape's alt-text (cNvPr@descr). python-pptx 1.0.2 has no stable
public accessor across shape types, so read it off the XML directly."""
for element in shape._element.iter():
if element.tag.endswith("}cNvPr"):
return element.get("descr") or ""
return ""
def _extract_pptx_text(fs_path) -> str:
"""Extract text from a ``.pptx``, including the regions an audience watching
the slides does not see: speaker notes, off-slide (off-canvas) text boxes, and
image/shape alt-text. ``python-pptx`` is imported lazily (dev/showcase-scoped).
"""
from pptx import Presentation
prs = Presentation(str(fs_path))
parts: list = []
for slide in prs.slides:
for shape in slide.shapes:
if shape.has_text_frame and shape.text_frame.text:
parts.append(shape.text_frame.text) # incl. off-slide boxes
alt = _shape_alt_text(shape)
if alt:
parts.append(alt)
if slide.has_notes_slide:
notes = slide.notes_slide.notes_text_frame.text
if notes:
parts.append(notes)
return "\n".join(parts)
def _ingest_regular_file(fs_path, rel_name, bundle, provenance, rejected, *, strict):
"""Dispatch one on-disk file by suffix. ``strict`` raises on an unsupported
suffix (a top-level drop); a folder walk passes ``strict=False`` to skip it."""
@ -159,6 +191,8 @@ def _ingest_regular_file(fs_path, rel_name, bundle, provenance, rejected, *, str
_ingest_csv(rel_name, text, bundle, provenance, rejected)
elif suffix == ".docx":
_materialize_text(rel_name, _extract_docx_text(fs_path), "docx", bundle, provenance)
elif suffix == ".pptx":
_materialize_text(rel_name, _extract_pptx_text(fs_path), "pptx", bundle, provenance)
elif suffix in _TEXT_SUFFIXES:
text = Path(fs_path).read_text(encoding="utf-8", errors="replace")
_materialize_text(rel_name, text, suffix.lstrip("."), bundle, provenance)