Відкрийте старий xls, збережіть його знову — і формула-додаток, що викликала зареєстровану аналітичну бібліотеку, тепер вказує на порожнє посилання всередині самої книги. HotXLS зводить те мовчазне пошкодження до одного поганого припущення: що запис BIFF SupBook — це або self, або зовнішній файл. [MS-XLS] визначає сім видів, а не два
Чому збережена книга втрачає посилання на надбудови?
Бо тест класифікації був структурним, а не типовим. Традиційний ярлик читає запис SupBook ($01AE), перевіряє, чи несе він маркер self, а якщо ні — сприймає який завгодно наступний рядок за URL документу. Кожен запис, що не є жодним із цих двох, провалюється в гілку за замовчуванням, а та майже завжди каже «це сама книга». Посилання-підтримка надбудови, посилання same-sheet, невикористаний слот і обрізаний запис усе отримують однакову хибну бирку. Нічого не кидає винятків, поки це коїться: запис розпарсився, формула перекомпілювалася, файл збережено без попередження, а дефект спливає за три тижні, коли хтось помічає колонку нулів там, де колись була валютна конвертація. [MS-XLS] §2.4.271 описує запис, що може бути самопосиланням, посиланням same-sheet, контейнером функцій надбудови, зовнішньою книгою з віртуальним шляхом і таблицею назв аркушів, DDE- чи OLE-лінком даних або невикористаним заповнювачем — і сьомий стан, якого немає в специфікації, але він існує на реальних дисках: запис, що не парситься. Виправлення — не краща евристика, а відмова мати евристику взагалі
Сім видів, які може нести запис SupBook
HotXLS оголошує таксономію підтримувальних посилань замкненим переліком у lxExternSheet.pas, і кожне рішення нижче за течією перемикається на ньому. Девʼять значень переліку покривають сім категорій, бо випадок DDE і OLE потребує проміжного стану, перш ніж його можна розвʼязати:
type
TXLSSupportingLinkKind = (
slkUnknown, // не розпарсився, або лишилися хвостові байти
slkSelf, // ця книга
slkSameSheet, // маркер U+0000
slkAddIn, // контейнер функцій надбудови
slkExternalWorkbook, // віртуальний шлях + таблиця назв аркушів
slkDde, // розвʼязано з прапорців ExternName
slkOle, // розвʼязано з прапорців ExternName
slkDdeOrOle, // одне з двох, ще не відомо котре
slkUnused); // заповнювач з одного пробілу
TXLSFormulaReferenceClass = (
frcInternal,
frcExternalWorkbook,
frcExternalOther,
frcUnknownOrMalformed);
TXLSXtiInfo = record
XtiIndex : Integer; // з нуля, як зберігається в ExternSheet.rgXTI
ExternID : Integer; // з одиниці, внутрішня конвенція
SupBookIndex: Integer;
Sheet1Index : Integer;
Sheet2Index : Integer;
LinkKind : TXLSSupportingLinkKind;
end;
Диспетчеризація ведеться сторожовими значеннями, а не рядками. Значення поля $0401 позначає запис self. Кількість аркушів один у парі з $3A01 позначає контейнер надбудови. Лише значення в діапазоні від 1 до $00FF означає, що далі йде закодований віртуальний шлях, і лише тоді HotXLS взагалі декодує рядок. Усе поза цими трьома формами лишається slkUnknown, а запис, чия таблиця назв аркушів не поглинає тіло запису точно, знижується назад до slkUnknown, навіть коли заголовок виглядав правдоподібно
Чому маркер same-sheet декодується в порожній рядок?
Бо універсальний читач рядків BIFF нищить байт, від якого залежить класифікація. Підтримувальне посилання same-sheet — це односимвольний рядок, чий єдиний символ — U+0000, і TXLSBlob.GetBiffString віддає його як порожній WideString, невідмінний від справді порожнього шляху — а це рівно той ввід, на який евристика самопосилання відповідає «self». Тому HotXLS читає сирий перший кодовий пункт із тіла запису, а не довіряє декодованому значенню:
StringOffset := offset;
FDocUrl := Data.GetBiffString(offset, False, True);
FirstChar := $FFFF;
if val = 1 then
begin
StringOptions := Data.GetByte(StringOffset + 2);
if (StringOptions and $01) = 0 then
FirstChar := Data.GetByte(StringOffset + 3) // стиснуто, один байт
else
FirstChar := Data.GetWord(StringOffset + 3); // широке, два байти
end;
if FirstChar = 0 then
FKind := slkSameSheet
else if (Length(FDocUrl) = 1) and (FDocUrl[1] = WideChar(#32)) then
FKind := slkUnused
else if Pos(WideChar(#3), FDocUrl) > 0 then
FKind := slkDdeOrOle
else if FDocUrl <> '' then
FKind := slkExternalWorkbook;
Зверніть увагу на гілку стиснутого проти широкого. Байт опцій сидить на фіксованому зсуві від заголовка рядка, а перший кодовий пункт — один байт чи два залежно від біта 0, тож читати його як байт безумовно працює на більшості файлів і відмовляє на файлах від локалізованих збірок — найгірший можливий розподіл для бага. Невикористаний заповнювач ловиться так само — за літеральним корисним навантаженням з одного пробілу, а випадок DDE чи OLE — за роздільником U+0003, вшитим у закодований шлях
Чому DDE й OLE неможливо розділити на етапі SupBook?
Бо запис SupBook не несе розрізнювальних бітів. Він каже, що посилання — одне з двох; прапорці fOle і fOleLink, що вирішують котре, живуть у записі ExternName ($0023), який приходить пізніше в потоці. HotXLS фіксує slkDdeOrOle на час розбору і звужує його в ParseExternalName, а якщо ExternName так і не прийде, вид назавжди лишається проміжним — і це правильно, бо файл справді цього не каже. Кожен споживач нижче за течією сприймає те проміжне значення як справжнє, а не відсутнє, тож жодному викликачу не треба вигадувати тайбрейк. Вгадування «швидше за все DDE» тут купило б охайніший перелік і клас хибних відповідей, які ніхто не зміг би прослідкувати:
if FKind = slkDdeOrOle then
begin
if Data.DataLength < 2 then
Exit;
Flags := Data.GetWord(0);
if (Flags and $0010) <> 0 then
FKind := slkOle
else if (Flags and $0008) <> 0 then
FKind := slkDde;
end;
Індекси XTI на диску — з нуля, усередині — з одиниці
HotXLS виконує конверсію зсуву на одиницю рівно один раз — у точці, де токен входить у внутрішнє синтаксичне дерево, — і ніде більше. PtgNameX.ixti ([MS-XLS] §2.5.198.85) — індекс із нуля в масив rgXTI запису ExternSheet ($0017, §2.4.106), тоді як внутрішня конвенція ExternID бібліотеки — з одиниці, з нулем, зарезервованим за «немає зовнішнього аркуша». Шлях читання BIFF8 робить FExternID := wValue + 1, коли декодує токен tNameX, а шлях запису видає StoreExternID - 1, лишаючи сирий вигляд токена й семантику на диску недоторканими. Помилитися тут незвично важко зловити: зовнішні визначені назви розвʼязуються в сусідній запис, і у файлі з одним записом XTI індекс 0 стає індексом 1, промахується, і назва мовчки деградує. Регресійний тест, що лише ганяє перекомпільований текст формул, ніколи цього не побачить, бо перекомпіляція взагалі не торкається дискового індексу — та сама пастка, що робить визначені назви, що перетинають аркуші та книги, вартими тестування на реальних байтових потоках. Розвʼязання обмежене з обох кінців: TlxExternSheetSheet.TryResolveXti повертає False для відʼємного індексу чи відсутнього запису, TXLSSupBook.TryGetKind повертає False для індексу SupBook поза масивом, а ClassifyXti потім відображає slkSelf і slkSameSheet у frcInternal, slkExternalWorkbook у frcExternalWorkbook, а slkAddIn, slkDde, slkOle і slkDdeOrOle у frcExternalOther. Все решта, включно з кожним шляхом поза діапазоном, приземляється на frcUnknownOrMalformed
Класифікація формули до її заморожування
TXLSCompiledFormula.ClassifyReferences сканує збережений потік токенів BIFF напряму, замість декомпілювати формулу й шукати квадратні дужки. Полювання на дужки в тексті формули — текстова евристика в шаті парсера: вона збігається з рядковими літералами, збігається зі структурованими посиланнями і повністю пропускає зовнішні визначені назви, бо ті не мають дужок у декомпільованій формі. Сканування токенів дивиться лише на PtgNameX, PtgRef3d, PtgArea3d, PtgRefErr3d і PtgAreaErr3d, з відкатом до обходу синтаксичного дерева, коли потік BIFF не вижив. Злиття свідомо песимістичне — фіксований пріоритет: frcUnknownOrMalformed, потім frcExternalWorkbook, потім frcExternalOther, потім frcInternal — тож один нечитабельний токен отруює всю формулу. Для зовнішньої визначеної назви валідується також індекс назви: з одиниці, у діапазоні та підкріплений утриманим записом ExternName
var
Wb : TXLSWorkbook;
Sheet: TXLSWorksheet;
i : Integer;
begin
Wb := TXLSWorkbook.Create;
try
Wb.Open('quarterly.xls');
for i := 1 to Wb.Sheets.Count do // Sheets — з одиниці
begin
Sheet := Wb.Sheets[i];
// заморожує ЛИШЕ формули, класифіковані frcExternalWorkbook;
// внутрішні, надбудови, DDE/OLE і пошкоджені посилання лишаються формулами
Sheet.ConvertFormulasToValues(True);
end;
Wb.SaveAs('quarterly-detached.xls');
finally
Wb.Free;
end;
end;
Параметр OnlyExternal — місце, де таксономія окупується. Заморожування формули незворотне, тож операція мусить довести, що посилання — зовнішня книга, а не просто підозрювати це. Виклики надбудов виживають, DDE- й OLE-посилання виживають, і все, що парсер не зміг повністю зрозуміти, виживає, бо безпечний наслідок невизначеності — нічого не міняти. Та сама дисципліна керує перепривʼязуванням формул, скопійованих між книгами, де неправильно класифіковане посилання перепривʼяжеться до неправильної книги замість того, щоб гучно відмовити
Записи, що не парсяться, записуються назад недоторканими
HotXLS тримає початкове корисне навантаження SupBook і перевидає його байт-у-байт, коли запис ніколи не редагувався. Невдача розбору встановлює slkUnknown і очищує похідний стан, але захоплене тіло залишається в FRawData, і шлях збереження надає йому перевагу над будь-якою реконструкцією, доки елемент не брудний і не є записом self. Альтернатива — нормалізувати нерозпарсений запис у самопосилання, щоб записувач мав щось добре сформоване для видачі — перетворює запис, якого ви не зрозуміли, на запис, що визначено хибний. Той принцип — той самий контракт, застосований до проєктів VBA та їхніх зовнішніх посилань упродовж циклу завантаження й збереження, і це різниця між бібліотекою, що робить round-trip реальних файлів, і тією, що робить round-trip файлів, які трапилися в її тестовому наборі. Книга, що пройшла через пʼятнадцять років версій Excel, генератор звітів і два інструменти міграції, міститиме записи, які ніхто з живих не проєктував. Записуйте їх назад такими, якими знайшли
Типова класифікація записів SupBook і XTI вийшла в HotXLS 2.361.2–2.361.4 разом з обмеженим розвʼязанням XTI і безпечнішим шляхом ConvertFormulasToValues, описаним тут. Якщо ви підтримуєте код Delphi чи C++Builder, що читає застарілі xls-файли з викликами надбудов, DDE- чи OLE-посиланнями або зовнішніми визначеними назвами, HotXLS Delphi spreadsheet component обробляє всю таксономію нативно, без інсталяції Excel і без OLE-автоматизації на машині, що виконує роботу