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

Записи Selection BIFF8 и прокрутка панелей в HotXLS

Инструмент сверки находит 3 000 ячеек, расходящихся с гроссбухом, и хочет, чтобы сохранённый .xls открылся со всеми ими выделенными под закреплённой строкой заголовка. На бумаге это одна запись Selection. В байтах это 9 + 3 000 × 6 = 18 009, а тело записи BIFF8 обрывается на 8 224. Та же книга поднимает второй вопрос, звучащий тривиально и не являющийся таковым: на листе, закреплённом в B2, "позиция прокрутки" — это четыре панели, делящие два смещения строк и два смещения столбцов, и эти четыре числа живут в двух разных записях

Обе задачи — арифметика формата: выделение режется на записи, которые читатель должен сшить обратно, панель именуется кодом, не следующим порядку чтения, а каждая ось прокрутки попадает в ту запись, из которой её читает Excel

Почему 3 000 выбранных областей требуют трёх записей Selection

[MS-XLS] §2.4.248 раскладывает запись Selection (0x001D) как 9-байтовую фиксированную часть, за которой следуют области. Фиксированная часть держит байт панели, rwAct и colAct активной ячейки, irefAct для индекса активной области и cref для счётчика областей. Каждая область — структура RefU с двумя 16-битными строками и двумя 8-битными столбцами. Наибольший счётчик, который влезает, — это (8,224 − 9) / 6 с округлением вниз, что даёт 1 369 областей и тело в 8 223 байта, на один байт ниже потолка

HotXLS берёт 1 369 своим потолком на запись. Когда TXLSWorksheet сохраняет группу выделения с большим числом областей, она пишет подряд идущие записи Selection для той же панели, по 1 369 областей за раз, и каждая запись повторяет тот же байт панели, активную ячейку и активный индекс. Выделение сверки на 3 000 ячеек выходит тремя записями, держащими 1 369, 1 369 и 262 области. Вызывающие той нарезки не видят: они отдают массив ссылок A1

var
  Book: TXLSWorkbook;
  Sheet: TXLSWorksheet;
  Diffs: TXLSSelectedAreas;
  I: Integer;
begin
  Book := TXLSWorkbook.Create;
  try
    Sheet := Book.Sheets.Add;
    Sheet.FreezePanes(1, 1);           // строка заголовка и столбец A остаются на месте

    SetLength(Diffs, 3000);
    for I := 0 to High(Diffs) do
      Diffs[I] := Format('C%d', [I + 2]);

    // Закрепление сбрасывает сохранённое выделение, поэтому выделяйте после закрепления.
    // 3000 областей сохраняются тремя записями Selection: 1369 + 1369 + 262
    if not Sheet.SelectAreas(xlspBottomRight, Diffs, 0) then
      raise Exception.Create('Selection rejected');

    Book.SaveAs('reconciliation.xls');
  finally
    Book.Free;
  end;
end;

Порядок вызовов здесь имеет значение. FreezePanes пересобирает раскладку панелей и стирает каждое панельное выделение, которое лист нёс, так что более раннее выделение к моменту сохранения уже исчезло бы. На незакреплённом листе правой нижней панели ещё не существует, и тот же вызов SelectAreas просто вернул бы False. Закрепляйте, затем выделяйте

На что указывает irefAct, когда выделение размазано по нескольким записям?

В нарезанном выделении irefAct индексирует всю агрегированную последовательность областей по всем кускам, и каждый кусок несёт одно и то же значение. Выделение на одну область за лимитом делает это конкретным: 1 370 областей с активной последней становятся двумя записями, первая с cref 1 369 и вторая с cref 1, и обе несут irefAct 1 369. Во второй записи тот индекс больше собственного счётчика областей record'а. Это выглядит сломанным, если читать по одной записи, и двух сортов читателей на этом заносит

  • Читатель, сверяющий irefAct с cref внутри каждой записи, отвергнет совершенно валидный файл на втором куске
  • Читатель, заменяющий состояние выделения на каждой записи Selection, оставит только последний кусок и потеряет первые 1 369 областей

