weidner/computer/software/perl/

Skip the BOM

Ich verkaufe meine Bücher unter anderem über Lulu. Von dort bekomme ich Übersichten zu den verkauften Büchern in UTF-8-kodierten CSV-Dateien. Diese wollte ich nehmen und die Daten in eine Datenbank importieren, mit der ich dann beliebige Auswertungen machen kann.

Perl schien mir dafür geeignet zu sein, da es hier die Module Text::CSV und Text::CSV_XS genau für diesen Zweck gibt.

Ein Skript zum Einlesen der CSV-Dateien war schnell geschrieben, brach dann aber mit folgenden Fehlermeldungen ab:

# CSV_XS ERROR: 2034 - EIF - Loose unescaped quote @ rec 0 pos 4
Not a HASH reference at ./read_lulu.pl line 35.

Also startete ich den Perl-Debugger und verfolgte das Skript Zeile um Zeile, um die Ursache zu finden.

Die erste Meldung führte mich zu folgender Programmzeile:

my $aoh = csv({ in => $fh, encoding => "utf8", headers => "auto" });

Anscheinend gab es ein Problem in der Funktion csv() von Text::CSV_XS.

Danach lief das Skript weiter bis zu dieser Stelle und brach dann ab:

for my $h (@$aoh) {
    $canonical->{reference} = $h->{Referenznummer};
    ...
}

Also habe ich das Skript noch einmal gestartet und geschaut, was an dieser Stelle mit $h los ist:

  DB<1> x $h
  0  ARRAY(0x2609f50)
     0  'DIST_763962'
     1  '31.12.2012'
     ...

Die Variable $h verweist offensichtlich auf ein Array und nicht auf ein Hash. Laut Dokumentation soll csv() ein Array of Hashes zurückgeben, wenn es mit der Option headers => "auto" aufgerufen wird.

Zeit, sich die CSV-Datei näher anzusehen. Der Befehl file zeigt:

sales-and-revenue.csv: UTF-8 Unicode (with BOM) text

Mit less sieht sie so aus:

<U+FEFF>"Referenznummer","Ereignisdatum","Produktnr.","Inhalts-ID",...
"DIST_763962","31.12.2012","20291920","12932757",...
...

Mit dem <U+FEFF> am Anfang der Datei kommt Text::CSV offensichtlich nicht zurecht.

File nennt es BOM, eine kurze Suche im Internet ergibt als Auflösung: Byte Order Mark. In Text soll das als geschütztes Leerzeichen der Länge 0 interpretiert werden.

Hintergrund

Ein BOM ist notwendig bei UTF-16 und UTF-32, da hier die Byte Order (Low Endian, Big Endian) bestimmt werden muss, um die Unicode-Zeichen korrekt zu identifizieren.

Bei UTF-8 ist das BOM nicht notwendig, weil UTF-8 mit einzelnen Bytes als Grundeinheit arbeitet. Das BOM ist hier aber nützlich für einige Programme, wie zum Beispiel MS Excel, wenn diese UTF-8-kodierte Dateien verarbeiten sollen. Andererseits ist das BOM schädlich für Programme, die damit nicht umgehen können. Zu diesen zählt Text::CSV.

Die Empfehlung lautet, BOM am Dateianfang zu entfernen und innerhalb der Datei als geschütztes Leerzeichen der Länge 0 zu interpretieren.

Für die CSV-Dateien von Lulu habe ich zwei Optionen, ich kann:

  1. einmalig das BOM aus den Dateien entfernen und diese so archivieren, oder

  2. das BOM entfernen, bevor ich die Dateien an Text::CSV übergebe.

Ich habe mich für die zweite Variante entschieden, weil das BOM gerade bei CSV-Dateien nützlich ist, wenn diese mit MS Excel eingelesen werden sollen. Es treibt mich zwar nichts, das zu tun, aber ich möchte trotzdem nicht zwei unterschiedliche Dateien ablegen, nur um mir diese Möglichkeit offen zu halten.

Umsetzung

Ich lasse also das BOM in der Datei und überspringe es, bevor ich die Datei an Text::CSV::csv() weiterreiche. Dafür kann ich die Perl-Funktion seek nutzen.

Mit dieser Funktion kann ich die aktuelle Position eines Dateihandles in Bezug auf den Dateianfang, die momentane Position oder das Dateiende setzen. Da ich nur das BOM am Dateianfang überspringen will, verwende ich die absolute Position. Somit sieht mein Code zum Einlesen der Datei wie folgt aus:

sub read_csv {
    my $fname = shift;

    open(my $fh, "<:encoding(utf8)", $fname)
        or die "open $fname: $!";
    # skip the BOM
    my $firstline = <$fh>;
    if ($firstline =~ /^\x{FEFF}/) {
        seek $fh, 3, 0;
    }
    else {
        seek $fh, 0, 0;
    }
    my $aoh = csv({in => $fh, encoding => "utf8", headers => "auto"});
    close $fh;
    return $aoh;
}

Ich öffne die CSV-Datei mit UTF-8-Encoding und lese die erste Zeile ein. Enthält diese am Anfang das BOM, springe ich mit seek zur absoluten Position 3 in der Datei, andernfalls wieder zu Position 0 (ganz an den Anfang).

Danach übergebe ich das Dateihandle an Text::CSV::csv(). Diese Funktion liest die gesamte Datei ab der eingestellten Position ein und gibt mir ein Array of Hashes zurück, das ich anschließend weiter verarbeiten kann.

Wer sich dafür interessiert kann hier das Skript finden, dass die Datei von Lulu einliest und den Inhalt als JSON-Array ausgibt.

Nachtrag 2017-09-23

Außer dem Perl-Modul Text::CSV gibt es noch einige andere Programme, die Probleme mit Byte Order Marks haben.

In einem Fall untersuchte ein Kollege Probleme mit einer PAC-Datei. Diese funktionierte, wenn sie auf den Rechner kopiert und im Webbrowser ausgewählt wurde, aber nicht, wenn sie von einem Webserver geladen wurde. Beim direkten Vergleich in einem Texteditor sahen beide Versionen identisch aus. Die Version vom Webserver war allerdings 3 Bytes länger.

Ich erinnerte mich an meine Probleme mit den CSV-Dateien von Lulu und riet ihm die Dateien in einem Hex-Editor zu vergleichen. Damit fand er das BOM und konnte diesen und damit das Problem eliminieren.

Posted 2016-04-03
Tags: