Technický článek

Export tabulek z Delphi bezpečný pro Unicode: RTF a HTML

Tabulka obsahuje sloupec se jmény zákazníků. Některá jsou v čínštině, jiná v azbuce, několik jich nese německé přehlásky nebo francouzský akcent. Vyexportujete to do CSV, otevřete výsledek a každý znak je neporušený. Vyexportujete stejný sešit do RTF pro šablonu hromadné korespondence, otevřete ho v textovém editoru a ne-ASCII jména se zhroutila do řádků otazníků. Data se nikdy nezměnila. Co se změnilo, je dohoda o kódování u formátu, který jste zapsali, a každá exportní cesta v sobě nese jinou

To je past, která uloví knihovnu tvářící se na první pohled jako plně uvědomělou s ohledem na Unicode. Text buňky je interně uchováván jako WideString, takže z modelu se nikdy neztratí ani znak. Ztráta nastává na hranici, u zapisovače (writer), který musí tento text serializovat do formátu s jeho vlastními pravidly o tom, jaké bajty jsou legální a jak musí být kódováno cokoli mimo tento legální rozsah. Trefte jeden zapisovač správně a stále můžete vydat jiný, který ten samý text zdeformuje. Oprava zde nespočívá v jednom globálním přepínači. Je to na sobě nezávislé a správné rozhodnutí u každé jedné cesty

RTF je od základu navržen jako 7bitově bezpečný formát

Formát Rich Text Format (RTF) tu byl ještě před Unicodem a byl specifikován tak, aby přežil přenosy, které propouštějí pouze tisknutelné ASCII znaky. Dokument RTF ve své hlavičce deklaruje kódovou stránku (code page) a jakýkoliv znak, který zapisovač v této kódové stránce nemůže reprezentovat, musí být vygenerován jako escape sekvence (únikový kód) namísto surového (raw) bajtu. Tím relevantním únikem je \u, který s sebou nese znaménkovou (signed) 16bitovou kódovou jednotku (code unit), jež je následovaná záložním ASCII znakem (fallback character) pro čtečky, které jsou natolik staré, že tomuto unikajícímu kódu vůbec nerozumí

HotXLS zapisuje RTF právě 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 převádí jakýkoli znak mimo základní (plain) ASCII na sekvenci \u, aby datový tok bajtů (byte stream) zůstal 7bitově čistý bez ohledu na to, co dokáže pojmout deklarovaná kódová stránka. Kódový bod, jako je U+4E2D, se doslovně stane sekvencí \u20013?, nikoli nezpracovaným surovým bajtem, který by se pak prohlížeč snažil interpretovat prostřednictvím jakékoliv kódové stránky, jíž právě předpokládá. Bez této disciplíny nemá cokoliv mimo ohraničení deklarované kódové stránky žádnou legální bajtovou reprezentaci a zapisovač odesílající rovnou nezpracovanou hodnotu (raw value) vytvoří ony otazníky, jimiž začínal tento článek

Ten jeden důležitý detail, který je třeba mít na paměti, je, že kódová stránka a sekvence escape kódů jsou pouhé dvě poloviny jedné ujednané smlouvy. Deklarování jen kódové stránky samotné neřeší ten text, který leží mimo ni. Emitování sekvencí úniků bez nadefinování kódové stránky naopak ponechává záložní (fallback) znaky v nejednoznačném postavení. Obojí je nezbytně nutné ošetřit společně a zcela korektně. Z tohoto důvodu zápis (writer), jenž dokáže zpracovat a vypořádat se jen s jedním z tohoto dua, nevyhnutelně i přesto zkrachuje u prvního pracovního sešitu z vícejazyčného formátu

Escapování v HTML je o více než jen o lomených závorkách

HTML export generuje dokument o více listech, kde jeho navigační rámce nesou coby viditelný text zapsané názvy jednotlivých listů (sheet names). Tyto názvy představují proměnné ovládané přímo tvůrcem, u nichž lze propasírovat cokoliv, a to včetně znaků se zvláštním vyhrazeným značkovacím (markup) významem. Název listu doslovně pojmenovaný jako Q1 & Q2 <draft> se po propuštění skrze čtečku musí zpracovat s escape entitami, jinak otevírací lomená závorka otevře neexistující tag a znak ampersandu spustí referenční entitu, což autor nikdy neměl v úmyslu. Díváte se přesně v této instanci na obyčejné escapování HTML, ale zapomenout na něj u názvu rámce je přesně tím druhem opomenutí, který projde každým testem obsahujícím pouze ASCII názvy listů

