The social media landscape from Twitter and Mastodon to Instagram and TikTok has, for better or worse, centralized on sharing text highlights and quotes as images rather than as plain text.

Now I can share my highlights easily as images on social media!

I like to share my highlights from across the web, which is why I publish topic-specific RSS feeds for people to subscribe to.

However, one of the features that I’ve been missing for a while now on Notado is exporting screenshots for social sharing. This weekend I finally set aside some time to make this feature a reality.

Creating the View

I have a lot of qualms with Tailwind, but it’s hard to deny that it’s a very convenient choice for a view that is largely isolated from the rest of a web application, especially one that exists primarily as an export view.

I was quickly able to get a design together on play.tailwindcss.com, which I then moved over to a Tera HTML template to be rendered by the Rust web server that powers Notado.

Interactively building the export view design on play.tailwindcss.com

Taking Chrome Screenshots on a Web Server

I initially tested out taking screenshots on a headless instance of Chrome locally using capture-website-cli. This was very helpful in giving me an idea of what was possible, how I should target the right elements, how to modify the page width to be able to export longer content properly, etc.

However, this tool had not been packaged in nixpkgs, the repository doesn’t even include a lockfile, and I’m generally not a fan of shelling out blocking commands from a handler on a web server.

I started taking a look in the Rust ecosystem and came across the rust-headless-chrome crate.

A high-level API to control headless Chrome or Chromium over the DevTools Protocol. It is the Rust equivalent of Puppeteer, a Node library maintained by the Chrome DevTools team.

This was just what I was looking for.

As I mentioned above, I’m not a fan of potentially long blocking commands running inside a handler on a web server, and spinning up Chrome instances on demand to take screenshots is not exactly easy on the hardware.

I decided to write and deploy a separate microservice called on a larger dedicated machine that I rent for various miscellaneous workloads.

Below is some pseudocode demonstrating roughly what a screenshot microservice handler in Axum might look like:

use axum::body::Body;
use axum::extract::Path;
use axum::response::Response;
use axum::http::StatusCode;

use headless_chrome::protocol::cdp::Page::CaptureScreenshotFormatOption;
use headless_chrome::protocol::cdp::Target::CreateTarget;
use headless_chrome::Browser;

async fn generate_screenshot(Path(id): Path<String>) -> Result<Response<Body>, AppError> {
    let browser = Browser::default()?;

    let tab = browser.new_tab_with_options(CreateTarget {
        url: format!("about:blank"),
        width: Some(1080),
        height: Some(5000),
        browser_context_id: None,
        enable_begin_frame_control: None,
        new_window: None,
        background: None,
    })?;

    tab.navigate_to(&format!("https://your.website/export-view/{id}"))?;
    tab.wait_until_navigated()?;
    tab.wait_for_element("#element-to-screenshot")?;

    let element = tab.find_element("#element-to-screenshot")?;
    element.scroll_into_view()?;

    let box_model = element.get_box_model()?;
    let mut viewport = box_model.margin_viewport();
    viewport.scale = 2.0;

    let screenshot = tab.capture_screenshot(
        CaptureScreenshotFormatOption::Png,
        Some(100),
        Some(viewport),
        true,
    )?;

    Ok(Response::builder()
        .status(StatusCode::OK)
        .header("Content-Type", "image/png")
        .body(Body::from(screenshot))?)
}

Some things to note here:

  • Set a large height so that longer text content can be screenshotted without unwanted distortions or cutoffs
  • Set viewport.scale to 2.0 because the default 1.0 scale doesn’t look very nice when viewed on iPhones
  • The headless_chrome crate uses anyhow::Error as its error type, so you can write custom From and IntoResponse implementations on a new type that wraps it for cleaner error handling with ?
use axum::response::IntoResponse;
use axum::http::StatusCode;

struct AppError(anyhow::Error);

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            format!("Something went wrong: {}", self.0),
        )
            .into_response()
    }
}

impl<E> From<E> for AppError
where
    E: Into<anyhow::Error>,
{
    fn from(err: E) -> Self {
        Self(err.into())
    }
}

Deploying the Microservice

The entire Notado stack is managed with Nix and deployed on NixOS. This makes deploying the new microservice pretty simple.

Below is some Nix pseudocode showing how you might set up a systemd service to run a screenshotter microservice, and caddy to route incoming requests on the desired subdomain to the screenshotter microservice (make sure you set a DNS record pointing to the IP address of the machine you’re deploying to otherwise this won’t work).

{
  systemd.services.screenshotter = {
    description = "screenshotter";
    after = [
      "network.target"
    ];

    wantedBy = ["multi-user.target"];
    serviceConfig = {
      Type = "simple";
      DynamicUser = true;
      Environment = [
        "PATH=${pkgs.google-chrome}/bin:$PATH"
      ];
      ExecStart = "${pkgs.your-package}/bin/screenshotter";
      Restart = "on-failure";
    };
  };

  services.caddy = {
    enable = true;
    virtualHosts = {
        "https://screenshotter.your.website".extraConfig = "reverse_proxy 127.0.0.1:<SCREENSHOTTER_PORT>"
    }
  };
}

There is one tiny little quality of life improvement when deploying services on NixOS that I just cannot live without now; being able to add binaries to the $PATH of a service without polluting the global $PATH of the system. In the example above, you can see the directory of the Google Chrome binary is prepended to the path of the screenshotter microservice.

If you have any questions you can reach out to me on Twitter and Mastodon.

If you’re interested in what I read to come up with solutions like this one, you can subscribe to my Software Development RSS feed.

If you’d like to watch me writing code while explaining what I’m doing, you can also subscribe to my YouTube channel.