Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Nous Research
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+220
View File
@@ -0,0 +1,220 @@
---
name: powerpoint
description: Create, read, edit .pptx decks with python-pptx.
version: 1.1.0
author: Nous Research
license: MIT
platforms: [linux, macos, windows]
metadata:
hermes:
tags: [pptx, powerpoint, presentations, slides, office, python-pptx]
category: productivity
related_skills: [docx, xlsx, pdf]
---
# Powerpoint Skill
Create, inspect, and edit PowerPoint (.pptx) presentations using the
python-pptx library. Five helper scripts cover deck creation from a JSON
spec, structured read-back, in-place edits, template-driven brand decks,
and slide rendering — all offline, no PowerPoint installation required.
## When to Use
- The user asks to build a slide deck, report presentation, or pitch deck.
- You need to extract text, notes, tables, chart data, or images from a
.pptx someone shared.
- You need to update an existing deck: replace text, refresh or patch
chart data, swap a logo, duplicate/remove/reorder slides, set
backgrounds, footers, hyperlinks, or speaker notes.
- You must produce an on-brand deck from a company .pptx template.
- Do NOT use this for .ppt (legacy binary) files — convert them first with
`soffice --convert-to pptx old.ppt` if LibreOffice is available.
## Prerequisites
- Python 3.10+ with `python-pptx` installed
(`pip install python-pptx`).
- Optional: LibreOffice (`soffice`) plus poppler (`pdftoppm` or
`pdftocairo`) for rendering slides to PNGs and for PDF export.
`pptx_render.py` detects both with `shutil.which` and degrades
gracefully (reports `{"rendered": false, "missing": [...]}`, exit 0)
when absent — all create/read/edit operations work without them.
- Check availability via `terminal`:
`python -c "import pptx; print(pptx.__version__)"` and `which soffice pdftoppm`.
## How to Run
All scripts live in `scripts/`, take `--help`, print JSON to stdout, and
exit non-zero on failure. Run them with `terminal`:
```bash
python scripts/pptx_create.py deck.json out.pptx
python scripts/pptx_read.py deck.pptx --outline # full JSON outline
python scripts/pptx_read.py deck.pptx --notes # speaker notes
python scripts/pptx_read.py deck.pptx --images ./img # export pictures
python scripts/pptx_edit.py deck.pptx --replace-text "Old Corp" "New Corp"
python scripts/pptx_edit.py deck.pptx --chart-data update.json
python scripts/pptx_edit.py deck.pptx --duplicate-slide 2
python scripts/pptx_edit.py deck.pptx --remove-slide 3 --move-slide 2 0
python scripts/pptx_from_template.py brand.pptx out.pptx --values vals.json
python scripts/pptx_render.py deck.pptx --outdir ./render # slide PNGs
```
Author JSON specs with `write_file`; inspect script output and generated
JSON with `read_file`.
## Quick Reference
| Task | Command |
|---|---|
| New deck from spec | `pptx_create.py spec.json out.pptx` |
| 16:9 vs 4:3 | `"slide_size": "16:9"` or `"4:3"` in the spec |
| Outline as JSON | `pptx_read.py deck.pptx --outline` |
| Export images | `pptx_read.py deck.pptx --images DIR` |
| Replace text | `pptx_edit.py deck.pptx --replace-text OLD NEW` |
| Replace chart data | `pptx_edit.py deck.pptx --chart-data spec.json` |
| Patch one series | same flag, spec with `"ops"` (see below) |
| Swap picture | `pptx_edit.py deck.pptx --swap-image N NAME new.png` |
| Duplicate slide | `pptx_edit.py deck.pptx --duplicate-slide N` |
| Remove slide | `pptx_edit.py deck.pptx --remove-slide N` |
| Reorder slide | `pptx_edit.py deck.pptx --move-slide FROM TO` |
| Slide background | `pptx_edit.py deck.pptx --set-background N RRGGBB` |
| Hyperlink runs | `pptx_edit.py deck.pptx --hyperlink N TEXT URL` |
| Slide number on | `pptx_edit.py deck.pptx --enable-slide-number N` |
| Footer text | `pptx_edit.py deck.pptx --set-footer N TEXT` |
| Set notes | `pptx_edit.py deck.pptx --set-notes N TEXT` |
| Append notes | `pptx_edit.py deck.pptx --append-notes N TEXT` |
| Fill template | `pptx_from_template.py tpl.pptx out.pptx --values v.json` |
| Render slide PNGs | `pptx_render.py deck.pptx --outdir DIR` |
## Procedure
### 1. Create a deck
Write a JSON spec (see `pptx_create.py --help` for the full format), then
run `pptx_create.py`. Per slide you can set: `layout` (title,
title_content, section, two_content, title_only, blank), `title`,
`subtitle`, `bullets` (strings, or dicts with `level` 0-4, `size` pt,
`bold`, `italic`, `font`, `color` hex, `link` URL for a hyperlink),
`background` (solid hex), `footer` (text; enables the layout's footer
placeholder), `slide_number` (true; enables the layout's slide-number
placeholder), `images` (path + left/top/width/height in inches), `tables`
(`rows` as list-of-lists), `shapes` (rectangle, rounded_rectangle, oval,
diamond, right_arrow, chevron, with `fill` hex + optional `text`),
`charts` (bar, bar_h, line, pie with `categories` + `series`), and
`notes` (speaker notes).
### 2. Read a deck
`pptx_read.py deck.pptx --outline` returns slide size, layout inventory,
and per slide: layout name, all shape texts, table cells, image inventory
(filename/ext/bytes), chart categories/series/values, and speaker notes.
Use `--images DIR` to dump embedded pictures to files, then
`vision_analyze` on any exported image if you need to see its content.
### 3. Edit a deck
`pptx_edit.py` combines operations in one pass; use `--output` to keep the
original. Text replacement scans slide shapes, table cells, and notes.
Image swap retargets the picture's relationship id so position and size
are preserved. Slide removal drops the relationship and the `<p:sldId>`
entry; reorder moves the `<p:sldId>` element within `<p:sldIdLst>`
(python-pptx has no public API for either — the script does the XML-level
work). `--duplicate-slide N` appends an independent deep copy of slide N:
shape XML plus image/media/hyperlink relationships are cloned and rIds
remapped, so editing the copy never touches the original. Chart slides
are refused (see Pitfalls). `--set-notes`/`--append-notes` edit speaker
notes; `--set-background`, `--hyperlink`, `--enable-slide-number`, and
`--set-footer` handle deck polish.
Chart updates take a JSON spec via `--chart-data`. Full replace:
`{"slide": 0, "chart": 0, "categories": [...], "series": {...}}`. For
surgical edits, pass `"ops"` instead — a list of
`{"op": "update_series", "name": ..., "values": [...]}`,
`add_series`, `remove_series`, `rename_category` (`from`/`to` or
`index`), and `set_title`. python-pptx can only swap a chart's entire
dataset (`replace_data`), so ops are implemented as read-existing →
modify → replace; the per-part UX is a wrapper, and any chart data not
expressible as categories + numeric series will be normalized by the
round-trip.
### 4. Build from a template
`pptx_from_template.py` opens a brand .pptx, replaces every
`{{token}}` from a values JSON across slides/tables/notes, and can append
new slides that use the template's own layouts (by layout name or index)
so they inherit the master's fonts and colors. Tip: to start from a
template with zero slides, delete existing ones afterward with
`pptx_edit.py --remove-slide`.
### 5. Visual verification
`pptx_render.py deck.pptx --outdir ./render` converts the deck to PDF
with `soffice --headless` and splits it into one PNG per slide with
`pdftoppm` (or `pdftocairo`). Output JSON lists the PNG paths — review
each with `vision_analyze`. When either tool is missing the script exits
0 with `{"rendered": false, "missing": [...]}` and guidance; fall back to
the JSON outline from `pptx_read.py`, which verifies content and
structure, just not visuals.
## Converting to PDF
If LibreOffice is installed, export the finished deck to PDF directly:
```bash
soffice --headless --convert-to pdf --outdir ./out deck.pptx
```
The output lands at `./out/deck.pdf`. Fonts not installed on the host are
substituted, so render-verify (Procedure step 5) before shipping the PDF.
There is no offline pure-Python .pptx→PDF path; if `soffice` is absent,
say so rather than approximating.
## Pitfalls
- **Run splitting**: PowerPoint fragments paragraph text into runs at
spell-check and edit boundaries. `--replace-text` first merges adjacent
runs whose formatting is identical, so matches split across such runs
are replaced with formatting fully preserved. Only when a match spans
*genuinely differently-formatted* runs is the paragraph rewritten with
the first run's formatting — verify those slides after replacement.
- **Chart slides cannot be duplicated**: each chart relationship embeds a
separate XLSX workbook part; cloning that graph reliably is not
supported, so `--duplicate-slide` refuses chart slides cleanly instead
of corrupting the deck. Rebuild the chart on a new slide instead.
External-hyperlink and image/media rels are carried over; layout and
notes rels are recreated fresh.
- **Chart ops are a wrapper**: python-pptx replaces the whole dataset;
`"ops"` round-trips existing plot data through `replace_data`, and
changing chart *type* is not possible.
- **Reordering is XML-level**: python-pptx has no supported reorder API.
`--move-slide` manipulates `<p:sldIdLst>` directly; safe for ordinary
decks but re-read the deck afterward to confirm.
- **Copying slides between decks is unsupported** — duplication works
only within one deck, where layouts and masters are shared.
- Footer/slide-number enablement copies the placeholder from the slide's
layout; on layouts without those placeholders, `--set-footer` fails
with a clear message (add a textbox instead).
- Hyperlinks apply to whole runs; `--hyperlink` links every run
containing the given text on that slide.
- The default python-pptx template is 4:3; the create script sets 16:9
unless the spec says otherwise. Custom templates keep their own size.
- Layout indexes vary by template. For brand templates, list layout names
first: `pptx_read.py template.pptx --outline` (`layouts_available`).
- `slide.shapes.title` is None on blank layouts — the create script
handles this, but remember it when writing ad-hoc python-pptx code.
- Always pass `encoding="utf-8"` when writing spec files; tokens like
`{{city}}` may be filled with non-ASCII values.
## Verification
1. After any create/edit, run `pptx_read.py OUT.pptx --outline` and check
slide count, texts, tables, notes, and chart values match intent.
2. `--images DIR` then file-size check confirms pictures embedded.
3. Render every slide with `pptx_render.py deck.pptx --outdir ./render`
and review each PNG with `vision_analyze` — this catches overlapping
shapes, truncated text, and color problems the outline cannot. If the
render tools are missing, the script says so; rely on the outline.
4. The bundled test suite is the full contract:
`python -m pytest tests/ -q` (requires python-pptx + pytest).
@@ -0,0 +1,214 @@
#!/usr/bin/env python3
"""Create a .pptx presentation from a JSON deck spec.
Spec format (all positions/sizes in inches, colors as RRGGBB hex):
{
"slide_size": "16:9", // or "4:3" (default "16:9")
"slides": [
{"layout": "title", "title": "My Deck", "subtitle": "Q3 review"},
{"layout": "title_content", "title": "Agenda",
"bullets": ["Top item",
{"text": "Sub item", "level": 1, "bold": true,
"size": 18, "color": "CC0000", "font": "Arial",
"italic": false,
"link": "https://example.com/agenda"}],
"background": "1F2937", // solid slide background (hex)
"footer": "Confidential", // footer placeholder text
"slide_number": true, // enable slide-number placeholder
"notes": "Speaker notes for this slide"},
{"layout": "blank", "title": "Widgets",
"images": [{"path": "logo.png", "left": 1, "top": 1, "width": 3}],
"tables": [{"left": 1, "top": 2, "width": 6, "height": 2,
"rows": [["H1", "H2"], ["a", "b"]]}],
"shapes": [{"type": "rounded_rectangle", "left": 8, "top": 1,
"width": 3, "height": 1, "fill": "4472C4",
"text": "Callout", "text_color": "FFFFFF"}],
"charts": [{"type": "bar", "left": 1, "top": 3, "width": 6,
"height": 3.5, "title": "Sales",
"categories": ["Q1", "Q2"],
"series": {"North": [10, 20], "South": [7, 13]}}]}
]
}
Layouts: title, title_content, section, two_content, title_only, blank
Chart types: bar, bar_h, line, pie Shape types: rectangle,
rounded_rectangle, oval, diamond, right_arrow, chevron
"""
import argparse
import copy
import json
import sys
from pptx import Presentation
from pptx.chart.data import CategoryChartData
from pptx.dml.color import RGBColor
from pptx.enum.chart import XL_CHART_TYPE
from pptx.enum.shapes import MSO_SHAPE
from pptx.util import Inches, Pt
LAYOUTS = {"title": 0, "title_content": 1, "section": 2,
"two_content": 3, "title_only": 5, "blank": 6}
CHART_TYPES = {"bar": XL_CHART_TYPE.COLUMN_CLUSTERED,
"bar_h": XL_CHART_TYPE.BAR_CLUSTERED,
"line": XL_CHART_TYPE.LINE_MARKERS,
"pie": XL_CHART_TYPE.PIE}
SHAPE_TYPES = {"rectangle": MSO_SHAPE.RECTANGLE,
"rounded_rectangle": MSO_SHAPE.ROUNDED_RECTANGLE,
"oval": MSO_SHAPE.OVAL, "diamond": MSO_SHAPE.DIAMOND,
"right_arrow": MSO_SHAPE.RIGHT_ARROW,
"chevron": MSO_SHAPE.CHEVRON}
def style_run(run, spec):
"""Apply font styling from a bullet/text spec dict to a run."""
font = run.font
if spec.get("size"):
font.size = Pt(spec["size"])
if spec.get("bold") is not None:
font.bold = spec["bold"]
if spec.get("italic") is not None:
font.italic = spec["italic"]
if spec.get("font"):
font.name = spec["font"]
if spec.get("color"):
font.color.rgb = RGBColor.from_string(spec["color"])
if spec.get("link"):
run.hyperlink.address = spec["link"]
def add_bullets(text_frame, bullets):
text_frame.clear()
for i, item in enumerate(bullets):
if isinstance(item, str):
item = {"text": item}
para = text_frame.paragraphs[0] if i == 0 else text_frame.add_paragraph()
para.level = int(item.get("level", 0))
run = para.add_run()
run.text = item.get("text", "")
style_run(run, item)
def copy_layout_placeholder(slide, ph_idx):
"""Copy a layout placeholder (footer=11, slide number=12) onto the
slide so it actually renders; returns the shape or None if the layout
does not provide it."""
for ph in slide.slide_layout.placeholders:
if ph.placeholder_format.idx == ph_idx:
slide.shapes._spTree.append(copy.deepcopy(ph._element))
for shape in slide.placeholders:
if shape.placeholder_format.idx == ph_idx:
return shape
return None
def build_slide(prs, spec):
layout_idx = LAYOUTS.get(spec.get("layout", "title_content"), 1)
slide = prs.slides.add_slide(prs.slide_layouts[layout_idx])
if spec.get("background"):
fill = slide.background.fill
fill.solid()
fill.fore_color.rgb = RGBColor.from_string(spec["background"])
if spec.get("slide_number"):
copy_layout_placeholder(slide, 12)
if spec.get("footer"):
shape = copy_layout_placeholder(slide, 11)
if shape is not None:
shape.text_frame.text = spec["footer"]
if spec.get("title") is not None and slide.shapes.title is not None:
slide.shapes.title.text = spec["title"]
if spec.get("subtitle") is not None:
for ph in slide.placeholders:
if ph.placeholder_format.idx == 1:
ph.text = spec["subtitle"]
break
if spec.get("bullets"):
body = next((ph for ph in slide.placeholders
if ph.placeholder_format.idx != 0), None)
if body is None:
body = slide.shapes.add_textbox(Inches(0.5), Inches(1.5),
Inches(9), Inches(5))
add_bullets(body.text_frame, spec["bullets"])
for img in spec.get("images", []):
kwargs = {}
if img.get("width"):
kwargs["width"] = Inches(img["width"])
if img.get("height"):
kwargs["height"] = Inches(img["height"])
slide.shapes.add_picture(img["path"], Inches(img.get("left", 1)),
Inches(img.get("top", 1)), **kwargs)
for tbl in spec.get("tables", []):
rows = tbl["rows"]
shape = slide.shapes.add_table(
len(rows), len(rows[0]), Inches(tbl.get("left", 1)),
Inches(tbl.get("top", 2)), Inches(tbl.get("width", 6)),
Inches(tbl.get("height", 2)))
for r, row in enumerate(rows):
for c, val in enumerate(row):
shape.table.cell(r, c).text = str(val)
for shp in spec.get("shapes", []):
shape = slide.shapes.add_shape(
SHAPE_TYPES.get(shp.get("type", "rectangle"), MSO_SHAPE.RECTANGLE),
Inches(shp.get("left", 1)), Inches(shp.get("top", 1)),
Inches(shp.get("width", 2)), Inches(shp.get("height", 1)))
if shp.get("fill"):
shape.fill.solid()
shape.fill.fore_color.rgb = RGBColor.from_string(shp["fill"])
if shp.get("text"):
shape.text_frame.text = shp["text"]
if shp.get("text_color"):
run = shape.text_frame.paragraphs[0].runs[0]
run.font.color.rgb = RGBColor.from_string(shp["text_color"])
for cht in spec.get("charts", []):
data = CategoryChartData()
data.categories = cht["categories"]
for name, values in cht["series"].items():
data.add_series(name, values)
frame = slide.shapes.add_chart(
CHART_TYPES.get(cht.get("type", "bar"),
XL_CHART_TYPE.COLUMN_CLUSTERED),
Inches(cht.get("left", 1)), Inches(cht.get("top", 2)),
Inches(cht.get("width", 6)), Inches(cht.get("height", 4)), data)
if cht.get("title"):
frame.chart.has_title = True
frame.chart.chart_title.text_frame.text = cht["title"]
if spec.get("notes"):
slide.notes_slide.notes_text_frame.text = spec["notes"]
return slide
def main(argv=None):
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
parser = argparse.ArgumentParser(
description="Create a .pptx deck from a JSON spec.",
epilog=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("spec", help="path to JSON deck spec")
parser.add_argument("output", help="output .pptx path")
args = parser.parse_args(argv)
with open(args.spec, encoding="utf-8") as fh:
spec = json.load(fh)
prs = Presentation()
if spec.get("slide_size", "16:9") == "16:9":
prs.slide_width, prs.slide_height = Inches(13.333), Inches(7.5)
else:
prs.slide_width, prs.slide_height = Inches(10), Inches(7.5)
for slide_spec in spec.get("slides", []):
build_slide(prs, slide_spec)
prs.save(args.output)
print(json.dumps({"ok": True, "output": args.output,
"slides": len(prs.slides._sldIdLst)}))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,436 @@
#!/usr/bin/env python3
"""Edit a .pptx in place (or save to --output).
Operations (repeatable / combinable):
--replace-text OLD NEW Replace text everywhere (slides, tables, notes).
Adjacent runs with identical formatting are
merged first, so matches PowerPoint split across
identically-formatted runs keep their formatting.
Only a match spanning genuinely different
formats falls back to a paragraph rewrite with
the first run's font (documented caveat).
--chart-data SPEC.json Update a chart. Full replace spec:
{"slide": 0, "chart": 0,
"categories": ["Q1", "Q2"],
"series": {"North": [1, 2], "South": [3, 4]}}
Or surgical ops (existing data is read, modified,
and written back via replace_data):
{"slide": 0, "chart": 0, "ops": [
{"op": "update_series", "name": "North",
"values": [5, 6]},
{"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": "New title"}]}
--swap-image SLIDE SHAPE_NAME NEW_IMAGE
Replace a picture's bits, keeping position/size.
--remove-slide N Delete slide at index N (0-based).
--move-slide FROM TO Reorder: move slide FROM to position TO.
--duplicate-slide N Append an independent deep copy of slide N
(text, images, tables, shapes, notes). Refuses
slides containing charts (a chart embeds an XLSX
workbook part that cannot be cloned reliably).
--set-background N HEX Solid background color for slide N.
--hyperlink N TEXT URL Make runs containing TEXT on slide N links.
--enable-slide-number N Copy the layout's slide-number placeholder in.
--set-footer N TEXT Enable the layout's footer placeholder with TEXT.
--set-notes N TEXT Replace slide N's speaker notes.
--append-notes N TEXT Append a paragraph to slide N's speaker notes.
"""
import argparse
import copy
import json
import sys
from lxml import etree
from pptx import Presentation
from pptx.chart.data import CategoryChartData
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_SHAPE_TYPE
from pptx.oxml.ns import qn
R_EMBED = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
def _run_format_key(r):
"""Canonical string for a run's <a:rPr>; None when absent."""
rPr = r.find(qn("a:rPr"))
if rPr is None:
return None
return etree.tostring(rPr)
def normalize_runs(para):
"""Merge adjacent runs whose formatting is byte-identical.
PowerPoint splits paragraph text into runs at spell-check and edit
boundaries even when formatting never changes; merging them back makes
cross-run text replacement lossless for the common case.
"""
runs = list(para.runs)
i = 0
while i + 1 < len(runs):
a, b = runs[i], runs[i + 1]
if (_run_format_key(a._r) == _run_format_key(b._r)
and a._r.getnext() is b._r):
a.text = a.text + b.text
b._r.getparent().remove(b._r)
runs.pop(i + 1)
else:
i += 1
def replace_in_text_frame(text_frame, old, new):
count = 0
for para in text_frame.paragraphs:
joined = "".join(run.text for run in para.runs)
if old not in joined:
continue
if not any(old in run.text for run in para.runs):
# Match spans runs: merge identically-formatted neighbours
# first, which resolves pure spell-check splits losslessly.
normalize_runs(para)
if any(old in run.text for run in para.runs):
# Run-level replace: preserves each run's formatting exactly.
for run in para.runs:
if old in run.text:
count += run.text.count(old)
run.text = run.text.replace(old, new)
else:
# Match spans genuinely differently-formatted runs -> rewrite
# paragraph, keeping only the first run's formatting (caveat).
joined = "".join(run.text for run in para.runs)
count += joined.count(old)
first = para.runs[0]
first.text = joined.replace(old, new)
for run in para.runs[1:]:
run._r.getparent().remove(run._r)
return count
def iter_text_frames(slide):
for shape in slide.shapes:
if shape.has_text_frame:
yield shape.text_frame
if shape.has_table:
for row in shape.table.rows:
for cell in row.cells:
yield cell.text_frame
if slide.has_notes_slide:
yield slide.notes_slide.notes_text_frame
def replace_text(prs, old, new):
total = 0
for slide in prs.slides:
for tf in iter_text_frames(slide):
total += replace_in_text_frame(tf, old, new)
return total
def _read_chart_data(chart):
"""Current categories and ordered (name, values) pairs of a chart."""
categories = [str(c) for c in chart.plots[0].categories]
series = []
for plot in chart.plots:
for s in plot.series:
try:
name = s.name
except (AttributeError, KeyError):
name = ""
series.append([name, list(s.values)])
return categories, series
def update_chart(prs, spec_path):
"""Full replace ("categories"+"series") or surgical "ops".
python-pptx can only swap a chart's entire dataset (replace_data), so
surgical ops are implemented as read-existing -> modify -> replace.
"""
with open(spec_path, encoding="utf-8") as fh:
spec = json.load(fh)
slide = prs.slides[spec.get("slide", 0)]
charts = [s.chart for s in slide.shapes if s.has_chart]
if not charts:
raise SystemExit(f"no chart on slide {spec.get('slide', 0)}")
chart = charts[spec.get("chart", 0)]
if "ops" in spec:
categories, series = _read_chart_data(chart)
dirty = False
for op in spec["ops"]:
kind = op["op"]
if kind == "update_series":
match = [s for s in series if s[0] == op["name"]]
if not match:
raise SystemExit(f"no series named {op['name']!r}")
match[0][1] = op["values"]
dirty = True
elif kind == "add_series":
series.append([op["name"], op["values"]])
dirty = True
elif kind == "remove_series":
before = len(series)
series = [s for s in series if s[0] != op["name"]]
if len(series) == before:
raise SystemExit(f"no series named {op['name']!r}")
dirty = True
elif kind == "rename_category":
if "index" in op:
idx = int(op["index"])
else:
if op["from"] not in categories:
raise SystemExit(
f"no category named {op['from']!r}")
idx = categories.index(op["from"])
categories[idx] = op["to"]
dirty = True
elif kind == "set_title":
chart.has_title = True
chart.chart_title.text_frame.text = op["title"]
else:
raise SystemExit(f"unknown chart op {kind!r}")
if dirty:
data = CategoryChartData()
data.categories = categories
for name, values in series:
data.add_series(name, values)
chart.replace_data(data)
return
data = CategoryChartData()
data.categories = spec["categories"]
for name, values in spec["series"].items():
data.add_series(name, values)
chart.replace_data(data)
def swap_image(prs, slide_idx, shape_name, new_path):
slide = prs.slides[int(slide_idx)]
for shape in slide.shapes:
if (shape.shape_type == MSO_SHAPE_TYPE.PICTURE
and shape.name == shape_name):
image_part, rid = slide.part.get_or_add_image_part(new_path)
blip = shape._element.blipFill.blip
blip.set(R_EMBED + "embed", rid)
return True
raise SystemExit(f"no picture named {shape_name!r} on slide {slide_idx}")
def remove_slide(prs, index):
sldIdLst = prs.slides._sldIdLst
slide_id = list(sldIdLst)[int(index)]
rid = slide_id.get(R_EMBED + "id")
prs.part.drop_rel(rid)
sldIdLst.remove(slide_id)
def move_slide(prs, src, dst):
"""Reorder by moving the <p:sldId> element inside <p:sldIdLst>."""
sldIdLst = prs.slides._sldIdLst
ids = list(sldIdLst)
element = ids[int(src)]
sldIdLst.remove(element)
sldIdLst.insert(int(dst), element)
def duplicate_slide(prs, index):
"""Append an independent deep copy of slide `index`.
Copies the shape tree XML and re-creates image/media relationships on
the new slide part, remapping rIds. Charts are refused: each chart
relationship embeds a separate XLSX workbook part, and cloning that
graph reliably is not supported — better to refuse than corrupt.
"""
source = prs.slides[int(index)]
if any(sh.has_chart for sh in source.shapes):
raise SystemExit(
f"slide {index} contains a chart; duplication of chart slides "
"is not supported (chart XML embeds a workbook part that "
"cannot be cloned safely). Rebuild the chart on a new slide "
"with pptx_create.py / pptx_from_template.py instead.")
dest = prs.slides.add_slide(source.slide_layout)
# drop the placeholders add_slide seeded from the layout
for shape in list(dest.shapes):
shape._element.getparent().remove(shape._element)
for shape in source.shapes:
dest.shapes._spTree.append(copy.deepcopy(shape._element))
# re-create the source slide's part relationships on the copy
rid_map = {}
for rel in list(source.part.rels.values()):
if rel.reltype.endswith(("/slideLayout", "/notesSlide")):
continue
if rel.is_external:
new_rid = dest.part.rels.get_or_add_ext_rel(
rel.reltype, rel.target_ref)
else:
new_rid = dest.part.relate_to(rel.target_part, rel.reltype)
rid_map[rel.rId] = new_rid
for el in dest.shapes._spTree.iter():
for attr, val in el.attrib.items():
if attr.startswith(R_EMBED) and val in rid_map:
el.set(attr, rid_map[val])
if source.has_notes_slide:
dest.notes_slide.notes_text_frame.text = (
source.notes_slide.notes_text_frame.text)
return len(prs.slides._sldIdLst) - 1
def set_background(slide, hex_color):
fill = slide.background.fill
fill.solid()
fill.fore_color.rgb = RGBColor.from_string(hex_color)
def add_hyperlink(prs, slide_idx, text, url):
"""Turn every run containing `text` on the slide into a hyperlink.
The link applies to the whole run (python-pptx links whole runs).
"""
slide = prs.slides[int(slide_idx)]
hits = 0
for shape in slide.shapes:
if not shape.has_text_frame:
continue
for para in shape.text_frame.paragraphs:
for run in para.runs:
if text in run.text:
run.hyperlink.address = url
hits += 1
if not hits:
raise SystemExit(f"no run containing {text!r} on slide {slide_idx}")
return hits
def _copy_layout_placeholder(slide, ph_idx):
"""Copy the layout placeholder with idx `ph_idx` onto the slide.
Slide-number (idx 12) and footer (idx 11) placeholders exist on the
layout but are not inherited by a slide until the slide carries its
own copy — this enables them. Returns the new shape, or None when the
layout does not provide that placeholder.
"""
for ph in slide.slide_layout.placeholders:
if ph.placeholder_format.idx == ph_idx:
el = copy.deepcopy(ph._element)
slide.shapes._spTree.append(el)
for shape in slide.placeholders:
if shape.placeholder_format.idx == ph_idx:
return shape
return None
return None
def enable_slide_number(slide):
if any(ph.placeholder_format.idx == 12 for ph in slide.placeholders):
return True
return _copy_layout_placeholder(slide, 12) is not None
def set_footer(slide, text):
shape = next((ph for ph in slide.placeholders
if ph.placeholder_format.idx == 11), None)
if shape is None:
shape = _copy_layout_placeholder(slide, 11)
if shape is None:
raise SystemExit("layout provides no footer placeholder; add a "
"textbox instead")
shape.text_frame.text = text
return True
def set_notes(slide, text, append=False):
tf = slide.notes_slide.notes_text_frame
if append and tf.text:
para = tf.add_paragraph()
para.text = text
else:
tf.text = text
def main(argv=None):
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
parser = argparse.ArgumentParser(
description="Edit a .pptx: replace text, update chart data, swap "
"images, duplicate/remove/reorder slides, backgrounds, "
"hyperlinks, footers, slide numbers, speaker notes.",
epilog=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("pptx", help="path to the .pptx file")
parser.add_argument("--output", help="save to this path instead of "
"overwriting the input")
parser.add_argument("--replace-text", nargs=2, action="append",
metavar=("OLD", "NEW"), default=[])
parser.add_argument("--chart-data", metavar="SPEC_JSON")
parser.add_argument("--swap-image", nargs=3,
metavar=("SLIDE", "SHAPE_NAME", "IMAGE"))
parser.add_argument("--remove-slide", type=int, metavar="N")
parser.add_argument("--move-slide", nargs=2, type=int,
metavar=("FROM", "TO"))
parser.add_argument("--duplicate-slide", type=int, metavar="N")
parser.add_argument("--set-background", nargs=2,
metavar=("SLIDE", "HEX"))
parser.add_argument("--hyperlink", nargs=3,
metavar=("SLIDE", "TEXT", "URL"))
parser.add_argument("--enable-slide-number", type=int, metavar="N")
parser.add_argument("--set-footer", nargs=2, metavar=("SLIDE", "TEXT"))
parser.add_argument("--set-notes", nargs=2, metavar=("SLIDE", "TEXT"))
parser.add_argument("--append-notes", nargs=2, metavar=("SLIDE", "TEXT"))
args = parser.parse_args(argv)
prs = Presentation(args.pptx)
report = {"ok": True, "replacements": 0}
for old, new in args.replace_text:
report["replacements"] += replace_text(prs, old, new)
if args.chart_data:
update_chart(prs, args.chart_data)
report["chart_updated"] = True
if args.swap_image:
swap_image(prs, *args.swap_image)
report["image_swapped"] = True
if args.duplicate_slide is not None:
report["duplicated_to"] = duplicate_slide(prs, args.duplicate_slide)
if args.set_background:
set_background(prs.slides[int(args.set_background[0])],
args.set_background[1])
report["background_set"] = True
if args.hyperlink:
report["hyperlinked_runs"] = add_hyperlink(prs, *args.hyperlink)
if args.enable_slide_number is not None:
report["slide_number_enabled"] = enable_slide_number(
prs.slides[args.enable_slide_number])
if args.set_footer:
set_footer(prs.slides[int(args.set_footer[0])], args.set_footer[1])
report["footer_set"] = True
if args.set_notes:
set_notes(prs.slides[int(args.set_notes[0])], args.set_notes[1])
report["notes_set"] = True
if args.append_notes:
set_notes(prs.slides[int(args.append_notes[0])],
args.append_notes[1], append=True)
report["notes_appended"] = True
if args.remove_slide is not None:
remove_slide(prs, args.remove_slide)
report["slide_removed"] = args.remove_slide
if args.move_slide:
move_slide(prs, *args.move_slide)
report["slide_moved"] = args.move_slide
out = args.output or args.pptx
prs.save(out)
report["output"] = out
print(json.dumps(report))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Build a deck from a .pptx template (brand deck) and fill placeholders.
Two modes:
1) Token fill (default): open TEMPLATE, replace every {{token}} across
slides, tables, and notes using --values JSON ({"token": "value"}),
save to OUTPUT. Formatting of the token's run is preserved.
2) --add-slides SPEC.json: additionally append slides built from the
template's own layouts (referenced by layout name or index), so new
slides inherit the brand master. Spec:
{"slides": [{"layout": "Title and Content", "title": "New",
"bullets": ["a", {"text": "b", "level": 1}],
"notes": "presenter text"}]}
"""
import argparse
import json
import sys
from pptx import Presentation
def fill_tokens(prs, values):
from pptx_edit import replace_text # same scripts/ directory
total = 0
for token, value in values.items():
total += replace_text(prs, "{{%s}}" % token, str(value))
return total
def find_layout(prs, ref):
if isinstance(ref, int):
return prs.slide_layouts[ref]
for layout in prs.slide_layouts:
if layout.name == ref:
return layout
raise SystemExit(f"layout {ref!r} not found; available: "
f"{[la.name for la in prs.slide_layouts]}")
def add_slides(prs, spec):
from pptx_create import add_bullets
for slide_spec in spec.get("slides", []):
layout = find_layout(prs, slide_spec.get("layout", 1))
slide = prs.slides.add_slide(layout)
if slide_spec.get("title") is not None and slide.shapes.title:
slide.shapes.title.text = slide_spec["title"]
if slide_spec.get("bullets"):
body = next((ph for ph in slide.placeholders
if ph.placeholder_format.idx != 0), None)
if body is not None:
add_bullets(body.text_frame, slide_spec["bullets"])
if slide_spec.get("notes"):
slide.notes_slide.notes_text_frame.text = slide_spec["notes"]
def main(argv=None):
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
parser = argparse.ArgumentParser(
description="Fill {{tokens}} in a .pptx template and optionally "
"append slides using the template's own layouts.",
epilog=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("template", help="path to the template .pptx")
parser.add_argument("output", help="output .pptx path")
parser.add_argument("--values", metavar="JSON",
help="JSON file mapping token -> replacement value")
parser.add_argument("--add-slides", metavar="SPEC_JSON",
help="JSON spec of slides to append")
args = parser.parse_args(argv)
prs = Presentation(args.template)
filled = 0
if args.values:
with open(args.values, encoding="utf-8") as fh:
filled = fill_tokens(prs, json.load(fh))
if args.add_slides:
with open(args.add_slides, encoding="utf-8") as fh:
add_slides(prs, json.load(fh))
prs.save(args.output)
print(json.dumps({"ok": True, "output": args.output,
"tokens_filled": filled}))
return 0
if __name__ == "__main__":
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.exit(main())
@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""Read a .pptx file: JSON outline, notes, or export embedded images.
Modes:
--outline JSON with per-slide layout, texts, tables, notes,
chart data, and image inventory (default mode).
--notes JSON list of speaker notes per slide.
--images DIR Export every embedded picture to DIR as files.
"""
import argparse
import json
import os
import sys
from pptx import Presentation
from pptx.enum.shapes import MSO_SHAPE_TYPE
from pptx.util import Emu
def iter_shapes(shapes):
"""Yield shapes, descending into groups."""
for shape in shapes:
if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
yield from iter_shapes(shape.shapes)
else:
yield shape
def chart_info(chart):
info = {"type": str(chart.chart_type),
"categories": [str(c) for c in chart.plots[0].categories],
"series": []}
for plot in chart.plots:
for series in plot.series:
try:
name = series.name
except (AttributeError, KeyError):
name = None
info["series"].append({"name": name,
"values": list(series.values)})
return info
def slide_record(index, slide):
rec = {"index": index, "layout": slide.slide_layout.name,
"texts": [], "tables": [], "images": [], "charts": [],
"notes": None}
for shape in iter_shapes(slide.shapes):
if shape.has_text_frame and shape.text_frame.text.strip():
rec["texts"].append(shape.text_frame.text)
if shape.has_table:
rec["tables"].append(
[[cell.text for cell in row.cells]
for row in shape.table.rows])
if shape.shape_type == MSO_SHAPE_TYPE.PICTURE:
try:
img = shape.image
rec["images"].append({"filename": img.filename,
"ext": img.ext,
"size_bytes": len(img.blob)})
except (KeyError, ValueError):
rec["images"].append({"filename": None, "ext": None,
"size_bytes": None,
"note": "linked or unreadable"})
if shape.has_chart:
rec["charts"].append(chart_info(shape.chart))
if slide.has_notes_slide:
rec["notes"] = slide.notes_slide.notes_text_frame.text
return rec
def export_images(prs, out_dir):
os.makedirs(out_dir, exist_ok=True)
written = []
for i, slide in enumerate(prs.slides):
for j, shape in enumerate(iter_shapes(slide.shapes)):
if shape.shape_type != MSO_SHAPE_TYPE.PICTURE:
continue
try:
img = shape.image
except (KeyError, ValueError):
continue
path = os.path.join(out_dir, f"slide{i}_img{j}.{img.ext}")
with open(path, "wb") as fh:
fh.write(img.blob)
written.append(path)
return written
def main(argv=None):
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
parser = argparse.ArgumentParser(
description="Read a .pptx: outline/notes as JSON, export images.")
parser.add_argument("pptx", help="path to the .pptx file")
parser.add_argument("--outline", action="store_true",
help="print full JSON outline (default)")
parser.add_argument("--notes", action="store_true",
help="print speaker notes only")
parser.add_argument("--images", metavar="DIR",
help="export embedded images into DIR")
args = parser.parse_args(argv)
prs = Presentation(args.pptx)
if args.images:
written = export_images(prs, args.images)
print(json.dumps({"ok": True, "exported": written}, indent=2))
return 0
if args.notes:
notes = [slide.notes_slide.notes_text_frame.text
if slide.has_notes_slide else None
for slide in prs.slides]
print(json.dumps({"ok": True, "notes": notes},
indent=2, ensure_ascii=True))
return 0
outline = {
"ok": True,
"slide_size_inches": [round(Emu(prs.slide_width).inches, 3),
round(Emu(prs.slide_height).inches, 3)],
"slide_count": len(prs.slides._sldIdLst),
"layouts_available": [lay.name for lay in prs.slide_layouts],
"slides": [slide_record(i, s) for i, s in enumerate(prs.slides)],
}
print(json.dumps(outline, indent=2, ensure_ascii=True))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""Render every slide of a .pptx to per-slide PNG images.
Pipeline: LibreOffice (soffice --headless --convert-to pdf) turns the deck
into a PDF, then poppler (pdftoppm, or pdftocairo as an alternate) splits
the PDF into one PNG per slide.
Output is JSON. When both tools are present:
{"rendered": true, "files": ["render/slide-1.png", ...]}
When either tool is missing the script still exits 0 and reports:
{"rendered": false, "missing": ["soffice"], "guidance": "..."}
so callers can degrade gracefully (fall back to pptx_read.py --outline).
"""
import argparse
import glob
import json
import os
import shutil
import subprocess
import sys
import tempfile
def find_tools():
"""Return (soffice, splitter, missing) using shutil.which."""
soffice = shutil.which("soffice")
splitter = shutil.which("pdftoppm") or shutil.which("pdftocairo")
missing = []
if not soffice:
missing.append("soffice")
if not splitter:
missing.append("pdftoppm (or pdftocairo)")
return soffice, splitter, missing
def render(pptx_path, out_dir, prefix, dpi):
soffice, splitter, missing = find_tools()
if missing:
return {
"rendered": False, "missing": missing,
"guidance": "Install LibreOffice (soffice) and poppler-utils "
"(pdftoppm/pdftocairo) to render slides. Without "
"them, verify decks with pptx_read.py --outline "
"instead.",
}
os.makedirs(out_dir, exist_ok=True)
with tempfile.TemporaryDirectory() as tmp:
proc = subprocess.run(
[soffice, "--headless", "--convert-to", "pdf",
"--outdir", tmp, pptx_path],
capture_output=True, text=True, encoding="utf-8",
errors="replace", timeout=300)
pdfs = glob.glob(os.path.join(tmp, "*.pdf"))
if proc.returncode != 0 or not pdfs:
raise SystemExit(f"soffice PDF conversion failed: {proc.stderr}")
pdf = pdfs[0]
out_prefix = os.path.join(out_dir, prefix)
proc = subprocess.run(
[splitter, "-png", "-r", str(dpi), pdf, out_prefix],
capture_output=True, text=True, encoding="utf-8",
errors="replace", timeout=300)
if proc.returncode != 0:
raise SystemExit(f"{os.path.basename(splitter)} failed: "
f"{proc.stderr}")
files = sorted(glob.glob(out_prefix + "*.png"))
return {"rendered": True, "files": files}
def main(argv=None):
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
parser = argparse.ArgumentParser(
description="Render each slide of a .pptx to a PNG via "
"soffice + pdftoppm/pdftocairo.",
epilog=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("pptx", help="path to the .pptx file")
parser.add_argument("--outdir", default="render",
help="directory for PNGs (default: ./render)")
parser.add_argument("--prefix", default="slide",
help="PNG filename prefix (default: slide)")
parser.add_argument("--dpi", type=int, default=100,
help="render resolution (default: 100)")
args = parser.parse_args(argv)
result = render(args.pptx, args.outdir, args.prefix, args.dpi)
print(json.dumps(result, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,475 @@
"""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()