返回 slidev
export.ts
根目录 / packages / slidev / node / commands / export.ts
1 import type { ExportArgs, ResolvedSlidevOptions, SlideInfo, TocItem } from '@slidev/types'
2 import { Buffer } from 'node:buffer'
3 import fs from 'node:fs/promises'
4 import process from 'node:process'
5 import { clearUndefined, ensureSuffix, slash } from '@antfu/utils'
6 import { outlinePdfFactory } from '@lillallol/outline-pdf'
7 import { parseRangeString } from '@slidev/parser/core'
8 import { blue, cyan, dim, green, yellow } from 'ansis'
9 import { Presets, SingleBar } from 'cli-progress'
10 import { resolve } from 'mlly'
11 import path, { dirname, relative } from 'pathe'
12 import * as pdfLib from 'pdf-lib'
13 import { PDFDocument } from 'pdf-lib'
14 import { getRoots } from '../resolver'
15
16 const RE_CLICKS_PARAM = /clicks=([1-9]\d*)/
17
18 export interface ExportOptions {
19 total: number
20 range?: string
21 slides: SlideInfo[]
22 port?: number
23 base?: string
24 format?: 'pdf' | 'png' | 'pptx' | 'md'
25 output?: string
26 timeout?: number
27 wait?: number
28 waitUntil: 'networkidle' | 'load' | 'domcontentloaded' | undefined
29 dark?: boolean
30 routerMode?: 'hash' | 'history'
31 width?: number
32 height?: number
33 withClicks?: boolean
34 executablePath?: string
35 withToc?: boolean
36 /**
37 * Render slides slide by slide. Works better with global components, but will break cross slide links and TOC in PDF.
38 * @default false
39 */
40 perSlide?: boolean
41 scale?: number
42 omitBackground?: boolean
43 }
44
45 interface ExportPngResult {
46 slideIndex: number
47 buffer: Buffer
48 filename: string
49 }
50
51 function addToTree(tree: TocItem[], info: SlideInfo, slideIndexes: Record<number, number>, level = 1) {
52 const titleLevel = info.level
53 if (titleLevel && titleLevel > level && tree.length > 0 && tree[tree.length - 1].titleLevel < titleLevel) {
54 addToTree(tree[tree.length - 1].children, info, slideIndexes, level + 1)
55 }
56 else {
57 tree.push({
58 no: info.index,
59 children: [],
60 level,
61 titleLevel: titleLevel ?? level,
62 path: String(slideIndexes[info.index + 1]),
63 hideInToc: Boolean(info.frontmatter?.hideInToc),
64 title: info.title,
65 })
66 }
67 }
68
69 function makeOutline(tree: TocItem[]): string {
70 return tree.map(({ title, path, level, children }) => {
71 const rootOutline = title ? `${path}|${'-'.repeat(level - 1)}|${title}` : null
72
73 const childrenOutline = makeOutline(children)
74
75 return childrenOutline.length > 0 ? `${rootOutline}\n${childrenOutline}` : rootOutline
76 }).filter(outline => !!outline).join('\n')
77 }
78
79 export interface ExportNotesOptions {
80 port?: number
81 base?: string
82 output?: string
83 timeout?: number
84 wait?: number
85 }
86
87 function createSlidevProgress(indeterminate = false) {
88 function getSpinner(n = 0) {
89 return [cyan('●'), green('◆'), blue('■'), yellow('▲')][n % 4]
90 }
91 let current = 0
92 let spinner = 0
93 let timer: any
94
95 const progress = new SingleBar({
96 clearOnComplete: true,
97 hideCursor: true,
98 format: ` {spin} ${yellow('rendering')}${indeterminate ? dim(yellow('...')) : ' {bar} {value}/{total}'}`,
99 linewrap: false,
100 barsize: 30,
101 }, Presets.shades_grey)
102
103 return {
104 bar: progress,
105 start(total: number) {
106 progress.start(total, 0, { spin: getSpinner(spinner) })
107 timer = setInterval(() => {
108 spinner += 1
109 progress.update(current, { spin: getSpinner(spinner) })
110 }, 200)
111 },
112 update(v: number) {
113 current = v
114 progress.update(v, { spin: getSpinner(spinner) })
115 },
116 stop() {
117 clearInterval(timer)
118 progress.stop()
119 },
120 }
121 }
122
123 export async function exportNotes({
124 port = 18724,
125 base = '/',
126 output = 'notes',
127 timeout = 30000,
128 wait = 0,
129 }: ExportNotesOptions): Promise<string> {
130 const { chromium } = await importPlaywright()
131 const browser = await chromium.launch()
132 const context = await browser.newContext()
133 const page = await context.newPage()
134
135 const progress = createSlidevProgress(true)
136
137 progress.start(1)
138
139 if (!output.endsWith('.pdf'))
140 output = `${output}.pdf`
141
142 try {
143 await page.goto(`http://localhost:${port}${base}presenter/print`, { waitUntil: 'networkidle', timeout })
144 await page.waitForLoadState('networkidle')
145 await page.emulateMedia({ media: 'screen' })
146
147 if (wait)
148 await page.waitForTimeout(wait)
149
150 await page.pdf({
151 path: output,
152 margin: {
153 left: 0,
154 top: 0,
155 right: 0,
156 bottom: 0,
157 },
158 printBackground: true,
159 preferCSSPageSize: true,
160 })
161 }
162 finally {
163 progress.stop()
164 await browser.close()
165 }
166
167 return output
168 }
169
170 export async function exportSlides({
171 port = 18724,
172 total = 0,
173 range,
174 format = 'pdf',
175 output = 'slides',
176 slides,
177 base = '/',
178 timeout = 30000,
179 wait = 0,
180 dark = false,
181 routerMode = 'history',
182 width = 1920,
183 height = 1080,
184 withClicks = false,
185 executablePath = undefined,
186 withToc = false,
187 perSlide = false,
188 scale = 1,
189 waitUntil,
190 omitBackground = false,
191 }: ExportOptions) {
192 const pages: number[] = parseRangeString(total, range)
193
194 const { chromium } = await importPlaywright()
195 const browser = await chromium.launch({
196 executablePath,
197 })
198 const context = await browser.newContext({
199 viewport: {
200 width,
201 // Calculate height for every slides to be in the viewport to trigger the rendering of iframes (twitter, youtube...)
202 height: perSlide ? height : height * pages.length,
203 },
204 deviceScaleFactor: scale,
205 })
206 const page = await context.newPage()
207 const progress = createSlidevProgress(!perSlide)
208 progress.start(pages.length)
209
210 try {
211 if (format === 'pdf') {
212 await genPagePdf()
213 }
214 else if (format === 'png') {
215 await genPagePng(output)
216 }
217 else if (format === 'md') {
218 await genPageMd()
219 }
220 else if (format === 'pptx') {
221 const buffers = await genPagePng(false)
222 await genPagePptx(buffers)
223 }
224 else {
225 throw new Error(`[slidev] Unsupported exporting format "${format}"`)
226 }
227 }
228 finally {
229 progress.stop()
230 await browser.close()
231 }
232
233 const relativeOutput = slash(relative('.', output))
234 return relativeOutput.startsWith('.') ? relativeOutput : `./${relativeOutput}`
235
236 async function go(no: number | string, clicks?: string) {
237 const query = new URLSearchParams()
238 if (withClicks)
239 query.set('print', 'clicks')
240 else
241 query.set('print', 'true')
242 if (range)
243 query.set('range', range)
244 if (clicks)
245 query.set('clicks', clicks)
246
247 const url = routerMode === 'hash'
248 ? `http://localhost:${port}${base}?${query}#${no}`
249 : `http://localhost:${port}${base}${no}?${query}`
250 await page.goto(url, {
251 waitUntil,
252 timeout,
253 })
254 if (waitUntil)
255 await page.waitForLoadState(waitUntil)
256 await page.emulateMedia({ colorScheme: dark ? 'dark' : 'light', media: 'screen' })
257 const slide = no === 'print'
258 ? page.locator('body')
259 : page.locator(`[data-slidev-no="${no}"]`)
260 await slide.waitFor()
261
262 // Wait for slides to be loaded
263 {
264 const elements = slide.locator('.slidev-slide-loading')
265 const count = await elements.count()
266 for (let index = 0; index < count; index++)
267 await elements.nth(index).waitFor({ state: 'detached' })
268 }
269 // Check for "data-waitfor" attribute and wait for given element to be loaded
270 {
271 const elements = slide.locator('[data-waitfor]')
272 const count = await elements.count()
273 for (let index = 0; index < count; index++) {
274 const element = elements.nth(index)
275 const attribute = await element.getAttribute('data-waitfor')
276 if (attribute) {
277 await element.locator(attribute).waitFor({ state: 'visible' }).catch((e) => {
278 console.error(e)
279 process.exitCode = 1
280 })
281 }
282 }
283 }
284 // Wait for frames to load
285 {
286 const frames = page.frames()
287 await Promise.all(frames.map(frame => frame.waitForLoadState(undefined, { timeout })))
288 }
289 // Wait for Mermaid graphs to be rendered
290 {
291 const container = slide.locator('#mermaid-rendering-container')
292 const count = await container.count()
293 if (count > 0) {
294 while (true) {
295 const element = container.locator('div').first()
296 if (await element.count() === 0)
297 break
298 await element.waitFor({ state: 'detached' })
299 }
300 await container.evaluate(node => node.style.display = 'none')
301 }
302 }
303 // Hide Monaco aria container
304 {
305 const elements = slide.locator('.monaco-aria-container')
306 const count = await elements.count()
307 for (let index = 0; index < count; index++) {
308 const element = elements.nth(index)
309 await element.evaluate(node => node.style.display = 'none')
310 }
311 }
312 // Wait for the given time
313 if (wait)
314 await page.waitForTimeout(wait)
315 }
316
317 async function getSlidesIndex() {
318 const clicksBySlide: Record<string, number> = {}
319 const slides = page.locator('.print-slide-container')
320 const count = await slides.count()
321 for (let i = 0; i < count; i++) {
322 const id = (await slides.nth(i).getAttribute('id')) || ''
323 const path = Number(id.split('-')[0])
324 clicksBySlide[path] = (clicksBySlide[path] || 0) + 1
325 }
326
327 const slideIndexes = Object.fromEntries(Object.entries(clicksBySlide)
328 .reduce<[string, number][]>((acc, [path, clicks], i) => {
329 acc.push([path, clicks + (acc[i - 1]?.[1] ?? 0)])
330 return acc
331 }, []))
332 return slideIndexes
333 }
334
335 function getClicksFromUrl(url: string) {
336 return url.match(RE_CLICKS_PARAM)?.[1]
337 }
338
339 async function genPageWithClicks(
340 fn: (no: number, clicks?: string) => Promise<any>,
341 no: number,
342 clicks?: string,
343 ) {
344 await fn(no, clicks)
345 if (withClicks) {
346 await page.keyboard.press('ArrowRight', { delay: 100 })
347 const _clicks = getClicksFromUrl(page.url())
348 if (_clicks && clicks !== _clicks)
349 await genPageWithClicks(fn, no, _clicks)
350 }
351 }
352
353 async function genPagePdfPerSlide() {
354 const buffers: Buffer[] = []
355 const genPdfBuffer = async (i: number, clicks?: string) => {
356 await go(i, clicks)
357 const pdf = await page.pdf({
358 width,
359 height,
360 margin: {
361 left: 0,
362 top: 0,
363 right: 0,
364 bottom: 0,
365 },
366 pageRanges: '1',
367 printBackground: true,
368 preferCSSPageSize: true,
369 })
370 buffers.push(pdf)
371 }
372 let idx = 0
373 for (const i of pages) {
374 await genPageWithClicks(genPdfBuffer, i)
375 progress.update(++idx)
376 }
377
378 let mergedPdf = await PDFDocument.create({})
379 for (const pdfBytes of buffers) {
380 const pdf = await PDFDocument.load(pdfBytes)
381 const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices())
382 copiedPages.forEach((page) => {
383 mergedPdf.addPage(page)
384 })
385 }
386
387 // Edit generated PDF: add metadata and (optionally) TOC
388 addPdfMetadata(mergedPdf)
389
390 if (withToc)
391 mergedPdf = await addTocToPdf(mergedPdf)
392
393 const buffer = await mergedPdf.save()
394 await fs.writeFile(output, buffer)
395 }
396
397 async function genPagePdfOnePiece() {
398 await go('print')
399 await page.pdf({
400 path: output,
401 width,
402 height,
403 margin: {
404 left: 0,
405 top: 0,
406 right: 0,
407 bottom: 0,
408 },
409 printBackground: true,
410 preferCSSPageSize: true,
411 })
412
413 // Edit generated PDF: add metadata and (optionally) TOC
414 let pdfData = await fs.readFile(output)
415 let pdf = await PDFDocument.load(pdfData)
416
417 addPdfMetadata(pdf)
418
419 if (withToc)
420 pdf = await addTocToPdf(pdf)
421
422 pdfData = Buffer.from(await pdf.save())
423 await fs.writeFile(output, pdfData)
424 }
425
426 async function genPagePngOnePiece(writeToDisk: string | false) {
427 const result: ExportPngResult[] = []
428 await go('print')
429 const slideContainers = page.locator('.print-slide-container')
430 const count = await slideContainers.count()
431
432 for (let i = 0; i < count; i++) {
433 const id = (await slideContainers.nth(i).getAttribute('id')) || ''
434 const slideNo = +id.split('-')[0]
435
436 // Only process slides that are in the specified range
437 if (!pages.includes(slideNo))
438 continue
439
440 progress.update(result.length + 1)
441
442 const buffer = await slideContainers.nth(i).screenshot({
443 omitBackground,
444 })
445 const filename = `${withClicks ? id : slideNo}.png`
446 result.push({ slideIndex: slideNo - 1, buffer, filename })
447 if (writeToDisk)
448 await fs.writeFile(path.join(writeToDisk, filename), buffer)
449 }
450 return result
451 }
452
453 async function genPagePngPerSlide(writeToDisk: string | false) {
454 const result: ExportPngResult[] = []
455 const genScreenshot = async (no: number, clicks?: string) => {
456 await go(no, clicks)
457 const buffer = await page.screenshot({
458 omitBackground,
459 })
460 const filename = `${no.toString().padStart(2, '0')}${clicks ? `-${clicks}` : ''}.png`
461 result.push({ slideIndex: no - 1, buffer, filename })
462 if (writeToDisk) {
463 await fs.writeFile(
464 path.join(writeToDisk, filename),
465 buffer,
466 )
467 }
468 }
469 for (const no of pages)
470 await genPageWithClicks(genScreenshot, no)
471 return result
472 }
473
474 function genPagePdf() {
475 if (!output.endsWith('.pdf'))
476 output = `${output}.pdf`
477 return perSlide
478 ? genPagePdfPerSlide()
479 : genPagePdfOnePiece()
480 }
481
482 async function genPagePng(writeToDisk: string | false, cleanOutput = true) {
483 if (writeToDisk) {
484 if (cleanOutput)
485 await fs.rm(writeToDisk, { force: true, recursive: true })
486 await fs.mkdir(writeToDisk, { recursive: true })
487 }
488 return perSlide
489 ? genPagePngPerSlide(writeToDisk)
490 : genPagePngOnePiece(writeToDisk)
491 }
492
493 async function genPageMd() {
494 const pngs = await genPagePng(dirname(output), false)
495 const content = slides
496 .filter(({ index }) => pages.includes(index + 1))
497 .map(({ title, index, note }) =>
498 pngs.filter(({ slideIndex }) => slideIndex === index)
499 .map(({ filename }) => `![${title || (index + 1)}](./${filename})\n\n`)
500 .join('')
501 + (note ? `${note.trim()}\n\n` : ''),
502 )
503 .join('---\n\n')
504 await fs.writeFile(ensureSuffix('.md', output), content)
505 }
506
507 // Ported from https://github.com/marp-team/marp-cli/blob/main/src/converter.ts
508 async function genPagePptx(pngs: ExportPngResult[]) {
509 const { default: PptxGenJS } = await import('pptxgenjs')
510 const pptx = new PptxGenJS()
511
512 const layoutName = `${width}x${height}`
513 pptx.defineLayout({
514 name: layoutName,
515 width: width / 96,
516 height: height / 96,
517 })
518 pptx.layout = layoutName
519
520 const titleSlide = slides[0]
521 pptx.author = titleSlide?.frontmatter?.author
522 pptx.company = 'Created using Slidev'
523 if (titleSlide?.title)
524 pptx.title = titleSlide?.title
525 if (titleSlide?.frontmatter?.info)
526 pptx.subject = titleSlide?.frontmatter?.info
527
528 pngs.forEach(({ slideIndex, buffer }) => {
529 const slide = pptx.addSlide()
530 slide.background = {
531 data: `data:image/png;base64,${buffer.toString('base64')}`,
532 }
533
534 const note = slides[slideIndex].note
535 if (note)
536 slide.addNotes(note)
537 })
538
539 const buffer = await pptx.write({
540 outputType: 'nodebuffer',
541 }) as Buffer
542 if (!output.endsWith('.pptx'))
543 output = `${output}.pptx`
544 await fs.writeFile(output, buffer)
545 }
546
547 // Adds metadata (title, author, keywords) to PDF document, mutating it
548 function addPdfMetadata(pdf: PDFDocument): void {
549 const titleSlide = slides[0]
550 if (titleSlide?.title)
551 pdf.setTitle(titleSlide.title)
552 if (titleSlide?.frontmatter?.info)
553 pdf.setSubject(titleSlide.frontmatter.info)
554 if (titleSlide?.frontmatter?.author)
555 pdf.setAuthor(titleSlide.frontmatter.author)
556 if (titleSlide?.frontmatter?.keywords) {
557 if (Array.isArray(titleSlide?.frontmatter?.keywords))
558 pdf.setKeywords(titleSlide?.frontmatter?.keywords)
559 else
560 pdf.setKeywords(titleSlide?.frontmatter?.keywords.split(','))
561 }
562 }
563
564 async function addTocToPdf(pdf: PDFDocument): Promise<PDFDocument> {
565 const outlinePdf = outlinePdfFactory(pdfLib)
566 const slideIndexes = await getSlidesIndex()
567
568 const tocTree = slides.filter(slide => slide.title)
569 .reduce((acc: TocItem[], slide) => {
570 addToTree(acc, slide, slideIndexes)
571 return acc
572 }, [])
573
574 const outline = makeOutline(tocTree)
575
576 return await outlinePdf({ outline, pdf })
577 }
578 }
579
580 export function getExportOptions(args: ExportArgs, options: ResolvedSlidevOptions, outFilename?: string): Omit<ExportOptions, 'port' | 'base'> {
581 const config = {
582 ...options.data.config.export,
583 ...args,
584 ...clearUndefined({
585 waitUntil: args['wait-until'],
586 withClicks: args['with-clicks'],
587 executablePath: args['executable-path'],
588 withToc: args['with-toc'],
589 perSlide: args['per-slide'],
590 omitBackground: args['omit-background'],
591 }),
592 }
593 const {
594 entry,
595 output,
596 format,
597 timeout,
598 wait,
599 waitUntil,
600 range,
601 dark,
602 withClicks,
603 executablePath,
604 withToc,
605 perSlide,
606 scale,
607 omitBackground,
608 } = config
609 outFilename = output || outFilename || options.data.config.exportFilename || `${path.basename(entry, '.md')}-export`
610 return {
611 output: outFilename,
612 slides: options.data.slides,
613 total: options.data.slides.length,
614 range,
615 format: (format || 'pdf') as 'pdf' | 'png' | 'pptx' | 'md',
616 timeout: timeout ?? 30000,
617 wait: wait ?? 0,
618 waitUntil: waitUntil === 'none' ? undefined : (waitUntil ?? 'networkidle') as 'networkidle' | 'load' | 'domcontentloaded',
619 dark: dark || options.data.config.colorSchema === 'dark',
620 // Export navigates by URL; memory routing ignores the URL, so fall back to history.
621 routerMode: options.data.config.routerMode === 'memory' ? 'history' : options.data.config.routerMode,
622 width: options.data.config.canvasWidth,
623 height: Math.round(options.data.config.canvasWidth / options.data.config.aspectRatio),
624 withClicks: withClicks ?? format === 'pptx',
625 executablePath,
626 withToc: withToc || false,
627 perSlide: perSlide || false,
628 scale: scale || 2,
629 omitBackground: omitBackground ?? false,
630 }
631 }
632
633 async function importPlaywright(): Promise<typeof import('playwright-chromium')> {
634 const { userRoot, userWorkspaceRoot } = await getRoots()
635
636 // 1. resolve from user root
637 try {
638 return await import(await resolve('playwright-chromium', { url: userRoot }))
639 }
640 catch { }
641
642 // 2. resolve from user workspace root
643 if (userWorkspaceRoot !== userRoot) {
644 try {
645 return await import(await resolve('playwright-chromium', { url: userWorkspaceRoot }))
646 }
647 catch { }
648 }
649
650 // 3. resolve from global registry
651 const { resolveGlobal } = await import('resolve-global')
652 try {
653 const imported = await import(resolveGlobal('playwright-chromium'))
654 return imported.default ?? imported
655 }
656 catch { }
657
658 // 4. resolve from current @slidev/cli installation
659 try {
660 return await import('playwright-chromium')
661 }
662 catch { }
663
664 throw new Error('The exporting for Slidev is powered by Playwright, please install it via `npm i -D playwright-chromium`')
665 }
666
666 lines TYPESCRIPT