返回 slidev
capture.ts
根目录 / packages / slidev / node / commands / pptx / capture.ts
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
444 lines TYPESCRIPT