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;
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
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
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