diff --git a/demo/starter/handout-bottom.vue b/demo/starter/handout-bottom.vue new file mode 100644 index 0000000000..c0b1a39b5e --- /dev/null +++ b/demo/starter/handout-bottom.vue @@ -0,0 +1,23 @@ + + + diff --git a/demo/starter/handout-cover.vue b/demo/starter/handout-cover.vue new file mode 100644 index 0000000000..ad03eb3bbf --- /dev/null +++ b/demo/starter/handout-cover.vue @@ -0,0 +1,51 @@ + diff --git a/demo/starter/slides.md b/demo/starter/slides.md index b9d655eb36..bcdedf2d0d 100644 --- a/demo/starter/slides.md +++ b/demo/starter/slides.md @@ -635,3 +635,7 @@ class: text-center # Learn More [Documentations](https://sli.dev) · [GitHub](https://github.com/slidevjs/slidev) · [Showcases](https://sli.dev/showcases.html) + + diff --git a/packages/client/composables/useNav.ts b/packages/client/composables/useNav.ts index 0460069c65..f89e3a1292 100644 --- a/packages/client/composables/useNav.ts +++ b/packages/client/composables/useNav.ts @@ -72,6 +72,7 @@ export interface SlidevContextNavState { currentRoute: ComputedRef isPrintMode: ComputedRef isPrintWithClicks: ComputedRef + isHandout: ComputedRef isEmbedded: ComputedRef isPlaying: ComputedRef isPresenter: ComputedRef @@ -277,7 +278,8 @@ const useNavState = createSharedComposable((): SlidevContextNavState => { router.currentRoute.value.query return new URLSearchParams(location.search) }) - const isPrintMode = computed(() => query.value.has('print')) + const isHandout = computed(() => currentRoute.value.query.handout !== undefined || currentRoute.value.path.startsWith('/handout')) + const isPrintMode = computed(() => currentRoute.value.query.print !== undefined || isHandout.value) const isPrintWithClicks = computed(() => query.value.get('print') === 'clicks') const isEmbedded = computed(() => query.value.has('embedded')) const isPlaying = computed(() => currentRoute.value.name === 'play') @@ -348,6 +350,7 @@ const useNavState = createSharedComposable((): SlidevContextNavState => { currentRoute, isPrintMode, isPrintWithClicks, + isHandout, isEmbedded, isPlaying, isPresenter, diff --git a/packages/client/internals/PrintContainerHandout.vue b/packages/client/internals/PrintContainerHandout.vue new file mode 100644 index 0000000000..600241c86b --- /dev/null +++ b/packages/client/internals/PrintContainerHandout.vue @@ -0,0 +1,33 @@ + + + + + diff --git a/packages/client/internals/PrintHandout.vue b/packages/client/internals/PrintHandout.vue new file mode 100644 index 0000000000..d5b7ff30b0 --- /dev/null +++ b/packages/client/internals/PrintHandout.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/packages/client/pages/cover/print.vue b/packages/client/pages/cover/print.vue new file mode 100644 index 0000000000..6a6c2634e8 --- /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..cac5a96f71 --- /dev/null +++ b/packages/client/pages/handout/print.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/packages/client/setup/routes.ts b/packages/client/setup/routes.ts index 19a5126fed..efb7478fd5 100644 --- a/packages/client/setup/routes.ts +++ b/packages/client/setup/routes.ts @@ -61,6 +61,16 @@ export default function setupRoutes() { path: '/presenter/print', component: () => import('../pages/presenter/print.vue'), }, + { + name: 'handout', + path: '/handout', + component: () => import('../pages/handout/print.vue'), + }, + { + name: 'cover', + path: '/cover', + component: () => import('../pages/cover/print.vue'), + }, ) } diff --git a/packages/slidev/node/cli.ts b/packages/slidev/node/cli.ts index 61966b85c1..9509a97c34 100644 --- a/packages/slidev/node/cli.ts +++ b/packages/slidev/node/cli.ts @@ -43,6 +43,8 @@ const CONFIG_RESTART_FIELDS: (keyof SlidevConfig)[] = [ const FILES_CREATE_RESTART_GLOBS = [ 'global-bottom.vue', 'global-top.vue', + 'handout-bottom.vue', + 'handout-cover.vue', 'uno.config.js', 'uno.config.ts', 'unocss.config.js', @@ -611,6 +613,14 @@ function exportOptions(args: Argv) { type: 'number', describe: 'scale factor for image export', }) + .option('cover', { + type: 'boolean', + describe: 'prepend cover to handout, needs handout-cover.vue in project', + }) + .option('handout', { + type: 'boolean', + describe: 'Export handout with slides on top and notes on bottom, optionally prepending a cover', + }) } function printInfo( diff --git a/packages/slidev/node/commands/export.ts b/packages/slidev/node/commands/export.ts index d89ef3e6b1..1453fbea3b 100644 --- a/packages/slidev/node/commands/export.ts +++ b/packages/slidev/node/commands/export.ts @@ -9,7 +9,8 @@ import { parseRangeString } from '@slidev/parser/core' import type { ExportArgs, ResolvedSlidevOptions, SlideInfo, TocItem } from '@slidev/types' import { outlinePdfFactory } from '@lillallol/outline-pdf' import * as pdfLib from 'pdf-lib' -import { PDFDocument } from 'pdf-lib' +import type { PDFRef } from 'pdf-lib' +import { PDFDict, PDFDocument, PDFName, PDFString, PageSizes, asPDFName, rgb } from 'pdf-lib' import { resolve } from 'mlly' import { getRoots } from '../resolver' @@ -21,6 +22,8 @@ export interface ExportOptions { base?: string format?: 'pdf' | 'png' | 'md' output?: string + handout?: boolean + cover?: boolean timeout?: number wait?: number dark?: boolean @@ -159,6 +162,8 @@ export async function exportSlides({ range, format = 'pdf', output = 'slides', + handout = false, + cover = false, slides, base = '/', timeout = 30000, @@ -211,7 +216,7 @@ export async function exportSlides({ }) await page.waitForLoadState('networkidle') await page.emulateMedia({ colorScheme: dark ? 'dark' : 'light', media: 'screen' }) - const slide = no === 'print' + const slide = (no === 'print' || no === 'handout' || no === 'cover') ? page.locator('body') : page.locator(`[data-slidev-no="${no}"]`) await slide.waitFor() @@ -350,6 +355,7 @@ export async function exportSlides({ const buffer = await mergedPdf.save() await fs.writeFile(output, buffer) + return output } async function genPagePdfOnePiece() { @@ -379,6 +385,7 @@ export async function exportSlides({ pdfData = Buffer.from(await pdf.save()) await fs.writeFile(output, pdfData) + return output } async function genPagePngOnePiece() { @@ -410,6 +417,331 @@ export async function exportSlides({ await genPageWithClicks(genScreenshot, i) } + async function genNotesPdfOnePiece() { + const baseName = output.replace('.pdf', '') + const output_notes = `${baseName}-notes.pdf` + + await go('handout') + await page.pdf({ + path: output_notes, + width, + height, + margin: { + left: 0, + top: 0, + right: 0, + bottom: 0, + }, + printBackground: true, + preferCSSPageSize: true, + }) + + return output_notes + } + + async function genCoverPdfOnePiece() { + const baseName = output.replace('.pdf', '') + const output_notes = `${baseName}-cover.pdf` + + await go('cover') + await page.pdf({ + path: output_notes, + width, + height, + margin: { + left: 0, + top: 0, + right: 0, + bottom: 0, + }, + printBackground: true, + preferCSSPageSize: true, + }) + return output_notes + } + + const createPageLinkAnnotation = ( + page: pdfLib.PDFPage, + uri: string, + llx = 0, + lly = 30, + urx = 40, + ury = 230, + ) => + page.doc.context.register( + page.doc.context.obj({ + Type: 'Annot', + Subtype: 'Link', + Rect: [llx, lly, urx, ury], + Border: [0, 0, 0], + C: [0, 0, 1], + A: { + Type: 'Action', + S: 'URI', + URI: PDFString.of(uri), + }, + }), + ) + + async function mergeSlidesWithNotes( + slides: pdfLib.PDFDocument, + pdfNotes: pdfLib.PDFDocument, + pdfCover: pdfLib.PDFDocument | undefined, + ) { + const pdfSlidePages = slides.getPages() + const numSlides = pdfSlidePages.length + + const pdf = await PDFDocument.create() + + if (pdfCover) { + for (let i = 0; i < pdfCover.getPages().length; i++) { + const coverPage = pdf.addPage(PageSizes.A4) + const coverEmbedded = await pdf.embedPage(pdfCover.getPages()[i]) + const coverEmbeddedDims = coverEmbedded.scale(1) + coverPage.drawPage(coverEmbedded, { + ...coverEmbeddedDims, + x: coverPage.getWidth() / 2 - coverEmbeddedDims.width / 2, + y: 0, + }) + } + } + + const notesPages = pdfNotes.getPages() + + for (let i = 0; i < numSlides; i++) { + const slideEmbedded = await pdf.embedPage(pdfSlidePages[i]) + const slideEmbeddedDims = slideEmbedded.scale(0.72) + + const currentPage = pdf.addPage(PageSizes.A4) + + // firstPage.drawPage(slideEmbedded as pdfLib.PDFEmbeddedPage, { + currentPage.drawPage(slideEmbedded, { + ...slideEmbeddedDims, + x: currentPage.getWidth() / 2 - slideEmbeddedDims.width / 2, + y: currentPage.getHeight() - slideEmbeddedDims.height - 30, + width: slideEmbeddedDims.width, + height: slideEmbeddedDims.height, + }) + + currentPage.drawRectangle({ + x: currentPage.getWidth() / 2 - slideEmbeddedDims.width / 2, + y: currentPage.getHeight() - slideEmbeddedDims.height - 30, + width: slideEmbeddedDims.width, + height: slideEmbeddedDims.height, + borderColor: rgb(0, 0, 0), + borderWidth: 0.1, + }) + + let noteEmbeddedDims: { + width: number + height: number + } + + /* add notes */ + try { + const noteEmbedded = await pdf.embedPage(notesPages[i], { + left: 0, + bottom: 0, + right: 600, + top: 530, + }) + + noteEmbeddedDims = noteEmbedded.scale(0.93) + + currentPage.drawPage(noteEmbedded, { + ...noteEmbeddedDims, + x: currentPage.getWidth() / 2 - noteEmbeddedDims.width / 2, + y: 0, + }) + } + catch (error) { + console.error(`Could not embed note as page does not exist: ${error}`) + } + + /* add links for slides */ + const annots = pdfSlidePages[i].node.Annots() + + const newLinkAnnotations: PDFRef[] = [] // Initialize an empty array to accumulate new link annotations + + try { + annots?.asArray().forEach((a) => { + const dict = slides.context.lookupMaybe(a, PDFDict) + if (!dict) + return + + const aRecord = dict.get(asPDFName(`A`)) + if (!aRecord) + return + + const subtype = dict.get(PDFName.of('Subtype'))?.toString() + if (!subtype) + return + + if (subtype === '/Link') { + const rect = dict.get(PDFName.of('Rect'))! + const link = slides.context.lookupMaybe(aRecord, PDFDict) + if (!link) + return + + const uri = link.get(asPDFName('URI'))!.toString().slice(1, -1) // get the original link, remove parenthesis + + const scale = slideEmbeddedDims.width / pdfSlidePages[i].getWidth() // Calculate scale based on the width (or height) + const offsetX + = currentPage.getWidth() / 2 - slideEmbeddedDims.width / 2 + const offsetY + = currentPage.getHeight() - slideEmbeddedDims.height - 30 + + // @ts-expect-error missing types + const newRect = rect.array.map((value, index) => { + if (index % 2 === 0) { + // x values (llx, urx) + return value * scale + offsetX + } + else { + // y values (lly, ury) + // Y values need to be inverted due to PDF's coordinate system (0 at bottom) + + const scaledY = (pdfSlidePages[i].getHeight() - value) * scale + // Then, adjust for the slide's position on the page, considering the slide is at the top + return offsetY - scaledY + slideEmbeddedDims.height + } + }) + + const newLink = createPageLinkAnnotation( + currentPage, + uri, + newRect[0], // llx + newRect[1], // lly + newRect[2], // urx + newRect[3], // ury + ) + newLinkAnnotations.push(newLink) + } + }) + } + catch (e) { + console.error(e) + } + + /* add links for handouts */ + const notesAnnots = notesPages[i]?.node.Annots() + try { + notesAnnots?.asArray().forEach((a) => { + let dict: PDFDict | undefined + try { + dict = pdfNotes.context.lookupMaybe(a, PDFDict) + } + catch (e) { + } + + if (!dict) + return + + const aRecord = dict.get(PDFName.of(`A`)) + const subtype = dict.get(PDFName.of('Subtype'))?.toString() + + if (subtype === '/Link') { + const rect = dict.get(PDFName.of('Rect'))! + const link = pdfNotes.context.lookupMaybe(aRecord, PDFDict)! + const uri = link.get(PDFName.of('URI'))!.toString().slice(1, -1) + + const scale = noteEmbeddedDims.width / notesPages[i].getWidth() + const offsetX = currentPage.getWidth() / 2 - noteEmbeddedDims.width / 2 + const offsetY = 0 // Notes are drawn at the bottom, so offsetY is 0 + + // @ts-expect-error missing types + const newRect = rect.array.map((value, index) => { + if (index % 2 === 0) { + return value * scale + offsetX // x values + } + else { + // y values need to be adjusted differently for notes + return -2 + offsetY + value * scale // Adjust y values for position + } + }) + + const newLink = createPageLinkAnnotation( + currentPage, + uri, + newRect[0], // llx + newRect[1], // lly + newRect[2], // urx + newRect[3], // ury + ) + newLinkAnnotations.push(newLink) + } + }) + } + catch (e) { + console.error(e) + } + + if (newLinkAnnotations.length > 0) { + currentPage.node.set( + PDFName.of('Annots'), + pdf.context.obj(newLinkAnnotations), + ) + } + } + + return pdf + } + + async function genHandoutAndMerge(pdfSlidesPath: string) { + if (format !== 'pdf') + throw new Error(`Unsupported exporting format for handout "${format}"`) + + /* 1. Read generated slides */ + const slidesData = await fs.readFile(pdfSlidesPath) + const pdfSlides = await PDFDocument.load(slidesData) + + /* 2. Generate notes pdf */ + const notesPath = await genNotesPdfOnePiece() + const notesData = await fs.readFile(notesPath) + const pdfNotes = await PDFDocument.load(notesData) + + /* 3. Generate cover pdf */ + let pdfCover + let coverPath + if (cover) { + coverPath = await genCoverPdfOnePiece() + const coverData = await fs.readFile(coverPath) + pdfCover = await PDFDocument.load(coverData) + } + + const pdf = await mergeSlidesWithNotes(pdfSlides, pdfNotes, pdfCover) + + /* cleanup */ + await fs.unlink(notesPath) + if (cover && coverPath) + await fs.unlink(coverPath) + + if (!pdf) + throw new Error('PDF could not be generated') + + { + const titleSlide = slides[0] + if (titleSlide?.title) + pdf.setTitle(titleSlide.title) + if (titleSlide?.frontmatter?.info) + pdf.setSubject(titleSlide.frontmatter.info) + if (titleSlide?.frontmatter?.author) + pdf.setAuthor(titleSlide.frontmatter.author) + if (titleSlide?.frontmatter?.keywords) { + if (Array.isArray(titleSlide?.frontmatter?.keywords)) + pdf.setKeywords(titleSlide?.frontmatter?.keywords) + else + pdf.setKeywords(titleSlide?.frontmatter?.keywords.split(',')) + } + } + + const pdfData = Buffer.from(await pdf.save()) + const baseName = output.replace('.pdf', '') + const handOut = `${baseName}-handout.pdf` + + await fs.writeFile(handOut, pdfData) + } + function genPagePdf() { if (!output.endsWith('.pdf')) output = `${output}.pdf` @@ -478,8 +810,10 @@ export async function exportSlides({ progress.start(pages.length) + let pdfSlidesPath + if (format === 'pdf') { - await genPagePdf() + pdfSlidesPath = await genPagePdf() } else if (format === 'png') { await genPagePng() @@ -492,6 +826,9 @@ export async function exportSlides({ throw new Error(`Unsupported exporting format "${format}"`) } + if (pdfSlidesPath && handout) + await genHandoutAndMerge(pdfSlidesPath) + progress.stop() browser.close() return output @@ -511,6 +848,8 @@ export function getExportOptions(args: ExportArgs, options: ResolvedSlidevOption const { entry, output, + handout, + cover, format, timeout, wait, @@ -527,6 +866,8 @@ export function getExportOptions(args: ExportArgs, options: ResolvedSlidevOption outFilename = path.join(outDir, outFilename) 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/global-components.ts b/packages/slidev/node/virtual/global-components.ts index c44a07700f..5e8335069b 100644 --- a/packages/slidev/node/virtual/global-components.ts +++ b/packages/slidev/node/virtual/global-components.ts @@ -3,7 +3,7 @@ import { join } from 'node:path' import { toAtFS } from '../resolver' import type { VirtualModuleTemplate } from './types' -function createGlobalComponentTemplate(layer: 'top' | 'bottom'): VirtualModuleTemplate { +function createGlobalComponentTemplate(layer: 'top' | 'bottom' | 'handout-bottom' | 'handout-cover'): VirtualModuleTemplate { return { id: `/@slidev/global-components/${layer}`, getContent({ roots }) { @@ -16,6 +16,18 @@ function createGlobalComponentTemplate(layer: 'top' | 'bottom'): VirtualModuleTe join(root, 'GlobalTop.vue'), ] } + else if (layer === 'handout-bottom') { + return [ + join(root, 'handout-bottom.vue'), + join(root, 'HandoutBottom.vue'), + ] + } + else if (layer === 'handout-cover') { + return [ + join(root, 'handout-cover.vue'), + join(root, 'HandoutCover.vue'), + ] + } else { return [ join(root, 'global-bottom.vue'), @@ -70,3 +82,5 @@ render() { export const templateGlobalTop = createGlobalComponentTemplate('top') export const templateGlobalBottom = createGlobalComponentTemplate('bottom') +export const templateGlobalHandoutBottom = createGlobalComponentTemplate('handout-bottom') +export const templateGlobalHandoutCover = createGlobalComponentTemplate('handout-cover') diff --git a/packages/slidev/node/virtual/index.ts b/packages/slidev/node/virtual/index.ts index 462bb73302..8064502e9b 100644 --- a/packages/slidev/node/virtual/index.ts +++ b/packages/slidev/node/virtual/index.ts @@ -1,6 +1,6 @@ import { templateConfigs } from './configs' import { templateLegacyRoutes, templateLegacyTitles } from './deprecated' -import { templateGlobalBottom, templateGlobalTop, templateNavControls } from './global-components' +import { templateGlobalBottom, templateGlobalHandoutBottom, templateGlobalHandoutCover, templateGlobalTop, templateNavControls } from './global-components' import { templateLayouts } from './layouts' import { templateMonacoRunDeps } from './monaco-deps' import { templateMonacoTypes } from './monaco-types' @@ -18,6 +18,8 @@ export const templates = [ templateStyle, templateGlobalBottom, templateGlobalTop, + templateGlobalHandoutBottom, + templateGlobalHandoutCover, templateNavControls, templateSlides, templateLayouts, diff --git a/packages/types/client.d.ts b/packages/types/client.d.ts index 6923fefc28..27b2359f7e 100644 --- a/packages/types/client.d.ts +++ b/packages/types/client.d.ts @@ -22,6 +22,20 @@ declare module '#slidev/global-components/bottom' { export default component } +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 { ShallowRef } from 'vue' import type { SlideRoute } from '@slidev/types' diff --git a/packages/types/src/cli.ts b/packages/types/src/cli.ts index c2de0ec31f..17dad1dc02 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