Browser…​unplugged
Showcases of modern Browser technologies

13. Mai 2018

Animierte Kalligrafie

Auf Stackoverflow gibt es immer mal wieder Fragen, ob es ein Equivalent zur stroke-dashoffset-Technik  zur Animation von SVG stroke auch für das fill-Attribut gibt. Wenn man genauer hinschaut, ist damit oft folgendes gemeint:

Ich habe etwas, dass in etwa wie eine Linie aussieht, aber weil es veränderliche Strichstärken hat, ist es in SVG als Füllung eines Pfades definiert. Wie kann ich diesen "Strich" animieren?

Kurz: Wie animiert man Kalligrafie?

Eine Maske überdeckt den kalligrafischen Strich

Die grundlegende Technik dafür ist relativ einfach. Zeichne einen zweiten (glatten) Pfad über die Kalligrafie, so dass er der wahrgenommenen Linie folgt. Wähle die Strichstärke so, dass er die Kalligrafie vollständig bedeckt.

Dieser obere Pfad kann als Maske für den unteren eingesetzt werden. Wende die stroke-dashoffset-Technik auf den Maskenpfad an. Das Ergebnis sieht so aus, als ob der untere Pfad "geschrieben" würde.

Hier muss mask eingesetzt werden - ein clip-path funktioniert nicht. Ausschneidepfade referenzieren immer nur den Füllbereich eines Pfades, der stroke wird ignoriert.

Am einfachsten ist es, für den Pfad in der Maske stroke: white anzugeben. Dann wird alles verborgen, was sich außerhalb des weißen Bereiches befindet, und alles innerhalb wird in unveränderter Form angezeigt.

See the Pen Writing calligraphy: basic example by ccprog (@ccprog) on CodePen.

So weit, so einfach. Ungemütlich wird es, wenn die zu animierende Kalligrafie sich selbst überlappt. Dies passiert bei naiver Implementierung:

See the Pen Writing calligraphy: faulty intersection by ccprog (@ccprog) on CodePen.

Der Schnitt im Maskenpfad und im kalligrafischen Strich müssen übereinstimmen

Am Überschneidungspunkt erfasst die Maske auch Teile des kreuzenden Strichs. Deswegen muss die Kalligrafie in sich nicht überschneidende Teile zerlegt werden. Staple sie in der richtigen Zeichenreihenfolge übereinander und definiere Maskenpfade für jeden einzelnen Teil.

Der heikelste Teil ist es, dabei den Eindruck eines kontinuierlichen Strichs zu bewahren. Wenn man einen glatten Pfad zerschneidet, passen die Schnittenden zusammen, solange die Pfade an ihrem Treffpunkt dieselbe Tangente besitzen. Die Pfadabschlüsse stehen dazu im rechten Winkel, und es ist entscheidend, dass der Schnitt im kalligrafischen Strich sich exakt daran ausrichtet. Achte darauf, dass alle Pfade dieselbe fortlaufende Richtung besitzen, und animiere einen Teil nach dem anderen.

Während viele Strichanimationen mit ungefähren Schätzungen der Länge auskommen, die für den stroke-dasharray benötigt wird, werden hier echte Messungen benötigt (bis auf vertretbar kleine Rundungen). Deswegen sei daran erinnert, dass man sie in den Devtools mit dieser Konsoleneingabe ermitteln kann:

document.querySelector('#mask1 path').getTotalLength()

See the Pen Writing calligraphy: divide up intersections by ccprog (@ccprog) on CodePen.

"Ein Teil nach dem anderen" ist in CSS etwas unbequem zu schreiben. Am besten ist es wahrscheinlich, allen Teilanimationen den gleichen Beginnzeitpunkt und die gleiche Gesamtdauer zu geben, und dann zu Zwischenzeiten Keyframes zu definieren, zwischen denen sich der stroke-dashoffset ändert. So:

@keyframes brush1 {
      0% { stroke-dashoffset: 160; } /* bleibt statisch */
     12% { stroke-dashoffset: 160; } /* Start des ersten Strich */
     44% { stroke-dashoffset: 0; }   /* Ende des ersten Strich entspricht Start des zweiten */
    100% { stroke-dashoffset: 0; }   /* bleibt statisch */
}
@keyframes brush2 {
      0% { stroke-dashoffset: 210; } /* bleibt statisch */
     44% { stroke-dashoffset: 210; } /* Start des zweiten Strich entspricht Ende des ersten */
     86% { stroke-dashoffset: 0; }   /* Ende des zweiten Strich */
    100% { stroke-dashoffset: 0; }   /* bleibt statisch */
}

