Artykuł techniczny

Konfiguracja PDFium: kiedy Brotli po cichu podmienia Skia

W PDFium Component dla Delphi włączenie BrotliEnabled albo IsolatePerDocument w TPdfLibraryConfiguration potrafiło przełączyć dołączony build Skia na renderer AGG bez żadnego błędu, bo obie opcje podnoszą FPDF_LIBRARY_CONFIG do wersji, w której PDFium czyta m_RendererType dosłownie. Od v3.123.0 domyślny renderer pozostaje domyślnym biblioteki DLL, a od v3.125.0 żądanie Skia albo Fontations, którego DLL nie zdoła spełnić, podnosi łapywalny EPdfError zamiast zabijać proces

Żaden z bugów się nie zasygnalizował. Pierwszy produkował strony wyglądające dobrze, po prostu wyrenderowane przez inny rasteryzer, z minimalnie innym antyaliasingiem i krawędziami tekstu niż build, który wysłałeś i przetestowałeś. Drugi się zasygnalizował — głośno, ściągnąwszy proces gospodarza od środka natywnej inicjalizacji. Oba biorą się z tego samego miejsca: wersjonowanej struktury C, której pola liczą się dopiero, gdy numer wersji tak powie, a której zera nie znaczą „nieustawione”, tylko są prawdziwymi wyborami

Jak FPDF_LIBRARY_CONFIG rozstrzyga, którego renderera używa PDFium?

FPDF_InitLibraryWithConfig zagląda do m_RendererType tylko wtedy, gdy pole Version struktury wynosi 4 albo więcej, i od tej wersji używa wartości dokładnie tak, jak zapisano. Poniżej wersji 4 PDFium ignoruje pole i bierze domyślny builda — Skia w buildach skompilowanych z PDF_USE_SKIA i AGG wszędzie indziej

Każde dalsze pole idzie tym samym schematem. Struktura rosła o jedną możliwość na raz, i każda możliwość przychodziła razem z nowym numerem wersji. PDFium Component buduje strukturę natywną w LoadLibrary z twojego TPdfLibraryConfiguration i podnosi wersję tylko tak wysoko, jak wymagają ustawione opcje

Wersja strukturyPole, które dodajeUstawiane przez
2m_pIsolate, m_v8EmbedderSlotZawsze zapisywane; V8Isolate, V8EmbedderSlot
3m_pPlatformV8Platform różne od nil
4m_RendererTypeRenderer inne niż prpDefault
5m_FontLibraryTypeFontBackend inne niż pfbpDefault
6m_BrotliEnabledBrotliEnabled = True
7m_IsolatePerDocumentIsolatePerDocument = True

Pułapka siedzi w dwóch ostatnich wierszach. Wersje są skumulowane: struktura wersji 6 jest też strukturą wersji 4 i wersji 5, więc PDFium czyta m_RendererType i m_FontLibraryType, choć poprosiłeś wyłącznie o Brotli. Cokolwiek siedzi w tych dwóch polach w tej chwili, staje się rendererem i backendem czcionek — czy chciałeś je wybrać, czy nie

Drabinka wersji FPDF_LIBRARY_CONFIG w PDFium Component od wersji 2 do wersji 7 pokazująca, która opcja TPdfLibraryConfiguration dodaje m_RendererType, m_FontLibraryType, m_BrotliEnabled i m_IsolatePerDocument, oraz czemu skumulowane wersje czynią wyzerowane pole renderera świadomym wyborem AGG, a nie wartością nieustawioną, na każdym buildzie
każda opcja podnosi wersję struktury, a każde wcześniejsze pole pozostaje żywe, więc zero w m_RendererType dociera do PDFium jako jawne żądanie AGG

Dlaczego włączenie Brotli przełączało renderer na AGG?

Przed v3.123.0 PDFium Component zapisywał FPDF_RENDERERTYPE_AGG do m_RendererType dla prpDefault, więc każda konfiguracja pchająca strukturę do wersji 6 albo 7 wymuszała AGG na buildzie Skia. Runtimy pdfium.dll i pdfium.v8.dll dołączane do komponentu to buildy Skia, więc trafiał to domyślne wdrożenie, a nie jakieś egzotyczne

Mapowanie wyglądało niewinnie, gdy je napisano. Przy wersji 2 albo 3 pole nie jest nigdy czytane, więc prpDefault naprawdę znaczyło „cokolwiek zrobi biblioteka DLL”. W momencie, gdy do gry weszły BrotliEnabled (wersja 6) albo IsolatePerDocument (wersja 7), ten sam kod zamienił „bez preferencji” w jawne żądanie AGG. Nic nie zawiodło. PDFium zainicjalizował się normalnie, wyrenderował każdą stronę i nie zwrócił żadnego kodu błędu, bo z jego punktu widzenia wołający poprosił o AGG i dostał AGG

Hash pikseli czyni podmianę widoczną tam, gdzie zrzuty ekranu nie. Wyrenderowanie pierwszej strony tego samego dokumentu próbnego w trzech konfiguracjach dało:

  • konfiguracja domyślna: hash 502D77C3711B4ACF
  • BrotliEnabled = True przy Renderer pozostawionym na prpDefault: hash F75B5EB4728ADE87
  • jawne prpAgg: hash F75B5EB4728ADE87, identyczny z przebiegiem Brotli

Poprawka z v3.123.0 to publiczna funkcja PdfNativeRendererType, która rozwiązuje TPdfRendererPreference na wartość wpisywaną do m_RendererType. prpAgg i prpSkia mapują się jeden do jednego. prpDefault mapuje się teraz na Skia, gdy wczytana biblioteka DLL eksportuje FPDF_RenderPageSkia, i na AGG w przeciwnym razie. Ten eksport jest kompilowany pod tym samym warunkiem PDF_USE_SKIA co sam domyślny Skia, co czyni go jedyną własnością builda obserwowalną z zewnątrz biblioteki DLL. Po poprawce konfiguracja Brotli daje ten sam hash co domyślna

Porównanie hashy pikseli w PDFium Component pokazujące hash renderu Skia 502D77C3711B4ACF z konfiguracji domyślnej, konfigurację BrotliEnabled sprzed v3.123.0 zbieżną z jawnym przebiegiem prpAgg przy hashu F75B5EB4728ADE87 oraz naprawiony wrapper rozwiązujący prpDefault przez eksport FPDF_RenderPageSkia z powrotem na oryginalny hash Skia
hash pikseli łapie to, co skrywają zrzuty ekranu: włączenie Brotli renderowało kiedyś każdą stronę przez AGG, a naprawiony domyślny zgadza się teraz z konfiguracją nietkniętą

Backend czcionek nigdy nie miał tego problemu. m_FontLibraryType jest czytane od wersji 5, a jego wartość zero, FPDF_FONTBACKENDTYPE_FREETYPE, to zarazem domyślny PDFium, gdy pole nie jest czytane wcale. Zapisanie FreeType dla pfbpDefault odtwarza więc natywny domyślny dokładnie. Wartości zero nie zawsze są złe — po prostu nigdy nie są automatycznie dobre

Z v3.123.0 albo nowszym kod startowy, który i tak napisałbyś naturalnie, robi teraz to, co mówi:

uses
  PDFium;

procedure ConfigurePdfiumAtStartup;
var
  Config: TPdfLibraryConfiguration;
begin
  // Musi się wykonać, zanim cokolwiek wczyta bibliotekę natywną
  Config := TPdfLibraryConfiguration.Default;
  Config.BrotliEnabled := True;   // podnosi FPDF_LIBRARY_CONFIG do wersji 6
  // Renderer zostaje prpDefault: rozwiązywany na Skia w buildach eksportujących
  // FPDF_RenderPageSkia i na AGG w buildach wyłącznie AGG
  SetLength(Config.UserFontPaths, 1);
  Config.UserFontPaths[0] := 'C:\ProgramData\MyApp\Fonts';
  ConfigurePdfLibrary(Config);
end;

Pamiętaj, że BrotliEnabled czyni strumienie /BrotliDecode z PDF 2.0 dekodowalne tylko wtedy, gdy sama biblioteka DLL była zbudowana z PDF_ENABLE_BROTLI. Flaga to prośba, a na buildzie bez wsparcia Brotli nie daje żadnego efektu. TPdfLibraryConfiguration.Hardened to to samo co Default, z tą różnicą, że AllowMachineTime jest False, co blokuje dokumentowemu JavaScriptowi czytanie prawdziwego zegara; to rozsądny punkt wyjścia do przetwarzania niezaufanych plików po stronie serwera

Co się dzieje, gdy zażądasz backendu, którego biblioteka DLL nie zawiera?

PDFium nie zwraca błędu dla renderera albo backendu czcionek nieobecnego w buildzie: FPDF_InitLibraryWithConfig wykłada natywny CHECK, który na Windows wychodzi na wierzch jako wyjątek breakpointa i, bez strukturalnego handlera wyjątków wokół wywołania, terminuje proces. Nagłówek mówi to wprost, ostrzegając, że niewspierana wartość „zawodzi podobnie, natychmiastowym crashem”

Dwa konkretne przypadki to build wyłącznie AGG dostający FPDF_RENDERERTYPE_SKIA i build bez Fontations dostający FPDF_FONTBACKENDTYPE_FONTATIONS. Dołączany runtime Skia należy do drugiej grupy: renderuje przez Skia, ale używa FreeType do czcionek. Żądanie prpSkia razem z pfbpFontations wobec niego produkowało External exception 80000003 po stronie Delphi. Gdy debugger albo handler wyjątków akurat to złapie, sytuacja jest wciąż nieodwracalna:

  • PDFium zostaje na wpół zainicjalizowany
  • konfiguracja całego procesu jest już zapieczętowana, więc ConfigurePdfLibrary odmawia skorygowanej konfiguracji
  • ponawianie z inną konfiguracją w tym samym procesie nie jest już możliwe

To porażka przeciwna do buga Brotli. Tam pole trzymało wartość, której nikt nie wybrał, a PDFium przyjmował ją po cichu. Tutaj pole trzyma wartość, którą wołający wybrał świadomie, i PDFium nie przyjmuje o niej żadnej dyskusji. Oba to problemy, które wrapper musi rozwiązać przed wywołaniem natywnym, bo po nim nie ma już czego łapać

Jak PDFium Component precheckuje Skia i Fontations

Od v3.125.0 LoadLibrary waliduje konfigurację po dowiązaniu eksportów biblioteki DLL, a przed wywołaniem FPDF_InitLibraryWithConfig, i zamienia niewspierany renderer albo backend czcionek w EPdfError z komunikatem nazywającym winne ustawienie i alternatywy. Biblioteka DLL jest wyładowywana, a konfiguracja odpieczętowuje się, więc wołający może wybrać inne ustawienia i wczytać ponownie

Decyzja sama mieszka w czystej funkcji PdfLibraryConfigurationSupportError, która bierze konfigurację plus dwa Boole'e opisujące build i zwraca pusty tekst, gdy kombinacja jest bezpieczna. Bo nie dotyka żadnego stanu natywnego, możesz wołać ją z własnych testów z dowolną kombinacją możliwości. Wewnątrz LoadLibrary oba Boole'e pochodzą z różnych rodzajów dowodów i zasługują na różny poziom zaufania:

  • Skia jest wykrywana z obecności eksportu FPDF_RenderPageSkia — tego samego sygnału, którego używa PdfNativeRendererType. Eksport i renderer Skia są kompilowane pod jednym warunkiem, więc sprawdzenie jest ścisłe
  • Fontations nie ma własnego eksportu. Jedyny ślad, jaki zostawia, to skrzynie czcionek Rust, które wciąga do binarki, więc PDFium Component skanuje wczytany plik biblioteki pod kątem nazw skrzyń skrifa i read-fonts (także read_fonts). Skan biegnie tylko, gdy zażądano pfbpFontations, a plik, którego nie da się przeczytać, liczy się jako „brak Fontations”

Sprawdzenie Fontations to heurystyka i może się mylić w jedną stronę: build Fontations obdarty ze wszystkich tych tekstów zostałby odrzucony, choć mógłby zadziałać. Ten kompromis zawarto celowo. Fałszywe odrzucenie kosztuje cię wyjątek, który złapiesz, i fallback na FreeType. Fałszywe przyjęcie kosztuje cię proces

Odpieczętowanie znaczy tyle, co samo sprawdzenie. LoadLibrary pieczętuje konfigurację na samym początku wczytywania, więc bez resetu odrzucenie możliwości zostawiłoby ConfigurePdfLibrary odpowiadające na każdą ponowną próbę EPdfError „PDFium library configuration is already sealed”. Ścieżka odrzucenia woła najpierw UnloadLibrary; jej wywołanie FPDF_DestroyLibrary jest w tym punkcie bezpieczne, bo PDFium nie został jeszcze zainicjalizowany i wraca natychmiast. Inne porażki wczytania, jak brakująca biblioteka DLL albo niedopasowanie architektury, trzymają pieczęć, więc pętla ponawiania musi umieć je odróżnić:

uses
  SysUtils, PDFium;

function StartPdfiumPreferringSkia: TPdfRendererPreference;
var
  Config: TPdfLibraryConfiguration;
begin
  Config := TPdfLibraryConfiguration.Default;
  Config.Renderer := prpSkia;
  ConfigurePdfLibrary(Config);
  try
    PDFium.LoadLibrary;   // kwalifikowane modułem: Windows.LoadLibrary ma tę samą nazwę
    Result := prpSkia;
  except
    on E: EPdfError do
    begin
      // Odrzucenie możliwości wyładowuje DLL i odpieczętowuje konfigurację.
      // DLL, który w ogóle się nie wczytał, zostaje zapieczętowany: ponawianie nie pomoże
      if PdfLibraryConfigurationSealed then
        raise;
      Config.Renderer := prpAgg;
      ConfigurePdfLibrary(Config);
      PDFium.LoadLibrary;
      Result := prpAgg;
    end;
  end;
end;

Zauważ jawne PDFium.LoadLibrary. W module, który używa też Windows albo Winapi.Windows, niewykwalifikowane LoadLibrary rozwiązuje się do tej jednostki, która jest ostatnia w uses; gdy to funkcja Win32, wywołanie bez parametrów nie skompiluje się z błędem liczby argumentów, który nic nie mówi o PDFium

Przebieg prechecku LoadLibrary w PDFium Component, gdzie ConfigurePdfLibrary pieczętuje konfigurację, sprawdzenie możliwości testuje eksport FPDF_RenderPageSkia i dowody tekstowe skrifa, niewspierane żądanie podnosi łapywalny EPdfError i odpieczętowuje na ponowienie, a biblioteka DLL, która nigdy się nie wczyta, trzyma PdfLibraryConfigurationSealed na true
walidacja biegnie po dowiązaniu eksportów i przed inicjalizacją, więc nieobecny backend zawodzi jako EPdfError, który złapiesz, zamiast natywnego CHECK-a zabijającego proces

Walidacja, która dzieje się jeszcze wcześniej

ConfigurePdfLibrary odrzuca niektóre kombinacje, zanim w ogóle wchodzi do gry jakakolwiek biblioteka DLL — wszystkie przez EPdfError. Jawny FontBackend, łącznie z pfbpFreeType, wymaga Renderer = prpSkia, bo PDFium konsultuje backend czcionek wyłącznie dla renderera Skia. IsolatePerDocument wymaga V8Isolate = nil, bo PDFium tworzy własny izolat per dokument i wykłada natywny CHECK, jeśli wręczysz mu też swój. Puste teksty w UserFontPaths są odrzucane. I każde wywołanie po pierwszej próbie wczytania zawodzi z „PDFium library configuration is already sealed”

Ostatnia reguła ma praktyczną konsekwencję: nie da się najpierw rozeznać w bibliotece DLL, a skonfigurować ją potem. GetSkiaRenderCapabilities, V8FeaturesAvailable, otwarcie dokumentu i większość pozostałych punktów wejścia woła w środku LoadLibrary, który pieczętuje konfigurację na miejscu. Późniejsze UnloadLibrary też jej nie otwiera. Najpierw skonfiguruj, potem wczytaj, potem zadawaj pytania — dokładnie ta kolejność, jakiej powinna trzymać się procedura diagnostyczna:

uses
  SysUtils, PDFium, FPdfView;

function DescribePdfiumState: string;
var
  Config: TPdfLibraryConfiguration;
  Renderer: string;
begin
  Config := GetPdfLibraryConfiguration;   // kopia, można bezpiecznie oglądać
  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;
  // Ta sama resolucja, którą LoadLibrary zastosował, budując 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;

Zalogowanie tej linijki raz na starcie jest tanie, i to pierwsza rzecz, jaką chcesz mieć w zgłoszeniu supportu mówiącym „tekst wygląda inaczej na serwerze”. PDFium.Loaded jest kwalifikowane z tego samego powodu co LoadLibrary: wewnątrz metody formularza albo komponentu gołe Loaded wiąże się do TComponent.Loaded

