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:
parent
26a231d6c4
commit
d8a95e465f
2 changed files with 113 additions and 0 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -284,3 +284,82 @@ def test_docx_hidden_run_detach_proof(tmp_path):
|
|||
fp = _make_docx(tmp_path, body="A normal paragraph.")
|
||||
_extracted, _result, verdict = receive([fp])
|
||||
assert verdict == "ADMIT"
|
||||
|
||||
|
||||
# --- slice 2e: .pptx (python-pptx) ------------------------------------------
|
||||
# The payload hides where an audience watching the slides does not look: speaker
|
||||
# notes, an off-slide text box, or an image's alt-text. The extractor surfaces
|
||||
# all three so the stage-2 scan catches them.
|
||||
|
||||
|
||||
def _set_alt_text(shape, text):
|
||||
# alt-text lives on the shape's non-visual props (cNvPr@descr); python-pptx
|
||||
# 1.0.2 has no stable public accessor across shape types, so set it on the XML.
|
||||
for el in shape._element.iter():
|
||||
if el.tag.endswith("}cNvPr"):
|
||||
el.set("descr", text)
|
||||
return
|
||||
|
||||
|
||||
def _make_pptx(tmp_path, name="deck.pptx", *, body=None, notes=None, offslide=None, alt=None):
|
||||
import io
|
||||
|
||||
from pptx import Presentation
|
||||
from pptx.util import Emu
|
||||
|
||||
prs = Presentation()
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6]) # blank
|
||||
if body is not None:
|
||||
tb = slide.shapes.add_textbox(Emu(0), Emu(0), Emu(3000000), Emu(500000))
|
||||
tb.text_frame.text = body
|
||||
if offslide is not None:
|
||||
tb = slide.shapes.add_textbox(Emu(-3000000), Emu(0), Emu(1000000), Emu(400000))
|
||||
tb.text_frame.text = offslide # positioned off the canvas
|
||||
if alt is not None:
|
||||
from PIL import Image
|
||||
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (2, 2), (255, 255, 255)).save(buf, "PNG")
|
||||
buf.seek(0)
|
||||
pic = slide.shapes.add_picture(buf, Emu(0), Emu(0), Emu(500000), Emu(500000))
|
||||
_set_alt_text(pic, alt)
|
||||
if notes is not None:
|
||||
slide.notes_slide.notes_text_frame.text = notes
|
||||
fp = tmp_path / name
|
||||
prs.save(str(fp))
|
||||
return fp
|
||||
|
||||
|
||||
def test_pptx_speaker_notes_injection_is_caught(tmp_path):
|
||||
fp = _make_pptx(tmp_path, body="Slide one.", notes=_INJECTION)
|
||||
extracted, result, verdict = receive([fp])
|
||||
assert extracted.provenance[0].source_type == "pptx"
|
||||
assert result.disposition is Disposition.FAIL_SECURE
|
||||
assert verdict == "REJECT"
|
||||
|
||||
|
||||
def test_pptx_offslide_textbox_injection_is_caught(tmp_path):
|
||||
fp = _make_pptx(tmp_path, body="Slide one.", offslide=_INJECTION)
|
||||
_extracted, _result, verdict = receive([fp])
|
||||
assert verdict == "REJECT"
|
||||
|
||||
|
||||
def test_pptx_image_alt_text_injection_is_caught(tmp_path):
|
||||
fp = _make_pptx(tmp_path, body="Slide one.", alt=_INJECTION)
|
||||
_extracted, _result, verdict = receive([fp])
|
||||
assert verdict == "REJECT"
|
||||
|
||||
|
||||
def test_clean_pptx_admits(tmp_path):
|
||||
fp = _make_pptx(tmp_path, name="clean.pptx", body="A routine slide. No behavior change.")
|
||||
extracted, _result, verdict = receive([fp])
|
||||
assert "uploads/clean.md" in extracted.bundle
|
||||
assert verdict == "ADMIT"
|
||||
|
||||
|
||||
def test_pptx_notes_detach_proof(tmp_path):
|
||||
# Same visible slide, no speaker notes -> ADMIT: the notes region is what
|
||||
# caught it, not merely "a pptx".
|
||||
fp = _make_pptx(tmp_path, body="A routine slide.")
|
||||
_extracted, _result, verdict = receive([fp])
|
||||
assert verdict == "ADMIT"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue