Making JavaScript Art (P. 1)
Or, in other words, turning code into pictures.
Link to the project: makejsart.pelmers.com
15-second Video demo
The land of JavaScript source code formatting is teeming with projects that promise an outcome, like beautifier and prettify. They'll all polish up the uneven spaces, missing semicolons, and inconsistent quote marks, like a make-up routine for your code. But how about I make a promise even more bold than theirs? Can I make your code into art?
Well maybe not... but at least we can try!
This post will share how I built makejsart.pelmers.com and document some of the interesting technical challenges along the way.
see angular.js in its true form
The Goal
The problem I aim to solve is to format JavaScript code into the style of ascii art.
Example of the effect
Approach
I realized that I could break the problem into two principal components.
- Format source code to an arbitrary shape (this post)
- Given a picture, decompose it into a collection of shapes (next post)
I figured that if I solve these two pieces, I'd just have to put them together to satisfy my goal. Easy enough!
Code Formatting
As I mentioned at the outset, JavaScript already has plenty of source code formatters out there. Because I've looked at the source code for one, I will confidently declare they all follow the same general recipe:
- Parse source code into an AST
- Print the AST back out with appropriate spaces between nodes
I'm all for being lazy where possible, so why not re-use this recipe for our needs? If we take an off-the-shelf JavaScript parser, then the problem of matching code to shapes boils down to just figuring out where to add whitespace.
// step 1 is a one-liner!
import { parse } from '@babel/parser';
const ast = parse(code);
Whitespace marker insertion
Step 1 was easy, but how about step 2? How do we know between which syntax tree nodes we can add or remove spaces? Let's consider how white space in JavaScript boils down to 3 main classes:
- optional spaces: there can be zero or more spaces, or new lines, between the two tokens. For example, these are all valid and the same:
log( "hello")
log(
"hello"
)
log("hello")
- required space: at least one space is required, but it can be a new line. Usually happens with keywords.
function hello() {} // valid
functionhello() {} // invalid
function
hello() {} // valid
- unbreakable space: at least one space is required, and it must not be a new line.
() => {
return 1;
}
() => {
return
1;
} // different behavior, function will return 'undefined'
To implement this, I examined the internals of an existing AST-to-source transformation and made some surgical adjustments to its operation. Or we can call it what it is, which is monkey-patching. Instead of emitting plain spaces, I print out special "white space markers" that classify each space as one of these three classes. The marker can be any string that won't appear in the original code, such as a universal unique id. The relevant source code is in generator.ts.
A little bit of geometry
For now, if we limit the discussion to polygons, we can use the line-centric nature of code to define a shape as a function that maps line number to the pair (leading whitespace, desired width). For example, to make a rectangle, your shape function would return the same width on each line, in other words (0, constant). To create a triangle with base \(b\), height \(h\), and the pointy end on top, the shape to width function would be \(s(n) = (\frac{n}{h})(b)\).
Naturally, the follow-up is how do you find the base and height? For that, we need to make an assumption about the font used for display, specifically the font's ratio of line height to character width. Then we can compute the area of the code and map that to the shape's proportions to derive its height and width. If the font's ratio is \(height = width * 1.7\), then its area (technically in ch units) is just \(1.7 * code length\) (imagine the text all stretched out on one line).
Final step: code to shape!
Alright, now we have two ingredients: 1) the whitespace-marked source code, and 2) a formula to compute the width of each line. To put these together, all we need is a straight-forward algorithm:
split the code on whitespace markers into a list of alternating code and space items
while we still have code left, take the next item,
if the item is unbreakable or it's code,
add it to the current line
if it's a space,
find the next possible breakable point in the list
if that point exceeds the current line's target width,
create a new line
otherwise,
add the space to the current line
The runnable code is also well-commented (if you'll excuse a bit of self-praise): reshape.ts.
This process usually leaves each line with some room at the end, so finally a justification routine randomly intersperses spaces into the line until it reaches the target width and makes it all nice and tidy.
function
wN( Xo ){
"use strict" ;
function io( nv){Xo
.JY(nv, 'font-weight'
,'bold') ; Xo . JY( nv,
'font-weight' );Xo .JY(nv,
'stroke-width','0px') ; if (Xo.
mm () .property(nv,'type' ) ==
'ACTIVITY_SPAWN'){Xo.Rc(nv, 'fill',
'rgb(161,217,155)');}}function Xg(nv,UW
){ for (var pm=0;pm<UW .length;PN++ ){var
Se=UW[pm] ; if (Xo.mm().edge_property( nv ,Se,
'type') === 'join' ){Xo.lM(nv ,Se , 'cursor' ,
'pointer' ); } } } Xo . Pk( io, Xg) ; }
An example to tie everything togther
Pictures to shapes
You'll have to see part 2! That covers salient region detection in JavaScript and room for future improvements.
If you read this far and haven't tried it out, what are you waiting for???
Here, have a link: makejsart.pelmers.com. If you're looking for some sample code to feed in, try copy-pasting this React source code.
View Comments