Mein Ehrgeiz für dieses Problem wurde durch die sehr gute Lösung meines Kollegen geweckt, der einen anderen Programmierstil pflegt. 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 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'"/>Plain Text
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:

Die Normalisierungsfunktion lässt Mike Kay XML Gurus Michael Kay dann mit einem aussagekräftigen Kommentar frei...

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(.,' ','')]"/>Plain Text
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>Plain Text
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::*])Plain Text
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>Plain Text
...fiel mir schliesslich die Korrektur für den ersten Schritt leicht:
<xsl:template match="text()[current() << ancestor::title[1]/ (descendant::text()[normalize-space(.)])[1]]" mode="pass-1"/>Plain Text
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 (imported):
<?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() << 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>Plain Text