Műszaki cikk

Megszakítható progresszív PDF-megjelenítés Delphiben (PDFium)

A legtöbb PDF-oldal raszterizálása néhány ezredmásodpercet vesz igénybe, és sosem gondol rá. Aztán egy felhasználó megnyit egy A1-es mérnöki rajzot, egy olyan oldalt, amely tele van több tízezer vektoros vonallal, vagy egy posztert, amely zsúfolásig tele van átlátszósági csoportokkal és lágy maszkokkal, és az az egyetlen hívás, amely megfesti, két-három másodpercet is igénybe vehet. Ha ez a hívás a felhasználói felület (UI) szálán fut, az ablak nem frissül tovább, a címsor kiszürkül, és az operációs rendszer felajánlja az alkalmazás bezárását. A munka jogos. Az oldalnak valóban ennyi időre van szüksége. A hiba az, hogy a megjelenítés egyetlen oszthatatlan blokkoló hívás, ahonnan nem lehet levegőhöz jutni, és nincs mód a leállításra

Ez a cikk pontosan erről a két probléma egyikéről szól: egy hosszú, egyoldalas renderelés megszakítása a felhasználói felület lefagyasztása nélkül. A felhasználó a következő oldalra kattintott, vagy nagyított, esetleg bezárta a dokumentumot, és a folyamatban lévő renderelés immár kárba veszett munka, aminek inkább a következő adandó alkalommal be kell fejeződnie, minthogy a végéig lefusson. A görgetés és nagyítás simítása a már raszterizált tartalom gyorsítótárazásával egy külön kérdés, amely saját kialakítással rendelkezik, és ezt a cikk végén linkelt kísérő cikk tárgyalja. Itt az egyetlen kérdés az, hogyan érhető el, hogy egy progresszív renderelés gyorsan és tisztán reagáljon a megszakítási kérésre

A PDFium már meglévő progresszív megjelenítési API-ja

A PDFium előre számított a probléma lefagyasztással kapcsolatos felére. Az egyszeri FPDF_RenderPageBitmap mellett egy progresszív változatot is kínál, amely az oldalt munkadarabokra bontja. Az FPDF_RenderPageBitmap_Start függvényt egyszer hívja meg, hogy beállítsa a megjelenítést egy célbittérképre, majd többször hívja meg az FPDF_RenderPage_Continue függvényt. Minden egyes Continue egy korlátozott szeletet raszterizál, és visszaad egy állapotot. Az FPDF_RENDER_TOBECONTINUED azt jelenti, hogy van még tennivaló, az FPDF_RENDER_DONE azt jelenti, hogy az oldal befejeződött, az FPDF_RENDER_FAILED pedig azt, hogy hibával leállt. Amikor a ciklus véget ér, hívja meg az FPDF_RenderPage_Close függvényt, hogy felszabadítsa az oldalankénti progresszív állapotot. Mivel a vezérlés a szeletek között visszatér a kódhoz, továbbíthatja az üzeneteket, frissítheti a folyamatjelzőt, vagy ellenőrizheti, hogy a munkára továbbra is szükség van-e

Az a mechanizmus, amelyet a PDFium a megszakítás eldöntésére biztosít, egy IFSDK_PAUSE nevű visszahívási (callback) struktúra. Ezt adja át a Start-nak és minden Continue-nak. Minden egyes darab után a PDFium meghívja a NeedToPauseNow függvénymutatóját, és ha ez nem nulla értéket ad vissza, az aktuális Continue korábban leáll, és visszaadja a vezérlést az FPDF_RENDER_TOBECONTINUED értékkel. A struktúra hordoz egy version mezőt is, amelyet 1-re kell állítani, és egy szabad formátumú user mutatót, amelyet a PDFium soha nem érint meg, és érintetlenül enged át. Ez az érintetlen mutató a következő kialakítás teljes sarkalatos pontja

A szüneteltetés átalakítása megszakítássá

A NeedToPauseNow eredeti célja az időszeletelés. Adjon vissza nem nullát, amikor a keret-keretkeret (frame budget) elfogyott, adjon vissza nullát a renderelés folytatásához, és a PDFium szünetet tart, hogy valami mást is elvégezhessen, mielőtt folytatná ugyanazt a renderelést. A PDFium komponens (PDFium Component) ugyanazt a jelet egy másik igére használja fel újra. Ahelyett, hogy arra válaszolna: „tartsak-e szünetet, és hagyjam, hogy folytassa”, a visszahívás arra válaszol: „meg lett-e szakítva ez a munka”. A kettő tisztán leképezhető egymásra amiatt, amit a ciklus tesz, amikor meglátja a jelzőt (flag). Egy valódi szünet egy későbbi Continue hívást vár; egy megszakítás nem. Amint a hívó ciklus észleli, hogy a token meg lett szakítva, lezárja a renderelési kontextust, és soha többé nem hívja meg a Continue-t, így ugyanaz a nem nulla visszatérés, amelyet a PDFium úgy olvas, mint „állítsd le ezt a darabot”, valójában „állj le végleg” jelzéssé válik

A megszakítást egy IPdfCancellationToken nevű interfész fejezi ki, amelynek IsCancelled tulajdonsága hamisról igazra vált, amikor a program egy másik része a megjelenítés leállítását kéri. A híd e Pascal interfész és a PDFium C visszahívása között egyetlen mutató. A token interfész hivatkozása az IFSDK_PAUSE.user-be van írva, és egy statikus cdecl visszahívás kiolvassa és lekérdezi azt. Ez annak a klasszikus problémája, amikor egy C könyvtár visszahívást indít a Pascalba: a visszahívásnak egy sima, C hívási konvencióval rendelkező függvénynek kell lennie, nem pedig egy metódusnak, mivel a PDFium egy puszta függvénymutatót tárol és hív meg, amely semmit sem tud a Pascal objektumokról vagy a Self-ről

type
  TPdfProgressivePause = record
    Pause: IFSDK_PAUSE;            // PDFium reads this; .user holds the token
    Token: IPdfCancellationToken; // strong ref keeps the token alive
  end;

function ProgressivePauseCallback(pThis: PIFSDK_PAUSE): FPDF_BOOL; cdecl;
var
  Token: IPdfCancellationToken;
begin
  Result := 0;
  if (pThis = nil) or (pThis^.user = nil) then
    Exit;
  Token := IPdfCancellationToken(pThis^.user);
  if Token.IsCancelled then
    Result := 1; // non-zero: PDFium stops this chunk
end;

A visszahívás úgy állítja vissza a tokent, hogy a pThis^.user-t visszakasztolja (cast) az interfész típusára, és kiolvassa az IsCancelled értéket. Semmi sem foglal le, nem zárol és nem blokkol benne semmit, ami azért fontos, mert a PDFium a megjelenítő szálon (rendering thread) hívja meg minden egyes darab után, és az itt elvégzett munka hozzáadódik magának a megjelenítésnek a költségéhez. A nil struktúra vagy a nil user mező elleni védelem azt jelenti, hogy ugyanazt a függvényt biztonságosan telepíthetjük még egy olyan megjelenítésnél is, amely soha nem kapott valódi tokent

A token életben tartása a ciklus során

Az interfész-mutatók nyers Pointer-en keresztüli kasztolása, majd vissza, az a hely, ahol az élettartam (lifetime) hibák születnek. Egy IInterface a Delphiben referenciaszámlált, és a szám csak akkor mozdul el, ha a fordító látja egy interfész-típusú változó hozzárendelését. A token kizárólag puszta mutatóként történő tárolása az IFSDK_PAUSE.user belsejében teljesen elrejtené azt a referenciaszámláló elől. Ha az erre a tokenre vonatkozó egyetlen másik hivatkozás kikerülne a hatókörből (scope), miközben a Continue ciklus még futna, az objektum felszabadulna a visszahívás (callback) alatt, és a következő darab egy lógó mutatót (dangling pointer) próbálna dereferálni

Ezért a leíró (descriptor) egy rekord, amely két dolgot tárol, nem pedig egyet. A Pause mező az a struktúra, amelyet a PDFium olvas. A Token mező egy valódi, interfész típusú hivatkozás, amelyet a fordító (compiler) számol, és semmi más okból nem létezik, csak azért, hogy a tokent a memóriában rögzítse addig, amíg a rekord él. A rekord egy helyi (local) változó a renderelő rutin veremén (stack), így az a ciklus teljes időtartama alatt érvényes marad, és csak a rutin kilépésekor semmisül meg. A user-ben lévő csupasz mutató (bare pointer) és a Token-ben lévő számolt hivatkozás ugyanazt az objektumot nevezi meg; az egyik az, amit a PDFium olvasni tud, a másik az, ami megakadályozza, hogy ezt az objektumot összegyűjtsék (garbage collect)

var
  Pause: TPdfProgressivePause;
  EffectiveToken: IPdfCancellationToken;
begin
  // ... choose EffectiveToken ...

  // Strong ref first, then publish the same object to PDFium via .user.
  Pause.Token := EffectiveToken;
  Pause.Pause.version := 1;
  Pause.Pause.NeedToPauseNow := ProgressivePauseCallback;
  Pause.Pause.user := Pointer(EffectiveToken);

A renderelési kontextus bezárása függetlenül attól, hogyan végződik a ciklus

Minden egyes FPDF_RenderPageBitmap_Start hívás progresszív állapotot foglal le, amelyet a PDFium társít az oldalhoz, és ezt az állapotot csak az FPDF_RenderPage_Close szabadítja fel. A meghajtó ciklusból háromféleképpen lehet kilépni. Az oldal befejeződik, és az utolsó állapot FPDF_RENDER_DONE. A token kiold (trips), és a ciklus korán kilép, megszakítást jelentve. Valami meghibásodik, és az állapot FPDF_RENDER_FAILED. Mindhárom esetben meg kell hívni a Close függvényt, és a megszakítási útvonalat a legkönnyebb elrontani, mivel a „látom a megszakítást, kilépek” természetes formája hajlamos átugrani a takarítást a kilépés felé vezető úton. Ha a Close hívását kihagyjuk, az oldalankénti állapot szivárgását okozza, és egy olyan megjelenítő (viewer), amely lehetővé teszi a felhasználó számára a megjelenítések egymás utáni megszakítását, ezt a szivárgást minden megszakított oldalon felhalmozná

Status := FPDF_RenderPageBitmap_Start(PdfBmp, FPage, Left, Top,
  Width, Height, Ord(Rotation), EncodeRenderOptions(Options), Pause.Pause);
try
  while Status = FPDF_RENDER_TOBECONTINUED do
  begin
    if EffectiveToken.IsCancelled then
    begin
      Result := prsCancelled;
      Exit;
    end;
    Status := FPDF_RenderPage_Continue(FPage, Pause.Pause);
  end;

  if EffectiveToken.IsCancelled then
    Result := prsCancelled
  else if Status = FPDF_RENDER_DONE then
    Result := prsDone
  else
    Result := prsFailed;
finally
  // Frees the progressive state Start allocated; mandatory on every path.
  FPDF_RenderPage_Close(FPage);
  FPDFBitmap_Destroy(PdfBmp);
end;

A ciklus minden egyes Continue hívás előtt ellenőrzi a tokent, valamint a benne lévő visszahívásra is támaszkodik. A visszahívás lerövidíti az aktuális darabot; a cikluson belüli ellenőrzés megakadályozza a következő darab elindulását. Ezek együttesen körülbelül egy darab (chunk) időtartamára korlátozzák, hogy mennyi időbe telik a megszakítás érvényesülése

Három kimenetel, és mit tartalmaz a bittérkép megszakítás után

A nyilvános belépési pont a TPdf.RenderPageProgressive, amely egy TPdfProgressiveStatus értéket ad vissza, amely lehet prsDone, prsCancelled vagy prsFailed. Az értékek a PDFium FPDF_RENDER_* állandóit tükrözik Pascal stílusban, de a megszakítás esetét elsődleges eredményként foglalják magukban, nem pedig hibaként

Az a pont, ami meglepi az embereket, az, hogy mit tartalmaz a célbittérkép a prsCancelled után. Nem üres. A PDFium progresszíven renderel ugyanabba a bittérképbe darabról darabra, így amikor a megszakítás leállítja a ciklust, a bittérkép mindazt tartalmazza, ami addig a pillanatig le lett festve, ami egy részleges kép (partial image): néhány sáv kész, a többi pedig még a kitöltőszínt (fill colour) mutatja. Hogy ez a részleges eredmény hasznos-e, a hívótól függ. Egy olyan megjelenítő, amely éppen eldobni készül a bittérképet, mert a felhasználó máshová navigált, egyszerűen figyelmen kívül hagyhatja. Egy megjelenítő, amely alacsony költségű előnézetet szeretne mutatni, megtarthatja azt. Amit nem szabad tennie, az a feltételezés, hogy a prsCancelled üres vagy meghatározatlan bittérképet jelent; ez egy befejezetlen megjelenítés hű pillanatképe

var
  Bmp: TBitmap;
  Token: IPdfCancellationToken;
  Status: TPdfProgressiveStatus;
begin
  Bmp := TBitmap.Create;
  try
    // Token starts un-cancelled; flip Token.IsCancelled from elsewhere
    // (a UI action, a navigation event) to abort the render in flight.
    Status := Pdf.RenderPageProgressive(Bmp, 0, 0, PageW, PageH, Token);
    case Status of
      prsDone:      Image1.Picture.Assign(Bmp);  // fully rendered
      prsCancelled: ;                            // partial bitmap, usually discarded
      prsFailed:    ShowMessage('Render failed');
    end;
  finally
    Bmp.Free;
  end;
end;

A nil token és az elágazásmentes visszahívási útvonal

A megszakítás opcionális (opt-in). Egy hívónak, aki csak a message-pumping előnye miatt akar progresszív renderelést használni, és nem áll szándékában megszakítani, képesnek kell lennie arra, hogy nil értéket adjon át tokenként. Ennek támogatásának naiv módja az, hogy a visszahívás (callback) és a ciklus során „ha tokent biztosítottak” ellenőrzéseket szórunk szét, ami minden egyes darabnál egy elágazást jelent, és egy olyan visszahívást eredményez, amelynek mind a valódi tokent, mind annak hiányát kezelnie kell

A megvalósítás elkerüli ezt egy egypéldányos osztály (singleton) helyettesítésével, amikor a hívó semmit sem ad át. Egy nil tokent felcserél egy PdfNoCancellationToken-re, amely egy olyan interfész, melynek az IsCancelled értéke mindig hamis. Ettől a ponttól kezdve a visszahívásnak és a ciklusnak minden esetben van egy lekérdezhető tokenje, így egyiknek sincs szüksége nil ellenőrzésre, és egyik sem igényel speciális útvonalat. A soha-meg-nem-szakító token egyszerűen mindig hamisat (false) válaszol, a visszahívás mindig nullát (zero) ad vissza, és a megjelenítés pontosan úgy lefut a végéig, ahogyan egy nem megszakítható tenné. Az opcionális viselkedést egy olyan tokenként modellezi, amely soha nem lép életbe (never fires), nem pedig a token hiányaként, ezáltal a leggyakrabban végrehajtott útvonalat (hot path) egységesen tartva

// nil -> never-cancel singleton, so the callback path is identical
// whether or not the caller opted into cancellation.
if AToken <> nil then
  EffectiveToken := AToken
else
  EffectiveToken := PdfNoCancellationToken;

Az ebből adódó forma kicsi, és érdemes újra hangsúlyozni, mert ez az újrahasználható rész. Egy visszahívást (callback) támogató C könyvtár pontosan egy csatornát ad, amelyen keresztül állapotot lehet átadni ebbe a visszahívásba: az átlátszatlan (opaque) user mutatót. Helyezzen egy számolt Pascal interfész hivatkozást e mutató mögé, tartson életben egy második valódi hivatkozást a struktúra mellett, hogy az objektumot ne lehessen összegyűjteni a hívás közben, és olvassa ki az interfészt egy statikus cdecl függvény belsejében. Zárja az egész meghajtó ciklust egy try blokkba, és szabadítsa fel a natív kontextust a finally részben. Ugyanez a sablon átvihető minden progresszív vagy visszahívás-vezérelt PDFium műveletre, ahol a Pascal kódnak meg kell tartania az élettartam feletti ellenőrzést, amíg a C egy mutatót tárol

A megszakítás csak az egyik fele a reszponzív megjelenítőnek (viewer). A másik fele az, hogy ne kelljen újra-renderelni a már megrajzolt oldalakat, és a nagyítás (zoom) és a görgetés (scroll) sima maradjon a gyorsítótárazott (cached) bittérképek kiszolgálásával, amivel a render gyorsítótárazásról (render caching) és a nagyítási teljesítményről (zoom performance) szóló cikkünkben foglalkozunk. Arról, hogyan illeszkedik a megszakítható renderelés egy teljes megjelenítőbe a navigáció, a kijelölés és a keresés mellett, olvassa el a funkciógazdag PDF megjelenítő építése a PDFium Komponenssel című cikkünket. Az itt leírt progresszív renderelés a Delphihez és Lazarushoz készült PDFium Component részeként érhető el, a betöltési, megjelenítési és űrlap API-k mellett, melyeket a blog egyéb részeiben tárgyaltunk