Artykuł techniczny

Drift długości rekordów BIFF w pisarce XLS Delphi

HotXLS 2.376.0 naprawił dryf długości rekordu BIFF w klasycznej pisarce XLS: emitter SXEx dla widoków tabel przestawnych deklarował w nagłówku ciało długości 24 bajtów, po czym dopisywał 26 bajtów. Czytnik BIFF ufa zadeklarowanej długości, więc dwa nadmiarowe bajty rozstroiły wszystko dalej, a skoroszyty łączące tabelę przestawną z arkuszem wykresu traciły wykres po ponownym otwarciu

Ciekawa nie jest sama różnica o jedno słowo, lecz odległość między błędem a objawem. W miejscu błędu nic się nie zepsuło. Rekordy tabeli przestawnej serializowały się poprawnie, plik zapisywał się bez błędu, Excel go otwierał, a szkoda ujawniała się dopiero setki bajtów dalej, w całkowicie niezwiązanym podstrumieniu. Taka odległość jest charakterystyczna dla każdego binarnego formatu z długościami prefiksowanymi i warto ją zrozumieć, zanim napiszesz kolejny emitter

Dlaczego jedna zła długość rekordu niszczy cały strumień arkusza?

Strumień skoroszytu BIFF8 nie ma własnego obramowania poza arytmetyką. Każdy rekord składa się z 4-bajtowego nagłówka: identyfikatora rekordu (2 bajty) i długości ciała (2 bajty), po którym następuje dokładnie tyle bajtów danych, ile podano w nagłówku ([MS-XLS] 2.1.4). Nie ma separatora, bajtu magicznego, sumy kontrolnej ani punktu ponownej synchronizacji. Czytnik trafia na następny rekord wyłącznie dlatego, że poprzedni powiedział prawdę o swoim rozmiarze. Zadeklarowana długość nie jest metadanymi rekordu, lecz wskaźnikiem do następnego. Prześledź więc, co zrobiły dwa nadmiarowe bajty. Czytnik zużył nagłówek SXEx, pominął 24 bajty obiecane przez nagłówek i wylądował dwa bajty za wcześnie, na parze zer pozostałych z nadmiarowego ciała. Odczytał te zera jako identyfikator rekordu $0000, następnie odczytał identyfikator rekordu EOF arkusza ($000A) jako długość tego widmowego rekordu i posłusznie pominął dziesięć bajtów w tym, co nastąpiło dalej. Od tej chwili każdy nagłówek był odczytywany pod błędnym przesunięciem. W uszkodzonym skoroszycie dało to arkusz wykresu, którego _Chart po ponownym otwarciu było nil, oraz zrzut debugowania pokazujący interpretację $18AF jako identyfikatora rekordu. Żadna z tych wartości nie występuje w pobliżu kodu tabeli przestawnej

Emitter i writer nigdy nie porównują swoich obliczeń

Strukturalna przyczyna dryfu była taka, że HotXLS buduje rekord BIFF jako TXLSBlob, a nagłówek i payload są dwoma niezależnymi faktami. EmitSXEx zapisuje identyfikator rekordu, potem Blob.AddWord(24) jako długość, a następnie dopisuje ciało pole po polu. Ta wartość 24 jest ręcznie policzoną stałą, której nigdy nie wyprowadza się z następujących bajtów ani z nimi nie porównuje. Ścieżka zapisu również nie zamyka tej luki: AddRec przekazuje blob do TXLSBlobList.Append, które kopiuje Data.DataLength bajtów wprost do strumienia wyjściowego. DataLength jest rzeczywistą liczbą bajtów, więc writer wiernie emituje 26 bajtów ciała za nagłówkiem twierdzącym, że jest ich 24. Obie połowy robią dokładnie to, co im kazano, a sprzeczność między nimi nie należy do niczyich obowiązków. HotXLS już unika tego przy odtwarzaniu zachowanych payloadów: TXLSWorkbook.StoreDConnBlobs wylicza słowo długości nagłówka z rzeczywistego rozmiaru ciała, a właśnie dlatego odtwarzanie blobów nigdy nie odjechało

Co [MS-XLS] 2.4.282 określa dla SXEx

