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

Обрамление рекордов PivotCache BIFF с HotXLS в Delphi

Субпоток PivotCache BIFF хранит кэшированный набор данных PivotTable отдельно от вью, которая его показывает, и HotXLS читает и пишет этот субпоток, разглядывая тела рекордов, а не доверяя номерам рекордов. Это различие — вся история: один и тот же номер рекорда несёт две несовместимые раскладки тела в зависимости от того, какой писатель произвёл файл, поэтому ридер решает обрамление по первому телу рекорда, которое увидит

Вы встречаетесь с этим слоем в момент, когда PivotTable должен пережить кругосветку. Pivot-вью без кэша — оболочка, и Excel пересоберёт кэш из исходного диапазона, когда откроет файл, что замечательно ровно до момента, когда исходного диапазона нет, данные вставлены из запроса или книга — архивный закрывающий отчёт, которому нельзя меняться, когда кто-то его открывает

Две структуры, два места в файле

Кэшированные данные и определение кэша живут в разных частях книги, и смешать их — первое, чего делать не надо. Кэшированные рекорды образуют собственный субпоток, заданный в [MS-XLS] §2.1.7.12 как PIVOTCACHE = SXDB SXDBEx *SXFORMULA *FDB *DBB EOF. Заметьте, чего нет: в начале этой продукции нет BOF

Определение же сидит в workbook globals, как PIVOTCACHEDEFINITION = SXStreamID SXVS [SXSRC] [SXADDLCACHE] (§2.1.7.20.3), позиционируемое после рекордов форматирования и перед рекордами BoundSheet и Country. Так что один кэш описан в двух местах, разнесённых на сотни рекордов, и связывает их идентификатор стрима, обязанный совпадать в трёх местах сразу

Обрамление PivotCache в HotXLS в BIFF8: PIVOTCACHEDEFINITION со своим SXStreamID сидит в workbook globals после форматирования и перед BoundSheet, тогда как кэшированные рекорды живут в стриме под хранилищем _SX_DB_CUR, названном четырёхзначным верхнерегистровым hex и содержащем рекорды SXDB, SXDBEx, SXFORMULA, FDB и DBB без BOF, и SXStreamID.idStm, поле idstm в SXDB и имя стрима обязаны совпадать
Один pivot-кэш описан в двух местах, разнесённых на сотни рекордов, и связан идентификатором стрима, обязанным совпадать одновременно в globals, в заголовке SXDB и в имени субпотока

Каждый кэш живёт в стриме под _SX_DB_CUR, чьё имя — четырёхзначная прописная шестнадцатеричная запись его идентификатора. SXStreamID.idStm, поле idstm, повторенное в заголовке SXDB, и это имя стрима обязаны совпасть. Выделяя новый идентификатор, сначала зарезервируйте все номера, уже прочитанные из файла, иначе новый кэш может присвоить номер, принадлежащий более старому кэшу, до которого ридер ещё не дошёл

Ещё один идентификатор ловит людей. Значение iCache в pivot-вью — это нулевая позиция соответствующего SXStreamID в глобальной последовательности, а не идентификатор кэша, который вы вольны выбрать. При записи его надо отмапить от объекта кэша к его реальной выходной позиции, и существующие вью обязаны перенумероваться вместе с ним, иначе апгрейд одного кэша молча нацелит вью на другой

var
  Book: TXLSWorkbook;
  Cache: TXLSPivotCache;
  Field: TXLSPivotCacheField;
  V: TXLSPivotCacheValue;
begin
  Book := TXLSWorkbook.Create(nil);
  try
    Book.LoadFromFile('sales.xls');
    Cache := Book.PivotCaches.Add;
    Cache.SourceRangeSheet := 'Data';
    Cache.SourceFirstRow := 1;  Cache.SourceFirstCol := 1;
    Cache.SourceLastRow := 500; Cache.SourceLastCol := 6;
    Cache.SourceDataType := 1;        // SXVS SHEET, MS-XLS 2.4.317
    Cache.RefreshOnLoad := False;     // верим кэшированным рекордам
    Cache.SaveData := True;

    Field := Cache.AddField('Region', xlpcftString);
    V.ValueType := xlpcftString;
    V.StrValue := 'North';
    Field.FindOrAddItem(V);

    Cache.SetRecordCount(0);          // сброс, затем размер сетки рекордов
    Cache.SetRecordCount(500);
    Book.StorePivotCaches;
  finally
    Book.Free;
  end;
end;

Двойной SetRecordCount — не суеверие. RecordCount — простая запись свойства, которая ничего не аллоцирует, а внутренний путь роста инициализирует только добавленные строки, поэтому кэш, чей размер выставили через заголовочный путь, может остаться с индексной сеткой нулевой длины. Записи в RecordIndices тогда отбрасываются без ошибки. Выставить размер в ноль и обратно — восстанавливает сетку, и это должно случиться после добавления каждого поля, потому что ширина строки приходит из числа полей

Почему номер рекорда не может сказать вам раскладку тела?

Потому что номера рекордов и раскладки тел менялись в разное время, и отображение между ними — не функция. Один номер из легаси-набора встречается только в файлах старых писателей, что делает его надёжным сигналом в одну сторону. Другой номер честно двусмысленен: он встречается и в правильных файлах, и в ряде промежуточных версий, использовавших новый номер со старой раскладкой тела

Поэтому обрамление решается по телу, и один раз на субпоток кэша, а не на рекорд. HotXLS фиксирует диалект по длине первого рекорда SXDBB в каждом субпотоке. В спецификационном обрамлении один SXDBB держит ровно один рекорд кэша, так что его длина равна одной ширине строки. В более старом упакованном обрамлении первый рекорд держит столько строк, сколько влезло, поэтому для любого кэша больше чем с одной строкой это минимум две ширины строки. Сравнение решает всякий раз, когда два предсказания различаются

