Technical Article

Bezpieczny dla Unicode eksport arkuszy w Delphi: RTF i HTML

Arkusz kalkulacyjny zawiera kolumnę z nazwami klientów. Niektóre są zapisane po chińsku, inne cyrylicą, kilka zawiera niemieckie umlauty lub francuskie akcenty. Eksportujesz go do formatu CSV i otwierasz wynik — każdy znak jest nienaruszony. Eksportujesz ten sam skoroszyt do RTF w celu użycia jako szablon korespondencji seryjnej, otwierasz go w edytorze tekstu, a nazwy spoza zakresu ASCII zmieniły się w ciągi znaków zapytania. Dane nigdy się nie zmieniły. Zmienił się kontrakt kodowania formatu, który zapisałeś, a każda ścieżka eksportu wiąże się z innym zestawem reguł.

To jest pułapka, w którą wpada biblioteka wyglądająca z pozoru na w pełni obsługującą Unicode. Tekst komórek jest przechowywany wewnętrznie jako WideString, więc model danych nie traci żadnych znaków. Utrata następuje na granicy systemu — w module zapisującym, który musi szeregować ten tekst do formatu mającego własne reguły dotyczące dozwolonych bajtów oraz sposobu kodowania wszystkiego, co wykracza poza dozwolony zakres. Możesz dopracować jeden moduł zapisu, a inny w tym samym pakiecie nadal będzie niszczył tekst. Rozwiązaniem nie jest globalny przełącznik. Wymaga to osobnej, poprawnej decyzji na każdej ścieżce zapisu.

RTF to format z założenia bezpieczny dla systemów 7-bitowych

Format RTF powstał przed upowszechnieniem standardu Unicode i został zaprojektowany tak, aby przetrwać transmisję przesyłającą wyłącznie znaki drukowalne ASCII. Dokument RTF deklaruje stronę kodową w swoim nagłówku, a każdy znak, którego program zapisujący nie jest w stanie przedstawić w tej stronie kodowej, musi zostać wysłany jako sekwencja ucieczki (escape), a nie surowy bajt. Odpowiednią sekwencją ucieczki jest \u, która przenosi 16-bitową jednostkę kodową ze znakiem, a po niej znak zastępczy ASCII dla czytników zbyt starych, by w ogóle zinterpretować tę sekwencję.

HotXLS zapisuje pliki RTF w ten sposób. Nagłówek dokumentu deklaruje na początku stronę kodową w formacie \ansi\ansicpg1252\uc1, a moduł zapisu w jednostce lxRTF analizuje każdy ciąg znaków, wysyłając znaki spoza ASCII jako sekwencje ucieczki \u, dzięki czemu strumień bajtów pozostaje czysty 7-bitowo, niezależnie od możliwości zadeklarowanej strony kodowej. Punkt kodowy taki jak U+4E2D staje się dosłowną sekwencją \u20013?, a nie surowym bajtem, który przeglądarka próbowałaby zinterpretować przy użyciu dowolnej przyjętej strony kodowej. Bez tej dyscypliny znaki spoza zadeklarowanej strony kodowej nie mają poprawnej reprezentacji bajtowej, a moduł zapisu wysyłający surową wartość wygeneruje znaki zapytania, o których mowa na początku artykułu.

Szczegółem, o którym należy pamiętać, jest to, że zadeklarowana strona kodowa i sekwencje ucieczki to dwie części jednego kontraktu. Sama deklaracja strony kodowej nie pomoże tekstowi, który leży poza nią. Wysyłanie sekwencji ucieczki bez zadeklarowanej strony kodowej pozostawia znaki zastępcze niejednoznacznymi. Obie te rzeczy muszą być poprawne jednocześnie, dlatego moduł zapisu obsługujący tylko jedną z nich i tak zawiedzie na pierwszym wielojęzycznym skoroszycie.

Eskapowanie HTML to coś więcej niż nawiasy ostre

