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)
|
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):
|
def _ingest_regular_file(fs_path, rel_name, bundle, provenance, rejected, *, strict):
|
||||||
"""Dispatch one on-disk file by suffix. ``strict`` raises on an unsupported
|
"""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."""
|
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)
|
_ingest_csv(rel_name, text, bundle, provenance, rejected)
|
||||||
elif suffix == ".docx":
|
elif suffix == ".docx":
|
||||||
_materialize_text(rel_name, _extract_docx_text(fs_path), "docx", bundle, provenance)
|
_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:
|
elif suffix in _TEXT_SUFFIXES:
|
||||||
text = Path(fs_path).read_text(encoding="utf-8", errors="replace")
|
text = Path(fs_path).read_text(encoding="utf-8", errors="replace")
|
||||||
_materialize_text(rel_name, text, suffix.lstrip("."), bundle, provenance)
|
_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.")
|
fp = _make_docx(tmp_path, body="A normal paragraph.")
|
||||||
_extracted, _result, verdict = receive([fp])
|
_extracted, _result, verdict = receive([fp])
|
||||||
assert verdict == "ADMIT"
|
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