HotXLS номерира всяка BIFF8 препратка към шрифт така, както я дефинира [MS-XLS] §2.5.129 FontIndex: стойности 0 до 3 са zero-based, стойности над 4 са one-based, а 4 никога не се появява, така че петият FONT record е ifnt 5, а най-големият валиден ifnt равнява на броя FONT records. От HotXLS 2.384.4 нататък XF writer-ът, XF reader-ът, rich text string run-овете и миграцията на run-ове между работни книги следват това правило, а 2.384.5 и 2.384.6 го разпростират върху comment и text box run-ове, включително при копирания и вмъквания на редове
Правилото изглежда като печатна грешка, докато не го засегне. Някой отваря работна книга с осем FONT records, намира XF, сочещ шрифт 8, и заключава, че writer-ът е издал out-of-range индекс. Точно това разсъждение влезе в HotXLS 2.384.1 като „фикс“ и превърна коректна имплементация в такава, при която всеки custom шрифт във файл, отворен от Excel, каца един слот по-рано. Интересното тук не е самият off-by-one, а колко места в една BIFF8 библиотека носят същата конвенция и как една font връзка може да оцелее след един запис и да се счупи на втория. Ако вече сте се блъскали с капаните около дължината и енкодинга, описани в декодирането на BIFF8 XLUnicodeString cch и fHigh, това е същото семейство бъгове: файлът е наред, аритметиката не е
Какво точно казва правилото за FontIndex в [MS-XLS]?
[MS-XLS] §2.5.129 казва, че FontIndex под 4 е zero-based позиция на record, FontIndex над 4 е one-based позиция, а стойността 4 НЕ ТРЯБВА да се ползва. Същият тип FontIndex ползват XF records, SST formatting run-ове и TXO formatting run-ове, така че едно погрешно прочетено правило разваля и трите. Доказателството се възпроизвежда лесно с файлове, създадени от Excel: SOLVSAMP.XLS, доставян с Office, има 19 FONT records и максимален XF ifnt 19, работна книга с 43 record-а стига до 43, а файл, записан от Excel 16 с 30 FONT records, сочи Courier New клетките си към ifnt 22 — двадесет и втория record. Нито един изобщо не съдържа 4. Ако трябва сами да анализирате мапинга в diagnostic инструмент, конверсията се събира в две кратки функции
// [MS-XLS] 2.5.129 FontIndex: 0..3 zero-based, > 4 one-based, 4 невалидно
function FontIndexToRecordNo(Ifnt: Word): Integer; // FONT запис, базиран на 1
begin
if Ifnt < 4 then
Result := Ifnt + 1
else if Ifnt > 4 then
Result := Ifnt
else
Result := -1; // 4 не бива да се среща
end;
function RecordNoToFontIndex(RecordNo: Integer): Word;
begin
if RecordNo <= 4 then
Result := RecordNo - 1
else
Result := RecordNo;
end;
Вътре в HotXLS същото правило живее на две огледални места. TXLSFontList.GetSaveIndex взима 1-based позицията на шрифта в реферирания списък и намалява само позициите 1 до 4, така че позиция 5 се записва като ifnt 5. TXLSReader.ParseXF върши обратното при зареждане: всеки ifnt 5 или повече се намалява до zero-based слот в списъка със шрифтове, а всичко под това остава каквото е. SST rich-run remap-ът и CountRichRunFontRefs прилагат същата конверсия ifnt >= 5, а това е смисълът: една конвенция, всички консуматори
// TXLSFontList.GetSaveIndex (страна на writer-а)
Result := inherited GetSaveIndex(Index); // 1-based реферирана позиция
if (Result > 0) and (Result < 5) then
Dec(Result); // 1..4 стават 0..3, 5+ без промяна
// TXLSReader.ParseXF (страна на reader-а)
fnti := Data.GetWord(0);
if fnti >= 5 then
Dec(fnti); // ifnt 5 е слот 4 в списъка със шрифтове
Защо zero-based „фикс“-ът отмести всеки custom шрифт с едно?
Zero-based пренаписването в HotXLS 2.384.1 отмести всеки custom шрифт, защото прочете one-based индекс като zero-based и после смени четири call site-а, за да се съгласуват с това погрешно четене: GetSaveIndex, ParseXF, SST run remap-а и миграцията на run-ове между книги в Sheets.AddCopy. Round trip-овете в HotXLS продължиха да изглеждат наред, защото writer и reader се разбираха помежду си. Excel не се разбираше. Файл, записан от 2.384.1, слагаше първия custom шрифт на ifnt 4, който Excel третира като default шрифт, а всеки следващ custom шрифт — един record по-рано; отварянето на Excel файл вървеше в обратната посока и връзваше всеки шрифт един record по-късно
Сигналът, който е трябвало да спре промяната, си стоеше в същата кодова база. CountRichRunFontRefs, chart FONTX и FBI remap-ът и списъкът със шрифтове на style engine-а не бяха пипнати и продължаваха да ползват skip-4, така че библиотеката си противоречеше от момента, в който 2.384.1 излезе, и само случайността, че rich-text шрифттовете обикновено бяха реферирани и от някой XF, държеше противоречието скрито. Когато една конвенция се появява на седем места и вие сменяте четири, заподозрете вашата промяна, преди да заподозрете другите три. Версия 2.384.4 възстанови номерирането от спецификацията и на четирите места, а старият регресионен тест, който assert-ваше ifnt < FontCount и така кодираше погрешното четене, бе сменен с тестове, които мапват всеки записан ifnt обратно до име на FONT record през формулата от спецификацията. Остава едно честно ограничение: файлове, записани от 2.384.1 до 2.384.3 с пет или повече шрифта, носят отместени индекси, които reader не може да различи от валидни данни, така че единственото лекарство е да ги генерирате наново
Защо comment font run-овете се чупят чак на втория запис?
Comment и text box run-овете се чупеха на втория запис, защото HotXLS пазеше първите N-1 FONT records безусловно и изхвърляше само последния, когато нищо не го реферира, докато TXO formatting run-овете ([MS-XLS] §2.4.329) се записваха обратно байт по байт без преномериране. .xls файлове, създадени от Excel, винаги завършват с нерефериран опашен шрифт (9pt DengXian на система с китайски локал), така че при първия запис шрифтът, ползван само от comment run, никога не беше последен и нищо не мърдаше видимо. Но първият запис изхвърляше опашния шрифт и издигаше шрифта, ползван само от коментара, на последна позиция. Вторият запис тогава го изхвърляше като нерефериран, ifnt на run-а сочеше след края, а Excel се връщаше към default шрифта; ако работната книга е спечелила нов шрифт междувременно, run-ът тихо се връзваше за него, което в тестовете превърна стилизиран text box run в Arial. Файловете с много коментари от рода на описаните в изграждането на review работен процес за коментари и хипервръзки са точно там, където това хапе, защото се отварят, анотират и записват многократно
HotXLS 2.384.5 третира TXO run-овете като SST run-овете. CountRichRunFontRefs вече минава през всеки TMSOShapeTextBox на всеки worksheet, конвертира skip-4 ifnt-а на всеки run в слот и го брои като референция, така че шрифт, ползван само от run, оцелява при save филтъра. Получилата се таблица слот-към-save-индекс отива в FontRunRemap на всяко drawing, а TMSOShapeTextBox.Store пренаписва run индексите върху частно копие на суровите run байтове, без да пипа опашния TxOLastRun, защото той не носи шрифт. За приложния код договорът е прост: TXLSComment.TextRuns.FontIndex и TXLSTextBox.TextRuns.FontIndex ползват номерацията на файла, с пропусната 4, точно както са прочетени; run индексите са 1-based, а CharIndex е отместването на символа, от който run-ът започва. След запис съхраненото число може да се различава от зададеното от вас, но сочи същия шрифт
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]; // номерация на файла, с пропусната 4
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;
Копирания, вмъквания на редове и миграция на run-ове между книги
От HotXLS 2.384.6 нататък всеки copy път на classic engine-а пази comment formatting run-овете, защото Range.Copy, CopyRange, Sheets.AddCopy и преместванията на клетки зад Range.Insert и Range.Delete минават всички през TXLSRange.CopyCell, а CopyCell копираше само текста и автора на коментара. Едно преместване е copy плюс clear, така че вмъкването на един ред над бележка с два run-а я оставяше с нула run-а и един шрифт. Fix-ът копира всеки run и мести шрифта му през TXLSWorkbook.MigrateRunFontIndex, който конвертира skip-4 индекса в слот, мигрира шрифта по стойност в целевата таблица със шрифтове и конвертира обратно към файлова номерация; SST rich-text миграцията в Sheets.AddCopy вече вика същата функция вместо да носи собствено копие на аритметиката. Дойдоха и два edge case-а: paste на място, при който source и destination са един и същ коментар, не бива да изчиства run-овете му преди да ги прочете, а Sheets.AddCopy вече минава втори път за коментари, закачени за клетки без съхранен cell record, които доскоро прескачаше изцяло. Страната на таблицата със шрифтове при копиране между книги следва същата логика по стойност като страната на формулите, описана в копиране между работни книги и rebinding на формули. На XLSX engine-а copy пътищата вече клонираха run-овете по стойност; дупката беше в самата comments част, където reader-ът игнорираше rFont, strike, u и vertAlign, а writer-ът никога не излъчваше u или vertAlign, така че run-овете вече оцеляват симетрично при запис и повторно отваряне
Как да тествате font индекси в BIFF8 файлове?
Тествайте font индексите като записвате и отваряте отново, в идеалния случай през повече от едно поколение, и като мапвате всеки ifnt обратно до FONT record, вместо да assert-вате числов диапазон. Всеки бъг в тази история мина през in-memory тест: регресията в 2.384.1 живееше в съчетана двойка writer и reader, TXO дрейфът искаше два записа със смяна на таблицата със шрифтове между тях, а изгубените comment run-ове на XLSX се показаха чак след reopen. Един полезен harness отваря пример, създаден от Excel, записва го два пъти през HotXLS, добавя или маха шрифт между записите и после проверява run позициите плюс, на байтово ниво, имената на шрифтовете зад всеки ifnt. Не сравнявайте стойностите на FontIndex преди и след запис, защото преномерирането е легитимно
procedure CheckNoteSurvivesShiftAndSave(const SrcFile, OutFile: string);
var
Book: IXLSWorkbook;
Note: TXLSComment;
RunCount: Integer;
SecondRunAt: Word;
begin
Book := TXLSWorkbook.Create;
Assert(Book.Open(SrcFile) = 1); // от Excel, C2 има два run-а
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 се мести в C3
Assert(Book.SaveAs(OutFile) = 1);
Book := TXLSWorkbook.Create; // reopen, не вярвай на паметта
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;
Ако четете и записвате класически XLS от Delphi или C++Builder и не ви се следи кой от многото font консуматори на библиотеката все още се съобразява с [MS-XLS] §2.5.129, skip-4 номерацията, преномерирането на run-овете при запис и миграцията на run-ове по стойност, описани тук, са вградени в HotXLS Delphi spreadsheet компонента, който чете и записва XLS и XLSX без Excel или OLE automation