diff --git a/README.md b/README.md index e9db989d8..9c916a34f 100644 --- a/README.md +++ b/README.md @@ -31,24 +31,24 @@ The packages layer from foundation up to user-facing interfaces. Each depends on Each converts one document format to and from the shared schema, built on `document-schema.js`: -| Package | Formats | -| ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [`ooxml.js`](packages/ooxml.js/README.md) | OOXML packages (docx, pptx, xlsx) to and from JSON. | -| [`odf.js`](packages/odf.js/README.md) | OpenDocument packages (odt, ods, odp) to and from JSON, plus read-only support for the pre-OASIS OpenOffice.org 1.x documents ODF was based on (sxw, sxc, sxi, sxd). | -| [`markdown-codec`](packages/markdown-codec/README.md) | CommonMark+GFM to and from the shared content schema. | -| [`pdf-codec`](packages/pdf-codec/README.md) | Parses arbitrary real-world PDFs and generates new ones, also depending on `byte-codec`. | -| [`epub-codec`](packages/epub-codec/README.md) | Flowable EPUB 2/3 to and from the shared content schema; writes EPUB 3 only. | -| [`rtf-codec`](packages/rtf-codec/README.md) | Rich Text Format to and from the shared content schema; hand-written tokenizer, destination state machine, and header-table parsers, since RTF is tokenised text rather than XML. | -| [`wpd-codec`](packages/wpd-codec/README.md) | WordPerfect 6.x-X6 (`.wpd`) to the shared content schema; read-only, and under active development. | -| [`doc-codec`](packages/doc-codec/README.md) | The pre-2007 Word Binary File Format (`.doc`, [MS-DOC]) to the shared content schema, also depending on `archive-codec` for its [MS-CFB] container. Read-only so far. | -| [`xls-codec`](packages/xls-codec/README.md) | Legacy Excel Binary File Format (`.xls`, BIFF8) to the shared content schema; read-only. | -| [`ppt-codec`](packages/ppt-codec/README.md) | PowerPoint 97-2003 binary presentations (`.ppt`, [MS-PPT]) to the shared content schema; read only, and under active development. | +| Package | Formats | +| ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`ooxml.js`](packages/ooxml.js/README.md) | OOXML packages (docx, pptx, xlsx) to and from JSON. | +| [`odf.js`](packages/odf.js/README.md) | OpenDocument packages (odt, ods, odp) to and from JSON, plus read-only support for the pre-OASIS OpenOffice.org 1.x documents ODF was based on (sxw, sxc, sxi, sxd). | +| [`markdown-codec`](packages/markdown-codec/README.md) | CommonMark+GFM to and from the shared content schema. | +| [`pdf-codec`](packages/pdf-codec/README.md) | Parses arbitrary real-world PDFs and generates new ones, also depending on `byte-codec`. | +| [`epub-codec`](packages/epub-codec/README.md) | Flowable EPUB 2/3 to and from the shared content schema; writes EPUB 3 only. | +| [`rtf-codec`](packages/rtf-codec/README.md) | Rich Text Format to and from the shared content schema; hand-written tokenizer, destination state machine, and header-table parsers, since RTF is tokenised text rather than XML. | +| [`wpd-codec`](packages/wpd-codec/README.md) | WordPerfect 6.x-X6 (`.wpd`) to the shared content schema; read-only, and under active development. | +| [`doc-codec`](packages/doc-codec/README.md) | The pre-2007 Word Binary File Format (`.doc`, [MS-DOC]) to the shared content schema, also depending on `archive-codec` for its [MS-CFB] container. Read-only so far. | +| [`xls-codec`](packages/xls-codec/README.md) | Legacy Excel Binary File Format (`.xls`, BIFF8) to the shared content schema; read-only. | +| [`ppt-codec`](packages/ppt-codec/README.md) | PowerPoint 97-2003 binary presentations (`.ppt`, [MS-PPT]) to and from the shared content schema, also depending on `archive-codec` for its [MS-CFB] container; the write side covers plain text-box slides only, and both directions remain under active development. | ### Conversion engine -| Package | What it is | -| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [`documents.js`](packages/documents.js/README.md) | Bidirectional docx/pptx to and from PDF conversion, and a read+write editable OOXML document model, built on `ooxml.js` and depending on every write-capable codec above plus `wpd-codec` as a read-only conversion source (routable everywhere but never a target, since wpd-codec ships no writer). `doc-codec`/`xls-codec`/`ppt-codec` remain unwired: they are read-only too, but nothing yet routes them the way `wpd-codec` now is. | +| Package | What it is | +| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`documents.js`](packages/documents.js/README.md) | Bidirectional docx/pptx to and from PDF conversion, and a read+write editable OOXML document model, built on `ooxml.js` and depending on every write-capable codec above plus `wpd-codec` as a read-only conversion source (routable everywhere but never a target, since wpd-codec ships no writer). `doc-codec`/`xls-codec`/`ppt-codec` remain unwired: nothing yet routes any of them the way `wpd-codec` now is, regardless of which of the three have gained their own write path. | ### Interfaces diff --git a/packages/ppt-codec/README.md b/packages/ppt-codec/README.md index 4639e2a23..dba82aa3d 100644 --- a/packages/ppt-codec/README.md +++ b/packages/ppt-codec/README.md @@ -2,13 +2,13 @@ [![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/documents.js/tree/main/packages/ppt-codec) [![npm](https://img.shields.io/badge/npm-CB3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/ppt-codec) [![npm version](https://img.shields.io/npm/v/ppt-codec)](https://www.npmjs.com/package/ppt-codec) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/documents.js/ci.yml?branch=main)](https://github.com/ExaDev/documents.js/actions) -> A hand-written reader for the PowerPoint 97-2003 binary file format (`.ppt`, [MS-PPT]), producing 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. +> A hand-written reader and writer for the PowerPoint 97-2003 binary file format (`.ppt`, [MS-PPT]), producing and consuming the same `document-schema.js` presentation content model `ooxml.js`'s pptx support and `odf.js`'s odp support both target. Worker-isomorphic: the same code runs under Node and inside a Cloudflare Workers isolate. Created for [documents.js#817](https://github.com/ExaDev/documents.js/issues/817), part of the legacy-binary-formats epic [#85](https://github.com/ExaDev/documents.js/issues/85). Nothing in the ecosystem read a pre-2007 PowerPoint file: `ooxml.js` reads the XML-based pptx that replaced it, and the two formats share no structure at all beyond both being containers. ## Status -**Under active development. The read path for slide text and geometry is built and tested; there is no write path.** What that means concretely is set out in [What it reads](#what-it-reads) and [What it does not read yet](#what-it-does-not-read-yet) below — both lists are exhaustive rather than illustrative, so a caller can tell from this page alone whether the format's own feature it cares about is covered. +**Under active development. The read path for slide text and geometry is built and tested. A narrower write path now exists too: one slide per input slide, with plain text-box shapes (basic character formatting, no images/tables/masters/layouts) — genuinely conformant [MS-PPT], verified by writing then reading every fixture back through this package's own reader, but not full read/write parity.** What that means concretely is set out in [What it reads](#what-it-reads)/[What it does not read yet](#what-it-does-not-read-yet) and [What it writes](#what-it-writes)/[What it does not write yet](#what-it-does-not-write-yet) below — every one of those four lists is exhaustive rather than illustrative, so a caller can tell from this page alone whether the format's own feature it cares about is covered. ## Why the format is shaped the way it is @@ -59,6 +59,22 @@ for (const slide of slides) { `readPptStreams(currentUserStream, powerPointDocumentStream)` 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. +## Writing a document + +```ts +import { writePpt, writePptContent } from "ppt-codec"; + +// The tree form: a document-schema.js DocumentTree in, real .ppt bytes out. +const pptBytes = writePpt(tree); + +// The flat form: metadata plus ContentSlide[] in -- metadata is accepted for +// symmetry with readPptContent's own return shape but is not written anywhere +// (see What it does not write yet). +const bytes = writePptContent({ metadata: {}, slides }); +``` + +`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 (an image, a table, a construct marker) is not an error — it is silently excluded from the written text body, the same documented-gap convention [What it does not read yet](#what-it-does-not-read-yet) already uses for the reader's own unsupported constructs. + ## What it reads The whole path from a file's first byte to a slide's text, record by record: @@ -80,7 +96,6 @@ Geometry is converted from master units (1/576 inch) to points on the way out, s 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: -- **Writing.** There is no write path at all: this package reads `.ppt` and does not produce it. - **Encrypted documents.** Recognised and refused by name (`PptEncryptedError`) rather than misparsed, but not decrypted. - **Speaker notes.** Every slide's `notes` is `""`. Notes live in their own `NotesContainer` persist objects reached through the document's notes list, which is not yet walked. - **Document metadata.** `metadata` is always `{}`. Document properties live in the compound file's own `SummaryInformation` stream ([MS-OSHARED]), not in any [MS-PPT] record. @@ -94,6 +109,42 @@ Each of these is a real construct of the format that this package currently igno - **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`. - **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 document container's and every slide container's stream offset — 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, and a `SlideListWithTextContainer` carrying one `SlidePersistAtom` per slide with no placeholder texts. | +| Slides | One `SlideContainer` per input slide, each holding its own `DrawingContainer`. | +| 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`) — no grouping, no `OfficeArtChildAnchor` nesting. | +| 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) 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. + +## 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: + +- **Images, tables, and OLE embeddings.** A shape whose blocks include an `image`, `table`, or `embeddedObject` block silently drops that block from the written text body — see [Writing a document](#writing-a-document) — rather than attempting a picture, table, or OLE object shape. +- **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, rotation, and any coordinate system beyond a plain `OfficeArtClientAnchor`.** Every shape this writer emits is an ungrouped, unrotated rectangle in slide coordinates; `ContentShape.rotationDeg` is not written, and there is no `OfficeArtChildAnchor`/`OfficeArtFSPGR` group nesting. +- **Per-shape text insets, autofit, and paint order.** `ContentShape.insetLeftPt`/`insetTopPt`/`insetRightPt`/`insetBottomPt`, `fontScale`, `lineSpacingReduction`, and `paintOrder` have no `OfficeArtFOPT` property table to land in, since this writer does not build one. +- **Masters, layouts, and scheme colours.** No `MainMaster`, no `MasterListWithTextContainer`, and no `SlideSchemeColorSchemeAtom`; every character run's colour must already be a literal, and every paragraph's formatting is exactly what the paragraph itself states. +- **Speaker notes and document metadata.** `PptDocument.metadata` is accepted (for symmetry with `readPptContent`'s own return shape) but never written anywhere; a slide's `notes` is likewise accepted and dropped, since neither `NotesContainer` persist objects nor the compound file's own `SummaryInformation` stream are built. +- **Hyperlinks, bullets, spacing, margins, and list numbering identity.** `ContentRun.hyperlink`, `ContentParagraph.list.numId`/`checked`/`itemId`, `spacingBeforePt`/`spacingAfterPt`/`lineSpacing`/`indentLeftPt`/`indentFirstLinePt`, and `pageBreakBefore`/`pageBreakAfter` have no [MS-PPT] field this writer populates; only `alignment` and `list.level` (as a `TextPFException` indent level) round-trip. +- **`strike`, `sourcePath`, `source`, and `frames`.** `ContentRun.strike` has no `TextCFException` bit this writer sets (the format's own `CFMasks`/`CFStyle` carry no strikethrough bit at all — a real gap in [MS-PPT], not a scope choice); the three fidelity/positioning fields are round-trip-irrelevant to a fresh write and are never populated. +- **Construct markers.** A `constructStart`/`constructEnd` pair (or any other non-`paragraph` block kind) is excluded from the written text body exactly like an image or table block, per [Writing a document](#writing-a-document). +- **Alignment values the shared schema has no name for.** The mirror of the read-side gap: `Tx_ALIGNDistributed`, `Tx_ALIGNThaiDistributed`, and `Tx_ALIGNJustifyLow` are never written, since `Alignment` has no member naming them. +- **Fractional character sizes.** `ContentRun.sizePt` is rounded to the nearest whole point, since `TextCFException`'s size field is a plain 16-bit integer. +- **Fonts, tables, animations, transitions, comments, and the metacharacter atoms.** Nothing here is written for the same reason none of it is read yet — see the corresponding entries in [What it does not read yet](#what-it-does-not-read-yet). + ## 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: @@ -103,37 +154,47 @@ 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. | -| `stream/current-user` | `CurrentUserAtom`: where the live edit is, and whether the file is encrypted. | -| `stream/persist` | `UserEditAtom`, `PersistDirectoryAtom`, and the persist directory the edit chain builds. | -| `document/document-atom` | `DocumentAtom`: slide and notes sizes, master persist references. | -| `document/fonts` | The font collection, resolved to typeface names a `FontIndexRef` indexes. | -| `document/slide-list` | `SlideListWithTextContainer`: each slide's persist reference and its placeholder texts. | -| `drawing/shapes` | The OfficeArt shape tree, flattened, with every anchor resolved into slide coordinates through its enclosing groups. | -| `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. | -| `content` | The mapping of PowerPoint's character-counted runs onto the schema's paragraph-owned runs. | -| `read` | The whole pipeline, and the `readPpt`/`readPptContent`/`readPptStreams` surface. | -| `units` | Master units to points. | -| `errors` | `PptFormatError` for malformed input, `PptEncryptedError` for well-formed input this package cannot decrypt. | +| 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. | +| `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. | +| `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. | +| `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 well-formed input this package cannot decrypt, `PptUnsupportedContentError` for well-formed content this package's writer cannot express. | ### Every fixture is built from the specification, not captured -There is no `.ppt` file anywhere in this package's tests. Every 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, 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. +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: `archive-codec` already owns bounded compound-file reading 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 — is hand-written against the published specification, the same bet every sibling codec here makes against a heavyweight format library. +The [MS-CFB] container beneath the format is the one piece not hand-written again, in either direction: `archive-codec` already owns bounded compound-file reading and conformant compound-file writing for the family, and a second implementation here would be exactly the duplication that package's extraction exists to prevent. Everything above it — the record tree, the persist layer, the OfficeArt walk, the text and formatting model, and their write-side mirrors — is hand-written against the published specification, the same bet every sibling codec here makes against a heavyweight format library. ## Conventions - Worker-isomorphic (see the [family-wide convention](../../README.md#conventions)): runtime `src/` must not import `node:*`, a bare Node builtin, or use the `Buffer` global — enforced by a `no-restricted-imports`/`no-restricted-globals` ESLint rule and exercised in CI by running a suite inside an actual `workerd` isolate (`pnpm test:workers`). Test files under `src/**/*.test.ts` and `src/test-support/` are exempt and may use Node APIs for fixtures. - Only `src/index.ts` may be named `index.*` — a custom ESLint rule (`local/no-non-barrel-index`) rejects any other module using an `index` basename, since that would be a hidden entry point the `exports` map in `package.json` doesn't advertise. -- Every structural failure throws `PptFormatError` rather than degrading: a malformed file fails whole, never returning a partial slide list that looks complete. +- 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 this writer cannot express throws `PptUnsupportedContentError` rather than silently substituting or dropping it — except a block kind outside this writer's scope (an image, a table, a construct marker), which is excluded from the written text body by design and documented as such, the same convention the reader already applies to its own unsupported constructs. ## Specification references @@ -151,7 +212,7 @@ Every field layout in this package is taken from a specification page, cited in - [[MS-PPT] 2.9.76: OfficeArtClientTextbox](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/f50070dd-a4dc-4edd-a446-c4fcc5c80ace), [TextHeaderAtom](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/08d31a66-0750-4009-b416-49f2871cd178), [TextCharsAtom](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/a3c5c8d5-e530-4167-a242-7743bc99aeac), [TextBytesAtom](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/80aae34b-2699-43fa-9e6a-c560ae790cd7) - [[MS-PPT]: StyleTextPropAtom](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/a9a5fa71-238d-491e-acc7-fa1fffd5f100), [TextPFException](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/c15a13b3-db2c-4b50-a7e6-08045581a663), [PFMasks](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/2a02831a-088b-44e7-84c9-c185ab314a71), [TextCFException](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/c75024a2-14cb-4d7d-9964-bdab2fcd9d93), [CFMasks](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/bbca8581-d011-4293-a375-b209523cf962), [CFStyle](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/3ea010b9-0ef9-4c05-9982-618130ca66cd), [ColorIndexStruct](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/5d6b0509-f3c7-435f-9bf4-6f1fc5f8293c) - [[MS-ODRAW]: Office Drawing Binary File Format](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-odraw/8560795e-7759-4745-838f-f7f2ef2f1872) — [OfficeArtSpContainer](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-odraw/16194cb9-b4b0-476c-9678-a6ac1f06b034), [OfficeArtFSP](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-odraw/8a7e7be3-0582-4461-9400-29d7eda8497d), [OfficeArtFSPGR](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-odraw/82d2d6a1-3a7a-4d15-9803-33145a76545a), [OfficeArtChildAnchor](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-odraw/33a44593-02df-4684-ab35-5a7c4a9bcaac) -- [[MS-CFB]: Compound File Binary File Format](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-cfb/53989ce4-7b05-4f8d-829b-d08d6148375b) — the container, read through `archive-codec` +- [[MS-CFB]: Compound File Binary File Format](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-cfb/53989ce4-7b05-4f8d-829b-d08d6148375b) — the container, read and written through `archive-codec` ## Install diff --git a/packages/ppt-codec/package.json b/packages/ppt-codec/package.json index 2f2206881..6d1e41934 100644 --- a/packages/ppt-codec/package.json +++ b/packages/ppt-codec/package.json @@ -1,7 +1,7 @@ { "name": "ppt-codec", "version": "1.0.2", - "description": "Hand-written PowerPoint 97-2003 binary (.ppt, [MS-PPT]) reader against the shared document-schema.js content pivot.", + "description": "Hand-written PowerPoint 97-2003 binary (.ppt, [MS-PPT]) reader and writer against the shared document-schema.js content pivot.", "type": "module", "repository": { "type": "git", diff --git a/packages/ppt-codec/src/content-write.test.ts b/packages/ppt-codec/src/content-write.test.ts new file mode 100644 index 000000000..7fe84ddef --- /dev/null +++ b/packages/ppt-codec/src/content-write.test.ts @@ -0,0 +1,157 @@ +import type { ContentBlock } from "document-schema.js"; +import { describe, expect, it } from "vitest"; +import { buildTextBody, collectFontFamilies } from "./content-write"; +import { ALIGN_CENTER, ALIGN_LEFT } from "./text/style"; + +const noFonts = (): never => { + throw new Error("no font family expected in this test"); +}; + +describe("buildTextBody", () => { + it("joins several paragraphs' text with the carriage-return separator", () => { + const blocks: ContentBlock[] = [ + { kind: "paragraph", runs: [{ text: "one" }] }, + { kind: "paragraph", runs: [{ text: "two" }] }, + ]; + expect(buildTextBody(blocks, noFonts).text).toBe("one\rtwo"); + }); + + it("silently excludes a non-paragraph block from the text body", () => { + const blocks: ContentBlock[] = [ + { kind: "paragraph", runs: [{ text: "kept" }] }, + { kind: "pageBreak" }, + ]; + expect(buildTextBody(blocks, noFonts).text).toBe("kept"); + }); + + it("gives each paragraph a PFRun whose count covers its own text plus one terminator character", () => { + const blocks: ContentBlock[] = [ + { kind: "paragraph", runs: [{ text: "abc" }] }, + { kind: "paragraph", runs: [{ text: "de" }] }, + ]; + const { style } = buildTextBody(blocks, noFonts); + expect(style.paragraphRuns.map((run) => run.count)).toEqual([4, 3]); + }); + + it("sums every character run's count to exactly the text body's own character count", () => { + const blocks: ContentBlock[] = [ + { + kind: "paragraph", + runs: [{ text: "bold", bold: true }, { text: "plain" }], + }, + { kind: "paragraph", runs: [{ text: "next" }] }, + ]; + const { text, style } = buildTextBody(blocks, noFonts); + const total = style.characterRuns.reduce((sum, run) => sum + run.count, 0); + // +1 for the implicit trailing terminator character [MS-PPT]'s own worked example states every text body carries. + expect(total).toBe(text.length + 1); + }); + + it("emits a single zero-property character run for a paragraph with no runs", () => { + const blocks: ContentBlock[] = [{ kind: "paragraph", runs: [] }]; + const { style } = buildTextBody(blocks, noFonts); + expect(style.characterRuns).toEqual([ + { + count: 1, + properties: { + bold: undefined, + italic: undefined, + underline: undefined, + shadow: undefined, + emboss: undefined, + fontRef: undefined, + sizePt: undefined, + color: undefined, + }, + }, + ]); + }); + + it("maps alignment and list level onto the paragraph run's properties", () => { + const blocks: ContentBlock[] = [ + { kind: "paragraph", runs: [{ text: "x" }], alignment: "center" }, + { kind: "paragraph", runs: [{ text: "y" }], list: { level: 3 } }, + ]; + const { style } = buildTextBody(blocks, noFonts); + expect(style.paragraphRuns[0]?.properties).toEqual({ + indentLevel: 0, + alignment: ALIGN_CENTER, + }); + expect(style.paragraphRuns[1]?.properties).toEqual({ + indentLevel: 3, + alignment: undefined, + }); + }); + + it("leaves alignment undefined for a paragraph that states none", () => { + const blocks: ContentBlock[] = [ + { kind: "paragraph", runs: [{ text: "x" }] }, + ]; + expect( + buildTextBody(blocks, noFonts).style.paragraphRuns[0]?.properties, + ).toEqual({ indentLevel: 0, alignment: undefined }); + }); + + it("resolves a run's fontFamily through the supplied index resolver", () => { + const blocks: ContentBlock[] = [ + { kind: "paragraph", runs: [{ text: "x", fontFamily: "Arial" }] }, + ]; + const { style } = buildTextBody(blocks, (family) => { + expect(family).toBe("Arial"); + return 4; + }); + expect(style.characterRuns[0]?.properties.fontRef).toBe(4); + }); + + it("converts a colour's 0-1 float components to 0-255 integer bytes", () => { + const blocks: ContentBlock[] = [ + { + kind: "paragraph", + runs: [ + { text: "x", color: { r: 0x33 / 255, g: 0x66 / 255, b: 0x99 / 255 } }, + ], + }, + ]; + const { style } = buildTextBody(blocks, noFonts); + expect(style.characterRuns[0]?.properties.color).toEqual({ + red: 0x33, + green: 0x66, + blue: 0x99, + }); + }); + + it("maps 'left' to the zero-valued alignment enumerant", () => { + const blocks: ContentBlock[] = [ + { kind: "paragraph", runs: [{ text: "x" }], alignment: "left" }, + ]; + expect( + buildTextBody(blocks, noFonts).style.paragraphRuns[0]?.properties + .alignment, + ).toBe(ALIGN_LEFT); + }); +}); + +describe("collectFontFamilies", () => { + it("collects every distinct family in first-seen order across several block lists", () => { + const first: ContentBlock[] = [ + { kind: "paragraph", runs: [{ text: "a", fontFamily: "Arial" }] }, + ]; + const second: ContentBlock[] = [ + { + kind: "paragraph", + runs: [ + { text: "b", fontFamily: "Verdana" }, + { text: "c", fontFamily: "Arial" }, + ], + }, + ]; + expect(collectFontFamilies([first, second])).toEqual(["Arial", "Verdana"]); + }); + + it("returns an empty list when no run names a font", () => { + const blocks: ContentBlock[] = [ + { kind: "paragraph", runs: [{ text: "x" }] }, + ]; + expect(collectFontFamilies([blocks])).toEqual([]); + }); +}); diff --git a/packages/ppt-codec/src/content-write.ts b/packages/ppt-codec/src/content-write.ts new file mode 100644 index 000000000..6b0682a37 --- /dev/null +++ b/packages/ppt-codec/src/content-write.ts @@ -0,0 +1,153 @@ +import type { + Alignment, + Color, + ContentBlock, + ContentParagraph, + ContentRun, +} from "document-schema.js"; +import { + ALIGN_CENTER, + ALIGN_JUSTIFY, + ALIGN_LEFT, + ALIGN_RIGHT, + type CharacterProperties, + type ParagraphProperties, + type RgbColor, + type StyleRun, + type StyleTextProps, +} from "./text/style"; +import { LINE_BREAK, PARAGRAPH_SEPARATOR } from "./text/atoms"; + +// The write-side mirror of content.ts: given a shape's ContentBlock list, produces the flat character-counted text body and the StyleTextProps runs [MS-PPT]'s StyleTextPropAtom carries alongside it -- the inverse of content.ts's buildParagraphs, which turns that same pairing back into ContentParagraph[]. Only 'paragraph' blocks contribute text; every other ContentBlock kind (image, table, embeddedObject, pageBreak, the two construct markers) is silently excluded from the written text body, the same documented-gap convention the reader's own README already uses for constructs it does not surface -- ppt-codec's writer covers text-box slides, not the full ContentBlock vocabulary. + +const BYTE_MAX = 255; + +function mapAlignmentToPpt( + alignment: Alignment | undefined, +): number | undefined { + switch (alignment) { + case "left": + return ALIGN_LEFT; + case "center": + return ALIGN_CENTER; + case "right": + return ALIGN_RIGHT; + case "justify": + return ALIGN_JUSTIFY; + default: + return undefined; + } +} + +function mapColorToPpt(color: Color | undefined): RgbColor | undefined { + if (color === undefined) { + return undefined; + } + return { + red: Math.round(color.r * BYTE_MAX), + green: Math.round(color.g * BYTE_MAX), + blue: Math.round(color.b * BYTE_MAX), + }; +} + +// A run's own text, with the schema's soft-line-break spelling ('\n') converted back to the stored codepoint (text/atoms.ts's LINE_BREAK, U+000B) -- the exact inverse of splitParagraphs' `.split(LINE_BREAK).join("\n")`. +function storedRunText(text: string): string { + return text.split("\n").join(LINE_BREAK); +} + +function characterPropertiesFrom( + run: ContentRun, + fontIndexOf: (family: string) => number, +): CharacterProperties { + return { + bold: run.bold, + italic: run.italic, + underline: run.underline, + shadow: undefined, + emboss: undefined, + fontRef: + run.fontFamily === undefined ? undefined : fontIndexOf(run.fontFamily), + sizePt: run.sizePt, + color: mapColorToPpt(run.color), + }; +} + +const EMPTY_CHARACTER_PROPERTIES: CharacterProperties = { + bold: undefined, + italic: undefined, + underline: undefined, + shadow: undefined, + emboss: undefined, + fontRef: undefined, + sizePt: undefined, + color: undefined, +}; + +export interface TextBody { + readonly text: string; + readonly style: StyleTextProps; +} + +// Builds one shape's whole text body and its StyleTextPropAtom runs from its ContentBlock list. Each paragraph's own PFRun/CFRun coverage is (paragraph text length) + 1: the extra character accounts for the paragraph's own trailing '\r' separator (every paragraph but the last) or the implicit final terminator characterCountOf's own comment describes ([MS-PPT]'s worked example: "a text body length of 22 because of the terminating line break character") -- attached to the last character run of each paragraph (or a zero-content synthetic run, for a paragraph with none), so the sum of every paragraph's contribution is exactly characterCountOf(text) with no separate accounting pass needed. +export function buildTextBody( + blocks: readonly ContentBlock[], + fontIndexOf: (family: string) => number, +): TextBody { + const paragraphs = blocks.filter( + (block): block is ContentParagraph => block.kind === "paragraph", + ); + const bodies = paragraphs.map((paragraph) => + paragraph.runs.map((run) => storedRunText(run.text)).join(""), + ); + const text = bodies.join(PARAGRAPH_SEPARATOR); + + const paragraphRuns: StyleRun[] = []; + const characterRuns: StyleRun[] = []; + + paragraphs.forEach((paragraph, index) => { + const bodyText = bodies[index] ?? ""; + paragraphRuns.push({ + count: bodyText.length + 1, + properties: { + indentLevel: paragraph.list?.level ?? 0, + alignment: mapAlignmentToPpt(paragraph.alignment), + }, + }); + + if (paragraph.runs.length === 0) { + characterRuns.push({ count: 1, properties: EMPTY_CHARACTER_PROPERTIES }); + return; + } + paragraph.runs.forEach((run, runIndex) => { + const isLastRunOfParagraph = runIndex === paragraph.runs.length - 1; + characterRuns.push({ + count: storedRunText(run.text).length + (isLastRunOfParagraph ? 1 : 0), + properties: characterPropertiesFrom(run, fontIndexOf), + }); + }); + }); + + return { text, style: { paragraphRuns, characterRuns } }; +} + +// Every distinct fontFamily a block's runs name, in first-seen order -- the order buildTextBody's fontIndexOf callback (built once per document, over every slide's every shape) must resolve against, matching the order the document's own FontCollectionContainer is written in. +export function collectFontFamilies( + blocksList: readonly (readonly ContentBlock[])[], +): string[] { + const seen = new Set(); + const names: string[] = []; + for (const blocks of blocksList) { + for (const block of blocks) { + if (block.kind !== "paragraph") { + continue; + } + for (const run of block.runs) { + if (run.fontFamily !== undefined && !seen.has(run.fontFamily)) { + seen.add(run.fontFamily); + names.push(run.fontFamily); + } + } + } + } + return names; +} diff --git a/packages/ppt-codec/src/document/document-atom-write.ts b/packages/ppt-codec/src/document/document-atom-write.ts new file mode 100644 index 000000000..175dba254 --- /dev/null +++ b/packages/ppt-codec/src/document/document-atom-write.ts @@ -0,0 +1,28 @@ +import type { PageSize } from "document-schema.js"; +import { concatBytes, i32le, u16le, u32le, writeAtom } from "../record/write"; +import { RT_DocumentAtom } from "../record/types"; +import { pointsToMasterUnits } from "../units"; + +// The write-side mirror of readDocumentAtom: [MS-PPT] 2.4.2's 40-byte DocumentAtom, recVer 0x1. https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/121f2728-3497-4a0a-829e-6f416fee2ee6 + +export function writeDocumentAtom(size: PageSize): Uint8Array { + const width = pointsToMasterUnits(size.widthPt); + const height = pointsToMasterUnits(size.heightPt); + return writeAtom( + RT_DocumentAtom, + concatBytes( + i32le(width), + i32le(height), // slideSize + i32le(width), + i32le(height), // notesSize -- this writer has no separate notes geometry to state (see the README's write-scope note on speaker notes), so it mirrors the slide size rather than stating a value nothing produced + i32le(1), + i32le(2), // serverZoom RatioStruct -- an OLE presentation zoom hint read.ts's DocumentAtom interface carries but never projects into anything this writer's own output depends on + u32le(0), // notesMasterPersistIdRef -- no master is written (see README write-scope note) + u32le(0), // handoutMasterPersistIdRef -- likewise + u16le(1), // firstSlideNumber + u16le(0), // slideSizeType: 0 = on-screen show, [MS-PPT] 2.13.28 SlideSizeTypeEnum + new Uint8Array(4), // fSaveWithFonts/fOmitTitlePlace/fRightToLeft/fShowComments bool1 flags, all false + ), + { recVer: 0x1 }, + ); +} diff --git a/packages/ppt-codec/src/document/document.test.ts b/packages/ppt-codec/src/document/document.test.ts index 142627bea..20e38d38a 100644 --- a/packages/ppt-codec/src/document/document.test.ts +++ b/packages/ppt-codec/src/document/document.test.ts @@ -15,14 +15,14 @@ import { } from "../record/types"; import { asciiBytes, - atom, concatBytes, - container, i32le, u16le, u32le, utf16le, -} from "../test-support/records"; + writeAtom as atom, + writeContainer as container, +} from "../record/write"; import { readDocumentAtom } from "./document-atom"; import { readFontNames } from "./fonts"; import { readSlideListWithText } from "./slide-list"; diff --git a/packages/ppt-codec/src/document/fonts-write.ts b/packages/ppt-codec/src/document/fonts-write.ts new file mode 100644 index 000000000..4584bc280 --- /dev/null +++ b/packages/ppt-codec/src/document/fonts-write.ts @@ -0,0 +1,39 @@ +import { + concatBytes, + utf16le, + writeAtom, + writeContainer, +} from "../record/write"; +import { + RT_Environment, + RT_FontCollection, + RT_FontEntityAtom, +} from "../record/types"; + +// The write-side mirror of document/fonts.ts's readFontNames: one FontEntityAtom per document-wide typeface name, in the order a FontIndexRef indexes them by. [MS-PPT] FontCollectionContainer: https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/88da04bf-6838-4f87-9a87-adf067543837 [MS-PPT] FontEntityAtom: https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/b5946b70-2fbc-4f7b-a119-b31fcbeb1794 + +// [MS-PPT] FontEntityAtom's fixed 64-byte lfFaceName field -- matches fonts.ts's own FACE_NAME_BYTES. A name longer than the field can hold (31 UTF-16 code units plus a terminating null) is truncated to fit, the same lossy edge the reader's own 64-byte read already imposes on the way back. +const FACE_NAME_FIELD_BYTES = 64; + +function writeFontEntityAtom(faceName: string): Uint8Array { + const nameField = new Uint8Array(FACE_NAME_FIELD_BYTES); + const encoded = utf16le(faceName); + nameField.set(encoded.subarray(0, FACE_NAME_FIELD_BYTES - 2)); + // The 4 bytes following lfFaceName (panose/clipPrecision/quality/pitchAndFamily in a real producer's own FontEntityAtom) are left zero: readFaceName never reads past the name field, so nothing here depends on their value. + return writeAtom( + RT_FontEntityAtom, + concatBytes(nameField, new Uint8Array(4)), + ); +} + +// The document's Environment container holding its font collection, or undefined when no run in the document names a font family at all -- matching the reader's own tolerance of a missing Environment (readFontNames on an absent one already yields []). +export function writeEnvironment( + fontNames: readonly string[], +): Uint8Array | undefined { + if (fontNames.length === 0) { + return undefined; + } + return writeContainer(RT_Environment, [ + writeContainer(RT_FontCollection, fontNames.map(writeFontEntityAtom)), + ]); +} diff --git a/packages/ppt-codec/src/document/slide-list-write.ts b/packages/ppt-codec/src/document/slide-list-write.ts new file mode 100644 index 000000000..9db2ee1b8 --- /dev/null +++ b/packages/ppt-codec/src/document/slide-list-write.ts @@ -0,0 +1,42 @@ +import { + concatBytes, + i32le, + u32le, + writeAtom, + writeContainer, +} from "../record/write"; +import { + RT_SlideListWithText, + RT_SlidePersistAtom, + SLIDE_LIST_INSTANCE_SLIDES, +} from "../record/types"; + +// The write-side mirror of document/slide-list.ts's readSlideListWithText, narrowed to what this writer actually needs: one SlidePersistAtom per slide, naming that slide's persist reference and slide id, with no placeholder texts. Every shape this writer emits carries its own text directly on its OfficeArtClientTextbox (drawing/shapes-write.ts) rather than through the OutlineTextRefAtom indirection into this list -- so cTexts is always 0, and readSlideListWithText's own text-grouping loop (which only fires on a TextHeaderAtom appearing in this container) never finds one. [MS-PPT] 2.4.14.3 SlideListWithTextContainer: https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/307e6d12-7304-47a8-acbd-3e7b8041ad3c [MS-PPT] 2.4.14.5 SlidePersistAtom: https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/48dce412-9692-4f93-aeb7-3d9fdd3a0a5a + +export interface SlidePersistRef { + readonly persistIdRef: number; + readonly slideId: number; +} + +function writeSlidePersistAtom(ref: SlidePersistRef): Uint8Array { + return writeAtom( + RT_SlidePersistAtom, + concatBytes( + u32le(ref.persistIdRef), + u32le(0), // fShouldCollapse/fNonOutlineData flags -- never set by this writer + i32le(0), // cTexts -- always 0, see the module comment + u32le(ref.slideId), + u32le(0), // reserved + ), + ); +} + +export function writeSlideListWithText( + slides: readonly SlidePersistRef[], +): Uint8Array { + return writeContainer( + RT_SlideListWithText, + slides.map(writeSlidePersistAtom), + { recInstance: SLIDE_LIST_INSTANCE_SLIDES }, + ); +} diff --git a/packages/ppt-codec/src/drawing/shapes-write.ts b/packages/ppt-codec/src/drawing/shapes-write.ts new file mode 100644 index 000000000..77c620035 --- /dev/null +++ b/packages/ppt-codec/src/drawing/shapes-write.ts @@ -0,0 +1,135 @@ +import type { ContentShape } from "document-schema.js"; +import { buildTextBody } from "../content-write"; +import { + concatBytes, + i32le, + u32le, + utf16le, + writeAtom, + writeContainer, +} from "../record/write"; +import { + OfficeArtClientAnchor, + OfficeArtClientTextbox, + OfficeArtDgContainer, + OfficeArtFSP, + OfficeArtFSPGR, + OfficeArtSpContainer, + OfficeArtSpgrContainer, + RT_Drawing, + RT_TextCharsAtom, + RT_TextHeaderAtom, +} from "../record/types"; +import { TEXT_TYPE_OTHER, characterCountOf } from "../text/atoms"; +import { writeStyleTextPropAtom } from "../text/style-write"; +import { pointsToMasterUnits } from "../units"; + +// The write-side mirror of drawing/shapes.ts: given a slide's ContentShape list, emits the [MS-ODRAW]/[MS-PPT] shape tree readDrawingShapes flattens back into PptShape[] -- one outermost patriarch group (the same fGroup|fPatriarch placeholder shape collectGroup/groupTransform special-case on read) followed by one plain OfficeArtSpContainer per content shape, each carrying a client anchor in slide coordinates and, when the shape has text, an OfficeArtClientTextbox. Deliberately narrower than the read side's own coverage: every shape this writer emits is a plain, ungrouped text box in slide coordinates (an OfficeArtClientAnchor, never OfficeArtChildAnchor/OfficeArtFSPGR group nesting) -- see the package README's write-scope section. + +// [MS-ODRAW] 2.2.40 OfficeArtFSP's flags word -- the same two bits drawing/shapes.ts's FSP_GROUP/FSP_PATRIARCH name for reading. +const FSP_GROUP = 1 << 0; +const FSP_PATRIARCH = 1 << 2; +// The patriarch's own shape id is always 1 ([MS-ODRAW] does not mandate this, but every real producer's outermost group shape is spid 1, and nothing in this reader's own drawing/shapes.ts inspects spid values at all -- see PptShape.spid's read-side comment); content shapes are numbered from 2, uniquely per slide, which is all readDrawingShapes/collectShape ever need of an spid. +const PATRIARCH_SPID = 1; +const FIRST_CONTENT_SPID = 2; + +function writeFsp(spid: number, flags: number): Uint8Array { + return writeAtom(OfficeArtFSP, concatBytes(u32le(spid), u32le(flags)), { + recVer: 0x2, + }); +} + +// [MS-PPT] 2.7.1 OfficeArtClientAnchor: written as the 16-byte RectStruct form (recLen 0x10, four signed 32-bit coordinates) rather than the 8-byte SmallRectStruct -- unlike a captured real file, this writer has no reason to prefer the smaller form, and the 32-bit range removes any risk of a large slide's master-unit coordinates overflowing a 16-bit one. Field order matches readClientAnchor's "top-left" spelling: top, left, right, bottom. +function writeClientAnchor( + xPt: number, + yPt: number, + widthPt: number, + heightPt: number, +): Uint8Array { + const left = pointsToMasterUnits(xPt); + const top = pointsToMasterUnits(yPt); + const right = pointsToMasterUnits(xPt + widthPt); + const bottom = pointsToMasterUnits(yPt + heightPt); + return writeAtom( + OfficeArtClientAnchor, + concatBytes(i32le(top), i32le(left), i32le(right), i32le(bottom)), + ); +} + +function writeTextCharsAtom(text: string): Uint8Array { + return writeAtom(RT_TextCharsAtom, utf16le(text)); +} + +// The shape's own text, or undefined when it carries no paragraph block at all -- matching the reader's own optional clientTextbox rather than emitting an empty one nothing wrote. +function writeClientTextbox( + shape: ContentShape, + fontIndexOf: (family: string) => number, +): Uint8Array | undefined { + const hasParagraph = shape.blocks.some((block) => block.kind === "paragraph"); + if (!hasParagraph) { + return undefined; + } + const { text, style } = buildTextBody(shape.blocks, fontIndexOf); + // characterCountOf/the run counts buildTextBody produced must agree, or the atom this writes could never be read back by readStyleTextPropAtom's own characterCount-driven termination -- asserted here rather than trusted, since it is the one invariant the whole run-count design in content-write.ts depends on. + const totalParagraphCount = style.paragraphRuns.reduce( + (sum, run) => sum + run.count, + 0, + ); + if (totalParagraphCount !== characterCountOf(text)) { + throw new Error( + `internal error: built ${totalParagraphCount} characters of paragraph runs for a ${characterCountOf(text)}-character text body`, + ); + } + return writeContainer(OfficeArtClientTextbox, [ + writeAtom(RT_TextHeaderAtom, u32le(TEXT_TYPE_OTHER)), + writeTextCharsAtom(text), + writeStyleTextPropAtom(style), + ]); +} + +function writeShape( + spid: number, + shape: ContentShape, + fontIndexOf: (family: string) => number, +): Uint8Array { + const clientTextbox = writeClientTextbox(shape, fontIndexOf); + const children = [ + writeFsp(spid, 0), + writeClientAnchor( + shape.frame.xPt, + shape.frame.yPt, + shape.frame.widthPt, + shape.frame.heightPt, + ), + ]; + if (clientTextbox !== undefined) { + children.push(clientTextbox); + } + return writeContainer(OfficeArtSpContainer, children); +} + +// The outermost group every real drawing carries: an OfficeArtSpContainer holding only an OfficeArtFSPGR (a degenerate coordinate system, never read for the patriarch -- groupTransform returns the parent transform unchanged whenever FSP_PATRIARCH is set) and an FSP with fGroup|fPatriarch set. [MS-ODRAW] 2.2.16: "the first child of a group container is always the OfficeArtSpContainer holding that group's own shape information" -- collectGroup relies on this exact position. +function writePatriarch(): Uint8Array { + return writeContainer(OfficeArtSpContainer, [ + writeAtom(OfficeArtFSPGR, new Uint8Array(16), { recVer: 0x1 }), + writeFsp(PATRIARCH_SPID, FSP_GROUP | FSP_PATRIARCH), + ]); +} + +// One slide's whole DrawingContainer: a single OfficeArtDgContainer holding one OfficeArtSpgrContainer (the patriarch group plus every content shape as its siblings) -- the same shape readDrawingShapes' top-level walk expects (one OfficeArtSpgrContainer collected via collectGroup, IDENTITY transform). +export function writeSlideDrawing( + shapes: readonly ContentShape[], + fontIndexOf: (family: string) => number, +): Uint8Array { + const shapeContainers = shapes.map((shape, index) => + writeShape(FIRST_CONTENT_SPID + index, shape, fontIndexOf), + ); + return writeContainer(RT_Drawing, [ + writeContainer(OfficeArtDgContainer, [ + writeContainer(OfficeArtSpgrContainer, [ + writePatriarch(), + ...shapeContainers, + ]), + ]), + ]); +} diff --git a/packages/ppt-codec/src/drawing/shapes.test.ts b/packages/ppt-codec/src/drawing/shapes.test.ts index b871a7dda..2f402077d 100644 --- a/packages/ppt-codec/src/drawing/shapes.test.ts +++ b/packages/ppt-codec/src/drawing/shapes.test.ts @@ -13,13 +13,13 @@ import { } from "../record/types"; import { asciiBytes, - atom, concatBytes, - container, i16le, i32le, u32le, -} from "../test-support/records"; + writeAtom as atom, + writeContainer as container, +} from "../record/write"; import { readDrawingShapes } from "./shapes"; // OfficeArtFSPGR ([MS-ODRAW] 2.2.38): recVer 0x1, recLen 0x10, then xLeft/yTop/xRight/yBottom as signed 32-bit integers. https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-odraw/82d2d6a1-3a7a-4d15-9803-33145a76545a diff --git a/packages/ppt-codec/src/errors.ts b/packages/ppt-codec/src/errors.ts index cf06e65b1..530464a76 100644 --- a/packages/ppt-codec/src/errors.ts +++ b/packages/ppt-codec/src/errors.ts @@ -13,3 +13,11 @@ export class PptEncryptedError extends Error { this.name = "PptEncryptedError"; } } + +// Thrown on the write path when the caller's content asks for something ppt-codec's writer cannot express: a document that is not a presentation, slides that do not share the one slide size [MS-PPT]'s DocumentAtom states for the whole presentation, or similar. Distinct from both errors above -- the input is neither malformed bytes (PptFormatError) nor an unreadable-but-valid file (PptEncryptedError), it is well-formed content whose shape sits outside this writer's deliberately narrower scope, documented in the README's write-scope section. A block kind the writer does not represent (an image, a table, a construct marker) is not this error: it is silently dropped from the written text body, the same documented-gap convention the reader already uses for its own unsupported constructs. +export class PptUnsupportedContentError extends Error { + constructor(message: string) { + super(message); + this.name = "PptUnsupportedContentError"; + } +} diff --git a/packages/ppt-codec/src/index.ts b/packages/ppt-codec/src/index.ts index fe491e6ae..fc0f62435 100644 --- a/packages/ppt-codec/src/index.ts +++ b/packages/ppt-codec/src/index.ts @@ -1,16 +1,26 @@ -// The PowerPoint 97-2003 binary file format ([MS-PPT]) reader for the documents.js family: the compound-file stream layer, the record-tree walk over the PowerPoint Document stream, and the mapping of slide text and geometry onto document-schema.js's shared presentation content model. Worker-isomorphic -- no node:*, no Buffer -- so the same code runs under Node and inside a Cloudflare Workers isolate. +// The PowerPoint 97-2003 binary file format ([MS-PPT]) reader and writer for the documents.js family: the compound-file stream layer, the record-tree walk over the PowerPoint Document stream, and the mapping of slide text and geometry onto document-schema.js's shared presentation content model. Worker-isomorphic -- no node:*, no Buffer -- so the same code runs under Node and inside a Cloudflare Workers isolate. export * from "./content"; +export * from "./content-write"; export * from "./document/document-atom"; +export * from "./document/document-atom-write"; export * from "./document/fonts"; +export * from "./document/fonts-write"; export * from "./document/slide-list"; +export * from "./document/slide-list-write"; export * from "./drawing/shapes"; +export * from "./drawing/shapes-write"; export * from "./errors"; export * from "./read"; export * from "./record/header"; export * from "./record/tree"; export * from "./record/types"; +export * from "./record/write"; export * from "./stream/current-user"; +export * from "./stream/current-user-write"; export * from "./stream/persist"; +export * from "./stream/persist-write"; export * from "./text/atoms"; export * from "./text/style"; +export * from "./text/style-write"; export * from "./units"; +export * from "./write"; diff --git a/packages/ppt-codec/src/record/tree.test.ts b/packages/ppt-codec/src/record/tree.test.ts index bb83bc810..b3f015dbe 100644 --- a/packages/ppt-codec/src/record/tree.test.ts +++ b/packages/ppt-codec/src/record/tree.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from "vitest"; import { PptFormatError } from "../errors"; -import { atom, concatBytes, container, u32le } from "../test-support/records"; import { RECORD_HEADER_SIZE } from "./header"; +import { + concatBytes, + u32le, + writeAtom as atom, + writeContainer as container, +} from "./write"; import { childRecords, findChild, diff --git a/packages/ppt-codec/src/test-support/records.ts b/packages/ppt-codec/src/record/write.ts similarity index 68% rename from packages/ppt-codec/src/test-support/records.ts rename to packages/ppt-codec/src/record/write.ts index b2377997c..fd2dde06f 100644 --- a/packages/ppt-codec/src/test-support/records.ts +++ b/packages/ppt-codec/src/record/write.ts @@ -1,6 +1,8 @@ -import { CONTAINER_REC_VER, RECORD_HEADER_SIZE } from "../record/header"; +import { CONTAINER_REC_VER, RECORD_HEADER_SIZE } from "./header"; -// Byte builders for the unit suites: every fixture in this package is a hand-constructed record tree assembled from [MS-PPT] 2.3.1's own header layout, not a captured binary. That is deliberate -- a fixture built from the spec's field tables states what the parser is being held to, whereas a real .ppt file would only state what one producer happened to emit, and could not be reduced to the single record under test. +// The write-side mirror of record/header.ts and record/tree.ts: byte primitives and the atom/container builders every writer module in this package composes records from. [MS-PPT] 2.3.1 RecordHeader: https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/df201194-0cd0-4dfb-bf10-eea353d8eabc +// +// Every test fixture in this package used to hand-build its own copy of these builders under src/test-support/records.ts; that module is gone now and every test imports writeAtom/writeContainer (aliased to the shorter atom/container names it always used) directly from here instead, so a fixture and a genuinely written file are assembled by identical code with no second copy to drift. export function concatBytes( ...parts: readonly Uint8Array[] @@ -43,7 +45,7 @@ export function i32le(value: number): Uint8Array { return bytes; } -// Each byte is one character's code unit, the way [MS-PPT]'s TextBytesAtom and PrintableAnsiString both store text. Written as an indexed loop rather than a spread or split, which would decompose by code point or UTF-16 unit and mean something different for text outside the ASCII range these fixtures use. +// Each byte is one character's code unit, the way [MS-PPT]'s PrintableAnsiString stores text -- an indexed loop rather than a spread or split, which would decompose by code point or UTF-16 unit and mean something different for text outside the ASCII range this function is used for (the writer's own hardcoded producer user name). export function asciiBytes(text: string): Uint8Array { const bytes = new Uint8Array(text.length); for (let i = 0; i < text.length; i++) { @@ -61,7 +63,7 @@ export function utf16le(text: string): Uint8Array { return bytes; } -interface HeaderOptions { +export interface RecordWriteOptions { readonly recVer?: number; readonly recInstance?: number; } @@ -81,10 +83,10 @@ function recordHeaderBytes( } // An atom record: an 8-byte header whose recLen is the payload's own length, followed by the payload. -export function atom( +export function writeAtom( recType: number, data: Uint8Array, - options: HeaderOptions = {}, + options: RecordWriteOptions = {}, ): Uint8Array { const { recVer = 0x0, recInstance = 0x000 } = options; return concatBytes( @@ -94,10 +96,10 @@ export function atom( } // A container record: recVer 0xF, and a recLen covering every child's header plus data. -export function container( +export function writeContainer( recType: number, children: readonly Uint8Array[], - options: Omit = {}, + options: Omit = {}, ): Uint8Array { const { recInstance = 0x000 } = options; const body = concatBytes(...children); diff --git a/packages/ppt-codec/src/stream/current-user-write.ts b/packages/ppt-codec/src/stream/current-user-write.ts new file mode 100644 index 000000000..3fc88056d --- /dev/null +++ b/packages/ppt-codec/src/stream/current-user-write.ts @@ -0,0 +1,44 @@ +import { + asciiBytes, + concatBytes, + u8, + u16le, + u32le, + utf16le, + writeAtom, +} from "../record/write"; +import { RT_CurrentUserAtom } from "../record/types"; +import { + CURRENT_USER_DOC_FILE_VERSION, + CURRENT_USER_FIXED_SIZE, + CURRENT_USER_HEADER_TOKEN_PLAIN, +} from "./current-user"; + +// The write-side mirror of readCurrentUserAtom: the sole record of the "Current User" stream. [MS-PPT] 2.3.2: https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/940d5700-e4d7-4fc0-ab48-fed5dbc48bc1 + +// Written into both the ansiUserName and unicodeUserName fields -- cosmetic provenance a reader never inspects for anything this package projects into content, so a fixed producer name rather than a caller-supplied one keeps writePptStreams' own signature free of a parameter nothing downstream needs. +const PRODUCER_USER_NAME = "documents.js"; +// [MS-PPT] 2.3.2 relVersion: "This field is unused and MUST be ignored." Written as 8 (matching this package's own synthetic-presentation fixture) since the value is unconstrained. +const REL_VERSION = 0x00000008; + +export function writeCurrentUserAtom( + offsetToCurrentEdit: number, +): Uint8Array { + const ansiUserName = asciiBytes(PRODUCER_USER_NAME); + return writeAtom( + RT_CurrentUserAtom, + concatBytes( + u32le(CURRENT_USER_FIXED_SIZE), + u32le(CURRENT_USER_HEADER_TOKEN_PLAIN), + u32le(offsetToCurrentEdit), + u16le(ansiUserName.length), + u16le(CURRENT_USER_DOC_FILE_VERSION), + u8(0x03), // release + u8(0x00), // build + u16le(0), // padding out CURRENT_USER_FIXED_SIZE's 20-byte fixed portion + ansiUserName, + u32le(REL_VERSION), + utf16le(PRODUCER_USER_NAME), + ), + ); +} diff --git a/packages/ppt-codec/src/stream/current-user.test.ts b/packages/ppt-codec/src/stream/current-user.test.ts index e143ee8b6..aec6a7a4a 100644 --- a/packages/ppt-codec/src/stream/current-user.test.ts +++ b/packages/ppt-codec/src/stream/current-user.test.ts @@ -3,13 +3,13 @@ import { PptFormatError } from "../errors"; import { RT_CurrentUserAtom } from "../record/types"; import { asciiBytes, - atom, concatBytes, u8, u16le, u32le, utf16le, -} from "../test-support/records"; + writeAtom as atom, +} from "../record/write"; import { CURRENT_USER_HEADER_TOKEN_ENCRYPTED, CURRENT_USER_HEADER_TOKEN_PLAIN, diff --git a/packages/ppt-codec/src/stream/current-user.ts b/packages/ppt-codec/src/stream/current-user.ts index 2c69aabf0..68eb8370f 100644 --- a/packages/ppt-codec/src/stream/current-user.ts +++ b/packages/ppt-codec/src/stream/current-user.ts @@ -10,9 +10,9 @@ export const CURRENT_USER_HEADER_TOKEN_PLAIN = 0xe391c05f; export const CURRENT_USER_HEADER_TOKEN_ENCRYPTED = 0xf3d1c4df; // [MS-PPT] 2.3.2 size: "It MUST be 0x00000014." -- the 20 bytes from size through unused, the portion preceding the variable-length ansiUserName. -const CURRENT_USER_FIXED_SIZE = 0x00000014; -// [MS-PPT] 2.3.2 docFileVersion: "It MUST be 0x03F4." -const CURRENT_USER_DOC_FILE_VERSION = 0x03f4; +export const CURRENT_USER_FIXED_SIZE = 0x00000014; +// [MS-PPT] 2.3.2 docFileVersion: "It MUST be 0x03F4." Exported so stream/current-user-write.ts's writeCurrentUserAtom stamps the identical mandated value this reader checks for, rather than a second copy of the same constant. +export const CURRENT_USER_DOC_FILE_VERSION = 0x03f4; export interface CurrentUser { readonly offsetToCurrentEdit: number; diff --git a/packages/ppt-codec/src/stream/persist-write.ts b/packages/ppt-codec/src/stream/persist-write.ts new file mode 100644 index 000000000..cec1366aa --- /dev/null +++ b/packages/ppt-codec/src/stream/persist-write.ts @@ -0,0 +1,54 @@ +import { concatBytes, u8, u16le, u32le, writeAtom } from "../record/write"; +import { RT_PersistDirectoryAtom, RT_UserEditAtom } from "../record/types"; + +// The write-side mirror of stream/persist.ts: a single-edit persist layer, since this writer never appends an incremental edit -- every persist object it writes is stated once, by one UserEditAtom pointing at one PersistDirectoryAtom that covers the whole document in its first (and only) edit. [MS-PPT] 2.3.3 UserEditAtom: https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/3ffb3fab-95de-4873-98aa-d508fbbac981 [MS-PPT] 2.3.4 PersistDirectoryAtom: https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/d10a093d-860f-409c-b065-aeb24b830505 [MS-PPT] 2.3.5 PersistDirectoryEntry: https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/6214b5a6-7ca2-4a86-8a0e-5fd3d3eff1c9 + +export interface PersistDirectoryEntryToWrite { + readonly persistId: number; + readonly offset: number; +} + +// One PersistDirectoryEntry per input entry (cPersist always 1), rather than packing contiguous persist ids into fewer, wider runs the way a real producer's incremental edits naturally would: a fixed cPersist of 1 needs no packed-run bookkeeping, has no ceiling to hit (the packed cPersist field is only 12 bits), and readPersistDirectoryAtom already reads a directory with as many entries as it is given -- one run per entry is exactly as valid a directory as one run per contiguous group. +export function writePersistDirectoryAtom( + entries: readonly PersistDirectoryEntryToWrite[], +): Uint8Array { + const data = concatBytes( + ...entries.map((entry) => + concatBytes( + // persistId occupies bits 0-19, cPersist bits 20-31 of one little-endian word -- see stream/persist.ts's own readPersistDirectoryAtom comment on the shift-and-mask split this mirrors. + u32le((entry.persistId & 0xfffff) | (1 << 20)), + u32le(entry.offset), + ), + ), + ); + return writeAtom(RT_PersistDirectoryAtom, data); +} + +export interface UserEditFields { + readonly lastSlideIdRef: number; + readonly offsetLastEdit: number; + readonly offsetPersistDirectory: number; + readonly docPersistIdRef: number; + readonly persistIdSeed: number; +} + +// [MS-PPT] 2.3.3's 0x1C-byte (no encryptSessionPersistIdRef) UserEditAtom -- version/minorVersion/majorVersion stamped with the same PowerPoint-97-2003 values (0, 0x00, 0x03) this package's own synthetic-presentation fixtures already use, since the reader ignores the field entirely and no other value would change what round-trips. +export function writeUserEditAtom( + fields: UserEditFields, +): Uint8Array { + return writeAtom( + RT_UserEditAtom, + concatBytes( + u32le(fields.lastSlideIdRef), + u16le(0), // version + u8(0x00), // minorVersion + u8(0x03), // majorVersion + u32le(fields.offsetLastEdit), + u32le(fields.offsetPersistDirectory), + u32le(fields.docPersistIdRef), + u32le(fields.persistIdSeed), + u16le(0), // lastView + u16le(0), // unused + ), + ); +} diff --git a/packages/ppt-codec/src/stream/persist.test.ts b/packages/ppt-codec/src/stream/persist.test.ts index e5ac04f64..d06009c42 100644 --- a/packages/ppt-codec/src/stream/persist.test.ts +++ b/packages/ppt-codec/src/stream/persist.test.ts @@ -2,7 +2,13 @@ import { describe, expect, it } from "vitest"; import { PptFormatError } from "../errors"; import { readRecordAt } from "../record/tree"; import { RT_PersistDirectoryAtom, RT_UserEditAtom } from "../record/types"; -import { atom, concatBytes, u8, u16le, u32le } from "../test-support/records"; +import { + concatBytes, + u8, + u16le, + u32le, + writeAtom as atom, +} from "../record/write"; import { buildPersistDirectory, readPersistDirectoryAtom, diff --git a/packages/ppt-codec/src/test-support/presentation.ts b/packages/ppt-codec/src/test-support/presentation.ts index 0fdb089df..95a9cd854 100644 --- a/packages/ppt-codec/src/test-support/presentation.ts +++ b/packages/ppt-codec/src/test-support/presentation.ts @@ -23,20 +23,20 @@ import { RT_UserEditAtom, SLIDE_LIST_INSTANCE_SLIDES, } from "../record/types"; -import { CURRENT_USER_HEADER_TOKEN_PLAIN } from "../stream/current-user"; -import { TEXT_TYPE_BODY, TEXT_TYPE_TITLE } from "../text/atoms"; import { asciiBytes, - atom, concatBytes, - container, i16le, i32le, u8, u16le, u32le, utf16le, -} from "./records"; + writeAtom as atom, + writeContainer as container, +} from "../record/write"; +import { CURRENT_USER_HEADER_TOKEN_PLAIN } from "../stream/current-user"; +import { TEXT_TYPE_BODY, TEXT_TYPE_TITLE } from "../text/atoms"; // A whole synthetic presentation: the two [MS-PPT] streams of a one-slide document carrying a title placeholder (whose text lives in the document's slide list, reached by an OutlineTextRefAtom) and a plain text box (whose text lives on the shape). Assembled from the same record builders the per-record suites use, so the end-to-end test exercises the real offset arithmetic -- the persist directory, the edit chain, and every cross-stream reference -- rather than a stubbed one. diff --git a/packages/ppt-codec/src/text/atoms.test.ts b/packages/ppt-codec/src/text/atoms.test.ts index 96408537f..85fb4d45d 100644 --- a/packages/ppt-codec/src/text/atoms.test.ts +++ b/packages/ppt-codec/src/text/atoms.test.ts @@ -7,11 +7,11 @@ import { } from "../record/types"; import { asciiBytes, - atom, concatBytes, u32le, utf16le, -} from "../test-support/records"; + writeAtom as atom, +} from "../record/write"; import { TEXT_TYPE_BODY, TEXT_TYPE_TITLE, diff --git a/packages/ppt-codec/src/text/style-write.test.ts b/packages/ppt-codec/src/text/style-write.test.ts new file mode 100644 index 000000000..5e1b7315b --- /dev/null +++ b/packages/ppt-codec/src/text/style-write.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; +import { readRecordAt } from "../record/tree"; +import { + ALIGN_CENTER, + type StyleTextProps, + readStyleTextPropAtom, +} from "./style"; +import { writeStyleTextPropAtom } from "./style-write"; + +// A direct round trip through this package's own reader: write a StyleTextPropAtom, read it back, and assert the recovered value equals the one written -- the same verification method write.test.ts uses at the whole-file level, applied here to the one record whose byte layout (masks, then fields in the spec's declared order rather than mask-bit order) is the most likely place a write/read mismatch would hide. + +function roundTrip( + style: StyleTextProps, + characterCount: number, +): StyleTextProps { + const bytes = writeStyleTextPropAtom(style); + return readStyleTextPropAtom(readRecordAt(bytes, 0), characterCount); +} + +// The spec's own termination rule (style.ts's readRuns: "The sum of the count fields ... MUST be equal to the number of characters") applies independently to BOTH run arrays -- a StyleTextPropAtom with paragraph runs covering the whole character count but no character runs at all is malformed, not merely incomplete. Tests that only care about the paragraph-run side still need a character run covering the same span. +function noCharacterProperties() { + return { + bold: undefined, + italic: undefined, + underline: undefined, + shadow: undefined, + emboss: undefined, + fontRef: undefined, + sizePt: undefined, + color: undefined, + }; +} + +describe("writeStyleTextPropAtom", () => { + it("round-trips a paragraph run's alignment", () => { + const style: StyleTextProps = { + paragraphRuns: [ + { count: 5, properties: { indentLevel: 0, alignment: ALIGN_CENTER } }, + ], + characterRuns: [{ count: 5, properties: noCharacterProperties() }], + }; + expect(roundTrip(style, 5).paragraphRuns).toEqual(style.paragraphRuns); + }); + + it("round-trips a paragraph run stating no alignment at all", () => { + const style: StyleTextProps = { + paragraphRuns: [ + { count: 5, properties: { indentLevel: 2, alignment: undefined } }, + ], + characterRuns: [{ count: 5, properties: noCharacterProperties() }], + }; + expect(roundTrip(style, 5).paragraphRuns).toEqual(style.paragraphRuns); + }); + + it("round-trips several paragraph runs covering the whole character count", () => { + const style: StyleTextProps = { + paragraphRuns: [ + { count: 3, properties: { indentLevel: 0, alignment: undefined } }, + { count: 4, properties: { indentLevel: 1, alignment: undefined } }, + ], + characterRuns: [{ count: 7, properties: noCharacterProperties() }], + }; + expect(roundTrip(style, 7).paragraphRuns).toEqual(style.paragraphRuns); + }); + + it("round-trips a character run's bold/italic/underline flags", () => { + const style: StyleTextProps = { + paragraphRuns: [ + { count: 4, properties: { indentLevel: 0, alignment: undefined } }, + ], + characterRuns: [ + { + count: 4, + properties: { + bold: true, + italic: false, + underline: true, + shadow: undefined, + emboss: undefined, + fontRef: undefined, + sizePt: undefined, + color: undefined, + }, + }, + ], + }; + expect(roundTrip(style, 4).characterRuns).toEqual(style.characterRuns); + }); + + it("round-trips a character run stating no font-style flags at all", () => { + const style: StyleTextProps = { + paragraphRuns: [ + { count: 3, properties: { indentLevel: 0, alignment: undefined } }, + ], + characterRuns: [ + { + count: 3, + properties: { + bold: undefined, + italic: undefined, + underline: undefined, + shadow: undefined, + emboss: undefined, + fontRef: undefined, + sizePt: undefined, + color: undefined, + }, + }, + ], + }; + expect(roundTrip(style, 3).characterRuns).toEqual(style.characterRuns); + }); + + it("round-trips a character run's font reference, size, and literal colour", () => { + const style: StyleTextProps = { + paragraphRuns: [ + { count: 3, properties: { indentLevel: 0, alignment: undefined } }, + ], + characterRuns: [ + { + count: 3, + properties: { + bold: undefined, + italic: undefined, + underline: undefined, + shadow: undefined, + emboss: undefined, + fontRef: 2, + sizePt: 18, + color: { red: 0x11, green: 0x22, blue: 0x33 }, + }, + }, + ], + }; + expect(roundTrip(style, 3).characterRuns).toEqual(style.characterRuns); + }); + + it("round-trips several character runs covering the whole character count", () => { + const style: StyleTextProps = { + paragraphRuns: [ + { count: 6, properties: { indentLevel: 0, alignment: undefined } }, + ], + characterRuns: [ + { + count: 3, + properties: { + bold: true, + italic: undefined, + underline: undefined, + shadow: undefined, + emboss: undefined, + fontRef: undefined, + sizePt: undefined, + color: undefined, + }, + }, + { + count: 3, + properties: { + bold: undefined, + italic: true, + underline: undefined, + shadow: undefined, + emboss: undefined, + fontRef: undefined, + sizePt: undefined, + color: undefined, + }, + }, + ], + }; + expect(roundTrip(style, 6).characterRuns).toEqual(style.characterRuns); + }); +}); diff --git a/packages/ppt-codec/src/text/style-write.ts b/packages/ppt-codec/src/text/style-write.ts new file mode 100644 index 000000000..c8416191a --- /dev/null +++ b/packages/ppt-codec/src/text/style-write.ts @@ -0,0 +1,119 @@ +import { concatBytes, i16le, u16le, u32le, writeAtom } from "../record/write"; +import { RT_StyleTextPropAtom } from "../record/types"; +import { + CF_BOLD, + CF_COLOR, + CF_ITALIC, + CF_SIZE, + CF_TYPEFACE, + CF_UNDERLINE, + COLOR_INDEX_SRGB, + type CharacterProperties, + PF_ALIGN, + type ParagraphProperties, + type RgbColor, + STYLE_BOLD, + STYLE_ITALIC, + STYLE_UNDERLINE, + type StyleRun, + type StyleTextProps, +} from "./style"; + +// The write-side mirror of text/style.ts's readStyleTextPropAtom: given the same StyleRun/StyleRun arrays the reader produces, emits a real StyleTextPropAtom whose bytes that reader parses back to an equal value. Every field is written in the identical declared order style.ts's own comment states the reader uses (masks, then each optional field in spec order) -- the two functions are inverses of literally the same byte layout, not independently derived from the spec a second time. + +function writeColorIndexStruct(color: RgbColor): Uint8Array { + return new Uint8Array([color.red, color.green, color.blue, COLOR_INDEX_SRGB]); +} + +// A TextPFException carrying only the one field this writer ever states: textAlignment. Every other PFMasks field (bullets, margins, spacing, tab stops, wrapping, direction) is left unset, which round-trips as "the format did not say" through the reader's own undefined-on-unset-mask behaviour -- exactly the same absence a run whose writer never set the bit already produces for those fields today. +function writeTextPFException( + properties: ParagraphProperties, +): Uint8Array { + if (properties.alignment === undefined) { + return u32le(0); + } + return concatBytes(u32le(PF_ALIGN), u16le(properties.alignment)); +} + +function writeTextCFException( + properties: CharacterProperties, +): Uint8Array { + let masks = 0; + let fontStyle = 0; + const hasFontStyle = + properties.bold !== undefined || + properties.italic !== undefined || + properties.underline !== undefined; + if (properties.bold !== undefined) { + masks |= CF_BOLD; + if (properties.bold) { + fontStyle |= STYLE_BOLD; + } + } + if (properties.italic !== undefined) { + masks |= CF_ITALIC; + if (properties.italic) { + fontStyle |= STYLE_ITALIC; + } + } + if (properties.underline !== undefined) { + masks |= CF_UNDERLINE; + if (properties.underline) { + fontStyle |= STYLE_UNDERLINE; + } + } + if (properties.fontRef !== undefined) { + masks |= CF_TYPEFACE; + } + if (properties.sizePt !== undefined) { + masks |= CF_SIZE; + } + if (properties.color !== undefined) { + masks |= CF_COLOR; + } + + // Field order matches readTextCFException exactly: masks, fontStyle, fontRef, [oldEA/ansi/symbol typeface -- never written], sizePt, color, [position -- never written]. + const fields: Uint8Array[] = [u32le(masks)]; + if (hasFontStyle) { + fields.push(u16le(fontStyle)); + } + if (properties.fontRef !== undefined) { + fields.push(u16le(properties.fontRef)); + } + if (properties.sizePt !== undefined) { + fields.push(i16le(Math.round(properties.sizePt))); + } + if (properties.color !== undefined) { + fields.push(writeColorIndexStruct(properties.color)); + } + return concatBytes(...fields); +} + +function writeParagraphRun( + run: StyleRun, +): Uint8Array { + return concatBytes( + u32le(run.count), + u16le(run.properties.indentLevel), + writeTextPFException(run.properties), + ); +} + +function writeCharacterRun( + run: StyleRun, +): Uint8Array { + return concatBytes(u32le(run.count), writeTextCFException(run.properties)); +} + +// Emits a real StyleTextPropAtom: the paragraph run array followed by the character run array, each run's count field first and its exception payload after -- the identical layout readStyleTextPropAtom parses. +export function writeStyleTextPropAtom( + style: StyleTextProps, +): Uint8Array { + return writeAtom( + RT_StyleTextPropAtom, + concatBytes( + ...style.paragraphRuns.map(writeParagraphRun), + ...style.characterRuns.map(writeCharacterRun), + ), + ); +} diff --git a/packages/ppt-codec/src/text/style.test.ts b/packages/ppt-codec/src/text/style.test.ts index 9188910f3..09b11772f 100644 --- a/packages/ppt-codec/src/text/style.test.ts +++ b/packages/ppt-codec/src/text/style.test.ts @@ -3,13 +3,13 @@ import { PptFormatError } from "../errors"; import { readRecordAt } from "../record/tree"; import { RT_StyleTextPropAtom } from "../record/types"; import { - atom, concatBytes, i16le, u8, u16le, u32le, -} from "../test-support/records"; + writeAtom as atom, +} from "../record/write"; import { ALIGN_CENTER, ALIGN_RIGHT, readStyleTextPropAtom } from "./style"; // Mask bit positions written as raw shifts here, straight from the spec's own bit tables, rather than imported from the implementation: a test asserting against the constants the parser reads would pass even if both were wrong together. PFMasks ([MS-PPT] 2.9.x): https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/2a02831a-088b-44e7-84c9-c185ab314a71 diff --git a/packages/ppt-codec/src/text/style.ts b/packages/ppt-codec/src/text/style.ts index 2c0a64507..267e6ed0e 100644 --- a/packages/ppt-codec/src/text/style.ts +++ b/packages/ppt-codec/src/text/style.ts @@ -13,55 +13,55 @@ export const ALIGN_DISTRIBUTED = 0x0004; export const ALIGN_THAI_DISTRIBUTED = 0x0005; export const ALIGN_JUSTIFY_LOW = 0x0006; -// PFMasks bit positions, in the spec's own A-to-Z order. Each bit says whether its field is present in the TextPFException that follows -- never what the field's value is. -const PF_HAS_BULLET = 1 << 0; -const PF_BULLET_HAS_FONT = 1 << 1; -const PF_BULLET_HAS_COLOR = 1 << 2; -const PF_BULLET_HAS_SIZE = 1 << 3; -const PF_BULLET_FONT = 1 << 4; -const PF_BULLET_COLOR = 1 << 5; -const PF_BULLET_SIZE = 1 << 6; -const PF_BULLET_CHAR = 1 << 7; -const PF_LEFT_MARGIN = 1 << 8; -const PF_INDENT = 1 << 10; -const PF_ALIGN = 1 << 11; -const PF_LINE_SPACING = 1 << 12; -const PF_SPACE_BEFORE = 1 << 13; -const PF_SPACE_AFTER = 1 << 14; -const PF_DEFAULT_TAB_SIZE = 1 << 15; -const PF_FONT_ALIGN = 1 << 16; -const PF_CHAR_WRAP = 1 << 17; -const PF_WORD_WRAP = 1 << 18; -const PF_OVERFLOW = 1 << 19; -const PF_TAB_STOPS = 1 << 20; -const PF_TEXT_DIRECTION = 1 << 21; +// PFMasks bit positions, in the spec's own A-to-Z order. Each bit says whether its field is present in the TextPFException that follows -- never what the field's value is. Exported (rather than kept private to this module) because style-write.ts's writeTextPFException sets the identical bits when serialising a property back to bytes -- one definition read and written by both directions rather than a second copy that could drift from this one. +export const PF_HAS_BULLET = 1 << 0; +export const PF_BULLET_HAS_FONT = 1 << 1; +export const PF_BULLET_HAS_COLOR = 1 << 2; +export const PF_BULLET_HAS_SIZE = 1 << 3; +export const PF_BULLET_FONT = 1 << 4; +export const PF_BULLET_COLOR = 1 << 5; +export const PF_BULLET_SIZE = 1 << 6; +export const PF_BULLET_CHAR = 1 << 7; +export const PF_LEFT_MARGIN = 1 << 8; +export const PF_INDENT = 1 << 10; +export const PF_ALIGN = 1 << 11; +export const PF_LINE_SPACING = 1 << 12; +export const PF_SPACE_BEFORE = 1 << 13; +export const PF_SPACE_AFTER = 1 << 14; +export const PF_DEFAULT_TAB_SIZE = 1 << 15; +export const PF_FONT_ALIGN = 1 << 16; +export const PF_CHAR_WRAP = 1 << 17; +export const PF_WORD_WRAP = 1 << 18; +export const PF_OVERFLOW = 1 << 19; +export const PF_TAB_STOPS = 1 << 20; +export const PF_TEXT_DIRECTION = 1 << 21; -// CFMasks bit positions, in the spec's own A-to-Z order. fHasStyle occupies bits 10-13 and unused4 bits 14-15, which is why the typeface group starts at bit 16 rather than 14. -const CF_BOLD = 1 << 0; -const CF_ITALIC = 1 << 1; -const CF_UNDERLINE = 1 << 2; -const CF_SHADOW = 1 << 4; -const CF_FEHINT = 1 << 5; -const CF_KUMI = 1 << 7; -const CF_EMBOSS = 1 << 9; -const CF_HAS_STYLE = 0xf << 10; -const CF_TYPEFACE = 1 << 16; -const CF_SIZE = 1 << 17; -const CF_COLOR = 1 << 18; -const CF_POSITION = 1 << 19; -const CF_OLD_EA_TYPEFACE = 1 << 21; -const CF_ANSI_TYPEFACE = 1 << 22; -const CF_SYMBOL_TYPEFACE = 1 << 23; +// CFMasks bit positions, in the spec's own A-to-Z order. fHasStyle occupies bits 10-13 and unused4 bits 14-15, which is why the typeface group starts at bit 16 rather than 14. Exported for the same reason the PFMasks bits above are: style-write.ts's writeTextCFException is the write-side mirror of readTextCFException and sets these identical bits. +export const CF_BOLD = 1 << 0; +export const CF_ITALIC = 1 << 1; +export const CF_UNDERLINE = 1 << 2; +export const CF_SHADOW = 1 << 4; +export const CF_FEHINT = 1 << 5; +export const CF_KUMI = 1 << 7; +export const CF_EMBOSS = 1 << 9; +export const CF_HAS_STYLE = 0xf << 10; +export const CF_TYPEFACE = 1 << 16; +export const CF_SIZE = 1 << 17; +export const CF_COLOR = 1 << 18; +export const CF_POSITION = 1 << 19; +export const CF_OLD_EA_TYPEFACE = 1 << 21; +export const CF_ANSI_TYPEFACE = 1 << 22; +export const CF_SYMBOL_TYPEFACE = 1 << 23; -// CFStyle value bits, which share the low ten positions of CFMasks by construction -- the mask says a property is stated, the style says what it is. -const STYLE_BOLD = 1 << 0; -const STYLE_ITALIC = 1 << 1; -const STYLE_UNDERLINE = 1 << 2; -const STYLE_SHADOW = 1 << 4; -const STYLE_EMBOSS = 1 << 9; +// CFStyle value bits, which share the low ten positions of CFMasks by construction -- the mask says a property is stated, the style says what it is. Exported for the same reason the mask bits above are. +export const STYLE_BOLD = 1 << 0; +export const STYLE_ITALIC = 1 << 1; +export const STYLE_UNDERLINE = 1 << 2; +export const STYLE_SHADOW = 1 << 4; +export const STYLE_EMBOSS = 1 << 9; -// ColorIndexStruct.index: the one value meaning the struct's red/green/blue bytes are a literal colour rather than a slot in the slide's colour scheme. https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/5d6b0509-f3c7-435f-9bf4-6f1fc5f8293c -const COLOR_INDEX_SRGB = 0xfe; +// ColorIndexStruct.index: the one value meaning the struct's red/green/blue bytes are a literal colour rather than a slot in the slide's colour scheme. https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/5d6b0509-f3c7-435f-9bf4-6f1fc5f8293c Exported so style-write.ts's writeColorIndexStruct writes the identical sentinel readColorIndexStruct below checks for. +export const COLOR_INDEX_SRGB = 0xfe; export interface RgbColor { readonly red: number; diff --git a/packages/ppt-codec/src/units.ts b/packages/ppt-codec/src/units.ts index fa4872c5b..e93dc1364 100644 --- a/packages/ppt-codec/src/units.ts +++ b/packages/ppt-codec/src/units.ts @@ -6,3 +6,8 @@ export const MASTER_UNITS_PER_POINT = MASTER_UNITS_PER_INCH / POINTS_PER_INCH; export function masterUnitsToPoints(masterUnits: number): number { return masterUnits / MASTER_UNITS_PER_POINT; } + +// The write-side inverse. Master units are always integral (the format has no fractional unit), so the conversion rounds rather than truncating -- truncation would systematically shrink every converted geometry by up to one master unit (1/576 inch), a bias rounding does not have. +export function pointsToMasterUnits(points: number): number { + return Math.round(points * MASTER_UNITS_PER_POINT); +} diff --git a/packages/ppt-codec/src/write.test.ts b/packages/ppt-codec/src/write.test.ts new file mode 100644 index 000000000..1f464ee09 --- /dev/null +++ b/packages/ppt-codec/src/write.test.ts @@ -0,0 +1,609 @@ +import { + ContentDocumentSchema, + DocumentTreeSchema, + type ContentDocument, + type ContentSlide, + assembleTree, + flattenTree, +} from "document-schema.js"; +import { describe, expect, it } from "vitest"; +import { PptUnsupportedContentError } from "./errors"; +import { readPptContent, readPpt } from "./read"; +import { writePpt, writePptContent, writePptStreams } from "./write"; + +// The primary verification method this package's own README already establishes for its record fixtures: write real records, then read them back through the package's own existing reader, and assert the recovered content equals what was written. A round trip through readPptContent proves the writer's bytes are genuinely conformant [MS-PPT] -- not merely internally self-consistent -- because the reader was built and tested entirely independently of the writer, against the specification alone. + +function slide(overrides: Partial = {}): ContentSlide { + return { + size: { widthPt: 720, heightPt: 540 }, + shapes: [], + notes: "", + ...overrides, + }; +} + +describe("writePptContent / readPptContent round trip", () => { + it("round-trips a single slide with a single plain-text paragraph", () => { + const document = { + metadata: {}, + slides: [ + slide({ + shapes: [ + { + frame: { xPt: 72, yPt: 36, widthPt: 360, heightPt: 180 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + blocks: [ + { + kind: "paragraph" as const, + runs: [{ text: "Hello, PowerPoint" }], + }, + ], + }, + ], + }), + ], + }; + + const bytes = writePptContent(document); + const { metadata, slides } = readPptContent(bytes); + + expect(metadata).toEqual({}); + expect(slides).toHaveLength(1); + expect(slides[0]?.size).toEqual({ widthPt: 720, heightPt: 540 }); + expect(slides[0]?.notes).toBe(""); + expect(slides[0]?.shapes).toHaveLength(1); + expect(slides[0]?.shapes[0]?.frame).toEqual({ + xPt: 72, + yPt: 36, + widthPt: 360, + heightPt: 180, + }); + expect(slides[0]?.shapes[0]?.blocks).toEqual([ + { kind: "paragraph", runs: [{ text: "Hello, PowerPoint" }] }, + ]); + }); + + it("round-trips several paragraphs, splitting on the carriage-return separator", () => { + const document = { + metadata: {}, + slides: [ + slide({ + shapes: [ + { + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + blocks: [ + { kind: "paragraph" as const, runs: [{ text: "First point" }] }, + { + kind: "paragraph" as const, + runs: [{ text: "Second point" }], + }, + { kind: "paragraph" as const, runs: [{ text: "Third point" }] }, + ], + }, + ], + }), + ], + }; + + const { slides } = readPptContent(writePptContent(document)); + expect(slides[0]?.shapes[0]?.blocks).toEqual([ + { kind: "paragraph", runs: [{ text: "First point" }] }, + { kind: "paragraph", runs: [{ text: "Second point" }] }, + { kind: "paragraph", runs: [{ text: "Third point" }] }, + ]); + }); + + it("round-trips an empty paragraph with no runs", () => { + const document = { + metadata: {}, + slides: [ + slide({ + shapes: [ + { + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + blocks: [ + { kind: "paragraph" as const, runs: [{ text: "Before" }] }, + { kind: "paragraph" as const, runs: [] }, + { kind: "paragraph" as const, runs: [{ text: "After" }] }, + ], + }, + ], + }), + ], + }; + + const { slides } = readPptContent(writePptContent(document)); + expect(slides[0]?.shapes[0]?.blocks).toEqual([ + { kind: "paragraph", runs: [{ text: "Before" }] }, + { kind: "paragraph", runs: [] }, + { kind: "paragraph", runs: [{ text: "After" }] }, + ]); + }); + + it("round-trips a paragraph's alignment and list level", () => { + const document = { + metadata: {}, + slides: [ + slide({ + shapes: [ + { + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + blocks: [ + { + kind: "paragraph" as const, + runs: [{ text: "Centered" }], + alignment: "center" as const, + }, + { + kind: "paragraph" as const, + runs: [{ text: "Indented" }], + list: { level: 2 }, + }, + ], + }, + ], + }), + ], + }; + + const { slides } = readPptContent(writePptContent(document)); + expect(slides[0]?.shapes[0]?.blocks).toEqual([ + { kind: "paragraph", runs: [{ text: "Centered" }], alignment: "center" }, + { kind: "paragraph", runs: [{ text: "Indented" }], list: { level: 2 } }, + ]); + }); + + it("round-trips several character-formatted runs within one paragraph", () => { + const document = { + metadata: {}, + slides: [ + slide({ + shapes: [ + { + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + blocks: [ + { + kind: "paragraph" as const, + runs: [ + { text: "bold ", bold: true }, + { text: "italic ", italic: true }, + { text: "underline", underline: true }, + ], + }, + ], + }, + ], + }), + ], + }; + + const { slides } = readPptContent(writePptContent(document)); + expect(slides[0]?.shapes[0]?.blocks).toEqual([ + { + kind: "paragraph", + runs: [ + { text: "bold ", bold: true }, + { text: "italic ", italic: true }, + { text: "underline", underline: true }, + ], + }, + ]); + }); + + it("round-trips a run's explicit false formatting, distinct from stating nothing", () => { + const document = { + metadata: {}, + slides: [ + slide({ + shapes: [ + { + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + blocks: [ + { + kind: "paragraph" as const, + runs: [{ text: "not bold", bold: false }], + }, + ], + }, + ], + }), + ], + }; + + const { slides } = readPptContent(writePptContent(document)); + expect(slides[0]?.shapes[0]?.blocks).toEqual([ + { kind: "paragraph", runs: [{ text: "not bold", bold: false }] }, + ]); + }); + + it("round-trips a run's font family, size, and colour", () => { + const document = { + metadata: {}, + slides: [ + slide({ + shapes: [ + { + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + blocks: [ + { + kind: "paragraph" as const, + runs: [ + { + text: "styled", + fontFamily: "Verdana", + sizePt: 24, + color: { r: 0x33 / 255, g: 0x66 / 255, b: 0x99 / 255 }, + }, + ], + }, + ], + }, + ], + }), + ], + }; + + const { slides } = readPptContent(writePptContent(document)); + expect(slides[0]?.shapes[0]?.blocks).toEqual([ + { + kind: "paragraph", + runs: [ + { + text: "styled", + fontFamily: "Verdana", + sizePt: 24, + color: { r: 0x33 / 255, g: 0x66 / 255, b: 0x99 / 255 }, + }, + ], + }, + ]); + }); + + it("resolves several distinct font families through one shared document font collection", () => { + const document = { + metadata: {}, + slides: [ + slide({ + shapes: [ + { + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + blocks: [ + { + kind: "paragraph" as const, + runs: [ + { text: "a", fontFamily: "Arial" }, + { text: "b", fontFamily: "Verdana" }, + { text: "c", fontFamily: "Arial" }, + ], + }, + ], + }, + ], + }), + ], + }; + + const { slides } = readPptContent(writePptContent(document)); + expect(slides[0]?.shapes[0]?.blocks).toEqual([ + { + kind: "paragraph", + runs: [ + { text: "a", fontFamily: "Arial" }, + { text: "b", fontFamily: "Verdana" }, + { text: "c", fontFamily: "Arial" }, + ], + }, + ]); + }); + + it("round-trips several shapes on one slide, each with its own frame and text", () => { + const document = { + metadata: {}, + slides: [ + slide({ + shapes: [ + { + frame: { xPt: 10, yPt: 10, widthPt: 200, heightPt: 50 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + blocks: [ + { kind: "paragraph" as const, runs: [{ text: "Title" }] }, + ], + }, + { + frame: { xPt: 10, yPt: 100, widthPt: 400, heightPt: 300 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + blocks: [ + { kind: "paragraph" as const, runs: [{ text: "Body" }] }, + ], + }, + ], + }), + ], + }; + + const { slides } = readPptContent(writePptContent(document)); + expect(slides[0]?.shapes).toHaveLength(2); + expect(slides[0]?.shapes[0]?.frame).toEqual({ + xPt: 10, + yPt: 10, + widthPt: 200, + heightPt: 50, + }); + expect(slides[0]?.shapes[0]?.blocks).toEqual([ + { kind: "paragraph", runs: [{ text: "Title" }] }, + ]); + expect(slides[0]?.shapes[1]?.frame).toEqual({ + xPt: 10, + yPt: 100, + widthPt: 400, + heightPt: 300, + }); + expect(slides[0]?.shapes[1]?.blocks).toEqual([ + { kind: "paragraph", runs: [{ text: "Body" }] }, + ]); + }); + + it("round-trips a shape carrying no text at all", () => { + const document = { + metadata: {}, + slides: [ + slide({ + shapes: [ + { + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + blocks: [], + }, + ], + }), + ], + }; + + const { slides } = readPptContent(writePptContent(document)); + expect(slides[0]?.shapes[0]?.blocks).toEqual([]); + }); + + it("round-trips several slides, each with its own persist object", () => { + const document = { + metadata: {}, + slides: [ + slide({ + shapes: [ + { + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + blocks: [ + { kind: "paragraph" as const, runs: [{ text: "Slide one" }] }, + ], + }, + ], + }), + slide({ + shapes: [ + { + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + blocks: [ + { kind: "paragraph" as const, runs: [{ text: "Slide two" }] }, + ], + }, + ], + }), + slide({ + shapes: [ + { + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + blocks: [ + { kind: "paragraph" as const, runs: [{ text: "Slide three" }] }, + ], + }, + ], + }), + ], + }; + + const { slides } = readPptContent(writePptContent(document)); + expect(slides).toHaveLength(3); + expect(slides.map((s) => s.shapes[0]?.blocks)).toEqual([ + [{ kind: "paragraph", runs: [{ text: "Slide one" }] }], + [{ kind: "paragraph", runs: [{ text: "Slide two" }] }], + [{ kind: "paragraph", runs: [{ text: "Slide three" }] }], + ]); + }); + + it("round-trips a presentation with no slides at all", () => { + const document = { metadata: {}, slides: [] }; + const { slides } = readPptContent(writePptContent(document)); + expect(slides).toEqual([]); + }); + + it("silently drops a block kind this writer does not represent, keeping the paragraphs around it", () => { + const document = { + metadata: {}, + slides: [ + slide({ + shapes: [ + { + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + blocks: [ + { kind: "paragraph" as const, runs: [{ text: "Before" }] }, + { + kind: "image" as const, + format: "png" as const, + base64: "", + widthPt: 10, + heightPt: 10, + }, + { kind: "paragraph" as const, runs: [{ text: "After" }] }, + ], + }, + ], + }), + ], + }; + + const { slides } = readPptContent(writePptContent(document)); + expect(slides[0]?.shapes[0]?.blocks).toEqual([ + { kind: "paragraph", runs: [{ text: "Before" }] }, + { kind: "paragraph", runs: [{ text: "After" }] }, + ]); + }); + + it("throws when two slides declare different sizes, which [MS-PPT] cannot express", () => { + const document = { + metadata: {}, + slides: [ + slide({ size: { widthPt: 720, heightPt: 540 } }), + slide({ size: { widthPt: 960, heightPt: 540 } }), + ], + }; + expect(() => writePptContent(document)).toThrow(PptUnsupportedContentError); + }); +}); + +describe("writePptStreams", () => { + it("produces the same two streams readPptStreams' own compound-file caller expects", () => { + const document = { metadata: {}, slides: [slide()] }; + const { currentUserStream, powerPointDocumentStream } = + writePptStreams(document); + expect(currentUserStream.length).toBeGreaterThan(0); + expect(powerPointDocumentStream.length).toBeGreaterThan(0); + }); +}); + +describe("writePpt / readPpt round trip", () => { + it("writes a DocumentTree and reads an equal one back", () => { + const content: ContentDocument = { + kind: "presentation", + metadata: {}, + slides: [ + slide({ + shapes: [ + { + frame: { xPt: 72, yPt: 72, widthPt: 400, heightPt: 100 }, + // readPpt always reports PowerPoint's own default insets (0.1in/0.05in) regardless of what a file's own OfficeArtFOPT states -- a documented reader-side gap, not something this writer's own insets ever reach -- so the input must state those same defaults for a whole-tree equality check to hold. + insetLeftPt: 7.2, + insetTopPt: 3.6, + insetRightPt: 7.2, + insetBottomPt: 3.6, + blocks: [ + { + kind: "paragraph" as const, + runs: [{ text: "Tree round trip" }], + }, + ], + }, + ], + }), + ], + }; + const tree = assembleTree(content); + const bytes = writePpt(tree); + const roundTripped = readPpt(bytes); + expect(roundTripped.kind).toBe("presentation"); + expect(flattenTree(roundTripped)).toEqual(flattenTree(tree)); + }); + + it("throws when asked to write a non-presentation document", () => { + const content: ContentDocument = { + kind: "wordprocessing", + metadata: {}, + sections: [], + }; + expect(() => writePpt(assembleTree(content))).toThrow( + PptUnsupportedContentError, + ); + }); +}); + +describe("the shared schema accepts what the writer's own round trip produces", () => { + it("parses the flat form written and read back as a presentation ContentDocument", () => { + const document = { + metadata: {}, + slides: [ + slide({ + shapes: [ + { + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + blocks: [ + { kind: "paragraph" as const, runs: [{ text: "Valid" }] }, + ], + }, + ], + }), + ], + }; + const { metadata, slides } = readPptContent(writePptContent(document)); + expect(() => + ContentDocumentSchema.parse({ kind: "presentation", metadata, slides }), + ).not.toThrow(); + }); + + it("parses the tree form written and read back as a DocumentTree", () => { + const document = { + metadata: {}, + slides: [slide()], + }; + expect(() => + DocumentTreeSchema.parse(readPpt(writePptContent(document))), + ).not.toThrow(); + }); +}); diff --git a/packages/ppt-codec/src/write.ts b/packages/ppt-codec/src/write.ts new file mode 100644 index 000000000..94441c8df --- /dev/null +++ b/packages/ppt-codec/src/write.ts @@ -0,0 +1,159 @@ +import { writeCompoundFile } from "archive-codec"; +import { + type ContentSlide, + type DocumentTree, + type PageSize, + flattenTree, +} from "document-schema.js"; +import { collectFontFamilies } from "./content-write"; +import { writeDocumentAtom } from "./document/document-atom-write"; +import { writeEnvironment } from "./document/fonts-write"; +import { + type SlidePersistRef, + writeSlideListWithText, +} from "./document/slide-list-write"; +import { writeSlideDrawing } from "./drawing/shapes-write"; +import { PptUnsupportedContentError } from "./errors"; +import { + CURRENT_USER_STREAM, + POWERPOINT_DOCUMENT_STREAM, + type PptDocument, +} from "./read"; +import { RT_Document, RT_Slide } from "./record/types"; +import { concatBytes, writeContainer } from "./record/write"; +import { writeCurrentUserAtom } from "./stream/current-user-write"; +import { + writePersistDirectoryAtom, + writeUserEditAtom, +} from "./stream/persist-write"; + +// The write path, the mirror image of read.ts: a presentation's ContentSlide[] mapped onto [MS-PPT] records (document container, slide list, one slide container per slide, each slide's drawing and text), a single-edit persist layer over them (stream/persist-write.ts), and the two [MS-CFB] streams archive-codec's writeCompoundFile wraps into real .ppt bytes. Deliberately narrower than the read path's own coverage -- see the package README's write-scope section for exactly what a written file carries and what it does not. + +// [MS-PPT] persist identifiers this writer mints: 1 always names the document; slides follow contiguously from 2. Real slide ids conventionally start at 256 (this package's own synthetic-presentation fixture uses the same value) -- readSlideListWithText/read.ts never interpret the slide id itself, so any distinct sequence would round-trip identically, but 256 matches what a real PowerPoint file states. +const DOCUMENT_PERSIST_ID = 1; +const FIRST_SLIDE_ID = 256; +const DEFAULT_SLIDE_SIZE: PageSize = { widthPt: 720, heightPt: 540 }; + +function requireOneSlideSize(slides: readonly ContentSlide[]): PageSize { + const first = slides[0]?.size ?? DEFAULT_SLIDE_SIZE; + for (const slide of slides) { + if ( + slide.size.widthPt !== first.widthPt || + slide.size.heightPt !== first.heightPt + ) { + throw new PptUnsupportedContentError( + `ppt-codec's writer cannot express per-slide sizes: slide sizes ${JSON.stringify(first)} and ${JSON.stringify(slide.size)} both appear, but [MS-PPT]'s DocumentAtom states exactly one slide size for the whole presentation`, + ); + } + } + return first; +} + +function writeSlideContainer( + shapes: ContentSlide["shapes"], + fontIndexOf: (family: string) => number, +): Uint8Array { + return writeContainer(RT_Slide, [writeSlideDrawing(shapes, fontIndexOf)]); +} + +// Streams a caller already holds two [MS-PPT] artifacts for -- the same split readPptStreams exposes on the way in, so a caller assembling its own container can bypass writePptContent's archive-codec dependency entirely. +export function writePptStreams(document: PptDocument): { + readonly currentUserStream: Uint8Array; + readonly powerPointDocumentStream: Uint8Array; +} { + const { slides } = document; + const size = requireOneSlideSize(slides); + + const fontNames = collectFontFamilies( + slides.map((slide) => slide.shapes.flatMap((shape) => shape.blocks)), + ); + const fontIndexOf = (family: string): number => { + const index = fontNames.indexOf(family); + if (index === -1) { + throw new PptUnsupportedContentError( + `font family '${family}' was not collected into the document's font table before writing`, + ); + } + return index; + }; + + const slidePersistRefs: SlidePersistRef[] = slides.map((slide, index) => ({ + persistIdRef: DOCUMENT_PERSIST_ID + 1 + index, + slideId: FIRST_SLIDE_ID + index, + })); + + const environment = writeEnvironment(fontNames); + const documentChildren = [writeDocumentAtom(size)]; + if (environment !== undefined) { + documentChildren.push(environment); + } + documentChildren.push(writeSlideListWithText(slidePersistRefs)); + const documentContainer = writeContainer(RT_Document, documentChildren); + + const slideContainers = slides.map((slide) => + writeSlideContainer(slide.shapes, fontIndexOf), + ); + + const persistEntries = [{ persistId: DOCUMENT_PERSIST_ID, offset: 0 }]; + let offset = documentContainer.length; + slideContainers.forEach((container, index) => { + const ref = slidePersistRefs[index]; + if (ref === undefined) { + throw new PptUnsupportedContentError( + "internal error: slide container count does not match slide persist reference count", + ); + } + persistEntries.push({ persistId: ref.persistIdRef, offset }); + offset += container.length; + }); + const persistDirectoryOffset = offset; + const persistDirectory = writePersistDirectoryAtom(persistEntries); + + const userEditOffset = persistDirectoryOffset + persistDirectory.length; + const lastSlideId = slidePersistRefs.at(-1)?.slideId ?? 0; + const userEdit = writeUserEditAtom({ + lastSlideIdRef: lastSlideId, + offsetLastEdit: 0, + offsetPersistDirectory: persistDirectoryOffset, + docPersistIdRef: DOCUMENT_PERSIST_ID, + persistIdSeed: DOCUMENT_PERSIST_ID + slides.length + 1, + }); + + const currentUserAtom = writeCurrentUserAtom(userEditOffset); + + return { + currentUserStream: currentUserAtom, + powerPointDocumentStream: concatBytes( + documentContainer, + ...slideContainers, + persistDirectory, + userEdit, + ), + }; +} + +// Wraps writePptStreams' two [MS-PPT] streams in a real [MS-CFB] compound file via archive-codec's writeCompoundFile -- genuine .ppt bytes readPptContent (and any conformant [MS-PPT] reader) can open. +export function writePptContent( + document: PptDocument, +): Uint8Array { + const { currentUserStream, powerPointDocumentStream } = + writePptStreams(document); + return writeCompoundFile([ + { path: CURRENT_USER_STREAM, bytes: currentUserStream }, + { path: POWERPOINT_DOCUMENT_STREAM, bytes: powerPointDocumentStream }, + ]); +} + +// Writes a presentation DocumentTree to .ppt bytes, the mirror of readPpt. Throws PptUnsupportedContentError for a tree of any other kind: this writer covers presentations only, the same kind readPpt itself always produces. +export function writePpt(tree: DocumentTree): Uint8Array { + const content = flattenTree(tree); + if (content.kind !== "presentation") { + throw new PptUnsupportedContentError( + `ppt-codec's writer only writes presentation documents; got a '${content.kind}' document`, + ); + } + return writePptContent({ + metadata: content.metadata, + slides: content.slides, + }); +} diff --git a/packages/ppt-codec/test/workers/ppt-codec.test.ts b/packages/ppt-codec/test/workers/ppt-codec.test.ts index 9e165f88a..faec808a7 100644 --- a/packages/ppt-codec/test/workers/ppt-codec.test.ts +++ b/packages/ppt-codec/test/workers/ppt-codec.test.ts @@ -6,11 +6,12 @@ import { readPpt, readPptContent, readPptStreams, + writePptContent, } from "../../src"; import { compoundFile } from "../../src/test-support/compound-file"; import { syntheticPresentation } from "../../src/test-support/presentation"; -// Proves ppt-codec's public read surface executes inside a Cloudflare Workers isolate (workerd, via @cloudflare/vitest-pool-workers) with no Node-only APIs. The package is isomorphic by design -- a hand-written [MS-PPT] record walk over DataView and Uint8Array, with archive-codec's own isomorphic [MS-CFB] reader beneath it and no node:fs, Buffer, or path anywhere -- so if any code path on either side of the package boundary reached for a Node-only API, this isolate would throw rather than these passing. The compound-file path matters here in its own right rather than being covered by the stream-level one: it is what pulls archive-codec into the isolate alongside this package's own code. This is the runtime complement to the node `vitest run --project unit` suite, not a replacement for it. +// Proves ppt-codec's public read AND write surface executes inside a Cloudflare Workers isolate (workerd, via @cloudflare/vitest-pool-workers) with no Node-only APIs. The package is isomorphic by design -- a hand-written [MS-PPT] record walk over DataView and Uint8Array in both directions, with archive-codec's own isomorphic [MS-CFB] reader and writer beneath it and no node:fs, Buffer, or path anywhere -- so if any code path on either side of the package boundary reached for a Node-only API, this isolate would throw rather than these passing. The compound-file path matters here in its own right rather than being covered by the stream-level one: it is what pulls archive-codec into the isolate alongside this package's own code. This is the runtime complement to the node `vitest run --project unit` suite, not a replacement for it. describe("ppt-codec under the Cloudflare Workers runtime", () => { const { currentUserStream, powerPointDocumentStream } = syntheticPresentation(); @@ -39,4 +40,32 @@ describe("ppt-codec under the Cloudflare Workers runtime", () => { expect(tree.kind).toBe("presentation"); expect(flattenTree(tree).kind).toBe("presentation"); }); + + it("writes a presentation and reads it back, exercising archive-codec's [MS-CFB] writer in the isolate too", () => { + const document = { + metadata: {}, + slides: [ + { + size: { widthPt: 720, heightPt: 540 }, + notes: "", + shapes: [ + { + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + blocks: [ + { kind: "paragraph" as const, runs: [{ text: "Written in the isolate" }] }, + ], + }, + ], + }, + ], + }; + const written = readPptContent(writePptContent(document)); + expect(written.slides[0]?.shapes[0]?.blocks).toEqual([ + { kind: "paragraph", runs: [{ text: "Written in the isolate" }] }, + ]); + }); });