Artykuł techniczny

Font index w BIFF8 pomija 4: rich text runs w HotXLS

HotXLS numeruje każde odwołanie do fontu w BIFF8 tak, jak definiuje to FontIndex z [MS-XLS] §2.5.129: wartości od 0 do 3 są zero-based, wartości powyżej 4 są one-based, a 4 nigdy nie występuje, więc piąty rekord FONT to ifnt 5, a największy poprawny ifnt równa się liczbie rekordów FONT. Od HotXLS 2.384.4 stosują ją pisarz XF, czytnik XF, runs łańcuchów rich text i migracja runs między skoroszytami, a 2.384.5 i 2.384.6 rozciągają ją na runs komentarzy i pól tekstowych, także przy kopiowaniu i wstawianiu wierszy

Ta reguła wygląda jak literówka, dopóki się na nią nie natkniesz. Ktoś otwiera skoroszyt z ośmioma rekordami FONT, znajduje XF wskazujący na font 8 i wyciąga wniosek, że pisarz wyprodukował indeks poza zakresem. Dokładnie to rozumowanie trafiło do HotXLS 2.384.1 jako "poprawka" i zamieniło poprawną implementację w taką, w której każdy własny font w pliku otwartym w Excelu lądował slot za wcześnie. Ciekawa nie jest sama off-by-one, tylko to, ile miejsc w bibliotece BIFF8 niesie tę samą konwencję i jak związanie fontu potrafi przeżyć jeden zapis i paść na drugim. Jeśli już walczyłeś z dziwactwami długości i kodowania opisanymi w dekodowaniu cch i fHigh w BIFF8 XLUnicodeString, to ta sama rodzina błędu: plik jest w porządku, arytmetyka nie

Co naprawdę mówi reguła FontIndex z [MS-XLS]?

[MS-XLS] §2.5.129 mówi, że FontIndex poniżej 4 to pozycja rekordu liczona od zera, FontIndex powyżej 4 to pozycja liczona od jedynki, a wartości 4 używać nie wolno. Ten sam typ FontIndex używają rekordy XF, runs formatujące SST i runs formatujące TXO, więc jedna źle przeczytana reguła psuje wszystkie trzy. Dowód łatwo odtworzyć na plikach autorstwa Excela: SOLVSAMP.XLS dostarczany z Office ma 19 rekordów FONT i maksymalny ifnt XF równy 19, skoroszyt z 43 rekordami kończy się na 43, a plik zapisany przez Excel 16 z 30 rekordami FONT celuje komórki w Courier New w ifnt 22, dwudziesty drugi rekord. Żaden z nich nigdy nie zawiera czwórki. Jeśli musisz sam analizować to mapowanie w narzędziu diagnostycznym, konwersja to dwie krótkie funkcje

// [MS-XLS] 2.5.129 FontIndex: 0..3 zero-based, > 4 one-based, 4 zabronione
function FontIndexToRecordNo(Ifnt: Word): Integer;  // rekord FONT liczony od 1
begin
  if Ifnt < 4 then
    Result := Ifnt + 1
  else if Ifnt > 4 then
    Result := Ifnt
  else
    Result := -1;  // 4 nie może wystąpić
end;

function RecordNoToFontIndex(RecordNo: Integer): Word;
begin
  if RecordNo <= 4 then
    Result := RecordNo - 1
  else
    Result := RecordNo;
end;
Mapowanie FontIndex w HotXLS wg MS-XLS 2.5.129, gdzie ifnt od 0 do 3 to pozycje rekordów FONT liczone od zera, ifnt od 5 wzwyż liczone od jedynki, a wartość 4 nigdy nie występuje, wraz z konwersją FontIndexToRecordNo i dowodami ze skoroszytów autorstwa Excela, takich jak SOLVSAMP.XLS
Piąty rekord FONT to ifnt 5, nie 4 — skoroszyt z 19 rekordami kończy się na ifnt 19, a żaden plik autorstwa Excela nigdy nie zapisuje zakazanej wartości pomiędzy

