Artykuł techniczny

HotPDF na Free Pascalu: deflate, AES i granice kodeków

HotPDF kompiluje się i działa pod Free Pascalem 3.2.2 z Lazarusem, a uczciwe streszczenie tego portu zmieści się w dwóch zdaniach. Tworzenie dokumentów, wczytywanie, zapis, kompresja, dekompresja, szyfrowanie i deszyfrowanie działają na backendach wyłącznie pascalskich, więc aplikacja Lazarusa może produkować i konsumować prawdziwy PDF bez jakiejkolwiek zależności od C. Opcjonalne natywne kodeki obrazów nie, bo wstępnie zbudowane obiekty Win64 używają odmiany COFF, której żaden konsolidator Free Pascala nie skonsumuje, więc na tym toolchainie punkty wejścia rozwiązują się do stubów, które zawodzą bezpiecznie

Mapa możliwości HotPDF na Free Pascalu: działające backendy deflate, AES i dokumentowe w Pascalu obok stubów kodeków obrazów, które zawodzą bezpiecznie
Funkcje dokumentowe, kompresji i szyfrowania działają na backendach wyłącznie pascalskich, podczas gdy natywne kodeki obrazów rozwiązują się do stubów zawodzących bezpiecznie

Droga od „kompiluje się” do „działa” wymagała konkretnego zestawu poprawek, a każda z nich jest pułapką, która złapie każdą inną bazę kodu Delphi przenoszoną na Free Pascal. Warte zanotowania w kolejności, w jakiej bolały

Dlaczego skompilowana jednostka niczego nie dowodzi?

Bo jednostka Pascala może odwoływać się do symbolu, który nigdy niczego użytecznego nie zrobi, i wciąż zaspokoić kompilator. W momencie, gdy wszystkie 113 jednostek biblioteki budowało się czysto pod Free Pascalem, handlery kontenerów archiwalnych naprawdę działały, zweryfikowane testem dymnym, który otwierał CBZ i konwertował go do PDF. Spłaszczanie formularzy XFA nie działało w ogóle, bo spłaszczanie musi zdekompresować skompresowany strumień pakietu /XFA, a punkt wejścia deflate był wciąż stubem. Nic w wyjściu builda nie odróżniało tych dwóch przypadków

Reguła, która z tego wynikła, jest krótka. Zanim napiszesz w nocie wydania, że funkcja działa na nowym toolchainie, napisz sondę uruchomieniową, która ćwiczy tę funkcję od końca do końca na tym toolchainie. Pokrycie kompilacją to warunek wstępny, nigdy dowód. Szerszy obraz tego, co port pokrywa, jest w notach wsparcia Free Pascal i Lazarus Win64

Raise wewnątrz stubu cdecl nie dociera do wywołującego

Ta zasługuje na własną sekcję, bo objaw jest tak mylący. Jednostki stubów wystawiają punkty wejścia C tak, jak zrobiłaby to biblioteka statyczna, więc stub wygląda tak

// Wygląda rozsądnie. Nie jest.
function inflate(Strm: Pointer; Flush: Integer): PtrUInt; cdecl;
  public name 'inflate';
begin
  raise ENotSupportedException.Create('codec unavailable');
end;

Na Free Pascalu dla Win64 ten wyjątek nie propaguje się do wywołującego. Nie ma handlera try..except, który by go zobaczył, bo rozwijanie przez granicę cdecl zadeklarowaną w ten sposób nie niesie pascalskiej ramki wyjątku; proces kończy się kodem wyjścia 217. Od strony aplikacji nie ma błędu, komunikatu ani linii w logu, tylko program, który znika. To jest ściśle gorsze niż zła odpowiedź, bo złą odpowiedź można obsłużyć

Dlaczego wyjątek zgłoszony wewnątrz stubu cdecl kończy proces Free Pascala kodem wyjścia 217 i jak naprawia to bramkowanie pascalskiego punktu wejścia
Pascalska ramka wyjątku nie umie się rozwijać przez granicę cdecl, więc proces umiera po cichu; poprawka bramkuje, zanim stub zostanie w ogóle osiągnięty

Kusząca poprawka to sprawienie, by stub zwracał kod awarii, i dla inflate to jest słuszne, bo zlib ma dobrze zdefiniowany zwrot błędu. W ogólności to błędne: stub dla jpeg_read_header zwracający zero mówi wywołującemu, żeby ciągnął dalej ze strukturą, której nikt nie zainicjalizował. Trwała poprawka to bramkowanie na pascalskim punkcie wejścia, a nie wewnątrz stubu o kształcie C, używając konwencji awarii, którą to API już ma

function TryDecodeJPEG(const Data: TBytes; out Bitmap: TBitmap): Boolean;
begin
{$IFDEF FPC}
  // Odmów zanim stub zostanie osiągnięty, używając własnej konwencji
  // awarii tego API, a nie wyjątku przez granicę cdecl
  Bitmap := nil;
  Result := False;
  Exit;
{$ENDIF}
  Result := DecodeJPEGNative(Data, Bitmap);
end;

paszlib to nie zlib, a różnica to dwie klasy dokumentów

Pascalska implementacja deflate dostępna na Free Pascalu obsługuje dwa opakowania: opakowanie zlib i surowy deflate. Nie obsługuje opakowania gzip, które zlib wybiera przez wartości windowBits od 16 do 31, ani trybu automatycznej detekcji wybieranego przez wartości od 32 do 47. HotPDF potrzebuje obu. Bezpieczna ścieżka importu SVG prosi o 31, a wczytywanie ma drabinę awaryjną proszącą o 47, gdy opakowanie strumienia jest niejednoznaczne. Pomiń którekolwiek i cała rodzina dokumentów przestaje się otwierać, z błędem dekodowania wskazującym na strumień, a nie na brakujące opakowanie

Pokrycie windowBits w paszlib względem zlib: brak zakresów opakowania gzip i auto-detekcji dla importu SVG HotPDF i fallbacku wczytywania
paszlib obsługuje opakowanie zlib i surowy deflate, ale HotPDF potrzebuje też windowBits 31 i 47, więc shim musi samo dostarczyć opakowanie gzip

Jest i druga, ostrzejsza niezgodność. Rekord z_stream deklarowany przez paszlib nie ma tego samego układu pamięci co ten z C: jego pole msg to short string, a nie wskaźnik, a total_in i total_out są 64-bitowe tam, gdzie ABI C ma słowa maszynowe. Rekord wywołującego nie może więc przejść na wprost. Działające rozwiązanie to trzymanie stanu paszlib za wskaźnikiem state, który publiczny rekord już rezerwuje, i kopiowanie publicznych pól w obie strony przy każdym wywołaniu. CRC gzip i ośmiobajtowy ogon długości są rozliczane w tej samej warstwie shim, co jest dla nich naturalnym miejscem, bo to ona już posiada decyzję o opakowaniu

Przekazywanie tablicy dynamicznej do nietypowanego parametru var

To jest błąd, który najbardziej prawdopodobnie siedzi właśnie teraz w twoim kodzie. Gdy przekazujesz tablicę dynamiczną do nietypowanego parametru var, wywoływany dostaje adres zmiennej tablicy, czyli adres wskaźnika, a nie adres ładunku. Odczyt do niej nadpisuje więc samą zmienną i to, co siedzi obok

var
  FBuffer: TBytes;
begin
  SetLength(FBuffer, 65536);

  // Źle: oddaje adres zmiennej FBuffer
  FStream.Read(FBuffer, Length(FBuffer));

  // Dobrze: oddaje adres pierwszego bajtu ładunku
  FStream.Read(FBuffer[0], Length(FBuffer));
end;

Na Delphi zła forma częstokroć wygląda na działającą, bo psuje sąsiednie gniazdo stosu, którego nic potem nie czyta. Na Free Pascalu ta sama linia daje naruszenie segmentacji przy pierwszym użyciu. To, co sprawia, że tak trudno to dostrzec okiem, to fakt, że tablice statyczne nie mają takiego problemu, bo zmienna tablicy statycznej jest własnym ładunkiem, więc obie pisownie są poprawne w tym samym pliku, zależnie od deklaracji sto kilkaset linii dalej

Kontenery ZIP bez System.Zip

Free Pascal nie ma odpowiednika jednostki zip z RTL, a dostępna alternatywa ma i inną powierzchnię API, i brak wsparcia dla starszego szyfrowania, którego starsze formaty kontenerów wciąż używają, więc mały czytnik we własnym kodzie biblioteki okazał się krótszy niż adaptowanie do niej. Dwa szczegóły formatu kosztowały czas i łatwo je zepsuć

Pierwszy to bajt kontrolny nagłówka szyfrowania. Jego dwunasty bajt to normalnie wysoki bajt CRC, ale gdy bit 3 flagi ogólnego przeznaczenia jest ustawiony, czyli rozmiary mieszczą się w końcowym deskryptorze danych, a CRC nie jest jeszcze znane, bajt kontrolny pochodzi z wysokiego bajtu czasu modyfikacji. Zaimplementuj tylko formę z CRC, a każde archiwum zapisane w trybie strumieniowym odrzuci poprawne hasło. Drugi to pole dodatkowe ZIP64: jego trzy pola 64-bitowe pojawiają się w stałej kolejności, ale są zapisywane tylko wtedy, gdy odpowiadające pole 32-bitowe jest przesycone, więc czytanie ich pod stałymi offsetami działa na archiwach, które przetestowano, i zawodzi na następnym. Parsuj je pozycyjnie względem tego, które pola 32-bitowe są przesycone

Jedna wygoda warta znajomości: strumień dekompresji Free Pascala przyjmuje drugi argument konstruktora, który pomija nagłówek zlib, i to jest dokładnie to, czego potrzebują wpisy ZIP, bo przechowują surowy deflate. Ta ścieżka w ogóle nie dotyka bibliotecznego shimu zlib, więc brakujący backend C jej nie dotyczy

Przezroczystość kolorowych glifów pod LCL

Odczyt kanału alfa zrastrowanego kolorowego glifu to jedyny szczegół graficzny bez bezpośredniego tłumaczenia. Klasa PNG w LCL nie ma akcesora linii skanowania wystawiającego alfę, a przypisanie PNG do bitmapy ją wyrzuca, więc kolorowy emoji przychodzi w pełni nieprzezroczysty i komponuje się z czarnym prostokątem za sobą. Działająca droga to obraz interfejsowy: utwórz go z PNG, potem czytaj piksele przez akcesor koloru, pamiętając, że jego składowe są 16-bitowe i wymagają przesunięcia w dół o osiem, by stać się bajtami. Ta powierzchnia używa też naturalnej kolejności wierszy z góry na dół, więc inwersja Height - 1 - Y, której wymaga kod linii skanowania VCL, musi zostać usunięta, a nie przeniesiona

Dwie uwagi systemu budowania, zanim zgłosisz błąd

Pełna przebudowa sporadycznie zawodzi z niezdefiniowanym symbolem, którego nazwa kończy się sufiksem $crc i wartością szesnastkową. Ten sufiks jest liczony z typów parametrów i przestaje się zgadzać, gdy jeden build kompiluje jednostkę względem dwóch różnych wersji interfejsu w tym samym przebiegu. Ponowne uruchomienie builda to czyści; sygnatura nie jest zła

Po drugie, Free Pascal 3.2.2 nie ma metod anonimowych, więc wszędzie tam, gdzie biblioteka używała domknięć do spięcia równoległego potoku, build Free Pascala bierze zamiast tego deterministyczny serialowy fallback. Wyjście jest identyczne, przepustowość nie; jeśli zależysz na równoległym renderowaniu stron, to powód, by na razie zostać przy Delphi, a projekt potoku opisuje artykuł o równoległym potoku renderowania. Sytuacja kodeków obrazów to drugie miejsce, gdzie wybór toolchaina zmienia możliwości, a nie tylko szybkość, więc wdrożenie Lazarusa powinno zaplanować swoje formaty obrazów odpowiednio; aktualna macierz per toolchain jest na stronie produktu HotPDF Delphi PDF component