Фиксация обрамления SXDBB в HotXLS: один номер рекорда несёт две несовместимые раскладки тела, поэтому ридер сравнивает длину первого рекорда SXDBB с шириной строки: одна ширина строки фиксирует спецификационный диалект, две и более — легаси-упакованное обрамление, ничья берёт спецификационное прочтение, а диалект фиксируется один раз на субпоток кэша, а не на рекорд
Номера рекордов не могут решить раскладку тела, потому что те менялись в разное время, поэтому HotXLS фиксирует диалект один раз на субпоток по длине первого SXDBB и на ничьей берёт спецификационное прочтение

Когда они не различаются, ридер берёт спецификационное прочтение — из принципа, что файлы, написанные Excel, численно превосходят файлы, написанные промежуточной сборкой. Это слепое пятно узкое по построению, и даже когда оно срабатывает, файл сам всё ещё проигрывается байт в байт. Пострадают только типизированные индексы, выдаваемые вызывающим

Ширина индекса живёт в другом рекорде

SXDBB (§2.4.276) несёт по индексу на каждое поле кэша с выставленным флагом различных значений, в порядке полей, и ширина каждого индекса решается в другом месте: соответствующий рекорд поля SXFDB (§2.4.283) объявляет флаг коротких элементов, и этот флаг говорит, занимает ли индекс два байта или один. Два рекорда, один неявный контракт и одно предложение в спецификации, их соединяющее

Именно на этой связке ломается самописное кодирование. Более ранний писатель HotXLS упаковывал каждое поле в минимальное число бит с выравниванием на границу байта между строками, что защищаемо само по себе и напрямую противоречит ширине, которую тот же писатель только что объявил в SXFDB. Поле с тремя различными значениями описывалось шириной в один байт в одном рекорде и занимало два бита в другом. Фиксом было не поправить арифметику, а вынести решение о ширине в одну функцию, которую зовут оба эмиттера, чтобы два рекорда больше не могли разъехаться. Это тот же класс дефекта, что описан в дрейфе объявления длины BIFF-рекорда, где заявленный размер и реальное тело расходятся

Последствие полного нечтения этих рекордов стоит проговорить, потому что его легко недооценить. Когда ридер пропускал индексы рекордов, каждый кэш, загруженный из файла, репортил индекс ноль для каждого поля каждой строки, то есть каждая строка указывала на первое значение каждого поля. Это не просто урезанная интроспекция: путь вычисления pivot и путь заполнения кэш-в-ячейки оба потребляют эту сетку. И round-trip-тест это не поймает, потому что кэш на сыром проигрывании пишется назад из своих исходных байтов

// Флаги происхождения говорят, что вы держите и что можно переписать
if Cache.FromRawBlobs then
begin
  Writeln('stream id        : ', IntToHex(Cache.StreamId, 4));
  Writeln('legacy framing   : ', Cache.RawFramingIsLegacy);
  Writeln('own storage      : ', Cache.RawHasStorageStream);
  Writeln('model complete   : ', Cache.RawModelIsComplete);
  // Реэмиссия без потерь только когда у каждого рекорда есть модель
  if Cache.CanUpgradeFraming then
    Writeln('safe to rewrite with the current emitters');
end;

Когда переписывание кэша происходит без потерь?

Только когда вместе выполняются три условия, и CanUpgradeFraming — единственное свойство, отвечающее на вопрос. Кэш всё ещё должен быть на сыром проигрывании, субпоток должен быть в одном из обрамлений, которые эта библиотека раньше писала неправильно, и ридер должен построить полную типизированную модель каждого рекорда в нём. Кэш, написанный Excel, никогда не проходит, потому что его субпоток несёт рекорды, для которых у HotXLS нет модели, и реэмиссия из модели их бы уронила

Тест полноты строже, чем кажется сперва. Рекорд, который ридер хранит лишь как непрозрачные байты, помечает модель неполной. Так же как объявленное число формульных рекордов, которое эмиттер не может воспроизвести: реэмиссия переписала бы объявление нескольких формульных рекордов на объявление нуля, а значение в файле, которое нельзя воспроизвести, эквивалентно рекорду, который нельзя воспроизвести

Осознанный консерватизм идёт и через писателя. Индексы зажимаются в легальный диапазон, а не кодируются внемассивным сентинелом, потому что спецификация определяет индекс в последовательность различных значений и ничего больше, а пустая ячейка — сама значение в этой последовательности. Тело рекорда кэша, превышающее потолок BIFF-рекорда, не пишется вовсе — для этого нужны тысячи полей кэша, и в пределах лимита колонок BIFF8 это всё равно недостижимо; фолбэк в том, что Excel обновится из исходного диапазона, что определённое поведение, а не битый файл

Даты несут последнюю межрекордную зависимость. Конверсия серийный-номер-в-дату зависит от системы дат книги, и рекордный эмиттер книгу не видит, поэтому выбор базовой даты передаётся параметром, дефолт — система 1900, а поставляет его путь сохранения уровня книги. В системе 1900 серийный номер — значение напрямую; система 1904 отличается на 1462 дня. Более широкое обращение с датами — в серийных номерах дат, системе 1904 и числовых форматах

Если вы работаете на слое вью, а не кэша, рекорды, описывающие видимый pivot, разобраны в наборе рекордов PivotTable BIFF8, а поведение на стороне вычислений — в вычисляемых полях, вычисляемых элементах и обновлении. Все три слоя поставляются в Delphi spreadsheet-компоненте HotXLS, что и делает возможным загрузить легаси-книгу, разглядеть, что реально содержит её кэш, и решить, безопасно ли его переписывать, прежде чем делать