Artykuł techniczny

Dekodowanie XLUnicodeString BIFF8 w Delphi: cch i fHigh

HotXLS dekoduje XLUnicodeString BIFF8, najpierw odczytując cch i flagę fHigh, a potem wybierając czytnik pasujący do kodowania: TXLSBlob.GetWideString z liczbą bajtów cch * 2, gdy fHigh wynosi 1, oraz TXLSBlob.GetString, gdy fHigh wynosi 0. Połącz je odwrotnie, a rekord zwróci pusty albo skrócony o połowę łańcuch, nigdy wyjątek

To właśnie sprawia, że ta klasa błędów jest kosztowna. Wykres się otwiera, serie rysują się poprawnie, osie są właściwe, a jeden podpis linii trendu jest po prostu pusty. Nic w logu, nic w handlerze wyjątku, żadnego okna o uszkodzonym pliku. Plik przez cały czas był poprawny; czytnik poprosił o niewłaściwą liczbę bajtów i dostał dokładnie to, o co poprosił

Dlaczego łańcuch BIFF8 wraca pusty?

Łańcuch BIFF8 wraca pusty, ponieważ strażnik długości odrzucił payload, zanim odczyt w ogóle nastąpił, albo dlatego, że czytnik zatrzymał się na pierwszym znalezionym NUL-u. Obie ścieżki są z założenia ciche. W HotXLS strażnikiem jest zwykle jawne sprawdzenie DataLength w handlerze rekordu, które trzeba obliczać zależnie od kodowania: payload 16-bitowy potrzebuje 8 + cch * 2 bajtów dla ciała SXViewLink, a 8-bitowy tylko 8 + cch. Zastosuj arytmetykę znaków szerokich do rekordu 8-bitowego, a każda krótka nazwa nie przejdzie bramki. Zachowanie dla NUL-a to druga pułapka, bo TXLSBlob.GetString i TXLSBlob.GetWideString skanują zdekodowany wynik pod kątem terminatora i przycinają go w tym miejscu, zwracając pusty łańcuch, gdy terminator trafi na pozycję pierwszą. Odczytaj ciało 16-bitowe z połową liczby bajtów, a zachowasz pierwsze cch div 2 znaków; odczytaj ciało 8-bitowe przez czytnik szeroki, a pary bajtów utworzą przypadkowe punkty kodowe. Głośny jest tylko odczyt zbyt długi: TXLSBlob.EnsureReadable zgłasza Blob read exceeds data size, gdy żądanie wychodzi poza blob. Zbyt krótki odczyt nie ma takiego alarmu

GetWideString liczy bajty, nie znaki

TXLSBlob.GetWideString(Index, Count) przyjmuje Count w bajtach. W środku wykonuje SetString na bazie PWideChar z Count div SizeOf(WideChar), więc przekazanie liczby znaków po cichu skraca łańcuch o połowę. Layouty rekordów BIFF8 wyrażają tymczasem długość łańcucha w znakach. Każde miejsce wywołania 16-bitowego musi więc samo przenosić konwersję * 2, a żadne miejsce 8-bitowe nie może jej przenosić. To ta sama granica kodowania, która pojawia się przy ponownym zapisie tekstu, więc jeśli pipeline przenosi łańcuchy w obie strony, warto przeczytać obok artykuł o bezpiecznym dla Unicode eksporcie arkuszy w Delphi

// 16-bitowy XLUnicodeStringNoCch: cch znaków, cch * 2 bajtów
Name := Data.GetWideString(Start, cch * 2);        // poprawnie
Name := Data.GetWideString(Start, cch);            // połowa tekstu, bez błędu

// 8-bitowy XLUnicodeStringNoCch: cch znaków, cch bajtów
Name := WideString(Data.GetString(Start, cch));    // poprawnie
Name := Data.GetWideStringWithZero(Start, cch);    // nadal czytnik szeroki

