Skip to content

Latest commit

 

History

History
352 lines (258 loc) · 84.7 KB

README.md

File metadata and controls

352 lines (258 loc) · 84.7 KB

ppt-codec

GitHub npm npm version CI

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, 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 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.

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

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 (#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 (#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), 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. 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 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).
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 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 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 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): 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.
  • 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.

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, decryptPptDocumentStream — [MS-OFFCRYPTO] 2.3.5 RC4 CryptoAPI decryption, wired against archive-codec's own key derivation (see 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).
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).
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): 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 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:

Install

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 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 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 now, as one implementation (ExaDev/documents.js#1282), so the module behind the ppt-codec/base64 deep import is gone.

Import from byte-codec directly:

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