Eksport do HTML tworzy dokument wieloarkuszowy, w którym ramki nawigacyjne zawierają nazwy arkuszy jako widoczny tekst. Nazwy te są ciągami kontrolowanymi przez autora i mogą zawierać dowolne znaki, w tym te mające znaczenie dla znaczników. Arkusz nazwany dosłownie Q1 & Q2 <draft> musi trafić na stronę w postaci wyeskapowanych encji, inaczej nawiasy ostre otworzą nieistniejący znacznik, a znak ampersand rozpocznie odwołanie do encji, którego nigdy nie planowano. To jest zwykłe eskapowanie HTML, a pominięcie go na etykiecie ramki to rodzaj niedopatrzenia, które przechodzi pomyślnie każdy test oparty wyłącznie na nazwach arkuszy w standardzie ASCII.

Kwestia kodowania leży o krok głębiej. Gdy znaki spoza ASCII trafiają do kontekstu, co do którego nie ma gwarancji obsługi jako UTF-8, bezpieczną reprezentacją jest numeryczne odwołanie znakowe, więc U+00E9 jest zapisywany jako é, a nie surowy bajt, którego znaczenie zależy od kodowania znaków odpowiedzi. Lustrzane odbicie tej reguły ma zastosowanie przy odczycie danych. Skoroszyt odczytany z powrotem z formatu XLSX niesie współdzielone ciągi znaków, w których znak może być już zapisany jako numeryczna encja XML, a encja ta musi zostać zdekodowana do postaci jednego pełnego znaku, zanim trafi do modelu komórek. Nieostrożne dekodowanie, polegające na rozbiciu punktu kodowego na osobne bajty, sprawi, że pojedynczy znak powróci jako zniekształcony ciąg (mojibake), którego żaden późniejszy eksport nie będzie w stanie naprawić.

Kontener XLSX to plik ZIP, a ZIP ma własne kodowanie nazw

Plik XLSX to archiwum ZIP, a archiwum przechowuje nazwę dla każdego elementu, który zawiera. Format ZIP jest na tyle stary, że jego pierwotna specyfikacja milczała na temat kodowania tych nazw, stąd czytnik niemający dodatkowych sygnałów zakłada lokalną stronę kodową systemu. To założenie jest błędne w momencie, gdy nazwa elementu zawiera znak spoza zakresu ASCII, co zdarza się w przypadku lokalizowanych nazw części arkusza oraz osadzonych multimediów, których nazwy plików niosą akcenty lub znaki inne niż łacińskie.

Rozwiązaniem jest pojedynczy bit. Bit ogólnego przeznaczenia 11 (General-purpose bit 11) w każdym nagłówku lokalnego pliku deklaruje, że nazwa elementu jest zakodowana w UTF-8. HotXLS sprawdza dokładnie ten bit podczas odczytu archiwum, testując flagi ogólnego przeznaczenia z maską $0800, a czytnik lub moduł zapisu ignorujący go błędnie odczyta nazwę, którą poprawna implementacja zapisała w formacie UTF-8. Ustawienie tego bitu i jego honorowanie są bezkosztowe, a decyduje to o różnicy między nazwą elementu, która przetrwa operację zapisu i odczytu, a nazwą uszkodzoną jeszcze przed rozpoczęciem parsowania zawartości arkusza.

Konwersja wielości znaków i skanowanie liczb kryją to samo zagrożenie

Obliczanie formuł to miejsce, w którym bezpieczeństwo Unicode przestaje dotyczyć serializacji, a zaczyna dotyczyć porównywania wartości. Funkcja SEARCH nie rozróżnia wielkości liter, co oznacza, że przed wyszukaniem podciągu musi zrównać wielkość znaków (fold case). Błędnym podejściem jest użycie strony kodowej ANSI, ponieważ zamiana tekstu spoza ASCII na wielkie litery tą drogą kieruje znaki przez wąską stronę kodową i uszkadza wszystko, co się w niej nie mieści. Właściwym sposobem jest konwersja do wielkich liter typu wide-string, która zachowuje pełny zakres UTF-16. HotXLS stosuje z tego powodu funkcję WideUpperCase, dzięki czemu wyszukiwanie tekstu z akcentami lub znakami niełacińskimi dopasowuje dokładnie te same znaki, które przekazano, a nie ich zniekształcone przez stronę kodową przybliżenie.

