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

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

HotXLS хранит выделения листа и позиции прокрутки в разрезе панелей через один API с учётом панелей — SelectAreas, GetSelectedAreas, ScrollWindow и TryGetWindowScroll на обоих классах, TXLSWorksheet и TXLSXWorksheet. Для классических файлов .xls HotXLS пишет записи BIFF8 Selection (0x001D) максимум по 1369 областей каждая, конвертирует логические имена панелей в определённые форматом байты панелей и держит каждую ось прокрутки на той записи, Window2 или Pane, где её ждёт Excel

Проблема обычно всплывает в инструменте сверки или аудита. Инструмент открывает выгрузку журнала, находит каждую ячейку, расходящуюся с исходной системой, и сохраняет книгу с этими ячейками уже выделенными под закреплённой строкой заголовка, чтобы проверяющий сразу попадал на расхождения, а не листал к ним. С сорока расхождениями всё работает. В файле на конец месяца их 3 000, а одной записи Selection с 3 000 областей существовать нельзя: её телу понадобилось бы 18 009 байт, больше чем вдвое против того, что способна нести одна запись BIFF8. У позиции прокрутки похожая ловушка. На листе с закреплёнными областями «где смотрел пользователь» — это четыре панели, делящие две позиции строк и две позиции столбцов, а не одна координата

Зачем большому выделению больше одной записи Selection?

Большому выделению нужно несколько записей, потому что тело записи BIFF8 ограничено 8224 байтами, а каждая выделенная область стоит фиксированные шесть байтов. [MS-XLS] §2.4.248 описывает запись Selection как 9-байтовую фиксированную часть (байт панели, rwAct и colAct активной ячейки, irefAct активной области и cref — счётчик областей), за которой следуют cref структур RefU, каждая держит две 16-битные строки и два 8-битных столбца. Максимум, что влезает, — это (8224 − 9) / 6 с округлением вниз, то есть 1369, и это даёт тело в 8223 байта, на байт ниже лимита. TXLSWorksheet.StoreSelectionGroup берёт эту константу как MaxAreasPerRecord и пишет группу покрупнее как последовательные записи Selection для той же панели, по 1369 областей за раз

Кусает деталь irefAct. Каждый кусок повторяет одни и те же активную строку, активный столбец и индекс активной области, а irefAct индексирует агрегированную последовательность всех кусков, а не области внутри той записи, что его несёт. Выделение на одну область больше лимита делает это наглядным: 1370 областей с активной последней превращаются в две записи — первую с cref 1369 и вторую с cref 1, и обе несут irefAct 1369. Это значение больше собственного счётчика областей второй записи. Читатель, сверяющий irefAct с cref в каждой записи, отвергнет валидный файл, а читатель, заменяющий состояние на каждой записи, потеряет первые 1369 областей. Читатель HotXLS дописывает последовательные записи одной панели в одну группу, требует от каждого куска совпадения активной ячейки и индекса и гоняет проверку диапазона только на EOF-записи листа, когда известна полная последовательность. Поэтому перегрузка SelectAreas с панелью первым аргументом не знает потолка в 1369 областей. Она валидирует каждую ссылку A1 и активный индекс до взятия блокировки записи листа и возвращает False с прежним выделением, если что-то не так

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

Какой байт панели использует запись Selection?

Запись Selection опознаёт свою панель по числовому коду, определённому форматом: 0 для правой нижней, 1 для правой верхней, 2 для левой нижней и 3 для левой верхней. Публичное перечисление TXLSPanePosition объявлено в порядке чтения: xlspTopLeft, xlspTopRight, xlspBottomLeft, xlspBottomRight, так что Ord(xlspTopLeft) равен 0, а это в файле правая нижняя панель. Лобовое приведение перечисления к байту панели записало бы каждое выделение левой верхней на правую нижнюю панель — без единой ошибки. Каждый панельный вход HotXLS конвертирует перечисление через явный case, так что вызывающим вообще не приходится иметь дело с числовыми кодами. Проверяется и существование панели: правая верхняя существует только при вертикальном разделении, левая нижняя — только при горизонтальном, а правая нижняя — только при обоих. Для панели, которой нет в текущей геометрии разделения или закрепления, SelectAreas возвращает False, а GetSelectedAreas — пустой массив с ActiveAreaIndex равным -1, не создавая ни панели, ни объекта выделения, ни ячейки в книге

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