Dwa sposoby, na jakie wersjonowana struktura konfiguracyjna C idzie na opak

Każda wersjonowana struktura konfiguracyjna — czy to FPDF_LIBRARY_CONFIG, rekord Win32 z cbSize, czy ABI wtyczki — zawodzi na dwa symetryczne sposoby, i wrapper musi strzec się obu. Pierwszy to wypełnienie pola przy zostawieniu wersji zbyt niskiej; drugi to podniesienie wersji przy zostawieniu pola na wartości zero, którą biblioteka czyta jako świadomy wybór

  1. Pole ustawione, wersja zbyt niska. Wpisz m_BrotliEnabled = 1 do struktury wersji 2, a PDFium nigdy na to nie spojrzy. Wywołanie się udaje, a strumienie Brotli pozostają niedekodowalne. Obroną jest wyprowadzanie wersji z pól faktycznie używanych — to robi LoadLibrary — zamiast wbijania jednej na sztywno
  2. Wersja wystarczająca, zero w polu coś znaczy. Podnieś wersję do 6, a każde pole do wersji 6 włącznie staje się żywe. FillChar zeruje m_RendererType do FPDF_RENDERERTYPE_AGG, czyli prawdziwego renderera, nie „nieustawione”. Obroną jest wpisanie każdego pola objętego wybraną wersją wartością intencjonalną i rozwiązywanie „domyślnego” względem faktycznego builda, zamiast go zakładać

Trzecia reguła dotyczy wartości, które potrafią wywalić wołanego: waliduj je względem tego, co binarka umie, przed wywołaniem, używając najmocniejszego dostępnego dowodu, i bądź szczery w kodzie i w dokumentacji, kiedy ten dowód jest heurystyką. Wyeksportowany symbol to dowód. Nazwa skrzyni w tablicy tekstów to dobre zgadywanie

Ściąga: konfiguracja biblioteki w PDFium Component

  • Wołaj ConfigurePdfLibrary raz, zanim cokolwiek wczyta bibliotekę DLL; każde zapytanie o możliwości albo wczytanie dokumentu ją pieczętuje
  • Zaktualizuj do v3.123.0 albo nowszej, jeśli ustawiasz BrotliEnabled albo IsolatePerDocument i oczekujesz wyjścia Skia z dołączonych runtime'ów
  • Zostaw Renderer na prpDefault, chyba że potrzebujesz konkretnego rasteryzera; rozwiązuje się teraz do domyślnego builda przy każdej wersji struktury
  • Używaj PdfNativeRendererType z GetSkiaRenderCapabilities.PageRender, żeby logować, który renderer faktycznie działa
  • Oczekuj EPdfError, nie crasha, dla prpSkia na bibliotece DLL wyłącznie AGG albo pfbpFontations na bibliotece bez Fontations w v3.125.0 albo nowszej
  • Po odrzuceniu możliwości PdfLibraryConfigurationSealed jest False i możesz przekonfigurować; po nieudanym wczytaniu biblioteki DLL zostaje True
  • Traktuj wykrywanie Fontations jako heurystykę i trzymaj fallback na FreeType
  • Pisz PDFium.LoadLibrary i PDFium.Loaded z nazwą modułu, żeby uniknąć kolizji nazw z Win32 i TComponent

Jeśli biblioteka DLL zawodzi, zanim konfiguracja w ogóle zacznie mieć znaczenie, zacznij od diagnostyki niepowodzeń wczytania PDFium DLL w Delphi, a o tym, jak komponent znajduje właściwą binarkę na każdej platformie, pisze wczytywanie natywnej biblioteki PDFium na dowolnym celu. Gdy renderer jest już ustalony, cache renderowania i taktyki płynnego zoomu pokazują, jak trzymać renderowanie stron szybkie w viewerze

PDFium Component opakowuje silnik PDFium dla Delphi i C++Buildera ze sprawdzeniami konfiguracji takimi jak te, więc natywna inicjalizacja zawodzi jako wyjątek Pascal, który obsłużysz, a nie jako śmierć procesu. Szczegóły produktu i pobieranie są na stronie produktu PDFium Component for Delphi