Parser formuł (tokenizer) niesie powiązany obowiązek, który nie ma nic wspólnego z literami, a wiąże się z określeniem końca tokenu. Zapis naukowy, taki jak 1E3 lub 2.5E-3, to pojedynczy literał liczbowy, a skaner musi rozpoznać znak E, opcjonalny znak plus/minus oraz kolejne cyfry jako część liczby, zamiast dzielić dane wejściowe na nazwę i osobną liczbę. Skaner, który błędnie to obsłuży, zamieni w pełni poprawną stałą w błąd parsowania lub, co gorsza, w cicho błędne wyrażenie. Temat ten należy do tej samej dyskusji, ponieważ oba przypadki dotyczą podejmowania przez czytnik prawidłowych decyzji na poziomie znaków: jednej dotyczącej sposobu konwersji znaku do porównania, a drugiej określającej, czy dany znak kontynuuje bieżący token.

Budowanie i eksportowanie wielojęzycznego skoroszytu

Publiczny interfejs API nie wymaga od Ciebie myślenia o żadnym z tych problemów. Budujesz skoroszyt z wartości komórek typu WideString i wywołujesz żądany punkt wejścia eksportu. Decyzje o kodowaniu zapadają wewnątrz każdego modułu zapisu. Poniższy przykład wypełnia arkusz tekstem w różnych pismach, a następnie zapisuje zarówno plik RTF, jak i HTML z tego samego skoroszytu, uruchamiając obie ścieżki na identycznych danych wejściowych.

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 wywołania zwracają status typu Integer i oba korzystają z tego samego tekstu w pamięci. Nic w kodzie wywołującym nie deklaruje strony kodowej ani nie eskapuje znaków, ponieważ odpowiedzialność ta spoczywa na module zapisu, który zna reguły własnego formatu. Metoda SaveAsCSV na poziomie skoroszytu działa w ten sam sposób, jeśli potrzebujesz eksportu rozdzielanego znakami z identycznego źródła.

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

Bezpieczeństwo Unicode zależy od ścieżki, a nie od biblioteki

Lekcja, którą warto zapamiętać, mówi o tym, że nie ma jednego miejsca odpowiedzialnego za bezpieczeństwo Unicode. RTF wymaga zadeklarowanej strony kodowej i sekwencji ucieczki \u. HTML potrzebuje eskapowania encji dla znaków o znaczeniu strukturalnym i numerycznych odwołań, gdy zestaw znaków nie jest zagwarantowany, a także prawidłowego dekodowania encji trafiających do współdzielonych ciągów znaków. Kontener ZIP wymaga ustawienia bitu 11 ogólnego przeznaczenia, aby nazwa elementu w UTF-8 była odczytana jako UTF-8. Obliczanie formuł wymaga konwersji wielkości znaków wide-string oraz tokenizera, który utrzymuje notację naukową w jednej części. Każde z tych wymagań to inny kontrakt, a biblioteka może spełniać jedno z nich, po cichu naruszając inne. To jest powód, dla którego narzędzie poprawnie tworzące pliki CSV może wygenerować plik RTF pełen znaków zapytania.

Jeśli Twoje eksporty opierają się na formatach rozdzielanych, różnice między nimi omówiono w naszym przewodniku po eksporcie do CSV, TSV i HTML. Gdy źródłem jest zestaw wyników, a nie ręcznie tworzony arkusz, wzorce przedstawione w artykule eksport baz danych dla raportów w Delphi naturalnie współpracują z opisanymi tutaj regułami kodowania. Całość jest dostępna w pakiecie HotXLS Component dla Delphi i C++Builder obok interfejsów API do odczytu, obsługi formuł i formatowania opisanych w innych częściach tego bloga.