Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Transforming SVG

A drawing is a document, so it can be changed after it is drawn, by someone who has never seen the description it came from. This section writes small stylesheets that take an SVG file, ours or one an editor saved, and write a changed copy: a dark theme, one node highlighted, the editor's leftovers removed, a different size. A query lists the text. All of them read SVG, so all of them must name SVG elements in the SVG namespace, and the section shows the ways to do that.

Copying everything, changing a little

Each stylesheet here starts from the same line:

<xsl:mode on-no-match="shallow-copy"/>

It says what happens to a node no template rule matches: it is copied, and its attributes and children are processed in turn. With only that line, a stylesheet copies its input unchanged; every template rule added to it is an exception. The rules below change a few nodes and leave the rest of the drawing alone, however large it is.

A dark theme

A dark theme swaps colours. dark.xsl holds a palette, a map from each light colour of our drawings to a dark one, and applies it wherever colours are written: in the style text, and in the colour attributes fill, stroke and stop-color. It also gives the drawing a dark background and makes the default colour light, because what has no colour of its own is drawn black:

dark.xsl:

<?xml version="1.0" encoding="UTF-8"?>
<!-- A dark theme for an SVG drawing: every colour of the light palette,
     in the style text and in colour attributes, becomes its dark
     counterpart, the drawing gets a dark background, and what has no
     colour of its own becomes light. Everything else is copied as it is. -->
<xsl:stylesheet version="4.0"
    xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
    xmlns:xs="http://www.w3.org/2001/XMLSchema"
    xmlns:map="http://www.w3.org/2005/xpath-functions/map"
    xmlns:svg="http://www.w3.org/2000/svg"
    xmlns:theme="urn:example:theme"
    exclude-result-prefixes="#all">

  <xsl:mode on-no-match="shallow-copy"/>
  <xsl:output omit-xml-declaration="yes"/>

  <xsl:variable name="palette" as="map(xs:string, xs:string)" select="{
    '#eef3fb': '#1f2a3d',
    '#3b5b92': '#8fb0e8',
    '#f6efe3': '#3a3020',
    '#8a6a2f': '#d9b878',
    '#1b1b1b': '#e8e8e8',
    '#555555': '#b0b0b0',
    '#ffffff': '#121212'
  }"/>

  <!-- The text with every colour of the palette replaced. -->
  <xsl:function name="theme:dark" as="xs:string">
    <xsl:param name="text" as="xs:string"/>
    <xsl:sequence select="fold-left(map:keys($palette), $text,
      fn($result, $colour) { replace($result, $colour, $palette($colour), 'qi') })"/>
  </xsl:function>

  <xsl:template match="/svg:svg">
    <xsl:copy>
      <xsl:apply-templates select="@*"/>
      <xsl:attribute name="style"
          select="string-join((@style, 'background-color: #121212'), '; ')"/>
      <!-- What has no colour of its own is black; make it light. -->
      <xsl:if test="not(@fill)">
        <xsl:attribute name="fill">#e8e8e8</xsl:attribute>
      </xsl:if>
      <xsl:apply-templates/>
    </xsl:copy>
  </xsl:template>

  <xsl:template match="svg:style/text()">
    <xsl:value-of select="theme:dark(.)"/>
  </xsl:template>

  <xsl:template match="@fill | @stroke | @stop-color">
    <xsl:attribute name="{name()}" select="theme:dark(.)"/>
  </xsl:template>

</xsl:stylesheet>

theme:dark replaces every colour of the palette in a string, with a fold-left over the palette's keys: the result of one replacement is the input of the next. The flags q and i of replace make the colour a literal string (not a regular expression) and ignore case, as CSS does for colours. The function is in a namespace of our own: a stylesheet function needs a prefix, and the SVG namespace is not ours to add functions to.

Names in the patterns have a prefix, svg:, bound to the SVG namespace on the stylesheet: /svg:svg is the root of an SVG drawing, and svg:style/text() the text of its style. exclude-result-prefixes keeps these declarations out of the result.

