From 1863494ab4e60e84d78563a6c30f1667e390057e Mon Sep 17 00:00:00 2001 From: Oliver-Tobias Ripka Date: Sun, 7 Sep 2025 15:49:07 +0200 Subject: [PATCH 1/3] feat: handout --- demo/starter/handout-bottom.vue | 41 +++++ demo/starter/handout-cover.vue | 51 ++++++ packages/client/composables/useNav.ts | 2 +- packages/client/composables/usePrintStyles.ts | 6 +- .../internals/PrintContainerHandout.vue | 58 +++++++ packages/client/internals/PrintHandout.vue | 160 ++++++++++++++++++ packages/client/pages/cover/print.vue | 62 +++++++ packages/client/pages/handout/print.vue | 114 +++++++++++++ packages/client/setup/routes.ts | 21 +++ packages/slidev/node/cli.ts | 10 ++ packages/slidev/node/commands/export.ts | 101 ++++++++++- .../slidev/node/virtual/handout-components.ts | 34 ++++ packages/slidev/node/virtual/index.ts | 3 + packages/types/client.d.ts | 14 ++ packages/types/src/cli.ts | 2 + 15 files changed, 670 insertions(+), 9 deletions(-) create mode 100644 demo/starter/handout-bottom.vue create mode 100644 demo/starter/handout-cover.vue create mode 100644 packages/client/internals/PrintContainerHandout.vue create mode 100644 packages/client/internals/PrintHandout.vue create mode 100644 packages/client/pages/cover/print.vue create mode 100644 packages/client/pages/handout/print.vue create mode 100644 packages/slidev/node/virtual/handout-components.ts diff --git a/demo/starter/handout-bottom.vue b/demo/starter/handout-bottom.vue new file mode 100644 index 0000000000..74c358c632 --- /dev/null +++ b/demo/starter/handout-bottom.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/demo/starter/handout-cover.vue b/demo/starter/handout-cover.vue new file mode 100644 index 0000000000..3c5728d3e8 --- /dev/null +++ b/demo/starter/handout-cover.vue @@ -0,0 +1,51 @@ + diff --git a/packages/client/composables/useNav.ts b/packages/client/composables/useNav.ts index e1500c42a4..5f2400ebbd 100644 --- a/packages/client/composables/useNav.ts +++ b/packages/client/composables/useNav.ts @@ -280,7 +280,7 @@ const useNavState = createSharedComposable((): SlidevContextNavState => { router?.currentRoute?.value?.query return new URLSearchParams(location.search) }) - const isPrintMode = computed(() => query.value.has('print') || currentRoute.name === 'export') + const isPrintMode = computed(() => query.value.has('print') || currentRoute.name === 'export' || currentRoute.name === 'handout' || currentRoute.name === 'cover') const isPrintWithClicks = ref(query.value.get('print') === 'clicks') const isEmbedded = computed(() => query.value.has('embedded')) const isPlaying = computed(() => currentRoute.name === 'play') diff --git a/packages/client/composables/usePrintStyles.ts b/packages/client/composables/usePrintStyles.ts index eb05058801..a8693c8ee8 100644 --- a/packages/client/composables/usePrintStyles.ts +++ b/packages/client/composables/usePrintStyles.ts @@ -1,12 +1,16 @@ import { useStyleTag } from '@vueuse/core' import { computed } from 'vue' +import { useRoute } from 'vue-router' import { slideHeight, slideWidth } from '../env' import { useNav } from './useNav' export function usePrintStyles() { const { isPrintMode } = useNav() + const route = useRoute() - useStyleTag(computed(() => isPrintMode.value + // Only inject slide-sized @page for the default print/export view. + // Handout and cover have their own A4 page sizing and should not be overridden. + useStyleTag(computed(() => (isPrintMode.value && !['handout', 'cover'].includes((route.name as string) || '')) ? ` @page { size: ${slideWidth.value}px ${slideHeight.value}px; diff --git a/packages/client/internals/PrintContainerHandout.vue b/packages/client/internals/PrintContainerHandout.vue new file mode 100644 index 0000000000..a80cc78349 --- /dev/null +++ b/packages/client/internals/PrintContainerHandout.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/packages/client/internals/PrintHandout.vue b/packages/client/internals/PrintHandout.vue new file mode 100644 index 0000000000..c10932e866 --- /dev/null +++ b/packages/client/internals/PrintHandout.vue @@ -0,0 +1,160 @@ + + + + + diff --git a/packages/client/pages/cover/print.vue b/packages/client/pages/cover/print.vue new file mode 100644 index 0000000000..f41a17d22a --- /dev/null +++ b/packages/client/pages/cover/print.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/packages/client/pages/handout/print.vue b/packages/client/pages/handout/print.vue new file mode 100644 index 0000000000..e800f9ceb8 --- /dev/null +++ b/packages/client/pages/handout/print.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/packages/client/setup/routes.ts b/packages/client/setup/routes.ts index f7cc47f127..85f1e8dbb3 100644 --- a/packages/client/setup/routes.ts +++ b/packages/client/setup/routes.ts @@ -68,6 +68,7 @@ export default function setupRoutes() { ) } + // Enable the browser exporter UI only when configured if (__SLIDEV_FEATURE_BROWSER_EXPORTER__) { routes.push( { @@ -79,6 +80,26 @@ export default function setupRoutes() { ) } + // Handout/Cover print routes are needed for both the browser exporter + // and the CLI exporter (print mode). Make them available when either + // feature is enabled so Playwright can navigate to them during CLI export. + if (__SLIDEV_FEATURE_BROWSER_EXPORTER__ || __SLIDEV_FEATURE_PRINT__) { + routes.push( + { + name: 'handout', + path: '/handout', + component: () => import('../pages/handout/print.vue'), + beforeEnter: passwordGuard, + }, + { + name: 'cover', + path: '/cover', + component: () => import('../pages/cover/print.vue'), + beforeEnter: passwordGuard, + }, + ) + } + routes.push( { name: 'play', diff --git a/packages/slidev/node/cli.ts b/packages/slidev/node/cli.ts index 0010a15818..4c6bbc34fe 100644 --- a/packages/slidev/node/cli.ts +++ b/packages/slidev/node/cli.ts @@ -39,6 +39,8 @@ const CONFIG_RESTART_FIELDS: (keyof SlidevConfig)[] = [ const FILES_CREATE_RESTART = [ 'global-bottom.vue', 'global-top.vue', + 'handout-bottom.vue', + 'handout-cover.vue', 'uno.config.js', 'uno.config.ts', 'unocss.config.js', @@ -584,6 +586,14 @@ function exportOptions(args: Argv) { type: 'string', describe: 'path to the output', }) + .option('handout', { + type: 'boolean', + describe: 'export handout PDF (A4, one page per slide with notes and header/footer) to a separate file', + }) + .option('cover', { + type: 'boolean', + describe: 'prepend a handout cover page if available (requires handout-cover.vue)', + }) .option('format', { type: 'string', choices: ['pdf', 'png', 'pptx', 'md'], diff --git a/packages/slidev/node/commands/export.ts b/packages/slidev/node/commands/export.ts index 364c393fb8..e6a97693af 100644 --- a/packages/slidev/node/commands/export.ts +++ b/packages/slidev/node/commands/export.ts @@ -21,6 +21,8 @@ export interface ExportOptions { base?: string format?: 'pdf' | 'png' | 'pptx' | 'md' output?: string + handout?: boolean + cover?: boolean timeout?: number wait?: number waitUntil: 'networkidle' | 'load' | 'domcontentloaded' | undefined @@ -139,7 +141,8 @@ export async function exportNotes({ await page.goto(`http://localhost:${port}${base}presenter/print`, { waitUntil: 'networkidle', timeout }) await page.waitForLoadState('networkidle') - await page.emulateMedia({ media: 'screen' }) + // Use print media so @page rules (e.g. A4) apply + await page.emulateMedia({ media: 'print' }) if (wait) await page.waitForTimeout(wait) @@ -168,6 +171,8 @@ export async function exportSlides({ range, format = 'pdf', output = 'slides', + handout = false, + cover = false, slides, base = '/', timeout = 30000, @@ -190,18 +195,95 @@ export async function exportSlides({ const browser = await chromium.launch({ executablePath, }) + // Use a sane, stable viewport for handout printing to avoid extremely tall pages + // that can cause Chromium to render black/blank pages when printing. + // For regular slide export, keep the original large viewport behavior. + const A4_WIDTH_PX = Math.round(210 / 25.4 * 96) + const A4_HEIGHT_PX = Math.round(297 / 25.4 * 96) const context = await browser.newContext({ - viewport: { - width, - // Calculate height for every slides to be in the viewport to trigger the rendering of iframes (twitter, youtube...) - height: perSlide ? height : height * pages.length, - }, - deviceScaleFactor: scale, + viewport: handout + ? { width: A4_WIDTH_PX, height: A4_HEIGHT_PX } + : { + width, + // Calculate height for every slides to be in the viewport to trigger the rendering of iframes (twitter, youtube...) + height: perSlide ? height : height * pages.length, + }, + // Keep device scale modest for handout to reduce rasterization artifacts + deviceScaleFactor: handout ? 1 : scale, }) const page = await context.newPage() const progress = createSlidevProgress(!perSlide) progress.start(pages.length) + // Simple handout export mode: directly print the handout route + if (handout) { + if (!output.endsWith('.pdf')) + output = `${output}.pdf` + const baseName = output.replace(/\.pdf$/, '') + const handoutOut = `${baseName}-handout.pdf` + // Build query string, optionally including cover + const qs = new URLSearchParams() + qs.set('print', 'true') + if (range) + qs.set('range', range) + if (cover) + qs.set('cover', 'true') + const handoutQuery = `?${qs.toString()}` + const handoutPath = routerMode === 'hash' + ? `http://localhost:${port}${base}${handoutQuery}#/handout` + : `http://localhost:${port}${base}handout${handoutQuery}` + // Avoid hanging on networkidle due to dev server websockets; prefer 'load' + const gotoWaitUntil = (waitUntil === 'networkidle' || !waitUntil) ? 'load' : waitUntil + await page.goto(handoutPath, { waitUntil: gotoWaitUntil, timeout }) + if (gotoWaitUntil) + await page.waitForLoadState(gotoWaitUntil) + // For handouts, use print media and a light color scheme to avoid dark backgrounds + await page.emulateMedia({ colorScheme: 'light', media: 'print' }) + // Ensure the app has switched to print mode (adds `html.print`) + try { + await page.waitForSelector('html.print', { state: 'attached', timeout: 5000 }) + } + catch {} + // Wait until the handout pages are rendered (best-effort, non-blocking) + try { + // Limit how long we wait for initial container + await page.waitForSelector('#print-content', { state: 'attached', timeout: Math.min(timeout, 10000) }) + + // Wait briefly for page containers to appear and stabilize in count + const maxWait = 5000 + const start = Date.now() + let lastCount = -1 + while (Date.now() - start < maxWait) { + const cnt = await page.locator('.page').count() + if (cnt > 0 && cnt === lastCount) + break + lastCount = cnt + await page.waitForTimeout(200) + } + + // Do not block on slide loading indicators; hide them if present + await page.locator('.slidev-slide-loading').evaluateAll((nodes) => { + nodes.forEach((n) => { + (n as HTMLElement).style.display = 'none' + }) + }).catch(() => {}) + } + catch {} + if (wait) + await page.waitForTimeout(wait) + await page.pdf({ + format: 'A4', + path: handoutOut, + margin: { left: 0, top: 0, right: 0, bottom: 0 }, + printBackground: true, + preferCSSPageSize: true, + }) + progress.stop() + browser.close() + const relativeOutput = slash(relative('.', handoutOut)) + return relativeOutput.startsWith('.') ? relativeOutput : `./${relativeOutput}` + } + if (format === 'pdf') { await genPagePdf() } @@ -469,6 +551,7 @@ export async function exportSlides({ await fs.rm(writeToDisk, { force: true, recursive: true }) await fs.mkdir(writeToDisk, { recursive: true }) } + // Typo fix: perSlidei -> perSlide return perSlide ? genPagePngPerSlide(writeToDisk) : genPagePngOnePiece(writeToDisk) @@ -574,6 +657,8 @@ export function getExportOptions(args: ExportArgs, options: ResolvedSlidevOption const { entry, output, + handout, + cover, format, timeout, wait, @@ -590,6 +675,8 @@ export function getExportOptions(args: ExportArgs, options: ResolvedSlidevOption outFilename = output || options.data.config.exportFilename || outFilename || `${path.basename(entry, '.md')}-export` return { output: outFilename, + handout: handout || false, + cover: cover || false, slides: options.data.slides, total: options.data.slides.length, range, diff --git a/packages/slidev/node/virtual/handout-components.ts b/packages/slidev/node/virtual/handout-components.ts new file mode 100644 index 0000000000..3a24665945 --- /dev/null +++ b/packages/slidev/node/virtual/handout-components.ts @@ -0,0 +1,34 @@ +import type { VirtualModuleTemplate } from './types' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { toAtFS } from '../resolver' + +function createHandoutComponentTemplate(name: 'handout-bottom' | 'handout-cover'): VirtualModuleTemplate { + return { + id: `/@slidev/global-components/${name}`, + getContent({ roots }) { + const candidates = name === 'handout-bottom' + ? ['handout-bottom.vue', 'HandoutBottom.vue'] + : ['handout-cover.vue', 'HandoutCover.vue'] + + const components = roots + .flatMap(root => candidates.map(n => join(root, n))) + .filter(p => existsSync(p)) + + if (components.length === 0) { + // Return an empty component + return `export default { render: () => null }` + } + + const key = name.replace('-', '_') + const imports = components.map((p, i) => `import __h${key}_${i} from '${toAtFS(p)}'`).join('\n') + // Explicitly declare and forward pageNumber (kebab or camelCase) so + // overrides using defineProps<{ pageNumber: number }>() receive it. + const body = `export default {\n name: 'SlidevGlobal_${key}',\n inheritAttrs: false,\n props: { pageNumber: Number },\n setup(props, { attrs }) {\n const camelize = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase())\n function normalize(p) {\n const out = {}\n for (const k in p) out[camelize(k)] = p[k]\n return out\n }\n return () => [${components.map((_, i) => `h(__h${key}_${i}, { ...normalize(attrs), pageNumber: props.pageNumber })`).join(',')}]\n }\n}` + return [imports, `import { h } from 'vue'`, body].join('\n') + }, + } +} + +export const templateGlobalHandoutBottom = createHandoutComponentTemplate('handout-bottom') +export const templateGlobalHandoutCover = createHandoutComponentTemplate('handout-cover') diff --git a/packages/slidev/node/virtual/index.ts b/packages/slidev/node/virtual/index.ts index 597d3e6077..fef46c8649 100644 --- a/packages/slidev/node/virtual/index.ts +++ b/packages/slidev/node/virtual/index.ts @@ -1,6 +1,7 @@ import { templateConfigs } from './configs' import { templateLegacyRoutes, templateLegacyTitles } from './deprecated' import { templateGlobalLayers } from './global-layers' +import { templateGlobalHandoutBottom, templateGlobalHandoutCover } from './handout-components' import { templateLayouts } from './layouts' import { templateMonacoRunDeps } from './monaco-deps' import { templateMonacoTypes } from './monaco-types' @@ -18,6 +19,8 @@ export const templates = [ templateConfigs, templateStyle, templateGlobalLayers, + templateGlobalHandoutBottom, + templateGlobalHandoutCover, templateNavControls, templateSlides, templateLayouts, diff --git a/packages/types/client.d.ts b/packages/types/client.d.ts index f1a0065af1..e1ad9e3f31 100644 --- a/packages/types/client.d.ts +++ b/packages/types/client.d.ts @@ -18,6 +18,20 @@ declare module '#slidev/global-layers' { export const SlideBottom: ComponentOptions } +declare module '#slidev/global-components/handout-bottom' { + import type { ComponentOptions } from 'vue' + + const component: ComponentOptions + export default component +} + +declare module '#slidev/global-components/handout-cover' { + import type { ComponentOptions } from 'vue' + + const component: ComponentOptions + export default component +} + declare module '#slidev/slides' { import type { SlideRoute } from '@slidev/types' import type { ShallowRef } from 'vue' diff --git a/packages/types/src/cli.ts b/packages/types/src/cli.ts index f2821d88ec..061e5d4ab9 100644 --- a/packages/types/src/cli.ts +++ b/packages/types/src/cli.ts @@ -5,6 +5,8 @@ export interface CommonArgs { export interface ExportArgs extends CommonArgs { 'output'?: string + 'handout'?: boolean + 'cover'?: boolean 'format'?: string 'timeout'?: number 'wait'?: number From ded6031da83945ad109eb1ef261ecfe3cc5aa130 Mon Sep 17 00:00:00 2001 From: Oliver-Tobias Ripka Date: Tue, 23 Sep 2025 09:56:35 +0200 Subject: [PATCH 2/3] feat: add ending page, support other formats than a4, cleanup, add docs --- demo/starter/handout-bottom.vue | 19 ++- demo/starter/handout-cover.vue | 111 ++++++++++++------ demo/starter/handout-ending.vue | 71 +++++++++++ docs/builtin/cli.md | 9 ++ docs/custom/index.md | 14 +++ docs/guide/exporting.md | 32 +++++ packages/client/composables/usePrintStyles.ts | 37 +++++- packages/client/internals/PrintHandout.vue | 21 ++-- packages/client/pages/cover/print.vue | 11 +- packages/client/pages/handout/print.vue | 103 ++++++++++++---- packages/parser/src/config.ts | 102 +++++++++++++++- packages/slidev/node/cli.ts | 12 +- packages/slidev/node/commands/export.ts | 30 ++++- .../slidev/node/virtual/handout-components.ts | 7 +- packages/slidev/node/virtual/index.ts | 3 +- packages/types/client.d.ts | 7 ++ packages/types/src/cli.ts | 1 + packages/types/src/config.ts | 20 ++++ packages/types/src/frontmatter.ts | 45 +++++++ 19 files changed, 565 insertions(+), 90 deletions(-) create mode 100644 demo/starter/handout-ending.vue diff --git a/demo/starter/handout-bottom.vue b/demo/starter/handout-bottom.vue index 74c358c632..5d6bc4ee79 100644 --- a/demo/starter/handout-bottom.vue +++ b/demo/starter/handout-bottom.vue @@ -6,7 +6,8 @@ defineProps<{ pageNumber: number }>() const year = new Date().getFullYear() // Replace these with your flavor system if needed const company = 'Slidev' -const rightText = 'Presentation slides for developers' +const rightText = 'Professional courses for developers' +const series = 'Engineering Enablement Series'