Specyfikacja jednoznacznie określa rozmiar, dzięki czemu poprawka była mechaniczna. [MS-XLS] 2.4.282 definiuje ciało SXEx jako 4-bajtowe grbit oraz dziesięć pól po 2 bajty: csxformat, cchErrorString, cchNullString, cchTag, csxselect, crwPage, ccolPage, cchPageFieldStyle, cchTableStyle i cchVacateStyle. Cztery plus dwadzieścia daje dwadzieścia cztery. Stary emitter zapisywał jedenaście zerowych słów, choć specyfikacja definiuje dziesięć, a anonimowe wywołania AddWord(0) nie zawierały nazw pól, więc liczenie ich na oko podczas przeglądu było dokładnie tak niezawodne, jak można się spodziewać. Wstępna alokacja była wskazówką, że layout zrozumiano, ale pętla nie: TXLSBlob.Create(28) prosi dokładnie o cztery bajty nagłówka i 24 bajty ciała, tymczasem blob rósł ponad tę podpowiedź przy każdym wywołaniu i robił to po cichu, bo AdjustBufferSize realokuje bufor na żądanie. Wskazówka pojemności, którą kod natychmiast przekracza, zasługuje na drugie spojrzenie w każdym serializerze

function EmitSXEx(Table: TXLSPivotTable; DataList: TXLSBlobList): Integer;
var
  Blob: TXLSBlob;
begin
  Blob := TXLSBlob.Create(28);   // 4-bajtowy nagłówek + 24-bajtowe ciało
  Blob.AddWord($00C6);
  Blob.AddWord(24);
  Blob.AddByte($02);
  Blob.AddByte($00);             // grbit1 = fPrintTitles
  Blob.AddByte($00);
  Blob.AddByte($00);             // grbit2
  // Dziesięć zerowych słów uzupełnia 24-bajtowe ciało zgodnie z [MS-XLS] 2.4.282
  // Zadeklarowana długość MUSI odpowiadać zapisanym bajtom, inaczej każdy kolejny rekord
  // zostanie błędnie zinterpretowany
  Blob.AddWord(0);               // csxformat
  Blob.AddWord(0);               // cchErrorString
  Blob.AddWord(0);               // cchNullString
  Blob.AddWord(0);               // cchTag
  Blob.AddWord(0);               // csxselect
  Blob.AddWord(0);               // crwPage
  Blob.AddWord(0);               // ccolPage
  Blob.AddWord(0);               // cchPageFieldStyle
  Blob.AddWord(0);               // cchTableStyle
  Blob.AddWord(0);               // cchVacateStyle
  AddRec(DataList, Blob);
  Result := 1;
end;

Dlaczego przetrwało to cały zestaw testów tabel przestawnych?

Bo istniejące testy tabel przestawnych nigdy nie wykonywały pełnego cyklu przez plik. Budowały skoroszyt, sprawdzały model w pamięci i kończyły działanie, a asercje w pamięci nie widzą rozbieżności długości istniejącej wyłącznie w serializowanym strumieniu bajtów. Zestaw rekordów opisany w artykule o zapisywaniu rekordów tabel przestawnych BIFF8 z Delphi był według tego standardu dobrze przetestowany, a mimo to wypuścił emitter uszkadzający strumień. Defekt wymagał też drugiej funkcji, żeby stać się widoczny: arkusz z tabelą przestawną, za którym nie następowało nic istotnego, nadal otwierał się ponownie, bo korupcja wychodziła poza koniec podstrumienia, którego nikt nie sprawdzał. Dopiero połączenie tabeli przestawnej i arkusza wykresu, gdzie arkusze wykresów i rysunki zajmują podstrumień następujący po arkuszu, zamieniło ciche przesunięcie w widoczny brak obiektu

// PivotChartRoundTripThroughLinkRecords, wersja skrócona
Wb.Sheets.Add.Name := 'Report';
Wb.Sheets[2].AddPivotTable('Data!A1:B3', 2, 2, 'SalesPivot');
Wb.Sheets.AddChartSheet('PivotView', TXLSChartType(2), '', '', '',
  Series, No3D, PivotInfo);
Assert.AreEqual(1, Wb.SaveAs(TempPath));
Wb.Free;
Wb := TXLSWorkbook.Create;
Wb.Open(TempPath);                       // błędna interpretacja następuje tutaj
Model := Wb.Sheets[3]._Chart.GetChartModel;
Assert.IsTrue(Model.IsPivotChart);

Przed poprawką Wb.Sheets[3]._Chart było nil w tej linii, ponieważ czytnik zgubił granicę podstrumienia na długo przed dotarciem do BOF wykresu. Asercja, która w końcu wykryła błąd serializacji tabeli przestawnej, dotyczyła wykresu

Jak odczytać źle wyrównany strumień BIFF aż do pierwszego błędnego rekordu

Przejdź po łańcuchu nagłówków i go wypisz, bo rozstrojenie strumienia BIFF ujawnia się strukturalnie na długo przed tym, jak dane zaczną wyglądać źle. Zacznij od BOF podstrumienia ($0809), odczytaj identyfikator i długość, przesuń się o cztery bajty plus długość i powtarzaj. Dopóki strumień jest wyrównany, trafiasz na wiarygodne identyfikatory rekordów, a łańcuch kończy się dokładnie na EOF ($000A). Po rozjechaniu dostajesz nieistniejące identyfikatory, długości wychodzące poza bufor albo łańcuch przechodzący prosto za miejsce, w którym powinien być EOF

