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

Конфигурация PDFium: когда Brotli беззвучно меняет Skia

В PDFium Component для Delphi включение BrotliEnabled или IsolatePerDocument в TPdfLibraryConfiguration раньше безо всякой ошибки переключало поставляемую сборку Skia на рендерер AGG, потому что обе опции поднимают FPDF_LIBRARY_CONFIG до версии, где PDFium читает m_RendererType буквально. С v3.123.0 рендерер по умолчанию остаётся собственным дефолтом DLL, а с v3.125.0 запрос Skia или Fontations, которого DLL не может исполнить, поднимает ловимый EPdfError вместо убийства процесса

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

Как FPDF_LIBRARY_CONFIG решает, какой рендерер использует PDFium?

FPDF_InitLibraryWithConfig советуется с m_RendererType, только когда поле Version структуры — 4 или выше, и с той версии использует значение ровно как записано. Ниже версии 4 PDFium игнорирует поле и берёт дефолт сборки — Skia в сборках, скомпилированных с PDF_USE_SKIA, и AGG во всех прочих

Каждое следующее поле следует тому же образцу. Структура росла по одной возможности за раз, и каждая возможность приходила вместе с новым номером версии. PDFium Component строит нативную структуру в LoadLibrary из вашего TPdfLibraryConfiguration и поднимает версию лишь настолько, насколько требуют выставленные опции

Версия структурыДобавляемое полеЗадаётся
2m_pIsolate, m_v8EmbedderSlotПишется всегда; V8Isolate, V8EmbedderSlot
3m_pPlatformV8Platform не nil
4m_RendererTypeRenderer отличен от prpDefault
5m_FontLibraryTypeFontBackend отличен от pfbpDefault
6m_BrotliEnabledBrotliEnabled = True
7m_IsolatePerDocumentIsolatePerDocument = True

Ловушка — в двух последних строках. Версии кумулятивны: структура версии 6 — это ещё и структура версии 4 и версии 5, так что PDFium читает m_RendererType и m_FontLibraryType, хотя вы просили один лишь Brotli. Что бы ни сидело в тех двух полях в тот момент, то и станет рендерером и шрифтовым бэкендом — задумывали вы их выбрать или нет

Лестница версий FPDF_LIBRARY_CONFIG в PDFium Component от версии 2 до версии 7, показывающая, какая опция TPdfLibraryConfiguration добавляет m_RendererType, m_FontLibraryType, m_BrotliEnabled и m_IsolatePerDocument, и почему кумулятивные версии делают обнулённое поле рендерера нарочитым выбором AGG, а не незаданным значением на любой сборке
Каждая опция поднимает версию структуры, и всякое более раннее поле остаётся живым, так что ноль в m_RendererType приходит к PDFium явным запросом AGG

Почему включение Brotli переключало рендерер на AGG?

До v3.123.0 PDFium Component писал FPDF_RENDERERTYPE_AGG в m_RendererType для prpDefault, так что любая конфигурация, дотолкавшая структуру до версии 6 или 7, навязывала AGG сборке со Skia. Рантаймы pdfium.dll и pdfium.v8.dll, поставляемые с компонентом, — сборки Skia, так что доставалось дефолтному развёртыванию, а не какому-то экзотическому

Отображение выглядело безобидно, когда писалось. На версии 2 или 3 поле не читается никогда, так что prpDefault и вправду значил «как решит DLL». В момент, когда в картину входили BrotliEnabled (версия 6) или IsolatePerDocument (версия 7), тот же код превращал «без предпочтений» в явный запрос AGG. Ничего не падало. PDFium инициализировался нормально, рендерил каждую страницу и не возвращал кода ошибки, потому что с его точки зрения звонящий просил AGG и получал AGG

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

  • Конфигурация по умолчанию: хэш 502D77C3711B4ACF
  • BrotliEnabled = True при Renderer, оставленном на prpDefault: хэш F75B5EB4728ADE87
  • Явный prpAgg: хэш F75B5EB4728ADE87, идентичен прогону с Brotli

Фикс в v3.123.0 — публичная функция PdfNativeRendererType, разрешающая TPdfRendererPreference в значение, записываемое в m_RendererType. prpAgg и prpSkia отображаются один в один. prpDefault теперь отображается в Skia, когда загруженная DLL экспортирует FPDF_RenderPageSkia, и в AGG в противном случае. Тот экспорт компилируется под тем же условием PDF_USE_SKIA, что и сам дефолт Skia, что делает его единственным свойством сборки, наблюдаемым снаружи DLL. После фикса конфигурация с Brotli даёт тот же хэш, что и дефолтная

