Skip to content

feat(ppt-codec): read PowerPoint 97-2003 binary presentations ([MS-PPT]) - #846

Merged
Mearman merged 13 commits into
mainfrom
worktree-agent-a87dfc8a46228e4bc
Sep 3, 2026
Merged

feat(ppt-codec): read PowerPoint 97-2003 binary presentations ([MS-PPT])#846
Mearman merged 13 commits into
mainfrom
worktree-agent-a87dfc8a46228e4bc

Conversation

@Mearman

@Mearman Mearman commented Sep 3, 2026

Copy link
Copy Markdown
Member

Adds packages/ppt-codec, a hand-written reader for the PowerPoint 97-2003 binary format ([MS-PPT]), producing the same document-schema.js presentation content model ooxml.js's pptx support and odf.js's odp support already target. Part of #817 (and #85) — not the full read+write scope that issue asks for, so no closing keyword; see the honest scope below.

Why the format needs this much machinery

Unlike Word's and Excel's binary formats, a .ppt file's content is a tree of records inside an [MS-CFB] compound file, and the tree isn't the whole story:

  • Every record — [MS-PPT]'s and the [MS-ODRAW] drawing records nested inside them — shares one 8-byte header, with recVer == 0xF marking a container and anything else an atom. That single field is what makes it a tree, and what lets an unknown record be skipped.
  • The PowerPoint Document stream is append-only across saves, so it can hold several generations of the same slide. Which copy is live is decided by the Current User stream's offsetToCurrentEdit, the UserEditAtom chain it starts, and the persist directory those edits accumulate — later entries superseding earlier ones. Scanning the stream for RT_Slide records would find superseded slides with no way to tell them apart.
  • A placeholder's text isn't on the slide at all. A title or body shape's OfficeArtClientTextbox holds an OutlineTextRefAtom indexing into the text records the document's slide list carries. Only a plain text box stores its own.

What's actually implemented (read only)

Layer Covered
Container Current User + PowerPoint Document streams via archive-codec's [MS-CFB] reader
Framing RecordHeader, container/atom split, sibling sequences, child walks, descendant search
Edit resolution CurrentUserAtom (incl. the encrypted headerToken), the UserEditAtom chain, PersistDirectoryAtom's packed 20/12-bit run form, and the oldest-first directory build — [MS-PPT] 2.1.2's "live record" process, Part 1
Document DocumentAtom (slide size), the font collection's typeface names, SlideListWithTextContainer (told apart from the master/notes lists by recInstance, whose values don't run in the order the names suggest)
Slides SlidePersistAtom → persist directory → SlideContainer, plus the placeholder texts
Drawing The OfficeArtSpgrContainer/OfficeArtSpContainer tree, OfficeArtFSP flags, OfficeArtClientAnchor in both its 8- and 16-byte spellings, and OfficeArtChildAnchor mapped through nested OfficeArtFSPGR group coordinate systems
Text TextHeaderAtom, TextCharsAtom/TextBytesAtom, OutlineTextRefAtom indirection, paragraph split on the stored \r
Formatting StyleTextPropAtom's TextPFRun/TextCFRun arrays: indent level, alignment, bold/italic/underline/shadow/emboss, typeface reference, size, literal sRGB colour

Public surface: readPpt (→ DocumentTree), readPptContent (→ flat { metadata, slides }), and readPptStreams for a caller that already holds the two streams.

