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

HotXLS на Free Pascal: Unicode, COM-слоты и zlib

HotXLS собирается под Free Pascal и Lazarus на Windows, и порт свёлся к четырём решениям, которые не имеют отношения к синтаксису Object Pascal: держать ядро в режиме DELPHIUNICODE, объявить OLE structured-storage интерфейсы как CORBA-интерфейсы с ручным подсчётом ссылок, заменить Win32 объектные файлы AES Pascal-реализацией и починить цикл inflate, способный принять обрезанный ZIP за полный

Кто переносил зрелую Delphi-библиотеку, знает форму этой работы. Компилятор принимает почти всё с первого прохода. Дальше идёт длинный хвост поведенческих различий, которые чисто компилируются и дают неправильные результаты, и движок таблиц к ним необычно уязвим, потому что трогает кодировку текста, COM structured storage, сжатие и криптографию в одном пути кода

Почему ядро настаивает на DELPHIUNICODE, а не на простом DELPHI?

Потому что формульный движок зависит от String и Char с семантикой UTF-16, а ANSI-альтернатива теряет символы ещё до того, как что-то дойдёт до файла. Соблазнительно собрать ядро в FPC-режиме DELPHI: это переключатель совместимости, за которым тянется большинство портов, и код компилируется. Потом книга с китайскими именами листов или кириллическими подписями проходит туда-обратно через путь вычислений, и символы исчезают к моменту, когда их видит писатель, — без единой ошибки где-либо

Режим не единообразен по всей библиотеке, и это намеренно, а не неряшливо. Байтовый декодер PNG и оверрайды LCL честно нуждаются в ANSI-сигнатурах, потому что имеют дело с байтами и с тем, что им отдаёт widgetset. Те юниты включают отдельный переключатель LX_FPC_ANSI. Два режима в одной библиотеке звучат как code smell, пока не заметишь, что альтернатива — байтовый декодер, трактующий свой ввод как текст

Есть сопутствующая деталь, которая ловит людей позже. DELPHIUNICODE не делает TFormatSettings.DecimalSeparator типа WideChar в рантайме FPC. Ввод, несущий Unicode-разделитель десятичных, надо сначала нормализовать к ASCII-разделителю внутри Unicode-строки, а всякий ввод, чей разделитель не совпал с ожидаемым, должен отвергаться, а не тихо обрезаться на символе, который парсер не распознал

program ExportReport;
{$MODE DELPHI}
uses
  Interfaces,          // обязателен первым: инициализирует LCL widgetset
  SysUtils, lxHandle;  // и слой конверсии UTF-8

var
  Book: TXLSWorkbook;
begin
  Book := TXLSWorkbook.Create(nil);
  try
    Book.LoadFromFile('input.xls');
    Book.Sheets[0].AsString[1, 1] := 'Quarterly summary';
    Book.SaveToFile('output.xls');
  finally
    Book.Free;
  end;
end.

Юнит Interfaces не опционален и обязан идти первым. Он инициализирует LCL widgetset и слой конверсии UTF-8, и HotXLS опирается на оба, как только шрифты, пути файлов или текст пересекают границу RTL и LCL. Консольная программа, пропустившая его, скомпилируется и будет неправильно вести себя на любом не-ASCII пути. Это же причина, почему успешная компиляция доказывает так мало: порт демонстративно работал только когда реальные документы с реальными именами шрифтов и реальными путями сделали полный круг

Классовый VMT — это не COM vtable

Free Pascal не даст вам вручить классовый VMT Windowsу как vtable COM-интерфейса, даже когда объявление выглядит идентично тому, что принимает Delphi. Раскладки различаются так, что вызов уходит в чужой слот, что проявляется крахом где-то, не связанном с местом вызова. Structured storage здесь важен, потому что классический бинарный формат книги — OLE compound file, и читать или писать его — значит реализовать ILockBytes, в который Windows storage API будет звонить

Рабочая схема — CORBA-интерфейс с явно объявленными COM-слотами и ручным управлением AddRef и Release. Это отказ от автоматического подсчёта ссылок для этих типов и принятие ответственности за время жизни — честная сделка для горстки интерфейсов, живущих внутри одного юнита. Конкретная ловушка внутри этой работы — QueryInterface: он обязан вернуть указатель на интерфейс, а не указатель на объект. Оба компилируются. Один из них вручает Windows адрес, чьё первое машинное слово — не vtable

Диаграмма, сравнивающая классовый VMT Free Pascal с vtable COM-интерфейса, которую HotXLS обязан предъявить Windows structured storage API: разные порядки слотов при одном и том же Pascal-объявлении, плюс ловушка QueryInterface, где возврат указателя на объект вместо указателя на интерфейс отправляет вызов ILockBytes в классовый слот и крашит далеко от места вызова
Free Pascal отказывается подавать классовый VMT как COM vtable, поэтому HotXLS объявляет CORBA-интерфейсы с явными COM-слотами и ручными AddRef и Release, а QueryInterface возвращает указатель на интерфейс, который Windows способна разыменовать

FPC-специфичные объявления живут в lxOleInterfaces.inc, рядом с lxAESBackend.inc и lxZlibBackend.inc в каталоге FPC-исходников, так что привязанные к компилятору решения сидят в одном месте, а не раскиданы по движку. Сам формат и то, как библиотека по нему ходит, описаны в чтении OLE2 compound files в Pascal

