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

Создание приложения просмотра PDF в Lazarus и Free Pascal с помощью PDFium

Delphi и Lazarus компилируют один и тот же код на Object Pascal, и это внешнее сходство часто вводит в заблуждение при переносе программы просмотра с одной платформы на другую. Компиляторы имеют три важных отличия при работе с PDF: нативный тип string представляет собой UTF-16 в Delphi и UTF-8 в приложении LCL; VCL и LCL — это разные визуальные библиотеки со своими компонентами, диалоговыми окнами и форматами потоковой передачи форм; наконец, скомпилированный в Delphi исполняемый файл ориентирован на Windows, тогда как FPC может быть нацелен на Linux или macOS. Эти различия не проявляются при сборке. Программа просмотра на базе PDFium Component (поставляющая редакции VCL и LCL из единого дерева кода) успешно соберется в Lazarus после замены некоторых имен модулей и добавления нескольких блоков {$IFDEF FPC}. Проблемы возникают позже, при развертывании и работе с реальными файлами, когда проявляются допущения, скрытые в сборке под Delphi

Четыре из этих допущений занимают большую часть времени разработчиков: кодировка текста на границе с интерфейсом, необходимость поддержки двух копий формы, способ сопоставления нативной DLL при запуске и озвучивание текста (TTS) вне Windows после отказа от SAPI. Каждую из них легко учесть заранее, но сложно отлаживать постфактум

Один Pascal, разные кодировки строк

В Delphi нативный тип string представляет собой UTF-16 (начиная с 2009 года). Lazarus и Free Pascal по умолчанию используют кодировку UTF-8 в приложениях LCL. Текстовые API компонента работают с UTF-16 через тип WString, который в FPC сопоставляется с типом WideString. Поэтому любая передача текста между интерфейсом LCL и движком PDF требует конвертации

Преобразования кодировок происходят автоматически при простых операциях присвоения, и чаще всего разработчику не нужно о них думать. Однако две привычки помогут избежать багов. Во-первых, передавайте текст напрямую без посимвольной обработки: код, вырезающий фрагмент поискового запроса по байтовому смещению, корректно работает в Delphi (где один символ Char равен одной единице UTF-16), но исказит многобайтовые символы UTF-8 в LCL. Во-вторых, тестируйте программу на не-ASCII данных с первых дней разработки. Немецкие имена файлов, поисковые запросы на кириллице, символы с диакритикой в метаданных документа — только на таких примерах можно выявить ошибки кодирования, так как в диапазоне ASCII кодировки UTF-8 и UTF-16 совпадают. ASCII-тесты лишь маскируют проблему, которая проявится при первом открытии файла пользователем

Один условный блок вместо ветвления для каждой IDE

После добавления множества директив IFDEF кодовая база начинает выглядеть как два разных проекта в одном репозитории, и возникает искушение разделить проект для каждой IDE. Это неверный шаг, удваивающий стоимость исправления любого бага в будущем. Реальные различия можно свести к одному общему блоку объявлений:

{$IFDEF FPC}
uses
  LCLType, Forms, Graphics, Controls;

type
  WString = WideString;   // component text APIs are UTF-16
  TBytes  = array of Byte;
{$ELSE}
uses
  Winapi.Windows, Vcl.Forms, Vcl.Graphics, Vcl.Controls;
{$ENDIF}

Код ниже этого блока компилируется абсолютно одинаково в обеих IDE. Работа с документами, переключение страниц, вызовы рендеринга — классы TPdf и TPdfView предоставляют идентичный интерфейс в редакциях VCL и LCL, поэтому основная часть кода программы просмотра не зависит от компилятора. Такое разделение — дисциплина разработки, а не хитрость. Общая логика PDF выносится в отдельные модули, не импортирующие специфичные для фреймворков панели или диалоги. Немногочисленные различия (например, диалоги печати и выбора файлов) скрываются за общим интерфейсом, реализуемым отдельно для каждого фреймворка. Блок IFDEF становится единственным местом локализации платформенных различий вместо засорения директивами компилятора десятков модулей

Создавайте форму в коде, а не в двух дизайнерах

Потоковая передача форм — место, где проекты для двух IDE часто дают сбои. Файлы описания форм .dfm и .lfm постепенно расходятся по набору свойств до тех пор, пока поведение двух сборок не начинает различаться по причинам, которые невозможно отследить простым сравнением файлов, так как они имеют разный формат. Динамическое создание формы просмотра в коде полностью решает эту проблему. Общая процедура конструктора версионируется как обычный код и выглядит одинаково на обеих платформах:

procedure TViewerForm.FormCreate(Sender: TObject);
begin
  Pdf := TPdf.Create(Self);

  PdfView := TPdfView.Create(Self);
  PdfView.Parent := Self;
  PdfView.Align := alClient;
  PdfView.Pdf := Pdf;
  PdfView.FitMode := pfmFitWidth;

  if ParamCount > 0 then
  begin
    Pdf.FileName := ParamStr(1);
    Pdf.Active := True;   // opens the document; PageCount valid after this
  end;
end;

