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