Article technique

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, et chaque caractère est intact. Vous exportez le même classeur (workbook) vers RTF pour un modèle de publipostage (mail-merge template), l'ouvrez dans un traitement de texte, et les noms non-ASCII se sont réduits à des rangées de points d'interrogation. Les données n'ont jamais changé. Ce qui a changé, c'est le contrat d'encodage (encoding contract) du format que vous avez écrit, et chaque chemin d'exportation en porte un différent

C'est le piège qui attrape une bibliothèque qui semble entièrement compatible Unicode (Unicode-aware) en surface. Le texte de la cellule est conservé en interne sous forme de WideString, de sorte que le modèle ne perd jamais un caractère. La perte se produit à la frontière (boundary), dans l'enregistreur (writer) qui doit sérialiser (serialise) ce texte dans un format avec ses propres règles sur les octets légaux et la manière dont tout ce qui se trouve en dehors de la plage légale doit être encodé. Obtenez un bon enregistreur et vous pouvez toujours en expédier (ship) un autre qui mutile (mangles) le même texte. Le correctif n'est pas un commutateur global (global switch). C'est une décision distincte et correcte sur chaque chemin

RTF est un format 7 bits sûr de par sa conception (by design)

Le format Rich Text (RTF) est antérieur à Unicode et a été spécifié pour survivre aux transports qui ne transmettent que de l'ASCII imprimable. Un document RTF déclare une page de codes (code page) dans son en-tête, et tout caractère que l'enregistreur (writer) ne peut pas représenter dans cette page de codes doit être émis sous forme d'échappement (escape) plutôt que d'octet brut (raw byte). L'échappement pertinent est \u, qui porte une unité de code (code unit) signée de 16 bits suivie d'un caractère de secours (fallback character) ASCII pour les lecteurs trop anciens pour comprendre l'échappement

HotXLS écrit le RTF de cette façon. L'en-tête du document s'ouvre en déclarant la page de codes, sous la forme \ansi\ansicpg1252\uc1, et l'enregistreur de l'unité lxRTF parcourt (walks) chaque chaîne en émettant tout caractère supérieur à l'ASCII simple comme un échappement \u de sorte que le flux d'octets reste propre sur 7 bits, indépendamment de ce que la page de codes déclarée peut contenir. Un point de code (code point) tel que U+4E2D devient la séquence littérale \u20013?, et non un octet brut qu'une visionneuse essaierait ensuite d'interpréter via la page de codes qu'elle a supposée. Sans cette discipline, tout ce qui se trouve en dehors de la page de codes déclarée n'a pas de représentation d'octet légale, et un enregistreur qui émet la valeur brute produit les points d'interrogation qui ont commencé cet article

Le détail à garder à l'esprit est que la page de codes déclarée et les échappements (escapes) sont deux moitiés d'un même contrat. Déclarer la page de codes seule n'aide pas le texte qui se trouve en dehors d'elle. L'émission d'échappements sans page de codes déclarée rend les caractères de secours (fallback characters) ambigus. Les deux doivent être corrects ensemble, c'est pourquoi un enregistreur qui ne gère qu'un seul d'entre eux échoue toujours sur le premier classeur multilingue

L'échappement HTML concerne plus que les chevrons (angle brackets)

L'exportation HTML produit un document multi-feuilles dont les cadres (frames) de navigation portent les noms des feuilles sous forme de texte visible. Ces noms sont des chaînes contrôlées par l'auteur qui peuvent contenir n'importe quel caractère, y compris ceux qui sont importants pour le balisage (markup-significant). Une feuille littéralement nommée Q1 & Q2 <draft> doit atteindre la page en tant qu'entités échappées (escaped entities), ou les chevrons ouvrent une balise fantôme et l'esperluette commence une référence d'entité qui n'a jamais été prévue. Il s'agit d'un échappement HTML ordinaire, et le sauter sur une étiquette de cadre (frame label) est le genre d'omission qui réussit tous les tests construits à partir de noms de feuilles uniquement ASCII

La question de l'encodage se situe une couche en dessous de cela. Lorsque des caractères non-ASCII atterrissent (land) dans un contexte qui n'est pas garanti d'être servi en tant que UTF-8, la représentation sûre est une référence de caractère numérique, de sorte que U+00E9 s'écrit é plutôt que comme un 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'entrée. Un classeur relu (read back) à partir de XLSX porte des chaînes partagées (shared strings) dans lesquelles un caractère peut déjà être stocké en tant qu'entité XML numérique, et cette entité doit être décodée en un caractère entier avant d'entrer dans le modèle de cellule. Décodez-le négligemment (carelessly), en divisant un point de code (code point) en octets séparés, et un seul caractère réapparaît (re-emerges) sous forme de deux morceaux de mojibake qu'aucune exportation ultérieure ne peut réparer

Le conteneur XLSX est un ZIP, et ZIP a son propre encodage de nom

Un fichier XLSX est une archive ZIP, et l'archive stocke un nom pour chaque membre qu'elle contient. ZIP est assez ancien pour que sa spécification d'origine ne dise rien sur l'encodage de ces noms, donc un lecteur qui ne trouve aucun signal suppose la page de codes (code page) locale de l'archive. Cette hypothèse est fausse au moment où un nom de membre contient un caractère non-ASCII, ce qui se produit avec les noms de parties de feuille de calcul localisées (localized worksheet part names) et avec les médias intégrés (embedded media) dont les noms de fichiers portent des accents ou un script non latin

