Artykuł techniczny

Podwójna rotacja i błędy dopasowania powiększenia w PDFium na Delphi

Funkcja FPDF_RenderPageBitmap w komponencie PDFium przyjmuje argument obrotu, który PDFium zawsze dodaje do jakiejkolwiek rotacji, którą strona już niesie we własnym wpisie /Rotate, więc odczytanie zapisanej rotacji strony i podanie tej samej wartości z powrotem do wywołania renderowania obraca stronę dwukrotnie. Identyczny błąd pojawia się w matematyce dopasowania powiększenia: wymiarowanie miniatury na podstawie nieobróconej szerokości i wysokości strony daje niewłaściwe proporcje za każdym razem, gdy /Rotate wynosi 90 lub 270 stopni, ponieważ wyrenderowana bitmapa wychodzi z zamienioną szerokością i wysokością

Awarię łatwo dostrzec, gdy już wiadomo, czego szukać, i łatwo przeoczyć, dopóki się tego nie wie. Partia zeskanowanych faktur przychodzi z mieszanką oryginałów pionowych i poziomych, ktoś prostuje połowę z nich obrotem o 90 stopni w Acrobacie przed archiwizacją, a pasek miniatur w przeglądarce Delphi zbudowanej na PDFium renderuje te konkretne strony bokiem, do góry nogami, albo ściśnięte w pudełko o kształcie dla niewłaściwej orientacji. Nic nie zgłasza wyjątku. Nic nie loguje błędu. Piksele są po prostu złe, i tylko dla podzbioru stron, które ktoś obrócił po fakcie — dokładnie ten rodzaj błędu, który przetrwa pełny przebieg QA na nieobróconym pliku testowym PDF, a potem ujawni się w produkcji na stronie 47 prawdziwego dokumentu

Dlaczego PDFium obraca stronę dwukrotnie?

PDFium automatycznie stosuje własną wartość /Rotate strony za każdym razem, gdy renderuje bitmapę, niezależnie od tego, co zostanie przekazane do renderera. Parametr obrotu FPDF_RenderPageBitmap, udostępniony w PDFiumPas jako wartości TRotation ro0, ro90, ro180 i ro270 w TPdf.RenderPage, TPdf.RenderTile i TPdf.RenderPageThumbnail, nie ustawia kąta, w jakim strona powinna ostatecznie się znaleźć; parametr obrotu ustawia, ile dodatkowego obrotu nałożyć na to, co słownik strony już określa, dlatego każda z tych metod domyślnie ustawia go na ro0

TPdf.PageRotation odczytuje tę samą wartość /Rotate przez FPDFPage_GetRotation, a kod aplikacji często potrzebuje jej z powodów niemających nic wspólnego z renderowaniem, na przykład decydowaniem, jak rozmieścić adnotację w przestrzeni strony. Pułapka to jedna linia: przekazanie PageRotation do argumentu Rotation w RenderPage, oczekując, że wywołanie znormalizuje stronę do pozycji pionowej. Strona zapisana już z /Rotate 90 wyświetla się poprawnie, obrócona, w każdej zgodnej przeglądarce, PDFium włącznie; dodaj ro90 jeszcze raz na to, a strona wychyli się do 180 stopni zamiast zamierzonych 90, podczas gdy strona bez żadnej rotacji zostanie obrócona o niechciany kwartał obrotu bez powodu

// Wrong: PageRotation already reflects /Rotate, and PDFium applies
// it automatically on every render -- passing it again as Rotation
// doubles the angle
Bitmap := Pdf.RenderPage(0, 0, TargetW, TargetH, Pdf.PageRotation, []);

// Right: leave Rotation at its ro0 default and let PDFium apply the
// page's own /Rotate exactly once
Bitmap := Pdf.RenderPage(0, 0, TargetW, TargetH, ro0, []);

Do czego naprawdę służy parametr Rotation

