ppt-codec 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +142 -38
  2. package/dist/content-write.cjs +114 -0
  3. package/dist/content-write.d.cts +11 -0
  4. package/dist/content-write.d.ts +11 -0
  5. package/dist/content-write.js +112 -0
  6. package/dist/content.cjs +22 -2
  7. package/dist/content.js +22 -2
  8. package/dist/document/color-scheme-write.cjs +57 -0
  9. package/dist/document/color-scheme-write.d.cts +4 -0
  10. package/dist/document/color-scheme-write.d.ts +4 -0
  11. package/dist/document/color-scheme-write.js +56 -0
  12. package/dist/document/document-atom-write.cjs +12 -0
  13. package/dist/document/document-atom-write.d.cts +5 -0
  14. package/dist/document/document-atom-write.d.ts +5 -0
  15. package/dist/document/document-atom-write.js +11 -0
  16. package/dist/document/fonts-write.cjs +17 -0
  17. package/dist/document/fonts-write.d.cts +4 -0
  18. package/dist/document/fonts-write.d.ts +4 -0
  19. package/dist/document/fonts-write.js +16 -0
  20. package/dist/document/master-write.cjs +128 -0
  21. package/dist/document/master-write.d.cts +15 -0
  22. package/dist/document/master-write.d.ts +15 -0
  23. package/dist/document/master-write.js +123 -0
  24. package/dist/document/notes-list-write.cjs +12 -0
  25. package/dist/document/notes-list-write.d.cts +5 -0
  26. package/dist/document/notes-list-write.d.ts +5 -0
  27. package/dist/document/notes-list-write.js +11 -0
  28. package/dist/document/notes-list.cjs +22 -0
  29. package/dist/document/notes-list.d.cts +9 -0
  30. package/dist/document/notes-list.d.ts +9 -0
  31. package/dist/document/notes-list.js +21 -0
  32. package/dist/document/notes-write.cjs +43 -0
  33. package/dist/document/notes-write.d.cts +6 -0
  34. package/dist/document/notes-write.d.ts +6 -0
  35. package/dist/document/notes-write.js +41 -0
  36. package/dist/document/notes.cjs +41 -0
  37. package/dist/document/notes.d.cts +12 -0
  38. package/dist/document/notes.d.ts +12 -0
  39. package/dist/document/notes.js +37 -0
  40. package/dist/document/slide-list-write.cjs +12 -0
  41. package/dist/document/slide-list-write.d.cts +8 -0
  42. package/dist/document/slide-list-write.d.ts +8 -0
  43. package/dist/document/slide-list-write.js +11 -0
  44. package/dist/drawing/shapes-write.cjs +60 -0
  45. package/dist/drawing/shapes-write.d.cts +10 -0
  46. package/dist/drawing/shapes-write.d.ts +10 -0
  47. package/dist/drawing/shapes-write.js +58 -0
  48. package/dist/errors.cjs +7 -0
  49. package/dist/errors.d.cts +4 -1
  50. package/dist/errors.d.ts +4 -1
  51. package/dist/errors.js +7 -1
  52. package/dist/index.cjs +112 -3
  53. package/dist/index.d.cts +24 -7
  54. package/dist/index.d.ts +24 -7
  55. package/dist/index.js +25 -8
  56. package/dist/metadata.cjs +14 -0
  57. package/dist/metadata.d.cts +6 -0
  58. package/dist/metadata.d.ts +6 -0
  59. package/dist/metadata.js +13 -0
  60. package/dist/read.cjs +33 -8
  61. package/dist/read.d.cts +5 -1
  62. package/dist/read.d.ts +5 -1
  63. package/dist/read.js +32 -10
  64. package/dist/record/types.cjs +8 -0
  65. package/dist/record/types.d.cts +5 -1
  66. package/dist/record/types.d.ts +5 -1
  67. package/dist/record/types.js +5 -1
  68. package/dist/record/write.cjs +75 -0
  69. package/dist/record/write.d.cts +17 -0
  70. package/dist/record/write.d.ts +17 -0
  71. package/dist/record/write.js +65 -0
  72. package/dist/stream/current-user-write.cjs +13 -0
  73. package/dist/stream/current-user-write.d.cts +4 -0
  74. package/dist/stream/current-user-write.d.ts +4 -0
  75. package/dist/stream/current-user-write.js +12 -0
  76. package/dist/stream/current-user.cjs +7 -5
  77. package/dist/stream/current-user.d.cts +3 -1
  78. package/dist/stream/current-user.d.ts +3 -1
  79. package/dist/stream/current-user.js +6 -6
  80. package/dist/stream/persist-write.cjs +14 -0
  81. package/dist/stream/persist-write.d.cts +16 -0
  82. package/dist/stream/persist-write.d.ts +16 -0
  83. package/dist/stream/persist-write.js +12 -0
  84. package/dist/text/style-write.cjs +79 -0
  85. package/dist/text/style-write.d.cts +5 -0
  86. package/dist/text/style-write.d.ts +5 -0
  87. package/dist/text/style-write.js +78 -0
  88. package/dist/text/style.cjs +85 -30
  89. package/dist/text/style.d.cts +50 -1
  90. package/dist/text/style.d.ts +50 -1
  91. package/dist/text/style.js +44 -31
  92. package/dist/units.cjs +4 -0
  93. package/dist/units.d.cts +2 -1
  94. package/dist/units.d.ts +2 -1
  95. package/dist/units.js +4 -1
  96. package/dist/write.cjs +145 -0
  97. package/dist/write.d.cts +11 -0
  98. package/dist/write.d.ts +11 -0
  99. package/dist/write.js +142 -0
  100. package/package.json +4 -4
package/README.md CHANGED
@@ -2,13 +2,13 @@
2
2
 
