Technical Article

Bezpečný export tabulek s kódováním Unicode v Delphi: RTF a HTML

Tabulka obsahuje sloupec se jmény zákazníků. Některá jsou v čínštině, jiná v cyrilici, několik jich nese německé přehlásky nebo francouzský akcent. Exportujete ji do CSV a otevřete výsledek; každý znak je neporušený. Exportujete stejný sešit do RTF pro šablonu hromadné korespondence, otevřete jej v textovém editoru a jména mimo rozsah ASCII se zhroutila do řad otazníků. Data se nikdy nezměnila. To, co se změnilo, je dohoda o kódování daného formátu, který jste zapsali, a každá exportní cesta nese jinou dohodu.

To je past, do které se chytí knihovna, která na povrchu vypadá jako plně podporující Unicode. Text buňky je interně uložen jako WideString, so model nikdy žádný znak neztratí. Ke ztrátě dochází na hranici, v zapisovači, který musí tento text serializovat do formátu s jeho vlastními pravidly o tom, které bajty jsou legální a jak musí být kódováno cokoli mimo povolený rozsah. Vylaďte jeden zapisovač a stále můžete distribuovat jiný, který stejný text zkomolí. Nápravou není globální přepínač. Je to samostatné, správné rozhodnutí na každé z cest.

RTF je z návrhu formát bezpečný pro 7 bitů

Rich Text Format (RTF) předchází Unicode a byl specifikován tak, aby přežil přenosy, které propouštějí pouze tisknutelné ASCII. Dokument RTF deklaruje ve své hlavičce kódovou stránku a jakýkoli znak, který zapisovač nemůže v této kódové stránce reprezentovat, musí být emitován jako escape sekvence, nikoli jako nezpracovaný bajt. Příslušnou escape sekvencí je \u, která nese znaménkovou 16bitovou kódovou jednotku následovanou záložním znakem ASCII pro čtečky příliš staré na to, aby této escape sekvenci vůbec rozuměly.

HotXLS zapisuje RTF tímto způsobem. Hlavička dokumentu se otevírá deklarací kódové stránky ve tvaru \ansi\ansicpg1252\uc1 a zapisovač v jednotce lxRTF prochází každý řetězec a emituje jakýkoli znak nad hodnotu ASCII jako escape sekvenci \u, takže proud bajtů zůstává 7bitově čistý bez ohledu na to, co deklarovaná kódová stránka dokáže pojmout. Kódový bod, jako je U+4E2D, se stává odpovídající escape sekvencí, nikoli nezpracovaným bajtem, který by se pak prohlížeč snažil interpretovat prostřednictvím jakékoli kódové stránky, kterou by náhodou předpokládal. Bez této disciplíny nemá cokoli mimo deklarovanou kódovou stránku žádnou platnou bajtovou reprezentaci a zapisovač, který emituje surovou hodnotu, produkuje otazníky popsané v úvodu tohoto článku.

Detail, který je třeba mít na paměti, je, že deklarovaná kódová stránka a escape sekvence jsou dvě poloviny jedné dohody. Samotná deklarace kódové stránky nepomůže textu, který leží mimo ni. Emitování escape sekvencí bez deklarované kódové stránky ponechává záložní znaky nejednoznačné. Obojí musí být správné společně, což je důvod, proč zapisovač, který zpracovává pouze jednu z těchto věcí, stále selže u prvního vícejazyčného sešitu.

HTML escapování je o více věcech než o úhlových závorkách

Export do HTML vytváří vícelistový dokument, jehož navigační rámce nesou názvy listů jako viditelný text. Tyto názvy jsou řetězce řízené autorem, které mohou obsahovat jakýkoli znak, včetně těch s významem pro značkování. List doslova pojmenovaný Q1 & Q2 <draft> se musí na stránku dostat jako escapované entity, or úhlové závorky otevřou fiktivní značku a ampersand zahájí odkaz na entitu, který nebyl nikdy zamýšlen. Jedná se o běžné HTML escapování a jeho vynechání na štítku rámce je typem opomenutí, které projde každým testem sestaveným z názvů listů obsahujících pouze ASCII.

Otázka kódování leží ještě o úroveň níže. Když znaky mimo ASCII dostanou do kontextu, u kterého není zaručeno, že bude poskytován jako UTF-8, bezpečnou reprezentací je číselná znaková entita, takže U+00E9 se zapíše jako odpovídající entita, nikoli jako surový bajt, jehož význam závisí na znakové sadě odpovědi. Zrcadlový obraz tohoto pravidla platí při načítání. Sešit načtený zpět z XLSX nese sdílené řetězce, ve kterých již znak může být uložen jako číselná entita XML, a tato entita musí být dekódována do jednoho celého znaku před vstupem do modelu buňky. Dekódujte ji neopatrně, rozdělením kódového bodu na samostatné bajty, a jediný znak se znovu objeví jako dva kusy rozsypaného čaje (mojibake), které žádný pozdější export nedokáže opravit.

Kontejner XLSX je ZIP a ZIP má své vlastní kódování názvů