Сравнение пиксельных хэшей в PDFium Component, показывающее хэш рендера дефолтной Skia 502D77C3711B4ACF, конфигурацию BrotliEnabled до v3.123.0, совпавшую с явным прогоном prpAgg с хэшем F75B5EB4728ADE87, и починенную обёртку, разрешающую prpDefault через экспорт FPDF_RenderPageSkia назад к исходному Skia-хэшу
Пиксельный хэш ловит то, что прячут скриншоты: включение Brotli рендерило каждую страницу через AGG, а починенный дефолт теперь совпадает с нетронутой конфигурацией

Шрифтовой бэкенд той же беды не знал. m_FontLibraryType читается с версии 5, а его нулевое значение, FPDF_FONTBACKENDTYPE_FREETYPE, — заодно и дефолт PDFium, когда поле не читается вовсе. Запись FreeType для pfbpDefault потому воспроизводит нативный дефолт в точности. Нулевые значения не всегда неверны — они просто никогда не бывают верны автоматически

С v3.123.0 и новее стартовый код, который вы и так написали бы, теперь делает то, что говорит:

uses
  PDFium;

procedure ConfigurePdfiumAtStartup;
var
  Config: TPdfLibraryConfiguration;
begin
  // Должно бежать раньше, чем что-либо загрузит нативную библиотеку
  Config := TPdfLibraryConfiguration.Default;
  Config.BrotliEnabled := True;   // поднимает FPDF_LIBRARY_CONFIG до версии 6
  // Renderer остаётся prpDefault: разрешается в Skia на сборках с экспортом
  // FPDF_RenderPageSkia и в AGG на сборках только с AGG
  SetLength(Config.UserFontPaths, 1);
  Config.UserFontPaths[0] := 'C:\ProgramData\MyApp\Fonts';
  ConfigurePdfLibrary(Config);
end;

Помните: BrotliEnabled делает потоки /BrotliDecode из PDF 2.0 декодируемыми, лишь когда сама DLL собрана с PDF_ENABLE_BROTLI. Флаг — это просьба, и на сборке без поддержки Brotli он ничего не делает. TPdfLibraryConfiguration.Hardened — то же, что Default, кроме AllowMachineTime = False, что отрезает документному JavaScript доступ к настоящим часам; разумная стартовая точка для серверной обработки недоверенных файлов

Что происходит, когда вы просите бэкенд, которого в DLL нет?

PDFium не возвращает ошибки для отсутствующего в сборке рендерера или шрифтового бэкенда: FPDF_InitLibraryWithConfig проваливает нативный CHECK, что на Windows всплывает исключением-точкой останова и без структурного обработчика исключений вокруг вызова терминирует процесс. Заголовок прямо предупреждает, что неподдерживаемое значение «will similarly fail with an immediate crash»

Два конкретных случая — сборка только с AGG, получающая FPDF_RENDERERTYPE_SKIA, и сборка без Fontations, получающая FPDF_FONTBACKENDTYPE_FONTATIONS. Поставляемый Skia-рантайм из второй группы: рендерит через Skia, но шрифты берёт из FreeType. Запрос prpSkia вместе с pfbpFontations против него давал External exception 80000003 на стороне Delphi. Когда отладчик или обработчик исключений случается это поймать, положение всё равно невосполнимо:

  • PDFium остаётся наполовину инициализированным
  • Конфигурация процесса уже опечатана, так что ConfigurePdfLibrary отказывает исправленной конфигурации
  • Повтор с другой конфигурацией в том же процессе уже невозможен

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

Как PDFium Component предварительно проверяет Skia и Fontations

С v3.125.0 LoadLibrary валидирует конфигурацию после привязки экспортов DLL и до вызова FPDF_InitLibraryWithConfig и превращает неподдерживаемый рендерер или шрифтовой бэкенд в EPdfError с сообщением, называющим провинившуюся настройку и альтернативы. DLL выгружается, конфигурация распечатывается, так что звонящий может выбрать другие настройки и загрузиться снова