Wewnątrz HotXLS ta sama reguła mieszka w dwóch lustrzanych miejscach. TXLSFontList.GetSaveIndex bierze pozycję fontu na listowanej liście liczoną od 1 i dekrementuje tylko pozycje od 1 do 4, więc pozycja 5 zapisuje się jako ifnt 5. TXLSReader.ParseXF przy wczytywaniu robi odwrotnie: każde ifnt od 5 wzwyż jest dekrementowane do slotu listy fontów liczonego od zera, a cokolwiek poniżej zostaje jak jest. Remap rich-runów SST i CountRichRunFontRefs stosują tę samą konwersję ifnt >= 5 — i o to chodzi: jedna konwencja, każdy konsument

// TXLSFontList.GetSaveIndex (strona zapisu)
Result := inherited GetSaveIndex(Index);   // pozycja na liście liczona od 1
if (Result > 0) and (Result < 5) then
  Dec(Result);                             // 1..4 stają się 0..3, 5+ bez zmian

// TXLSReader.ParseXF (strona odczytu)
fnti := Data.GetWord(0);
if fnti >= 5 then
  Dec(fnti);                               // ifnt 5 to slot 4 listy fontów

Dlaczego zero-based "poprawka" przesunęła każdy własny font o jeden?

Przepisanie na zero-based w HotXLS 2.384.1 przesunęło każdy własny font, bo czytało indeks liczony od jedynki jako liczony od zera, a potem zmieniło cztery miejsca wywołań, żeby pasowały do tej błędnej lektury: GetSaveIndex, ParseXF, remap runów SST i migrację runów między skoroszytami w Sheets.AddCopy. Round-tripy HotXLS wciąż wyglądały dobrze, bo pisarz i czytnik zgadzali się ze sobą. Excel się nie zgadzał. Plik zapisany przez 2.384.1 wkładał pierwszy własny font na ifnt 4, które Excel traktuje jako font domyślny, a każdy kolejny własny font lądował rekord za wcześnie; otwieranie pliku Excela szło w drugą stronę, wiążąc każdy font rekord za późno

Regresja w HotXLS 2.384.1, gdzie GetSaveIndex i ParseXF czytały wartości FontIndex liczone od jedynki jako zero-based, zapisując pierwszy własny font jako zakazane ifnt 4, które Excel rozwiązuje do fontu domyślnego, a każdy kolejny font lądował rekord za wcześnie, mimo że round-tripy nadal przechodziły
Pisarz i czytnik zgadzali się co do tej samej błędnej lektury, więc test zapisz-otwórz-ponownie zostawał zielony, podczas gdy każdy font otwarty w Excelu lądował slot obok — gdy jedna konwencja mieszka w siedmiu miejscach, a zmieniasz cztery, podejrzewaj najpierw swoją zmianę

Poszlaka, która powinna zatrzymać tę zmianę, siedziała w tym samym kodzie. CountRichRunFontRefs, remap FONTX i FBI wykresów oraz lista fontów silnika stylów nie zostały dotknięte i nadal używały skip-4, więc biblioteka przeczyła sobie sama w chwili, gdy 2.384.1 wylądował, a sprzeczność trzymał w ukryciu tylko zbieg okoliczności, że fonty rich-textu zwykle były też powoływane przez jakiś XF. Gdy jedna konwencja występuje w siedmiu miejscach, a ty zmieniasz cztery, podejrzewaj swoją zmianę, zanim podejrzewasz pozostałe trzy. Wersja 2.384.4 przywróciła numerację ze specyfikacji w wszystkich czterech miejscach, a stary test regresji, który asercjonował ifnt < FontCount i przez to zakodował błędną lekturę, zastąpiono testami mapującymi każde zapisane ifnt z powrotem do nazwy rekordu FONT przez formułę ze specyfikacji. Jedno uczciwe ograniczenie zostaje: pliki zapisane przez 2.384.1 do 2.384.3 z pięcioma lub więcej fontami niosą przesunięte indeksy, których czytnik nie odróżni od poprawnych danych, więc jedynym lekarstwem jest ich regeneracja

Dlaczego runs fontów komentarzy psują się dopiero przy drugim zapisie?

