Artykuł techniczny

Uchwyty obiektów strony PDFium nieaktualne po transformacji w Delphi

Gdy FPDFPage_TransFormWithClip przepisuje stronę, każdy uchwyt FPDF_PAGEOBJECT, który już trzymasz, wciąż opisuje parsing sprzed transformacji. PDFium Component dla Delphi i C++Builder rozwiązuje to wewnątrz TransformPageContent, które wyładowuje stronę tekstową, regeneruje treść, a potem przeładowuje stronę, tak by późniejsze zapytania widziały nowe współrzędne

Objaw jest cichy. Stosujesz skalę 0,9, by dodać margines do druku, a potem czytasz PageObjectInfo i dostajesz dokładnie te same liczby co przed wywołaniem. Żadnego wyjątku, żadnego kodu błędu, niczego w logu. To inna awaria niż buforowana strona tekstowa opisana w artykule o nieaktualnych stronach tekstowych po edycji: tam cache to pojedynczy uchwyt FPDF_TEXTPAGE, który możesz odrzucić i odbudować, tutaj problemem jest każdy uchwyt obiektu strony w Twoich własnych zmiennych, plus klasa getterów zgłaszających porażkę przez kod zwracany, który większość wywołujących wyrzuca

Dlaczego granice obiektu strony stają się nieaktualne bez błędu?

Ponieważ uchwyt obiektu strony to wskaźnik w sparsowaną reprezentację jednego konkretnego strumienia treści, a transformacja całej strony zastępuje ten strumień treści nowym. PDFium nie przechodzi po Twoim stosie wywołań, szukając uchwytów do załatania. Buduje świeży graf obiektów i zostawia stary dokładnie taki, jaki był, więc odczyt na starym uchwycie jest całkowicie poprawnym odczytem struktury, która już nie odpowiada temu, co mówi plik

ISO 32000-1 §7.8.2 definiuje strumień treści jako sekwencję operatorów rysujących stronę, a §8.3.3 definiuje, jak bieżąca macierz transformacji mapuje przestrzeń użytkownika na przestrzeń urządzenia. Transformacja na poziomie strony jest wyrażana przez opakowanie i przepisanie tych operatorów, nie przez edycję współrzędnych per obiekt w miejscu. Więc współrzędne, które niosą obiekty, mogą się w ogóle nie zmienić; zmienia się macierz obowiązująca w momencie ich rysowania. Każdy uchwyt sparsowany pod starą macierzą odpowiada na pytania o geometrię pod starą macierzą, i odpowiada bez zastrzeżeń

Co faktycznie przepisuje FPDFPage_TransFormWithClip

Przepisuje stronę, nie Twoje migawki. FPDFPage_TransFormWithClip bierze FS_MATRIX i prostokąt przycięcia FS_RECTF i stosuje oba do całej treści strony. To właściwe wywołanie do marginesów, skalowania impozycji i normalizowania dziwnie wymiarowanej strony wobec docelowego pola. Jest złym wywołaniem, po które sięgnąć, jeśli oczekujesz, że istniejące uchwyty będą podążać za zmianą, i warto też pamiętać, że dotyka wyłącznie treści strony: adnotacje to osobna warstwa i potrzebują TransformPageAnnotations, które przekazuje te same sześć współczynników macierzy do FPDFPage_TransformAnnots

var
  Info: TPdfPageObjectInfo;
  Scale: FS_MATRIX;
  Clip: TPdfRectangle;
begin
  Pdf.PageNumber:= 1;
  Info:= Pdf.PageObjectInfo(0);           // snapshot taken before the transform

  Scale.a:= 0.9;   Scale.b:= 0.0;
  Scale.c:= 0.0;   Scale.d:= 0.9;
  Scale.e:= 29.7;  Scale.f:= 42.0;        // 5% margin, A4 in points
  Clip:= Pdf.GetPageBox(pbMedia);
  Pdf.TransformPageContent(Scale, Clip);

  // Info.Bounds still holds pre-transform geometry, and Info.Handle now
  // points into a page that TransformPageContent has already replaced