Само решение живёт в чистой функции PdfLibraryConfigurationSupportError, берущей конфигурацию плюс два Boolean, описывающих сборку, и возвращающей пустую строку, когда комбинация безопасна. Поскольку она не трогает нативное состояние, звать её из собственных тестов можно с любой комбинацией возможностей. Внутри LoadLibrary два Boolean приходят из разных сортов улик, и доверия они заслуживают разного:

  • Skia детектируется по наличию экспорта FPDF_RenderPageSkia — той же улики, которой пользуется PdfNativeRendererType. Экспорт и Skia-рендерер компилируются под одним условием, так что проверка точна
  • Fontations собственного экспорта не имеет. Единственный след, который он оставляет, — Rust-крейты шрифтов, втянутые в бинарник, так что PDFium Component сканирует файл загруженной библиотеки на имена крейтов skrifa и read-fonts (также read_fonts). Сканирование идёт лишь при запросе pfbpFontations, а нечитаемый файл считается «Fontations нет»

Проверка Fontations — эвристика, и ошибиться она может в одну сторону: сборка с Fontations, ободранная от всех этих строк, была бы отвергнута, хотя могла бы сработать. Тот размен сделан нарочно. Ложное отвержение стоит вам ловимого исключения и фоллбэка на FreeType. Ложное принятие стоит процесса

Распечатывание важно не меньше проверки. LoadLibrary опечатывает конфигурацию в самом начале загрузки, так что без сброса отказ в возможности оставлял бы ConfigurePdfLibrary отвечающим на каждый повтор EPdfError «PDFium library configuration is already sealed». Путь отказа сперва зовёт UnloadLibrary; его вызов FPDF_DestroyLibrary в тот момент безопасен, потому что PDFium ещё не инициализирован и возвращается немедленно. Прочие отказы загрузки — отсутствующая DLL, несовпадение архитектуры — печать хранят, так что циклу повторов придётся различать то и другое:

uses
  SysUtils, PDFium;

function StartPdfiumPreferringSkia: TPdfRendererPreference;
var
  Config: TPdfLibraryConfiguration;
begin
  Config := TPdfLibraryConfiguration.Default;
  Config.Renderer := prpSkia;
  ConfigurePdfLibrary(Config);
  try
    PDFium.LoadLibrary;   // с квалификацией юнита: Windows.LoadLibrary носит то же имя
    Result := prpSkia;
  except
    on E: EPdfError do
    begin
      // Отказ в возможности выгружает DLL и распечатывает конфигурацию.
      // DLL, вовсе не загрузившаяся, остаётся запечатанной: повтор не поможет
      if PdfLibraryConfigurationSealed then
        raise;
      Config.Renderer := prpAgg;
      ConfigurePdfLibrary(Config);
      PDFium.LoadLibrary;
      Result := prpAgg;
    end;
  end;
end;

Заметьте явный PDFium.LoadLibrary. В юните, пользующем также Windows или Winapi.Windows, неквалифицированный LoadLibrary разрешается в тот юнит, что стоит последним в uses; когда это Win32-функция, беспараметровый вызов не компилируется с ошибкой счёта аргументов, не говорящей о PDFium ничего

Схема precheck-потока LoadLibrary в PDFium Component, где ConfigurePdfLibrary запечатывает конфигурацию, проверка возможностей тестирует экспорт FPDF_RenderPageSkia и строковые улики skrifa, неподдерживаемый запрос поднимает ловимый EPdfError и распечатывает для повтора, а DLL, так и не загрузившаяся, хранит PdfLibraryConfigurationSealed истинным
Валидация бежит после привязки экспортов и до инициализации, так что отсутствующий бэкенд падает как ловимый EPdfError, а не нативный CHECK, убивающий процесс

Валидация, что случается ещё раньше

ConfigurePdfLibrary отвергает некоторые комбинации ещё до всякой DLL — все с EPdfError. Явный FontBackend, включая pfbpFreeType, требует Renderer = prpSkia, потому что шрифтовым бэкендом PDFium советуется только при рендерере Skia. IsolatePerDocument требует nil в V8Isolate, поскольку PDFium сам создаёт изолят на документ и проваливает нативный CHECK, если вручить ещё один. Пустые строки в UserFontPaths отвергаются. И любой вызов после первой попытки загрузки падает с «PDFium library configuration is already sealed»

У того последнего правила есть практическое следствие: сперва прозондировать DLL и настроить потом нельзя. GetSkiaRenderCapabilities, V8FeaturesAvailable, открытие документа и большинство прочих входов зовут LoadLibrary внутренне, что запечатывает конфигурацию на месте. Поздний вызов UnloadLibrary её тоже не переоткроет. Настройте, затем загрузите, затем спрашивайте — ровно тот порядок, которому должна следовать диагностическая подпрограмма:

