use std::path::Path;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
use serde_json::{Map, Value, json};
use crate::error::{Result, ZendriverError};
use crate::query::BoundingBox;
use crate::tab::Tab;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Format {
Png,
Jpeg,
Webp,
}
impl Format {
#[must_use]
pub fn as_cdp(&self) -> &'static str {
match self {
Self::Png => "png",
Self::Jpeg => "jpeg",
Self::Webp => "webp",
}
}
}
#[derive(Debug)]
pub struct ScreenshotBuilder<'tab> {
tab: &'tab Tab,
format: Format,
full_page: bool,
clip: Option<BoundingBox>,
quality: Option<u8>,
omit_background: bool,
}
impl<'tab> ScreenshotBuilder<'tab> {
#[must_use]
pub fn new(tab: &'tab Tab) -> Self {
Self {
tab,
format: Format::Png,
full_page: false,
clip: None,
quality: None,
omit_background: false,
}
}
#[must_use]
pub fn png(mut self) -> Self {
self.format = Format::Png;
self
}
#[must_use]
pub fn jpeg(mut self) -> Self {
self.format = Format::Jpeg;
self
}
#[must_use]
pub fn webp(mut self) -> Self {
self.format = Format::Webp;
self
}
#[must_use]
pub fn full_page(mut self, on: bool) -> Self {
self.full_page = on;
self
}
#[must_use]
pub fn clip(mut self, bbox: BoundingBox) -> Self {
self.clip = Some(bbox);
self
}
#[must_use]
pub fn quality(mut self, q: u8) -> Self {
self.quality = Some(q);
self
}
#[must_use]
pub fn omit_background(mut self, on: bool) -> Self {
self.omit_background = on;
self
}
pub async fn bytes(self) -> Result<Vec<u8>> {
let mut params = Map::new();
params.insert(
"format".to_string(),
Value::String(self.format.as_cdp().into()),
);
let effective_clip = if self.full_page {
let metrics = self.tab.call("Page.getLayoutMetrics", json!({})).await?;
let css_content_size = metrics.get("cssContentSize").ok_or_else(|| {
ZendriverError::Navigation(
"Page.getLayoutMetrics response missing cssContentSize".into(),
)
})?;
let width = css_content_size
.get("width")
.and_then(Value::as_f64)
.ok_or_else(|| {
ZendriverError::Navigation(
"Page.getLayoutMetrics cssContentSize missing width".into(),
)
})?;
let height = css_content_size
.get("height")
.and_then(Value::as_f64)
.ok_or_else(|| {
ZendriverError::Navigation(
"Page.getLayoutMetrics cssContentSize missing height".into(),
)
})?;
params.insert("captureBeyondViewport".to_string(), Value::Bool(true));
Some(BoundingBox {
x: 0.0,
y: 0.0,
width,
height,
})
} else {
self.clip
};
if let Some(bbox) = effective_clip {
params.insert(
"clip".to_string(),
json!({
"x": bbox.x,
"y": bbox.y,
"width": bbox.width,
"height": bbox.height,
"scale": 1,
}),
);
}
if let Some(q) = self.quality {
params.insert("quality".to_string(), json!(q));
}
if self.omit_background {
params.insert("omitBackground".to_string(), Value::Bool(true));
}
let res = self
.tab
.call("Page.captureScreenshot", Value::Object(params))
.await?;
let data = res.get("data").and_then(|v| v.as_str()).ok_or_else(|| {
ZendriverError::Navigation("Page.captureScreenshot returned no data".into())
})?;
BASE64
.decode(data)
.map_err(|e| ZendriverError::Navigation(format!("invalid base64 in screenshot: {e}")))
}
pub async fn save(self, path: impl AsRef<Path>) -> Result<()> {
let bytes = self.bytes().await?;
tokio::fs::write(path, bytes).await?;
Ok(())
}
}
#[cfg(test)]
#[allow(clippy::panic, clippy::unwrap_used)]
mod tests {
use super::*;
use serde_json::json;
use zendriver_transport::SessionHandle;
use zendriver_transport::testing::MockConnection;
#[tokio::test]
async fn default_is_png_no_clip_no_full_page() {
let (mut mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
let tab = Tab::new_for_test(sess);
let fut = tokio::spawn({
let t = tab.clone();
async move { ScreenshotBuilder::new(&t).bytes().await }
});
let id = mock.expect_cmd("Page.captureScreenshot").await;
let sent = mock.last_sent();
let params = &sent["params"];
assert_eq!(params["format"], "png");
assert!(params.get("clip").is_none());
assert!(params.get("quality").is_none());
assert!(params.get("captureBeyondViewport").is_none());
assert!(params.get("omitBackground").is_none());
mock.reply(id, json!({ "data": "UE5HIQ==" })).await;
let bytes = fut.await.unwrap().unwrap();
assert_eq!(bytes, b"PNG!");
conn.shutdown();
}
#[tokio::test]
async fn jpeg_quality_sets_format_and_quality_on_wire() {
let (mut mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
let tab = Tab::new_for_test(sess);
let fut = tokio::spawn({
let t = tab.clone();
async move { ScreenshotBuilder::new(&t).jpeg().quality(80).bytes().await }
});
let id = mock.expect_cmd("Page.captureScreenshot").await;
let sent = mock.last_sent();
let params = &sent["params"];
assert_eq!(params["format"], "jpeg");
assert_eq!(params["quality"], 80);
assert!(params.get("captureBeyondViewport").is_none());
assert!(params.get("clip").is_none());
mock.reply(id, json!({ "data": "SlBHIQ==" })).await;
let bytes = fut.await.unwrap().unwrap();
assert_eq!(bytes, b"JPG!");
conn.shutdown();
}
#[tokio::test]
async fn full_page_uses_layout_metrics_and_capture_beyond_viewport() {
let (mut mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
let tab = Tab::new_for_test(sess);
let fut = tokio::spawn({
let t = tab.clone();
async move { ScreenshotBuilder::new(&t).full_page(true).bytes().await }
});
let metrics_id = mock.expect_cmd("Page.getLayoutMetrics").await;
mock.reply(
metrics_id,
json!({
"cssLayoutViewport": { "pageX": 0, "pageY": 0, "clientWidth": 800, "clientHeight": 600 },
"cssVisualViewport": { "pageX": 0, "pageY": 0, "clientWidth": 800, "clientHeight": 600,
"offsetX": 0, "offsetY": 0, "scale": 1, "zoom": 1 },
"cssContentSize": { "x": 0, "y": 0, "width": 1280.0, "height": 4200.0 },
}),
)
.await;
let shot_id = mock.expect_cmd("Page.captureScreenshot").await;
let sent = mock.last_sent();
let params = &sent["params"];
assert_eq!(params["format"], "png");
assert_eq!(params["captureBeyondViewport"], true);
let clip = ¶ms["clip"];
assert_eq!(clip["x"], 0.0);
assert_eq!(clip["y"], 0.0);
assert_eq!(clip["width"], 1280.0);
assert_eq!(clip["height"], 4200.0);
assert_eq!(clip["scale"], 1);
mock.reply(shot_id, json!({ "data": "UE5HIQ==" })).await;
let bytes = fut.await.unwrap().unwrap();
assert_eq!(bytes, b"PNG!");
conn.shutdown();
}
}