Browser…​unplugged
Showcases of modern Browser technologies

27. April 2020

Vermitteln zwischen CSS und SVG für das Pfad-Mikroformat mit der Bibliothek pathfit

In den letzten Jahren wurden einige Anstrenguen unternommen, um CSS und SVG miteinander zu verzahnen. In der SVG 2-Spezifikation gibt es sogar den Beschluss, dass es keine neuen Präsentations-Attribute mehr geben soll und nur noch CSS-Eigenschafte eingesetzt werden sollen. Dennoch, einige Teile widersetzen sich hartnäckig der Vereinheitlichung. Das Pfad-Mikroformat gehört dazu.

(Während dieser Artikel eigentlich eine Bibliothek vorstellen soll, folgt hier erst einmal eine grundsätzliche Abhandlung über Parser. Wen das nicht interessiert, findet weiter unten, wie pathfit Pfadbeschreibungen an Container-Elemente anpasst.)

In CSS sind wir gewohnt, dass Orts- oder Maßangaben eine Einheit haben, und dass die immer direkt an die Zahl angefügt werden muss. SVG kennt im Gegensatz dazu das Konzept des Benutzer-Koordinatensystems, dass an jeder Stelle des XML-Baumes transformiert und neu definiert werden kann, so dass die eigentlichen Koordinatenangaben dimensionslose Zahlen sein können.

Beide Konzepte haben ihren Weg tief in die jeweilige Grammatik eingegraben und führen zu Grundkonzepten, die nurmehr schwer miteinander zu vereinbaren sind.

Dazu kommt ein weiterer Unterschied in der Art und Weise, wie Wörter getrennt werden. SVG hat einen permissiven Ansatz, wo Kommas grundsätzlich optional sind, und wo sogr Whitespace weggelassen werden kann, solange die angrenzenden Wörter noch eindeutig unterscheidbar sind. CSS unterscheidet die Bedeutung von Leerzeichen (als Trenner von Schlüsselworten) und Kommas (als Kennzeichner von Listen).

🔗 Wieso haben Pfade keine Einheiten?

Die aus SVG stammende Mikrosyntax des Pfaddefinitions-Attributes (<path d="..." />) hat inzwischen ihren Weg in CSS gefunden: neben dem URL-Referenzieren von <path>-Elementen z. B. in Eigenschaften wie shape-outside gibt es einige Eigenschaften, die die path()-Funktion als Wert akzeptieren, mit der Pfad-Syntax als String-Argument: clip-path, motion-path, shape-inside und shape-outside.

Seit Browser diese Eigenschaften implementieren, beschweren sich Entwickler immer öfter, dass die dimensionslosen Pfade de facto nur px-Einheiten darstellen, die sich nicht responsiv verhalten. „Wir wollen Prozent-Werte!“, so lese ich es immer wieder.

Ich sehe allerdings derzeit keinen gangbaren Weg, um diese Forderung zu verwirklichen. Weder kann der CSS-Parser die Pfad-Grammatik verarbeiten, noch ist der SVG-Parser so designt, dass er Zahlen mit Einheiten zu verarbeiten vermöchte. Abwärtskompatibele Änderungen hätten erhebliche Hürden zu überwinden.

🔗 Pfade im CSS-Parser

Sehen wir uns einmal ein Pfaddefinitions-Attribut an. Alle diese Schreibweisen stellen den gleichen AST dar, und alle folgen einem der typischen Muster, wie sie von oft verwendeter Software serialisiert werden:

M0,20h20q5-10,20-10,5,10,2,20z            <- Illustrator
M 0,20 h 20 q 5,-10 20,-10 5,10 20,20 z   <- Inkscape
M 0 20 h 20 q 5 -10 20 -10 5 10 20 20 z   <- D3

In YAML könnte dieser Baum etwa so aussehen:

- command: M
  sequence:
  - coordinate_pair: { x: 0, y: 20 }
- command: h
  sequence:
  - coordinate: 20
- command: q
  sequence:
  - control_point: { x: 5, y: -10 }
    coordinate_pair: { x: 20, y: -10 }
  - control_point: { x: 5, y: 10 }
    coordinate_pair: { x: 20, y: 20 }
- command: z

Dieselben Sequenzen, wenn sie als CSS interpretiert würden, ergäben drei unterschiedliche Ergebnisse. Die erste wäre schon nach dem Tokenizer  unverwendbar:

- token: name
  value: M0
- token: comma
- token: dimension
  value: 20
  type: integer
  ident: h20q5-10 # vergleiche https://www.w3.org/TR/css-values/#component-whitespace
- token: comma
- token: number
  value: -10
  type: integer
