Technischer Artikel

Unicode-sicherer Tabellenexport in Delphi: RTF und HTML

Eine Tabellenkalkulation enthält eine Spalte mit Kundennamen. Einige sind auf Chinesisch, einige kyrillisch, einige wenige haben deutsche Umlaute oder einen französischen Akzent. Sie exportieren dies in eine CSV-Datei und öffnen das Ergebnis, und jedes Zeichen ist intakt. Sie exportieren dieselbe Arbeitsmappe in eine RTF-Datei für eine Serienbriefvorlage, öffnen sie in einem Textverarbeitungsprogramm, und die Nicht-ASCII-Namen sind zu Zeilen von Fragezeichen kollabiert. Die Daten haben sich nie geändert. Was sich geändert hat, ist der Kodierungsvertrag des von Ihnen geschriebenen Formats, und jeder Exportpfad bringt einen anderen mit sich

Das ist die Falle, in die eine Bibliothek tappt, die an der Oberfläche vollständig Unicode-fähig aussieht. Der Zelltext wird intern als WideString gehalten, sodass das Modell nie ein Zeichen verliert. Der Verlust geschieht an der Grenze, in dem Writer, der diesen Text in ein Format serialisieren muss, das seine eigenen Regeln darüber hat, welche Bytes zulässig sind und wie alles außerhalb des zulässigen Bereichs kodiert werden muss. Wenn man einen Writer richtig konfiguriert, kann man immer noch einen anderen ausliefern, der denselben Text verstümmelt. Die Lösung ist kein globaler Schalter. Es ist eine separate, korrekte Entscheidung für jeden Pfad

RTF ist von Haus aus ein 7-Bit-sicheres Format

Das Rich Text Format existierte bereits vor Unicode und wurde spezifiziert, um Transporte zu überstehen, die nur druckbares ASCII durchlassen. Ein RTF-Dokument deklariert in seinem Header eine Codepage, und jedes Zeichen, das der Writer nicht in dieser Codepage darstellen kann, muss als Escape-Sequenz statt als rohes Byte ausgegeben werden. Das relevante Escape ist \u, welches eine vorzeichenbehaftete 16-Bit-Codeeinheit trägt, gefolgt von einem ASCII-Ersatzzeichen für Leseprogramme, die zu alt sind, um die Escape-Sequenz überhaupt zu verstehen

HotXLS schreibt RTF auf diese Weise. Der Dokument-Header beginnt mit der Deklaration der Codepage in der Form \ansi\ansicpg1252\uc1, und der Writer in der lxRTF-Unit durchläuft jeden String und gibt jedes Zeichen oberhalb von einfachem ASCII als \u-Escape aus, sodass der Bytestrom unabhängig davon, was die deklarierte Codepage aufnehmen kann, 7-Bit-sauber bleibt. Ein Codepoint wie U+4E2D wird zur literalen Sequenz \u20013? und nicht zu einem rohen Byte, das ein Viewer dann durch eine beliebige Codepage zu interpretieren versuchen würde, die er gerade angenommen hat. Ohne diese Disziplin hat alles außerhalb der deklarierten Codepage keine zulässige Byte-Darstellung, und ein Writer, der den rohen Wert ausgibt, erzeugt die Fragezeichen, die diesen Artikel eingeleitet haben

Das Detail, das man im Hinterkopf behalten muss, ist, dass die deklarierte Codepage und die Escapes zwei Hälften eines Vertrages sind. Die Codepage allein zu deklarieren, hilft keinem Text, der außerhalb dieser liegt. Escapes ohne eine deklarierte Codepage auszugeben, lässt die Ersatzzeichen mehrdeutig. Beides muss zusammen stimmen, weshalb ein Writer, der nur eines davon handhabt, bereits bei der ersten mehrsprachigen Arbeitsmappe scheitert

Beim HTML-Escaping geht es um mehr als spitze Klammern

Der HTML-Export erzeugt ein mehrblättriges Dokument, dessen Navigationsframes die Blattnamen als sichtbaren Text tragen. Diese Namen sind vom Autor kontrollierte Strings, die jedes beliebige Zeichen enthalten können, einschließlich solcher mit Markup-Bedeutung. Ein Blatt, das wörtlich Q1 & Q2 <draft> heißt, muss als maskierte Entitäten auf der Seite ankommen, andernfalls öffnen die spitzen Klammern ein Phantom-Tag und das kaufmännische Und startet eine Entitätsreferenz, die nie beabsichtigt war. Dies ist gewöhnliches HTML-Escaping, und es auf einem Frame-Label zu überspringen, ist die Art von Versäumnis, die jeden Test besteht, der nur aus ASCII-Blattnamen aufgebaut ist

