| 1 | import type { Page } from 'playwright-chromium' |
| 2 | import type { IrImage, IrRaster, Rect, SlideIr } from './ir' |
| 3 | import type { RasterRequest } from './normalize' |
| 4 | import { Buffer } from 'node:buffer' |
| 5 | |
| 6 | /** |
| 7 | * The only Playwright glue in the exporter. Rasterization runs as a second phase, |
| 8 | * after every measurement is in hand, because isolating an element for a screenshot |
| 9 | * means mutating the DOM of the live app. Only inline `visibility`/`background-color` |
| 10 | * (no reflow) and the viewport height are touched; width is what `PrintContainer` |
| 11 | * scales from, so the coordinates the walker measured stay valid here. |
| 12 | */ |
| 13 | |
| 14 | export const ID_ATTRIBUTE = 'data-slidev-export-id' |
| 15 | |
| 16 | /** Marks an element this module hid, and remembers what to put back. */ |
| 17 | const RESTORE_ATTRIBUTE = 'data-slidev-export-restore' |
| 18 | |
| 19 | /** |
| 20 | * Cache the document plus every open shadow root on `window`. `isolate` and `restore` |
| 21 | * both need to see inside shadow roots (Mermaid renders into one), and rescanning the |
| 22 | * tree per capture is a full walk per picture. Built on first use, after every slide has rendered. |
| 23 | */ |
| 24 | async function installRootFinder(page: Page): Promise<void> { |
| 25 | await page.evaluate(() => { |
| 26 | if ((window as any).__slidevExportRoots) |
| 27 | return |
| 28 | let cache: (Document | ShadowRoot)[] | undefined |
| 29 | ;(window as any).__slidevExportRoots = () => { |
| 30 | if (cache) |
| 31 | return cache |
| 32 | const found: (Document | ShadowRoot)[] = [document] |
| 33 | const collect = (root: Document | ShadowRoot) => { |
| 34 | for (const el of Array.from(root.querySelectorAll('*'))) { |
| 35 | const shadow = (el as any).shadowRoot |
| 36 | if (shadow) { |
| 37 | found.push(shadow) |
| 38 | collect(shadow) |
| 39 | } |
| 40 | } |
| 41 | } |
| 42 | collect(document) |
| 43 | cache = found |
| 44 | return cache |
| 45 | } |
| 46 | }) |
| 47 | } |
| 48 | |
| 49 | /** |
| 50 | * Hide everything except the target and its ancestors. `locator.screenshot()` clips |
| 51 | * the page to the element's box rather than isolating it, so overlapping content would |
| 52 | * be captured and then drawn again as shapes. `visibility` rather than `display`: |
| 53 | * `display: none` reflows and moves the target. |
| 54 | */ |
| 55 | async function isolate(page: Page, id: number, hideDescendants: boolean): Promise<boolean> { |
| 56 | return await page.evaluate( |
| 57 | ({ id, idAttribute, restoreAttribute, hideDescendants }) => { |
| 58 | // `document.querySelector` does not pierce shadow DOM while `page.locator` does, |
| 59 | // so the screenshot succeeds even when isolation silently misses a shadow-DOM target. |
| 60 | const roots = (window as any).__slidevExportRoots() as (Document | ShadowRoot)[] |
| 61 | let target: Element | null = null |
| 62 | for (const root of roots) { |
| 63 | const hit = root.querySelector(`[${idAttribute}="${id}"]`) |
| 64 | if (hit) { |
| 65 | target = hit |
| 66 | break |
| 67 | } |
| 68 | } |
| 69 | if (!target) |
| 70 | return false |
| 71 | |
| 72 | const hide = (el: Element) => { |
| 73 | const html = el as HTMLElement |
| 74 | html.setAttribute(restoreAttribute, html.style.visibility || '') |
| 75 | html.style.visibility = 'hidden' |
| 76 | } |
| 77 | |
| 78 | // `omitBackground` only drops the browser's default backdrop; clear ancestor |
| 79 | // backgrounds too, or a mostly transparent element captures an opaque one. |
| 80 | const clear = (el: Element) => { |
| 81 | const html = el as HTMLElement |
| 82 | html.setAttribute(`${restoreAttribute}-bg`, html.style.backgroundColor || '') |
| 83 | html.style.backgroundColor = 'transparent' |
| 84 | } |
| 85 | |
| 86 | // Only correct when the descendants are redrawn as shapes afterwards; |
| 87 | // a leaf's children are its artwork. |
| 88 | if (hideDescendants) { |
| 89 | for (const child of Array.from(target.children)) |
| 90 | hide(child) |
| 91 | // A direct text child has no element to hide; make it transparent. |
| 92 | const html = target as HTMLElement |
| 93 | html.setAttribute(`${restoreAttribute}-color`, html.style.color || '') |
| 94 | html.style.color = 'transparent' |
| 95 | } |
| 96 | |
| 97 | // Climbs through shadow boundaries: `parentElement` is null at the top |
| 98 | // of a shadow tree, so a loop conditioned on it hides nothing there. |
| 99 | let node: Element = target |
| 100 | for (;;) { |
| 101 | const parent: HTMLElement | null = node.parentElement |
| 102 | if (parent) { |
| 103 | for (const sibling of Array.from(parent.children)) { |
| 104 | if (sibling !== node) |
| 105 | hide(sibling) |
| 106 | } |
| 107 | // The target keeps its own background: a backdrop is the thing captured. |
| 108 | clear(parent) |
| 109 | node = parent |
| 110 | continue |
| 111 | } |
| 112 | const root = node.getRootNode() as ShadowRoot |
| 113 | if (!root || !root.host) |
| 114 | break |
| 115 | for (const sibling of Array.from(root.children)) { |
| 116 | if (sibling !== node) |
| 117 | hide(sibling) |
| 118 | } |
| 119 | node = root.host |
| 120 | } |
| 121 | return true |
| 122 | }, |
| 123 | { id, idAttribute: ID_ATTRIBUTE, restoreAttribute: RESTORE_ATTRIBUTE, hideDescendants }, |
| 124 | ) |
| 125 | } |
| 126 | |
| 127 | /** |
| 128 | * Put back everything `isolate` hid. Driven off an attribute rather than a remembered |
| 129 | * list, so it is correct even if a previous restore was interrupted. Runs in a |
| 130 | * `finally`: a half-hidden deck corrupts every capture after it. |
| 131 | */ |
| 132 | async function restore(page: Page): Promise<void> { |
| 133 | await page.evaluate((restoreAttribute) => { |
| 134 | // Shadow roots too, or elements hidden inside them stay hidden. |
| 135 | const roots = (window as any).__slidevExportRoots() |
| 136 | const all = (selector: string) => roots.flatMap((root: Document | ShadowRoot) => Array.from(root.querySelectorAll(selector))) |
| 137 | for (const el of all(`[${restoreAttribute}-bg]`)) { |
| 138 | const previous = el.getAttribute(`${restoreAttribute}-bg`) ?? '' |
| 139 | const style = (el as HTMLElement).style |
| 140 | if (previous) |
| 141 | style.backgroundColor = previous |
| 142 | else |
| 143 | style.removeProperty('background-color') |
| 144 | el.removeAttribute(`${restoreAttribute}-bg`) |
| 145 | } |
| 146 | for (const el of all(`[${restoreAttribute}-color]`)) { |
| 147 | const previous = el.getAttribute(`${restoreAttribute}-color`) ?? '' |
| 148 | const style = (el as HTMLElement).style |
| 149 | if (previous) |
| 150 | style.color = previous |
| 151 | else |
| 152 | style.removeProperty('color') |
| 153 | el.removeAttribute(`${restoreAttribute}-color`) |
| 154 | } |
| 155 | for (const el of all(`[${restoreAttribute}]`)) { |
| 156 | const previous = el.getAttribute(restoreAttribute) ?? '' |
| 157 | const style = (el as HTMLElement).style |
| 158 | if (previous) |
| 159 | style.visibility = previous |
| 160 | else |
| 161 | style.removeProperty('visibility') |
| 162 | el.removeAttribute(restoreAttribute) |
| 163 | } |
| 164 | }, RESTORE_ATTRIBUTE) |
| 165 | } |
| 166 | |
| 167 | /** |
| 168 | * Screenshot an element, or return undefined; never throws, since two of the three |
| 169 | * call sites are fallback paths. `locator.screenshot()` is not used: on a print page |
| 170 | * far taller than its viewport it returns a region from elsewhere on the page |
| 171 | * entirely, so the element's live box is read and the page clipped at it instead. |
| 172 | */ |
| 173 | async function shoot(page: Page, selector: string): Promise<string | undefined> { |
| 174 | try { |
| 175 | const locator = page.locator(selector).first() |
| 176 | if (!(await locator.count())) |
| 177 | return undefined |
| 178 | const box = await locator.boundingBox() |
| 179 | if (!box || box.width <= 0 || box.height <= 0) |
| 180 | return undefined |
| 181 | // `boundingBox()` is viewport-relative; `shootClip` wants document coordinates. |
| 182 | const scrollY = await page.evaluate(() => window.scrollY) |
| 183 | return await shootClip(page, { x: box.x, y: box.y + scrollY, w: box.width, h: box.height }) |
| 184 | } |
| 185 | catch { |
| 186 | return undefined |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | /** |
| 191 | * Chromium silently truncates captures past roughly twenty thousand CSS pixels, and |
| 192 | * the print route sizes its viewport to the whole deck, so on a long deck every clip |
| 193 | * below that point fails. Clips are taken through a short, scrolled viewport instead. |
| 194 | */ |
| 195 | const CLIP_VIEWPORT_MIN_HEIGHT = 2000 |
| 196 | |
| 197 | /** |
| 198 | * A viewport tall enough to hold the tallest thing being captured: with `canvasWidth: 3840` |
| 199 | * a single 16:9 slide is 2160 tall, so a fixed height would fail every whole-slide clip. |
| 200 | */ |
| 201 | function clipViewportHeight(slides: SlideIr[], requests: RasterRequest[]): number { |
| 202 | const tallest = Math.max( |
| 203 | 0, |
| 204 | ...slides.map(slide => slide.size.h), |
| 205 | ...requests.map(request => request.clip?.h ?? 0), |
| 206 | ) |
| 207 | return Math.max(CLIP_VIEWPORT_MIN_HEIGHT, Math.ceil(tallest) + 200) |
| 208 | } |
| 209 | |
| 210 | /** |
| 211 | * Screenshot a rectangle of the document. `clip` is in document coordinates while |
| 212 | * Playwright reads it as viewport-relative, so the region is scrolled to first and the |
| 213 | * clip rebased onto the scroll position actually reached. `fullPage` is not an option: |
| 214 | * on a long deck it asks Chromium for a bitmap large enough to kill the page. |
| 215 | */ |
| 216 | export async function shootClip(page: Page, clip: Rect): Promise<string | undefined> { |
| 217 | // Chromium rejects a clip with a negative origin outright; clamp and keep what there is. |
| 218 | const x = Math.max(0, clip.x) |
| 219 | const y = Math.max(0, clip.y) |
| 220 | const w = clip.w - (x - clip.x) |
| 221 | const h = clip.h - (y - clip.y) |
| 222 | if (w <= 0 || h <= 0) |
| 223 | return undefined |
| 224 | try { |
| 225 | const scrollY = await page.evaluate((top) => { |
| 226 | window.scrollTo(0, top) |
| 227 | return window.scrollY |
| 228 | }, Math.max(0, y - 1)) |
| 229 | const buffer = await page.screenshot({ |
| 230 | clip: { x, y: y - scrollY, width: w, height: h }, |
| 231 | omitBackground: true, |
| 232 | timeout: 10_000, |
| 233 | }) |
| 234 | return `data:image/png;base64,${buffer.toString('base64')}` |
| 235 | } |
| 236 | catch { |
| 237 | return undefined |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | /** |
| 242 | * Whether a data URI can be embedded as-is. pptxgenjs needs `image/<type>;base64,` and |
| 243 | * emits nothing for a URL-encoded data URI; SVG is rasterized on this path regardless. |
| 244 | * Both fall through to a screenshot. |
| 245 | */ |
| 246 | export function isUsableDataUri(url: string): boolean { |
| 247 | return /^data:image\/(?!svg\+xml)[\w.+-]+;base64,/.test(url) |
| 248 | } |
| 249 | |
| 250 | /** |
| 251 | * Fetch an image through `APIRequestContext`, which is not subject to CORS; |
| 252 | * the in-page route, `canvas.toDataURL()`, throws on a cross-origin image. |
| 253 | */ |
| 254 | async function fetchImage(page: Page, url: string): Promise<string | undefined> { |
| 255 | if (url.startsWith('data:')) |
| 256 | return isUsableDataUri(url) ? url : undefined |
| 257 | try { |
| 258 | const response = await page.context().request.get(url, { timeout: 15_000 }) |
| 259 | if (!response.ok()) |
| 260 | return undefined |
| 261 | const type = response.headers()['content-type']?.split(';')[0] ?? 'image/png' |
| 262 | // pptxgenjs writes a broken raster fallback for SVG from Node; let the |
| 263 | // caller screenshot the element instead. |
| 264 | if (type.includes('svg')) |
| 265 | return undefined |
| 266 | const body = await response.body() |
| 267 | return `data:${type};base64,${Buffer.from(body).toString('base64')}` |
| 268 | } |
| 269 | catch { |
| 270 | return undefined |
| 271 | } |
| 272 | } |
| 273 | |
| 274 | export interface CaptureReport { |
| 275 | rastersCaptured: number |
| 276 | rastersFailed: number |
| 277 | imagesFetched: number |
| 278 | /** Images that could be neither fetched nor screenshotted. */ |
| 279 | imagesDropped: number |
| 280 | /** Captures that asked for isolation and could not find their element. A silent miss bakes the slide's own text into the picture, which is then drawn again as shapes. */ |
| 281 | isolationMissed: number |
| 282 | fallbackSlides: { no: number, reason: string }[] |
| 283 | } |
| 284 | |
| 285 | /** |
| 286 | * Run every capture through one short, scrollable viewport. Resizing reflows the page, |
| 287 | * so it happens once around all of them rather than per capture; boxes are read live |
| 288 | * inside this viewport, so they stay consistent with it. |
| 289 | */ |
| 290 | async function throughShortViewport(page: Page, needed: boolean, height: number, fn: () => Promise<void>): Promise<void> { |
| 291 | if (!needed) { |
| 292 | await fn() |
| 293 | return |
| 294 | } |
| 295 | const viewport = page.viewportSize() |
| 296 | try { |
| 297 | if (viewport && viewport.height > height) |
| 298 | await page.setViewportSize({ width: viewport.width, height }) |
| 299 | await fn() |
| 300 | } |
| 301 | finally { |
| 302 | if (viewport) |
| 303 | await page.setViewportSize(viewport) |
| 304 | await page.evaluate(() => window.scrollTo(0, 0)) |
| 305 | } |
| 306 | } |
| 307 | |
| 308 | /** Fill in every picture the IR asked for, and shoot whole-slide fallbacks. Mutates `slides` in place. */ |
| 309 | export async function capture( |
| 310 | page: Page, |
| 311 | slides: SlideIr[], |
| 312 | requests: RasterRequest[], |
| 313 | ): Promise<CaptureReport> { |
| 314 | const report: CaptureReport = { |
| 315 | rastersCaptured: 0, |
| 316 | rastersFailed: 0, |
| 317 | imagesFetched: 0, |
| 318 | imagesDropped: 0, |
| 319 | isolationMissed: 0, |
| 320 | fallbackSlides: [], |
| 321 | } |
| 322 | |
| 323 | await installRootFinder(page) |
| 324 | |
| 325 | const rasterBySource = new Map<number, IrRaster[]>() |
| 326 | const imagesBySource = new Map<number, IrImage[]>() |
| 327 | for (const slide of slides) { |
| 328 | for (const node of slide.nodes) { |
| 329 | if (node.kind === 'raster') { |
| 330 | const list = rasterBySource.get(node.sourceId) ?? [] |
| 331 | list.push(node) |
| 332 | rasterBySource.set(node.sourceId, list) |
| 333 | } |
| 334 | else if (node.kind === 'image') { |
| 335 | const list = imagesBySource.get(node.sourceId) ?? [] |
| 336 | list.push(node) |
| 337 | imagesBySource.set(node.sourceId, list) |
| 338 | } |
| 339 | } |
| 340 | } |
| 341 | |
| 342 | const fulfil = async (request: RasterRequest): Promise<void> => { |
| 343 | let data: string | undefined |
| 344 | try { |
| 345 | if (request.isolate && !(await isolate(page, request.isolateId ?? request.sourceId, request.hideDescendants))) |
| 346 | report.isolationMissed++ |
| 347 | // A pseudo-element has no element to point at; clip the page to the box |
| 348 | // the walker computed for it instead. |
| 349 | data = request.clip |
| 350 | ? await shootClip(page, request.clip) |
| 351 | : await shoot(page, `[${ID_ATTRIBUTE}="${request.sourceId}"]`) |
| 352 | } |
| 353 | catch { |
| 354 | data = undefined |
| 355 | } |
| 356 | finally { |
| 357 | if (request.isolate) |
| 358 | await restore(page) |
| 359 | } |
| 360 | |
| 361 | for (const node of rasterBySource.get(request.sourceId) ?? []) { |
| 362 | if (data) { |
| 363 | node.data = data |
| 364 | report.rastersCaptured++ |
| 365 | } |
| 366 | else { |
| 367 | report.rastersFailed++ |
| 368 | } |
| 369 | } |
| 370 | } |
| 371 | |
| 372 | const viewportHeight = clipViewportHeight(slides, requests) |
| 373 | |
| 374 | await throughShortViewport(page, !!requests.length || !!imagesBySource.size, viewportHeight, async () => { |
| 375 | for (const request of requests) |
| 376 | await fulfil(request) |
| 377 | |
| 378 | for (const [sourceId, nodes] of imagesBySource) { |
| 379 | let data = await fetchImage(page, nodes[0].data) |
| 380 | if (data) { |
| 381 | report.imagesFetched++ |
| 382 | } |
| 383 | else { |
| 384 | // Unfetchable, or an SVG: screenshot the element instead, isolated so |
| 385 | // the picture does not carry what the slide painted behind it. |
| 386 | try { |
| 387 | if (!(await isolate(page, sourceId, false))) |
| 388 | report.isolationMissed++ |
| 389 | // Once per element, not once per node it produced across click steps. |
| 390 | data = await shoot(page, `[${ID_ATTRIBUTE}="${sourceId}"]`) |
| 391 | if (data) |
| 392 | report.imagesFetched++ |
| 393 | } |
| 394 | finally { |
| 395 | await restore(page) |
| 396 | } |
| 397 | } |
| 398 | if (!data) |
| 399 | continue |
| 400 | for (const node of nodes) |
| 401 | node.data = data |
| 402 | } |
| 403 | }) |
| 404 | |
| 405 | // Anything without real image data would reach pptxgenjs as a bare URL or |
| 406 | // an empty string, which it rejects on stderr while writing nothing. |
| 407 | for (const slide of slides) { |
| 408 | slide.nodes = slide.nodes.filter((node) => { |
| 409 | if (node.kind === 'raster') |
| 410 | return !!node.data |
| 411 | if (node.kind === 'image' && !node.data.startsWith('data:')) { |
| 412 | report.imagesDropped++ |
| 413 | return false |
| 414 | } |
| 415 | return true |
| 416 | }) |
| 417 | } |
| 418 | |
| 419 | await throughShortViewport(page, slides.some(slide => !!slide.fallbackReason), viewportHeight, async () => { |
| 420 | for (const slide of slides) { |
| 421 | if (!slide.fallbackReason) |
| 422 | continue |
| 423 | // The exact container id: a prefix match plus `.first()` hands every |
| 424 | // click step of a slide the picture of step one. |
| 425 | const shot = await shoot(page, `[id="${slide.containerId}"]`) |
| 426 | if (shot) { |
| 427 | slide.fallbackPng = shot |
| 428 | report.fallbackSlides.push({ no: slide.no, reason: slide.fallbackReason }) |
| 429 | } |
| 430 | else { |
| 431 | // Screenshot failed too. The slide keeps its shapes, but `normalize` |
| 432 | // withheld its raster requests, so keep the warning and clear the reason. |
| 433 | report.fallbackSlides.push({ |
| 434 | no: slide.no, |
| 435 | reason: `${slide.fallbackReason}, and the replacement screenshot failed, so the slide is incomplete`, |
| 436 | }) |
| 437 | slide.fallbackReason = undefined |
| 438 | } |
| 439 | } |
| 440 | }) |
| 441 | |
| 442 | return report |
| 443 | } |
| 444 |