@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,69 @@
1
+ /**
2
+ * Public surface of `react-pptx`. The high-frequency symbols a template
3
+ * author or MCP framework needs.
4
+ *
5
+ * Specialized internals (PPTX op-translator, fake-runtime model types, etc.)
6
+ * live behind sub-path imports (`react-pptx/op-translator-pptx`,
7
+ * `react-pptx/fake-runtime`, …) so tree-shaking stays clean.
8
+ */
9
+
10
+ // Canvas + units
11
+ export {
12
+ CANVAS_16_9,
13
+ CANVAS_4_3,
14
+ type Canvas,
15
+ type Emu,
16
+ type Pt,
17
+ type Rect,
18
+ ptToEmu,
19
+ inToEmu,
20
+ } from './geometry.js';
21
+
22
+ // Template contract
23
+ export type { Template, TypographyToken } from './template.js';
24
+ export { defineTemplate, defineTemplateComponent, type TemplateComponent } from './template.js';
25
+ export { type FontStack } from './font-resolver.js';
26
+
27
+ // Runtime op types
28
+ export type {
29
+ ApplyOpsResult,
30
+ EmuRect,
31
+ HexColor,
32
+ ShapeKind,
33
+ ShapeProperties,
34
+ SlideOp,
35
+ SlidesRuntime,
36
+ TextRange,
37
+ TextStyle,
38
+ ParagraphStyle,
39
+ } from './runtime.js';
40
+
41
+ // JSX primitives a template's components build on
42
+ export {
43
+ Slide,
44
+ Box,
45
+ Text,
46
+ Color,
47
+ Image,
48
+ type SlideProps,
49
+ type BoxProps,
50
+ type BoxFill,
51
+ type TextProps,
52
+ type ColorProps,
53
+ type ImageProps,
54
+ type ImageRef,
55
+ } from './components.js';
56
+
57
+ // Reconciler entry point
58
+ export { renderToOps, ReconcilerError, type RenderToOpsInput } from './reconciler.js';
59
+
60
+ // Manifest types
61
+ export type { ArtifactRef, GenerationManifest, ReconcileResult, SlotId } from './manifest.js';
62
+
63
+ // Runtimes
64
+ export { FakeSlidesRuntime, type FakeDeck } from './fake-runtime.js';
65
+ export {
66
+ PptxSlidesRuntime,
67
+ DEFAULT_PPTX_FONT_SUBSTITUTION,
68
+ type PptxSlidesRuntimeOptions,
69
+ } from './pptx-runtime.js';
@@ -0,0 +1,33 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { decodeAltText, encodeAltText, type SlotId } from './manifest.js';
3
+
4
+ describe('encodeAltText / decodeAltText', () => {
5
+ test('round-trips a slot ID through the alt-text encoding', () => {
6
+ const slotId: SlotId = 'cover:title';
7
+ const encoded = encodeAltText(slotId);
8
+ expect(encoded).toBe('rgs-slot:cover:title');
9
+ expect(decodeAltText(encoded)).toBe(slotId);
10
+ });
11
+
12
+ test('returns undefined for an unmanaged alt-text caption', () => {
13
+ expect(decodeAltText('Picture of a sunrise')).toBeUndefined();
14
+ expect(decodeAltText('')).toBeUndefined();
15
+ expect(decodeAltText(null)).toBeUndefined();
16
+ expect(decodeAltText(undefined)).toBeUndefined();
17
+ });
18
+
19
+ test('rejects malformed payloads after the prefix', () => {
20
+ // Missing colon between component and slot.
21
+ expect(decodeAltText('rgs-slot:invalid')).toBeUndefined();
22
+ // Disallowed characters (spaces, capitals).
23
+ expect(decodeAltText('rgs-slot:Cover:Title')).toBeUndefined();
24
+ expect(decodeAltText('rgs-slot:cover :title')).toBeUndefined();
25
+ // Empty halves.
26
+ expect(decodeAltText('rgs-slot::title')).toBeUndefined();
27
+ expect(decodeAltText('rgs-slot:cover:')).toBeUndefined();
28
+ });
29
+
30
+ test('accepts hyphenated identifiers in both halves', () => {
31
+ expect(decodeAltText('rgs-slot:two-column:left-body')).toBe('two-column:left-body');
32
+ });
33
+ });
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The generation manifest — what the reconciler emits *alongside* the SlideOps.
3
+ *
4
+ * The manifest captures everything needed to:
5
+ *
6
+ * 1. Re-fill the deck later (`replaceAllText` against named slots, preserving
7
+ * manual polish on the rest of the deck).
8
+ * 2. Audit which decks were AI-generated and from which template version.
9
+ * 3. Detect drift between resolved-at-generation-time brand artifacts
10
+ * (textures, logos, master templates) and their current upstream state.
11
+ *
12
+ * Template-agnostic: any brand package reads it the same way; the substrate
13
+ * doesn't encode brand-specific slot keys.
14
+ */
15
+
16
+ import type { SlideOp } from './runtime.js';
17
+
18
+ /**
19
+ * A stable identity for a slot in a generated deck.
20
+ *
21
+ * Slot IDs are encoded into the Slides shape's *alt text* at generation time —
22
+ * the alt-text-as-ID pattern documented in `generation-model.md`. This is what
23
+ * makes in-place re-fill robust: when re-running with new data, we look up
24
+ * shapes by the alt-text token rather than by index/position (which can drift
25
+ * if the marketer moved or duplicated a shape).
26
+ *
27
+ * Format: `<componentName>:<slotName>` (colon-separated, both segments are
28
+ * `[a-z0-9-]+`). E.g., `cover:title`, `two-column:left-body`.
29
+ *
30
+ * Restricting the character set keeps the alt-text round-trippable across
31
+ * backends, some of which are picky about non-ASCII content.
32
+ */
33
+ export type SlotId = `${string}:${string}`;
34
+
35
+ /** The token we wrap a SlotId in when storing it as alt-text on a Slides shape. */
36
+ const ALT_TEXT_PREFIX = 'rgs-slot:';
37
+
38
+ /**
39
+ * Encode a SlotId into the alt-text representation written on a Slides shape.
40
+ *
41
+ * The wrapper prefix `rgs-slot:` is what `decodeAltText` keys off when reading
42
+ * an existing deck back. Anything else in the alt-text field — a marketer's
43
+ * accessibility caption, an empty string — is treated as "not a managed slot."
44
+ */
45
+ export const encodeAltText = (slotId: SlotId): string => `${ALT_TEXT_PREFIX}${slotId}`;
46
+
47
+ /**
48
+ * Decode a Slides shape's alt-text field back into a SlotId, or return
49
+ * `undefined` if the alt-text isn't a managed slot tag.
50
+ *
51
+ * Mirror of `encodeAltText`. Used at re-fill time to identify which shapes
52
+ * the system owns.
53
+ */
54
+ export const decodeAltText = (altText: string | undefined | null): SlotId | undefined => {
55
+ if (typeof altText !== 'string') return undefined;
56
+ if (!altText.startsWith(ALT_TEXT_PREFIX)) return undefined;
57
+ const candidate = altText.slice(ALT_TEXT_PREFIX.length);
58
+ // Must look like `a:b` with both halves non-empty and matching the safe charset.
59
+ if (!/^[a-z0-9-]+:[a-z0-9-]+$/.test(candidate)) return undefined;
60
+ return candidate as SlotId;
61
+ };
62
+
63
+ /**
64
+ * The reconciler's record of where each slot landed in the emitted ops.
65
+ *
66
+ * Keys are SlotIds. Values reference the Slides object IDs (`shapeId` /
67
+ * `imageId`) the reconciler assigned at op-emission time. Re-fill resolves a
68
+ * SlotId → object ID via this map, then issues a `replaceAllText` op against
69
+ * that object.
70
+ */
71
+ export type SlotRegistry = Readonly<Record<SlotId, string>>;
72
+
73
+ /**
74
+ * A reference to a brand artifact (texture, logo, font, master template, image)
75
+ * the deck depends on at generation time.
76
+ *
77
+ * Recorded in the manifest so re-fill can detect 404s and (optionally)
78
+ * verify content integrity via `contentHash`. The substrate type doesn't
79
+ * fetch or validate — it just records what the brand resolver produced.
80
+ */
81
+ export interface ArtifactRef {
82
+ /** What kind of artifact this is. Drives any artifact-type-specific re-fill behavior. */
83
+ readonly type: 'texture' | 'logo' | 'font' | 'master-template' | 'image';
84
+
85
+ /**
86
+ * Template-meaningful identifier, e.g., `"dots-grid-base-medium-dark"`. Stable
87
+ * across generations; the brand's resolver maps this to a current URL.
88
+ */
89
+ readonly identifier: string;
90
+
91
+ /** The URL the resolver produced for this artifact at generation time. */
92
+ readonly resolvedUrl: string;
93
+
94
+ /** ISO-8601 timestamp the resolution occurred. */
95
+ readonly resolvedAt: string;
96
+
97
+ /**
98
+ * Optional SHA-256 (or other) of the artifact bytes, hex-encoded.
99
+ *
100
+ * The reconciler does not compute or verify this; it's a forward-compat
101
+ * slot brands can populate when they want stricter integrity checks at
102
+ * re-fill time.
103
+ */
104
+ readonly contentHash?: string;
105
+ }
106
+
107
+ /**
108
+ * The full record the reconciler emits alongside its SlideOp stream.
109
+ *
110
+ * Persisted somewhere accessible at re-fill time. The persistence strategy
111
+ * is a runtime concern (each backend picks its own — file metadata, a
112
+ * sidecar JSON, embedded XML, etc.); this type just defines the shape.
113
+ */
114
+ export interface GenerationManifest {
115
+ /** Schema version of the manifest itself. Bump on breaking changes to this shape. */
116
+ readonly manifestVersion: '1';
117
+
118
+ /** The substrate that produced this manifest. Locked literal for forward compat. */
119
+ readonly generatedBy: 'react-pptx';
120
+
121
+ /** ISO-8601 timestamp of generation. */
122
+ readonly generatedAt: string;
123
+
124
+ /** Name of the template whose components produced this deck (`template.name`). */
125
+ readonly templateName: string;
126
+
127
+ /** ID of the deck this manifest applies to. `null` if the deck wasn't created yet. */
128
+ readonly deckId: string | null;
129
+
130
+ /** Map of SlotId → Slides object ID, for re-fill lookups. */
131
+ readonly slots: SlotRegistry;
132
+
133
+ /** Template artifacts the deck depends on. See `ArtifactRef`. */
134
+ readonly artifacts: readonly ArtifactRef[];
135
+ }
136
+
137
+ /**
138
+ * The full output of a single `renderToOps` call.
139
+ *
140
+ * Carries both the op stream (what to send to the runtime) and the manifest
141
+ * (what to persist). They're emitted together because they're computed
142
+ * together — the slot map can't exist without the ops that created the
143
+ * shapes it points at.
144
+ */
145
+ export interface ReconcileResult {
146
+ /** The op stream, in the order the reconciler emitted them. */
147
+ readonly ops: readonly SlideOp[];
148
+ /** The generation manifest. */
149
+ readonly manifest: GenerationManifest;
150
+ }
@@ -0,0 +1,204 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { translateOpsToPptx, hexToPptxColor } from './op-translator-pptx.js';
3
+ import { ptToEmu, EMU_PER_INCH } from './geometry.js';
4
+ import type { SlideOp } from './runtime.js';
5
+
6
+ describe('translateOpsToPptx', () => {
7
+ it('createSlide produces a slide entry; insertAt is preserved', () => {
8
+ const ops: SlideOp[] = [{ type: 'createSlide', slideId: 'slide_1', insertAt: 0 }];
9
+ const batch = translateOpsToPptx(ops);
10
+ expect(batch.slides).toEqual([{ slideId: 'slide_1', insertAt: 0 }]);
11
+ expect(batch.objects).toEqual([]);
12
+ expect(batch.createdObjectIds).toContain('slide_1');
13
+ });
14
+
15
+ it('createShape TEXT_BOX produces a text object with EMU rect converted to inches', () => {
16
+ const ops: SlideOp[] = [
17
+ { type: 'createSlide', slideId: 'slide_1' },
18
+ {
19
+ type: 'createShape',
20
+ slideId: 'slide_1',
21
+ shapeId: 'shape_1',
22
+ shape: 'TEXT_BOX',
23
+ // 1 inch = 914400 EMU
24
+ rect: { x: EMU_PER_INCH, y: 2 * EMU_PER_INCH, w: 3 * EMU_PER_INCH, h: 4 * EMU_PER_INCH },
25
+ },
26
+ ];
27
+ const batch = translateOpsToPptx(ops);
28
+ expect(batch.objects).toHaveLength(1);
29
+ const obj = batch.objects[0];
30
+ expect(obj?.kind).toBe('text');
31
+ expect(obj?.position).toEqual({ x: 1, y: 2, w: 3, h: 4 });
32
+ });
33
+
34
+ it('createShape RECTANGLE produces a rectangle object', () => {
35
+ const ops: SlideOp[] = [
36
+ { type: 'createSlide', slideId: 's' },
37
+ {
38
+ type: 'createShape',
39
+ slideId: 's',
40
+ shapeId: 'r1',
41
+ shape: 'RECTANGLE',
42
+ rect: { x: 0, y: 0, w: EMU_PER_INCH, h: EMU_PER_INCH },
43
+ },
44
+ ];
45
+ const batch = translateOpsToPptx(ops);
46
+ expect(batch.objects[0]?.kind).toBe('rectangle');
47
+ });
48
+
49
+ it('insertText sets text content on the matching object', () => {
50
+ const ops: SlideOp[] = [
51
+ { type: 'createSlide', slideId: 's' },
52
+ {
53
+ type: 'createShape',
54
+ slideId: 's',
55
+ shapeId: 'shape_1',
56
+ shape: 'TEXT_BOX',
57
+ rect: { x: 0, y: 0, w: EMU_PER_INCH, h: EMU_PER_INCH },
58
+ },
59
+ { type: 'insertText', objectId: 'shape_1', text: 'hello world' },
60
+ ];
61
+ const batch = translateOpsToPptx(ops);
62
+ const obj = batch.objects[0];
63
+ expect(obj?.kind).toBe('text');
64
+ if (obj?.kind === 'text') {
65
+ expect(obj.text).toBe('hello world');
66
+ }
67
+ });
68
+
69
+ it('updateShapeProperties merges fillColor (strips leading #)', () => {
70
+ const ops: SlideOp[] = [
71
+ { type: 'createSlide', slideId: 's' },
72
+ {
73
+ type: 'createShape',
74
+ slideId: 's',
75
+ shapeId: 'shape_1',
76
+ shape: 'TEXT_BOX',
77
+ rect: { x: 0, y: 0, w: EMU_PER_INCH, h: EMU_PER_INCH },
78
+ },
79
+ { type: 'updateShapeProperties', objectId: 'shape_1', properties: { fillColor: '#0b0b0b' } },
80
+ ];
81
+ const batch = translateOpsToPptx(ops);
82
+ const obj = batch.objects[0];
83
+ if (obj?.kind === 'text') {
84
+ expect(obj.fill).toBe('0B0B0B');
85
+ } else {
86
+ throw new Error('expected text object');
87
+ }
88
+ });
89
+
90
+ it('updateTextStyle applies font substitution (Waldenburg → Inter)', () => {
91
+ const ops: SlideOp[] = [
92
+ { type: 'createSlide', slideId: 's' },
93
+ {
94
+ type: 'createShape',
95
+ slideId: 's',
96
+ shapeId: 'shape_1',
97
+ shape: 'TEXT_BOX',
98
+ rect: { x: 0, y: 0, w: ptToEmu(100), h: ptToEmu(50) },
99
+ },
100
+ { type: 'insertText', objectId: 'shape_1', text: 'hi' },
101
+ {
102
+ type: 'updateTextStyle',
103
+ objectId: 'shape_1',
104
+ range: { start: 0, end: 2 },
105
+ style: { fontFamily: 'Waldenburg', fontSize: 24 },
106
+ },
107
+ ];
108
+ const batch = translateOpsToPptx(ops, {
109
+ fontSubstitution: { Waldenburg: 'Inter' },
110
+ onUnknownFont: null,
111
+ });
112
+ const obj = batch.objects[0];
113
+ if (obj?.kind !== 'text') throw new Error('expected text object');
114
+ expect(obj.textSpans[0]?.style.fontFamily).toBe('Inter');
115
+ // Identity passthrough doesn't substitute and doesn't warn.
116
+ expect(obj.textSpans[0]?.style.fontSize).toBe(24);
117
+ });
118
+
119
+ it('updateTextStyle warns once per unknown font', () => {
120
+ const warn = vi.fn();
121
+ const ops: SlideOp[] = [
122
+ { type: 'createSlide', slideId: 's' },
123
+ {
124
+ type: 'createShape',
125
+ slideId: 's',
126
+ shapeId: 'a',
127
+ shape: 'TEXT_BOX',
128
+ rect: { x: 0, y: 0, w: ptToEmu(10), h: ptToEmu(10) },
129
+ },
130
+ { type: 'insertText', objectId: 'a', text: 'x' },
131
+ {
132
+ type: 'updateTextStyle',
133
+ objectId: 'a',
134
+ range: { start: 0, end: 1 },
135
+ style: { fontFamily: 'Comic Sans' },
136
+ },
137
+ {
138
+ type: 'updateTextStyle',
139
+ objectId: 'a',
140
+ range: { start: 0, end: 1 },
141
+ style: { fontFamily: 'Comic Sans' },
142
+ },
143
+ ];
144
+ translateOpsToPptx(ops, { fontSubstitution: {}, onUnknownFont: warn });
145
+ expect(warn).toHaveBeenCalledTimes(1);
146
+ expect(warn).toHaveBeenCalledWith('Comic Sans');
147
+ });
148
+
149
+ it('createImage with altText emits an image object with both fields populated', () => {
150
+ const ops: SlideOp[] = [
151
+ { type: 'createSlide', slideId: 's' },
152
+ {
153
+ type: 'createImage',
154
+ slideId: 's',
155
+ imageId: 'img_1',
156
+ url: 'https://example.com/x.png',
157
+ rect: { x: 0, y: 0, w: EMU_PER_INCH, h: EMU_PER_INCH },
158
+ altText: 'rgs-slot:cover:hero',
159
+ },
160
+ ];
161
+ const batch = translateOpsToPptx(ops);
162
+ const obj = batch.objects[0];
163
+ expect(obj?.kind).toBe('image');
164
+ if (obj?.kind === 'image') {
165
+ expect(obj.url).toBe('https://example.com/x.png');
166
+ expect(obj.altText).toBe('rgs-slot:cover:hero');
167
+ }
168
+ });
169
+
170
+ it('updateParagraphStyle merges paragraph style on text object', () => {
171
+ const ops: SlideOp[] = [
172
+ { type: 'createSlide', slideId: 's' },
173
+ {
174
+ type: 'createShape',
175
+ slideId: 's',
176
+ shapeId: 'a',
177
+ shape: 'TEXT_BOX',
178
+ rect: { x: 0, y: 0, w: EMU_PER_INCH, h: EMU_PER_INCH },
179
+ },
180
+ { type: 'insertText', objectId: 'a', text: 'x' },
181
+ {
182
+ type: 'updateParagraphStyle',
183
+ objectId: 'a',
184
+ range: { start: 0, end: 1 },
185
+ style: { alignment: 'CENTER', lineSpacing: 1.4 },
186
+ },
187
+ ];
188
+ const batch = translateOpsToPptx(ops);
189
+ const obj = batch.objects[0];
190
+ if (obj?.kind !== 'text') throw new Error('expected text');
191
+ expect(obj.paragraphStyle).toEqual({ alignment: 'CENTER', lineSpacing: 1.4 });
192
+ });
193
+ });
194
+
195
+ describe('hexToPptxColor', () => {
196
+ it('strips leading # and uppercases', () => {
197
+ expect(hexToPptxColor('#ff5500')).toBe('FF5500');
198
+ expect(hexToPptxColor('#0b0b0b')).toBe('0B0B0B');
199
+ });
200
+
201
+ it('passes through bare hex', () => {
202
+ expect(hexToPptxColor('FF5500' as `#${string}`)).toBe('FF5500');
203
+ });
204
+ });