Metadata-Version: 2.4
Name: pptxkit
Version: 0.0.2
Summary: Build, take apart, reconcile and check PowerPoint decks kept as a recipe
Author-email: Dan Jacobellis <danjacobellis@utexas.edu>
License-Expression: LicenseRef-Proprietary
Project-URL: Homepage, https://danjacobellis.net
Project-URL: Repository, https://github.com/danjacobellis/pptxkit
Project-URL: Issues, https://github.com/danjacobellis/pptxkit/issues
Keywords: powerpoint,pptx,slides,reconcile,python-pptx
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Operating System :: OS Independent
Classifier: Topic :: Office/Business
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: python-pptx
Requires-Dist: lxml
Requires-Dist: pillow
Requires-Dist: nbformat
Requires-Dist: numpy
Provides-Extra: win
Requires-Dist: pywin32; extra == "win"
Provides-Extra: exec
Requires-Dist: nbclient; extra == "exec"
Dynamic: license-file

# pptxkit

[![PyPI](https://img.shields.io/pypi/v/pptxkit)](https://pypi.org/project/pptxkit/)

Tools for a PowerPoint deck that is kept as a recipe and built by a script, with a workflow for taking hand edits made in PowerPoint back into the recipe. The headless parts (python-pptx + lxml) run anywhere; the parts that need PowerPoint drive it through COM and run on Windows only. Origin: the tooling that grew around one large defense deck (2026-09), with everything specific to that deck removed.

## Install

```
pip install pptxkit[win]        # Windows, with PowerPoint: everything
pip install pptxkit             # elsewhere: the headless modules
```

## A deck as a recipe

The recipe has four parts, all tracked in git:

- `deck.ipynb`, a notebook whose markdown cells are slides. A cell starts with `<!-- slide: <id> layout: "<layout name>" -->` and holds the title, bullets, pictures, tables and `> ` speaker-notes lines in a small grammar (`pptxkit.notebook`, docstring). Other cell kinds: `<!-- slide: <id> static -->` takes a slide verbatim from the template, `<!-- slide: <id> spec -->` rebuilds it from tracked slide parts, `<!-- slide: <id> seed: <n> -->` copies slide n of an untracked seed deck. A cell `## Section: <name>` starts a section, which the build can mark with a coloured title bar. Header flags: `size:`, `nonum`, `hidden`, `norecenter`, `nobar`; `spec: <other id>` and `static: <other id>` alias another slide.
- `template.pptx`, the master and layouts, plus any slide kept verbatim as `static:<id>`.
- `slides/<id>/`, the parts of a slide that is tracked as text: `slide.xml` (the DrawingML, pretty-printed, pictures referenced as `rel:image:<sha1>.webp`), `timing.xml` (the animation), `notes.md`, `spec.json`, `README.md`; the pictures themselves in `slides/_assets/`, content-addressed, rasters re-encoded to WebP and everything else byte for byte (`pptxkit.parts`).
- Generators: Python that draws animated slides into `slides/<id>/` with `pptxkit.draw.Slide` (text boxes, filled shapes, connectors, pictures, and a step list where each step is one click).

Every slide the build makes is named `<prefix>:<id>` and every shape it makes `<prefix>:<id>:<role>` (default prefix `slide`), which is what lets an edited deck be matched back to its cells.

A project's build script is a few lines:

```python
# build.py
from pptxkit.build import BuildConfig
from pptxkit.win.build import build
cfg = BuildConfig(prefix='slide', template='template.pptx', slides_dir='slides',
                  bars={'intro': '1F77B4', 'results': '2CA02C'})
build('deck.ipynb', 'deck.pptx', cfg, export_png=True)
```

`python build.py` opens the template in PowerPoint, adds the slides in cell order (layout slides filled from the cell, static slides moved into place and filled by role, spec slides added empty), saves, then rebuilds every spec slide inside the saved file headlessly (`pptxkit.build.post_pass`) and adds the title bars (`pptxkit.titlebar`). With `export_png=True` one PNG per slide lands in `figures/slides/`. `python -m pptxkit.win.build deck.ipynb --bars intro=1F77B4` does the same from the command line.

## Reconciling hand edits

Editing in PowerPoint is faster than editing a recipe for many things (moving a box, restyling, animating), so the two alternate: the author edits the built deck and saves it; a round then makes the recipe reproduce the edited deck. The project's round script:

```python
# reconcile.py
import subprocess, sys
from pptxkit.reconcile import Round
r = Round(deck='deck.pptx', build=lambda: subprocess.run([sys.executable, 'build.py'], check=True))
getattr(r, sys.argv[1])()      # start | diff
```

```
python reconcile.py start      # deck.pptx -> deck_modified.pptx, archived under rounds/<stamp>/,
                               # rebuild deck.pptx from the recipe, export PNGs of both, print the diff
python reconcile.py diff       # after editing the recipe: rebuild and diff again
```

`start` refuses to run while the deck is open in PowerPoint (the `~$deck.pptx` lock file). The diff (`pptxkit.inventory`) is keyed by slide name, so a reordered deck shows an order report rather than every slide changing; it lists per slide the shapes with their geometry, text per paragraph with run-level font overrides, table cells, picture identity and crop, animation targets, the hidden flag and the speaker notes. A slide that PowerPoint left without a name (cut and pasted, or copied) is matched to the recipe slides by a content signature and reported with the match confidence; notes that differ only by trailing whitespace are labelled rather than diffed, so nothing else hides behind that artefact; `--ignore-picture-hash` compares pictures by geometry and pixel size when a re-encode has changed their bytes.

Each hunk is routed by a fixed set of rules ([`docs/prompts/reconcile_round.md`](https://github.com/danjacobellis/pptxkit/blob/main/docs/prompts/reconcile_round.md)): text and notes to the cell; a moved picture to a `![](x.png) [l t w h]` geometry; anything else geometric or stylistic on one slide by promotion or extraction (below); a change meant for every slide of a layout to the template; an accidental nudge dropped and listed. `diff` is repeated until only intended differences remain.

Two further checks, because the inventory reads the XML and not what PowerPoint does with it:

```
python -m pptxkit.win.clicks deck.pptx deck_modified.pptx --all       # clicks per slide through a slide show, by position, plus headless target counts
python -m pptxkit.win.export deck_modified.pptx rounds/<stamp>/modified # slide PNGs (reconcile.py start already did this)
python -m pptxkit.visual rounds/<stamp>/modified figures/slides --above # slides whose final rendered state differs
```

Exit criteria for a round: an empty inventory diff apart from named artefacts, equal click and target counts on every slide, equal hidden flags, the same order, no unnamed slide, and no slide above the visual threshold. A final, exhaustive round (every slide, every property, a per-slide report) is `docs/prompts/final_reconcile.md`.

## Extraction and promotion

A slide made by hand enters the recipe in one of two ways.

Promotion copies it into the template as `static:<id>` and names its placeholders and pictures so the cell can still fill them (`title`, `body`, `ph2`..., `fig1`...); the cell header becomes `<!-- slide: <id> static -->` and later text edits keep flowing through the notebook:

```
python -m pptxkit.win.promote deck_modified.pptx <id or position> --as <id> --template template.pptx
```

Extraction takes the slide apart into `slides/<id>/` so its DrawingML, animation and notes are tracked as text and can be edited or animated by a generator (`Slide.load`); the cell header becomes `<!-- slide: <id> spec -->`:

```
python -m pptxkit.extract deck_modified.pptx <position> <id> --slides slides --readme "what the slide is"
```

Extraction is deterministic (a re-run rewrites byte-identical files) and reports every element it could not reproduce with `skip:`; a picture that came out of an earlier build is recognised and reuses its asset instead of being re-encoded under a new name. `extract` and `inject` are inverses at the level of canonical XML, which is what the tests check.

## Per-click previews

An animated slide is reviewed as a contact sheet with one frame per click state, built headlessly from its parts and rendered by PowerPoint:

```
python -m pptxkit.win.preview <id> [<id> ...] --slides slides --template template.pptx --out figures/preview
```

Media objects (audio, video) have no representation in the slide parts; a generator leaves a placeholder shape named `<id>:media:<key>` and records the file in `spec.json` under `media`, and after the build `python -m pptxkit.win.media deck.pptx --slides slides` inserts the objects at those places with the placeholder's animation; `--check` plays each one through a slide show and reports the player state.

## Drawing a slide

```python
from pptxkit.draw import Slide, Style
s = Slide('workflow', 'Workflow', slides_dir='slides', style=Style(font='Calibri'))
a = s.box(60, 150, 200, 80, text='Encoder')      # rounded rectangle with a theme style block
p = s.picture('latent.png', 300, 150, 120)        # picture; the file becomes a WebP asset
e = s.arrow(260, 190, 300, 190)                   # dashed grey connector
t = s.text(60, 260, 200, 40, 'a caption', size=14)
s.step(a); s.step(p, e); s.step(t)                # three clicks
s.write('Workflow drawing', notes=['what to say'])
```

Geometry is in points on a 960 x 540 slide. Fonts, colours and slide size come from `Style`; the defaults are Calibri, Cambria Math for symbols, Office accent blue. `Slide.load(id)` starts from an extracted slide (`ungroup`, `move`, `clone`, `crop`, `rows`, `split_lines`, `set_text`, `set_picture`, then `step`). `check_timing(slides/<id>)` verifies that a slide's `timing.xml` is reproduced byte for byte from its own step list, i.e. that its animation is in the vocabulary the builder writes.

## The prompt-file pattern

Large, detail-sensitive tasks (a reconcile round, the final exhaustive round) are done from a self-contained `prompts/<task>.md` in the project: a structured spec of the task pointing at all pre-staged resources, written first, reviewed, then executed by a fresh session. A prompt states the files, the procedure, the routing rules, the exit criteria, the report format, and lists the decisions it makes that the author did not make explicitly. Two generalised prompts are under [`docs/prompts/`](https://github.com/danjacobellis/pptxkit/tree/main/docs/prompts): [`reconcile_round.md`](https://github.com/danjacobellis/pptxkit/blob/main/docs/prompts/reconcile_round.md) (one round) and [`final_reconcile.md`](https://github.com/danjacobellis/pptxkit/blob/main/docs/prompts/final_reconcile.md) (the last round before a deck is archived as the reference). Every project-specific name in them is replaced by the configuration it reads (`Round.deck`, `BuildConfig.slides_dir`, the prefix).

## Tests and self-tests

`python -m pytest tests -q` is headless: it builds small decks with python-pptx inside the test (a title, a text box, a picture from a generated image, a reordered copy, a hidden slide, an unnamed slide) and checks the extract/inject round trip at the canonical-XML level, the inventory diff (reorder, hidden flag, a notes change, the trailing-whitespace label, the unnamed-slide signature match), the notebook grammar, the visual comparison, the title bar, and that the `Slide` builder writes a part that `inject` accepts and that extracts back unchanged. The synthetic decks are made by `pptxkit.testing`, which the self-tests share. One small real deck is committed as a fixture, `tests/fixtures/sample.pptx` (five slides PowerPoint itself wrote through `tests/fixtures/make_fixture.py`, run once on Windows): a three-click Appear sequence with PowerPoint's own `<p:timing>`, a hidden slide, speaker notes with a soft line break, a cropped picture and a group, and a pasted copy PowerPoint left without a name. `tests/test_fixture.py` reads it headlessly: the inventory, the unnamed copy matched to its source by signature, the hidden flag, extract, inject and re-extract of the animated slide at the canonical-XML level (its timing reproduced byte for byte from its own step list), and the notes soft break through extract and inject.

Each COM module has a `--selftest` that opens PowerPoint on a synthetic deck; pytest does not run them:

```
python -m pptxkit.win.export --selftest
python -m pptxkit.win.clicks --selftest
python -m pptxkit.win.promote --selftest
python -m pptxkit.win.preview --selftest
python -m pptxkit.win.media --selftest
python -m pptxkit.win.build --selftest
```

## State of the port (2026-09-19)

PyPI name check: `curl -s -o /dev/null -w "%{http_code}" https://pypi.org/pypi/pptxkit/json` returned `404` on 2026-09-19 (the name is free). Not published.

What moved, from the source project's `presentation/` directory to the package: `slidespec.py` -> `pptxkit.parts` (with the asset-reuse index), `slidekit.py` -> `pptxkit.draw` (fonts, colours, sizes and the assets directory are now `Style` / constructor parameters; the equation switch is `Style.equations`; the named-slide self-test became `check_timing`), `dump.py` -> `pptxkit.inventory` (with `targets()` and `hidden_flags()` from `clickcheck.py`), `pixdiff.py` -> `pptxkit.visual`, `clickcheck.py` -> `pptxkit.win.clicks`, `reconcile.py` -> `pptxkit.reconcile.Round` + `pptxkit.win.export`, `extract_seed.py` -> `pptxkit.extract`, `promote.py` -> `pptxkit.win.promote`, `preview_spec.py` -> `pptxkit.win.preview`, `titlebar.py` -> `pptxkit.titlebar` (plan keyed by colour, geometry and font in `BarStyle`, the per-project shape exclusion as `skip_suffixes`), `build.py` -> `pptxkit.notebook` (the cell grammar, `## Section:` headings) + `pptxkit.build` (`BuildConfig`, post-pass, bar plan) + `pptxkit.win.build` (the COM builder), `e2a2/embed_media.py` -> `pptxkit.win.media`.

Not yet moved, and why: `tags.py` (a table of one deck's chapters and colours; its role is `BuildConfig.bars`), every generator (`<section>/make_slides.py`, `intro/*.py`; one deck's content), `outline_variants.py`, `assemble.py`, `check_draft.py`, `verify_spec.py` and `render_seed.sh` (all written against one seed deck and its manifest), the `--batch` mode of `extract_seed.py` and its `seed/manifest.tsv` README lines (seed-manifest specific; `pptxkit.extract` takes one slide and a `--readme`), `e2a2/extract_notebook.py` (one deck's figures), the fixed slide-number box and placeholder name of the old build (now `BuildConfig.slide_number_box` and `BuildConfig.slide_number_name`, defaults: the layout's and PowerPoint's), and the old `reconcile.py` command-line entry point (`Round` is a class; a project supplies the five-line script above).

Install and test output on the first build (Windows, `~/g`, Python 3.14):

```
$ python -m build
Successfully built pptxkit-0.0.1.tar.gz and pptxkit-0.0.1-py3-none-any.whl
$ pip install --force-reinstall --no-deps dist/pptxkit-0.0.1-py3-none-any.whl
Successfully installed pptxkit-0.0.1
$ python -c "import pptxkit; print(pptxkit.__version__)"
0.0.1
$ python -m pytest tests -q
.............                                                            [100%]
13 passed in 1.54s
$ python -m pptxkit.win.export --selftest
export selftest: ['slide001_one.png', 'slide002_two.png', 'slide003_three.png'] -> ok
$ python -m pptxkit.win.clicks --selftest
clicks selftest: animated 2 clicks / 3 targets, plain 0 clicks -> ok
$ python -m pptxkit.win.promote --selftest
promote selftest: template slides ['static:one'], shapes ['slide:one:title', 'TextBox 2', 'slide:one:fig1'] -> ok
$ python -m pptxkit.win.preview --selftest
preview selftest: ['slide001_anim_0.png', 'slide002_anim_1.png', 'slide003_anim_2.png'], sheet True -> ok
$ python -m pptxkit.win.media --selftest
media selftest: media shape present True -> ok
$ python -m pptxkit.win.build --selftest
build selftest: ['slide:first', 'slide:extra', 'slide:drawn'], hidden [False, True, False], targets [0, 0, 2], notes 'Notes one \nsecond line' -> ok
```

## First user (2026-09-19)

The deck this tooling came from (a dissertation defense, `dissertation/presentation/`) builds with the package since 2026-09-19: its `presentation/*.py` tooling files are thin wrappers that keep their command lines and importable names and pass one configuration module (`deckconfig.py`: the shape-name prefix, the font, the section colours as `BuildConfig.bars`, the slide-number box, the paths, the media directory) into `BuildConfig`, `draw.Style`, `titlebar.BarStyle`, `reconcile.Round` and the `prefix` arguments. The wrappers pin nothing; the package is installed into the machine's venv from its built wheel (`python -m build`, then `pip install --force-reinstall --no-deps dist/pptxkit-0.0.1-py3-none-any.whl`), so a change here reaches the deck after a rebuild and reinstall, and anything the deck needs that the package lacks is added here as a parameter with a neutral default, never kept as a fork on the deck's side.

What the migration added to the package, because the deck's copies had it and the port had dropped or generalised it away: `parts.NATIVE_MAX_BYTES` (extraction keeps a raster of at most 256 KB byte for byte, so a re-extraction is lossless; `encode_asset(..., native_max_bytes=)`), while a generator's own pictures are always re-encoded to WebP (`draw.Slide.add_asset`), which is what the tracked recipe holds; the PANOSE rule of `draw` (the number is written for the Style's fonts in their own roles, the text font on text runs, the math font on equation runs, the bullet font on bullets, and for no font named on a single run); `notebook.parse_notebook(path, section_heading=)` and `BuildConfig.section_heading` (the deck's section cells say `## Quarter: <name>`); `BuildConfig.slide_number_name` (the deck renames the slide-number placeholder the build adds). Verified on the deck: every generator that owns tracked slides re-runs byte-identically, the reconcile diff against the presented deck is empty, click counts and animation targets agree on all 148 positions, the visual comparison flags the one known slide, and every media object plays.