Le correctif (fix) est un seul bit. Le bit 11 à usage général (General-purpose bit 11) dans chaque en-tête de fichier local déclare que le nom du membre est encodé en UTF-8. HotXLS vérifie exactement ce bit lorsqu'il lit une archive, testant les drapeaux (flags) à usage général par rapport au masque $0800, et un lecteur ou un enregistreur qui l'ignore lira mal (misread) un nom qu'une implémentation correcte a stocké sous forme de UTF-8. Le bit est peu coûteux à définir (cheap to set) et peu coûteux à honorer (cheap to honour), et c'est toute la différence entre un nom de membre qui survit à l'aller-retour (round trip) et un qui arrive corrompu avant même que le contenu de la feuille de calcul ne soit analysé (parsed)

Le pliage de casse (Case folding) et l'analyse des nombres (number scanning) cachent le même danger

L'évaluation de la formule est l'endroit où la sécurité Unicode (Unicode safety) cesse d'être une question de sérialisation et devient une question de comparaison. La fonction SEARCH est insensible à la casse (case-insensitive), ce qui signifie qu'elle doit plier la casse (fold case) avant de rechercher une sous-chaîne (substring). La mauvaise façon de plier est via la page de codes ANSI, car la mise en majuscule (uppercasing) du texte non-ASCII de cette façon achemine les caractères via une page de codes étroite (narrow code page) et corrompt tout ce qui s'y trouve en dehors. La bonne méthode est la mise en majuscule sur chaîne large (wide-string uppercasing), qui préserve toute la plage UTF-16. HotXLS se plie (folds) avec WideUpperCase pour cette raison exacte, de sorte qu'une recherche de texte accentué ou non latin correspond aux mêmes caractères qui lui ont été donnés plutôt qu'à une approximation de ceux-ci mutilée par la page de codes (code-page-mangled)

Le tokeniseur de formule (formula tokenizer) porte une obligation connexe qui n'a rien à voir avec les lettres et tout à voir avec l'endroit où se termine un jeton (token). La notation scientifique telle que 1E3 ou 2.5E-3 est un littéral numérique unique, et le scanner doit reconnaître le E, un signe facultatif, et les chiffres suivants comme faisant partie du nombre plutôt que de diviser l'entrée en un nom suivi d'un nombre distinct. Un scanner qui gère mal (mishandles) cela transforme une constante parfaitement valide en une erreur d'analyse ou, pire, une expression silencieusement erronée. Cela appartient à la même discussion parce que les deux cas concernent un lecteur prenant une décision correcte au niveau du caractère : l'un sur la façon de plier (fold) un caractère pour comparaison, l'autre sur la question de savoir si un caractère continue le jeton actuel

Création et exportation d'un classeur multilingue (multilingual workbook)

L'API publique ne vous demande pas de penser à tout cela. Vous construisez le classeur à partir des valeurs de cellule WideString et appelez le point d'entrée (entry point) d'exportation que vous souhaitez. Les décisions d'encodage se produisent à l'intérieur de chaque enregistreur (writer). L'exemple ci-dessous alimente (seeds) une feuille avec du texte dans plusieurs scripts, puis écrit à la fois 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 état Integer, et les deux consomment le même texte en mémoire. Rien dans le code appelant ne déclare une page de codes ni n'échappe à un caractère, car la responsabilité incombe à l'enregistreur qui connaît son propre format. Le SaveAsCSV au niveau du classeur suit la même forme si vous avez besoin d'une exportation délimitée à partir d'une source identique

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

La sécurité Unicode (Unicode safety) est par chemin (per-path), pas par bibliothèque

La leçon à retenir est qu'il n'y a pas d'endroit unique pour être compatible Unicode (Unicode-safe). RTF nécessite une page de codes déclarée plus des échappements \u. HTML nécessite l'échappement d'entité pour les caractères significatifs de balisage et les références numériques lorsque le jeu de caractères (charset) n'est pas garanti, ainsi que le décodage correct des entités qui arrivent dans des chaînes partagées (shared strings). Le conteneur ZIP a besoin du bit 11 à usage général (general-purpose bit 11) défini pour qu'un nom de membre UTF-8 soit lu comme UTF-8. L'évaluation de la formule nécessite un pliage de casse de chaîne large (wide-string case folding) et un tokeniseur qui conserve la notation scientifique en un seul morceau. Chacun de ces éléments est un contrat différent, et une bibliothèque peut en satisfaire un tout en violant discrètement un autre. C'est la raison pour laquelle un outil qui gère correctement le CSV peut toujours vous remettre un RTF plein de points d'interrogation

Si vos exportations s'appuient sur les formats délimités, les compromis entre eux sont couverts dans notre présentation de l'exportation CSV, TSV et HTML, et lorsque la source est un ensemble de résultats (result set) plutôt qu'une feuille construite à la main, les modèles (patterns) de l'exportation de base de données pour les rapports Delphi se marient naturellement avec les règles d'encodage décrites ici. Tout cela est fourni dans le cadre du Composant HotXLS pour Delphi et C++Builder, aux côtés des API de lecture, de formule et de formatage couvertes ailleurs sur ce blog