Parametr Rotation zdobywa swoje miejsce w API dla naprawdę innego zadania: dodania rotacji wyłącznie widoku, która nie ma nic wspólnego z zapisaną orientacją strony, tego rodzaju, który przycisk obrotu widoku na pasku narzędzi stosuje bez dotykania bazowego pliku. TPdfView utrzymuje te dwie koncepcje jako dwie osobne właściwości dokładnie z tego powodu. TPdfView.PageRotation odzwierciedla własne /Rotate strony i, przez FPDFPage_SetRotation, może zapisać nową wartość z powrotem do dokumentu; TPdfView.Rotation jest właściwością przejściową, wyłącznie widokową, domyślnie ro0, i nigdy nie dotyka pliku. Odczytanie pierwszej właściwości i zapisanie jej do drugiej to cały błąd w jednym zdaniu

// View-only: rotates what the user sees, changes nothing in the file
procedure TViewerForm.RotateViewClick(Sender: TObject);
begin
  case PdfView.Rotation of
    ro0:   PdfView.Rotation := ro90;
    ro90:  PdfView.Rotation := ro180;
    ro180: PdfView.Rotation := ro270;
    ro270: PdfView.Rotation := ro0;
  end;
end;

// Persistent: rewrites the page's own /Rotate entry in the document
procedure TViewerForm.RotatePageClick(Sender: TObject);
begin
  case PdfView.PageRotation of
    ro0:   PdfView.PageRotation := ro90;
    ro90:  PdfView.PageRotation := ro180;
    ro180: PdfView.PageRotation := ro270;
    ro270: PdfView.PageRotation := ro0;
  end;
end;

Dlaczego wymiarowanie dopasowania powiększenia psuje się w ten sam sposób?

Wymiarowanie dopasowania powiększenia psuje się z odwrotnego powodu: obliczenie zaczyna się od niewłaściwej pary liczb, a nie od niewłaściwego kąta. Typowy sposób wymiarowania pudełka miniatury pyta PDFium o szerokość i wysokość strony, porównuje te proporcje z dostępnym pudełkiem i oblicza największy prostokąt, który się w nim mieści — co działa czysto dla nieobróconej strony. To samo obliczenie po cichu zawodzi dla strony z /Rotate 90 lub /Rotate 270, gdy szerokość i wysokość pochodzą z wywołania, które zgłasza wewnętrzny, nieobrócony rozmiar strony: strona A4 w orientacji pionowej niosąca /Rotate 90 wciąż zgłasza mniej więcej 595 na 842 punkty, mimo że PDFium renderuje ją, poprawnie, na mniej więcej 842 na 595, gdy tylko rotacja zacznie obowiązywać, a pudełko dopasowania obliczone z nieobróconej pary kończy się z kształtem dla zupełnie niewłaściwej orientacji

FPDF_GetPageSizeByIndex to jeden konkretny przykład wywołania, które celowo zgłasza ten wewnętrzny, nieobrócony rozmiar, co czyni je wygodnym do skanowania wymiarów stron bez wczytywania każdej strony, a ryzykownym dla matematyki dopasowania powiększenia, która zapomina to uwzględnić. Poprawka wynika bezpośrednio z nazwania problemu: sprawdź rotację strony przed wykonaniem arytmetyki dopasowania, zamień szerokość i wysokość za każdym razem, gdy ta rotacja wynosi 90 lub 270 stopni, oblicz pudełko dopasowania z zamienionej pary, i wciąż przekaż ro0 do faktycznego wywołania renderowania, ponieważ to nadal PDFium stosuje rzeczywistą rotację

Poprawne miniatury bez odkrywania na nowo matematyki dopasowania

TPdf.RenderPageThumbnail już niesie tę poprawkę, więc najkrótsza droga do poprawnej miniatury to wywołanie jej, a nie ręczne składanie na nowo logiki dopasowania i rotacji. Mając jednostkowy (1-based) indeks strony oraz maksymalną szerokość i wysokość, RenderPageThumbnail oblicza pudełko dopasowania, koryguje je wewnętrznie dla /Rotate wynoszącego 90 lub 270, i zwraca bitmapę, której właścicielem jest wywołujący, bez zakłócania bieżącej strony dokumentu ani wywoływania zdarzenia OnPageChange — co ma znaczenie dla paska miniatur zbudowanego obok żywej przeglądarki na tej samej instancji TPdf