Где живёт позиция прокрутки каждой панели?

Позиция прокрутки каждой панели размазана по двум записям, потому что четыре панели делят лишь две позиции строк и две позиции столбцов. В классической книге первая видимая строка верхних панелей и первый видимый столбец левых панелей — это Window2.rwTop и Window2.colLeft, а строка нижних панелей и столбец правых — Pane.rwTop и Pane.colLeft. Поэтому ScrollWindow(xlspTopRight, R, C) пишет Window2.rwTop и Pane.colLeft, и установка столбца правой верхней панели двигает и правую нижнюю — ровно как эти двое делят одну горизонтальную полосу прокрутки в Excel. Публичные методы используют нумерацию строк и столбцов с единицы. Отсутствующая панель возвращает False и обнуляет оба выходных параметра запроса, а координата вне диапазона отвергается до того, как сдвинется хоть одна ось. Здесь ничто не зависит от того, как вьюер рисует сетку. Рендер-контрол держит собственные TopRow и LeftCol, как описывает статья про отрисовку книг в собственном VCL-гриде, и это состояние времени выполнения, а не то, что сохраняется

Где живёт каждая ось прокрутки панелей 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) для правой нижней стороны разделения. Оба атрибута могут присутствовать одновременно. HotXLS читает внешний атрибут первым в поля уровня окна, позволяет дочернему pane переопределять только поля уровня панелей и пишет оба обратно раздельно. Схлопывание двух слоёв в один — это ровно тот способ, которым позиция прокрутки верхней или левой панели тихо исчезает при загрузке. Копии листа несут оба слоя в обоих движках. Старые входные точки сохраняют исходное поведение: классические свойства ScrollRow и ScrollColumn, а также XLSX-методы SetPaneScroll и GetPaneScroll с нумерацией от нуля. Сама геометрия закрепления и разделения настраивается настройками уровня листа из статьи про защиту листа, настройку страницы и печать

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;

Что происходит, когда запись Selection повреждена?

Когда запись Selection повреждена, HotXLS держит её как непрозрачные байты, рапортует диагностический код 1304 (xlsDiagnosticSelectionRecordInvalid) и при сохранении записывает исходное тело обратно байт в байт. Прежде чем запись присоединится к группе своей панели, читатель проверяет её по порядку. Байт панели должен быть не больше 3. Записи одной панели должны быть смежными в потоке. 9 фиксированных байтов должны присутствовать. cref должен быть от 1 до 1369, а тело — длиной ровно 9 + cref × 6 байтов. Каждый кусок в группе должен соглашаться по активной ячейке и irefAct, у 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.Add(Format('%s: record $%.4x kept opaque (%s)',
        [D.SheetName, D.RecordId, D.Message]));
  end;
end;

Как выделения переживают вставку строк и столбцов?

Выделения переживают структурные правки, потому что вставка или удаление целых строк или столбцов перемапливает каждую представленную панельную группу в обоих движках, классическом и XLSX, через один общий ремаппер. Пережившие области сохраняют порядок, а активная область — свою идентичность. Если активная область удалена, активной становится первая пережившая преемница, а если за ней ничего нет — последняя пережившая предшественница. Если удалены все области, группа схлопывается в одну ячейку на границе удаления, а активная ячейка, которая больше не попадает внутрь выбранной области, переезжает в её левый верхний угол — индекс и координата никогда не противоречат друг другу. Ограничения намеренные. Невалидные классические группы ремаппер пропускает, а не переписывает в выдуманное выделение, так что их исходные байты всё ещё проходят round-trip. Правка одной панели заменяет записи только этой панели, а остальные оставляет байт в байт. ODS состояния выделения панелей не получает вовсе, потому что в ODF нет эквивалентной структуры представления листа, которая могла бы его нести

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