feat(ppt-codec): read PowerPoint 97-2003 binary presentations ([MS-PPT]) - #846
Conversation
|
One thing that needs a person before this package can first release, per CONTRIBUTING's "Adding a package to the workspace" checklist step 6: |
bc37c9b to
338c6e6
Compare
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Confirmed, and it's not just ppt-codec — every new package from this legacy-format batch (doc-codec, wpd-codec, xls-codec, ppt-codec, rtf-codec) needs its own trusted-publisher registration before its first release, same as CONTRIBUTING step 6 describes. Tracking all five in one place: #850. |
|
@codex review |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
OIDC trusted publishing is now configured for all five packages (per #850, closed). |
…header A .ppt file's content is a tree of records rather than a flat stream, and one field carries the distinction: recVer 0xF marks a container whose data is more records, anything else an atom whose data is fields. Both spellings share the same 8-byte header, and so do the [MS-ODRAW] drawing records nested inside them, which is what lets a single walk cross the boundary between the two specifications. Records are located by their offset within the stream they came from, not by a copy of their bytes, because every cross-reference in the format is a stream offset: a persist directory entry, a UserEditAtom's offsetLastEdit, and a CurrentUserAtom's offsetToCurrentEdit all name a position in the PowerPoint Document stream, and a reader that had already sliced its records apart could no longer honour them.
…tory A .ppt file's PowerPoint Document stream is append-only across saves, so it can hold several generations of the same slide with nothing in a record itself saying which copy is current. The Current User stream's offsetToCurrentEdit names the newest UserEditAtom, that atom's chain walks back through every earlier edit, and each edit's PersistDirectoryAtom contributes persist-identifier-to-offset pairs. The directories are applied oldest first, so a newer edit's entry replaces an older one's for the same identifier. Applying them in the order they are found -- newest first -- would invert that and resurrect superseded objects. The chain needs no visited set to terminate: the spec requires each offsetLastEdit to be strictly less than its own atom's offset, so enforcing that makes every step move towards the start of the stream.
…tting A shape's text is one flat character array, stored either as UTF-16 (TextCharsAtom) or as one byte per character (TextBytesAtom), with a carriage return separating paragraphs inside it. The body's final paragraph mark is never written into the atom but is still counted by every formatting run, so the character count a StyleTextPropAtom is parsed against is one more than the stored text's length. StyleTextPropAtom's two run arrays carry paragraph and character properties, each as a mask word followed by only the fields its bits select. Those fields are read in the specification's declared field order rather than its mask-bit order: the two genuinely differ (bulletChar is bit 7 but is emitted before bulletFontRef at bit 4, and textAlignment at bit 11 before leftMargin at bit 8), so iterating the bits numerically would misalign every field after the first divergence. A property whose mask bit is clear stays undefined rather than becoming false, because an unstated property inherits from the master's text style rather than defaulting off.
…e systems A slide's DrawingContainer holds an [MS-ODRAW] shape tree whose group containers nest to arbitrary depth. A top-level shape's OfficeArtClientAnchor is already in slide coordinates, but a grouped shape's OfficeArtChildAnchor is stated in the private coordinate system its group's OfficeArtFSPGR declares, so its rectangle only means anything once every enclosing group's transform has been composed onto it. The patriarch -- the outermost group of every drawing -- is the exception the format's own structure creates rather than an assumption: it declares a degenerate coordinate system and no anchor, because its children are already in slide coordinates. The client anchor's payload is picked by its own recLen: 0x8 is a SmallRectStruct of 16-bit coordinates, 0x10 a RectStruct of 32-bit ones. Both order their fields top, left, right, bottom, not the left-first order the names suggest, while OfficeArtChildAnchor and OfficeArtFSPGR order theirs xLeft, yTop, xRight, yBottom -- reading either with the other's order silently transposes the rectangle.
…onts DocumentAtom carries the slide size in master units, and the environment's font collection carries the typeface names a character run's FontIndexRef indexes positionally. SlideListWithTextContainer carries both each slide's persist reference and, for a placeholder shape, that shape's text: a title or body's characters are genuinely absent from the slide's own drawing, so a reader that only walked slide drawings would report those shapes as empty. Its grammar is positional rather than nested -- a SlidePersistAtom opens a slide and a TextHeaderAtom opens a text, each owning every record until the next opener -- so it is read in a single pass with two current-item cursors. The master, slide and notes lists all carry RT_SlideListWithText and differ only by recInstance, whose values do not run in the order the names suggest (the slide list is 0x000 and the master list 0x001), so each is taken from its own specification page rather than inferred from the trio.
… model readPpt and readPptContent take a file's bytes through archive-codec's compound-file reader, the persist directory, and the slide walk, producing the same ContentSlide/ContentShape/ContentParagraph/ContentRun vocabulary ooxml.js's pptx reader and odf.js's odp reader produce -- so a .ppt reaches every consumer of that schema without a second representation of a slide existing anywhere. readPptStreams is the same read one level down, for a caller already holding the two streams, which is what lets every record-level behaviour be tested without a container around it. PowerPoint and the shared schema disagree structurally: PowerPoint stores a shape's text as one flat character array with formatting expressed as character-counted runs over it, while the schema stores paragraphs each owning their own runs. Turning one into the other is an intersection of two independent partitions of the same character range -- paragraphs by separator, formatting by run count. Geometry converts from master units (1/576 inch) to points on the way out, so slide sizes and shape frames are in the unit the schema uses everywhere else. The end-to-end suite builds a whole synthetic presentation and a real compound file from the specification's own field tables, so it exercises the actual offset arithmetic rather than a stubbed one; the workerd suite runs the same read inside a Cloudflare Workers isolate, which is what turns the isomorphism claim into a runtime-checked fact.
The package's README lists what each layer reads and, exhaustively, what it does not: no write path, no speaker notes, no document metadata, no master or layout style inheritance, no scheme colours, no per-shape text insets, no images, tables or OLE embeddings, and no decryption. Both lists are exhaustive rather than illustrative, so a caller can tell from that page alone whether the construct it needs is covered.
Adds the package to the root README's codec table and its Worker-isomorphic list, and to pnpm-workspace.yaml's minimumReleaseAgeExclude, without which pnpm install --frozen-lockfile can reject the package's own same-day release the moment a sibling bumps to depend on it.
Symlinks rather than copies, matching every sibling package: one file states the package's architecture and scope, and the agent-facing names resolve to it rather than drifting from it.
Every package states this in its own .gitignore rather than the root's, so a package that has not made the decision is never silently covered by someone else's.
[MS-ODRAW] 2.2.14 makes shapeProp a required field of every OfficeArtSpContainer, so a container without a readable OfficeArtFSP is malformed rather than a shape whose flags happen to be unknown. Reading the spid and the flags word separately let one of them answer 0 for a record the other would reject outright; reading them as one pair means the malformed case fails once, loudly, with a message naming what is missing.
Comparing the produced slides to a plain object proves the reader built what the suite expected; parsing them with ContentDocumentSchema and DocumentTreeSchema proves it built what document-schema.js actually requires. A missing ContentShape inset, or a slide without its required notes field, would satisfy the first check and fail this one.
5ef21b2 to
be1839b
Compare
Adds
packages/ppt-codec, a hand-written reader for the PowerPoint 97-2003 binary format ([MS-PPT]), producing the samedocument-schema.jspresentation content modelooxml.js's pptx support andodf.js's odp support already target. Part of #817 (and #85) — not the full read+write scope that issue asks for, so no closing keyword; see the honest scope below.Why the format needs this much machinery
Unlike Word's and Excel's binary formats, a
.pptfile's content is a tree of records inside an [MS-CFB] compound file, and the tree isn't the whole story:recVer == 0xFmarking a container and anything else an atom. That single field is what makes it a tree, and what lets an unknown record be skipped.PowerPoint Documentstream is append-only across saves, so it can hold several generations of the same slide. Which copy is live is decided by theCurrent Userstream'soffsetToCurrentEdit, theUserEditAtomchain it starts, and the persist directory those edits accumulate — later entries superseding earlier ones. Scanning the stream forRT_Sliderecords would find superseded slides with no way to tell them apart.OfficeArtClientTextboxholds anOutlineTextRefAtomindexing into the text records the document's slide list carries. Only a plain text box stores its own.What's actually implemented (read only)
Current User+PowerPoint Documentstreams viaarchive-codec's [MS-CFB] readerRecordHeader, container/atom split, sibling sequences, child walks, descendant searchCurrentUserAtom(incl. the encryptedheaderToken), theUserEditAtomchain,PersistDirectoryAtom's packed 20/12-bit run form, and the oldest-first directory build — [MS-PPT] 2.1.2's "live record" process, Part 1DocumentAtom(slide size), the font collection's typeface names,SlideListWithTextContainer(told apart from the master/notes lists byrecInstance, whose values don't run in the order the names suggest)SlidePersistAtom→ persist directory →SlideContainer, plus the placeholder textsOfficeArtSpgrContainer/OfficeArtSpContainertree,OfficeArtFSPflags,OfficeArtClientAnchorin both its 8- and 16-byte spellings, andOfficeArtChildAnchormapped through nestedOfficeArtFSPGRgroup coordinate systemsTextHeaderAtom,TextCharsAtom/TextBytesAtom,OutlineTextRefAtomindirection, paragraph split on the stored\rStyleTextPropAtom'sTextPFRun/TextCFRunarrays: indent level, alignment, bold/italic/underline/shadow/emboss, typeface reference, size, literal sRGB colourPublic surface:
readPpt(→DocumentTree),readPptContent(→ flat{ metadata, slides }), andreadPptStreamsfor a caller that already holds the two streams.Known gaps — deliberately not in this PR
.ppt; it does not produce it. That's the half of Add .ppt (PowerPoint Binary File Format) read+write support #817 still open.PptEncryptedError), not decrypted.notesis""; the notes list isn't walked yet.{}; properties live in the compound file'sSummaryInformationstream ([MS-OSHARED]), not in any [MS-PPT] record.TextMasterStyleAtom.ColorIndexStructnaming a scheme slot yields no colour (resolving it needs the slide's colour scheme).OfficeArtFOPToverride table isn't read.ExObjListContainerisn't walked.documents.js's conversion registry. The codec is standalone. Wiring it in adds a dependency edge at the top of the graph for a package still under active development, so it's deliberately a separate step.The package README states all of this in the same detail, under the family's "Status: Under active development" convention.
Testing
116 unit tests, 3 workerd tests, 37 smoke tests. There is no
.pptfile anywhere in the fixtures — every one is assembled byte by byte from [MS-PPT]'s own field-layout tables, including a whole synthetic presentation and a minimal [MS-CFB] writer, so the end-to-end suite exercises the real offset arithmetic (persist directory, edit chain, cross-stream references) rather than a stub. A fixture built from the spec states what the parser is held to; a captured file would only state what one producer happened to emit.Worker-isomorphic per the family convention (
isomorphic: true, nonode:*, noBuffer), proven at runtime by the workerd suite rather than asserted.Every field layout is cited in the source at the point it's used; the README lists the load-bearing specification pages.