// PageW, PageH are a page's own (unrotated) dimensions in points, for
// example from FPDF_GetPageSizeByIndex, which reports size before
// /Rotate is applied
function FitBox(PageW, PageH: Double; Rotation: TRotation;
  MaxW, MaxH: Integer; out FitW, FitH: Integer): Boolean;
var
  PgW, PgH, Swap: Integer;
begin
  PgW := Round(PageW);
  PgH := Round(PageH);
  if PgW < 1 then PgW := 1;
  if PgH < 1 then PgH := 1;

  if Rotation in [ro90, ro270] then
  begin
    Swap := PgW;
    PgW := PgH;
    PgH := Swap;
  end;

  Result := (MaxW > 0) and (MaxH > 0);
  if not Result then
    Exit;

  if PgW * MaxH > PgH * MaxW then
  begin
    FitW := MaxW;
    FitH := (MaxW * PgH) div PgW;
  end
  else
  begin
    FitH := MaxH;
    FitW := (MaxH * PgW) div PgH;
  end;
end;

Pomocniczą funkcję FitBox warto zachować mimo wszystko, ponieważ RenderPageThumbnail obejmuje tylko przypadek pojedynczej bitmapy. Niestandardowa siatka miniatur, pasek podglądu wydruku, czy okno wyboru strony, które rozmieszcza kilka stron względem niezależnych pudełek, potrzebuje tej samej matematyki dopasowania świadomej rotacji, niekoniecznie chcąc świeżej bitmapy dla każdego kafelka, a własne tryby powiększenia dopasuj-do-strony i dopasuj-do-szerokości w TPdfView opierają się wewnętrznie na tym samym pomyśle, wybierając między szerokością a wysokością strony do obliczenia współczynnika powiększenia na podstawie bieżącej rotacji widoku, zanim porówna to z dostępnym obszarem klienta. Jeśli wydajność powiększania i przewijania w takiej przeglądarce jest kolejnym problemem na liście, towarzyszący artykuł o buforowaniu renderowania i płynnym powiększaniu w przeglądarce Delphi opartej na PDFium podejmuje temat dokładnie tam, gdzie kończy się poprawne wymiarowanie

Wykrywanie podwójnej rotacji, zanim zrobi to klient

Podwójna rotacja ma jeden niezawodny sygnaturowy objaw wizualny: strona, która została obrócona o 90 stopni na wejściu, wychodzi wyglądając na obróconą o 180 względem reszty dokumentu, nie o 90, ponieważ dodatkowe ro90 nałożyło się na własne ro90 strony, zamiast je zastąpić. Zestaw testowy zbudowany wyłącznie ze stron /Rotate 0 nigdy tego nie wychwyci, ponieważ dodanie ro0 do ro0 to wciąż ro0, i błąd pozostaje niewidoczny; zestaw testowy potrzebuje przynajmniej jednej strony zapisanej z /Rotate 90 i jednej z /Rotate 270, zanim ścieżce kodu miniatur lub dopasowania powiększenia będzie można zaufać

Podstawowy potok strona-do-bitmapy opisany w renderowaniu stron PDF do JPEG za pomocą komponentu PDFium już renderuje obrócone strony poprawnie bez żadnego kodu specjalnego przypadku, dokładnie dlatego, że pozostawia Rotation przy domyślnym ro0 i pozwala PDFium samodzielnie zastosować /Rotate. Błąd podwójnej rotacji pojawia się dopiero, gdy kod aplikacji zaczyna odczytywać PageRotation z powrotem i podawać ją tam, gdzie nie należy

Opisane tutaj wywołania renderowania świadome rotacji oraz wymiarowanie miniatur są częścią komponentu PDFium dla Delphi i C++Buildera, obok reszty API renderowania, przeglądania i wyodrębniania tekstu zbudowanych na tych samych klasach TPdf i TPdfView