viewpoint_core/page/screenshot/
mod.rs1use std::path::Path;
6
7use tracing::{debug, info, instrument};
8use viewpoint_cdp::protocol::page::{
9 CaptureScreenshotParams, CaptureScreenshotResult, ScreenshotFormat as CdpScreenshotFormat,
10 Viewport,
11};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum ScreenshotFormat {
16 #[default]
18 Png,
19 Jpeg,
21 Webp,
23}
24
25impl From<ScreenshotFormat> for CdpScreenshotFormat {
26 fn from(format: ScreenshotFormat) -> Self {
27 match format {
28 ScreenshotFormat::Png => CdpScreenshotFormat::Png,
29 ScreenshotFormat::Jpeg => CdpScreenshotFormat::Jpeg,
30 ScreenshotFormat::Webp => CdpScreenshotFormat::Webp,
31 }
32 }
33}
34
35use crate::error::PageError;
36
37use super::Page;
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
41pub enum Animations {
42 #[default]
44 Allow,
45 Disabled,
47}
48
49#[derive(Debug, Clone, Copy)]
51pub struct ClipRegion {
52 pub x: f64,
54 pub y: f64,
56 pub width: f64,
58 pub height: f64,
60}
61
62impl ClipRegion {
63 pub fn new(x: f64, y: f64, width: f64, height: f64) -> Self {
65 Self {
66 x,
67 y,
68 width,
69 height,
70 }
71 }
72}
73
74#[derive(Debug, Clone)]
76pub struct ScreenshotBuilder<'a> {
77 page: &'a Page,
78 format: ScreenshotFormat,
79 quality: Option<u8>,
80 full_page: bool,
81 clip: Option<ClipRegion>,
82 path: Option<String>,
83 omit_background: bool,
84 animations: Animations,
85 capture_beyond_viewport: bool,
86}
87
88impl<'a> ScreenshotBuilder<'a> {
89 pub(crate) fn new(page: &'a Page) -> Self {
91 Self {
92 page,
93 format: ScreenshotFormat::Png,
94 quality: None,
95 full_page: false,
96 clip: None,
97 path: None,
98 omit_background: false,
99 animations: Animations::default(),
100 capture_beyond_viewport: false,
101 }
102 }
103
104 #[must_use]
106 pub fn png(mut self) -> Self {
107 self.format = ScreenshotFormat::Png;
108 self
109 }
110
111 #[must_use]
113 pub fn jpeg(mut self, quality: Option<u8>) -> Self {
114 self.format = ScreenshotFormat::Jpeg;
115 self.quality = quality;
116 self
117 }
118
119 #[must_use]
121 pub fn format(mut self, format: ScreenshotFormat) -> Self {
122 self.format = format;
123 self
124 }
125
126 #[must_use]
128 pub fn quality(mut self, quality: u8) -> Self {
129 self.quality = Some(quality.min(100));
130 self
131 }
132
133 #[must_use]
135 pub fn full_page(mut self, full_page: bool) -> Self {
136 self.full_page = full_page;
137 self.capture_beyond_viewport = full_page;
138 self
139 }
140
141 #[must_use]
143 pub fn clip(mut self, x: f64, y: f64, width: f64, height: f64) -> Self {
144 self.clip = Some(ClipRegion::new(x, y, width, height));
145 self
146 }
147
148 #[must_use]
150 pub fn clip_region(mut self, region: ClipRegion) -> Self {
151 self.clip = Some(region);
152 self
153 }
154
155 #[must_use]
157 pub fn path(mut self, path: impl AsRef<Path>) -> Self {
158 self.path = Some(path.as_ref().to_string_lossy().to_string());
159 self
160 }
161
162 #[must_use]
165 pub fn omit_background(mut self, omit: bool) -> Self {
166 self.omit_background = omit;
167 self
168 }
169
170 #[must_use]
172 pub fn animations(mut self, animations: Animations) -> Self {
173 self.animations = animations;
174 self
175 }
176
177 #[instrument(level = "info", skip(self), fields(format = ?self.format, full_page = self.full_page, has_path = self.path.is_some()))]
188 pub async fn capture(self) -> Result<Vec<u8>, PageError> {
189 if self.page.is_closed() {
190 return Err(PageError::Closed);
191 }
192
193 info!("Capturing screenshot");
194
195 if self.animations == Animations::Disabled {
197 debug!("Disabling animations");
198 self.disable_animations().await?;
199 }
200
201 let clip = if self.full_page {
203 let dimensions = self.get_full_page_dimensions().await?;
205 debug!(
206 width = dimensions.0,
207 height = dimensions.1,
208 "Full page dimensions"
209 );
210 Some(Viewport {
211 x: 0.0,
212 y: 0.0,
213 width: dimensions.0,
214 height: dimensions.1,
215 scale: 1.0,
216 })
217 } else {
218 self.clip.map(|c| Viewport {
219 x: c.x,
220 y: c.y,
221 width: c.width,
222 height: c.height,
223 scale: 1.0,
224 })
225 };
226
227 let params = CaptureScreenshotParams {
228 format: Some(self.format.into()),
229 quality: self.quality,
230 clip,
231 from_surface: Some(true),
232 capture_beyond_viewport: Some(self.capture_beyond_viewport),
233 optimize_for_speed: None,
234 };
235
236 debug!("Sending Page.captureScreenshot command");
237 let result: CaptureScreenshotResult = self
238 .page
239 .connection()
240 .send_command(
241 "Page.captureScreenshot",
242 Some(params),
243 Some(self.page.session_id()),
244 )
245 .await?;
246
247 if self.animations == Animations::Disabled {
249 debug!("Re-enabling animations");
250 self.enable_animations().await?;
251 }
252
253 let data = base64_decode(&result.data)?;
255 debug!(bytes = data.len(), "Screenshot captured");
256
257 if let Some(ref path) = self.path {
259 debug!(path = path, "Saving screenshot to file");
260 tokio::fs::write(path, &data).await.map_err(|e| {
261 PageError::EvaluationFailed(format!("Failed to save screenshot: {e}"))
262 })?;
263 info!(path = path, "Screenshot saved");
264 }
265
266 Ok(data)
267 }
268
269 async fn get_full_page_dimensions(&self) -> Result<(f64, f64), PageError> {
271 let result: viewpoint_cdp::protocol::runtime::EvaluateResult = self
272 .page
273 .connection()
274 .send_command(
275 "Runtime.evaluate",
276 Some(viewpoint_cdp::protocol::runtime::EvaluateParams {
277 expression: r"
278 JSON.stringify({
279 width: Math.max(
280 document.body.scrollWidth,
281 document.documentElement.scrollWidth,
282 document.body.offsetWidth,
283 document.documentElement.offsetWidth,
284 document.body.clientWidth,
285 document.documentElement.clientWidth
286 ),
287 height: Math.max(
288 document.body.scrollHeight,
289 document.documentElement.scrollHeight,
290 document.body.offsetHeight,
291 document.documentElement.offsetHeight,
292 document.body.clientHeight,
293 document.documentElement.clientHeight
294 )
295 })
296 "
297 .to_string(),
298 object_group: None,
299 include_command_line_api: None,
300 silent: Some(true),
301 context_id: None,
302 return_by_value: Some(true),
303 await_promise: Some(false),
304 }),
305 Some(self.page.session_id()),
306 )
307 .await?;
308
309 let json_str = result
310 .result
311 .value
312 .and_then(|v| v.as_str().map(String::from))
313 .ok_or_else(|| {
314 PageError::EvaluationFailed("Failed to get page dimensions".to_string())
315 })?;
316
317 let dimensions: serde_json::Value = serde_json::from_str(&json_str)
318 .map_err(|e| PageError::EvaluationFailed(format!("Failed to parse dimensions: {e}")))?;
319
320 let width = dimensions["width"].as_f64().unwrap_or(800.0);
321 let height = dimensions["height"].as_f64().unwrap_or(600.0);
322
323 Ok((width, height))
324 }
325
326 async fn disable_animations(&self) -> Result<(), PageError> {
328 let script = r"
329 (function() {
330 const style = document.createElement('style');
331 style.id = '__viewpoint_disable_animations__';
332 style.textContent = '*, *::before, *::after { animation-duration: 0s !important; animation-delay: 0s !important; transition-duration: 0s !important; transition-delay: 0s !important; }';
333 document.head.appendChild(style);
334 })()
335 ";
336
337 self.page
338 .connection()
339 .send_command::<_, serde_json::Value>(
340 "Runtime.evaluate",
341 Some(viewpoint_cdp::protocol::runtime::EvaluateParams {
342 expression: script.to_string(),
343 object_group: None,
344 include_command_line_api: None,
345 silent: Some(true),
346 context_id: None,
347 return_by_value: Some(true),
348 await_promise: Some(false),
349 }),
350 Some(self.page.session_id()),
351 )
352 .await?;
353
354 Ok(())
355 }
356
357 async fn enable_animations(&self) -> Result<(), PageError> {
359 let script = r"
360 (function() {
361 const style = document.getElementById('__viewpoint_disable_animations__');
362 if (style) style.remove();
363 })()
364 ";
365
366 self.page
367 .connection()
368 .send_command::<_, serde_json::Value>(
369 "Runtime.evaluate",
370 Some(viewpoint_cdp::protocol::runtime::EvaluateParams {
371 expression: script.to_string(),
372 object_group: None,
373 include_command_line_api: None,
374 silent: Some(true),
375 context_id: None,
376 return_by_value: Some(true),
377 await_promise: Some(false),
378 }),
379 Some(self.page.session_id()),
380 )
381 .await?;
382
383 Ok(())
384 }
385}
386
387pub(crate) fn base64_decode(input: &str) -> Result<Vec<u8>, PageError> {
389 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
391
392 fn decode_char(c: u8) -> Option<u8> {
393 ALPHABET.iter().position(|&x| x == c).map(|p| p as u8)
394 }
395
396 let input = input.as_bytes();
397 let mut output = Vec::with_capacity(input.len() * 3 / 4);
398
399 let mut buffer = 0u32;
400 let mut bits = 0u8;
401
402 for &byte in input {
403 if byte == b'=' {
404 break;
405 }
406 if byte == b'\n' || byte == b'\r' || byte == b' ' {
407 continue;
408 }
409
410 let val = decode_char(byte)
411 .ok_or_else(|| PageError::EvaluationFailed("Invalid base64 character".to_string()))?;
412
413 buffer = (buffer << 6) | u32::from(val);
414 bits += 6;
415
416 if bits >= 8 {
417 bits -= 8;
418 output.push((buffer >> bits) as u8);
419 }
420 }
421
422 Ok(output)
423}