Technical Discussion

How SVG paths are optimised

 —  A peek into something I'm working on
Return Home

Personally, I think SVGs are a really cool tech. I remember watching my dad use CorelDraw in the early noughties and was stunned at how one could zoom in on an image ad-infinitum. This is the appeal of vector graphics.

I've also been playing with Rust, and I've written an SVG optimiser, wanting to try out more low-level programming. So far I think it's been great! SVGs are a low level problem, they require parsing, math operations, and when it comes to optimising them, a good deal of complexity. Rust really helps break some of those complexities, and I'll show that when comparing my rewrite of SVG path optimisations when compared to SVGO.

Background

What is an SVG path?

If you've ever looked at an SVG, you would have likely seen an esoteric element that looks something like this.

<path d="M0 10L-50 10" />

And you would have noted that this produces some sort of shape. But what those symbols in the definition (d) mean is quite unreadable. What it is, is a list of commands. A command is made up of a character representing the type of command and then a set of numbers as arguments for that command. In the above example we have M which moves the cursor to a point, (0, 10) followed by the next command L which draws a line to the point (-50, 10).

There's command for moving the cursor, drawing lines/arcs, and closing the shape. Each command has a lowercase and uppercase variant depending whether it moves relative by a distance or absolutely to a point respectively.

The Process

High-level

The optimisation of an SVG path is approached simply with a filter-map technique. Basically, we do the following.

  1. Convert all commands to a consistent form. In our case, we'll choose to use relative commands as they're easier to work with.
  2. Filter, merge, or convert commands to a more concise form where possible.
  3. Convert commands back to a mix of relative/absolute command depending which is more concise

At a high level, this is all very simple, but as we break it down, you'll see that there's a lot of parts we have to track

Convert to relative

This part is easy. Move along the path and when you run into an absolute command, use the difference between the start point and end point instead.

<path d="M0 10L-50 10" />
<!-- 1. "M0 10" starts at (0, 0) and ends at (0, 10) -->
<!--     So, it moves by (0, 10) -->
<!-- 2. "L-50 20" starts at (0, 10) and ends at (-50, 10) -->
<!--     So, it moves by (-50, 0) -->
<path d="m0 10l-50 0" />

Also note, that repeated commands are implicit, so when an l is followed by l you can omit the character. The exception is m which is implicitly followed by an l. So, you can rewrite the above as.

<path d="m0 10-50 0" />

We don't need to keep tabs on this at the moment, but it's good to know that we can make paths shorter by taking advantage of this

Filter, merge, and map

What to track

During this process there are a few things we want to track.

  • Where each command starts and ends
  • The relative sub-point (see below)
  • Curve data
Relative Sub-points

As we round commands along our path, we're bound to get rounding errors which will cause later commands to drift away from their original point. To amend this, we keep track of a "relative sub-point" which is a point opposed to the amount of drift we've accrued to push future rounding back into the right direction.

To simplify, when we round a number in one direction we track that, so the next time we round we do so in a more ideal direction. For example:

<!-- The original path ends at (1, 1.001) -->
<path d="m0 0.0005l1 1.0005" -->

<!-- Using naive rounding, it ends at (1, 1.002) -->
<path d="m 0.001l1 1.001" />

<!-- By using a relative-subpoint, it ends at (1, 1.001) -->
<path d="m 0.001l1 1" />

Filtering

