Откройте старый 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, даже если заголовок выглядел правдоподобно
Почему маркер 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, встроенному в закодированный путь
Почему 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
Классификация формулы до её заморозки
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-автоматизации на машине, выполняющей работу