Техническая статья

Классификация внешних ссылок BIFF SupBook и XTI в Delphi

Откройте старый xls, сохраните снова — и формула add-in, вызывавшая зарегистрированную аналитическую библиотеку, теперь указывает на пустую ссылку внутри самой книги. HotXLS возводит эту тихую порчу к одной плохой посылке: будто запись BIFF SupBook — либо self, либо внешний файл. [MS-XLS] определяет семь видов, а не два

Почему сохранённая книга теряет ссылки add-in?

Потому что классификационный тест был структурным, а не типизированным. Традиционный краткий путь читает запись SupBook ($01AE), проверяет, несёт ли она маркер self, и если нет — трактует любую строку далее как URL документа. Каждая запись, не являющаяся ни тем ни другим, проваливается в ветку по умолчанию, а ветка по умолчанию почти всегда гласит «это сама книга». Ссылка поддержки add-in, ссылка same-sheet, неиспользуемый слот и обрезанная запись все заканчивают в одном и том же неверном ярлыке. Ничто не выбрасывает исключение, пока это происходит: запись разобралась, формула перекомпилировалась, файл сохранён без предупреждения, а дефект всплывает три недели спустя, когда кто-то замечает столбец нулей там, где был пересчёт валюты. [MS-XLS] §2.4.271 описывает запись, которая может быть ссылкой на себя, ссылкой same-sheet, контейнером функций add-in, внешней книгой с виртуальным путём и таблицей имён листов, каналом данных DDE или OLE либо неиспользуемым заполнителем — плюс седьмое состояние, которого нет в спецификации, но оно есть на настоящих дисках: запись, не разбирающаяся. Исправление — не лучшая эвристика; это отказ от эвристики вообще

Семь видов, которые может нести запись SupBook

HotXLS объявляет таксономию ссылок поддержки закрытым перечислением в lxExternSheet.pas, и каждое последующее решение переключается по нему. Девять значений перечисления покрывают семь категорий, ведь случай DDE и OLE нуждается во временном состоянии, прежде чем разрешиться:

type
  TXLSSupportingLinkKind = (
    slkUnknown,           // не разобралась либо остались хвостовые байты
    slkSelf,              // эта книга
    slkSameSheet,         // маркер U+0000
    slkAddIn,             // контейнер функций add-in
    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 помечает контейнер add-in. Только значение в диапазоне от 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, поэтому безусловное чтение её байтом работает на большинстве файлов и падает на файлах, записанных локализованными сборками, — худшее возможное распределение для бага. Неиспользуемый заполнитель ловится тем же способом — по своему литеральному payload из одиночного пробела, а случай 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 выполняет конверсию off-by-one ровно один раз — в точке, где токен входит во внутреннее синтаксическое дерево, — и нигде более. 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 с единицы в единственной точке, с ограниченным разрешением на обоих концах и потребляющей его картой классификации
Off-by-one между дисковым индексом с нуля и внутренним ExternID с единицы применяется один раз, когда токен входит в синтаксическое дерево, и каждый неразрешимый индекс приземляется в класс malformed

Классификация формулы до её заморозки

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;
      // внутренние, add-in, DDE/OLE и malformed ссылки остаются формулами
      Sheet.ConvertFormulasToValues(True);
    end;
    Wb.SaveAs('quarterly-detached.xls');
  finally
    Wb.Free;
  end;
end;

Параметр OnlyExternal — место, где таксономия окупается. Заморозка формулы необратима, поэтому операция обязана доказать, что ссылка — внешняя книга, а не просто заподозрить. Вызовы add-in выживают, каналы DDE и OLE выживают, и всё, что парсер не смог понять полностью, выживает, ведь безопасный исход неопределённости — ничего не менять. Та же дисциплина управляет перепривязкой формул, скопированных между книгами, где неверно классифицированная ссылка перепривязывается к чужой книге вместо громкого отказа

Записи, которые не разбираются, записываются обратно нетронутыми

HotXLS хранит исходную полезную нагрузку SupBook и перевыдаёт её побайтово, пока запись не редактировалась. Сбой разбора ставит slkUnknown и очищает производное состояние, но снятое тело остаётся в FRawData, и путь сохранения предпочитает его любой реконструкции, пока элемент не dirty и не является записью self. Альтернатива — нормализовать неразобранную запись в ссылку-на-себя, чтобы писателю было что корректное выдать, — превращает запись, которую вы не поняли, в запись, которая определённо неверна. Этот принцип — тот же контракт, применённый к VBA-проектам и их внешним ссылкам через цикл загрузки-сохранения, и это разница между библиотекой, которая туда-обратно гоняет настоящие файлы, и той, что гоняет файлы, случайно оказавшиеся в её тестовом наборе. Книга, прошедшая через пятнадцать лет версий Excel, генератор отчётов и два инструмента миграции, будет содержать записи, которые никто из ныне живущих не проектировал. Записывайте их обратно так, как нашли

Типизированная классификация записей SupBook и XTI вышла в HotXLS 2.361.2–2.361.4 вместе с ограниченным разрешением XTI и описанным здесь более безопасным путём ConvertFormulasToValues. Если вы поддерживаете код на Delphi или C++Builder, читающий унаследованные xls-файлы с вызовами add-in, каналами DDE или OLE либо внешними определёнными именами, компонент электронных таблиц HotXLS Delphi обрабатывает всю таксономию нативно, без установки Excel и без OLE-автоматизации на машине, выполняющей работу