ppt-codec
v2.0.0
Published
Hand-written PowerPoint 97-2003 binary (.ppt, [MS-PPT]) reader and writer against the shared document-schema.js content pivot.
Maintainers
Readme
ppt-codec
A hand-written reader and writer for the PowerPoint 97-2003 binary file format (
.ppt, [MS-PPT]), producing and consuming the samedocument-schema.jspresentation content modelooxml.js's pptx support andodf.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, part of the legacy-binary-formats epic #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 does not read yet and What it writes/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 Documentstream holds the records and whoseCurrent Userstream 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) andrecInstance(12 bits), thenrecTypeandrecLen.recVer == 0xFmarks 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 seekingrecLenbytes 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 Userstream'soffsetToCurrentEdit, theUserEditAtomchain 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 forRT_Sliderecords 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
OfficeArtClientTextboxholds anOutlineTextRefAtom— 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.
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 importTo 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
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).
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 currentUserEditAtom.encryptSessionPersistIdRefthrough 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 anddoc-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 —
decryptPptDocumentStreamdecrypts 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), 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.
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
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), the same documented-gap convention 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 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, split into its own decision as #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. |
| 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), 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. |
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 covers, not attempted at all.- A linked (as opposed to embedded) OLE object.
ExOleLinkContainernames 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 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, orooxml.js), so it cannot turn an embedded object's recovered[MS-CFB]bytes into a real nestedContentDocumenton its own — the same architectural boundaryooxml.js's own embedded-object recovery states for a classic-binary.binpayload it finds noPackagestream in.readPptContent/readPpt'sReadPptOptions.decodeEmbeddedObjectis 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
OfficeArtClientAnchornor anOfficeArtChildAnchoris dropped, becauseContentShapehas no way to say "positioned, but unknown where". - Hyperlinks and bullets.
InteractiveInfo/TextInteractiveInfoAtomandTextPFException'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_ALIGNThaiDistributedandTx_ALIGNJustifyLowmap to no alignment rather than being rounded tojustify. ParaSpacingvalues in the form the shared schema cannot state.lineSpacing(the schema's line-height multiplier) only has a value to report forParaSpacing'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. |
| 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). |
| 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 byreadPptContent, and the recovered notes match what LibreOffice's own--convert-to fodpre-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 whoseTextHeaderAtomstatesTx_TYPE_OTHER, rather than on thePT_NotesBodyplaceholder 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
.pptwritten bywritePptContentwith notes on some slides opens in LibreOffice with every slide's own text intact and each slide's notes insidepresentation:notes— the notes view — rather than on the slide itself, confirmed by converting the written file back with--convert-to fodpand 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 inodf.js's ownwriteOdpby 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
.pptwritten withlineSpacing,spacingBeforePt/spacingAfterPt, andindentLeftPtset 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]TextPFExceptionspecification directly (leftMarginthenindent, both afterspaceAfter), 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
ContentDocumentback into[MS-CFB]bytes (it depends on no sibling format codec — the write-side mirror of the read-side decode gap above), soWritePptOptions.serialiseEmbeddedObjectis the injected port a caller holding every codec supplies; without one, a shape'sembeddedObjectblock writes with noclientDataand noExOleObjStgpersist object, silently, matching every other block kind this writer cannot express (see What it writes for what a supplied port produces). - Shapes with no text. Written with a client anchor and no
OfficeArtClientTextboxat 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
OfficeArtClientAnchoror the table group's ownOfficeArtFSPGR. Every non-table shape this writer emits is an ungrouped rectangle in slide coordinates; there is no generalOfficeArtChildAnchor/OfficeArtFSPGRgroup nesting outside the one a table block itself produces.ContentShape.rotationDegis written (see Drawing in the table above). - Autofit and paint order.
ContentShape.fontScale,lineSpacingReduction, andpaintOrderhave noOfficeArtFOPTproperty table entry this writer states (per-shape text insets are written — see Drawing in the table above). - Master content, layouts, and scheme colours. A
MainMasterContainerand itsMasterListWithTextContainerare written, but only as the minimum [MS-PPT] requires of one (see Why a writer of plain text-box slides writes a master slide): its five placeholder shapes carry no text, itsTextMasterStyleAtomitems state no style level of their own, and itsSlideSchemeColorSchemeAtomis 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
NotesContaineris written for the notes master, andDocumentAtom.notesMasterPersistIdRefstays 0, so each notes slide inherits nothing (itsNotesAtom.slideFlagsis clear) and states the same fixed defaultSlideSchemeColorSchemeAtomthe master does rather than a scheme of the input's choosing. The notes page is the same size as the slide, becauseContentSlidecarries 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, andpageBreakBefore/pageBreakAfterhave no [MS-PPT] field this writer populates;alignment,list.level(as aTextPFExceptionindent level),spacingBeforePt/spacingAfterPt/lineSpacing/indentLeftPt/indentFirstLinePtround-trip. strike,sourcePath,source, andframes.ContentRun.strikehas noTextCFExceptionbit this writer sets (the format's ownCFMasks/CFStylecarry 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/constructEndpair (or any other non-paragraphblock kind) is excluded from the written text body exactly like an image or table block, per Writing a document. - Alignment values the shared schema has no name for. The mirror of the read-side gap:
Tx_ALIGNDistributed,Tx_ALIGNThaiDistributed, andTx_ALIGNJustifyLoware never written, sinceAlignmenthas no member naming them. - Fractional character sizes.
ContentRun.sizePtis rounded to the nearest whole point, sinceTextCFException'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.
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:
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, `decryptPptDocumentStrea
