A pure nim reader for Apple Keynote, Pages, and Numbers documents
Open any iWork 2013+ document with openDocument(path) - the kind is
auto-detected (by extension, or by sniffing the content when there is
none) and every on-disk layout works: plain zip, zip with a nested
Index.zip, or a directory bundle.
From there:
- All the text, in reading order -
doc.getText()returns everything the document says, from any of the three formats: page headers and footers, body paragraphs, slide titles and text boxes, presenter notes, and table rows.doc.textBlocksgives the same thing as labeled pieces (tbHeader,tbBody,tbNotes, ...) tagged with the slide or sheet they came from - Raw storage text -
doc.plainText()dumps every text storage in object order instead, master slides and all, with attachment placeholders stripped and line breaks normalized - Keynote -
doc.slidesgives you each slide's title, body text boxes, presenter notes, and skip flag, in deck order - Numbers -
doc.sheets/doc.tablesdecode the binary cell storage into typed values (text, number, bool, date, duration, formula, error), withtoCsvfor quick export - Pages -
doc.bodyTextreturns the document body as paragraphs
And when the high-level api isn't enough, the layers it's built on are all exported too:
- container access: list
.iwaentries, read raw bytes, grab the metadata plist decodeIwafor the snappy chunk format,decodeMessagefor a generic protobuf wire decoder (no proto files, no codegen)- the full object graph:
buildIndexmaps every object by id, andderef/derefAllfollow references between them
Pre-2013 documents are detected and rejected with a clear
IworkUnsupportedError. Not done yet: formula ast decoding (formula
cells surface their cached display value) and rich text cells.
- v1.1.7 (2026-07-24) - first release: keynote slides, numbers tables
with typed cells, pages body text, plain text extraction for all three
formats, and the
iworkdumpdebug CLI
nimble install iworkOr straight from the repo:
nimble install https://github.com/alfredchiesa/nim-iworkThen in your project's .nimble file:
requires "iwork >= 1.1.7"The snippets below run as-is from a repo checkout - they use the real
documents in tests/fixtures/. Swap in your own paths if you wish.
import iwork
let doc = openDocument("tests/fixtures/simple.key")
echo doc.kind # dkKeynote (auto-detected)
echo doc.getText() # all text, in reading order
for blk in doc.textBlocks: # the same text, labeled
echo blk.section, " ", blk.kind, ": ", blk.text
for slide in doc.slides: # keynote only
echo slide.index, ": ", slide.title
for line in slide.body:
echo " ", line
if slide.presenterNotes.len > 0:
echo " notes: ", slide.presenterNotes
let book = openDocument("tests/fixtures/rich.numbers")
for sheet in book.sheets: # numbers only
for table in sheet.tables:
echo sheet.name, " / ", table.name
echo toCsv(table)
let report = openDocument("tests/fixtures/simple.pages")
for paragraph in report.bodyText: # pages only
echo paragraphor from the CLI via the bundled example:
nim c examples/extract_text.nim
examples/extract_text tests/fixtures/simple.pagestools/iworkdump.nim is a small debug CLI built on the library:
nim c tools/iworkdump.nim
# list iwa entries with compressed/decompressed sizes
tools/iworkdump ls tests/fixtures/simple.key
# write an entry's decompressed stream to stdout
tools/iworkdump cat tests/fixtures/simple.key Index/Document.iwa > document.bin
# list every object's id, registry type, and top-level field numbers
tools/iworkdump objects tests/fixtures/rich.numbers
# pretty-print one object's field tree as json
tools/iworkdump obj tests/fixtures/simple.key 1Everything from the iWork 2013+ format family, in any of its three
on-disk layouts (plain zip, zip with a nested Index.zip, or a directory
bundle):
| Format | Extensions | Supported | Notes |
|---|---|---|---|
| Keynote | .key |
yes | slides with titles, body text boxes, presenter notes, skip flag |
| Pages | .pages |
yes | body paragraphs and plain text |
| Numbers | .numbers |
yes | sheets, tables, typed cell values, csv export |
| iWork '09 and earlier | .key, .pages, .numbers |
no | pre-2013 XML format (index.xml / index.apxl) - detected and rejected with IworkUnsupportedError |
| Password-protected documents | any | no | encrypted containers can't be read |
| Writing / creating documents | any | no | this is a reader, and writing isn't planned |
Within supported documents, a few cell/content flavors are partial:
| Content | Status |
|---|---|
| text, numbers, currency, booleans, dates, durations | fully decoded |
| formula cells | cached display value; the formula itself shows as =? when no cached value exists |
| rich text cells | decode as empty text for now |
| images, movies, charts | skipped (text extraction only) |
The short version of what this library actually parses:
- an iwork document is a zip (or a directory bundle) holding
Index/*.iwafiles plus metadata and media - each
.iwais a sequence of chunks: a tiny 4-byte header, then a raw snappy block (no framing, no crc) - the decompressed stream is protobuf: repeated
[varint length, archive info, object payload]records, each carrying an object id and a per-application type number - objects reference each other by id, forming one big graph - document to show to slides in keynote, document to sheets to tables in numbers
- numbers cells are one more layer down: a custom binary record format packed inside the tile protobufs, with a flags word saying which fields are present
The type and field numbers aren't published by Apple - they come from the community reverse-engineering credited below, re-verified against real documents as this library was built.
The whole suite runs with one command:
nimble testTests live in tests/t*.nim and run against real documents in
tests/fixtures/, with expected outputs pinned in tests/golden/. To run
a single test file:
nim c -r tests/tnumbers.nim# regenerate docs locally and browse them
nimble docs && open htmldocs/index.html
# compile everything that should compile
for f in examples/*.nim tools/*.nim; do nim c --hints:off "$f"; doneReleases are automated: conventional commits on main feed
release-please, which
keeps a rolling release PR. Merging that PR bumps the version, updates
CHANGELOG.md, tags vX.Y.Z, and publishes a github release. Nimble
installs by git tag, so once the package is registered every merged
release PR is automatically live.
The registration itself is a one-time step:
-
create a github personal access token with
reposcope and export it asGITHUB_TOKEN(or let the prompt ask for it) -
run
nimble publishfrom the repo root - it forks nim-lang/packages, adds the iwork entry topackages.json, and opens the PR for you. prefer doing it by hand? fork nim-lang/packages and add:{ "name": "iwork", "url": "https://github.com/alfredchiesa/nim-iwork", "method": "git", "tags": ["iwork", "keynote", "pages", "numbers", "apple", "parser"], "description": "Pure Nim reader for Apple Keynote, Pages, and Numbers documents", "license": "MIT", "web": "https://github.com/alfredchiesa/nim-iwork" } -
once that PR merges,
nimble install iworkresolves to the latest git tag and the package shows up on nimble.directory with a link to the docs
See CONTRIBUTING.md for the commit-to-version-bump rules.
The iwork format is undocumented; this library stands on the reverse-engineering work of:
- keynote-parser - the reference for keynote type and field numbers
- numbers-parser - the reference for the numbers cell storage format
- iWorkFileFormat - the original deep dive into the container, snappy, and protobuf layers
Contributions, issues, and feature requests are all welcome! Found a bug or
have an idea? Open an issue.
PRs are appreciated too - for bigger changes, it's worth opening an issue first
so we can talk it through. Commit messages follow
Conventional Commits (feat:, fix:,
docs:, ...), since releases are cut automatically from them. See
CONTRIBUTING.md for the details.