DEV Community

Custodia-Admin
Custodia-Admin

Posted on • Originally published at pagebolt.dev

How to take screenshots and generate PDFs in Scala

How to Take Screenshots and Generate PDFs in Scala

Scala inherits the JVM's headless browser problem. Options typically include Selenium WebDriver (requires ChromeDriver), scala-scraper (HTML parsing, not rendering), or bridging to a Node.js Puppeteer process. None of these are easy to manage in an Akka or ZIO application.

Here's the cleaner path: one HTTP call, binary response. Works in any Scala project — Play, Akka HTTP, http4s, or plain sbt run.

Using sttp (recommended)

sttp is the standard choice for synchronous and async HTTP in Scala:

// build.sbt
libraryDependencies ++= Seq(
  "com.softwaremill.sttp.client4" %% "core" % "4.0.0-RC1",
  "com.softwaremill.sttp.client4" %% "circe" % "4.0.0-RC1",
  "io.circe" %% "circe-generic" % "0.14.7"
)
Enter fullscreen mode Exit fullscreen mode
import sttp.client4._
import sttp.client4.circe._
import io.circe.generic.auto._

object PageBolt {
  private val apiKey = sys.env("PAGEBOLT_API_KEY")
  private val baseUrl = "https://pagebolt.dev/api/v1"
  private val backend = DefaultSyncBackend()

  case class ScreenshotRequest(url: String, fullPage: Boolean = true, blockBanners: Boolean = true)
  case class PdfRequest(url: Option[String] = None, html: Option[String] = None, blockBanners: Boolean = true)

  def screenshot(url: String): Array[Byte] =
    basicRequest
      .post(uri"$baseUrl/screenshot")
      .header("x-api-key", apiKey)
      .body(ScreenshotRequest(url))
      .response(asByteArrayAlways)
      .send(backend)
      .body

  def pdfFromUrl(url: String): Array[Byte] =
    basicRequest
      .post(uri"$baseUrl/pdf")
      .header("x-api-key", apiKey)
      .body(PdfRequest(url = Some(url)))
      .response(asByteArrayAlways)
      .send(backend)
      .body

  def pdfFromHtml(html: String): Array[Byte] =
    basicRequest
      .post(uri"$baseUrl/pdf")
      .header("x-api-key", apiKey)
      .body(PdfRequest(html = Some(html)))
      .response(asByteArrayAlways)
      .send(backend)
      .body
}
Enter fullscreen mode Exit fullscreen mode

Play Framework controller

import play.api.mvc._
import play.api.libs.ws._
import javax.inject._
import scala.concurrent.{ExecutionContext, Future}

@Singleton
class InvoiceController @Inject() (
  ws: WSClient,
  cc: ControllerComponents
)(implicit ec: ExecutionContext) extends AbstractController(cc) {

  private val apiKey = sys.env("PAGEBOLT_API_KEY")
  private val baseUrl = "https://pagebolt.dev/api/v1"

  def downloadPdf(id: Long) = Action.async {
    val html = renderInvoiceTemplate(id)

    ws.url(s"$baseUrl/pdf")
      .withHttpHeaders("x-api-key" -> apiKey, "Content-Type" -> "application/json")
      .post(s"""{"html":${ujson.Str(html)}}""")
      .map { response =>
        Ok(response.bodyAsBytes)
          .as("application/pdf")
          .withHeaders("Content-Disposition" -> s"""attachment; filename="invoice-$id.pdf"""")
      }
  }
}
Enter fullscreen mode Exit fullscreen mode

Akka HTTP route

import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.model._
import akka.http.scaladsl.Http
import akka.stream.Materializer
import scala.concurrent.Future

class InvoiceRoutes(pagebolt: PageBoltAsync)(implicit mat: Materializer) {
  val routes =
    path("invoices" / LongNumber / "pdf") { id =>
      get {
        val html = renderInvoiceTemplate(id)
        onSuccess(pagebolt.pdfFromHtml(html)) { pdfBytes =>
          complete(
            HttpResponse(
              entity = HttpEntity(ContentType(MediaTypes.`application/pdf`), pdfBytes),
              headers = List(
                headers.`Content-Disposition`(
                  ContentDispositionTypes.attachment,
                  Map("filename" -> s"invoice-$id.pdf")
                )
              )
            )
          )
        }
      }
    }
}
Enter fullscreen mode Exit fullscreen mode

ZIO-based async client

import zio._
import zio.http._

object PageBoltZIO {
  val screenshot: ZIO[Client, Throwable, Chunk[Byte]] =
    for {
      apiKey <- System.env("PAGEBOLT_API_KEY").someOrFail(new Exception("PAGEBOLT_API_KEY not set"))
      response <- Client.batched(
        Request.post(
          url = URL.decode("https://pagebolt.dev/api/v1/screenshot").toOption.get,
          body = Body.fromString("""{"url":"https://example.com","fullPage":true}""")
        ).addHeader("x-api-key", apiKey)
         .addHeader(Header.ContentType(MediaType.application.json))
      )
      bytes <- response.body.asChunk
    } yield bytes
}
Enter fullscreen mode Exit fullscreen mode

No ChromeDriver, no Selenium, no browser process to supervise. sttp's synchronous backend works with any ExecutionContext and is straightforward to test — swap DefaultSyncBackend() for RecordingSttpBackend in tests.


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

Top comments (0)