end;

Kolejność odświeżania, jakiej używa TransformPageContent

Cztery kroki, w tej kolejności: wyładuj stronę tekstową, transformuj, wygeneruj treść, przeładuj stronę. TPdf.TransformPageContent wykonuje dokładnie tę sekwencję. Wywołuje CheckPageActive, kopiuje macierz i przycięcie do ich natywnych kształtów rekordowych, wywołuje UnloadTextPage, potem FPDFPage_TransFormWithClip, potem UpdatePage, czyli wrapper wokół FPDFPage_GenerateContent, i na koniec ReloadPage

Każdy krok zarabia na swoje miejsce. UnloadTextPage idzie pierwszy, ponieważ zbuforowany FPDF_TEXTPAGE trzyma ramki znaków obliczone pod starą macierzą, i odrzuca też wyprowadzoną listę linków webowych oraz jakąkolwiek trwającą sesję wyszukiwania, które zostały z niej zbudowane. FPDFPage_GenerateContent musi uruchomić się przed przeładowaniem, ponieważ transformacja żyje w stronie w pamięci, dopóki nie zostanie zserializowana z powrotem do strumienia treści, a przeładowanie inaczej ponownie sparsowałoby niezmodyfikowany strumień. ReloadPage kończy przez FPDF_LoadPage wobec bieżącego indeksu strony, co jest jedyną rzeczą, która faktycznie daje Ci świeży graf obiektów

// After the transform, re-enumerate. Do not reuse anything captured earlier.
var
  I: Integer;
  Info: TPdfPageObjectInfo;
begin
  Pdf.TransformPageContent(Scale, Clip);   // unload text page, transform,
                                           // generate content, reload page
  for I:= 0 to Pdf.ObjectCount- 1 do
  begin
    Info:= Pdf.PageObjectInfo(I);          // handle and bounds from the new parse
    if Info.Bounds.Right> PageWidth then
      Log('object '+ IntToStr(I)+ ' still overflows after scaling');
  end;
end;

Jeden szczegół w ReloadPage warto skopiować, jeśli kiedykolwiek napiszesz tę sekwencję sam. Wczytuje najpierw nową stronę i dopiero potem zatwierdza ją do pola, więc wczytanie strony, które zawiedzie, zostawia bieżącą natywną stronę i wszystkie jej wyprowadzone cache nietknięte, zamiast wrzucać Cię w stan pół-zburzony. Przeładowanie nie jest darmowe — płacisz za pełne ponowne sparsowanie strony — ale jest opłacane raz na transformację, nie raz na zapytanie, i nie ma tańszej poprawnej alternatywy

Nie przenoś uchwytów przez przeładowanie

Po przeładowaniu stare uchwyty nie są tylko nieaktualne, są wiszące. Poprzedni FPDF_PAGE został zamknięty, a wartości FPDF_PAGEOBJECT, które do niego należały, są wskaźnikami w zwolnioną pamięć. TPdfPageObjectInfo udostępnia natywny uchwyt w swoim polu Handle, co jest naprawdę przydatne przy przekazywaniu obiektu prosto do wywołania niższego poziomu, i równie naprawdę niebezpieczne przy trzymaniu go w polu formularza albo liście przez operację, która przeładowuje stronę. Traktuj rekord migawki jako ważny tylko do następnego wywołania regenerującego treść, w tym samym duchu co zasady własności omówione w notatkach o bezpieczeństwie ABI i pamięci na granicy PDFium

Czy getter może zawieść, a wciąż wyglądać jak poprawne dane?

Tak, i to jest druga połowa tego samego problemu. FPDFPageObj_GetRotatedBounds i FPDFPageObj_GetIsActive to gettery z parametrem wyjściowym: zwracają flagę powodzenia int i zapisują prawdziwą odpowiedź do argumentu referencyjnego. Oba mogą zwrócić FALSE dla obiektu, który został utworzony, ale którego strona nie została jeszcze ponownie sparsowana. Gdy to się zdarza, parametr wyjściowy pozostaje nietknięty, a rekord Pascal zainicjalizowany przez Default(TPdfPageObjectInfo) jest samymi zerami, więc wywołujący widzi czworokąt z czterema punktami w początku układu i flagą Active równą False. Nieudane wywołanie zostało po cichu awansowane na prawdopodobnie wyglądające dane

