Verschachtelte <use>s in SVG-Symbolen

Verschachteltete <use>-Elemente innerhalb von SVG-<symbol>-Elementen werden von keinem Browser, der mir zur Verfügung steht, wie erwartet angezeigt. Die einzige Lösung, die ich dafür finden konnte, bestand darin, die Referenzen mit cheerio aufzulösen. Es ist zwar umständlich, funktionierte aber in meinem Fall.

Ein Icon-Set für Flaggen

Das Problem tauchte in einem Projekt auf, für das ich ein Icon-Set mit Flaggen brauchte. Ich beschloss, es mit SVG-Sprites mit <symbol>-Elementen zu versuchen. Im Folgenden ist beschrieben, wie man das mit gulp und npm an den Start bringt:

$ mkdir nested-use
$ cd nested-use
$ npm init --force
npm WARN using --force I sure hope you know what you are doing.
Wrote to .../package.json:
...

Als nächstes müssen die Abhängigkeiten installiert werden:

$ npm install --save-dev gulp
...
$ npm install --save-dev gulp-svgmin 
...
$ npm install --save-dev gulp-svgstore 
...
$ npm install --save-dev flag-icon-css 
...
$ npm install --save-dev gulp-rename 
...

flag-icon-css ist eine Sammlung von Länderflaggen, die als einzelne SVGs gespeichert sind. Diese müssen zunächst einmal in einer einzigen, großen SVG-Datei zusammengeführt werden. Das bewerkstelligt das folgende gulpfile.js:

var gulp = require('gulp');
var svgmin = require('gulp-svgmin');
var svgstore = require('gulp-svgstore');
var rename = require('gulp-rename');

gulp.task('default', ['flags']);

gulp.task('flags', function() {
    return gulp.src('node_modules/flag-icon-css/flags/4x3/*.svg')
    .pipe(svgmin())
    .pipe(svgstore())
    .pipe(rename('flags.svg'))
    .pipe(gulp.dest('.'));
});

Das ist soweit Standard für SVG-Icon-Sets und bedarf nur einer oberflächlichen Erklärung. Der Gulp-Task flags ist in Zeile 8 definiert. In Zeile 9 wird der Stream mit den einzelnen Dateien aus flag-icon-css befüllt. Die SVGs werden in Zeile 10 optimiert und dann in Zeile 11 in einer großen SVG-Datei zusammengeführt. In Zeile 12 wird der Ausgabedateiname auf flags.svg gesetzt, und die Datei gulp.dest() auf die Platte geschrieben.

Das Gulpfile wird mit node node_modules/gulp/bin/gulp.js oder einfach gulp (falls global installiert) ausgeführt.

Lädt man die SVG-Datei flags.svg in den Browser, ist man zunächst überrascht, dass man nichts sieht. Das wird klarer, wenn man die Struktur des generierten SVGs betrachtet:

<svg xmlns="http://www.w3.org/2000/svg" 
     xmlns:xlink="http://www.w3.org/1999/xlink">
  <defs>...</defs>
  <symbol id="ad">...</symbol>
  <symbol id="ae">...</symbol>
  <symbol id="af">...</symbol>
  <symbol id="ag">...</symbol>
  <symbol id="ai">...</symbol>
  ...
</svg>

Weil die SVG-Datei minimiert ist, untersucht man die Struktur am besten mit dem DOM-Inspektor der Entwickler-Konsole des Browsers.

Das zusammengeführte SVG enthält ein <defs>-Element mit einer Reihe von <clipPath>s für jedes Icon. Darauf folgen <symbol>-Elemente, eins für jede Eingabedatei. Ein <symbol> ist in etwa das gleiche wie eine Gruppe (<g>), nur dass Symbole nicht gerendert werden. Das erklärt, weshalb der Browser nichts anzeigt.

Damit das passiert, muss eine minimale HTML-Datei erzeugt werden, in der die SVG-Symbole referenziert werden:

