返回 slidev
build.ts
根目录 / packages / slidev / node / commands / pptx / build.ts
1 /**
2 * `SlideIr[]` to a `.pptx` buffer, through `pptxgenjs`. Pure: no DOM,
3 * Playwright or filesystem, so every rule is unit-testable by building a
4 * fixture and unzipping the result.
5 */
6
7 import type { Buffer } from 'node:buffer'
8 import type PptxGenJS from 'pptxgenjs'
9 import type {
10 Border,
11 IrBox,
12 IrImage,
13 IrRaster,
14 IrRun,
15 IrText,
16 Rgba,
17 SlideIr,
18 } from './ir'
19 import { INCHES_PER_PX, PT_PER_PX } from './ir'
20
21 /**
22 * Extra width beyond the measured glyph bounds: PowerPoint sets the same
23 * string slightly wider than Chromium, so a tight single-line box re-wraps.
24 * Single-line boxes get the generous value and `wrap: false`; multi-line
25 * boxes re-wrap anyway, and widening them pushes into the next column.
26 */
27 const SLACK_SINGLE_LINE_PX = 12
28 const SLACK_MULTI_LINE_PX = 2
29
30 function hex(color: Rgba): string {
31 const channel = (v: number) => Math.max(0, Math.min(255, Math.round(v)))
32 .toString(16)
33 .padStart(2, '0')
34 .toUpperCase()
35 return `${channel(color.r)}${channel(color.g)}${channel(color.b)}`
36 }
37
38 /**
39 * CSS alpha (0 opaque..1) to PowerPoint transparency (0 opaque..100), or
40 * undefined when fully opaque: `pptxgenjs` tests the field for truthiness.
41 */
42 function transparency(color: Rgba): number | undefined {
43 if (color.a >= 1)
44 return undefined
45 return Math.round((1 - color.a) * 100)
46 }
47
48 /** Canvas pixels to inches, which is what every `pptxgenjs` coordinate wants. */
49 function inch(px: number): number {
50 return px * INCHES_PER_PX
51 }
52
53 /** Canvas pixels to points, which is what every `pptxgenjs` type size wants. */
54 function pt(px: number): number {
55 return px * PT_PER_PX
56 }
57
58 function runProps(run: IrRun): Record<string, unknown> {
59 const options: Record<string, unknown> = {
60 fontFace: run.fontFamily,
61 fontSize: pt(run.fontSize),
62 }
63 if (run.bold)
64 options.bold = true
65 if (run.italic)
66 options.italic = true
67 if (run.strike)
68 options.strike = true
69 if (run.underline)
70 options.underline = { style: run.underlineStyle ?? 'sng' }
71 else if (run.link)
72 // pptxgenjs underlines every hyperlink run unless told otherwise. CSS said
73 // not to: a theme that rules its links with a `border-bottom` already has
74 // that drawn as its own shape, so the default added a second line.
75 options.underline = { style: 'none' }
76 if (run.color) {
77 options.color = hex(run.color)
78 const alpha = transparency(run.color)
79 if (alpha !== undefined)
80 options.transparency = alpha
81 }
82 if (run.letterSpacing)
83 options.charSpacing = pt(run.letterSpacing)
84 if (run.link)
85 options.hyperlink = { url: run.link }
86 if (run.endsParagraph)
87 options.breakLine = true
88 // `softBreakBefore` emits a real <a:br/> inside one paragraph; a \v in the
89 // text renders in PowerPoint but not in Keynote or LibreOffice.
90 if (run.breakBefore)
91 options.softBreakBefore = true
92 return options
93 }
94
95 function addText(slide: PptxGenJS.Slide, node: IrText): void {
96 const slack = node.lineCount === 1 ? SLACK_SINGLE_LINE_PX : SLACK_MULTI_LINE_PX
97
98 // Widening a box moves its content unless the origin moves too: a centered
99 // line would drift right by half the slack, a right-aligned one by all of it.
100 let x = node.rect.x
101 if (node.align === 'center')
102 x -= slack / 2
103 else if (node.align === 'right')
104 x -= slack
105
106 slide.addText(
107 node.runs.map(run => ({ text: run.text, options: runProps(run) })),
108 {
109 x: inch(x),
110 y: inch(node.rect.y),
111 w: inch(node.rect.w + slack),
112 h: inch(node.rect.h),
113 align: node.align,
114 // PowerPoint's default inset is 0.05in/0.1in; the IR position is glyph
115 // bounds, so any inset here is a visible offset.
116 margin: 0,
117 // The default 'middle' would center a one-line box measured from the glyphs.
118 valign: node.valign ?? 'top',
119 // Autofit would rescale type the moment PowerPoint disagrees about metrics.
120 fit: 'none',
121 isTextBox: true,
122 wrap: node.lineCount > 1,
123 // Only for multi-line text: a single-line box is measured from glyph ink,
124 // shorter than the CSS line box, and the taller value clips descenders.
125 ...(node.lineCount > 1 ? { lineSpacing: pt(node.lineHeight) } : {}),
126 },
127 )
128 }
129
130 /**
131 * One side of a border, as its own filled rectangle: `pptxgenjs` gives a shape
132 * one uniform `line`, and `addShape(LINE)` centers its stroke on the geometry,
133 * putting half the border outside the element box.
134 */
135 function addEdge(slide: PptxGenJS.Slide, shapeType: typeof PptxGenJS.ShapeType, box: IrBox, side: 0 | 1 | 2 | 3, border: Border): void {
136 const { x, y, w, h } = box.rect
137 const t = border.width
138 const rect
139 = side === 0
140 ? { x, y, w, h: t }
141 : side === 1
142 ? { x: x + w - t, y, w: t, h }
143 : side === 2
144 ? { x, y: y + h - t, w, h: t }
145 : { x, y, w: t, h }
146
147 // A filled rectangle cannot carry a dash pattern, so a dashed or dotted rule
148 // came out solid: Slidev rules its links this way, and every link in a deck
149 // gained a solid bar. A line can be dashed, and for a hairline the half
150 // stroke that falls outside the box is well under a pixel.
151 if (border.style !== 'solid') {
152 slide.addShape(shapeType.line, {
153 x: inch(rect.x),
154 y: inch(rect.y),
155 w: inch(side === 0 || side === 2 ? rect.w : 0),
156 h: inch(side === 0 || side === 2 ? 0 : rect.h),
157 line: {
158 color: hex(border.color),
159 transparency: transparency(border.color),
160 width: pt(border.width),
161 dashType: border.style === 'dotted' ? 'sysDot' : 'dash',
162 },
163 })
164 return
165 }
166
167 slide.addShape(shapeType.rect, {
168 x: inch(rect.x),
169 y: inch(rect.y),
170 w: inch(rect.w),
171 h: inch(rect.h),
172 fill: { color: hex(border.color), transparency: transparency(border.color) },
173 })
174 }
175
176 function sameBorder(a?: Border, b?: Border): boolean {
177 if (!a || !b)
178 return a === b
179 return a.width === b.width && a.style === b.style && hex(a.color) === hex(b.color)
180 && a.color.a === b.color.a
181 }
182
183 function addBox(slide: PptxGenJS.Slide, shapeType: typeof PptxGenJS.ShapeType, node: IrBox): void {
184 const borders = node.borders
185 const uniform
186 = borders
187 && borders[0]
188 && sameBorder(borders[0], borders[1])
189 && sameBorder(borders[1], borders[2])
190 && sameBorder(borders[2], borders[3])
191
192 // With no fill and no uniform border there is nothing for this shape to
193 // carry: its only border is drawn as its own edge below. Emitting it anyway
194 // left a rectangle with neither fill nor outline specified, which PowerPoint
195 // resolves from its default shape style rather than leaving blank.
196 if (!node.fill && !uniform && !node.shadow) {
197 for (const side of [0, 1, 2, 3] as const) {
198 const border = borders?.[side]
199 if (border && border.width > 0)
200 addEdge(slide, shapeType, node, side, border)
201 }
202 return
203 }
204
205 const options: Record<string, unknown> = {
206 x: inch(node.rect.x),
207 y: inch(node.rect.y),
208 w: inch(node.rect.w),
209 h: inch(node.rect.h),
210 }
211
212 // `fill` and `line` are omitted rather than set to `{ type: 'none' }`.
213 // pptxgenjs writes `<a:noFill/>` for an ABSENT fill and nothing at all for
214 // that object, so the explicit-looking version was the one that inherited.
215 if (node.fill)
216 options.fill = { color: hex(node.fill), transparency: transparency(node.fill) }
217
218 if (uniform && borders[0]) {
219 options.line = {
220 color: hex(borders[0].color),
221 width: pt(borders[0].width),
222 dashType: borders[0].style === 'solid' ? 'solid' : borders[0].style === 'dotted' ? 'sysDot' : 'dash',
223 // `ShapeLineProps extends ShapeFillProps`: without `transparency` a
224 // hairline set in `rgba(0, 0, 0, 0.1)` comes out solid black.
225 transparency: transparency(borders[0].color),
226 }
227 }
228
229 if (node.shadow) {
230 options.shadow = {
231 type: 'outer',
232 blur: pt(node.shadow.blur),
233 offset: pt(node.shadow.offset),
234 angle: node.shadow.angle,
235 color: hex(node.shadow.color),
236 opacity: node.shadow.color.a,
237 }
238 }
239
240 if (node.radius) {
241 // `rectRadius` is documented as "values: 0.0 to 1.0" but is an inch
242 // measurement: the runtime multiplies it by EMU before dividing by the
243 // shorter side, so a value in the documented range rounds to zero.
244 const shorter = Math.min(node.rect.w, node.rect.h)
245 options.rectRadius = inch(Math.min(node.radius, shorter / 2))
246 slide.addShape(shapeType.roundRect, options)
247 }
248 else {
249 slide.addShape(shapeType.rect, options)
250 }
251
252 if (borders && !uniform) {
253 for (const side of [0, 1, 2, 3] as const) {
254 const border = borders[side]
255 if (border && border.width > 0)
256 addEdge(slide, shapeType, node, side, border)
257 }
258 }
259 }
260
261 function addPicture(slide: PptxGenJS.Slide, node: IrImage | IrRaster): void {
262 const options: Record<string, unknown> = {
263 data: node.data,
264 x: inch(node.rect.x),
265 y: inch(node.rect.y),
266 w: inch(node.rect.w),
267 h: inch(node.rect.h),
268 }
269 if (node.kind === 'image') {
270 if (node.alt)
271 options.altText = node.alt
272 if (node.link)
273 options.hyperlink = { url: node.link }
274 // `sizing.crop` reads `w`/`h` as the picture's full display size and the
275 // visible window from `sizing`, the opposite of every other option here;
276 // `<a:srcRect>` trims the rest away.
277 if (node.crop) {
278 options.w = inch(node.crop.w)
279 options.h = inch(node.crop.h)
280 options.sizing = {
281 type: 'crop',
282 x: inch(node.crop.x),
283 y: inch(node.crop.y),
284 w: inch(node.rect.w),
285 h: inch(node.rect.h),
286 }
287 }
288 }
289 else {
290 // The reason is the most useful alt text a rasterized element can carry.
291 options.altText = `Rendered as an image because of CSS ${node.reason}`
292 }
293 slide.addImage(options)
294 }
295
296 export interface BuildOptions {
297 /** Canvas width in pixels. The slide becomes `width / 96` inches wide. */
298 width: number
299 /** Canvas height in pixels. */
300 height: number
301 title?: string
302 author?: string
303 subject?: string
304 }
305
306 /**
307 * Build the deck. The constructor is passed in so the caller keeps its dynamic
308 * `import('pptxgenjs')`, which keeps the library off the CLI's startup path.
309 */
310 export async function buildPptx(
311 Pptx: typeof PptxGenJS,
312 slides: SlideIr[],
313 options: BuildOptions,
314 ): Promise<Buffer> {
315 const pptx = new Pptx()
316
317 // Same layout the image exporter defines; changing it here would silently
318 // change `--format pptx` output too.
319 const layoutName = `${options.width}x${options.height}`
320 pptx.defineLayout({
321 name: layoutName,
322 width: inch(options.width),
323 height: inch(options.height),
324 })
325 pptx.layout = layoutName
326
327 pptx.company = 'Created using Slidev'
328 if (options.title)
329 pptx.title = options.title
330 if (options.author)
331 pptx.author = options.author
332 if (options.subject)
333 pptx.subject = options.subject
334
335 for (const ir of slides) {
336 const slide = pptx.addSlide()
337
338 if (ir.fallbackPng) {
339 // Exactly what `--format pptx` produces, for this slide only: a theme
340 // the walker cannot handle degrades to today's behavior.
341 slide.background = { data: ir.fallbackPng }
342 }
343 else {
344 if (ir.background) {
345 slide.background = {
346 color: hex(ir.background),
347 transparency: transparency(ir.background),
348 }
349 }
350 for (const node of ir.nodes) {
351 switch (node.kind) {
352 case 'box':
353 addBox(slide, pptx.ShapeType, node)
354 break
355 case 'text':
356 addText(slide, node)
357 break
358 case 'image':
359 case 'raster':
360 addPicture(slide, node)
361 break
362 }
363 }
364 }
365
366 if (ir.note)
367 slide.addNotes(ir.note)
368 }
369
370 return await pptx.write({ outputType: 'nodebuffer' }) as Buffer
371 }
372
372 lines TYPESCRIPT