Инструмент сверки находит 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 областей
На пишущей стороне у панельно-первой перегрузки 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, и ни один из вызовов не создаёт по пути ни панели, ни выделения, ни ячейки
Лист вдобавок хранит старую двухаргументную 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;
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