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