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, ScreenshotFormat as CdpScreenshotFormat,
10    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 {
66            x,
67            y,
68            width,
69            height,
70        }
71    }
72}
73
74/// Builder for capturing screenshots.
75#[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    /// Create a new screenshot builder.
90    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    /// Set the image format to PNG.
105    #[must_use]
106    pub fn png(mut self) -> Self {
107        self.format = ScreenshotFormat::Png;
108        self
109    }
110
111    /// Set the image format to JPEG with optional quality (0-100).
112    #[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    /// Set the image format.
120    #[must_use]
121    pub fn format(mut self, format: ScreenshotFormat) -> Self {
122        self.format = format;
123        self
124    }
125
126    /// Set the image quality (0-100, applicable to JPEG and WebP only).
127    #[must_use]
128    pub fn quality(mut self, quality: u8) -> Self {
129        self.quality = Some(quality.min(100));
130        self
131    }
132
133    /// Capture the full scrollable page instead of just the viewport.
134    #[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    /// Clip the screenshot to a specific region.
142    #[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    /// Clip the screenshot using a `ClipRegion`.
149    #[must_use]
150    pub fn clip_region(mut self, region: ClipRegion) -> Self {
151        self.clip = Some(region);
152        self
153    }
154
155    /// Save the screenshot to a file.
156    #[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    /// Set whether to omit the background (transparent).
163    /// Note: Only applicable to PNG format.
164    #[must_use]
165    pub fn omit_background(mut self, omit: bool) -> Self {
166        self.omit_background = omit;
167        self
168    }
169
170    /// Set animation handling.
171    #[must_use]
172    pub fn animations(mut self, animations: Animations) -> Self {
173        self.animations = animations;
174        self
175    }
176
177    /// Capture the screenshot.
178    ///
179    /// Returns the screenshot as a byte buffer.
180    ///
181    /// # Errors
182    ///
183    /// Returns an error if:
184    /// - The page is closed
185    /// - The CDP command fails
186    /// - File saving fails (if a path was specified)
187    #[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        // Disable animations if requested
196        if self.animations == Animations::Disabled {
197            debug!("Disabling animations");
198            self.disable_animations().await?;
199        }
200
201        // Build CDP parameters
202        let clip = if self.full_page {
203            // For full page, we need to get the full page dimensions first
204            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        // Re-enable animations if they were disabled
248        if self.animations == Animations::Disabled {
249            debug!("Re-enabling animations");
250            self.enable_animations().await?;
251        }
252
253        // Decode base64 data
254        let data = base64_decode(&result.data)?;
255        debug!(bytes = data.len(), "Screenshot captured");
256
257        // Save to file if path specified
258        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    /// Get the full page dimensions.
270    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    /// Disable CSS animations.
327    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    /// Re-enable CSS animations.
358    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
387/// Decode base64 string to bytes.
388pub(crate) fn base64_decode(input: &str) -> Result<Vec<u8>, PageError> {
389    // Simple base64 decode implementation
390    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}