Runs komentarzy i pól tekstowych padały przy drugim zapisie, bo HotXLS trzymał pierwsze N-1 rekordów FONT bezwarunkowo, a wyrzucał tylko ostatni, gdy żaden XF się na niego nie powoływał, podczas gdy runsy formatujące TXO ([MS-XLS] §2.4.329) były zapisywane z powrotem bajt po bajcie bez renumeracji. Pliki .xls autorstwa Excela zawsze kończą się niepowołanym fontem na ogonie (9pt DengXian na systemie z chińskimi ustawieniami regionalnymi), więc przy pierwszym zapisie font używany tylko przez run komentarza nigdy nie był ostatni i nic widocznie się nie ruszało. Ten pierwszy zapis jednak wyrzucał font z ogona i awansował font tylko-komentarzowy na ostatnią pozycję. Drugi zapis wyrzucał go już jako niepowołany, ifnt runa celował za koniec, a Excel cofał się do fontu domyślnego; jeśli skoroszyt zdążył w międzyczasie zyskać nowy font, run cicho wiązał się z nim, co w testach zamieniało sformatowany run pola tekstowego w Arial. Pliki pełne komentarzy, jak te opisane w budowaniu workflow przeglądu komentarzy i hiperłączy, to dokładnie miejsce, gdzie to gryzie, bo są otwierane, adnotowane i zapisywane w kółko

Tabela fontów HotXLS w dwóch zapisach, gdzie nigdy niepowołany końcowy rekord FONT, który Excel zawsze zapisuje, wypada pierwszy, font tylko-komentarzowy staje się ostatnim i zostaje wyrzucony, bo runsy formatujące TXO były zapisywane z powrotem bez liczenia powołań, aż do naprawy filtra przetrwania w CountRichRunFontRefs w 2.384.5
Pierwszy zapis wyglądał czysto, bo stratę brał font z ogona, a font komentarza znikał dopiero przy drugim — mapuj każde ifnt do nazwy rekordu FONT między zapisami, zamiast ufać testowi w pamięci

HotXLS 2.384.5 traktuje runy TXO jak runy SST. CountRichRunFontRefs chodzi teraz po każdym TMSOShapeTextBox na każdym arkuszu, konwertuje skip-4 ifnt każdego runa na slot i liczy go jako powołanie, więc font używany tylko przez runy przeżywa filtr zapisu. Powstała tabela slot-na-indeks-zapisu trafia do FontRunRemap każdego rysunku, a TMSOShapeTextBox.Store przepisuje indeksy runów na prywatnej kopii surowych bajtów runów, zostawiając końcowy TxOLastRun w spokoju, bo nie niesie fontu. Dla kodu aplikacji kontrakt jest prosty: TXLSComment.TextRuns.FontIndex i TXLSTextBox.TextRuns.FontIndex używają numeracji plikowej, z pominiętą czwórką, dokładnie tak, jak zostały wczytane; indeksy runów są liczone od 1, a CharIndex to przesunięcie znakowe, na którym run się zaczyna. Po zapisie zapisany numer może się różnić od tego, który ustawiłeś, ale nadal wskazuje ten sam font

var
  Book: IXLSWorkbook;
  Note: TXLSComment;
  I: Integer;
  Ifnt: Word;
begin
  Book := TXLSWorkbook.Create;
  if Book.Open('review-notes.xls') <> 1 then
    raise Exception.Create('Cannot open review-notes.xls');
  Note := Book.Sheets[1].Range['C2', 'C2'].Comment;
  if Note <> nil then
    for I := 1 to Note.TextRuns.Count do
    begin
      Ifnt := Note.TextRuns.FontIndex[I];   // numeracja plikowa, czwórka pominięta
      if Ifnt = 4 then
        raise Exception.CreateFmt('Run %d uses invalid ifnt 4', [I]);
      Writeln(Format('run %d at char %d: ifnt %d = FONT record #%d',
        [I, Note.TextRuns.CharIndex[I], Ifnt, FontIndexToRecordNo(Ifnt)]));
    end;
end;

Kopiowanie, wstawianie wierszy i migracja runów między skoroszytami