There are the simple parts of filtering where obvious

  • Repeated close commands are redundant
  • Useless segments (commands which don't move) are redundant

Merging

Merging is the most complicated step in the whole process and is part of why we have to keep track of so much.

Curves

As we trawl through our curves we want to check future curves are a continuation of the previous curve's trajectory. So:

  • For any curve, we want to keep a record of the ellipses it fits into, the angle, etc of that curve
  • For a smooth bezier (s) or a cubic bezier (c) that happens to be smooth, we want to keep a record of the arguments of that curve
  • For a quadratic bezier (q), we want to keep a record of the control points of that curve

Once we have tabs on this information, we can merge curves based on the following

  • If we have smooth s or c commands that all lie on the same curve of an ellipses, they can be joined as one command Take a look at this curve. This is a cubic bezier followed by a smooth bezier which run across the same circle. So, we can rewrite this one as an arc
<!-- The two commands run along a circle at (0, -5) with a radius of 5 -->
<!-- It starts at an angle of 1.57rad (90deg) and ends at 3.14rad (180deg) -->
<!-- It is a convex arc, i.e it's part of a circle -->
<path d="m0 0c-2.761 0-5-2.239-5-5s2.239-5 5-5" />
<!-- So with all that, we can write it as an arc -->
<path d="m0 0a5 5 0 0 1 0-10" />
Lines

More obviously, lines which followed the trajectory of a prior line can also be merged. I trust anyone with a high-school graduation can see how this works.

<!-- Both lines have the same slope -->
<path d="m0 0l10 20l20 40" />
<!-- So we can rewrite it via addition -->
<path d="m0 0l30 60" />

Mapping

Some commands also have an equivalent shorthand.

  • Lines can be mapped to horizontal (h) and vertical (v) commands if they only move in one direction
<!-- The line has no horizontal movement -->
<path d="m0 0l0 10" />
<!-- So we can use a vertical command -->
<path d="m0 0v10" />
  • Cubic beziers (c) can be mapped to quadratic beziers (q) if the control points are equally opposed
<!-- control-point 1 is (3, -6) -->
<!-- control-point 2 is (3, -6) -->
<path d="m0 0c4 -8 8 -8 12 0" />
<!-- So we can use a quadratic curve -->
<path d="m0 0q6 -12 12 0" />
  • Cubic beziers (c) can be mapped to smooth beziers (s) depending on the previous command (I'm gonna be real, I don't know the math on this one)
<!-- consider the path as m0 0(c1)x1 y1 x2 y2 x y(d2)x1 y2 x2 y2 x y -->
<!-- c2's x1 is 0 and c1's x2 - x is 0 -->
<!-- c2's y1 is 1 and c1's y2 - y is 1 -->
<path d="m0 0c0 1 2 3 2 4c0 1 2 3 4 5" />
<!-- So we can use just c2's x2 y2 control and the x y coordinates -->
<path d="m0 0c0 1 2 3 2 4s2 3 4 5" />
  • Similarly, quadratic beziers (q) can be mapped to smooth quadratic bezier (t)
<!-- consider the path as "m0 0(q1)x1 y1 x2 y2(q2)x1 y1 x2 y2" -->
<!-- q2's x1 is -0.5 and q1's x1 - x2 is 0.5 -->
<!-- q2's y1 is -0.5 and q1's y1 - y2 is 0.5 -->
<path d="m0 0q0.7 -4.5 0.2 -5q-0.5 -0.5 -1.5 -0.5" />
<!-- So we can use just q2's x2 y2 coordinate -->
<path d="m0 0q0.7 -4.5 0.2 -5t-1.5 -0.5" />
  • Lines which return home can be mapped to a close command (z)
<!-- The final line returns to (0, 0), where the `m` is set -->
<path d="m0 0h10v10l-10 -10" />
<!-- So we can use a close command instead -->
<path d="m0 0h10v10z" />
Mixing

Once we're all done playing our with our commands, we can pick and choose between the relative and absolute equivalent that produces the shortest commands

<!-- The final line here goes to (3, 3) -->
<path d="m0 1l2 0.5l1 1.5" />
<!-- So it's more consise as an absolute command -->
<path d="m0 1l2 0.5L3 3" />

And of course, as mentioned earlier we can improve the formatting and use implicit commands where available. So the previous example becomes this

<path d="m 01 2 .5L3 3" />

Note that while the omitted l there doesn't shorten the string, it is advantageous to compression algorithms. This is because most compression algorithms work better when there's fewer unique bytes in a pattern.

Writing this in Rust

Right now I don't think the tooling around SVG is great and hasn't received as much attention as other web technologies do nowadays. (Look at LightningCSS for CSS, Biome for formatting, and SWC for TypeScript) SVGO and InkScape are the two big tools out right now for working with SVGs in a build system, and the former is frustratingly slow to work with through a CI.

SVG is still a large piece of puzzle that seems to be missing out from a lot of the effort. That's why I'm starting OXVG to fit as a piece within that puzzle. SVGs are a ubiquitous format used across the web and are a powerful way of providing rich, animate-able graphics. SVGO is probably the best tool of the lot, but it's a project that started more than 12 years ago and for the job it's doing, JavaScript may not be the ideal tool. That's why I've chosen it as one of the first problems to tackle in this space.

Particularly when looking through SVGO's source code for path optimisation, which is a 1300 line file with control structures reaching 9 levels of indentation. It was quite frustrating to read through and understand. I imagine the complex structure is a consequence of the methods of optimising JavaScript, since it is ideal to write fewer lines of code to send less byte across the web. In order to make it readable, I imagine the codes line count would quadruple in length.

The benefit of Rust is that it's great for writing for low-level problems.

  • It has limits on mutability and ownership that make it easier to reconcile the code.
  • Abstractions like traits, functions, and structs have almost no cost
  • It has a strong type system (stronger than TypeScript's) so that error-free code is certain

But

  • It can only be used on the web via WASM
  • It can be harder to write code for high level problems (such as FrontEnd)

So

  • For a low-level problem like this, the best code you can produce is easy to reason with

JavaScript is great for the opposite reasons, but for high-level problems

  • You can pass data around without worry what's referencing it and you can mutate anything from one form to another
  • TypeScript's structural typing is very expressive making it easy to be confident in your code

But

  • Abstractions like classes, functions, and objects have a network-time and runtime cost
  • TypeScript can require additional code to play nice, which comes at similar costs

So

  • For a low-level problem like this, the best code you can produce is very complicated