Known gaps — deliberately not in this PR

  • No write path at all. This reads .ppt; it does not produce it. That's the half of Add .ppt (PowerPoint Binary File Format) read+write support #817 still open.
  • Encrypted documents are recognised and refused by name (PptEncryptedError), not decrypted.
  • Speaker notes — every slide's notes is ""; the notes list isn't walked yet.
  • Document metadata — always {}; properties live in the compound file's SummaryInformation stream ([MS-OSHARED]), not in any [MS-PPT] record.
  • Master/layout style inheritance — an unstated run property is reported absent rather than resolved against TextMasterStyleAtom.
  • Scheme colours — a ColorIndexStruct naming a scheme slot yields no colour (resolving it needs the slide's colour scheme).
  • Per-shape text insets — PowerPoint's own defaults are used; the OfficeArtFOPT override table isn't read.
  • Images, tables, OLE embeddings — read as a shape with geometry and no blocks; ExObjListContainer isn't walked.
  • Hyperlinks, bullets, spacing/margins — parsed past correctly, not surfaced.
  • Animations, transitions, comments, headers/footers, metacharacter atoms.
  • Not wired into documents.js's conversion registry. The codec is standalone. Wiring it in adds a dependency edge at the top of the graph for a package still under active development, so it's deliberately a separate step.

The package README states all of this in the same detail, under the family's "Status: Under active development" convention.

Testing

116 unit tests, 3 workerd tests, 37 smoke tests. There is no .ppt file anywhere in the fixtures — every one is assembled byte by byte from [MS-PPT]'s own field-layout tables, including a whole synthetic presentation and a minimal [MS-CFB] writer, so the end-to-end suite exercises the real offset arithmetic (persist directory, edit chain, cross-stream references) rather than a stub. A fixture built from the spec states what the parser is held to; a captured file would only state what one producer happened to emit.

Worker-isomorphic per the family convention (isomorphic: true, no node:*, no Buffer), proven at runtime by the workerd suite rather than asserted.

Every field layout is cited in the source at the point it's used; the README lists the load-bearing specification pages.

@Mearman

Mearman commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

One thing that needs a person before this package can first release, per CONTRIBUTING's "Adding a package to the workspace" checklist step 6: ppt-codec's npm trusted publisher has to be registered against this repository and ci.yml on npmjs.com. There's no local signal when it's missing — OIDC publishing just fails with ENONPMTOKEN, and because packages release in one topological pass that aborts on the first failure, a misconfigured package early in that order blocks every other package's release too, not only its own.

@Mearman
Mearman force-pushed the worktree-agent-a87dfc8a46228e4bc branch from bc37c9b to 338c6e6 Compare September 3, 2026 04:57
@Mearman
Mearman marked this pull request as ready for review September 3, 2026 05:00
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
🔒 Security Review ⚠️ Failed 2026-09-03T05:11:21.702245Z 5ef21b2 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@Mearman

Mearman commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

Confirmed, and it's not just ppt-codec — every new package from this legacy-format batch (doc-codec, wpd-codec, xls-codec, ppt-codec, rtf-codec) needs its own trusted-publisher registration before its first release, same as CONTRIBUTING step 6 describes. Tracking all five in one place: #850.

@Mearman

Mearman commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@Mearman

Mearman commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

OIDC trusted publishing is now configured for all five packages (per #850, closed).

…header

A .ppt file's content is a tree of records rather than a flat stream,
and one field carries the distinction: recVer 0xF marks a container whose
data is more records, anything else an atom whose data is fields.
Both spellings share the same 8-byte header, and so do the [MS-ODRAW]
drawing records nested inside them, which is what lets a single walk cross
the boundary between the two specifications.

Records are located by their offset within the stream they came from,
not by a copy of their bytes, because every cross-reference in the format
is a stream offset: a persist directory entry, a UserEditAtom's
offsetLastEdit, and a CurrentUserAtom's offsetToCurrentEdit all name a
position in the PowerPoint Document stream, and a reader that had already
sliced its records apart could no longer honour them.
…tory

A .ppt file's PowerPoint Document stream is append-only across saves, so it
can hold several generations of the same slide with nothing in a record
itself saying which copy is current. The Current User stream's
offsetToCurrentEdit names the newest UserEditAtom, that atom's chain walks
back through every earlier edit, and each edit's PersistDirectoryAtom
contributes persist-identifier-to-offset pairs.

The directories are applied oldest first, so a newer edit's entry replaces
an older one's for the same identifier. Applying them in the order they are
found -- newest first -- would invert that and resurrect superseded objects.

The chain needs no visited set to terminate: the spec requires each
offsetLastEdit to be strictly less than its own atom's offset, so enforcing
that makes every step move towards the start of the stream.
…tting

A shape's text is one flat character array, stored either as UTF-16
(TextCharsAtom) or as one byte per character (TextBytesAtom), with a
carriage return separating paragraphs inside it. The body's final paragraph
mark is never written into the atom but is still counted by every
formatting run, so the character count a StyleTextPropAtom is parsed
against is one more than the stored text's length.

StyleTextPropAtom's two run arrays carry paragraph and character
properties, each as a mask word followed by only the fields its bits
select. Those fields are read in the specification's declared field order
rather than its mask-bit order: the two genuinely differ (bulletChar is bit
7 but is emitted before bulletFontRef at bit 4, and textAlignment at bit 11
before leftMargin at bit 8), so iterating the bits numerically would
misalign every field after the first divergence.

A property whose mask bit is clear stays undefined rather than becoming
false, because an unstated property inherits from the master's text style
rather than defaulting off.
…e systems

A slide's DrawingContainer holds an [MS-ODRAW] shape tree whose group
containers nest to arbitrary depth. A top-level shape's
OfficeArtClientAnchor is already in slide coordinates, but a grouped
shape's OfficeArtChildAnchor is stated in the private coordinate system its
group's OfficeArtFSPGR declares, so its rectangle only means anything once
every enclosing group's transform has been composed onto it.

The patriarch -- the outermost group of every drawing -- is the exception
the format's own structure creates rather than an assumption: it declares a
degenerate coordinate system and no anchor, because its children are
already in slide coordinates.

The client anchor's payload is picked by its own recLen: 0x8 is a
SmallRectStruct of 16-bit coordinates, 0x10 a RectStruct of 32-bit ones.
Both order their fields top, left, right, bottom, not the left-first order
the names suggest, while OfficeArtChildAnchor and OfficeArtFSPGR order
theirs xLeft, yTop, xRight, yBottom -- reading either with the other's
order silently transposes the rectangle.
…onts

DocumentAtom carries the slide size in master units, and the environment's
font collection carries the typeface names a character run's FontIndexRef
indexes positionally.

SlideListWithTextContainer carries both each slide's persist reference and,
for a placeholder shape, that shape's text: a title or body's characters
are genuinely absent from the slide's own drawing, so a reader that only
walked slide drawings would report those shapes as empty. Its grammar is
positional rather than nested -- a SlidePersistAtom opens a slide and a
TextHeaderAtom opens a text, each owning every record until the next opener
-- so it is read in a single pass with two current-item cursors.

The master, slide and notes lists all carry RT_SlideListWithText and differ
only by recInstance, whose values do not run in the order the names suggest
(the slide list is 0x000 and the master list 0x001), so each is taken from
its own specification page rather than inferred from the trio.
… model

readPpt and readPptContent take a file's bytes through archive-codec's
compound-file reader, the persist directory, and the slide walk, producing
the same ContentSlide/ContentShape/ContentParagraph/ContentRun vocabulary
ooxml.js's pptx reader and odf.js's odp reader produce -- so a .ppt reaches
every consumer of that schema without a second representation of a slide
existing anywhere. readPptStreams is the same read one level down, for a
caller already holding the two streams, which is what lets every
record-level behaviour be tested without a container around it.

PowerPoint and the shared schema disagree structurally: PowerPoint stores a
shape's text as one flat character array with formatting expressed as
character-counted runs over it, while the schema stores paragraphs each
owning their own runs. Turning one into the other is an intersection of two
independent partitions of the same character range -- paragraphs by
separator, formatting by run count.

Geometry converts from master units (1/576 inch) to points on the way out,
so slide sizes and shape frames are in the unit the schema uses everywhere
else.

The end-to-end suite builds a whole synthetic presentation and a real
compound file from the specification's own field tables, so it exercises
the actual offset arithmetic rather than a stubbed one; the workerd suite
runs the same read inside a Cloudflare Workers isolate, which is what turns
the isomorphism claim into a runtime-checked fact.
The package's README lists what each layer reads and, exhaustively, what it
does not: no write path, no speaker notes, no document metadata, no master
or layout style inheritance, no scheme colours, no per-shape text insets,
no images, tables or OLE embeddings, and no decryption. Both lists are
exhaustive rather than illustrative, so a caller can tell from that page
alone whether the construct it needs is covered.
Adds the package to the root README's codec table and its Worker-isomorphic
list, and to pnpm-workspace.yaml's minimumReleaseAgeExclude, without which
pnpm install --frozen-lockfile can reject the package's own same-day
release the moment a sibling bumps to depend on it.
Symlinks rather than copies, matching every sibling package: one file states
the package's architecture and scope, and the agent-facing names resolve to it
rather than drifting from it.
Every package states this in its own .gitignore rather than the root's, so a
package that has not made the decision is never silently covered by someone
else's.
[MS-ODRAW] 2.2.14 makes shapeProp a required field of every
OfficeArtSpContainer, so a container without a readable OfficeArtFSP is
malformed rather than a shape whose flags happen to be unknown. Reading the
spid and the flags word separately let one of them answer 0 for a record
the other would reject outright; reading them as one pair means the
malformed case fails once, loudly, with a message naming what is missing.
Comparing the produced slides to a plain object proves the reader built
what the suite expected; parsing them with ContentDocumentSchema and
DocumentTreeSchema proves it built what document-schema.js actually
requires. A missing ContentShape inset, or a slide without its required
notes field, would satisfy the first check and fail this one.
@Mearman
Mearman force-pushed the worktree-agent-a87dfc8a46228e4bc branch from 5ef21b2 to be1839b Compare September 3, 2026 05:33
@Mearman
Mearman merged commit 9144d64 into main Sep 3, 2026
16 checks passed
@Mearman
Mearman deleted the worktree-agent-a87dfc8a46228e4bc branch September 3, 2026 05:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant