From 6f32e70c6d391f7c00e9809ea3b75b4a3e0434a0 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Tue, 7 Jul 2026 07:32:10 +0200 Subject: [PATCH] feat(inbox): docx table cells + grouped pptx shapes (stage 2g) Two structural regions the office extractors missed, no new dependency: - .docx table cells (they live outside doc.paragraphs); - grouped .pptx shapes (add_group_shape moves a shape inside the group, so the extractor recurses through MSO_SHAPE_TYPE.GROUP to reach it). Each proven by an injection-in-region REJECT plus a clean-region ADMIT. Tests 310 -> 314. --- tests/inbox_frontend.py | 19 ++++++++++- tests/test_okf_inbox_uploads.py | 59 +++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/tests/inbox_frontend.py b/tests/inbox_frontend.py index 83908a8..5a85463 100644 --- a/tests/inbox_frontend.py +++ b/tests/inbox_frontend.py @@ -137,6 +137,13 @@ def _extract_docx_text(fs_path) -> str: doc = Document(str(fs_path)) parts = [para.text for para in doc.paragraphs if para.text] + # Table cells live outside doc.paragraphs — iterate them explicitly. + for table in doc.tables: + for row in table.rows: + for cell in row.cells: + if cell.text: + parts.append(cell.text) + cp = doc.core_properties for attr in ("title", "subject", "keywords", "comments", "category", "author"): value = getattr(cp, attr, None) @@ -165,11 +172,21 @@ def _extract_pptx_text(fs_path) -> str: image/shape alt-text. ``python-pptx`` is imported lazily (dev/showcase-scoped). """ from pptx import Presentation + from pptx.enum.shapes import MSO_SHAPE_TYPE + + def walk(shapes): + # Flatten grouped shapes: add_group_shape moves a shape inside the group, + # so only recursion reaches its text/alt-text. + for shape in shapes: + if shape.shape_type == MSO_SHAPE_TYPE.GROUP: + yield from walk(shape.shapes) + else: + yield shape prs = Presentation(str(fs_path)) parts: list = [] for slide in prs.slides: - for shape in slide.shapes: + for shape in walk(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) diff --git a/tests/test_okf_inbox_uploads.py b/tests/test_okf_inbox_uploads.py index bf6d4f9..7d38c66 100644 --- a/tests/test_okf_inbox_uploads.py +++ b/tests/test_okf_inbox_uploads.py @@ -363,3 +363,62 @@ def test_pptx_notes_detach_proof(tmp_path): fp = _make_pptx(tmp_path, body="A routine slide.") _extracted, _result, verdict = receive([fp]) assert verdict == "ADMIT" + + +# --- slice 2g: office-extractor completeness (no new dep) ------------------- +# Two structural regions the earlier slices did not reach: .docx table cells (they +# live outside doc.paragraphs) and grouped .pptx shapes (add_group_shape moves the +# shape inside the group, so only recursion reaches it). + + +def _docx_with_table(tmp_path, cell_text, name="table.docx"): + from docx import Document + + d = Document() + d.add_paragraph("A normal paragraph.") + table = d.add_table(rows=1, cols=2) + table.cell(0, 0).text = "label" + table.cell(0, 1).text = cell_text + fp = tmp_path / name + d.save(str(fp)) + return fp + + +def _pptx_with_grouped_text(tmp_path, text, name="grouped.pptx"): + from pptx import Presentation + from pptx.util import Emu + + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + tb = slide.shapes.add_textbox(Emu(0), Emu(0), Emu(1000000), Emu(400000)) + tb.text_frame.text = text + slide.shapes.add_group_shape([tb]) # moves tb inside the group (not top-level) + fp = tmp_path / name + prs.save(str(fp)) + return fp + + +def test_docx_table_cell_injection_is_caught(tmp_path): + fp = _docx_with_table(tmp_path, _INJECTION) + _extracted, result, verdict = receive([fp]) + assert result.disposition is Disposition.FAIL_SECURE + assert verdict == "REJECT" + + +def test_clean_docx_table_admits(tmp_path): + fp = _docx_with_table(tmp_path, "a clean value") + _extracted, _result, verdict = receive([fp]) + assert verdict == "ADMIT" + + +def test_pptx_grouped_shape_injection_is_caught(tmp_path): + fp = _pptx_with_grouped_text(tmp_path, _INJECTION) + _extracted, result, verdict = receive([fp]) + assert result.disposition is Disposition.FAIL_SECURE + assert verdict == "REJECT" + + +def test_clean_pptx_grouped_shape_admits(tmp_path): + fp = _pptx_with_grouped_text(tmp_path, "clean grouped text") + _extracted, _result, verdict = receive([fp]) + assert verdict == "ADMIT"