HotXLS нумерует каждую ссылку на шрифт BIFF8 так, как это определяет FontIndex из [MS-XLS] §2.5.129: значения 0–3 нумеруются от нуля, значения выше 4 — от единицы, а 4 не встречается никогда, так что пятая запись FONT — это ifnt 5, а максимальный валидный ifnt равен числу записей FONT. Начиная с HotXLS 2.384.4 этому правилу следуют XF-писатель, XF-читатель, runs строк rich text и миграция runs между книгами, а 2.384.5 и 2.384.6 распространяют его на runs комментариев и текстбоксов, в том числе при копировании и вставке строк
Правило выглядит как опечатка, пока на него не наткнёшься. Кто-то открывает книгу с восемью записями FONT, находит XF, указывающий на шрифт 8, и заключает, что писатель выдал индекс вне диапазона. Ровно это рассуждение вышло в HotXLS 2.384.1 в качестве «исправления», и оно превратило корректную реализацию в такую, где каждый пользовательский шрифт в открытом Excel файле оказывался на одну позицию раньше. Интересна тут не сама ошибка на единицу, а то, сколько мест в библиотеке BIFF8 несут одну и ту же конвенцию и как привязка шрифта может пережить одно сохранение и сломаться на втором. Если вы уже сражались с причудами длины и кодировки из статьи о декодировании cch и fHigh в BIFF8 XLUnicodeString, это та же семья багов: файл в порядке, арифметика — нет
Что на самом деле говорит правило FontIndex из [MS-XLS]?
[MS-XLS] §2.5.129 говорит, что FontIndex ниже 4 — это позиция записи с нумерацией от нуля, FontIndex выше 4 — позиция с нумерацией от единицы, а значение 4 использовать НЕЛЬЗЯ. Тот же тип FontIndex используют записи XF, formatting runs SST и formatting runs TXO, так что одна неверно понятая норма портит все три. Доказательство легко воспроизвести на файлах, созданных Excel: поставлявшийся с Office SOLVSAMP.XLS имеет 19 записей FONT и максимум XF ifnt 19, книга из 43 записей упирается в 43, а файл, сохранённый Excel 16 с 30 записями FONT, указывает ячейками с Courier New на ifnt 22 — 22-ю запись. Ни в одном четвёрка не встречается. Если вам нужно проанализировать маппинг самому в диагностическом инструменте, конверсия — это две короткие функции
// [MS-XLS] 2.5.129 FontIndex: 0..3 от нуля, > 4 от единицы, 4 невалиден
function FontIndexToRecordNo(Ifnt: Word): Integer; // запись FONT с нумерацией от единицы
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–4, так что позиция 5 записывается как ifnt 5. TXLSReader.ParseXF при загрузке делает обратное: любой ifnt от 5 и выше уменьшается до слота списка шрифтов с нумерацией от нуля, а всё, что ниже, остаётся на месте. Ремап rich-runs SST и CountRichRunFontRefs применяют ту же конверсию ifnt >= 5 — в этом и суть: одна конвенция, все потребители
// TXLSFontList.GetSaveIndex (сторона писателя)
Result := inherited GetSaveIndex(Index); // ссылаемая позиция с нумерацией от единицы
if (Result > 0) and (Result < 5) then
Dec(Result); // 1..4 становятся 0..3, 5+ без изменений
// TXLSReader.ParseXF (сторона читателя)
fnti := Data.GetWord(0);
if fnti >= 5 then
Dec(fnti); // ifnt 5 — это слот 4 списка шрифтов
Почему «исправление» с нумерацией от нуля сдвинуло каждый пользовательский шрифт на единицу?
Переписывание с нумерацией от нуля в HotXLS 2.384.1 сдвинуло каждый пользовательский шрифт, потому что оно прочло индекс с нумерацией от единицы как индекс от нуля и затем поменяло четыре места вызова под это неверное прочтение: GetSaveIndex, ParseXF, ремап runs SST и миграцию runs между книгами в Sheets.AddCopy. Round-trip внутри HotXLS выглядел нормально, потому что писатель и читатель соглашались друг с другом. Excel не соглашался. Файл, записанный 2.384.1, клал первый пользовательский шрифт в ifnt 4, который Excel считает шрифтом по умолчанию, а каждый следующий пользовательский шрифт — на запись раньше; открытие файла Excel шло в обратную сторону, привязывая каждый шрифт на запись позже
Улика, которая должна была остановить правку, лежала в той же кодовой базе. CountRichRunFontRefs, ремап chart FONTX и FBI и список шрифтов движка стилей не трогали вовсе, и они продолжали использовать skip-4, так что библиотека противоречила сама себе с момента выхода 2.384.1, и лишь совпадение — шрифты rich text обычно ещё и ссылались каким-нибудь XF — держало противоречие скрытым. Когда одна конвенция появляется в семи местах, а вы меняете четыре, подозревайте свою правку раньше, чем остальные три. Версия 2.384.4 вернула нумерацию спецификации во все четыре места, а старый регрессионный тест, ассертивший ifnt < FontCount и потому кодировавший неверное прочтение, заменили тестами, которые через формулу спецификации мапят каждый записанный ifnt обратно в имя записи FONT. Одно честное ограничение осталось: файлы, сохранённые версиями с 2.384.1 по 2.384.3 с пятью и более шрифтами, несут сдвинутые индексы, которые читатель не отличит от валидных данных, так что единственное лекарство — перегенерировать их
Почему runs шрифтов комментариев ломаются только на втором сохранении?
Runs комментариев и текстбоксов ломались на втором сохранении, потому что HotXLS безусловно держал первые N-1 записей FONT и выбрасывал только последнюю, если ни один XF на неё не ссылался, а TXO formatting runs ([MS-XLS] §2.4.329) писались обратно байт в байт без перенумерации. Файлы .xls, созданные Excel, всегда кончаются нессылаемым хвостовым шрифтом (9pt DengXian на системе с китайской локалью), так что при первом сохранении шрифт, использованный только run'ом комментария, никогда не был последним, и ничего видимо не двигалось. Но то первое сохранение выбросило хвостовой шрифт и продвинуло шрифт, живущий только в комментарии, на последнюю позицию. Второе сохранение отбросило его как нессылаемый, ifnt run'а указывал за конец, и Excel откатился к шрифту по умолчанию; а если книга между делом обзавелась новым шрифтом, run тихо привязывался уже к нему — на тестах это превратило стилизованный run текстбокса в Arial. Файлы, густо населённые комментариями, как описанные в статье о построении workflow ревью комментариев и гиперссылок, — ровно то место, где это кусается: их открывают, аннотируют и сохраняют снова и снова
HotXLS 2.384.5 обращается с TXO runs как с SST runs. CountRichRunFontRefs теперь обходит каждый TMSOShapeTextBox на каждом листе, конвертирует skip-4 ifnt каждого run'а в слот и считает это ссылкой, так что шрифт, живущий только в run'ах, переживает фильтр сохранения. Получившаяся таблица слот-в-save-index уходит в FontRunRemap каждого рисунка, а TMSOShapeTextBox.Store переписывает индексы run'ов на приватной копии сырых байтов run'ов, не трогая хвостовой TxOLastRun, потому что тот шрифта не несёт. Для кода приложения контракт простой: TXLSComment.TextRuns.FontIndex и TXLSTextBox.TextRuns.FontIndex используют файловую нумерацию с пропуском 4, ровно как прочитано; индексы run'ов нумеруются от единицы, а 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;
Копии, вставка строк и миграция runs между книгами
Начиная с HotXLS 2.384.6, каждый путь копирования классического движка сохраняет formatting runs комментариев, потому что Range.Copy, CopyRange, Sheets.AddCopy и сдвиги ячеек, стоящие за Range.Insert и Range.Delete, все проходят через TXLSRange.CopyCell, а CopyCell раньше копировал только текст и автора комментария. Сдвиг — это копирование плюс очистка, поэтому вставка одной строки над заметкой с двумя run'ами оставляла её с нулём run'ов и одним шрифтом. Исправление копирует каждый run и переносит его шрифт через TXLSWorkbook.MigrateRunFontIndex, которая конвертирует skip-4 индекс в слот, мигрирует шрифт по значению в таблицу шрифтов приёмника и конвертирует обратно в файловую нумерацию; миграция rich-text SST в Sheets.AddCopy теперь зовёт ту же функцию вместо того, чтобы тащить собственную копию арифметики. Заодно пришли два краевых случая: вставка на месте, где источник и приёмник — один и тот же комментарий, не должна чистить его runs до их чтения, а Sheets.AddCopy теперь делает второй проход для комментариев, прицепленных к ячейкам без сохранённой записи ячейки, которые раньше пропускались целиком. Сторона таблицы шрифтов при копировании между книгами следует той же логике по значению, что и сторона формул из статьи о копировании между книгами и перепривязке формул. На XLSX-движке пути копирования уже клонировали runs по значению; дыра сидела в самой части комментариев, где читатель игнорировал rFont, strike, u и vertAlign, а писатель никогда не выпускал u и vertAlign, так что теперь runs переживают сохранение и переоткрытие симметрично
Как тестировать индексы шрифтов в файлах BIFF8?
Тестируйте индексы шрифтов сохранением и переоткрытием, в идеале больше чем через одно поколение, и мапьте каждый ifnt обратно в запись FONT вместо ассерта на числовой диапазон. Каждый баг этой истории проходил тест в памяти: регрессия 2.384.1 жила в согласованной паре писатель-читатель, дрейф TXO требовал двух сохранений с переменой таблицы шрифтов между ними, а потерянные runs комментариев на XLSX вылезали только после переоткрытия. Полезный харнесс открывает образец, созданный 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; // переоткрываем, памяти не верим
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 и не хотите отслеживать, какой из многочисленных потребителей шрифтов библиотеки всё ещё согласен с [MS-XLS] §2.5.129, — нумерация с пропуском 4, перенумерация run'ов при сохранении и миграция run'ов по значению, описанные здесь, встроены в компонент электронных таблиц HotXLS для Delphi, который читает и пишет XLS и XLSX без Excel и OLE automation