Ta konwencja obowiązuje wszędzie tam, gdzie strumień bajtów jest przechodzony ręcznie. Gdy HotXLS składa z powrotem długi rekord String ($0207, [MS-XLS] 2.4.268) z rekordów Continue ($003C), gałąź szeroka wylicza segCh z długości segmentu, a następnie wywołuje GetWideString(3, segCh * 2), ponieważ ciało rekordu zaczyna się pod offsetem 3, a liczba nadal jest liczbą bajtów. Czytnik rich-text robi to samo od offsetu 1 w pierwszym segmencie Continue. [MS-XLS] 2.5.293 gwarantuje, że przerwa przypada na granicę znaku dwubajtowego, gdy fHighByte wynosi 1, więc nie potrzeba obsługi częściowego znaku, ale arytmetykę bajtów nadal musisz wykonać poprawnie

Co dokładnie robi GetWideStringWithZero?

TXLSBlob.GetWideStringWithZero jest czytnikiem znaków szerokich, który zachowuje osadzone NUL-e. Sufiks WithZero oznacza zachowanie NUL-a, a nie szerokość znaku: wewnętrznie wykonuje to samo SetString na bazie PWideChar z Count div SizeOf(WideChar) co GetWideString, tylko bez skanowania terminatora. Jednobajtowym odpowiednikiem jest TXLSBlob.GetStringWithZero, który zwraca AnsiString. Nazwa nie mówi, który jest który, a ta niejednoznaczność kosztowała bazę kodu realne błędy. Warto nazwać konkretne błędne odczytanie, bo wygląda bardzo wiarygodnie: GetWideString potrzebuje cch * 2, więc GetWideStringWithZero musi przyjmować bezpośrednio cch. Rzeczywiście przyjmuje cch bez skargi, rzeczywiście zwraca WideString i kompilator jest zadowolony. Zwraca też połowę znaków, złożoną z niewłaściwych par bajtów. Poprawną ścieżką 8-bitową jest TXLSBlob.GetString ze zwykłą liczbą bajtów cch, rzutowaną na WideString przy przypisaniu. HotXLS 2.376.0 naprawił dokładnie to nadużycie w dwóch dekoderach wykresów

SXViewLink i bramka długości zależna od kodowania

SXViewLink ($0858, [MS-XLS] 2.4.316) jest najczystszym przykładem, ponieważ pakuje obie asymetrie w jeden ośmiobajtowy nagłówek. Layout to rt(2), unused(2), reserved(2), cch(1), fHigh(1), a po nim ciało XLUnicodeStringNoCch: fHigh = 1 oznacza cch * 2 bajtów UTF-16, fHigh = 0 oznacza cch bajtów znaków jednobajtowych, a cch jest ograniczone do 255, bo pole długości ma tylko jeden bajt. HotXLS zapisuje rekord w globalach wykresu przed Units, obok PivotChartBits ($0859, [MS-XLS] 2.4.196), gdy arkusz wykresu wskazuje widok tabeli przestawnej; opis rekordów tej mechaniki znajduje się w artykule o zapisywaniu rekordów tabel przestawnych BIFF8 w Delphi

// SXViewLink ([MS-XLS] 2.4.316): rt(2) unused(2) reserved(2) cch(1)
// potem XLUnicodeStringNoCch - fHigh(1) po polu długości
PivCch := Item.FData.GetByte(6);
if (PivCch > 0) and (Item.FData.GetByte(7) <> 0) and
   (Item.FData.DataLength >= LongWord(8 + PivCch * 2)) then
begin
  Result.PivotSourceName := Item.FData.GetWideString(8, PivCch * 2);
  Result.IsPivotChart := True;
end
else if (PivCch > 0) and (Item.FData.GetByte(7) = 0) and
   (Item.FData.DataLength >= LongWord(8 + PivCch)) then
begin
  Result.PivotSourceName := WideString(Item.FData.GetString(8, PivCch));
  Result.IsPivotChart := True;
end;

