返回 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' | 'pptx-editable' | '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 if (format === 'pptx-editable') {
225 await genPagePptxEditable()
226 }
227 else {
228 throw new Error(`[slidev] Unsupported exporting format "${format}"`)
229 }
230 }
231 finally {
232 progress.stop()
233 await browser.close()
234 }
235
236 const relativeOutput = slash(relative('.', output))
237 return relativeOutput.startsWith('.') ? relativeOutput : `./${relativeOutput}`
238
239 async function go(no: number | string, clicks?: string) {
240 const query = new URLSearchParams()
241 if (withClicks)
242 query.set('print', 'clicks')
243 else
244 query.set('print', 'true')
245 if (range)
246 query.set('range', range)
247 if (clicks)
248 query.set('clicks', clicks)
249
250 const url = routerMode === 'hash'
251 ? `http://localhost:${port}${base}?${query}#${no}`
252 : `http://localhost:${port}${base}${no}?${query}`
253 await page.goto(url, {
254 waitUntil,
255 timeout,
256 })
257 if (waitUntil)
258 await page.waitForLoadState(waitUntil)
259 await page.emulateMedia({ colorScheme: dark ? 'dark' : 'light', media: 'screen' })
260 const slide = no === 'print'
261 ? page.locator('body')
262 : page.locator(`[data-slidev-no="${no}"]`)
263 await slide.waitFor()
264
265 // Wait for slides to be loaded
266 {
267 const elements = slide.locator('.slidev-slide-loading')
268 const count = await elements.count()
269 for (let index = 0; index < count; index++)
270 await elements.nth(index).waitFor({ state: 'detached' })
271 }
272 // Check for "data-waitfor" attribute and wait for given element to be loaded
273 {
274 const elements = slide.locator('[data-waitfor]')
275 const count = await elements.count()
276 for (let index = 0; index < count; index++) {
277 const element = elements.nth(index)
278 const attribute = await element.getAttribute('data-waitfor')
279 if (attribute) {
280 await element.locator(attribute).waitFor({ state: 'visible' }).catch((e) => {
281 console.error(e)
282 process.exitCode = 1
283 })
284 }
285 }
286 }
287 // Wait for frames to load
288 {
289 const frames = page.frames()
290 await Promise.all(frames.map(frame => frame.waitForLoadState(undefined, { timeout })))
291 }
292 // Wait for Mermaid graphs to be rendered
293 {
294 const container = slide.locator('#mermaid-rendering-container')
295 const count = await container.count()
296 if (count > 0) {
297 while (true) {
298 const element = container.locator('div').first()
299 if (await element.count() === 0)
300 break
301 await element.waitFor({ state: 'detached' })
302 }
303 await container.evaluate(node => node.style.display = 'none')
304 }
305 }
306 // Hide Monaco aria container
307 {
308 const elements = slide.locator('.monaco-aria-container')
309 const count = await elements.count()
310 for (let index = 0; index < count; index++) {
311 const element = elements.nth(index)
312 await element.evaluate(node => node.style.display = 'none')
313 }
314 }
315 // Wait for the given time
316 if (wait)
317 await page.waitForTimeout(wait)
318 }
319
320 async function getSlidesIndex() {
321 const clicksBySlide: Record<string, number> = {}
322 const slides = page.locator('.print-slide-container')
323 const count = await slides.count()
324 for (let i = 0; i < count; i++) {
325 const id = (await slides.nth(i).getAttribute('id')) || ''
326 const path = Number(id.split('-')[0])
327 clicksBySlide[path] = (clicksBySlide[path] || 0) + 1
328 }
329
330 const slideIndexes = Object.fromEntries(Object.entries(clicksBySlide)
331 .reduce<[string, number][]>((acc, [path, clicks], i) => {
332 acc.push([path, clicks + (acc[i - 1]?.[1] ?? 0)])
333 return acc
334 }, []))
335 return slideIndexes
336 }
337
338 function getClicksFromUrl(url: string) {
339 return url.match(RE_CLICKS_PARAM)?.[1]
340 }
341
342 async function genPageWithClicks(
343 fn: (no: number, clicks?: string) => Promise<any>,
344 no: number,
345 clicks?: string,
346 ) {
347 await fn(no, clicks)
348 if (withClicks) {
349 await page.keyboard.press('ArrowRight', { delay: 100 })
350 const _clicks = getClicksFromUrl(page.url())
351 if (_clicks && clicks !== _clicks)
352 await genPageWithClicks(fn, no, _clicks)
353 }
354 }
355
356 async function genPagePdfPerSlide() {
357 const buffers: Buffer[] = []
358 const genPdfBuffer = async (i: number, clicks?: string) => {
359 await go(i, clicks)
360 const pdf = await page.pdf({
361 width,
362 height,
363 margin: {
364 left: 0,
365 top: 0,
366 right: 0,
367 bottom: 0,
368 },
369 pageRanges: '1',
370 printBackground: true,
371 preferCSSPageSize: true,
372 })
373 buffers.push(pdf)
374 }
375 let idx = 0
376 for (const i of pages) {
377 await genPageWithClicks(genPdfBuffer, i)
378 progress.update(++idx)
379 }
380
381 let mergedPdf = await PDFDocument.create({})
382 for (const pdfBytes of buffers) {
383 const pdf = await PDFDocument.load(pdfBytes)
384 const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices())
385 copiedPages.forEach((page) => {
386 mergedPdf.addPage(page)
387 })
388 }
389
390 // Edit generated PDF: add metadata and (optionally) TOC
391 addPdfMetadata(mergedPdf)
392
393 if (withToc)
394 mergedPdf = await addTocToPdf(mergedPdf)
395
396 const buffer = await mergedPdf.save()
397 await fs.writeFile(output, buffer)
398 }
399
400 async function genPagePdfOnePiece() {
401 await go('print')
402 await page.pdf({
403 path: output,
404 width,
405 height,
406 margin: {
407 left: 0,
408 top: 0,
409 right: 0,
410 bottom: 0,
411 },
412 printBackground: true,
413 preferCSSPageSize: true,
414 })
415
416 // Edit generated PDF: add metadata and (optionally) TOC
417 let pdfData = await fs.readFile(output)
418 let pdf = await PDFDocument.load(pdfData)
419
420 addPdfMetadata(pdf)
421
422 if (withToc)
423 pdf = await addTocToPdf(pdf)
424
425 pdfData = Buffer.from(await pdf.save())
426 await fs.writeFile(output, pdfData)
427 }
428
429 async function genPagePngOnePiece(writeToDisk: string | false) {
430 const result: ExportPngResult[] = []
431 await go('print')
432 const slideContainers = page.locator('.print-slide-container')
433 const count = await slideContainers.count()
434
435 for (let i = 0; i < count; i++) {
436 const id = (await slideContainers.nth(i).getAttribute('id')) || ''
437 const slideNo = +id.split('-')[0]
438
439 // Only process slides that are in the specified range
440 if (!pages.includes(slideNo))
441 continue
442
443 progress.update(result.length + 1)
444
445 const buffer = await slideContainers.nth(i).screenshot({
446 omitBackground,
447 })
448 const filename = `${withClicks ? id : slideNo}.png`
449 result.push({ slideIndex: slideNo - 1, buffer, filename })
450 if (writeToDisk)
451 await fs.writeFile(path.join(writeToDisk, filename), buffer)
452 }
453 return result
454 }
455
456 async function genPagePngPerSlide(writeToDisk: string | false) {
457 const result: ExportPngResult[] = []
458 const genScreenshot = async (no: number, clicks?: string) => {
459 await go(no, clicks)
460 const buffer = await page.screenshot({
461 omitBackground,
462 })
463 const filename = `${no.toString().padStart(2, '0')}${clicks ? `-${clicks}` : ''}.png`
464 result.push({ slideIndex: no - 1, buffer, filename })
465 if (writeToDisk) {
466 await fs.writeFile(
467 path.join(writeToDisk, filename),
468 buffer,
469 )
470 }
471 }
472 for (const no of pages)
473 await genPageWithClicks(genScreenshot, no)
474 return result
475 }
476
477 function genPagePdf() {
478 if (!output.endsWith('.pdf'))
479 output = `${output}.pdf`
480 return perSlide
481 ? genPagePdfPerSlide()
482 : genPagePdfOnePiece()
483 }
484
485 async function genPagePng(writeToDisk: string | false, cleanOutput = true) {
486 if (writeToDisk) {
487 if (cleanOutput)
488 await fs.rm(writeToDisk, { force: true, recursive: true })
489 await fs.mkdir(writeToDisk, { recursive: true })
490 }
491 return perSlide
492 ? genPagePngPerSlide(writeToDisk)
493 : genPagePngOnePiece(writeToDisk)
494 }
495
496 async function genPageMd() {
497 const pngs = await genPagePng(dirname(output), false)
498 const content = slides
499 .filter(({ index }) => pages.includes(index + 1))
500 .map(({ title, index, note }) =>
501 pngs.filter(({ slideIndex }) => slideIndex === index)
502 .map(({ filename }) => `![${title || (index + 1)}](./${filename})\n\n`)
503 .join('')
504 + (note ? `${note.trim()}\n\n` : ''),
505 )
506 .join('---\n\n')
507 await fs.writeFile(ensureSuffix('.md', output), content)
508 }
509
510 // Ported from https://github.com/marp-team/marp-cli/blob/main/src/converter.ts
511 async function genPagePptx(pngs: ExportPngResult[]) {
512 const { default: PptxGenJS } = await import('pptxgenjs')
513 const pptx = new PptxGenJS()
514
515 const layoutName = `${width}x${height}`
516 pptx.defineLayout({
517 name: layoutName,
518 width: width / 96,
519 height: height / 96,
520 })
521 pptx.layout = layoutName
522
523 const titleSlide = slides[0]
524 pptx.author = titleSlide?.frontmatter?.author
525 pptx.company = 'Created using Slidev'
526 if (titleSlide?.title)
527 pptx.title = titleSlide?.title
528 if (titleSlide?.frontmatter?.info)
529 pptx.subject = titleSlide?.frontmatter?.info
530
531 pngs.forEach(({ slideIndex, buffer }) => {
532 const slide = pptx.addSlide()
533 slide.background = {
534 data: `data:image/png;base64,${buffer.toString('base64')}`,
535 }
536
537 const note = slides[slideIndex].note
538 if (note)
539 slide.addNotes(note)
540 })
541
542 const buffer = await pptx.write({
543 outputType: 'nodebuffer',
544 }) as Buffer
545 if (!output.endsWith('.pptx'))
546 output = `${output}.pptx`
547 await fs.writeFile(output, buffer)
548 }
549
550 // Native shapes and editable text, rather than one picture per slide. The
551 // walker, normalizer and builder live in ./pptx, so this is a delegation.
552 async function genPagePptxEditable() {
553 if (perSlide) {
554 // Per-slide mode never renders the print page the measurement depends
555 // on. Failing beats writing a deck missing every slide but the first.
556 throw new Error('[slidev] `--per-slide` is not supported with `--format pptx-editable`')
557 }
558
559 const { exportPptxEditable, reportEditableExport } = await import('./pptx')
560 const result = await exportPptxEditable({ page, slides, width, height, pages, go }, output)
561 // So the "exported to ..." line names the file that was actually written.
562 output = result.output
563
564 // The progress bar repaints on a timer with the cursor hidden, so anything
565 // written while it runs is interleaved with it or overwritten.
566 progress.stop()
567 reportEditableExport(result)
568 }
569
570 // Adds metadata (title, author, keywords) to PDF document, mutating it
571 function addPdfMetadata(pdf: PDFDocument): void {
572 const titleSlide = slides[0]
573 if (titleSlide?.title)
574 pdf.setTitle(titleSlide.title)
575 if (titleSlide?.frontmatter?.info)
576 pdf.setSubject(titleSlide.frontmatter.info)
577 if (titleSlide?.frontmatter?.author)
578 pdf.setAuthor(titleSlide.frontmatter.author)
579 if (titleSlide?.frontmatter?.keywords) {
580 if (Array.isArray(titleSlide?.frontmatter?.keywords))
581 pdf.setKeywords(titleSlide?.frontmatter?.keywords)
582 else
583 pdf.setKeywords(titleSlide?.frontmatter?.keywords.split(','))
584 }
585 }
586
587 async function addTocToPdf(pdf: PDFDocument): Promise<PDFDocument> {
588 const outlinePdf = outlinePdfFactory(pdfLib)
589 const slideIndexes = await getSlidesIndex()
590
591 const tocTree = slides.filter(slide => slide.title)
592 .reduce((acc: TocItem[], slide) => {
593 addToTree(acc, slide, slideIndexes)
594 return acc
595 }, [])
596
597 const outline = makeOutline(tocTree)
598
599 return await outlinePdf({ outline, pdf })
600 }
601 }
602
603 export function getExportOptions(args: ExportArgs, options: ResolvedSlidevOptions, outFilename?: string): Omit<ExportOptions, 'port' | 'base'> {
604 const config = {
605 ...options.data.config.export,
606 ...args,
607 ...clearUndefined({
608 waitUntil: args['wait-until'],
609 withClicks: args['with-clicks'],
610 executablePath: args['executable-path'],
611 withToc: args['with-toc'],
612 perSlide: args['per-slide'],
613 omitBackground: args['omit-background'],
614 }),
615 }
616 const {
617 entry,
618 output,
619 format,
620 timeout,
621 wait,
622 waitUntil,
623 range,
624 dark,
625 withClicks,
626 executablePath,
627 withToc,
628 perSlide,
629 scale,
630 omitBackground,
631 } = config
632 outFilename = output || outFilename || options.data.config.exportFilename || `${path.basename(entry, '.md')}-export`
633 return {
634 output: outFilename,
635 slides: options.data.slides,
636 total: options.data.slides.length,
637 range,
638 format: (format || 'pdf') as 'pdf' | 'png' | 'pptx' | 'pptx-editable' | 'md',
639 timeout: timeout ?? 30000,
640 wait: wait ?? 0,
641 waitUntil: waitUntil === 'none' ? undefined : (waitUntil ?? 'networkidle') as 'networkidle' | 'load' | 'domcontentloaded',
642 dark: dark || options.data.config.colorSchema === 'dark',
643 // Export navigates by URL; memory routing ignores the URL, so fall back to history.
644 routerMode: options.data.config.routerMode === 'memory' ? 'history' : options.data.config.routerMode,
645 width: options.data.config.canvasWidth,
646 height: Math.round(options.data.config.canvasWidth / options.data.config.aspectRatio),
647 // Both pptx formats default to one slide per click step. Testing the
648 // exact string here silently collapsed click steps for the editable one.
649 withClicks: withClicks ?? !!format?.startsWith('pptx'),
650 executablePath,
651 withToc: withToc || false,
652 perSlide: perSlide || false,
653 scale: scale || 2,
654 omitBackground: omitBackground ?? false,
655 }
656 }
657
658 async function importPlaywright(): Promise<typeof import('playwright-chromium')> {
659 const { userRoot, userWorkspaceRoot } = await getRoots()
660
661 // 1. resolve from user root
662 try {
663 return await import(await resolve('playwright-chromium', { url: userRoot }))
664 }
665 catch { }
666
667 // 2. resolve from user workspace root
668 if (userWorkspaceRoot !== userRoot) {
669 try {
670 return await import(await resolve('playwright-chromium', { url: userWorkspaceRoot }))
671 }
672 catch { }
673 }
674
675 // 3. resolve from global registry
676 const { resolveGlobal } = await import('resolve-global')
677 try {
678 const imported = await import(resolveGlobal('playwright-chromium'))
679 return imported.default ?? imported
680 }
681 catch { }
682
683 // 4. resolve from current @slidev/cli installation
684 try {
685 return await import('playwright-chromium')
686 }
687 catch { }
688
689 throw new Error('The exporting for Slidev is powered by Playwright, please install it via `npm i -D playwright-chromium`')
690 }
691
691 lines TYPESCRIPT