Точный порядок присвоения свойств менее важен, чем строка, выполняющая основную связку. Строка PdfView.Pdf := Pdf подключает визуальный элемент управления к компоненту документа, после чего переключение страниц через PageNumber и масштабирование через FitMode работают одинаково под VCL и LCL. Однако стоит знать одну кросс-платформенную особенность: ручное изменение масштаба Zoom сбрасывает режим FitMode в состояние pfmNone на обоих фреймворках. Поэтому если кнопка «по ширине» на панели инструментов должна оставаться активной, вам нужно заново устанавливать режим масштабирования после любого программного изменения масштаба

Нативный бинарный файл, о котором не предупреждала IDE

Компонент оборачивает движок PDFium, поставляемый в виде скомпилированной динамической библиотеки платформы, которая и является причиной большинства сбоев при запуске приложения вне IDE. Важно помнить три правила. Во-первых, разрядность должна совпадать полностью. 32-битное приложение не может загрузить 64-битную библиотеку pdfium. Сообщение ОС («модуль не найден») часто дезориентирует, так как сам файл лежит рядом с исполняемым файлом. Во-вторых, всегда определяйте путь к библиотеке относительно исполняемого файла, а не текущего каталога (working directory), так как запуск из IDE и из проводника различаются именно этой деталью. В-третьих, проверяйте успешность загрузки DLL до открытия первого документа и выводите понятное сообщение с ожидаемым путем и разрядностью. Ошибка «Не найдена 64-битная библиотека PDFium по пути <...>» понятна пользователю, в отличие от «приложение упало при запуске»

Поставляйте соответствующую версию библиотеки вместе с исполняемым файлом. Проект PDFium развивается быстро, и обновление программы с сохранением старой версии DLL на диске вызовет сбои, которые вы не сможете воспроизвести у себя, так как на ваших компьютерах библиотеки обновлены. Относитесь к DLL как к части исполняемого файла с теми же правилами обновления и отката версий

Регистрация компонентов в Lazarus IDE

Динамическое создание компонентов в коде не требует их регистрации в среде разработки, что является наиболее чистым подходом для кросс-платформенного проекта. Если же вам нужны компоненты на панели Lazarus для проектирования форм визуально, установите пакет из модуля регистрации PDFiumLazReg по пути Lib/FPC/PDFiumLaz.lpk. Этот модуль помечен как design-time, так как он использует интерфейсы редакторов свойств IDE, которые не должны компилироваться в конечный исполняемый файл приложения

Нарушение этого правила приведет к тому, что ваше приложение начнет требовать наличия пакетов IDE Lazarus при запуске, из-за чего упадет на любом компьютере пользователя, где среда разработки Lazarus никогда не устанавливалась

Озвучивание текста (TTS) вне Windows

Преобразование текста в речь — функция, кросс-платформенная реализация которой упирается в возможности операционной системы, а не компонента. Стандартная система SAPI, используемая в Windows, доступна только в Windows. Сборка под Lazarus, нацеленная на Windows, сохраняет поддержку SAPI и совместимость с экранными дикторами NVDA, поэтому пользователи не заметят разницы между Delphi и Lazarus сборками

Сборка под Linux или macOS — другое дело. Там SAPI отсутствует, поэтому вывод звука нужно перенаправлять на нативные службы речи платформ, сохраняя логику чтения текста. Это причина, по которой озвучивание текста стоит выносить за общий интерфейс с первых дней разработки: логика анализа порядка чтения и маркеры слов не зависят от платформы, и только модуль воспроизведения звука реализуется отдельно. Статья о создании доступного средства чтения подробно разбирает этот процесс

Контрольный список перед релизом

Приведенный ниже список проверок помогает выявить типичные проблемы кросс-платформенной сборки. Откройте документ, путь к которому содержит не-ASCII символы. Выполните поиск слова с символами кириллицы или диакритики и убедитесь, что совпадения подсвечиваются верно. Проверьте прокрутку колесом мыши, выделение мышью и клавиатурную навигацию на всех целевых ОС, так как обработка фокуса и колеса мыши наиболее зависимы от конкретных виджетов LCL. Проверьте рендеринг при масштабировании интерфейса 100%, 150% и 200%. Наконец, запустите скомпилированное приложение на чистом компьютере без установленной IDE — только так можно проверить правильность поиска DLL при развертывании

Скорость рендеринга одинакова для обеих редакций, поэтому логика кэширования из статьи о кэше рендеринга и масштабировании применима к LCL-программе без изменений

Все это не делает редакцию LCL менее функциональной. Базовый API идентичен на обеих платформах: классы TPdf, TPdfView, рендеринг, формы, извлечение текста и функции доступности ведут себя одинаково независимо от компилятора. Все различия продиктованы исключительно возможностями ОС: SAPI работает только на Windows, диалоги соответствуют стилю фреймворков, а разрядность DLL должна совпадать с разрядностью процесса. Настройте правильную кодировку, динамическое создание форм и поиск DLL, а остальное компилятор сделает за вас

Описанные редакции VCL и LCL поставляются в составе единого компонента PDFium Component с исходным кодом и одинаковым публичным API для Delphi, C++Builder и Lazarus/FPC