Читатель HotXLS дописывает подряд идущие записи одной панели в одну группу Selection'ов и требует от каждого куска совпадения по активной строке, активному столбцу и irefAct. Проверка диапазона irefAct и проверка, что активная ячейка реально сидит внутри области, на которую указывает, выполняются только когда приезжает EOF-запись worksheet'а и полная последовательность известна. Проверять раньше — проще по коду и неверно для любого выделения свыше 1 369 областей

Почему HotXLS пишет большое выделение листа несколькими записями BIFF8 Selection: лимит тела в 8 224 байта вмещает 9 фиксированных байтов плюс 1369 шестибайтовых областей RefU, так что 3 000 областей становятся тремя записями одной панели — 1369, 1369 и 262, а irefAct индексирует агрегированную последовательность, поэтому 1370 областей с активной последней дают обеим записям irefAct 1369
Каждый кусок повторяет одни и те же активные ячейку и индекс, читатель HotXLS дописывает последовательные записи одной панели в одну группу, а проверка диапазона выполняется только на EOF-записи, когда известна полная последовательность

На пишущей стороне у панельно-первой перегрузки SelectAreas собственного потолка в 1 369 областей нет. Она разбирает каждую ссылку до касания листа: маркеры $ принимаются и срезаются, перевёрнутый диапазон вроде C9:A2 нормализуется в A2:C9, а пустая или неразбираемая запись валит весь вызов. Активный индекс обязан попадать внутрь массива и оставаться не выше 32 767, ведь irefAct — знаковое 16-битное поле. Любой отказ возвращает False и оставляет прежнее выделение ровно как было

Коды панелей не следуют порядку чтения

Запись Selection называет свою панель числовым кодом, который определяет формат: 0 для правой нижней, 1 для правой верхней, 2 для левой нижней и 3 для левой верхней. Публичное перечисление TXLSPanePosition объявлено в порядке чтения: xlspTopLeft, xlspTopRight, xlspBottomLeft, xlspBottomRight. Так что Ord(xlspTopLeft) равен 0, а 0 в файле значит правая нижняя. Райтер, кастующий перечисление прямиком в байт панели, положит каждое выделение левой верхней на правую нижнюю панель, и файл останется структурно валидным, ведь оба — легальные коды панелей

Каждая панельно-осведомлённая точка входа HotXLS конвертирует перечисление через явный case, так что код приложения numeric-кодов вообще не касается. Существование панели тоже проверяется по текущей геометрии закрепления или разделения. Левая верхняя панель существует всегда. Правой верхней нужен вертикальный сплит, левой нижней — горизонтальный, правой нижней — оба. Для панели, которой у листа нет, SelectAreas возвращает False, а GetSelectedAreas — пустой массив с ActiveAreaIndex равным -1, и ни один из вызовов не создаёт по пути ни панели, ни выделения, ни ячейки

Как HotXLS мапит TXLSPanePosition на байт панели записи BIFF8 Selection: перечисление объявлено в порядке чтения, поэтому Ord(xlspTopLeft) равен 0, тогда как файл определяет 0 для правой нижней, 1 для правой верхней, 2 для левой нижней и 3 для левой верхней, и каждый панельный вход конвертирует через явный case
Лобовое приведение перечисления к байту панели записало бы каждое выделение левой верхней на правую нижнюю панель, поэтому HotXLS перед записью ещё и проверяет существование панели по текущей геометрии разделения или закрепления

Лист вдобавок хранит старую двухаргументную SelectAreas(Areas, ActiveAreaIndex), которая пишет в ту панель, что сейчас активна, и возвращает Integer-статус. На листе с закреплёнными панелями это часто догадка о состоянии, которое вы сами не задавали. Для всего, что бежит на закреплённых или разделённых листах, берите сперва перегрузку с TXLSPanePosition: панель тогда часть вызова, а результат False говорит, что геометрия и запрос разошлись. Чтение обратно следует той же форме:

