Files
pdf/gateway/tests/convert/test_pptx_converters.py
T

197 lines
6.3 KiB
Python

"""Tests for PDF <-> PPTX document conversion routes."""
from __future__ import annotations
import io
import pypdf
from pptx import Presentation
from app.services.convert.converters import all_plugins, pdf_to_pptx, pptx_to_pdf_convert
from app.services.convert.formatters.pptx_formatter import format_pptx
from app.services.convert.idm.model import BBox, Block, BlockType, Document, Page
from app.services.convert.writers.pdf_from_pptx import pptx_to_pdf
def _make_sample_idm() -> Document:
doc = Document()
page = Page(index=0, width=792.0, height=612.0) # Landscape letter
# Slide Title
page.blocks.append(
Block(
type=BlockType.heading,
text="Q3 Global Operations Review",
bbox=BBox(x=72.0, y=520.0, w=648.0, h=40.0),
level=1,
reading_order=0,
)
)
# Subtitle / Body paragraph
page.blocks.append(
Block(
type=BlockType.paragraph,
text="Executive summary of key regional performance indicators.",
bbox=BBox(x=72.0, y=470.0, w=648.0, h=25.0),
reading_order=1,
)
)
# Table
table_grid = [
["Region", "Target", "Actual", "Growth %"],
["North America", "$5.0M", "$5.8M", "+16%"],
["EMEA", "$4.2M", "$4.5M", "+7%"],
["APAC", "$3.8M", "$4.4M", "+15%"],
]
page.blocks.append(
Block(
type=BlockType.table,
cells=table_grid,
bbox=BBox(x=72.0, y=200.0, w=648.0, h=240.0),
reading_order=2,
table_confidence=0.95,
)
)
doc.pages.append(page)
return doc
def test_registry_contains_pptx_routes():
plugins = {f"{p.source}->{p.target}": p for p in all_plugins()}
assert "pdf->pptx" in plugins
assert "pptx->pdf" in plugins
def test_format_pptx_from_idm():
idm = _make_sample_idm()
pptx_bytes = format_pptx(idm)
assert pptx_bytes[:2] == b"PK", "Must produce valid OOXML zip archive"
prs = Presentation(io.BytesIO(pptx_bytes))
assert len(prs.slides) == 1
slide = prs.slides[0]
shape_types = [shape.has_table for shape in slide.shapes]
assert any(shape_types), "Slide must contain a table shape"
# Find table
table_shape = next(shape for shape in slide.shapes if shape.has_table)
table = table_shape.table
assert len(table.rows) == 4
assert len(table.columns) == 4
headers = [cell.text.strip() for cell in table.rows[0].cells]
assert headers == ["Region", "Target", "Actual", "Growth %"]
first_row = [cell.text.strip() for cell in table.rows[1].cells]
assert first_row == ["North America", "$5.0M", "$5.8M", "+16%"]
def test_pptx_to_pdf_round_trip():
idm = _make_sample_idm()
pptx_bytes = format_pptx(idm)
pdf_bytes, warnings = pptx_to_pdf(pptx_bytes)
assert pdf_bytes.startswith(b"%PDF-")
reader = pypdf.PdfReader(io.BytesIO(pdf_bytes))
assert len(reader.pages) == 1
page_text = reader.pages[0].extract_text()
assert "Q3 Global Operations Review" in page_text
assert "North America" in page_text
assert "EMEA" in page_text
assert "+16%" in page_text
def _ruled_table_pdf() -> bytes:
"""A one-page PDF holding a real, ruled table — built, not downloaded.
This test used to read ``corpus/convert/_tmp_samples/real_web/w3c_pdf_table.pdf``
and skip when it was absent. It was always absent: ``_tmp_samples`` is a
scratch download directory that is not in the repository and is not meant
to be, and the path was relative, so the test also depended on the working
directory pytest happened to be started from. The result was a permanent
skip — the pdf->pptx table path shipped with no test covering it at all.
Generating the fixture removes both problems. The table is drawn with real
vector text and real ruling lines, which is what the table detector keys
on, so the conversion exercised here is the same one a scanned-free
business PDF would take.
"""
from reportlab.lib import colors
from reportlab.lib.pagesizes import LETTER
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
buffer = io.BytesIO()
doc = SimpleDocTemplate(buffer, pagesize=LETTER)
styles = getSampleStyleSheet()
rows = [
["Cohort", "Participants", "Accuracy"],
["Disability", "128", "94%"],
["Control", "131", "96%"],
]
table = Table(rows, colWidths=[160, 110, 90])
table.setStyle(
TableStyle(
[
("GRID", (0, 0), (-1, -1), 0.75, colors.black),
("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 11),
("ALIGN", (1, 0), (-1, -1), "CENTER"),
]
)
)
doc.build(
[
Paragraph("Accessibility Conformance Summary", styles["Title"]),
Spacer(1, 18),
table,
]
)
return buffer.getvalue()
def _pptx_texts(pptx_bytes: bytes) -> list[str]:
"""Every string a slide carries, from tables and text frames alike."""
prs = Presentation(io.BytesIO(pptx_bytes))
texts: list[str] = []
for slide in prs.slides:
for shape in slide.shapes:
if shape.has_table:
for row in shape.table.rows:
texts.extend(cell.text for cell in row.cells)
elif shape.has_text_frame:
texts.append(shape.text)
return texts
def test_pdf_to_pptx_recovers_ruled_table():
"""A ruled table in a PDF must survive the trip to PPTX with its words."""
pdf_bytes = _ruled_table_pdf()
assert pdf_bytes.startswith(b"%PDF-")
pptx_bytes, fidelity, warnings, media, score = pdf_to_pptx(pdf_bytes, "conformance.pdf")
assert pptx_bytes[:2] == b"PK"
assert media == "application/vnd.openxmlformats-officedocument.presentationml.presentation"
prs = Presentation(io.BytesIO(pptx_bytes))
assert len(prs.slides) >= 1
joined = " ".join(_pptx_texts(pptx_bytes))
# The header row, a data label and a figure: enough to prove the cells
# travelled, not just that a shape was created.
assert "Disability" in joined
assert "Participants" in joined
assert "Accuracy" in joined
assert "128" in joined