Technical Article

Izvoz preglednic varni pred Unicode v Delphi: RTF in HTML

Preglednica vsebuje stolpec z imeni strank. Nekatera so v kitajščini, nekatera v cirilici, nekaj jih vsebuje nemške preglase ali francoski naglas. Izvozite jo v CSV in odprete rezultat – vsak znak je nedotaknjen. Isti delovni zvezek izvozite v RTF za predlogo spajanja dokumentov (mail-merge), jo odprete v urejevalniku besedil in ne-ASCII imena so se sesula v vrstice vprašajev. Podatki se niso nikoli spremenili. Spremenila se je pogodba o kodiranju formata, ki ste ga zapisali, in vsaka izvozna pot prinaša drugačno.

To je past, v katero se ujame knjižnica, ki je na površju videti popolnoma zavedna o Unicode. Besedilo celic se interno hrani kot WideString, zato model nikoli ne izgubi znaka. Izguba se zgodi na meji, v pisatelju, ki mora to besedilo serilizirati v format z lastnimi pravili o tem, kateri bajti so dovoljeni in kako mora biti kodirano vse, kar je zunaj dovoljenega obsega. Tudi če pravilno nastavite enega pisatelja, lahko še vedno pošljete drugega, ki bo isto besedilo pohabil. Rešitev ni globalno stikalo. Gre za ločeno, pravilno odločitev na vsaki poti.

RTF je po zasnovi 7-bitno varen format

Format Rich Text Format (RTF) je starejši od Unicode in je bil določen tako, da preživi prenose, ki prenašajo le tiskane znake ASCII. Dokument RTF v svoji glavi deklarira kodno stran, vsak znak, ki ga pisatelj v tej kodni strani ne more predstaviti, pa mora biti oddan kot ubežno zaporedje (escape) namesto kot surov bajt. Ustrezno ubežno zaporedje je \u, ki nosi predznačeno 16-bitno kodno enoto, ki ji sledi nadomestni znak ASCII za bralnike, ki so prestari, da bi sploh razumeli to ubežno zaporedje.

HotXLS piše RTF na ta način. Glava dokumenta se začne z deklaracijo kodne strani v obliki \ansi\ansicpg1252\uc1, pisatelj v enoti lxRTF pa se sprehodi skozi vsak niz in odda vsak znak nad navadnim ASCII kot ubežno zaporedje \u, tako da tok bajtov ostane 7-bitno čist, ne glede na to, kaj deklarirana kodna stran lahko vsebuje. Kodna točka, kot je U+4E2D, postane dobesedno zaporedje  3? in ne surov bajt, ki bi ga pregledovalnik nato poskušal interpretirati prek katere koli kodne strani, ki bi jo predvideval. Brez te discipline vse, kar je zunaj deklarirane kodne strani, nima zakonite predstavitve bajtov, pisatelj, ki odda surovo vrednost, pa ustvari vprašaje, s katerimi se je ta članek začel.

Podrobnost, ki jo je treba imeti v mislih, je, da sta deklarirana kodna stran in ubežna zaporedja dve polovici iste pogodbe. Samo deklaracija kodne strani ne pomaga besedilu, ki leži zunaj nje. Oddajanje ubežnih zaporedij brez deklarirane kodne strani pa pušča nadomestne znake dvoumne. Oboje mora biti pravilno skupaj, zato pisatelj, ki obravnava le enega od teh delov, še vedno odpove pri prvem večjezičnem delovnem zvezku.

HTML escaping is about more than angle brackets

Izvoz HTML ustvari dokument z več listi, katerega navigacijski okvirji nosijo imena listov kot vidno besedilo. Ta imena so nizi pod nadzorom avtorja, ki lahko vsebujejo kateri koli znak, vključno s tistimi, ki so pomembni za označevanje. List, ki se dobesedno imenuje Q1 & Q2 <draft>, mora doseči stran kot ubežne entitete, sicer trikotni oklepaji odprejo fantomsko oznako, znak & pa začne referenco entitete, ki nikoli ni bila načrtovana. To je običajno ubežno zaporedje HTML (HTML escaping), izpustitev tega na oznaki okvirja pa je tiste vrste napaka, ki uspešno opravi vsak test, zgrajen izključno iz ASCII imen listov.

Vprašanje kodiranja se nahaja eno raven pod tem. Ko ne-ASCII znaki pristanejo v kontekstu, za katerega ni zagotovljeno, da bo postrežen kot UTF-8, je varna predstavitev numerična referenca znakov, zato se U+00E9 zapiše kot é in ne kot surov bajt, katerega pomen je odvisen od nabora znakov odgovora. Zrcalna slika tega pravila velja pri branju. Delovni zvezek, prebran nazaj iz XLSX, vsebuje skupne nize (shared strings), v katerih je znak morda že shranjen kot numerična entiteta XML, to entiteto pa je treba pred vstopom v model celic dekodirati v en celoten znak. 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.

Vsebnik XLSX je ZIP in ZIP ima lastno kodiranje imen

