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

PDFium library config: когато Brotli тихо сменя Skia

В PDFium Component за Delphi включването на BrotliEnabled или IsolatePerDocument в TPdfLibraryConfiguration сменяше вградения Skia билд с AGG рендерера без никаква грешка, защото и двете опции вдигат FPDF_LIBRARY_CONFIG до версия, в която PDFium чете m_RendererType буквално. От v3.123.0 насам default рендерерът си остава собственият default на DLL-а, а от v3.125.0 насам заявка за Skia или Fontations, която DLL-ът не може да уважи, вдига прихващаем EPdfError вместо да убие процеса

Нито единият бъг не се обяви. Първият даваше страници, които изглеждаха наред — просто рендерирани от друг растеризатор, с малко по-различно anti-aliasing и ръбове на текста от билда, който сте доставили и тествали. Вторият се обяви, и то гръмко, като сваля host процеса отвътре, от native инициализацията. И двамата идват от едно място: версионирана C структура, чиито полета се броят чак когато номерът на версия каже, че се броят, и чиито нулеви стойности не са „неизпаднати“, а истински избори

Как FPDF_LIBRARY_CONFIG решава кой рендерер ползва PDFium?

FPDF_InitLibraryWithConfig поглежда m_RendererType само когато полето Version на структурата е 4 или по-високо, и от тази версия нататък ползва стойността точно както е написана. Под версия 4 PDFium игнорира полето и взима default-а на билда — Skia в билдове, компилирани с PDF_USE_SKIA, и AGG навсякъде другаде

Всяко следващо поле следва същия модел. Структурата растеше по една възможност наведнъж, и всяка възможност идваше заедно с нов номер на версия. PDFium Component строи native структурата в 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. Това, което стои в тези две полета в момента, става рендерерът и font backend-ът — независимо дали сте искали да ги изберете

Стълбица на версиите на FPDF_LIBRARY_CONFIG в PDFium Component от версия 2 до версия 7, показваща коя опция в TPdfLibraryConfiguration добавя m_RendererType, m_FontLibraryType, m_BrotliEnabled и m_IsolatePerDocument, и защо кумулативните версии правят нулирания renderer полето съзнателен AGG избор, а не незададена стойност на всеки билд
Всяка опция вдига версията на структурата, а всяко по-рано поле остава живо, така че нулата в m_RendererType пристига при PDFium като изрична AGG заявка

Защо включването на Brotli смени рендерера с AGG?

Преди v3.123.0 PDFium Component записваше FPDF_RENDERERTYPE_AGG в m_RendererType за prpDefault, така че всяка конфигурация, бутаща структурата до версия 6 или 7, принуждаваше AGG върху Skia билд. Runtime-ите pdfium.dll и pdfium.v8.dll, които се доставят с компонента, са Skia билдове, така че това улучваше default deployment-а, а не някой екзотичен

Мапването изглеждаше безобидно, когато го е писано. При версия 2 или 3 полето никога не се чете, така че prpDefault реално значеше „каквото прави DLL-ът“. В момента, в който BrotliEnabled (версия 6) или IsolatePerDocument (версия 7) влязоха в картината, същият код превръщаше „нямам предпочитание“ в изрична AGG заявка. Нищо не се провали. PDFium се инициализира нормално, рендерира всяка страница и не върна никакъв код за грешка, защото от негова гледна точка извикващият е поискал AGG и е получил AGG

Pixel hash прави смяната видима там, където екранните снимки не. Рендерирането на първата страница на един и същ примерен документ под три конфигурации даде:

  • Default конфигурация: hash 502D77C3711B4ACF
  • BrotliEnabled = True с Renderer оставен на prpDefault: hash F75B5EB4728ADE87
  • Изричен prpAgg: hash F75B5EB4728ADE87, идентичен с Brotli прогона

Поправката в v3.123.0 е публичната функция PdfNativeRendererType, която разрешава TPdfRendererPreference до стойността, записвана в m_RendererType. prpAgg и prpSkia мапват един към един. prpDefault вече мапва към Skia, когато заредения DLL експортира FPDF_RenderPageSkia, и към AGG в останалите случаи. Този export се компилира под същото условие PDF_USE_SKIA като самия Skia default, което го прави единственото свойство на билда, наблюдаемо отвън на DLL-а. След поправката Brotli конфигурацията дава същия hash като default-ната

Сравнение на pixel hash в PDFium Component, показващо hash 502D77C3711B4ACF на default Skia рендериране, конфигурацията BrotliEnabled преди v3.123.0, съвпадаща с изричен prpAgg прогон с hash F75B5EB4728ADE87, и поправения wrapper, разрешаващ prpDefault през export-а FPDF_RenderPageSkia обратно към оригиналния Skia hash
Pixel hash хваща това, което екранните снимки крият: включеният Brotli рендерираше всяка страница с AGG, а поправеният default вече съвпада с непипнатата конфигурация

