| 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 |