3
3
  [![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)
4
4
 
5
- > 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.
5
+ > 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.
6
6
 
7
7
  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.
8
8
 
9
9
  ## Status
10
10
 
11
- **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.
11
+ **Under active development. The read path for slide text, geometry and speaker notes is built and tested. A narrower write path now exists too: one slide per input slide, with plain text-box shapes and their speaker notes (basic character formatting, no images/tables/layouts) — genuinely conformant [MS-PPT], verified by writing then reading every fixture back through this package's own reader, and, for speaker notes, against LibreOffice in both directions, but not full read/write parity.** What that means concretely is set out in [What it reads](#what-it-reads)/[What it does not read yet](#what-it-does-not-read-yet) and [What it writes](#what-it-writes)/[What it does not write yet](#what-it-does-not-write-yet) below — every one of those four lists is exhaustive rather than illustrative, so a caller can tell from this page alone whether the format's own feature it cares about is covered.
12
12
 
13
13
  ## Why the format is shaped the way it is
14
14
 
@@ -59,20 +59,37 @@ for (const slide of slides) {
59
59
 
60
60
  `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.
61
61
 
62
+ ## Writing a document
63
+
64
+ ```ts
65
+ import { writePpt, writePptContent } from "ppt-codec";
66
+
67
+ // The tree form: a document-schema.js DocumentTree in, real .ppt bytes out.
68
+ const pptBytes = writePpt(tree);
69
+
70
+ // The flat form: metadata plus ContentSlide[] in -- title/author/dates are
71
+ // written to a real "\x05SummaryInformation" stream when metadata carries
72
+ // any of them (see Metadata).
73
+ const bytes = writePptContent({ metadata: {}, slides });
74
+ ```
75
+
76
+ `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.
77
+
62
78
  ## What it reads
63
79
 
64
80
  The whole path from a file's first byte to a slide's text, record by record:
65
81
 
66
- | Layer | Records |
67
- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
68
- | Container | The `Current User` and `PowerPoint Document` streams, read through `archive-codec`'s bounded [MS-CFB] reader. |
69
- | Record framing | The generic 8-byte `RecordHeader`, the container/atom distinction, sibling sequences, child walks, and typed-descendant search — shared with [MS-ODRAW]'s records, which carry the identical header. |
70
- | Edit resolution | `CurrentUserAtom` (including its encrypted/plaintext `headerToken`), the `UserEditAtom` chain, `PersistDirectoryAtom`/`PersistDirectoryEntry`'s packed 20-bit/12-bit run form, and the oldest-first directory construction whose later entries supersede earlier ones — [MS-PPT] 2.1.2's own "live record" process, Part 1. |
71
- | Document | `DocumentContainer` → `DocumentAtom` (slide size, in master units), `DocumentTextInfoContainer`'s `FontCollectionContainer`/`FontEntityAtom` typeface names, and `SlideListWithTextContainer` (distinguished from the master and notes lists by `recInstance`, which does not run in the order the names suggest). |
72
- | Slides | `SlidePersistAtom` → the persist directory → each `SlideContainer`, and the placeholder texts the slide list carries for it. |
73
- | Drawing | `DrawingContainer` → `OfficeArtDgContainer` → the `OfficeArtSpgrContainer`/`OfficeArtSpContainer` tree, `OfficeArtFSP`'s group/patriarch/deleted flags, `OfficeArtClientAnchor` in both its 8-byte `SmallRectStruct` and 16-byte `RectStruct` spellings, and `OfficeArtChildAnchor` mapped through nested `OfficeArtFSPGR` group coordinate systems. |
74
- | Text | `OfficeArtClientTextbox`, `TextHeaderAtom`, `TextCharsAtom` (UTF-16) and `TextBytesAtom` (one byte per character), `OutlineTextRefAtom` indirection into the slide list, and the paragraph split on the stored `\r`. |
75
- | Formatting | `StyleTextPropAtom`: `TextPFRun`/`TextPFException` (indent level, alignment) and `TextCFRun`/`TextCFException` (bold, italic, underline, shadow, emboss, typeface reference, size in points, and a `ColorIndexStruct` colour when it is a literal sRGB value), each read in the spec's **declared field order** rather than its mask-bit order — the two differ, and following the mask-bit order desynchronises every field after the first divergence. |
82
+ | Layer | Records |
83
+ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
84
+ | Container | The `Current User` and `PowerPoint Document` streams, read through `archive-codec`'s bounded [MS-CFB] reader. |
85
+ | Record framing | The generic 8-byte `RecordHeader`, the container/atom distinction, sibling sequences, child walks, and typed-descendant search — shared with [MS-ODRAW]'s records, which carry the identical header. |
86
+ | Edit resolution | `CurrentUserAtom` (including its encrypted/plaintext `headerToken`), the `UserEditAtom` chain, `PersistDirectoryAtom`/`PersistDirectoryEntry`'s packed 20-bit/12-bit run form, and the oldest-first directory construction whose later entries supersede earlier ones — [MS-PPT] 2.1.2's own "live record" process, Part 1. |
87
+ | Document | `DocumentContainer` → `DocumentAtom` (slide size, in master units), `DocumentTextInfoContainer`'s `FontCollectionContainer`/`FontEntityAtom` typeface names, and `SlideListWithTextContainer` (distinguished from the master and notes lists by `recInstance`, which does not run in the order the names suggest). |
88
+ | Slides | `SlidePersistAtom` → the persist directory → each `SlideContainer`, and the placeholder texts the slide list carries for it. |
89
+ | Speaker notes | `NotesListWithTextContainer` (the third of the three containers sharing `RT_SlideListWithText`) → `NotesPersistAtom` → the persist directory → each `NotesContainer`, and the `NotesAtom.slideIdRef` naming the presentation slide those notes belong to. The text comes from the notes slide's own drawing, since the notes list unlike the slide list carries no texts for an `OutlineTextRefAtom` to reach into. |
90
+ | Drawing | `DrawingContainer` `OfficeArtDgContainer` the `OfficeArtSpgrContainer`/`OfficeArtSpContainer` tree, `OfficeArtFSP`'s group/patriarch/deleted flags, `OfficeArtClientAnchor` in both its 8-byte `SmallRectStruct` and 16-byte `RectStruct` spellings, and `OfficeArtChildAnchor` mapped through nested `OfficeArtFSPGR` group coordinate systems. |
91
+ | Text | `OfficeArtClientTextbox`, `TextHeaderAtom`, `TextCharsAtom` (UTF-16) and `TextBytesAtom` (one byte per character), `OutlineTextRefAtom` indirection into the slide list, and the paragraph split on the stored `\r`. |
92
+ | Formatting | `StyleTextPropAtom`: `TextPFRun`/`TextPFException` (indent level, alignment, line spacing, space before/after, left margin, and first-line indent) and `TextCFRun`/`TextCFException` (bold, italic, underline, shadow, emboss, typeface reference, size in points, and a `ColorIndexStruct` colour when it is a literal sRGB value), each read in the spec's **declared field order** rather than its mask-bit order — the two differ, and following the mask-bit order desynchronises every field after the first divergence. |
76
93
 
77
94
  Geometry is converted from master units (1/576 inch) to points on the way out, so a slide's `size` and every shape's `frame` are in the same unit the shared schema uses everywhere else.
78
95
 
@@ -80,20 +97,88 @@ Geometry is converted from master units (1/576 inch) to points on the way out, s
80
97
 
81
98
  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:
82
99
 
83
- - **Writing.** There is no write path at all: this package reads `.ppt` and does not produce it.
84
100
  - **Encrypted documents.** Recognised and refused by name (`PptEncryptedError`) rather than misparsed, but not decrypted.
85
- - **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.
86
- - **Document metadata.** `metadata` is always `{}`. Document properties live in the compound file's own `SummaryInformation` stream ([MS-OSHARED]), not in any [MS-PPT] record.
101
+ - **`DocumentSummaryInformation`'s extended and user-defined properties** (company, manager, custom properties) a genuinely different stream from the one [Metadata](#metadata) covers, not attempted at all.
87
102
  - **Master and layout inheritance.** A run that states no size, typeface, or weight inherits it from the master's `TextMasterStyleAtom`; this reader reports such a property as absent rather than resolving the cascade, so a run's formatting is what the slide itself states and no more.
88
103
  - **Scheme colours.** A `ColorIndexStruct` naming a colour-scheme slot (rather than a literal sRGB value) yields no colour, because resolving it needs the slide's `SlideSchemeColorSchemeAtom`.
89
104
  - **Per-shape text insets.** Every shape reports PowerPoint's own defaults (0.1 inch left and right, 0.05 inch top and bottom); a per-shape override lives in the shape's `OfficeArtFOPT` property table, which is not read.
90
105
  - **Images, tables, and OLE embeddings.** A picture shape, a table object, and an embedded or linked OLE object all read as a shape with geometry and no blocks. `ExObjListContainer` and the `ExOleObjStg` persist objects are not walked.
91
106
  - **Shapes with no anchor.** A shape carrying neither an `OfficeArtClientAnchor` nor an `OfficeArtChildAnchor` is dropped, because `ContentShape` has no way to say "positioned, but unknown where".
92
- - **Hyperlinks, bullets, spacing and margins.** `InteractiveInfo`/`TextInteractiveInfoAtom`, `TextPFException`'s bullet fields, and its `lineSpacing`/`spaceBefore`/`spaceAfter`/`leftMargin`/`indent` are parsed past correctly but not surfaced.
107
+ - **Hyperlinks and bullets.** `InteractiveInfo`/`TextInteractiveInfoAtom` and `TextPFException`'s bullet fields are parsed past correctly but not surfaced.
93
108
  - **Animations, transitions, comments, headers and footers, and the metacharacter atoms** (slide number, date, header, footer).
94
109
  - **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`.
110
+ - **`ParaSpacing` values in the form the shared schema cannot state.** `lineSpacing` (the schema's line-height multiplier) only has a value to report for `ParaSpacing`'s percentage-of-line-height form; `spacingBeforePt`/`spacingAfterPt` (the schema's plain points) only have a value for the absolute master-units form. A paragraph stating the other form of either field reports no value at all for it, rather than a wrong one — there is no rendered line height available here to convert one form into the other.
95
111
  - **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.
96
112
 
113
+ ## What it writes
114
+
115
+ The whole path from a `ContentSlide[]` to a real `.ppt` file's bytes, mirroring the read-side table above in the opposite direction:
116
+
117
+ | Layer | Records |
118
+ | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
119
+ | Container | The `Current User` and `PowerPoint Document` streams, wrapped in a real [MS-CFB] compound file through `archive-codec`'s conformant writer. |
120
+ | 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. |
121
+ | Edit resolution | A single-edit persist layer: one `CurrentUserAtom` pointing at one `UserEditAtom` pointing at one `PersistDirectoryAtom` whose entries name the stream offset of the document container, the master, every slide container and every notes container — never an incremental append, since nothing about this writer's own output needs a second generation of any object. |
122
+ | Document | `DocumentContainer` → `DocumentAtom` (one slide size, in master units, taken from the input's own first slide and required to match every other slide — see below), an `Environment`/`FontCollectionContainer` built from every distinct `fontFamily` a run names, a `MasterListWithTextContainer` naming the one master, a `SlideListWithTextContainer` carrying one `SlidePersistAtom` per slide with no placeholder texts, and — only when some slide has notes — a `NotesListWithTextContainer`. |
123
+ | Master | One minimal `MainMasterContainer`: its own `SlideAtom`, the title/body/notes `TextMasterStyleAtom` items [MS-PPT] 2.5.3 requires (each stating `cLevels` 0, so every level falls through to the document's own text styles), the five `SL_TitleBody` placeholder shapes a main master must carry, and a default `SlideSchemeColorSchemeAtom`. It exists because speaker notes need it — see below. |
124
+ | Slides | One `SlideContainer` per input slide, each opening with a `SlideAtom` that names the master it follows and, when the slide has notes, the notes slide holding them, then its own `DrawingContainer`. |
125
+ | Speaker notes | One `NotesContainer` per slide that actually has notes — a `NotesAtom` naming that slide, then a `DrawingContainer` whose single text box carries the notes, one paragraph per line, then the `SlideSchemeColorSchemeAtom` [MS-PPT] 2.5.6 requires of one — the notes slide's own `NotesAtom.slideFlags` leaves `fMasterScheme` clear, so it inherits no scheme and has to state one. A slide with no notes gets no notes slide at all rather than an empty one. |
126
+ | 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. |
127
+ | 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. |
128
+ | Formatting | `StyleTextPropAtom`: one `TextPFRun` per paragraph (indent level, alignment, line spacing, space before/after, left margin, and first-line indent) and one `TextCFRun` per character run (bold, italic, underline, a font-collection reference, size in points, and a literal sRGB `ColorIndexStruct` colour), fields written in the identical spec-declared order `readTextPFException`/`readTextCFException` parse them in. |
129
+
130
+ 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.
131
+
132
+ 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.
133
+
134
+ ### Why a writer of plain text-box slides writes a master slide
135
+
136
+ Speaker notes are the reason, and the chain is worth stating because none of its links is obvious from the specification alone.
137
+
138
+ [MS-PPT] 3.5.3 names exactly one association between a notes slide and its presentation slide: the `slideIdRef` field of the notes slide's own `NotesAtom`. That is the link this package's **reader** follows, and it is sufficient for reading. It is **not** sufficient for writing, because a real consumer follows the opposite link — the `notesIdRef` field of the presentation slide's own `SlideAtom`. Verified directly against LibreOffice: a file carrying only the specification's stated link has its speaker notes silently dropped on import, and the identical file with `notesIdRef` additionally set has them imported onto the right slides. A conformant writer therefore has to state both.
139
+
140
+ Stating `notesIdRef` means writing a `SlideAtom`, and [MS-PPT] 2.5.2 requires a `SlideContainer`'s `SlideAtom` to name a master: "`masterIdRef` … MUST NOT be 0x00000000 if the record that contains this `SlideAtom` record is a `SlideContainer`". So the master is a prerequisite the notes linkage drags in, not a feature added beside it. It is deliberately minimal — no master text, no background, no layouts, the placeholder shapes empty — and every slide this writer emits is `SL_Blank`, instantiating none of them, so nothing from the master is drawn on any slide.
141
+
142
+ ### Verified against a second [MS-PPT] implementation
143
+
144
+ The round trip above proves the reader and writer agree with each other. Speaker notes are additionally checked against LibreOffice, an independently written [MS-PPT] implementation, in both directions:
145
+
146
+ - **Reading real bytes.** A presentation authored as flat ODF and converted with `soffice --headless --convert-to ppt` — three slides, notes on the first and third, none on the second — is read by `readPptContent`, and the recovered notes match what LibreOffice's own `--convert-to fodp` re-export of the same file independently reports. This is what established that a real producer stores the notes body on a plain, un-placeholdered text box whose `TextHeaderAtom` states `Tx_TYPE_OTHER`, rather than on the `PT_NotesBody` placeholder the spelling suggests — a reader keyed on the notes text type would recover nothing from a real file.
147
+ - **Writing bytes a real consumer reads.** A `.ppt` written by `writePptContent` with notes on some slides opens in LibreOffice with every slide's own text intact and each slide's notes inside `presentation:notes` — the notes view — rather than on the slide itself, confirmed by converting the written file back with `--convert-to fodp` and checking which element the text landed in. That last check is the one that matters: the same class of bug (notes rendering on the slide rather than the notes page) was caught in `odf.js`'s own `writeOdp` by exactly this test and by nothing else. Feeding the written file back through LibreOffice's own PPT export and reading _that_ returns the same slides and the same notes again.
148
+ - **Line spacing, paragraph spacing, and left margin.** A `.ppt` written with `lineSpacing`, `spacingBeforePt`/`spacingAfterPt`, and `indentLeftPt` set converts cleanly through LibreOffice to both `.pptx` (`a:lnSpc`/`a:spcBef`/`a:spcAft`/`a:pPr@marL`) and `.odp` (`fo:line-height`/`fo:margin-top`/`fo:margin-bottom`/`fo:margin-left`), each matching the written value. `indentFirstLinePt` (the hanging/first-line indent, `TextPFException.indent`) does not: on every input tried, regardless of export target, LibreOffice's own import produces a value with no relationship to what was written. This package's own reader — built independently against the specification alone, with no knowledge of the writer's internals — recovers the exact value written, and the field's byte offset and order were separately confirmed against the [MS-PPT] `TextPFException` specification directly (`leftMargin` then `indent`, both after `spaceAfter`), so the discrepancy sits in LibreOffice's own import of this one field rather than in the bytes offered to it.
149
+
150
+ ## What it does not write yet
151
+
152
+ 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:
153
+
154
+ - **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.
155
+ - **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.
156
+ - **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.
157
+ - **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.
158
+ - **Master content, layouts, and scheme colours.** A `MainMasterContainer` and its `MasterListWithTextContainer` are written, but only as the minimum [MS-PPT] requires of one (see [Why a writer of plain text-box slides writes a master slide](#why-a-writer-of-plain-text-box-slides-writes-a-master-slide)): its five placeholder shapes carry no text, its `TextMasterStyleAtom` items state no style level of their own, and its `SlideSchemeColorSchemeAtom` is a fixed default rather than anything the input chose. There are still no slide layouts, and every character run's colour must already be a literal, since no scheme is there to resolve one against.
159
+ - **Notes masters, and a notes page geometry of its own.** No `NotesContainer` is written for the notes master, and `DocumentAtom.notesMasterPersistIdRef` stays 0, so each notes slide inherits nothing (its `NotesAtom.slideFlags` is clear) and states the same fixed default `SlideSchemeColorSchemeAtom` the master does rather than a scheme of the input's choosing. The notes page is the same size as the slide, because `ContentSlide` carries no notes-page geometry to state a different one from, and the notes text box is placed in the lower half of it.
160
+ - **Hyperlinks, bullets, and list numbering identity.** `ContentRun.hyperlink`, `ContentParagraph.list.numId`/`checked`/`itemId`, and `pageBreakBefore`/`pageBreakAfter` have no [MS-PPT] field this writer populates; `alignment`, `list.level` (as a `TextPFException` indent level), `spacingBeforePt`/`spacingAfterPt`/`lineSpacing`/`indentLeftPt`/`indentFirstLinePt` round-trip.
161
+ - **`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.
162
+ - **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).
163
+ - **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.
164
+ - **Fractional character sizes.** `ContentRun.sizePt` is rounded to the nearest whole point, since `TextCFException`'s size field is a plain 16-bit integer.
165
+ - **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).
166
+
167
+ ## Metadata
168
+
169
+ A `.ppt`'s title, author, and dates do not live in any [MS-PPT] record at all — they live in a `"\x05SummaryInformation"` stream, a genuinely different format ([MS-OLEPS] Property Set Streams, [MS-OSHARED] 2.3.3.2.2's own naming of the specific properties Office uses) that happens to sit beside `Current User`/`PowerPoint Document` in the same [MS-CFB] compound file. `readPptContent` reads that stream when present (`archive-codec`'s `readSummaryInformation`, since the property-set format itself is zero document-format knowledge, exactly as the [MS-CFB] container it sits inside is) and maps it onto `document-schema.js`'s `LayoutMetadata` (`archive-codec`'s own `summaryInformationToLayoutMetadata` — the mapping is format-agnostic, so it lives there rather than being copied in this package, alongside `doc-codec`'s and `xls-codec`'s identical need for it); `writePptContent` does the inverse (`src/metadata.ts`'s `layoutMetadataToSummaryInformation`, which validates `createdIso`/`modifiedIso` as real dates and throws a `PptUnsupportedContentError` naming the offending field before delegating to `archive-codec`'s own mapping), including a `"\x05SummaryInformation"` stream in its `writeCompoundFile` call only when the input's metadata actually carries something that stream can hold — an input whose metadata is `{}`, or carries only fields the mapping below has no destination for, produces no stream at all, matching what an absent-metadata read already returns.
170
+
171
+ `readPptStreams`/`writePptStreams`, the record-level split one layer below, do not touch this at all: they take or return only the two required [MS-PPT] streams, with no compound file to look a third stream up in. `readPptContent`/`writePptContent` are where the container-level fact lives.
172
+
173
+ The mapping is not 1:1, and each gap is permanent rather than a remaining TODO:
174
+
175
+ | Direction | Fields covered | Gap |
176
+ | ----------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
177
+ | SummaryInformation → LayoutMetadata | `title`, `subject`, `author`, `keywords`, `createdIso`, `lastSavedIso` → `modifiedIso` | `comments` and `lastPrintedIso` have no LayoutMetadata field to land in — no other codec in the family has a "last printed" or free-text "comments" concept, so these are read from the stream but never reach a `PptDocument`. |
178
+ | LayoutMetadata → SummaryInformation | the same six fields, in reverse | `creator`, `producer`, and `language` have no SummaryInformation equivalent: `producer` is a PDF-only concept in this schema, and `creator`/`language` are not among the fields the stream this package writes covers. |
179
+
180
+ Only the fixed SummaryInformation property set is read or written — the sibling `"\x05DocumentSummaryInformation"` stream (company, manager, and custom user-defined properties, [MS-OLEPS]'s two-property-set spelling) is not attempted at all, an explicit scope boundary `archive-codec`'s own `oleps` support shares.
181
+
97
182
  ## Architecture
98
183
 
99
184
  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 +188,54 @@ import { readRecordAt } from "ppt-codec/record/tree";
103
188
  import { readStyleTextPropAtom } from "ppt-codec/text/style";
104
189
  ```
105
190
 
106
- | Module | What it owns |
107
- | ------------------------ | -------------------------------------------------------------------------------------------------------------------- |
108
- | `record/header` | The generic 8-byte record header and the container/atom distinction. |
109
- | `record/types` | The `RecordType` values this reader dispatches on, plus the [MS-ODRAW] types the drawing walk crosses into. |
110
- | `record/tree` | Offset-addressed records, sibling sequences, child walks, typed-descendant search. |
111
- | `stream/current-user` | `CurrentUserAtom`: where the live edit is, and whether the file is encrypted. |
112
- | `stream/persist` | `UserEditAtom`, `PersistDirectoryAtom`, and the persist directory the edit chain builds. |
113
- | `document/document-atom` | `DocumentAtom`: slide and notes sizes, master persist references. |
114
- | `document/fonts` | The font collection, resolved to typeface names a `FontIndexRef` indexes. |
115
- | `document/slide-list` | `SlideListWithTextContainer`: each slide's persist reference and its placeholder texts. |
116
- | `drawing/shapes` | The OfficeArt shape tree, flattened, with every anchor resolved into slide coordinates through its enclosing groups. |
117
- | `text/atoms` | The two text-body spellings, the text-type enumeration, and the paragraph split. |
118
- | `text/style` | `StyleTextPropAtom`'s two run arrays and their mask-driven exception structures. |
119
- | `content` | The mapping of PowerPoint's character-counted runs onto the schema's paragraph-owned runs. |
120
- | `read` | The whole pipeline, and the `readPpt`/`readPptContent`/`readPptStreams` surface. |
121
- | `units` | Master units to points. |
122
- | `errors` | `PptFormatError` for malformed input, `PptEncryptedError` for well-formed input this package cannot decrypt. |
191
+ | Module | What it owns |
192
+ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
193
+ | `record/header` | The generic 8-byte record header and the container/atom distinction. |
194
+ | `record/types` | The `RecordType` values this reader dispatches on, plus the [MS-ODRAW] types the drawing walk crosses into. |
195
+ | `record/tree` | Offset-addressed records, sibling sequences, child walks, typed-descendant search. |
196
+ | `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. |
197
+ | `stream/current-user` | `CurrentUserAtom`: where the live edit is, and whether the file is encrypted. |
198
+ | `stream/current-user-write` | Writes a real `CurrentUserAtom` pointing at the single edit this writer always produces. |
199
+ | `stream/persist` | `UserEditAtom`, `PersistDirectoryAtom`, and the persist directory the edit chain builds. |
200
+ | `stream/persist-write` | Writes a single-edit `UserEditAtom`/`PersistDirectoryAtom` pair covering the document container and every slide container. |
201
+ | `document/document-atom` | `DocumentAtom`: slide and notes sizes, master persist references. |
202
+ | `document/document-atom-write` | Writes a `DocumentAtom` for the one slide size every slide must share. |
203
+ | `document/fonts` | The font collection, resolved to typeface names a `FontIndexRef` indexes. |
204
+ | `document/fonts-write` | Writes an `Environment`/`FontCollectionContainer` from a document's own distinct font families. |
205
+ | `document/slide-list` | `SlideListWithTextContainer`: each slide's persist reference and its placeholder texts. |
206
+ | `document/slide-list-write` | Writes a `SlideListWithTextContainer` naming each slide's persist reference, with no placeholder texts. |
207
+ | `document/notes-list` | `NotesListWithTextContainer`: each notes slide's persist reference and its notes identifier. Its own module rather than a parameter of `slide-list`, because the container holds no texts and `NotesPersistAtom` spends on a reserved field what `SlidePersistAtom` spends on `cTexts`. |
208
+ | `document/notes-list-write` | Writes that container from the same `NotesPersist` shape the reader produces. |
209
+ | `document/notes` | A `NotesContainer`: its `NotesAtom` (which slide the notes belong to) and the notes text its drawing carries. |
210
+ | `document/notes-write` | Writes a `NotesContainer` for one slide's notes, through the same drawing writer a slide's own shapes go through. |
211
+ | `document/master-write` | The minimal `MainMasterContainer` and `MasterListWithTextContainer`, plus the `SlideAtom` every slide needs in order to name that master and its own notes slide. Write-only: nothing reads a `SlideAtom` yet. |
212
+ | `document/color-scheme-write` | The `SlideSchemeColorSchemeAtom` [MS-PPT] 2.9.51 gives four different containers, two of which this writer produces — the main master and every notes slide — so it belongs to neither of them. |
213
+ | `drawing/shapes` | The OfficeArt shape tree, flattened, with every anchor resolved into slide coordinates through its enclosing groups. |
214
+ | `drawing/shapes-write` | Writes the patriarch group and one plain, anchored `OfficeArtSpContainer` per shape. |
215
+ | `text/atoms` | The two text-body spellings, the text-type enumeration, and the paragraph split. |
216
+ | `text/style` | `StyleTextPropAtom`'s two run arrays and their mask-driven exception structures. |
217
+ | `text/style-write` | Writes a `StyleTextPropAtom` from the same `StyleRun`/`ParagraphProperties`/`CharacterProperties` shapes `text/style` reads into. |
218
+ | `content` | The mapping of PowerPoint's character-counted runs onto the schema's paragraph-owned runs. |
219
+ | `content-write` | The inverse: a shape's `ContentBlock[]` to the flat character-counted text body and `StyleTextProps` `text/style-write` needs. |
220
+ | `metadata` | Wraps `archive-codec`'s own `SummaryInformationProperties` <-> `LayoutMetadata` mapping with this package's `createdIso`/`modifiedIso` date validation, throwing `PptUnsupportedContentError` for a malformed one rather than letting an opaque `RangeError` escape the FILETIME conversion (see [Metadata](#metadata)). |
221
+ | `read` | The whole read pipeline, and the `readPpt`/`readPptContent`/`readPptStreams` surface. |
222
+ | `write` | The whole write pipeline, and the `writePpt`/`writePptContent`/`writePptStreams` surface. |
223
+ | `units` | Master units to points, and points to master units. |
224
+ | `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. |
123
225
 
124
226
  ### Every fixture is built from the specification, not captured
125
227
 
126
- 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.
228
+ 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.
127
229
 
128
230
  ### What it deliberately does not depend on
129
231
 
130
- 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.
232
+ 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.
131
233
 
132
234
  ## Conventions
133
235
 
134
236
  - 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.
135
237
  - 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.
136
- - Every structural failure throws `PptFormatError` rather than degrading: a malformed file fails whole, never returning a partial slide list that looks complete.
238
+ - 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.
137
239
 
138
240
  ## Specification references
139
241
 
@@ -146,12 +248,14 @@ Every field layout in this package is taken from a specification page, cited in
146
248
  - [[MS-PPT] 2.3.3: UserEditAtom](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/3ffb3fab-95de-4873-98aa-d508fbbac981) and [2.3.5: PersistDirectoryEntry](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/6214b5a6-7ca2-4a86-8a0e-5fd3d3eff1c9)
147
249
  - [[MS-PPT] 2.4.1: DocumentContainer](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/6254c4d1-5217-4e16-b20d-c04ddcce31c9) and [2.4.2: DocumentAtom](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/121f2728-3497-4a0a-829e-6f416fee2ee6)
148
250
  - [[MS-PPT] 2.4.14.3: SlideListWithTextContainer](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/307e6d12-7304-47a8-acbd-3e7b8041ad3c) and [2.4.14.5: SlidePersistAtom](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/48dce412-9692-4f93-aeb7-3d9fdd3a0a5a)
149
- - [[MS-PPT] 2.5.1: SlideContainer](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/4cac0976-73d0-4ab3-a70b-e98b3cf1c312) and [2.5.13: DrawingContainer](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/0595b49f-da96-4402-b353-1f766e9d548f)
251
+ - [[MS-PPT] 2.5.1: SlideContainer](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/4cac0976-73d0-4ab3-a70b-e98b3cf1c312), [2.5.2: SlideAtom](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/57e11e6c-e550-4c43-80b6-72731eee8abd), [2.5.3: MainMasterContainer](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/e2f5fbf3-d790-487e-b96b-5ccdee0f0aa8) and [2.5.13: DrawingContainer](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/0595b49f-da96-4402-b353-1f766e9d548f)
252
+ - [[MS-PPT] 2.4.14.6: NotesListWithTextContainer](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/55453e37-0674-4703-bd8d-fcaba335f840), [2.4.14.7: NotesPersistAtom](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/b595ad14-a46c-4fcc-b4bd-7298712043a4), [2.5.6: NotesContainer](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/50bfc0f7-c101-4c32-8754-6ca59772b785) and [2.5.7: NotesAtom](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/9bb3e352-1014-477b-b286-cd43127c3b74) — the speaker-notes records, plus [3.5.3: Notes Slides](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/0d430f90-17fc-4730-92c9-90198d19c13b), the worked example stating the notes-to-slide association
253
+ - [[MS-PPT] 2.9.22: PlaceholderAtom](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/5a37fba1-1e70-45d8-bda4-315730bd3c54), [2.13.21: PlaceholderEnum](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/dbfd04be-0776-44d9-b408-b07f4c951eaf), [2.13.28: SlideLayoutType](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/df8f3d7b-db67-47dc-8c89-20f5cbbf0fa9), [2.13.7: SlideFlags](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/a840eba8-ed03-47a0-a0c9-769236e21a0b), [2.9.31: TextMasterStyleAtom](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/5febad27-0c48-4f98-b655-562b986f5874) and [2.9.51: SlideSchemeColorSchemeAtom](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/9cfca750-dabb-4967-b133-2583a9f8c392) — what the written master is built from
150
254
  - [[MS-PPT] 2.7.1: OfficeArtClientAnchor](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/37ee18c7-3c7c-4adc-91fb-cb3b01789d72), [SmallRectStruct](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/e47cb973-8480-4995-90b2-008bcb2ffc65), [RectStruct](https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-ppt/8a58e3ae-2682-42d0-82cd-a41c2999584e)
151
255
  - [[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)
152
256
  - [[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)
153
257
  - [[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)
154
- - [[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`
258
+ - [[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`
155
259
 
156
260
  ## Install
157
261
 
@@ -0,0 +1,114 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ require("./text/style.cjs");
3
+ require("./text/atoms.cjs");
4
+ const require_units = require("./units.cjs");
5
+ //#region src/content-write.ts
6
+ const BYTE_MAX = 255;
7
+ function mapAlignmentToPpt(alignment) {
8
+ switch (alignment) {
9
+ case "left": return 0;
10
+ case "center": return 1;
11
+ case "right": return 2;
12
+ case "justify": return 3;
13
+ default: return;
14
+ }
15
+ }
16
+ /** document-schema.js's lineSpacing is always a positive multiple of single line height (the schema's own z.number().positive()), so this always writes ParaSpacing's percentage form -- there is no master-units case to choose between, unlike the read side's two-way branch. */
17
+ function lineSpacingToParaSpacing(multiple) {
18
+ return multiple === void 0 ? void 0 : Math.round(multiple * 100);
19
+ }
20
+ /** document-schema.js's spacingBeforePt/spacingAfterPt are always plain points, so this always writes ParaSpacing's negative (absolute master-units) form -- the percentage-of-line-height form has no point value to derive it from. */
21
+ function pointsToParaSpacing(pt) {
22
+ return pt === void 0 ? void 0 : -require_units.pointsToMasterUnits(pt);
23
+ }
24
+ function mapColorToPpt(color) {
25
+ if (color === void 0) return;
26
+ return {
27
+ red: Math.round(color.r * BYTE_MAX),
28
+ green: Math.round(color.g * BYTE_MAX),
29
+ blue: Math.round(color.b * BYTE_MAX)
30
+ };
31
+ }
32
+ function storedRunText(text) {
33
+ return text.split("\n").join("\v");
34
+ }
35
+ function characterPropertiesFrom(run, fontIndexOf) {
36
+ return {
37
+ bold: run.bold,
38
+ italic: run.italic,
39
+ underline: run.underline,
40
+ shadow: void 0,
41
+ emboss: void 0,
42
+ fontRef: run.fontFamily === void 0 ? void 0 : fontIndexOf(run.fontFamily),
43
+ sizePt: run.sizePt,
44
+ color: mapColorToPpt(run.color)
45
+ };
46
+ }
47
+ const EMPTY_CHARACTER_PROPERTIES = {
48
+ bold: void 0,
49
+ italic: void 0,
50
+ underline: void 0,
51
+ shadow: void 0,
52
+ emboss: void 0,
53
+ fontRef: void 0,
54
+ sizePt: void 0,
55
+ color: void 0
56
+ };
57
+ function buildTextBody(blocks, fontIndexOf) {
58
+ const paragraphs = blocks.filter((block) => block.kind === "paragraph");
59
+ const bodies = paragraphs.map((paragraph) => paragraph.runs.map((run) => storedRunText(run.text)).join(""));
60
+ const text = bodies.join("\r");
61
+ const paragraphRuns = [];
62
+ const characterRuns = [];
63
+ paragraphs.forEach((paragraph, index) => {
64
+ const bodyText = bodies[index] ?? "";
65
+ paragraphRuns.push({
66
+ count: bodyText.length + 1,
67
+ properties: {
68
+ indentLevel: paragraph.list?.level ?? 0,
69
+ alignment: mapAlignmentToPpt(paragraph.alignment),
70
+ lineSpacing: lineSpacingToParaSpacing(paragraph.lineSpacing),
71
+ spaceBefore: pointsToParaSpacing(paragraph.spacingBeforePt),
72
+ spaceAfter: pointsToParaSpacing(paragraph.spacingAfterPt),
73
+ leftMargin: paragraph.indentLeftPt === void 0 ? void 0 : require_units.pointsToMasterUnits(paragraph.indentLeftPt),
74
+ indent: paragraph.indentFirstLinePt === void 0 ? void 0 : require_units.pointsToMasterUnits(paragraph.indentFirstLinePt)
75
+ }
76
+ });
77
+ if (paragraph.runs.length === 0) {
78
+ characterRuns.push({
79
+ count: 1,
80
+ properties: EMPTY_CHARACTER_PROPERTIES
81
+ });
82
+ return;
83
+ }
84
+ paragraph.runs.forEach((run, runIndex) => {
85
+ const isLastRunOfParagraph = runIndex === paragraph.runs.length - 1;
86
+ characterRuns.push({
87
+ count: storedRunText(run.text).length + (isLastRunOfParagraph ? 1 : 0),
88
+ properties: characterPropertiesFrom(run, fontIndexOf)
89
+ });
90
+ });
91
+ });
92
+ return {
93
+ text,
94
+ style: {
95
+ paragraphRuns,
96
+ characterRuns
97
+ }
98
+ };
99
+ }
100
+ function collectFontFamilies(blocksList) {
101
+ const seen = /* @__PURE__ */ new Set();
102
+ const names = [];
103
+ for (const blocks of blocksList) for (const block of blocks) {
104
+ if (block.kind !== "paragraph") continue;
105
+ for (const run of block.runs) if (run.fontFamily !== void 0 && !seen.has(run.fontFamily)) {
106
+ seen.add(run.fontFamily);
107
+ names.push(run.fontFamily);
108
+ }
109
+ }
110
+ return names;
111
+ }
112
+ //#endregion
113
+ exports.buildTextBody = buildTextBody;
114
+ exports.collectFontFamilies = collectFontFamilies;
@@ -0,0 +1,11 @@
1
+ import { StyleTextProps } from "./text/style.cjs";
2
+ import { ContentBlock } from "document-schema.js";
3
+ //#region src/content-write.d.ts
4
+ interface TextBody {
5
+ readonly text: string;
6
+ readonly style: StyleTextProps;
7
+ }
8
+ declare function buildTextBody(blocks: readonly ContentBlock[], fontIndexOf: (family: string) => number): TextBody;
9
+ declare function collectFontFamilies(blocksList: readonly (readonly ContentBlock[])[]): string[];
10
+ //#endregion
11
+ export { TextBody, buildTextBody, collectFontFamilies };
@@ -0,0 +1,11 @@
1
+ import { StyleTextProps } from "./text/style.js";
2
+ import { ContentBlock } from "document-schema.js";
3
+ //#region src/content-write.d.ts
4
+ interface TextBody {
5
+ readonly text: string;
6
+ readonly style: StyleTextProps;
7
+ }
8
+ declare function buildTextBody(blocks: readonly ContentBlock[], fontIndexOf: (family: string) => number): TextBody;
9
+ declare function collectFontFamilies(blocksList: readonly (readonly ContentBlock[])[]): string[];
10
+ //#endregion
11
+ export { TextBody, buildTextBody, collectFontFamilies };
@@ -0,0 +1,112 @@
1
+ import "./text/style.js";
2
+ import "./text/atoms.js";
3
+ import { pointsToMasterUnits } from "./units.js";
4
+ //#region src/content-write.ts
5
+ const BYTE_MAX = 255;
6
+ function mapAlignmentToPpt(alignment) {
7
+ switch (alignment) {
8
+ case "left": return 0;
9
+ case "center": return 1;
10
+ case "right": return 2;
11
+ case "justify": return 3;
12
+ default: return;
13
+ }
14
+ }
15
+ /** document-schema.js's lineSpacing is always a positive multiple of single line height (the schema's own z.number().positive()), so this always writes ParaSpacing's percentage form -- there is no master-units case to choose between, unlike the read side's two-way branch. */
16
+ function lineSpacingToParaSpacing(multiple) {
17
+ return multiple === void 0 ? void 0 : Math.round(multiple * 100);
18
+ }
19
+ /** document-schema.js's spacingBeforePt/spacingAfterPt are always plain points, so this always writes ParaSpacing's negative (absolute master-units) form -- the percentage-of-line-height form has no point value to derive it from. */
20
+ function pointsToParaSpacing(pt) {
21
+ return pt === void 0 ? void 0 : -pointsToMasterUnits(pt);
22
+ }
23
+ function mapColorToPpt(color) {
24
+ if (color === void 0) return;
25
+ return {
26
+ red: Math.round(color.r * BYTE_MAX),
27
+ green: Math.round(color.g * BYTE_MAX),
28
+ blue: Math.round(color.b * BYTE_MAX)
29
+ };
30
+ }
31
+ function storedRunText(text) {
32
+ return text.split("\n").join("\v");
33
+ }
34
+ function characterPropertiesFrom(run, fontIndexOf) {
35
+ return {
36
+ bold: run.bold,
37
+ italic: run.italic,
38
+ underline: run.underline,
39
+ shadow: void 0,
40
+ emboss: void 0,
41
+ fontRef: run.fontFamily === void 0 ? void 0 : fontIndexOf(run.fontFamily),
42
+ sizePt: run.sizePt,
43
+ color: mapColorToPpt(run.color)
44
+ };
45
+ }
46
+ const EMPTY_CHARACTER_PROPERTIES = {
47
+ bold: void 0,
48
+ italic: void 0,
49
+ underline: void 0,
50
+ shadow: void 0,
51
+ emboss: void 0,
52
+ fontRef: void 0,
53
+ sizePt: void 0,
54
+ color: void 0
55
+ };
56
+ function buildTextBody(blocks, fontIndexOf) {
57
+ const paragraphs = blocks.filter((block) => block.kind === "paragraph");
58
+ const bodies = paragraphs.map((paragraph) => paragraph.runs.map((run) => storedRunText(run.text)).join(""));
59
+ const text = bodies.join("\r");
60
+ const paragraphRuns = [];
61
+ const characterRuns = [];
62
+ paragraphs.forEach((paragraph, index) => {
63
+ const bodyText = bodies[index] ?? "";
64
+ paragraphRuns.push({
65
+ count: bodyText.length + 1,
66
+ properties: {
67
+ indentLevel: paragraph.list?.level ?? 0,
68
+ alignment: mapAlignmentToPpt(paragraph.alignment),
69
+ lineSpacing: lineSpacingToParaSpacing(paragraph.lineSpacing),
70
+ spaceBefore: pointsToParaSpacing(paragraph.spacingBeforePt),
71
+ spaceAfter: pointsToParaSpacing(paragraph.spacingAfterPt),
72
+ leftMargin: paragraph.indentLeftPt === void 0 ? void 0 : pointsToMasterUnits(paragraph.indentLeftPt),
73
+ indent: paragraph.indentFirstLinePt === void 0 ? void 0 : pointsToMasterUnits(paragraph.indentFirstLinePt)
74
+ }
75
+ });
76
+ if (paragraph.runs.length === 0) {
77
+ characterRuns.push({
78
+ count: 1,
79
+ properties: EMPTY_CHARACTER_PROPERTIES
80
+ });
81
+ return;
82
+ }
83
+ paragraph.runs.forEach((run, runIndex) => {
84
+ const isLastRunOfParagraph = runIndex === paragraph.runs.length - 1;
85
+ characterRuns.push({
86
+ count: storedRunText(run.text).length + (isLastRunOfParagraph ? 1 : 0),
87
+ properties: characterPropertiesFrom(run, fontIndexOf)
88
+ });
89
+ });
90
+ });
91
+ return {
92
+ text,
93
+ style: {
94
+ paragraphRuns,
95
+ characterRuns
96
+ }
97
+ };
98
+ }
99
+ function collectFontFamilies(blocksList) {
100
+ const seen = /* @__PURE__ */ new Set();
101
+ const names = [];
102
+ for (const blocks of blocksList) for (const block of blocks) {
103
+ if (block.kind !== "paragraph") continue;
104
+ for (const run of block.runs) if (run.fontFamily !== void 0 && !seen.has(run.fontFamily)) {
105
+ seen.add(run.fontFamily);
106
+ names.push(run.fontFamily);
107
+ }
108
+ }
109
+ return names;
110
+ }
111
+ //#endregion
112
+ export { buildTextBody, collectFontFamilies };