Autobrand - how does it work?
This post is the explanation I promised you for this autobranding/autotheming trick.
I’m going to show you a few attempts that don’t work, and each attempt will get a little bit better. In the end, we’ll see the full technique in action.
Preliminaries
A reference image
In the demo I shared, I apply the effect to the top-level HTML element, affecting the entire page. Changing the whole page makes it hard to compare and contrast different approaches. Here, I will instead apply different effects to a reference image.
The constraints of the problem
If we want a page in dark mode, it’s usually the case that charts have to be rerendered in dark mode. Worse yet, if webpages want to provide both light and dark mode (which is common nowadays, with OSs allowing users to control this setting), such an approach would require rendering multiple versions of the charts.
In today’s R and Python APIs, that means writing code differently than you would in a single-theme setting. That’s annoying.
That brings us to the first constraint I’m setting for myself: whether it’s possible to apply theming effects to both text and charts of a Quarto document, without changing how charts are produced.
The technique I developed relies on feColorMatrix
, which is a particular kind of SVG filter. Note: even though they’re called “SVG filters”, they can apply to any element on a page.
feColorMatrix
provides an additional constraint: its parameter is a matrix of RGBA values in homogeneous coordinates, so our transformations are limited to be affine: a linear transformation plus a fixed additive offset. Concretely, we can only use a 4x5 matrix.
Color spaces
We will assume an RGB cube from \([0,0,0]\) to \([1,1,1]\).
We will also make use of the XYZ color space, and will assume a D65 white point.
Dark mode, basic
One very simple affine transformation is the “image invert”: \(f([r,g,b, a]) = [1-r, 1-g, 1-b, a]\). This transformation can be represented by the following homogeneous matrix:
\[\left ( \begin{array}{ccccc} -1 & 0 & 0 & 0 & 1 \\ 0 & -1 & 0 & 0 & 1 \\ 0 & 0 & -1 & 0 & 1 \\ 0 & 0 & 0 & 1 & 0 \end{array} \right )\]
If we apply this transformation to the reference image, we get a pretty bad version of dark mode: our hues have all turned around. Reds became cyans, greens became purples, blues became yellows.
If we do an autotheming transformation that flips dark colors to light ones, we will have to fix our hues. Oh well, at least this transformation is linear.
Linear hue inversion
If you look at the typical Hue, Saturation, and Lightness color spaces, you will find that the calculation of hue is pretty much a procedure call which doesn’t seem to be linear at all. So how can we get a hue inversion to be linear?
The first hint is that hues are a circle. Instead of thinking of hues as a single number, think instead of two coordinates in a plane.
The XYZ color space is a good place for us to start. It has two important features:
- the transformation from RGB to XYZ and back is linear.
- the Y coordinate is meant to represent the luminance of a color
It takes a bit of convincing ourselves, but if RGB can be represented as XYZ and Y is luminance, then hue must somehow be encoded in the X and Z values.
Now think about the circle of hues. The transformation we want is to invert only the hue: each color should become its opposite on the circle, and nothing else should change. So we need to transform the XZ values across “the neutral values”.
(NB: I would be shocked if the following trick hasn’t been noticed before, but I don’t know of a reference right away, so I invented a name for it. Color theory experts, my DMs are open.)
In order to flip the hue with a linear inversion, we want a new coordinate system \((X', Y, Z')\) where neutral colors have \(X' = Z' = 0\). Here, we use a shear transformation, a slightly uncommon linear operator. Specifically, the transformation to take XYZ to X’YZ’ is given by the following matrix:
\[\left ( \begin{array}{ccc} 1 & -0.950 & 0 \\ 0 & 1 & 0 \\ 0 & -1.088 & 1 \end{array} \right )\]
With our colors in X’YZ’ space, we can flip the hues by negating the X’ and Z’ values, also a linear operation. We then convert back to XYZ space, and then back to RGB space. These are all linear operations, so we can put them all in a matrix.
This gives us a way to invert the hues of an image without changing the luminance or saturation of the colors (kind of. We’re doing all of this with linear operations, which means we can only go so far).
✨Linear algebra✨: Dark Mode
Now for the first cool trick. Because both operations above are linear, their composition is also linear. So we can just multiply the two matrices (if you’re paying attention, you’ll have noticed we’re currently specifying 4x5 matrices that can’t be multiplied together. We can make a 5x5 version of the matrices with the homogeneous component kept throughout, and then throw the bottom row away after composing.) (if you’re really paying attention and reading the source of this page, you’ll see that the easy way out is to just use two feColorMatrix
filters in sequence)
Brand: Easy mode
What if the theme you want has a non-white background and a non-black foreground? Then there’s a simple solution based on linear interpolation. What you want is a transformation that sends \([0,0,0]\) to our foreground color \(\texttt{fg}\), sends \([1,1,1]\) to our background color \(\texttt{bg}\), and preserves lines (that is, it sends lines to lines). A coordinate-wise linear interpolation transformation serves just fine.
For the sake of concreteness, let’s use a simple theme \(\texttt{bg} = [0, 0.3, 0], \texttt{fg} = [1,1,1]\). In this case, the transformation is:
\[\left ( \begin{array}{ccccc} 1 & 0 & 0 & 0 & 0 \\ 0 & 0.7 & 0 & 0 & 0.3 \\ 0 & 0 & 1 & 0 & 0 \\ 0 & 0 & 0 & 1 & 0 \end{array} \right )\]
Dark Brand: Not quite so easy
If we take the same idea and apply it to a theme that makes the background dark, we run into our original problem: the hues are flipped:
✨Linear algebra✨: Putting it all together
If we flip the hues on top of the other transformation, then we’re almost there. Because we flipped the hues of the entire color space, we get the theme flipped! But there’s a simple trick to get us out of this. If what we would get would be flipped from what we wanted, we can just ask for the flipped color to get what we want! (the weird spacing around this image is needed, tyvm Chrome? This appears to be a bug on the browser, yikes)
Easy as pie! :)
Notes
We actually have two different classes of transformations: one that flips hues, and one that doesn’t. My code decides on which to pick depending on whether we’re flipping the luminance axis. As a result, if the foreground-to-background line doesn’t change the luminance axis, then this will give bad results. But if you make a theme with the same luminance for foreground and background, then you deserve what’s coming for you :p
I’m not sure how robust these SVG filters are when it comes to browser support. I’m seeing some pretty strange behavior (when filters are applies to individual images, eventually the browser goes haywire and starts literally painting outside the boxes). In addition, apparently this doesn’t even work on Firefox for me.
More work is needed.