- token: comma
# usw

In der zweiten Variante würden die Kommas als übergeordnete Struktur interpretiert, und das Ergebnis wäre eine Liste, deren Hierarchie die Koordinaten-Paare auseinander reißt:

- [M, 0]
- [20, h, 20, q, 5]
- [-10, 20]
- [-10, 5]
- [10, 20]
- [20, z]

Nur die dritte Variante würde ein Ergebnis erbringen (eine simple Sequenz von Namen und Zahlen), dass dem Parser auch nur eine Chance lässt, die Grammatik zu interpretieren.

Zusammengefasst: Der CSS-Parser kann Pfadkommandos nicht verlässlich interpretieren. Die Verarbeitung muss mit dem SVG-Parser für die Pfad-Mikrosyntax erfolgen. Dass ist der Grund, warum in der CSS-Funktion path() das Pfadargument in Anführungszeichen steht, so dass der Inhalt unverändert weitergereicht werden kann.

🔗 Einheiten im SVG-Parser

SVG verweist in Version 2 für die Beschreibung von Attributwerten fast immer auf CSS Values and Units . Auch Längenangaben  werden im allgemeinen so spezifiziert. Die eine auffällige Ausnahme sind Pfaddefinitionen, die mit einer eigenen EBNF-Grammatik  definiert werden. Der Grund ist natürlich: die Mischung aus Kommandos und Zahlen passt genauso wenig ins CSS-Konzept wie der permissive Umgang mit Leerzeichen und Kommas.

Könnte der so beschriebene Parser erweitert werden, um auch Längenenheiten zu verstehen? Das hätte so seine Tücken.

Die folgende Sequenz besteht aus einer Mischung aus Zahlen und Buchstaben:

M1e2, 3l4.5in, 6Q 7,8 9,10

Das erste M ist ein Pfad-Kommando. Das e ist Teil einer Zahl in wissenschaftlicher Notation, das l wieder ein Pfad-Kommando, die Buchstaben in sind eine Längeneinheit. Aber was bedeutet das Q?

Als Pfadkommando steht es für einen quadratischen Bezier-Bogen, der duch vier folgende Zahlen (zwei absolute Koordinaten) beschrieben wird. Aber es gibt auch eine Längeneinheit Q: ein Viertel-Milimeter. Ich wusste davon ehrlich gesagt auch nichts, bis ich es in der Spezifikation  gefunden habe, aber es existiert, und ein kurzer Test zeigt, dass die Browser es auch implementieren.

Ein Parser, der mit dem Ziel der Abwärtskopatibilität weiter entwickelt wird, darf er seine Grundkonzepte nicht ändern und muss weiterhin „greedy“ arbeiten:

The processing of the EBNF must consume as much of a given EBNF production as possible, stopping at the point when a character is encountered which no longer satisfies the production.

Die Verarbeitung der EBNF muss soviel einer gegebenen EBNF-Produktion verarbeiten wie möglich und erst anhalten, wenn ein Zeichen erreicht wird, dass die Produktion nicht mehr erfüllt.

Also: wenn eine Zahl von einer Einheit gefolgt werden darf, dann wird als erstes versucht, die folgenden Zeichen als Einheit zu interpretieren. Erst danach ist die Verarbeitung der Produktion „Länge“ abgeschlossen, und die nächste Einheit kann als Kommando verstanden werden.

Damit würde das Q zwingend als Einheit der vorausgehend Zahl zugeordnet. Die geforderte Abwärtskopatibilät wäre unmöglich geworden.

Eine interessante Variante hat übrigens Amelia Bellamy-Royds 2016 vorgeschagen : Setze alle einheitenbehafteten Längen oder sogar Rechenoperationen in runde Klammern. Etwa so (ihr Beispiel):

M(10px)(1em)
H(100%-10px)
A(10px)(5mm)0 0 0 (100%)(1em+5mm)
V(1em+3in-5mm)
A(10px)(5mm)0 0 0(100%-10px)(1em+3in)
H(10px)
A(10px)(5mm)0 0 0(0px)(1em+3in-5mm)
V(1em+5mm)
A(10px)(5mm)0 0 0 Z

Geworden ist daraus allerdings bisher nichts, auch wenn der Vorschlag als Milestone  für eine zukünftige SVG-Version 2.1 Erwähnung findet.

🔗 Rechnen mit Pfaden

Damit sind wir zurück bei der Ersatzlösung: Wenn in CSS eingesetzte Pfade sich an Einheiten anpassen sollen, dann müssen sie vorher in das Benutzer-Koordinatensystem umgerechnet werden.

