Note

A previous version of this article unfairly implied that Firefox has better SVG standards conformance than Inkscape. The situation is more complicated than that and includes the standards being cut down to match stagnant browser implementations, while there are also some SVG 2.0 features missing in Inkscape. Nobody is perfect here.

My partner has a brilliant poster they made of the backing of 3M command strips in their apartment, which I wanted to recreate as a vector image to make another. I initially tried inkscape, where I ran into issues with the tiled clone tool not supporting dragging to set spacing and more crucially not supporting absolute distances, which meant that it could not maintain proper spacing of things of different height (I later realized it could probably be done with a group, but there were unrelated factors of fiddliness at play such as difficulty working in a transformed coordinate system that made Inkscape infeasible to use).

I conceded and did this project as a simple Python SVG generator. First, I took a picture as reference, then included it in the SVG as an <image>. SVG is fun because it is not HTML, and is also strict XML. For instance, one difference is that the image tag is called image rather than img and uses an href, not a src (or indeed, xlink:href if you are using an older implementation such as inkscape).

With that out of the way, I made a <g> group that's rotated 45 degrees and translated some amount (transform="rotate(-45) translate(-1000, 100)"), then I created the four languages of <text> text elements inside. Regarding how to get the actual text to put in there, there are various ways to do this; I typed it in on my phone (including the Japanese! there's a drawing keyboard for Japanese in Google Keyboard, so even my dubious-quality non-Japanese-speaker scrawls got turned into characters pretty easily).

To get each text fragment into position easily, I nicked some code to make them draggable, then noted down the transformation after dragging them into position. Next, I duplicated each language's element and moved the new one into the next horizontal position to figure out the horizontal (along the line) period and the next vertical position to figure out the cross-line period.

Then, I made these definitions reusable by giving them an id property and putting them in a <defs> block. This makes the original definition invisible, so you have to reference them as something like <use xlink:href="#someId" />.

Since I knew the spacings, I got Python out in earnest. To make my workflow more pleasant, I wanted to rebuild the image on every editor save. I found a tool entr that can do this: ls *.py | entr -r python wallside.py. It takes a list of files to watch on standard input, and a command to run when any of them change.

With my setup sufficiently pleasant, I wrote some Python to generate a series of instances of the definition with the horizontal spacing for every language, with each looking like this: <use x="{idx * SPACING[language]}" xlink:href="#{language}" />. This forms one line of several copies of each of English, French, Spanish, and Japanese. I put this into a <defs> block as a group, then referenced it in the body of the document with a <use> to check my work.

After I was satisfied this worked, I then started generating that <use> automatically, with the y offset set to some multiple of the line spacing, and with some tweaks, that was that.

Next was the job of getting it to work on Inkscape since I was prototyping against Firefox which has different standards support than Inkscape. One thing I was doing that was not ideal for Inkscape was that I was rendering a bunch of text off-page, which was appearing. I fixed this with a clip path the size of the document like so:

<clipPath id="viewRect">
    <rect height="100%" width="100%" />
</clipPath>
<!-- ... -->
<g clip-path="url(#viewRect)">
    <g transform="..."><!-- all the text goes in here --></g>
</g>

Another thing that Inkscape was incompatible with (to the point of not rendering anything) was the use of href="..." in my document. Its predecessor, xlink:href, was noted on MDN as being deprecated, replaced in the SVG 2 standard by unprefixed href. I just had to switch to xlink:href and add xmlns:xlink="http://www.w3.org/1999/xlink" to my <svg> element to fix this.

The last bit of trouble I got from Inkscape was that it does not support the CSS transform property, so I had to convert to the transform="..." property on tags. And it comes out of Inkscape and ImageMagick nicely now! Yay!

Finally, I have a SVG file that is exactly what I want and was not that painful to create. That was fun!

I've included the sources and SVG file below (note: it requires Source Han Sans installed on your computer, which is a nice open source sans-serif font with Chinese/Japanese/Korean support).

wallside.py
import contextlib


class Periods:
    y = 106
    x = {
        'en': 106,
        'fr': 106,
        'es': 146,
        'jp': 82,
    }


DEFS = """
"""

