Articol tehnic

Bug-uri de dublă rotație și zoom-adaptare PDFium în Delphi

Funcția FPDF_RenderPageBitmap a componentei PDFium acceptă un argument rotate pe care PDFium îl adaugă întotdeauna deasupra oricărei rotații poartă deja pagina în propria sa intrare /Rotate, așa că citirea rotației stocate a unei pagini și introducerea aceleiași valori înapoi în apelul de randare rotește pagina de două ori. Aceeași greșeală identică apare în matematica de zoom-adaptare: dimensionarea unei miniaturi din lățimea și înălțimea nerorite ale paginii produce raportul de aspect greșit ori de câte ori /Rotate este 90 sau 270 de grade, pentru că bitmap-ul randat iese cu lățimea și înălțimea inversate

Eșecul este ușor de observat odată ce știți ce să căutați, și ușor de ratat până atunci. Un lot de facturi scanate sosește cu un amestec de originale portret și peisaj, cineva îndreaptă jumătate din ele cu o rotație de 90 de grade în Acrobat înainte de arhivare, iar banda de miniaturi dintr-un vizualizator Delphi construit pe PDFium randează acele pagini particulare pe o parte, cu susul în jos, sau înghesuite într-o casetă în formă pentru orientarea greșită. Nimic nu ridică o excepție. Nimic nu înregistrează o eroare. Pixelii sunt pur și simplu greșiți, și doar pentru subsetul de pagini pe care cineva le-a rotit ulterior — exact genul de bug care supraviețuiește unei treceri QA complete față de un PDF de test nerotit și apoi apare în producție la pagina 47 a unuia real

De ce rotește PDFium pagina de două ori?

PDFium aplică automat propria valoare /Rotate a unei pagini de fiecare dată când randează un bitmap, indiferent de ce este transmis randerer-ului. Parametrul rotate al FPDF_RenderPageBitmap, expus în PDFiumPas ca valorile TRotation ro0, ro90, ro180 și ro270 pe TPdf.RenderPage, TPdf.RenderTile și TPdf.RenderPageThumbnail, nu setează unghiul la care ar trebui să ajungă o pagină; parametrul rotate setează cât de multă rotație suplimentară să stratifice deasupra a orice specifică deja dicționarul de pagină, motiv pentru care fiecare din acele metode îl setează implicit la ro0

TPdf.PageRotation citește aceeași valoare /Rotate prin FPDFPage_GetRotation, iar codul aplicației are adesea nevoie de ea din motive care nu au nimic de-a face cu randarea, precum decizia cum să așeze o adnotare în spațiul paginii. Capcana este o singură linie: transmiterea PageRotation în argumentul Rotation al RenderPage, așteptând ca apelul să normalizeze pagina la verticală. O pagină deja salvată cu /Rotate 90 se afișează corect, rotită, în orice vizualizator conform, PDFium inclusiv; adăugați ro90 din nou peste asta, iar pagina se rotește la 180 de grade în loc de intenționatele 90, în timp ce o pagină fără nicio rotație deloc primește un sfert de tură nedorit fără niciun motiv

// 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, []);

Pentru ce este de fapt parametrul Rotation

Parametrul Rotation își câștigă locul în API pentru o sarcină cu adevărat diferită: adăugarea unei rotații doar-de-vizualizare care nu are nimic de-a face cu orientarea stocată a unei pagini, genul pe care un buton de bară de instrumente de rotire-vizualizare îl aplică fără a atinge fișierul de bază. TPdfView păstrează cele două concepte ca două proprietăți separate exact din acest motiv. TPdfView.PageRotation oglindește propriul /Rotate al paginii și, prin FPDFPage_SetRotation, poate scrie o valoare nouă înapoi în document; TPdfView.Rotation este o proprietate tranzitorie, doar-de-vizualizare, care este implicit ro0 și nu atinge niciodată fișierul. Citirea primei proprietăți și scrierea ei în a doua este întregul bug într-o singură propoziție

// 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;

De ce se rupe dimensionarea zoom-adaptării la fel?

