@markdstage/markdstage 0.1.3 → 2.4.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.
@@ -0,0 +1,195 @@
1
+ import { readFile, realpath, stat } from "node:fs/promises";
2
+ import { isAbsolute, relative, resolve, sep } from "node:path";
3
+
4
+ import { parseArchitecture } from "../renderer/architecture.mjs";
5
+ import {
6
+ findArchitectureBlocks,
7
+ replaceArchitectureBlock,
8
+ } from "../scripts/markdown-blocks.mjs";
9
+ import { isMarkdownPath, MARKDOWN_MAX_BYTES } from "../scripts/markdown-files.mjs";
10
+ import { serializeMarkdownSave } from "../scripts/markdown-save-coordinator.mjs";
11
+ import { atomicReplaceMarkdown } from "../scripts/atomic-markdown-replace.mjs";
12
+ import { isPathInside } from "./output-paths.mjs";
13
+
14
+ function sourceError(code, message) {
15
+ const error = new Error(message);
16
+ error.code = code;
17
+ return error;
18
+ }
19
+
20
+ export async function resolveArchitectureSourceTarget(workspaceRoot, sourcePath) {
21
+ const root = resolve(workspaceRoot);
22
+ if (
23
+ typeof sourcePath !== "string" ||
24
+ !sourcePath ||
25
+ isAbsolute(sourcePath) ||
26
+ sourcePath.includes("\0")
27
+ ) {
28
+ throw sourceError(
29
+ "invalid_source_path",
30
+ "sourcePath must be a Markdown file inside the workspace.",
31
+ );
32
+ }
33
+ const candidate = resolve(root, sourcePath);
34
+ if (!isPathInside(root, candidate) || !isMarkdownPath(candidate)) {
35
+ throw sourceError(
36
+ "invalid_source_path",
37
+ "sourcePath must be a Markdown file inside the workspace.",
38
+ );
39
+ }
40
+
41
+ let canonicalRoot;
42
+ let canonicalSource;
43
+ try {
44
+ [canonicalRoot, canonicalSource] = await Promise.all([realpath(root), realpath(candidate)]);
45
+ } catch (_) {
46
+ throw sourceError("source_file_not_found", `Markdown file not found: ${sourcePath}`);
47
+ }
48
+ if (
49
+ !isPathInside(canonicalRoot, canonicalSource) ||
50
+ resolve(canonicalSource) !== resolve(candidate)
51
+ ) {
52
+ throw sourceError(
53
+ "invalid_source_path",
54
+ "sourcePath must resolve directly to a Markdown file inside the workspace.",
55
+ );
56
+ }
57
+ const info = await stat(canonicalSource);
58
+ if (!info.isFile()) {
59
+ throw sourceError("source_file_not_found", `Markdown file not found: ${sourcePath}`);
60
+ }
61
+ if (info.size > MARKDOWN_MAX_BYTES) {
62
+ throw sourceError("source_file_too_large", "The Markdown file is too large to edit.");
63
+ }
64
+ return {
65
+ root: canonicalRoot,
66
+ path: canonicalSource,
67
+ relativePath: relative(canonicalRoot, canonicalSource),
68
+ mode: info.mode,
69
+ };
70
+ }
71
+
72
+ export async function readArchitectureSourceTarget(workspaceRoot, sourcePath, blockIndex) {
73
+ if (!Number.isInteger(blockIndex) || blockIndex < 0) {
74
+ throw sourceError("invalid_block_index", "blockIndex must be a non-negative integer.");
75
+ }
76
+ const target = await resolveArchitectureSourceTarget(workspaceRoot, sourcePath);
77
+ const markdown = await readFile(target.path, "utf8");
78
+ const block = findArchitectureBlocks(markdown)[blockIndex];
79
+ if (!block) {
80
+ throw sourceError(
81
+ "block_not_found",
82
+ `Architecture block ${blockIndex} was not found in ${sourcePath}.`,
83
+ );
84
+ }
85
+ try {
86
+ parseArchitecture(block.body);
87
+ } catch (error) {
88
+ throw sourceError("invalid_architecture", error?.message || "Invalid Architecture DSL.");
89
+ }
90
+ return { ...target, markdown, source: block.body };
91
+ }
92
+
93
+ export function saveArchitectureSource({
94
+ workspaceRoot,
95
+ sourcePath,
96
+ sourceFile,
97
+ blockIndex,
98
+ source,
99
+ expectedMarkdown,
100
+ }) {
101
+ const queuePath = sourceFile || resolve(workspaceRoot, sourcePath);
102
+ return serializeMarkdownSave(queuePath, async () => {
103
+ try {
104
+ parseArchitecture(source);
105
+ } catch (error) {
106
+ return {
107
+ ok: false,
108
+ error: "invalid_architecture",
109
+ message: error?.message || "The diagram is invalid.",
110
+ };
111
+ }
112
+
113
+ let target;
114
+ try {
115
+ target = await resolveArchitectureSourceTarget(workspaceRoot, sourcePath);
116
+ } catch (error) {
117
+ return {
118
+ ok: false,
119
+ error:
120
+ error?.code === "source_file_too_large" ? "source_file_too_large" : "source_changed",
121
+ message: "The source Markdown target changed outside the editor. Reload before saving.",
122
+ };
123
+ }
124
+ if (sourceFile && resolve(target.path) !== resolve(sourceFile)) {
125
+ return {
126
+ ok: false,
127
+ error: "source_changed",
128
+ message: "The source Markdown target changed outside the editor. Reload before saving.",
129
+ };
130
+ }
131
+
132
+ let markdown;
133
+ try {
134
+ markdown = await readFile(target.path, "utf8");
135
+ } catch (_) {
136
+ return {
137
+ ok: false,
138
+ error: "source_file_not_found",
139
+ message: "The source Markdown file no longer exists.",
140
+ };
141
+ }
142
+ if (markdown !== expectedMarkdown) {
143
+ return {
144
+ ok: false,
145
+ error: "source_changed",
146
+ message: "The source Markdown changed outside the editor. Reload before saving.",
147
+ };
148
+ }
149
+
150
+ const next = replaceArchitectureBlock(markdown, blockIndex, source);
151
+ if (next === null) {
152
+ return {
153
+ ok: false,
154
+ error: "block_not_found",
155
+ message: "The Architecture block no longer exists.",
156
+ };
157
+ }
158
+ try {
159
+ await atomicReplaceMarkdown({
160
+ path: target.path,
161
+ markdown: next,
162
+ expectedMarkdown: markdown,
163
+ mode: target.mode,
164
+ revalidate: async () => {
165
+ try {
166
+ const verified = await resolveArchitectureSourceTarget(workspaceRoot, sourcePath);
167
+ if (resolve(verified.path) === resolve(target.path)) return;
168
+ } catch (_) {
169
+ // Target replacement and removal are both write conflicts.
170
+ }
171
+ throw sourceError("SOURCE_CHANGED", "source_changed");
172
+ },
173
+ });
174
+ } catch (error) {
175
+ if (error?.code === "SOURCE_CHANGED") {
176
+ return {
177
+ ok: false,
178
+ error: "source_changed",
179
+ message: "The source Markdown changed while it was being saved.",
180
+ };
181
+ }
182
+ return {
183
+ ok: false,
184
+ error: "source_write_failed",
185
+ message: error?.message || "The source Markdown could not be saved.",
186
+ };
187
+ }
188
+ return {
189
+ ok: true,
190
+ sourcePath: target.relativePath.split(sep).join("/"),
191
+ blockIndex,
192
+ markdown: next,
193
+ };
194
+ });
195
+ }
@@ -427,9 +427,8 @@ async function waitForOutputJob(job, child, diagnostics) {
427
427
  }