Die Frage der Kodierung liegt eine Schicht darunter. Wenn Nicht-ASCII-Zeichen in einem Kontext landen, bei dem nicht garantiert ist, dass er als UTF-8 ausgeliefert wird, ist die sichere Darstellung eine numerische Zeichenreferenz, sodass U+00E9 als é geschrieben wird und nicht als rohes Byte, dessen Bedeutung vom Antwort-Zeichensatz abhängt. Das Spiegelbild dieser Regel gilt für den Weg hinein. Eine aus XLSX zurückgelesene Arbeitsmappe trägt gemeinsam genutzte Strings, in denen ein Zeichen möglicherweise bereits als numerische XML-Entität gespeichert ist, und diese Entität muss zu einem ganzen Zeichen dekodiert werden, bevor sie in das Zellmodell gelangt. Wenn man sie unvorsichtig dekodiert und einen Codepoint in separate Bytes aufteilt, taucht ein einzelnes Zeichen als zwei Teile von Zeichensalat (Mojibake) wieder auf, die kein späterer Export reparieren kann

Der XLSX-Container ist ein ZIP, und ZIP hat seine eigene Namenskodierung

Eine XLSX-Datei ist ein ZIP-Archiv, und das Archiv speichert einen Namen für jedes Element, das es enthält. ZIP ist alt genug, dass seine ursprüngliche Spezifikation nichts über die Kodierung dieser Namen aussagte, sodass ein Leseprogramm, das kein Signal findet, die lokale Codepage des Archivs annimmt. Diese Annahme ist in dem Moment falsch, in dem ein Elementname ein Nicht-ASCII-Zeichen enthält, was bei lokalisierten Tabellenblatt-Teilenamen und bei eingebetteten Medien vorkommt, deren Dateinamen Akzente oder nicht-lateinische Schriften tragen

Die Lösung ist ein einziges Bit. Das Allzweck-Bit 11 in jedem lokalen Datei-Header deklariert, dass der Elementname als UTF-8 kodiert ist. HotXLS prüft genau dieses Bit, wenn es ein Archiv liest, und testet die Allzweck-Flags gegen die Maske $0800. Ein Leseprogramm oder Writer, der dies ignoriert, wird einen Namen falsch lesen, den eine korrekte Implementierung als UTF-8 gespeichert hat. Das Bit ist billig zu setzen und billig zu beachten, und es macht den ganzen Unterschied aus zwischen einem Elementnamen, der den Weg hin und zurück übersteht, und einem, der beschädigt ankommt, bevor der Tabelleninhalt überhaupt geparst wird

Case-Folding und Zahlen-Scanning bergen dieselbe Gefahr

Bei der Formelauswertung geht es bei der Unicode-Sicherheit nicht mehr um Serialisierung, sondern um den Vergleich. Die SEARCH-Funktion (SUCHEN) unterscheidet nicht zwischen Groß- und Kleinschreibung, was bedeutet, dass sie die Groß-/Kleinschreibung umwandeln muss, bevor sie nach einem Teilstring sucht. Der falsche Weg für diese Umwandlung führt über die ANSI-Codepage, denn die Umwandlung von Nicht-ASCII-Text in Großbuchstaben auf diese Weise leitet die Zeichen durch eine enge Codepage und beschädigt alles außerhalb davon. Der richtige Weg ist das Wide-String-Uppercasing, das den gesamten UTF-16-Bereich erhält. HotXLS wandelt die Schreibung aus genau diesem Grund mit WideUpperCase um, sodass eine Suche nach akzentuiertem oder nicht-lateinischem Text genau den Zeichen entspricht, die eingegeben wurden, anstatt einer durch eine Codepage verstümmelten Annäherung daran

Der Formel-Tokenizer trägt eine damit verbundene Verpflichtung, die nichts mit Buchstaben zu tun hat, sondern ganz und gar damit, wo ein Token endet. Eine wissenschaftliche Notation wie 1E3 oder 2.5E-3 ist ein einzelnes numerisches Literal, und der Scanner muss das E, ein optionales Vorzeichen und die folgenden Ziffern als Teil der Zahl erkennen, anstatt die Eingabe in einen Namen, gefolgt von einer separaten Zahl, zu zerlegen. Ein Scanner, der dies falsch handhabt, verwandelt eine vollkommen gültige Konstante in einen Parse-Fehler oder, noch schlimmer, in einen stillschweigend falschen Ausdruck. Es gehört in dieselbe Diskussion, da es in beiden Fällen darum geht, dass ein Leseprogramm eine korrekte Entscheidung auf Zeichenebene trifft: die eine darüber, wie ein Zeichen für den Vergleich umgewandelt wird, die andere darüber, ob ein Zeichen das aktuelle Token fortsetzt

