Technický článek

Dvojité otočení a chyby fit-zoomu v PDFium pro Delphi

Funkce FPDF_RenderPageBitmap v PDFium Component přijímá argument rotate, který PDFium vždy přidá navrch jakéhokoli otočení, které stránka už nese ve svém vlastním záznamu /Rotate, takže přečtení uloženého otočení stránky a jeho zpětné podání do volání vykreslení stránku otočí dvakrát. Identická chyba se objevuje v matematice fit-zoomu: velikost miniatury odvozená z neotočené šířky a výšky stránky vyprodukuje špatný poměr stran kdykoli, když je /Rotate 90 nebo 270 stupňů, protože vykreslená bitmapa vyjde s prohozenou šířkou a výškou

Selhání je snadné odhalit, jakmile víte, co hledat, a snadné přehlédnout do té doby. Dávka naskenovaných faktur dorazí se směsí portrétních a krajinových originálů, někdo polovinu z nich před archivací narovná otočením o 90 stupňů v Acrobatu, a pás miniatur v prohlížeči Delphi postaveném na PDFium tyto konkrétní stránky vykreslí bokem, vzhůru nohama, nebo vmáčknuté do rámečku tvarovaného pro špatnou orientaci. Nic nevyvolá výjimku. Nic nezaloguje chybu. Pixely jsou prostě špatně, a jen pro podmnožinu stránek, které někdo dodatečně otočil — přesně ten druh chyby, který přežije plný průchod QA proti neotočenému testovacímu PDF a pak se objeví v produkci na stránce 47 skutečného

Proč PDFium otočí stránku dvakrát?

PDFium aplikuje vlastní hodnotu /Rotate stránky automaticky při každém vykreslení bitmapy, bez ohledu na to, co se předá vykreslovači. Parametr rotate FPDF_RenderPageBitmap, vystavený v PDFiumPas jako hodnoty TRotation ro0, ro90, ro180 a ro270 na TPdf.RenderPage, TPdf.RenderTile a TPdf.RenderPageThumbnail, nenastavuje úhel, na kterém by stránka měla skončit; parametr rotate nastavuje, kolik dodatečné rotace navrstvit navrch toho, co slovník stránky už specifikuje, což je důvod, proč jej všechny tyto metody standardně nastavují na ro0

TPdf.PageRotation čte tutéž hodnotu /Rotate přes FPDFPage_GetRotation a kód aplikace ji často potřebuje z důvodů, které nemají nic společného s vykreslováním, jako rozhodnutí, jak rozvrhnout anotaci v prostoru stránky. Past je jeden jediný řádek: předání PageRotation do argumentu Rotation metody RenderPage v očekávání, že volání stránku znormalizuje na svislou. Stránka už uložená s /Rotate 90 se v jakémkoli konformním prohlížeči, PDFium nevyjímaje, zobrazí správně, otočená; přidejte navrch ro90 znovu a stránka se stočí na 180 stupňů místo zamýšlených 90, zatímco stránka bez jakéhokoli otočení dostane nechtěnou čtvrtinovou otočku bezdůvodně

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

Na co je parametr Rotation vlastně určen

Parametr Rotation si vydobyl místo v API pro skutečně odlišnou práci: přidání rotace jen pro pohled, která nemá nic společného s uloženou orientací stránky, druh, jaký aplikuje tlačítko panelu nástrojů pro otočení pohledu, aniž by se dotklo podkladového souboru. TPdfView proto drží tyto dva koncepty jako dvě samostatné vlastnosti. TPdfView.PageRotation zrcadlí vlastní /Rotate stránky a přes FPDFPage_SetRotation dokáže zapsat novou hodnotu zpátky do dokumentu; TPdfView.Rotation je přechodná vlastnost jen pro pohled, jejíž výchozí hodnota je ro0 a která se souboru nikdy nedotkne. Přečtení první vlastnosti a její zápis do druhé je celá tato chyba v jedné větě

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

Proč se velikost fit-zoomu rozbije stejným způsobem?

Velikost fit-zoomu se rozbije ze zrcadlově obráceného důvodu: výpočet začíná od špatné dvojice čísel místo od špatného úhlu. Typický způsob, jak stanovit velikost rámečku miniatury, požádá PDFium o šířku a výšku stránky, porovná tento poměr stran s dostupným rámečkem a spočítá největší obdélník, který se do něj vejde — což čistě funguje pro neotočenou stránku. Stejný výpočet tiše selže u stránky /Rotate 90 nebo /Rotate 270, když šířka a výška pocházejí z volání, které hlásí vnitřní, neotočenou velikost stránky: stránka A4 na výšku nesoucí /Rotate 90 stále hlásí zhruba 595 na 842 bodů, přestože ji PDFium vykreslí, správně, na zhruba 842 na 595, jakmile otočení nabude účinnosti, a rámeček pro přizpůsobení spočítaný z neotočené dvojice skončí tvarovaný pro úplně špatnou orientaci

FPDF_GetPageSizeByIndex je jeden konkrétní příklad volání, které záměrně hlásí tuto vnitřní, neotočenou velikost, což jej dělá pohodlným pro skenování rozměrů stránek bez načtení každé stránky a riskantním pro matematiku fit-zoomu, která zapomene s tím počítat. Oprava přímo plyne z pojmenování problému: zkontrolujte otočení stránky dřív, než uděláte aritmetiku přizpůsobení, prohoďte šířku a výšku, kdykoli je toto otočení 90 nebo 270 stupňů, spočítejte rámeček přizpůsobení z prohozené dvojice, a pořád předejte ro0 do skutečného volání vykreslení, protože PDFium zůstává tím, kdo aplikuje skutečné otočení

Správné miniatury bez znovuvynalézání matematiky přizpůsobení

TPdf.RenderPageThumbnail už tuto opravu nese, takže nejkratší cesta ke správné miniatuře je zavolat ji místo ručního sestavování logiky přizpůsobení a otočení. Vzhledem k indexu stránky počítanému od 1 a maximální šířce a výšce RenderPageThumbnail spočítá rámeček přizpůsobení, interně jej opraví pro /Rotate 90 nebo 270, a vrátí bitmapu vlastněnou volajícím, aniž by narušila aktuální stránku dokumentu nebo vyvolala událost OnPageChange — což je důležité pro pás miniatur postavený vedle živého prohlížeče na stejné instanci 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;

Pomocnou funkci FitBox se vyplatí ponechat u sebe tak jako tak, protože RenderPageThumbnail pokrývá jen případ jedné bitmapy. Vlastní mřížka miniatur, pás náhledu tisku, nebo dialog výběru stránky, který rozvrhuje několik stránek proti nezávislým rámečkům, potřebuje stejnou matematiku přizpůsobení vědomou si otočení, aniž by nutně chtěl čerstvou bitmapu pro každou dlaždici, a vlastní režimy zoomu přizpůsobit-stránce a přizpůsobit-šířce v TPdfView se interně opírají o identickou myšlenku, vybírají mezi šířkou a výškou stránky pro výpočet poměru zoomu na základě aktuálního otočení pohledu ještě předtím, než jej porovnají s dostupnou plochou klienta. Pokud je výkon zoomu a scrollování v takovém prohlížeči dalším problémem na seznamu, doprovodný text o cachování vykreslování a plynulém zoomu v prohlížeči Delphi postaveném na PDFium navazuje přesně tam, kde správná velikost končí

Odhalení dvojitého otočení dřív, než jej odhalí zákazník

Dvojité otočení má jeden spolehlivý vizuální podpis: stránka, která byla na vstupu otočena o 90 stupňů, vyjde vypadající otočená o 180 relativně vůči zbytku dokumentu, ne o 90, protože se extra ro90 navrstvilo navrch vlastního ro90 stránky místo toho, aby jej nahradilo. Testovací fixtura postavená jen ze stránek /Rotate 0 to nikdy neodhalí, protože přidání ro0 k ro0 je pořád ro0 a chyba zůstává neviditelná; fixtura potřebuje alespoň jednu stránku uloženou s /Rotate 90 a jednu s /Rotate 270, než lze cestě kódu pro miniaturu nebo fit-zoom věřit

Základní pipeline stránka-na-bitmapu popsaná v vykreslování stránek PDF do JPEG s PDFium Component už otočené stránky vykresluje správně bez jakéhokoli speciálního kódu, přesně proto, že ponechává Rotation na výchozím ro0 a nechává PDFium aplikovat /Rotate samo. Chyba dvojitého otočení se objeví teprve tehdy, když kód aplikace začne PageRotation zpětně číst a podávat jej někam, kam nepatří

Volání vykreslení vědomá si otočení a stanovení velikosti miniatur popsané zde jsou součástí komponenty PDFium pro Delphi a C++Builder, spolu se zbytkem API pro vykreslování, prohlížení a extrakci textu postaveného na stejných třídách TPdf a TPdfView