Datoteka XLSX je arhiv ZIP in arhiv shrani ime za vsakega člana, ki ga vsebuje. ZIP je dovolj star, da njegova prvotna specifikacija ni govorila ničesar o kodiranju teh imen, zato bralnik, ki ne najde nobenega signala, predvideva lokalno kodno stran arhiva. Ta predpostavka je napačna v trenutku, ko ime člana vsebuje ne-ASCII znak, kar se zgodi z lokaliziranimi imeni delov delovnega lista in z vdelanimi mediji, katerih imena datotek vsebujejo naglase ali nelatinične pisave.

Rešitev je en sam bit. Splošnonamenski bit 11 v vsaki lokalni glavi datoteke izjavlja, da je ime člana kodirano kot UTF-8. HotXLS preveri natanko ta bit, ko bere arhiv, s testiranjem splošnonamenskih zastavic proti maski $0800, bralnik ali pisatelj, ki to prezre, pa bo napačno prebral ime, ki ga je pravilna implementacija shranila kot UTF-8. Bit je poceni nastaviti in poceni spoštovati, to pa predstavlja celotno razliko med imenom člana, ki preživi dvosmerno pot, in tistim, ki prispe poškodovano, še preden se vsebina preglednice sploh razčleni.

Zlaganje velikosti črk in skeniranje števil skrita isto nevarnost

Vrednotenje formul je točka, kjer se varnost Unicode preneha nanašati na serilizacijo in se začne nanašati na primerjavo. Funkcija SEARCH ne razlikuje med velikimi in malimi črkami, kar pomeni, da mora zložiti velikost črk (case folding), preden išče podniz. Napačen način zlaganja je prek kodne strani ANSI, saj pretvorba ne-ASCII besedila v velike črke na ta način usmeri znake skozi ozko kodno stran in poškoduje vse zunaj nje. Pravilen način je pretvorba v velike črke širokih nizov (wide-string), kar ohrani celotno območje UTF-16. HotXLS iz tega razloga zlaga z WideUpperCase, tako da iskanje naglašenega ali nelatiničnega besedila ujema iste znake, kot so bili podani, in ne le njihovega približka, popačenega s kodno stranjo.

Žetonizator formul (formula tokenizer) nosi s tem povezano obveznost, ki nima nobene zveze s črkami in ima vse opraviti s tem, kje se žeton konča. Znanstveni zapis, kot sta 1E3 or 2.5E-3, je en sam številski literal in skener mora prepoznati znak E, neobvezen predznak in naslednje števke kot del števila, namesto da bi vhod razdelil na ime, ki mu sledi ločeno število. Skener, ki to napačno obravnava, spremeni povsem veljavno konstanto v napako razčlenjevanja ali, še huje, v tiho napačen izraz. Sodi v isto razpravo, saj gre v obeh primerih za pravilno odločitev bralnika na ravni znakov: eno o tem, kako zložiti znak za primerjavo, drugo pa o tem, ali znak nadaljuje trenutni žeton.

Izdelava in izvoz večjezičnega delovnega zvezka

Javni API od vas ne zahteva razmišljanja o ničemer od tega. Delovni zvezek zgradite iz vrednosti celic tipa WideString in pokličete želeno vstopno točko za izvoz. Odločitve o kodiranju se zgodijo znotraj vsakega pisatelja. Spodnji primer zaseje list z besedilom v več pisavah, nato pa iz istega delovnega zvezka zapiše tako datoteko RTF kot datoteko HTML, tako da obe poti delujeta na enakem vhodu.

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;

Oba klica vrneta status Integer in oba porabita isto besedilo v pomnilniku. Nič v klicni kodi ne deklarira kodne strani ali ubežnih znakov, saj odgovornost nosi pisatelj, ki pozna svoj format. Metoda SaveAsCSV na ravni delovnega zvezka sledi enaki obliki, če potrebujete izvoz z ločili iz enakega vira.

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

Varnost Unicode je odvisna od poti in ne od knjižnice

Lekcija, ki jo je vredno odnesti s seboj, je, da ni enega samega mesta, kjer bi bili Unicode-varni. RTF potrebuje deklarirano kodno stran in ubežna zaporedja \u. HTML potrebuje ubežna zaporedja entitet za pomembne znake označevanja in numerične reference, kjer nabor znakov ni zagotovljen, ter pravilno dekodiranje entitet, ki prispejo v skupnih nizih. Vsebnik ZIP potrebuje nastavljen splošnonamenski bit 11, da se ime člana UTF-8 prebere kot UTF-8. Vrednotenje formul potrebuje zlaganje velikosti črk širokih nizov in žetonizator, ki ohranja znanstveni zapis v enem kosu. Vsak od teh predstavlja drugačno pogodbo, knjižnica pa lahko izpolni eno, medtem ko potihem krši drugo. To je razlog, zakaj vam lahko orodje, ki pravilno izvozi CSV, še vedno izroči RTF, poln vprašajev.

Če vaši izvozi temeljijo na formatih z ločili, so kompromisi med njimi obravnavani v našem vodiču po izvozu CSV, TSV in HTML, ko pa je vir nabor rezultatov in ne ročno zgrajen list, se vzorci v izvozu podatkovnih baz za Delphi poročila naravno povežejo s pravili kodiranja, opisanimi tukaj. Vse to se pošilja kot del komponente HotXLS Component za Delphi in C++Builder, skupaj z vmesniki API za branje, formule in oblikovanje, ki so obravnavani drugje na tem blogu.