<!DOCTYPE html>
<html>
  <body>
    <svg viewBox="0 0 640 480" width="160" height="120"
         style="border: 1px solid #eeeeee;">
      <use xlink:href="flags.svg#bg"/>
    </svg>
    <svg viewBox="0 0 640 480" width="160" height="120"
         style="border: 1px solid #eeeeee;">
      <use xlink:href="flags.svg#de"/>
    </svg>
    <svg viewBox="0 0 640 480" width="160" height="120"
         style="border: 1px solid #eeeeee;">
      <use xlink:href="flags.svg#in"/>
    </svg>
  </body>
</html>

Was haben wir hier? Es werden drei Flaggen aus der Kollektion angezeigt, die bulgarische, die deutsche und die indische. Für jede Flagge wird ein Inline-<svg>-Element mit etwas Alibi-CSS und einem einzigen Kindelement, einem <use> geschrieben. Das <use>-Element referenziert die Icons mit den IDs bg, de, und in aus der externen Datei flags.svg, die wir gerade erzeugt haben.

Im Browser sieht man jetzt, dass dies für die bulgarische und die deutsche Flagge einwandfrei funktioniert. Nur die indische Flagge wird lediglich als schwarzes Rechteck dargestellt.

Hinweis: Google Chrome zeigt hier überhaupt keine Grafiken an, wenn die HTML-Datei über einen file:///-URI geladen wurde. Am besten startet man daher einen Ad-Hoc-Web-Server und betrachtet die Datei dort.

Struktur der fehlerhaften SVG-Dateien

Das Problem mit der Grafik der indischen Flagge (und einer Reihe anderer Flaggen) ist, dass sie selber <use>-Elemente enthält:

<svg xmlns="http://www.w3.org/2000/svg" 
     xmlns:xlink="http://www.w3.org/1999/xlink" 
     height="480" width="640" version="1">
  <path fill="#f93" d="M0 0h640v160H0z"/>
  <path fill="#fff" d="M0 160h640v160H0z"/>
  <path fill="#128807" d="M0 320h640v160H0z"/>
  <g transform="matrix(3.2 0 0 3.2 320 240)">
    <circle r="20" fill="#008"/>
    <circle r="17.5" fill="#fff"/>
    <circle r="3.5" fill="#008"/>
    <g id="d">
      <g id="c">
        <g id="b">
          <g id="a" fill="#008">
            <circle r=".875" transform="rotate(7.5 -8.75 133.5)"/>
            <path d="M0 17.5L.6 7 0 2l-.6 5L0 17.5z"/>
          </g>
          <use height="100%" width="100%" xlink:href="#a" transform="rotate(15)"/>
        </g>
        <use height="100%" width="100%" xlink:href="#b" transform="rotate(30)"/>
      </g>
      <use height="100%" width="100%" xlink:href="#c" transform="rotate(60)"/>
    </g>
    <use height="100%" width="100%" xlink:href="#d" transform="rotate(120)"/>
    <use height="100%" width="100%" xlink:href="#d" transform="rotate(-120)"/>
  </g>
</svg>

Hier hat jemand einen sehr cleveren Weg gefunden, um die auf der indischen Flagge abgebildete Ashoka Chakra mit Hilfe von <use>-Elementen zusammenzupuzzlen, offensichtlich zu clever für alle Browser, die ich testen konnte, nämlich Firefox 47, Chrome 51 and Safari 9.1.1, alle auf Mac OS X.

Auflösen der <use>-Elemente

Ein <use>-Element in SVG ist einfach ein Platzhalter für das Element, das es referenziert. Es sollte daher möglich sein, dass Problem zu lösen, indem die <use>s aufgelöst, also durch das Element, das sie referenzieren, ersetzt werden.

Wir verwenden bereits gulp-svgmin für die Optimierung der SVG-Grafiken mit SVGO. Allerdings hat SVGO kein Plug-In für das Auflösen von <use>-Elementen, was auch verständlich ist, weil das SVG dadurch nicht optimiert, sondern vielmehr unnötig aufgebläht wird.

