diff --git a/demo/starter/handout-bottom.vue b/demo/starter/handout-bottom.vue new file mode 100644 index 0000000000..5d6bc4ee79 --- /dev/null +++ b/demo/starter/handout-bottom.vue @@ -0,0 +1,56 @@ + + + + + diff --git a/demo/starter/handout-cover.vue b/demo/starter/handout-cover.vue new file mode 100644 index 0000000000..b86b5095b4 --- /dev/null +++ b/demo/starter/handout-cover.vue @@ -0,0 +1,86 @@ + diff --git a/demo/starter/handout-ending.vue b/demo/starter/handout-ending.vue new file mode 100644 index 0000000000..457f696e6b --- /dev/null +++ b/demo/starter/handout-ending.vue @@ -0,0 +1,71 @@ + diff --git a/docs/builtin/cli.md b/docs/builtin/cli.md index c9a748e4e2..c47b421588 100644 --- a/docs/builtin/cli.md +++ b/docs/builtin/cli.md @@ -69,13 +69,22 @@ Export slides to PDF (or other format). See Available since v0.36.10 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..02fdb2812e 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 { slideHeight, slideWidth } from '../env' +import { useRoute } from 'vue-router' +import { configs, 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; @@ -26,3 +30,38 @@ export function patchMonacoColors() { el.media = '' }) } + +export type HandoutPageContext = 'handout' | 'cover' | 'ending' + +export function useHandoutPageSetup(context: HandoutPageContext = 'handout') { + const { isPrintMode } = useNav() + const route = useRoute() + + const allowedRoute = computed(() => { + if (context === 'cover') + return route.name === 'cover' + return route.name === 'handout' + }) + + const margins = computed(() => context === 'cover' + ? configs.handout.coverMargins + : configs.handout.margins) + const pageSize = computed(() => configs.handout.cssPageSize) + + useStyleTag(computed(() => (isPrintMode.value && allowedRoute.value) + ? ` +@page { + size: ${pageSize.value}; + margin-top: ${margins.value.top}; + margin-right: ${margins.value.right}; + margin-bottom: ${margins.value.bottom}; + margin-left: ${margins.value.left}; +} +` + : '')) + + return { + margins, + pageSize, + } +} 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..aa62db357d --- /dev/null +++ b/packages/client/internals/PrintHandout.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/packages/client/pages/cover/print.vue b/packages/client/pages/cover/print.vue new file mode 100644 index 0000000000..218c19ab0c --- /dev/null +++ b/packages/client/pages/cover/print.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/packages/client/pages/handout/print.vue b/packages/client/pages/handout/print.vue new file mode 100644 index 0000000000..a1067e7956 --- /dev/null +++ b/packages/client/pages/handout/print.vue @@ -0,0 +1,177 @@ + + + + + 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/parser/src/config.ts b/packages/parser/src/config.ts index 52fbb03da8..4883fc283f 100644 --- a/packages/parser/src/config.ts +++ b/packages/parser/src/config.ts @@ -1,4 +1,4 @@ -import type { DrawingsOptions, FontOptions, ResolvedDrawingsOptions, ResolvedExportOptions, ResolvedFontOptions, SlidevConfig, SlidevThemeMeta } from '@slidev/types' +import type { DrawingsOptions, FontOptions, HandoutOptions, ResolvedDrawingsOptions, ResolvedExportOptions, ResolvedFontOptions, ResolvedHandoutOptions, SlidevConfig, SlidevThemeMeta } from '@slidev/types' import { toArray, uniq } from '@antfu/utils' import { parseAspectRatio } from './utils' @@ -45,6 +45,7 @@ export function getDefaultConfig(): SlidevConfig { remote: false, mdc: false, seoMeta: {}, + handout: resolveHandoutOptions(), } } @@ -78,6 +79,8 @@ export function resolveConfig(headmatter: any, themeMeta: SlidevThemeMeta = {}, }, } + config.handout = resolveHandoutOptions(config.handout) + // @ts-expect-error compat if (config.highlighter === 'shikiji') { console.warn(`[slidev] "shikiji" is merged back to "shiki", you can safely change it "highlighter: shiki"`) @@ -204,6 +207,103 @@ export function resolveFonts(fonts: FontOptions = {}): ResolvedFontOptions { } } +const PAPER_SIZES_MM: Record = { + a5: { label: 'A5', mm: [148, 210] }, + a4: { label: 'A4', mm: [210, 297] }, + a3: { label: 'A3', mm: [297, 420] }, + letter: { label: 'letter', mm: [215.9, 279.4] }, + legal: { label: 'legal', mm: [215.9, 355.6] }, + tabloid: { label: 'tabloid', mm: [279.4, 431.8] }, + executive: { label: 'executive', mm: [184.15, 266.7] }, +} + +const PX_PER_MM = 96 / 25.4 +const DEFAULT_HANDOUT_MARGINS = { top: '0cm', right: '0cm', bottom: '0cm', left: '0cm' } as const +const DEFAULT_HANDOUT_COVER_MARGINS = { top: '1cm', right: '1.5cm', bottom: '1cm', left: '1.5cm' } as const + +function formatMm(value: number): string { + return `${Number(value.toFixed(3))}mm` +} + +function normalizeMargins( + input: string | Partial> | undefined, + fallback: ResolvedHandoutOptions['margins'], +): ResolvedHandoutOptions['margins'] { + if (!input) + return { ...fallback } + if (typeof input === 'string') { + return { + top: input, + right: input, + bottom: input, + left: input, + } + } + return { + top: input.top ?? fallback.top, + right: input.right ?? fallback.right, + bottom: input.bottom ?? fallback.bottom, + left: input.left ?? fallback.left, + } +} + +export function resolveHandoutOptions( + options: HandoutOptions | ResolvedHandoutOptions | undefined = undefined, +): ResolvedHandoutOptions { + if (options && typeof options === 'object' && 'widthMm' in options && 'heightMm' in options && 'cssPageSize' in options) { + return { + ...options, + margins: { ...options.margins }, + coverMargins: { ...options.coverMargins }, + } + } + + const normalized = typeof options === 'string' ? { size: options } : options || {} + const requestedKey = normalized.size ? normalized.size.toLowerCase() : 'a4' + const preset = PAPER_SIZES_MM[requestedKey] || PAPER_SIZES_MM.a4 + + let widthMm = normalized.width && normalized.height + ? normalized.unit === 'in' + ? normalized.width * 25.4 + : normalized.width + : preset.mm[0] + let heightMm = normalized.width && normalized.height + ? normalized.unit === 'in' + ? normalized.height * 25.4 + : normalized.height + : preset.mm[1] + + const orientation = normalized.orientation + ?? (widthMm >= heightMm ? 'landscape' : 'portrait') + + if (orientation === 'landscape' && heightMm > widthMm) + [widthMm, heightMm] = [heightMm, widthMm] + else if (orientation === 'portrait' && widthMm > heightMm) + [widthMm, heightMm] = [heightMm, widthMm] + + const widthPx = Math.round(widthMm * PX_PER_MM) + const heightPx = Math.round(heightMm * PX_PER_MM) + + const cssKeyword = preset.label + const cssPageSize = normalized.width && normalized.height + ? `${formatMm(widthMm)} ${formatMm(heightMm)}` + : orientation === 'landscape' + ? `${cssKeyword} landscape` + : cssKeyword + + return { + size: normalized.width && normalized.height ? 'custom' : cssKeyword, + orientation, + widthMm, + heightMm, + widthPx, + heightPx, + cssPageSize, + margins: normalizeMargins(normalized.margins, DEFAULT_HANDOUT_MARGINS), + coverMargins: normalizeMargins(normalized.coverMargins, DEFAULT_HANDOUT_COVER_MARGINS), + } +} + function resolveDrawings(options: DrawingsOptions = {}, filepath?: string): ResolvedDrawingsOptions { const { enabled = true, diff --git a/packages/slidev/node/cli.ts b/packages/slidev/node/cli.ts index 0010a15818..b5799e7f8f 100644 --- a/packages/slidev/node/cli.ts +++ b/packages/slidev/node/cli.ts @@ -39,6 +39,12 @@ const CONFIG_RESTART_FIELDS: (keyof SlidevConfig)[] = [ const FILES_CREATE_RESTART = [ 'global-bottom.vue', 'global-top.vue', + 'handout-bottom.vue', + 'handout-cover.vue', + 'handout-ending.vue', + 'HandoutBottom.vue', + 'HandoutCover.vue', + 'HandoutEnding.vue', 'uno.config.js', 'uno.config.ts', 'unocss.config.js', @@ -584,6 +590,18 @@ function exportOptions(args: Argv) { type: 'string', describe: 'path to the output', }) + .option('handout', { + type: 'boolean', + describe: 'export handout PDF (configurable page size, one page per slide with notes and header/footer) to a separate file', + }) + .option('cover', { + type: 'boolean', + describe: 'prepend handout cover page(s) if available (requires handout-cover.vue)', + }) + .option('ending', { + type: 'boolean', + describe: 'append handout ending page(s) if available (requires handout-ending.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..c6daa264e1 100644 --- a/packages/slidev/node/commands/export.ts +++ b/packages/slidev/node/commands/export.ts @@ -1,4 +1,4 @@ -import type { ExportArgs, ResolvedSlidevOptions, SlideInfo, TocItem } from '@slidev/types' +import type { ExportArgs, ResolvedHandoutOptions, ResolvedSlidevOptions, SlideInfo, TocItem } from '@slidev/types' import { Buffer } from 'node:buffer' import fs from 'node:fs/promises' import path, { dirname, relative } from 'node:path' @@ -21,6 +21,9 @@ export interface ExportOptions { base?: string format?: 'pdf' | 'png' | 'pptx' | 'md' output?: string + handout?: boolean + cover?: boolean + ending?: boolean timeout?: number wait?: number waitUntil: 'networkidle' | 'load' | 'domcontentloaded' | undefined @@ -38,6 +41,7 @@ export interface ExportOptions { perSlide?: boolean scale?: number omitBackground?: boolean + handoutOptions?: ResolvedHandoutOptions } interface ExportPngResult { @@ -139,7 +143,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 +173,9 @@ export async function exportSlides({ range, format = 'pdf', output = 'slides', + handout = false, + cover = false, + ending = false, slides, base = '/', timeout = 30000, @@ -183,6 +191,7 @@ export async function exportSlides({ scale = 1, waitUntil, omitBackground = false, + handoutOptions, }: ExportOptions) { const pages: number[] = parseRangeString(total, range) @@ -190,18 +199,108 @@ 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 fallbackHandout: ResolvedHandoutOptions = { + size: 'A4', + orientation: 'portrait', + widthMm: 210, + heightMm: 297, + widthPx: Math.round(210 / 25.4 * 96), + heightPx: Math.round(297 / 25.4 * 96), + cssPageSize: 'A4', + margins: { top: '0cm', right: '0cm', bottom: '0cm', left: '0cm' }, + coverMargins: { top: '1cm', right: '1.5cm', bottom: '1cm', left: '1.5cm' }, + } + const handoutConfig = handoutOptions || fallbackHandout 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: handoutConfig.widthPx, height: handoutConfig.heightPx } + : { + 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') + if (ending) + qs.set('ending', '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({ + width: `${handoutConfig.widthMm}mm`, + height: `${handoutConfig.heightMm}mm`, + 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 +568,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 +674,9 @@ export function getExportOptions(args: ExportArgs, options: ResolvedSlidevOption const { entry, output, + handout, + cover, + ending, format, timeout, wait, @@ -590,6 +693,9 @@ 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, + ending: ending || false, slides: options.data.slides, total: options.data.slides.length, range, @@ -607,6 +713,7 @@ export function getExportOptions(args: ExportArgs, options: ResolvedSlidevOption perSlide: perSlide || false, scale: scale || 2, omitBackground: omitBackground ?? false, + handoutOptions: options.data.config.handout, } } diff --git a/packages/slidev/node/virtual/handout-components.ts b/packages/slidev/node/virtual/handout-components.ts new file mode 100644 index 0000000000..c7a4d5451d --- /dev/null +++ b/packages/slidev/node/virtual/handout-components.ts @@ -0,0 +1,37 @@ +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' | 'handout-ending'): VirtualModuleTemplate { + return { + id: `/@slidev/global-components/${name}`, + getContent({ roots }) { + const candidates = name === 'handout-bottom' + ? ['handout-bottom.vue', 'HandoutBottom.vue'] + : name === 'handout-cover' + ? ['handout-cover.vue', 'HandoutCover.vue'] + : ['handout-ending.vue', 'HandoutEnding.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') +export const templateGlobalHandoutEnding = createHandoutComponentTemplate('handout-ending') diff --git a/packages/slidev/node/virtual/index.ts b/packages/slidev/node/virtual/index.ts index 597d3e6077..1f5c81a5c6 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, templateGlobalHandoutEnding } from './handout-components' import { templateLayouts } from './layouts' import { templateMonacoRunDeps } from './monaco-deps' import { templateMonacoTypes } from './monaco-types' @@ -18,6 +19,9 @@ export const templates = [ templateConfigs, templateStyle, templateGlobalLayers, + templateGlobalHandoutBottom, + templateGlobalHandoutCover, + templateGlobalHandoutEnding, templateNavControls, templateSlides, templateLayouts, diff --git a/packages/types/client.d.ts b/packages/types/client.d.ts index f1a0065af1..db7f319179 100644 --- a/packages/types/client.d.ts +++ b/packages/types/client.d.ts @@ -18,6 +18,27 @@ 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/global-components/handout-ending' { + 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..ae629d75c6 100644 --- a/packages/types/src/cli.ts +++ b/packages/types/src/cli.ts @@ -5,6 +5,9 @@ export interface CommonArgs { export interface ExportArgs extends CommonArgs { 'output'?: string + 'handout'?: boolean + 'cover'?: boolean + 'ending'?: boolean 'format'?: string 'timeout'?: number 'wait'?: number diff --git a/packages/types/src/config.ts b/packages/types/src/config.ts index fbe4dbdc11..96c477026b 100644 --- a/packages/types/src/config.ts +++ b/packages/types/src/config.ts @@ -6,6 +6,7 @@ export interface ResolvedSlidevConfigSub { drawings: ResolvedDrawingsOptions fonts: ResolvedFontOptions aspectRatio: number + handout: ResolvedHandoutOptions } export interface SlidevConfig extends @@ -36,3 +37,22 @@ export interface ResolvedExportOptions extends Omit + +export type HandoutPageMarginInput = Record<'top' | 'right' | 'bottom' | 'left', string> + +export interface HandoutOptions { + /** + * Predefined page size to use. Set `width`/`height` for custom sizes. + */ + size?: HandoutPredefinedSize + /** + * Page orientation. + * + * @default 'portrait' + */ + orientation?: 'portrait' | 'landscape' + /** + * Custom page width. + */ + width?: number + /** + * Custom page height. + */ + height?: number + /** + * Units for `width`/`height` when provided. + * + * @default 'mm' + */ + unit?: 'mm' | 'in' + /** + * Margins applied to the handout pages. Can be a CSS shorthand string or per-side object. + */ + margins?: string | Partial + /** + * Margins applied specifically to the optional cover pages. + */ + coverMargins?: string | Partial +} + export type BuiltinSlideTransition = 'slide-up' | 'slide-down' | 'slide-left' | 'slide-right' | 'fade' | 'zoom' | 'none' export interface TransitionOptions { diff --git a/packages/vscode/schema/headmatter.json b/packages/vscode/schema/headmatter.json index bed19a9401..97195d4d15 100644 --- a/packages/vscode/schema/headmatter.json +++ b/packages/vscode/schema/headmatter.json @@ -431,6 +431,12 @@ "markdownDescription": "Force the filename used when exporting the presentation.\nThe extension, e.g. .pdf, gets automatically added.", "default": "" }, + "handout": { + "$ref": "#/definitions/HandoutOptions", + "description": "Options for exported handouts (page size, orientation, margins).", + "markdownDescription": "Options for exported handouts (page size, orientation, margins).", + "default": {} + }, "monaco": { "anyOf": [ { @@ -786,6 +792,117 @@ } } }, + "HandoutOptions": { + "type": "object", + "properties": { + "size": { + "$ref": "#/definitions/HandoutPredefinedSize", + "description": "Predefined page size to use. Set `width`/`height` for custom sizes.", + "markdownDescription": "Predefined page size to use. Set `width`/`height` for custom sizes." + }, + "orientation": { + "type": "string", + "enum": [ + "portrait", + "landscape" + ], + "description": "Page orientation.", + "markdownDescription": "Page orientation.", + "default": "portrait" + }, + "width": { + "type": "number", + "description": "Custom page width.", + "markdownDescription": "Custom page width." + }, + "height": { + "type": "number", + "description": "Custom page height.", + "markdownDescription": "Custom page height." + }, + "unit": { + "type": "string", + "enum": [ + "mm", + "in" + ], + "description": "Units for `width`/`height` when provided.", + "markdownDescription": "Units for `width`/`height` when provided.", + "default": "mm" + }, + "margins": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "top": { + "type": "string" + }, + "right": { + "type": "string" + }, + "bottom": { + "type": "string" + }, + "left": { + "type": "string" + } + } + } + ], + "description": "Margins applied to the handout pages. Can be a CSS shorthand string or per-side object.", + "markdownDescription": "Margins applied to the handout pages. Can be a CSS shorthand string or per-side object." + }, + "coverMargins": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "top": { + "type": "string" + }, + "right": { + "type": "string" + }, + "bottom": { + "type": "string" + }, + "left": { + "type": "string" + } + } + } + ], + "description": "Margins applied specifically to the optional cover pages.", + "markdownDescription": "Margins applied specifically to the optional cover pages." + } + } + }, + "HandoutPredefinedSize": { + "type": "string", + "enum": [ + "A5", + "A4", + "A3", + "Letter", + "Legal", + "Tabloid", + "Executive", + "a5", + "a4", + "a3", + "letter", + "legal", + "tabloid", + "executive" + ] + }, "SeoMeta": { "type": "object", "properties": {