Teknisk artikel

PDFium dobbelt-rotation og fit-zoom-fejl i Delphi

PDFium-komponentens FPDF_RenderPageBitmap-funktion accepterer et rotate-argument, PDFium altid lægger oven på, hvilken rotation siden allerede bærer i sin egen /Rotate-post, så at læse en sides gemte rotation og fodre den samme værdi tilbage ind i gengivelseskaldet roterer siden to gange. Den identiske fejl viser sig i fit-zoom-matematikken: at dimensionere et miniaturebillede fra sidens ikke-roterede bredde og højde producerer det forkerte billedformat, hver gang /Rotate er 90 eller 270 grader, fordi den gengivne bitmap kommer ud med bredde og højde byttet om

Fejlen er let at få øje på, når man ved, hvad man skal kigge efter, og let at overse indtil da. En batch af scannede fakturaer ankommer med en blanding af portræt- og landskabs-originaler, nogen retter halvdelen af dem ud med en 90-graders-rotation i Acrobat før arkivering, og miniaturebillede-stribens i en Delphi-fremviser bygget på PDFium gengiver de sider på siden, på hovedet, eller klemt ind i en boks formet til den forkerte orientering. Intet kaster en undtagelse. Intet logger en fejl. Pixels er simpelthen forkerte, og kun for den delmængde af sider, nogen roterede efterfølgende — præcis den slags bug, der overlever en fuld QA-gennemgang mod en ikke-roteret test-PDF og derefter dukker op i produktion på side 47 af en rigtig en

Hvorfor roterer PDFium siden to gange?

PDFium anvender en sides egen /Rotate-værdi automatisk hver gang, den gengiver en bitmap, uanset hvad der sendes til rendereren. FPDF_RenderPageBitmap rotate-parameteren, eksponeret i PDFiumPas som TRotation-værdierne ro0, ro90, ro180 og ro270 på TPdf.RenderPage, TPdf.RenderTile og TPdf.RenderPageThumbnail, sætter ikke den vinkel, en side skal ende ved; rotate-parameteren sætter, hvor meget ekstra rotation der skal lægges oven på, hvad end side-ordbogen allerede angiver, hvilket er grunden til, at hver af de metoder som standard sætter den til ro0

TPdf.PageRotation læser den samme /Rotate-værdi gennem FPDFPage_GetRotation, og applikationskode har ofte brug for den af grunde, der intet har at gøre med gengivelse, såsom at beslutte, hvordan en annotation skal layoutes i sidekoordinater. Fælden er én enkelt linje: at sende PageRotation ind i Rotation-argumentet for RenderPage, i forventning om at kaldet normaliserer siden til opret. En side allerede gemt med /Rotate 90 vises korrekt, roteret, i enhver konform fremviser, PDFium inkluderet; tilføj ro90 igen oven på det, og siden svinger til 180 grader i stedet for de tiltænkte 90, mens en side uden nogen rotation overhovedet får en uønsket kvart omdrejning uden grund

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

Hvad Rotation-parameteren rent faktisk er til

Rotation-parameteren fortjener sin plads i API'et til en genuint anden opgave: at tilføje en kun-visnings-rotation, der intet har at gøre med en sides gemte orientering, den slags en roter-visning-værktøjslinjeknap anvender uden at røre den underliggende fil. TPdfView holder de to koncepter som to separate egenskaber netop af den grund. TPdfView.PageRotation spejler sidens egen /Rotate og kan, gennem FPDFPage_SetRotation, skrive en ny værdi tilbage ind i dokumentet; TPdfView.Rotation er en flygtig, kun-visnings-egenskab, der som standard er ro0 og aldrig rører filen. At læse den første egenskab og skrive den ind i den anden er hele bugen i én sætning

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

Hvorfor går fit-zoom-dimensionering i stykker på samme måde?

