4.1.9  Leerzeichenbehandlung

Die Leerzeichenbehandlung ist im Bereich Publishing ein heisses Thema. Einerseits sollte der Redakteur dafür sorgen, dass keine überflüssigen Leerzeichen in die Bedatung gelangen, andererseits werden diese aber durch Editoren und Transformationsstrecken hinzugemogelt.
Diese unerwünschten Leerzeichen will man bei der Publikation wieder loswerden. Hierzu stellt XSLT verschiedene Mittel bereit:
1.

Mit xsl:strip-space und xsl:preserve-space kann in der Stylesheet-Deklaration festgelegt werden, in welchen Elementen Leerzeichen-artige Zeichen, das sind z.B. Spaces und Zeilenumbrüche, ggf. auf ein Leerzeichen normalisiert werden sollen. Andersrum kann man auch angeben, wo das explizit nicht geschehen soll.

2.

Die Funktion fn:normalize-space ermöglicht diese Funktionalität in XPath Abfragen und mit fn:translate können Leerzeichen auf andere Zeichen, wie z.B. auch auf den Leerstring, abgebildet werden.

3.

Will man nur die Leerzeichen am Anfang oder am Ende eines PCDATA Elements loswerden, empfiehlt sich ggf. auch ein Blick in die FunctX Bibliothek von Priscillia Wamsley zum Thema Trimming ↗↗.

4.1.9.1  Leerzeichen am Satzanfang

Schnell stellt man fest, dass diese Hausmittel nicht ausreichen, wenn man die Leerzeichen am Anfang einer verschachtelten Inline-Struktur loswerden will, wie z.B. dieser hier:
<title><i>••<!-- comment --><cap>•A</cap>lfons Bliemetsrieders </i>Tagebuch</title>
Die Leerzeichen, markiert als schwarze Punkte •, haben sich eingeschlichen. Man kann hier nicht obige Funktionen pauschal anwenden, denn z.B. das Leerzeichen hinter Bliementsrieder ist durch ein Editierproblem entstanden. Hier wurde versehentlich das Leerzeichen kursiv mitformatiert. Würde man auf dem <i> Tag normalisieren, dann würde dieses Leerzeichen verschluckt werden.

4.1.9.2  Zweistufige Leerzeichen-Eliminierung

Mein Ergeiz für dieses Problem wurde durch die sehr gute Lösung meines Kollegen geweckt, der einen anderen Programmierstil pflegt als ich. Deshalb musste ich beweisen, dass man auch "old-school" regelbasiert mit XSLT zu einer vernünftigen Lösung kommt. Glücklicherweise gibt es mittlerweile sehr ausgeklügelte XSLT Konstrukte.
Wenn man sich das obige Beispiel anschaut, dann lässt sich die Aufgabe in zwei Teile zerlegen:
1.

Entferne alle Textknoten unterhalb von <title> bis zum ersten Textknoten, der auch Buchstaben und sichtbare Zeichen enthält.

2.

Danach kannst Du am ersten Textknoten unterhalb von <title> die führenden Leereichen abschneiden.

Hört sich simpel an, ist es aber leider nicht.
Zunächst recherchierte ich, ob denn auch wirklich an einem PCDATA Element nur ein Textknoten dranhängt. Diese Information war nötig, weil mein erster Algorithmus noch nicht ganz so ausgefeilt war, wie in den zwei Punkten oben beschrieben.
Man kann in einer Transformation mehrere Textknoten hintereinander erzeugen, wie:
<xsl:value-of select="'erster Textknoten'"/><xsl:value-of select="'zweiter Textknoten'"/>
Diese werden aber bei einem "Save-Load Cycle" zu einem Textknoten normalisiert. So steht das zumindest in der DOM Core Spezifikation ↗↗. Inwieweit das dann in den XML Prozessoren umgesetzt ist, musste noch geprüft werden. Dazu habe ich den Saxon Quellcode herangezogen:
Quellcode Schnippsel aus dem Saxon XSLT Prozessor, das zeigt, dass der EndElement-Listener im Parser einen Normalisierungsschritt auf den beteiligten DOM Knoten aufruft.

