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

HotXLS: сохранение макросов VBA и внешних ссылок в Delphi

Представьте задание, которое почти ничего не делает: открыть месячную книгу, записать в одну ячейку сегодняшнюю дату, сохранить обратно. Прогоните это через сервис достаточно часто — и жалоба всё равно придёт. Макросы пропали или связанные курсы валют теперь читаются как #REF!, и служба эксплуатации убеждена, что их удалил ваш код. Ничего он не удалял. Обычно происходит вот что: книга с макросами уходит под обычным именем .xlsx, и Excel подчиняется правилам типов содержимого ECMA-376: пакет, чей тип содержимого не объявляет VBA, не может загрузить проект VBA независимо от того, лежат ли байты прямо там. Файл не сломался. Его переименовали в состояние, в котором Excel обязан игнорировать его часть

Макросы и внешние ссылки на книги — две вещи, которые автоматизация теряет надёжнее всего, и причина у этого одна. Обе живут вне сетки ячеек, которой на самом деле касается редактирующий код, поэтому код, рассуждающий строками и колонками, потеряет их, ни разу не отдав команды на удаление. HotXLS — нативная библиотека для Delphi и C++Builder, читающая и пишущая XLS и XLSX без установленного Excel, — обращается с обоими активами как с грузом, который несёт намеренно, а не как с данными, которые копируются заодно. Дальше речь о том, что каждому из них нужно от вашего пути сохранения и где заканчиваются гарантии

Почему эти два актива ведут себя при перезаписи по-разному

Проект VBA — это один непрозрачный двоичный блок. В пакете OOXML это файл vbaProject.bin; в устаревшем файле BIFF это хранилище OLE. Потерять его можно ровно двумя способами: писатель не скопировал его в вывод либо вывод получил тип файла, который его запрещает. Любой из этих отказов тотален и беззвучен. Проект либо есть, либо его нет

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

Сравнительная схема блоба проекта VBA и трёх частей внешней ссылки на книгу, которые HotXLS проносит через перезапись в Delphi
Проект VBA переживает перезапись как двоичный груз по принципу всё или ничего, тогда как внешняя ссылка — небольшой граф, чьи цель, имена листов и кэшированные значения могут сохраняться или теряться независимо друг от друга

Проносим проект VBA через перезапись XLSX

На стороне XLSX TXLSXWorkbook сохраняет груз макросов дословно. Свойство VbaProject держит сырые байты vbaProject.bin внутри AnsiString, и пустая строка — это способ модели сказать, что макросов нет. Вокруг него три операции: HasVbaProject отвечает, присутствует ли проект, ClearVbaProject удаляет его намеренно, а LoadVbaProjectFromFile внедряет проект, извлечённый из шаблона. Последний вызов стоит больше, чем кажется. Он позволяет сгенерированным книгам подхватывать стандартный проект макросов, не таща через конвейер полный файл шаблона

Схема потока вызова сохранения в Delphi, где расширение .xlsm выбирает тип содержимого с поддержкой макросов, а .xlsx заставляет Excel молча отказать макросам
HotXLS проносит сырые байты vbaProject.bin через сохранение, а расширение .xlsm — это то, что выбирает требуемый Excel тип содержимого с поддержкой макросов
var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
begin
  Book := TXLSXWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Data');
    Sheet.Cells[1, 1].Value := 'Refreshed ' + DateTimeToStr(Now);

    Book.LoadVbaProjectFromFile('macros\vbaProject.bin');
    if not Book.HasVbaProject then
      raise Exception.Create('VBA payload failed to load');

    // Расширение .xlsm не косметика: оно выбирает
    // тип содержимого с поддержкой макросов внутри пакета.
    Book.SaveAs('monthly-report.xlsm');
  finally
    Book.Free;
  end;
end;

