@honeydeck/honeydeck 0.1.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/AGENTS.md +25 -0
- package/DEVELOPMENT.md +522 -0
- package/LICENSE +21 -0
- package/Readme.md +49 -0
- package/SPEC.md +88 -0
- package/docs/components.md +63 -0
- package/docs/configuration.md +91 -0
- package/docs/getting-started.md +116 -0
- package/docs/kit-authoring.md +207 -0
- package/docs/kits.md +387 -0
- package/docs/local-development.md +95 -0
- package/docs/mermaid.md +198 -0
- package/docs/mobile.md +108 -0
- package/docs/navigation.md +93 -0
- package/docs/next-steps.md +377 -0
- package/docs/pdf-export.md +91 -0
- package/docs/presenter-mode.md +104 -0
- package/docs/slides.md +130 -0
- package/docs/slidev-migration.md +42 -0
- package/docs/steps-and-reveals.md +171 -0
- package/package.json +134 -0
- package/skills/SPEC.md +21 -0
- package/skills/honeydeck/SKILL.md +65 -0
- package/skills/presentation-writing/SKILL.md +75 -0
- package/skills/slidev-migration/SKILL.md +153 -0
- package/src/SPEC.md +89 -0
- package/src/assets.d.ts +30 -0
- package/src/cli/SPEC.md +230 -0
- package/src/cli/args.ts +3 -0
- package/src/cli/banner.ts +9 -0
- package/src/cli/bin.js +5 -0
- package/src/cli/build.ts +229 -0
- package/src/cli/deck-path.ts +32 -0
- package/src/cli/dev.ts +263 -0
- package/src/cli/index.ts +126 -0
- package/src/cli/init.ts +369 -0
- package/src/cli/pdf.ts +923 -0
- package/src/cli/skill.ts +75 -0
- package/src/cli/templates/SPEC.md +70 -0
- package/src/cli/templates/deck-mdx.ts +15 -0
- package/src/cli/templates/package-json.ts +36 -0
- package/src/cli/templates/sparkle-button.ts +15 -0
- package/src/cli/templates/starter/components/SparkleButton.tsx +84 -0
- package/src/cli/templates/starter/deck.mdx +153 -0
- package/src/cli/templates/starter/styles.css +14 -0
- package/src/cli/templates/styles-css.ts +14 -0
- package/src/defaults.ts +1 -0
- package/src/layouts/ColorModeImage.tsx +55 -0
- package/src/layouts/SPEC.md +393 -0
- package/src/layouts/SlideFrame.tsx +48 -0
- package/src/layouts/bee/Blank.tsx +12 -0
- package/src/layouts/bee/Cover.tsx +70 -0
- package/src/layouts/bee/Default.tsx +42 -0
- package/src/layouts/bee/Image/Image.tsx +151 -0
- package/src/layouts/bee/Image/placeholder-dark.webp +0 -0
- package/src/layouts/bee/Image/placeholder-vertical-dark.webp +0 -0
- package/src/layouts/bee/Image/placeholder-vertical.webp +0 -0
- package/src/layouts/bee/Image/placeholder.webp +0 -0
- package/src/layouts/bee/ImageLeft.tsx +27 -0
- package/src/layouts/bee/ImageRight.tsx +27 -0
- package/src/layouts/bee/ImageSide.tsx +107 -0
- package/src/layouts/bee/Section.tsx +40 -0
- package/src/layouts/bee/TwoCol.tsx +108 -0
- package/src/layouts/bee/index.ts +40 -0
- package/src/layouts/clean/Blank.tsx +12 -0
- package/src/layouts/clean/Cover.tsx +58 -0
- package/src/layouts/clean/Default.tsx +33 -0
- package/src/layouts/clean/Image/Image.tsx +103 -0
- package/src/layouts/clean/ImageLeft.tsx +27 -0
- package/src/layouts/clean/ImageRight.tsx +27 -0
- package/src/layouts/clean/ImageSide.tsx +113 -0
- package/src/layouts/clean/Section.tsx +35 -0
- package/src/layouts/clean/TwoCol.tsx +63 -0
- package/src/layouts/clean/index.ts +40 -0
- package/src/layouts/index.ts +60 -0
- package/src/layouts/placeholders.ts +9 -0
- package/src/layouts/utils.ts +13 -0
- package/src/remark/SPEC.md +49 -0
- package/src/remark/h1-extract.ts +124 -0
- package/src/remark/index.ts +4 -0
- package/src/remark/shiki-code-blocks.ts +325 -0
- package/src/remark/step-numbering.ts +412 -0
- package/src/runtime/Deck.tsx +533 -0
- package/src/runtime/SPEC.md +256 -0
- package/src/runtime/SlideCanvas.tsx +95 -0
- package/src/runtime/TimelineContext.tsx +122 -0
- package/src/runtime/app-shell/index.html +31 -0
- package/src/runtime/app-shell/main.tsx +42 -0
- package/src/runtime/aspectRatio.ts +34 -0
- package/src/runtime/colorMode.ts +23 -0
- package/src/runtime/components/BrowserFrame.tsx +233 -0
- package/src/runtime/components/Button.tsx +57 -0
- package/src/runtime/components/CodeBlock.tsx +210 -0
- package/src/runtime/components/ColorModeCycleButton.tsx +59 -0
- package/src/runtime/components/ErrorBoundary.tsx +125 -0
- package/src/runtime/components/Keyboard.tsx +87 -0
- package/src/runtime/components/ListStyle.tsx +203 -0
- package/src/runtime/components/NavBar.tsx +223 -0
- package/src/runtime/components/NavBarButton.tsx +47 -0
- package/src/runtime/components/NavBarDivider.tsx +3 -0
- package/src/runtime/components/Notes.tsx +171 -0
- package/src/runtime/components/Reveal.tsx +82 -0
- package/src/runtime/components/RevealGroup.tsx +193 -0
- package/src/runtime/components/SPEC.md +263 -0
- package/src/runtime/components/SlideNumberBadge.tsx +11 -0
- package/src/runtime/components/TimelineSteps.tsx +115 -0
- package/src/runtime/components/index.ts +55 -0
- package/src/runtime/index.ts +42 -0
- package/src/runtime/inputOwnership.ts +68 -0
- package/src/runtime/keyboardTarget.ts +7 -0
- package/src/runtime/lastSlideRoute.ts +56 -0
- package/src/runtime/navigation.ts +211 -0
- package/src/runtime/router.ts +157 -0
- package/src/runtime/slideData.ts +137 -0
- package/src/runtime/sync.ts +267 -0
- package/src/runtime/types.ts +182 -0
- package/src/runtime/useKeyboardNav.ts +138 -0
- package/src/runtime/useSwipeNav.ts +257 -0
- package/src/runtime/views/DocsView.tsx +74 -0
- package/src/runtime/views/OverviewView.tsx +386 -0
- package/src/runtime/views/PresenterNotesPanel.tsx +76 -0
- package/src/runtime/views/PresenterView.tsx +340 -0
- package/src/runtime/views/SPEC.md +152 -0
- package/src/runtime/views/docs/ComponentsTab.tsx +178 -0
- package/src/runtime/views/docs/DocsHeader.tsx +101 -0
- package/src/runtime/views/docs/Intro.tsx +20 -0
- package/src/runtime/views/docs/LayoutsTab.tsx +324 -0
- package/src/runtime/views/docs/ThemeTab.tsx +110 -0
- package/src/runtime/views/index.ts +7 -0
- package/src/runtime/views/overviewGrid.ts +106 -0
- package/src/runtime/views/presenterPreview.ts +27 -0
- package/src/runtime/virtual-modules.d.ts +98 -0
- package/src/theme/SPEC.md +179 -0
- package/src/theme/base.css +623 -0
- package/src/theme/bee.css +35 -0
- package/src/theme/clean.css +38 -0
- package/src/vite-plugin/SPEC.md +114 -0
- package/src/vite-plugin/component-doc-crawler.ts +350 -0
- package/src/vite-plugin/deck-loader.ts +148 -0
- package/src/vite-plugin/index.ts +373 -0
- package/src/vite-plugin/layout-demo-crawler.ts +802 -0
- package/src/vite-plugin/splitter.ts +353 -0
- package/src/vite-plugin/token-manifest.ts +163 -0
- package/src/vite-plugin/virtual-modules.ts +587 -0
package/src/cli/pdf.ts
ADDED
|
@@ -0,0 +1,923 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `honeydeck pdf` โ export the presentation as a PDF file.
|
|
3
|
+
*
|
|
4
|
+
* ### Pipeline
|
|
5
|
+
*
|
|
6
|
+
* 1. Parse CLI arguments (`--deck`, `-o`, `--steps`, `--mode`, `--parallel`).
|
|
7
|
+
* 2. Read the deck entry: count slides, extract deck frontmatter.
|
|
8
|
+
* 3. Resolve PDF color mode: CLI flag > `pdfColorMode` FM > `colorMode` FM (if pinned) > `light`.
|
|
9
|
+
* 4. If `--steps all`: pre-compile each slide to get per-slide step counts.
|
|
10
|
+
* 5. Build the presentation to a temporary directory (quiet Vite build).
|
|
11
|
+
* 6. Start a minimal static HTTP server on a random OS-assigned port.
|
|
12
|
+
* 7. Launch headless Chromium via Playwright.
|
|
13
|
+
* 8. Navigate to `/#/1/0` (full load), then use hash changes for the rest.
|
|
14
|
+
* 9. Capture slide/step PNG screenshots with a bounded pool of Playwright pages.
|
|
15
|
+
* For each capture target:
|
|
16
|
+
* a. Set `location.hash` to `#/<slide>/<step>`.
|
|
17
|
+
* b. Wait for React to re-render.
|
|
18
|
+
* c. Force `data-honeydeck-color-mode` on <html>.
|
|
19
|
+
* d. Capture PNG screenshot at deck-derived PDF dimensions.
|
|
20
|
+
* 10. Embed all PNGs into a PDF document via pdf-lib.
|
|
21
|
+
* 11. Write PDF to the output path.
|
|
22
|
+
* 12. Clean up: close browser, stop server, remove temp build dir.
|
|
23
|
+
*
|
|
24
|
+
* ### Notes
|
|
25
|
+
* - Uses `pdfSteps: final` by default โ all reveals visible (step index 999
|
|
26
|
+
* is higher than any realistic step count, so all `<Reveal>` nodes show).
|
|
27
|
+
* The first URL includes `honeydeckPdfRender=final`, which tells custom
|
|
28
|
+
* step-driven components they may render a PDF-specific final composition.
|
|
29
|
+
* - Playwright must have Chromium installed (`npx playwright install chromium`).
|
|
30
|
+
* - Temp directory is always cleaned up in a `finally` block.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
existsSync,
|
|
35
|
+
mkdtempSync,
|
|
36
|
+
readFileSync,
|
|
37
|
+
rmSync,
|
|
38
|
+
writeFileSync,
|
|
39
|
+
} from "node:fs";
|
|
40
|
+
import {
|
|
41
|
+
createServer,
|
|
42
|
+
type IncomingMessage,
|
|
43
|
+
type ServerResponse,
|
|
44
|
+
} from "node:http";
|
|
45
|
+
import { availableParallelism, tmpdir } from "node:os";
|
|
46
|
+
import {
|
|
47
|
+
dirname,
|
|
48
|
+
extname,
|
|
49
|
+
isAbsolute,
|
|
50
|
+
join,
|
|
51
|
+
relative,
|
|
52
|
+
resolve,
|
|
53
|
+
} from "node:path";
|
|
54
|
+
import { fileURLToPath } from "node:url";
|
|
55
|
+
import { compile } from "@mdx-js/mdx";
|
|
56
|
+
import { PDFDocument } from "pdf-lib";
|
|
57
|
+
import type { Page } from "playwright";
|
|
58
|
+
import { chromium } from "playwright";
|
|
59
|
+
import remarkFrontmatter from "remark-frontmatter";
|
|
60
|
+
import { remarkH1Extract } from "#remark/h1-extract.ts";
|
|
61
|
+
import { remarkShikiCodeBlocks } from "#remark/shiki-code-blocks.ts";
|
|
62
|
+
import { remarkStepNumbering } from "#remark/step-numbering.ts";
|
|
63
|
+
import { loadDeck } from "#vite-plugin/deck-loader.ts";
|
|
64
|
+
import { parseAspectRatio } from "../runtime/aspectRatio.ts";
|
|
65
|
+
import { hasHelpFlag } from "./args.ts";
|
|
66
|
+
import { formatCommandBanner } from "./banner.ts";
|
|
67
|
+
import { buildPresentation } from "./build.ts";
|
|
68
|
+
import {
|
|
69
|
+
type DeckPathOptions,
|
|
70
|
+
rejectRootOption,
|
|
71
|
+
resolveDeckPath,
|
|
72
|
+
validateDeckPath,
|
|
73
|
+
} from "./deck-path.ts";
|
|
74
|
+
|
|
75
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
76
|
+
const __dirname = dirname(__filename);
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Constants
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Step index used for "final" mode โ larger than any real step count so all
|
|
84
|
+
* `<Reveal>` nodes are visible.
|
|
85
|
+
*/
|
|
86
|
+
const FINAL_STEP_INDEX = 999;
|
|
87
|
+
const MAX_PDF_CAPTURE_CONCURRENCY = 16;
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Arg parsing
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
export type PdfOptions = DeckPathOptions & {
|
|
94
|
+
output: string;
|
|
95
|
+
/** Explicit CLI --steps override. `null` = read from frontmatter/default. */
|
|
96
|
+
steps: "final" | "all" | null;
|
|
97
|
+
/** Explicit CLI --mode override. `null` = read from frontmatter. */
|
|
98
|
+
mode: "light" | "dark" | null;
|
|
99
|
+
/** Number of Playwright pages used to capture slide screenshots. */
|
|
100
|
+
parallel: number;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export function printPdfHelp(): void {
|
|
104
|
+
console.log(`
|
|
105
|
+
โจ honeydeck pdf โ export slides to a PDF file
|
|
106
|
+
|
|
107
|
+
Usage:
|
|
108
|
+
honeydeck pdf [options]
|
|
109
|
+
|
|
110
|
+
Options:
|
|
111
|
+
--deck <file.mdx> Deck entry file (default: ./deck.mdx)
|
|
112
|
+
-o, --output <file> Output file path (default: deck.pdf)
|
|
113
|
+
--steps <mode> 'final' or 'all' (default: final)
|
|
114
|
+
--mode <mode> 'light' or 'dark' (default: from frontmatter)
|
|
115
|
+
--parallel <count> Parallel captures, 1-16 (default: CPU count, max 16)
|
|
116
|
+
-h, --help Show this help page
|
|
117
|
+
|
|
118
|
+
Examples:
|
|
119
|
+
honeydeck pdf
|
|
120
|
+
honeydeck pdf -o my-talk.pdf
|
|
121
|
+
honeydeck pdf --steps all --parallel 6 --mode dark --deck talk.mdx
|
|
122
|
+
`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function readOptionValue(
|
|
126
|
+
args: string[],
|
|
127
|
+
index: number,
|
|
128
|
+
option: string,
|
|
129
|
+
): string {
|
|
130
|
+
const value = args[index + 1];
|
|
131
|
+
if (!value || value.startsWith("-")) {
|
|
132
|
+
console.error(`โ Missing value for ${option}`);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function parsePdfArgs(args: string[]): PdfOptions {
|
|
139
|
+
let deckPath: string | null = null;
|
|
140
|
+
let output = "deck.pdf";
|
|
141
|
+
let steps: "final" | "all" | null = null;
|
|
142
|
+
let mode: "light" | "dark" | null = null;
|
|
143
|
+
let parallel = resolveDefaultPdfCaptureConcurrency();
|
|
144
|
+
|
|
145
|
+
for (let i = 0; i < args.length; i++) {
|
|
146
|
+
const arg = args[i];
|
|
147
|
+
|
|
148
|
+
if (arg === "--deck") {
|
|
149
|
+
const value = readOptionValue(args, i, arg);
|
|
150
|
+
validateDeckPath(value, arg);
|
|
151
|
+
deckPath = value;
|
|
152
|
+
i++;
|
|
153
|
+
} else if (arg === "--root") {
|
|
154
|
+
rejectRootOption();
|
|
155
|
+
} else if (arg === "-o" || arg === "--output") {
|
|
156
|
+
const value = readOptionValue(args, i, arg);
|
|
157
|
+
output = value;
|
|
158
|
+
i++;
|
|
159
|
+
} else if (arg === "--steps") {
|
|
160
|
+
const value = readOptionValue(args, i, arg);
|
|
161
|
+
if (value === "all") {
|
|
162
|
+
steps = "all";
|
|
163
|
+
} else if (value === "final") {
|
|
164
|
+
steps = "final";
|
|
165
|
+
} else {
|
|
166
|
+
console.error(
|
|
167
|
+
`โ Unknown --steps value "${value}". Use "final" or "all".`,
|
|
168
|
+
);
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
i++;
|
|
172
|
+
} else if (arg === "--mode") {
|
|
173
|
+
const value = readOptionValue(args, i, arg);
|
|
174
|
+
if (value === "light" || value === "dark") {
|
|
175
|
+
mode = value;
|
|
176
|
+
} else {
|
|
177
|
+
console.error(
|
|
178
|
+
`โ Unknown --mode value "${value}". Use "light" or "dark".`,
|
|
179
|
+
);
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
i++;
|
|
183
|
+
} else if (arg === "--parallel") {
|
|
184
|
+
const value = readOptionValue(args, i, arg);
|
|
185
|
+
parallel = parsePdfCaptureConcurrency(value, arg);
|
|
186
|
+
i++;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
...resolveDeckPath(deckPath ?? undefined),
|
|
192
|
+
output,
|
|
193
|
+
steps,
|
|
194
|
+
mode,
|
|
195
|
+
parallel,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function parsePdfCaptureConcurrency(value: string, option: string): number {
|
|
200
|
+
if (!/^\d+$/.test(value)) {
|
|
201
|
+
console.error(
|
|
202
|
+
`โ Unknown ${option} value "${value}". Use an integer from 1 to ${MAX_PDF_CAPTURE_CONCURRENCY}.`,
|
|
203
|
+
);
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const parsed = Number(value);
|
|
208
|
+
if (parsed < 1 || parsed > MAX_PDF_CAPTURE_CONCURRENCY) {
|
|
209
|
+
console.error(
|
|
210
|
+
`โ Unknown ${option} value "${value}". Use an integer from 1 to ${MAX_PDF_CAPTURE_CONCURRENCY}.`,
|
|
211
|
+
);
|
|
212
|
+
process.exit(1);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return parsed;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// Static HTTP server
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
const MIME_TYPES: Record<string, string> = {
|
|
223
|
+
".html": "text/html; charset=utf-8",
|
|
224
|
+
".js": "application/javascript",
|
|
225
|
+
".mjs": "application/javascript",
|
|
226
|
+
".css": "text/css",
|
|
227
|
+
".png": "image/png",
|
|
228
|
+
".jpg": "image/jpeg",
|
|
229
|
+
".jpeg": "image/jpeg",
|
|
230
|
+
".svg": "image/svg+xml",
|
|
231
|
+
".ico": "image/x-icon",
|
|
232
|
+
".json": "application/json",
|
|
233
|
+
".woff": "font/woff",
|
|
234
|
+
".woff2": "font/woff2",
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
type StaticServer = {
|
|
238
|
+
url: string;
|
|
239
|
+
close: () => Promise<void>;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
function isPathInside(root: string, candidate: string): boolean {
|
|
243
|
+
const relativePath = relative(root, candidate);
|
|
244
|
+
return (
|
|
245
|
+
relativePath === "" ||
|
|
246
|
+
(!relativePath.startsWith("..") && !isAbsolute(relativePath))
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function resolveStaticFilePath(
|
|
251
|
+
dir: string,
|
|
252
|
+
requestUrl: string | undefined,
|
|
253
|
+
): string | null {
|
|
254
|
+
const root = resolve(dir);
|
|
255
|
+
const [rawPath = "/"] = (requestUrl ?? "/").split("?");
|
|
256
|
+
|
|
257
|
+
let decodedPath: string;
|
|
258
|
+
try {
|
|
259
|
+
decodedPath = decodeURIComponent(rawPath);
|
|
260
|
+
} catch {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (decodedPath.includes("\0")) return null;
|
|
265
|
+
|
|
266
|
+
const requestedFile =
|
|
267
|
+
decodedPath === "/" ? "index.html" : decodedPath.replace(/^\/+/, "");
|
|
268
|
+
let filePath = resolve(root, requestedFile);
|
|
269
|
+
|
|
270
|
+
if (!isPathInside(root, filePath)) return null;
|
|
271
|
+
|
|
272
|
+
// SPA fallback: if the resolved path doesn't exist or has no extension
|
|
273
|
+
// (looks like a route rather than an asset), serve index.html instead.
|
|
274
|
+
if (!existsSync(filePath) || extname(filePath) === "") {
|
|
275
|
+
filePath = join(root, "index.html");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return filePath;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Start a minimal static file server for the built SPA.
|
|
283
|
+
* Binds to 127.0.0.1 on a random OS-assigned port (port 0).
|
|
284
|
+
* Falls back to serving index.html for any path not found on disk
|
|
285
|
+
* (SPA hash-routing: all routes are the same HTML entry).
|
|
286
|
+
*/
|
|
287
|
+
function startStaticServer(dir: string): Promise<StaticServer> {
|
|
288
|
+
return new Promise((resolvePromise, reject) => {
|
|
289
|
+
const handler = (req: IncomingMessage, res: ServerResponse): void => {
|
|
290
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
291
|
+
res.writeHead(405, { "Content-Type": "text/plain" });
|
|
292
|
+
res.end("Method not allowed");
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const filePath = resolveStaticFilePath(dir, req.url);
|
|
297
|
+
if (!filePath) {
|
|
298
|
+
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
299
|
+
res.end("Forbidden");
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
const content = readFileSync(filePath);
|
|
305
|
+
const mime =
|
|
306
|
+
MIME_TYPES[extname(filePath).toLowerCase()] ??
|
|
307
|
+
"application/octet-stream";
|
|
308
|
+
res.writeHead(200, { "Content-Type": mime });
|
|
309
|
+
res.end(req.method === "HEAD" ? undefined : content);
|
|
310
|
+
} catch {
|
|
311
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
312
|
+
res.end("Not found");
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const server = createServer(handler);
|
|
317
|
+
|
|
318
|
+
server.listen(0, "127.0.0.1", () => {
|
|
319
|
+
const addr = server.address() as { port: number };
|
|
320
|
+
resolvePromise({
|
|
321
|
+
url: `http://127.0.0.1:${addr.port}`,
|
|
322
|
+
close: () =>
|
|
323
|
+
new Promise<void>((r, e) =>
|
|
324
|
+
server.close((err) => (err ? e(err) : r())),
|
|
325
|
+
),
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
server.on("error", reject);
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
// Step count computation
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Pre-compile each slide to extract the `stepCount` injected by
|
|
339
|
+
* `remarkStepNumbering`. Used only for `--steps all` mode.
|
|
340
|
+
*
|
|
341
|
+
* Mirrors the compilation done inside the virtual modules plugin so the
|
|
342
|
+
* counts are identical to what the runtime reports.
|
|
343
|
+
*/
|
|
344
|
+
async function getStepCounts(entryPath: string): Promise<number[]> {
|
|
345
|
+
const { slides } = loadDeck(entryPath);
|
|
346
|
+
|
|
347
|
+
const counts: number[] = [];
|
|
348
|
+
|
|
349
|
+
for (const slide of slides) {
|
|
350
|
+
try {
|
|
351
|
+
const vfile = await compile(slide.rawMdx, {
|
|
352
|
+
remarkPlugins: [
|
|
353
|
+
remarkFrontmatter,
|
|
354
|
+
remarkH1Extract,
|
|
355
|
+
remarkStepNumbering,
|
|
356
|
+
remarkShikiCodeBlocks,
|
|
357
|
+
],
|
|
358
|
+
jsxImportSource: "react",
|
|
359
|
+
outputFormat: "program",
|
|
360
|
+
});
|
|
361
|
+
counts.push((vfile.data.stepCount as number | undefined) ?? 0);
|
|
362
|
+
} catch {
|
|
363
|
+
// Compilation error for an individual slide โ assume 0 steps.
|
|
364
|
+
counts.push(0);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return counts;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
// Color mode resolution
|
|
373
|
+
// ---------------------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Resolve the effective PDF color mode in priority order:
|
|
377
|
+
* CLI `--mode` flag > `pdfColorMode` frontmatter > `colorMode` (if pinned) > `light`
|
|
378
|
+
*/
|
|
379
|
+
export function resolveColorMode(
|
|
380
|
+
cliMode: "light" | "dark" | null,
|
|
381
|
+
deckFrontmatter: Record<string, unknown>,
|
|
382
|
+
): "light" | "dark" {
|
|
383
|
+
if (cliMode) return cliMode;
|
|
384
|
+
|
|
385
|
+
const pdfMode = deckFrontmatter.pdfColorMode;
|
|
386
|
+
if (pdfMode === "light" || pdfMode === "dark") return pdfMode;
|
|
387
|
+
|
|
388
|
+
const colorMode = deckFrontmatter.colorMode;
|
|
389
|
+
if (colorMode === "light" || colorMode === "dark") return colorMode;
|
|
390
|
+
|
|
391
|
+
return "light";
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Resolve exported steps in priority order:
|
|
396
|
+
* CLI `--steps` flag > `pdfSteps` frontmatter > `final`
|
|
397
|
+
*/
|
|
398
|
+
export function resolvePdfSteps(
|
|
399
|
+
cliSteps: "final" | "all" | null,
|
|
400
|
+
deckFrontmatter: Record<string, unknown>,
|
|
401
|
+
): "final" | "all" {
|
|
402
|
+
if (cliSteps) return cliSteps;
|
|
403
|
+
|
|
404
|
+
const pdfSteps = deckFrontmatter.pdfSteps;
|
|
405
|
+
if (pdfSteps === "all" || pdfSteps === "final") return pdfSteps;
|
|
406
|
+
|
|
407
|
+
return "final";
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
// Capture target planning
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
export type PdfCaptureTarget = {
|
|
415
|
+
pageIndex: number;
|
|
416
|
+
slide: number;
|
|
417
|
+
step: number;
|
|
418
|
+
slideStepCount: number;
|
|
419
|
+
isPdfFinalRender: boolean;
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
export function buildPdfCaptureTargets(
|
|
423
|
+
slideCount: number,
|
|
424
|
+
stepCounts: number[],
|
|
425
|
+
steps: "final" | "all",
|
|
426
|
+
): PdfCaptureTarget[] {
|
|
427
|
+
const targets: PdfCaptureTarget[] = [];
|
|
428
|
+
|
|
429
|
+
for (let slideIndex = 0; slideIndex < slideCount; slideIndex++) {
|
|
430
|
+
const slide = slideIndex + 1;
|
|
431
|
+
const slideStepCount = stepCounts[slideIndex] ?? 0;
|
|
432
|
+
|
|
433
|
+
if (steps === "all") {
|
|
434
|
+
for (let step = 0; step <= slideStepCount; step++) {
|
|
435
|
+
targets.push({
|
|
436
|
+
pageIndex: targets.length,
|
|
437
|
+
slide,
|
|
438
|
+
step,
|
|
439
|
+
slideStepCount,
|
|
440
|
+
isPdfFinalRender: false,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
} else {
|
|
444
|
+
targets.push({
|
|
445
|
+
pageIndex: targets.length,
|
|
446
|
+
slide,
|
|
447
|
+
step: FINAL_STEP_INDEX,
|
|
448
|
+
slideStepCount,
|
|
449
|
+
isPdfFinalRender: true,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return targets;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export function resolvePdfCaptureConcurrency(
|
|
458
|
+
requestedParallelism: number,
|
|
459
|
+
targetCount: number,
|
|
460
|
+
): number {
|
|
461
|
+
if (targetCount <= 0) return 0;
|
|
462
|
+
return Math.min(requestedParallelism, targetCount);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export function resolveDefaultPdfCaptureConcurrency(
|
|
466
|
+
cpuCount = availableParallelism(),
|
|
467
|
+
): number {
|
|
468
|
+
return Math.min(
|
|
469
|
+
Math.max(1, Math.floor(cpuCount)),
|
|
470
|
+
MAX_PDF_CAPTURE_CONCURRENCY,
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ---------------------------------------------------------------------------
|
|
475
|
+
// Screenshot helpers
|
|
476
|
+
// ---------------------------------------------------------------------------
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Navigate the Playwright page to a specific slide+step and return a PNG
|
|
480
|
+
* screenshot buffer.
|
|
481
|
+
*
|
|
482
|
+
* Strategy:
|
|
483
|
+
* - `firstLoad = true` โ full `page.goto()` that boots the React SPA.
|
|
484
|
+
* - `firstLoad = false` โ hash change only; avoids a full page reload and is
|
|
485
|
+
* much faster when iterating over many slides/steps.
|
|
486
|
+
*
|
|
487
|
+
* After each navigation the color mode attribute is forced so it overrides
|
|
488
|
+
* any system preference or in-app state from the previous slide.
|
|
489
|
+
*/
|
|
490
|
+
async function captureSlide(
|
|
491
|
+
page: Page,
|
|
492
|
+
serverUrl: string,
|
|
493
|
+
slide: number,
|
|
494
|
+
step: number,
|
|
495
|
+
colorMode: "light" | "dark",
|
|
496
|
+
isPdfFinalRender: boolean,
|
|
497
|
+
firstLoad: boolean,
|
|
498
|
+
getPageError: () => Error | null,
|
|
499
|
+
): Promise<Buffer> {
|
|
500
|
+
if (firstLoad) {
|
|
501
|
+
const pdfRenderQuery = isPdfFinalRender ? "?honeydeckPdfRender=final" : "";
|
|
502
|
+
|
|
503
|
+
// Full navigation: boots the SPA and waits for network to settle.
|
|
504
|
+
await page.goto(`${serverUrl}/${pdfRenderQuery}#/${slide}/${step}`, {
|
|
505
|
+
waitUntil: "networkidle",
|
|
506
|
+
timeout: 30_000,
|
|
507
|
+
});
|
|
508
|
+
// Ensure the slide canvas element exists before we continue.
|
|
509
|
+
await waitForSlideCanvas(page, getPageError);
|
|
510
|
+
} else {
|
|
511
|
+
// Hash-only navigation: React router picks it up via the hashchange event.
|
|
512
|
+
await page.evaluate((hash: string) => {
|
|
513
|
+
window.location.hash = hash;
|
|
514
|
+
}, `#/${slide}/${step}`);
|
|
515
|
+
// Wait for React to finish re-rendering (opacity transition + reveals).
|
|
516
|
+
await page.waitForTimeout(500);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Force the color mode attribute regardless of system preference.
|
|
520
|
+
await page.evaluate((mode: string) => {
|
|
521
|
+
document.documentElement.setAttribute("data-honeydeck-color-mode", mode);
|
|
522
|
+
}, colorMode);
|
|
523
|
+
|
|
524
|
+
await waitForPdfColorMode(page, colorMode, getPageError);
|
|
525
|
+
|
|
526
|
+
// Brief pause for any CSS color-mode transitions to settle.
|
|
527
|
+
await page.waitForTimeout(150);
|
|
528
|
+
|
|
529
|
+
const png = await page.screenshot({ type: "png" });
|
|
530
|
+
return Buffer.from(png);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async function prepareCapturePage(
|
|
534
|
+
page: Page,
|
|
535
|
+
serverUrl: string,
|
|
536
|
+
colorMode: "light" | "dark",
|
|
537
|
+
isPdfFinalRender: boolean,
|
|
538
|
+
getPageError: () => Error | null,
|
|
539
|
+
): Promise<void> {
|
|
540
|
+
const pdfRenderQuery = isPdfFinalRender ? "?honeydeckPdfRender=final" : "";
|
|
541
|
+
|
|
542
|
+
await page.goto(`${serverUrl}/${pdfRenderQuery}#/1/0`, {
|
|
543
|
+
waitUntil: "networkidle",
|
|
544
|
+
timeout: 30_000,
|
|
545
|
+
});
|
|
546
|
+
await waitForSlideCanvas(page, getPageError);
|
|
547
|
+
|
|
548
|
+
await page.evaluate((mode: string) => {
|
|
549
|
+
document.documentElement.setAttribute("data-honeydeck-color-mode", mode);
|
|
550
|
+
}, colorMode);
|
|
551
|
+
|
|
552
|
+
await waitForPdfColorMode(page, colorMode, getPageError);
|
|
553
|
+
await page.waitForTimeout(150);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async function waitForPdfColorMode(
|
|
557
|
+
page: Page,
|
|
558
|
+
colorMode: "light" | "dark",
|
|
559
|
+
getPageError: () => Error | null,
|
|
560
|
+
): Promise<void> {
|
|
561
|
+
await waitForBrowserWork(
|
|
562
|
+
page.waitForFunction(
|
|
563
|
+
(mode) => {
|
|
564
|
+
if (
|
|
565
|
+
document.documentElement.getAttribute("data-honeydeck-color-mode") !==
|
|
566
|
+
mode
|
|
567
|
+
) {
|
|
568
|
+
return false;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return true;
|
|
572
|
+
},
|
|
573
|
+
colorMode,
|
|
574
|
+
{ timeout: 5_000 },
|
|
575
|
+
),
|
|
576
|
+
getPageError,
|
|
577
|
+
5_000,
|
|
578
|
+
async () =>
|
|
579
|
+
new Error(
|
|
580
|
+
`Timed out waiting for PDF color mode "${colorMode}" to apply.`,
|
|
581
|
+
),
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
async function waitForBrowserWork<T>(
|
|
586
|
+
work: Promise<T>,
|
|
587
|
+
getPageError: () => Error | null,
|
|
588
|
+
timeoutMs: number,
|
|
589
|
+
makeTimeoutError: () => Promise<Error>,
|
|
590
|
+
): Promise<T> {
|
|
591
|
+
return new Promise<T>((resolve, reject) => {
|
|
592
|
+
let settled = false;
|
|
593
|
+
let pageErrorInterval: ReturnType<typeof setInterval>;
|
|
594
|
+
let timeout: ReturnType<typeof setTimeout>;
|
|
595
|
+
|
|
596
|
+
const finish = (callback: () => void) => {
|
|
597
|
+
if (settled) return;
|
|
598
|
+
settled = true;
|
|
599
|
+
clearInterval(pageErrorInterval);
|
|
600
|
+
clearTimeout(timeout);
|
|
601
|
+
callback();
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
pageErrorInterval = setInterval(() => {
|
|
605
|
+
const pageError = getPageError();
|
|
606
|
+
if (!pageError) return;
|
|
607
|
+
finish(() => {
|
|
608
|
+
reject(
|
|
609
|
+
new Error(
|
|
610
|
+
`Browser failed while rendering the deck:\n${formatPageError(pageError)}`,
|
|
611
|
+
),
|
|
612
|
+
);
|
|
613
|
+
});
|
|
614
|
+
}, 100);
|
|
615
|
+
|
|
616
|
+
timeout = setTimeout(() => {
|
|
617
|
+
void makeTimeoutError().then(
|
|
618
|
+
(error) => finish(() => reject(error)),
|
|
619
|
+
(error: unknown) => finish(() => reject(error)),
|
|
620
|
+
);
|
|
621
|
+
}, timeoutMs);
|
|
622
|
+
|
|
623
|
+
work.then(
|
|
624
|
+
(value) => finish(() => resolve(value)),
|
|
625
|
+
(error: unknown) => finish(() => reject(error)),
|
|
626
|
+
);
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async function waitForSlideCanvas(
|
|
631
|
+
page: Page,
|
|
632
|
+
getPageError: () => Error | null,
|
|
633
|
+
): Promise<void> {
|
|
634
|
+
const timeoutMs = 15_000;
|
|
635
|
+
const startedAt = Date.now();
|
|
636
|
+
|
|
637
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
638
|
+
const pageError = getPageError();
|
|
639
|
+
if (pageError) {
|
|
640
|
+
throw new Error(
|
|
641
|
+
`Browser failed while rendering the deck:\n${formatPageError(pageError)}`,
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const canvas = await page.$(".honeydeck-slide-canvas");
|
|
646
|
+
if (canvas && (await canvas.isVisible())) return;
|
|
647
|
+
|
|
648
|
+
await page.waitForTimeout(100);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const pageError = getPageError();
|
|
652
|
+
if (pageError) {
|
|
653
|
+
throw new Error(
|
|
654
|
+
`Browser failed while rendering the deck:\n${formatPageError(pageError)}`,
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
throw new Error(
|
|
659
|
+
"Timed out waiting for .honeydeck-slide-canvas to become visible.",
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function formatPageError(error: Error): string {
|
|
664
|
+
return error.stack || error.message;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function formatCaptureProgress(
|
|
668
|
+
target: PdfCaptureTarget,
|
|
669
|
+
targetCount: number,
|
|
670
|
+
slideCount: number,
|
|
671
|
+
steps: "final" | "all",
|
|
672
|
+
): string {
|
|
673
|
+
const pageProgress = `${target.pageIndex + 1}/${targetCount}`;
|
|
674
|
+
|
|
675
|
+
if (steps === "all") {
|
|
676
|
+
return (
|
|
677
|
+
` ๐ Rendering page ${pageProgress} ` +
|
|
678
|
+
`(slide ${target.slide}/${slideCount}, step ${target.step}/${target.slideStepCount})โฆ\n`
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
return (
|
|
683
|
+
` ๐ Rendering page ${pageProgress} ` +
|
|
684
|
+
`(slide ${target.slide}/${slideCount})โฆ\n`
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// ---------------------------------------------------------------------------
|
|
689
|
+
// Public API
|
|
690
|
+
// ---------------------------------------------------------------------------
|
|
691
|
+
|
|
692
|
+
export async function runPdf(args: string[]): Promise<void> {
|
|
693
|
+
if (hasHelpFlag(args)) {
|
|
694
|
+
printPdfHelp();
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const {
|
|
699
|
+
root,
|
|
700
|
+
entry,
|
|
701
|
+
deck,
|
|
702
|
+
output,
|
|
703
|
+
steps: cliSteps,
|
|
704
|
+
mode: cliMode,
|
|
705
|
+
parallel,
|
|
706
|
+
} = parsePdfArgs(args);
|
|
707
|
+
// Resolve output path against cwd so relative paths like "deck.pdf" work.
|
|
708
|
+
const outputPath = resolve(process.cwd(), output);
|
|
709
|
+
|
|
710
|
+
console.log(`\n${formatCommandBanner()}\n`);
|
|
711
|
+
console.log(" ๐จ๏ธ Exporting PDFโฆ\n");
|
|
712
|
+
|
|
713
|
+
// โโ Read deck entry โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
714
|
+
let deckFrontmatter: Record<string, unknown> = {};
|
|
715
|
+
let slideCount = 0;
|
|
716
|
+
|
|
717
|
+
try {
|
|
718
|
+
const { slides, deckFrontmatter: fm } = loadDeck(deck);
|
|
719
|
+
deckFrontmatter = fm;
|
|
720
|
+
slideCount = slides.length;
|
|
721
|
+
} catch (err) {
|
|
722
|
+
console.error(`\n โ Cannot read ${deck}`);
|
|
723
|
+
console.error(err);
|
|
724
|
+
process.exit(1);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
if (slideCount === 0) {
|
|
728
|
+
console.error(`\n โ No slides found in ${deck}`);
|
|
729
|
+
process.exit(1);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const colorMode = resolveColorMode(cliMode, deckFrontmatter);
|
|
733
|
+
const steps = resolvePdfSteps(cliSteps, deckFrontmatter);
|
|
734
|
+
const pdfDimensions = parseAspectRatio(deckFrontmatter.aspectRatio);
|
|
735
|
+
|
|
736
|
+
// โโ Step counts (only needed for --steps all) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
737
|
+
let stepCounts: number[] = Array<number>(slideCount).fill(0);
|
|
738
|
+
if (steps === "all") {
|
|
739
|
+
process.stdout.write(" ๐ข Computing step countsโฆ\n");
|
|
740
|
+
stepCounts = await getStepCounts(deck);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// โโ Build to temp directory โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
744
|
+
const tempDir = mkdtempSync(join(tmpdir(), "honeydeck-pdf-"));
|
|
745
|
+
|
|
746
|
+
try {
|
|
747
|
+
process.stdout.write(" ๐๏ธ Building presentationโฆ\n");
|
|
748
|
+
|
|
749
|
+
await buildPresentation({
|
|
750
|
+
userRoot: root,
|
|
751
|
+
entry,
|
|
752
|
+
outDir: tempDir,
|
|
753
|
+
logLevel: "error", // Suppress Vite output during PDF sub-build
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
// โโ Static server โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
757
|
+
const server = await startStaticServer(tempDir);
|
|
758
|
+
|
|
759
|
+
try {
|
|
760
|
+
// โโ Playwright โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
761
|
+
let browser: Awaited<ReturnType<typeof chromium.launch>>;
|
|
762
|
+
|
|
763
|
+
try {
|
|
764
|
+
browser = await chromium.launch({ headless: true });
|
|
765
|
+
} catch (err) {
|
|
766
|
+
console.error("\n โ Failed to launch Chromium.");
|
|
767
|
+
console.error(" Make sure Playwright browsers are installed:");
|
|
768
|
+
console.error(" npx playwright install chromium\n");
|
|
769
|
+
throw err;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
try {
|
|
773
|
+
const colorModeInitScript = `
|
|
774
|
+
(() => {
|
|
775
|
+
const mode = ${JSON.stringify(colorMode)};
|
|
776
|
+
const applyMode = () => {
|
|
777
|
+
if (document.documentElement) {
|
|
778
|
+
document.documentElement.setAttribute('data-honeydeck-color-mode', mode);
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
applyMode();
|
|
783
|
+
|
|
784
|
+
if (!document.documentElement) {
|
|
785
|
+
const observer = new MutationObserver(() => {
|
|
786
|
+
if (!document.documentElement) return;
|
|
787
|
+
applyMode();
|
|
788
|
+
observer.disconnect();
|
|
789
|
+
});
|
|
790
|
+
observer.observe(document, { childList: true });
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
document.addEventListener('DOMContentLoaded', applyMode, { once: true });
|
|
794
|
+
})();
|
|
795
|
+
`;
|
|
796
|
+
|
|
797
|
+
const targets = buildPdfCaptureTargets(slideCount, stepCounts, steps);
|
|
798
|
+
const captureConcurrency = resolvePdfCaptureConcurrency(
|
|
799
|
+
parallel,
|
|
800
|
+
targets.length,
|
|
801
|
+
);
|
|
802
|
+
const screenshots: Buffer[] = Array<Buffer>(targets.length);
|
|
803
|
+
let nextTargetIndex = 0;
|
|
804
|
+
|
|
805
|
+
process.stdout.write("\n");
|
|
806
|
+
|
|
807
|
+
process.stdout.write(
|
|
808
|
+
` ๐งต Capturing ${targets.length} page${targets.length !== 1 ? "s" : ""} with ${captureConcurrency} worker${captureConcurrency !== 1 ? "s" : ""}โฆ\n\n`,
|
|
809
|
+
);
|
|
810
|
+
|
|
811
|
+
await Promise.all(
|
|
812
|
+
Array.from({ length: captureConcurrency }, async () => {
|
|
813
|
+
const context = await browser.newContext({
|
|
814
|
+
// PDF page follows deck aspect ratio. Runtime slide canvas uses the
|
|
815
|
+
// same 1920px base width and derived height, so screenshots fill the
|
|
816
|
+
// exported page without letterbox/pillarbox space.
|
|
817
|
+
viewport: pdfDimensions,
|
|
818
|
+
// Make `colorMode: system` resolve to the same mode that PDF export
|
|
819
|
+
// will force later.
|
|
820
|
+
colorScheme: colorMode,
|
|
821
|
+
});
|
|
822
|
+
await context.addInitScript({ content: colorModeInitScript });
|
|
823
|
+
let page: Page | null = null;
|
|
824
|
+
let firstPageError: Error | null = null;
|
|
825
|
+
|
|
826
|
+
try {
|
|
827
|
+
page = await context.newPage();
|
|
828
|
+
page.on("pageerror", (error) => {
|
|
829
|
+
firstPageError ??= error;
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
await prepareCapturePage(
|
|
833
|
+
page,
|
|
834
|
+
server.url,
|
|
835
|
+
colorMode,
|
|
836
|
+
steps === "final",
|
|
837
|
+
() => firstPageError,
|
|
838
|
+
);
|
|
839
|
+
|
|
840
|
+
while (true) {
|
|
841
|
+
const target = targets[nextTargetIndex++];
|
|
842
|
+
if (!target) break;
|
|
843
|
+
|
|
844
|
+
process.stdout.write(
|
|
845
|
+
formatCaptureProgress(
|
|
846
|
+
target,
|
|
847
|
+
targets.length,
|
|
848
|
+
slideCount,
|
|
849
|
+
steps,
|
|
850
|
+
),
|
|
851
|
+
);
|
|
852
|
+
|
|
853
|
+
const screenshot = await captureSlide(
|
|
854
|
+
page,
|
|
855
|
+
server.url,
|
|
856
|
+
target.slide,
|
|
857
|
+
target.step,
|
|
858
|
+
colorMode,
|
|
859
|
+
target.isPdfFinalRender,
|
|
860
|
+
false,
|
|
861
|
+
() => firstPageError,
|
|
862
|
+
);
|
|
863
|
+
|
|
864
|
+
screenshots[target.pageIndex] = screenshot;
|
|
865
|
+
}
|
|
866
|
+
} finally {
|
|
867
|
+
await page?.close();
|
|
868
|
+
await context.close();
|
|
869
|
+
}
|
|
870
|
+
}),
|
|
871
|
+
);
|
|
872
|
+
|
|
873
|
+
// โโ Assemble PDF โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
874
|
+
process.stdout.write("\n ๐ Assembling PDFโฆ\n");
|
|
875
|
+
|
|
876
|
+
const pdfDoc = await PDFDocument.create();
|
|
877
|
+
|
|
878
|
+
for (const target of targets) {
|
|
879
|
+
const screenshot = screenshots[target.pageIndex];
|
|
880
|
+
if (!screenshot) {
|
|
881
|
+
throw new Error(
|
|
882
|
+
`Missing screenshot for PDF page ${target.pageIndex + 1}.`,
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// pdf-lib uses PDF points as units; 1920ร1080 points = 26.67"ร15"
|
|
887
|
+
// for default 16:9 decks. Other aspect ratios keep the same 1920pt
|
|
888
|
+
// base width and use the deck-derived height.
|
|
889
|
+
const pngImage = await pdfDoc.embedPng(screenshot);
|
|
890
|
+
const pdfPage = pdfDoc.addPage([
|
|
891
|
+
pdfDimensions.width,
|
|
892
|
+
pdfDimensions.height,
|
|
893
|
+
]);
|
|
894
|
+
pdfPage.drawImage(pngImage, {
|
|
895
|
+
x: 0,
|
|
896
|
+
y: 0,
|
|
897
|
+
width: pdfDimensions.width,
|
|
898
|
+
height: pdfDimensions.height,
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const pdfBytes = await pdfDoc.save();
|
|
903
|
+
writeFileSync(outputPath, pdfBytes);
|
|
904
|
+
|
|
905
|
+
const pageCount = screenshots.length;
|
|
906
|
+
console.log(
|
|
907
|
+
`\n โ
Done! ${output} (${pageCount} page${pageCount !== 1 ? "s" : ""})\n`,
|
|
908
|
+
);
|
|
909
|
+
} finally {
|
|
910
|
+
await browser.close();
|
|
911
|
+
}
|
|
912
|
+
} finally {
|
|
913
|
+
await server.close();
|
|
914
|
+
}
|
|
915
|
+
} finally {
|
|
916
|
+
// Always remove the temp build directory โ even on error.
|
|
917
|
+
try {
|
|
918
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
919
|
+
} catch {
|
|
920
|
+
// Non-fatal: OS will clean up eventually.
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|