pykeynote
Read, and soon write, Apple Keynote presentations from Python. pykeynote
works on .key files directly, so it runs anywhere Python does. You don't
need Keynote or a Mac.
A .key file is a zip of Snappy-compressed protobuf archives (Index/*.iwa)
plus images and media in Data/. Decoding comes from
keynote-parser, which supports
files saved by Keynote up to 14.5.
For JavaScript and TypeScript, the same library is in js/ as the
npm package @kevsmac/keynotejs.
Install
pip install pykeynote
For development, clone the repo and run pip install -e ".[dev]".
Reading a deck
from pykeynote import Presentation
deck = Presentation.open("talk.key")
print(deck.theme, deck.size, deck.layouts)
for slide in deck:
print(slide.index, slide.layout, slide.title)
for para in slide.paragraphs: # text, indent level, bulleted?
print(" " * para.level, para.text)
print(slide.notes, slide.transition.effect, slide.skipped)
print(deck.to_markdown())
title and body are None when the slide's layout hides that placeholder.
Making and changing decks
from pykeynote import Presentation, Paragraph, from_markdown
deck = Presentation.new() # Basic White, 16:9, no slides yet
# Presentation.new(aspect="4:3") gives Keynote's Standard 1024 x 768 size
deck.add_slide("Title", title="Robots!", body="Kevin McAleer")
slide = deck.add_slide(
"Title & Bullets",
title="Why robots?",
body=[Paragraph("They're fun"), Paragraph("Mostly", level=1), "They're useful"],
notes="Keep this one short.",
)
slide.set_transition("dissolve", 1.5)
deck.save("talk.key")
deck = Presentation.open("talk.key")
deck[1].title = "Why robots? 🤖"
deck.duplicate_slide(1)
deck.move_slide(2, 0)
deck.delete_slide(-1)
deck[0].skipped = True
deck.save() # back to talk.key
slide = deck[1]
slide.add_build("title", "move in") # one click
slide.add_build("body", "dissolve") # then one click per bullet
print(slide.builds)
deck = from_markdown(open("talk.md").read())
deck.save("from-markdown.key")
Builds, transitions and subtitles
deck = Presentation.new()
s = deck.add_slide("Title & Bullets", title="test", body=["some text", "more points", "last bits"])
s.add_subtitle("sub title") # in the theme's Subtitle style, under the title
s.set_transition("wipe", 0.75) # direction="from right" and so on
s.add_build("title", "wipe", direction="from left")
s.add_build("subtitle", "wipe", direction="from left", start="after previous", delay=0.1)
s.add_build("body", "fade and move", delivery="by bullet", duration=0.3)
deck.save("style-check.key")
Build effects include "dissolve", "move in", "wipe" and "fade and move" (also "fade-and-move"), or Keynote's own ids such as
"apple:wipe". start is "on click" (the default), "after previous"
or "with previous", as in Keynote's Build Order panel, and delay is the
seconds the build's first step waits. As Keynote does, each bullet of a
delivery="by bullet" build starts the same way as the first. direction is "from left", "from right", "from top" or
"from bottom" for effects that move (Wipe, Move In, Fade and Move), and
the same names work for transitions; asking for a direction on an effect
without one, such as Dissolve, raises ValueError. Each Build in
slide.builds has start, delay and direction, and
slide.transition.direction gives the transition's.
On the "Title" layouts the body placeholder ("Presentation Subtitle") is
the subtitle, so add_subtitle fills it. Basic White's "Title & Bullets"
and several other layouts come with an empty subtitle text box, which it
fills instead; on other layouts it adds one under the title.
slide.subtitle reads or sets it. In Markdown, a slide with just a #
title and a ## subtitle becomes a "Title" slide.
To give a whole deck one look, apply_style sets the transition and the
title, subtitle and body builds on every slide, leaving out a build where a
slide doesn't show that text:
deck.apply_style(
transition={"effect": "wipe", "duration": 0.75},
title_build={"effect": "wipe", "direction": "from left"},
subtitle_build={"effect": "wipe", "start": "after previous", "delay": 0.1},
body_build={"effect": "fade and move", "delivery": "by bullet", "duration": 0.3},
)
Images
deck = Presentation.new()
slide = deck.add_slide("Title & Photo", title="Meet the robot")
slide.add_image("robot.jpg", description="A small wheeled robot") # fills the photo placeholder
slide = deck.add_slide("Blank")
slide.add_image("logo.png", position=(1600, 40), size=(200, None)) # top right, 200 pt wide
for image in slide.images: # back to front
print(image.position, image.size, image.pixel_size, image.description, image.filename)
open(image.name, "wb").write(image.data)
slide.replace_image(0, "new-logo.png") # by index, Image, or placeholder tag such as "Media"
slide.remove_image(0)
add_image takes a path or bytes, as PNG, JPEG or GIF. With no position or
size it fills the slide's first media placeholder, cropping the picture to it
as Keynote does; otherwise the image goes on top, centred if no position is
given. A placeholder still showing the theme's sample photo has
is_sample == True and no data, because Keynote keeps those photos outside
the file. slide.placeholders lists the layout's placeholders in reading
order. Images can have builds too: slide.add_build(image.identifier, "dissolve").
Video and sound
slide = deck.add_slide("Title & Photo", title="It moves")
slide.add_movie("demo.mp4", description="The robot driving") # fills the photo placeholder
slide = deck.add_slide("Blank")
video = slide.add_movie("demo.mp4", position=(200, 200), size=(640, None), loop="repeat", poster="frame.png")
slide.add_audio("theme.m4a", volume=0.5)
slide.add_build(video, "dissolve")
for movie in slide.movies: # back to front
print(movie.audio_only, movie.duration, movie.position, movie.size, movie.filename)
slide.remove_movie(0) # by index, Movie, or placeholder tag
pykeynote takes MPEG-4 and QuickTime files (.mp4, .m4v, .mov,
.m4a), and reads their length and picture size itself. A video goes where
an image would, fitted inside a placeholder; a sound has no picture, so it
sits in the middle of the slide. pykeynote can't decode video, so the poster
(the frame shown before a video plays) is black unless you pass poster=.
add_slide copies a slide that already uses the layout, so it keeps the
theme's look. A deck from Presentation.new() has one of every layout to
copy from, kept out of sight and left out when you save. For a deck you
opened, at least one slide must already use the layout.
Shapes, tables and charts
from pykeynote import Presentation
deck = Presentation.new()
slide = deck.add_slide("Title Only", title="Results")
# Shapes and text boxes. Positions and sizes are in points from the top left.
box = slide.add_shape("rounded rectangle", x=100, y=300, width=500, height=300,
text="Hello", fill="#3366ff")
slide.add_shape("star", style=3) # one of the theme's six shape styles
slide.add_text_box("A note", x=100, y=700, width=800, height=60)
slide.add_build(box) # shapes can have builds too
# Tables: lists of text, numbers, True/False, or None for an empty cell.
table = slide.add_table(
[["Name", "Score"], ["Ada", 42], ["Bob", 3.5]],
x=700, y=300, header_rows=1, style=1,
)
table.set_cell(2, 1, 7)
print(table.rows, table.header_rows, table.column_widths)
# Charts: one list of values per row name.
slide = deck.add_slide("Blank")
chart = slide.add_chart("column", [[1, 2, 3], [4, 5, 6]],
row_names=["2024", "2025"], column_names=["Q1", "Q2", "Q3"])
slide.add_chart("pie", [[3, 5, 2]], ["Share"], ["A", "B", "C"], x=1100, y=200)
chart.set_data([[9, 8, 7]], ["2026"], ["Q1", "Q2", "Q3"])
chart.chart_type = "line"
for drawable in slide.drawables: # shapes, text boxes, tables and charts
print(drawable.to_dict())
deck.save("results.key")
Shape kinds are rectangle, rounded rectangle, oval, triangle, diamond,
pentagon, hexagon, octagon, star, arrow, double arrow and plus. Chart types
are column, bar, line, area, their stacked forms, pie, donut and scatter.
Each row of chart data is a series unless series_by="column", which is the
default for pies and donuts because Keynote draws a pie from the first value
of each series (so a pie shows only its first row). The chart's box includes
room for its legend, shown when there is more than one series, and its axis
labels; chart.position and chart.size are the plot inside it.
New tables and charts are copied from a small deck Keynote made, bundled
with pykeynote, and take the look of your deck's theme. slide.shapes,
slide.tables and slide.charts read what is already on a slide, Keynote's
own included; slide.delete_drawable(d) removes one, and duplicated slides
get their own copies.
Themes
pykeynote.themes() lists the bundled themes, each at 16:9 and 4:3. They are
Basic White, Basic Black, Basic Color, Classic White, Color Gradient,
Minimalist Light, Minimalist Dark and Briefing (Keynote spells two of them
"Colour"; use the US spelling here). Start a
deck in one, or switch an existing deck to another:
from pykeynote import Presentation, themes
print(themes()) # ['Basic Black', 'Basic White', ...]
deck = Presentation.new("Basic Black", aspect="4:3")
deck = Presentation.open("talk.key")
deck.apply_theme("Basic Black") # or: deck.theme = "Basic Black"
deck.apply_theme("Basic Black", layouts={"Quote": "Statement"})
deck.save("talk-dark.key")
apply_theme rebuilds each slide on the new theme's layout of the same
name, or the closest one that shows a title and body where the slide has
them (deck.match_layout(name, title=True, body=True) shows the choice).
Titles and bodies with their indent levels, subtitles, notes, transitions,
skipping and builds on titles, subtitles and bodies come along. Images, shapes, text boxes and
text formatting beyond the layout's own don't. The slide size stays 16:9 or
4:3 unless you pass aspect=.
The templates are made by Keynote 14.5 with scripts/make_templates.py.
Markdown
to_markdown() writes one block per slide, separated by ---. Slide
settings go in HTML comments, and presenter notes follow a ??? line:
<!-- layout: Title & Bullets -->
<!-- transition: wipe 0.75s from left -->
<!-- build: title, wipe, 1s, from left -->
<!-- build: subtitle, wipe, 1s, after previous, delay 0.1s -->
<!-- build: body, fade and move, by bullet, 0.3s -->
# Why robots?
## A subtitle
- They're fun
- Mostly
- They're useful
| Robot | Legs |
| --- | --- |
| Dog | 4 |
???
Keep this one short.
A ## heading straight after the # title is the slide's subtitle. A
build comment takes a target (title, subtitle or body) and an effect,
then in any order a delivery, a duration, in or out, a start (after previous, with previous), a delay (delay 0.1s) and a direction (from left). A transition comment can end with a direction too.
Tables are pipe tables both ways; the rows above the | --- | line are
header rows. A slide with a title and a table but no bullets gets the
"Title Only" layout.
Images use Markdown's own syntax. One without a title fills the layout's
next photo placeholder; a title of "x,y widthxheight" puts it at that spot
instead. A slide with images and no layout comment gets a photo layout,
and from_markdown removes any placeholder left without a picture.
to_markdown(image_dir="talk-images") saves the pictures and links them
from there; image paths in from_markdown are relative to base_dir.
# Meet the robot



Videos and sounds use the image syntax too, picked out by their extension.
Command line
Installing pykeynote adds a pykeynote command. Add --json to any
command to get machine-readable output for scripts and LLM agents.
pykeynote new talk.key -m talk.md # Markdown to .key (-m - reads stdin)
pykeynote info talk.key # theme, size, layouts, slide list
pykeynote show talk.key 1 # one slide: text, notes, transition, builds
pykeynote export talk.key -o talk.md # .key to Markdown
pykeynote edit talk.key 1 --title "Why robots?" --body "They're fun" --body " Mostly" --notes "Short"
pykeynote edit talk.key 1 --transition dissolve --duration 1.5 --skip no
pykeynote edit talk.key 1 --transition wipe --duration 0.75 --direction from-left --subtitle "sub title"
pykeynote add talk.key --layout "Title & Bullets" --title "One more" --at 2
pykeynote duplicate talk.key 1
pykeynote move talk.key 3 0
pykeynote delete talk.key -1
pykeynote build talk.key 1 --target body --effect dissolve
pykeynote build talk.key 1 --target subtitle --effect wipe --start after-previous --delay 0.1 --direction from-left
pykeynote theme talk.key "Basic Black" --layout Quote=Statement
pykeynote themes
pykeynote image talk.key 2 robot.png --alt "A robot arm" # fills the media placeholder if there is one
pykeynote image talk.key 2 arm.png --x 100 --y 200 --width 600
pykeynote image talk.key 2 new.png --replace Media # or an image id from 'show'
pykeynote movie talk.key 2 demo.mp4 --loop repeat # a video or sound (.mp4, .m4v, .mov, .m4a)
pykeynote shape talk.key 1 --kind star --text "New!" --fill "#ff3366" --x 1500 --y 100 --width 300 --height 300
pykeynote textbox talk.key 1 "Source: our survey" --x 100 --y 980
pykeynote table talk.key 3 --csv scores.csv # or --row Name,Score --row Ada,3
pykeynote chart talk.key 4 column --csv sales.csv # column names in row 1, row names in column 1
pykeynote build talk.key 4 --target 2655081 # build an object in, by its id from 'show'
pykeynote remove talk.key 4 2655081 # delete a shape, table, chart, image or movie
Slide indexes start at 0, and negative ones count from the end. show
lists each shape, table, chart, image, video and sound with an id, which build --target
and remove take. Positions and sizes are in points from the slide's top
left; anything placed without --x and --y is centred. Each
--body is one paragraph; two leading spaces indent it one level. Editing
commands save the deck in place unless you pass -o other.key. pykeynote
only writes .key files.
Using it from Claude (MCP)
pykeynote mcp runs a Model Context Protocol
server over stdio, so Claude and other MCP clients can read and edit decks.
It has no extra dependencies, and needs a pykeynote release newer than 0.2.0.
Its tools are list_themes, deck_info,
read_slide, export_markdown, create_deck, edit_slide, add_slide,
delete_slide, move_slide, duplicate_slide, apply_theme, add_build,
apply_style, clear_builds, add_image, replace_image, remove_image, add_movie, remove_movie, add_shape,
add_text_box, add_table, add_chart and delete_drawable. Editing tools save the deck when they finish, or write a copy
when given output. add_build takes start, delay and direction, and
edit_slide takes subtitle and transition_direction.
For Claude Code:
claude mcp add keynote -- uvx --from pykeynote pykeynote mcp
# or, with pykeynote installed already:
claude mcp add keynote -- pykeynote mcp
For Claude Desktop, add this to claude_desktop_config.json
(Settings > Developer > Edit Config) and restart it:
{
"mcpServers": {
"keynote": {
"command": "uvx",
"args": ["--from", "pykeynote", "pykeynote", "mcp"]
}
}
}
If you installed with pip instead of using uvx, set "command" to the full
path that which pykeynote prints and "args" to ["mcp"]. Give Claude
absolute paths to decks, since the server's working directory isn't yours.
Then ask something like "Make a five-slide deck about robot arms in
~/Desktop/arms.key" or "Tighten the bullets on slide 3 of ~/Desktop/talk.key".
Roadmap
- Read decks and export Markdown. Done.
- Edit text and notes, then save. Done.
- Add, duplicate, delete and reorder slides, set transitions and skipping, and build decks from Markdown. Done.
- Build animations on titles and bodies. Done.
- More bundled themes and switching a deck's theme. Done.
- A command line tool and an MCP server for LLMs. Done.
- Images: read, add, fill placeholders, replace and remove, with Markdown support. Done. Images, shapes, tables and charts are in the CLI and MCP tools too.
- Shapes, text boxes, tables and charts, with builds on them and tables in Markdown. Done.
- Video and sound, with Markdown, CLI and MCP support. Done.
Tests
pytest # everything that runs without Keynote
PYKEYNOTE_INTEGRATION=1 pytest # also open the results in real Keynote (macOS)
The integration tests drive Keynote 14.5 by bundle id. Set
PYKEYNOTE_KEYNOTE_ID=com.apple.Keynote to use Keynote Creator Studio.
The sample decks in tests/fixtures/ were made in Keynote 14.5.
Metadata
Release files for pykeynote 0.5.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| pykeynote-0.5.0.tar.gz | 8.7 MB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pykeynote-0.5.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 13.7 MB
Release files / pykeynote-0.5.0.tar.gz
| Download URL | pykeynote-0.5.0.tar.gz |
|---|---|
| Size | 8.7 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ff3ea746926f839934184b79984b506ce4a060db82f8c5589377a728dc05ec73
|
|
BLAKE2b-256 checksum How to use checksums |
52f56b1ff85af852ebbeccb04ac1fa60ec6779816921d82cf0b2e8e68381c976
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 24, 2026.
Transparency logRelease files / pykeynote-0.5.0-py3-none-any.whl
| Download URL | pykeynote-0.5.0-py3-none-any.whl |
|---|---|
| Size | 4.9 MB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
bb59aff7ee0b3de07d5511e060e5fa3b27b5089402623ada743d74492caa4b49
|
|
BLAKE2b-256 checksum How to use checksums |
08abdca6f8a09dda190000fd435827bdb9bcae4b5945d60e5f7bab6a2559b3bb
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 24, 2026.
Transparency logSecurity for pykeynote 0.5.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for pykeynote-0.5.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 24, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.