Technical Article

Exportation de feuilles de calcul compatible Unicode dans Delphi : RTF et HTML

Une feuille de calcul contient une colonne de noms de clients. Certains sont en chinois, d'autres en cyrillique, quelques-uns portent des trémas allemands ou un accent français. Vous l'exportez au format CSV et ouvrez le résultat : chaque caractère est intact. Vous exportez le même classeur au format RTF pour un modèle de fusion de documents, vous l'ouvrez dans un traitement de texte, et les noms non ASCII se sont transformés en suites de points d'interrogation. Les données n'ont jamais changé. Ce qui a changé, c'est le contrat d'encodage du format que vous avez écrit, et chaque chemin d'exportation en comporte un différent.

C'est le piège dans lequel tombe une bibliothèque qui semble pourtant totalement compatible Unicode en surface. Le texte de la cellule est conservé en interne sous la forme WideString, de sorte que le modèle ne perd jamais aucun caractère. La perte se produit à la limite, dans le module d'écriture (writer) qui doit sérialiser ce texte dans un format ayant ses propres règles sur les octets autorisés et sur la manière dont tout élément en dehors de la plage légale doit être encodé. Même si vous concevez un module d'écriture correct, vous pouvez toujours en livrer un autre qui corrompt le même texte. La solution n'est pas un commutateur global. C'est une décision distincte et correcte sur chaque chemin.

Le format RTF est conçu pour être sécurisé sur 7 bits

Le format RTF (Rich Text Format) est antérieur à Unicode et a été conçu pour survivre à des transferts qui ne transmettent que de l'ASCII imprimable. Un document RTF déclare une page de code dans son en-tête, et tout caractère que le module d'écriture ne peut pas représenter dans cette page de code doit être émis sous forme d'échappement plutôt que d'octet brut. L'échappement concerné est \u, qui porte une unité de code signée de 16 bits suivie d'un caractère de repli ASCII pour les lecteurs trop anciens pour comprendre l'échappement.

HotXLS écrit le RTF de cette manière. L'en-tête du document commence par déclarer la page de code, sous la forme \ansi\ansicpg1252\uc1, et le module d'écriture de l'unité lxRTF parcourt chaque chaîne en émettant tout caractère supérieur à l'ASCII brut sous forme d'échappement \u afin que le flux d'octets reste propre en 7 bits, indépendamment de ce que la page de code déclarée peut contenir. Un point de code tel que U+4E2D devient la séquence littérale  3?, et non un octet brut qu'un visualiseur tenterait ensuite d'interpréter à travers la page de code qu'il suppose. Sans cette discipline, tout élément situé en dehors de la page de code déclarée ne dispose d'aucune représentation d'octet valide, et un module d'écriture qui émet la valeur brute produit les points d'interrogation mentionnés au début de cet article.

Le détail à garder à l'esprit est que la page de code déclarée et les échappements sont les deux moitiés d'un même contrat. Déclarer la page de code seule n'aide pas le texte qui se situe en dehors de celle-ci. Émettre des échappements sans page de code déclarée laisse les caractères de repli ambigus. Les deux doivent être corrects ensemble, c'est pourquoi un module d'écriture qui ne gère qu'un seul des deux échoue dès le premier classeur multilingue.

L'échappement HTML va bien au-delà des chevrons

L'exportation HTML produit un document multi-feuilles dont les cadres de navigation portent les noms des feuilles sous forme de texte visible. Ces noms sont des chaînes définies par l'auteur qui peuvent contenir n'importe quel caractère, y compris ceux ayant une signification pour le balisage. Une feuille littéralement nommée Q1 & Q2 <draft> doit arriver sur la page sous forme d'entités échappées, sinon les chevrons ouvrent une balise fantôme et l'esperluette commence une référence d'entité qui n'a jamais été voulue. Il s'agit d'un échappement HTML ordinaire, et l'omettre sur une étiquette de cadre est le genre d'oubli qui passe inaperçu lors de tous les tests construits avec des noms de feuilles uniquement en ASCII.

La question de l'encodage se situe un niveau en dessous. Lorsque des caractères non ASCII se retrouvent dans un contexte qui n'est pas garanti d'être servi en UTF-8, la représentation sûre est une référence de caractère numérique, ainsi U+00E9 s'écrit sous la forme é plutôt que sous forme d'octet brut dont la signification dépend du jeu de caractères (charset) de réponse. L'image miroir de cette règle s'applique à l'importation. Un classeur lu depuis un XLSX contient des chaînes partagées dans lesquelles un caractère peut déjà être stocké sous forme d'entité XML numérique, et cette entité doit être décodée en un caractère complet avant d'entrer dans le modèle de cellule. Decode it carelessly, splitting a code point into separate bytes, and a single character re-emerges as two pieces of mojibake that no later export can repair.

Le conteneur XLSX est un ZIP, et le format ZIP a son propre encodage de noms

