@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.
- package/README.md +241 -0
- package/SKILL.md +119 -0
- package/dist/cli.d.ts +38 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +386 -0
- package/dist/cli.js.map +1 -0
- package/dist/core/components.d.ts +179 -0
- package/dist/core/components.d.ts.map +1 -0
- package/dist/core/components.js +40 -0
- package/dist/core/components.js.map +1 -0
- package/dist/core/fake-runtime.d.ts +138 -0
- package/dist/core/fake-runtime.d.ts.map +1 -0
- package/dist/core/fake-runtime.js +210 -0
- package/dist/core/fake-runtime.js.map +1 -0
- package/dist/core/font-resolver.d.ts +28 -0
- package/dist/core/font-resolver.d.ts.map +1 -0
- package/dist/core/font-resolver.js +30 -0
- package/dist/core/font-resolver.js.map +1 -0
- package/dist/core/geometry.d.ts +71 -0
- package/dist/core/geometry.d.ts.map +1 -0
- package/dist/core/geometry.js +44 -0
- package/dist/core/geometry.js.map +1 -0
- package/dist/core/index.d.ts +19 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +20 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/manifest.d.ts +123 -0
- package/dist/core/manifest.d.ts.map +1 -0
- package/dist/core/manifest.js +43 -0
- package/dist/core/manifest.js.map +1 -0
- package/dist/core/op-translator-pptx.d.ts +150 -0
- package/dist/core/op-translator-pptx.d.ts.map +1 -0
- package/dist/core/op-translator-pptx.js +245 -0
- package/dist/core/op-translator-pptx.js.map +1 -0
- package/dist/core/pptx-runtime.d.ts +103 -0
- package/dist/core/pptx-runtime.d.ts.map +1 -0
- package/dist/core/pptx-runtime.js +405 -0
- package/dist/core/pptx-runtime.js.map +1 -0
- package/dist/core/reconciler.d.ts +113 -0
- package/dist/core/reconciler.d.ts.map +1 -0
- package/dist/core/reconciler.js +453 -0
- package/dist/core/reconciler.js.map +1 -0
- package/dist/core/runtime.d.ts +161 -0
- package/dist/core/runtime.d.ts.map +1 -0
- package/dist/core/runtime.js +11 -0
- package/dist/core/runtime.js.map +1 -0
- package/dist/core/template.d.ts +32 -0
- package/dist/core/template.d.ts.map +1 -0
- package/dist/core/template.js +3 -0
- package/dist/core/template.js.map +1 -0
- package/dist/dev/auto-examples.d.ts +6 -0
- package/dist/dev/auto-examples.d.ts.map +1 -0
- package/dist/dev/auto-examples.js +79 -0
- package/dist/dev/auto-examples.js.map +1 -0
- package/dist/dev/bin/slides-dev.d.ts +3 -0
- package/dist/dev/bin/slides-dev.d.ts.map +1 -0
- package/dist/dev/bin/slides-dev.js +87 -0
- package/dist/dev/bin/slides-dev.js.map +1 -0
- package/dist/dev/bin/slides-dev.mjs +24 -0
- package/dist/dev/compose-deck.d.ts +18 -0
- package/dist/dev/compose-deck.d.ts.map +1 -0
- package/dist/dev/compose-deck.js +19 -0
- package/dist/dev/compose-deck.js.map +1 -0
- package/dist/dev/deck-viewer.d.ts +19 -0
- package/dist/dev/deck-viewer.d.ts.map +1 -0
- package/dist/dev/deck-viewer.js +237 -0
- package/dist/dev/deck-viewer.js.map +1 -0
- package/dist/dev/dev-server/client/entry.d.ts +2 -0
- package/dist/dev/dev-server/client/entry.d.ts.map +1 -0
- package/dist/dev/dev-server/client/entry.js +12 -0
- package/dist/dev/dev-server/client/entry.js.map +1 -0
- package/dist/dev/dev-server/output.d.ts +8 -0
- package/dist/dev/dev-server/output.d.ts.map +1 -0
- package/dist/dev/dev-server/output.js +32 -0
- package/dist/dev/dev-server/output.js.map +1 -0
- package/dist/dev/dev-server/server-only-stub.d.ts +7 -0
- package/dist/dev/dev-server/server-only-stub.d.ts.map +1 -0
- package/dist/dev/dev-server/server-only-stub.js +12 -0
- package/dist/dev/dev-server/server-only-stub.js.map +1 -0
- package/dist/dev/dev-server/start.d.ts +14 -0
- package/dist/dev/dev-server/start.d.ts.map +1 -0
- package/dist/dev/dev-server/start.js +135 -0
- package/dist/dev/dev-server/start.js.map +1 -0
- package/dist/dev/index.d.ts +5 -0
- package/dist/dev/index.d.ts.map +1 -0
- package/dist/dev/index.js +5 -0
- package/dist/dev/index.js.map +1 -0
- package/dist/dev/lib/cn.d.ts +3 -0
- package/dist/dev/lib/cn.d.ts.map +1 -0
- package/dist/dev/lib/cn.js +3 -0
- package/dist/dev/lib/cn.js.map +1 -0
- package/dist/dev/slide-canvas.d.ts +12 -0
- package/dist/dev/slide-canvas.d.ts.map +1 -0
- package/dist/dev/slide-canvas.js +123 -0
- package/dist/dev/slide-canvas.js.map +1 -0
- package/dist/dev/styles.css +37 -0
- package/dist/dev/ui/icon-button.d.ts +12 -0
- package/dist/dev/ui/icon-button.d.ts.map +1 -0
- package/dist/dev/ui/icon-button.js +6 -0
- package/dist/dev/ui/icon-button.js.map +1 -0
- package/dist/dev/ui/kbd.d.ts +6 -0
- package/dist/dev/ui/kbd.d.ts.map +1 -0
- package/dist/dev/ui/kbd.js +4 -0
- package/dist/dev/ui/kbd.js.map +1 -0
- package/dist/dev/ui/text-button.d.ts +10 -0
- package/dist/dev/ui/text-button.d.ts.map +1 -0
- package/dist/dev/ui/text-button.js +6 -0
- package/dist/dev/ui/text-button.js.map +1 -0
- package/dist/dev/url-state.d.ts +7 -0
- package/dist/dev/url-state.d.ts.map +1 -0
- package/dist/dev/url-state.js +13 -0
- package/dist/dev/url-state.js.map +1 -0
- package/dist/dev/use-keyboard-nav.d.ts +17 -0
- package/dist/dev/use-keyboard-nav.d.ts.map +1 -0
- package/dist/dev/use-keyboard-nav.js +53 -0
- package/dist/dev/use-keyboard-nav.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp/errors.d.ts +57 -0
- package/dist/mcp/errors.d.ts.map +1 -0
- package/dist/mcp/errors.js +44 -0
- package/dist/mcp/errors.js.map +1 -0
- package/dist/mcp/index.d.ts +29 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +29 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/mcp/naming.d.ts +37 -0
- package/dist/mcp/naming.d.ts.map +1 -0
- package/dist/mcp/naming.js +43 -0
- package/dist/mcp/naming.js.map +1 -0
- package/dist/mcp/render.d.ts +45 -0
- package/dist/mcp/render.d.ts.map +1 -0
- package/dist/mcp/render.js +77 -0
- package/dist/mcp/render.js.map +1 -0
- package/dist/mcp/schema.d.ts +54 -0
- package/dist/mcp/schema.d.ts.map +1 -0
- package/dist/mcp/schema.js +55 -0
- package/dist/mcp/schema.js.map +1 -0
- package/dist/mcp/server.d.ts +63 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +196 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/scaffold/index.d.ts +39 -0
- package/dist/scaffold/index.d.ts.map +1 -0
- package/dist/scaffold/index.js +84 -0
- package/dist/scaffold/index.js.map +1 -0
- package/dist/scaffold/template-base/README.md +134 -0
- package/dist/scaffold/template-base/_gitignore +4 -0
- package/dist/scaffold/template-base/package.json +35 -0
- package/dist/scaffold/template-base/src/components/Cover.tsx +30 -0
- package/dist/scaffold/template-base/src/index.ts +27 -0
- package/dist/scaffold/template-base/src/preview.tsx +9 -0
- package/dist/scaffold/template-base/tsconfig.build.json +10 -0
- package/dist/scaffold/template-base/tsconfig.json +18 -0
- package/package.json +164 -0
- package/src/__tests__/fixtures/test-template/index.tsx +77 -0
- package/src/__tests__/pptx-mcp.test.ts +85 -0
- package/src/__tests__/pptx-smoke.test.ts +45 -0
- package/src/__tests__/preview.test.ts +28 -0
- package/src/cli.ts +426 -0
- package/src/core/__snapshots__/reconciler.test.ts.snap +320 -0
- package/src/core/components.test.ts +57 -0
- package/src/core/components.ts +196 -0
- package/src/core/fake-runtime.test.ts +174 -0
- package/src/core/fake-runtime.ts +302 -0
- package/src/core/font-resolver.ts +46 -0
- package/src/core/geometry.test.ts +58 -0
- package/src/core/geometry.ts +91 -0
- package/src/core/index.ts +69 -0
- package/src/core/manifest.test.ts +33 -0
- package/src/core/manifest.ts +150 -0
- package/src/core/op-translator-pptx.test.ts +204 -0
- package/src/core/op-translator-pptx.ts +365 -0
- package/src/core/pptx-runtime.test.ts +137 -0
- package/src/core/pptx-runtime.ts +504 -0
- package/src/core/reconciler.test.ts +644 -0
- package/src/core/reconciler.ts +603 -0
- package/src/core/runtime.ts +150 -0
- package/src/core/template.test.ts +136 -0
- package/src/core/template.ts +37 -0
- package/src/dev/auto-examples.ts +89 -0
- package/src/dev/bin/slides-dev.mjs +24 -0
- package/src/dev/bin/slides-dev.ts +101 -0
- package/src/dev/compose-deck.test.ts +68 -0
- package/src/dev/compose-deck.ts +40 -0
- package/src/dev/deck-viewer.tsx +677 -0
- package/src/dev/dev-server/client/entry.tsx +15 -0
- package/src/dev/dev-server/client/index.html +24 -0
- package/src/dev/dev-server/output.ts +37 -0
- package/src/dev/dev-server/server-only-stub.ts +12 -0
- package/src/dev/dev-server/start.ts +155 -0
- package/src/dev/index.ts +4 -0
- package/src/dev/lib/cn.ts +3 -0
- package/src/dev/slide-canvas.test.tsx +66 -0
- package/src/dev/slide-canvas.tsx +170 -0
- package/src/dev/styles.css +37 -0
- package/src/dev/ui/icon-button.tsx +31 -0
- package/src/dev/ui/kbd.tsx +20 -0
- package/src/dev/ui/text-button.tsx +31 -0
- package/src/dev/url-state.test.ts +22 -0
- package/src/dev/url-state.ts +17 -0
- package/src/dev/use-keyboard-nav.ts +64 -0
- package/src/index.ts +17 -0
- package/src/mcp/errors.test.ts +51 -0
- package/src/mcp/errors.ts +76 -0
- package/src/mcp/index.ts +45 -0
- package/src/mcp/naming.test.ts +39 -0
- package/src/mcp/naming.ts +49 -0
- package/src/mcp/render.ts +110 -0
- package/src/mcp/schema.test.ts +86 -0
- package/src/mcp/schema.ts +93 -0
- package/src/mcp/server.test.ts +309 -0
- package/src/mcp/server.ts +276 -0
- package/src/scaffold/index.ts +102 -0
- package/src/scaffold/template-base/README.md +134 -0
- package/src/scaffold/template-base/_gitignore +4 -0
- package/src/scaffold/template-base/package.json +35 -0
- package/src/scaffold/template-base/src/components/Cover.tsx +30 -0
- package/src/scaffold/template-base/src/index.ts +27 -0
- package/src/scaffold/template-base/src/preview.tsx +9 -0
- package/src/scaffold/template-base/tsconfig.build.json +10 -0
- 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
|
+
});
|