Fit-zoom-dimensionering går i stykker af en spejlbilledgrund: beregningen starter fra det forkerte talpar frem for den forkerte vinkel. En typisk måde at dimensionere en miniaturebillede-boks på beder PDFium om en sides bredde og højde, sammenligner det billedformat med den tilgængelige boks, og beregner det størst mulige rektangel, der passer indeni — hvilket fungerer rent for en ikke-roteret side. Den samme beregning fejler i stilhed for en /Rotate 90- eller /Rotate 270-side, når bredden og højden kom fra et kald, der rapporterer sidens iboende, ikke-roterede størrelse: en A4-portræt-side, der bærer /Rotate 90, rapporterer stadig omtrent 595 gange 842 punkter, selvom PDFium gengiver den, korrekt, ved omtrent 842 gange 595, når rotationen træder i kraft, og en fit-boks beregnet fra det ikke-roterede par ender med at være formet til den helt forkerte orientering

FPDF_GetPageSizeByIndex er ét konkret eksempel på et kald, der rapporterer den iboende, ikke-roterede størrelse med vilje, hvilket gør det bekvemt til at skanne side-dimensioner uden at indlæse hver side, og risikabelt for fit-zoom-matematik, der glemmer at tage højde for det. Fixen følger direkte af at navngive problemet: tjek sidens rotation, før man udfører fit-aritmetikken, byt bredde og højde, når den rotation er 90 eller 270 grader, beregn fit-boksen fra det byttede par, og send stadig ro0 til det faktiske gengivelseskald, fordi PDFium forbliver den, der anvender den reelle rotation

At få miniaturebilleder rigtige uden at genopfinde fit-matematikken

TPdf.RenderPageThumbnail bærer allerede denne fix, så den korteste vej til et korrekt miniaturebillede er at kalde den frem for at samle fit-og-roter-logikken i hånden. Givet et 1-baseret sideindeks og en maksimal bredde og højde beregner RenderPageThumbnail en fit-boks, retter den for en /Rotate på 90 eller 270 internt, og returnerer en kalder-ejet bitmap uden at forstyrre dokumentets aktuelle side eller udløse en OnPageChange-hændelse — hvilket betyder noget for en miniaturebillede-stribe bygget ved siden af en levende fremviser på den samme TPdf-instans

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

FitBox-hjælperen er værd at beholde alligevel, fordi RenderPageThumbnail kun dækker enkelt-bitmap-tilfældet. Et brugerdefineret miniaturebillede-grid, en print-forhåndsvisnings-stribe, eller en side-vælger-dialog, der layouter flere sider mod uafhængige bokse, har brug for den samme rotations-bevidste fit-matematik uden nødvendigvis at ville have en frisk bitmap for hver flise, og TPdfViews egne fit-side- og fit-bredde-zoom-tilstande læner sig på den identiske idé internt, og vælger mellem en sides bredde og højde til zoom-forholds-beregningen baseret på visningens aktuelle rotation, før den sammenligner det mod det tilgængelige klient-område. Hvis zoom- og scroll-ydeevne i den slags fremviser er det næste problem på listen, tager følgestykket om gengivelses-caching og jævn zoom i en PDFium-baseret Delphi-fremviser op, netop hvor korrekt dimensionering slipper

At få øje på en dobbelt-rotation, før en kunde gør

En dobbelt-rotation har ét pålideligt visuelt signatur: en side, der blev roteret 90 grader på vej ind, kommer ud og ser roteret 180 ud i forhold til resten af dokumentet, ikke 90, fordi den ekstra ro90 stablede oven på sidens egen ro90 i stedet for at erstatte den. En testfixture bygget kun fra /Rotate 0-sider vil aldrig fange dette, da at tilføje ro0 til ro0 stadig er ro0, og bugen forbliver usynlig; en fixture har brug for mindst én side gemt med /Rotate 90 og én med /Rotate 270, før en miniaturebillede- eller fit-zoom-kodevej kan betros

Den grundlæggende side-til-bitmap-pipeline dækket i gengivelse af PDF-sider til JPEG med PDFium-komponenten gengiver allerede roterede sider korrekt uden nogen specialtilfælde-kode, netop fordi den lader Rotation stå på sin ro0-standard og lader PDFium anvende /Rotate på egen hånd. Dobbelt-rotations-bugen dukker først op, når applikationskode begynder at læse PageRotation tilbage ud og fodre den et sted, den ikke hører hjemme

De rotations-bevidste gengivelseskald og miniaturebillede-dimensionering beskrevet her er en del af PDFium-komponenten til Delphi og C++Builder, sammen med resten af gengivelses-, visnings- og tekstudtræknings-API'erne bygget på de samme TPdf- og TPdfView-klasser