Ich habe die Transformierung daher mit cheerio selbst geschrieben. Zunächst muss aber cheerio als Dev-Abhängigkeit installiert werden:

$ npm install --save-dev gulp-cheerio

Das gulpfile.js muss wie folgt geändert werden:

const gulp = require('gulp');
const svgmin = require('gulp-svgmin');
const svgstore = require('gulp-svgstore');
const rename = require('gulp-rename');
const cheerio = require('gulp-cheerio');

gulp.task('default', ['flags']);

gulp.task('flags', function() {
    function inlineUse($) {
        $('use').each(function(i, elem) {
            var ref = $(this).attr('xlink:href');
            if (ref !== undefined && $(ref) !== null) {
                var copy = $(ref).clone(),
                    attributes = $(this).attr();

                copy.attr('id', null);
                for (var attr in attributes) {
                    if (attributes.hasOwnProperty(attr)
                        && attr !== 'xlink:href') {
                        var value = attributes[attr];
                        if (attr === 'transform'
                            && copy.attr(attr) !== undefined) {
                            value = copy.attr(attr) + ' ' + value;
                        }
                        copy.attr(attr, value);
                    }
                }
                $(this).replaceWith(copy);
            } else {
                $(this).remove();
            }
        })
    }

    return gulp.src('node_modules/flag-icon-css/flags/4x3/*.svg')
    .pipe(svgmin())
    .pipe(cheerio({
        run: inlineUse,
        parserOptions: { xmlMode: true }
    }))
    .pipe(svgstore())
    .pipe(rename('flags.svg'))
    .pipe(gulp.dest('.'));
});

Was ist hier neu? Natürlich muss gulp-cheerio eingebunden (Zeile 5) und in den Zeilen 38 bis 41, zwischen svgmin() and svgstore() aufgerufen werden. Die eigentliche Transformation bewerkstelligt die Funktion inlineUse() in Zeile 10.

Die Funktion iteriert über alle <use>-Elemente (Zeile 11). Für alle diese Elemente, die ein Attribut xref:href haben, das auf ein existierendes Element verweist (siehe Zeile 13), wird eine (tiefe) Kopie erzeugt. Hier kann man optimieren und stattdessen eine flache Kopie erzeugen, die ausreichend ist.

Weil das referenzierte Element eventuell mehrfach dupliziert wird, muss ein eventuell vorhandenes id-Attribut gelöscht werden (Zeile 17). Hier droht eventuell Ungemach, falls die ID anderswo verwendet wird, aber das ist relativ unwahrscheinlich.

In der Schleife, die in Zeile 18 beginnt, werden alle Attribute des <use>-Elements, bis auf das Attribute xlink:href in den Klon des referenzierten Elements kopiert. Nur das Attribut transform wird besonders behandelt, und mit einem eventuellen transform-Attribut des Klons verkettet.

Schließlich wird das <use>-Element in Zeile 29 durch die modifizierte Kopie ersetzt, allerdings nicht, wenn es ungültig war. In diesem Falle wird es stattdessen einfach aus dem DOM entfernt (Zeile 31). Das ist sinnvoll, weil es so aussieht, dass die bloße Anwesenheit eines <use> schon die Anzeige durch den Browser verhindert.

Wird das Gulpfile erneut mit node node_modules/gulp/bin/gulp.js ausgeführt, sollte der Browser jetzt nach einem Reload die indische Flagge in voller Schönheit und mit Ashoka Chakra darstellen.

Fazit

Ich bin wahrlich kein SVG-Experte, und es ist gut möglich, dass es eine viel einfachere Lösung für das Problem gibt. Ich bin auch nicht sicher, ob das von mir beobachtete Browserverhalten ein Defekt ist (der eventuell bald behoben wird) oder einen guten Grund hat. Wer mehr darüber weiß als ich, ist herzlich eingeladen, sein Wissen in einem Kommentar zu teilen.


blog comments powered by Disqus