Od HotXLS 2.384.6 każda ścieżka kopiowania silnika klasycznego zachowuje runsy formatowania komentarzy, bo Range.Copy, CopyRange, Sheets.AddCopy i przesunięcia komórek za Range.Insert i Range.Delete przechodzą wszystko przez TXLSRange.CopyCell, a CopyCell kopiował dotąd tylko tekst komentarza i autora. Przesunięcie to kopia plus wyczyszczenie, więc wstawienie pojedynczego wiersza nad notatką z dwoma runami zostawiało ją z zerem runów i jednym fontem. Poprawka kopiuje każdy run i przenosi jego font przez TXLSWorkbook.MigrateRunFontIndex, które konwertuje indeks skip-4 na slot, migruje font po wartości do docelowej tabeli fontów i konwertuje z powrotem na numerację plikową; migracja rich-textu SST w Sheets.AddCopy woła teraz tę samą funkcję, zamiast wozić własną kopię arytmetyki. Doszły dwa przypadki brzegowe: wklejenie w miejscu, gdzie źródło i cel to ten sam komentarz, nie może wyczyścić jego runów przed ich przeczytaniem, a Sheets.AddCopy robi teraz drugi przebieg dla komentarzy przyczepionych do komórek bez zapisanego rekordu komórki, które wcześniej pomijał w całości. Strona tabeli fontów przy kopiowaniu między skoroszytami idzie tą samą logiką po wartości co strona formuł opisana w kopiowaniu między skoroszytami i rebindingu formuł. W silniku XLSX ścieżki kopiowania już klonowały runy po wartości; luka była w samej części komentarzy, gdzie czytnik ignorował rFont, strike, u i vertAlign, a pisarz nigdy nie emitował u ani vertAlign, więc runy teraz przeżywają zapis i ponowne otwarcie symetrycznie

Jak testować indeksy fontów w plikach BIFF8?

Testuj indeksy fontów przez zapis i ponowne otwarcie, najlepiej przez więcej niż jedną generację, oraz przez mapowanie każdego ifnt z powrotem do rekordu FONT, zamiast asercjonować zakres liczbowy. Każdy błąd w tej historii przeszedł test w pamięci: regresja 2.384.1 żyła w sparowanej parze pisarz-czytnik, dryf TXO potrzebował dwóch zapisów ze zmianą tabeli fontów pomiędzy, a zgubione runy komentarzy na XLSX pokazywały się dopiero po ponownym otwarciu. Przydatny harness otwiera próbkę autorstwa Excela, zapisuje ją dwa razy przez HotXLS, dodaje albo usuwa font między zapisami, a potem sprawdza pozycje runów plus, na poziomie bajtów, nazwy fontów za każdym ifnt. Nie porównuj wartości FontIndex przed i po zapisie, bo renumeracja jest legalna

procedure CheckNoteSurvivesShiftAndSave(const SrcFile, OutFile: string);
var
  Book: IXLSWorkbook;
  Note: TXLSComment;
  RunCount: Integer;
  SecondRunAt: Word;
begin
  Book := TXLSWorkbook.Create;
  Assert(Book.Open(SrcFile) = 1);          // plik Excela, C2 ma dwa runy
  Note := Book.Sheets[1].Range['C2', 'C2'].Comment;
  RunCount := Note.TextRuns.Count;
  SecondRunAt := Note.TextRuns.CharIndex[2];

  Book.Sheets[1].Range['C2', 'C2'].Copy(Book.Sheets[1].Range['E5', 'E5']);
  Book.Sheets[1].Range['C1', 'C1'].Insert(xlShiftDown);   // C2 ląduje na C3
  Assert(Book.SaveAs(OutFile) = 1);

  Book := TXLSWorkbook.Create;               // otwórz ponownie, nie ufaj pamięci
  Assert(Book.Open(OutFile) = 1);
  Note := Book.Sheets[1].Range['C3', 'C3'].Comment;
  Assert((Note <> nil) and (Note.TextRuns.Count = RunCount));
  Assert(Note.TextRuns.CharIndex[2] = SecondRunAt);
  Note := Book.Sheets[1].Range['E5', 'E5'].Comment;
  Assert((Note <> nil) and (Note.TextRuns.Count = RunCount));
end;

Jeśli czytasz i zapisujesz klasyczne XLS z Delphi albo C++Buildera i nie chciałbyś śledzić, który z licznych konsumentów fontów w bibliotece nadal zgadza się z [MS-XLS] §2.5.129, numeracja skip-4, renumeracja runów przy zapisie i migracja runów po wartości opisane tutaj są wbudowane w arkuszowy komponent HotXLS dla Delphi, który czyta i zapisuje XLS oraz XLSX bez Excela i automatyzacji OLE