@markdstage/markdstage 0.1.3 → 2.3.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 +21 -6
- package/package.json +3 -1
- package/shared/README.md +29 -12
- package/shared/architecture-editor/editor.css +155 -0
- package/shared/architecture-editor/editor.js +1775 -0
- package/shared/architecture-editor/index.html +99 -0
- package/shared/markdstage-guide.mjs +1 -1
- package/shared/renderer/architecture.mjs +233 -0
- package/shared/renderer/index.html +7 -6
- package/shared/renderer/renderer.js +1163 -16
- package/shared/renderer/slides.css +42 -2
- package/shared/runtime/architecture-editor-server.mjs +651 -0
- package/shared/runtime/architecture-source.mjs +195 -0
- package/shared/runtime/browser.mjs +71 -4
- package/shared/runtime/deck-session.mjs +3 -1
- package/shared/runtime/output-paths.mjs +18 -1
- package/shared/runtime/output.mjs +267 -4
- package/shared/runtime/pptx-package.mjs +1088 -0
- package/shared/runtime/presentation-server.mjs +268 -12
- package/src/cli.mjs +42 -4
- package/src/commands/export.mjs +31 -11
- package/src/commands/present.mjs +138 -49
- package/src/deck.mjs +2 -0
- package/src/runtime.mjs +8 -2
- package/src/skills.mjs +9 -3
|
@@ -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
|
-
|
|
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
|
|
495
|
-
|
|
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
|
+
}
|