This is a card in Dave's Virtual Box of Cards.

SVG Minifier in Ruby

Created: 2023-12-14
Updated: 2023-12-27

I’m embedding line-art as SVG graphics in a hypertext choose-your-own-adventure game builder tool: Hiss.

I’m drawing everything in Inkscape, but I want the tiniest possible code for the drawings because they’ll be exported with every player.

There are online tools for this and they work just fine, but since I have extremely minimalistic needs, I can go even further than a generalized minifier/compressor. But the process of uploading and downloading a file is cumbersome and I still need to copy/paste the <svg> data and tailor it (give it an ID) for HTML embedding anyway. It would be nice to automate some of that.

Plus, it should be fun, right?

Spoiler: It worked. The tiny (30 line) Ruby program is at the bottom of this page.

Before: 3,540 bytes SVG file straight from Inkscape in an <img> tag:

original hiss image line drawing

After…​

1,039 bytes: Everything but path data stripped, SVG embedded in this HTML document:

664 bytes: Path data rounded to 1 decimal place:

496 bytes: Path data rounded to whole integers:

This last one is 86% smaller than the original.

All three minified versions look fine to me. Which makes total sense - whole numbers are still "pixel-perfect" at 100% scale.

Goals

(These will make sense only if you read the intro above.)

  • Get all of the <path> elements from an SVG.

  • Strip everything from the paths except the <d> (path data)

  • Ignore groups (<g>) and anything else.

  • Write SVG output suitable for HTML inlining/embedding.

  • Write paths into a single group for styling the group.

Possible additions if the need arises:

  • Maybe extract rects, ellipses, circles, etc.

  • Maybe keep stroke and fill properties, selectively.

Tools and documentation needed

I’m writing this in Ruby because it is now my One True Scripting Language. (And it has yet to fail me in having everything I need in its Standard Library. No gems required!)

REXML

Ruby comes standard with this complete XML handling library. The documentation is in a bit of a shambles (links that don’t work, etc.) and search engines find the most ridiculous stuff when you look it up. But the basics are very simple once you dig a little.

First, you make a Document from your XML source.

Example from docs:

xml_string = '<root><foo>Foo</foo><bar>Bar</bar></root>'
d = REXML::Document.new(xml_string)
d.to_s # => "<root><foo>Foo</foo><bar>Bar</bar></root>"

Then you can use XPath to find the elements you want from the XML:

Example from docs:

XPath.first( doc, "//b"} )
XPath.each( node, '/*[@attr='v']' ) { |el| ... }

And, thankfully, Wikipedia has a really nice summary of XPath with examples:

From Wikipedia’s examples:

/Wikimedia/projects/project/@name

"selects name attributes for all projects, and"

/Wikimedia//editions

"selects all editions of all projects…​"

Which is all I need to extract the width, height, and path information from an SVG graphic.

Embedding SVG in HTML

A stand-alone SVG document is XML data, so it starts with an XML declaration and stuff like that. Here’s the first two lines of an SVG created with Inkscape:

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->

But to embed an SVG in HTML, you just want the <svg> element.

MDN’s got the scoop:

MDN’s example:

<svg width="300" height="200">
  <rect width="100%" height="100%" fill="green" />
</svg>

Inkscape settings

Note that for this to work, we need the Inkscape Document Properties to use a pixel (px) width and height and scale of 1. Actually, I’m not entirely sure about that scale, but I set it and everything worked like I was expecting.

Also note that you can’t have any "translate" operations or things may not be where you expect them after the minifier does its job because it will strip out the translation. This shouldn’t be an issue if you draw your lines in place in the document as-is. But I did end up with some when I moved stuff around after resizing my document and stuff like that.

screenshot shows inkscape settings as described

By the way, how did I get rid of the existing translate() transformations that were in my Inkscape document without just trashing everything?

I removed them with a text editor. Sure enough, when I re-opened the document in Inkscape, my stuff was now shifted off by the expected amount. I moved it where I wanted it (by selecting all the lines, not a group). Then, in the text editor I confirmed what I was hoping: the individual items were moved and no translations had been added.

svgmin.rb

Okay, the moment you’ve been waiting for. Here’s the entire minification script.

Update: I added circles, which also demonstrates getting attributes from selected REXML elements.

#!/usr/bin/env ruby

svg = File.read(ARGV[0])
id = ARGV[1]

require 'rexml/document'
xml = REXML::Document.new(svg)

width = REXML::XPath.first(xml, "/svg/@width")
height = REXML::XPath.first(xml, "/svg/@height")

puts %'<svg id="#{id}" width="#{width}" height="#{height}">
<g style="fill:none; stroke: #000000; stroke-width: 3;">'

REXML::XPath.each(xml, "/svg//path/@d") do |d|
  # unrounded decimals:
  #puts %'<path d="#{d}" />'

  # rounded decimals
  path = '<path d="' # start path tag and data attribute
  d.to_s.split(/(\d+\.\d+)/).each do |chunk|
    if chunk.match? /\d+\.\d+/
      path << chunk.to_f.round.to_s
    else
      path << chunk
    end
  end
  path << '" />' # end data attribute and path tag
  puts path
end

REXML::XPath.each(xml, "/svg//circle") do |circle|
  cx = circle.attribute("cx").to_s.to_f.round.to_s
  cy = circle.attribute("cy").to_s.to_f.round.to_s
  r = circle.attribute("r").to_s.to_f.round.to_s
  puts %Q{<circle cx="#{cx}" cy="#{cy}" r="#{r}" />}
end

puts '</g>
</svg>'

It’s so small, it should be quite simple to adapt to different needs.

Enjoy!