@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,504 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `SlidesRuntime` that emits a .pptx file to disk via pptxgenjs.
|
|
3
|
+
*
|
|
4
|
+
* The brand visuals (colors, layout, typography sizes) are preserved
|
|
5
|
+
* end-to-end. Fonts that aren't installed on the viewer's machine get
|
|
6
|
+
* substituted via the brand-supplied substitution table (see "Font
|
|
7
|
+
* substitution" below).
|
|
8
|
+
*
|
|
9
|
+
* # Known limitations
|
|
10
|
+
*
|
|
11
|
+
* - **Re-fill against an existing .pptx.** Not supported; the manifest is
|
|
12
|
+
* held in memory only.
|
|
13
|
+
* - **Master templates.** PPTX has no master-ref concept here; the
|
|
14
|
+
* `masterRef` argument to `createDeckFromMaster` is recorded as the
|
|
15
|
+
* presentation's subject metadata and otherwise ignored.
|
|
16
|
+
*
|
|
17
|
+
* # Font substitution
|
|
18
|
+
*
|
|
19
|
+
* PPTX cannot embed fonts. Template-specific families (e.g., a brand's display
|
|
20
|
+
* face) may not be installed on the viewer's machine. The runtime accepts a
|
|
21
|
+
* `fontSubstitution` map keyed by the literal font name (as the reconciler
|
|
22
|
+
* emits it after role resolution) → the family name to write into the
|
|
23
|
+
* .pptx file. Anything not in the map passes through unchanged.
|
|
24
|
+
*
|
|
25
|
+
* The substrate ships an empty default. Brands provide their own table
|
|
26
|
+
* (see `@sanity-labs/slides` for an example). Use `onUnknownFont` if you want
|
|
27
|
+
* to surface diagnostics for unmapped families.
|
|
28
|
+
*
|
|
29
|
+
* # Manifest persistence
|
|
30
|
+
*
|
|
31
|
+
* Held in memory and accessible via `getManifest()`. Persistence into the
|
|
32
|
+
* .pptx file itself (custom XML / hidden shape) is not implemented; re-fill
|
|
33
|
+
* against a previously-generated .pptx is out of scope for this runtime.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { promises as fs, readFileSync } from 'node:fs';
|
|
37
|
+
import * as path from 'node:path';
|
|
38
|
+
import { Resvg } from '@resvg/resvg-js';
|
|
39
|
+
import PptxGenJS from 'pptxgenjs';
|
|
40
|
+
import type { GenerationManifest } from './manifest.js';
|
|
41
|
+
import {
|
|
42
|
+
hexToPptxColor,
|
|
43
|
+
translateOpsToPptx,
|
|
44
|
+
type PptxBatch,
|
|
45
|
+
type PptxImage,
|
|
46
|
+
type PptxObject,
|
|
47
|
+
type PptxRectangle,
|
|
48
|
+
type PptxText,
|
|
49
|
+
} from './op-translator-pptx.js';
|
|
50
|
+
import type {
|
|
51
|
+
ApplyOpsResult,
|
|
52
|
+
ParagraphStyle,
|
|
53
|
+
SlideOp,
|
|
54
|
+
SlidesRuntime,
|
|
55
|
+
TextStyle,
|
|
56
|
+
} from './runtime.js';
|
|
57
|
+
|
|
58
|
+
/** Configuration for a `PptxSlidesRuntime` instance. */
|
|
59
|
+
export interface PptxSlidesRuntimeOptions {
|
|
60
|
+
/** Output directory. Created if missing. Default: `process.cwd()`. */
|
|
61
|
+
readonly outputDir?: string;
|
|
62
|
+
/**
|
|
63
|
+
* Substitution map: literal font name (as the reconciler emits it) → the
|
|
64
|
+
* family name to write into the .pptx file. Default: `{}` (pass-through).
|
|
65
|
+
* Templates inject their own table here.
|
|
66
|
+
*/
|
|
67
|
+
readonly fontSubstitution?: Readonly<Record<string, string>>;
|
|
68
|
+
/**
|
|
69
|
+
* Optional hook called once per unknown font (a font not in the
|
|
70
|
+
* substitution table). Default: no-op. Pass `console.warn` (or your
|
|
71
|
+
* logger) to surface diagnostics.
|
|
72
|
+
*/
|
|
73
|
+
readonly onUnknownFont?: ((fontFamily: string) => void) | null;
|
|
74
|
+
/** Author metadata baked into the .pptx file. Default: `'react-pptx'`. */
|
|
75
|
+
readonly author?: string;
|
|
76
|
+
/** Company metadata. Default: undefined. */
|
|
77
|
+
readonly company?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Default font substitution table for the substrate: empty.
|
|
82
|
+
*
|
|
83
|
+
* PPTX cannot embed fonts. Template packages ship their own substitution table
|
|
84
|
+
* (see `@sanity-labs/slides` for an example) and pass it via
|
|
85
|
+
* `PptxSlidesRuntimeOptions.fontSubstitution`. The substrate stays
|
|
86
|
+
* brand-agnostic.
|
|
87
|
+
*/
|
|
88
|
+
export const DEFAULT_PPTX_FONT_SUBSTITUTION: Readonly<Record<string, string>> = Object.freeze({});
|
|
89
|
+
|
|
90
|
+
/** What the runtime tracks per deck. */
|
|
91
|
+
interface DeckEntry {
|
|
92
|
+
readonly title: string;
|
|
93
|
+
readonly pres: PptxGenJS;
|
|
94
|
+
/**
|
|
95
|
+
* Map of slide ID → pptxgenjs Slide object. Populated as
|
|
96
|
+
* `createSlide` ops translate.
|
|
97
|
+
*/
|
|
98
|
+
readonly slides: Map<string, PptxGenJS.Slide>;
|
|
99
|
+
manifest?: GenerationManifest;
|
|
100
|
+
revision: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A `SlidesRuntime` that emits .pptx files via pptxgenjs.
|
|
105
|
+
*
|
|
106
|
+
* See module docstring for limitations and font-substitution behavior.
|
|
107
|
+
*/
|
|
108
|
+
export class PptxSlidesRuntime implements SlidesRuntime {
|
|
109
|
+
private readonly outputDir: string;
|
|
110
|
+
private readonly fontSubstitution: Readonly<Record<string, string>>;
|
|
111
|
+
private readonly onUnknownFont: ((fontFamily: string) => void) | null;
|
|
112
|
+
private readonly author: string;
|
|
113
|
+
private readonly company: string | undefined;
|
|
114
|
+
private readonly decks = new Map<string, DeckEntry>();
|
|
115
|
+
private deckCounter = 0;
|
|
116
|
+
|
|
117
|
+
constructor(options: PptxSlidesRuntimeOptions = {}) {
|
|
118
|
+
this.outputDir = options.outputDir ?? process.cwd();
|
|
119
|
+
this.fontSubstitution = options.fontSubstitution ?? DEFAULT_PPTX_FONT_SUBSTITUTION;
|
|
120
|
+
this.onUnknownFont = options.onUnknownFont ?? null;
|
|
121
|
+
this.author = options.author ?? 'react-pptx';
|
|
122
|
+
this.company = options.company;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// -- SlidesRuntime --------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
async applyOps(deckId: string, ops: readonly SlideOp[]): Promise<ApplyOpsResult> {
|
|
128
|
+
const deck = this.requireDeck(deckId);
|
|
129
|
+
const batch = translateOpsToPptx(ops, {
|
|
130
|
+
fontSubstitution: this.fontSubstitution,
|
|
131
|
+
onUnknownFont: this.onUnknownFont,
|
|
132
|
+
});
|
|
133
|
+
applyBatchToPresentation(deck, batch);
|
|
134
|
+
deck.revision += 1;
|
|
135
|
+
const createdObjectIds: Record<string, string> = {};
|
|
136
|
+
for (const id of batch.createdObjectIds) {
|
|
137
|
+
createdObjectIds[id] = id;
|
|
138
|
+
}
|
|
139
|
+
return { createdObjectIds, revisionId: `pptx-rev-${deck.revision}` };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async createDeckFromMaster(masterRef: string, title: string): Promise<{ deckId: string }> {
|
|
143
|
+
// PPTX has no master-ref concept; `masterRef` is informational only,
|
|
144
|
+
// recorded for diagnostic purposes. We initialize a blank 16:9
|
|
145
|
+
// presentation matching the substrate's default canvas.
|
|
146
|
+
const pres = new PptxGenJS();
|
|
147
|
+
pres.layout = 'LAYOUT_WIDE'; // 13.333" x 7.5" — matches CANVAS_16_9 (960×540 pt)
|
|
148
|
+
pres.title = title;
|
|
149
|
+
pres.author = this.author;
|
|
150
|
+
if (this.company !== undefined) pres.company = this.company;
|
|
151
|
+
// Tag the presentation so curious file inspectors know what produced it.
|
|
152
|
+
pres.subject = `Generated by react-pptx (template: ${masterRef})`;
|
|
153
|
+
|
|
154
|
+
this.deckCounter += 1;
|
|
155
|
+
const deckId = `pptx-deck-${this.deckCounter}`;
|
|
156
|
+
this.decks.set(deckId, {
|
|
157
|
+
title,
|
|
158
|
+
pres,
|
|
159
|
+
slides: new Map(),
|
|
160
|
+
revision: 0,
|
|
161
|
+
});
|
|
162
|
+
return { deckId };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// -- SlidesRuntime (continued) --------------------------------------------
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Serialize the in-memory presentation for a deck to a .pptx file.
|
|
169
|
+
*
|
|
170
|
+
* Returns the absolute path to the generated file. The filename is
|
|
171
|
+
* derived from the deck title (sanitized) with a `.pptx` extension.
|
|
172
|
+
*/
|
|
173
|
+
async write(deckId: string): Promise<{ filePath: string }> {
|
|
174
|
+
const deck = this.requireDeck(deckId);
|
|
175
|
+
await fs.mkdir(this.outputDir, { recursive: true });
|
|
176
|
+
const filename = sanitizeFilename(deck.title);
|
|
177
|
+
const filePath = path.join(this.outputDir, `${filename}.pptx`);
|
|
178
|
+
const buffer = (await deck.pres.write({ outputType: 'nodebuffer' })) as Buffer;
|
|
179
|
+
await fs.writeFile(filePath, buffer);
|
|
180
|
+
return { filePath };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async toBuffer(deckId: string): Promise<Buffer> {
|
|
184
|
+
const deck = this.requireDeck(deckId);
|
|
185
|
+
return (await deck.pres.write({ outputType: 'nodebuffer' })) as Buffer;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Attach a manifest to a deck (held in memory; not embedded into the .pptx file). */
|
|
189
|
+
attachManifest(deckId: string, manifest: GenerationManifest): void {
|
|
190
|
+
const deck = this.requireDeck(deckId);
|
|
191
|
+
deck.manifest = manifest;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// -- PPTX-specific extras -------------------------------------------------
|
|
195
|
+
|
|
196
|
+
/** Retrieve a deck's manifest (if attached). */
|
|
197
|
+
getManifest(deckId: string): GenerationManifest | undefined {
|
|
198
|
+
return this.decks.get(deckId)?.manifest;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** List deck IDs the runtime is tracking. */
|
|
202
|
+
listDeckIds(): readonly string[] {
|
|
203
|
+
return [...this.decks.keys()];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private requireDeck(deckId: string): DeckEntry {
|
|
207
|
+
const deck = this.decks.get(deckId);
|
|
208
|
+
if (!deck) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
`PptxSlidesRuntime: deck "${deckId}" does not exist. Create it via createDeckFromMaster() first.`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
return deck;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// Apply translated batch to a pptxgenjs Presentation.
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
/** Apply a translated batch to a pptxgenjs presentation. */
|
|
222
|
+
const applyBatchToPresentation = (deck: DeckEntry, batch: PptxBatch): void => {
|
|
223
|
+
for (const slideSpec of batch.slides) {
|
|
224
|
+
if (deck.slides.has(slideSpec.slideId)) {
|
|
225
|
+
throw new Error(`PptxSlidesRuntime: slide "${slideSpec.slideId}" already exists.`);
|
|
226
|
+
}
|
|
227
|
+
const slide = deck.pres.addSlide();
|
|
228
|
+
deck.slides.set(slideSpec.slideId, slide);
|
|
229
|
+
}
|
|
230
|
+
for (const obj of batch.objects) {
|
|
231
|
+
const slide = deck.slides.get(obj.slideId);
|
|
232
|
+
if (!slide) {
|
|
233
|
+
throw new Error(
|
|
234
|
+
`PptxSlidesRuntime: object "${obj.objectId}" references unknown slide "${obj.slideId}".`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
emitObjectToSlide(slide, obj);
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const emitObjectToSlide = (slide: PptxGenJS.Slide, obj: PptxObject): void => {
|
|
242
|
+
switch (obj.kind) {
|
|
243
|
+
case 'text':
|
|
244
|
+
emitText(slide, obj);
|
|
245
|
+
return;
|
|
246
|
+
case 'rectangle':
|
|
247
|
+
emitRectangle(slide, obj);
|
|
248
|
+
return;
|
|
249
|
+
case 'image':
|
|
250
|
+
emitImage(slide, obj);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const emitText = (slide: PptxGenJS.Slide, obj: PptxText): void => {
|
|
256
|
+
const positionOpts = positionToPptx(obj.position);
|
|
257
|
+
// If the text is empty AND we have a fill, emit a sibling rectangle for
|
|
258
|
+
// the background. pptxgenjs's `addText` with no text won't emit a fill
|
|
259
|
+
// reliably across renderers; `addShape` does.
|
|
260
|
+
if (obj.text.length === 0 && obj.fill !== undefined) {
|
|
261
|
+
slide.addShape('rect', { ...positionOpts, fill: { color: obj.fill }, line: { type: 'none' } });
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (obj.text.length === 0 && obj.fill === undefined) {
|
|
265
|
+
// Empty text box, no fill — nothing to render. Skip; mirrors the
|
|
266
|
+
// FakeSlidesRuntime (which keeps the shape but the live deck has no
|
|
267
|
+
// visible artifact).
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const textProps = composeTextSegments(obj);
|
|
272
|
+
const opts: PptxGenJS.TextPropsOptions = {
|
|
273
|
+
...positionOpts,
|
|
274
|
+
...(obj.fill !== undefined ? { fill: { color: obj.fill } } : {}),
|
|
275
|
+
...paragraphStyleToPptx(obj.paragraphStyle),
|
|
276
|
+
// Default vertical alignment to `top` so text starts at the top of the
|
|
277
|
+
// box.
|
|
278
|
+
valign: paragraphValignFromStyle(obj.paragraphStyle) ?? 'top',
|
|
279
|
+
// Margin 0 — the substrate emits exact rects; pptxgenjs's default text
|
|
280
|
+
// box padding would offset the visible text.
|
|
281
|
+
margin: 0,
|
|
282
|
+
// `fit: 'shrink'` lets PowerPoint shrink overflowing text rather than
|
|
283
|
+
// clipping.
|
|
284
|
+
fit: 'shrink',
|
|
285
|
+
};
|
|
286
|
+
slide.addText(textProps, opts);
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const emitRectangle = (slide: PptxGenJS.Slide, obj: PptxRectangle): void => {
|
|
290
|
+
const opts: PptxGenJS.ShapeProps = {
|
|
291
|
+
...positionToPptx(obj.position),
|
|
292
|
+
...(obj.fill !== undefined ? { fill: { color: obj.fill } } : {}),
|
|
293
|
+
};
|
|
294
|
+
if (obj.outlineColor !== undefined || obj.outlineWeight !== undefined) {
|
|
295
|
+
const line: PptxGenJS.ShapeLineProps = {};
|
|
296
|
+
if (obj.outlineColor !== undefined) line.color = obj.outlineColor;
|
|
297
|
+
if (obj.outlineWeight !== undefined) line.width = obj.outlineWeight;
|
|
298
|
+
opts.line = line;
|
|
299
|
+
} else {
|
|
300
|
+
// Explicit `type: 'none'` so PowerPoint doesn't draw the default 1pt
|
|
301
|
+
// outline (visually noticeable on full-bleed backgrounds).
|
|
302
|
+
opts.line = { type: 'none' };
|
|
303
|
+
}
|
|
304
|
+
slide.addShape(obj.shape, opts);
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const emitImage = (slide: PptxGenJS.Slide, obj: PptxImage): void => {
|
|
308
|
+
const opts: PptxGenJS.ImageProps = {
|
|
309
|
+
...positionToPptx(obj.position),
|
|
310
|
+
...(obj.altText !== undefined ? { altText: obj.altText } : {}),
|
|
311
|
+
};
|
|
312
|
+
const source = imageSourceForPptx(obj.url);
|
|
313
|
+
if (source.kind === 'data') {
|
|
314
|
+
opts.data = source.value;
|
|
315
|
+
} else {
|
|
316
|
+
opts.path = source.value;
|
|
317
|
+
}
|
|
318
|
+
slide.addImage(opts);
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
type PptxImageSource =
|
|
322
|
+
| { readonly kind: 'data'; readonly value: string }
|
|
323
|
+
| { readonly kind: 'path'; readonly value: string };
|
|
324
|
+
|
|
325
|
+
const imageSourceForPptx = (url: string): PptxImageSource => {
|
|
326
|
+
if (isSvgDataUri(url)) {
|
|
327
|
+
return { kind: 'data', value: svgToPngDataUri(decodeSvgDataUri(url), 'data URI') };
|
|
328
|
+
}
|
|
329
|
+
if (isLocalSvgPath(url)) {
|
|
330
|
+
const filePath = localSvgPath(url);
|
|
331
|
+
return { kind: 'data', value: svgToPngDataUri(readFileSync(filePath, 'utf8'), filePath) };
|
|
332
|
+
}
|
|
333
|
+
if (url.startsWith('data:')) {
|
|
334
|
+
return { kind: 'data', value: url };
|
|
335
|
+
}
|
|
336
|
+
return { kind: 'path', value: url };
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const isSvgDataUri = (url: string): boolean => /^data:image\/svg\+xml[;,]/i.test(url);
|
|
340
|
+
|
|
341
|
+
const decodeSvgDataUri = (url: string): string => {
|
|
342
|
+
const commaIndex = url.indexOf(',');
|
|
343
|
+
if (commaIndex === -1) {
|
|
344
|
+
throw new Error('PptxSlidesRuntime: invalid SVG data URI; missing comma separator.');
|
|
345
|
+
}
|
|
346
|
+
const meta = url.slice(0, commaIndex).toLowerCase();
|
|
347
|
+
const payload = url.slice(commaIndex + 1);
|
|
348
|
+
return meta.includes(';base64')
|
|
349
|
+
? Buffer.from(payload, 'base64').toString('utf8')
|
|
350
|
+
: decodeURIComponent(payload);
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const isLocalSvgPath = (url: string): boolean =>
|
|
354
|
+
!/^https?:\/\//i.test(url) && !url.startsWith('data:') && /\.svg(?:$|[?#])/i.test(url);
|
|
355
|
+
|
|
356
|
+
const localSvgPath = (url: string): string => {
|
|
357
|
+
if (url.startsWith('file://')) {
|
|
358
|
+
return new URL(url).pathname;
|
|
359
|
+
}
|
|
360
|
+
const queryIndex = url.search(/[?#]/);
|
|
361
|
+
return queryIndex === -1 ? url : url.slice(0, queryIndex);
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const svgToPngDataUri = (svg: string, sourceLabel: string): string => {
|
|
365
|
+
try {
|
|
366
|
+
const png = new Resvg(svg).render().asPng();
|
|
367
|
+
return `data:image/png;base64,${Buffer.from(png).toString('base64')}`;
|
|
368
|
+
} catch (err) {
|
|
369
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
370
|
+
throw new Error(
|
|
371
|
+
`PptxSlidesRuntime: failed to rasterize SVG image (${sourceLabel}): ${message}`,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
// Style adapters
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
const positionToPptx = (position: {
|
|
381
|
+
x: number;
|
|
382
|
+
y: number;
|
|
383
|
+
w: number;
|
|
384
|
+
h: number;
|
|
385
|
+
}): { x: number; y: number; w: number; h: number } => ({
|
|
386
|
+
x: position.x,
|
|
387
|
+
y: position.y,
|
|
388
|
+
w: position.w,
|
|
389
|
+
h: position.h,
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Compose the per-range text-style spans into a flat array of pptxgenjs
|
|
394
|
+
* `TextProps` segments, applying spans in op order with last-write-wins
|
|
395
|
+
* semantics on overlap.
|
|
396
|
+
*/
|
|
397
|
+
const composeTextSegments = (obj: PptxText): PptxGenJS.TextProps[] => {
|
|
398
|
+
const len = obj.text.length;
|
|
399
|
+
if (len === 0) return [];
|
|
400
|
+
// Per-character resolved style.
|
|
401
|
+
const styles: TextStyle[] = Array.from({ length: len }, () => ({}));
|
|
402
|
+
for (const span of obj.textSpans) {
|
|
403
|
+
const start = Math.max(0, Math.min(len, span.start));
|
|
404
|
+
const end = Math.max(0, Math.min(len, span.end));
|
|
405
|
+
for (let i = start; i < end; i++) {
|
|
406
|
+
styles[i] = { ...styles[i], ...span.style };
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// Coalesce consecutive characters with identical resolved style.
|
|
410
|
+
const segments: PptxGenJS.TextProps[] = [];
|
|
411
|
+
let segStart = 0;
|
|
412
|
+
while (segStart < len) {
|
|
413
|
+
let segEnd = segStart + 1;
|
|
414
|
+
while (segEnd < len && styleEquals(styles[segStart] ?? {}, styles[segEnd] ?? {})) {
|
|
415
|
+
segEnd++;
|
|
416
|
+
}
|
|
417
|
+
segments.push({
|
|
418
|
+
text: obj.text.slice(segStart, segEnd),
|
|
419
|
+
options: textStyleToPptx(styles[segStart] ?? {}),
|
|
420
|
+
});
|
|
421
|
+
segStart = segEnd;
|
|
422
|
+
}
|
|
423
|
+
return segments;
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const styleEquals = (a: TextStyle, b: TextStyle): boolean => {
|
|
427
|
+
const keysA = Object.keys(a) as (keyof TextStyle)[];
|
|
428
|
+
const keysB = Object.keys(b) as (keyof TextStyle)[];
|
|
429
|
+
if (keysA.length !== keysB.length) return false;
|
|
430
|
+
return keysA.every((k) => a[k] === b[k]);
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const textStyleToPptx = (style: TextStyle): PptxGenJS.TextPropsOptions => {
|
|
434
|
+
const opts: PptxGenJS.TextPropsOptions = {};
|
|
435
|
+
if (style.fontFamily !== undefined) opts.fontFace = style.fontFamily;
|
|
436
|
+
if (style.fontSize !== undefined) opts.fontSize = style.fontSize;
|
|
437
|
+
if (style.bold !== undefined) opts.bold = style.bold;
|
|
438
|
+
if (style.italic !== undefined) opts.italic = style.italic;
|
|
439
|
+
if (style.underline !== undefined && style.underline) {
|
|
440
|
+
opts.underline = { style: 'sng' };
|
|
441
|
+
}
|
|
442
|
+
if (style.foregroundColor !== undefined) opts.color = hexToPptxColor(style.foregroundColor);
|
|
443
|
+
if (style.backgroundColor !== undefined) opts.highlight = hexToPptxColor(style.backgroundColor);
|
|
444
|
+
return opts;
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
const paragraphStyleToPptx = (
|
|
448
|
+
style: ParagraphStyle | undefined,
|
|
449
|
+
): Pick<
|
|
450
|
+
PptxGenJS.TextPropsOptions,
|
|
451
|
+
'align' | 'lineSpacingMultiple' | 'paraSpaceBefore' | 'paraSpaceAfter'
|
|
452
|
+
> => {
|
|
453
|
+
if (!style) return {};
|
|
454
|
+
const out: Pick<
|
|
455
|
+
PptxGenJS.TextPropsOptions,
|
|
456
|
+
'align' | 'lineSpacingMultiple' | 'paraSpaceBefore' | 'paraSpaceAfter'
|
|
457
|
+
> = {};
|
|
458
|
+
if (style.alignment !== undefined) {
|
|
459
|
+
out.align = pptxAlign(style.alignment);
|
|
460
|
+
}
|
|
461
|
+
if (style.lineSpacing !== undefined) {
|
|
462
|
+
// pptxgenjs's `lineSpacingMultiple` matches the substrate's
|
|
463
|
+
// multiplier (1.0 = normal). 1:1.
|
|
464
|
+
out.lineSpacingMultiple = style.lineSpacing;
|
|
465
|
+
}
|
|
466
|
+
if (style.spaceAbove !== undefined) out.paraSpaceBefore = style.spaceAbove;
|
|
467
|
+
if (style.spaceBelow !== undefined) out.paraSpaceAfter = style.spaceBelow;
|
|
468
|
+
return out;
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const pptxAlign = (
|
|
472
|
+
alignment: 'START' | 'CENTER' | 'END' | 'JUSTIFIED',
|
|
473
|
+
): 'left' | 'center' | 'right' | 'justify' => {
|
|
474
|
+
switch (alignment) {
|
|
475
|
+
case 'START':
|
|
476
|
+
return 'left';
|
|
477
|
+
case 'CENTER':
|
|
478
|
+
return 'center';
|
|
479
|
+
case 'END':
|
|
480
|
+
return 'right';
|
|
481
|
+
case 'JUSTIFIED':
|
|
482
|
+
return 'justify';
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
const paragraphValignFromStyle = (
|
|
487
|
+
_style: ParagraphStyle | undefined,
|
|
488
|
+
): 'top' | 'middle' | 'bottom' | undefined => {
|
|
489
|
+
// The substrate's `ParagraphStyle` doesn't carry vertical alignment;
|
|
490
|
+
// returning undefined preserves pptxgenjs's default. Reserved for a
|
|
491
|
+
// future v-align addition.
|
|
492
|
+
return undefined;
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
const sanitizeFilename = (title: string): string => {
|
|
496
|
+
// Replace anything non-alphanumeric / non-dash / non-underscore with `-`;
|
|
497
|
+
// collapse runs; trim leading/trailing dashes. Falls back to "deck" if
|
|
498
|
+
// the result would be empty.
|
|
499
|
+
const cleaned = title
|
|
500
|
+
.replace(/[^a-zA-Z0-9_-]+/g, '-')
|
|
501
|
+
.replace(/-+/g, '-')
|
|
502
|
+
.replace(/^-|-$/g, '');
|
|
503
|
+
return cleaned.length > 0 ? cleaned : 'deck';
|
|
504
|
+
};
|