Строка сохранения — то место, где вся проблема и поворачивается. Книга с проектом VBA обязана записываться с семантикой поддержки макросов, и HotXLS применяет её, когда имя цели заканчивается на .xlsm. Дайте вместо этого .xlsx — и Excel откажет макросам, хотя байты физически присутствуют в пакете и прекрасно бы десериализовались. Расширение здесь не украшение; оно выбирает тип содержимого, который сообщает Excel, что проекту VBA дозволено существовать. Чаще всего груз нужно просто пронести. Когда же требуется в него заглянуть, скажем перечислить имена модулей для отчёта аудита, ParsedVBAProject даёт разобранную модель модулей, а VbaProject остаётся исходными нетронутыми байтами

Переиспользование макросов из устаревших книг XLS

Фасад BIFF повторяет этот набор инструментов с одним дополнительным шагом. HasVBAProject прощупывает загруженный файл, SaveVBAProjectToFile выписывает хранилище проекта на диск, а LoadVBAProjectFromFile читает его обратно в другую книгу. Крюк через файл делает частую задачу модернизации простой: поднять макросы из модели образца 2003 года и посадить их в свежесгенерированный вывод XLS, без исходного шаблона во время выполнения

var
  Src, Dst: IXLSWorkbook;   // ссылки на интерфейс: Free вручную не нужен
begin
  Src := TXLSWorkbook.Create;
  if Src.Open('legacy-model.xls') <= 0 then
    raise Exception.Create('Cannot open legacy model');
  if Src.HasVBAProject then
    Src.SaveVBAProjectToFile('extracted-vba.bin');

  Dst := TXLSWorkbook.Create;
  Dst.Sheets.Add.Name := 'Report2026';
  Dst.LoadVBAProjectFromFile('extracted-vba.bin');
  Dst.SaveAs('report-with-macros.xls');
end;

Ловушка здесь — модель памяти, и она обратна классу XLSX. TXLSWorkbook удерживается через интерфейс IXLSWorkbook с подсчётом ссылок, поэтому вручную его освобождать нельзя; TXLSXWorkbook из мира XLSX — обычный объект, который нужно обернуть в try..finally и освободить. Смешайте оба соглашения в одном модуле — и последуют падения из-за двойного освобождения. Ещё одна граница, которую стоит уважать: держите извлечение и внедрение внутри одного формата файла. Хранилище проекта BIFF и vbaProject.bin из OOXML — родственники, но не один и тот же контейнер, и конвейеру, обязанному выдавать макросы в обоих форматах, следует держать отдельный шаблон макросов для каждого

Внешние ссылки: карта выживает, кэшированные значения — нет

Для книг XLSX HotXLS открывает внешние ссылки через коллекцию ExternalLinks. Каждая TXLSXExternalLink несёт Target — путь или URL удалённой книги — плюс список SheetNames с именами листов, на которые она ссылается. И то и другое переживает цикл открытия и сохранения нетронутым, а ссылку можно и построить с нуля:

var
  Link: TXLSXExternalLink;
begin
  Link := Book.ExternalLinks.Add('\\fileserver\finance\fx-rates-2026.xlsx');
  Link.SheetNames.Add('FX');

  if Book.ExternalLinks.Count > 0 then
    Writeln(Format('%d external link(s): delivery requires reachable targets',
      [Book.ExternalLinks.Count]));
end;

Граница проходит на уровень глубже списка целей. HotXLS проводит через обратный цикл карту ссылок, то есть цель и имена листов, но он не разбирает и не переписывает кэшированные значения ячеек, которые OOXML держит в элементе sheetDataSet ссылки. Этот кэш и позволяет Excel показать последнее известное число, когда исходный файл недоступен, а сгенерированная книга уходит без него. Последствие достаётся получателю, а не вам. Откройте такой файл там, где цель недостижима — ноутбук вне VPN или переименованный сетевой ресурс, — и формулы, зависящие от ссылки, разрешатся в #REF! или застынут за приглашением обновить. Отсюда следуют два правила. Не обещайте, что сгенерированная книга покажет свои внешние связанные значения без сети. И читайте ненулевой ExternalLinks.Count как условие доставки, а не как возможность: каждая цель обязана быть достижима оттуда, где файл действительно откроют

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