Font backend-ът никога не имаше същия проблем. m_FontLibraryType се чете от версия 5 нататък, а нулевата му стойност, FPDF_FONTBACKENDTYPE_FREETYPE, е същевременно и default-ът на PDFium, когато полето изобщо не се чете. Записването на FreeType за pfbpDefault затова възпроизвежда native default-а точно. Нулевите стойности не са винаги грешни — те просто никога не са автоматично верни

С v3.123.0 или по-нов startup кодът, който бихте написали естествено, вече прави това, което казва:

uses
  PDFium;

procedure ConfigurePdfiumAtStartup;
var
  Config: TPdfLibraryConfiguration;
begin
  // Трябва да тръгне преди нещо да зареди native библиотеката
  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 прави PDF 2.0 stream-овете /BrotliDecode декодируеми само когато самият DLL е билднат с PDF_ENABLE_BROTLI. Флагът е заявка, а върху билд без Brotli поддръжка няма никакъв ефект. TPdfLibraryConfiguration.Hardened е същото като Default, с това, че AllowMachineTime е False, което спира document JavaScript да чете истинския часовник; разумен начален пункт за сървърна обработка на недоверени файлове

Какво става, когато поискате backend, когото DLL-ът не съдържа?

PDFium не връща грешка за липсващ в билда рендерер или font backend: FPDF_InitLibraryWithConfig проваля native CHECK, който на Windows излиза като breakpoint exception и, без structured exception handler около извикването, прекратява процеса. Header-ът го казва прямо, с предупреждение, че неподдържана стойност „will similarly fail with an immediate crash“

Двата конкретни случая са AGG-само билд, получил FPDF_RENDERERTYPE_SKIA, и билд без Fontations, получил FPDF_FONTBACKENDTYPE_FONTATIONS. Вграденият Skia runtime е във втората група: рендерира със Skia, но ползва FreeType за шрифтове. Заявка за prpSkia заедно с pfbpFontations срещу него даваше External exception 80000003 от Delphi страната. Когато дебъгерът или exception handler случайно го хванат, ситуацията пак е невъзстановима:

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

Това е противоположният провал на бъга с Brotli. Там полето държеше стойност, която никой не е избрал, и PDFium я прие тихо. Тук полето държи стойност, която извикващият е избрал съзнателно, и PDFium не приема никакъв разговор по въпроса. И двата са проблеми, които wrapper трябва да разреши преди native извикването, защото след него няма какво да прихване

Как PDFium Component предпроверява Skia и Fontations

От v3.125.0 насам LoadLibrary валидира конфигурацията след закачането на DLL export-ите и преди извикването на FPDF_InitLibraryWithConfig и превръща неподдържан рендерер или font backend в EPdfError със съобщение, което назовава виновната настройка и алтернативите. DLL-ът се разтоварва и конфигурацията се отпечатва, така че извикващият може да избере други настройки и да зареди пак

Самото решение живее в чистата функция PdfLibraryConfigurationSupportError, която приема конфигурацията плюс две булеви стойности, описващи билда, и връща празен низ, когато комбинацията е безопасна. Тъй като не пипа native състояние, можете да я викате от собствени тестове с всякаква комбинация от възможности. Вътре в LoadLibrary двете булеви идват от различни видове доказателства, и заслужават различно ниво на доверие:

  • Skia се разпознава от наличието на export-а FPDF_RenderPageSkia — същият сигнал, който ползва PdfNativeRendererType. Export-ът и Skia рендерерът се компилират под едно условие, така че проверката е точна
  • Fontations няма собствен export. Единствената следа, която оставя, са Rust font crate-овете, които дърпа в бинария, затова PDFium Component сканира заредения библиотечен файл за имената на crate-овете skrifa и read-fonts (също read_fonts). Сканирането тръгва само когато е поискан pfbpFontations, а файл, който не може да се прочете, се брои за „без Fontations“

Проверката за Fontations е евристика и може да сгреши в една посока: Fontations билд, оголен от всеки един от тези низове, би бил отхвърлян, макар да би могъл да работи. Този компромис е направен нарочно. Фалшив отказ струва едно exception, което можете да прихванете, и fallback към FreeType. Фалшиво приемане струва процеса

Отпечатването е толкова важно, колкото и проверката. LoadLibrary запечатва конфигурацията в самото начало на зареждането, така че без нулирането отказ за възможност би оставил ConfigurePdfLibrary да отговаря на всеки повторен опит с EPdfError „PDFium library configuration is already sealed“. Пътят на отказа първо вика UnloadLibrary; извикването му FPDF_DestroyLibrary е безопасно в този момент, защото PDFium все още не е инициализиран и връща веднага. Други провали при зареждане — липсваща DLL или несъответствие на архитектура — пазят печата, така че retry цикъл трябва да различава двете:

uses
  SysUtils, PDFium;

function StartPdfiumPreferringSkia: TPdfRendererPreference;
var
  Config: TPdfLibraryConfiguration;
begin
  Config := TPdfLibraryConfiguration.Default;
  Config.Renderer := prpSkia;
  ConfigurePdfLibrary(Config);
  try
    PDFium.LoadLibrary;   // с име на unit: 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. В unit, който ползва и Windows или Winapi.Windows, неквалифицирано LoadLibrary се разрешава към unit-а, стоящ последен в uses клаузата; когато това е Win32 функцията, извикването без параметри не се компилира с грешка за брой аргументи, която не казва нищо за PDFium

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

Валидация, която става още по-рано

ConfigurePdfLibrary отказва някои комбинации, преди изобщо да се намеси DLL, всички с EPdfError. Изричен FontBackend, включително pfbpFreeType, изисква Renderer = prpSkia, защото PDFium поглежда font backend-а само за Skia рендерера. IsolatePerDocument изисква V8Isolate да е nil, понеже PDFium създава свой isolate на документ и проваля native 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;

Логването на този ред веднъж при стартиране е евтино, и е първото нещо, което искате в support тикет, казващ „текстът изглежда различно на сървъра“. PDFium.Loaded е квалифицирано по същата причина като LoadLibrary: вътре в метод на форма или компонента голо Loaded се вързва за TComponent.Loaded

Два начина, по които версионирана C конфигурационна структура се чупи

Всяка версионирана конфигурационна структура — дали FPDF_LIBRARY_CONFIG, Win32 запис с cbSize или plugin ABI — проваля по два симетрични начина, и wrapper трябва да се пази и от двата. Първият е попълване на поле при оставена прекалено ниска версия; вторият е вдигане на версия при оставено поле на нулева стойност, която библиотеката чете като съзнателен избор

  1. Поле зададено, версия прекалено ниска. Запишете m_BrotliEnabled = 1 в структура от версия 2 и PDFium изобщо не я поглежда. Извикването успява, а Brotli stream-овете остават недекодируеми. Защитата е версията да се извежда от полетата, реално ползвани — което прави LoadLibrary — вместо да се закова една
  2. Версия достатъчно висока, нулевото поле значи нещо. Вдигнете версията на 6 и всяко поле до версия 6 вече е живо. FillChar нулира m_RendererType до FPDF_RENDERERTYPE_AGG, което е истински рендерер, не „незададено“. Защитата е всеки член, покрит от избраната версия, да получи съзнателна стойност, а „default“ да се разрешава спрямо реалния билд, вместо да се предполага

За стойности, които могат да катастрофират извиквания, следва и трето правило: валидирайте ги спрямо това, което бинарият може, преди извикването, с най-силното налично доказателство, и бъдете честни в кода и документацията, когато това доказателство е евристика. Експортиран символ е доказателство. Име на crate в string таблица е добра догадка

Бърза справка: конфигурация на библиотеката в PDFium Component

  • Викайте ConfigurePdfLibrary веднъж, преди нещо да зареди DLL-а; всяко запитване за възможности или зареждане на документ го запечатва
  • Обновете до v3.123.0 или по-нов, ако задавате BrotliEnabled или IsolatePerDocument и очаквате Skia изход от вградените runtime-и
  • Оставете Renderer на prpDefault, освен ако не ви трябва конкретен растеризатор; той вече се разрешава до default-а на билда при всяка версия на структурата
  • Ползвайте PdfNativeRendererType с GetSkiaRenderCapabilities.PageRender, за да логвате кой рендерер реално е активен
  • Очаквайте EPdfError, а не краш, за prpSkia върху AGG-само DLL или pfbpFontations върху DLL без Fontations в v3.125.0 или по-нов
  • След отказ за възможности PdfLibraryConfigurationSealed е False и можете да преконфигурирате; след провалено зареждане на DLL той остава True
  • Третирайте разпознаването на Fontations като евристика и дръжте fallback към FreeType
  • Пишете PDFium.LoadLibrary и PDFium.Loaded с името на unit-а, за да избегнете сблъсъци с имената на Win32 и TComponent

Ако DLL-ът се провали преди конфигурацията изобщо да има значение, започнете с диагностицирането на провалени зареждания на PDFium DLL в Delphi, а за това как компонентът намира правилния бинарен файл на всяка платформа вижте зареждането на PDFium native библиотеката на произволна цел. Щом рендерерът е уреден, render cache и тактиките за плавно zoom покрива как да държите рендерирането на страниците бързо в viewer

PDFium Component обвива PDFium двигателя за Delphi и C++Builder с конфигурационни проверки като тези, така че native инициализацията проваля като Pascal exception, който можете да обработите, а не като изход от процеса. Детайли за продукта и изтеглянията са на страницата на PDFium Component за Delphi