@sanity-labs/slides 0.0.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 (224) hide show
  1. package/README.md +241 -0
  2. package/SKILL.md +119 -0
  3. package/dist/cli.d.ts +38 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +386 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/core/components.d.ts +179 -0
  8. package/dist/core/components.d.ts.map +1 -0
  9. package/dist/core/components.js +40 -0
  10. package/dist/core/components.js.map +1 -0
  11. package/dist/core/fake-runtime.d.ts +138 -0
  12. package/dist/core/fake-runtime.d.ts.map +1 -0
  13. package/dist/core/fake-runtime.js +210 -0
  14. package/dist/core/fake-runtime.js.map +1 -0
  15. package/dist/core/font-resolver.d.ts +28 -0
  16. package/dist/core/font-resolver.d.ts.map +1 -0
  17. package/dist/core/font-resolver.js +30 -0
  18. package/dist/core/font-resolver.js.map +1 -0
  19. package/dist/core/geometry.d.ts +71 -0
  20. package/dist/core/geometry.d.ts.map +1 -0
  21. package/dist/core/geometry.js +44 -0
  22. package/dist/core/geometry.js.map +1 -0
  23. package/dist/core/index.d.ts +19 -0
  24. package/dist/core/index.d.ts.map +1 -0
  25. package/dist/core/index.js +20 -0
  26. package/dist/core/index.js.map +1 -0
  27. package/dist/core/manifest.d.ts +123 -0
  28. package/dist/core/manifest.d.ts.map +1 -0
  29. package/dist/core/manifest.js +43 -0
  30. package/dist/core/manifest.js.map +1 -0
  31. package/dist/core/op-translator-pptx.d.ts +150 -0
  32. package/dist/core/op-translator-pptx.d.ts.map +1 -0
  33. package/dist/core/op-translator-pptx.js +245 -0
  34. package/dist/core/op-translator-pptx.js.map +1 -0
  35. package/dist/core/pptx-runtime.d.ts +103 -0
  36. package/dist/core/pptx-runtime.d.ts.map +1 -0
  37. package/dist/core/pptx-runtime.js +405 -0
  38. package/dist/core/pptx-runtime.js.map +1 -0
  39. package/dist/core/reconciler.d.ts +113 -0
  40. package/dist/core/reconciler.d.ts.map +1 -0
  41. package/dist/core/reconciler.js +453 -0
  42. package/dist/core/reconciler.js.map +1 -0
  43. package/dist/core/runtime.d.ts +161 -0
  44. package/dist/core/runtime.d.ts.map +1 -0
  45. package/dist/core/runtime.js +11 -0
  46. package/dist/core/runtime.js.map +1 -0
  47. package/dist/core/template.d.ts +32 -0
  48. package/dist/core/template.d.ts.map +1 -0
  49. package/dist/core/template.js +3 -0
  50. package/dist/core/template.js.map +1 -0
  51. package/dist/dev/auto-examples.d.ts +6 -0
  52. package/dist/dev/auto-examples.d.ts.map +1 -0
  53. package/dist/dev/auto-examples.js +79 -0
  54. package/dist/dev/auto-examples.js.map +1 -0
  55. package/dist/dev/bin/slides-dev.d.ts +3 -0
  56. package/dist/dev/bin/slides-dev.d.ts.map +1 -0
  57. package/dist/dev/bin/slides-dev.js +87 -0
  58. package/dist/dev/bin/slides-dev.js.map +1 -0
  59. package/dist/dev/bin/slides-dev.mjs +24 -0
  60. package/dist/dev/compose-deck.d.ts +18 -0
  61. package/dist/dev/compose-deck.d.ts.map +1 -0
  62. package/dist/dev/compose-deck.js +19 -0
  63. package/dist/dev/compose-deck.js.map +1 -0
  64. package/dist/dev/deck-viewer.d.ts +19 -0
  65. package/dist/dev/deck-viewer.d.ts.map +1 -0
  66. package/dist/dev/deck-viewer.js +237 -0
  67. package/dist/dev/deck-viewer.js.map +1 -0
  68. package/dist/dev/dev-server/client/entry.d.ts +2 -0
  69. package/dist/dev/dev-server/client/entry.d.ts.map +1 -0
  70. package/dist/dev/dev-server/client/entry.js +12 -0
  71. package/dist/dev/dev-server/client/entry.js.map +1 -0
  72. package/dist/dev/dev-server/output.d.ts +8 -0
  73. package/dist/dev/dev-server/output.d.ts.map +1 -0
  74. package/dist/dev/dev-server/output.js +32 -0
  75. package/dist/dev/dev-server/output.js.map +1 -0
  76. package/dist/dev/dev-server/server-only-stub.d.ts +7 -0
  77. package/dist/dev/dev-server/server-only-stub.d.ts.map +1 -0
  78. package/dist/dev/dev-server/server-only-stub.js +12 -0
  79. package/dist/dev/dev-server/server-only-stub.js.map +1 -0
  80. package/dist/dev/dev-server/start.d.ts +14 -0
  81. package/dist/dev/dev-server/start.d.ts.map +1 -0
  82. package/dist/dev/dev-server/start.js +135 -0
  83. package/dist/dev/dev-server/start.js.map +1 -0
  84. package/dist/dev/index.d.ts +5 -0
  85. package/dist/dev/index.d.ts.map +1 -0
  86. package/dist/dev/index.js +5 -0
  87. package/dist/dev/index.js.map +1 -0
  88. package/dist/dev/lib/cn.d.ts +3 -0
  89. package/dist/dev/lib/cn.d.ts.map +1 -0
  90. package/dist/dev/lib/cn.js +3 -0
  91. package/dist/dev/lib/cn.js.map +1 -0
  92. package/dist/dev/slide-canvas.d.ts +12 -0
  93. package/dist/dev/slide-canvas.d.ts.map +1 -0
  94. package/dist/dev/slide-canvas.js +123 -0
  95. package/dist/dev/slide-canvas.js.map +1 -0
  96. package/dist/dev/styles.css +37 -0
  97. package/dist/dev/ui/icon-button.d.ts +12 -0
  98. package/dist/dev/ui/icon-button.d.ts.map +1 -0
  99. package/dist/dev/ui/icon-button.js +6 -0
  100. package/dist/dev/ui/icon-button.js.map +1 -0
  101. package/dist/dev/ui/kbd.d.ts +6 -0
  102. package/dist/dev/ui/kbd.d.ts.map +1 -0
  103. package/dist/dev/ui/kbd.js +4 -0
  104. package/dist/dev/ui/kbd.js.map +1 -0
  105. package/dist/dev/ui/text-button.d.ts +10 -0
  106. package/dist/dev/ui/text-button.d.ts.map +1 -0
  107. package/dist/dev/ui/text-button.js +6 -0
  108. package/dist/dev/ui/text-button.js.map +1 -0
  109. package/dist/dev/url-state.d.ts +7 -0
  110. package/dist/dev/url-state.d.ts.map +1 -0
  111. package/dist/dev/url-state.js +13 -0
  112. package/dist/dev/url-state.js.map +1 -0
  113. package/dist/dev/use-keyboard-nav.d.ts +17 -0
  114. package/dist/dev/use-keyboard-nav.d.ts.map +1 -0
  115. package/dist/dev/use-keyboard-nav.js +53 -0
  116. package/dist/dev/use-keyboard-nav.js.map +1 -0
  117. package/dist/index.d.ts +17 -0
  118. package/dist/index.d.ts.map +1 -0
  119. package/dist/index.js +17 -0
  120. package/dist/index.js.map +1 -0
  121. package/dist/mcp/errors.d.ts +57 -0
  122. package/dist/mcp/errors.d.ts.map +1 -0
  123. package/dist/mcp/errors.js +44 -0
  124. package/dist/mcp/errors.js.map +1 -0
  125. package/dist/mcp/index.d.ts +29 -0
  126. package/dist/mcp/index.d.ts.map +1 -0
  127. package/dist/mcp/index.js +29 -0
  128. package/dist/mcp/index.js.map +1 -0
  129. package/dist/mcp/naming.d.ts +37 -0
  130. package/dist/mcp/naming.d.ts.map +1 -0
  131. package/dist/mcp/naming.js +43 -0
  132. package/dist/mcp/naming.js.map +1 -0
  133. package/dist/mcp/render.d.ts +45 -0
  134. package/dist/mcp/render.d.ts.map +1 -0
  135. package/dist/mcp/render.js +77 -0
  136. package/dist/mcp/render.js.map +1 -0
  137. package/dist/mcp/schema.d.ts +54 -0
  138. package/dist/mcp/schema.d.ts.map +1 -0
  139. package/dist/mcp/schema.js +55 -0
  140. package/dist/mcp/schema.js.map +1 -0
  141. package/dist/mcp/server.d.ts +63 -0
  142. package/dist/mcp/server.d.ts.map +1 -0
  143. package/dist/mcp/server.js +196 -0
  144. package/dist/mcp/server.js.map +1 -0
  145. package/dist/scaffold/index.d.ts +39 -0
  146. package/dist/scaffold/index.d.ts.map +1 -0
  147. package/dist/scaffold/index.js +84 -0
  148. package/dist/scaffold/index.js.map +1 -0
  149. package/dist/scaffold/template-base/README.md +134 -0
  150. package/dist/scaffold/template-base/_gitignore +4 -0
  151. package/dist/scaffold/template-base/package.json +35 -0
  152. package/dist/scaffold/template-base/src/components/Cover.tsx +30 -0
  153. package/dist/scaffold/template-base/src/index.ts +27 -0
  154. package/dist/scaffold/template-base/src/preview.tsx +9 -0
  155. package/dist/scaffold/template-base/tsconfig.build.json +10 -0
  156. package/dist/scaffold/template-base/tsconfig.json +18 -0
  157. package/package.json +164 -0
  158. package/src/__tests__/fixtures/test-template/index.tsx +77 -0
  159. package/src/__tests__/pptx-mcp.test.ts +85 -0
  160. package/src/__tests__/pptx-smoke.test.ts +45 -0
  161. package/src/__tests__/preview.test.ts +28 -0
  162. package/src/cli.ts +426 -0
  163. package/src/core/__snapshots__/reconciler.test.ts.snap +320 -0
  164. package/src/core/components.test.ts +57 -0
  165. package/src/core/components.ts +196 -0
  166. package/src/core/fake-runtime.test.ts +174 -0
  167. package/src/core/fake-runtime.ts +302 -0
  168. package/src/core/font-resolver.ts +46 -0
  169. package/src/core/geometry.test.ts +58 -0
  170. package/src/core/geometry.ts +91 -0
  171. package/src/core/index.ts +69 -0
  172. package/src/core/manifest.test.ts +33 -0
  173. package/src/core/manifest.ts +150 -0
  174. package/src/core/op-translator-pptx.test.ts +204 -0
  175. package/src/core/op-translator-pptx.ts +365 -0
  176. package/src/core/pptx-runtime.test.ts +137 -0
  177. package/src/core/pptx-runtime.ts +504 -0
  178. package/src/core/reconciler.test.ts +644 -0
  179. package/src/core/reconciler.ts +603 -0
  180. package/src/core/runtime.ts +150 -0
  181. package/src/core/template.test.ts +136 -0
  182. package/src/core/template.ts +37 -0
  183. package/src/dev/auto-examples.ts +89 -0
  184. package/src/dev/bin/slides-dev.mjs +24 -0
  185. package/src/dev/bin/slides-dev.ts +101 -0
  186. package/src/dev/compose-deck.test.ts +68 -0
  187. package/src/dev/compose-deck.ts +40 -0
  188. package/src/dev/deck-viewer.tsx +677 -0
  189. package/src/dev/dev-server/client/entry.tsx +15 -0
  190. package/src/dev/dev-server/client/index.html +24 -0
  191. package/src/dev/dev-server/output.ts +37 -0
  192. package/src/dev/dev-server/server-only-stub.ts +12 -0
  193. package/src/dev/dev-server/start.ts +155 -0
  194. package/src/dev/index.ts +4 -0
  195. package/src/dev/lib/cn.ts +3 -0
  196. package/src/dev/slide-canvas.test.tsx +66 -0
  197. package/src/dev/slide-canvas.tsx +170 -0
  198. package/src/dev/styles.css +37 -0
  199. package/src/dev/ui/icon-button.tsx +31 -0
  200. package/src/dev/ui/kbd.tsx +20 -0
  201. package/src/dev/ui/text-button.tsx +31 -0
  202. package/src/dev/url-state.test.ts +22 -0
  203. package/src/dev/url-state.ts +17 -0
  204. package/src/dev/use-keyboard-nav.ts +64 -0
  205. package/src/index.ts +17 -0
  206. package/src/mcp/errors.test.ts +51 -0
  207. package/src/mcp/errors.ts +76 -0
  208. package/src/mcp/index.ts +45 -0
  209. package/src/mcp/naming.test.ts +39 -0
  210. package/src/mcp/naming.ts +49 -0
  211. package/src/mcp/render.ts +110 -0
  212. package/src/mcp/schema.test.ts +86 -0
  213. package/src/mcp/schema.ts +93 -0
  214. package/src/mcp/server.test.ts +309 -0
  215. package/src/mcp/server.ts +276 -0
  216. package/src/scaffold/index.ts +102 -0
  217. package/src/scaffold/template-base/README.md +134 -0
  218. package/src/scaffold/template-base/_gitignore +4 -0
  219. package/src/scaffold/template-base/package.json +35 -0
  220. package/src/scaffold/template-base/src/components/Cover.tsx +30 -0
  221. package/src/scaffold/template-base/src/index.ts +27 -0
  222. package/src/scaffold/template-base/src/preview.tsx +9 -0
  223. package/src/scaffold/template-base/tsconfig.build.json +10 -0
  224. package/src/scaffold/template-base/tsconfig.json +18 -0
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Pure translation from `SlideOp` (the reconciler's typed intent) to a
3
+ * compact intermediate representation that pptxgenjs can consume.
4
+ *
5
+ * # Why not emit pptxgenjs calls directly
6
+ *
7
+ * pptxgenjs is stateful: `slide.addText(...)` mutates the Slide. The
8
+ * `SlideOp` stream is sequential — `createShape` is followed by
9
+ * `insertText`, `updateTextStyle`, etc. — so a one-pass-per-op translator
10
+ * would have to issue `addText` immediately on `createShape`, then mutate
11
+ * the already-added text on style ops, which pptxgenjs doesn't support.
12
+ *
13
+ * Instead, this module collapses an op stream into an array of
14
+ * `PptxObject` records — one per slide-level object. The runtime then walks
15
+ * the records and emits pptxgenjs calls in one shot per shape.
16
+ *
17
+ * # Unit conversion
18
+ *
19
+ * SlideOps carry positions in EMU. pptxgenjs uses inches. This module is
20
+ * the single boundary that converts EMU → inches; the substrate's
21
+ * pt-then-EMU-at-emit choice is unchanged.
22
+ *
23
+ * # Per-runtime font substitution
24
+ *
25
+ * The translator accepts a `fontSubstitution` map. When a SlideOp's
26
+ * `updateTextStyle.fontFamily` matches a key, the value replaces it on
27
+ * emit. PPTX cannot embed fonts; viewers need the family installed locally.
28
+ * Brands ship their own substitution table (see `@sanity-labs/slides` for an
29
+ * example); the substrate's default is empty.
30
+ *
31
+ * # Known gaps
32
+ *
33
+ * - **Outline weight + outline color on TEXT_BOX shapes.** pptxgenjs
34
+ * supports outlines via `line` on `addShape`, but text boxes go through
35
+ * `addText` whose options don't carry an outline. If a brand needs
36
+ * outlined text boxes, emit a sibling rectangle.
37
+ * - **Manifest persistence into the .pptx file.** The runtime holds the
38
+ * manifest in memory only; re-fill against a previously-generated .pptx
39
+ * is out of scope.
40
+ */
41
+
42
+ import { EMU_PER_INCH } from './geometry.js';
43
+ import type {
44
+ EmuRect,
45
+ HexColor,
46
+ ParagraphStyle,
47
+ ShapeProperties,
48
+ SlideOp,
49
+ TextStyle,
50
+ } from './runtime.js';
51
+
52
+ /**
53
+ * The pptxgenjs-shaped representation of a single object on a slide.
54
+ * Discriminated by `kind` so the runtime can dispatch `addText` vs
55
+ * `addShape` vs `addImage`.
56
+ */
57
+ export type PptxObject = PptxText | PptxRectangle | PptxImage;
58
+
59
+ /** A text box (TEXT_BOX shape) — emitted via `slide.addText`. */
60
+ export interface PptxText {
61
+ readonly kind: 'text';
62
+ readonly slideId: string;
63
+ readonly objectId: string;
64
+ /** Position, in inches. */
65
+ readonly position: PptxPosition;
66
+ /** Background fill color, no leading `#`. */
67
+ readonly fill?: string;
68
+ /** Plain text content. Empty string when no `insertText` op was applied. */
69
+ readonly text: string;
70
+ /**
71
+ * Per-range text-style spans. The runtime composes these into an array
72
+ * of `TextProps` segments for pptxgenjs.
73
+ *
74
+ * Spans are recorded in op order; later spans on overlapping ranges win
75
+ * at composition time (matches FakeSlidesRuntime semantics).
76
+ */
77
+ readonly textSpans: ReadonlyArray<{ start: number; end: number; style: TextStyle }>;
78
+ /** Paragraph-level style — pptxgenjs applies one set per addText call. */
79
+ readonly paragraphStyle?: ParagraphStyle;
80
+ }
81
+
82
+ /** A non-text rectangle/ellipse — emitted via `slide.addShape`. */
83
+ export interface PptxRectangle {
84
+ readonly kind: 'rectangle';
85
+ readonly slideId: string;
86
+ readonly objectId: string;
87
+ readonly position: PptxPosition;
88
+ readonly shape: 'rect' | 'ellipse' | 'line';
89
+ readonly fill?: string;
90
+ readonly outlineColor?: string;
91
+ readonly outlineWeight?: number;
92
+ }
93
+
94
+ /** An image — emitted via `slide.addImage`. */
95
+ export interface PptxImage {
96
+ readonly kind: 'image';
97
+ readonly slideId: string;
98
+ readonly objectId: string;
99
+ readonly position: PptxPosition;
100
+ readonly url: string;
101
+ readonly altText?: string;
102
+ }
103
+
104
+ /** Position in inches — pptxgenjs's native unit. */
105
+ export interface PptxPosition {
106
+ readonly x: number;
107
+ readonly y: number;
108
+ readonly w: number;
109
+ readonly h: number;
110
+ }
111
+
112
+ /** What the translator emits for a whole `applyOps` batch. */
113
+ export interface PptxBatch {
114
+ /**
115
+ * Slides to create, in op order. For each entry, `insertAt` mirrors the
116
+ * SlideOp's index (currently informational; pptxgenjs appends in
117
+ * call order).
118
+ */
119
+ readonly slides: ReadonlyArray<{ slideId: string; insertAt: number | undefined }>;
120
+ /**
121
+ * Per-slide objects, in creation order. The runtime iterates and emits
122
+ * the corresponding pptxgenjs call.
123
+ */
124
+ readonly objects: readonly PptxObject[];
125
+ /** Object IDs (slide / shape / image) created by this batch. */
126
+ readonly createdObjectIds: readonly string[];
127
+ }
128
+
129
+ /**
130
+ * Per-call translator options. The font-substitution map is the Option X
131
+ * font resolver — see `pptx-runtime.ts` for the rationale.
132
+ */
133
+ export interface TranslateOptions {
134
+ /**
135
+ * Map of resolved-font-name → output-font-name. When an op's
136
+ * `fontFamily` is a key, the value replaces it. Pass-through when not.
137
+ */
138
+ readonly fontSubstitution?: Readonly<Record<string, string>>;
139
+ /**
140
+ * Optional warning hook for fonts that aren't in the substitution table.
141
+ * Default: no-op. The runtime itself decides whether to log; libraries
142
+ * should not write to stdout/stderr without opt-in.
143
+ */
144
+ readonly onUnknownFont?: ((fontFamily: string) => void) | null;
145
+ }
146
+
147
+ /**
148
+ * Walk a SlideOp stream and produce the pptxgenjs-shaped batch.
149
+ *
150
+ * The translator is stateful within a single call: shapes accumulate
151
+ * styles from later ops. Across calls, it's pure — given the same ops
152
+ * and options, output is deterministic.
153
+ */
154
+ export const translateOpsToPptx = (
155
+ ops: readonly SlideOp[],
156
+ options: TranslateOptions = {},
157
+ ): PptxBatch => {
158
+ const slides: { slideId: string; insertAt: number | undefined }[] = [];
159
+ const objects: PptxObject[] = [];
160
+ const objectIndex = new Map<string, number>();
161
+ const createdObjectIds: string[] = [];
162
+ const subs = options.fontSubstitution ?? {};
163
+ const warned = new Set<string>();
164
+ // Default to a no-op: libraries shouldn't write to stdout/stderr without
165
+ // explicit opt-in. The runtime can pass `console.warn` (or its own logger)
166
+ // when it actually wants user-facing warnings.
167
+ const warn = options.onUnknownFont ?? noopWarn;
168
+
169
+ const upsertText = (slideId: string, objectId: string, position: PptxPosition): PptxText => {
170
+ const idx = objectIndex.get(objectId);
171
+ if (idx !== undefined) {
172
+ const existing = objects[idx];
173
+ if (existing && existing.kind === 'text') return existing;
174
+ throw new Error(
175
+ `translateOpsToPptx: object "${objectId}" already exists as a non-text ${existing?.kind ?? 'unknown'}.`,
176
+ );
177
+ }
178
+ const text: PptxText = {
179
+ kind: 'text',
180
+ slideId,
181
+ objectId,
182
+ position,
183
+ text: '',
184
+ textSpans: [],
185
+ } as PptxText;
186
+ objectIndex.set(objectId, objects.length);
187
+ objects.push(text);
188
+ createdObjectIds.push(objectId);
189
+ return text;
190
+ };
191
+
192
+ const findText = (objectId: string, opName: string): PptxText => {
193
+ const idx = objectIndex.get(objectId);
194
+ if (idx === undefined) {
195
+ throw new Error(`translateOpsToPptx: ${opName} targets unknown object "${objectId}".`);
196
+ }
197
+ const obj = objects[idx];
198
+ if (!obj || obj.kind !== 'text') {
199
+ throw new Error(`translateOpsToPptx: ${opName} targets non-text object "${objectId}".`);
200
+ }
201
+ return obj;
202
+ };
203
+
204
+ const replaceObject = (idx: number, replacement: PptxObject): void => {
205
+ objects[idx] = replacement;
206
+ };
207
+
208
+ for (const op of ops) {
209
+ switch (op.type) {
210
+ case 'createSlide': {
211
+ slides.push({ slideId: op.slideId, insertAt: op.insertAt });
212
+ createdObjectIds.push(op.slideId);
213
+ break;
214
+ }
215
+ case 'createShape': {
216
+ const position = emuRectToInches(op.rect);
217
+ if (op.shape === 'TEXT_BOX') {
218
+ upsertText(op.slideId, op.shapeId, position);
219
+ } else {
220
+ // RECTANGLE / ELLIPSE / LINE — emit as pptxgenjs shape.
221
+ const rect: PptxRectangle = {
222
+ kind: 'rectangle',
223
+ slideId: op.slideId,
224
+ objectId: op.shapeId,
225
+ position,
226
+ shape: shapeKindToPptx(op.shape),
227
+ };
228
+ objectIndex.set(op.shapeId, objects.length);
229
+ objects.push(rect);
230
+ createdObjectIds.push(op.shapeId);
231
+ }
232
+ break;
233
+ }
234
+ case 'createImage': {
235
+ const image: PptxImage = {
236
+ kind: 'image',
237
+ slideId: op.slideId,
238
+ objectId: op.imageId,
239
+ position: emuRectToInches(op.rect),
240
+ url: op.url,
241
+ ...(op.altText !== undefined ? { altText: op.altText } : {}),
242
+ };
243
+ objectIndex.set(op.imageId, objects.length);
244
+ objects.push(image);
245
+ createdObjectIds.push(op.imageId);
246
+ break;
247
+ }
248
+ case 'insertText': {
249
+ const target = findText(op.objectId, 'insertText');
250
+ const idx = objectIndex.get(op.objectId);
251
+ if (idx === undefined) break;
252
+ replaceObject(idx, { ...target, text: op.text });
253
+ break;
254
+ }
255
+ case 'updateTextStyle': {
256
+ const target = findText(op.objectId, 'updateTextStyle');
257
+ const idx = objectIndex.get(op.objectId);
258
+ if (idx === undefined) break;
259
+ const style = applyFontSubstitution(op.style, subs, warn, warned);
260
+ const span = { start: op.range.start, end: op.range.end, style };
261
+ replaceObject(idx, { ...target, textSpans: [...target.textSpans, span] });
262
+ break;
263
+ }
264
+ case 'updateParagraphStyle': {
265
+ const target = findText(op.objectId, 'updateParagraphStyle');
266
+ const idx = objectIndex.get(op.objectId);
267
+ if (idx === undefined) break;
268
+ replaceObject(idx, {
269
+ ...target,
270
+ paragraphStyle: { ...(target.paragraphStyle ?? {}), ...op.style },
271
+ });
272
+ break;
273
+ }
274
+ case 'updateShapeProperties': {
275
+ const idx = objectIndex.get(op.objectId);
276
+ if (idx === undefined) {
277
+ throw new Error(
278
+ `translateOpsToPptx: updateShapeProperties targets unknown object "${op.objectId}".`,
279
+ );
280
+ }
281
+ const obj = objects[idx];
282
+ if (!obj) break;
283
+ replaceObject(idx, mergeShapeProperties(obj, op.properties));
284
+ break;
285
+ }
286
+ }
287
+ }
288
+
289
+ return { slides, objects, createdObjectIds };
290
+ };
291
+
292
+ // ---------------------------------------------------------------------------
293
+ // Helpers
294
+ // ---------------------------------------------------------------------------
295
+
296
+ /** Convert EMU rect to inches (pptxgenjs's native unit). */
297
+ const emuRectToInches = (rect: EmuRect): PptxPosition => ({
298
+ x: rect.x / EMU_PER_INCH,
299
+ y: rect.y / EMU_PER_INCH,
300
+ w: rect.w / EMU_PER_INCH,
301
+ h: rect.h / EMU_PER_INCH,
302
+ });
303
+
304
+ const shapeKindToPptx = (kind: 'RECTANGLE' | 'ELLIPSE' | 'LINE'): 'rect' | 'ellipse' | 'line' => {
305
+ switch (kind) {
306
+ case 'RECTANGLE':
307
+ return 'rect';
308
+ case 'ELLIPSE':
309
+ return 'ellipse';
310
+ case 'LINE':
311
+ return 'line';
312
+ }
313
+ };
314
+
315
+ /**
316
+ * Strip the leading `#` from a hex color and return uppercase. pptxgenjs
317
+ * uses bare hex (e.g., `'FF5500'`).
318
+ */
319
+ export const hexToPptxColor = (hex: HexColor): string => {
320
+ const stripped = hex.startsWith('#') ? hex.slice(1) : hex;
321
+ return stripped.toUpperCase();
322
+ };
323
+
324
+ const mergeShapeProperties = (obj: PptxObject, props: ShapeProperties): PptxObject => {
325
+ if (obj.kind === 'text') {
326
+ return {
327
+ ...obj,
328
+ ...(props.fillColor !== undefined ? { fill: hexToPptxColor(props.fillColor) } : {}),
329
+ };
330
+ }
331
+ if (obj.kind === 'rectangle') {
332
+ const next: PptxRectangle = { ...obj };
333
+ if (props.fillColor !== undefined)
334
+ (next as { fill?: string }).fill = hexToPptxColor(props.fillColor);
335
+ if (props.outlineColor !== undefined)
336
+ (next as { outlineColor?: string }).outlineColor = hexToPptxColor(props.outlineColor);
337
+ if (props.outlineWeight !== undefined)
338
+ (next as { outlineWeight?: number }).outlineWeight = props.outlineWeight;
339
+ return next;
340
+ }
341
+ // image — no shape properties apply
342
+ return obj;
343
+ };
344
+
345
+ const applyFontSubstitution = (
346
+ style: TextStyle,
347
+ subs: Readonly<Record<string, string>>,
348
+ warn: (font: string) => void,
349
+ warned: Set<string>,
350
+ ): TextStyle => {
351
+ if (style.fontFamily === undefined) return style;
352
+ const sub = subs[style.fontFamily];
353
+ if (sub !== undefined) {
354
+ if (sub === style.fontFamily) return style;
355
+ return { ...style, fontFamily: sub };
356
+ }
357
+ // Not in substitution table — emit warning once per font, then pass through.
358
+ if (!warned.has(style.fontFamily)) {
359
+ warned.add(style.fontFamily);
360
+ warn(style.fontFamily);
361
+ }
362
+ return style;
363
+ };
364
+
365
+ const noopWarn = (_font: string): void => {};
@@ -0,0 +1,137 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { promises as fs } from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { PptxSlidesRuntime, DEFAULT_PPTX_FONT_SUBSTITUTION } from './pptx-runtime.js';
6
+ import { ptToEmu } from './geometry.js';
7
+ import type { SlideOp } from './runtime.js';
8
+
9
+ const tmpDir = async (): Promise<string> => {
10
+ return fs.mkdtemp(path.join(os.tmpdir(), 'pptx-runtime-test-'));
11
+ };
12
+
13
+ const isZipMagicNumber = (buf: Buffer): boolean =>
14
+ // PPTX is a ZIP — first two bytes are 'PK' (0x50, 0x4B).
15
+ buf.length >= 2 && buf[0] === 0x50 && buf[1] === 0x4b;
16
+
17
+ describe('PptxSlidesRuntime', () => {
18
+ it('createDeckFromMaster mints a deckId and tracks it', async () => {
19
+ const runtime = new PptxSlidesRuntime();
20
+ const { deckId } = await runtime.createDeckFromMaster('sanity:cover-v1', 'Test Deck');
21
+ expect(deckId).toMatch(/^pptx-deck-\d+$/);
22
+ expect(runtime.listDeckIds()).toContain(deckId);
23
+ });
24
+
25
+ it('applyOps emits createObjectIds containing every created id', async () => {
26
+ const runtime = new PptxSlidesRuntime();
27
+ const { deckId } = await runtime.createDeckFromMaster('sanity:cover-v1', 'X');
28
+ const ops: SlideOp[] = [
29
+ { type: 'createSlide', slideId: 'slide_1' },
30
+ {
31
+ type: 'createShape',
32
+ slideId: 'slide_1',
33
+ shapeId: 'shape_1',
34
+ shape: 'TEXT_BOX',
35
+ rect: { x: 0, y: 0, w: ptToEmu(100), h: ptToEmu(50) },
36
+ },
37
+ { type: 'insertText', objectId: 'shape_1', text: 'hello' },
38
+ ];
39
+ const result = await runtime.applyOps(deckId, ops);
40
+ expect(result.createdObjectIds['slide_1']).toBe('slide_1');
41
+ expect(result.createdObjectIds['shape_1']).toBe('shape_1');
42
+ expect(result.revisionId).toBe('pptx-rev-1');
43
+ });
44
+
45
+ it('write produces a real PPTX file (ZIP magic number, non-zero bytes)', async () => {
46
+ const dir = await tmpDir();
47
+ const runtime = new PptxSlidesRuntime({ outputDir: dir });
48
+ const { deckId } = await runtime.createDeckFromMaster('sanity:cover-v1', 'Smoke Test');
49
+ const ops: SlideOp[] = [
50
+ { type: 'createSlide', slideId: 'slide_1' },
51
+ {
52
+ type: 'createShape',
53
+ slideId: 'slide_1',
54
+ shapeId: 'shape_1',
55
+ shape: 'TEXT_BOX',
56
+ rect: { x: ptToEmu(50), y: ptToEmu(50), w: ptToEmu(400), h: ptToEmu(100) },
57
+ },
58
+ { type: 'insertText', objectId: 'shape_1', text: 'Hello PPTX' },
59
+ {
60
+ type: 'updateTextStyle',
61
+ objectId: 'shape_1',
62
+ range: { start: 0, end: 10 },
63
+ style: { fontFamily: 'Inter', fontSize: 32, foregroundColor: '#FF5500', bold: true },
64
+ },
65
+ ];
66
+ await runtime.applyOps(deckId, ops);
67
+ const { filePath } = await runtime.write(deckId);
68
+ expect(filePath).toMatch(/Smoke-Test\.pptx$/);
69
+ const buf = await fs.readFile(filePath);
70
+ expect(buf.length).toBeGreaterThan(1000);
71
+ expect(isZipMagicNumber(buf)).toBe(true);
72
+ await fs.rm(dir, { recursive: true, force: true });
73
+ });
74
+
75
+ it('write emits a deck containing a fill-only background rectangle (regression: empty Box + fill)', async () => {
76
+ // The Cover component emits a full-bleed empty Box with a fill — the
77
+ // runtime should produce a non-trivial file even though the box has no
78
+ // text.
79
+ const dir = await tmpDir();
80
+ const runtime = new PptxSlidesRuntime({ outputDir: dir });
81
+ const { deckId } = await runtime.createDeckFromMaster('sanity:cover-v1', 'Bg Only');
82
+ const ops: SlideOp[] = [
83
+ { type: 'createSlide', slideId: 's' },
84
+ {
85
+ type: 'createShape',
86
+ slideId: 's',
87
+ shapeId: 'bg',
88
+ shape: 'TEXT_BOX',
89
+ rect: { x: 0, y: 0, w: ptToEmu(960), h: ptToEmu(540) },
90
+ },
91
+ { type: 'updateShapeProperties', objectId: 'bg', properties: { fillColor: '#0b0b0b' } },
92
+ ];
93
+ await runtime.applyOps(deckId, ops);
94
+ const { filePath } = await runtime.write(deckId);
95
+ const buf = await fs.readFile(filePath);
96
+ expect(isZipMagicNumber(buf)).toBe(true);
97
+ expect(buf.length).toBeGreaterThan(1000);
98
+ await fs.rm(dir, { recursive: true, force: true });
99
+ });
100
+
101
+ it('substrate ships an empty default substitution table (brands inject their own)', () => {
102
+ expect(Object.keys(DEFAULT_PPTX_FONT_SUBSTITUTION)).toHaveLength(0);
103
+ });
104
+
105
+ it('throws a clear error when applyOps targets an unknown deck', async () => {
106
+ const runtime = new PptxSlidesRuntime();
107
+ await expect(runtime.applyOps('does-not-exist', [])).rejects.toThrow(
108
+ /deck "does-not-exist" does not exist/,
109
+ );
110
+ });
111
+
112
+ it('attachManifest + getManifest round-trip', async () => {
113
+ const runtime = new PptxSlidesRuntime();
114
+ const { deckId } = await runtime.createDeckFromMaster('m', 't');
115
+ const manifest = {
116
+ manifestVersion: '1' as const,
117
+ generatedBy: 'react-pptx' as const,
118
+ generatedAt: '2026-01-01T00:00:00.000Z',
119
+ templateName: 'sanity',
120
+ deckId,
121
+ slots: {},
122
+ artifacts: [],
123
+ };
124
+ runtime.attachManifest(deckId, manifest);
125
+ expect(runtime.getManifest(deckId)).toEqual(manifest);
126
+ });
127
+
128
+ it('sanitizes deck title into safe filename (regression: spaces and slashes)', async () => {
129
+ const dir = await tmpDir();
130
+ const runtime = new PptxSlidesRuntime({ outputDir: dir });
131
+ const { deckId } = await runtime.createDeckFromMaster('m', 'Q2 Review / Final');
132
+ await runtime.applyOps(deckId, [{ type: 'createSlide', slideId: 's' }]);
133
+ const { filePath } = await runtime.write(deckId);
134
+ expect(filePath).toMatch(/Q2-Review-Final\.pptx$/);
135
+ await fs.rm(dir, { recursive: true, force: true });
136
+ });
137
+ });