Ещё одна типовая деталь из того же семейства. LargeInt обязан резолвиться в Int64 в FPC-ветке, а классификация Comp компилятором различается между тулчейнами достаточно, чтобы разрешение перегрузок выбрало другого кандидата. Тестируйте поведение с большими смещениями файловым стримом, а не HGLOBAL-стримом: стрим глобальной памяти Windows сам заворачивается на seek за 4 GiB, так что проходящий там тест не доказывает ничего про вашу арифметику

Что прячет самосогласованная AES-реализация

Win32 объектные файлы AES, которые линкует Delphi-сборка, — это OMF, и линковщик Free Pascal их не переваривает, поэтому FPC-ветка использует Pascal-реализацию AES. Delphi продолжает линковать те же объектные файлы, что и всегда, так что релизный бинарник для существующих клиентов не меняется

Требование верификации — та часть, что стоит унести в любой проект. Зашифровать данные и расшифровать их той же реализацией не доказывает ничего: симметричный алгоритм с неправильным key schedule, неправильным порядком блоков или неправильным сцеплением идеально самосогласован и каждый раз проходит свой же вывод туда-обратно. Ловят это только known-answer векторы, сверяющие расширение ключа, порядок блоков и CBC-сцепление с опубликованными значениями. Отгрузите самосогласованную неправильную реализацию — и симптом появится, когда клиент впервые откроет файл в Excel

У сжатия был дефект другого характера. Pascal-бэкенд inflate может иметь невыполненный вывод после того, как съел весь сжатый ввод, поэтому вызывающий обязан звать его, пока стрим не отрапортует конец. Считать исчерпание ввода концом стрима — значит обрезать последний блок. Хуже, это превращает повреждённый архив в тихо принятый, — ровно тот режим отказа, против которого существует харденинг из валидации записи ZIP end-of-central-directory. Правило: нет прогресса плюс не закончено — это ошибка усечения, никогда не EOF

Две ловушки системы сборки, стоившие реальных часов

Пути поиска LCL обязаны предшествовать wildcard-путям пакетов FPC, иначе юнит Menus из Free Vision затеняет одноимённый юнит LCL, и вы получаете несовпадение контрольной суммы PPU, не говорящее ничего ни о том, ни о другом. Установка Lazarus, которую двигали после установки, тоже может оставить протухшие пути в fpc.cfg, поэтому точки входа сборки задают пути юнитов и бинарников явно, а не наследуют то, что предлагает окружение

Вторая ловушка не имеет отношения к Pascal. Батник .cmd, написанный с LF-концами строк, работает, пока файл не вырастет за размер буфера чтения интерпретатора, после чего call :label падает с заявлением, что метки батника не существует, и отказ проявляется на той программе, которая случайно оказалась за границей. Любой инструмент, переписывающий батник, обязан писать CRLF обратно. И lazbuild --build-all чистит выходной каталог юнитов пакетов перед компиляцией, так что файл опций, припаркованный в этом каталоге, удаляется раньше, чем его прочитают: держите его снаружи и помните, что путь @ резолвится относительно каталога пакета, потому что lazbuild зовёт компилятор оттуда

Карта двух слоёв опасностей за чистой компиляцией HotXLS под Free Pascal: режим DELPHIUNICODE, держащий String и Char в UTF-16, обход LX_FPC_ANSI для байтового декодера PNG и оверрайдов LCL и ловушки сборки — затенение Menus из Free Vision, протухшие пути fpc.cfg, батники только с LF и затирание вывода lazbuild
Первая компиляция доказывает мало: карта режимов решает, какие символы доживут до писателя, а ловушки системы сборки проявляются как несовпадения контрольных сумм, фантомно отсутствующие метки и файлы опций, удалённые раньше, чем их прочитали
// Экспорт грида в Lazarus: TGridToXLS поставляется в Lazarus-пакете,
// поэтому тот же код экспорта DB-grid работает в LCL-приложении
var
  Exporter: TGridToXLS;
begin
  Exporter := TGridToXLS.Create(nil);
  try
    Exporter.DBGrid := GridOrders;
    Exporter.WorksheetName := 'Orders';
    Exporter.ExportHeader := True;
    Exporter.SetColumnsWidth := True;
    Exporter.ExportDBGrid;
    Exporter.SaveAs('orders.xls');
  finally
    Exporter.Free;
  end;
end;

Чего стоит предупреждение компилятора

Free Pascal репортит неинициализированные локальные переменные, которых Delphi не замечает, и запуск FPC-сборки превратил это различие в два реальных дефекта в юните вычислений. Одна функция читала переменную-счётчик, никогда не присвоенную до использования, а другая использовала две координаты в одной ветке до того, как вычисливший их код выполнялся в другой ветке. Под Delphi обе вели себя по тому, что случайно лежало на стеке, — это определение бага, который воспроизводится на одной машине и не воспроизводится на другой

Практический вывод: второй компилятор стоит держать в контуре, даже для продукта, поставляемого прежде всего на первом. Периодическое сканирование классов предупреждений FPC — дешёвый проход статического анализа по Delphi-кодовой базе, и он находит категорию дефектов, до которой ни один тестовый набор надёжно не добирается. Более широкая дисциплина матрицы версий, внутри которой это сидит, описана в межкомпиляторной матрице сборки

Поддержка Free Pascal и Lazarus для Windows поставляется с Delphi spreadsheet-компонентом HotXLS в виде Lazarus-пакета рядом с пакетами Delphi и C++Builder, собираясь из того же дерева исходников, а не из форка. В этом смысл всего упражнения: один движок, четыре тулчейна, и компиляторо-зависимые решения изолированы в include-файлах, которые можно прочитать за один присест