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"onxsl:copyleaves out the ones the copy doesn't need, so theinkscapeandrdfdeclarations disappear along with what used them. - The whitespace around a removed element stays behind as blank lines.
xsl:strip-spacedrops whitespace-only text from the input (except intextandstyle, where it may matter), andindent="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(), " ")
$ 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:
| Way | Where | Example |
|---|---|---|
| a prefix bound in the stylesheet or query | dark.xsl, strip.xsl, scale.xsl, texts.xq | svg:style, //svg:text |
| a default namespace for names in paths and patterns | highlight.xsl, boxes.xsl | xpath-default-namespace="…", then g |
| any namespace | quick XPath at the command line | //*:rect |
| the namespace in the name | anywhere | Q{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.