Технічна стаття

Індекси шрифтів BIFF8 перескакують 4: HotXLS у Delphi

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, приземлявся на один слот раніше. Цікаве тут — не сам off-by-one, а те, скільки місць у бібліотеці 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: SOLVSAMP.XLS з постачання Office має 19 записів FONT і максимальний ifnt у XF рівно 19, книга з 43 записами зупиняється на 43, а файл, збережений Excel 16 із 30 записами FONT, вказує свої комірки з Courier New на ifnt 22 — 22-й запис. Жоден із них ніколи не містить 4. Якщо вам треба самому проаналізувати мапування індексів у діагностичному інструменті, конвертація — це дві короткі функції

// [MS-XLS] 2.5.129 FontIndex: 0..3 від нуля, > 4 від одиниці, 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;
Відображення FontIndex у HotXLS за MS-XLS 2.5.129, де ifnt 0–3 — це позиції записів FONT від нуля, ifnt 5 і вище — від одиниці, а значення 4 не трапляється ніколи, з конверсією FontIndexToRecordNo і доказами з книг від Excel на кшталт SOLVSAMP.XLS
П'ятий запис FONT — це ifnt 5, а не 4: книга з 19 записами зупиняється на ifnt 19, і жоден файл від Excel ніколи не зберігає заборонене значення поміж ними

Усередині HotXLS те саме правило живе у двох дзеркальних місцях. TXLSFontList.GetSaveIndex бере позицію шрифта у списку, раховану від 1, і декрементує лише позиції 1–4, тож позиція 5 записується як ifnt 5. TXLSReader.ParseXF на завантаженні робить обернене: будь-який ifnt від 5 і більше декрементується до слота списку шрифтів від нуля, а все нижче лишається на місці. Перемапа rich-run у SST і CountRichRunFontRefs застосовують ту саму конверсію ifnt >= 5, і в цьому суть: одна конвенція — кожен споживач

// TXLSFontList.GetSaveIndex (сторона writer)
Result := inherited GetSaveIndex(Index);   // позиція у списку від 1
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 списку шрифтів

Чому «виправлення» з нульовим рахунком зсунуло кожен власний шрифт?

Переписування на нульовий рахунок у HotXLS 2.384.1 зсунуло кожен власний шрифт, бо воно прочитало індекс від одиниці як індекс від нуля, а тоді змінило чотири місця виклику під це неправильне прочитання: GetSaveIndex, ParseXF, перемапу runs SST і міграцію runs між книгами у Sheets.AddCopy. Round trips самого HotXLS досі виглядали гаразд, бо writer і reader були згодні один з одним. Excel не був згоден. Файл, записаний 2.384.1, клав перший власний шрифт на ifnt 4, який Excel трактує як шрифт за замовчуванням, а кожен наступний власний шрифт — на запис раніше; відкриття файлу Excel ішло в інший бік, прив'язуючи кожен шрифт на запис пізно

Регресія HotXLS 2.384.1, де GetSaveIndex і ParseXF читають значення FontIndex від одиниці як від нуля, записуючи перший власний шрифт як заборонений ifnt 4, який Excel розв'язує у шрифт за замовчуванням, і приземляючи кожен наступний шрифт на запис раніше, поки round trips проходили
Writer і reader були згодні в тому самому неправильному прочитанні, тож тест «зберегти й перевідкрити» лишався зеленим, поки кожен шрифт у файлі, відкритому Excel, приземлявся на слот із зсувом: коли одна конвенція живе у семи місцях, а ви змінюєте чотири, спершу підозрюйте свою зміну

Підказка, яка мала б зупинити зміну, лежала в тому самому коді. CountRichRunFontRefs, перемапа FONTX і FBI у графіках та список шрифтів style engine ніхто не чіпав, і вони досі використовували 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, тоді як formatting runs TXO ([MS-XLS] §2.4.329) писалися назад байт у байт без перенумерації. Файли .xls від Excel завжди закінчуються непосиланим хвостовим шрифтом (9pt DengXian у системі з китайською локаллю), тож при першому збереженні шрифт, який використовував лише run коментаря, ніколи не був останнім, і нічого видимого не зсунулося. Але те перше збереження викидало хвостовий шрифт і підвищувало шрифт «лише для коментаря» на останню позицію. Друге збереження тоді відкидало його як непосиланий, а ifnt у run вказував уже за кінець, і Excel падав назад до шрифту за замовчуванням; якщо книга тим часом набувала нового шрифту, run тихо прив'язувався до нього, що в тестуванні перетворювало стилізований run текстового поля на Arial. Файли, густо насичені коментарями, як ті, що описані в побудові робочого процесу рецензування коментарів і гіперпосилань, — саме там це кусається, бо їх відкривають, анотують і зберігають знову і знову

Таблиця шрифтів HotXLS упродовж двох збережень, де непосиланий хвостовий запис FONT, який Excel пише завжди, викидається першим, шрифт «лише для коментаря» стає останнім і тоді відкидається, бо formatting runs TXO писалися назад без рахування посилань, аж поки CountRichRunFontRefs не полагодив фільтр виживання у 2.384.5
Перше збереження виглядало чистим, бо втрати взяв на себе хвостовий шрифт, а шрифт коментаря зник лише на другому: відображайте кожен ifnt до імені запису FONT крізь збереження, замість довіряти тестові в пам'яті

HotXLS 2.384.5 трактує runs TXO як runs SST. CountRichRunFontRefs тепер обходить кожен TMSOShapeTextBox на кожному аркуші, конвертує skip-4 ifnt кожного run у слот і рахує його як посилання, тож шрифт «лише для runs» переживає фільтр збереження. Таблиця слот-до-індексу-збереження, що вийшла, йде в FontRunRemap кожного малюнка, а TMSOShapeTextBox.Store переписує індекси run на приватній копії сирих байтів run, не чіпаючи хвостовий TxOLastRun, бо той не несе шрифту. Для коду застосунку контракт простий: TXLSComment.TextRuns.FontIndex і TXLSTextBox.TextRuns.FontIndex використовують файлову нумерацію з перескоченою 4, точно як прочитано; індекси run рахуються від 1, а 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 копіював лише текст коментаря й автора. Зсув — це копія плюс очищення, тож вставлення одного рядка над нотаткою з двома runs лишало її з нульовими runs і одним шрифтом. Виправлення копіює кожен 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 жила в узгодженій парі writer і reader, дрейф TXO потребував двох збережень зі зміною таблиці шрифтів поміж них, а втрачені runs коментарів на XLSX вилазили лише після перевідкриття. Корисний стенд відкриває зразок від Excel, двічі зберігає його через HotXLS, додає чи прибирає шрифт між збереженнями, а тоді перевіряє позиції runs плюс, на рівні байтів, імена шрифтів за кожним 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 має два runs
  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, нумерація skip-4, перенумерація run при збереженні та міграція run за значенням, описані тут, вбудовані в компонент електронних таблиць HotXLS для Delphi, який читає і пише XLS та XLSX без Excel чи OLE automation