Weiter unten beschreibe ich, wie eine SMIL-Animation eine etwas flüssigere und expressivere Schreibweise für Zeitangaben ermöglicht.

Links der Maskenpfad, rechts seine Anwendung

Ein ähnliches Problem entsteht, wenn der Kurvenradius des Maskenpfads kleiner wird als seine Strichbreite. Wenn die Animation diese Kurve durchläuft, kann es sein, dass ein Zwischenzustand völlig misslungen aussieht.

Der Radius bleibt groß genug

Die Lösung ist, den Maskenpfad aus der kalligrafischen Kurve hinauslaufen zu lassen. Es ist nur wichtig, dass die Innenkante den Strich noch bedeckt. Man kann sogar den Maskenpfad zerschneiden und die Enden gegeneinander verschieben, solange nur die Schnittflächen zusammen passen.

See the Pen Writing calligraphy: divide up intersections by ccprog (@ccprog) on CodePen.

Und damit lässt sich auch eine komplexe arabische Kalligrafie zeichnen, wie diese hier. Die ursprüngliche Zeichnung, die Tughra  des osmanischen Sultans Mahmud II.  stammt von einem unbekannten Kalligrafen des 19. Jahrhunderts. Die Vektorisierung hat der Wikipedia-Grafiker Baba66  erstellt. Die Animation ist mein Versuch, die arabischen Schriftzeichen innerhalb der Zeichnung erkennbar zu machen und baut auf einer früheren Version von Baba66 auf. Lizenz: Creative Commons Attribution-Share Alike 2.5 .

Im Unterschied zu den obigen Beispielen nutzt diese Animation SMIL . Deswegen funkioniert sie leider nicht in Internet Explorer oder Edge.

Der folgende Codeausschnitt zeigt die Weiterentwicklung der Methode, um eine Animation nach der anderen laufen zu lassen und um Wiederholungen zu ermöglichen.

mask path {
    fill: none;
    stroke: white;
    stroke-width: 16;
}
.brush {
    fill: #0d33f2;
}
<mask id="mask1" maskUnits="userSpaceOnUse">
  <path stroke-dasharray="160 160" stroke-dashoffset="160" d="...">
    <!-- Die Animation beginnt nach dem Dokumentstart und wird mit einem Klick
         auf den "Repeat"-Button wiederholt. -->
    <animate id="animate1" attributeName="stroke-dashoffset"
             from="160" to="0" begin="1s;repeat.click" dur="1.6s" />
  </path>
</mask>
<mask id="mask2" maskUnits="userSpaceOnUse">
  <path stroke-dasharray="350 350" stroke-dashoffset="350" d="...">
    <!-- Die Animation beginnt am Ende der vorherigen. -->
    <animate id="animate2" attributeName="stroke-dashoffset"
             from="350" to="0" begin="animate1.end" dur="3.5s" />
  </path>
</mask>
<!-- Weitere masks... -->
<mask id="mask15" maskUnits="userSpaceOnUse">
  <path stroke-dasharray="230 230" stroke-dashoffset="230" d="...">
    <!-- fügt eine künstliche Pause zwischen den Animationen ein, als ob
         der Pinsel abgesetzt wurde. -->
    <animate id="animate15" attributeName="stroke-dashoffset"
             from="230" to="0" begin="animate14.end+0.5s" dur="2.3s" />
  </path>
</mask>

<g class="brush">
  <path id="brush1" d="...">
    <!-- Die Maske wird nur angewendet, nach dem das Dokument startet/wiederholt
         wird, und nur solange die Animation läuft. Das stellt sicher, dass
         Renderer, die SMIL nicht unterstützen, dennoch Striche zeigen. -->
    <set attributeName="mask" to="url(#mask1)"
         begin="0s;repeat.click" end="animate1.end;indefinite" />
  </path>
  <path id="brush2" d="...">
    <set attributeName="mask" to="url(#mask2)"
         begin="0s;repeat.click" end="animate2.end;indefinite" />
  </path>
  <!-- Weitere paths... -->
  <path id="brush15" d="...">
    <set attributeName="mask" to="url(#mask2)"
         begin="0s;repeat.click" end="animate15.end;indefinite" />
  </path>
</g>