476 lines
19 KiB
Python
476 lines
19 KiB
Python
"""End-to-end tests for the powerpoint skill helper scripts.
|
|
|
|
Runs entirely offline. Exercises create, read, template-fill (with
|
|
non-ASCII values), and edit (text + chart data + remove/move slide).
|
|
"""
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
SKILL = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
SCRIPTS = os.path.join(SKILL, "scripts")
|
|
|
|
|
|
def run(script, *args):
|
|
env = dict(os.environ, LC_ALL="C", PYTHONIOENCODING="utf-8")
|
|
proc = subprocess.run(
|
|
[sys.executable, os.path.join(SCRIPTS, script), *args],
|
|
capture_output=True, text=True, encoding="utf-8", env=env)
|
|
assert proc.returncode == 0, f"{script} failed: {proc.stderr}"
|
|
return json.loads(proc.stdout)
|
|
|
|
|
|
def write_json(path, obj):
|
|
with open(path, "w", encoding="utf-8") as fh:
|
|
json.dump(obj, fh, ensure_ascii=False)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def workdir(tmp_path_factory):
|
|
return tmp_path_factory.mktemp("pptx")
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def sample_png(workdir):
|
|
# 1x1 red PNG, hardcoded bytes -> no Pillow dependency.
|
|
import base64
|
|
data = base64.b64decode(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4"
|
|
"z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==")
|
|
path = workdir / "red.png"
|
|
path.write_bytes(data)
|
|
return str(path)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def deck(workdir, sample_png):
|
|
"""Create a deck exercising every create feature."""
|
|
spec = {
|
|
"slide_size": "16:9",
|
|
"slides": [
|
|
{"layout": "title", "title": "Annual Review",
|
|
"subtitle": "Fiscal year 2026",
|
|
"notes": "Welcome the audience."},
|
|
{"layout": "title_content", "title": "Agenda",
|
|
"bullets": [
|
|
"Overview",
|
|
{"text": "Details", "level": 1, "bold": True,
|
|
"size": 20, "color": "CC0000", "font": "Arial"},
|
|
{"text": "Deep dive", "level": 2, "italic": True},
|
|
],
|
|
"notes": "Keep this under two minutes."},
|
|
{"layout": "blank", "title": None,
|
|
"images": [{"path": sample_png, "left": 0.5, "top": 0.5,
|
|
"width": 2, "height": 2}],
|
|
"tables": [{"left": 3, "top": 1, "width": 6, "height": 2,
|
|
"rows": [["Region", "Sales"],
|
|
["North", "120"], ["South", "80"]]}],
|
|
"shapes": [{"type": "rounded_rectangle", "left": 10,
|
|
"top": 1, "width": 2.5, "height": 1,
|
|
"fill": "4472C4", "text": "Callout",
|
|
"text_color": "FFFFFF"}]},
|
|
{"layout": "title_only", "title": "Charts",
|
|
"charts": [
|
|
{"type": "bar", "left": 0.5, "top": 1.5, "width": 4,
|
|
"height": 3, "title": "Sales",
|
|
"categories": ["Q1", "Q2"],
|
|
"series": {"North": [10, 20], "South": [7, 13]}},
|
|
{"type": "line", "left": 4.7, "top": 1.5, "width": 4,
|
|
"height": 3, "categories": ["Jan", "Feb", "Mar"],
|
|
"series": {"Trend": [1, 3, 2]}},
|
|
{"type": "pie", "left": 8.9, "top": 1.5, "width": 4,
|
|
"height": 3, "categories": ["A", "B", "C"],
|
|
"series": {"Share": [50, 30, 20]}}]},
|
|
],
|
|
}
|
|
spec_path = workdir / "deck.json"
|
|
write_json(spec_path, spec)
|
|
out = workdir / "deck.pptx"
|
|
result = run("pptx_create.py", str(spec_path), str(out))
|
|
assert result["ok"] and result["slides"] == 4
|
|
return str(out)
|
|
|
|
|
|
def test_create_and_outline(deck):
|
|
outline = run("pptx_read.py", deck, "--outline")
|
|
assert outline["slide_count"] == 4
|
|
assert outline["slide_size_inches"][0] == pytest.approx(13.333, abs=0.01)
|
|
s0, s1, s2, s3 = outline["slides"]
|
|
assert "Annual Review" in s0["texts"]
|
|
assert "Fiscal year 2026" in s0["texts"]
|
|
assert s0["notes"] == "Welcome the audience."
|
|
# bullets present on slide 1
|
|
assert any("Deep dive" in t for t in s1["texts"])
|
|
# table content
|
|
assert s2["tables"][0][0] == ["Region", "Sales"]
|
|
assert s2["tables"][0][2] == ["South", "80"]
|
|
# image inventory + shape text
|
|
assert len(s2["images"]) == 1 and s2["images"][0]["ext"] == "png"
|
|
assert "Callout" in s2["texts"]
|
|
# three charts with correct data
|
|
assert len(s3["charts"]) == 3
|
|
bar = s3["charts"][0]
|
|
assert bar["categories"] == ["Q1", "Q2"]
|
|
north = next(s for s in bar["series"] if s["name"] == "North")
|
|
assert north["values"] == [10.0, 20.0]
|
|
|
|
|
|
def test_create_43_size(workdir):
|
|
spec_path = workdir / "small.json"
|
|
write_json(spec_path, {"slide_size": "4:3",
|
|
"slides": [{"layout": "title", "title": "T"}]})
|
|
out = workdir / "small.pptx"
|
|
run("pptx_create.py", str(spec_path), str(out))
|
|
outline = run("pptx_read.py", str(out))
|
|
assert outline["slide_size_inches"] == [10.0, 7.5]
|
|
|
|
|
|
def test_notes_mode(deck):
|
|
result = run("pptx_read.py", deck, "--notes")
|
|
assert result["notes"][1] == "Keep this under two minutes."
|
|
|
|
|
|
def test_image_export(deck, workdir):
|
|
out_dir = workdir / "exported"
|
|
result = run("pptx_read.py", deck, "--images", str(out_dir))
|
|
assert len(result["exported"]) == 1
|
|
exported = result["exported"][0]
|
|
assert os.path.getsize(exported) > 0
|
|
with open(exported, "rb") as fh:
|
|
assert fh.read(8) == b"\x89PNG\r\n\x1a\n"
|
|
|
|
|
|
def test_template_fill_non_ascii(workdir):
|
|
"""Template token fill with non-ASCII values, forced under LC_ALL=C."""
|
|
tpl_spec = workdir / "tpl.json"
|
|
write_json(tpl_spec, {"slides": [
|
|
{"layout": "title", "title": "{{city}} report",
|
|
"subtitle": "Prepared by {{author}}",
|
|
"notes": "Deck for {{city}}."},
|
|
{"layout": "title_content", "title": "Data",
|
|
"tables": [{"rows": [["Site", "{{city}}"]]}]},
|
|
]})
|
|
template = workdir / "template.pptx"
|
|
run("pptx_create.py", str(tpl_spec), str(template))
|
|
|
|
values = workdir / "values.json"
|
|
write_json(values, {"city": "Z\u00fcrich \u2014 \u2018Bericht\u2019",
|
|
"author": "Beispiel GmbH"})
|
|
out = workdir / "filled.pptx"
|
|
result = run("pptx_from_template.py", str(template), str(out),
|
|
"--values", str(values))
|
|
assert result["tokens_filled"] == 4
|
|
|
|
outline = run("pptx_read.py", str(out))
|
|
expected = "Z\u00fcrich \u2014 \u2018Bericht\u2019"
|
|
assert f"{expected} report" in outline["slides"][0]["texts"]
|
|
assert outline["slides"][0]["notes"] == f"Deck for {expected}."
|
|
assert outline["slides"][1]["tables"][0][0][1] == expected
|
|
|
|
|
|
def test_template_add_slides(workdir):
|
|
template = workdir / "template.pptx"
|
|
add_spec = workdir / "add.json"
|
|
write_json(add_spec, {"slides": [
|
|
{"layout": 1, "title": "Appended", "bullets": ["from layout"],
|
|
"notes": "appended slide"}]})
|
|
out = workdir / "appended.pptx"
|
|
run("pptx_from_template.py", str(template), str(out),
|
|
"--add-slides", str(add_spec))
|
|
outline = run("pptx_read.py", str(out))
|
|
assert outline["slide_count"] == 3
|
|
assert "Appended" in outline["slides"][2]["texts"]
|
|
|
|
|
|
def test_edit_replace_text(deck, workdir):
|
|
edited = workdir / "edited.pptx"
|
|
result = run("pptx_edit.py", deck, "--output", str(edited),
|
|
"--replace-text", "Annual Review", "Semi-Annual Review",
|
|
"--replace-text", "South", "West")
|
|
assert result["replacements"] == 2 # title + table cell
|
|
outline = run("pptx_read.py", str(edited))
|
|
assert "Semi-Annual Review" in outline["slides"][0]["texts"]
|
|
assert outline["slides"][2]["tables"][0][2][0] == "West"
|
|
# formatting survives a run-level replace: red bold bullet untouched
|
|
assert any("Deep dive" in t for t in outline["slides"][1]["texts"])
|
|
|
|
|
|
def test_edit_chart_data(deck, workdir):
|
|
spec = workdir / "chart_update.json"
|
|
write_json(spec, {"slide": 3, "chart": 0,
|
|
"categories": ["Q3", "Q4"],
|
|
"series": {"North": [30, 40], "South": [21, 34]}})
|
|
edited = workdir / "chart_edited.pptx"
|
|
run("pptx_edit.py", deck, "--output", str(edited),
|
|
"--chart-data", str(spec))
|
|
outline = run("pptx_read.py", str(edited))
|
|
bar = outline["slides"][3]["charts"][0]
|
|
assert bar["categories"] == ["Q3", "Q4"]
|
|
north = next(s for s in bar["series"] if s["name"] == "North")
|
|
assert north["values"] == [30.0, 40.0]
|
|
|
|
|
|
def test_edit_remove_and_move_slide(deck, workdir):
|
|
edited = workdir / "reordered.pptx"
|
|
run("pptx_edit.py", deck, "--output", str(edited),
|
|
"--remove-slide", "2", "--move-slide", "2", "0")
|
|
outline = run("pptx_read.py", str(edited))
|
|
assert outline["slide_count"] == 3
|
|
# charts slide (was index 3, then 2 after removal) moved to front
|
|
assert len(outline["slides"][0]["charts"]) == 3
|
|
assert "Annual Review" in outline["slides"][1]["texts"]
|
|
|
|
|
|
def test_edit_swap_image(deck, workdir):
|
|
import base64
|
|
blue = base64.b64decode(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNg"
|
|
"YPj/HwADAgH/p5UronAAAAAASUVORK5CYII=")
|
|
blue_path = workdir / "blue.png"
|
|
blue_path.write_bytes(blue)
|
|
outline = run("pptx_read.py", deck)
|
|
# find picture shape name via python-pptx directly
|
|
sys.path.insert(0, SCRIPTS)
|
|
from pptx import Presentation
|
|
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
|
prs = Presentation(deck)
|
|
name = next(s.name for s in prs.slides[2].shapes
|
|
if s.shape_type == MSO_SHAPE_TYPE.PICTURE)
|
|
edited = workdir / "swapped.pptx"
|
|
run("pptx_edit.py", deck, "--output", str(edited),
|
|
"--swap-image", "2", name, str(blue_path))
|
|
out_dir = workdir / "swapped_images"
|
|
result = run("pptx_read.py", str(edited), "--images", str(out_dir))
|
|
with open(result["exported"][0], "rb") as fh:
|
|
assert fh.read() == blue
|
|
|
|
|
|
def test_help_flags():
|
|
for script in ("pptx_create.py", "pptx_read.py", "pptx_edit.py",
|
|
"pptx_from_template.py"):
|
|
proc = subprocess.run(
|
|
[sys.executable, os.path.join(SCRIPTS, script), "--help"],
|
|
capture_output=True, text=True, encoding="utf-8")
|
|
assert proc.returncode == 0 and "usage" in proc.stdout.lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# parity features: render, run-merge replace, chart ops, duplication,
|
|
# polish (background/hyperlink/slide-number/footer), notes editing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def run_raw(script, *args):
|
|
env = dict(os.environ, LC_ALL="C", PYTHONIOENCODING="utf-8")
|
|
return subprocess.run(
|
|
[sys.executable, os.path.join(SCRIPTS, script), *args],
|
|
capture_output=True, text=True, encoding="utf-8", env=env)
|
|
|
|
|
|
def test_render_all_slides(workdir):
|
|
import shutil
|
|
spec_path = workdir / "render_spec.json"
|
|
write_json(spec_path, {"slides": [
|
|
{"layout": "title", "title": "One"},
|
|
{"layout": "title_content", "title": "Two", "bullets": ["b"]},
|
|
{"layout": "blank"}]})
|
|
deck3 = workdir / "render_me.pptx"
|
|
run("pptx_create.py", str(spec_path), str(deck3))
|
|
out_dir = workdir / "render_out"
|
|
result = run("pptx_render.py", str(deck3), "--outdir", str(out_dir))
|
|
have_tools = shutil.which("soffice") and (
|
|
shutil.which("pdftoppm") or shutil.which("pdftocairo"))
|
|
if have_tools:
|
|
assert result["rendered"] is True
|
|
assert len(result["files"]) == 3
|
|
for png in result["files"]:
|
|
with open(png, "rb") as fh:
|
|
assert fh.read(8) == b"\x89PNG\r\n\x1a\n"
|
|
else:
|
|
assert result["rendered"] is False
|
|
assert result["missing"]
|
|
assert "guidance" in result
|
|
|
|
|
|
def test_replace_across_identically_formatted_runs(workdir):
|
|
"""A match split mid-word into equal-format runs keeps formatting."""
|
|
import copy as cp
|
|
from pptx import Presentation
|
|
from pptx.dml.color import RGBColor
|
|
from pptx.util import Inches, Pt
|
|
|
|
prs = Presentation()
|
|
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
|
box = slide.shapes.add_textbox(Inches(1), Inches(1),
|
|
Inches(6), Inches(1))
|
|
para = box.text_frame.paragraphs[0]
|
|
run_obj = para.add_run()
|
|
run_obj.text = "Say TotalWord now"
|
|
run_obj.font.bold = True
|
|
run_obj.font.size = Pt(20)
|
|
run_obj.font.color.rgb = RGBColor.from_string("CC0000")
|
|
# simulate PowerPoint's spell-check split: same rPr, seam mid-match
|
|
second = cp.deepcopy(run_obj._r)
|
|
run_obj._r.addnext(second)
|
|
run_obj.text = "Say Total"
|
|
para.runs[1].text = "Word now"
|
|
path = workdir / "split_runs.pptx"
|
|
prs.save(str(path))
|
|
|
|
edited = workdir / "split_runs_edited.pptx"
|
|
result = run("pptx_edit.py", str(path), "--output", str(edited),
|
|
"--replace-text", "TotalWord", "MergedWord")
|
|
assert result["replacements"] == 1
|
|
|
|
prs2 = Presentation(str(edited))
|
|
para2 = prs2.slides[0].shapes[0].text_frame.paragraphs[0]
|
|
assert "".join(r.text for r in para2.runs) == "Say MergedWord now"
|
|
for r in para2.runs:
|
|
assert r.font.bold is True
|
|
assert r.font.size == Pt(20)
|
|
assert str(r.font.color.rgb) == "CC0000"
|
|
|
|
|
|
def test_chart_surgical_ops(deck, workdir):
|
|
from pptx import Presentation
|
|
spec = workdir / "chart_ops.json"
|
|
write_json(spec, {"slide": 3, "chart": 0, "ops": [
|
|
{"op": "update_series", "name": "North", "values": [99, 88]},
|
|
{"op": "add_series", "name": "East", "values": [1, 2]},
|
|
{"op": "remove_series", "name": "South"},
|
|
{"op": "rename_category", "from": "Q1", "to": "Q1 FY26"},
|
|
{"op": "set_title", "title": "Updated Sales"}]})
|
|
edited = workdir / "chart_ops.pptx"
|
|
run("pptx_edit.py", deck, "--output", str(edited),
|
|
"--chart-data", str(spec))
|
|
outline = run("pptx_read.py", str(edited))
|
|
bar = outline["slides"][3]["charts"][0]
|
|
assert bar["categories"] == ["Q1 FY26", "Q2"]
|
|
names = [s["name"] for s in bar["series"]]
|
|
assert "South" not in names and "East" in names
|
|
north = next(s for s in bar["series"] if s["name"] == "North")
|
|
assert north["values"] == [99.0, 88.0]
|
|
east = next(s for s in bar["series"] if s["name"] == "East")
|
|
assert east["values"] == [1.0, 2.0]
|
|
chart = next(s.chart for s in Presentation(str(edited)).slides[3].shapes
|
|
if s.has_chart)
|
|
assert chart.chart_title.text_frame.text == "Updated Sales"
|
|
|
|
|
|
def test_chart_op_unknown_series_fails(deck, workdir):
|
|
spec = workdir / "chart_bad.json"
|
|
write_json(spec, {"slide": 3, "chart": 0, "ops": [
|
|
{"op": "update_series", "name": "Nowhere", "values": [1, 2]}]})
|
|
proc = run_raw("pptx_edit.py", deck, "--output",
|
|
str(workdir / "never.pptx"), "--chart-data", str(spec))
|
|
assert proc.returncode != 0
|
|
assert "Nowhere" in proc.stderr
|
|
|
|
|
|
def test_duplicate_image_slide_is_independent(deck, workdir):
|
|
import base64
|
|
dup = workdir / "dup.pptx"
|
|
result = run("pptx_edit.py", deck, "--output", str(dup),
|
|
"--duplicate-slide", "2")
|
|
assert result["duplicated_to"] == 4
|
|
outline = run("pptx_read.py", str(dup)) # re-opens cleanly
|
|
assert outline["slide_count"] == 5
|
|
s4 = outline["slides"][4]
|
|
assert len(s4["images"]) == 1
|
|
assert s4["tables"][0][0] == ["Region", "Sales"]
|
|
assert "Callout" in s4["texts"]
|
|
|
|
# independence: swap the image on the COPY, original stays red
|
|
blue = base64.b64decode(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNg"
|
|
"YPj/HwADAgH/p5UronAAAAAASUVORK5CYII=")
|
|
blue_path = workdir / "blue2.png"
|
|
blue_path.write_bytes(blue)
|
|
from pptx import Presentation
|
|
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
|
prs = Presentation(str(dup))
|
|
name = next(s.name for s in prs.slides[4].shapes
|
|
if s.shape_type == MSO_SHAPE_TYPE.PICTURE)
|
|
run("pptx_edit.py", str(dup), "--swap-image", "4", name,
|
|
str(blue_path))
|
|
prs = Presentation(str(dup))
|
|
orig = next(s for s in prs.slides[2].shapes
|
|
if s.shape_type == MSO_SHAPE_TYPE.PICTURE)
|
|
copy_pic = next(s for s in prs.slides[4].shapes
|
|
if s.shape_type == MSO_SHAPE_TYPE.PICTURE)
|
|
assert copy_pic.image.blob == blue
|
|
assert orig.image.blob != blue
|
|
|
|
|
|
def test_duplicate_chart_slide_refused(deck, workdir):
|
|
proc = run_raw("pptx_edit.py", deck, "--output",
|
|
str(workdir / "never2.pptx"), "--duplicate-slide", "3")
|
|
assert proc.returncode != 0
|
|
assert "chart" in proc.stderr.lower()
|
|
|
|
|
|
def test_create_polish_features(workdir):
|
|
from pptx import Presentation
|
|
spec_path = workdir / "polish.json"
|
|
write_json(spec_path, {"slides": [
|
|
{"layout": "title_content", "title": "Polished",
|
|
"background": "112233", "footer": "Confidential draft",
|
|
"slide_number": True,
|
|
"bullets": [{"text": "Visit example",
|
|
"link": "https://example.com/info"}]}]})
|
|
out = workdir / "polish.pptx"
|
|
run("pptx_create.py", str(spec_path), str(out))
|
|
prs = Presentation(str(out))
|
|
slide = prs.slides[0]
|
|
assert str(slide.background.fill.fore_color.rgb) == "112233"
|
|
ph_idx = [ph.placeholder_format.idx for ph in slide.placeholders]
|
|
assert 12 in ph_idx # slide number enabled
|
|
footer = next(ph for ph in slide.placeholders
|
|
if ph.placeholder_format.idx == 11)
|
|
assert footer.text_frame.text == "Confidential draft"
|
|
link_runs = [r for sh in slide.shapes if sh.has_text_frame
|
|
for p in sh.text_frame.paragraphs for r in p.runs
|
|
if r.hyperlink.address]
|
|
assert link_runs[0].hyperlink.address == "https://example.com/info"
|
|
|
|
|
|
def test_edit_polish_features(deck, workdir):
|
|
from pptx import Presentation
|
|
edited = workdir / "polish_edit.pptx"
|
|
result = run("pptx_edit.py", deck, "--output", str(edited),
|
|
"--set-background", "0", "004400",
|
|
"--hyperlink", "1", "Overview", "https://example.com/x",
|
|
"--enable-slide-number", "1",
|
|
"--set-footer", "1", "Footer via edit")
|
|
assert result["background_set"] and result["footer_set"]
|
|
assert result["hyperlinked_runs"] == 1
|
|
assert result["slide_number_enabled"] is True
|
|
prs = Presentation(str(edited))
|
|
assert str(prs.slides[0].background.fill.fore_color.rgb) == "004400"
|
|
s1 = prs.slides[1]
|
|
assert any(ph.placeholder_format.idx == 12 for ph in s1.placeholders)
|
|
footer = next(ph for ph in s1.placeholders
|
|
if ph.placeholder_format.idx == 11)
|
|
assert footer.text_frame.text == "Footer via edit"
|
|
links = [r.hyperlink.address for sh in s1.shapes if sh.has_text_frame
|
|
for p in sh.text_frame.paragraphs for r in p.runs
|
|
if r.hyperlink.address]
|
|
assert links == ["https://example.com/x"]
|
|
|
|
|
|
def test_set_and_append_notes(deck, workdir):
|
|
edited = workdir / "notes_edit.pptx"
|
|
result = run("pptx_edit.py", deck, "--output", str(edited),
|
|
"--set-notes", "0", "Fresh notes")
|
|
assert result["notes_set"]
|
|
run("pptx_edit.py", str(edited), "--append-notes", "0", "Second line")
|
|
notes = run("pptx_read.py", str(edited), "--notes")
|
|
assert notes["notes"][0] == "Fresh notes\nSecond line"
|
|
|
|
|
|
def test_render_help_flag():
|
|
proc = run_raw("pptx_render.py", "--help")
|
|
assert proc.returncode == 0 and "usage" in proc.stdout.lower()
|