// Przejdź po strumieniu rekordów BIFF i zatrzymaj się na pierwszym niemożliwym nagłówku
procedure ScanRecords(Buf: PByte; Size: LongWord);
var
  Pos: LongWord;
  Id, Len: Word;
begin
  Pos := 0;
  while Pos + 4 <= Size do
  begin
    Id  := PWord(Buf + Pos)^;
    Len := PWord(Buf + Pos + 2)^;
    // Zerowy identyfikator nigdy nie jest legalnym rekordem, a ciało wychodzące poza
    // bufor dowodzi, że łańcuch rozjechał się już gdzieś wcześniej
    if (Id = 0) or (Pos + 4 + LongWord(Len) > Size) then
    begin
      WriteLn(Format('desync at %d: id=$%.4x len=%d', [Pos, Id, Len]));
      Break;
    end;
    WriteLn(Format('%6d  id=$%.4x  len=%d', [Pos, Id, Len]));
    if Id = $000A then
      WriteLn('-- EOF, substream ends cleanly --');
    Inc(Pos, 4 + LongWord(Len));
  end;
end;

Następnie czytaj wynik od końca i trzymaj się jednej zasady: pierwszy rekord, którego nie da się sparsować, prawie nigdy nie jest winowajcą. Jest ofiarą. Winowajcą jest rekord bezpośrednio przed nim, ostatni, który został sparsowany bez skargi, bo kłamca mówiący o własnej długości zawsze parsuje się poprawnie. W tym przypadku przejście zatrzymało się na widmowym rekordzie $0000, a poprzednim rekordem był SXEx. Porównaj zadeklarowaną długość tego rekordu z listą pól w specyfikacji, bajt po bajcie, a arytmetyka albo się zgodzi, albo nie. Jeśli przejście nie dociera nawet do poprawnego pierwszego rekordu, problem leży warstwę niżej, w złożonym pliku OLE2 zawierającym strumień Workbook, i żadne zrzuty na poziomie rekordów nie pomogą

Emitter, który nie może skłamać o własnej długości

Trwała poprawka nie polega na wpisaniu właściwej stałej, tylko na usunięciu możliwości zapisania niewłaściwej. Zarezerwuj słowo długości, wyemituj ciało, a potem uzupełnij nagłówek na podstawie rzeczywistej liczby wyprodukowanych bajtów. HotXLS udostępnia wszystko, czego to wymaga: TXLSBlob.DataLength podaje bieżący offset, a SetWord zapisuje z powrotem w już wyemitowanym miejscu

function BeginRecord(Blob: TXLSBlob; RecId: Word): LongWord;
begin
  Blob.AddWord(RecId);
  Result := Blob.DataLength;   // zapamiętaj miejsce słowa długości
  Blob.AddWord(0);             // znacznik zastępczy, uzupełniany przez EndRecord
end;

procedure EndRecord(Blob: TXLSBlob; LenPos: LongWord);
var
  Body: LongWord;
begin
  Body := Blob.DataLength - LenPos - SizeOf(Word);
  if Body > 8224 then
    raise Exception.Create('BIFF body exceeds 8224 bytes, split with Continue');
  Blob.SetWord(Word(Body), LenPos);
end;

Uczciwie określ, gdzie kończy się ta gwarancja. Ogólna asercja, że wyemitowane bajty równe się 2 + 2 + zadeklarowana długość, obowiązuje tylko dla rekordów mieszczących się poniżej limitu BIFF8 wynoszącego 8224 bajty payloadu. Zbyt duże ciała słusznie deklarują 8224 w nagłówku i są kontynuowane w rekordach $003C Continue, dokładnie tak, jak robią to writer cache tabel przestawnych i połączeń HotXLS przy dużych payloadach, więc niezmiennik jest warunkowy: poniżej limitu długość wyemitowanego bloba musi równać się zadeklarowanej długości plus cztery, a powyżej limitu arytmetyką zajmuje się splitter. Zapisz to rozróżnienie w helperze, a nie w komentarzu. To samo rozumowanie przenosi się na każdy format tag-length-value, nie tylko BIFF. Emitter deklarujący rozmiar, zanim go zna, składa obietnicę, której kod nie może sprawdzić, a recenzent nie może policzyć, i działa aż do chwili, gdy za pierwszą funkcją pojawi się druga

Writer BIFF8, emitery rekordów tabel przestawnych i opisany tutaj podstrumień wykresu są częścią komponentu arkuszy HotXLS dla Delphi dla Delphi i C++Builder, który odczytuje i zapisuje XLS, XLSX oraz ODS bez zainstalowanego Excela