Soubor XLSX je archiv ZIP a archiv ukládá název každého členu, kterého obsahuje. ZIP je dostatečně starý na to, aby jeho původní specifikace o kódování těchto názvů nic neříkala, takže čtečka, která nenajde žádný signál, předpokládá lokální kódovou stránku archivu. Tento předpoklad je chybný v okamžiku, kdy název členu obsahuje znak mimo ASCII, což se děje u lokalizovaných názvů částí listu a u vložených médií, jejichž názvy souborů nesou akcenty nebo nelatinkové písmo.

Nápravou je jediný bit. Obecný bit 11 (general-purpose bit 11) v každé lokální hlavičce souboru deklaruje, že název členu je kódován jako UTF-8. HotXLS při čtení archivu kontroluje přesně tento bit, přičemž testuje obecné příznaky proti masce $0800, a čtečka nebo zapisovač, které jej ignorují, nesprávně přečtou název, který správná implementace uložila jako UTF-8. Nastavení i respektování tohoto bitu je snadné a představuje celý rozdíl mezi názvem členu, který přežije obousměrnou cestu, a názvem, který dorazí poškozený ještě předtím, než je obsah tabulky vůbec analyzován.

Převod velikosti písmen a skenování čísel skrývají stejné nebezpečí

Vyhodnocování vzorců je místem, kde bezpečnost Unicode přestává být záležitostí serializace a stává se záležitostí porovnávání. Funkce SEARCH nerozlišuje velikost písmen, což znamená, že před hledáním podřetězce musí převést velikost písmen (fold case). Nesprávným způsobem převodu je použití kódové stránky ANSI, protože převod textu mimo ASCII na velká písmena tímto způsobem směruje znaky přes úzkou kódovou stránku a poškodí cokoli mimo ni. Správným způsobem je převod na velká písmena u širokých řetězců (wide-string), který zachovává celý rozsah UTF-16. HotXLS provádí převod pomocí WideUpperCase přesně z tohoto důvodu, takže hledání textu s akcenty nebo nelatinkového textu odpovídá stejným znakům, jaké byly zadány, nikoli jejich kódovou stránkou zkomolené aproximaci.

Tokenizer vzorců nese související povinnost, která nemá nic společného s písmeny a souvisí s tím, kde token končí. Vědecký zápis, jako je 1E3 nebo 2.5E-3, je jediným číselným literálem a skener musí rozpoznat E, volitelné znaménko a následující číslice jako součást čísla, namísto rozdělení vstupu na název následovaný samostatným číslem. Skener, který s tímto zachází nesprávně, změní zcela platnou konstantu v chybu analýzy nebo, což je horší, v tiše chybný výraz. Patří to do stejné diskuse, protože oba případy jsou o tom, že čtečka dělá správné rozhodnutí na úrovni znaků: jedno o tom, jak převést znak pro porovnání, a druhé o tom, zda znak pokračuje v aktuálním tokenu.

Sestavení a export vícejazyčného sešitu

Veřejné rozhraní API po vás nevyžaduje, abyste o něčem z toho přemýšleli. Sešit sestavíte z hodnot buněk typu WideString a zavoláte požadovaný vstupní bod exportu. Rozhodnutí o kódování probíhají uvnitř každého zapisovače. Níže uvedený příklad naplní list textem v několika písmech a poté zapíše jak soubor RTF, tak soubor HTML ze stejného sešitu, takže obě cesty běžely proti identickému vstupu.

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;

Obě volání vracejí stav Integer a obě spotřebovávají stejný text v paměti. Nic ve volajícím kódu nedeklaruje kódovou stránku ani neescapuje znak, protože tato odpovědnost leží na zapisovači, který zná svůj vlastní formát. Metoda SaveAsCSV na úrovni sešitu sleduje stejný tvar, pokud potřebujete export s oddělovači ze stejného zdroje.

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

Bezpečnost Unicode je záležitostí cesty, nikoli knihovny

Ponaučení, které stojí za to si odnést, je, že neexistuje jediné místo, kde by se dala vyřešit bezpečnost Unicode. RTF potřebuje deklarovanou kódovou stránku plus escape sekvence \u. HTML vyžaduje escapování entit pro znaky s významem pro značkování a číselné odkazy tam, kde není zaručena znaková sada, a navíc správné dekódování entit, které přicházejí ve sdílených řetězcích. Kontejner ZIP potřebuje nastavit obecný bit 11, aby se název členu v UTF-8 četl jako UTF-8. Vyhodnocování vzorců vyžaduje převod velikosti písmen u širokých řetězců a tokenizer, který udržuje vědecký zápis vcelku. Každá z těchto věcí je jinou dohodou a knihovna může vyhovět jedné, zatímco potichu porušuje druhou. To je důvod, proč vám nástroj, který zvládne správně CSV, může přesto předat RTF plný otazníků.

Pokud se vaše exporty opírají o formáty s oddělovači, kompromisy mezi nimi popisuje náš průvodce exportem do CSV, TSV a HTML, a když je zdrojem sada výsledků namísto ručně vytvořeného listu, vzorce v databázovém exportu pro Delphi reporty se přirozeně párují s pravidly kódování popsanými zde. Vše se dodává jako součást produktu HotXLS Component pro Delphi a C++Builder spolu s rozhraními API pro čtení, vzorce a formátování popsanými na jiných místech tohoto blogu.