返回 slidev
index.ts
根目录 / packages / slidev / node / commands / pptx / index.ts
1 import type { SlideInfo } from '@slidev/types'
2 import type { Page } from 'playwright-chromium'
3 import fs from 'node:fs/promises'
4 import { dim, yellow } from 'ansis'
5 import { buildPptx } from './build'
6 import { capture, ID_ATTRIBUTE } from './capture'
7 import { normalize } from './normalize'
8 import { collectSnapshot } from './walker'
9
10 /** Everything the editable exporter needs from `exportSlides`, as an explicit object rather than closure scope. */
11 export interface PptxExportContext {
12 page: Page
13 slides: SlideInfo[]
14 /** Canvas width in pixels. */
15 width: number
16 /** Canvas height in pixels. */
17 height: number
18 /** The 1-based slide numbers `--range` selected; the same defensive filter the image exporter carries. */
19 pages: number[]
20 /** Navigate and wait for the deck to settle. `exportSlides` owns this. */
21 go: (no: number | string, clicks?: string) => Promise<void>
22 }
23
24 export interface EditableExportResult {
25 slideCount: number
26 fallbackSlides: { no: number, reason: string }[]
27 fontsNamed: string[]
28 /** Images that could be neither fetched nor screenshotted. */
29 imagesDropped: number
30 /** Captures that asked for isolation and could not find their element. */
31 isolationMissed: number
32 /** Elements whose screenshot failed, so they are missing from the file. */
33 rastersFailed: number
34 /** Color strings no parser understood, so the user can report them. */
35 unparsedColors: string[]
36 /** Decorative pseudo-elements whose box could not be resolved. */
37 unplaceablePseudos: string[]
38 /** The path actually written, extension included. */
39 output: string
40 }
41
42 /**
43 * Export the deck as PowerPoint with native shapes and editable text. One
44 * navigation covers everything: the print route renders every slide and click
45 * step into a single tall page, so there is no per-slide loop here and
46 * `--with-clicks` needs no special handling.
47 */
48 export async function exportPptxEditable(
49 ctx: PptxExportContext,
50 output: string,
51 ): Promise<EditableExportResult> {
52 await ctx.go('print')
53
54 // Measurement and capture are separate passes, so anything still animating is
55 // measured at one frame and photographed at another. Pausing keeps the settled
56 // end state; `animation: none` would rewind a fade-in to opacity zero.
57 await ctx.page.addStyleTag({
58 content: '*, *::before, *::after { animation-play-state: paused !important; transition: none !important; }',
59 })
60
61 const snapshot = await ctx.page.evaluate(collectSnapshot, {
62 containerSelector: '.print-slide-container',
63 idAttribute: ID_ATTRIBUTE,
64 })
65
66 // Filter before normalizing, so leftover pages cost no measurement or screenshots.
67 snapshot.slides = snapshot.slides.filter(slide => ctx.pages.includes(slide.no))
68
69 const notes = new Map<number, string | undefined>()
70 ctx.slides.forEach((slide, index) => notes.set(index + 1, slide.note))
71
72 const { slides, rasterRequests, unparsedColors } = normalize(snapshot, { notes })
73 const report = await capture(ctx.page, slides, rasterRequests)
74
75 const title = ctx.slides[0]
76 // A bundler or CJS interop layer can hand back the constructor itself rather
77 // than a namespace with `default` on it.
78 const pptxgenjs = await import('pptxgenjs')
79 const buffer = await buildPptx(
80 pptxgenjs.default ?? (pptxgenjs as unknown as typeof pptxgenjs.default),
81 slides,
82 {
83 width: ctx.width,
84 height: ctx.height,
85 title: title?.title,
86 author: title?.frontmatter?.author,
87 subject: title?.frontmatter?.info,
88 },
89 )
90
91 // Return the path actually written: `exportSlides` prints the path it was given.
92 const written = output.endsWith('.pptx') ? output : `${output}.pptx`
93 await fs.writeFile(written, buffer)
94
95 return {
96 output: written,
97 slideCount: slides.length,
98 fallbackSlides: report.fallbackSlides,
99 imagesDropped: report.imagesDropped,
100 isolationMissed: report.isolationMissed,
101 rastersFailed: report.rastersFailed,
102 unparsedColors,
103 unplaceablePseudos: [...new Set(snapshot.unplaceablePseudos ?? [])].sort(),
104 // A pptx names fonts, it does not carry them; report what recipients need installed.
105 fontsNamed: [...new Set(Object.values(snapshot.fontResolution).filter(Boolean))].sort(),
106 }
107 }
108
109 /** Print what the export could not do, after the progress bar has stopped. */
110 export function reportEditableExport(result: EditableExportResult): void {
111 for (const slide of result.fallbackSlides)
112 console.warn(yellow(` slide ${slide.no}: exported as an image (${slide.reason})`))
113 if (result.imagesDropped)
114 console.warn(yellow(` ${result.imagesDropped} image(s) could not be read and were left out`))
115 if (result.rastersFailed)
116 console.warn(yellow(` ${result.rastersFailed} element(s) could not be captured as a picture and were left out`))
117 if (result.unparsedColors.length) {
118 console.warn(yellow(` ${result.unparsedColors.length} color value(s) could not be read, so those fills are missing:`))
119 console.warn(dim(` ${result.unparsedColors.slice(0, 5).join(', ')}`))
120 }
121 if (result.isolationMissed)
122 console.warn(yellow(` ${result.isolationMissed} picture(s) could not be isolated, so their slide may show doubled text`))
123 if (result.unplaceablePseudos.length) {
124 console.warn(yellow(` ${result.unplaceablePseudos.length} CSS decoration(s) could not be placed and were left out:`))
125 console.warn(dim(` ${result.unplaceablePseudos.slice(0, 5).join(', ')}`))
126 }
127 if (result.fontsNamed.length) {
128 console.warn(dim(` fonts named in this file: ${result.fontsNamed.join(', ')}`))
129 console.warn(dim(' a .pptx names fonts rather than embedding them, so recipients need these installed'))
130 }
131 }
132
132 lines TYPESCRIPT