var
  Areas: TXLSSelectedAreas;
  Active: Integer;
begin
  Areas := Sheet.GetSelectedAreas(xlspBottomRight, Active);
  if Active < 0 then
    Memo1.Lines.Add('No usable selection on the bottom-right pane')
  else
    Memo1.Lines.Add(Format('%d areas, active area %s',
      [Length(Areas), Areas[Active]]));
end;

Где живёт позиция прокрутки каждого pane'а?

Позиция прокрутки каждой панели размазана по двум записям, ведь четыре панели делят лишь две позиции строк и две позиции столбцов. В классической книге первая видимая строка верхних pane'ов и первый видимый столбец левых pane'ов — это Window2.rwTop и Window2.colLeft. Строка нижних pane'ов и столбец правых pane'ов — Pane.rwTop и Pane.colLeft. Правая верхняя потому берёт строку из Window2 и столбец из Pane, а левая нижняя — наоборот

ScrollWindow и TryGetWindowScroll следуют той раскладке в точности и используют нумерацию строк и столбцов с единицы. ScrollWindow(xlspTopRight, R, C) пишет Window2.rwTop и Pane.colLeft, а значит установка столбца правой верхней двигает и правую нижнюю панель. Это совпадает с Excel, где две правые панели делят одну горизонтальную полосу прокрутки. Отсутствующая панель заставляет ScrollWindow вернуть False, а TryGetWindowScroll — обнулить оба выхода. Строка или столбец вне диапазона отвергаются прежде, чем сдвинется хоть одна ось, так что плохой столбец не оставит после себя полуприменённую строку

var
  Row, Col: Integer;
begin
  Sheet.FreezePanes(1, 1);

  // Правая нижняя: ось нижних строк (Pane.rwTop) и ось правых столбцов (Pane.colLeft)
  Sheet.ScrollWindow(xlspBottomRight, 500, 3);

  // Правая верхняя делит ось правых столбцов, так что это сдвинет и правую нижнюю на столбец 6
  Sheet.ScrollWindow(xlspTopRight, 1, 6);

  if Sheet.TryGetWindowScroll(xlspBottomRight, Row, Col) then
    Memo1.Lines.Add(Format('Bottom-right starts at row %d, column %d', [Row, Col]));
    // Правая нижняя начинается со строки 500, столбца 6
end;
Где живёт каждая ось прокрутки панелей HotXLS: четыре панели делят две позиции строк и две позиции столбцов, поэтому верхняя строка и левый столбец — это Window2.rwTop и Window2.colLeft, нижняя строка и правый столбец — Pane.rwTop и Pane.colLeft, а ScrollWindow(xlspTopRight, 1, 6) пишет одно поле Window2 плюс одно поле Pane, и правая нижняя следует за ними
XLSX размазывает те же данные по атрибутам topLeftCell у sheetView и pane, и схлопывание двух слоёв в один — это ровно тот способ, которым позиция прокрутки верхней или левой панели тихо исчезает при загрузке

XLSX хранит те же два слоя в двух элементах: sheetView/@topLeftCell (ECMA-376 Part 1, §18.3.1.87) для окна целиком и дочерний pane/@topLeftCell (§18.3.1.66) для правой нижней стороны разделения. Оба атрибута могут присутствовать разом. Загрузчик, удерживающий лишь один из них, теряет либо верхние строки, либо левые столбцы каждого закреплённого листа, которого коснётся. TXLSXWorksheet читает внешний атрибут в поля уровня окна первым, позволяет ребёнку pane переопределять только поля панельного уровня и пишет оба обратно раздельно. ScrollWindow и TryGetWindowScroll имеют те же сигнатуры и смысл на TXLSXWorksheet, а копирование листа несёт оба слоя в любом формате