uses
  SysUtils, PDFium, FPdfView;

function DescribePdfiumState: string;
var
  Config: TPdfLibraryConfiguration;
  Renderer: string;
begin
  Config := GetPdfLibraryConfiguration;   // копия, осматривать безопасно
  if not PDFium.Loaded then
  begin
    if PdfLibraryConfigurationSealed then
      Exit('PDFium failed to load; configuration is sealed');
    Exit('PDFium not loaded; configuration can still change');
  end;
  // То же разрешение, что LoadLibrary применил, строя FPDF_LIBRARY_CONFIG
  if PdfNativeRendererType(Config.Renderer,
    GetSkiaRenderCapabilities.PageRender) = FPDF_RENDERERTYPE_SKIA then
    Renderer := 'Skia'
  else
    Renderer := 'AGG';
  Result := Format('Renderer=%s Brotli=%s IsolatePerDocument=%s',
    [Renderer, BoolToStr(Config.BrotliEnabled, True),
     BoolToStr(Config.IsolatePerDocument, True)]);
end;

Залогировать ту строку раз при старте дёшево, и это первое, что вам понадобится в тикете поддержки со словами «на сервере текст выглядит иначе». PDFium.Loaded квалифицирован по той же причине, что и LoadLibrary: внутри метода формы или компонента голый Loaded привяжется к TComponent.Loaded

Два способа, какими ломается версионированная C-структура конфигурации

Всякая версионированная структура конфигурации — FPDF_LIBRARY_CONFIG, Win32-запись с cbSize или ABI плагина — ломается двумя симметричными способами, и обёртка обязана огораживаться от обоих. Первый — заполнить поле, оставив версию слишком низкой; второй — поднять версию, оставив поле на нулевом значении, которое библиотека прочтёт как нарочитый выбор

  1. Поле задано, версия занижена. Впишите m_BrotliEnabled = 1 в структуру версии 2 — PDFium на неё и не взглянет. Вызов успехен, потоки Brotli остаются недекодируемыми. Защита — выводить версию из реально используемых полей, что и делает LoadLibrary, а не зашивать её константой
  2. Версия достаточна, нулевое поле значит нечто. Поднимите версию до 6 — и всякое поле вплоть до версии 6 теперь живо. FillChar обнуляет m_RendererType в FPDF_RENDERERTYPE_AGG, а это настоящий рендерер, не «не задано». Защита — писать всякое поле, покрываемое выбранной версией, нарочитым значением и разрешать «дефолт» против фактической сборки, а не предполагать его

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

Шпаргалка: конфигурация библиотеки PDFium Component

  • Зовите ConfigurePdfLibrary однажды, раньше, чем что-либо загрузит DLL; любой запрос возможностей или загрузка документа запечатывают её
  • Обновляйтесь до v3.123.0 и новее, если выставляете BrotliEnabled или IsolatePerDocument и ждёте вывода Skia от поставляемых рантаймов
  • Оставляйте Renderer на prpDefault, если не нужен конкретный растеризатор; он теперь разрешается в дефолт сборки на всякой версии структуры
  • Пользуйтесь PdfNativeRendererType с GetSkiaRenderCapabilities.PageRender, чтобы логировать активный рендерер
  • Ждите EPdfError, а не краха, за prpSkia на DLL только с AGG или pfbpFontations на DLL без Fontations в v3.125.0 и новее
  • После отказа в возможности PdfLibraryConfigurationSealed — False, и можно перенастроить; после сорванной загрузки DLL флаг хранит True
  • Считайте детекцию Fontations эвристикой и держите фоллбэк на FreeType
  • Пишите PDFium.LoadLibrary и PDFium.Loaded с именем юнита, чтобы избежать коллизий имён с Win32 и TComponent

Если DLL падает раньше, чем конфигурация вообще вступает в игру, начните со статьи о диагностике отказов загрузки PDFium DLL в Delphi, а о том, как компонент находит правильный бинарник на каждой платформе, — загрузка нативной библиотеки PDFium на любой цели. Когда рендерер улажен, тактики рендер-кэша и плавного зума расскажут, как держать рендеринг страниц быстрым во вьюере

PDFium Component оборачивает движок PDFium для Delphi и C++Builder с такими вот проверками конфигурации, чтобы нативная инициализация падала Pascal-исключением, которое можно обработать, а не выходом из процесса. Детали продукта и загрузки — на странице PDFium Component for Delphi product page