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

HotXLS: merged cells and layout-driven report templates

Пройдите циклом по ячейкам свежеоткрытого шаблона отчёта, и объединённый заголовок поведёт себя как утечка. Вы читаете A1 и получаете «Quarterly Statement»; вы читаете диапазон от B1 до F1, который визуально находится под тем же баннером, и получаете пустоту. Записываете значение в C1, чтобы подправить заголовок, и оно никогда не появляется на экране. Сетка не потеряла ваши данные. Она делает именно то, что означает объединение: и в XLS, и в XLSX объединённый прямоугольник отображает содержимое одной ячейки — верхнего левого якоря — и обращается с остальными как с накрытым пространством, которое хранит значения, но никогда их не показывает. Пользователи Excel усваивают это методом проб и ошибок. Генератору отчётов приходится закодировать это как правило, потому что в сгенерированном коде симптом — это пустая область без исключения, до которого можно было бы докопаться. HotXLS, нативная библиотека Object Pascal, читающая и записывающая оба формата Excel из Delphi и C++Builder, раскрывает таблицу объединений достаточно явно, чтобы можно было программировать по этому правилу, а не заново открывать его через тикет в поддержку

Одно значение, один якорь

Объединение — это инструкция отображения, наложенная поверх сетки, которая не меняет форму. Каждая накрытая ячейка по-прежнему существует в файле как собственный слот; запись объединения лишь говорит потребителю нарисовать содержимое якоря по всему прямоугольнику. Это различие определяет три поведения, которые стоит усвоить до написания любого кода макета. Чтение накрытой ячейки возвращает её собственное хранимое значение, которое для построенного вами баннера обычно пусто, поэтому любой код, проверяющий объединённый заголовок, должен разрешить и прочитать именно якорь. Запись в накрытую ячейку успешно проходит на уровне файла и нигде не отображается — это и есть ловушка невидимого заголовка из начала статьи. А снятие объединения с региона обнажает всё, что там пряталось всё это время, так что случайное значение, записанное в накрытое пространство, превращается в видимый дефект в тот день, когда кто-то распустит объединение

Диаграмма объединённого баннера HotXLS: покрытые ячейки хранят свои слоты, а чтения разрешаются в якорь A1 в таблицах Delphi
HotXLS держит каждую накрытую ячейку как настоящий слот и перерисовывает только якорь, поэтому чтения разрешаются через A1, а записи в накрытое пространство остаются невидимыми до снятия объединения

На стороне XLSX эта таблица — полноценный объект. Sheet.MergedCells несёт Add('A1:C1'), FindAt(Row, Col), DeleteAt и Items, а вызов, к которому обращаются чаще всего, — это FindAt: передайте ему любую координату, и он вернёт объединённую область, накрывающую эту ячейку, или nil, если ячейка стоит сама по себе. Именно этот единственный запрос лежит в основе обеих половин правильной обработки объединений — безопасного чтения и защиты от записи, — и обе появятся ниже

Два фасада, два идиомы объединения

HotXLS хранит классический движок BIFF8 .xls и движок OOXML .xlsx как отдельные объектные модели, и они по-разному записывают объединение, потому что происходят из разных традиций. Фасад XLS следует идиоме Excel COM: вы берёте диапазон из индексированного свойства с двумя аргументами и вызываете Merge с OleVariant, чьё значение решает, какая геометрия у вас в итоге получится

var
  Book: IXLSWorkbook;   // со счётчиком ссылок интерфейса: ручной Free не нужен
  Sh: IXLSWorksheet;
begin
  Book := TXLSWorkbook.Create;
  Sh := Book.Sheets[1];                 // коллекция листов XLS нумеруется с 1
  Sh.Range['A1', 'F1'].Merge(False);    // False = один объединённый блок
  Sh.Cells.Item[1, 1].Value := 'Quarterly Statement';
  Sh.Range['A3', 'F4'].Merge(True);     // True = объединение построчно: одно объединение на строку
  Book.SaveAs('layout.xls');
end;

Именно с аргументом Merge чаще всего и ошибаются. На двухстрочном диапазоне Merge(True) создаёт два независимых однострочных объединения — это эквивалент «Merge Across» в Excel, и именно это нужно для стопки полос заголовка, строки которой должны оставаться разделяемыми. Merge(False) сплавляет весь прямоугольник в единый блок. Диапазон также сообщает MergeCells как флаг состояния, возвращает содержащую область через MergeArea и распускает себя через Unmerge. Фасад XLSX предоставляет те же операции под другими именами: Sheet.MergeCells(Row1, Col1, Row2, Col2) принимает целочисленные границы, TXLSXRange.Merge принимает эквивалентный вариант Across, а коллекция MergedCells хранит результат

Шаблон, который растёт вместе со своими данными

Настоящий шаблон отчёта — это не фиксированная сетка. Заголовок и итоги зафиксированы, но раздел деталей между ними растягивается до того размера, который вернёт запрос. Устойчивый паттерн — держать в шаблоне одну полностью оформленную строку деталей, клонировать её по одному разу на запись, а затем открывать зазор перед блоком итогов, чтобы всё, что закреплено ниже, сдвигалось вниз без потери форматирования