Ein Anwendungsfall ist dabei besonders wichtig: relative Einheiten können sich responsiv anpassen. Dabei ist es gar nicht mal so entscheidend, jede Zahl einzeln zu bestimmen. Es wäre schon viel gewonnen, wenn der Pfad insgesamt Transformationene unterworfen werden kann.

Nun könnte man sagen: genau dazu gibt es ja die CSS-Eigenschaft transform. Und für clip-path ist das vermutlich auch völlig ausreichend.

Aber bei shape-inside und shape-outside wird es schon unangenehmer: der den Pfad umfließende Text wird mittransformiert. Und bei motion-path würde die Transformation auf das zu animierende Element andgewandt. Es wäre notwendig, ein inneres Element mit einer inversen Transformation zu definieren, um dies zu vermeiden.

Und Responsivität ware damit auch noch nicht erreicht, denn relative Einheiten funktionieren auch nicht mit allen Transformations-Funktionen. Vor allem die wichtigste, scale(), arbeitet nur mit dimensionslosen Zahlen. Damit kann sie nicht auf Veränderungen z. B. des Viewports reagieren. Wie drückt man aus, dass das Ergebnis einer Skalierung immer denselben Prozentsatz eines Container-Elements beschreiben soll?

🔗 Standardaufgabe Größenanpassung

Dieses Problem macht sich meine Bibliothek pathfit zur Aufgabe. Mein Dank geht an Jhey Tompkins, der mich auf dieses Projekt gebracht hat .

Im Kern ermöglicht sie, Pfad-Definitionen beliebigen affinen Transformationen zu unterwerfen. Also etwa:

Ausgangspfad:    'M 0,0 100,100'
Transformation:  translate(50, 0) scale(0.5, 0.8)
Ergebnispfad:    'M 50,0 100,80'

Das hätte übrigens keine neue Bibliothek erfordert, die gibt es schon . Aber für die hier gestellte Aufgabe, einen Pfad responsiv an seine Umgebung anzupassen, bleibt noch ein erheblicher Rechenweg übrig.

Deswegen kennt die Bibliothek noch zwei weitere Rechenmethoden: wenn ein Orginalmaß für einen Pfad angegeben wird, also ein Rechteck, in dem der Pfad „passt“, dann kann der Pfad genau so skaliert werden, dass er in ein Rechteck mit anderer Größe genauso passt. Zum Beispiel:

Ausgangspfad:    'M 0,0 100,0 100,100 0,100 z'
Ausgangsgrö0e:   width: 100px; height: 100px;
Zielgröße:       width: 50px; height: 50px;
Ergebnispfad:    'M 0,0 50,0 50,50 0,50 z'

Wer sich ein wenig auskennt, dem fällt aber gleich auf, dass es ein Probleem gibt, wenn das Seitenverhältnis der Start-und Zielrechtecke nicht übereinstimmt. Deshalb braucht es noch weitere Angaben zur Art der Passung und zur Positionierung.

Dafür gibt es zwei Syntax-Varianten:

  • in SVG kann das Ausgangsrechteck mit vier Werten für Position und Größe als eine viewBox beschrieben werden, und die Passung mit dem Attribut preserveAspectRatio.

  • in CSS gibt es zwar nur zwei Werte für eine intrinsiche Breite und Höhe eines Ausgangsrechtecks, aber dafür liefern die Eigenschaften object-fit (oder background-size) und object-position (oder background-position) reichhaltigere Möglichkeiten zur Beschreibung des gewünschten Ergebnisses.

pathfit implementiert beide Varianten.

Mit preserveAspectRatio könnte eine Anwendung so aussehen:

const pathfitter = new Pathfit({
    viewBox: '0 0 100 100',
    preserveAspectRatio: 'xMinYMid meet'
});
pathfitter.set_path('M 0,0 100,0 100,100 0,100 z');

const container = document.querySelector('.motionContainer');
const moving = container.querySelector('.moving');

const containerObserver = new ResizeObserver((entries) => {
    const {width, height} = entries.pop().contentRect;

    const scaled_path = pathfitter.scale_with_aspect_Ratio(width, height);

    moving.style.offsetPath = `path('${scaled_path}')`;
});

containerObserver.observe(container);

Mit object-fit kann ich ein vollständiges, ausführbares Beispiel präsentieren:

See the Pen Responsive offset-path with pathfit.js by ccprog (@ccprog) on CodePen.

Kleiner Tipp zum Schluss: Wie alle npm-Pakete kann auch pathfit direkt von unpkg.com  eingebunden werden:

<script src="https://unpkg.com/pathfit/pathfit.js"></script>