Bild: 9  endElement() Funktion im Saxon XSLT Prozessor
Die Normalisierungsfunktion lässt Mike Kay dann mit einem aussagekräftigen Kommentar frei...
Methodenrumpf der Normalisierungsfunktion im Saxon XSLT Prozessor

Bild: 10  normalize() Funktion im Saxon XSLT Prozessor
Damit war ich zufrieden - ob das jetzt stimmt oder nicht, ist glücklicherweise für die endgültige Lösung irrelevant.
Mein erster Versuch alle Textknoten auszuschneiden, die nur Leerzeichen enthalten, sah so aus:
<!-- Entferne alle Leerzeichen-Only Knoten, die kein Vorgänger Inline-Element haben -->
<xsl:template match="text()[ancestor::title 
                            and not((.|..)/preceding-sibling::node()[1][self::*]) and
                                not(translate(.,' ','')]"/>
  • Die erste Bedingung prüft, ob sich der Textknoten irgendwo unterhalb von <title> befindet.
  • Die zweite Bedingung prüft, ob als unmittelbarer Vorgänger ein Inline-Element existiert. Gesetzt den Fall, dass aneinander angrenzende Textknoten zu einem Textknoten zusammengefasst sind - wie oben recherchiert - würde das im Negativfall bedeuten, dass wir uns am Satzanfang befinden.
  • Die dritte Bedingung prüft, ob es sich um einen Knoten handelt, der nur aus Leerzeichen besteht. Hier müssten streng genommen auch noch Zeilenumbrüche aufgelistet sein.
Leider konnte dadurch der folgende - Nicht-Real-Welt - Testfall nicht gelöst werden:
<title><b>•Fettes</b><b><i><b><i> </i></b></i>Editierproblem</b></title>
Das Leerzeichen im <i> Tag wurde verschluckt. Das kam wegen der zweiten Bedingung, die nur maximal eine Verschachtelungsebene beachtet. Man könnte zwar den Ausdruck noch aufbohren, z.B. so:
not((.|..|../..|../../..)/preceding-sibling::node()[1][self::*])
Das sieht aber schon wirklich sehr unschön aus.
Da ich mir aber zuvor schon den zweiten Schritt überlegt hatte, der so aussieht:
<!-- Entferne am ersten Textknoten unterhalb von title führende Leerzeichen -->
<xsl:template match="text()[current() is ancestor::title[1]/(descendant::text())[1]]" 
              priority="10"  mode="pass-2">
    <xsl:value-of select="replace(.,'^\s+','')"/>
