viewpoint_core/page/screenshot/
mod.rs

1//! Screenshot capture functionality.
2//!
3//! This module provides the `ScreenshotBuilder` for capturing page screenshots.
4
5use 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/// Image format for screenshots.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum ScreenshotFormat {
16    /// PNG format (default).
17    #[default]
18    Png,
19    /// JPEG format.
20    Jpeg,
21    /// WebP format.
22    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/// Animation handling mode for screenshots.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
41pub enum Animations {
42    /// Allow animations to run.
43    #[default]
44    Allow,
45    /// Disable animations before capture.
46    Disabled,
47}
48
49/// Clip region for screenshots.
50#[derive(Debug, Clone, Copy)]
51pub struct ClipRegion {
52    /// X coordinate.
53    pub x: f64,
54    /// Y coordinate.
55    pub y: f64,
56    /// Width.
57    pub width: f64,
58    /// Height.
59    pub height: f64,
60}
61
62impl ClipRegion {
63    /// Create a new clip region.
64    pub fn new(x: f64, y: f64, width: f64, height: f64) -> Self {
65        Self { x, y, width, height }
66    }
67}
68
69/// Builder for capturing screenshots.
70#[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    /// Create a new screenshot builder.
85    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    /// Set the image format to PNG.
100    #[must_use]
101    pub fn png(mut self) -> Self {
102        self.format = ScreenshotFormat::Png;
103        self
104    }
105
106    /// Set the image format to JPEG with optional quality (0-100).
107    #[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    /// Set the image format.
115    #[must_use]
116    pub fn format(mut self, format: ScreenshotFormat) -> Self {
117        self.format = format;
118        self
119    }
120
121    /// Set the image quality (0-100, applicable to JPEG and WebP only).
122    #[must_use]
123    pub fn quality(mut self, quality: u8) -> Self {
124        self.quality = Some(quality.min(100));
125        self
126    }
127
128    /// Capture the full scrollable page instead of just the viewport.
129    #[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    /// Clip the screenshot to a specific region.
137    #[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    /// Clip the screenshot using a `ClipRegion`.
144    #[must_use]
145    pub fn clip_region(mut self, region: ClipRegion) -> Self {
146        self.clip = Some(region);
147        self
148    }
149
150    /// Save the screenshot to a file.
151    #[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    /// Set whether to omit the background (transparent).
158    /// Note: Only applicable to PNG format.
159    #[must_use]
160    pub fn omit_background(mut self, omit: bool) -> Self {
161        self.omit_background = omit;
162        self
163    }
164
165    /// Set animation handling.
166    #[must_use]
167    pub fn animations(mut self, animations: Animations) -> Self {
168        self.animations = animations;
169        self
170    }
171
172    /// Capture the screenshot.
173    ///
174    /// Returns the screenshot as a byte buffer.
175    ///
176    /// # Errors
177    ///
178    /// Returns an error if:
179    /// - The page is closed
180    /// - The CDP command fails
181    /// - File saving fails (if a path was specified)
182    #[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        // Disable animations if requested
191        if self.animations == Animations::Disabled {
192            debug!("Disabling animations");
193            self.disable_animations().await?;
194        }
195
196        // Build CDP parameters
197        let clip = if self.full_page {
198            // For full page, we need to get the full page dimensions first
199            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        // Re-enable animations if they were disabled
235        if self.animations == Animations::Disabled {
236            debug!("Re-enabling animations");
237            self.enable_animations().await?;
238        }
239
240        // Decode base64 data
241        let data = base64_decode(&result.data)?;
242        debug!(bytes = data.len(), "Screenshot captured");
243
244        // Save to file if path specified
245        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    /// Get the full page dimensions.
257    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    /// Disable CSS animations.
311    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    /// Re-enable CSS animations.
342    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
371/// Decode base64 string to bytes.
372pub(crate) fn base64_decode(input: &str) -> Result<Vec<u8>, PageError> {
373    // Simple base64 decode implementation
374    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}