Generating JavaScript from SVG, an Intro to Code Generation — Part 3
Adding some color, parsing CSS, and generating code
Welcome back again to our introduction to code generation. Generating code from some non-code description is a big topic, so we’re focusing our attention for now on the specific problem of generating JavaScript code from an SVG drawing. In the last part, we managed to generate code that could not only draw rectangles, but also render them at different sizes and aspect ratios.
In this last part of the series, we’re going to make our code generator smart enough to handle colors. We’re working from the repository at https://github.com/Cycling74/codegen-eng-blog. If you want to pick up where we left off last time, you can checkout the step-3
tag, with something like this:
git checkout tags/step-3
A Splash of Color
For the grand finale, how would we make the rectangles have color? For this, we’re going to have to go back to the SVG file. If we make a drawing in Illustrator with colored rectangles, it looks like this:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 353.03 321"> | |
<defs> | |
<style> | |
.cls-1 { | |
fill: #15ffb3; | |
} | |
.cls-2 { | |
fill: #4abaff; | |
} | |
.cls-3 { | |
fill: #ff00a5; | |
} | |
.cls-4 { | |
fill: #008ae9; | |
} | |
</style> | |
</defs> | |
<g id="Layer_2" data-name="Layer 2"> | |
<g id="Layer_1-2" data-name="Layer 1"> | |
<rect class="cls-1" x="70.96" y="20" width="29" height="187" /> | |
<rect class="cls-2" x="253.96" y="20" width="29" height="187" /> | |
<rect class="cls-3" width="353.03" height="41" /> | |
<rect class="cls-4" x="155.66" y="14" width="47.61" height="307" /> | |
</g> | |
</g> | |
</svg> |
Now we’ve got a really tricky situation on our hands. We’ve got an SVG file with inline CSS! However, in the same exact way that we parse SVG as XML, we can do just the same thing with CSS. The idea is, parse and walk through the CSS text. Every time you encounter a CSS class selector that has a fill
property, create a function that will color our rectangle correctly. In our generated code we’ll have something like this:
function style1() { | |
mgraphics.set_source_rgba(1, 0, 0, 1); | |
} | |
function paint() { | |
mgraphics.save(); | |
style1(); | |
mgraphics.rectangle(0, 0, 100, 100); | |
mgraphics.fill(); | |
mgraphics.restore(); | |
} |
So the challenge is twofold:
Create a styling function for each CSS class.
Call that function before drawing each rectangle.
Parsing CSS
First, let’s install our CSS parsing library.
npm i --save css-tree @types/css-tree
Now let’s think about how to get to the CSS text. If you look at the SVG file, you can see that the actual CSS is just the text of a tag called style
. Still, this is a bit tough for us. While htmlparser2
does let us define a function ontext
for when we encounter the text of an XML tag, it doesn’t give us an easy way of knowing whether that text belongs to a style
tag or not. The best solution I can come up with is something like this:
function translateSource(data: string, outPath: string) { | |
let paintStatements: t.Statement[] = []; | |
let viewBox: number[]; | |
let inStyle = false; | |
const parser = new html2.Parser({ | |
onopentag(name: string, attribs: {[s: string]: string}) { | |
// ... | |
else if (name === "svg") { | |
// Split the viewbox string on spaces and convert to numbers | |
viewBox = attribs["viewbox"].split(" ").map(Number.parseFloat); | |
} | |
else if (name === "style") { | |
inStyle = true; | |
} | |
}, | |
ontext(data: string) { | |
if (inStyle) { | |
// parse CSS | |
} | |
}, | |
onclosetag(name) { | |
if (name === "style") { | |
inStyle = false; | |
} | |
} | |
}); |
Now in order to know how to handle each CSS class selector, it’s helpful to put the CSS into AST Explorer.
Let’s try saying this in English, then we’ll worry about code. Given a CSS Rule, look in its SelectorList for a ClassSelector. The name of that ClassSelector will be the name of our styling function. Within that same Rule, look for a Block with a series of Declaration. Get declarations with the property “fill” and extract their value as a HexColor.
Here’s what that looks like in code:
import * as csstree from "css-tree"; | |
// ... | |
ontext(data: string) { | |
if (inStyle) { | |
let styleFunctionName; | |
let inDeclaration = false; | |
const cssast = csstree.parse(data); | |
csstree.walk(cssast, { | |
enter(node: csstree.CssNode) { | |
if (node.type === "Rule") { | |
// Reset state machine | |
styleFunctionName = undefined; | |
} | |
else if (node.type === "ClassSelector") { | |
styleFunctionName = createLegalName(node.name); | |
} | |
else if (node.type === "Declaration") { | |
inDeclaration = true; | |
} | |
else if (node.type === "HexColor") { | |
if (inDeclaration && styleFunctionName) { | |
const color = hexColorToColorArray(node.value); | |
styleFunctions = styleFunctions.concat( | |
makeStyleFunction({ | |
styleFunctionName: t.identifier(styleFunctionName), | |
r: t.numericLiteral(color[0]), | |
g: t.numericLiteral(color[1]), | |
b: t.numericLiteral(color[2]), | |
a: t.numericLiteral(color[3]) | |
}) | |
); | |
// Make sure we only have one | |
styleFunctionName = undefined; | |
} | |
} | |
}, | |
leave(node: csstree.CssNode) { | |
if (node.type === "Declaration") { | |
inDeclaration = false; | |
} | |
} | |
}); | |
} | |
}, |
This relies on two helper functions createLegalName
and hexColorToColorArray
. The first, createLegalName
, simply turns a CSS class selector into a legal name for a JavaScript variable. The second, hexColorToColorArray
goes from a hex string like #fff or #ff00ff to [1.0, 1.0, 1.0, 1.0] and [1.0, 0.0, 1.0, 1.0] respectively (note that an alpha value has been appended automatically. The babel template that actually makes the style functions, makeStyleFunction
, looks like this:
const makeStyleFunction = template(` | |
function %%styleFunctionName%%() { | |
mgraphics.set_source_rgba(%%r%%, %%g%%, %%b%%, %%a%%); | |
} | |
`); |
Adding Styling to the Rectangles
Now we need to modify our rectangle drawing code to be style aware. That’s as simple as adding a call to the styling function to the rectangle template, though we still want to be able to draw a rectangle without a style.
const makeRectDrawStatements = template(` | |
mgraphics.rectangle(%%x%% * aspect, %%y%%, %%w%% * aspect, %%h%%); | |
mgraphics.fill(); | |
`); | |
const makeStyledRectDrawStatements = template(` | |
mgraphics.save(); | |
%%styleFunctionName%%(); | |
mgraphics.rectangle(%%x%% * aspect, %%y%%, %%w%% * aspect, %%h%%); | |
mgraphics.fill(); | |
mgraphics.restore(); | |
`); |
And then we just need to pass the styleFunctionName
when we create the rect drawing statements.
onopentag(name: string, attribs: {[s: string]: string}) { | |
if (name === "rect") { | |
if (viewBox !== undefined) { | |
let x = t.numericLiteral( | |
2 * Number.parseFloat(attribs.x || "0") / viewBox[2] - 1 | |
); | |
let y = t.numericLiteral( | |
1 - 2 * Number.parseFloat(attribs.y || "0") / viewBox[3] | |
); | |
let w = t.numericLiteral( | |
2 * Number.parseFloat(attribs.width || "0") / viewBox[2] | |
); | |
let h = t.numericLiteral( | |
2 * Number.parseFloat(attribs.height || "0") / viewBox[3] | |
); | |
let rectStatements; | |
if (attribs.class) { | |
const styleFunctionName = t.identifier(createLegalName(attribs.class)); | |
rectStatements = makeStyledRectDrawStatements({ styleFunctionName, x, y, w, h }); | |
} else { | |
rectStatements = makeRectDrawStatements({ x, y, w, h }); | |
} | |
paintStatements = paintStatements.concat(rectStatements); | |
} else { | |
console.warn("rect tag outside of svg parent tag with defined viewBox, skipping"); | |
} | |
} | |
// ... |
Adding the Style Functions
The very last thing we need to do is add the style function definitions before the paint function. So, let’s add to the makePaintFunction
template.
const makePaintFunction = template(` | |
mgraphics.relative_coords = 1; | |
%%styleFunctions%% | |
function calcAspect() { | |
var width = this.box.rect[2] - this.box.rect[0]; | |
var height = this.box.rect[3] - this.box.rect[1]; | |
return width/height; | |
} | |
function paint() { | |
const aspect = calcAspect(); | |
%%statements%% | |
} | |
`); |
Nearly there now, just need to call the template function with the right argument.
parser.parseComplete(data); | |
const paintFunction = ([] as t.Statement[]).concat( | |
makePaintFunction({ | |
statements: paintStatements, | |
styleFunctions: styleFunctions | |
}) | |
); | |
const programAST = t.program(paintFunction); |
Celebration
Just look at that beautiful JavaScript output.
mgraphics.relative_coords = 1; | |
function cls_1() { | |
mgraphics.set_source_rgba(0.08235294117647059, 1, 0.7019607843137254, 1); | |
} | |
function cls_2() { | |
mgraphics.set_source_rgba(0.2901960784313726, 0.7294117647058823, 1, 1); | |
} | |
function cls_3() { | |
mgraphics.set_source_rgba(1, 0, 0.6470588235294118, 1); | |
} | |
function cls_4() { | |
mgraphics.set_source_rgba(0, 0.5411764705882353, 0.9137254901960784, 1); | |
} | |
function calcAspect() { | |
var width = this.box.rect[2] - this.box.rect[0]; | |
var height = this.box.rect[3] - this.box.rect[1]; | |
return width / height; | |
} | |
function paint() { | |
const aspect = calcAspect(); | |
mgraphics.save(); | |
cls_1(); | |
mgraphics.rectangle(-0.5979945047163131 * aspect, 0.8753894080996885, 0.16429198651672663 * aspect, 1.1651090342679127); | |
mgraphics.fill(); | |
mgraphics.restore(); | |
mgraphics.save(); | |
cls_2(); | |
mgraphics.rectangle(0.4387445826133758 * aspect, 0.8753894080996885, 0.16429198651672663 * aspect, 1.1651090342679127); | |
mgraphics.fill(); | |
mgraphics.restore(); | |
mgraphics.save(); | |
cls_3(); | |
mgraphics.rectangle(-1 * aspect, 1, 2 * aspect, 0.2554517133956386); | |
mgraphics.fill(); | |
mgraphics.restore(); | |
mgraphics.save(); | |
cls_4(); | |
mgraphics.rectangle(-0.11814859926918386 * aspect, 0.9127725856697819, 0.26972211993315015 * aspect, 1.912772585669782); | |
mgraphics.fill(); | |
mgraphics.restore(); | |
} |
Imagine what that would have been like to program by hand? And now any kind of SVG drawing you make (provided it’s colored rectangles and colored rectangles only) can be turned into JavaScript instantly.
Next Up
If we were continuing this series, we might think about how to add behavior to our generated drawing. One of the really appealing parts of generating code is that it lets you define behaviors without having to code that behavior explicitly. So we might ask how we could define an interface object, like a slider or a knob, using just an SVG image.
Believe it or not we aren’t the first people to think about this kind of thing. If you’re familiar with Max you might also know about VCV Rack, a program for simulating Eurorack modules. VCV supports custom, user-defined modules, complete with a code generation script that will turn an SVG drawing into an active interface. This scheme uses different colors to denote different functionality, with green for inputs and blue for outputs. If you wanted to go deep into SVG-based code generation, you might follow a similar strategy for denoting different behaviors.
For now, this is the end of our code generation journey. I hope it’s been useful for you—certainly I learned a ton about working with different text parsers, and about using Babel to generate code from an AST. Until next time!