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

Класифікація зовнішніх посилань BIFF SupBook і XTI у Delphi

Відкрийте старий 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, навіть коли заголовок виглядав правдоподібно

Сходи на сторожових значеннях, за якими HotXLS класифікує запис BIFF SupBook на сім видів: рядок декодується лише для значень у діапазоні закодованого шляху, а відкат іде в невідомий вид, а не в гілку за замовчуванням
Кожен вид досягається сторожовим значенням, а не тестом рядка, і запис, що не збігається з жодною формою, лишається невідомим замість провалювання в гілку за замовчуванням, що означає цю книгу

Чому маркер 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, вшитим у закодований шлях

Чому HotXLS читає сирий перший кодовий пункт із тіла запису BIFF SupBook, а не декодований рядок: універсальний читач рядків перетворює маркер same-sheet U+0000 на порожнє значення
Маркер same-sheet — односимвольний рядок з символом U+0000, тож універсальний читач рядків згортає його в порожнє значення, і лише сирий кодовий пункт на зсуві байта опцій його зберігає

Чому 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

HotXLS конвертує індекс XTI із нуля токена BIFF PtgNameX у внутрішній ExternID з одиниці в одній точці, з обмеженим розвʼязанням з обох кінців і картою класифікації, що її споживає
Зсув на одиницю між дисковим індексом із нуля та внутрішнім ExternID з одиниці застосовується один раз, коли токен входить у синтаксичне дерево, і кожен нерозвʼязний індекс приземляється на клас пошкоджених

Класифікація формули до її заморожування

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-автоматизації на машині, що виконує роботу