HEADER = """
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="17in" width="11in" onload="makeDraggable(evt)">
  <style>
    .text {
      font-family: "Source Han Sans";
      font-size: 14pt;
      font-weight: bolder;
      letter-spacing: -1.5px;
    }
    .text.japanese {
      font-size: 16pt;
      letter-spacing: -0.8px;
    }
    .wallside {
        transform: rotate(-45deg)translate(-13in, 1.2in);
    }
    .draggable, .draggable-group {
      cursor: move;
    }
  </style>
  <!--<image href="./wallsideref.jpg" x="0" y="0" transform="scale(0.4, 0.4)" /> -->
  <clipPath id="viewRect">
    <rect height="17in" width="11in" />
  </clipPath>
  <rect height="17in" width="11in" stroke="black" fill="none" />
  """

FOOTER = """
  <script type="text/javascript"><![CDATA[
    function makeDraggable(evt) {
        var svg = evt.target;

        svg.addEventListener('mousedown', startDrag);
        svg.addEventListener('mousemove', drag);
        svg.addEventListener('mouseup', endDrag);
        svg.addEventListener('mouseleave', endDrag);
        svg.addEventListener('touchstart', startDrag);
        svg.addEventListener('touchmove', drag);
        svg.addEventListener('touchend', endDrag);
        svg.addEventListener('touchleave', endDrag);
        svg.addEventListener('touchcancel', endDrag);

        function getMousePosition(evt) {
          var CTM = svg.getScreenCTM();
          if (evt.touches) { evt = evt.touches[0]; }
          return {
            x: (evt.clientX - CTM.e) / CTM.a,
            y: (evt.clientY - CTM.f) / CTM.d
          };
        }

        var selectedElement, offset, transform;

        function initialiseDragging(evt) {
            offset = getMousePosition(evt);

            // Make sure the first transform on the element is a translate transform
            var transforms = selectedElement.transform.baseVal;

            if (transforms.length === 0 || transforms.getItem(0).type !== SVGTransform.SVG_TRANSFORM_TRANSLATE) {
              // Create an transform that translates by (0, 0)
              var translate = svg.createSVGTransform();
              translate.setTranslate(0, 0);
              selectedElement.transform.baseVal.insertItemBefore(translate, 0);
            }

            // Get initial translation
            transform = transforms.getItem(0);
            offset.x -= transform.matrix.e;
            offset.y -= transform.matrix.f;
        }

        function startDrag(evt) {
          if (evt.target.classList.contains('draggable')) {
            selectedElement = evt.target;
            initialiseDragging(evt);
          } else if (evt.target.parentNode.classList.contains('draggable-group')) {
            selectedElement = evt.target.parentNode;
            initialiseDragging(evt);
          }
        }

        function drag(evt) {
          if (selectedElement) {
            evt.preventDefault();
            var coord = getMousePosition(evt);
            transform.setTranslate(coord.x - offset.x, coord.y - offset.y);
          }
        }

        function endDrag(evt) {
          selectedElement = false;
        }
      }

  ]]></script>

</svg>
"""

BODY = """
  <defs>
    <text id="en" x="0" y="0" transform="translate(0, -80)" class="text">WALL side</text>
    <text id="fr" x="0" y="0" transform="translate(-25, -56)" class="text">Côté MUR</text>
    <text id="es" x="0" y="0" transform="translate(-28, 0)" class="text">lado de la PARED</text>
    <text id="jp" x="0" y="0" transform="translate(-20, -27)" class="text japanese">かベ面</text>
  </defs>

  <g class="wallside">
    <!--
    <use href="#en" />
    <use href="#en" x="106" class="draggable" />
    <use href="#en" y="106" class="draggable" />
    <use href="#fr" />
    <use href="#fr" x="106" class="draggable" />
    <use href="#es" />
    <use href="#es" x="146" class="draggable" />
    <use href="#jp" />
    <use href="#jp" x="82" class="draggable" />
    -->
  </g>
"""


def make_line_def(id):
    print(f'<defs><g id="{id}">')
    for (lang, per) in Periods.x.items():
        for i in range(30 if lang == 'jp' else 20):
            print(f'<use x="{i * per}" xlink:href="#{lang}" />')
    print('</g></defs>')


def make_wallside():
    # 96px/in * {13in, 1.2in}
    print('<g clip-path="url(#viewRect)"><g transform="rotate(-45) translate(-1248, 115)">')
    for i in range(20):
        print(f'<use xlink:href="#line-10" y="{Periods.y * i}" />')
    print('</g></g>')


def main():
    f = open('./wallside.svg', 'w')
    with contextlib.redirect_stdout(f):
        build()
    print('built svg')


def build():
    print(HEADER)
    print(DEFS)
    make_line_def('line-10')
    make_wallside()
    print(BODY)
    print(FOOTER)


if __name__ == '__main__':
    main()