Otázka kódování se nachází o vrstvu níže. Když se ne-ASCII znaky ocitnou v kontextu, kde není zaručeno, že budou doručovány jako UTF-8, bezpečnou reprezentací je číselná znaková reference. Například U+00E9 se zapíše jako &#233; namísto surového bajtu, jehož význam by závisel na cílové znakové sadě (charset) v odpovědi. Zrcadlový obraz tohoto pravidla platí i pro cestu dovnitř. Sešit načtený z XLSX obsahuje sdílené řetězce (shared strings), v nichž znak už může být uložen jako číselná XML entita, a tato entita musí být dekódována na jeden celistvý znak předtím, než vstoupí do modelu buňky. Dekódujte ji neopatrně tím, že rozdělíte kódový bod (code point) na samostatné bajty, a z jednoho znaku se vyklubou dva kousky rozsypaného čaje (mojibake), které už žádný pozdější export nedokáže opravit

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

Soubor XLSX je archív ZIP a tento archív ukládá jméno pro každého člena (member), kterého obsahuje. ZIP je dostatečně starý formát na to, že jeho původní specifikace neříkala nic o kódování těchto jmen, takže čtečka, která nenajde žádný signál, předpokládá místní kódovou stránku archívu. Tento předpoklad je chybný v okamžiku, kdy jméno člena obsahuje ne-ASCII znak, což se stává s lokalizovanými názvy částí listů a s vloženými médii, jejichž názvy souborů obsahují akcenty nebo nelatinské písmo

Opravou je zde jediný bit. Obecný bit 11 v hlavičce každého lokálního souboru deklaruje, že jméno člena je kódováno v UTF-8. HotXLS kontroluje přesně tento bit, když čte archív – testuje vlajky pro obecné použití proti masce $0800 – a čtečka nebo zapisovač, který ho ignoruje, nesprávně přečte jméno, jež správná implementace uložila jako UTF-8. Tento bit je levné nastavit i levné respektovat a představuje celý ten rozdíl mezi jménem člena, které přežije cestu tam i zpět, a jménem, které dorazí poškozené, ještě než se obsah tabulky vůbec začne parsovat

Skládání velikosti písmen a skenování čísel skrývají stejné riziko

Při vyhodnocování vzorců už nejde o serializaci, nýbrž o porovnávání. Funkce SEARCH nerozlišuje velká a malá písmena, což znamená, že před hledáním podřetězce musí sjednotit velikost písmen (fold case). Špatný způsob, jak to udělat, je přes kódovou stránku ANSI, protože převádění ne-ASCII textu na velká písmena tímto způsobem prožene znaky přes úzkou kódovou stránku a poškodí cokoliv, co do ní nepatří. Správný způsob je použití funkce uppercasingu (převodu na velká písmena) pro široké řetězce (wide-string), která zachovává plný rozsah UTF-16. HotXLS používá k tomuto účelu WideUpperCase právě z tohoto důvodu, takže hledání textu s diakritikou nebo v nelatince odpovídá úplně stejným znakům, jež mu byly poskytnuty, a ne nějaké zkomolené aproximaci v rámci kódové stránky

Tokenizer vzorců nese podobnou zodpovědnost, která sice nemá nic společného s písmeny, zato ale plně souvisí s tím, kde končí token. Vědecká notace jako 1E3 nebo 2.5E-3 je jeden jediný numerický literál a skener musí rozpoznat písmeno E, volitelné znaménko a následující číslice jako součást čísla, místo aby rozdělil vstup na název následovaný odděleným číslem. Skener, který toto špatně zpracuje, promění naprosto platnou konstantu na chybu při parsování nebo, v horším případě, na tiše (silently) zkomolený výraz. Patří to do stejné debaty, protože oba tyto případy jsou o tom, jak má čtečka učinit správné rozhodnutí na úrovni jednotlivých znaků: jedno o tom, jak složit velikost znaku pro porovnání, a to druhé, zdali daný znak nadále prodlužuje aktuální token

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