Растущий шаблон отчёта HotXLS в Delphi: стилизованная строка деталей клонируется на каждую запись, и InsertRows открывает зазор, так что блок итогов съезжает вниз с целыми объединениями
Клонирование оформленной строки деталей несёт её стили и формулы в каждую копию, а InsertRows затем сдвигает полосу итогов вниз, сохраняя объединения и форматы
Sheet.Range['A1:F1'].Merge;
Sheet.Cells[1, 1].Value := 'INVOICE #2026-0611';    // значение уходит в якорь, A1
Sheet.RowHeight[1] := 28;
TitleFont := Book.Fonts.Add('Calibri', 16, True, False);
Sheet.Cells[1, 1].FontIndex := TitleFont + 1;        // индекс в пуле с 0, на стороне ячейки — с 1

// строка 5 — оформленная строка-шаблон деталей
for I := 0 to ItemCount - 1 do
  Sheet.CopyRange(5, 1, 5, 6, 6 + I, 1);             // стили и формулы переносятся вместе с ней

// открываем зазор над блоком итогов; содержимое ниже сдвигается вниз
Sheet.InsertRows(6 + ItemCount, 1);
Sheet.Range['A1:F1'].SetBorders(xlsxEdgeOutline, xlsxBorderMedium);

Две строки заслуживают повторного взгляда. Присваивание шрифта несёт ошибку смещения на единицу, которая кусается незаметно: Fonts.Add возвращает позицию в пуле с отсчётом от 0, тогда как ячейка хранит ссылку на шрифт с отсчётом от 1, где 0 означает шрифт по умолчанию, так что пропуск + 1 ничего не вызывает — он просто оформляет ваш заголовок неправильным шрифтом. Другая строка — CopyRange, которая переносит форматирование и формулы вместе со значениями. Именно ради этого клонируют вручную построенную строку шаблона, а не воссоздают её внешний вид в коде. Дизайнер владеет внешним видом один раз, в шаблоне; генератор лишь заливает данные в его копии

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

Что перемещает InsertRows, а что нет

Паттерн «растущего шаблона» работает только потому, что InsertRows в XLSX — это структурная правка, а не перетасовка ячеек. Открывая зазор, она перемещает объединённые области, высоты строк, гиперссылки, комментарии, закреплённые области, диапазоны автофильтра, условное форматирование, проверку данных, таблицы, именованные диапазоны, якоря изображений и якоря диаграмм, лежащие ниже точки вставки, — а не только значения ячеек. Именно это позволяет блоку итогов оказаться на новой строке с целыми объединениями и числовыми форматами, а не ободранным

Вокруг двух её задокументированных ограничений и нужно строить архитектуру. Корректировка формул ограничена редактируемым листом: ссылки внутри этого листа переписываются, и формула на другом листе, указывающая в сдвинутую область, тоже переписывается, но корректировка следует только за ссылками, нацеленными на редактируемый лист, так что любая схема межкнижных ссылок заслуживает отдельного аудита, а не слепого доверия. Второе ограничение острее, и оно на стороне XLS. Сводные таблицы переживают циклы открытия-сохранения как сырые сохранённые записи, а не как смоделированные объекты, которые HotXLS может перемещать, поэтому вставка строк не переносит след сводной таблицы. Любой шаблон, который вы строите для формата .xls, должен держать свои области сводных таблиц подальше от любой растущей полосы

Отказ от записи данных в пространство макета

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

// отказываем в записи данных деталей в объединённую область макета
if Sheet.MergedCells.FindAt(Row, 1) <> nil then
  raise Exception.CreateFmt('row %d overlaps a merged layout region', [Row]);
Sheet.Cells[Row, 1].Value := Detail.Description;

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

Как объединения деградируют при экспорте

Объединение — понятие уровня книги, и каждый текстовый формат экспорта учитывает его в разной степени. Знание этих трёх видов поведения заранее экономит цикл QA. Экспорт в HTML воспроизводит объединения точно, выдавая colspan и rowspan в единой таблице, так что отчёт для браузера сохраняет свой полосатый вид. Экспорт в RTF вообще не растягивает столбцы: текст якоря попадает в собственную ячейку, а оставшаяся ширина объединения выходит как пустые ячейки, из-за чего широкий заголовок визуально прижимается влево в текстовом процессоре. У CSV вообще нет понятия объединения, поэтому значение якоря занимает одно поле, а каждая накрытая ячейка выводится как пустое поле. Вывод для книги, которая также питает экспорт с разделителями, — держать всё несущее содержимое вне геометрии объединений; статья об экспорте в CSV, TSV и HTML подробно разбирает каждый формат

Объединённый заголовок HotXLS, экспортированный из Delphi: в HTML с colspan и rowspan, в RTF без span'ов и в CSV как расплющенные поля
Один и тот же объединённый заголовок переживает HTML-экспорт через colspan и rowspan, деградирует в RTF до одинокой ячейки, прибитой влево, и сплющивается в значение плюс пустые поля в CSV

Одна успокаивающая деталь для тех, кто взвешивает это против размера файла: объединения почти ничего не стоят в масштабе отчёта. Таблица объединений крошечная по сравнению с данными ячеек, а чтение накрытой ячейки по-прежнему идёт через FindAt, а не через сканирование. Давление на производительность в больших книгах приходит из других мест, в основном из роста пула стилей и памяти, которую держит путь сохранения, — этим напрямую занимается статья о производительности больших книг. Оба API объединения, операции структурного редактирования и демонстрационные шаблоны поставляются вместе с HotXLS Delphi Component