Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -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.
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user