$ sxml transform dark.xsl diagram.svg --output=dark.svg
$ sxml xpath --context-item=dark.svg '/*/@style/string(), //*:style/string()'
"background-color: #121212"
"
    .node rect { fill: #1f2a3d; stroke: #8fb0e8; stroke-width: 1.5 }
    .store rect { fill: #3a3020; stroke: #d9b878 }
    .node text { font: 14px sans-serif; fill: #e8e8e8 }
    .edge line { stroke: #b0b0b0; stroke-width: 1.5 }
    .edge text { font: 12px sans-serif; fill: #b0b0b0 }
  "

The palette works on any drawing that uses these colours. The rules for added, removed and changed use colours that are not in it, and stay as they are, readable on dark too:

$ sxml xquery --context-item=change.xml draw.xq | sxml transform dark.xsl - | sxml xpath --context-item=- 'tokenize(//*:style, "\n")[contains(., "added") or contains(., "store")] ! normalize-space()'
".store rect { fill: #3a3020; stroke: #d9b878 }"
".added rect, .added line, .added path { stroke: #2e7d32; stroke-width: 3 }"

Highlighting a node

To point at one module in a review, highlight it and the edges that start or end at it, and fade the rest. Which node is a stylesheet parameter, given on the command line:

highlight.xsl:

<?xml version="1.0" encoding="UTF-8"?>
<!-- Highlights one node of a drawing made by draw.xqm, and the edges that
     start or end at it; the rest of the drawing fades. The node is the
     parameter "node", a node id of the description. -->
<xsl:stylesheet version="4.0"
    xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
    xmlns:xs="http://www.w3.org/2001/XMLSchema"
    xpath-default-namespace="http://www.w3.org/2000/svg"
    expand-text="yes">

  <xsl:param name="node" as="xs:string" required="yes"/>

  <xsl:mode on-no-match="shallow-copy"/>
  <xsl:output omit-xml-declaration="yes"/>

  <!-- The node ids of the drawing, from the groups' ids "node-ID". -->
  <xsl:variable name="ids" select="//g[starts-with(@id, 'node-')] ! substring(@id, 6)"/>

  <!-- The ids of the groups to highlight: the node and its edges, whose
       ids are "edge-FROM-TO". -->
  <xsl:variable name="highlighted" select="
    'node-' || $node,
    $ids ! ('edge-' || $node || '-' || ., 'edge-' || . || '-' || $node)"/>

  <xsl:template match="/">
    <xsl:if test="not($node = $ids)">
      <xsl:message>highlight.xsl: the drawing has no node "{$node}"</xsl:message>
    </xsl:if>
    <xsl:apply-templates/>
  </xsl:template>

  <xsl:template match="style/text()">
    <xsl:value-of select="."/>
    <xsl:text>  .node:not(.highlight), .edge:not(.highlight) {{ opacity: 0.3 }}
    .highlight rect, .highlight line {{ stroke: #d81b60; stroke-width: 3 }}
  </xsl:text>
  </xsl:template>

  <xsl:template match="g[@id = $highlighted]">
    <xsl:copy>
      <xsl:apply-templates select="@*"/>
      <xsl:attribute name="class" select="@class, 'highlight'"/>
      <xsl:apply-templates/>
    </xsl:copy>
  </xsl:template>

</xsl:stylesheet>

This stylesheet uses the other way of naming SVG elements: xpath-default-namespace puts unprefixed names in patterns and paths in the SVG namespace, so g, style and //g mean SVG elements.

The drawing has no description inside it, only ids, so the stylesheet works from them. A node's group has the id node-ID, and an edge's group edge-FROM-TO. The variable $ids collects the node ids, and $highlighted the ids of the groups to highlight: the node's own, and for every node M the two possible edges between it and M. Matching whole ids, rather than looking for the node's id somewhere inside a group's id, matters here: the backend SDK's node has the id node, and contains(@id, 'node') would be true of every node's group.

A template rule whose pattern uses a variable is fine in XSLT: g[@id = $highlighted] matches the groups to change, which get the class highlight, and two style rules do the rest. In CSS, :not(.highlight) selects the groups without the class.

$ sxml transform --param=node=registry highlight.xsl diagram.svg --output=highlight.svg
$ sxml xpath --context-item=highlight.svg '//*:g[contains-token(@class, "highlight")]/@id/string()'
"edge-flags-registry"
"edge-registry-postgresql"
"node-registry"

as="xs:string" on the parameter converts the value given, and required="yes" makes a missing one an error. A node the drawing doesn't have, such as the audit module, which only the drawing after the change has, is not an error, but worth a word, which xsl:message writes to standard error while the transformation goes on:

$ sxml transform highlight.xsl diagram.svg
highlight.xsl: err:XTDE0050: no value is supplied for a required stylesheet parameter (XSLT 4.0, 9.5)
$ sxml transform --param=node=audit highlight.xsl diagram.svg --output=nothing.svg
highlight.xsl: the drawing has no node "audit"

Without xpath-default-namespace, the stylesheet still runs, and does nothing: //g finds no groups in no namespace, so even registry is not found. This is the most common mistake with SVG and XSLT, and the message is how you notice it:

$ sed '/xpath-default-namespace/d' highlight.xsl > highlight-plain.xsl
$ sxml transform --param=node=registry highlight-plain.xsl diagram.svg | grep -c highlight
highlight.xsl: the drawing has no node "registry"
0

Removing ids and metadata

Drawings made in an editor carry the editor with them. legend.svg is a legend for our diagrams as an editor saved it, with a comment, its own elements and attributes in its own namespaces, RDF metadata, and an id on everything:

legend.svg:

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg width="120" height="85" viewBox="0 0 120 85" version="1.1" id="svg1"
     inkscape:version="1.3 (0e150ed6c4, 2023-07-21)"
     sodipodi:docname="legend.svg"
     xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
     xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
     xmlns="http://www.w3.org/2000/svg"
     xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
     xmlns:cc="http://creativecommons.org/ns#"
     xmlns:dc="http://purl.org/dc/elements/1.1/">
  <sodipodi:namedview id="namedview1" pagecolor="#ffffff" inkscape:zoom="4"/>
  <defs id="defs1">
    <linearGradient id="fade" x1="0" y1="0" x2="0" y2="1">
      <stop id="stop1" offset="0" stop-color="#ffffff"/>
      <stop id="stop2" offset="1" stop-color="#eef3fb"/>
    </linearGradient>
  </defs>
  <metadata id="metadata1">
    <rdf:RDF>
      <cc:Work rdf:about="">
        <dc:title>Legend</dc:title>
      </cc:Work>
    </rdf:RDF>
  </metadata>
  <g inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1">
    <rect id="rect1" x="10" y="10" width="100" height="30" rx="6"
          fill="url(#fade)" stroke="#3b5b92"/>
    <text id="text1" x="60" y="30" text-anchor="middle" font-size="12">module</text>
    <rect id="rect2" x="10" y="48" width="100" height="30" rx="15"
          fill="#f6efe3" stroke="#8a6a2f"/>
    <text id="text2" x="60" y="68" text-anchor="middle" font-size="12">store</text>
  </g>
</svg>

strip.xsl removes all that. Removing a node is a template rule with no content: the node matches, and nothing is written for it.

strip.xsl:

<?xml version="1.0" encoding="UTF-8"?>
<!-- Removes what an SVG editor leaves in a drawing: comments, metadata,
     elements and attributes in the editor's namespaces, and every id that
     nothing refers to. A reference is "#id" in an attribute (url(#id),
     href="#id") or in the style text. -->
<xsl:stylesheet version="4.0"
    xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
    xmlns:svg="http://www.w3.org/2000/svg"
    exclude-result-prefixes="#all">

  <xsl:mode on-no-match="shallow-copy"/>
  <xsl:output omit-xml-declaration="yes" indent="yes"/>
  <xsl:strip-space elements="*"/>
  <xsl:preserve-space elements="svg:text svg:style"/>

  <!-- The ids that something refers to. -->
  <xsl:variable name="referenced" select="
    distinct-values(
      tokenize(string-join((//@*, //svg:style), ' '), '[^\w#.-]+')
        [starts-with(., '#')] ! substring(., 2)
    )"/>

  <!-- Elements in the SVG namespace are copied without the namespace
       declarations they don't use. -->
  <xsl:template match="svg:*">
    <xsl:copy copy-namespaces="no">
      <xsl:apply-templates select="@* | node()"/>
    </xsl:copy>
  </xsl:template>

  <!-- Dropped: metadata, comments, elements in other namespaces,
       attributes in a namespace, and ids nothing refers to. A union
       pattern has the lowest priority of its parts (comment(), -0.5), so
       the rule says its priority, to win over svg:* (-0.25). -->
  <xsl:template match="svg:metadata | comment() | *[namespace-uri() ne 'http://www.w3.org/2000/svg']"
                priority="1"/>
  <xsl:template match="@*[namespace-uri()] | @id[not(. = $referenced)]"/>

</xsl:stylesheet>

Ids need care. The gradient fade is used by fill="url(#fade)", and a drawing that loses that id loses its gradient (in our drawings, the arrow marker is url(#arrow)). So $referenced collects every #name in the attributes and in the style text, and only the other ids go.

Three details are about namespaces and whitespace:

  • Patterns test namespaces directly: *[namespace-uri() ne '…'] is any element outside SVG, and @*[namespace-uri()] any attribute in a namespace (ordinary attributes are in none).
  • A copied element takes the namespace declarations in scope with it, even unused ones; copy-namespaces="no" on xsl:copy leaves out the ones the copy doesn't need, so the inkscape and rdf declarations disappear along with what used them.
  • The whitespace around a removed element stays behind as blank lines. xsl:strip-space drops whitespace-only text from the input (except in text and style, where it may matter), and indent="yes" lays the result out again.

The rule that drops elements is a union of three patterns. In XSLT 4.0 a union has the lowest priority of its parts, which is comment()'s -0.5, lower than the -0.25 of svg:*; the rule gives itself priority="1", or svg:* would copy metadata instead of dropping it.

$ sxml transform strip.xsl legend.svg
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="85" viewBox="0 0 120 85" version="1.1">
  <defs>
    <linearGradient id="fade" x1="0" y1="0" x2="0" y2="1">
      <stop offset="0" stop-color="#ffffff"/>
      <stop offset="1" stop-color="#eef3fb"/>
    </linearGradient>
  </defs>
  <g>
    <rect x="10" y="10" width="100" height="30" rx="6" fill="url(#fade)" stroke="#3b5b92"/>
    <text x="60" y="30" text-anchor="middle" font-size="12">module</text>
    <rect x="10" y="48" width="100" height="30" rx="15" fill="#f6efe3" stroke="#8a6a2f"/>
    <text x="60" y="68" text-anchor="middle" font-size="12">store</text>
  </g>
</svg>

Transformations compose. The cleaned legend in the dark theme is two stylesheets in a pipeline, and the gradient's light stops become dark:

$ sxml transform strip.xsl legend.svg | sxml transform dark.xsl - | sxml xpath --context-item=- '//*:stop/@stop-color/string(), //*:rect/@fill/string()'
"#121212"
"#1f2a3d"
"url(#fade)"
"#3a3020"

Listing the text

A query reads SVG as easily. declare namespace binds a prefix for the query, the XQuery counterpart of xmlns:svg on a stylesheet:

texts.xq:

xquery version "4.0";
(: The text of an SVG drawing (the context item), one line per text
   element, in drawing order. :)
declare namespace svg = "http://www.w3.org/2000/svg";
declare option output:method "text";

string-join(//svg:text ! normalize-space(), "&#10;")
$ sxml xquery --context-item=diagram.svg texts.xq
HTTP
HTTP
SQL
Redis
@vane/browser
@vane/node
vane/api
vane/flags
vane/registry
vane/cache
postgresql
redis
$ sxml xquery --context-item=legend.svg texts.xq
module
store

The edge labels come first because the edges are drawn first. A list like this is what a spelling checker or a translator needs, and what a test can compare.

Scaling with the viewBox

The viewBox of the root says which area of the drawing's own coordinates is shown, and width and height say how large the result is. Change the size and keep the viewBox, and the whole drawing scales, lines and text alike:

scale.xsl:

<?xml version="1.0" encoding="UTF-8"?>
<!-- Scales an SVG drawing: the width and height of the root element are
     multiplied by the parameter "factor"; the viewBox stays, so the
     drawing grows or shrinks with them. A drawing without a viewBox gets
     one first, from its width and height, or it would be cut off instead. -->
<xsl:stylesheet version="4.0"
    xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
    xmlns:xs="http://www.w3.org/2001/XMLSchema"
    xmlns:svg="http://www.w3.org/2000/svg">

  <xsl:param name="factor" as="xs:decimal" select="2"/>

  <xsl:mode on-no-match="shallow-copy"/>
  <xsl:output omit-xml-declaration="yes"/>

  <xsl:template match="/svg:svg[not(@viewBox)]">
    <xsl:copy>
      <xsl:attribute name="viewBox" select="0, 0, @width, @height"/>
      <xsl:apply-templates select="@* | node()"/>
    </xsl:copy>
  </xsl:template>

  <xsl:template match="/svg:svg/@width | /svg:svg/@height">
    <xsl:attribute name="{name()}" select="xs:decimal(.) * $factor"/>
  </xsl:template>

</xsl:stylesheet>
$ sxml transform scale.xsl diagram.svg | head -1
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="960" viewBox="0 0 360 480">
$ sxml transform --param=factor=0.5 scale.xsl box.svg | head -1
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="70" viewBox="0 0 200 140">

Without a viewBox, a larger width would show more empty space, not a larger drawing, so the rule for /svg:svg[not(@viewBox)] adds one made from the old size before the sizes change. The pattern /svg:svg/@width starts with /: it matches only the root's width, not the width of every rect.

$ sed 's/ viewBox="0 0 200 140"//' box.svg > plain.svg
$ sxml transform scale.xsl plain.svg | head -1
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 140" width="400" height="280">

Namespaces in patterns, once more

Every stylesheet and query in this section names SVG elements, and they do it in four ways:

WayWhereExample
a prefix bound in the stylesheet or querydark.xsl, strip.xsl, scale.xsl, texts.xqsvg:style, //svg:text
a default namespace for names in paths and patternshighlight.xsl, boxes.xslxpath-default-namespace="…", then g
any namespacequick XPath at the command line//*:rect
the namespace in the nameanywhereQ{http://www.w3.org/2000/svg}g

What doesn't work is an unprefixed name with no default: it means no namespace, and an SVG drawing has no elements there.

With this, the diagram tool is complete: a description, a layout, two ways of drawing it, and stylesheets that change any drawing. The state machine, call-chain and trace projects reuse draw.xqm to draw what they compute.