I recently finished up a long engagement with one of my clients, AdmitHub. To celebrate the work we had done together, I wanted to give them a going away gift.
I liked the idea of giving them a Commits.io poster, but I wasn’t comfortable handing out access to my client’s private repository to a third party.
Not to be deterred, I decided to use this opportunity to practice Elixir and build my own poster generator!
In the process of making the AdmitHub poster, I also made an Elixir poster to celebrate my ever increasing love for the language.
High-level Strategy
If we approach the problem of generating a code poster from a high level, we can break it into three distinct parts.
First, we’ll want to load a source image and a blob of source code. These pieces of data are the raw building blocks for our final poster.
Next, we’ll need to merge this data together. Our ideal outcome is an SVG filled with <text>
elements. Each <text>
element will contain one or more characters, or code points, from our source code blob, colored to match the corresponding pixel in our source image.
Once we’ve merged this data together in memory, we need to generate our final SVG and save it to disk.
With our SVG in hand, we can use a tool like Adobe Illustrator or Inkscape to render it into a more printer friendly format and then send it off to be printed!
Loading Our Source Data
Our application will make use of the Imagineer elixir library to load our source image. As an example, we’ll be using a beautiful version of the Elixir logo as designed by Bruna Kochi.
Before loading our image into our application, we need to consider a few things.
We’ll be using the Source Code Pro font to render the code in our poster. It’s important to realize that the characters in Source Code Pro, while monospaced, aren’t perfectly square. It turns out that the ratio of each character’s width to its height is 0.6
.
This means that if we’re inserting a character of code for every pixel in our source image, and we want our final poster to maintain the aspect ratio of the original logo, we’ll need to scale the width of our source image by a factor of 1.667
.
The total width and height of our source image also effects the outcome of our poster. More pixels means more (and smaller) code characters. A width and height of 389px
by 300px
gives good results.
Check out the scaled and resized source image to the right.
Once we’ve scaled and resized our source image, loading it into our Elixir application is a breeze:
{:ok, image} = Imagineer.load(image_path)
Within the resulting image
struct, we’ll find a pixels
field that holds all of the raw pixel data for the image in a two-dimensional array of tuples. Each tuple holds the RGB value for that specific pixel.
Now that we’ve loaded our source image, we’ll need to load the code we want to render onto our poster.
Rather than letting a library do the heavy lifting for us, we’ll take a more hands-on approach here.
We’ll use File.read!
to load the file at code_path
into memory, strip out any excess whitespace with the join_code
helper function, and finally split the resulting string into a list of individual code points:
code = code_path
|> File.read!
|> join_code
|> String.codepoints
The join_code
function simply replaces all newlines with leading and trailing spaces with a single space character, and replaces all non-space whitespace characters (tabs, etc…) with spaces:
def join_code(code) do
code
|> String.trim
|> String.replace(~r/\s*\n+\s*/, " ")
|> String.replace(~r/\s/," ")
end
And with that, we’ve successfully loaded both our source image, and our source code!
Merging Pixels and Code Points
The real meat of our application is in merging these two disparate sets of data.
Our goal is to build an in-memory data structure that places each code point from our source code blob in its correct position on the poster, and colors it according to the pixel corresponding to that same position.
Imagineer delivers pixel data in the form of a list of rows of pixels. Each pixel is a tuple of RGB values. The structure of this data influences how we’ll structure our solution.
Ultimately, we’ll map
over each row of pixels and reduce
each row down to a list of tuples representing each <text>
element in our final poster.
The reduction of each row is probably the most interesting part of our application. When reducing an individual pixel from a row of pixels, there are three possible scenarios.
This might be the first pixel we’ve encountered in a row. In that case, create a new <text>
element:
def merge_pixel_into_row(fill, character, x, y, []) do
[{:text, %{x: x, y: y, fill: fill}, character}]
end
The current pixel might match the fill
color of the previous pixel. In that case, append the current character
to the body of the previous <text>
element:
def merge_pixel_into_row(fill, character, _, _,
[{:text, element = %{fill: fill}, text} | tail]) do
[{:text, element, text <> character} | tail]
end
Notice that we’re pattern matching the current fill
color to the fill
color of the previously seen text element. Awesome!
Lastly, the current pixel might be a different color. In that case, create and append a new <text>
element to the head of the list:
def merge_pixel_into_row(fill, character, x, y, pixels) do
[{:text, %{x: x, y: y, fill: fill}, character} | pixels]
end
After flattening the results of our map
/reduce
, we finally have our resulting list of correctly positioned and colored <text>
elements!
Building Our SVG
Now that we’ve built up the representations of our <text>
elements, we can finally construct our SVG.
We’ll use the xml_builder
Elixir module to generate our final SVG. Generating an SVG with xml_builder
is as simple as passing an :svg
tuple populated with our text elements and any needed attributes into XmlBuilder.generate
:
{:svg,
%{
viewBox: "0 0 #{width*ratio} #{height}",
xmlns: "http://www.w3.org/2000/svg",
style: "font-family: 'Source Code Pro'; font-size: 1; font-weight: 900;",
width: final_width,
height: final_height,
"xml:space": "preserve"
},
text_elements}
|> XmlBuilder.generate
Notice that we’re defining a viewBox
based on the source image’s width
, height
, and our font’s ratio
. Similarly, we’re setting the final width
and height
based on the provided final_width
and final_height
.
The "xml:space": "preserve"
attribute is important. Without preserving whitespace, text elements with leading with space characters will be trimmed. This results in strangely chopped up words in the final poster and is a difficult issue to track down (trust me).
Lastly, we pass in the list of :text
tuples we’ve generated and stored in the text_elements
list.
The Final Poster
After the final SVG is generated and saved to disk, it can be loaded into Illustrator or Inkscape and rendered to a PNG for printing.
A 10500
by 13500
pixel image at 300 dpi looks fantastic when printed onto a 20x30 inch poster.
Overall, I’m very happy with the outcome of the final poster. It beautifully commemorates the work my client and I accomplished together over the past two years, and will hang proudly in my office and theirs.
The Elixir poster turned out very nicely as well. If you’re interested or want to print your own poster, you can download the full resolution output image here.
The application takes approximately one minute to generate a 20x30 inch poster, depending on the color variation per row. This performance can almost definitely be improved and may be the subject of a future post.
Was Elixir the tool for this project? Probably not. Was it still a fun project and a good learning experience? Absolutely.
If you’re interested in seeing more of the code, be sure to check out the entire project on Github.