返回 slidev
capture.test.ts
根目录 / packages / slidev / node / commands / pptx / capture.test.ts
1 import type { Page } from 'playwright-chromium'
2 import type { RasterRequest } from './normalize'
3 import { Buffer } from 'node:buffer'
4 import { describe, expect, it } from 'vitest'
5 import { capture, isUsableDataUri, shootClip } from './capture'
6
7 /**
8 * `capture.ts` needs a live browser, so only its pure predicates are unit
9 * tested here. This one guards a real failure: pptxgenjs answers a data URI it
10 * cannot parse by printing to stderr and writing no picture at all, so the
11 * image is missing and the export still reports success.
12 */
13 describe('isUsableDataUri', () => {
14 it('accepts a base64 raster', () => {
15 expect(isUsableDataUri('data:image/png;base64,iVBORw0KGgo=')).toBe(true)
16 expect(isUsableDataUri('data:image/jpeg;base64,/9j/4AAQ')).toBe(true)
17 })
18
19 it('rejects a URL-encoded data URI, which is how inline SVG is usually written', () => {
20 expect(isUsableDataUri('data:image/svg+xml,%3c!--%20icon%20--%3e')).toBe(false)
21 })
22
23 it('rejects SVG even when it is base64', () => {
24 // pptxgenjs writes a broken raster fallback for SVG from Node, so these
25 // are screenshotted rather than embedded.
26 expect(isUsableDataUri('data:image/svg+xml;base64,PHN2Zz4=')).toBe(false)
27 })
28 })
29
30 /**
31 * A page just real enough for the two things that geometry depends on: how far
32 * it actually scrolled, and how tall its viewport is.
33 */
34 function fakePage(options: { documentHeight: number, viewportHeight: number, box?: { x: number, y: number, width: number, height: number } }) {
35 const calls: {
36 clips: { x: number, y: number, width: number, height: number }[]
37 viewports: number[]
38 locators: string[]
39 elementShots: number
40 } = { clips: [], viewports: [], locators: [], elementShots: 0 }
41 let scrollY = 0
42 let viewportHeight = options.viewportHeight
43 const page = {
44 viewportSize: () => ({ width: 980, height: viewportHeight }),
45 async setViewportSize({ height }: { height: number }) {
46 viewportHeight = height
47 calls.viewports.push(height)
48 },
49 async evaluate(fn: any, arg: any) {
50 // Only scroll calls reach here, and they are told apart by their source
51 // because all three are no-argument functions of `window`, which this
52 // stands in for.
53 if (typeof arg === 'number') {
54 scrollY = Math.max(0, Math.min(arg, options.documentHeight - viewportHeight))
55 return scrollY
56 }
57 if (String(fn).includes('scrollTo')) {
58 scrollY = 0
59 return undefined
60 }
61 return scrollY
62 },
63 async screenshot({ clip }: any) {
64 calls.clips.push(clip)
65 return Buffer.from('png')
66 },
67 locator(selector: string) {
68 calls.locators.push(selector)
69 return {
70 first: () => ({
71 count: async () => (options.box ? 1 : 0),
72 // Viewport-relative, exactly as Playwright reports it.
73 boundingBox: async () => (options.box ? { ...options.box, y: options.box.y - scrollY } : null),
74 screenshot: async () => {
75 calls.elementShots++
76 return Buffer.from('png')
77 },
78 }),
79 }
80 },
81 }
82 return { page: page as unknown as Page, calls, scrollY: () => scrollY }
83 }
84
85 describe('a clip is taken relative to the scroll the page actually reached', () => {
86 it('rebases the clip onto the real scroll position', () => {
87 // `clip` is in DOCUMENT coordinates while Playwright reads it as
88 // viewport-relative, so the difference has to come from where the browser
89 // ended up, not from where it was asked to go.
90 const { page, calls } = fakePage({ documentHeight: 28152, viewportHeight: 2000 })
91 return shootClip(page, { x: 100, y: 20000, w: 300, h: 150 }).then(() => {
92 expect(calls.clips).toEqual([{ x: 100, y: 1, width: 300, height: 150 }])
93 })
94 })
95
96 it('does not scroll past the end of the document', () => {
97 // At the very bottom the browser stops short of the requested offset. A
98 // clip rebased on the REQUESTED offset lands above the region and
99 // Playwright answers "clipped area is empty", which the caller swallows,
100 // so the picture silently goes missing.
101 const { page, calls } = fakePage({ documentHeight: 28152, viewportHeight: 2000 })
102 return shootClip(page, { x: 0, y: 28100, w: 100, h: 50 }).then(() => {
103 // Scroll stops at 26152, so the region is 1948 down the viewport.
104 expect(calls.clips).toEqual([{ x: 0, y: 1948, width: 100, height: 50 }])
105 })
106 })
107 })
108
109 describe('clip captures run through a shortened viewport', () => {
110 const request = (sourceId: number, clip?: { x: number, y: number, w: number, h: number }): RasterRequest =>
111 ({ sourceId, isolate: false, hideDescendants: false, ...(clip ? { clip } : {}) })
112
113 it('shortens the viewport for the clips and puts it back', async () => {
114 // The print route sizes its viewport to the WHOLE deck, and past roughly
115 // twenty thousand pixels Chromium truncates the capture surface, so every
116 // clip in the last third of a long deck failed and its picture vanished
117 // with nothing in the log to say so.
118 const { page, calls } = fakePage({ documentHeight: 28152, viewportHeight: 28152 })
119 const slides = [{
120 no: 1,
121 clickIndex: 0,
122 containerId: '001-01',
123 size: { w: 980, h: 552 },
124 nodes: [{ kind: 'raster' as const, sourceId: 1, rect: { x: 0, y: 0, w: 10, h: 10 }, data: '', reason: 'svg' as const, isolate: false, hideDescendants: false }],
125 }]
126 const report = await capture(page, slides as any, [request(1, { x: 0, y: 20000, w: 10, h: 10 })])
127 expect(calls.viewports).toEqual([2000, 28152])
128 expect(report.rastersCaptured).toBe(1)
129 expect(report.rastersFailed).toBe(0)
130 })
131
132 it('leaves the viewport alone when nothing needs a clip', async () => {
133 const { page, calls } = fakePage({ documentHeight: 28152, viewportHeight: 28152 })
134 await capture(page, [], [])
135 expect(calls.viewports).toEqual([])
136 })
137 })
138
139 describe('an element is captured by clipping the page at its box', () => {
140 const request = (sourceId: number): RasterRequest => ({ sourceId, isolate: false, hideDescendants: false })
141 const slide = (sourceId: number) => ({
142 no: 1,
143 clickIndex: 0,
144 containerId: '001-01',
145 size: { w: 980, h: 552 },
146 nodes: [{ kind: 'raster' as const, sourceId, rect: { x: 0, y: 0, w: 10, h: 10 }, data: '', reason: 'svg' as const, isolate: false, hideDescendants: false }],
147 })
148
149 it('clips rather than calling locator.screenshot', async () => {
150 // `locator.screenshot()` is the obvious call and returns a region from
151 // somewhere else entirely on a print page far taller than its viewport:
152 // Slidev's own starter deck came back with a picture of slide one pasted
153 // into slide four.
154 const { page, calls } = fakePage({
155 documentHeight: 28152,
156 viewportHeight: 28152,
157 box: { x: 100, y: 2049, width: 320, height: 194 },
158 })
159 const report = await capture(page, [slide(166)] as any, [request(166)])
160 expect(calls.elementShots).toBe(0)
161 expect(calls.clips).toEqual([{ x: 100, y: 1, width: 320, height: 194 }])
162 expect(report.rastersCaptured).toBe(1)
163 })
164
165 it('reports a failure when the element is not there', async () => {
166 const { page } = fakePage({ documentHeight: 28152, viewportHeight: 28152 })
167 const report = await capture(page, [slide(166)] as any, [request(166)])
168 expect(report.rastersFailed).toBe(1)
169 })
170 })
171
172 describe('shootClip', () => {
173 it('clamps a box that starts left of the page', async () => {
174 // An absolutely positioned decoration can hang off the left edge, and
175 // Chromium cannot capture a negative origin: it rejects the whole
176 // screenshot, so the picture went missing rather than being trimmed.
177 const { page, calls } = fakePage({ documentHeight: 5000, viewportHeight: 2000 })
178 await shootClip(page, { x: -28, y: 100, w: 320, h: 194 })
179 expect(calls.clips).toEqual([{ x: 0, y: 1, width: 292, height: 194 }])
180 })
181
182 it('gives up on a box with nothing left to capture', async () => {
183 const { page, calls } = fakePage({ documentHeight: 5000, viewportHeight: 2000 })
184 expect(await shootClip(page, { x: -400, y: 100, w: 320, h: 194 })).toBeUndefined()
185 expect(calls.clips).toEqual([])
186 })
187 })
188
188 lines TYPESCRIPT