</xsl:template>
...fiel mir schliesslich die Korrektur für den ersten Schritt leicht:
<xsl:template match="text()[current() &lt;&lt; ancestor::title[1]/
                           (descendant::text()[normalize-space(.)])[1]]" mode="pass-1"/>
  • Ein Test text()[normalize-space(.) genügt, um festzustellen, ob der Textknoten nicht nur Leerzeichen enthält.
  • Andersrum prüft man mit text()[not(translate(.,' ','')) ob der Textknoten nur aus Leerzeichen besteht.
  • Das Flachklopfen einer Sequenzmenge mittels () , wie in (descendant::text()) ist notwendig, damit man auch wirklich nur das erste Element des Descendant-Lookups bekommt.
  • Die fn:current() Funktion wird viel zu selten benutzt... damit erspart man sich eine Variablendeklaration im Rumpf der Regel.
  • Den coolen << Operator, der prüft, ob ein Knoten vor einem anderen kommt, muss man in einem Match-Statement escapen.
Abschliessend ist noch der ganze Quelltext der Lösung abgebildet. Dieser zeigt auch nochmal das Pattern bzgl. der Vortransformationen:
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
    xmlns:xs="http://www.w3.org/2001/XMLSchema"
    exclude-result-prefixes="xs"
    version="2.0">
    
    <xsl:template match="text()[current() &lt;&lt; ancestor::title[1]/
                               (descendant::text()[normalize-space(.)])[1]]" 
                  mode="pass-1"/>
    
    <xsl:template match="text()[current() is ancestor::title[1]/(descendant::text())[1]]" 
                  priority="10"  mode="pass-2">
        <xsl:value-of select="replace(.,'^\s+','')"/>
    </xsl:template>
    
    <xsl:template match="/">
        <xsl:variable name="pass-1">
            <xsl:apply-templates mode="pass-1"/>
        </xsl:variable>
        <xsl:apply-templates select="$pass-1" mode="pass-2"/>
    </xsl:template>
    
    <xsl:template match="node()|@*" mode="#all">
        <xsl:copy>
            <xsl:apply-templates select="node()|@*" mode="#current"/>          
        </xsl:copy>
    </xsl:template>
    
</xsl:stylesheet>

4.1.9.3  Exkurs: Performanz der Descendant-Achse

Aus Verlegenheit hatte ich die komplexere Match-Bedingung des ersten Schritts gegen einen einfachen Pfadselektor ausgewechselt, also das....
match="text()[current() is ancestor::title[1]/(descendant::text())[1]]"
... gegen das ...
match="title/(descendant::text())[1]"
... ersetzt.
GEFAHR!
Damit lief die Transformation aber in ein exponentiellen Performanzproblem!
In einer Match-Bedingung sollte niemals die Descendant-Achse im Pfadselektor auftauchen!
Ansonsten performt die Lösung aber auch bei mehreren Tausend Titeln in Sekundenbruchteilen.

4.1.9.4  Leerzeichen vor einem Inline-Element

Auch die Leerzeichen vor einem Inline-Element sind prüfenswert. Insbesondere um zu sehen, ob keines vergessen wurde. Eine naive Herangehensweise wäre bspw. ausgehend vom Inline-Element zu prüfen, ob ein Vorgänger Textknoten mit einem Leerzeichen abschliesst:
ends-with(preceding-sibling::text()[1],' ')
Dabei werden aber nur die Text-Vorgängerknoten in der Zeile betrachtet, etwaige Inline-Elemente werden aussen vor gelassen. D.h. der folgende Para würde nach diesem XPath Ausdruck noch als gültig erkannt, da nach dem "Hallo " ein Leerzeichen steht.
<p>Hallo <b>fetter Text</b><link>link text</link>
Offensichtlich fehlt aber ein Leerzeichen vor dem <link> Element.
Um zu prüfen, ob der unmittelbare Vorgängerknoten ein Textknoten ist und ob dieser mit einem Leerzeichen abschliesst, kann man diesen XPath Ausdruck verwenden:
ends-with(preceding-sibling::node()[1][self::text()],' ')
Oder klarer:
preceding-sibling::node()[1][self::text()]/ends-with(.,' ')
Da wir aber bisher immer nur die preceding-sibling Achse betrachten, entgehen uns Leerzeichen, die in zuvor gesetzten Inline-Elementen vorkommen, bspw. hier:
<p>Hallo <b>fetter Text </b><link>link Text</link></p>
Deshalb müsste man eigentlich den XPath Ausdruck noch erweitern:
preceding-sibling::node()[1][self::* or self::text()]/ends-with(string(.),' ')
Dabei ist zu beachten, dass der string()-Cast auch noch verschachtelte Inline Strukturen flachklopfen würde.
Jetzt könnte man sich denken, dass man ja eigentlich diesen Ausruck verkürzen könnte zu:
preceding-sibling::node()[1]/ends-with(string(.),' ')
Dann würde aber auch die folgende Zeile erfolgreich geprüft:
<p>Hallo <b> fetter Text <i>Hallo</i></b><!-- Hallo --><link>link Text</link></p>
Denn der Kommentar schliesst mit einem Leerzeichen ab. Auch Kommentare und Processing Instructions sind vom Typ node().
Previous Page Next Page
Version: 93
Jan 25 2021