Dimensionarea zoom-adaptării se rupe dintr-un motiv de imagine în oglindă: calculul pornește de la perechea greșită de numere, nu de la unghiul greșit. Un mod tipic de dimensionare a unei casete de miniatură cere PDFium lățimea și înălțimea unei pagini, compară acel raport de aspect cu caseta disponibilă și calculează cel mai mare dreptunghi care se încadrează în interiorul ei — ceea ce funcționează curat pentru o pagină nerotită. Același calcul eșuează silențios pentru o pagină /Rotate 90 sau /Rotate 270 atunci când lățimea și înălțimea provin dintr-un apel care raportează dimensiunea intrinsecă, nerotită, a paginii: o pagină portret A4 care poartă /Rotate 90 tot raportează aproximativ 595 pe 842 de puncte, chiar dacă PDFium o randează, corect, la aproximativ 842 pe 595 odată ce rotația se aplică, iar o casetă de adaptare calculată din perechea nerotită ajunge complet în forma greșită de orientare

FPDF_GetPageSizeByIndex este un exemplu concret de apel care raportează acea dimensiune intrinsecă, nerotită, prin design, ceea ce îl face convenabil pentru scanarea dimensiunilor de pagină fără a încărca fiecare pagină și riscant pentru matematica de zoom-adaptare care uită să țină cont de ea. Soluția rezultă direct din numirea problemei: verificați rotația paginii înainte de a face aritmetica de adaptare, inversați lățimea și înălțimea ori de câte ori acea rotație este 90 sau 270 de grade, calculați caseta de adaptare din perechea inversată, și tot transmiteți ro0 la apelul de randare efectiv, pentru că PDFium rămâne cel care aplică rotația reală

Obținerea miniaturilor corecte fără a reinventa matematica de adaptare

TPdf.RenderPageThumbnail deja poartă această corecție, așa că cea mai scurtă cale către o miniatură corectă este apelarea ei, în loc de a reasambla logica de adaptare-și-rotire manual. Dat un index de pagină bazat pe 1 și o lățime și înălțime maximă, RenderPageThumbnail calculează o casetă de adaptare, o corectează pentru un /Rotate de 90 sau 270 intern, și returnează un bitmap deținut de apelant fără a deranja pagina curentă a documentului sau a declanșa un eveniment OnPageChange — ceea ce contează pentru o bandă de miniaturi construită alături de un vizualizator viu pe aceeași instanță 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;

Helper-ul FitBox merită păstrat oricum, pentru că RenderPageThumbnail acoperă doar cazul cu un singur bitmap. Un grid de miniaturi personalizat, o bandă de previzualizare tipărire, sau un dialog de selectare a paginii care așează mai multe pagini în casete independente are nevoie de aceeași matematică de adaptare conștientă de rotație, fără a dori neapărat un bitmap nou pentru fiecare piesă, iar propriile moduri de zoom fit-page și fit-width ale TPdfView se bazează intern pe aceeași idee identică, alegând între lățimea și înălțimea unei pagini pentru calculul raportului de zoom pe baza rotației curente a vizualizării, înainte de a-l compara cu zona de client disponibilă. Dacă performanța de zoom și derulare în acel tip de vizualizator este următoarea problemă de pe listă, articolul complementar despre cache-ul de randare și zoom-ul fluid într-un vizualizator Delphi bazat pe PDFium preia exact de unde lasă dimensionarea corectă

Depistarea unei duble rotații înainte ca un client să o facă

O dublă rotație are o semnătură vizuală fiabilă: o pagină care a fost rotită cu 90 de grade la intrare iese arătând rotită cu 180 relativ la restul documentului, nu 90, pentru că ro90-ul suplimentar s-a stratificat deasupra propriului ro90 al paginii, în loc să îl înlocuiască. Un fixture de test construit doar din pagini /Rotate 0 nu va prinde niciodată asta, întrucât adăugarea ro0 la ro0 este tot ro0, iar bug-ul rămâne invizibil; un fixture are nevoie de cel puțin o pagină salvată cu /Rotate 90 și una cu /Rotate 270 înainte ca o cale de cod de miniatură sau zoom-adaptare să poată fi de încredere

Pipeline-ul de bază pagină-la-bitmap acoperit în randarea paginilor PDF în JPEG cu componenta PDFium deja randează paginile rotite corect fără niciun cod de caz special, tocmai pentru că lasă Rotation la implicitul său ro0 și permite PDFium să aplice /Rotate de la sine. Bug-ul de dublă rotație apare doar odată ce codul aplicației începe să citească PageRotation înapoi și să îl introducă undeva unde nu îi este locul

Apelurile de randare conștiente de rotație și dimensionarea miniaturilor descrise aici fac parte din componenta PDFium pentru Delphi și C++Builder, alături de restul API-urilor de randare, vizualizare și extragere de text construite pe aceleași clase TPdf și TPdfView