A PPTX is a zip of XML, not a picture
Rename a .pptx to .zip and it opens like any Office file: one part per slide, one per layout, and two that matter — ppt/presentation.xml, which lists slides in presentation order, and ppt/slides/slideN.xml, which holds the text runs. No rule in the format forces a title to be a title; the authoring app decides that when it writes the file.
deck.pptx
├── [Content_Types].xml
├── ppt/
│ ├── presentation.xml # sldIdLst — the slide order
│ ├── slides/slide1.xml … slide4.xml # one XML part per slide
│ ├── notesSlides/notesSlide2.xml # speaker notes, pulled in by the slide
│ ├── charts/chart1.xml # the numbers live here, not on the slide
│ └── media/image1.png # pictures, renamed on import
└── docProps/core.xml # title, author, dates
Because the text really is text in XML, the conversion is a parse, not an OCR guess — which is why a four-slide deck returns in about a tenth of a second.
unzip -l deck.pptx | grep -E 'slides/|notes' tells you how many slides there are and how many of them carry notes before you upload anything.What LlmFiles returns
Measured on a four-slide test deck — a title slide, bullets with sub-bullets plus speaker notes, a 4×3 table and two text boxes with mixed emphasis — 36 KB in, 640 characters out across 27 lines. The output, verbatim:
<!-- Slide number: 1 -->
# Q3 Platform Review
Engineering · October 2026
<!-- Slide number: 2 -->
# What shipped
Batch queue
up to 20 files per request
progress per file
Recent conversions in the browser
10 entries, localStorage only
### Notes:
Speaker note: mention the 25 MB free-tier limit.
<!-- Slide number: 3 -->
# Numbers
| Metric | Q2 | Q3 |
| --- | --- | --- |
| Conversions | 1,204 | 3,981 |
| p95 latency | 1.8 s | 1.1 s |
| Error rate | 0.9% | 0.3% |
<!-- Slide number: 4 -->
# Risks
Vendor lock-in: the old pipeline depends on a cloud OCR API
Free tier abuse: 12 of 18 visitors hit the limit
Three things to notice. Every slide opens with an HTML comment carrying its number, so the file can be split later without guessing where slides begin. The slide title is a real # heading — but there are four of them in a four-slide file, so the document never has a single top-level title. And the bullets arrive as bare lines: no -, no indentation, nothing that says which line belongs under which.
The mapping, measured element by element
Every row here was checked against real output, not copied from documentation:
| In the deck | In the Markdown | Note |
|---|---|---|
| Slide title placeholder | # heading | One per slide, so a deck has no single document title |
| Body placeholder paragraphs | plain lines, one per paragraph | - markers and indentation are dropped |
| Sub-bullets at level 1 and 2 | plain lines, identical to level 0 | The hierarchy is lost — see the next section |
| Speaker notes | ### Notes: then the text | Only on slides that have notes; no empty heading otherwise |
| Native chart | ### Chart plus a pipe table | One row per category, one column per series |
| Table shape | pipe table with row 0 as header | Row 0 becomes the header whether or not it is one |
| Merged cells | text joined by a space, covered cell empty | Measured: | 12 48 | | 9 | |
| Picture |  | Alt is the filename, target is an internal part name |
| Text box, autoshape | plain paragraph lines | File order, not necessarily the visual order |
| Hyperlink inside a run | the text only | The URL is dropped entirely |
| Bold, italic, underline | dropped | Formatting inside a paragraph does not survive |
| Bullet glyphs, numbering, colour, theme | nothing | Presentation, and presentation is discarded |
| Empty slide | the slide comment and nothing else | Kept so numbering stays true |
docProps/core.xml metadata | not read | Title and author never reach the output |
UTF-8 text, &, curly quotes | preserved exactly | Öffnen, naïve, — all intact |
Bullet levels are the main casualty
This is the honest weak point. The converter writes one line per paragraph and ignores the level attribute, so a three-level checklist arrives completely flat:
# Checklist
Pre-flight
curl -I /health
expect 200
curl -I /pricing
expect 200
smoke on mobile viewport
Nothing in that text says that expect 200 belongs to curl -I /health. For a person skimming a summary it is tolerable. For a retrieval pipeline it needs a decision: a chunker that splits on headings will keep the parent and its children together, which is usually what you want, but any downstream step that needs the tree cannot get it from the Markdown.
When the hierarchy is load-bearing, keep python-pptx in the pipeline for that step — paragraph.level is right there — and use the Markdown as the human-readable view of the same deck:
from pptx import Presentation
prs = Presentation("deck.pptx")
for i, slide in enumerate(prs.slides, 1):
title = slide.shapes.title
print(f"\n## {title.text if title else f'Slide {i}'}")
for shape in slide.shapes:
if shape == title or not shape.has_text_frame:
continue
for para in shape.text_frame.paragraphs:
print(" " * para.level + "- " + para.text)
Speaker notes survive, and that is rare
Notes are a separate part of the package, which is why most converters ignore them without saying so. Here they come out under a ### Notes: heading at the end of the slide they belong to. In a real deck that block is often the most useful text on the page: the caveat the presenter said out loud, the link nobody put on the slide, the number that did not fit.
The heading is a genuine ###, so a heading-based chunker keeps the notes attached to their own slide instead of pooling them at the end of the document.
Charts come out as tables
A chart built in PowerPoint is a data part, not a picture, and it is read:
# Latency by month
### Chart
| Category | p95, ms |
|---|---|
| Jul | 1800.0 |
| Aug | 1400.0 |
| Sep | 1100.0 |
Values keep their raw float form — 1800.0, not 1,800 — and axis titles, number formats and anything in the legend beyond the series name are lost. A chart pasted as a screenshot has no data part, so you get the image line and no numbers.
Images, links and formatting
A picture becomes a Markdown image whose alt text is the original filename and whose target is the internal part name, . Nothing serves that path — the media file stays inside the package and is not extracted.
Hyperlinks are dropped: a run pointing at a documentation URL arrived as plain text with no URL anywhere in the output. Bold and italic collapse into plain text. Merged cells collapse into one cell. If any of that matters, the .pptx stays the source of truth and the Markdown is a readable copy.
Legacy .ppt and .pptm
Dispatch is by extension, and two PowerPoint extensions do not take the presentation path. A .pptm — a deck with macros, same zip container as .pptx — and a legacy binary .ppt both fall back to dumping raw bytes in a code block. One of them is a one-line fix: renaming .pptm to .pptx converts correctly, verified on a real file. For .ppt there is no shortcut; re-save it first:
soffice --convert-to pptx old.ppt
Convert a deck
- Open the converter and drop the
.pptxin — up to 25 MB on the free plan. - Check the preview: headings, slide comments, notes.
- Download the
.md, or copy the text. - Several decks at once: put them in one
.zipand each file inside comes back as its own.md, or post to the batch endpoint described in the API reference.
Splitting a deck into one file per slide
A 40-slide deck measured 108 KB in and produced 2,440 words in a single file — pleasant to read, awkward as a corpus where you want one idea per chunk. The slide comments make the split mechanical:
import re, pathlib
text = pathlib.Path("deck.md").read_text()
parts = re.split(r"<!-- Slide number: (\d+) -->\n", text)
# ['', '1', 'slide 1', '2', 'slide 2', ...]
for num, body in zip(parts[1::2], parts[2::2]):
body = body.strip()
if body:
pathlib.Path(f"slide-{int(num):02d}.md").write_text(body + "\n")
Each file then starts with its own # heading — exactly the boundary a retrieval chunker wants. For chapter-sized chunks instead, group the slides by their comment numbers and concatenate.
Limits, and what it costs
Free: 5 files a day from one IP address, up to 25 MB each, no sign-up. Pro is not on sale yet — the plan and its limits are defined, the buy button is not live. If you need decks above 25 MB or automation at volume, leave a request on the pricing page and you will hear when it opens.
For pipelines: POST /api/convert for one file, /api/convert/batch for up to 20 in a single request, /api/convert/zip for an archive. Fields and error codes are documented on the API page. Parsing happens on this server with a local library; the deck is not handed to a third-party conversion API.