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;
Усередині 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 ішло в інший бік, прив'язуючи кожен шрифт на запис пізно
Підказка, яка мала б зупинити зміну, лежала в тому самому коді. 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 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