Obie gałęzie nie są ozdobnikiem. Wcześniejsza wersja przepuszczała oba kodowania przez wyrażenie szerokoznakowe 8 + cch * 2, więc zapisana przez Excela 8-bitowa nazwa widoku nie przechodziła strażnika, a dekoder zwracał pusty PivotSourceName z IsPivotChart pozostawionym jako false. Odsyłacz do tabeli przestawnej znikał z modelu bez jednego komunikatu diagnostycznego. Identyczny błąd tkwił w dekoderze Trendline ($2050, [MS-XLS] 2.4.328), gdzie pole nazwy następuje po 28 bajtach danych liczbowych, z dwubajtowym cch pod offsetem 28, fHigh pod offsetem 30 i znakami pod offsetem 31; podpisy linii trendu, które Excel zapisał znakami 8-bitowymi, dekodowały się jako puste łańcuchy. Oba przypadki naprawiono w tym samym wydaniu. Przypadek 8-bitowy nie jest też historyczną ciekawostką ograniczoną do plików Excela od 2.0 do 4.0: współczesny Excel nadal zapisuje 8-bitowe payloady BIFF8, gdy każdy znak mieści się w jednym bajcie

Jak bezpiecznie dekodować nowy rekord BIFF8 w Delphi?

Jeśli pole naprawdę jest standardowym XLUnicodeString, użyj TXLSBlob.GetBiffString zamiast pisać gałąź ręcznie. Metoda odczytuje pole długości, opcję, wybiera pasujący czytnik i przesuwa kursor za ciało. Dwa parametry Boolean to część, którą trzeba przeczytać uważnie: is8bit opisuje szerokość pola długości, a nie szerokość znaków, natomiast iswide mówi, czy w ogóle występuje bajt opcji fHigh. Wersje BIFF poniżej $0600 nie mają żadnego z nich

var
  Offset: LongWord;
begin
  Offset := 6;  // w SXViewLink bajt cch zaczyna się tutaj
  // is8bit = pole długości ma szerokość jednego bajtu
  // iswide = po polu długości występuje bajt opcji fHigh
  Name := Data.GetBiffString(Offset, True, True);
  // Offset wskazuje teraz pierwszy bajt za ciałem łańcucha

Ręcznie pisane gałęzie nadal mają sens, gdy handler musi przetrwać obcięte albo wrogie dane wejściowe, ponieważ GetBiffString opiera się na zgłoszeniu wyjątku przez EnsureReadable, a nie na kontrolowanym przez ciebie sprawdzeniu granic. Dlatego dekoder wykresów HotXLS sprawdza DataLength i nic nie zwraca zamiast rzucać wyjątek: uszkodzony skoroszyt z zewnętrznego źródła powinien kosztować jeden podpis, a nie cały dokument. To świadomy kompromis i właśnie dlatego strażnik zależny od kodowania musi być poprawny, bo to on zamienia zły odczyt w ciszę

Ostatni element procesu, poznany boleśnie w tym samym wydaniu. Wykonuj asercje na zapisanym i ponownie otwartym skoroszycie, a nie na modelu w pamięci, który dopiero zbudowałeś. Batch 2.376.0 wykrył również emitter SXEx ([MS-XLS] 2.4.282), który deklarował ciało 24-bajtowe, a zapisywał tylko 22 bajty, rozstrajając każdy rekord po widoku tabeli przestawnej, w tym EOF arkusza i każdy następujący podstrumień arkusza wykresu. Istniejące testy tabel przestawnych tego nie wykryły, bo wszystkie sprawdzały pamięć. Dekodowanie łańcuchów ma tę samą właściwość: tylko cykl przez plik naprawdę ćwiczy liczenie bajtów

Jeśli pracujesz z wnętrznościami klasycznego XLS w Delphi albo C++Builder i wolisz nie utrzymywać własnego czytnika rekordów BIFF8, opisane wyżej reguły kodowania są już zaimplementowane i objęte testami regresji w komponencie arkuszy HotXLS dla Delphi, który odczytuje i zapisuje XLS oraz XLSX bez Excela i automatyzacji OLE