Erstellen und Exportieren einer mehrsprachigen Arbeitsmappe

Die öffentliche API verlangt nicht, dass Sie über all dies nachdenken. Sie bauen die Arbeitsmappe aus WideString-Zellwerten auf und rufen den gewünschten Export-Einstiegspunkt auf. Die Kodierungsentscheidungen finden innerhalb der jeweiligen Writer statt. Das folgende Beispiel füllt ein Blatt mit Text in verschiedenen Schriften und schreibt dann sowohl eine RTF-Datei als auch eine HTML-Datei aus derselben Arbeitsmappe, sodass die beiden Pfade gegen eine identische Eingabe laufen

uses
  lxHandle;

procedure ExportMultilingualWorkbook;
var
  Book: IXLSWorkbook;
  Sheet: IXLSWorksheet;
begin
  Book := TXLSWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Customers');

    Sheet.Cells[1, 1].Value := 'Name';
    Sheet.Cells[1, 2].Value := 'City';

    // Cell text is held as WideString, so every script survives the model.
    Sheet.Cells[2, 1].Value := '王伟';          // Chinese
    Sheet.Cells[2, 2].Value := '北京';
    Sheet.Cells[3, 1].Value := 'Müller';        // German umlaut
    Sheet.Cells[3, 2].Value := 'Köln';
    Sheet.Cells[4, 1].Value := 'Иванов';        // Cyrillic
    Sheet.Cells[4, 2].Value := 'Москва';
    Sheet.Cells[5, 1].Value := 'Désirée';       // French accents
    Sheet.Cells[5, 2].Value := 'Montréal';

    // RTF: the lxRTF writer declares the code page and emits every
    // non-ASCII character as a \u escape, keeping the file 7-bit clean.
    Book.SaveAsRTF('Customers.rtf');

    // HTML: sheet names are HTML-escaped and non-ASCII text is written
    // so it does not depend on a guessed response charset.
    Book.SaveAsHTML('Customers.html');
  finally
    Book := nil;
  end;
end;

Beide Aufrufe geben einen Integer-Status zurück, und beide verbrauchen denselben In-Memory-Text. Nichts im aufrufenden Code deklariert eine Codepage oder maskiert ein Zeichen, denn die Verantwortung liegt bei dem Writer, der sein eigenes Format kennt. Die Funktion SaveAsCSV auf Arbeitsmappenebene hat dieselbe Form, wenn Sie einen durch Trennzeichen getrennten Export aus der identischen Quelle benötigen

// Same workbook, a third export path with its own encoding rules.
Book.SaveAsCSV('Customers.csv');

Unicode-Sicherheit gilt pro Pfad, nicht pro Bibliothek

Die Lektion, die es mitzunehmen lohnt, ist, dass es keinen einzigen Ort gibt, an dem man Unicode-sicher sein kann. RTF benötigt eine deklarierte Codepage plus \u-Escapes. HTML erfordert Entitäts-Escaping für Zeichen mit Markup-Bedeutung und numerische Referenzen dort, wo der Zeichensatz nicht garantiert ist, plus korrekte Dekodierung von Entitäten, die in gemeinsam genutzten Strings ankommen. Der ZIP-Container benötigt das gesetzte Allzweck-Bit 11, damit ein UTF-8-Elementname als UTF-8 gelesen wird. Die Formelauswertung benötigt Wide-String-Case-Folding und einen Tokenizer, der die wissenschaftliche Notation in einem Stück belässt. Jeder dieser Punkte ist ein anderer Vertrag, und eine Bibliothek kann einen erfüllen, während sie stillschweigend einen anderen verletzt. Das ist der Grund, warum ein Tool, das CSV richtig macht, Ihnen immer noch ein RTF voller Fragezeichen liefern kann

Wenn sich Ihre Exporte auf durch Trennzeichen getrennte Formate stützen, werden die Kompromisse zwischen ihnen in unserem Leitfaden zum CSV-, TSV- und HTML-Export behandelt. Und wenn die Quelle eine Ergebnismenge statt eines manuell erstellten Blattes ist, passen die Muster für den Datenbankexport für Delphi-Berichte auf natürliche Weise zu den hier beschriebenen Kodierungsregeln. All dies wird als Teil der HotXLS-Komponente für Delphi und C++Builder ausgeliefert, zusammen mit den Lese-, Formel- und Formatierungs-APIs, die an anderer Stelle in diesem Blog behandelt werden