diff --git a/docs/guide/exporting.md b/docs/guide/exporting.md index 6e27dcfcda..750891d8ad 100644 --- a/docs/guide/exporting.md +++ b/docs/guide/exporting.md @@ -199,6 +199,16 @@ You can generate the PDF outline by passing the `--with-toc` option: $ slidev export --with-toc ``` +### Overview + +You can export the overview page (slides with presenter notes side by side) as an A4 portrait PDF by passing the `--overview` option: + +```bash +$ slidev export --overview +``` + +This is useful when you want a printable summary of your entire presentation including all speaker notes. Each slide is rendered alongside its notes, and slides are never split across page boundaries. + ### Omit Background When exporting to PNGs, you can remove the default browser background by passing `--omit-background`: diff --git a/packages/client/logic/utils.ts b/packages/client/logic/utils.ts index 313833dc0d..97db5ba780 100644 --- a/packages/client/logic/utils.ts +++ b/packages/client/logic/utils.ts @@ -1,5 +1,20 @@ import { parseRangeString } from '@slidev/parser/core' +export function wordCount(str: string) { + const pattern = /[\w`'\-\u0392-\u03C9\u00C0-\u00FF\u0600-\u06FF\u0400-\u04FF]+|[\u4E00-\u9FFF\u3400-\u4DBF\uF900-\uFAFF\u3040-\u309F\uAC00-\uD7AF]+/g + const m = str.match(pattern) + let count = 0 + if (!m) + return 0 + for (let i = 0; i < m.length; i++) { + if (m[i].charCodeAt(0) >= 0x4E00) + count += m[i].length + else + count += 1 + } + return count +} + export function makeId(length = 5) { const result = [] const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' diff --git a/packages/client/pages/overview.vue b/packages/client/pages/overview.vue index 2b4da0bae2..80abaedaea 100644 --- a/packages/client/pages/overview.vue +++ b/packages/client/pages/overview.vue @@ -14,6 +14,7 @@ import SlideContainer from '../internals/SlideContainer.vue' import SlideWrapper from '../internals/SlideWrapper.vue' import { isColorSchemaConfigured, isDark, toggleDark } from '../logic/dark' import { getSlidePath } from '../logic/slides' +import { wordCount } from '../logic/utils' const cardWidth = 450 @@ -48,23 +49,6 @@ function toggleRoute(route: SlideRoute) { activeSlide.value = route } -function wordCount(str: string) { - const pattern = /[\w`'\-\u0392-\u03C9\u00C0-\u00FF\u0600-\u06FF\u0400-\u04FF]+|[\u4E00-\u9FFF\u3400-\u4DBF\uF900-\uFAFF\u3040-\u309F\uAC00-\uD7AF]+/g - const m = str.match(pattern) - let count = 0 - if (!m) - return 0 - for (let i = 0; i < m.length; i++) { - if (m[i].charCodeAt(0) >= 0x4E00) { - count += m[i].length - } - else { - count += 1 - } - } - return count -} - function isElementInViewport(el: HTMLElement) { const rect = el.getBoundingClientRect() const delta = 20 diff --git a/packages/client/pages/overview/print.vue b/packages/client/pages/overview/print.vue new file mode 100644 index 0000000000..019ad8e0cf --- /dev/null +++ b/packages/client/pages/overview/print.vue @@ -0,0 +1,104 @@ + + + diff --git a/packages/client/setup/routes.ts b/packages/client/setup/routes.ts index d94ade0e4a..d310da3d64 100644 --- a/packages/client/setup/routes.ts +++ b/packages/client/setup/routes.ts @@ -71,6 +71,11 @@ export default function setupRoutes() { component: () => import('../pages/presenter/print.vue'), beforeEnter: passwordGuard, }, + { + path: '/overview/print', + component: () => import('../pages/overview/print.vue'), + beforeEnter: passwordGuard, + }, ) } diff --git a/packages/slidev/node/cli.ts b/packages/slidev/node/cli.ts index 08f4200482..8f87085935 100644 --- a/packages/slidev/node/cli.ts +++ b/packages/slidev/node/cli.ts @@ -457,7 +457,7 @@ cli.command( .help(), async (args) => { const { entry, theme } = args - const { exportSlides, getExportOptions } = await import('./commands/export') + const { exportSlides, exportOverview, getExportOptions } = await import('./commands/export') const candidatePort = await getPort(12445) let warned = false @@ -485,10 +485,26 @@ cli.command( await server.listen(candidatePort) const port = getViteServerPort(server) printInfo(options) - const result = await exportSlides({ - port, - ...getExportOptions({ ...args, entry: entryFile }, options), - }) + + let result: string + if (args.overview) { + const exportOpts = getExportOptions({ ...args, entry: entryFile }, options) + result = await exportOverview({ + port, + output: args.output || `${path.basename(entryFile, '.md')}-overview`, + timeout: exportOpts.timeout, + wait: exportOpts.wait, + dark: exportOpts.dark, + waitUntil: exportOpts.waitUntil, + executablePath: exportOpts.executablePath, + }) + } + else { + result = await exportSlides({ + port, + ...getExportOptions({ ...args, entry: entryFile }, options), + }) + } console.log(`${green(' ✓ ')}${dim('exported to ')}${result}\n`) } finally { @@ -649,6 +665,10 @@ function exportOptions(args: Argv) { type: 'boolean', describe: 'export png pages without the default browser background', }) + .option('overview', { + type: 'boolean', + describe: 'export the overview page (slides + notes) as a single PDF', + }) } function printInfo( diff --git a/packages/slidev/node/commands/export.ts b/packages/slidev/node/commands/export.ts index ad944185cf..55542512fc 100644 --- a/packages/slidev/node/commands/export.ts +++ b/packages/slidev/node/commands/export.ts @@ -162,6 +162,64 @@ export async function exportNotes({ return output } +export interface ExportOverviewOptions { + port?: number + base?: string + output?: string + timeout?: number + wait?: number + dark?: boolean + waitUntil?: 'networkidle' | 'load' | 'domcontentloaded' | undefined + executablePath?: string +} + +export async function exportOverview({ + port = 18724, + base = '/', + output = 'slides-overview', + timeout = 30000, + wait = 0, + dark = false, + waitUntil, + executablePath, +}: ExportOverviewOptions): Promise { + if (!output.endsWith('.pdf')) + output = `${output}.pdf` + + const { chromium } = await importPlaywright() + const browser = await chromium.launch({ executablePath }) + const context = await browser.newContext() + const page = await context.newPage() + + const progress = createSlidevProgress(true) + progress.start(1) + + await page.goto(`http://localhost:${port}${base}overview/print`, { waitUntil: waitUntil || 'networkidle', timeout }) + await page.waitForLoadState('networkidle') + await page.emulateMedia({ colorScheme: dark ? 'dark' : 'light', media: 'screen' }) + + if (wait) + await page.waitForTimeout(wait) + + await page.pdf({ + path: output, + margin: { + left: 0, + top: 0, + right: 0, + bottom: 0, + }, + printBackground: true, + preferCSSPageSize: true, + }) + + progress.stop() + browser.close() + + const relativeOutput = slash(relative('.', output)) + return relativeOutput.startsWith('.') ? relativeOutput : `./${relativeOutput}` +} + export async function exportSlides({ port = 18724, total = 0, diff --git a/packages/types/src/cli.ts b/packages/types/src/cli.ts index 2e2e98a75e..e93b301c77 100644 --- a/packages/types/src/cli.ts +++ b/packages/types/src/cli.ts @@ -17,6 +17,7 @@ export interface ExportArgs extends CommonArgs { 'per-slide'?: boolean 'scale'?: number 'omit-background'?: boolean + 'overview'?: boolean } export interface BuildArgs extends ExportArgs {