# ppt-codec

[![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/documents.js/tree/main/packages/ppt-codec) [![npm](https://img.shields.io/badge/npm-CB3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/ppt-codec) [![npm version](https://img.shields.io/npm/v/ppt-codec)](https://www.npmjs.com/package/ppt-codec) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/documents.js/ci.yml?branch=main)](https://github.com/ExaDev/documents.js/actions)

> A hand-written reader and writer for the PowerPoint 97-2003 binary file format (`.ppt`, [MS-PPT]), producing and consuming the same `document-schema.js` presentation content model `ooxml.js`'s pptx support and `odf.js`'s odp support both target. Worker-isomorphic: the same code runs under Node and inside a Cloudflare Workers isolate.

Created for [documents.js#817](https://github.com/ExaDev/documents.js/issues/817), part of the legacy-binary-formats epic [#85](https://github.com/ExaDev/documents.js/issues/85). Nothing in the ecosystem read a pre-2007 PowerPoint file: `ooxml.js` reads the XML-based pptx that replaced it, and the two formats share no structure at all beyond both being containers.

## Status

**Under active development. The read path for slide text, geometry, pictures, tables, rotation and speaker notes is built and tested. A narrower write path now exists too: one slide per input slide, with text-box, picture, and table shapes (basic character formatting, rotation, no layouts) plus their speaker notes — genuinely conformant [MS-PPT], verified by writing then reading every fixture back through this package's own reader, and, for speaker notes, against LibreOffice in both directions, but not full read/write parity.** What that means concretely is set out in [What it reads](#what-it-reads)/[What it does not read yet](#what-it-does-not-read-yet) and [What it writes](#what-it-writes)/[What it does not write yet](#what-it-does-not-write-yet) below — every one of those four lists is exhaustive rather than illustrative, so a caller can tell from this page alone whether the format's own feature it cares about is covered.

## Why the format is shaped the way it is

Unlike Word's and Excel's binary formats, whose content is a flat stream of records, a `.ppt` file's content is a **tree** of records, and the tree is not even the whole story:

- The file is an [MS-CFB] compound file, whose `PowerPoint Document` stream holds the records and whose `Current User` stream holds a single atom pointing into it.
- Every record — [MS-PPT]'s own and the [MS-ODRAW] drawing records nested inside it — carries the identical 8-byte header: a 16-bit word packing `recVer` (4 bits) and `recInstance` (12 bits), then `recType` and `recLen`. `recVer == 0xF` marks a container, whose data is more records; anything else marks an atom, whose data is fields. That one distinction is what makes the format a tree rather than a stream, and it is also what lets an unknown record be skipped by seeking `recLen` bytes past its header.
- The stream is **append-only across edits**. Saving a presentation can append a new _user edit_ rather than rewriting the file, so the same stream 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 build up — a later edit's directory entry supersedes an earlier one's for the same persist identifier. A reader that simply scanned the stream for `RT_Slide` records would find superseded slides and have no way to tell them from live ones.
- Slide **placeholder** text is not stored on the slide. A title or body shape's `OfficeArtClientTextbox` holds an `OutlineTextRefAtom` — an index into the text records the _document's_ slide list carries for that slide. Only a plain text box stores its own text.

## Getting started

Requires Node.js `>=20` and pnpm `11.6.0`.

```sh
pnpm install
pnpm build          # tsdown -> dist/ (ESM + CJS + .d.ts, one file set per src module)
pnpm typecheck      # tsc -p tsconfig.json && tsc -p tsconfig.node.json, then attw --pack
pnpm lint           # eslint . --fix --cache --max-warnings 0
pnpm test           # vitest run --project unit
pnpm test:watch     # vitest --project unit
pnpm test:workers   # vitest run --config vitest.workers.config.ts, inside a real Cloudflare Workers (workerd) isolate
pnpm test:smoke     # builds dist/, then loads the built ESM and CJS barrels and every advertised deep import
```

To run a single test file, pass its path to vitest directly, e.g. `pnpm exec vitest run src/text/style.test.ts`.

## Reading a document

```ts
import { readPpt, readPptContent } from "ppt-codec";

// The tree form: a document-schema.js DocumentTree, the same artefact
// ooxml.js's readPptx and odf.js's readOdp produce for their own formats.
const tree = readPpt(pptBytes);

// The flat form: metadata plus ContentSlide[], matching the shape
// readPptxContent and readOdpContent return.
const { metadata, slides } = readPptContent(pptBytes);
for (const slide of slides) {
  slide.size; // { widthPt, heightPt }
  for (const shape of slide.shapes) {
    shape.frame; // { xPt, yPt, widthPt, heightPt }
    shape.blocks; // ContentParagraph[], each with its own ContentRun[]
  }
}
```

`readPptStreams(currentUserStream, powerPointDocumentStream, password)` is the same read one level down, for a caller that already holds the two streams — the compound file beneath them is `archive-codec`'s business, and separating the two is what lets every record-level behaviour be tested without a container around it.

## Encryption

A `.ppt` protected with a password to open uses [MS-OFFCRYPTO] 2.3.5 "RC4 CryptoAPI Encryption" — genuinely different from the MD5-based scheme `xls-codec` and `doc-codec` share (ExaDev/documents.js#1108/#1113): SHA-1-based key derivation with no intermediate-hash iteration, and re-keying per persist object rather than at a fixed byte interval. `readPpt`/`readPptContent`/`readPptStreams` take an optional `password`, ignored for an unencrypted presentation; a missing or incorrect password against an encrypted one throws `PptEncryptedError` rather than returning a partial or garbled document, and so does an encryption shape this package does not implement (anything other than RC4 CryptoAPI — [MS-PPT] itself never specifies any other scheme for the binary format).

```ts
import { readPptContent } from "ppt-codec";

const { metadata, slides } = readPptContent(
  pptBytes,
  "correct horse battery staple",
);
```

`archive-codec`'s `crypto/office-rc4-cryptoapi` module (ExaDev/documents.js#1116) carries the key derivation and password verification; this package's own `src/encryption.ts` carries the container-specific pieces, which differ from both xls-codec's and doc-codec's shared scheme in three real ways:

- **Location.** There is no fixed-offset header at all. The `DocumentEncryptionAtom` (`RT_CryptSession10Container`, [MS-PPT]'s own name for record type 0x2F14) is just another persist object, reached only by walking the current `UserEditAtom.encryptSessionPersistIdRef` through the same persist directory every other record uses.
- **Re-keying granularity.** Each top-level persist object gets its own RC4 key, derived from its own persist ID as the "block number" — not a running byte offset within one continuous stream, the way both `xls-codec`'s FilePass scheme and `doc-codec`'s own EncryptionHeader scheme re-key.
- **Encrypted headers.** A persist object's own 8-byte record header is encrypted along with its data, unlike the never-encrypted headers the shared xls/doc scheme leaves alone — `decryptPptDocumentStream` decrypts a peek of those 8 bytes first, under the object's own key, to learn its real length before decrypting the object in full.

Pictures in a separate `Pictures` stream are also RC4 CryptoAPI-encrypted per [MS-PPT], but this package does not read the `Pictures` stream at all today (see [Images, tables, and OLE embeddings](#what-it-does-not-read-yet)), so decrypting it is out of scope until something needs to.

`writePptContent` never encrypts.

## Master and colour inheritance

A real `.ppt` deck overwhelmingly relies on master-inherited formatting rather than direct, per-run formatting: a title placeholder typically states its own text and nothing else, leaving size, typeface, weight, and colour entirely to its slide's own master. `readPptStreams`/`readPptContent`/`readPpt` resolve this cascade for every run, not just report the run's own direct formatting as before.

```ts
import { readPptContent } from "ppt-codec";

const { slides } = readPptContent(pptBytes);
// A run whose own TextCFException states neither bold nor colour still
// reports both here, resolved against its master and the slide's own
// colour scheme.
slides[0]?.shapes[0]?.blocks[0]?.runs[0]?.bold;
slides[0]?.shapes[0]?.blocks[0]?.runs[0]?.color;
```

**Text-formatting cascade** (`document/master.ts`): a run's own `TextPFException`/`TextCFException` field wins outright when it states one. For everything it leaves unstated, resolution walks the applicable `TextMasterStyleAtom` from the run's own (clamped) outline level down to level 0, then — if the run's own `TextTypeEnum` is a variant with no cascade of its own (`CENTER_BODY`/`HALF_BODY`/`QUARTER_BODY` retry as plain `BODY`; `CENTER_TITLE` retries as plain `TITLE`) — repeats the same walk against the fallback type. `NOTES` and `OTHER` have no further fallback. A `TextTypeEnum` a slide's own master carries no atom for falls back to the single document-wide default `TextMasterStyleAtom` [MS-PPT] 2.9.35 states lives inside `DocumentTextInfoContainer` (a child of `Environment`), the fallback of last resort for every type.

**Multi-master resolution**: unlike this package's own writer, which only ever produces one `MainMasterContainer`, a real file can carry several (different design templates within one deck). Every slide's own `SlideAtom.masterIdRef` is read and resolved against the master list, not assumed to be the deck's only master.

**Colour-scheme resolution** (`document/color-scheme.ts`) is a separate, simpler step: a `ColorIndexStruct` naming a colour-scheme slot (`0x00`-`0x07` — background, text, shadow, title text, fill, Accent 1, Accent 2, Accent 3) resolves against the slide's own `SlideSchemeColorSchemeAtom` when it carries one, or its master's otherwise. Unlike text formatting, this never walks a master-level cascade: [MS-PPT] mandates every slide-shaped container carry its own complete colour scheme (a slide that visually "follows the master's scheme" does so by a real producer duplicating the master's own values into it), so a slide's own scheme is the only place this reader looks first.

## Writing a document

```ts
import { writePpt, writePptContent } from "ppt-codec";

// The tree form: a document-schema.js DocumentTree in, real .ppt bytes out.
const pptBytes = writePpt(tree);

// The flat form: metadata plus ContentSlide[] in — title/author/dates are written to a real "\x05SummaryInformation" stream when metadata carries any of them (see Metadata). An optional sink hears every block this writer had to drop (an unblippable image format, a second table on one shape, or an embeddedObject block with no serialiseEmbeddedObject port to turn its nested document into real bytes) instead of losing that information silently.
const bytes = writePptContent(
  { metadata: {}, slides },
  { sink: (diagnostic) => console.warn(diagnostic.code, diagnostic.message) },
);
```

`writePptStreams(document)` is the same write one level down, returning the two [MS-PPT] streams without wrapping them in a compound file — the mirror of `readPptStreams`, for a caller assembling its own container. Every function throws `PptUnsupportedContentError` (not `PptFormatError`, which is reserved for malformed bytes on the read side) when asked to write content outside this writer's scope: a document that is not a presentation, or slides that do not all share one size (`[MS-PPT]`'s `DocumentAtom` states exactly one slide size for the whole presentation). A block kind this writer does not represent (a construct marker, an image in a format with no blip token, or an embeddedObject block whose nested document `serialiseEmbeddedObject` declines or was never supplied for) is, by default, not an error — it is dropped from the written text body with a diagnostic naming it (see [`src/diagnostics.ts`](src/diagnostics.ts)), the same documented-gap convention [What it does not read yet](#what-it-does-not-read-yet) already uses for the reader's own unsupported constructs. `WritePptOptions.onUnwritableBlock: 'throw'` turns exactly that drop into a thrown `PptUnsupportedContentError` instead — see [Silent drop, or a thrown error](#silent-drop-or-a-thrown-error) for why `'drop'` stays the default rather than converging on doc-codec's own throw-always convention.

### Silent drop, or a thrown error

`doc-codec` throws for a block kind its own writer cannot express; this package's writer, by default, drops the block and names it through the diagnostic sink instead — a real, deliberate divergence between two codecs in the same family, not an oversight ([ExaDev/documents.js#1188](https://github.com/ExaDev/documents.js/issues/1188), split into its own decision as [#1220](https://github.com/ExaDev/documents.js/issues/1220)). The two packages' own upstream differs in kind, not degree: `documents.js`'s PDF-to-ppt and odp-to-ppt reconstruction is this package's primary caller today, and it routinely hands this writer content the binary PPT format simply has no spelling for at all (an unrecognised alignment value, a construct marker, an OLE object with no serialiser port supplied) — not as a rare malformed-input edge case a bug would explain, but as the ordinary shape of reconstructing a narrower target format from a richer source. Converging on `doc-codec`'s own default would turn "this slide's chart degrades to geometry" into "the whole presentation fails to convert" for every one of those callers, a severe regression imposed on working code rather than a bug fixed in it.

`WritePptOptions.onUnwritableBlock` is the caller's own choice between the two policies rather than a permanent split: `'drop'` (the default, and every existing caller's own unchanged behaviour) keeps writing the rest of the shape and names the drop through the sink; `'throw'` raises a `PptUnsupportedContentError` naming the identical block and reason the sink would otherwise merely report, for a caller that would rather fail the whole conversion than ship a file quietly missing content it was asked to carry. The option covers a genuine block-level omission only — `PptDiagnosticCodes.TABLE_SPAN_DROPPED` (a merged cell's `colSpan`/`rowSpan` narrowed to one column/row, since this format's own tables carry no merge record at all) is a lossy narrowing of content that is still written, not an omission of it, and always stays sink-only regardless of this option; conflating the two would make `'throw'` fail a conversion over a table that wrote completely, just with a merge visually flattened.

## What it reads

The whole path from a file's first byte to a slide's text, record by record:

| Layer           | Records                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Container       | The `Current User` and `PowerPoint Document` streams, read through `archive-codec`'s bounded [MS-CFB] reader.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| Record framing  | The generic 8-byte `RecordHeader`, the container/atom distinction, sibling sequences, child walks, and typed-descendant search — shared with [MS-ODRAW]'s records, which carry the identical header.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| Edit resolution | `CurrentUserAtom` (including its encrypted/plaintext `headerToken`), the `UserEditAtom` chain, `PersistDirectoryAtom`/`PersistDirectoryEntry`'s packed 20-bit/12-bit run form, and the oldest-first directory construction whose later entries supersede earlier ones — [MS-PPT] 2.1.2's own "live record" process, Part 1.                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| Encryption      | `DocumentEncryptionAtom` (`UserEditAtom.encryptSessionPersistIdRef` → the persist directory), [MS-OFFCRYPTO] 2.3.5's RC4 CryptoAPI scheme — see [Encryption](#encryption).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| Document        | `DocumentContainer` → `DocumentAtom` (slide size, in master units), `DocumentTextInfoContainer`'s `FontCollectionContainer`/`FontEntityAtom` typeface names and its own document-default `TextMasterStyleAtom`, and `SlideListWithTextContainer` (distinguished from the master and notes lists by `recInstance`, which does not run in the order the names suggest).                                                                                                                                                                                                                                                                                                                                                                                                  |
| Masters         | `MasterListWithTextContainer` → `MasterPersistAtom` → the persist directory → each `MainMasterContainer`'s own `TextMasterStyleAtom` items and `SlideSchemeColorSchemeAtom` — a real file can carry more than one master, and `SlideAtom.masterIdRef` decides which one a given slide actually follows.                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| Slides          | `SlidePersistAtom` → the persist directory → each `SlideContainer`'s own `SlideAtom` (`masterIdRef`, `notesIdRef`) and drawing, and the placeholder texts the slide list carries for it.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| Speaker notes   | `NotesListWithTextContainer` (the third of the three containers sharing `RT_SlideListWithText`) → `NotesPersistAtom` → the persist directory → each `NotesContainer`, and the `NotesAtom.slideIdRef` naming the presentation slide those notes belong to. The text comes from the notes slide's own drawing, since the notes list — unlike the slide list — carries no texts for an `OutlineTextRefAtom` to reach into.                                                                                                                                                                                                                                                                                                                                                |
| Drawing         | `DrawingContainer` → `OfficeArtDgContainer` → the `OfficeArtSpgrContainer`/`OfficeArtSpContainer` tree, `OfficeArtFSP`'s group/patriarch/deleted flags, `OfficeArtClientAnchor` in both its 8-byte `SmallRectStruct` and 16-byte `RectStruct` spellings, `OfficeArtChildAnchor` mapped through nested `OfficeArtFSPGR` group coordinate systems, a shape's own `OfficeArtFOPT` rotation property (`PROPERTY_ROTATION`, an [MS-OSHARED] 2.2.1.6 Fixed Point), and its four text-inset properties (`dxTextLeft`/`dyTextTop`/`dxTextRight`/`dyTextBottom`, each an EMU value read independently — a shape stating only one override still gets PowerPoint's own default on the other three).                                                                              |
| Pictures        | The document-wide `OfficeArtBStoreContainer` (`OfficeArtFBSE` entries, each an inline or `Pictures`-stream-offset blip), resolved through a picture shape's own `pib` property; only the two `MSOBLIPTYPE` tokens `document-schema.js`'s `ContentImageBlock` can hold losslessly (`0x05` JPEG, `0x06` PNG) decode to an image block, sized to the shape's own frame. An unresolvable pib (past the store's end, an empty slot, or a WMF/EMF/TIFF/DIB blip this package does not decode) keeps the shape with empty content rather than dropping the shape.                                                                                                                                                                                                             |
| Tables          | A table group's own grid, recovered from its cells' rectangles rather than from any row/column record — the format states none: row and column boundaries are the cells' own distinct tops and lefts, a cell lands at the intersection of its own top and left, and a real producer's degenerate zero-width/zero-height gridline shapes are excluded from the grid by that same geometry check rather than treated as cells.                                                                                                                                                                                                                                                                                                                                           |
| OLE embeddings  | A shape's `OfficeArtClientData` → `ExObjRefAtom` → the document's `ExObjListContainer` → `ExOleEmbedContainer` (`ExOleObjAtom`'s own `persistIdRef`, and an optional `ProgIDAtom`) → the persist directory → an `ExOleObjStg` persist object, decompressed (zlib/[RFC1950]) when `rh.recInstance` says so — recovering the embedded object's own raw [MS-CFB] compound-file bytes with no further "Package"-stream wrapper. This package cannot decode those bytes into a real nested document itself (see [What it does not read yet](#what-it-does-not-read-yet)), so the recovery stops at the bytes/progId pair unless a caller injects `ReadPptOptions.decodeEmbeddedObject` — `documents.js` wires one from `doc-codec`/`xls-codec`/its own `ppt-codec` adapter. |
| Text            | `OfficeArtClientTextbox`, `TextHeaderAtom`, `TextCharsAtom` (UTF-16) and `TextBytesAtom` (one byte per character), `OutlineTextRefAtom` indirection into the slide list, and the paragraph split on the stored `\r`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| Formatting      | `StyleTextPropAtom`: `TextPFRun`/`TextPFException` (indent level, alignment, line spacing, space before/after, left margin, and first-line indent) and `TextCFRun`/`TextCFException` (bold, italic, underline, shadow, emboss, typeface reference, size in points, and a `ColorIndexStruct` colour — literal sRGB or a colour-scheme slot reference), each read in the spec's **declared field order** rather than its mask-bit order — the two differ, and following the mask-bit order desynchronises every field after the first divergence. A field a run states neither directly nor at all resolves against the applicable master's own cascade and colour scheme — see [Master and colour inheritance](#master-and-colour-inheritance).                         |

Geometry is converted from master units (1/576 inch) to points on the way out, so a slide's `size` and every shape's `frame` are in the same unit the shared schema uses everywhere else.

## What it does not read yet

Each of these is a real construct of the format that this package currently ignores or cannot represent — not a claim that it does not exist:

- **`DocumentSummaryInformation`'s extended and user-defined properties** (company, manager, custom properties) — a genuinely different stream from the one [Metadata](#metadata) covers, not attempted at all.
- **A linked (as opposed to embedded) OLE object.** `ExOleLinkContainer` names an external file this package has no path to resolve independently of the host document, so a linked object's shape reads with geometry and no blocks — the identical "no recovery path, no entry" degrade an unresolvable persist reference already gets. An embedded OLE object's own linkage and storage recovery is real — see [OLE embeddings](#what-it-reads) in the table above.
- **Decoding an OLE-embedded object's own nested content, without a caller-supplied port.** This package depends on no sibling format codec (`doc-codec`/`xls-codec`/itself, or `ooxml.js`), so it cannot turn an embedded object's recovered `[MS-CFB]` bytes into a real nested `ContentDocument` on its own — the same architectural boundary `ooxml.js`'s own embedded-object recovery states for a classic-binary `.bin` payload it finds no `Package` stream in. `readPptContent`/`readPpt`'s `ReadPptOptions.decodeEmbeddedObject` is the injected port a caller holding every codec — `documents.js` — supplies; without one, an OLE-embedded shape's blocks stay whatever its own picture/text already give it (the same degrade a decode failure, an unrecognised progId, or a missing persist entry all produce), never a document this package invented or guessed at.
- **Image formats beyond PNG and JPEG.** A blip in any other `MSOBLIPTYPE` (WMF/EMF metafile, PICT, a raw DIB, TIFF) reads as no image at all, keeping the shape's geometry with empty content — the same convention an unresolvable pib already uses.
- **Cell merges in a read table.** [MS-PPT]'s own table shapes are a strict grid of one shape per cell with no merge record at all (merged cells arrived only with the 2010 XML format), so a read table never states `colSpan`/`rowSpan`.
- **Shapes with no anchor.** A shape carrying neither an `OfficeArtClientAnchor` nor an `OfficeArtChildAnchor` is dropped, because `ContentShape` has no way to say "positioned, but unknown where".
- **Hyperlinks and bullets.** `InteractiveInfo`/`TextInteractiveInfoAtom` and `TextPFException`'s bullet fields are parsed past correctly but not surfaced.
- **Animations, transitions, comments, headers and footers, and the metacharacter atoms** (slide number, date, header, footer).
- **Alignment values the shared schema has no name for.** `Tx_ALIGNDistributed`, `Tx_ALIGNThaiDistributed` and `Tx_ALIGNJustifyLow` map to no alignment rather than being rounded to `justify`.
- **`ParaSpacing` values in the form the shared schema cannot state.** `lineSpacing` (the schema's line-height multiplier) only has a value to report for `ParaSpacing`'s percentage-of-line-height form; `spacingBeforePt`/`spacingAfterPt` (the schema's plain points) only have a value for the absolute master-units form. A paragraph stating the other form of either field reports no value at all for it, rather than a wrong one — there is no rendered line height available here to convert one form into the other.
- **The soft line break.** U+000B inside a paragraph is converted to a newline, an inference from the spec's own worked examples rather than a rule it states; the specification publishes no table of the special characters a text body may hold.

## What it writes

The whole path from a `ContentSlide[]` to a real `.ppt` file's bytes, mirroring the read-side table above in the opposite direction:

| Layer           | Records                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Container       | The `Current User` and `PowerPoint Document` streams, wrapped in a real [MS-CFB] compound file through `archive-codec`'s conformant writer.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| Record framing  | The generic 8-byte `RecordHeader`, atom and container builders — `record/write.ts`, shared by every writer module below and by this package's own test fixtures.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| Edit resolution | A single-edit persist layer: one `CurrentUserAtom` pointing at one `UserEditAtom` pointing at one `PersistDirectoryAtom` whose entries name the stream offset of the document container, the master, every slide container and every notes container — never an incremental append, since nothing about this writer's own output needs a second generation of any object.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| Document        | `DocumentContainer` → `DocumentAtom` (one slide size, in master units, taken from the input's own first slide and required to match every other slide — see below), an `Environment`/`FontCollectionContainer` built from every distinct `fontFamily` a run names, a `MasterListWithTextContainer` naming the one master, a `SlideListWithTextContainer` carrying one `SlidePersistAtom` per slide with no placeholder texts, and — only when some slide has notes — a `NotesListWithTextContainer`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| Master          | One minimal `MainMasterContainer`: its own `SlideAtom`, the title/body/notes `TextMasterStyleAtom` items [MS-PPT] 2.5.3 requires (each stating `cLevels` 0, so every level falls through to the document's own text styles), the five `SL_TitleBody` placeholder shapes a main master must carry, and a default `SlideSchemeColorSchemeAtom`. It exists because speaker notes need it — see below.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| Slides          | One `SlideContainer` per input slide, each opening with a `SlideAtom` that names the master it follows and, when the slide has notes, the notes slide holding them, then its own `DrawingContainer`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| Speaker notes   | One `NotesContainer` per slide that actually has notes — a `NotesAtom` naming that slide, then a `DrawingContainer` whose single text box carries the notes, one paragraph per line, then the `SlideSchemeColorSchemeAtom` [MS-PPT] 2.5.6 requires of one — the notes slide's own `NotesAtom.slideFlags` leaves `fMasterScheme` clear, so it inherits no scheme and has to state one. A slide with no notes gets no notes slide at all rather than an empty one.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| Drawing         | `OfficeArtDgContainer` → one `OfficeArtSpgrContainer` (the patriarch group every real drawing carries) → one plain `OfficeArtSpContainer` per shape, each anchored in slide coordinates via a 32-bit `OfficeArtClientAnchor` (`RectStruct`, never the 16-bit `SmallRectStruct`), a shape's own `rotationDeg` as an `OfficeArtFOPT` `PROPERTY_ROTATION` entry, and whichever of its four text insets differ from the default its own picture-ness implies, each as its own `dxTextLeft`/`dyTextTop`/`dxTextRight`/`dyTextBottom` property — no grouping otherwise, beyond the table group below.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| Pictures        | The first `png`/`jpeg` image block on a shape becomes that shape's one blip-store reference (`blipIndexOf`, feeding the document-wide `OfficeArtBStoreContainer` every picture shares); an image in any other format, or a second image on a shape whose single blip reference an earlier one already claimed, is dropped with a diagnostic (`ppt/image-dropped`) rather than silently discarded.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| Tables          | A table block turns its whole shape into a table group in the spelling a real PowerPoint-authored file carries (confirmed against Microsoft Office PowerPoint's own output and Apache POI's `table_test.ppt` fixture): an `OfficeArtFSPGR` child coordinate system identical to the shape's own client anchor, `fIsTable`/`tableRowProperties` in the tertiary property table, and one plain text-box shape per cell at its own `OfficeArtChildAnchor` grid position — the same grid `tableBlockFor` reads back. A cell's `colSpan`/`rowSpan` is dropped with a diagnostic (`ppt/table-span-dropped`), since the format has no merge record to state it in. A table that breaks the grid rule (as document-schema.js's `findTableGridFault` defines it) is reported through `ppt/table-grid-fault` and still written with every entry at its own grid position, so nothing a covered position held is lost.                                                                                                                                                                                                                                        |
| OLE embeddings  | A shape whose blocks carry an `embeddedObject` block gets a real `ExObjRefAtom` in its own `OfficeArtClientData`, naming a fresh entry in the document's own `ExObjListContainer` (`ExOleEmbedContainer`: `ExOleEmbedAtom`, `ExOleObjAtom`, and — for a `wordprocessing`/`spreadsheet` object kind, the two this package can name a real legacy-Office ProgID for — a `ProgIDAtom`) whose `persistIdRef` names a fresh `ExOleObjStgUncompressedAtom` persist object, only when `WritePptOptions.serialiseEmbeddedObject` (this package has no sibling-codec writer of its own to serialise the nested document with — the write-side mirror of the read-side decode port above) actually recovers `[MS-CFB]` bytes for that object's own nested document; a shape whose embed the port declines (no port supplied, or a document kind — `formula`/`drawing` — it cannot serialise) writes with no `clientData` at all, identical to a shape that never carried an embeddedObject block in the first place — the same silent-drop policy every other unwritable block already gets (see [What it does not write yet](#what-it-does-not-write-yet)). |
| Text            | Every shape carries its own text directly on its `OfficeArtClientTextbox` (`TextHeaderAtom` + a UTF-16 `TextCharsAtom`) rather than through the `OutlineTextRefAtom` placeholder indirection into the slide list — a plain text box is all this writer produces, so there is no separate placeholder text to route through the document's own slide list.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| Formatting      | `StyleTextPropAtom`: one `TextPFRun` per paragraph (indent level, alignment, line spacing, space before/after, left margin, and first-line indent) and one `TextCFRun` per character run (bold, italic, underline, a font-collection reference, size in points, and a literal sRGB `ColorIndexStruct` colour), fields written in the identical spec-declared order `readTextPFException`/`readTextCFException` parse them in.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |

Geometry is converted from points to master units on the way in, rounding to the nearest whole master unit (1/576 inch) — the format's own smallest unit of length.

Verification is a direct round trip through this package's own reader (`write.test.ts`, `content-write.test.ts`, `text/style-write.test.ts`): write real records, read them back through `readPptContent`/`readPpt`, and assert the recovered content equals what was written. This proves the writer's bytes are genuinely conformant [MS-PPT] rather than merely internally self-consistent, since the reader was built and tested independently, against the specification alone, before any writer existed.

### Why a writer of plain text-box slides writes a master slide

Speaker notes are the reason, and the chain is worth stating because none of its links is obvious from the specification alone.

[MS-PPT] 3.5.3 names exactly one association between a notes slide and its presentation slide: the `slideIdRef` field of the notes slide's own `NotesAtom`. That is the link this package's **reader** follows, and it is sufficient for reading. It is **not** sufficient for writing, because a real consumer follows the opposite link — the `notesIdRef` field of the presentation slide's own `SlideAtom`. Verified directly against LibreOffice: a file carrying only the specification's stated link has its speaker notes silently dropped on import, and the identical file with `notesIdRef` additionally set has them imported onto the right slides. A conformant writer therefore has to state both.

Stating `notesIdRef` means writing a `SlideAtom`, and [MS-PPT] 2.5.2 requires a `SlideContainer`'s `SlideAtom` to name a master: "`masterIdRef` … MUST NOT be 0x00000000 if the record that contains this `SlideAtom` record is a `SlideContainer`". So the master is a prerequisite the notes linkage drags in, not a feature added beside it. It is deliberately minimal — no master text, no background, no layouts, the placeholder shapes empty — and every slide this writer emits is `SL_Blank`, instantiating none of them, so nothing from the master is drawn on any slide.

### Verified against a second [MS-PPT] implementation

The round trip above proves the reader and writer agree with each other. Speaker notes are additionally checked against LibreOffice, an independently written [MS-PPT] implementation, in both directions:

- **Reading real bytes.** A presentation authored as flat ODF and converted with `soffice --headless --convert-to ppt` — three slides, notes on the first and third, none on the second — is read by `readPptContent`, and the recovered notes match what LibreOffice's own `--convert-to fodp` re-export of the same file independently reports. This is what established that a real producer stores the notes body on a plain, un-placeholdered text box whose `TextHeaderAtom` states `Tx_TYPE_OTHER`, rather than on the `PT_NotesBody` placeholder the spelling suggests — a reader keyed on the notes text type would recover nothing from a real file.
- **Writing bytes a real consumer reads.** A `.ppt` written by `writePptContent` with notes on some slides opens in LibreOffice with every slide's own text intact and each slide's notes inside `presentation:notes` — the notes view — rather than on the slide itself, confirmed by converting the written file back with `--convert-to fodp` and checking which element the text landed in. That last check is the one that matters: the same class of bug (notes rendering on the slide rather than the notes page) was caught in `odf.js`'s own `writeOdp` by exactly this test and by nothing else. Feeding the written file back through LibreOffice's own PPT export and reading _that_ returns the same slides and the same notes again.
- **Line spacing, paragraph spacing, and left margin.** A `.ppt` written with `lineSpacing`, `spacingBeforePt`/`spacingAfterPt`, and `indentLeftPt` set converts cleanly through LibreOffice to both `.pptx` (`a:lnSpc`/`a:spcBef`/`a:spcAft`/`a:pPr@marL`) and `.odp` (`fo:line-height`/`fo:margin-top`/`fo:margin-bottom`/`fo:margin-left`), each matching the written value. `indentFirstLinePt` (the hanging/first-line indent, `TextPFException.indent`) does not: on every input tried, regardless of export target, LibreOffice's own import produces a value with no relationship to what was written. This package's own reader — built independently against the specification alone, with no knowledge of the writer's internals — recovers the exact value written, and the field's byte offset and order were separately confirmed against the [MS-PPT] `TextPFException` specification directly (`leftMargin` then `indent`, both after `spaceAfter`), so the discrepancy sits in LibreOffice's own import of this one field rather than in the bytes offered to it.

## What it does not write yet

Each of these is either a real construct this writer deliberately does not attempt (a smaller, genuinely correct core rather than a larger, unreliable one — see the two tables above for exactly what it does write), or a `ContentShape`/`ContentParagraph`/`ContentRun` field this writer's own OfficeArt shape tree has nowhere to carry:

- **An OLE embedding's own nested content, without a caller-supplied serialiser.** This package cannot itself turn a nested `ContentDocument` back into `[MS-CFB]` bytes (it depends on no sibling format codec — the write-side mirror of the read-side decode gap above), so `WritePptOptions.serialiseEmbeddedObject` is the injected port a caller holding every codec supplies; without one, a shape's `embeddedObject` block writes with no `clientData` and no `ExOleObjStg` persist object, silently, matching every other block kind this writer cannot express (see [What it writes](#what-it-writes) for what a supplied port produces).
- **Shapes with no text.** Written with a client anchor and no `OfficeArtClientTextbox` at all, matching how the reader represents one (`blocks: []`); nothing is lost, since there was nothing to write.
- **Grouped shapes beyond a table, and any coordinate system beyond a plain `OfficeArtClientAnchor` or the table group's own `OfficeArtFSPGR`.** Every non-table shape this writer emits is an ungrouped rectangle in slide coordinates; there is no general `OfficeArtChildAnchor`/`OfficeArtFSPGR` group nesting outside the one a table block itself produces. `ContentShape.rotationDeg` is written (see [Drawing](#what-it-writes) in the table above).
- **Autofit and paint order.** `ContentShape.fontScale`, `lineSpacingReduction`, and `paintOrder` have no `OfficeArtFOPT` property table entry this writer states (per-shape text insets are written — see [Drawing](#what-it-writes) in the table above).
- **Master content, layouts, and scheme colours.** A `MainMasterContainer` and its `MasterListWithTextContainer` are written, but only as the minimum [MS-PPT] requires of one (see [Why a writer of plain text-box slides writes a master slide](#why-a-writer-of-plain-text-box-slides-writes-a-master-slide)): its five placeholder shapes carry no text, its `TextMasterStyleAtom` items state no style level of their own, and its `SlideSchemeColorSchemeAtom` is a fixed default rather than anything the input chose. There are still no slide layouts, and every character run's colour must already be a literal, since no scheme is there to resolve one against.
- **Notes masters, and a notes page geometry of its own.** No `NotesContainer` is written for the notes master, and `DocumentAtom.notesMasterPersistIdRef` stays 0, so each notes slide inherits nothing (its `NotesAtom.slideFlags` is clear) and states the same fixed default `SlideSchemeColorSchemeAtom` the master does rather than a scheme of the input's choosing. The notes page is the same size as the slide, because `ContentSlide` carries no notes-page geometry to state a different one from, and the notes text box is placed in the lower half of it.
- **Hyperlinks, bullets, and list numbering identity.** `ContentRun.hyperlink`, `ContentParagraph.list.numId`/`checked`/`itemId`, and `pageBreakBefore`/`pageBreakAfter` have no [MS-PPT] field this writer populates; `alignment`, `list.level` (as a `TextPFException` indent level), `spacingBeforePt`/`spacingAfterPt`/`lineSpacing`/`indentLeftPt`/`indentFirstLinePt` round-trip.
- **`strike`, `sourcePath`, `source`, and `frames`.** `ContentRun.strike` has no `TextCFException` bit this writer sets (the format's own `CFMasks`/`CFStyle` carry no strikethrough bit at all — a real gap in [MS-PPT], not a scope choice); the three fidelity/positioning fields are round-trip-irrelevant to a fresh write and are never populated.
- **Construct markers.** A `constructStart`/`constructEnd` pair (or any other non-`paragraph` block kind) is excluded from the written text body exactly like an image or table block, per [Writing a document](#writing-a-document).
- **Alignment values the shared schema has no name for.** The mirror of the read-side gap: `Tx_ALIGNDistributed`, `Tx_ALIGNThaiDistributed`, and `Tx_ALIGNJustifyLow` are never written, since `Alignment` has no member naming them.
- **Fractional character sizes.** `ContentRun.sizePt` is rounded to the nearest whole point, since `TextCFException`'s size field is a plain 16-bit integer.
- **Fonts (custom embedding), animations, transitions, comments, and the metacharacter atoms.** Nothing here is written for the same reason none of it is read yet — see the corresponding entries in [What it does not read yet](#what-it-does-not-read-yet).

## Metadata

A `.ppt`'s title, author, and dates do not live in any [MS-PPT] record at all — they live in a `"\x05SummaryInformation"` stream, a genuinely different format ([MS-OLEPS] Property Set Streams, [MS-OSHARED] 2.3.3.2.2's own naming of the specific properties Office uses) that happens to sit beside `Current User`/`PowerPoint Document` in the same [MS-CFB] compound file. `readPptContent` reads that stream when present (`archive-codec`'s `readSummaryInformation`, since the property-set format itself is zero document-format knowledge, exactly as the [MS-CFB] container it sits inside is) and maps it onto `document-schema.js`'s `LayoutMetadata` (`archive-codec`'s own `summaryInformationToLayoutMetadata` — the mapping is format-agnostic, so it lives there rather than being copied in this package, alongside `doc-codec`'s and `xls-codec`'s identical need for it); `writePptContent` does the inverse (`src/metadata.ts`'s `layoutMetadataToSummaryInformation`, which validates `createdIso`/`modifiedIso` as real dates and throws a `PptUnsupportedContentError` naming the offending field before delegating to `archive-codec`'s own mapping), including a `"\x05SummaryInformation"` stream in its `writeCompoundFile` call only when the input's metadata actually carries something that stream can hold — an input whose metadata is `{}`, or carries only fields the mapping below has no destination for, produces no stream at all, matching what an absent-metadata read already returns.

`readPptStreams`/`writePptStreams`, the record-level split one layer below, do not touch this at all: they take or return only the two required [MS-PPT] streams, with no compound file to look a third stream up in. `readPptContent`/`writePptContent` are where the container-level fact lives.

The mapping is not 1:1, and each gap is permanent rather than a remaining TODO:

| Direction                           | Fields covered                                                                         | Gap                                                                                                                                                                                                                             |
| ----------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| SummaryInformation → LayoutMetadata | `title`, `subject`, `author`, `keywords`, `createdIso`, `lastSavedIso` → `modifiedIso` | `comments` and `lastPrintedIso` have no LayoutMetadata field to land in — no other codec in the family has a "last printed" or free-text "comments" concept, so these are read from the stream but never reach a `PptDocument`. |
| LayoutMetadata → SummaryInformation | the same six fields, in reverse                                                        | `creator`, `producer`, and `language` have no SummaryInformation equivalent: `producer` is a PDF-only concept in this schema, and `creator`/`language` are not among the fields the stream this package writes covers.          |

Only the fixed SummaryInformation property set is read or written — the sibling `"\x05DocumentSummaryInformation"` stream (company, manager, and custom user-defined properties, [MS-OLEPS]'s two-property-set spelling) is not attempted at all, an explicit scope boundary `archive-codec`'s own `oleps` support shares.

## Architecture

Every module is importable by package-relative path as well as through the barrel — `tsdown` builds one dist file per src module (`root: 'src'`, the layout every sibling codec ships), and `package.json`'s `./*` exports wildcard maps each subpath onto it:

```ts
import { readRecordAt } from "ppt-codec/record/tree";
import { readStyleTextPropAtom } from "ppt-codec/text/style";
```

| Module                         | What it owns                                                                                                                                                                                                                                                                                                               |
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `record/header`                | The generic 8-byte record header and the container/atom distinction.                                                                                                                                                                                                                                                       |
| `record/types`                 | The `RecordType` values this reader dispatches on, plus the [MS-ODRAW] types the drawing walk crosses into.                                                                                                                                                                                                                |
| `record/tree`                  | Offset-addressed records, sibling sequences, child walks, typed-descendant search.                                                                                                                                                                                                                                         |
| `record/write`                 | Byte primitives and the atom/container builders every writer module below composes records from — the write-side mirror of `record/header`/`record/tree`, and what this package's own test fixtures build on too.                                                                                                          |
| `stream/current-user`          | `CurrentUserAtom`: where the live edit is, and whether the file is encrypted.                                                                                                                                                                                                                                              |
| `stream/current-user-write`    | Writes a real `CurrentUserAtom` pointing at the single edit this writer always produces.                                                                                                                                                                                                                                   |
| `stream/persist`               | `UserEditAtom`, `PersistDirectoryAtom`, and the persist directory the edit chain builds.                                                                                                                                                                                                                                   |
| `stream/persist-write`         | Writes a single-edit `UserEditAtom`/`PersistDirectoryAtom` pair covering the document container and every slide container.                                                                                                                                                                                                 |
| `encryption`                   | `readDocumentEncryptionAtom`, `decryptPptDocumentStream` — [MS-OFFCRYPTO] 2.3.5 RC4 CryptoAPI decryption, wired against `archive-codec`'s own key derivation (see [Encryption](#encryption)).                                                                                                                              |
| `document/document-atom`       | `DocumentAtom`: slide and notes sizes, master persist references.                                                                                                                                                                                                                                                          |
| `document/document-atom-write` | Writes a `DocumentAtom` for the one slide size every slide must share.                                                                                                                                                                                                                                                     |
| `document/fonts`               | The font collection, resolved to typeface names a `FontIndexRef` indexes.                                                                                                                                                                                                                                                  |
| `document/fonts-write`         | Writes an `Environment`/`FontCollectionContainer` from a document's own distinct font families.                                                                                                                                                                                                                            |
| `document/slide-list`          | `SlideListWithTextContainer`: each slide's persist reference and its placeholder texts.                                                                                                                                                                                                                                    |
| `document/slide-list-write`    | Writes a `SlideListWithTextContainer` naming each slide's persist reference, with no placeholder texts.                                                                                                                                                                                                                    |
| `document/notes-list`          | `NotesListWithTextContainer`: each notes slide's persist reference and its notes identifier. Its own module rather than a parameter of `slide-list`, because the container holds no texts and `NotesPersistAtom` spends on a reserved field what `SlidePersistAtom` spends on `cTexts`.                                    |
| `document/notes-list-write`    | Writes that container from the same `NotesPersist` shape the reader produces.                                                                                                                                                                                                                                              |
| `document/notes`               | A `NotesContainer`: its `NotesAtom` (which slide the notes belong to) and the notes text its drawing carries.                                                                                                                                                                                                              |
| `document/notes-write`         | Writes a `NotesContainer` for one slide's notes, through the same drawing writer a slide's own shapes go through.                                                                                                                                                                                                          |
| `document/master`              | `readSlideAtom` (`masterIdRef`/`notesIdRef`), `buildMasterStyleTable`, and `resolveCharacterProperties`/`resolveParagraphProperties` — the master text-formatting cascade a run whose own fields are absent resolves against (see [Master and colour inheritance](#master-and-colour-inheritance)).                        |
| `document/master-write`        | The minimal `MainMasterContainer` and `MasterListWithTextContainer`, plus the `SlideAtom` every slide needs in order to name that master and its own notes slide.                                                                                                                                                          |
| `document/color-scheme`        | `readSlideSchemeColorSchemeAtom`, `resolveSchemeColor` — resolves a `ColorIndexStruct`'s scheme-slot reference against the slide's (or its master's) own colour scheme.                                                                                                                                                    |
| `document/color-scheme-write`  | The `SlideSchemeColorSchemeAtom` [MS-PPT] 2.9.51 gives four different containers, two of which this writer produces — the main master and every notes slide — so it belongs to neither of them.                                                                                                                            |
| `drawing/shapes`               | The OfficeArt shape tree, flattened, with every anchor resolved into slide coordinates through its enclosing groups.                                                                                                                                                                                                       |
| `drawing/shapes-write`         | Writes the patriarch group and one plain, anchored `OfficeArtSpContainer` per shape.                                                                                                                                                                                                                                       |
| `text/atoms`                   | The two text-body spellings, the text-type enumeration, and the paragraph split.                                                                                                                                                                                                                                           |
| `text/style`                   | `StyleTextPropAtom`'s two run arrays and their mask-driven exception structures.                                                                                                                                                                                                                                           |
| `text/style-write`             | Writes a `StyleTextPropAtom` from the same `StyleRun`/`ParagraphProperties`/`CharacterProperties` shapes `text/style` reads into.                                                                                                                                                                                          |
| `content`                      | The mapping of PowerPoint's character-counted runs onto the schema's paragraph-owned runs.                                                                                                                                                                                                                                 |
| `content-write`                | The inverse: a shape's `ContentBlock[]` to the flat character-counted text body and `StyleTextProps` `text/style-write` needs.                                                                                                                                                                                             |
| `metadata`                     | Wraps `archive-codec`'s own `SummaryInformationProperties` <-> `LayoutMetadata` mapping with this package's `createdIso`/`modifiedIso` date validation, throwing `PptUnsupportedContentError` for a malformed one rather than letting an opaque `RangeError` escape the FILETIME conversion (see [Metadata](#metadata)).   |
| `read`                         | The whole read pipeline, and the `readPpt`/`readPptContent`/`readPptStreams` surface.                                                                                                                                                                                                                                      |
| `write`                        | The whole write pipeline, and the `writePpt`/`writePptContent`/`writePptStreams` surface.                                                                                                                                                                                                                                  |
| `units`                        | Master units to points, and points to master units.                                                                                                                                                                                                                                                                        |
| `errors`                       | `PptFormatError` for malformed input, `PptEncryptedError` for encrypted input given no password, an incorrect one, or an encryption scheme this package does not implement (anything other than RC4 CryptoAPI), `PptUnsupportedContentError` for well-formed content this package's writer cannot express.                 |
| `diagnostics`                  | `PptDiagnostic`/`PptDiagnosticSink`/`NOOP_PPT_DIAGNOSTIC_SINK` and the stable `PptDiagnosticCodes` a caller branches on — the write-side channel a dropped image, block, or table span fires through, matching the shape markdown-codec's, pdf-codec's, epub-codec's, and rtf-codec's own diagnostic channels already use. |

### Every fixture is built from the specification, not captured

There is no `.ppt` file anywhere in this package's tests. Every read-path fixture is assembled byte by byte from [MS-PPT]'s own field-layout tables, through the builders in `src/test-support/` — including a whole synthetic presentation and a minimal [MS-CFB] writer kept separate from `record/write.ts`'s real one, so the end-to-end suite exercises the real offset arithmetic (the persist directory, the edit chain, every cross-stream reference) rather than a stubbed one. That is deliberate: a fixture built from the spec's field tables states what the parser is being held to, whereas a captured file would only state what one producer happened to emit, and could not be reduced to the single record under test. The write path's own tests (`write.test.ts`, `content-write.test.ts`, `text/style-write.test.ts`) invert this: rather than hand-building bytes to feed the reader, they hand-build `ContentDocument`/`ContentBlock` values, write real records from them through `record/write.ts`, and read those bytes back through the unmodified reader — the same "build from the spec, not a captured file" discipline, applied to the writer's own output instead of a hand-assembled fixture.

### What it deliberately does not depend on

The [MS-CFB] container beneath the format is the one piece not hand-written again, in either direction: `archive-codec` already owns bounded compound-file reading and conformant compound-file writing for the family, and a second implementation here would be exactly the duplication that package's extraction exists to prevent. Everything above it — the record tree, the persist layer, the OfficeArt walk, the text and formatting model, and their write-side mirrors — is hand-written against the published specification, the same bet every sibling codec here makes against a heavyweight format library.

## Conventions

- Worker-isomorphic (see the [family-wide convention](../../README.md#conventions)): runtime `src/` must not import `node:*`, a bare Node builtin, or use the `Buffer` global — enforced by a `no-restricted-imports`/`no-restricted-globals` ESLint rule and exercised in CI by running a suite inside an actual `workerd` isolate (`pnpm test:workers`). Test files under `src/**/*.test.ts` and `src/test-support/` are exempt and may use Node APIs for fixtures.
- Only `src/index.ts` may be named `index.*` — a custom ESLint rule (`local/no-non-barrel-index`) rejects any other module using an `index` basename, since that would be a hidden entry point the `exports` map in `package.json` doesn't advertise.
- Every structural failure throws `PptFormatError` rather than degrading: a malformed file fails whole, never returning a partial slide list that looks complete. On the write side, content genuinely outside this writer's own scope (a document that is not a presentation, slides that disagree on size) throws `PptUnsupportedContentError`. A block kind this writer cannot represent at all (an image, a table, a construct marker) is different: by default it is excluded from the written text body with a diagnostic naming it, never approximated, and `WritePptOptions.onUnwritableBlock: 'throw'` lets a caller opt into raising `PptUnsupportedContentError` for that case too — see [Silent drop, or a thrown error](#silent-drop-or-a-thrown-error) for why the default stays a diagnosed drop rather than a throw.

## Specification references

Every field layout in this package is taken from a specification page, cited in the source at the point it is used. The load-bearing ones:

- [[MS-PPT]: PowerPoint (.ppt) Binary File Format](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/6be79dde-33c1-4c1b-8ccc-4b2301c08662)
- [[MS-PPT] 2.1.2: PowerPoint Document Stream](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/1fc22d56-28f9-4818-bd45-67c2bf721ccf) — the "live record" process this reader implements
- [[MS-PPT] 2.3.1: RecordHeader](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/df201194-0cd0-4dfb-bf10-eea353d8eabc)
- [[MS-PPT] 2.3.2: CurrentUserAtom](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/940d5700-e4d7-4fc0-ab48-fed5dbc48bc1)
- [[MS-PPT] 2.3.3: UserEditAtom](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/3ffb3fab-95de-4873-98aa-d508fbbac981) and [2.3.5: PersistDirectoryEntry](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/6214b5a6-7ca2-4a86-8a0e-5fd3d3eff1c9)
- [[MS-PPT] 2.4.1: DocumentContainer](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/6254c4d1-5217-4e16-b20d-c04ddcce31c9) and [2.4.2: DocumentAtom](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/121f2728-3497-4a0a-829e-6f416fee2ee6)
- [[MS-PPT] 2.4.14.3: SlideListWithTextContainer](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/307e6d12-7304-47a8-acbd-3e7b8041ad3c) and [2.4.14.5: SlidePersistAtom](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/48dce412-9692-4f93-aeb7-3d9fdd3a0a5a)
- [[MS-PPT] 2.5.1: SlideContainer](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/4cac0976-73d0-4ab3-a70b-e98b3cf1c312), [2.5.2: SlideAtom](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/57e11e6c-e550-4c43-80b6-72731eee8abd), [2.5.3: MainMasterContainer](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/e2f5fbf3-d790-487e-b96b-5ccdee0f0aa8) and [2.5.13: DrawingContainer](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/0595b49f-da96-4402-b353-1f766e9d548f)
- [[MS-PPT] 2.4.14.6: NotesListWithTextContainer](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/55453e37-0674-4703-bd8d-fcaba335f840), [2.4.14.7: NotesPersistAtom](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/b595ad14-a46c-4fcc-b4bd-7298712043a4), [2.5.6: NotesContainer](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/50bfc0f7-c101-4c32-8754-6ca59772b785) and [2.5.7: NotesAtom](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/9bb3e352-1014-477b-b286-cd43127c3b74) — the speaker-notes records, plus [3.5.3: Notes Slides](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/0d430f90-17fc-4730-92c9-90198d19c13b), the worked example stating the notes-to-slide association
- [[MS-PPT] 2.9.22: PlaceholderAtom](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/5a37fba1-1e70-45d8-bda4-315730bd3c54), [2.13.21: PlaceholderEnum](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/dbfd04be-0776-44d9-b408-b07f4c951eaf), [2.13.28: SlideLayoutType](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/df8f3d7b-db67-47dc-8c89-20f5cbbf0fa9), [2.13.7: SlideFlags](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/a840eba8-ed03-47a0-a0c9-769236e21a0b), [2.9.31: TextMasterStyleAtom](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/5febad27-0c48-4f98-b655-562b986f5874) and [2.9.51: SlideSchemeColorSchemeAtom](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/9cfca750-dabb-4967-b133-2583a9f8c392) — what the written master is built from
- [[MS-PPT] 2.7.1: OfficeArtClientAnchor](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/37ee18c7-3c7c-4adc-91fb-cb3b01789d72), [SmallRectStruct](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/e47cb973-8480-4995-90b2-008bcb2ffc65), [RectStruct](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/8a58e3ae-2682-42d0-82cd-a41c2999584e)
- [[MS-PPT] 2.9.76: OfficeArtClientTextbox](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/f50070dd-a4dc-4edd-a446-c4fcc5c80ace), [TextHeaderAtom](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/08d31a66-0750-4009-b416-49f2871cd178), [TextCharsAtom](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/a3c5c8d5-e530-4167-a242-7743bc99aeac), [TextBytesAtom](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/80aae34b-2699-43fa-9e6a-c560ae790cd7)
- [[MS-PPT]: StyleTextPropAtom](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/a9a5fa71-238d-491e-acc7-fa1fffd5f100), [TextPFException](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/c15a13b3-db2c-4b50-a7e6-08045581a663), [PFMasks](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/2a02831a-088b-44e7-84c9-c185ab314a71), [TextCFException](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/c75024a2-14cb-4d7d-9964-bdab2fcd9d93), [CFMasks](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/bbca8581-d011-4293-a375-b209523cf962), [CFStyle](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/3ea010b9-0ef9-4c05-9982-618130ca66cd), [ColorIndexStruct](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/5d6b0509-f3c7-435f-9bf4-6f1fc5f8293c)
- [[MS-ODRAW]: Office Drawing Binary File Format](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-odraw/8560795e-7759-4745-838f-f7f2ef2f1872) — [OfficeArtSpContainer](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-odraw/16194cb9-b4b0-476c-9678-a6ac1f06b034), [OfficeArtFSP](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-odraw/8a7e7be3-0582-4461-9400-29d7eda8497d), [OfficeArtFSPGR](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-odraw/82d2d6a1-3a7a-4d15-9803-33145a76545a), [OfficeArtChildAnchor](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-odraw/33a44593-02df-4684-ab35-5a7c4a9bcaac)
- [[MS-CFB]: Compound File Binary File Format](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-cfb/53989ce4-7b05-4f8d-829b-d08d6148375b) — the container, read and written through `archive-codec`

## Install

```sh
pnpm add ppt-codec
# or
npm install ppt-codec
```

## Release and publishing

Release, CI, and commit-message conventions are all workspace-wide, not package-local — see the [monorepo root README](../../README.md#releases) for the mechanism.

## Contributing

Conventional Commits, enforced workspace-wide by commitlint through a root `commit-msg` hook. Work inside `packages/ppt-codec/`; see [CONTRIBUTING.md](../../CONTRIBUTING.md) for the shared git hooks and history conventions.

## Removed: base64 from `ppt-codec/base64`

`bytesToBase64` and `base64ToBytes` were this package's own copy of helpers every codec in the family carried separately. They live in [`byte-codec`](../byte-codec/README.md) now, as one implementation ([ExaDev/documents.js#1282](https://github.com/ExaDev/documents.js/issues/1282)), so the module behind the `ppt-codec/base64` deep import is gone.

Import from `byte-codec` directly:

```ts
import { base64ToBytes, bytesToBase64 } from "byte-codec";
```

Both names are also still on this package's own barrel, exactly as before, so `import { bytesToBase64 } from "ppt-codec"` is unaffected.

## License

MIT
