Faultd.

Journal of a software engineer.

Archive: 2023

A list of 3 posts.

Friends don't let friends unwrap

20 October, 2023

I'm pretty new to Rust, so I should say that not too long ago I thought unwrap-ing everything was the norm. Of course, I would check for things with is_some, is_none, is_ok, is_err and then unwrap, because I'm not a psychopath, but in the end of the day, I would still unwrap everything.

Then I discovered that I can just use ? instead, and capture all the errors in one big generic enum. This is made especially easy using the thiserror crate, with what my code can now look like this:

#[derive(Error, Debug)]
pub enum Error {
    #[error("JSON error: {0}")]
    Json(#[from] serde_json::Error),
    #[error("HTTP error: {0}")]
    Http(#[from] reqwest::Error),
    #[error("URL error: {0}")]
    Url(#[from] url::ParseError),
}

fn main() -> Result<(), Error> {
    let url = Url::parse("https://example.com")?;
    let response = reqwest::get(url)?;
    let body = response.text()?;
    let json: Value = serde_json::from_str(&body)?;
    
    println!("{}", json);
    
    Ok(())
}

Much nicer, right? I think so. Thanks to thiserror I can still have custom error messages, but also I get to use ? instead of unwrap-ing everything, leaving my code a lot cleaner and nicer to read.

Rendering a SwiftUI view to multiple A4 PDF pages using ImageRenderer

8 July, 2023

The current version of Invobi is able to create PDF’s using the SwiftUI ImageRenderer by rendering an entire SwiftUI view into a PDF file. It does so basically 1:1 with how the ImageRenderer documentation shows to do it. However, the resulting PDF is always just one page, and the size of it is determined by the size of the view, making it fine for online-only use, but difficult for printing.

I wanted to offer people options to change this. They could continue saving one-page PDF files with fluid sizes or they could choose a fixed format, like A4 or US Letter, which would save multi-page PDF’s, ideal for printing. To do this, I created dimensions for the size of a single PDF page based on the desired format, and then split the view into parts, rendering each part to its corresponding page based on translateBy(y:) of the CGContext.

Determine the ideal page size

Before we can do anything else, we should figure out what is the exact page size we want something to have. Let’s start with the format A4, which at 72dpi would be:

let pageWidth = 8.27 * 72
let pageHeight = 11.69 * 72

Create the renderer

Now that we know what we want each page to look like, let’s create the renderer, inside of which we’ll be doing all the math required to split views and to render them.

let view = YourSwiftUIView().frame(width: pageWidth)
let renderer = ImageRenderer(content: view)

renderer.render { size, context in
    // here we'll be doing our work
}

Create the PDF pages

Let’s create a CGRect and CGContext, which we’ll be using for drawing to our PDF.

var box = CGRect(x: 0, y: 0, width: pageWidth, height: pageHeight)

guard let pdf = CGContext(fileUrl as CFURL, mediaBox: &box, nil) else {
    return
}

Now let’s get a pageCount figured out, so we can loop over something to create the correct number of PDF pages per our selected format.

let totalHeight = size.height
let pageCount = Int(ceil(totalHeight / pageHeight))

Yup, that’s it, just divide the total view height by the desired page height. But, make sure to ceil that number up, because there most likely will be a case where the last page will only be partially filled, and have more than 50% of empty space, but you still want that partial rendering instead of losing that half a page.

And now, let’s create our PDF pages.

for page in 0...(pageCount - 1) {
    pdf.beginPDFPage(nil)

// Here we'll be doing the drawing

pdf.endPDFPage()
}

pdf.closePDF()

When you run the what we’ve so far come up with, you should be getting the correct number of PDF pages based on the A4 format and your SwiftUI view size, but they are all blank.

Draw to the PDF pages

Since the view is totalHeight tall, and our PDF pages are pageHeight tall, we need to start positioning our view in our PDF pages in such a way that it’ll start from a point where the previous page left off. To do this, we’ll be using the CGContext‘s translateBy method, which allows us to to position our PDF page area anywhere in the SwiftUI view we are rendering. Think of it like a magnifying glass only going over a specific portion of a newspaper.

There’s one thing though, which complicates this a bit – it’s that the translateBy(y:) goes from bottom to top, not from top to bottom, meaning that we have to do our positioning in reverse, where the last page has the Y coordinate being 0, and the first page has the Y coordinate -{totalHeight}.

Let’s calculate our Y coordinate.

let y = -(Double(pageCount - 1) - Double(page)) * pageHeight

This takes the page count, but deducts 1 so that we start from 0, then takes the current page, deducts that from the result as well, and multiplies it by pageHeight, thus arriving at a result -0.0 for our last page and -totalHeight for our first page. Remember, we’re going backwards here.

So, putting this together, we can draw our pages like this:

for page in 0...(pageCount - 1) {
    pdf.beginPDFPage(nil)

let y = -(Double(pageCount - 1) - Double(page)) * pageHeight

pdf.translateBy(x: 0, y: CGFloat(y))
    context(pdf)

pdf.endPDFPage()
}

But wait, that’s not quite right!

You might be seeing some odd extra space on top of the first page. Why isn’t the page starting from the beginning like it should?

Well, remember that ceil we did way back in the beginning to round upward the number of pages we want? That’s the reason. Because of the ceil, we get some extra space we don’t actually use, and because the whole translateBy(y:) logic goes backwards, the empty space does not show up in the end of the last page, where it would be fine, but rather in beginning before the first page, where it is not fine.

But, worry not, the fix is rather simple. We first have to figure out what is that extra space, and add it to our let y , like this:

let emptyOffset = (Double(pageCount) * pageHeight) - totalHeight

I suggest to do this calculation right before the for loop, because we only need to calculate it once and not per page. Then, once that’s done, modify the let y like this:

let y = -(((Double(pageCount - 1) - Double(page)) * pageHeight) - emptyOffset)

And that solves that problem. Now you should have a perfectly functioning, multi-page PDF rendering system. If you want to change the format, simply change the pageWidth and pageHeight variables accordingly, the rest will continue working without needing any changes.

Routing with Ruuter in a Reagent / Re-frame project

27 February, 2023

Ruuter, my zero-dependency Clojure(Script) router can be used as a general router, without any HTTP server as well. This is true for both Clojure and ClojureScript, and because the router has no dependencies, also true for Babashka and NBB, and is exactly what I did in a Reagent / Re-frame project recently, and here’s how I did it.

At the core of it all are your routes, let’s define them as something simple:

(def routes
  [{:path "/"
    :response (fn [_]
                [:div "Hello, World"])}
   {:path "/hello/:who"
    :response (fn [{params :params}]
                [:div "Hello, " (:who params)])}])

Unlike with a HTTP server such as HTTP-Kit, we don’t need the route to have a :method, nor do we need it to return a response map. We can have it return anything we want, which in this case is a Reagent component.

Now let’s create a Re-frame event for setting URI path:

(ns events
  (:require
    [re-frame.core :refer [reg-event-fx]]))

(reg-event-fx
  :set-path
  (fn [{db :db} [_ path]]
    (.pushState (.-history js/window) nil "" path)
    {:db (assoc db :path path)}))

This allows us to call a :set-path event whenever we want to change the current route in-place, and it will also update the URL visible in the browser.

Then let’s create a Re-frame subscription, so we could listen to said path:

(ns subs
  (:require
    [re-frame.core :refer [reg-sub]]))

(reg-sub
  :path
  (fn [db _]
    (-> db :path)))

And finally let’s put it all to work in our core component:

(ns core
  (:require
    [reagent.core :as r]
    [reagent.dom :as rd]
    [re-frame.core :refer [dispatch dispatch-sync subscribe]]
    [ruuter.core :as ruuter]
    [events]
    [subs]))

(def routes
  [{:path "/"
    :response (fn [_]
                [:div "Hello, World"])}
   {:path "/hello/:who"
    :response (fn [{params :params}]
                [:div "Hello, " (:who params)])}])

(defn- app []
  (let [popstate-fn #(dispatch [:set-path (-> js/window .-location .-pathname)])
        path (subscribe [:path])]
    (r/create-class
      {:component-did-mount
       (fn [_]
         (dispatch-sync [:initialise-db])
         (.addEventListener js/window "popstate" popstate-fn))
       :component-will-unmount
       (fn [_]
         (.removeEventListener js/window "popstate" popstate-fn))
       :reagent-render
       (fn []
         (when @path
           (ruuter/route routes {:uri @path})))})))

(defn ^:export init []
  (rd/render [app] (.querySelector js/document "#app")))

As you can see, when the Reagent app loads, it adds an event listener for popstate, which listens to a URI change by the user. Thus, if the user changes the URL manually, the app will call :set-path on its own. Regardless if you call the :set-path event yourself manually or whether the popstate event promps that call, the end result is the same – it re-renders the app component, which then will run Ruuter again, matching against the new path, loading the corresponding component.

So if you now navigate to /hello/John, it should render “Hello, John” on the page. Oh and, currently when you visit the page via a link directly, it won’t load the correct component, because the default path isn’t set, so I recommend you set it via your Re-frame db initialisation, like so:

(ns events
  (:require
    [re-frame.core :refer [reg-event-fx]]))

(def default-db
  {:path (-> js/window .-location .-pathname)})

(reg-event-fx
  :initialise-db
  (fn [_ _]
    {:db db/default-db}))

And that’s how you can use Ruuter to do any type of routing, whether that would be in Clojure side, ClojureScript or even in Babashka and NBB.