Что читатель XLS сохраняет побайтово

Для структур, которые он не моделирует, сторона BIFF даёт другой ответ: оставить их ровно такими, какими нашла. Кэши и представления сводных таблиц (семейство записей SX*), определения QueryTable, внешние подключения к данным, пользовательские представления, картинки колонтитулов и записи темы — всё это проходит цикл открытия и сохранения сырыми блоками записей, неразобранными и неизменёнными. Сами внешние ссылки проходят обратный цикл через нижележащие записи EXTERNSHEET и SupBook. Типизированного API создания для них на стороне XLS нет, но существующая ссылка переживает редактирование нетронутой

Побайтовое сохранение — настоящая гарантия с острым краем. Поскольку сохранённую структуру никто не читает, ваши правки не могут её испортить. По той же причине никто её и не обновляет. Вставьте строки через область, на которую указывает сохранённый кэш сводной таблицы или таблица запроса, — и структура удержит исходные координаты, пока данные под ней съезжают. Файл остаётся корректным XML или BIFF; смысл тихо ушёл из соответствия, и никакая ошибка вам об этом не сообщит. Защитимая схема — держать сгенерированные правки на листах, где нет сохранённых структур, и это та же дисциплина, что защищает заблокированные и настроенные под печать листы в нашей статье о защите листов и параметрах страницы

Проверка того файла, который вы действительно записали

Оба режима отказа беззвучны в момент записи, поэтому значимое утверждение делается повторным открытием вывода, а не доверием к коду, который его произвёл. Три проверки покрывают почти всё. Откройте файл заново и убедитесь, что HasVbaProject по-прежнему возвращает true всякий раз, когда макросы ожидались, — это одним тестом ловит и потерянный груз, и неверное расширение. Прочитайте ExternalLinks.Count и сравните его с числом до перезаписи. Затем откройте файл разок в Excel с отключёнными макросами, потому что проверка типов содержимого в Excel строже любой библиотечной, а Excel — та программа, по которой ваши клиенты будут судить о файле

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

Несколько вопросов возникают достаточно часто, чтобы ответить на них прямо. HotXLS никогда не выполняет сохраняемые им макросы: в библиотеке нет среды выполнения VBA, только механика хранения, копирования, извлечения и внедрения проекта как данных. На сервере это свойство безопасности, о котором стоит сказать вслух, поскольку враждебный макрос, проходящий через конвейер, остаётся инертным, пока настольный Excel не откроет файл и пользователь не разрешит содержимое. Превратить .xlsm в .xlsx и сохранить макросы невозможно, и это правило формата, а не ограничение библиотеки: тип содержимого .xlsx объявляет книгу без макросов, поэтому честных исходов только два — остаться в .xlsm либо вызвать ClearVbaProject и отправить файл, в котором макросов действительно нет. Тихое переименование — единственный выбор, который не устроит никого. А когда связанные ячейки после перезаписи показывают #REF!, причина в отсутствующем кэше значений, о котором сказано выше: новый файл несёт цель, но не кэшированные числа, поэтому Excel обязан разрешить источник при открытии, а недостижимый или зависящий от окружения путь ему этого не даёт. Либо гарантируйте достижимость цели, либо запишите вычисленные значения в ячейки перед доставкой и снимите зависимость целиком

Редактирование чужих книг — это по большей части работа по сохранению того, чего вы не писали и не понимаете до конца. Описанные здесь средства обратного цикла для VBA и внешних ссылок поставляются с HotXLS Delphi Component для Delphi и C++Builder вместе со свойствами аудита, позволяющими обнаружить регулируемое содержимое в тот же миг, когда файл пришёл