DEV Community

Custodia-Admin
Custodia-Admin

Posted on • Originally published at pagebolt.dev

How to take screenshots and generate PDFs in Rust

How to Take Screenshots and Generate PDFs in Rust

Rust has no native headless browser. Teams that need screenshots or PDFs typically reach for headless_chrome (a Rust binding for Chrome DevTools Protocol), fantoccini (WebDriver), or shell out to a Puppeteer subprocess. All three require a running Chrome installation and add complexity to deployment.

Here's the simpler path: one HTTP call with reqwest, binary response, no browser.

Dependencies

# Cargo.toml
[dependencies]
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
serde_json = "1"
Enter fullscreen mode Exit fullscreen mode

Screenshot from a URL

use reqwest::Client;
use std::env;

async fn screenshot(url: &str) -> Result<Vec<u8>, reqwest::Error> {
    let api_key = env::var("PAGEBOLT_API_KEY").expect("PAGEBOLT_API_KEY not set");
    let client = Client::new();

    let body = serde_json::json!({
        "url": url,
        "fullPage": true,
        "blockBanners": true
    });

    let bytes = client
        .post("https://pagebolt.dev/api/v1/screenshot")
        .header("x-api-key", api_key)
        .json(&body)
        .send()
        .await?
        .bytes()
        .await?;

    Ok(bytes.to_vec())
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let image = screenshot("https://example.com").await?;
    std::fs::write("screenshot.png", &image)?;
    println!("Screenshot saved ({} bytes)", image.len());
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

PDF from a URL

async fn pdf_from_url(url: &str) -> Result<Vec<u8>, reqwest::Error> {
    let api_key = env::var("PAGEBOLT_API_KEY").expect("PAGEBOLT_API_KEY not set");
    let client = Client::new();

    let bytes = client
        .post("https://pagebolt.dev/api/v1/pdf")
        .header("x-api-key", api_key)
        .json(&serde_json::json!({ "url": url, "blockBanners": true }))
        .send()
        .await?
        .bytes()
        .await?;

    Ok(bytes.to_vec())
}
Enter fullscreen mode Exit fullscreen mode

PDF from HTML

async fn pdf_from_html(html: &str) -> Result<Vec<u8>, reqwest::Error> {
    let api_key = env::var("PAGEBOLT_API_KEY").expect("PAGEBOLT_API_KEY not set");
    let client = Client::new();

    let bytes = client
        .post("https://pagebolt.dev/api/v1/pdf")
        .header("x-api-key", api_key)
        .json(&serde_json::json!({ "html": html }))
        .send()
        .await?
        .bytes()
        .await?;

    Ok(bytes.to_vec())
}
Enter fullscreen mode Exit fullscreen mode

serde_json::json! handles escaping for you — no manual string building.

Reusable client struct

use reqwest::Client;
use std::env;

pub struct PageBolt {
    client: Client,
    api_key: String,
}

impl PageBolt {
    pub fn new() -> Self {
        Self {
            client: Client::new(),
            api_key: env::var("PAGEBOLT_API_KEY").expect("PAGEBOLT_API_KEY not set"),
        }
    }

    pub async fn screenshot(&self, url: &str) -> Result<Vec<u8>, reqwest::Error> {
        let bytes = self.client
            .post("https://pagebolt.dev/api/v1/screenshot")
            .header("x-api-key", &self.api_key)
            .json(&serde_json::json!({ "url": url, "fullPage": true, "blockBanners": true }))
            .send()
            .await?
            .bytes()
            .await?;
        Ok(bytes.to_vec())
    }

    pub async fn pdf_from_url(&self, url: &str) -> Result<Vec<u8>, reqwest::Error> {
        let bytes = self.client
            .post("https://pagebolt.dev/api/v1/pdf")
            .header("x-api-key", &self.api_key)
            .json(&serde_json::json!({ "url": url }))
            .send()
            .await?
            .bytes()
            .await?;
        Ok(bytes.to_vec())
    }

    pub async fn pdf_from_html(&self, html: &str) -> Result<Vec<u8>, reqwest::Error> {
        let bytes = self.client
            .post("https://pagebolt.dev/api/v1/pdf")
            .header("x-api-key", &self.api_key)
            .json(&serde_json::json!({ "html": html }))
            .send()
            .await?
            .bytes()
            .await?;
        Ok(bytes.to_vec())
    }
}
Enter fullscreen mode Exit fullscreen mode

Actix-web handler

use actix_web::{get, web, HttpResponse, Result};

#[get("/invoices/{id}/pdf")]
async fn invoice_pdf(
    path: web::Path<u64>,
    pagebolt: web::Data<PageBolt>,
    invoice_service: web::Data<InvoiceService>,
) -> Result<HttpResponse> {
    let id = path.into_inner();
    let html = invoice_service.render_html(id).await?;

    let pdf = pagebolt
        .pdf_from_html(&html)
        .await
        .map_err(|e| actix_web::error::ErrorInternalServerError(e))?;

    Ok(HttpResponse::Ok()
        .content_type("application/pdf")
        .append_header((
            "Content-Disposition",
            format!("attachment; filename=\"invoice-{}.pdf\"", id),
        ))
        .body(pdf))
}
Enter fullscreen mode Exit fullscreen mode

No browser, no ChromeDriver, no headless_chrome with its ChromeDriver version pinning. The reqwest client used here is the standard choice for async HTTP in Rust — it compiles to a single binary with no external runtime.


Try it free — 100 requests/month, no credit card. → Get started in 2 minutes

Top comments (0)