Der Auslöser war unspektakulär. Nach dem Aufbau der Website meldete eine Prüfung, dass eine Datei kein gültiges UTF-8 sei: skills/urheberrecht.html. Im Browser sah die Seite völlig normal aus.
Die Spur
An der fraglichen Stelle stand in der Datenbank korrekt:
„Im Internet gefunden“ ist keine Rechtsgrundlage
In der erzeugten Datei stand dort ein einzelnes Byte 0x9E, gefolgt von Im Internet. Aus den drei Bytes E2 80 9E des öffnenden Anführungszeichens waren zwei verschwunden.
Der Wert kam korrekt aus der Datenbank – eine Kontrolle über PDO zeigte 0a e2 80 9e 49 6d. Erst nach dem Durchlauf durch eine Hilfsfunktion war er beschädigt. Die Funktion zerlegt mehrzeilige Felder in Listen und entfernt dabei führende Aufzählungszeichen:
fn($l) => trim(ltrim($l, "-*• \t")),
Warum das falsch ist
ltrim() nimmt keine Liste von Zeichen entgegen, sondern eine Liste von Bytes. Der Aufzählungspunkt • ist U+2022 und besteht in UTF-8 aus E2 80 A2. Damit standen in der Abschneideliste die drei Bytes E2, 80 und A2 einzeln.
Das öffnende deutsche Anführungszeichen „ ist U+201E und besteht aus E2 80 9E. Die ersten beiden Bytes stehen in der Liste – sie wurden abgeschnitten. Zurück blieb 9E, für sich kein gültiges UTF-8.
Betroffen war jede Zeile, die mit einem typografischen Zeichen beginnt: Anführungszeichen, Gedankenstrich, Auslassungspunkte, Pfeile, Guillemets – sie alle beginnen in UTF-8 mit E2.
Warum es niemandem auffiel
Browser sind nachsichtig. Ein ungültiges Byte wird still durch ein Ersatzzeichen dargestellt oder übersprungen; an der fraglichen Stelle stand einfach kein Anführungszeichen. Wer nicht danach sucht, sieht nichts.
Streng sind dagegen Maschinen: ein JSON-LD-Parser, eine Feed-Auswertung, ein Sprachmodell, das die Seite verarbeitet. Genau die Zielgruppe also, für die dieser Aufwand mit strukturierten Daten überhaupt betrieben wird.
Die Korrektur
fn($l) => trim(preg_replace('/^[\s\-\*\x{2022}\x{00B7}]+/u', '', $l)),
Der Modifikator /u macht den Ausdruck zeichenorientiert. Verhalten und Ergebnis bleiben sonst gleich.
Was daraus zu lernen ist
Eine Zeichenliste für trim(), ltrim() oder rtrim(), die Zeichen außerhalb von ASCII enthält, ist immer ein Fehler – auch wenn der Aufruf jahrelang unauffällig lief. Ein Suchlauf über den eigenen Code nach solchen Aufrufen dauert eine Minute.
Und für den Bestand: Ob erzeugte Seiten sauber sind, lässt sich ohne Datenbankzugriff prüfen.
find sites/ -name "*.html" -exec sh -c \
'iconv -f UTF-8 -t UTF-8 "$1" >/dev/null 2>&1 || echo "UNGÜLTIG: $1"' _ {} \;