428
428
  }
429
429
 
430
- export async function runCdpOutputBrowser(browser, pageUrl, profileDir, job, capturePng) {
430
+ function launchCdpOutputBrowser(browser, profileDir) {
431
431
  const diagnostics = { value: "" };
432
- await rm(join(profileDir, "DevToolsActivePort"), { force: true }).catch(() => {});
433
432
  const args = withSandboxFallback([
434
433
  "--headless=new",
435
434
  "--disable-gpu",
@@ -457,7 +456,12 @@ export async function runCdpOutputBrowser(browser, pageUrl, profileDir, job, cap
457
456
  };
458
457
  child.stdout.on("data", appendDiagnostics);
459
458
  child.stderr.on("data", appendDiagnostics);
459
+ return { child, diagnostics };
460
+ }
460
461
 
462
+ async function openCdpOutputPage(browser, pageUrl, profileDir, job) {
463
+ await rm(join(profileDir, "DevToolsActivePort"), { force: true }).catch(() => {});
464
+ const { child, diagnostics } = launchCdpOutputBrowser(browser, profileDir);
461
465
  let cdp = null;
462
466
  try {
463
467
  const port = await waitForDevToolsPort(profileDir, child, diagnostics);
@@ -475,6 +479,22 @@ export async function runCdpOutputBrowser(browser, pageUrl, profileDir, job, cap
475
479
  throw new Error(`Chromium could not open the renderer: ${navigation.errorText}`);
476
480
  }
477
481
  await waitForOutputJob(job, child, diagnostics);
482
+ return { cdp, child };
483
+ } catch (error) {
484
+ cdp?.close();
485
+ if (isProcessRunning(child)) await terminateProcessTree(child);
486
+ throw error;
487
+ }
488
+ }
489
+
490
+ async function closeCdpOutputPage(cdp, child) {
491
+ cdp?.close();
492
+ if (isProcessRunning(child)) await terminateProcessTree(child);
493
+ }
494
+
495
+ export async function runCdpOutputBrowser(browser, pageUrl, profileDir, job, capturePng) {
496
+ const { cdp, child } = await openCdpOutputPage(browser, pageUrl, profileDir, job);
497
+ try {
478
498
  if (!capturePng) return null;
479
499
  await cdp.send("Runtime.evaluate", {
480
500
  expression:
@@ -491,8 +511,55 @@ export async function runCdpOutputBrowser(browser, pageUrl, profileDir, job, cap
491
511
  }
492
512
  return Buffer.from(screenshot.data, "base64");
493
513
  } finally {
494
- cdp?.close();
495
- if (isProcessRunning(child)) await terminateProcessTree(child);
514
+ await closeCdpOutputPage(cdp, child);
515
+ }
516
+ }
517
+
518
+ export async function runPptxOutputBrowser(browser, pageUrl, profileDir, job, total) {
519
+ const { cdp, child } = await openCdpOutputPage(browser, pageUrl, profileDir, job);
520
+ try {
521
+ const evaluated = await cdp.send("Runtime.evaluate", {
522
+ expression: "window.__presentationPptxModel",
523
+ returnByValue: true,
524
+ });
525
+ if (evaluated.exceptionDetails) {
526
+ throw new Error(
527
+ evaluated.exceptionDetails.text || "The renderer could not expose the PowerPoint model.",
528
+ );
529
+ }
530
+ const model = evaluated.result?.value;
531
+ if (!model || !Array.isArray(model.slides) || model.slides.length !== total) {
532
+ throw new Error("The renderer returned an invalid PowerPoint export model.");
533
+ }
534
+
535
+ await cdp.send("Runtime.evaluate", {
536
+ expression:
537
+ "new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))",
538
+ awaitPromise: true,
539
+ });
540
+
541
+ const backgrounds = [];
542
+ for (let index = 0; index < total; index += 1) {
543
+ const screenshot = await cdp.send("Page.captureScreenshot", {
544
+ format: "png",
545
+ fromSurface: true,
546
+ captureBeyondViewport: true,
547
+ clip: {
548
+ x: 0,
549
+ y: index * 720,
550
+ width: 1280,
551
+ height: 720,
552
+ scale: 1,
553
+ },
554
+ });
555
+ if (typeof screenshot.data !== "string" || screenshot.data.length === 0) {
556
+ throw new Error(`Chromium did not return fallback artwork for slide ${index + 1}.`);
557
+ }
558
+ backgrounds.push(Buffer.from(screenshot.data, "base64"));
559
+ }
560
+ return { model, backgrounds };
561
+ } finally {
562
+ await closeCdpOutputPage(cdp, child);
496
563
  }
497
564
  }
498
565
 
@@ -123,6 +123,7 @@ export async function createDeckSession({
123
123
  url: "",
124
124
  version: 0,
125
125
  deckVersion: 0,
126
+ sourceMarkdown: "",
126
127
  markdown: "",
127
128
  slides: [],
128
129
  index: 0,
@@ -144,7 +145,7 @@ export async function createDeckSession({
144
145
  };
145
146
 
146
147
  session.load = async ({ preserveIndex = false } = {}) => {
147
- const { slides } = await readDeckSlides(session.file);
148
+ const { markdown, slides } = await readDeckSlides(session.file);
148
149
  const selection = resolveDeckTheme({
149
150
  slides,
150
151
  explicitTheme: session.requestedTheme,
@@ -166,6 +167,7 @@ export async function createDeckSession({
166
167
  session.customThemeDir = custom.dir;
167
168
  session.customThemeMeta = custom.metadata;
168
169
  session.customThemeAssets = new Set(custom.assets);
170
+ session.sourceMarkdown = markdown;
169
171
  session.slides = ensureBackCover(slides.slice());
170
172
  session.index = clampIndex(preserveIndex ? session.index : 0, session.slides.length);
171
173
  session.markdown = session.slides[session.index] ?? "";
@@ -1,7 +1,7 @@
1
1
  // Workspace-confined output path resolution shared by the Canvas Extension and
2
2
  // the MarkdStage CLI.
3
3
  //
4
- // Every generated file (PDF, PNG) must land inside the resolved workspace, and
4
+ // Every generated file (PDF, PPTX, PNG) must land inside the resolved workspace, and
5
5
  // no intermediate directory may traverse a symlink or junction that escapes it.
6
6
 
7
7
  import { mkdir, realpath, stat } from "node:fs/promises";
@@ -18,6 +18,7 @@ import {
18
18
  import { MarkdStageError } from "./errors.mjs";
19
19
 
20
20
  export const DEFAULT_PDF_NAME = "markdstage.pdf";
21
+ export const DEFAULT_PPTX_NAME = "markdstage.pptx";
21
22
  export const DEFAULT_CAPTURE_DIR = "markdstage-previews";
22
23
 
23
24
  function safeBaseName(sourceName) {
@@ -33,6 +34,10 @@ export function pdfNameForSource(sourceName) {
33
34
  return `${safeBaseName(sourceName) || basename(DEFAULT_PDF_NAME, ".pdf")}.pdf`;
34
35
  }
35
36
 
37
+ export function pptxNameForSource(sourceName) {
38
+ return `${safeBaseName(sourceName) || basename(DEFAULT_PPTX_NAME, ".pptx")}.pptx`;
39
+ }
40
+
36
41
  export function captureDirectoryName(sourceName) {
37
42
  const safeBase = safeBaseName(sourceName);
38
43
  return safeBase ? `${safeBase}-previews` : DEFAULT_CAPTURE_DIR;
@@ -95,6 +100,14 @@ export function resolvePdfOutputPath(workspaceRoot, requestedPath) {
95
100
  });
96
101
  }
97
102
 
103
+ export function resolvePptxOutputPath(workspaceRoot, requestedPath) {
104
+ return resolveWorkspaceOutputPath(workspaceRoot, requestedPath, {
105
+ defaultName: DEFAULT_PPTX_NAME,
106
+ extension: ".pptx",
107
+ label: "PowerPoint",
108
+ });
109
+ }
110
+
98
111
  export function resolveCaptureOutputDirectory(workspaceRoot, sourceName, requestedPath) {
99
112
  const root = resolve(workspaceRoot);
100
113
  const requested =
@@ -157,3 +170,7 @@ export async function prepareWorkspaceDirectory(workspaceRoot, outputParent, lab
157
170
  export async function preparePdfOutputDirectory(workspaceRoot, outputPath) {
158
171
  return prepareWorkspaceDirectory(workspaceRoot, dirname(outputPath), "PDF");
159
172
  }
173
+
174
+ export async function preparePptxOutputDirectory(workspaceRoot, outputPath) {
175
+ return prepareWorkspaceDirectory(workspaceRoot, dirname(outputPath), "PowerPoint");
176
+ }
@@ -1,4 +1,4 @@
1
- // PDF export, PNG capture, and fixed 16:9 layout inspection.
1
+ // PDF/PowerPoint export, PNG capture, and fixed 16:9 layout inspection.
2
2
  //
3
3
  // The Canvas Extension and the MarkdStage CLI share this implementation so both
4
4
  // produce byte-identical output. Callers provide a session object with:
@@ -17,17 +17,27 @@ import {
17
17
  findChromiumBrowser,
18
18
  runCdpOutputBrowser,
19
19
  runPdfBrowser,
20
+ runPptxOutputBrowser,
20
21
  verifyPdf,
21
22
  verifyPng,
22
23
  } from "./browser.mjs";
23
24
  import {
24
25
  prepareWorkspaceDirectory,
25
26
  preparePdfOutputDirectory,
27
+ preparePptxOutputDirectory,
26
28
  resolveCaptureOutputDirectory,
27
29
  resolvePdfOutputPath,
30
+ resolvePptxOutputPath,
28
31
  } from "./output-paths.mjs";
32
+ import {
33
+ buildPptxPackage,
34
+ inspectPptxPackage,
35
+ PPTX_DIMENSIONS,
36
+ } from "./pptx-package.mjs";
29
37
 
30
38
  export const MAX_CAPTURE_SLIDES = 10;
39
+ export const MAX_PPTX_ASSET_BYTES = 10 * 1024 * 1024;
40
+ export const MAX_PPTX_TOTAL_ASSET_BYTES = 100 * 1024 * 1024;
31
41
 
32
42
  function logFor(inst, message, level = "info") {
33
43
  try {
@@ -70,6 +80,142 @@ export function createOutputJob(snapshot, kind) {
70
80
  };
71
81
  }
72
82
 
83
+ function decodeDataImage(source) {
84
+ const match = /^data:(image\/(?:png|jpeg|gif))(;base64)?,([\s\S]*)$/i.exec(source);
85
+ if (!match) {
86
+ throw new Error("Only PNG, JPEG, or GIF data URLs can be embedded in PowerPoint.");
87
+ }
88
+ const data = match[2]
89
+ ? Buffer.from(match[3], "base64")
90
+ : Buffer.from(decodeURIComponent(match[3]), "binary");
91
+ return { data, contentType: match[1].toLowerCase() };
92
+ }
93
+
94
+ function ensurePptxAssetSize(data, source, currentTotal) {
95
+ if (data.length > MAX_PPTX_ASSET_BYTES) {
96
+ throw new Error(
97
+ `PowerPoint image exceeds ${MAX_PPTX_ASSET_BYTES} bytes: ${source}`,
98
+ );
99
+ }
100
+ if (currentTotal + data.length > MAX_PPTX_TOTAL_ASSET_BYTES) {
101
+ throw new Error(
102
+ `PowerPoint image assets exceed ${MAX_PPTX_TOTAL_ASSET_BYTES} bytes in total.`,
103
+ );
104
+ }
105
+ }
106
+
107
+ async function loadPptxImage(inst, source, fetchImpl, currentTotal) {
108
+ if (typeof source !== "string" || !source) {
109
+ throw new Error("PowerPoint image is missing its source URL.");
110
+ }
111
+ if (source.startsWith("data:")) {
112
+ const decoded = decodeDataImage(source);
113
+ ensurePptxAssetSize(decoded.data, "data URL", currentTotal);
114
+ return decoded;
115
+ }
116
+
117
+ const base = new URL(inst.url);
118
+ const url = new URL(source, base);
119
+ if (url.origin !== base.origin) {
120
+ throw new Error(`PowerPoint image must be served by the MarkdStage workspace: ${source}`);
121
+ }
122
+ const response = await fetchImpl(url, { cache: "no-store" });
123
+ if (!response.ok) {
124
+ throw new Error(`Could not load PowerPoint image (${response.status}): ${source}`);
125
+ }
126
+ const data = Buffer.from(await response.arrayBuffer());
127
+ ensurePptxAssetSize(data, source, currentTotal);
128
+ const responseType = response.headers.get("content-type")?.split(";")[0].trim().toLowerCase();
129
+ const contentType = ["image/png", "image/jpeg", "image/gif"].includes(responseType)
130
+ ? responseType
131
+ : undefined;
132
+ return { data, contentType };
133
+ }
134
+
135
+ export async function preparePptxPackageModel(
136
+ inst,
137
+ model,
138
+ backgrounds,
139
+ fetchImpl = fetch,
140
+ ) {
141
+ if (
142
+ !model ||
143
+ model.version !== 1 ||
144
+ model.width !== PPTX_DIMENSIONS.widthPx ||
145
+ model.height !== PPTX_DIMENSIONS.heightPx ||
146
+ !Array.isArray(model.slides) ||
147
+ model.slides.length === 0
148
+ ) {
149
+ throw new Error("The renderer returned an unsupported PowerPoint export model.");
150
+ }
151
+ if (!Array.isArray(backgrounds) || backgrounds.length !== model.slides.length) {
152
+ throw new Error("PowerPoint fallback artwork does not match the slide count.");
153
+ }
154
+
155
+ const assets = [];
156
+ const sourceAssets = new Map();
157
+ let totalAssetBytes = 0;
158
+ for (const [index, background] of backgrounds.entries()) {
159
+ if (!Buffer.isBuffer(background)) {
160
+ throw new Error(`PowerPoint fallback artwork for slide ${index + 1} is invalid.`);
161
+ }
162
+ ensurePptxAssetSize(background, `slide ${index + 1} fallback artwork`, totalAssetBytes);
163
+ totalAssetBytes += background.length;
164
+ assets.push({
165
+ id: `markdstage-background-${index + 1}`,
166
+ contentType: "image/png",
167
+ data: background,
168
+ });
169
+ }
170
+
171
+ const slides = [];
172
+ for (const [slideIndex, sourceSlide] of model.slides.entries()) {
173
+ if (!sourceSlide || !Array.isArray(sourceSlide.elements)) {
174
+ throw new Error(`PowerPoint slide ${slideIndex + 1} has an invalid element list.`);
175
+ }
176
+ const elements = [];
177
+ for (const sourceElement of sourceSlide.elements) {
178
+ if (sourceElement?.type !== "image") {
179
+ elements.push(sourceElement);
180
+ continue;
181
+ }
182
+ const source = sourceElement.src;
183
+ let assetId = sourceAssets.get(source);
184
+ if (!assetId) {
185
+ const loaded = await loadPptxImage(inst, source, fetchImpl, totalAssetBytes);
186
+ totalAssetBytes += loaded.data.length;
187
+ assetId = `markdstage-image-${sourceAssets.size + 1}`;
188
+ sourceAssets.set(source, assetId);
189
+ assets.push({ id: assetId, ...loaded });
190
+ }
191
+ const { src: _src, source: _source, ...image } = sourceElement;
192
+ if (
193
+ (image.fit === "contain" || image.fit === "scale-down") &&
194
+ image.naturalWidth > 0 &&
195
+ image.naturalHeight > 0
196
+ ) {
197
+ const scale = Math.min(
198
+ image.width / image.naturalWidth,
199
+ image.height / image.naturalHeight,
200
+ image.fit === "scale-down" ? 1 : Number.POSITIVE_INFINITY,
201
+ );
202
+ const width = image.naturalWidth * scale;
203
+ const height = image.naturalHeight * scale;
204
+ image.x += (image.width - width) / 2;
205
+ image.y += (image.height - height) / 2;
206
+ image.width = width;
207
+ image.height = height;
208
+ }
209
+ elements.push({ ...image, assetId });
210
+ }
211
+ slides.push({
212
+ backgroundAssetId: `markdstage-background-${slideIndex + 1}`,
213
+ elements,
214
+ });
215
+ }
216
+ return { slides, assets };
217
+ }
218
+
73
219
  async function runLayoutInspectionJob(inst, snapshot, browser) {
74
220
  const token = randomUUID();
75
221
  const profileDir = await mkdtemp(join(tmpdir(), "markdstage-inspect-"));
@@ -114,7 +260,7 @@ export async function inspectLayout(inst, requestedIndex, includeFits = false) {
114
260
  if (inst.exporting) {
115
261
  throw new MarkdStageError(
116
262
  "output_in_progress",
117
- "Another PDF, layout inspection, or PNG output job is already running for this canvas.",
263
+ "Another PDF, PowerPoint, layout inspection, or PNG output job is already running for this canvas.",
118
264
  );
119
265
  }
120
266
  const snapshot = createOutputSnapshot(inst);
@@ -194,7 +340,7 @@ export async function captureSlides(
194
340
  if (inst.exporting) {
195
341
  throw new MarkdStageError(
196
342
  "output_in_progress",
197
- "Another PDF, layout inspection, or PNG output job is already running for this canvas.",
343
+ "Another PDF, PowerPoint, layout inspection, or PNG output job is already running for this canvas.",
198
344
  );
199
345
  }
200
346
  const snapshot = createOutputSnapshot(inst, requestedTheme);
@@ -315,9 +461,10 @@ export async function exportPdf(inst, requestedPath, requestedTheme) {
315
461
  if (inst.exporting) {
316
462
  throw new MarkdStageError(
317
463
  "export_in_progress",
318
- "Another PDF, layout inspection, or PNG output job is already running for this canvas.",
464
+ "Another PDF, PowerPoint, layout inspection, or PNG output job is already running for this canvas.",
319
465
  );
320
466
  }
467
+
321
468
  inst.exporting = true;
322
469
  let token = "";
323
470
  let profileDir = "";
@@ -362,6 +509,7 @@ export async function exportPdf(inst, requestedPath, requestedTheme) {
362
509
  logFor(inst, `MarkdStage: exported ${snapshot.slides.length} slides to ${outputPath}`);
363
510
  return {
364
511
  ok: true,
512
+ format: "pdf",
365
513
  path: outputPath,
366
514
  total: snapshot.slides.length,
367
515
  theme: snapshot.theme,
@@ -383,3 +531,118 @@ export async function exportPdf(inst, requestedPath, requestedTheme) {
383
531
  }
384
532
  }
385
533
  }
534
+
535
+ export async function exportPptx(
536
+ inst,
537
+ requestedPath,
538
+ requestedTheme,
539
+ dependencies = {},
540
+ ) {
541
+ const findBrowser = dependencies.findChromiumBrowser ?? findChromiumBrowser;
542
+ const runBrowser = dependencies.runPptxOutputBrowser ?? runPptxOutputBrowser;
543
+ const prepareModel = dependencies.preparePptxPackageModel ?? preparePptxPackageModel;
544
+ const buildPackage = dependencies.buildPptxPackage ?? buildPptxPackage;
545
+ const inspectPackage = dependencies.inspectPptxPackage ?? inspectPptxPackage;
546
+ if (inst.exporting) {
547
+ throw new MarkdStageError(
548
+ "export_in_progress",
549
+ "Another PDF, PowerPoint, layout inspection, or PNG output job is already running for this canvas.",
550
+ );
551
+ }
552
+ inst.exporting = true;
553
+ let token = "";
554
+ let profileDir = "";
555
+ let temporaryOutputPath = "";
556
+
557
+ try {
558
+ const snapshot = createOutputSnapshot(inst, requestedTheme);
559
+ if (!snapshot.slides.length) {
560
+ throw new MarkdStageError(
561
+ "no_deck",
562
+ "No slides are loaded. Load a deck before exporting PowerPoint.",
563
+ );
564
+ }
565
+
566
+ const browser = findBrowser();
567
+ if (!browser) {
568
+ throw new MarkdStageError(
569
+ "pptx_browser_not_found",
570
+ "PowerPoint export requires Microsoft Edge, Google Chrome, or Chromium.",
571
+ );
572
+ }
573
+
574
+ const outputPath = resolvePptxOutputPath(inst.workspaceRoot, requestedPath);
575
+ const outputParent = await preparePptxOutputDirectory(inst.workspaceRoot, outputPath);
576
+ token = randomUUID();
577
+ profileDir = await mkdtemp(join(tmpdir(), "markdstage-pptx-"));
578
+ const outputBase = basename(outputPath, extname(outputPath)) || "markdstage";
579
+ temporaryOutputPath = join(outputParent, `.${outputBase}.${token}.tmp.pptx`);
580
+ const job = createOutputJob(snapshot, "pptx");
581
+ inst.exportJobs.set(token, job);
582
+
583
+ const pageUrl = pageUrlFor(inst, { pptx: 1, token });
584
+ const { model, backgrounds } = await runBrowser(
585
+ browser,
586
+ pageUrl,
587
+ profileDir,
588
+ job,
589
+ snapshot.slides.length,
590
+ );
591
+ const packageModel = await prepareModel(inst, model, backgrounds);
592
+ const buffer = buildPackage({
593
+ title: model.slides[0]?.title || outputBase,
594
+ ...packageModel,
595
+ });
596
+ const packageSummary = inspectPackage(buffer);
597
+ if (
598
+ !packageSummary.valid ||
599
+ packageSummary.slideCount !== snapshot.slides.length ||
600
+ packageSummary.dimensions.widthEmu !== PPTX_DIMENSIONS.widthEmu ||
601
+ packageSummary.dimensions.heightEmu !== PPTX_DIMENSIONS.heightEmu
602
+ ) {
603
+ throw new Error("The generated PowerPoint package failed validation.");
604
+ }
605
+
606
+ await writeFile(temporaryOutputPath, buffer);
607
+ await rename(temporaryOutputPath, outputPath);
608
+ temporaryOutputPath = "";
609
+ const fallbacks = model.slides.flatMap((slide, slideIndex) =>
610
+ (Array.isArray(slide.fallbacks) ? slide.fallbacks : []).map((fallback) => ({
611
+ slideIndex,
612
+ page: slideIndex + 1,
613
+ ...fallback,
614
+ })),
615
+ );
616
+ logFor(
617
+ inst,
618
+ `MarkdStage: exported ${snapshot.slides.length} slides to ${outputPath} (${fallbacks.length} fallbacks)`,
619
+ );
620
+ return {
621
+ ok: true,
622
+ format: "pptx",
623
+ path: outputPath,
624
+ total: snapshot.slides.length,
625
+ theme: snapshot.theme,
626
+ bytes: buffer.length,
627
+ fallbackCount: fallbacks.length,
628
+ fallbacks,
629
+ };
630
+ } catch (error) {
631
+ if (error instanceof MarkdStageError) throw error;
632
+ throw new MarkdStageError(
633
+ "pptx_export_failed",
634
+ error?.message || "PowerPoint export failed.",
635
+ );
636
+ } finally {
637
+ if (token) {
638
+ inst.exportJobs.delete(token);
639
+ }
640
+ if (temporaryOutputPath) {
641
+ await rm(temporaryOutputPath, { force: true }).catch(() => {});
642
+ }
643
+ inst.exporting = false;
644
+ if (profileDir) {
645
+ await rm(profileDir, { recursive: true, force: true }).catch(() => {});
646
+ }
647
+ }
648
+ }