Veřejné API (public API) po vás nepožaduje, abyste na cokoliv z tohoto museli myslet. Vybudujete si pracovní sešit (workbook) hodnotami z WideString proměnných na úrovni buněk a rovnou zavoláte požadovaný exportní vstupní bod. Rozhodnutí ohledně kódování se dějí až uvnitř každého zapisovače. Níže uvedený příklad zaplní list texty v několika různých typech písma a následně z téhož sešitu zapíše jak soubor RTF, tak soubor HTML. Obě tyto cesty tak proběhnou oproti totožné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';

    // Text v buňkách je uchováván jako WideString, takže každé písmo přežije model.
    Sheet.Cells[2, 1].Value := '王伟';          // Čínština
    Sheet.Cells[2, 2].Value := '北京';
    Sheet.Cells[3, 1].Value := 'Müller';        // Německá přehláska
    Sheet.Cells[3, 2].Value := 'Köln';
    Sheet.Cells[4, 1].Value := 'Иванов';        // Azbuka
    Sheet.Cells[4, 2].Value := 'Москва';
    Sheet.Cells[5, 1].Value := 'Désirée';       // Francouzské akcenty
    Sheet.Cells[5, 2].Value := 'Montréal';

    // RTF: Zapisovač lxRTF deklaruje kódovou stránku a každý ne-ASCII znak
    // zapíše jako \u sekvenci, čímž udrží soubor čistý pro 7bitový přenos.
    Book.SaveAsRTF('Customers.rtf');

    // HTML: Názvy listů jsou HTML-escapovány a ne-ASCII text je zapsán tak,
    // aby nezávisel na nějaké uhodnuté znakové sadě odpovědi.
    Book.SaveAsHTML('Customers.html');
  finally
    Book := nil;
  end;
end;

Obě volání vrací status typu Integer a obě konzumují úplně totožný text z paměti. Vůbec nic z volajícího kódu nedeklaruje nějakou kódovou stránku a ani neescapuje žádné znaky, jelikož tato odpovědnost leží na příslušném zapisovači, který naprosto perfektně zná svůj domovský formát. Export formátu na úrovni sešitu v SaveAsCSV respektuje z totožného zdroje zcela stejný vzorec volání u formátů vyžadujících oddělovač (delimited export)

// Stejný sešit, třetí exportní cesta s vlastními pravidly pro kódování.
Book.SaveAsCSV('Customers.csv');

Bezpečnost Unicode je per-cesta, nikoli per-knihovna

Lekce, kterou stojí za to si odnést, spočívá v tom, že neexistuje žádné jedno ústřední místo, které zaručí bezpečnost Unicode. Formát RTF potřebuje deklarovanou kódovou stránku v kombinaci s \u escape sekvencemi. Formát HTML vyžaduje escapování entit pro znaky důležité ve značkování (markup) a numerické reference, jakmile není zaručena znaková sada, plus správné dekódování entit, jež přicházejí ze sdílených řetězců (shared strings). Kontejner ZIP pro svoji spolehlivost potřebuje zaškrnmutí (nastavení) s obecným bitem na pozici 11, aby byl název v UTF-8 skutečně takto přečten jako UTF-8. A vyhodnocování vzorců spoléhá na to, že velikost písmen u širokých řetězců funguje korektně, s fungujícím tokenizerem, jenž zvládne ponechat vědeckou notaci v jednom kuse. Každá tato situace je jiná dohoda, takže knihovna je v pozici schopné perfektně jedné obhájit, zatímco tou dobou by naprosto potichu poškodila jinou. To je prapůvodní důvod a základ věci ohledně nástroje, jež si se ctí hravě poradí u CSV exportu a i pak tak stejně navrátí RTF zasypané řadou otazníků

Pokud se vaše exporty kloní k oddělovaným (delimited) formátům, kompromisy mezi nimi jsou rozebrány v našem přehledu exportu do CSV, TSV a HTML, a když zdrojem není ručně stavěný list, nýbrž výsledek (result set), vzory probírané v článku o exportu z databáze pro sestavy v Delphi přirozeně navazují na zde popsaná pravidla kódování. Všechno toto se dodává jako součást komponenty HotXLS Component pro Delphi a C++Builder, a to společně s API pro čtení, vzorce a formátování, která probíráme i v ostatních článcích na tomto blogu