TPdfPageObjectInfo odpowiada na to jawnymi strażnikami. HasRotatedBounds niesie wynik wywołania FPDFPageObj_GetRotatedBounds, HasActiveState niesie wynik FPDFPageObj_GetIsActive, a pola geometrii i stanu są zapisywane tylko wtedy, gdy odpowiadający strażnik jest True. Ten sam kształt powtarza się w rekordzie dla pozostałych getterów z parametrem wyjściowym, więc HasMatrix, HasFillColor, HasStrokeColor i HasStrokeWidth znaczą wszystkie to samo: natywne wywołanie się powiodło, a sąsiadujące pole jest znaczące

Info:= Pdf.PageObjectInfo(I);

if Info.HasRotatedBounds then
  // RotatedBounds is array [1..4] of TPdfPoint, in draw order
  UseQuad(Info.RotatedBounds[1], Info.RotatedBounds[2],
          Info.RotatedBounds[3], Info.RotatedBounds[4])
else
  // the native call failed; fall back to the axis-aligned rectangle
  UseRect(Info.Bounds);

if Info.HasActiveState and (not Info.Active) then
  SkipObject(I);         // genuinely inactive
// if HasActiveState is False, the object state is unknown, not inactive

Wzorzec uogólnia się na każdy getter PDFium, który podąża za konwencją kodu zwracanego plus parametru wyjściowego, a jest ich sporo. Jeśli wrapper zwija tę konwencję do zwykłego wyniku funkcji, wyrzucił jedyny sygnał odróżniający „odpowiedź to zero” od „nie ma odpowiedzi”. Przenoszenie jednego dodatkowego boola na pole kosztuje bajt i usuwa całą kategorię błędu, w której zdomyślony rekord jest mylony z pomiarem

Gdzie to wciąż gryzie

Trzy uczciwe granice. Po pierwsze, odświeżenie jest per strona: transformuj stronę drugą, a jakiekolwiek uchwyty trzymane dla strony pierwszej pozostają nietknięte, ale masz teraz dwie strony sparsowane w różnym czasie i to na Tobie spoczywa pamiętanie, które migawki pochodzą z których. Po drugie, stabilność indeksu nie jest gwarantowana przez regenerację treści — po przeładowaniu indeks 3 to cokolwiek jest indeksem 3 w nowym parsingu, więc identyfikuj obiekty ponownie po ich typie i geometrii zamiast zakładać, że pozycje się utrzymały. Po trzecie, prostokąt przycięcia w FPDFPage_TransFormWithClip jest stosowany do treści strony i nie zmienia rozmiaru żadnego z pól strony; jeśli zeskalujesz treść w dół, by stworzyć margines, MediaBox wciąż ma taki rozmiar, jaki zawsze miał, a przeglądarka pokaże oryginalny arkusz z rysunkiem skurczonym w środku. Nic z tego nie jest egzotyczne — to zwykła konsekwencja API C, które rozdaje wskaźniki w sparsowany stan i zostawia czas życia wywołującemu. Naprawa jest tą, która działa wszędzie indziej: zdefiniuj dokładnie, kiedy migawka wygasa, odświeżaj na tej granicy, i nigdy nie pozwól, by nieudane wywołanie udawało wartość

Jeśli pracujesz nad zachowaniem macierzy ogólniej, kolejność mnożenia decydująca o tym, gdzie ląduje transformacja, jest omówiona w artykule o prepend, append i pivot z macierzami. Opisane tu API transformacji i obiektów strony są dostarczane z PDFium Component dla Delphi i C++Builder, którego strona produktu niesie pełną dokumentację rekordu migawki obiektu strony i jego pól strażniczych