Template Toolkit und Unicode

Schon bei der bloßen Erwähnung von Unicode im Zusammenhang mit Template Toolkit bricht bei vielen Perl-Hackern das große Heulen und Zähneklappern aus. Sobald die Eingabedaten Nicht-ASCII-Zeichen enthalten kommt es immer wieder zu scheinbar zufällig auftretenden doppelt kodierten UTF-8-Zeichen bzw. Fragezeichen in der Ausgabe. Wie lässt sich das vermeiden?

Perls UTF8-Flag

Die Quelle allen Übels ist Perls sogenannentes UTF8[sic!]-Flag. Beginnent mit Perl 5.6 haben Perl-Skalare --- für unsere Zwecke können wir auch Zeichenketten sagen --- diese versteckte Eigenschaft. Ob das Flag gesetzt ist oder nicht, liegt im Belieben des Autors des Codes, der die Strings produziert hat.

Das Flag ist vor allem eines, nämlich nutzlos. Allerdings verhunzt Perl Daten, wenn Strings, die das Flag gesetzt haben und Strings ohne das Flag konkateniert werden. Nicht-US-ASCII-Zeichen werden dabei manchmal mit einem Fragezeichen ersetzt, manchmal doppelt kodiert. Umlaute und akzentuierte Zeichen beispielsweise werden durch zwei Zeichen ersetzt, wobei das erste ein großes A mit Tilde ist (zum Beispiel Café statt Café). Kyrillische Strings verwandeln sich in eine Ansammlung von Ðs und Ñs, oft von nicht druckbaren Zeichen gefolgt. Was genau herauskommt, hängt vom Schriftsystem der Eingabedaten ab.

Werden Strings, für die das Flag gesetzt ist, in eine Datei geschrieben, müssen dabei ebenfalls zusätzliche Maßnahmen getroffen werden, damit Perl die Daten nicht korrumpiert.

Das Problem rührt daher, dass Perl unzulässigerweise annimmt, dass Daten ohne das Flag in einem 8-Bit-Zeichensatz kodiert sind und dann munter zwischen einer geratenen 8-Bit-Kodierung und UTF-8 hin- und herkodiert. Das ist immer falsch, lässt sich aber leider nicht verhindern.

Template Toolkit und das UTF8-Flag

Die vornehmliche Aufgabe von Template-Prozessoren --- Template Toolkit macht da keine Ausnahme --- ist das Zusammenfügen von Strings aus verschiedenen Quellen zu einem Ausgabestring. Wenn all diese Strings UTF-8-kodiert sind, ist das eigentlich eine triviale Angelegenheit, aber aufgrund der oben beschriebenen merkwürdigen Perl-Features, gibt es hier etliche Stolpersteine. Kurz gesagt, muss sichergestellt sein, dass das Flag für alle beteiligten Daten entweder an- oder abgeschaltet ist. Ein Mix resultiert unweigerlich in Fehlern.

Am einfachsten lässt sich eine korrekte Ausgabe erzielen, indem man nichts tut! Wenn möglich, sollte man das Flag einfach ignorieren, nicht die Template-Toolkit-Option ENCODING verwenden, und auch die sogenannten Ausgabe-Disziplinen bei der Ausgabe nicht verwenden.

Leider lässt sich das nicht immer verwirklichen, insbesondere, wenn die Applikation mit Drittsoftware interagiert. Es gibt hauptsächlich drei Gelegenheiten, bei denen es zu Fehlern kommen kann:

  1. Lesen der Templates
  2. Interpolation von Variablen
  3. Ausgabe.

Ein einfaches Beispiel soll dies illustrieren. Dazu erzeugen wir eine Skriptdatei render.pl:

[% FILTER $Highlight language-perl line-numbers %] use strict;

use Template; use YAML qw(Load);

my $yaml = <<EOF;

month: Décembre year: 2018 prices: coffee: 3,50 tea: 2,40 beer: 2,80 EOF my \(vars = Load \)yaml; $vars->{currency} = '€';

my $template = <Café de la gare

Menu pour [% month %] [% year %]

[% INCLUDE menu.tt %] EOF

Template->new->process($template, $vars); [% END %]

Das Skript liest die Template-Variablen $vars aus YAML und erweitert sie mit einer Währungsangabe, bevor sie dem Template-Prozessor zum Rendern übergeben werden. Damit das Beispiel funktioniert, muss noch eine Template-Datei menu.tt im selben Verzeichnis erzeugt werden:

<ul>
  <li>Café:  </li>
  <li>Thé:  </li>
  <li>Bière:  </li>
</ul>

Sind die Perl-Module Template und YAML installiert, sollte das Skript die folgende Ausgabe liefern:

$ perl render.pl
<h1>Café de la gare</h1>
<p>Menu pour Décembre 2018</p>
<ul>
  <li>Café: 3,50 €</li>
  <li>Thé: 2,40 €</li>
  <li>Bière: 2,80 €</li>
</ul>

Alles korrekt, bis jemand im Perldoc von YAML liest, dass man stattdessen lieber YAML::XS verwenden soll, weil es schneller und robuster ist. Also wird YAML::XS installiert, und Zeile 4 entsprechend geändert:

use YAML::XS qw(Load);

Jetzt liefert das Skript diese Ausgabe:

$ perl render.pl
<h1>Café de la gare</h1>
<p>Menu pour D?cembre 2018</p>
<ul>
  <li>Café: 3,50 €</li>
  <li>Thé: 2,40 €</li>
  <li>Bière: 2,80 €</li>
</ul>

Aua. Die Zeile mit dem Monat ist nicht mehr korrekt. Das e mit Accent Aigue ist verhunzt: D?cembre.

Was ist passiert? Ursache des Problems ist, dass YAML::XS das UTF8-Flag rekursiv für die erzeugte Datenstruktur setzt, und dieses Verhalten lässt sich leider nicht vermeiden.

Alle Daten sind noch immer UTF-8-kodiert, aber teilweise ist das Flag gesetzt, teilweise nicht. Als Resultat repariert Perl die Daten, indem es sie mehr oder weniger willkürlich verhunzt.

Strategie 1: Das UTF8-Flag abschalten

Eine Möglichkeit besteht darin, dass UTF8-Flag rekursiv für alle Daten abzuschalten:

...
my $vars = Load $yaml;

use Encode;
Encode::_utf8_off($vars->{month});

$vars->{currency} = '€';
...

Das ist nicht wirklich rekursiv, reicht aber, weil lediglich month nicht US-ASCII ist. Das sieht zunächst wie ein Hack aus. Beim weiteren Lesen wird jedoch deutlich, dass dies vermutlich der einfachste und dementsprechend auch beste Ansatz ist.

Für eine wasserdichte Lösung muss das Flag tatsächlich rekursive abgeschaltet werden. Diese Funktion bewerkstelligt das:

use Data::Walk qw*(walk);
use Scalar::Util qw(reftype);

sub clear_utf8_flag {
    my ($data) = @_;

    my $wanted = sub {
        if (ref $_) {
            my $obj = $_;
            if ('HASH' eq reftype $obj) {
                foreach my $key (keys %$obj) {
                    if (Encode::is_utf8($key)) {
                        my $value = delete $obj->{$key};
                        Encode::_utf8_off($key);
                        $obj->{$key} = $value;
                    }

                    my $value = $obj->{$key};
                    if (defined $value && !ref $value
                        && Encode::is_utf8($value)) {
                        Encode::_utf8_off($obj->{$key});
                    }
                }
            } elsif ('ARRAY' eq reftype $obj) {
                foreach my $item (@$obj) {
                    if (defined $item && !ref $item
                        && Encode::is_utf8($item)) {
                        Encode::_utf8_off($item);
                    }
                }
            }
        }
    };

    walk $wanted, $data;

    return $data;
}

Unglücklicherweise, kann diese Routine für tief verschachtelte und komplizierte Datenstrukturen schnell zum Flaschenhals werden.

Strategie 2: Mit dem UTF8-Flag leben

Man kann auch versuchen, Perls Unicode-Features wie vorgesehen zu verwenden, was allerdings etliche Umdrehungen erfordert.

Templates lesen: Der Parameter ENCODING

Die Variablen, die von YAML::XS kommen, haben allesamt das Flag gesetzt. Sobald diese Strings mit Strings, die das Flag nicht gesetzt haben, zusammengefügt werden, werden sie verhunzt. Wir müssen daher sicherstellen, dass alle Daten das UTF8-Flag gesetzt haben.

Wir beginnen mit dem Template-String und ändern das Ende von render.pl:

[% FILTER \(Highlight "language-perl" %] my \)template = <Café de la gare

Menu pour [% month %] [% year %]

[% INCLUDE menu.tt %] EOF use Encode; Encode::_utf8_on($template);

Template->new({ENCODING => 'UTF-8'})->process($template, $vars); [% END %]

Was hat sich geändert? Das UTF8-Flag für den Template-String wird angeschaltet, und zusätzlich wird die Option ENCODING => 'UTF-8' an Template Toolkit übergeben. Das erzeugt die folgende Ausgabe:

$ perl render.pl
<h1>Caf? de la gare</h1>
<p>Menu pour D?cembre 2018</p>
<ul>
  <li>Caf?: 3,50 €</li>
  <li>Th?: 2,40 €</li>
  <li>Bi?re: 2,80 €</li>
</ul>

Schlimmer als vorher. Die Ausgabe ist völlig zerschossen, obwohl --- das sollte man nicht vergessen --- alle Daten korrekt UTF-8-kodiert sind. Tatsächlich werden sie von Perl in dem Augenblick unbrauchbar gemacht, in dem sie auf STDOUT geschrieben werden. Das lässt sich beheben, indem die folgende Zeile eingefügt wrid, bevor Template Toolkit aufgerufen wird:

binmode STDOUT, ':utf8';
Template->new({ENCODING => 'UTF-8'})->process(\$template, $vars);

Dieser Aufruf von binmode() bewirkt, dass Perl die UTF-8-kodierten Texte nicht mehr modifiziert.

Das Ergebnis sieht so aus::

$ perl render.pl
<h1>Café de la gare</h1>
<p>Menu pour Décembre 2018</p>
<ul>
  <li>Café: 3,50 €</li>
  <li>Thé: 2,40 €</li>
  <li>Bière: 2,80 €</li>
</ul>

Arrgh, jetzt hat es die Euro-Zeichen erwischt. Grund ist, dass $vars->{currency} das Flag nicht gesetzt hat. Diesmal probieren wir eine andere Variante, wie sich das Flag anschalten lässt. Dafür wird die Zeile, in der die Variable initialisiert wird, geändert:

$vars->{currency} = Encode::decode('UTF-8', '€');

Das ist äquivalent zu:

$vars->{currency} = '€';
Encode::_utf8_on($vars->{currency});

Wir starten render.pl ein weiteres Mal, und, hurra, die Ausgabe sollte jetzt korrekt sein.

Debugging von Unicode-Problemen mit Template Toolkit

Wie oben erwähnt, gibt es zwei Strategien, um Template Toolkit mit UTF-8-kodierten Daten oder Templates zu verwenden:

  1. Nichts tun! Sollte die Ausgabe zerschossen sein, bedeutet das, dass bei irgendeinem String das UTF8-Flag angeschaltet ist. Lösung: Den schuldigen String finden und das Flag abschalten. Das Problem verschwindet dann.

  2. Template->new wird mit der Option ENCODING => 'UTF-8' aufgerufen. Weiterhin muss das UTF8-Flag für sämtliche Template-Variablen und Templates angeschaltet werden. Schließlich muss bei der Ausgabe binmode STDOUT, ':utf8' aufgerufen werden (STDOUT muss natürliche mit dem Ausgabedatei-Handle ersetzt werden).

Beim Debuggen dieser Art Probleme sollte immer die erste Stelle, an der das Problem auftritt, betrachtet werden. Sodann muss herausgefunden werden, woher die jeweiligen Daten kommen, und das Problem dort behoben werden. Je nach Strategie muss dazu das Flag ab- oder angeschaltet werden.

Wer wirklich die zweite Strategie verfolgen muss oder will, sollte ebenfalls sicherstellen, dass für das Ausgabedatei-Handle binmode HANDLE, ':utf8' aufgerufen wurde.

Der Vollständigkeit halber sollte noch erwähnt werden, dass Template Toolkit Eingabetemplates auf das Vorhandensein eines BOMs (Byte Order Mark) prüft. Wird ein BOM gefunden, wird das UTF8-Flag unabhängig von der Option ENCODING gesetzt. Weil dieses Verhalten nicht dokumentiert ist, und Byte Order Marks ohnehin obsolet sind, sollte man lieber das BOM aus allen Templates entfernen, und erst dann weiter debuggen.


blog comments powered by Disqus