Более узкие свойства остались для кода, которому нужна лишь одна пара осей: ScrollRow и ScrollColumn на классическом листе и нулевые по базе SetPaneScroll и GetPaneScroll для правой нижней панели на XLSX. Рендер-контрол держит собственные TopRow и LeftCol, как описывает статья о отрисовке книг в собственном VCL-гриде, и то состояние времени выполнения отдельно от того, что сохраняется. Сама геометрия закрепления и разделения задаётся настройками уровня листа из статьи о защите листа, настройке страницы и печати

Повреждённые записи Selection сохраняются байт в байт

Когда запись Selection оказывается кривой, HotXLS держит её тело как непрозрачные байты, репортит диагностический код 1304 (xlsDiagnosticSelectionRecordInvalid) как предупреждение и при сохранении пишет исходное тело обратно без изменений. Файлы от неизвестных экспортёров или рукописных BIFF-райтеров могут нести такие записи. Прежде чем запись влезет в группу своего pane'а, читатель проверяет байт панели на 3 или меньше, смежность записей панели, cref между 1 и 1 369 с телом длиной ровно 9 + cref × 6 байтов, согласие кусков по активной ячейке и irefAct без знакового бита, активный столбец на сетке и отсутствие перевёрнутых областей. Проблемы внутри одной физической записи репортятся по разу на запись; противоречия, вылезающие только после агрегации, — скажем irefAct, указывающий за общий счётчик областей, или активная ячейка вне проиндексированной области, — репортятся по разу на группу на EOF

Невалидная группа остаётся за бортом типизированного API: GetSelectedAreas возвращает для той панели пустой массив с индексом -1, а прочие панели продолжают работать как обычно

var
  I: Integer;
  D: TXLSDiagnostic;
begin
  if Book.Open('supplier-upload.xls') <> 1 then
    Exit;
  for I := 0 to Book.Diagnostics.Count - 1 do
  begin
    D := Book.Diagnostics[I];
    if D.Code = xlsDiagnosticSelectionRecordInvalid then
      // Log — ваш собственный TStrings; HotXLS лишь наполняет Diagnostics
      Log.Add(Format('%s: record $%.4x kept opaque (%s)',
        [D.SheetName, D.RecordId, D.Message]));
  end;
end;

Починить запись — зажать irefAct или выбросить перевёрнутую область — было другим вариантом. Сохранение как есть — более безопасный дефолт: починенное выделение — выделение, которое никто не выбирал, а исходные байты были бы утрачены. Диагностическое сообщение называет провалившуюся проверку, например "the area count does not match the record length" или "records for the same pane are not contiguous", а RecordId для этого кода всегда $001D

Вставки строк и столбцов перемапливают выделение каждого pane'а

Вставка или удаление целых строк или столбцов перемапливает выделение каждой панели через одну общую процедуру, в обоих движках, классическом и XLSX. Пережившие области хранят порядок и сдвигаются вместе с ячейками, которые покрывают, а активная область хранит свою идентичность. Если активная область удалена, активной становится первая пережившая область после неё; если дальше ничего нет — последняя пережившая перед ней. Если удалены все области, группа схлопывается в одну ячейку на границе удаления. Активная ячейка, вывалившаяся из выбранной области, переезжает в её верхний левый угол, так что индекс и координата никогда не противоречат друг другу в сохранённом файле

Группы, помеченные невалидными при загрузке, ремаппер не трогает, так что их исходные байты проходят кругосветку и после правки. Смена выделения на одной панели переписывает только записи этого pane'а и оставляет записи прочих pane'ов побайтно идентичными. ODS-файлы вовсе не несут состояния панельного выделения: у OpenDocument нет эквивалентной понанельной структуры представления листа; выделения и позиции прокрутки панелей относятся к XLS и XLSX

Если ваше приложение сохраняет книги, которые люди открывают, чтобы разобрать помеченные ячейки, продолжить с места остановки или прочесть закреплённый дашборд, панельно-осведомлённые вызовы выделения и прокрутки поставляются с компонентом электронных таблиц HotXLS для Delphi и ведут себя одинаково для вывода .xls и .xlsx