exupero's blog
RSSApps

Manipulating SVGs in Clojure

In the previous post I created some SVGs derived from this image on Wikipedia. While I could have created them by editing the original SVG in a vector graphics editor like Inkscape, I instead generated them programmatically using Clojure. That wasn't always easier than using a GUI-based editor, but it did save me a lot of manual rework as I tinkered with different illustrations. Here I'll document a few of the more interesting parts of the code and process.

SVGs are XML data, and given Clojure's facility with data there are several libraries available; I used the relatively basic clojure.data.xml. To highlight the shapes of interest, I first had to find their respective elements in the XML, which I did by walking all the nodes with clojure.walk/postwalk and setting an incrementing ID on each path element:

(defn tag [node]
  (and (map? node)
       (some-> node :tag name keyword)))
(defn path? [node]
  (= :path (tag node)))
(defn add-ids [xml]
  (let [ids (atom (iterate inc 1))
        next-id (fn []
                  (let [[id] @ids]
                    (swap! ids rest)
                    id))]
    (clojure.walk/postwalk
      (fn [node]
        (if (path? node)
          (let [id (next-id)]
            (assoc-in node [:attrs :data-id] (str id)))
          node))
      xml)))

I then wrote the modified tree to an SVG file using clojure.data.xml/emit and opened it in a browser. Using the web inspector, I found the elements I wanted and noted their IDs. This is the part that would have been easier in a GUI. Since the paths in the original SVG are just strokes, Chrome's "Select an element" tool required pixel-perfect precision to select an element, and it would have been quicker to be able to select an area.

Most of the lines I wanted to highlight were distinct elements in the XML, but a few weren't. A couple of the SVG paths had a d attribute that drew multiple, disjoint lines by using more than one M directive, which jumps the location of the path's "pen" to a new point. To fix that, I split select elements' d attribute into multiple nodes:

(defn split-d [node]
  (let [d (get-in node [:attrs :d])
        segments (clojure.string/split d #"M")]
    {:tag :g
     :content (for [[i segment] (map-indexed vector segments)
                    :when (not (clojure.string/blank? segment))]
                (-> node
                  (assoc-in [:attrs :d] (str "M" segment))
                  (update-in [:attrs :data-id] str \- i)))}))

That worked for most of the elements that encoded multiple lines, but one path made relative jumps using m rather than jumping to absolute coordinates with M. To fix that, I found this Codepen that uses SVGPathCommander to translate the relative coordinates into absolute coordinates, then replaced the node's original d specification with one using all absolute coordinates.

With all the necessary shapes separated and ID'd, I walked the XML again and overwrote style attributes to change certain paths' colors and line thicknesses.

Drawing a shape on top of the original image was far simpler, and only required appending some content to the svg node's contents:

(defn add-triangle [xml [p1 p2 p3]]
  (update xml :content concat
          [(clojure.data.xml/sexp-as-element
             [:path {:d (transduce
                          (comp
                            (map #(clojure.string/join "," %))
                            (interpose "L"))
                          str "M" [p1 p2 p3 p1])
                     :stroke "red"
                     :stroke-width 2
                     :fill "none"}])]))

Before performing the rotations, I got rid of all the shapes other than the ones of interest, which I did by walking the XML and returning nil in place of any path elements whose ID wasn't in a list of allowed IDs.

To rotate the remaining shapes on top of themselves, I put them in a group with an id attribute, then rotated them with a use node:

(defn rotate-around [[x y] angle]
  (-> (clojure.data.xml/element :g
        {:transform (str
                      "translate(" x "," y ")"
                      "rotate(" angle ")"
                      "translate(" (- x) "," (- y) ")")})
      (assoc :content
             (clojure.data.xml/element :use {:href "#symbols"}))))

Rotation about a point is done by a compound transform that moves the point of rotation to the origin, rotates the image by the given angle, then moves the origin to the original point of rotation. Similar code mirrored the image across an arbitrary vertical axis, using scale(-1, 0) instead of rotate(...).

To slide groups of shapes toward the center, I made the whole arrangement a group with an id, then added use nodes with masks and transforms. The each mask hid everything but one octant, and the transform moved that octant to the desired location.

I quit before reproducing the folding operations, but presumably they could also be done with masks and transforms, though the overlapping circles mean the results wouldn't look as clean as in the source material.

The final step is one that all the SVGs on this blog go through, which is that they're run through svgo to shrink them. Besides removing whitespace, svgo changed IDs like symbols into single characters.