Un fichier XLSX est une archive ZIP, et l'archive stocke un nom pour chaque membre qu'elle contient. Le format ZIP est assez ancien pour que sa spécification d'origine ne mentionne rien sur l'encodage de ces noms, ainsi un lecteur qui ne trouve aucun signal suppose la page de code locale de l'archive. Cette hypothèse est fausse dès qu'un nom de membre contient un caractère non ASCII, ce qui se produit avec les noms de parties de feuilles de calcul localisés et avec les fichiers multimédias intégrés dont les noms portent des accents ou des écritures non latines.

La solution est un bit unique. General-purpose bit 11 in each local file header declares that the member name is encoded as UTF-8. HotXLS vérifie précisément ce bit lorsqu'il lit une archive, en testant les drapeaux d'usage général par rapport au masque $0800, et un lecteur ou un module d'écriture qui l'ignore lira mal un nom qu'une implémentation correcte a stocké en UTF-8. Ce bit est peu coûteux à définir et à respecter, et c'est toute la différence entre un nom de membre qui survit au voyage aller-retour et un autre qui arrive corrompu avant même que le contenu de la feuille de calcul ne soit analysé.

La conversion de casse et l'analyse de nombres cachent le même danger

C'est lors de l'évaluation des formules que la sécurité Unicode cesse de concerner la sérialisation pour concerner la comparaison. La fonction SEARCH est insensible à la casse, ce qui signifie qu'elle doit convertir la casse avant de rechercher une sous-chaîne. La mauvaise façon de convertir est de passer par la page de code ANSI, car mettre en majuscules du texte non ASCII de cette manière fait passer les caractères par une page de code étroite et corrompt tout élément situé en dehors de celle-ci. La bonne façon est la mise en majuscules en chaînes larges (wide-string), qui préserve l'ensemble de la plage UTF-16. HotXLS convertit la casse avec WideUpperCase précisément pour cette raison, de sorte qu'une recherche de texte accentué ou non latin corresponde aux caractères fournis plutôt qu'à une approximation altérée par la page de code.

Le décompilateur de formules (tokenizer) porte une obligation connexe qui n'a rien à voir avec les lettres et tout à voir avec l'endroit où se termine un élément (token). Une notation scientifique telle que 1E3 ou 2.5E-3 est un littéral numérique unique, et l'analyseur doit reconnaître le E, un signe facultatif, et les chiffres suivants comme faisant partie du nombre au lieu de diviser l'entrée en un nom suivi d'un nombre distinct. Un analyseur qui gère mal cet aspect transforme une constante parfaitement valide en erreur d'analyse ou, pire, en expression silencieusement erronée. Cela s'inscrit dans la même discussion car les deux cas concernent un lecteur prenant une décision correcte au niveau du caractère : l'une sur la façon de convertir un caractère pour comparaison, l'autre sur le fait qu'un caractère prolonge ou non l'élément actuel.

Création et exportation d'un classeur multilingue

L'API publique ne vous demande pas de réfléchir à tout cela. Vous construisez le classeur à partir de valeurs de cellules de type WideString et appelez le point d'entrée d'exportation souhaité. Les décisions d'encodage se prennent à l'intérieur de chaque module d'écriture. L'exemple ci-dessous initialise une feuille avec du texte dans plusieurs écritures, puis écrit un fichier RTF et un fichier HTML à partir du même classeur, de sorte que les deux chemins s'exécutent sur une entrée identique.

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;

Les deux appels renvoient un statut Integer, et tous deux consomment le même texte en mémoire. Rien dans le code appelant ne déclare de page de code ni n'échappe de caractère, car cette responsabilité incombe au module d'écriture qui connaît son propre format. La méthode SaveAsCSV au niveau du classeur suit la même logique si vous avez besoin d'un export délimité à partir de la même source.

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

La sécurité Unicode s'applique par chemin, pas par bibliothèque

La leçon à retenir est qu'il n'existe pas d'endroit unique pour garantir la compatibilité Unicode. Le RTF nécessite une page de code déclarée plus des échappements \u. L'HTML nécessite un échappement d'entités pour les caractères significatifs de balisage et des références numériques là où le jeu de caractères (charset) n'est pas garanti, en plus d'un décodage correct des entités arrivant dans les chaînes partagées. Le conteneur ZIP nécessite le bit 11 d'usage général pour qu'un nom de membre en UTF-8 soit lu comme de l'UTF-8. L'évaluation de formules nécessite une conversion de casse en chaînes larges (wide-string) et un analyseur qui conserve la notation scientifique en un seul morceau. Chacun de ces aspects est un contrat différent, et une bibliothèque peut en satisfaire un tout en enfreignant discrètement un autre. C'est la raison pour laquelle un outil qui gère correctement le CSV peut encore vous renvoyer un RTF rempli de points d'interrogation.

Si vos exportations s'appuient sur les formats délimités, les compromis entre eux sont traités dans our walkthrough of CSV, TSV and HTML export, et quand la source est un jeu de résultats plutôt qu'une feuille construite à la main, les modèles d'exportation de base de données pour les rapports Delphi s'associent naturellement aux règles d'encodage présentées ici. Tout cela fait partie du composant HotXLS pour Delphi et C++Builder, avec les API de lecture, de formule et de formatage présentées par ailleurs sur ce blog.